- `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>
272 lines
8.3 KiB
TypeScript
272 lines
8.3 KiB
TypeScript
'use client';
|
|
|
|
import { useRef, useMemo, useEffect, Suspense, useState, useCallback, Component, type ReactNode } from 'react';
|
|
import { useGLTF } from '@react-three/drei';
|
|
import { useFrame } from '@react-three/fiber';
|
|
import * as THREE from 'three';
|
|
import { useConfigStore } from '@/store/useConfigStore';
|
|
import { personaStore, usePersonaStore } from '@/store/usePersonaStore';
|
|
|
|
// Configure Draco decoder so compressed .glb files load correctly
|
|
useGLTF.setDecoderPath('/draco/');
|
|
|
|
const STATIC_ATTIRE_GLB: Record<string, string> = {
|
|
'emarati-kandura': '/Kandoura.glb',
|
|
'industrial-vest': '/Vest.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
|
|
|
|
/** Merge static map with any custom GLBs stored in the persona store */
|
|
function buildAttireGlbMap(personas: { id: string; modelPath?: string }[]): Record<string, string> {
|
|
const dynamic: Record<string, string> = {};
|
|
personas.forEach((p) => {
|
|
if (p.modelPath && p.id !== 'none') dynamic[p.id] = p.modelPath;
|
|
});
|
|
return { ...STATIC_ATTIRE_GLB, ...dynamic }; // uploaded GLBs override static
|
|
}
|
|
|
|
interface ErrorBoundaryProps {
|
|
children: ReactNode;
|
|
onError: () => void;
|
|
tag: string;
|
|
}
|
|
interface ErrorBoundaryState { hasError: boolean; }
|
|
|
|
class ModelErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
constructor(props: ErrorBoundaryProps) {
|
|
super(props);
|
|
this.state = { hasError: false };
|
|
}
|
|
static getDerivedStateFromError(): ErrorBoundaryState {
|
|
return { hasError: true };
|
|
}
|
|
componentDidCatch(error: Error) {
|
|
console.warn(`[${this.props.tag}] Failed to load GLB, falling back:`, error.message);
|
|
this.props.onError();
|
|
}
|
|
render() {
|
|
if (this.state.hasError) return null;
|
|
return this.props.children;
|
|
}
|
|
}
|
|
|
|
function easeInOutCubic(t: number): number {
|
|
return t < 0.5
|
|
? 4 * t * t * t
|
|
: 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
}
|
|
|
|
function BaseBodyMesh({ glbPath, primaryColor, visible }: { glbPath: string; primaryColor: string; visible: boolean }) {
|
|
useEffect(() => {
|
|
return () => {
|
|
useGLTF.clear(glbPath);
|
|
};
|
|
}, [glbPath]);
|
|
|
|
const { scene } = useGLTF(glbPath);
|
|
|
|
const processedScene = useMemo(() => {
|
|
const clonedScene = scene.clone();
|
|
|
|
clonedScene.traverse((child) => {
|
|
if (child instanceof THREE.Mesh) {
|
|
if (!child.material) {
|
|
child.material = new THREE.MeshStandardMaterial({
|
|
color: '#96a2b6',
|
|
metalness: 0.8,
|
|
roughness: 0.2,
|
|
});
|
|
}
|
|
|
|
if (child.material instanceof THREE.MeshStandardMaterial) {
|
|
child.material.envMapIntensity = 1;
|
|
child.material.needsUpdate = true;
|
|
}
|
|
}
|
|
});
|
|
|
|
const box = new THREE.Box3().setFromObject(clonedScene);
|
|
const center = box.getCenter(new THREE.Vector3());
|
|
const size = box.getSize(new THREE.Vector3());
|
|
|
|
const maxDim = Math.max(size.x, size.y, size.z);
|
|
const scale = 2 / maxDim;
|
|
clonedScene.scale.setScalar(scale);
|
|
|
|
const offset = new THREE.Vector3(
|
|
-center.x * scale,
|
|
-center.y * scale + 0.5,
|
|
-center.z * scale
|
|
);
|
|
clonedScene.position.copy(offset);
|
|
|
|
return clonedScene;
|
|
}, [scene]);
|
|
|
|
useEffect(() => {
|
|
processedScene.visible = visible;
|
|
}, [visible, processedScene]);
|
|
|
|
useEffect(() => {
|
|
processedScene.traverse((child) => {
|
|
if (child instanceof THREE.Mesh && child.material instanceof THREE.MeshStandardMaterial) {
|
|
if (child.name.startsWith('Unitree_G1')) {
|
|
child.material.color.set(primaryColor);
|
|
child.material.needsUpdate = true;
|
|
}
|
|
}
|
|
});
|
|
}, [primaryColor, processedScene]);
|
|
|
|
return <primitive object={processedScene} />;
|
|
}
|
|
|
|
function AttireModel({ glbPath, onLoaded }: { glbPath: string; onLoaded: () => void }) {
|
|
// Clear stale useGLTF cache entries for paths that share the same base filename
|
|
// (e.g. /models/robot-doctor.glb?v=1 replaced by ?v=2). This prevents Three.js
|
|
// from serving the old binary when the version query string changes.
|
|
useEffect(() => {
|
|
return () => {
|
|
useGLTF.clear(glbPath);
|
|
};
|
|
}, [glbPath]);
|
|
|
|
const { scene } = useGLTF(glbPath);
|
|
|
|
const processedAttire = useMemo(() => {
|
|
const cloned = scene.clone();
|
|
|
|
cloned.traverse((child) => {
|
|
if (child instanceof THREE.Mesh && child.material instanceof THREE.MeshStandardMaterial) {
|
|
child.material.envMapIntensity = 1;
|
|
child.material.needsUpdate = true;
|
|
}
|
|
});
|
|
|
|
const box = new THREE.Box3().setFromObject(cloned);
|
|
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;
|
|
|
|
cloned.scale.setScalar(scale);
|
|
cloned.position.set(
|
|
-center.x * scale,
|
|
-center.y * scale + 0.5,
|
|
-center.z * scale
|
|
);
|
|
|
|
return cloned;
|
|
}, [scene]);
|
|
|
|
// Signal that the model is ready and rendered
|
|
useEffect(() => {
|
|
onLoaded();
|
|
}, [onLoaded]);
|
|
|
|
return <primitive object={processedAttire} />;
|
|
}
|
|
|
|
interface RobotModelProps {
|
|
onError?: (error: Error) => void;
|
|
}
|
|
|
|
export function RobotModel({ onError: _onError }: RobotModelProps) {
|
|
const groupRef = useRef<THREE.Group>(null);
|
|
|
|
const spinningRef = useRef(false);
|
|
const spinProgressRef = useRef(0);
|
|
const swappedRef = useRef(false);
|
|
const targetAttireRef = useRef<string>('none');
|
|
|
|
const [displayedAttire, setDisplayedAttire] = useState('none');
|
|
const [attireReady, setAttireReady] = useState(false);
|
|
const previousAttireRef = useRef('none');
|
|
|
|
const activeBody = useConfigStore((state) => state.activeBody);
|
|
const bodyGlbPath = BODY_GLB[activeBody] ?? BODY_GLB.basic;
|
|
|
|
const activeColors = useConfigStore((state) => state.activeColors);
|
|
const activePersonaAttire = useConfigStore((state) => state.activePersonaAttire);
|
|
// Subscribe to persona store so custom GLB paths are reactive
|
|
const personas = usePersonaStore((s) => s.personas);
|
|
|
|
// Detect attire change and trigger spin
|
|
useEffect(() => {
|
|
if (activePersonaAttire !== previousAttireRef.current) {
|
|
targetAttireRef.current = activePersonaAttire;
|
|
spinningRef.current = true;
|
|
spinProgressRef.current = 0;
|
|
swappedRef.current = false;
|
|
setAttireReady(false);
|
|
previousAttireRef.current = activePersonaAttire;
|
|
}
|
|
}, [activePersonaAttire]);
|
|
|
|
// Spin animation loop
|
|
useFrame((_, delta) => {
|
|
if (!spinningRef.current || !groupRef.current) return;
|
|
|
|
const spinSpeed = 2.5;
|
|
spinProgressRef.current += delta * spinSpeed;
|
|
|
|
const progress = Math.min(spinProgressRef.current, 1);
|
|
const easedRotation = easeInOutCubic(progress) * Math.PI * 2;
|
|
groupRef.current.rotation.y = easedRotation;
|
|
|
|
if (progress >= 0.5 && !swappedRef.current) {
|
|
setDisplayedAttire(targetAttireRef.current);
|
|
swappedRef.current = true;
|
|
}
|
|
|
|
if (progress >= 1) {
|
|
groupRef.current.rotation.y = 0;
|
|
spinningRef.current = false;
|
|
spinProgressRef.current = 0;
|
|
}
|
|
});
|
|
|
|
// Hide base robot only when attire is selected AND the attire GLB has loaded
|
|
// Show base robot when 'none' is selected or attire is still loading
|
|
const showBase = displayedAttire === 'none' || !attireReady;
|
|
|
|
const attireGlbPath = buildAttireGlbMap(personas)[displayedAttire] || null;
|
|
|
|
const handleAttireLoaded = useCallback(() => {
|
|
setAttireReady(true);
|
|
}, []);
|
|
|
|
return (
|
|
<group ref={groupRef}>
|
|
<Suspense fallback={null}>
|
|
<BaseBodyMesh glbPath={bodyGlbPath} primaryColor={activeColors.primary} visible={showBase} />
|
|
</Suspense>
|
|
|
|
{attireGlbPath && (
|
|
<ModelErrorBoundary key={attireGlbPath} tag="AttireModel" onError={() => { setDisplayedAttire('none'); setAttireReady(false); }}>
|
|
<Suspense fallback={null}>
|
|
<AttireModel
|
|
glbPath={attireGlbPath}
|
|
onLoaded={handleAttireLoaded}
|
|
/>
|
|
</Suspense>
|
|
</ModelErrorBoundary>
|
|
)}
|
|
</group>
|
|
);
|
|
}
|
|
|
|
useGLTF.preload('/Unitree_G1.glb');
|
|
// Preload uploaded attire in the background so they're ready before the user clicks
|
|
useGLTF.preload('/models/robot-doctor.glb');
|
|
useGLTF.preload('/models/security-guard.glb');
|