Compare commits

...

49 Commits
main ... main

Author SHA1 Message Date
Najjar\NajjarV02
c26338f355 perf: preload robot-doctor and security-guard GLBs in background on page load 2026-04-20 16:13:12 +04:00
Najjar\NajjarV02
dcb6a9bb43 feat: add new GLB models for police mannequin, medical professional, robot doctor, and security guard 2026-04-20 16:03:43 +04:00
Najjar\NajjarV02
e220d83397 revert: use original uncompressed GLBs (robot-doctor 30MB, security-guard 38.8MB) 2026-04-20 16:02:37 +04:00
Najjar\NajjarV02
3741d7386d chore: remove raw uncompressed GLBs from repo root (keep compressed versions in public/models) 2026-04-20 16:00:34 +04:00
Najjar\NajjarV02
1e128466f3 fix: prevent duplicate persona entries (race condition in hydrate), remove accidental 54MB file 2026-04-20 15:59:17 +04:00
Najjar\NajjarV02
2ff21c5b54 perf: compress GLBs 75%, add Draco decoder, loading spinner for attire 2026-04-20 15:52:00 +04:00
Najjar\NajjarV02
b2a484f402 fix: dynamic attire buttons in ScrollOverlays + mobile touch support 2026-04-20 15:13:34 +04:00
Najjar\NajjarV02
320b77b32b fix: contacts API - use ADMIN_JWT_SECRET env var 2026-04-20 14:31:21 +04:00
Najjar\NajjarV02
822ab076b4 Refactor code structure for improved readability and maintainability 2026-04-20 13:57:21 +04:00
Najjar\NajjarV02
25ffbf4b5d feat: add favicon and app icons for PWA support 2026-04-20 13:53:43 +04:00
Najjar\NajjarV02
4d145ae7b0 feat: add FooterAndContact component and privacy policy page 2026-04-20 12:07:23 +04:00
Najjar\NajjarV02
e686d41d26 fix: use configStore.getState().setPersonaAttire in ScrollOverlays 2026-04-20 12:00:20 +04:00
Najjar\NajjarV02
e1599650f0 feat: add GET endpoint to retrieve contact requests with admin authentication 2026-04-20 11:44:20 +04:00
Najjar\NajjarV02
9394b58b0e feat: initialize Lootah Robotics web application with 3D configurator and scroll-driven UI components 2026-04-20 11:36:04 +04:00
Najjar\NajjarV02
6c5f1f7f15 feat: enhance overlay button styles for improved visibility and user experience 2026-04-17 16:35:47 +04:00
Najjar\NajjarV02
7503a487ee feat: adjust overlay CTA section positioning for improved visibility 2026-04-17 16:30:53 +04:00
Najjar\NajjarV02
90a43c4312 feat: update overlay positioning and styles for improved CTA visibility 2026-04-17 16:24:20 +04:00
Najjar\NajjarV02
1788df79e1 feat: add ConfigurePage with back button and ConfiguratorSection integration 2026-04-17 16:17:52 +04:00
Najjar\NajjarV02
11ab8908fa feat: enhance pricing and persona synchronization logic for improved data consistency 2026-04-17 16:08:11 +04:00
Najjar\NajjarV02
13050a6541 feat: fetch and auto-register personas from server pricing items 2026-04-17 15:58:00 +04:00
Najjar\NajjarV02
26770e070c feat: refine overlay positioning and styles for improved layout on ScrollOverlays component 2026-04-17 15:57:02 +04:00
Najjar\NajjarV02
8216cbe0c0 feat: implement pricing item model and CRUD API for pricing management 2026-04-17 15:55:59 +04:00
Najjar\NajjarV02
5aba12f163 feat: add type definitions for draco3dgltf module 2026-04-17 15:42:27 +04:00
Najjar\NajjarV02
a955da7ec8 feat: add responsive overlay styles and enhance ScrollOverlays component 2026-04-17 15:37:27 +04:00
Najjar\NajjarV02
83d0d9ab00 feat: add GLTF transformation and compression support
- Updated package.json to include @gltf-transform/core, @gltf-transform/extensions, @gltf-transform/functions, and draco3dgltf for GLB file processing.
- Implemented GLB compression using Draco in the upload model API route.
- Adjusted camera keyframes in ScrollScene for improved animation.
- Enhanced overlay styles in ScrollOverlays for better visual clarity.
- Added original GLB files for Kandoura, Suit, Unitree_G1, and Vest.
- Updated existing GLB files for Kandoura, Suit, Unitree_G1, and Vest.
2026-04-17 15:34:41 +04:00
Najjar\NajjarV02
6dc705b332 feat: add AttireErrorBoundary component for improved error handling in attire model loading 2026-04-17 15:19:54 +04:00
Najjar\NajjarV02
ed6ebcc8af feat: update attire model loading strategy to improve initial load performance 2026-04-17 15:13:42 +04:00
Najjar\NajjarV02
9b8a50672e Add favicon.ico to the application 2026-04-17 15:01:05 +04:00
Najjar\NajjarV02
e8dda14973 feat: update page title for improved branding consistency 2026-04-17 14:56:36 +04:00
Najjar\NajjarV02
b9444979f2 feat: enhance responsive design for glass panel and configurator section 2026-04-17 13:29:44 +04:00
Najjar\NajjarV02
0793a650fb feat: add Robot Doctor and Security Guard personas with pricing and model paths 2026-04-17 10:10:54 +04:00
Najjar\NajjarV02
f53b2d3cb8 feat: update seed script to TypeScript and add tsx dependency 2026-04-15 10:53:51 +04:00
Najjar\NajjarV02
2397bf7f71 feat: add seed.js for database seeding functionality 2026-04-15 10:49:04 +04:00
Najjar\NajjarV02
22387dbfe1 feat: compile and use seed.js for database seeding in entrypoint script 2026-04-15 10:46:42 +04:00
Najjar\NajjarV02
61f1e0f589 fix: rename middleware function to proxy for clarity 2026-04-15 10:42:24 +04:00
Najjar\NajjarV02
dc99abad37 feat: update Dockerfile to include Prisma seed script and enhance entrypoint for seeding
feat: migrate middleware logic to new proxy.ts file for improved structure
2026-04-15 10:39:41 +04:00
Najjar\NajjarV02
45ffd33d39 feat: remove --skip-generate option from Prisma db push in entrypoint script 2026-04-15 10:30:13 +04:00
Najjar\NajjarV02
4924593b40 feat: consolidate Prisma CLI dependencies in Dockerfile for cleaner build 2026-04-15 10:25:09 +04:00
Najjar\NajjarV02
7227d0d10f feat: simplify Dockerfile by consolidating Prisma dependencies copy commands 2026-04-15 10:21:40 +04:00
Najjar\NajjarV02
c34e565132 feat: update Dockerfile to copy additional Prisma dependencies from deps stage 2026-04-15 10:17:47 +04:00
Najjar\NajjarV02
2b2198dfcb feat: set output configuration to standalone in Next.js config 2026-04-15 10:12:14 +04:00
Najjar\NajjarV02
138e8377a6 feat: update environment configuration for production and add Docker support 2026-04-15 10:04:07 +04:00
Najjar\NajjarV02
3fbdc3b187 feat: add snapshot caching functionality to PricingEngine and implement snapshot store 2026-04-14 10:47:27 +04:00
Najjar\NajjarV02
bb3b1f25f2 refactor: remove order synchronization logic from admin page and streamline order loading 2026-04-14 10:45:27 +04:00
Najjar\NajjarV02
9a1a562fc6 feat: implement order synchronization with Stripe and enhance order management features 2026-04-14 10:34:19 +04:00
Najjar\NajjarV02
a501d94645 feat: implement GLB file synchronization and add API for listing models 2026-04-14 10:16:56 +04:00
Najjar\NajjarV02
51671b85b8 feat: Integrate Stripe payment processing and enhance admin settings management
- Removed the previous demo payment system and implemented a real integration with Stripe for handling payments.
- Added new API routes for creating payment intents and handling webhooks from Stripe.
- Updated the database schema to include an Order model for storing payment details.
- Enhanced the admin page to manage pricing items, including the ability to upload 3D models.
- Introduced a settings management feature for the admin panel, allowing for dynamic key-value pairs.
- Improved the RobotModel component to support dynamic attire based on uploaded models.
- Added error handling and validation for file uploads and settings management.
2026-04-14 10:14:18 +04:00
Najjar\NajjarV02
dc42aeb72a feat: implement snapshot capture and storage for robot configurations 2026-04-13 18:00:12 +04:00
Najjar\NajjarV02
05b540997e feat: add admin authentication and management features
- Implemented Prisma schema with models for AdminUser, AppSettings, and Snapshot.
- Created seed script to initialize the database with an admin user and JWT secret.
- Developed admin login page with form handling and error management.
- Added API routes for admin login, logout, change password, and JWT verification.
- Integrated Stripe for payment intent management in admin orders.
- Established middleware for protecting admin routes with JWT authentication.
- Created Zustand stores for managing persona and snapshot states.
2026-04-13 17:57:59 +04:00
94 changed files with 9328 additions and 494 deletions

23
.dockerignore Normal file
View File

@ -0,0 +1,23 @@
node_modules
.next
.git
.gitignore
# Local env files — set these as environment variables in Coolify instead
.env
.env.local
.env.*.local
# SQLite database files — mount /app/prisma as a persistent volume in Coolify
prisma/*.db
prisma/*.db-shm
prisma/*.db-wal
prisma/migrations
# Dev/test artifacts
coverage
*.log
vitest.config.ts
src/**/*.test.ts
src/**/*.test.tsx
test/

18
.env.example Normal file
View File

@ -0,0 +1,18 @@
# ─────────────────────────────────────────────────────────────────────────────
# Stripe — https://dashboard.stripe.com/apikeys
# ─────────────────────────────────────────────────────────────────────────────
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
# ─────────────────────────────────────────────────────────────────────────────
# Admin JWT (generate with: openssl rand -hex 32)
# ─────────────────────────────────────────────────────────────────────────────
ADMIN_JWT_SECRET=change_me_generate_with_openssl_rand_hex_32
# ─────────────────────────────────────────────────────────────────────────────
# Database — SQLite via libsql
# In Coolify: set to file:/app/prisma/lootah.db
# and mount /app/prisma as a persistent volume
# ─────────────────────────────────────────────────────────────────────────────
DATABASE_URL=file:./prisma/lootah.db

View File

@ -0,0 +1,116 @@
# Plan: Admin Dashboard Auth + CRUD + Enhancements
## TL;DR
Add password authentication to the admin dashboard, enable full CRUD for personas/pricing, and add useful admin features like order analytics and configurator settings management.
## Phase 1: Admin Authentication (Database-backed with Prisma + SQLite)
### Steps
1. **Setup Prisma + SQLite**
- Install `prisma` and `@prisma/client`
- Create `prisma/schema.prisma` with SQLite provider
- Models: `AdminUser` (id, username, passwordHash, createdAt), `AppSettings` (key, value)
- Run `npx prisma db push` to create the database
- Create `src/lib/prisma.ts` — singleton Prisma client
2. **Create seed script** `prisma/seed.ts`
- Creates default admin user with bcrypt-hashed password
- Generates and stores JWT secret in `AppSettings` table
- Run with `npx prisma db seed`
3. **Create admin auth API routes**
- `src/app/api/admin/login/route.ts` — POST, accepts `{ username, password }`, verifies bcrypt hash from DB, returns JWT in httpOnly cookie (JWT secret from DB)
- `src/app/api/admin/verify/route.ts` — GET, checks JWT cookie validity
- `src/app/api/admin/logout/route.ts` — POST, clears auth cookie
- `src/app/api/admin/change-password/route.ts` — POST, accepts `{ currentPassword, newPassword }`, updates hash in DB
4. **Create admin middleware** `src/middleware.ts`
- Protects all `/admin/*` routes (except `/admin/login/`)
- Checks for valid JWT cookie, redirects to `/admin/login/` if missing/invalid
- Note: middleware can't use Prisma directly (edge runtime), so JWT secret needs to be in env OR verify via API call
5. **Create admin login page** `src/app/admin/login/page.tsx`
- Username + password form (styled to match existing admin design)
- Calls login API, redirects to `/admin/` on success
- Shows error on wrong credentials
6. **Add JWT_SECRET to .env.local** (only this one — password is in DB)
- Alternative: store JWT secret in DB and load at startup into a module-level cache
### Dependencies to install
- `prisma` (dev), `@prisma/client`, `bcryptjs`, `@types/bcryptjs` (dev), `jose`
## Phase 2: Full CRUD for Pricing Items + Personas
### Steps (depends on Phase 1)
6. **Add/Remove pricing items in admin** — Update `src/app/admin/page.tsx`
- "Add Item" button with fields: id (auto-slug from label), label, price
- Delete button per row (with confirmation)
- Update `usePricingStore` to support `addItem()` and `removeItem()` actions
7. **Persona management section** — New section in admin page
- List current personas with edit capability (label, description, colors)
- Add new persona form
- Delete persona button
- Create `usePersonaStore` or extend `usePricingStore` to manage persona definitions
- Sync persona list with ConfigPanel's `PERSONA_OPTIONS` (currently hardcoded)
## Phase 3: Additional Admin Features
### Steps (parallel with Phase 2)
8. **Orders dashboard section** — New section in admin page
- Show orders from Stripe API (list recent payments)
- Display: order ID, customer email, amount, status, date
- New API route `src/app/api/admin/orders/route.ts` to fetch from Stripe
9. **Analytics overview cards** at top of admin page
- Total revenue (from Stripe)
- Number of orders
- Most popular persona
- Most popular color
10. **Configurator settings** section
- Edit default color (`#96a2b6`)
- Toggle available color options
- Set min/max price boundaries
11. **Logout button** in admin header
## Relevant Files
- `src/app/admin/page.tsx` — Main admin dashboard (add CRUD UI, analytics)
- `src/app/admin/login/page.tsx` — New login page
- `src/app/api/admin/login/route.ts` — New auth API
- `src/app/api/admin/verify/route.ts` — New verify API
- `src/app/api/admin/orders/route.ts` — New orders API
- `src/middleware.ts` — New, protects admin routes
- `src/lib/prisma.ts` — New, Prisma client singleton
- `prisma/schema.prisma` — New, database schema (AdminUser, AppSettings)
- `prisma/seed.ts` — New, seeds default admin user
- `src/store/usePricingStore.ts` — Add `addItem()`, `removeItem()` actions
- `src/components/ConfigPanel.tsx` — Persona options currently hardcoded (PERSONA_OPTIONS const)
- `.env.local` — Add ADMIN_PASSWORD, ADMIN_JWT_SECRET
## Verification
1. Visit `/admin/` without login → redirected to `/admin/login/`
2. Enter wrong password → error message shown
3. Enter correct password → redirected to `/admin/`
4. Add a pricing item → appears in configurator pricing breakdown
5. Remove a pricing item → disappears from pricing
6. Edit persona → reflected in ConfigPanel
7. Orders section shows real Stripe payment data
8. Refresh admin page → still logged in (cookie persists)
9. Run `npx vitest run` → all existing tests pass
## Decisions
- **Database: Prisma + SQLite** — file-based, no external server needed, real ORM
- Admin password stored as bcrypt hash in DB (not in env vars)
- JWT secret stored in AppSettings table in DB
- JWT in httpOnly cookie — secure, no localStorage tokens
- jose library for JWT — lightweight, edge-compatible (works in Next.js middleware)
- Personas need to move from hardcoded array to store-driven — breaking change in ConfigPanel
- Stripe orders fetched via Stripe API directly — no local DB needed for orders
## Further Considerations
1. **Persona storage**: Currently hardcoded in ConfigPanel. Moving to a store means ConfigPanel reads from store dynamically. This is required for admin CRUD to work. **Recommended: create persona store with localStorage persistence (same pattern as pricing store)**
2. **Stripe orders vs local orders**: Currently orders only exist client-side. To show in admin, we fetch from Stripe's payment intents list. **Recommended: use Stripe API directly, no local DB needed**
3. **Multi-admin support**: Currently single password. If needed later, can upgrade to NextAuth with credentials provider. **Recommended: start simple, upgrade if needed**

2
.gitignore vendored
View File

@ -35,3 +35,5 @@ next-env.d.ts
# IDE # IDE
.idea .idea
.vscode .vscode
/src/generated/prisma

127
CHANGELOG.md Normal file
View File

@ -0,0 +1,127 @@
# Changelog — Lootah Robotics G1 Configurator
> تاريخ التغييرات — 20 أبريل 2026
---
## 2ff21c5 — perf: compress GLBs 75%, add Draco decoder, loading spinner for attire
### المشكلة
- الموبايلات القديمة كانت تعلّق لأوقات طويلة عند تحميل الأزياء الجديدة
- التبديل بين الأزياء (Robot Doctor / Security Guard) كان يعلّق بدون أي مؤشر تحميل
### التغييرات
| الملف | التغيير |
|---|---|
| `public/models/robot-doctor.glb` | ضغط Draco: 32 MB → 8.5 MB (توفير 74%) |
| `public/models/security-guard.glb` | ضغط Draco: 29 MB → 6.85 MB (توفير 77%) |
| `public/draco/` | إضافة ملفات Draco decoder للمتصفح |
| `src/components/RobotModel.tsx` | تفعيل `useGLTF.setDecoderPath('/draco/')` |
| `src/components/RobotCanvas.tsx` | تفعيل Draco decoder + تخفيض DPR من 2x إلى 1.5x |
| `src/components/ScrollScene.tsx` | تفعيل Draco decoder |
| `src/components/ConfigPanel.tsx` | إضافة spinner عند تحميل زي جديد |
### لماذا كانت المشكلة موجودة؟
الملفات كانت مضغوطة بـ Draco لكن المتصفح لم يكن عنده الـ decoder لفك الضغط، فكان يفشل بصمت ويرجع للروبوت الأساسي.
---
## b2a484f — fix: dynamic attire buttons in ScrollOverlays + mobile touch support
### المشكلة
- أزرار الأزياء في صفحة الـ Landing (Kandura, Vest, Suit) كانت مكتوبة بشكل ثابت (hardcoded)
- أي زي جديد يضاف من الأدمن لا يظهر في الصفحة الرئيسية
- الأزرار كانت تعمل بـ hover فقط (لا تعمل على الموبايل بالضغط)
### التغييرات
| الملف | التغيير |
|---|---|
| `src/components/ScrollOverlays.tsx` | جلب الأزياء ديناميكياً من `/api/admin/pricing/` |
| `src/components/ScrollOverlays.tsx` | إضافة `onClick` بجانب `onMouseEnter` لدعم اللمس |
| `src/components/ScrollOverlays.tsx` | إضافة `pointerEvents: 'auto'` لأن الـ overlay كان `pointerEvents: 'none'` |
---
## 320b77b — fix: contacts API - use ADMIN_JWT_SECRET env var
### المشكلة
- صفحة Contacts في الأدمن كانت ترجع خطأ 500
### السبب
- Route الـ contacts كان يستخدم `JWT_SECRET` بينما باقي الـ routes تستخدم `ADMIN_JWT_SECRET`
- أي JWT مولّد بـ `ADMIN_JWT_SECRET` سيفشل التحقق عند استخدام متغير مختلف
### التغييرات
| الملف | التغيير |
|---|---|
| `src/app/api/admin/contacts/route.ts` | استخدام `ADMIN_JWT_SECRET` بدلاً من `JWT_SECRET` |
| `src/app/api/admin/contacts/route.ts` | إضافة رسالة خطأ واضحة إذا كان `ADMIN_JWT_SECRET` غير موجود |
---
## 25ffbf4 — feat: add favicon and app icons for PWA support
### التغييرات
| الملف | التغيير |
|---|---|
| `public/favicon.ico` | أيقونة المتصفح |
| `public/apple-touch-icon.png` | أيقونة iOS Home Screen |
| `public/icon-192.png` | أيقونة PWA 192px |
| `public/icon-192-maskable.png` | أيقونة PWA maskable 192px |
| `public/icon-512.png` | أيقونة PWA 512px |
| `public/icon-512-maskable.png` | أيقونة PWA maskable 512px |
| `src/app/layout.tsx` | إضافة `<link rel="icon">` و `<link rel="apple-touch-icon">` |
---
## e686d41 — fix: use configStore.getState().setPersonaAttire in ScrollOverlays
### المشكلة
- بناء Docker كان يفشل مع خطأ TypeScript:
```
Property 'getState' does not exist on type '<T>(selector: (state: ConfigStore) => T) => T'
```
### السبب
كان الكود يستخدم `useConfigStore.getState()` لكن `useConfigStore` هو React hook (دالة عادية) وليس Zustand store. فقط `configStore` المُصدَّر من vanilla Zustand يملك `.getState()`.
بالإضافة لذلك، اسم الدالة كان خاطئاً: `setActivePersonaAttire` بدلاً من `setPersonaAttire`.
### التغييرات
| الملف | التغيير |
|---|---|
| `src/components/ScrollOverlays.tsx` | `useConfigStore.getState().setActivePersonaAttire()``configStore.getState().setPersonaAttire()` |
---
## e159965 — feat: add GET endpoint to retrieve contact requests with admin authentication
### التغييرات
| الملف | التغيير |
|---|---|
| `src/app/api/admin/contacts/route.ts` | إضافة GET endpoint لجلب طلبات التواصل مع التحقق من الأدمن |
---
## ملاحظات عامة على البنية
### متغيرات البيئة المطلوبة
```
ADMIN_JWT_SECRET= # مطلوب لجميع routes الأدمن
DATABASE_URL= # Prisma / SQLite
STRIPE_SECRET_KEY= # للمدفوعات
```
### هيكل Stores
| Store | الوصف |
|---|---|
| `configStore` (vanilla Zustand) | الألوان والزي النشط — يدعم `.getState()` |
| `useConfigStore` (React hook) | wrapper لـ `configStore` للاستخدام داخل components |
| `personaStore` (vanilla Zustand) | قائمة الأزياء — تُحمَّل من API عند التهيئة |
| `pricingStore` | أسعار العناصر — تُزامَن مع قاعدة البيانات |
### تدفق الأزياء المرفوعة
1. الأدمن يرفع `.glb` من لوحة التحكم
2. يُضغط تلقائياً بـ Draco عبر `upload-model` route
3. يُحفظ المسار في قاعدة البيانات (`PricingItem.modelPath`)
4. عند تحميل الصفحة، `personaStore.hydrate()` يجلب الأزياء من `/api/admin/pricing/`
5. تظهر تلقائياً في `ConfigPanel` وفي `ScrollOverlays` (الصفحة الرئيسية)

60
Dockerfile Normal file
View File

@ -0,0 +1,60 @@
# ── Stage 1: install all dependencies ─────────────────────────────────────────
FROM node:22.14-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ── Stage 2: build ─────────────────────────────────────────────────────────────
FROM node:22.14-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
# Generate Prisma client (reads prisma/schema.prisma + prisma.config.ts)
RUN npx prisma generate
# Build Next.js — produces .next/standalone (set in next.config.mjs)
RUN npm run build
# ── Stage 3: production runner ─────────────────────────────────────────────────
FROM node:22.14-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# dumb-init: proper PID 1 / signal forwarding
RUN apk add --no-cache dumb-init \
&& addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
# ── Next.js standalone server ──────────────────────────────────────────────────
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
# ── Prisma runtime ─────────────────────────────────────────────────────────────
# Config, schema, and seed script
COPY --from=builder --chown=nextjs:nodejs /app/prisma.config.ts ./
COPY --from=builder --chown=nextjs:nodejs /app/prisma/schema.prisma ./prisma/
COPY --from=builder --chown=nextjs:nodejs /app/prisma/seed.ts ./prisma/
# Generated Prisma client (imported by the compiled Next.js server bundle)
COPY --from=builder --chown=nextjs:nodejs /app/src/generated ./src/generated
# Prisma CLI + all its transitive dependencies for `prisma db push` at startup
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
# ── Entrypoint ─────────────────────────────────────────────────────────────────
COPY --chown=nextjs:nodejs docker-entrypoint.sh ./
RUN chmod +x /app/docker-entrypoint.sh
USER nextjs
EXPOSE 3000
# dumb-init wraps our entrypoint so SIGTERM is forwarded to node properly
ENTRYPOINT ["/usr/bin/dumb-init", "--", "/app/docker-entrypoint.sh"]

0
dev.db Normal file
View File

16
docker-entrypoint.sh Normal file
View File

@ -0,0 +1,16 @@
#!/bin/sh
set -e
# Ensure the persistent database directory exists
# (Coolify: mount a volume at /app/prisma so data survives redeployments)
mkdir -p /app/prisma
echo "→ Syncing database schema..."
# db push creates the SQLite file and syncs tables to match schema.prisma
/app/node_modules/.bin/prisma db push
echo "→ Seeding database (idempotent — skips existing records)..."
/app/node_modules/.bin/tsx /app/prisma/seed.ts
echo "→ Starting Next.js on port ${PORT:-3000}..."
exec node /app/server.js

View File

@ -1,6 +1,6 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: 'export', output: 'standalone',
images: { images: {
unoptimized: true, unoptimized: true,
}, },

4336
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,32 +11,49 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@gltf-transform/core": "^4.3.0",
"@gltf-transform/extensions": "^4.3.0",
"@gltf-transform/functions": "^4.3.0",
"@libsql/client": "^0.17.2",
"@prisma/adapter-libsql": "^7.7.0",
"@prisma/client": "^7.7.0",
"@react-spring/three": "^10.0.3", "@react-spring/three": "^10.0.3",
"@react-three/drei": "^10.7.7", "@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0", "@react-three/fiber": "^9.5.0",
"@react-three/postprocessing": "^2.16.0", "@react-three/postprocessing": "^2.16.0",
"@stripe/react-stripe-js": "^6.1.0",
"@stripe/stripe-js": "^9.1.0",
"@types/three": "^0.183.1", "@types/three": "^0.183.1",
"bcryptjs": "^3.0.3",
"draco3dgltf": "^1.5.7",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"gsap": "^3.14.2", "gsap": "^3.14.2",
"i18next": "^26.0.3", "i18next": "^26.0.3",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"jose": "^6.2.2",
"next": "16.2.2", "next": "16.2.2",
"react": "19.0.0", "react": "19.0.0",
"react-country-phone-input": "^1.0.2",
"react-dom": "19.0.0", "react-dom": "19.0.0",
"react-i18next": "^17.0.2", "react-i18next": "^17.0.2",
"stripe": "^22.0.1",
"three": "^0.170.0", "three": "^0.170.0",
"tsx": "^4.19.2",
"zustand": "^5.0.12" "zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@gltf-transform/cli": "^4.3.0",
"@tailwindcss/postcss": "^4.2.2", "@tailwindcss/postcss": "^4.2.2",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"jsdom": "^29.0.2", "jsdom": "^29.0.2",
"prisma": "^7.7.0",
"tailwindcss": "4.2.2", "tailwindcss": "4.2.2",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"vitest": "^4.1.2" "vitest": "^4.1.2"

13
prisma.config.ts Normal file
View File

@ -0,0 +1,13 @@
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
// In production (Coolify), set DATABASE_URL=file:/app/prisma/lootah.db
// and mount /app/prisma as a persistent volume.
url: process.env.DATABASE_URL ?? "file:./prisma/lootah.db",
},
});

BIN
prisma/lootah.db Normal file

Binary file not shown.

67
prisma/schema.prisma Normal file
View File

@ -0,0 +1,67 @@
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "sqlite"
}
model AdminUser {
id String @id @default(cuid())
username String @unique
passwordHash String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AppSettings {
key String @id
value String
updatedAt DateTime @updatedAt
}
model Snapshot {
paymentIntentId String @id
imageData String
createdAt DateTime @default(now())
}
model Order {
id String @id @default(cuid())
paymentIntentId String @unique
amount Int
currency String @default("aed")
status String
customerName String?
customerEmail String?
customerPhone String?
customerAddress String?
customerCity String?
customerCountry String?
customerPostalCode String?
persona String?
color String?
priceItems String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model PricingItem {
id String @id
label String
price Int
modelPath String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ContactRequest {
id String @id @default(cuid())
name String
email String
phone String?
message String
createdAt DateTime @default(now())
}

51
prisma/seed.js Normal file
View File

@ -0,0 +1,51 @@
'use strict';
const { PrismaClient } = require('../src/generated/prisma/client.js');
const { PrismaLibSql } = require('@prisma/adapter-libsql');
const bcrypt = require('bcryptjs');
const { randomBytes } = require('crypto');
const path = require('path');
const dbUrl = process.env.DATABASE_URL ?? `file:${path.resolve(process.cwd(), 'prisma', 'lootah.db')}`;
const adapter = new PrismaLibSql({ url: dbUrl });
const prisma = new PrismaClient({ adapter });
async function main() {
console.log('Seeding database...');
const existingAdmin = await prisma.adminUser.findUnique({
where: { username: 'admin' },
});
if (!existingAdmin) {
const passwordHash = await bcrypt.hash('admin123', 12);
await prisma.adminUser.create({
data: { username: 'admin', passwordHash },
});
console.log('✓ Created admin user (username: admin, password: admin123)');
console.log(' ⚠️ Change the password after first login!');
} else {
console.log('✓ Admin user already exists, skipping.');
}
const existingSecret = await prisma.appSettings.findUnique({
where: { key: 'jwt_secret' },
});
if (!existingSecret) {
const jwtSecret = randomBytes(64).toString('hex');
await prisma.appSettings.upsert({
where: { key: 'jwt_secret' },
update: { value: jwtSecret },
create: { key: 'jwt_secret', value: jwtSecret },
});
console.log('✓ Generated JWT secret and stored in database.');
} else {
console.log('✓ JWT secret already exists, skipping.');
}
console.log('Seeding complete!');
}
main()
.catch((e) => { console.error(e); process.exit(1); })
.finally(async () => { await prisma.$disconnect(); });

80
prisma/seed.ts Normal file
View File

@ -0,0 +1,80 @@
import { PrismaClient } from '../src/generated/prisma/client.js';
import { PrismaLibSql } from '@prisma/adapter-libsql';
import bcrypt from 'bcryptjs';
import { randomBytes } from 'crypto';
import path from 'path';
const dbPath = path.resolve(process.cwd(), 'prisma', 'lootah.db');
const adapter = new PrismaLibSql({ url: `file:${dbPath}` });
const prisma = new PrismaClient({ adapter } as ConstructorParameters<typeof PrismaClient>[0]);
async function main() {
console.log('Seeding database...');
// Create default admin user
const existingAdmin = await prisma.adminUser.findUnique({
where: { username: 'admin' },
});
if (!existingAdmin) {
const passwordHash = await bcrypt.hash('admin123', 12);
await prisma.adminUser.create({
data: {
username: 'admin',
passwordHash,
},
});
console.log('✓ Created admin user (username: admin, password: admin123)');
console.log(' ⚠️ Change the password after first login!');
} else {
console.log('✓ Admin user already exists, skipping.');
}
// Generate and store JWT secret
const existingSecret = await prisma.appSettings.findUnique({
where: { key: 'jwt_secret' },
});
if (!existingSecret) {
const jwtSecret = randomBytes(64).toString('hex');
await prisma.appSettings.upsert({
where: { key: 'jwt_secret' },
update: { value: jwtSecret },
create: { key: 'jwt_secret', value: jwtSecret },
});
console.log('✓ Generated JWT secret and stored in database.');
} else {
console.log('✓ JWT secret already exists, skipping.');
}
// Seed default pricing items (idempotent — only if table is empty)
const pricingCount = await prisma.pricingItem.count();
if (pricingCount === 0) {
const defaultItems = [
{ id: 'base', label: 'G1 Robot Base', price: 250000, sortOrder: 0 },
{ id: 'emarati-kandura', label: 'Emarati Kandura', price: 15000, sortOrder: 1 },
{ id: 'industrial-vest', label: 'Industrial Vest', price: 8500, sortOrder: 2 },
{ id: 'business-suit', label: 'Business Suit', price: 12000, sortOrder: 3 },
{ id: 'custom-color', label: 'Custom Color', price: 3500, sortOrder: 4 },
{ id: 'robot-doctor', label: 'Robot Doctor', price: 5000, sortOrder: 5 },
{ id: 'security-guard', label: 'Security Guard', price: 5000, sortOrder: 6 },
];
for (const item of defaultItems) {
await prisma.pricingItem.create({ data: item });
}
console.log(`✓ Seeded ${defaultItems.length} default pricing items.`);
} else {
console.log(`${pricingCount} pricing items already exist, skipping.`);
}
console.log('Seeding complete!');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/Suit_original.glb Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/Vest_original.glb Normal file

Binary file not shown.

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,116 @@
var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.arrayIteratorImpl=function(h){var n=0;return function(){return n<h.length?{done:!1,value:h[n++]}:{done:!0}}};$jscomp.arrayIterator=function(h){return{next:$jscomp.arrayIteratorImpl(h)}};$jscomp.makeIterator=function(h){var n="undefined"!=typeof Symbol&&Symbol.iterator&&h[Symbol.iterator];return n?n.call(h):$jscomp.arrayIterator(h)};$jscomp.ASSUME_ES5=!1;$jscomp.ASSUME_NO_NATIVE_MAP=!1;$jscomp.ASSUME_NO_NATIVE_SET=!1;$jscomp.SIMPLE_FROUND_POLYFILL=!1;
$jscomp.ISOLATE_POLYFILLS=!1;$jscomp.FORCE_POLYFILL_PROMISE=!1;$jscomp.FORCE_POLYFILL_PROMISE_WHEN_NO_UNHANDLED_REJECTION=!1;$jscomp.getGlobal=function(h){h=["object"==typeof globalThis&&globalThis,h,"object"==typeof window&&window,"object"==typeof self&&self,"object"==typeof global&&global];for(var n=0;n<h.length;++n){var k=h[n];if(k&&k.Math==Math)return k}throw Error("Cannot find global object");};$jscomp.global=$jscomp.getGlobal(this);
$jscomp.defineProperty=$jscomp.ASSUME_ES5||"function"==typeof Object.defineProperties?Object.defineProperty:function(h,n,k){if(h==Array.prototype||h==Object.prototype)return h;h[n]=k.value;return h};$jscomp.IS_SYMBOL_NATIVE="function"===typeof Symbol&&"symbol"===typeof Symbol("x");$jscomp.TRUST_ES6_POLYFILLS=!$jscomp.ISOLATE_POLYFILLS||$jscomp.IS_SYMBOL_NATIVE;$jscomp.polyfills={};$jscomp.propertyToPolyfillSymbol={};$jscomp.POLYFILL_PREFIX="$jscp$";
var $jscomp$lookupPolyfilledValue=function(h,n){var k=$jscomp.propertyToPolyfillSymbol[n];if(null==k)return h[n];k=h[k];return void 0!==k?k:h[n]};$jscomp.polyfill=function(h,n,k,p){n&&($jscomp.ISOLATE_POLYFILLS?$jscomp.polyfillIsolated(h,n,k,p):$jscomp.polyfillUnisolated(h,n,k,p))};
$jscomp.polyfillUnisolated=function(h,n,k,p){k=$jscomp.global;h=h.split(".");for(p=0;p<h.length-1;p++){var l=h[p];if(!(l in k))return;k=k[l]}h=h[h.length-1];p=k[h];n=n(p);n!=p&&null!=n&&$jscomp.defineProperty(k,h,{configurable:!0,writable:!0,value:n})};
$jscomp.polyfillIsolated=function(h,n,k,p){var l=h.split(".");h=1===l.length;p=l[0];p=!h&&p in $jscomp.polyfills?$jscomp.polyfills:$jscomp.global;for(var y=0;y<l.length-1;y++){var f=l[y];if(!(f in p))return;p=p[f]}l=l[l.length-1];k=$jscomp.IS_SYMBOL_NATIVE&&"es6"===k?p[l]:null;n=n(k);null!=n&&(h?$jscomp.defineProperty($jscomp.polyfills,l,{configurable:!0,writable:!0,value:n}):n!==k&&(void 0===$jscomp.propertyToPolyfillSymbol[l]&&(k=1E9*Math.random()>>>0,$jscomp.propertyToPolyfillSymbol[l]=$jscomp.IS_SYMBOL_NATIVE?
$jscomp.global.Symbol(l):$jscomp.POLYFILL_PREFIX+k+"$"+l),$jscomp.defineProperty(p,$jscomp.propertyToPolyfillSymbol[l],{configurable:!0,writable:!0,value:n})))};
$jscomp.polyfill("Promise",function(h){function n(){this.batch_=null}function k(f){return f instanceof l?f:new l(function(q,u){q(f)})}if(h&&(!($jscomp.FORCE_POLYFILL_PROMISE||$jscomp.FORCE_POLYFILL_PROMISE_WHEN_NO_UNHANDLED_REJECTION&&"undefined"===typeof $jscomp.global.PromiseRejectionEvent)||!$jscomp.global.Promise||-1===$jscomp.global.Promise.toString().indexOf("[native code]")))return h;n.prototype.asyncExecute=function(f){if(null==this.batch_){this.batch_=[];var q=this;this.asyncExecuteFunction(function(){q.executeBatch_()})}this.batch_.push(f)};
var p=$jscomp.global.setTimeout;n.prototype.asyncExecuteFunction=function(f){p(f,0)};n.prototype.executeBatch_=function(){for(;this.batch_&&this.batch_.length;){var f=this.batch_;this.batch_=[];for(var q=0;q<f.length;++q){var u=f[q];f[q]=null;try{u()}catch(A){this.asyncThrow_(A)}}}this.batch_=null};n.prototype.asyncThrow_=function(f){this.asyncExecuteFunction(function(){throw f;})};var l=function(f){this.state_=0;this.result_=void 0;this.onSettledCallbacks_=[];this.isRejectionHandled_=!1;var q=this.createResolveAndReject_();
try{f(q.resolve,q.reject)}catch(u){q.reject(u)}};l.prototype.createResolveAndReject_=function(){function f(A){return function(F){u||(u=!0,A.call(q,F))}}var q=this,u=!1;return{resolve:f(this.resolveTo_),reject:f(this.reject_)}};l.prototype.resolveTo_=function(f){if(f===this)this.reject_(new TypeError("A Promise cannot resolve to itself"));else if(f instanceof l)this.settleSameAsPromise_(f);else{a:switch(typeof f){case "object":var q=null!=f;break a;case "function":q=!0;break a;default:q=!1}q?this.resolveToNonPromiseObj_(f):
this.fulfill_(f)}};l.prototype.resolveToNonPromiseObj_=function(f){var q=void 0;try{q=f.then}catch(u){this.reject_(u);return}"function"==typeof q?this.settleSameAsThenable_(q,f):this.fulfill_(f)};l.prototype.reject_=function(f){this.settle_(2,f)};l.prototype.fulfill_=function(f){this.settle_(1,f)};l.prototype.settle_=function(f,q){if(0!=this.state_)throw Error("Cannot settle("+f+", "+q+"): Promise already settled in state"+this.state_);this.state_=f;this.result_=q;2===this.state_&&this.scheduleUnhandledRejectionCheck_();
this.executeOnSettledCallbacks_()};l.prototype.scheduleUnhandledRejectionCheck_=function(){var f=this;p(function(){if(f.notifyUnhandledRejection_()){var q=$jscomp.global.console;"undefined"!==typeof q&&q.error(f.result_)}},1)};l.prototype.notifyUnhandledRejection_=function(){if(this.isRejectionHandled_)return!1;var f=$jscomp.global.CustomEvent,q=$jscomp.global.Event,u=$jscomp.global.dispatchEvent;if("undefined"===typeof u)return!0;"function"===typeof f?f=new f("unhandledrejection",{cancelable:!0}):
"function"===typeof q?f=new q("unhandledrejection",{cancelable:!0}):(f=$jscomp.global.document.createEvent("CustomEvent"),f.initCustomEvent("unhandledrejection",!1,!0,f));f.promise=this;f.reason=this.result_;return u(f)};l.prototype.executeOnSettledCallbacks_=function(){if(null!=this.onSettledCallbacks_){for(var f=0;f<this.onSettledCallbacks_.length;++f)y.asyncExecute(this.onSettledCallbacks_[f]);this.onSettledCallbacks_=null}};var y=new n;l.prototype.settleSameAsPromise_=function(f){var q=this.createResolveAndReject_();
f.callWhenSettled_(q.resolve,q.reject)};l.prototype.settleSameAsThenable_=function(f,q){var u=this.createResolveAndReject_();try{f.call(q,u.resolve,u.reject)}catch(A){u.reject(A)}};l.prototype.then=function(f,q){function u(w,B){return"function"==typeof w?function(R){try{A(w(R))}catch(Z){F(Z)}}:B}var A,F,v=new l(function(w,B){A=w;F=B});this.callWhenSettled_(u(f,A),u(q,F));return v};l.prototype.catch=function(f){return this.then(void 0,f)};l.prototype.callWhenSettled_=function(f,q){function u(){switch(A.state_){case 1:f(A.result_);
break;case 2:q(A.result_);break;default:throw Error("Unexpected state: "+A.state_);}}var A=this;null==this.onSettledCallbacks_?y.asyncExecute(u):this.onSettledCallbacks_.push(u);this.isRejectionHandled_=!0};l.resolve=k;l.reject=function(f){return new l(function(q,u){u(f)})};l.race=function(f){return new l(function(q,u){for(var A=$jscomp.makeIterator(f),F=A.next();!F.done;F=A.next())k(F.value).callWhenSettled_(q,u)})};l.all=function(f){var q=$jscomp.makeIterator(f),u=q.next();return u.done?k([]):new l(function(A,
F){function v(R){return function(Z){w[R]=Z;B--;0==B&&A(w)}}var w=[],B=0;do w.push(void 0),B++,k(u.value).callWhenSettled_(v(w.length-1),F),u=q.next();while(!u.done)})};return l},"es6","es3");$jscomp.owns=function(h,n){return Object.prototype.hasOwnProperty.call(h,n)};$jscomp.assign=$jscomp.TRUST_ES6_POLYFILLS&&"function"==typeof Object.assign?Object.assign:function(h,n){for(var k=1;k<arguments.length;k++){var p=arguments[k];if(p)for(var l in p)$jscomp.owns(p,l)&&(h[l]=p[l])}return h};
$jscomp.polyfill("Object.assign",function(h){return h||$jscomp.assign},"es6","es3");$jscomp.checkStringArgs=function(h,n,k){if(null==h)throw new TypeError("The 'this' value for String.prototype."+k+" must not be null or undefined");if(n instanceof RegExp)throw new TypeError("First argument to String.prototype."+k+" must not be a regular expression");return h+""};
$jscomp.polyfill("String.prototype.startsWith",function(h){return h?h:function(n,k){var p=$jscomp.checkStringArgs(this,n,"startsWith");n+="";var l=p.length,y=n.length;k=Math.max(0,Math.min(k|0,p.length));for(var f=0;f<y&&k<l;)if(p[k++]!=n[f++])return!1;return f>=y}},"es6","es3");
$jscomp.polyfill("Array.prototype.copyWithin",function(h){function n(k){k=Number(k);return Infinity===k||-Infinity===k?k:k|0}return h?h:function(k,p,l){var y=this.length;k=n(k);p=n(p);l=void 0===l?y:n(l);k=0>k?Math.max(y+k,0):Math.min(k,y);p=0>p?Math.max(y+p,0):Math.min(p,y);l=0>l?Math.max(y+l,0):Math.min(l,y);if(k<p)for(;p<l;)p in this?this[k++]=this[p++]:(delete this[k++],p++);else for(l=Math.min(l,y+p-k),k+=l-p;l>p;)--l in this?this[--k]=this[l]:delete this[--k];return this}},"es6","es3");
$jscomp.typedArrayCopyWithin=function(h){return h?h:Array.prototype.copyWithin};$jscomp.polyfill("Int8Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Uint8Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Uint8ClampedArray.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Int16Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");
$jscomp.polyfill("Uint16Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Int32Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Uint32Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Float32Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Float64Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");
var DracoDecoderModule=function(){var h="undefined"!==typeof document&&document.currentScript?document.currentScript.src:void 0;"undefined"!==typeof __filename&&(h=h||__filename);return function(n){function k(e){return a.locateFile?a.locateFile(e,U):U+e}function p(e,b){if(e){var c=ia;var d=e+b;for(b=e;c[b]&&!(b>=d);)++b;if(16<b-e&&c.buffer&&ra)c=ra.decode(c.subarray(e,b));else{for(d="";e<b;){var g=c[e++];if(g&128){var t=c[e++]&63;if(192==(g&224))d+=String.fromCharCode((g&31)<<6|t);else{var aa=c[e++]&
63;g=224==(g&240)?(g&15)<<12|t<<6|aa:(g&7)<<18|t<<12|aa<<6|c[e++]&63;65536>g?d+=String.fromCharCode(g):(g-=65536,d+=String.fromCharCode(55296|g>>10,56320|g&1023))}}else d+=String.fromCharCode(g)}c=d}}else c="";return c}function l(){var e=ja.buffer;a.HEAP8=W=new Int8Array(e);a.HEAP16=new Int16Array(e);a.HEAP32=ca=new Int32Array(e);a.HEAPU8=ia=new Uint8Array(e);a.HEAPU16=new Uint16Array(e);a.HEAPU32=Y=new Uint32Array(e);a.HEAPF32=new Float32Array(e);a.HEAPF64=new Float64Array(e)}function y(e){if(a.onAbort)a.onAbort(e);
e="Aborted("+e+")";da(e);sa=!0;e=new WebAssembly.RuntimeError(e+". Build with -sASSERTIONS for more info.");ka(e);throw e;}function f(e){try{if(e==P&&ea)return new Uint8Array(ea);if(ma)return ma(e);throw"both async and sync fetching of the wasm failed";}catch(b){y(b)}}function q(){if(!ea&&(ta||fa)){if("function"==typeof fetch&&!P.startsWith("file://"))return fetch(P,{credentials:"same-origin"}).then(function(e){if(!e.ok)throw"failed to load wasm binary file at '"+P+"'";return e.arrayBuffer()}).catch(function(){return f(P)});
if(na)return new Promise(function(e,b){na(P,function(c){e(new Uint8Array(c))},b)})}return Promise.resolve().then(function(){return f(P)})}function u(e){for(;0<e.length;)e.shift()(a)}function A(e){this.excPtr=e;this.ptr=e-24;this.set_type=function(b){Y[this.ptr+4>>2]=b};this.get_type=function(){return Y[this.ptr+4>>2]};this.set_destructor=function(b){Y[this.ptr+8>>2]=b};this.get_destructor=function(){return Y[this.ptr+8>>2]};this.set_refcount=function(b){ca[this.ptr>>2]=b};this.set_caught=function(b){W[this.ptr+
12>>0]=b?1:0};this.get_caught=function(){return 0!=W[this.ptr+12>>0]};this.set_rethrown=function(b){W[this.ptr+13>>0]=b?1:0};this.get_rethrown=function(){return 0!=W[this.ptr+13>>0]};this.init=function(b,c){this.set_adjusted_ptr(0);this.set_type(b);this.set_destructor(c);this.set_refcount(0);this.set_caught(!1);this.set_rethrown(!1)};this.add_ref=function(){ca[this.ptr>>2]+=1};this.release_ref=function(){var b=ca[this.ptr>>2];ca[this.ptr>>2]=b-1;return 1===b};this.set_adjusted_ptr=function(b){Y[this.ptr+
16>>2]=b};this.get_adjusted_ptr=function(){return Y[this.ptr+16>>2]};this.get_exception_ptr=function(){if(ua(this.get_type()))return Y[this.excPtr>>2];var b=this.get_adjusted_ptr();return 0!==b?b:this.excPtr}}function F(){function e(){if(!la&&(la=!0,a.calledRun=!0,!sa)){va=!0;u(oa);wa(a);if(a.onRuntimeInitialized)a.onRuntimeInitialized();if(a.postRun)for("function"==typeof a.postRun&&(a.postRun=[a.postRun]);a.postRun.length;)xa.unshift(a.postRun.shift());u(xa)}}if(!(0<ba)){if(a.preRun)for("function"==
typeof a.preRun&&(a.preRun=[a.preRun]);a.preRun.length;)ya.unshift(a.preRun.shift());u(ya);0<ba||(a.setStatus?(a.setStatus("Running..."),setTimeout(function(){setTimeout(function(){a.setStatus("")},1);e()},1)):e())}}function v(){}function w(e){return(e||v).__cache__}function B(e,b){var c=w(b),d=c[e];if(d)return d;d=Object.create((b||v).prototype);d.ptr=e;return c[e]=d}function R(e){if("string"===typeof e){for(var b=0,c=0;c<e.length;++c){var d=e.charCodeAt(c);127>=d?b++:2047>=d?b+=2:55296<=d&&57343>=
d?(b+=4,++c):b+=3}b=Array(b+1);c=0;d=b.length;if(0<d){d=c+d-1;for(var g=0;g<e.length;++g){var t=e.charCodeAt(g);if(55296<=t&&57343>=t){var aa=e.charCodeAt(++g);t=65536+((t&1023)<<10)|aa&1023}if(127>=t){if(c>=d)break;b[c++]=t}else{if(2047>=t){if(c+1>=d)break;b[c++]=192|t>>6}else{if(65535>=t){if(c+2>=d)break;b[c++]=224|t>>12}else{if(c+3>=d)break;b[c++]=240|t>>18;b[c++]=128|t>>12&63}b[c++]=128|t>>6&63}b[c++]=128|t&63}}b[c]=0}e=r.alloc(b,W);r.copy(b,W,e);return e}return e}function Z(e){if("object"===
typeof e){var b=r.alloc(e,W);r.copy(e,W,b);return b}return e}function X(){throw"cannot construct a VoidPtr, no constructor in IDL";}function S(){this.ptr=za();w(S)[this.ptr]=this}function Q(){this.ptr=Aa();w(Q)[this.ptr]=this}function V(){this.ptr=Ba();w(V)[this.ptr]=this}function x(){this.ptr=Ca();w(x)[this.ptr]=this}function D(){this.ptr=Da();w(D)[this.ptr]=this}function G(){this.ptr=Ea();w(G)[this.ptr]=this}function H(){this.ptr=Fa();w(H)[this.ptr]=this}function E(){this.ptr=Ga();w(E)[this.ptr]=
this}function T(){this.ptr=Ha();w(T)[this.ptr]=this}function C(){throw"cannot construct a Status, no constructor in IDL";}function I(){this.ptr=Ia();w(I)[this.ptr]=this}function J(){this.ptr=Ja();w(J)[this.ptr]=this}function K(){this.ptr=Ka();w(K)[this.ptr]=this}function L(){this.ptr=La();w(L)[this.ptr]=this}function M(){this.ptr=Ma();w(M)[this.ptr]=this}function N(){this.ptr=Na();w(N)[this.ptr]=this}function O(){this.ptr=Oa();w(O)[this.ptr]=this}function z(){this.ptr=Pa();w(z)[this.ptr]=this}function m(){this.ptr=
Qa();w(m)[this.ptr]=this}n=void 0===n?{}:n;var a="undefined"!=typeof n?n:{},wa,ka;a.ready=new Promise(function(e,b){wa=e;ka=b});var Ra=!1,Sa=!1;a.onRuntimeInitialized=function(){Ra=!0;if(Sa&&"function"===typeof a.onModuleLoaded)a.onModuleLoaded(a)};a.onModuleParsed=function(){Sa=!0;if(Ra&&"function"===typeof a.onModuleLoaded)a.onModuleLoaded(a)};a.isVersionSupported=function(e){if("string"!==typeof e)return!1;e=e.split(".");return 2>e.length||3<e.length?!1:1==e[0]&&0<=e[1]&&5>=e[1]?!0:0!=e[0]||10<
e[1]?!1:!0};var Ta=Object.assign({},a),ta="object"==typeof window,fa="function"==typeof importScripts,Ua="object"==typeof process&&"object"==typeof process.versions&&"string"==typeof process.versions.node,U="";if(Ua){var Va=require("fs"),pa=require("path");U=fa?pa.dirname(U)+"/":__dirname+"/";var Wa=function(e,b){e=e.startsWith("file://")?new URL(e):pa.normalize(e);return Va.readFileSync(e,b?void 0:"utf8")};var ma=function(e){e=Wa(e,!0);e.buffer||(e=new Uint8Array(e));return e};var na=function(e,
b,c){e=e.startsWith("file://")?new URL(e):pa.normalize(e);Va.readFile(e,function(d,g){d?c(d):b(g.buffer)})};1<process.argv.length&&process.argv[1].replace(/\\/g,"/");process.argv.slice(2);a.inspect=function(){return"[Emscripten Module object]"}}else if(ta||fa)fa?U=self.location.href:"undefined"!=typeof document&&document.currentScript&&(U=document.currentScript.src),h&&(U=h),U=0!==U.indexOf("blob:")?U.substr(0,U.replace(/[?#].*/,"").lastIndexOf("/")+1):"",Wa=function(e){var b=new XMLHttpRequest;b.open("GET",
e,!1);b.send(null);return b.responseText},fa&&(ma=function(e){var b=new XMLHttpRequest;b.open("GET",e,!1);b.responseType="arraybuffer";b.send(null);return new Uint8Array(b.response)}),na=function(e,b,c){var d=new XMLHttpRequest;d.open("GET",e,!0);d.responseType="arraybuffer";d.onload=function(){200==d.status||0==d.status&&d.response?b(d.response):c()};d.onerror=c;d.send(null)};a.print||console.log.bind(console);var da=a.printErr||console.warn.bind(console);Object.assign(a,Ta);Ta=null;var ea;a.wasmBinary&&
(ea=a.wasmBinary);"object"!=typeof WebAssembly&&y("no native wasm support detected");var ja,sa=!1,ra="undefined"!=typeof TextDecoder?new TextDecoder("utf8"):void 0,W,ia,ca,Y,ya=[],oa=[],xa=[],va=!1,ba=0,qa=null,ha=null;var P="draco_decoder_gltf.wasm";P.startsWith("data:application/octet-stream;base64,")||(P=k(P));var pd=0,qd={b:function(e,b,c){(new A(e)).init(b,c);pd++;throw e;},a:function(){y("")},d:function(e,b,c){ia.copyWithin(e,b,b+c)},c:function(e){var b=ia.length;e>>>=0;if(2147483648<e)return!1;
for(var c=1;4>=c;c*=2){var d=b*(1+.2/c);d=Math.min(d,e+100663296);var g=Math;d=Math.max(e,d);g=g.min.call(g,2147483648,d+(65536-d%65536)%65536);a:{d=ja.buffer;try{ja.grow(g-d.byteLength+65535>>>16);l();var t=1;break a}catch(aa){}t=void 0}if(t)return!0}return!1}};(function(){function e(g,t){a.asm=g.exports;ja=a.asm.e;l();oa.unshift(a.asm.f);ba--;a.monitorRunDependencies&&a.monitorRunDependencies(ba);0==ba&&(null!==qa&&(clearInterval(qa),qa=null),ha&&(g=ha,ha=null,g()))}function b(g){e(g.instance)}
function c(g){return q().then(function(t){return WebAssembly.instantiate(t,d)}).then(function(t){return t}).then(g,function(t){da("failed to asynchronously prepare wasm: "+t);y(t)})}var d={a:qd};ba++;a.monitorRunDependencies&&a.monitorRunDependencies(ba);if(a.instantiateWasm)try{return a.instantiateWasm(d,e)}catch(g){da("Module.instantiateWasm callback failed with error: "+g),ka(g)}(function(){return ea||"function"!=typeof WebAssembly.instantiateStreaming||P.startsWith("data:application/octet-stream;base64,")||
P.startsWith("file://")||Ua||"function"!=typeof fetch?c(b):fetch(P,{credentials:"same-origin"}).then(function(g){return WebAssembly.instantiateStreaming(g,d).then(b,function(t){da("wasm streaming compile failed: "+t);da("falling back to ArrayBuffer instantiation");return c(b)})})})().catch(ka);return{}})();var Xa=a._emscripten_bind_VoidPtr___destroy___0=function(){return(Xa=a._emscripten_bind_VoidPtr___destroy___0=a.asm.h).apply(null,arguments)},za=a._emscripten_bind_DecoderBuffer_DecoderBuffer_0=
function(){return(za=a._emscripten_bind_DecoderBuffer_DecoderBuffer_0=a.asm.i).apply(null,arguments)},Ya=a._emscripten_bind_DecoderBuffer_Init_2=function(){return(Ya=a._emscripten_bind_DecoderBuffer_Init_2=a.asm.j).apply(null,arguments)},Za=a._emscripten_bind_DecoderBuffer___destroy___0=function(){return(Za=a._emscripten_bind_DecoderBuffer___destroy___0=a.asm.k).apply(null,arguments)},Aa=a._emscripten_bind_AttributeTransformData_AttributeTransformData_0=function(){return(Aa=a._emscripten_bind_AttributeTransformData_AttributeTransformData_0=
a.asm.l).apply(null,arguments)},$a=a._emscripten_bind_AttributeTransformData_transform_type_0=function(){return($a=a._emscripten_bind_AttributeTransformData_transform_type_0=a.asm.m).apply(null,arguments)},ab=a._emscripten_bind_AttributeTransformData___destroy___0=function(){return(ab=a._emscripten_bind_AttributeTransformData___destroy___0=a.asm.n).apply(null,arguments)},Ba=a._emscripten_bind_GeometryAttribute_GeometryAttribute_0=function(){return(Ba=a._emscripten_bind_GeometryAttribute_GeometryAttribute_0=
a.asm.o).apply(null,arguments)},bb=a._emscripten_bind_GeometryAttribute___destroy___0=function(){return(bb=a._emscripten_bind_GeometryAttribute___destroy___0=a.asm.p).apply(null,arguments)},Ca=a._emscripten_bind_PointAttribute_PointAttribute_0=function(){return(Ca=a._emscripten_bind_PointAttribute_PointAttribute_0=a.asm.q).apply(null,arguments)},cb=a._emscripten_bind_PointAttribute_size_0=function(){return(cb=a._emscripten_bind_PointAttribute_size_0=a.asm.r).apply(null,arguments)},db=a._emscripten_bind_PointAttribute_GetAttributeTransformData_0=
function(){return(db=a._emscripten_bind_PointAttribute_GetAttributeTransformData_0=a.asm.s).apply(null,arguments)},eb=a._emscripten_bind_PointAttribute_attribute_type_0=function(){return(eb=a._emscripten_bind_PointAttribute_attribute_type_0=a.asm.t).apply(null,arguments)},fb=a._emscripten_bind_PointAttribute_data_type_0=function(){return(fb=a._emscripten_bind_PointAttribute_data_type_0=a.asm.u).apply(null,arguments)},gb=a._emscripten_bind_PointAttribute_num_components_0=function(){return(gb=a._emscripten_bind_PointAttribute_num_components_0=
a.asm.v).apply(null,arguments)},hb=a._emscripten_bind_PointAttribute_normalized_0=function(){return(hb=a._emscripten_bind_PointAttribute_normalized_0=a.asm.w).apply(null,arguments)},ib=a._emscripten_bind_PointAttribute_byte_stride_0=function(){return(ib=a._emscripten_bind_PointAttribute_byte_stride_0=a.asm.x).apply(null,arguments)},jb=a._emscripten_bind_PointAttribute_byte_offset_0=function(){return(jb=a._emscripten_bind_PointAttribute_byte_offset_0=a.asm.y).apply(null,arguments)},kb=a._emscripten_bind_PointAttribute_unique_id_0=
function(){return(kb=a._emscripten_bind_PointAttribute_unique_id_0=a.asm.z).apply(null,arguments)},lb=a._emscripten_bind_PointAttribute___destroy___0=function(){return(lb=a._emscripten_bind_PointAttribute___destroy___0=a.asm.A).apply(null,arguments)},Da=a._emscripten_bind_AttributeQuantizationTransform_AttributeQuantizationTransform_0=function(){return(Da=a._emscripten_bind_AttributeQuantizationTransform_AttributeQuantizationTransform_0=a.asm.B).apply(null,arguments)},mb=a._emscripten_bind_AttributeQuantizationTransform_InitFromAttribute_1=
function(){return(mb=a._emscripten_bind_AttributeQuantizationTransform_InitFromAttribute_1=a.asm.C).apply(null,arguments)},nb=a._emscripten_bind_AttributeQuantizationTransform_quantization_bits_0=function(){return(nb=a._emscripten_bind_AttributeQuantizationTransform_quantization_bits_0=a.asm.D).apply(null,arguments)},ob=a._emscripten_bind_AttributeQuantizationTransform_min_value_1=function(){return(ob=a._emscripten_bind_AttributeQuantizationTransform_min_value_1=a.asm.E).apply(null,arguments)},pb=
a._emscripten_bind_AttributeQuantizationTransform_range_0=function(){return(pb=a._emscripten_bind_AttributeQuantizationTransform_range_0=a.asm.F).apply(null,arguments)},qb=a._emscripten_bind_AttributeQuantizationTransform___destroy___0=function(){return(qb=a._emscripten_bind_AttributeQuantizationTransform___destroy___0=a.asm.G).apply(null,arguments)},Ea=a._emscripten_bind_AttributeOctahedronTransform_AttributeOctahedronTransform_0=function(){return(Ea=a._emscripten_bind_AttributeOctahedronTransform_AttributeOctahedronTransform_0=
a.asm.H).apply(null,arguments)},rb=a._emscripten_bind_AttributeOctahedronTransform_InitFromAttribute_1=function(){return(rb=a._emscripten_bind_AttributeOctahedronTransform_InitFromAttribute_1=a.asm.I).apply(null,arguments)},sb=a._emscripten_bind_AttributeOctahedronTransform_quantization_bits_0=function(){return(sb=a._emscripten_bind_AttributeOctahedronTransform_quantization_bits_0=a.asm.J).apply(null,arguments)},tb=a._emscripten_bind_AttributeOctahedronTransform___destroy___0=function(){return(tb=
a._emscripten_bind_AttributeOctahedronTransform___destroy___0=a.asm.K).apply(null,arguments)},Fa=a._emscripten_bind_PointCloud_PointCloud_0=function(){return(Fa=a._emscripten_bind_PointCloud_PointCloud_0=a.asm.L).apply(null,arguments)},ub=a._emscripten_bind_PointCloud_num_attributes_0=function(){return(ub=a._emscripten_bind_PointCloud_num_attributes_0=a.asm.M).apply(null,arguments)},vb=a._emscripten_bind_PointCloud_num_points_0=function(){return(vb=a._emscripten_bind_PointCloud_num_points_0=a.asm.N).apply(null,
arguments)},wb=a._emscripten_bind_PointCloud___destroy___0=function(){return(wb=a._emscripten_bind_PointCloud___destroy___0=a.asm.O).apply(null,arguments)},Ga=a._emscripten_bind_Mesh_Mesh_0=function(){return(Ga=a._emscripten_bind_Mesh_Mesh_0=a.asm.P).apply(null,arguments)},xb=a._emscripten_bind_Mesh_num_faces_0=function(){return(xb=a._emscripten_bind_Mesh_num_faces_0=a.asm.Q).apply(null,arguments)},yb=a._emscripten_bind_Mesh_num_attributes_0=function(){return(yb=a._emscripten_bind_Mesh_num_attributes_0=
a.asm.R).apply(null,arguments)},zb=a._emscripten_bind_Mesh_num_points_0=function(){return(zb=a._emscripten_bind_Mesh_num_points_0=a.asm.S).apply(null,arguments)},Ab=a._emscripten_bind_Mesh___destroy___0=function(){return(Ab=a._emscripten_bind_Mesh___destroy___0=a.asm.T).apply(null,arguments)},Ha=a._emscripten_bind_Metadata_Metadata_0=function(){return(Ha=a._emscripten_bind_Metadata_Metadata_0=a.asm.U).apply(null,arguments)},Bb=a._emscripten_bind_Metadata___destroy___0=function(){return(Bb=a._emscripten_bind_Metadata___destroy___0=
a.asm.V).apply(null,arguments)},Cb=a._emscripten_bind_Status_code_0=function(){return(Cb=a._emscripten_bind_Status_code_0=a.asm.W).apply(null,arguments)},Db=a._emscripten_bind_Status_ok_0=function(){return(Db=a._emscripten_bind_Status_ok_0=a.asm.X).apply(null,arguments)},Eb=a._emscripten_bind_Status_error_msg_0=function(){return(Eb=a._emscripten_bind_Status_error_msg_0=a.asm.Y).apply(null,arguments)},Fb=a._emscripten_bind_Status___destroy___0=function(){return(Fb=a._emscripten_bind_Status___destroy___0=
a.asm.Z).apply(null,arguments)},Ia=a._emscripten_bind_DracoFloat32Array_DracoFloat32Array_0=function(){return(Ia=a._emscripten_bind_DracoFloat32Array_DracoFloat32Array_0=a.asm._).apply(null,arguments)},Gb=a._emscripten_bind_DracoFloat32Array_GetValue_1=function(){return(Gb=a._emscripten_bind_DracoFloat32Array_GetValue_1=a.asm.$).apply(null,arguments)},Hb=a._emscripten_bind_DracoFloat32Array_size_0=function(){return(Hb=a._emscripten_bind_DracoFloat32Array_size_0=a.asm.aa).apply(null,arguments)},Ib=
a._emscripten_bind_DracoFloat32Array___destroy___0=function(){return(Ib=a._emscripten_bind_DracoFloat32Array___destroy___0=a.asm.ba).apply(null,arguments)},Ja=a._emscripten_bind_DracoInt8Array_DracoInt8Array_0=function(){return(Ja=a._emscripten_bind_DracoInt8Array_DracoInt8Array_0=a.asm.ca).apply(null,arguments)},Jb=a._emscripten_bind_DracoInt8Array_GetValue_1=function(){return(Jb=a._emscripten_bind_DracoInt8Array_GetValue_1=a.asm.da).apply(null,arguments)},Kb=a._emscripten_bind_DracoInt8Array_size_0=
function(){return(Kb=a._emscripten_bind_DracoInt8Array_size_0=a.asm.ea).apply(null,arguments)},Lb=a._emscripten_bind_DracoInt8Array___destroy___0=function(){return(Lb=a._emscripten_bind_DracoInt8Array___destroy___0=a.asm.fa).apply(null,arguments)},Ka=a._emscripten_bind_DracoUInt8Array_DracoUInt8Array_0=function(){return(Ka=a._emscripten_bind_DracoUInt8Array_DracoUInt8Array_0=a.asm.ga).apply(null,arguments)},Mb=a._emscripten_bind_DracoUInt8Array_GetValue_1=function(){return(Mb=a._emscripten_bind_DracoUInt8Array_GetValue_1=
a.asm.ha).apply(null,arguments)},Nb=a._emscripten_bind_DracoUInt8Array_size_0=function(){return(Nb=a._emscripten_bind_DracoUInt8Array_size_0=a.asm.ia).apply(null,arguments)},Ob=a._emscripten_bind_DracoUInt8Array___destroy___0=function(){return(Ob=a._emscripten_bind_DracoUInt8Array___destroy___0=a.asm.ja).apply(null,arguments)},La=a._emscripten_bind_DracoInt16Array_DracoInt16Array_0=function(){return(La=a._emscripten_bind_DracoInt16Array_DracoInt16Array_0=a.asm.ka).apply(null,arguments)},Pb=a._emscripten_bind_DracoInt16Array_GetValue_1=
function(){return(Pb=a._emscripten_bind_DracoInt16Array_GetValue_1=a.asm.la).apply(null,arguments)},Qb=a._emscripten_bind_DracoInt16Array_size_0=function(){return(Qb=a._emscripten_bind_DracoInt16Array_size_0=a.asm.ma).apply(null,arguments)},Rb=a._emscripten_bind_DracoInt16Array___destroy___0=function(){return(Rb=a._emscripten_bind_DracoInt16Array___destroy___0=a.asm.na).apply(null,arguments)},Ma=a._emscripten_bind_DracoUInt16Array_DracoUInt16Array_0=function(){return(Ma=a._emscripten_bind_DracoUInt16Array_DracoUInt16Array_0=
a.asm.oa).apply(null,arguments)},Sb=a._emscripten_bind_DracoUInt16Array_GetValue_1=function(){return(Sb=a._emscripten_bind_DracoUInt16Array_GetValue_1=a.asm.pa).apply(null,arguments)},Tb=a._emscripten_bind_DracoUInt16Array_size_0=function(){return(Tb=a._emscripten_bind_DracoUInt16Array_size_0=a.asm.qa).apply(null,arguments)},Ub=a._emscripten_bind_DracoUInt16Array___destroy___0=function(){return(Ub=a._emscripten_bind_DracoUInt16Array___destroy___0=a.asm.ra).apply(null,arguments)},Na=a._emscripten_bind_DracoInt32Array_DracoInt32Array_0=
function(){return(Na=a._emscripten_bind_DracoInt32Array_DracoInt32Array_0=a.asm.sa).apply(null,arguments)},Vb=a._emscripten_bind_DracoInt32Array_GetValue_1=function(){return(Vb=a._emscripten_bind_DracoInt32Array_GetValue_1=a.asm.ta).apply(null,arguments)},Wb=a._emscripten_bind_DracoInt32Array_size_0=function(){return(Wb=a._emscripten_bind_DracoInt32Array_size_0=a.asm.ua).apply(null,arguments)},Xb=a._emscripten_bind_DracoInt32Array___destroy___0=function(){return(Xb=a._emscripten_bind_DracoInt32Array___destroy___0=
a.asm.va).apply(null,arguments)},Oa=a._emscripten_bind_DracoUInt32Array_DracoUInt32Array_0=function(){return(Oa=a._emscripten_bind_DracoUInt32Array_DracoUInt32Array_0=a.asm.wa).apply(null,arguments)},Yb=a._emscripten_bind_DracoUInt32Array_GetValue_1=function(){return(Yb=a._emscripten_bind_DracoUInt32Array_GetValue_1=a.asm.xa).apply(null,arguments)},Zb=a._emscripten_bind_DracoUInt32Array_size_0=function(){return(Zb=a._emscripten_bind_DracoUInt32Array_size_0=a.asm.ya).apply(null,arguments)},$b=a._emscripten_bind_DracoUInt32Array___destroy___0=
function(){return($b=a._emscripten_bind_DracoUInt32Array___destroy___0=a.asm.za).apply(null,arguments)},Pa=a._emscripten_bind_MetadataQuerier_MetadataQuerier_0=function(){return(Pa=a._emscripten_bind_MetadataQuerier_MetadataQuerier_0=a.asm.Aa).apply(null,arguments)},ac=a._emscripten_bind_MetadataQuerier_HasEntry_2=function(){return(ac=a._emscripten_bind_MetadataQuerier_HasEntry_2=a.asm.Ba).apply(null,arguments)},bc=a._emscripten_bind_MetadataQuerier_GetIntEntry_2=function(){return(bc=a._emscripten_bind_MetadataQuerier_GetIntEntry_2=
a.asm.Ca).apply(null,arguments)},cc=a._emscripten_bind_MetadataQuerier_GetIntEntryArray_3=function(){return(cc=a._emscripten_bind_MetadataQuerier_GetIntEntryArray_3=a.asm.Da).apply(null,arguments)},dc=a._emscripten_bind_MetadataQuerier_GetDoubleEntry_2=function(){return(dc=a._emscripten_bind_MetadataQuerier_GetDoubleEntry_2=a.asm.Ea).apply(null,arguments)},ec=a._emscripten_bind_MetadataQuerier_GetStringEntry_2=function(){return(ec=a._emscripten_bind_MetadataQuerier_GetStringEntry_2=a.asm.Fa).apply(null,
arguments)},fc=a._emscripten_bind_MetadataQuerier_NumEntries_1=function(){return(fc=a._emscripten_bind_MetadataQuerier_NumEntries_1=a.asm.Ga).apply(null,arguments)},gc=a._emscripten_bind_MetadataQuerier_GetEntryName_2=function(){return(gc=a._emscripten_bind_MetadataQuerier_GetEntryName_2=a.asm.Ha).apply(null,arguments)},hc=a._emscripten_bind_MetadataQuerier___destroy___0=function(){return(hc=a._emscripten_bind_MetadataQuerier___destroy___0=a.asm.Ia).apply(null,arguments)},Qa=a._emscripten_bind_Decoder_Decoder_0=
function(){return(Qa=a._emscripten_bind_Decoder_Decoder_0=a.asm.Ja).apply(null,arguments)},ic=a._emscripten_bind_Decoder_DecodeArrayToPointCloud_3=function(){return(ic=a._emscripten_bind_Decoder_DecodeArrayToPointCloud_3=a.asm.Ka).apply(null,arguments)},jc=a._emscripten_bind_Decoder_DecodeArrayToMesh_3=function(){return(jc=a._emscripten_bind_Decoder_DecodeArrayToMesh_3=a.asm.La).apply(null,arguments)},kc=a._emscripten_bind_Decoder_GetAttributeId_2=function(){return(kc=a._emscripten_bind_Decoder_GetAttributeId_2=
a.asm.Ma).apply(null,arguments)},lc=a._emscripten_bind_Decoder_GetAttributeIdByName_2=function(){return(lc=a._emscripten_bind_Decoder_GetAttributeIdByName_2=a.asm.Na).apply(null,arguments)},mc=a._emscripten_bind_Decoder_GetAttributeIdByMetadataEntry_3=function(){return(mc=a._emscripten_bind_Decoder_GetAttributeIdByMetadataEntry_3=a.asm.Oa).apply(null,arguments)},nc=a._emscripten_bind_Decoder_GetAttribute_2=function(){return(nc=a._emscripten_bind_Decoder_GetAttribute_2=a.asm.Pa).apply(null,arguments)},
oc=a._emscripten_bind_Decoder_GetAttributeByUniqueId_2=function(){return(oc=a._emscripten_bind_Decoder_GetAttributeByUniqueId_2=a.asm.Qa).apply(null,arguments)},pc=a._emscripten_bind_Decoder_GetMetadata_1=function(){return(pc=a._emscripten_bind_Decoder_GetMetadata_1=a.asm.Ra).apply(null,arguments)},qc=a._emscripten_bind_Decoder_GetAttributeMetadata_2=function(){return(qc=a._emscripten_bind_Decoder_GetAttributeMetadata_2=a.asm.Sa).apply(null,arguments)},rc=a._emscripten_bind_Decoder_GetFaceFromMesh_3=
function(){return(rc=a._emscripten_bind_Decoder_GetFaceFromMesh_3=a.asm.Ta).apply(null,arguments)},sc=a._emscripten_bind_Decoder_GetTriangleStripsFromMesh_2=function(){return(sc=a._emscripten_bind_Decoder_GetTriangleStripsFromMesh_2=a.asm.Ua).apply(null,arguments)},tc=a._emscripten_bind_Decoder_GetTrianglesUInt16Array_3=function(){return(tc=a._emscripten_bind_Decoder_GetTrianglesUInt16Array_3=a.asm.Va).apply(null,arguments)},uc=a._emscripten_bind_Decoder_GetTrianglesUInt32Array_3=function(){return(uc=
a._emscripten_bind_Decoder_GetTrianglesUInt32Array_3=a.asm.Wa).apply(null,arguments)},vc=a._emscripten_bind_Decoder_GetAttributeFloat_3=function(){return(vc=a._emscripten_bind_Decoder_GetAttributeFloat_3=a.asm.Xa).apply(null,arguments)},wc=a._emscripten_bind_Decoder_GetAttributeFloatForAllPoints_3=function(){return(wc=a._emscripten_bind_Decoder_GetAttributeFloatForAllPoints_3=a.asm.Ya).apply(null,arguments)},xc=a._emscripten_bind_Decoder_GetAttributeIntForAllPoints_3=function(){return(xc=a._emscripten_bind_Decoder_GetAttributeIntForAllPoints_3=
a.asm.Za).apply(null,arguments)},yc=a._emscripten_bind_Decoder_GetAttributeInt8ForAllPoints_3=function(){return(yc=a._emscripten_bind_Decoder_GetAttributeInt8ForAllPoints_3=a.asm._a).apply(null,arguments)},zc=a._emscripten_bind_Decoder_GetAttributeUInt8ForAllPoints_3=function(){return(zc=a._emscripten_bind_Decoder_GetAttributeUInt8ForAllPoints_3=a.asm.$a).apply(null,arguments)},Ac=a._emscripten_bind_Decoder_GetAttributeInt16ForAllPoints_3=function(){return(Ac=a._emscripten_bind_Decoder_GetAttributeInt16ForAllPoints_3=
a.asm.ab).apply(null,arguments)},Bc=a._emscripten_bind_Decoder_GetAttributeUInt16ForAllPoints_3=function(){return(Bc=a._emscripten_bind_Decoder_GetAttributeUInt16ForAllPoints_3=a.asm.bb).apply(null,arguments)},Cc=a._emscripten_bind_Decoder_GetAttributeInt32ForAllPoints_3=function(){return(Cc=a._emscripten_bind_Decoder_GetAttributeInt32ForAllPoints_3=a.asm.cb).apply(null,arguments)},Dc=a._emscripten_bind_Decoder_GetAttributeUInt32ForAllPoints_3=function(){return(Dc=a._emscripten_bind_Decoder_GetAttributeUInt32ForAllPoints_3=
a.asm.db).apply(null,arguments)},Ec=a._emscripten_bind_Decoder_GetAttributeDataArrayForAllPoints_5=function(){return(Ec=a._emscripten_bind_Decoder_GetAttributeDataArrayForAllPoints_5=a.asm.eb).apply(null,arguments)},Fc=a._emscripten_bind_Decoder_SkipAttributeTransform_1=function(){return(Fc=a._emscripten_bind_Decoder_SkipAttributeTransform_1=a.asm.fb).apply(null,arguments)},Gc=a._emscripten_bind_Decoder_GetEncodedGeometryType_Deprecated_1=function(){return(Gc=a._emscripten_bind_Decoder_GetEncodedGeometryType_Deprecated_1=
a.asm.gb).apply(null,arguments)},Hc=a._emscripten_bind_Decoder_DecodeBufferToPointCloud_2=function(){return(Hc=a._emscripten_bind_Decoder_DecodeBufferToPointCloud_2=a.asm.hb).apply(null,arguments)},Ic=a._emscripten_bind_Decoder_DecodeBufferToMesh_2=function(){return(Ic=a._emscripten_bind_Decoder_DecodeBufferToMesh_2=a.asm.ib).apply(null,arguments)},Jc=a._emscripten_bind_Decoder___destroy___0=function(){return(Jc=a._emscripten_bind_Decoder___destroy___0=a.asm.jb).apply(null,arguments)},Kc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_INVALID_TRANSFORM=
function(){return(Kc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_INVALID_TRANSFORM=a.asm.kb).apply(null,arguments)},Lc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_NO_TRANSFORM=function(){return(Lc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_NO_TRANSFORM=a.asm.lb).apply(null,arguments)},Mc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_QUANTIZATION_TRANSFORM=function(){return(Mc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_QUANTIZATION_TRANSFORM=
a.asm.mb).apply(null,arguments)},Nc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_OCTAHEDRON_TRANSFORM=function(){return(Nc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_OCTAHEDRON_TRANSFORM=a.asm.nb).apply(null,arguments)},Oc=a._emscripten_enum_draco_GeometryAttribute_Type_INVALID=function(){return(Oc=a._emscripten_enum_draco_GeometryAttribute_Type_INVALID=a.asm.ob).apply(null,arguments)},Pc=a._emscripten_enum_draco_GeometryAttribute_Type_POSITION=function(){return(Pc=a._emscripten_enum_draco_GeometryAttribute_Type_POSITION=
a.asm.pb).apply(null,arguments)},Qc=a._emscripten_enum_draco_GeometryAttribute_Type_NORMAL=function(){return(Qc=a._emscripten_enum_draco_GeometryAttribute_Type_NORMAL=a.asm.qb).apply(null,arguments)},Rc=a._emscripten_enum_draco_GeometryAttribute_Type_COLOR=function(){return(Rc=a._emscripten_enum_draco_GeometryAttribute_Type_COLOR=a.asm.rb).apply(null,arguments)},Sc=a._emscripten_enum_draco_GeometryAttribute_Type_TEX_COORD=function(){return(Sc=a._emscripten_enum_draco_GeometryAttribute_Type_TEX_COORD=
a.asm.sb).apply(null,arguments)},Tc=a._emscripten_enum_draco_GeometryAttribute_Type_GENERIC=function(){return(Tc=a._emscripten_enum_draco_GeometryAttribute_Type_GENERIC=a.asm.tb).apply(null,arguments)},Uc=a._emscripten_enum_draco_EncodedGeometryType_INVALID_GEOMETRY_TYPE=function(){return(Uc=a._emscripten_enum_draco_EncodedGeometryType_INVALID_GEOMETRY_TYPE=a.asm.ub).apply(null,arguments)},Vc=a._emscripten_enum_draco_EncodedGeometryType_POINT_CLOUD=function(){return(Vc=a._emscripten_enum_draco_EncodedGeometryType_POINT_CLOUD=
a.asm.vb).apply(null,arguments)},Wc=a._emscripten_enum_draco_EncodedGeometryType_TRIANGULAR_MESH=function(){return(Wc=a._emscripten_enum_draco_EncodedGeometryType_TRIANGULAR_MESH=a.asm.wb).apply(null,arguments)},Xc=a._emscripten_enum_draco_DataType_DT_INVALID=function(){return(Xc=a._emscripten_enum_draco_DataType_DT_INVALID=a.asm.xb).apply(null,arguments)},Yc=a._emscripten_enum_draco_DataType_DT_INT8=function(){return(Yc=a._emscripten_enum_draco_DataType_DT_INT8=a.asm.yb).apply(null,arguments)},Zc=
a._emscripten_enum_draco_DataType_DT_UINT8=function(){return(Zc=a._emscripten_enum_draco_DataType_DT_UINT8=a.asm.zb).apply(null,arguments)},$c=a._emscripten_enum_draco_DataType_DT_INT16=function(){return($c=a._emscripten_enum_draco_DataType_DT_INT16=a.asm.Ab).apply(null,arguments)},ad=a._emscripten_enum_draco_DataType_DT_UINT16=function(){return(ad=a._emscripten_enum_draco_DataType_DT_UINT16=a.asm.Bb).apply(null,arguments)},bd=a._emscripten_enum_draco_DataType_DT_INT32=function(){return(bd=a._emscripten_enum_draco_DataType_DT_INT32=
a.asm.Cb).apply(null,arguments)},cd=a._emscripten_enum_draco_DataType_DT_UINT32=function(){return(cd=a._emscripten_enum_draco_DataType_DT_UINT32=a.asm.Db).apply(null,arguments)},dd=a._emscripten_enum_draco_DataType_DT_INT64=function(){return(dd=a._emscripten_enum_draco_DataType_DT_INT64=a.asm.Eb).apply(null,arguments)},ed=a._emscripten_enum_draco_DataType_DT_UINT64=function(){return(ed=a._emscripten_enum_draco_DataType_DT_UINT64=a.asm.Fb).apply(null,arguments)},fd=a._emscripten_enum_draco_DataType_DT_FLOAT32=
function(){return(fd=a._emscripten_enum_draco_DataType_DT_FLOAT32=a.asm.Gb).apply(null,arguments)},gd=a._emscripten_enum_draco_DataType_DT_FLOAT64=function(){return(gd=a._emscripten_enum_draco_DataType_DT_FLOAT64=a.asm.Hb).apply(null,arguments)},hd=a._emscripten_enum_draco_DataType_DT_BOOL=function(){return(hd=a._emscripten_enum_draco_DataType_DT_BOOL=a.asm.Ib).apply(null,arguments)},id=a._emscripten_enum_draco_DataType_DT_TYPES_COUNT=function(){return(id=a._emscripten_enum_draco_DataType_DT_TYPES_COUNT=
a.asm.Jb).apply(null,arguments)},jd=a._emscripten_enum_draco_StatusCode_OK=function(){return(jd=a._emscripten_enum_draco_StatusCode_OK=a.asm.Kb).apply(null,arguments)},kd=a._emscripten_enum_draco_StatusCode_DRACO_ERROR=function(){return(kd=a._emscripten_enum_draco_StatusCode_DRACO_ERROR=a.asm.Lb).apply(null,arguments)},ld=a._emscripten_enum_draco_StatusCode_IO_ERROR=function(){return(ld=a._emscripten_enum_draco_StatusCode_IO_ERROR=a.asm.Mb).apply(null,arguments)},md=a._emscripten_enum_draco_StatusCode_INVALID_PARAMETER=
function(){return(md=a._emscripten_enum_draco_StatusCode_INVALID_PARAMETER=a.asm.Nb).apply(null,arguments)},nd=a._emscripten_enum_draco_StatusCode_UNSUPPORTED_VERSION=function(){return(nd=a._emscripten_enum_draco_StatusCode_UNSUPPORTED_VERSION=a.asm.Ob).apply(null,arguments)},od=a._emscripten_enum_draco_StatusCode_UNKNOWN_VERSION=function(){return(od=a._emscripten_enum_draco_StatusCode_UNKNOWN_VERSION=a.asm.Pb).apply(null,arguments)};a._malloc=function(){return(a._malloc=a.asm.Qb).apply(null,arguments)};
a._free=function(){return(a._free=a.asm.Rb).apply(null,arguments)};var ua=function(){return(ua=a.asm.Sb).apply(null,arguments)};a.___start_em_js=11660;a.___stop_em_js=11758;var la;ha=function b(){la||F();la||(ha=b)};if(a.preInit)for("function"==typeof a.preInit&&(a.preInit=[a.preInit]);0<a.preInit.length;)a.preInit.pop()();F();v.prototype=Object.create(v.prototype);v.prototype.constructor=v;v.prototype.__class__=v;v.__cache__={};a.WrapperObject=v;a.getCache=w;a.wrapPointer=B;a.castObject=function(b,
c){return B(b.ptr,c)};a.NULL=B(0);a.destroy=function(b){if(!b.__destroy__)throw"Error: Cannot destroy object. (Did you create it yourself?)";b.__destroy__();delete w(b.__class__)[b.ptr]};a.compare=function(b,c){return b.ptr===c.ptr};a.getPointer=function(b){return b.ptr};a.getClass=function(b){return b.__class__};var r={buffer:0,size:0,pos:0,temps:[],needed:0,prepare:function(){if(r.needed){for(var b=0;b<r.temps.length;b++)a._free(r.temps[b]);r.temps.length=0;a._free(r.buffer);r.buffer=0;r.size+=
r.needed;r.needed=0}r.buffer||(r.size+=128,r.buffer=a._malloc(r.size),r.buffer||y(void 0));r.pos=0},alloc:function(b,c){r.buffer||y(void 0);b=b.length*c.BYTES_PER_ELEMENT;b=b+7&-8;r.pos+b>=r.size?(0<b||y(void 0),r.needed+=b,c=a._malloc(b),r.temps.push(c)):(c=r.buffer+r.pos,r.pos+=b);return c},copy:function(b,c,d){d>>>=0;switch(c.BYTES_PER_ELEMENT){case 2:d>>>=1;break;case 4:d>>>=2;break;case 8:d>>>=3}for(var g=0;g<b.length;g++)c[d+g]=b[g]}};X.prototype=Object.create(v.prototype);X.prototype.constructor=
X;X.prototype.__class__=X;X.__cache__={};a.VoidPtr=X;X.prototype.__destroy__=X.prototype.__destroy__=function(){Xa(this.ptr)};S.prototype=Object.create(v.prototype);S.prototype.constructor=S;S.prototype.__class__=S;S.__cache__={};a.DecoderBuffer=S;S.prototype.Init=S.prototype.Init=function(b,c){var d=this.ptr;r.prepare();"object"==typeof b&&(b=Z(b));c&&"object"===typeof c&&(c=c.ptr);Ya(d,b,c)};S.prototype.__destroy__=S.prototype.__destroy__=function(){Za(this.ptr)};Q.prototype=Object.create(v.prototype);
Q.prototype.constructor=Q;Q.prototype.__class__=Q;Q.__cache__={};a.AttributeTransformData=Q;Q.prototype.transform_type=Q.prototype.transform_type=function(){return $a(this.ptr)};Q.prototype.__destroy__=Q.prototype.__destroy__=function(){ab(this.ptr)};V.prototype=Object.create(v.prototype);V.prototype.constructor=V;V.prototype.__class__=V;V.__cache__={};a.GeometryAttribute=V;V.prototype.__destroy__=V.prototype.__destroy__=function(){bb(this.ptr)};x.prototype=Object.create(v.prototype);x.prototype.constructor=
x;x.prototype.__class__=x;x.__cache__={};a.PointAttribute=x;x.prototype.size=x.prototype.size=function(){return cb(this.ptr)};x.prototype.GetAttributeTransformData=x.prototype.GetAttributeTransformData=function(){return B(db(this.ptr),Q)};x.prototype.attribute_type=x.prototype.attribute_type=function(){return eb(this.ptr)};x.prototype.data_type=x.prototype.data_type=function(){return fb(this.ptr)};x.prototype.num_components=x.prototype.num_components=function(){return gb(this.ptr)};x.prototype.normalized=
x.prototype.normalized=function(){return!!hb(this.ptr)};x.prototype.byte_stride=x.prototype.byte_stride=function(){return ib(this.ptr)};x.prototype.byte_offset=x.prototype.byte_offset=function(){return jb(this.ptr)};x.prototype.unique_id=x.prototype.unique_id=function(){return kb(this.ptr)};x.prototype.__destroy__=x.prototype.__destroy__=function(){lb(this.ptr)};D.prototype=Object.create(v.prototype);D.prototype.constructor=D;D.prototype.__class__=D;D.__cache__={};a.AttributeQuantizationTransform=
D;D.prototype.InitFromAttribute=D.prototype.InitFromAttribute=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return!!mb(c,b)};D.prototype.quantization_bits=D.prototype.quantization_bits=function(){return nb(this.ptr)};D.prototype.min_value=D.prototype.min_value=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return ob(c,b)};D.prototype.range=D.prototype.range=function(){return pb(this.ptr)};D.prototype.__destroy__=D.prototype.__destroy__=function(){qb(this.ptr)};G.prototype=
Object.create(v.prototype);G.prototype.constructor=G;G.prototype.__class__=G;G.__cache__={};a.AttributeOctahedronTransform=G;G.prototype.InitFromAttribute=G.prototype.InitFromAttribute=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return!!rb(c,b)};G.prototype.quantization_bits=G.prototype.quantization_bits=function(){return sb(this.ptr)};G.prototype.__destroy__=G.prototype.__destroy__=function(){tb(this.ptr)};H.prototype=Object.create(v.prototype);H.prototype.constructor=H;H.prototype.__class__=
H;H.__cache__={};a.PointCloud=H;H.prototype.num_attributes=H.prototype.num_attributes=function(){return ub(this.ptr)};H.prototype.num_points=H.prototype.num_points=function(){return vb(this.ptr)};H.prototype.__destroy__=H.prototype.__destroy__=function(){wb(this.ptr)};E.prototype=Object.create(v.prototype);E.prototype.constructor=E;E.prototype.__class__=E;E.__cache__={};a.Mesh=E;E.prototype.num_faces=E.prototype.num_faces=function(){return xb(this.ptr)};E.prototype.num_attributes=E.prototype.num_attributes=
function(){return yb(this.ptr)};E.prototype.num_points=E.prototype.num_points=function(){return zb(this.ptr)};E.prototype.__destroy__=E.prototype.__destroy__=function(){Ab(this.ptr)};T.prototype=Object.create(v.prototype);T.prototype.constructor=T;T.prototype.__class__=T;T.__cache__={};a.Metadata=T;T.prototype.__destroy__=T.prototype.__destroy__=function(){Bb(this.ptr)};C.prototype=Object.create(v.prototype);C.prototype.constructor=C;C.prototype.__class__=C;C.__cache__={};a.Status=C;C.prototype.code=
C.prototype.code=function(){return Cb(this.ptr)};C.prototype.ok=C.prototype.ok=function(){return!!Db(this.ptr)};C.prototype.error_msg=C.prototype.error_msg=function(){return p(Eb(this.ptr))};C.prototype.__destroy__=C.prototype.__destroy__=function(){Fb(this.ptr)};I.prototype=Object.create(v.prototype);I.prototype.constructor=I;I.prototype.__class__=I;I.__cache__={};a.DracoFloat32Array=I;I.prototype.GetValue=I.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Gb(c,
b)};I.prototype.size=I.prototype.size=function(){return Hb(this.ptr)};I.prototype.__destroy__=I.prototype.__destroy__=function(){Ib(this.ptr)};J.prototype=Object.create(v.prototype);J.prototype.constructor=J;J.prototype.__class__=J;J.__cache__={};a.DracoInt8Array=J;J.prototype.GetValue=J.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Jb(c,b)};J.prototype.size=J.prototype.size=function(){return Kb(this.ptr)};J.prototype.__destroy__=J.prototype.__destroy__=function(){Lb(this.ptr)};
K.prototype=Object.create(v.prototype);K.prototype.constructor=K;K.prototype.__class__=K;K.__cache__={};a.DracoUInt8Array=K;K.prototype.GetValue=K.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Mb(c,b)};K.prototype.size=K.prototype.size=function(){return Nb(this.ptr)};K.prototype.__destroy__=K.prototype.__destroy__=function(){Ob(this.ptr)};L.prototype=Object.create(v.prototype);L.prototype.constructor=L;L.prototype.__class__=L;L.__cache__={};a.DracoInt16Array=
L;L.prototype.GetValue=L.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Pb(c,b)};L.prototype.size=L.prototype.size=function(){return Qb(this.ptr)};L.prototype.__destroy__=L.prototype.__destroy__=function(){Rb(this.ptr)};M.prototype=Object.create(v.prototype);M.prototype.constructor=M;M.prototype.__class__=M;M.__cache__={};a.DracoUInt16Array=M;M.prototype.GetValue=M.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Sb(c,b)};
M.prototype.size=M.prototype.size=function(){return Tb(this.ptr)};M.prototype.__destroy__=M.prototype.__destroy__=function(){Ub(this.ptr)};N.prototype=Object.create(v.prototype);N.prototype.constructor=N;N.prototype.__class__=N;N.__cache__={};a.DracoInt32Array=N;N.prototype.GetValue=N.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Vb(c,b)};N.prototype.size=N.prototype.size=function(){return Wb(this.ptr)};N.prototype.__destroy__=N.prototype.__destroy__=function(){Xb(this.ptr)};
O.prototype=Object.create(v.prototype);O.prototype.constructor=O;O.prototype.__class__=O;O.__cache__={};a.DracoUInt32Array=O;O.prototype.GetValue=O.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Yb(c,b)};O.prototype.size=O.prototype.size=function(){return Zb(this.ptr)};O.prototype.__destroy__=O.prototype.__destroy__=function(){$b(this.ptr)};z.prototype=Object.create(v.prototype);z.prototype.constructor=z;z.prototype.__class__=z;z.__cache__={};a.MetadataQuerier=
z;z.prototype.HasEntry=z.prototype.HasEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return!!ac(d,b,c)};z.prototype.GetIntEntry=z.prototype.GetIntEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return bc(d,b,c)};z.prototype.GetIntEntryArray=z.prototype.GetIntEntryArray=function(b,c,d){var g=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===
typeof c?c.ptr:R(c);d&&"object"===typeof d&&(d=d.ptr);cc(g,b,c,d)};z.prototype.GetDoubleEntry=z.prototype.GetDoubleEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return dc(d,b,c)};z.prototype.GetStringEntry=z.prototype.GetStringEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return p(ec(d,b,c))};z.prototype.NumEntries=z.prototype.NumEntries=function(b){var c=this.ptr;
b&&"object"===typeof b&&(b=b.ptr);return fc(c,b)};z.prototype.GetEntryName=z.prototype.GetEntryName=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return p(gc(d,b,c))};z.prototype.__destroy__=z.prototype.__destroy__=function(){hc(this.ptr)};m.prototype=Object.create(v.prototype);m.prototype.constructor=m;m.prototype.__class__=m;m.__cache__={};a.Decoder=m;m.prototype.DecodeArrayToPointCloud=m.prototype.DecodeArrayToPointCloud=function(b,c,d){var g=
this.ptr;r.prepare();"object"==typeof b&&(b=Z(b));c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return B(ic(g,b,c,d),C)};m.prototype.DecodeArrayToMesh=m.prototype.DecodeArrayToMesh=function(b,c,d){var g=this.ptr;r.prepare();"object"==typeof b&&(b=Z(b));c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return B(jc(g,b,c,d),C)};m.prototype.GetAttributeId=m.prototype.GetAttributeId=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&
(c=c.ptr);return kc(d,b,c)};m.prototype.GetAttributeIdByName=m.prototype.GetAttributeIdByName=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return lc(d,b,c)};m.prototype.GetAttributeIdByMetadataEntry=m.prototype.GetAttributeIdByMetadataEntry=function(b,c,d){var g=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);d=d&&"object"===typeof d?d.ptr:R(d);return mc(g,b,c,d)};m.prototype.GetAttribute=
m.prototype.GetAttribute=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return B(nc(d,b,c),x)};m.prototype.GetAttributeByUniqueId=m.prototype.GetAttributeByUniqueId=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return B(oc(d,b,c),x)};m.prototype.GetMetadata=m.prototype.GetMetadata=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return B(pc(c,b),T)};m.prototype.GetAttributeMetadata=m.prototype.GetAttributeMetadata=
function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return B(qc(d,b,c),T)};m.prototype.GetFaceFromMesh=m.prototype.GetFaceFromMesh=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!rc(g,b,c,d)};m.prototype.GetTriangleStripsFromMesh=m.prototype.GetTriangleStripsFromMesh=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);
return sc(d,b,c)};m.prototype.GetTrianglesUInt16Array=m.prototype.GetTrianglesUInt16Array=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!tc(g,b,c,d)};m.prototype.GetTrianglesUInt32Array=m.prototype.GetTrianglesUInt32Array=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!uc(g,b,c,d)};m.prototype.GetAttributeFloat=m.prototype.GetAttributeFloat=
function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!vc(g,b,c,d)};m.prototype.GetAttributeFloatForAllPoints=m.prototype.GetAttributeFloatForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!wc(g,b,c,d)};m.prototype.GetAttributeIntForAllPoints=m.prototype.GetAttributeIntForAllPoints=function(b,c,d){var g=this.ptr;
b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!xc(g,b,c,d)};m.prototype.GetAttributeInt8ForAllPoints=m.prototype.GetAttributeInt8ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!yc(g,b,c,d)};m.prototype.GetAttributeUInt8ForAllPoints=m.prototype.GetAttributeUInt8ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=
b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!zc(g,b,c,d)};m.prototype.GetAttributeInt16ForAllPoints=m.prototype.GetAttributeInt16ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Ac(g,b,c,d)};m.prototype.GetAttributeUInt16ForAllPoints=m.prototype.GetAttributeUInt16ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&
(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Bc(g,b,c,d)};m.prototype.GetAttributeInt32ForAllPoints=m.prototype.GetAttributeInt32ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Cc(g,b,c,d)};m.prototype.GetAttributeUInt32ForAllPoints=m.prototype.GetAttributeUInt32ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===
typeof d&&(d=d.ptr);return!!Dc(g,b,c,d)};m.prototype.GetAttributeDataArrayForAllPoints=m.prototype.GetAttributeDataArrayForAllPoints=function(b,c,d,g,t){var aa=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);g&&"object"===typeof g&&(g=g.ptr);t&&"object"===typeof t&&(t=t.ptr);return!!Ec(aa,b,c,d,g,t)};m.prototype.SkipAttributeTransform=m.prototype.SkipAttributeTransform=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);Fc(c,
b)};m.prototype.GetEncodedGeometryType_Deprecated=m.prototype.GetEncodedGeometryType_Deprecated=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Gc(c,b)};m.prototype.DecodeBufferToPointCloud=m.prototype.DecodeBufferToPointCloud=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return B(Hc(d,b,c),C)};m.prototype.DecodeBufferToMesh=m.prototype.DecodeBufferToMesh=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===
typeof c&&(c=c.ptr);return B(Ic(d,b,c),C)};m.prototype.__destroy__=m.prototype.__destroy__=function(){Jc(this.ptr)};(function(){function b(){a.ATTRIBUTE_INVALID_TRANSFORM=Kc();a.ATTRIBUTE_NO_TRANSFORM=Lc();a.ATTRIBUTE_QUANTIZATION_TRANSFORM=Mc();a.ATTRIBUTE_OCTAHEDRON_TRANSFORM=Nc();a.INVALID=Oc();a.POSITION=Pc();a.NORMAL=Qc();a.COLOR=Rc();a.TEX_COORD=Sc();a.GENERIC=Tc();a.INVALID_GEOMETRY_TYPE=Uc();a.POINT_CLOUD=Vc();a.TRIANGULAR_MESH=Wc();a.DT_INVALID=Xc();a.DT_INT8=Yc();a.DT_UINT8=Zc();a.DT_INT16=
$c();a.DT_UINT16=ad();a.DT_INT32=bd();a.DT_UINT32=cd();a.DT_INT64=dd();a.DT_UINT64=ed();a.DT_FLOAT32=fd();a.DT_FLOAT64=gd();a.DT_BOOL=hd();a.DT_TYPES_COUNT=id();a.OK=jd();a.DRACO_ERROR=kd();a.IO_ERROR=ld();a.INVALID_PARAMETER=md();a.UNSUPPORTED_VERSION=nd();a.UNKNOWN_VERSION=od()}va?b():oa.unshift(b)})();if("function"===typeof a.onModuleParsed)a.onModuleParsed();a.Decoder.prototype.GetEncodedGeometryType=function(b){if(b.__class__&&b.__class__===a.DecoderBuffer)return a.Decoder.prototype.GetEncodedGeometryType_Deprecated(b);
if(8>b.byteLength)return a.INVALID_GEOMETRY_TYPE;switch(b[7]){case 0:return a.POINT_CLOUD;case 1:return a.TRIANGULAR_MESH;default:return a.INVALID_GEOMETRY_TYPE}};return n.ready}}();"object"===typeof exports&&"object"===typeof module?module.exports=DracoDecoderModule:"function"===typeof define&&define.amd?define([],function(){return DracoDecoderModule}):"object"===typeof exports&&(exports.DracoDecoderModule=DracoDecoderModule);

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Binary file not shown.

BIN
public/robot-doctor.glb Normal file

Binary file not shown.

BIN
public/security-guard.glb Normal file

Binary file not shown.

View File

@ -0,0 +1,172 @@
'use client';
import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
export default function AdminLoginPage() {
const router = useRouter();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await fetch('/api/admin/login/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (res.ok) {
router.push('/admin/');
router.refresh();
} else {
setError(data.error ?? 'Login failed');
}
} catch {
setError('Network error. Please try again.');
} finally {
setLoading(false);
}
};
return (
<div style={pageStyle}>
<div style={cardStyle}>
<div style={{ textAlign: 'center', marginBottom: '2rem' }}>
<div style={{
width: '48px',
height: '48px',
borderRadius: '12px',
background: 'rgba(59, 130, 246, 0.08)',
border: '1px solid rgba(59, 130, 246, 0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto 1rem',
fontSize: '1.25rem',
}}>
🔐
</div>
<h1 style={{ fontSize: '1.25rem', fontWeight: 700, color: '#1a1a2e', margin: 0, marginBottom: '0.25rem' }}>
Admin Login
</h1>
<p style={{ fontSize: '0.8rem', color: '#94a3b8', margin: 0 }}>
Lootah Robotics G1 Configurator
</p>
</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div>
<label style={labelStyle} htmlFor="username">Username</label>
<input
id="username"
type="text"
autoComplete="username"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
style={inputStyle}
onFocus={(e) => (e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)')}
onBlur={(e) => (e.currentTarget.style.borderColor = 'rgba(0, 0, 0, 0.1)')}
placeholder="admin"
/>
</div>
<div>
<label style={labelStyle} htmlFor="password">Password</label>
<input
id="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
style={inputStyle}
onFocus={(e) => (e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)')}
onBlur={(e) => (e.currentTarget.style.borderColor = 'rgba(0, 0, 0, 0.1)')}
placeholder="••••••••"
/>
</div>
{error && (
<div style={{
padding: '0.6rem 0.875rem',
borderRadius: '0.375rem',
background: 'rgba(239, 68, 68, 0.06)',
border: '1px solid rgba(239, 68, 68, 0.2)',
color: '#dc2626',
fontSize: '0.8rem',
}}>
{error}
</div>
)}
<button
type="submit"
disabled={loading}
style={{
padding: '0.7rem',
borderRadius: '0.5rem',
border: '1px solid rgba(59, 130, 246, 0.3)',
background: loading ? 'rgba(148, 163, 184, 0.1)' : 'rgba(59, 130, 246, 0.08)',
color: loading ? '#94a3b8' : '#2563eb',
fontSize: '0.875rem',
fontWeight: 600,
cursor: loading ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
marginTop: '0.25rem',
}}
>
{loading ? 'Signing in…' : 'Sign In'}
</button>
</form>
</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 cardStyle: React.CSSProperties = {
width: '100%',
maxWidth: '380px',
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(20px)',
border: '1px solid rgba(0, 0, 0, 0.06)',
borderRadius: '1rem',
padding: '2rem',
};
const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: '0.75rem',
fontWeight: 600,
color: '#374151',
marginBottom: '0.375rem',
letterSpacing: '0.02em',
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '0.6rem 0.875rem',
borderRadius: '0.5rem',
border: '1px solid rgba(0, 0, 0, 0.1)',
background: '#ffffff',
color: '#1a1a2e',
fontSize: '0.875rem',
outline: 'none',
transition: 'border-color 0.2s ease',
boxSizing: 'border-box',
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { jwtVerify } from 'jose';
import bcrypt from 'bcryptjs';
import { prisma } from '@/lib/prisma';
export async function POST(request: Request) {
try {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const jwtSecret = process.env.ADMIN_JWT_SECRET;
if (!jwtSecret) return NextResponse.json({ error: 'Server error' }, { status: 500 });
const secret = new TextEncoder().encode(jwtSecret);
const { payload } = await jwtVerify(token, secret);
const { currentPassword, newPassword } = await request.json();
if (!currentPassword || !newPassword || newPassword.length < 8) {
return NextResponse.json({ error: 'Invalid input. New password must be at least 8 characters.' }, { status: 400 });
}
const user = await prisma.adminUser.findUnique({ where: { id: payload.sub as string } });
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 });
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!valid) return NextResponse.json({ error: 'Current password is incorrect' }, { status: 401 });
const newHash = await bcrypt.hash(newPassword, 12);
await prisma.adminUser.update({ where: { id: user.id }, data: { passwordHash: newHash } });
return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { cookies } from 'next/headers';
import * as jose from 'jose';
export async function GET(request: Request) {
try {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const jwtSecret = process.env.ADMIN_JWT_SECRET;
if (!jwtSecret) {
console.error('[/api/admin/contacts] ADMIN_JWT_SECRET is not defined');
return NextResponse.json({ error: 'Server error', detail: 'Missing JWT_SECRET env var' }, { status: 500 });
}
await jose.jwtVerify(token, new TextEncoder().encode(jwtSecret));
const contacts = await prisma.contactRequest.findMany({
orderBy: { createdAt: 'desc' },
});
return NextResponse.json({ contacts });
} catch (error) {
console.error('[/api/admin/contacts] Full error:', error);
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: 'Failed to load contacts', detail: message }, { status: 500 });
}
}

View File

@ -0,0 +1,39 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { jwtVerify } from 'jose';
import { readdir } from 'fs/promises';
import path from 'path';
async function verifyAdmin() {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
if (!token) return false;
const jwtSecret = process.env.ADMIN_JWT_SECRET;
if (!jwtSecret) return false;
try {
await jwtVerify(token, new TextEncoder().encode(jwtSecret));
return true;
} catch {
return false;
}
}
// GET /api/admin/list-models/ → returns all .glb files in public/models/
export async function GET() {
if (!(await verifyAdmin())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const modelsDir = path.resolve(process.cwd(), 'public', 'models');
try {
const files = await readdir(modelsDir);
const glbFiles = files
.filter((f) => f.toLowerCase().endsWith('.glb'))
.map((f) => ({
filename: f,
id: f.replace(/\.glb$/i, ''),
modelPath: `/models/${f}`,
}));
return NextResponse.json({ models: glbFiles });
} catch {
return NextResponse.json({ models: [] });
}
}

View File

@ -0,0 +1,49 @@
import { NextResponse } from 'next/server';
import bcrypt from 'bcryptjs';
import { SignJWT } from 'jose';
import { prisma } from '@/lib/prisma';
export async function POST(request: Request) {
try {
const { username, password } = await request.json();
if (!username || !password) {
return NextResponse.json({ error: 'Username and password are required' }, { status: 400 });
}
const user = await prisma.adminUser.findUnique({ where: { username } });
if (!user) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
const jwtSecret = process.env.ADMIN_JWT_SECRET;
if (!jwtSecret) {
return NextResponse.json({ error: 'Server configuration error' }, { status: 500 });
}
const secret = new TextEncoder().encode(jwtSecret);
const token = await new SignJWT({ sub: user.id, username: user.username })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(secret);
const response = NextResponse.json({ success: true });
response.cookies.set('admin_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
});
return response;
} catch {
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@ -0,0 +1,13 @@
import { NextResponse } from 'next/server';
export async function POST() {
const response = NextResponse.json({ success: true });
response.cookies.set('admin_token', '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 0,
path: '/',
});
return response;
}

View File

@ -0,0 +1,57 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { jwtVerify } from 'jose';
import { prisma } from '@/lib/prisma';
async function verifyAdmin() {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
if (!token) return false;
const jwtSecret = process.env.ADMIN_JWT_SECRET;
if (!jwtSecret) return false;
try {
await jwtVerify(token, new TextEncoder().encode(jwtSecret));
return true;
} catch {
return false;
}
}
export async function GET(request: Request) {
if (!(await verifyAdmin())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') ?? '50', 10), 200);
try {
const dbOrders = await prisma.order.findMany({
orderBy: { createdAt: 'desc' },
take: limit,
});
const orders = dbOrders.map((o) => ({
id: o.paymentIntentId,
amount: o.amount,
currency: o.currency,
status: o.status,
created: Math.floor(new Date(o.createdAt).getTime() / 1000),
metadata: {
customerName: o.customerName ?? '',
customerEmail: o.customerEmail ?? '',
customerPhone: o.customerPhone ?? '',
customerAddress: o.customerAddress ?? '',
customerCity: o.customerCity ?? '',
customerCountry: o.customerCountry ?? '',
customerPostalCode: o.customerPostalCode ?? '',
persona: o.persona ?? '',
color: o.color ?? '',
priceItems: o.priceItems ?? '',
},
}));
return NextResponse.json({ orders, hasMore: dbOrders.length === limit });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,91 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { jwtVerify } from 'jose';
import { prisma } from '@/lib/prisma';
async function verifyAdmin() {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
if (!token) return false;
const jwtSecret = process.env.ADMIN_JWT_SECRET;
if (!jwtSecret) return false;
try {
await jwtVerify(token, new TextEncoder().encode(jwtSecret));
return true;
} catch {
return false;
}
}
// GET /api/admin/pricing/ → all pricing items from DB
export async function GET() {
// Public endpoint — no auth required so the storefront can read prices
const items = await prisma.pricingItem.findMany({ orderBy: { sortOrder: 'asc' } });
return NextResponse.json({ items });
}
// PUT /api/admin/pricing/ → upsert a single pricing item
export async function PUT(request: Request) {
if (!(await verifyAdmin())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await request.json();
const { id, label, price, modelPath, sortOrder } = body;
if (!id || typeof id !== 'string') return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
if (!label || typeof label !== 'string') return NextResponse.json({ error: 'Invalid label' }, { status: 400 });
if (typeof price !== 'number' || price < 0) return NextResponse.json({ error: 'Invalid price' }, { status: 400 });
const safeId = id.toLowerCase().replace(/_/g, '-').replace(/[^a-z0-9-]/g, '');
if (!safeId) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
const item = await prisma.pricingItem.upsert({
where: { id: safeId },
create: { id: safeId, label: label.trim(), price, modelPath: modelPath ?? null, sortOrder: sortOrder ?? 0 },
update: { label: label.trim(), price, ...(modelPath !== undefined ? { modelPath } : {}), ...(sortOrder !== undefined ? { sortOrder } : {}) },
});
return NextResponse.json({ item });
}
// POST /api/admin/pricing/ → bulk save all items
export async function POST(request: Request) {
if (!(await verifyAdmin())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { items } = await request.json();
if (!Array.isArray(items)) return NextResponse.json({ error: 'items must be an array' }, { status: 400 });
const results = [];
for (let i = 0; i < items.length; i++) {
const { id, label, price, modelPath } = items[i];
if (!id || !label || typeof price !== 'number') continue;
const safeId = String(id).toLowerCase().replace(/_/g, '-').replace(/[^a-z0-9-]/g, '');
if (!safeId) continue;
const item = await prisma.pricingItem.upsert({
where: { id: safeId },
create: { id: safeId, label: String(label).trim(), price, modelPath: modelPath ?? null, sortOrder: i },
update: { label: String(label).trim(), price, sortOrder: i, ...(modelPath !== undefined ? { modelPath } : {}) },
});
results.push(item);
}
return NextResponse.json({ items: results });
}
// DELETE /api/admin/pricing/ → delete a pricing item
export async function DELETE(request: Request) {
if (!(await verifyAdmin())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 });
if (id === 'base') return NextResponse.json({ error: 'Cannot delete base item' }, { status: 400 });
try {
await prisma.pricingItem.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ error: 'Item not found' }, { status: 404 });
}
}

View File

@ -0,0 +1,69 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { jwtVerify } from 'jose';
import { prisma } from '@/lib/prisma';
async function verifyAdmin() {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
if (!token) return false;
const jwtSecret = process.env.ADMIN_JWT_SECRET;
if (!jwtSecret) return false;
try {
await jwtVerify(token, new TextEncoder().encode(jwtSecret));
return true;
} catch {
return false;
}
}
// GET /api/admin/settings/ → all key-value pairs
export async function GET() {
if (!(await verifyAdmin())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const settings = await prisma.appSettings.findMany({ orderBy: { key: 'asc' } });
return NextResponse.json({ settings });
}
// PUT /api/admin/settings/ → upsert a key
export async function PUT(request: Request) {
if (!(await verifyAdmin())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { key, value } = await request.json();
if (!key || typeof key !== 'string' || key.trim() === '') {
return NextResponse.json({ error: 'Invalid key' }, { status: 400 });
}
if (typeof value !== 'string') {
return NextResponse.json({ error: 'Invalid value' }, { status: 400 });
}
// Prevent overwriting internal keys with sensitive data
const protectedKeys = ['jwt_secret'];
if (protectedKeys.includes(key.toLowerCase())) {
return NextResponse.json({ error: 'This key is protected and cannot be edited here' }, { status: 403 });
}
const setting = await prisma.appSettings.upsert({
where: { key: key.trim() },
create: { key: key.trim(), value },
update: { value },
});
return NextResponse.json({ setting });
}
// DELETE /api/admin/settings/?key=xxx
export async function DELETE(request: Request) {
if (!(await verifyAdmin())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(request.url);
const key = searchParams.get('key');
if (!key) return NextResponse.json({ error: 'Missing key' }, { status: 400 });
const protectedKeys = ['jwt_secret'];
if (protectedKeys.includes(key.toLowerCase())) {
return NextResponse.json({ error: 'This key is protected' }, { status: 403 });
}
await prisma.appSettings.delete({ where: { key } }).catch(() => {});
return NextResponse.json({ success: true });
}

View File

@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { jwtVerify } from 'jose';
import { prisma } from '@/lib/prisma';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Verify admin JWT
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const jwtSecret = process.env.ADMIN_JWT_SECRET;
if (!jwtSecret) return NextResponse.json({ error: 'Server error' }, { status: 500 });
await jwtVerify(token, new TextEncoder().encode(jwtSecret));
const { id } = await params;
if (!id || !id.startsWith('pi_')) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
const snapshot = await prisma.snapshot.findUnique({ where: { paymentIntentId: id } });
if (!snapshot) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json({ imageData: snapshot.imageData });
} catch {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}

View File

@ -0,0 +1,66 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { jwtVerify } from 'jose';
import Stripe from 'stripe';
import { prisma } from '@/lib/prisma';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
apiVersion: '2026-03-25.dahlia' as any,
});
async function verifyAdmin() {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
if (!token) return false;
const jwtSecret = process.env.ADMIN_JWT_SECRET;
if (!jwtSecret) return false;
try {
await jwtVerify(token, new TextEncoder().encode(jwtSecret));
return true;
} catch {
return false;
}
}
// POST /api/admin/sync-orders/
// Fetches recent PaymentIntents from Stripe and upserts them into the DB
export async function POST() {
if (!(await verifyAdmin())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const list = await stripe.paymentIntents.list({ limit: 100 });
let synced = 0;
for (const pi of list.data) {
if (pi.status !== 'succeeded' && pi.status !== 'canceled') continue;
const m = pi.metadata ?? {};
const data = {
amount: pi.amount,
currency: pi.currency,
status: pi.status,
customerName: m.customerName ?? null,
customerEmail: m.customerEmail ?? pi.receipt_email ?? null,
customerPhone: m.customerPhone ?? null,
customerAddress: m.customerAddress ?? null,
customerCity: m.customerCity ?? null,
customerCountry: m.customerCountry ?? null,
customerPostalCode: m.customerPostalCode ?? null,
persona: m.persona ?? null,
color: m.color ?? null,
priceItems: m.priceItems ?? null,
};
await prisma.order.upsert({
where: { paymentIntentId: pi.id },
create: { paymentIntentId: pi.id, ...data },
update: data,
});
synced++;
}
return NextResponse.json({ synced });
} catch (err) {
const message = err instanceof Error ? err.message : 'Sync failed';
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,78 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { jwtVerify } from 'jose';
import { writeFile, mkdir, readFile } from 'fs/promises';
import path from 'path';
import { Document, NodeIO } from '@gltf-transform/core';
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
import { draco, dedup, prune, weld, simplify } from '@gltf-transform/functions';
import draco3d from 'draco3dgltf';
async function compressGLB(inputPath: string): Promise<void> {
const io = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ 'draco3d.decoder': await draco3d.createDecoderModule(), 'draco3d.encoder': await draco3d.createEncoderModule() });
const document = await io.read(inputPath);
await document.transform(dedup(), prune(), weld(), draco());
await io.write(inputPath, document);
}
export async function POST(request: Request) {
// Verify admin JWT
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const jwtSecret = process.env.ADMIN_JWT_SECRET;
if (!jwtSecret) return NextResponse.json({ error: 'Server error' }, { status: 500 });
try {
await jwtVerify(token, new TextEncoder().encode(jwtSecret));
} catch {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
let formData: FormData;
try {
formData = await request.formData();
} catch {
return NextResponse.json({ error: 'Invalid form data' }, { status: 400 });
}
const file = formData.get('file') as File | null;
const itemId = formData.get('itemId') as string | null;
if (!file || !itemId) {
return NextResponse.json({ error: 'Missing file or itemId' }, { status: 400 });
}
// Only accept .glb files
if (!file.name.toLowerCase().endsWith('.glb')) {
return NextResponse.json({ error: 'Only .glb files are allowed' }, { status: 400 });
}
// Sanitize the item ID — convert underscores to hyphens, keep lowercase letters, digits, hyphens
const safeId = itemId.toLowerCase().replace(/_/g, '-').replace(/[^a-z0-9-]/g, '');
if (!safeId) {
return NextResponse.json({ error: 'Invalid itemId' }, { status: 400 });
}
const modelsDir = path.resolve(process.cwd(), 'public', 'models');
await mkdir(modelsDir, { recursive: true });
const destPath = path.join(modelsDir, `${safeId}.glb`);
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(destPath, buffer);
// Compress the uploaded GLB with Draco to reduce file size
try {
await compressGLB(destPath);
} catch (err) {
console.warn('GLB compression failed, serving uncompressed:', err);
}
// Append a version timestamp so useGLTF cache is busted on replacement uploads
const version = Date.now();
return NextResponse.json({ modelPath: `/models/${safeId}.glb?v=${version}` });
}

View File

@ -0,0 +1,26 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { jwtVerify } from 'jose';
export async function GET() {
try {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
if (!token) {
return NextResponse.json({ authenticated: false }, { status: 401 });
}
const jwtSecret = process.env.ADMIN_JWT_SECRET;
if (!jwtSecret) {
return NextResponse.json({ authenticated: false }, { status: 500 });
}
const secret = new TextEncoder().encode(jwtSecret);
await jwtVerify(token, secret);
return NextResponse.json({ authenticated: true });
} catch {
return NextResponse.json({ authenticated: false }, { status: 401 });
}
}

View File

@ -0,0 +1,34 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma'; // Check if this is the correct export path
export async function POST(request: Request) {
try {
const data = await request.json();
// Validate required fields
if (!data.name || !data.email || !data.message) {
return NextResponse.json(
{ error: 'Name, Email, and Message are required' },
{ status: 400 }
);
}
// Save to database
const contact = await prisma.contactRequest.create({
data: {
name: data.name,
email: data.email,
phone: data.phone || null,
message: data.message,
},
});
return NextResponse.json({ success: true, contact });
} catch (error) {
console.error('Failed to submit contact form:', error);
return NextResponse.json(
{ error: 'Failed to submit contact form' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,39 @@
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
if (!process.env.STRIPE_SECRET_KEY) {
console.error('STRIPE_SECRET_KEY is not set in environment variables');
}
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || '', {
apiVersion: '2026-03-25.dahlia',
});
export async function POST(request: Request) {
try {
const { amount, currency, metadata, receiptEmail } = await request.json();
if (!amount || typeof amount !== 'number' || amount <= 0) {
return NextResponse.json({ error: 'Invalid amount' }, { status: 400 });
}
// Stripe expects amount in the smallest currency unit (fils for AED)
const amountInFils = Math.round(amount * 100);
const paymentIntent = await stripe.paymentIntents.create({
amount: amountInFils,
currency: currency || 'aed',
metadata: metadata || {},
...(receiptEmail ? { receipt_email: receiptEmail } : {}),
automatic_payment_methods: { enabled: true },
});
return NextResponse.json({
clientSecret: paymentIntent.client_secret,
paymentIntentId: paymentIntent.id,
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Internal server error';
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,63 @@
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { prisma } from '@/lib/prisma';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
apiVersion: '2026-03-25.dahlia' as any,
});
export async function POST(request: Request) {
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const { paymentIntentId } = body;
if (!paymentIntentId || typeof paymentIntentId !== 'string') {
return NextResponse.json({ error: 'Missing paymentIntentId' }, { status: 400 });
}
// Try to get authoritative data from Stripe, but don't block save if it fails
let stripeAmount: number | null = null;
let stripeCurrency: string | null = null;
let stripeStatus: string | null = null;
let stripeMetadata: Record<string, string> = {};
try {
const pi = await stripe.paymentIntents.retrieve(paymentIntentId);
stripeAmount = pi.amount;
stripeCurrency = pi.currency;
stripeStatus = pi.status;
stripeMetadata = (pi.metadata ?? {}) as Record<string, string>;
} catch {
// Stripe unreachable — save with client-submitted data
}
const m = stripeMetadata;
const data = {
amount: stripeAmount ?? (typeof body.amount === 'number' ? body.amount : 0),
currency: stripeCurrency ?? (typeof body.currency === 'string' ? body.currency : 'aed'),
status: stripeStatus ?? (typeof body.status === 'string' ? body.status : 'pending'),
customerName: (body.customerName as string | null) ?? m.customerName ?? null,
customerEmail: (body.customerEmail as string | null) ?? m.customerEmail ?? null,
customerPhone: (body.customerPhone as string | null) ?? m.customerPhone ?? null,
customerAddress: (body.customerAddress as string | null) ?? m.customerAddress ?? null,
customerCity: (body.customerCity as string | null) ?? m.customerCity ?? null,
customerCountry: (body.customerCountry as string | null) ?? m.customerCountry ?? null,
customerPostalCode: (body.customerPostalCode as string | null) ?? m.customerPostalCode ?? null,
persona: (body.persona as string | null) ?? m.persona ?? null,
color: (body.color as string | null) ?? m.color ?? null,
priceItems: (body.priceItems as string | null) ?? m.priceItems ?? null,
};
await prisma.order.upsert({
where: { paymentIntentId },
create: { paymentIntentId, ...data },
update: data,
});
return NextResponse.json({ saved: true });
}

View File

@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function POST(request: Request) {
try {
const { paymentIntentId, imageData } = await request.json();
// Validate paymentIntentId format
if (!paymentIntentId || typeof paymentIntentId !== 'string' || !paymentIntentId.startsWith('pi_')) {
return NextResponse.json({ error: 'Invalid paymentIntentId' }, { status: 400 });
}
if (!imageData || typeof imageData !== 'string') {
return NextResponse.json({ error: 'Invalid imageData' }, { status: 400 });
}
// Only accept data URLs (JPEG or PNG)
if (!imageData.startsWith('data:image/jpeg;base64,') && !imageData.startsWith('data:image/png;base64,')) {
return NextResponse.json({ error: 'Invalid image format' }, { status: 400 });
}
await prisma.snapshot.upsert({
where: { paymentIntentId },
create: { paymentIntentId, imageData },
update: { imageData },
});
return NextResponse.json({ success: true });
} catch (err) {
const message = err instanceof Error ? err.message : 'Internal server error';
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,58 @@
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { prisma } from '@/lib/prisma';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
apiVersion: '2026-03-25.dahlia' as any,
});
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get('stripe-signature');
if (!signature) {
return NextResponse.json({ error: 'Missing stripe-signature header' }, { status: 400 });
}
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) {
return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Invalid signature';
return NextResponse.json({ error: `Webhook signature verification failed: ${msg}` }, { status: 400 });
}
if (event.type === 'payment_intent.succeeded' || event.type === 'payment_intent.payment_failed') {
const pi = event.data.object as Stripe.PaymentIntent;
const m = pi.metadata ?? {};
const data = {
amount: pi.amount,
currency: pi.currency,
status: pi.status,
customerName: m.customerName ?? null,
customerEmail: m.customerEmail ?? null,
customerPhone: m.customerPhone ?? null,
customerAddress: m.customerAddress ?? null,
customerCity: m.customerCity ?? null,
customerCountry: m.customerCountry ?? null,
customerPostalCode: m.customerPostalCode ?? null,
persona: m.persona ?? null,
color: m.color ?? null,
};
await prisma.order.upsert({
where: { paymentIntentId: pi.id },
create: { paymentIntentId: pi.id, ...data },
update: data,
});
}
return NextResponse.json({ received: true });
}

View File

@ -0,0 +1,20 @@
'use client';
import { ConfiguratorSection } from '@/components/ConfiguratorSection';
import { Navbar } from '@/components/Navbar';
import { FooterAndContact } from '@/components/FooterAndContact';
export default function ConfigurePage() {
return (
<>
<Navbar />
{/* Configurator section takes up full height minus navbar height roughly, or we just let it take its normal height */}
<div style={{ minHeight: '100vh', paddingTop: '80px' }}>
<ConfiguratorSection />
</div>
<FooterAndContact />
</>
);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -200,22 +200,57 @@ html {
@media (max-width: 768px) { @media (max-width: 768px) {
.layout-container { .layout-container {
flex-direction: column !important; flex-direction: column-reverse !important;
height: 100dvh !important;
} }
.glass-panel-responsive { .glass-panel-responsive {
position: fixed !important; order: unset !important;
bottom: 0 !important; position: relative !important;
left: 0 !important; bottom: auto !important;
right: 0 !important; left: auto !important;
right: auto !important;
width: 100% !important; width: 100% !important;
height: 50vh !important; height: 55dvh !important;
border-left: none !important; max-height: 55dvh !important;
border-right: none !important; border-right: none !important;
border-left: none !important;
border-top: 1px solid var(--color-border) !important; border-top: 1px solid var(--color-border) !important;
box-shadow: 0 -4px 40px rgba(0, 0, 0, 0.08) !important; box-shadow: 0 -4px 30px rgba(0, 0, 0, 0.06) !important;
border-radius: 1rem 1rem 0 0 !important; border-radius: 1rem 1rem 0 0 !important;
overflow-y: auto !important;
overflow-x: hidden !important;
z-index: 50; z-index: 50;
padding-bottom: env(safe-area-inset-bottom, 0px) !important;
}
/* Expanded (fullscreen) state overrides */
.glass-panel-responsive.panel-expanded {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100dvh !important;
max-height: 100dvh !important;
border-radius: 0 !important;
border-top: none !important;
box-shadow: none !important;
z-index: 200 !important;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch !important;
padding-bottom: 0 !important;
}
.canvas-area {
height: 45dvh !important;
max-height: 45dvh !important;
min-height: 180px !important;
width: 100% !important;
flex: none !important;
}
#configurator {
height: 100dvh !important;
} }
.mobile-handle { .mobile-handle {
@ -223,6 +258,286 @@ html {
} }
} }
/* Small phones */
@media (max-width: 480px) {
.glass-panel-responsive {
height: 58dvh !important;
max-height: 58dvh !important;
border-radius: 0.75rem 0.75rem 0 0 !important;
}
.canvas-area {
height: 42dvh !important;
max-height: 42dvh !important;
min-height: 160px !important;
}
.glass-panel-responsive header {
padding: 0.75rem 1rem !important;
}
.glass-panel-responsive > div[role="region"] {
padding: 1rem !important;
}
}
/* Very small phones (iPhone SE, etc.) */
@media (max-width: 375px) {
.glass-panel-responsive {
height: 60dvh !important;
max-height: 60dvh !important;
}
.canvas-area {
height: 40dvh !important;
max-height: 40dvh !important;
min-height: 140px !important;
}
}
/* Landscape mobile */
@media (max-height: 500px) and (orientation: landscape) {
.layout-container {
flex-direction: row !important;
}
.glass-panel-responsive {
order: unset !important;
position: relative !important;
width: 320px !important;
min-width: 320px !important;
height: 100% !important;
max-height: 100% !important;
border-radius: 0 !important;
border-top: none !important;
border-left: 1px solid var(--color-border) !important;
overflow-y: auto !important;
}
.canvas-area {
height: 100% !important;
max-height: 100% !important;
width: auto !important;
flex: 1 !important;
}
.mobile-handle {
display: none !important;
}
}
.mobile-handle { .mobile-handle {
display: none; display: none;
} }
/* Hide expand button on desktop */
.panel-expand-btn {
display: none !important;
}
/* Show expand button only on mobile */
@media (max-width: 768px) {
.panel-expand-btn {
display: flex !important;
}
/* Expanded (fullscreen) panel state position handled by React inline styles */
.layout-expanded .canvas-area {
height: 0 !important;
max-height: 0 !important;
min-height: 0 !important;
overflow: hidden !important;
}
}
@media (max-width: 480px) {
.glass-panel-responsive.panel-expanded {
height: 100dvh !important;
max-height: 100dvh !important;
}
}
@media (max-width: 375px) {
.glass-panel-responsive.panel-expanded {
height: 100dvh !important;
max-height: 100dvh !important;
}
}
/* ===== Scroll Overlay Responsive ===== */
/* Overlay glass panels (side sections) */
.overlay-panel {
max-width: 450px;
padding: 2.5rem;
border-radius: 1.5rem;
}
/* Overlay section positioning */
.overlay-section-left {
left: clamp(2rem, 6vw, 6rem);
}
.overlay-section-right {
right: clamp(2rem, 6vw, 6rem);
}
.overlay-heading {
font-size: clamp(2rem, 3.5vw, 3rem);
}
.overlay-hero-heading {
font-size: clamp(2.5rem, 5vw, 4.5rem);
}
.overlay-stat {
font-size: 1.8rem;
}
/* Tablet */
@media (max-width: 1024px) {
.overlay-panel {
max-width: 380px;
padding: 2rem;
}
}
/* Mobile */
@media (max-width: 768px) {
.overlay-section-left,
.overlay-section-right {
left: 50% !important;
right: auto !important;
transform: translateX(-50%);
width: 90vw;
max-width: 400px;
}
.overlay-section-left {
top: auto !important;
bottom: 4vh !important;
align-items: center !important;
}
.overlay-section-right {
top: auto !important;
bottom: 4vh !important;
align-items: center !important;
}
.overlay-panel {
max-width: 100%;
padding: 1.25rem;
border-radius: 1rem;
text-align: center !important;
}
.overlay-panel > div {
align-items: center !important;
}
.overlay-hero-heading {
font-size: clamp(1.8rem, 8vw, 2.8rem);
}
.overlay-heading {
font-size: clamp(1.5rem, 6vw, 2rem);
}
.overlay-stat {
font-size: 1.4rem;
}
.overlay-brand {
top: 4vh !important;
bottom: auto !important;
left: 50% !important;
transform: translateX(-50%);
width: 90vw;
}
.overlay-brand span {
font-size: 0.6rem !important;
letter-spacing: 0.25em !important;
}
.overlay-brand p {
font-size: 0.75rem !important;
}
.overlay-scroll-hint {
bottom: 1.5rem !important;
}
.overlay-cta-section {
top: 12vh !important;
bottom: auto !important;
}
.overlay-cta-btn {
padding: 1.2rem 3rem !important;
font-size: 1.1rem !important;
}
}
/* Hide overlay CTA on desktop since it takes over the screen */
@media (min-width: 769px) {
.overlay-cta-section {
display: none !important;
}
}
/* Specific class to ensure configurator shows only on desktop */
.desktop-configurator {
display: block !important;
position: relative;
z-index: 10;
background-color: #ffffff;
}
@media (max-width: 768px) {
.desktop-configurator {
display: none !important;
}
}
/* Small phones */
@media (max-width: 480px) {
.overlay-section-left,
.overlay-section-right {
width: 92vw;
bottom: 4vh !important;
}
.overlay-panel {
padding: 1.25rem;
border-radius: 1rem;
}
.overlay-hero-heading {
font-size: clamp(1.5rem, 7vw, 2.2rem);
}
.overlay-heading {
font-size: clamp(1.3rem, 5.5vw, 1.8rem);
}
.overlay-stat {
font-size: 1.2rem;
}
}
/* Very small phones */
@media (max-width: 375px) {
.overlay-panel {
padding: 1rem;
}
.overlay-hero-heading {
font-size: 1.5rem;
}
.overlay-heading {
font-size: 1.3rem;
}
}

View File

@ -4,7 +4,7 @@ import { I18nProvider } from "@/components/I18nProvider";
import "./globals.css"; import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "G1 Configurator | Lootah Robotics", title: "Lootah Robotics | G1 Configurator",
description: "3D Configurator for the G1 Robot by Lootah Robotics", description: "3D Configurator for the G1 Robot by Lootah Robotics",
}; };
@ -22,13 +22,26 @@ export default function RootLayout({
return ( return (
<html lang="en" dir="ltr"> <html lang="en" dir="ltr">
<head> <head>
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" /> <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" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700&display=swap" rel="stylesheet" />
</head> </head>
<body suppressHydrationWarning> <body suppressHydrationWarning>
<ErrorBoundary> <ErrorBoundary>
<I18nProvider>{children}</I18nProvider> <I18nProvider>
{children}
{/* WhatsApp Floating Button */}
<a href="https://wa.me/971559482728" target="_blank" rel="noopener noreferrer" aria-label="Contact us on WhatsApp" className="fixed bottom-6 z-50 flex items-center justify-center h-14 w-14 rounded-full bg-gradient-to-r from-[#25D366] to-[#128C7E] text-white shadow-lg shadow-[#25D366]/30 transition-all duration-300 ease-out hover:scale-110 hover:shadow-[#25D366]/50 hover:shadow-xl active:scale-95 group" style={{ insetInlineEnd: '1.5rem' }}>
<span className="absolute inset-0 -z-10 rounded-full bg-gradient-to-r from-[#25D366] to-[#128C7E] opacity-0 blur-xl transition-opacity duration-300 group-hover:opacity-100"></span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="tabler-icon tabler-icon-brand-whatsapp relative z-10 h-8 w-8">
<path d="M3 21l1.65 -3.8a9 9 0 1 1 3.4 2.9l-5.05 .9"></path>
<path d="M9 10a.5 .5 0 0 0 1 0v-1a.5 .5 0 0 0 -1 0v1a5 5 0 0 0 5 5h1a.5 .5 0 0 0 0 -1h-1a.5 .5 0 0 0 0 1"></path>
</svg>
<span aria-hidden="true" className="absolute inset-0 rounded-full border border-[#25D366] bg-[#25D366]/50 animate-[ping_2.2s_cubic-bezier(0,0,0.2,1)_infinite]"></span>
</a>
</I18nProvider>
</ErrorBoundary> </ErrorBoundary>
</body> </body>
</html> </html>

View File

@ -4,13 +4,16 @@ import { useRef } from "react";
import { ClientOnly } from "@/components/ClientOnly"; import { ClientOnly } from "@/components/ClientOnly";
import { ScrollScene } from "@/components/ScrollScene"; import { ScrollScene } from "@/components/ScrollScene";
import { ScrollOverlays } from "@/components/ScrollOverlays"; import { ScrollOverlays } from "@/components/ScrollOverlays";
import { ConfiguratorSection } from "@/components/ConfiguratorSection"; import { FooterAndContact } from "@/components/FooterAndContact";
import { Navbar } from "@/components/Navbar";
export default function HomePage() { export default function HomePage() {
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
return ( return (
<> <>
<Navbar />
{/* Fixed 3D scene behind everything */} {/* Fixed 3D scene behind everything */}
<ClientOnly> <ClientOnly>
<ScrollScene scrollContainerRef={scrollContainerRef} /> <ScrollScene scrollContainerRef={scrollContainerRef} />
@ -30,9 +33,8 @@ export default function HomePage() {
<div className="snap-section" /> <div className="snap-section" />
</div> </div>
{/* Configurator section */} <div className="snap-section" style={{ scrollSnapAlign: 'start' }}>
<div style={{ position: "relative", zIndex: 20 }}> <FooterAndContact />
<ConfiguratorSection />
</div> </div>
</> </>
); );

View File

@ -0,0 +1,51 @@
import React from 'react';
import { Navbar } from '@/components/Navbar';
import { FooterAndContact } from '@/components/FooterAndContact';
export default function PrivacyPolicyPage() {
return (
<>
<Navbar />
<div style={{ background: '#050508', minHeight: '100vh', color: '#ffffff', fontFamily: 'Inter, sans-serif' }}>
<main style={{ maxWidth: '800px', margin: '0 auto', padding: '12rem 1.5rem 6rem', lineHeight: 1.8 }}>
<h1 style={{ fontSize: '3rem', fontWeight: 200, marginBottom: '1rem', letterSpacing: '-0.03em' }}>Privacy <span style={{ color: 'var(--color-gold)', fontWeight: 500 }}>Policy</span></h1>
<p style={{ color: '#94a3b8', fontSize: '1rem', marginBottom: '4rem' }}>Effective Date: {new Date().toLocaleDateString('en-AE')}</p>
<section style={{ marginBottom: '3rem' }}>
<h2 style={{ fontSize: '1.5rem', fontWeight: 500, color: '#e2e8f0', marginBottom: '1rem' }}>1. Information We Collect</h2>
<p style={{ color: '#cbd5e1', marginBottom: '1rem' }}>
At YS Lootah Robotics, we collect information you provide directly to us when you request information, use the G1 Customizer, or contact us. This includes your name, email address, phone number, and any other information you choose to provide in your message.
</p>
</section>
<section style={{ marginBottom: '3rem' }}>
<h2 style={{ fontSize: '1.5rem', fontWeight: 500, color: '#e2e8f0', marginBottom: '1rem' }}>2. How We Use Your Information</h2>
<p style={{ color: '#cbd5e1', marginBottom: '1rem' }}>
We use the information we collect to respond to your inquiries, deliver our robotics enterprise solutions, maintain our dashboard, and communicate with you about your custom humanoid configurations.
</p>
</section>
<section style={{ marginBottom: '3rem' }}>
<h2 style={{ fontSize: '1.5rem', fontWeight: 500, color: '#e2e8f0', marginBottom: '1rem' }}>3. Data Security</h2>
<p style={{ color: '#cbd5e1', marginBottom: '1rem' }}>
We implement robust security measures designed to protect your personal information. Your contact data is stored securely in our private databases strictly for administrative and operational purposes.
</p>
</section>
<section style={{ marginBottom: '3rem' }}>
<h2 style={{ fontSize: '1.5rem', fontWeight: 500, color: '#e2e8f0', marginBottom: '1rem' }}>4. Contact Us</h2>
<p style={{ color: '#cbd5e1', marginBottom: '1rem' }}>
If you have questions or concerns about this Privacy Policy, please reach out to us at:
<br/><br/>
<strong>YS Lootah Robotics</strong><br/>
Office 408, City Bay Business Center<br/>
Dubai, United Arab Emirates<br/>
Email: info@yslootahtech.com
</p>
</section>
</main>
</div>
<FooterAndContact />
</>
);
}

32
src/app/sitemap.ts Normal file
View File

@ -0,0 +1,32 @@
import { MetadataRoute } from 'next';
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = 'https://lootahrobotics.com'; // Adjust to your actual production domain
return [
{
url: `${baseUrl}`,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 1,
},
{
url: `${baseUrl}/configure`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: `${baseUrl}/privacy-policy`,
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 0.5,
},
{
url: `${baseUrl}/terms-of-service`,
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 0.5,
},
];
}

View File

@ -0,0 +1,47 @@
import React from 'react';
import { Navbar } from '@/components/Navbar';
import { FooterAndContact } from '@/components/FooterAndContact';
export default function TermsOfServicePage() {
return (
<>
<Navbar />
<div style={{ background: '#050508', minHeight: '100vh', color: '#ffffff', fontFamily: 'Inter, sans-serif' }}>
<main style={{ maxWidth: '800px', margin: '0 auto', padding: '12rem 1.5rem 6rem', lineHeight: 1.8 }}>
<h1 style={{ fontSize: '3rem', fontWeight: 200, marginBottom: '1rem', letterSpacing: '-0.03em' }}>Terms of <span style={{ color: 'var(--color-gold)', fontWeight: 500 }}>Service</span></h1>
<p style={{ color: '#94a3b8', fontSize: '1rem', marginBottom: '4rem' }}>Effective Date: {new Date().toLocaleDateString('en-AE')}</p>
<section style={{ marginBottom: '3rem' }}>
<h2 style={{ fontSize: '1.5rem', fontWeight: 500, color: '#e2e8f0', marginBottom: '1rem' }}>1. Acceptance of Terms</h2>
<p style={{ color: '#cbd5e1', marginBottom: '1rem' }}>
By accessing and utilizing the YS Lootah Robotics web platform and the G1 Configurator, you accept and agree to be bound by the terms and provisions of this agreement.
</p>
</section>
<section style={{ marginBottom: '3rem' }}>
<h2 style={{ fontSize: '1.5rem', fontWeight: 500, color: '#e2e8f0', marginBottom: '1rem' }}>2. Use of the Site & Configurator</h2>
<p style={{ color: '#cbd5e1', marginBottom: '1rem' }}>
The 3D G1 Configurator is provided for informational and demonstrative purposes to showcase the capabilities of YS Lootah technologies. You agree to use this site strictly for lawful purposes resulting in enterprise robotics inquiries and configurations.
</p>
</section>
<section style={{ marginBottom: '3rem' }}>
<h2 style={{ fontSize: '1.5rem', fontWeight: 500, color: '#e2e8f0', marginBottom: '1rem' }}>3. Intellectual Property Rights</h2>
<p style={{ color: '#cbd5e1', marginBottom: '1rem' }}>
All original content on this website, including but not limited to text, graphics, 3D models (GLB files), logos, and software, is the exclusive property of YS Lootah Robotics and is protected by United Arab Emirates and international copyright laws.
</p>
</section>
<section style={{ marginBottom: '3rem' }}>
<h2 style={{ fontSize: '1.5rem', fontWeight: 500, color: '#e2e8f0', marginBottom: '1rem' }}>4. Disclaimer of Warranties</h2>
<p style={{ color: '#cbd5e1', marginBottom: '1rem' }}>
The materials on our platform are provided "as is". We make no warranties, expressed or implied, and hereby disclaim to the fullest extent permitted by law all warranties regarding the immediate enterprise availability of the rendered concepts displayed in the Configurator.
</p>
</section>
</main>
</div>
<FooterAndContact />
</>
);
}

View File

@ -1,12 +1,16 @@
'use client'; 'use client';
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import { Elements } from '@stripe/react-stripe-js';
import { useOrderStore, orderStore, type CheckoutStep } from '@/store/useOrderStore'; import { useOrderStore, orderStore, type CheckoutStep } from '@/store/useOrderStore';
import { ShippingStep } from './checkout/ShippingStep'; import { ShippingStep } from './checkout/ShippingStep';
import { PaymentStep } from './checkout/PaymentStep'; import { PaymentStep } from './checkout/PaymentStep';
import { ReviewStep } from './checkout/ReviewStep'; import { ReviewStep } from './checkout/ReviewStep';
import { ConfirmationStep } from './checkout/ConfirmationStep'; import { ConfirmationStep } from './checkout/ConfirmationStep';
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
const STEPS: { id: CheckoutStep; label: string }[] = [ const STEPS: { id: CheckoutStep; label: string }[] = [
{ id: 'shipping', label: 'Shipping' }, { id: 'shipping', label: 'Shipping' },
{ id: 'payment', label: 'Payment' }, { id: 'payment', label: 'Payment' },
@ -19,6 +23,10 @@ function getStepIndex(step: CheckoutStep): number {
export function CheckoutOverlay() { export function CheckoutOverlay() {
const step = useOrderStore((s) => s.step); const step = useOrderStore((s) => s.step);
const clientSecret = useOrderStore((s) => s.payment.clientSecret);
const paymentStatus = useOrderStore((s) => s.payment.status);
const paymentError = useOrderStore((s) => s.payment.errorMessage);
const [isCreatingIntent, setIsCreatingIntent] = useState(false);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
orderStore.getState().resetOrder(); orderStore.getState().resetOrder();
@ -33,6 +41,16 @@ export function CheckoutOverlay() {
} }
}, [step]); }, [step]);
// Create payment intent when entering the payment step (only once)
useEffect(() => {
if (step === 'payment' && !clientSecret && !isCreatingIntent && paymentStatus !== 'failed') {
setIsCreatingIntent(true);
orderStore.getState().createPaymentIntent().finally(() => {
setIsCreatingIntent(false);
});
}
}, [step, clientSecret, isCreatingIntent, paymentStatus]);
// Close on Escape key // Close on Escape key
useEffect(() => { useEffect(() => {
if (step === 'config') return; if (step === 'config') return;
@ -153,8 +171,55 @@ export function CheckoutOverlay() {
{/* Step Content */} {/* Step Content */}
<div style={{ padding: '1.5rem' }}> <div style={{ padding: '1.5rem' }}>
{step === 'shipping' && <ShippingStep />} {step === 'shipping' && <ShippingStep />}
{step === 'payment' && <PaymentStep />} {(step === 'payment' || step === 'review') && clientSecret ? (
{step === 'review' && <ReviewStep />} <Elements
stripe={stripePromise}
options={{
clientSecret,
appearance: {
theme: 'stripe',
variables: {
colorPrimary: '#2563eb',
borderRadius: '0.375rem',
fontFamily: 'inherit',
},
},
}}
>
{/* Keep PaymentElement mounted (hidden) during review so confirmPayment works */}
<div style={{ display: step === 'payment' ? 'block' : 'none' }}>
<PaymentStep />
</div>
{step === 'review' && <ReviewStep />}
</Elements>
) : (step === 'payment' || step === 'review') && !clientSecret ? (
<div style={{ textAlign: 'center', padding: '2rem', fontSize: '0.85rem' }}>
{paymentStatus === 'failed' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', alignItems: 'center' }}>
<div style={{ color: '#dc2626' }}>{paymentError || 'Failed to initialize payment'}</div>
<button
onClick={() => {
orderStore.getState().setPayment({ status: 'idle', errorMessage: '' });
}}
style={{
padding: '0.5rem 1rem',
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,
}}
>
Retry
</button>
</div>
) : (
<span style={{ color: '#94a3b8' }}>Initializing secure payment...</span>
)}
</div>
) : null}
{step === 'confirmed' && <ConfirmationStep />} {step === 'confirmed' && <ConfirmationStep />}
</div> </div>
</div> </div>

View File

@ -1,50 +1,51 @@
'use client'; 'use client';
import { useCallback, useRef } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { configStore, useConfigStore } from '@/store/useConfigStore'; import { configStore, useConfigStore } from '@/store/useConfigStore';
import { personaStore, usePersonaStore } from '@/store/usePersonaStore';
import { PricingEngine } from './PricingEngine'; 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() { export function ConfigPanel() {
const activeColors = useConfigStore((s) => s.activeColors); const activeColors = useConfigStore((s) => s.activeColors);
const activePersona = useConfigStore((s) => s.activePersonaAttire); const activePersona = useConfigStore((s) => s.activePersonaAttire);
const personas = usePersonaStore((s) => s.personas);
// Track which persona is loading (waiting for GLB to download)
const [loadingPersona, setLoadingPersona] = useState<string | null>(null);
const colorsSectionRef = useRef<HTMLElement>(null); const colorsSectionRef = useRef<HTMLElement>(null);
const personaSectionRef = useRef<HTMLElement>(null); const personaSectionRef = useRef<HTMLElement>(null);
// Clear loading state once activePersona matches what we requested
const prevActivePersona = useRef(activePersona);
useEffect(() => {
if (activePersona !== prevActivePersona.current) {
prevActivePersona.current = activePersona;
}
// After 6s max, clear loading state even if something went wrong
if (loadingPersona !== null) {
const t = setTimeout(() => setLoadingPersona(null), 6000);
return () => clearTimeout(t);
}
}, [activePersona, loadingPersona]);
useEffect(() => {
personaStore.getState().hydrate();
}, []);
const handleColorChange = useCallback((key: 'primary' | 'secondary' | 'accent', value: string) => { const handleColorChange = useCallback((key: 'primary' | 'secondary' | 'accent', value: string) => {
configStore.getState().setColors({ [key]: value }); configStore.getState().setColors({ [key]: value });
}, []); }, []);
const handlePersonaSelect = useCallback((attire: string) => { const handlePersonaSelect = useCallback((attire: string) => {
// Only show loading for dynamic (uploaded) attire that has a GLB to download
const persona = personaStore.getState().personas.find((p) => p.id === attire);
const isStatic = ['none', 'emarati-kandura', 'industrial-vest', 'business-suit'].includes(attire);
if (!isStatic && persona?.modelPath) {
setLoadingPersona(attire);
}
configStore.getState().setPersonaAttire(attire); configStore.getState().setPersonaAttire(attire);
// Clear loading once the spin animation would have completed (~800ms)
setTimeout(() => setLoadingPersona(null), 3000);
}, []); }, []);
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
@ -80,7 +81,7 @@ export function ConfigPanel() {
> >
<h3 style={sectionTitleStyle}>Persona Attire</h3> <h3 style={sectionTitleStyle}>Persona Attire</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{PERSONA_OPTIONS.map((persona) => { {personas.map((persona) => {
const isActive = activePersona === persona.id; const isActive = activePersona === persona.id;
return ( return (
<button <button
@ -144,8 +145,16 @@ export function ConfigPanel() {
</div> </div>
</div> </div>
{/* Active checkmark */} {/* Active checkmark or loading spinner */}
{isActive && ( {isActive && loadingPersona === persona.id ? (
<div style={{
width: '20px', height: '20px', borderRadius: '50%',
border: '2px solid rgba(59,130,246,0.2)',
borderTopColor: '#3b82f6',
animation: 'spin 0.8s linear infinite',
flexShrink: 0,
}} />
) : isActive ? (
<div style={{ <div style={{
width: '20px', width: '20px',
height: '20px', height: '20px',
@ -160,7 +169,7 @@ export function ConfigPanel() {
<polyline points="20 6 9 17 4 12" /> <polyline points="20 6 9 17 4 12" />
</svg> </svg>
</div> </div>
)} ) : null}
</button> </button>
); );
})} })}

View File

@ -1,5 +1,6 @@
'use client'; 'use client';
import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useUrlSync } from '@/hooks/useUrlSync'; import { useUrlSync } from '@/hooks/useUrlSync';
import { RobotCanvas } from '@/components/RobotCanvas'; import { RobotCanvas } from '@/components/RobotCanvas';
@ -10,6 +11,7 @@ import { CheckoutOverlay } from '@/components/CheckoutOverlay';
export function ConfiguratorSection() { export function ConfiguratorSection() {
const { isHydrated } = useUrlSync(); const { isHydrated } = useUrlSync();
const { t } = useTranslation(); const { t } = useTranslation();
const [panelExpanded, setPanelExpanded] = useState(false);
return ( return (
<section <section
@ -23,7 +25,7 @@ export function ConfiguratorSection() {
}} }}
> >
<div <div
className="layout-container" className={`layout-container${panelExpanded ? ' layout-expanded' : ''}`}
style={{ style={{
display: 'flex', display: 'flex',
width: '100%', width: '100%',
@ -32,9 +34,8 @@ export function ConfiguratorSection() {
}} }}
> >
<aside <aside
className="glass-panel-responsive" className={`glass-panel-responsive${panelExpanded ? ' panel-expanded' : ''}`}
style={{ style={{
order: -1,
width: '420px', width: '420px',
height: '100%', height: '100%',
background: 'rgba(255, 255, 255, 0.9)', background: 'rgba(255, 255, 255, 0.9)',
@ -45,28 +46,39 @@ export function ConfiguratorSection() {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
position: 'relative', position: 'relative',
overflow: 'hidden', overflowX: 'hidden',
overflowY: 'auto',
}} }}
role="complementary" role="complementary"
aria-label={t('panel.title')} aria-label={t('panel.title')}
> >
<div <div
className="mobile-handle" className="mobile-handle"
onClick={() => setPanelExpanded((v) => !v)}
role="button"
tabIndex={0}
aria-label={panelExpanded ? 'Collapse panel' : 'Expand panel'}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setPanelExpanded((v) => !v); } }}
style={{ style={{
padding: '0.75rem', padding: '0.75rem',
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
position: 'relative',
cursor: 'pointer',
userSelect: 'none',
}} }}
aria-hidden="true"
> >
{/* Drag indicator tap to expand/collapse */}
<div <div
style={{ style={{
width: '40px', width: '40px',
height: '4px', height: '4px',
backgroundColor: '#e2e8f0', backgroundColor: '#e2e8f0',
borderRadius: '2px', borderRadius: '2px',
transition: 'width 0.2s, background-color 0.2s',
}} }}
aria-hidden="true"
/> />
</div> </div>
@ -94,7 +106,7 @@ export function ConfiguratorSection() {
style={{ style={{
flex: 1, flex: 1,
padding: '1.5rem', padding: '1.5rem',
overflowY: 'auto', overflowY: 'visible',
position: 'relative', position: 'relative',
zIndex: 1, zIndex: 1,
}} }}
@ -106,6 +118,7 @@ export function ConfiguratorSection() {
</aside> </aside>
<main <main
className="canvas-area"
style={{ style={{
flex: 1, flex: 1,
display: 'flex', display: 'flex',

View File

@ -0,0 +1,378 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import PhoneInput from 'react-country-phone-input';
import 'react-country-phone-input/lib/style.css';
export function FooterAndContact() {
const [formData, setFormData] = useState({ name: '', email: '', phone: '', message: '' });
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setStatus('loading');
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
if (res.ok) {
setStatus('success');
setFormData({ name: '', email: '', phone: '', message: '' });
setTimeout(() => setStatus('idle'), 4000);
} else {
setStatus('error');
setTimeout(() => setStatus('idle'), 4000);
}
} catch {
setStatus('error');
setTimeout(() => setStatus('idle'), 4000);
}
};
return (
<div style={{ position: 'relative', zIndex: 10, background: '#0a0a0f', color: '#ffffff', fontFamily: 'Inter, sans-serif' }}>
{/* Premium Desktop CTA section */}
<div style={{
padding: 'clamp(4rem, 8vw, 8rem) clamp(1.5rem, 5vw, 3rem)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
background: 'linear-gradient(180deg, #11111a 0%, #0a0a0f 100%)',
position: 'relative',
overflow: 'hidden'
}}>
{/* Subtle background glow */}
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 'clamp(300px, 80vw, 600px)',
height: 'clamp(200px, 50vw, 400px)',
background: 'radial-gradient(circle, rgba(196,162,101,0.08) 0%, rgba(0,0,0,0) 70%)',
pointerEvents: 'none'
}} />
<h2 style={{
fontSize: 'clamp(2rem, 6vw, 3.5rem)',
fontWeight: 200,
color: '#ffffff',
marginBottom: '1.5rem',
letterSpacing: '-0.04em',
position: 'relative'
}}>
Ready to Build Your <span style={{ color: 'var(--color-gold)', fontWeight: 500 }}>G1</span>?
</h2>
<p style={{
fontSize: 'clamp(1rem, 2.5vw, 1.15rem)',
color: '#94a3b8',
maxWidth: '600px',
textAlign: 'center',
lineHeight: 1.7,
marginBottom: '3.5rem',
fontWeight: 300,
position: 'relative'
}}>
Customize every detail. From intelligent locomotion to identity and purpose. Your enterprise-grade humanoid is just a few clicks away.
</p>
<Link
href="/configure/"
style={{
position: 'relative',
display: 'inline-flex',
alignItems: 'center',
gap: '1rem',
padding: 'clamp(0.8rem, 2vw, 1.25rem) clamp(1.5rem, 5vw, 3.5rem)',
borderRadius: '4rem',
background: 'transparent',
color: 'var(--color-gold)',
border: '1px solid var(--color-gold)',
fontSize: 'clamp(0.85rem, 2vw, 1.1rem)',
fontWeight: 500,
letterSpacing: '0.1em',
textDecoration: 'none',
textTransform: 'uppercase',
transition: 'all 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
overflow: 'hidden',
}}
onMouseOver={(e) => {
e.currentTarget.style.background = 'var(--color-gold)';
e.currentTarget.style.color = '#ffffff';
e.currentTarget.style.boxShadow = '0 0 30px rgba(196, 162, 101, 0.4)';
e.currentTarget.style.transform = 'translateY(-2px)';
}}
onMouseOut={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'var(--color-gold)';
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.transform = 'translateY(0)';
}}
>
Configure Your G1
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
</Link>
</div>
{/* Contact Section */}
<section style={{ padding: 'clamp(4rem, 8vw, 6rem) 1.5rem', maxWidth: '1280px', margin: '0 auto', borderTop: '1px solid rgba(255,255,255,0.05)' }} id="contact">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'clamp(2rem, 5vw, 4rem)', justifyContent: 'space-between' }}>
{/* Contact Info */}
<div style={{ flex: '1 1 min(400px, 100%)' }}>
<div style={{ width: '40px', height: '1px', background: 'var(--color-gold)', marginBottom: '2rem' }} />
<h3 style={{ fontSize: '0.8rem', fontWeight: 500, color: 'var(--color-gold)', letterSpacing: '0.3em', textTransform: 'uppercase', marginBottom: '1rem' }}>
Connect With Us
</h3>
<h2 style={{ fontSize: 'clamp(2rem, 5vw, 2.8rem)', fontWeight: 200, color: '#ffffff', lineHeight: 1.15, marginBottom: '2rem', letterSpacing: '-0.03em' }}>
Start your<br /><span style={{ fontWeight: 500 }}>Robotics Journey</span>
</h2>
<p style={{ fontSize: '1.05rem', color: '#94a3b8', lineHeight: 1.7, marginBottom: '4rem', fontWeight: 300 }}>
Whether you are looking to integrate the G1 into your enterprise workflows or have questions about custom developments, our team in the UAE is here to help.
</p>
<div style={{ padding: 'clamp(1.5rem, 4vw, 2rem)', borderRadius: '1rem', border: '1px solid rgba(255,255,255,0.05)', background: '#11111a', marginTop: '2rem' }}>
<h3 className="text-xl font-semibold text-white mb-6" style={{ letterSpacing: '0.05em' }}>Contact Information</h3>
<div className="space-y-6">
<a className="group flex items-start gap-4" href="tel:+971 55 948 2728" target="_blank" rel="noopener noreferrer">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[#c4a265]/10 text-[#c4a265] transition-all duration-300 group-hover:bg-[#c4a265]/20 group-hover:scale-110">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-phone h-5 w-5" aria-hidden="true"><path d="M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384"></path></svg>
</div>
<div><div className="text-sm text-slate-400">Phone</div><div className="mt-1 font-medium text-white">+971 55 948 2728</div></div>
</a>
<a className="group flex items-start gap-4" href="tel:+971 4 349 9319" target="_blank" rel="noopener noreferrer">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[#c4a265]/10 text-[#c4a265] transition-all duration-300 group-hover:bg-[#c4a265]/20 group-hover:scale-110">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-phone h-5 w-5" aria-hidden="true"><path d="M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384"></path></svg>
</div>
<div><div className="text-sm text-slate-400">Phone</div><div className="mt-1 font-medium text-white">+971 4 349 9319</div></div>
</a>
<a className="group flex items-start gap-4" href="mailto:info@yslootahtech.com" target="_blank" rel="noopener noreferrer">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[#c4a265]/10 text-[#c4a265] transition-all duration-300 group-hover:bg-[#c4a265]/20 group-hover:scale-110">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-mail h-5 w-5" aria-hidden="true"><path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7"></path><rect x="2" y="4" width="20" height="16" rx="2"></rect></svg>
</div>
<div><div className="text-sm text-slate-400">Email</div><div className="mt-1 font-medium text-white">info@yslootahtech.com</div></div>
</a>
<a className="group flex items-start gap-4" href="https://maps.google.com/?q=Office+408+City+Bay+Business+Center+Dubai" target="_blank" rel="noopener noreferrer">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[#c4a265]/10 text-[#c4a265] transition-all duration-300 group-hover:bg-[#c4a265]/20 group-hover:scale-110">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-map-pin h-5 w-5" aria-hidden="true"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"></path><circle cx="12" cy="10" r="3"></circle></svg>
</div>
<div><div className="text-sm text-slate-400">Address</div><div className="mt-1 font-medium text-white">Office 408, City Bay Business Center<br/>Dubai, UAE</div></div>
</a>
</div>
<div className="mt-8 pt-6 border-t border-[rgba(255,255,255,0.05)]">
<div className="text-sm text-slate-400 mb-4" style={{ letterSpacing: '0.05em' }}>Follow Us</div>
<div className="flex gap-3">
<a href="https://www.instagram.com/yslootahtech" target="_blank" rel="noopener noreferrer" className="group flex h-11 w-11 items-center justify-center rounded-xl border border-[rgba(255,255,255,0.05)] bg-[#11111a] transition-all duration-300 hover:border-[#c4a265]/50 hover:bg-[#c4a265]/10 group-hover:text-[#c4a265]" aria-label="Instagram"><svg className="h-5 w-5 text-slate-400 transition-colors group-hover:text-[#c4a265]" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"></path></svg></a>
<a href="https://www.linkedin.com/company/ys-lootah-tech" target="_blank" rel="noopener noreferrer" className="group flex h-11 w-11 items-center justify-center rounded-xl border border-[rgba(255,255,255,0.05)] bg-[#11111a] transition-all duration-300 hover:border-[#c4a265]/50 hover:bg-[#c4a265]/10 group-hover:text-[#c4a265]" aria-label="Linkedin"><svg className="h-5 w-5 text-slate-400 transition-colors group-hover:text-[#c4a265]" fill="currentColor" viewBox="0 0 24 24"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"></path></svg></a>
<a href="https://www.facebook.com/yslootahtech" target="_blank" rel="noopener noreferrer" className="group flex h-11 w-11 items-center justify-center rounded-xl border border-[rgba(255,255,255,0.05)] bg-[#11111a] transition-all duration-300 hover:border-[#c4a265]/50 hover:bg-[#c4a265]/10 group-hover:text-[#c4a265]" aria-label="Facebook"><svg className="h-5 w-5 text-slate-400 transition-colors group-hover:text-[#c4a265]" fill="currentColor" viewBox="0 0 24 24"><path d="M9.101 24v-11.01h-3.427v-3.929h3.427v-2.897c0-3.411 2.083-5.268 5.123-5.268 1.455 0 2.707.108 3.07.157v3.56h-2.107c-1.654 0-1.974.786-1.974 1.938v2.51h3.942l-.513 3.929h-3.429V24H9.101z"></path></svg></a>
</div>
</div>
</div>
</div>
{/* Contact Form */}
<div style={{ flex: '1 1 min(450px, 100%)', padding: 'clamp(1.5rem, 5vw, 3.5rem)', borderRadius: '1rem', background: '#11111a', border: '1px solid rgba(255,255,255,0.05)', boxSizing: 'border-box' }}>
<form style={{ display: 'flex', flexDirection: 'column', gap: '1.75rem' }} onSubmit={handleSubmit}>
{status === 'success' && (
<div style={{ background: 'rgba(34, 197, 94, 0.1)', color: '#22c55e', padding: '1rem', borderRadius: '0.5rem', border: '1px solid rgba(34, 197, 94, 0.2)', textAlign: 'center', fontSize: '0.9rem' }}>
Thank you! Your message has been sent successfully.
</div>
)}
{status === 'error' && (
<div style={{ background: 'rgba(239, 68, 68, 0.1)', color: '#ef4444', padding: '1rem', borderRadius: '0.5rem', border: '1px solid rgba(239, 68, 68, 0.2)', textAlign: 'center', fontSize: '0.9rem' }}>
An error occurred. Please try again.
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<label style={{ fontSize: '0.8rem', fontWeight: 500, color: '#cbd5e1', letterSpacing: '0.05em' }}>Full Name</label>
<input
type="text"
required
placeholder="John Doe"
value={formData.name}
onChange={(e) => setFormData({...formData, name: e.target.value})}
style={{ width: '100%', padding: '1rem 0', border: 'none', borderBottom: '1px solid rgba(255,255,255,0.1)', fontSize: '1rem', background: 'transparent', color: '#ffffff', outline: 'none', transition: 'border-color 0.3s', boxSizing: 'border-box' }}
onFocus={(e) => e.target.style.borderBottom = '1px solid var(--color-gold)'}
onBlur={(e) => e.target.style.borderBottom = '1px solid rgba(255,255,255,0.1)'}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<label style={{ fontSize: '0.8rem', fontWeight: 500, color: '#cbd5e1', letterSpacing: '0.05em' }}>Email Address</label>
<input
type="email"
required
placeholder="john@company.com"
value={formData.email}
onChange={(e) => setFormData({...formData, email: e.target.value})}
style={{ width: '100%', padding: '1rem 0', border: 'none', borderBottom: '1px solid rgba(255,255,255,0.1)', fontSize: '1rem', background: 'transparent', color: '#ffffff', outline: 'none', transition: 'border-color 0.3s', boxSizing: 'border-box' }}
onFocus={(e) => e.target.style.borderBottom = '1px solid var(--color-gold)'}
onBlur={(e) => e.target.style.borderBottom = '1px solid rgba(255,255,255,0.1)'}
/>
</div>
{/* Mobile Number with Country Code */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<label style={{ fontSize: '0.8rem', fontWeight: 500, color: '#cbd5e1', letterSpacing: '0.05em' }}>Mobile Number</label>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'flex-end' }}>
<PhoneInput
country={'ae'}
value={formData.phone}
onChange={(phone) => setFormData({...formData, phone})}
containerStyle={{ width: '100%' }}
inputStyle={{
width: '100%',
padding: '1rem 0 1rem 3.5rem',
border: 'none',
borderBottom: '1px solid rgba(255,255,255,0.1)',
fontSize: '1rem',
background: 'transparent',
color: '#ffffff',
outline: 'none',
transition: 'border-color 0.3s',
boxSizing: 'border-box'
}}
buttonStyle={{
background: 'transparent',
border: 'none',
borderBottom: '1px solid rgba(255,255,255,0.1)',
padding: '0 0.5rem',
}}
dropdownStyle={{ background: '#11111a', color: '#fff', border: '1px solid rgba(255,255,255,0.1)' }}
/>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '0.5rem' }}>
<label style={{ fontSize: '0.8rem', fontWeight: 500, color: '#cbd5e1', letterSpacing: '0.05em' }}>Message</label>
<textarea
rows={3}
required
placeholder="How can we help you?"
value={formData.message}
onChange={(e) => setFormData({...formData, message: e.target.value})}
style={{ width: '100%', padding: '1rem 0', border: 'none', borderBottom: '1px solid rgba(255,255,255,0.1)', fontSize: '1rem', background: 'transparent', color: '#ffffff', outline: 'none', transition: 'border-color 0.3s', resize: 'none', boxSizing: 'border-box' }}
onFocus={(e) => e.target.style.borderBottom = '1px solid var(--color-gold)'}
onBlur={(e) => e.target.style.borderBottom = '1px solid rgba(255,255,255,0.1)'}
/>
</div>
<button
disabled={status === 'loading'}
style={{
padding: '1.25rem 2.5rem',
borderRadius: '3rem',
background: status === 'loading' ? '#64748b' : '#ffffff',
color: status === 'loading' ? '#ffffff' : '#0a0a0f',
fontSize: '0.95rem',
fontWeight: 600,
letterSpacing: '0.05em',
textTransform: 'uppercase',
marginTop: '1.5rem',
border: 'none',
cursor: status === 'loading' ? 'not-allowed' : 'pointer',
transition: 'background-color 0.3s, transform 0.3s'
}}
onMouseOver={(e) => { if(status !== 'loading') e.currentTarget.style.background = 'var(--color-gold)' }}
onMouseOut={(e) => { if(status !== 'loading') e.currentTarget.style.background = '#ffffff' }}
>
{status === 'loading' ? 'Sending...' : 'Send Message'}
</button>
</form>
</div>
</div>
</section>
{/* Redesigned Footer (Inspired by yslootahtech.com) */}
<footer style={{ background: '#050508', borderTop: '1px solid rgba(255,255,255,0.05)', padding: 'clamp(3rem, 8vw, 6rem) 1.5rem 2rem' }}>
<div style={{ maxWidth: '1280px', margin: '0 auto', display: 'flex', flexWrap: 'wrap', gap: 'clamp(2rem, 5vw, 5rem)', justifyContent: 'space-between', paddingBottom: 'clamp(2rem, 5vw, 5rem)' }}>
{/* Brand Column */}
<div style={{ flex: '1 1 min(350px, 100%)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '2rem' }}>
<span style={{ fontSize: '1.2rem', fontWeight: 700, color: 'var(--color-gold)', letterSpacing: '0.25em', textTransform: 'uppercase' }}>
YS Lootah
</span>
<span style={{ fontSize: '1.2rem', fontWeight: 300, color: '#ffffff', letterSpacing: '0.25em', textTransform: 'uppercase' }}>
Robotics
</span>
</div>
<p style={{ fontSize: '1rem', color: '#94a3b8', lineHeight: 1.8, fontWeight: 300, maxWidth: '90%', marginBottom: '2rem' }}>
Innovating today for a smarter tomorrow. We are more than an automation provider; we are your trusted technology partner delivering advanced enterprise humanoid robotics.
</p>
<div style={{ display: 'flex', gap: '1rem' }}>
<a href="https://www.instagram.com/yslootahtech" target="_blank" rel="noopener noreferrer" style={{ color: '#64748b', transition: 'color 0.3s' }} onMouseOver={e=>e.currentTarget.style.color='var(--color-gold)'} onMouseOut={e=>e.currentTarget.style.color='#64748b'}>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"></path></svg>
</a>
<a href="https://www.linkedin.com/company/ys-lootah-tech" target="_blank" rel="noopener noreferrer" style={{ color: '#64748b', transition: 'color 0.3s' }} onMouseOver={e=>e.currentTarget.style.color='var(--color-gold)'} onMouseOut={e=>e.currentTarget.style.color='#64748b'}>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"></path></svg>
</a>
<a href="https://www.facebook.com/yslootahtech" target="_blank" rel="noopener noreferrer" style={{ color: '#64748b', transition: 'color 0.3s' }} onMouseOver={e=>e.currentTarget.style.color='var(--color-gold)'} onMouseOut={e=>e.currentTarget.style.color='#64748b'}>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><path d="M9.101 24v-11.01h-3.427v-3.929h3.427v-2.897c0-3.411 2.083-5.268 5.123-5.268 1.455 0 2.707.108 3.07.157v3.56h-2.107c-1.654 0-1.974.786-1.974 1.938v2.51h3.942l-.513 3.929h-3.429V24H9.101z"></path></svg>
</a>
</div>
</div>
<div style={{ flex: '1 1 200px' }}>
<h4 style={{ fontSize: '0.85rem', fontWeight: 600, color: '#ffffff', letterSpacing: '0.15em', textTransform: 'uppercase', marginBottom: '2rem' }}>Customization</h4>
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
{[
{ label: 'Emarati Kandura', href: '/configure/?config=eyJjIjp7InByaW1hcnkiOiIjOTZhMmI2Iiwic2Vjb25kYXJ5IjoiIzFlMjkzYiIsImFjY2VudCI6IiNmNTllMGIifSwicCI6ImVtYXJhdGkta2FuZHVyYSIsInkiOltdfQ%3D%3D' },
{ label: 'Industrial Vest', href: '/configure/?config=eyJjIjp7InByaW1hcnkiOiIjOTZhMmI2Iiwic2Vjb25kYXJ5IjoiIzFlMjkzYiIsImFjY2VudCI6IiNmNTllMGIifSwicCI6ImluZHVzdHJpYWwtdmVzdCIsInkiOltdfQ%3D%3D' },
{ label: 'Business Suit', href: '/configure/?config=eyJjIjp7InByaW1hcnkiOiIjOTZhMmI2Iiwic2Vjb25kYXJ5IjoiIzFlMjkzYiIsImFjY2VudCI6IiNmNTllMGIifSwicCI6ImJ1c2luZXNzLXN1aXQiLCJ5IjpbXX0%3D' },
{ label: 'Robot Doctor', href: '/configure/?config=eyJjIjp7InByaW1hcnkiOiIjOTZhMmI2Iiwic2Vjb25kYXJ5IjoiIzFlMjkzYiIsImFjY2VudCI6IiNmNTllMGIifSwicCI6InJvYm90LWRvY3RvciIsInkiOltdfQ%3D%3D' },
{ label: 'Security Guard', href: '/configure/?config=eyJjIjp7InByaW1hcnkiOiIjOTZhMmI2Iiwic2Vjb25kYXJ5IjoiIzFlMjkzYiIsImFjY2VudCI6IiNmNTllMGIifSwicCI6InNlY3VyaXR5LWd1YXJkIiwieSI6W119' }
].map((item, i) => (
<li key={i}>
<Link href={item.href} style={{ color: '#94a3b8', textDecoration: 'none', fontSize: '0.95rem', fontWeight: 300, transition: 'color 0.3s, padding-left 0.3s' }}
onMouseOver={e => { e.currentTarget.style.color = 'var(--color-gold)'; e.currentTarget.style.paddingLeft = '5px'; }}
onMouseOut={e => { e.currentTarget.style.color = '#94a3b8'; e.currentTarget.style.paddingLeft = '0'; }}
>
{item.label}
</Link>
</li>
))}
</ul>
</div>
<div style={{ flex: '1 1 200px' }}>
<h4 style={{ fontSize: '0.85rem', fontWeight: 600, color: '#ffffff', letterSpacing: '0.15em', textTransform: 'uppercase', marginBottom: '2rem' }}>Company</h4>
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
{[
{ label: 'About YS Lootah', href: 'https://yslootahtech.com/#whyus' },
{ label: 'Contact Us', href: '#contact' }
].map((item, i) => (
<li key={i}>
<Link href={item.href} style={{ color: '#94a3b8', textDecoration: 'none', fontSize: '0.95rem', fontWeight: 300, transition: 'color 0.3s, padding-left 0.3s' }}
onMouseOver={e => { e.currentTarget.style.color = 'var(--color-gold)'; e.currentTarget.style.paddingLeft = '5px'; }}
onMouseOut={e => { e.currentTarget.style.color = '#94a3b8'; e.currentTarget.style.paddingLeft = '0'; }}
>
{item.label}
</Link>
</li>
))}
</ul>
</div>
</div>
{/* Copyright */}
<div style={{ maxWidth: '1280px', margin: '0 auto', paddingTop: '2.5rem', borderTop: '1px solid rgba(255,255,255,0.05)', display: 'flex', flexWrap: 'wrap', justifyContent: 'space-between', alignItems: 'center', gap: '1rem' }}>
<p style={{ fontSize: '0.85rem', color: '#64748b', margin: 0, fontWeight: 300 }}>
&copy; {new Date().getFullYear()} YS Lootah Robotics. All rights reserved.
</p>
<div style={{ display: 'flex', gap: '2rem' }}>
<Link href="/privacy-policy" style={{ fontSize: '0.85rem', color: '#64748b', textDecoration: 'none', transition: 'color 0.3s' }} onMouseOver={e=>e.currentTarget.style.color='#fff'} onMouseOut={e=>e.currentTarget.style.color='#64748b'}>Privacy Policy</Link>
<Link href="/terms-of-service" style={{ fontSize: '0.85rem', color: '#64748b', textDecoration: 'none', transition: 'color 0.3s' }} onMouseOver={e=>e.currentTarget.style.color='#fff'} onMouseOut={e=>e.currentTarget.style.color='#64748b'}>Terms of Service</Link>
</div>
</div>
</footer>
</div>
);
}

216
src/components/Navbar.tsx Normal file
View File

@ -0,0 +1,216 @@
'use client';
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
export function Navbar() {
const [scrolled, setScrolled] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
useEffect(() => {
const handleScroll = () => {
setScrolled(window.scrollY > 20);
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const navLinks = [
{ label: 'Contact', href: '#contact' }
];
const handleNavClick = (e: React.MouseEvent<HTMLAnchorElement>, link: any) => {
if (link.progress !== undefined) {
e.preventDefault();
setMobileMenuOpen(false);
// We have 7 snap-sections of 100vh each. Max scroll inside the scene is 600vh.
const targetScroll = link.progress * (6 * window.innerHeight);
window.scrollTo({ top: targetScroll, behavior: 'smooth' });
}
};
return (
<>
<nav
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 100,
background: scrolled ? 'rgba(255, 255, 255, 0.85)' : 'transparent',
backdropFilter: scrolled ? 'blur(16px)' : 'none',
WebkitBackdropFilter: scrolled ? 'blur(16px)' : 'none',
borderBottom: scrolled ? '1px solid rgba(0,0,0,0.05)' : '1px solid transparent',
transition: 'all 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
padding: scrolled ? '1rem 2rem' : '1.5rem 2rem',
}}
>
<div style={{ maxWidth: '1280px', margin: '0 auto', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
{/* Logo */}
<Link href="/" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<span style={{
fontSize: '1.1rem',
fontWeight: 700,
color: 'var(--color-gold)',
letterSpacing: '0.15em',
textTransform: 'uppercase',
textShadow: scrolled ? 'none' : '0 2px 10px rgba(0,0,0,0.1)'
}}>
YS Lootah
</span>
<span style={{
fontSize: '1.1rem',
fontWeight: 300,
color: scrolled ? '#1a1a2e' : '#1a1a2e',
letterSpacing: '0.15em',
textTransform: 'uppercase'
}}>
Robotics
</span>
</Link>
{/* Desktop Links */}
<div className="hidden md:flex" style={{ alignItems: 'center', gap: '3rem' }}>
{navLinks.map((link) => (
<Link
key={link.label}
href={link.href || '#'}
onClick={(e) => handleNavClick(e, link)}
style={{
color: scrolled ? '#64748b' : '#1a1a2e',
fontSize: '0.85rem',
fontWeight: 600,
textDecoration: 'none',
textTransform: 'uppercase',
letterSpacing: '0.1em',
transition: 'color 0.2s',
textShadow: scrolled ? 'none' : '0 2px 10px rgba(255,255,255,0.2)'
}}
onMouseOver={(e) => e.currentTarget.style.color = 'var(--color-gold)'}
onMouseOut={(e) => e.currentTarget.style.color = scrolled ? '#64748b' : '#1a1a2e'}
>
{link.label}
</Link>
))}
</div>
{/* Action Button & Hamburger */}
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<Link
href="/configure/"
className="hidden md:flex"
style={{
alignItems: 'center',
justifyContent: 'center',
padding: '0.75rem 1.5rem',
borderRadius: '2rem',
background: scrolled ? '#1a1a2e' : 'var(--color-gold)',
color: '#ffffff',
fontSize: '0.85rem',
fontWeight: 600,
textDecoration: 'none',
textTransform: 'uppercase',
letterSpacing: '0.05em',
transition: 'all 0.3s',
boxShadow: scrolled ? 'none' : '0 4px 14px rgba(196, 162, 101, 0.3)',
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = scrolled ? '0 4px 14px rgba(26, 26, 46, 0.2)' : '0 6px 20px rgba(196, 162, 101, 0.4)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = scrolled ? 'none' : '0 4px 14px rgba(196, 162, 101, 0.3)';
}}
>
Configure G1
</Link>
{/* Mobile Menu Toggle */}
<button
className="md:hidden"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
style={{
background: 'transparent',
border: 'none',
cursor: 'pointer',
color: scrolled ? '#1a1a2e' : 'var(--color-gold)',
padding: '0.5rem',
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{mobileMenuOpen ? (
<path d="M18 6L6 18M6 6l12 12" />
) : (
<path d="M4 12h16M4 6h16M4 18h16" />
)}
</svg>
</button>
</div>
</div>
</nav>
{/* Mobile Menu Dropdown */}
<div
className="md:hidden"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: '#ffffff',
zIndex: 90,
display: mobileMenuOpen ? 'block' : 'none',
paddingTop: '6rem',
}}
>
<div style={{ display: 'flex', flexDirection: 'column', padding: '0 2rem', gap: '2rem' }}>
{navLinks.map((link) => (
<Link
key={link.label}
href={link.href || '#'}
onClick={(e) => handleNavClick(e, link)}
style={{
color: '#1a1a2e',
fontSize: '1.4rem',
fontWeight: 300,
textDecoration: 'none',
textTransform: 'uppercase',
letterSpacing: '0.1em',
borderBottom: '1px solid #f1f5f9',
paddingBottom: '1rem',
}}
>
{link.label}
</Link>
))}
<Link
href="/configure/"
onClick={() => setMobileMenuOpen(false)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '1.25rem',
borderRadius: '3rem',
background: 'var(--color-gold)',
color: '#ffffff',
fontSize: '1.1rem',
fontWeight: 600,
textDecoration: 'none',
textTransform: 'uppercase',
letterSpacing: '0.1em',
marginTop: '1rem',
boxShadow: '0 4px 14px rgba(196, 162, 101, 0.3)',
}}
>
Configure Your G1
</Link>
</div>
</div>
</>
);
}

View File

@ -4,6 +4,7 @@ import { useEffect } from 'react';
import { useConfigStore } from '@/store/useConfigStore'; import { useConfigStore } from '@/store/useConfigStore';
import { usePricingStore, pricingStore } from '@/store/usePricingStore'; import { usePricingStore, pricingStore } from '@/store/usePricingStore';
import { orderStore } from '@/store/useOrderStore'; import { orderStore } from '@/store/useOrderStore';
import { snapshotStore } from '@/store/useSnapshotStore';
const DEFAULT_COLOR = '#96a2b6'; const DEFAULT_COLOR = '#96a2b6';
@ -31,11 +32,20 @@ export function PricingEngine() {
const personaLabel = items.find((i) => i.id === persona)?.label ?? ''; const personaLabel = items.find((i) => i.id === persona)?.label ?? '';
const handleProceed = () => { const handleProceed = () => {
// Cache the robot snapshot now, while the canvas is still visible
snapshotStore.getState().cacheSnapshot();
const store = orderStore.getState(); const store = orderStore.getState();
const lineItems: { label: string; price: number }[] = [
{ label: 'G1 Robot Base', price: basePrice },
...(personaPrice > 0 ? [{ label: personaLabel, price: personaPrice }] : []),
...(colorPrice > 0 ? [{ label: 'Custom Color', price: colorPrice }] : []),
];
store.setOrderTotal(total); store.setOrderTotal(total);
store.setConfigSummary( store.setConfigSummary(
persona === 'none' ? 'Default' : personaLabel, persona === 'none' ? 'Default' : personaLabel,
primaryColor primaryColor,
lineItems,
); );
store.setStep('shipping'); store.setStep('shipping');
}; };

View File

@ -10,7 +10,11 @@ import {
Html, Html,
} from '@react-three/drei'; } from '@react-three/drei';
import { useThree } from '@react-three/fiber'; import { useThree } from '@react-three/fiber';
import { useGLTF } from '@react-three/drei';
import { RobotModel } from './RobotModel'; import { RobotModel } from './RobotModel';
import { snapshotStore } from '@/store/useSnapshotStore';
useGLTF.setDecoderPath('/draco/');
import type { WebGLRenderer, Scene, Camera } from 'three'; import type { WebGLRenderer, Scene, Camera } from 'three';
function Loader() { function Loader() {
@ -70,6 +74,17 @@ export function RobotCanvas() {
glRef.current = gl; glRef.current = gl;
sceneRef.current = scene; sceneRef.current = scene;
cameraRef.current = camera; cameraRef.current = camera;
// Register a programmatic capture function for the checkout snapshot
snapshotStore.getState().registerCapture(() => {
if (!glRef.current || !sceneRef.current || !cameraRef.current) return null;
try {
glRef.current.render(sceneRef.current, cameraRef.current);
return glRef.current.domElement.toDataURL('image/jpeg', 0.75);
} catch {
return null;
}
});
}, []); }, []);
const handleShare = useCallback(async () => { const handleShare = useCallback(async () => {
@ -120,7 +135,7 @@ export function RobotCanvas() {
return ( return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}> <div style={{ position: 'relative', width: '100%', height: '100%' }}>
<Canvas <Canvas
dpr={[1, 2]} dpr={[1, 1.5]}
camera={{ position: [0, 1, 5], fov: 50 }} camera={{ position: [0, 1, 5], fov: 50 }}
gl={{ antialias: true, powerPreference: 'high-performance' }} gl={{ antialias: true, powerPreference: 'high-performance' }}
style={{ background: 'linear-gradient(180deg, #e8e8e4 0%, #f0f0ec 50%, #e8e8e4 100%)' }} style={{ background: 'linear-gradient(180deg, #e8e8e4 0%, #f0f0ec 50%, #e8e8e4 100%)' }}

View File

@ -1,19 +1,55 @@
'use client'; 'use client';
import { useRef, useMemo, useEffect, Suspense, useState } from 'react'; import { useRef, useMemo, useEffect, Suspense, useState, Component, type ReactNode } from 'react';
import { useGLTF } from '@react-three/drei'; import { useGLTF } from '@react-three/drei';
import { useFrame } from '@react-three/fiber'; import { useFrame } from '@react-three/fiber';
import * as THREE from 'three'; import * as THREE from 'three';
import { useConfigStore } from '@/store/useConfigStore'; import { useConfigStore } from '@/store/useConfigStore';
import { personaStore, usePersonaStore } from '@/store/usePersonaStore';
const ATTIRE_GLB: Record<string, string> = { // Configure Draco decoder so compressed .glb files load correctly
useGLTF.setDecoderPath('/draco/');
const STATIC_ATTIRE_GLB: Record<string, string> = {
'emarati-kandura': '/Kandoura.glb', 'emarati-kandura': '/Kandoura.glb',
'industrial-vest': '/Vest.glb', 'industrial-vest': '/Vest.glb',
'business-suit': '/Suit.glb', 'business-suit': '/Suit.glb',
}; };
// Preload all attire models so they're cached before user clicks // Attire models are loaded on-demand to avoid blocking the initial 50 MB robot load
Object.values(ATTIRE_GLB).forEach((path) => useGLTF.preload(path));
/** Merge static map with any custom GLBs stored in the persona store */
function buildAttireGlbMap(personas: { id: string; modelPath?: string }[]): Record<string, string> {
const dynamic: Record<string, string> = {};
personas.forEach((p) => {
if (p.modelPath && p.id !== 'none') dynamic[p.id] = p.modelPath;
});
return { ...STATIC_ATTIRE_GLB, ...dynamic }; // uploaded GLBs override static
}
interface AttireErrorBoundaryProps {
children: ReactNode;
onError: () => void;
}
interface AttireErrorBoundaryState { hasError: boolean; }
class AttireErrorBoundary extends Component<AttireErrorBoundaryProps, AttireErrorBoundaryState> {
constructor(props: AttireErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): AttireErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error) {
console.warn('[AttireModel] Failed to load GLB, falling back to base robot:', error.message);
this.props.onError();
}
render() {
if (this.state.hasError) return null;
return this.props.children;
}
}
function easeInOutCubic(t: number): number { function easeInOutCubic(t: number): number {
return t < 0.5 return t < 0.5
@ -22,6 +58,15 @@ function easeInOutCubic(t: number): number {
} }
function AttireModel({ glbPath, onLoaded }: { glbPath: string; onLoaded: () => void }) { function AttireModel({ glbPath, onLoaded }: { glbPath: string; onLoaded: () => void }) {
// Clear stale useGLTF cache entries for paths that share the same base filename
// (e.g. /models/robot-doctor.glb?v=1 replaced by ?v=2). This prevents Three.js
// from serving the old binary when the version query string changes.
useEffect(() => {
return () => {
useGLTF.clear(glbPath);
};
}, [glbPath]);
const { scene } = useGLTF(glbPath); const { scene } = useGLTF(glbPath);
const processedAttire = useMemo(() => { const processedAttire = useMemo(() => {
@ -78,6 +123,8 @@ export function RobotModel({ onError }: RobotModelProps) {
const activeColors = useConfigStore((state) => state.activeColors); const activeColors = useConfigStore((state) => state.activeColors);
const activePersonaAttire = useConfigStore((state) => state.activePersonaAttire); const activePersonaAttire = useConfigStore((state) => state.activePersonaAttire);
// Subscribe to persona store so custom GLB paths are reactive
const personas = usePersonaStore((s) => s.personas);
// Detect attire change and trigger spin // Detect attire change and trigger spin
useEffect(() => { useEffect(() => {
@ -174,7 +221,7 @@ export function RobotModel({ onError }: RobotModelProps) {
}); });
}, [activeColors]); }, [activeColors]);
const attireGlbPath = ATTIRE_GLB[displayedAttire] || null; const attireGlbPath = buildAttireGlbMap(personas)[displayedAttire] || null;
const handleAttireLoaded = () => { const handleAttireLoaded = () => {
setAttireReady(true); setAttireReady(true);
@ -185,16 +232,20 @@ export function RobotModel({ onError }: RobotModelProps) {
<primitive object={processedScene} /> <primitive object={processedScene} />
{attireGlbPath && ( {attireGlbPath && (
<Suspense fallback={null}> <AttireErrorBoundary key={attireGlbPath} onError={() => { setDisplayedAttire('none'); setAttireReady(false); }}>
<AttireModel <Suspense fallback={null}>
key={attireGlbPath} <AttireModel
glbPath={attireGlbPath} glbPath={attireGlbPath}
onLoaded={handleAttireLoaded} onLoaded={handleAttireLoaded}
/> />
</Suspense> </Suspense>
</AttireErrorBoundary>
)} )}
</group> </group>
); );
} }
useGLTF.preload('/Unitree_G1.glb'); useGLTF.preload('/Unitree_G1.glb');
// Preload uploaded attire in the background so they're ready before the user clicks
useGLTF.preload('/models/robot-doctor.glb');
useGLTF.preload('/models/security-guard.glb');

View File

@ -1,7 +1,8 @@
'use client'; 'use client';
import { useScroll, useTransform, motion } from 'framer-motion'; import { useScroll, useTransform, motion } from 'framer-motion';
import { ReactNode } from 'react'; import { ReactNode, useEffect, useState } from 'react';
import Link from 'next/link';
interface SectionProps { interface SectionProps {
children: ReactNode; children: ReactNode;
@ -12,6 +13,7 @@ interface SectionProps {
align: 'center' | 'left' | 'right'; align: 'center' | 'left' | 'right';
verticalAlign?: 'top' | 'center' | 'bottom'; verticalAlign?: 'top' | 'center' | 'bottom';
offsetY?: number; offsetY?: number;
className?: string;
} }
function OverlaySection({ function OverlaySection({
@ -23,6 +25,7 @@ function OverlaySection({
align, align,
verticalAlign = 'center', verticalAlign = 'center',
offsetY = 50, offsetY = 50,
className = '',
}: SectionProps) { }: SectionProps) {
// Define a wide "plateau" zone where the text is fully readable and static. // 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. // We use 30% of the travel distance for fading in, 30% for fading out.
@ -57,36 +60,41 @@ function OverlaySection({
const alignStyle: React.CSSProperties = const alignStyle: React.CSSProperties =
align === 'left' align === 'left'
? { left: 'clamp(2rem, 6vw, 6rem)', alignItems: 'flex-start' } ? { alignItems: 'flex-start' }
: align === 'right' : align === 'right'
? { right: 'clamp(2rem, 6vw, 6rem)', alignItems: 'flex-end' } ? { alignItems: 'flex-end' }
: { left: '50%', x: '-50%', alignItems: 'center' }; : { left: '50%', transform: 'translateX(-50%)', alignItems: 'center' };
const alignClass =
align === 'left'
? 'overlay-section-left'
: align === 'right'
? 'overlay-section-right'
: 'overlay-section-center';
const verticalStyle: React.CSSProperties = const verticalStyle: React.CSSProperties =
verticalAlign === 'top' verticalAlign === 'top'
? { top: '15vh' } ? { top: '15vh' }
: verticalAlign === 'bottom' : verticalAlign === 'bottom'
? { bottom: '15vh', top: 'auto' } ? { bottom: '15vh', top: 'auto' }
: { top: '50%', y: '-50%' }; : { top: '50%' };
// Glass panel appearance for side text to not clash with the robot // Glass panel appearance for side text to not clash with the robot
const isCenter = align === 'center'; const isCenter = align === 'center';
const panelStyle: React.CSSProperties = isCenter const panelStyle: React.CSSProperties = isCenter
? { textAlign: 'center' } ? { textAlign: 'center' }
: { : {
background: 'rgba(255, 255, 255, 0.45)', background: 'rgba(255, 255, 255, 0.92)',
backdropFilter: 'blur(16px)', backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(20px)',
padding: '2.5rem',
borderRadius: '1.5rem',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.08)', boxShadow: '0 8px 32px rgba(0, 0, 0, 0.08)',
border: '1px solid rgba(255, 255, 255, 0.6)', border: '1px solid rgba(255, 255, 255, 0.6)',
textAlign: align === 'left' ? 'left' : 'right', textAlign: align === 'left' ? 'left' : 'right',
maxWidth: '450px',
}; };
return ( return (
<motion.div <motion.div
className={`${alignClass} ${className}`.trim()}
style={{ style={{
position: 'absolute', position: 'absolute',
display: 'flex', display: 'flex',
@ -104,7 +112,7 @@ function OverlaySection({
scale, scale,
...panelStyle, ...panelStyle,
}} }}
className="will-change-transform" className={`will-change-transform ${isCenter ? '' : 'overlay-panel'}`}
> >
{children} {children}
</motion.div> </motion.div>
@ -118,12 +126,39 @@ const SECTION_CONFIGS = [
{ id: 'hero', startAt: 0.10, peakAt: 0.22, endAt: 0.35, align: 'left' as const, verticalAlign: 'center' 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: '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: '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 }, { id: 'mobility', startAt: 0.75, peakAt: 0.82, endAt: 0.90, align: 'right' as const, verticalAlign: 'center' as const },
]; ];
export function ScrollOverlays() { export function ScrollOverlays() {
const { scrollYProgress } = useScroll(); const { scrollYProgress } = useScroll();
// Dynamically load personas from the pricing API so any admin-added attire shows here
const [attireItems, setAttireItems] = useState<{ label: string; id: string }[]>([
{ label: 'Kandura', id: 'emarati-kandura' },
{ label: 'Vest', id: 'industrial-vest' },
{ label: 'Suit', id: 'business-suit' },
]);
useEffect(() => {
fetch('/api/admin/pricing/')
.then((r) => r.json())
.then((data) => {
const excluded = new Set(['base', 'custom-color', 'emarati-kandura', 'industrial-vest', 'business-suit']);
const extras: { label: string; id: string }[] = (data.items ?? [])
.filter((item: { id: string; label: string; modelPath?: string | null }) =>
!excluded.has(item.id) && item.modelPath
)
.map((item: { id: string; label: string }) => ({ label: item.label, id: item.id }));
setAttireItems([
{ label: 'Kandura', id: 'emarati-kandura' },
{ label: 'Vest', id: 'industrial-vest' },
{ label: 'Suit', id: 'business-suit' },
...extras,
]);
})
.catch(() => {});
}, []);
return ( return (
<div <div
style={{ style={{
@ -135,7 +170,7 @@ export function ScrollOverlays() {
}} }}
> >
{/* 1. Brand Intro */} {/* 1. Brand Intro */}
<OverlaySection progress={scrollYProgress} {...SECTION_CONFIGS[0]}> <OverlaySection progress={scrollYProgress} {...SECTION_CONFIGS[0]} className="overlay-brand">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '1rem', marginBottom: '1rem' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '1rem', marginBottom: '1rem' }}>
<div style={{ width: '30px', height: '1px', background: 'linear-gradient(90deg, transparent, #c4a265)' }} /> <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' }}> <span style={{ fontSize: '0.75rem', fontWeight: 600, color: '#c4a265', letterSpacing: '0.4em', textTransform: 'uppercase' }}>
@ -150,10 +185,10 @@ export function ScrollOverlays() {
{/* 2. Hero */} {/* 2. Hero */}
<OverlaySection progress={scrollYProgress} {...SECTION_CONFIGS[1]}> <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 }}> <motion.h1 className="overlay-hero-heading" style={{ fontWeight: 200, color: '#1a1a2e', lineHeight: 1.0, letterSpacing: '-0.04em', margin: 0 }}>
The Future The Future
</motion.h1> </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' }}> <motion.h1 className="overlay-hero-heading" style={{ fontWeight: 200, color: '#1a1a2e', lineHeight: 1.0, letterSpacing: '-0.04em', margin: '0.1em 0 0' }}>
of <span style={{ color: '#c4a265', fontWeight: 400 }}>Robotics</span> of <span style={{ color: '#c4a265', fontWeight: 400 }}>Robotics</span>
</motion.h1> </motion.h1>
<p style={{ fontSize: '1rem', color: '#475569', lineHeight: 1.7, margin: '1.5rem 0 0', fontWeight: 300 }}> <p style={{ fontSize: '1rem', color: '#475569', lineHeight: 1.7, margin: '1.5rem 0 0', fontWeight: 300 }}>
@ -168,7 +203,7 @@ export function ScrollOverlays() {
<div style={{ fontSize: '0.65rem', fontWeight: 600, color: '#c4a265', letterSpacing: '0.3em', textTransform: 'uppercase', marginBottom: '1rem' }}> <div style={{ fontSize: '0.65rem', fontWeight: 600, color: '#c4a265', letterSpacing: '0.3em', textTransform: 'uppercase', marginBottom: '1rem' }}>
Intelligent by Design Intelligent by Design
</div> </div>
<h2 style={{ fontSize: 'clamp(2rem, 3.5vw, 3rem)', fontWeight: 300, color: '#1a1a2e', lineHeight: 1.1, margin: '0 0 1rem', letterSpacing: '-0.03em' }}> <h2 className="overlay-heading" style={{ fontWeight: 300, color: '#1a1a2e', lineHeight: 1.1, margin: '0 0 1rem', letterSpacing: '-0.03em' }}>
Vision That<br />Understands Vision That<br />Understands
</h2> </h2>
<p style={{ fontSize: '0.95rem', color: '#475569', lineHeight: 1.6, margin: 0, fontWeight: 300 }}> <p style={{ fontSize: '0.95rem', color: '#475569', lineHeight: 1.6, margin: 0, fontWeight: 300 }}>
@ -176,11 +211,11 @@ export function ScrollOverlays() {
</p> </p>
<div style={{ display: 'flex', gap: '2.5rem', marginTop: '2rem' }}> <div style={{ display: 'flex', gap: '2.5rem', marginTop: '2rem' }}>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '1.8rem', fontWeight: 300, color: '#1a1a2e', fontFamily: 'monospace' }}>360°</div> <div className="overlay-stat" style={{ 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 style={{ fontSize: '0.65rem', color: '#64748b', letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: '0.5rem' }}>Field of View</div>
</div> </div>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '1.8rem', fontWeight: 300, color: '#1a1a2e', fontFamily: 'monospace' }}>{'<'}50ms</div> <div className="overlay-stat" style={{ 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 style={{ fontSize: '0.65rem', color: '#64748b', letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: '0.5rem' }}>Response Time</div>
</div> </div>
</div> </div>
@ -193,28 +228,56 @@ export function ScrollOverlays() {
<div style={{ fontSize: '0.65rem', fontWeight: 600, color: '#c4a265', letterSpacing: '0.3em', textTransform: 'uppercase', marginBottom: '1rem' }}> <div style={{ fontSize: '0.65rem', fontWeight: 600, color: '#c4a265', letterSpacing: '0.3em', textTransform: 'uppercase', marginBottom: '1rem' }}>
Your Identity Your Identity
</div> </div>
<h2 style={{ fontSize: 'clamp(2rem, 3.5vw, 3rem)', fontWeight: 300, color: '#1a1a2e', lineHeight: 1.1, margin: '0 0 1rem', letterSpacing: '-0.03em' }}> <h2 className="overlay-heading" style={{ fontWeight: 300, color: '#1a1a2e', lineHeight: 1.1, margin: '0 0 1rem', letterSpacing: '-0.03em' }}>
Dress for Any<br />Mission Dress for Any<br />Mission
</h2> </h2>
<p style={{ fontSize: '0.95rem', color: '#475569', lineHeight: 1.6, margin: 0, fontWeight: 300 }}> <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. From traditional Emarati Kandura to industrial safety gear and professional business attire. Configure every detail to match your brand.
</p> </p>
<div style={{ display: 'flex', gap: '1rem', marginTop: '2rem' }}> <div style={{ display: 'flex', gap: '1rem', marginTop: '2rem', flexWrap: 'wrap' }}>
{['Kandura', 'Vest', 'Suit'].map((label) => ( {attireItems.map((item) => (
<div <div
key={label} key={item.id}
onClick={() => {
import('@/store/useConfigStore').then(({ configStore }) => {
configStore.getState().setPersonaAttire(item.id);
});
}}
onMouseEnter={() => {
import('@/store/useConfigStore').then(({ configStore }) => {
configStore.getState().setPersonaAttire(item.id);
});
}}
style={{ style={{
padding: '0.5rem 1rem', padding: '0.6rem 1.25rem',
borderRadius: '2rem', borderRadius: '2rem',
background: 'rgba(196, 162, 101, 0.1)', background: 'rgba(255, 255, 255, 0.5)',
border: '1px solid rgba(196, 162, 101, 0.3)', border: '1px solid rgba(196, 162, 101, 0.3)',
fontSize: '0.75rem', fontSize: '0.75rem',
color: '#c4a265', color: '#1a1a2e',
letterSpacing: '0.1em', letterSpacing: '0.1em',
fontWeight: 500 fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.3s ease',
boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
pointerEvents: 'auto',
userSelect: 'none',
WebkitTapHighlightColor: 'transparent',
}}
onMouseOver={(e) => {
e.currentTarget.style.background = 'var(--color-gold)';
e.currentTarget.style.color = '#ffffff';
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 6px 16px rgba(196, 162, 101, 0.3)';
}}
onMouseOut={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.5)';
e.currentTarget.style.color = '#1a1a2e';
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.05)';
}} }}
> >
{label} {item.label}
</div> </div>
))} ))}
</div> </div>
@ -227,7 +290,7 @@ export function ScrollOverlays() {
<div style={{ fontSize: '0.65rem', fontWeight: 600, color: '#c4a265', letterSpacing: '0.3em', textTransform: 'uppercase', marginBottom: '1rem' }}> <div style={{ fontSize: '0.65rem', fontWeight: 600, color: '#c4a265', letterSpacing: '0.3em', textTransform: 'uppercase', marginBottom: '1rem' }}>
Advanced Mobility Advanced Mobility
</div> </div>
<h2 style={{ fontSize: 'clamp(2rem, 3.5vw, 3rem)', fontWeight: 300, color: '#1a1a2e', lineHeight: 1.1, margin: '0 0 1rem', letterSpacing: '-0.03em' }}> <h2 className="overlay-heading" style={{ fontWeight: 300, color: '#1a1a2e', lineHeight: 1.1, margin: '0 0 1rem', letterSpacing: '-0.03em' }}>
23 Degrees of<br />Freedom 23 Degrees of<br />Freedom
</h2> </h2>
<p style={{ fontSize: '0.95rem', color: '#475569', lineHeight: 1.6, margin: 0, fontWeight: 300 }}> <p style={{ fontSize: '0.95rem', color: '#475569', lineHeight: 1.6, margin: 0, fontWeight: 300 }}>
@ -235,19 +298,22 @@ export function ScrollOverlays() {
</p> </p>
<div style={{ display: 'flex', gap: '2.5rem', marginTop: '2rem' }}> <div style={{ display: 'flex', gap: '2.5rem', marginTop: '2rem' }}>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '1.8rem', fontWeight: 300, color: '#1a1a2e', fontFamily: 'monospace' }}>2m/s</div> <div className="overlay-stat" style={{ 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 style={{ fontSize: '0.65rem', color: '#64748b', letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: '0.5rem' }}>Max Speed</div>
</div> </div>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '1.8rem', fontWeight: 300, color: '#1a1a2e', fontFamily: 'monospace' }}>127kg</div> <div className="overlay-stat" style={{ 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 style={{ fontSize: '0.65rem', color: '#64748b', letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: '0.5rem' }}>Payload</div>
</div> </div>
</div> </div>
</div> </div>
</OverlaySection> </OverlaySection>
{/* Scroll indicator mapped to vanish rapidly when scrolled */} {/* Scroll indicator mapped to vanish rapidly when scrolled */}
<motion.div <motion.div
className="overlay-scroll-hint"
style={{ style={{
position: 'absolute', position: 'absolute',
bottom: '2.5rem', bottom: '2.5rem',

View File

@ -2,8 +2,11 @@
import React, { Suspense, useRef, useEffect } from 'react'; import React, { Suspense, useRef, useEffect } from 'react';
import { Canvas, useFrame, useThree } from '@react-three/fiber'; import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { Environment, ContactShadows, useGLTF, Html, useProgress } from '@react-three/drei'; import { Environment, ContactShadows, Html, useProgress, useGLTF } from '@react-three/drei';
import * as THREE from 'three'; import * as THREE from 'three';
import { RobotModel } from './RobotModel';
useGLTF.setDecoderPath('/draco/');
// Original camera keyframes - cinematic path with dramatic shots // Original camera keyframes - cinematic path with dramatic shots
const CAMERA_KEYFRAMES: [number, [number, number, number]][] = [ const CAMERA_KEYFRAMES: [number, [number, number, number]][] = [
@ -13,8 +16,8 @@ const CAMERA_KEYFRAMES: [number, [number, number, number]][] = [
[0.40, [0.3, 1.5, 2.0]], // HEAD+CHEST: tight, high angle looking down [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.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.68, [1.6, 0.7, 2.5]], // Dramatic right side sweep
[0.80, [0.3, 0.3, 2.2]], // Circle to front-low [0.80, [-1.8, 0.3, 3.0]], // Circle to front-left, pulled back to avoid text overlap
[0.92, [0, -0.3, 2.5]], // LOW ANGLE: heroic looking up [0.92, [-1.5, -0.1, 3.2]], // LOW ANGLE: heroic, offset left for right-side text
[1.0, [0, 1.2, 5.5]], // Final: pull back for configurator [1.0, [0, 1.2, 5.5]], // Final: pull back for configurator
]; ];
@ -60,8 +63,9 @@ function lerpScalar(a: number, b: number, t: number): number {
const scrollState = { progress: 0, inScrollZone: true }; const scrollState = { progress: 0, inScrollZone: true };
function ScrollCamera() { function ScrollCamera() {
const { camera } = useThree(); const { camera, size } = useThree();
const lookAtTarget = useRef(new THREE.Vector3(0, 0.5, 0)); const lookAtTarget = useRef(new THREE.Vector3(0, 0.5, 0));
const isMobile = size.width < 768;
useFrame(({ clock }) => { useFrame(({ clock }) => {
if (!scrollState.inScrollZone) return; if (!scrollState.inScrollZone) return;
@ -70,6 +74,10 @@ function ScrollCamera() {
const pos = interpolateKeyframes(CAMERA_KEYFRAMES, p); const pos = interpolateKeyframes(CAMERA_KEYFRAMES, p);
const lookAt = interpolateKeyframes(LOOKAT_KEYFRAMES, p); const lookAt = interpolateKeyframes(LOOKAT_KEYFRAMES, p);
// On mobile, center the camera more (reduce dramatic side-to-side movement)
const mobileZOffset = isMobile ? 1.0 : 0;
const mobileCenterX = isMobile ? pos.x * 0.6 : 0;
// Dynamic camera oscillation based on scroll position // Dynamic camera oscillation based on scroll position
const oscillationX = Math.sin(p * Math.PI * 4) * 0.05; const oscillationX = Math.sin(p * Math.PI * 4) * 0.05;
const oscillationY = Math.cos(p * Math.PI * 3) * 0.025; const oscillationY = Math.cos(p * Math.PI * 3) * 0.025;
@ -80,9 +88,9 @@ function ScrollCamera() {
// Apply oscillations to position // Apply oscillations to position
const adjustedPos = new THREE.Vector3( const adjustedPos = new THREE.Vector3(
pos.x + oscillationX + driftZone, pos.x - mobileCenterX + oscillationX + driftZone,
pos.y + oscillationY + verticalDrift, pos.y + oscillationY + verticalDrift,
pos.z pos.z + mobileZOffset
); );
// Adaptive lerp for smooth camera transitions // Adaptive lerp for smooth camera transitions
@ -220,46 +228,6 @@ function ScrollLighting() {
); );
} }
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() { function Loader() {
const { progress } = useProgress(); const { progress } = useProgress();
@ -294,7 +262,7 @@ function SceneContent() {
<ScrollLighting /> <ScrollLighting />
<LightOrbs /> <LightOrbs />
<RobotDisplay /> <RobotModel />
<ContactShadows position={[0, -1, 0]} opacity={0.25} scale={10} blur={2} far={4} resolution={256} color="#000000" /> <ContactShadows position={[0, -1, 0]} opacity={0.25} scale={10} blur={2} far={4} resolution={256} color="#000000" />
</> </>
); );
@ -354,3 +322,5 @@ export function ScrollScene({ scrollContainerRef }: ScrollSceneProps) {
} }
useGLTF.preload('/Unitree_G1.glb'); useGLTF.preload('/Unitree_G1.glb');
useGLTF.preload('/models/robot-doctor.glb');
useGLTF.preload('/models/security-guard.glb');

View File

@ -1,89 +1,33 @@
'use client'; 'use client';
import { useState, useCallback } from 'react'; import { useState } from 'react';
import { orderStore, type PaymentInfo } from '@/store/useOrderStore'; import { PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
import { orderStore } 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() { export function PaymentStep() {
const [form, setForm] = useState<PaymentInfo>({ const stripe = useStripe();
cardNumber: '', const elements = useElements();
expiry: '', const [errorMsg, setErrorMsg] = useState('');
cvv: '', const [isLoading, setIsLoading] = useState(false);
nameOnCard: '',
});
const [errors, setErrors] = useState<FormErrors>({});
const handleChange = useCallback((field: keyof PaymentInfo, value: string) => { const handleSubmit = async () => {
let processed = value; if (!stripe || !elements) return;
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 })); setIsLoading(true);
setErrors((prev) => { setErrorMsg('');
const next = { ...prev };
delete next[field];
return next;
});
}, []);
const validate = (): boolean => { // Validate the payment element first
const errs: FormErrors = {}; const { error: submitError } = await elements.submit();
const digits = form.cardNumber.replace(/\s/g, ''); if (submitError) {
if (digits.length < 16) errs.cardNumber = 'Enter a valid 16-digit card number'; setErrorMsg(submitError.message || 'Validation failed');
if (form.expiry.length < 5) errs.expiry = 'Enter a valid expiry (MM/YY)'; setIsLoading(false);
if (form.cvv.length < 3) errs.cvv = 'Enter a valid 3-digit CVV'; return;
if (!form.nameOnCard.trim()) errs.nameOnCard = 'Name on card is required'; }
setErrors(errs);
return Object.keys(errs).length === 0;
};
const handleSubmit = () => { // Move to review — actual confirmation happens on "Place Order"
if (!validate()) return; orderStore.getState().setPayment({ status: 'idle' });
orderStore.getState().setPayment(form);
orderStore.getState().setStep('review'); orderStore.getState().setStep('review');
setIsLoading(false);
}; };
return ( return (
@ -93,89 +37,48 @@ export function PaymentStep() {
</h3> </h3>
<div style={{ <div style={{
padding: '0.6rem 0.75rem', padding: '1rem',
borderRadius: '0.375rem', borderRadius: '0.5rem',
background: 'rgba(245, 158, 11, 0.06)', background: '#fff',
border: '1px solid rgba(245, 158, 11, 0.15)', border: '1px solid rgba(0, 0, 0, 0.08)',
fontSize: '0.7rem',
color: '#d97706',
}}> }}>
This is a demo checkout. No real payment will be processed. <PaymentElement
</div> options={{
layout: 'tabs',
<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>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}> {errorMsg && (
<div> <div style={{
<label style={labelStyle}>Expiry Date</label> padding: '0.6rem 0.75rem',
<input borderRadius: '0.375rem',
type="text" background: 'rgba(239, 68, 68, 0.06)',
value={form.expiry} border: '1px solid rgba(239, 68, 68, 0.15)',
onChange={(e) => handleChange('expiry', e.target.value)} fontSize: '0.75rem',
placeholder="MM/YY" color: '#dc2626',
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)'; }} {errorMsg}
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>
<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 <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!stripe || isLoading}
style={{ style={{
marginTop: '0.5rem', marginTop: '0.5rem',
padding: '0.75rem', padding: '0.75rem',
borderRadius: '0.375rem', borderRadius: '0.375rem',
border: '1px solid rgba(59, 130, 246, 0.5)', border: '1px solid rgba(59, 130, 246, 0.5)',
background: 'rgba(59, 130, 246, 0.08)', background: isLoading ? 'rgba(59, 130, 246, 0.15)' : 'rgba(59, 130, 246, 0.08)',
color: '#2563eb', color: '#2563eb',
cursor: 'pointer', cursor: isLoading ? 'wait' : 'pointer',
fontSize: '0.85rem', fontSize: '0.85rem',
fontWeight: 600, fontWeight: 600,
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
}} }}
> >
Review Order {isLoading ? 'Validating...' : 'Review Order'}
</button> </button>
</div> </div>
); );

View File

@ -1,23 +1,97 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { useStripe, useElements } from '@stripe/react-stripe-js';
import { useOrderStore, orderStore } from '@/store/useOrderStore'; import { useOrderStore, orderStore } from '@/store/useOrderStore';
import { snapshotStore } from '@/store/useSnapshotStore';
function formatAED(price: number): string { function formatAED(price: number): string {
return new Intl.NumberFormat('en-AE').format(price); return new Intl.NumberFormat('en-AE').format(price);
} }
export function ReviewStep() { export function ReviewStep() {
const stripe = useStripe();
const elements = useElements();
const shipping = useOrderStore((s) => s.shipping); const shipping = useOrderStore((s) => s.shipping);
const orderTotal = useOrderStore((s) => s.orderTotal); const orderTotal = useOrderStore((s) => s.orderTotal);
const personaSummary = useOrderStore((s) => s.personaSummary); const personaSummary = useOrderStore((s) => s.personaSummary);
const colorSummary = useOrderStore((s) => s.colorSummary); const colorSummary = useOrderStore((s) => s.colorSummary);
const clientSecret = useOrderStore((s) => s.payment.clientSecret);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [errorMsg, setErrorMsg] = useState('');
const handlePlaceOrder = async () => { const handlePlaceOrder = async () => {
if (!stripe || !elements || !clientSecret) return;
setIsProcessing(true); setIsProcessing(true);
// Simulate payment processing delay setErrorMsg('');
await new Promise((resolve) => setTimeout(resolve, 1500));
// Capture robot snapshot before payment (canvas still rendered behind overlay)
const snapshotDataUrl = snapshotStore.getState().capture();
const { error } = await stripe.confirmPayment({
elements,
clientSecret,
confirmParams: {
return_url: `${window.location.origin}?order=confirmed`,
receipt_email: shipping.email,
},
redirect: 'if_required',
});
if (error) {
setErrorMsg(error.message || 'Payment failed. Please try again.');
orderStore.getState().setPayment({ status: 'failed', errorMessage: error.message || '' });
setIsProcessing(false);
return;
}
// Payment succeeded — save order to DB and upload snapshot
const paymentIntentId = orderStore.getState().payment.paymentIntentId;
const s = orderStore.getState().shipping;
const configSummary = orderStore.getState().personaSummary;
const colorVal = orderStore.getState().colorSummary;
const priceItems = orderStore.getState().priceItems;
const total = orderStore.getState().orderTotal;
// Retrieve the resolved PaymentIntent to get final status + amount
const pi = await stripe.retrievePaymentIntent(clientSecret);
const piStatus = pi.paymentIntent?.status ?? 'succeeded';
const piAmount = pi.paymentIntent?.amount ?? Math.round(total * 100);
// Save order to DB directly (covers local dev where webhook can't reach localhost)
if (paymentIntentId) {
fetch('/api/orders/save/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
paymentIntentId,
amount: piAmount,
currency: 'aed',
status: piStatus,
customerName: s.name,
customerEmail: s.email,
customerPhone: s.phone,
customerAddress: s.address,
customerCity: s.city,
customerCountry: s.country,
customerPostalCode: s.postalCode,
persona: configSummary,
color: colorVal,
priceItems: JSON.stringify(priceItems),
}),
}).catch(() => {});
}
if (snapshotDataUrl && paymentIntentId) {
fetch('/api/snapshots/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paymentIntentId, imageData: snapshotDataUrl }),
}).catch(() => {}); // fire-and-forget
}
orderStore.getState().setPayment({ status: 'succeeded' });
orderStore.getState().placeOrder(); orderStore.getState().placeOrder();
}; };
@ -69,10 +143,24 @@ export function ReviewStep() {
</div> </div>
</SummarySection> </SummarySection>
{/* Error message */}
{errorMsg && (
<div style={{
padding: '0.6rem 0.75rem',
borderRadius: '0.375rem',
background: 'rgba(239, 68, 68, 0.06)',
border: '1px solid rgba(239, 68, 68, 0.15)',
fontSize: '0.75rem',
color: '#dc2626',
}}>
{errorMsg}
</div>
)}
{/* Place Order Button */} {/* Place Order Button */}
<button <button
onClick={handlePlaceOrder} onClick={handlePlaceOrder}
disabled={isProcessing} disabled={isProcessing || !stripe}
style={{ style={{
marginTop: '0.25rem', marginTop: '0.25rem',
padding: '0.85rem', padding: '0.85rem',

17
src/lib/prisma.ts Normal file
View File

@ -0,0 +1,17 @@
import { PrismaClient } from '@/generated/prisma/client';
import { PrismaLibSql } from '@prisma/adapter-libsql';
import path from 'path';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
function createPrismaClient() {
// Use DATABASE_URL if set (production/Coolify), otherwise fall back to local path.
const dbUrl = process.env.DATABASE_URL
?? `file:${path.resolve(process.cwd(), 'prisma/lootah.db')}`;
const adapter = new PrismaLibSql({ url: dbUrl });
return new PrismaClient({ adapter } as ConstructorParameters<typeof PrismaClient>[0]);
}
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

34
src/proxy.ts Normal file
View File

@ -0,0 +1,34 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// Allow login page through
if (pathname.startsWith('/admin/login')) return NextResponse.next();
const token = request.cookies.get('admin_token')?.value;
if (!token) {
return NextResponse.redirect(new URL('/admin/login/', request.url));
}
const jwtSecret = process.env.ADMIN_JWT_SECRET;
if (!jwtSecret) {
return NextResponse.redirect(new URL('/admin/login/', request.url));
}
try {
const secret = new TextEncoder().encode(jwtSecret);
await jwtVerify(token, secret);
return NextResponse.next();
} catch {
const response = NextResponse.redirect(new URL('/admin/login/', request.url));
response.cookies.delete('admin_token');
return response;
}
}
export const config = {
matcher: ['/admin/:path*'],
};

View File

@ -20,8 +20,9 @@ describe('useOrderStore', () => {
it('should have empty payment info', () => { it('should have empty payment info', () => {
const { payment } = orderStore.getState(); const { payment } = orderStore.getState();
expect(payment.cardNumber).toBe(''); expect(payment.paymentIntentId).toBe('');
expect(payment.cvv).toBe(''); expect(payment.clientSecret).toBe('');
expect(payment.status).toBe('idle');
}); });
it('should have no order ID', () => { it('should have no order ID', () => {
@ -69,16 +70,15 @@ describe('useOrderStore', () => {
describe('setPayment', () => { describe('setPayment', () => {
it('should store payment information', () => { it('should store payment information', () => {
orderStore.getState().setPayment({ orderStore.getState().setPayment({
cardNumber: '4242 4242 4242 4242', paymentIntentId: 'pi_test_123',
expiry: '12/28', clientSecret: 'pi_test_123_secret_abc',
cvv: '123', status: 'succeeded',
nameOnCard: 'John Doe',
}); });
const { payment } = orderStore.getState(); const { payment } = orderStore.getState();
expect(payment.cardNumber).toBe('4242 4242 4242 4242'); expect(payment.paymentIntentId).toBe('pi_test_123');
expect(payment.expiry).toBe('12/28'); expect(payment.clientSecret).toBe('pi_test_123_secret_abc');
expect(payment.nameOnCard).toBe('John Doe'); expect(payment.status).toBe('succeeded');
}); });
}); });

View File

@ -14,10 +14,15 @@ export interface ShippingInfo {
} }
export interface PaymentInfo { export interface PaymentInfo {
cardNumber: string; paymentIntentId: string;
expiry: string; clientSecret: string;
cvv: string; status: 'idle' | 'processing' | 'succeeded' | 'failed';
nameOnCard: string; errorMessage: string;
}
export interface PriceLineItem {
label: string;
price: number;
} }
export interface OrderState { export interface OrderState {
@ -28,14 +33,16 @@ export interface OrderState {
orderTotal: number; orderTotal: number;
personaSummary: string; personaSummary: string;
colorSummary: string; colorSummary: string;
priceItems: PriceLineItem[];
} }
export interface OrderActions { export interface OrderActions {
setStep: (step: CheckoutStep) => void; setStep: (step: CheckoutStep) => void;
setShipping: (shipping: ShippingInfo) => void; setShipping: (shipping: ShippingInfo) => void;
setPayment: (payment: PaymentInfo) => void; setPayment: (payment: Partial<PaymentInfo>) => void;
setOrderTotal: (total: number) => void; setOrderTotal: (total: number) => void;
setConfigSummary: (persona: string, color: string) => void; setConfigSummary: (persona: string, color: string, priceItems?: PriceLineItem[]) => void;
createPaymentIntent: () => Promise<string | null>;
placeOrder: () => void; placeOrder: () => void;
resetOrder: () => void; resetOrder: () => void;
} }
@ -53,10 +60,10 @@ const emptyShipping: ShippingInfo = {
}; };
const emptyPayment: PaymentInfo = { const emptyPayment: PaymentInfo = {
cardNumber: '', paymentIntentId: '',
expiry: '', clientSecret: '',
cvv: '', status: 'idle',
nameOnCard: '', errorMessage: '',
}; };
const defaultState: OrderState = { const defaultState: OrderState = {
@ -67,6 +74,7 @@ const defaultState: OrderState = {
orderTotal: 0, orderTotal: 0,
personaSummary: '', personaSummary: '',
colorSummary: '', colorSummary: '',
priceItems: [],
}; };
function generateOrderId(): string { function generateOrderId(): string {
@ -82,15 +90,60 @@ export const orderStore = createStore<OrderStore>((set) => ({
setShipping: (shipping: ShippingInfo) => set({ shipping }), setShipping: (shipping: ShippingInfo) => set({ shipping }),
setPayment: (payment: PaymentInfo) => set({ payment }), setPayment: (payment: Partial<PaymentInfo>) => set((state) => ({
payment: { ...state.payment, ...payment },
})),
setOrderTotal: (total: number) => set({ orderTotal: total }), setOrderTotal: (total: number) => set({ orderTotal: total }),
setConfigSummary: (persona: string, color: string) => set({ setConfigSummary: (persona: string, color: string, priceItems: PriceLineItem[] = []) => set({
personaSummary: persona, personaSummary: persona,
colorSummary: color, colorSummary: color,
priceItems,
}), }),
createPaymentIntent: async (): Promise<string | null> => {
const { orderTotal, personaSummary, colorSummary, shipping, priceItems } = orderStore.getState();
try {
const res: Response = await fetch('/api/create-payment-intent/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: orderTotal,
currency: 'aed',
receiptEmail: shipping.email || undefined,
metadata: {
persona: personaSummary,
color: colorSummary,
priceItems: JSON.stringify(priceItems),
customerName: shipping.name,
customerEmail: shipping.email,
customerPhone: shipping.phone,
customerAddress: shipping.address,
customerCity: shipping.city,
customerCountry: shipping.country,
customerPostalCode: shipping.postalCode,
},
}),
});
const data: { clientSecret: string; paymentIntentId: string; error?: string } = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to create payment intent');
set({
payment: {
paymentIntentId: data.paymentIntentId,
clientSecret: data.clientSecret,
status: 'idle',
errorMessage: '',
},
});
return data.clientSecret;
} catch (err) {
const msg = err instanceof Error ? err.message : 'Payment initialization failed';
set((s) => ({ payment: { ...s.payment, status: 'failed', errorMessage: msg } }));
return null;
}
},
placeOrder: () => set({ placeOrder: () => set({
orderId: generateOrderId(), orderId: generateOrderId(),
step: 'confirmed', step: 'confirmed',

View File

@ -0,0 +1,209 @@
import { createStore } from 'zustand/vanilla';
import { useSyncExternalStore } from 'react';
export interface PersonaOption {
id: string;
label: string;
description: string;
colors: { torso: string; legs: string };
modelPath?: string;
}
export interface PersonaState {
personas: PersonaOption[];
isHydrated: boolean;
}
export interface PersonaActions {
addPersona: (persona: Omit<PersonaOption, 'id'> & { id?: string }) => void;
removePersona: (id: string) => void;
updatePersona: (id: string, updates: Partial<Omit<PersonaOption, 'id'>>) => void;
resetPersonas: () => void;
hydrate: () => void;
}
export type PersonaStore = PersonaState & PersonaActions;
export const DEFAULT_PERSONAS: PersonaOption[] = [
{
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' },
},
];
const STORAGE_KEY = 'lootah-personas';
function loadFromStorage(): PersonaOption[] | 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(personas: PersonaOption[]) {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(personas));
} catch {
// Storage unavailable
}
}
function generateId(): string {
return `persona-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}
export const personaStore = createStore<PersonaStore>((set, get) => ({
personas: DEFAULT_PERSONAS,
isHydrated: false,
addPersona: (persona) => {
const newPersona: PersonaOption = {
id: persona.id ?? generateId(),
label: persona.label,
description: persona.description,
colors: persona.colors,
...(persona.modelPath ? { modelPath: persona.modelPath } : {}),
};
set((state) => {
const updated = [...state.personas, newPersona];
saveToStorage(updated);
return { personas: updated };
});
},
removePersona: (id) => {
// Prevent removing the default 'none' persona
if (id === 'none') return;
set((state) => {
const updated = state.personas.filter((p) => p.id !== id);
saveToStorage(updated);
return { personas: updated };
});
},
updatePersona: (id, updates) => {
set((state) => {
const updated = state.personas.map((p) =>
p.id === id ? { ...p, ...updates } : p
);
saveToStorage(updated);
return { personas: updated };
});
},
resetPersonas: () => {
saveToStorage(DEFAULT_PERSONAS);
set({ personas: [...DEFAULT_PERSONAS] });
},
hydrate: () => {
// Guard: only hydrate once — prevents race condition duplicates when
// called from multiple components at the same time.
if (get().isHydrated) return;
set({ isHydrated: true });
const raw = loadFromStorage();
// Deduplicate stored personas (keep last occurrence of each id)
const deduped = raw
? [...new Map(raw.map((p) => [p.id, p])).values()]
: null;
if (deduped && deduped.length > 0) {
// Only re-inject truly built-in personas (those still in DEFAULT_PERSONAS) if missing.
// Dynamic/uploaded personas that were deleted via the dashboard must NOT be re-added.
const storedIds = new Set(deduped.map((s) => s.id));
const missing = DEFAULT_PERSONAS.filter((d) => !storedIds.has(d.id));
set({ personas: [...deduped, ...missing] });
} else {
set({ personas: [...DEFAULT_PERSONAS] });
}
// Fetch pricing items from server DB and auto-register personas for all attire items
if (typeof window !== 'undefined') {
fetch('/api/admin/pricing/')
.then((r) => r.json())
.then((data) => {
const serverItems: { id: string; label: string; modelPath: string | null }[] = data.items ?? [];
const current = get().personas;
const currentIds = new Set(current.map((p) => p.id));
const newPersonas: PersonaOption[] = [];
// Items that should not appear as selectable personas
const excludeIds = new Set(['base', 'custom-color']);
serverItems.forEach(({ id, label, modelPath }) => {
if (excludeIds.has(id)) return;
if (currentIds.has(id)) {
// Update modelPath if it changed
const existing = current.find((p) => p.id === id);
if (existing && modelPath && existing.modelPath !== modelPath) {
set((state) => ({
personas: state.personas.map((p) =>
p.id === id ? { ...p, modelPath } : p
),
}));
}
} else {
// Auto-create a persona entry for every pricing item
newPersonas.push({
id,
label,
description: label,
colors: { torso: '#3b82f6', legs: '#3b82f6' },
...(modelPath ? { modelPath } : {}),
});
}
});
if (newPersonas.length > 0) {
set((state) => {
// Deduplicate: merge by id, new personas take precedence for modelPath
const merged = new Map(state.personas.map((p) => [p.id, p]));
newPersonas.forEach((p) => { if (!merged.has(p.id)) merged.set(p.id, p); });
const updated = [...merged.values()];
saveToStorage(updated);
return { personas: updated };
});
} else {
// Save current state to localStorage so it persists
saveToStorage(get().personas);
}
})
.catch(() => {}); // silent — use local data as fallback
}
},
}));
export const usePersonaStore = <T>(selector: (state: PersonaStore) => T): T => {
return useSyncExternalStore(
personaStore.subscribe,
() => selector(personaStore.getState()),
() => selector(personaStore.getState())
);
};

View File

@ -5,6 +5,7 @@ export interface PricingItem {
id: string; id: string;
label: string; label: string;
price: number; price: number;
modelPath?: string;
} }
export interface PricingState { export interface PricingState {
@ -14,6 +15,9 @@ export interface PricingState {
export interface PricingActions { export interface PricingActions {
updatePrice: (itemId: string, newPrice: number) => void; updatePrice: (itemId: string, newPrice: number) => void;
updateItem: (itemId: string, updates: Partial<Pick<PricingItem, 'label' | 'price' | 'modelPath'>>) => void;
addItem: (item: PricingItem) => void;
removeItem: (itemId: string) => void;
resetPrices: () => void; resetPrices: () => void;
hydrate: () => void; hydrate: () => void;
} }
@ -26,6 +30,8 @@ const DEFAULT_ITEMS: PricingItem[] = [
{ id: 'industrial-vest', label: 'Industrial Vest', price: 8500 }, { id: 'industrial-vest', label: 'Industrial Vest', price: 8500 },
{ id: 'business-suit', label: 'Business Suit', price: 12000 }, { id: 'business-suit', label: 'Business Suit', price: 12000 },
{ id: 'custom-color', label: 'Custom Color', price: 3500 }, { id: 'custom-color', label: 'Custom Color', price: 3500 },
{ id: 'robot-doctor', label: 'Robot Doctor', price: 5000 },
{ id: 'security-guard', label: 'Security Guard', price: 5000 },
]; ];
const STORAGE_KEY = 'lootah-pricing'; const STORAGE_KEY = 'lootah-pricing';
@ -66,23 +72,81 @@ export const pricingStore = createStore<PricingStore>((set, get) => ({
}); });
}, },
updateItem: (itemId: string, updates: Partial<Pick<PricingItem, 'label' | 'price' | 'modelPath'>>) => {
set((state) => {
const updated = state.items.map((item) =>
item.id === itemId ? { ...item, ...updates } : item
);
saveToStorage(updated);
return { items: updated };
});
},
resetPrices: () => { resetPrices: () => {
saveToStorage(DEFAULT_ITEMS); saveToStorage(DEFAULT_ITEMS);
set({ items: [...DEFAULT_ITEMS] }); set({ items: [...DEFAULT_ITEMS] });
}, },
addItem: (item: PricingItem) => {
set((state) => {
if (state.items.some((i) => i.id === item.id)) return state;
const updated = [...state.items, item];
saveToStorage(updated);
return { items: updated };
});
},
removeItem: (itemId: string) => {
// Prevent removing the base robot price
if (itemId === 'base') return;
set((state) => {
const updated = state.items.filter((i) => i.id !== itemId);
saveToStorage(updated);
return { items: updated };
});
},
hydrate: () => { hydrate: () => {
const stored = loadFromStorage(); const stored = loadFromStorage();
if (stored) { if (stored && stored.length > 0) {
// Merge stored prices with defaults (in case new items were added) // Use stored items directly (preserves custom labels, prices, added items).
const merged = DEFAULT_ITEMS.map((def) => { // Re-add any default items that were never stored (fresh install gap).
const found = stored.find((s) => s.id === def.id); const storedIds = new Set(stored.map((s) => s.id));
return found ? { ...def, price: found.price } : def; const missing = DEFAULT_ITEMS.filter((d) => !storedIds.has(d.id));
}); set({ items: [...stored, ...missing], isHydrated: true });
set({ items: merged, isHydrated: true });
} else { } else {
set({ isHydrated: true }); set({ isHydrated: true });
} }
// Also fetch from server DB to get the latest pricing (async, non-blocking)
if (typeof window !== 'undefined') {
fetch('/api/admin/pricing/')
.then((r) => r.json())
.then((data) => {
const serverItems: PricingItem[] = data.items ?? [];
if (serverItems.length > 0) {
// Merge: server wins for label/price, but keep local modelPath if server doesn't have one
const localItems = get().items;
const localMap = new Map(localItems.map((l) => [l.id, l]));
const merged: PricingItem[] = serverItems.map((s) => {
const local = localMap.get(s.id);
return {
...s,
modelPath: s.modelPath || local?.modelPath,
};
});
// Also keep any local-only items not in server
for (const local of localItems) {
if (!serverItems.some((s) => s.id === local.id)) {
merged.push(local);
}
}
saveToStorage(merged);
set({ items: merged });
}
})
.catch(() => {}); // silent — use local data as fallback
}
}, },
})); }));

View File

@ -0,0 +1,32 @@
import { createStore } from 'zustand/vanilla';
interface SnapshotStore {
_captureFn: (() => string | null) | null;
cachedSnapshot: string | null;
registerCapture: (fn: () => string | null) => void;
capture: () => string | null;
cacheSnapshot: () => void;
}
export const snapshotStore = createStore<SnapshotStore>((set, get) => ({
_captureFn: null,
cachedSnapshot: null,
registerCapture: (fn) => set({ _captureFn: fn }),
capture: () => {
// Return cached snapshot if available (taken before overlay opened)
const cached = get().cachedSnapshot;
if (cached) return cached;
const fn = get()._captureFn;
return fn ? fn() : null;
},
cacheSnapshot: () => {
const fn = get()._captureFn;
if (fn) {
const data = fn();
if (data) set({ cachedSnapshot: data });
}
},
}));

7
src/types/draco3dgltf.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
declare module 'draco3dgltf' {
const draco3d: {
createDecoderModule(): Promise<unknown>;
createEncoderModule(): Promise<unknown>;
};
export default draco3d;
}

18
web/README.txt Normal file
View File

@ -0,0 +1,18 @@
Add this to your HTML <head>:
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
Add this to your app's manifest.json:
...
{
"icons": [
{ "src": "/favicon.ico", "type": "image/x-icon", "sizes": "16x16 32x32" },
{ "src": "/icon-192.png", "type": "image/png", "sizes": "192x192" },
{ "src": "/icon-512.png", "type": "image/png", "sizes": "512x512" },
{ "src": "/icon-192-maskable.png", "type": "image/png", "sizes": "192x192", "purpose": "maskable" },
{ "src": "/icon-512-maskable.png", "type": "image/png", "sizes": "512x512", "purpose": "maskable" }
]
}
...

BIN
web/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
web/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

BIN
web/icon-192-maskable.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
web/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
web/icon-512-maskable.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
web/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB