yslootahrobotics/src/store/usePersonaStore.ts
Najjar\NajjarV02 11ab8908fa
Some checks are pending
CI/CD / test-and-build (push) Waiting to run
CI/CD / deploy (push) Blocked by required conditions
feat: enhance pricing and persona synchronization logic for improved data consistency
2026-04-17 16:08:11 +04:00

197 lines
6.1 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: () => {
const stored = loadFromStorage();
if (stored && stored.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(stored.map((s) => s.id));
const missing = DEFAULT_PERSONAS.filter((d) => !storedIds.has(d.id));
set({ personas: [...stored, ...missing], isHydrated: true });
} else {
set({ personas: [...DEFAULT_PERSONAS], isHydrated: true });
}
// 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) => {
const updated = [...state.personas, ...newPersonas];
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())
);
};