feat: add Robot Doctor and Security Guard personas with pricing and model paths

This commit is contained in:
Najjar\NajjarV02 2026-04-17 10:10:54 +04:00
parent f53b2d3cb8
commit 0793a650fb
8 changed files with 62 additions and 6 deletions

0
dev.db Normal file
View File

Binary file not shown.

Binary file not shown.

View File

@ -37,9 +37,36 @@ export default function AdminPage() {
const [rowGlbError, setRowGlbError] = useState<Record<string, string>>({});
const [syncingGlbs, setSyncingGlbs] = useState(false);
const [syncResult, setSyncResult] = useState('');
// Local state for immediate badge update after upload (bypasses any store re-render delay)
const [uploadedPaths, setUploadedPaths] = useState<Record<string, string>>({});
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) {
personaStore.getState().updatePersona(id, { modelPath });
} else {
personaStore.getState().addPersona({ id, label: item.label, description: item.label, colors: { torso: '#3b82f6', legs: '#3b82f6' }, modelPath });
}
}
});
})
.catch(() => {}); // silent — non-critical
}, [isPricingHydrated]);
useEffect(() => {
const priceMap: Record<string, number> = {};
const labelMap: Record<string, string> = {};
@ -93,6 +120,8 @@ export default function AdminPage() {
modelPath,
});
}
// Immediately reflect the new path in the badge regardless of store re-render timing
setUploadedPaths((p) => ({ ...p, [itemId]: modelPath }));
setRowGlbFiles((p) => ({ ...p, [itemId]: null }));
} catch (err) {
setRowGlbError((p) => ({ ...p, [itemId]: err instanceof Error ? err.message : 'Upload failed' }));
@ -432,12 +461,12 @@ export default function AdminPage() {
{/* 3D Model column */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
{item.modelPath ? (
{(uploadedPaths[item.id] || item.modelPath) ? (
<span
title={item.modelPath}
title={uploadedPaths[item.id] || item.modelPath}
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.7rem', color: '#16a34a', background: 'rgba(22,163,74,0.08)', border: '1px solid rgba(22,163,74,0.2)', borderRadius: '0.375rem', padding: '0.2rem 0.45rem', whiteSpace: 'nowrap', overflow: 'hidden', maxWidth: '100%' }}
>
{item.modelPath.split('/').pop()}
{(uploadedPaths[item.id] || item.modelPath)!.split('/').pop()?.split('?')[0]}
</span>
) : null}
<label

View File

@ -51,5 +51,7 @@ export async function POST(request: Request) {
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(destPath, buffer);
return NextResponse.json({ modelPath: `/models/${safeId}.glb` });
// Append a version timestamp so useGLTF cache is busted on replacement uploads
const version = Date.now();
return NextResponse.json({ modelPath: `/models/${safeId}.glb?v=${version}` });
}

View File

@ -22,7 +22,7 @@ function buildAttireGlbMap(personas: { id: string; modelPath?: string }[]): Reco
personas.forEach((p) => {
if (p.modelPath && p.id !== 'none') dynamic[p.id] = p.modelPath;
});
return { ...dynamic, ...STATIC_ATTIRE_GLB }; // static takes priority
return { ...STATIC_ATTIRE_GLB, ...dynamic }; // uploaded GLBs override static
}
function easeInOutCubic(t: number): number {
@ -32,6 +32,15 @@ function easeInOutCubic(t: number): number {
}
function AttireModel({ glbPath, onLoaded }: { glbPath: string; onLoaded: () => void }) {
// Clear stale useGLTF cache entries for paths that share the same base filename
// (e.g. /models/robot-doctor.glb?v=1 replaced by ?v=2). This prevents Three.js
// from serving the old binary when the version query string changes.
useEffect(() => {
return () => {
useGLTF.clear(glbPath);
};
}, [glbPath]);
const { scene } = useGLTF(glbPath);
const processedAttire = useMemo(() => {

View File

@ -49,6 +49,18 @@ export const DEFAULT_PERSONAS: PersonaOption[] = [
description: 'Professional navy suit',
colors: { torso: '#1e293b', legs: '#1e293b' },
},
{
id: 'robot-doctor',
label: 'Robot Doctor',
description: 'Medical doctor attire',
colors: { torso: '#ffffff', legs: '#ffffff' },
},
{
id: 'security-guard',
label: 'Security Guard',
description: 'Security personnel uniform',
colors: { torso: '#1c1c1c', legs: '#1c1c1c' },
},
];
const STORAGE_KEY = 'lootah-personas';
@ -126,7 +138,9 @@ export const personaStore = createStore<PersonaStore>((set, get) => ({
hydrate: () => {
const stored = loadFromStorage();
if (stored && stored.length > 0) {
set({ personas: stored, isHydrated: true });
const storedIds = new Set(stored.map((s) => s.id));
const missing = DEFAULT_PERSONAS.filter((d) => !storedIds.has(d.id));
set({ personas: [...stored, ...missing], isHydrated: true });
} else {
set({ isHydrated: true });
}

View File

@ -30,6 +30,8 @@ const DEFAULT_ITEMS: PricingItem[] = [
{ id: 'industrial-vest', label: 'Industrial Vest', price: 8500 },
{ id: 'business-suit', label: 'Business Suit', price: 12000 },
{ id: 'custom-color', label: 'Custom Color', price: 3500 },
{ id: 'robot-doctor', label: 'Robot Doctor', price: 5000 },
{ id: 'security-guard', label: 'Security Guard', price: 5000 },
];
const STORAGE_KEY = 'lootah-pricing';