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",
"runtimeExecutable": "npm",
"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/made-to-order)",
"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).
# For Postgres later, change provider in prisma/schema.prisma and set this to your Postgres URL.
DATABASE_URL="file:./dev.db"
# Database — PostgreSQL connection string. prisma/schema.prisma is fixed to the
# postgresql provider, so this must point at a real Postgres instance (local
# 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_SECRET_KEY="sk_live_or_test_..."
+1
View File
@@ -9,3 +9,4 @@ prisma/dev.db-*
.claude/settings.local.json
dist/
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
- **Design Tool** — In-browser tool to customize templates with text and images
- **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
- **Dark Mode** — Full dark mode support with system preference detection
- **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
### 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
- **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)
- **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
@@ -80,7 +81,7 @@ A complete self-hosted e-commerce platform for personalized products (apparel, d
## Configuration
**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)
- `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.
### 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
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
### 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)
2. [Configuration Reference](#configuration-reference)
3. [Database Management](#database-management)
4. [Feature Setup](#feature-setup)
5. [Building for Production](#building-for-production)
6. [Deployment](#deployment)
7. [Troubleshooting](#troubleshooting)
4. [Image & File Storage](#image--file-storage)
5. [Feature Setup](#feature-setup)
6. [Building for Production](#building-for-production)
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).
**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
```bash
@@ -46,7 +49,7 @@ cp .env.example .env
Edit `.env` and configure at minimum:
```env
DATABASE_URL="file:./dev.db"
DATABASE_URL="postgresql://user:password@localhost:5432/craft2prints?schema=public"
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
ADMIN_USER="admin"
ADMIN_PASSWORD="changeme"
@@ -62,7 +65,8 @@ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
### Step 3: Database Setup
```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
# Load starter products
@@ -87,7 +91,7 @@ Open http://localhost:3000 in your browser.
| 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_PASSWORD` | Admin login password | `changeme` |
| `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
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
**Create database and migrations:**
**Sync the schema to the database:**
```bash
npm run db:push
```
**Reset database (⚠️ deletes all data):**
```bash
rm dev.db
npm run db:push
npx prisma db push --force-reset
npm run db:seed
```
@@ -165,6 +170,8 @@ npm run db:seed
1. Edit `prisma/schema.prisma`
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:**
```bash
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:**
```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
### Stripe Payments
@@ -274,7 +317,8 @@ MAIL_FROM="Craft2Prints <noreply@yourdomain.com>"
**Hardware Requirements:**
- Server with Node.js 18+ support
- 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:**
@@ -366,8 +410,9 @@ CMD ["npm", "start"]
```bash
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
@@ -379,6 +424,7 @@ docker run -p 3000:3000 --env-file .env craft2prints
- [ ] Email SMTP tested (send test order confirmation)
- [ ] CAPTCHA working on public forms
- [ ] Database backups scheduled
- [ ] `public/uploads/` backups scheduled (separate from the database — see [Image & File Storage](#image--file-storage))
- [ ] Log monitoring set up
- [ ] Rate limiting verified
- [ ] 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
```
**Database corrupted**
**Database out of sync / corrupted state**
```bash
rm dev.db
npm run db:push
npx prisma db push --force-reset # ⚠️ drops and recreates all tables
npm run db:seed
```
@@ -434,10 +479,8 @@ node -e "const smtp = require('nodemailer'); console.log(JSON.stringify(process.
**Database connection issues**
```bash
# Test connection
psql $DATABASE_URL # If PostgreSQL
# Or
sqlite3 dev.db ".tables" # If SQLite
# Test connection and list tables
psql $DATABASE_URL -c '\dt'
```
---
@@ -451,7 +494,7 @@ sqlite3 dev.db ".tables" # If SQLite
- Check payment processing (Stripe dashboard)
**Weekly:**
- Backup database
- Backup database and `public/uploads/`
- Review admin activity logs
- 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/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// 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} */
const nextConfig = {
reactStrictMode: true,
eslint: { ignoreDuringBuilds: true },
serverExternalPackages: ['@prisma/client', 'sharp'],
experimental: {
serverComponentsExternalPackages: ['@prisma/client'],
// Invoice uploads (phone photos/PDFs) and product photo uploads go through
// server actions — the 1MB default rejects typical phone photos.
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": {
"@prisma/client": "5.16.1",
"bcryptjs": "^3.0.3",
"fabric": "^7.4.0",
"idb-keyval": "6.2.1",
"jose": "^6.2.3",
"konva": "9.3.14",
"next": "14.2.5",
"next": "^16.2.11",
"nodemailer": "6.9.14",
"pdf-parse": "^2.4.5",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-konva": "18.2.10",
"sharp": "^0.35.3",
"stripe": "16.2.0",
"tesseract.js": "^7.0.0",
"uuid": "9.0.1",
+5 -20
View File
@@ -72,21 +72,6 @@ model Product {
manualSaleItems ManualSaleItem[]
stockLevels StockLevel[]
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
@@ -114,7 +99,7 @@ model Purchase {
id String @id @default(cuid())
supplierName String
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?
total Int // pence — sum of line totals, recomputed server-side
notes String?
@@ -147,7 +132,7 @@ model Expense {
description String
category String @default("Stock purchases") // "Stock purchases", "Postage", others as user enters them
amount Int // pence
receiptDataUrl String? // uploaded receipt as data URL
receiptUrl String? // disk path under /uploads/receipts/...
receiptFileName String?
notes String?
purchaseId String? @unique // null = standalone expense; set = auto-created from a Purchase (one-to-one)
@@ -167,7 +152,7 @@ model DesignProof {
color String
quantity Int @default(1)
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
status String @default("PENDING") // PENDING | APPROVED
createdAt DateTime @default(now())
@@ -333,7 +318,7 @@ model CustomRequest {
customerName String
customerEmail String
customerPhone String?
imageDataUrl String // the photo they want us to work with
imageUrl String // disk path under /uploads/custom-requests/...
note String?
status String @default("NEW") // NEW | DONE
createdAt DateTime @default(now())
@@ -430,7 +415,7 @@ model GalleryCategory {
model GalleryPhoto {
id String @id @default(cuid())
imageDataUrl String // base64 data URL
imageUrl String // disk path under /uploads/gallery/...
caption String?
categoryId String? // nullable — a photo can be uncategorized
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.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 { success } = await verifyTurnstileToken(turnstileToken, ip);
if (!success) redirect('/account/register?error=captcha');
@@ -61,8 +61,9 @@ export async function loginCustomer(formData: FormData) {
const password = String(formData.get('password') ?? '');
const next = safeNext(formData.get('next'));
const ip = getClientIp(headers());
const userAgent = headers().get('user-agent') ?? undefined;
const headersList = await headers();
const ip = getClientIp(headersList);
const userAgent = headersList.get('user-agent') ?? undefined;
const { allowed } = await checkRateLimit(`login:customer:${ip}`, 5, 15 * 60 * 1000);
if (!allowed) {
@@ -95,7 +96,7 @@ export async function loginCustomer(formData: FormData) {
}
export async function logoutCustomer() {
clearSession();
await clearSession();
redirect('/');
}
+3 -2
View File
@@ -10,11 +10,12 @@ const ERROR_MESSAGES: Record<string, string> = {
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();
if (!customer) redirect('/account/login');
const error = searchParams.error ? ERROR_MESSAGES[searchParams.error] : null;
const error = errorParam ? ERROR_MESSAGES[errorParam] : null;
return (
<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 { requestPasswordReset } from '../actions';
export default function ForgotPasswordPage({ searchParams }: { searchParams: { sent?: string } }) {
const sent = searchParams.sent === '1';
export default async function ForgotPasswordPage({ searchParams }: { searchParams: Promise<{ sent?: string }> }) {
const params = await searchParams;
const sent = params.sent === '1';
return (
<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.',
};
export default function LoginPage({ searchParams }: { searchParams: { error?: string; next?: string } }) {
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null;
const next = searchParams.next ?? '/account';
export default async function LoginPage({ searchParams }: { searchParams: Promise<{ error?: string; next?: string }> }) {
const params = await searchParams;
const error = params.error ? (ERROR_MESSAGES[params.error] ?? 'Something went wrong.') : null;
const next = params.next ?? '/account';
return (
<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';
}
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();
if (!customer) redirect('/account/login');
const order = await prisma.order.findUnique({
where: { id: params.id },
where: { id },
include: { items: true },
});
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.',
};
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();
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({
where: { customerId: customer.id },
@@ -47,6 +48,12 @@ export default async function AccountPage({ searchParams }: { searchParams: { su
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
const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
@@ -121,6 +128,39 @@ export default async function AccountPage({ searchParams }: { searchParams: { su
</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>
{orders.length === 0 ? (
<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.',
};
export default function RegisterPage({ searchParams }: { searchParams: { error?: string; next?: string } }) {
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null;
const next = searchParams.next ?? '/account';
export default async function RegisterPage({ searchParams }: { searchParams: Promise<{ error?: string; next?: string }> }) {
const params = await searchParams;
const error = params.error ? (ERROR_MESSAGES[params.error] ?? 'Something went wrong.') : null;
const next = params.next ?? '/account';
return (
<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.',
};
export default function ResetPasswordPage({ searchParams }: { searchParams: { token?: string; error?: string } }) {
const token = searchParams.token ?? '';
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null;
export default async function ResetPasswordPage({ searchParams }: { searchParams: Promise<{ token?: string; error?: string }> }) {
const params = await searchParams;
const token = params.token ?? '';
const error = params.error ? (ERROR_MESSAGES[params.error] ?? 'Something went wrong.') : null;
if (!token) {
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.' };
}
const ip = getClientIp(headers());
const ip = getClientIp(await headers());
const { success } = await verifyTurnstileToken(turnstileToken, ip);
if (!success) {
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 AdminNav from '@/components/AdminNav';
import Price from '@/components/Price';
import { isPdfUrl } from '@/lib/storage';
export default async function ExpenseDetailPage({ params }: { params: { id: string } }) {
const expense = await prisma.expense.findUnique({ where: { id: params.id } });
export default async function ExpenseDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const expense = await prisma.expense.findUnique({ where: { id } });
if (!expense) notFound();
const isPdf = expense.receiptDataUrl?.startsWith('data:application/pdf');
const isPdf = isPdfUrl(expense.receiptUrl);
return (
<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>}
</div>
{expense.receiptDataUrl && (
{expense.receiptUrl && (
<div className="mt-8">
<p className="tag-label mb-3">Receipt{expense.receiptFileName ? `${expense.receiptFileName}` : ''}</p>
{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
src={expense.receiptDataUrl}
src={expense.receiptUrl}
alt="Receipt"
className="max-h-[600px] w-full border border-line object-contain"
/>
)}
<a
href={expense.receiptDataUrl}
href={expense.receiptUrl}
download={expense.receiptFileName ?? 'receipt'}
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 { prisma } from '@/lib/prisma';
import { saveUploadedFile } from '@/lib/storage';
export async function createExpense(formData: FormData) {
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));
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;
if (receipt && receipt.size > 0) {
if (receipt.size > 8 * 1024 * 1024) {
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')}`;
receiptUrl = await saveUploadedFile(receipt, 'receipts', { maxBytes: 8 * 1024 * 1024, allowPdf: true });
receiptFileName = receipt.name || 'receipt';
}
@@ -33,7 +29,7 @@ export async function createExpense(formData: FormData) {
description,
category,
amount,
receiptDataUrl,
receiptUrl,
receiptFileName,
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">
<Price cents={e.amount} />
</span>
{e.receiptDataUrl && (
{e.receiptUrl && (
<Link href={`/admin/accounts/expenses/${e.id}`} className="tag-label text-muted hover:text-ink">
View
</Link>
+16 -15
View File
@@ -26,15 +26,16 @@ type LedgerEntry = {
export default async function AccountsPage({
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 from = searchParams.from ? new Date(searchParams.from) : null;
const sp = await searchParams;
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.
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 manualSales = [];
let orders: any[] = [];
let manualSales: any[] = [];
try {
[orders, manualSales] = await Promise.all([
@@ -59,22 +60,22 @@ export default async function AccountsPage({
}
const entries: LedgerEntry[] = [
...orders.map((o) => ({
...orders.map((o: any) => ({
id: o.id,
kind: 'WEB' as const,
date: o.createdAt,
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)',
total: o.total,
href: `/admin/orders`,
})),
...manualSales.map((s) => ({
...manualSales.map((s: any) => ({
id: s.id,
kind: 'MANUAL' as const,
date: s.soldAt,
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,
total: s.total,
href: null,
@@ -88,8 +89,8 @@ export default async function AccountsPage({
const filterHref = (t: string) => {
const params = new URLSearchParams();
if (t !== 'all') params.set('type', t);
if (searchParams.from) params.set('from', searchParams.from);
if (searchParams.to) params.set('to', searchParams.to);
if (sp.from) params.set('from', sp.from);
if (sp.to) params.set('to', sp.to);
const qs = params.toString();
return `/admin/accounts${qs ? `?${qs}` : ''}`;
};
@@ -160,7 +161,7 @@ export default async function AccountsPage({
<input
type="date"
name="from"
defaultValue={searchParams.from ?? ''}
defaultValue={sp.from ?? ''}
className="border border-line bg-paper px-2 py-1.5"
/>
</label>
@@ -169,14 +170,14 @@ export default async function AccountsPage({
<input
type="date"
name="to"
defaultValue={searchParams.to ?? ''}
defaultValue={sp.to ?? ''}
className="border border-line bg-paper px-2 py-1.5"
/>
</label>
<button type="submit" className="border border-line px-3 py-1.5 hover:border-clay hover:text-clay">
Apply
</button>
{(searchParams.from || searchParams.to) && (
{(sp.from || sp.to) && (
<Link href={filterHref(type)} className="tag-label text-muted hover:text-ink">
Clear dates
</Link>
@@ -3,15 +3,17 @@ import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
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({
where: { id: params.id },
where: { id },
include: { items: { include: { product: true } } },
});
if (!purchase) notFound();
const isPdf = purchase.invoiceDataUrl?.startsWith('data:application/pdf');
const isPdf = isPdfUrl(purchase.invoiceUrl);
return (
<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.invoiceDataUrl && (
{purchase.invoiceUrl && (
<div className="mt-8">
<p className="tag-label mb-3">Invoice{purchase.invoiceFileName ? `${purchase.invoiceFileName}` : ''}</p>
{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
src={purchase.invoiceDataUrl}
src={purchase.invoiceUrl}
alt="Invoice"
className="max-h-[600px] w-full border border-line object-contain"
/>
)}
<a
href={purchase.invoiceDataUrl}
href={purchase.invoiceUrl}
download={purchase.invoiceFileName ?? 'invoice'}
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 { applyStockMovement } from '@/lib/stock';
import { logFinancialChange } from '@/lib/financialAudit';
import { saveUploadedFile } from '@/lib/storage';
export type PurchaseItemInput = {
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;
if (invoice && invoice.size > 0) {
if (invoice.size > MAX_INVOICE_BYTES) {
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')}`;
invoiceUrl = await saveUploadedFile(invoice, 'invoices', { maxBytes: MAX_INVOICE_BYTES, allowPdf: true });
invoiceFileName = invoice.name || 'invoice';
}
@@ -62,7 +58,7 @@ export async function createPurchase(formData: FormData) {
data: {
supplierName,
purchasedAt: purchaseDate,
invoiceDataUrl,
invoiceUrl,
invoiceFileName,
total,
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>
</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">
<Price cents={p.total} />
</span>
@@ -9,14 +9,14 @@ import ReconciliationClient from './client';
export default async function ReconciliationPage({
searchParams,
}: {
searchParams: { id?: string };
searchParams: Promise<{ id?: string }>;
}) {
const isAdmin = await isAdminAuthenticated();
if (!isAdmin) {
return <div className="px-6 py-12 text-center">Unauthorized</div>;
}
const statementId = searchParams.id;
const { id: statementId } = await searchParams;
if (!statementId) {
// Show list of statements
+4 -4
View File
@@ -4,8 +4,8 @@ import AccountsNav from '@/components/AccountsNav';
import StockAdjustForm from '@/components/StockAdjustForm';
export default async function StockPage() {
let levels = [];
let productRows = [];
let levels: any[] = [];
let productRows: any[] = [];
try {
[levels, productRows] = await Promise.all([
@@ -18,7 +18,7 @@ export default async function StockPage() {
console.error('Error fetching stock data:', err);
}
const products = productRows.map((p) => ({
const products = productRows.map((p: any) => ({
id: p.id,
name: p.name,
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.
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.color !== b.color) return a.color.localeCompare(b.color);
return (a.size || '').localeCompare(b.size || '');
+4 -3
View File
@@ -3,12 +3,13 @@ import { prisma } from '@/lib/prisma';
import { updateCategory } from '../../actions';
import AdminNav from '@/components/AdminNav';
export default async function EditCategoryPage({ params }: { params: { id: string } }) {
const category = await prisma.category.findUnique({ where: { id: params.id } });
export default async function EditCategoryPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const category = await prisma.category.findUnique({ where: { id } });
if (!category) notFound();
const parentCategories = await prisma.category.findMany({
where: { parentId: null, id: { not: params.id } },
where: { parentId: null, id: { not: id } },
orderBy: { name: 'asc' },
});
+3 -2
View File
@@ -3,7 +3,8 @@ import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
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 counts = await prisma.product.groupBy({ by: ['category'], _count: true });
const countFor = (slug: string) => counts.find((c) => c.category === slug)?._count ?? 0;
@@ -21,7 +22,7 @@ export default async function CategoriesPage({ searchParams }: { searchParams: {
</Link>
</div>
{searchParams.error === 'in_use' && (
{error === 'in_use' && (
<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
products first.
+3 -2
View File
@@ -10,11 +10,12 @@ const ERROR_MESSAGES: Record<string, string> = {
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();
if (!isAuthenticated) redirect('/admin/login');
const error = searchParams.error ? ERROR_MESSAGES[searchParams.error] : null;
const error = errorParam ? ERROR_MESSAGES[errorParam] : null;
return (
<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 { 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 occasions = collections.filter((c) => c.group === 'OCCASION');
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.
</p>
{searchParams.error === 'in_use' && (
{error === 'in_use' && (
<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
first.
+5 -4
View File
@@ -6,9 +6,10 @@ import ShareButtons from '@/components/ShareButtons';
import DownloadPhotoButton from '@/components/DownloadPhotoButton';
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({
where: { id: params.id },
where: { id },
include: { product: true },
});
if (!request) notFound();
@@ -33,7 +34,7 @@ export default async function CustomRequestDetailPage({ params }: { params: { id
</div>
<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>
{request.note && (
@@ -54,7 +55,7 @@ export default async function CustomRequestDetailPage({ params }: { params: { id
</div>
<div className="mt-8 flex flex-wrap gap-3">
<DownloadPhotoButton photoUrl={request.imageDataUrl} customerName={request.customerName} />
<DownloadPhotoButton photoUrl={request.imageUrl ?? ''} customerName={request.customerName} />
<Link
href={`/admin/custom-requests/${request.id}/start-design`}
@@ -3,9 +3,10 @@ import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
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({
where: { id: params.id },
where: { id },
include: { product: true },
});
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>
<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 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) => (
<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">
<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">
<p className="font-medium">{r.customerName}</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({
searchParams,
}: {
searchParams: { sent?: string; failed?: string };
searchParams: Promise<{ sent?: string; failed?: string }>;
}) {
let entries = [];
const params = await searchParams;
let entries: any[] = [];
try {
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) {
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 (
<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.
</p>
{searchParams.sent !== undefined && (
{params.sent !== undefined && (
<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'}.
{Number(searchParams.failed) > 0 && ` ${searchParams.failed} failed to send.`}
Sent to {params.sent} recipient{params.sent === '1' ? '' : 's'}.
{Number(params.failed) > 0 && ` ${params.failed} failed to send.`}
</div>
)}
+2 -21
View File
@@ -4,33 +4,14 @@ import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import dynamic from 'next/dynamic';
import type { ProductDTO } from '@/lib/types';
interface DesignApproval {
id: string;
customerEmail: string;
customerName: string | null;
productId: string;
product: {
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;
};
product: ProductDTO;
designJson: string;
color: string;
size: string | null;
+1
View File
@@ -57,6 +57,7 @@ export default function DesignDetailPage({ params }: { params: { id: string } })
if (res.ok) {
const updated = await res.json();
setDesign(updated);
alert(`✓ Design approved! Customer will receive an email with a link to order.${expectedDeliveryDate ? ` Estimated delivery: ${new Date(expectedDeliveryDate).toLocaleDateString()}` : ''}`);
}
} finally {
setApproving(false);
+7 -7
View File
@@ -4,7 +4,7 @@ import AdminNav from '@/components/AdminNav';
import DeleteDesignButton from '@/components/DeleteDesignButton';
export default async function DesignsPage() {
let designs = [];
let designs: any[] = [];
try {
designs = await prisma.designApproval.findMany({
select: {
@@ -20,15 +20,15 @@ export default async function DesignsPage() {
});
// 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({
where: { id: { in: productIds } },
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
designs = designs.map(d => ({
designs = designs.map((d: any) => ({
...d,
product: productMap[d.productId],
messages: [],
@@ -37,9 +37,9 @@ export default async function DesignsPage() {
console.error('Error fetching designs:', err);
}
const pending = designs.filter((d) => d.status === 'PENDING');
const approved = designs.filter((d) => d.status === 'APPROVED');
const rejected = designs.filter((d) => d.status === 'REJECTED');
const pending = designs.filter((d: any) => d.status === 'PENDING');
const approved = designs.filter((d: any) => d.status === 'APPROVED');
const rejected = designs.filter((d: any) => d.status === 'REJECTED');
const DesignList = ({ items, status }: { items: typeof designs; status: string }) => (
<div className="mt-8">
+9 -8
View File
@@ -6,17 +6,18 @@ import Link from 'next/link';
export default async function FinancialAuditPage({
searchParams,
}: {
searchParams: { type?: string; action?: string; from?: string; to?: string };
searchParams: Promise<{ type?: string; action?: string; from?: string; to?: string }>;
}) {
const isAdmin = await isAdminAuthenticated();
if (!isAdmin) {
return <div className="px-6 py-12 text-center">Unauthorized</div>;
}
const entityType = searchParams.type as any;
const action = searchParams.action as any;
const fromDate = searchParams.from ? new Date(searchParams.from) : null;
const toDate = searchParams.to ? new Date(`${searchParams.to}T23:59:59`) : null;
const sp = await searchParams;
const entityType = sp.type as any;
const action = sp.action as any;
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({
where: {
@@ -110,7 +111,7 @@ export default async function FinancialAuditPage({
<input
type="date"
name="from"
defaultValue={searchParams.from ?? ''}
defaultValue={sp.from ?? ''}
className="border border-line bg-paper px-3 py-2 text-sm"
placeholder="From"
/>
@@ -118,7 +119,7 @@ export default async function FinancialAuditPage({
<input
type="date"
name="to"
defaultValue={searchParams.to ?? ''}
defaultValue={sp.to ?? ''}
className="border border-line bg-paper px-3 py-2 text-sm"
placeholder="To"
/>
@@ -130,7 +131,7 @@ export default async function FinancialAuditPage({
Filter
</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">
Clear filters
</Link>
+4 -3
View File
@@ -4,9 +4,10 @@ import AdminNav from '@/components/AdminNav';
import { updateGalleryPhoto } from '../../actions';
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({
where: { id: params.id },
where: { id },
include: { category: true },
});
@@ -25,7 +26,7 @@ export default async function EditGalleryPhotoPage({ params }: { params: { id: s
</p>
<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">
<form action={updateGalleryPhoto}>
<input type="hidden" name="id" value={photo.id} />
+5 -10
View File
@@ -2,6 +2,7 @@
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { saveUploadedFile } from '@/lib/storage';
function slugify(input: string) {
return input
@@ -21,12 +22,6 @@ async function uniqueSlug(base: string) {
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) {
const caption = String(formData.get('caption') ?? '').trim();
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.');
}
const imageDataUrl = await fileToDataUrl(file);
const imageUrl = await saveUploadedFile(file, 'gallery');
await prisma.galleryPhoto.create({
data: {
imageDataUrl,
imageUrl,
caption: caption || null,
categoryId,
},
@@ -113,10 +108,10 @@ export async function uploadGalleryPhotoBulk(formData: FormData) {
if (file.size === 0) continue;
try {
const imageDataUrl = await fileToDataUrl(file);
const imageUrl = await saveUploadedFile(file, 'gallery');
const photo = await prisma.galleryPhoto.create({
data: {
imageDataUrl,
imageUrl,
caption: null,
categoryId,
},
@@ -1,12 +1,13 @@
import AdminNav from '@/components/AdminNav';
import { createGalleryCategory } from '../../actions';
export default function NewGalleryCategoryPage({
export default async function NewGalleryCategoryPage({
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 (
<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 { 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 counts = await prisma.galleryPhoto.groupBy({ by: ['categoryId'], _count: true });
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.
</p>
{searchParams.error === 'in_use' && (
{error === 'in_use' && (
<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
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">
{photos.map((p) => (
<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">
<p className="font-medium">{p.caption || <span className="text-muted">No caption</span>}</p>
<p className="tag-label mt-0.5">
+4 -3
View File
@@ -8,9 +8,10 @@ function formatPrice(cents: number) {
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({
where: { id: params.id },
where: { id },
include: { product: true },
});
if (!proof) notFound();
@@ -20,7 +21,7 @@ export default async function AdminThreadPage({ params }: { params: { id: string
<AdminNav />
<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">
<p className="font-medium">{proof.customerName || proof.customerEmail}</p>
<p className="text-sm text-muted">
+1 -1
View File
@@ -32,7 +32,7 @@ export default async function InboxPage() {
href={`/admin/inbox/${p.id}`}
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">
<p className="font-medium">{p.customerName || p.customerEmail}</p>
<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 next = safeNext(formData.get('next'));
const ip = getClientIp(headers());
const userAgent = headers().get('user-agent') ?? undefined;
const headersList = await headers();
const ip = getClientIp(headersList);
const userAgent = headersList.get('user-agent') ?? undefined;
const { allowed } = await checkRateLimit(`login:admin:${ip}`, 5, 15 * 60 * 1000);
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.',
};
export default function AdminLoginPage({ searchParams }: { searchParams: { error?: string; next?: string } }) {
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null;
const next = searchParams.next ?? '/admin/dashboard';
export default async function AdminLoginPage({ searchParams }: { searchParams: Promise<{ error?: string; next?: string }> }) {
const { error: errorKey, next } = await searchParams;
const error = errorKey ? (ERROR_MESSAGES[errorKey] ?? 'Something went wrong.') : null;
return (
<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)}`;
}
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({
where: { id: params.id },
where: { id },
include: { items: true },
});
if (!order) notFound();
@@ -237,8 +238,9 @@ export default async function OrderDetailPage({ params }: { params: { id: string
{order.items.map((item) => {
let hasFrontDesign = false;
let hasBackDesign = false;
let design: { front: unknown[]; back: unknown[] } = { front: [], back: [] };
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;
hasBackDesign = (design.back?.length ?? 0) > 0;
} catch {
+11 -10
View File
@@ -26,18 +26,19 @@ const SUCCESS_MESSAGES: Record<string, string> = {
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();
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 showOnlyPersonalised = searchParams.personalised === '1';
const page = Math.max(1, parseInt(searchParams.page || '1'));
const showArchived = sp.archived === '1';
const showOnlyPersonalised = sp.personalised === '1';
const page = Math.max(1, parseInt(sp.page || '1'));
const pageSize = 50;
const skip = (page - 1) * pageSize;
let orders = [];
let orders: any[] = [];
let total = 0;
try {
// 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
if (showOnlyPersonalised) {
orders = orders.filter((order) =>
order.items.some((item) => {
orders = orders.filter((order: any) =>
order.items.some((item: any) => {
const design = JSON.parse(item.designJson) as { front: unknown[]; back: unknown[] };
return design.front.length > 0 || design.back.length > 0;
})
@@ -148,8 +149,8 @@ export default async function OrdersPage({ searchParams }: { searchParams: { arc
</p>
) : (
<div className="mt-8 divide-y divide-line border-t border-line">
{orders.map((order) => {
const hasPersonalised = order.items.some((item) => {
{orders.map((order: any) => {
const hasPersonalised = order.items.some((item: any) => {
try {
const design = JSON.parse(item.designJson) as { front: unknown[]; back: unknown[] };
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 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 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();
const product = toProductDTO(row, activePromotions);
const categories = await prisma.category.findMany({
where: { parentId: null },
orderBy: { name: 'asc' },
select: {
id: true,
name: true,
slug: true,
include: {
children: {
select: { id: true, name: true, slug: true },
orderBy: { name: 'asc' },
},
},
});
}) as any;
const collections = await prisma.collection.findMany({ orderBy: { name: 'asc' } });
const occasions = collections.filter((c) => c.group === 'OCCASION');
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,
} : 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 (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
@@ -279,12 +280,12 @@ export default async function EditProductPage({ params }: { params: { id: string
<div>
<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>
<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>
+10 -15
View File
@@ -3,6 +3,7 @@
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { DEFAULT_PRINT_AREAS } from '@/lib/mockupDefaults';
import { saveUploadedFile } from '@/lib/storage';
function slugify(input: string) {
return input
@@ -22,15 +23,9 @@ async function uniqueSlug(base: string) {
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
// "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(
formData: FormData,
colors: string[],
@@ -41,7 +36,7 @@ async function extractColorPhotos(
for (const hex of colors) {
const file = formData.get(`${prefix}${hex.replace('#', '')}`) as File | null;
if (file && file.size > 0) {
result[hex] = await fileToDataUrl(file);
result[hex] = await saveUploadedFile(file, 'products');
}
}
return result;
@@ -103,8 +98,8 @@ export async function createProduct(formData: FormData) {
const weightKgInput = String(formData.get('weightKg') ?? '').trim();
const weightGrams = weightKgInput ? Math.round(Number(weightKgInput) * 1000) : null;
const imageUrl = imageFile && imageFile.size > 0 ? await fileToDataUrl(imageFile) : null;
const imageUrlBack = imageBackFile && imageBackFile.size > 0 ? await fileToDataUrl(imageBackFile) : null;
const imageUrl = imageFile && imageFile.size > 0 ? await saveUploadedFile(imageFile, 'products') : null;
const imageUrlBack = imageBackFile && imageBackFile.size > 0 ? await saveUploadedFile(imageBackFile, 'products') : null;
const colorPhotos = await extractColorPhotos(formData, colors, 'front');
const colorPhotosBack = await extractColorPhotos(formData, colors, 'back');
const saleFields = parseSaleFields(formData);
@@ -276,10 +271,10 @@ export async function updateProduct(formData: FormData) {
referenceWidthCm: number | null;
referenceHeightCm: number | null;
weightGrams: number | null;
imageUrl?: string;
imageUrlBack?: string;
imageUrl?: string | null;
imageUrlBack?: string | null;
printArea?: string;
printAreaBack?: string;
printAreaBack?: string | null;
} = {
name,
category,
@@ -310,7 +305,7 @@ export async function updateProduct(formData: FormData) {
data.imageUrl = null;
data.printArea = JSON.stringify(DEFAULT_PRINT_AREAS[mockup] ?? DEFAULT_PRINT_AREAS.tshirt);
} else if (imageFile && imageFile.size > 0) {
data.imageUrl = await fileToDataUrl(imageFile);
data.imageUrl = await saveUploadedFile(imageFile, 'products');
data.printArea = JSON.stringify(
hasCustomPrintArea
? {
@@ -341,7 +336,7 @@ export async function updateProduct(formData: FormData) {
data.imageUrlBack = null;
data.printAreaBack = null;
} else if (imageBackFile && imageBackFile.size > 0) {
data.imageUrlBack = await fileToDataUrl(imageBackFile);
data.imageUrlBack = await saveUploadedFile(imageBackFile, 'products');
if (hasCustomPrintAreaBack) {
data.printAreaBack = JSON.stringify({
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({
where: { parentId: null },
orderBy: { name: 'asc' },
select: {
id: true,
name: true,
slug: true,
include: {
children: {
select: { id: true, name: true, slug: true },
orderBy: { name: 'asc' },
},
},
});
}) as any;
const collections = await prisma.collection.findMany({ orderBy: { name: 'asc' } });
const occasions = collections.filter((c) => c.group === 'OCCASION');
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({
searchParams,
}: {
searchParams: { error?: string };
searchParams: Promise<{ error?: string }>;
}) {
const { error } = await searchParams;
const activePromotions = await fetchActivePromotions();
let products = [];
let products: any[] = [];
try {
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()
);
} catch (err) {
@@ -35,7 +36,7 @@ export default async function AdminProductsPage({
</Link>
</div>
{searchParams.error === 'in_use' && (
{error === 'in_use' && (
<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.
</div>
+3 -2
View File
@@ -4,9 +4,10 @@ import AdminNav from '@/components/AdminNav';
import PromotionScopePicker from '@/components/PromotionScopePicker';
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([
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' } }),
]);
if (!promotion) notFound();
+8 -6
View File
@@ -7,18 +7,20 @@ export default async function ProofSentPage({
params,
searchParams,
}: {
params: { id: string };
searchParams: { emailed?: string };
params: Promise<{ id: string }>;
searchParams: Promise<{ emailed?: string }>;
}) {
const { id } = await params;
const { emailed: emailedParam } = await searchParams;
const proof = await prisma.designProof.findUnique({
where: { id: params.id },
where: { id },
include: { product: true },
});
if (!proof) notFound();
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
const link = `${siteUrl}/proofs/${proof.id}`;
const emailed = searchParams.emailed === '1';
const emailed = emailedParam === '1';
return (
<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">
{proof.imageDataUrl ? (
{proof.imageUrl ? (
<img
src={proof.imageDataUrl}
src={proof.imageUrl}
alt="Uploaded design"
className="mx-auto max-h-64 object-contain"
/>
+3 -4
View File
@@ -3,6 +3,7 @@
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { sendProofEmail } from '@/lib/mail';
import { saveUploadedFile } from '@/lib/storage';
export async function createProof(formData: FormData) {
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 } });
if (!product) throw new Error('Product not found.');
const bytes = Buffer.from(await file.arrayBuffer());
const mimeType = file.type || 'image/png';
const imageDataUrl = `data:${mimeType};base64,${bytes.toString('base64')}`;
const imageUrl = await saveUploadedFile(file, 'proofs');
const unitPrice = priceOverride && String(priceOverride).trim() !== ''
? Math.round(Number(priceOverride) * 100)
@@ -37,7 +36,7 @@ export async function createProof(formData: FormData) {
color,
quantity,
unitPrice,
imageDataUrl,
imageUrl,
note: note || null,
},
});
+5 -4
View File
@@ -5,8 +5,9 @@ import AdminNav from '@/components/AdminNav';
export default async function NewProofPage({
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' } });
return (
@@ -27,7 +28,7 @@ export default async function NewProofPage({
id="productId"
name="productId"
required
defaultValue={searchParams.productId}
defaultValue={params.productId}
className="w-full border border-line px-3 py-2 text-sm"
>
{products.map((p) => (
@@ -46,7 +47,7 @@ export default async function NewProofPage({
<input
id="customerName"
name="customerName"
defaultValue={searchParams.customerName}
defaultValue={params.customerName}
className="w-full border border-line px-3 py-2 text-sm"
placeholder="Jane Doe"
/>
@@ -60,7 +61,7 @@ export default async function NewProofPage({
name="customerEmail"
type="email"
required
defaultValue={searchParams.customerEmail}
defaultValue={params.customerEmail}
className="w-full border border-line px-3 py-2 text-sm"
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">
{proofs.map((p) => (
<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">
<p className="font-medium">{p.customerName || p.customerEmail}</p>
<p className="text-sm text-muted">
+3 -2
View File
@@ -20,8 +20,9 @@ async function deleteReview(reviewId: string) {
redirect('/admin/reviews?status=pending');
}
export default async function AdminReviewsPage({ searchParams }: { searchParams: { status?: string } }) {
const status = searchParams.status === 'approved' ? 'approved' : 'pending';
export default async function AdminReviewsPage({ searchParams }: { searchParams: Promise<{ status?: string }> }) {
const { status: statusParam } = await searchParams;
const status = statusParam === 'approved' ? 'approved' : 'pending';
const reviews = await prisma.review.findMany({
where: {
+28 -19
View File
@@ -7,6 +7,11 @@ import { fetchActivePromotions } from '@/lib/promotions';
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
import { getRoyalMailShippingQuotes } from '@/lib/royalmail-shipping';
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 = {
productId: string;
@@ -130,7 +135,7 @@ export async function POST(req: Request) {
// Calculate shipping cost (skip for collection orders)
let shippingCost = 0;
let selectedShippingService = shippingService;
let selectedShippingService: string | undefined | null = shippingService;
if (isCollection) {
console.log('Collection order - no shipping cost');
@@ -179,6 +184,26 @@ export async function POST(req: Request) {
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
// the design files (and the order itself) retrievable afterward, whether or
// 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,
guestName: guest?.name,
guestPhone: guest?.phone,
isCollectionPickup,
items: {
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,
})),
},
isCollectionPickup: isCollection,
items: { create: orderItems },
},
});
+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(
req: Request,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await req.json();
const { expectedDeliveryDate } = body;
const design = await prisma.designApproval.findUnique({
where: { id: params.id },
where: { id },
include: { product: true },
});
@@ -21,7 +22,7 @@ export async function POST(
}
const updated = await prisma.designApproval.update({
where: { id: params.id },
where: { id },
data: {
status: 'APPROVED',
expectedDeliveryDate: expectedDeliveryDate ? new Date(expectedDeliveryDate) : null,
+6 -4
View File
@@ -4,11 +4,12 @@ import type { DesignApprovalMessage } from '@prisma/client';
export async function GET(
req: Request,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const messages = await prisma.designApprovalMessage.findMany({
where: { designId: params.id },
where: { designId: id },
orderBy: { createdAt: 'asc' },
});
@@ -24,9 +25,10 @@ export async function GET(
export async function POST(
req: Request,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const { sender, body, role, message, messageType } = await req.json();
// 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({
data: {
designId: params.id,
designId: id,
sender: finalSender,
body: finalBody,
messageType: finalMessageType,
+9 -7
View File
@@ -1,11 +1,13 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { saveDataUrl } from '@/lib/storage';
export async function POST(
req: Request,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const {
designJson,
designPreviewUrl,
@@ -17,7 +19,7 @@ export async function POST(
} = await req.json();
const design = await prisma.designApproval.findUnique({
where: { id: params.id },
where: { id: id },
});
if (!design) {
@@ -25,13 +27,13 @@ export async function POST(
}
const updated = await prisma.designApproval.update({
where: { id: params.id },
where: { id: id },
data: {
...(designJson && { designJson }),
...(designPreviewUrl && { designPreviewUrl }),
...(designPreviewUrlBack && { designPreviewUrlBack }),
...(placementPreviewUrl && { placementPreviewUrl }),
...(placementPreviewUrlBack && { placementPreviewUrlBack }),
...(designPreviewUrl && { designPreviewUrl: await saveDataUrl(designPreviewUrl, 'designs') }),
...(designPreviewUrlBack && { designPreviewUrlBack: await saveDataUrl(designPreviewUrlBack, 'designs') }),
...(placementPreviewUrl && { placementPreviewUrl: await saveDataUrl(placementPreviewUrl, 'design-placements') }),
...(placementPreviewUrlBack && { placementPreviewUrlBack: await saveDataUrl(placementPreviewUrlBack, 'design-placements') }),
...(designWidthCm !== undefined && { designWidthCm }),
...(designHeightCm !== undefined && { designHeightCm }),
},
+10 -7
View File
@@ -5,11 +5,12 @@ import { toProductDTO } from '@/lib/product';
export async function GET(
req: Request,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const design = await prisma.designApproval.findUnique({
where: { id: params.id },
where: { id },
include: { product: true, messages: { orderBy: { createdAt: 'asc' } } },
});
@@ -32,9 +33,10 @@ export async function GET(
export async function PATCH(
req: Request,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const customer = await getCurrentCustomer();
if (!customer) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
@@ -43,7 +45,7 @@ export async function PATCH(
const { status } = await req.json();
const updated = await prisma.designApproval.update({
where: { id: params.id },
where: { id },
data: { status },
include: { product: true, messages: { orderBy: { createdAt: 'asc' } } },
});
@@ -63,17 +65,18 @@ export async function PATCH(
export async function DELETE(
req: Request,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Delete associated messages first
await prisma.designApprovalMessage.deleteMany({
where: { designId: params.id },
where: { designId: id },
});
// Delete the design approval
await prisma.designApproval.delete({
where: { id: params.id },
where: { id: id },
});
return NextResponse.json({ success: true });
@@ -4,13 +4,14 @@ import { sendDesignReadyForReview } from '@/lib/mail';
export async function POST(
req: Request,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const { designPreviewUrl, designPreviewUrlBack, placementPreviewUrl, placementPreviewUrlBack } = await req.json().catch(() => ({}));
const design = await prisma.designApproval.findUnique({
where: { id: params.id },
where: { id: id },
include: { product: true },
});
@@ -26,7 +27,7 @@ export async function POST(
if (placementPreviewUrlBack) updateData.placementPreviewUrlBack = placementPreviewUrlBack;
const updated = await prisma.designApproval.update({
where: { id: params.id },
where: { id: id },
data: updateData,
include: { product: true, messages: { orderBy: { createdAt: 'asc' } } },
});
@@ -36,7 +37,7 @@ export async function POST(
customerName: design.customerName || 'Customer',
customerEmail: design.customerEmail,
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);
+9 -4
View File
@@ -2,6 +2,11 @@ import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { sendDesignSubmissionNotification } from '@/lib/mail';
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) {
try {
@@ -49,10 +54,10 @@ export async function POST(req: Request) {
customerName: customer.name,
productId,
designJson,
designPreviewUrl,
designPreviewUrlBack,
placementPreviewUrl,
placementPreviewUrlBack,
designPreviewUrl: await saveDataUrl(designPreviewUrl, 'designs'),
designPreviewUrlBack: await saveIfPresent(designPreviewUrlBack, 'designs'),
placementPreviewUrl: await saveIfPresent(placementPreviewUrl, 'design-placements'),
placementPreviewUrlBack: await saveIfPresent(placementPreviewUrlBack, 'design-placements'),
designWidthCm: designWidthCm || null,
designHeightCm: designHeightCm || null,
designImageDimensions: designImageDimensions && designImageDimensions.length > 0 ? JSON.stringify(designImageDimensions) : null,
+3 -3
View File
@@ -1,9 +1,9 @@
import { NextResponse } from 'next/server';
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 {
const { id } = params;
const { id } = await params;
const order = await prisma.order.findUnique({
where: { id },
include: { items: true },
@@ -292,7 +292,7 @@ export async function GET(req: Request, { params }: { params: { id: string } })
${item.designImageDimensions ? (() => {
try {
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) {
return '';
}
@@ -4,11 +4,12 @@ import { fetchRoyalMailTracking, mapTrackingStatusToOrderStatus } from '@/lib/ro
export async function POST(
req: Request,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const order = await prisma.order.findUnique({
where: { id: params.id },
where: { id: id },
});
if (!order) {
@@ -31,13 +32,13 @@ export async function POST(
// Update order with new status
const updated = await prisma.order.update({
where: { id: params.id },
where: { id: id },
data: {
status: newStatus,
},
});
console.log(`✓ Order ${params.id} tracking synced: ${trackingData.status}${newStatus}`);
console.log(`✓ Order ${id} tracking synced: ${trackingData.status}${newStatus}`);
return NextResponse.json({
success: true,
+4 -3
View File
@@ -1,14 +1,15 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function POST(_req: Request, { params }: { params: { id: string } }) {
const proof = await prisma.designProof.findUnique({ where: { id: params.id } });
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const proof = await prisma.designProof.findUnique({ where: { id } });
if (!proof) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
await prisma.designProof.update({
where: { id: params.id },
where: { id },
data: { status: 'APPROVED' },
});
+8 -6
View File
@@ -2,13 +2,14 @@ import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
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({
where: { proofId: params.id },
where: { proofId: id },
orderBy: { createdAt: 'asc' },
});
const messages: MessageDTO[] = rows.map((m) => ({
const messages: MessageDTO[] = rows.map((m: any) => ({
id: m.id,
proofId: m.proofId,
sender: m.sender as MessageDTO['sender'],
@@ -19,7 +20,8 @@ export async function GET(_req: Request, { params }: { params: { id: string } })
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();
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 });
}
const proof = await prisma.designProof.findUnique({ where: { id: params.id } });
const proof = await prisma.designProof.findUnique({ where: { id } });
if (!proof) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const created = await prisma.message.create({
data: { proofId: params.id, sender, body: trimmed },
data: { proofId: id, sender, body: trimmed },
});
const message: MessageDTO = {
+6 -4
View File
@@ -3,11 +3,12 @@ import { prisma } from '@/lib/prisma';
export async function DELETE(
req: Request,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const review = await prisma.review.delete({
where: { id: params.id },
where: { id: id },
});
return NextResponse.json(review);
@@ -22,14 +23,15 @@ export async function DELETE(
export async function PATCH(
req: Request,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await req.json();
const { approved } = body;
const review = await prisma.review.update({
where: { id: params.id },
where: { id: id },
data: { approved },
});
+2 -2
View File
@@ -6,9 +6,9 @@ import Price from '@/components/Price';
export default async function CheckoutSuccessPage({
searchParams,
}: {
searchParams: { session_id?: string };
searchParams: Promise<{ session_id?: string }>;
}) {
const sessionId = searchParams.session_id;
const { session_id: sessionId } = await searchParams;
let order = null;
if (sessionId) {
try {
+1 -1
View File
@@ -15,7 +15,7 @@ export async function sendContactMessage(formData: FormData) {
redirect('/contact?error=missing');
}
const ip = getClientIp(headers());
const ip = getClientIp(await headers());
const turnstileToken = String(formData.get('cf-turnstile-response') ?? '');
const { success } = await verifyTurnstileToken(turnstileToken, ip);
if (!success) {
+4 -3
View File
@@ -10,10 +10,11 @@ const ERROR_MESSAGES: Record<string, string> = {
export default async function ContactPage({
searchParams,
}: {
searchParams: { sent?: string; error?: string };
searchParams: Promise<{ sent?: string; error?: string }>;
}) {
const params = await searchParams;
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 (
<div className="mx-auto max-w-2xl px-6 py-12">
@@ -24,7 +25,7 @@ export default async function ContactPage({
back to you.
</p>
{searchParams.sent === '1' ? (
{params.sent === '1' ? (
<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.
</div>
+4 -5
View File
@@ -6,6 +6,7 @@ import { prisma } from '@/lib/prisma';
import { sendCustomRequestNotification } from '@/lib/mail';
import { verifyTurnstileToken } from '@/lib/turnstile';
import { getClientIp } from '@/lib/rateLimit';
import { saveUploadedFile } from '@/lib/storage';
export async function createCustomRequest(formData: FormData) {
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.');
}
const ip = getClientIp(headers());
const ip = getClientIp(await headers());
const turnstileToken = String(formData.get('cf-turnstile-response') ?? '');
const { success } = await verifyTurnstileToken(turnstileToken, ip);
if (!success) {
@@ -30,9 +31,7 @@ export async function createCustomRequest(formData: FormData) {
const product = await prisma.product.findUnique({ where: { id: productId } });
if (!product) throw new Error('Product not found.');
const bytes = Buffer.from(await file.arrayBuffer());
const mimeType = file.type || 'image/png';
const imageDataUrl = `data:${mimeType};base64,${bytes.toString('base64')}`;
const imageUrl = await saveUploadedFile(file, 'custom-requests');
const request = await prisma.customRequest.create({
data: {
@@ -41,7 +40,7 @@ export async function createCustomRequest(formData: FormData) {
customerName,
customerEmail,
customerPhone: customerPhone || null,
imageDataUrl,
imageUrl,
note: note || null,
},
});
+4 -3
View File
@@ -5,9 +5,10 @@ import { prisma } from '@/lib/prisma';
export default async function CustomRequestSubmittedPage({
searchParams,
}: {
searchParams: { id?: string };
searchParams: Promise<{ id?: string }>;
}) {
const id = searchParams.id ?? '';
const params = await searchParams;
const id = params.id ?? '';
const request = id
? await prisma.customRequest.findUnique({ where: { id }, include: { product: true } })
: null;
@@ -23,7 +24,7 @@ export default async function CustomRequestSubmittedPage({
<div className="mt-8 border border-line bg-surface p-6">
<img
src={request.imageDataUrl}
src={request.imageUrl ?? ''}
alt="Your submitted photo"
className="mx-auto max-h-64 object-contain"
/>
+49 -3
View File
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useCart } from '@/lib/cartStore';
interface DesignApproval {
@@ -18,18 +19,23 @@ interface DesignApproval {
};
designJson: string;
designPreviewUrl: string;
designPreviewUrlBack: string | null;
placementPreviewUrl: string | null;
placementPreviewUrlBack: string | null;
color: string;
size: string | null;
status: string;
expectedDeliveryDate: string | null;
}
export default function DesignReviewPage({ params }: { params: { id: string } }) {
const router = useRouter();
const [design, setDesign] = useState<DesignApproval | null>(null);
const [loading, setLoading] = useState(true);
const [approving, setApproving] = useState(false);
const [changingMessage, setChangingMessage] = useState('');
const [sendingChanges, setSendingChanges] = useState(false);
const [showCheckoutOptions, setShowCheckoutOptions] = useState(false);
const addItem = useCart((s) => s.addItem);
useEffect(() => {
@@ -43,7 +49,11 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
if (!design) return;
setApproving(true);
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) {
const updated = await res.json();
setDesign(updated);
@@ -59,7 +69,7 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
size: design.size,
quantity: 1,
unitPrice: design.product.personalisedPrice ?? design.product.effectivePrice,
designJson: design.designJson,
designJson: JSON.parse(design.designJson),
designPreviewUrl: design.designPreviewUrl,
designPreviewUrlBack: design.designPreviewUrlBack,
placementPreviewUrl: design.placementPreviewUrl,
@@ -68,8 +78,13 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
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 {
setApproving(false);
}
@@ -167,6 +182,11 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
<p className="border-t border-line pt-3">
<strong>Price:</strong> £{((design.product.personalisedPrice ?? design.product.effectivePrice) / 100).toFixed(2)}
</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>
<button
@@ -197,6 +217,32 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
</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>
);
}
+4 -3
View File
@@ -2,10 +2,11 @@ import Link from 'next/link';
import { prisma } from '@/lib/prisma';
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 activeSlug = searchParams.category ?? null;
const activeSlug = category ?? null;
const activeCategory = activeSlug ? categories.find((c) => c.slug === activeSlug) ?? null : null;
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">
<div className="aspect-square w-full overflow-hidden">
<img
src={p.imageDataUrl}
src={p.imageUrl ?? ''}
alt={p.caption ?? 'Completed work'}
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 MaintenancePage from '@/components/MaintenancePage';
import LogoutOnUnload from '@/components/LogoutOnUnload';
import ThemeInitializer from '@/components/ThemeInitializer';
import { CurrencyProvider } from '@/lib/CurrencyContext';
import { getSiteSettings } from '@/lib/settings';
import { isAdminAuthenticated } from '@/lib/adminAuth';
@@ -31,7 +32,7 @@ export const metadata: Metadata = {
export default async function RootLayout({ children }: { children: React.ReactNode }) {
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
// reachable so the owner can log in and toggle it back off.
// 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"
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>
<body suppressHydrationWarning className="flex min-h-screen flex-col">
<ThemeInitializer />
<LogoutOnUnload />
<CurrencyProvider>
{showMaintenance ? (
+10 -10
View File
@@ -1,22 +1,22 @@
import { notFound } from 'next/navigation';
import dynamic from 'next/dynamic';
import { prisma } from '@/lib/prisma';
import { toProductDTO } from '@/lib/product';
import { fetchActivePromotions } from '@/lib/promotions';
import ProductCard from '@/components/ProductCard';
const DesignCanvas = dynamic(() => import('@/components/DesignCanvas'), { ssr: false });
import DesignCanvasWrapper from '@/components/DesignCanvasWrapper';
export default async function ProductDetailPage({
params,
searchParams,
}: {
params: { slug: string };
searchParams: { customRequestId?: string };
params: Promise<{ slug: string }>;
searchParams: Promise<{ customRequestId?: string }>;
}) {
const { slug } = await params;
const { customRequestId } = await searchParams;
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();
const product = toProductDTO(row, activePromotions, 'personalised');
@@ -29,18 +29,18 @@ export default async function ProductDetailPage({
// Fetch custom photo from request if customRequestId is provided
let customPhoto: string | undefined;
if (searchParams.customRequestId) {
if (customRequestId) {
const customRequest = await prisma.customRequest.findUnique({
where: { id: searchParams.customRequestId },
where: { id: customRequestId },
});
if (customRequest) {
customPhoto = customRequest.imageDataUrl;
customPhoto = customRequest.imageUrl ?? undefined;
}
}
return (
<div className="mx-auto max-w-6xl px-6 py-12">
<DesignCanvas product={product} customPhoto={customPhoto} />
<DesignCanvasWrapper product={product} customPhoto={customPhoto} />
{related.length > 0 && (
<section className="mt-20 border-t border-line pt-12">
+38 -257
View File
@@ -1,300 +1,81 @@
import { prisma } from '@/lib/prisma';
import { toProductDTO } from '@/lib/product';
import { fetchActivePromotions } from '@/lib/promotions';
import { getPersonalisedPaletteColors } from '@/lib/cache';
import ProductCard from '@/components/ProductCard';
import type { Prisma } from '@prisma/client';
type SearchParams = {
category?: string | string[];
collection?: string | string[];
color?: string | string[];
min?: string;
max?: string;
q?: string;
page?: string;
};
const PRODUCTS_PER_PAGE = 20;
function toArray(v?: string | string[]) {
if (!v) return [];
return Array.isArray(v) ? v : [v];
}
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) } : {}),
};
}
export default async function ProductsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const { page } = await searchParams;
const currentPage = Math.max(1, Number(page) || 1);
const activePromotions = await fetchActivePromotions();
const [rows, totalCount] = await Promise.all([
prisma.product.findMany({
where,
where: { showOnPersonalised: true },
orderBy: { createdAt: 'asc' },
take: 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 totalPages = Math.ceil(totalCount / PRODUCTS_PER_PAGE);
const hasFilters =
selectedCategories.length > 0 ||
selectedCollections.length > 0 ||
selectedColors.length > 0 ||
q ||
minDollars !== undefined ||
maxDollars !== undefined;
return (
<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>
<p className="tag-label">
{totalCount} template{totalCount === 1 ? '' : 's'}
</p>
</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>
{products.length === 0 ? (
<div className="border border-line bg-surface p-12 text-center">
<p className="font-display text-xl">Nothing here yet</p>
<p className="mt-2 text-sm text-muted">Templates will show up here once created.</p>
</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 className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</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>
))}
{totalPages > 1 && (
<div className="mt-12 flex items-center justify-center gap-4 border-t border-line pt-8">
{currentPage > 1 && (
<a
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"
>
Previous
</a>
)}
<p className="text-sm text-muted">
Page {currentPage} of {totalPages}
</p>
{currentPage < totalPages && (
<a
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"
>
Next
</a>
)}
</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>
{products.length === 0 ? (
<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="mt-2 text-sm text-muted">Try widening your price range or clearing a filter.</p>
</div>
) : (
<div>
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
{totalPages > 1 && (
<div className="mt-12 flex items-center justify-center gap-4 border-t border-line pt-8">
{currentPage > 1 && (
<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}` : ''}`}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Previous
</a>
)}
<p className="text-sm text-muted">
Page {currentPage} of {totalPages}
</p>
{currentPage < totalPages && (
<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}` : ''}`}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Next
</a>
)}
</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({
take: 6,
orderBy: { createdAt: 'desc' },
select: { id: true, imageUrl: true, caption: true },
});
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"
>
<img
src={p.imageDataUrl}
src={p.imageUrl ?? ''}
alt={p.caption ?? 'Completed work'}
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 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 row = await prisma.product.findUnique({ where: { slug: params.slug } });
const row = await prisma.product.findUnique({ where: { slug } });
if (!row || !row.showAsReadyMade) notFound();
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 { 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({
where: { slug: params.slug },
where: { slug },
include: {
reviews: {
where: { approved: true },
+35 -263
View File
@@ -1,120 +1,42 @@
import { prisma } from '@/lib/prisma';
import { toProductDTO } from '@/lib/product';
import { fetchActivePromotions } from '@/lib/promotions';
import { getPaletteColors } from '@/lib/cache';
import ProductCard from '@/components/ProductCard';
import type { Prisma } from '@prisma/client';
type SearchParams = {
category?: string | string[];
collection?: string | string[];
color?: string | string[];
min?: string;
max?: string;
q?: string;
page?: string;
};
const PRODUCTS_PER_PAGE = 20;
function toArray(v?: string | string[]) {
if (!v) return [];
return Array.isArray(v) ? v : [v];
}
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) } : {}),
};
}
export default async function ReadyMadeProductsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams;
const currentPage = Math.max(1, Number(params.page) || 1);
const activePromotions = await fetchActivePromotions();
const [rows, totalCount] = await Promise.all([
prisma.product.findMany({
where,
where: { showAsReadyMade: true },
orderBy: { createdAt: 'asc' },
take: 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 totalPages = Math.ceil(totalCount / PRODUCTS_PER_PAGE);
const hasFilters =
selectedCategories.length > 0 ||
selectedCollections.length > 0 ||
selectedColors.length > 0 ||
q ||
minDollars !== undefined ||
maxDollars !== undefined;
return (
<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>
<p className="tag-label">
{products.length} item{products.length === 1 ? '' : 's'}
{totalCount} item{totalCount === 1 ? '' : 's'}
</p>
</div>
{products.length === 0 && !hasFilters ? (
{products.length === 0 ? (
<div className="border border-line bg-surface p-12 text-center">
<p className="font-display text-xl">Nothing here yet</p>
<p className="mt-2 text-sm text-muted">
@@ -123,187 +45,37 @@ export default async function ReadyMadeProductsPage({ searchParams }: { searchPa
</p>
</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>
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
{products.map((p) => (
<ProductCard key={p.id} product={p} hrefBase="/products" />
))}
</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
{totalPages > 1 && (
<div className="mt-12 flex items-center justify-center gap-4 border-t border-line pt-8">
{currentPage > 1 && (
<a
href={`/products?page=${currentPage - 1}`}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Previous
</a>
)}
<p className="text-sm text-muted">
Page {currentPage} of {totalPages}
</p>
{currentPage < totalPages && (
<a
href={`/products?page=${currentPage + 1}`}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Next
</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 className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
{products.map((p) => (
<ProductCard key={p.id} product={p} hrefBase="/products" />
))}
</div>
{totalPages > 1 && (
<div className="mt-12 flex items-center justify-center gap-4 border-t border-line pt-8">
{currentPage > 1 && (
<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}` : ''}`}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Previous
</a>
)}
<p className="text-sm text-muted">
Page {currentPage} of {totalPages}
</p>
{currentPage < totalPages && (
<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}` : ''}`}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Next
</a>
)}
</div>
)}
</div>
)}
</div>
</form>
)}
</div>
)}
</div>
);
+5 -4
View File
@@ -5,9 +5,10 @@ import ApproveSection from '@/components/ApproveSection';
import Chat from '@/components/Chat';
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({
where: { id: params.id },
where: { id },
include: { product: true },
});
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="border border-line bg-surface p-6">
{proof.imageDataUrl ? (
<img src={proof.imageDataUrl} alt={`${proof.productName} design`} className="w-full object-contain" />
{proof.imageUrl ? (
<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>
)}
+1 -1
View File
@@ -2,7 +2,7 @@
import { useRef, useState, useEffect } from 'react';
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';
interface AdminDesignEditorProps {
+5 -3
View File
@@ -26,11 +26,13 @@ export default function ApproveSection({ proof }: { proof: ProofDTO }) {
size: null,
quantity: proof.quantity,
unitPrice: proof.unitPrice,
designJson: { front: [], back: [] },
designPreviewUrl: proof.imageDataUrl,
designJson: { front: [], back: [] } as any,
designPreviewUrl: proof.imageUrl,
designPreviewUrlBack: null,
placementPreviewUrl: proof.imageDataUrl,
placementPreviewUrl: proof.imageUrl,
placementPreviewUrlBack: null,
designWidthCm: null,
designHeightCm: null,
});
setStatus('APPROVED');
router.push('/cart');
+73 -54
View File
@@ -325,12 +325,13 @@ function PrintSurface({
/>
{elements.map((el) =>
el.type === 'text' ? (
el.curvedPath ? (
(el as any).curvedPath ? (
// Curved text - TextPath with dragging via offset
(() => {
const elAny = el as any;
const offsetX = el.x - 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})`);
return (
<TextPath
@@ -522,6 +523,7 @@ export default function DesignCanvas({
const [submissionMessage, setSubmissionMessage] = useState<string | null>(null);
const [imageDimensions, setImageDimensions] = useState<Record<string, { widthCm: number | null; heightCm: number | null }>>({});
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
const [isUploadingImage, setIsUploadingImage] = useState(false);
const toggleSection = (section: string) => {
setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] }));
@@ -714,44 +716,53 @@ export default function DesignCanvas({
const handleUploadClick = () => fileInputRef.current?.click();
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const src = reader.result as string;
const img = new window.Image();
img.onload = () => {
// Clear old approval status when starting a new design
if (approvalStatus) {
setApprovalStatus(null);
setDesignApprovalId(null);
if (typeof window !== 'undefined') {
localStorage.removeItem(`designApproval_${product.id}`);
}
}
const maxDim = Math.min(activeStageW, activeStageH) * 0.7;
const scale = Math.min(maxDim / img.width, maxDim / img.height, 1);
const id = uuid();
setActiveElements((prev) => [
...prev,
{
id,
type: 'image',
x: activeStageW / 2 - (img.width * scale) / 2,
y: activeStageH / 2 - (img.height * scale) / 2,
rotation: 0,
width: img.width * scale,
height: img.height * scale,
src,
},
]);
setSelectedId(id);
};
img.src = src;
};
reader.readAsDataURL(file);
e.target.value = '';
if (!file) return;
setIsUploadingImage(true);
try {
const formData = new FormData();
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
if (approvalStatus) {
setApprovalStatus(null);
setDesignApprovalId(null);
if (typeof window !== 'undefined') {
localStorage.removeItem(`designApproval_${product.id}`);
}
}
const maxDim = Math.min(activeStageW, activeStageH) * 0.7;
const scale = Math.min(maxDim / img.width, maxDim / img.height, 1);
const id = uuid();
setActiveElements((prev) => [
...prev,
{
id,
type: 'image',
x: activeStageW / 2 - (img.width * scale) / 2,
y: activeStageH / 2 - (img.height * scale) / 2,
rotation: 0,
width: img.width * scale,
height: img.height * scale,
src,
},
]);
setSelectedId(id);
} catch (err) {
console.error('Failed to upload image:', err);
alert('Could not upload that image — please try again.');
} finally {
setIsUploadingImage(false);
}
};
const deleteSelected = () => {
@@ -773,10 +784,10 @@ export default function DesignCanvas({
salePrice: product.onSale ? product.effectivePrice : null,
saleStartsAt: null,
saleEndsAt: null,
},
} as any,
[],
new Date(),
true,
'personalised',
).price
: product.effectivePrice;
@@ -1251,10 +1262,10 @@ export default function DesignCanvas({
salePrice: product.onSale ? product.effectivePrice : null,
saleStartsAt: null,
saleEndsAt: null,
},
} as any,
[],
new Date(),
true,
'personalised',
);
return (
<>
@@ -1493,20 +1504,24 @@ export default function DesignCanvas({
<label className="flex items-center gap-2 text-sm text-muted mb-3">
<input
type="checkbox"
checked={text.curvedPath ? true : false}
onChange={(e) => updateElement(text.id, { curvedPath: e.target.checked ? { type: 'circle', radius: 80 } : null })}
checked={(text as any).curvedPath ? true : false}
onChange={(e) => updateElement(text.id, { curvedPath: e.target.checked ? { type: 'circle', radius: 80 } : null } as any)}
className="cursor-pointer"
/>
Curved text
</label>
{text.curvedPath && (
{(text as any).curvedPath && (
<div className="space-y-3">
{(() => {
const textAny = text as any;
return (
<>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted">Curve type</span>
<select
value={text.curvedPath.type || 'circle'}
onChange={(e) => updateElement(text.id, { curvedPath: { ...text.curvedPath, type: e.target.value as any } })}
value={textAny.curvedPath.type || 'circle'}
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"
>
<option value="circle">Circle</option>
@@ -1523,23 +1538,26 @@ export default function DesignCanvas({
type="range"
min={20}
max={200}
value={text.curvedPath.radius || 80}
onChange={(e) => updateElement(text.id, { curvedPath: { ...text.curvedPath, radius: Number(e.target.value) } })}
value={textAny.curvedPath.radius || 80}
onChange={(e) => updateElement(text.id, { curvedPath: { ...textAny.curvedPath, radius: Number(e.target.value) } } as any)}
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>
</label>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={text.curvedPath.reverse ? true : false}
onChange={(e) => updateElement(text.id, { curvedPath: { ...text.curvedPath, reverse: e.target.checked } })}
checked={textAny.curvedPath.reverse ? true : false}
onChange={(e) => updateElement(text.id, { curvedPath: { ...textAny.curvedPath, reverse: e.target.checked } } as any)}
className="cursor-pointer"
/>
<span className="text-muted">Reverse direction</span>
</label>
</>
);
})()}
</div>
)}
</div>
@@ -1558,9 +1576,10 @@ export default function DesignCanvas({
</button>
<button
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>
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFile} />
{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