diff --git a/STRIPE_INTEGRATION.md b/STRIPE_INTEGRATION.md deleted file mode 100644 index e43d3ab..0000000 --- a/STRIPE_INTEGRATION.md +++ /dev/null @@ -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` للتحديث التدريجي - ---- - -### 4. `src/components/checkout/PaymentStep.tsx` -**قبل:** فورم يدوي يجمع بيانات البطاقة (رقم، تاريخ انتهاء، CVV، اسم) — بدون معالجة حقيقية - -**بعد:** يستخدم `` من 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 بـ `` 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 -``` diff --git a/prisma/lootah.db b/prisma/lootah.db index 494bb18..f3d1d31 100644 Binary files a/prisma/lootah.db and b/prisma/lootah.db differ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a801c90..a28da2a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 +} diff --git a/public/models/security-guard.glb b/public/models/security-guard.glb new file mode 100644 index 0000000..286b3f6 Binary files /dev/null and b/public/models/security-guard.glb differ diff --git a/public/models/security-guardglb.glb b/public/models/security-guardglb.glb new file mode 100644 index 0000000..286b3f6 Binary files /dev/null and b/public/models/security-guardglb.glb differ diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 3412cfe..866f54f 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -15,7 +15,7 @@ interface Order { metadata: Record; } -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>({}); + const [editedLabels, setEditedLabels] = useState>({}); const [priceSaved, setPriceSaved] = useState(false); const [newItem, setNewItem] = useState({ id: '', label: '', price: '' }); + const [addItemGlb, setAddItemGlb] = useState(null); + const [addItemUploading, setAddItemUploading] = useState(false); const [addItemError, setAddItemError] = useState(''); + // Per-row GLB upload state + const [rowGlbFiles, setRowGlbFiles] = useState>({}); + const [rowGlbUploading, setRowGlbUploading] = useState>({}); + const [rowGlbError, setRowGlbError] = useState>({}); useEffect(() => { pricingStore.getState().hydrate(); }, []); useEffect(() => { - const map: Record = {}; - items.forEach((item) => { map[item.id] = item.price; }); - setEditedPrices(map); + const priceMap: Record = {}; + const labelMap: Record = {}; + 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 */}
- {(['pricing', 'personas', 'orders'] as Tab[]).map((t) => ( + {(['pricing', 'personas', 'orders', 'settings'] as Tab[]).map((t) => ( + )} +
+
{item.id !== 'base' && ( )}
+ {rowGlbError[item.id] && ( +
{rowGlbError[item.id]}
+ )} + ))} @@ -249,21 +432,59 @@ export default function AdminPage() {
- setNewItem((p) => ({ ...p, id: e.target.value }))} /> + setNewItem((p) => ({ ...p, id: e.target.value }))} />
- setNewItem((p) => ({ ...p, label: e.target.value }))} /> + setNewItem((p) => ({ ...p, label: e.target.value }))} />
setNewItem((p) => ({ ...p, price: e.target.value }))} />
+ + {/* GLB file picker */} +
+ + +
+ {addItemError &&

{addItemError}

}
- + @@ -370,6 +591,60 @@ export default function AdminPage() { )}
)} + + {/* SETTINGS TAB */} + {activeTab === 'settings' && ( +
+

App Settings

+ {settingError &&

{settingError}

} + {settingsLoading ? ( +

Loading…

+ ) : ( + + + {settings.length === 0 && ( +

No settings yet.

+ )} + {settings.map((s, i) => ( + + ))} +
+ )} + + {/* Add new setting */} +
+

Add Setting

+
+
+ + setNewSetting((p) => ({ ...p, key: e.target.value }))} + placeholder="e.g. site_name" + style={formInputStyle} + /> +
+
+ + setNewSetting((p) => ({ ...p, value: e.target.value }))} + placeholder="Value" + style={formInputStyle} + /> +
+ +
+
+
+ )} {/* 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 ( +
0 ? '1px solid rgba(0,0,0,0.04)' : 'none' }}> + {setting.key} + setEditVal(e.target.value)} + style={{ ...formInputStyle, fontSize: '0.8rem', padding: '0.3rem 0.5rem' }} + /> + + +
+ ); +} + function OrderRow({ order, isLast, diff --git a/src/app/api/admin/orders/route.ts b/src/app/api/admin/orders/route.ts index eb632d3..7722e56 100644 --- a/src/app/api/admin/orders/route.ts +++ b/src/app/api/admin/orders/route.ts @@ -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[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 }); } } + diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts new file mode 100644 index 0000000..b827ad4 --- /dev/null +++ b/src/app/api/admin/settings/route.ts @@ -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 }); +} diff --git a/src/app/api/admin/upload-model/route.ts b/src/app/api/admin/upload-model/route.ts new file mode 100644 index 0000000..034df6e --- /dev/null +++ b/src/app/api/admin/upload-model/route.ts @@ -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` }); +} diff --git a/src/app/api/webhooks/stripe/route.ts b/src/app/api/webhooks/stripe/route.ts new file mode 100644 index 0000000..e76bf66 --- /dev/null +++ b/src/app/api/webhooks/stripe/route.ts @@ -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 }); +} diff --git a/src/components/RobotModel.tsx b/src/components/RobotModel.tsx index 1f2ba18..2e208ba 100644 --- a/src/components/RobotModel.tsx +++ b/src/components/RobotModel.tsx @@ -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 = { +const STATIC_ATTIRE_GLB: Record = { '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 { + const dynamic: Record = {}; + 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); diff --git a/src/store/usePersonaStore.ts b/src/store/usePersonaStore.ts index 47f0b5e..efdb6e0 100644 --- a/src/store/usePersonaStore.ts +++ b/src/store/usePersonaStore.ts @@ -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((set, get) => ({ label: persona.label, description: persona.description, colors: persona.colors, + ...(persona.modelPath ? { modelPath: persona.modelPath } : {}), }; set((state) => { const updated = [...state.personas, newPersona]; diff --git a/src/store/usePricingStore.ts b/src/store/usePricingStore.ts index 413deb6..7f79fe2 100644 --- a/src/store/usePricingStore.ts +++ b/src/store/usePricingStore.ts @@ -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>) => void; addItem: (item: PricingItem) => void; removeItem: (itemId: string) => void; resetPrices: () => void; @@ -68,6 +70,16 @@ export const pricingStore = createStore((set, get) => ({ }); }, + updateItem: (itemId: string, updates: Partial>) => { + 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((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 }); }