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

This commit is contained in:
Yjoz
2026-04-10 15:31:59 +04:00
parent 87d2688eeb
commit ec0f991a30
55 changed files with 10218 additions and 0 deletions
+214
View File
@@ -0,0 +1,214 @@
'use client';
import { useEffect, useState } from 'react';
import { pricingStore, usePricingStore } from '@/store/usePricingStore';
import Link from 'next/link';
export default function AdminPage() {
const items = usePricingStore((s) => s.items);
const isHydrated = usePricingStore((s) => s.isHydrated);
const [editedPrices, setEditedPrices] = useState<Record<string, number>>({});
const [saved, setSaved] = useState(false);
useEffect(() => {
pricingStore.getState().hydrate();
}, []);
useEffect(() => {
const map: Record<string, number> = {};
items.forEach((item) => {
map[item.id] = item.price;
});
setEditedPrices(map);
}, [items]);
const handlePriceChange = (id: string, value: string) => {
const num = parseInt(value.replace(/[^0-9]/g, ''), 10);
if (!isNaN(num)) {
setEditedPrices((prev) => ({ ...prev, [id]: num }));
} else if (value === '') {
setEditedPrices((prev) => ({ ...prev, [id]: 0 }));
}
};
const handleSave = () => {
Object.entries(editedPrices).forEach(([id, price]) => {
pricingStore.getState().updatePrice(id, price);
});
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
const handleReset = () => {
pricingStore.getState().resetPrices();
setSaved(false);
};
const formatPrice = (price: number) => {
return new Intl.NumberFormat('en-AE', { style: 'decimal' }).format(price);
};
if (!isHydrated) {
return (
<div style={pageStyle}>
<p style={{ color: '#64748b' }}>Loading...</p>
</div>
);
}
return (
<div style={pageStyle}>
<div style={containerStyle}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '2rem' }}>
<div>
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, color: '#1a1a2e', margin: 0, marginBottom: '0.25rem' }}>
Pricing Dashboard
</h1>
<p style={{ fontSize: '0.8rem', color: '#94a3b8', margin: 0 }}>
Edit prices for the G1 Robot Configurator
</p>
</div>
<Link
href="/"
style={{
padding: '0.5rem 1rem',
borderRadius: '0.375rem',
border: '1px solid rgba(59, 130, 246, 0.2)',
background: 'rgba(59, 130, 246, 0.06)',
color: '#2563eb',
fontSize: '0.8rem',
textDecoration: 'none',
transition: 'all 0.2s ease',
}}
>
Back to Configurator
</Link>
</div>
{/* Pricing Table */}
<div style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(20px)',
border: '1px solid rgba(0, 0, 0, 0.06)',
borderRadius: '0.75rem',
overflow: 'hidden',
}}>
{/* Table Header */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 200px',
padding: '0.75rem 1.25rem',
borderBottom: '1px solid rgba(0, 0, 0, 0.04)',
background: 'rgba(248, 248, 246, 0.5)',
}}>
<span style={{ fontSize: '0.7rem', fontWeight: 600, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Item
</span>
<span style={{ fontSize: '0.7rem', fontWeight: 600, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em', textAlign: 'right' }}>
Price (AED)
</span>
</div>
{/* Rows */}
{items.map((item, index) => (
<div
key={item.id}
style={{
display: 'grid',
gridTemplateColumns: '1fr 200px',
padding: '1rem 1.25rem',
alignItems: 'center',
borderBottom: index < items.length - 1 ? '1px solid rgba(0, 0, 0, 0.04)' : 'none',
transition: 'background 0.15s ease',
}}
>
<div>
<div style={{ fontSize: '0.875rem', color: '#374151', fontWeight: 500 }}>
{item.label}
</div>
<div style={{ fontSize: '0.7rem', color: '#94a3b8', fontFamily: 'monospace' }}>
{item.id}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '0.5rem' }}>
<span style={{ fontSize: '0.75rem', color: '#94a3b8' }}>AED</span>
<input
type="text"
value={formatPrice(editedPrices[item.id] ?? item.price)}
onChange={(e) => handlePriceChange(item.id, e.target.value)}
style={{
width: '130px',
padding: '0.5rem 0.75rem',
borderRadius: '0.375rem',
border: '1px solid rgba(0, 0, 0, 0.1)',
background: 'rgba(255, 255, 255, 1)',
color: '#1a1a2e',
fontSize: '0.875rem',
fontFamily: 'monospace',
textAlign: 'right',
outline: 'none',
transition: 'border-color 0.2s ease',
}}
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = 'rgba(0, 0, 0, 0.1)'; }}
aria-label={`Price for ${item.label}`}
/>
</div>
</div>
))}
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1.5rem', justifyContent: 'flex-end' }}>
<button
onClick={handleReset}
style={{
padding: '0.6rem 1.25rem',
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 to Defaults
</button>
<button
onClick={handleSave}
style={{
padding: '0.6rem 1.5rem',
borderRadius: '0.375rem',
border: '1px solid rgba(59, 130, 246, 0.3)',
background: saved ? 'rgba(34, 197, 94, 0.08)' : 'rgba(59, 130, 246, 0.08)',
color: saved ? '#16a34a' : '#2563eb',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
transition: 'all 0.2s ease',
}}
>
{saved ? 'Saved!' : 'Save Prices'}
</button>
</div>
</div>
</div>
);
}
const pageStyle: React.CSSProperties = {
minHeight: '100vh',
background: '#ffffff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem',
fontFamily: 'system-ui, -apple-system, sans-serif',
};
const containerStyle: React.CSSProperties = {
width: '100%',
maxWidth: '640px',
};
+228
View File
@@ -0,0 +1,228 @@
@import "tailwindcss";
@theme {
/* Light Mode Color Palette */
--color-primary: #ffffff;
--color-secondary: #f8f8f6;
--color-accent: #3b82f6;
--color-accent-hover: #2563eb;
--color-gold: #c4a265;
--color-text-primary: #1a1a2e;
--color-text-secondary: #64748b;
--color-text-muted: #94a3b8;
--color-border: #e2e8f0;
--color-border-light: #cbd5e1;
/* Glassmorphism Colors - Light */
--color-glass-bg: rgba(255, 255, 255, 0.85);
--color-glass-border: rgba(0, 0, 0, 0.08);
--color-glass-highlight: rgba(255, 255, 255, 0.5);
/* Spacing & Sizing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
/* Border Radius */
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
/* Shadows */
--shadow-glow: 0 2px 20px rgba(0, 0, 0, 0.06);
--shadow-glow-lg: 0 4px 40px rgba(0, 0, 0, 0.08);
/* Transitions */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
--transition-slow: 300ms ease;
}
/* Base Styles */
html,
body {
margin: 0;
padding: 0;
min-height: 100vh;
background-color: var(--color-primary);
color: var(--color-text-primary);
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
overflow-x: hidden;
}
/* Scroll Snap */
html {
scroll-behavior: smooth;
scroll-snap-type: y mandatory;
}
/* Focus Styles for Accessibility */
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
/* Selection Styles */
::selection {
background-color: var(--color-accent);
color: #ffffff;
}
/* Glassmorphism Utilities - Light */
.glass-panel {
background: var(--color-glass-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--color-glass-border);
box-shadow: var(--shadow-glow);
}
.glass-panel-highlight {
background: linear-gradient(
135deg,
var(--color-glass-highlight) 0%,
transparent 50%
);
}
/* Hero animated gradient - Light */
.hero-gradient {
position: absolute;
inset: 0;
background: radial-gradient(ellipse 80% 60% at 50% 40%, rgba(59, 130, 246, 0.04) 0%, transparent 60%),
radial-gradient(ellipse 60% 50% at 30% 70%, rgba(196, 162, 101, 0.03) 0%, transparent 50%),
linear-gradient(180deg, #f0f0ec 0%, #e8e8e4 50%, #f0f0ec 100%);
animation: heroShift 12s ease-in-out infinite alternate;
}
@keyframes heroShift {
0% {
background-position: 0% 0%, 0% 0%, 0% 0%;
opacity: 1;
}
50% {
opacity: 0.9;
}
100% {
background-position: 100% 100%, 100% 0%, 0% 0%;
opacity: 1;
}
}
/* Fade in up animation */
.fade-in-up {
animation: fadeInUp 0.8s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Scroll indicator - dark for light mode */
.scroll-indicator {
width: 1px;
height: 40px;
position: relative;
overflow: hidden;
}
.scroll-indicator::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 1px;
height: 100%;
background: linear-gradient(180deg, #c4a265, transparent);
animation: scrollPulse 2s ease-in-out infinite;
}
@keyframes scrollPulse {
0%, 100% {
transform: translateY(-100%);
opacity: 0;
}
50% {
transform: translateY(0);
opacity: 1;
}
}
/* Spin animation for loaders */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Scrollbar Styling - Light */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--color-border-light);
border-radius: var(--radius-sm);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
}
/* Snap children */
.snap-section {
scroll-snap-align: start;
height: 100vh;
}
/* Responsive Layout Styles */
@media (max-width: 1024px) {
.glass-panel-responsive {
width: 360px !important;
}
}
@media (max-width: 768px) {
.layout-container {
flex-direction: column !important;
}
.glass-panel-responsive {
position: fixed !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
width: 100% !important;
height: 50vh !important;
border-left: none !important;
border-right: none !important;
border-top: 1px solid var(--color-border) !important;
box-shadow: 0 -4px 40px rgba(0, 0, 0, 0.08) !important;
border-radius: 1rem 1rem 0 0 !important;
z-index: 50;
}
.mobile-handle {
display: flex !important;
}
}
.mobile-handle {
display: none;
}
+36
View File
@@ -0,0 +1,36 @@
import type { Metadata, Viewport } from "next";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { I18nProvider } from "@/components/I18nProvider";
import "./globals.css";
export const metadata: Metadata = {
title: "G1 Configurator | Lootah Robotics",
description: "3D Configurator for the G1 Robot by Lootah Robotics",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
themeColor: "#ffffff",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" dir="ltr">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body suppressHydrationWarning>
<ErrorBoundary>
<I18nProvider>{children}</I18nProvider>
</ErrorBoundary>
</body>
</html>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { useRef } from "react";
import { ClientOnly } from "@/components/ClientOnly";
import { ScrollScene } from "@/components/ScrollScene";
import { ScrollOverlays } from "@/components/ScrollOverlays";
import { ConfiguratorSection } from "@/components/ConfiguratorSection";
export default function HomePage() {
const scrollContainerRef = useRef<HTMLDivElement>(null);
return (
<>
{/* Fixed 3D scene behind everything */}
<ClientOnly>
<ScrollScene scrollContainerRef={scrollContainerRef} />
</ClientOnly>
{/* Text overlays that fade based on scroll */}
<ScrollOverlays />
{/* Scroll spacer with snap sections -- 7 sections for the landing */}
<div ref={scrollContainerRef} style={{ position: "relative", zIndex: 1, pointerEvents: "none" }}>
<div className="snap-section" />
<div className="snap-section" />
<div className="snap-section" />
<div className="snap-section" />
<div className="snap-section" />
<div className="snap-section" />
<div className="snap-section" />
</div>
{/* Configurator section */}
<div style={{ position: "relative", zIndex: 20 }}>
<ConfiguratorSection />
</div>
</>
);
}
+163
View File
@@ -0,0 +1,163 @@
'use client';
import { useCallback, useEffect } from 'react';
import { useOrderStore, orderStore, type CheckoutStep } from '@/store/useOrderStore';
import { ShippingStep } from './checkout/ShippingStep';
import { PaymentStep } from './checkout/PaymentStep';
import { ReviewStep } from './checkout/ReviewStep';
import { ConfirmationStep } from './checkout/ConfirmationStep';
const STEPS: { id: CheckoutStep; label: string }[] = [
{ id: 'shipping', label: 'Shipping' },
{ id: 'payment', label: 'Payment' },
{ id: 'review', label: 'Review' },
];
function getStepIndex(step: CheckoutStep): number {
return STEPS.findIndex((s) => s.id === step);
}
export function CheckoutOverlay() {
const step = useOrderStore((s) => s.step);
const handleClose = useCallback(() => {
orderStore.getState().resetOrder();
}, []);
const handleBack = useCallback(() => {
const currentIndex = getStepIndex(step);
if (currentIndex > 0) {
orderStore.getState().setStep(STEPS[currentIndex - 1].id);
} else {
orderStore.getState().setStep('config');
}
}, [step]);
// Close on Escape key
useEffect(() => {
if (step === 'config') return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [step, handleClose]);
if (step === 'config') return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label="Checkout"
style={{
position: 'fixed',
inset: 0,
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(255, 255, 255, 0.92)',
backdropFilter: 'blur(12px)',
}}
>
<div style={{
width: '100%',
maxWidth: '560px',
maxHeight: '90vh',
overflowY: 'auto',
margin: '1rem',
background: 'rgba(255, 255, 255, 0.95)',
border: '1px solid rgba(59, 130, 246, 0.08)',
borderRadius: '1rem',
boxShadow: '0 24px 80px rgba(0, 0, 0, 0.5), 0 0 40px rgba(59, 130, 246, 0.1)',
}}>
{/* Header with progress */}
{step !== 'confirmed' && (
<div style={{ padding: '1.25rem 1.5rem', borderBottom: '1px solid rgba(59, 130, 246, 0.1)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<button
onClick={handleBack}
style={{
padding: '0.35rem 0.75rem',
borderRadius: '0.375rem',
border: '1px solid rgba(0, 0, 0, 0.08)',
background: 'transparent',
color: '#94a3b8',
cursor: 'pointer',
fontSize: '0.75rem',
transition: 'all 0.2s ease',
}}
>
Back
</button>
<h2 style={{ fontSize: '0.9rem', fontWeight: 600, color: '#1a1a2e', margin: 0 }}>
Checkout
</h2>
<button
onClick={handleClose}
style={{
width: '28px',
height: '28px',
borderRadius: '50%',
border: '1px solid rgba(0, 0, 0, 0.08)',
background: 'transparent',
color: '#94a3b8',
cursor: 'pointer',
fontSize: '0.9rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
aria-label="Close checkout"
>
x
</button>
</div>
{/* Progress bar */}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{STEPS.map((s, i) => {
const currentIdx = getStepIndex(step);
const isActive = i === currentIdx;
const isComplete = i < currentIdx;
return (
<div key={s.id} style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<div style={{
height: '3px',
borderRadius: '2px',
background: isComplete
? '#3b82f6'
: isActive
? 'linear-gradient(90deg, #3b82f6, rgba(59, 130, 246, 0.2))'
: 'rgba(0, 0, 0, 0.06)',
transition: 'all 0.3s ease',
}} />
<span style={{
fontSize: '0.65rem',
color: isActive ? '#2563eb' : isComplete ? '#3b82f6' : '#64748b',
fontWeight: isActive ? 600 : 400,
textAlign: 'center',
}}>
{s.label}
</span>
</div>
);
})}
</div>
</div>
)}
{/* Step Content */}
<div style={{ padding: '1.5rem' }}>
{step === 'shipping' && <ShippingStep />}
{step === 'payment' && <PaymentStep />}
{step === 'review' && <ReviewStep />}
{step === 'confirmed' && <ConfirmationStep />}
</div>
</div>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
'use client';
import { useState, useEffect, ReactNode } from 'react';
interface ClientOnlyProps {
children: ReactNode;
fallback?: ReactNode;
}
export function ClientOnly({ children, fallback = null }: ClientOnlyProps) {
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
setHasMounted(true);
}, []);
if (!hasMounted) {
return <>{fallback}</>;
}
return <>{children}</>;
}
+229
View File
@@ -0,0 +1,229 @@
'use client';
import { useCallback, useRef } from 'react';
import { configStore, useConfigStore } from '@/store/useConfigStore';
import { PricingEngine } from './PricingEngine';
// Persona attire options with visual metadata
const PERSONA_OPTIONS = [
{
id: 'none',
label: 'Default',
description: 'Original robot appearance',
colors: { torso: '#3b82f6', legs: '#3b82f6' },
},
{
id: 'emarati-kandura',
label: 'Emarati Kandura',
description: 'Traditional white robe attire',
colors: { torso: '#f8fafc', legs: '#f8fafc' },
},
{
id: 'industrial-vest',
label: 'Industrial Vest',
description: 'High-visibility safety vest',
colors: { torso: '#f59e0b', legs: '#3b82f6' },
},
{
id: 'business-suit',
label: 'Business Suit',
description: 'Professional navy suit',
colors: { torso: '#1e293b', legs: '#1e293b' },
},
] as const;
export function ConfigPanel() {
const activeColors = useConfigStore((s) => s.activeColors);
const activePersona = useConfigStore((s) => s.activePersonaAttire);
const colorsSectionRef = useRef<HTMLElement>(null);
const personaSectionRef = useRef<HTMLElement>(null);
const handleColorChange = useCallback((key: 'primary' | 'secondary' | 'accent', value: string) => {
configStore.getState().setColors({ [key]: value });
}, []);
const handlePersonaSelect = useCallback((attire: string) => {
configStore.getState().setPersonaAttire(attire);
}, []);
const handleReset = useCallback(() => {
configStore.getState().reset();
configStore.getState().setHydrated(true);
}, []);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{/* --- COLORS SECTION --- */}
<section
ref={colorsSectionRef}
id="section-colors"
style={{
borderRadius: '0.5rem',
padding: '0.75rem',
}}
>
<h3 style={sectionTitleStyle}>Colors</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<ColorInput label="Primary" value={activeColors.primary} onChange={(v) => handleColorChange('primary', v)} />
</div>
</section>
{/* --- PERSONA SECTION --- */}
<section
ref={personaSectionRef}
id="section-persona"
style={{
borderRadius: '0.5rem',
padding: '0.75rem',
}}
>
<h3 style={sectionTitleStyle}>Persona Attire</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{PERSONA_OPTIONS.map((persona) => {
const isActive = activePersona === persona.id;
return (
<button
key={persona.id}
onClick={() => handlePersonaSelect(persona.id)}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.6rem 0.75rem',
borderRadius: '0.5rem',
border: isActive
? '1px solid rgba(59, 130, 246, 0.5)'
: '1px solid rgba(0, 0, 0, 0.06)',
background: isActive
? 'rgba(59, 130, 246, 0.06)'
: 'rgba(248, 248, 246, 0.4)',
cursor: 'pointer',
transition: 'all 0.25s ease',
textAlign: 'left',
width: '100%',
}}
aria-pressed={isActive}
>
{/* Color preview swatch */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px', flexShrink: 0 }}>
<div style={{
width: '36px',
height: '12px',
borderRadius: '3px 3px 0 0',
backgroundColor: persona.colors.torso,
border: '1px solid rgba(255,255,255,0.1)',
borderBottom: 'none',
}} />
<div style={{
width: '36px',
height: '12px',
borderRadius: '0 0 3px 3px',
backgroundColor: persona.colors.legs,
border: '1px solid rgba(255,255,255,0.1)',
borderTop: 'none',
}} />
</div>
{/* Text */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: '0.8rem',
fontWeight: isActive ? 600 : 400,
color: isActive ? '#374151' : '#94a3b8',
marginBottom: '2px',
}}>
{persona.label}
</div>
<div style={{
fontSize: '0.65rem',
color: '#64748b',
lineHeight: 1.3,
}}>
{persona.description}
</div>
</div>
{/* Active checkmark */}
{isActive && (
<div style={{
width: '20px',
height: '20px',
borderRadius: '50%',
background: 'rgba(59, 130, 246, 0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#2563eb" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
)}
</button>
);
})}
</div>
</section>
{/* --- PRICING --- */}
<PricingEngine />
{/* --- RESET --- */}
<button
onClick={handleReset}
style={{
padding: '0.6rem',
borderRadius: '0.375rem',
border: '1px solid rgba(239, 68, 68, 0.2)',
background: 'rgba(239, 68, 68, 0.05)',
color: '#ef4444',
cursor: 'pointer',
fontSize: '0.8rem',
transition: 'all 0.2s ease',
}}
>
Reset Configuration
</button>
</div>
);
}
function ColorInput({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input
type="color"
value={value}
onChange={(e) => onChange(e.target.value)}
style={{
width: '32px',
height: '32px',
border: '2px solid rgba(0, 0, 0, 0.08)',
borderRadius: '0.375rem',
background: 'transparent',
cursor: 'pointer',
padding: 0,
}}
aria-label={`${label} color`}
/>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '0.8rem', color: '#374151', marginBottom: '2px' }}>{label}</div>
<div style={{ fontSize: '0.7rem', color: '#64748b', fontFamily: 'monospace' }}>{value}</div>
</div>
</div>
);
}
const sectionTitleStyle: React.CSSProperties = {
fontSize: '0.75rem',
fontWeight: 500,
color: '#94a3b8',
textTransform: 'uppercase',
letterSpacing: '0.05em',
margin: 0,
paddingBottom: '0.5rem',
borderBottom: '1px solid rgba(0, 0, 0, 0.06)',
marginBottom: '0.75rem',
};
+165
View File
@@ -0,0 +1,165 @@
'use client';
import { useTranslation } from 'react-i18next';
import { useUrlSync } from '@/hooks/useUrlSync';
import { RobotCanvas } from '@/components/RobotCanvas';
import { ClientOnly } from '@/components/ClientOnly';
import { ConfigPanel } from '@/components/ConfigPanel';
import { CheckoutOverlay } from '@/components/CheckoutOverlay';
export function ConfiguratorSection() {
const { isHydrated } = useUrlSync();
const { t } = useTranslation();
return (
<section
id="configurator"
className="snap-section"
style={{
position: 'relative',
width: '100%',
height: '100vh',
background: '#ffffff',
}}
>
<div
className="layout-container"
style={{
display: 'flex',
width: '100%',
height: '100%',
overflow: 'hidden',
}}
>
<aside
className="glass-panel-responsive"
style={{
order: -1,
width: '420px',
height: '100%',
background: 'rgba(255, 255, 255, 0.9)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
borderRight: '1px solid #e2e8f0',
boxShadow: '4px 0 30px rgba(0, 0, 0, 0.04)',
display: 'flex',
flexDirection: 'column',
position: 'relative',
overflow: 'hidden',
}}
role="complementary"
aria-label={t('panel.title')}
>
<div
className="mobile-handle"
style={{
padding: '0.75rem',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
aria-hidden="true"
>
<div
style={{
width: '40px',
height: '4px',
backgroundColor: '#e2e8f0',
borderRadius: '2px',
}}
/>
</div>
<header
style={{
padding: '1.25rem 1.5rem',
borderBottom: '1px solid #e2e8f0',
position: 'relative',
zIndex: 1,
}}
>
<h2
style={{
fontSize: '1rem',
fontWeight: 600,
color: '#1a1a2e',
margin: 0,
}}
>
{t('panel.title')}
</h2>
</header>
<div
style={{
flex: 1,
padding: '1.5rem',
overflowY: 'auto',
position: 'relative',
zIndex: 1,
}}
role="region"
aria-label={t('panel.currentConfiguration')}
>
<ConfigPanel />
</div>
</aside>
<main
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
width: '100%',
height: '100%',
}}
role="main"
aria-label={t('app.title')}
>
{!isHydrated && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#ffffff',
zIndex: 10,
}}
role="status"
aria-live="polite"
>
<div
style={{
width: '48px',
height: '48px',
border: '3px solid #e2e8f0',
borderTopColor: '#3b82f6',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}}
/>
<p style={{ marginTop: '1rem', fontSize: '0.875rem', color: '#94a3b8' }}>
{t('loading.configuration')}
</p>
</div>
)}
{isHydrated && (
<div style={{ width: '100%', height: '100%' }}>
<ClientOnly>
<RobotCanvas />
</ClientOnly>
</div>
)}
</main>
</div>
<CheckoutOverlay />
</section>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { Component, ReactNode } from "react";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
console.error("ErrorBoundary caught an error:", error, errorInfo);
}
render(): ReactNode {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
padding: "2rem",
backgroundColor: "var(--color-primary)",
color: "var(--color-text-primary)",
textAlign: "center",
}}
role="alert"
aria-live="assertive"
>
<h1 style={{ fontSize: "1.5rem", marginBottom: "1rem" }}>
Something went wrong
</h1>
<p style={{ color: "var(--color-text-secondary)", marginBottom: "1.5rem" }}>
{this.state.error?.message || "An unexpected error occurred"}
</p>
<button
onClick={() => window.location.reload()}
style={{
padding: "0.75rem 1.5rem",
backgroundColor: "var(--color-accent)",
color: "white",
border: "none",
borderRadius: "var(--radius-md)",
cursor: "pointer",
fontWeight: 500,
transition: "background-color var(--transition-fast)",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = "var(--color-accent-hover)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = "var(--color-accent)")
}
>
Reload Page
</button>
</div>
);
}
return this.props.children;
}
}
+35
View File
@@ -0,0 +1,35 @@
'use client';
import { useEffect, useState } from 'react';
import { I18nextProvider } from 'react-i18next';
import i18n from '@/i18n/config';
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
// Set initial document direction based on detected language
const lang = i18n.language || 'en';
document.documentElement.dir = lang === 'ar' ? 'rtl' : 'ltr';
document.documentElement.lang = lang;
// Listen for language changes
const handleLanguageChanged = (lng: string) => {
document.documentElement.dir = lng === 'ar' ? 'rtl' : 'ltr';
document.documentElement.lang = lng;
};
i18n.on('languageChanged', handleLanguageChanged);
setIsInitialized(true);
return () => {
i18n.off('languageChanged', handleLanguageChanged);
};
}, []);
if (!isInitialized) {
return null;
}
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
}
+195
View File
@@ -0,0 +1,195 @@
'use client';
import { useRef, useState, useCallback } from 'react';
import { Html } from '@react-three/drei';
import { useFrame, ThreeEvent } from '@react-three/fiber';
import * as THREE from 'three';
import { SocketName, getSocketTransform } from './SocketPoints';
interface InteractiveHotspotProps {
socketName: SocketName;
label: string;
icon?: string;
onClick?: (socketName: SocketName) => void;
visible?: boolean;
}
export function InteractiveHotspot({
socketName,
label,
icon = '+',
onClick,
visible = true,
}: InteractiveHotspotProps) {
const meshRef = useRef<THREE.Mesh>(null);
const ringRef = useRef<THREE.Mesh>(null);
const [hovered, setHovered] = useState(false);
const [showTooltip, setShowTooltip] = useState(false);
const transform = getSocketTransform(socketName);
// Pulsing animation for the hotspot
useFrame((state) => {
if (meshRef.current) {
const scale = 1 + Math.sin(state.clock.elapsedTime * 3) * 0.1;
meshRef.current.scale.setScalar(hovered ? 1.3 : scale);
}
if (ringRef.current) {
const ringScale = 1 + Math.sin(state.clock.elapsedTime * 2) * 0.15;
ringRef.current.scale.setScalar(ringScale);
const opacity = 0.3 + Math.sin(state.clock.elapsedTime * 2) * 0.2;
if (ringRef.current.material instanceof THREE.MeshBasicMaterial) {
ringRef.current.material.opacity = opacity;
}
}
});
const handleClick = useCallback(
(e: ThreeEvent<MouseEvent>) => {
e.stopPropagation();
onClick?.(socketName);
},
[onClick, socketName]
);
const handlePointerEnter = useCallback(() => {
setHovered(true);
setShowTooltip(true);
document.body.style.cursor = 'pointer';
}, []);
const handlePointerLeave = useCallback(() => {
setHovered(false);
setShowTooltip(false);
document.body.style.cursor = 'auto';
}, []);
return (
<group position={transform.position}>
{/* 3D pulsing sphere */}
<mesh
ref={meshRef}
onClick={handleClick}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
<sphereGeometry args={[0.025, 16, 16]} />
<meshStandardMaterial
color={hovered ? '#3b82f6' : '#f59e0b'}
emissive={hovered ? '#3b82f6' : '#f59e0b'}
emissiveIntensity={hovered ? 0.8 : 0.4}
transparent
opacity={0.9}
/>
</mesh>
{/* Outer ring */}
<mesh ref={ringRef} rotation={[0, 0, 0]}>
<ringGeometry args={[0.03, 0.04, 32]} />
<meshBasicMaterial
color="#f59e0b"
transparent
opacity={0.5}
side={THREE.DoubleSide}
/>
</mesh>
{/* HTML label */}
<Html
position={[0, 0.06, 0]}
center
distanceFactor={8}
style={{
transition: 'all 0.2s',
opacity: 1,
pointerEvents: 'none',
}}
>
{visible && showTooltip && (
<div
style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(8px)',
border: '1px solid rgba(59, 130, 246, 0.3)',
borderRadius: '8px',
padding: '8px 12px',
whiteSpace: 'nowrap',
fontFamily: 'system-ui, -apple-system, sans-serif',
fontSize: '12px',
color: '#1a1a2e',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
animation: 'fadeIn 0.15s ease-out',
}}
>
<div style={{ fontWeight: 600, marginBottom: 2 }}>{label}</div>
<div
style={{
fontSize: '10px',
color: '#94a3b8',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
<span
style={{
background: '#3b82f6',
color: 'white',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '10px',
}}
>
{icon}
</span>
Click to customize
</div>
</div>
)}
</Html>
</group>
);
}
// Hotspot panel component to manage visibility of multiple hotspots
interface HotspotPanelProps {
onSocketClick?: (socketName: SocketName) => void;
visibleSockets?: SocketName[];
}
export function HotspotPanel({ onSocketClick, visibleSockets }: HotspotPanelProps) {
const defaultSockets: SocketName[] = ['head', 'chest', 'left_shoulder', 'right_shoulder', 'back', 'base'];
const socketsToShow = visibleSockets || defaultSockets;
const socketLabels: Record<SocketName, string> = {
head: 'Head Mount',
chest: 'Chest Plate',
left_shoulder: 'Left Shoulder',
right_shoulder: 'Right Shoulder',
back: 'Back Pack',
base: 'Base Unit',
};
const socketIcons: Record<SocketName, string> = {
head: 'H',
chest: 'C',
left_shoulder: 'L',
right_shoulder: 'R',
back: 'B',
base: 'U',
};
return (
<>
{socketsToShow.map((socket) => (
<InteractiveHotspot
key={socket}
socketName={socket}
label={socketLabels[socket]}
icon={socketIcons[socket]}
onClick={onSocketClick}
/>
))}
</>
);
}
+55
View File
@@ -0,0 +1,55 @@
'use client';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export function LanguageSwitcher() {
const { i18n } = useTranslation();
const toggleLanguage = useCallback(() => {
const newLang = i18n.language === 'en' ? 'ar' : 'en';
i18n.changeLanguage(newLang);
// Update document direction for RTL support
document.documentElement.dir = newLang === 'ar' ? 'rtl' : 'ltr';
document.documentElement.lang = newLang;
}, [i18n]);
return (
<button
onClick={toggleLanguage}
style={{
padding: '0.5rem 1rem',
backgroundColor: 'rgba(0, 0, 0, 0.04)',
border: '1px solid rgba(0, 0, 0, 0.08)',
borderRadius: '0.5rem',
color: '#1a1a2e',
cursor: 'pointer',
fontSize: '0.875rem',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
transition: 'all 0.2s ease',
}}
aria-label="Toggle language"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
{i18n.language === 'en' ? 'العربية' : 'English'}
</button>
);
}
+227
View File
@@ -0,0 +1,227 @@
'use client';
import React, { Suspense, useMemo, useState, useCallback } from 'react';
import { useGLTF } from '@react-three/drei';
import { useSpring, animated } from '@react-spring/three';
import * as THREE from 'three';
import { SocketName, getSocketTransform } from './SocketPoints';
interface PayloadMeshProps {
payloadId: string;
type: string;
socketPosition: SocketName;
}
// Map payload types to GLB file paths
const PAYLOAD_MODELS: Record<string, string> = {
'camera': '/payload-camera.glb',
'ptz-camera': '/payload-ptz.glb',
'light': '/payload-light.glb',
'sensor': '/payload-sensor.glb',
'speaker': '/payload-speaker.glb',
};
// Normalize payload type for lookup
const getModelPath = (type: string): string | null => {
const normalized = type.toLowerCase().replace(/\s+/g, '-');
return PAYLOAD_MODELS[normalized] || null;
};
// Placeholder materials by payload type - visually distinct and bright
const PLACEHOLDER_MATERIALS: Record<string, THREE.MeshStandardMaterial> = {
camera: new THREE.MeshStandardMaterial({
color: '#3b82f6',
metalness: 0.6,
roughness: 0.4,
emissive: '#3b82f6',
emissiveIntensity: 0.5,
}),
ptzcamera: new THREE.MeshStandardMaterial({
color: '#3b82f6',
metalness: 0.6,
roughness: 0.4,
emissive: '#3b82f6',
emissiveIntensity: 0.5,
}),
ptz: new THREE.MeshStandardMaterial({
color: '#3b82f6',
metalness: 0.6,
roughness: 0.4,
emissive: '#3b82f6',
emissiveIntensity: 0.5,
}),
light: new THREE.MeshStandardMaterial({
color: '#fbbf24',
metalness: 0.8,
roughness: 0.2,
emissive: '#fbbf24',
emissiveIntensity: 0.8,
}),
sensor: new THREE.MeshStandardMaterial({
color: '#22c55e',
metalness: 0.4,
roughness: 0.6,
emissive: '#22c55e',
emissiveIntensity: 0.6,
}),
speaker: new THREE.MeshStandardMaterial({
color: '#a855f7',
metalness: 0.5,
roughness: 0.5,
emissive: '#a855f7',
emissiveIntensity: 0.5,
}),
default: new THREE.MeshStandardMaterial({
color: '#3b82f6',
metalness: 0.6,
roughness: 0.4,
emissive: '#3b82f6',
emissiveIntensity: 0.5,
}),
};
function PayloadLoader({ modelPath }: { modelPath: string }) {
const [hasError, setHasError] = useState(false);
const { scene } = useGLTF(modelPath);
const processedScene = useMemo(() => {
if (hasError) return null;
const cloned = scene.clone();
cloned.traverse((child) => {
if (child instanceof THREE.Mesh) {
if (child.material) {
child.material = new THREE.MeshStandardMaterial({
color: '#64748b',
metalness: 0.6,
roughness: 0.4,
});
}
}
});
return cloned;
}, [scene, hasError]);
if (hasError || !processedScene) {
throw new Error('Failed to load payload model');
}
return <primitive object={processedScene} />;
}
// Placeholder geometry when GLB is not available or fails to load
function PayloadPlaceholder({ type }: { type: string }) {
const normalizedType = type.toLowerCase().replace(/\s+/g, '-').replace('-camera', 'camera');
const material = PLACEHOLDER_MATERIALS[normalizedType] || PLACEHOLDER_MATERIALS.default;
// Different placeholder shapes based on payload type
const renderGeometry = () => {
switch (normalizedType) {
case 'camera':
return <cylinderGeometry args={[0.03, 0.04, 0.08, 16]} />;
case 'ptzcamera':
case 'ptz':
return <cylinderGeometry args={[0.04, 0.05, 0.1, 16]} />;
case 'light':
return <boxGeometry args={[0.06, 0.06, 0.03]} />;
case 'sensor':
return <sphereGeometry args={[0.04, 16, 16]} />;
case 'speaker':
return <cylinderGeometry args={[0.035, 0.035, 0.05, 16]} />;
default:
return <boxGeometry args={[0.05, 0.05, 0.05]} />;
}
};
return (
<mesh rotation={[Math.PI / 2, 0, 0]}>
{renderGeometry()}
<primitive object={material} attach="material" />
</mesh>
);
}
// Error boundary fallback component
function PayloadErrorFallback({ type }: { type: string }) {
return <PayloadPlaceholder type={type} />;
}
// Wrapper with error handling for GLB loading
function PayloadWithErrorBoundary({ modelPath, type }: { modelPath: string; type: string }) {
const [error, setError] = useState<Error | null>(null);
const handleError = useCallback((err: Error) => {
console.warn('Payload GLB failed to load, using placeholder:', err.message);
setError(err);
}, []);
if (error) {
return <PayloadErrorFallback type={type} />;
}
return (
<Suspense fallback={<PayloadPlaceholder type={type} />}>
<ErrorBoundary onError={handleError}>
<PayloadLoader modelPath={modelPath} />
</ErrorBoundary>
</Suspense>
);
}
// Simple error boundary component for class-based fallback
class ErrorBoundary extends React.Component<
{ children: React.ReactNode; onError: (error: Error) => void },
{ hasError: boolean }
> {
constructor(props: { children: React.ReactNode; onError: (error: Error) => void }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error) {
this.props.onError(error);
}
render() {
if (this.state.hasError) {
return <PayloadPlaceholder type="default" />;
}
return this.props.children;
}
}
export function PayloadMesh({ payloadId, type, socketPosition }: PayloadMeshProps) {
const transform = getSocketTransform(socketPosition);
const modelPath = getModelPath(type);
// Spring animation for scale and y position
const { scale, posY } = useSpring({
from: { scale: 0, posY: transform.position.y - 0.1 },
to: { scale: 1, posY: transform.position.y },
config: { mass: 0.3, tension: 300, friction: 20 },
});
return (
<animated.group
position-x={transform.position.x}
position-y={posY}
position-z={transform.position.z}
rotation={transform.rotation}
scale={scale}
>
{/* Always render either GLB model or placeholder */}
{modelPath ? (
<PayloadWithErrorBoundary modelPath={modelPath} type={type} />
) : (
<PayloadPlaceholder type={type} />
)}
</animated.group>
);
}
+130
View File
@@ -0,0 +1,130 @@
'use client';
import { useEffect } from 'react';
import { useConfigStore } from '@/store/useConfigStore';
import { usePricingStore, pricingStore } from '@/store/usePricingStore';
import { orderStore } from '@/store/useOrderStore';
const DEFAULT_COLOR = '#96a2b6';
function formatAED(price: number): string {
return new Intl.NumberFormat('en-AE').format(price);
}
export function PricingEngine() {
const persona = useConfigStore((s) => s.activePersonaAttire);
const primaryColor = useConfigStore((s) => s.activeColors.primary);
const items = usePricingStore((s) => s.items);
const isHydrated = usePricingStore((s) => s.isHydrated);
useEffect(() => {
pricingStore.getState().hydrate();
}, []);
const getPrice = (id: string) => items.find((i) => i.id === id)?.price ?? 0;
const basePrice = getPrice('base');
const personaPrice = persona !== 'none' ? getPrice(persona) : 0;
const colorPrice = primaryColor !== DEFAULT_COLOR ? getPrice('custom-color') : 0;
const total = basePrice + personaPrice + colorPrice;
const personaLabel = items.find((i) => i.id === persona)?.label ?? '';
const handleProceed = () => {
const store = orderStore.getState();
store.setOrderTotal(total);
store.setConfigSummary(
persona === 'none' ? 'Default' : personaLabel,
primaryColor
);
store.setStep('shipping');
};
if (!isHydrated) return null;
return (
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
padding: '0.75rem',
borderRadius: '0.5rem',
background: 'rgba(59, 130, 246, 0.03)',
border: '1px solid rgba(59, 130, 246, 0.06)',
}}>
<h3 style={{
fontSize: '0.75rem',
fontWeight: 500,
color: '#94a3b8',
textTransform: 'uppercase',
letterSpacing: '0.05em',
margin: 0,
paddingBottom: '0.5rem',
borderBottom: '1px solid rgba(0, 0, 0, 0.06)',
}}>
Price Breakdown
</h3>
{/* Line items */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<PriceLine label="G1 Robot Base" price={basePrice} />
{personaPrice > 0 && <PriceLine label={personaLabel} price={personaPrice} />}
{colorPrice > 0 && <PriceLine label="Custom Color" price={colorPrice} />}
</div>
{/* Total */}
<div
aria-live="polite"
aria-atomic="true"
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: '0.5rem',
borderTop: '1px solid rgba(0, 0, 0, 0.06)',
marginTop: '0.25rem',
}}
>
<span style={{ fontSize: '0.8rem', fontWeight: 600, color: '#374151' }}>Total</span>
<span style={{ fontSize: '0.9rem', fontWeight: 700, color: '#1a1a2e', fontFamily: 'monospace' }}>
AED {formatAED(total)}
</span>
</div>
{/* Proceed button */}
<button
onClick={handleProceed}
style={{
marginTop: '0.5rem',
padding: '0.7rem',
borderRadius: '0.375rem',
border: '1px solid rgba(59, 130, 246, 0.5)',
background: 'rgba(59, 130, 246, 0.08)',
color: '#2563eb',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
transition: 'all 0.2s ease',
textAlign: 'center',
}}
>
Proceed to Order
</button>
</div>
);
}
function PriceLine({ label, price }: { label: string; price: number }) {
return (
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}>
<span style={{ fontSize: '0.75rem', color: '#94a3b8' }}>{label}</span>
<span style={{ fontSize: '0.75rem', color: '#374151', fontFamily: 'monospace' }}>
AED {formatAED(price)}
</span>
</div>
);
}
+163
View File
@@ -0,0 +1,163 @@
'use client';
import React, { Suspense, useRef, useCallback, useState } from 'react';
import { Canvas } from '@react-three/fiber';
import {
Environment,
ContactShadows,
OrbitControls,
useProgress,
Html,
} from '@react-three/drei';
import { useThree } from '@react-three/fiber';
import { RobotModel } from './RobotModel';
import type { WebGLRenderer, Scene, Camera } from 'three';
function Loader() {
const { progress } = useProgress();
return (
<Html center>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem' }}>
<div style={{
width: '48px', height: '48px',
border: '3px solid rgba(59, 130, 246, 0.15)',
borderTopColor: '#3b82f6',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}} />
<p style={{ fontSize: '0.875rem', color: '#64748b', fontFamily: 'system-ui, sans-serif' }}>
{progress.toFixed(0)}% loaded
</p>
</div>
</Html>
);
}
function SceneCapture({ onCapture }: { onCapture: (gl: WebGLRenderer, scene: Scene, camera: Camera) => void }) {
const { gl, scene, camera } = useThree();
React.useEffect(() => {
onCapture(gl, scene, camera);
}, [gl, scene, camera, onCapture]);
return null;
}
function SceneContent({ onCapture }: { onCapture: (gl: WebGLRenderer, scene: Scene, camera: Camera) => void }) {
return (
<>
<SceneCapture onCapture={onCapture} />
<Environment preset="city" />
<ambientLight intensity={0.8} />
<directionalLight position={[5, 5, 5]} intensity={1.8} color="#ffffff" castShadow shadow-mapSize={[1024, 1024]} />
<directionalLight position={[-4, 3, 2]} intensity={0.8} color="#e8f0ff" />
<directionalLight position={[0, 3, -5]} intensity={0.8} color="#94a3b8" />
<spotLight position={[0, 8, 0]} intensity={1.0} angle={0.6} penumbra={0.5} color="#ffffff" />
<directionalLight position={[0, 2, 5]} intensity={0.7} color="#ffffff" />
<RobotModel />
<OrbitControls enablePan enableZoom enableRotate minDistance={2} maxDistance={10} minPolarAngle={0} maxPolarAngle={Math.PI} dampingFactor={0.05} enableDamping />
<ContactShadows position={[0, -1, 0]} opacity={0.25} scale={10} blur={2} far={4} resolution={256} color="#000000" />
</>
);
}
export function RobotCanvas() {
const glRef = useRef<WebGLRenderer | null>(null);
const sceneRef = useRef<Scene | null>(null);
const cameraRef = useRef<Camera | null>(null);
const [isCapturing, setIsCapturing] = useState(false);
const [shareStatus, setShareStatus] = useState<'idle' | 'copied' | 'failed'>('idle');
const handleCapture = useCallback((gl: WebGLRenderer, scene: Scene, camera: Camera) => {
glRef.current = gl;
sceneRef.current = scene;
cameraRef.current = camera;
}, []);
const handleShare = useCallback(async () => {
try {
await navigator.clipboard.writeText(window.location.href);
setShareStatus('copied');
setTimeout(() => setShareStatus('idle'), 2000);
} catch {
setShareStatus('failed');
setTimeout(() => setShareStatus('idle'), 2000);
}
}, []);
const handleSnapshot = useCallback(() => {
if (!glRef.current || !sceneRef.current || !cameraRef.current) return;
setIsCapturing(true);
try {
glRef.current.render(sceneRef.current, cameraRef.current);
const dataUrl = glRef.current.domElement.toDataURL('image/png');
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const link = document.createElement('a');
link.download = `g1-robot-${timestamp}.png`;
link.href = dataUrl;
link.click();
} catch (error) {
console.error('Failed to capture snapshot:', error);
} finally {
setIsCapturing(false);
}
}, []);
const btnBase: React.CSSProperties = {
position: 'absolute',
top: '1rem',
padding: '0.5rem 1rem',
backdropFilter: 'blur(8px)',
borderRadius: '0.5rem',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
transition: 'all 0.2s ease',
zIndex: 10,
};
return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<Canvas
dpr={[1, 2]}
camera={{ position: [0, 1, 5], fov: 50 }}
gl={{ antialias: true, powerPreference: 'high-performance' }}
style={{ background: 'linear-gradient(180deg, #e8e8e4 0%, #f0f0ec 50%, #e8e8e4 100%)' }}
>
<Suspense fallback={<Loader />}>
<SceneContent onCapture={handleCapture} />
</Suspense>
</Canvas>
<button
onClick={handleSnapshot}
disabled={isCapturing}
style={{ ...btnBase, left: '1rem', backgroundColor: 'rgba(255,255,255,0.8)', border: '1px solid #e2e8f0', color: '#1a1a2e' }}
aria-label="Capture 3D scene snapshot"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" /><circle cx="8.5" cy="8.5" r="1.5" /><polyline points="21 15 16 10 5 21" />
</svg>
{isCapturing ? 'Capturing...' : 'Snapshot'}
</button>
<button
onClick={handleShare}
style={{
...btnBase,
left: '8.5rem',
backgroundColor: shareStatus === 'copied' ? 'rgba(34,197,94,0.1)' : 'rgba(255,255,255,0.8)',
border: `1px solid ${shareStatus === 'copied' ? '#86efac' : '#e2e8f0'}`,
color: shareStatus === 'copied' ? '#16a34a' : '#1a1a2e',
}}
aria-label="Share configuration link"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><line x1="8.59" y1="13.51" x2="15.42" y2="17.49" /><line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
</svg>
{shareStatus === 'copied' ? 'Copied!' : 'Share'}
</button>
</div>
);
}
+200
View File
@@ -0,0 +1,200 @@
'use client';
import { useRef, useMemo, useEffect, Suspense, useState } from 'react';
import { useGLTF } from '@react-three/drei';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import { useConfigStore } from '@/store/useConfigStore';
const ATTIRE_GLB: Record<string, string> = {
'emarati-kandura': '/Kandoura.glb',
'industrial-vest': '/Vest.glb',
'business-suit': '/Suit.glb',
};
// Preload all attire models so they're cached before user clicks
Object.values(ATTIRE_GLB).forEach((path) => useGLTF.preload(path));
function easeInOutCubic(t: number): number {
return t < 0.5
? 4 * t * t * t
: 1 - Math.pow(-2 * t + 2, 3) / 2;
}
function AttireModel({ glbPath, onLoaded }: { glbPath: string; onLoaded: () => void }) {
const { scene } = useGLTF(glbPath);
const processedAttire = useMemo(() => {
const cloned = scene.clone();
cloned.traverse((child) => {
if (child instanceof THREE.Mesh && child.material instanceof THREE.MeshStandardMaterial) {
child.material.envMapIntensity = 1;
child.material.needsUpdate = true;
}
});
const box = new THREE.Box3().setFromObject(cloned);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 2 / maxDim;
cloned.scale.setScalar(scale);
cloned.position.set(
-center.x * scale,
-center.y * scale + 0.5,
-center.z * scale
);
return cloned;
}, [scene]);
// Signal that the model is ready and rendered
useEffect(() => {
onLoaded();
}, [onLoaded]);
return <primitive object={processedAttire} />;
}
interface RobotModelProps {
onError?: (error: Error) => void;
}
export function RobotModel({ onError }: RobotModelProps) {
const groupRef = useRef<THREE.Group>(null);
const spinningRef = useRef(false);
const spinProgressRef = useRef(0);
const swappedRef = useRef(false);
const targetAttireRef = useRef<string>('none');
const [displayedAttire, setDisplayedAttire] = useState('none');
const [attireReady, setAttireReady] = useState(false);
const previousAttireRef = useRef('none');
const { scene } = useGLTF('/Unitree_G1.glb');
const activeColors = useConfigStore((state) => state.activeColors);
const activePersonaAttire = useConfigStore((state) => state.activePersonaAttire);
// Detect attire change and trigger spin
useEffect(() => {
if (activePersonaAttire !== previousAttireRef.current) {
targetAttireRef.current = activePersonaAttire;
spinningRef.current = true;
spinProgressRef.current = 0;
swappedRef.current = false;
setAttireReady(false);
previousAttireRef.current = activePersonaAttire;
}
}, [activePersonaAttire]);
// Spin animation loop
useFrame((_, delta) => {
if (!spinningRef.current || !groupRef.current) return;
const spinSpeed = 2.5;
spinProgressRef.current += delta * spinSpeed;
const progress = Math.min(spinProgressRef.current, 1);
const easedRotation = easeInOutCubic(progress) * Math.PI * 2;
groupRef.current.rotation.y = easedRotation;
if (progress >= 0.5 && !swappedRef.current) {
setDisplayedAttire(targetAttireRef.current);
swappedRef.current = true;
}
if (progress >= 1) {
groupRef.current.rotation.y = 0;
spinningRef.current = false;
spinProgressRef.current = 0;
}
});
const processedScene = useMemo(() => {
const clonedScene = scene.clone();
clonedScene.traverse((child) => {
if (child instanceof THREE.Mesh) {
if (!child.material) {
child.material = new THREE.MeshStandardMaterial({
color: '#96a2b6',
metalness: 0.8,
roughness: 0.2,
});
}
if (child.material instanceof THREE.MeshStandardMaterial) {
child.material.envMapIntensity = 1;
child.material.needsUpdate = true;
}
}
});
const box = new THREE.Box3().setFromObject(clonedScene);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 2 / maxDim;
clonedScene.scale.setScalar(scale);
const offset = new THREE.Vector3(
-center.x * scale,
-center.y * scale + 0.5,
-center.z * scale
);
clonedScene.position.copy(offset);
return clonedScene;
}, [scene]);
// Hide base robot only when attire is selected AND the attire GLB has loaded
// Show base robot when 'none' is selected or attire is still loading
const showBase = displayedAttire === 'none' || !attireReady;
useEffect(() => {
processedScene.visible = showBase;
}, [showBase, processedScene]);
// Apply primary color to the base robot only
useEffect(() => {
if (!groupRef.current) return;
groupRef.current.traverse((child) => {
if (child instanceof THREE.Mesh && child.material instanceof THREE.MeshStandardMaterial) {
if (child.name.startsWith('Unitree_G1')) {
child.material.color.set(activeColors.primary);
child.material.needsUpdate = true;
}
}
});
}, [activeColors]);
const attireGlbPath = ATTIRE_GLB[displayedAttire] || null;
const handleAttireLoaded = () => {
setAttireReady(true);
};
return (
<group ref={groupRef}>
<primitive object={processedScene} />
{attireGlbPath && (
<Suspense fallback={null}>
<AttireModel
key={attireGlbPath}
glbPath={attireGlbPath}
onLoaded={handleAttireLoaded}
/>
</Suspense>
)}
</group>
);
}
useGLTF.preload('/Unitree_G1.glb');
+269
View File
@@ -0,0 +1,269 @@
'use client';
import { useScroll, useTransform, motion } from 'framer-motion';
import { ReactNode } from 'react';
interface SectionProps {
children: ReactNode;
progress: any;
startAt: number;
peakAt: number;
endAt: number;
align: 'center' | 'left' | 'right';
verticalAlign?: 'top' | 'center' | 'bottom';
offsetY?: number;
}
function OverlaySection({
children,
progress,
startAt,
peakAt,
endAt,
align,
verticalAlign = 'center',
offsetY = 50,
}: SectionProps) {
// Define a wide "plateau" zone where the text is fully readable and static.
// We use 30% of the travel distance for fading in, 30% for fading out.
const diffIn = peakAt - startAt;
const diffOut = endAt - peakAt;
const inStart = startAt;
const inEnd = startAt + diffIn * 0.4; // Fades in quickly
const outStart = endAt - diffOut * 0.6; // Holds for a long time
const outEnd = endAt;
// Smooth fade in and out mapping over a lengthy plateau
const opacity = useTransform(
progress,
[inStart, inEnd, outStart, outEnd],
[0, 1, 1, 0]
);
// Translate Y to slide in, pause entirely while reading, slide out
const y = useTransform(
progress,
[inStart, inEnd, outStart, outEnd],
[offsetY, 0, 0, -offsetY]
);
// Scale stays at 1.0 during reading mode
const scale = useTransform(
progress,
[inStart, inEnd, outStart, outEnd],
[0.95, 1, 1, 1.05]
);
const alignStyle: React.CSSProperties =
align === 'left'
? { left: 'clamp(2rem, 6vw, 6rem)', alignItems: 'flex-start' }
: align === 'right'
? { right: 'clamp(2rem, 6vw, 6rem)', alignItems: 'flex-end' }
: { left: '50%', x: '-50%', alignItems: 'center' };
const verticalStyle: React.CSSProperties =
verticalAlign === 'top'
? { top: '15vh' }
: verticalAlign === 'bottom'
? { bottom: '15vh', top: 'auto' }
: { top: '50%', y: '-50%' };
// Glass panel appearance for side text to not clash with the robot
const isCenter = align === 'center';
const panelStyle: React.CSSProperties = isCenter
? { textAlign: 'center' }
: {
background: 'rgba(255, 255, 255, 0.45)',
backdropFilter: 'blur(16px)',
WebkitBackdropFilter: 'blur(16px)',
padding: '2.5rem',
borderRadius: '1.5rem',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.08)',
border: '1px solid rgba(255, 255, 255, 0.6)',
textAlign: align === 'left' ? 'left' : 'right',
maxWidth: '450px',
};
return (
<motion.div
style={{
position: 'absolute',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
opacity,
pointerEvents: 'none',
...verticalStyle,
...alignStyle,
}}
>
<motion.div
style={{
y,
scale,
...panelStyle,
}}
className="will-change-transform"
>
{children}
</motion.div>
</motion.div>
);
}
// Section timing definitions to match ScrollScene camera moves
const SECTION_CONFIGS = [
{ id: 'brand', startAt: 0, peakAt: 0.05, endAt: 0.15, align: 'center' as const, verticalAlign: 'top' as const },
{ id: 'hero', startAt: 0.10, peakAt: 0.22, endAt: 0.35, align: 'left' as const, verticalAlign: 'center' as const },
{ id: 'headReveal', startAt: 0.35, peakAt: 0.46, endAt: 0.53, align: 'right' as const, verticalAlign: 'center' as const },
{ id: 'customization', startAt: 0.55, peakAt: 0.66, endAt: 0.77, align: 'left' as const, verticalAlign: 'center' as const },
{ id: 'mobility', startAt: 0.80, peakAt: 0.90, endAt: 1.0, align: 'right' as const, verticalAlign: 'center' as const },
];
export function ScrollOverlays() {
const { scrollYProgress } = useScroll();
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 10,
pointerEvents: 'none',
overflow: 'hidden',
}}
>
{/* 1. Brand Intro */}
<OverlaySection progress={scrollYProgress} {...SECTION_CONFIGS[0]}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '1rem', marginBottom: '1rem' }}>
<div style={{ width: '30px', height: '1px', background: 'linear-gradient(90deg, transparent, #c4a265)' }} />
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: '#c4a265', letterSpacing: '0.4em', textTransform: 'uppercase' }}>
YS Lootah Robotics
</span>
<div style={{ width: '30px', height: '1px', background: 'linear-gradient(90deg, #c4a265, transparent)' }} />
</div>
<p style={{ fontSize: '0.9rem', color: '#64748b', fontWeight: 400, letterSpacing: '0.15em', margin: 0 }}>
Pioneering Humanoid Robotics in the UAE
</p>
</OverlaySection>
{/* 2. Hero */}
<OverlaySection progress={scrollYProgress} {...SECTION_CONFIGS[1]}>
<motion.h1 style={{ fontSize: 'clamp(2.5rem, 5vw, 4.5rem)', fontWeight: 200, color: '#1a1a2e', lineHeight: 1.0, letterSpacing: '-0.04em', margin: 0 }}>
The Future
</motion.h1>
<motion.h1 style={{ fontSize: 'clamp(2.5rem, 5vw, 4.5rem)', fontWeight: 200, color: '#1a1a2e', lineHeight: 1.0, letterSpacing: '-0.04em', margin: '0.1em 0 0' }}>
of <span style={{ color: '#c4a265', fontWeight: 400 }}>Robotics</span>
</motion.h1>
<p style={{ fontSize: '1rem', color: '#475569', lineHeight: 1.7, margin: '1.5rem 0 0', fontWeight: 300 }}>
Meet the G1 Humanoid Robot. Fully customizable, enterprise-ready, designed for the world of tomorrow.
</p>
</OverlaySection>
{/* 3. Head Reveal */}
<OverlaySection progress={scrollYProgress} {...SECTION_CONFIGS[2]}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
<div style={{ width: '50px', height: '2px', background: '#c4a265', marginBottom: '1.5rem' }} />
<div style={{ fontSize: '0.65rem', fontWeight: 600, color: '#c4a265', letterSpacing: '0.3em', textTransform: 'uppercase', marginBottom: '1rem' }}>
Intelligent by Design
</div>
<h2 style={{ fontSize: 'clamp(2rem, 3.5vw, 3rem)', fontWeight: 300, color: '#1a1a2e', lineHeight: 1.1, margin: '0 0 1rem', letterSpacing: '-0.03em' }}>
Vision That<br />Understands
</h2>
<p style={{ fontSize: '0.95rem', color: '#475569', lineHeight: 1.6, margin: 0, fontWeight: 300 }}>
Advanced computer vision and neural processing. The G1 sees, interprets, and responds to the world in real-time.
</p>
<div style={{ display: 'flex', gap: '2.5rem', marginTop: '2rem' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '1.8rem', fontWeight: 300, color: '#1a1a2e', fontFamily: 'monospace' }}>360°</div>
<div style={{ fontSize: '0.65rem', color: '#64748b', letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: '0.5rem' }}>Field of View</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '1.8rem', fontWeight: 300, color: '#1a1a2e', fontFamily: 'monospace' }}>{'<'}50ms</div>
<div style={{ fontSize: '0.65rem', color: '#64748b', letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: '0.5rem' }}>Response Time</div>
</div>
</div>
</div>
</OverlaySection>
{/* 4. Customization */}
<OverlaySection progress={scrollYProgress} {...SECTION_CONFIGS[3]}>
<div style={{ width: '50px', height: '2px', background: '#c4a265', marginBottom: '1.5rem' }} />
<div style={{ fontSize: '0.65rem', fontWeight: 600, color: '#c4a265', letterSpacing: '0.3em', textTransform: 'uppercase', marginBottom: '1rem' }}>
Your Identity
</div>
<h2 style={{ fontSize: 'clamp(2rem, 3.5vw, 3rem)', fontWeight: 300, color: '#1a1a2e', lineHeight: 1.1, margin: '0 0 1rem', letterSpacing: '-0.03em' }}>
Dress for Any<br />Mission
</h2>
<p style={{ fontSize: '0.95rem', color: '#475569', lineHeight: 1.6, margin: 0, fontWeight: 300 }}>
From traditional Emarati Kandura to industrial safety gear and professional business attire. Configure every detail to match your brand.
</p>
<div style={{ display: 'flex', gap: '1rem', marginTop: '2rem' }}>
{['Kandura', 'Vest', 'Suit'].map((label) => (
<div
key={label}
style={{
padding: '0.5rem 1rem',
borderRadius: '2rem',
background: 'rgba(196, 162, 101, 0.1)',
border: '1px solid rgba(196, 162, 101, 0.3)',
fontSize: '0.75rem',
color: '#c4a265',
letterSpacing: '0.1em',
fontWeight: 500
}}
>
{label}
</div>
))}
</div>
</OverlaySection>
{/* 5. Mobility */}
<OverlaySection progress={scrollYProgress} {...SECTION_CONFIGS[4]}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
<div style={{ width: '50px', height: '2px', background: '#c4a265', marginBottom: '1.5rem' }} />
<div style={{ fontSize: '0.65rem', fontWeight: 600, color: '#c4a265', letterSpacing: '0.3em', textTransform: 'uppercase', marginBottom: '1rem' }}>
Advanced Mobility
</div>
<h2 style={{ fontSize: 'clamp(2rem, 3.5vw, 3rem)', fontWeight: 300, color: '#1a1a2e', lineHeight: 1.1, margin: '0 0 1rem', letterSpacing: '-0.03em' }}>
23 Degrees of<br />Freedom
</h2>
<p style={{ fontSize: '0.95rem', color: '#475569', lineHeight: 1.6, margin: 0, fontWeight: 300 }}>
State-of-the-art locomotion enabling natural human-like movement, balance, and agility across any terrain.
</p>
<div style={{ display: 'flex', gap: '2.5rem', marginTop: '2rem' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '1.8rem', fontWeight: 300, color: '#1a1a2e', fontFamily: 'monospace' }}>2m/s</div>
<div style={{ fontSize: '0.65rem', color: '#64748b', letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: '0.5rem' }}>Max Speed</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '1.8rem', fontWeight: 300, color: '#1a1a2e', fontFamily: 'monospace' }}>127kg</div>
<div style={{ fontSize: '0.65rem', color: '#64748b', letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: '0.5rem' }}>Payload</div>
</div>
</div>
</div>
</OverlaySection>
{/* Scroll indicator mapped to vanish rapidly when scrolled */}
<motion.div
style={{
position: 'absolute',
bottom: '2.5rem',
left: '50%',
x: '-50%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
opacity: useTransform(scrollYProgress, [0, 0.05], [1, 0]),
}}
>
<span style={{ fontSize: '0.65rem', fontWeight: 500, color: '#64748b', letterSpacing: '0.3em', textTransform: 'uppercase', marginBottom: '1rem' }}>
Scroll to Explore
</span>
<div className="scroll-indicator" />
</motion.div>
</div>
);
}
+356
View File
@@ -0,0 +1,356 @@
'use client';
import React, { Suspense, useRef, useEffect } from 'react';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { Environment, ContactShadows, useGLTF, Html, useProgress } from '@react-three/drei';
import * as THREE from 'three';
// Original camera keyframes - cinematic path with dramatic shots
const CAMERA_KEYFRAMES: [number, [number, number, number]][] = [
[0.0, [-2.5, 0.6, 7]], // Start: far left wide shot
[0.12, [-1.8, 0.7, 5.5]], // Slow drift toward center
[0.25, [-0.5, 0.9, 4]], // Approach center gently
[0.40, [0.3, 1.5, 2.0]], // HEAD+CHEST: tight, high angle looking down
[0.55, [1.2, 2.2, 2.2]], // ABOVE: bird's eye with slight right offset
[0.68, [1.6, 0.7, 2.5]], // Dramatic right side sweep
[0.80, [0.3, 0.3, 2.2]], // Circle to front-low
[0.92, [0, -0.3, 2.5]], // LOW ANGLE: heroic looking up
[1.0, [0, 1.2, 5.5]], // Final: pull back for configurator
];
const LOOKAT_KEYFRAMES: [number, [number, number, number]][] = [
[0.0, [0, 0.4, 0]],
[0.12, [0, 0.45, 0]],
[0.25, [0, 0.55, 0]],
[0.40, [0, 1.2, 0]], // Look at head during tight shot
[0.55, [0, 0.85, 0]], // Look at chest from above
[0.68, [0, 0.65, 0]],
[0.80, [0, 0.5, 0]],
[0.92, [0, 0.6, 0]], // Look slightly up from low angle
[1.0, [0, 0.5, 0]],
];
function interpolateKeyframes(keyframes: [number, [number, number, number]][], progress: number): THREE.Vector3 {
const clamped = Math.max(0, Math.min(1, progress));
for (let i = 0; i < keyframes.length - 1; i++) {
const [startT, startPos] = keyframes[i];
const [endT, endPos] = keyframes[i + 1];
if (clamped >= startT && clamped <= endT) {
const localT = (clamped - startT) / (endT - startT);
const eased = localT * localT * (3 - 2 * localT);
return new THREE.Vector3(
startPos[0] + (endPos[0] - startPos[0]) * eased,
startPos[1] + (endPos[1] - startPos[1]) * eased,
startPos[2] + (endPos[2] - startPos[2]) * eased,
);
}
}
const last = keyframes[keyframes.length - 1][1];
return new THREE.Vector3(last[0], last[1], last[2]);
}
function lerpScalar(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
const scrollState = { progress: 0, inScrollZone: true };
function ScrollCamera() {
const { camera } = useThree();
const lookAtTarget = useRef(new THREE.Vector3(0, 0.5, 0));
useFrame(({ clock }) => {
if (!scrollState.inScrollZone) return;
const p = scrollState.progress;
const pos = interpolateKeyframes(CAMERA_KEYFRAMES, p);
const lookAt = interpolateKeyframes(LOOKAT_KEYFRAMES, p);
// Dynamic camera oscillation based on scroll position
const oscillationX = Math.sin(p * Math.PI * 4) * 0.05;
const oscillationY = Math.cos(p * Math.PI * 3) * 0.025;
// Add subtle drift during specific zones for cinematic effect
const driftZone = Math.sin(p * Math.PI * 2) * 0.03;
const verticalDrift = Math.sin(clock.elapsedTime * 0.3) * 0.02;
// Apply oscillations to position
const adjustedPos = new THREE.Vector3(
pos.x + oscillationX + driftZone,
pos.y + oscillationY + verticalDrift,
pos.z
);
// Adaptive lerp for smooth camera transitions
const lerpFactor = p < 0.15 ? 0.015 : (p > 0.9 ? 0.02 : 0.02);
camera.position.lerp(adjustedPos, lerpFactor);
lookAtTarget.current.lerp(lookAt, lerpFactor * 1.0);
camera.lookAt(lookAtTarget.current);
});
return null;
}
// Invisible orbiting point lights + halo ring (no visible sphere meshes)
function LightOrbs() {
const blueOrbRef = useRef<THREE.Group>(null);
const goldOrbRef = useRef<THREE.Group>(null);
const haloRef = useRef<THREE.Mesh>(null);
const bluePointRef = useRef<THREE.PointLight>(null);
const goldPointRef = useRef<THREE.PointLight>(null);
useFrame(({ clock }) => {
const p = scrollState.progress;
const time = clock.elapsedTime;
if (blueOrbRef.current) {
// Orbital path
const angle = p * Math.PI * 2.5 + time * 0.15;
const radius = 1.3 + Math.sin(p * Math.PI * 1.5) * 0.25;
blueOrbRef.current.position.set(
Math.cos(angle) * radius,
0.5 + Math.sin(p * Math.PI * 2 + time * 0.25) * 0.35,
Math.sin(angle) * radius,
);
}
if (goldOrbRef.current) {
// Counter-rotating orbit
const angle = -p * Math.PI * 2 + time * 0.1 + Math.PI;
const radius = 1.1 + Math.cos(p * Math.PI * 1.5) * 0.2;
goldOrbRef.current.position.set(
Math.cos(angle) * radius,
1.1 + Math.sin(p * Math.PI * 1.5 + time * 0.2) * 0.3,
Math.sin(angle) * radius,
);
}
if (bluePointRef.current) {
bluePointRef.current.intensity = 2.2 + Math.sin(p * Math.PI * 3 + time * 0.5) * 0.8;
}
if (goldPointRef.current) {
goldPointRef.current.intensity = 2.0 + Math.sin(p * Math.PI * 2.5 + time * 0.35) * 0.6;
}
if (haloRef.current) {
const haloZone = Math.max(0, 1 - Math.abs(p - 0.38) / 0.15);
(haloRef.current.material as THREE.MeshBasicMaterial).opacity = haloZone * 0.7;
haloRef.current.position.set(0, 1.55, 0);
haloRef.current.rotation.x = Math.PI / 2 + Math.sin(time * 0.3) * 0.08;
haloRef.current.rotation.z = p * Math.PI * 2 + time * 0.15;
}
});
return (
<>
{/* Orbiting blue point light (no visible mesh) */}
<group ref={blueOrbRef}>
<pointLight ref={bluePointRef} color="#4a7aff" intensity={2.0} distance={5} decay={2} />
</group>
{/* Orbiting gold point light (no visible mesh) */}
<group ref={goldOrbRef}>
<pointLight ref={goldPointRef} color="#c4a265" intensity={1.8} distance={4.5} decay={2} />
</group>
{/* Halo ring above head - visible during close-up */}
<mesh ref={haloRef} position={[0, 1.6, 0]} rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.25, 0.008, 16, 64]} />
<meshBasicMaterial color="#c4a265" transparent opacity={0} />
</mesh>
</>
);
}
function ScrollLighting() {
const rimLightRef = useRef<THREE.DirectionalLight>(null);
const goldSpotRef = useRef<THREE.SpotLight>(null);
const leftSideRef = useRef<THREE.DirectionalLight>(null);
const rightSideRef = useRef<THREE.DirectionalLight>(null);
const fillRef = useRef<THREE.DirectionalLight>(null);
useFrame(() => {
const p = scrollState.progress;
if (rimLightRef.current) {
rimLightRef.current.intensity = lerpScalar(3.0, 1.0, Math.min(1, p / 0.4));
rimLightRef.current.position.set(
Math.sin(p * Math.PI * 2) * 3,
3,
-5 + Math.sin(p * Math.PI) * 2,
);
}
if (goldSpotRef.current) {
const headZone = Math.max(0, 1 - Math.abs(p - 0.28) / 0.12);
goldSpotRef.current.intensity = lerpScalar(0, 5.0, headZone);
goldSpotRef.current.position.set(0.5, 2.5, 2);
}
if (leftSideRef.current) {
const orbitZone = Math.max(0, 1 - Math.abs(p - 0.45) / 0.15);
leftSideRef.current.intensity = lerpScalar(0.8, 2.8, orbitZone);
leftSideRef.current.position.set(-5 + p * 3, 3, 2);
}
if (rightSideRef.current) {
const orbitZone2 = Math.max(0, 1 - Math.abs(p - 0.58) / 0.15);
rightSideRef.current.intensity = lerpScalar(0.8, 2.8, orbitZone2);
rightSideRef.current.position.set(5 - p * 3, 2, 3);
}
if (fillRef.current) {
fillRef.current.intensity = lerpScalar(0.8, 2.0, Math.max(0, (p - 0.6) / 0.4));
}
});
return (
<>
<directionalLight ref={rimLightRef} position={[0, 3, -5]} intensity={3.0} color="#4a7aff" />
<spotLight ref={goldSpotRef} position={[0.5, 2.5, 2]} intensity={0} color="#c4a265" angle={0.5} penumbra={0.8} />
<directionalLight ref={leftSideRef} position={[-5, 3, 2]} intensity={0.8} color="#e8f0ff" />
<directionalLight ref={rightSideRef} position={[5, 2, 3]} intensity={0.8} color="#e0e8ff" />
<directionalLight ref={fillRef} position={[0, 2, 5]} intensity={0.8} color="#ffffff" />
</>
);
}
function RobotDisplay() {
const { scene } = useGLTF('/Unitree_G1.glb');
const processedScene = React.useMemo(() => {
const cloned = scene.clone();
cloned.traverse((child) => {
if (child instanceof THREE.Mesh) {
if (!child.material) {
child.material = new THREE.MeshStandardMaterial({
color: '#96a2b6',
metalness: 0.8,
roughness: 0.2,
});
}
if (child.material instanceof THREE.MeshStandardMaterial) {
child.material.envMapIntensity = 1.8;
child.material.needsUpdate = true;
}
}
});
const box = new THREE.Box3().setFromObject(cloned);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 2 / maxDim;
cloned.scale.setScalar(scale);
cloned.position.set(
-center.x * scale,
-center.y * scale + 0.5,
-center.z * scale,
);
return cloned;
}, [scene]);
return <primitive object={processedScene} />;
}
function Loader() {
const { progress } = useProgress();
return (
<Html center>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem' }}>
<div style={{
width: '48px', height: '48px',
border: '3px solid rgba(196, 162, 101, 0.15)',
borderTopColor: '#c4a265',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}} />
<p style={{ fontSize: '0.8rem', color: '#64748b', fontFamily: 'Inter, system-ui, sans-serif' }}>
{progress.toFixed(0)}%
</p>
</div>
</Html>
);
}
function SceneContent() {
return (
<>
<ScrollCamera />
<Environment preset="city" />
<ambientLight intensity={0.8} />
<directionalLight position={[5, 5, 5]} intensity={1.8} color="#ffffff" castShadow shadow-mapSize={[1024, 1024]} />
<spotLight position={[0, 8, 0]} intensity={1.2} angle={0.6} penumbra={0.5} color="#ffffff" />
<ScrollLighting />
<LightOrbs />
<RobotDisplay />
<ContactShadows position={[0, -1, 0]} opacity={0.25} scale={10} blur={2} far={4} resolution={256} color="#000000" />
</>
);
}
interface ScrollSceneProps {
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
}
export function ScrollScene({ scrollContainerRef }: ScrollSceneProps) {
useEffect(() => {
const handleScroll = () => {
const scrollTop = window.scrollY;
const container = scrollContainerRef.current;
if (!container) return;
const spacerHeight = container.offsetHeight;
const viewportHeight = window.innerHeight;
const maxScroll = spacerHeight - viewportHeight;
if (maxScroll > 0) {
scrollState.progress = Math.max(0, Math.min(1, scrollTop / maxScroll));
scrollState.inScrollZone = scrollTop <= spacerHeight;
}
};
window.addEventListener('scroll', handleScroll, { passive: true });
handleScroll();
return () => window.removeEventListener('scroll', handleScroll);
}, [scrollContainerRef]);
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 0,
pointerEvents: 'none',
}}
>
<Canvas
dpr={[1, 2]}
camera={{ position: [0, 1, 7], fov: 50 }}
gl={{ antialias: true, powerPreference: 'high-performance' }}
style={{
background: 'linear-gradient(180deg, #e8e8e4 0%, #f0f0ec 50%, #e8e8e4 100%)',
pointerEvents: 'auto',
}}
>
<Suspense fallback={<Loader />}>
<SceneContent />
</Suspense>
</Canvas>
</div>
);
}
useGLTF.preload('/Unitree_G1.glb');
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { SnapshotButton } from '@/components/SnapshotButton';
// Mock Three.js objects
const mockGL = {
render: vi.fn(),
domElement: {
toDataURL: vi.fn(() => 'data:image/png;base64,mock-image-data'),
},
};
const mockScene = {};
const mockCamera = {};
describe('SnapshotButton', () => {
beforeEach(() => {
vi.clearAllMocks();
global.URL.createObjectURL = vi.fn(() => 'blob:mock-url');
global.URL.revokeObjectURL = vi.fn();
});
it('renders snapshot button', () => {
render(<SnapshotButton gl={mockGL as any} scene={mockScene as any} camera={mockCamera as any} />);
expect(screen.getByRole('button', { name: /capture 3d scene snapshot/i })).toBeInTheDocument();
});
it('renders with custom filename', () => {
render(<SnapshotButton gl={mockGL as any} scene={mockScene as any} camera={mockCamera as any} filename="custom-robot" />);
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('calls onSnapshot callback after capture', () => {
const onSnapshot = vi.fn();
render(<SnapshotButton gl={mockGL as any} scene={mockScene as any} camera={mockCamera as any} onSnapshot={onSnapshot} />);
const button = screen.getByRole('button');
fireEvent.click(button);
// Check that onSnapshot was called (after async operations)
setTimeout(() => {
expect(onSnapshot).toHaveBeenCalled();
}, 0);
});
});
+108
View File
@@ -0,0 +1,108 @@
'use client';
import { useCallback, useState } from 'react';
import { useThree } from '@react-three/fiber';
import type { WebGLRenderer, Scene, Camera } from 'three';
interface SnapshotButtonInnerProps {
gl: WebGLRenderer;
scene: Scene;
camera: Camera;
filename?: string;
onSnapshot?: (dataUrl: string) => void;
}
function SnapshotButtonInner({ gl, scene, camera, filename = 'g1-robot', onSnapshot }: SnapshotButtonInnerProps) {
const [isCapturing, setIsCapturing] = useState(false);
const handleCapture = useCallback(() => {
setIsCapturing(true);
try {
gl.render(scene, camera);
const dataUrl = gl.domElement.toDataURL('image/png');
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const fullFilename = `${filename}-${timestamp}.png`;
const link = document.createElement('a');
link.download = fullFilename;
link.href = dataUrl;
link.click();
onSnapshot?.(dataUrl);
} catch (error) {
console.error('Failed to capture snapshot:', error);
} finally {
setIsCapturing(false);
}
}, [gl, scene, camera, filename, onSnapshot]);
return (
<button
onClick={handleCapture}
disabled={isCapturing}
style={{
padding: '0.5rem 1rem',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.3)',
borderRadius: '0.5rem',
color: '#f8fafc',
cursor: isCapturing ? 'wait' : 'pointer',
fontSize: '0.875rem',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isCapturing) {
e.currentTarget.style.backgroundColor = 'rgba(59, 130, 246, 0.2)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'rgba(59, 130, 246, 0.1)';
}}
aria-label="Capture 3D scene snapshot"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
{isCapturing ? 'Capturing...' : 'Snapshot'}
</button>
);
}
// Re-export the inner component for tests
export { SnapshotButtonInner as SnapshotButton };
/**
* Captures the R3F context then renders a fixed-position overlay button
* outside the 3D scene so it stays in place when the camera moves.
*/
export function SnapshotButtonWrapper({ filename = 'g1-robot', onSnapshot }: { filename?: string; onSnapshot?: (dataUrl: string) => void }) {
const { gl, scene, camera } = useThree();
return (
<SnapshotButtonInner
gl={gl}
scene={scene}
camera={camera}
filename={filename}
onSnapshot={onSnapshot}
/>
);
}
+65
View File
@@ -0,0 +1,65 @@
import * as THREE from 'three';
// Socket position names
export type SocketName = 'head' | 'chest' | 'left_shoulder' | 'right_shoulder' | 'back' | 'base';
// Socket position definitions relative to the robot model
export interface SocketTransform {
position: THREE.Vector3;
rotation: THREE.Euler;
}
// Calibrated from actual model debug data:
// Robot bounding box: y=-0.5 to y=1.5, x=-0.37 to x=0.37, z=-0.17 to z=0.17
// Torso: center y=0.984, width=0.315, depth=0.227
// Hands: center y=0.759, full span width=0.739
// Legs: center y=0.049, width=0.500
export const SOCKET_POSITIONS: Record<SocketName, SocketTransform> = {
head: {
position: new THREE.Vector3(0, 1.5, 0),
rotation: new THREE.Euler(0, 0, 0),
},
chest: {
position: new THREE.Vector3(0, 0.98, 0.12),
rotation: new THREE.Euler(0, 0, 0),
},
left_shoulder: {
position: new THREE.Vector3(-0.22, 1.2, 0),
rotation: new THREE.Euler(0, 0, 0),
},
right_shoulder: {
position: new THREE.Vector3(0.22, 1.2, 0),
rotation: new THREE.Euler(0, 0, 0),
},
back: {
position: new THREE.Vector3(0, 0.98, -0.15),
rotation: new THREE.Euler(0, Math.PI, 0),
},
base: {
position: new THREE.Vector3(0, 0.55, 0),
rotation: new THREE.Euler(0, 0, 0),
},
};
// Human-readable labels for each socket
export const SOCKET_LABELS: Record<SocketName, string> = {
head: 'Head',
chest: 'Chest',
left_shoulder: 'Left Shoulder',
right_shoulder: 'Right Shoulder',
back: 'Back',
base: 'Base',
};
// Get socket transform by name
export const getSocketTransform = (socketName: SocketName): SocketTransform => {
return SOCKET_POSITIONS[socketName] || SOCKET_POSITIONS.base;
};
// Check if a position string is a valid socket name
export const isValidSocket = (position: string): position is SocketName => {
return position in SOCKET_POSITIONS;
};
// All socket names as array
export const ALL_SOCKETS: SocketName[] = ['head', 'chest', 'left_shoulder', 'right_shoulder', 'back', 'base'];
@@ -0,0 +1,108 @@
'use client';
import { useCallback } from 'react';
import { useOrderStore, orderStore } from '@/store/useOrderStore';
import { configStore } from '@/store/useConfigStore';
function formatAED(price: number): string {
return new Intl.NumberFormat('en-AE').format(price);
}
export function ConfirmationStep() {
const orderId = useOrderStore((s) => s.orderId);
const orderTotal = useOrderStore((s) => s.orderTotal);
const personaSummary = useOrderStore((s) => s.personaSummary);
const shipping = useOrderStore((s) => s.shipping);
const handleNewOrder = useCallback(() => {
orderStore.getState().resetOrder();
configStore.getState().reset();
configStore.getState().setHydrated(true);
}, []);
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1.25rem', textAlign: 'center', padding: '1rem 0' }}>
{/* Success checkmark */}
<div style={{
width: '64px',
height: '64px',
borderRadius: '50%',
background: 'rgba(34, 197, 94, 0.08)',
border: '2px solid rgba(34, 197, 94, 0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<div>
<h3 style={{ fontSize: '1.25rem', fontWeight: 700, color: '#1a1a2e', margin: 0, marginBottom: '0.5rem' }}>
Order Confirmed!
</h3>
<p style={{ fontSize: '0.8rem', color: '#94a3b8', margin: 0 }}>
Thank you for your order. Your G1 Robot is being prepared.
</p>
</div>
{/* Order ID */}
<div style={{
padding: '0.75rem 1.5rem',
borderRadius: '0.5rem',
background: 'rgba(59, 130, 246, 0.04)',
border: '1px solid rgba(59, 130, 246, 0.2)',
}}>
<div style={{ fontSize: '0.65rem', color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.25rem' }}>
Order ID
</div>
<div style={{ fontSize: '1.1rem', fontWeight: 700, color: '#2563eb', fontFamily: 'monospace', letterSpacing: '0.05em' }}>
{orderId}
</div>
</div>
{/* Order details */}
<div style={{
width: '100%',
padding: '0.75rem',
borderRadius: '0.5rem',
background: 'rgba(248, 248, 246, 0.4)',
border: '1px solid rgba(0, 0, 0, 0.04)',
textAlign: 'left',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.75rem', color: '#94a3b8' }}>Configuration</span>
<span style={{ fontSize: '0.75rem', color: '#374151' }}>{personaSummary}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.75rem', color: '#94a3b8' }}>Ship to</span>
<span style={{ fontSize: '0.75rem', color: '#374151' }}>{shipping.name}, {shipping.city}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', paddingTop: '0.5rem', borderTop: '1px solid rgba(0, 0, 0, 0.06)' }}>
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: '#374151' }}>Total Paid</span>
<span style={{ fontSize: '0.85rem', fontWeight: 700, color: '#1a1a2e', fontFamily: 'monospace' }}>AED {formatAED(orderTotal)}</span>
</div>
</div>
{/* Actions */}
<button
onClick={handleNewOrder}
style={{
marginTop: '0.5rem',
padding: '0.75rem 1.5rem',
borderRadius: '0.375rem',
border: '1px solid rgba(59, 130, 246, 0.5)',
background: 'rgba(59, 130, 246, 0.08)',
color: '#2563eb',
cursor: 'pointer',
fontSize: '0.85rem',
fontWeight: 600,
transition: 'all 0.2s ease',
}}
>
Configure Another Robot
</button>
</div>
);
}
+182
View File
@@ -0,0 +1,182 @@
'use client';
import { useState, useCallback } from 'react';
import { orderStore, type PaymentInfo } from '@/store/useOrderStore';
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '0.6rem 0.75rem',
borderRadius: '0.375rem',
border: '1px solid rgba(0, 0, 0, 0.08)',
background: 'rgba(255, 255, 255, 0.8)',
color: '#1a1a2e',
fontSize: '0.8rem',
outline: 'none',
transition: 'border-color 0.2s ease',
};
const labelStyle: React.CSSProperties = {
fontSize: '0.7rem',
fontWeight: 500,
color: '#94a3b8',
marginBottom: '0.3rem',
display: 'block',
};
const errorStyle: React.CSSProperties = {
fontSize: '0.65rem',
color: '#ef4444',
marginTop: '0.2rem',
};
interface FormErrors {
[key: string]: string;
}
function formatCardNumber(value: string): string {
const digits = value.replace(/\D/g, '').slice(0, 16);
return digits.replace(/(\d{4})(?=\d)/g, '$1 ');
}
function formatExpiry(value: string): string {
const digits = value.replace(/\D/g, '').slice(0, 4);
if (digits.length > 2) {
return `${digits.slice(0, 2)}/${digits.slice(2)}`;
}
return digits;
}
export function PaymentStep() {
const [form, setForm] = useState<PaymentInfo>({
cardNumber: '',
expiry: '',
cvv: '',
nameOnCard: '',
});
const [errors, setErrors] = useState<FormErrors>({});
const handleChange = useCallback((field: keyof PaymentInfo, value: string) => {
let processed = value;
if (field === 'cardNumber') processed = formatCardNumber(value);
if (field === 'expiry') processed = formatExpiry(value);
if (field === 'cvv') processed = value.replace(/\D/g, '').slice(0, 3);
setForm((prev) => ({ ...prev, [field]: processed }));
setErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
}, []);
const validate = (): boolean => {
const errs: FormErrors = {};
const digits = form.cardNumber.replace(/\s/g, '');
if (digits.length < 16) errs.cardNumber = 'Enter a valid 16-digit card number';
if (form.expiry.length < 5) errs.expiry = 'Enter a valid expiry (MM/YY)';
if (form.cvv.length < 3) errs.cvv = 'Enter a valid 3-digit CVV';
if (!form.nameOnCard.trim()) errs.nameOnCard = 'Name on card is required';
setErrors(errs);
return Object.keys(errs).length === 0;
};
const handleSubmit = () => {
if (!validate()) return;
orderStore.getState().setPayment(form);
orderStore.getState().setStep('review');
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 600, color: '#1a1a2e', margin: 0 }}>
Payment Details
</h3>
<div style={{
padding: '0.6rem 0.75rem',
borderRadius: '0.375rem',
background: 'rgba(245, 158, 11, 0.06)',
border: '1px solid rgba(245, 158, 11, 0.15)',
fontSize: '0.7rem',
color: '#d97706',
}}>
This is a demo checkout. No real payment will be processed.
</div>
<div>
<label style={labelStyle}>Card Number</label>
<input
type="text"
value={form.cardNumber}
onChange={(e) => handleChange('cardNumber', e.target.value)}
placeholder="4242 4242 4242 4242"
style={{ ...inputStyle, fontFamily: 'monospace', letterSpacing: '0.1em', borderColor: errors.cardNumber ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = errors.cardNumber ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
/>
{errors.cardNumber && <div style={errorStyle}>{errors.cardNumber}</div>}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<div>
<label style={labelStyle}>Expiry Date</label>
<input
type="text"
value={form.expiry}
onChange={(e) => handleChange('expiry', e.target.value)}
placeholder="MM/YY"
style={{ ...inputStyle, fontFamily: 'monospace', borderColor: errors.expiry ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = errors.expiry ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
/>
{errors.expiry && <div style={errorStyle}>{errors.expiry}</div>}
</div>
<div>
<label style={labelStyle}>CVV</label>
<input
type="text"
value={form.cvv}
onChange={(e) => handleChange('cvv', e.target.value)}
placeholder="123"
style={{ ...inputStyle, fontFamily: 'monospace', borderColor: errors.cvv ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = errors.cvv ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
/>
{errors.cvv && <div style={errorStyle}>{errors.cvv}</div>}
</div>
</div>
<div>
<label style={labelStyle}>Name on Card</label>
<input
type="text"
value={form.nameOnCard}
onChange={(e) => handleChange('nameOnCard', e.target.value)}
placeholder="John Doe"
style={{ ...inputStyle, borderColor: errors.nameOnCard ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = errors.nameOnCard ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
/>
{errors.nameOnCard && <div style={errorStyle}>{errors.nameOnCard}</div>}
</div>
<button
onClick={handleSubmit}
style={{
marginTop: '0.5rem',
padding: '0.75rem',
borderRadius: '0.375rem',
border: '1px solid rgba(59, 130, 246, 0.5)',
background: 'rgba(59, 130, 246, 0.08)',
color: '#2563eb',
cursor: 'pointer',
fontSize: '0.85rem',
fontWeight: 600,
transition: 'all 0.2s ease',
}}
>
Review Order
</button>
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
'use client';
import { useState } from 'react';
import { useOrderStore, orderStore } from '@/store/useOrderStore';
function formatAED(price: number): string {
return new Intl.NumberFormat('en-AE').format(price);
}
export function ReviewStep() {
const shipping = useOrderStore((s) => s.shipping);
const orderTotal = useOrderStore((s) => s.orderTotal);
const personaSummary = useOrderStore((s) => s.personaSummary);
const colorSummary = useOrderStore((s) => s.colorSummary);
const [isProcessing, setIsProcessing] = useState(false);
const handlePlaceOrder = async () => {
setIsProcessing(true);
// Simulate payment processing delay
await new Promise((resolve) => setTimeout(resolve, 1500));
orderStore.getState().placeOrder();
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 600, color: '#1a1a2e', margin: 0 }}>
Review Your Order
</h3>
{/* Configuration Summary */}
<SummarySection title="Configuration">
<SummaryRow label="Persona" value={personaSummary} />
<SummaryRow label="Color" value={
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{
width: '14px',
height: '14px',
borderRadius: '3px',
backgroundColor: colorSummary,
border: '1px solid rgba(255,255,255,0.15)',
}} />
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem' }}>{colorSummary}</span>
</div>
} />
</SummarySection>
{/* Shipping Summary */}
<SummarySection title="Shipping To">
<div style={{ fontSize: '0.8rem', color: '#374151', lineHeight: 1.6 }}>
<div style={{ fontWeight: 500 }}>{shipping.name}</div>
<div>{shipping.address}</div>
<div>{shipping.city}, {shipping.country} {shipping.postalCode}</div>
<div style={{ color: '#94a3b8', marginTop: '0.25rem' }}>{shipping.email}</div>
<div style={{ color: '#94a3b8' }}>{shipping.phone}</div>
</div>
</SummarySection>
{/* Price Summary */}
<SummarySection title="Order Total">
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}>
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: '#374151' }}>Total</span>
<span style={{ fontSize: '1.1rem', fontWeight: 700, color: '#1a1a2e', fontFamily: 'monospace' }}>
AED {formatAED(orderTotal)}
</span>
</div>
</SummarySection>
{/* Place Order Button */}
<button
onClick={handlePlaceOrder}
disabled={isProcessing}
style={{
marginTop: '0.25rem',
padding: '0.85rem',
borderRadius: '0.375rem',
border: 'none',
background: isProcessing
? 'rgba(59, 130, 246, 0.2)'
: 'linear-gradient(135deg, #3b82f6, #2563eb)',
color: '#ffffff',
cursor: isProcessing ? 'wait' : 'pointer',
fontSize: '0.9rem',
fontWeight: 700,
transition: 'all 0.2s ease',
textAlign: 'center',
boxShadow: isProcessing ? 'none' : '0 4px 20px rgba(59, 130, 246, 0.2)',
}}
>
{isProcessing ? 'Processing Payment...' : 'Place Order'}
</button>
</div>
);
}
function SummarySection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div style={{
padding: '0.75rem',
borderRadius: '0.5rem',
background: 'rgba(248, 248, 246, 0.4)',
border: '1px solid rgba(0, 0, 0, 0.04)',
}}>
<div style={{
fontSize: '0.65rem',
fontWeight: 600,
color: '#64748b',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: '0.5rem',
}}>
{title}
</div>
{children}
</div>
);
}
function SummaryRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.25rem 0',
}}>
<span style={{ fontSize: '0.8rem', color: '#94a3b8' }}>{label}</span>
<span style={{ fontSize: '0.8rem', color: '#374151' }}>{value}</span>
</div>
);
}
+150
View File
@@ -0,0 +1,150 @@
'use client';
import { useState, useCallback } from 'react';
import { orderStore, type ShippingInfo } from '@/store/useOrderStore';
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '0.6rem 0.75rem',
borderRadius: '0.375rem',
border: '1px solid rgba(0, 0, 0, 0.08)',
background: 'rgba(255, 255, 255, 0.8)',
color: '#1a1a2e',
fontSize: '0.8rem',
outline: 'none',
transition: 'border-color 0.2s ease',
};
const labelStyle: React.CSSProperties = {
fontSize: '0.7rem',
fontWeight: 500,
color: '#94a3b8',
marginBottom: '0.3rem',
display: 'block',
};
const errorStyle: React.CSSProperties = {
fontSize: '0.65rem',
color: '#ef4444',
marginTop: '0.2rem',
};
interface FormErrors {
[key: string]: string;
}
function validateEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
export function ShippingStep() {
const [form, setForm] = useState<ShippingInfo>({
name: '',
email: '',
phone: '',
address: '',
city: '',
country: '',
postalCode: '',
});
const [errors, setErrors] = useState<FormErrors>({});
const handleChange = useCallback((field: keyof ShippingInfo, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
setErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
}, []);
const validate = (): boolean => {
const errs: FormErrors = {};
if (!form.name.trim()) errs.name = 'Name is required';
if (!form.email.trim()) errs.email = 'Email is required';
else if (!validateEmail(form.email)) errs.email = 'Invalid email format';
if (!form.phone.trim()) errs.phone = 'Phone is required';
if (!form.address.trim()) errs.address = 'Address is required';
if (!form.city.trim()) errs.city = 'City is required';
if (!form.country.trim()) errs.country = 'Country is required';
if (!form.postalCode.trim()) errs.postalCode = 'Postal code is required';
setErrors(errs);
return Object.keys(errs).length === 0;
};
const handleSubmit = () => {
if (!validate()) return;
orderStore.getState().setShipping(form);
orderStore.getState().setStep('payment');
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 600, color: '#1a1a2e', margin: 0 }}>
Shipping Information
</h3>
<Field label="Full Name" value={form.name} error={errors.name} onChange={(v) => handleChange('name', v)} placeholder="John Doe" />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<Field label="Email" value={form.email} error={errors.email} onChange={(v) => handleChange('email', v)} placeholder="john@example.com" type="email" />
<Field label="Phone" value={form.phone} error={errors.phone} onChange={(v) => handleChange('phone', v)} placeholder="+971 50 123 4567" type="tel" />
</div>
<Field label="Address" value={form.address} error={errors.address} onChange={(v) => handleChange('address', v)} placeholder="123 Sheikh Zayed Road" />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<Field label="City" value={form.city} error={errors.city} onChange={(v) => handleChange('city', v)} placeholder="Dubai" />
<Field label="Country" value={form.country} error={errors.country} onChange={(v) => handleChange('country', v)} placeholder="UAE" />
</div>
<Field label="Postal Code" value={form.postalCode} error={errors.postalCode} onChange={(v) => handleChange('postalCode', v)} placeholder="00000" />
<button
onClick={handleSubmit}
style={{
marginTop: '0.5rem',
padding: '0.75rem',
borderRadius: '0.375rem',
border: '1px solid rgba(59, 130, 246, 0.5)',
background: 'rgba(59, 130, 246, 0.08)',
color: '#2563eb',
cursor: 'pointer',
fontSize: '0.85rem',
fontWeight: 600,
transition: 'all 0.2s ease',
}}
>
Continue to Payment
</button>
</div>
);
}
function Field({ label, value, error, onChange, placeholder, type = 'text' }: {
label: string;
value: string;
error?: string;
onChange: (v: string) => void;
placeholder?: string;
type?: string;
}) {
return (
<div>
<label style={labelStyle}>{label}</label>
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
style={{
...inputStyle,
borderColor: error ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)',
}}
onFocus={(e) => { e.currentTarget.style.borderColor = error ? 'rgba(239, 68, 68, 0.7)' : 'rgba(59, 130, 246, 0.5)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = error ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
/>
{error && <div style={errorStyle}>{error}</div>}
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { configStore, serializeConfig, deserializeConfig } from '@/store/useConfigStore';
// Mock window.history
const mockReplaceState = vi.fn();
const mockPushState = vi.fn();
const mockAddEventListener = vi.fn();
const mockRemoveEventListener = vi.fn();
vi.stubGlobal('window', {
location: {
href: 'http://localhost:3000',
search: '',
},
history: {
replaceState: mockReplaceState,
pushState: mockPushState,
state: {},
},
addEventListener: mockAddEventListener,
removeEventListener: mockRemoveEventListener,
});
describe('URL Serialization', () => {
describe('serializeConfig', () => {
it('should serialize colors, persona, and payloads to base64', () => {
const encoded = serializeConfig({
activeColors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' },
activePersonaAttire: 'Emarati Kandura',
activePayloads: [{ id: 'cam1', type: 'camera', position: 'head' }],
isHydrated: true,
});
expect(typeof encoded).toBe('string');
// Verify it decodes correctly
const decoded = JSON.parse(atob(encoded));
expect(decoded.c.primary).toBe('#ff0000');
expect(decoded.p).toBe('Emarati Kandura');
expect(decoded.y[0].id).toBe('cam1');
});
});
describe('deserializeConfig', () => {
it('should decode base64 and return valid state', () => {
const original = {
activeColors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' },
activePersonaAttire: 'Industrial Vest',
activePayloads: [
{ id: 'cam1', type: 'camera', position: 'head' },
{ id: 'light1', type: 'light', position: 'arm' },
],
isHydrated: true,
};
const encoded = serializeConfig(original);
const decoded = deserializeConfig(encoded);
expect(decoded).not.toBeNull();
expect(decoded?.activeColors).toEqual(original.activeColors);
expect(decoded?.activePersonaAttire).toBe(original.activePersonaAttire);
expect(decoded?.activePayloads).toEqual(original.activePayloads);
});
it('should return null for malformed base64', () => {
expect(deserializeConfig('!!!invalid-base64!!!')).toBeNull();
});
it('should return null for valid base64 but invalid JSON', () => {
expect(deserializeConfig(btoa('not json'))).toBeNull();
});
it('should return null for valid JSON but wrong schema', () => {
expect(deserializeConfig(btoa(JSON.stringify({ wrong: 'schema' })))).toBeNull();
});
});
});
describe('Store integration with URL', () => {
beforeEach(() => {
vi.clearAllMocks();
configStore.getState().reset();
});
it('should create encoded URL with config', () => {
configStore.getState().setColors({ primary: '#ff0000' });
configStore.getState().setPersonaAttire('Test');
configStore.getState().addPayload({ id: 'p1', type: 'camera', position: 'head' });
const state = configStore.getState();
const encoded = serializeConfig(state);
expect(encoded).toBeTruthy();
expect(encoded.length).toBeGreaterThan(0);
});
it('should preserve state through serialize/deserialize cycle', () => {
configStore.getState().setColors({ primary: '#abc123', secondary: '#def456', accent: '#789abc' });
configStore.getState().setPersonaAttire('Emarati Kandura');
configStore.getState().addPayload({ id: 'cam1', type: 'PTZ Camera', position: 'head' });
configStore.getState().addPayload({ id: 'light1', type: 'LED Panel', position: 'chest' });
configStore.getState().setHydrated(true);
const state = configStore.getState();
const encoded = serializeConfig(state);
const decoded = deserializeConfig(encoded);
expect(decoded).not.toBeNull();
expect(decoded?.activeColors).toEqual(state.activeColors);
expect(decoded?.activePersonaAttire).toBe(state.activePersonaAttire);
expect(decoded?.activePayloads).toHaveLength(2);
});
});
+122
View File
@@ -0,0 +1,122 @@
'use client';
import { useEffect, useRef, useSyncExternalStore } from 'react';
import {
configStore,
serializeConfig,
deserializeConfig,
ConfigState,
} from '@/store/useConfigStore';
const URL_PARAM_KEY = 'config';
const DEBOUNCE_MS = 300;
function debounce<T extends (...args: Parameters<T>) => void>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
fn(...args);
timeoutId = null;
}, delay);
};
}
export const useUrlSync = () => {
const stateRef = useRef<ConfigState | null>(null);
// Subscribe to store changes for hydration status
const isHydrated = useSyncExternalStore(
(callback) => configStore.subscribe(callback),
() => configStore.getState().isHydrated,
() => false // Server-side always returns false
);
// Hydrate store from URL on mount
useEffect(() => {
if (typeof window === 'undefined') return;
const url = new URL(window.location.href);
const encodedConfig = url.searchParams.get(URL_PARAM_KEY);
if (encodedConfig) {
const config = deserializeConfig(encodedConfig);
if (config) {
configStore.getState().hydrate(config);
// Clean URL after hydration using replaceState
url.searchParams.delete(URL_PARAM_KEY);
window.history.replaceState({}, '', url.toString());
return;
}
}
// No valid config in URL, mark as hydrated with defaults
configStore.getState().hydrate({});
}, []);
// Subscribe to store changes and update URL (debounced)
useEffect(() => {
if (typeof window === 'undefined') return;
const updateUrl = () => {
const state = stateRef.current;
if (!state || !state.isHydrated) return;
const encoded = serializeConfig(state);
const url = new URL(window.location.href);
url.searchParams.set(URL_PARAM_KEY, encoded);
window.history.pushState({}, '', url.toString());
};
const debouncedUpdateUrl = debounce(updateUrl, DEBOUNCE_MS);
// Store current state and subscribe for updates
stateRef.current = configStore.getState();
const unsubscribe = configStore.subscribe((newState) => {
stateRef.current = newState;
if (newState.isHydrated) {
debouncedUpdateUrl();
}
});
return unsubscribe;
}, []);
// Handle browser back/forward navigation
useEffect(() => {
if (typeof window === 'undefined') return;
const handlePopState = () => {
const url = new URL(window.location.href);
const encodedConfig = url.searchParams.get(URL_PARAM_KEY);
if (encodedConfig) {
const config = deserializeConfig(encodedConfig);
if (config) {
configStore.setState({ ...config, isHydrated: true });
}
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
return { isHydrated };
};
// Hook to get shareable URL
export const useShareableUrl = () => {
const state = configStore.getState();
const encoded = serializeConfig(state);
const url = new URL(window.location.href);
url.searchParams.set(URL_PARAM_KEY, encoded);
return url.toString();
};
+31
View File
@@ -0,0 +1,31 @@
'use client';
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import en from './locales/en.json';
import ar from './locales/ar.json';
const resources = {
en: { translation: en },
ar: { translation: ar },
};
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'en',
supportedLngs: ['en', 'ar'],
interpolation: {
escapeValue: false,
},
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
},
});
export default i18n;
+149
View File
@@ -0,0 +1,149 @@
{
"app": {
"title": "مُكوِّن G1",
"subtitle": "روبوتيكس لوتاه"
},
"loading": {
"configuration": "جاري تحميل التكوين...",
"scene": "جاري تهيئة المشهد ثلاثي الأبعاد..."
},
"panel": {
"title": "التكوين",
"collapse": "طي اللوحة",
"expand": "توسيع اللوحة",
"currentConfiguration": "التكوين الحالي",
"persona": "الشخصية",
"payloads": "الأحمولة",
"none": "لا شيء"
},
"controls": {
"snapshot": "لقطة",
"capturing": "جاري الالتقاط...",
"share": "مشاركة",
"copied": "تم نسخ الرابط!",
"copyFailed": "فشل النسخ"
},
"config": {
"colors": "الألوان",
"primary": "أساسي",
"secondary": "ثانوي",
"accent": "تمييز"
},
"pricing": {
"title": "تفاصيل السعر",
"base": "روبوت G1 الأساسي",
"customColor": "لون مخصص",
"total": "الإجمالي",
"proceed": "متابعة الطلب"
},
"checkout": {
"title": "الدفع",
"back": "رجوع",
"close": "إغلاق الدفع",
"shipping": {
"title": "معلومات الشحن",
"name": "الاسم الكامل",
"email": "البريد الإلكتروني",
"phone": "الهاتف",
"address": "العنوان",
"city": "المدينة",
"country": "الدولة",
"postalCode": "الرمز البريدي",
"continue": "متابعة إلى الدفع"
},
"payment": {
"title": "تفاصيل الدفع",
"demoNotice": "هذا عرض تجريبي. لن يتم معالجة أي دفع حقيقي.",
"cardNumber": "رقم البطاقة",
"expiry": "تاريخ الانتهاء",
"cvv": "رمز الأمان",
"nameOnCard": "الاسم على البطاقة",
"continue": "مراجعة الطلب"
},
"review": {
"title": "مراجعة طلبك",
"configuration": "التكوين",
"persona": "الشخصية",
"color": "اللون",
"shippingTo": "الشحن إلى",
"orderTotal": "إجمالي الطلب",
"total": "الإجمالي",
"placeOrder": "تأكيد الطلب",
"processing": "جاري معالجة الدفع..."
},
"confirmation": {
"title": "تم تأكيد الطلب!",
"message": "شكراً لطلبك. يتم تحضير روبوت G1 الخاص بك.",
"orderId": "رقم الطلب",
"configuration": "التكوين",
"shipTo": "الشحن إلى",
"totalPaid": "المبلغ المدفوع",
"newOrder": "تكوين روبوت آخر"
},
"steps": {
"shipping": "الشحن",
"payment": "الدفع",
"review": "المراجعة"
}
},
"validation": {
"required": "هذا الحقل مطلوب",
"invalidEmail": "صيغة البريد الإلكتروني غير صحيحة",
"invalidCard": "أدخل رقم بطاقة صالح من 16 رقم",
"invalidExpiry": "أدخل تاريخ انتهاء صالح",
"invalidCvv": "أدخل رمز أمان صالح من 3 أرقام",
"nameRequired": "الاسم مطلوب",
"emailRequired": "البريد الإلكتروني مطلوب",
"phoneRequired": "الهاتف مطلوب",
"addressRequired": "العنوان مطلوب",
"cityRequired": "المدينة مطلوبة",
"countryRequired": "الدولة مطلوبة",
"postalRequired": "الرمز البريدي مطلوب",
"cardNameRequired": "الاسم على البطاقة مطلوب"
},
"landing": {
"hero": {
"overline": "نقدّم لكم G1",
"title": "مستقبل الروبوتات",
"subtitle": "اكتشف روبوت G1 البشري من لوتاه روبوتيكس. قابل للتخصيص بالكامل، جاهز للمؤسسات، ومصمم لعالم الغد.",
"cta": "صمّم روبوتك G1",
"scroll": "اكتشف"
},
"showcase": {
"overline": "القدرات",
"title": "مصمّم للتميّز",
"cta": "ابدأ التكوين"
},
"features": {
"attire": {
"title": "أزياء قابلة للتخصيص",
"description": "ألبس روبوت G1 لأي مناسبة — من الكندورة الإماراتية التقليدية إلى بدلات الأعمال وملابس السلامة الصناعية."
},
"mobility": {
"title": "حركة متقدمة",
"description": "نظام حركة متطور بـ 23 درجة حرية، يمكّن من الحركة الطبيعية والتوازن كالإنسان."
},
"enterprise": {
"title": "جاهز للمؤسسات",
"description": "مصمم للضيافة والتجزئة والأمن والبيئات المؤسسية مع واجهات برمجة قوية وإدارة الأسطول."
},
"ai": {
"title": "ذكاء اصطناعي متقدم",
"description": "أنظمة رؤية وكلام واتخاذ قرار مدمجة مدعومة بأحدث تطورات الذكاء الاصطناعي."
}
},
"configurator": {
"title": "صمّم روبوتك G1"
}
},
"admin": {
"title": "لوحة الأسعار",
"subtitle": "تعديل أسعار مكوّن روبوت G1",
"backToConfigurator": "العودة للمكوّن",
"item": "العنصر",
"price": "السعر (درهم)",
"save": "حفظ الأسعار",
"saved": "تم الحفظ!",
"resetDefaults": "إعادة تعيين الافتراضي"
}
}
+149
View File
@@ -0,0 +1,149 @@
{
"app": {
"title": "G1 Configurator",
"subtitle": "Lootah Robotics"
},
"loading": {
"configuration": "Loading configuration...",
"scene": "Initializing 3D Scene..."
},
"panel": {
"title": "Configuration",
"collapse": "Collapse panel",
"expand": "Expand panel",
"currentConfiguration": "Current Configuration",
"persona": "Persona",
"payloads": "Payloads",
"none": "None"
},
"controls": {
"snapshot": "Snapshot",
"capturing": "Capturing...",
"share": "Share",
"copied": "Link Copied!",
"copyFailed": "Copy failed"
},
"config": {
"colors": "Colors",
"primary": "Primary",
"secondary": "Secondary",
"accent": "Accent"
},
"pricing": {
"title": "Price Breakdown",
"base": "G1 Robot Base",
"customColor": "Custom Color",
"total": "Total",
"proceed": "Proceed to Order"
},
"checkout": {
"title": "Checkout",
"back": "Back",
"close": "Close checkout",
"shipping": {
"title": "Shipping Information",
"name": "Full Name",
"email": "Email",
"phone": "Phone",
"address": "Address",
"city": "City",
"country": "Country",
"postalCode": "Postal Code",
"continue": "Continue to Payment"
},
"payment": {
"title": "Payment Details",
"demoNotice": "This is a demo checkout. No real payment will be processed.",
"cardNumber": "Card Number",
"expiry": "Expiry Date",
"cvv": "CVV",
"nameOnCard": "Name on Card",
"continue": "Review Order"
},
"review": {
"title": "Review Your Order",
"configuration": "Configuration",
"persona": "Persona",
"color": "Color",
"shippingTo": "Shipping To",
"orderTotal": "Order Total",
"total": "Total",
"placeOrder": "Place Order",
"processing": "Processing Payment..."
},
"confirmation": {
"title": "Order Confirmed!",
"message": "Thank you for your order. Your G1 Robot is being prepared.",
"orderId": "Order ID",
"configuration": "Configuration",
"shipTo": "Ship to",
"totalPaid": "Total Paid",
"newOrder": "Configure Another Robot"
},
"steps": {
"shipping": "Shipping",
"payment": "Payment",
"review": "Review"
}
},
"validation": {
"required": "This field is required",
"invalidEmail": "Invalid email format",
"invalidCard": "Enter a valid 16-digit card number",
"invalidExpiry": "Enter a valid expiry (MM/YY)",
"invalidCvv": "Enter a valid 3-digit CVV",
"nameRequired": "Name is required",
"emailRequired": "Email is required",
"phoneRequired": "Phone is required",
"addressRequired": "Address is required",
"cityRequired": "City is required",
"countryRequired": "Country is required",
"postalRequired": "Postal code is required",
"cardNameRequired": "Name on card is required"
},
"landing": {
"hero": {
"overline": "Introducing the G1",
"title": "The Future of Robotics",
"subtitle": "Experience the G1 Humanoid Robot by Lootah Robotics. Fully customizable, enterprise-ready, and designed for the world of tomorrow.",
"cta": "Configure Your G1",
"scroll": "Discover"
},
"showcase": {
"overline": "Capabilities",
"title": "Engineered for Excellence",
"cta": "Start Configuring"
},
"features": {
"attire": {
"title": "Customizable Attire",
"description": "Dress your G1 for any occasion — from traditional Emarati Kandura to professional business attire and industrial safety gear."
},
"mobility": {
"title": "Advanced Mobility",
"description": "State-of-the-art locomotion system with 23 degrees of freedom, enabling natural human-like movement and balance."
},
"enterprise": {
"title": "Enterprise Ready",
"description": "Built for hospitality, retail, security, and corporate environments with robust APIs and fleet management capabilities."
},
"ai": {
"title": "AI-Powered Intelligence",
"description": "Integrated vision, speech, and decision-making systems powered by the latest advances in artificial intelligence."
}
},
"configurator": {
"title": "Configure Your G1"
}
},
"admin": {
"title": "Pricing Dashboard",
"subtitle": "Edit prices for the G1 Robot Configurator",
"backToConfigurator": "Back to Configurator",
"item": "Item",
"price": "Price (AED)",
"save": "Save Prices",
"saved": "Saved!",
"resetDefaults": "Reset to Defaults"
}
}
+210
View File
@@ -0,0 +1,210 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { configStore, ConfigState, Payload, serializeConfig, deserializeConfig } from '@/store/useConfigStore';
describe('useConfigStore (vanilla)', () => {
beforeEach(() => {
configStore.getState().reset();
});
describe('Initial State', () => {
it('should have correct default values', () => {
const state = configStore.getState();
expect(state.activeColors).toEqual({
primary: '#96a2b6',
secondary: '#1e293b',
accent: '#f59e0b',
});
expect(state.activePersonaAttire).toBe('none');
expect(state.activePayloads).toEqual([]);
expect(state.isHydrated).toBe(false);
});
});
describe('setColors', () => {
it('should update primary color', () => {
configStore.getState().setColors({ primary: '#ff0000' });
const state = configStore.getState();
expect(state.activeColors.primary).toBe('#ff0000');
expect(state.activeColors.secondary).toBe('#1e293b');
expect(state.activeColors.accent).toBe('#f59e0b');
});
it('should update multiple colors at once', () => {
configStore.getState().setColors({
primary: '#ff0000',
secondary: '#00ff00',
accent: '#0000ff',
});
expect(configStore.getState().activeColors).toEqual({
primary: '#ff0000',
secondary: '#00ff00',
accent: '#0000ff',
});
});
});
describe('setPersonaAttire', () => {
it('should update persona attire', () => {
configStore.getState().setPersonaAttire('Emarati Kandura');
expect(configStore.getState().activePersonaAttire).toBe('Emarati Kandura');
});
});
describe('addPayload', () => {
it('should add a new payload', () => {
const payload: Payload = { id: 'cam1', type: 'camera', position: 'head' };
configStore.getState().addPayload(payload);
expect(configStore.getState().activePayloads).toHaveLength(1);
expect(configStore.getState().activePayloads[0]).toEqual(payload);
});
it('should not add duplicate payload by id', () => {
const payload: Payload = { id: 'cam1', type: 'camera', position: 'head' };
configStore.getState().addPayload(payload);
configStore.getState().addPayload(payload);
expect(configStore.getState().activePayloads).toHaveLength(1);
});
});
describe('removePayload', () => {
it('should remove a payload by id', () => {
const payload: Payload = { id: 'cam1', type: 'camera', position: 'head' };
configStore.getState().addPayload(payload);
expect(configStore.getState().activePayloads).toHaveLength(1);
configStore.getState().removePayload('cam1');
expect(configStore.getState().activePayloads).toHaveLength(0);
});
});
describe('updatePayload', () => {
it('should update an existing payload', () => {
const payload: Payload = { id: 'cam1', type: 'camera', position: 'head' };
configStore.getState().addPayload(payload);
configStore.getState().updatePayload('cam1', { position: 'chest' });
expect(configStore.getState().activePayloads[0].position).toBe('chest');
});
});
describe('clearPayloads', () => {
it('should remove all payloads', () => {
configStore.getState().addPayload({ id: 'cam1', type: 'camera', position: 'head' });
configStore.getState().addPayload({ id: 'cam2', type: 'camera', position: 'chest' });
expect(configStore.getState().activePayloads).toHaveLength(2);
configStore.getState().clearPayloads();
expect(configStore.getState().activePayloads).toHaveLength(0);
});
});
describe('reset', () => {
it('should reset all state to defaults', () => {
configStore.getState().setColors({ primary: '#ff0000' });
configStore.getState().setPersonaAttire('Industrial Vest');
configStore.getState().addPayload({ id: 'cam1', type: 'camera', position: 'head' });
configStore.getState().setHydrated(true);
configStore.getState().reset();
const state = configStore.getState();
expect(state.activeColors.primary).toBe('#96a2b6');
expect(state.activePersonaAttire).toBe('none');
expect(state.activePayloads).toEqual([]);
expect(state.isHydrated).toBe(false);
});
});
describe('hydrate', () => {
it('should hydrate state from partial config', () => {
configStore.getState().hydrate({
activeColors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' },
activePersonaAttire: 'Emarati Kandura',
});
const state = configStore.getState();
expect(state.activeColors.primary).toBe('#ff0000');
expect(state.activePersonaAttire).toBe('Emarati Kandura');
expect(state.isHydrated).toBe(true);
});
});
});
describe('serializeConfig & deserializeConfig', () => {
beforeEach(() => {
configStore.getState().reset();
});
it('should serialize state to base64', () => {
const state: ConfigState = {
activeColors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' },
activePersonaAttire: 'Test Attire',
activePayloads: [{ id: 'p1', type: 'camera', position: 'head' }],
isHydrated: true,
};
const encoded = serializeConfig(state);
expect(typeof encoded).toBe('string');
expect(encoded.length).toBeGreaterThan(0);
});
it('should deserialize base64 to state', () => {
const state: ConfigState = {
activeColors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' },
activePersonaAttire: 'Test Attire',
activePayloads: [{ id: 'p1', type: 'camera', position: 'head' }],
isHydrated: true,
};
const encoded = serializeConfig(state);
const decoded = deserializeConfig(encoded);
expect(decoded).not.toBeNull();
expect(decoded?.activeColors).toEqual(state.activeColors);
expect(decoded?.activePersonaAttire).toBe(state.activePersonaAttire);
expect(decoded?.activePayloads).toEqual(state.activePayloads);
});
it('should return null for invalid base64', () => {
const result = deserializeConfig('not-valid-base64!!!');
expect(result).toBeNull();
});
it('should return null for invalid schema', () => {
const invalidJson = btoa(JSON.stringify({ invalid: 'schema' }));
const result = deserializeConfig(invalidJson);
expect(result).toBeNull();
});
it('should round-trip complex state correctly', () => {
const original: ConfigState = {
activeColors: { primary: '#aabbcc', secondary: '#ddeeff', accent: '#112233' },
activePersonaAttire: 'Emarati Kandura',
activePayloads: [
{ id: 'cam1', type: 'PTZ Camera', position: 'head' },
{ id: 'light1', type: 'LED Array', position: 'chest' },
{ id: 'sensor1', type: 'Lidar', position: 'base' },
],
isHydrated: true,
};
const encoded = serializeConfig(original);
const decoded = deserializeConfig(encoded);
expect(decoded).not.toBeNull();
expect(decoded?.activeColors).toEqual(original.activeColors);
expect(decoded?.activePersonaAttire).toBe(original.activePersonaAttire);
expect(decoded?.activePayloads).toHaveLength(3);
expect(decoded?.activePayloads).toEqual(original.activePayloads);
});
});
+228
View File
@@ -0,0 +1,228 @@
import { createStore } from 'zustand/vanilla';
// Type definitions
export interface ColorConfig {
primary: string;
secondary: string;
accent: string;
}
export interface Payload {
id: string;
type: string;
position: string;
}
export interface ConfigState {
activeColors: ColorConfig;
activePersonaAttire: string;
activePayloads: Payload[];
isHydrated: boolean;
}
export interface ConfigActions {
setColors: (colors: Partial<ColorConfig>) => void;
setPersonaAttire: (attire: string) => void;
addPayload: (payload: Payload) => void;
removePayload: (payloadId: string) => void;
updatePayload: (payloadId: string, updates: Partial<Payload>) => void;
clearPayloads: () => void;
reset: () => void;
setHydrated: (hydrated: boolean) => void;
hydrate: (state: Partial<ConfigState>) => void;
}
export type ConfigStore = ConfigState & ConfigActions;
// Default state values
const defaultColors: ColorConfig = {
primary: '#96a2b6',
secondary: '#1e293b',
accent: '#f59e0b',
};
const defaultState: ConfigState = {
activeColors: defaultColors,
activePersonaAttire: 'none',
activePayloads: [],
isHydrated: false,
};
// Create the vanilla Zustand store
export const configStore = createStore<ConfigStore>((set) => ({
...defaultState,
setColors: (colors: Partial<ColorConfig>) => {
set((state) => ({
activeColors: { ...state.activeColors, ...colors },
}));
},
setPersonaAttire: (attire: string) => {
set({ activePersonaAttire: attire });
},
addPayload: (payload: Payload) => {
set((state) => {
if (state.activePayloads.some((p) => p.id === payload.id)) {
return state;
}
return {
activePayloads: [...state.activePayloads, payload],
};
});
},
removePayload: (payloadId: string) => {
set((state) => ({
activePayloads: state.activePayloads.filter((p) => p.id !== payloadId),
}));
},
updatePayload: (payloadId: string, updates: Partial<Payload>) => {
set((state) => ({
activePayloads: state.activePayloads.map((p) =>
p.id === payloadId ? { ...p, ...updates } : p
),
}));
},
clearPayloads: () => {
set({ activePayloads: [] });
},
reset: () => {
set(defaultState);
},
setHydrated: (hydrated: boolean) => {
set({ isHydrated: hydrated });
},
hydrate: (state: Partial<ConfigState>) => {
set((current) => ({
...current,
...state,
isHydrated: true,
}));
},
}));
// React hook wrapper
import { useSyncExternalStore } from 'react';
export const useConfigStore = <T>(selector: (state: ConfigStore) => T): T => {
return useSyncExternalStore(
configStore.subscribe,
() => selector(configStore.getState()),
() => selector(configStore.getState())
);
};
// Direct selector hooks for optimized re-renders
export const useActiveColors = () =>
useConfigStore((state) => state.activeColors);
export const usePersonaAttire = () =>
useConfigStore((state) => state.activePersonaAttire);
export const usePayloads = () =>
useConfigStore((state) => state.activePayloads);
export const useIsHydrated = () =>
useConfigStore((state) => state.isHydrated);
// Memoized state serializer for URL encoding
export const serializeConfig = (state: ConfigState): string => {
const data = {
c: state.activeColors,
p: state.activePersonaAttire,
y: state.activePayloads,
};
return btoa(JSON.stringify(data));
};
// State schema validator
export const validateConfigSchema = (data: unknown): data is ConfigState => {
if (typeof data !== 'object' || data === null) return false;
const obj = data as Record<string, unknown>;
if (typeof obj.activeColors !== 'object' || obj.activeColors === null) return false;
const colors = obj.activeColors as Record<string, unknown>;
if (
typeof colors.primary !== 'string' ||
typeof colors.secondary !== 'string' ||
typeof colors.accent !== 'string'
) {
return false;
}
if (typeof obj.activePersonaAttire !== 'string') return false;
if (!Array.isArray(obj.activePayloads)) return false;
for (const payload of obj.activePayloads) {
if (typeof payload !== 'object' || payload === null) return false;
const p = payload as Record<string, unknown>;
if (
typeof p.id !== 'string' ||
typeof p.type !== 'string' ||
typeof p.position !== 'string'
) {
return false;
}
}
return true;
};
// Deserializer for URL decoding
export const deserializeConfig = (encoded: string): Partial<ConfigState> | null => {
try {
const decoded = atob(encoded);
const data = JSON.parse(decoded);
// Data uses abbreviated keys: c=colors, p=persona, y=payloads
if (
typeof data !== 'object' ||
data === null ||
!data.c ||
!data.p ||
!Array.isArray(data.y)
) {
console.error('Invalid config data structure');
return null;
}
const colors = data.c as Record<string, string>;
if (
typeof colors.primary !== 'string' ||
typeof colors.secondary !== 'string' ||
typeof colors.accent !== 'string'
) {
console.error('Invalid color config');
return null;
}
const payloads = data.y as Array<Record<string, string>>;
for (const payload of payloads) {
if (
typeof payload.id !== 'string' ||
typeof payload.type !== 'string' ||
typeof payload.position !== 'string'
) {
console.error('Invalid payload structure');
return null;
}
}
return {
activeColors: data.c,
activePersonaAttire: data.p,
activePayloads: data.y,
};
} catch (error) {
console.error('Failed to deserialize config:', error);
return null;
}
};
+150
View File
@@ -0,0 +1,150 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { orderStore } from '@/store/useOrderStore';
describe('useOrderStore', () => {
beforeEach(() => {
orderStore.getState().resetOrder();
});
describe('Initial State', () => {
it('should start at config step', () => {
expect(orderStore.getState().step).toBe('config');
});
it('should have empty shipping info', () => {
const { shipping } = orderStore.getState();
expect(shipping.name).toBe('');
expect(shipping.email).toBe('');
expect(shipping.phone).toBe('');
});
it('should have empty payment info', () => {
const { payment } = orderStore.getState();
expect(payment.cardNumber).toBe('');
expect(payment.cvv).toBe('');
});
it('should have no order ID', () => {
expect(orderStore.getState().orderId).toBe('');
});
});
describe('setStep', () => {
it('should transition to shipping', () => {
orderStore.getState().setStep('shipping');
expect(orderStore.getState().step).toBe('shipping');
});
it('should transition through full checkout flow', () => {
orderStore.getState().setStep('shipping');
expect(orderStore.getState().step).toBe('shipping');
orderStore.getState().setStep('payment');
expect(orderStore.getState().step).toBe('payment');
orderStore.getState().setStep('review');
expect(orderStore.getState().step).toBe('review');
});
});
describe('setShipping', () => {
it('should store shipping information', () => {
orderStore.getState().setShipping({
name: 'John Doe',
email: 'john@example.com',
phone: '+971501234567',
address: '123 Sheikh Zayed Road',
city: 'Dubai',
country: 'UAE',
postalCode: '00000',
});
const { shipping } = orderStore.getState();
expect(shipping.name).toBe('John Doe');
expect(shipping.email).toBe('john@example.com');
expect(shipping.city).toBe('Dubai');
});
});
describe('setPayment', () => {
it('should store payment information', () => {
orderStore.getState().setPayment({
cardNumber: '4242 4242 4242 4242',
expiry: '12/28',
cvv: '123',
nameOnCard: 'John Doe',
});
const { payment } = orderStore.getState();
expect(payment.cardNumber).toBe('4242 4242 4242 4242');
expect(payment.expiry).toBe('12/28');
expect(payment.nameOnCard).toBe('John Doe');
});
});
describe('setOrderTotal', () => {
it('should set the order total', () => {
orderStore.getState().setOrderTotal(265000);
expect(orderStore.getState().orderTotal).toBe(265000);
});
});
describe('setConfigSummary', () => {
it('should store persona and color summary', () => {
orderStore.getState().setConfigSummary('Emarati Kandura', '#ff0000');
expect(orderStore.getState().personaSummary).toBe('Emarati Kandura');
expect(orderStore.getState().colorSummary).toBe('#ff0000');
});
});
describe('placeOrder', () => {
it('should generate an order ID', () => {
orderStore.getState().placeOrder();
const { orderId } = orderStore.getState();
expect(orderId).toBeTruthy();
expect(orderId.startsWith('LR-G1-')).toBe(true);
});
it('should transition to confirmed step', () => {
orderStore.getState().placeOrder();
expect(orderStore.getState().step).toBe('confirmed');
});
it('should generate unique order IDs', () => {
orderStore.getState().placeOrder();
const id1 = orderStore.getState().orderId;
orderStore.getState().resetOrder();
orderStore.getState().placeOrder();
const id2 = orderStore.getState().orderId;
expect(id1).not.toBe(id2);
});
});
describe('resetOrder', () => {
it('should reset all state to defaults', () => {
orderStore.getState().setStep('payment');
orderStore.getState().setShipping({
name: 'Test',
email: 'test@test.com',
phone: '123',
address: 'addr',
city: 'city',
country: 'country',
postalCode: '000',
});
orderStore.getState().setOrderTotal(999999);
orderStore.getState().placeOrder();
orderStore.getState().resetOrder();
expect(orderStore.getState().step).toBe('config');
expect(orderStore.getState().shipping.name).toBe('');
expect(orderStore.getState().orderId).toBe('');
expect(orderStore.getState().orderTotal).toBe(0);
});
});
});
+108
View File
@@ -0,0 +1,108 @@
import { createStore } from 'zustand/vanilla';
import { useSyncExternalStore } from 'react';
export type CheckoutStep = 'config' | 'shipping' | 'payment' | 'review' | 'confirmed';
export interface ShippingInfo {
name: string;
email: string;
phone: string;
address: string;
city: string;
country: string;
postalCode: string;
}
export interface PaymentInfo {
cardNumber: string;
expiry: string;
cvv: string;
nameOnCard: string;
}
export interface OrderState {
step: CheckoutStep;
shipping: ShippingInfo;
payment: PaymentInfo;
orderId: string;
orderTotal: number;
personaSummary: string;
colorSummary: string;
}
export interface OrderActions {
setStep: (step: CheckoutStep) => void;
setShipping: (shipping: ShippingInfo) => void;
setPayment: (payment: PaymentInfo) => void;
setOrderTotal: (total: number) => void;
setConfigSummary: (persona: string, color: string) => void;
placeOrder: () => void;
resetOrder: () => void;
}
export type OrderStore = OrderState & OrderActions;
const emptyShipping: ShippingInfo = {
name: '',
email: '',
phone: '',
address: '',
city: '',
country: '',
postalCode: '',
};
const emptyPayment: PaymentInfo = {
cardNumber: '',
expiry: '',
cvv: '',
nameOnCard: '',
};
const defaultState: OrderState = {
step: 'config',
shipping: emptyShipping,
payment: emptyPayment,
orderId: '',
orderTotal: 0,
personaSummary: '',
colorSummary: '',
};
function generateOrderId(): string {
const timestamp = Date.now().toString(36).toUpperCase();
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
return `LR-G1-${timestamp}${random}`;
}
export const orderStore = createStore<OrderStore>((set) => ({
...defaultState,
setStep: (step: CheckoutStep) => set({ step }),
setShipping: (shipping: ShippingInfo) => set({ shipping }),
setPayment: (payment: PaymentInfo) => set({ payment }),
setOrderTotal: (total: number) => set({ orderTotal: total }),
setConfigSummary: (persona: string, color: string) => set({
personaSummary: persona,
colorSummary: color,
}),
placeOrder: () => set({
orderId: generateOrderId(),
step: 'confirmed',
}),
resetOrder: () => set({ ...defaultState }),
}));
export const useOrderStore = <T>(selector: (state: OrderStore) => T): T => {
return useSyncExternalStore(
orderStore.subscribe,
() => selector(orderStore.getState()),
() => selector(orderStore.getState())
);
};
+115
View File
@@ -0,0 +1,115 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { pricingStore } from '@/store/usePricingStore';
const mockStorage: Record<string, string> = {};
vi.stubGlobal('localStorage', {
getItem: vi.fn((key: string) => mockStorage[key] ?? null),
setItem: vi.fn((key: string, value: string) => { mockStorage[key] = value; }),
removeItem: vi.fn((key: string) => { delete mockStorage[key]; }),
clear: vi.fn(() => { Object.keys(mockStorage).forEach((k) => delete mockStorage[k]); }),
});
describe('usePricingStore', () => {
beforeEach(() => {
Object.keys(mockStorage).forEach((k) => delete mockStorage[k]);
pricingStore.getState().resetPrices();
});
describe('Default Prices', () => {
it('should have 5 default pricing items', () => {
const items = pricingStore.getState().items;
expect(items).toHaveLength(5);
});
it('should have correct default base price', () => {
const base = pricingStore.getState().items.find((i) => i.id === 'base');
expect(base).toBeDefined();
expect(base!.price).toBe(250000);
});
it('should have correct default attire prices', () => {
const items = pricingStore.getState().items;
const kandura = items.find((i) => i.id === 'emarati-kandura');
const vest = items.find((i) => i.id === 'industrial-vest');
const suit = items.find((i) => i.id === 'business-suit');
expect(kandura!.price).toBe(15000);
expect(vest!.price).toBe(8500);
expect(suit!.price).toBe(12000);
});
it('should have correct default custom color price', () => {
const color = pricingStore.getState().items.find((i) => i.id === 'custom-color');
expect(color!.price).toBe(3500);
});
});
describe('updatePrice', () => {
it('should update a single item price', () => {
pricingStore.getState().updatePrice('base', 300000);
const base = pricingStore.getState().items.find((i) => i.id === 'base');
expect(base!.price).toBe(300000);
});
it('should not affect other items when updating one', () => {
pricingStore.getState().updatePrice('base', 300000);
const kandura = pricingStore.getState().items.find((i) => i.id === 'emarati-kandura');
expect(kandura!.price).toBe(15000);
});
it('should persist to localStorage after update', () => {
pricingStore.getState().updatePrice('base', 999999);
expect(localStorage.setItem).toHaveBeenCalled();
const stored = JSON.parse(mockStorage['lootah-pricing']);
const base = stored.find((i: { id: string }) => i.id === 'base');
expect(base.price).toBe(999999);
});
});
describe('resetPrices', () => {
it('should restore all prices to defaults', () => {
pricingStore.getState().updatePrice('base', 1);
pricingStore.getState().updatePrice('emarati-kandura', 2);
pricingStore.getState().resetPrices();
const items = pricingStore.getState().items;
expect(items.find((i) => i.id === 'base')!.price).toBe(250000);
expect(items.find((i) => i.id === 'emarati-kandura')!.price).toBe(15000);
});
});
describe('hydrate', () => {
it('should load prices from localStorage', () => {
mockStorage['lootah-pricing'] = JSON.stringify([
{ id: 'base', label: 'G1 Robot Base', price: 500000 },
]);
pricingStore.getState().hydrate();
const base = pricingStore.getState().items.find((i) => i.id === 'base');
expect(base!.price).toBe(500000);
expect(pricingStore.getState().isHydrated).toBe(true);
});
it('should keep defaults for items not in localStorage', () => {
mockStorage['lootah-pricing'] = JSON.stringify([
{ id: 'base', label: 'G1 Robot Base', price: 500000 },
]);
pricingStore.getState().hydrate();
const kandura = pricingStore.getState().items.find((i) => i.id === 'emarati-kandura');
expect(kandura!.price).toBe(15000);
});
it('should set isHydrated even with no stored data', () => {
pricingStore.getState().hydrate();
expect(pricingStore.getState().isHydrated).toBe(true);
});
});
});
+100
View File
@@ -0,0 +1,100 @@
import { createStore } from 'zustand/vanilla';
import { useSyncExternalStore } from 'react';
export interface PricingItem {
id: string;
label: string;
price: number;
}
export interface PricingState {
items: PricingItem[];
isHydrated: boolean;
}
export interface PricingActions {
updatePrice: (itemId: string, newPrice: number) => void;
resetPrices: () => void;
hydrate: () => void;
}
export type PricingStore = PricingState & PricingActions;
const DEFAULT_ITEMS: PricingItem[] = [
{ id: 'base', label: 'G1 Robot Base', price: 250000 },
{ id: 'emarati-kandura', label: 'Emarati Kandura', price: 15000 },
{ id: 'industrial-vest', label: 'Industrial Vest', price: 8500 },
{ id: 'business-suit', label: 'Business Suit', price: 12000 },
{ id: 'custom-color', label: 'Custom Color', price: 3500 },
];
const STORAGE_KEY = 'lootah-pricing';
function loadFromStorage(): PricingItem[] | null {
if (typeof window === 'undefined') return null;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return null;
return parsed;
} catch {
return null;
}
}
function saveToStorage(items: PricingItem[]) {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
} catch {
// Storage full or unavailable
}
}
export const pricingStore = createStore<PricingStore>((set, get) => ({
items: DEFAULT_ITEMS,
isHydrated: false,
updatePrice: (itemId: string, newPrice: number) => {
set((state) => {
const updated = state.items.map((item) =>
item.id === itemId ? { ...item, price: newPrice } : item
);
saveToStorage(updated);
return { items: updated };
});
},
resetPrices: () => {
saveToStorage(DEFAULT_ITEMS);
set({ items: [...DEFAULT_ITEMS] });
},
hydrate: () => {
const stored = loadFromStorage();
if (stored) {
// Merge stored prices with defaults (in case new items were added)
const merged = DEFAULT_ITEMS.map((def) => {
const found = stored.find((s) => s.id === def.id);
return found ? { ...def, price: found.price } : def;
});
set({ items: merged, isHydrated: true });
} else {
set({ isHydrated: true });
}
},
}));
export const usePricingStore = <T>(selector: (state: PricingStore) => T): T => {
return useSyncExternalStore(
pricingStore.subscribe,
() => selector(pricingStore.getState()),
() => selector(pricingStore.getState())
);
};
export const getPrice = (itemId: string): number => {
const item = pricingStore.getState().items.find((i) => i.id === itemId);
return item?.price ?? 0;
};
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
+123
View File
@@ -0,0 +1,123 @@
/**
* Image Generation Utilities
*
* This module provides utilities for generating shareable images of the configured G1 robot.
* It serves as a placeholder for serverless API integration (e.g., Vercel OG, Cloudinary).
*/
import { ConfigState } from '@/store/useConfigStore';
import { serializeConfig } from '@/store/useConfigStore';
/**
* Configuration for image generation API
*/
interface ImageGeneratorConfig {
apiEndpoint?: string;
width?: number;
height?: number;
}
/**
* Response from image generation API
*/
interface ImageGenerationResult {
success: boolean;
imageUrl?: string;
error?: string;
}
/**
* Generates a shareable URL for the current configuration
* This URL can be used to share the exact robot configuration
*/
export const generateShareableUrl = (config: ConfigState): string => {
const encoded = serializeConfig(config);
const baseUrl = typeof window !== 'undefined' ? window.location.origin : '';
return `${baseUrl}?config=${encoded}`;
};
/**
* Placeholder for serverless image generation API
*
* In production, this would call a serverless function that:
* 1. Receives the configuration
* 2. Renders the 3D model server-side (using headless WebGL)
* 3. Returns a shareable image URL
*
* Example integrations:
* - Vercel OG Image: https://vercel.com/docs/concepts/og-image-generation
* - Cloudinary: https://cloudinary.com/documentation/transformation_api
* - AWS Lambda + Sharp: Custom implementation
*/
export const generateShareableImage = async (
config: ConfigState,
options: ImageGeneratorConfig = {}
): Promise<ImageGenerationResult> => {
const { apiEndpoint, width = 1200, height = 630 } = options;
// If no API endpoint is configured, return the shareable URL
if (!apiEndpoint) {
return {
success: true,
imageUrl: generateShareableUrl(config),
};
}
try {
const response = await fetch(apiEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
config: serializeConfig(config),
width,
height,
}),
});
if (!response.ok) {
throw new Error(`Image generation failed: ${response.statusText}`);
}
const data = await response.json();
return {
success: true,
imageUrl: data.imageUrl || data.url,
};
} catch (error) {
console.error('Image generation error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
imageUrl: generateShareableUrl(config), // Fallback to URL
};
}
};
/**
* Copy text to clipboard
*/
export const copyToClipboard = async (text: string): Promise<boolean> => {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
return true;
} catch {
return false;
} finally {
document.body.removeChild(textArea);
}
}
};