feat: add admin authentication and management features
Some checks are pending
CI/CD / test-and-build (push) Waiting to run
CI/CD / deploy (push) Blocked by required conditions

- Implemented Prisma schema with models for AdminUser, AppSettings, and Snapshot.
- Created seed script to initialize the database with an admin user and JWT secret.
- Developed admin login page with form handling and error management.
- Added API routes for admin login, logout, change password, and JWT verification.
- Integrated Stripe for payment intent management in admin orders.
- Established middleware for protecting admin routes with JWT authentication.
- Created Zustand stores for managing persona and snapshot states.
This commit is contained in:
Najjar\NajjarV02 2026-04-13 17:57:59 +04:00
parent ec0f991a30
commit 05b540997e
30 changed files with 3338 additions and 362 deletions

4
.env.example Normal file
View File

@ -0,0 +1,4 @@
# Stripe Keys
# Get your keys from https://dashboard.stripe.com/apikeys
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51RBtf7I7xOcO9rzigsLLK3esMLmBlJoRztbzUadPhQm7tcHQuScViFEkwdfAwDxbaqt5n8BOuJV9wRSMdn2IrxIX00lqGOOJfT
STRIPE_SECRET_KEY=sk_test_51RBtf7I7xOcO9rzitxMqK3jnTb3SPdEbyGxGBnPccGEfrIrpiEFEOIEG2oHuTumaUejUN4FyAOBg0AVCBRn6AOKI00LeWSDC10

View File

@ -0,0 +1,116 @@
# Plan: Admin Dashboard Auth + CRUD + Enhancements
## TL;DR
Add password authentication to the admin dashboard, enable full CRUD for personas/pricing, and add useful admin features like order analytics and configurator settings management.
## Phase 1: Admin Authentication (Database-backed with Prisma + SQLite)
### Steps
1. **Setup Prisma + SQLite**
- Install `prisma` and `@prisma/client`
- Create `prisma/schema.prisma` with SQLite provider
- Models: `AdminUser` (id, username, passwordHash, createdAt), `AppSettings` (key, value)
- Run `npx prisma db push` to create the database
- Create `src/lib/prisma.ts` — singleton Prisma client
2. **Create seed script** `prisma/seed.ts`
- Creates default admin user with bcrypt-hashed password
- Generates and stores JWT secret in `AppSettings` table
- Run with `npx prisma db seed`
3. **Create admin auth API routes**
- `src/app/api/admin/login/route.ts` — POST, accepts `{ username, password }`, verifies bcrypt hash from DB, returns JWT in httpOnly cookie (JWT secret from DB)
- `src/app/api/admin/verify/route.ts` — GET, checks JWT cookie validity
- `src/app/api/admin/logout/route.ts` — POST, clears auth cookie
- `src/app/api/admin/change-password/route.ts` — POST, accepts `{ currentPassword, newPassword }`, updates hash in DB
4. **Create admin middleware** `src/middleware.ts`
- Protects all `/admin/*` routes (except `/admin/login/`)
- Checks for valid JWT cookie, redirects to `/admin/login/` if missing/invalid
- Note: middleware can't use Prisma directly (edge runtime), so JWT secret needs to be in env OR verify via API call
5. **Create admin login page** `src/app/admin/login/page.tsx`
- Username + password form (styled to match existing admin design)
- Calls login API, redirects to `/admin/` on success
- Shows error on wrong credentials
6. **Add JWT_SECRET to .env.local** (only this one — password is in DB)
- Alternative: store JWT secret in DB and load at startup into a module-level cache
### Dependencies to install
- `prisma` (dev), `@prisma/client`, `bcryptjs`, `@types/bcryptjs` (dev), `jose`
## Phase 2: Full CRUD for Pricing Items + Personas
### Steps (depends on Phase 1)
6. **Add/Remove pricing items in admin** — Update `src/app/admin/page.tsx`
- "Add Item" button with fields: id (auto-slug from label), label, price
- Delete button per row (with confirmation)
- Update `usePricingStore` to support `addItem()` and `removeItem()` actions
7. **Persona management section** — New section in admin page
- List current personas with edit capability (label, description, colors)
- Add new persona form
- Delete persona button
- Create `usePersonaStore` or extend `usePricingStore` to manage persona definitions
- Sync persona list with ConfigPanel's `PERSONA_OPTIONS` (currently hardcoded)
## Phase 3: Additional Admin Features
### Steps (parallel with Phase 2)
8. **Orders dashboard section** — New section in admin page
- Show orders from Stripe API (list recent payments)
- Display: order ID, customer email, amount, status, date
- New API route `src/app/api/admin/orders/route.ts` to fetch from Stripe
9. **Analytics overview cards** at top of admin page
- Total revenue (from Stripe)
- Number of orders
- Most popular persona
- Most popular color
10. **Configurator settings** section
- Edit default color (`#96a2b6`)
- Toggle available color options
- Set min/max price boundaries
11. **Logout button** in admin header
## Relevant Files
- `src/app/admin/page.tsx` — Main admin dashboard (add CRUD UI, analytics)
- `src/app/admin/login/page.tsx` — New login page
- `src/app/api/admin/login/route.ts` — New auth API
- `src/app/api/admin/verify/route.ts` — New verify API
- `src/app/api/admin/orders/route.ts` — New orders API
- `src/middleware.ts` — New, protects admin routes
- `src/lib/prisma.ts` — New, Prisma client singleton
- `prisma/schema.prisma` — New, database schema (AdminUser, AppSettings)
- `prisma/seed.ts` — New, seeds default admin user
- `src/store/usePricingStore.ts` — Add `addItem()`, `removeItem()` actions
- `src/components/ConfigPanel.tsx` — Persona options currently hardcoded (PERSONA_OPTIONS const)
- `.env.local` — Add ADMIN_PASSWORD, ADMIN_JWT_SECRET
## Verification
1. Visit `/admin/` without login → redirected to `/admin/login/`
2. Enter wrong password → error message shown
3. Enter correct password → redirected to `/admin/`
4. Add a pricing item → appears in configurator pricing breakdown
5. Remove a pricing item → disappears from pricing
6. Edit persona → reflected in ConfigPanel
7. Orders section shows real Stripe payment data
8. Refresh admin page → still logged in (cookie persists)
9. Run `npx vitest run` → all existing tests pass
## Decisions
- **Database: Prisma + SQLite** — file-based, no external server needed, real ORM
- Admin password stored as bcrypt hash in DB (not in env vars)
- JWT secret stored in AppSettings table in DB
- JWT in httpOnly cookie — secure, no localStorage tokens
- jose library for JWT — lightweight, edge-compatible (works in Next.js middleware)
- Personas need to move from hardcoded array to store-driven — breaking change in ConfigPanel
- Stripe orders fetched via Stripe API directly — no local DB needed for orders
## Further Considerations
1. **Persona storage**: Currently hardcoded in ConfigPanel. Moving to a store means ConfigPanel reads from store dynamically. This is required for admin CRUD to work. **Recommended: create persona store with localStorage persistence (same pattern as pricing store)**
2. **Stripe orders vs local orders**: Currently orders only exist client-side. To show in admin, we fetch from Stripe's payment intents list. **Recommended: use Stripe API directly, no local DB needed**
3. **Multi-admin support**: Currently single password. If needed later, can upgrade to NextAuth with credentials provider. **Recommended: start simple, upgrade if needed**

2
.gitignore vendored
View File

@ -35,3 +35,5 @@ next-env.d.ts
# IDE
.idea
.vscode
/src/generated/prisma

108
STRIPE_INTEGRATION.md Normal file
View File

@ -0,0 +1,108 @@
# Stripe Payment Integration
## ملخص التعديلات
تم استبدال نظام الدفع التجريبي (Demo) بتكامل حقيقي مع **Stripe** لمعالجة المدفوعات.
---
## الملفات الجديدة
### 1. `src/app/api/create-payment-intent/route.ts`
- API Route على السيرفر لإنشاء Stripe PaymentIntent
- يستقبل المبلغ بالدرهم (AED) ويحوله لأصغر وحدة (فلس)
- يرسل metadata تشمل الـ persona واللون وإيميل العميل
### 2. `.env.example`
- ملف مرجعي يحتوي على أسماء المتغيرات المطلوبة لمفاتيح Stripe
---
## الملفات المعدّلة
### 3. `src/store/useOrderStore.ts`
**قبل:**
```ts
export interface PaymentInfo {
cardNumber: string;
expiry: string;
cvv: string;
nameOnCard: string;
}
```
**بعد:**
```ts
export interface PaymentInfo {
paymentIntentId: string;
clientSecret: string;
status: 'idle' | 'processing' | 'succeeded' | 'failed';
errorMessage: string;
}
```
- تمت إضافة action جديد: `createPaymentIntent()` — يستدعي الـ API Route وينشئ PaymentIntent
- تم تعديل `setPayment()` ليقبل `Partial<PaymentInfo>` للتحديث التدريجي
---
### 4. `src/components/checkout/PaymentStep.tsx`
**قبل:** فورم يدوي يجمع بيانات البطاقة (رقم، تاريخ انتهاء، CVV، اسم) — بدون معالجة حقيقية
**بعد:** يستخدم `<PaymentElement>` من Stripe Elements — يعرض واجهة دفع آمنة تدعم:
- بطاقات الائتمان/الخصم
- Apple Pay
- Google Pay
- طرق دفع أخرى حسب إعدادات حساب Stripe
---
### 5. `src/components/checkout/ReviewStep.tsx`
**قبل:** زر "Place Order" كان يعمل `setTimeout(1500)` فقط (محاكاة)
**بعد:** يستدعي `stripe.confirmPayment()` لتأكيد الدفع الحقيقي عبر Stripe، مع عرض رسائل الخطأ إذا فشل الدفع
---
### 6. `src/components/CheckoutOverlay.tsx`
- تم تحميل Stripe.js عبر `loadStripe()`
- يتم إنشاء PaymentIntent تلقائياً عند الدخول لخطوة الدفع
- يتم تغليف خطوتي Payment و Review بـ `<Elements>` provider من Stripe
---
### 7. `src/store/useOrderStore.test.ts`
- تم تحديث الـ tests لتتوافق مع الـ `PaymentInfo` الجديد
---
## الحزم المضافة
```
stripe — Stripe SDK (سيرفر)
@stripe/stripe-js — Stripe.js loader (كلاينت)
@stripe/react-stripe-js — React components (Elements, PaymentElement)
```
---
## الإعداد
1. أنشئ ملف `.env.local` في root المشروع:
```env
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxx
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
```
2. احصل على المفاتيح من: https://dashboard.stripe.com/apikeys
3. للتجربة استخدم مفاتيح الـ test (`pk_test_` / `sk_test_`)
---
## تدفق الدفع الجديد
```
Config → Shipping → [Payment Intent Created] → Payment (Stripe Elements) → Review → confirmPayment() → Confirmed
```

View File

@ -1,6 +1,5 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
images: {
unoptimized: true,
},

1617
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,19 +11,27 @@
"test:watch": "vitest"
},
"dependencies": {
"@libsql/client": "^0.17.2",
"@prisma/adapter-libsql": "^7.7.0",
"@prisma/client": "^7.7.0",
"@react-spring/three": "^10.0.3",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@react-three/postprocessing": "^2.16.0",
"@stripe/react-stripe-js": "^6.1.0",
"@stripe/stripe-js": "^9.1.0",
"@types/three": "^0.183.1",
"bcryptjs": "^3.0.3",
"framer-motion": "^12.38.0",
"gsap": "^3.14.2",
"i18next": "^26.0.3",
"i18next-browser-languagedetector": "^8.2.1",
"jose": "^6.2.2",
"next": "16.2.2",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-i18next": "^17.0.2",
"stripe": "^22.0.1",
"three": "^0.170.0",
"zustand": "^5.0.12"
},
@ -32,11 +40,13 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.1",
"jsdom": "^29.0.2",
"prisma": "^7.7.0",
"tailwindcss": "4.2.2",
"typescript": "^5.0.0",
"vitest": "^4.1.2"

11
prisma.config.ts Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: "file:./prisma/lootah.db",
},
});

BIN
prisma/lootah.db Normal file

Binary file not shown.

28
prisma/schema.prisma Normal file
View File

@ -0,0 +1,28 @@
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "sqlite"
}
model AdminUser {
id String @id @default(cuid())
username String @unique
passwordHash String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AppSettings {
key String @id
value String
updatedAt DateTime @updatedAt
}
model Snapshot {
paymentIntentId String @id
imageData String
createdAt DateTime @default(now())
}

60
prisma/seed.ts Normal file
View File

@ -0,0 +1,60 @@
import { PrismaClient } from '../src/generated/prisma/client.js';
import { PrismaLibSql } from '@prisma/adapter-libsql';
import bcrypt from 'bcryptjs';
import { randomBytes } from 'crypto';
import path from 'path';
const dbPath = path.resolve(process.cwd(), 'prisma', 'lootah.db');
const adapter = new PrismaLibSql({ url: `file:${dbPath}` });
const prisma = new PrismaClient({ adapter } as ConstructorParameters<typeof PrismaClient>[0]);
async function main() {
console.log('Seeding database...');
// Create default admin user
const existingAdmin = await prisma.adminUser.findUnique({
where: { username: 'admin' },
});
if (!existingAdmin) {
const passwordHash = await bcrypt.hash('admin123', 12);
await prisma.adminUser.create({
data: {
username: 'admin',
passwordHash,
},
});
console.log('✓ Created admin user (username: admin, password: admin123)');
console.log(' ⚠️ Change the password after first login!');
} else {
console.log('✓ Admin user already exists, skipping.');
}
// Generate and store JWT secret
const existingSecret = await prisma.appSettings.findUnique({
where: { key: 'jwt_secret' },
});
if (!existingSecret) {
const jwtSecret = randomBytes(64).toString('hex');
await prisma.appSettings.upsert({
where: { key: 'jwt_secret' },
update: { value: jwtSecret },
create: { key: 'jwt_secret', value: jwtSecret },
});
console.log('✓ Generated JWT secret and stored in database.');
} else {
console.log('✓ JWT secret already exists, skipping.');
}
console.log('Seeding complete!');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -0,0 +1,172 @@
'use client';
import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
export default function AdminLoginPage() {
const router = useRouter();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await fetch('/api/admin/login/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (res.ok) {
router.push('/admin/');
router.refresh();
} else {
setError(data.error ?? 'Login failed');
}
} catch {
setError('Network error. Please try again.');
} finally {
setLoading(false);
}
};
return (
<div style={pageStyle}>
<div style={cardStyle}>
<div style={{ textAlign: 'center', marginBottom: '2rem' }}>
<div style={{
width: '48px',
height: '48px',
borderRadius: '12px',
background: 'rgba(59, 130, 246, 0.08)',
border: '1px solid rgba(59, 130, 246, 0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto 1rem',
fontSize: '1.25rem',
}}>
🔐
</div>
<h1 style={{ fontSize: '1.25rem', fontWeight: 700, color: '#1a1a2e', margin: 0, marginBottom: '0.25rem' }}>
Admin Login
</h1>
<p style={{ fontSize: '0.8rem', color: '#94a3b8', margin: 0 }}>
Lootah Robotics G1 Configurator
</p>
</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div>
<label style={labelStyle} htmlFor="username">Username</label>
<input
id="username"
type="text"
autoComplete="username"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
style={inputStyle}
onFocus={(e) => (e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)')}
onBlur={(e) => (e.currentTarget.style.borderColor = 'rgba(0, 0, 0, 0.1)')}
placeholder="admin"
/>
</div>
<div>
<label style={labelStyle} htmlFor="password">Password</label>
<input
id="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
style={inputStyle}
onFocus={(e) => (e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)')}
onBlur={(e) => (e.currentTarget.style.borderColor = 'rgba(0, 0, 0, 0.1)')}
placeholder="••••••••"
/>
</div>
{error && (
<div style={{
padding: '0.6rem 0.875rem',
borderRadius: '0.375rem',
background: 'rgba(239, 68, 68, 0.06)',
border: '1px solid rgba(239, 68, 68, 0.2)',
color: '#dc2626',
fontSize: '0.8rem',
}}>
{error}
</div>
)}
<button
type="submit"
disabled={loading}
style={{
padding: '0.7rem',
borderRadius: '0.5rem',
border: '1px solid rgba(59, 130, 246, 0.3)',
background: loading ? 'rgba(148, 163, 184, 0.1)' : 'rgba(59, 130, 246, 0.08)',
color: loading ? '#94a3b8' : '#2563eb',
fontSize: '0.875rem',
fontWeight: 600,
cursor: loading ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
marginTop: '0.25rem',
}}
>
{loading ? 'Signing in…' : 'Sign In'}
</button>
</form>
</div>
</div>
);
}
const pageStyle: React.CSSProperties = {
minHeight: '100vh',
background: '#ffffff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem',
fontFamily: 'system-ui, -apple-system, sans-serif',
};
const cardStyle: React.CSSProperties = {
width: '100%',
maxWidth: '380px',
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(20px)',
border: '1px solid rgba(0, 0, 0, 0.06)',
borderRadius: '1rem',
padding: '2rem',
};
const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: '0.75rem',
fontWeight: 600,
color: '#374151',
marginBottom: '0.375rem',
letterSpacing: '0.02em',
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '0.6rem 0.875rem',
borderRadius: '0.5rem',
border: '1px solid rgba(0, 0, 0, 0.1)',
background: '#ffffff',
color: '#1a1a2e',
fontSize: '0.875rem',
outline: 'none',
transition: 'border-color 0.2s ease',
boxSizing: 'border-box',
};

View File

@ -1,208 +1,526 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { pricingStore, usePricingStore } from '@/store/usePricingStore';
import { personaStore, usePersonaStore } from '@/store/usePersonaStore';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
export default function AdminPage() {
const items = usePricingStore((s) => s.items);
const isHydrated = usePricingStore((s) => s.isHydrated);
const [editedPrices, setEditedPrices] = useState<Record<string, number>>({});
const [saved, setSaved] = useState(false);
interface Order {
id: string;
amount: number;
currency: string;
status: string;
created: number;
metadata: Record<string, string>;
}
useEffect(() => {
pricingStore.getState().hydrate();
}, []);
type Tab = 'pricing' | 'personas' | 'orders';
export default function AdminPage() {
const router = useRouter();
const [activeTab, setActiveTab] = useState<Tab>('pricing');
// --------------- PRICING ---------------
const items = usePricingStore((s) => s.items);
const isPricingHydrated = usePricingStore((s) => s.isHydrated);
const [editedPrices, setEditedPrices] = useState<Record<string, number>>({});
const [priceSaved, setPriceSaved] = useState(false);
const [newItem, setNewItem] = useState({ id: '', label: '', price: '' });
const [addItemError, setAddItemError] = useState('');
useEffect(() => { pricingStore.getState().hydrate(); }, []);
useEffect(() => {
const map: Record<string, number> = {};
items.forEach((item) => {
map[item.id] = item.price;
});
items.forEach((item) => { map[item.id] = item.price; });
setEditedPrices(map);
}, [items]);
const handlePriceChange = (id: string, value: string) => {
const num = parseInt(value.replace(/[^0-9]/g, ''), 10);
if (!isNaN(num)) {
setEditedPrices((prev) => ({ ...prev, [id]: num }));
} else if (value === '') {
setEditedPrices((prev) => ({ ...prev, [id]: 0 }));
}
setEditedPrices((prev) => ({ ...prev, [id]: isNaN(num) ? 0 : num }));
};
const handleSave = () => {
const handleSavePrices = () => {
Object.entries(editedPrices).forEach(([id, price]) => {
pricingStore.getState().updatePrice(id, price);
});
setSaved(true);
setTimeout(() => setSaved(false), 2000);
setPriceSaved(true);
setTimeout(() => setPriceSaved(false), 2000);
};
const handleReset = () => {
pricingStore.getState().resetPrices();
setSaved(false);
const handleAddItem = () => {
setAddItemError('');
const id = newItem.id.trim().toLowerCase().replace(/\s+/g, '-');
const label = newItem.label.trim();
const price = parseInt(newItem.price, 10);
if (!id || !label || isNaN(price) || price < 0) {
setAddItemError('Please fill all fields with valid values.');
return;
}
if (items.some((i) => i.id === id)) {
setAddItemError(`ID "${id}" already exists.`);
return;
}
pricingStore.getState().addItem({ id, label, price });
setNewItem({ id: '', label: '', price: '' });
};
const formatPrice = (price: number) => {
return new Intl.NumberFormat('en-AE', { style: 'decimal' }).format(price);
// --------------- PERSONAS ---------------
const personas = usePersonaStore((s) => s.personas);
const isPersonaHydrated = usePersonaStore((s) => s.isHydrated);
const [newPersona, setNewPersona] = useState({ label: '', description: '', torso: '#3b82f6', legs: '#3b82f6' });
const [personaError, setPersonaError] = useState('');
const [personaSaved, setPersonaSaved] = useState(false);
useEffect(() => { personaStore.getState().hydrate(); }, []);
const handleAddPersona = () => {
setPersonaError('');
const label = newPersona.label.trim();
const description = newPersona.description.trim();
if (!label || !description) {
setPersonaError('Label and description are required.');
return;
}
personaStore.getState().addPersona({
label,
description,
colors: { torso: newPersona.torso, legs: newPersona.legs },
});
setNewPersona({ label: '', description: '', torso: '#3b82f6', legs: '#3b82f6' });
setPersonaSaved(true);
setTimeout(() => setPersonaSaved(false), 2000);
};
if (!isHydrated) {
return (
<div style={pageStyle}>
<p style={{ color: '#64748b' }}>Loading...</p>
</div>
);
// --------------- ORDERS ---------------
const [orders, setOrders] = useState<Order[]>([]);
const [ordersLoading, setOrdersLoading] = useState(false);
const [ordersError, setOrdersError] = useState('');
const [totalRevenue, setTotalRevenue] = useState(0);
const loadOrders = useCallback(async () => {
setOrdersLoading(true);
setOrdersError('');
try {
const res = await fetch('/api/admin/orders/');
if (!res.ok) throw new Error('Failed to load orders');
const data = await res.json();
setOrders(data.orders ?? []);
const succeeded = (data.orders ?? []).filter((o: Order) => o.status === 'succeeded');
setTotalRevenue(succeeded.reduce((sum: number, o: Order) => sum + o.amount, 0));
} catch (err) {
setOrdersError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setOrdersLoading(false);
}
}, []);
useEffect(() => {
if (activeTab === 'orders') loadOrders();
}, [activeTab, loadOrders]);
// --------------- AUTH ---------------
const handleLogout = async () => {
await fetch('/api/admin/logout/', { method: 'POST' });
router.push('/admin/login/');
router.refresh();
};
// --------------- CHANGE PASSWORD ---------------
const [showPwModal, setShowPwModal] = useState(false);
const [pwForm, setPwForm] = useState({ current: '', next: '', confirm: '' });
const [pwError, setPwError] = useState('');
const [pwSaved, setPwSaved] = useState(false);
const handleChangePassword = async () => {
setPwError('');
if (pwForm.next !== pwForm.confirm) { setPwError('Passwords do not match.'); return; }
if (pwForm.next.length < 8) { setPwError('Password must be at least 8 characters.'); return; }
const res = await fetch('/api/admin/change-password/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword: pwForm.current, newPassword: pwForm.next }),
});
const data = await res.json();
if (res.ok) {
setPwSaved(true);
setTimeout(() => { setPwSaved(false); setShowPwModal(false); setPwForm({ current: '', next: '', confirm: '' }); }, 1500);
} else {
setPwError(data.error ?? 'Failed to update password.');
}
};
const formatPrice = (price: number) =>
new Intl.NumberFormat('en-AE', { style: 'decimal' }).format(price);
const formatAmount = (amount: number, currency: string) =>
new Intl.NumberFormat('en-AE', { style: 'currency', currency: currency.toUpperCase(), maximumFractionDigits: 0 }).format(amount / 100);
const formatDate = (ts: number) =>
new Date(ts * 1000).toLocaleDateString('en-AE', { day: 'numeric', month: 'short', year: 'numeric' });
if (!isPricingHydrated) {
return <div style={pageStyle}><p style={{ color: '#64748b' }}>Loading</p></div>;
}
return (
<div style={pageStyle}>
<div style={containerStyle}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '2rem' }}>
{/* HEADER */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem' }}>
<div>
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, color: '#1a1a2e', margin: 0, marginBottom: '0.25rem' }}>
Pricing Dashboard
<h1 style={{ fontSize: '1.25rem', fontWeight: 700, color: '#1a1a2e', margin: 0, marginBottom: '0.2rem' }}>
Admin Dashboard
</h1>
<p style={{ fontSize: '0.8rem', color: '#94a3b8', margin: 0 }}>
Edit prices for the G1 Robot Configurator
</p>
<p style={{ fontSize: '0.75rem', color: '#94a3b8', margin: 0 }}>Lootah Robotics G1 Configurator</p>
</div>
<Link
href="/"
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<button onClick={() => setShowPwModal(true)} style={ghostBtnStyle}>Change Password</button>
<button onClick={handleLogout} style={{ ...ghostBtnStyle, color: '#ef4444', borderColor: 'rgba(239,68,68,0.2)' }}>Logout</button>
<Link href="/" style={{ ...ghostBtnStyle, textDecoration: 'none' }}> Configurator</Link>
</div>
</div>
{/* ANALYTICS STRIP */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.75rem', marginBottom: '1.5rem' }}>
<StatCard label="Pricing Items" value={String(items.length)} />
<StatCard label="Personas" value={String(personas.length)} />
<StatCard label="Succeeded Revenue" value={totalRevenue > 0 ? formatAmount(totalRevenue, 'aed') : '—'} />
</div>
{/* TABS */}
<div style={{ display: 'flex', gap: '0.25rem', marginBottom: '1.25rem', borderBottom: '1px solid rgba(0,0,0,0.06)', paddingBottom: '0.5rem' }}>
{(['pricing', 'personas', 'orders'] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setActiveTab(t)}
style={{
padding: '0.5rem 1rem',
padding: '0.4rem 0.875rem',
borderRadius: '0.375rem',
border: '1px solid rgba(59, 130, 246, 0.2)',
background: 'rgba(59, 130, 246, 0.06)',
color: '#2563eb',
border: 'none',
background: activeTab === t ? 'rgba(59,130,246,0.08)' : 'transparent',
color: activeTab === t ? '#2563eb' : '#64748b',
fontSize: '0.8rem',
textDecoration: 'none',
transition: 'all 0.2s ease',
fontWeight: activeTab === t ? 600 : 400,
cursor: 'pointer',
textTransform: 'capitalize',
}}
>
Back to Configurator
</Link>
{t}
</button>
))}
</div>
{/* Pricing Table */}
<div style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(20px)',
border: '1px solid rgba(0, 0, 0, 0.06)',
borderRadius: '0.75rem',
overflow: 'hidden',
}}>
{/* Table Header */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 200px',
padding: '0.75rem 1.25rem',
borderBottom: '1px solid rgba(0, 0, 0, 0.04)',
background: 'rgba(248, 248, 246, 0.5)',
}}>
<span style={{ fontSize: '0.7rem', fontWeight: 600, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Item
</span>
<span style={{ fontSize: '0.7rem', fontWeight: 600, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em', textAlign: 'right' }}>
Price (AED)
</span>
</div>
{/* Rows */}
{items.map((item, index) => (
<div
key={item.id}
style={{
display: 'grid',
gridTemplateColumns: '1fr 200px',
padding: '1rem 1.25rem',
alignItems: 'center',
borderBottom: index < items.length - 1 ? '1px solid rgba(0, 0, 0, 0.04)' : 'none',
transition: 'background 0.15s ease',
}}
>
{/* ===== PRICING TAB ===== */}
{activeTab === 'pricing' && (
<div>
<div style={{ fontSize: '0.875rem', color: '#374151', fontWeight: 500 }}>
{item.label}
<TableCard>
<TableHeader cols="1fr 180px 56px" labels={['Item', 'Price (AED)', '']} />
{items.map((item, i) => (
<div key={item.id} style={{ display: 'grid', gridTemplateColumns: '1fr 180px 56px', padding: '0.875rem 1.25rem', alignItems: 'center', borderBottom: i < items.length - 1 ? '1px solid rgba(0,0,0,0.04)' : 'none' }}>
<div>
<div style={{ fontSize: '0.85rem', color: '#374151', fontWeight: 500 }}>{item.label}</div>
<div style={{ fontSize: '0.7rem', color: '#94a3b8', fontFamily: 'monospace' }}>{item.id}</div>
</div>
<div style={{ fontSize: '0.7rem', color: '#94a3b8', fontFamily: 'monospace' }}>
{item.id}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '0.5rem' }}>
<span style={{ fontSize: '0.75rem', color: '#94a3b8' }}>AED</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
<span style={{ fontSize: '0.7rem', color: '#94a3b8' }}>AED</span>
<input
type="text"
value={formatPrice(editedPrices[item.id] ?? item.price)}
onChange={(e) => handlePriceChange(item.id, e.target.value)}
style={{
width: '130px',
padding: '0.5rem 0.75rem',
borderRadius: '0.375rem',
border: '1px solid rgba(0, 0, 0, 0.1)',
background: 'rgba(255, 255, 255, 1)',
color: '#1a1a2e',
fontSize: '0.875rem',
fontFamily: 'monospace',
textAlign: 'right',
outline: 'none',
transition: 'border-color 0.2s ease',
}}
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = 'rgba(0, 0, 0, 0.1)'; }}
style={tableInputStyle}
onFocus={(e) => (e.currentTarget.style.borderColor = 'rgba(59,130,246,0.5)')}
onBlur={(e) => (e.currentTarget.style.borderColor = 'rgba(0,0,0,0.1)')}
aria-label={`Price for ${item.label}`}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'center' }}>
{item.id !== 'base' && (
<button onClick={() => pricingStore.getState().removeItem(item.id)} style={deleteBtnStyle} title="Remove item"></button>
)}
</div>
</div>
))}
</TableCard>
{/* Add new pricing item */}
<div style={{ marginTop: '1rem', padding: '1rem 1.25rem', background: 'rgba(248,248,246,0.6)', border: '1px solid rgba(0,0,0,0.06)', borderRadius: '0.75rem' }}>
<p style={{ fontSize: '0.75rem', fontWeight: 600, color: '#374151', marginBottom: '0.75rem', marginTop: 0 }}>Add Pricing Item</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 120px', gap: '0.5rem', alignItems: 'flex-end' }}>
<div>
<label style={labelStyle}>ID (slug)</label>
<input style={formInputStyle} placeholder="e.g. wifi-module" value={newItem.id} onChange={(e) => setNewItem((p) => ({ ...p, id: e.target.value }))} />
</div>
<div>
<label style={labelStyle}>Label</label>
<input style={formInputStyle} placeholder="e.g. Wi-Fi Module" value={newItem.label} onChange={(e) => setNewItem((p) => ({ ...p, label: e.target.value }))} />
</div>
<div>
<label style={labelStyle}>Price (AED)</label>
<input style={formInputStyle} type="number" min="0" placeholder="5000" value={newItem.price} onChange={(e) => setNewItem((p) => ({ ...p, price: e.target.value }))} />
</div>
</div>
{addItemError && <p style={errorTextStyle}>{addItemError}</p>}
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.75rem', justifyContent: 'flex-end' }}>
<button onClick={() => pricingStore.getState().resetPrices()} style={dangerBtnStyle}>Reset to Defaults</button>
<button onClick={handleAddItem} style={secondaryBtnStyle}>Add Item</button>
<button onClick={handleSavePrices} style={{ ...primaryBtnStyle, background: priceSaved ? 'rgba(34,197,94,0.08)' : 'rgba(59,130,246,0.08)', color: priceSaved ? '#16a34a' : '#2563eb' }}>
{priceSaved ? 'Saved!' : 'Save Prices'}
</button>
</div>
</div>
</div>
)}
{/* ===== PERSONAS TAB ===== */}
{activeTab === 'personas' && (
<div>
{!isPersonaHydrated ? (
<p style={{ color: '#64748b', fontSize: '0.85rem' }}>Loading personas</p>
) : (
<TableCard>
<TableHeader cols="1fr 80px 80px 56px" labels={['Persona', 'Torso', 'Legs', '']} />
{personas.map((p, i) => (
<div key={p.id} style={{ display: 'grid', gridTemplateColumns: '1fr 80px 80px 56px', padding: '0.875rem 1.25rem', alignItems: 'center', borderBottom: i < personas.length - 1 ? '1px solid rgba(0,0,0,0.04)' : 'none' }}>
<div>
<div style={{ fontSize: '0.85rem', color: '#374151', fontWeight: 500 }}>{p.label}</div>
<div style={{ fontSize: '0.7rem', color: '#94a3b8' }}>{p.description}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
<div style={{ width: 20, height: 20, borderRadius: 4, backgroundColor: p.colors.torso, border: '1px solid rgba(0,0,0,0.1)', flexShrink: 0 }} />
<span style={{ fontSize: '0.7rem', color: '#94a3b8', fontFamily: 'monospace' }}>{p.colors.torso}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
<div style={{ width: 20, height: 20, borderRadius: 4, backgroundColor: p.colors.legs, border: '1px solid rgba(0,0,0,0.1)', flexShrink: 0 }} />
<span style={{ fontSize: '0.7rem', color: '#94a3b8', fontFamily: 'monospace' }}>{p.colors.legs}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'center' }}>
{p.id !== 'none' && (
<button onClick={() => personaStore.getState().removePersona(p.id)} style={deleteBtnStyle} title="Remove persona"></button>
)}
</div>
</div>
))}
</TableCard>
)}
{/* Add new persona */}
<div style={{ marginTop: '1rem', padding: '1rem 1.25rem', background: 'rgba(248,248,246,0.6)', border: '1px solid rgba(0,0,0,0.06)', borderRadius: '0.75rem' }}>
<p style={{ fontSize: '0.75rem', fontWeight: 600, color: '#374151', marginBottom: '0.75rem', marginTop: 0 }}>Add Persona</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem' }}>
<div>
<label style={labelStyle}>Label</label>
<input style={formInputStyle} placeholder="e.g. Security Guard" value={newPersona.label} onChange={(e) => setNewPersona((p) => ({ ...p, label: e.target.value }))} />
</div>
<div>
<label style={labelStyle}>Description</label>
<input style={formInputStyle} placeholder="e.g. Dark tactical suit" value={newPersona.description} onChange={(e) => setNewPersona((p) => ({ ...p, description: e.target.value }))} />
</div>
<div>
<label style={labelStyle}>Torso Color</label>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<input type="color" value={newPersona.torso} onChange={(e) => setNewPersona((p) => ({ ...p, torso: e.target.value }))} style={{ width: 36, height: 32, border: '1px solid rgba(0,0,0,0.1)', borderRadius: 4, cursor: 'pointer', padding: 2 }} />
<input style={{ ...formInputStyle, fontFamily: 'monospace', flex: 1 }} value={newPersona.torso} onChange={(e) => setNewPersona((p) => ({ ...p, torso: e.target.value }))} />
</div>
</div>
<div>
<label style={labelStyle}>Legs Color</label>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<input type="color" value={newPersona.legs} onChange={(e) => setNewPersona((p) => ({ ...p, legs: e.target.value }))} style={{ width: 36, height: 32, border: '1px solid rgba(0,0,0,0.1)', borderRadius: 4, cursor: 'pointer', padding: 2 }} />
<input style={{ ...formInputStyle, fontFamily: 'monospace', flex: 1 }} value={newPersona.legs} onChange={(e) => setNewPersona((p) => ({ ...p, legs: e.target.value }))} />
</div>
</div>
</div>
{personaError && <p style={errorTextStyle}>{personaError}</p>}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem', marginTop: '0.75rem' }}>
<button onClick={() => personaStore.getState().resetPersonas()} style={dangerBtnStyle}>Reset to Defaults</button>
<button onClick={handleAddPersona} style={{ ...primaryBtnStyle, background: personaSaved ? 'rgba(34,197,94,0.08)' : 'rgba(59,130,246,0.08)', color: personaSaved ? '#16a34a' : '#2563eb' }}>
{personaSaved ? 'Added!' : 'Add Persona'}
</button>
</div>
</div>
</div>
)}
{/* ===== ORDERS TAB ===== */}
{activeTab === 'orders' && (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '0.75rem' }}>
<button onClick={loadOrders} disabled={ordersLoading} style={secondaryBtnStyle}>
{ordersLoading ? 'Loading…' : 'Refresh'}
</button>
</div>
{ordersError && <p style={errorTextStyle}>{ordersError}</p>}
{!ordersLoading && orders.length === 0 && !ordersError && (
<p style={{ color: '#94a3b8', fontSize: '0.85rem', textAlign: 'center', padding: '2rem' }}>No orders found.</p>
)}
{orders.length > 0 && (
<TableCard>
<TableHeader cols="1fr 120px 90px 100px 28px" labels={['Customer', 'Amount', 'Status', 'Date', '']} />
{orders.map((order, i) => (
<OrderRow
key={order.id}
order={order}
isLast={i === orders.length - 1}
formatAmount={formatAmount}
formatDate={formatDate}
/>
))}
</TableCard>
)}
</div>
)}
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1.5rem', justifyContent: 'flex-end' }}>
<button
onClick={handleReset}
style={{
padding: '0.6rem 1.25rem',
borderRadius: '0.375rem',
border: '1px solid rgba(239, 68, 68, 0.2)',
background: 'rgba(239, 68, 68, 0.05)',
color: '#ef4444',
cursor: 'pointer',
fontSize: '0.8rem',
transition: 'all 0.2s ease',
}}
>
Reset to Defaults
</button>
<button
onClick={handleSave}
style={{
padding: '0.6rem 1.5rem',
borderRadius: '0.375rem',
border: '1px solid rgba(59, 130, 246, 0.3)',
background: saved ? 'rgba(34, 197, 94, 0.08)' : 'rgba(59, 130, 246, 0.08)',
color: saved ? '#16a34a' : '#2563eb',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
transition: 'all 0.2s ease',
}}
>
{saved ? 'Saved!' : 'Save Prices'}
</button>
{/* CHANGE PASSWORD MODAL */}
{showPwModal && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 50 }}>
<div style={{ background: '#fff', borderRadius: '1rem', padding: '1.5rem', width: '100%', maxWidth: '380px', boxShadow: '0 20px 60px rgba(0,0,0,0.15)' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700, color: '#1a1a2e', margin: '0 0 1.25rem' }}>Change Password</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{(['current', 'next', 'confirm'] as const).map((field) => (
<div key={field}>
<label style={labelStyle}>{field === 'current' ? 'Current Password' : field === 'next' ? 'New Password' : 'Confirm New Password'}</label>
<input
type="password"
autoComplete={field === 'current' ? 'current-password' : 'new-password'}
value={pwForm[field]}
onChange={(e) => setPwForm((p) => ({ ...p, [field]: e.target.value }))}
style={formInputStyle}
/>
</div>
))}
{pwError && <p style={errorTextStyle}>{pwError}</p>}
{pwSaved && <p style={{ color: '#16a34a', fontSize: '0.8rem' }}>Password updated!</p>}
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem', justifyContent: 'flex-end' }}>
<button onClick={() => { setShowPwModal(false); setPwError(''); }} style={ghostBtnStyle}>Cancel</button>
<button onClick={handleChangePassword} style={primaryBtnStyle}>Update</button>
</div>
</div>
</div>
)}
</div>
);
}
// ===== SUB-COMPONENTS =====
function OrderRow({
order,
isLast,
formatAmount,
formatDate,
}: {
order: Order;
isLast: boolean;
formatAmount: (amount: number, currency: string) => string;
formatDate: (ts: number) => string;
}) {
const [expanded, setExpanded] = useState(false);
const m = order.metadata;
const name = m.customerName || '—';
const email = m.customerEmail || '';
const phone = m.customerPhone || '';
const address = [m.customerAddress, m.customerCity, m.customerCountry, m.customerPostalCode]
.filter(Boolean).join(', ') || '';
const persona = m.persona || '';
const color = m.color || '';
return (
<div style={{ borderBottom: isLast ? 'none' : '1px solid rgba(0,0,0,0.04)' }}>
{/* Main row */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 120px 90px 100px 28px', padding: '0.75rem 1.25rem', alignItems: 'center' }}>
<div>
<div style={{ fontSize: '0.85rem', color: '#374151', fontWeight: 500 }}>{name}</div>
{email && <div style={{ fontSize: '0.7rem', color: '#94a3b8' }}>{email}</div>}
<div style={{ fontSize: '0.65rem', color: '#cbd5e1', fontFamily: 'monospace', marginTop: '0.1rem' }}>{order.id}</div>
</div>
<div style={{ fontSize: '0.85rem', color: '#374151', fontWeight: 500 }}>{formatAmount(order.amount, order.currency)}</div>
<div>
<span style={{
display: 'inline-block',
padding: '0.15rem 0.5rem',
borderRadius: '999px',
fontSize: '0.65rem',
fontWeight: 600,
textTransform: 'uppercase',
background: order.status === 'succeeded' ? 'rgba(34,197,94,0.1)' : 'rgba(148,163,184,0.15)',
color: order.status === 'succeeded' ? '#16a34a' : '#64748b',
}}>
{order.status}
</span>
</div>
<div style={{ fontSize: '0.75rem', color: '#94a3b8' }}>{formatDate(order.created)}</div>
<button
onClick={() => setExpanded((v) => !v)}
title={expanded ? 'Collapse' : 'Show details'}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#94a3b8', fontSize: '0.7rem', padding: '2px 4px', borderRadius: 4 }}
>
{expanded ? '▲' : '▼'}
</button>
</div>
{/* Expanded customer details */}
{expanded && (
<div style={{ padding: '0 1.25rem 0.875rem 1.25rem' }}>
<div style={{ background: 'rgba(248,248,246,0.8)', borderRadius: '0.5rem', padding: '0.75rem 1rem', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem 1.5rem' }}>
{[
{ label: 'Full Name', value: m.customerName },
{ label: 'Email', value: m.customerEmail },
{ label: 'Phone', value: m.customerPhone },
{ label: 'Address', value: address },
{ label: 'Persona', value: persona },
{ label: 'Color', value: color },
].map(({ label, value }) =>
value ? (
<div key={label}>
<div style={{ fontSize: '0.65rem', fontWeight: 600, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{label}</div>
<div style={{ fontSize: '0.8rem', color: '#374151', marginTop: '0.125rem', wordBreak: 'break-word' }}>{value}</div>
</div>
) : null
)}
</div>
</div>
)}
</div>
);
}
function StatCard({ label, value }: { label: string; value: string }) {
return (
<div style={{ background: 'rgba(255,255,255,0.95)', border: '1px solid rgba(0,0,0,0.06)', borderRadius: '0.75rem', padding: '1rem 1.25rem' }}>
<div style={{ fontSize: '0.7rem', fontWeight: 600, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.25rem' }}>{label}</div>
<div style={{ fontSize: '1.25rem', fontWeight: 700, color: '#1a1a2e' }}>{value}</div>
</div>
);
}
function TableCard({ children }: { children: React.ReactNode }) {
return (
<div style={{ background: 'rgba(255,255,255,0.95)', backdropFilter: 'blur(20px)', border: '1px solid rgba(0,0,0,0.06)', borderRadius: '0.75rem', overflow: 'hidden' }}>
{children}
</div>
);
}
function TableHeader({ cols, labels }: { cols: string; labels: string[] }) {
return (
<div style={{ display: 'grid', gridTemplateColumns: cols, padding: '0.6rem 1.25rem', borderBottom: '1px solid rgba(0,0,0,0.04)', background: 'rgba(248,248,246,0.5)' }}>
{labels.map((l) => (
<span key={l} style={{ fontSize: '0.65rem', fontWeight: 600, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em' }}>{l}</span>
))}
</div>
);
}
// ===== STYLES =====
const pageStyle: React.CSSProperties = {
minHeight: '100vh',
background: '#ffffff',
display: 'flex',
alignItems: 'center',
alignItems: 'flex-start',
justifyContent: 'center',
padding: '2rem',
fontFamily: 'system-ui, -apple-system, sans-serif',
@ -210,5 +528,102 @@ const pageStyle: React.CSSProperties = {
const containerStyle: React.CSSProperties = {
width: '100%',
maxWidth: '640px',
maxWidth: '860px',
paddingTop: '1rem',
};
const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: '0.7rem',
fontWeight: 600,
color: '#374151',
marginBottom: '0.25rem',
letterSpacing: '0.02em',
};
const formInputStyle: React.CSSProperties = {
width: '100%',
padding: '0.5rem 0.75rem',
borderRadius: '0.375rem',
border: '1px solid rgba(0,0,0,0.1)',
background: '#ffffff',
color: '#1a1a2e',
fontSize: '0.8rem',
outline: 'none',
boxSizing: 'border-box',
};
const tableInputStyle: React.CSSProperties = {
width: '120px',
padding: '0.4rem 0.6rem',
borderRadius: '0.375rem',
border: '1px solid rgba(0,0,0,0.1)',
background: '#ffffff',
color: '#1a1a2e',
fontSize: '0.8rem',
fontFamily: 'monospace',
textAlign: 'right' as const,
outline: 'none',
};
const ghostBtnStyle: React.CSSProperties = {
padding: '0.4rem 0.875rem',
borderRadius: '0.375rem',
border: '1px solid rgba(0,0,0,0.1)',
background: 'transparent',
color: '#374151',
fontSize: '0.75rem',
cursor: 'pointer',
};
const primaryBtnStyle: React.CSSProperties = {
padding: '0.5rem 1.25rem',
borderRadius: '0.375rem',
border: '1px solid rgba(59,130,246,0.3)',
background: 'rgba(59,130,246,0.08)',
color: '#2563eb',
fontSize: '0.8rem',
fontWeight: 600,
cursor: 'pointer',
};
const secondaryBtnStyle: React.CSSProperties = {
padding: '0.5rem 1rem',
borderRadius: '0.375rem',
border: '1px solid rgba(0,0,0,0.1)',
background: 'rgba(248,248,246,0.8)',
color: '#374151',
fontSize: '0.8rem',
cursor: 'pointer',
};
const dangerBtnStyle: React.CSSProperties = {
padding: '0.5rem 1rem',
borderRadius: '0.375rem',
border: '1px solid rgba(239,68,68,0.2)',
background: 'rgba(239,68,68,0.05)',
color: '#ef4444',
fontSize: '0.8rem',
cursor: 'pointer',
};
const deleteBtnStyle: React.CSSProperties = {
width: '24px',
height: '24px',
borderRadius: '50%',
border: '1px solid rgba(239,68,68,0.2)',
background: 'rgba(239,68,68,0.06)',
color: '#ef4444',
fontSize: '0.65rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 0,
};
const errorTextStyle: React.CSSProperties = {
color: '#dc2626',
fontSize: '0.75rem',
margin: '0.5rem 0 0',
};

View File

@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { jwtVerify } from 'jose';
import bcrypt from 'bcryptjs';
import { prisma } from '@/lib/prisma';
export async function POST(request: Request) {
try {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const jwtSecret = process.env.ADMIN_JWT_SECRET;
if (!jwtSecret) return NextResponse.json({ error: 'Server error' }, { status: 500 });
const secret = new TextEncoder().encode(jwtSecret);
const { payload } = await jwtVerify(token, secret);
const { currentPassword, newPassword } = await request.json();
if (!currentPassword || !newPassword || newPassword.length < 8) {
return NextResponse.json({ error: 'Invalid input. New password must be at least 8 characters.' }, { status: 400 });
}
const user = await prisma.adminUser.findUnique({ where: { id: payload.sub as string } });
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 });
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!valid) return NextResponse.json({ error: 'Current password is incorrect' }, { status: 401 });
const newHash = await bcrypt.hash(newPassword, 12);
await prisma.adminUser.update({ where: { id: user.id }, data: { passwordHash: newHash } });
return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@ -0,0 +1,49 @@
import { NextResponse } from 'next/server';
import bcrypt from 'bcryptjs';
import { SignJWT } from 'jose';
import { prisma } from '@/lib/prisma';
export async function POST(request: Request) {
try {
const { username, password } = await request.json();
if (!username || !password) {
return NextResponse.json({ error: 'Username and password are required' }, { status: 400 });
}
const user = await prisma.adminUser.findUnique({ where: { username } });
if (!user) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
const jwtSecret = process.env.ADMIN_JWT_SECRET;
if (!jwtSecret) {
return NextResponse.json({ error: 'Server configuration error' }, { status: 500 });
}
const secret = new TextEncoder().encode(jwtSecret);
const token = await new SignJWT({ sub: user.id, username: user.username })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(secret);
const response = NextResponse.json({ success: true });
response.cookies.set('admin_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
});
return response;
} catch {
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@ -0,0 +1,13 @@
import { NextResponse } from 'next/server';
export async function POST() {
const response = NextResponse.json({ success: true });
response.cookies.set('admin_token', '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 0,
path: '/',
});
return response;
}

View File

@ -0,0 +1,32 @@
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2026-03-25.dahlia' as Parameters<typeof Stripe>[1]['apiVersion'],
});
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') ?? '20', 10), 100);
try {
const paymentIntents = await stripe.paymentIntents.list({
limit,
expand: ['data.latest_charge'],
});
const orders = paymentIntents.data.map((pi) => ({
id: pi.id,
amount: pi.amount,
currency: pi.currency,
status: pi.status,
created: pi.created,
metadata: pi.metadata,
}));
return NextResponse.json({ orders, hasMore: paymentIntents.has_more });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,26 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { jwtVerify } from 'jose';
export async function GET() {
try {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
if (!token) {
return NextResponse.json({ authenticated: false }, { status: 401 });
}
const jwtSecret = process.env.ADMIN_JWT_SECRET;
if (!jwtSecret) {
return NextResponse.json({ authenticated: false }, { status: 500 });
}
const secret = new TextEncoder().encode(jwtSecret);
await jwtVerify(token, secret);
return NextResponse.json({ authenticated: true });
} catch {
return NextResponse.json({ authenticated: false }, { status: 401 });
}
}

View File

@ -0,0 +1,39 @@
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
if (!process.env.STRIPE_SECRET_KEY) {
console.error('STRIPE_SECRET_KEY is not set in environment variables');
}
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || '', {
apiVersion: '2026-03-25.dahlia',
});
export async function POST(request: Request) {
try {
const { amount, currency, metadata, receiptEmail } = await request.json();
if (!amount || typeof amount !== 'number' || amount <= 0) {
return NextResponse.json({ error: 'Invalid amount' }, { status: 400 });
}
// Stripe expects amount in the smallest currency unit (fils for AED)
const amountInFils = Math.round(amount * 100);
const paymentIntent = await stripe.paymentIntents.create({
amount: amountInFils,
currency: currency || 'aed',
metadata: metadata || {},
...(receiptEmail ? { receipt_email: receiptEmail } : {}),
automatic_payment_methods: { enabled: true },
});
return NextResponse.json({
clientSecret: paymentIntent.client_secret,
paymentIntentId: paymentIntent.id,
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Internal server error';
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -1,12 +1,16 @@
'use client';
import { useCallback, useEffect } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import { Elements } from '@stripe/react-stripe-js';
import { useOrderStore, orderStore, type CheckoutStep } from '@/store/useOrderStore';
import { ShippingStep } from './checkout/ShippingStep';
import { PaymentStep } from './checkout/PaymentStep';
import { ReviewStep } from './checkout/ReviewStep';
import { ConfirmationStep } from './checkout/ConfirmationStep';
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
const STEPS: { id: CheckoutStep; label: string }[] = [
{ id: 'shipping', label: 'Shipping' },
{ id: 'payment', label: 'Payment' },
@ -19,6 +23,10 @@ function getStepIndex(step: CheckoutStep): number {
export function CheckoutOverlay() {
const step = useOrderStore((s) => s.step);
const clientSecret = useOrderStore((s) => s.payment.clientSecret);
const paymentStatus = useOrderStore((s) => s.payment.status);
const paymentError = useOrderStore((s) => s.payment.errorMessage);
const [isCreatingIntent, setIsCreatingIntent] = useState(false);
const handleClose = useCallback(() => {
orderStore.getState().resetOrder();
@ -33,6 +41,16 @@ export function CheckoutOverlay() {
}
}, [step]);
// Create payment intent when entering the payment step (only once)
useEffect(() => {
if (step === 'payment' && !clientSecret && !isCreatingIntent && paymentStatus !== 'failed') {
setIsCreatingIntent(true);
orderStore.getState().createPaymentIntent().finally(() => {
setIsCreatingIntent(false);
});
}
}, [step, clientSecret, isCreatingIntent, paymentStatus]);
// Close on Escape key
useEffect(() => {
if (step === 'config') return;
@ -153,8 +171,55 @@ export function CheckoutOverlay() {
{/* Step Content */}
<div style={{ padding: '1.5rem' }}>
{step === 'shipping' && <ShippingStep />}
{step === 'payment' && <PaymentStep />}
{(step === 'payment' || step === 'review') && clientSecret ? (
<Elements
stripe={stripePromise}
options={{
clientSecret,
appearance: {
theme: 'stripe',
variables: {
colorPrimary: '#2563eb',
borderRadius: '0.375rem',
fontFamily: 'inherit',
},
},
}}
>
{/* Keep PaymentElement mounted (hidden) during review so confirmPayment works */}
<div style={{ display: step === 'payment' ? 'block' : 'none' }}>
<PaymentStep />
</div>
{step === 'review' && <ReviewStep />}
</Elements>
) : (step === 'payment' || step === 'review') && !clientSecret ? (
<div style={{ textAlign: 'center', padding: '2rem', fontSize: '0.85rem' }}>
{paymentStatus === 'failed' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', alignItems: 'center' }}>
<div style={{ color: '#dc2626' }}>{paymentError || 'Failed to initialize payment'}</div>
<button
onClick={() => {
orderStore.getState().setPayment({ status: 'idle', errorMessage: '' });
}}
style={{
padding: '0.5rem 1rem',
borderRadius: '0.375rem',
border: '1px solid rgba(59, 130, 246, 0.5)',
background: 'rgba(59, 130, 246, 0.08)',
color: '#2563eb',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
}}
>
Retry
</button>
</div>
) : (
<span style={{ color: '#94a3b8' }}>Initializing secure payment...</span>
)}
</div>
) : null}
{step === 'confirmed' && <ConfirmationStep />}
</div>
</div>

View File

@ -1,44 +1,22 @@
'use client';
import { useCallback, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { configStore, useConfigStore } from '@/store/useConfigStore';
import { personaStore, usePersonaStore } from '@/store/usePersonaStore';
import { PricingEngine } from './PricingEngine';
// Persona attire options with visual metadata
const PERSONA_OPTIONS = [
{
id: 'none',
label: 'Default',
description: 'Original robot appearance',
colors: { torso: '#3b82f6', legs: '#3b82f6' },
},
{
id: 'emarati-kandura',
label: 'Emarati Kandura',
description: 'Traditional white robe attire',
colors: { torso: '#f8fafc', legs: '#f8fafc' },
},
{
id: 'industrial-vest',
label: 'Industrial Vest',
description: 'High-visibility safety vest',
colors: { torso: '#f59e0b', legs: '#3b82f6' },
},
{
id: 'business-suit',
label: 'Business Suit',
description: 'Professional navy suit',
colors: { torso: '#1e293b', legs: '#1e293b' },
},
] as const;
export function ConfigPanel() {
const activeColors = useConfigStore((s) => s.activeColors);
const activePersona = useConfigStore((s) => s.activePersonaAttire);
const personas = usePersonaStore((s) => s.personas);
const colorsSectionRef = useRef<HTMLElement>(null);
const personaSectionRef = useRef<HTMLElement>(null);
useEffect(() => {
personaStore.getState().hydrate();
}, []);
const handleColorChange = useCallback((key: 'primary' | 'secondary' | 'accent', value: string) => {
configStore.getState().setColors({ [key]: value });
}, []);
@ -80,7 +58,7 @@ export function ConfigPanel() {
>
<h3 style={sectionTitleStyle}>Persona Attire</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{PERSONA_OPTIONS.map((persona) => {
{personas.map((persona) => {
const isActive = activePersona === persona.id;
return (
<button

View File

@ -1,89 +1,33 @@
'use client';
import { useState, useCallback } from 'react';
import { orderStore, type PaymentInfo } from '@/store/useOrderStore';
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '0.6rem 0.75rem',
borderRadius: '0.375rem',
border: '1px solid rgba(0, 0, 0, 0.08)',
background: 'rgba(255, 255, 255, 0.8)',
color: '#1a1a2e',
fontSize: '0.8rem',
outline: 'none',
transition: 'border-color 0.2s ease',
};
const labelStyle: React.CSSProperties = {
fontSize: '0.7rem',
fontWeight: 500,
color: '#94a3b8',
marginBottom: '0.3rem',
display: 'block',
};
const errorStyle: React.CSSProperties = {
fontSize: '0.65rem',
color: '#ef4444',
marginTop: '0.2rem',
};
interface FormErrors {
[key: string]: string;
}
function formatCardNumber(value: string): string {
const digits = value.replace(/\D/g, '').slice(0, 16);
return digits.replace(/(\d{4})(?=\d)/g, '$1 ');
}
function formatExpiry(value: string): string {
const digits = value.replace(/\D/g, '').slice(0, 4);
if (digits.length > 2) {
return `${digits.slice(0, 2)}/${digits.slice(2)}`;
}
return digits;
}
import { useState } from 'react';
import { PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
import { orderStore } from '@/store/useOrderStore';
export function PaymentStep() {
const [form, setForm] = useState<PaymentInfo>({
cardNumber: '',
expiry: '',
cvv: '',
nameOnCard: '',
});
const [errors, setErrors] = useState<FormErrors>({});
const stripe = useStripe();
const elements = useElements();
const [errorMsg, setErrorMsg] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleChange = useCallback((field: keyof PaymentInfo, value: string) => {
let processed = value;
if (field === 'cardNumber') processed = formatCardNumber(value);
if (field === 'expiry') processed = formatExpiry(value);
if (field === 'cvv') processed = value.replace(/\D/g, '').slice(0, 3);
const handleSubmit = async () => {
if (!stripe || !elements) return;
setForm((prev) => ({ ...prev, [field]: processed }));
setErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
}, []);
setIsLoading(true);
setErrorMsg('');
const validate = (): boolean => {
const errs: FormErrors = {};
const digits = form.cardNumber.replace(/\s/g, '');
if (digits.length < 16) errs.cardNumber = 'Enter a valid 16-digit card number';
if (form.expiry.length < 5) errs.expiry = 'Enter a valid expiry (MM/YY)';
if (form.cvv.length < 3) errs.cvv = 'Enter a valid 3-digit CVV';
if (!form.nameOnCard.trim()) errs.nameOnCard = 'Name on card is required';
setErrors(errs);
return Object.keys(errs).length === 0;
};
// Validate the payment element first
const { error: submitError } = await elements.submit();
if (submitError) {
setErrorMsg(submitError.message || 'Validation failed');
setIsLoading(false);
return;
}
const handleSubmit = () => {
if (!validate()) return;
orderStore.getState().setPayment(form);
// Move to review — actual confirmation happens on "Place Order"
orderStore.getState().setPayment({ status: 'idle' });
orderStore.getState().setStep('review');
setIsLoading(false);
};
return (
@ -92,90 +36,49 @@ export function PaymentStep() {
Payment Details
</h3>
<div style={{
padding: '1rem',
borderRadius: '0.5rem',
background: '#fff',
border: '1px solid rgba(0, 0, 0, 0.08)',
}}>
<PaymentElement
options={{
layout: 'tabs',
}}
/>
</div>
{errorMsg && (
<div style={{
padding: '0.6rem 0.75rem',
borderRadius: '0.375rem',
background: 'rgba(245, 158, 11, 0.06)',
border: '1px solid rgba(245, 158, 11, 0.15)',
fontSize: '0.7rem',
color: '#d97706',
background: 'rgba(239, 68, 68, 0.06)',
border: '1px solid rgba(239, 68, 68, 0.15)',
fontSize: '0.75rem',
color: '#dc2626',
}}>
This is a demo checkout. No real payment will be processed.
</div>
<div>
<label style={labelStyle}>Card Number</label>
<input
type="text"
value={form.cardNumber}
onChange={(e) => handleChange('cardNumber', e.target.value)}
placeholder="4242 4242 4242 4242"
style={{ ...inputStyle, fontFamily: 'monospace', letterSpacing: '0.1em', borderColor: errors.cardNumber ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = errors.cardNumber ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
/>
{errors.cardNumber && <div style={errorStyle}>{errors.cardNumber}</div>}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<div>
<label style={labelStyle}>Expiry Date</label>
<input
type="text"
value={form.expiry}
onChange={(e) => handleChange('expiry', e.target.value)}
placeholder="MM/YY"
style={{ ...inputStyle, fontFamily: 'monospace', borderColor: errors.expiry ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = errors.expiry ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
/>
{errors.expiry && <div style={errorStyle}>{errors.expiry}</div>}
</div>
<div>
<label style={labelStyle}>CVV</label>
<input
type="text"
value={form.cvv}
onChange={(e) => handleChange('cvv', e.target.value)}
placeholder="123"
style={{ ...inputStyle, fontFamily: 'monospace', borderColor: errors.cvv ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = errors.cvv ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
/>
{errors.cvv && <div style={errorStyle}>{errors.cvv}</div>}
</div>
</div>
<div>
<label style={labelStyle}>Name on Card</label>
<input
type="text"
value={form.nameOnCard}
onChange={(e) => handleChange('nameOnCard', e.target.value)}
placeholder="John Doe"
style={{ ...inputStyle, borderColor: errors.nameOnCard ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = errors.nameOnCard ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
/>
{errors.nameOnCard && <div style={errorStyle}>{errors.nameOnCard}</div>}
{errorMsg}
</div>
)}
<button
onClick={handleSubmit}
disabled={!stripe || isLoading}
style={{
marginTop: '0.5rem',
padding: '0.75rem',
borderRadius: '0.375rem',
border: '1px solid rgba(59, 130, 246, 0.5)',
background: 'rgba(59, 130, 246, 0.08)',
background: isLoading ? 'rgba(59, 130, 246, 0.15)' : 'rgba(59, 130, 246, 0.08)',
color: '#2563eb',
cursor: 'pointer',
cursor: isLoading ? 'wait' : 'pointer',
fontSize: '0.85rem',
fontWeight: 600,
transition: 'all 0.2s ease',
}}
>
Review Order
{isLoading ? 'Validating...' : 'Review Order'}
</button>
</div>
);

View File

@ -1,6 +1,7 @@
'use client';
import { useState } from 'react';
import { useStripe, useElements } from '@stripe/react-stripe-js';
import { useOrderStore, orderStore } from '@/store/useOrderStore';
function formatAED(price: number): string {
@ -8,16 +9,41 @@ function formatAED(price: number): string {
}
export function ReviewStep() {
const stripe = useStripe();
const elements = useElements();
const shipping = useOrderStore((s) => s.shipping);
const orderTotal = useOrderStore((s) => s.orderTotal);
const personaSummary = useOrderStore((s) => s.personaSummary);
const colorSummary = useOrderStore((s) => s.colorSummary);
const clientSecret = useOrderStore((s) => s.payment.clientSecret);
const [isProcessing, setIsProcessing] = useState(false);
const [errorMsg, setErrorMsg] = useState('');
const handlePlaceOrder = async () => {
if (!stripe || !elements || !clientSecret) return;
setIsProcessing(true);
// Simulate payment processing delay
await new Promise((resolve) => setTimeout(resolve, 1500));
setErrorMsg('');
const { error } = await stripe.confirmPayment({
elements,
clientSecret,
confirmParams: {
return_url: `${window.location.origin}?order=confirmed`,
receipt_email: shipping.email,
},
redirect: 'if_required',
});
if (error) {
setErrorMsg(error.message || 'Payment failed. Please try again.');
orderStore.getState().setPayment({ status: 'failed', errorMessage: error.message || '' });
setIsProcessing(false);
return;
}
// Payment succeeded
orderStore.getState().setPayment({ status: 'succeeded' });
orderStore.getState().placeOrder();
};
@ -69,10 +95,24 @@ export function ReviewStep() {
</div>
</SummarySection>
{/* Error message */}
{errorMsg && (
<div style={{
padding: '0.6rem 0.75rem',
borderRadius: '0.375rem',
background: 'rgba(239, 68, 68, 0.06)',
border: '1px solid rgba(239, 68, 68, 0.15)',
fontSize: '0.75rem',
color: '#dc2626',
}}>
{errorMsg}
</div>
)}
{/* Place Order Button */}
<button
onClick={handlePlaceOrder}
disabled={isProcessing}
disabled={isProcessing || !stripe}
style={{
marginTop: '0.25rem',
padding: '0.85rem',

15
src/lib/prisma.ts Normal file
View File

@ -0,0 +1,15 @@
import { PrismaClient } from '@/generated/prisma/client';
import { PrismaLibSql } from '@prisma/adapter-libsql';
import path from 'path';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
function createPrismaClient() {
const dbPath = path.resolve(process.cwd(), 'prisma/lootah.db');
const adapter = new PrismaLibSql({ url: `file:${dbPath}` });
return new PrismaClient({ adapter } as ConstructorParameters<typeof PrismaClient>[0]);
}
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

34
src/middleware.ts Normal file
View File

@ -0,0 +1,34 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Allow login page through
if (pathname.startsWith('/admin/login')) return NextResponse.next();
const token = request.cookies.get('admin_token')?.value;
if (!token) {
return NextResponse.redirect(new URL('/admin/login/', request.url));
}
const jwtSecret = process.env.ADMIN_JWT_SECRET;
if (!jwtSecret) {
return NextResponse.redirect(new URL('/admin/login/', request.url));
}
try {
const secret = new TextEncoder().encode(jwtSecret);
await jwtVerify(token, secret);
return NextResponse.next();
} catch {
const response = NextResponse.redirect(new URL('/admin/login/', request.url));
response.cookies.delete('admin_token');
return response;
}
}
export const config = {
matcher: ['/admin/:path*'],
};

View File

@ -20,8 +20,9 @@ describe('useOrderStore', () => {
it('should have empty payment info', () => {
const { payment } = orderStore.getState();
expect(payment.cardNumber).toBe('');
expect(payment.cvv).toBe('');
expect(payment.paymentIntentId).toBe('');
expect(payment.clientSecret).toBe('');
expect(payment.status).toBe('idle');
});
it('should have no order ID', () => {
@ -69,16 +70,15 @@ describe('useOrderStore', () => {
describe('setPayment', () => {
it('should store payment information', () => {
orderStore.getState().setPayment({
cardNumber: '4242 4242 4242 4242',
expiry: '12/28',
cvv: '123',
nameOnCard: 'John Doe',
paymentIntentId: 'pi_test_123',
clientSecret: 'pi_test_123_secret_abc',
status: 'succeeded',
});
const { payment } = orderStore.getState();
expect(payment.cardNumber).toBe('4242 4242 4242 4242');
expect(payment.expiry).toBe('12/28');
expect(payment.nameOnCard).toBe('John Doe');
expect(payment.paymentIntentId).toBe('pi_test_123');
expect(payment.clientSecret).toBe('pi_test_123_secret_abc');
expect(payment.status).toBe('succeeded');
});
});

View File

@ -14,10 +14,10 @@ export interface ShippingInfo {
}
export interface PaymentInfo {
cardNumber: string;
expiry: string;
cvv: string;
nameOnCard: string;
paymentIntentId: string;
clientSecret: string;
status: 'idle' | 'processing' | 'succeeded' | 'failed';
errorMessage: string;
}
export interface OrderState {
@ -33,9 +33,10 @@ export interface OrderState {
export interface OrderActions {
setStep: (step: CheckoutStep) => void;
setShipping: (shipping: ShippingInfo) => void;
setPayment: (payment: PaymentInfo) => void;
setPayment: (payment: Partial<PaymentInfo>) => void;
setOrderTotal: (total: number) => void;
setConfigSummary: (persona: string, color: string) => void;
createPaymentIntent: () => Promise<string | null>;
placeOrder: () => void;
resetOrder: () => void;
}
@ -53,10 +54,10 @@ const emptyShipping: ShippingInfo = {
};
const emptyPayment: PaymentInfo = {
cardNumber: '',
expiry: '',
cvv: '',
nameOnCard: '',
paymentIntentId: '',
clientSecret: '',
status: 'idle',
errorMessage: '',
};
const defaultState: OrderState = {
@ -82,7 +83,9 @@ export const orderStore = createStore<OrderStore>((set) => ({
setShipping: (shipping: ShippingInfo) => set({ shipping }),
setPayment: (payment: PaymentInfo) => set({ payment }),
setPayment: (payment: Partial<PaymentInfo>) => set((state) => ({
payment: { ...state.payment, ...payment },
})),
setOrderTotal: (total: number) => set({ orderTotal: total }),
@ -91,6 +94,47 @@ export const orderStore = createStore<OrderStore>((set) => ({
colorSummary: color,
}),
createPaymentIntent: async (): Promise<string | null> => {
const { orderTotal, personaSummary, colorSummary, shipping } = orderStore.getState();
try {
const res: Response = await fetch('/api/create-payment-intent/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: orderTotal,
currency: 'aed',
receiptEmail: shipping.email || undefined,
metadata: {
persona: personaSummary,
color: colorSummary,
customerName: shipping.name,
customerEmail: shipping.email,
customerPhone: shipping.phone,
customerAddress: shipping.address,
customerCity: shipping.city,
customerCountry: shipping.country,
customerPostalCode: shipping.postalCode,
},
}),
});
const data: { clientSecret: string; paymentIntentId: string; error?: string } = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to create payment intent');
set({
payment: {
paymentIntentId: data.paymentIntentId,
clientSecret: data.clientSecret,
status: 'idle',
errorMessage: '',
},
});
return data.clientSecret;
} catch (err) {
const msg = err instanceof Error ? err.message : 'Payment initialization failed';
set((s) => ({ payment: { ...s.payment, status: 'failed', errorMessage: msg } }));
return null;
}
},
placeOrder: () => set({
orderId: generateOrderId(),
step: 'confirmed',

View File

@ -0,0 +1,140 @@
import { createStore } from 'zustand/vanilla';
import { useSyncExternalStore } from 'react';
export interface PersonaOption {
id: string;
label: string;
description: string;
colors: { torso: string; legs: string };
}
export interface PersonaState {
personas: PersonaOption[];
isHydrated: boolean;
}
export interface PersonaActions {
addPersona: (persona: Omit<PersonaOption, 'id'> & { id?: string }) => void;
removePersona: (id: string) => void;
updatePersona: (id: string, updates: Partial<Omit<PersonaOption, 'id'>>) => void;
resetPersonas: () => void;
hydrate: () => void;
}
export type PersonaStore = PersonaState & PersonaActions;
export const DEFAULT_PERSONAS: PersonaOption[] = [
{
id: 'none',
label: 'Default',
description: 'Original robot appearance',
colors: { torso: '#3b82f6', legs: '#3b82f6' },
},
{
id: 'emarati-kandura',
label: 'Emarati Kandura',
description: 'Traditional white robe attire',
colors: { torso: '#f8fafc', legs: '#f8fafc' },
},
{
id: 'industrial-vest',
label: 'Industrial Vest',
description: 'High-visibility safety vest',
colors: { torso: '#f59e0b', legs: '#3b82f6' },
},
{
id: 'business-suit',
label: 'Business Suit',
description: 'Professional navy suit',
colors: { torso: '#1e293b', legs: '#1e293b' },
},
];
const STORAGE_KEY = 'lootah-personas';
function loadFromStorage(): PersonaOption[] | null {
if (typeof window === 'undefined') return null;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return null;
return parsed;
} catch {
return null;
}
}
function saveToStorage(personas: PersonaOption[]) {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(personas));
} catch {
// Storage unavailable
}
}
function generateId(): string {
return `persona-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}
export const personaStore = createStore<PersonaStore>((set, get) => ({
personas: DEFAULT_PERSONAS,
isHydrated: false,
addPersona: (persona) => {
const newPersona: PersonaOption = {
id: persona.id ?? generateId(),
label: persona.label,
description: persona.description,
colors: persona.colors,
};
set((state) => {
const updated = [...state.personas, newPersona];
saveToStorage(updated);
return { personas: updated };
});
},
removePersona: (id) => {
// Prevent removing the default 'none' persona
if (id === 'none') return;
set((state) => {
const updated = state.personas.filter((p) => p.id !== id);
saveToStorage(updated);
return { personas: updated };
});
},
updatePersona: (id, updates) => {
set((state) => {
const updated = state.personas.map((p) =>
p.id === id ? { ...p, ...updates } : p
);
saveToStorage(updated);
return { personas: updated };
});
},
resetPersonas: () => {
saveToStorage(DEFAULT_PERSONAS);
set({ personas: [...DEFAULT_PERSONAS] });
},
hydrate: () => {
const stored = loadFromStorage();
if (stored && stored.length > 0) {
set({ personas: stored, isHydrated: true });
} else {
set({ isHydrated: true });
}
},
}));
export const usePersonaStore = <T>(selector: (state: PersonaStore) => T): T => {
return useSyncExternalStore(
personaStore.subscribe,
() => selector(personaStore.getState()),
() => selector(personaStore.getState())
);
};

View File

@ -14,6 +14,8 @@ export interface PricingState {
export interface PricingActions {
updatePrice: (itemId: string, newPrice: number) => void;
addItem: (item: PricingItem) => void;
removeItem: (itemId: string) => void;
resetPrices: () => void;
hydrate: () => void;
}
@ -71,6 +73,25 @@ export const pricingStore = createStore<PricingStore>((set, get) => ({
set({ items: [...DEFAULT_ITEMS] });
},
addItem: (item: PricingItem) => {
set((state) => {
if (state.items.some((i) => i.id === item.id)) return state;
const updated = [...state.items, item];
saveToStorage(updated);
return { items: updated };
});
},
removeItem: (itemId: string) => {
// Prevent removing the base robot price
if (itemId === 'base') return;
set((state) => {
const updated = state.items.filter((i) => i.id !== itemId);
saveToStorage(updated);
return { items: updated };
});
},
hydrate: () => {
const stored = loadFromStorage();
if (stored) {

View File

@ -0,0 +1,18 @@
import { createStore } from 'zustand/vanilla';
interface SnapshotStore {
_captureFn: (() => string | null) | null;
registerCapture: (fn: () => string | null) => void;
capture: () => string | null;
}
export const snapshotStore = createStore<SnapshotStore>((set, get) => ({
_captureFn: null,
registerCapture: (fn) => set({ _captureFn: fn }),
capture: () => {
const fn = get()._captureFn;
return fn ? fn() : null;
},
}));