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