Compare commits
No commits in common. "main" and "main" have entirely different histories.
@ -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/
|
|
||||||
18
.env.example
@ -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
|
|
||||||
@ -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
@ -35,5 +35,3 @@ next-env.d.ts
|
|||||||
# IDE
|
# IDE
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
|
|
||||||
/src/generated/prisma
|
|
||||||
|
|||||||
127
CHANGELOG.md
@ -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` (الصفحة الرئيسية)
|
|
||||||
60
Dockerfile
@ -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"]
|
|
||||||
@ -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
|
|
||||||
@ -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
17
package.json
@ -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"
|
||||||
|
|||||||
@ -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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
BIN
prisma/lootah.db
@ -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())
|
|
||||||
}
|
|
||||||
@ -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(); });
|
|
||||||
@ -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();
|
|
||||||
});
|
|
||||||
BIN
public/Suit.glb
BIN
public/Vest.glb
|
Before Width: | Height: | Size: 18 KiB |
@ -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);
|
|
||||||
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 69 KiB |
@ -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',
|
|
||||||
};
|
|
||||||
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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: [] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 });
|
|
||||||
}
|
|
||||||
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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}` });
|
|
||||||
}
|
|
||||||
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 });
|
|
||||||
}
|
|
||||||
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 });
|
|
||||||
}
|
|
||||||
@ -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 />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 15 KiB |
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@ -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 />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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
|
{step === 'review' && <ReviewStep />}
|
||||||
stripe={stripePromise}
|
|
||||||
options={{
|
|
||||||
clientSecret,
|
|
||||||
appearance: {
|
|
||||||
theme: 'stripe',
|
|
||||||
variables: {
|
|
||||||
colorPrimary: '#2563eb',
|
|
||||||
borderRadius: '0.375rem',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Keep PaymentElement mounted (hidden) during review so confirmPayment works */}
|
|
||||||
<div style={{ display: step === 'payment' ? 'block' : 'none' }}>
|
|
||||||
<PaymentStep />
|
|
||||||
</div>
|
|
||||||
{step === 'review' && <ReviewStep />}
|
|
||||||
</Elements>
|
|
||||||
) : (step === 'payment' || step === 'review') && !clientSecret ? (
|
|
||||||
<div style={{ textAlign: 'center', padding: '2rem', fontSize: '0.85rem' }}>
|
|
||||||
{paymentStatus === 'failed' ? (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', alignItems: 'center' }}>
|
|
||||||
<div style={{ color: '#dc2626' }}>{paymentError || 'Failed to initialize payment'}</div>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
orderStore.getState().setPayment({ status: 'idle', errorMessage: '' });
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: '0.5rem 1rem',
|
|
||||||
borderRadius: '0.375rem',
|
|
||||||
border: '1px solid rgba(59, 130, 246, 0.5)',
|
|
||||||
background: 'rgba(59, 130, 246, 0.08)',
|
|
||||||
color: '#2563eb',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span style={{ color: '#94a3b8' }}>Initializing secure payment...</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{step === 'confirmed' && <ConfirmationStep />}
|
{step === 'confirmed' && <ConfirmationStep />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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 }}>
|
|
||||||
© {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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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');
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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%)' }}
|
||||||
|
|||||||
@ -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');
|
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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');
|
|
||||||
|
|||||||
@ -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';
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.6rem 0.75rem',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
border: '1px solid rgba(0, 0, 0, 0.08)',
|
||||||
|
background: 'rgba(255, 255, 255, 0.8)',
|
||||||
|
color: '#1a1a2e',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'border-color 0.2s ease',
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#94a3b8',
|
||||||
|
marginBottom: '0.3rem',
|
||||||
|
display: 'block',
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorStyle: React.CSSProperties = {
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
color: '#ef4444',
|
||||||
|
marginTop: '0.2rem',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FormErrors {
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCardNumber(value: string): string {
|
||||||
|
const digits = value.replace(/\D/g, '').slice(0, 16);
|
||||||
|
return digits.replace(/(\d{4})(?=\d)/g, '$1 ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExpiry(value: string): string {
|
||||||
|
const digits = value.replace(/\D/g, '').slice(0, 4);
|
||||||
|
if (digits.length > 2) {
|
||||||
|
return `${digits.slice(0, 2)}/${digits.slice(2)}`;
|
||||||
|
}
|
||||||
|
return digits;
|
||||||
|
}
|
||||||
|
|
||||||
export function PaymentStep() {
|
export function PaymentStep() {
|
||||||
const stripe = useStripe();
|
const [form, setForm] = useState<PaymentInfo>({
|
||||||
const elements = useElements();
|
cardNumber: '',
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
expiry: '',
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
cvv: '',
|
||||||
|
nameOnCard: '',
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleChange = useCallback((field: keyof PaymentInfo, value: string) => {
|
||||||
if (!stripe || !elements) return;
|
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);
|
||||||
|
|
||||||
setIsLoading(true);
|
setForm((prev) => ({ ...prev, [field]: processed }));
|
||||||
setErrorMsg('');
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[field];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Validate the payment element first
|
const validate = (): boolean => {
|
||||||
const { error: submitError } = await elements.submit();
|
const errs: FormErrors = {};
|
||||||
if (submitError) {
|
const digits = form.cardNumber.replace(/\s/g, '');
|
||||||
setErrorMsg(submitError.message || 'Validation failed');
|
if (digits.length < 16) errs.cardNumber = 'Enter a valid 16-digit card number';
|
||||||
setIsLoading(false);
|
if (form.expiry.length < 5) errs.expiry = 'Enter a valid expiry (MM/YY)';
|
||||||
return;
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
// Move to review — actual confirmation happens on "Place Order"
|
const handleSubmit = () => {
|
||||||
orderStore.getState().setPayment({ status: 'idle' });
|
if (!validate()) return;
|
||||||
|
orderStore.getState().setPayment(form);
|
||||||
orderStore.getState().setStep('review');
|
orderStore.getState().setStep('review');
|
||||||
setIsLoading(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -37,48 +93,89 @@ export function PaymentStep() {
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '1rem',
|
padding: '0.6rem 0.75rem',
|
||||||
borderRadius: '0.5rem',
|
borderRadius: '0.375rem',
|
||||||
background: '#fff',
|
background: 'rgba(245, 158, 11, 0.06)',
|
||||||
border: '1px solid rgba(0, 0, 0, 0.08)',
|
border: '1px solid rgba(245, 158, 11, 0.15)',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
color: '#d97706',
|
||||||
}}>
|
}}>
|
||||||
<PaymentElement
|
This is a demo checkout. No real payment will be processed.
|
||||||
options={{
|
|
||||||
layout: 'tabs',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{errorMsg && (
|
<div>
|
||||||
<div style={{
|
<label style={labelStyle}>Card Number</label>
|
||||||
padding: '0.6rem 0.75rem',
|
<input
|
||||||
borderRadius: '0.375rem',
|
type="text"
|
||||||
background: 'rgba(239, 68, 68, 0.06)',
|
value={form.cardNumber}
|
||||||
border: '1px solid rgba(239, 68, 68, 0.15)',
|
onChange={(e) => handleChange('cardNumber', e.target.value)}
|
||||||
fontSize: '0.75rem',
|
placeholder="4242 4242 4242 4242"
|
||||||
color: '#dc2626',
|
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)'; }}
|
||||||
{errorMsg}
|
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>
|
||||||
)}
|
<div>
|
||||||
|
<label style={labelStyle}>CVV</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.cvv}
|
||||||
|
onChange={(e) => handleChange('cvv', e.target.value)}
|
||||||
|
placeholder="123"
|
||||||
|
style={{ ...inputStyle, fontFamily: 'monospace', borderColor: errors.cvv ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
|
||||||
|
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
|
||||||
|
onBlur={(e) => { e.currentTarget.style.borderColor = errors.cvv ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
|
||||||
|
/>
|
||||||
|
{errors.cvv && <div style={errorStyle}>{errors.cvv}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Name on Card</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.nameOnCard}
|
||||||
|
onChange={(e) => handleChange('nameOnCard', e.target.value)}
|
||||||
|
placeholder="John Doe"
|
||||||
|
style={{ ...inputStyle, borderColor: errors.nameOnCard ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
|
||||||
|
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
|
||||||
|
onBlur={(e) => { e.currentTarget.style.borderColor = errors.nameOnCard ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
|
||||||
|
/>
|
||||||
|
{errors.nameOnCard && <div style={errorStyle}>{errors.nameOnCard}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!stripe || isLoading}
|
|
||||||
style={{
|
style={{
|
||||||
marginTop: '0.5rem',
|
marginTop: '0.5rem',
|
||||||
padding: '0.75rem',
|
padding: '0.75rem',
|
||||||
borderRadius: '0.375rem',
|
borderRadius: '0.375rem',
|
||||||
border: '1px solid rgba(59, 130, 246, 0.5)',
|
border: '1px solid rgba(59, 130, 246, 0.5)',
|
||||||
background: 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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;
|
|
||||||
34
src/proxy.ts
@ -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*'],
|
|
||||||
};
|
|
||||||
@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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())
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@ -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 });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
7
src/types/draco3dgltf.d.ts
vendored
@ -1,7 +0,0 @@
|
|||||||
declare module 'draco3dgltf' {
|
|
||||||
const draco3d: {
|
|
||||||
createDecoderModule(): Promise<unknown>;
|
|
||||||
createEncoderModule(): Promise<unknown>;
|
|
||||||
};
|
|
||||||
export default draco3d;
|
|
||||||
}
|
|
||||||
@ -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" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
...
|
|
||||||
|
Before Width: | Height: | Size: 18 KiB |
BIN
web/favicon.ico
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 17 KiB |
BIN
web/icon-192.png
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 68 KiB |
BIN
web/icon-512.png
|
Before Width: | Height: | Size: 69 KiB |