first
CI/CD / test-and-build (push) Has been cancelled
CI/CD / deploy (push) Has been cancelled

This commit is contained in:
Yjoz
2026-04-10 15:31:59 +04:00
parent 87d2688eeb
commit ec0f991a30
55 changed files with 10218 additions and 0 deletions
+163
View File
@@ -0,0 +1,163 @@
'use client';
import { useCallback, useEffect } from 'react';
import { useOrderStore, orderStore, type CheckoutStep } from '@/store/useOrderStore';
import { ShippingStep } from './checkout/ShippingStep';
import { PaymentStep } from './checkout/PaymentStep';
import { ReviewStep } from './checkout/ReviewStep';
import { ConfirmationStep } from './checkout/ConfirmationStep';
const STEPS: { id: CheckoutStep; label: string }[] = [
{ id: 'shipping', label: 'Shipping' },
{ id: 'payment', label: 'Payment' },
{ id: 'review', label: 'Review' },
];
function getStepIndex(step: CheckoutStep): number {
return STEPS.findIndex((s) => s.id === step);
}
export function CheckoutOverlay() {
const step = useOrderStore((s) => s.step);
const handleClose = useCallback(() => {
orderStore.getState().resetOrder();
}, []);
const handleBack = useCallback(() => {
const currentIndex = getStepIndex(step);
if (currentIndex > 0) {
orderStore.getState().setStep(STEPS[currentIndex - 1].id);
} else {
orderStore.getState().setStep('config');
}
}, [step]);
// Close on Escape key
useEffect(() => {
if (step === 'config') return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [step, handleClose]);
if (step === 'config') return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label="Checkout"
style={{
position: 'fixed',
inset: 0,
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(255, 255, 255, 0.92)',
backdropFilter: 'blur(12px)',
}}
>
<div style={{
width: '100%',
maxWidth: '560px',
maxHeight: '90vh',
overflowY: 'auto',
margin: '1rem',
background: 'rgba(255, 255, 255, 0.95)',
border: '1px solid rgba(59, 130, 246, 0.08)',
borderRadius: '1rem',
boxShadow: '0 24px 80px rgba(0, 0, 0, 0.5), 0 0 40px rgba(59, 130, 246, 0.1)',
}}>
{/* Header with progress */}
{step !== 'confirmed' && (
<div style={{ padding: '1.25rem 1.5rem', borderBottom: '1px solid rgba(59, 130, 246, 0.1)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<button
onClick={handleBack}
style={{
padding: '0.35rem 0.75rem',
borderRadius: '0.375rem',
border: '1px solid rgba(0, 0, 0, 0.08)',
background: 'transparent',
color: '#94a3b8',
cursor: 'pointer',
fontSize: '0.75rem',
transition: 'all 0.2s ease',
}}
>
Back
</button>
<h2 style={{ fontSize: '0.9rem', fontWeight: 600, color: '#1a1a2e', margin: 0 }}>
Checkout
</h2>
<button
onClick={handleClose}
style={{
width: '28px',
height: '28px',
borderRadius: '50%',
border: '1px solid rgba(0, 0, 0, 0.08)',
background: 'transparent',
color: '#94a3b8',
cursor: 'pointer',
fontSize: '0.9rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
aria-label="Close checkout"
>
x
</button>
</div>
{/* Progress bar */}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{STEPS.map((s, i) => {
const currentIdx = getStepIndex(step);
const isActive = i === currentIdx;
const isComplete = i < currentIdx;
return (
<div key={s.id} style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<div style={{
height: '3px',
borderRadius: '2px',
background: isComplete
? '#3b82f6'
: isActive
? 'linear-gradient(90deg, #3b82f6, rgba(59, 130, 246, 0.2))'
: 'rgba(0, 0, 0, 0.06)',
transition: 'all 0.3s ease',
}} />
<span style={{
fontSize: '0.65rem',
color: isActive ? '#2563eb' : isComplete ? '#3b82f6' : '#64748b',
fontWeight: isActive ? 600 : 400,
textAlign: 'center',
}}>
{s.label}
</span>
</div>
);
})}
</div>
</div>
)}
{/* Step Content */}
<div style={{ padding: '1.5rem' }}>
{step === 'shipping' && <ShippingStep />}
{step === 'payment' && <PaymentStep />}
{step === 'review' && <ReviewStep />}
{step === 'confirmed' && <ConfirmationStep />}
</div>
</div>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
'use client';
import { useState, useEffect, ReactNode } from 'react';
interface ClientOnlyProps {
children: ReactNode;
fallback?: ReactNode;
}
export function ClientOnly({ children, fallback = null }: ClientOnlyProps) {
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
setHasMounted(true);
}, []);
if (!hasMounted) {
return <>{fallback}</>;
}
return <>{children}</>;
}
+229
View File
@@ -0,0 +1,229 @@
'use client';
import { useCallback, useRef } from 'react';
import { configStore, useConfigStore } from '@/store/useConfigStore';
import { PricingEngine } from './PricingEngine';
// Persona attire options with visual metadata
const PERSONA_OPTIONS = [
{
id: 'none',
label: 'Default',
description: 'Original robot appearance',
colors: { torso: '#3b82f6', legs: '#3b82f6' },
},
{
id: 'emarati-kandura',
label: 'Emarati Kandura',
description: 'Traditional white robe attire',
colors: { torso: '#f8fafc', legs: '#f8fafc' },
},
{
id: 'industrial-vest',
label: 'Industrial Vest',
description: 'High-visibility safety vest',
colors: { torso: '#f59e0b', legs: '#3b82f6' },
},
{
id: 'business-suit',
label: 'Business Suit',
description: 'Professional navy suit',
colors: { torso: '#1e293b', legs: '#1e293b' },
},
] as const;
export function ConfigPanel() {
const activeColors = useConfigStore((s) => s.activeColors);
const activePersona = useConfigStore((s) => s.activePersonaAttire);
const colorsSectionRef = useRef<HTMLElement>(null);
const personaSectionRef = useRef<HTMLElement>(null);
const handleColorChange = useCallback((key: 'primary' | 'secondary' | 'accent', value: string) => {
configStore.getState().setColors({ [key]: value });
}, []);
const handlePersonaSelect = useCallback((attire: string) => {
configStore.getState().setPersonaAttire(attire);
}, []);
const handleReset = useCallback(() => {
configStore.getState().reset();
configStore.getState().setHydrated(true);
}, []);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{/* --- COLORS SECTION --- */}
<section
ref={colorsSectionRef}
id="section-colors"
style={{
borderRadius: '0.5rem',
padding: '0.75rem',
}}
>
<h3 style={sectionTitleStyle}>Colors</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<ColorInput label="Primary" value={activeColors.primary} onChange={(v) => handleColorChange('primary', v)} />
</div>
</section>
{/* --- PERSONA SECTION --- */}
<section
ref={personaSectionRef}
id="section-persona"
style={{
borderRadius: '0.5rem',
padding: '0.75rem',
}}
>
<h3 style={sectionTitleStyle}>Persona Attire</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{PERSONA_OPTIONS.map((persona) => {
const isActive = activePersona === persona.id;
return (
<button
key={persona.id}
onClick={() => handlePersonaSelect(persona.id)}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
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',
width: '100%',
}}
aria-pressed={isActive}
>
{/* Color preview swatch */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px', flexShrink: 0 }}>
<div style={{
width: '36px',
height: '12px',
borderRadius: '3px 3px 0 0',
backgroundColor: persona.colors.torso,
border: '1px solid rgba(255,255,255,0.1)',
borderBottom: 'none',
}} />
<div style={{
width: '36px',
height: '12px',
borderRadius: '0 0 3px 3px',
backgroundColor: persona.colors.legs,
border: '1px solid rgba(255,255,255,0.1)',
borderTop: 'none',
}} />
</div>
{/* Text */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: '0.8rem',
fontWeight: isActive ? 600 : 400,
color: isActive ? '#374151' : '#94a3b8',
marginBottom: '2px',
}}>
{persona.label}
</div>
<div style={{
fontSize: '0.65rem',
color: '#64748b',
lineHeight: 1.3,
}}>
{persona.description}
</div>
</div>
{/* Active checkmark */}
{isActive && (
<div style={{
width: '20px',
height: '20px',
borderRadius: '50%',
background: 'rgba(59, 130, 246, 0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#2563eb" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
)}
</button>
);
})}
</div>
</section>
{/* --- PRICING --- */}
<PricingEngine />
{/* --- RESET --- */}
<button
onClick={handleReset}
style={{
padding: '0.6rem',
borderRadius: '0.375rem',
border: '1px solid rgba(239, 68, 68, 0.2)',
background: 'rgba(239, 68, 68, 0.05)',
color: '#ef4444',
cursor: 'pointer',
fontSize: '0.8rem',
transition: 'all 0.2s ease',
}}
>
Reset Configuration
</button>
</div>
);
}
function ColorInput({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input
type="color"
value={value}
onChange={(e) => onChange(e.target.value)}
style={{
width: '32px',
height: '32px',
border: '2px solid rgba(0, 0, 0, 0.08)',
borderRadius: '0.375rem',
background: 'transparent',
cursor: 'pointer',
padding: 0,
}}
aria-label={`${label} color`}
/>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '0.8rem', color: '#374151', marginBottom: '2px' }}>{label}</div>
<div style={{ fontSize: '0.7rem', color: '#64748b', fontFamily: 'monospace' }}>{value}</div>
</div>
</div>
);
}
const sectionTitleStyle: React.CSSProperties = {
fontSize: '0.75rem',
fontWeight: 500,
color: '#94a3b8',
textTransform: 'uppercase',
letterSpacing: '0.05em',
margin: 0,
paddingBottom: '0.5rem',
borderBottom: '1px solid rgba(0, 0, 0, 0.06)',
marginBottom: '0.75rem',
};
+165
View File
@@ -0,0 +1,165 @@
'use client';
import { useTranslation } from 'react-i18next';
import { useUrlSync } from '@/hooks/useUrlSync';
import { RobotCanvas } from '@/components/RobotCanvas';
import { ClientOnly } from '@/components/ClientOnly';
import { ConfigPanel } from '@/components/ConfigPanel';
import { CheckoutOverlay } from '@/components/CheckoutOverlay';
export function ConfiguratorSection() {
const { isHydrated } = useUrlSync();
const { t } = useTranslation();
return (
<section
id="configurator"
className="snap-section"
style={{
position: 'relative',
width: '100%',
height: '100vh',
background: '#ffffff',
}}
>
<div
className="layout-container"
style={{
display: 'flex',
width: '100%',
height: '100%',
overflow: 'hidden',
}}
>
<aside
className="glass-panel-responsive"
style={{
order: -1,
width: '420px',
height: '100%',
background: 'rgba(255, 255, 255, 0.9)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
borderRight: '1px solid #e2e8f0',
boxShadow: '4px 0 30px rgba(0, 0, 0, 0.04)',
display: 'flex',
flexDirection: 'column',
position: 'relative',
overflow: 'hidden',
}}
role="complementary"
aria-label={t('panel.title')}
>
<div
className="mobile-handle"
style={{
padding: '0.75rem',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
aria-hidden="true"
>
<div
style={{
width: '40px',
height: '4px',
backgroundColor: '#e2e8f0',
borderRadius: '2px',
}}
/>
</div>
<header
style={{
padding: '1.25rem 1.5rem',
borderBottom: '1px solid #e2e8f0',
position: 'relative',
zIndex: 1,
}}
>
<h2
style={{
fontSize: '1rem',
fontWeight: 600,
color: '#1a1a2e',
margin: 0,
}}
>
{t('panel.title')}
</h2>
</header>
<div
style={{
flex: 1,
padding: '1.5rem',
overflowY: 'auto',
position: 'relative',
zIndex: 1,
}}
role="region"
aria-label={t('panel.currentConfiguration')}
>
<ConfigPanel />
</div>
</aside>
<main
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
width: '100%',
height: '100%',
}}
role="main"
aria-label={t('app.title')}
>
{!isHydrated && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#ffffff',
zIndex: 10,
}}
role="status"
aria-live="polite"
>
<div
style={{
width: '48px',
height: '48px',
border: '3px solid #e2e8f0',
borderTopColor: '#3b82f6',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}}
/>
<p style={{ marginTop: '1rem', fontSize: '0.875rem', color: '#94a3b8' }}>
{t('loading.configuration')}
</p>
</div>
)}
{isHydrated && (
<div style={{ width: '100%', height: '100%' }}>
<ClientOnly>
<RobotCanvas />
</ClientOnly>
</div>
)}
</main>
</div>
<CheckoutOverlay />
</section>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { Component, ReactNode } from "react";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
console.error("ErrorBoundary caught an error:", error, errorInfo);
}
render(): ReactNode {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
padding: "2rem",
backgroundColor: "var(--color-primary)",
color: "var(--color-text-primary)",
textAlign: "center",
}}
role="alert"
aria-live="assertive"
>
<h1 style={{ fontSize: "1.5rem", marginBottom: "1rem" }}>
Something went wrong
</h1>
<p style={{ color: "var(--color-text-secondary)", marginBottom: "1.5rem" }}>
{this.state.error?.message || "An unexpected error occurred"}
</p>
<button
onClick={() => window.location.reload()}
style={{
padding: "0.75rem 1.5rem",
backgroundColor: "var(--color-accent)",
color: "white",
border: "none",
borderRadius: "var(--radius-md)",
cursor: "pointer",
fontWeight: 500,
transition: "background-color var(--transition-fast)",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = "var(--color-accent-hover)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = "var(--color-accent)")
}
>
Reload Page
</button>
</div>
);
}
return this.props.children;
}
}
+35
View File
@@ -0,0 +1,35 @@
'use client';
import { useEffect, useState } from 'react';
import { I18nextProvider } from 'react-i18next';
import i18n from '@/i18n/config';
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
// Set initial document direction based on detected language
const lang = i18n.language || 'en';
document.documentElement.dir = lang === 'ar' ? 'rtl' : 'ltr';
document.documentElement.lang = lang;
// Listen for language changes
const handleLanguageChanged = (lng: string) => {
document.documentElement.dir = lng === 'ar' ? 'rtl' : 'ltr';
document.documentElement.lang = lng;
};
i18n.on('languageChanged', handleLanguageChanged);
setIsInitialized(true);
return () => {
i18n.off('languageChanged', handleLanguageChanged);
};
}, []);
if (!isInitialized) {
return null;
}
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
}
+195
View File
@@ -0,0 +1,195 @@
'use client';
import { useRef, useState, useCallback } from 'react';
import { Html } from '@react-three/drei';
import { useFrame, ThreeEvent } from '@react-three/fiber';
import * as THREE from 'three';
import { SocketName, getSocketTransform } from './SocketPoints';
interface InteractiveHotspotProps {
socketName: SocketName;
label: string;
icon?: string;
onClick?: (socketName: SocketName) => void;
visible?: boolean;
}
export function InteractiveHotspot({
socketName,
label,
icon = '+',
onClick,
visible = true,
}: InteractiveHotspotProps) {
const meshRef = useRef<THREE.Mesh>(null);
const ringRef = useRef<THREE.Mesh>(null);
const [hovered, setHovered] = useState(false);
const [showTooltip, setShowTooltip] = useState(false);
const transform = getSocketTransform(socketName);
// Pulsing animation for the hotspot
useFrame((state) => {
if (meshRef.current) {
const scale = 1 + Math.sin(state.clock.elapsedTime * 3) * 0.1;
meshRef.current.scale.setScalar(hovered ? 1.3 : scale);
}
if (ringRef.current) {
const ringScale = 1 + Math.sin(state.clock.elapsedTime * 2) * 0.15;
ringRef.current.scale.setScalar(ringScale);
const opacity = 0.3 + Math.sin(state.clock.elapsedTime * 2) * 0.2;
if (ringRef.current.material instanceof THREE.MeshBasicMaterial) {
ringRef.current.material.opacity = opacity;
}
}
});
const handleClick = useCallback(
(e: ThreeEvent<MouseEvent>) => {
e.stopPropagation();
onClick?.(socketName);
},
[onClick, socketName]
);
const handlePointerEnter = useCallback(() => {
setHovered(true);
setShowTooltip(true);
document.body.style.cursor = 'pointer';
}, []);
const handlePointerLeave = useCallback(() => {
setHovered(false);
setShowTooltip(false);
document.body.style.cursor = 'auto';
}, []);
return (
<group position={transform.position}>
{/* 3D pulsing sphere */}
<mesh
ref={meshRef}
onClick={handleClick}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
<sphereGeometry args={[0.025, 16, 16]} />
<meshStandardMaterial
color={hovered ? '#3b82f6' : '#f59e0b'}
emissive={hovered ? '#3b82f6' : '#f59e0b'}
emissiveIntensity={hovered ? 0.8 : 0.4}
transparent
opacity={0.9}
/>
</mesh>
{/* Outer ring */}
<mesh ref={ringRef} rotation={[0, 0, 0]}>
<ringGeometry args={[0.03, 0.04, 32]} />
<meshBasicMaterial
color="#f59e0b"
transparent
opacity={0.5}
side={THREE.DoubleSide}
/>
</mesh>
{/* HTML label */}
<Html
position={[0, 0.06, 0]}
center
distanceFactor={8}
style={{
transition: 'all 0.2s',
opacity: 1,
pointerEvents: 'none',
}}
>
{visible && showTooltip && (
<div
style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(8px)',
border: '1px solid rgba(59, 130, 246, 0.3)',
borderRadius: '8px',
padding: '8px 12px',
whiteSpace: 'nowrap',
fontFamily: 'system-ui, -apple-system, sans-serif',
fontSize: '12px',
color: '#1a1a2e',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
animation: 'fadeIn 0.15s ease-out',
}}
>
<div style={{ fontWeight: 600, marginBottom: 2 }}>{label}</div>
<div
style={{
fontSize: '10px',
color: '#94a3b8',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
<span
style={{
background: '#3b82f6',
color: 'white',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '10px',
}}
>
{icon}
</span>
Click to customize
</div>
</div>
)}
</Html>
</group>
);
}
// Hotspot panel component to manage visibility of multiple hotspots
interface HotspotPanelProps {
onSocketClick?: (socketName: SocketName) => void;
visibleSockets?: SocketName[];
}
export function HotspotPanel({ onSocketClick, visibleSockets }: HotspotPanelProps) {
const defaultSockets: SocketName[] = ['head', 'chest', 'left_shoulder', 'right_shoulder', 'back', 'base'];
const socketsToShow = visibleSockets || defaultSockets;
const socketLabels: Record<SocketName, string> = {
head: 'Head Mount',
chest: 'Chest Plate',
left_shoulder: 'Left Shoulder',
right_shoulder: 'Right Shoulder',
back: 'Back Pack',
base: 'Base Unit',
};
const socketIcons: Record<SocketName, string> = {
head: 'H',
chest: 'C',
left_shoulder: 'L',
right_shoulder: 'R',
back: 'B',
base: 'U',
};
return (
<>
{socketsToShow.map((socket) => (
<InteractiveHotspot
key={socket}
socketName={socket}
label={socketLabels[socket]}
icon={socketIcons[socket]}
onClick={onSocketClick}
/>
))}
</>
);
}
+55
View File
@@ -0,0 +1,55 @@
'use client';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export function LanguageSwitcher() {
const { i18n } = useTranslation();
const toggleLanguage = useCallback(() => {
const newLang = i18n.language === 'en' ? 'ar' : 'en';
i18n.changeLanguage(newLang);
// Update document direction for RTL support
document.documentElement.dir = newLang === 'ar' ? 'rtl' : 'ltr';
document.documentElement.lang = newLang;
}, [i18n]);
return (
<button
onClick={toggleLanguage}
style={{
padding: '0.5rem 1rem',
backgroundColor: 'rgba(0, 0, 0, 0.04)',
border: '1px solid rgba(0, 0, 0, 0.08)',
borderRadius: '0.5rem',
color: '#1a1a2e',
cursor: 'pointer',
fontSize: '0.875rem',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
transition: 'all 0.2s ease',
}}
aria-label="Toggle language"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
{i18n.language === 'en' ? 'العربية' : 'English'}
</button>
);
}
+227
View File
@@ -0,0 +1,227 @@
'use client';
import React, { Suspense, useMemo, useState, useCallback } from 'react';
import { useGLTF } from '@react-three/drei';
import { useSpring, animated } from '@react-spring/three';
import * as THREE from 'three';
import { SocketName, getSocketTransform } from './SocketPoints';
interface PayloadMeshProps {
payloadId: string;
type: string;
socketPosition: SocketName;
}
// Map payload types to GLB file paths
const PAYLOAD_MODELS: Record<string, string> = {
'camera': '/payload-camera.glb',
'ptz-camera': '/payload-ptz.glb',
'light': '/payload-light.glb',
'sensor': '/payload-sensor.glb',
'speaker': '/payload-speaker.glb',
};
// Normalize payload type for lookup
const getModelPath = (type: string): string | null => {
const normalized = type.toLowerCase().replace(/\s+/g, '-');
return PAYLOAD_MODELS[normalized] || null;
};
// Placeholder materials by payload type - visually distinct and bright
const PLACEHOLDER_MATERIALS: Record<string, THREE.MeshStandardMaterial> = {
camera: new THREE.MeshStandardMaterial({
color: '#3b82f6',
metalness: 0.6,
roughness: 0.4,
emissive: '#3b82f6',
emissiveIntensity: 0.5,
}),
ptzcamera: new THREE.MeshStandardMaterial({
color: '#3b82f6',
metalness: 0.6,
roughness: 0.4,
emissive: '#3b82f6',
emissiveIntensity: 0.5,
}),
ptz: new THREE.MeshStandardMaterial({
color: '#3b82f6',
metalness: 0.6,
roughness: 0.4,
emissive: '#3b82f6',
emissiveIntensity: 0.5,
}),
light: new THREE.MeshStandardMaterial({
color: '#fbbf24',
metalness: 0.8,
roughness: 0.2,
emissive: '#fbbf24',
emissiveIntensity: 0.8,
}),
sensor: new THREE.MeshStandardMaterial({
color: '#22c55e',
metalness: 0.4,
roughness: 0.6,
emissive: '#22c55e',
emissiveIntensity: 0.6,
}),
speaker: new THREE.MeshStandardMaterial({
color: '#a855f7',
metalness: 0.5,
roughness: 0.5,
emissive: '#a855f7',
emissiveIntensity: 0.5,
}),
default: new THREE.MeshStandardMaterial({
color: '#3b82f6',
metalness: 0.6,
roughness: 0.4,
emissive: '#3b82f6',
emissiveIntensity: 0.5,
}),
};
function PayloadLoader({ modelPath }: { modelPath: string }) {
const [hasError, setHasError] = useState(false);
const { scene } = useGLTF(modelPath);
const processedScene = useMemo(() => {
if (hasError) return null;
const cloned = scene.clone();
cloned.traverse((child) => {
if (child instanceof THREE.Mesh) {
if (child.material) {
child.material = new THREE.MeshStandardMaterial({
color: '#64748b',
metalness: 0.6,
roughness: 0.4,
});
}
}
});
return cloned;
}, [scene, hasError]);
if (hasError || !processedScene) {
throw new Error('Failed to load payload model');
}
return <primitive object={processedScene} />;
}
// Placeholder geometry when GLB is not available or fails to load
function PayloadPlaceholder({ type }: { type: string }) {
const normalizedType = type.toLowerCase().replace(/\s+/g, '-').replace('-camera', 'camera');
const material = PLACEHOLDER_MATERIALS[normalizedType] || PLACEHOLDER_MATERIALS.default;
// Different placeholder shapes based on payload type
const renderGeometry = () => {
switch (normalizedType) {
case 'camera':
return <cylinderGeometry args={[0.03, 0.04, 0.08, 16]} />;
case 'ptzcamera':
case 'ptz':
return <cylinderGeometry args={[0.04, 0.05, 0.1, 16]} />;
case 'light':
return <boxGeometry args={[0.06, 0.06, 0.03]} />;
case 'sensor':
return <sphereGeometry args={[0.04, 16, 16]} />;
case 'speaker':
return <cylinderGeometry args={[0.035, 0.035, 0.05, 16]} />;
default:
return <boxGeometry args={[0.05, 0.05, 0.05]} />;
}
};
return (
<mesh rotation={[Math.PI / 2, 0, 0]}>
{renderGeometry()}
<primitive object={material} attach="material" />
</mesh>
);
}
// Error boundary fallback component
function PayloadErrorFallback({ type }: { type: string }) {
return <PayloadPlaceholder type={type} />;
}
// Wrapper with error handling for GLB loading
function PayloadWithErrorBoundary({ modelPath, type }: { modelPath: string; type: string }) {
const [error, setError] = useState<Error | null>(null);
const handleError = useCallback((err: Error) => {
console.warn('Payload GLB failed to load, using placeholder:', err.message);
setError(err);
}, []);
if (error) {
return <PayloadErrorFallback type={type} />;
}
return (
<Suspense fallback={<PayloadPlaceholder type={type} />}>
<ErrorBoundary onError={handleError}>
<PayloadLoader modelPath={modelPath} />
</ErrorBoundary>
</Suspense>
);
}
// Simple error boundary component for class-based fallback
class ErrorBoundary extends React.Component<
{ children: React.ReactNode; onError: (error: Error) => void },
{ hasError: boolean }
> {
constructor(props: { children: React.ReactNode; onError: (error: Error) => void }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error) {
this.props.onError(error);
}
render() {
if (this.state.hasError) {
return <PayloadPlaceholder type="default" />;
}
return this.props.children;
}
}
export function PayloadMesh({ payloadId, type, socketPosition }: PayloadMeshProps) {
const transform = getSocketTransform(socketPosition);
const modelPath = getModelPath(type);
// Spring animation for scale and y position
const { scale, posY } = useSpring({
from: { scale: 0, posY: transform.position.y - 0.1 },
to: { scale: 1, posY: transform.position.y },
config: { mass: 0.3, tension: 300, friction: 20 },
});
return (
<animated.group
position-x={transform.position.x}
position-y={posY}
position-z={transform.position.z}
rotation={transform.rotation}
scale={scale}
>
{/* Always render either GLB model or placeholder */}
{modelPath ? (
<PayloadWithErrorBoundary modelPath={modelPath} type={type} />
) : (
<PayloadPlaceholder type={type} />
)}
</animated.group>
);
}
+130
View File
@@ -0,0 +1,130 @@
'use client';
import { useEffect } from 'react';
import { useConfigStore } from '@/store/useConfigStore';
import { usePricingStore, pricingStore } from '@/store/usePricingStore';
import { orderStore } from '@/store/useOrderStore';
const DEFAULT_COLOR = '#96a2b6';
function formatAED(price: number): string {
return new Intl.NumberFormat('en-AE').format(price);
}
export function PricingEngine() {
const persona = useConfigStore((s) => s.activePersonaAttire);
const primaryColor = useConfigStore((s) => s.activeColors.primary);
const items = usePricingStore((s) => s.items);
const isHydrated = usePricingStore((s) => s.isHydrated);
useEffect(() => {
pricingStore.getState().hydrate();
}, []);
const getPrice = (id: string) => items.find((i) => i.id === id)?.price ?? 0;
const basePrice = getPrice('base');
const personaPrice = persona !== 'none' ? getPrice(persona) : 0;
const colorPrice = primaryColor !== DEFAULT_COLOR ? getPrice('custom-color') : 0;
const total = basePrice + personaPrice + colorPrice;
const personaLabel = items.find((i) => i.id === persona)?.label ?? '';
const handleProceed = () => {
const store = orderStore.getState();
store.setOrderTotal(total);
store.setConfigSummary(
persona === 'none' ? 'Default' : personaLabel,
primaryColor
);
store.setStep('shipping');
};
if (!isHydrated) return null;
return (
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
padding: '0.75rem',
borderRadius: '0.5rem',
background: 'rgba(59, 130, 246, 0.03)',
border: '1px solid rgba(59, 130, 246, 0.06)',
}}>
<h3 style={{
fontSize: '0.75rem',
fontWeight: 500,
color: '#94a3b8',
textTransform: 'uppercase',
letterSpacing: '0.05em',
margin: 0,
paddingBottom: '0.5rem',
borderBottom: '1px solid rgba(0, 0, 0, 0.06)',
}}>
Price Breakdown
</h3>
{/* Line items */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<PriceLine label="G1 Robot Base" price={basePrice} />
{personaPrice > 0 && <PriceLine label={personaLabel} price={personaPrice} />}
{colorPrice > 0 && <PriceLine label="Custom Color" price={colorPrice} />}
</div>
{/* Total */}
<div
aria-live="polite"
aria-atomic="true"
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: '0.5rem',
borderTop: '1px solid rgba(0, 0, 0, 0.06)',
marginTop: '0.25rem',
}}
>
<span style={{ fontSize: '0.8rem', fontWeight: 600, color: '#374151' }}>Total</span>
<span style={{ fontSize: '0.9rem', fontWeight: 700, color: '#1a1a2e', fontFamily: 'monospace' }}>
AED {formatAED(total)}
</span>
</div>
{/* Proceed button */}
<button
onClick={handleProceed}
style={{
marginTop: '0.5rem',
padding: '0.7rem',
borderRadius: '0.375rem',
border: '1px solid rgba(59, 130, 246, 0.5)',
background: 'rgba(59, 130, 246, 0.08)',
color: '#2563eb',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
transition: 'all 0.2s ease',
textAlign: 'center',
}}
>
Proceed to Order
</button>
</div>
);
}
function PriceLine({ label, price }: { label: string; price: number }) {
return (
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}>
<span style={{ fontSize: '0.75rem', color: '#94a3b8' }}>{label}</span>
<span style={{ fontSize: '0.75rem', color: '#374151', fontFamily: 'monospace' }}>
AED {formatAED(price)}
</span>
</div>
);
}
+163
View File
@@ -0,0 +1,163 @@
'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 { RobotModel } from './RobotModel';
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(59, 130, 246, 0.15)',
borderTopColor: '#3b82f6',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}} />
<p style={{ fontSize: '0.875rem', color: '#64748b', 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="city" />
<ambientLight intensity={0.8} />
<directionalLight position={[5, 5, 5]} intensity={1.8} color="#ffffff" castShadow shadow-mapSize={[1024, 1024]} />
<directionalLight position={[-4, 3, 2]} intensity={0.8} color="#e8f0ff" />
<directionalLight position={[0, 3, -5]} intensity={0.8} color="#94a3b8" />
<spotLight position={[0, 8, 0]} intensity={1.0} angle={0.6} penumbra={0.5} color="#ffffff" />
<directionalLight position={[0, 2, 5]} intensity={0.7} color="#ffffff" />
<RobotModel />
<OrbitControls enablePan enableZoom enableRotate minDistance={2} maxDistance={10} minPolarAngle={0} maxPolarAngle={Math.PI} dampingFactor={0.05} enableDamping />
<ContactShadows position={[0, -1, 0]} opacity={0.25} scale={10} blur={2} 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;
}, []);
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, 2]}
camera={{ position: [0, 1, 5], fov: 50 }}
gl={{ antialias: true, powerPreference: 'high-performance' }}
style={{ background: 'linear-gradient(180deg, #e8e8e4 0%, #f0f0ec 50%, #e8e8e4 100%)' }}
>
<Suspense fallback={<Loader />}>
<SceneContent onCapture={handleCapture} />
</Suspense>
</Canvas>
<button
onClick={handleSnapshot}
disabled={isCapturing}
style={{ ...btnBase, left: '1rem', backgroundColor: 'rgba(255,255,255,0.8)', border: '1px solid #e2e8f0', color: '#1a1a2e' }}
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(34,197,94,0.1)' : 'rgba(255,255,255,0.8)',
border: `1px solid ${shareStatus === 'copied' ? '#86efac' : '#e2e8f0'}`,
color: shareStatus === 'copied' ? '#16a34a' : '#1a1a2e',
}}
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>
);
}
+200
View File
@@ -0,0 +1,200 @@
'use client';
import { useRef, useMemo, useEffect, Suspense, useState } from 'react';
import { useGLTF } from '@react-three/drei';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import { useConfigStore } from '@/store/useConfigStore';
const ATTIRE_GLB: Record<string, string> = {
'emarati-kandura': '/Kandoura.glb',
'industrial-vest': '/Vest.glb',
'business-suit': '/Suit.glb',
};
// Preload all attire models so they're cached before user clicks
Object.values(ATTIRE_GLB).forEach((path) => useGLTF.preload(path));
function easeInOutCubic(t: number): number {
return t < 0.5
? 4 * t * t * t
: 1 - Math.pow(-2 * t + 2, 3) / 2;
}
function AttireModel({ glbPath, onLoaded }: { glbPath: string; onLoaded: () => void }) {
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 }: 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 { scene } = useGLTF('/Unitree_G1.glb');
const activeColors = useConfigStore((state) => state.activeColors);
const activePersonaAttire = useConfigStore((state) => state.activePersonaAttire);
// 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;
}
});
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
// Show base robot when 'none' is selected or attire is still loading
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 = ATTIRE_GLB[displayedAttire] || null;
const handleAttireLoaded = () => {
setAttireReady(true);
};
return (
<group ref={groupRef}>
<primitive object={processedScene} />
{attireGlbPath && (
<Suspense fallback={null}>
<AttireModel
key={attireGlbPath}
glbPath={attireGlbPath}
onLoaded={handleAttireLoaded}
/>
</Suspense>
)}
</group>
);
}
useGLTF.preload('/Unitree_G1.glb');
+269
View File
@@ -0,0 +1,269 @@
'use client';
import { useScroll, useTransform, motion } from 'framer-motion';
import { ReactNode } from 'react';
interface SectionProps {
children: ReactNode;
progress: any;
startAt: number;
peakAt: number;
endAt: number;
align: 'center' | 'left' | 'right';
verticalAlign?: 'top' | 'center' | 'bottom';
offsetY?: number;
}
function OverlaySection({
children,
progress,
startAt,
peakAt,
endAt,
align,
verticalAlign = 'center',
offsetY = 50,
}: SectionProps) {
// Define a wide "plateau" zone where the text is fully readable and static.
// We use 30% of the travel distance for fading in, 30% for fading out.
const diffIn = peakAt - startAt;
const diffOut = endAt - peakAt;
const inStart = startAt;
const inEnd = startAt + diffIn * 0.4; // Fades in quickly
const outStart = endAt - diffOut * 0.6; // Holds for a long time
const outEnd = endAt;
// Smooth fade in and out mapping over a lengthy plateau
const opacity = useTransform(
progress,
[inStart, inEnd, outStart, outEnd],
[0, 1, 1, 0]
);
// Translate Y to slide in, pause entirely while reading, slide out
const y = useTransform(
progress,
[inStart, inEnd, outStart, outEnd],
[offsetY, 0, 0, -offsetY]
);
// Scale stays at 1.0 during reading mode
const scale = useTransform(
progress,
[inStart, inEnd, outStart, outEnd],
[0.95, 1, 1, 1.05]
);
const alignStyle: React.CSSProperties =
align === 'left'
? { left: 'clamp(2rem, 6vw, 6rem)', alignItems: 'flex-start' }
: align === 'right'
? { right: 'clamp(2rem, 6vw, 6rem)', alignItems: 'flex-end' }
: { left: '50%', x: '-50%', alignItems: 'center' };
const verticalStyle: React.CSSProperties =
verticalAlign === 'top'
? { top: '15vh' }
: verticalAlign === 'bottom'
? { bottom: '15vh', top: 'auto' }
: { top: '50%', y: '-50%' };
// Glass panel appearance for side text to not clash with the robot
const isCenter = align === 'center';
const panelStyle: React.CSSProperties = isCenter
? { textAlign: 'center' }
: {
background: 'rgba(255, 255, 255, 0.45)',
backdropFilter: 'blur(16px)',
WebkitBackdropFilter: 'blur(16px)',
padding: '2.5rem',
borderRadius: '1.5rem',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.08)',
border: '1px solid rgba(255, 255, 255, 0.6)',
textAlign: align === 'left' ? 'left' : 'right',
maxWidth: '450px',
};
return (
<motion.div
style={{
position: 'absolute',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
opacity,
pointerEvents: 'none',
...verticalStyle,
...alignStyle,
}}
>
<motion.div
style={{
y,
scale,
...panelStyle,
}}
className="will-change-transform"
>
{children}
</motion.div>
</motion.div>
);
}
// Section timing definitions to match ScrollScene camera moves
const SECTION_CONFIGS = [
{ id: 'brand', startAt: 0, peakAt: 0.05, endAt: 0.15, align: 'center' as const, verticalAlign: 'top' as const },
{ id: 'hero', startAt: 0.10, peakAt: 0.22, endAt: 0.35, align: 'left' as const, verticalAlign: 'center' as const },
{ id: 'headReveal', startAt: 0.35, peakAt: 0.46, endAt: 0.53, align: 'right' as const, verticalAlign: 'center' as const },
{ id: 'customization', startAt: 0.55, peakAt: 0.66, endAt: 0.77, align: 'left' as const, verticalAlign: 'center' as const },
{ id: 'mobility', startAt: 0.80, peakAt: 0.90, endAt: 1.0, align: 'right' as const, verticalAlign: 'center' as const },
];
export function ScrollOverlays() {
const { scrollYProgress } = useScroll();
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 10,
pointerEvents: 'none',
overflow: 'hidden',
}}
>
{/* 1. Brand Intro */}
<OverlaySection progress={scrollYProgress} {...SECTION_CONFIGS[0]}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '1rem', marginBottom: '1rem' }}>
<div style={{ width: '30px', height: '1px', background: 'linear-gradient(90deg, transparent, #c4a265)' }} />
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: '#c4a265', letterSpacing: '0.4em', textTransform: 'uppercase' }}>
YS Lootah Robotics
</span>
<div style={{ width: '30px', height: '1px', background: 'linear-gradient(90deg, #c4a265, transparent)' }} />
</div>
<p style={{ fontSize: '0.9rem', color: '#64748b', fontWeight: 400, letterSpacing: '0.15em', margin: 0 }}>
Pioneering Humanoid Robotics in the UAE
</p>
</OverlaySection>
{/* 2. Hero */}
<OverlaySection progress={scrollYProgress} {...SECTION_CONFIGS[1]}>
<motion.h1 style={{ fontSize: 'clamp(2.5rem, 5vw, 4.5rem)', fontWeight: 200, color: '#1a1a2e', lineHeight: 1.0, letterSpacing: '-0.04em', margin: 0 }}>
The Future
</motion.h1>
<motion.h1 style={{ fontSize: 'clamp(2.5rem, 5vw, 4.5rem)', fontWeight: 200, color: '#1a1a2e', lineHeight: 1.0, letterSpacing: '-0.04em', margin: '0.1em 0 0' }}>
of <span style={{ color: '#c4a265', fontWeight: 400 }}>Robotics</span>
</motion.h1>
<p style={{ fontSize: '1rem', color: '#475569', lineHeight: 1.7, margin: '1.5rem 0 0', fontWeight: 300 }}>
Meet the G1 Humanoid Robot. Fully customizable, enterprise-ready, designed for the world of tomorrow.
</p>
</OverlaySection>
{/* 3. Head Reveal */}
<OverlaySection progress={scrollYProgress} {...SECTION_CONFIGS[2]}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
<div style={{ width: '50px', height: '2px', background: '#c4a265', marginBottom: '1.5rem' }} />
<div style={{ fontSize: '0.65rem', fontWeight: 600, color: '#c4a265', letterSpacing: '0.3em', textTransform: 'uppercase', marginBottom: '1rem' }}>
Intelligent by Design
</div>
<h2 style={{ fontSize: 'clamp(2rem, 3.5vw, 3rem)', fontWeight: 300, color: '#1a1a2e', lineHeight: 1.1, margin: '0 0 1rem', letterSpacing: '-0.03em' }}>
Vision That<br />Understands
</h2>
<p style={{ fontSize: '0.95rem', color: '#475569', lineHeight: 1.6, margin: 0, fontWeight: 300 }}>
Advanced computer vision and neural processing. The G1 sees, interprets, and responds to the world in real-time.
</p>
<div style={{ display: 'flex', gap: '2.5rem', marginTop: '2rem' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '1.8rem', fontWeight: 300, color: '#1a1a2e', fontFamily: 'monospace' }}>360°</div>
<div style={{ fontSize: '0.65rem', color: '#64748b', letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: '0.5rem' }}>Field of View</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '1.8rem', fontWeight: 300, color: '#1a1a2e', fontFamily: 'monospace' }}>{'<'}50ms</div>
<div style={{ fontSize: '0.65rem', color: '#64748b', letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: '0.5rem' }}>Response Time</div>
</div>
</div>
</div>
</OverlaySection>
{/* 4. Customization */}
<OverlaySection progress={scrollYProgress} {...SECTION_CONFIGS[3]}>
<div style={{ width: '50px', height: '2px', background: '#c4a265', marginBottom: '1.5rem' }} />
<div style={{ fontSize: '0.65rem', fontWeight: 600, color: '#c4a265', letterSpacing: '0.3em', textTransform: 'uppercase', marginBottom: '1rem' }}>
Your Identity
</div>
<h2 style={{ fontSize: 'clamp(2rem, 3.5vw, 3rem)', fontWeight: 300, color: '#1a1a2e', lineHeight: 1.1, margin: '0 0 1rem', letterSpacing: '-0.03em' }}>
Dress for Any<br />Mission
</h2>
<p style={{ fontSize: '0.95rem', color: '#475569', lineHeight: 1.6, margin: 0, fontWeight: 300 }}>
From traditional Emarati Kandura to industrial safety gear and professional business attire. Configure every detail to match your brand.
</p>
<div style={{ display: 'flex', gap: '1rem', marginTop: '2rem' }}>
{['Kandura', 'Vest', 'Suit'].map((label) => (
<div
key={label}
style={{
padding: '0.5rem 1rem',
borderRadius: '2rem',
background: 'rgba(196, 162, 101, 0.1)',
border: '1px solid rgba(196, 162, 101, 0.3)',
fontSize: '0.75rem',
color: '#c4a265',
letterSpacing: '0.1em',
fontWeight: 500
}}
>
{label}
</div>
))}
</div>
</OverlaySection>
{/* 5. Mobility */}
<OverlaySection progress={scrollYProgress} {...SECTION_CONFIGS[4]}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
<div style={{ width: '50px', height: '2px', background: '#c4a265', marginBottom: '1.5rem' }} />
<div style={{ fontSize: '0.65rem', fontWeight: 600, color: '#c4a265', letterSpacing: '0.3em', textTransform: 'uppercase', marginBottom: '1rem' }}>
Advanced Mobility
</div>
<h2 style={{ fontSize: 'clamp(2rem, 3.5vw, 3rem)', fontWeight: 300, color: '#1a1a2e', lineHeight: 1.1, margin: '0 0 1rem', letterSpacing: '-0.03em' }}>
23 Degrees of<br />Freedom
</h2>
<p style={{ fontSize: '0.95rem', color: '#475569', lineHeight: 1.6, margin: 0, fontWeight: 300 }}>
State-of-the-art locomotion enabling natural human-like movement, balance, and agility across any terrain.
</p>
<div style={{ display: 'flex', gap: '2.5rem', marginTop: '2rem' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '1.8rem', fontWeight: 300, color: '#1a1a2e', fontFamily: 'monospace' }}>2m/s</div>
<div style={{ fontSize: '0.65rem', color: '#64748b', letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: '0.5rem' }}>Max Speed</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '1.8rem', fontWeight: 300, color: '#1a1a2e', fontFamily: 'monospace' }}>127kg</div>
<div style={{ fontSize: '0.65rem', color: '#64748b', letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: '0.5rem' }}>Payload</div>
</div>
</div>
</div>
</OverlaySection>
{/* Scroll indicator mapped to vanish rapidly when scrolled */}
<motion.div
style={{
position: 'absolute',
bottom: '2.5rem',
left: '50%',
x: '-50%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
opacity: useTransform(scrollYProgress, [0, 0.05], [1, 0]),
}}
>
<span style={{ fontSize: '0.65rem', fontWeight: 500, color: '#64748b', letterSpacing: '0.3em', textTransform: 'uppercase', marginBottom: '1rem' }}>
Scroll to Explore
</span>
<div className="scroll-indicator" />
</motion.div>
</div>
);
}
+356
View File
@@ -0,0 +1,356 @@
'use client';
import React, { Suspense, useRef, useEffect } from 'react';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { Environment, ContactShadows, useGLTF, Html, useProgress } from '@react-three/drei';
import * as THREE from 'three';
// Original camera keyframes - cinematic path with dramatic shots
const CAMERA_KEYFRAMES: [number, [number, number, number]][] = [
[0.0, [-2.5, 0.6, 7]], // Start: far left wide shot
[0.12, [-1.8, 0.7, 5.5]], // Slow drift toward center
[0.25, [-0.5, 0.9, 4]], // Approach center gently
[0.40, [0.3, 1.5, 2.0]], // HEAD+CHEST: tight, high angle looking down
[0.55, [1.2, 2.2, 2.2]], // ABOVE: bird's eye with slight right offset
[0.68, [1.6, 0.7, 2.5]], // Dramatic right side sweep
[0.80, [0.3, 0.3, 2.2]], // Circle to front-low
[0.92, [0, -0.3, 2.5]], // LOW ANGLE: heroic looking up
[1.0, [0, 1.2, 5.5]], // Final: pull back for configurator
];
const LOOKAT_KEYFRAMES: [number, [number, number, number]][] = [
[0.0, [0, 0.4, 0]],
[0.12, [0, 0.45, 0]],
[0.25, [0, 0.55, 0]],
[0.40, [0, 1.2, 0]], // Look at head during tight shot
[0.55, [0, 0.85, 0]], // Look at chest from above
[0.68, [0, 0.65, 0]],
[0.80, [0, 0.5, 0]],
[0.92, [0, 0.6, 0]], // Look slightly up from low angle
[1.0, [0, 0.5, 0]],
];
function interpolateKeyframes(keyframes: [number, [number, number, number]][], progress: number): THREE.Vector3 {
const clamped = Math.max(0, Math.min(1, progress));
for (let i = 0; i < keyframes.length - 1; i++) {
const [startT, startPos] = keyframes[i];
const [endT, endPos] = keyframes[i + 1];
if (clamped >= startT && clamped <= endT) {
const localT = (clamped - startT) / (endT - startT);
const eased = localT * localT * (3 - 2 * localT);
return new THREE.Vector3(
startPos[0] + (endPos[0] - startPos[0]) * eased,
startPos[1] + (endPos[1] - startPos[1]) * eased,
startPos[2] + (endPos[2] - startPos[2]) * eased,
);
}
}
const last = keyframes[keyframes.length - 1][1];
return new THREE.Vector3(last[0], last[1], last[2]);
}
function lerpScalar(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
const scrollState = { progress: 0, inScrollZone: true };
function ScrollCamera() {
const { camera } = useThree();
const lookAtTarget = useRef(new THREE.Vector3(0, 0.5, 0));
useFrame(({ clock }) => {
if (!scrollState.inScrollZone) return;
const p = scrollState.progress;
const pos = interpolateKeyframes(CAMERA_KEYFRAMES, p);
const lookAt = interpolateKeyframes(LOOKAT_KEYFRAMES, p);
// Dynamic camera oscillation based on scroll position
const oscillationX = Math.sin(p * Math.PI * 4) * 0.05;
const oscillationY = Math.cos(p * Math.PI * 3) * 0.025;
// Add subtle drift during specific zones for cinematic effect
const driftZone = Math.sin(p * Math.PI * 2) * 0.03;
const verticalDrift = Math.sin(clock.elapsedTime * 0.3) * 0.02;
// Apply oscillations to position
const adjustedPos = new THREE.Vector3(
pos.x + oscillationX + driftZone,
pos.y + oscillationY + verticalDrift,
pos.z
);
// Adaptive lerp for smooth camera transitions
const lerpFactor = p < 0.15 ? 0.015 : (p > 0.9 ? 0.02 : 0.02);
camera.position.lerp(adjustedPos, lerpFactor);
lookAtTarget.current.lerp(lookAt, lerpFactor * 1.0);
camera.lookAt(lookAtTarget.current);
});
return null;
}
// Invisible orbiting point lights + halo ring (no visible sphere meshes)
function LightOrbs() {
const blueOrbRef = useRef<THREE.Group>(null);
const goldOrbRef = useRef<THREE.Group>(null);
const haloRef = useRef<THREE.Mesh>(null);
const bluePointRef = useRef<THREE.PointLight>(null);
const goldPointRef = useRef<THREE.PointLight>(null);
useFrame(({ clock }) => {
const p = scrollState.progress;
const time = clock.elapsedTime;
if (blueOrbRef.current) {
// Orbital path
const angle = p * Math.PI * 2.5 + time * 0.15;
const radius = 1.3 + Math.sin(p * Math.PI * 1.5) * 0.25;
blueOrbRef.current.position.set(
Math.cos(angle) * radius,
0.5 + Math.sin(p * Math.PI * 2 + time * 0.25) * 0.35,
Math.sin(angle) * radius,
);
}
if (goldOrbRef.current) {
// Counter-rotating orbit
const angle = -p * Math.PI * 2 + time * 0.1 + Math.PI;
const radius = 1.1 + Math.cos(p * Math.PI * 1.5) * 0.2;
goldOrbRef.current.position.set(
Math.cos(angle) * radius,
1.1 + Math.sin(p * Math.PI * 1.5 + time * 0.2) * 0.3,
Math.sin(angle) * radius,
);
}
if (bluePointRef.current) {
bluePointRef.current.intensity = 2.2 + Math.sin(p * Math.PI * 3 + time * 0.5) * 0.8;
}
if (goldPointRef.current) {
goldPointRef.current.intensity = 2.0 + Math.sin(p * Math.PI * 2.5 + time * 0.35) * 0.6;
}
if (haloRef.current) {
const haloZone = Math.max(0, 1 - Math.abs(p - 0.38) / 0.15);
(haloRef.current.material as THREE.MeshBasicMaterial).opacity = haloZone * 0.7;
haloRef.current.position.set(0, 1.55, 0);
haloRef.current.rotation.x = Math.PI / 2 + Math.sin(time * 0.3) * 0.08;
haloRef.current.rotation.z = p * Math.PI * 2 + time * 0.15;
}
});
return (
<>
{/* Orbiting blue point light (no visible mesh) */}
<group ref={blueOrbRef}>
<pointLight ref={bluePointRef} color="#4a7aff" intensity={2.0} distance={5} decay={2} />
</group>
{/* Orbiting gold point light (no visible mesh) */}
<group ref={goldOrbRef}>
<pointLight ref={goldPointRef} color="#c4a265" intensity={1.8} distance={4.5} decay={2} />
</group>
{/* Halo ring above head - visible during close-up */}
<mesh ref={haloRef} position={[0, 1.6, 0]} rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.25, 0.008, 16, 64]} />
<meshBasicMaterial color="#c4a265" transparent opacity={0} />
</mesh>
</>
);
}
function ScrollLighting() {
const rimLightRef = useRef<THREE.DirectionalLight>(null);
const goldSpotRef = useRef<THREE.SpotLight>(null);
const leftSideRef = useRef<THREE.DirectionalLight>(null);
const rightSideRef = useRef<THREE.DirectionalLight>(null);
const fillRef = useRef<THREE.DirectionalLight>(null);
useFrame(() => {
const p = scrollState.progress;
if (rimLightRef.current) {
rimLightRef.current.intensity = lerpScalar(3.0, 1.0, Math.min(1, p / 0.4));
rimLightRef.current.position.set(
Math.sin(p * Math.PI * 2) * 3,
3,
-5 + Math.sin(p * Math.PI) * 2,
);
}
if (goldSpotRef.current) {
const headZone = Math.max(0, 1 - Math.abs(p - 0.28) / 0.12);
goldSpotRef.current.intensity = lerpScalar(0, 5.0, headZone);
goldSpotRef.current.position.set(0.5, 2.5, 2);
}
if (leftSideRef.current) {
const orbitZone = Math.max(0, 1 - Math.abs(p - 0.45) / 0.15);
leftSideRef.current.intensity = lerpScalar(0.8, 2.8, orbitZone);
leftSideRef.current.position.set(-5 + p * 3, 3, 2);
}
if (rightSideRef.current) {
const orbitZone2 = Math.max(0, 1 - Math.abs(p - 0.58) / 0.15);
rightSideRef.current.intensity = lerpScalar(0.8, 2.8, orbitZone2);
rightSideRef.current.position.set(5 - p * 3, 2, 3);
}
if (fillRef.current) {
fillRef.current.intensity = lerpScalar(0.8, 2.0, Math.max(0, (p - 0.6) / 0.4));
}
});
return (
<>
<directionalLight ref={rimLightRef} position={[0, 3, -5]} intensity={3.0} color="#4a7aff" />
<spotLight ref={goldSpotRef} position={[0.5, 2.5, 2]} intensity={0} color="#c4a265" angle={0.5} penumbra={0.8} />
<directionalLight ref={leftSideRef} position={[-5, 3, 2]} intensity={0.8} color="#e8f0ff" />
<directionalLight ref={rightSideRef} position={[5, 2, 3]} intensity={0.8} color="#e0e8ff" />
<directionalLight ref={fillRef} position={[0, 2, 5]} intensity={0.8} color="#ffffff" />
</>
);
}
function RobotDisplay() {
const { scene } = useGLTF('/Unitree_G1.glb');
const processedScene = React.useMemo(() => {
const cloned = scene.clone();
cloned.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.8;
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]);
return <primitive object={processedScene} />;
}
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(196, 162, 101, 0.15)',
borderTopColor: '#c4a265',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}} />
<p style={{ fontSize: '0.8rem', color: '#64748b', fontFamily: 'Inter, system-ui, sans-serif' }}>
{progress.toFixed(0)}%
</p>
</div>
</Html>
);
}
function SceneContent() {
return (
<>
<ScrollCamera />
<Environment preset="city" />
<ambientLight intensity={0.8} />
<directionalLight position={[5, 5, 5]} intensity={1.8} color="#ffffff" castShadow shadow-mapSize={[1024, 1024]} />
<spotLight position={[0, 8, 0]} intensity={1.2} angle={0.6} penumbra={0.5} color="#ffffff" />
<ScrollLighting />
<LightOrbs />
<RobotDisplay />
<ContactShadows position={[0, -1, 0]} opacity={0.25} scale={10} blur={2} far={4} resolution={256} color="#000000" />
</>
);
}
interface ScrollSceneProps {
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
}
export function ScrollScene({ scrollContainerRef }: ScrollSceneProps) {
useEffect(() => {
const handleScroll = () => {
const scrollTop = window.scrollY;
const container = scrollContainerRef.current;
if (!container) return;
const spacerHeight = container.offsetHeight;
const viewportHeight = window.innerHeight;
const maxScroll = spacerHeight - viewportHeight;
if (maxScroll > 0) {
scrollState.progress = Math.max(0, Math.min(1, scrollTop / maxScroll));
scrollState.inScrollZone = scrollTop <= spacerHeight;
}
};
window.addEventListener('scroll', handleScroll, { passive: true });
handleScroll();
return () => window.removeEventListener('scroll', handleScroll);
}, [scrollContainerRef]);
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 0,
pointerEvents: 'none',
}}
>
<Canvas
dpr={[1, 2]}
camera={{ position: [0, 1, 7], fov: 50 }}
gl={{ antialias: true, powerPreference: 'high-performance' }}
style={{
background: 'linear-gradient(180deg, #e8e8e4 0%, #f0f0ec 50%, #e8e8e4 100%)',
pointerEvents: 'auto',
}}
>
<Suspense fallback={<Loader />}>
<SceneContent />
</Suspense>
</Canvas>
</div>
);
}
useGLTF.preload('/Unitree_G1.glb');
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { SnapshotButton } from '@/components/SnapshotButton';
// Mock Three.js objects
const mockGL = {
render: vi.fn(),
domElement: {
toDataURL: vi.fn(() => 'data:image/png;base64,mock-image-data'),
},
};
const mockScene = {};
const mockCamera = {};
describe('SnapshotButton', () => {
beforeEach(() => {
vi.clearAllMocks();
global.URL.createObjectURL = vi.fn(() => 'blob:mock-url');
global.URL.revokeObjectURL = vi.fn();
});
it('renders snapshot button', () => {
render(<SnapshotButton gl={mockGL as any} scene={mockScene as any} camera={mockCamera as any} />);
expect(screen.getByRole('button', { name: /capture 3d scene snapshot/i })).toBeInTheDocument();
});
it('renders with custom filename', () => {
render(<SnapshotButton gl={mockGL as any} scene={mockScene as any} camera={mockCamera as any} filename="custom-robot" />);
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('calls onSnapshot callback after capture', () => {
const onSnapshot = vi.fn();
render(<SnapshotButton gl={mockGL as any} scene={mockScene as any} camera={mockCamera as any} onSnapshot={onSnapshot} />);
const button = screen.getByRole('button');
fireEvent.click(button);
// Check that onSnapshot was called (after async operations)
setTimeout(() => {
expect(onSnapshot).toHaveBeenCalled();
}, 0);
});
});
+108
View File
@@ -0,0 +1,108 @@
'use client';
import { useCallback, useState } from 'react';
import { useThree } from '@react-three/fiber';
import type { WebGLRenderer, Scene, Camera } from 'three';
interface SnapshotButtonInnerProps {
gl: WebGLRenderer;
scene: Scene;
camera: Camera;
filename?: string;
onSnapshot?: (dataUrl: string) => void;
}
function SnapshotButtonInner({ gl, scene, camera, filename = 'g1-robot', onSnapshot }: SnapshotButtonInnerProps) {
const [isCapturing, setIsCapturing] = useState(false);
const handleCapture = useCallback(() => {
setIsCapturing(true);
try {
gl.render(scene, camera);
const dataUrl = gl.domElement.toDataURL('image/png');
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const fullFilename = `${filename}-${timestamp}.png`;
const link = document.createElement('a');
link.download = fullFilename;
link.href = dataUrl;
link.click();
onSnapshot?.(dataUrl);
} catch (error) {
console.error('Failed to capture snapshot:', error);
} finally {
setIsCapturing(false);
}
}, [gl, scene, camera, filename, onSnapshot]);
return (
<button
onClick={handleCapture}
disabled={isCapturing}
style={{
padding: '0.5rem 1rem',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.3)',
borderRadius: '0.5rem',
color: '#f8fafc',
cursor: isCapturing ? 'wait' : 'pointer',
fontSize: '0.875rem',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isCapturing) {
e.currentTarget.style.backgroundColor = 'rgba(59, 130, 246, 0.2)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'rgba(59, 130, 246, 0.1)';
}}
aria-label="Capture 3D scene snapshot"
>
<svg
width="16"
height="16"
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>
);
}
// Re-export the inner component for tests
export { SnapshotButtonInner as SnapshotButton };
/**
* Captures the R3F context then renders a fixed-position overlay button
* outside the 3D scene so it stays in place when the camera moves.
*/
export function SnapshotButtonWrapper({ filename = 'g1-robot', onSnapshot }: { filename?: string; onSnapshot?: (dataUrl: string) => void }) {
const { gl, scene, camera } = useThree();
return (
<SnapshotButtonInner
gl={gl}
scene={scene}
camera={camera}
filename={filename}
onSnapshot={onSnapshot}
/>
);
}
+65
View File
@@ -0,0 +1,65 @@
import * as THREE from 'three';
// Socket position names
export type SocketName = 'head' | 'chest' | 'left_shoulder' | 'right_shoulder' | 'back' | 'base';
// Socket position definitions relative to the robot model
export interface SocketTransform {
position: THREE.Vector3;
rotation: THREE.Euler;
}
// Calibrated from actual model debug data:
// Robot bounding box: y=-0.5 to y=1.5, x=-0.37 to x=0.37, z=-0.17 to z=0.17
// Torso: center y=0.984, width=0.315, depth=0.227
// Hands: center y=0.759, full span width=0.739
// Legs: center y=0.049, width=0.500
export const SOCKET_POSITIONS: Record<SocketName, SocketTransform> = {
head: {
position: new THREE.Vector3(0, 1.5, 0),
rotation: new THREE.Euler(0, 0, 0),
},
chest: {
position: new THREE.Vector3(0, 0.98, 0.12),
rotation: new THREE.Euler(0, 0, 0),
},
left_shoulder: {
position: new THREE.Vector3(-0.22, 1.2, 0),
rotation: new THREE.Euler(0, 0, 0),
},
right_shoulder: {
position: new THREE.Vector3(0.22, 1.2, 0),
rotation: new THREE.Euler(0, 0, 0),
},
back: {
position: new THREE.Vector3(0, 0.98, -0.15),
rotation: new THREE.Euler(0, Math.PI, 0),
},
base: {
position: new THREE.Vector3(0, 0.55, 0),
rotation: new THREE.Euler(0, 0, 0),
},
};
// Human-readable labels for each socket
export const SOCKET_LABELS: Record<SocketName, string> = {
head: 'Head',
chest: 'Chest',
left_shoulder: 'Left Shoulder',
right_shoulder: 'Right Shoulder',
back: 'Back',
base: 'Base',
};
// Get socket transform by name
export const getSocketTransform = (socketName: SocketName): SocketTransform => {
return SOCKET_POSITIONS[socketName] || SOCKET_POSITIONS.base;
};
// Check if a position string is a valid socket name
export const isValidSocket = (position: string): position is SocketName => {
return position in SOCKET_POSITIONS;
};
// All socket names as array
export const ALL_SOCKETS: SocketName[] = ['head', 'chest', 'left_shoulder', 'right_shoulder', 'back', 'base'];
@@ -0,0 +1,108 @@
'use client';
import { useCallback } from 'react';
import { useOrderStore, orderStore } from '@/store/useOrderStore';
import { configStore } from '@/store/useConfigStore';
function formatAED(price: number): string {
return new Intl.NumberFormat('en-AE').format(price);
}
export function ConfirmationStep() {
const orderId = useOrderStore((s) => s.orderId);
const orderTotal = useOrderStore((s) => s.orderTotal);
const personaSummary = useOrderStore((s) => s.personaSummary);
const shipping = useOrderStore((s) => s.shipping);
const handleNewOrder = useCallback(() => {
orderStore.getState().resetOrder();
configStore.getState().reset();
configStore.getState().setHydrated(true);
}, []);
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1.25rem', textAlign: 'center', padding: '1rem 0' }}>
{/* Success checkmark */}
<div style={{
width: '64px',
height: '64px',
borderRadius: '50%',
background: 'rgba(34, 197, 94, 0.08)',
border: '2px solid rgba(34, 197, 94, 0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<div>
<h3 style={{ fontSize: '1.25rem', fontWeight: 700, color: '#1a1a2e', margin: 0, marginBottom: '0.5rem' }}>
Order Confirmed!
</h3>
<p style={{ fontSize: '0.8rem', color: '#94a3b8', margin: 0 }}>
Thank you for your order. Your G1 Robot is being prepared.
</p>
</div>
{/* Order ID */}
<div style={{
padding: '0.75rem 1.5rem',
borderRadius: '0.5rem',
background: 'rgba(59, 130, 246, 0.04)',
border: '1px solid rgba(59, 130, 246, 0.2)',
}}>
<div style={{ fontSize: '0.65rem', color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.25rem' }}>
Order ID
</div>
<div style={{ fontSize: '1.1rem', fontWeight: 700, color: '#2563eb', fontFamily: 'monospace', letterSpacing: '0.05em' }}>
{orderId}
</div>
</div>
{/* Order details */}
<div style={{
width: '100%',
padding: '0.75rem',
borderRadius: '0.5rem',
background: 'rgba(248, 248, 246, 0.4)',
border: '1px solid rgba(0, 0, 0, 0.04)',
textAlign: 'left',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.75rem', color: '#94a3b8' }}>Configuration</span>
<span style={{ fontSize: '0.75rem', color: '#374151' }}>{personaSummary}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.75rem', color: '#94a3b8' }}>Ship to</span>
<span style={{ fontSize: '0.75rem', color: '#374151' }}>{shipping.name}, {shipping.city}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', paddingTop: '0.5rem', borderTop: '1px solid rgba(0, 0, 0, 0.06)' }}>
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: '#374151' }}>Total Paid</span>
<span style={{ fontSize: '0.85rem', fontWeight: 700, color: '#1a1a2e', fontFamily: 'monospace' }}>AED {formatAED(orderTotal)}</span>
</div>
</div>
{/* Actions */}
<button
onClick={handleNewOrder}
style={{
marginTop: '0.5rem',
padding: '0.75rem 1.5rem',
borderRadius: '0.375rem',
border: '1px solid rgba(59, 130, 246, 0.5)',
background: 'rgba(59, 130, 246, 0.08)',
color: '#2563eb',
cursor: 'pointer',
fontSize: '0.85rem',
fontWeight: 600,
transition: 'all 0.2s ease',
}}
>
Configure Another Robot
</button>
</div>
);
}
+182
View File
@@ -0,0 +1,182 @@
'use client';
import { useState, useCallback } from 'react';
import { orderStore, type PaymentInfo } from '@/store/useOrderStore';
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '0.6rem 0.75rem',
borderRadius: '0.375rem',
border: '1px solid rgba(0, 0, 0, 0.08)',
background: 'rgba(255, 255, 255, 0.8)',
color: '#1a1a2e',
fontSize: '0.8rem',
outline: 'none',
transition: 'border-color 0.2s ease',
};
const labelStyle: React.CSSProperties = {
fontSize: '0.7rem',
fontWeight: 500,
color: '#94a3b8',
marginBottom: '0.3rem',
display: 'block',
};
const errorStyle: React.CSSProperties = {
fontSize: '0.65rem',
color: '#ef4444',
marginTop: '0.2rem',
};
interface FormErrors {
[key: string]: string;
}
function formatCardNumber(value: string): string {
const digits = value.replace(/\D/g, '').slice(0, 16);
return digits.replace(/(\d{4})(?=\d)/g, '$1 ');
}
function formatExpiry(value: string): string {
const digits = value.replace(/\D/g, '').slice(0, 4);
if (digits.length > 2) {
return `${digits.slice(0, 2)}/${digits.slice(2)}`;
}
return digits;
}
export function PaymentStep() {
const [form, setForm] = useState<PaymentInfo>({
cardNumber: '',
expiry: '',
cvv: '',
nameOnCard: '',
});
const [errors, setErrors] = useState<FormErrors>({});
const handleChange = useCallback((field: keyof PaymentInfo, value: string) => {
let processed = value;
if (field === 'cardNumber') processed = formatCardNumber(value);
if (field === 'expiry') processed = formatExpiry(value);
if (field === 'cvv') processed = value.replace(/\D/g, '').slice(0, 3);
setForm((prev) => ({ ...prev, [field]: processed }));
setErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
}, []);
const validate = (): boolean => {
const errs: FormErrors = {};
const digits = form.cardNumber.replace(/\s/g, '');
if (digits.length < 16) errs.cardNumber = 'Enter a valid 16-digit card number';
if (form.expiry.length < 5) errs.expiry = 'Enter a valid expiry (MM/YY)';
if (form.cvv.length < 3) errs.cvv = 'Enter a valid 3-digit CVV';
if (!form.nameOnCard.trim()) errs.nameOnCard = 'Name on card is required';
setErrors(errs);
return Object.keys(errs).length === 0;
};
const handleSubmit = () => {
if (!validate()) return;
orderStore.getState().setPayment(form);
orderStore.getState().setStep('review');
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 600, color: '#1a1a2e', margin: 0 }}>
Payment Details
</h3>
<div style={{
padding: '0.6rem 0.75rem',
borderRadius: '0.375rem',
background: 'rgba(245, 158, 11, 0.06)',
border: '1px solid rgba(245, 158, 11, 0.15)',
fontSize: '0.7rem',
color: '#d97706',
}}>
This is a demo checkout. No real payment will be processed.
</div>
<div>
<label style={labelStyle}>Card Number</label>
<input
type="text"
value={form.cardNumber}
onChange={(e) => handleChange('cardNumber', e.target.value)}
placeholder="4242 4242 4242 4242"
style={{ ...inputStyle, fontFamily: 'monospace', letterSpacing: '0.1em', borderColor: errors.cardNumber ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = errors.cardNumber ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
/>
{errors.cardNumber && <div style={errorStyle}>{errors.cardNumber}</div>}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<div>
<label style={labelStyle}>Expiry Date</label>
<input
type="text"
value={form.expiry}
onChange={(e) => handleChange('expiry', e.target.value)}
placeholder="MM/YY"
style={{ ...inputStyle, fontFamily: 'monospace', borderColor: errors.expiry ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = errors.expiry ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
/>
{errors.expiry && <div style={errorStyle}>{errors.expiry}</div>}
</div>
<div>
<label style={labelStyle}>CVV</label>
<input
type="text"
value={form.cvv}
onChange={(e) => handleChange('cvv', e.target.value)}
placeholder="123"
style={{ ...inputStyle, fontFamily: 'monospace', borderColor: errors.cvv ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = errors.cvv ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
/>
{errors.cvv && <div style={errorStyle}>{errors.cvv}</div>}
</div>
</div>
<div>
<label style={labelStyle}>Name on Card</label>
<input
type="text"
value={form.nameOnCard}
onChange={(e) => handleChange('nameOnCard', e.target.value)}
placeholder="John Doe"
style={{ ...inputStyle, borderColor: errors.nameOnCard ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = errors.nameOnCard ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
/>
{errors.nameOnCard && <div style={errorStyle}>{errors.nameOnCard}</div>}
</div>
<button
onClick={handleSubmit}
style={{
marginTop: '0.5rem',
padding: '0.75rem',
borderRadius: '0.375rem',
border: '1px solid rgba(59, 130, 246, 0.5)',
background: 'rgba(59, 130, 246, 0.08)',
color: '#2563eb',
cursor: 'pointer',
fontSize: '0.85rem',
fontWeight: 600,
transition: 'all 0.2s ease',
}}
>
Review Order
</button>
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
'use client';
import { useState } from 'react';
import { useOrderStore, orderStore } from '@/store/useOrderStore';
function formatAED(price: number): string {
return new Intl.NumberFormat('en-AE').format(price);
}
export function ReviewStep() {
const shipping = useOrderStore((s) => s.shipping);
const orderTotal = useOrderStore((s) => s.orderTotal);
const personaSummary = useOrderStore((s) => s.personaSummary);
const colorSummary = useOrderStore((s) => s.colorSummary);
const [isProcessing, setIsProcessing] = useState(false);
const handlePlaceOrder = async () => {
setIsProcessing(true);
// Simulate payment processing delay
await new Promise((resolve) => setTimeout(resolve, 1500));
orderStore.getState().placeOrder();
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 600, color: '#1a1a2e', margin: 0 }}>
Review Your Order
</h3>
{/* Configuration Summary */}
<SummarySection title="Configuration">
<SummaryRow label="Persona" value={personaSummary} />
<SummaryRow label="Color" value={
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{
width: '14px',
height: '14px',
borderRadius: '3px',
backgroundColor: colorSummary,
border: '1px solid rgba(255,255,255,0.15)',
}} />
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem' }}>{colorSummary}</span>
</div>
} />
</SummarySection>
{/* Shipping Summary */}
<SummarySection title="Shipping To">
<div style={{ fontSize: '0.8rem', color: '#374151', lineHeight: 1.6 }}>
<div style={{ fontWeight: 500 }}>{shipping.name}</div>
<div>{shipping.address}</div>
<div>{shipping.city}, {shipping.country} {shipping.postalCode}</div>
<div style={{ color: '#94a3b8', marginTop: '0.25rem' }}>{shipping.email}</div>
<div style={{ color: '#94a3b8' }}>{shipping.phone}</div>
</div>
</SummarySection>
{/* Price Summary */}
<SummarySection title="Order Total">
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}>
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: '#374151' }}>Total</span>
<span style={{ fontSize: '1.1rem', fontWeight: 700, color: '#1a1a2e', fontFamily: 'monospace' }}>
AED {formatAED(orderTotal)}
</span>
</div>
</SummarySection>
{/* Place Order Button */}
<button
onClick={handlePlaceOrder}
disabled={isProcessing}
style={{
marginTop: '0.25rem',
padding: '0.85rem',
borderRadius: '0.375rem',
border: 'none',
background: isProcessing
? 'rgba(59, 130, 246, 0.2)'
: 'linear-gradient(135deg, #3b82f6, #2563eb)',
color: '#ffffff',
cursor: isProcessing ? 'wait' : 'pointer',
fontSize: '0.9rem',
fontWeight: 700,
transition: 'all 0.2s ease',
textAlign: 'center',
boxShadow: isProcessing ? 'none' : '0 4px 20px rgba(59, 130, 246, 0.2)',
}}
>
{isProcessing ? 'Processing Payment...' : 'Place Order'}
</button>
</div>
);
}
function SummarySection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div style={{
padding: '0.75rem',
borderRadius: '0.5rem',
background: 'rgba(248, 248, 246, 0.4)',
border: '1px solid rgba(0, 0, 0, 0.04)',
}}>
<div style={{
fontSize: '0.65rem',
fontWeight: 600,
color: '#64748b',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: '0.5rem',
}}>
{title}
</div>
{children}
</div>
);
}
function SummaryRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.25rem 0',
}}>
<span style={{ fontSize: '0.8rem', color: '#94a3b8' }}>{label}</span>
<span style={{ fontSize: '0.8rem', color: '#374151' }}>{value}</span>
</div>
);
}
+150
View File
@@ -0,0 +1,150 @@
'use client';
import { useState, useCallback } from 'react';
import { orderStore, type ShippingInfo } from '@/store/useOrderStore';
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '0.6rem 0.75rem',
borderRadius: '0.375rem',
border: '1px solid rgba(0, 0, 0, 0.08)',
background: 'rgba(255, 255, 255, 0.8)',
color: '#1a1a2e',
fontSize: '0.8rem',
outline: 'none',
transition: 'border-color 0.2s ease',
};
const labelStyle: React.CSSProperties = {
fontSize: '0.7rem',
fontWeight: 500,
color: '#94a3b8',
marginBottom: '0.3rem',
display: 'block',
};
const errorStyle: React.CSSProperties = {
fontSize: '0.65rem',
color: '#ef4444',
marginTop: '0.2rem',
};
interface FormErrors {
[key: string]: string;
}
function validateEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
export function ShippingStep() {
const [form, setForm] = useState<ShippingInfo>({
name: '',
email: '',
phone: '',
address: '',
city: '',
country: '',
postalCode: '',
});
const [errors, setErrors] = useState<FormErrors>({});
const handleChange = useCallback((field: keyof ShippingInfo, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
setErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
}, []);
const validate = (): boolean => {
const errs: FormErrors = {};
if (!form.name.trim()) errs.name = 'Name is required';
if (!form.email.trim()) errs.email = 'Email is required';
else if (!validateEmail(form.email)) errs.email = 'Invalid email format';
if (!form.phone.trim()) errs.phone = 'Phone is required';
if (!form.address.trim()) errs.address = 'Address is required';
if (!form.city.trim()) errs.city = 'City is required';
if (!form.country.trim()) errs.country = 'Country is required';
if (!form.postalCode.trim()) errs.postalCode = 'Postal code is required';
setErrors(errs);
return Object.keys(errs).length === 0;
};
const handleSubmit = () => {
if (!validate()) return;
orderStore.getState().setShipping(form);
orderStore.getState().setStep('payment');
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 600, color: '#1a1a2e', margin: 0 }}>
Shipping Information
</h3>
<Field label="Full Name" value={form.name} error={errors.name} onChange={(v) => handleChange('name', v)} placeholder="John Doe" />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<Field label="Email" value={form.email} error={errors.email} onChange={(v) => handleChange('email', v)} placeholder="john@example.com" type="email" />
<Field label="Phone" value={form.phone} error={errors.phone} onChange={(v) => handleChange('phone', v)} placeholder="+971 50 123 4567" type="tel" />
</div>
<Field label="Address" value={form.address} error={errors.address} onChange={(v) => handleChange('address', v)} placeholder="123 Sheikh Zayed Road" />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<Field label="City" value={form.city} error={errors.city} onChange={(v) => handleChange('city', v)} placeholder="Dubai" />
<Field label="Country" value={form.country} error={errors.country} onChange={(v) => handleChange('country', v)} placeholder="UAE" />
</div>
<Field label="Postal Code" value={form.postalCode} error={errors.postalCode} onChange={(v) => handleChange('postalCode', v)} placeholder="00000" />
<button
onClick={handleSubmit}
style={{
marginTop: '0.5rem',
padding: '0.75rem',
borderRadius: '0.375rem',
border: '1px solid rgba(59, 130, 246, 0.5)',
background: 'rgba(59, 130, 246, 0.08)',
color: '#2563eb',
cursor: 'pointer',
fontSize: '0.85rem',
fontWeight: 600,
transition: 'all 0.2s ease',
}}
>
Continue to Payment
</button>
</div>
);
}
function Field({ label, value, error, onChange, placeholder, type = 'text' }: {
label: string;
value: string;
error?: string;
onChange: (v: string) => void;
placeholder?: string;
type?: string;
}) {
return (
<div>
<label style={labelStyle}>{label}</label>
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
style={{
...inputStyle,
borderColor: error ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)',
}}
onFocus={(e) => { e.currentTarget.style.borderColor = error ? 'rgba(239, 68, 68, 0.7)' : 'rgba(59, 130, 246, 0.5)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = error ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
/>
{error && <div style={errorStyle}>{error}</div>}
</div>
);
}