Compare commits

..

No commits in common. "fe5f1ce25f1268be46a5154e392c1331c7280e3a" and "c26338f35561d12381bef13e80dc707365185771" have entirely different histories.

18 changed files with 102 additions and 285 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

View File

@ -50,19 +50,14 @@ async function main() {
// Seed default pricing items (idempotent — only if table is empty)
const pricingCount = await prisma.pricingItem.count();
if (pricingCount === 0) {
// Prices in AED. USD → AED at 3.6725 (CBUAE peg).
// Basic = Unitree G1 retail ($16k) + $5k markup = $21k ≈ 77,125 AED.
// EDU = $40k flat ≈ 146,900 AED.
// All persona attire = 5,000 AED each.
const defaultItems = [
{ id: 'base', label: 'G1 Robot Basic', price: 77125, sortOrder: 0 },
{ id: 'edu', label: 'G1 Robot EDU', price: 146900, sortOrder: 1 },
{ id: 'emarati-kandura', label: 'Emarati Kandura', price: 5000, sortOrder: 2 },
{ id: 'industrial-vest', label: 'Industrial Vest', price: 5000, sortOrder: 3 },
{ id: 'business-suit', label: 'Business Suit', price: 5000, sortOrder: 4 },
{ id: 'custom-color', label: 'Custom Color', price: 3500, sortOrder: 5 },
{ id: 'robot-doctor', label: 'Robot Doctor', price: 5000, sortOrder: 6 },
{ id: 'security-guard', label: 'Security Guard', price: 5000, sortOrder: 7 },
{ id: 'base', label: 'G1 Robot Base', price: 250000, sortOrder: 0 },
{ id: 'emarati-kandura', label: 'Emarati Kandura', price: 15000, sortOrder: 1 },
{ id: 'industrial-vest', label: 'Industrial Vest', price: 8500, sortOrder: 2 },
{ id: 'business-suit', label: 'Business Suit', price: 12000, sortOrder: 3 },
{ id: 'custom-color', label: 'Custom Color', price: 3500, sortOrder: 4 },
{ id: 'robot-doctor', label: 'Robot Doctor', price: 5000, sortOrder: 5 },
{ id: 'security-guard', label: 'Security Guard', price: 5000, sortOrder: 6 },
];
for (const item of defaultItems) {
await prisma.pricingItem.create({ data: item });

View File

@ -1,40 +0,0 @@
// One-off price refresh. Run with: npx tsx prisma/update-prices.ts
// Idempotent: upserts every row, safe to re-run.
import { PrismaClient } from '../src/generated/prisma/client.js';
import { PrismaLibSql } from '@prisma/adapter-libsql';
import path from 'path';
const dbPath = path.resolve(process.cwd(), 'prisma', 'lootah.db');
const adapter = new PrismaLibSql({ url: `file:${dbPath}` });
const prisma = new PrismaClient({ adapter } as ConstructorParameters<typeof PrismaClient>[0]);
const items = [
{ id: 'base', label: 'G1 Robot Basic', price: 77125, sortOrder: 0 },
{ id: 'edu', label: 'G1 Robot EDU', price: 146900, sortOrder: 1 },
{ id: 'emarati-kandura', label: 'Emarati Kandura', price: 5000, sortOrder: 2 },
{ id: 'industrial-vest', label: 'Industrial Vest', price: 5000, sortOrder: 3 },
{ id: 'business-suit', label: 'Business Suit', price: 5000, sortOrder: 4 },
{ id: 'custom-color', label: 'Custom Color', price: 3500, sortOrder: 5 },
{ id: 'robot-doctor', label: 'Robot Doctor', price: 5000, sortOrder: 6 },
{ id: 'security-guard', label: 'Security Guard', price: 5000, sortOrder: 7 },
];
async function main() {
for (const item of items) {
await prisma.pricingItem.upsert({
where: { id: item.id },
create: item,
update: { label: item.label, price: item.price, sortOrder: item.sortOrder },
});
console.log(`${item.id}${item.label} @ ${item.price} AED`);
}
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -5,15 +5,9 @@ import { configStore, useConfigStore } from '@/store/useConfigStore';
import { personaStore, usePersonaStore } from '@/store/usePersonaStore';
import { PricingEngine } from './PricingEngine';
const BODY_OPTIONS: { id: 'basic' | 'edu'; label: string; description: string }[] = [
{ id: 'basic', label: 'Basic', description: 'Standard consumer variant' },
{ id: 'edu', label: 'EDU', description: 'Open-source education / research variant' },
];
export function ConfigPanel() {
const activeColors = useConfigStore((s) => s.activeColors);
const activePersona = useConfigStore((s) => s.activePersonaAttire);
const activeBody = useConfigStore((s) => s.activeBody);
const personas = usePersonaStore((s) => s.personas);
// Track which persona is loading (waiting for GLB to download)
const [loadingPersona, setLoadingPersona] = useState<string | null>(null);
@ -42,10 +36,6 @@ export function ConfigPanel() {
configStore.getState().setColors({ [key]: value });
}, []);
const handleBodySelect = useCallback((body: 'basic' | 'edu') => {
configStore.getState().setBody(body);
}, []);
const handlePersonaSelect = useCallback((attire: string) => {
// Only show loading for dynamic (uploaded) attire that has a GLB to download
const persona = personaStore.getState().personas.find((p) => p.id === attire);
@ -186,59 +176,6 @@ export function ConfigPanel() {
</div>
</section>
{/* --- ROBOT BODY SECTION --- */}
<section
id="section-body"
style={{
borderRadius: '0.5rem',
padding: '0.75rem',
}}
>
<h3 style={sectionTitleStyle}>Robot Body</h3>
<div style={{ display: 'flex', gap: '0.5rem' }}>
{BODY_OPTIONS.map((opt) => {
const isActive = activeBody === opt.id;
return (
<button
key={opt.id}
onClick={() => handleBodySelect(opt.id)}
aria-pressed={isActive}
style={{
flex: 1,
padding: '0.6rem 0.75rem',
borderRadius: '0.5rem',
border: isActive
? '1px solid rgba(59, 130, 246, 0.5)'
: '1px solid rgba(0, 0, 0, 0.06)',
background: isActive
? 'rgba(59, 130, 246, 0.06)'
: 'rgba(248, 248, 246, 0.4)',
cursor: 'pointer',
transition: 'all 0.25s ease',
textAlign: 'left',
}}
>
<div style={{
fontSize: '0.8rem',
fontWeight: isActive ? 600 : 500,
color: isActive ? '#374151' : '#64748b',
marginBottom: '2px',
}}>
{opt.label}
</div>
<div style={{
fontSize: '0.65rem',
color: '#94a3b8',
lineHeight: 1.3,
}}>
{opt.description}
</div>
</button>
);
})}
</div>
</section>
{/* --- PRICING --- */}
<PricingEngine />

View File

@ -14,7 +14,6 @@ function formatAED(price: number): string {
export function PricingEngine() {
const persona = useConfigStore((s) => s.activePersonaAttire);
const body = useConfigStore((s) => s.activeBody);
const primaryColor = useConfigStore((s) => s.activeColors.primary);
const items = usePricingStore((s) => s.items);
const isHydrated = usePricingStore((s) => s.isHydrated);
@ -24,12 +23,8 @@ export function PricingEngine() {
}, []);
const getPrice = (id: string) => items.find((i) => i.id === id)?.price ?? 0;
const getLabel = (id: string, fallback: string) =>
items.find((i) => i.id === id)?.label ?? fallback;
const bodyId = body === 'edu' ? 'edu' : 'base';
const baseLabel = getLabel(bodyId, body === 'edu' ? 'G1 Robot EDU' : 'G1 Robot Basic');
const basePrice = getPrice(bodyId);
const basePrice = getPrice('base');
const personaPrice = persona !== 'none' ? getPrice(persona) : 0;
const colorPrice = primaryColor !== DEFAULT_COLOR ? getPrice('custom-color') : 0;
const total = basePrice + personaPrice + colorPrice;
@ -42,7 +37,7 @@ export function PricingEngine() {
const store = orderStore.getState();
const lineItems: { label: string; price: number }[] = [
{ label: baseLabel, price: basePrice },
{ label: 'G1 Robot Base', price: basePrice },
...(personaPrice > 0 ? [{ label: personaLabel, price: personaPrice }] : []),
...(colorPrice > 0 ? [{ label: 'Custom Color', price: colorPrice }] : []),
];
@ -82,7 +77,7 @@ export function PricingEngine() {
{/* Line items */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<PriceLine label={baseLabel} price={basePrice} />
<PriceLine label="G1 Robot Base" price={basePrice} />
{personaPrice > 0 && <PriceLine label={personaLabel} price={personaPrice} />}
{colorPrice > 0 && <PriceLine label="Custom Color" price={colorPrice} />}
</div>

View File

@ -1,6 +1,6 @@
'use client';
import { useRef, useMemo, useEffect, Suspense, useState, useCallback, Component, type ReactNode } from 'react';
import { useRef, useMemo, useEffect, Suspense, useState, Component, type ReactNode } from 'react';
import { useGLTF } from '@react-three/drei';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
@ -16,13 +16,6 @@ const STATIC_ATTIRE_GLB: Record<string, string> = {
'business-suit': '/Suit.glb',
};
// Same chassis GLB for both — variants differ in software/licensing (EDU = open-source
// education / research build), not in geometry.
const BODY_GLB: Record<'basic' | 'edu', string> = {
basic: '/Unitree_G1.glb',
edu: '/Unitree_G1.glb',
};
// Attire models are loaded on-demand to avoid blocking the initial 50 MB robot load
/** Merge static map with any custom GLBs stored in the persona store */
@ -34,23 +27,22 @@ function buildAttireGlbMap(personas: { id: string; modelPath?: string }[]): Reco
return { ...STATIC_ATTIRE_GLB, ...dynamic }; // uploaded GLBs override static
}
interface ErrorBoundaryProps {
interface AttireErrorBoundaryProps {
children: ReactNode;
onError: () => void;
tag: string;
}
interface ErrorBoundaryState { hasError: boolean; }
interface AttireErrorBoundaryState { hasError: boolean; }
class ModelErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
class AttireErrorBoundary extends Component<AttireErrorBoundaryProps, AttireErrorBoundaryState> {
constructor(props: AttireErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): ErrorBoundaryState {
static getDerivedStateFromError(): AttireErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error) {
console.warn(`[${this.props.tag}] Failed to load GLB, falling back:`, error.message);
console.warn('[AttireModel] Failed to load GLB, falling back to base robot:', error.message);
this.props.onError();
}
render() {
@ -65,71 +57,6 @@ function easeInOutCubic(t: number): number {
: 1 - Math.pow(-2 * t + 2, 3) / 2;
}
function BaseBodyMesh({ glbPath, primaryColor, visible }: { glbPath: string; primaryColor: string; visible: boolean }) {
useEffect(() => {
return () => {
useGLTF.clear(glbPath);
};
}, [glbPath]);
const { scene } = useGLTF(glbPath);
const processedScene = useMemo(() => {
const clonedScene = scene.clone();
clonedScene.traverse((child) => {
if (child instanceof THREE.Mesh) {
if (!child.material) {
child.material = new THREE.MeshStandardMaterial({
color: '#96a2b6',
metalness: 0.8,
roughness: 0.2,
});
}
if (child.material instanceof THREE.MeshStandardMaterial) {
child.material.envMapIntensity = 1;
child.material.needsUpdate = true;
}
}
});
const box = new THREE.Box3().setFromObject(clonedScene);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 2 / maxDim;
clonedScene.scale.setScalar(scale);
const offset = new THREE.Vector3(
-center.x * scale,
-center.y * scale + 0.5,
-center.z * scale
);
clonedScene.position.copy(offset);
return clonedScene;
}, [scene]);
useEffect(() => {
processedScene.visible = visible;
}, [visible, processedScene]);
useEffect(() => {
processedScene.traverse((child) => {
if (child instanceof THREE.Mesh && child.material instanceof THREE.MeshStandardMaterial) {
if (child.name.startsWith('Unitree_G1')) {
child.material.color.set(primaryColor);
child.material.needsUpdate = true;
}
}
});
}, [primaryColor, processedScene]);
return <primitive object={processedScene} />;
}
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
@ -180,7 +107,7 @@ interface RobotModelProps {
onError?: (error: Error) => void;
}
export function RobotModel({ onError: _onError }: RobotModelProps) {
export function RobotModel({ onError }: RobotModelProps) {
const groupRef = useRef<THREE.Group>(null);
const spinningRef = useRef(false);
@ -192,8 +119,7 @@ export function RobotModel({ onError: _onError }: RobotModelProps) {
const [attireReady, setAttireReady] = useState(false);
const previousAttireRef = useRef('none');
const activeBody = useConfigStore((state) => state.activeBody);
const bodyGlbPath = BODY_GLB[activeBody] ?? BODY_GLB.basic;
const { scene } = useGLTF('/Unitree_G1.glb');
const activeColors = useConfigStore((state) => state.activeColors);
const activePersonaAttire = useConfigStore((state) => state.activePersonaAttire);
@ -235,31 +161,85 @@ export function RobotModel({ onError: _onError }: RobotModelProps) {
}
});
const processedScene = useMemo(() => {
const clonedScene = scene.clone();
clonedScene.traverse((child) => {
if (child instanceof THREE.Mesh) {
if (!child.material) {
child.material = new THREE.MeshStandardMaterial({
color: '#96a2b6',
metalness: 0.8,
roughness: 0.2,
});
}
if (child.material instanceof THREE.MeshStandardMaterial) {
child.material.envMapIntensity = 1;
child.material.needsUpdate = true;
}
}
});
const box = new THREE.Box3().setFromObject(clonedScene);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 2 / maxDim;
clonedScene.scale.setScalar(scale);
const offset = new THREE.Vector3(
-center.x * scale,
-center.y * scale + 0.5,
-center.z * scale
);
clonedScene.position.copy(offset);
return clonedScene;
}, [scene]);
// Hide base robot only when attire is selected AND the attire GLB has loaded
// Show base robot when 'none' is selected or attire is still loading
const showBase = displayedAttire === 'none' || !attireReady;
useEffect(() => {
processedScene.visible = showBase;
}, [showBase, processedScene]);
// Apply primary color to the base robot only
useEffect(() => {
if (!groupRef.current) return;
groupRef.current.traverse((child) => {
if (child instanceof THREE.Mesh && child.material instanceof THREE.MeshStandardMaterial) {
if (child.name.startsWith('Unitree_G1')) {
child.material.color.set(activeColors.primary);
child.material.needsUpdate = true;
}
}
});
}, [activeColors]);
const attireGlbPath = buildAttireGlbMap(personas)[displayedAttire] || null;
const handleAttireLoaded = useCallback(() => {
const handleAttireLoaded = () => {
setAttireReady(true);
}, []);
};
return (
<group ref={groupRef}>
<Suspense fallback={null}>
<BaseBodyMesh glbPath={bodyGlbPath} primaryColor={activeColors.primary} visible={showBase} />
</Suspense>
<primitive object={processedScene} />
{attireGlbPath && (
<ModelErrorBoundary key={attireGlbPath} tag="AttireModel" onError={() => { setDisplayedAttire('none'); setAttireReady(false); }}>
<AttireErrorBoundary key={attireGlbPath} onError={() => { setDisplayedAttire('none'); setAttireReady(false); }}>
<Suspense fallback={null}>
<AttireModel
glbPath={attireGlbPath}
onLoaded={handleAttireLoaded}
/>
</Suspense>
</ModelErrorBoundary>
</AttireErrorBoundary>
)}
</group>
);

View File

@ -27,7 +27,6 @@ describe('URL Serialization', () => {
const encoded = serializeConfig({
activeColors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' },
activePersonaAttire: 'Emarati Kandura',
activeBody: 'basic',
activePayloads: [{ id: 'cam1', type: 'camera', position: 'head' }],
isHydrated: true,
});
@ -46,7 +45,6 @@ describe('URL Serialization', () => {
const original = {
activeColors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' },
activePersonaAttire: 'Industrial Vest',
activeBody: 'edu' as const,
activePayloads: [
{ id: 'cam1', type: 'camera', position: 'head' },
{ id: 'light1', type: 'light', position: 'arm' },

View File

@ -16,25 +16,11 @@ describe('useConfigStore (vanilla)', () => {
accent: '#f59e0b',
});
expect(state.activePersonaAttire).toBe('none');
expect(state.activeBody).toBe('basic');
expect(state.activePayloads).toEqual([]);
expect(state.isHydrated).toBe(false);
});
});
describe('setBody', () => {
it('should update active body to edu', () => {
configStore.getState().setBody('edu');
expect(configStore.getState().activeBody).toBe('edu');
});
it('should update active body back to basic', () => {
configStore.getState().setBody('edu');
configStore.getState().setBody('basic');
expect(configStore.getState().activeBody).toBe('basic');
});
});
describe('setColors', () => {
it('should update primary color', () => {
configStore.getState().setColors({ primary: '#ff0000' });
@ -126,7 +112,6 @@ describe('useConfigStore (vanilla)', () => {
it('should reset all state to defaults', () => {
configStore.getState().setColors({ primary: '#ff0000' });
configStore.getState().setPersonaAttire('Industrial Vest');
configStore.getState().setBody('edu');
configStore.getState().addPayload({ id: 'cam1', type: 'camera', position: 'head' });
configStore.getState().setHydrated(true);
@ -135,7 +120,6 @@ describe('useConfigStore (vanilla)', () => {
const state = configStore.getState();
expect(state.activeColors.primary).toBe('#96a2b6');
expect(state.activePersonaAttire).toBe('none');
expect(state.activeBody).toBe('basic');
expect(state.activePayloads).toEqual([]);
expect(state.isHydrated).toBe(false);
});
@ -165,7 +149,6 @@ describe('serializeConfig & deserializeConfig', () => {
const state: ConfigState = {
activeColors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' },
activePersonaAttire: 'Test Attire',
activeBody: 'basic',
activePayloads: [{ id: 'p1', type: 'camera', position: 'head' }],
isHydrated: true,
};
@ -179,7 +162,6 @@ describe('serializeConfig & deserializeConfig', () => {
const state: ConfigState = {
activeColors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' },
activePersonaAttire: 'Test Attire',
activeBody: 'basic',
activePayloads: [{ id: 'p1', type: 'camera', position: 'head' }],
isHydrated: true,
};
@ -208,7 +190,6 @@ describe('serializeConfig & deserializeConfig', () => {
const original: ConfigState = {
activeColors: { primary: '#aabbcc', secondary: '#ddeeff', accent: '#112233' },
activePersonaAttire: 'Emarati Kandura',
activeBody: 'edu',
activePayloads: [
{ id: 'cam1', type: 'PTZ Camera', position: 'head' },
{ id: 'light1', type: 'LED Array', position: 'chest' },
@ -223,7 +204,6 @@ describe('serializeConfig & deserializeConfig', () => {
expect(decoded).not.toBeNull();
expect(decoded?.activeColors).toEqual(original.activeColors);
expect(decoded?.activePersonaAttire).toBe(original.activePersonaAttire);
expect(decoded?.activeBody).toBe(original.activeBody);
expect(decoded?.activePayloads).toHaveLength(3);
expect(decoded?.activePayloads).toEqual(original.activePayloads);
});

View File

@ -13,12 +13,9 @@ export interface Payload {
position: string;
}
export type RobotBody = 'basic' | 'edu';
export interface ConfigState {
activeColors: ColorConfig;
activePersonaAttire: string;
activeBody: RobotBody;
activePayloads: Payload[];
isHydrated: boolean;
}
@ -26,7 +23,6 @@ export interface ConfigState {
export interface ConfigActions {
setColors: (colors: Partial<ColorConfig>) => void;
setPersonaAttire: (attire: string) => void;
setBody: (body: RobotBody) => void;
addPayload: (payload: Payload) => void;
removePayload: (payloadId: string) => void;
updatePayload: (payloadId: string, updates: Partial<Payload>) => void;
@ -48,7 +44,6 @@ const defaultColors: ColorConfig = {
const defaultState: ConfigState = {
activeColors: defaultColors,
activePersonaAttire: 'none',
activeBody: 'basic',
activePayloads: [],
isHydrated: false,
};
@ -67,10 +62,6 @@ export const configStore = createStore<ConfigStore>((set) => ({
set({ activePersonaAttire: attire });
},
setBody: (body: RobotBody) => {
set({ activeBody: body });
},
addPayload: (payload: Payload) => {
set((state) => {
if (state.activePayloads.some((p) => p.id === payload.id)) {
@ -135,9 +126,6 @@ export const useActiveColors = () =>
export const usePersonaAttire = () =>
useConfigStore((state) => state.activePersonaAttire);
export const useActiveBody = () =>
useConfigStore((state) => state.activeBody);
export const usePayloads = () =>
useConfigStore((state) => state.activePayloads);
@ -149,7 +137,6 @@ export const serializeConfig = (state: ConfigState): string => {
const data = {
c: state.activeColors,
p: state.activePersonaAttire,
b: state.activeBody,
y: state.activePayloads,
};
return btoa(JSON.stringify(data));
@ -229,12 +216,9 @@ export const deserializeConfig = (encoded: string): Partial<ConfigState> | null
}
}
const body: RobotBody = data.b === 'edu' ? 'edu' : 'basic';
return {
activeColors: data.c,
activePersonaAttire: data.p,
activeBody: body,
activePayloads: data.y,
};
} catch (error) {

View File

@ -17,32 +17,26 @@ describe('usePricingStore', () => {
});
describe('Default Prices', () => {
it('should have all default pricing items', () => {
it('should have 5 default pricing items', () => {
const items = pricingStore.getState().items;
expect(items.length).toBeGreaterThanOrEqual(8);
expect(items).toHaveLength(5);
});
it('should have correct default basic body price (Unitree + $5k markup in AED)', () => {
it('should have correct default base price', () => {
const base = pricingStore.getState().items.find((i) => i.id === 'base');
expect(base).toBeDefined();
expect(base!.price).toBe(77125);
expect(base!.price).toBe(250000);
});
it('should have correct default EDU body price ($40k in AED)', () => {
const edu = pricingStore.getState().items.find((i) => i.id === 'edu');
expect(edu).toBeDefined();
expect(edu!.price).toBe(146900);
});
it('should have all persona attire priced at 5000 AED', () => {
it('should have correct default attire prices', () => {
const items = pricingStore.getState().items;
const kandura = items.find((i) => i.id === 'emarati-kandura');
const vest = items.find((i) => i.id === 'industrial-vest');
const suit = items.find((i) => i.id === 'business-suit');
expect(kandura!.price).toBe(5000);
expect(vest!.price).toBe(5000);
expect(suit!.price).toBe(5000);
expect(kandura!.price).toBe(15000);
expect(vest!.price).toBe(8500);
expect(suit!.price).toBe(12000);
});
it('should have correct default custom color price', () => {
@ -63,14 +57,14 @@ describe('usePricingStore', () => {
pricingStore.getState().updatePrice('base', 300000);
const kandura = pricingStore.getState().items.find((i) => i.id === 'emarati-kandura');
expect(kandura!.price).toBe(5000);
expect(kandura!.price).toBe(15000);
});
it('should persist to localStorage after update', () => {
pricingStore.getState().updatePrice('base', 999999);
expect(localStorage.setItem).toHaveBeenCalled();
const stored = JSON.parse(mockStorage['lootah-pricing-v2']);
const stored = JSON.parse(mockStorage['lootah-pricing']);
const base = stored.find((i: { id: string }) => i.id === 'base');
expect(base.price).toBe(999999);
});
@ -84,15 +78,15 @@ describe('usePricingStore', () => {
pricingStore.getState().resetPrices();
const items = pricingStore.getState().items;
expect(items.find((i) => i.id === 'base')!.price).toBe(77125);
expect(items.find((i) => i.id === 'emarati-kandura')!.price).toBe(5000);
expect(items.find((i) => i.id === 'base')!.price).toBe(250000);
expect(items.find((i) => i.id === 'emarati-kandura')!.price).toBe(15000);
});
});
describe('hydrate', () => {
it('should load prices from localStorage', () => {
mockStorage['lootah-pricing-v2'] = JSON.stringify([
{ id: 'base', label: 'G1 Robot Basic', price: 500000 },
mockStorage['lootah-pricing'] = JSON.stringify([
{ id: 'base', label: 'G1 Robot Base', price: 500000 },
]);
pricingStore.getState().hydrate();
@ -103,14 +97,14 @@ describe('usePricingStore', () => {
});
it('should keep defaults for items not in localStorage', () => {
mockStorage['lootah-pricing-v2'] = JSON.stringify([
{ id: 'base', label: 'G1 Robot Basic', price: 500000 },
mockStorage['lootah-pricing'] = JSON.stringify([
{ id: 'base', label: 'G1 Robot Base', price: 500000 },
]);
pricingStore.getState().hydrate();
const kandura = pricingStore.getState().items.find((i) => i.id === 'emarati-kandura');
expect(kandura!.price).toBe(5000);
expect(kandura!.price).toBe(15000);
});
it('should set isHydrated even with no stored data', () => {

View File

@ -24,23 +24,17 @@ export interface PricingActions {
export type PricingStore = PricingState & PricingActions;
// Prices in AED. USD → AED at 3.6725 (CBUAE peg).
// Basic = Unitree G1 retail ($16k) + $5k markup = $21k ≈ 77,125 AED.
// EDU = $40k flat ≈ 146,900 AED.
// All persona attire = 5,000 AED each.
const DEFAULT_ITEMS: PricingItem[] = [
{ id: 'base', label: 'G1 Robot Basic', price: 77125 },
{ id: 'edu', label: 'G1 Robot EDU', price: 146900 },
{ id: 'emarati-kandura', label: 'Emarati Kandura', price: 5000 },
{ id: 'industrial-vest', label: 'Industrial Vest', price: 5000 },
{ id: 'business-suit', label: 'Business Suit', price: 5000 },
{ id: 'base', label: 'G1 Robot Base', price: 250000 },
{ id: 'emarati-kandura', label: 'Emarati Kandura', price: 15000 },
{ 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 },
];
// Bump suffix to invalidate any cached localStorage prices from the old pricing scheme.
const STORAGE_KEY = 'lootah-pricing-v2';
const STORAGE_KEY = 'lootah-pricing';
function loadFromStorage(): PricingItem[] | null {
if (typeof window === 'undefined') return null;