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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user