feat(pricing): switch base+EDU prices to AED and unify attire at 5000 AED

- `base` (Basic body) → 77,125 AED  (Unitree G1 retail $16k + $5k markup)
- new `edu` row     → 146,900 AED  ($40k flat)
- all persona attire (kandura, vest, suit, robot-doctor, security-guard) → 5,000 AED
- `custom-color` unchanged at 3,500 AED
- PricingEngine now swaps the base line when EDU body is selected
- localStorage key bumped to `lootah-pricing-v2` to invalidate stale client caches
- Robot Body buttons now describe the variants as `Standard consumer` vs `Open-source education / research` (same chassis GLB, differs in licensing)
- New `prisma/update-prices.ts` idempotent script applies the new prices to an existing DB; seed.ts updated for fresh installs
- Pricing tests updated to match new defaults

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Najjar\NajjarV02 2026-05-19 17:40:04 +04:00
parent 03dbc4ac98
commit 52b910ed93
8 changed files with 103 additions and 48 deletions

Binary file not shown.

View File

@ -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 });

40
prisma/update-prices.ts Normal file
View File

@ -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<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

@ -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() {

View File

@ -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 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<PriceLine label="G1 Robot Base" price={basePrice} />
<PriceLine label={baseLabel} price={basePrice} />
{personaPrice > 0 && <PriceLine label={personaLabel} price={personaPrice} />}
{colorPrice > 0 && <PriceLine label="Custom Color" price={colorPrice} />}
</div>

View File

@ -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<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_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 (
<group ref={groupRef}>
<ModelErrorBoundary key={bodyGlbPath} tag="BodyModel" onError={handleBodyError}>
<Suspense fallback={null}>
<BaseBodyMesh glbPath={bodyGlbPath} primaryColor={activeColors.primary} visible={showBase} />
</Suspense>
</ModelErrorBoundary>
<Suspense fallback={null}>
<BaseBodyMesh glbPath={bodyGlbPath} primaryColor={activeColors.primary} visible={showBase} />
</Suspense>
{attireGlbPath && (
<ModelErrorBoundary key={attireGlbPath} tag="AttireModel" onError={() => { setDisplayedAttire('none'); setAttireReady(false); }}>

View File

@ -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', () => {

View File

@ -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;