forked from hazem/yslootahrobotics
feat: Integrate Stripe payment processing and enhance admin settings management
- 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:
parent
dc42aeb72a
commit
51671b85b8
@ -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
|
||||
```
|
||||
BIN
prisma/lootah.db
BIN
prisma/lootah.db
Binary file not shown.
@ -26,3 +26,22 @@ model Snapshot {
|
||||
imageData String
|
||||
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
|
||||
}
|
||||
|
||||
BIN
public/models/security-guard.glb
Normal file
BIN
public/models/security-guard.glb
Normal file
Binary file not shown.
BIN
public/models/security-guardglb.glb
Normal file
BIN
public/models/security-guardglb.glb
Normal file
Binary file not shown.
@ -15,7 +15,7 @@ interface Order {
|
||||
metadata: Record<string, string>;
|
||||
}
|
||||
|
||||
type Tab = 'pricing' | 'personas' | 'orders';
|
||||
type Tab = 'pricing' | 'personas' | 'orders' | 'settings';
|
||||
|
||||
export default function AdminPage() {
|
||||
const router = useRouter();
|
||||
@ -25,16 +25,25 @@ export default function AdminPage() {
|
||||
const items = usePricingStore((s) => s.items);
|
||||
const isPricingHydrated = usePricingStore((s) => s.isHydrated);
|
||||
const [editedPrices, setEditedPrices] = useState<Record<string, number>>({});
|
||||
const [editedLabels, setEditedLabels] = useState<Record<string, string>>({});
|
||||
const [priceSaved, setPriceSaved] = useState(false);
|
||||
const [newItem, setNewItem] = useState({ id: '', label: '', price: '' });
|
||||
const [addItemGlb, setAddItemGlb] = useState<File | null>(null);
|
||||
const [addItemUploading, setAddItemUploading] = useState(false);
|
||||
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(() => {
|
||||
const map: Record<string, number> = {};
|
||||
items.forEach((item) => { map[item.id] = item.price; });
|
||||
setEditedPrices(map);
|
||||
const priceMap: Record<string, number> = {};
|
||||
const labelMap: Record<string, string> = {};
|
||||
items.forEach((item) => { priceMap[item.id] = item.price; labelMap[item.id] = item.label; });
|
||||
setEditedPrices(priceMap);
|
||||
setEditedLabels(labelMap);
|
||||
}, [items]);
|
||||
|
||||
const handlePriceChange = (id: string, value: string) => {
|
||||
@ -43,16 +52,56 @@ export default function AdminPage() {
|
||||
};
|
||||
|
||||
const handleSavePrices = () => {
|
||||
Object.entries(editedPrices).forEach(([id, price]) => {
|
||||
pricingStore.getState().updatePrice(id, price);
|
||||
items.forEach((item) => {
|
||||
pricingStore.getState().updateItem(item.id, {
|
||||
price: editedPrices[item.id] ?? item.price,
|
||||
label: (editedLabels[item.id] ?? item.label).trim() || item.label,
|
||||
});
|
||||
});
|
||||
setPriceSaved(true);
|
||||
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('');
|
||||
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 price = parseInt(newItem.price, 10);
|
||||
if (!id || !label || isNaN(price) || price < 0) {
|
||||
@ -63,8 +112,43 @@ export default function AdminPage() {
|
||||
setAddItemError(`ID "${id}" already exists.`);
|
||||
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: '' });
|
||||
setAddItemGlb(null);
|
||||
};
|
||||
|
||||
// --------------- PERSONAS ---------------
|
||||
@ -121,15 +205,72 @@ export default function AdminPage() {
|
||||
if (activeTab === 'orders') 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 () => {
|
||||
await fetch('/api/admin/logout/', { method: 'POST' });
|
||||
router.push('/admin/login/');
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
// --------------- CHANGE PASSWORD ---------------
|
||||
const [showPwModal, setShowPwModal] = useState(false);
|
||||
|
||||
// --------------- CHANGE PASSWORD ---------------
|
||||
const [pwForm, setPwForm] = useState({ current: '', next: '', confirm: '' });
|
||||
const [pwError, setPwError] = useState('');
|
||||
const [pwSaved, setPwSaved] = useState(false);
|
||||
@ -190,7 +331,7 @@ export default function AdminPage() {
|
||||
|
||||
{/* 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) => (
|
||||
{(['pricing', 'personas', 'orders', 'settings'] as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setActiveTab(t)}
|
||||
@ -215,13 +356,22 @@ export default function AdminPage() {
|
||||
{activeTab === 'pricing' && (
|
||||
<div>
|
||||
<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) => (
|
||||
<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 style={{ display: 'grid', gridTemplateColumns: '1fr 150px 160px 160px 56px', padding: '0.625rem 1.25rem', alignItems: 'center' }}>
|
||||
<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>
|
||||
<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 style={{ fontSize: '0.7rem', color: '#94a3b8', fontFamily: 'monospace', paddingLeft: '0.25rem' }}>{item.id}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||
<span style={{ fontSize: '0.7rem', color: '#94a3b8' }}>AED</span>
|
||||
<input
|
||||
@ -234,12 +384,45 @@ export default function AdminPage() {
|
||||
aria-label={`Price for ${item.label}`}
|
||||
/>
|
||||
</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' }}>
|
||||
{item.id !== 'base' && (
|
||||
<button onClick={() => pricingStore.getState().removeItem(item.id)} style={deleteBtnStyle} title="Remove item">✕</button>
|
||||
)}
|
||||
</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>
|
||||
|
||||
@ -249,21 +432,59 @@ export default function AdminPage() {
|
||||
<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 }))} />
|
||||
<input style={formInputStyle} placeholder="e.g. medical-suit" 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 }))} />
|
||||
<input style={formInputStyle} placeholder="e.g. Medical Suit" 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>
|
||||
|
||||
{/* 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>}
|
||||
<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={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' }}>
|
||||
{priceSaved ? 'Saved!' : 'Save Prices'}
|
||||
</button>
|
||||
@ -370,6 +591,60 @@ export default function AdminPage() {
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* CHANGE PASSWORD MODAL */}
|
||||
@ -406,6 +681,48 @@ export default function AdminPage() {
|
||||
|
||||
// ===== 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({
|
||||
order,
|
||||
isLast,
|
||||
|
||||
@ -1,32 +1,39 @@
|
||||
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'],
|
||||
});
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
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 {
|
||||
const paymentIntents = await stripe.paymentIntents.list({
|
||||
limit,
|
||||
expand: ['data.latest_charge'],
|
||||
const dbOrders = await prisma.order.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
const orders = paymentIntents.data.map((pi) => ({
|
||||
id: pi.id,
|
||||
amount: pi.amount,
|
||||
currency: pi.currency,
|
||||
status: pi.status,
|
||||
created: pi.created,
|
||||
metadata: pi.metadata,
|
||||
const orders = dbOrders.map((o) => ({
|
||||
id: o.paymentIntentId,
|
||||
amount: o.amount,
|
||||
currency: o.currency,
|
||||
status: o.status,
|
||||
created: Math.floor(new Date(o.createdAt).getTime() / 1000),
|
||||
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) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
69
src/app/api/admin/settings/route.ts
Normal file
69
src/app/api/admin/settings/route.ts
Normal 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 });
|
||||
}
|
||||
55
src/app/api/admin/upload-model/route.ts
Normal file
55
src/app/api/admin/upload-model/route.ts
Normal 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` });
|
||||
}
|
||||
58
src/app/api/webhooks/stripe/route.ts
Normal file
58
src/app/api/webhooks/stripe/route.ts
Normal 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 });
|
||||
}
|
||||
@ -5,15 +5,25 @@ import { useGLTF } from '@react-three/drei';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import * as THREE from 'three';
|
||||
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',
|
||||
'industrial-vest': '/Vest.glb',
|
||||
'business-suit': '/Suit.glb',
|
||||
};
|
||||
|
||||
// Preload all attire models so they're cached before user clicks
|
||||
Object.values(ATTIRE_GLB).forEach((path) => useGLTF.preload(path));
|
||||
// Preload static attire models so they're cached before user clicks
|
||||
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 {
|
||||
return t < 0.5
|
||||
@ -78,6 +88,8 @@ export function RobotModel({ onError }: RobotModelProps) {
|
||||
|
||||
const activeColors = useConfigStore((state) => state.activeColors);
|
||||
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
|
||||
useEffect(() => {
|
||||
@ -174,7 +186,7 @@ export function RobotModel({ onError }: RobotModelProps) {
|
||||
});
|
||||
}, [activeColors]);
|
||||
|
||||
const attireGlbPath = ATTIRE_GLB[displayedAttire] || null;
|
||||
const attireGlbPath = buildAttireGlbMap(personas)[displayedAttire] || null;
|
||||
|
||||
const handleAttireLoaded = () => {
|
||||
setAttireReady(true);
|
||||
|
||||
@ -6,6 +6,7 @@ export interface PersonaOption {
|
||||
label: string;
|
||||
description: string;
|
||||
colors: { torso: string; legs: string };
|
||||
modelPath?: string;
|
||||
}
|
||||
|
||||
export interface PersonaState {
|
||||
@ -88,6 +89,7 @@ export const personaStore = createStore<PersonaStore>((set, get) => ({
|
||||
label: persona.label,
|
||||
description: persona.description,
|
||||
colors: persona.colors,
|
||||
...(persona.modelPath ? { modelPath: persona.modelPath } : {}),
|
||||
};
|
||||
set((state) => {
|
||||
const updated = [...state.personas, newPersona];
|
||||
|
||||
@ -5,6 +5,7 @@ export interface PricingItem {
|
||||
id: string;
|
||||
label: string;
|
||||
price: number;
|
||||
modelPath?: string;
|
||||
}
|
||||
|
||||
export interface PricingState {
|
||||
@ -14,6 +15,7 @@ export interface PricingState {
|
||||
|
||||
export interface PricingActions {
|
||||
updatePrice: (itemId: string, newPrice: number) => void;
|
||||
updateItem: (itemId: string, updates: Partial<Pick<PricingItem, 'label' | 'price' | 'modelPath'>>) => void;
|
||||
addItem: (item: PricingItem) => void;
|
||||
removeItem: (itemId: string) => 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: () => {
|
||||
saveToStorage(DEFAULT_ITEMS);
|
||||
set({ items: [...DEFAULT_ITEMS] });
|
||||
@ -94,13 +106,12 @@ export const pricingStore = createStore<PricingStore>((set, get) => ({
|
||||
|
||||
hydrate: () => {
|
||||
const stored = loadFromStorage();
|
||||
if (stored) {
|
||||
// Merge stored prices with defaults (in case new items were added)
|
||||
const merged = DEFAULT_ITEMS.map((def) => {
|
||||
const found = stored.find((s) => s.id === def.id);
|
||||
return found ? { ...def, price: found.price } : def;
|
||||
});
|
||||
set({ items: merged, isHydrated: true });
|
||||
if (stored && stored.length > 0) {
|
||||
// Use stored items directly (preserves custom labels, prices, added items).
|
||||
// Re-add any default items that were never stored (fresh install gap).
|
||||
const storedIds = new Set(stored.map((s) => s.id));
|
||||
const missing = DEFAULT_ITEMS.filter((d) => !storedIds.has(d.id));
|
||||
set({ items: [...stored, ...missing], isHydrated: true });
|
||||
} else {
|
||||
set({ isHydrated: true });
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user