yslootahrobotics/src/components/RobotCanvas.tsx
Najjar 873026a4a9
Some checks are pending
CI/CD / test-and-build (push) Waiting to run
CI/CD / deploy (push) Blocked by required conditions
feat: monochrome black & white rebrand + new Pudu robots
- 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>
2026-07-06 13:20:26 +04:00

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