first
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user