feat: implement pricing item model and CRUD API for pricing management
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
+84 -45
View File
@@ -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<string, number> = {};
@@ -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}
<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' }}
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 }))} />
{rowGlbFiles[item.id] ? '📎 ' + rowGlbFiles[item.id]!.name.slice(0, 12) + '…' : '+ .glb'}
<input type="file" accept=".glb" style={{ display: 'none' }} onChange={(e) => {
const f = e.target.files?.[0] ?? null;
if (f) handleRowGlbUpload(item.id, f);
e.target.value = '';
}} />
{rowGlbUploading[item.id] ? '⏳ Uploading…' : '+ .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>
<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>