forked from hazem/yslootahrobotics
feat: add Robot Doctor and Security Guard personas with pricing and model paths
This commit is contained in:
parent
f53b2d3cb8
commit
0793a650fb
Binary file not shown.
Binary file not shown.
@ -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
|
||||
|
||||
@ -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}` });
|
||||
}
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user