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) // Seed default pricing items (idempotent — only if table is empty)
const pricingCount = await prisma.pricingItem.count(); const pricingCount = await prisma.pricingItem.count();
if (pricingCount === 0) { 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 = [ const defaultItems = [
{ id: 'base', label: 'G1 Robot Basic', price: 77125, sortOrder: 0 }, { id: 'base', label: 'G1 Robot Base', price: 250000, sortOrder: 0 },
{ id: 'edu', label: 'G1 Robot EDU', price: 146900, sortOrder: 1 }, { id: 'emarati-kandura', label: 'Emarati Kandura', price: 15000, sortOrder: 1 },
{ id: 'emarati-kandura', label: 'Emarati Kandura', price: 5000, sortOrder: 2 }, { id: 'industrial-vest', label: 'Industrial Vest', price: 8500, sortOrder: 2 },
{ id: 'industrial-vest', label: 'Industrial Vest', price: 5000, sortOrder: 3 }, { id: 'business-suit', label: 'Business Suit', price: 12000, sortOrder: 3 },
{ id: 'business-suit', label: 'Business Suit', price: 5000, sortOrder: 4 }, { id: 'custom-color', label: 'Custom Color', price: 3500, sortOrder: 4 },
{ id: 'custom-color', label: 'Custom Color', price: 3500, sortOrder: 5 }, { id: 'robot-doctor', label: 'Robot Doctor', price: 5000, sortOrder: 5 },
{ id: 'robot-doctor', label: 'Robot Doctor', price: 5000, sortOrder: 6 }, { id: 'security-guard', label: 'Security Guard', price: 5000, sortOrder: 6 },
{ id: 'security-guard', label: 'Security Guard', price: 5000, sortOrder: 7 },
]; ];
for (const item of defaultItems) { for (const item of defaultItems) {
await prisma.pricingItem.create({ data: item }); 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 { personaStore, usePersonaStore } from '@/store/usePersonaStore';
import { PricingEngine } from './PricingEngine'; 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() { export function ConfigPanel() {
const activeColors = useConfigStore((s) => s.activeColors); const activeColors = useConfigStore((s) => s.activeColors);
const activePersona = useConfigStore((s) => s.activePersonaAttire); const activePersona = useConfigStore((s) => s.activePersonaAttire);
const activeBody = useConfigStore((s) => s.activeBody);
const personas = usePersonaStore((s) => s.personas); const personas = usePersonaStore((s) => s.personas);
// Track which persona is loading (waiting for GLB to download) // Track which persona is loading (waiting for GLB to download)
const [loadingPersona, setLoadingPersona] = useState<string | null>(null); const [loadingPersona, setLoadingPersona] = useState<string | null>(null);
@ -42,10 +36,6 @@ export function ConfigPanel() {
configStore.getState().setColors({ [key]: value }); configStore.getState().setColors({ [key]: value });
}, []); }, []);
const handleBodySelect = useCallback((body: 'basic' | 'edu') => {
configStore.getState().setBody(body);
}, []);
const handlePersonaSelect = useCallback((attire: string) => { const handlePersonaSelect = useCallback((attire: string) => {
// Only show loading for dynamic (uploaded) attire that has a GLB to download // Only show loading for dynamic (uploaded) attire that has a GLB to download
const persona = personaStore.getState().personas.find((p) => p.id === attire); const persona = personaStore.getState().personas.find((p) => p.id === attire);
@ -186,59 +176,6 @@ export function ConfigPanel() {
</div> </div>
</section> </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 --- */} {/* --- PRICING --- */}
<PricingEngine /> <PricingEngine />

View File

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

View File

@ -1,6 +1,6 @@
'use client'; '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 { useGLTF } from '@react-three/drei';
import { useFrame } from '@react-three/fiber'; import { useFrame } from '@react-three/fiber';
import * as THREE from 'three'; import * as THREE from 'three';
@ -16,13 +16,6 @@ const STATIC_ATTIRE_GLB: Record<string, string> = {
'business-suit': '/Suit.glb', '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 // 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 */ /** 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 return { ...STATIC_ATTIRE_GLB, ...dynamic }; // uploaded GLBs override static
} }
interface ErrorBoundaryProps { interface AttireErrorBoundaryProps {
children: ReactNode; children: ReactNode;
onError: () => void; onError: () => void;
tag: string;
} }
interface ErrorBoundaryState { hasError: boolean; } interface AttireErrorBoundaryState { hasError: boolean; }
class ModelErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> { class AttireErrorBoundary extends Component<AttireErrorBoundaryProps, AttireErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) { constructor(props: AttireErrorBoundaryProps) {
super(props); super(props);
this.state = { hasError: false }; this.state = { hasError: false };
} }
static getDerivedStateFromError(): ErrorBoundaryState { static getDerivedStateFromError(): AttireErrorBoundaryState {
return { hasError: true }; return { hasError: true };
} }
componentDidCatch(error: Error) { 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(); this.props.onError();
} }
render() { render() {
@ -65,71 +57,6 @@ function easeInOutCubic(t: number): number {
: 1 - Math.pow(-2 * t + 2, 3) / 2; : 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 }) { function AttireModel({ glbPath, onLoaded }: { glbPath: string; onLoaded: () => void }) {
// Clear stale useGLTF cache entries for paths that share the same base filename // 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 // (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; onError?: (error: Error) => void;
} }
export function RobotModel({ onError: _onError }: RobotModelProps) { export function RobotModel({ onError }: RobotModelProps) {
const groupRef = useRef<THREE.Group>(null); const groupRef = useRef<THREE.Group>(null);
const spinningRef = useRef(false); const spinningRef = useRef(false);
@ -192,8 +119,7 @@ export function RobotModel({ onError: _onError }: RobotModelProps) {
const [attireReady, setAttireReady] = useState(false); const [attireReady, setAttireReady] = useState(false);
const previousAttireRef = useRef('none'); const previousAttireRef = useRef('none');
const activeBody = useConfigStore((state) => state.activeBody); const { scene } = useGLTF('/Unitree_G1.glb');
const bodyGlbPath = BODY_GLB[activeBody] ?? BODY_GLB.basic;
const activeColors = useConfigStore((state) => state.activeColors); const activeColors = useConfigStore((state) => state.activeColors);
const activePersonaAttire = useConfigStore((state) => state.activePersonaAttire); 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 // 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 // Show base robot when 'none' is selected or attire is still loading
const showBase = displayedAttire === 'none' || !attireReady; 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 attireGlbPath = buildAttireGlbMap(personas)[displayedAttire] || null;
const handleAttireLoaded = useCallback(() => { const handleAttireLoaded = () => {
setAttireReady(true); setAttireReady(true);
}, []); };
return ( return (
<group ref={groupRef}> <group ref={groupRef}>
<Suspense fallback={null}> <primitive object={processedScene} />
<BaseBodyMesh glbPath={bodyGlbPath} primaryColor={activeColors.primary} visible={showBase} />
</Suspense>
{attireGlbPath && ( {attireGlbPath && (
<ModelErrorBoundary key={attireGlbPath} tag="AttireModel" onError={() => { setDisplayedAttire('none'); setAttireReady(false); }}> <AttireErrorBoundary key={attireGlbPath} onError={() => { setDisplayedAttire('none'); setAttireReady(false); }}>
<Suspense fallback={null}> <Suspense fallback={null}>
<AttireModel <AttireModel
glbPath={attireGlbPath} glbPath={attireGlbPath}
onLoaded={handleAttireLoaded} onLoaded={handleAttireLoaded}
/> />
</Suspense> </Suspense>
</ModelErrorBoundary> </AttireErrorBoundary>
)} )}
</group> </group>
); );

View File

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

View File

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

View File

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

View File

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

View File

@ -24,23 +24,17 @@ export interface PricingActions {
export type PricingStore = PricingState & 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[] = [ const DEFAULT_ITEMS: PricingItem[] = [
{ id: 'base', label: 'G1 Robot Basic', price: 77125 }, { id: 'base', label: 'G1 Robot Base', price: 250000 },
{ id: 'edu', label: 'G1 Robot EDU', price: 146900 }, { id: 'emarati-kandura', label: 'Emarati Kandura', price: 15000 },
{ id: 'emarati-kandura', label: 'Emarati Kandura', price: 5000 }, { id: 'industrial-vest', label: 'Industrial Vest', price: 8500 },
{ id: 'industrial-vest', label: 'Industrial Vest', price: 5000 }, { id: 'business-suit', label: 'Business Suit', price: 12000 },
{ id: 'business-suit', label: 'Business Suit', price: 5000 },
{ id: 'custom-color', label: 'Custom Color', price: 3500 }, { id: 'custom-color', label: 'Custom Color', price: 3500 },
{ id: 'robot-doctor', label: 'Robot Doctor', price: 5000 }, { id: 'robot-doctor', label: 'Robot Doctor', price: 5000 },
{ id: 'security-guard', label: 'Security Guard', 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';
const STORAGE_KEY = 'lootah-pricing-v2';
function loadFromStorage(): PricingItem[] | null { function loadFromStorage(): PricingItem[] | null {
if (typeof window === 'undefined') return null; if (typeof window === 'undefined') return null;