- Convert site from royal-blue theme to black/white monochrome (desaturate all blue-hued colors; keep WhatsApp green and error red) - Swap blue circular brand logo for mono YS mark; regenerate favicon/PWA icons - Add 6 Pudu robots (CC1 Pro, MT1 Max, MT1 Vac, T600, T600 Underride, BG1 Pro) - Fix favicon.ico to RGBA (Next 16 Turbopack decode) and Stripe apiVersion type Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
219 lines
8.2 KiB
TypeScript
219 lines
8.2 KiB
TypeScript
'use client';
|
|
|
|
import React, { Suspense, useRef, useCallback, useState } from 'react';
|
|
import { Canvas } from '@react-three/fiber';
|
|
import {
|
|
Environment,
|
|
ContactShadows,
|
|
OrbitControls,
|
|
useProgress,
|
|
Html,
|
|
} from '@react-three/drei';
|
|
import { useThree } from '@react-three/fiber';
|
|
import { useGLTF } from '@react-three/drei';
|
|
import { RobotModel } from './RobotModel';
|
|
import { snapshotStore } from '@/store/useSnapshotStore';
|
|
|
|
useGLTF.setDecoderPath('/draco/');
|
|
import type { WebGLRenderer, Scene, Camera } from 'three';
|
|
|
|
function Loader() {
|
|
const { progress } = useProgress();
|
|
return (
|
|
<Html center>
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem' }}>
|
|
<div style={{
|
|
width: '48px', height: '48px',
|
|
border: '3px solid rgba(153, 153, 153, 0.15)',
|
|
borderTopColor: '#999999',
|
|
borderRadius: '50%',
|
|
animation: 'spin 1s linear infinite',
|
|
}} />
|
|
<p style={{ fontSize: '0.875rem', color: '#888888', fontFamily: 'system-ui, sans-serif' }}>
|
|
{progress.toFixed(0)}% loaded
|
|
</p>
|
|
</div>
|
|
</Html>
|
|
);
|
|
}
|
|
|
|
function SceneCapture({ onCapture }: { onCapture: (gl: WebGLRenderer, scene: Scene, camera: Camera) => void }) {
|
|
const { gl, scene, camera } = useThree();
|
|
React.useEffect(() => {
|
|
onCapture(gl, scene, camera);
|
|
}, [gl, scene, camera, onCapture]);
|
|
return null;
|
|
}
|
|
|
|
function SceneContent({ onCapture }: { onCapture: (gl: WebGLRenderer, scene: Scene, camera: Camera) => void }) {
|
|
return (
|
|
<>
|
|
<SceneCapture onCapture={onCapture} />
|
|
<Environment preset="studio" environmentIntensity={2.2} />
|
|
<ambientLight intensity={3.4} />
|
|
<hemisphereLight args={['#ffffff', '#595959', 2.6]} />
|
|
{/* Key — front-right, strong white */}
|
|
<directionalLight position={[5, 5, 5]} intensity={6.0} color="#ffffff" castShadow shadow-mapSize={[1024, 1024]} />
|
|
{/* Fill — front-left, cool tint */}
|
|
<directionalLight position={[-4, 3, 2]} intensity={4.0} color="#f4f4f4" />
|
|
{/* Rim — behind */}
|
|
<directionalLight position={[0, 3, -5]} intensity={2.8} color="#bfbfbf" />
|
|
{/* Top spotlight */}
|
|
<spotLight position={[0, 8, 0]} intensity={4.5} angle={0.8} penumbra={0.45} color="#ffffff" />
|
|
{/* Front fill — direct face/torso */}
|
|
<directionalLight position={[0, 1.5, 6]} intensity={4.0} color="#ffffff" />
|
|
{/* Face spotlight — soft direct head light */}
|
|
<spotLight position={[0, 2.2, 4.2]} intensity={3.0} angle={0.55} penumbra={0.6} color="#ffffff" />
|
|
{/* Bottom fill — lift the legs */}
|
|
<directionalLight position={[0, -1, 4]} intensity={1.4} color="#efefef" />
|
|
{/* Side accents */}
|
|
<pointLight position={[3, 1.5, 3]} intensity={2.4} color="#ffffff" distance={10} />
|
|
<pointLight position={[-3, 1.5, 3]} intensity={2.4} color="#efefef" distance={10} />
|
|
{/* Back side accents — define silhouette */}
|
|
<pointLight position={[2.5, 2.5, -2]} intensity={1.6} color="#d8d8d8" distance={10} />
|
|
<pointLight position={[-2.5, 2.5, -2]} intensity={1.6} color="#d8d8d8" distance={10} />
|
|
<RobotModel />
|
|
<OrbitControls
|
|
target={[0, 0.5, 0]}
|
|
enablePan
|
|
enableZoom
|
|
enableRotate
|
|
minDistance={2}
|
|
maxDistance={10}
|
|
minPolarAngle={0}
|
|
maxPolarAngle={Math.PI}
|
|
dampingFactor={0.05}
|
|
enableDamping
|
|
/>
|
|
<ContactShadows position={[0, -1, 0]} opacity={0.55} scale={10} blur={2.4} far={4} resolution={256} color="#000000" />
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function RobotCanvas() {
|
|
const glRef = useRef<WebGLRenderer | null>(null);
|
|
const sceneRef = useRef<Scene | null>(null);
|
|
const cameraRef = useRef<Camera | null>(null);
|
|
const [isCapturing, setIsCapturing] = useState(false);
|
|
const [shareStatus, setShareStatus] = useState<'idle' | 'copied' | 'failed'>('idle');
|
|
|
|
const handleCapture = useCallback((gl: WebGLRenderer, scene: Scene, camera: Camera) => {
|
|
glRef.current = gl;
|
|
sceneRef.current = scene;
|
|
cameraRef.current = camera;
|
|
|
|
// Register a programmatic capture function for the checkout snapshot
|
|
snapshotStore.getState().registerCapture(() => {
|
|
if (!glRef.current || !sceneRef.current || !cameraRef.current) return null;
|
|
try {
|
|
glRef.current.render(sceneRef.current, cameraRef.current);
|
|
return glRef.current.domElement.toDataURL('image/jpeg', 0.75);
|
|
} catch {
|
|
return null;
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
const handleShare = useCallback(async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(window.location.href);
|
|
setShareStatus('copied');
|
|
setTimeout(() => setShareStatus('idle'), 2000);
|
|
} catch {
|
|
setShareStatus('failed');
|
|
setTimeout(() => setShareStatus('idle'), 2000);
|
|
}
|
|
}, []);
|
|
|
|
const handleSnapshot = useCallback(() => {
|
|
if (!glRef.current || !sceneRef.current || !cameraRef.current) return;
|
|
setIsCapturing(true);
|
|
try {
|
|
glRef.current.render(sceneRef.current, cameraRef.current);
|
|
const dataUrl = glRef.current.domElement.toDataURL('image/png');
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const link = document.createElement('a');
|
|
link.download = `g1-robot-${timestamp}.png`;
|
|
link.href = dataUrl;
|
|
link.click();
|
|
} catch (error) {
|
|
console.error('Failed to capture snapshot:', error);
|
|
} finally {
|
|
setIsCapturing(false);
|
|
}
|
|
}, []);
|
|
|
|
const btnBase: React.CSSProperties = {
|
|
position: 'absolute',
|
|
top: '1rem',
|
|
padding: '0.5rem 1rem',
|
|
backdropFilter: 'blur(8px)',
|
|
borderRadius: '0.5rem',
|
|
cursor: 'pointer',
|
|
fontSize: '0.8rem',
|
|
fontWeight: 500,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '0.5rem',
|
|
transition: 'all 0.2s ease',
|
|
zIndex: 10,
|
|
};
|
|
|
|
return (
|
|
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
|
<Canvas
|
|
dpr={[1, 1.5]}
|
|
camera={{ position: [0, 0.7, 4.6], fov: 42 }}
|
|
gl={{ antialias: true, powerPreference: 'high-performance' }}
|
|
style={{
|
|
background:
|
|
'radial-gradient(ellipse 45% 22% at 50% 82%, rgba(233, 233, 233, 0.32), transparent 70%), radial-gradient(ellipse 70% 65% at 50% 45%, rgba(140, 140, 140, 0.38), transparent 65%), linear-gradient(180deg, #242424 0%, #141414 60%, #0f0f0f 100%)',
|
|
}}
|
|
>
|
|
<Suspense fallback={<Loader />}>
|
|
<SceneContent onCapture={handleCapture} />
|
|
</Suspense>
|
|
</Canvas>
|
|
|
|
<button
|
|
onClick={handleSnapshot}
|
|
disabled={isCapturing}
|
|
style={{
|
|
...btnBase,
|
|
left: '1rem',
|
|
backgroundColor: 'rgba(23, 23, 23, 0.78)',
|
|
border: '1px solid rgba(145, 145, 145, 0.36)',
|
|
color: '#e7e7e7',
|
|
boxShadow: '0 8px 22px rgba(0, 0, 0, 0.45)',
|
|
letterSpacing: '0.04em',
|
|
}}
|
|
aria-label="Capture 3D scene snapshot"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" /><circle cx="8.5" cy="8.5" r="1.5" /><polyline points="21 15 16 10 5 21" />
|
|
</svg>
|
|
{isCapturing ? 'Capturing…' : 'Snapshot'}
|
|
</button>
|
|
|
|
<button
|
|
onClick={handleShare}
|
|
style={{
|
|
...btnBase,
|
|
left: '8.5rem',
|
|
backgroundColor: shareStatus === 'copied' ? 'rgba(171, 171, 171, 0.18)' : 'rgba(23, 23, 23, 0.78)',
|
|
border: `1px solid ${shareStatus === 'copied' ? 'rgba(171, 171, 171, 0.6)' : 'rgba(145, 145, 145, 0.36)'}`,
|
|
color: shareStatus === 'copied' ? '#ababab' : '#e7e7e7',
|
|
boxShadow: '0 8px 22px rgba(0, 0, 0, 0.45)',
|
|
letterSpacing: '0.04em',
|
|
}}
|
|
aria-label="Share configuration link"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><line x1="8.59" y1="13.51" x2="15.42" y2="17.49" /><line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
|
|
</svg>
|
|
{shareStatus === 'copied' ? 'Copied!' : 'Share'}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|