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

This commit is contained in:
Yjoz 2026-04-10 15:31:59 +04:00
parent 87d2688eeb
commit ec0f991a30
55 changed files with 10218 additions and 0 deletions

View File

@ -0,0 +1,42 @@
---
description: Operational guidelines for Lootah Robotics G1 3D Configurator development
alwaysApply: true
---
# Lootah Robotics G1 3D Configurator - Operational Rules
## Prompt Adherence
- **Strict Following**: Always adhere to prompt instructions including Context, Objective, Constraints, File Structure, and Implementation Details
- **No Deviations**: Do NOT introduce features, libraries, or architectural changes not explicitly requested
- **Sequential Execution**: Execute prompts sequentially; output of previous prompt forms context for next
- **Contextual Awareness**: Maintain awareness of overall project goal and progress made
## Coding Standards
- Write clean, readable, well-structured code; adhere to ESLint/Prettier conventions
- **TypeScript First**: Use TypeScript for all new code; avoid `any` unless absolutely necessary
- **Performance Minded**: Prioritize performance for 3D rendering and state management; use memoization and lazy loading
- **Error Handling**: Implement robust error handling with informative feedback and logging
- **Modularity**: Design components with clear responsibilities; favor composition over inheritance
## UI/UX & Design
- **Design System**: Adhere to `yslootahtech.com` corporate dark-tech theme with glassmorphism design
- **Responsiveness**: All UI and 3D scene must be fully responsive
- **Accessibility (WCAG)**: Keyboard navigation, ARIA attributes, sufficient color contrast
- **Immersive Experience**: Smooth transitions, intuitive interactions, subtle animations
- **User Feedback**: Clear and immediate visual feedback for all interactions
## Asset Management
- All 3D models (GLB/GLTF), textures, media must be optimized for web delivery
- External models loaded dynamically and attached without altering base model geometry/materials
- Use relative paths for assets (e.g., `public/g1-model.glb`)
## Operational Rules
- **No Unapproved Dependencies**: Do NOT add external libraries unless explicitly approved
- **Self-Correction**: Analyze errors, identify root cause, fix properly; don't repeat mistakes
- **Documentation**: Clear inline comments for complex logic; self-documenting code structure
- **Testing**: Automated tests for critical functionalities (state management, 3D interactions)

View File

@ -0,0 +1,61 @@
---
name: lootah-robotics-expertise
description: Required expertise for Lootah Robotics G1 3D Configurator development. Use when building the configurator with Next.js, React Three Fiber, 3D rendering, state management, or UI/UX tasks.
---
# Lootah Robotics G1 3D Configurator Development
## Required Expertise Areas
### 1. Core Web Technologies
- **Next.js (v16.2.2)**: Project scaffolding, static export, routing, data fetching
- **React (v19.0.0)**: Component architecture, hooks, context API, memoization
- **TypeScript**: Strong typing for code quality and maintainability
- **Tailwind CSS (v4.2.2)**: Utility-first CSS, responsive design, custom theming
- **Zustand (v5.0.12)**: Lightweight state management for complex application state
### 2. 3D Development (React Three Fiber & Three.js)
- **React Three Fiber (v9.5.0)**: Declarative 3D scene construction, R3F primitives
- **React Three Drei (v10.7.7)**: Environment, ContactShadows, PresentationControls, useGLTF, EffectComposer
- **Three.js Fundamentals**: Scene graph, geometries, PBR materials, lights, cameras, renderers
- **3D Model Handling**: GLB/GLTF loading, model hierarchy, LOD optimization
- **Dynamic 3D Interaction**: Interactive camera controls, object manipulation, event handling
### 3. Animation & Interactivity
- **Framer Motion (v12.38.0)**: Physics-based UI animations, state transitions
- **GSAP (v3.14.2)**: Timeline-based complex animations, micro-interactions
- **Micro-interactions**: Subtle animations for user engagement
### 4. UI/UX & Accessibility
- **Responsive Design**: Cross-device UIs (desktop, tablet, mobile)
- **Glassmorphism**: Advanced blur effects, dynamic lighting, textures
- **Accessibility (WCAG)**: Keyboard navigation, ARIA attributes, reduced motion
- **User Feedback**: Loading states, error handling, visual feedback
### 5. Performance & Optimization
- **Web Performance**: Fast loading, 60fps animations, resource efficiency
- **Asset Optimization**: 3D model/texture optimization for web delivery
- **Debugging**: Browser dev tools, R3F/Three.js profiling
## Key Dependencies
```json
{
"next": "16.2.2",
"react": "19.0.0",
"@react-three/fiber": "9.5.0",
"@react-three/drei": "10.7.7",
"three": "latest",
"zustand": "5.0.12",
"framer-motion": "12.38.0",
"gsap": "3.14.2",
"tailwindcss": "4.2.2",
"typescript": "latest"
}
```
## Quick Reference
- Project: 3D robot configurator with customizable parts
- State: Zustand for configuration state management
- 3D: R3F + Drei for declarative Three.js
- Styling: Tailwind CSS with glassmorphism effects
- Animation: Framer Motion for UI, GSAP for complex 3D sequences

55
.github/workflows/ci-cd.yml vendored Normal file
View File

@ -0,0 +1,55 @@
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test-and-build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Build static export
run: npm run build
- name: Upload build artifacts
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
uses: actions/upload-pages-artifact@v3
with:
path: out/
# Deploy to GitHub Pages (enable in repo Settings > Pages > Source: GitHub Actions)
deploy:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
needs: test-and-build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

37
.gitignore vendored Normal file
View File

@ -0,0 +1,37 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Build outputs
.next
out
dist
build
# Testing
coverage
# Misc
.DS_Store
*.pem
.env*.local
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local env files
.env
# Vercel
.vercel
# TypeScript
*.tsbuildinfo
next-env.d.ts
# IDE
.idea
.vscode

View File

@ -0,0 +1,294 @@
# Master Prompt-Chaining Plan for Lootah Robotics G1 3D Configurator
This document serves as a self-contained architectural blueprint for developing the Lootah Robotics G1 3D Configurator. It includes the required developer skills, operational rules, and a sequence of four detailed prompts to guide an AI developer (like MiniMax M2.7) through the entire development process.
---
# SKILL.md: Required Expertise for Development from skills
This section outlines the essential skills and areas of expertise required for the AI developer. Proficiency is expected across modern web development, advanced 3D rendering, state management, and UI/UX best practices.
## 1. Core Web Technologies & Frameworks
| Skill Area | Specific Expertise Required |
| :--- | :--- |
| **Next.js (v16.2.2)** | Project scaffolding, static export configuration, routing, data fetching strategies, and leveraging built-in optimizations (e.g., image optimization, code splitting). |
| **React (v19.0.0)** | Component-based architecture, hooks (useState, useEffect, useContext, useMemo, useCallback), context API, performance optimization with `React.memo` and memoization techniques. |
| **TypeScript** | Strong typing for enhanced code quality, maintainability, and error prevention across the entire codebase. |
| **Tailwind CSS (v4.2.2)** | Utility-first CSS framework for rapid and responsive UI development, custom theme configuration, and integration with Next.js. |
| **Zustand (v5.0.12)** | Efficient and lightweight state management for complex application states, including actions, selectors, and middleware. |
## 2. Advanced 3D Development (React Three Fiber & Three.js)
| Skill Area | Specific Expertise Required |
| :--- | :--- |
| **React Three Fiber (v9.5.0)** | Declarative 3D scene construction, integration of Three.js primitives and custom components, performance optimization within the R3F ecosystem. |
| **React Three Drei (v10.7.7)** | Utilization of helper components for common 3D tasks, including `Environment` (studio lighting), `ContactShadows`, `PresentationControls`, `useGLTF` for model loading, and `EffectComposer` for post-processing. |
| **Three.js Fundamentals** | Understanding of scene graph, geometries, materials (PBR), lights, cameras, and renderers. Direct manipulation of Three.js objects when necessary. |
| **3D Model Handling** | Efficient loading and rendering of GLB/GLTF models, including understanding of model hierarchy, nodes, and meshes. Implementation of 3D model optimization techniques (e.g., compression, Level of Detail - LOD). |
| **Dynamic 3D Interaction** | Implementing interactive camera controls, object manipulation, and event handling within the 3D scene. |
## 3. Animation & Interactivity
| Skill Area | Specific Expertise Required |
| :--- | :--- |
| **Framer Motion (v12.38.0)** | Implementing declarative, physics-based animations for UI elements and seamless transitions between states. |
| **GSAP (v3.14.2)** | High-performance, timeline-based animations for complex sequences, micro-interactions, and dynamic visual effects within the 3D configurator. |
| **Micro-interactions** | Designing and implementing subtle animations and visual feedback to enhance user engagement and perceived responsiveness. |
## 4. UI/UX & Accessibility
| Skill Area | Specific Expertise Required |
| :--- | :--- |
| **Responsive Design** | Building UIs that adapt seamlessly across various screen sizes and devices (desktop, tablet, mobile) using Tailwind CSS and responsive design principles. |
| **Glassmorphism Design** | Implementing advanced glassmorphism effects with refined blur algorithms, dynamic lighting, and subtle textures. |
| **Accessibility (WCAG)** | Adherence to Web Content Accessibility Guidelines, including keyboard navigation, ARIA attributes, semantic HTML, and consideration for reduced motion preferences. |
| **User Feedback & Loading States** | Implementing clear visual feedback for user actions, loading indicators (spinners, skeleton screens), and graceful error handling. |
## 5. Performance & Optimization
| Skill Area | Specific Expertise Required |
| :--- | :--- |
| **Web Performance** | Understanding and applying techniques for fast loading times, smooth animations (60fps), and efficient resource utilization. |
| **Asset Optimization** | Optimizing 3D models, textures, and other media assets for web delivery without compromising visual quality. |
| **Debugging & Profiling** | Utilizing browser developer tools and R3F/Three.js specific tools for identifying and resolving performance bottlenecks and rendering issues. |
---
# lootah-robotics-operational.mdc: Operational Guidelines for Development frim rules
This section outlines the strict rules and operational boundaries that the AI developer must adhere to. These rules ensure consistency, quality, security, and alignment with project goals.
## 1. Prompt Adherence & Interpretation
| Rule Category | Specific Rule |
| :--- | :--- |
| **Strict Prompt Following** | ALWAYS adhere strictly to the provided prompt instructions, including Context, Objective, Constraints, File Structure, and Implementation Details. |
| **No Deviations** | DO NOT introduce features, libraries, or architectural changes not explicitly requested or implied by the prompt. If ambiguity arises, seek clarification. |
| **Sequential Execution** | Prompts MUST be executed sequentially. The output of a previous prompt forms the context for the next. DO NOT skip or reorder prompts. |
| **Contextual Awareness** | Maintain awareness of the overall project goal and the progress made in previous prompts. Each prompt builds upon the last. |
## 2. Coding Standards & Best Practices
| Rule Category | Specific Rule |
| :--- | :--- |
| **Code Quality** | Write clean, readable, well-structured, and maintainable code. Adhere to established coding conventions (e.g., ESLint, Prettier). |
| **TypeScript First** | ALWAYS use TypeScript for all new code and ensure strict type checking. Avoid `any` type unless absolutely necessary and justified. |
| **Performance Minded** | Prioritize performance in all implementations, especially for 3D rendering and state management. Utilize memoization, lazy loading, and efficient algorithms. |
| **Security Conscious** | Implement secure coding practices. Avoid hardcoding sensitive information. Ensure proper input validation and sanitization where applicable. |
| **Error Handling** | Implement robust and graceful error handling for all potential failure points, providing informative feedback to the user and logging errors appropriately. |
| **Modularity** | Design components and modules with clear responsibilities and minimal coupling. Favor composition over inheritance. |
## 3. UI/UX & Design Principles
| Rule Category | Specific Rule |
| :--- | :--- |
| **Design System Adherence** | STRICTLY adhere to the corporate dark-tech theme of `yslootahtech.com` and the specified glassmorphism design principles. |
| **Responsiveness** | ALL UI components and the 3D scene MUST be fully responsive and adapt seamlessly to various screen sizes and device orientations. |
| **Accessibility (WCAG)** | Implement all UI elements with WCAG compliance in mind, including keyboard navigation, appropriate ARIA attributes, and sufficient color contrast. |
| **Immersive Experience** | Ensure smooth transitions, intuitive interactions, and subtle animations to create an immersive and engaging user experience. Avoid jarring changes or performance drops. |
| **User Feedback** | Provide clear and immediate visual feedback for all user interactions, loading states, and system responses. |
## 4. Asset Management
| Rule Category | Specific Rule |
| :--- | :--- |
| **Optimized Assets** | ALL 3D models (GLB/GLTF), textures, and other media assets MUST be optimized for web delivery (e.g., compressed, appropriate polycount) without compromising visual quality. |
| **External Asset Handling** | External 3D models for personas and payloads MUST be loaded dynamically and attached to the base G1 model without altering its original geometry or materials. |
| **Asset Paths** | Use relative paths for assets (e.g., `public/g1-model.glb`) as specified in the prompts. |
## 5. General Operational Rules
| Rule Category | Specific Rule |
| :--- | :--- |
| **No External Dependencies (Unapproved)** | DO NOT introduce any external libraries, packages, or APIs unless explicitly mentioned in the prompt or approved through a clarification process. |
| **Self-Correction** | If an error occurs during code generation or execution, analyze the error, identify the root cause, and implement a fix. DO NOT repeat the same mistake. |
| **Documentation** | Generate clear and concise inline comments for complex logic, and ensure code structure is self-documenting where possible. |
| **Testing** | Implement automated tests for critical functionalities as specified in the prompts, especially for state management and core 3D interactions. |
| **Ethical Considerations** | Ensure all generated code and content is ethical, unbiased, and adheres to privacy best practices. |
---
## Tech Stack & Versions (Latest Stable)
| Technology | Version |
| :--- | :--- |
| **Next.js** | `16.2.2` |
| **React** | `19.0.0` |
| **React Three Fiber** | `9.5.0` |
| **React Three Drei** | `10.7.7` |
| **Zustand** | `5.0.12` |
| **Tailwind CSS** | `4.2.2` |
| **Framer Motion** | `12.38.0` |
| **GSAP** | `3.14.2` |
---
## Perfection Layers: Elevating the Configurator Experience
To achieve a truly "perfect" website, the Lootah Robotics G1 3D Configurator will integrate advanced coding and UI/UX considerations. These enhancements aim to deliver a highly performant, robust, visually stunning, immersive, and accessible digital experience.
### Coding Perfection (Performance, Robustness, Maintainability)
1. **3D Model Optimization:** Implement techniques for efficient loading and rendering of 3D models, including Level of Detail (LOD), instancing for repeated elements, and optimal compression of GLB assets.
2. **Asset Loading Strategy:** Utilize lazy loading for non-critical assets, strategic preloading for essential components, and browser caching for static resources to ensure rapid load times and smooth transitions.
3. **R3F & Three.js Optimizations:** Employ `useMemo`, `useCallback`, and `React.memo` to prevent unnecessary re-renders. Integrate performance monitoring tools to identify and resolve bottlenecks.
4. **Next.js Optimizations:** Leverage built-in features like image optimization, intelligent code splitting, and static site generation for superior initial load performance.
5. **Robustness & User Feedback:** Implement comprehensive error handling, display loading states (spinners, skeleton screens), and ensure full responsiveness across all devices.
6. **Maintainability & Code Quality:** Enforce a modular code structure, strict TypeScript usage, and automated testing for critical components to ensure long-term stability and extensibility.
### UI/UX Perfection (Visual Polish, Immersion, Accessibility)
1. **Visual Polish & Aesthetics:** Refine the glassmorphism effect with advanced blur algorithms and dynamic lighting. Introduce micro-interactions and haptic feedback for a more engaging experience. Ensure all 3D assets are high-quality with consistent PBR materials.
2. **Advanced Post-processing Effects:** Integrate Three.js post-processing effects such as Ambient Occlusion (SSAO/GTAO), Bloom, Depth of Field (DoF), and Color Grading for enhanced realism and visual appeal.
3. **Immersion & User Engagement:** Implement seamless transitions between customization states, intuitive camera controls with easing functions, and interactive hotspots/annotations on the G1 model. Consider subtle environmental storytelling elements.
4. **Accessibility & Inclusivity:** Ensure full keyboard navigation, utilize ARIA attributes and semantic HTML, adhere to WCAG compliance for color contrast and text sizing, and offer reduced motion preferences for sensitive users.
---
## PROMPT 1: Initialization, Theming, and UI Layout
```
Context: We are starting the development of the Lootah Robotics G1 3D Configurator. This initial prompt focuses on setting up the foundational project structure, applying the corporate theme, and establishing the core UI layout.
Objective: Scaffold a Next.js 16.2.2 project configured for static export, integrate Tailwind CSS 4.2.2 with a custom dark theme matching yslootahtech.com, and construct the full-screen layout including a game-like floating glassmorphism overlay on the right side. This initial setup must prioritize responsiveness, accessibility, and foundational performance optimizations.
Constraints:
- Use Next.js 16.2.2 with the 'export' output option for static export.
- Use React 19.0.0.
- Do not use server components.
- Ensure Tailwind CSS 4.2.2 is configured to match the corporate dark-tech theme of yslootahtech.com.
- The UI must be a full-screen layout, fully responsive across desktop, tablet, and mobile devices.
- Implement a floating glassmorphism overlay panel on the right side of the screen, with refined blur algorithms and dynamic lighting interactions.
- Implement foundational accessibility features, including keyboard navigation and semantic HTML for UI elements.
- Optimize initial load performance using Next.js features like intelligent code splitting.
File Structure:
- `next.config.mjs`: Configuration for Next.js static export.
- `tailwind.config.ts`: Tailwind CSS configuration with custom theme.
- `src/app/layout.tsx`: Root layout for the application.
- `src/app/page.tsx`: Main page component containing the full-screen layout and glassmorphism panel.
- `src/app/globals.css`: Global styles for Tailwind imports.
Implementation Details:
- Initialize a new Next.js project with the specified versions, ensuring `next.config.mjs` is configured for static HTML export and optimal code splitting.
- Install and configure Tailwind CSS 4.2.2, defining a custom dark theme in `tailwind.config.ts` that aligns with yslootahtech.com's aesthetic.
- Create a full-screen layout in `src/app/page.tsx` that is inherently responsive, utilizing Tailwind's utility classes for adaptive design.
- Design and implement the glassmorphism floating right-panel overlay. This panel should have a translucent background, refined blur effect, and dynamic lighting interactions to create a premium feel. Ensure it's responsive, positioned correctly, and accessible via keyboard navigation with appropriate ARIA attributes.
- Implement basic error boundaries for UI components to ensure graceful degradation.
```
## PROMPT 2: State Management & URL Serialization (Zustand)
```
Context: We have successfully initialized the Next.js project, set up the theming, and established the basic UI layout. Now, we need to implement the core state management for the 3D configurator.
Objective: Create a Zustand 5.0.12 store (`useConfigStore.ts`) to manage the configurable aspects of the G1 robot, including active colors, active persona attire, and active payloads. Crucially, implement logic to serialize this store's state into a Base64 encoded URL parameter for sharing and to hydrate the store from this URL parameter upon page load. This implementation must prioritize robustness, maintainability, and efficient state updates.
Constraints:
- Use Zustand 5.0.12 for state management, ensuring optimal performance with `useMemo` and `useCallback` where appropriate.
- The state must be serializable to and deserializable from a Base64 encoded URL parameter, with robust error handling for malformed URLs.
- The URL parameter should be updated efficiently whenever the state changes, debouncing updates to prevent excessive re-renders.
- The state should be initialized from the URL parameter on application load, providing clear loading states if hydration takes time.
- Do not use server components.
- Implement comprehensive automated tests for the Zustand store and URL serialization logic to ensure reliability.
File Structure:
- `src/store/useConfigStore.ts`: Zustand store definition.
- `src/hooks/useUrlSync.ts`: Custom hook for URL serialization and hydration.
- `src/app/page.tsx`: Integrate the URL synchronization logic.
Implementation Details:
- Define the Zustand store (`useConfigStore.ts`) with a modular structure, holding the following state:
- `activeColors`: An array or object representing the currently selected colors for different parts of the robot.
- `activePersonaAttire`: A string or enum representing the selected persona/clothing (e.g., 'Emarati Kandura', 'Industrial Vest').
- `activePayloads`: An array of objects, each describing a mounted payload (e.g., `{ id: 'camera', position: 'head' }`).
- Implement actions within the store to update these state variables, ensuring efficient updates and preventing unnecessary re-renders.
- Create a custom React hook (`useUrlSync.ts`) that:
- Reads the Base64 encoded state from a URL parameter on initial load, gracefully handling missing or invalid parameters, and uses it to hydrate the Zustand store.
- Subscribes to changes in the Zustand store and updates the URL parameter with the Base64 encoded state, employing debouncing to optimize URL updates.
- Integrate `useUrlSync` into `src/app/page.tsx` to ensure state persistence via the URL, providing visual feedback (e.g., a subtle loading indicator) during hydration.
- Ensure proper encoding and decoding of the state object to and from Base64, with robust error handling for serialization/deserialization failures.
- Implement unit tests for `useConfigStore.ts` and `useUrlSync.ts` to guarantee their reliability and correctness.
```
## PROMPT 3: The 3D Canvas & Lighting Foundation
```
Context: We have established the project structure, theming, UI layout, and robust state management with URL serialization. The next step is to set up the 3D rendering environment.
Objective: Instruct MiniMax to set up the <Canvas> component from React Three Fiber 9.5.0, configure `@react-three/drei` 10.7.7 for studio lighting (`Environment`), add `ContactShadows` for realistic ground shadows, and integrate `PresentationControls` for interactive camera manipulation. Additionally, MiniMax needs to create a boilerplate `RobotModel` component responsible for loading the base `g1-model.glb` 3D model. This setup must prioritize 3D model optimization, efficient asset loading, and the integration of advanced visual post-processing effects for an immersive experience.
Constraints:
- Use React Three Fiber 9.5.0 for 3D rendering, applying `useMemo` and `useCallback` for performance optimization.
- Utilize `@react-three/drei` 10.7.7 for environmental lighting and effects, including `Environment` (studio lighting), `ContactShadows`, and `PresentationControls`.
- Ensure the 3D scene is interactive with intuitive camera controls (orbit, pan, zoom) and appropriate easing functions.
- The base 3D model (`g1-model.glb`) should be loaded and displayed using optimized techniques (e.g., compression, potential LOD considerations).
- Implement loading states (e.g., spinners, skeleton screens) while 3D assets are loading.
- Integrate advanced post-processing effects (e.g., Ambient Occlusion, Bloom, Depth of Field, Color Grading) for visual polish.
- Do not use server components.
File Structure:
- `src/components/RobotCanvas.tsx`: Contains the R3F <Canvas> setup and lighting.
- `src/components/RobotModel.tsx`: Component for loading and displaying the `g1-model.glb`.
- `public/g1-model.glb`: The 3D model file (assume it's already placed here).
- `src/app/page.tsx`: Integrate the `RobotCanvas` component.
Implementation Details:
- Create `src/components/RobotCanvas.tsx` which will encapsulate the R3F <Canvas>.
- Inside `RobotCanvas.tsx`:
- Set up the <Canvas> with appropriate camera settings (e.g., `dpr={[1, 2]}`, `camera={{ position: [0, 0, 5], fov: 50 }}`), ensuring intuitive camera controls with easing functions.
- Add the `Environment` component from `@react-three/drei` configured for studio lighting (e.g., `preset="studio"`).
- Include `ContactShadows` for realistic shadows beneath the robot model.
- Integrate `PresentationControls` to allow users to rotate and pan the camera around the model, with performance optimizations using `useMemo` and `useCallback`.
- Implement loading states (e.g., a spinner or skeleton) while the 3D scene and models are being prepared.
- Integrate advanced post-processing effects (e.g., `EffectComposer` with `SSAO`, `Bloom`, `DepthOfField`, `ColorGrading`) to enhance visual realism and polish.
- Place the `RobotModel` component within the <Canvas>.
- Create `src/components/RobotModel.tsx`:
- This component should use `@react-three/fiber`'s `useLoader` hook (or similar) to load `public/g1-model.glb`, ensuring the GLB is optimally compressed. Consider implementing LOD if multiple model versions are available.
- Apply high-quality `PBR materials` to the loaded model, ensuring it looks realistic and consistent with the corporate theme.
- Position and scale the model appropriately within the scene.
- Integrate `RobotCanvas` into `src/app/page.tsx`, ensuring it takes up the necessary display area and handles responsiveness.
```
## PROMPT 4: Deep Customization Logic (The Sockets & Personas)
```
Context: We have a fully functional 3D canvas with lighting and the base G1 robot model loaded. State management for customization options is also in place and synchronized with the URL. This final prompt focuses on implementing the dynamic customization logic.
Objective: Implement the "Socket" logic within the `RobotModel` R3F component to dynamically and conditionally render external meshes (e.g., Emarati Kandura geometry, PTZ Camera geometry) based on the `useConfigStore` state. This involves attaching these meshes to specific nested coordinates (joints) of the base G1 model and ensuring they respond to state changes. This implementation must prioritize efficient loading of external assets, seamless transitions during customization, and maintaining visual consistency with the base model and post-processing effects.
Constraints:
- The customization logic must be integrated within the `RobotModel` component, leveraging `useMemo` and `useCallback` for performance.
- External meshes (personas, payloads) should be loaded efficiently (e.g., lazy loading, compression) and rendered conditionally based on the `activePersonaAttire` and `activePayloads` from the Zustand store.
- Meshes must be positioned accurately at predefined "socket" coordinates on the base G1 model, ensuring seamless visual integration.
- The solution should be performant, avoid unnecessary re-renders, and provide smooth transitions when customization options change.
- Ensure high-quality 3D assets for personas and payloads with consistent PBR materials.
- Consider interactive hotspots or annotations on the G1 model to highlight customizable areas.
- Do not use server components.
File Structure:
- `src/components/RobotModel.tsx`: Integrate the socket and persona rendering logic.
- `src/components/PersonaMesh.tsx`: Component for loading and rendering persona attire.
- `src/components/PayloadMesh.tsx`: Component for loading and rendering individual payloads.
- `public/persona-kandura.glb`, `public/payload-camera.glb`, etc.: External 3D model files for personas and payloads (assume these are available).
Implementation Details:
- Modify `src/components/RobotModel.tsx` to:
- Access the `activePersonaAttire` and `activePayloads` from the `useConfigStore`.
- Implement a mechanism to identify specific "socket" points (e.g., head, shoulders, arms) on the loaded `g1-model.glb` geometry. This will involve traversing the loaded model's hierarchy to find relevant nodes (e.g., bones or mesh parts) and using their transformations as attachment points. The base G1 model's geometry and materials will remain untouched, ensuring its original look is preserved.
- Conditionally render `PersonaMesh` components based on `activePersonaAttire`, ensuring smooth transitions and animations (e.g., using Framer Motion or GSAP) when the persona changes.
- Conditionally render `PayloadMesh` components for each item in `activePayloads`, also with smooth transitions.
- Implement interactive hotspots on the G1 model that, when clicked, can trigger UI updates or provide contextual information about customization options.
- Create `src/components/PersonaMesh.tsx`:
- This component should take `attireType` and `socketPosition` as props.
- Load the corresponding GLB model (e.g., `persona-kandura.glb`), ensuring it is optimized (compressed, potentially LOD) and uses high-quality PBR materials.
- Position and orient the loaded persona mesh relative to the `socketPosition` on the G1 model. This ensures the persona attire appears to be 'worn' by the robot without altering the base G1 model itself, and integrates visually with any active post-processing effects.
- Apply appropriate materials, ensuring consistency with the overall theme.
- Create `src/components/PayloadMesh.tsx`:
- This component should take `payloadId` and `socketPosition` as props.
- Load the corresponding GLB model (e.g., `payload-camera.glb`), ensuring it is optimized and uses high-quality PBR materials.
- Position and orient the loaded payload mesh relative to the `socketPosition` on the G1 model, integrating visually with post-processing effects.
- Apply appropriate materials.
- Ensure that when the Zustand store updates, the rendered persona and payload meshes are correctly added, removed, or changed in the 3D scene with seamless visual feedback and animations. Implement robust error handling for external model loading failures.
```

View File

@ -0,0 +1,51 @@
# RULES.md: Operational Guidelines for Lootah Robotics G1 3D Configurator Development
This document outlines the strict rules and operational boundaries that an AI developer (such as MiniMax M2.7) must adhere to during the development of the Lootah Robotics G1 3D Configurator. These rules ensure consistency, quality, security, and alignment with project goals.
## 1. Prompt Adherence & Interpretation
| Rule Category | Specific Rule |
| :--- | :--- |
| **Strict Prompt Following** | ALWAYS adhere strictly to the provided prompt instructions, including Context, Objective, Constraints, File Structure, and Implementation Details. |
| **No Deviations** | DO NOT introduce features, libraries, or architectural changes not explicitly requested or implied by the prompt. If ambiguity arises, seek clarification. |
| **Sequential Execution** | Prompts MUST be executed sequentially. The output of a previous prompt forms the context for the next. DO NOT skip or reorder prompts. |
| **Contextual Awareness** | Maintain awareness of the overall project goal and the progress made in previous prompts. Each prompt builds upon the last. |
## 2. Coding Standards & Best Practices
| Rule Category | Specific Rule |
| :--- | :--- |
| **Code Quality** | Write clean, readable, well-structured, and maintainable code. Adhere to established coding conventions (e.g., ESLint, Prettier). |
| **TypeScript First** | ALWAYS use TypeScript for all new code and ensure strict type checking. Avoid `any` type unless absolutely necessary and justified. |
| **Performance Minded** | Prioritize performance in all implementations, especially for 3D rendering and state management. Utilize memoization, lazy loading, and efficient algorithms. |
| **Security Conscious** | Implement secure coding practices. Avoid hardcoding sensitive information. Ensure proper input validation and sanitization where applicable. |
| **Error Handling** | Implement robust and graceful error handling for all potential failure points, providing informative feedback to the user and logging errors appropriately. |
| **Modularity** | Design components and modules with clear responsibilities and minimal coupling. Favor composition over inheritance. |
## 3. UI/UX & Design Principles
| Rule Category | Specific Rule |
| :--- | :--- |
| **Design System Adherence** | STRICTLY adhere to the corporate dark-tech theme of `yslootahtech.com` and the specified glassmorphism design principles. |
| **Responsiveness** | ALL UI components and the 3D scene MUST be fully responsive and adapt seamlessly to various screen sizes and device orientations. |
| **Accessibility (WCAG)** | Implement all UI elements with WCAG compliance in mind, including keyboard navigation, appropriate ARIA attributes, and sufficient color contrast. |
| **Immersive Experience** | Ensure smooth transitions, intuitive interactions, and subtle animations to create an immersive and engaging user experience. Avoid jarring changes or performance drops. |
| **User Feedback** | Provide clear and immediate visual feedback for all user interactions, loading states, and system responses. |
## 4. Asset Management
| Rule Category | Specific Rule |
| :--- | :--- |
| **Optimized Assets** | ALL 3D models (GLB/GLTF), textures, and other media assets MUST be optimized for web delivery (e.g., compressed, appropriate polycount) without compromising visual quality. |
| **External Asset Handling** | External 3D models for personas and payloads MUST be loaded dynamically and attached to the base G1 model without altering its original geometry or materials. |
| **Asset Paths** | Use relative paths for assets (e.g., `public/g1-model.glb`) as specified in the prompts. |
## 5. General Operational Rules
| Rule Category | Specific Rule |
| :--- | :--- |
| **No External Dependencies (Unapproved)** | DO NOT introduce any external libraries, packages, or APIs unless explicitly mentioned in the prompt or approved through a clarification process. |
| **Self-Correction** | If an error occurs during code generation or execution, analyze the error, identify the root cause, and implement a fix. DO NOT repeat the same mistake. |
| **Documentation** | Generate clear and concise inline comments for complex logic, and ensure code structure is self-documenting where possible. |
| **Testing** | Implement automated tests for critical functionalities as specified in the prompts, especially for state management and core 3D interactions. |
| **Ethical Considerations** | Ensure all generated code and content is ethical, unbiased, and adheres to privacy best practices. |

View File

@ -0,0 +1,49 @@
# SKILLS.md: Required Expertise for Lootah Robotics G1 3D Configurator Development
This document outlines the essential skills and areas of expertise required for an AI developer (such as MiniMax M2.7) to successfully implement the Lootah Robotics G1 3D Configurator. The developer must demonstrate proficiency across modern web development, advanced 3D rendering, state management, and UI/UX best practices.
## 1. Core Web Technologies & Frameworks
| Skill Area | Specific Expertise Required |
| :--- | :--- |
| **Next.js (v16.2.2)** | Project scaffolding, static export configuration, routing, data fetching strategies, and leveraging built-in optimizations (e.g., image optimization, code splitting). |
| **React (v19.0.0)** | Component-based architecture, hooks (useState, useEffect, useContext, useMemo, useCallback), context API, performance optimization with `React.memo` and memoization techniques. |
| **TypeScript** | Strong typing for enhanced code quality, maintainability, and error prevention across the entire codebase. |
| **Tailwind CSS (v4.2.2)** | Utility-first CSS framework for rapid and responsive UI development, custom theme configuration, and integration with Next.js. |
| **Zustand (v5.0.12)** | Efficient and lightweight state management for complex application states, including actions, selectors, and middleware. |
## 2. Advanced 3D Development (React Three Fiber & Three.js)
| Skill Area | Specific Expertise Required |
| :--- | :--- |
| **React Three Fiber (v9.5.0)** | Declarative 3D scene construction, integration of Three.js primitives and custom components, performance optimization within the R3F ecosystem. |
| **React Three Drei (v10.7.7)** | Utilization of helper components for common 3D tasks, including `Environment` (studio lighting), `ContactShadows`, `PresentationControls`, `useGLTF` for model loading, and `EffectComposer` for post-processing. |
| **Three.js Fundamentals** | Understanding of scene graph, geometries, materials (PBR), lights, cameras, and renderers. Direct manipulation of Three.js objects when necessary. |
| **3D Model Handling** | Efficient loading and rendering of GLB/GLTF models, including understanding of model hierarchy, nodes, and meshes. Implementation of 3D model optimization techniques (e.g., compression, Level of Detail - LOD). |
| **Dynamic 3D Interaction** | Implementing
interactive camera controls, object manipulation, and event handling within the 3D scene.
## 3. Animation & Interactivity
| Skill Area | Specific Expertise Required |
| :--- | :--- |
| **Framer Motion (v12.38.0)** | Implementing declarative, physics-based animations for UI elements and seamless transitions between states. |
| **GSAP (v3.14.2)** | High-performance, timeline-based animations for complex sequences, micro-interactions, and dynamic visual effects within the 3D configurator. |
| **Micro-interactions** | Designing and implementing subtle animations and visual feedback to enhance user engagement and perceived responsiveness. |
## 4. UI/UX & Accessibility
| Skill Area | Specific Expertise Required |
| :--- | :--- |
| **Responsive Design** | Building UIs that adapt seamlessly across various screen sizes and devices (desktop, tablet, mobile) using Tailwind CSS and responsive design principles. |
| **Glassmorphism Design** | Implementing advanced glassmorphism effects with refined blur algorithms, dynamic lighting, and subtle textures. |
| **Accessibility (WCAG)** | Adherence to Web Content Accessibility Guidelines, including keyboard navigation, ARIA attributes, semantic HTML, and consideration for reduced motion preferences. |
| **User Feedback & Loading States** | Implementing clear visual feedback for user actions, loading indicators (spinners, skeleton screens), and graceful error handling. |
## 5. Performance & Optimization
| Skill Area | Specific Expertise Required |
| :--- | :--- |
| **Web Performance** | Understanding and applying techniques for fast loading times, smooth animations (60fps), and efficient resource utilization. |
| **Asset Optimization** | Optimizing 3D models, textures, and other media assets for web delivery without compromising visual quality. |
| **Debugging & Profiling** | Utilizing browser developer tools and R3F/Three.js specific tools for identifying and resolving performance bottlenecks and rendering issues.

11
next.config.mjs Normal file
View File

@ -0,0 +1,11 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
images: {
unoptimized: true,
},
trailingSlash: true,
reactStrictMode: true,
};
export default nextConfig;

4309
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
package.json Normal file
View File

@ -0,0 +1,44 @@
{
"name": "yslootah-robotics-g1-configurator",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@react-spring/three": "^10.0.3",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@react-three/postprocessing": "^2.16.0",
"@types/three": "^0.183.1",
"framer-motion": "^12.38.0",
"gsap": "^3.14.2",
"i18next": "^26.0.3",
"i18next-browser-languagedetector": "^8.2.1",
"next": "16.2.2",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-i18next": "^17.0.2",
"three": "^0.170.0",
"zustand": "^5.0.12"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.1",
"jsdom": "^29.0.2",
"tailwindcss": "4.2.2",
"typescript": "^5.0.0",
"vitest": "^4.1.2"
}
}

7
postcss.config.mjs Normal file
View File

@ -0,0 +1,7 @@
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;

BIN
public/Kandoura.glb Normal file

Binary file not shown.

BIN
public/Suit.glb Normal file

Binary file not shown.

BIN
public/Unitree_G1.glb Normal file

Binary file not shown.

BIN
public/Vest.glb Normal file

Binary file not shown.

214
src/app/admin/page.tsx Normal file
View File

@ -0,0 +1,214 @@
'use client';
import { useEffect, useState } from 'react';
import { pricingStore, usePricingStore } from '@/store/usePricingStore';
import Link from 'next/link';
export default function AdminPage() {
const items = usePricingStore((s) => s.items);
const isHydrated = usePricingStore((s) => s.isHydrated);
const [editedPrices, setEditedPrices] = useState<Record<string, number>>({});
const [saved, setSaved] = useState(false);
useEffect(() => {
pricingStore.getState().hydrate();
}, []);
useEffect(() => {
const map: Record<string, number> = {};
items.forEach((item) => {
map[item.id] = item.price;
});
setEditedPrices(map);
}, [items]);
const handlePriceChange = (id: string, value: string) => {
const num = parseInt(value.replace(/[^0-9]/g, ''), 10);
if (!isNaN(num)) {
setEditedPrices((prev) => ({ ...prev, [id]: num }));
} else if (value === '') {
setEditedPrices((prev) => ({ ...prev, [id]: 0 }));
}
};
const handleSave = () => {
Object.entries(editedPrices).forEach(([id, price]) => {
pricingStore.getState().updatePrice(id, price);
});
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
const handleReset = () => {
pricingStore.getState().resetPrices();
setSaved(false);
};
const formatPrice = (price: number) => {
return new Intl.NumberFormat('en-AE', { style: 'decimal' }).format(price);
};
if (!isHydrated) {
return (
<div style={pageStyle}>
<p style={{ color: '#64748b' }}>Loading...</p>
</div>
);
}
return (
<div style={pageStyle}>
<div style={containerStyle}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '2rem' }}>
<div>
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, color: '#1a1a2e', margin: 0, marginBottom: '0.25rem' }}>
Pricing Dashboard
</h1>
<p style={{ fontSize: '0.8rem', color: '#94a3b8', margin: 0 }}>
Edit prices for the G1 Robot Configurator
</p>
</div>
<Link
href="/"
style={{
padding: '0.5rem 1rem',
borderRadius: '0.375rem',
border: '1px solid rgba(59, 130, 246, 0.2)',
background: 'rgba(59, 130, 246, 0.06)',
color: '#2563eb',
fontSize: '0.8rem',
textDecoration: 'none',
transition: 'all 0.2s ease',
}}
>
Back to Configurator
</Link>
</div>
{/* Pricing Table */}
<div style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(20px)',
border: '1px solid rgba(0, 0, 0, 0.06)',
borderRadius: '0.75rem',
overflow: 'hidden',
}}>
{/* Table Header */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 200px',
padding: '0.75rem 1.25rem',
borderBottom: '1px solid rgba(0, 0, 0, 0.04)',
background: 'rgba(248, 248, 246, 0.5)',
}}>
<span style={{ fontSize: '0.7rem', fontWeight: 600, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Item
</span>
<span style={{ fontSize: '0.7rem', fontWeight: 600, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em', textAlign: 'right' }}>
Price (AED)
</span>
</div>
{/* Rows */}
{items.map((item, index) => (
<div
key={item.id}
style={{
display: 'grid',
gridTemplateColumns: '1fr 200px',
padding: '1rem 1.25rem',
alignItems: 'center',
borderBottom: index < items.length - 1 ? '1px solid rgba(0, 0, 0, 0.04)' : 'none',
transition: 'background 0.15s ease',
}}
>
<div>
<div style={{ fontSize: '0.875rem', color: '#374151', fontWeight: 500 }}>
{item.label}
</div>
<div style={{ fontSize: '0.7rem', color: '#94a3b8', fontFamily: 'monospace' }}>
{item.id}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '0.5rem' }}>
<span style={{ fontSize: '0.75rem', color: '#94a3b8' }}>AED</span>
<input
type="text"
value={formatPrice(editedPrices[item.id] ?? item.price)}
onChange={(e) => handlePriceChange(item.id, e.target.value)}
style={{
width: '130px',
padding: '0.5rem 0.75rem',
borderRadius: '0.375rem',
border: '1px solid rgba(0, 0, 0, 0.1)',
background: 'rgba(255, 255, 255, 1)',
color: '#1a1a2e',
fontSize: '0.875rem',
fontFamily: 'monospace',
textAlign: 'right',
outline: 'none',
transition: 'border-color 0.2s ease',
}}
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = 'rgba(0, 0, 0, 0.1)'; }}
aria-label={`Price for ${item.label}`}
/>
</div>
</div>
))}
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1.5rem', justifyContent: 'flex-end' }}>
<button
onClick={handleReset}
style={{
padding: '0.6rem 1.25rem',
borderRadius: '0.375rem',
border: '1px solid rgba(239, 68, 68, 0.2)',
background: 'rgba(239, 68, 68, 0.05)',
color: '#ef4444',
cursor: 'pointer',
fontSize: '0.8rem',
transition: 'all 0.2s ease',
}}
>
Reset to Defaults
</button>
<button
onClick={handleSave}
style={{
padding: '0.6rem 1.5rem',
borderRadius: '0.375rem',
border: '1px solid rgba(59, 130, 246, 0.3)',
background: saved ? 'rgba(34, 197, 94, 0.08)' : 'rgba(59, 130, 246, 0.08)',
color: saved ? '#16a34a' : '#2563eb',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
transition: 'all 0.2s ease',
}}
>
{saved ? 'Saved!' : 'Save Prices'}
</button>
</div>
</div>
</div>
);
}
const pageStyle: React.CSSProperties = {
minHeight: '100vh',
background: '#ffffff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem',
fontFamily: 'system-ui, -apple-system, sans-serif',
};
const containerStyle: React.CSSProperties = {
width: '100%',
maxWidth: '640px',
};

228
src/app/globals.css Normal file
View File

@ -0,0 +1,228 @@
@import "tailwindcss";
@theme {
/* Light Mode Color Palette */
--color-primary: #ffffff;
--color-secondary: #f8f8f6;
--color-accent: #3b82f6;
--color-accent-hover: #2563eb;
--color-gold: #c4a265;
--color-text-primary: #1a1a2e;
--color-text-secondary: #64748b;
--color-text-muted: #94a3b8;
--color-border: #e2e8f0;
--color-border-light: #cbd5e1;
/* Glassmorphism Colors - Light */
--color-glass-bg: rgba(255, 255, 255, 0.85);
--color-glass-border: rgba(0, 0, 0, 0.08);
--color-glass-highlight: rgba(255, 255, 255, 0.5);
/* Spacing & Sizing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
/* Border Radius */
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
/* Shadows */
--shadow-glow: 0 2px 20px rgba(0, 0, 0, 0.06);
--shadow-glow-lg: 0 4px 40px rgba(0, 0, 0, 0.08);
/* Transitions */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
--transition-slow: 300ms ease;
}
/* Base Styles */
html,
body {
margin: 0;
padding: 0;
min-height: 100vh;
background-color: var(--color-primary);
color: var(--color-text-primary);
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
overflow-x: hidden;
}
/* Scroll Snap */
html {
scroll-behavior: smooth;
scroll-snap-type: y mandatory;
}
/* Focus Styles for Accessibility */
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
/* Selection Styles */
::selection {
background-color: var(--color-accent);
color: #ffffff;
}
/* Glassmorphism Utilities - Light */
.glass-panel {
background: var(--color-glass-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--color-glass-border);
box-shadow: var(--shadow-glow);
}
.glass-panel-highlight {
background: linear-gradient(
135deg,
var(--color-glass-highlight) 0%,
transparent 50%
);
}
/* Hero animated gradient - Light */
.hero-gradient {
position: absolute;
inset: 0;
background: radial-gradient(ellipse 80% 60% at 50% 40%, rgba(59, 130, 246, 0.04) 0%, transparent 60%),
radial-gradient(ellipse 60% 50% at 30% 70%, rgba(196, 162, 101, 0.03) 0%, transparent 50%),
linear-gradient(180deg, #f0f0ec 0%, #e8e8e4 50%, #f0f0ec 100%);
animation: heroShift 12s ease-in-out infinite alternate;
}
@keyframes heroShift {
0% {
background-position: 0% 0%, 0% 0%, 0% 0%;
opacity: 1;
}
50% {
opacity: 0.9;
}
100% {
background-position: 100% 100%, 100% 0%, 0% 0%;
opacity: 1;
}
}
/* Fade in up animation */
.fade-in-up {
animation: fadeInUp 0.8s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Scroll indicator - dark for light mode */
.scroll-indicator {
width: 1px;
height: 40px;
position: relative;
overflow: hidden;
}
.scroll-indicator::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 1px;
height: 100%;
background: linear-gradient(180deg, #c4a265, transparent);
animation: scrollPulse 2s ease-in-out infinite;
}
@keyframes scrollPulse {
0%, 100% {
transform: translateY(-100%);
opacity: 0;
}
50% {
transform: translateY(0);
opacity: 1;
}
}
/* Spin animation for loaders */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Scrollbar Styling - Light */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--color-border-light);
border-radius: var(--radius-sm);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
}
/* Snap children */
.snap-section {
scroll-snap-align: start;
height: 100vh;
}
/* Responsive Layout Styles */
@media (max-width: 1024px) {
.glass-panel-responsive {
width: 360px !important;
}
}
@media (max-width: 768px) {
.layout-container {
flex-direction: column !important;
}
.glass-panel-responsive {
position: fixed !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
width: 100% !important;
height: 50vh !important;
border-left: none !important;
border-right: none !important;
border-top: 1px solid var(--color-border) !important;
box-shadow: 0 -4px 40px rgba(0, 0, 0, 0.08) !important;
border-radius: 1rem 1rem 0 0 !important;
z-index: 50;
}
.mobile-handle {
display: flex !important;
}
}
.mobile-handle {
display: none;
}

36
src/app/layout.tsx Normal file
View File

@ -0,0 +1,36 @@
import type { Metadata, Viewport } from "next";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { I18nProvider } from "@/components/I18nProvider";
import "./globals.css";
export const metadata: Metadata = {
title: "G1 Configurator | Lootah Robotics",
description: "3D Configurator for the G1 Robot by Lootah Robotics",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
themeColor: "#ffffff",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" dir="ltr">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body suppressHydrationWarning>
<ErrorBoundary>
<I18nProvider>{children}</I18nProvider>
</ErrorBoundary>
</body>
</html>
);
}

39
src/app/page.tsx Normal file
View File

@ -0,0 +1,39 @@
"use client";
import { useRef } from "react";
import { ClientOnly } from "@/components/ClientOnly";
import { ScrollScene } from "@/components/ScrollScene";
import { ScrollOverlays } from "@/components/ScrollOverlays";
import { ConfiguratorSection } from "@/components/ConfiguratorSection";
export default function HomePage() {
const scrollContainerRef = useRef<HTMLDivElement>(null);
return (
<>
{/* Fixed 3D scene behind everything */}
<ClientOnly>
<ScrollScene scrollContainerRef={scrollContainerRef} />
</ClientOnly>
{/* Text overlays that fade based on scroll */}
<ScrollOverlays />
{/* Scroll spacer with snap sections -- 7 sections for the landing */}
<div ref={scrollContainerRef} style={{ position: "relative", zIndex: 1, pointerEvents: "none" }}>
<div className="snap-section" />
<div className="snap-section" />
<div className="snap-section" />
<div className="snap-section" />
<div className="snap-section" />
<div className="snap-section" />
<div className="snap-section" />
</div>
{/* Configurator section */}
<div style={{ position: "relative", zIndex: 20 }}>
<ConfiguratorSection />
</div>
</>
);
}

View File

@ -0,0 +1,163 @@
'use client';
import { useCallback, useEffect } from 'react';
import { useOrderStore, orderStore, type CheckoutStep } from '@/store/useOrderStore';
import { ShippingStep } from './checkout/ShippingStep';
import { PaymentStep } from './checkout/PaymentStep';
import { ReviewStep } from './checkout/ReviewStep';
import { ConfirmationStep } from './checkout/ConfirmationStep';
const STEPS: { id: CheckoutStep; label: string }[] = [
{ id: 'shipping', label: 'Shipping' },
{ id: 'payment', label: 'Payment' },
{ id: 'review', label: 'Review' },
];
function getStepIndex(step: CheckoutStep): number {
return STEPS.findIndex((s) => s.id === step);
}
export function CheckoutOverlay() {
const step = useOrderStore((s) => s.step);
const handleClose = useCallback(() => {
orderStore.getState().resetOrder();
}, []);
const handleBack = useCallback(() => {
const currentIndex = getStepIndex(step);
if (currentIndex > 0) {
orderStore.getState().setStep(STEPS[currentIndex - 1].id);
} else {
orderStore.getState().setStep('config');
}
}, [step]);
// Close on Escape key
useEffect(() => {
if (step === 'config') return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [step, handleClose]);
if (step === 'config') return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label="Checkout"
style={{
position: 'fixed',
inset: 0,
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(255, 255, 255, 0.92)',
backdropFilter: 'blur(12px)',
}}
>
<div style={{
width: '100%',
maxWidth: '560px',
maxHeight: '90vh',
overflowY: 'auto',
margin: '1rem',
background: 'rgba(255, 255, 255, 0.95)',
border: '1px solid rgba(59, 130, 246, 0.08)',
borderRadius: '1rem',
boxShadow: '0 24px 80px rgba(0, 0, 0, 0.5), 0 0 40px rgba(59, 130, 246, 0.1)',
}}>
{/* Header with progress */}
{step !== 'confirmed' && (
<div style={{ padding: '1.25rem 1.5rem', borderBottom: '1px solid rgba(59, 130, 246, 0.1)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<button
onClick={handleBack}
style={{
padding: '0.35rem 0.75rem',
borderRadius: '0.375rem',
border: '1px solid rgba(0, 0, 0, 0.08)',
background: 'transparent',
color: '#94a3b8',
cursor: 'pointer',
fontSize: '0.75rem',
transition: 'all 0.2s ease',
}}
>
Back
</button>
<h2 style={{ fontSize: '0.9rem', fontWeight: 600, color: '#1a1a2e', margin: 0 }}>
Checkout
</h2>
<button
onClick={handleClose}
style={{
width: '28px',
height: '28px',
borderRadius: '50%',
border: '1px solid rgba(0, 0, 0, 0.08)',
background: 'transparent',
color: '#94a3b8',
cursor: 'pointer',
fontSize: '0.9rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
aria-label="Close checkout"
>
x
</button>
</div>
{/* Progress bar */}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{STEPS.map((s, i) => {
const currentIdx = getStepIndex(step);
const isActive = i === currentIdx;
const isComplete = i < currentIdx;
return (
<div key={s.id} style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<div style={{
height: '3px',
borderRadius: '2px',
background: isComplete
? '#3b82f6'
: isActive
? 'linear-gradient(90deg, #3b82f6, rgba(59, 130, 246, 0.2))'
: 'rgba(0, 0, 0, 0.06)',
transition: 'all 0.3s ease',
}} />
<span style={{
fontSize: '0.65rem',
color: isActive ? '#2563eb' : isComplete ? '#3b82f6' : '#64748b',
fontWeight: isActive ? 600 : 400,
textAlign: 'center',
}}>
{s.label}
</span>
</div>
);
})}
</div>
</div>
)}
{/* Step Content */}
<div style={{ padding: '1.5rem' }}>
{step === 'shipping' && <ShippingStep />}
{step === 'payment' && <PaymentStep />}
{step === 'review' && <ReviewStep />}
{step === 'confirmed' && <ConfirmationStep />}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,22 @@
'use client';
import { useState, useEffect, ReactNode } from 'react';
interface ClientOnlyProps {
children: ReactNode;
fallback?: ReactNode;
}
export function ClientOnly({ children, fallback = null }: ClientOnlyProps) {
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
setHasMounted(true);
}, []);
if (!hasMounted) {
return <>{fallback}</>;
}
return <>{children}</>;
}

View File

@ -0,0 +1,229 @@
'use client';
import { useCallback, useRef } from 'react';
import { configStore, useConfigStore } from '@/store/useConfigStore';
import { PricingEngine } from './PricingEngine';
// Persona attire options with visual metadata
const PERSONA_OPTIONS = [
{
id: 'none',
label: 'Default',
description: 'Original robot appearance',
colors: { torso: '#3b82f6', legs: '#3b82f6' },
},
{
id: 'emarati-kandura',
label: 'Emarati Kandura',
description: 'Traditional white robe attire',
colors: { torso: '#f8fafc', legs: '#f8fafc' },
},
{
id: 'industrial-vest',
label: 'Industrial Vest',
description: 'High-visibility safety vest',
colors: { torso: '#f59e0b', legs: '#3b82f6' },
},
{
id: 'business-suit',
label: 'Business Suit',
description: 'Professional navy suit',
colors: { torso: '#1e293b', legs: '#1e293b' },
},
] as const;
export function ConfigPanel() {
const activeColors = useConfigStore((s) => s.activeColors);
const activePersona = useConfigStore((s) => s.activePersonaAttire);
const colorsSectionRef = useRef<HTMLElement>(null);
const personaSectionRef = useRef<HTMLElement>(null);
const handleColorChange = useCallback((key: 'primary' | 'secondary' | 'accent', value: string) => {
configStore.getState().setColors({ [key]: value });
}, []);
const handlePersonaSelect = useCallback((attire: string) => {
configStore.getState().setPersonaAttire(attire);
}, []);
const handleReset = useCallback(() => {
configStore.getState().reset();
configStore.getState().setHydrated(true);
}, []);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{/* --- COLORS SECTION --- */}
<section
ref={colorsSectionRef}
id="section-colors"
style={{
borderRadius: '0.5rem',
padding: '0.75rem',
}}
>
<h3 style={sectionTitleStyle}>Colors</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<ColorInput label="Primary" value={activeColors.primary} onChange={(v) => handleColorChange('primary', v)} />
</div>
</section>
{/* --- PERSONA SECTION --- */}
<section
ref={personaSectionRef}
id="section-persona"
style={{
borderRadius: '0.5rem',
padding: '0.75rem',
}}
>
<h3 style={sectionTitleStyle}>Persona Attire</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{PERSONA_OPTIONS.map((persona) => {
const isActive = activePersona === persona.id;
return (
<button
key={persona.id}
onClick={() => handlePersonaSelect(persona.id)}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.6rem 0.75rem',
borderRadius: '0.5rem',
border: isActive
? '1px solid rgba(59, 130, 246, 0.5)'
: '1px solid rgba(0, 0, 0, 0.06)',
background: isActive
? 'rgba(59, 130, 246, 0.06)'
: 'rgba(248, 248, 246, 0.4)',
cursor: 'pointer',
transition: 'all 0.25s ease',
textAlign: 'left',
width: '100%',
}}
aria-pressed={isActive}
>
{/* Color preview swatch */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px', flexShrink: 0 }}>
<div style={{
width: '36px',
height: '12px',
borderRadius: '3px 3px 0 0',
backgroundColor: persona.colors.torso,
border: '1px solid rgba(255,255,255,0.1)',
borderBottom: 'none',
}} />
<div style={{
width: '36px',
height: '12px',
borderRadius: '0 0 3px 3px',
backgroundColor: persona.colors.legs,
border: '1px solid rgba(255,255,255,0.1)',
borderTop: 'none',
}} />
</div>
{/* Text */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: '0.8rem',
fontWeight: isActive ? 600 : 400,
color: isActive ? '#374151' : '#94a3b8',
marginBottom: '2px',
}}>
{persona.label}
</div>
<div style={{
fontSize: '0.65rem',
color: '#64748b',
lineHeight: 1.3,
}}>
{persona.description}
</div>
</div>
{/* Active checkmark */}
{isActive && (
<div style={{
width: '20px',
height: '20px',
borderRadius: '50%',
background: 'rgba(59, 130, 246, 0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#2563eb" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
)}
</button>
);
})}
</div>
</section>
{/* --- PRICING --- */}
<PricingEngine />
{/* --- RESET --- */}
<button
onClick={handleReset}
style={{
padding: '0.6rem',
borderRadius: '0.375rem',
border: '1px solid rgba(239, 68, 68, 0.2)',
background: 'rgba(239, 68, 68, 0.05)',
color: '#ef4444',
cursor: 'pointer',
fontSize: '0.8rem',
transition: 'all 0.2s ease',
}}
>
Reset Configuration
</button>
</div>
);
}
function ColorInput({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input
type="color"
value={value}
onChange={(e) => onChange(e.target.value)}
style={{
width: '32px',
height: '32px',
border: '2px solid rgba(0, 0, 0, 0.08)',
borderRadius: '0.375rem',
background: 'transparent',
cursor: 'pointer',
padding: 0,
}}
aria-label={`${label} color`}
/>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '0.8rem', color: '#374151', marginBottom: '2px' }}>{label}</div>
<div style={{ fontSize: '0.7rem', color: '#64748b', fontFamily: 'monospace' }}>{value}</div>
</div>
</div>
);
}
const sectionTitleStyle: React.CSSProperties = {
fontSize: '0.75rem',
fontWeight: 500,
color: '#94a3b8',
textTransform: 'uppercase',
letterSpacing: '0.05em',
margin: 0,
paddingBottom: '0.5rem',
borderBottom: '1px solid rgba(0, 0, 0, 0.06)',
marginBottom: '0.75rem',
};

View File

@ -0,0 +1,165 @@
'use client';
import { useTranslation } from 'react-i18next';
import { useUrlSync } from '@/hooks/useUrlSync';
import { RobotCanvas } from '@/components/RobotCanvas';
import { ClientOnly } from '@/components/ClientOnly';
import { ConfigPanel } from '@/components/ConfigPanel';
import { CheckoutOverlay } from '@/components/CheckoutOverlay';
export function ConfiguratorSection() {
const { isHydrated } = useUrlSync();
const { t } = useTranslation();
return (
<section
id="configurator"
className="snap-section"
style={{
position: 'relative',
width: '100%',
height: '100vh',
background: '#ffffff',
}}
>
<div
className="layout-container"
style={{
display: 'flex',
width: '100%',
height: '100%',
overflow: 'hidden',
}}
>
<aside
className="glass-panel-responsive"
style={{
order: -1,
width: '420px',
height: '100%',
background: 'rgba(255, 255, 255, 0.9)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
borderRight: '1px solid #e2e8f0',
boxShadow: '4px 0 30px rgba(0, 0, 0, 0.04)',
display: 'flex',
flexDirection: 'column',
position: 'relative',
overflow: 'hidden',
}}
role="complementary"
aria-label={t('panel.title')}
>
<div
className="mobile-handle"
style={{
padding: '0.75rem',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
aria-hidden="true"
>
<div
style={{
width: '40px',
height: '4px',
backgroundColor: '#e2e8f0',
borderRadius: '2px',
}}
/>
</div>
<header
style={{
padding: '1.25rem 1.5rem',
borderBottom: '1px solid #e2e8f0',
position: 'relative',
zIndex: 1,
}}
>
<h2
style={{
fontSize: '1rem',
fontWeight: 600,
color: '#1a1a2e',
margin: 0,
}}
>
{t('panel.title')}
</h2>
</header>
<div
style={{
flex: 1,
padding: '1.5rem',
overflowY: 'auto',
position: 'relative',
zIndex: 1,
}}
role="region"
aria-label={t('panel.currentConfiguration')}
>
<ConfigPanel />
</div>
</aside>
<main
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
width: '100%',
height: '100%',
}}
role="main"
aria-label={t('app.title')}
>
{!isHydrated && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#ffffff',
zIndex: 10,
}}
role="status"
aria-live="polite"
>
<div
style={{
width: '48px',
height: '48px',
border: '3px solid #e2e8f0',
borderTopColor: '#3b82f6',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}}
/>
<p style={{ marginTop: '1rem', fontSize: '0.875rem', color: '#94a3b8' }}>
{t('loading.configuration')}
</p>
</div>
)}
{isHydrated && (
<div style={{ width: '100%', height: '100%' }}>
<ClientOnly>
<RobotCanvas />
</ClientOnly>
</div>
)}
</main>
</div>
<CheckoutOverlay />
</section>
);
}

View File

@ -0,0 +1,84 @@
"use client";
import { Component, ReactNode } from "react";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
console.error("ErrorBoundary caught an error:", error, errorInfo);
}
render(): ReactNode {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
padding: "2rem",
backgroundColor: "var(--color-primary)",
color: "var(--color-text-primary)",
textAlign: "center",
}}
role="alert"
aria-live="assertive"
>
<h1 style={{ fontSize: "1.5rem", marginBottom: "1rem" }}>
Something went wrong
</h1>
<p style={{ color: "var(--color-text-secondary)", marginBottom: "1.5rem" }}>
{this.state.error?.message || "An unexpected error occurred"}
</p>
<button
onClick={() => window.location.reload()}
style={{
padding: "0.75rem 1.5rem",
backgroundColor: "var(--color-accent)",
color: "white",
border: "none",
borderRadius: "var(--radius-md)",
cursor: "pointer",
fontWeight: 500,
transition: "background-color var(--transition-fast)",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = "var(--color-accent-hover)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = "var(--color-accent)")
}
>
Reload Page
</button>
</div>
);
}
return this.props.children;
}
}

View File

@ -0,0 +1,35 @@
'use client';
import { useEffect, useState } from 'react';
import { I18nextProvider } from 'react-i18next';
import i18n from '@/i18n/config';
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
// Set initial document direction based on detected language
const lang = i18n.language || 'en';
document.documentElement.dir = lang === 'ar' ? 'rtl' : 'ltr';
document.documentElement.lang = lang;
// Listen for language changes
const handleLanguageChanged = (lng: string) => {
document.documentElement.dir = lng === 'ar' ? 'rtl' : 'ltr';
document.documentElement.lang = lng;
};
i18n.on('languageChanged', handleLanguageChanged);
setIsInitialized(true);
return () => {
i18n.off('languageChanged', handleLanguageChanged);
};
}, []);
if (!isInitialized) {
return null;
}
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
}

View File

@ -0,0 +1,195 @@
'use client';
import { useRef, useState, useCallback } from 'react';
import { Html } from '@react-three/drei';
import { useFrame, ThreeEvent } from '@react-three/fiber';
import * as THREE from 'three';
import { SocketName, getSocketTransform } from './SocketPoints';
interface InteractiveHotspotProps {
socketName: SocketName;
label: string;
icon?: string;
onClick?: (socketName: SocketName) => void;
visible?: boolean;
}
export function InteractiveHotspot({
socketName,
label,
icon = '+',
onClick,
visible = true,
}: InteractiveHotspotProps) {
const meshRef = useRef<THREE.Mesh>(null);
const ringRef = useRef<THREE.Mesh>(null);
const [hovered, setHovered] = useState(false);
const [showTooltip, setShowTooltip] = useState(false);
const transform = getSocketTransform(socketName);
// Pulsing animation for the hotspot
useFrame((state) => {
if (meshRef.current) {
const scale = 1 + Math.sin(state.clock.elapsedTime * 3) * 0.1;
meshRef.current.scale.setScalar(hovered ? 1.3 : scale);
}
if (ringRef.current) {
const ringScale = 1 + Math.sin(state.clock.elapsedTime * 2) * 0.15;
ringRef.current.scale.setScalar(ringScale);
const opacity = 0.3 + Math.sin(state.clock.elapsedTime * 2) * 0.2;
if (ringRef.current.material instanceof THREE.MeshBasicMaterial) {
ringRef.current.material.opacity = opacity;
}
}
});
const handleClick = useCallback(
(e: ThreeEvent<MouseEvent>) => {
e.stopPropagation();
onClick?.(socketName);
},
[onClick, socketName]
);
const handlePointerEnter = useCallback(() => {
setHovered(true);
setShowTooltip(true);
document.body.style.cursor = 'pointer';
}, []);
const handlePointerLeave = useCallback(() => {
setHovered(false);
setShowTooltip(false);
document.body.style.cursor = 'auto';
}, []);
return (
<group position={transform.position}>
{/* 3D pulsing sphere */}
<mesh
ref={meshRef}
onClick={handleClick}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
<sphereGeometry args={[0.025, 16, 16]} />
<meshStandardMaterial
color={hovered ? '#3b82f6' : '#f59e0b'}
emissive={hovered ? '#3b82f6' : '#f59e0b'}
emissiveIntensity={hovered ? 0.8 : 0.4}
transparent
opacity={0.9}
/>
</mesh>
{/* Outer ring */}
<mesh ref={ringRef} rotation={[0, 0, 0]}>
<ringGeometry args={[0.03, 0.04, 32]} />
<meshBasicMaterial
color="#f59e0b"
transparent
opacity={0.5}
side={THREE.DoubleSide}
/>
</mesh>
{/* HTML label */}
<Html
position={[0, 0.06, 0]}
center
distanceFactor={8}
style={{
transition: 'all 0.2s',
opacity: 1,
pointerEvents: 'none',
}}
>
{visible && showTooltip && (
<div
style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(8px)',
border: '1px solid rgba(59, 130, 246, 0.3)',
borderRadius: '8px',
padding: '8px 12px',
whiteSpace: 'nowrap',
fontFamily: 'system-ui, -apple-system, sans-serif',
fontSize: '12px',
color: '#1a1a2e',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
animation: 'fadeIn 0.15s ease-out',
}}
>
<div style={{ fontWeight: 600, marginBottom: 2 }}>{label}</div>
<div
style={{
fontSize: '10px',
color: '#94a3b8',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
<span
style={{
background: '#3b82f6',
color: 'white',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '10px',
}}
>
{icon}
</span>
Click to customize
</div>
</div>
)}
</Html>
</group>
);
}
// Hotspot panel component to manage visibility of multiple hotspots
interface HotspotPanelProps {
onSocketClick?: (socketName: SocketName) => void;
visibleSockets?: SocketName[];
}
export function HotspotPanel({ onSocketClick, visibleSockets }: HotspotPanelProps) {
const defaultSockets: SocketName[] = ['head', 'chest', 'left_shoulder', 'right_shoulder', 'back', 'base'];
const socketsToShow = visibleSockets || defaultSockets;
const socketLabels: Record<SocketName, string> = {
head: 'Head Mount',
chest: 'Chest Plate',
left_shoulder: 'Left Shoulder',
right_shoulder: 'Right Shoulder',
back: 'Back Pack',
base: 'Base Unit',
};
const socketIcons: Record<SocketName, string> = {
head: 'H',
chest: 'C',
left_shoulder: 'L',
right_shoulder: 'R',
back: 'B',
base: 'U',
};
return (
<>
{socketsToShow.map((socket) => (
<InteractiveHotspot
key={socket}
socketName={socket}
label={socketLabels[socket]}
icon={socketIcons[socket]}
onClick={onSocketClick}
/>
))}
</>
);
}

View File

@ -0,0 +1,55 @@
'use client';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export function LanguageSwitcher() {
const { i18n } = useTranslation();
const toggleLanguage = useCallback(() => {
const newLang = i18n.language === 'en' ? 'ar' : 'en';
i18n.changeLanguage(newLang);
// Update document direction for RTL support
document.documentElement.dir = newLang === 'ar' ? 'rtl' : 'ltr';
document.documentElement.lang = newLang;
}, [i18n]);
return (
<button
onClick={toggleLanguage}
style={{
padding: '0.5rem 1rem',
backgroundColor: 'rgba(0, 0, 0, 0.04)',
border: '1px solid rgba(0, 0, 0, 0.08)',
borderRadius: '0.5rem',
color: '#1a1a2e',
cursor: 'pointer',
fontSize: '0.875rem',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
transition: 'all 0.2s ease',
}}
aria-label="Toggle language"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
{i18n.language === 'en' ? 'العربية' : 'English'}
</button>
);
}

View File

@ -0,0 +1,227 @@
'use client';
import React, { Suspense, useMemo, useState, useCallback } from 'react';
import { useGLTF } from '@react-three/drei';
import { useSpring, animated } from '@react-spring/three';
import * as THREE from 'three';
import { SocketName, getSocketTransform } from './SocketPoints';
interface PayloadMeshProps {
payloadId: string;
type: string;
socketPosition: SocketName;
}
// Map payload types to GLB file paths
const PAYLOAD_MODELS: Record<string, string> = {
'camera': '/payload-camera.glb',
'ptz-camera': '/payload-ptz.glb',
'light': '/payload-light.glb',
'sensor': '/payload-sensor.glb',
'speaker': '/payload-speaker.glb',
};
// Normalize payload type for lookup
const getModelPath = (type: string): string | null => {
const normalized = type.toLowerCase().replace(/\s+/g, '-');
return PAYLOAD_MODELS[normalized] || null;
};
// Placeholder materials by payload type - visually distinct and bright
const PLACEHOLDER_MATERIALS: Record<string, THREE.MeshStandardMaterial> = {
camera: new THREE.MeshStandardMaterial({
color: '#3b82f6',
metalness: 0.6,
roughness: 0.4,
emissive: '#3b82f6',
emissiveIntensity: 0.5,
}),
ptzcamera: new THREE.MeshStandardMaterial({
color: '#3b82f6',
metalness: 0.6,
roughness: 0.4,
emissive: '#3b82f6',
emissiveIntensity: 0.5,
}),
ptz: new THREE.MeshStandardMaterial({
color: '#3b82f6',
metalness: 0.6,
roughness: 0.4,
emissive: '#3b82f6',
emissiveIntensity: 0.5,
}),
light: new THREE.MeshStandardMaterial({
color: '#fbbf24',
metalness: 0.8,
roughness: 0.2,
emissive: '#fbbf24',
emissiveIntensity: 0.8,
}),
sensor: new THREE.MeshStandardMaterial({
color: '#22c55e',
metalness: 0.4,
roughness: 0.6,
emissive: '#22c55e',
emissiveIntensity: 0.6,
}),
speaker: new THREE.MeshStandardMaterial({
color: '#a855f7',
metalness: 0.5,
roughness: 0.5,
emissive: '#a855f7',
emissiveIntensity: 0.5,
}),
default: new THREE.MeshStandardMaterial({
color: '#3b82f6',
metalness: 0.6,
roughness: 0.4,
emissive: '#3b82f6',
emissiveIntensity: 0.5,
}),
};
function PayloadLoader({ modelPath }: { modelPath: string }) {
const [hasError, setHasError] = useState(false);
const { scene } = useGLTF(modelPath);
const processedScene = useMemo(() => {
if (hasError) return null;
const cloned = scene.clone();
cloned.traverse((child) => {
if (child instanceof THREE.Mesh) {
if (child.material) {
child.material = new THREE.MeshStandardMaterial({
color: '#64748b',
metalness: 0.6,
roughness: 0.4,
});
}
}
});
return cloned;
}, [scene, hasError]);
if (hasError || !processedScene) {
throw new Error('Failed to load payload model');
}
return <primitive object={processedScene} />;
}
// Placeholder geometry when GLB is not available or fails to load
function PayloadPlaceholder({ type }: { type: string }) {
const normalizedType = type.toLowerCase().replace(/\s+/g, '-').replace('-camera', 'camera');
const material = PLACEHOLDER_MATERIALS[normalizedType] || PLACEHOLDER_MATERIALS.default;
// Different placeholder shapes based on payload type
const renderGeometry = () => {
switch (normalizedType) {
case 'camera':
return <cylinderGeometry args={[0.03, 0.04, 0.08, 16]} />;
case 'ptzcamera':
case 'ptz':
return <cylinderGeometry args={[0.04, 0.05, 0.1, 16]} />;
case 'light':
return <boxGeometry args={[0.06, 0.06, 0.03]} />;
case 'sensor':
return <sphereGeometry args={[0.04, 16, 16]} />;
case 'speaker':
return <cylinderGeometry args={[0.035, 0.035, 0.05, 16]} />;
default:
return <boxGeometry args={[0.05, 0.05, 0.05]} />;
}
};
return (
<mesh rotation={[Math.PI / 2, 0, 0]}>
{renderGeometry()}
<primitive object={material} attach="material" />
</mesh>
);
}
// Error boundary fallback component
function PayloadErrorFallback({ type }: { type: string }) {
return <PayloadPlaceholder type={type} />;
}
// Wrapper with error handling for GLB loading
function PayloadWithErrorBoundary({ modelPath, type }: { modelPath: string; type: string }) {
const [error, setError] = useState<Error | null>(null);
const handleError = useCallback((err: Error) => {
console.warn('Payload GLB failed to load, using placeholder:', err.message);
setError(err);
}, []);
if (error) {
return <PayloadErrorFallback type={type} />;
}
return (
<Suspense fallback={<PayloadPlaceholder type={type} />}>
<ErrorBoundary onError={handleError}>
<PayloadLoader modelPath={modelPath} />
</ErrorBoundary>
</Suspense>
);
}
// Simple error boundary component for class-based fallback
class ErrorBoundary extends React.Component<
{ children: React.ReactNode; onError: (error: Error) => void },
{ hasError: boolean }
> {
constructor(props: { children: React.ReactNode; onError: (error: Error) => void }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error) {
this.props.onError(error);
}
render() {
if (this.state.hasError) {
return <PayloadPlaceholder type="default" />;
}
return this.props.children;
}
}
export function PayloadMesh({ payloadId, type, socketPosition }: PayloadMeshProps) {
const transform = getSocketTransform(socketPosition);
const modelPath = getModelPath(type);
// Spring animation for scale and y position
const { scale, posY } = useSpring({
from: { scale: 0, posY: transform.position.y - 0.1 },
to: { scale: 1, posY: transform.position.y },
config: { mass: 0.3, tension: 300, friction: 20 },
});
return (
<animated.group
position-x={transform.position.x}
position-y={posY}
position-z={transform.position.z}
rotation={transform.rotation}
scale={scale}
>
{/* Always render either GLB model or placeholder */}
{modelPath ? (
<PayloadWithErrorBoundary modelPath={modelPath} type={type} />
) : (
<PayloadPlaceholder type={type} />
)}
</animated.group>
);
}

View File

@ -0,0 +1,130 @@
'use client';
import { useEffect } from 'react';
import { useConfigStore } from '@/store/useConfigStore';
import { usePricingStore, pricingStore } from '@/store/usePricingStore';
import { orderStore } from '@/store/useOrderStore';
const DEFAULT_COLOR = '#96a2b6';
function formatAED(price: number): string {
return new Intl.NumberFormat('en-AE').format(price);
}
export function PricingEngine() {
const persona = useConfigStore((s) => s.activePersonaAttire);
const primaryColor = useConfigStore((s) => s.activeColors.primary);
const items = usePricingStore((s) => s.items);
const isHydrated = usePricingStore((s) => s.isHydrated);
useEffect(() => {
pricingStore.getState().hydrate();
}, []);
const getPrice = (id: string) => items.find((i) => i.id === id)?.price ?? 0;
const basePrice = getPrice('base');
const personaPrice = persona !== 'none' ? getPrice(persona) : 0;
const colorPrice = primaryColor !== DEFAULT_COLOR ? getPrice('custom-color') : 0;
const total = basePrice + personaPrice + colorPrice;
const personaLabel = items.find((i) => i.id === persona)?.label ?? '';
const handleProceed = () => {
const store = orderStore.getState();
store.setOrderTotal(total);
store.setConfigSummary(
persona === 'none' ? 'Default' : personaLabel,
primaryColor
);
store.setStep('shipping');
};
if (!isHydrated) return null;
return (
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
padding: '0.75rem',
borderRadius: '0.5rem',
background: 'rgba(59, 130, 246, 0.03)',
border: '1px solid rgba(59, 130, 246, 0.06)',
}}>
<h3 style={{
fontSize: '0.75rem',
fontWeight: 500,
color: '#94a3b8',
textTransform: 'uppercase',
letterSpacing: '0.05em',
margin: 0,
paddingBottom: '0.5rem',
borderBottom: '1px solid rgba(0, 0, 0, 0.06)',
}}>
Price Breakdown
</h3>
{/* Line items */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<PriceLine label="G1 Robot Base" price={basePrice} />
{personaPrice > 0 && <PriceLine label={personaLabel} price={personaPrice} />}
{colorPrice > 0 && <PriceLine label="Custom Color" price={colorPrice} />}
</div>
{/* Total */}
<div
aria-live="polite"
aria-atomic="true"
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: '0.5rem',
borderTop: '1px solid rgba(0, 0, 0, 0.06)',
marginTop: '0.25rem',
}}
>
<span style={{ fontSize: '0.8rem', fontWeight: 600, color: '#374151' }}>Total</span>
<span style={{ fontSize: '0.9rem', fontWeight: 700, color: '#1a1a2e', fontFamily: 'monospace' }}>
AED {formatAED(total)}
</span>
</div>
{/* Proceed button */}
<button
onClick={handleProceed}
style={{
marginTop: '0.5rem',
padding: '0.7rem',
borderRadius: '0.375rem',
border: '1px solid rgba(59, 130, 246, 0.5)',
background: 'rgba(59, 130, 246, 0.08)',
color: '#2563eb',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
transition: 'all 0.2s ease',
textAlign: 'center',
}}
>
Proceed to Order
</button>
</div>
);
}
function PriceLine({ label, price }: { label: string; price: number }) {
return (
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}>
<span style={{ fontSize: '0.75rem', color: '#94a3b8' }}>{label}</span>
<span style={{ fontSize: '0.75rem', color: '#374151', fontFamily: 'monospace' }}>
AED {formatAED(price)}
</span>
</div>
);
}

View File

@ -0,0 +1,163 @@
'use client';
import React, { Suspense, useRef, useCallback, useState } from 'react';
import { Canvas } from '@react-three/fiber';
import {
Environment,
ContactShadows,
OrbitControls,
useProgress,
Html,
} from '@react-three/drei';
import { useThree } from '@react-three/fiber';
import { RobotModel } from './RobotModel';
import type { WebGLRenderer, Scene, Camera } from 'three';
function Loader() {
const { progress } = useProgress();
return (
<Html center>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem' }}>
<div style={{
width: '48px', height: '48px',
border: '3px solid rgba(59, 130, 246, 0.15)',
borderTopColor: '#3b82f6',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}} />
<p style={{ fontSize: '0.875rem', color: '#64748b', fontFamily: 'system-ui, sans-serif' }}>
{progress.toFixed(0)}% loaded
</p>
</div>
</Html>
);
}
function SceneCapture({ onCapture }: { onCapture: (gl: WebGLRenderer, scene: Scene, camera: Camera) => void }) {
const { gl, scene, camera } = useThree();
React.useEffect(() => {
onCapture(gl, scene, camera);
}, [gl, scene, camera, onCapture]);
return null;
}
function SceneContent({ onCapture }: { onCapture: (gl: WebGLRenderer, scene: Scene, camera: Camera) => void }) {
return (
<>
<SceneCapture onCapture={onCapture} />
<Environment preset="city" />
<ambientLight intensity={0.8} />
<directionalLight position={[5, 5, 5]} intensity={1.8} color="#ffffff" castShadow shadow-mapSize={[1024, 1024]} />
<directionalLight position={[-4, 3, 2]} intensity={0.8} color="#e8f0ff" />
<directionalLight position={[0, 3, -5]} intensity={0.8} color="#94a3b8" />
<spotLight position={[0, 8, 0]} intensity={1.0} angle={0.6} penumbra={0.5} color="#ffffff" />
<directionalLight position={[0, 2, 5]} intensity={0.7} color="#ffffff" />
<RobotModel />
<OrbitControls enablePan enableZoom enableRotate minDistance={2} maxDistance={10} minPolarAngle={0} maxPolarAngle={Math.PI} dampingFactor={0.05} enableDamping />
<ContactShadows position={[0, -1, 0]} opacity={0.25} scale={10} blur={2} far={4} resolution={256} color="#000000" />
</>
);
}
export function RobotCanvas() {
const glRef = useRef<WebGLRenderer | null>(null);
const sceneRef = useRef<Scene | null>(null);
const cameraRef = useRef<Camera | null>(null);
const [isCapturing, setIsCapturing] = useState(false);
const [shareStatus, setShareStatus] = useState<'idle' | 'copied' | 'failed'>('idle');
const handleCapture = useCallback((gl: WebGLRenderer, scene: Scene, camera: Camera) => {
glRef.current = gl;
sceneRef.current = scene;
cameraRef.current = camera;
}, []);
const handleShare = useCallback(async () => {
try {
await navigator.clipboard.writeText(window.location.href);
setShareStatus('copied');
setTimeout(() => setShareStatus('idle'), 2000);
} catch {
setShareStatus('failed');
setTimeout(() => setShareStatus('idle'), 2000);
}
}, []);
const handleSnapshot = useCallback(() => {
if (!glRef.current || !sceneRef.current || !cameraRef.current) return;
setIsCapturing(true);
try {
glRef.current.render(sceneRef.current, cameraRef.current);
const dataUrl = glRef.current.domElement.toDataURL('image/png');
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const link = document.createElement('a');
link.download = `g1-robot-${timestamp}.png`;
link.href = dataUrl;
link.click();
} catch (error) {
console.error('Failed to capture snapshot:', error);
} finally {
setIsCapturing(false);
}
}, []);
const btnBase: React.CSSProperties = {
position: 'absolute',
top: '1rem',
padding: '0.5rem 1rem',
backdropFilter: 'blur(8px)',
borderRadius: '0.5rem',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
transition: 'all 0.2s ease',
zIndex: 10,
};
return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<Canvas
dpr={[1, 2]}
camera={{ position: [0, 1, 5], fov: 50 }}
gl={{ antialias: true, powerPreference: 'high-performance' }}
style={{ background: 'linear-gradient(180deg, #e8e8e4 0%, #f0f0ec 50%, #e8e8e4 100%)' }}
>
<Suspense fallback={<Loader />}>
<SceneContent onCapture={handleCapture} />
</Suspense>
</Canvas>
<button
onClick={handleSnapshot}
disabled={isCapturing}
style={{ ...btnBase, left: '1rem', backgroundColor: 'rgba(255,255,255,0.8)', border: '1px solid #e2e8f0', color: '#1a1a2e' }}
aria-label="Capture 3D scene snapshot"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" /><circle cx="8.5" cy="8.5" r="1.5" /><polyline points="21 15 16 10 5 21" />
</svg>
{isCapturing ? 'Capturing...' : 'Snapshot'}
</button>
<button
onClick={handleShare}
style={{
...btnBase,
left: '8.5rem',
backgroundColor: shareStatus === 'copied' ? 'rgba(34,197,94,0.1)' : 'rgba(255,255,255,0.8)',
border: `1px solid ${shareStatus === 'copied' ? '#86efac' : '#e2e8f0'}`,
color: shareStatus === 'copied' ? '#16a34a' : '#1a1a2e',
}}
aria-label="Share configuration link"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><line x1="8.59" y1="13.51" x2="15.42" y2="17.49" /><line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
</svg>
{shareStatus === 'copied' ? 'Copied!' : 'Share'}
</button>
</div>
);
}

View File

@ -0,0 +1,200 @@
'use client';
import { useRef, useMemo, useEffect, Suspense, useState } from 'react';
import { useGLTF } from '@react-three/drei';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import { useConfigStore } from '@/store/useConfigStore';
const ATTIRE_GLB: Record<string, string> = {
'emarati-kandura': '/Kandoura.glb',
'industrial-vest': '/Vest.glb',
'business-suit': '/Suit.glb',
};
// Preload all attire models so they're cached before user clicks
Object.values(ATTIRE_GLB).forEach((path) => useGLTF.preload(path));
function easeInOutCubic(t: number): number {
return t < 0.5
? 4 * t * t * t
: 1 - Math.pow(-2 * t + 2, 3) / 2;
}
function AttireModel({ glbPath, onLoaded }: { glbPath: string; onLoaded: () => void }) {
const { scene } = useGLTF(glbPath);
const processedAttire = useMemo(() => {
const cloned = scene.clone();
cloned.traverse((child) => {
if (child instanceof THREE.Mesh && child.material instanceof THREE.MeshStandardMaterial) {
child.material.envMapIntensity = 1;
child.material.needsUpdate = true;
}
});
const box = new THREE.Box3().setFromObject(cloned);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 2 / maxDim;
cloned.scale.setScalar(scale);
cloned.position.set(
-center.x * scale,
-center.y * scale + 0.5,
-center.z * scale
);
return cloned;
}, [scene]);
// Signal that the model is ready and rendered
useEffect(() => {
onLoaded();
}, [onLoaded]);
return <primitive object={processedAttire} />;
}
interface RobotModelProps {
onError?: (error: Error) => void;
}
export function RobotModel({ onError }: RobotModelProps) {
const groupRef = useRef<THREE.Group>(null);
const spinningRef = useRef(false);
const spinProgressRef = useRef(0);
const swappedRef = useRef(false);
const targetAttireRef = useRef<string>('none');
const [displayedAttire, setDisplayedAttire] = useState('none');
const [attireReady, setAttireReady] = useState(false);
const previousAttireRef = useRef('none');
const { scene } = useGLTF('/Unitree_G1.glb');
const activeColors = useConfigStore((state) => state.activeColors);
const activePersonaAttire = useConfigStore((state) => state.activePersonaAttire);
// Detect attire change and trigger spin
useEffect(() => {
if (activePersonaAttire !== previousAttireRef.current) {
targetAttireRef.current = activePersonaAttire;
spinningRef.current = true;
spinProgressRef.current = 0;
swappedRef.current = false;
setAttireReady(false);
previousAttireRef.current = activePersonaAttire;
}
}, [activePersonaAttire]);
// Spin animation loop
useFrame((_, delta) => {
if (!spinningRef.current || !groupRef.current) return;
const spinSpeed = 2.5;
spinProgressRef.current += delta * spinSpeed;
const progress = Math.min(spinProgressRef.current, 1);
const easedRotation = easeInOutCubic(progress) * Math.PI * 2;
groupRef.current.rotation.y = easedRotation;
if (progress >= 0.5 && !swappedRef.current) {
setDisplayedAttire(targetAttireRef.current);
swappedRef.current = true;
}
if (progress >= 1) {
groupRef.current.rotation.y = 0;
spinningRef.current = false;
spinProgressRef.current = 0;
}
});
const processedScene = useMemo(() => {
const clonedScene = scene.clone();
clonedScene.traverse((child) => {
if (child instanceof THREE.Mesh) {
if (!child.material) {
child.material = new THREE.MeshStandardMaterial({
color: '#96a2b6',
metalness: 0.8,
roughness: 0.2,
});
}
if (child.material instanceof THREE.MeshStandardMaterial) {
child.material.envMapIntensity = 1;
child.material.needsUpdate = true;
}
}
});
const box = new THREE.Box3().setFromObject(clonedScene);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 2 / maxDim;
clonedScene.scale.setScalar(scale);
const offset = new THREE.Vector3(
-center.x * scale,
-center.y * scale + 0.5,
-center.z * scale
);
clonedScene.position.copy(offset);
return clonedScene;
}, [scene]);
// Hide base robot only when attire is selected AND the attire GLB has loaded
// Show base robot when 'none' is selected or attire is still loading
const showBase = displayedAttire === 'none' || !attireReady;
useEffect(() => {
processedScene.visible = showBase;
}, [showBase, processedScene]);
// Apply primary color to the base robot only
useEffect(() => {
if (!groupRef.current) return;
groupRef.current.traverse((child) => {
if (child instanceof THREE.Mesh && child.material instanceof THREE.MeshStandardMaterial) {
if (child.name.startsWith('Unitree_G1')) {
child.material.color.set(activeColors.primary);
child.material.needsUpdate = true;
}
}
});
}, [activeColors]);
const attireGlbPath = ATTIRE_GLB[displayedAttire] || null;
const handleAttireLoaded = () => {
setAttireReady(true);
};
return (
<group ref={groupRef}>
<primitive object={processedScene} />
{attireGlbPath && (
<Suspense fallback={null}>
<AttireModel
key={attireGlbPath}
glbPath={attireGlbPath}
onLoaded={handleAttireLoaded}
/>
</Suspense>
)}
</group>
);
}
useGLTF.preload('/Unitree_G1.glb');

View File

@ -0,0 +1,269 @@
'use client';
import { useScroll, useTransform, motion } from 'framer-motion';
import { ReactNode } from 'react';
interface SectionProps {
children: ReactNode;
progress: any;
startAt: number;
peakAt: number;
endAt: number;
align: 'center' | 'left' | 'right';
verticalAlign?: 'top' | 'center' | 'bottom';
offsetY?: number;
}
function OverlaySection({
children,
progress,
startAt,
peakAt,
endAt,
align,
verticalAlign = 'center',
offsetY = 50,
}: SectionProps) {
// Define a wide "plateau" zone where the text is fully readable and static.
// We use 30% of the travel distance for fading in, 30% for fading out.
const diffIn = peakAt - startAt;
const diffOut = endAt - peakAt;
const inStart = startAt;
const inEnd = startAt + diffIn * 0.4; // Fades in quickly
const outStart = endAt - diffOut * 0.6; // Holds for a long time
const outEnd = endAt;
// Smooth fade in and out mapping over a lengthy plateau
const opacity = useTransform(
progress,
[inStart, inEnd, outStart, outEnd],
[0, 1, 1, 0]
);
// Translate Y to slide in, pause entirely while reading, slide out
const y = useTransform(
progress,
[inStart, inEnd, outStart, outEnd],
[offsetY, 0, 0, -offsetY]
);
// Scale stays at 1.0 during reading mode
const scale = useTransform(
progress,
[inStart, inEnd, outStart, outEnd],
[0.95, 1, 1, 1.05]
);
const alignStyle: React.CSSProperties =
align === 'left'
? { left: 'clamp(2rem, 6vw, 6rem)', alignItems: 'flex-start' }
: align === 'right'
? { right: 'clamp(2rem, 6vw, 6rem)', alignItems: 'flex-end' }
: { left: '50%', x: '-50%', alignItems: 'center' };
const verticalStyle: React.CSSProperties =
verticalAlign === 'top'
? { top: '15vh' }
: verticalAlign === 'bottom'
? { bottom: '15vh', top: 'auto' }
: { top: '50%', y: '-50%' };
// Glass panel appearance for side text to not clash with the robot
const isCenter = align === 'center';
const panelStyle: React.CSSProperties = isCenter
? { textAlign: 'center' }
: {
background: 'rgba(255, 255, 255, 0.45)',
backdropFilter: 'blur(16px)',
WebkitBackdropFilter: 'blur(16px)',
padding: '2.5rem',
borderRadius: '1.5rem',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.08)',
border: '1px solid rgba(255, 255, 255, 0.6)',
textAlign: align === 'left' ? 'left' : 'right',
maxWidth: '450px',
};
return (
<motion.div
style={{
position: 'absolute',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
opacity,
pointerEvents: 'none',
...verticalStyle,
...alignStyle,
}}
>
<motion.div
style={{
y,
scale,
...panelStyle,
}}
className="will-change-transform"
>
{children}
</motion.div>
</motion.div>
);
}
// Section timing definitions to match ScrollScene camera moves
const SECTION_CONFIGS = [
{ id: 'brand', startAt: 0, peakAt: 0.05, endAt: 0.15, align: 'center' as const, verticalAlign: 'top' as const },
{ id: 'hero', startAt: 0.10, peakAt: 0.22, endAt: 0.35, align: 'left' as const, verticalAlign: 'center' as const },
{ id: 'headReveal', startAt: 0.35, peakAt: 0.46, endAt: 0.53, align: 'right' as const, verticalAlign: 'center' as const },
{ id: 'customization', startAt: 0.55, peakAt: 0.66, endAt: 0.77, align: 'left' as const, verticalAlign: 'center' as const },
{ id: 'mobility', startAt: 0.80, peakAt: 0.90, endAt: 1.0, align: 'right' as const, verticalAlign: 'center' as const },
];
export function ScrollOverlays() {
const { scrollYProgress } = useScroll();
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 10,
pointerEvents: 'none',
overflow: 'hidden',
}}
>
{/* 1. Brand Intro */}
<OverlaySection progress={scrollYProgress} {...SECTION_CONFIGS[0]}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '1rem', marginBottom: '1rem' }}>
<div style={{ width: '30px', height: '1px', background: 'linear-gradient(90deg, transparent, #c4a265)' }} />
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: '#c4a265', letterSpacing: '0.4em', textTransform: 'uppercase' }}>
YS Lootah Robotics
</span>
<div style={{ width: '30px', height: '1px', background: 'linear-gradient(90deg, #c4a265, transparent)' }} />
</div>
<p style={{ fontSize: '0.9rem', color: '#64748b', fontWeight: 400, letterSpacing: '0.15em', margin: 0 }}>
Pioneering Humanoid Robotics in the UAE
</p>
</OverlaySection>
{/* 2. Hero */}
<OverlaySection progress={scrollYProgress} {...SECTION_CONFIGS[1]}>
<motion.h1 style={{ fontSize: 'clamp(2.5rem, 5vw, 4.5rem)', fontWeight: 200, color: '#1a1a2e', lineHeight: 1.0, letterSpacing: '-0.04em', margin: 0 }}>
The Future
</motion.h1>
<motion.h1 style={{ fontSize: 'clamp(2.5rem, 5vw, 4.5rem)', fontWeight: 200, color: '#1a1a2e', lineHeight: 1.0, letterSpacing: '-0.04em', margin: '0.1em 0 0' }}>
of <span style={{ color: '#c4a265', fontWeight: 400 }}>Robotics</span>
</motion.h1>
<p style={{ fontSize: '1rem', color: '#475569', lineHeight: 1.7, margin: '1.5rem 0 0', fontWeight: 300 }}>
Meet the G1 Humanoid Robot. Fully customizable, enterprise-ready, designed for the world of tomorrow.
</p>
</OverlaySection>
{/* 3. Head Reveal */}
<OverlaySection progress={scrollYProgress} {...SECTION_CONFIGS[2]}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
<div style={{ width: '50px', height: '2px', background: '#c4a265', marginBottom: '1.5rem' }} />
<div style={{ fontSize: '0.65rem', fontWeight: 600, color: '#c4a265', letterSpacing: '0.3em', textTransform: 'uppercase', marginBottom: '1rem' }}>
Intelligent by Design
</div>
<h2 style={{ fontSize: 'clamp(2rem, 3.5vw, 3rem)', fontWeight: 300, color: '#1a1a2e', lineHeight: 1.1, margin: '0 0 1rem', letterSpacing: '-0.03em' }}>
Vision That<br />Understands
</h2>
<p style={{ fontSize: '0.95rem', color: '#475569', lineHeight: 1.6, margin: 0, fontWeight: 300 }}>
Advanced computer vision and neural processing. The G1 sees, interprets, and responds to the world in real-time.
</p>
<div style={{ display: 'flex', gap: '2.5rem', marginTop: '2rem' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '1.8rem', fontWeight: 300, color: '#1a1a2e', fontFamily: 'monospace' }}>360°</div>
<div style={{ fontSize: '0.65rem', color: '#64748b', letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: '0.5rem' }}>Field of View</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '1.8rem', fontWeight: 300, color: '#1a1a2e', fontFamily: 'monospace' }}>{'<'}50ms</div>
<div style={{ fontSize: '0.65rem', color: '#64748b', letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: '0.5rem' }}>Response Time</div>
</div>
</div>
</div>
</OverlaySection>
{/* 4. Customization */}
<OverlaySection progress={scrollYProgress} {...SECTION_CONFIGS[3]}>
<div style={{ width: '50px', height: '2px', background: '#c4a265', marginBottom: '1.5rem' }} />
<div style={{ fontSize: '0.65rem', fontWeight: 600, color: '#c4a265', letterSpacing: '0.3em', textTransform: 'uppercase', marginBottom: '1rem' }}>
Your Identity
</div>
<h2 style={{ fontSize: 'clamp(2rem, 3.5vw, 3rem)', fontWeight: 300, color: '#1a1a2e', lineHeight: 1.1, margin: '0 0 1rem', letterSpacing: '-0.03em' }}>
Dress for Any<br />Mission
</h2>
<p style={{ fontSize: '0.95rem', color: '#475569', lineHeight: 1.6, margin: 0, fontWeight: 300 }}>
From traditional Emarati Kandura to industrial safety gear and professional business attire. Configure every detail to match your brand.
</p>
<div style={{ display: 'flex', gap: '1rem', marginTop: '2rem' }}>
{['Kandura', 'Vest', 'Suit'].map((label) => (
<div
key={label}
style={{
padding: '0.5rem 1rem',
borderRadius: '2rem',
background: 'rgba(196, 162, 101, 0.1)',
border: '1px solid rgba(196, 162, 101, 0.3)',
fontSize: '0.75rem',
color: '#c4a265',
letterSpacing: '0.1em',
fontWeight: 500
}}
>
{label}
</div>
))}
</div>
</OverlaySection>
{/* 5. Mobility */}
<OverlaySection progress={scrollYProgress} {...SECTION_CONFIGS[4]}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
<div style={{ width: '50px', height: '2px', background: '#c4a265', marginBottom: '1.5rem' }} />
<div style={{ fontSize: '0.65rem', fontWeight: 600, color: '#c4a265', letterSpacing: '0.3em', textTransform: 'uppercase', marginBottom: '1rem' }}>
Advanced Mobility
</div>
<h2 style={{ fontSize: 'clamp(2rem, 3.5vw, 3rem)', fontWeight: 300, color: '#1a1a2e', lineHeight: 1.1, margin: '0 0 1rem', letterSpacing: '-0.03em' }}>
23 Degrees of<br />Freedom
</h2>
<p style={{ fontSize: '0.95rem', color: '#475569', lineHeight: 1.6, margin: 0, fontWeight: 300 }}>
State-of-the-art locomotion enabling natural human-like movement, balance, and agility across any terrain.
</p>
<div style={{ display: 'flex', gap: '2.5rem', marginTop: '2rem' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '1.8rem', fontWeight: 300, color: '#1a1a2e', fontFamily: 'monospace' }}>2m/s</div>
<div style={{ fontSize: '0.65rem', color: '#64748b', letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: '0.5rem' }}>Max Speed</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '1.8rem', fontWeight: 300, color: '#1a1a2e', fontFamily: 'monospace' }}>127kg</div>
<div style={{ fontSize: '0.65rem', color: '#64748b', letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: '0.5rem' }}>Payload</div>
</div>
</div>
</div>
</OverlaySection>
{/* Scroll indicator mapped to vanish rapidly when scrolled */}
<motion.div
style={{
position: 'absolute',
bottom: '2.5rem',
left: '50%',
x: '-50%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
opacity: useTransform(scrollYProgress, [0, 0.05], [1, 0]),
}}
>
<span style={{ fontSize: '0.65rem', fontWeight: 500, color: '#64748b', letterSpacing: '0.3em', textTransform: 'uppercase', marginBottom: '1rem' }}>
Scroll to Explore
</span>
<div className="scroll-indicator" />
</motion.div>
</div>
);
}

View File

@ -0,0 +1,356 @@
'use client';
import React, { Suspense, useRef, useEffect } from 'react';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { Environment, ContactShadows, useGLTF, Html, useProgress } from '@react-three/drei';
import * as THREE from 'three';
// Original camera keyframes - cinematic path with dramatic shots
const CAMERA_KEYFRAMES: [number, [number, number, number]][] = [
[0.0, [-2.5, 0.6, 7]], // Start: far left wide shot
[0.12, [-1.8, 0.7, 5.5]], // Slow drift toward center
[0.25, [-0.5, 0.9, 4]], // Approach center gently
[0.40, [0.3, 1.5, 2.0]], // HEAD+CHEST: tight, high angle looking down
[0.55, [1.2, 2.2, 2.2]], // ABOVE: bird's eye with slight right offset
[0.68, [1.6, 0.7, 2.5]], // Dramatic right side sweep
[0.80, [0.3, 0.3, 2.2]], // Circle to front-low
[0.92, [0, -0.3, 2.5]], // LOW ANGLE: heroic looking up
[1.0, [0, 1.2, 5.5]], // Final: pull back for configurator
];
const LOOKAT_KEYFRAMES: [number, [number, number, number]][] = [
[0.0, [0, 0.4, 0]],
[0.12, [0, 0.45, 0]],
[0.25, [0, 0.55, 0]],
[0.40, [0, 1.2, 0]], // Look at head during tight shot
[0.55, [0, 0.85, 0]], // Look at chest from above
[0.68, [0, 0.65, 0]],
[0.80, [0, 0.5, 0]],
[0.92, [0, 0.6, 0]], // Look slightly up from low angle
[1.0, [0, 0.5, 0]],
];
function interpolateKeyframes(keyframes: [number, [number, number, number]][], progress: number): THREE.Vector3 {
const clamped = Math.max(0, Math.min(1, progress));
for (let i = 0; i < keyframes.length - 1; i++) {
const [startT, startPos] = keyframes[i];
const [endT, endPos] = keyframes[i + 1];
if (clamped >= startT && clamped <= endT) {
const localT = (clamped - startT) / (endT - startT);
const eased = localT * localT * (3 - 2 * localT);
return new THREE.Vector3(
startPos[0] + (endPos[0] - startPos[0]) * eased,
startPos[1] + (endPos[1] - startPos[1]) * eased,
startPos[2] + (endPos[2] - startPos[2]) * eased,
);
}
}
const last = keyframes[keyframes.length - 1][1];
return new THREE.Vector3(last[0], last[1], last[2]);
}
function lerpScalar(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
const scrollState = { progress: 0, inScrollZone: true };
function ScrollCamera() {
const { camera } = useThree();
const lookAtTarget = useRef(new THREE.Vector3(0, 0.5, 0));
useFrame(({ clock }) => {
if (!scrollState.inScrollZone) return;
const p = scrollState.progress;
const pos = interpolateKeyframes(CAMERA_KEYFRAMES, p);
const lookAt = interpolateKeyframes(LOOKAT_KEYFRAMES, p);
// Dynamic camera oscillation based on scroll position
const oscillationX = Math.sin(p * Math.PI * 4) * 0.05;
const oscillationY = Math.cos(p * Math.PI * 3) * 0.025;
// Add subtle drift during specific zones for cinematic effect
const driftZone = Math.sin(p * Math.PI * 2) * 0.03;
const verticalDrift = Math.sin(clock.elapsedTime * 0.3) * 0.02;
// Apply oscillations to position
const adjustedPos = new THREE.Vector3(
pos.x + oscillationX + driftZone,
pos.y + oscillationY + verticalDrift,
pos.z
);
// Adaptive lerp for smooth camera transitions
const lerpFactor = p < 0.15 ? 0.015 : (p > 0.9 ? 0.02 : 0.02);
camera.position.lerp(adjustedPos, lerpFactor);
lookAtTarget.current.lerp(lookAt, lerpFactor * 1.0);
camera.lookAt(lookAtTarget.current);
});
return null;
}
// Invisible orbiting point lights + halo ring (no visible sphere meshes)
function LightOrbs() {
const blueOrbRef = useRef<THREE.Group>(null);
const goldOrbRef = useRef<THREE.Group>(null);
const haloRef = useRef<THREE.Mesh>(null);
const bluePointRef = useRef<THREE.PointLight>(null);
const goldPointRef = useRef<THREE.PointLight>(null);
useFrame(({ clock }) => {
const p = scrollState.progress;
const time = clock.elapsedTime;
if (blueOrbRef.current) {
// Orbital path
const angle = p * Math.PI * 2.5 + time * 0.15;
const radius = 1.3 + Math.sin(p * Math.PI * 1.5) * 0.25;
blueOrbRef.current.position.set(
Math.cos(angle) * radius,
0.5 + Math.sin(p * Math.PI * 2 + time * 0.25) * 0.35,
Math.sin(angle) * radius,
);
}
if (goldOrbRef.current) {
// Counter-rotating orbit
const angle = -p * Math.PI * 2 + time * 0.1 + Math.PI;
const radius = 1.1 + Math.cos(p * Math.PI * 1.5) * 0.2;
goldOrbRef.current.position.set(
Math.cos(angle) * radius,
1.1 + Math.sin(p * Math.PI * 1.5 + time * 0.2) * 0.3,
Math.sin(angle) * radius,
);
}
if (bluePointRef.current) {
bluePointRef.current.intensity = 2.2 + Math.sin(p * Math.PI * 3 + time * 0.5) * 0.8;
}
if (goldPointRef.current) {
goldPointRef.current.intensity = 2.0 + Math.sin(p * Math.PI * 2.5 + time * 0.35) * 0.6;
}
if (haloRef.current) {
const haloZone = Math.max(0, 1 - Math.abs(p - 0.38) / 0.15);
(haloRef.current.material as THREE.MeshBasicMaterial).opacity = haloZone * 0.7;
haloRef.current.position.set(0, 1.55, 0);
haloRef.current.rotation.x = Math.PI / 2 + Math.sin(time * 0.3) * 0.08;
haloRef.current.rotation.z = p * Math.PI * 2 + time * 0.15;
}
});
return (
<>
{/* Orbiting blue point light (no visible mesh) */}
<group ref={blueOrbRef}>
<pointLight ref={bluePointRef} color="#4a7aff" intensity={2.0} distance={5} decay={2} />
</group>
{/* Orbiting gold point light (no visible mesh) */}
<group ref={goldOrbRef}>
<pointLight ref={goldPointRef} color="#c4a265" intensity={1.8} distance={4.5} decay={2} />
</group>
{/* Halo ring above head - visible during close-up */}
<mesh ref={haloRef} position={[0, 1.6, 0]} rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.25, 0.008, 16, 64]} />
<meshBasicMaterial color="#c4a265" transparent opacity={0} />
</mesh>
</>
);
}
function ScrollLighting() {
const rimLightRef = useRef<THREE.DirectionalLight>(null);
const goldSpotRef = useRef<THREE.SpotLight>(null);
const leftSideRef = useRef<THREE.DirectionalLight>(null);
const rightSideRef = useRef<THREE.DirectionalLight>(null);
const fillRef = useRef<THREE.DirectionalLight>(null);
useFrame(() => {
const p = scrollState.progress;
if (rimLightRef.current) {
rimLightRef.current.intensity = lerpScalar(3.0, 1.0, Math.min(1, p / 0.4));
rimLightRef.current.position.set(
Math.sin(p * Math.PI * 2) * 3,
3,
-5 + Math.sin(p * Math.PI) * 2,
);
}
if (goldSpotRef.current) {
const headZone = Math.max(0, 1 - Math.abs(p - 0.28) / 0.12);
goldSpotRef.current.intensity = lerpScalar(0, 5.0, headZone);
goldSpotRef.current.position.set(0.5, 2.5, 2);
}
if (leftSideRef.current) {
const orbitZone = Math.max(0, 1 - Math.abs(p - 0.45) / 0.15);
leftSideRef.current.intensity = lerpScalar(0.8, 2.8, orbitZone);
leftSideRef.current.position.set(-5 + p * 3, 3, 2);
}
if (rightSideRef.current) {
const orbitZone2 = Math.max(0, 1 - Math.abs(p - 0.58) / 0.15);
rightSideRef.current.intensity = lerpScalar(0.8, 2.8, orbitZone2);
rightSideRef.current.position.set(5 - p * 3, 2, 3);
}
if (fillRef.current) {
fillRef.current.intensity = lerpScalar(0.8, 2.0, Math.max(0, (p - 0.6) / 0.4));
}
});
return (
<>
<directionalLight ref={rimLightRef} position={[0, 3, -5]} intensity={3.0} color="#4a7aff" />
<spotLight ref={goldSpotRef} position={[0.5, 2.5, 2]} intensity={0} color="#c4a265" angle={0.5} penumbra={0.8} />
<directionalLight ref={leftSideRef} position={[-5, 3, 2]} intensity={0.8} color="#e8f0ff" />
<directionalLight ref={rightSideRef} position={[5, 2, 3]} intensity={0.8} color="#e0e8ff" />
<directionalLight ref={fillRef} position={[0, 2, 5]} intensity={0.8} color="#ffffff" />
</>
);
}
function RobotDisplay() {
const { scene } = useGLTF('/Unitree_G1.glb');
const processedScene = React.useMemo(() => {
const cloned = scene.clone();
cloned.traverse((child) => {
if (child instanceof THREE.Mesh) {
if (!child.material) {
child.material = new THREE.MeshStandardMaterial({
color: '#96a2b6',
metalness: 0.8,
roughness: 0.2,
});
}
if (child.material instanceof THREE.MeshStandardMaterial) {
child.material.envMapIntensity = 1.8;
child.material.needsUpdate = true;
}
}
});
const box = new THREE.Box3().setFromObject(cloned);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 2 / maxDim;
cloned.scale.setScalar(scale);
cloned.position.set(
-center.x * scale,
-center.y * scale + 0.5,
-center.z * scale,
);
return cloned;
}, [scene]);
return <primitive object={processedScene} />;
}
function Loader() {
const { progress } = useProgress();
return (
<Html center>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem' }}>
<div style={{
width: '48px', height: '48px',
border: '3px solid rgba(196, 162, 101, 0.15)',
borderTopColor: '#c4a265',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}} />
<p style={{ fontSize: '0.8rem', color: '#64748b', fontFamily: 'Inter, system-ui, sans-serif' }}>
{progress.toFixed(0)}%
</p>
</div>
</Html>
);
}
function SceneContent() {
return (
<>
<ScrollCamera />
<Environment preset="city" />
<ambientLight intensity={0.8} />
<directionalLight position={[5, 5, 5]} intensity={1.8} color="#ffffff" castShadow shadow-mapSize={[1024, 1024]} />
<spotLight position={[0, 8, 0]} intensity={1.2} angle={0.6} penumbra={0.5} color="#ffffff" />
<ScrollLighting />
<LightOrbs />
<RobotDisplay />
<ContactShadows position={[0, -1, 0]} opacity={0.25} scale={10} blur={2} far={4} resolution={256} color="#000000" />
</>
);
}
interface ScrollSceneProps {
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
}
export function ScrollScene({ scrollContainerRef }: ScrollSceneProps) {
useEffect(() => {
const handleScroll = () => {
const scrollTop = window.scrollY;
const container = scrollContainerRef.current;
if (!container) return;
const spacerHeight = container.offsetHeight;
const viewportHeight = window.innerHeight;
const maxScroll = spacerHeight - viewportHeight;
if (maxScroll > 0) {
scrollState.progress = Math.max(0, Math.min(1, scrollTop / maxScroll));
scrollState.inScrollZone = scrollTop <= spacerHeight;
}
};
window.addEventListener('scroll', handleScroll, { passive: true });
handleScroll();
return () => window.removeEventListener('scroll', handleScroll);
}, [scrollContainerRef]);
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 0,
pointerEvents: 'none',
}}
>
<Canvas
dpr={[1, 2]}
camera={{ position: [0, 1, 7], fov: 50 }}
gl={{ antialias: true, powerPreference: 'high-performance' }}
style={{
background: 'linear-gradient(180deg, #e8e8e4 0%, #f0f0ec 50%, #e8e8e4 100%)',
pointerEvents: 'auto',
}}
>
<Suspense fallback={<Loader />}>
<SceneContent />
</Suspense>
</Canvas>
</div>
);
}
useGLTF.preload('/Unitree_G1.glb');

View File

@ -0,0 +1,45 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { SnapshotButton } from '@/components/SnapshotButton';
// Mock Three.js objects
const mockGL = {
render: vi.fn(),
domElement: {
toDataURL: vi.fn(() => 'data:image/png;base64,mock-image-data'),
},
};
const mockScene = {};
const mockCamera = {};
describe('SnapshotButton', () => {
beforeEach(() => {
vi.clearAllMocks();
global.URL.createObjectURL = vi.fn(() => 'blob:mock-url');
global.URL.revokeObjectURL = vi.fn();
});
it('renders snapshot button', () => {
render(<SnapshotButton gl={mockGL as any} scene={mockScene as any} camera={mockCamera as any} />);
expect(screen.getByRole('button', { name: /capture 3d scene snapshot/i })).toBeInTheDocument();
});
it('renders with custom filename', () => {
render(<SnapshotButton gl={mockGL as any} scene={mockScene as any} camera={mockCamera as any} filename="custom-robot" />);
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('calls onSnapshot callback after capture', () => {
const onSnapshot = vi.fn();
render(<SnapshotButton gl={mockGL as any} scene={mockScene as any} camera={mockCamera as any} onSnapshot={onSnapshot} />);
const button = screen.getByRole('button');
fireEvent.click(button);
// Check that onSnapshot was called (after async operations)
setTimeout(() => {
expect(onSnapshot).toHaveBeenCalled();
}, 0);
});
});

View File

@ -0,0 +1,108 @@
'use client';
import { useCallback, useState } from 'react';
import { useThree } from '@react-three/fiber';
import type { WebGLRenderer, Scene, Camera } from 'three';
interface SnapshotButtonInnerProps {
gl: WebGLRenderer;
scene: Scene;
camera: Camera;
filename?: string;
onSnapshot?: (dataUrl: string) => void;
}
function SnapshotButtonInner({ gl, scene, camera, filename = 'g1-robot', onSnapshot }: SnapshotButtonInnerProps) {
const [isCapturing, setIsCapturing] = useState(false);
const handleCapture = useCallback(() => {
setIsCapturing(true);
try {
gl.render(scene, camera);
const dataUrl = gl.domElement.toDataURL('image/png');
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const fullFilename = `${filename}-${timestamp}.png`;
const link = document.createElement('a');
link.download = fullFilename;
link.href = dataUrl;
link.click();
onSnapshot?.(dataUrl);
} catch (error) {
console.error('Failed to capture snapshot:', error);
} finally {
setIsCapturing(false);
}
}, [gl, scene, camera, filename, onSnapshot]);
return (
<button
onClick={handleCapture}
disabled={isCapturing}
style={{
padding: '0.5rem 1rem',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.3)',
borderRadius: '0.5rem',
color: '#f8fafc',
cursor: isCapturing ? 'wait' : 'pointer',
fontSize: '0.875rem',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isCapturing) {
e.currentTarget.style.backgroundColor = 'rgba(59, 130, 246, 0.2)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'rgba(59, 130, 246, 0.1)';
}}
aria-label="Capture 3D scene snapshot"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
{isCapturing ? 'Capturing...' : 'Snapshot'}
</button>
);
}
// Re-export the inner component for tests
export { SnapshotButtonInner as SnapshotButton };
/**
* Captures the R3F context then renders a fixed-position overlay button
* outside the 3D scene so it stays in place when the camera moves.
*/
export function SnapshotButtonWrapper({ filename = 'g1-robot', onSnapshot }: { filename?: string; onSnapshot?: (dataUrl: string) => void }) {
const { gl, scene, camera } = useThree();
return (
<SnapshotButtonInner
gl={gl}
scene={scene}
camera={camera}
filename={filename}
onSnapshot={onSnapshot}
/>
);
}

View File

@ -0,0 +1,65 @@
import * as THREE from 'three';
// Socket position names
export type SocketName = 'head' | 'chest' | 'left_shoulder' | 'right_shoulder' | 'back' | 'base';
// Socket position definitions relative to the robot model
export interface SocketTransform {
position: THREE.Vector3;
rotation: THREE.Euler;
}
// Calibrated from actual model debug data:
// Robot bounding box: y=-0.5 to y=1.5, x=-0.37 to x=0.37, z=-0.17 to z=0.17
// Torso: center y=0.984, width=0.315, depth=0.227
// Hands: center y=0.759, full span width=0.739
// Legs: center y=0.049, width=0.500
export const SOCKET_POSITIONS: Record<SocketName, SocketTransform> = {
head: {
position: new THREE.Vector3(0, 1.5, 0),
rotation: new THREE.Euler(0, 0, 0),
},
chest: {
position: new THREE.Vector3(0, 0.98, 0.12),
rotation: new THREE.Euler(0, 0, 0),
},
left_shoulder: {
position: new THREE.Vector3(-0.22, 1.2, 0),
rotation: new THREE.Euler(0, 0, 0),
},
right_shoulder: {
position: new THREE.Vector3(0.22, 1.2, 0),
rotation: new THREE.Euler(0, 0, 0),
},
back: {
position: new THREE.Vector3(0, 0.98, -0.15),
rotation: new THREE.Euler(0, Math.PI, 0),
},
base: {
position: new THREE.Vector3(0, 0.55, 0),
rotation: new THREE.Euler(0, 0, 0),
},
};
// Human-readable labels for each socket
export const SOCKET_LABELS: Record<SocketName, string> = {
head: 'Head',
chest: 'Chest',
left_shoulder: 'Left Shoulder',
right_shoulder: 'Right Shoulder',
back: 'Back',
base: 'Base',
};
// Get socket transform by name
export const getSocketTransform = (socketName: SocketName): SocketTransform => {
return SOCKET_POSITIONS[socketName] || SOCKET_POSITIONS.base;
};
// Check if a position string is a valid socket name
export const isValidSocket = (position: string): position is SocketName => {
return position in SOCKET_POSITIONS;
};
// All socket names as array
export const ALL_SOCKETS: SocketName[] = ['head', 'chest', 'left_shoulder', 'right_shoulder', 'back', 'base'];

View File

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

View File

@ -0,0 +1,182 @@
'use client';
import { useState, useCallback } from 'react';
import { orderStore, type PaymentInfo } from '@/store/useOrderStore';
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '0.6rem 0.75rem',
borderRadius: '0.375rem',
border: '1px solid rgba(0, 0, 0, 0.08)',
background: 'rgba(255, 255, 255, 0.8)',
color: '#1a1a2e',
fontSize: '0.8rem',
outline: 'none',
transition: 'border-color 0.2s ease',
};
const labelStyle: React.CSSProperties = {
fontSize: '0.7rem',
fontWeight: 500,
color: '#94a3b8',
marginBottom: '0.3rem',
display: 'block',
};
const errorStyle: React.CSSProperties = {
fontSize: '0.65rem',
color: '#ef4444',
marginTop: '0.2rem',
};
interface FormErrors {
[key: string]: string;
}
function formatCardNumber(value: string): string {
const digits = value.replace(/\D/g, '').slice(0, 16);
return digits.replace(/(\d{4})(?=\d)/g, '$1 ');
}
function formatExpiry(value: string): string {
const digits = value.replace(/\D/g, '').slice(0, 4);
if (digits.length > 2) {
return `${digits.slice(0, 2)}/${digits.slice(2)}`;
}
return digits;
}
export function PaymentStep() {
const [form, setForm] = useState<PaymentInfo>({
cardNumber: '',
expiry: '',
cvv: '',
nameOnCard: '',
});
const [errors, setErrors] = useState<FormErrors>({});
const handleChange = useCallback((field: keyof PaymentInfo, value: string) => {
let processed = value;
if (field === 'cardNumber') processed = formatCardNumber(value);
if (field === 'expiry') processed = formatExpiry(value);
if (field === 'cvv') processed = value.replace(/\D/g, '').slice(0, 3);
setForm((prev) => ({ ...prev, [field]: processed }));
setErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
}, []);
const validate = (): boolean => {
const errs: FormErrors = {};
const digits = form.cardNumber.replace(/\s/g, '');
if (digits.length < 16) errs.cardNumber = 'Enter a valid 16-digit card number';
if (form.expiry.length < 5) errs.expiry = 'Enter a valid expiry (MM/YY)';
if (form.cvv.length < 3) errs.cvv = 'Enter a valid 3-digit CVV';
if (!form.nameOnCard.trim()) errs.nameOnCard = 'Name on card is required';
setErrors(errs);
return Object.keys(errs).length === 0;
};
const handleSubmit = () => {
if (!validate()) return;
orderStore.getState().setPayment(form);
orderStore.getState().setStep('review');
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 600, color: '#1a1a2e', margin: 0 }}>
Payment Details
</h3>
<div style={{
padding: '0.6rem 0.75rem',
borderRadius: '0.375rem',
background: 'rgba(245, 158, 11, 0.06)',
border: '1px solid rgba(245, 158, 11, 0.15)',
fontSize: '0.7rem',
color: '#d97706',
}}>
This is a demo checkout. No real payment will be processed.
</div>
<div>
<label style={labelStyle}>Card Number</label>
<input
type="text"
value={form.cardNumber}
onChange={(e) => handleChange('cardNumber', e.target.value)}
placeholder="4242 4242 4242 4242"
style={{ ...inputStyle, fontFamily: 'monospace', letterSpacing: '0.1em', borderColor: errors.cardNumber ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = errors.cardNumber ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
/>
{errors.cardNumber && <div style={errorStyle}>{errors.cardNumber}</div>}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<div>
<label style={labelStyle}>Expiry Date</label>
<input
type="text"
value={form.expiry}
onChange={(e) => handleChange('expiry', e.target.value)}
placeholder="MM/YY"
style={{ ...inputStyle, fontFamily: 'monospace', borderColor: errors.expiry ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = errors.expiry ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
/>
{errors.expiry && <div style={errorStyle}>{errors.expiry}</div>}
</div>
<div>
<label style={labelStyle}>CVV</label>
<input
type="text"
value={form.cvv}
onChange={(e) => handleChange('cvv', e.target.value)}
placeholder="123"
style={{ ...inputStyle, fontFamily: 'monospace', borderColor: errors.cvv ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = errors.cvv ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
/>
{errors.cvv && <div style={errorStyle}>{errors.cvv}</div>}
</div>
</div>
<div>
<label style={labelStyle}>Name on Card</label>
<input
type="text"
value={form.nameOnCard}
onChange={(e) => handleChange('nameOnCard', e.target.value)}
placeholder="John Doe"
style={{ ...inputStyle, borderColor: errors.nameOnCard ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = errors.nameOnCard ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
/>
{errors.nameOnCard && <div style={errorStyle}>{errors.nameOnCard}</div>}
</div>
<button
onClick={handleSubmit}
style={{
marginTop: '0.5rem',
padding: '0.75rem',
borderRadius: '0.375rem',
border: '1px solid rgba(59, 130, 246, 0.5)',
background: 'rgba(59, 130, 246, 0.08)',
color: '#2563eb',
cursor: 'pointer',
fontSize: '0.85rem',
fontWeight: 600,
transition: 'all 0.2s ease',
}}
>
Review Order
</button>
</div>
);
}

View File

@ -0,0 +1,134 @@
'use client';
import { useState } from 'react';
import { useOrderStore, orderStore } from '@/store/useOrderStore';
function formatAED(price: number): string {
return new Intl.NumberFormat('en-AE').format(price);
}
export function ReviewStep() {
const shipping = useOrderStore((s) => s.shipping);
const orderTotal = useOrderStore((s) => s.orderTotal);
const personaSummary = useOrderStore((s) => s.personaSummary);
const colorSummary = useOrderStore((s) => s.colorSummary);
const [isProcessing, setIsProcessing] = useState(false);
const handlePlaceOrder = async () => {
setIsProcessing(true);
// Simulate payment processing delay
await new Promise((resolve) => setTimeout(resolve, 1500));
orderStore.getState().placeOrder();
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 600, color: '#1a1a2e', margin: 0 }}>
Review Your Order
</h3>
{/* Configuration Summary */}
<SummarySection title="Configuration">
<SummaryRow label="Persona" value={personaSummary} />
<SummaryRow label="Color" value={
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{
width: '14px',
height: '14px',
borderRadius: '3px',
backgroundColor: colorSummary,
border: '1px solid rgba(255,255,255,0.15)',
}} />
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem' }}>{colorSummary}</span>
</div>
} />
</SummarySection>
{/* Shipping Summary */}
<SummarySection title="Shipping To">
<div style={{ fontSize: '0.8rem', color: '#374151', lineHeight: 1.6 }}>
<div style={{ fontWeight: 500 }}>{shipping.name}</div>
<div>{shipping.address}</div>
<div>{shipping.city}, {shipping.country} {shipping.postalCode}</div>
<div style={{ color: '#94a3b8', marginTop: '0.25rem' }}>{shipping.email}</div>
<div style={{ color: '#94a3b8' }}>{shipping.phone}</div>
</div>
</SummarySection>
{/* Price Summary */}
<SummarySection title="Order Total">
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}>
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: '#374151' }}>Total</span>
<span style={{ fontSize: '1.1rem', fontWeight: 700, color: '#1a1a2e', fontFamily: 'monospace' }}>
AED {formatAED(orderTotal)}
</span>
</div>
</SummarySection>
{/* Place Order Button */}
<button
onClick={handlePlaceOrder}
disabled={isProcessing}
style={{
marginTop: '0.25rem',
padding: '0.85rem',
borderRadius: '0.375rem',
border: 'none',
background: isProcessing
? 'rgba(59, 130, 246, 0.2)'
: 'linear-gradient(135deg, #3b82f6, #2563eb)',
color: '#ffffff',
cursor: isProcessing ? 'wait' : 'pointer',
fontSize: '0.9rem',
fontWeight: 700,
transition: 'all 0.2s ease',
textAlign: 'center',
boxShadow: isProcessing ? 'none' : '0 4px 20px rgba(59, 130, 246, 0.2)',
}}
>
{isProcessing ? 'Processing Payment...' : 'Place Order'}
</button>
</div>
);
}
function SummarySection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div style={{
padding: '0.75rem',
borderRadius: '0.5rem',
background: 'rgba(248, 248, 246, 0.4)',
border: '1px solid rgba(0, 0, 0, 0.04)',
}}>
<div style={{
fontSize: '0.65rem',
fontWeight: 600,
color: '#64748b',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: '0.5rem',
}}>
{title}
</div>
{children}
</div>
);
}
function SummaryRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.25rem 0',
}}>
<span style={{ fontSize: '0.8rem', color: '#94a3b8' }}>{label}</span>
<span style={{ fontSize: '0.8rem', color: '#374151' }}>{value}</span>
</div>
);
}

View File

@ -0,0 +1,150 @@
'use client';
import { useState, useCallback } from 'react';
import { orderStore, type ShippingInfo } from '@/store/useOrderStore';
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '0.6rem 0.75rem',
borderRadius: '0.375rem',
border: '1px solid rgba(0, 0, 0, 0.08)',
background: 'rgba(255, 255, 255, 0.8)',
color: '#1a1a2e',
fontSize: '0.8rem',
outline: 'none',
transition: 'border-color 0.2s ease',
};
const labelStyle: React.CSSProperties = {
fontSize: '0.7rem',
fontWeight: 500,
color: '#94a3b8',
marginBottom: '0.3rem',
display: 'block',
};
const errorStyle: React.CSSProperties = {
fontSize: '0.65rem',
color: '#ef4444',
marginTop: '0.2rem',
};
interface FormErrors {
[key: string]: string;
}
function validateEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
export function ShippingStep() {
const [form, setForm] = useState<ShippingInfo>({
name: '',
email: '',
phone: '',
address: '',
city: '',
country: '',
postalCode: '',
});
const [errors, setErrors] = useState<FormErrors>({});
const handleChange = useCallback((field: keyof ShippingInfo, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
setErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
}, []);
const validate = (): boolean => {
const errs: FormErrors = {};
if (!form.name.trim()) errs.name = 'Name is required';
if (!form.email.trim()) errs.email = 'Email is required';
else if (!validateEmail(form.email)) errs.email = 'Invalid email format';
if (!form.phone.trim()) errs.phone = 'Phone is required';
if (!form.address.trim()) errs.address = 'Address is required';
if (!form.city.trim()) errs.city = 'City is required';
if (!form.country.trim()) errs.country = 'Country is required';
if (!form.postalCode.trim()) errs.postalCode = 'Postal code is required';
setErrors(errs);
return Object.keys(errs).length === 0;
};
const handleSubmit = () => {
if (!validate()) return;
orderStore.getState().setShipping(form);
orderStore.getState().setStep('payment');
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 600, color: '#1a1a2e', margin: 0 }}>
Shipping Information
</h3>
<Field label="Full Name" value={form.name} error={errors.name} onChange={(v) => handleChange('name', v)} placeholder="John Doe" />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<Field label="Email" value={form.email} error={errors.email} onChange={(v) => handleChange('email', v)} placeholder="john@example.com" type="email" />
<Field label="Phone" value={form.phone} error={errors.phone} onChange={(v) => handleChange('phone', v)} placeholder="+971 50 123 4567" type="tel" />
</div>
<Field label="Address" value={form.address} error={errors.address} onChange={(v) => handleChange('address', v)} placeholder="123 Sheikh Zayed Road" />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<Field label="City" value={form.city} error={errors.city} onChange={(v) => handleChange('city', v)} placeholder="Dubai" />
<Field label="Country" value={form.country} error={errors.country} onChange={(v) => handleChange('country', v)} placeholder="UAE" />
</div>
<Field label="Postal Code" value={form.postalCode} error={errors.postalCode} onChange={(v) => handleChange('postalCode', v)} placeholder="00000" />
<button
onClick={handleSubmit}
style={{
marginTop: '0.5rem',
padding: '0.75rem',
borderRadius: '0.375rem',
border: '1px solid rgba(59, 130, 246, 0.5)',
background: 'rgba(59, 130, 246, 0.08)',
color: '#2563eb',
cursor: 'pointer',
fontSize: '0.85rem',
fontWeight: 600,
transition: 'all 0.2s ease',
}}
>
Continue to Payment
</button>
</div>
);
}
function Field({ label, value, error, onChange, placeholder, type = 'text' }: {
label: string;
value: string;
error?: string;
onChange: (v: string) => void;
placeholder?: string;
type?: string;
}) {
return (
<div>
<label style={labelStyle}>{label}</label>
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
style={{
...inputStyle,
borderColor: error ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)',
}}
onFocus={(e) => { e.currentTarget.style.borderColor = error ? 'rgba(239, 68, 68, 0.7)' : 'rgba(59, 130, 246, 0.5)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = error ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
/>
{error && <div style={errorStyle}>{error}</div>}
</div>
);
}

View File

@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { configStore, serializeConfig, deserializeConfig } from '@/store/useConfigStore';
// Mock window.history
const mockReplaceState = vi.fn();
const mockPushState = vi.fn();
const mockAddEventListener = vi.fn();
const mockRemoveEventListener = vi.fn();
vi.stubGlobal('window', {
location: {
href: 'http://localhost:3000',
search: '',
},
history: {
replaceState: mockReplaceState,
pushState: mockPushState,
state: {},
},
addEventListener: mockAddEventListener,
removeEventListener: mockRemoveEventListener,
});
describe('URL Serialization', () => {
describe('serializeConfig', () => {
it('should serialize colors, persona, and payloads to base64', () => {
const encoded = serializeConfig({
activeColors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' },
activePersonaAttire: 'Emarati Kandura',
activePayloads: [{ id: 'cam1', type: 'camera', position: 'head' }],
isHydrated: true,
});
expect(typeof encoded).toBe('string');
// Verify it decodes correctly
const decoded = JSON.parse(atob(encoded));
expect(decoded.c.primary).toBe('#ff0000');
expect(decoded.p).toBe('Emarati Kandura');
expect(decoded.y[0].id).toBe('cam1');
});
});
describe('deserializeConfig', () => {
it('should decode base64 and return valid state', () => {
const original = {
activeColors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' },
activePersonaAttire: 'Industrial Vest',
activePayloads: [
{ id: 'cam1', type: 'camera', position: 'head' },
{ id: 'light1', type: 'light', position: 'arm' },
],
isHydrated: true,
};
const encoded = serializeConfig(original);
const decoded = deserializeConfig(encoded);
expect(decoded).not.toBeNull();
expect(decoded?.activeColors).toEqual(original.activeColors);
expect(decoded?.activePersonaAttire).toBe(original.activePersonaAttire);
expect(decoded?.activePayloads).toEqual(original.activePayloads);
});
it('should return null for malformed base64', () => {
expect(deserializeConfig('!!!invalid-base64!!!')).toBeNull();
});
it('should return null for valid base64 but invalid JSON', () => {
expect(deserializeConfig(btoa('not json'))).toBeNull();
});
it('should return null for valid JSON but wrong schema', () => {
expect(deserializeConfig(btoa(JSON.stringify({ wrong: 'schema' })))).toBeNull();
});
});
});
describe('Store integration with URL', () => {
beforeEach(() => {
vi.clearAllMocks();
configStore.getState().reset();
});
it('should create encoded URL with config', () => {
configStore.getState().setColors({ primary: '#ff0000' });
configStore.getState().setPersonaAttire('Test');
configStore.getState().addPayload({ id: 'p1', type: 'camera', position: 'head' });
const state = configStore.getState();
const encoded = serializeConfig(state);
expect(encoded).toBeTruthy();
expect(encoded.length).toBeGreaterThan(0);
});
it('should preserve state through serialize/deserialize cycle', () => {
configStore.getState().setColors({ primary: '#abc123', secondary: '#def456', accent: '#789abc' });
configStore.getState().setPersonaAttire('Emarati Kandura');
configStore.getState().addPayload({ id: 'cam1', type: 'PTZ Camera', position: 'head' });
configStore.getState().addPayload({ id: 'light1', type: 'LED Panel', position: 'chest' });
configStore.getState().setHydrated(true);
const state = configStore.getState();
const encoded = serializeConfig(state);
const decoded = deserializeConfig(encoded);
expect(decoded).not.toBeNull();
expect(decoded?.activeColors).toEqual(state.activeColors);
expect(decoded?.activePersonaAttire).toBe(state.activePersonaAttire);
expect(decoded?.activePayloads).toHaveLength(2);
});
});

122
src/hooks/useUrlSync.ts Normal file
View File

@ -0,0 +1,122 @@
'use client';
import { useEffect, useRef, useSyncExternalStore } from 'react';
import {
configStore,
serializeConfig,
deserializeConfig,
ConfigState,
} from '@/store/useConfigStore';
const URL_PARAM_KEY = 'config';
const DEBOUNCE_MS = 300;
function debounce<T extends (...args: Parameters<T>) => void>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
fn(...args);
timeoutId = null;
}, delay);
};
}
export const useUrlSync = () => {
const stateRef = useRef<ConfigState | null>(null);
// Subscribe to store changes for hydration status
const isHydrated = useSyncExternalStore(
(callback) => configStore.subscribe(callback),
() => configStore.getState().isHydrated,
() => false // Server-side always returns false
);
// Hydrate store from URL on mount
useEffect(() => {
if (typeof window === 'undefined') return;
const url = new URL(window.location.href);
const encodedConfig = url.searchParams.get(URL_PARAM_KEY);
if (encodedConfig) {
const config = deserializeConfig(encodedConfig);
if (config) {
configStore.getState().hydrate(config);
// Clean URL after hydration using replaceState
url.searchParams.delete(URL_PARAM_KEY);
window.history.replaceState({}, '', url.toString());
return;
}
}
// No valid config in URL, mark as hydrated with defaults
configStore.getState().hydrate({});
}, []);
// Subscribe to store changes and update URL (debounced)
useEffect(() => {
if (typeof window === 'undefined') return;
const updateUrl = () => {
const state = stateRef.current;
if (!state || !state.isHydrated) return;
const encoded = serializeConfig(state);
const url = new URL(window.location.href);
url.searchParams.set(URL_PARAM_KEY, encoded);
window.history.pushState({}, '', url.toString());
};
const debouncedUpdateUrl = debounce(updateUrl, DEBOUNCE_MS);
// Store current state and subscribe for updates
stateRef.current = configStore.getState();
const unsubscribe = configStore.subscribe((newState) => {
stateRef.current = newState;
if (newState.isHydrated) {
debouncedUpdateUrl();
}
});
return unsubscribe;
}, []);
// Handle browser back/forward navigation
useEffect(() => {
if (typeof window === 'undefined') return;
const handlePopState = () => {
const url = new URL(window.location.href);
const encodedConfig = url.searchParams.get(URL_PARAM_KEY);
if (encodedConfig) {
const config = deserializeConfig(encodedConfig);
if (config) {
configStore.setState({ ...config, isHydrated: true });
}
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
return { isHydrated };
};
// Hook to get shareable URL
export const useShareableUrl = () => {
const state = configStore.getState();
const encoded = serializeConfig(state);
const url = new URL(window.location.href);
url.searchParams.set(URL_PARAM_KEY, encoded);
return url.toString();
};

31
src/i18n/config.ts Normal file
View File

@ -0,0 +1,31 @@
'use client';
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import en from './locales/en.json';
import ar from './locales/ar.json';
const resources = {
en: { translation: en },
ar: { translation: ar },
};
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'en',
supportedLngs: ['en', 'ar'],
interpolation: {
escapeValue: false,
},
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
},
});
export default i18n;

149
src/i18n/locales/ar.json Normal file
View File

@ -0,0 +1,149 @@
{
"app": {
"title": "مُكوِّن G1",
"subtitle": "روبوتيكس لوتاه"
},
"loading": {
"configuration": "جاري تحميل التكوين...",
"scene": "جاري تهيئة المشهد ثلاثي الأبعاد..."
},
"panel": {
"title": "التكوين",
"collapse": "طي اللوحة",
"expand": "توسيع اللوحة",
"currentConfiguration": "التكوين الحالي",
"persona": "الشخصية",
"payloads": "الأحمولة",
"none": "لا شيء"
},
"controls": {
"snapshot": "لقطة",
"capturing": "جاري الالتقاط...",
"share": "مشاركة",
"copied": "تم نسخ الرابط!",
"copyFailed": "فشل النسخ"
},
"config": {
"colors": "الألوان",
"primary": "أساسي",
"secondary": "ثانوي",
"accent": "تمييز"
},
"pricing": {
"title": "تفاصيل السعر",
"base": "روبوت G1 الأساسي",
"customColor": "لون مخصص",
"total": "الإجمالي",
"proceed": "متابعة الطلب"
},
"checkout": {
"title": "الدفع",
"back": "رجوع",
"close": "إغلاق الدفع",
"shipping": {
"title": "معلومات الشحن",
"name": "الاسم الكامل",
"email": "البريد الإلكتروني",
"phone": "الهاتف",
"address": "العنوان",
"city": "المدينة",
"country": "الدولة",
"postalCode": "الرمز البريدي",
"continue": "متابعة إلى الدفع"
},
"payment": {
"title": "تفاصيل الدفع",
"demoNotice": "هذا عرض تجريبي. لن يتم معالجة أي دفع حقيقي.",
"cardNumber": "رقم البطاقة",
"expiry": "تاريخ الانتهاء",
"cvv": "رمز الأمان",
"nameOnCard": "الاسم على البطاقة",
"continue": "مراجعة الطلب"
},
"review": {
"title": "مراجعة طلبك",
"configuration": "التكوين",
"persona": "الشخصية",
"color": "اللون",
"shippingTo": "الشحن إلى",
"orderTotal": "إجمالي الطلب",
"total": "الإجمالي",
"placeOrder": "تأكيد الطلب",
"processing": "جاري معالجة الدفع..."
},
"confirmation": {
"title": "تم تأكيد الطلب!",
"message": "شكراً لطلبك. يتم تحضير روبوت G1 الخاص بك.",
"orderId": "رقم الطلب",
"configuration": "التكوين",
"shipTo": "الشحن إلى",
"totalPaid": "المبلغ المدفوع",
"newOrder": "تكوين روبوت آخر"
},
"steps": {
"shipping": "الشحن",
"payment": "الدفع",
"review": "المراجعة"
}
},
"validation": {
"required": "هذا الحقل مطلوب",
"invalidEmail": "صيغة البريد الإلكتروني غير صحيحة",
"invalidCard": "أدخل رقم بطاقة صالح من 16 رقم",
"invalidExpiry": "أدخل تاريخ انتهاء صالح",
"invalidCvv": "أدخل رمز أمان صالح من 3 أرقام",
"nameRequired": "الاسم مطلوب",
"emailRequired": "البريد الإلكتروني مطلوب",
"phoneRequired": "الهاتف مطلوب",
"addressRequired": "العنوان مطلوب",
"cityRequired": "المدينة مطلوبة",
"countryRequired": "الدولة مطلوبة",
"postalRequired": "الرمز البريدي مطلوب",
"cardNameRequired": "الاسم على البطاقة مطلوب"
},
"landing": {
"hero": {
"overline": "نقدّم لكم G1",
"title": "مستقبل الروبوتات",
"subtitle": "اكتشف روبوت G1 البشري من لوتاه روبوتيكس. قابل للتخصيص بالكامل، جاهز للمؤسسات، ومصمم لعالم الغد.",
"cta": "صمّم روبوتك G1",
"scroll": "اكتشف"
},
"showcase": {
"overline": "القدرات",
"title": "مصمّم للتميّز",
"cta": "ابدأ التكوين"
},
"features": {
"attire": {
"title": "أزياء قابلة للتخصيص",
"description": "ألبس روبوت G1 لأي مناسبة — من الكندورة الإماراتية التقليدية إلى بدلات الأعمال وملابس السلامة الصناعية."
},
"mobility": {
"title": "حركة متقدمة",
"description": "نظام حركة متطور بـ 23 درجة حرية، يمكّن من الحركة الطبيعية والتوازن كالإنسان."
},
"enterprise": {
"title": "جاهز للمؤسسات",
"description": "مصمم للضيافة والتجزئة والأمن والبيئات المؤسسية مع واجهات برمجة قوية وإدارة الأسطول."
},
"ai": {
"title": "ذكاء اصطناعي متقدم",
"description": "أنظمة رؤية وكلام واتخاذ قرار مدمجة مدعومة بأحدث تطورات الذكاء الاصطناعي."
}
},
"configurator": {
"title": "صمّم روبوتك G1"
}
},
"admin": {
"title": "لوحة الأسعار",
"subtitle": "تعديل أسعار مكوّن روبوت G1",
"backToConfigurator": "العودة للمكوّن",
"item": "العنصر",
"price": "السعر (درهم)",
"save": "حفظ الأسعار",
"saved": "تم الحفظ!",
"resetDefaults": "إعادة تعيين الافتراضي"
}
}

149
src/i18n/locales/en.json Normal file
View File

@ -0,0 +1,149 @@
{
"app": {
"title": "G1 Configurator",
"subtitle": "Lootah Robotics"
},
"loading": {
"configuration": "Loading configuration...",
"scene": "Initializing 3D Scene..."
},
"panel": {
"title": "Configuration",
"collapse": "Collapse panel",
"expand": "Expand panel",
"currentConfiguration": "Current Configuration",
"persona": "Persona",
"payloads": "Payloads",
"none": "None"
},
"controls": {
"snapshot": "Snapshot",
"capturing": "Capturing...",
"share": "Share",
"copied": "Link Copied!",
"copyFailed": "Copy failed"
},
"config": {
"colors": "Colors",
"primary": "Primary",
"secondary": "Secondary",
"accent": "Accent"
},
"pricing": {
"title": "Price Breakdown",
"base": "G1 Robot Base",
"customColor": "Custom Color",
"total": "Total",
"proceed": "Proceed to Order"
},
"checkout": {
"title": "Checkout",
"back": "Back",
"close": "Close checkout",
"shipping": {
"title": "Shipping Information",
"name": "Full Name",
"email": "Email",
"phone": "Phone",
"address": "Address",
"city": "City",
"country": "Country",
"postalCode": "Postal Code",
"continue": "Continue to Payment"
},
"payment": {
"title": "Payment Details",
"demoNotice": "This is a demo checkout. No real payment will be processed.",
"cardNumber": "Card Number",
"expiry": "Expiry Date",
"cvv": "CVV",
"nameOnCard": "Name on Card",
"continue": "Review Order"
},
"review": {
"title": "Review Your Order",
"configuration": "Configuration",
"persona": "Persona",
"color": "Color",
"shippingTo": "Shipping To",
"orderTotal": "Order Total",
"total": "Total",
"placeOrder": "Place Order",
"processing": "Processing Payment..."
},
"confirmation": {
"title": "Order Confirmed!",
"message": "Thank you for your order. Your G1 Robot is being prepared.",
"orderId": "Order ID",
"configuration": "Configuration",
"shipTo": "Ship to",
"totalPaid": "Total Paid",
"newOrder": "Configure Another Robot"
},
"steps": {
"shipping": "Shipping",
"payment": "Payment",
"review": "Review"
}
},
"validation": {
"required": "This field is required",
"invalidEmail": "Invalid email format",
"invalidCard": "Enter a valid 16-digit card number",
"invalidExpiry": "Enter a valid expiry (MM/YY)",
"invalidCvv": "Enter a valid 3-digit CVV",
"nameRequired": "Name is required",
"emailRequired": "Email is required",
"phoneRequired": "Phone is required",
"addressRequired": "Address is required",
"cityRequired": "City is required",
"countryRequired": "Country is required",
"postalRequired": "Postal code is required",
"cardNameRequired": "Name on card is required"
},
"landing": {
"hero": {
"overline": "Introducing the G1",
"title": "The Future of Robotics",
"subtitle": "Experience the G1 Humanoid Robot by Lootah Robotics. Fully customizable, enterprise-ready, and designed for the world of tomorrow.",
"cta": "Configure Your G1",
"scroll": "Discover"
},
"showcase": {
"overline": "Capabilities",
"title": "Engineered for Excellence",
"cta": "Start Configuring"
},
"features": {
"attire": {
"title": "Customizable Attire",
"description": "Dress your G1 for any occasion — from traditional Emarati Kandura to professional business attire and industrial safety gear."
},
"mobility": {
"title": "Advanced Mobility",
"description": "State-of-the-art locomotion system with 23 degrees of freedom, enabling natural human-like movement and balance."
},
"enterprise": {
"title": "Enterprise Ready",
"description": "Built for hospitality, retail, security, and corporate environments with robust APIs and fleet management capabilities."
},
"ai": {
"title": "AI-Powered Intelligence",
"description": "Integrated vision, speech, and decision-making systems powered by the latest advances in artificial intelligence."
}
},
"configurator": {
"title": "Configure Your G1"
}
},
"admin": {
"title": "Pricing Dashboard",
"subtitle": "Edit prices for the G1 Robot Configurator",
"backToConfigurator": "Back to Configurator",
"item": "Item",
"price": "Price (AED)",
"save": "Save Prices",
"saved": "Saved!",
"resetDefaults": "Reset to Defaults"
}
}

View File

@ -0,0 +1,210 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { configStore, ConfigState, Payload, serializeConfig, deserializeConfig } from '@/store/useConfigStore';
describe('useConfigStore (vanilla)', () => {
beforeEach(() => {
configStore.getState().reset();
});
describe('Initial State', () => {
it('should have correct default values', () => {
const state = configStore.getState();
expect(state.activeColors).toEqual({
primary: '#96a2b6',
secondary: '#1e293b',
accent: '#f59e0b',
});
expect(state.activePersonaAttire).toBe('none');
expect(state.activePayloads).toEqual([]);
expect(state.isHydrated).toBe(false);
});
});
describe('setColors', () => {
it('should update primary color', () => {
configStore.getState().setColors({ primary: '#ff0000' });
const state = configStore.getState();
expect(state.activeColors.primary).toBe('#ff0000');
expect(state.activeColors.secondary).toBe('#1e293b');
expect(state.activeColors.accent).toBe('#f59e0b');
});
it('should update multiple colors at once', () => {
configStore.getState().setColors({
primary: '#ff0000',
secondary: '#00ff00',
accent: '#0000ff',
});
expect(configStore.getState().activeColors).toEqual({
primary: '#ff0000',
secondary: '#00ff00',
accent: '#0000ff',
});
});
});
describe('setPersonaAttire', () => {
it('should update persona attire', () => {
configStore.getState().setPersonaAttire('Emarati Kandura');
expect(configStore.getState().activePersonaAttire).toBe('Emarati Kandura');
});
});
describe('addPayload', () => {
it('should add a new payload', () => {
const payload: Payload = { id: 'cam1', type: 'camera', position: 'head' };
configStore.getState().addPayload(payload);
expect(configStore.getState().activePayloads).toHaveLength(1);
expect(configStore.getState().activePayloads[0]).toEqual(payload);
});
it('should not add duplicate payload by id', () => {
const payload: Payload = { id: 'cam1', type: 'camera', position: 'head' };
configStore.getState().addPayload(payload);
configStore.getState().addPayload(payload);
expect(configStore.getState().activePayloads).toHaveLength(1);
});
});
describe('removePayload', () => {
it('should remove a payload by id', () => {
const payload: Payload = { id: 'cam1', type: 'camera', position: 'head' };
configStore.getState().addPayload(payload);
expect(configStore.getState().activePayloads).toHaveLength(1);
configStore.getState().removePayload('cam1');
expect(configStore.getState().activePayloads).toHaveLength(0);
});
});
describe('updatePayload', () => {
it('should update an existing payload', () => {
const payload: Payload = { id: 'cam1', type: 'camera', position: 'head' };
configStore.getState().addPayload(payload);
configStore.getState().updatePayload('cam1', { position: 'chest' });
expect(configStore.getState().activePayloads[0].position).toBe('chest');
});
});
describe('clearPayloads', () => {
it('should remove all payloads', () => {
configStore.getState().addPayload({ id: 'cam1', type: 'camera', position: 'head' });
configStore.getState().addPayload({ id: 'cam2', type: 'camera', position: 'chest' });
expect(configStore.getState().activePayloads).toHaveLength(2);
configStore.getState().clearPayloads();
expect(configStore.getState().activePayloads).toHaveLength(0);
});
});
describe('reset', () => {
it('should reset all state to defaults', () => {
configStore.getState().setColors({ primary: '#ff0000' });
configStore.getState().setPersonaAttire('Industrial Vest');
configStore.getState().addPayload({ id: 'cam1', type: 'camera', position: 'head' });
configStore.getState().setHydrated(true);
configStore.getState().reset();
const state = configStore.getState();
expect(state.activeColors.primary).toBe('#96a2b6');
expect(state.activePersonaAttire).toBe('none');
expect(state.activePayloads).toEqual([]);
expect(state.isHydrated).toBe(false);
});
});
describe('hydrate', () => {
it('should hydrate state from partial config', () => {
configStore.getState().hydrate({
activeColors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' },
activePersonaAttire: 'Emarati Kandura',
});
const state = configStore.getState();
expect(state.activeColors.primary).toBe('#ff0000');
expect(state.activePersonaAttire).toBe('Emarati Kandura');
expect(state.isHydrated).toBe(true);
});
});
});
describe('serializeConfig & deserializeConfig', () => {
beforeEach(() => {
configStore.getState().reset();
});
it('should serialize state to base64', () => {
const state: ConfigState = {
activeColors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' },
activePersonaAttire: 'Test Attire',
activePayloads: [{ id: 'p1', type: 'camera', position: 'head' }],
isHydrated: true,
};
const encoded = serializeConfig(state);
expect(typeof encoded).toBe('string');
expect(encoded.length).toBeGreaterThan(0);
});
it('should deserialize base64 to state', () => {
const state: ConfigState = {
activeColors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' },
activePersonaAttire: 'Test Attire',
activePayloads: [{ id: 'p1', type: 'camera', position: 'head' }],
isHydrated: true,
};
const encoded = serializeConfig(state);
const decoded = deserializeConfig(encoded);
expect(decoded).not.toBeNull();
expect(decoded?.activeColors).toEqual(state.activeColors);
expect(decoded?.activePersonaAttire).toBe(state.activePersonaAttire);
expect(decoded?.activePayloads).toEqual(state.activePayloads);
});
it('should return null for invalid base64', () => {
const result = deserializeConfig('not-valid-base64!!!');
expect(result).toBeNull();
});
it('should return null for invalid schema', () => {
const invalidJson = btoa(JSON.stringify({ invalid: 'schema' }));
const result = deserializeConfig(invalidJson);
expect(result).toBeNull();
});
it('should round-trip complex state correctly', () => {
const original: ConfigState = {
activeColors: { primary: '#aabbcc', secondary: '#ddeeff', accent: '#112233' },
activePersonaAttire: 'Emarati Kandura',
activePayloads: [
{ id: 'cam1', type: 'PTZ Camera', position: 'head' },
{ id: 'light1', type: 'LED Array', position: 'chest' },
{ id: 'sensor1', type: 'Lidar', position: 'base' },
],
isHydrated: true,
};
const encoded = serializeConfig(original);
const decoded = deserializeConfig(encoded);
expect(decoded).not.toBeNull();
expect(decoded?.activeColors).toEqual(original.activeColors);
expect(decoded?.activePersonaAttire).toBe(original.activePersonaAttire);
expect(decoded?.activePayloads).toHaveLength(3);
expect(decoded?.activePayloads).toEqual(original.activePayloads);
});
});

228
src/store/useConfigStore.ts Normal file
View File

@ -0,0 +1,228 @@
import { createStore } from 'zustand/vanilla';
// Type definitions
export interface ColorConfig {
primary: string;
secondary: string;
accent: string;
}
export interface Payload {
id: string;
type: string;
position: string;
}
export interface ConfigState {
activeColors: ColorConfig;
activePersonaAttire: string;
activePayloads: Payload[];
isHydrated: boolean;
}
export interface ConfigActions {
setColors: (colors: Partial<ColorConfig>) => void;
setPersonaAttire: (attire: string) => void;
addPayload: (payload: Payload) => void;
removePayload: (payloadId: string) => void;
updatePayload: (payloadId: string, updates: Partial<Payload>) => void;
clearPayloads: () => void;
reset: () => void;
setHydrated: (hydrated: boolean) => void;
hydrate: (state: Partial<ConfigState>) => void;
}
export type ConfigStore = ConfigState & ConfigActions;
// Default state values
const defaultColors: ColorConfig = {
primary: '#96a2b6',
secondary: '#1e293b',
accent: '#f59e0b',
};
const defaultState: ConfigState = {
activeColors: defaultColors,
activePersonaAttire: 'none',
activePayloads: [],
isHydrated: false,
};
// Create the vanilla Zustand store
export const configStore = createStore<ConfigStore>((set) => ({
...defaultState,
setColors: (colors: Partial<ColorConfig>) => {
set((state) => ({
activeColors: { ...state.activeColors, ...colors },
}));
},
setPersonaAttire: (attire: string) => {
set({ activePersonaAttire: attire });
},
addPayload: (payload: Payload) => {
set((state) => {
if (state.activePayloads.some((p) => p.id === payload.id)) {
return state;
}
return {
activePayloads: [...state.activePayloads, payload],
};
});
},
removePayload: (payloadId: string) => {
set((state) => ({
activePayloads: state.activePayloads.filter((p) => p.id !== payloadId),
}));
},
updatePayload: (payloadId: string, updates: Partial<Payload>) => {
set((state) => ({
activePayloads: state.activePayloads.map((p) =>
p.id === payloadId ? { ...p, ...updates } : p
),
}));
},
clearPayloads: () => {
set({ activePayloads: [] });
},
reset: () => {
set(defaultState);
},
setHydrated: (hydrated: boolean) => {
set({ isHydrated: hydrated });
},
hydrate: (state: Partial<ConfigState>) => {
set((current) => ({
...current,
...state,
isHydrated: true,
}));
},
}));
// React hook wrapper
import { useSyncExternalStore } from 'react';
export const useConfigStore = <T>(selector: (state: ConfigStore) => T): T => {
return useSyncExternalStore(
configStore.subscribe,
() => selector(configStore.getState()),
() => selector(configStore.getState())
);
};
// Direct selector hooks for optimized re-renders
export const useActiveColors = () =>
useConfigStore((state) => state.activeColors);
export const usePersonaAttire = () =>
useConfigStore((state) => state.activePersonaAttire);
export const usePayloads = () =>
useConfigStore((state) => state.activePayloads);
export const useIsHydrated = () =>
useConfigStore((state) => state.isHydrated);
// Memoized state serializer for URL encoding
export const serializeConfig = (state: ConfigState): string => {
const data = {
c: state.activeColors,
p: state.activePersonaAttire,
y: state.activePayloads,
};
return btoa(JSON.stringify(data));
};
// State schema validator
export const validateConfigSchema = (data: unknown): data is ConfigState => {
if (typeof data !== 'object' || data === null) return false;
const obj = data as Record<string, unknown>;
if (typeof obj.activeColors !== 'object' || obj.activeColors === null) return false;
const colors = obj.activeColors as Record<string, unknown>;
if (
typeof colors.primary !== 'string' ||
typeof colors.secondary !== 'string' ||
typeof colors.accent !== 'string'
) {
return false;
}
if (typeof obj.activePersonaAttire !== 'string') return false;
if (!Array.isArray(obj.activePayloads)) return false;
for (const payload of obj.activePayloads) {
if (typeof payload !== 'object' || payload === null) return false;
const p = payload as Record<string, unknown>;
if (
typeof p.id !== 'string' ||
typeof p.type !== 'string' ||
typeof p.position !== 'string'
) {
return false;
}
}
return true;
};
// Deserializer for URL decoding
export const deserializeConfig = (encoded: string): Partial<ConfigState> | null => {
try {
const decoded = atob(encoded);
const data = JSON.parse(decoded);
// Data uses abbreviated keys: c=colors, p=persona, y=payloads
if (
typeof data !== 'object' ||
data === null ||
!data.c ||
!data.p ||
!Array.isArray(data.y)
) {
console.error('Invalid config data structure');
return null;
}
const colors = data.c as Record<string, string>;
if (
typeof colors.primary !== 'string' ||
typeof colors.secondary !== 'string' ||
typeof colors.accent !== 'string'
) {
console.error('Invalid color config');
return null;
}
const payloads = data.y as Array<Record<string, string>>;
for (const payload of payloads) {
if (
typeof payload.id !== 'string' ||
typeof payload.type !== 'string' ||
typeof payload.position !== 'string'
) {
console.error('Invalid payload structure');
return null;
}
}
return {
activeColors: data.c,
activePersonaAttire: data.p,
activePayloads: data.y,
};
} catch (error) {
console.error('Failed to deserialize config:', error);
return null;
}
};

View File

@ -0,0 +1,150 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { orderStore } from '@/store/useOrderStore';
describe('useOrderStore', () => {
beforeEach(() => {
orderStore.getState().resetOrder();
});
describe('Initial State', () => {
it('should start at config step', () => {
expect(orderStore.getState().step).toBe('config');
});
it('should have empty shipping info', () => {
const { shipping } = orderStore.getState();
expect(shipping.name).toBe('');
expect(shipping.email).toBe('');
expect(shipping.phone).toBe('');
});
it('should have empty payment info', () => {
const { payment } = orderStore.getState();
expect(payment.cardNumber).toBe('');
expect(payment.cvv).toBe('');
});
it('should have no order ID', () => {
expect(orderStore.getState().orderId).toBe('');
});
});
describe('setStep', () => {
it('should transition to shipping', () => {
orderStore.getState().setStep('shipping');
expect(orderStore.getState().step).toBe('shipping');
});
it('should transition through full checkout flow', () => {
orderStore.getState().setStep('shipping');
expect(orderStore.getState().step).toBe('shipping');
orderStore.getState().setStep('payment');
expect(orderStore.getState().step).toBe('payment');
orderStore.getState().setStep('review');
expect(orderStore.getState().step).toBe('review');
});
});
describe('setShipping', () => {
it('should store shipping information', () => {
orderStore.getState().setShipping({
name: 'John Doe',
email: 'john@example.com',
phone: '+971501234567',
address: '123 Sheikh Zayed Road',
city: 'Dubai',
country: 'UAE',
postalCode: '00000',
});
const { shipping } = orderStore.getState();
expect(shipping.name).toBe('John Doe');
expect(shipping.email).toBe('john@example.com');
expect(shipping.city).toBe('Dubai');
});
});
describe('setPayment', () => {
it('should store payment information', () => {
orderStore.getState().setPayment({
cardNumber: '4242 4242 4242 4242',
expiry: '12/28',
cvv: '123',
nameOnCard: 'John Doe',
});
const { payment } = orderStore.getState();
expect(payment.cardNumber).toBe('4242 4242 4242 4242');
expect(payment.expiry).toBe('12/28');
expect(payment.nameOnCard).toBe('John Doe');
});
});
describe('setOrderTotal', () => {
it('should set the order total', () => {
orderStore.getState().setOrderTotal(265000);
expect(orderStore.getState().orderTotal).toBe(265000);
});
});
describe('setConfigSummary', () => {
it('should store persona and color summary', () => {
orderStore.getState().setConfigSummary('Emarati Kandura', '#ff0000');
expect(orderStore.getState().personaSummary).toBe('Emarati Kandura');
expect(orderStore.getState().colorSummary).toBe('#ff0000');
});
});
describe('placeOrder', () => {
it('should generate an order ID', () => {
orderStore.getState().placeOrder();
const { orderId } = orderStore.getState();
expect(orderId).toBeTruthy();
expect(orderId.startsWith('LR-G1-')).toBe(true);
});
it('should transition to confirmed step', () => {
orderStore.getState().placeOrder();
expect(orderStore.getState().step).toBe('confirmed');
});
it('should generate unique order IDs', () => {
orderStore.getState().placeOrder();
const id1 = orderStore.getState().orderId;
orderStore.getState().resetOrder();
orderStore.getState().placeOrder();
const id2 = orderStore.getState().orderId;
expect(id1).not.toBe(id2);
});
});
describe('resetOrder', () => {
it('should reset all state to defaults', () => {
orderStore.getState().setStep('payment');
orderStore.getState().setShipping({
name: 'Test',
email: 'test@test.com',
phone: '123',
address: 'addr',
city: 'city',
country: 'country',
postalCode: '000',
});
orderStore.getState().setOrderTotal(999999);
orderStore.getState().placeOrder();
orderStore.getState().resetOrder();
expect(orderStore.getState().step).toBe('config');
expect(orderStore.getState().shipping.name).toBe('');
expect(orderStore.getState().orderId).toBe('');
expect(orderStore.getState().orderTotal).toBe(0);
});
});
});

108
src/store/useOrderStore.ts Normal file
View File

@ -0,0 +1,108 @@
import { createStore } from 'zustand/vanilla';
import { useSyncExternalStore } from 'react';
export type CheckoutStep = 'config' | 'shipping' | 'payment' | 'review' | 'confirmed';
export interface ShippingInfo {
name: string;
email: string;
phone: string;
address: string;
city: string;
country: string;
postalCode: string;
}
export interface PaymentInfo {
cardNumber: string;
expiry: string;
cvv: string;
nameOnCard: string;
}
export interface OrderState {
step: CheckoutStep;
shipping: ShippingInfo;
payment: PaymentInfo;
orderId: string;
orderTotal: number;
personaSummary: string;
colorSummary: string;
}
export interface OrderActions {
setStep: (step: CheckoutStep) => void;
setShipping: (shipping: ShippingInfo) => void;
setPayment: (payment: PaymentInfo) => void;
setOrderTotal: (total: number) => void;
setConfigSummary: (persona: string, color: string) => void;
placeOrder: () => void;
resetOrder: () => void;
}
export type OrderStore = OrderState & OrderActions;
const emptyShipping: ShippingInfo = {
name: '',
email: '',
phone: '',
address: '',
city: '',
country: '',
postalCode: '',
};
const emptyPayment: PaymentInfo = {
cardNumber: '',
expiry: '',
cvv: '',
nameOnCard: '',
};
const defaultState: OrderState = {
step: 'config',
shipping: emptyShipping,
payment: emptyPayment,
orderId: '',
orderTotal: 0,
personaSummary: '',
colorSummary: '',
};
function generateOrderId(): string {
const timestamp = Date.now().toString(36).toUpperCase();
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
return `LR-G1-${timestamp}${random}`;
}
export const orderStore = createStore<OrderStore>((set) => ({
...defaultState,
setStep: (step: CheckoutStep) => set({ step }),
setShipping: (shipping: ShippingInfo) => set({ shipping }),
setPayment: (payment: PaymentInfo) => set({ payment }),
setOrderTotal: (total: number) => set({ orderTotal: total }),
setConfigSummary: (persona: string, color: string) => set({
personaSummary: persona,
colorSummary: color,
}),
placeOrder: () => set({
orderId: generateOrderId(),
step: 'confirmed',
}),
resetOrder: () => set({ ...defaultState }),
}));
export const useOrderStore = <T>(selector: (state: OrderStore) => T): T => {
return useSyncExternalStore(
orderStore.subscribe,
() => selector(orderStore.getState()),
() => selector(orderStore.getState())
);
};

View File

@ -0,0 +1,115 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { pricingStore } from '@/store/usePricingStore';
const mockStorage: Record<string, string> = {};
vi.stubGlobal('localStorage', {
getItem: vi.fn((key: string) => mockStorage[key] ?? null),
setItem: vi.fn((key: string, value: string) => { mockStorage[key] = value; }),
removeItem: vi.fn((key: string) => { delete mockStorage[key]; }),
clear: vi.fn(() => { Object.keys(mockStorage).forEach((k) => delete mockStorage[k]); }),
});
describe('usePricingStore', () => {
beforeEach(() => {
Object.keys(mockStorage).forEach((k) => delete mockStorage[k]);
pricingStore.getState().resetPrices();
});
describe('Default Prices', () => {
it('should have 5 default pricing items', () => {
const items = pricingStore.getState().items;
expect(items).toHaveLength(5);
});
it('should have correct default base price', () => {
const base = pricingStore.getState().items.find((i) => i.id === 'base');
expect(base).toBeDefined();
expect(base!.price).toBe(250000);
});
it('should have correct default attire prices', () => {
const items = pricingStore.getState().items;
const kandura = items.find((i) => i.id === 'emarati-kandura');
const vest = items.find((i) => i.id === 'industrial-vest');
const suit = items.find((i) => i.id === 'business-suit');
expect(kandura!.price).toBe(15000);
expect(vest!.price).toBe(8500);
expect(suit!.price).toBe(12000);
});
it('should have correct default custom color price', () => {
const color = pricingStore.getState().items.find((i) => i.id === 'custom-color');
expect(color!.price).toBe(3500);
});
});
describe('updatePrice', () => {
it('should update a single item price', () => {
pricingStore.getState().updatePrice('base', 300000);
const base = pricingStore.getState().items.find((i) => i.id === 'base');
expect(base!.price).toBe(300000);
});
it('should not affect other items when updating one', () => {
pricingStore.getState().updatePrice('base', 300000);
const kandura = pricingStore.getState().items.find((i) => i.id === 'emarati-kandura');
expect(kandura!.price).toBe(15000);
});
it('should persist to localStorage after update', () => {
pricingStore.getState().updatePrice('base', 999999);
expect(localStorage.setItem).toHaveBeenCalled();
const stored = JSON.parse(mockStorage['lootah-pricing']);
const base = stored.find((i: { id: string }) => i.id === 'base');
expect(base.price).toBe(999999);
});
});
describe('resetPrices', () => {
it('should restore all prices to defaults', () => {
pricingStore.getState().updatePrice('base', 1);
pricingStore.getState().updatePrice('emarati-kandura', 2);
pricingStore.getState().resetPrices();
const items = pricingStore.getState().items;
expect(items.find((i) => i.id === 'base')!.price).toBe(250000);
expect(items.find((i) => i.id === 'emarati-kandura')!.price).toBe(15000);
});
});
describe('hydrate', () => {
it('should load prices from localStorage', () => {
mockStorage['lootah-pricing'] = JSON.stringify([
{ id: 'base', label: 'G1 Robot Base', price: 500000 },
]);
pricingStore.getState().hydrate();
const base = pricingStore.getState().items.find((i) => i.id === 'base');
expect(base!.price).toBe(500000);
expect(pricingStore.getState().isHydrated).toBe(true);
});
it('should keep defaults for items not in localStorage', () => {
mockStorage['lootah-pricing'] = JSON.stringify([
{ id: 'base', label: 'G1 Robot Base', price: 500000 },
]);
pricingStore.getState().hydrate();
const kandura = pricingStore.getState().items.find((i) => i.id === 'emarati-kandura');
expect(kandura!.price).toBe(15000);
});
it('should set isHydrated even with no stored data', () => {
pricingStore.getState().hydrate();
expect(pricingStore.getState().isHydrated).toBe(true);
});
});
});

View File

@ -0,0 +1,100 @@
import { createStore } from 'zustand/vanilla';
import { useSyncExternalStore } from 'react';
export interface PricingItem {
id: string;
label: string;
price: number;
}
export interface PricingState {
items: PricingItem[];
isHydrated: boolean;
}
export interface PricingActions {
updatePrice: (itemId: string, newPrice: number) => void;
resetPrices: () => void;
hydrate: () => void;
}
export type PricingStore = PricingState & PricingActions;
const DEFAULT_ITEMS: PricingItem[] = [
{ id: 'base', label: 'G1 Robot Base', price: 250000 },
{ id: 'emarati-kandura', label: 'Emarati Kandura', price: 15000 },
{ id: 'industrial-vest', label: 'Industrial Vest', price: 8500 },
{ id: 'business-suit', label: 'Business Suit', price: 12000 },
{ id: 'custom-color', label: 'Custom Color', price: 3500 },
];
const STORAGE_KEY = 'lootah-pricing';
function loadFromStorage(): PricingItem[] | null {
if (typeof window === 'undefined') return null;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return null;
return parsed;
} catch {
return null;
}
}
function saveToStorage(items: PricingItem[]) {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
} catch {
// Storage full or unavailable
}
}
export const pricingStore = createStore<PricingStore>((set, get) => ({
items: DEFAULT_ITEMS,
isHydrated: false,
updatePrice: (itemId: string, newPrice: number) => {
set((state) => {
const updated = state.items.map((item) =>
item.id === itemId ? { ...item, price: newPrice } : item
);
saveToStorage(updated);
return { items: updated };
});
},
resetPrices: () => {
saveToStorage(DEFAULT_ITEMS);
set({ items: [...DEFAULT_ITEMS] });
},
hydrate: () => {
const stored = loadFromStorage();
if (stored) {
// Merge stored prices with defaults (in case new items were added)
const merged = DEFAULT_ITEMS.map((def) => {
const found = stored.find((s) => s.id === def.id);
return found ? { ...def, price: found.price } : def;
});
set({ items: merged, isHydrated: true });
} else {
set({ isHydrated: true });
}
},
}));
export const usePricingStore = <T>(selector: (state: PricingStore) => T): T => {
return useSyncExternalStore(
pricingStore.subscribe,
() => selector(pricingStore.getState()),
() => selector(pricingStore.getState())
);
};
export const getPrice = (itemId: string): number => {
const item = pricingStore.getState().items.find((i) => i.id === itemId);
return item?.price ?? 0;
};

1
src/test/setup.ts Normal file
View File

@ -0,0 +1 @@
import '@testing-library/jest-dom';

123
src/utils/imageGenerator.ts Normal file
View File

@ -0,0 +1,123 @@
/**
* Image Generation Utilities
*
* This module provides utilities for generating shareable images of the configured G1 robot.
* It serves as a placeholder for serverless API integration (e.g., Vercel OG, Cloudinary).
*/
import { ConfigState } from '@/store/useConfigStore';
import { serializeConfig } from '@/store/useConfigStore';
/**
* Configuration for image generation API
*/
interface ImageGeneratorConfig {
apiEndpoint?: string;
width?: number;
height?: number;
}
/**
* Response from image generation API
*/
interface ImageGenerationResult {
success: boolean;
imageUrl?: string;
error?: string;
}
/**
* Generates a shareable URL for the current configuration
* This URL can be used to share the exact robot configuration
*/
export const generateShareableUrl = (config: ConfigState): string => {
const encoded = serializeConfig(config);
const baseUrl = typeof window !== 'undefined' ? window.location.origin : '';
return `${baseUrl}?config=${encoded}`;
};
/**
* Placeholder for serverless image generation API
*
* In production, this would call a serverless function that:
* 1. Receives the configuration
* 2. Renders the 3D model server-side (using headless WebGL)
* 3. Returns a shareable image URL
*
* Example integrations:
* - Vercel OG Image: https://vercel.com/docs/concepts/og-image-generation
* - Cloudinary: https://cloudinary.com/documentation/transformation_api
* - AWS Lambda + Sharp: Custom implementation
*/
export const generateShareableImage = async (
config: ConfigState,
options: ImageGeneratorConfig = {}
): Promise<ImageGenerationResult> => {
const { apiEndpoint, width = 1200, height = 630 } = options;
// If no API endpoint is configured, return the shareable URL
if (!apiEndpoint) {
return {
success: true,
imageUrl: generateShareableUrl(config),
};
}
try {
const response = await fetch(apiEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
config: serializeConfig(config),
width,
height,
}),
});
if (!response.ok) {
throw new Error(`Image generation failed: ${response.statusText}`);
}
const data = await response.json();
return {
success: true,
imageUrl: data.imageUrl || data.url,
};
} catch (error) {
console.error('Image generation error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
imageUrl: generateShareableUrl(config), // Fallback to URL
};
}
};
/**
* Copy text to clipboard
*/
export const copyToClipboard = async (text: string): Promise<boolean> => {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
return true;
} catch {
return false;
} finally {
document.body.removeChild(textArea);
}
}
};

41
tsconfig.json Normal file
View File

@ -0,0 +1,41 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
},
"target": "ES2017"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

17
vitest.config.ts Normal file
View File

@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});