forked from hazem/yslootahrobotics
feat: implement pricing item model and CRUD API for pricing management
This commit is contained in:
parent
5aba12f163
commit
8216cbe0c0
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
91
src/app/api/admin/pricing/route.ts
Normal file
91
src/app/api/admin/pricing/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user