diff --git a/prisma/lootah.db b/prisma/lootah.db index 9ca8d19..9695d41 100644 Binary files a/prisma/lootah.db and b/prisma/lootah.db differ diff --git a/prisma/seed.ts b/prisma/seed.ts index fc5762f..0beb894 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -50,14 +50,19 @@ 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 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 }, + { 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 }, ]; for (const item of defaultItems) { await prisma.pricingItem.create({ data: item }); diff --git a/prisma/update-prices.ts b/prisma/update-prices.ts new file mode 100644 index 0000000..e1526fa --- /dev/null +++ b/prisma/update-prices.ts @@ -0,0 +1,40 @@ +// 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[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(); + }); diff --git a/src/components/ConfigPanel.tsx b/src/components/ConfigPanel.tsx index 53ac3fb..ab74646 100644 --- a/src/components/ConfigPanel.tsx +++ b/src/components/ConfigPanel.tsx @@ -6,8 +6,8 @@ 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 G1 chassis' }, - { id: 'edu', label: 'EDU', description: 'Education / research variant' }, + { id: 'basic', label: 'Basic', description: 'Standard consumer variant' }, + { id: 'edu', label: 'EDU', description: 'Open-source education / research variant' }, ]; export function ConfigPanel() { diff --git a/src/components/PricingEngine.tsx b/src/components/PricingEngine.tsx index 2c33ce8..52fd79e 100644 --- a/src/components/PricingEngine.tsx +++ b/src/components/PricingEngine.tsx @@ -14,6 +14,7 @@ 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); @@ -23,8 +24,12 @@ 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 basePrice = getPrice('base'); + const bodyId = body === 'edu' ? 'edu' : '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 colorPrice = primaryColor !== DEFAULT_COLOR ? getPrice('custom-color') : 0; const total = basePrice + personaPrice + colorPrice; @@ -37,7 +42,7 @@ export function PricingEngine() { const store = orderStore.getState(); const lineItems: { label: string; price: number }[] = [ - { label: 'G1 Robot Base', price: basePrice }, + { label: baseLabel, price: basePrice }, ...(personaPrice > 0 ? [{ label: personaLabel, price: personaPrice }] : []), ...(colorPrice > 0 ? [{ label: 'Custom Color', price: colorPrice }] : []), ]; @@ -77,7 +82,7 @@ export function PricingEngine() { {/* Line items */}
- + {personaPrice > 0 && } {colorPrice > 0 && }
diff --git a/src/components/RobotModel.tsx b/src/components/RobotModel.tsx index 7d1bbb0..3889fe0 100644 --- a/src/components/RobotModel.tsx +++ b/src/components/RobotModel.tsx @@ -4,7 +4,7 @@ import { useRef, useMemo, useEffect, Suspense, useState, useCallback, Component, import { useGLTF } from '@react-three/drei'; import { useFrame } from '@react-three/fiber'; import * as THREE from 'three'; -import { configStore, useConfigStore } from '@/store/useConfigStore'; +import { useConfigStore } from '@/store/useConfigStore'; import { personaStore, usePersonaStore } from '@/store/usePersonaStore'; // Configure Draco decoder so compressed .glb files load correctly @@ -16,9 +16,11 @@ const STATIC_ATTIRE_GLB: Record = { '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_EDU.glb', + edu: '/Unitree_G1.glb', }; // Attire models are loaded on-demand to avoid blocking the initial 50 MB robot load @@ -243,20 +245,11 @@ export function RobotModel({ onError: _onError }: RobotModelProps) { setAttireReady(true); }, []); - const handleBodyError = useCallback(() => { - // Fallback to basic body when EDU (or any non-basic) GLB fails to load - if (configStore.getState().activeBody !== 'basic') { - configStore.getState().setBody('basic'); - } - }, []); - return ( - - - - - + + + {attireGlbPath && ( { setDisplayedAttire('none'); setAttireReady(false); }}> diff --git a/src/store/usePricingStore.test.ts b/src/store/usePricingStore.test.ts index fb7712d..0967d66 100644 --- a/src/store/usePricingStore.test.ts +++ b/src/store/usePricingStore.test.ts @@ -17,26 +17,32 @@ describe('usePricingStore', () => { }); describe('Default Prices', () => { - it('should have 5 default pricing items', () => { + it('should have all default pricing items', () => { const items = pricingStore.getState().items; - expect(items).toHaveLength(5); + expect(items.length).toBeGreaterThanOrEqual(8); }); - it('should have correct default base price', () => { + it('should have correct default basic body price (Unitree + $5k markup in AED)', () => { const base = pricingStore.getState().items.find((i) => i.id === 'base'); expect(base).toBeDefined(); - expect(base!.price).toBe(250000); + expect(base!.price).toBe(77125); }); - it('should have correct default attire prices', () => { + 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', () => { 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(15000); - expect(vest!.price).toBe(8500); - expect(suit!.price).toBe(12000); + expect(kandura!.price).toBe(5000); + expect(vest!.price).toBe(5000); + expect(suit!.price).toBe(5000); }); it('should have correct default custom color price', () => { @@ -57,14 +63,14 @@ describe('usePricingStore', () => { pricingStore.getState().updatePrice('base', 300000); const kandura = pricingStore.getState().items.find((i) => i.id === 'emarati-kandura'); - expect(kandura!.price).toBe(15000); + expect(kandura!.price).toBe(5000); }); it('should persist to localStorage after update', () => { pricingStore.getState().updatePrice('base', 999999); expect(localStorage.setItem).toHaveBeenCalled(); - const stored = JSON.parse(mockStorage['lootah-pricing']); + const stored = JSON.parse(mockStorage['lootah-pricing-v2']); const base = stored.find((i: { id: string }) => i.id === 'base'); expect(base.price).toBe(999999); }); @@ -78,15 +84,15 @@ describe('usePricingStore', () => { pricingStore.getState().resetPrices(); const items = pricingStore.getState().items; - expect(items.find((i) => i.id === 'base')!.price).toBe(250000); - expect(items.find((i) => i.id === 'emarati-kandura')!.price).toBe(15000); + expect(items.find((i) => i.id === 'base')!.price).toBe(77125); + expect(items.find((i) => i.id === 'emarati-kandura')!.price).toBe(5000); }); }); describe('hydrate', () => { it('should load prices from localStorage', () => { - mockStorage['lootah-pricing'] = JSON.stringify([ - { id: 'base', label: 'G1 Robot Base', price: 500000 }, + mockStorage['lootah-pricing-v2'] = JSON.stringify([ + { id: 'base', label: 'G1 Robot Basic', price: 500000 }, ]); pricingStore.getState().hydrate(); @@ -97,14 +103,14 @@ describe('usePricingStore', () => { }); it('should keep defaults for items not in localStorage', () => { - mockStorage['lootah-pricing'] = JSON.stringify([ - { id: 'base', label: 'G1 Robot Base', price: 500000 }, + mockStorage['lootah-pricing-v2'] = JSON.stringify([ + { id: 'base', label: 'G1 Robot Basic', price: 500000 }, ]); pricingStore.getState().hydrate(); const kandura = pricingStore.getState().items.find((i) => i.id === 'emarati-kandura'); - expect(kandura!.price).toBe(15000); + expect(kandura!.price).toBe(5000); }); it('should set isHydrated even with no stored data', () => { diff --git a/src/store/usePricingStore.ts b/src/store/usePricingStore.ts index 3cab6d3..0829ae7 100644 --- a/src/store/usePricingStore.ts +++ b/src/store/usePricingStore.ts @@ -24,17 +24,23 @@ 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 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: '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: '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'; +// Bump suffix to invalidate any cached localStorage prices from the old pricing scheme. +const STORAGE_KEY = 'lootah-pricing-v2'; function loadFromStorage(): PricingItem[] | null { if (typeof window === 'undefined') return null;