initial app
This commit is contained in:
commit
27c78e2ac4
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# Vercel (removed - using VPS deployment)
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
168
DEPLOYMENT.md
Normal file
168
DEPLOYMENT.md
Normal file
@ -0,0 +1,168 @@
|
||||
# Deployment Guide
|
||||
|
||||
## 🚀 Deploy to a VPS with Nginx (Reverse Proxy)
|
||||
|
||||
This project exports fully static assets in `out/`. You can serve them directly with Nginx.
|
||||
|
||||
### 1) Build static files
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
By default, the app is built for the root path `/`. If you want to serve it under a subpath (e.g. `/namecard`), build with:
|
||||
```bash
|
||||
BASE_PATH=/namecard NEXT_PUBLIC_BASE_URL=https://example.com/namecard npm run build
|
||||
```
|
||||
|
||||
### 2) Upload to server
|
||||
```bash
|
||||
scp -r out/ user@server:/var/www/namecard
|
||||
```
|
||||
|
||||
### 3) Nginx config (root path)
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name example.com; # your domain
|
||||
|
||||
root /var/www/namecard;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4) Nginx config (subpath, e.g. /namecard)
|
||||
Build with `BASE_PATH=/namecard` and then:
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name example.com;
|
||||
|
||||
location /namecard/ {
|
||||
alias /var/www/namecard/;
|
||||
try_files $uri $uri/ /namecard/index.html;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5) Reload Nginx
|
||||
```bash
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
## 🌐 Custom Domain Setup
|
||||
|
||||
1. **Configure DNS:**
|
||||
- Add A record pointing to your VPS IP address
|
||||
- Or CNAME if using a subdomain (e.g., `card.rooftop.my`)
|
||||
|
||||
2. **SSL Certificate:**
|
||||
- Use Let's Encrypt: `sudo certbot --nginx`
|
||||
- Auto-renewal for free SSL certificates
|
||||
|
||||
## 📱 PWA Configuration (Optional)
|
||||
|
||||
To enable "Add to Home Screen" functionality:
|
||||
|
||||
1. **Create manifest.json:**
|
||||
```json
|
||||
{
|
||||
"name": "Rooftop Energy Namecard",
|
||||
"short_name": "Rooftop",
|
||||
"description": "Digital namecard for Rooftop Energy staff",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0a0a0a",
|
||||
"theme_color": "#fcd913",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/logo.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
2. **Add to layout.tsx:**
|
||||
```tsx
|
||||
<head>
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
</head>
|
||||
```
|
||||
|
||||
## 🔧 Environment Variables
|
||||
|
||||
Set at build time (locally or in CI):
|
||||
|
||||
```
|
||||
# Base URL used for Open Graph/Twitter image URLs and absolute links
|
||||
NEXT_PUBLIC_BASE_URL=https://example.com
|
||||
|
||||
# Optional: if serving under a subpath, set both
|
||||
BASE_PATH=/namecard
|
||||
NEXT_PUBLIC_BASE_URL=https://example.com/namecard
|
||||
```
|
||||
|
||||
## 📊 Performance Monitoring
|
||||
|
||||
1. **Web Analytics:**
|
||||
- Use Google Analytics or similar service
|
||||
- Monitor Core Web Vitals with PageSpeed Insights
|
||||
|
||||
2. **Server Monitoring:**
|
||||
- Monitor server resources (CPU, memory, disk)
|
||||
- Set up alerts for high resource usage
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
### Build Errors
|
||||
|
||||
- **Node version:** Ensure Node.js 18+ is used
|
||||
- **Dependencies:** Run `npm install` locally first
|
||||
- **TypeScript errors:** Run `npm run type-check` locally
|
||||
|
||||
### Runtime Issues
|
||||
|
||||
- **Logo not loading:** Check `public/logo.png` exists
|
||||
- **Styling issues:** Verify Tailwind CSS is building correctly
|
||||
- **Query params not working:** Check URL encoding
|
||||
|
||||
### Performance Issues
|
||||
|
||||
- **Large bundle:** Check for unused dependencies
|
||||
- **Slow loading:** Optimize images and enable compression
|
||||
- **SEO issues:** Verify meta tags and Open Graph
|
||||
|
||||
## 📈 Post-Deployment Checklist
|
||||
|
||||
- [ ] Logo displays correctly
|
||||
- [ ] All CTAs work (Call, Email, WhatsApp, LinkedIn)
|
||||
- [ ] vCard download functions
|
||||
- [ ] Copy details works
|
||||
- [ ] Mobile responsive design
|
||||
- [ ] URL query overrides work
|
||||
- [ ] Performance scores meet targets
|
||||
- [ ] Analytics tracking (if enabled)
|
||||
- [ ] Custom domain configured (if applicable)
|
||||
|
||||
## 🔄 Updates & Maintenance
|
||||
|
||||
### Regular Updates
|
||||
|
||||
1. **Dependencies:** Run `npm audit` and update packages
|
||||
2. **Content:** Edit `src/config/staff.ts` for staff changes
|
||||
3. **Logo:** Replace `public/logo.png` when needed
|
||||
|
||||
### Deployment Updates
|
||||
|
||||
1. Build locally: `npm run build`
|
||||
2. Sync `out/` to your server: `rsync -az out/ user@server:/var/www/namecard`
|
||||
3. Reload Nginx: `sudo systemctl reload nginx`
|
||||
|
||||
---
|
||||
|
||||
**Need help?** Check the main README.md or contact the development team.
|
||||
252
README.md
Normal file
252
README.md
Normal file
@ -0,0 +1,252 @@
|
||||
# Rooftop Energy Digital Namecard
|
||||
|
||||
A mobile-first, dark-mode single-page application that presents Rooftop Energy staff namecards with clear CTAs, vCard download, and contact details copying functionality.
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
- **Contact CTAs**: Call, Email, WhatsApp, and LinkedIn buttons
|
||||
- **vCard Download**: Generate and download contact information as .vcf files
|
||||
- **Copy Details**: Copy all contact information to clipboard
|
||||
- **URL Overrides**: Customize details via query parameters for quick sharing
|
||||
- **Responsive Design**: Optimized for mobile with graceful desktop scaling
|
||||
- **Dark Mode**: Professional dark theme with Rooftop Energy branding
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
- **Framework**: Next.js 14 (App Router)
|
||||
- **Styling**: Tailwind CSS
|
||||
- **Icons**: Lucide React
|
||||
- **Fonts**: Exo 2 (Google Fonts)
|
||||
- **Deployment**: VPS-ready with static export and reverse proxy support
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── globals.css # Global styles and Tailwind imports
|
||||
│ ├── layout.tsx # Root layout with font loading
|
||||
│ ├── page.tsx # Main page with employee directory
|
||||
│ ├── [employeeId]/ # Dynamic routes for individual employees
|
||||
│ │ └── page.tsx # Individual employee namecard page
|
||||
│ └── not-found.tsx # Custom 404 page
|
||||
├── components/
|
||||
│ └── Namecard.tsx # Main namecard component
|
||||
└── config/
|
||||
├── staff.ts # Base staff profile interface
|
||||
└── employees.ts # All employee data and utilities
|
||||
public/
|
||||
├── logo.png # Company logo (replace with actual file)
|
||||
├── profilepic.png # Default profile picture
|
||||
├── whatsapp.png # WhatsApp icon
|
||||
└── linkedin.png # LinkedIn icon
|
||||
```
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
### Managing Employee Data
|
||||
|
||||
The application now supports multiple employees with individual routing. All employee data is stored in `src/config/employees.ts`.
|
||||
|
||||
#### Adding New Employees
|
||||
|
||||
To add a new employee, add an entry to the `employees` array:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: "unique-employee-id", // URL-friendly identifier (e.g., "john-doe")
|
||||
name: "John Doe", // Staff member's name
|
||||
title: "Senior Energy Consultant", // Job title
|
||||
phone: "+60 12-345 6789", // Formatted phone number
|
||||
whatsapp: "60123456789", // WhatsApp number (digits only)
|
||||
email: "john.doe@rooftop.my", // Email address
|
||||
linkedin: "https://linkedin.com/in/johndoe", // LinkedIn profile URL
|
||||
address: "Level 15, Menara 1 Sentral\nKuala Lumpur Sentral\n50470 Kuala Lumpur\nMalaysia", // Address with \n for line breaks
|
||||
website: "https://rooftop.my", // Company website
|
||||
logoPath: "/logo.png", // Path to company logo
|
||||
profilePic: "/profilepic.png" // Path to profile picture
|
||||
}
|
||||
```
|
||||
|
||||
#### URL Structure
|
||||
|
||||
Each employee gets their own URL:
|
||||
- **Homepage**: `card.rooftop.my/` - Shows all employees
|
||||
- **Individual**: `card.rooftop.my/john-doe` - Shows John Doe's namecard
|
||||
- **Direct sharing**: Share individual employee URLs for targeted contact
|
||||
|
||||
### URL Query Parameter Overrides
|
||||
|
||||
You can temporarily override any field via URL parameters:
|
||||
|
||||
```
|
||||
https://yoursite.com/?name=Jane%20Smith&title=Energy%20Analyst&phone=%2B60123456789
|
||||
```
|
||||
|
||||
Available parameters:
|
||||
- `name` - Staff name
|
||||
- `title` - Job title
|
||||
- `phone` - Phone number
|
||||
- `wa` - WhatsApp number
|
||||
- `email` - Email address
|
||||
- `linkedin` - LinkedIn URL
|
||||
- `address` - Address (use %0A for line breaks)
|
||||
- `site` - Website URL
|
||||
- `logo` - Logo path
|
||||
|
||||
## 🎨 Branding
|
||||
|
||||
The application uses the Rooftop Energy brand colors:
|
||||
- **Primary Accent**: `#fcd913` (Rooftop yellow)
|
||||
- **Background**: Deep charcoal (`#0a0a0a`)
|
||||
- **Card Surface**: Near-black (`#1a1a1a`)
|
||||
- **Borders**: Muted indigo (`#2a2a3a`)
|
||||
- **Text**: White and muted blue-gray (`#8b9bb4`)
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- npm or yarn
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone <your-repo-url>
|
||||
cd rooftop-energy-namecard
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Replace the logo:
|
||||
- Place your Rooftop Energy logo in `public/logo.png`
|
||||
- Recommended: 120x120px transparent PNG or SVG
|
||||
|
||||
4. Update staff details in `src/config/staff.ts`
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) to view the application.
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
The built application will be in the `out/` directory.
|
||||
|
||||
## 🌐 Deployment
|
||||
|
||||
### VPS + Reverse Proxy (Nginx example)
|
||||
|
||||
1. Build static files:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
2. Copy the `out/` directory to your server, e.g. `/var/www/namecard`:
|
||||
```bash
|
||||
scp -r out/ user@server:/var/www/namecard
|
||||
```
|
||||
|
||||
3. Configure Nginx:
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name example.com; # change to your domain
|
||||
|
||||
# Optional subpath base (set BASE_PATH to match, e.g. "/namecard")
|
||||
# location /namecard/ {
|
||||
# alias /var/www/namecard/;
|
||||
# try_files $uri $uri/ /index.html;
|
||||
# }
|
||||
|
||||
root /var/www/namecard;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. If you want to serve from a subpath (e.g. `/namecard`), set at build time:
|
||||
```bash
|
||||
BASE_PATH=/namecard NEXT_PUBLIC_BASE_URL=https://example.com/namecard npm run build
|
||||
```
|
||||
Then deploy the `out/` directory to the path configured by Nginx.
|
||||
|
||||
5. Reload Nginx:
|
||||
```bash
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
## 📱 Mobile Optimization
|
||||
|
||||
- Touch targets are ≥44px height
|
||||
- Optimized for 360-414px viewport widths
|
||||
- Responsive grid layouts (2×2 on mobile, 1×4 on desktop)
|
||||
- Mobile-first design approach
|
||||
|
||||
## ♿ Accessibility
|
||||
|
||||
- WCAG AA color contrast compliance
|
||||
- Visible focus states
|
||||
- Proper ARIA labels
|
||||
- Semantic HTML structure
|
||||
- Keyboard navigation support
|
||||
|
||||
## 🔧 Customization
|
||||
|
||||
### Adding Multiple Profiles
|
||||
|
||||
To support multiple staff members, you can:
|
||||
|
||||
1. Create a profiles directory: `src/config/profiles/`
|
||||
2. Add individual profile files
|
||||
3. Modify the routing to support `/[username]` paths
|
||||
4. Update the main page to load profiles dynamically
|
||||
|
||||
### Styling Changes
|
||||
|
||||
- Colors: Update `tailwind.config.js`
|
||||
- Typography: Modify `src/app/globals.css`
|
||||
- Layout: Edit `src/components/Namecard.tsx`
|
||||
|
||||
## 📊 Performance
|
||||
|
||||
The application targets:
|
||||
- **Lighthouse Performance**: ≥90
|
||||
- **Accessibility**: ≥95
|
||||
- **Best Practices**: ≥95
|
||||
- **SEO**: ≥90
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Test thoroughly
|
||||
5. Submit a pull request
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is proprietary to Rooftop Energy.
|
||||
|
||||
## 🆘 Support
|
||||
|
||||
For technical support or questions about the application, please contact the development team.
|
||||
|
||||
---
|
||||
|
||||
**Note**: Remember to replace `public/logo.png` with the actual Rooftop Energy logo before deploying!
|
||||
11
env.example
Normal file
11
env.example
Normal file
@ -0,0 +1,11 @@
|
||||
# Example environment variables for local development
|
||||
# Copy this file to .env.local and update values as needed
|
||||
|
||||
# Base URL for the application (used for QR codes and social sharing)
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
|
||||
# Analytics (optional)
|
||||
NEXT_PUBLIC_GA_ID=your-google-analytics-id
|
||||
|
||||
# Contact form (if adding contact forms later)
|
||||
CONTACT_EMAIL=contact@rooftop.my
|
||||
13
next.config.js
Normal file
13
next.config.js
Normal file
@ -0,0 +1,13 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const basePath = process.env.BASE_PATH || ''
|
||||
|
||||
const nextConfig = {
|
||||
output: 'export',
|
||||
trailingSlash: true,
|
||||
images: {
|
||||
unoptimized: true
|
||||
},
|
||||
basePath
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
6048
package-lock.json
generated
Normal file
6048
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
package.json
Normal file
40
package.json
Normal file
@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "rooftop-energy-namecard",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Digital namecard application for Rooftop Energy staff",
|
||||
"keywords": ["namecard", "digital", "contact", "rooftop", "energy"],
|
||||
"author": "Rooftop Energy",
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"export": "next build",
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rm -rf .next out",
|
||||
"setup": "npm install && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.0.4",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"lucide-react": "^0.294.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.0.4",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
public/linkedin.png
Normal file
BIN
public/linkedin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
BIN
public/logo.png
Normal file
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
BIN
public/profilepic.png
Normal file
BIN
public/profilepic.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/whatsapp.png
Normal file
BIN
public/whatsapp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
38
src/app/[employeeId]/page.tsx
Normal file
38
src/app/[employeeId]/page.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { Suspense } from 'react';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Namecard from '@/components/Namecard';
|
||||
import { getEmployeeById } from '@/config/employees';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
employeeId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function EmployeePage({ params }: PageProps) {
|
||||
const employee = getEmployeeById(params.employeeId);
|
||||
|
||||
if (!employee) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen bg-deep-charcoal flex items-center justify-center">
|
||||
<div className="text-white text-lg">Loading...</div>
|
||||
</div>
|
||||
}>
|
||||
<Namecard profile={employee} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// Generate static params for all employees
|
||||
export async function generateStaticParams() {
|
||||
const { getAllEmployees } = await import('@/config/employees');
|
||||
const employees = getAllEmployees();
|
||||
|
||||
return employees.map((employee) => ({
|
||||
employeeId: employee.id,
|
||||
}));
|
||||
}
|
||||
33
src/app/globals.css
Normal file
33
src/app/globals.css
Normal file
@ -0,0 +1,33 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: var(--font-exo-2), system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-deep-charcoal text-white;
|
||||
font-feature-settings: "kern" 1;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply bg-rooftop-yellow text-deep-charcoal font-semibold px-6 py-3 rounded-xl
|
||||
hover:scale-105 active:scale-95 transition-all duration-200 ease-out
|
||||
focus:outline-none focus:ring-2 focus:ring-rooftop-yellow focus:ring-offset-2 focus:ring-offset-deep-charcoal;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-card-surface text-white border border-muted-border font-medium px-6 py-3 rounded-xl
|
||||
hover:bg-muted-border hover:scale-105 active:scale-95 transition-all duration-200 ease-out
|
||||
focus:outline-none focus:ring-2 focus:ring-muted-border focus:ring-offset-2 focus:ring-offset-deep-charcoal;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-card-surface border border-muted-border rounded-2xl shadow-xl;
|
||||
}
|
||||
}
|
||||
61
src/app/layout.tsx
Normal file
61
src/app/layout.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Exo_2 } from 'next/font/google'
|
||||
import './globals.css'
|
||||
|
||||
const exo2 = Exo_2({
|
||||
subsets: ['latin'],
|
||||
weight: ['300', '400', '600', '700'],
|
||||
variable: '--font-exo-2',
|
||||
display: 'swap',
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Rooftop Energy - Digital Namecard',
|
||||
description: 'Connect with our energy consultants. Call, email, WhatsApp, or download contact details.',
|
||||
keywords: 'Rooftop Energy, energy consultant, Malaysia, renewable energy, solar',
|
||||
authors: [{ name: 'Rooftop Energy' }],
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'),
|
||||
openGraph: {
|
||||
title: 'Rooftop Energy - Digital Namecard',
|
||||
description: 'Connect with our energy consultants. Call, email, WhatsApp, or download contact details.',
|
||||
url: 'https://rooftop.my',
|
||||
siteName: 'Rooftop Energy',
|
||||
images: [
|
||||
{
|
||||
url: '/logo.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'Rooftop Energy Logo',
|
||||
},
|
||||
],
|
||||
locale: 'en_MY',
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'Rooftop Energy - Digital Namecard',
|
||||
description: 'Connect with our energy consultants. Call, email, WhatsApp, or download contact details.',
|
||||
images: ['/logo.png'],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className={exo2.variable}>
|
||||
<head>
|
||||
{/* Favicon and touch icons can be added here when available */}
|
||||
</head>
|
||||
<body className={`${exo2.variable} font-exo antialiased`}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
32
src/app/not-found.tsx
Normal file
32
src/app/not-found.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-charcoal flex items-center justify-center p-4">
|
||||
<div className="text-center max-w-md">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-6xl font-bold text-rooftop-yellow mb-4">404</h1>
|
||||
<h2 className="text-2xl font-semibold text-white mb-4">
|
||||
Employee Not Found
|
||||
</h2>
|
||||
<p className="text-muted-text mb-8">
|
||||
The employee you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-block w-full px-6 py-3 bg-rooftop-yellow text-deep-charcoal font-semibold rounded-xl hover:bg-yellow-400 transition-colors"
|
||||
>
|
||||
View All Employees
|
||||
</Link>
|
||||
|
||||
<p className="text-sm text-muted-text">
|
||||
Or check the URL and try again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
src/app/page.tsx
Normal file
75
src/app/page.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import Link from 'next/link';
|
||||
import { getAllEmployees } from '@/config/employees';
|
||||
|
||||
export default function HomePage() {
|
||||
const employees = getAllEmployees();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-charcoal p-4 sm:p-6 lg:p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold text-white mb-4">
|
||||
Rooftop Energy Team
|
||||
</h1>
|
||||
<p className="text-xl text-muted-text">
|
||||
Connect with our energy consultants and specialists
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Employee Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{employees.map((employee) => (
|
||||
<Link
|
||||
key={employee.id}
|
||||
href={`/${employee.id}`}
|
||||
className="card p-6 hover:scale-105 transition-transform duration-200 cursor-pointer group"
|
||||
>
|
||||
<div className="text-center">
|
||||
{/* Profile Picture */}
|
||||
<img
|
||||
src={employee.profilePic}
|
||||
alt={employee.name}
|
||||
className="w-20 h-20 mx-auto mb-4 rounded-full object-cover border-2 border-muted-text group-hover:border-rooftop-yellow transition-colors"
|
||||
/>
|
||||
|
||||
{/* Name and Title */}
|
||||
<h2 className="text-xl font-semibold text-white mb-2 group-hover:text-rooftop-yellow transition-colors">
|
||||
{employee.name}
|
||||
</h2>
|
||||
<p className="text-muted-text mb-4">
|
||||
{employee.title}
|
||||
</p>
|
||||
|
||||
{/* Contact Info Preview */}
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-text">
|
||||
<span>📧</span>
|
||||
<span className="truncate">{employee.email}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 text-muted-text">
|
||||
<span>📱</span>
|
||||
<span>{employee.phone}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View Card Button */}
|
||||
<div className="mt-4">
|
||||
<span className="inline-block px-4 py-2 bg-rooftop-yellow text-deep-charcoal font-medium rounded-lg text-sm group-hover:bg-yellow-400 transition-colors">
|
||||
View Namecard
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center mt-12 text-muted-text text-sm">
|
||||
<p>© 2024 Rooftop Energy. All rights reserved.</p>
|
||||
<p className="mt-1">Powered by sustainable technology</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
240
src/components/Namecard.tsx
Normal file
240
src/components/Namecard.tsx
Normal file
@ -0,0 +1,240 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Phone, Mail, Globe, Copy, Download, Check } from 'lucide-react';
|
||||
import { StaffProfile } from '@/config/staff';
|
||||
|
||||
interface NamecardProps {
|
||||
profile: StaffProfile;
|
||||
}
|
||||
|
||||
export default function Namecard({ profile }: NamecardProps) {
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
const [showFullAddress, setShowFullAddress] = useState(false);
|
||||
const [currentProfile, setCurrentProfile] = useState<StaffProfile>(profile);
|
||||
|
||||
// Handle URL query parameters on client side
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const overrides: Partial<StaffProfile> = {};
|
||||
|
||||
if (urlParams.get('name')) overrides.name = urlParams.get('name')!;
|
||||
if (urlParams.get('title')) overrides.title = urlParams.get('title')!;
|
||||
if (urlParams.get('phone')) overrides.phone = urlParams.get('phone')!;
|
||||
if (urlParams.get('wa')) overrides.whatsapp = urlParams.get('wa')!;
|
||||
if (urlParams.get('email')) overrides.email = urlParams.get('email')!;
|
||||
if (urlParams.get('linkedin')) overrides.linkedin = urlParams.get('linkedin')!;
|
||||
if (urlParams.get('address')) overrides.address = urlParams.get('address')!;
|
||||
if (urlParams.get('site')) overrides.website = urlParams.get('site')!;
|
||||
if (urlParams.get('logo')) overrides.logoPath = urlParams.get('logo')!;
|
||||
|
||||
if (Object.keys(overrides).length > 0) {
|
||||
setCurrentProfile({ ...profile, ...overrides });
|
||||
}
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
const handleCopyDetails = async () => {
|
||||
const details = `${currentProfile.name}
|
||||
${currentProfile.title}
|
||||
Rooftop Energy
|
||||
${currentProfile.phone}
|
||||
${currentProfile.email}
|
||||
${currentProfile.website}
|
||||
${currentProfile.address}`;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(details);
|
||||
setCopySuccess(true);
|
||||
setTimeout(() => setCopySuccess(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy: ', err);
|
||||
}
|
||||
};
|
||||
|
||||
const generateVCard = () => {
|
||||
const vcard = `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:${currentProfile.name}
|
||||
N:${currentProfile.name.split(' ').reverse().join(';')};;;
|
||||
ORG:Rooftop Energy
|
||||
TITLE:${currentProfile.title}
|
||||
TEL;TYPE=CELL:${currentProfile.phone.replace(/\s+/g, '')}
|
||||
EMAIL;TYPE=INTERNET:${currentProfile.email}
|
||||
URL:${currentProfile.website}
|
||||
ADR;TYPE=WORK:${currentProfile.address.replace(/\n/g, ';')}
|
||||
END:VCARD`;
|
||||
|
||||
const blob = new Blob([vcard], { type: 'text/vcard' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${currentProfile.name.replace(/\s+/g, '_')}_RooftopEnergy.vcf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const addressLines = currentProfile.address.split('\n');
|
||||
const displayAddress = showFullAddress ? addressLines : addressLines.slice(0, 2);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-charcoal p-4 sm:p-6 lg:p-8">
|
||||
<div className="max-w-md mx-auto">
|
||||
{/* Header */}
|
||||
<div className="card p-6 mb-6 text-center">
|
||||
<div className="mb-6">
|
||||
<img
|
||||
src={currentProfile.logoPath}
|
||||
alt="Rooftop Energy"
|
||||
className="w-24 h-auto mx-auto mb-4"
|
||||
/>
|
||||
|
||||
{/* Profile Picture */}
|
||||
<img
|
||||
src={currentProfile.profilePic || "/profilepic.png"}
|
||||
alt="Profile Picture"
|
||||
className="w-24 h-24 mx-auto mb-4 rounded-full object-cover border-2 border-muted-text"
|
||||
/>
|
||||
|
||||
<h1 className="text-2xl font-bold tracking-tight text-white mb-2">
|
||||
{currentProfile.name}
|
||||
</h1>
|
||||
<p className="text-lg text-muted-text font-light">
|
||||
{currentProfile.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Website button - only show on larger screens */}
|
||||
<div className="hidden sm:block mb-6">
|
||||
<a
|
||||
href={currentProfile.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-rooftop-yellow hover:text-yellow-400 transition-colors"
|
||||
>
|
||||
<Globe size={16} />
|
||||
<span className="text-sm font-medium">Visit Website</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA Grid */}
|
||||
<div className="card p-6 mb-6">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<a
|
||||
href={`tel:${currentProfile.phone}`}
|
||||
className="btn-primary flex flex-col items-center gap-2 py-4"
|
||||
>
|
||||
<Phone size={20} />
|
||||
<span className="text-sm font-semibold">Call</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={`mailto:${currentProfile.email}?subject=Hello ${currentProfile.name.split(' ')[0]} — via Rooftop Energy`}
|
||||
className="btn-primary flex flex-col items-center gap-2 py-4"
|
||||
>
|
||||
<Mail size={20} />
|
||||
<span className="text-sm font-semibold">Email</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={`https://wa.me/${currentProfile.whatsapp}?text=Hello ${currentProfile.name.split(' ')[0]}, I'd like to discuss energy solutions with Rooftop Energy.`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-primary flex flex-col items-center gap-2 py-4"
|
||||
>
|
||||
<img src="/whatsapp.png" alt="WhatsApp" className="w-5 h-5" />
|
||||
<span className="text-sm font-semibold">WhatsApp</span>
|
||||
</a>
|
||||
|
||||
{currentProfile.linkedin && (
|
||||
<a
|
||||
href={currentProfile.linkedin}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-primary flex flex-col items-center gap-2 py-4"
|
||||
>
|
||||
<img src="/linkedin.png" alt="LinkedIn" className="w-5 h-auto" />
|
||||
<span className="text-sm font-semibold">LinkedIn</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="card p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Contact Details</h2>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-text">Phone:</span>
|
||||
<span className="text-white ml-2">{currentProfile.phone}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-text">Email:</span>
|
||||
<span className="text-white ml-2">{currentProfile.email}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-text">Website:</span>
|
||||
<span className="text-white ml-2">{currentProfile.website}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-text">Address:</span>
|
||||
<div className="text-white ml-2 mt-1">
|
||||
{displayAddress.map((line, index) => (
|
||||
<div key={index}>{line}</div>
|
||||
))}
|
||||
{addressLines.length > 2 && (
|
||||
<button
|
||||
onClick={() => setShowFullAddress(!showFullAddress)}
|
||||
className="text-rooftop-yellow hover:text-yellow-400 text-xs mt-1 transition-colors"
|
||||
>
|
||||
{showFullAddress ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Row */}
|
||||
<div className="card p-6 mb-6">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={handleCopyDetails}
|
||||
className="btn-secondary flex items-center justify-center gap-2 py-4"
|
||||
>
|
||||
{copySuccess ? (
|
||||
<>
|
||||
<Check size={18} className="text-green-400" />
|
||||
<span className="text-sm">Copied!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={18} />
|
||||
<span className="text-sm">Copy Details</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={generateVCard}
|
||||
className="btn-secondary flex items-center justify-center gap-2 py-4"
|
||||
>
|
||||
<Download size={18} />
|
||||
<span className="text-sm">Save vCard</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center text-muted-text text-xs">
|
||||
<p>© 2024 Rooftop Energy. All rights reserved.</p>
|
||||
<p className="mt-1">Powered by sustainable technology</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
src/config/employees.ts
Normal file
73
src/config/employees.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { StaffProfile } from './staff';
|
||||
|
||||
export interface Employee extends StaffProfile {
|
||||
id: string;
|
||||
profilePic: string;
|
||||
}
|
||||
|
||||
// Import the YAML data (you'll need to install js-yaml if you want to parse YAML files)
|
||||
// For now, we'll define the data directly in TypeScript
|
||||
export const employees: Employee[] = [
|
||||
{
|
||||
id: "john-doe",
|
||||
name: "John Doe",
|
||||
title: "Senior Energy Consultant",
|
||||
phone: "+60 12-345 6789",
|
||||
whatsapp: "60123456789",
|
||||
email: "john.doe@rooftop.my",
|
||||
linkedin: "https://linkedin.com/in/johndoe",
|
||||
address: "Level 15, Menara 1 Sentral\nKuala Lumpur Sentral\n50470 Kuala Lumpur\nMalaysia",
|
||||
website: "https://rooftop.my",
|
||||
logoPath: "/logo.png",
|
||||
profilePic: "/profilepic.png"
|
||||
},
|
||||
{
|
||||
id: "jane-smith",
|
||||
name: "Jane Smith",
|
||||
title: "Energy Analyst",
|
||||
phone: "+60 12-345 6790",
|
||||
whatsapp: "60123456790",
|
||||
email: "jane.smith@rooftop.my",
|
||||
linkedin: "https://linkedin.com/in/janesmith",
|
||||
address: "Level 15, Menara 1 Sentral\nKuala Lumpur Sentral\n50470 Kuala Lumpur\nMalaysia",
|
||||
website: "https://rooftop.my",
|
||||
logoPath: "/logo.png",
|
||||
profilePic: "/profilepic.png"
|
||||
},
|
||||
{
|
||||
id: "ahmad-ali",
|
||||
name: "Ahmad Ali",
|
||||
title: "Renewable Energy Specialist",
|
||||
phone: "+60 12-345 6791",
|
||||
whatsapp: "60123456791",
|
||||
email: "ahmad.ali@rooftop.my",
|
||||
linkedin: "https://linkedin.com/in/ahmadali",
|
||||
address: "Level 15, Menara 1 Sentral\nKuala Lumpur Sentral\n50470 Kuala Lumpur\nMalaysia",
|
||||
website: "https://rooftop.my",
|
||||
logoPath: "/logo.png",
|
||||
profilePic: "/profilepic.png"
|
||||
},
|
||||
{
|
||||
id: "sarah-wong",
|
||||
name: "Sarah Wong",
|
||||
title: "Solar Energy Engineer",
|
||||
phone: "+60 12-345 6792",
|
||||
whatsapp: "60123456792",
|
||||
email: "sarah.wong@rooftop.my",
|
||||
linkedin: "https://linkedin.com/in/sarahwong",
|
||||
address: "Level 15, Menara 1 Sentral\nKuala Lumpur Sentral\n50470 Kuala Lumpur\nMalaysia",
|
||||
website: "https://rooftop.my",
|
||||
logoPath: "/logo.png",
|
||||
profilePic: "/profilepic.png"
|
||||
}
|
||||
];
|
||||
|
||||
export function getEmployeeById(id: string): Employee | undefined {
|
||||
return employees.find(emp => emp.id === id);
|
||||
}
|
||||
|
||||
export function getAllEmployees(): Employee[] {
|
||||
return employees;
|
||||
}
|
||||
|
||||
export default employees;
|
||||
26
src/config/staff.ts
Normal file
26
src/config/staff.ts
Normal file
@ -0,0 +1,26 @@
|
||||
export interface StaffProfile {
|
||||
name: string;
|
||||
title: string;
|
||||
phone: string;
|
||||
whatsapp: string; // digits only
|
||||
email: string;
|
||||
linkedin: string; // full URL
|
||||
address: string; // supports \n for line breaks
|
||||
website: string;
|
||||
logoPath: string;
|
||||
profilePic?: string; // optional profile picture path
|
||||
}
|
||||
|
||||
export const staffProfile: StaffProfile = {
|
||||
name: "John Doe",
|
||||
title: "Senior Energy Consultant",
|
||||
phone: "+60 12-345 6789",
|
||||
whatsapp: "60123456789",
|
||||
email: "john.doe@rooftop.my",
|
||||
linkedin: "https://linkedin.com/in/johndoe",
|
||||
address: "Level 15, Menara 1 Sentral\nKuala Lumpur Sentral\n50470 Kuala Lumpur\nMalaysia",
|
||||
website: "https://rooftop.my",
|
||||
logoPath: "/logo.png"
|
||||
};
|
||||
|
||||
export default staffProfile;
|
||||
38
tailwind.config.js
Normal file
38
tailwind.config.js
Normal file
@ -0,0 +1,38 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'rooftop-yellow': '#fcd913',
|
||||
'deep-charcoal': '#0a0a0a',
|
||||
'near-black': '#111111',
|
||||
'card-surface': '#1a1a1a',
|
||||
'muted-border': '#2a2a3a',
|
||||
'muted-text': '#8b9bb4',
|
||||
},
|
||||
fontFamily: {
|
||||
'exo': ['var(--font-exo-2)', 'sans-serif'],
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.2s ease-out',
|
||||
'scale-in': 'scaleIn 0.15s ease-out',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
scaleIn: {
|
||||
'0%': { transform: 'scale(0.95)' },
|
||||
'100%': { transform: 'scale(1)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "es6"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user