feat: add admin authentication and management features
- 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:
@@ -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',
|
||||
};
|
||||
+570
-155
@@ -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>
|
||||
<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>
|
||||
<Link
|
||||
href="/"
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '0.375rem',
|
||||
border: '1px solid rgba(59, 130, 246, 0.2)',
|
||||
background: 'rgba(59, 130, 246, 0.06)',
|
||||
color: '#2563eb',
|
||||
fontSize: '0.8rem',
|
||||
textDecoration: 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
Back to Configurator
|
||||
</Link>
|
||||
</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>
|
||||
{/* 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>
|
||||
|
||||
{/* Rows */}
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
{/* 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={{
|
||||
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',
|
||||
padding: '0.4rem 0.875rem',
|
||||
borderRadius: '0.375rem',
|
||||
border: 'none',
|
||||
background: activeTab === t ? 'rgba(59,130,246,0.08)' : 'transparent',
|
||||
color: activeTab === t ? '#2563eb' : '#64748b',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: activeTab === t ? 600 : 400,
|
||||
cursor: 'pointer',
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.875rem', color: '#374151', fontWeight: 500 }}>
|
||||
{item.label}
|
||||
</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>
|
||||
<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)'; }}
|
||||
aria-label={`Price for ${item.label}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
</div>
|
||||
{/* ===== PRICING TAB ===== */}
|
||||
{activeTab === 'pricing' && (
|
||||
<div>
|
||||
<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={{ 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={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>
|
||||
|
||||
{/* 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',
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 === 'review' && <ReviewStep />}
|
||||
{(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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
@@ -93,89 +37,48 @@ export function PaymentStep() {
|
||||
</h3>
|
||||
|
||||
<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',
|
||||
padding: '1rem',
|
||||
borderRadius: '0.5rem',
|
||||
background: '#fff',
|
||||
border: '1px solid rgba(0, 0, 0, 0.08)',
|
||||
}}>
|
||||
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)'; }}
|
||||
<PaymentElement
|
||||
options={{
|
||||
layout: 'tabs',
|
||||
}}
|
||||
/>
|
||||
{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>}
|
||||
{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>
|
||||
<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>}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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*'],
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+54
-10
@@ -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',
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user