diff --git a/README.md b/README.md index 6c13126..ec6a4eb 100644 --- a/README.md +++ b/README.md @@ -1,127 +1,157 @@ # VibeCart -AI-powered modern e-commerce platform built with industrial-grade engineering practices. +A full-stack luxury e-commerce platform built with a modern monorepo architecture. Features a complete storefront, admin dashboard, product management, order tracking, and role-based access control. ## Tech Stack -**Frontend:** Vite + React + TypeScript, Tailwind CSS, shadcn/ui, React Router, React Hook Form + Zod - -**Backend:** Node.js + TypeScript, Express.js, Prisma ORM, JWT authentication - -**Database:** PostgreSQL (Supabase), Redis (planned) - -**Infrastructure:** Render (API), Vercel (Frontend), GitHub Actions (CI/CD), Sentry (error tracking), Pino (logging) - -**Email:** Resend +| Layer | Technology | +|-------|-----------| +| Frontend | React 18 + TypeScript, Vite, Tailwind CSS v4, shadcn/ui, React Router | +| Backend | Node.js + TypeScript, Express.js, Prisma ORM | +| Database | PostgreSQL (Supabase) | +| Auth | JWT access tokens + refresh tokens | +| Email | Resend | +| Logging | Pino + Sentry | +| Deployment | Render (API), Vercel (Frontend) | +| CI/CD | GitHub Actions | ## Project Structure +``` vibecart/ ├── apps/ -│ ├── api/ # Express.js backend -│ │ ├── prisma/ -│ │ │ └── schema.prisma # Database schema (20+ models) -│ │ └── src/ -│ │ ├── api/v1/ -│ │ │ ├── controllers/ # Request handlers -│ │ │ ├── routes/ # Route definitions -│ │ │ ├── services/ # Business logic -│ │ │ └── validators/ # Zod schemas -│ │ ├── config/ # Environment config -│ │ ├── lib/ # Prisma client, logger, Sentry, email -│ │ ├── middleware/ # Error handling, auth -│ │ ├── utils/ # Response helpers -│ │ ├── app.ts # Express app setup -│ │ └── server.ts # Server entry point -│ └── web/ # Vite + React frontend -│ └── src/ -│ ├── app/ -│ │ ├── components/ # Layout, UI, ProtectedRoute, UserMenu -│ │ ├── contexts/ # AuthContext -│ │ ├── pages/ # Home, Shop, Cart, Account, Admin, Auth -│ │ └── routes.tsx # React Router config -│ ├── lib/ # Axios API client -│ ├── services/ # Frontend service layer -│ └── styles/ # Tailwind, theme, fonts -├── packages/ -│ ├── shared-types/ # Shared TypeScript types -│ └── config/ # Shared ESLint/TS configs -├── docs/ # Documentation -├── scripts/ # Deployment scripts -└── .github/workflows/ # CI/CD pipelines +│ ├── api/ # Express REST API +│ │ ├── prisma/ # Schema, migrations, seed +│ │ └── src/ +│ │ ├── api/v1/ +│ │ │ ├── controllers/ +│ │ │ ├── services/ +│ │ │ ├── routes/ +│ │ │ └── validators/ +│ │ ├── middleware/ +│ │ └── config/ +│ └── web/ # React storefront + admin +│ └── src/ +│ └── app/ +│ ├── admin/ # Admin panel pages +│ ├── components/ +│ ├── pages/ # Storefront pages +│ ├── store/ # Cart store +│ └── data/ +└── package.json # Root workspace scripts +``` + +## Features + +**Storefront** +- Homepage with new arrivals, best sellers, editorial sections +- Shop page with filters (category, brand, color, price), sorting, search +- Product detail page with image gallery, size selector, add to cart +- Cart with quantity controls and order summary +- Wishlist +- User login / registration with JWT auth + +**Admin Panel** (`/admin`) +- Dashboard with sales overview, order status, top products, low stock alerts +- Product management — create, edit, bulk import via CSV, status control +- Order management with status updates and order detail panel +- User & roles management with permission matrix +- Settings and support pages + +**API** +- Storefront and admin product endpoints +- Order CRUD with status tracking +- Role-based access control (ADMIN, OPERATIONS, MARKETING, SUPPORT, CUSTOMER) +- Audit logging on all mutations +- CSV bulk product import ## API Endpoints ### Auth -| Method | Route | Description | -| ------ | ------------------------------------- | ------------------------------ | -| POST | `/api/v1/auth/register` | Create new account | -| POST | `/api/v1/auth/login` | Sign in with email/password | -| POST | `/api/v1/auth/logout` | Sign out, revoke refresh token | -| GET | `/api/v1/auth/me` | Get current user profile | -| GET | `/api/v1/auth/verify-email?token=...` | Verify email address | -| POST | `/api/v1/auth/resend-verification` | Resend verification email | -| POST | `/api/v1/auth/forgot-password` | Request password reset email | -| POST | `/api/v1/auth/reset-password` | Reset password with token | +| Method | Route | Description | +|--------|-------|-------------| +| POST | `/api/v1/auth/register` | Create customer account | +| POST | `/api/v1/auth/login` | Sign in | +| POST | `/api/v1/auth/logout` | Sign out | +| GET | `/api/v1/auth/me` | Current user profile | +| GET | `/api/v1/auth/verify-email` | Verify email address | +| POST | `/api/v1/auth/forgot-password` | Request password reset | +| POST | `/api/v1/auth/reset-password` | Reset password | +| POST | `/api/v1/admin/auth/login` | Admin sign in | + +### Products + +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/api/v1/products/storefront` | Published products (filters, sort, pagination) | +| GET | `/api/v1/products/storefront/:slug` | Single product by slug | +| GET | `/api/v1/products/admin` | All products (admin, requires auth) | +| POST | `/api/v1/products` | Create product | +| PATCH | `/api/v1/products/:id` | Update product | +| PATCH | `/api/v1/products/:id/status` | Publish / archive | + +### Orders + +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/api/v1/orders` | List orders (admin) | +| GET | `/api/v1/orders/stats` | Dashboard stats | +| GET | `/api/v1/orders/:id` | Order detail | +| PATCH | `/api/v1/orders/:id/status` | Update order status | ### System -| Method | Route | Description | -| ------ | ---------------- | --------------------------- | -| GET | `/api/v1/health` | Health check with DB status | -| GET | `/api/v1/` | API info and endpoints list | +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/api/v1/health` | Health check with DB status | ## Frontend Routes -| Route | Access | Description | -| ------------------ | ----------------- | ---------------------------- | -| `/` | Public | Homepage | -| `/shop/:category?` | Public | Product listing with filters | -| `/product/:id` | Public | Product detail page | -| `/cart` | Public | Shopping cart | -| `/register` | Public | Create account | -| `/login` | Public | Sign in | -| `/forgot-password` | Public | Request password reset | -| `/reset-password` | Public | Set new password | -| `/verify-email` | Public | Email verification result | -| `/checkout` | Protected | Checkout flow | -| `/wishlist` | Protected | Saved items | -| `/account` | Protected | Account dashboard | -| `/admin` | Protected (staff) | Admin dashboard | -| `/admin/products` | Protected (staff) | Product management | -| `/admin/orders` | Protected (staff) | Order management | - -## Setup +| Route | Description | +|-------|-------------| +| `/` | Homepage | +| `/shop` | Product listing with filters | +| `/product/:slug` | Product detail | +| `/cart` | Shopping cart | +| `/checkout` | Checkout | +| `/login` | Sign in / Create account | +| `/wishlist` | Saved items | +| `/admin/login` | Admin sign in | +| `/admin` | Dashboard | +| `/admin/products` | Product management | +| `/admin/products/new` | Create product | +| `/admin/orders` | Order management | +| `/admin/users` | Users & roles | +| `/admin/import` | Bulk CSV import | +| `/admin/settings` | Settings | + +## Local Setup ### Prerequisites - Node.js 18+ -- npm 9+ - PostgreSQL database (or Supabase account) -- Resend account (optional, for emails) ### Environment Variables -**API** (`apps/api/.env`): +**`apps/api/.env`** ```env NODE_ENV=development PORT=3000 -API_VERSION=v1 DATABASE_URL=postgresql://user:pass@host:port/dbname DATABASE_URL_DIRECT=postgresql://user:pass@host:port/dbname -JWT_SECRET=your-secret-min-32-characters-long!! +JWT_SECRET=your-secret-min-32-characters-long JWT_EXPIRES_IN=7d CORS_ORIGIN=http://localhost:5173 -LOG_LEVEL=debug -SENTRY_DSN=https://...@sentry.io/... RESEND_API_KEY=re_your_key_here -EMAIL_FROM=VibeCart +EMAIL_FROM=VibeCart FRONTEND_URL=http://localhost:5173 +SENTRY_DSN=https://...@sentry.io/... ``` -**Frontend** (`apps/web/.env.local`): +**`apps/web/.env.local`** ```env VITE_API_URL=http://localhost:3000/api/v1 @@ -131,104 +161,75 @@ VITE_APP_NAME=VibeCart ### Installation ```bash -# Clone the repo -git clone https://github.com/your-username/vibecart.git +git clone https://github.com/dhruv-techdev/vibecart.git cd vibecart - -# Install dependencies npm install -cd apps/api && npm install && cd ../.. -cd apps/web && npm install && cd ../.. -# Set up database +# Set up the database cd apps/api -cp .env.example .env # Edit with your values +cp .env.example .env # fill in your values npx prisma migrate dev -npx prisma generate +npx prisma db seed cd ../.. ``` -### Development +### Running Locally ```bash -# Terminal 1 — API -cd apps/api && npm run dev +# Run both API and frontend together +npm run dev -# Terminal 2 — Frontend -cd apps/web && npm run dev +# Or separately: +npm run dev:api # http://localhost:3000 +npm run dev:web # http://localhost:5173 ``` -API runs on `http://localhost:3000`, frontend on `http://localhost:5173`. +### Seed Accounts + +| Role | Email | Password | +|------|-------|----------| +| Admin | admin@vibecart.com | Admin1234! | +| Customer | customer@vibecart.com | Customer1234! | -### Testing +### Other Useful Commands ```bash -# Register a user -curl -X POST http://localhost:3000/api/v1/auth/register \ - -H "Content-Type: application/json" \ - -d '{"email":"test@vibecart.com","password":"Test1234!","firstName":"Test","lastName":"User"}' - -# Login -curl -X POST http://localhost:3000/api/v1/auth/login \ - -H "Content-Type: application/json" \ - -d '{"email":"test@vibecart.com","password":"Test1234!"}' - -# Health check -curl http://localhost:3000/api/v1/health +npm run db:studio # Open Prisma Studio +npm run db:migrate # Run migrations +npm run db:generate # Regenerate Prisma client +npm run type-check # TypeScript check across all packages +npm run lint # ESLint +npm run build # Build all packages ``` ## Deployment -**API:** Auto-deploys to Render from `develop` branch - -- Staging: `https://vibecart-os23.onrender.com` - -**Frontend:** Auto-deploys to Vercel - -## Completed User Stories - -### Sprint 1 — Foundation (US-001 to US-010) - -- API structure standardization -- Shared request/response contracts -- Monorepo setup -- Environment configuration -- CI/CD pipeline -- Database connectivity (Supabase) -- Prisma schema baseline -- Staging deployment -- Centralized logging and error tracking - -### Sprint 2 — Authentication (US-011 to US-016) +| Service | Platform | Branch | +|---------|----------|--------| +| API | Render | `main` | +| Frontend | Vercel | `main` | -- US-011: User registration (signup endpoint, validation, password hashing, form UI) -- US-012: User login (login endpoint, JWT tokens, auth context, login form) -- US-013: User logout (session invalidation, client state clearing, protected routes) -- US-014: Email verification (verification tokens, Resend integration, verification UI) -- US-015: Password reset (forgot password flow, reset tokens, reset form) -- US-016: Reset link expiry (token expiry, used token invalidation) +Staging API: `https://vibecart-os23.onrender.com` -## Architecture Decisions +## Architecture -- **Monorepo with npm workspaces** — shared types and config across packages -- **Controller → Service pattern** — separation of HTTP handling and business logic -- **Prisma over raw SQL** — type safety, migrations, easy relations -- **JWT + refresh tokens in DB** — stateless access tokens, revocable refresh tokens -- **Vite + React over Next.js** — matches Figma export, simpler SPA setup -- **Resend over Nodemailer** — simpler API, free tier sufficient for MVP -- **Standardized response format** — consistent `{success, message, data, timestamp}` contract +- **Monorepo with npm workspaces** — shared scripts and config at root +- **Controller → Service pattern** — HTTP layer separated from business logic +- **Prisma ORM** — type-safe queries, managed migrations, easy relations +- **JWT + DB refresh tokens** — stateless access, revocable sessions +- **Zod validation** — schema-first request validation with typed errors +- **RBAC** — permission-based middleware guards on every admin route +- **Audit log** — every create/update/delete writes an audit record ## Security - Passwords hashed with bcrypt (12 rounds) -- JWT access tokens with configurable expiry -- Refresh tokens stored in DB with revocation support -- Password reset tokens expire in 1 hour, single-use -- Email verification tokens expire in 24 hours -- CORS, Helmet, input validation on all endpoints -- Audit logging for auth events -- Role-based access control (RBAC) -- Protected routes on frontend and backend +- JWT access tokens (short-lived) + refresh tokens (DB-stored, revocable) +- Password reset tokens: 1-hour expiry, single-use +- Email verification tokens: 24-hour expiry +- CORS, Helmet, rate limiting on all endpoints +- Input validation (Zod) on all request bodies +- Role-based permission checks on every protected route ## License diff --git a/apps/api/package.json b/apps/api/package.json index fb35384..1c249e2 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -4,6 +4,9 @@ "private": true, "description": "VibeCart Backend API", "main": "dist/server.js", + "prisma": { + "seed": "ts-node prisma/seed.ts" + }, "scripts": { "dev": "ts-node-dev --respawn --transpile-only src/server.ts", "build": "prisma generate && tsc", diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 5060baa..e12c9da 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -1,8 +1,17 @@ +/// import { PrismaClient } from '@prisma/client'; import bcrypt from 'bcryptjs'; const prisma = new PrismaClient(); +// ─── Image helper ──────────────────────────────────────────────────────────── +const img = (id: string, w = 900) => + `https://images.unsplash.com/${id}?auto=format&fit=crop&w=${w}&q=80`; + +// ============================================================================= +// CATEGORIES +// Images match exactly what the frontend uses in data/products.ts + homepage +// ============================================================================= interface CategorySeed { name: string; slug: string; @@ -11,6 +20,66 @@ interface CategorySeed { sortOrder: number; } +const categories: CategorySeed[] = [ + { + name: 'Clothing', + slug: 'clothing', + description: 'Editorial clothing, knitwear, tailoring, outerwear, and everyday wardrobe essentials.', + imageUrl: img('photo-1629511565591-a1d494ad6c58'), // frontend: Fashion category + sortOrder: 1, + }, + { + name: 'Bags', + slug: 'bags', + description: 'Structured totes, shoulder bags, and everyday luxury leather goods.', + imageUrl: img('photo-1590739225287-bd31519780c3'), // frontend: Accessories/tote image + sortOrder: 2, + }, + { + name: 'Shoes', + slug: 'shoes', + description: 'Footwear from minimalist sneakers to refined leather boots and derby shoes.', + imageUrl: img('photo-1682364853446-db043f643207'), // frontend: Footwear category + sortOrder: 3, + }, + { + name: 'Accessories', + slug: 'accessories', + description: 'Sunglasses, scarves, belts, watches, silk scarves, and curated finishing pieces.', + imageUrl: img('photo-1620786514684-ff35b5aae55e'), // frontend: Accessories category image + sortOrder: 4, + }, + { + name: 'Beauty', + slug: 'beauty', + description: 'Makeup, facial masks, and luxury beauty rituals for a refined vanity routine.', + imageUrl: img('photo-1631730486572-226d1f595b68'), // frontend: Beauty category + sortOrder: 5, + }, + { + name: 'Fragrance', + slug: 'fragrance', + description: 'Eau de parfum, eau de toilette, and luxury scents for refined sensibilities.', + imageUrl: img('photo-1622618991746-fe6004db3a47'), // frontend: Fragrances category + sortOrder: 6, + }, + { + name: 'Skincare', + slug: 'skincare', + description: 'Serums, moisturisers, SPF, and premium skincare for daily refinement.', + imageUrl: img('photo-1595425959632-34f2822322ce'), // frontend: Skincare category + sortOrder: 7, + }, +]; + +// ============================================================================= +// PRODUCTS +// Combines: +// • 24 products from ShopPage.tsx ALL_PRODUCTS (prefix: VC-) +// • 12 products from data/products.ts (prefix: PD-) — the ProductPage source +// hoverImage is stored as a second ProductImage (sortOrder: 1) +// color is stored as a lowercase tag for backend color filtering +// ============================================================================= interface ProductSeed { title: string; slug: string; @@ -21,6 +90,7 @@ interface ProductSeed { compareAtPrice?: number; stock: number; isFeatured?: boolean; + isNew?: boolean; avgRating: number; reviewCount: number; soldCount: number; @@ -28,582 +98,711 @@ interface ProductSeed { shortDescription: string; description: string; image: string; + hoverImage?: string; + sizes?: string[]; } -const imageUrl = (id: string, width = 900) => - `https://images.unsplash.com/${id}?auto=format&fit=crop&w=${width}&q=80`; - -const categories: CategorySeed[] = [ - { - name: 'Electronics', - slug: 'electronics', - description: 'Portable tech, smart devices, cameras, audio, and desk essentials.', - imageUrl: imageUrl('photo-1523170335258-f5ed11844a49'), - sortOrder: 1, - }, - { - name: 'Fashion', - slug: 'fashion', - description: 'Editorial clothing, bags, shoes, and everyday wardrobe upgrades.', - imageUrl: imageUrl('photo-1515886657613-9f3515b0c78f'), - sortOrder: 2, - }, - { - name: 'Home & Living', - slug: 'home', - description: 'Modern furniture, warm decor, lighting, bedding, and storage.', - imageUrl: imageUrl('photo-1555041469-a586c61ea9bc'), - sortOrder: 3, - }, - { - name: 'Beauty', - slug: 'beauty', - description: 'Skincare, makeup, body care, and clean vanity essentials.', - imageUrl: imageUrl('photo-1596462502278-27bfdc403348'), - sortOrder: 4, - }, -]; - -const products: ProductSeed[] = [ - { - title: 'Studio Pro Wireless Headphones', - slug: 'studio-pro-wireless-headphones', - sku: 'ELEC-AUD-001', - categorySlug: 'electronics', - brand: 'Sony', - price: 249.99, - compareAtPrice: 299.99, - stock: 42, +// ───────────────────────────────────────────────────────────────────────────── +// BLOCK A: ShopPage products (24) +// Source: apps/web/src/app/pages/ShopPage.tsx ALL_PRODUCTS +// ───────────────────────────────────────────────────────────────────────────── +const shopProducts: ProductSeed[] = [ + // ── Clothing (11) ─────────────────────────────────────────────────────── + { + title: 'Black Wool Coat', + slug: 'black-wool-coat', + sku: 'VC-MV-CLO-001', + categorySlug: 'clothing', + brand: 'Maison Voss', + price: 1490, + compareAtPrice: 1850, + stock: 14, isFeatured: true, - avgRating: 4.8, - reviewCount: 214, - soldCount: 940, - tags: ['audio', 'headphones', 'wireless', 'work'], - shortDescription: 'Noise-cancelling wireless headphones with all-day comfort.', - description: 'Premium over-ear headphones tuned for deep focus, commuting, and studio-quality listening.', - image: imageUrl('photo-1505740420928-5e560c06d30e'), - }, - { - title: 'Minimal Smart Watch', - slug: 'minimal-smart-watch', - sku: 'ELEC-WAR-002', - categorySlug: 'electronics', - brand: 'Apple', - price: 399.99, - compareAtPrice: 449.99, - stock: 27, + isNew: true, + avgRating: 4.9, + reviewCount: 48, + soldCount: 210, + tags: ['clothing', 'outerwear', 'coat', 'black', 'wool', 'new-arrival'], + shortDescription: 'A structured wool coat in deep black with an elongated silhouette.', + description: 'Cut in premium double-faced wool, this coat anchors any wardrobe with its clean lapels, refined weight, and timeless black colourway. Designed for layering over tailoring or minimal separates.', + image: img('photo-1603189343302-e603f7add05a'), + hoverImage: img('photo-1779912217839-96eabe1de90b'), + sizes: ['XS', 'S', 'M', 'L', 'XL'], + }, + { + title: 'White Cotton Shirt', + slug: 'white-cotton-shirt', + sku: 'VC-LM-CLO-002', + categorySlug: 'clothing', + brand: 'Lumière', + price: 390, + stock: 62, + avgRating: 4.6, + reviewCount: 91, + soldCount: 480, + tags: ['clothing', 'shirt', 'white', 'cotton', 'menswear'], + shortDescription: 'A relaxed-fit cotton shirt in clean white with a subtle drop shoulder.', + description: 'Woven from 100% Egyptian cotton, this oversized shirt offers the kind of quiet nonchalance that only premium fabrication can deliver. Wear open over a tee or tucked into tailored trousers.', + image: img('photo-1600508115810-0d733f925812'), + hoverImage: img('photo-1776273920142-f30bceff0cf3'), + sizes: ['XS', 'S', 'M', 'L', 'XL'], + }, + { + title: 'Grey Tailored Trouser', + slug: 'grey-tailored-trouser', + sku: 'VC-AN-CLO-003', + categorySlug: 'clothing', + brand: 'Atelier Noir', + price: 640, + stock: 28, + avgRating: 4.7, + reviewCount: 55, + soldCount: 295, + tags: ['clothing', 'trousers', 'grey', 'tailored', 'womenswear'], + shortDescription: 'High-waisted tailored trousers in mid-grey with a straight leg.', + description: 'These wide-cut trousers in a fine wool-blend hold their shape through wear. The elevated waistline and straight leg create a proportional silhouette that pairs equally well with a silk blouse or a knit.', + image: img('photo-1574015974293-817f0ebebb74'), + sizes: ['XS', 'S', 'M', 'L', 'XL'], + }, + { + title: 'Black Oversized Blazer', + slug: 'black-oversized-blazer', + sku: 'VC-MV-CLO-004', + categorySlug: 'clothing', + brand: 'Maison Voss', + price: 1290, + stock: 18, isFeatured: true, + avgRating: 4.8, + reviewCount: 63, + soldCount: 340, + tags: ['clothing', 'blazer', 'black', 'oversized', 'tailored'], + shortDescription: 'An oversized blazer in black with a deliberately loose, editorial cut.', + description: 'Tailored in Italy from pure virgin wool, this blazer reimagines the classic silhouette with extended shoulders and a below-hip length. The absence of excess buttons and detailing makes it a true wardrobe cornerstone.', + image: img('photo-1683642765591-2370edc15193'), + sizes: ['XS', 'S', 'M', 'L', 'XL'], + }, + { + title: 'Ivory Silk Dress', + slug: 'ivory-silk-dress', + sku: 'VC-LM-CLO-005', + categorySlug: 'clothing', + brand: 'Lumière', + price: 895, + compareAtPrice: 1090, + stock: 22, + isNew: true, avgRating: 4.7, - reviewCount: 168, - soldCount: 720, - tags: ['wearables', 'watch', 'fitness', 'smart'], - shortDescription: 'A polished smart watch for fitness, alerts, and daily planning.', - description: 'A sleek wearable with health tracking, notifications, and a clean everyday profile.', - image: imageUrl('photo-1523170335258-f5ed11844a49'), - }, - { - title: 'Compact Mirrorless Camera', - slug: 'compact-mirrorless-camera', - sku: 'ELEC-CAM-003', - categorySlug: 'electronics', - brand: 'Canon', - price: 899.99, - compareAtPrice: 1049.99, + reviewCount: 44, + soldCount: 188, + tags: ['clothing', 'dress', 'ivory', 'silk', 'womenswear', 'new-arrival'], + shortDescription: 'A fluid ivory slip dress in pure silk with a bias-cut finish.', + description: 'Crafted from 19mm Charmeuse silk, this dress moves with the body in a way that only bias-cut construction allows. Equally appropriate dressed down with a cashmere cardigan or worn as an evening piece.', + image: img('photo-1621036382228-d728f0d09e33'), + sizes: ['XS', 'S', 'M', 'L'], + }, + { + title: 'Charcoal Knit Sweater', + slug: 'charcoal-knit-sweater', + sku: 'VC-MV-CLO-006', + categorySlug: 'clothing', + brand: 'Maison Voss', + price: 580, + stock: 45, + avgRating: 4.6, + reviewCount: 72, + soldCount: 390, + tags: ['clothing', 'knitwear', 'charcoal', 'sweater', 'wool'], + shortDescription: 'A relaxed-fit knitwear sweater in charcoal with a ribbed hem and cuffs.', + description: 'Knit from a merino-cashmere blend, this sweater sits at the intersection of warmth and refinement. The mid-weight knit works as a standalone layer or beneath a coat when temperatures drop.', + image: img('photo-1552393700-42696fb89bfa'), + sizes: ['XS', 'S', 'M', 'L', 'XL'], + }, + { + title: 'Beige Trench Coat', + slug: 'beige-trench-coat', + sku: 'VC-LM-CLO-007', + categorySlug: 'clothing', + brand: 'Lumière', + price: 1380, + compareAtPrice: 1680, stock: 16, + isFeatured: true, + isNew: true, avgRating: 4.9, - reviewCount: 96, - soldCount: 310, - tags: ['camera', 'creator', 'travel', 'photo'], - shortDescription: 'Lightweight mirrorless camera for travel and content creation.', - description: 'A creator-friendly camera body with crisp image quality and compact carry.', - image: imageUrl('photo-1516035069371-29a1b244cc32'), - }, - { - title: 'Portable Bluetooth Speaker', - slug: 'portable-bluetooth-speaker', - sku: 'ELEC-AUD-004', - categorySlug: 'electronics', - brand: 'Bose', - price: 129.99, - stock: 58, + reviewCount: 39, + soldCount: 172, + tags: ['clothing', 'outerwear', 'beige', 'trench', 'coat', 'new-arrival'], + shortDescription: 'A classic beige trench coat in water-resistant cotton gabardine.', + description: 'Recut for a modern proportion, this trench retains all the heritage details — storm flap, epaulettes, D-ring belt — while bringing the silhouette closer to a clean, straight line that reads current.', + image: img('photo-1762605135012-56a59a059e60'), + sizes: ['XS', 'S', 'M', 'L', 'XL'], + }, + { + title: 'Beige Cashmere Sweater', + slug: 'beige-cashmere-sweater', + sku: 'VC-MV-CLO-008', + categorySlug: 'clothing', + brand: 'Maison Voss', + price: 680, + stock: 31, + avgRating: 4.8, + reviewCount: 57, + soldCount: 260, + tags: ['clothing', 'knitwear', 'beige', 'cashmere', 'sweater'], + shortDescription: 'A fine-gauge cashmere sweater in warm beige with a relaxed crewneck.', + description: 'Spun from Grade A Inner Mongolian cashmere, this sweater is the definition of understated luxury. The lightweight gauge is versatile enough for year-round wear, and the beige tone grounds every outfit.', + image: img('photo-1653875842174-429c1b467548'), + sizes: ['XS', 'S', 'M', 'L', 'XL'], + }, + { + title: 'Charcoal Wide-Leg Trouser', + slug: 'charcoal-wide-leg-trouser', + sku: 'VC-AN-CLO-009', + categorySlug: 'clothing', + brand: 'Atelier Noir', + price: 590, + stock: 24, avgRating: 4.5, - reviewCount: 132, - soldCount: 515, - tags: ['audio', 'speaker', 'portable', 'wireless'], - shortDescription: 'Compact speaker with rich sound for desk, kitchen, and travel.', - description: 'A small Bluetooth speaker with room-filling audio and easy everyday portability.', - image: imageUrl('photo-1608043152269-423dbba4e7e1'), - }, - { - title: 'Ultrabook Air 14', - slug: 'ultrabook-air-14', - sku: 'ELEC-LAP-005', - categorySlug: 'electronics', - brand: 'Dell', - price: 1199.99, - compareAtPrice: 1399.99, - stock: 21, - isFeatured: true, + reviewCount: 38, + soldCount: 195, + tags: ['clothing', 'trousers', 'charcoal', 'wide-leg', 'tailored'], + shortDescription: 'Wide-leg tailored trousers in deep charcoal with a clean front crease.', + description: 'The wide-leg silhouette is executed here in a fine charcoal wool-blend that holds a sharp press and drapes cleanly. The cut flatters through proportion and balance rather than structure alone.', + image: img('photo-1664076458686-3449062080ac'), + sizes: ['XS', 'S', 'M', 'L', 'XL'], + }, + { + title: 'White Tailored Blouse', + slug: 'white-tailored-blouse', + sku: 'VC-LM-CLO-010', + categorySlug: 'clothing', + brand: 'Lumière', + price: 445, + stock: 38, avgRating: 4.6, - reviewCount: 88, - soldCount: 265, - tags: ['laptop', 'work', 'portable', 'computer'], - shortDescription: 'Thin 14-inch laptop built for work, browsing, and travel.', - description: 'A lightweight laptop with a bright display, fast storage, and long battery life.', - image: imageUrl('photo-1496181133206-80ce9b88a853'), - }, - { - title: 'Wireless Earbuds Case Set', - slug: 'wireless-earbuds-case-set', - sku: 'ELEC-AUD-006', - categorySlug: 'electronics', - brand: 'Samsung', - price: 149.99, - compareAtPrice: 179.99, - stock: 64, - avgRating: 4.4, - reviewCount: 183, - soldCount: 630, - tags: ['earbuds', 'audio', 'wireless', 'travel'], - shortDescription: 'Pocket-size wireless earbuds with a clean charging case.', - description: 'Everyday earbuds with balanced sound, quick pairing, and a compact charging case.', - image: imageUrl('photo-1606220945770-b5b6c2c55bf1'), - }, - { - title: 'Creator Mechanical Keyboard', - slug: 'creator-mechanical-keyboard', - sku: 'ELEC-DSK-007', - categorySlug: 'electronics', - brand: 'Keychron', - price: 159.99, - stock: 36, + reviewCount: 49, + soldCount: 228, + tags: ['clothing', 'blouse', 'white', 'tailored', 'womenswear'], + shortDescription: 'A structured white blouse with a pointed collar and back-pleat detail.', + description: 'This poplin blouse balances a sharp collar with a slightly relaxed body, creating a piece that transitions effortlessly between a polished look and a dressed-down editorial one.', + image: img('photo-1705249569862-3104399dae79'), + sizes: ['XS', 'S', 'M', 'L', 'XL'], + }, + { + title: 'Black Evening Dress', + slug: 'black-evening-dress', + sku: 'VC-ES-CLO-011', + categorySlug: 'clothing', + brand: 'Élan Studio', + price: 1190, + stock: 12, + isFeatured: true, avgRating: 4.8, - reviewCount: 121, - soldCount: 410, - tags: ['keyboard', 'desk', 'work', 'creator'], - shortDescription: 'Tactile mechanical keyboard for focused desk setups.', - description: 'A compact mechanical keyboard with satisfying switches and a refined desktop look.', - image: imageUrl('photo-1541140532154-b024d705b90a'), - }, - { - title: 'Slim USB-C Travel Hub', - slug: 'slim-usb-c-travel-hub', - sku: 'ELEC-ACC-008', - categorySlug: 'electronics', - brand: 'Anker', - price: 59.99, - stock: 75, - avgRating: 4.3, - reviewCount: 77, - soldCount: 295, - tags: ['usb-c', 'desk', 'travel', 'accessory'], - shortDescription: 'A compact hub for laptop ports, displays, and fast transfers.', - description: 'A slim USB-C hub that keeps modern workstations flexible without adding bulk.', - image: imageUrl('photo-1625948515291-69613efd103f'), - }, - { - title: 'Structured Leather Tote', - slug: 'structured-leather-tote', - sku: 'FASH-BAG-001', - categorySlug: 'fashion', - brand: 'Celine', - price: 189.99, - compareAtPrice: 240, - stock: 31, + reviewCount: 31, + soldCount: 140, + tags: ['clothing', 'dress', 'black', 'evening', 'womenswear'], + shortDescription: 'A minimal long black dress with a low-back and clean structured bodice.', + description: 'Architectural in its restraint, this evening dress relies on proportion and the quality of the fabrication — a matte jersey that sculpts the body without clinging — to make its statement.', + image: img('photo-1637536701369-f815af927b59'), + sizes: ['XS', 'S', 'M', 'L'], + }, + // ── Bags (3) ──────────────────────────────────────────────────────────── + { + title: 'Grey Leather Bag', + slug: 'grey-leather-bag', + sku: 'VC-CV-BAG-001', + categorySlug: 'bags', + brand: 'Croft & Vale', + price: 1250, + stock: 19, isFeatured: true, + isNew: true, + avgRating: 4.8, + reviewCount: 62, + soldCount: 290, + tags: ['bags', 'leather', 'grey', 'tote', 'new-arrival'], + shortDescription: 'A structured grey leather tote with a suede-lined interior.', + description: 'Handcrafted in Northern Italy from full-grain grey calfskin, this tote navigates between work and leisure with a composed elegance. The suede lining protects contents while adding a tactile luxury to every use.', + image: img('photo-1575403538007-acb790100421'), + hoverImage: img('photo-1559563458-527698bf5295'), + }, + { + title: 'Black Structured Handbag', + slug: 'black-structured-handbag', + sku: 'VC-CV-BAG-002', + categorySlug: 'bags', + brand: 'Croft & Vale', + price: 1850, + stock: 11, + avgRating: 4.9, + reviewCount: 41, + soldCount: 190, + tags: ['bags', 'handbag', 'black', 'structured', 'leather'], + shortDescription: 'A rigid structured handbag in black with polished brass hardware.', + description: 'Defined by its clean box form and hand-stitched edges, this handbag is built around a rigid frame that holds its shape without bulk. The understated brass hardware and minimal exterior detailing speak to a confident restraint.', + image: img('photo-1605733513597-a8f8341084e6'), + hoverImage: img('photo-1590739225287-bd31519780c3'), + }, + { + title: 'Faded Brown Leather Bag', + slug: 'faded-brown-leather-bag', + sku: 'VC-CV-BAG-003', + categorySlug: 'bags', + brand: 'Croft & Vale', + price: 1450, + stock: 9, avgRating: 4.7, - reviewCount: 142, - soldCount: 510, - tags: ['bag', 'tote', 'work', 'leather'], - shortDescription: 'A structured tote with a polished everyday silhouette.', - description: 'A refined tote sized for workdays, errands, and elevated casual styling.', - image: imageUrl('photo-1590874103328-eac38a683ce7'), - }, - { - title: 'Satin Slip Dress', - slug: 'satin-slip-dress', - sku: 'FASH-DRS-002', - categorySlug: 'fashion', - brand: 'Zara', - price: 89.99, - stock: 48, - isFeatured: true, + reviewCount: 28, + soldCount: 115, + tags: ['bags', 'leather', 'brown', 'shoulder-bag', 'vintage'], + shortDescription: 'A shoulder bag in naturally faded brown leather that deepens with wear.', + description: 'Made from vegetable-tanned leather that develops a richer patina over time, this bag is designed to be carried forever. The faded brown tone nods to archival design while the silhouette remains thoroughly modern.', + image: img('photo-1683921377994-928bd73b889f'), + }, + // ── Shoes (3) ──────────────────────────────────────────────────────────── + { + title: 'Charcoal Leather Loafer', + slug: 'charcoal-leather-loafer', + sku: 'VC-ND-SHO-001', + categorySlug: 'shoes', + brand: 'Nordsen', + price: 685, + stock: 34, + avgRating: 4.7, + reviewCount: 83, + soldCount: 420, + tags: ['shoes', 'loafer', 'charcoal', 'leather', 'menswear'], + shortDescription: 'A refined charcoal leather loafer with a hand-welted sole.', + description: 'The Goodyear-welted construction ensures decades of wear, while the hand-burnished charcoal leather develops character with every step. Works with tailoring, denim, and everything between.', + image: img('photo-1562856416-ca417c64871f'), + hoverImage: img('photo-1591348278900-019a8a2a8b1d'), + sizes: ['EU 38', 'EU 39', 'EU 40', 'EU 41', 'EU 42', 'EU 43', 'EU 44', 'EU 45'], + }, + { + title: 'Black Leather Boot', + slug: 'black-leather-boot', + sku: 'VC-ND-SHO-002', + categorySlug: 'shoes', + brand: 'Nordsen', + price: 795, + compareAtPrice: 950, + stock: 26, + avgRating: 4.8, + reviewCount: 74, + soldCount: 358, + tags: ['shoes', 'boots', 'black', 'leather', 'chelsea'], + shortDescription: 'A pull-on Chelsea boot in polished black calf leather.', + description: 'Clean and unadorned, this Chelsea boot is built on a leather-covered block heel that adds slight height without compromising comfort. The elastic panel and pull tab make it practical; the leather quality makes it lasting.', + image: img('photo-1605732440685-d0654d81aa30'), + sizes: ['EU 36', 'EU 37', 'EU 38', 'EU 39', 'EU 40', 'EU 41', 'EU 42'], + }, + { + title: 'White Minimal Sneaker', + slug: 'white-minimal-sneaker', + sku: 'VC-ND-SHO-003', + categorySlug: 'shoes', + brand: 'Nordsen', + price: 490, + stock: 55, avgRating: 4.6, - reviewCount: 98, - soldCount: 430, - tags: ['dress', 'satin', 'evening', 'women'], - shortDescription: 'A fluid satin dress for dinners, events, and layered looks.', - description: 'A softly draped slip dress with elegant movement and an easy day-to-night finish.', - image: imageUrl('photo-1515886657613-9f3515b0c78f'), - }, - { - title: 'Tailored Linen Blazer', - slug: 'tailored-linen-blazer', - sku: 'FASH-BLZ-003', - categorySlug: 'fashion', - brand: 'Massimo Dutti', - price: 149.99, - compareAtPrice: 198, - stock: 22, + reviewCount: 118, + soldCount: 560, + tags: ['shoes', 'sneakers', 'white', 'minimal', 'unisex'], + shortDescription: 'A low-profile white sneaker in tumbled leather with a clean cup sole.', + description: 'Designed to be the one pair that goes with everything — the flat profile, untextured leather upper, and blank white sole create a canvas-like neutrality that only improves with slight wear.', + image: img('photo-1618947085672-1dcb69696c33'), + sizes: ['EU 36', 'EU 37', 'EU 38', 'EU 39', 'EU 40', 'EU 41', 'EU 42', 'EU 43', 'EU 44'], + }, + // ── Accessories (4) ────────────────────────────────────────────────────── + { + title: 'Black Sunglasses', + slug: 'black-sunglasses', + sku: 'VC-ES-ACC-001', + categorySlug: 'accessories', + brand: 'Élan Studio', + price: 395, + stock: 47, + avgRating: 4.7, + reviewCount: 94, + soldCount: 435, + tags: ['accessories', 'sunglasses', 'black', 'eyewear', 'unisex'], + shortDescription: 'Sculptural black acetate sunglasses with a square silhouette.', + description: 'The thick black acetate frame and dark mineral glass lenses give these sunglasses a presence that needs nothing else. UV400 protection and spring hinges make them as practical as they are considered.', + image: img('photo-1553614186-5fc725ac6396'), + hoverImage: img('photo-1613915617430-8ab0fd7c6baf'), + }, + { + title: 'Grey Wool Scarf', + slug: 'grey-wool-scarf', + sku: 'VC-ES-ACC-002', + categorySlug: 'accessories', + brand: 'Élan Studio', + price: 295, + stock: 58, avgRating: 4.5, reviewCount: 67, + soldCount: 310, + tags: ['accessories', 'scarf', 'grey', 'wool', 'unisex'], + shortDescription: 'An oversized grey scarf in boiled wool with raw-edge finishing.', + description: 'At 220cm in length, this scarf wraps, drapes, and layers with a deliberate generosity. The boiled wool softens after a few wears and the monochrome grey makes it a permanent fixture in any coat season rotation.', + image: img('photo-1613915617430-8ab0fd7c6baf'), + }, + { + title: 'Black Leather Belt', + slug: 'black-leather-belt', + sku: 'VC-CV-ACC-003', + categorySlug: 'accessories', + brand: 'Croft & Vale', + price: 245, + stock: 72, + avgRating: 4.6, + reviewCount: 88, + soldCount: 415, + tags: ['accessories', 'belt', 'black', 'leather', 'unisex'], + shortDescription: 'A 3cm black calf-leather belt with a brushed silver pin buckle.', + description: 'Made from a single cut of tannery-selected calf leather, this belt finishes tailoring and trousers with a precision that cheaper alternatives simply cannot replicate. The brushed silver buckle sits flush without drawing attention.', + image: img('photo-1683921470299-b8f0f3331657'), + }, + { + title: 'Black Minimal Watch', + slug: 'black-minimal-watch', + sku: 'VC-ES-ACC-004', + categorySlug: 'accessories', + brand: 'Élan Studio', + price: 890, + compareAtPrice: 1080, + stock: 21, + isNew: true, + avgRating: 4.8, + reviewCount: 53, soldCount: 245, - tags: ['blazer', 'linen', 'tailored', 'workwear'], - shortDescription: 'A breathable linen blazer with a sharp relaxed cut.', - description: 'A lightweight blazer that sharpens denim, dresses, and office basics.', - image: imageUrl('photo-1496747611176-843222e1e57c'), - }, - { - title: 'Designer Court Sneakers', - slug: 'designer-court-sneakers', - sku: 'FASH-SHO-004', - categorySlug: 'fashion', - brand: 'Nike', - price: 129.99, - stock: 56, + tags: ['accessories', 'watch', 'black', 'minimal', 'unisex', 'new-arrival'], + shortDescription: 'A 38mm black-dialled watch on a black calf-leather strap.', + description: 'No date, no subdials — just a clean matte black dial, applied indices, and a sapphire crystal. The 38mm case size works on both wrists, and the leather strap ages into a distinctly personal object over time.', + image: img('photo-1637248666034-d5f4e91870a3'), + }, + // ── Skincare (1) ───────────────────────────────────────────────────────── + { + title: 'Ivory Skincare Set', + slug: 'ivory-skincare-set', + sku: 'VC-AU-SKN-001', + categorySlug: 'skincare', + brand: 'Aurel', + price: 430, + compareAtPrice: 510, + stock: 35, + isFeatured: true, avgRating: 4.8, - reviewCount: 238, - soldCount: 820, - tags: ['sneakers', 'shoes', 'streetwear', 'unisex'], - shortDescription: 'Clean court sneakers that work with denim, tailoring, and sets.', - description: 'Minimal sneakers with a cushioned sole and a versatile everyday profile.', - image: imageUrl('photo-1542291026-7eec264c27ff'), - }, - { - title: 'Oversized Trench Coat', - slug: 'oversized-trench-coat', - sku: 'FASH-OUT-005', - categorySlug: 'fashion', - brand: 'COS', - price: 219.99, - compareAtPrice: 280, + reviewCount: 76, + soldCount: 320, + tags: ['skincare', 'ivory', 'set', 'routine', 'serum', 'moisturiser'], + shortDescription: 'A three-piece skincare ritual in ivory packaging for morning and evening use.', + description: 'The Aurel Trio comprises a brightening vitamin C serum, a hyaluronic barrier cream, and a botanical face oil — formulated to work in sequence and designed around a minimal, zero-waste aesthetic.', + image: img('photo-1764694071462-db50e50a3925'), + }, + // ── Fragrance (2) ───────────────────────────────────────────────────────── + { + title: 'White Fragrance', + slug: 'white-fragrance', + sku: 'VC-VL-FRG-001', + categorySlug: 'fragrance', + brand: 'Veloria', + price: 280, + stock: 44, + avgRating: 4.7, + reviewCount: 89, + soldCount: 410, + tags: ['fragrance', 'white', 'edp', 'luxury', 'unisex', 'musk', 'new-arrival'], + shortDescription: 'A white musk eau de parfum with base notes of sandalwood and iris.', + description: `Veloria's White opens with a breath of white iris and cool aldehydes before settling into a warm sandalwood and musk dry-down. It is the olfactory equivalent of clean linen — effortless and entirely memorable.`, + image: img('photo-1712273119968-84533ec59de9'), + hoverImage: img('photo-1543422655-ac1c6ca993ed'), + }, + { + title: 'Grey Fragrance', + slug: 'grey-fragrance', + sku: 'VC-VL-FRG-002', + categorySlug: 'fragrance', + brand: 'Veloria', + price: 310, + stock: 38, + avgRating: 4.6, + reviewCount: 61, + soldCount: 290, + tags: ['fragrance', 'grey', 'edp', 'luxury', 'unisex', 'woody', 'vetiver'], + shortDescription: 'A vetiver and grey amber eau de parfum with a cool, precise character.', + description: 'Grey opens with a mineral accord — crushed slate and cold air — before a heart of grey amber and vetiver establishes its contemplative depth. A fragrance for those who prefer their luxury undeclared.', + image: img('photo-1764694187688-454d172e5ca0'), + }, +]; + +// ───────────────────────────────────────────────────────────────────────────── +// BLOCK B: ProductPage products (12) +// Source: apps/web/src/app/data/products.ts +// These power the /product/:id route. Slugs are derived from product names. +// ───────────────────────────────────────────────────────────────────────────── +const detailProducts: ProductSeed[] = [ + { + title: 'Structured Wool Blazer', + slug: 'structured-wool-blazer', + sku: 'PD-MV-CLO-001', + categorySlug: 'clothing', + brand: 'Maison Voss', + price: 1290, + stock: 24, + isFeatured: true, + isNew: true, + avgRating: 4.8, + reviewCount: 124, + soldCount: 510, + tags: ['clothing', 'blazer', 'black', 'ivory', 'wool', 'tailored', 'new-arrival'], + shortDescription: 'Crafted from finest Italian wool with a structured silhouette.', + description: 'Crafted from the finest Italian wool, this blazer embodies understated power. The structured silhouette moves with precision — a testament to old-world tailoring elevated for modern wardrobes.', + image: img('photo-1629511565591-a1d494ad6c58'), + hoverImage: img('photo-1613915617430-8ab0fd7c6baf'), + sizes: ['XS', 'S', 'M', 'L', 'XL'], + }, + { + title: 'Silk Charmeuse Slip Dress', + slug: 'silk-charmeuse-slip-dress', + sku: 'PD-LM-CLO-002', + categorySlug: 'clothing', + brand: 'Lumière', + price: 895, stock: 18, isFeatured: true, + isNew: true, + avgRating: 4.9, + reviewCount: 87, + soldCount: 430, + tags: ['clothing', 'dress', 'silk', 'champagne', 'womenswear', 'new-arrival'], + shortDescription: 'Bias-cut pure silk cascades over the body with liquid ease.', + description: 'An ode to effortless luxury. Bias-cut pure silk cascades over the body with liquid ease. From private dinners to gallery openings, this is the dress that transcends occasion.', + image: img('photo-1662532577856-e8ee8b138a8b'), + hoverImage: img('photo-1770062422093-ae32c8fed2a3'), + sizes: ['XS', 'S', 'M', 'L'], + }, + { + title: 'Leather Tote — Obsidian', + slug: 'leather-tote-obsidian', + sku: 'PD-CV-BAG-001', + categorySlug: 'bags', + brand: 'Croft & Vale', + price: 1850, + stock: 15, + isFeatured: true, avgRating: 4.7, - reviewCount: 54, - soldCount: 190, - tags: ['coat', 'outerwear', 'trench', 'layering'], - shortDescription: 'A modern trench with an oversized, editorial shape.', - description: 'A clean trench coat designed for layered outfits and transitional weather.', - image: imageUrl('photo-1539109136881-3be0616acf4b'), - }, - { - title: 'Ribbed Knit Lounge Set', - slug: 'ribbed-knit-lounge-set', - sku: 'FASH-KNT-006', - categorySlug: 'fashion', - brand: 'Skims', - price: 118, - stock: 39, - avgRating: 4.4, - reviewCount: 82, - soldCount: 350, - tags: ['knit', 'lounge', 'set', 'women'], - shortDescription: 'Soft ribbed separates made for elevated off-duty styling.', - description: 'A comfortable knit set that keeps loungewear refined and easy to style.', - image: imageUrl('photo-1485968579580-b6d095142e6e'), - }, - { - title: 'Printed Silk Scarf', - slug: 'printed-silk-scarf', - sku: 'FASH-ACC-007', - categorySlug: 'fashion', - brand: 'Gucci', - price: 74.99, - stock: 44, - avgRating: 4.3, - reviewCount: 35, - soldCount: 160, - tags: ['scarf', 'accessory', 'silk', 'styling'], - shortDescription: 'A polished silk scarf for bags, hair, and neck styling.', - description: 'A lightweight silk scarf with a graphic print and versatile styling options.', - image: imageUrl('photo-1584030373081-f37b7bb4fa8e'), - }, - { - title: 'City Sunglasses', - slug: 'city-sunglasses', - sku: 'FASH-EYE-008', - categorySlug: 'fashion', - brand: 'Prada', - price: 99.99, - compareAtPrice: 130, + reviewCount: 203, + soldCount: 610, + tags: ['bags', 'tote', 'leather', 'black', 'bestseller', 'gold-hardware'], + shortDescription: 'Handcrafted in Florence from full-grain calfskin with suede lining.', + description: 'Handcrafted in Florence from full-grain calfskin, the Obsidian Tote is built to last a lifetime. Its clean geometry and minimal hardware speak volumes without saying a word.', + image: img('photo-1590739225287-bd31519780c3'), + hoverImage: img('photo-1559563458-527698bf5295'), + }, + { + title: 'Eau de Parfum — Sève Noire', + slug: 'eau-de-parfum-seve-noire', + sku: 'PD-VL-FRG-001', + categorySlug: 'fragrance', + brand: 'Veloria', + price: 320, + stock: 28, + isFeatured: true, + isNew: true, + avgRating: 4.9, + reviewCount: 156, + soldCount: 520, + tags: ['fragrance', 'edp', 'oud', 'amber', 'black', 'exclusive', 'new-arrival', 'unisex'], + shortDescription: 'Dark amber resin, Indonesian oud, and a heart of smoked vetiver.', + description: 'Dark amber resin, Indonesian oud, and a heart of smoked vetiver. Sève Noire is a fragrance for those who understand that the most compelling stories are told in whispers.', + image: img('photo-1622618991746-fe6004db3a47'), + hoverImage: img('photo-1543422655-ac1c6ca993ed'), + }, + { + title: 'Cellular Renewal Serum', + slug: 'cellular-renewal-serum', + sku: 'PD-AU-SKN-001', + categorySlug: 'skincare', + brand: 'Aurel', + price: 245, + compareAtPrice: 290, stock: 52, - avgRating: 4.5, - reviewCount: 71, - soldCount: 280, - tags: ['sunglasses', 'accessory', 'summer', 'eyewear'], - shortDescription: 'Sharp sunglasses with a clean city-ready profile.', - description: 'Polished sunglasses with a flattering frame and everyday UV protection.', - image: imageUrl('photo-1511499767150-a48a237f0083'), - }, - { - title: 'Cloud Modular Sofa', - slug: 'cloud-modular-sofa', - sku: 'HOME-SOF-001', - categorySlug: 'home', - brand: 'Article', - price: 1299, - compareAtPrice: 1599, - stock: 9, isFeatured: true, avgRating: 4.8, - reviewCount: 73, - soldCount: 155, - tags: ['sofa', 'living room', 'modular', 'furniture'], - shortDescription: 'A low, plush sofa with a warm modern silhouette.', - description: 'A modular sofa with deep seating, soft cushions, and flexible room planning.', - image: imageUrl('photo-1555041469-a586c61ea9bc'), - }, - { - title: 'Oak Round Coffee Table', - slug: 'oak-round-coffee-table', - sku: 'HOME-TBL-002', - categorySlug: 'home', - brand: 'West Elm', - price: 349.99, - stock: 17, - avgRating: 4.6, - reviewCount: 49, - soldCount: 120, - tags: ['table', 'oak', 'living room', 'furniture'], - shortDescription: 'A rounded oak coffee table for softer living room layouts.', - description: 'A warm oak table with a simple rounded profile and everyday durability.', - image: imageUrl('photo-1532372320572-cda25653a694'), - }, - { - title: 'Ceramic Table Lamp', - slug: 'ceramic-table-lamp', - sku: 'HOME-LGT-003', - categorySlug: 'home', - brand: 'CB2', - price: 129.99, - compareAtPrice: 160, - stock: 28, - avgRating: 4.4, - reviewCount: 41, - soldCount: 175, - tags: ['lamp', 'lighting', 'ceramic', 'decor'], - shortDescription: 'A ceramic lamp that adds warm light and sculptural texture.', - description: 'A softly shaped table lamp for bedside, console, and reading corners.', - image: imageUrl('photo-1507473885765-e6ed057f782c'), - }, - { - title: 'Textured Area Rug', - slug: 'textured-area-rug', - sku: 'HOME-RUG-004', - categorySlug: 'home', - brand: 'IKEA', - price: 219.99, - stock: 24, - avgRating: 4.5, - reviewCount: 58, - soldCount: 210, - tags: ['rug', 'decor', 'living room', 'textile'], - shortDescription: 'A neutral rug with soft texture for grounding a room.', - description: 'A durable area rug with subtle patterning and a comfortable underfoot feel.', - image: imageUrl('photo-1583847268964-b28dc8f51f92'), - }, - { - title: 'Washed Linen Bedding Set', - slug: 'washed-linen-bedding-set', - sku: 'HOME-BED-005', - categorySlug: 'home', - brand: 'Parachute', - price: 189.99, - compareAtPrice: 229.99, - stock: 33, + reviewCount: 342, + soldCount: 860, + tags: ['skincare', 'serum', 'bestseller', 'peptides', 'renewal'], + shortDescription: 'Alpine botanicals and next-gen peptides for overnight skin transformation.', + description: 'A breakthrough in cellular renewal technology. This concentrated serum harnesses rare Alpine botanicals and next-generation peptides to visibly transform skin texture overnight.', + image: img('photo-1595425959632-34f2822322ce'), + hoverImage: img('photo-1598528738936-c50861cc75a9'), + }, + { + title: 'The Archive Derby — Suede', + slug: 'archive-derby-suede', + sku: 'PD-ND-SHO-001', + categorySlug: 'shoes', + brand: 'Nordsen', + price: 685, + stock: 22, isFeatured: true, - avgRating: 4.9, - reviewCount: 116, - soldCount: 380, - tags: ['bedding', 'linen', 'bedroom', 'textile'], - shortDescription: 'Breathable washed linen bedding with a relaxed finish.', - description: 'Soft linen sheets and covers designed for year-round comfort and a lived-in look.', - image: imageUrl('photo-1505693416388-ac5ce068fe85'), - }, - { - title: 'Arched Floor Mirror', - slug: 'arched-floor-mirror', - sku: 'HOME-MIR-006', - categorySlug: 'home', - brand: 'Crate', - price: 279.99, - stock: 14, avgRating: 4.6, - reviewCount: 32, - soldCount: 95, - tags: ['mirror', 'decor', 'bedroom', 'entryway'], - shortDescription: 'A tall arched mirror for bedrooms, closets, and entryways.', - description: 'A full-length mirror with a slender frame and an elegant arched top.', - image: imageUrl('photo-1513506003901-1e6a229e2d15'), - }, - { - title: 'Woven Storage Basket Trio', - slug: 'woven-storage-basket-trio', - sku: 'HOME-STR-007', - categorySlug: 'home', - brand: 'H&M Home', - price: 69.99, - stock: 46, - avgRating: 4.3, - reviewCount: 64, - soldCount: 260, - tags: ['storage', 'basket', 'woven', 'organization'], - shortDescription: 'Three woven baskets for blankets, toys, and shelf styling.', - description: 'Natural woven baskets that keep storage practical without losing visual warmth.', - image: imageUrl('photo-1604014237800-1c9102c219da'), - }, - { - title: 'Walnut Dining Chair', - slug: 'walnut-dining-chair', - sku: 'HOME-CHR-008', - categorySlug: 'home', - brand: 'Wayfair', - price: 159.99, - stock: 30, - avgRating: 4.4, - reviewCount: 45, - soldCount: 140, - tags: ['chair', 'dining', 'walnut', 'furniture'], - shortDescription: 'A walnut dining chair with a clean, comfortable profile.', - description: 'A modern dining chair with warm wood tones and a supportive seat.', - image: imageUrl('photo-1549497538-303791108f95'), - }, - { - title: 'Glow Serum Set', - slug: 'glow-serum-set', - sku: 'BEAU-SKN-001', - categorySlug: 'beauty', - brand: 'Glossier', - price: 58, - compareAtPrice: 72, - stock: 54, + reviewCount: 98, + soldCount: 380, + tags: ['shoes', 'derby', 'suede', 'black', 'brown', 'grey', 'bestseller', 'goodyear-welt'], + shortDescription: 'Italian suede upper, leather sole, hand-stitched welt.', + description: 'The Archive Derby reconsiders a century-old silhouette. Italian suede upper, leather sole, hand-stitched welt. Tradition reimagined for those who move through the world with intention.', + image: img('photo-1682364853446-db043f643207'), + hoverImage: img('photo-1591348278900-019a8a2a8b1d'), + sizes: ['EU 38', 'EU 39', 'EU 40', 'EU 41', 'EU 42', 'EU 43', 'EU 44', 'EU 45'], + }, + { + title: 'Cashmere Turtleneck', + slug: 'cashmere-turtleneck', + sku: 'PD-MV-CLO-007', + categorySlug: 'clothing', + brand: 'Maison Voss', + price: 680, + stock: 40, isFeatured: true, + avgRating: 4.9, + reviewCount: 211, + soldCount: 740, + tags: ['clothing', 'knitwear', 'cashmere', 'turtleneck', 'bestseller', 'oatmeal', 'black'], + shortDescription: 'Grade-A Mongolian cashmere, ribbed at the neck and cuffs.', + description: 'Grade-A Mongolian cashmere, ribbed at the neck and cuffs. This is the sweater that renders all others unnecessary — a quiet declaration of taste.', + image: img('photo-1645561305502-63a9ba09ab09'), + hoverImage: img('photo-1675685828170-fe4b100acd24'), + sizes: ['XS', 'S', 'M', 'L', 'XL'], + }, + { + title: 'Silk Scarf — Cartography', + slug: 'silk-scarf-cartography', + sku: 'PD-CV-ACC-001', + categorySlug: 'accessories', + brand: 'Croft & Vale', + price: 395, + stock: 36, + isNew: true, avgRating: 4.7, - reviewCount: 138, - soldCount: 610, - tags: ['skincare', 'serum', 'glow', 'routine'], - shortDescription: 'A brightening serum set for a simple daily skincare routine.', - description: 'A lightweight serum duo that supports hydrated, balanced, and radiant-looking skin.', - image: imageUrl('photo-1620916566398-39f1143ab7be'), - }, - { - title: 'Daily Cleanser Duo', - slug: 'daily-cleanser-duo', - sku: 'BEAU-SKN-002', - categorySlug: 'beauty', - brand: 'CeraVe', - price: 34.99, - stock: 86, + reviewCount: 67, + soldCount: 225, + tags: ['accessories', 'scarf', 'silk', 'new-arrival', 'print', 'limited-edition'], + shortDescription: 'Hand-rolled silk twill printed with maps of forgotten trade routes.', + description: 'Hand-rolled silk twill printed with maps of forgotten trade routes. A collector\'s piece that transforms any outfit into a statement of worldly sophistication.', + image: img('photo-1590739169125-a9438401596a'), + hoverImage: img('photo-1587467512961-120760940315'), + }, + { + title: 'Wide-Leg Tailored Trouser', + slug: 'wide-leg-tailored-trouser', + sku: 'PD-LM-CLO-009', + categorySlug: 'clothing', + brand: 'Lumière', + price: 590, + stock: 29, avgRating: 4.5, - reviewCount: 104, - soldCount: 480, - tags: ['cleanser', 'skincare', 'daily', 'face wash'], - shortDescription: 'Gentle daily cleansers for morning and evening routines.', - description: 'A balanced cleansing duo made for removing buildup without stripping skin.', - image: imageUrl('photo-1556228578-0d85b1a4d571'), - }, - { - title: 'Hydrating Face Cream', - slug: 'hydrating-face-cream', - sku: 'BEAU-SKN-003', - categorySlug: 'beauty', - brand: 'Rare Beauty', - price: 42, - compareAtPrice: 52, - stock: 61, - avgRating: 4.6, reviewCount: 89, - soldCount: 340, - tags: ['moisturizer', 'skincare', 'hydrating', 'cream'], - shortDescription: 'A plush face cream that locks in hydration.', - description: 'A soft, nourishing moisturizer made to keep skin comfortable through the day.', - image: imageUrl('photo-1596755389378-c31d21fd1273'), - }, - { - title: 'Soft Matte Lipstick', - slug: 'soft-matte-lipstick', - sku: 'BEAU-MKP-004', - categorySlug: 'beauty', - brand: 'MAC', - price: 24, - stock: 72, - avgRating: 4.4, - reviewCount: 176, - soldCount: 700, - tags: ['lipstick', 'makeup', 'matte', 'lips'], - shortDescription: 'A soft matte lipstick with comfortable everyday wear.', - description: 'A richly pigmented lipstick with a smooth matte finish and flattering color payoff.', - image: imageUrl('photo-1586495777744-4413f21062fa'), - }, - { - title: 'Everyday Makeup Palette', - slug: 'everyday-makeup-palette', - sku: 'BEAU-MKP-005', + soldCount: 310, + tags: ['clothing', 'trousers', 'wide-leg', 'tailored', 'chalk', 'black', 'grey'], + shortDescription: 'Cut from Italian gabardine with an architectural drape.', + description: 'Cut from Italian gabardine with an architectural drape, these trousers redefine the silhouette. High-waisted, wide through the leg — a modern classic.', + image: img('photo-1770062422145-c4c08e1f5cc5'), + hoverImage: img('photo-1638265499174-62c2cb29137a'), + sizes: ['XS', 'S', 'M', 'L', 'XL'], + }, + { + title: 'Eau de Parfum — Blanc Absolu', + slug: 'eau-de-parfum-blanc-absolu', + sku: 'PD-VL-FRG-002', + categorySlug: 'fragrance', + brand: 'Veloria', + price: 280, + stock: 41, + avgRating: 4.8, + reviewCount: 134, + soldCount: 490, + tags: ['fragrance', 'edp', 'white', 'floral', 'musk', 'tuberose', 'jasmine'], + shortDescription: 'Tuberose absolute, jasmine grandiflorum, and a trail of warm musk.', + description: 'Pure white flowers captured in glass. Blanc Absolu is an olfactive meditation — tuberose absolute, jasmine grandiflorum, and a trail of warm musk.', + image: img('photo-1557170334-a9632e77c6e4'), + hoverImage: img('photo-1622618991746-fe6004db3a47'), + }, + { + title: 'Luminous Crème — Masque', + slug: 'luminous-creme-masque', + sku: 'PD-AU-BTY-001', categorySlug: 'beauty', - brand: 'Dior', - price: 68, - compareAtPrice: 82, - stock: 25, + brand: 'Aurel', + price: 185, + stock: 48, + isNew: true, + avgRating: 4.7, + reviewCount: 178, + soldCount: 540, + tags: ['beauty', 'mask', 'skincare', 'kaolin', 'luminous', 'new-arrival'], + shortDescription: 'Rare kaolin, diamond powder, and hydrolyzed silk proteins in a weekly mask.', + description: 'A weekly ritual in a jar. Rare kaolin, diamond powder, and hydrolyzed silk proteins work in concert to reveal the most luminous version of your complexion.', + image: img('photo-1631730486572-226d1f595b68'), + hoverImage: img('photo-1598528738936-c50861cc75a9'), + }, + { + title: 'Chelsea Boot — Matte Leather', + slug: 'chelsea-boot-matte-leather', + sku: 'PD-ND-SHO-002', + categorySlug: 'shoes', + brand: 'Nordsen', + price: 795, + stock: 31, isFeatured: true, avgRating: 4.8, - reviewCount: 91, - soldCount: 285, - tags: ['makeup', 'palette', 'eyeshadow', 'face'], - shortDescription: 'A neutral palette for polished everyday makeup.', - description: 'A compact palette with blendable shades for eyes, cheeks, and soft definition.', - image: imageUrl('photo-1512496015851-a90fb38ba796'), - }, - { - title: 'Mineral Sunscreen SPF 40', - slug: 'mineral-sunscreen-spf-40', - sku: 'BEAU-SKN-006', - categorySlug: 'beauty', - brand: 'Supergoop', - price: 36, - stock: 68, - avgRating: 4.6, - reviewCount: 112, - soldCount: 455, - tags: ['sunscreen', 'spf', 'skincare', 'daily'], - shortDescription: 'A lightweight mineral sunscreen for daily protection.', - description: 'A smooth SPF formula designed to layer cleanly under skincare and makeup.', - image: imageUrl('photo-1620916297397-a4a5402a3c6c'), - }, - { - title: 'Body Oil Ritual', - slug: 'body-oil-ritual', - sku: 'BEAU-BDY-007', - categorySlug: 'beauty', - brand: 'Nécessaire', - price: 44, - stock: 37, - avgRating: 4.5, - reviewCount: 63, - soldCount: 205, - tags: ['body care', 'oil', 'hydration', 'ritual'], - shortDescription: 'A silky body oil for post-shower moisture and glow.', - description: 'A lightweight body oil that softens skin and adds a refined, healthy-looking sheen.', - image: imageUrl('photo-1608248543803-ba4f8c70ae0b'), - }, - { - title: 'Blush Brush Set', - slug: 'blush-brush-set', - sku: 'BEAU-TOL-008', - categorySlug: 'beauty', - brand: 'Fenty', - price: 49, - stock: 43, - avgRating: 4.3, - reviewCount: 52, - soldCount: 170, - tags: ['brushes', 'makeup', 'tools', 'blush'], - shortDescription: 'Soft makeup brushes for blush, powder, and blending.', - description: 'A curated brush set with soft bristles and balanced handles for everyday makeup.', - image: imageUrl('photo-1522335789203-aabd1fc54bc9'), + reviewCount: 156, + soldCount: 620, + tags: ['shoes', 'boots', 'black', 'leather', 'chelsea', 'bestseller'], + shortDescription: 'Matte calf leather Chelsea boot with stacked leather heel.', + description: 'The Chelsea boot, elevated. Matte calf leather, elastic goring, stacked leather heel. Every detail considered, nothing superfluous.', + image: img('photo-1682364853446-db043f643207'), + hoverImage: img('photo-1559563458-527698bf5295'), + sizes: ['EU 36', 'EU 37', 'EU 38', 'EU 39', 'EU 40', 'EU 41', 'EU 42'], }, ]; +// Combined list +const allProducts: ProductSeed[] = [...shopProducts, ...detailProducts]; + +// ============================================================================= +// LEGACY CLEANUP +// Removes Electronics and Home & Living from previous seed runs +// ============================================================================= +const LEGACY_SLUGS = ['electronics', 'home']; + +async function cleanupLegacyData() { + const legacy = await prisma.category.findMany({ + where: { slug: { in: LEGACY_SLUGS } }, + select: { id: true, slug: true }, + }); + + if (legacy.length === 0) { + console.log('No legacy categories found — skipping cleanup.'); + return; + } + + const legacyIds = legacy.map((c) => c.id); + + const { count } = await prisma.product.deleteMany({ + where: { categoryId: { in: legacyIds } }, + }); + console.log(` Deleted ${count} legacy products (Electronics / Home & Living).`); + + await prisma.category.deleteMany({ where: { id: { in: legacyIds } } }); + console.log(` Deleted legacy categories: ${legacy.map((c) => c.slug).join(', ')}`); +} + +// ============================================================================= +// USERS +// ============================================================================= async function seedUsers() { const adminEmail = 'admin@vibecart.com'; const existing = await prisma.user.findUnique({ where: { email: adminEmail } }); @@ -622,13 +821,34 @@ async function seedUsers() { isActive: true, }, }); - console.log('Created admin user: admin@vibecart.com / Admin1234!'); + console.log(' Created admin: admin@vibecart.com / Admin1234!'); } else { - console.log('Admin user already exists'); + console.log(' Admin already exists.'); } - const roles = ['OPERATIONS', 'MARKETING', 'SUPPORT'] as const; - for (const role of roles) { + const customerEmail = 'customer@vibecart.com'; + const customerExists = await prisma.user.findUnique({ where: { email: customerEmail } }); + if (!customerExists) { + const passwordHash = await bcrypt.hash('Customer1234!', 12); + await prisma.user.create({ + data: { + email: customerEmail, + passwordHash, + firstName: 'Test', + lastName: 'Customer', + role: 'CUSTOMER', + emailVerified: true, + emailVerifiedAt: new Date(), + isActive: true, + }, + }); + console.log(' Created customer: customer@vibecart.com / Customer1234!'); + } else { + console.log(' Customer already exists.'); + } + + const staffRoles = ['OPERATIONS', 'MARKETING', 'SUPPORT'] as const; + for (const role of staffRoles) { const email = `${role.toLowerCase()}@vibecart.com`; const exists = await prisma.user.findUnique({ where: { email } }); if (!exists) { @@ -645,129 +865,150 @@ async function seedUsers() { isActive: true, }, }); - console.log(`Created ${role} user: ${email} / Staff1234!`); + console.log(` Created ${role}: ${email} / Staff1234!`); } } } +// ============================================================================= +// CATEGORIES +// ============================================================================= async function seedCategories() { - const categoryBySlug = new Map(); + const bySlug = new Map(); - for (const category of categories) { - const seededCategory = await prisma.category.upsert({ - where: { slug: category.slug }, + for (const cat of categories) { + const record = await prisma.category.upsert({ + where: { slug: cat.slug }, update: { - name: category.name, - description: category.description, - imageUrl: category.imageUrl, + name: cat.name, + description: cat.description, + imageUrl: cat.imageUrl, isActive: true, - sortOrder: category.sortOrder, - metaTitle: `${category.name} | VibeCart`, - metaDesc: category.description, + sortOrder: cat.sortOrder, + metaTitle: `${cat.name} | VibeCart`, + metaDesc: cat.description, }, create: { - name: category.name, - slug: category.slug, - description: category.description, - imageUrl: category.imageUrl, + name: cat.name, + slug: cat.slug, + description: cat.description, + imageUrl: cat.imageUrl, isActive: true, - sortOrder: category.sortOrder, - metaTitle: `${category.name} | VibeCart`, - metaDesc: category.description, + sortOrder: cat.sortOrder, + metaTitle: `${cat.name} | VibeCart`, + metaDesc: cat.description, }, - select: { id: true, name: true, slug: true }, + select: { id: true }, }); - - categoryBySlug.set(category.slug, seededCategory); + bySlug.set(cat.slug, record); + console.log(` ✓ ${cat.name}`); } - return categoryBySlug; + return bySlug; } -async function seedProducts(categoryBySlug: Map) { - for (const product of products) { - const category = categoryBySlug.get(product.categorySlug); - if (!category) throw new Error(`Missing category for product: ${product.title}`); +// ============================================================================= +// PRODUCTS +// ============================================================================= +async function seedProducts(bySlug: Map) { + let created = 0; + let updated = 0; - const seededProduct = await prisma.product.upsert({ - where: { slug: product.slug }, - update: { - title: product.title, - description: product.description, - shortDescription: product.shortDescription, - sku: product.sku, - price: product.price, - compareAtPrice: product.compareAtPrice ?? null, - stock: product.stock, - lowStockThreshold: 10, - trackInventory: true, - allowBackorder: false, - status: 'PUBLISHED', - isFeatured: product.isFeatured ?? false, - isDigital: false, - categoryId: category.id, - brand: product.brand, - tags: product.tags, - avgRating: product.avgRating, - reviewCount: product.reviewCount, - soldCount: product.soldCount, - metaTitle: `${product.title} | VibeCart`, - metaDescription: product.shortDescription, - }, - create: { - title: product.title, - slug: product.slug, - description: product.description, - shortDescription: product.shortDescription, - sku: product.sku, - price: product.price, - compareAtPrice: product.compareAtPrice ?? null, - stock: product.stock, - lowStockThreshold: 10, - trackInventory: true, - allowBackorder: false, - status: 'PUBLISHED', - isFeatured: product.isFeatured ?? false, - isDigital: false, - categoryId: category.id, - brand: product.brand, - tags: product.tags, - avgRating: product.avgRating, - reviewCount: product.reviewCount, - soldCount: product.soldCount, - metaTitle: `${product.title} | VibeCart`, - metaDescription: product.shortDescription, - }, + for (const p of allProducts) { + const cat = bySlug.get(p.categorySlug); + if (!cat) throw new Error(`Missing category "${p.categorySlug}" for product "${p.title}"`); + + const commonData = { + title: p.title, + description: p.description, + shortDescription: p.shortDescription, + sku: p.sku, + price: p.price, + compareAtPrice: p.compareAtPrice ?? null, + stock: p.stock, + lowStockThreshold: 5, + trackInventory: true, + allowBackorder: false, + status: 'PUBLISHED' as const, + isFeatured: p.isFeatured ?? false, + isDigital: false, + categoryId: cat.id, + brand: p.brand, + tags: p.tags, + avgRating: p.avgRating, + reviewCount: p.reviewCount, + soldCount: p.soldCount, + metaTitle: `${p.title} — ${p.brand} | VibeCart`, + metaDescription: p.shortDescription, + }; + + const existing = await prisma.product.findUnique({ + where: { slug: p.slug }, select: { id: true }, }); - await prisma.productImage.deleteMany({ where: { productId: seededProduct.id } }); - await prisma.productImage.create({ - data: { - productId: seededProduct.id, - url: product.image, - altText: product.title, - sortOrder: 0, - isPrimary: true, - }, + let productId: string; + + if (existing) { + await prisma.product.update({ where: { id: existing.id }, data: commonData }); + productId = existing.id; + updated++; + } else { + const created_ = await prisma.product.create({ + data: { ...commonData, slug: p.slug }, + select: { id: true }, + }); + productId = created_.id; + created++; + } + + // Replace all images + await prisma.productImage.deleteMany({ where: { productId } }); + + const imagesToCreate = [ + { url: p.image, altText: p.title, sortOrder: 0, isPrimary: true }, + ...(p.hoverImage ? [{ url: p.hoverImage, altText: `${p.title} — alternate view`, sortOrder: 1, isPrimary: false }] : []), + ]; + + await prisma.productImage.createMany({ + data: imagesToCreate.map((i) => ({ ...i, productId })), }); + + console.log(` ✓ [${p.sku}] ${p.title}`); } + + return { created, updated }; } +// ============================================================================= +// ENTRY POINT +// ============================================================================= async function main() { - console.log('Seeding database...'); + console.log('\n══════════════════════════════════════════'); + console.log(' VibeCart Database Seed'); + console.log('══════════════════════════════════════════\n'); + console.log('► Cleanup legacy data...'); + await cleanupLegacyData(); + + console.log('\n► Users...'); await seedUsers(); - const categoryBySlug = await seedCategories(); - await seedProducts(categoryBySlug); - console.log(`Seeded ${categories.length} categories and ${products.length} products.`); - console.log('Seeding complete.'); + console.log('\n► Categories (7)...'); + const bySlug = await seedCategories(); + + console.log(`\n► Products (${allProducts.length})...`); + const { created, updated } = await seedProducts(bySlug); + + console.log(`\n══════════════════════════════════════════`); + console.log(` Done.`); + console.log(` Categories : ${categories.length}`); + console.log(` Products : ${allProducts.length} (${created} new, ${updated} updated)`); + console.log(` ShopPage : ${shopProducts.length}`); + console.log(` ProductPage : ${detailProducts.length}`); + console.log('══════════════════════════════════════════\n'); } main() - .catch((error) => { - console.error(error); - process.exit(1); - }) + .catch((e) => { console.error(e); process.exit(1); }) .finally(() => prisma.$disconnect()); diff --git a/apps/api/src/api/v1/controllers/orders.controller.ts b/apps/api/src/api/v1/controllers/orders.controller.ts new file mode 100644 index 0000000..711a0dd --- /dev/null +++ b/apps/api/src/api/v1/controllers/orders.controller.ts @@ -0,0 +1,58 @@ +import { Response, NextFunction } from 'express'; +import { AuthRequest } from '../../../middleware/auth'; +import { OrdersService } from '../services/orders.service'; +import { OrderStatus } from '@prisma/client'; + +interface HttpError extends Error { statusCode: number; } +function isHttpError(e: unknown): e is HttpError { + return e instanceof Error && 'statusCode' in e && typeof (e as HttpError).statusCode === 'number'; +} + +export class OrdersController { + static async list(req: AuthRequest, res: Response, next: NextFunction): Promise { + try { + const { page, limit, status, search, from, to } = req.query as Record; + const result = await OrdersService.listOrders({ + page: page ? Number(page) : undefined, + limit: limit ? Number(limit) : undefined, + status: status as OrderStatus | undefined, + search, + from, + to, + }); + res.json({ success: true, data: result, timestamp: new Date().toISOString() }); + } catch (error) { next(error); } + } + + static async getById(req: AuthRequest, res: Response, next: NextFunction): Promise { + try { + const order = await OrdersService.getOrderById(req.params.id); + res.json({ success: true, data: { order }, timestamp: new Date().toISOString() }); + } catch (error) { + if (isHttpError(error)) { res.status(error.statusCode).json({ success: false, message: error.message, timestamp: new Date().toISOString() }); return; } + next(error); + } + } + + static async updateStatus(req: AuthRequest, res: Response, next: NextFunction): Promise { + try { + const { status } = req.body; + if (!status || !Object.values(OrderStatus).includes(status)) { + res.status(400).json({ success: false, message: 'Invalid status value', timestamp: new Date().toISOString() }); + return; + } + const order = await OrdersService.updateStatus(req.params.id, status as OrderStatus); + res.json({ success: true, message: 'Order status updated', data: { order }, timestamp: new Date().toISOString() }); + } catch (error) { + if (isHttpError(error)) { res.status(error.statusCode).json({ success: false, message: error.message, timestamp: new Date().toISOString() }); return; } + next(error); + } + } + + static async getDashboardStats(_req: AuthRequest, res: Response, next: NextFunction): Promise { + try { + const data = await OrdersService.getDashboardStats(); + res.json({ success: true, data, timestamp: new Date().toISOString() }); + } catch (error) { next(error); } + } +} diff --git a/apps/api/src/api/v1/controllers/product-csv.controller.ts b/apps/api/src/api/v1/controllers/product-csv.controller.ts index 185b350..e5d7f34 100644 --- a/apps/api/src/api/v1/controllers/product-csv.controller.ts +++ b/apps/api/src/api/v1/controllers/product-csv.controller.ts @@ -49,8 +49,8 @@ export class ProductCsvController { } static async downloadTemplate(_req: AuthRequest, res: Response): Promise { - const template = 'title,sku,price,compareAtPrice,costPrice,stock,status,brand,tags,description,shortDescription,categoryId,metaTitle,metaDescription,trackInventory,allowBackorder,isFeatured,isDigital,weight,weightUnit,barcode,lowStockThreshold\n'; - const example = '"Example Product","EX-001",29.99,49.99,15.00,100,"DRAFT","BrandName","tag1, tag2","Full description here","Short desc","","SEO Title","SEO Description",true,false,false,false,0.5,"kg","",10\n'; + const template = 'title,sku,price,compareAtPrice,costPrice,stock,status,brand,categoryName,tags,description,shortDescription,metaTitle,metaDescription,trackInventory,allowBackorder,isFeatured,isDigital,weight,weightUnit,barcode,lowStockThreshold\n'; + const example = '"Example Product","EX-001",29.99,49.99,15.00,100,"DRAFT","BrandName","Electronics","tag1, tag2","Full description here","Short desc","SEO Title","SEO Description",true,false,false,false,0.5,"kg","",10\n'; res.setHeader('Content-Type', 'text/csv'); res.setHeader('Content-Disposition', 'attachment; filename="vibecart-import-template.csv"'); diff --git a/apps/api/src/api/v1/controllers/product.controller.ts b/apps/api/src/api/v1/controllers/product.controller.ts index b3bcc89..638ab5e 100644 --- a/apps/api/src/api/v1/controllers/product.controller.ts +++ b/apps/api/src/api/v1/controllers/product.controller.ts @@ -65,13 +65,27 @@ export class ProductController { static async listPublished(req: Request, res: Response, next: NextFunction): Promise { try { - const { page, limit, categoryId, brand, search, sort, minPrice, maxPrice, inStock, tags } = req.query; + const { + page, limit, + categoryId, categorySlug, + brand, color, + search, sort, + minPrice, maxPrice, + inStock, tags, + } = req.query; const result = await ProductService.listPublished({ - page: page ? Number(page) : undefined, limit: limit ? Number(limit) : undefined, - categoryId: categoryId as string, brand: brand as string, search: search as string, + page: page ? Number(page) : undefined, + limit: limit ? Number(limit) : undefined, + categoryId: categoryId as string | undefined, + categorySlug: categorySlug as string | undefined, + brand: brand as string | undefined, + color: color as string | undefined, + search: search as string | undefined, sort: (sort as SortOption) || 'newest', - minPrice: minPrice ? Number(minPrice) : undefined, maxPrice: maxPrice ? Number(maxPrice) : undefined, - inStock: inStock === 'true', tags: tags ? (tags as string).split(',') : undefined, + minPrice: minPrice ? Number(minPrice) : undefined, + maxPrice: maxPrice ? Number(maxPrice) : undefined, + inStock: inStock === 'true', + tags: tags ? (tags as string).split(',') : undefined, }); res.status(200).json({ success: true, message: 'Products retrieved', data: { products: result.products, filters: result.filters }, meta: result.meta, timestamp: new Date().toISOString() }); } catch (error: unknown) { next(error); } diff --git a/apps/api/src/api/v1/routes/index.ts b/apps/api/src/api/v1/routes/index.ts index b33a25c..92f00d0 100644 --- a/apps/api/src/api/v1/routes/index.ts +++ b/apps/api/src/api/v1/routes/index.ts @@ -1,22 +1,24 @@ import { Router } from 'express'; import { controllers } from '../controllers'; import debugRoutes from './debug.routes'; +import authRoutes from './auth.routes'; +import adminAuthRoutes from './admin-auth.routes'; +import productRoutes from './product.routes'; +import productImageRoutes from './product-image.routes'; +import productCsvRoutes from './product-csv.routes'; +import categoryRoutes from './category.routes'; +import collectionRoutes from './collection.routes'; +import cartRoutes from './cart.routes'; +import wishlistRoutes from './wishlist.routes'; +import variantRoutes from './variant.routes'; +import rolesRoutes from './roles.routes'; +import ordersRoutes from './orders.routes'; import { config } from '../../../config'; const router = Router(); -/** - * @route GET /api/v1/health - * @desc Health check endpoint - * @access Public - */ router.get('/health', controllers.health.getHealth); -/** - * @route GET /api/v1/ - * @desc API info endpoint - * @access Public - */ router.get('/', (_req, res) => { res.json({ success: true, @@ -30,7 +32,19 @@ router.get('/', (_req, res) => { }); }); -// Debug routes (non-production only) +router.use('/auth', authRoutes); +router.use('/admin/auth', adminAuthRoutes); +router.use('/products/:productId/images', productImageRoutes); +router.use('/products-csv', productCsvRoutes); +router.use('/products', productRoutes); +router.use('/categories', categoryRoutes); +router.use('/collections', collectionRoutes); +router.use('/cart', cartRoutes); +router.use('/wishlist', wishlistRoutes); +router.use('/variants', variantRoutes); +router.use('/roles', rolesRoutes); +router.use('/orders', ordersRoutes); + if (!config.isProduction) { router.use('/debug', debugRoutes); } diff --git a/apps/api/src/api/v1/routes/orders.routes.ts b/apps/api/src/api/v1/routes/orders.routes.ts new file mode 100644 index 0000000..16df5bd --- /dev/null +++ b/apps/api/src/api/v1/routes/orders.routes.ts @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import { OrdersController } from '../controllers/orders.controller'; +import { authenticate, requirePermission } from '../../../middleware/auth'; +import { PERMISSIONS } from '../../../config/roles'; + +const router = Router(); + +router.get('/stats', authenticate, requirePermission(PERMISSIONS.ORDERS_READ), OrdersController.getDashboardStats); +router.get('/', authenticate, requirePermission(PERMISSIONS.ORDERS_READ), OrdersController.list); +router.get('/:id', authenticate, requirePermission(PERMISSIONS.ORDERS_READ), OrdersController.getById); +router.patch('/:id/status', authenticate, requirePermission(PERMISSIONS.ORDERS_UPDATE), OrdersController.updateStatus); + +export default router; diff --git a/apps/api/src/api/v1/services/orders.service.ts b/apps/api/src/api/v1/services/orders.service.ts new file mode 100644 index 0000000..9fd3aa0 --- /dev/null +++ b/apps/api/src/api/v1/services/orders.service.ts @@ -0,0 +1,155 @@ +import { Prisma, OrderStatus } from '@prisma/client'; +import { prisma } from '../../../lib/prisma'; + +const ORDER_INCLUDE = { + user: { select: { id: true, firstName: true, lastName: true, email: true } }, + items: { + include: { + product: { select: { id: true, title: true, slug: true } }, + variant: { select: { id: true, name: true, sku: true } }, + }, + }, + shippingAddress: true, +} satisfies Prisma.OrderInclude; + +export interface ListOrdersFilter { + page?: number; + limit?: number; + status?: OrderStatus; + search?: string; + from?: string; + to?: string; +} + +export class OrdersService { + static async listOrders(filter: ListOrdersFilter = {}) { + const page = Math.max(1, filter.page ?? 1); + const limit = Math.min(100, Math.max(1, filter.limit ?? 20)); + const skip = (page - 1) * limit; + + const where: Prisma.OrderWhereInput = {}; + + if (filter.status) where.status = filter.status; + + if (filter.search) { + where.OR = [ + { orderNumber: { contains: filter.search, mode: 'insensitive' } }, + { user: { email: { contains: filter.search, mode: 'insensitive' } } }, + { user: { firstName: { contains: filter.search, mode: 'insensitive' } } }, + { user: { lastName: { contains: filter.search, mode: 'insensitive' } } }, + ]; + } + + if (filter.from || filter.to) { + where.createdAt = {}; + if (filter.from) where.createdAt.gte = new Date(filter.from); + if (filter.to) where.createdAt.lte = new Date(filter.to); + } + + const [orders, total] = await Promise.all([ + prisma.order.findMany({ where, include: ORDER_INCLUDE, orderBy: { createdAt: 'desc' }, skip, take: limit }), + prisma.order.count({ where }), + ]); + + return { orders, total, page, limit, totalPages: Math.ceil(total / limit) }; + } + + static async getOrderById(id: string) { + const order = await prisma.order.findUnique({ where: { id }, include: ORDER_INCLUDE }); + if (!order) { + const err = Object.assign(new Error('Order not found'), { statusCode: 404 }); + throw err; + } + return order; + } + + static async updateStatus(id: string, status: OrderStatus) { + const order = await prisma.order.findUnique({ where: { id } }); + if (!order) { + const err = Object.assign(new Error('Order not found'), { statusCode: 404 }); + throw err; + } + return prisma.order.update({ where: { id }, data: { status }, include: ORDER_INCLUDE }); + } + + static async getDashboardStats() { + const now = new Date(); + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + const startOfLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); + const endOfLastMonth = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59); + + const [ + totalOrders, + monthOrders, + lastMonthOrders, + revenueAgg, + monthRevenueAgg, + lastMonthRevenueAgg, + totalCustomers, + newMonthCustomers, + lastMonthCustomers, + totalProducts, + ordersByStatus, + recentOrders, + revenueByMonth, + ordersByDay, + ] = await Promise.all([ + prisma.order.count(), + prisma.order.count({ where: { createdAt: { gte: startOfMonth } } }), + prisma.order.count({ where: { createdAt: { gte: startOfLastMonth, lte: endOfLastMonth } } }), + prisma.order.aggregate({ _sum: { total: true } }), + prisma.order.aggregate({ _sum: { total: true }, where: { createdAt: { gte: startOfMonth } } }), + prisma.order.aggregate({ _sum: { total: true }, where: { createdAt: { gte: startOfLastMonth, lte: endOfLastMonth } } }), + prisma.user.count({ where: { role: 'CUSTOMER' } }), + prisma.user.count({ where: { role: 'CUSTOMER', createdAt: { gte: startOfMonth } } }), + prisma.user.count({ where: { role: 'CUSTOMER', createdAt: { gte: startOfLastMonth, lte: endOfLastMonth } } }), + prisma.product.count({ where: { status: 'PUBLISHED' } }), + prisma.order.groupBy({ by: ['status'], _count: { id: true } }), + prisma.order.findMany({ + take: 5, + orderBy: { createdAt: 'desc' }, + include: { user: { select: { firstName: true, lastName: true, email: true } } }, + }), + // Revenue for past 6 months + prisma.$queryRaw<{ month: string; revenue: number }[]>` + SELECT TO_CHAR(DATE_TRUNC('month', "created_at"), 'Mon') as month, + COALESCE(SUM(total), 0)::float as revenue + FROM orders + WHERE "created_at" >= NOW() - INTERVAL '6 months' + GROUP BY DATE_TRUNC('month', "created_at"), TO_CHAR(DATE_TRUNC('month', "created_at"), 'Mon') + ORDER BY DATE_TRUNC('month', "created_at") + `, + // Orders for past 7 days + prisma.$queryRaw<{ day: string; orders: number }[]>` + SELECT TO_CHAR("created_at", 'Dy') as day, + COUNT(*)::int as orders + FROM orders + WHERE "created_at" >= NOW() - INTERVAL '7 days' + GROUP BY DATE_TRUNC('day', "created_at"), TO_CHAR("created_at", 'Dy') + ORDER BY DATE_TRUNC('day', "created_at") + `, + ]); + + const pct = (curr: number, prev: number) => + prev === 0 ? null : Math.round(((curr - prev) / prev) * 100 * 10) / 10; + + const totalRevenue = Number(revenueAgg._sum.total ?? 0); + const monthRevenue = Number(monthRevenueAgg._sum.total ?? 0); + const lastMonthRevenue = Number(lastMonthRevenueAgg._sum.total ?? 0); + + const statusMap = Object.fromEntries(ordersByStatus.map((s) => [s.status, s._count.id])); + + return { + stats: { + totalRevenue: { value: totalRevenue, change: pct(monthRevenue, lastMonthRevenue) }, + totalOrders: { value: totalOrders, change: pct(monthOrders, lastMonthOrders) }, + totalCustomers: { value: totalCustomers, change: pct(newMonthCustomers, lastMonthCustomers) }, + totalProducts: { value: totalProducts, change: null }, + }, + ordersByStatus: statusMap, + recentOrders, + revenueByMonth, + ordersByDay, + }; + } +} diff --git a/apps/api/src/api/v1/services/product-csv.service.ts b/apps/api/src/api/v1/services/product-csv.service.ts index 52a9bcc..0cefda3 100644 --- a/apps/api/src/api/v1/services/product-csv.service.ts +++ b/apps/api/src/api/v1/services/product-csv.service.ts @@ -108,6 +108,10 @@ export class ProductCsvService { throw new ServiceError(`Missing required columns: ${missingRequired.join(', ')}`, 400); } + // Pre-fetch all categories for name→id lookup (case-insensitive) + const allCategories = await prisma.category.findMany({ select: { id: true, name: true } }); + const categoryNameMap = new Map(allCategories.map((c) => [c.name.toLowerCase(), c.id])); + const validatedRows: ImportRow[] = records.map((record, index) => { const errors: string[] = []; const data: Record = {}; @@ -151,12 +155,25 @@ export class ProductCsvService { if (record.shortDescription) data.shortDescription = String(record.shortDescription).trim(); if (record.barcode) data.barcode = String(record.barcode).trim(); if (record.brand) data.brand = String(record.brand).trim(); - if (record.categoryId) data.categoryId = String(record.categoryId).trim(); if (record.metaTitle) data.metaTitle = String(record.metaTitle).trim(); if (record.metaDescription) data.metaDescription = String(record.metaDescription).trim(); if (record.lowStockThreshold) data.lowStockThreshold = Number(record.lowStockThreshold) || 10; if (record.weightUnit) data.weightUnit = String(record.weightUnit).trim(); + // Category: accept categoryName (user-friendly) or categoryId (UUID) + const categoryIdRaw = record.categoryId ? String(record.categoryId).trim() : ''; + const categoryNameRaw = record.categoryName ? String(record.categoryName).trim() : ''; + if (categoryIdRaw !== '') { + data.categoryId = categoryIdRaw; + } else if (categoryNameRaw !== '') { + const resolvedId = categoryNameMap.get(categoryNameRaw.toLowerCase()); + if (resolvedId) { + data.categoryId = resolvedId; + } else { + errors.push(`Category "${categoryNameRaw}" not found — check spelling or leave blank`); + } + } + if (record.tags && String(record.tags).trim() !== '') { data.tags = String(record.tags).split(',').map((t: string) => t.trim()).filter(Boolean); } diff --git a/apps/api/src/api/v1/services/product.service.ts b/apps/api/src/api/v1/services/product.service.ts index 040125a..cb8061e 100644 --- a/apps/api/src/api/v1/services/product.service.ts +++ b/apps/api/src/api/v1/services/product.service.ts @@ -19,7 +19,22 @@ const STOREFRONT_INCLUDE = { variants: { where: { isActive: true }, orderBy: { sortOrder: 'asc' as const } }, }; -export type SortOption = 'newest' | 'price_asc' | 'price_desc' | 'rating' | 'popularity' | 'title_asc' | 'title_desc'; +// Frontend sort labels map to these keys: +// "Latest arrivals" → newest +// "Trending" → trending +// "Price: Low to high" → price_asc +// "Price: High to low" → price_desc +// "Discount: High to low" → discount +export type SortOption = + | 'newest' + | 'price_asc' + | 'price_desc' + | 'rating' + | 'popularity' + | 'trending' + | 'discount' + | 'title_asc' + | 'title_desc'; class AppError extends Error { constructor(message: string, readonly statusCode: number) { super(message); } @@ -27,13 +42,18 @@ class AppError extends Error { function getSortOrder(sort: SortOption): Prisma.ProductOrderByWithRelationInput { switch (sort) { - case 'price_asc': return { price: 'asc' }; + case 'price_asc': return { price: 'asc' }; case 'price_desc': return { price: 'desc' }; - case 'rating': return { avgRating: 'desc' }; + case 'rating': return { avgRating: 'desc' }; case 'popularity': return { soldCount: 'desc' }; - case 'title_asc': return { title: 'asc' }; + case 'trending': return { soldCount: 'desc' }; + // Sort by compareAtPrice desc — products with highest original price (largest savings) first. + // Items without a compareAtPrice are ranked last via nulls: 'last'. + case 'discount': return { compareAtPrice: { sort: 'desc', nulls: 'last' } }; + case 'title_asc': return { title: 'asc' }; case 'title_desc': return { title: 'desc' }; - case 'newest': default: return { createdAt: 'desc' }; + case 'newest': + default: return { createdAt: 'desc' }; } } @@ -133,7 +153,9 @@ export class ProductService { } static async listPublished(params: { - page?: number; limit?: number; categoryId?: string; brand?: string; + page?: number; limit?: number; + categoryId?: string; categorySlug?: string; + brand?: string; color?: string; search?: string; sort?: SortOption; minPrice?: number; maxPrice?: number; inStock?: boolean; tags?: string[]; }) { @@ -142,8 +164,30 @@ export class ProductService { const skip = (page - 1) * limit; const where: Prisma.ProductWhereInput = { status: 'PUBLISHED' }; - if (params.categoryId) where.categoryId = params.categoryId; + + // Category filtering: prefer explicit categoryId, fall back to slug lookup + if (params.categoryId) { + where.categoryId = params.categoryId; + } else if (params.categorySlug) { + const cat = await prisma.category.findUnique({ + where: { slug: params.categorySlug.toLowerCase() }, + select: { id: true }, + }); + if (cat) where.categoryId = cat.id; + } + if (params.brand) where.brand = { equals: params.brand, mode: 'insensitive' }; + + // Color filter: stored as a lowercase tag on each product (e.g. "black", "grey") + if (params.color && params.color.toLowerCase() !== 'all colors') { + const colorTag = params.color.toLowerCase(); + // Merge with existing tags filter if present + if (params.tags && params.tags.length > 0) { + where.tags = { hasEvery: [colorTag, ...params.tags] }; + } else { + where.tags = { has: colorTag }; + } + } if (params.inStock) where.stock = { gt: 0 }; if (params.minPrice || params.maxPrice) { where.price = { @@ -151,7 +195,10 @@ export class ProductService { ...(params.maxPrice ? { lte: params.maxPrice } : {}), }; } - if (params.tags && params.tags.length > 0) where.tags = { hasSome: params.tags }; + // tags-only filter (only applied when no color filter is active — color already merged above) + if (!params.color && params.tags && params.tags.length > 0) { + where.tags = { hasSome: params.tags }; + } if (params.search) { where.OR = [ { title: { contains: params.search, mode: 'insensitive' } }, diff --git a/apps/web/.eslintrc.cjs b/apps/web/.eslintrc.cjs index 103c1f0..2a3eb20 100644 --- a/apps/web/.eslintrc.cjs +++ b/apps/web/.eslintrc.cjs @@ -1,19 +1,30 @@ module.exports = { root: true, + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + ecmaFeatures: { jsx: true }, + }, + plugins: ['@typescript-eslint'], extends: [ 'eslint:recommended', 'plugin:@typescript-eslint/recommended', ], - parser: '@typescript-eslint/parser', - plugins: ['@typescript-eslint'], env: { browser: true, es2020: true, }, rules: { '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }], + '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/no-explicit-any': 'warn', - 'no-console': ['warn', { allow: ['warn', 'error'] }], + '@typescript-eslint/no-non-null-assertion': 'off', + 'no-console': ['warn', { allow: ['warn', 'error', 'info'] }], + 'prefer-const': 'error', + 'no-var': 'error', + 'no-eval': 'error', + 'no-implied-eval': 'error', }, - ignorePatterns: ['dist/', 'node_modules/'], + ignorePatterns: ['dist/', 'node_modules/', '*.js', '*.mjs', '*.cjs'], }; diff --git a/apps/web/.gitignore b/apps/web/.gitignore deleted file mode 100644 index 33e8320..0000000 --- a/apps/web/.gitignore +++ /dev/null @@ -1,22 +0,0 @@ -# Dependencies -node_modules/ - -# Next.js -.next/ -out/ - -# Production -build/ - -# Debug -npm-debug.log* - -# Local env files -.env*.local - -# Vercel -.vercel - -# TypeScript -*.tsbuildinfo -next-env.d.ts diff --git a/apps/web/README.md b/apps/web/README.md index 1df6809..21364a5 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -1,11 +1,100 @@ +# VibeCart — Frontend - # VibeCart Full UI +React storefront and admin panel for the VibeCart e-commerce platform. - This is a code bundle for VibeCart Full UI. The original project is available at https://www.figma.com/design/ItOlGPPenXDQ6yK7RBklqX/VibeCart-Full-UI. +## Tech Stack - ## Running the code +- **React 18** + TypeScript +- **Vite** — dev server and build tool +- **Tailwind CSS v4** — utility-first styling +- **shadcn/ui** — accessible UI components +- **React Router** — client-side routing +- **Lucide React** — icons - Run `npm i` to install the dependencies. +## Structure - Run `npm run dev` to start the development server. - \ No newline at end of file +``` +src/ +└── app/ + ├── admin/ # Admin panel pages + │ ├── AdminLogin.tsx + │ ├── AdminDashboard.tsx + │ ├── AdminProducts.tsx + │ ├── AdminOrders.tsx + │ ├── AdminUsers.tsx + │ ├── AdminCreateProduct.tsx + │ ├── AdminBulkImport.tsx + │ └── AdminLayout.tsx + ├── components/ + │ ├── Navigation.tsx + │ ├── Footer.tsx + │ └── ProductCard.tsx + ├── pages/ # Storefront pages + │ ├── HomePage.tsx + │ ├── ShopPage.tsx + │ ├── ProductPage.tsx + │ ├── CartPage.tsx + │ ├── CheckoutPage.tsx + │ ├── LoginPage.tsx + │ └── WishlistPage.tsx + ├── store/ + │ └── cartStore.ts + ├── data/ + │ └── products.ts + └── lib/ + └── api.ts +``` + +## Setup + +```bash +cd apps/web +npm install +``` + +Create `apps/web/.env.local`: + +```env +VITE_API_URL=http://localhost:3000/api/v1 +VITE_APP_NAME=VibeCart +``` + +## Running + +```bash +npm run dev # http://localhost:5173 +npm run build # production build +npm run preview # preview production build +``` + +## Pages + +| Route | Page | +|-------|------| +| `/` | Homepage — hero, categories, new arrivals, best sellers | +| `/shop` | Product listing with filters, sort, search | +| `/product/:slug` | Product detail with images, variants, add to cart | +| `/cart` | Cart with quantity controls and order summary | +| `/checkout` | Checkout form | +| `/login` | Sign in and create account tabs | +| `/wishlist` | Saved products | +| `/admin/login` | Admin sign in | +| `/admin` | Dashboard — stats, charts, recent orders | +| `/admin/products` | Product table with search and filters | +| `/admin/products/new` | Create product form | +| `/admin/orders` | Order list with detail panel | +| `/admin/users` | Team members and permission matrix | +| `/admin/import` | Bulk CSV product import | + +## Auth + +Tokens are stored in `localStorage`: + +| Key | Value | +|-----|-------| +| `accessToken` | Customer JWT | +| `vc_user` | Customer user object (JSON) | +| `vc_admin_token` | Admin JWT | +| `vc_admin_user` | Admin user object (JSON) | + +The `Navigation` component reads `accessToken` on every route change and shows the user's first name + logout button when signed in. diff --git a/apps/web/default_shadcn_theme.css b/apps/web/default_shadcn_theme.css new file mode 100644 index 0000000..39e1b44 --- /dev/null +++ b/apps/web/default_shadcn_theme.css @@ -0,0 +1,120 @@ +/* KEEP_IN_SYNC(fullscreen/resources/figmake/shadcn/globals.css) */ + +:root { + --font-size: 16px; + --background: #ffffff; + --foreground: oklch(0.145 0 0); + --card: #ffffff; + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: #030213; + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.95 0.0058 264.53); + --secondary-foreground: #030213; + --muted: #ececf0; + --muted-foreground: #717182; + --accent: #e9ebef; + --accent-foreground: #030213; + --destructive: #d4183d; + --destructive-foreground: #ffffff; + --border: rgba(0, 0, 0, 0.1); + --input: transparent; + --input-background: #f3f3f5; + --switch-background: #cbced4; + --font-weight-medium: 500; + --font-weight-normal: 400; + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: #030213; + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.145 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.145 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.985 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.396 0.141 25.723); + --destructive-foreground: oklch(0.637 0.237 25.331); + --border: oklch(0.269 0 0); + --input: oklch(0.269 0 0); + --ring: oklch(0.439 0 0); + --font-weight-medium: 500; + --font-weight-normal: 400; + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.269 0 0); + --sidebar-ring: oklch(0.439 0 0); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-input-background: var(--input-background); + --color-switch-background: var(--switch-background); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} diff --git a/apps/web/index.html b/apps/web/index.html index 4d4e65c..55ffde6 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -2,15 +2,21 @@ + - VibeCart Full UI + Luxury Ecommerce Website Design + + + +
+ \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index 58d632f..b6e95ae 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@vibecart/web", "private": true, - "version": "0.0.1", + "version": "1.0.0", "type": "module", "scripts": { "build": "vite build", @@ -10,9 +10,10 @@ "type-check": "tsc --noEmit" }, "dependencies": { + "react": "18.3.1", + "react-dom": "18.3.1", "@emotion/react": "11.14.0", "@emotion/styled": "11.14.1", - "@hookform/resolvers": "^5.2.2", "@mui/icons-material": "7.3.5", "@mui/material": "7.3.5", "@popperjs/core": "2.11.8", @@ -39,13 +40,10 @@ "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-switch": "1.1.3", "@radix-ui/react-tabs": "1.1.3", - "@radix-ui/react-toggle": "1.1.2", "@radix-ui/react-toggle-group": "1.1.2", + "@radix-ui/react-toggle": "1.1.2", "@radix-ui/react-tooltip": "1.1.8", - "@tailwindcss/postcss": "^4.2.2", - "@types/react-dom": "^19.2.3", - "autoprefixer": "^10.5.0", - "axios": "^1.15.0", + "canvas-confetti": "1.9.4", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", @@ -55,7 +53,6 @@ "lucide-react": "0.487.0", "motion": "12.23.24", "next-themes": "0.4.6", - "postcss": "^8.5.10", "react-day-picker": "8.10.1", "react-dnd": "16.0.1", "react-dnd-html5-backend": "16.0.1", @@ -69,33 +66,14 @@ "sonner": "2.0.3", "tailwind-merge": "3.2.0", "tw-animate-css": "1.3.8", - "vaul": "1.1.2", - "zod": "^3.25.76" + "vaul": "1.1.2" }, "devDependencies": { "@tailwindcss/vite": "4.1.12", - "@typescript-eslint/eslint-plugin": "^8.58.2", - "@typescript-eslint/parser": "^8.58.2", + "@types/react": "18.3.1", + "@types/react-dom": "18.3.1", "@vitejs/plugin-react": "4.7.0", - "eslint": "^8.57.1", - "tailwindcss": "^4.1.12", + "tailwindcss": "4.1.12", "vite": "6.3.5" - }, - "peerDependencies": { - "react": "18.3.1", - "react-dom": "18.3.1" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - }, - "pnpm": { - "overrides": { - "vite": "6.3.5" - } } } diff --git a/apps/web/pnpm-workspace.yaml b/apps/web/pnpm-workspace.yaml new file mode 100644 index 0000000..e4aab11 --- /dev/null +++ b/apps/web/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - '.' \ No newline at end of file diff --git a/apps/web/postcss.config.cjs b/apps/web/postcss.config.cjs deleted file mode 100644 index b4bee66..0000000 --- a/apps/web/postcss.config.cjs +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: { - '@tailwindcss/postcss': {}, - autoprefixer: {}, - }, -}; diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index adb1d90..fcde8eb 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -1,14 +1,100 @@ -import { RouterProvider } from "react-router"; -import { router } from "./routes"; -import { AuthProvider } from "../contexts/AuthContext"; -import { CartProvider } from "../contexts/CartContext"; +import { BrowserRouter, Routes, Route, useLocation } from "react-router"; +import { useEffect } from "react"; +import { Navigation } from "./components/Navigation"; +import { Footer } from "./components/Footer"; +import { HomePage } from "./pages/HomePage"; +import { ShopPage } from "./pages/ShopPage"; +import { ProductPage } from "./pages/ProductPage"; +import { CartPage } from "./pages/CartPage"; +import { CheckoutPage } from "./pages/CheckoutPage"; +import { LoginPage } from "./pages/LoginPage"; +import { WishlistPage } from "./pages/WishlistPage"; +import { AboutPage } from "./pages/AboutPage"; +import { CustomerCarePage } from "./pages/CustomerCarePage"; +import { CareersPage } from "./pages/CareersPage"; +import { PressPage } from "./pages/PressPage"; +import { SustainabilityPage } from "./pages/SustainabilityPage"; +import { PrivacyPage, TermsPage } from "./pages/LegalPage"; + +/* Admin */ +import { AdminLogin } from "./admin/AdminLogin"; +import { AdminDashboard } from "./admin/AdminDashboard"; +import { AdminProducts } from "./admin/AdminProducts"; +import { AdminCreateProduct, AdminEditProduct } from "./admin/AdminCreateProduct"; +import { AdminOrders } from "./admin/AdminOrders"; +import { AdminBulkImport } from "./admin/AdminBulkImport"; +import { AdminUsers } from "./admin/AdminUsers"; +import { AdminSupport } from "./admin/AdminSupport"; +import { AdminSettings } from "./admin/AdminSettings"; + +function ScrollToTop() { + const { pathname } = useLocation(); + useEffect(() => { window.scrollTo(0, 0); }, [pathname]); + return null; +} + +const NO_SHELL = ["/checkout", "/login"]; + +function StorefrontLayout() { + const { pathname } = useLocation(); + const hideShell = NO_SHELL.includes(pathname); + return ( +
+ + {!hideShell && } +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* Customer Care */} + } /> + {/* Company */} + } /> + } /> + } /> + } /> + } /> + } /> + +
+ {!hideShell &&
} +
+ ); +} + +function AdminRoutes() { + return ( + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ); +} + +function AppRouter() { + const { pathname } = useLocation(); + return pathname.startsWith("/admin") ? : ; +} export default function App() { return ( - - - - - + + + ); } diff --git a/apps/web/src/app/admin/AdminBulkImport.tsx b/apps/web/src/app/admin/AdminBulkImport.tsx new file mode 100644 index 0000000..6579cd3 --- /dev/null +++ b/apps/web/src/app/admin/AdminBulkImport.tsx @@ -0,0 +1,197 @@ +import { useState } from "react"; +import { AdminLayout, PageHeader, AdminCard, AdminBtn, TH, TD } from "./AdminLayout"; +import { Upload, FileText, Download, CheckCircle, AlertCircle } from "lucide-react"; + +const HISTORY = [ + { file: "products_jun_2026.csv", by: "Admin User", date: "15 Jun 2026", created: 48, errors: 2, status: "Completed" }, + { file: "beauty_import_v2.csv", by: "Admin User", date: "2 Jun 2026", created: 22, errors: 0, status: "Completed" }, + { file: "footwear_ss26.csv", by: "Admin User", date: "18 May 2026", created: 31, errors: 5, status: "Completed" }, + { file: "accessories_batch.csv", by: "Admin User", date: "4 May 2026", created: 14, errors: 0, status: "Completed" }, +]; + +const REQUIRED_COLS = ["name","slug","brand","category","price","stock","description","imageUrl"]; + +const PREVIEW = [ + { name: "Ivory Blazer", brand: "Lumière", category: "Clothing", price: "$890", stock: 24, valid: true }, + { name: "Beige Loafer", brand: "Nordsen", category: "Shoes", price: "$620", stock: 18, valid: true }, + { name: "Grey Tote", brand: "Croft & Vale", category: "Bags", price: "$1,150",stock: 8, valid: true }, + { name: "White Knit", brand: "", category: "Clothing", price: "$450", stock: 30, valid: false }, + { name: "Black Sunglasses", brand: "Élan Studio", category: "Accessories",price: "$380",stock: 45, valid: true }, +]; + +const jost = "'Jost', sans-serif"; + +export function AdminBulkImport() { + const [dragging, setDragging] = useState(false); + const [file, setFile] = useState(null); + + return ( + + DOWNLOAD TEMPLATE} + /> + +
+
+ + {/* Upload area */} + +

+ UPLOAD CSV +

+
{ e.preventDefault(); setDragging(true); }} + onDragLeave={() => setDragging(false)} + onDrop={e => { e.preventDefault(); setDragging(false); setFile("products_import.csv"); }} + onClick={() => setFile("products_import.csv")} + > + +

+ {file ? `${file} — ready to validate` : "Drag and drop your CSV file here"} +

+

or click to browse

+ { e.stopPropagation(); setFile("products_import.csv"); }}> + UPLOAD CSV + +
+ {file && ( +
+ +

{file} uploaded successfully. 5 products detected.

+
+ )} + {file && ( +
+ VALIDATE FILE + START IMPORT +
+ )} +
+ + {/* Field mapping */} + {file && ( + +

+ FIELD MAPPING +

+
+ + + + {[ + { csv: "product_name", field: "name", ok: true }, + { csv: "url_slug", field: "slug", ok: true }, + { csv: "brand_name", field: "brand", ok: true }, + { csv: "category", field: "category", ok: true }, + { csv: "retail_price", field: "price", ok: true }, + { csv: "qty", field: "stock", ok: true }, + { csv: "copy", field: "description", ok: true }, + { csv: "main_image", field: "imageUrl", ok: true }, + ].map(row => ( + + + + + + ))} + +
CSV COLUMNPRODUCT FIELDSTATUS
{row.csv}{row.field} +
+ + Matched +
+
+
+
+ )} + + {/* Preview */} + {file && ( + +
+

IMPORT PREVIEW

+
+ + 4 valid + + 1 error +
+
+
+ + + + {PREVIEW.map((p, i) => ( + + + + + + + + + ))} + +
NAMEBRANDCATEGORYPRICESTOCKVALIDATION
{p.name} + {p.brand || Missing} + {p.category}{p.price}{p.stock} + {p.valid + ?
Valid
+ :
Brand required
+ } +
+
+
+ )} +
+ + {/* Right — instructions + history */} +
+ +

+ REQUIRED COLUMNS +

+
+ {REQUIRED_COLS.map(col => ( +
+
+ {col} +
+ ))} +
+

+ Optional: comparePrice, colors, sizes, material, tags, metaTitle, metaDescription +

+ + + +
+

IMPORT HISTORY

+
+
+ + + + {HISTORY.map((h, i) => ( + + + + + + ))} + +
FILECREATEDERRORS
+

{h.file}

+

{h.date}

+
{h.created} 0 ? "#92400E" : "#999999" }}>{h.errors}
+
+
+
+
+ + ); +} diff --git a/apps/web/src/app/admin/AdminCreateProduct.tsx b/apps/web/src/app/admin/AdminCreateProduct.tsx new file mode 100644 index 0000000..252aead --- /dev/null +++ b/apps/web/src/app/admin/AdminCreateProduct.tsx @@ -0,0 +1,184 @@ +import { AdminLayout, PageHeader, AdminBtn, FormSection, Input, Textarea, Select, Toggle } from "./AdminLayout"; +import { ImagePlus } from "lucide-react"; + +const jost = "'Jost', sans-serif"; + +interface Props { editMode?: boolean; } + +export function AdminCreateProduct({ editMode = false }: Props) { + return ( + + + SAVE DRAFT + {editMode ? "SAVE CHANGES" : "PUBLISH PRODUCT"} +
+ } + /> + +
+ {/* Left — main form */} +
+ + +
+ + +
+
+ +
+
+