feat: Integrate Stripe payment processing and enhance admin settings management
Some checks are pending
CI/CD / test-and-build (push) Waiting to run
CI/CD / deploy (push) Blocked by required conditions

- Removed the previous demo payment system and implemented a real integration with Stripe for handling payments.
- Added new API routes for creating payment intents and handling webhooks from Stripe.
- Updated the database schema to include an Order model for storing payment details.
- Enhanced the admin page to manage pricing items, including the ability to upload 3D models.
- Introduced a settings management feature for the admin panel, allowing for dynamic key-value pairs.
- Improved the RobotModel component to support dynamic attire based on uploaded models.
- Added error handling and validation for file uploads and settings management.
This commit is contained in:
Najjar\NajjarV02 2026-04-14 10:14:18 +04:00
parent dc42aeb72a
commit 51671b85b8
13 changed files with 598 additions and 156 deletions

View File

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

Binary file not shown.

View File

@ -26,3 +26,22 @@ model Snapshot {
imageData String imageData String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
} }
model Order {
id String @id @default(cuid())
paymentIntentId String @unique
amount Int
currency String @default("aed")
status String
customerName String?
customerEmail String?
customerPhone String?
customerAddress String?
customerCity String?
customerCountry String?
customerPostalCode String?
persona String?
color String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

Binary file not shown.

Binary file not shown.

View File

@ -15,7 +15,7 @@ interface Order {
metadata: Record<string, string>; metadata: Record<string, string>;
} }
type Tab = 'pricing' | 'personas' | 'orders'; type Tab = 'pricing' | 'personas' | 'orders' | 'settings';
export default function AdminPage() { export default function AdminPage() {
const router = useRouter(); const router = useRouter();
@ -25,16 +25,25 @@ export default function AdminPage() {
const items = usePricingStore((s) => s.items); const items = usePricingStore((s) => s.items);
const isPricingHydrated = usePricingStore((s) => s.isHydrated); const isPricingHydrated = usePricingStore((s) => s.isHydrated);
const [editedPrices, setEditedPrices] = useState<Record<string, number>>({}); const [editedPrices, setEditedPrices] = useState<Record<string, number>>({});
const [editedLabels, setEditedLabels] = useState<Record<string, string>>({});
const [priceSaved, setPriceSaved] = useState(false); const [priceSaved, setPriceSaved] = useState(false);
const [newItem, setNewItem] = useState({ id: '', label: '', price: '' }); const [newItem, setNewItem] = useState({ id: '', label: '', price: '' });
const [addItemGlb, setAddItemGlb] = useState<File | null>(null);
const [addItemUploading, setAddItemUploading] = useState(false);
const [addItemError, setAddItemError] = useState(''); const [addItemError, setAddItemError] = useState('');
// Per-row GLB upload state
const [rowGlbFiles, setRowGlbFiles] = useState<Record<string, File | null>>({});
const [rowGlbUploading, setRowGlbUploading] = useState<Record<string, boolean>>({});
const [rowGlbError, setRowGlbError] = useState<Record<string, string>>({});
useEffect(() => { pricingStore.getState().hydrate(); }, []); useEffect(() => { pricingStore.getState().hydrate(); }, []);
useEffect(() => { useEffect(() => {
const map: Record<string, number> = {}; const priceMap: Record<string, number> = {};
items.forEach((item) => { map[item.id] = item.price; }); const labelMap: Record<string, string> = {};
setEditedPrices(map); items.forEach((item) => { priceMap[item.id] = item.price; labelMap[item.id] = item.label; });
setEditedPrices(priceMap);
setEditedLabels(labelMap);
}, [items]); }, [items]);
const handlePriceChange = (id: string, value: string) => { const handlePriceChange = (id: string, value: string) => {
@ -43,16 +52,56 @@ export default function AdminPage() {
}; };
const handleSavePrices = () => { const handleSavePrices = () => {
Object.entries(editedPrices).forEach(([id, price]) => { items.forEach((item) => {
pricingStore.getState().updatePrice(id, price); pricingStore.getState().updateItem(item.id, {
price: editedPrices[item.id] ?? item.price,
label: (editedLabels[item.id] ?? item.label).trim() || item.label,
});
}); });
setPriceSaved(true); setPriceSaved(true);
setTimeout(() => setPriceSaved(false), 2000); setTimeout(() => setPriceSaved(false), 2000);
}; };
const handleAddItem = () => { const handleRowGlbUpload = async (itemId: string) => {
const file = rowGlbFiles[itemId];
if (!file) return;
setRowGlbUploading((p) => ({ ...p, [itemId]: true }));
setRowGlbError((p) => ({ ...p, [itemId]: '' }));
try {
const fd = new FormData();
fd.append('file', file);
fd.append('itemId', itemId);
const res = await fetch('/api/admin/upload-model/', { method: 'POST', body: fd });
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? 'Upload failed');
const modelPath = data.modelPath as string;
// Update pricing item
pricingStore.getState().updateItem(itemId, { modelPath });
// Update or create persona
const existing = personaStore.getState().personas.find((p) => p.id === itemId);
if (existing) {
personaStore.getState().updatePersona(itemId, { modelPath });
} else {
const item = pricingStore.getState().items.find((i) => i.id === itemId);
personaStore.getState().addPersona({
id: itemId,
label: item?.label ?? itemId,
description: item?.label ?? itemId,
colors: { torso: '#3b82f6', legs: '#3b82f6' },
modelPath,
});
}
setRowGlbFiles((p) => ({ ...p, [itemId]: null }));
} catch (err) {
setRowGlbError((p) => ({ ...p, [itemId]: err instanceof Error ? err.message : 'Upload failed' }));
} finally {
setRowGlbUploading((p) => ({ ...p, [itemId]: false }));
}
};
const handleAddItem = async () => {
setAddItemError(''); setAddItemError('');
const id = newItem.id.trim().toLowerCase().replace(/\s+/g, '-'); const id = newItem.id.trim().toLowerCase().replace(/_/g, '-').replace(/\s+/g, '-');
const label = newItem.label.trim(); const label = newItem.label.trim();
const price = parseInt(newItem.price, 10); const price = parseInt(newItem.price, 10);
if (!id || !label || isNaN(price) || price < 0) { if (!id || !label || isNaN(price) || price < 0) {
@ -63,8 +112,43 @@ export default function AdminPage() {
setAddItemError(`ID "${id}" already exists.`); setAddItemError(`ID "${id}" already exists.`);
return; return;
} }
pricingStore.getState().addItem({ id, label, price });
let modelPath: string | undefined;
if (addItemGlb) {
setAddItemUploading(true);
const fd = new FormData();
fd.append('file', addItemGlb);
fd.append('itemId', id);
try {
const res = await fetch('/api/admin/upload-model/', { method: 'POST', body: fd });
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? 'Upload failed');
modelPath = data.modelPath as string;
} catch (err) {
setAddItemError(err instanceof Error ? err.message : 'GLB upload failed');
setAddItemUploading(false);
return;
} finally {
setAddItemUploading(false);
}
}
pricingStore.getState().addItem({ id, label, price, modelPath });
// If a GLB was uploaded, also register as a persona so it shows on the robot
if (modelPath) {
personaStore.getState().addPersona({
id,
label,
description: label,
colors: { torso: '#3b82f6', legs: '#3b82f6' },
modelPath,
});
}
setNewItem({ id: '', label: '', price: '' }); setNewItem({ id: '', label: '', price: '' });
setAddItemGlb(null);
}; };
// --------------- PERSONAS --------------- // --------------- PERSONAS ---------------
@ -121,15 +205,72 @@ export default function AdminPage() {
if (activeTab === 'orders') loadOrders(); if (activeTab === 'orders') loadOrders();
}, [activeTab, loadOrders]); }, [activeTab, loadOrders]);
// --------------- AUTH --------------- // --------------- SETTINGS ---------------
const [settings, setSettings] = useState<{ key: string; value: string }[]>([]);
const [settingsLoading, setSettingsLoading] = useState(false);
const [newSetting, setNewSetting] = useState({ key: '', value: '' });
const [settingError, setSettingError] = useState('');
const [settingSaved, setSettingSaved] = useState('');
const loadSettings = useCallback(async () => {
setSettingsLoading(true);
try {
const res = await fetch('/api/admin/settings/');
if (!res.ok) throw new Error('Failed');
const data = await res.json();
setSettings(data.settings ?? []);
} catch { /* ignore */ } finally {
setSettingsLoading(false);
}
}, []);
useEffect(() => {
if (activeTab === 'settings') loadSettings();
}, [activeTab, loadSettings]);
const handleSaveSetting = async (key: string, value: string) => {
const res = await fetch('/api/admin/settings/', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value }),
});
if (res.ok) {
setSettingSaved(key);
setTimeout(() => setSettingSaved(''), 1500);
loadSettings();
} else {
const d = await res.json();
setSettingError(d.error ?? 'Failed to save');
}
};
const handleAddSetting = async () => {
setSettingError('');
const key = newSetting.key.trim();
const value = newSetting.value;
if (!key) { setSettingError('Key is required.'); return; }
await handleSaveSetting(key, value);
setNewSetting({ key: '', value: '' });
};
const handleDeleteSetting = async (key: string) => {
const res = await fetch(`/api/admin/settings/?key=${encodeURIComponent(key)}`, { method: 'DELETE' });
if (res.ok) loadSettings();
else {
const d = await res.json();
setSettingError(d.error ?? 'Failed to delete');
}
};
const handleLogout = async () => { const handleLogout = async () => {
await fetch('/api/admin/logout/', { method: 'POST' }); await fetch('/api/admin/logout/', { method: 'POST' });
router.push('/admin/login/'); router.push('/admin/login/');
router.refresh(); router.refresh();
}; };
// --------------- CHANGE PASSWORD ---------------
const [showPwModal, setShowPwModal] = useState(false); const [showPwModal, setShowPwModal] = useState(false);
// --------------- CHANGE PASSWORD ---------------
const [pwForm, setPwForm] = useState({ current: '', next: '', confirm: '' }); const [pwForm, setPwForm] = useState({ current: '', next: '', confirm: '' });
const [pwError, setPwError] = useState(''); const [pwError, setPwError] = useState('');
const [pwSaved, setPwSaved] = useState(false); const [pwSaved, setPwSaved] = useState(false);
@ -190,7 +331,7 @@ export default function AdminPage() {
{/* TABS */} {/* TABS */}
<div style={{ display: 'flex', gap: '0.25rem', marginBottom: '1.25rem', borderBottom: '1px solid rgba(0,0,0,0.06)', paddingBottom: '0.5rem' }}> <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) => ( {(['pricing', 'personas', 'orders', 'settings'] as Tab[]).map((t) => (
<button <button
key={t} key={t}
onClick={() => setActiveTab(t)} onClick={() => setActiveTab(t)}
@ -215,13 +356,22 @@ export default function AdminPage() {
{activeTab === 'pricing' && ( {activeTab === 'pricing' && (
<div> <div>
<TableCard> <TableCard>
<TableHeader cols="1fr 180px 56px" labels={['Item', 'Price (AED)', '']} /> <TableHeader cols="1fr 150px 160px 160px 56px" labels={['Label', 'ID', 'Price (AED)', '3D Model', '']} />
{items.map((item, i) => ( {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 key={item.id} style={{ borderBottom: i < items.length - 1 ? '1px solid rgba(0,0,0,0.04)' : 'none' }}>
<div> <div style={{ display: 'grid', gridTemplateColumns: '1fr 150px 160px 160px 56px', padding: '0.625rem 1.25rem', alignItems: 'center' }}>
<div style={{ fontSize: '0.85rem', color: '#374151', fontWeight: 500 }}>{item.label}</div> <div>
<div style={{ fontSize: '0.7rem', color: '#94a3b8', fontFamily: 'monospace' }}>{item.id}</div> <input
type="text"
value={editedLabels[item.id] ?? item.label}
onChange={(e) => setEditedLabels((prev) => ({ ...prev, [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={`Label for ${item.label}`}
/>
</div> </div>
<div style={{ fontSize: '0.7rem', color: '#94a3b8', fontFamily: 'monospace', paddingLeft: '0.25rem' }}>{item.id}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
<span style={{ fontSize: '0.7rem', color: '#94a3b8' }}>AED</span> <span style={{ fontSize: '0.7rem', color: '#94a3b8' }}>AED</span>
<input <input
@ -234,12 +384,45 @@ export default function AdminPage() {
aria-label={`Price for ${item.label}`} aria-label={`Price for ${item.label}`}
/> />
</div> </div>
{/* 3D Model column */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
{item.modelPath ? (
<span
title={item.modelPath}
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.7rem', color: '#16a34a', background: 'rgba(22,163,74,0.08)', border: '1px solid rgba(22,163,74,0.2)', borderRadius: '0.375rem', padding: '0.2rem 0.45rem', whiteSpace: 'nowrap', overflow: 'hidden', maxWidth: '100%' }}
>
{item.modelPath.split('/').pop()}
</span>
) : null}
<label
title="Upload / replace .glb"
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem', cursor: 'pointer', fontSize: '0.7rem', color: rowGlbFiles[item.id] ? '#2563eb' : '#94a3b8', background: rowGlbFiles[item.id] ? 'rgba(59,130,246,0.06)' : 'transparent', border: `1px dashed ${rowGlbFiles[item.id] ? 'rgba(59,130,246,0.3)' : 'rgba(0,0,0,0.12)'}`, borderRadius: '0.375rem', padding: '0.2rem 0.45rem', whiteSpace: 'nowrap' }}
>
<input type="file" accept=".glb" style={{ display: 'none' }} onChange={(e) => setRowGlbFiles((p) => ({ ...p, [item.id]: e.target.files?.[0] ?? null }))} />
{rowGlbFiles[item.id] ? '📎 ' + rowGlbFiles[item.id]!.name.slice(0, 12) + '…' : ' .glb'}
</label>
{rowGlbFiles[item.id] && (
<button
onClick={() => handleRowGlbUpload(item.id)}
disabled={rowGlbUploading[item.id]}
style={{ ...secondaryBtnStyle, fontSize: '0.7rem', padding: '0.2rem 0.45rem' }}
>
{rowGlbUploading[item.id] ? '…' : 'Save'}
</button>
)}
</div>
<div style={{ display: 'flex', justifyContent: 'center' }}> <div style={{ display: 'flex', justifyContent: 'center' }}>
{item.id !== 'base' && ( {item.id !== 'base' && (
<button onClick={() => pricingStore.getState().removeItem(item.id)} style={deleteBtnStyle} title="Remove item"></button> <button onClick={() => pricingStore.getState().removeItem(item.id)} style={deleteBtnStyle} title="Remove item"></button>
)} )}
</div> </div>
</div> </div>
{rowGlbError[item.id] && (
<div style={{ gridColumn: '1 / -1', padding: '0 1.25rem 0.5rem', fontSize: '0.75rem', color: '#dc2626' }}>{rowGlbError[item.id]}</div>
)}
</div>
))} ))}
</TableCard> </TableCard>
@ -249,21 +432,59 @@ export default function AdminPage() {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 120px', gap: '0.5rem', alignItems: 'flex-end' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 120px', gap: '0.5rem', alignItems: 'flex-end' }}>
<div> <div>
<label style={labelStyle}>ID (slug)</label> <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 }))} /> <input style={formInputStyle} placeholder="e.g. medical-suit" value={newItem.id} onChange={(e) => setNewItem((p) => ({ ...p, id: e.target.value }))} />
</div> </div>
<div> <div>
<label style={labelStyle}>Label</label> <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 }))} /> <input style={formInputStyle} placeholder="e.g. Medical Suit" value={newItem.label} onChange={(e) => setNewItem((p) => ({ ...p, label: e.target.value }))} />
</div> </div>
<div> <div>
<label style={labelStyle}>Price (AED)</label> <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 }))} /> <input style={formInputStyle} type="number" min="0" placeholder="5000" value={newItem.price} onChange={(e) => setNewItem((p) => ({ ...p, price: e.target.value }))} />
</div> </div>
</div> </div>
{/* GLB file picker */}
<div style={{ marginTop: '0.625rem' }}>
<label style={labelStyle}>3D Model .glb file (optional)</label>
<label style={{
display: 'flex',
alignItems: 'center',
gap: '0.625rem',
padding: '0.5rem 0.75rem',
borderRadius: '0.375rem',
border: `1px dashed ${addItemGlb ? 'rgba(59,130,246,0.4)' : 'rgba(0,0,0,0.15)'}`,
background: addItemGlb ? 'rgba(59,130,246,0.04)' : '#fff',
cursor: 'pointer',
fontSize: '0.8rem',
color: addItemGlb ? '#2563eb' : '#94a3b8',
transition: 'all 0.2s',
}}>
<input
type="file"
accept=".glb"
style={{ display: 'none' }}
onChange={(e) => setAddItemGlb(e.target.files?.[0] ?? null)}
/>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" />
</svg>
{addItemGlb ? addItemGlb.name : 'Browse .glb file…'}
{addItemGlb && (
<button
onClick={(e) => { e.preventDefault(); setAddItemGlb(null); }}
style={{ marginLeft: 'auto', background: 'none', border: 'none', cursor: 'pointer', color: '#94a3b8', fontSize: '0.75rem', padding: '0 2px' }}
></button>
)}
</label>
</div>
{addItemError && <p style={errorTextStyle}>{addItemError}</p>} {addItemError && <p style={errorTextStyle}>{addItemError}</p>}
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.75rem', justifyContent: 'flex-end' }}> <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={() => pricingStore.getState().resetPrices()} style={dangerBtnStyle}>Reset to Defaults</button>
<button onClick={handleAddItem} style={secondaryBtnStyle}>Add Item</button> <button onClick={handleAddItem} disabled={addItemUploading} style={secondaryBtnStyle}>
{addItemUploading ? 'Uploading…' : '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' }}> <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'} {priceSaved ? 'Saved!' : 'Save Prices'}
</button> </button>
@ -370,6 +591,60 @@ export default function AdminPage() {
)} )}
</div> </div>
)} )}
{/* SETTINGS TAB */}
{activeTab === 'settings' && (
<div>
<h2 style={{ fontSize: '1rem', fontWeight: 700, color: '#1a1a2e', margin: '0 0 1rem' }}>App Settings</h2>
{settingError && <p style={{ color: '#dc2626', fontSize: '0.8rem', marginBottom: '0.75rem' }}>{settingError}</p>}
{settingsLoading ? (
<p style={{ color: '#64748b', fontSize: '0.875rem' }}>Loading</p>
) : (
<TableCard>
<TableHeader cols="1fr 1.5fr 110px 50px" labels={['Key', 'Value', '', '']} />
{settings.length === 0 && (
<p style={{ padding: '1rem', color: '#94a3b8', fontSize: '0.8rem' }}>No settings yet.</p>
)}
{settings.map((s, i) => (
<SettingRow
key={s.key}
setting={s}
index={i}
isSaved={settingSaved === s.key}
onSave={handleSaveSetting}
onDelete={handleDeleteSetting}
/>
))}
</TableCard>
)}
{/* Add new setting */}
<div style={{ marginTop: '1.25rem', padding: '1rem', background: '#f8fafc', borderRadius: '0.75rem', border: '1px solid rgba(0,0,0,0.06)' }}>
<h3 style={{ fontSize: '0.875rem', fontWeight: 600, color: '#1a1a2e', margin: '0 0 0.75rem' }}>Add Setting</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.5fr auto', gap: '0.5rem', alignItems: 'flex-end' }}>
<div>
<label style={labelStyle}>Key</label>
<input
value={newSetting.key}
onChange={(e) => setNewSetting((p) => ({ ...p, key: e.target.value }))}
placeholder="e.g. site_name"
style={formInputStyle}
/>
</div>
<div>
<label style={labelStyle}>Value</label>
<input
value={newSetting.value}
onChange={(e) => setNewSetting((p) => ({ ...p, value: e.target.value }))}
placeholder="Value"
style={formInputStyle}
/>
</div>
<button onClick={handleAddSetting} style={secondaryBtnStyle}>Add</button>
</div>
</div>
</div>
)}
</div> </div>
{/* CHANGE PASSWORD MODAL */} {/* CHANGE PASSWORD MODAL */}
@ -406,6 +681,48 @@ export default function AdminPage() {
// ===== SUB-COMPONENTS ===== // ===== SUB-COMPONENTS =====
function SettingRow({
setting,
index,
isSaved,
onSave,
onDelete,
}: {
setting: { key: string; value: string };
index: number;
isSaved: boolean;
onSave: (key: string, value: string) => void;
onDelete: (key: string) => void;
}) {
const [editVal, setEditVal] = useState(setting.value);
// Sync if parent refreshes the list
useEffect(() => { setEditVal(setting.value); }, [setting.value]);
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.5fr 110px 50px', gap: '0.75rem', padding: '0.625rem 1rem', alignItems: 'center', background: index % 2 === 0 ? '#fff' : '#f8fafc', borderTop: index > 0 ? '1px solid rgba(0,0,0,0.04)' : 'none' }}>
<span style={{ fontSize: '0.8rem', fontFamily: 'monospace', color: '#334155' }}>{setting.key}</span>
<input
value={editVal}
onChange={(e) => setEditVal(e.target.value)}
style={{ ...formInputStyle, fontSize: '0.8rem', padding: '0.3rem 0.5rem' }}
/>
<button
onClick={() => onSave(setting.key, editVal)}
style={{ ...secondaryBtnStyle, fontSize: '0.75rem', padding: '0.3rem 0.6rem', color: isSaved ? '#16a34a' : undefined }}
>
{isSaved ? '✓ Saved' : 'Save'}
</button>
<button
onClick={() => onDelete(setting.key)}
style={{ ...deleteBtnStyle, fontSize: '0.75rem', padding: '0.3rem 0.5rem' }}
>
</button>
</div>
);
}
function OrderRow({ function OrderRow({
order, order,
isLast, isLast,

View File

@ -1,32 +1,39 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import Stripe from 'stripe'; import { prisma } from '@/lib/prisma';
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) { export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') ?? '20', 10), 100); const limit = Math.min(parseInt(searchParams.get('limit') ?? '50', 10), 200);
try { try {
const paymentIntents = await stripe.paymentIntents.list({ const dbOrders = await prisma.order.findMany({
limit, orderBy: { createdAt: 'desc' },
expand: ['data.latest_charge'], take: limit,
}); });
const orders = paymentIntents.data.map((pi) => ({ const orders = dbOrders.map((o) => ({
id: pi.id, id: o.paymentIntentId,
amount: pi.amount, amount: o.amount,
currency: pi.currency, currency: o.currency,
status: pi.status, status: o.status,
created: pi.created, created: Math.floor(new Date(o.createdAt).getTime() / 1000),
metadata: pi.metadata, metadata: {
customerName: o.customerName ?? '',
customerEmail: o.customerEmail ?? '',
customerPhone: o.customerPhone ?? '',
customerAddress: o.customerAddress ?? '',
customerCity: o.customerCity ?? '',
customerCountry: o.customerCountry ?? '',
customerPostalCode: o.customerPostalCode ?? '',
persona: o.persona ?? '',
color: o.color ?? '',
},
})); }));
return NextResponse.json({ orders, hasMore: paymentIntents.has_more }); return NextResponse.json({ orders, hasMore: dbOrders.length === limit });
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'; const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 }); return NextResponse.json({ error: message }, { status: 500 });
} }
} }

View File

@ -0,0 +1,69 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { jwtVerify } from 'jose';
import { prisma } from '@/lib/prisma';
async function verifyAdmin() {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
if (!token) return false;
const jwtSecret = process.env.ADMIN_JWT_SECRET;
if (!jwtSecret) return false;
try {
await jwtVerify(token, new TextEncoder().encode(jwtSecret));
return true;
} catch {
return false;
}
}
// GET /api/admin/settings/ → all key-value pairs
export async function GET() {
if (!(await verifyAdmin())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const settings = await prisma.appSettings.findMany({ orderBy: { key: 'asc' } });
return NextResponse.json({ settings });
}
// PUT /api/admin/settings/ → upsert a key
export async function PUT(request: Request) {
if (!(await verifyAdmin())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { key, value } = await request.json();
if (!key || typeof key !== 'string' || key.trim() === '') {
return NextResponse.json({ error: 'Invalid key' }, { status: 400 });
}
if (typeof value !== 'string') {
return NextResponse.json({ error: 'Invalid value' }, { status: 400 });
}
// Prevent overwriting internal keys with sensitive data
const protectedKeys = ['jwt_secret'];
if (protectedKeys.includes(key.toLowerCase())) {
return NextResponse.json({ error: 'This key is protected and cannot be edited here' }, { status: 403 });
}
const setting = await prisma.appSettings.upsert({
where: { key: key.trim() },
create: { key: key.trim(), value },
update: { value },
});
return NextResponse.json({ setting });
}
// DELETE /api/admin/settings/?key=xxx
export async function DELETE(request: Request) {
if (!(await verifyAdmin())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(request.url);
const key = searchParams.get('key');
if (!key) return NextResponse.json({ error: 'Missing key' }, { status: 400 });
const protectedKeys = ['jwt_secret'];
if (protectedKeys.includes(key.toLowerCase())) {
return NextResponse.json({ error: 'This key is protected' }, { status: 403 });
}
await prisma.appSettings.delete({ where: { key } }).catch(() => {});
return NextResponse.json({ success: true });
}

View File

@ -0,0 +1,55 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { jwtVerify } from 'jose';
import { writeFile, mkdir } from 'fs/promises';
import path from 'path';
export async function POST(request: Request) {
// Verify admin JWT
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 });
try {
await jwtVerify(token, new TextEncoder().encode(jwtSecret));
} catch {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
let formData: FormData;
try {
formData = await request.formData();
} catch {
return NextResponse.json({ error: 'Invalid form data' }, { status: 400 });
}
const file = formData.get('file') as File | null;
const itemId = formData.get('itemId') as string | null;
if (!file || !itemId) {
return NextResponse.json({ error: 'Missing file or itemId' }, { status: 400 });
}
// Only accept .glb files
if (!file.name.toLowerCase().endsWith('.glb')) {
return NextResponse.json({ error: 'Only .glb files are allowed' }, { status: 400 });
}
// Sanitize the item ID — convert underscores to hyphens, keep lowercase letters, digits, hyphens
const safeId = itemId.toLowerCase().replace(/_/g, '-').replace(/[^a-z0-9-]/g, '');
if (!safeId) {
return NextResponse.json({ error: 'Invalid itemId' }, { status: 400 });
}
const modelsDir = path.resolve(process.cwd(), 'public', 'models');
await mkdir(modelsDir, { recursive: true });
const destPath = path.join(modelsDir, `${safeId}.glb`);
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(destPath, buffer);
return NextResponse.json({ modelPath: `/models/${safeId}.glb` });
}

View File

@ -0,0 +1,58 @@
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { prisma } from '@/lib/prisma';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
apiVersion: '2026-03-25.dahlia' as any,
});
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get('stripe-signature');
if (!signature) {
return NextResponse.json({ error: 'Missing stripe-signature header' }, { status: 400 });
}
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) {
return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Invalid signature';
return NextResponse.json({ error: `Webhook signature verification failed: ${msg}` }, { status: 400 });
}
if (event.type === 'payment_intent.succeeded' || event.type === 'payment_intent.payment_failed') {
const pi = event.data.object as Stripe.PaymentIntent;
const m = pi.metadata ?? {};
const data = {
amount: pi.amount,
currency: pi.currency,
status: pi.status,
customerName: m.customerName ?? null,
customerEmail: m.customerEmail ?? null,
customerPhone: m.customerPhone ?? null,
customerAddress: m.customerAddress ?? null,
customerCity: m.customerCity ?? null,
customerCountry: m.customerCountry ?? null,
customerPostalCode: m.customerPostalCode ?? null,
persona: m.persona ?? null,
color: m.color ?? null,
};
await prisma.order.upsert({
where: { paymentIntentId: pi.id },
create: { paymentIntentId: pi.id, ...data },
update: data,
});
}
return NextResponse.json({ received: true });
}

View File

@ -5,15 +5,25 @@ import { useGLTF } from '@react-three/drei';
import { useFrame } from '@react-three/fiber'; import { useFrame } from '@react-three/fiber';
import * as THREE from 'three'; import * as THREE from 'three';
import { useConfigStore } from '@/store/useConfigStore'; import { useConfigStore } from '@/store/useConfigStore';
import { personaStore, usePersonaStore } from '@/store/usePersonaStore';
const ATTIRE_GLB: Record<string, string> = { const STATIC_ATTIRE_GLB: Record<string, string> = {
'emarati-kandura': '/Kandoura.glb', 'emarati-kandura': '/Kandoura.glb',
'industrial-vest': '/Vest.glb', 'industrial-vest': '/Vest.glb',
'business-suit': '/Suit.glb', 'business-suit': '/Suit.glb',
}; };
// Preload all attire models so they're cached before user clicks // Preload static attire models so they're cached before user clicks
Object.values(ATTIRE_GLB).forEach((path) => useGLTF.preload(path)); Object.values(STATIC_ATTIRE_GLB).forEach((p) => useGLTF.preload(p));
/** Merge static map with any custom GLBs stored in the persona store */
function buildAttireGlbMap(personas: { id: string; modelPath?: string }[]): Record<string, string> {
const dynamic: Record<string, string> = {};
personas.forEach((p) => {
if (p.modelPath && p.id !== 'none') dynamic[p.id] = p.modelPath;
});
return { ...dynamic, ...STATIC_ATTIRE_GLB }; // static takes priority
}
function easeInOutCubic(t: number): number { function easeInOutCubic(t: number): number {
return t < 0.5 return t < 0.5
@ -78,6 +88,8 @@ export function RobotModel({ onError }: RobotModelProps) {
const activeColors = useConfigStore((state) => state.activeColors); const activeColors = useConfigStore((state) => state.activeColors);
const activePersonaAttire = useConfigStore((state) => state.activePersonaAttire); const activePersonaAttire = useConfigStore((state) => state.activePersonaAttire);
// Subscribe to persona store so custom GLB paths are reactive
const personas = usePersonaStore((s) => s.personas);
// Detect attire change and trigger spin // Detect attire change and trigger spin
useEffect(() => { useEffect(() => {
@ -174,7 +186,7 @@ export function RobotModel({ onError }: RobotModelProps) {
}); });
}, [activeColors]); }, [activeColors]);
const attireGlbPath = ATTIRE_GLB[displayedAttire] || null; const attireGlbPath = buildAttireGlbMap(personas)[displayedAttire] || null;
const handleAttireLoaded = () => { const handleAttireLoaded = () => {
setAttireReady(true); setAttireReady(true);

View File

@ -6,6 +6,7 @@ export interface PersonaOption {
label: string; label: string;
description: string; description: string;
colors: { torso: string; legs: string }; colors: { torso: string; legs: string };
modelPath?: string;
} }
export interface PersonaState { export interface PersonaState {
@ -88,6 +89,7 @@ export const personaStore = createStore<PersonaStore>((set, get) => ({
label: persona.label, label: persona.label,
description: persona.description, description: persona.description,
colors: persona.colors, colors: persona.colors,
...(persona.modelPath ? { modelPath: persona.modelPath } : {}),
}; };
set((state) => { set((state) => {
const updated = [...state.personas, newPersona]; const updated = [...state.personas, newPersona];

View File

@ -5,6 +5,7 @@ export interface PricingItem {
id: string; id: string;
label: string; label: string;
price: number; price: number;
modelPath?: string;
} }
export interface PricingState { export interface PricingState {
@ -14,6 +15,7 @@ export interface PricingState {
export interface PricingActions { export interface PricingActions {
updatePrice: (itemId: string, newPrice: number) => void; updatePrice: (itemId: string, newPrice: number) => void;
updateItem: (itemId: string, updates: Partial<Pick<PricingItem, 'label' | 'price' | 'modelPath'>>) => void;
addItem: (item: PricingItem) => void; addItem: (item: PricingItem) => void;
removeItem: (itemId: string) => void; removeItem: (itemId: string) => void;
resetPrices: () => void; resetPrices: () => void;
@ -68,6 +70,16 @@ export const pricingStore = createStore<PricingStore>((set, get) => ({
}); });
}, },
updateItem: (itemId: string, updates: Partial<Pick<PricingItem, 'label' | 'price' | 'modelPath'>>) => {
set((state) => {
const updated = state.items.map((item) =>
item.id === itemId ? { ...item, ...updates } : item
);
saveToStorage(updated);
return { items: updated };
});
},
resetPrices: () => { resetPrices: () => {
saveToStorage(DEFAULT_ITEMS); saveToStorage(DEFAULT_ITEMS);
set({ items: [...DEFAULT_ITEMS] }); set({ items: [...DEFAULT_ITEMS] });
@ -94,13 +106,12 @@ export const pricingStore = createStore<PricingStore>((set, get) => ({
hydrate: () => { hydrate: () => {
const stored = loadFromStorage(); const stored = loadFromStorage();
if (stored) { if (stored && stored.length > 0) {
// Merge stored prices with defaults (in case new items were added) // Use stored items directly (preserves custom labels, prices, added items).
const merged = DEFAULT_ITEMS.map((def) => { // Re-add any default items that were never stored (fresh install gap).
const found = stored.find((s) => s.id === def.id); const storedIds = new Set(stored.map((s) => s.id));
return found ? { ...def, price: found.price } : def; const missing = DEFAULT_ITEMS.filter((d) => !storedIds.has(d.id));
}); set({ items: [...stored, ...missing], isHydrated: true });
set({ items: merged, isHydrated: true });
} else { } else {
set({ isHydrated: true }); set({ isHydrated: true });
} }