feat: add admin authentication and management features
- Implemented Prisma schema with models for AdminUser, AppSettings, and Snapshot. - Created seed script to initialize the database with an admin user and JWT secret. - Developed admin login page with form handling and error management. - Added API routes for admin login, logout, change password, and JWT verification. - Integrated Stripe for payment intent management in admin orders. - Established middleware for protecting admin routes with JWT authentication. - Created Zustand stores for managing persona and snapshot states.
This commit is contained in:
parent
ec0f991a30
commit
05b540997e
4
.env.example
Normal file
4
.env.example
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# Stripe Keys
|
||||||
|
# Get your keys from https://dashboard.stripe.com/apikeys
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51RBtf7I7xOcO9rzigsLLK3esMLmBlJoRztbzUadPhQm7tcHQuScViFEkwdfAwDxbaqt5n8BOuJV9wRSMdn2IrxIX00lqGOOJfT
|
||||||
|
STRIPE_SECRET_KEY=sk_test_51RBtf7I7xOcO9rzitxMqK3jnTb3SPdEbyGxGBnPccGEfrIrpiEFEOIEG2oHuTumaUejUN4FyAOBg0AVCBRn6AOKI00LeWSDC10
|
||||||
116
.github/prompts/plan-adminDashboardAuthCrudEnhancements.prompt.md
vendored
Normal file
116
.github/prompts/plan-adminDashboardAuthCrudEnhancements.prompt.md
vendored
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
# Plan: Admin Dashboard Auth + CRUD + Enhancements
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
Add password authentication to the admin dashboard, enable full CRUD for personas/pricing, and add useful admin features like order analytics and configurator settings management.
|
||||||
|
|
||||||
|
## Phase 1: Admin Authentication (Database-backed with Prisma + SQLite)
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
1. **Setup Prisma + SQLite**
|
||||||
|
- Install `prisma` and `@prisma/client`
|
||||||
|
- Create `prisma/schema.prisma` with SQLite provider
|
||||||
|
- Models: `AdminUser` (id, username, passwordHash, createdAt), `AppSettings` (key, value)
|
||||||
|
- Run `npx prisma db push` to create the database
|
||||||
|
- Create `src/lib/prisma.ts` — singleton Prisma client
|
||||||
|
|
||||||
|
2. **Create seed script** `prisma/seed.ts`
|
||||||
|
- Creates default admin user with bcrypt-hashed password
|
||||||
|
- Generates and stores JWT secret in `AppSettings` table
|
||||||
|
- Run with `npx prisma db seed`
|
||||||
|
|
||||||
|
3. **Create admin auth API routes**
|
||||||
|
- `src/app/api/admin/login/route.ts` — POST, accepts `{ username, password }`, verifies bcrypt hash from DB, returns JWT in httpOnly cookie (JWT secret from DB)
|
||||||
|
- `src/app/api/admin/verify/route.ts` — GET, checks JWT cookie validity
|
||||||
|
- `src/app/api/admin/logout/route.ts` — POST, clears auth cookie
|
||||||
|
- `src/app/api/admin/change-password/route.ts` — POST, accepts `{ currentPassword, newPassword }`, updates hash in DB
|
||||||
|
|
||||||
|
4. **Create admin middleware** `src/middleware.ts`
|
||||||
|
- Protects all `/admin/*` routes (except `/admin/login/`)
|
||||||
|
- Checks for valid JWT cookie, redirects to `/admin/login/` if missing/invalid
|
||||||
|
- Note: middleware can't use Prisma directly (edge runtime), so JWT secret needs to be in env OR verify via API call
|
||||||
|
|
||||||
|
5. **Create admin login page** `src/app/admin/login/page.tsx`
|
||||||
|
- Username + password form (styled to match existing admin design)
|
||||||
|
- Calls login API, redirects to `/admin/` on success
|
||||||
|
- Shows error on wrong credentials
|
||||||
|
|
||||||
|
6. **Add JWT_SECRET to .env.local** (only this one — password is in DB)
|
||||||
|
- Alternative: store JWT secret in DB and load at startup into a module-level cache
|
||||||
|
|
||||||
|
### Dependencies to install
|
||||||
|
- `prisma` (dev), `@prisma/client`, `bcryptjs`, `@types/bcryptjs` (dev), `jose`
|
||||||
|
|
||||||
|
## Phase 2: Full CRUD for Pricing Items + Personas
|
||||||
|
|
||||||
|
### Steps (depends on Phase 1)
|
||||||
|
6. **Add/Remove pricing items in admin** — Update `src/app/admin/page.tsx`
|
||||||
|
- "Add Item" button with fields: id (auto-slug from label), label, price
|
||||||
|
- Delete button per row (with confirmation)
|
||||||
|
- Update `usePricingStore` to support `addItem()` and `removeItem()` actions
|
||||||
|
|
||||||
|
7. **Persona management section** — New section in admin page
|
||||||
|
- List current personas with edit capability (label, description, colors)
|
||||||
|
- Add new persona form
|
||||||
|
- Delete persona button
|
||||||
|
- Create `usePersonaStore` or extend `usePricingStore` to manage persona definitions
|
||||||
|
- Sync persona list with ConfigPanel's `PERSONA_OPTIONS` (currently hardcoded)
|
||||||
|
|
||||||
|
## Phase 3: Additional Admin Features
|
||||||
|
|
||||||
|
### Steps (parallel with Phase 2)
|
||||||
|
8. **Orders dashboard section** — New section in admin page
|
||||||
|
- Show orders from Stripe API (list recent payments)
|
||||||
|
- Display: order ID, customer email, amount, status, date
|
||||||
|
- New API route `src/app/api/admin/orders/route.ts` to fetch from Stripe
|
||||||
|
|
||||||
|
9. **Analytics overview cards** at top of admin page
|
||||||
|
- Total revenue (from Stripe)
|
||||||
|
- Number of orders
|
||||||
|
- Most popular persona
|
||||||
|
- Most popular color
|
||||||
|
|
||||||
|
10. **Configurator settings** section
|
||||||
|
- Edit default color (`#96a2b6`)
|
||||||
|
- Toggle available color options
|
||||||
|
- Set min/max price boundaries
|
||||||
|
|
||||||
|
11. **Logout button** in admin header
|
||||||
|
|
||||||
|
## Relevant Files
|
||||||
|
- `src/app/admin/page.tsx` — Main admin dashboard (add CRUD UI, analytics)
|
||||||
|
- `src/app/admin/login/page.tsx` — New login page
|
||||||
|
- `src/app/api/admin/login/route.ts` — New auth API
|
||||||
|
- `src/app/api/admin/verify/route.ts` — New verify API
|
||||||
|
- `src/app/api/admin/orders/route.ts` — New orders API
|
||||||
|
- `src/middleware.ts` — New, protects admin routes
|
||||||
|
- `src/lib/prisma.ts` — New, Prisma client singleton
|
||||||
|
- `prisma/schema.prisma` — New, database schema (AdminUser, AppSettings)
|
||||||
|
- `prisma/seed.ts` — New, seeds default admin user
|
||||||
|
- `src/store/usePricingStore.ts` — Add `addItem()`, `removeItem()` actions
|
||||||
|
- `src/components/ConfigPanel.tsx` — Persona options currently hardcoded (PERSONA_OPTIONS const)
|
||||||
|
- `.env.local` — Add ADMIN_PASSWORD, ADMIN_JWT_SECRET
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
1. Visit `/admin/` without login → redirected to `/admin/login/`
|
||||||
|
2. Enter wrong password → error message shown
|
||||||
|
3. Enter correct password → redirected to `/admin/`
|
||||||
|
4. Add a pricing item → appears in configurator pricing breakdown
|
||||||
|
5. Remove a pricing item → disappears from pricing
|
||||||
|
6. Edit persona → reflected in ConfigPanel
|
||||||
|
7. Orders section shows real Stripe payment data
|
||||||
|
8. Refresh admin page → still logged in (cookie persists)
|
||||||
|
9. Run `npx vitest run` → all existing tests pass
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
- **Database: Prisma + SQLite** — file-based, no external server needed, real ORM
|
||||||
|
- Admin password stored as bcrypt hash in DB (not in env vars)
|
||||||
|
- JWT secret stored in AppSettings table in DB
|
||||||
|
- JWT in httpOnly cookie — secure, no localStorage tokens
|
||||||
|
- jose library for JWT — lightweight, edge-compatible (works in Next.js middleware)
|
||||||
|
- Personas need to move from hardcoded array to store-driven — breaking change in ConfigPanel
|
||||||
|
- Stripe orders fetched via Stripe API directly — no local DB needed for orders
|
||||||
|
|
||||||
|
## Further Considerations
|
||||||
|
1. **Persona storage**: Currently hardcoded in ConfigPanel. Moving to a store means ConfigPanel reads from store dynamically. This is required for admin CRUD to work. **Recommended: create persona store with localStorage persistence (same pattern as pricing store)**
|
||||||
|
2. **Stripe orders vs local orders**: Currently orders only exist client-side. To show in admin, we fetch from Stripe's payment intents list. **Recommended: use Stripe API directly, no local DB needed**
|
||||||
|
3. **Multi-admin support**: Currently single password. If needed later, can upgrade to NextAuth with credentials provider. **Recommended: start simple, upgrade if needed**
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -35,3 +35,5 @@ next-env.d.ts
|
|||||||
# IDE
|
# IDE
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
|
|
||||||
|
/src/generated/prisma
|
||||||
|
|||||||
108
STRIPE_INTEGRATION.md
Normal file
108
STRIPE_INTEGRATION.md
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
# Stripe Payment Integration
|
||||||
|
|
||||||
|
## ملخص التعديلات
|
||||||
|
|
||||||
|
تم استبدال نظام الدفع التجريبي (Demo) بتكامل حقيقي مع **Stripe** لمعالجة المدفوعات.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## الملفات الجديدة
|
||||||
|
|
||||||
|
### 1. `src/app/api/create-payment-intent/route.ts`
|
||||||
|
- API Route على السيرفر لإنشاء Stripe PaymentIntent
|
||||||
|
- يستقبل المبلغ بالدرهم (AED) ويحوله لأصغر وحدة (فلس)
|
||||||
|
- يرسل metadata تشمل الـ persona واللون وإيميل العميل
|
||||||
|
|
||||||
|
### 2. `.env.example`
|
||||||
|
- ملف مرجعي يحتوي على أسماء المتغيرات المطلوبة لمفاتيح Stripe
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## الملفات المعدّلة
|
||||||
|
|
||||||
|
### 3. `src/store/useOrderStore.ts`
|
||||||
|
**قبل:**
|
||||||
|
```ts
|
||||||
|
export interface PaymentInfo {
|
||||||
|
cardNumber: string;
|
||||||
|
expiry: string;
|
||||||
|
cvv: string;
|
||||||
|
nameOnCard: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**بعد:**
|
||||||
|
```ts
|
||||||
|
export interface PaymentInfo {
|
||||||
|
paymentIntentId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
status: 'idle' | 'processing' | 'succeeded' | 'failed';
|
||||||
|
errorMessage: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- تمت إضافة action جديد: `createPaymentIntent()` — يستدعي الـ API Route وينشئ PaymentIntent
|
||||||
|
- تم تعديل `setPayment()` ليقبل `Partial<PaymentInfo>` للتحديث التدريجي
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. `src/components/checkout/PaymentStep.tsx`
|
||||||
|
**قبل:** فورم يدوي يجمع بيانات البطاقة (رقم، تاريخ انتهاء، CVV، اسم) — بدون معالجة حقيقية
|
||||||
|
|
||||||
|
**بعد:** يستخدم `<PaymentElement>` من Stripe Elements — يعرض واجهة دفع آمنة تدعم:
|
||||||
|
- بطاقات الائتمان/الخصم
|
||||||
|
- Apple Pay
|
||||||
|
- Google Pay
|
||||||
|
- طرق دفع أخرى حسب إعدادات حساب Stripe
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. `src/components/checkout/ReviewStep.tsx`
|
||||||
|
**قبل:** زر "Place Order" كان يعمل `setTimeout(1500)` فقط (محاكاة)
|
||||||
|
|
||||||
|
**بعد:** يستدعي `stripe.confirmPayment()` لتأكيد الدفع الحقيقي عبر Stripe، مع عرض رسائل الخطأ إذا فشل الدفع
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. `src/components/CheckoutOverlay.tsx`
|
||||||
|
- تم تحميل Stripe.js عبر `loadStripe()`
|
||||||
|
- يتم إنشاء PaymentIntent تلقائياً عند الدخول لخطوة الدفع
|
||||||
|
- يتم تغليف خطوتي Payment و Review بـ `<Elements>` provider من Stripe
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. `src/store/useOrderStore.test.ts`
|
||||||
|
- تم تحديث الـ tests لتتوافق مع الـ `PaymentInfo` الجديد
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## الحزم المضافة
|
||||||
|
|
||||||
|
```
|
||||||
|
stripe — Stripe SDK (سيرفر)
|
||||||
|
@stripe/stripe-js — Stripe.js loader (كلاينت)
|
||||||
|
@stripe/react-stripe-js — React components (Elements, PaymentElement)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## الإعداد
|
||||||
|
|
||||||
|
1. أنشئ ملف `.env.local` في root المشروع:
|
||||||
|
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
```
|
||||||
|
|
||||||
|
2. احصل على المفاتيح من: https://dashboard.stripe.com/apikeys
|
||||||
|
|
||||||
|
3. للتجربة استخدم مفاتيح الـ test (`pk_test_` / `sk_test_`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## تدفق الدفع الجديد
|
||||||
|
|
||||||
|
```
|
||||||
|
Config → Shipping → [Payment Intent Created] → Payment (Stripe Elements) → Review → confirmPayment() → Confirmed
|
||||||
|
```
|
||||||
@ -1,6 +1,5 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: 'export',
|
|
||||||
images: {
|
images: {
|
||||||
unoptimized: true,
|
unoptimized: true,
|
||||||
},
|
},
|
||||||
|
|||||||
1617
package-lock.json
generated
1617
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@ -11,19 +11,27 @@
|
|||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@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",
|
||||||
"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-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",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
@ -32,11 +40,13 @@
|
|||||||
"@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"
|
||||||
|
|||||||
11
prisma.config.ts
Normal file
11
prisma.config.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: "file:./prisma/lootah.db",
|
||||||
|
},
|
||||||
|
});
|
||||||
BIN
prisma/lootah.db
Normal file
BIN
prisma/lootah.db
Normal file
Binary file not shown.
28
prisma/schema.prisma
Normal file
28
prisma/schema.prisma
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
60
prisma/seed.ts
Normal file
60
prisma/seed.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Seeding complete!');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
172
src/app/admin/login/page.tsx
Normal file
172
src/app/admin/login/page.tsx
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, FormEvent } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function AdminLoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/login/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
router.push('/admin/');
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
setError(data.error ?? 'Login failed');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Network error. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={pageStyle}>
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '2rem' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '48px',
|
||||||
|
height: '48px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
background: 'rgba(59, 130, 246, 0.08)',
|
||||||
|
border: '1px solid rgba(59, 130, 246, 0.2)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
margin: '0 auto 1rem',
|
||||||
|
fontSize: '1.25rem',
|
||||||
|
}}>
|
||||||
|
🔐
|
||||||
|
</div>
|
||||||
|
<h1 style={{ fontSize: '1.25rem', fontWeight: 700, color: '#1a1a2e', margin: 0, marginBottom: '0.25rem' }}>
|
||||||
|
Admin Login
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: '#94a3b8', margin: 0 }}>
|
||||||
|
Lootah Robotics — G1 Configurator
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle} htmlFor="username">Username</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={(e) => (e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)')}
|
||||||
|
onBlur={(e) => (e.currentTarget.style.borderColor = 'rgba(0, 0, 0, 0.1)')}
|
||||||
|
placeholder="admin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle} htmlFor="password">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={(e) => (e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)')}
|
||||||
|
onBlur={(e) => (e.currentTarget.style.borderColor = 'rgba(0, 0, 0, 0.1)')}
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
padding: '0.6rem 0.875rem',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
background: 'rgba(239, 68, 68, 0.06)',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||||
|
color: '#dc2626',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
padding: '0.7rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: '1px solid rgba(59, 130, 246, 0.3)',
|
||||||
|
background: loading ? 'rgba(148, 163, 184, 0.1)' : 'rgba(59, 130, 246, 0.08)',
|
||||||
|
color: loading ? '#94a3b8' : '#2563eb',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: loading ? 'not-allowed' : 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
marginTop: '0.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? 'Signing in…' : 'Sign In'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageStyle: React.CSSProperties = {
|
||||||
|
minHeight: '100vh',
|
||||||
|
background: '#ffffff',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '2rem',
|
||||||
|
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '380px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
backdropFilter: 'blur(20px)',
|
||||||
|
border: '1px solid rgba(0, 0, 0, 0.06)',
|
||||||
|
borderRadius: '1rem',
|
||||||
|
padding: '2rem',
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
display: 'block',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#374151',
|
||||||
|
marginBottom: '0.375rem',
|
||||||
|
letterSpacing: '0.02em',
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.6rem 0.875rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: '1px solid rgba(0, 0, 0, 0.1)',
|
||||||
|
background: '#ffffff',
|
||||||
|
color: '#1a1a2e',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'border-color 0.2s ease',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
};
|
||||||
@ -1,208 +1,526 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { pricingStore, usePricingStore } from '@/store/usePricingStore';
|
import { pricingStore, usePricingStore } from '@/store/usePricingStore';
|
||||||
|
import { personaStore, usePersonaStore } from '@/store/usePersonaStore';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
export default function AdminPage() {
|
interface Order {
|
||||||
const items = usePricingStore((s) => s.items);
|
id: string;
|
||||||
const isHydrated = usePricingStore((s) => s.isHydrated);
|
amount: number;
|
||||||
const [editedPrices, setEditedPrices] = useState<Record<string, number>>({});
|
currency: string;
|
||||||
const [saved, setSaved] = useState(false);
|
status: string;
|
||||||
|
created: number;
|
||||||
|
metadata: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
type Tab = 'pricing' | 'personas' | 'orders';
|
||||||
pricingStore.getState().hydrate();
|
|
||||||
}, []);
|
export default function AdminPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('pricing');
|
||||||
|
|
||||||
|
// --------------- PRICING ---------------
|
||||||
|
const items = usePricingStore((s) => s.items);
|
||||||
|
const isPricingHydrated = usePricingStore((s) => s.isHydrated);
|
||||||
|
const [editedPrices, setEditedPrices] = useState<Record<string, number>>({});
|
||||||
|
const [priceSaved, setPriceSaved] = useState(false);
|
||||||
|
const [newItem, setNewItem] = useState({ id: '', label: '', price: '' });
|
||||||
|
const [addItemError, setAddItemError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => { pricingStore.getState().hydrate(); }, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map: Record<string, number> = {};
|
const map: Record<string, number> = {};
|
||||||
items.forEach((item) => {
|
items.forEach((item) => { map[item.id] = item.price; });
|
||||||
map[item.id] = item.price;
|
|
||||||
});
|
|
||||||
setEditedPrices(map);
|
setEditedPrices(map);
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
const handlePriceChange = (id: string, value: string) => {
|
const handlePriceChange = (id: string, value: string) => {
|
||||||
const num = parseInt(value.replace(/[^0-9]/g, ''), 10);
|
const num = parseInt(value.replace(/[^0-9]/g, ''), 10);
|
||||||
if (!isNaN(num)) {
|
setEditedPrices((prev) => ({ ...prev, [id]: isNaN(num) ? 0 : num }));
|
||||||
setEditedPrices((prev) => ({ ...prev, [id]: num }));
|
|
||||||
} else if (value === '') {
|
|
||||||
setEditedPrices((prev) => ({ ...prev, [id]: 0 }));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSavePrices = () => {
|
||||||
Object.entries(editedPrices).forEach(([id, price]) => {
|
Object.entries(editedPrices).forEach(([id, price]) => {
|
||||||
pricingStore.getState().updatePrice(id, price);
|
pricingStore.getState().updatePrice(id, price);
|
||||||
});
|
});
|
||||||
setSaved(true);
|
setPriceSaved(true);
|
||||||
setTimeout(() => setSaved(false), 2000);
|
setTimeout(() => setPriceSaved(false), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleAddItem = () => {
|
||||||
pricingStore.getState().resetPrices();
|
setAddItemError('');
|
||||||
setSaved(false);
|
const id = newItem.id.trim().toLowerCase().replace(/\s+/g, '-');
|
||||||
|
const label = newItem.label.trim();
|
||||||
|
const price = parseInt(newItem.price, 10);
|
||||||
|
if (!id || !label || isNaN(price) || price < 0) {
|
||||||
|
setAddItemError('Please fill all fields with valid values.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (items.some((i) => i.id === id)) {
|
||||||
|
setAddItemError(`ID "${id}" already exists.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pricingStore.getState().addItem({ id, label, price });
|
||||||
|
setNewItem({ id: '', label: '', price: '' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatPrice = (price: number) => {
|
// --------------- PERSONAS ---------------
|
||||||
return new Intl.NumberFormat('en-AE', { style: 'decimal' }).format(price);
|
const personas = usePersonaStore((s) => s.personas);
|
||||||
|
const isPersonaHydrated = usePersonaStore((s) => s.isHydrated);
|
||||||
|
const [newPersona, setNewPersona] = useState({ label: '', description: '', torso: '#3b82f6', legs: '#3b82f6' });
|
||||||
|
const [personaError, setPersonaError] = useState('');
|
||||||
|
const [personaSaved, setPersonaSaved] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { personaStore.getState().hydrate(); }, []);
|
||||||
|
|
||||||
|
const handleAddPersona = () => {
|
||||||
|
setPersonaError('');
|
||||||
|
const label = newPersona.label.trim();
|
||||||
|
const description = newPersona.description.trim();
|
||||||
|
if (!label || !description) {
|
||||||
|
setPersonaError('Label and description are required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
personaStore.getState().addPersona({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
colors: { torso: newPersona.torso, legs: newPersona.legs },
|
||||||
|
});
|
||||||
|
setNewPersona({ label: '', description: '', torso: '#3b82f6', legs: '#3b82f6' });
|
||||||
|
setPersonaSaved(true);
|
||||||
|
setTimeout(() => setPersonaSaved(false), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isHydrated) {
|
// --------------- ORDERS ---------------
|
||||||
return (
|
const [orders, setOrders] = useState<Order[]>([]);
|
||||||
<div style={pageStyle}>
|
const [ordersLoading, setOrdersLoading] = useState(false);
|
||||||
<p style={{ color: '#64748b' }}>Loading...</p>
|
const [ordersError, setOrdersError] = useState('');
|
||||||
</div>
|
const [totalRevenue, setTotalRevenue] = useState(0);
|
||||||
);
|
|
||||||
|
const loadOrders = useCallback(async () => {
|
||||||
|
setOrdersLoading(true);
|
||||||
|
setOrdersError('');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/orders/');
|
||||||
|
if (!res.ok) throw new Error('Failed to load orders');
|
||||||
|
const data = await res.json();
|
||||||
|
setOrders(data.orders ?? []);
|
||||||
|
const succeeded = (data.orders ?? []).filter((o: Order) => o.status === 'succeeded');
|
||||||
|
setTotalRevenue(succeeded.reduce((sum: number, o: Order) => sum + o.amount, 0));
|
||||||
|
} catch (err) {
|
||||||
|
setOrdersError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
} finally {
|
||||||
|
setOrdersLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'orders') loadOrders();
|
||||||
|
}, [activeTab, loadOrders]);
|
||||||
|
|
||||||
|
// --------------- AUTH ---------------
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await fetch('/api/admin/logout/', { method: 'POST' });
|
||||||
|
router.push('/admin/login/');
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------- CHANGE PASSWORD ---------------
|
||||||
|
const [showPwModal, setShowPwModal] = useState(false);
|
||||||
|
const [pwForm, setPwForm] = useState({ current: '', next: '', confirm: '' });
|
||||||
|
const [pwError, setPwError] = useState('');
|
||||||
|
const [pwSaved, setPwSaved] = useState(false);
|
||||||
|
|
||||||
|
const handleChangePassword = async () => {
|
||||||
|
setPwError('');
|
||||||
|
if (pwForm.next !== pwForm.confirm) { setPwError('Passwords do not match.'); return; }
|
||||||
|
if (pwForm.next.length < 8) { setPwError('Password must be at least 8 characters.'); return; }
|
||||||
|
const res = await fetch('/api/admin/change-password/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ currentPassword: pwForm.current, newPassword: pwForm.next }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setPwSaved(true);
|
||||||
|
setTimeout(() => { setPwSaved(false); setShowPwModal(false); setPwForm({ current: '', next: '', confirm: '' }); }, 1500);
|
||||||
|
} else {
|
||||||
|
setPwError(data.error ?? 'Failed to update password.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPrice = (price: number) =>
|
||||||
|
new Intl.NumberFormat('en-AE', { style: 'decimal' }).format(price);
|
||||||
|
const formatAmount = (amount: number, currency: string) =>
|
||||||
|
new Intl.NumberFormat('en-AE', { style: 'currency', currency: currency.toUpperCase(), maximumFractionDigits: 0 }).format(amount / 100);
|
||||||
|
const formatDate = (ts: number) =>
|
||||||
|
new Date(ts * 1000).toLocaleDateString('en-AE', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||||
|
|
||||||
|
if (!isPricingHydrated) {
|
||||||
|
return <div style={pageStyle}><p style={{ color: '#64748b' }}>Loading…</p></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={pageStyle}>
|
<div style={pageStyle}>
|
||||||
<div style={containerStyle}>
|
<div style={containerStyle}>
|
||||||
{/* Header */}
|
{/* HEADER */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '2rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem' }}>
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, color: '#1a1a2e', margin: 0, marginBottom: '0.25rem' }}>
|
<h1 style={{ fontSize: '1.25rem', fontWeight: 700, color: '#1a1a2e', margin: 0, marginBottom: '0.2rem' }}>
|
||||||
Pricing Dashboard
|
Admin Dashboard
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ fontSize: '0.8rem', color: '#94a3b8', margin: 0 }}>
|
<p style={{ fontSize: '0.75rem', color: '#94a3b8', margin: 0 }}>Lootah Robotics G1 Configurator</p>
|
||||||
Edit prices for the G1 Robot Configurator
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
href="/"
|
<button onClick={() => setShowPwModal(true)} style={ghostBtnStyle}>Change Password</button>
|
||||||
|
<button onClick={handleLogout} style={{ ...ghostBtnStyle, color: '#ef4444', borderColor: 'rgba(239,68,68,0.2)' }}>Logout</button>
|
||||||
|
<Link href="/" style={{ ...ghostBtnStyle, textDecoration: 'none' }}>← Configurator</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ANALYTICS STRIP */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.75rem', marginBottom: '1.5rem' }}>
|
||||||
|
<StatCard label="Pricing Items" value={String(items.length)} />
|
||||||
|
<StatCard label="Personas" value={String(personas.length)} />
|
||||||
|
<StatCard label="Succeeded Revenue" value={totalRevenue > 0 ? formatAmount(totalRevenue, 'aed') : '—'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TABS */}
|
||||||
|
<div style={{ display: 'flex', gap: '0.25rem', marginBottom: '1.25rem', borderBottom: '1px solid rgba(0,0,0,0.06)', paddingBottom: '0.5rem' }}>
|
||||||
|
{(['pricing', 'personas', 'orders'] as Tab[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setActiveTab(t)}
|
||||||
style={{
|
style={{
|
||||||
padding: '0.5rem 1rem',
|
padding: '0.4rem 0.875rem',
|
||||||
borderRadius: '0.375rem',
|
borderRadius: '0.375rem',
|
||||||
border: '1px solid rgba(59, 130, 246, 0.2)',
|
border: 'none',
|
||||||
background: 'rgba(59, 130, 246, 0.06)',
|
background: activeTab === t ? 'rgba(59,130,246,0.08)' : 'transparent',
|
||||||
color: '#2563eb',
|
color: activeTab === t ? '#2563eb' : '#64748b',
|
||||||
fontSize: '0.8rem',
|
fontSize: '0.8rem',
|
||||||
textDecoration: 'none',
|
fontWeight: activeTab === t ? 600 : 400,
|
||||||
transition: 'all 0.2s ease',
|
cursor: 'pointer',
|
||||||
|
textTransform: 'capitalize',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Back to Configurator
|
{t}
|
||||||
</Link>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pricing Table */}
|
{/* ===== PRICING TAB ===== */}
|
||||||
<div style={{
|
{activeTab === 'pricing' && (
|
||||||
background: 'rgba(255, 255, 255, 0.95)',
|
|
||||||
backdropFilter: 'blur(20px)',
|
|
||||||
border: '1px solid rgba(0, 0, 0, 0.06)',
|
|
||||||
borderRadius: '0.75rem',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}>
|
|
||||||
{/* Table Header */}
|
|
||||||
<div style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: '1fr 200px',
|
|
||||||
padding: '0.75rem 1.25rem',
|
|
||||||
borderBottom: '1px solid rgba(0, 0, 0, 0.04)',
|
|
||||||
background: 'rgba(248, 248, 246, 0.5)',
|
|
||||||
}}>
|
|
||||||
<span style={{ fontSize: '0.7rem', fontWeight: 600, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
|
||||||
Item
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: '0.7rem', fontWeight: 600, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em', textAlign: 'right' }}>
|
|
||||||
Price (AED)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rows */}
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: '1fr 200px',
|
|
||||||
padding: '1rem 1.25rem',
|
|
||||||
alignItems: 'center',
|
|
||||||
borderBottom: index < items.length - 1 ? '1px solid rgba(0, 0, 0, 0.04)' : 'none',
|
|
||||||
transition: 'background 0.15s ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '0.875rem', color: '#374151', fontWeight: 500 }}>
|
<TableCard>
|
||||||
{item.label}
|
<TableHeader cols="1fr 180px 56px" labels={['Item', 'Price (AED)', '']} />
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<div key={item.id} style={{ display: 'grid', gridTemplateColumns: '1fr 180px 56px', padding: '0.875rem 1.25rem', alignItems: 'center', borderBottom: i < items.length - 1 ? '1px solid rgba(0,0,0,0.04)' : 'none' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.85rem', color: '#374151', fontWeight: 500 }}>{item.label}</div>
|
||||||
|
<div style={{ fontSize: '0.7rem', color: '#94a3b8', fontFamily: 'monospace' }}>{item.id}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.7rem', color: '#94a3b8', fontFamily: 'monospace' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||||
{item.id}
|
<span style={{ fontSize: '0.7rem', color: '#94a3b8' }}>AED</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '0.5rem' }}>
|
|
||||||
<span style={{ fontSize: '0.75rem', color: '#94a3b8' }}>AED</span>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formatPrice(editedPrices[item.id] ?? item.price)}
|
value={formatPrice(editedPrices[item.id] ?? item.price)}
|
||||||
onChange={(e) => handlePriceChange(item.id, e.target.value)}
|
onChange={(e) => handlePriceChange(item.id, e.target.value)}
|
||||||
style={{
|
style={tableInputStyle}
|
||||||
width: '130px',
|
onFocus={(e) => (e.currentTarget.style.borderColor = 'rgba(59,130,246,0.5)')}
|
||||||
padding: '0.5rem 0.75rem',
|
onBlur={(e) => (e.currentTarget.style.borderColor = 'rgba(0,0,0,0.1)')}
|
||||||
borderRadius: '0.375rem',
|
|
||||||
border: '1px solid rgba(0, 0, 0, 0.1)',
|
|
||||||
background: 'rgba(255, 255, 255, 1)',
|
|
||||||
color: '#1a1a2e',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
textAlign: 'right',
|
|
||||||
outline: 'none',
|
|
||||||
transition: 'border-color 0.2s ease',
|
|
||||||
}}
|
|
||||||
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
|
|
||||||
onBlur={(e) => { e.currentTarget.style.borderColor = 'rgba(0, 0, 0, 0.1)'; }}
|
|
||||||
aria-label={`Price for ${item.label}`}
|
aria-label={`Price for ${item.label}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
{item.id !== 'base' && (
|
||||||
|
<button onClick={() => pricingStore.getState().removeItem(item.id)} style={deleteBtnStyle} title="Remove item">✕</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</TableCard>
|
||||||
|
|
||||||
|
{/* Add new pricing item */}
|
||||||
|
<div style={{ marginTop: '1rem', padding: '1rem 1.25rem', background: 'rgba(248,248,246,0.6)', border: '1px solid rgba(0,0,0,0.06)', borderRadius: '0.75rem' }}>
|
||||||
|
<p style={{ fontSize: '0.75rem', fontWeight: 600, color: '#374151', marginBottom: '0.75rem', marginTop: 0 }}>Add Pricing Item</p>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 120px', gap: '0.5rem', alignItems: 'flex-end' }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>ID (slug)</label>
|
||||||
|
<input style={formInputStyle} placeholder="e.g. wifi-module" value={newItem.id} onChange={(e) => setNewItem((p) => ({ ...p, id: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Label</label>
|
||||||
|
<input style={formInputStyle} placeholder="e.g. Wi-Fi Module" value={newItem.label} onChange={(e) => setNewItem((p) => ({ ...p, label: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Price (AED)</label>
|
||||||
|
<input style={formInputStyle} type="number" min="0" placeholder="5000" value={newItem.price} onChange={(e) => setNewItem((p) => ({ ...p, price: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{addItemError && <p style={errorTextStyle}>{addItemError}</p>}
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.75rem', justifyContent: 'flex-end' }}>
|
||||||
|
<button onClick={() => pricingStore.getState().resetPrices()} style={dangerBtnStyle}>Reset to Defaults</button>
|
||||||
|
<button onClick={handleAddItem} style={secondaryBtnStyle}>Add Item</button>
|
||||||
|
<button onClick={handleSavePrices} style={{ ...primaryBtnStyle, background: priceSaved ? 'rgba(34,197,94,0.08)' : 'rgba(59,130,246,0.08)', color: priceSaved ? '#16a34a' : '#2563eb' }}>
|
||||||
|
{priceSaved ? 'Saved!' : 'Save Prices'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== PERSONAS TAB ===== */}
|
||||||
|
{activeTab === 'personas' && (
|
||||||
|
<div>
|
||||||
|
{!isPersonaHydrated ? (
|
||||||
|
<p style={{ color: '#64748b', fontSize: '0.85rem' }}>Loading personas…</p>
|
||||||
|
) : (
|
||||||
|
<TableCard>
|
||||||
|
<TableHeader cols="1fr 80px 80px 56px" labels={['Persona', 'Torso', 'Legs', '']} />
|
||||||
|
{personas.map((p, i) => (
|
||||||
|
<div key={p.id} style={{ display: 'grid', gridTemplateColumns: '1fr 80px 80px 56px', padding: '0.875rem 1.25rem', alignItems: 'center', borderBottom: i < personas.length - 1 ? '1px solid rgba(0,0,0,0.04)' : 'none' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.85rem', color: '#374151', fontWeight: 500 }}>{p.label}</div>
|
||||||
|
<div style={{ fontSize: '0.7rem', color: '#94a3b8' }}>{p.description}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||||
|
<div style={{ width: 20, height: 20, borderRadius: 4, backgroundColor: p.colors.torso, border: '1px solid rgba(0,0,0,0.1)', flexShrink: 0 }} />
|
||||||
|
<span style={{ fontSize: '0.7rem', color: '#94a3b8', fontFamily: 'monospace' }}>{p.colors.torso}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||||
|
<div style={{ width: 20, height: 20, borderRadius: 4, backgroundColor: p.colors.legs, border: '1px solid rgba(0,0,0,0.1)', flexShrink: 0 }} />
|
||||||
|
<span style={{ fontSize: '0.7rem', color: '#94a3b8', fontFamily: 'monospace' }}>{p.colors.legs}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
{p.id !== 'none' && (
|
||||||
|
<button onClick={() => personaStore.getState().removePersona(p.id)} style={deleteBtnStyle} title="Remove persona">✕</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</TableCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add new persona */}
|
||||||
|
<div style={{ marginTop: '1rem', padding: '1rem 1.25rem', background: 'rgba(248,248,246,0.6)', border: '1px solid rgba(0,0,0,0.06)', borderRadius: '0.75rem' }}>
|
||||||
|
<p style={{ fontSize: '0.75rem', fontWeight: 600, color: '#374151', marginBottom: '0.75rem', marginTop: 0 }}>Add Persona</p>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem' }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Label</label>
|
||||||
|
<input style={formInputStyle} placeholder="e.g. Security Guard" value={newPersona.label} onChange={(e) => setNewPersona((p) => ({ ...p, label: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Description</label>
|
||||||
|
<input style={formInputStyle} placeholder="e.g. Dark tactical suit" value={newPersona.description} onChange={(e) => setNewPersona((p) => ({ ...p, description: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Torso Color</label>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
|
<input type="color" value={newPersona.torso} onChange={(e) => setNewPersona((p) => ({ ...p, torso: e.target.value }))} style={{ width: 36, height: 32, border: '1px solid rgba(0,0,0,0.1)', borderRadius: 4, cursor: 'pointer', padding: 2 }} />
|
||||||
|
<input style={{ ...formInputStyle, fontFamily: 'monospace', flex: 1 }} value={newPersona.torso} onChange={(e) => setNewPersona((p) => ({ ...p, torso: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Legs Color</label>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
|
<input type="color" value={newPersona.legs} onChange={(e) => setNewPersona((p) => ({ ...p, legs: e.target.value }))} style={{ width: 36, height: 32, border: '1px solid rgba(0,0,0,0.1)', borderRadius: 4, cursor: 'pointer', padding: 2 }} />
|
||||||
|
<input style={{ ...formInputStyle, fontFamily: 'monospace', flex: 1 }} value={newPersona.legs} onChange={(e) => setNewPersona((p) => ({ ...p, legs: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{personaError && <p style={errorTextStyle}>{personaError}</p>}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem', marginTop: '0.75rem' }}>
|
||||||
|
<button onClick={() => personaStore.getState().resetPersonas()} style={dangerBtnStyle}>Reset to Defaults</button>
|
||||||
|
<button onClick={handleAddPersona} style={{ ...primaryBtnStyle, background: personaSaved ? 'rgba(34,197,94,0.08)' : 'rgba(59,130,246,0.08)', color: personaSaved ? '#16a34a' : '#2563eb' }}>
|
||||||
|
{personaSaved ? 'Added!' : 'Add Persona'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== ORDERS TAB ===== */}
|
||||||
|
{activeTab === 'orders' && (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '0.75rem' }}>
|
||||||
|
<button onClick={loadOrders} disabled={ordersLoading} style={secondaryBtnStyle}>
|
||||||
|
{ordersLoading ? 'Loading…' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{ordersError && <p style={errorTextStyle}>{ordersError}</p>}
|
||||||
|
{!ordersLoading && orders.length === 0 && !ordersError && (
|
||||||
|
<p style={{ color: '#94a3b8', fontSize: '0.85rem', textAlign: 'center', padding: '2rem' }}>No orders found.</p>
|
||||||
|
)}
|
||||||
|
{orders.length > 0 && (
|
||||||
|
<TableCard>
|
||||||
|
<TableHeader cols="1fr 120px 90px 100px 28px" labels={['Customer', 'Amount', 'Status', 'Date', '']} />
|
||||||
|
{orders.map((order, i) => (
|
||||||
|
<OrderRow
|
||||||
|
key={order.id}
|
||||||
|
order={order}
|
||||||
|
isLast={i === orders.length - 1}
|
||||||
|
formatAmount={formatAmount}
|
||||||
|
formatDate={formatDate}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableCard>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* CHANGE PASSWORD MODAL */}
|
||||||
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1.5rem', justifyContent: 'flex-end' }}>
|
{showPwModal && (
|
||||||
<button
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 50 }}>
|
||||||
onClick={handleReset}
|
<div style={{ background: '#fff', borderRadius: '1rem', padding: '1.5rem', width: '100%', maxWidth: '380px', boxShadow: '0 20px 60px rgba(0,0,0,0.15)' }}>
|
||||||
style={{
|
<h2 style={{ fontSize: '1rem', fontWeight: 700, color: '#1a1a2e', margin: '0 0 1.25rem' }}>Change Password</h2>
|
||||||
padding: '0.6rem 1.25rem',
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
borderRadius: '0.375rem',
|
{(['current', 'next', 'confirm'] as const).map((field) => (
|
||||||
border: '1px solid rgba(239, 68, 68, 0.2)',
|
<div key={field}>
|
||||||
background: 'rgba(239, 68, 68, 0.05)',
|
<label style={labelStyle}>{field === 'current' ? 'Current Password' : field === 'next' ? 'New Password' : 'Confirm New Password'}</label>
|
||||||
color: '#ef4444',
|
<input
|
||||||
cursor: 'pointer',
|
type="password"
|
||||||
fontSize: '0.8rem',
|
autoComplete={field === 'current' ? 'current-password' : 'new-password'}
|
||||||
transition: 'all 0.2s ease',
|
value={pwForm[field]}
|
||||||
}}
|
onChange={(e) => setPwForm((p) => ({ ...p, [field]: e.target.value }))}
|
||||||
>
|
style={formInputStyle}
|
||||||
Reset to Defaults
|
/>
|
||||||
</button>
|
</div>
|
||||||
<button
|
))}
|
||||||
onClick={handleSave}
|
{pwError && <p style={errorTextStyle}>{pwError}</p>}
|
||||||
style={{
|
{pwSaved && <p style={{ color: '#16a34a', fontSize: '0.8rem' }}>Password updated!</p>}
|
||||||
padding: '0.6rem 1.5rem',
|
</div>
|
||||||
borderRadius: '0.375rem',
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem', justifyContent: 'flex-end' }}>
|
||||||
border: '1px solid rgba(59, 130, 246, 0.3)',
|
<button onClick={() => { setShowPwModal(false); setPwError(''); }} style={ghostBtnStyle}>Cancel</button>
|
||||||
background: saved ? 'rgba(34, 197, 94, 0.08)' : 'rgba(59, 130, 246, 0.08)',
|
<button onClick={handleChangePassword} style={primaryBtnStyle}>Update</button>
|
||||||
color: saved ? '#16a34a' : '#2563eb',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
fontWeight: 600,
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{saved ? 'Saved!' : 'Save Prices'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== SUB-COMPONENTS =====
|
||||||
|
|
||||||
|
function OrderRow({
|
||||||
|
order,
|
||||||
|
isLast,
|
||||||
|
formatAmount,
|
||||||
|
formatDate,
|
||||||
|
}: {
|
||||||
|
order: Order;
|
||||||
|
isLast: boolean;
|
||||||
|
formatAmount: (amount: number, currency: string) => string;
|
||||||
|
formatDate: (ts: number) => string;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const m = order.metadata;
|
||||||
|
const name = m.customerName || '—';
|
||||||
|
const email = m.customerEmail || '';
|
||||||
|
const phone = m.customerPhone || '';
|
||||||
|
const address = [m.customerAddress, m.customerCity, m.customerCountry, m.customerPostalCode]
|
||||||
|
.filter(Boolean).join(', ') || '';
|
||||||
|
const persona = m.persona || '';
|
||||||
|
const color = m.color || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ borderBottom: isLast ? 'none' : '1px solid rgba(0,0,0,0.04)' }}>
|
||||||
|
{/* Main row */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 120px 90px 100px 28px', padding: '0.75rem 1.25rem', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.85rem', color: '#374151', fontWeight: 500 }}>{name}</div>
|
||||||
|
{email && <div style={{ fontSize: '0.7rem', color: '#94a3b8' }}>{email}</div>}
|
||||||
|
<div style={{ fontSize: '0.65rem', color: '#cbd5e1', fontFamily: 'monospace', marginTop: '0.1rem' }}>{order.id}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.85rem', color: '#374151', fontWeight: 500 }}>{formatAmount(order.amount, order.currency)}</div>
|
||||||
|
<div>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '0.15rem 0.5rem',
|
||||||
|
borderRadius: '999px',
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
background: order.status === 'succeeded' ? 'rgba(34,197,94,0.1)' : 'rgba(148,163,184,0.15)',
|
||||||
|
color: order.status === 'succeeded' ? '#16a34a' : '#64748b',
|
||||||
|
}}>
|
||||||
|
{order.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: '#94a3b8' }}>{formatDate(order.created)}</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
title={expanded ? 'Collapse' : 'Show details'}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#94a3b8', fontSize: '0.7rem', padding: '2px 4px', borderRadius: 4 }}
|
||||||
|
>
|
||||||
|
{expanded ? '▲' : '▼'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded customer details */}
|
||||||
|
{expanded && (
|
||||||
|
<div style={{ padding: '0 1.25rem 0.875rem 1.25rem' }}>
|
||||||
|
<div style={{ background: 'rgba(248,248,246,0.8)', borderRadius: '0.5rem', padding: '0.75rem 1rem', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem 1.5rem' }}>
|
||||||
|
{[
|
||||||
|
{ label: 'Full Name', value: m.customerName },
|
||||||
|
{ label: 'Email', value: m.customerEmail },
|
||||||
|
{ label: 'Phone', value: m.customerPhone },
|
||||||
|
{ label: 'Address', value: address },
|
||||||
|
{ label: 'Persona', value: persona },
|
||||||
|
{ label: 'Color', value: color },
|
||||||
|
].map(({ label, value }) =>
|
||||||
|
value ? (
|
||||||
|
<div key={label}>
|
||||||
|
<div style={{ fontSize: '0.65rem', fontWeight: 600, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{label}</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: '#374151', marginTop: '0.125rem', wordBreak: 'break-word' }}>{value}</div>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.95)', border: '1px solid rgba(0,0,0,0.06)', borderRadius: '0.75rem', padding: '1rem 1.25rem' }}>
|
||||||
|
<div style={{ fontSize: '0.7rem', fontWeight: 600, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.25rem' }}>{label}</div>
|
||||||
|
<div style={{ fontSize: '1.25rem', fontWeight: 700, color: '#1a1a2e' }}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCard({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.95)', backdropFilter: 'blur(20px)', border: '1px solid rgba(0,0,0,0.06)', borderRadius: '0.75rem', overflow: 'hidden' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ cols, labels }: { cols: string; labels: string[] }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: cols, padding: '0.6rem 1.25rem', borderBottom: '1px solid rgba(0,0,0,0.04)', background: 'rgba(248,248,246,0.5)' }}>
|
||||||
|
{labels.map((l) => (
|
||||||
|
<span key={l} style={{ fontSize: '0.65rem', fontWeight: 600, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em' }}>{l}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== STYLES =====
|
||||||
|
|
||||||
const pageStyle: React.CSSProperties = {
|
const pageStyle: React.CSSProperties = {
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
background: '#ffffff',
|
background: '#ffffff',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'flex-start',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: '2rem',
|
padding: '2rem',
|
||||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||||
@ -210,5 +528,102 @@ const pageStyle: React.CSSProperties = {
|
|||||||
|
|
||||||
const containerStyle: React.CSSProperties = {
|
const containerStyle: React.CSSProperties = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: '640px',
|
maxWidth: '860px',
|
||||||
|
paddingTop: '1rem',
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
display: 'block',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#374151',
|
||||||
|
marginBottom: '0.25rem',
|
||||||
|
letterSpacing: '0.02em',
|
||||||
|
};
|
||||||
|
|
||||||
|
const formInputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
border: '1px solid rgba(0,0,0,0.1)',
|
||||||
|
background: '#ffffff',
|
||||||
|
color: '#1a1a2e',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
outline: 'none',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
};
|
||||||
|
|
||||||
|
const tableInputStyle: React.CSSProperties = {
|
||||||
|
width: '120px',
|
||||||
|
padding: '0.4rem 0.6rem',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
border: '1px solid rgba(0,0,0,0.1)',
|
||||||
|
background: '#ffffff',
|
||||||
|
color: '#1a1a2e',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
textAlign: 'right' as const,
|
||||||
|
outline: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ghostBtnStyle: React.CSSProperties = {
|
||||||
|
padding: '0.4rem 0.875rem',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
border: '1px solid rgba(0,0,0,0.1)',
|
||||||
|
background: 'transparent',
|
||||||
|
color: '#374151',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
};
|
||||||
|
|
||||||
|
const primaryBtnStyle: React.CSSProperties = {
|
||||||
|
padding: '0.5rem 1.25rem',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
border: '1px solid rgba(59,130,246,0.3)',
|
||||||
|
background: 'rgba(59,130,246,0.08)',
|
||||||
|
color: '#2563eb',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondaryBtnStyle: React.CSSProperties = {
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
border: '1px solid rgba(0,0,0,0.1)',
|
||||||
|
background: 'rgba(248,248,246,0.8)',
|
||||||
|
color: '#374151',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
};
|
||||||
|
|
||||||
|
const dangerBtnStyle: React.CSSProperties = {
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
border: '1px solid rgba(239,68,68,0.2)',
|
||||||
|
background: 'rgba(239,68,68,0.05)',
|
||||||
|
color: '#ef4444',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteBtnStyle: React.CSSProperties = {
|
||||||
|
width: '24px',
|
||||||
|
height: '24px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: '1px solid rgba(239,68,68,0.2)',
|
||||||
|
background: 'rgba(239,68,68,0.06)',
|
||||||
|
color: '#ef4444',
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorTextStyle: React.CSSProperties = {
|
||||||
|
color: '#dc2626',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
margin: '0.5rem 0 0',
|
||||||
};
|
};
|
||||||
|
|||||||
37
src/app/api/admin/change-password/route.ts
Normal file
37
src/app/api/admin/change-password/route.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { jwtVerify } from 'jose';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get('admin_token')?.value;
|
||||||
|
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const jwtSecret = process.env.ADMIN_JWT_SECRET;
|
||||||
|
if (!jwtSecret) return NextResponse.json({ error: 'Server error' }, { status: 500 });
|
||||||
|
|
||||||
|
const secret = new TextEncoder().encode(jwtSecret);
|
||||||
|
const { payload } = await jwtVerify(token, secret);
|
||||||
|
|
||||||
|
const { currentPassword, newPassword } = await request.json();
|
||||||
|
if (!currentPassword || !newPassword || newPassword.length < 8) {
|
||||||
|
return NextResponse.json({ error: 'Invalid input. New password must be at least 8 characters.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.adminUser.findUnique({ where: { id: payload.sub as string } });
|
||||||
|
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
|
||||||
|
if (!valid) return NextResponse.json({ error: 'Current password is incorrect' }, { status: 401 });
|
||||||
|
|
||||||
|
const newHash = await bcrypt.hash(newPassword, 12);
|
||||||
|
await prisma.adminUser.update({ where: { id: user.id }, data: { passwordHash: newHash } });
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/app/api/admin/login/route.ts
Normal file
49
src/app/api/admin/login/route.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { SignJWT } from 'jose';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { username, password } = await request.json();
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json({ error: 'Username and password are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.adminUser.findUnique({ where: { username } });
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||||
|
if (!valid) {
|
||||||
|
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const jwtSecret = process.env.ADMIN_JWT_SECRET;
|
||||||
|
if (!jwtSecret) {
|
||||||
|
return NextResponse.json({ error: 'Server configuration error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = new TextEncoder().encode(jwtSecret);
|
||||||
|
const token = await new SignJWT({ sub: user.id, username: user.username })
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime('7d')
|
||||||
|
.sign(secret);
|
||||||
|
|
||||||
|
const response = NextResponse.json({ success: true });
|
||||||
|
response.cookies.set('admin_token', token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/app/api/admin/logout/route.ts
Normal file
13
src/app/api/admin/logout/route.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const response = NextResponse.json({ success: true });
|
||||||
|
response.cookies.set('admin_token', '', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 0,
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
32
src/app/api/admin/orders/route.ts
Normal file
32
src/app/api/admin/orders/route.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||||
|
apiVersion: '2026-03-25.dahlia' as Parameters<typeof Stripe>[1]['apiVersion'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const limit = Math.min(parseInt(searchParams.get('limit') ?? '20', 10), 100);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const paymentIntents = await stripe.paymentIntents.list({
|
||||||
|
limit,
|
||||||
|
expand: ['data.latest_charge'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const orders = paymentIntents.data.map((pi) => ({
|
||||||
|
id: pi.id,
|
||||||
|
amount: pi.amount,
|
||||||
|
currency: pi.currency,
|
||||||
|
status: pi.status,
|
||||||
|
created: pi.created,
|
||||||
|
metadata: pi.metadata,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ orders, hasMore: paymentIntents.has_more });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/app/api/admin/verify/route.ts
Normal file
26
src/app/api/admin/verify/route.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { jwtVerify } from 'jose';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get('admin_token')?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ authenticated: false }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const jwtSecret = process.env.ADMIN_JWT_SECRET;
|
||||||
|
if (!jwtSecret) {
|
||||||
|
return NextResponse.json({ authenticated: false }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = new TextEncoder().encode(jwtSecret);
|
||||||
|
await jwtVerify(token, secret);
|
||||||
|
|
||||||
|
return NextResponse.json({ authenticated: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ authenticated: false }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/app/api/create-payment-intent/route.ts
Normal file
39
src/app/api/create-payment-intent/route.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
|
if (!process.env.STRIPE_SECRET_KEY) {
|
||||||
|
console.error('STRIPE_SECRET_KEY is not set in environment variables');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || '', {
|
||||||
|
apiVersion: '2026-03-25.dahlia',
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { amount, currency, metadata, receiptEmail } = await request.json();
|
||||||
|
|
||||||
|
if (!amount || typeof amount !== 'number' || amount <= 0) {
|
||||||
|
return NextResponse.json({ error: 'Invalid amount' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stripe expects amount in the smallest currency unit (fils for AED)
|
||||||
|
const amountInFils = Math.round(amount * 100);
|
||||||
|
|
||||||
|
const paymentIntent = await stripe.paymentIntents.create({
|
||||||
|
amount: amountInFils,
|
||||||
|
currency: currency || 'aed',
|
||||||
|
metadata: metadata || {},
|
||||||
|
...(receiptEmail ? { receipt_email: receiptEmail } : {}),
|
||||||
|
automatic_payment_methods: { enabled: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
clientSecret: paymentIntent.client_secret,
|
||||||
|
paymentIntentId: paymentIntent.id,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Internal server error';
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,12 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { loadStripe } from '@stripe/stripe-js';
|
||||||
|
import { Elements } from '@stripe/react-stripe-js';
|
||||||
import { useOrderStore, orderStore, type CheckoutStep } from '@/store/useOrderStore';
|
import { useOrderStore, orderStore, type CheckoutStep } from '@/store/useOrderStore';
|
||||||
import { ShippingStep } from './checkout/ShippingStep';
|
import { ShippingStep } from './checkout/ShippingStep';
|
||||||
import { PaymentStep } from './checkout/PaymentStep';
|
import { PaymentStep } from './checkout/PaymentStep';
|
||||||
import { ReviewStep } from './checkout/ReviewStep';
|
import { ReviewStep } from './checkout/ReviewStep';
|
||||||
import { ConfirmationStep } from './checkout/ConfirmationStep';
|
import { ConfirmationStep } from './checkout/ConfirmationStep';
|
||||||
|
|
||||||
|
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
|
||||||
|
|
||||||
const STEPS: { id: CheckoutStep; label: string }[] = [
|
const STEPS: { id: CheckoutStep; label: string }[] = [
|
||||||
{ id: 'shipping', label: 'Shipping' },
|
{ id: 'shipping', label: 'Shipping' },
|
||||||
{ id: 'payment', label: 'Payment' },
|
{ id: 'payment', label: 'Payment' },
|
||||||
@ -19,6 +23,10 @@ function getStepIndex(step: CheckoutStep): number {
|
|||||||
|
|
||||||
export function CheckoutOverlay() {
|
export function CheckoutOverlay() {
|
||||||
const step = useOrderStore((s) => s.step);
|
const step = useOrderStore((s) => s.step);
|
||||||
|
const clientSecret = useOrderStore((s) => s.payment.clientSecret);
|
||||||
|
const paymentStatus = useOrderStore((s) => s.payment.status);
|
||||||
|
const paymentError = useOrderStore((s) => s.payment.errorMessage);
|
||||||
|
const [isCreatingIntent, setIsCreatingIntent] = useState(false);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
orderStore.getState().resetOrder();
|
orderStore.getState().resetOrder();
|
||||||
@ -33,6 +41,16 @@ export function CheckoutOverlay() {
|
|||||||
}
|
}
|
||||||
}, [step]);
|
}, [step]);
|
||||||
|
|
||||||
|
// Create payment intent when entering the payment step (only once)
|
||||||
|
useEffect(() => {
|
||||||
|
if (step === 'payment' && !clientSecret && !isCreatingIntent && paymentStatus !== 'failed') {
|
||||||
|
setIsCreatingIntent(true);
|
||||||
|
orderStore.getState().createPaymentIntent().finally(() => {
|
||||||
|
setIsCreatingIntent(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [step, clientSecret, isCreatingIntent, paymentStatus]);
|
||||||
|
|
||||||
// Close on Escape key
|
// Close on Escape key
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (step === 'config') return;
|
if (step === 'config') return;
|
||||||
@ -153,8 +171,55 @@ export function CheckoutOverlay() {
|
|||||||
{/* Step Content */}
|
{/* Step Content */}
|
||||||
<div style={{ padding: '1.5rem' }}>
|
<div style={{ padding: '1.5rem' }}>
|
||||||
{step === 'shipping' && <ShippingStep />}
|
{step === 'shipping' && <ShippingStep />}
|
||||||
{step === 'payment' && <PaymentStep />}
|
{(step === 'payment' || step === 'review') && clientSecret ? (
|
||||||
|
<Elements
|
||||||
|
stripe={stripePromise}
|
||||||
|
options={{
|
||||||
|
clientSecret,
|
||||||
|
appearance: {
|
||||||
|
theme: 'stripe',
|
||||||
|
variables: {
|
||||||
|
colorPrimary: '#2563eb',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Keep PaymentElement mounted (hidden) during review so confirmPayment works */}
|
||||||
|
<div style={{ display: step === 'payment' ? 'block' : 'none' }}>
|
||||||
|
<PaymentStep />
|
||||||
|
</div>
|
||||||
{step === 'review' && <ReviewStep />}
|
{step === 'review' && <ReviewStep />}
|
||||||
|
</Elements>
|
||||||
|
) : (step === 'payment' || step === 'review') && !clientSecret ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '2rem', fontSize: '0.85rem' }}>
|
||||||
|
{paymentStatus === 'failed' ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', alignItems: 'center' }}>
|
||||||
|
<div style={{ color: '#dc2626' }}>{paymentError || 'Failed to initialize payment'}</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
orderStore.getState().setPayment({ status: 'idle', errorMessage: '' });
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
border: '1px solid rgba(59, 130, 246, 0.5)',
|
||||||
|
background: 'rgba(59, 130, 246, 0.08)',
|
||||||
|
color: '#2563eb',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: '#94a3b8' }}>Initializing secure payment...</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{step === 'confirmed' && <ConfirmationStep />}
|
{step === 'confirmed' && <ConfirmationStep />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,44 +1,22 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useRef } from 'react';
|
import { useCallback, useEffect, 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);
|
||||||
|
|
||||||
const colorsSectionRef = useRef<HTMLElement>(null);
|
const colorsSectionRef = useRef<HTMLElement>(null);
|
||||||
const personaSectionRef = useRef<HTMLElement>(null);
|
const personaSectionRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
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 });
|
||||||
}, []);
|
}, []);
|
||||||
@ -80,7 +58,7 @@ export function ConfigPanel() {
|
|||||||
>
|
>
|
||||||
<h3 style={sectionTitleStyle}>Persona Attire</h3>
|
<h3 style={sectionTitleStyle}>Persona Attire</h3>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
{PERSONA_OPTIONS.map((persona) => {
|
{personas.map((persona) => {
|
||||||
const isActive = activePersona === persona.id;
|
const isActive = activePersona === persona.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -1,89 +1,33 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState } from 'react';
|
||||||
import { orderStore, type PaymentInfo } from '@/store/useOrderStore';
|
import { PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
|
||||||
|
import { orderStore } from '@/store/useOrderStore';
|
||||||
const inputStyle: React.CSSProperties = {
|
|
||||||
width: '100%',
|
|
||||||
padding: '0.6rem 0.75rem',
|
|
||||||
borderRadius: '0.375rem',
|
|
||||||
border: '1px solid rgba(0, 0, 0, 0.08)',
|
|
||||||
background: 'rgba(255, 255, 255, 0.8)',
|
|
||||||
color: '#1a1a2e',
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
outline: 'none',
|
|
||||||
transition: 'border-color 0.2s ease',
|
|
||||||
};
|
|
||||||
|
|
||||||
const labelStyle: React.CSSProperties = {
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
fontWeight: 500,
|
|
||||||
color: '#94a3b8',
|
|
||||||
marginBottom: '0.3rem',
|
|
||||||
display: 'block',
|
|
||||||
};
|
|
||||||
|
|
||||||
const errorStyle: React.CSSProperties = {
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
color: '#ef4444',
|
|
||||||
marginTop: '0.2rem',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface FormErrors {
|
|
||||||
[key: string]: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCardNumber(value: string): string {
|
|
||||||
const digits = value.replace(/\D/g, '').slice(0, 16);
|
|
||||||
return digits.replace(/(\d{4})(?=\d)/g, '$1 ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatExpiry(value: string): string {
|
|
||||||
const digits = value.replace(/\D/g, '').slice(0, 4);
|
|
||||||
if (digits.length > 2) {
|
|
||||||
return `${digits.slice(0, 2)}/${digits.slice(2)}`;
|
|
||||||
}
|
|
||||||
return digits;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PaymentStep() {
|
export function PaymentStep() {
|
||||||
const [form, setForm] = useState<PaymentInfo>({
|
const stripe = useStripe();
|
||||||
cardNumber: '',
|
const elements = useElements();
|
||||||
expiry: '',
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
cvv: '',
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
nameOnCard: '',
|
|
||||||
});
|
|
||||||
const [errors, setErrors] = useState<FormErrors>({});
|
|
||||||
|
|
||||||
const handleChange = useCallback((field: keyof PaymentInfo, value: string) => {
|
const handleSubmit = async () => {
|
||||||
let processed = value;
|
if (!stripe || !elements) return;
|
||||||
if (field === 'cardNumber') processed = formatCardNumber(value);
|
|
||||||
if (field === 'expiry') processed = formatExpiry(value);
|
|
||||||
if (field === 'cvv') processed = value.replace(/\D/g, '').slice(0, 3);
|
|
||||||
|
|
||||||
setForm((prev) => ({ ...prev, [field]: processed }));
|
setIsLoading(true);
|
||||||
setErrors((prev) => {
|
setErrorMsg('');
|
||||||
const next = { ...prev };
|
|
||||||
delete next[field];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const validate = (): boolean => {
|
// Validate the payment element first
|
||||||
const errs: FormErrors = {};
|
const { error: submitError } = await elements.submit();
|
||||||
const digits = form.cardNumber.replace(/\s/g, '');
|
if (submitError) {
|
||||||
if (digits.length < 16) errs.cardNumber = 'Enter a valid 16-digit card number';
|
setErrorMsg(submitError.message || 'Validation failed');
|
||||||
if (form.expiry.length < 5) errs.expiry = 'Enter a valid expiry (MM/YY)';
|
setIsLoading(false);
|
||||||
if (form.cvv.length < 3) errs.cvv = 'Enter a valid 3-digit CVV';
|
return;
|
||||||
if (!form.nameOnCard.trim()) errs.nameOnCard = 'Name on card is required';
|
}
|
||||||
setErrors(errs);
|
|
||||||
return Object.keys(errs).length === 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
// Move to review — actual confirmation happens on "Place Order"
|
||||||
if (!validate()) return;
|
orderStore.getState().setPayment({ status: 'idle' });
|
||||||
orderStore.getState().setPayment(form);
|
|
||||||
orderStore.getState().setStep('review');
|
orderStore.getState().setStep('review');
|
||||||
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -92,90 +36,49 @@ export function PaymentStep() {
|
|||||||
Payment Details
|
Payment Details
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
padding: '1rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
background: '#fff',
|
||||||
|
border: '1px solid rgba(0, 0, 0, 0.08)',
|
||||||
|
}}>
|
||||||
|
<PaymentElement
|
||||||
|
options={{
|
||||||
|
layout: 'tabs',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorMsg && (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '0.6rem 0.75rem',
|
padding: '0.6rem 0.75rem',
|
||||||
borderRadius: '0.375rem',
|
borderRadius: '0.375rem',
|
||||||
background: 'rgba(245, 158, 11, 0.06)',
|
background: 'rgba(239, 68, 68, 0.06)',
|
||||||
border: '1px solid rgba(245, 158, 11, 0.15)',
|
border: '1px solid rgba(239, 68, 68, 0.15)',
|
||||||
fontSize: '0.7rem',
|
fontSize: '0.75rem',
|
||||||
color: '#d97706',
|
color: '#dc2626',
|
||||||
}}>
|
}}>
|
||||||
This is a demo checkout. No real payment will be processed.
|
{errorMsg}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>Card Number</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.cardNumber}
|
|
||||||
onChange={(e) => handleChange('cardNumber', e.target.value)}
|
|
||||||
placeholder="4242 4242 4242 4242"
|
|
||||||
style={{ ...inputStyle, fontFamily: 'monospace', letterSpacing: '0.1em', borderColor: errors.cardNumber ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
|
|
||||||
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
|
|
||||||
onBlur={(e) => { e.currentTarget.style.borderColor = errors.cardNumber ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
|
|
||||||
/>
|
|
||||||
{errors.cardNumber && <div style={errorStyle}>{errors.cardNumber}</div>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>Expiry Date</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.expiry}
|
|
||||||
onChange={(e) => handleChange('expiry', e.target.value)}
|
|
||||||
placeholder="MM/YY"
|
|
||||||
style={{ ...inputStyle, fontFamily: 'monospace', borderColor: errors.expiry ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
|
|
||||||
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
|
|
||||||
onBlur={(e) => { e.currentTarget.style.borderColor = errors.expiry ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
|
|
||||||
/>
|
|
||||||
{errors.expiry && <div style={errorStyle}>{errors.expiry}</div>}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>CVV</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.cvv}
|
|
||||||
onChange={(e) => handleChange('cvv', e.target.value)}
|
|
||||||
placeholder="123"
|
|
||||||
style={{ ...inputStyle, fontFamily: 'monospace', borderColor: errors.cvv ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
|
|
||||||
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
|
|
||||||
onBlur={(e) => { e.currentTarget.style.borderColor = errors.cvv ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
|
|
||||||
/>
|
|
||||||
{errors.cvv && <div style={errorStyle}>{errors.cvv}</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>Name on Card</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.nameOnCard}
|
|
||||||
onChange={(e) => handleChange('nameOnCard', e.target.value)}
|
|
||||||
placeholder="John Doe"
|
|
||||||
style={{ ...inputStyle, borderColor: errors.nameOnCard ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
|
|
||||||
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
|
|
||||||
onBlur={(e) => { e.currentTarget.style.borderColor = errors.nameOnCard ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
|
|
||||||
/>
|
|
||||||
{errors.nameOnCard && <div style={errorStyle}>{errors.nameOnCard}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
|
disabled={!stripe || isLoading}
|
||||||
style={{
|
style={{
|
||||||
marginTop: '0.5rem',
|
marginTop: '0.5rem',
|
||||||
padding: '0.75rem',
|
padding: '0.75rem',
|
||||||
borderRadius: '0.375rem',
|
borderRadius: '0.375rem',
|
||||||
border: '1px solid rgba(59, 130, 246, 0.5)',
|
border: '1px solid rgba(59, 130, 246, 0.5)',
|
||||||
background: 'rgba(59, 130, 246, 0.08)',
|
background: isLoading ? 'rgba(59, 130, 246, 0.15)' : 'rgba(59, 130, 246, 0.08)',
|
||||||
color: '#2563eb',
|
color: '#2563eb',
|
||||||
cursor: 'pointer',
|
cursor: isLoading ? 'wait' : 'pointer',
|
||||||
fontSize: '0.85rem',
|
fontSize: '0.85rem',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
transition: 'all 0.2s ease',
|
transition: 'all 0.2s ease',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Review Order
|
{isLoading ? 'Validating...' : 'Review Order'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
'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';
|
||||||
|
|
||||||
function formatAED(price: number): string {
|
function formatAED(price: number): string {
|
||||||
@ -8,16 +9,41 @@ function formatAED(price: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ReviewStep() {
|
export function ReviewStep() {
|
||||||
|
const stripe = useStripe();
|
||||||
|
const elements = useElements();
|
||||||
const shipping = useOrderStore((s) => s.shipping);
|
const shipping = useOrderStore((s) => s.shipping);
|
||||||
const orderTotal = useOrderStore((s) => s.orderTotal);
|
const orderTotal = useOrderStore((s) => s.orderTotal);
|
||||||
const personaSummary = useOrderStore((s) => s.personaSummary);
|
const personaSummary = useOrderStore((s) => s.personaSummary);
|
||||||
const colorSummary = useOrderStore((s) => s.colorSummary);
|
const colorSummary = useOrderStore((s) => s.colorSummary);
|
||||||
|
const clientSecret = useOrderStore((s) => s.payment.clientSecret);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
|
||||||
const handlePlaceOrder = async () => {
|
const handlePlaceOrder = async () => {
|
||||||
|
if (!stripe || !elements || !clientSecret) return;
|
||||||
|
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
// Simulate payment processing delay
|
setErrorMsg('');
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
||||||
|
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
|
||||||
|
orderStore.getState().setPayment({ status: 'succeeded' });
|
||||||
orderStore.getState().placeOrder();
|
orderStore.getState().placeOrder();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -69,10 +95,24 @@ export function ReviewStep() {
|
|||||||
</div>
|
</div>
|
||||||
</SummarySection>
|
</SummarySection>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{errorMsg && (
|
||||||
|
<div style={{
|
||||||
|
padding: '0.6rem 0.75rem',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
background: 'rgba(239, 68, 68, 0.06)',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.15)',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: '#dc2626',
|
||||||
|
}}>
|
||||||
|
{errorMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Place Order Button */}
|
{/* Place Order Button */}
|
||||||
<button
|
<button
|
||||||
onClick={handlePlaceOrder}
|
onClick={handlePlaceOrder}
|
||||||
disabled={isProcessing}
|
disabled={isProcessing || !stripe}
|
||||||
style={{
|
style={{
|
||||||
marginTop: '0.25rem',
|
marginTop: '0.25rem',
|
||||||
padding: '0.85rem',
|
padding: '0.85rem',
|
||||||
|
|||||||
15
src/lib/prisma.ts
Normal file
15
src/lib/prisma.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
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() {
|
||||||
|
const dbPath = path.resolve(process.cwd(), 'prisma/lootah.db');
|
||||||
|
const adapter = new PrismaLibSql({ url: `file:${dbPath}` });
|
||||||
|
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/middleware.ts
Normal file
34
src/middleware.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { jwtVerify } from 'jose';
|
||||||
|
|
||||||
|
export async function middleware(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,8 +20,9 @@ describe('useOrderStore', () => {
|
|||||||
|
|
||||||
it('should have empty payment info', () => {
|
it('should have empty payment info', () => {
|
||||||
const { payment } = orderStore.getState();
|
const { payment } = orderStore.getState();
|
||||||
expect(payment.cardNumber).toBe('');
|
expect(payment.paymentIntentId).toBe('');
|
||||||
expect(payment.cvv).toBe('');
|
expect(payment.clientSecret).toBe('');
|
||||||
|
expect(payment.status).toBe('idle');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have no order ID', () => {
|
it('should have no order ID', () => {
|
||||||
@ -69,16 +70,15 @@ describe('useOrderStore', () => {
|
|||||||
describe('setPayment', () => {
|
describe('setPayment', () => {
|
||||||
it('should store payment information', () => {
|
it('should store payment information', () => {
|
||||||
orderStore.getState().setPayment({
|
orderStore.getState().setPayment({
|
||||||
cardNumber: '4242 4242 4242 4242',
|
paymentIntentId: 'pi_test_123',
|
||||||
expiry: '12/28',
|
clientSecret: 'pi_test_123_secret_abc',
|
||||||
cvv: '123',
|
status: 'succeeded',
|
||||||
nameOnCard: 'John Doe',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { payment } = orderStore.getState();
|
const { payment } = orderStore.getState();
|
||||||
expect(payment.cardNumber).toBe('4242 4242 4242 4242');
|
expect(payment.paymentIntentId).toBe('pi_test_123');
|
||||||
expect(payment.expiry).toBe('12/28');
|
expect(payment.clientSecret).toBe('pi_test_123_secret_abc');
|
||||||
expect(payment.nameOnCard).toBe('John Doe');
|
expect(payment.status).toBe('succeeded');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -14,10 +14,10 @@ export interface ShippingInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PaymentInfo {
|
export interface PaymentInfo {
|
||||||
cardNumber: string;
|
paymentIntentId: string;
|
||||||
expiry: string;
|
clientSecret: string;
|
||||||
cvv: string;
|
status: 'idle' | 'processing' | 'succeeded' | 'failed';
|
||||||
nameOnCard: string;
|
errorMessage: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrderState {
|
export interface OrderState {
|
||||||
@ -33,9 +33,10 @@ export interface OrderState {
|
|||||||
export interface OrderActions {
|
export interface OrderActions {
|
||||||
setStep: (step: CheckoutStep) => void;
|
setStep: (step: CheckoutStep) => void;
|
||||||
setShipping: (shipping: ShippingInfo) => void;
|
setShipping: (shipping: ShippingInfo) => void;
|
||||||
setPayment: (payment: PaymentInfo) => void;
|
setPayment: (payment: Partial<PaymentInfo>) => void;
|
||||||
setOrderTotal: (total: number) => void;
|
setOrderTotal: (total: number) => void;
|
||||||
setConfigSummary: (persona: string, color: string) => void;
|
setConfigSummary: (persona: string, color: string) => void;
|
||||||
|
createPaymentIntent: () => Promise<string | null>;
|
||||||
placeOrder: () => void;
|
placeOrder: () => void;
|
||||||
resetOrder: () => void;
|
resetOrder: () => void;
|
||||||
}
|
}
|
||||||
@ -53,10 +54,10 @@ const emptyShipping: ShippingInfo = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const emptyPayment: PaymentInfo = {
|
const emptyPayment: PaymentInfo = {
|
||||||
cardNumber: '',
|
paymentIntentId: '',
|
||||||
expiry: '',
|
clientSecret: '',
|
||||||
cvv: '',
|
status: 'idle',
|
||||||
nameOnCard: '',
|
errorMessage: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultState: OrderState = {
|
const defaultState: OrderState = {
|
||||||
@ -82,7 +83,9 @@ export const orderStore = createStore<OrderStore>((set) => ({
|
|||||||
|
|
||||||
setShipping: (shipping: ShippingInfo) => set({ shipping }),
|
setShipping: (shipping: ShippingInfo) => set({ shipping }),
|
||||||
|
|
||||||
setPayment: (payment: PaymentInfo) => set({ payment }),
|
setPayment: (payment: Partial<PaymentInfo>) => set((state) => ({
|
||||||
|
payment: { ...state.payment, ...payment },
|
||||||
|
})),
|
||||||
|
|
||||||
setOrderTotal: (total: number) => set({ orderTotal: total }),
|
setOrderTotal: (total: number) => set({ orderTotal: total }),
|
||||||
|
|
||||||
@ -91,6 +94,47 @@ export const orderStore = createStore<OrderStore>((set) => ({
|
|||||||
colorSummary: color,
|
colorSummary: color,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
createPaymentIntent: async (): Promise<string | null> => {
|
||||||
|
const { orderTotal, personaSummary, colorSummary, shipping } = 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,
|
||||||
|
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',
|
||||||
|
|||||||
140
src/store/usePersonaStore.ts
Normal file
140
src/store/usePersonaStore.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { createStore } from 'zustand/vanilla';
|
||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
export interface PersonaOption {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
colors: { torso: string; legs: 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,
|
||||||
|
};
|
||||||
|
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: () => {
|
||||||
|
const stored = loadFromStorage();
|
||||||
|
if (stored && stored.length > 0) {
|
||||||
|
set({ personas: stored, isHydrated: true });
|
||||||
|
} else {
|
||||||
|
set({ isHydrated: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const usePersonaStore = <T>(selector: (state: PersonaStore) => T): T => {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
personaStore.subscribe,
|
||||||
|
() => selector(personaStore.getState()),
|
||||||
|
() => selector(personaStore.getState())
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -14,6 +14,8 @@ export interface PricingState {
|
|||||||
|
|
||||||
export interface PricingActions {
|
export interface PricingActions {
|
||||||
updatePrice: (itemId: string, newPrice: number) => void;
|
updatePrice: (itemId: string, newPrice: number) => void;
|
||||||
|
addItem: (item: PricingItem) => void;
|
||||||
|
removeItem: (itemId: string) => void;
|
||||||
resetPrices: () => void;
|
resetPrices: () => void;
|
||||||
hydrate: () => void;
|
hydrate: () => void;
|
||||||
}
|
}
|
||||||
@ -71,6 +73,25 @@ export const pricingStore = createStore<PricingStore>((set, get) => ({
|
|||||||
set({ items: [...DEFAULT_ITEMS] });
|
set({ items: [...DEFAULT_ITEMS] });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addItem: (item: PricingItem) => {
|
||||||
|
set((state) => {
|
||||||
|
if (state.items.some((i) => i.id === item.id)) return state;
|
||||||
|
const updated = [...state.items, item];
|
||||||
|
saveToStorage(updated);
|
||||||
|
return { items: updated };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
removeItem: (itemId: string) => {
|
||||||
|
// Prevent removing the base robot price
|
||||||
|
if (itemId === 'base') return;
|
||||||
|
set((state) => {
|
||||||
|
const updated = state.items.filter((i) => i.id !== itemId);
|
||||||
|
saveToStorage(updated);
|
||||||
|
return { items: updated };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
hydrate: () => {
|
hydrate: () => {
|
||||||
const stored = loadFromStorage();
|
const stored = loadFromStorage();
|
||||||
if (stored) {
|
if (stored) {
|
||||||
|
|||||||
18
src/store/useSnapshotStore.ts
Normal file
18
src/store/useSnapshotStore.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { createStore } from 'zustand/vanilla';
|
||||||
|
|
||||||
|
interface SnapshotStore {
|
||||||
|
_captureFn: (() => string | null) | null;
|
||||||
|
registerCapture: (fn: () => string | null) => void;
|
||||||
|
capture: () => string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const snapshotStore = createStore<SnapshotStore>((set, get) => ({
|
||||||
|
_captureFn: null,
|
||||||
|
|
||||||
|
registerCapture: (fn) => set({ _captureFn: fn }),
|
||||||
|
|
||||||
|
capture: () => {
|
||||||
|
const fn = get()._captureFn;
|
||||||
|
return fn ? fn() : null;
|
||||||
|
},
|
||||||
|
}));
|
||||||
Loading…
x
Reference in New Issue
Block a user