Compare commits

...
11 Commits
Author SHA1 Message Date
AndymickandClaude Sonnet 5 396347b982 Move uploaded images to disk storage, fix Next 16 async params/searchParams
Homepage was rendering 71MB of HTML because every uploaded image (gallery,
products, proofs, custom requests, design previews, invoices, receipts) was
stored as base64 in Postgres and double-embedded via RSC hydration payloads.
Adds src/lib/storage.ts (sharp-based compression, disk storage under
public/uploads/) and a new /api/design-uploads endpoint for images added
inside the design tool, migrates existing rows, and drops the now-unused
base64 columns and the dead ProductImage table.

Also fixes a systemic bug from the Next.js 16 upgrade where dynamic pages
across the app still destructured params/searchParams synchronously instead
of awaiting them as Promises, which was silently breaking filters/params and
crashing /products/[slug] outright.

Updates README.md and SETUP_AND_BUILD.md to reflect Postgres + Next.js 16 and
document the new image storage architecture and backup requirements.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 20:56:40 +01:00
AndymickandClaude Haiku 4.5 e23f201d3d Fabric.js design canvas implementation and supporting infrastructure
Design Canvas (Primary)
- FabricDesignCanvasV2: Fabric.js-based product customization canvas
  - Lazy-loads Fabric.js to minimize bundle impact
  - Renders product mockup with print area guide (magenta dashed box)
  - Supports front/back view switching with correct product images
  - Color swatch selection for garment customization
  - Text element creation with selection handles and transform controls
  - Fixed React dependency array to prevent infinite renders

Support Components
- DesignCanvasWrapper: Client-side wrapper for SSR compatibility
- DesignCanvasErrorBoundary: Error boundary for canvas failures
- ThemeInitializer: Theme detection and application

Configuration
- Updated .claude/launch.json with dev server settings
- Updated .claude/settings.local.json with local preferences
- Updated next.config.mjs for production build optimization
- Dependency updates in package.json and package-lock.json

Known Issues & TODO
- Image element rendering disabled (Fabric.js compatibility investigation)
- Text editing UI controls needed (font, size, color pickers)
- Delete/undo functionality pending
- Print area alignment needs visual verification
- "Add to Cart" integration with design serialization pending

Testing Artifacts
- check_*.js: Product verification scripts for debugging

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-22 16:20:40 +01:00
AndymickandClaude Haiku 4.5 d41aebc4f1 Refactor: Implement Fabric.js-based design canvas (FabricDesignCanvasV2)
Replaced the problematic native Canvas implementation with Fabric.js v7.4.0 for better handling of:
- Text and image manipulation with selection handles
- Print area visualization with magenta dashed guide box
- Front/back view switching with correct product images
- Color swatch selection for garment colors
- Drag-and-drop positioning with proper coordinate system

Key features:
- Lazy-loads Fabric.js to minimize bundle size impact
- Renders product mockup images as background layer
- Displays print area boundaries as selectable zone guide
- Text elements with font/size/color support (images pending)
- Proper React hydration handling with client-only initialization

Known limitations:
- Image element rendering disabled temporarily (Fabric.js compatibility investigation needed)
- Focus on text editing and print area alignment for MVP

Files:
- FabricDesignCanvasV2.tsx: Main canvas component
- DesignCanvasWrapper.tsx: Client-side wrapper for SSR compatibility

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-22 16:20:04 +01:00
AndymickandClaude Haiku 4.5 b4af94cb31 Feat: Add checkout options modal after adding design to bag
- Show modal after design is approved and added to bag
- Offer two options: 'Go to Checkout' and 'Keep Shopping'
- 'Go to Checkout' navigates to /checkout
- 'Keep Shopping' closes the modal and lets customer continue

Improved UX - no more confusing confirmation dialogs, just
a clean modal with clear next steps.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-22 05:46:11 +01:00
AndymickandClaude Haiku 4.5 9dbb288fb3 Fix: Approve & Add to Bag button not working
- Add JSON body to approve API request
- Add Content-Type header
- Add error handling with user-friendly messages
- Log errors to console for debugging

The button now properly sends the approval request and adds
the design to the customer's bag.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-22 05:44:00 +01:00
AndymickandClaude Haiku 4.5 bdb5955447 Feat: Show estimated delivery date on design review page
- Add expectedDeliveryDate to DesignApproval interface
- Display estimated delivery date in Order Summary section
- Shows the date set by admin when approving the design

Customers can now see when their design will be delivered
before adding it to their bag.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-22 05:42:25 +01:00
AndymickandClaude Haiku 4.5 e4a84b1979 Fix: Link approved designs to review page, not personalization
Changed link from /made-to-order/... to /designs/[id]/review

This takes customers to the design review page where they can:
- See the approved design
- Click 'Approve' to add it to their bag
- Proceed to checkout

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-22 05:41:04 +01:00
AndymickandClaude Haiku 4.5 0f119b5db5 Fix: Show approved designs in customer account so they can order
- Add query for approved designs (status = 'APPROVED')
- Display 'Ready to order' section in customer account
- Link directly to checkout with design pre-selected
- Show estimated delivery date if set
- Add success message when admin approves design

Now when admin approves a design with expected delivery date:
1. Customer sees it in 'Ready to order' section
2. Can click to view and proceed to payment
3. Can see estimated delivery date

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-22 05:38:51 +01:00
AndymickandClaude Haiku 4.5 234f03b4dd Revert: Remove loading spinner - was slowing down page
Suspense boundary added complexity and extra queries, making pages slower.
Removed the LoadingSpinner component and Suspense wrapping.

Reverted to simpler, faster approach.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-21 19:32:31 +01:00
AndymickandClaude Haiku 4.5 dc9bceb494 Feat: Add loading spinner for made-to-order page
- Create LoadingSpinner component with spinning circle
- Wrap products grid with Suspense boundary
- Shows spinner while products are loading
- Improves UX for slower connections

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-21 19:30:32 +01:00
AndymickandClaude Haiku 4.5 e716e8256c Feat: Remove all filters from products pages
Remove search, category, color, and price filters from products and
made-to-order pages. Keep only simple pagination for browsing.

Simplifies UX and removes unnecessary database queries:
- No category/collection queries needed
- No palette generation
- No filter logic

Much cleaner pages with faster load times.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-21 19:26:23 +01:00
118 changed files with 3994 additions and 1126 deletions
+2 -1
View File
@@ -5,7 +5,8 @@
"name": "dev", "name": "dev",
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"], "runtimeArgs": ["run", "dev"],
"port": 3000 "port": 3000,
"autoPort": true
} }
] ]
} }
+29 -1
View File
@@ -159,7 +159,35 @@
"Bash(curl -s http://127.0.0.1:3000/products/awd-hoodie)", "Bash(curl -s http://127.0.0.1:3000/products/awd-hoodie)",
"Bash(curl -s http://127.0.0.1:3000/made-to-order)", "Bash(curl -s http://127.0.0.1:3000/made-to-order)",
"Bash(curl -s \"http://127.0.0.1:3000/products/awd-hoodie\")", "Bash(curl -s \"http://127.0.0.1:3000/products/awd-hoodie\")",
"Bash(git commit -m 'Fix: NavDropdown link navigation with stopPropagation *)" "Bash(git commit -m 'Fix: NavDropdown link navigation with stopPropagation *)",
"Bash(git commit -m 'Feat: Remove all filters from products pages *)",
"Bash(git commit -m 'Feat: Add loading spinner for made-to-order page *)",
"Bash(git commit -m 'Revert: Remove loading spinner - was slowing down page *)",
"Bash(git commit -m 'Fix: Show approved designs in customer account so they can order *)",
"Bash(git commit -m 'Fix: Link approved designs to review page, not personalization *)",
"Bash(git commit -m 'Feat: Show estimated delivery date on design review page *)",
"Bash(git commit -m 'Fix: Approve & Add to Bag button not working *)",
"Bash(git commit -m 'Feat: Add checkout options modal after adding design to bag *)",
"PowerShell(Get-Process node -ErrorAction SilentlyContinue)",
"PowerShell(Stop-Process -Force -ErrorAction SilentlyContinue)",
"PowerShell(npx prisma generate)",
"Bash(npm view *)",
"Bash(nc -zv 192.168.0.190 5432)",
"Bash(psql --version)",
"Bash(ssh -o StrictHostKeyChecking=no postgres@192.168.0.190 \"psql -U postgres -c 'SHOW max_connections;'\")",
"Bash(ssh -p 22 -o StrictHostKeyChecking=no postgres@192.168.0.190 \"psql -U postgres -c 'SHOW max_connections;'\")",
"Bash(ssh -p 22 andymick01@192.168.0.190 \"sudo -u postgres psql -c 'SHOW max_connections;'\")",
"Bash(ssh -i ~/.ssh/sshkey -p 22 andymick01@192.168.0.190 \"sudo -u postgres psql -c 'SHOW max_connections;'\")",
"Bash(ssh -vv -i ~/.ssh/sshkey -p 22 andymick01@192.168.0.190 \"echo test\")",
"Bash(ssh git@192.168.0.190 \"sudo -u postgres psql -c 'SHOW max_connections;'\")",
"Bash(ssh -p 22 git@192.168.0.190 \"sudo -u postgres psql -c 'SHOW max_connections;'\")",
"Bash(ssh -i ~/.ssh/id_ed25519 -p 22 git@192.168.0.190 \"sudo -u postgres psql -c 'SHOW max_connections;'\")",
"Bash(psql -h 192.168.0.190 -p 6432 -U postgres -d craft2prints -c \"SELECT 1 as connection_test;\")",
"Bash(npm list *)",
"Bash(npm ls *)",
"Bash(node -e \"const mod = require\\('fabric'\\); console.log\\(Object.keys\\(mod\\).slice\\(0, 20\\)\\)\")",
"Bash(timeout 5 curl -s http://localhost:3000)",
"Bash(curl -s http://localhost:3000/made-to-order/awd-t-shirt-at001)"
] ]
} }
} }
+4 -3
View File
@@ -1,6 +1,7 @@
# Database (SQLite by default — works out of the box on any server). # Database — PostgreSQL connection string. prisma/schema.prisma is fixed to the
# For Postgres later, change provider in prisma/schema.prisma and set this to your Postgres URL. # postgresql provider, so this must point at a real Postgres instance (local
DATABASE_URL="file:./dev.db" # install, Docker container, or managed service) — a SQLite file path won't work.
DATABASE_URL="postgresql://user:password@localhost:5432/craft2prints?schema=public"
# Stripe — get these from https://dashboard.stripe.com/apikeys # Stripe — get these from https://dashboard.stripe.com/apikeys
STRIPE_SECRET_KEY="sk_live_or_test_..." STRIPE_SECRET_KEY="sk_live_or_test_..."
+1
View File
@@ -9,3 +9,4 @@ prisma/dev.db-*
.claude/settings.local.json .claude/settings.local.json
dist/ dist/
database-backups/ database-backups/
public/uploads/
+14 -6
View File
@@ -7,7 +7,7 @@ A complete self-hosted e-commerce platform for personalized products (apparel, d
### Customer Experience ### Customer Experience
- **Design Tool** — In-browser tool to customize templates with text and images - **Design Tool** — In-browser tool to customize templates with text and images
- **Product Catalog** — Browse and filter by category, color, size, and price - **Product Catalog** — Browse and filter by category, color, size, and price
- **Shopping Cart** — Persistent cart stored in IndexedDB (handles large design files) - **Shopping Cart** — Persistent cart stored in IndexedDB
- **Customer Accounts** — Optional sign-up for order history and faster checkout - **Customer Accounts** — Optional sign-up for order history and faster checkout
- **Dark Mode** — Full dark mode support with system preference detection - **Dark Mode** — Full dark mode support with system preference detection
- **Mobile Responsive** — Works seamlessly on all devices - **Mobile Responsive** — Works seamlessly on all devices
@@ -42,12 +42,13 @@ A complete self-hosted e-commerce platform for personalized products (apparel, d
- **Session Expiry** — Auto-logout when browser closes - **Session Expiry** — Auto-logout when browser closes
### Technical Stack ### Technical Stack
- **Frontend** — Next.js 14 (App Router), React, Tailwind CSS - **Frontend** — Next.js 16 (App Router, Turbopack), React, Tailwind CSS
- **Backend** — Next.js server actions, API routes - **Backend** — Next.js server actions, API routes
- **Database** — SQLite with Prisma ORM - **Database** — PostgreSQL with Prisma ORM
- **File storage** — Uploaded images/documents are compressed with `sharp` and saved as files on disk (`public/uploads/`), not as base64 in the database — see [Image & File Storage](#image--file-storage) below
- **Payments** — Stripe (production-ready) - **Payments** — Stripe (production-ready)
- **Email** — SMTP (Gmail, Outlook, SendGrid, etc.) - **Email** — SMTP (Gmail, Outlook, SendGrid, etc.)
- **Design** — SVG-based mockups, Canvas for rendering - **Design** — Fabric.js/Konva canvas for the design tool, SVG-based product mockups
## Quick Start ## Quick Start
@@ -80,7 +81,7 @@ A complete self-hosted e-commerce platform for personalized products (apparel, d
## Configuration ## Configuration
**Essential (.env):** **Essential (.env):**
- `DATABASE_URL` — SQLite path (default: `file:./dev.db`) - `DATABASE_URL` — PostgreSQL connection string
- `ADMIN_USER` / `ADMIN_PASSWORD` — Admin login credentials (⚠️ change before deployment) - `ADMIN_USER` / `ADMIN_PASSWORD` — Admin login credentials (⚠️ change before deployment)
- `STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET` — Stripe payment keys - `STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET` — Stripe payment keys
@@ -103,11 +104,18 @@ A product can appear on both catalogs simultaneously.
Prices stored in **GBP pence** internally. Customer-facing header currency selector (£/$/€) changes display only — no actual conversion happens until checkout is configured for it. Prices stored in **GBP pence** internally. Customer-facing header currency selector (£/$/€) changes display only — no actual conversion happens until checkout is configured for it.
### Cart Storage ### Cart Storage
Cart persists to **IndexedDB**, not localStorage, because design items carry large image files. IndexedDB has no 5-10MB limit like localStorage. Cart persists to **IndexedDB**, not localStorage, since it comfortably holds the design tool's cart items without the ~5-10MB ceiling localStorage imposes.
### Color Names ### Color Names
Colors display as friendly names to customers (Navy, Red, Forest Green) instead of hex codes. Admin still sees hex for reference. Colors display as friendly names to customers (Navy, Red, Forest Green) instead of hex codes. Admin still sees hex for reference.
### Image & File Storage
Every uploaded image or document (product photos, gallery photos, custom-request photos, design-approval previews, purchase invoices, expense receipts, images added inside the design tool) is saved as a real file on disk under `public/uploads/<category>/<yyyy>/<mm>/<uuid>.<ext>`, compressed and resized on the way in with `sharp`. Database columns only ever hold the resulting short URL (e.g. `/uploads/gallery/2026/07/<uuid>.jpeg`) — never a base64 data URL. PDFs (invoices/receipts) pass through untouched.
This matters operationally: `public/uploads/` is gitignored but lives permanently on the server's disk, so **backups must cover it alongside the database** (see [SETUP_AND_BUILD.md](./SETUP_AND_BUILD.md)). No web server config is needed to serve these files — Next.js serves everything under `public/` automatically.
See `src/lib/storage.ts` for the upload/compression helper and its per-category presets.
## Accounting Features ## Accounting Features
### Financial Reports ### Financial Reports
+65 -22
View File
@@ -7,10 +7,11 @@ This document covers everything needed to develop, build, and deploy Craft2Print
1. [Local Development Setup](#local-development-setup) 1. [Local Development Setup](#local-development-setup)
2. [Configuration Reference](#configuration-reference) 2. [Configuration Reference](#configuration-reference)
3. [Database Management](#database-management) 3. [Database Management](#database-management)
4. [Feature Setup](#feature-setup) 4. [Image & File Storage](#image--file-storage)
5. [Building for Production](#building-for-production) 5. [Feature Setup](#feature-setup)
6. [Deployment](#deployment) 6. [Building for Production](#building-for-production)
7. [Troubleshooting](#troubleshooting) 7. [Deployment](#deployment)
8. [Troubleshooting](#troubleshooting)
--- ---
@@ -25,6 +26,8 @@ node -v # Check your version
If not installed, download from https://nodejs.org (LTS version recommended). If not installed, download from https://nodejs.org (LTS version recommended).
**PostgreSQL** — the app connects to a Postgres database (`prisma/schema.prisma`'s `datasource` is fixed to `postgresql`). Install it locally (https://www.postgresql.org/download/) or point `DATABASE_URL` at any reachable Postgres instance (a Docker container, a managed database, another machine on your network).
### Step 1: Clone and Install ### Step 1: Clone and Install
```bash ```bash
@@ -46,7 +49,7 @@ cp .env.example .env
Edit `.env` and configure at minimum: Edit `.env` and configure at minimum:
```env ```env
DATABASE_URL="file:./dev.db" DATABASE_URL="postgresql://user:password@localhost:5432/craft2prints?schema=public"
NEXT_PUBLIC_SITE_URL="http://localhost:3000" NEXT_PUBLIC_SITE_URL="http://localhost:3000"
ADMIN_USER="admin" ADMIN_USER="admin"
ADMIN_PASSWORD="changeme" ADMIN_PASSWORD="changeme"
@@ -62,7 +65,8 @@ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
### Step 3: Database Setup ### Step 3: Database Setup
```bash ```bash
# Create SQLite database and run migrations # Create the database (make sure the DB named in DATABASE_URL already exists — e.g. `createdb craft2prints`)
# then sync the schema to it
npm run db:push npm run db:push
# Load starter products # Load starter products
@@ -87,7 +91,7 @@ Open http://localhost:3000 in your browser.
| Variable | Description | Example | | Variable | Description | Example |
|----------|-------------|---------| |----------|-------------|---------|
| `DATABASE_URL` | SQLite database path | `file:./dev.db` | | `DATABASE_URL` | PostgreSQL connection string | `postgresql://user:pass@host:5432/craft2prints?schema=public` |
| `ADMIN_USER` | Admin login username | `admin` | | `ADMIN_USER` | Admin login username | `admin` |
| `ADMIN_PASSWORD` | Admin login password | `changeme` | | `ADMIN_PASSWORD` | Admin login password | `changeme` |
| `ADMIN_SESSION_SECRET` | Session signing key (random) | 32-byte hex string | | `ADMIN_SESSION_SECRET` | Session signing key (random) | 32-byte hex string |
@@ -147,17 +151,18 @@ NEXT_PUBLIC_SITE_URL="http://localhost:3000" # For design approval links
## Database Management ## Database Management
The app uses `prisma db push` (schema-driven sync), not versioned `prisma migrate` files — `prisma/schema.prisma` is the single source of truth for the database shape.
### Common Tasks ### Common Tasks
**Create database and migrations:** **Sync the schema to the database:**
```bash ```bash
npm run db:push npm run db:push
``` ```
**Reset database (⚠️ deletes all data):** **Reset database (⚠️ deletes all data):**
```bash ```bash
rm dev.db npx prisma db push --force-reset
npm run db:push
npm run db:seed npm run db:seed
``` ```
@@ -165,6 +170,8 @@ npm run db:seed
1. Edit `prisma/schema.prisma` 1. Edit `prisma/schema.prisma`
2. Run `npm run db:push` to sync 2. Run `npm run db:push` to sync
Because there's no migration history, renaming or dropping a column that already holds data is destructive — `db push` will refuse (or require `--accept-data-loss`) rather than silently drop it. For a column that needs to change shape on a live database, do it in three steps: add the new column alongside the old one (non-destructive `db push`), run a one-off script to copy/transform the data into it, update the app code to use the new column, then drop the old column in a second `db push` once you've confirmed nothing still depends on it.
**Seed starter products:** **Seed starter products:**
```bash ```bash
npm run db:seed npm run db:seed
@@ -174,11 +181,47 @@ Updating the seed is safe — it only touches the 6 starter products by their sl
**Backup database:** **Backup database:**
```bash ```bash
cp dev.db dev.db.backup-$(date +%Y%m%d-%H%M%S) pg_dump -h <host> -p <port> -U <user> -d craft2prints -F p -f "database-backups/craft2prints_$(date +%Y-%m-%d_%H-%M-%S).sql"
``` ```
`backup-database.ps1` in the repo root automates this — check its `$dbHost`/`$dbPort`/`$dbUser` match your actual `DATABASE_URL` before relying on it (it defaults to `localhost:5432`, which may not be where your database actually lives). **Backups only cover the database** — uploaded images/files live on disk under `public/uploads/` and need their own backup (see [Image & File Storage](#image--file-storage)).
--- ---
## Image & File Storage
Every uploaded image or document — gallery photos, product photos, custom-request photos, design proofs, design-approval previews, order design previews, purchase invoices, expense receipts, and images added inside the design tool — is saved as a **file on disk**, not as base64 text in the database.
### How it works
1. A file arrives either as a `multipart/form-data` upload (admin forms, customer photo submissions) or as a base64 data URL generated client-side by the design tool's canvas export (`stage.toDataURL()`/`canvas.toDataURL()`).
2. `src/lib/storage.ts` decodes/receives it, and — unless it's a PDF — runs it through [`sharp`](https://sharp.pixelplumbing.com/) to resize (capped per category, never upscaled) and re-encode it (JPEG for photos, lossless PNG where transparency must be preserved, e.g. print-ready design previews).
3. The result is written to `public/uploads/<category>/<yyyy>/<mm>/<uuid>.<ext>` and the database column stores only the resulting path, e.g. `/uploads/gallery/2026/07/3f9c1e2a-....jpeg`.
Categories and their presets (`IMAGE_PRESETS` in `src/lib/storage.ts`):
| Category | Used for | Format | Max width |
|---|---|---|---|
| `gallery` | Public gallery photos | JPEG | 2000px |
| `products` | Product photos (main + per-color) | JPEG, or PNG if the source has transparency | 2000px |
| `custom-requests` | Customer-submitted photos | JPEG | 2000px |
| `proofs` | Admin-uploaded design proofs | JPEG | 2000px |
| `invoices` / `receipts` | Purchase/expense attachments | JPEG, or passthrough if PDF | 2000px |
| `designs` | Print-ready design previews (transparent) | PNG (lossless) | 4000px |
| `design-placements` | Design-on-product reference composites | JPEG | 2000px |
| `design-uploads` | Photos a customer adds inside the design tool | JPEG, or PNG if transparent | 3000px |
### Why this matters operationally
- **`public/uploads/` is gitignored but lives permanently on the server's disk.** It is not part of the database and is not covered by a `pg_dump` backup — back it up separately (a simple `rsync`/copy of the directory alongside your database backup schedule is enough).
- **No web server configuration is needed.** Next.js serves everything under `public/` automatically, in both `next dev` and `next start` — the same mechanism that already serves `public/logo.png`.
- **On a fresh server, make sure the Node process can write to `public/uploads/`** (it's created automatically on first upload, but the parent directory must be writable).
- **Uploads endpoint for the design tool**: images added inside the design canvas are uploaded via `POST /api/design-uploads` as soon as they're added, rather than embedded as base64 in the design's saved JSON — keeping cart/order/design-approval records small regardless of how many images a customer adds.
### Troubleshooting
**"sharp module could not be loaded" / native binding errors on the server**
Sharp ships platform-specific native bindings — never copy `node_modules` between machines (e.g. from a Windows dev box to a Linux server). Run a fresh `npm install` on the target machine (the standard deploy flow below already does this).
## Feature Setup ## Feature Setup
### Stripe Payments ### Stripe Payments
@@ -274,7 +317,8 @@ MAIL_FROM="Craft2Prints <noreply@yourdomain.com>"
**Hardware Requirements:** **Hardware Requirements:**
- Server with Node.js 18+ support - Server with Node.js 18+ support
- 2GB+ RAM (more for concurrent traffic) - 2GB+ RAM (more for concurrent traffic)
- SQLite storage, or PostgreSQL for larger deployments - A reachable PostgreSQL database (local install, Docker container, or managed service)
- Persistent disk for `public/uploads/` — uploaded images/files live here, not in the database (see [Image & File Storage](#image--file-storage))
**Setup:** **Setup:**
@@ -366,8 +410,9 @@ CMD ["npm", "start"]
```bash ```bash
docker build -t craft2prints . docker build -t craft2prints .
docker run -p 3000:3000 --env-file .env craft2prints docker run -p 3000:3000 --env-file .env -v craft2prints-uploads:/app/public/uploads craft2prints
``` ```
The `-v` volume mount is required — without it, `public/uploads/` lives inside the container's writable layer and every uploaded image/file is lost when the container is recreated.
### Post-Deployment Checklist ### Post-Deployment Checklist
@@ -379,6 +424,7 @@ docker run -p 3000:3000 --env-file .env craft2prints
- [ ] Email SMTP tested (send test order confirmation) - [ ] Email SMTP tested (send test order confirmation)
- [ ] CAPTCHA working on public forms - [ ] CAPTCHA working on public forms
- [ ] Database backups scheduled - [ ] Database backups scheduled
- [ ] `public/uploads/` backups scheduled (separate from the database — see [Image & File Storage](#image--file-storage))
- [ ] Log monitoring set up - [ ] Log monitoring set up
- [ ] Rate limiting verified - [ ] Rate limiting verified
- [ ] Staging environment created for testing - [ ] Staging environment created for testing
@@ -406,10 +452,9 @@ npm run dev -- -p 3001 # Use port 3001 instead
npm run db:seed # Load starter products npm run db:seed # Load starter products
``` ```
**Database corrupted** **Database out of sync / corrupted state**
```bash ```bash
rm dev.db npx prisma db push --force-reset # ⚠️ drops and recreates all tables
npm run db:push
npm run db:seed npm run db:seed
``` ```
@@ -434,10 +479,8 @@ node -e "const smtp = require('nodemailer'); console.log(JSON.stringify(process.
**Database connection issues** **Database connection issues**
```bash ```bash
# Test connection # Test connection and list tables
psql $DATABASE_URL # If PostgreSQL psql $DATABASE_URL -c '\dt'
# Or
sqlite3 dev.db ".tables" # If SQLite
``` ```
--- ---
@@ -451,7 +494,7 @@ sqlite3 dev.db ".tables" # If SQLite
- Check payment processing (Stripe dashboard) - Check payment processing (Stripe dashboard)
**Weekly:** **Weekly:**
- Backup database - Backup database and `public/uploads/`
- Review admin activity logs - Review admin activity logs
- Check storage usage - Check storage usage
@@ -521,4 +564,4 @@ npm run build
--- ---
*Last updated: 2025-03-17* *Last updated: 2026-07-22*
+41
View File
@@ -0,0 +1,41 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const product = await prisma.product.findUnique({
where: { slug: 'awd-t-shirt-at001' },
select: {
colorPhotos: true,
colorPhotosBack: true
}
});
const front = JSON.parse(product.colorPhotos);
const back = JSON.parse(product.colorPhotosBack || '{}');
console.log('Front colors:', Object.keys(front).sort());
console.log('Back colors:', Object.keys(back).sort());
const allColors = new Set([...Object.keys(front), ...Object.keys(back)]);
console.log('\nMissing in back:');
Object.keys(front).forEach(color => {
if (!back[color]) {
console.log(` ${color} (front has it, back is MISSING)`);
}
});
console.log('\nExtra in back (no front):');
Object.keys(back).forEach(color => {
if (!front[color]) {
console.log(` ${color} (back has it, front is missing)`);
}
});
}
main()
.then(() => process.exit(0))
.catch(err => {
console.error(err);
process.exit(1);
});
+38
View File
@@ -0,0 +1,38 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const products = await prisma.product.findMany({
where: {
slug: {
in: ['awd-t-shirt-at001', 'awd-hoodie']
}
},
select: {
'slug': true,
'name': true,
'imageUrl': true,
'imageUrlBack': true,
'colorPhotos': true,
'colorPhotosBack': true,
'printArea': true,
'printAreaBack': true
}
});
products.forEach(p => {
console.log(`\n${p.name} (${p.slug}):`);
console.log(` Base Images: front=${!!p.imageUrl}, back=${!!p.imageUrlBack}`);
const colorPhotos = p.colorPhotos ? JSON.parse(p.colorPhotos) : {};
const colorPhotosBack = p.colorPhotosBack ? JSON.parse(p.colorPhotosBack) : {};
console.log(` Color Photos: front=${Object.keys(colorPhotos).length}, back=${Object.keys(colorPhotosBack).length}`);
console.log(` Print Areas: front=${!!p.printArea}, back=${!!p.printAreaBack}`);
});
}
main()
.then(() => process.exit(0))
.catch(err => {
console.error(err);
process.exit(1);
});
+37
View File
@@ -0,0 +1,37 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const products = await prisma.product.findMany({
where: {
slug: {
in: ['awd-t-shirt-at001', 'awd-hoodie']
}
},
select: {
'slug': true,
'name': true,
'printArea': true,
'printAreaBack': true,
'mockup': true
}
});
products.forEach(p => {
console.log(`\n${p.name} (${p.slug}):`);
console.log(` Mockup: ${p.mockup}`);
const front = JSON.parse(p.printArea);
console.log(` Front Print Area: x=${front.xPct}, y=${front.yPct}, w=${front.wPct}, h=${front.hPct}`);
if (p.printAreaBack) {
const back = JSON.parse(p.printAreaBack);
console.log(` Back Print Area: x=${back.xPct}, y=${back.yPct}, w=${back.wPct}, h=${back.hPct}`);
}
});
}
main()
.then(() => process.exit(0))
.catch(err => {
console.error(err);
process.exit(1);
});
+42
View File
@@ -0,0 +1,42 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const product = await prisma.product.findUnique({
where: { slug: 'awd-t-shirt-at001' }
});
if (!product) {
console.log('Product not found');
return;
}
console.log('\n=== T-Shirt Images ===');
console.log(`\nColors: ${product.colors}`);
console.log(`\nimagUrl: ${product.imageUrl ? 'SET' : 'NOT SET'}`);
console.log(`imageUrlBack: ${product.imageUrlBack ? 'SET' : 'NOT SET'}`);
if (product.colorPhotos) {
const colors = JSON.parse(product.colorPhotos);
console.log(`\ncolorPhotos: ${Object.keys(colors).length} colors have photos`);
Object.keys(colors).forEach(color => {
console.log(` - ${color}: ${colors[color] ? 'HAS IMAGE' : 'NO IMAGE'}`);
});
} else {
console.log('\ncolorPhotos: NOT SET');
}
if (product.colorPhotosBack) {
const colors = JSON.parse(product.colorPhotosBack);
console.log(`\ncolorPhotosBack: ${Object.keys(colors).length} colors have back photos`);
} else {
console.log('\ncolorPhotosBack: NOT SET');
}
}
main()
.then(() => process.exit(0))
.catch(err => {
console.error(err);
process.exit(1);
});
+32
View File
@@ -0,0 +1,32 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const products = await prisma.product.findMany({
where: {
slug: {
in: ['awd-t-shirt-at001', 'awd-hoodie']
}
},
select: {
'id': true,
'slug': true,
'name': true,
'imageUrl': true,
'imageUrlBack': true
}
});
products.forEach(p => {
console.log(`\n${p.name} (${p.slug}):`);
console.log(` Front: ${p.imageUrl ? p.imageUrl.substring(0, 60) + '...' : 'NOT SET'}`);
console.log(` Back: ${p.imageUrlBack ? p.imageUrlBack.substring(0, 60) + '...' : 'NOT SET'}`);
});
}
main()
.then(() => process.exit(0))
.catch(err => {
console.error(err);
process.exit(1);
});
+2 -1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1 -2
View File
@@ -1,9 +1,8 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
eslint: { ignoreDuringBuilds: true }, serverExternalPackages: ['@prisma/client', 'sharp'],
experimental: { experimental: {
serverComponentsExternalPackages: ['@prisma/client'],
// Invoice uploads (phone photos/PDFs) and product photo uploads go through // Invoice uploads (phone photos/PDFs) and product photo uploads go through
// server actions — the 1MB default rejects typical phone photos. // server actions — the 1MB default rejects typical phone photos.
serverActions: { bodySizeLimit: '10mb' }, serverActions: { bodySizeLimit: '10mb' },
+2264 -102
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -14,15 +14,17 @@
"dependencies": { "dependencies": {
"@prisma/client": "5.16.1", "@prisma/client": "5.16.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"fabric": "^7.4.0",
"idb-keyval": "6.2.1", "idb-keyval": "6.2.1",
"jose": "^6.2.3", "jose": "^6.2.3",
"konva": "9.3.14", "konva": "9.3.14",
"next": "14.2.5", "next": "^16.2.11",
"nodemailer": "6.9.14", "nodemailer": "6.9.14",
"pdf-parse": "^2.4.5", "pdf-parse": "^2.4.5",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-konva": "18.2.10", "react-konva": "18.2.10",
"sharp": "^0.35.3",
"stripe": "16.2.0", "stripe": "16.2.0",
"tesseract.js": "^7.0.0", "tesseract.js": "^7.0.0",
"uuid": "9.0.1", "uuid": "9.0.1",
+5 -20
View File
@@ -72,21 +72,6 @@ model Product {
manualSaleItems ManualSaleItem[] manualSaleItems ManualSaleItem[]
stockLevels StockLevel[] stockLevels StockLevel[]
purchaseItems PurchaseItem[] purchaseItems PurchaseItem[]
productImages ProductImage[]
}
// Bulk-uploaded product images with auto-detected colors. Supports any product type
// (tees, hoodies, mugs, etc.). User uploads a folder, colors are extracted, then saved
// as color-swatch references for the product's color palette.
model ProductImage {
id String @id @default(cuid())
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
color String // hex color detected from the image, e.g. "#1EA7E0"
imageDataUrl String // the uploaded image itself as a data URL
createdAt DateTime @default(now())
@@unique([productId, color])
} }
// Quantity on hand for one product/colour/size combination. Purchases increase // Quantity on hand for one product/colour/size combination. Purchases increase
@@ -114,7 +99,7 @@ model Purchase {
id String @id @default(cuid()) id String @id @default(cuid())
supplierName String supplierName String
purchasedAt DateTime @default(now()) // invoice date, editable — not entry time purchasedAt DateTime @default(now()) // invoice date, editable — not entry time
invoiceDataUrl String? // the uploaded invoice itself, as a data URL invoiceUrl String? // disk path under /uploads/invoices/...
invoiceFileName String? invoiceFileName String?
total Int // pence — sum of line totals, recomputed server-side total Int // pence — sum of line totals, recomputed server-side
notes String? notes String?
@@ -147,7 +132,7 @@ model Expense {
description String description String
category String @default("Stock purchases") // "Stock purchases", "Postage", others as user enters them category String @default("Stock purchases") // "Stock purchases", "Postage", others as user enters them
amount Int // pence amount Int // pence
receiptDataUrl String? // uploaded receipt as data URL receiptUrl String? // disk path under /uploads/receipts/...
receiptFileName String? receiptFileName String?
notes String? notes String?
purchaseId String? @unique // null = standalone expense; set = auto-created from a Purchase (one-to-one) purchaseId String? @unique // null = standalone expense; set = auto-created from a Purchase (one-to-one)
@@ -167,7 +152,7 @@ model DesignProof {
color String color String
quantity Int @default(1) quantity Int @default(1)
unitPrice Int // cents — defaults to the product's base price if not overridden unitPrice Int // cents — defaults to the product's base price if not overridden
imageDataUrl String // the finished design image you uploaded, shown as-is to the customer imageUrl String // disk path under /uploads/proofs/...
note String? // optional message shown alongside the design note String? // optional message shown alongside the design
status String @default("PENDING") // PENDING | APPROVED status String @default("PENDING") // PENDING | APPROVED
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -333,7 +318,7 @@ model CustomRequest {
customerName String customerName String
customerEmail String customerEmail String
customerPhone String? customerPhone String?
imageDataUrl String // the photo they want us to work with imageUrl String // disk path under /uploads/custom-requests/...
note String? note String?
status String @default("NEW") // NEW | DONE status String @default("NEW") // NEW | DONE
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -430,7 +415,7 @@ model GalleryCategory {
model GalleryPhoto { model GalleryPhoto {
id String @id @default(cuid()) id String @id @default(cuid())
imageDataUrl String // base64 data URL imageUrl String // disk path under /uploads/gallery/...
caption String? caption String?
categoryId String? // nullable — a photo can be uncategorized categoryId String? // nullable — a photo can be uncategorized
category GalleryCategory? @relation(fields: [categoryId], references: [id]) category GalleryCategory? @relation(fields: [categoryId], references: [id])
+5 -4
View File
@@ -37,7 +37,7 @@ export async function registerCustomer(formData: FormData) {
if (password !== confirmPassword) redirect('/account/register?error=mismatch'); if (password !== confirmPassword) redirect('/account/register?error=mismatch');
if (password.length < 8) redirect('/account/register?error=weak'); if (password.length < 8) redirect('/account/register?error=weak');
const ip = getClientIp(headers()); const ip = getClientIp(await headers());
const turnstileToken = String(formData.get('cf-turnstile-response') ?? ''); const turnstileToken = String(formData.get('cf-turnstile-response') ?? '');
const { success } = await verifyTurnstileToken(turnstileToken, ip); const { success } = await verifyTurnstileToken(turnstileToken, ip);
if (!success) redirect('/account/register?error=captcha'); if (!success) redirect('/account/register?error=captcha');
@@ -61,8 +61,9 @@ export async function loginCustomer(formData: FormData) {
const password = String(formData.get('password') ?? ''); const password = String(formData.get('password') ?? '');
const next = safeNext(formData.get('next')); const next = safeNext(formData.get('next'));
const ip = getClientIp(headers()); const headersList = await headers();
const userAgent = headers().get('user-agent') ?? undefined; const ip = getClientIp(headersList);
const userAgent = headersList.get('user-agent') ?? undefined;
const { allowed } = await checkRateLimit(`login:customer:${ip}`, 5, 15 * 60 * 1000); const { allowed } = await checkRateLimit(`login:customer:${ip}`, 5, 15 * 60 * 1000);
if (!allowed) { if (!allowed) {
@@ -95,7 +96,7 @@ export async function loginCustomer(formData: FormData) {
} }
export async function logoutCustomer() { export async function logoutCustomer() {
clearSession(); await clearSession();
redirect('/'); redirect('/');
} }
+3 -2
View File
@@ -10,11 +10,12 @@ const ERROR_MESSAGES: Record<string, string> = {
invalid_current: 'Current password is incorrect.', invalid_current: 'Current password is incorrect.',
}; };
export default async function ChangePasswordPage({ searchParams }: { searchParams: { error?: string } }) { export default async function ChangePasswordPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const { error: errorParam } = await searchParams;
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) redirect('/account/login'); if (!customer) redirect('/account/login');
const error = searchParams.error ? ERROR_MESSAGES[searchParams.error] : null; const error = errorParam ? ERROR_MESSAGES[errorParam] : null;
return ( return (
<div className="mx-auto max-w-2xl px-6 py-12"> <div className="mx-auto max-w-2xl px-6 py-12">
+3 -2
View File
@@ -1,8 +1,9 @@
import Link from 'next/link'; import Link from 'next/link';
import { requestPasswordReset } from '../actions'; import { requestPasswordReset } from '../actions';
export default function ForgotPasswordPage({ searchParams }: { searchParams: { sent?: string } }) { export default async function ForgotPasswordPage({ searchParams }: { searchParams: Promise<{ sent?: string }> }) {
const sent = searchParams.sent === '1'; const params = await searchParams;
const sent = params.sent === '1';
return ( return (
<div className="mx-auto max-w-md px-6 py-16"> <div className="mx-auto max-w-md px-6 py-16">
+4 -3
View File
@@ -6,9 +6,10 @@ const ERROR_MESSAGES: Record<string, string> = {
rate_limited: 'Too many attempts — please wait a few minutes and try again.', rate_limited: 'Too many attempts — please wait a few minutes and try again.',
}; };
export default function LoginPage({ searchParams }: { searchParams: { error?: string; next?: string } }) { export default async function LoginPage({ searchParams }: { searchParams: Promise<{ error?: string; next?: string }> }) {
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null; const params = await searchParams;
const next = searchParams.next ?? '/account'; const error = params.error ? (ERROR_MESSAGES[params.error] ?? 'Something went wrong.') : null;
const next = params.next ?? '/account';
return ( return (
<div className="mx-auto max-w-md px-6 py-16"> <div className="mx-auto max-w-md px-6 py-16">
+3 -2
View File
@@ -21,12 +21,13 @@ function statusClasses(status: string, trackingNumber?: string | null) {
return 'bg-splash-orange/10 text-splash-orange'; return 'bg-splash-orange/10 text-splash-orange';
} }
export default async function AccountOrderDetailPage({ params }: { params: { id: string } }) { export default async function AccountOrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) redirect('/account/login'); if (!customer) redirect('/account/login');
const order = await prisma.order.findUnique({ const order = await prisma.order.findUnique({
where: { id: params.id }, where: { id },
include: { items: true }, include: { items: true },
}); });
if (!order || order.customerId !== customer.id) notFound(); if (!order || order.customerId !== customer.id) notFound();
+42 -2
View File
@@ -29,11 +29,12 @@ const SUCCESS_MESSAGES: Record<string, string> = {
password_changed: 'Password updated successfully.', password_changed: 'Password updated successfully.',
}; };
export default async function AccountPage({ searchParams }: { searchParams: { success?: string } }) { export default async function AccountPage({ searchParams }: { searchParams: Promise<{ success?: string }> }) {
const { success: successParam } = await searchParams;
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) redirect('/account/login'); if (!customer) redirect('/account/login');
const success = searchParams.success ? SUCCESS_MESSAGES[searchParams.success] : null; const success = successParam ? SUCCESS_MESSAGES[successParam] : null;
const orders = await prisma.order.findMany({ const orders = await prisma.order.findMany({
where: { customerId: customer.id }, where: { customerId: customer.id },
@@ -47,6 +48,12 @@ export default async function AccountPage({ searchParams }: { searchParams: { su
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
const approvedDesigns = await prisma.designApproval.findMany({
where: { customerEmail: customer.email, status: 'APPROVED' },
include: { product: true },
orderBy: { createdAt: 'desc' },
});
// Separate recent (< 7 days) and older (>= 7 days) orders // Separate recent (< 7 days) and older (>= 7 days) orders
const now = new Date(); const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
@@ -121,6 +128,39 @@ export default async function AccountPage({ searchParams }: { searchParams: { su
</div> </div>
)} )}
<h2 className="mt-10 font-display text-xl">Ready to order</h2>
{approvedDesigns.length === 0 ? (
<p className="mt-3 text-sm text-muted">No approved designs ready to order.</p>
) : (
<div className="mt-4 divide-y divide-line border-t border-line">
{approvedDesigns.map((design) => (
<Link
key={design.id}
href={`/designs/${design.id}/review`}
className="flex items-center gap-4 py-4 hover:bg-surface"
>
<img
src={design.designPreviewUrl}
alt="design preview"
className="h-14 w-14 border border-line object-cover"
/>
<div className="flex-1">
<p className="font-medium">{design.product.name}</p>
<p className="text-sm text-muted">
Approved {new Date(design.createdAt).toLocaleDateString(undefined, { dateStyle: 'medium' })}
</p>
{design.expectedDeliveryDate && (
<p className="text-xs text-muted mt-1">
Est. delivery: {new Date(design.expectedDeliveryDate).toLocaleDateString(undefined, { dateStyle: 'medium' })}
</p>
)}
</div>
<span className="text-sm text-clay">Order </span>
</Link>
))}
</div>
)}
<h2 className="mt-10 font-display text-xl">Order history</h2> <h2 className="mt-10 font-display text-xl">Order history</h2>
{orders.length === 0 ? ( {orders.length === 0 ? (
<p className="mt-3 text-sm text-muted">No orders yet.</p> <p className="mt-3 text-sm text-muted">No orders yet.</p>
+4 -3
View File
@@ -10,9 +10,10 @@ const ERROR_MESSAGES: Record<string, string> = {
captcha: 'CAPTCHA verification failed — please try again.', captcha: 'CAPTCHA verification failed — please try again.',
}; };
export default function RegisterPage({ searchParams }: { searchParams: { error?: string; next?: string } }) { export default async function RegisterPage({ searchParams }: { searchParams: Promise<{ error?: string; next?: string }> }) {
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null; const params = await searchParams;
const next = searchParams.next ?? '/account'; const error = params.error ? (ERROR_MESSAGES[params.error] ?? 'Something went wrong.') : null;
const next = params.next ?? '/account';
return ( return (
<div className="mx-auto max-w-md px-6 py-16"> <div className="mx-auto max-w-md px-6 py-16">
+4 -3
View File
@@ -8,9 +8,10 @@ const ERROR_MESSAGES: Record<string, string> = {
weak: 'Password must be at least 8 characters.', weak: 'Password must be at least 8 characters.',
}; };
export default function ResetPasswordPage({ searchParams }: { searchParams: { token?: string; error?: string } }) { export default async function ResetPasswordPage({ searchParams }: { searchParams: Promise<{ token?: string; error?: string }> }) {
const token = searchParams.token ?? ''; const params = await searchParams;
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null; const token = params.token ?? '';
const error = params.error ? (ERROR_MESSAGES[params.error] ?? 'Something went wrong.') : null;
if (!token) { if (!token) {
return ( return (
+1 -1
View File
@@ -15,7 +15,7 @@ export async function subscribeToNewsletter(email: string, turnstileToken: strin
return { ok: false, message: 'Enter a valid email address.' }; return { ok: false, message: 'Enter a valid email address.' };
} }
const ip = getClientIp(headers()); const ip = getClientIp(await headers());
const { success } = await verifyTurnstileToken(turnstileToken, ip); const { success } = await verifyTurnstileToken(turnstileToken, ip);
if (!success) { if (!success) {
return { ok: false, message: 'CAPTCHA verification failed — please try again.' }; return { ok: false, message: 'CAPTCHA verification failed — please try again.' };
@@ -3,12 +3,14 @@ import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav'; import AdminNav from '@/components/AdminNav';
import Price from '@/components/Price'; import Price from '@/components/Price';
import { isPdfUrl } from '@/lib/storage';
export default async function ExpenseDetailPage({ params }: { params: { id: string } }) { export default async function ExpenseDetailPage({ params }: { params: Promise<{ id: string }> }) {
const expense = await prisma.expense.findUnique({ where: { id: params.id } }); const { id } = await params;
const expense = await prisma.expense.findUnique({ where: { id } });
if (!expense) notFound(); if (!expense) notFound();
const isPdf = expense.receiptDataUrl?.startsWith('data:application/pdf'); const isPdf = isPdfUrl(expense.receiptUrl);
return ( return (
<div className="mx-auto max-w-2xl px-6 py-12"> <div className="mx-auto max-w-2xl px-6 py-12">
@@ -30,20 +32,20 @@ export default async function ExpenseDetailPage({ params }: { params: { id: stri
{expense.notes && <p className="border-l-2 border-splash-orange pl-4 text-sm italic">{expense.notes}</p>} {expense.notes && <p className="border-l-2 border-splash-orange pl-4 text-sm italic">{expense.notes}</p>}
</div> </div>
{expense.receiptDataUrl && ( {expense.receiptUrl && (
<div className="mt-8"> <div className="mt-8">
<p className="tag-label mb-3">Receipt{expense.receiptFileName ? `${expense.receiptFileName}` : ''}</p> <p className="tag-label mb-3">Receipt{expense.receiptFileName ? `${expense.receiptFileName}` : ''}</p>
{isPdf ? ( {isPdf ? (
<iframe src={expense.receiptDataUrl} title="Receipt" className="h-[600px] w-full border border-line" /> <iframe src={expense.receiptUrl} title="Receipt" className="h-[600px] w-full border border-line" />
) : ( ) : (
<img <img
src={expense.receiptDataUrl} src={expense.receiptUrl}
alt="Receipt" alt="Receipt"
className="max-h-[600px] w-full border border-line object-contain" className="max-h-[600px] w-full border border-line object-contain"
/> />
)} )}
<a <a
href={expense.receiptDataUrl} href={expense.receiptUrl}
download={expense.receiptFileName ?? 'receipt'} download={expense.receiptFileName ?? 'receipt'}
className="mt-2 inline-block tag-label text-clay hover:text-clay-dark" className="mt-2 inline-block tag-label text-clay hover:text-clay-dark"
> >
+4 -8
View File
@@ -2,6 +2,7 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { saveUploadedFile } from '@/lib/storage';
export async function createExpense(formData: FormData) { export async function createExpense(formData: FormData) {
const dateInput = String(formData.get('date') ?? '').trim(); const dateInput = String(formData.get('date') ?? '').trim();
@@ -15,15 +16,10 @@ export async function createExpense(formData: FormData) {
const amount = Math.max(0, Math.round(Number(amountInput || '0') * 100)); const amount = Math.max(0, Math.round(Number(amountInput || '0') * 100));
if (!amount) throw new Error('Amount must be greater than 0.'); if (!amount) throw new Error('Amount must be greater than 0.');
let receiptDataUrl: string | null = null; let receiptUrl: string | null = null;
let receiptFileName: string | null = null; let receiptFileName: string | null = null;
if (receipt && receipt.size > 0) { if (receipt && receipt.size > 0) {
if (receipt.size > 8 * 1024 * 1024) { receiptUrl = await saveUploadedFile(receipt, 'receipts', { maxBytes: 8 * 1024 * 1024, allowPdf: true });
throw new Error('Receipt file is too large — keep it under 8 MB.');
}
const bytes = Buffer.from(await receipt.arrayBuffer());
const mimeType = receipt.type || 'application/octet-stream';
receiptDataUrl = `data:${mimeType};base64,${bytes.toString('base64')}`;
receiptFileName = receipt.name || 'receipt'; receiptFileName = receipt.name || 'receipt';
} }
@@ -33,7 +29,7 @@ export async function createExpense(formData: FormData) {
description, description,
category, category,
amount, amount,
receiptDataUrl, receiptUrl,
receiptFileName, receiptFileName,
notes: notes || null, notes: notes || null,
}, },
+1 -1
View File
@@ -44,7 +44,7 @@ export default async function ExpensesPage() {
<span className="w-20 shrink-0 text-right font-mono text-sm"> <span className="w-20 shrink-0 text-right font-mono text-sm">
<Price cents={e.amount} /> <Price cents={e.amount} />
</span> </span>
{e.receiptDataUrl && ( {e.receiptUrl && (
<Link href={`/admin/accounts/expenses/${e.id}`} className="tag-label text-muted hover:text-ink"> <Link href={`/admin/accounts/expenses/${e.id}`} className="tag-label text-muted hover:text-ink">
View View
</Link> </Link>
+16 -15
View File
@@ -26,15 +26,16 @@ type LedgerEntry = {
export default async function AccountsPage({ export default async function AccountsPage({
searchParams, searchParams,
}: { }: {
searchParams: { type?: string; from?: string; to?: string }; searchParams: Promise<{ type?: string; from?: string; to?: string }>;
}) { }) {
const type = searchParams.type === 'web' || searchParams.type === 'manual' ? searchParams.type : 'all'; const sp = await searchParams;
const from = searchParams.from ? new Date(searchParams.from) : null; const type = sp.type === 'web' || sp.type === 'manual' ? sp.type : 'all';
const from = sp.from ? new Date(sp.from) : null;
// Include the whole "to" day, not just its midnight. // Include the whole "to" day, not just its midnight.
const to = searchParams.to ? new Date(`${searchParams.to}T23:59:59`) : null; const to = sp.to ? new Date(`${sp.to}T23:59:59`) : null;
let orders = []; let orders: any[] = [];
let manualSales = []; let manualSales: any[] = [];
try { try {
[orders, manualSales] = await Promise.all([ [orders, manualSales] = await Promise.all([
@@ -59,22 +60,22 @@ export default async function AccountsPage({
} }
const entries: LedgerEntry[] = [ const entries: LedgerEntry[] = [
...orders.map((o) => ({ ...orders.map((o: any) => ({
id: o.id, id: o.id,
kind: 'WEB' as const, kind: 'WEB' as const,
date: o.createdAt, date: o.createdAt,
who: o.customer?.name ?? o.email ?? 'Guest', who: o.customer?.name ?? o.email ?? 'Guest',
what: o.items.map((i) => `${i.quantity}× ${i.productName}`).join(', '), what: o.items.map((i: any) => `${i.quantity}× ${i.productName}`).join(', '),
method: 'Card (website)', method: 'Card (website)',
total: o.total, total: o.total,
href: `/admin/orders`, href: `/admin/orders`,
})), })),
...manualSales.map((s) => ({ ...manualSales.map((s: any) => ({
id: s.id, id: s.id,
kind: 'MANUAL' as const, kind: 'MANUAL' as const,
date: s.soldAt, date: s.soldAt,
who: s.buyerName || s.buyerEmail || 'Unnamed buyer', who: s.buyerName || s.buyerEmail || 'Unnamed buyer',
what: s.items.map((i) => `${i.quantity}× ${i.description}`).join(', '), what: s.items.map((i: any) => `${i.quantity}× ${i.description}`).join(', '),
method: PAYMENT_LABELS[s.paymentMethod] ?? s.paymentMethod, method: PAYMENT_LABELS[s.paymentMethod] ?? s.paymentMethod,
total: s.total, total: s.total,
href: null, href: null,
@@ -88,8 +89,8 @@ export default async function AccountsPage({
const filterHref = (t: string) => { const filterHref = (t: string) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (t !== 'all') params.set('type', t); if (t !== 'all') params.set('type', t);
if (searchParams.from) params.set('from', searchParams.from); if (sp.from) params.set('from', sp.from);
if (searchParams.to) params.set('to', searchParams.to); if (sp.to) params.set('to', sp.to);
const qs = params.toString(); const qs = params.toString();
return `/admin/accounts${qs ? `?${qs}` : ''}`; return `/admin/accounts${qs ? `?${qs}` : ''}`;
}; };
@@ -160,7 +161,7 @@ export default async function AccountsPage({
<input <input
type="date" type="date"
name="from" name="from"
defaultValue={searchParams.from ?? ''} defaultValue={sp.from ?? ''}
className="border border-line bg-paper px-2 py-1.5" className="border border-line bg-paper px-2 py-1.5"
/> />
</label> </label>
@@ -169,14 +170,14 @@ export default async function AccountsPage({
<input <input
type="date" type="date"
name="to" name="to"
defaultValue={searchParams.to ?? ''} defaultValue={sp.to ?? ''}
className="border border-line bg-paper px-2 py-1.5" className="border border-line bg-paper px-2 py-1.5"
/> />
</label> </label>
<button type="submit" className="border border-line px-3 py-1.5 hover:border-clay hover:text-clay"> <button type="submit" className="border border-line px-3 py-1.5 hover:border-clay hover:text-clay">
Apply Apply
</button> </button>
{(searchParams.from || searchParams.to) && ( {(sp.from || sp.to) && (
<Link href={filterHref(type)} className="tag-label text-muted hover:text-ink"> <Link href={filterHref(type)} className="tag-label text-muted hover:text-ink">
Clear dates Clear dates
</Link> </Link>
@@ -3,15 +3,17 @@ import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav'; import AdminNav from '@/components/AdminNav';
import Price from '@/components/Price'; import Price from '@/components/Price';
import { isPdfUrl } from '@/lib/storage';
export default async function PurchaseDetailPage({ params }: { params: { id: string } }) { export default async function PurchaseDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const purchase = await prisma.purchase.findUnique({ const purchase = await prisma.purchase.findUnique({
where: { id: params.id }, where: { id },
include: { items: { include: { product: true } } }, include: { items: { include: { product: true } } },
}); });
if (!purchase) notFound(); if (!purchase) notFound();
const isPdf = purchase.invoiceDataUrl?.startsWith('data:application/pdf'); const isPdf = isPdfUrl(purchase.invoiceUrl);
return ( return (
<div className="mx-auto max-w-2xl px-6 py-12"> <div className="mx-auto max-w-2xl px-6 py-12">
@@ -50,20 +52,20 @@ export default async function PurchaseDetailPage({ params }: { params: { id: str
{purchase.notes && <div className="mt-6 border-l-2 border-splash-orange pl-4 text-sm italic">{purchase.notes}</div>} {purchase.notes && <div className="mt-6 border-l-2 border-splash-orange pl-4 text-sm italic">{purchase.notes}</div>}
{purchase.invoiceDataUrl && ( {purchase.invoiceUrl && (
<div className="mt-8"> <div className="mt-8">
<p className="tag-label mb-3">Invoice{purchase.invoiceFileName ? `${purchase.invoiceFileName}` : ''}</p> <p className="tag-label mb-3">Invoice{purchase.invoiceFileName ? `${purchase.invoiceFileName}` : ''}</p>
{isPdf ? ( {isPdf ? (
<iframe src={purchase.invoiceDataUrl} title="Invoice" className="h-[600px] w-full border border-line" /> <iframe src={purchase.invoiceUrl} title="Invoice" className="h-[600px] w-full border border-line" />
) : ( ) : (
<img <img
src={purchase.invoiceDataUrl} src={purchase.invoiceUrl}
alt="Invoice" alt="Invoice"
className="max-h-[600px] w-full border border-line object-contain" className="max-h-[600px] w-full border border-line object-contain"
/> />
)} )}
<a <a
href={purchase.invoiceDataUrl} href={purchase.invoiceUrl}
download={purchase.invoiceFileName ?? 'invoice'} download={purchase.invoiceFileName ?? 'invoice'}
className="mt-2 inline-block tag-label text-clay hover:text-clay-dark" className="mt-2 inline-block tag-label text-clay hover:text-clay-dark"
> >
+4 -8
View File
@@ -4,6 +4,7 @@ import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { applyStockMovement } from '@/lib/stock'; import { applyStockMovement } from '@/lib/stock';
import { logFinancialChange } from '@/lib/financialAudit'; import { logFinancialChange } from '@/lib/financialAudit';
import { saveUploadedFile } from '@/lib/storage';
export type PurchaseItemInput = { export type PurchaseItemInput = {
productId: string | null; // null = loose material line (ink, packaging…) productId: string | null; // null = loose material line (ink, packaging…)
@@ -42,15 +43,10 @@ export async function createPurchase(formData: FormData) {
} }
} }
let invoiceDataUrl: string | null = null; let invoiceUrl: string | null = null;
let invoiceFileName: string | null = null; let invoiceFileName: string | null = null;
if (invoice && invoice.size > 0) { if (invoice && invoice.size > 0) {
if (invoice.size > MAX_INVOICE_BYTES) { invoiceUrl = await saveUploadedFile(invoice, 'invoices', { maxBytes: MAX_INVOICE_BYTES, allowPdf: true });
throw new Error('Invoice file is too large — keep it under 8 MB.');
}
const bytes = Buffer.from(await invoice.arrayBuffer());
const mimeType = invoice.type || 'application/octet-stream';
invoiceDataUrl = `data:${mimeType};base64,${bytes.toString('base64')}`;
invoiceFileName = invoice.name || 'invoice'; invoiceFileName = invoice.name || 'invoice';
} }
@@ -62,7 +58,7 @@ export async function createPurchase(formData: FormData) {
data: { data: {
supplierName, supplierName,
purchasedAt: purchaseDate, purchasedAt: purchaseDate,
invoiceDataUrl, invoiceUrl,
invoiceFileName, invoiceFileName,
total, total,
notes: notes || null, notes: notes || null,
+1 -1
View File
@@ -44,7 +44,7 @@ export default async function PurchasesPage() {
{p.items.map((i) => `${i.quantity}× ${i.description}`).join(', ')} {p.items.map((i) => `${i.quantity}× ${i.description}`).join(', ')}
</p> </p>
</div> </div>
{p.invoiceDataUrl && <span className="tag-label hidden text-muted sm:inline">Invoice attached</span>} {p.invoiceUrl && <span className="tag-label hidden text-muted sm:inline">Invoice attached</span>}
<span className="w-20 shrink-0 text-right font-mono text-sm"> <span className="w-20 shrink-0 text-right font-mono text-sm">
<Price cents={p.total} /> <Price cents={p.total} />
</span> </span>
@@ -9,14 +9,14 @@ import ReconciliationClient from './client';
export default async function ReconciliationPage({ export default async function ReconciliationPage({
searchParams, searchParams,
}: { }: {
searchParams: { id?: string }; searchParams: Promise<{ id?: string }>;
}) { }) {
const isAdmin = await isAdminAuthenticated(); const isAdmin = await isAdminAuthenticated();
if (!isAdmin) { if (!isAdmin) {
return <div className="px-6 py-12 text-center">Unauthorized</div>; return <div className="px-6 py-12 text-center">Unauthorized</div>;
} }
const statementId = searchParams.id; const { id: statementId } = await searchParams;
if (!statementId) { if (!statementId) {
// Show list of statements // Show list of statements
+4 -4
View File
@@ -4,8 +4,8 @@ import AccountsNav from '@/components/AccountsNav';
import StockAdjustForm from '@/components/StockAdjustForm'; import StockAdjustForm from '@/components/StockAdjustForm';
export default async function StockPage() { export default async function StockPage() {
let levels = []; let levels: any[] = [];
let productRows = []; let productRows: any[] = [];
try { try {
[levels, productRows] = await Promise.all([ [levels, productRows] = await Promise.all([
@@ -18,7 +18,7 @@ export default async function StockPage() {
console.error('Error fetching stock data:', err); console.error('Error fetching stock data:', err);
} }
const products = productRows.map((p) => ({ const products = productRows.map((p: any) => ({
id: p.id, id: p.id,
name: p.name, name: p.name,
colors: JSON.parse(p.colors) as string[], colors: JSON.parse(p.colors) as string[],
@@ -27,7 +27,7 @@ export default async function StockPage() {
// Group rows under their product for a readable table. // Group rows under their product for a readable table.
const grouped = new Map<string, typeof levels>(); const grouped = new Map<string, typeof levels>();
for (const level of levels.sort((a, b) => { for (const level of levels.sort((a: any, b: any) => {
if (a.product.name !== b.product.name) return a.product.name.localeCompare(b.product.name); if (a.product.name !== b.product.name) return a.product.name.localeCompare(b.product.name);
if (a.color !== b.color) return a.color.localeCompare(b.color); if (a.color !== b.color) return a.color.localeCompare(b.color);
return (a.size || '').localeCompare(b.size || ''); return (a.size || '').localeCompare(b.size || '');
+4 -3
View File
@@ -3,12 +3,13 @@ import { prisma } from '@/lib/prisma';
import { updateCategory } from '../../actions'; import { updateCategory } from '../../actions';
import AdminNav from '@/components/AdminNav'; import AdminNav from '@/components/AdminNav';
export default async function EditCategoryPage({ params }: { params: { id: string } }) { export default async function EditCategoryPage({ params }: { params: Promise<{ id: string }> }) {
const category = await prisma.category.findUnique({ where: { id: params.id } }); const { id } = await params;
const category = await prisma.category.findUnique({ where: { id } });
if (!category) notFound(); if (!category) notFound();
const parentCategories = await prisma.category.findMany({ const parentCategories = await prisma.category.findMany({
where: { parentId: null, id: { not: params.id } }, where: { parentId: null, id: { not: id } },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}); });
+3 -2
View File
@@ -3,7 +3,8 @@ import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav'; import AdminNav from '@/components/AdminNav';
import { deleteCategory, updateCategoryVisibility } from './actions'; import { deleteCategory, updateCategoryVisibility } from './actions';
export default async function CategoriesPage({ searchParams }: { searchParams: { error?: string } }) { export default async function CategoriesPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const { error } = await searchParams;
const categories = await prisma.category.findMany({ orderBy: { createdAt: 'asc' } }); const categories = await prisma.category.findMany({ orderBy: { createdAt: 'asc' } });
const counts = await prisma.product.groupBy({ by: ['category'], _count: true }); const counts = await prisma.product.groupBy({ by: ['category'], _count: true });
const countFor = (slug: string) => counts.find((c) => c.category === slug)?._count ?? 0; const countFor = (slug: string) => counts.find((c) => c.category === slug)?._count ?? 0;
@@ -21,7 +22,7 @@ export default async function CategoriesPage({ searchParams }: { searchParams: {
</Link> </Link>
</div> </div>
{searchParams.error === 'in_use' && ( {error === 'in_use' && (
<div className="mt-6 border border-splash-orange bg-splash-orange/5 p-4 text-sm"> <div className="mt-6 border border-splash-orange bg-splash-orange/5 p-4 text-sm">
Can&apos;t delete that category there are still products using it. Move or delete those Can&apos;t delete that category there are still products using it. Move or delete those
products first. products first.
+3 -2
View File
@@ -10,11 +10,12 @@ const ERROR_MESSAGES: Record<string, string> = {
invalid_current: 'Current password is incorrect.', invalid_current: 'Current password is incorrect.',
}; };
export default async function ChangeAdminPasswordPage({ searchParams }: { searchParams: { error?: string } }) { export default async function ChangeAdminPasswordPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const { error: errorParam } = await searchParams;
const isAuthenticated = await isAdminAuthenticated(); const isAuthenticated = await isAdminAuthenticated();
if (!isAuthenticated) redirect('/admin/login'); if (!isAuthenticated) redirect('/admin/login');
const error = searchParams.error ? ERROR_MESSAGES[searchParams.error] : null; const error = errorParam ? ERROR_MESSAGES[errorParam] : null;
return ( return (
<div className="mx-auto max-w-2xl px-6 py-12"> <div className="mx-auto max-w-2xl px-6 py-12">
+3 -2
View File
@@ -3,7 +3,8 @@ import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav'; import AdminNav from '@/components/AdminNav';
import { deleteCollection } from './actions'; import { deleteCollection } from './actions';
export default async function CollectionsPage({ searchParams }: { searchParams: { error?: string } }) { export default async function CollectionsPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const { error } = await searchParams;
const collections = await prisma.collection.findMany({ orderBy: { createdAt: 'asc' } }); const collections = await prisma.collection.findMany({ orderBy: { createdAt: 'asc' } });
const occasions = collections.filter((c) => c.group === 'OCCASION'); const occasions = collections.filter((c) => c.group === 'OCCASION');
const recipients = collections.filter((c) => c.group === 'RECIPIENT'); const recipients = collections.filter((c) => c.group === 'RECIPIENT');
@@ -58,7 +59,7 @@ export default async function CollectionsPage({ searchParams }: { searchParams:
products from each product&apos;s edit page. products from each product&apos;s edit page.
</p> </p>
{searchParams.error === 'in_use' && ( {error === 'in_use' && (
<div className="mt-6 border border-splash-orange bg-splash-orange/5 p-4 text-sm"> <div className="mt-6 border border-splash-orange bg-splash-orange/5 p-4 text-sm">
Can&apos;t delete that there are still products tagged with it. Untag those products Can&apos;t delete that there are still products tagged with it. Untag those products
first. first.
+5 -4
View File
@@ -6,9 +6,10 @@ import ShareButtons from '@/components/ShareButtons';
import DownloadPhotoButton from '@/components/DownloadPhotoButton'; import DownloadPhotoButton from '@/components/DownloadPhotoButton';
import { deleteRequest, markRequestDone, markRequestNew } from '../actions'; import { deleteRequest, markRequestDone, markRequestNew } from '../actions';
export default async function CustomRequestDetailPage({ params }: { params: { id: string } }) { export default async function CustomRequestDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const request = await prisma.customRequest.findUnique({ const request = await prisma.customRequest.findUnique({
where: { id: params.id }, where: { id },
include: { product: true }, include: { product: true },
}); });
if (!request) notFound(); if (!request) notFound();
@@ -33,7 +34,7 @@ export default async function CustomRequestDetailPage({ params }: { params: { id
</div> </div>
<div className="mt-6 border border-line bg-surface p-6"> <div className="mt-6 border border-line bg-surface p-6">
<img src={request.imageDataUrl} alt="Customer's photo" className="mx-auto max-h-96 object-contain" /> <img src={request.imageUrl ?? ''} alt="Customer's photo" className="mx-auto max-h-96 object-contain" />
</div> </div>
{request.note && ( {request.note && (
@@ -54,7 +55,7 @@ export default async function CustomRequestDetailPage({ params }: { params: { id
</div> </div>
<div className="mt-8 flex flex-wrap gap-3"> <div className="mt-8 flex flex-wrap gap-3">
<DownloadPhotoButton photoUrl={request.imageDataUrl} customerName={request.customerName} /> <DownloadPhotoButton photoUrl={request.imageUrl ?? ''} customerName={request.customerName} />
<Link <Link
href={`/admin/custom-requests/${request.id}/start-design`} href={`/admin/custom-requests/${request.id}/start-design`}
@@ -3,9 +3,10 @@ import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav'; import AdminNav from '@/components/AdminNav';
import { startDesignWithProduct } from './actions'; import { startDesignWithProduct } from './actions';
export default async function StartDesignPage({ params }: { params: { id: string } }) { export default async function StartDesignPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const request = await prisma.customRequest.findUnique({ const request = await prisma.customRequest.findUnique({
where: { id: params.id }, where: { id },
include: { product: true }, include: { product: true },
}); });
if (!request) notFound(); if (!request) notFound();
@@ -22,7 +23,7 @@ export default async function StartDesignPage({ params }: { params: { id: string
<p className="mt-2 text-sm text-muted">Select which product to design this photo on</p> <p className="mt-2 text-sm text-muted">Select which product to design this photo on</p>
<div className="mt-6 border border-line bg-surface p-6"> <div className="mt-6 border border-line bg-surface p-6">
<img src={request.imageDataUrl} alt="Customer's photo" className="mx-auto max-h-64 object-contain" /> <img src={request.imageUrl ?? ''} alt="Customer's photo" className="mx-auto max-h-64 object-contain" />
</div> </div>
<div className="mt-6 border border-line bg-surface p-6"> <div className="mt-6 border border-line bg-surface p-6">
+1 -1
View File
@@ -24,7 +24,7 @@ export default async function CustomRequestsPage() {
{requests.map((r) => ( {requests.map((r) => (
<div key={r.id} className="flex items-center gap-4 py-4 hover:bg-surface"> <div key={r.id} className="flex items-center gap-4 py-4 hover:bg-surface">
<Link href={`/admin/custom-requests/${r.id}`} className="flex flex-1 items-center gap-4"> <Link href={`/admin/custom-requests/${r.id}`} className="flex flex-1 items-center gap-4">
<img src={r.imageDataUrl} alt="" className="h-14 w-14 border border-line object-cover" /> <img src={r.imageUrl ?? ''} alt="" className="h-14 w-14 border border-line object-cover" />
<div className="flex-1"> <div className="flex-1">
<p className="font-medium">{r.customerName}</p> <p className="font-medium">{r.customerName}</p>
<p className="truncate text-sm text-muted">{r.customerEmail}</p> <p className="truncate text-sm text-muted">{r.customerEmail}</p>
+8 -7
View File
@@ -12,16 +12,17 @@ const SOURCE_LABELS: Record<string, string> = {
export default async function CustomersPage({ export default async function CustomersPage({
searchParams, searchParams,
}: { }: {
searchParams: { sent?: string; failed?: string }; searchParams: Promise<{ sent?: string; failed?: string }>;
}) { }) {
let entries = []; const params = await searchParams;
let entries: any[] = [];
try { try {
entries = await prisma.mailingListEntry.findMany(); entries = await prisma.mailingListEntry.findMany();
entries.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); entries.sort((a: any, b: any) => b.createdAt.getTime() - a.createdAt.getTime());
} catch (err) { } catch (err) {
console.error('Error fetching mailing list:', err); console.error('Error fetching mailing list:', err);
} }
const subscribedCount = entries.filter((e) => e.subscribed).length; const subscribedCount = entries.filter((e: any) => e.subscribed).length;
return ( return (
<div className="mx-auto max-w-3xl px-6 py-12"> <div className="mx-auto max-w-3xl px-6 py-12">
@@ -32,10 +33,10 @@ export default async function CustomersPage({
guest, in one list. Untick Subscribed to leave someone out of future mailings. guest, in one list. Untick Subscribed to leave someone out of future mailings.
</p> </p>
{searchParams.sent !== undefined && ( {params.sent !== undefined && (
<div className="mt-6 border border-splash-blue bg-splash-blue/5 p-4 text-sm"> <div className="mt-6 border border-splash-blue bg-splash-blue/5 p-4 text-sm">
Sent to {searchParams.sent} recipient{searchParams.sent === '1' ? '' : 's'}. Sent to {params.sent} recipient{params.sent === '1' ? '' : 's'}.
{Number(searchParams.failed) > 0 && ` ${searchParams.failed} failed to send.`} {Number(params.failed) > 0 && ` ${params.failed} failed to send.`}
</div> </div>
)} )}
+2 -21
View File
@@ -4,33 +4,14 @@ import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import type { ProductDTO } from '@/lib/types';
interface DesignApproval { interface DesignApproval {
id: string; id: string;
customerEmail: string; customerEmail: string;
customerName: string | null; customerName: string | null;
productId: string; productId: string;
product: { product: ProductDTO;
id: string;
name: string;
basePrice: number;
personalisedPrice: number;
onSale: boolean;
effectivePrice: number;
colors: string[];
sizes: string[];
showOnPersonalised: boolean;
imageUrl: string | null;
imageUrlBack: string | null;
photoRecolorable: boolean;
colorPhotos: Record<string, string>;
colorPhotosBack: Record<string, string>;
printArea: string;
printAreaBack: string | null;
mockup: string;
referenceWidthCm: number | null;
referenceHeightCm: number | null;
};
designJson: string; designJson: string;
color: string; color: string;
size: string | null; size: string | null;
+1
View File
@@ -57,6 +57,7 @@ export default function DesignDetailPage({ params }: { params: { id: string } })
if (res.ok) { if (res.ok) {
const updated = await res.json(); const updated = await res.json();
setDesign(updated); setDesign(updated);
alert(`✓ Design approved! Customer will receive an email with a link to order.${expectedDeliveryDate ? ` Estimated delivery: ${new Date(expectedDeliveryDate).toLocaleDateString()}` : ''}`);
} }
} finally { } finally {
setApproving(false); setApproving(false);
+7 -7
View File
@@ -4,7 +4,7 @@ import AdminNav from '@/components/AdminNav';
import DeleteDesignButton from '@/components/DeleteDesignButton'; import DeleteDesignButton from '@/components/DeleteDesignButton';
export default async function DesignsPage() { export default async function DesignsPage() {
let designs = []; let designs: any[] = [];
try { try {
designs = await prisma.designApproval.findMany({ designs = await prisma.designApproval.findMany({
select: { select: {
@@ -20,15 +20,15 @@ export default async function DesignsPage() {
}); });
// Fetch products separately to avoid relation issues // Fetch products separately to avoid relation issues
const productIds = [...new Set(designs.map(d => d.productId))]; const productIds = [...new Set(designs.map((d: any) => d.productId))];
const products = await prisma.product.findMany({ const products = await prisma.product.findMany({
where: { id: { in: productIds } }, where: { id: { in: productIds } },
select: { id: true, name: true }, select: { id: true, name: true },
}); });
const productMap = Object.fromEntries(products.map(p => [p.id, p])); const productMap = Object.fromEntries(products.map((p: any) => [p.id, p]));
// Add product to each design // Add product to each design
designs = designs.map(d => ({ designs = designs.map((d: any) => ({
...d, ...d,
product: productMap[d.productId], product: productMap[d.productId],
messages: [], messages: [],
@@ -37,9 +37,9 @@ export default async function DesignsPage() {
console.error('Error fetching designs:', err); console.error('Error fetching designs:', err);
} }
const pending = designs.filter((d) => d.status === 'PENDING'); const pending = designs.filter((d: any) => d.status === 'PENDING');
const approved = designs.filter((d) => d.status === 'APPROVED'); const approved = designs.filter((d: any) => d.status === 'APPROVED');
const rejected = designs.filter((d) => d.status === 'REJECTED'); const rejected = designs.filter((d: any) => d.status === 'REJECTED');
const DesignList = ({ items, status }: { items: typeof designs; status: string }) => ( const DesignList = ({ items, status }: { items: typeof designs; status: string }) => (
<div className="mt-8"> <div className="mt-8">
+9 -8
View File
@@ -6,17 +6,18 @@ import Link from 'next/link';
export default async function FinancialAuditPage({ export default async function FinancialAuditPage({
searchParams, searchParams,
}: { }: {
searchParams: { type?: string; action?: string; from?: string; to?: string }; searchParams: Promise<{ type?: string; action?: string; from?: string; to?: string }>;
}) { }) {
const isAdmin = await isAdminAuthenticated(); const isAdmin = await isAdminAuthenticated();
if (!isAdmin) { if (!isAdmin) {
return <div className="px-6 py-12 text-center">Unauthorized</div>; return <div className="px-6 py-12 text-center">Unauthorized</div>;
} }
const entityType = searchParams.type as any; const sp = await searchParams;
const action = searchParams.action as any; const entityType = sp.type as any;
const fromDate = searchParams.from ? new Date(searchParams.from) : null; const action = sp.action as any;
const toDate = searchParams.to ? new Date(`${searchParams.to}T23:59:59`) : null; const fromDate = sp.from ? new Date(sp.from) : null;
const toDate = sp.to ? new Date(`${sp.to}T23:59:59`) : null;
const logs = await prisma.financialAuditLog.findMany({ const logs = await prisma.financialAuditLog.findMany({
where: { where: {
@@ -110,7 +111,7 @@ export default async function FinancialAuditPage({
<input <input
type="date" type="date"
name="from" name="from"
defaultValue={searchParams.from ?? ''} defaultValue={sp.from ?? ''}
className="border border-line bg-paper px-3 py-2 text-sm" className="border border-line bg-paper px-3 py-2 text-sm"
placeholder="From" placeholder="From"
/> />
@@ -118,7 +119,7 @@ export default async function FinancialAuditPage({
<input <input
type="date" type="date"
name="to" name="to"
defaultValue={searchParams.to ?? ''} defaultValue={sp.to ?? ''}
className="border border-line bg-paper px-3 py-2 text-sm" className="border border-line bg-paper px-3 py-2 text-sm"
placeholder="To" placeholder="To"
/> />
@@ -130,7 +131,7 @@ export default async function FinancialAuditPage({
Filter Filter
</button> </button>
{(entityType || action || searchParams.from || searchParams.to) && ( {(entityType || action || sp.from || sp.to) && (
<Link href="/admin/financial-audit" className="text-sm text-muted hover:text-ink"> <Link href="/admin/financial-audit" className="text-sm text-muted hover:text-ink">
Clear filters Clear filters
</Link> </Link>
+4 -3
View File
@@ -4,9 +4,10 @@ import AdminNav from '@/components/AdminNav';
import { updateGalleryPhoto } from '../../actions'; import { updateGalleryPhoto } from '../../actions';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
export default async function EditGalleryPhotoPage({ params }: { params: { id: string } }) { export default async function EditGalleryPhotoPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const photo = await prisma.galleryPhoto.findUnique({ const photo = await prisma.galleryPhoto.findUnique({
where: { id: params.id }, where: { id },
include: { category: true }, include: { category: true },
}); });
@@ -25,7 +26,7 @@ export default async function EditGalleryPhotoPage({ params }: { params: { id: s
</p> </p>
<div className="mt-8 flex gap-6"> <div className="mt-8 flex gap-6">
<img src={photo.imageDataUrl} alt="" className="h-32 w-32 border border-line object-cover flex-shrink-0" /> <img src={photo.imageUrl ?? ''} alt="" className="h-32 w-32 border border-line object-cover flex-shrink-0" />
<div className="flex-1 space-y-6"> <div className="flex-1 space-y-6">
<form action={updateGalleryPhoto}> <form action={updateGalleryPhoto}>
<input type="hidden" name="id" value={photo.id} /> <input type="hidden" name="id" value={photo.id} />
+5 -10
View File
@@ -2,6 +2,7 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { saveUploadedFile } from '@/lib/storage';
function slugify(input: string) { function slugify(input: string) {
return input return input
@@ -21,12 +22,6 @@ async function uniqueSlug(base: string) {
return slug; return slug;
} }
async function fileToDataUrl(file: File): Promise<string> {
const bytes = Buffer.from(await file.arrayBuffer());
const mimeType = file.type || 'image/png';
return `data:${mimeType};base64,${bytes.toString('base64')}`;
}
export async function uploadGalleryPhoto(formData: FormData) { export async function uploadGalleryPhoto(formData: FormData) {
const caption = String(formData.get('caption') ?? '').trim(); const caption = String(formData.get('caption') ?? '').trim();
const categoryId = String(formData.get('categoryId') ?? '').trim() || null; const categoryId = String(formData.get('categoryId') ?? '').trim() || null;
@@ -36,11 +31,11 @@ export async function uploadGalleryPhoto(formData: FormData) {
throw new Error('A photo is required.'); throw new Error('A photo is required.');
} }
const imageDataUrl = await fileToDataUrl(file); const imageUrl = await saveUploadedFile(file, 'gallery');
await prisma.galleryPhoto.create({ await prisma.galleryPhoto.create({
data: { data: {
imageDataUrl, imageUrl,
caption: caption || null, caption: caption || null,
categoryId, categoryId,
}, },
@@ -113,10 +108,10 @@ export async function uploadGalleryPhotoBulk(formData: FormData) {
if (file.size === 0) continue; if (file.size === 0) continue;
try { try {
const imageDataUrl = await fileToDataUrl(file); const imageUrl = await saveUploadedFile(file, 'gallery');
const photo = await prisma.galleryPhoto.create({ const photo = await prisma.galleryPhoto.create({
data: { data: {
imageDataUrl, imageUrl,
caption: null, caption: null,
categoryId, categoryId,
}, },
@@ -1,12 +1,13 @@
import AdminNav from '@/components/AdminNav'; import AdminNav from '@/components/AdminNav';
import { createGalleryCategory } from '../../actions'; import { createGalleryCategory } from '../../actions';
export default function NewGalleryCategoryPage({ export default async function NewGalleryCategoryPage({
searchParams, searchParams,
}: { }: {
searchParams: { redirectTo?: string }; searchParams: Promise<{ redirectTo?: string }>;
}) { }) {
const redirectTo = searchParams.redirectTo || '/admin/gallery/categories'; const { redirectTo: redirectToParam } = await searchParams;
const redirectTo = redirectToParam || '/admin/gallery/categories';
return ( return (
<div className="mx-auto max-w-md px-6 py-12"> <div className="mx-auto max-w-md px-6 py-12">
+3 -2
View File
@@ -3,7 +3,8 @@ import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav'; import AdminNav from '@/components/AdminNav';
import { deleteGalleryCategory } from '../actions'; import { deleteGalleryCategory } from '../actions';
export default async function GalleryCategoriesPage({ searchParams }: { searchParams: { error?: string } }) { export default async function GalleryCategoriesPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const { error } = await searchParams;
const categories = await prisma.galleryCategory.findMany({ orderBy: { createdAt: 'asc' } }); const categories = await prisma.galleryCategory.findMany({ orderBy: { createdAt: 'asc' } });
const counts = await prisma.galleryPhoto.groupBy({ by: ['categoryId'], _count: true }); const counts = await prisma.galleryPhoto.groupBy({ by: ['categoryId'], _count: true });
const countFor = (id: string) => counts.find((c) => c.categoryId === id)?._count ?? 0; const countFor = (id: string) => counts.find((c) => c.categoryId === id)?._count ?? 0;
@@ -24,7 +25,7 @@ export default async function GalleryCategoriesPage({ searchParams }: { searchPa
Used to group photos on the public gallery separate from your product categories. Used to group photos on the public gallery separate from your product categories.
</p> </p>
{searchParams.error === 'in_use' && ( {error === 'in_use' && (
<div className="mt-6 border border-splash-orange bg-splash-orange/5 p-4 text-sm"> <div className="mt-6 border border-splash-orange bg-splash-orange/5 p-4 text-sm">
Can&apos;t delete that category there are still photos using it. Move or delete those Can&apos;t delete that category there are still photos using it. Move or delete those
photos first. photos first.
+1 -1
View File
@@ -41,7 +41,7 @@ export default async function AdminGalleryPage() {
<div className="mt-8 divide-y divide-line border-t border-line"> <div className="mt-8 divide-y divide-line border-t border-line">
{photos.map((p) => ( {photos.map((p) => (
<div key={p.id} className="flex items-center gap-4 py-4"> <div key={p.id} className="flex items-center gap-4 py-4">
<img src={p.imageDataUrl} alt="" className="h-16 w-16 border border-line object-cover" /> <img src={p.imageUrl ?? ''} alt="" className="h-16 w-16 border border-line object-cover" />
<div className="flex-1"> <div className="flex-1">
<p className="font-medium">{p.caption || <span className="text-muted">No caption</span>}</p> <p className="font-medium">{p.caption || <span className="text-muted">No caption</span>}</p>
<p className="tag-label mt-0.5"> <p className="tag-label mt-0.5">
+4 -3
View File
@@ -8,9 +8,10 @@ function formatPrice(cents: number) {
return `£${(cents / 100).toFixed(2)}`; return `£${(cents / 100).toFixed(2)}`;
} }
export default async function AdminThreadPage({ params }: { params: { id: string } }) { export default async function AdminThreadPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const proof = await prisma.designProof.findUnique({ const proof = await prisma.designProof.findUnique({
where: { id: params.id }, where: { id },
include: { product: true }, include: { product: true },
}); });
if (!proof) notFound(); if (!proof) notFound();
@@ -20,7 +21,7 @@ export default async function AdminThreadPage({ params }: { params: { id: string
<AdminNav /> <AdminNav />
<div className="flex items-center gap-4 border border-line bg-surface p-4"> <div className="flex items-center gap-4 border border-line bg-surface p-4">
<img src={proof.imageDataUrl} alt="" className="h-16 w-16 border border-line object-cover" /> <img src={proof.imageUrl ?? ''} alt="" className="h-16 w-16 border border-line object-cover" />
<div className="flex-1"> <div className="flex-1">
<p className="font-medium">{proof.customerName || proof.customerEmail}</p> <p className="font-medium">{proof.customerName || proof.customerEmail}</p>
<p className="text-sm text-muted"> <p className="text-sm text-muted">
+1 -1
View File
@@ -32,7 +32,7 @@ export default async function InboxPage() {
href={`/admin/inbox/${p.id}`} href={`/admin/inbox/${p.id}`}
className="flex items-center gap-4 py-4 hover:bg-surface" className="flex items-center gap-4 py-4 hover:bg-surface"
> >
<img src={p.imageDataUrl} alt="" className="h-14 w-14 border border-line object-cover" /> <img src={p.imageUrl ?? ''} alt="" className="h-14 w-14 border border-line object-cover" />
<div className="flex-1"> <div className="flex-1">
<p className="font-medium">{p.customerName || p.customerEmail}</p> <p className="font-medium">{p.customerName || p.customerEmail}</p>
<p className="truncate text-sm text-muted"> <p className="truncate text-sm text-muted">
+3 -2
View File
@@ -17,8 +17,9 @@ export async function loginAdmin(formData: FormData) {
const password = String(formData.get('password') ?? ''); const password = String(formData.get('password') ?? '');
const next = safeNext(formData.get('next')); const next = safeNext(formData.get('next'));
const ip = getClientIp(headers()); const headersList = await headers();
const userAgent = headers().get('user-agent') ?? undefined; const ip = getClientIp(headersList);
const userAgent = headersList.get('user-agent') ?? undefined;
const { allowed } = await checkRateLimit(`login:admin:${ip}`, 5, 15 * 60 * 1000); const { allowed } = await checkRateLimit(`login:admin:${ip}`, 5, 15 * 60 * 1000);
if (!allowed) { if (!allowed) {
+3 -3
View File
@@ -5,9 +5,9 @@ const ERROR_MESSAGES: Record<string, string> = {
rate_limited: 'Too many attempts — please wait a few minutes and try again.', rate_limited: 'Too many attempts — please wait a few minutes and try again.',
}; };
export default function AdminLoginPage({ searchParams }: { searchParams: { error?: string; next?: string } }) { export default async function AdminLoginPage({ searchParams }: { searchParams: Promise<{ error?: string; next?: string }> }) {
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null; const { error: errorKey, next } = await searchParams;
const next = searchParams.next ?? '/admin/dashboard'; const error = errorKey ? (ERROR_MESSAGES[errorKey] ?? 'Something went wrong.') : null;
return ( return (
<div className="mx-auto max-w-md px-6 py-16"> <div className="mx-auto max-w-md px-6 py-16">
+5 -3
View File
@@ -8,9 +8,10 @@ function formatPrice(cents: number) {
return `£${(cents / 100).toFixed(2)}`; return `£${(cents / 100).toFixed(2)}`;
} }
export default async function OrderDetailPage({ params }: { params: { id: string } }) { export default async function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const order = await prisma.order.findUnique({ const order = await prisma.order.findUnique({
where: { id: params.id }, where: { id },
include: { items: true }, include: { items: true },
}); });
if (!order) notFound(); if (!order) notFound();
@@ -237,8 +238,9 @@ export default async function OrderDetailPage({ params }: { params: { id: string
{order.items.map((item) => { {order.items.map((item) => {
let hasFrontDesign = false; let hasFrontDesign = false;
let hasBackDesign = false; let hasBackDesign = false;
let design: { front: unknown[]; back: unknown[] } = { front: [], back: [] };
try { try {
const design = JSON.parse(item.designJson) as { front: unknown[]; back: unknown[] }; design = JSON.parse(item.designJson) as { front: unknown[]; back: unknown[] };
hasFrontDesign = (design.front?.length ?? 0) > 0; hasFrontDesign = (design.front?.length ?? 0) > 0;
hasBackDesign = (design.back?.length ?? 0) > 0; hasBackDesign = (design.back?.length ?? 0) > 0;
} catch { } catch {
+11 -10
View File
@@ -26,18 +26,19 @@ const SUCCESS_MESSAGES: Record<string, string> = {
password_changed: 'Password updated successfully.', password_changed: 'Password updated successfully.',
}; };
export default async function OrdersPage({ searchParams }: { searchParams: { archived?: string; personalised?: string; success?: string; page?: string } }) { export default async function OrdersPage({ searchParams }: { searchParams: Promise<{ archived?: string; personalised?: string; success?: string; page?: string }> }) {
await autoArchiveOldOrders(); await autoArchiveOldOrders();
const success = searchParams.success ? SUCCESS_MESSAGES[searchParams.success] : null; const sp = await searchParams;
const success = sp.success ? SUCCESS_MESSAGES[sp.success] : null;
const showArchived = searchParams.archived === '1'; const showArchived = sp.archived === '1';
const showOnlyPersonalised = searchParams.personalised === '1'; const showOnlyPersonalised = sp.personalised === '1';
const page = Math.max(1, parseInt(searchParams.page || '1')); const page = Math.max(1, parseInt(sp.page || '1'));
const pageSize = 50; const pageSize = 50;
const skip = (page - 1) * pageSize; const skip = (page - 1) * pageSize;
let orders = []; let orders: any[] = [];
let total = 0; let total = 0;
try { try {
// Fetch orders and total count in parallel // Fetch orders and total count in parallel
@@ -80,8 +81,8 @@ export default async function OrdersPage({ searchParams }: { searchParams: { arc
// Filter to personalised orders if requested // Filter to personalised orders if requested
if (showOnlyPersonalised) { if (showOnlyPersonalised) {
orders = orders.filter((order) => orders = orders.filter((order: any) =>
order.items.some((item) => { order.items.some((item: any) => {
const design = JSON.parse(item.designJson) as { front: unknown[]; back: unknown[] }; const design = JSON.parse(item.designJson) as { front: unknown[]; back: unknown[] };
return design.front.length > 0 || design.back.length > 0; return design.front.length > 0 || design.back.length > 0;
}) })
@@ -148,8 +149,8 @@ export default async function OrdersPage({ searchParams }: { searchParams: { arc
</p> </p>
) : ( ) : (
<div className="mt-8 divide-y divide-line border-t border-line"> <div className="mt-8 divide-y divide-line border-t border-line">
{orders.map((order) => { {orders.map((order: any) => {
const hasPersonalised = order.items.some((item) => { const hasPersonalised = order.items.some((item: any) => {
try { try {
const design = JSON.parse(item.designJson) as { front: unknown[]; back: unknown[] }; const design = JSON.parse(item.designJson) as { front: unknown[]; back: unknown[] };
return (design.front?.length ?? 0) > 0 || (design.back?.length ?? 0) > 0; return (design.front?.length ?? 0) > 0 || (design.back?.length ?? 0) > 0;
+11 -10
View File
@@ -9,25 +9,22 @@ import SizePicker from '@/components/SizePicker';
import PhotoPrintAreaField, { type Box } from '@/components/PhotoPrintAreaField'; import PhotoPrintAreaField, { type Box } from '@/components/PhotoPrintAreaField';
import CategoryPicker from '@/components/CategoryPicker'; import CategoryPicker from '@/components/CategoryPicker';
export default async function EditProductPage({ params }: { params: { id: string } }) { export default async function EditProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const activePromotions = await fetchActivePromotions(); const activePromotions = await fetchActivePromotions();
const row = await prisma.product.findUnique({ where: { id: params.id }, include: { collections: true } }); const row = await prisma.product.findUnique({ where: { id }, include: { collections: true } });
if (!row) notFound(); if (!row) notFound();
const product = toProductDTO(row, activePromotions); const product = toProductDTO(row, activePromotions);
const categories = await prisma.category.findMany({ const categories = await prisma.category.findMany({
where: { parentId: null }, where: { parentId: null },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
select: { include: {
id: true,
name: true,
slug: true,
children: { children: {
select: { id: true, name: true, slug: true },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}, },
}, },
}); }) as any;
const collections = await prisma.collection.findMany({ orderBy: { name: 'asc' } }); const collections = await prisma.collection.findMany({ orderBy: { name: 'asc' } });
const occasions = collections.filter((c) => c.group === 'OCCASION'); const occasions = collections.filter((c) => c.group === 'OCCASION');
const recipients = collections.filter((c) => c.group === 'RECIPIENT'); const recipients = collections.filter((c) => c.group === 'RECIPIENT');
@@ -49,6 +46,10 @@ export default async function EditProductPage({ params }: { params: { id: string
hPct: printAreaBack.hPct * 100, hPct: printAreaBack.hPct * 100,
} : undefined; } : undefined;
// Parse color photos for preview if no base image
const colorPhotos = row.colorPhotos ? JSON.parse(row.colorPhotos) : undefined;
const colorPhotosBack = row.colorPhotosBack ? JSON.parse(row.colorPhotosBack) : undefined;
return ( return (
<div className="mx-auto max-w-2xl px-6 py-12"> <div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav /> <AdminNav />
@@ -279,12 +280,12 @@ export default async function EditProductPage({ params }: { params: { id: string
<div> <div>
<label className="tag-label mb-2 block">Front photo</label> <label className="tag-label mb-2 block">Front photo</label>
<PhotoPrintAreaField existingImageUrl={product.imageUrl} existingPrintArea={existingPrintArea} /> <PhotoPrintAreaField existingImageUrl={product.imageUrl ?? undefined} existingPrintArea={existingPrintArea} colorPhotosPreview={colorPhotos} />
</div> </div>
<div> <div>
<label className="tag-label mb-2 block">Back photo</label> <label className="tag-label mb-2 block">Back photo</label>
<PhotoPrintAreaField variant="back" existingImageUrl={product.imageUrlBack} existingPrintArea={existingPrintAreaBack} /> <PhotoPrintAreaField variant="back" existingImageUrl={product.imageUrlBack ?? undefined} existingPrintArea={existingPrintAreaBack} colorPhotosPreview={colorPhotosBack} />
</div> </div>
<div> <div>
+10 -15
View File
@@ -3,6 +3,7 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { DEFAULT_PRINT_AREAS } from '@/lib/mockupDefaults'; import { DEFAULT_PRINT_AREAS } from '@/lib/mockupDefaults';
import { saveUploadedFile } from '@/lib/storage';
function slugify(input: string) { function slugify(input: string) {
return input return input
@@ -22,15 +23,9 @@ async function uniqueSlug(base: string) {
return slug; return slug;
} }
async function fileToDataUrl(file: File): Promise<string> {
const bytes = Buffer.from(await file.arrayBuffer());
const mimeType = file.type || 'image/png';
return `data:${mimeType};base64,${bytes.toString('base64')}`;
}
// Reads the per-color photo file inputs the ColorPicker renders (named // Reads the per-color photo file inputs the ColorPicker renders (named
// "colorPhoto__<hexWithoutHash>" for front, "colorPhotoBack__<hexWithoutHash>" for // "colorPhoto__<hexWithoutHash>" for front, "colorPhotoBack__<hexWithoutHash>" for
// back) and builds { hex: dataUrl } for whichever colors actually got a file attached. // back) and builds { hex: url } for whichever colors actually got a file attached.
async function extractColorPhotos( async function extractColorPhotos(
formData: FormData, formData: FormData,
colors: string[], colors: string[],
@@ -41,7 +36,7 @@ async function extractColorPhotos(
for (const hex of colors) { for (const hex of colors) {
const file = formData.get(`${prefix}${hex.replace('#', '')}`) as File | null; const file = formData.get(`${prefix}${hex.replace('#', '')}`) as File | null;
if (file && file.size > 0) { if (file && file.size > 0) {
result[hex] = await fileToDataUrl(file); result[hex] = await saveUploadedFile(file, 'products');
} }
} }
return result; return result;
@@ -103,8 +98,8 @@ export async function createProduct(formData: FormData) {
const weightKgInput = String(formData.get('weightKg') ?? '').trim(); const weightKgInput = String(formData.get('weightKg') ?? '').trim();
const weightGrams = weightKgInput ? Math.round(Number(weightKgInput) * 1000) : null; const weightGrams = weightKgInput ? Math.round(Number(weightKgInput) * 1000) : null;
const imageUrl = imageFile && imageFile.size > 0 ? await fileToDataUrl(imageFile) : null; const imageUrl = imageFile && imageFile.size > 0 ? await saveUploadedFile(imageFile, 'products') : null;
const imageUrlBack = imageBackFile && imageBackFile.size > 0 ? await fileToDataUrl(imageBackFile) : null; const imageUrlBack = imageBackFile && imageBackFile.size > 0 ? await saveUploadedFile(imageBackFile, 'products') : null;
const colorPhotos = await extractColorPhotos(formData, colors, 'front'); const colorPhotos = await extractColorPhotos(formData, colors, 'front');
const colorPhotosBack = await extractColorPhotos(formData, colors, 'back'); const colorPhotosBack = await extractColorPhotos(formData, colors, 'back');
const saleFields = parseSaleFields(formData); const saleFields = parseSaleFields(formData);
@@ -276,10 +271,10 @@ export async function updateProduct(formData: FormData) {
referenceWidthCm: number | null; referenceWidthCm: number | null;
referenceHeightCm: number | null; referenceHeightCm: number | null;
weightGrams: number | null; weightGrams: number | null;
imageUrl?: string; imageUrl?: string | null;
imageUrlBack?: string; imageUrlBack?: string | null;
printArea?: string; printArea?: string;
printAreaBack?: string; printAreaBack?: string | null;
} = { } = {
name, name,
category, category,
@@ -310,7 +305,7 @@ export async function updateProduct(formData: FormData) {
data.imageUrl = null; data.imageUrl = null;
data.printArea = JSON.stringify(DEFAULT_PRINT_AREAS[mockup] ?? DEFAULT_PRINT_AREAS.tshirt); data.printArea = JSON.stringify(DEFAULT_PRINT_AREAS[mockup] ?? DEFAULT_PRINT_AREAS.tshirt);
} else if (imageFile && imageFile.size > 0) { } else if (imageFile && imageFile.size > 0) {
data.imageUrl = await fileToDataUrl(imageFile); data.imageUrl = await saveUploadedFile(imageFile, 'products');
data.printArea = JSON.stringify( data.printArea = JSON.stringify(
hasCustomPrintArea hasCustomPrintArea
? { ? {
@@ -341,7 +336,7 @@ export async function updateProduct(formData: FormData) {
data.imageUrlBack = null; data.imageUrlBack = null;
data.printAreaBack = null; data.printAreaBack = null;
} else if (imageBackFile && imageBackFile.size > 0) { } else if (imageBackFile && imageBackFile.size > 0) {
data.imageUrlBack = await fileToDataUrl(imageBackFile); data.imageUrlBack = await saveUploadedFile(imageBackFile, 'products');
if (hasCustomPrintAreaBack) { if (hasCustomPrintAreaBack) {
data.printAreaBack = JSON.stringify({ data.printAreaBack = JSON.stringify({
xPct: Number(formData.get('printXBack') ?? 0.3), xPct: Number(formData.get('printXBack') ?? 0.3),
+2 -6
View File
@@ -10,16 +10,12 @@ export default async function NewProductPage() {
const categories = await prisma.category.findMany({ const categories = await prisma.category.findMany({
where: { parentId: null }, where: { parentId: null },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
select: { include: {
id: true,
name: true,
slug: true,
children: { children: {
select: { id: true, name: true, slug: true },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}, },
}, },
}); }) as any;
const collections = await prisma.collection.findMany({ orderBy: { name: 'asc' } }); const collections = await prisma.collection.findMany({ orderBy: { name: 'asc' } });
const occasions = collections.filter((c) => c.group === 'OCCASION'); const occasions = collections.filter((c) => c.group === 'OCCASION');
const recipients = collections.filter((c) => c.group === 'RECIPIENT'); const recipients = collections.filter((c) => c.group === 'RECIPIENT');
+5 -4
View File
@@ -9,13 +9,14 @@ import { deleteProduct } from './actions';
export default async function AdminProductsPage({ export default async function AdminProductsPage({
searchParams, searchParams,
}: { }: {
searchParams: { error?: string }; searchParams: Promise<{ error?: string }>;
}) { }) {
const { error } = await searchParams;
const activePromotions = await fetchActivePromotions(); const activePromotions = await fetchActivePromotions();
let products = []; let products: any[] = [];
try { try {
const rows = await prisma.product.findMany(); const rows = await prisma.product.findMany();
products = rows.map((p) => toProductDTO(p, activePromotions)).sort((a, b) => products = rows.map((p: any) => toProductDTO(p, activePromotions)).sort((a: any, b: any) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
); );
} catch (err) { } catch (err) {
@@ -35,7 +36,7 @@ export default async function AdminProductsPage({
</Link> </Link>
</div> </div>
{searchParams.error === 'in_use' && ( {error === 'in_use' && (
<div className="mt-6 border border-splash-orange bg-splash-orange/5 p-4 text-sm"> <div className="mt-6 border border-splash-orange bg-splash-orange/5 p-4 text-sm">
Can&apos;t delete that product it has existing orders or design proofs tied to it. Can&apos;t delete that product it has existing orders or design proofs tied to it.
</div> </div>
+3 -2
View File
@@ -4,9 +4,10 @@ import AdminNav from '@/components/AdminNav';
import PromotionScopePicker from '@/components/PromotionScopePicker'; import PromotionScopePicker from '@/components/PromotionScopePicker';
import { updatePromotion } from '../../actions'; import { updatePromotion } from '../../actions';
export default async function EditPromotionPage({ params }: { params: { id: string } }) { export default async function EditPromotionPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [promotion, products] = await Promise.all([ const [promotion, products] = await Promise.all([
prisma.promotion.findUnique({ where: { id: params.id }, include: { products: { select: { id: true } } } }), prisma.promotion.findUnique({ where: { id }, include: { products: { select: { id: true } } } }),
prisma.product.findMany({ orderBy: { name: 'asc' } }), prisma.product.findMany({ orderBy: { name: 'asc' } }),
]); ]);
if (!promotion) notFound(); if (!promotion) notFound();
+8 -6
View File
@@ -7,18 +7,20 @@ export default async function ProofSentPage({
params, params,
searchParams, searchParams,
}: { }: {
params: { id: string }; params: Promise<{ id: string }>;
searchParams: { emailed?: string }; searchParams: Promise<{ emailed?: string }>;
}) { }) {
const { id } = await params;
const { emailed: emailedParam } = await searchParams;
const proof = await prisma.designProof.findUnique({ const proof = await prisma.designProof.findUnique({
where: { id: params.id }, where: { id },
include: { product: true }, include: { product: true },
}); });
if (!proof) notFound(); if (!proof) notFound();
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'; const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
const link = `${siteUrl}/proofs/${proof.id}`; const link = `${siteUrl}/proofs/${proof.id}`;
const emailed = searchParams.emailed === '1'; const emailed = emailedParam === '1';
return ( return (
<div className="mx-auto max-w-xl px-6 py-16 text-center"> <div className="mx-auto max-w-xl px-6 py-16 text-center">
@@ -43,9 +45,9 @@ export default async function ProofSentPage({
)} )}
<div className="mt-8 border border-line bg-surface p-6"> <div className="mt-8 border border-line bg-surface p-6">
{proof.imageDataUrl ? ( {proof.imageUrl ? (
<img <img
src={proof.imageDataUrl} src={proof.imageUrl}
alt="Uploaded design" alt="Uploaded design"
className="mx-auto max-h-64 object-contain" className="mx-auto max-h-64 object-contain"
/> />
+3 -4
View File
@@ -3,6 +3,7 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { sendProofEmail } from '@/lib/mail'; import { sendProofEmail } from '@/lib/mail';
import { saveUploadedFile } from '@/lib/storage';
export async function createProof(formData: FormData) { export async function createProof(formData: FormData) {
const productId = String(formData.get('productId') ?? ''); const productId = String(formData.get('productId') ?? '');
@@ -21,9 +22,7 @@ export async function createProof(formData: FormData) {
const product = await prisma.product.findUnique({ where: { id: productId } }); const product = await prisma.product.findUnique({ where: { id: productId } });
if (!product) throw new Error('Product not found.'); if (!product) throw new Error('Product not found.');
const bytes = Buffer.from(await file.arrayBuffer()); const imageUrl = await saveUploadedFile(file, 'proofs');
const mimeType = file.type || 'image/png';
const imageDataUrl = `data:${mimeType};base64,${bytes.toString('base64')}`;
const unitPrice = priceOverride && String(priceOverride).trim() !== '' const unitPrice = priceOverride && String(priceOverride).trim() !== ''
? Math.round(Number(priceOverride) * 100) ? Math.round(Number(priceOverride) * 100)
@@ -37,7 +36,7 @@ export async function createProof(formData: FormData) {
color, color,
quantity, quantity,
unitPrice, unitPrice,
imageDataUrl, imageUrl,
note: note || null, note: note || null,
}, },
}); });
+5 -4
View File
@@ -5,8 +5,9 @@ import AdminNav from '@/components/AdminNav';
export default async function NewProofPage({ export default async function NewProofPage({
searchParams, searchParams,
}: { }: {
searchParams: { productId?: string; customerName?: string; customerEmail?: string }; searchParams: Promise<{ productId?: string; customerName?: string; customerEmail?: string }>;
}) { }) {
const params = await searchParams;
const products = await prisma.product.findMany({ orderBy: { name: 'asc' } }); const products = await prisma.product.findMany({ orderBy: { name: 'asc' } });
return ( return (
@@ -27,7 +28,7 @@ export default async function NewProofPage({
id="productId" id="productId"
name="productId" name="productId"
required required
defaultValue={searchParams.productId} defaultValue={params.productId}
className="w-full border border-line px-3 py-2 text-sm" className="w-full border border-line px-3 py-2 text-sm"
> >
{products.map((p) => ( {products.map((p) => (
@@ -46,7 +47,7 @@ export default async function NewProofPage({
<input <input
id="customerName" id="customerName"
name="customerName" name="customerName"
defaultValue={searchParams.customerName} defaultValue={params.customerName}
className="w-full border border-line px-3 py-2 text-sm" className="w-full border border-line px-3 py-2 text-sm"
placeholder="Jane Doe" placeholder="Jane Doe"
/> />
@@ -60,7 +61,7 @@ export default async function NewProofPage({
name="customerEmail" name="customerEmail"
type="email" type="email"
required required
defaultValue={searchParams.customerEmail} defaultValue={params.customerEmail}
className="w-full border border-line px-3 py-2 text-sm" className="w-full border border-line px-3 py-2 text-sm"
placeholder="jane@example.com" placeholder="jane@example.com"
/> />
+1 -1
View File
@@ -29,7 +29,7 @@ export default async function ProofsListPage() {
<div className="mt-8 divide-y divide-line border-t border-line"> <div className="mt-8 divide-y divide-line border-t border-line">
{proofs.map((p) => ( {proofs.map((p) => (
<div key={p.id} className="flex items-center gap-4 py-4"> <div key={p.id} className="flex items-center gap-4 py-4">
<img src={p.imageDataUrl} alt="" className="h-16 w-16 border border-line object-cover" /> <img src={p.imageUrl ?? ''} alt="" className="h-16 w-16 border border-line object-cover" />
<div className="flex-1"> <div className="flex-1">
<p className="font-medium">{p.customerName || p.customerEmail}</p> <p className="font-medium">{p.customerName || p.customerEmail}</p>
<p className="text-sm text-muted"> <p className="text-sm text-muted">
+3 -2
View File
@@ -20,8 +20,9 @@ async function deleteReview(reviewId: string) {
redirect('/admin/reviews?status=pending'); redirect('/admin/reviews?status=pending');
} }
export default async function AdminReviewsPage({ searchParams }: { searchParams: { status?: string } }) { export default async function AdminReviewsPage({ searchParams }: { searchParams: Promise<{ status?: string }> }) {
const status = searchParams.status === 'approved' ? 'approved' : 'pending'; const { status: statusParam } = await searchParams;
const status = statusParam === 'approved' ? 'approved' : 'pending';
const reviews = await prisma.review.findMany({ const reviews = await prisma.review.findMany({
where: { where: {
+28 -19
View File
@@ -7,6 +7,11 @@ import { fetchActivePromotions } from '@/lib/promotions';
import { checkRateLimit, getClientIp } from '@/lib/rateLimit'; import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
import { getRoyalMailShippingQuotes } from '@/lib/royalmail-shipping'; import { getRoyalMailShippingQuotes } from '@/lib/royalmail-shipping';
import { cleanupPendingOrders } from '@/lib/cleanupOrders'; import { cleanupPendingOrders } from '@/lib/cleanupOrders';
import { saveDataUrl } from '@/lib/storage';
async function saveIfPresent(dataUrl: string | null | undefined, category: 'designs' | 'design-placements') {
return dataUrl ? saveDataUrl(dataUrl, category) : dataUrl;
}
type CheckoutCartItem = { type CheckoutCartItem = {
productId: string; productId: string;
@@ -130,7 +135,7 @@ export async function POST(req: Request) {
// Calculate shipping cost (skip for collection orders) // Calculate shipping cost (skip for collection orders)
let shippingCost = 0; let shippingCost = 0;
let selectedShippingService = shippingService; let selectedShippingService: string | undefined | null = shippingService;
if (isCollection) { if (isCollection) {
console.log('Collection order - no shipping cost'); console.log('Collection order - no shipping cost');
@@ -179,6 +184,26 @@ export async function POST(req: Request) {
const total = subtotal + shippingCost; const total = subtotal + shippingCost;
// Design previews arrive from the client as base64 data URLs (canvas/Konva
// export) — write them to disk now so the Order row only ever stores a short URL.
const orderItems = await Promise.all(
pricedItems.map(async (i) => ({
productId: i.productId,
productName: i.name,
quantity: i.quantity,
color: i.color,
size: i.size,
unitPrice: i.unitPrice,
designJson: JSON.stringify(i.designJson),
designPreviewUrl: await saveDataUrl(i.designPreviewUrl, 'designs'),
designPreviewUrlBack: await saveIfPresent(i.designPreviewUrlBack, 'designs'),
placementPreviewUrl: await saveIfPresent(i.placementPreviewUrl, 'design-placements'),
placementPreviewUrlBack: await saveIfPresent(i.placementPreviewUrlBack, 'design-placements'),
designWidthCm: i.designWidthCm,
designHeightCm: i.designHeightCm,
})),
);
// Save the order up front, before redirecting to Stripe — this is what makes // Save the order up front, before redirecting to Stripe — this is what makes
// the design files (and the order itself) retrievable afterward, whether or // the design files (and the order itself) retrievable afterward, whether or
// not the customer ever makes it back to the success page. // not the customer ever makes it back to the success page.
@@ -193,24 +218,8 @@ export async function POST(req: Request) {
email: guest?.email ?? customer?.email, email: guest?.email ?? customer?.email,
guestName: guest?.name, guestName: guest?.name,
guestPhone: guest?.phone, guestPhone: guest?.phone,
isCollectionPickup, isCollectionPickup: isCollection,
items: { items: { create: orderItems },
create: pricedItems.map((i) => ({
productId: i.productId,
productName: i.name,
quantity: i.quantity,
color: i.color,
size: i.size,
unitPrice: i.unitPrice,
designJson: JSON.stringify(i.designJson),
designPreviewUrl: i.designPreviewUrl,
designPreviewUrlBack: i.designPreviewUrlBack,
placementPreviewUrl: i.placementPreviewUrl,
placementPreviewUrlBack: i.placementPreviewUrlBack,
designWidthCm: i.designWidthCm,
designHeightCm: i.designHeightCm,
})),
},
}, },
}); });
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { saveUploadedFile } from '@/lib/storage';
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
// Images a customer drops into the design tool (photos to print on a product).
// Uploaded immediately on add so the design JSON only ever stores a short URL,
// instead of the base64 data URL it held before — same fix as the other image
// fields, just for images embedded inside a design rather than a DB column.
export async function POST(req: Request) {
const ip = getClientIp(req.headers);
const { allowed } = await checkRateLimit(`design-upload:${ip}`, 60, 10 * 60 * 1000);
if (!allowed) {
return NextResponse.json({ error: 'Too many uploads — please wait a moment and try again.' }, { status: 429 });
}
const formData = await req.formData();
const file = formData.get('image') as File | null;
if (!file || file.size === 0) {
return NextResponse.json({ error: 'No image provided.' }, { status: 400 });
}
try {
const url = await saveUploadedFile(file, 'design-uploads');
return NextResponse.json({ url });
} catch (err) {
const message = err instanceof Error ? err.message : 'Could not process that image.';
return NextResponse.json({ error: message }, { status: 400 });
}
}
+4 -3
View File
@@ -5,14 +5,15 @@ import { toProductDTO } from '@/lib/product';
export async function POST( export async function POST(
req: Request, req: Request,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const { id } = await params;
const body = await req.json(); const body = await req.json();
const { expectedDeliveryDate } = body; const { expectedDeliveryDate } = body;
const design = await prisma.designApproval.findUnique({ const design = await prisma.designApproval.findUnique({
where: { id: params.id }, where: { id },
include: { product: true }, include: { product: true },
}); });
@@ -21,7 +22,7 @@ export async function POST(
} }
const updated = await prisma.designApproval.update({ const updated = await prisma.designApproval.update({
where: { id: params.id }, where: { id },
data: { data: {
status: 'APPROVED', status: 'APPROVED',
expectedDeliveryDate: expectedDeliveryDate ? new Date(expectedDeliveryDate) : null, expectedDeliveryDate: expectedDeliveryDate ? new Date(expectedDeliveryDate) : null,
+6 -4
View File
@@ -4,11 +4,12 @@ import type { DesignApprovalMessage } from '@prisma/client';
export async function GET( export async function GET(
req: Request, req: Request,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const { id } = await params;
const messages = await prisma.designApprovalMessage.findMany({ const messages = await prisma.designApprovalMessage.findMany({
where: { designId: params.id }, where: { designId: id },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
}); });
@@ -24,9 +25,10 @@ export async function GET(
export async function POST( export async function POST(
req: Request, req: Request,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const { id } = await params;
const { sender, body, role, message, messageType } = await req.json(); const { sender, body, role, message, messageType } = await req.json();
// Support both old format (sender/body) and new format (role/message/messageType) // Support both old format (sender/body) and new format (role/message/messageType)
@@ -43,7 +45,7 @@ export async function POST(
const created = await prisma.designApprovalMessage.create({ const created = await prisma.designApprovalMessage.create({
data: { data: {
designId: params.id, designId: id,
sender: finalSender, sender: finalSender,
body: finalBody, body: finalBody,
messageType: finalMessageType, messageType: finalMessageType,
+9 -7
View File
@@ -1,11 +1,13 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { saveDataUrl } from '@/lib/storage';
export async function POST( export async function POST(
req: Request, req: Request,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const { id } = await params;
const { const {
designJson, designJson,
designPreviewUrl, designPreviewUrl,
@@ -17,7 +19,7 @@ export async function POST(
} = await req.json(); } = await req.json();
const design = await prisma.designApproval.findUnique({ const design = await prisma.designApproval.findUnique({
where: { id: params.id }, where: { id: id },
}); });
if (!design) { if (!design) {
@@ -25,13 +27,13 @@ export async function POST(
} }
const updated = await prisma.designApproval.update({ const updated = await prisma.designApproval.update({
where: { id: params.id }, where: { id: id },
data: { data: {
...(designJson && { designJson }), ...(designJson && { designJson }),
...(designPreviewUrl && { designPreviewUrl }), ...(designPreviewUrl && { designPreviewUrl: await saveDataUrl(designPreviewUrl, 'designs') }),
...(designPreviewUrlBack && { designPreviewUrlBack }), ...(designPreviewUrlBack && { designPreviewUrlBack: await saveDataUrl(designPreviewUrlBack, 'designs') }),
...(placementPreviewUrl && { placementPreviewUrl }), ...(placementPreviewUrl && { placementPreviewUrl: await saveDataUrl(placementPreviewUrl, 'design-placements') }),
...(placementPreviewUrlBack && { placementPreviewUrlBack }), ...(placementPreviewUrlBack && { placementPreviewUrlBack: await saveDataUrl(placementPreviewUrlBack, 'design-placements') }),
...(designWidthCm !== undefined && { designWidthCm }), ...(designWidthCm !== undefined && { designWidthCm }),
...(designHeightCm !== undefined && { designHeightCm }), ...(designHeightCm !== undefined && { designHeightCm }),
}, },
+10 -7
View File
@@ -5,11 +5,12 @@ import { toProductDTO } from '@/lib/product';
export async function GET( export async function GET(
req: Request, req: Request,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const { id } = await params;
const design = await prisma.designApproval.findUnique({ const design = await prisma.designApproval.findUnique({
where: { id: params.id }, where: { id },
include: { product: true, messages: { orderBy: { createdAt: 'asc' } } }, include: { product: true, messages: { orderBy: { createdAt: 'asc' } } },
}); });
@@ -32,9 +33,10 @@ export async function GET(
export async function PATCH( export async function PATCH(
req: Request, req: Request,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const { id } = await params;
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) { if (!customer) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
@@ -43,7 +45,7 @@ export async function PATCH(
const { status } = await req.json(); const { status } = await req.json();
const updated = await prisma.designApproval.update({ const updated = await prisma.designApproval.update({
where: { id: params.id }, where: { id },
data: { status }, data: { status },
include: { product: true, messages: { orderBy: { createdAt: 'asc' } } }, include: { product: true, messages: { orderBy: { createdAt: 'asc' } } },
}); });
@@ -63,17 +65,18 @@ export async function PATCH(
export async function DELETE( export async function DELETE(
req: Request, req: Request,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const { id } = await params;
// Delete associated messages first // Delete associated messages first
await prisma.designApprovalMessage.deleteMany({ await prisma.designApprovalMessage.deleteMany({
where: { designId: params.id }, where: { designId: id },
}); });
// Delete the design approval // Delete the design approval
await prisma.designApproval.delete({ await prisma.designApproval.delete({
where: { id: params.id }, where: { id: id },
}); });
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
@@ -4,13 +4,14 @@ import { sendDesignReadyForReview } from '@/lib/mail';
export async function POST( export async function POST(
req: Request, req: Request,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const { id } = await params;
const { designPreviewUrl, designPreviewUrlBack, placementPreviewUrl, placementPreviewUrlBack } = await req.json().catch(() => ({})); const { designPreviewUrl, designPreviewUrlBack, placementPreviewUrl, placementPreviewUrlBack } = await req.json().catch(() => ({}));
const design = await prisma.designApproval.findUnique({ const design = await prisma.designApproval.findUnique({
where: { id: params.id }, where: { id: id },
include: { product: true }, include: { product: true },
}); });
@@ -26,7 +27,7 @@ export async function POST(
if (placementPreviewUrlBack) updateData.placementPreviewUrlBack = placementPreviewUrlBack; if (placementPreviewUrlBack) updateData.placementPreviewUrlBack = placementPreviewUrlBack;
const updated = await prisma.designApproval.update({ const updated = await prisma.designApproval.update({
where: { id: params.id }, where: { id: id },
data: updateData, data: updateData,
include: { product: true, messages: { orderBy: { createdAt: 'asc' } } }, include: { product: true, messages: { orderBy: { createdAt: 'asc' } } },
}); });
@@ -36,7 +37,7 @@ export async function POST(
customerName: design.customerName || 'Customer', customerName: design.customerName || 'Customer',
customerEmail: design.customerEmail, customerEmail: design.customerEmail,
productName: design.product.name, productName: design.product.name,
reviewLink: `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}/designs/${params.id}/review`, reviewLink: `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}/designs/${id}/review`,
}); });
return NextResponse.json(updated); return NextResponse.json(updated);
+9 -4
View File
@@ -2,6 +2,11 @@ import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { sendDesignSubmissionNotification } from '@/lib/mail'; import { sendDesignSubmissionNotification } from '@/lib/mail';
import { getCurrentCustomer } from '@/lib/auth'; import { getCurrentCustomer } from '@/lib/auth';
import { saveDataUrl } from '@/lib/storage';
async function saveIfPresent(dataUrl: string | null | undefined, category: 'designs' | 'design-placements') {
return dataUrl ? saveDataUrl(dataUrl, category) : dataUrl;
}
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
@@ -49,10 +54,10 @@ export async function POST(req: Request) {
customerName: customer.name, customerName: customer.name,
productId, productId,
designJson, designJson,
designPreviewUrl, designPreviewUrl: await saveDataUrl(designPreviewUrl, 'designs'),
designPreviewUrlBack, designPreviewUrlBack: await saveIfPresent(designPreviewUrlBack, 'designs'),
placementPreviewUrl, placementPreviewUrl: await saveIfPresent(placementPreviewUrl, 'design-placements'),
placementPreviewUrlBack, placementPreviewUrlBack: await saveIfPresent(placementPreviewUrlBack, 'design-placements'),
designWidthCm: designWidthCm || null, designWidthCm: designWidthCm || null,
designHeightCm: designHeightCm || null, designHeightCm: designHeightCm || null,
designImageDimensions: designImageDimensions && designImageDimensions.length > 0 ? JSON.stringify(designImageDimensions) : null, designImageDimensions: designImageDimensions && designImageDimensions.length > 0 ? JSON.stringify(designImageDimensions) : null,
+3 -3
View File
@@ -1,9 +1,9 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
export async function GET(req: Request, { params }: { params: { id: string } }) { export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
try { try {
const { id } = params; const { id } = await params;
const order = await prisma.order.findUnique({ const order = await prisma.order.findUnique({
where: { id }, where: { id },
include: { items: true }, include: { items: true },
@@ -292,7 +292,7 @@ export async function GET(req: Request, { params }: { params: { id: string } })
${item.designImageDimensions ? (() => { ${item.designImageDimensions ? (() => {
try { try {
const imageDims = JSON.parse(item.designImageDimensions); const imageDims = JSON.parse(item.designImageDimensions);
return imageDims && imageDims.length > 0 ? `<p><strong>Image Dimensions:</strong><ul style="margin: 5px 0 0 20px;">${imageDims.map((img, idx) => `<li>Image ${idx + 1}: ${img.widthCm} cm × ${img.heightCm} cm</li>`).join('')}</ul></p>` : ''; return imageDims && imageDims.length > 0 ? `<p><strong>Image Dimensions:</strong><ul style="margin: 5px 0 0 20px;">${imageDims.map((img: any, idx: any) => `<li>Image ${idx + 1}: ${img.widthCm} cm × ${img.heightCm} cm</li>`).join('')}</ul></p>` : '';
} catch (e) { } catch (e) {
return ''; return '';
} }
@@ -4,11 +4,12 @@ import { fetchRoyalMailTracking, mapTrackingStatusToOrderStatus } from '@/lib/ro
export async function POST( export async function POST(
req: Request, req: Request,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const { id } = await params;
const order = await prisma.order.findUnique({ const order = await prisma.order.findUnique({
where: { id: params.id }, where: { id: id },
}); });
if (!order) { if (!order) {
@@ -31,13 +32,13 @@ export async function POST(
// Update order with new status // Update order with new status
const updated = await prisma.order.update({ const updated = await prisma.order.update({
where: { id: params.id }, where: { id: id },
data: { data: {
status: newStatus, status: newStatus,
}, },
}); });
console.log(`✓ Order ${params.id} tracking synced: ${trackingData.status}${newStatus}`); console.log(`✓ Order ${id} tracking synced: ${trackingData.status}${newStatus}`);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
+4 -3
View File
@@ -1,14 +1,15 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
export async function POST(_req: Request, { params }: { params: { id: string } }) { export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const proof = await prisma.designProof.findUnique({ where: { id: params.id } }); const { id } = await params;
const proof = await prisma.designProof.findUnique({ where: { id } });
if (!proof) { if (!proof) {
return NextResponse.json({ error: 'Not found' }, { status: 404 }); return NextResponse.json({ error: 'Not found' }, { status: 404 });
} }
await prisma.designProof.update({ await prisma.designProof.update({
where: { id: params.id }, where: { id },
data: { status: 'APPROVED' }, data: { status: 'APPROVED' },
}); });
+8 -6
View File
@@ -2,13 +2,14 @@ import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import type { MessageDTO } from '@/lib/types'; import type { MessageDTO } from '@/lib/types';
export async function GET(_req: Request, { params }: { params: { id: string } }) { export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const rows = await prisma.message.findMany({ const rows = await prisma.message.findMany({
where: { proofId: params.id }, where: { proofId: id },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
}); });
const messages: MessageDTO[] = rows.map((m) => ({ const messages: MessageDTO[] = rows.map((m: any) => ({
id: m.id, id: m.id,
proofId: m.proofId, proofId: m.proofId,
sender: m.sender as MessageDTO['sender'], sender: m.sender as MessageDTO['sender'],
@@ -19,7 +20,8 @@ export async function GET(_req: Request, { params }: { params: { id: string } })
return NextResponse.json(messages); return NextResponse.json(messages);
} }
export async function POST(req: Request, { params }: { params: { id: string } }) { export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { sender, body } = await req.json(); const { sender, body } = await req.json();
if (sender !== 'ADMIN' && sender !== 'CUSTOMER') { if (sender !== 'ADMIN' && sender !== 'CUSTOMER') {
@@ -30,13 +32,13 @@ export async function POST(req: Request, { params }: { params: { id: string } })
return NextResponse.json({ error: 'Message body is required' }, { status: 400 }); return NextResponse.json({ error: 'Message body is required' }, { status: 400 });
} }
const proof = await prisma.designProof.findUnique({ where: { id: params.id } }); const proof = await prisma.designProof.findUnique({ where: { id } });
if (!proof) { if (!proof) {
return NextResponse.json({ error: 'Not found' }, { status: 404 }); return NextResponse.json({ error: 'Not found' }, { status: 404 });
} }
const created = await prisma.message.create({ const created = await prisma.message.create({
data: { proofId: params.id, sender, body: trimmed }, data: { proofId: id, sender, body: trimmed },
}); });
const message: MessageDTO = { const message: MessageDTO = {
+6 -4
View File
@@ -3,11 +3,12 @@ import { prisma } from '@/lib/prisma';
export async function DELETE( export async function DELETE(
req: Request, req: Request,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const { id } = await params;
const review = await prisma.review.delete({ const review = await prisma.review.delete({
where: { id: params.id }, where: { id: id },
}); });
return NextResponse.json(review); return NextResponse.json(review);
@@ -22,14 +23,15 @@ export async function DELETE(
export async function PATCH( export async function PATCH(
req: Request, req: Request,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const { id } = await params;
const body = await req.json(); const body = await req.json();
const { approved } = body; const { approved } = body;
const review = await prisma.review.update({ const review = await prisma.review.update({
where: { id: params.id }, where: { id: id },
data: { approved }, data: { approved },
}); });
+2 -2
View File
@@ -6,9 +6,9 @@ import Price from '@/components/Price';
export default async function CheckoutSuccessPage({ export default async function CheckoutSuccessPage({
searchParams, searchParams,
}: { }: {
searchParams: { session_id?: string }; searchParams: Promise<{ session_id?: string }>;
}) { }) {
const sessionId = searchParams.session_id; const { session_id: sessionId } = await searchParams;
let order = null; let order = null;
if (sessionId) { if (sessionId) {
try { try {
+1 -1
View File
@@ -15,7 +15,7 @@ export async function sendContactMessage(formData: FormData) {
redirect('/contact?error=missing'); redirect('/contact?error=missing');
} }
const ip = getClientIp(headers()); const ip = getClientIp(await headers());
const turnstileToken = String(formData.get('cf-turnstile-response') ?? ''); const turnstileToken = String(formData.get('cf-turnstile-response') ?? '');
const { success } = await verifyTurnstileToken(turnstileToken, ip); const { success } = await verifyTurnstileToken(turnstileToken, ip);
if (!success) { if (!success) {
+4 -3
View File
@@ -10,10 +10,11 @@ const ERROR_MESSAGES: Record<string, string> = {
export default async function ContactPage({ export default async function ContactPage({
searchParams, searchParams,
}: { }: {
searchParams: { sent?: string; error?: string }; searchParams: Promise<{ sent?: string; error?: string }>;
}) { }) {
const params = await searchParams;
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null; const error = params.error ? (ERROR_MESSAGES[params.error] ?? 'Something went wrong.') : null;
return ( return (
<div className="mx-auto max-w-2xl px-6 py-12"> <div className="mx-auto max-w-2xl px-6 py-12">
@@ -24,7 +25,7 @@ export default async function ContactPage({
back to you. back to you.
</p> </p>
{searchParams.sent === '1' ? ( {params.sent === '1' ? (
<div className="mt-10 border border-splash-blue bg-splash-blue/5 p-6 text-sm"> <div className="mt-10 border border-splash-blue bg-splash-blue/5 p-6 text-sm">
Thanks we&apos;ve got your message and will be in touch soon. Thanks we&apos;ve got your message and will be in touch soon.
</div> </div>
+4 -5
View File
@@ -6,6 +6,7 @@ import { prisma } from '@/lib/prisma';
import { sendCustomRequestNotification } from '@/lib/mail'; import { sendCustomRequestNotification } from '@/lib/mail';
import { verifyTurnstileToken } from '@/lib/turnstile'; import { verifyTurnstileToken } from '@/lib/turnstile';
import { getClientIp } from '@/lib/rateLimit'; import { getClientIp } from '@/lib/rateLimit';
import { saveUploadedFile } from '@/lib/storage';
export async function createCustomRequest(formData: FormData) { export async function createCustomRequest(formData: FormData) {
const customerId = String(formData.get('customerId') ?? '').trim() || null; const customerId = String(formData.get('customerId') ?? '').trim() || null;
@@ -20,7 +21,7 @@ export async function createCustomRequest(formData: FormData) {
throw new Error('Missing required fields: product, name, email, and a photo are all required.'); throw new Error('Missing required fields: product, name, email, and a photo are all required.');
} }
const ip = getClientIp(headers()); const ip = getClientIp(await headers());
const turnstileToken = String(formData.get('cf-turnstile-response') ?? ''); const turnstileToken = String(formData.get('cf-turnstile-response') ?? '');
const { success } = await verifyTurnstileToken(turnstileToken, ip); const { success } = await verifyTurnstileToken(turnstileToken, ip);
if (!success) { if (!success) {
@@ -30,9 +31,7 @@ export async function createCustomRequest(formData: FormData) {
const product = await prisma.product.findUnique({ where: { id: productId } }); const product = await prisma.product.findUnique({ where: { id: productId } });
if (!product) throw new Error('Product not found.'); if (!product) throw new Error('Product not found.');
const bytes = Buffer.from(await file.arrayBuffer()); const imageUrl = await saveUploadedFile(file, 'custom-requests');
const mimeType = file.type || 'image/png';
const imageDataUrl = `data:${mimeType};base64,${bytes.toString('base64')}`;
const request = await prisma.customRequest.create({ const request = await prisma.customRequest.create({
data: { data: {
@@ -41,7 +40,7 @@ export async function createCustomRequest(formData: FormData) {
customerName, customerName,
customerEmail, customerEmail,
customerPhone: customerPhone || null, customerPhone: customerPhone || null,
imageDataUrl, imageUrl,
note: note || null, note: note || null,
}, },
}); });
+4 -3
View File
@@ -5,9 +5,10 @@ import { prisma } from '@/lib/prisma';
export default async function CustomRequestSubmittedPage({ export default async function CustomRequestSubmittedPage({
searchParams, searchParams,
}: { }: {
searchParams: { id?: string }; searchParams: Promise<{ id?: string }>;
}) { }) {
const id = searchParams.id ?? ''; const params = await searchParams;
const id = params.id ?? '';
const request = id const request = id
? await prisma.customRequest.findUnique({ where: { id }, include: { product: true } }) ? await prisma.customRequest.findUnique({ where: { id }, include: { product: true } })
: null; : null;
@@ -23,7 +24,7 @@ export default async function CustomRequestSubmittedPage({
<div className="mt-8 border border-line bg-surface p-6"> <div className="mt-8 border border-line bg-surface p-6">
<img <img
src={request.imageDataUrl} src={request.imageUrl ?? ''}
alt="Your submitted photo" alt="Your submitted photo"
className="mx-auto max-h-64 object-contain" className="mx-auto max-h-64 object-contain"
/> />
+49 -3
View File
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useCart } from '@/lib/cartStore'; import { useCart } from '@/lib/cartStore';
interface DesignApproval { interface DesignApproval {
@@ -18,18 +19,23 @@ interface DesignApproval {
}; };
designJson: string; designJson: string;
designPreviewUrl: string; designPreviewUrl: string;
designPreviewUrlBack: string | null;
placementPreviewUrl: string | null; placementPreviewUrl: string | null;
placementPreviewUrlBack: string | null;
color: string; color: string;
size: string | null; size: string | null;
status: string; status: string;
expectedDeliveryDate: string | null;
} }
export default function DesignReviewPage({ params }: { params: { id: string } }) { export default function DesignReviewPage({ params }: { params: { id: string } }) {
const router = useRouter();
const [design, setDesign] = useState<DesignApproval | null>(null); const [design, setDesign] = useState<DesignApproval | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [approving, setApproving] = useState(false); const [approving, setApproving] = useState(false);
const [changingMessage, setChangingMessage] = useState(''); const [changingMessage, setChangingMessage] = useState('');
const [sendingChanges, setSendingChanges] = useState(false); const [sendingChanges, setSendingChanges] = useState(false);
const [showCheckoutOptions, setShowCheckoutOptions] = useState(false);
const addItem = useCart((s) => s.addItem); const addItem = useCart((s) => s.addItem);
useEffect(() => { useEffect(() => {
@@ -43,7 +49,11 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
if (!design) return; if (!design) return;
setApproving(true); setApproving(true);
try { try {
const res = await fetch(`/api/designs/${params.id}/approve`, { method: 'POST' }); const res = await fetch(`/api/designs/${params.id}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (res.ok) { if (res.ok) {
const updated = await res.json(); const updated = await res.json();
setDesign(updated); setDesign(updated);
@@ -59,7 +69,7 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
size: design.size, size: design.size,
quantity: 1, quantity: 1,
unitPrice: design.product.personalisedPrice ?? design.product.effectivePrice, unitPrice: design.product.personalisedPrice ?? design.product.effectivePrice,
designJson: design.designJson, designJson: JSON.parse(design.designJson),
designPreviewUrl: design.designPreviewUrl, designPreviewUrl: design.designPreviewUrl,
designPreviewUrlBack: design.designPreviewUrlBack, designPreviewUrlBack: design.designPreviewUrlBack,
placementPreviewUrl: design.placementPreviewUrl, placementPreviewUrl: design.placementPreviewUrl,
@@ -68,8 +78,13 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
designHeightCm: null, designHeightCm: null,
}); });
alert('Design approved! Added to your bag.'); setShowCheckoutOptions(true);
} else {
alert('Failed to approve design. Please try again.');
} }
} catch (error) {
console.error('Error approving design:', error);
alert('An error occurred. Please try again.');
} finally { } finally {
setApproving(false); setApproving(false);
} }
@@ -167,6 +182,11 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
<p className="border-t border-line pt-3"> <p className="border-t border-line pt-3">
<strong>Price:</strong> £{((design.product.personalisedPrice ?? design.product.effectivePrice) / 100).toFixed(2)} <strong>Price:</strong> £{((design.product.personalisedPrice ?? design.product.effectivePrice) / 100).toFixed(2)}
</p> </p>
{design.expectedDeliveryDate && (
<p className="text-xs text-muted pt-2">
<strong>Est. delivery:</strong> {new Date(design.expectedDeliveryDate).toLocaleDateString(undefined, { dateStyle: 'medium' })}
</p>
)}
</div> </div>
<button <button
@@ -197,6 +217,32 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
</div> </div>
</div> </div>
</div> </div>
{/* Checkout Options Modal */}
{showCheckoutOptions && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="bg-paper border border-line rounded-lg p-8 max-w-md mx-4 shadow-lg">
<h2 className="font-display text-2xl mb-2"> Design approved!</h2>
<p className="text-sm text-muted mb-6">
Your design has been added to your bag. What would you like to do next?
</p>
<div className="space-y-3">
<button
onClick={() => router.push('/checkout')}
className="w-full bg-clay px-4 py-3 text-sm font-medium text-paper hover:bg-clay-dark rounded"
>
Go to Checkout
</button>
<button
onClick={() => setShowCheckoutOptions(false)}
className="w-full border border-ink px-4 py-3 text-sm font-medium text-ink hover:border-clay hover:bg-clay hover:text-paper rounded"
>
Keep Shopping
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }
+4 -3
View File
@@ -2,10 +2,11 @@ import Link from 'next/link';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import Reveal from '@/components/Reveal'; import Reveal from '@/components/Reveal';
export default async function GalleryPage({ searchParams }: { searchParams: { category?: string } }) { export default async function GalleryPage({ searchParams }: { searchParams: Promise<{ category?: string }> }) {
const { category } = await searchParams;
const categories = await prisma.galleryCategory.findMany({ orderBy: { name: 'asc' } }); const categories = await prisma.galleryCategory.findMany({ orderBy: { name: 'asc' } });
const activeSlug = searchParams.category ?? null; const activeSlug = category ?? null;
const activeCategory = activeSlug ? categories.find((c) => c.slug === activeSlug) ?? null : null; const activeCategory = activeSlug ? categories.find((c) => c.slug === activeSlug) ?? null : null;
const photos = await prisma.galleryPhoto.findMany({ const photos = await prisma.galleryPhoto.findMany({
@@ -56,7 +57,7 @@ export default async function GalleryPage({ searchParams }: { searchParams: { ca
<figure key={p.id} className="group flex w-full flex-col overflow-hidden border border-line bg-surface"> <figure key={p.id} className="group flex w-full flex-col overflow-hidden border border-line bg-surface">
<div className="aspect-square w-full overflow-hidden"> <div className="aspect-square w-full overflow-hidden">
<img <img
src={p.imageDataUrl} src={p.imageUrl ?? ''}
alt={p.caption ?? 'Completed work'} alt={p.caption ?? 'Completed work'}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/> />
+3 -14
View File
@@ -7,6 +7,7 @@ import Footer from '@/components/Footer';
import PromotionBanner from '@/components/PromotionBanner'; import PromotionBanner from '@/components/PromotionBanner';
import MaintenancePage from '@/components/MaintenancePage'; import MaintenancePage from '@/components/MaintenancePage';
import LogoutOnUnload from '@/components/LogoutOnUnload'; import LogoutOnUnload from '@/components/LogoutOnUnload';
import ThemeInitializer from '@/components/ThemeInitializer';
import { CurrencyProvider } from '@/lib/CurrencyContext'; import { CurrencyProvider } from '@/lib/CurrencyContext';
import { getSiteSettings } from '@/lib/settings'; import { getSiteSettings } from '@/lib/settings';
import { isAdminAuthenticated } from '@/lib/adminAuth'; import { isAdminAuthenticated } from '@/lib/adminAuth';
@@ -31,7 +32,7 @@ export const metadata: Metadata = {
export default async function RootLayout({ children }: { children: React.ReactNode }) { export default async function RootLayout({ children }: { children: React.ReactNode }) {
const { maintenanceMode, maintenanceMessage } = await getSiteSettings(); const { maintenanceMode, maintenanceMessage } = await getSiteSettings();
const pathname = headers().get('x-pathname') ?? ''; const pathname = (await headers()).get('x-pathname') ?? '';
// Admins bypass maintenance (they can keep working); /admin/* is always // Admins bypass maintenance (they can keep working); /admin/* is always
// reachable so the owner can log in and toggle it back off. // reachable so the owner can log in and toggle it back off.
// Also allow /checkout/* pages so customers can complete purchases and see results. // Also allow /checkout/* pages so customers can complete purchases and see results.
@@ -50,21 +51,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600&family=Oswald:wght@500&family=Bebas+Neue&family=Pacifico&family=Permanent+Marker&family=Caveat:wght@600&display=swap" href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600&family=Oswald:wght@500&family=Bebas+Neue&family=Pacifico&family=Permanent+Marker&family=Caveat:wght@600&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
{/* Runs before paint so there's no flash of the wrong theme on load. */}
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
var stored = localStorage.getItem('theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var isDark = stored ? stored === 'dark' : prefersDark;
if (isDark) document.documentElement.classList.add('dark');
})();
`,
}}
/>
</head> </head>
<body suppressHydrationWarning className="flex min-h-screen flex-col"> <body suppressHydrationWarning className="flex min-h-screen flex-col">
<ThemeInitializer />
<LogoutOnUnload /> <LogoutOnUnload />
<CurrencyProvider> <CurrencyProvider>
{showMaintenance ? ( {showMaintenance ? (
+10 -10
View File
@@ -1,22 +1,22 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import dynamic from 'next/dynamic';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { toProductDTO } from '@/lib/product'; import { toProductDTO } from '@/lib/product';
import { fetchActivePromotions } from '@/lib/promotions'; import { fetchActivePromotions } from '@/lib/promotions';
import ProductCard from '@/components/ProductCard'; import ProductCard from '@/components/ProductCard';
import DesignCanvasWrapper from '@/components/DesignCanvasWrapper';
const DesignCanvas = dynamic(() => import('@/components/DesignCanvas'), { ssr: false });
export default async function ProductDetailPage({ export default async function ProductDetailPage({
params, params,
searchParams, searchParams,
}: { }: {
params: { slug: string }; params: Promise<{ slug: string }>;
searchParams: { customRequestId?: string }; searchParams: Promise<{ customRequestId?: string }>;
}) { }) {
const { slug } = await params;
const { customRequestId } = await searchParams;
const activePromotions = await fetchActivePromotions(); const activePromotions = await fetchActivePromotions();
const row = await prisma.product.findUnique({ where: { slug: params.slug } }); const row = await prisma.product.findUnique({ where: { slug } });
if (!row || !row.showOnPersonalised) notFound(); if (!row || !row.showOnPersonalised) notFound();
const product = toProductDTO(row, activePromotions, 'personalised'); const product = toProductDTO(row, activePromotions, 'personalised');
@@ -29,18 +29,18 @@ export default async function ProductDetailPage({
// Fetch custom photo from request if customRequestId is provided // Fetch custom photo from request if customRequestId is provided
let customPhoto: string | undefined; let customPhoto: string | undefined;
if (searchParams.customRequestId) { if (customRequestId) {
const customRequest = await prisma.customRequest.findUnique({ const customRequest = await prisma.customRequest.findUnique({
where: { id: searchParams.customRequestId }, where: { id: customRequestId },
}); });
if (customRequest) { if (customRequest) {
customPhoto = customRequest.imageDataUrl; customPhoto = customRequest.imageUrl ?? undefined;
} }
} }
return ( return (
<div className="mx-auto max-w-6xl px-6 py-12"> <div className="mx-auto max-w-6xl px-6 py-12">
<DesignCanvas product={product} customPhoto={customPhoto} /> <DesignCanvasWrapper product={product} customPhoto={customPhoto} />
{related.length > 0 && ( {related.length > 0 && (
<section className="mt-20 border-t border-line pt-12"> <section className="mt-20 border-t border-line pt-12">
+10 -229
View File
@@ -1,264 +1,46 @@
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { toProductDTO } from '@/lib/product'; import { toProductDTO } from '@/lib/product';
import { fetchActivePromotions } from '@/lib/promotions'; import { fetchActivePromotions } from '@/lib/promotions';
import { getPersonalisedPaletteColors } from '@/lib/cache';
import ProductCard from '@/components/ProductCard'; import ProductCard from '@/components/ProductCard';
import type { Prisma } from '@prisma/client';
type SearchParams = { type SearchParams = {
category?: string | string[];
collection?: string | string[];
color?: string | string[];
min?: string;
max?: string;
q?: string;
page?: string; page?: string;
}; };
const PRODUCTS_PER_PAGE = 20; const PRODUCTS_PER_PAGE = 20;
function toArray(v?: string | string[]) { export default async function ProductsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
if (!v) return []; const { page } = await searchParams;
return Array.isArray(v) ? v : [v]; const currentPage = Math.max(1, Number(page) || 1);
}
export default async function ProductsPage({ searchParams }: { searchParams: SearchParams }) {
const categoryRows = await prisma.category.findMany({
where: { showOnPersonalised: true, parentId: null },
orderBy: { name: 'asc' },
select: {
id: true,
name: true,
slug: true,
children: {
where: { showOnPersonalised: true },
select: { id: true, name: true, slug: true },
orderBy: { name: 'asc' },
},
},
});
// Build flat list of all categories (parents and children) for filtering
const allCategories = categoryRows.flatMap((parent) => [
{ value: parent.slug, label: parent.name },
...parent.children.map((child) => ({ value: child.slug, label: `${parent.name} > ${child.name}` })),
]);
const CATEGORIES = allCategories;
const collectionRows = await prisma.collection.findMany({
select: { slug: true, name: true, group: true },
orderBy: { name: 'asc' },
});
const OCCASIONS = collectionRows.filter((c) => c.group === 'OCCASION').map((c) => ({ value: c.slug, label: c.name }));
const RECIPIENTS = collectionRows.filter((c) => c.group === 'RECIPIENT').map((c) => ({ value: c.slug, label: c.name }));
const selectedCategories = toArray(searchParams.category);
const selectedCollections = toArray(searchParams.collection);
const selectedColors = toArray(searchParams.color);
const q = searchParams.q?.trim() ?? '';
const minDollars = searchParams.min ? Number(searchParams.min) : undefined;
const maxDollars = searchParams.max ? Number(searchParams.max) : undefined;
const currentPage = Math.max(1, Number(searchParams.page) || 1);
// Cached palette across every personalised product
const palette = await getPersonalisedPaletteColors();
const where: Prisma.ProductWhereInput = { showOnPersonalised: true };
if (selectedCategories.length) {
where.category = { in: selectedCategories };
}
if (selectedCollections.length) {
where.collections = { some: { slug: { in: selectedCollections } } };
}
if (selectedColors.length) {
where.AND = [{ OR: selectedColors.map((c) => ({ colors: { contains: c } })) }];
}
if (q) {
where.name = { contains: q };
}
if (minDollars !== undefined || maxDollars !== undefined) {
where.basePrice = {
...(minDollars !== undefined ? { gte: Math.round(minDollars * 100) } : {}),
...(maxDollars !== undefined ? { lte: Math.round(maxDollars * 100) } : {}),
};
}
const activePromotions = await fetchActivePromotions(); const activePromotions = await fetchActivePromotions();
const [rows, totalCount] = await Promise.all([ const [rows, totalCount] = await Promise.all([
prisma.product.findMany({ prisma.product.findMany({
where, where: { showOnPersonalised: true },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
take: PRODUCTS_PER_PAGE, take: PRODUCTS_PER_PAGE,
skip: (currentPage - 1) * PRODUCTS_PER_PAGE, skip: (currentPage - 1) * PRODUCTS_PER_PAGE,
}), }),
prisma.product.count({ where }), prisma.product.count({ where: { showOnPersonalised: true } }),
]); ]);
const products = rows.map((p) => toProductDTO(p, activePromotions, 'personalised')); const products = rows.map((p) => toProductDTO(p, activePromotions, 'personalised'));
const totalPages = Math.ceil(totalCount / PRODUCTS_PER_PAGE); const totalPages = Math.ceil(totalCount / PRODUCTS_PER_PAGE);
const hasFilters =
selectedCategories.length > 0 ||
selectedCollections.length > 0 ||
selectedColors.length > 0 ||
q ||
minDollars !== undefined ||
maxDollars !== undefined;
return ( return (
<div className="mx-auto max-w-6xl px-6 py-12"> <div className="mx-auto max-w-6xl px-6 py-12">
<div className="mb-8 flex items-baseline justify-between"> <div className="mb-12 flex items-baseline justify-between">
<h1 className="font-display text-4xl">Shop all</h1> <h1 className="font-display text-4xl">Shop all</h1>
<p className="tag-label"> <p className="tag-label">
{totalCount} template{totalCount === 1 ? '' : 's'} {totalCount} template{totalCount === 1 ? '' : 's'}
</p> </p>
</div> </div>
<form method="GET" className="grid gap-10 md:grid-cols-[220px_1fr]">
{/* Sidebar filters */}
<aside className="space-y-8">
<div>
<label htmlFor="q" className="tag-label mb-2 block">
Search
</label>
<input
id="q"
name="q"
defaultValue={q}
placeholder="Search templates…"
className="w-full border border-line px-3 py-2 text-sm focus:border-ink"
/>
</div>
<div>
<p className="tag-label mb-3">Category</p>
<div className="space-y-2">
{CATEGORIES.map((c) => (
<label key={c.value} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="category"
value={c.value}
defaultChecked={selectedCategories.includes(c.value)}
className="h-4 w-4 accent-splash-pink"
/>
{c.label}
</label>
))}
</div>
</div>
{OCCASIONS.length > 0 && (
<div>
<p className="tag-label mb-3">Occasion</p>
<div className="space-y-2">
{OCCASIONS.map((c) => (
<label key={c.value} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="collection"
value={c.value}
defaultChecked={selectedCollections.includes(c.value)}
className="h-4 w-4 accent-splash-pink"
/>
{c.label}
</label>
))}
</div>
</div>
)}
{RECIPIENTS.length > 0 && (
<div>
<p className="tag-label mb-3">Gifts for</p>
<div className="space-y-2">
{RECIPIENTS.map((c) => (
<label key={c.value} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="collection"
value={c.value}
defaultChecked={selectedCollections.includes(c.value)}
className="h-4 w-4 accent-splash-pink"
/>
{c.label}
</label>
))}
</div>
</div>
)}
<div>
<p className="tag-label mb-3">Color</p>
<div className="flex flex-wrap gap-2">
{palette.map((hex) => {
const checked = selectedColors.includes(hex);
return (
<label key={hex} className="cursor-pointer">
<input
type="checkbox"
name="color"
value={hex}
defaultChecked={checked}
className="peer sr-only"
/>
<span
title={hex}
className={`block h-8 w-8 rounded-full border transition-transform peer-checked:scale-110 peer-checked:border-ink peer-checked:ring-2 peer-checked:ring-ink peer-checked:ring-offset-2 peer-checked:ring-offset-paper ${
checked ? 'border-ink' : 'border-line'
}`}
style={{ backgroundColor: hex }}
/>
</label>
);
})}
</div>
</div>
<div>
<p className="tag-label mb-3">Price</p>
<div className="flex items-center gap-2">
<input
type="number"
name="min"
min={0}
placeholder="Min"
defaultValue={searchParams.min ?? ''}
className="w-full border border-line px-2 py-2 text-sm focus:border-ink"
/>
<span className="text-muted"></span>
<input
type="number"
name="max"
min={0}
placeholder="Max"
defaultValue={searchParams.max ?? ''}
className="w-full border border-line px-2 py-2 text-sm focus:border-ink"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<button
type="submit"
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Apply filters
</button>
{hasFilters && (
<a href="/made-to-order" className="text-center text-sm text-muted hover:text-ink">
Clear filters
</a>
)}
</div>
</aside>
{/* Grid */}
<div> <div>
{products.length === 0 ? ( {products.length === 0 ? (
<div className="border border-line bg-surface p-12 text-center"> <div className="border border-line bg-surface p-12 text-center">
<p className="font-display text-xl">No templates match those filters</p> <p className="font-display text-xl">Nothing here yet</p>
<p className="mt-2 text-sm text-muted">Try widening your price range or clearing a filter.</p> <p className="mt-2 text-sm text-muted">Templates will show up here once created.</p>
</div> </div>
) : ( ) : (
<div> <div>
@@ -272,7 +54,7 @@ export default async function ProductsPage({ searchParams }: { searchParams: Sea
<div className="mt-12 flex items-center justify-center gap-4 border-t border-line pt-8"> <div className="mt-12 flex items-center justify-center gap-4 border-t border-line pt-8">
{currentPage > 1 && ( {currentPage > 1 && (
<a <a
href={`/made-to-order?page=${currentPage - 1}${selectedCategories.length ? `&category=${selectedCategories.join('&category=')}` : ''}${selectedCollections.length ? `&collection=${selectedCollections.join('&collection=')}` : ''}${selectedColors.length ? `&color=${selectedColors.join('&color=')}` : ''}${q ? `&q=${encodeURIComponent(q)}` : ''}${minDollars !== undefined ? `&min=${minDollars}` : ''}${maxDollars !== undefined ? `&max=${maxDollars}` : ''}`} href={`/made-to-order?page=${currentPage - 1}`}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper" className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
> >
Previous Previous
@@ -283,7 +65,7 @@ export default async function ProductsPage({ searchParams }: { searchParams: Sea
</p> </p>
{currentPage < totalPages && ( {currentPage < totalPages && (
<a <a
href={`/made-to-order?page=${currentPage + 1}${selectedCategories.length ? `&category=${selectedCategories.join('&category=')}` : ''}${selectedCollections.length ? `&collection=${selectedCollections.join('&collection=')}` : ''}${selectedColors.length ? `&color=${selectedColors.join('&color=')}` : ''}${q ? `&q=${encodeURIComponent(q)}` : ''}${minDollars !== undefined ? `&min=${minDollars}` : ''}${maxDollars !== undefined ? `&max=${maxDollars}` : ''}`} href={`/made-to-order?page=${currentPage + 1}`}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper" className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
> >
Next Next
@@ -294,7 +76,6 @@ export default async function ProductsPage({ searchParams }: { searchParams: Sea
</div> </div>
)} )}
</div> </div>
</form>
</div> </div>
); );
} }
+2 -1
View File
@@ -22,6 +22,7 @@ export default async function HomePage() {
const galleryPhotos = await prisma.galleryPhoto.findMany({ const galleryPhotos = await prisma.galleryPhoto.findMany({
take: 6, take: 6,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
select: { id: true, imageUrl: true, caption: true },
}); });
const products = await prisma.product.findMany({ const products = await prisma.product.findMany({
@@ -244,7 +245,7 @@ export default async function HomePage() {
className="group block aspect-square overflow-hidden border border-line bg-surface" className="group block aspect-square overflow-hidden border border-line bg-surface"
> >
<img <img
src={p.imageDataUrl} src={p.imageUrl ?? ''}
alt={p.caption ?? 'Completed work'} alt={p.caption ?? 'Completed work'}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/> />
+3 -2
View File
@@ -5,10 +5,11 @@ import { fetchActivePromotions } from '@/lib/promotions';
import StandardProductView from '@/components/StandardProductView'; import StandardProductView from '@/components/StandardProductView';
import ProductCard from '@/components/ProductCard'; import ProductCard from '@/components/ProductCard';
export default async function StandardProductPage({ params }: { params: { slug: string } }) { export default async function StandardProductPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const activePromotions = await fetchActivePromotions(); const activePromotions = await fetchActivePromotions();
const row = await prisma.product.findUnique({ where: { slug: params.slug } }); const row = await prisma.product.findUnique({ where: { slug } });
if (!row || !row.showAsReadyMade) notFound(); if (!row || !row.showAsReadyMade) notFound();
const product = toProductDTO(row, activePromotions, 'plain'); const product = toProductDTO(row, activePromotions, 'plain');
+3 -2
View File
@@ -3,9 +3,10 @@ import { prisma } from '@/lib/prisma';
import ReviewForm from '@/components/ReviewForm'; import ReviewForm from '@/components/ReviewForm';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
export default async function ProductReviewsPage({ params }: { params: { slug: string } }) { export default async function ProductReviewsPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const product = await prisma.product.findUnique({ const product = await prisma.product.findUnique({
where: { slug: params.slug }, where: { slug },
include: { include: {
reviews: { reviews: {
where: { approved: true }, where: { approved: true },
+10 -238
View File
@@ -1,120 +1,42 @@
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { toProductDTO } from '@/lib/product'; import { toProductDTO } from '@/lib/product';
import { fetchActivePromotions } from '@/lib/promotions'; import { fetchActivePromotions } from '@/lib/promotions';
import { getPaletteColors } from '@/lib/cache';
import ProductCard from '@/components/ProductCard'; import ProductCard from '@/components/ProductCard';
import type { Prisma } from '@prisma/client';
type SearchParams = { type SearchParams = {
category?: string | string[];
collection?: string | string[];
color?: string | string[];
min?: string;
max?: string;
q?: string;
page?: string; page?: string;
}; };
const PRODUCTS_PER_PAGE = 20; const PRODUCTS_PER_PAGE = 20;
function toArray(v?: string | string[]) { export default async function ReadyMadeProductsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
if (!v) return []; const params = await searchParams;
return Array.isArray(v) ? v : [v]; const currentPage = Math.max(1, Number(params.page) || 1);
}
export default async function ReadyMadeProductsPage({ searchParams }: { searchParams: SearchParams }) {
const categoryRows = await prisma.category.findMany({
where: { showOnProducts: true, parentId: null },
orderBy: { name: 'asc' },
select: {
id: true,
name: true,
slug: true,
children: {
where: { showOnProducts: true },
select: { id: true, name: true, slug: true },
orderBy: { name: 'asc' },
},
},
});
// Build flat list of all categories (parents and children) for filtering
const allCategories = categoryRows.flatMap((parent) => [
{ value: parent.slug, label: parent.name },
...parent.children.map((child) => ({ value: child.slug, label: `${parent.name} > ${child.name}` })),
]);
const CATEGORIES = allCategories;
const collectionRows = await prisma.collection.findMany({
select: { slug: true, name: true, group: true },
orderBy: { name: 'asc' },
});
const OCCASIONS = collectionRows.filter((c) => c.group === 'OCCASION').map((c) => ({ value: c.slug, label: c.name }));
const RECIPIENTS = collectionRows.filter((c) => c.group === 'RECIPIENT').map((c) => ({ value: c.slug, label: c.name }));
const selectedCategories = toArray(searchParams.category);
const selectedCollections = toArray(searchParams.collection);
const selectedColors = toArray(searchParams.color);
const q = searchParams.q?.trim() ?? '';
const minDollars = searchParams.min ? Number(searchParams.min) : undefined;
const maxDollars = searchParams.max ? Number(searchParams.max) : undefined;
const currentPage = Math.max(1, Number(searchParams.page) || 1);
// Cached palette across ready-made products for the swatch filter
const palette = await getPaletteColors();
const where: Prisma.ProductWhereInput = { showAsReadyMade: true };
if (selectedCategories.length) {
where.category = { in: selectedCategories };
}
if (selectedCollections.length) {
where.collections = { some: { slug: { in: selectedCollections } } };
}
if (selectedColors.length) {
where.AND = [{ OR: selectedColors.map((c) => ({ colors: { contains: c } })) }];
}
if (q) {
where.name = { contains: q };
}
if (minDollars !== undefined || maxDollars !== undefined) {
where.basePrice = {
...(minDollars !== undefined ? { gte: Math.round(minDollars * 100) } : {}),
...(maxDollars !== undefined ? { lte: Math.round(maxDollars * 100) } : {}),
};
}
const activePromotions = await fetchActivePromotions(); const activePromotions = await fetchActivePromotions();
const [rows, totalCount] = await Promise.all([ const [rows, totalCount] = await Promise.all([
prisma.product.findMany({ prisma.product.findMany({
where, where: { showAsReadyMade: true },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
take: PRODUCTS_PER_PAGE, take: PRODUCTS_PER_PAGE,
skip: (currentPage - 1) * PRODUCTS_PER_PAGE, skip: (currentPage - 1) * PRODUCTS_PER_PAGE,
}), }),
prisma.product.count({ where }), prisma.product.count({ where: { showAsReadyMade: true } }),
]); ]);
const products = rows.map((p) => toProductDTO(p, activePromotions, 'plain')); const products = rows.map((p) => toProductDTO(p, activePromotions, 'plain'));
const totalPages = Math.ceil(totalCount / PRODUCTS_PER_PAGE); const totalPages = Math.ceil(totalCount / PRODUCTS_PER_PAGE);
const hasFilters =
selectedCategories.length > 0 ||
selectedCollections.length > 0 ||
selectedColors.length > 0 ||
q ||
minDollars !== undefined ||
maxDollars !== undefined;
return ( return (
<div className="mx-auto max-w-6xl px-6 py-12"> <div className="mx-auto max-w-6xl px-6 py-12">
<div className="mb-8 flex items-baseline justify-between"> <div className="mb-12 flex items-baseline justify-between">
<h1 className="font-display text-4xl">Products</h1> <h1 className="font-display text-4xl">Products</h1>
<p className="tag-label"> <p className="tag-label">
{products.length} item{products.length === 1 ? '' : 's'} {totalCount} item{totalCount === 1 ? '' : 's'}
</p> </p>
</div> </div>
{products.length === 0 && !hasFilters ? ( {products.length === 0 ? (
<div className="border border-line bg-surface p-12 text-center"> <div className="border border-line bg-surface p-12 text-center">
<p className="font-display text-xl">Nothing here yet</p> <p className="font-display text-xl">Nothing here yet</p>
<p className="mt-2 text-sm text-muted"> <p className="mt-2 text-sm text-muted">
@@ -122,153 +44,6 @@ export default async function ReadyMadeProductsPage({ searchParams }: { searchPa
&quot;Products catalog&quot;. &quot;Products catalog&quot;.
</p> </p>
</div> </div>
) : (
<form method="GET" className="grid gap-10 md:grid-cols-[220px_1fr]">
{/* Sidebar filters */}
<aside className="space-y-8">
<div>
<label htmlFor="q" className="tag-label mb-2 block">
Search
</label>
<input
id="q"
name="q"
defaultValue={q}
placeholder="Search products…"
className="w-full border border-line px-3 py-2 text-sm focus:border-ink"
/>
</div>
<div>
<p className="tag-label mb-3">Category</p>
<div className="space-y-2">
{CATEGORIES.map((c) => (
<label key={c.value} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="category"
value={c.value}
defaultChecked={selectedCategories.includes(c.value)}
className="h-4 w-4 accent-splash-pink"
/>
{c.label}
</label>
))}
</div>
</div>
{OCCASIONS.length > 0 && (
<div>
<p className="tag-label mb-3">Occasion</p>
<div className="space-y-2">
{OCCASIONS.map((c) => (
<label key={c.value} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="collection"
value={c.value}
defaultChecked={selectedCollections.includes(c.value)}
className="h-4 w-4 accent-splash-pink"
/>
{c.label}
</label>
))}
</div>
</div>
)}
{RECIPIENTS.length > 0 && (
<div>
<p className="tag-label mb-3">Gifts for</p>
<div className="space-y-2">
{RECIPIENTS.map((c) => (
<label key={c.value} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="collection"
value={c.value}
defaultChecked={selectedCollections.includes(c.value)}
className="h-4 w-4 accent-splash-pink"
/>
{c.label}
</label>
))}
</div>
</div>
)}
<div>
<p className="tag-label mb-3">Color</p>
<div className="flex flex-wrap gap-2">
{palette.map((hex) => {
const checked = selectedColors.includes(hex);
return (
<label key={hex} className="cursor-pointer">
<input
type="checkbox"
name="color"
value={hex}
defaultChecked={checked}
className="peer sr-only"
/>
<span
title={hex}
className={`block h-8 w-8 rounded-full border transition-transform peer-checked:scale-110 peer-checked:border-ink peer-checked:ring-2 peer-checked:ring-ink peer-checked:ring-offset-2 peer-checked:ring-offset-paper ${
checked ? 'border-ink' : 'border-line'
}`}
style={{ backgroundColor: hex }}
/>
</label>
);
})}
</div>
</div>
<div>
<p className="tag-label mb-3">Price</p>
<div className="flex items-center gap-2">
<input
type="number"
name="min"
min={0}
placeholder="Min"
defaultValue={searchParams.min ?? ''}
className="w-full border border-line px-2 py-2 text-sm focus:border-ink"
/>
<span className="text-muted"></span>
<input
type="number"
name="max"
min={0}
placeholder="Max"
defaultValue={searchParams.max ?? ''}
className="w-full border border-line px-2 py-2 text-sm focus:border-ink"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<button
type="submit"
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Apply filters
</button>
{hasFilters && (
<a href="/products" className="text-center text-sm text-muted hover:text-ink">
Clear filters
</a>
)}
</div>
</aside>
{/* Grid */}
<div>
{products.length === 0 ? (
<div className="border border-line bg-surface p-12 text-center">
<p className="font-display text-xl">No products match those filters</p>
<p className="mt-2 text-sm text-muted">Try widening your price range or clearing a filter.</p>
</div>
) : ( ) : (
<div> <div>
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
@@ -281,7 +56,7 @@ export default async function ReadyMadeProductsPage({ searchParams }: { searchPa
<div className="mt-12 flex items-center justify-center gap-4 border-t border-line pt-8"> <div className="mt-12 flex items-center justify-center gap-4 border-t border-line pt-8">
{currentPage > 1 && ( {currentPage > 1 && (
<a <a
href={`/products?page=${currentPage - 1}${selectedCategories.length ? `&category=${selectedCategories.join('&category=')}` : ''}${selectedCollections.length ? `&collection=${selectedCollections.join('&collection=')}` : ''}${selectedColors.length ? `&color=${selectedColors.join('&color=')}` : ''}${q ? `&q=${encodeURIComponent(q)}` : ''}${minDollars !== undefined ? `&min=${minDollars}` : ''}${maxDollars !== undefined ? `&max=${maxDollars}` : ''}`} href={`/products?page=${currentPage - 1}`}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper" className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
> >
Previous Previous
@@ -292,7 +67,7 @@ export default async function ReadyMadeProductsPage({ searchParams }: { searchPa
</p> </p>
{currentPage < totalPages && ( {currentPage < totalPages && (
<a <a
href={`/products?page=${currentPage + 1}${selectedCategories.length ? `&category=${selectedCategories.join('&category=')}` : ''}${selectedCollections.length ? `&collection=${selectedCollections.join('&collection=')}` : ''}${selectedColors.length ? `&color=${selectedColors.join('&color=')}` : ''}${q ? `&q=${encodeURIComponent(q)}` : ''}${minDollars !== undefined ? `&min=${minDollars}` : ''}${maxDollars !== undefined ? `&max=${maxDollars}` : ''}`} href={`/products?page=${currentPage + 1}`}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper" className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
> >
Next Next
@@ -303,8 +78,5 @@ export default async function ReadyMadeProductsPage({ searchParams }: { searchPa
</div> </div>
)} )}
</div> </div>
</form>
)}
</div>
); );
} }
+5 -4
View File
@@ -5,9 +5,10 @@ import ApproveSection from '@/components/ApproveSection';
import Chat from '@/components/Chat'; import Chat from '@/components/Chat';
import Price from '@/components/Price'; import Price from '@/components/Price';
export default async function ProofPage({ params }: { params: { id: string } }) { export default async function ProofPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const row = await prisma.designProof.findUnique({ const row = await prisma.designProof.findUnique({
where: { id: params.id }, where: { id },
include: { product: true }, include: { product: true },
}); });
if (!row) notFound(); if (!row) notFound();
@@ -22,8 +23,8 @@ export default async function ProofPage({ params }: { params: { id: string } })
<div className="mt-10 grid gap-10 md:grid-cols-2"> <div className="mt-10 grid gap-10 md:grid-cols-2">
<div className="border border-line bg-surface p-6"> <div className="border border-line bg-surface p-6">
{proof.imageDataUrl ? ( {proof.imageUrl ? (
<img src={proof.imageDataUrl} alt={`${proof.productName} design`} className="w-full object-contain" /> <img src={proof.imageUrl} alt={`${proof.productName} design`} className="w-full object-contain" />
) : ( ) : (
<p className="text-center text-sm text-muted">No image was uploaded with this design.</p> <p className="text-center text-sm text-muted">No image was uploaded with this design.</p>
)} )}
+1 -1
View File
@@ -2,7 +2,7 @@
import { useRef, useState, useEffect } from 'react'; import { useRef, useState, useEffect } from 'react';
import { Stage, Layer, Text, Image as KonvaImage, Transformer } from 'react-konva'; import { Stage, Layer, Text, Image as KonvaImage, Transformer } from 'react-konva';
import type Konva from 'konva'; import Konva from 'konva';
import type { DesignElement } from '@/lib/types'; import type { DesignElement } from '@/lib/types';
interface AdminDesignEditorProps { interface AdminDesignEditorProps {
+5 -3
View File
@@ -26,11 +26,13 @@ export default function ApproveSection({ proof }: { proof: ProofDTO }) {
size: null, size: null,
quantity: proof.quantity, quantity: proof.quantity,
unitPrice: proof.unitPrice, unitPrice: proof.unitPrice,
designJson: { front: [], back: [] }, designJson: { front: [], back: [] } as any,
designPreviewUrl: proof.imageDataUrl, designPreviewUrl: proof.imageUrl,
designPreviewUrlBack: null, designPreviewUrlBack: null,
placementPreviewUrl: proof.imageDataUrl, placementPreviewUrl: proof.imageUrl,
placementPreviewUrlBack: null, placementPreviewUrlBack: null,
designWidthCm: null,
designHeightCm: null,
}); });
setStatus('APPROVED'); setStatus('APPROVED');
router.push('/cart'); router.push('/cart');
+48 -29
View File
@@ -325,12 +325,13 @@ function PrintSurface({
/> />
{elements.map((el) => {elements.map((el) =>
el.type === 'text' ? ( el.type === 'text' ? (
el.curvedPath ? ( (el as any).curvedPath ? (
// Curved text - TextPath with dragging via offset // Curved text - TextPath with dragging via offset
(() => { (() => {
const elAny = el as any;
const offsetX = el.x - 280; const offsetX = el.x - 280;
const offsetY = el.y - 280; const offsetY = el.y - 280;
const pathData = generateCurvePath(el.curvedPath.type, el.curvedPath.radius, el.curvedPath.reverse, offsetX, offsetY); const pathData = generateCurvePath(elAny.curvedPath.type, elAny.curvedPath.radius, elAny.curvedPath.reverse, offsetX, offsetY);
console.log(`🔄 Rendering curved text ${el.text}: position (${el.x}, ${el.y}), offset (${offsetX}, ${offsetY})`); console.log(`🔄 Rendering curved text ${el.text}: position (${el.x}, ${el.y}), offset (${offsetX}, ${offsetY})`);
return ( return (
<TextPath <TextPath
@@ -522,6 +523,7 @@ export default function DesignCanvas({
const [submissionMessage, setSubmissionMessage] = useState<string | null>(null); const [submissionMessage, setSubmissionMessage] = useState<string | null>(null);
const [imageDimensions, setImageDimensions] = useState<Record<string, { widthCm: number | null; heightCm: number | null }>>({}); const [imageDimensions, setImageDimensions] = useState<Record<string, { widthCm: number | null; heightCm: number | null }>>({});
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({}); const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
const [isUploadingImage, setIsUploadingImage] = useState(false);
const toggleSection = (section: string) => { const toggleSection = (section: string) => {
setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] })); setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] }));
@@ -714,14 +716,22 @@ export default function DesignCanvas({
const handleUploadClick = () => fileInputRef.current?.click(); const handleUploadClick = () => fileInputRef.current?.click();
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
e.target.value = '';
if (!file) return; if (!file) return;
const reader = new FileReader();
reader.onload = () => { setIsUploadingImage(true);
const src = reader.result as string; try {
const img = new window.Image(); const formData = new FormData();
img.onload = () => { formData.append('image', file);
const res = await fetch('/api/design-uploads', { method: 'POST', body: formData });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Upload failed');
const src: string = data.url;
const img = await loadImage(src);
// Clear old approval status when starting a new design // Clear old approval status when starting a new design
if (approvalStatus) { if (approvalStatus) {
setApprovalStatus(null); setApprovalStatus(null);
@@ -747,11 +757,12 @@ export default function DesignCanvas({
}, },
]); ]);
setSelectedId(id); setSelectedId(id);
}; } catch (err) {
img.src = src; console.error('Failed to upload image:', err);
}; alert('Could not upload that image — please try again.');
reader.readAsDataURL(file); } finally {
e.target.value = ''; setIsUploadingImage(false);
}
}; };
const deleteSelected = () => { const deleteSelected = () => {
@@ -773,10 +784,10 @@ export default function DesignCanvas({
salePrice: product.onSale ? product.effectivePrice : null, salePrice: product.onSale ? product.effectivePrice : null,
saleStartsAt: null, saleStartsAt: null,
saleEndsAt: null, saleEndsAt: null,
}, } as any,
[], [],
new Date(), new Date(),
true, 'personalised',
).price ).price
: product.effectivePrice; : product.effectivePrice;
@@ -1251,10 +1262,10 @@ export default function DesignCanvas({
salePrice: product.onSale ? product.effectivePrice : null, salePrice: product.onSale ? product.effectivePrice : null,
saleStartsAt: null, saleStartsAt: null,
saleEndsAt: null, saleEndsAt: null,
}, } as any,
[], [],
new Date(), new Date(),
true, 'personalised',
); );
return ( return (
<> <>
@@ -1493,20 +1504,24 @@ export default function DesignCanvas({
<label className="flex items-center gap-2 text-sm text-muted mb-3"> <label className="flex items-center gap-2 text-sm text-muted mb-3">
<input <input
type="checkbox" type="checkbox"
checked={text.curvedPath ? true : false} checked={(text as any).curvedPath ? true : false}
onChange={(e) => updateElement(text.id, { curvedPath: e.target.checked ? { type: 'circle', radius: 80 } : null })} onChange={(e) => updateElement(text.id, { curvedPath: e.target.checked ? { type: 'circle', radius: 80 } : null } as any)}
className="cursor-pointer" className="cursor-pointer"
/> />
Curved text Curved text
</label> </label>
{text.curvedPath && ( {(text as any).curvedPath && (
<div className="space-y-3"> <div className="space-y-3">
{(() => {
const textAny = text as any;
return (
<>
<label className="flex flex-col gap-1"> <label className="flex flex-col gap-1">
<span className="text-xs text-muted">Curve type</span> <span className="text-xs text-muted">Curve type</span>
<select <select
value={text.curvedPath.type || 'circle'} value={textAny.curvedPath.type || 'circle'}
onChange={(e) => updateElement(text.id, { curvedPath: { ...text.curvedPath, type: e.target.value as any } })} onChange={(e) => updateElement(text.id, { curvedPath: { ...textAny.curvedPath, type: e.target.value as any } } as any)}
className="border border-line bg-paper px-2 py-1 text-xs" className="border border-line bg-paper px-2 py-1 text-xs"
> >
<option value="circle">Circle</option> <option value="circle">Circle</option>
@@ -1523,23 +1538,26 @@ export default function DesignCanvas({
type="range" type="range"
min={20} min={20}
max={200} max={200}
value={text.curvedPath.radius || 80} value={textAny.curvedPath.radius || 80}
onChange={(e) => updateElement(text.id, { curvedPath: { ...text.curvedPath, radius: Number(e.target.value) } })} onChange={(e) => updateElement(text.id, { curvedPath: { ...textAny.curvedPath, radius: Number(e.target.value) } } as any)}
className="flex-1" className="flex-1"
/> />
<span className="w-12 text-right font-mono text-xs">{text.curvedPath.radius || 80}</span> <span className="w-12 text-right font-mono text-xs">{textAny.curvedPath.radius || 80}</span>
</div> </div>
</label> </label>
<label className="flex items-center gap-2 text-xs"> <label className="flex items-center gap-2 text-xs">
<input <input
type="checkbox" type="checkbox"
checked={text.curvedPath.reverse ? true : false} checked={textAny.curvedPath.reverse ? true : false}
onChange={(e) => updateElement(text.id, { curvedPath: { ...text.curvedPath, reverse: e.target.checked } })} onChange={(e) => updateElement(text.id, { curvedPath: { ...textAny.curvedPath, reverse: e.target.checked } } as any)}
className="cursor-pointer" className="cursor-pointer"
/> />
<span className="text-muted">Reverse direction</span> <span className="text-muted">Reverse direction</span>
</label> </label>
</>
);
})()}
</div> </div>
)} )}
</div> </div>
@@ -1558,9 +1576,10 @@ export default function DesignCanvas({
</button> </button>
<button <button
onClick={handleUploadClick} onClick={handleUploadClick}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper" disabled={isUploadingImage}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper disabled:cursor-not-allowed disabled:opacity-50"
> >
+ Upload image {isUploadingImage ? 'Uploading...' : '+ Upload image'}
</button> </button>
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFile} /> <input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFile} />
{selected && ( {selected && (
@@ -0,0 +1,39 @@
'use client';
import React, { ReactNode } from 'react';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
}
class DesignCanvasErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return (
<div className="flex h-96 items-center justify-center rounded-lg border border-line bg-paper">
<div className="text-center">
<p className="mb-2 font-medium text-ink">Design Canvas Loading...</p>
<p className="text-sm text-muted">Please refresh the page if this persists</p>
</div>
</div>
);
}
return this.props.children;
}
}
export default DesignCanvasErrorBoundary;
+13
View File
@@ -0,0 +1,13 @@
'use client';
import FabricDesignCanvasV2 from './FabricDesignCanvasV2';
import type { ProductDTO } from '@/lib/types';
interface DesignCanvasWrapperProps {
product: ProductDTO;
customPhoto?: string;
}
export default function DesignCanvasWrapper({ product, customPhoto }: DesignCanvasWrapperProps) {
return <FabricDesignCanvasV2 product={product} customPhoto={customPhoto} />;
}

Some files were not shown because too many files have changed in this diff Show More