Compare commits
3 Commits
c26338f355
...
fe5f1ce25f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe5f1ce25f | ||
|
|
52b910ed93 | ||
|
|
03dbc4ac98 |
BIN
a172484f-a0fb-4065-86b0-795412e7e73a (1).jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
a172484f-a0fb-4065-86b0-795412e7e73a.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
images-4UX8NEE.jpg
Normal file
|
After Width: | Height: | Size: 233 KiB |
BIN
images-GPK3BKP (1).jpg
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
images-GPK3BKP.jpg
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
images-PXXS3XL-2.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
images-T3ZJ8DB.jpg
Normal file
|
After Width: | Height: | Size: 232 KiB |
BIN
prisma/lootah.db
@ -50,14 +50,19 @@ 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 Base', price: 250000, sortOrder: 0 },
|
{ id: 'base', label: 'G1 Robot Basic', price: 77125, sortOrder: 0 },
|
||||||
{ id: 'emarati-kandura', label: 'Emarati Kandura', price: 15000, sortOrder: 1 },
|
{ id: 'edu', label: 'G1 Robot EDU', price: 146900, sortOrder: 1 },
|
||||||
{ id: 'industrial-vest', label: 'Industrial Vest', price: 8500, sortOrder: 2 },
|
{ id: 'emarati-kandura', label: 'Emarati Kandura', price: 5000, sortOrder: 2 },
|
||||||
{ id: 'business-suit', label: 'Business Suit', price: 12000, sortOrder: 3 },
|
{ id: 'industrial-vest', label: 'Industrial Vest', price: 5000, sortOrder: 3 },
|
||||||
{ id: 'custom-color', label: 'Custom Color', price: 3500, sortOrder: 4 },
|
{ id: 'business-suit', label: 'Business Suit', price: 5000, sortOrder: 4 },
|
||||||
{ id: 'robot-doctor', label: 'Robot Doctor', price: 5000, sortOrder: 5 },
|
{ id: 'custom-color', label: 'Custom Color', price: 3500, sortOrder: 5 },
|
||||||
{ id: 'security-guard', label: 'Security Guard', price: 5000, sortOrder: 6 },
|
{ 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) {
|
for (const item of defaultItems) {
|
||||||
await prisma.pricingItem.create({ data: item });
|
await prisma.pricingItem.create({ data: item });
|
||||||
|
|||||||
40
prisma/update-prices.ts
Normal 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();
|
||||||
|
});
|
||||||
@ -5,9 +5,15 @@ 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);
|
||||||
@ -36,6 +42,10 @@ 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);
|
||||||
@ -176,6 +186,59 @@ 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 />
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ 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);
|
||||||
@ -23,8 +24,12 @@ 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 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 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;
|
||||||
@ -37,7 +42,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: 'G1 Robot Base', price: basePrice },
|
{ label: baseLabel, 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 }] : []),
|
||||||
];
|
];
|
||||||
@ -77,7 +82,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="G1 Robot Base" price={basePrice} />
|
<PriceLine label={baseLabel} 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>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRef, useMemo, useEffect, Suspense, useState, Component, type ReactNode } from 'react';
|
import { useRef, useMemo, useEffect, Suspense, useState, useCallback, 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,6 +16,13 @@ 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 */
|
||||||
@ -27,22 +34,23 @@ 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 AttireErrorBoundaryProps {
|
interface ErrorBoundaryProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
onError: () => void;
|
onError: () => void;
|
||||||
|
tag: string;
|
||||||
}
|
}
|
||||||
interface AttireErrorBoundaryState { hasError: boolean; }
|
interface ErrorBoundaryState { hasError: boolean; }
|
||||||
|
|
||||||
class AttireErrorBoundary extends Component<AttireErrorBoundaryProps, AttireErrorBoundaryState> {
|
class ModelErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
constructor(props: AttireErrorBoundaryProps) {
|
constructor(props: ErrorBoundaryProps) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { hasError: false };
|
this.state = { hasError: false };
|
||||||
}
|
}
|
||||||
static getDerivedStateFromError(): AttireErrorBoundaryState {
|
static getDerivedStateFromError(): ErrorBoundaryState {
|
||||||
return { hasError: true };
|
return { hasError: true };
|
||||||
}
|
}
|
||||||
componentDidCatch(error: Error) {
|
componentDidCatch(error: Error) {
|
||||||
console.warn('[AttireModel] Failed to load GLB, falling back to base robot:', error.message);
|
console.warn(`[${this.props.tag}] Failed to load GLB, falling back:`, error.message);
|
||||||
this.props.onError();
|
this.props.onError();
|
||||||
}
|
}
|
||||||
render() {
|
render() {
|
||||||
@ -57,6 +65,71 @@ 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
|
||||||
@ -107,7 +180,7 @@ interface RobotModelProps {
|
|||||||
onError?: (error: Error) => void;
|
onError?: (error: Error) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RobotModel({ onError }: RobotModelProps) {
|
export function RobotModel({ onError: _onError }: RobotModelProps) {
|
||||||
const groupRef = useRef<THREE.Group>(null);
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
|
||||||
const spinningRef = useRef(false);
|
const spinningRef = useRef(false);
|
||||||
@ -119,7 +192,8 @@ export function RobotModel({ onError }: RobotModelProps) {
|
|||||||
const [attireReady, setAttireReady] = useState(false);
|
const [attireReady, setAttireReady] = useState(false);
|
||||||
const previousAttireRef = useRef('none');
|
const previousAttireRef = useRef('none');
|
||||||
|
|
||||||
const { scene } = useGLTF('/Unitree_G1.glb');
|
const activeBody = useConfigStore((state) => state.activeBody);
|
||||||
|
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);
|
||||||
@ -161,85 +235,31 @@ export function RobotModel({ 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 = () => {
|
const handleAttireLoaded = useCallback(() => {
|
||||||
setAttireReady(true);
|
setAttireReady(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={groupRef}>
|
<group ref={groupRef}>
|
||||||
<primitive object={processedScene} />
|
<Suspense fallback={null}>
|
||||||
|
<BaseBodyMesh glbPath={bodyGlbPath} primaryColor={activeColors.primary} visible={showBase} />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
{attireGlbPath && (
|
{attireGlbPath && (
|
||||||
<AttireErrorBoundary key={attireGlbPath} onError={() => { setDisplayedAttire('none'); setAttireReady(false); }}>
|
<ModelErrorBoundary key={attireGlbPath} tag="AttireModel" onError={() => { setDisplayedAttire('none'); setAttireReady(false); }}>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<AttireModel
|
<AttireModel
|
||||||
glbPath={attireGlbPath}
|
glbPath={attireGlbPath}
|
||||||
onLoaded={handleAttireLoaded}
|
onLoaded={handleAttireLoaded}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</AttireErrorBoundary>
|
</ModelErrorBoundary>
|
||||||
)}
|
)}
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -27,6 +27,7 @@ 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,
|
||||||
});
|
});
|
||||||
@ -45,6 +46,7 @@ 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' },
|
||||||
|
|||||||
@ -16,11 +16,25 @@ 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' });
|
||||||
@ -112,6 +126,7 @@ 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);
|
||||||
|
|
||||||
@ -120,6 +135,7 @@ 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);
|
||||||
});
|
});
|
||||||
@ -149,6 +165,7 @@ 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,
|
||||||
};
|
};
|
||||||
@ -162,6 +179,7 @@ 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,
|
||||||
};
|
};
|
||||||
@ -190,6 +208,7 @@ 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' },
|
||||||
@ -204,6 +223,7 @@ 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);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -13,9 +13,12 @@ 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;
|
||||||
}
|
}
|
||||||
@ -23,6 +26,7 @@ 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;
|
||||||
@ -44,6 +48,7 @@ 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,
|
||||||
};
|
};
|
||||||
@ -62,6 +67,10 @@ 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)) {
|
||||||
@ -126,6 +135,9 @@ 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);
|
||||||
|
|
||||||
@ -137,6 +149,7 @@ 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));
|
||||||
@ -216,9 +229,12 @@ 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) {
|
||||||
|
|||||||
@ -17,26 +17,32 @@ describe('usePricingStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Default Prices', () => {
|
describe('Default Prices', () => {
|
||||||
it('should have 5 default pricing items', () => {
|
it('should have all default pricing items', () => {
|
||||||
const items = pricingStore.getState().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');
|
const base = pricingStore.getState().items.find((i) => i.id === 'base');
|
||||||
expect(base).toBeDefined();
|
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 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(15000);
|
expect(kandura!.price).toBe(5000);
|
||||||
expect(vest!.price).toBe(8500);
|
expect(vest!.price).toBe(5000);
|
||||||
expect(suit!.price).toBe(12000);
|
expect(suit!.price).toBe(5000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have correct default custom color price', () => {
|
it('should have correct default custom color price', () => {
|
||||||
@ -57,14 +63,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(15000);
|
expect(kandura!.price).toBe(5000);
|
||||||
});
|
});
|
||||||
|
|
||||||
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']);
|
const stored = JSON.parse(mockStorage['lootah-pricing-v2']);
|
||||||
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);
|
||||||
});
|
});
|
||||||
@ -78,15 +84,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(250000);
|
expect(items.find((i) => i.id === 'base')!.price).toBe(77125);
|
||||||
expect(items.find((i) => i.id === 'emarati-kandura')!.price).toBe(15000);
|
expect(items.find((i) => i.id === 'emarati-kandura')!.price).toBe(5000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('hydrate', () => {
|
describe('hydrate', () => {
|
||||||
it('should load prices from localStorage', () => {
|
it('should load prices from localStorage', () => {
|
||||||
mockStorage['lootah-pricing'] = JSON.stringify([
|
mockStorage['lootah-pricing-v2'] = JSON.stringify([
|
||||||
{ id: 'base', label: 'G1 Robot Base', price: 500000 },
|
{ id: 'base', label: 'G1 Robot Basic', price: 500000 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
pricingStore.getState().hydrate();
|
pricingStore.getState().hydrate();
|
||||||
@ -97,14 +103,14 @@ describe('usePricingStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should keep defaults for items not in localStorage', () => {
|
it('should keep defaults for items not in localStorage', () => {
|
||||||
mockStorage['lootah-pricing'] = JSON.stringify([
|
mockStorage['lootah-pricing-v2'] = JSON.stringify([
|
||||||
{ id: 'base', label: 'G1 Robot Base', price: 500000 },
|
{ id: 'base', label: 'G1 Robot Basic', 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(15000);
|
expect(kandura!.price).toBe(5000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set isHydrated even with no stored data', () => {
|
it('should set isHydrated even with no stored data', () => {
|
||||||
|
|||||||
@ -24,17 +24,23 @@ 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 Base', price: 250000 },
|
{ id: 'base', label: 'G1 Robot Basic', price: 77125 },
|
||||||
{ id: 'emarati-kandura', label: 'Emarati Kandura', price: 15000 },
|
{ id: 'edu', label: 'G1 Robot EDU', price: 146900 },
|
||||||
{ id: 'industrial-vest', label: 'Industrial Vest', price: 8500 },
|
{ id: 'emarati-kandura', label: 'Emarati Kandura', price: 5000 },
|
||||||
{ id: 'business-suit', label: 'Business Suit', price: 12000 },
|
{ 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: '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 },
|
||||||
];
|
];
|
||||||
|
|
||||||
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 {
|
function loadFromStorage(): PricingItem[] | null {
|
||||||
if (typeof window === 'undefined') return null;
|
if (typeof window === 'undefined') return null;
|
||||||
|
|||||||