feat: implement pricing item model and CRUD API for pricing management
Some checks are pending
CI/CD / test-and-build (push) Waiting to run
CI/CD / deploy (push) Blocked by required conditions

This commit is contained in:
Najjar\NajjarV02 2026-04-17 15:55:59 +04:00
parent 5aba12f163
commit 8216cbe0c0
5 changed files with 219 additions and 45 deletions

View File

@ -46,3 +46,13 @@ model Order {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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
}

View File

@ -47,6 +47,26 @@ async function main() {
console.log('✓ JWT secret already exists, skipping.'); 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!'); console.log('Seeding complete!');
} }

View File

@ -42,30 +42,41 @@ export default function AdminPage() {
useEffect(() => { pricingStore.getState().hydrate(); }, []); useEffect(() => { pricingStore.getState().hydrate(); }, []);
// Auto-sync GLB files from server after hydration — ensures modelPaths are always // Load pricing from server DB on mount and sync to local stores
// populated regardless of which browser/session last uploaded the files. const loadPricingFromServer = useCallback(async () => {
useEffect(() => { try {
if (!isPricingHydrated) return; const res = await fetch('/api/admin/pricing/');
fetch('/api/admin/list-models/') const data = await res.json();
.then((r) => r.json()) const serverItems: { id: string; label: string; price: number; modelPath: string | null }[] = data.items ?? [];
.then((data) => { if (serverItems.length > 0) {
const models: { id: string; modelPath: string }[] = data.models ?? []; // Sync server data into local stores
models.forEach(({ id, modelPath }) => { serverItems.forEach(({ id, label, price, modelPath }) => {
const item = pricingStore.getState().items.find((i) => i.id === id); const existing = 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) { if (existing) {
personaStore.getState().updatePersona(id, { modelPath }); pricingStore.getState().updateItem(id, { label, price, modelPath: modelPath ?? undefined });
} else { } else {
personaStore.getState().addPersona({ id, label: item.label, description: item.label, colors: { torso: '#3b82f6', legs: '#3b82f6' }, modelPath }); 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 if (id !== 'base') {
personaStore.getState().addPersona({ id, label, description: label, colors: { torso: '#3b82f6', legs: '#3b82f6' }, modelPath });
} }
} }
}); });
}) }
.catch(() => {}); // silent — non-critical } catch {
}, [isPricingHydrated]); // Fall back to localStorage data (already hydrated)
}
}, []);
useEffect(() => {
if (!isPricingHydrated) return;
loadPricingFromServer();
}, [isPricingHydrated, loadPricingFromServer]);
useEffect(() => { useEffect(() => {
const priceMap: Record<string, number> = {}; const priceMap: Record<string, number> = {};
@ -80,38 +91,61 @@ export default function AdminPage() {
setEditedPrices((prev) => ({ ...prev, [id]: isNaN(num) ? 0 : num })); setEditedPrices((prev) => ({ ...prev, [id]: isNaN(num) ? 0 : num }));
}; };
const handleSavePrices = () => { const handleSavePrices = async () => {
items.forEach((item) => { // Update local stores
pricingStore.getState().updateItem(item.id, { const updatedItems = items.map((item, i) => ({
price: editedPrices[item.id] ?? item.price, id: item.id,
label: (editedLabels[item.id] ?? item.label).trim() || item.label, 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); setPriceSaved(true);
setTimeout(() => setPriceSaved(false), 2000); setTimeout(() => setPriceSaved(false), 2000);
}; };
const handleRowGlbUpload = async (itemId: string) => { const handleRowGlbUpload = async (itemId: string, file?: File) => {
const file = rowGlbFiles[itemId]; const uploadFile = file ?? rowGlbFiles[itemId];
if (!file) return; if (!uploadFile) return;
setRowGlbUploading((p) => ({ ...p, [itemId]: true })); setRowGlbUploading((p) => ({ ...p, [itemId]: true }));
setRowGlbError((p) => ({ ...p, [itemId]: '' })); setRowGlbError((p) => ({ ...p, [itemId]: '' }));
try { try {
const fd = new FormData(); const fd = new FormData();
fd.append('file', file); fd.append('file', uploadFile);
fd.append('itemId', itemId); fd.append('itemId', itemId);
const res = await fetch('/api/admin/upload-model/', { method: 'POST', body: fd }); const res = await fetch('/api/admin/upload-model/', { method: 'POST', body: fd });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error ?? 'Upload failed'); if (!res.ok) throw new Error(data.error ?? 'Upload failed');
const modelPath = data.modelPath as string; const modelPath = data.modelPath as string;
// Update pricing item // Update pricing item in local store
pricingStore.getState().updateItem(itemId, { modelPath }); 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 // Update or create persona
const existing = personaStore.getState().personas.find((p) => p.id === itemId); const existing = personaStore.getState().personas.find((p) => p.id === itemId);
if (existing) { if (existing) {
personaStore.getState().updatePersona(itemId, { modelPath }); personaStore.getState().updatePersona(itemId, { modelPath });
} else { } else {
const item = pricingStore.getState().items.find((i) => i.id === itemId);
personaStore.getState().addPersona({ personaStore.getState().addPersona({
id: itemId, id: itemId,
label: item?.label ?? itemId, label: item?.label ?? itemId,
@ -210,6 +244,13 @@ export default function AdminPage() {
pricingStore.getState().addItem({ id, label, price, modelPath }); 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 a GLB was uploaded, also register as a persona so it shows on the robot
if (modelPath) { if (modelPath) {
personaStore.getState().addPersona({ personaStore.getState().addPersona({
@ -471,25 +512,23 @@ export default function AdminPage() {
) : null} ) : null}
<label <label
title="Upload / replace .glb" 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' }} style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem', cursor: rowGlbUploading[item.id] ? 'wait' : 'pointer', fontSize: '0.7rem', color: rowGlbUploading[item.id] ? '#2563eb' : '#94a3b8', background: rowGlbUploading[item.id] ? 'rgba(59,130,246,0.06)' : 'transparent', border: `1px dashed ${rowGlbUploading[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 }))} /> <input type="file" accept=".glb" style={{ display: 'none' }} onChange={(e) => {
{rowGlbFiles[item.id] ? '📎 ' + rowGlbFiles[item.id]!.name.slice(0, 12) + '…' : ' .glb'} const f = e.target.files?.[0] ?? null;
if (f) handleRowGlbUpload(item.id, f);
e.target.value = '';
}} />
{rowGlbUploading[item.id] ? '⏳ Uploading…' : ' .glb'}
</label> </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>
<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);
fetch(`/api/admin/pricing/?id=${encodeURIComponent(item.id)}`, { method: 'DELETE' }).catch(() => {});
}} style={deleteBtnStyle} title="Remove item"></button>
)} )}
</div> </div>
</div> </div>

View File

@ -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 });
}
}

View File

@ -117,6 +117,20 @@ export const pricingStore = createStore<PricingStore>((set, get) => ({
} else { } else {
set({ isHydrated: true }); 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
}
}, },
})); }));