Compare commits

..

No commits in common. "main" and "main" have entirely different histories.
main ... main

94 changed files with 495 additions and 9329 deletions

View File

@ -1,23 +0,0 @@
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/

View File

@ -1,18 +0,0 @@
# ─────────────────────────────────────────────────────────────────────────────
# 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

@ -1,116 +0,0 @@
# 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,5 +35,3 @@ next-env.d.ts
# IDE # IDE
.idea .idea
.vscode .vscode
/src/generated/prisma

View File

@ -1,127 +0,0 @@
# 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` (الصفحة الرئيسية)

View File

@ -1,60 +0,0 @@
# ── 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
View File

View File

@ -1,16 +0,0 @@
#!/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: 'standalone', output: 'export',
images: { images: {
unoptimized: true, unoptimized: true,
}, },

4336
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,49 +11,32 @@
"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"

View File

@ -1,13 +0,0 @@
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",
},
});

Binary file not shown.

View File

@ -1,67 +0,0 @@
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())
}

View File

@ -1,51 +0,0 @@
'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(); });

View File

@ -1,80 +0,0 @@
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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

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

@ -1,116 +0,0 @@
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);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,172 +0,0 @@
'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

@ -1,37 +0,0 @@
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

@ -1,31 +0,0 @@
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

@ -1,39 +0,0 @@
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

@ -1,49 +0,0 @@
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

@ -1,13 +0,0 @@
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

@ -1,57 +0,0 @@
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

@ -1,91 +0,0 @@
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

@ -1,69 +0,0 @@
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

@ -1,33 +0,0 @@
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

@ -1,66 +0,0 @@
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

@ -1,78 +0,0 @@
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

@ -1,26 +0,0 @@
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

@ -1,34 +0,0 @@
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

@ -1,39 +0,0 @@
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

@ -1,63 +0,0 @@
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

@ -1,33 +0,0 @@
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

@ -1,58 +0,0 @@
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

@ -1,20 +0,0 @@
'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 />
</>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@ -200,57 +200,22 @@ html {
@media (max-width: 768px) { @media (max-width: 768px) {
.layout-container { .layout-container {
flex-direction: column-reverse !important; flex-direction: column !important;
height: 100dvh !important;
} }
.glass-panel-responsive { .glass-panel-responsive {
order: unset !important;
position: relative !important;
bottom: auto !important;
left: auto !important;
right: auto !important;
width: 100% !important;
height: 55dvh !important;
max-height: 55dvh !important;
border-right: none !important;
border-left: none !important;
border-top: 1px solid var(--color-border) !important;
box-shadow: 0 -4px 30px rgba(0, 0, 0, 0.06) !important;
border-radius: 1rem 1rem 0 0 !important;
overflow-y: auto !important;
overflow-x: hidden !important;
z-index: 50;
padding-bottom: env(safe-area-inset-bottom, 0px) !important;
}
/* Expanded (fullscreen) state overrides */
.glass-panel-responsive.panel-expanded {
position: fixed !important; position: fixed !important;
top: 0 !important; bottom: 0 !important;
left: 0 !important; left: 0 !important;
right: 0 !important;
width: 100% !important; width: 100% !important;
height: 100dvh !important; height: 50vh !important;
max-height: 100dvh !important; border-left: none !important;
border-radius: 0 !important; border-right: none !important;
border-top: none !important; border-top: 1px solid var(--color-border) !important;
box-shadow: none !important; box-shadow: 0 -4px 40px rgba(0, 0, 0, 0.08) !important;
z-index: 200 !important; border-radius: 1rem 1rem 0 0 !important;
overflow-y: auto !important; z-index: 50;
-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 {
@ -258,286 +223,6 @@ 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: "Lootah Robotics | G1 Configurator", title: "G1 Configurator | Lootah Robotics",
description: "3D Configurator for the G1 Robot by Lootah Robotics", description: "3D Configurator for the G1 Robot by Lootah Robotics",
}; };
@ -22,26 +22,13 @@ 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> <I18nProvider>{children}</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,16 +4,13 @@ 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 { FooterAndContact } from "@/components/FooterAndContact"; import { ConfiguratorSection } from "@/components/ConfiguratorSection";
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} />
@ -33,8 +30,9 @@ export default function HomePage() {
<div className="snap-section" /> <div className="snap-section" />
</div> </div>
<div className="snap-section" style={{ scrollSnapAlign: 'start' }}> {/* Configurator section */}
<FooterAndContact /> <div style={{ position: "relative", zIndex: 20 }}>
<ConfiguratorSection />
</div> </div>
</> </>
); );

View File

@ -1,51 +0,0 @@
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 />
</>
);
}

View File

@ -1,32 +0,0 @@
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

@ -1,47 +0,0 @@
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,16 +1,12 @@
'use client'; 'use client';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect } 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' },
@ -23,10 +19,6 @@ 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();
@ -41,16 +33,6 @@ 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;
@ -171,55 +153,8 @@ 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' || step === 'review') && clientSecret ? ( {step === 'payment' && <PaymentStep />}
<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 />} {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,51 +1,50 @@
'use client'; 'use client';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useRef } 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(() => {
@ -81,7 +80,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' }}>
{personas.map((persona) => { {PERSONA_OPTIONS.map((persona) => {
const isActive = activePersona === persona.id; const isActive = activePersona === persona.id;
return ( return (
<button <button
@ -145,16 +144,8 @@ export function ConfigPanel() {
</div> </div>
</div> </div>
{/* Active checkmark or loading spinner */} {/* Active checkmark */}
{isActive && loadingPersona === persona.id ? ( {isActive && (
<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',
@ -169,7 +160,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,6 +1,5 @@
'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';
@ -11,7 +10,6 @@ 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
@ -25,7 +23,7 @@ export function ConfiguratorSection() {
}} }}
> >
<div <div
className={`layout-container${panelExpanded ? ' layout-expanded' : ''}`} className="layout-container"
style={{ style={{
display: 'flex', display: 'flex',
width: '100%', width: '100%',
@ -34,8 +32,9 @@ export function ConfiguratorSection() {
}} }}
> >
<aside <aside
className={`glass-panel-responsive${panelExpanded ? ' panel-expanded' : ''}`} className="glass-panel-responsive"
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)',
@ -46,39 +45,28 @@ export function ConfiguratorSection() {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
position: 'relative', position: 'relative',
overflowX: 'hidden', overflow: '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>
@ -106,7 +94,7 @@ export function ConfiguratorSection() {
style={{ style={{
flex: 1, flex: 1,
padding: '1.5rem', padding: '1.5rem',
overflowY: 'visible', overflowY: 'auto',
position: 'relative', position: 'relative',
zIndex: 1, zIndex: 1,
}} }}
@ -118,7 +106,6 @@ export function ConfiguratorSection() {
</aside> </aside>
<main <main
className="canvas-area"
style={{ style={{
flex: 1, flex: 1,
display: 'flex', display: 'flex',

View File

@ -1,378 +0,0 @@
'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>
);
}

View File

@ -1,216 +0,0 @@
'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,7 +4,6 @@ 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';
@ -32,20 +31,11 @@ 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,11 +10,7 @@ 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() {
@ -74,17 +70,6 @@ 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 () => {
@ -135,7 +120,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, 1.5]} dpr={[1, 2]}
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,55 +1,19 @@
'use client'; 'use client';
import { useRef, useMemo, useEffect, Suspense, useState, Component, type ReactNode } from 'react'; import { useRef, useMemo, useEffect, Suspense, useState } 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';
// Configure Draco decoder so compressed .glb files load correctly const ATTIRE_GLB: Record<string, string> = {
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',
}; };
// Attire models are loaded on-demand to avoid blocking the initial 50 MB robot load // Preload all attire models so they're cached before user clicks
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
@ -58,15 +22,6 @@ 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(() => {
@ -123,8 +78,6 @@ 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(() => {
@ -221,7 +174,7 @@ export function RobotModel({ onError }: RobotModelProps) {
}); });
}, [activeColors]); }, [activeColors]);
const attireGlbPath = buildAttireGlbMap(personas)[displayedAttire] || null; const attireGlbPath = ATTIRE_GLB[displayedAttire] || null;
const handleAttireLoaded = () => { const handleAttireLoaded = () => {
setAttireReady(true); setAttireReady(true);
@ -232,20 +185,16 @@ export function RobotModel({ onError }: RobotModelProps) {
<primitive object={processedScene} /> <primitive object={processedScene} />
{attireGlbPath && ( {attireGlbPath && (
<AttireErrorBoundary key={attireGlbPath} onError={() => { setDisplayedAttire('none'); setAttireReady(false); }}>
<Suspense fallback={null}> <Suspense fallback={null}>
<AttireModel <AttireModel
key={attireGlbPath}
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,8 +1,7 @@
'use client'; 'use client';
import { useScroll, useTransform, motion } from 'framer-motion'; import { useScroll, useTransform, motion } from 'framer-motion';
import { ReactNode, useEffect, useState } from 'react'; import { ReactNode } from 'react';
import Link from 'next/link';
interface SectionProps { interface SectionProps {
children: ReactNode; children: ReactNode;
@ -13,7 +12,6 @@ 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({
@ -25,7 +23,6 @@ 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.
@ -60,41 +57,36 @@ function OverlaySection({
const alignStyle: React.CSSProperties = const alignStyle: React.CSSProperties =
align === 'left' align === 'left'
? { alignItems: 'flex-start' } ? { left: 'clamp(2rem, 6vw, 6rem)', alignItems: 'flex-start' }
: align === 'right' : align === 'right'
? { alignItems: 'flex-end' } ? { right: 'clamp(2rem, 6vw, 6rem)', alignItems: 'flex-end' }
: { left: '50%', transform: 'translateX(-50%)', alignItems: 'center' }; : { left: '50%', x: '-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%' }; : { top: '50%', y: '-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.92)', background: 'rgba(255, 255, 255, 0.45)',
backdropFilter: 'blur(20px)', backdropFilter: 'blur(16px)',
WebkitBackdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(16px)',
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',
@ -112,7 +104,7 @@ function OverlaySection({
scale, scale,
...panelStyle, ...panelStyle,
}} }}
className={`will-change-transform ${isCenter ? '' : 'overlay-panel'}`} className="will-change-transform"
> >
{children} {children}
</motion.div> </motion.div>
@ -126,39 +118,12 @@ 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.75, peakAt: 0.82, endAt: 0.90, align: 'right' as const, verticalAlign: 'center' as const }, { id: 'mobility', startAt: 0.80, peakAt: 0.90, endAt: 1.0, align: 'right' as const, verticalAlign: 'center' as const },
]; ];
export function ScrollOverlays() { 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={{
@ -170,7 +135,7 @@ export function ScrollOverlays() {
}} }}
> >
{/* 1. Brand Intro */} {/* 1. Brand Intro */}
<OverlaySection progress={scrollYProgress} {...SECTION_CONFIGS[0]} className="overlay-brand"> <OverlaySection progress={scrollYProgress} {...SECTION_CONFIGS[0]}>
<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' }}>
@ -185,10 +150,10 @@ export function ScrollOverlays() {
{/* 2. Hero */} {/* 2. Hero */}
<OverlaySection progress={scrollYProgress} {...SECTION_CONFIGS[1]}> <OverlaySection progress={scrollYProgress} {...SECTION_CONFIGS[1]}>
<motion.h1 className="overlay-hero-heading" style={{ fontWeight: 200, color: '#1a1a2e', lineHeight: 1.0, letterSpacing: '-0.04em', margin: 0 }}> <motion.h1 style={{ fontSize: 'clamp(2.5rem, 5vw, 4.5rem)', fontWeight: 200, color: '#1a1a2e', lineHeight: 1.0, letterSpacing: '-0.04em', margin: 0 }}>
The Future The Future
</motion.h1> </motion.h1>
<motion.h1 className="overlay-hero-heading" style={{ fontWeight: 200, color: '#1a1a2e', lineHeight: 1.0, letterSpacing: '-0.04em', margin: '0.1em 0 0' }}> <motion.h1 style={{ fontSize: 'clamp(2.5rem, 5vw, 4.5rem)', fontWeight: 200, color: '#1a1a2e', lineHeight: 1.0, letterSpacing: '-0.04em', margin: '0.1em 0 0' }}>
of <span style={{ color: '#c4a265', fontWeight: 400 }}>Robotics</span> 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 }}>
@ -203,7 +168,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 className="overlay-heading" style={{ fontWeight: 300, color: '#1a1a2e', lineHeight: 1.1, margin: '0 0 1rem', letterSpacing: '-0.03em' }}> <h2 style={{ fontSize: 'clamp(2rem, 3.5vw, 3rem)', fontWeight: 300, color: '#1a1a2e', lineHeight: 1.1, margin: '0 0 1rem', letterSpacing: '-0.03em' }}>
Vision That<br />Understands 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 }}>
@ -211,11 +176,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 className="overlay-stat" style={{ fontWeight: 300, color: '#1a1a2e', fontFamily: 'monospace' }}>360°</div> <div style={{ fontSize: '1.8rem', fontWeight: 300, color: '#1a1a2e', fontFamily: 'monospace' }}>360°</div>
<div style={{ fontSize: '0.65rem', color: '#64748b', letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: '0.5rem' }}>Field of View</div> <div 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 className="overlay-stat" style={{ fontWeight: 300, color: '#1a1a2e', fontFamily: 'monospace' }}>{'<'}50ms</div> <div style={{ fontSize: '1.8rem', fontWeight: 300, color: '#1a1a2e', fontFamily: 'monospace' }}>{'<'}50ms</div>
<div style={{ fontSize: '0.65rem', color: '#64748b', letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: '0.5rem' }}>Response Time</div> <div style={{ fontSize: '0.65rem', color: '#64748b', letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: '0.5rem' }}>Response Time</div>
</div> </div>
</div> </div>
@ -228,56 +193,28 @@ 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 className="overlay-heading" style={{ fontWeight: 300, color: '#1a1a2e', lineHeight: 1.1, margin: '0 0 1rem', letterSpacing: '-0.03em' }}> <h2 style={{ fontSize: 'clamp(2rem, 3.5vw, 3rem)', fontWeight: 300, color: '#1a1a2e', lineHeight: 1.1, margin: '0 0 1rem', letterSpacing: '-0.03em' }}>
Dress for Any<br />Mission 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', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '1rem', marginTop: '2rem' }}>
{attireItems.map((item) => ( {['Kandura', 'Vest', 'Suit'].map((label) => (
<div <div
key={item.id} key={label}
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.6rem 1.25rem', padding: '0.5rem 1rem',
borderRadius: '2rem', borderRadius: '2rem',
background: 'rgba(255, 255, 255, 0.5)', background: 'rgba(196, 162, 101, 0.1)',
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: '#1a1a2e', color: '#c4a265',
letterSpacing: '0.1em', letterSpacing: '0.1em',
fontWeight: 600, fontWeight: 500
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)';
}} }}
> >
{item.label} {label}
</div> </div>
))} ))}
</div> </div>
@ -290,7 +227,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 className="overlay-heading" style={{ fontWeight: 300, color: '#1a1a2e', lineHeight: 1.1, margin: '0 0 1rem', letterSpacing: '-0.03em' }}> <h2 style={{ fontSize: 'clamp(2rem, 3.5vw, 3rem)', fontWeight: 300, color: '#1a1a2e', lineHeight: 1.1, margin: '0 0 1rem', letterSpacing: '-0.03em' }}>
23 Degrees of<br />Freedom 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 }}>
@ -298,22 +235,19 @@ 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 className="overlay-stat" style={{ fontWeight: 300, color: '#1a1a2e', fontFamily: 'monospace' }}>2m/s</div> <div style={{ fontSize: '1.8rem', fontWeight: 300, color: '#1a1a2e', fontFamily: 'monospace' }}>2m/s</div>
<div style={{ fontSize: '0.65rem', color: '#64748b', letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: '0.5rem' }}>Max Speed</div> <div 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 className="overlay-stat" style={{ fontWeight: 300, color: '#1a1a2e', fontFamily: 'monospace' }}>127kg</div> <div style={{ fontSize: '1.8rem', fontWeight: 300, color: '#1a1a2e', fontFamily: 'monospace' }}>127kg</div>
<div style={{ fontSize: '0.65rem', color: '#64748b', letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: '0.5rem' }}>Payload</div> <div 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,11 +2,8 @@
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, Html, useProgress, useGLTF } from '@react-three/drei'; import { Environment, ContactShadows, useGLTF, Html, useProgress } 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]][] = [
@ -16,8 +13,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, [-1.8, 0.3, 3.0]], // Circle to front-left, pulled back to avoid text overlap [0.80, [0.3, 0.3, 2.2]], // Circle to front-low
[0.92, [-1.5, -0.1, 3.2]], // LOW ANGLE: heroic, offset left for right-side text [0.92, [0, -0.3, 2.5]], // LOW ANGLE: heroic looking up
[1.0, [0, 1.2, 5.5]], // Final: pull back for configurator [1.0, [0, 1.2, 5.5]], // Final: pull back for configurator
]; ];
@ -63,9 +60,8 @@ 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, size } = useThree(); const { camera } = 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;
@ -74,10 +70,6 @@ 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;
@ -88,9 +80,9 @@ function ScrollCamera() {
// Apply oscillations to position // Apply oscillations to position
const adjustedPos = new THREE.Vector3( const adjustedPos = new THREE.Vector3(
pos.x - mobileCenterX + oscillationX + driftZone, pos.x + oscillationX + driftZone,
pos.y + oscillationY + verticalDrift, pos.y + oscillationY + verticalDrift,
pos.z + mobileZOffset pos.z
); );
// Adaptive lerp for smooth camera transitions // Adaptive lerp for smooth camera transitions
@ -228,6 +220,46 @@ 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();
@ -262,7 +294,7 @@ function SceneContent() {
<ScrollLighting /> <ScrollLighting />
<LightOrbs /> <LightOrbs />
<RobotModel /> <RobotDisplay />
<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" />
</> </>
); );
@ -322,5 +354,3 @@ 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,33 +1,89 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useCallback } from 'react';
import { PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js'; import { orderStore, type PaymentInfo } from '@/store/useOrderStore';
import { orderStore } from '@/store/useOrderStore';
export function PaymentStep() { const inputStyle: React.CSSProperties = {
const stripe = useStripe(); width: '100%',
const elements = useElements(); padding: '0.6rem 0.75rem',
const [errorMsg, setErrorMsg] = useState(''); borderRadius: '0.375rem',
const [isLoading, setIsLoading] = useState(false); 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 handleSubmit = async () => { const labelStyle: React.CSSProperties = {
if (!stripe || !elements) return; fontSize: '0.7rem',
fontWeight: 500,
color: '#94a3b8',
marginBottom: '0.3rem',
display: 'block',
};
setIsLoading(true); const errorStyle: React.CSSProperties = {
setErrorMsg(''); fontSize: '0.65rem',
color: '#ef4444',
marginTop: '0.2rem',
};
// Validate the payment element first interface FormErrors {
const { error: submitError } = await elements.submit(); [key: string]: string;
if (submitError) {
setErrorMsg(submitError.message || 'Validation failed');
setIsLoading(false);
return;
} }
// Move to review — actual confirmation happens on "Place Order" function formatCardNumber(value: string): string {
orderStore.getState().setPayment({ status: 'idle' }); const digits = value.replace(/\D/g, '').slice(0, 16);
return digits.replace(/(\d{4})(?=\d)/g, '$1 ');
}
function formatExpiry(value: string): string {
const digits = value.replace(/\D/g, '').slice(0, 4);
if (digits.length > 2) {
return `${digits.slice(0, 2)}/${digits.slice(2)}`;
}
return digits;
}
export function PaymentStep() {
const [form, setForm] = useState<PaymentInfo>({
cardNumber: '',
expiry: '',
cvv: '',
nameOnCard: '',
});
const [errors, setErrors] = useState<FormErrors>({});
const handleChange = useCallback((field: keyof PaymentInfo, value: string) => {
let processed = value;
if (field === 'cardNumber') processed = formatCardNumber(value);
if (field === 'expiry') processed = formatExpiry(value);
if (field === 'cvv') processed = value.replace(/\D/g, '').slice(0, 3);
setForm((prev) => ({ ...prev, [field]: processed }));
setErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
}, []);
const validate = (): boolean => {
const errs: FormErrors = {};
const digits = form.cardNumber.replace(/\s/g, '');
if (digits.length < 16) errs.cardNumber = 'Enter a valid 16-digit card number';
if (form.expiry.length < 5) errs.expiry = 'Enter a valid expiry (MM/YY)';
if (form.cvv.length < 3) errs.cvv = 'Enter a valid 3-digit CVV';
if (!form.nameOnCard.trim()) errs.nameOnCard = 'Name on card is required';
setErrors(errs);
return Object.keys(errs).length === 0;
};
const handleSubmit = () => {
if (!validate()) return;
orderStore.getState().setPayment(form);
orderStore.getState().setStep('review'); orderStore.getState().setStep('review');
setIsLoading(false);
}; };
return ( return (
@ -36,49 +92,90 @@ export function PaymentStep() {
Payment Details Payment Details
</h3> </h3>
<div style={{
padding: '1rem',
borderRadius: '0.5rem',
background: '#fff',
border: '1px solid rgba(0, 0, 0, 0.08)',
}}>
<PaymentElement
options={{
layout: 'tabs',
}}
/>
</div>
{errorMsg && (
<div style={{ <div style={{
padding: '0.6rem 0.75rem', padding: '0.6rem 0.75rem',
borderRadius: '0.375rem', borderRadius: '0.375rem',
background: 'rgba(239, 68, 68, 0.06)', background: 'rgba(245, 158, 11, 0.06)',
border: '1px solid rgba(239, 68, 68, 0.15)', border: '1px solid rgba(245, 158, 11, 0.15)',
fontSize: '0.75rem', fontSize: '0.7rem',
color: '#dc2626', color: '#d97706',
}}> }}>
{errorMsg} This is a demo checkout. No real payment will be processed.
</div>
<div>
<label style={labelStyle}>Card Number</label>
<input
type="text"
value={form.cardNumber}
onChange={(e) => handleChange('cardNumber', e.target.value)}
placeholder="4242 4242 4242 4242"
style={{ ...inputStyle, fontFamily: 'monospace', letterSpacing: '0.1em', borderColor: errors.cardNumber ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = errors.cardNumber ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
/>
{errors.cardNumber && <div style={errorStyle}>{errors.cardNumber}</div>}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<div>
<label style={labelStyle}>Expiry Date</label>
<input
type="text"
value={form.expiry}
onChange={(e) => handleChange('expiry', e.target.value)}
placeholder="MM/YY"
style={{ ...inputStyle, fontFamily: 'monospace', borderColor: errors.expiry ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = errors.expiry ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
/>
{errors.expiry && <div style={errorStyle}>{errors.expiry}</div>}
</div>
<div>
<label style={labelStyle}>CVV</label>
<input
type="text"
value={form.cvv}
onChange={(e) => handleChange('cvv', e.target.value)}
placeholder="123"
style={{ ...inputStyle, fontFamily: 'monospace', borderColor: errors.cvv ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = errors.cvv ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
/>
{errors.cvv && <div style={errorStyle}>{errors.cvv}</div>}
</div>
</div>
<div>
<label style={labelStyle}>Name on Card</label>
<input
type="text"
value={form.nameOnCard}
onChange={(e) => handleChange('nameOnCard', e.target.value)}
placeholder="John Doe"
style={{ ...inputStyle, borderColor: errors.nameOnCard ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = errors.nameOnCard ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
/>
{errors.nameOnCard && <div style={errorStyle}>{errors.nameOnCard}</div>}
</div> </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: isLoading ? 'rgba(59, 130, 246, 0.15)' : 'rgba(59, 130, 246, 0.08)', background: 'rgba(59, 130, 246, 0.08)',
color: '#2563eb', color: '#2563eb',
cursor: isLoading ? 'wait' : 'pointer', cursor: 'pointer',
fontSize: '0.85rem', fontSize: '0.85rem',
fontWeight: 600, fontWeight: 600,
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
}} }}
> >
{isLoading ? 'Validating...' : 'Review Order'} Review Order
</button> </button>
</div> </div>
); );

View File

@ -1,97 +1,23 @@
'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);
setErrorMsg(''); // Simulate payment processing delay
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();
}; };
@ -143,24 +69,10 @@ 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 || !stripe} disabled={isProcessing}
style={{ style={{
marginTop: '0.25rem', marginTop: '0.25rem',
padding: '0.85rem', padding: '0.85rem',

View File

@ -1,17 +0,0 @@
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;

View File

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

View File

@ -14,15 +14,10 @@ export interface ShippingInfo {
} }
export interface PaymentInfo { export interface PaymentInfo {
paymentIntentId: string; cardNumber: string;
clientSecret: string; expiry: string;
status: 'idle' | 'processing' | 'succeeded' | 'failed'; cvv: string;
errorMessage: string; nameOnCard: string;
}
export interface PriceLineItem {
label: string;
price: number;
} }
export interface OrderState { export interface OrderState {
@ -33,16 +28,14 @@ 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: Partial<PaymentInfo>) => void; setPayment: (payment: PaymentInfo) => void;
setOrderTotal: (total: number) => void; setOrderTotal: (total: number) => void;
setConfigSummary: (persona: string, color: string, priceItems?: PriceLineItem[]) => void; setConfigSummary: (persona: string, color: string) => void;
createPaymentIntent: () => Promise<string | null>;
placeOrder: () => void; placeOrder: () => void;
resetOrder: () => void; resetOrder: () => void;
} }
@ -60,10 +53,10 @@ const emptyShipping: ShippingInfo = {
}; };
const emptyPayment: PaymentInfo = { const emptyPayment: PaymentInfo = {
paymentIntentId: '', cardNumber: '',
clientSecret: '', expiry: '',
status: 'idle', cvv: '',
errorMessage: '', nameOnCard: '',
}; };
const defaultState: OrderState = { const defaultState: OrderState = {
@ -74,7 +67,6 @@ const defaultState: OrderState = {
orderTotal: 0, orderTotal: 0,
personaSummary: '', personaSummary: '',
colorSummary: '', colorSummary: '',
priceItems: [],
}; };
function generateOrderId(): string { function generateOrderId(): string {
@ -90,60 +82,15 @@ export const orderStore = createStore<OrderStore>((set) => ({
setShipping: (shipping: ShippingInfo) => set({ shipping }), setShipping: (shipping: ShippingInfo) => set({ shipping }),
setPayment: (payment: Partial<PaymentInfo>) => set((state) => ({ setPayment: (payment: PaymentInfo) => set({ payment }),
payment: { ...state.payment, ...payment },
})),
setOrderTotal: (total: number) => set({ orderTotal: total }), setOrderTotal: (total: number) => set({ orderTotal: total }),
setConfigSummary: (persona: string, color: string, priceItems: PriceLineItem[] = []) => set({ setConfigSummary: (persona: string, color: string) => 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

@ -1,209 +0,0 @@
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,7 +5,6 @@ export interface PricingItem {
id: string; id: string;
label: string; label: string;
price: number; price: number;
modelPath?: string;
} }
export interface PricingState { export interface PricingState {
@ -15,9 +14,6 @@ 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;
} }
@ -30,8 +26,6 @@ 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';
@ -72,81 +66,23 @@ 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 && stored.length > 0) { if (stored) {
// Use stored items directly (preserves custom labels, prices, added items). // Merge stored prices with defaults (in case new items were added)
// Re-add any default items that were never stored (fresh install gap). const merged = DEFAULT_ITEMS.map((def) => {
const storedIds = new Set(stored.map((s) => s.id)); const found = stored.find((s) => s.id === def.id);
const missing = DEFAULT_ITEMS.filter((d) => !storedIds.has(d.id)); return found ? { ...def, price: found.price } : def;
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

@ -1,32 +0,0 @@
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 });
}
},
}));

View File

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

View File

@ -1,18 +0,0 @@
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" }
]
}
...

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB