From 8216cbe0c07adf0ce362ed06211657609d9eec10 Mon Sep 17 00:00:00 2001 From: "Najjar\\NajjarV02" Date: Fri, 17 Apr 2026 15:55:59 +0400 Subject: [PATCH] feat: implement pricing item model and CRUD API for pricing management --- prisma/schema.prisma | 10 +++ prisma/seed.ts | 20 +++++ src/app/admin/page.tsx | 129 +++++++++++++++++++---------- src/app/api/admin/pricing/route.ts | 91 ++++++++++++++++++++ src/store/usePricingStore.ts | 14 ++++ 5 files changed, 219 insertions(+), 45 deletions(-) create mode 100644 src/app/api/admin/pricing/route.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e699111..1511b68 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -46,3 +46,13 @@ model Order { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +model PricingItem { + id String @id + label String + price Int + modelPath String? + sortOrder Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/prisma/seed.ts b/prisma/seed.ts index 8600244..fc5762f 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -47,6 +47,26 @@ async function main() { console.log('✓ JWT secret already exists, skipping.'); } + // Seed default pricing items (idempotent — only if table is empty) + const pricingCount = await prisma.pricingItem.count(); + if (pricingCount === 0) { + const defaultItems = [ + { id: 'base', label: 'G1 Robot Base', price: 250000, sortOrder: 0 }, + { id: 'emarati-kandura', label: 'Emarati Kandura', price: 15000, sortOrder: 1 }, + { id: 'industrial-vest', label: 'Industrial Vest', price: 8500, sortOrder: 2 }, + { id: 'business-suit', label: 'Business Suit', price: 12000, sortOrder: 3 }, + { id: 'custom-color', label: 'Custom Color', price: 3500, sortOrder: 4 }, + { id: 'robot-doctor', label: 'Robot Doctor', price: 5000, sortOrder: 5 }, + { id: 'security-guard', label: 'Security Guard', price: 5000, sortOrder: 6 }, + ]; + for (const item of defaultItems) { + await prisma.pricingItem.create({ data: item }); + } + console.log(`✓ Seeded ${defaultItems.length} default pricing items.`); + } else { + console.log(`✓ ${pricingCount} pricing items already exist, skipping.`); + } + console.log('Seeding complete!'); } diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index fca186e..c23dc65 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -42,30 +42,41 @@ export default function AdminPage() { useEffect(() => { pricingStore.getState().hydrate(); }, []); - // Auto-sync GLB files from server after hydration — ensures modelPaths are always - // populated regardless of which browser/session last uploaded the files. - useEffect(() => { - if (!isPricingHydrated) return; - fetch('/api/admin/list-models/') - .then((r) => r.json()) - .then((data) => { - const models: { id: string; modelPath: string }[] = data.models ?? []; - models.forEach(({ id, modelPath }) => { - const item = pricingStore.getState().items.find((i) => i.id === id); - // Only link if the item exists and has no modelPath yet (don't overwrite versioned upload URLs) - if (item && !item.modelPath) { - pricingStore.getState().updateItem(id, { modelPath }); - const existing = personaStore.getState().personas.find((p) => p.id === id); - if (existing) { + // Load pricing from server DB on mount and sync to local stores + const loadPricingFromServer = useCallback(async () => { + try { + const res = await fetch('/api/admin/pricing/'); + const data = await res.json(); + const serverItems: { id: string; label: string; price: number; modelPath: string | null }[] = data.items ?? []; + if (serverItems.length > 0) { + // Sync server data into local stores + serverItems.forEach(({ id, label, price, modelPath }) => { + const existing = pricingStore.getState().items.find((i) => i.id === id); + if (existing) { + pricingStore.getState().updateItem(id, { label, price, modelPath: modelPath ?? undefined }); + } else { + pricingStore.getState().addItem({ id, label, price, modelPath: modelPath ?? undefined }); + } + // Sync persona store + if (modelPath) { + const existingPersona = personaStore.getState().personas.find((p) => p.id === id); + if (existingPersona) { personaStore.getState().updatePersona(id, { modelPath }); - } else { - personaStore.getState().addPersona({ id, label: item.label, description: item.label, colors: { torso: '#3b82f6', legs: '#3b82f6' }, modelPath }); + } else if (id !== 'base') { + personaStore.getState().addPersona({ id, label, description: label, colors: { torso: '#3b82f6', legs: '#3b82f6' }, modelPath }); } } }); - }) - .catch(() => {}); // silent — non-critical - }, [isPricingHydrated]); + } + } catch { + // Fall back to localStorage data (already hydrated) + } + }, []); + + useEffect(() => { + if (!isPricingHydrated) return; + loadPricingFromServer(); + }, [isPricingHydrated, loadPricingFromServer]); useEffect(() => { const priceMap: Record = {}; @@ -80,38 +91,61 @@ export default function AdminPage() { setEditedPrices((prev) => ({ ...prev, [id]: isNaN(num) ? 0 : num })); }; - const handleSavePrices = () => { - items.forEach((item) => { - pricingStore.getState().updateItem(item.id, { - price: editedPrices[item.id] ?? item.price, - label: (editedLabels[item.id] ?? item.label).trim() || item.label, - }); + const handleSavePrices = async () => { + // Update local stores + const updatedItems = items.map((item, i) => ({ + id: item.id, + label: (editedLabels[item.id] ?? item.label).trim() || item.label, + price: editedPrices[item.id] ?? item.price, + modelPath: item.modelPath, + sortOrder: i, + })); + updatedItems.forEach((u) => { + pricingStore.getState().updateItem(u.id, { price: u.price, label: u.label }); }); + // Persist to server DB + try { + await fetch('/api/admin/pricing/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ items: updatedItems }), + }); + } catch { + // localStorage already saved as fallback + } setPriceSaved(true); setTimeout(() => setPriceSaved(false), 2000); }; - const handleRowGlbUpload = async (itemId: string) => { - const file = rowGlbFiles[itemId]; - if (!file) return; + const handleRowGlbUpload = async (itemId: string, file?: File) => { + const uploadFile = file ?? rowGlbFiles[itemId]; + if (!uploadFile) return; setRowGlbUploading((p) => ({ ...p, [itemId]: true })); setRowGlbError((p) => ({ ...p, [itemId]: '' })); try { const fd = new FormData(); - fd.append('file', file); + fd.append('file', uploadFile); 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 + // Update pricing item in local store pricingStore.getState().updateItem(itemId, { modelPath }); + // Persist model path to server DB + const item = pricingStore.getState().items.find((i) => i.id === itemId); + if (item) { + fetch('/api/admin/pricing/', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: itemId, label: item.label, price: item.price, modelPath }), + }).catch(() => {}); + } // 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, @@ -210,6 +244,13 @@ export default function AdminPage() { pricingStore.getState().addItem({ id, label, price, modelPath }); + // Persist to server DB + fetch('/api/admin/pricing/', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, label, price, modelPath: modelPath ?? null }), + }).catch(() => {}); + // If a GLB was uploaded, also register as a persona so it shows on the robot if (modelPath) { personaStore.getState().addPersona({ @@ -471,25 +512,23 @@ export default function AdminPage() { ) : null} - {rowGlbFiles[item.id] && ( - - )}
{item.id !== 'base' && ( - + )}
diff --git a/src/app/api/admin/pricing/route.ts b/src/app/api/admin/pricing/route.ts new file mode 100644 index 0000000..42db6ea --- /dev/null +++ b/src/app/api/admin/pricing/route.ts @@ -0,0 +1,91 @@ +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/pricing/ → all pricing items from DB +export async function GET() { + // Public endpoint — no auth required so the storefront can read prices + const items = await prisma.pricingItem.findMany({ orderBy: { sortOrder: 'asc' } }); + return NextResponse.json({ items }); +} + +// PUT /api/admin/pricing/ → upsert a single pricing item +export async function PUT(request: Request) { + if (!(await verifyAdmin())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const body = await request.json(); + const { id, label, price, modelPath, sortOrder } = body; + + if (!id || typeof id !== 'string') return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); + if (!label || typeof label !== 'string') return NextResponse.json({ error: 'Invalid label' }, { status: 400 }); + if (typeof price !== 'number' || price < 0) return NextResponse.json({ error: 'Invalid price' }, { status: 400 }); + + const safeId = id.toLowerCase().replace(/_/g, '-').replace(/[^a-z0-9-]/g, ''); + if (!safeId) return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); + + const item = await prisma.pricingItem.upsert({ + where: { id: safeId }, + create: { id: safeId, label: label.trim(), price, modelPath: modelPath ?? null, sortOrder: sortOrder ?? 0 }, + update: { label: label.trim(), price, ...(modelPath !== undefined ? { modelPath } : {}), ...(sortOrder !== undefined ? { sortOrder } : {}) }, + }); + + return NextResponse.json({ item }); +} + +// POST /api/admin/pricing/ → bulk save all items +export async function POST(request: Request) { + if (!(await verifyAdmin())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { items } = await request.json(); + if (!Array.isArray(items)) return NextResponse.json({ error: 'items must be an array' }, { status: 400 }); + + const results = []; + for (let i = 0; i < items.length; i++) { + const { id, label, price, modelPath } = items[i]; + if (!id || !label || typeof price !== 'number') continue; + + const safeId = String(id).toLowerCase().replace(/_/g, '-').replace(/[^a-z0-9-]/g, ''); + if (!safeId) continue; + + const item = await prisma.pricingItem.upsert({ + where: { id: safeId }, + create: { id: safeId, label: String(label).trim(), price, modelPath: modelPath ?? null, sortOrder: i }, + update: { label: String(label).trim(), price, sortOrder: i, ...(modelPath !== undefined ? { modelPath } : {}) }, + }); + results.push(item); + } + + return NextResponse.json({ items: results }); +} + +// DELETE /api/admin/pricing/ → delete a pricing item +export async function DELETE(request: Request) { + if (!(await verifyAdmin())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 }); + if (id === 'base') return NextResponse.json({ error: 'Cannot delete base item' }, { status: 400 }); + + try { + await prisma.pricingItem.delete({ where: { id } }); + return NextResponse.json({ success: true }); + } catch { + return NextResponse.json({ error: 'Item not found' }, { status: 404 }); + } +} diff --git a/src/store/usePricingStore.ts b/src/store/usePricingStore.ts index 5629da1..86f5a8b 100644 --- a/src/store/usePricingStore.ts +++ b/src/store/usePricingStore.ts @@ -117,6 +117,20 @@ export const pricingStore = createStore((set, get) => ({ } else { set({ isHydrated: true }); } + + // Also fetch from server DB to get the latest pricing (async, non-blocking) + if (typeof window !== 'undefined') { + fetch('/api/admin/pricing/') + .then((r) => r.json()) + .then((data) => { + const serverItems: PricingItem[] = data.items ?? []; + if (serverItems.length > 0) { + saveToStorage(serverItems); + set({ items: serverItems }); + } + }) + .catch(() => {}); // silent — use local data as fallback + } }, }));