feat: add admin authentication and management features
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
+172
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',
};
+570 -155
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>
<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',
};