initial app

This commit is contained in:
Lucas Tan 2025-08-19 11:49:57 +08:00
commit 27c78e2ac4
22 changed files with 7216 additions and 0 deletions

35
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

40
package.json Normal file
View 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
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
public/linkedin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
public/profilepic.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
public/whatsapp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]
}