forked from hazem/yslootahrobotics
210 lines
6.7 KiB
TypeScript
210 lines
6.7 KiB
TypeScript
import { createStore } from 'zustand/vanilla';
|
|
import { useSyncExternalStore } from 'react';
|
|
|
|
export interface PersonaOption {
|
|
id: string;
|
|
label: string;
|
|
description: string;
|
|
colors: { torso: string; legs: string };
|
|
modelPath?: string;
|
|
}
|
|
|
|
export interface PersonaState {
|
|
personas: PersonaOption[];
|
|
isHydrated: boolean;
|
|
}
|
|
|
|
export interface PersonaActions {
|
|
addPersona: (persona: Omit<PersonaOption, 'id'> & { id?: string }) => void;
|
|
removePersona: (id: string) => void;
|
|
updatePersona: (id: string, updates: Partial<Omit<PersonaOption, 'id'>>) => void;
|
|
resetPersonas: () => void;
|
|
hydrate: () => void;
|
|
}
|
|
|
|
export type PersonaStore = PersonaState & PersonaActions;
|
|
|
|
export const DEFAULT_PERSONAS: PersonaOption[] = [
|
|
{
|
|
id: 'none',
|
|
label: 'Default',
|
|
description: 'Original robot appearance',
|
|
colors: { torso: '#3b82f6', legs: '#3b82f6' },
|
|
},
|
|
{
|
|
id: 'emarati-kandura',
|
|
label: 'Emarati Kandura',
|
|
description: 'Traditional white robe attire',
|
|
colors: { torso: '#f8fafc', legs: '#f8fafc' },
|
|
},
|
|
{
|
|
id: 'industrial-vest',
|
|
label: 'Industrial Vest',
|
|
description: 'High-visibility safety vest',
|
|
colors: { torso: '#f59e0b', legs: '#3b82f6' },
|
|
},
|
|
{
|
|
id: 'business-suit',
|
|
label: 'Business Suit',
|
|
description: 'Professional navy suit',
|
|
colors: { torso: '#1e293b', legs: '#1e293b' },
|
|
},
|
|
];
|
|
|
|
const STORAGE_KEY = 'lootah-personas';
|
|
|
|
function loadFromStorage(): PersonaOption[] | null {
|
|
if (typeof window === 'undefined') return null;
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY);
|
|
if (!raw) return null;
|
|
const parsed = JSON.parse(raw);
|
|
if (!Array.isArray(parsed)) return null;
|
|
return parsed;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function saveToStorage(personas: PersonaOption[]) {
|
|
if (typeof window === 'undefined') return;
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(personas));
|
|
} catch {
|
|
// Storage unavailable
|
|
}
|
|
}
|
|
|
|
function generateId(): string {
|
|
return `persona-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
}
|
|
|
|
export const personaStore = createStore<PersonaStore>((set, get) => ({
|
|
personas: DEFAULT_PERSONAS,
|
|
isHydrated: false,
|
|
|
|
addPersona: (persona) => {
|
|
const newPersona: PersonaOption = {
|
|
id: persona.id ?? generateId(),
|
|
label: persona.label,
|
|
description: persona.description,
|
|
colors: persona.colors,
|
|
...(persona.modelPath ? { modelPath: persona.modelPath } : {}),
|
|
};
|
|
set((state) => {
|
|
const updated = [...state.personas, newPersona];
|
|
saveToStorage(updated);
|
|
return { personas: updated };
|
|
});
|
|
},
|
|
|
|
removePersona: (id) => {
|
|
// Prevent removing the default 'none' persona
|
|
if (id === 'none') return;
|
|
set((state) => {
|
|
const updated = state.personas.filter((p) => p.id !== id);
|
|
saveToStorage(updated);
|
|
return { personas: updated };
|
|
});
|
|
},
|
|
|
|
updatePersona: (id, updates) => {
|
|
set((state) => {
|
|
const updated = state.personas.map((p) =>
|
|
p.id === id ? { ...p, ...updates } : p
|
|
);
|
|
saveToStorage(updated);
|
|
return { personas: updated };
|
|
});
|
|
},
|
|
|
|
resetPersonas: () => {
|
|
saveToStorage(DEFAULT_PERSONAS);
|
|
set({ personas: [...DEFAULT_PERSONAS] });
|
|
},
|
|
|
|
hydrate: () => {
|
|
// Guard: only hydrate once — prevents race condition duplicates when
|
|
// called from multiple components at the same time.
|
|
if (get().isHydrated) return;
|
|
set({ isHydrated: true });
|
|
|
|
const raw = loadFromStorage();
|
|
// Deduplicate stored personas (keep last occurrence of each id)
|
|
const deduped = raw
|
|
? [...new Map(raw.map((p) => [p.id, p])).values()]
|
|
: null;
|
|
|
|
if (deduped && deduped.length > 0) {
|
|
// Only re-inject truly built-in personas (those still in DEFAULT_PERSONAS) if missing.
|
|
// Dynamic/uploaded personas that were deleted via the dashboard must NOT be re-added.
|
|
const storedIds = new Set(deduped.map((s) => s.id));
|
|
const missing = DEFAULT_PERSONAS.filter((d) => !storedIds.has(d.id));
|
|
set({ personas: [...deduped, ...missing] });
|
|
} else {
|
|
set({ personas: [...DEFAULT_PERSONAS] });
|
|
}
|
|
|
|
// Fetch pricing items from server DB and auto-register personas for all attire items
|
|
if (typeof window !== 'undefined') {
|
|
fetch('/api/admin/pricing/')
|
|
.then((r) => r.json())
|
|
.then((data) => {
|
|
const serverItems: { id: string; label: string; modelPath: string | null }[] = data.items ?? [];
|
|
const current = get().personas;
|
|
const currentIds = new Set(current.map((p) => p.id));
|
|
const newPersonas: PersonaOption[] = [];
|
|
// Items that should not appear as selectable personas
|
|
const excludeIds = new Set(['base', 'custom-color']);
|
|
|
|
serverItems.forEach(({ id, label, modelPath }) => {
|
|
if (excludeIds.has(id)) return;
|
|
if (currentIds.has(id)) {
|
|
// Update modelPath if it changed
|
|
const existing = current.find((p) => p.id === id);
|
|
if (existing && modelPath && existing.modelPath !== modelPath) {
|
|
set((state) => ({
|
|
personas: state.personas.map((p) =>
|
|
p.id === id ? { ...p, modelPath } : p
|
|
),
|
|
}));
|
|
}
|
|
} else {
|
|
// Auto-create a persona entry for every pricing item
|
|
newPersonas.push({
|
|
id,
|
|
label,
|
|
description: label,
|
|
colors: { torso: '#3b82f6', legs: '#3b82f6' },
|
|
...(modelPath ? { modelPath } : {}),
|
|
});
|
|
}
|
|
});
|
|
|
|
if (newPersonas.length > 0) {
|
|
set((state) => {
|
|
// Deduplicate: merge by id, new personas take precedence for modelPath
|
|
const merged = new Map(state.personas.map((p) => [p.id, p]));
|
|
newPersonas.forEach((p) => { if (!merged.has(p.id)) merged.set(p.id, p); });
|
|
const updated = [...merged.values()];
|
|
saveToStorage(updated);
|
|
return { personas: updated };
|
|
});
|
|
} else {
|
|
// Save current state to localStorage so it persists
|
|
saveToStorage(get().personas);
|
|
}
|
|
})
|
|
.catch(() => {}); // silent — use local data as fallback
|
|
}
|
|
},
|
|
}));
|
|
|
|
export const usePersonaStore = <T>(selector: (state: PersonaStore) => T): T => {
|
|
return useSyncExternalStore(
|
|
personaStore.subscribe,
|
|
() => selector(personaStore.getState()),
|
|
() => selector(personaStore.getState())
|
|
);
|
|
};
|