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>
This commit is contained in:
Andymick
2026-07-22 20:56:40 +01:00
co-authored by Claude Sonnet 5
parent e23f201d3d
commit 396347b982
72 changed files with 1399 additions and 1336 deletions
+4 -3
View File
@@ -1,6 +1,7 @@
# Database (SQLite by default — works out of the box on any server). # Database — PostgreSQL connection string. prisma/schema.prisma is fixed to the
# For Postgres later, change provider in prisma/schema.prisma and set this to your Postgres URL. # postgresql provider, so this must point at a real Postgres instance (local
DATABASE_URL="file:./dev.db" # install, Docker container, or managed service) — a SQLite file path won't work.
DATABASE_URL="postgresql://user:password@localhost:5432/craft2prints?schema=public"
# Stripe — get these from https://dashboard.stripe.com/apikeys # Stripe — get these from https://dashboard.stripe.com/apikeys
STRIPE_SECRET_KEY="sk_live_or_test_..." STRIPE_SECRET_KEY="sk_live_or_test_..."
+1
View File
@@ -9,3 +9,4 @@ prisma/dev.db-*
.claude/settings.local.json .claude/settings.local.json
dist/ dist/
database-backups/ database-backups/
public/uploads/
+14 -6
View File
@@ -7,7 +7,7 @@ A complete self-hosted e-commerce platform for personalized products (apparel, d
### Customer Experience ### Customer Experience
- **Design Tool** — In-browser tool to customize templates with text and images - **Design Tool** — In-browser tool to customize templates with text and images
- **Product Catalog** — Browse and filter by category, color, size, and price - **Product Catalog** — Browse and filter by category, color, size, and price
- **Shopping Cart** — Persistent cart stored in IndexedDB (handles large design files) - **Shopping Cart** — Persistent cart stored in IndexedDB
- **Customer Accounts** — Optional sign-up for order history and faster checkout - **Customer Accounts** — Optional sign-up for order history and faster checkout
- **Dark Mode** — Full dark mode support with system preference detection - **Dark Mode** — Full dark mode support with system preference detection
- **Mobile Responsive** — Works seamlessly on all devices - **Mobile Responsive** — Works seamlessly on all devices
@@ -42,12 +42,13 @@ A complete self-hosted e-commerce platform for personalized products (apparel, d
- **Session Expiry** — Auto-logout when browser closes - **Session Expiry** — Auto-logout when browser closes
### Technical Stack ### Technical Stack
- **Frontend** — Next.js 14 (App Router), React, Tailwind CSS - **Frontend** — Next.js 16 (App Router, Turbopack), React, Tailwind CSS
- **Backend** — Next.js server actions, API routes - **Backend** — Next.js server actions, API routes
- **Database** — SQLite with Prisma ORM - **Database** — PostgreSQL with Prisma ORM
- **File storage** — Uploaded images/documents are compressed with `sharp` and saved as files on disk (`public/uploads/`), not as base64 in the database — see [Image & File Storage](#image--file-storage) below
- **Payments** — Stripe (production-ready) - **Payments** — Stripe (production-ready)
- **Email** — SMTP (Gmail, Outlook, SendGrid, etc.) - **Email** — SMTP (Gmail, Outlook, SendGrid, etc.)
- **Design** — SVG-based mockups, Canvas for rendering - **Design** — Fabric.js/Konva canvas for the design tool, SVG-based product mockups
## Quick Start ## Quick Start
@@ -80,7 +81,7 @@ A complete self-hosted e-commerce platform for personalized products (apparel, d
## Configuration ## Configuration
**Essential (.env):** **Essential (.env):**
- `DATABASE_URL` — SQLite path (default: `file:./dev.db`) - `DATABASE_URL` — PostgreSQL connection string
- `ADMIN_USER` / `ADMIN_PASSWORD` — Admin login credentials (⚠️ change before deployment) - `ADMIN_USER` / `ADMIN_PASSWORD` — Admin login credentials (⚠️ change before deployment)
- `STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET` — Stripe payment keys - `STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET` — Stripe payment keys
@@ -103,11 +104,18 @@ A product can appear on both catalogs simultaneously.
Prices stored in **GBP pence** internally. Customer-facing header currency selector (£/$/€) changes display only — no actual conversion happens until checkout is configured for it. Prices stored in **GBP pence** internally. Customer-facing header currency selector (£/$/€) changes display only — no actual conversion happens until checkout is configured for it.
### Cart Storage ### Cart Storage
Cart persists to **IndexedDB**, not localStorage, because design items carry large image files. IndexedDB has no 5-10MB limit like localStorage. Cart persists to **IndexedDB**, not localStorage, since it comfortably holds the design tool's cart items without the ~5-10MB ceiling localStorage imposes.
### Color Names ### Color Names
Colors display as friendly names to customers (Navy, Red, Forest Green) instead of hex codes. Admin still sees hex for reference. Colors display as friendly names to customers (Navy, Red, Forest Green) instead of hex codes. Admin still sees hex for reference.
### Image & File Storage
Every uploaded image or document (product photos, gallery photos, custom-request photos, design-approval previews, purchase invoices, expense receipts, images added inside the design tool) is saved as a real file on disk under `public/uploads/<category>/<yyyy>/<mm>/<uuid>.<ext>`, compressed and resized on the way in with `sharp`. Database columns only ever hold the resulting short URL (e.g. `/uploads/gallery/2026/07/<uuid>.jpeg`) — never a base64 data URL. PDFs (invoices/receipts) pass through untouched.
This matters operationally: `public/uploads/` is gitignored but lives permanently on the server's disk, so **backups must cover it alongside the database** (see [SETUP_AND_BUILD.md](./SETUP_AND_BUILD.md)). No web server config is needed to serve these files — Next.js serves everything under `public/` automatically.
See `src/lib/storage.ts` for the upload/compression helper and its per-category presets.
## Accounting Features ## Accounting Features
### Financial Reports ### Financial Reports
+65 -22
View File
@@ -7,10 +7,11 @@ This document covers everything needed to develop, build, and deploy Craft2Print
1. [Local Development Setup](#local-development-setup) 1. [Local Development Setup](#local-development-setup)
2. [Configuration Reference](#configuration-reference) 2. [Configuration Reference](#configuration-reference)
3. [Database Management](#database-management) 3. [Database Management](#database-management)
4. [Feature Setup](#feature-setup) 4. [Image & File Storage](#image--file-storage)
5. [Building for Production](#building-for-production) 5. [Feature Setup](#feature-setup)
6. [Deployment](#deployment) 6. [Building for Production](#building-for-production)
7. [Troubleshooting](#troubleshooting) 7. [Deployment](#deployment)
8. [Troubleshooting](#troubleshooting)
--- ---
@@ -25,6 +26,8 @@ node -v # Check your version
If not installed, download from https://nodejs.org (LTS version recommended). If not installed, download from https://nodejs.org (LTS version recommended).
**PostgreSQL** — the app connects to a Postgres database (`prisma/schema.prisma`'s `datasource` is fixed to `postgresql`). Install it locally (https://www.postgresql.org/download/) or point `DATABASE_URL` at any reachable Postgres instance (a Docker container, a managed database, another machine on your network).
### Step 1: Clone and Install ### Step 1: Clone and Install
```bash ```bash
@@ -46,7 +49,7 @@ cp .env.example .env
Edit `.env` and configure at minimum: Edit `.env` and configure at minimum:
```env ```env
DATABASE_URL="file:./dev.db" DATABASE_URL="postgresql://user:password@localhost:5432/craft2prints?schema=public"
NEXT_PUBLIC_SITE_URL="http://localhost:3000" NEXT_PUBLIC_SITE_URL="http://localhost:3000"
ADMIN_USER="admin" ADMIN_USER="admin"
ADMIN_PASSWORD="changeme" ADMIN_PASSWORD="changeme"
@@ -62,7 +65,8 @@ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
### Step 3: Database Setup ### Step 3: Database Setup
```bash ```bash
# Create SQLite database and run migrations # Create the database (make sure the DB named in DATABASE_URL already exists — e.g. `createdb craft2prints`)
# then sync the schema to it
npm run db:push npm run db:push
# Load starter products # Load starter products
@@ -87,7 +91,7 @@ Open http://localhost:3000 in your browser.
| Variable | Description | Example | | Variable | Description | Example |
|----------|-------------|---------| |----------|-------------|---------|
| `DATABASE_URL` | SQLite database path | `file:./dev.db` | | `DATABASE_URL` | PostgreSQL connection string | `postgresql://user:pass@host:5432/craft2prints?schema=public` |
| `ADMIN_USER` | Admin login username | `admin` | | `ADMIN_USER` | Admin login username | `admin` |
| `ADMIN_PASSWORD` | Admin login password | `changeme` | | `ADMIN_PASSWORD` | Admin login password | `changeme` |
| `ADMIN_SESSION_SECRET` | Session signing key (random) | 32-byte hex string | | `ADMIN_SESSION_SECRET` | Session signing key (random) | 32-byte hex string |
@@ -147,17 +151,18 @@ NEXT_PUBLIC_SITE_URL="http://localhost:3000" # For design approval links
## Database Management ## Database Management
The app uses `prisma db push` (schema-driven sync), not versioned `prisma migrate` files — `prisma/schema.prisma` is the single source of truth for the database shape.
### Common Tasks ### Common Tasks
**Create database and migrations:** **Sync the schema to the database:**
```bash ```bash
npm run db:push npm run db:push
``` ```
**Reset database (⚠️ deletes all data):** **Reset database (⚠️ deletes all data):**
```bash ```bash
rm dev.db npx prisma db push --force-reset
npm run db:push
npm run db:seed npm run db:seed
``` ```
@@ -165,6 +170,8 @@ npm run db:seed
1. Edit `prisma/schema.prisma` 1. Edit `prisma/schema.prisma`
2. Run `npm run db:push` to sync 2. Run `npm run db:push` to sync
Because there's no migration history, renaming or dropping a column that already holds data is destructive — `db push` will refuse (or require `--accept-data-loss`) rather than silently drop it. For a column that needs to change shape on a live database, do it in three steps: add the new column alongside the old one (non-destructive `db push`), run a one-off script to copy/transform the data into it, update the app code to use the new column, then drop the old column in a second `db push` once you've confirmed nothing still depends on it.
**Seed starter products:** **Seed starter products:**
```bash ```bash
npm run db:seed npm run db:seed
@@ -174,11 +181,47 @@ Updating the seed is safe — it only touches the 6 starter products by their sl
**Backup database:** **Backup database:**
```bash ```bash
cp dev.db dev.db.backup-$(date +%Y%m%d-%H%M%S) pg_dump -h <host> -p <port> -U <user> -d craft2prints -F p -f "database-backups/craft2prints_$(date +%Y-%m-%d_%H-%M-%S).sql"
``` ```
`backup-database.ps1` in the repo root automates this — check its `$dbHost`/`$dbPort`/`$dbUser` match your actual `DATABASE_URL` before relying on it (it defaults to `localhost:5432`, which may not be where your database actually lives). **Backups only cover the database** — uploaded images/files live on disk under `public/uploads/` and need their own backup (see [Image & File Storage](#image--file-storage)).
--- ---
## Image & File Storage
Every uploaded image or document — gallery photos, product photos, custom-request photos, design proofs, design-approval previews, order design previews, purchase invoices, expense receipts, and images added inside the design tool — is saved as a **file on disk**, not as base64 text in the database.
### How it works
1. A file arrives either as a `multipart/form-data` upload (admin forms, customer photo submissions) or as a base64 data URL generated client-side by the design tool's canvas export (`stage.toDataURL()`/`canvas.toDataURL()`).
2. `src/lib/storage.ts` decodes/receives it, and — unless it's a PDF — runs it through [`sharp`](https://sharp.pixelplumbing.com/) to resize (capped per category, never upscaled) and re-encode it (JPEG for photos, lossless PNG where transparency must be preserved, e.g. print-ready design previews).
3. The result is written to `public/uploads/<category>/<yyyy>/<mm>/<uuid>.<ext>` and the database column stores only the resulting path, e.g. `/uploads/gallery/2026/07/3f9c1e2a-....jpeg`.
Categories and their presets (`IMAGE_PRESETS` in `src/lib/storage.ts`):
| Category | Used for | Format | Max width |
|---|---|---|---|
| `gallery` | Public gallery photos | JPEG | 2000px |
| `products` | Product photos (main + per-color) | JPEG, or PNG if the source has transparency | 2000px |
| `custom-requests` | Customer-submitted photos | JPEG | 2000px |
| `proofs` | Admin-uploaded design proofs | JPEG | 2000px |
| `invoices` / `receipts` | Purchase/expense attachments | JPEG, or passthrough if PDF | 2000px |
| `designs` | Print-ready design previews (transparent) | PNG (lossless) | 4000px |
| `design-placements` | Design-on-product reference composites | JPEG | 2000px |
| `design-uploads` | Photos a customer adds inside the design tool | JPEG, or PNG if transparent | 3000px |
### Why this matters operationally
- **`public/uploads/` is gitignored but lives permanently on the server's disk.** It is not part of the database and is not covered by a `pg_dump` backup — back it up separately (a simple `rsync`/copy of the directory alongside your database backup schedule is enough).
- **No web server configuration is needed.** Next.js serves everything under `public/` automatically, in both `next dev` and `next start` — the same mechanism that already serves `public/logo.png`.
- **On a fresh server, make sure the Node process can write to `public/uploads/`** (it's created automatically on first upload, but the parent directory must be writable).
- **Uploads endpoint for the design tool**: images added inside the design canvas are uploaded via `POST /api/design-uploads` as soon as they're added, rather than embedded as base64 in the design's saved JSON — keeping cart/order/design-approval records small regardless of how many images a customer adds.
### Troubleshooting
**"sharp module could not be loaded" / native binding errors on the server**
Sharp ships platform-specific native bindings — never copy `node_modules` between machines (e.g. from a Windows dev box to a Linux server). Run a fresh `npm install` on the target machine (the standard deploy flow below already does this).
## Feature Setup ## Feature Setup
### Stripe Payments ### Stripe Payments
@@ -274,7 +317,8 @@ MAIL_FROM="Craft2Prints <noreply@yourdomain.com>"
**Hardware Requirements:** **Hardware Requirements:**
- Server with Node.js 18+ support - Server with Node.js 18+ support
- 2GB+ RAM (more for concurrent traffic) - 2GB+ RAM (more for concurrent traffic)
- SQLite storage, or PostgreSQL for larger deployments - A reachable PostgreSQL database (local install, Docker container, or managed service)
- Persistent disk for `public/uploads/` — uploaded images/files live here, not in the database (see [Image & File Storage](#image--file-storage))
**Setup:** **Setup:**
@@ -366,8 +410,9 @@ CMD ["npm", "start"]
```bash ```bash
docker build -t craft2prints . docker build -t craft2prints .
docker run -p 3000:3000 --env-file .env craft2prints docker run -p 3000:3000 --env-file .env -v craft2prints-uploads:/app/public/uploads craft2prints
``` ```
The `-v` volume mount is required — without it, `public/uploads/` lives inside the container's writable layer and every uploaded image/file is lost when the container is recreated.
### Post-Deployment Checklist ### Post-Deployment Checklist
@@ -379,6 +424,7 @@ docker run -p 3000:3000 --env-file .env craft2prints
- [ ] Email SMTP tested (send test order confirmation) - [ ] Email SMTP tested (send test order confirmation)
- [ ] CAPTCHA working on public forms - [ ] CAPTCHA working on public forms
- [ ] Database backups scheduled - [ ] Database backups scheduled
- [ ] `public/uploads/` backups scheduled (separate from the database — see [Image & File Storage](#image--file-storage))
- [ ] Log monitoring set up - [ ] Log monitoring set up
- [ ] Rate limiting verified - [ ] Rate limiting verified
- [ ] Staging environment created for testing - [ ] Staging environment created for testing
@@ -406,10 +452,9 @@ npm run dev -- -p 3001 # Use port 3001 instead
npm run db:seed # Load starter products npm run db:seed # Load starter products
``` ```
**Database corrupted** **Database out of sync / corrupted state**
```bash ```bash
rm dev.db npx prisma db push --force-reset # ⚠️ drops and recreates all tables
npm run db:push
npm run db:seed npm run db:seed
``` ```
@@ -434,10 +479,8 @@ node -e "const smtp = require('nodemailer'); console.log(JSON.stringify(process.
**Database connection issues** **Database connection issues**
```bash ```bash
# Test connection # Test connection and list tables
psql $DATABASE_URL # If PostgreSQL psql $DATABASE_URL -c '\dt'
# Or
sqlite3 dev.db ".tables" # If SQLite
``` ```
--- ---
@@ -451,7 +494,7 @@ sqlite3 dev.db ".tables" # If SQLite
- Check payment processing (Stripe dashboard) - Check payment processing (Stripe dashboard)
**Weekly:** **Weekly:**
- Backup database - Backup database and `public/uploads/`
- Review admin activity logs - Review admin activity logs
- Check storage usage - Check storage usage
@@ -521,4 +564,4 @@ npm run build
--- ---
*Last updated: 2025-03-17* *Last updated: 2026-07-22*
+1 -1
View File
@@ -1,7 +1,7 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
serverExternalPackages: ['@prisma/client'], serverExternalPackages: ['@prisma/client', 'sharp'],
experimental: { experimental: {
// Invoice uploads (phone photos/PDFs) and product photo uploads go through // Invoice uploads (phone photos/PDFs) and product photo uploads go through
// server actions — the 1MB default rejects typical phone photos. // server actions — the 1MB default rejects typical phone photos.
+721 -135
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -24,6 +24,7 @@
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-konva": "18.2.10", "react-konva": "18.2.10",
"sharp": "^0.35.3",
"stripe": "16.2.0", "stripe": "16.2.0",
"tesseract.js": "^7.0.0", "tesseract.js": "^7.0.0",
"uuid": "9.0.1", "uuid": "9.0.1",
+5 -20
View File
@@ -72,21 +72,6 @@ model Product {
manualSaleItems ManualSaleItem[] manualSaleItems ManualSaleItem[]
stockLevels StockLevel[] stockLevels StockLevel[]
purchaseItems PurchaseItem[] purchaseItems PurchaseItem[]
productImages ProductImage[]
}
// Bulk-uploaded product images with auto-detected colors. Supports any product type
// (tees, hoodies, mugs, etc.). User uploads a folder, colors are extracted, then saved
// as color-swatch references for the product's color palette.
model ProductImage {
id String @id @default(cuid())
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
color String // hex color detected from the image, e.g. "#1EA7E0"
imageDataUrl String // the uploaded image itself as a data URL
createdAt DateTime @default(now())
@@unique([productId, color])
} }
// Quantity on hand for one product/colour/size combination. Purchases increase // Quantity on hand for one product/colour/size combination. Purchases increase
@@ -114,7 +99,7 @@ model Purchase {
id String @id @default(cuid()) id String @id @default(cuid())
supplierName String supplierName String
purchasedAt DateTime @default(now()) // invoice date, editable — not entry time purchasedAt DateTime @default(now()) // invoice date, editable — not entry time
invoiceDataUrl String? // the uploaded invoice itself, as a data URL invoiceUrl String? // disk path under /uploads/invoices/...
invoiceFileName String? invoiceFileName String?
total Int // pence — sum of line totals, recomputed server-side total Int // pence — sum of line totals, recomputed server-side
notes String? notes String?
@@ -147,7 +132,7 @@ model Expense {
description String description String
category String @default("Stock purchases") // "Stock purchases", "Postage", others as user enters them category String @default("Stock purchases") // "Stock purchases", "Postage", others as user enters them
amount Int // pence amount Int // pence
receiptDataUrl String? // uploaded receipt as data URL receiptUrl String? // disk path under /uploads/receipts/...
receiptFileName String? receiptFileName String?
notes String? notes String?
purchaseId String? @unique // null = standalone expense; set = auto-created from a Purchase (one-to-one) purchaseId String? @unique // null = standalone expense; set = auto-created from a Purchase (one-to-one)
@@ -167,7 +152,7 @@ model DesignProof {
color String color String
quantity Int @default(1) quantity Int @default(1)
unitPrice Int // cents — defaults to the product's base price if not overridden unitPrice Int // cents — defaults to the product's base price if not overridden
imageDataUrl String // the finished design image you uploaded, shown as-is to the customer imageUrl String // disk path under /uploads/proofs/...
note String? // optional message shown alongside the design note String? // optional message shown alongside the design
status String @default("PENDING") // PENDING | APPROVED status String @default("PENDING") // PENDING | APPROVED
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -333,7 +318,7 @@ model CustomRequest {
customerName String customerName String
customerEmail String customerEmail String
customerPhone String? customerPhone String?
imageDataUrl String // the photo they want us to work with imageUrl String // disk path under /uploads/custom-requests/...
note String? note String?
status String @default("NEW") // NEW | DONE status String @default("NEW") // NEW | DONE
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -430,7 +415,7 @@ model GalleryCategory {
model GalleryPhoto { model GalleryPhoto {
id String @id @default(cuid()) id String @id @default(cuid())
imageDataUrl String // base64 data URL imageUrl String // disk path under /uploads/gallery/...
caption String? caption String?
categoryId String? // nullable — a photo can be uncategorized categoryId String? // nullable — a photo can be uncategorized
category GalleryCategory? @relation(fields: [categoryId], references: [id]) category GalleryCategory? @relation(fields: [categoryId], references: [id])
+3 -2
View File
@@ -10,11 +10,12 @@ const ERROR_MESSAGES: Record<string, string> = {
invalid_current: 'Current password is incorrect.', invalid_current: 'Current password is incorrect.',
}; };
export default async function ChangePasswordPage({ searchParams }: { searchParams: { error?: string } }) { export default async function ChangePasswordPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const { error: errorParam } = await searchParams;
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) redirect('/account/login'); if (!customer) redirect('/account/login');
const error = searchParams.error ? ERROR_MESSAGES[searchParams.error] : null; const error = errorParam ? ERROR_MESSAGES[errorParam] : null;
return ( return (
<div className="mx-auto max-w-2xl px-6 py-12"> <div className="mx-auto max-w-2xl px-6 py-12">
+3 -2
View File
@@ -1,8 +1,9 @@
import Link from 'next/link'; import Link from 'next/link';
import { requestPasswordReset } from '../actions'; import { requestPasswordReset } from '../actions';
export default function ForgotPasswordPage({ searchParams }: { searchParams: { sent?: string } }) { export default async function ForgotPasswordPage({ searchParams }: { searchParams: Promise<{ sent?: string }> }) {
const sent = searchParams.sent === '1'; const params = await searchParams;
const sent = params.sent === '1';
return ( return (
<div className="mx-auto max-w-md px-6 py-16"> <div className="mx-auto max-w-md px-6 py-16">
+4 -3
View File
@@ -6,9 +6,10 @@ const ERROR_MESSAGES: Record<string, string> = {
rate_limited: 'Too many attempts — please wait a few minutes and try again.', rate_limited: 'Too many attempts — please wait a few minutes and try again.',
}; };
export default function LoginPage({ searchParams }: { searchParams: { error?: string; next?: string } }) { export default async function LoginPage({ searchParams }: { searchParams: Promise<{ error?: string; next?: string }> }) {
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null; const params = await searchParams;
const next = searchParams.next ?? '/account'; const error = params.error ? (ERROR_MESSAGES[params.error] ?? 'Something went wrong.') : null;
const next = params.next ?? '/account';
return ( return (
<div className="mx-auto max-w-md px-6 py-16"> <div className="mx-auto max-w-md px-6 py-16">
+3 -2
View File
@@ -21,12 +21,13 @@ function statusClasses(status: string, trackingNumber?: string | null) {
return 'bg-splash-orange/10 text-splash-orange'; return 'bg-splash-orange/10 text-splash-orange';
} }
export default async function AccountOrderDetailPage({ params }: { params: { id: string } }) { export default async function AccountOrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) redirect('/account/login'); if (!customer) redirect('/account/login');
const order = await prisma.order.findUnique({ const order = await prisma.order.findUnique({
where: { id: params.id }, where: { id },
include: { items: true }, include: { items: true },
}); });
if (!order || order.customerId !== customer.id) notFound(); if (!order || order.customerId !== customer.id) notFound();
+3 -2
View File
@@ -29,11 +29,12 @@ const SUCCESS_MESSAGES: Record<string, string> = {
password_changed: 'Password updated successfully.', password_changed: 'Password updated successfully.',
}; };
export default async function AccountPage({ searchParams }: { searchParams: { success?: string } }) { export default async function AccountPage({ searchParams }: { searchParams: Promise<{ success?: string }> }) {
const { success: successParam } = await searchParams;
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) redirect('/account/login'); if (!customer) redirect('/account/login');
const success = searchParams.success ? SUCCESS_MESSAGES[searchParams.success] : null; const success = successParam ? SUCCESS_MESSAGES[successParam] : null;
const orders = await prisma.order.findMany({ const orders = await prisma.order.findMany({
where: { customerId: customer.id }, where: { customerId: customer.id },
+4 -3
View File
@@ -10,9 +10,10 @@ const ERROR_MESSAGES: Record<string, string> = {
captcha: 'CAPTCHA verification failed — please try again.', captcha: 'CAPTCHA verification failed — please try again.',
}; };
export default function RegisterPage({ searchParams }: { searchParams: { error?: string; next?: string } }) { export default async function RegisterPage({ searchParams }: { searchParams: Promise<{ error?: string; next?: string }> }) {
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null; const params = await searchParams;
const next = searchParams.next ?? '/account'; const error = params.error ? (ERROR_MESSAGES[params.error] ?? 'Something went wrong.') : null;
const next = params.next ?? '/account';
return ( return (
<div className="mx-auto max-w-md px-6 py-16"> <div className="mx-auto max-w-md px-6 py-16">
+4 -3
View File
@@ -8,9 +8,10 @@ const ERROR_MESSAGES: Record<string, string> = {
weak: 'Password must be at least 8 characters.', weak: 'Password must be at least 8 characters.',
}; };
export default function ResetPasswordPage({ searchParams }: { searchParams: { token?: string; error?: string } }) { export default async function ResetPasswordPage({ searchParams }: { searchParams: Promise<{ token?: string; error?: string }> }) {
const token = searchParams.token ?? ''; const params = await searchParams;
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null; const token = params.token ?? '';
const error = params.error ? (ERROR_MESSAGES[params.error] ?? 'Something went wrong.') : null;
if (!token) { if (!token) {
return ( return (
@@ -3,12 +3,14 @@ import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav'; import AdminNav from '@/components/AdminNav';
import Price from '@/components/Price'; import Price from '@/components/Price';
import { isPdfUrl } from '@/lib/storage';
export default async function ExpenseDetailPage({ params }: { params: { id: string } }) { export default async function ExpenseDetailPage({ params }: { params: Promise<{ id: string }> }) {
const expense = await prisma.expense.findUnique({ where: { id: params.id } }); const { id } = await params;
const expense = await prisma.expense.findUnique({ where: { id } });
if (!expense) notFound(); if (!expense) notFound();
const isPdf = expense.receiptDataUrl?.startsWith('data:application/pdf'); const isPdf = isPdfUrl(expense.receiptUrl);
return ( return (
<div className="mx-auto max-w-2xl px-6 py-12"> <div className="mx-auto max-w-2xl px-6 py-12">
@@ -30,20 +32,20 @@ export default async function ExpenseDetailPage({ params }: { params: { id: stri
{expense.notes && <p className="border-l-2 border-splash-orange pl-4 text-sm italic">{expense.notes}</p>} {expense.notes && <p className="border-l-2 border-splash-orange pl-4 text-sm italic">{expense.notes}</p>}
</div> </div>
{expense.receiptDataUrl && ( {expense.receiptUrl && (
<div className="mt-8"> <div className="mt-8">
<p className="tag-label mb-3">Receipt{expense.receiptFileName ? `${expense.receiptFileName}` : ''}</p> <p className="tag-label mb-3">Receipt{expense.receiptFileName ? `${expense.receiptFileName}` : ''}</p>
{isPdf ? ( {isPdf ? (
<iframe src={expense.receiptDataUrl} title="Receipt" className="h-[600px] w-full border border-line" /> <iframe src={expense.receiptUrl} title="Receipt" className="h-[600px] w-full border border-line" />
) : ( ) : (
<img <img
src={expense.receiptDataUrl} src={expense.receiptUrl}
alt="Receipt" alt="Receipt"
className="max-h-[600px] w-full border border-line object-contain" className="max-h-[600px] w-full border border-line object-contain"
/> />
)} )}
<a <a
href={expense.receiptDataUrl} href={expense.receiptUrl}
download={expense.receiptFileName ?? 'receipt'} download={expense.receiptFileName ?? 'receipt'}
className="mt-2 inline-block tag-label text-clay hover:text-clay-dark" className="mt-2 inline-block tag-label text-clay hover:text-clay-dark"
> >
+4 -8
View File
@@ -2,6 +2,7 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { saveUploadedFile } from '@/lib/storage';
export async function createExpense(formData: FormData) { export async function createExpense(formData: FormData) {
const dateInput = String(formData.get('date') ?? '').trim(); const dateInput = String(formData.get('date') ?? '').trim();
@@ -15,15 +16,10 @@ export async function createExpense(formData: FormData) {
const amount = Math.max(0, Math.round(Number(amountInput || '0') * 100)); const amount = Math.max(0, Math.round(Number(amountInput || '0') * 100));
if (!amount) throw new Error('Amount must be greater than 0.'); if (!amount) throw new Error('Amount must be greater than 0.');
let receiptDataUrl: string | null = null; let receiptUrl: string | null = null;
let receiptFileName: string | null = null; let receiptFileName: string | null = null;
if (receipt && receipt.size > 0) { if (receipt && receipt.size > 0) {
if (receipt.size > 8 * 1024 * 1024) { receiptUrl = await saveUploadedFile(receipt, 'receipts', { maxBytes: 8 * 1024 * 1024, allowPdf: true });
throw new Error('Receipt file is too large — keep it under 8 MB.');
}
const bytes = Buffer.from(await receipt.arrayBuffer());
const mimeType = receipt.type || 'application/octet-stream';
receiptDataUrl = `data:${mimeType};base64,${bytes.toString('base64')}`;
receiptFileName = receipt.name || 'receipt'; receiptFileName = receipt.name || 'receipt';
} }
@@ -33,7 +29,7 @@ export async function createExpense(formData: FormData) {
description, description,
category, category,
amount, amount,
receiptDataUrl, receiptUrl,
receiptFileName, receiptFileName,
notes: notes || null, notes: notes || null,
}, },
+1 -1
View File
@@ -44,7 +44,7 @@ export default async function ExpensesPage() {
<span className="w-20 shrink-0 text-right font-mono text-sm"> <span className="w-20 shrink-0 text-right font-mono text-sm">
<Price cents={e.amount} /> <Price cents={e.amount} />
</span> </span>
{e.receiptDataUrl && ( {e.receiptUrl && (
<Link href={`/admin/accounts/expenses/${e.id}`} className="tag-label text-muted hover:text-ink"> <Link href={`/admin/accounts/expenses/${e.id}`} className="tag-label text-muted hover:text-ink">
View View
</Link> </Link>
+10 -9
View File
@@ -26,12 +26,13 @@ type LedgerEntry = {
export default async function AccountsPage({ export default async function AccountsPage({
searchParams, searchParams,
}: { }: {
searchParams: { type?: string; from?: string; to?: string }; searchParams: Promise<{ type?: string; from?: string; to?: string }>;
}) { }) {
const type = searchParams.type === 'web' || searchParams.type === 'manual' ? searchParams.type : 'all'; const sp = await searchParams;
const from = searchParams.from ? new Date(searchParams.from) : null; const type = sp.type === 'web' || sp.type === 'manual' ? sp.type : 'all';
const from = sp.from ? new Date(sp.from) : null;
// Include the whole "to" day, not just its midnight. // Include the whole "to" day, not just its midnight.
const to = searchParams.to ? new Date(`${searchParams.to}T23:59:59`) : null; const to = sp.to ? new Date(`${sp.to}T23:59:59`) : null;
let orders: any[] = []; let orders: any[] = [];
let manualSales: any[] = []; let manualSales: any[] = [];
@@ -88,8 +89,8 @@ export default async function AccountsPage({
const filterHref = (t: string) => { const filterHref = (t: string) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (t !== 'all') params.set('type', t); if (t !== 'all') params.set('type', t);
if (searchParams.from) params.set('from', searchParams.from); if (sp.from) params.set('from', sp.from);
if (searchParams.to) params.set('to', searchParams.to); if (sp.to) params.set('to', sp.to);
const qs = params.toString(); const qs = params.toString();
return `/admin/accounts${qs ? `?${qs}` : ''}`; return `/admin/accounts${qs ? `?${qs}` : ''}`;
}; };
@@ -160,7 +161,7 @@ export default async function AccountsPage({
<input <input
type="date" type="date"
name="from" name="from"
defaultValue={searchParams.from ?? ''} defaultValue={sp.from ?? ''}
className="border border-line bg-paper px-2 py-1.5" className="border border-line bg-paper px-2 py-1.5"
/> />
</label> </label>
@@ -169,14 +170,14 @@ export default async function AccountsPage({
<input <input
type="date" type="date"
name="to" name="to"
defaultValue={searchParams.to ?? ''} defaultValue={sp.to ?? ''}
className="border border-line bg-paper px-2 py-1.5" className="border border-line bg-paper px-2 py-1.5"
/> />
</label> </label>
<button type="submit" className="border border-line px-3 py-1.5 hover:border-clay hover:text-clay"> <button type="submit" className="border border-line px-3 py-1.5 hover:border-clay hover:text-clay">
Apply Apply
</button> </button>
{(searchParams.from || searchParams.to) && ( {(sp.from || sp.to) && (
<Link href={filterHref(type)} className="tag-label text-muted hover:text-ink"> <Link href={filterHref(type)} className="tag-label text-muted hover:text-ink">
Clear dates Clear dates
</Link> </Link>
@@ -3,15 +3,17 @@ import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav'; import AdminNav from '@/components/AdminNav';
import Price from '@/components/Price'; import Price from '@/components/Price';
import { isPdfUrl } from '@/lib/storage';
export default async function PurchaseDetailPage({ params }: { params: { id: string } }) { export default async function PurchaseDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const purchase = await prisma.purchase.findUnique({ const purchase = await prisma.purchase.findUnique({
where: { id: params.id }, where: { id },
include: { items: { include: { product: true } } }, include: { items: { include: { product: true } } },
}); });
if (!purchase) notFound(); if (!purchase) notFound();
const isPdf = purchase.invoiceDataUrl?.startsWith('data:application/pdf'); const isPdf = isPdfUrl(purchase.invoiceUrl);
return ( return (
<div className="mx-auto max-w-2xl px-6 py-12"> <div className="mx-auto max-w-2xl px-6 py-12">
@@ -50,20 +52,20 @@ export default async function PurchaseDetailPage({ params }: { params: { id: str
{purchase.notes && <div className="mt-6 border-l-2 border-splash-orange pl-4 text-sm italic">{purchase.notes}</div>} {purchase.notes && <div className="mt-6 border-l-2 border-splash-orange pl-4 text-sm italic">{purchase.notes}</div>}
{purchase.invoiceDataUrl && ( {purchase.invoiceUrl && (
<div className="mt-8"> <div className="mt-8">
<p className="tag-label mb-3">Invoice{purchase.invoiceFileName ? `${purchase.invoiceFileName}` : ''}</p> <p className="tag-label mb-3">Invoice{purchase.invoiceFileName ? `${purchase.invoiceFileName}` : ''}</p>
{isPdf ? ( {isPdf ? (
<iframe src={purchase.invoiceDataUrl} title="Invoice" className="h-[600px] w-full border border-line" /> <iframe src={purchase.invoiceUrl} title="Invoice" className="h-[600px] w-full border border-line" />
) : ( ) : (
<img <img
src={purchase.invoiceDataUrl} src={purchase.invoiceUrl}
alt="Invoice" alt="Invoice"
className="max-h-[600px] w-full border border-line object-contain" className="max-h-[600px] w-full border border-line object-contain"
/> />
)} )}
<a <a
href={purchase.invoiceDataUrl} href={purchase.invoiceUrl}
download={purchase.invoiceFileName ?? 'invoice'} download={purchase.invoiceFileName ?? 'invoice'}
className="mt-2 inline-block tag-label text-clay hover:text-clay-dark" className="mt-2 inline-block tag-label text-clay hover:text-clay-dark"
> >
+4 -8
View File
@@ -4,6 +4,7 @@ import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { applyStockMovement } from '@/lib/stock'; import { applyStockMovement } from '@/lib/stock';
import { logFinancialChange } from '@/lib/financialAudit'; import { logFinancialChange } from '@/lib/financialAudit';
import { saveUploadedFile } from '@/lib/storage';
export type PurchaseItemInput = { export type PurchaseItemInput = {
productId: string | null; // null = loose material line (ink, packaging…) productId: string | null; // null = loose material line (ink, packaging…)
@@ -42,15 +43,10 @@ export async function createPurchase(formData: FormData) {
} }
} }
let invoiceDataUrl: string | null = null; let invoiceUrl: string | null = null;
let invoiceFileName: string | null = null; let invoiceFileName: string | null = null;
if (invoice && invoice.size > 0) { if (invoice && invoice.size > 0) {
if (invoice.size > MAX_INVOICE_BYTES) { invoiceUrl = await saveUploadedFile(invoice, 'invoices', { maxBytes: MAX_INVOICE_BYTES, allowPdf: true });
throw new Error('Invoice file is too large — keep it under 8 MB.');
}
const bytes = Buffer.from(await invoice.arrayBuffer());
const mimeType = invoice.type || 'application/octet-stream';
invoiceDataUrl = `data:${mimeType};base64,${bytes.toString('base64')}`;
invoiceFileName = invoice.name || 'invoice'; invoiceFileName = invoice.name || 'invoice';
} }
@@ -62,7 +58,7 @@ export async function createPurchase(formData: FormData) {
data: { data: {
supplierName, supplierName,
purchasedAt: purchaseDate, purchasedAt: purchaseDate,
invoiceDataUrl, invoiceUrl,
invoiceFileName, invoiceFileName,
total, total,
notes: notes || null, notes: notes || null,
+1 -1
View File
@@ -44,7 +44,7 @@ export default async function PurchasesPage() {
{p.items.map((i) => `${i.quantity}× ${i.description}`).join(', ')} {p.items.map((i) => `${i.quantity}× ${i.description}`).join(', ')}
</p> </p>
</div> </div>
{p.invoiceDataUrl && <span className="tag-label hidden text-muted sm:inline">Invoice attached</span>} {p.invoiceUrl && <span className="tag-label hidden text-muted sm:inline">Invoice attached</span>}
<span className="w-20 shrink-0 text-right font-mono text-sm"> <span className="w-20 shrink-0 text-right font-mono text-sm">
<Price cents={p.total} /> <Price cents={p.total} />
</span> </span>
@@ -9,14 +9,14 @@ import ReconciliationClient from './client';
export default async function ReconciliationPage({ export default async function ReconciliationPage({
searchParams, searchParams,
}: { }: {
searchParams: { id?: string }; searchParams: Promise<{ id?: string }>;
}) { }) {
const isAdmin = await isAdminAuthenticated(); const isAdmin = await isAdminAuthenticated();
if (!isAdmin) { if (!isAdmin) {
return <div className="px-6 py-12 text-center">Unauthorized</div>; return <div className="px-6 py-12 text-center">Unauthorized</div>;
} }
const statementId = searchParams.id; const { id: statementId } = await searchParams;
if (!statementId) { if (!statementId) {
// Show list of statements // Show list of statements
+4 -3
View File
@@ -3,12 +3,13 @@ import { prisma } from '@/lib/prisma';
import { updateCategory } from '../../actions'; import { updateCategory } from '../../actions';
import AdminNav from '@/components/AdminNav'; import AdminNav from '@/components/AdminNav';
export default async function EditCategoryPage({ params }: { params: { id: string } }) { export default async function EditCategoryPage({ params }: { params: Promise<{ id: string }> }) {
const category = await prisma.category.findUnique({ where: { id: params.id } }); const { id } = await params;
const category = await prisma.category.findUnique({ where: { id } });
if (!category) notFound(); if (!category) notFound();
const parentCategories = await prisma.category.findMany({ const parentCategories = await prisma.category.findMany({
where: { parentId: null, id: { not: params.id } }, where: { parentId: null, id: { not: id } },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}); });
+3 -2
View File
@@ -3,7 +3,8 @@ import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav'; import AdminNav from '@/components/AdminNav';
import { deleteCategory, updateCategoryVisibility } from './actions'; import { deleteCategory, updateCategoryVisibility } from './actions';
export default async function CategoriesPage({ searchParams }: { searchParams: { error?: string } }) { export default async function CategoriesPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const { error } = await searchParams;
const categories = await prisma.category.findMany({ orderBy: { createdAt: 'asc' } }); const categories = await prisma.category.findMany({ orderBy: { createdAt: 'asc' } });
const counts = await prisma.product.groupBy({ by: ['category'], _count: true }); const counts = await prisma.product.groupBy({ by: ['category'], _count: true });
const countFor = (slug: string) => counts.find((c) => c.category === slug)?._count ?? 0; const countFor = (slug: string) => counts.find((c) => c.category === slug)?._count ?? 0;
@@ -21,7 +22,7 @@ export default async function CategoriesPage({ searchParams }: { searchParams: {
</Link> </Link>
</div> </div>
{searchParams.error === 'in_use' && ( {error === 'in_use' && (
<div className="mt-6 border border-splash-orange bg-splash-orange/5 p-4 text-sm"> <div className="mt-6 border border-splash-orange bg-splash-orange/5 p-4 text-sm">
Can&apos;t delete that category there are still products using it. Move or delete those Can&apos;t delete that category there are still products using it. Move or delete those
products first. products first.
+3 -2
View File
@@ -10,11 +10,12 @@ const ERROR_MESSAGES: Record<string, string> = {
invalid_current: 'Current password is incorrect.', invalid_current: 'Current password is incorrect.',
}; };
export default async function ChangeAdminPasswordPage({ searchParams }: { searchParams: { error?: string } }) { export default async function ChangeAdminPasswordPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const { error: errorParam } = await searchParams;
const isAuthenticated = await isAdminAuthenticated(); const isAuthenticated = await isAdminAuthenticated();
if (!isAuthenticated) redirect('/admin/login'); if (!isAuthenticated) redirect('/admin/login');
const error = searchParams.error ? ERROR_MESSAGES[searchParams.error] : null; const error = errorParam ? ERROR_MESSAGES[errorParam] : null;
return ( return (
<div className="mx-auto max-w-2xl px-6 py-12"> <div className="mx-auto max-w-2xl px-6 py-12">
+3 -2
View File
@@ -3,7 +3,8 @@ import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav'; import AdminNav from '@/components/AdminNav';
import { deleteCollection } from './actions'; import { deleteCollection } from './actions';
export default async function CollectionsPage({ searchParams }: { searchParams: { error?: string } }) { export default async function CollectionsPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const { error } = await searchParams;
const collections = await prisma.collection.findMany({ orderBy: { createdAt: 'asc' } }); const collections = await prisma.collection.findMany({ orderBy: { createdAt: 'asc' } });
const occasions = collections.filter((c) => c.group === 'OCCASION'); const occasions = collections.filter((c) => c.group === 'OCCASION');
const recipients = collections.filter((c) => c.group === 'RECIPIENT'); const recipients = collections.filter((c) => c.group === 'RECIPIENT');
@@ -58,7 +59,7 @@ export default async function CollectionsPage({ searchParams }: { searchParams:
products from each product&apos;s edit page. products from each product&apos;s edit page.
</p> </p>
{searchParams.error === 'in_use' && ( {error === 'in_use' && (
<div className="mt-6 border border-splash-orange bg-splash-orange/5 p-4 text-sm"> <div className="mt-6 border border-splash-orange bg-splash-orange/5 p-4 text-sm">
Can&apos;t delete that there are still products tagged with it. Untag those products Can&apos;t delete that there are still products tagged with it. Untag those products
first. first.
+5 -4
View File
@@ -6,9 +6,10 @@ import ShareButtons from '@/components/ShareButtons';
import DownloadPhotoButton from '@/components/DownloadPhotoButton'; import DownloadPhotoButton from '@/components/DownloadPhotoButton';
import { deleteRequest, markRequestDone, markRequestNew } from '../actions'; import { deleteRequest, markRequestDone, markRequestNew } from '../actions';
export default async function CustomRequestDetailPage({ params }: { params: { id: string } }) { export default async function CustomRequestDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const request = await prisma.customRequest.findUnique({ const request = await prisma.customRequest.findUnique({
where: { id: params.id }, where: { id },
include: { product: true }, include: { product: true },
}); });
if (!request) notFound(); if (!request) notFound();
@@ -33,7 +34,7 @@ export default async function CustomRequestDetailPage({ params }: { params: { id
</div> </div>
<div className="mt-6 border border-line bg-surface p-6"> <div className="mt-6 border border-line bg-surface p-6">
<img src={request.imageDataUrl} alt="Customer's photo" className="mx-auto max-h-96 object-contain" /> <img src={request.imageUrl ?? ''} alt="Customer's photo" className="mx-auto max-h-96 object-contain" />
</div> </div>
{request.note && ( {request.note && (
@@ -54,7 +55,7 @@ export default async function CustomRequestDetailPage({ params }: { params: { id
</div> </div>
<div className="mt-8 flex flex-wrap gap-3"> <div className="mt-8 flex flex-wrap gap-3">
<DownloadPhotoButton photoUrl={request.imageDataUrl} customerName={request.customerName} /> <DownloadPhotoButton photoUrl={request.imageUrl ?? ''} customerName={request.customerName} />
<Link <Link
href={`/admin/custom-requests/${request.id}/start-design`} href={`/admin/custom-requests/${request.id}/start-design`}
@@ -3,9 +3,10 @@ import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav'; import AdminNav from '@/components/AdminNav';
import { startDesignWithProduct } from './actions'; import { startDesignWithProduct } from './actions';
export default async function StartDesignPage({ params }: { params: { id: string } }) { export default async function StartDesignPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const request = await prisma.customRequest.findUnique({ const request = await prisma.customRequest.findUnique({
where: { id: params.id }, where: { id },
include: { product: true }, include: { product: true },
}); });
if (!request) notFound(); if (!request) notFound();
@@ -22,7 +23,7 @@ export default async function StartDesignPage({ params }: { params: { id: string
<p className="mt-2 text-sm text-muted">Select which product to design this photo on</p> <p className="mt-2 text-sm text-muted">Select which product to design this photo on</p>
<div className="mt-6 border border-line bg-surface p-6"> <div className="mt-6 border border-line bg-surface p-6">
<img src={request.imageDataUrl} alt="Customer's photo" className="mx-auto max-h-64 object-contain" /> <img src={request.imageUrl ?? ''} alt="Customer's photo" className="mx-auto max-h-64 object-contain" />
</div> </div>
<div className="mt-6 border border-line bg-surface p-6"> <div className="mt-6 border border-line bg-surface p-6">
+1 -1
View File
@@ -24,7 +24,7 @@ export default async function CustomRequestsPage() {
{requests.map((r) => ( {requests.map((r) => (
<div key={r.id} className="flex items-center gap-4 py-4 hover:bg-surface"> <div key={r.id} className="flex items-center gap-4 py-4 hover:bg-surface">
<Link href={`/admin/custom-requests/${r.id}`} className="flex flex-1 items-center gap-4"> <Link href={`/admin/custom-requests/${r.id}`} className="flex flex-1 items-center gap-4">
<img src={r.imageDataUrl} alt="" className="h-14 w-14 border border-line object-cover" /> <img src={r.imageUrl ?? ''} alt="" className="h-14 w-14 border border-line object-cover" />
<div className="flex-1"> <div className="flex-1">
<p className="font-medium">{r.customerName}</p> <p className="font-medium">{r.customerName}</p>
<p className="truncate text-sm text-muted">{r.customerEmail}</p> <p className="truncate text-sm text-muted">{r.customerEmail}</p>
+5 -4
View File
@@ -12,8 +12,9 @@ const SOURCE_LABELS: Record<string, string> = {
export default async function CustomersPage({ export default async function CustomersPage({
searchParams, searchParams,
}: { }: {
searchParams: { sent?: string; failed?: string }; searchParams: Promise<{ sent?: string; failed?: string }>;
}) { }) {
const params = await searchParams;
let entries: any[] = []; let entries: any[] = [];
try { try {
entries = await prisma.mailingListEntry.findMany(); entries = await prisma.mailingListEntry.findMany();
@@ -32,10 +33,10 @@ export default async function CustomersPage({
guest, in one list. Untick Subscribed to leave someone out of future mailings. guest, in one list. Untick Subscribed to leave someone out of future mailings.
</p> </p>
{searchParams.sent !== undefined && ( {params.sent !== undefined && (
<div className="mt-6 border border-splash-blue bg-splash-blue/5 p-4 text-sm"> <div className="mt-6 border border-splash-blue bg-splash-blue/5 p-4 text-sm">
Sent to {searchParams.sent} recipient{searchParams.sent === '1' ? '' : 's'}. Sent to {params.sent} recipient{params.sent === '1' ? '' : 's'}.
{Number(searchParams.failed) > 0 && ` ${searchParams.failed} failed to send.`} {Number(params.failed) > 0 && ` ${params.failed} failed to send.`}
</div> </div>
)} )}
+9 -8
View File
@@ -6,17 +6,18 @@ import Link from 'next/link';
export default async function FinancialAuditPage({ export default async function FinancialAuditPage({
searchParams, searchParams,
}: { }: {
searchParams: { type?: string; action?: string; from?: string; to?: string }; searchParams: Promise<{ type?: string; action?: string; from?: string; to?: string }>;
}) { }) {
const isAdmin = await isAdminAuthenticated(); const isAdmin = await isAdminAuthenticated();
if (!isAdmin) { if (!isAdmin) {
return <div className="px-6 py-12 text-center">Unauthorized</div>; return <div className="px-6 py-12 text-center">Unauthorized</div>;
} }
const entityType = searchParams.type as any; const sp = await searchParams;
const action = searchParams.action as any; const entityType = sp.type as any;
const fromDate = searchParams.from ? new Date(searchParams.from) : null; const action = sp.action as any;
const toDate = searchParams.to ? new Date(`${searchParams.to}T23:59:59`) : null; const fromDate = sp.from ? new Date(sp.from) : null;
const toDate = sp.to ? new Date(`${sp.to}T23:59:59`) : null;
const logs = await prisma.financialAuditLog.findMany({ const logs = await prisma.financialAuditLog.findMany({
where: { where: {
@@ -110,7 +111,7 @@ export default async function FinancialAuditPage({
<input <input
type="date" type="date"
name="from" name="from"
defaultValue={searchParams.from ?? ''} defaultValue={sp.from ?? ''}
className="border border-line bg-paper px-3 py-2 text-sm" className="border border-line bg-paper px-3 py-2 text-sm"
placeholder="From" placeholder="From"
/> />
@@ -118,7 +119,7 @@ export default async function FinancialAuditPage({
<input <input
type="date" type="date"
name="to" name="to"
defaultValue={searchParams.to ?? ''} defaultValue={sp.to ?? ''}
className="border border-line bg-paper px-3 py-2 text-sm" className="border border-line bg-paper px-3 py-2 text-sm"
placeholder="To" placeholder="To"
/> />
@@ -130,7 +131,7 @@ export default async function FinancialAuditPage({
Filter Filter
</button> </button>
{(entityType || action || searchParams.from || searchParams.to) && ( {(entityType || action || sp.from || sp.to) && (
<Link href="/admin/financial-audit" className="text-sm text-muted hover:text-ink"> <Link href="/admin/financial-audit" className="text-sm text-muted hover:text-ink">
Clear filters Clear filters
</Link> </Link>
+4 -3
View File
@@ -4,9 +4,10 @@ import AdminNav from '@/components/AdminNav';
import { updateGalleryPhoto } from '../../actions'; import { updateGalleryPhoto } from '../../actions';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
export default async function EditGalleryPhotoPage({ params }: { params: { id: string } }) { export default async function EditGalleryPhotoPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const photo = await prisma.galleryPhoto.findUnique({ const photo = await prisma.galleryPhoto.findUnique({
where: { id: params.id }, where: { id },
include: { category: true }, include: { category: true },
}); });
@@ -25,7 +26,7 @@ export default async function EditGalleryPhotoPage({ params }: { params: { id: s
</p> </p>
<div className="mt-8 flex gap-6"> <div className="mt-8 flex gap-6">
<img src={photo.imageDataUrl} alt="" className="h-32 w-32 border border-line object-cover flex-shrink-0" /> <img src={photo.imageUrl ?? ''} alt="" className="h-32 w-32 border border-line object-cover flex-shrink-0" />
<div className="flex-1 space-y-6"> <div className="flex-1 space-y-6">
<form action={updateGalleryPhoto}> <form action={updateGalleryPhoto}>
<input type="hidden" name="id" value={photo.id} /> <input type="hidden" name="id" value={photo.id} />
+5 -10
View File
@@ -2,6 +2,7 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { saveUploadedFile } from '@/lib/storage';
function slugify(input: string) { function slugify(input: string) {
return input return input
@@ -21,12 +22,6 @@ async function uniqueSlug(base: string) {
return slug; return slug;
} }
async function fileToDataUrl(file: File): Promise<string> {
const bytes = Buffer.from(await file.arrayBuffer());
const mimeType = file.type || 'image/png';
return `data:${mimeType};base64,${bytes.toString('base64')}`;
}
export async function uploadGalleryPhoto(formData: FormData) { export async function uploadGalleryPhoto(formData: FormData) {
const caption = String(formData.get('caption') ?? '').trim(); const caption = String(formData.get('caption') ?? '').trim();
const categoryId = String(formData.get('categoryId') ?? '').trim() || null; const categoryId = String(formData.get('categoryId') ?? '').trim() || null;
@@ -36,11 +31,11 @@ export async function uploadGalleryPhoto(formData: FormData) {
throw new Error('A photo is required.'); throw new Error('A photo is required.');
} }
const imageDataUrl = await fileToDataUrl(file); const imageUrl = await saveUploadedFile(file, 'gallery');
await prisma.galleryPhoto.create({ await prisma.galleryPhoto.create({
data: { data: {
imageDataUrl, imageUrl,
caption: caption || null, caption: caption || null,
categoryId, categoryId,
}, },
@@ -113,10 +108,10 @@ export async function uploadGalleryPhotoBulk(formData: FormData) {
if (file.size === 0) continue; if (file.size === 0) continue;
try { try {
const imageDataUrl = await fileToDataUrl(file); const imageUrl = await saveUploadedFile(file, 'gallery');
const photo = await prisma.galleryPhoto.create({ const photo = await prisma.galleryPhoto.create({
data: { data: {
imageDataUrl, imageUrl,
caption: null, caption: null,
categoryId, categoryId,
}, },
@@ -1,12 +1,13 @@
import AdminNav from '@/components/AdminNav'; import AdminNav from '@/components/AdminNav';
import { createGalleryCategory } from '../../actions'; import { createGalleryCategory } from '../../actions';
export default function NewGalleryCategoryPage({ export default async function NewGalleryCategoryPage({
searchParams, searchParams,
}: { }: {
searchParams: { redirectTo?: string }; searchParams: Promise<{ redirectTo?: string }>;
}) { }) {
const redirectTo = searchParams.redirectTo || '/admin/gallery/categories'; const { redirectTo: redirectToParam } = await searchParams;
const redirectTo = redirectToParam || '/admin/gallery/categories';
return ( return (
<div className="mx-auto max-w-md px-6 py-12"> <div className="mx-auto max-w-md px-6 py-12">
+3 -2
View File
@@ -3,7 +3,8 @@ import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav'; import AdminNav from '@/components/AdminNav';
import { deleteGalleryCategory } from '../actions'; import { deleteGalleryCategory } from '../actions';
export default async function GalleryCategoriesPage({ searchParams }: { searchParams: { error?: string } }) { export default async function GalleryCategoriesPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const { error } = await searchParams;
const categories = await prisma.galleryCategory.findMany({ orderBy: { createdAt: 'asc' } }); const categories = await prisma.galleryCategory.findMany({ orderBy: { createdAt: 'asc' } });
const counts = await prisma.galleryPhoto.groupBy({ by: ['categoryId'], _count: true }); const counts = await prisma.galleryPhoto.groupBy({ by: ['categoryId'], _count: true });
const countFor = (id: string) => counts.find((c) => c.categoryId === id)?._count ?? 0; const countFor = (id: string) => counts.find((c) => c.categoryId === id)?._count ?? 0;
@@ -24,7 +25,7 @@ export default async function GalleryCategoriesPage({ searchParams }: { searchPa
Used to group photos on the public gallery separate from your product categories. Used to group photos on the public gallery separate from your product categories.
</p> </p>
{searchParams.error === 'in_use' && ( {error === 'in_use' && (
<div className="mt-6 border border-splash-orange bg-splash-orange/5 p-4 text-sm"> <div className="mt-6 border border-splash-orange bg-splash-orange/5 p-4 text-sm">
Can&apos;t delete that category there are still photos using it. Move or delete those Can&apos;t delete that category there are still photos using it. Move or delete those
photos first. photos first.
+1 -1
View File
@@ -41,7 +41,7 @@ export default async function AdminGalleryPage() {
<div className="mt-8 divide-y divide-line border-t border-line"> <div className="mt-8 divide-y divide-line border-t border-line">
{photos.map((p) => ( {photos.map((p) => (
<div key={p.id} className="flex items-center gap-4 py-4"> <div key={p.id} className="flex items-center gap-4 py-4">
<img src={p.imageDataUrl} alt="" className="h-16 w-16 border border-line object-cover" /> <img src={p.imageUrl ?? ''} alt="" className="h-16 w-16 border border-line object-cover" />
<div className="flex-1"> <div className="flex-1">
<p className="font-medium">{p.caption || <span className="text-muted">No caption</span>}</p> <p className="font-medium">{p.caption || <span className="text-muted">No caption</span>}</p>
<p className="tag-label mt-0.5"> <p className="tag-label mt-0.5">
+4 -3
View File
@@ -8,9 +8,10 @@ function formatPrice(cents: number) {
return `£${(cents / 100).toFixed(2)}`; return `£${(cents / 100).toFixed(2)}`;
} }
export default async function AdminThreadPage({ params }: { params: { id: string } }) { export default async function AdminThreadPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const proof = await prisma.designProof.findUnique({ const proof = await prisma.designProof.findUnique({
where: { id: params.id }, where: { id },
include: { product: true }, include: { product: true },
}); });
if (!proof) notFound(); if (!proof) notFound();
@@ -20,7 +21,7 @@ export default async function AdminThreadPage({ params }: { params: { id: string
<AdminNav /> <AdminNav />
<div className="flex items-center gap-4 border border-line bg-surface p-4"> <div className="flex items-center gap-4 border border-line bg-surface p-4">
<img src={proof.imageDataUrl} alt="" className="h-16 w-16 border border-line object-cover" /> <img src={proof.imageUrl ?? ''} alt="" className="h-16 w-16 border border-line object-cover" />
<div className="flex-1"> <div className="flex-1">
<p className="font-medium">{proof.customerName || proof.customerEmail}</p> <p className="font-medium">{proof.customerName || proof.customerEmail}</p>
<p className="text-sm text-muted"> <p className="text-sm text-muted">
+1 -1
View File
@@ -32,7 +32,7 @@ export default async function InboxPage() {
href={`/admin/inbox/${p.id}`} href={`/admin/inbox/${p.id}`}
className="flex items-center gap-4 py-4 hover:bg-surface" className="flex items-center gap-4 py-4 hover:bg-surface"
> >
<img src={p.imageDataUrl} alt="" className="h-14 w-14 border border-line object-cover" /> <img src={p.imageUrl ?? ''} alt="" className="h-14 w-14 border border-line object-cover" />
<div className="flex-1"> <div className="flex-1">
<p className="font-medium">{p.customerName || p.customerEmail}</p> <p className="font-medium">{p.customerName || p.customerEmail}</p>
<p className="truncate text-sm text-muted"> <p className="truncate text-sm text-muted">
+3 -2
View File
@@ -8,9 +8,10 @@ function formatPrice(cents: number) {
return `£${(cents / 100).toFixed(2)}`; return `£${(cents / 100).toFixed(2)}`;
} }
export default async function OrderDetailPage({ params }: { params: { id: string } }) { export default async function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const order = await prisma.order.findUnique({ const order = await prisma.order.findUnique({
where: { id: params.id }, where: { id },
include: { items: true }, include: { items: true },
}); });
if (!order) notFound(); if (!order) notFound();
+6 -5
View File
@@ -26,14 +26,15 @@ const SUCCESS_MESSAGES: Record<string, string> = {
password_changed: 'Password updated successfully.', password_changed: 'Password updated successfully.',
}; };
export default async function OrdersPage({ searchParams }: { searchParams: { archived?: string; personalised?: string; success?: string; page?: string } }) { export default async function OrdersPage({ searchParams }: { searchParams: Promise<{ archived?: string; personalised?: string; success?: string; page?: string }> }) {
await autoArchiveOldOrders(); await autoArchiveOldOrders();
const success = searchParams.success ? SUCCESS_MESSAGES[searchParams.success] : null; const sp = await searchParams;
const success = sp.success ? SUCCESS_MESSAGES[sp.success] : null;
const showArchived = searchParams.archived === '1'; const showArchived = sp.archived === '1';
const showOnlyPersonalised = searchParams.personalised === '1'; const showOnlyPersonalised = sp.personalised === '1';
const page = Math.max(1, parseInt(searchParams.page || '1')); const page = Math.max(1, parseInt(sp.page || '1'));
const pageSize = 50; const pageSize = 50;
const skip = (page - 1) * pageSize; const skip = (page - 1) * pageSize;
+7 -12
View File
@@ -3,6 +3,7 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { DEFAULT_PRINT_AREAS } from '@/lib/mockupDefaults'; import { DEFAULT_PRINT_AREAS } from '@/lib/mockupDefaults';
import { saveUploadedFile } from '@/lib/storage';
function slugify(input: string) { function slugify(input: string) {
return input return input
@@ -22,15 +23,9 @@ async function uniqueSlug(base: string) {
return slug; return slug;
} }
async function fileToDataUrl(file: File): Promise<string> {
const bytes = Buffer.from(await file.arrayBuffer());
const mimeType = file.type || 'image/png';
return `data:${mimeType};base64,${bytes.toString('base64')}`;
}
// Reads the per-color photo file inputs the ColorPicker renders (named // Reads the per-color photo file inputs the ColorPicker renders (named
// "colorPhoto__<hexWithoutHash>" for front, "colorPhotoBack__<hexWithoutHash>" for // "colorPhoto__<hexWithoutHash>" for front, "colorPhotoBack__<hexWithoutHash>" for
// back) and builds { hex: dataUrl } for whichever colors actually got a file attached. // back) and builds { hex: url } for whichever colors actually got a file attached.
async function extractColorPhotos( async function extractColorPhotos(
formData: FormData, formData: FormData,
colors: string[], colors: string[],
@@ -41,7 +36,7 @@ async function extractColorPhotos(
for (const hex of colors) { for (const hex of colors) {
const file = formData.get(`${prefix}${hex.replace('#', '')}`) as File | null; const file = formData.get(`${prefix}${hex.replace('#', '')}`) as File | null;
if (file && file.size > 0) { if (file && file.size > 0) {
result[hex] = await fileToDataUrl(file); result[hex] = await saveUploadedFile(file, 'products');
} }
} }
return result; return result;
@@ -103,8 +98,8 @@ export async function createProduct(formData: FormData) {
const weightKgInput = String(formData.get('weightKg') ?? '').trim(); const weightKgInput = String(formData.get('weightKg') ?? '').trim();
const weightGrams = weightKgInput ? Math.round(Number(weightKgInput) * 1000) : null; const weightGrams = weightKgInput ? Math.round(Number(weightKgInput) * 1000) : null;
const imageUrl = imageFile && imageFile.size > 0 ? await fileToDataUrl(imageFile) : null; const imageUrl = imageFile && imageFile.size > 0 ? await saveUploadedFile(imageFile, 'products') : null;
const imageUrlBack = imageBackFile && imageBackFile.size > 0 ? await fileToDataUrl(imageBackFile) : null; const imageUrlBack = imageBackFile && imageBackFile.size > 0 ? await saveUploadedFile(imageBackFile, 'products') : null;
const colorPhotos = await extractColorPhotos(formData, colors, 'front'); const colorPhotos = await extractColorPhotos(formData, colors, 'front');
const colorPhotosBack = await extractColorPhotos(formData, colors, 'back'); const colorPhotosBack = await extractColorPhotos(formData, colors, 'back');
const saleFields = parseSaleFields(formData); const saleFields = parseSaleFields(formData);
@@ -310,7 +305,7 @@ export async function updateProduct(formData: FormData) {
data.imageUrl = null; data.imageUrl = null;
data.printArea = JSON.stringify(DEFAULT_PRINT_AREAS[mockup] ?? DEFAULT_PRINT_AREAS.tshirt); data.printArea = JSON.stringify(DEFAULT_PRINT_AREAS[mockup] ?? DEFAULT_PRINT_AREAS.tshirt);
} else if (imageFile && imageFile.size > 0) { } else if (imageFile && imageFile.size > 0) {
data.imageUrl = await fileToDataUrl(imageFile); data.imageUrl = await saveUploadedFile(imageFile, 'products');
data.printArea = JSON.stringify( data.printArea = JSON.stringify(
hasCustomPrintArea hasCustomPrintArea
? { ? {
@@ -341,7 +336,7 @@ export async function updateProduct(formData: FormData) {
data.imageUrlBack = null; data.imageUrlBack = null;
data.printAreaBack = null; data.printAreaBack = null;
} else if (imageBackFile && imageBackFile.size > 0) { } else if (imageBackFile && imageBackFile.size > 0) {
data.imageUrlBack = await fileToDataUrl(imageBackFile); data.imageUrlBack = await saveUploadedFile(imageBackFile, 'products');
if (hasCustomPrintAreaBack) { if (hasCustomPrintAreaBack) {
data.printAreaBack = JSON.stringify({ data.printAreaBack = JSON.stringify({
xPct: Number(formData.get('printXBack') ?? 0.3), xPct: Number(formData.get('printXBack') ?? 0.3),
+3 -2
View File
@@ -4,9 +4,10 @@ import AdminNav from '@/components/AdminNav';
import PromotionScopePicker from '@/components/PromotionScopePicker'; import PromotionScopePicker from '@/components/PromotionScopePicker';
import { updatePromotion } from '../../actions'; import { updatePromotion } from '../../actions';
export default async function EditPromotionPage({ params }: { params: { id: string } }) { export default async function EditPromotionPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [promotion, products] = await Promise.all([ const [promotion, products] = await Promise.all([
prisma.promotion.findUnique({ where: { id: params.id }, include: { products: { select: { id: true } } } }), prisma.promotion.findUnique({ where: { id }, include: { products: { select: { id: true } } } }),
prisma.product.findMany({ orderBy: { name: 'asc' } }), prisma.product.findMany({ orderBy: { name: 'asc' } }),
]); ]);
if (!promotion) notFound(); if (!promotion) notFound();
+8 -6
View File
@@ -7,18 +7,20 @@ export default async function ProofSentPage({
params, params,
searchParams, searchParams,
}: { }: {
params: { id: string }; params: Promise<{ id: string }>;
searchParams: { emailed?: string }; searchParams: Promise<{ emailed?: string }>;
}) { }) {
const { id } = await params;
const { emailed: emailedParam } = await searchParams;
const proof = await prisma.designProof.findUnique({ const proof = await prisma.designProof.findUnique({
where: { id: params.id }, where: { id },
include: { product: true }, include: { product: true },
}); });
if (!proof) notFound(); if (!proof) notFound();
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'; const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
const link = `${siteUrl}/proofs/${proof.id}`; const link = `${siteUrl}/proofs/${proof.id}`;
const emailed = searchParams.emailed === '1'; const emailed = emailedParam === '1';
return ( return (
<div className="mx-auto max-w-xl px-6 py-16 text-center"> <div className="mx-auto max-w-xl px-6 py-16 text-center">
@@ -43,9 +45,9 @@ export default async function ProofSentPage({
)} )}
<div className="mt-8 border border-line bg-surface p-6"> <div className="mt-8 border border-line bg-surface p-6">
{proof.imageDataUrl ? ( {proof.imageUrl ? (
<img <img
src={proof.imageDataUrl} src={proof.imageUrl}
alt="Uploaded design" alt="Uploaded design"
className="mx-auto max-h-64 object-contain" className="mx-auto max-h-64 object-contain"
/> />
+3 -4
View File
@@ -3,6 +3,7 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { sendProofEmail } from '@/lib/mail'; import { sendProofEmail } from '@/lib/mail';
import { saveUploadedFile } from '@/lib/storage';
export async function createProof(formData: FormData) { export async function createProof(formData: FormData) {
const productId = String(formData.get('productId') ?? ''); const productId = String(formData.get('productId') ?? '');
@@ -21,9 +22,7 @@ export async function createProof(formData: FormData) {
const product = await prisma.product.findUnique({ where: { id: productId } }); const product = await prisma.product.findUnique({ where: { id: productId } });
if (!product) throw new Error('Product not found.'); if (!product) throw new Error('Product not found.');
const bytes = Buffer.from(await file.arrayBuffer()); const imageUrl = await saveUploadedFile(file, 'proofs');
const mimeType = file.type || 'image/png';
const imageDataUrl = `data:${mimeType};base64,${bytes.toString('base64')}`;
const unitPrice = priceOverride && String(priceOverride).trim() !== '' const unitPrice = priceOverride && String(priceOverride).trim() !== ''
? Math.round(Number(priceOverride) * 100) ? Math.round(Number(priceOverride) * 100)
@@ -37,7 +36,7 @@ export async function createProof(formData: FormData) {
color, color,
quantity, quantity,
unitPrice, unitPrice,
imageDataUrl, imageUrl,
note: note || null, note: note || null,
}, },
}); });
+5 -4
View File
@@ -5,8 +5,9 @@ import AdminNav from '@/components/AdminNav';
export default async function NewProofPage({ export default async function NewProofPage({
searchParams, searchParams,
}: { }: {
searchParams: { productId?: string; customerName?: string; customerEmail?: string }; searchParams: Promise<{ productId?: string; customerName?: string; customerEmail?: string }>;
}) { }) {
const params = await searchParams;
const products = await prisma.product.findMany({ orderBy: { name: 'asc' } }); const products = await prisma.product.findMany({ orderBy: { name: 'asc' } });
return ( return (
@@ -27,7 +28,7 @@ export default async function NewProofPage({
id="productId" id="productId"
name="productId" name="productId"
required required
defaultValue={searchParams.productId} defaultValue={params.productId}
className="w-full border border-line px-3 py-2 text-sm" className="w-full border border-line px-3 py-2 text-sm"
> >
{products.map((p) => ( {products.map((p) => (
@@ -46,7 +47,7 @@ export default async function NewProofPage({
<input <input
id="customerName" id="customerName"
name="customerName" name="customerName"
defaultValue={searchParams.customerName} defaultValue={params.customerName}
className="w-full border border-line px-3 py-2 text-sm" className="w-full border border-line px-3 py-2 text-sm"
placeholder="Jane Doe" placeholder="Jane Doe"
/> />
@@ -60,7 +61,7 @@ export default async function NewProofPage({
name="customerEmail" name="customerEmail"
type="email" type="email"
required required
defaultValue={searchParams.customerEmail} defaultValue={params.customerEmail}
className="w-full border border-line px-3 py-2 text-sm" className="w-full border border-line px-3 py-2 text-sm"
placeholder="jane@example.com" placeholder="jane@example.com"
/> />
+1 -1
View File
@@ -29,7 +29,7 @@ export default async function ProofsListPage() {
<div className="mt-8 divide-y divide-line border-t border-line"> <div className="mt-8 divide-y divide-line border-t border-line">
{proofs.map((p) => ( {proofs.map((p) => (
<div key={p.id} className="flex items-center gap-4 py-4"> <div key={p.id} className="flex items-center gap-4 py-4">
<img src={p.imageDataUrl} alt="" className="h-16 w-16 border border-line object-cover" /> <img src={p.imageUrl ?? ''} alt="" className="h-16 w-16 border border-line object-cover" />
<div className="flex-1"> <div className="flex-1">
<p className="font-medium">{p.customerName || p.customerEmail}</p> <p className="font-medium">{p.customerName || p.customerEmail}</p>
<p className="text-sm text-muted"> <p className="text-sm text-muted">
+3 -2
View File
@@ -20,8 +20,9 @@ async function deleteReview(reviewId: string) {
redirect('/admin/reviews?status=pending'); redirect('/admin/reviews?status=pending');
} }
export default async function AdminReviewsPage({ searchParams }: { searchParams: { status?: string } }) { export default async function AdminReviewsPage({ searchParams }: { searchParams: Promise<{ status?: string }> }) {
const status = searchParams.status === 'approved' ? 'approved' : 'pending'; const { status: statusParam } = await searchParams;
const status = statusParam === 'approved' ? 'approved' : 'pending';
const reviews = await prisma.review.findMany({ const reviews = await prisma.review.findMany({
where: { where: {
+26 -17
View File
@@ -7,6 +7,11 @@ import { fetchActivePromotions } from '@/lib/promotions';
import { checkRateLimit, getClientIp } from '@/lib/rateLimit'; import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
import { getRoyalMailShippingQuotes } from '@/lib/royalmail-shipping'; import { getRoyalMailShippingQuotes } from '@/lib/royalmail-shipping';
import { cleanupPendingOrders } from '@/lib/cleanupOrders'; import { cleanupPendingOrders } from '@/lib/cleanupOrders';
import { saveDataUrl } from '@/lib/storage';
async function saveIfPresent(dataUrl: string | null | undefined, category: 'designs' | 'design-placements') {
return dataUrl ? saveDataUrl(dataUrl, category) : dataUrl;
}
type CheckoutCartItem = { type CheckoutCartItem = {
productId: string; productId: string;
@@ -179,6 +184,26 @@ export async function POST(req: Request) {
const total = subtotal + shippingCost; const total = subtotal + shippingCost;
// Design previews arrive from the client as base64 data URLs (canvas/Konva
// export) — write them to disk now so the Order row only ever stores a short URL.
const orderItems = await Promise.all(
pricedItems.map(async (i) => ({
productId: i.productId,
productName: i.name,
quantity: i.quantity,
color: i.color,
size: i.size,
unitPrice: i.unitPrice,
designJson: JSON.stringify(i.designJson),
designPreviewUrl: await saveDataUrl(i.designPreviewUrl, 'designs'),
designPreviewUrlBack: await saveIfPresent(i.designPreviewUrlBack, 'designs'),
placementPreviewUrl: await saveIfPresent(i.placementPreviewUrl, 'design-placements'),
placementPreviewUrlBack: await saveIfPresent(i.placementPreviewUrlBack, 'design-placements'),
designWidthCm: i.designWidthCm,
designHeightCm: i.designHeightCm,
})),
);
// Save the order up front, before redirecting to Stripe — this is what makes // Save the order up front, before redirecting to Stripe — this is what makes
// the design files (and the order itself) retrievable afterward, whether or // the design files (and the order itself) retrievable afterward, whether or
// not the customer ever makes it back to the success page. // not the customer ever makes it back to the success page.
@@ -194,23 +219,7 @@ export async function POST(req: Request) {
guestName: guest?.name, guestName: guest?.name,
guestPhone: guest?.phone, guestPhone: guest?.phone,
isCollectionPickup: isCollection, isCollectionPickup: isCollection,
items: { items: { create: orderItems },
create: pricedItems.map((i) => ({
productId: i.productId,
productName: i.name,
quantity: i.quantity,
color: i.color,
size: i.size,
unitPrice: i.unitPrice,
designJson: JSON.stringify(i.designJson),
designPreviewUrl: i.designPreviewUrl,
designPreviewUrlBack: i.designPreviewUrlBack,
placementPreviewUrl: i.placementPreviewUrl,
placementPreviewUrlBack: i.placementPreviewUrlBack,
designWidthCm: i.designWidthCm,
designHeightCm: i.designHeightCm,
})),
},
}, },
}); });
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { saveUploadedFile } from '@/lib/storage';
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
// Images a customer drops into the design tool (photos to print on a product).
// Uploaded immediately on add so the design JSON only ever stores a short URL,
// instead of the base64 data URL it held before — same fix as the other image
// fields, just for images embedded inside a design rather than a DB column.
export async function POST(req: Request) {
const ip = getClientIp(req.headers);
const { allowed } = await checkRateLimit(`design-upload:${ip}`, 60, 10 * 60 * 1000);
if (!allowed) {
return NextResponse.json({ error: 'Too many uploads — please wait a moment and try again.' }, { status: 429 });
}
const formData = await req.formData();
const file = formData.get('image') as File | null;
if (!file || file.size === 0) {
return NextResponse.json({ error: 'No image provided.' }, { status: 400 });
}
try {
const url = await saveUploadedFile(file, 'design-uploads');
return NextResponse.json({ url });
} catch (err) {
const message = err instanceof Error ? err.message : 'Could not process that image.';
return NextResponse.json({ error: message }, { status: 400 });
}
}
+5 -4
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { saveDataUrl } from '@/lib/storage';
export async function POST( export async function POST(
req: Request, req: Request,
@@ -29,10 +30,10 @@ export async function POST(
where: { id: id }, where: { id: id },
data: { data: {
...(designJson && { designJson }), ...(designJson && { designJson }),
...(designPreviewUrl && { designPreviewUrl }), ...(designPreviewUrl && { designPreviewUrl: await saveDataUrl(designPreviewUrl, 'designs') }),
...(designPreviewUrlBack && { designPreviewUrlBack }), ...(designPreviewUrlBack && { designPreviewUrlBack: await saveDataUrl(designPreviewUrlBack, 'designs') }),
...(placementPreviewUrl && { placementPreviewUrl }), ...(placementPreviewUrl && { placementPreviewUrl: await saveDataUrl(placementPreviewUrl, 'design-placements') }),
...(placementPreviewUrlBack && { placementPreviewUrlBack }), ...(placementPreviewUrlBack && { placementPreviewUrlBack: await saveDataUrl(placementPreviewUrlBack, 'design-placements') }),
...(designWidthCm !== undefined && { designWidthCm }), ...(designWidthCm !== undefined && { designWidthCm }),
...(designHeightCm !== undefined && { designHeightCm }), ...(designHeightCm !== undefined && { designHeightCm }),
}, },
+9 -4
View File
@@ -2,6 +2,11 @@ import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { sendDesignSubmissionNotification } from '@/lib/mail'; import { sendDesignSubmissionNotification } from '@/lib/mail';
import { getCurrentCustomer } from '@/lib/auth'; import { getCurrentCustomer } from '@/lib/auth';
import { saveDataUrl } from '@/lib/storage';
async function saveIfPresent(dataUrl: string | null | undefined, category: 'designs' | 'design-placements') {
return dataUrl ? saveDataUrl(dataUrl, category) : dataUrl;
}
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
@@ -49,10 +54,10 @@ export async function POST(req: Request) {
customerName: customer.name, customerName: customer.name,
productId, productId,
designJson, designJson,
designPreviewUrl, designPreviewUrl: await saveDataUrl(designPreviewUrl, 'designs'),
designPreviewUrlBack, designPreviewUrlBack: await saveIfPresent(designPreviewUrlBack, 'designs'),
placementPreviewUrl, placementPreviewUrl: await saveIfPresent(placementPreviewUrl, 'design-placements'),
placementPreviewUrlBack, placementPreviewUrlBack: await saveIfPresent(placementPreviewUrlBack, 'design-placements'),
designWidthCm: designWidthCm || null, designWidthCm: designWidthCm || null,
designHeightCm: designHeightCm || null, designHeightCm: designHeightCm || null,
designImageDimensions: designImageDimensions && designImageDimensions.length > 0 ? JSON.stringify(designImageDimensions) : null, designImageDimensions: designImageDimensions && designImageDimensions.length > 0 ? JSON.stringify(designImageDimensions) : null,
+2 -2
View File
@@ -6,9 +6,9 @@ import Price from '@/components/Price';
export default async function CheckoutSuccessPage({ export default async function CheckoutSuccessPage({
searchParams, searchParams,
}: { }: {
searchParams: { session_id?: string }; searchParams: Promise<{ session_id?: string }>;
}) { }) {
const sessionId = searchParams.session_id; const { session_id: sessionId } = await searchParams;
let order = null; let order = null;
if (sessionId) { if (sessionId) {
try { try {
+4 -3
View File
@@ -10,10 +10,11 @@ const ERROR_MESSAGES: Record<string, string> = {
export default async function ContactPage({ export default async function ContactPage({
searchParams, searchParams,
}: { }: {
searchParams: { sent?: string; error?: string }; searchParams: Promise<{ sent?: string; error?: string }>;
}) { }) {
const params = await searchParams;
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null; const error = params.error ? (ERROR_MESSAGES[params.error] ?? 'Something went wrong.') : null;
return ( return (
<div className="mx-auto max-w-2xl px-6 py-12"> <div className="mx-auto max-w-2xl px-6 py-12">
@@ -24,7 +25,7 @@ export default async function ContactPage({
back to you. back to you.
</p> </p>
{searchParams.sent === '1' ? ( {params.sent === '1' ? (
<div className="mt-10 border border-splash-blue bg-splash-blue/5 p-6 text-sm"> <div className="mt-10 border border-splash-blue bg-splash-blue/5 p-6 text-sm">
Thanks we&apos;ve got your message and will be in touch soon. Thanks we&apos;ve got your message and will be in touch soon.
</div> </div>
+3 -4
View File
@@ -6,6 +6,7 @@ import { prisma } from '@/lib/prisma';
import { sendCustomRequestNotification } from '@/lib/mail'; import { sendCustomRequestNotification } from '@/lib/mail';
import { verifyTurnstileToken } from '@/lib/turnstile'; import { verifyTurnstileToken } from '@/lib/turnstile';
import { getClientIp } from '@/lib/rateLimit'; import { getClientIp } from '@/lib/rateLimit';
import { saveUploadedFile } from '@/lib/storage';
export async function createCustomRequest(formData: FormData) { export async function createCustomRequest(formData: FormData) {
const customerId = String(formData.get('customerId') ?? '').trim() || null; const customerId = String(formData.get('customerId') ?? '').trim() || null;
@@ -30,9 +31,7 @@ export async function createCustomRequest(formData: FormData) {
const product = await prisma.product.findUnique({ where: { id: productId } }); const product = await prisma.product.findUnique({ where: { id: productId } });
if (!product) throw new Error('Product not found.'); if (!product) throw new Error('Product not found.');
const bytes = Buffer.from(await file.arrayBuffer()); const imageUrl = await saveUploadedFile(file, 'custom-requests');
const mimeType = file.type || 'image/png';
const imageDataUrl = `data:${mimeType};base64,${bytes.toString('base64')}`;
const request = await prisma.customRequest.create({ const request = await prisma.customRequest.create({
data: { data: {
@@ -41,7 +40,7 @@ export async function createCustomRequest(formData: FormData) {
customerName, customerName,
customerEmail, customerEmail,
customerPhone: customerPhone || null, customerPhone: customerPhone || null,
imageDataUrl, imageUrl,
note: note || null, note: note || null,
}, },
}); });
+4 -3
View File
@@ -5,9 +5,10 @@ import { prisma } from '@/lib/prisma';
export default async function CustomRequestSubmittedPage({ export default async function CustomRequestSubmittedPage({
searchParams, searchParams,
}: { }: {
searchParams: { id?: string }; searchParams: Promise<{ id?: string }>;
}) { }) {
const id = searchParams.id ?? ''; const params = await searchParams;
const id = params.id ?? '';
const request = id const request = id
? await prisma.customRequest.findUnique({ where: { id }, include: { product: true } }) ? await prisma.customRequest.findUnique({ where: { id }, include: { product: true } })
: null; : null;
@@ -23,7 +24,7 @@ export default async function CustomRequestSubmittedPage({
<div className="mt-8 border border-line bg-surface p-6"> <div className="mt-8 border border-line bg-surface p-6">
<img <img
src={request.imageDataUrl} src={request.imageUrl ?? ''}
alt="Your submitted photo" alt="Your submitted photo"
className="mx-auto max-h-64 object-contain" className="mx-auto max-h-64 object-contain"
/> />
+1 -1
View File
@@ -57,7 +57,7 @@ export default async function GalleryPage({ searchParams }: { searchParams: Prom
<figure key={p.id} className="group flex w-full flex-col overflow-hidden border border-line bg-surface"> <figure key={p.id} className="group flex w-full flex-col overflow-hidden border border-line bg-surface">
<div className="aspect-square w-full overflow-hidden"> <div className="aspect-square w-full overflow-hidden">
<img <img
src={p.imageDataUrl} src={p.imageUrl ?? ''}
alt={p.caption ?? 'Completed work'} alt={p.caption ?? 'Completed work'}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/> />
+1 -1
View File
@@ -34,7 +34,7 @@ export default async function ProductDetailPage({
where: { id: customRequestId }, where: { id: customRequestId },
}); });
if (customRequest) { if (customRequest) {
customPhoto = customRequest.imageDataUrl; customPhoto = customRequest.imageUrl ?? undefined;
} }
} }
+2 -1
View File
@@ -22,6 +22,7 @@ export default async function HomePage() {
const galleryPhotos = await prisma.galleryPhoto.findMany({ const galleryPhotos = await prisma.galleryPhoto.findMany({
take: 6, take: 6,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
select: { id: true, imageUrl: true, caption: true },
}); });
const products = await prisma.product.findMany({ const products = await prisma.product.findMany({
@@ -244,7 +245,7 @@ export default async function HomePage() {
className="group block aspect-square overflow-hidden border border-line bg-surface" className="group block aspect-square overflow-hidden border border-line bg-surface"
> >
<img <img
src={p.imageDataUrl} src={p.imageUrl ?? ''}
alt={p.caption ?? 'Completed work'} alt={p.caption ?? 'Completed work'}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/> />
+3 -2
View File
@@ -5,10 +5,11 @@ import { fetchActivePromotions } from '@/lib/promotions';
import StandardProductView from '@/components/StandardProductView'; import StandardProductView from '@/components/StandardProductView';
import ProductCard from '@/components/ProductCard'; import ProductCard from '@/components/ProductCard';
export default async function StandardProductPage({ params }: { params: { slug: string } }) { export default async function StandardProductPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const activePromotions = await fetchActivePromotions(); const activePromotions = await fetchActivePromotions();
const row = await prisma.product.findUnique({ where: { slug: params.slug } }); const row = await prisma.product.findUnique({ where: { slug } });
if (!row || !row.showAsReadyMade) notFound(); if (!row || !row.showAsReadyMade) notFound();
const product = toProductDTO(row, activePromotions, 'plain'); const product = toProductDTO(row, activePromotions, 'plain');
+3 -2
View File
@@ -3,9 +3,10 @@ import { prisma } from '@/lib/prisma';
import ReviewForm from '@/components/ReviewForm'; import ReviewForm from '@/components/ReviewForm';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
export default async function ProductReviewsPage({ params }: { params: { slug: string } }) { export default async function ProductReviewsPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const product = await prisma.product.findUnique({ const product = await prisma.product.findUnique({
where: { slug: params.slug }, where: { slug },
include: { include: {
reviews: { reviews: {
where: { approved: true }, where: { approved: true },
+3 -2
View File
@@ -9,8 +9,9 @@ type SearchParams = {
const PRODUCTS_PER_PAGE = 20; const PRODUCTS_PER_PAGE = 20;
export default async function ReadyMadeProductsPage({ searchParams }: { searchParams: SearchParams }) { export default async function ReadyMadeProductsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const currentPage = Math.max(1, Number(searchParams.page) || 1); const params = await searchParams;
const currentPage = Math.max(1, Number(params.page) || 1);
const activePromotions = await fetchActivePromotions(); const activePromotions = await fetchActivePromotions();
const [rows, totalCount] = await Promise.all([ const [rows, totalCount] = await Promise.all([
+5 -4
View File
@@ -5,9 +5,10 @@ import ApproveSection from '@/components/ApproveSection';
import Chat from '@/components/Chat'; import Chat from '@/components/Chat';
import Price from '@/components/Price'; import Price from '@/components/Price';
export default async function ProofPage({ params }: { params: { id: string } }) { export default async function ProofPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const row = await prisma.designProof.findUnique({ const row = await prisma.designProof.findUnique({
where: { id: params.id }, where: { id },
include: { product: true }, include: { product: true },
}); });
if (!row) notFound(); if (!row) notFound();
@@ -22,8 +23,8 @@ export default async function ProofPage({ params }: { params: { id: string } })
<div className="mt-10 grid gap-10 md:grid-cols-2"> <div className="mt-10 grid gap-10 md:grid-cols-2">
<div className="border border-line bg-surface p-6"> <div className="border border-line bg-surface p-6">
{proof.imageDataUrl ? ( {proof.imageUrl ? (
<img src={proof.imageDataUrl} alt={`${proof.productName} design`} className="w-full object-contain" /> <img src={proof.imageUrl} alt={`${proof.productName} design`} className="w-full object-contain" />
) : ( ) : (
<p className="text-center text-sm text-muted">No image was uploaded with this design.</p> <p className="text-center text-sm text-muted">No image was uploaded with this design.</p>
)} )}
+2 -2
View File
@@ -27,9 +27,9 @@ export default function ApproveSection({ proof }: { proof: ProofDTO }) {
quantity: proof.quantity, quantity: proof.quantity,
unitPrice: proof.unitPrice, unitPrice: proof.unitPrice,
designJson: { front: [], back: [] } as any, designJson: { front: [], back: [] } as any,
designPreviewUrl: proof.imageDataUrl, designPreviewUrl: proof.imageUrl,
designPreviewUrlBack: null, designPreviewUrlBack: null,
placementPreviewUrl: proof.imageDataUrl, placementPreviewUrl: proof.imageUrl,
placementPreviewUrlBack: null, placementPreviewUrlBack: null,
designWidthCm: null, designWidthCm: null,
designHeightCm: null, designHeightCm: null,
+49 -38
View File
@@ -523,6 +523,7 @@ export default function DesignCanvas({
const [submissionMessage, setSubmissionMessage] = useState<string | null>(null); const [submissionMessage, setSubmissionMessage] = useState<string | null>(null);
const [imageDimensions, setImageDimensions] = useState<Record<string, { widthCm: number | null; heightCm: number | null }>>({}); const [imageDimensions, setImageDimensions] = useState<Record<string, { widthCm: number | null; heightCm: number | null }>>({});
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({}); const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
const [isUploadingImage, setIsUploadingImage] = useState(false);
const toggleSection = (section: string) => { const toggleSection = (section: string) => {
setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] })); setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] }));
@@ -715,44 +716,53 @@ export default function DesignCanvas({
const handleUploadClick = () => fileInputRef.current?.click(); const handleUploadClick = () => fileInputRef.current?.click();
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
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 = ''; 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 = () => { const deleteSelected = () => {
@@ -1566,9 +1576,10 @@ export default function DesignCanvas({
</button> </button>
<button <button
onClick={handleUploadClick} onClick={handleUploadClick}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper" disabled={isUploadingImage}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper disabled:cursor-not-allowed disabled:opacity-50"
> >
+ Upload image {isUploadingImage ? 'Uploading...' : '+ Upload image'}
</button> </button>
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFile} /> <input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFile} />
{selected && ( {selected && (
-881
View File
@@ -1,881 +0,0 @@
'use client';
import { useRef, useState, useCallback, useEffect } from 'react';
import { v4 as uuid } from 'uuid';
import ProductMockup from './ProductMockup';
import Price from './Price';
import LoginPromptModal from './LoginPromptModal';
import { useCart } from '@/lib/cartStore';
import { getEffectivePrice } from '@/lib/pricing';
import { checkCustomerAuth } from '@/app/actions';
import type { DesignElement, ProductDTO } from '@/lib/types';
const DISPLAY = 560;
const FONTS = ['Arial', 'Georgia', 'Courier New', 'Times New Roman', 'Verdana', 'Comic Sans MS'];
interface DesignCanvasProps {
product: ProductDTO;
customPhoto?: string;
}
export default function FabricDesignCanvas({ product, customPhoto }: DesignCanvasProps) {
const canvasContainerRef = useRef<HTMLDivElement>(null);
const frontCanvasRef = useRef<HTMLCanvasElement>(null);
const backCanvasRef = useRef<HTMLCanvasElement>(null);
const measureCanvasRef = useRef<HTMLCanvasElement | null>(null);
const [design, setDesign] = useState<{ front: DesignElement[]; back: DesignElement[] }>({
front: [],
back: [],
});
const [side, setSide] = useState<'front' | 'back'>('front');
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [pendingId, setPendingId] = useState<string | null>(null);
const [selectedColor, setSelectedColor] = useState<string>(product.colors[0]);
const [dragging, setDragging] = useState<{
id: string;
type: 'move' | 'resize-nw' | 'resize-ne' | 'resize-sw' | 'resize-se';
startX: number;
startY: number;
elementX: number;
elementY: number;
offsetX: number;
offsetY: number;
} | null>(null);
const { addItem } = useCart();
// Helper to measure text width
const measureTextWidth = (text: string, fontSize: number, fontFamily: string): number => {
if (!measureCanvasRef.current) {
measureCanvasRef.current = document.createElement('canvas');
}
const ctx = measureCanvasRef.current.getContext('2d');
if (!ctx) return text.length * (fontSize * 0.6); // Fallback estimate
ctx.font = `${fontSize}px ${fontFamily}`;
return ctx.measureText(text).width;
};
// Validate element stays within design area
const validateElementBounds = useCallback((element: DesignElement, printArea: any): DesignElement => {
const minX = Math.round(printArea.xPct * DISPLAY);
const minY = Math.round(printArea.yPct * DISPLAY);
const maxX = minX + Math.round(printArea.wPct * DISPLAY);
const maxY = minY + Math.round(printArea.hPct * DISPLAY);
const elementWidth = element.type === 'text' ? 150 : element.width;
const elementHeight = element.type === 'text' ? element.fontSize : element.height;
return {
...element,
x: Math.max(minX, Math.min(element.x, maxX - elementWidth)),
y: Math.max(minY, Math.min(element.y, maxY - elementHeight)),
};
}, []);
// Reset design state when product changes
useEffect(() => {
setDesign({ front: [], back: [] });
setSide('front');
setSelectedId(null);
setPendingId(null);
}, [product.id]);
// Clear selection and pending when switching views
useEffect(() => {
setSelectedId(null);
setPendingId(null);
}, [side]);
// Redraw canvas whenever design, selection, or dragging changes (for dynamic updates)
useEffect(() => {
const canvas = side === 'front' ? frontCanvasRef.current : backCanvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Get print area and validate all elements before drawing
const printArea = side === 'front' ? product.printArea : product.printAreaBack || product.printArea;
const elements = side === 'front' ? design.front : design.back;
elements.forEach((el) => {
// Validate element bounds before drawing
const validatedEl = validateElementBounds(el, printArea);
if (validatedEl.type === 'text') {
ctx.font = `${validatedEl.fontSize}px ${validatedEl.fontFamily}`;
ctx.fillStyle = validatedEl.fill;
ctx.textBaseline = 'top';
ctx.save();
ctx.translate(validatedEl.x, validatedEl.y);
ctx.rotate(((validatedEl.rotation || 0) * Math.PI) / 180);
ctx.fillText(validatedEl.text, 0, 0);
ctx.restore();
// Draw selection box and resize handles if selected
if (selectedId === validatedEl.id) {
const textWidth = ctx.measureText(validatedEl.text).width;
const textHeight = validatedEl.fontSize;
ctx.strokeStyle = '#FF6B6B';
ctx.lineWidth = 2;
ctx.strokeRect(validatedEl.x - 5, validatedEl.y - 5, textWidth + 10, textHeight + 10);
// Draw resize handles
const handleSize = 8;
const handles = [
{ x: validatedEl.x - handleSize / 2, y: validatedEl.y - handleSize / 2 }, // top-left
{ x: validatedEl.x + textWidth + handleSize / 2, y: validatedEl.y - handleSize / 2 }, // top-right
{ x: validatedEl.x - handleSize / 2, y: validatedEl.y + textHeight + handleSize / 2 }, // bottom-left
{ x: validatedEl.x + textWidth + handleSize / 2, y: validatedEl.y + textHeight + handleSize / 2 }, // bottom-right
];
handles.forEach((handle) => {
ctx.fillStyle = '#FF6B6B';
ctx.fillRect(handle.x - handleSize / 2, handle.y - handleSize / 2, handleSize, handleSize);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.strokeRect(handle.x - handleSize / 2, handle.y - handleSize / 2, handleSize, handleSize);
});
}
} else if (validatedEl.type === 'image') {
const img = new Image();
img.onload = () => {
ctx.save();
ctx.translate(validatedEl.x, validatedEl.y);
ctx.rotate(((validatedEl.rotation || 0) * Math.PI) / 180);
ctx.drawImage(img, 0, 0, validatedEl.width, validatedEl.height);
// Draw selection box if selected
if (selectedId === validatedEl.id) {
ctx.strokeStyle = '#FF6B6B';
ctx.lineWidth = 2;
ctx.strokeRect(-5, -5, validatedEl.width + 10, validatedEl.height + 10);
// Draw resize handles
const handleSize = 8;
const handles = [
{ x: -handleSize / 2, y: -handleSize / 2 }, // top-left
{ x: validatedEl.width + handleSize / 2, y: -handleSize / 2 }, // top-right
{ x: -handleSize / 2, y: validatedEl.height + handleSize / 2 }, // bottom-left
{ x: validatedEl.width + handleSize / 2, y: validatedEl.height + handleSize / 2 }, // bottom-right
];
handles.forEach((handle) => {
ctx.fillStyle = '#FF6B6B';
ctx.fillRect(handle.x - handleSize / 2, handle.y - handleSize / 2, handleSize, handleSize);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.strokeRect(handle.x - handleSize / 2, handle.y - handleSize / 2, handleSize, handleSize);
});
}
ctx.restore();
};
img.src = validatedEl.src;
}
});
}, [design, side, selectedId, product, dragging]);
const addTextElement = useCallback(() => {
const id = uuid();
// Calculate initial position centered in the print area
const printArea = side === 'front' ? product.printArea : product.printAreaBack || product.printArea;
const minX = Math.round(printArea.xPct * DISPLAY);
const minY = Math.round(printArea.yPct * DISPLAY);
const printWidth = Math.round(printArea.wPct * DISPLAY);
const printHeight = Math.round(printArea.hPct * DISPLAY);
// Measure actual text width
const fontSize = 24;
const textWidth = measureTextWidth('New Text', fontSize, 'Arial');
// Center horizontally and vertically in print area
const centerX = minX + Math.round((printWidth - textWidth) / 2);
const centerY = minY + Math.round((printHeight - fontSize) / 2);
const newElement: DesignElement = {
id,
type: 'text',
text: 'New Text',
x: Math.max(minX, centerX),
y: Math.max(minY, centerY),
fontSize,
fontFamily: 'Arial',
fill: '#000000',
rotation: 0,
};
setDesign((prev) => ({
...prev,
[side]: [...prev[side], newElement],
}));
setSelectedId(id);
setPendingId(id);
}, [side, product]);
const updateElement = useCallback(
(id: string, updates: Partial<DesignElement>) => {
setDesign((prev) => ({
...prev,
[side]: prev[side].map((el) =>
el.id === id ? { ...el, ...updates } : el
),
}));
},
[side]
);
const addImageElement = useCallback(() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (evt) => {
const id = uuid();
const newElement: DesignElement = {
id,
type: 'image',
src: evt.target?.result as string,
x: 50,
y: 50,
width: 100,
height: 100,
rotation: 0,
};
setDesign((prev) => ({
...prev,
[side]: [...prev[side], newElement],
}));
setSelectedId(id);
setPendingId(id);
};
reader.readAsDataURL(file);
}
};
input.click();
}, [side]);
const confirmElement = useCallback(() => {
setPendingId(null);
}, []);
const cancelElement = useCallback(() => {
if (!pendingId) return;
setDesign((prev) => ({
...prev,
[side]: prev[side].filter((el) => el.id !== pendingId),
}));
setSelectedId(null);
setPendingId(null);
}, [pendingId, side]);
const deleteElement = useCallback(
(id: string) => {
setDesign((prev) => ({
...prev,
[side]: prev[side].filter((el) => el.id !== id),
}));
setSelectedId(null);
},
[side]
);
const handleCanvasClick = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = e.currentTarget;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const elements = side === 'front' ? design.front : design.back;
let clicked = false;
for (const el of elements) {
if (el.type === 'text') {
if (x >= el.x && x <= el.x + 200 && y >= el.y && y <= el.y + el.fontSize + 10) {
setSelectedId(el.id);
setPendingId(null);
clicked = true;
break;
}
} else if (el.type === 'image') {
if (x >= el.x && x <= el.x + el.width && y >= el.y && y <= el.y + el.height) {
setSelectedId(el.id);
setPendingId(null);
clicked = true;
break;
}
}
}
if (!clicked) {
setSelectedId(null);
setPendingId(null);
}
},
[design, side]
);
const handleCanvasMouseDown = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
if (!selectedId) return;
const canvas = e.currentTarget;
const rect = canvas.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickY = e.clientY - rect.top;
const elements = side === 'front' ? design.front : design.back;
const element = elements.find((el) => el.id === selectedId);
if (!element) return;
// Determine if clicking on a resize handle or the element itself
let dragType: 'move' | 'resize-nw' | 'resize-ne' | 'resize-sw' | 'resize-se' = 'move';
if (element.type === 'text') {
const textWidth = measureTextWidth(element.text, element.fontSize, element.fontFamily);
const textHeight = element.fontSize;
const handleSize = 8;
// Check if click is within handleSize of each corner
const nearTopLeft = Math.abs(clickX - element.x) < handleSize && Math.abs(clickY - element.y) < handleSize;
const nearTopRight = Math.abs(clickX - (element.x + textWidth)) < handleSize && Math.abs(clickY - element.y) < handleSize;
const nearBottomLeft = Math.abs(clickX - element.x) < handleSize && Math.abs(clickY - (element.y + textHeight)) < handleSize;
const nearBottomRight = Math.abs(clickX - (element.x + textWidth)) < handleSize && Math.abs(clickY - (element.y + textHeight)) < handleSize;
if (nearTopLeft) dragType = 'resize-nw';
else if (nearTopRight) dragType = 'resize-ne';
else if (nearBottomLeft) dragType = 'resize-sw';
else if (nearBottomRight) dragType = 'resize-se';
} else if (element.type === 'image') {
const handleSize = 10;
// Check if click is within handleSize of each corner
const nearTopLeft = Math.abs(clickX - element.x) < handleSize && Math.abs(clickY - element.y) < handleSize;
const nearTopRight = Math.abs(clickX - (element.x + element.width)) < handleSize && Math.abs(clickY - element.y) < handleSize;
const nearBottomLeft = Math.abs(clickX - element.x) < handleSize && Math.abs(clickY - (element.y + element.height)) < handleSize;
const nearBottomRight = Math.abs(clickX - (element.x + element.width)) < handleSize && Math.abs(clickY - (element.y + element.height)) < handleSize;
if (nearTopLeft) dragType = 'resize-nw';
else if (nearTopRight) dragType = 'resize-ne';
else if (nearBottomLeft) dragType = 'resize-sw';
else if (nearBottomRight) dragType = 'resize-se';
}
setDragging({
id: selectedId,
type: dragType,
startX: clickX,
startY: clickY,
elementX: element.x,
elementY: element.y,
offsetX: element.x - clickX,
offsetY: element.y - clickY,
});
},
[selectedId, side, design]
);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!dragging) return;
const canvas = side === 'front' ? frontCanvasRef.current : backCanvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const currentX = e.clientX - rect.left;
const currentY = e.clientY - rect.top;
const elements = side === 'front' ? design.front : design.back;
const element = elements.find((el) => el.id === dragging.id);
if (element) {
// Calculate print area bounds
const printArea = side === 'front' ? product.printArea : product.printAreaBack || product.printArea;
const minX = Math.round(printArea.xPct * DISPLAY);
const minY = Math.round(printArea.yPct * DISPLAY);
const maxX = minX + Math.round(printArea.wPct * DISPLAY);
const maxY = minY + Math.round(printArea.hPct * DISPLAY);
const printAreaWidth = maxX - minX;
const printAreaHeight = maxY - minY;
if (dragging.type === 'move') {
// Position element based on initial element position + mouse delta
const dx = currentX - dragging.startX;
const dy = currentY - dragging.startY;
let newX = dragging.elementX + dx;
let newY = dragging.elementY + dy;
// Constrain to print area boundaries - ensure element stays fully inside
let elementWidth = element.width || 0;
let elementHeight = element.height || element.fontSize || 0;
// For text, measure actual width
if (element.type === 'text') {
elementWidth = measureTextWidth(element.text, element.fontSize, element.fontFamily) + 10;
elementHeight = element.fontSize * 1.2; // Account for line height
}
// Constrain to print area - allow some overflow if element is too large
// X-axis: keep left edge from going too far left, but allow right to extend
const maxLeftX = maxX - 20; // Allow small overlap on right
newX = Math.max(minX - elementWidth + 20, Math.min(newX, maxLeftX));
// Y-axis: similar approach
const maxTopY = maxY - 10;
newY = Math.max(minY - elementHeight + 10, Math.min(newY, maxTopY));
updateElement(dragging.id, { x: newX, y: newY });
} else if (element.type === 'text') {
// Resize text by changing font size based on vertical drag
const dy = currentY - dragging.startY;
const newSize = Math.max(8, Math.min(72, element.fontSize + Math.round(dy / 10)));
updateElement(dragging.id, { fontSize: newSize });
} else if (element.type === 'image') {
// Resize image from corners with proper boundary handling
const dx = currentX - dragging.startX;
const dy = currentY - dragging.startY;
let newX = element.x;
let newY = element.y;
let newWidth = element.width;
let newHeight = element.height;
// Handle corner-specific resize logic
if (dragging.type === 'resize-se') {
// Southeast: expand right and down
newWidth = Math.max(20, element.width + Math.round(dx / 2));
newHeight = Math.max(20, element.height + Math.round(dy / 2));
} else if (dragging.type === 'resize-sw') {
// Southwest: expand left and down
newX = element.x + Math.round(dx / 2);
newWidth = Math.max(20, element.width - Math.round(dx / 2));
newHeight = Math.max(20, element.height + Math.round(dy / 2));
} else if (dragging.type === 'resize-ne') {
// Northeast: expand right and up
newY = element.y + Math.round(dy / 2);
newWidth = Math.max(20, element.width + Math.round(dx / 2));
newHeight = Math.max(20, element.height - Math.round(dy / 2));
} else if (dragging.type === 'resize-nw') {
// Northwest: expand left and up
newX = element.x + Math.round(dx / 2);
newY = element.y + Math.round(dy / 2);
newWidth = Math.max(20, element.width - Math.round(dx / 2));
newHeight = Math.max(20, element.height - Math.round(dy / 2));
}
// Constrain to print area boundaries
newX = Math.max(minX, Math.min(newX, maxX - 20));
newY = Math.max(minY, Math.min(newY, maxY - 20));
newWidth = Math.min(newWidth, maxX - newX);
newHeight = Math.min(newHeight, maxY - newY);
updateElement(dragging.id, { x: newX, y: newY, width: newWidth, height: newHeight });
}
}
},
[dragging, side, design, product, updateElement]
);
const handleMouseUp = useCallback(() => {
setDragging(null);
}, []);
useEffect(() => {
if (dragging) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}
}, [dragging, handleMouseMove, handleMouseUp]);
const selectedElement = selectedId
? (side === 'front' ? design.front : design.back).find((el) => el.id === selectedId)
: null;
const printArea = side === 'front' ? product.printArea : product.printAreaBack || product.printArea;
const canvasW = Math.round(printArea.wPct * DISPLAY);
const canvasH = Math.round(printArea.hPct * DISPLAY);
const handleAddToCart = async () => {
const customer = await checkCustomerAuth();
if (!customer) {
setShowLoginPrompt(true);
return;
}
const priceInfo = getEffectivePrice(product, [], new Date(), 'personalised');
addItem({
productId: product.id,
quantity: 1,
designJson: JSON.stringify(design),
designWidthCm: product.referenceWidthCm || null,
designHeightCm: product.referenceHeightCm || null,
price: priceInfo.price,
});
};
return (
<div className="space-y-8">
{showLoginPrompt && <LoginPromptModal onClose={() => setShowLoginPrompt(false)} />}
<div className="p-6 bg-paper rounded-lg border border-line">
<h3 className="text-lg font-medium mb-4">Design Canvas</h3>
<div className="grid grid-cols-3 gap-6">
{/* Canvas */}
<div className="col-span-2">
<div className="flex gap-2 mb-4">
<button
onClick={() => {
setSide('front');
setSelectedId(null);
}}
className={`px-4 py-2 rounded ${side === 'front' ? 'bg-clay text-paper' : 'bg-line text-ink'}`}
>
Front
</button>
<button
onClick={() => {
setSide('back');
setSelectedId(null);
}}
className={`px-4 py-2 rounded ${side === 'back' ? 'bg-clay text-paper' : 'bg-line text-ink'}`}
>
Back
</button>
</div>
{/* Color Picker */}
{product.colors.length > 1 && (
<div className="mt-4 flex gap-2 items-center">
<span className="text-sm font-medium">Garment Color:</span>
<div className="flex gap-2">
{product.colors.map((color) => (
<button
key={color}
onClick={() => setSelectedColor(color)}
className={`w-8 h-8 rounded border-2 transition-all ${
selectedColor === color
? 'border-clay ring-2 ring-clay'
: 'border-line hover:border-clay'
}`}
style={{ backgroundColor: color }}
title={color}
aria-label={`Color ${color}`}
/>
))}
</div>
</div>
)}
<div ref={canvasContainerRef} className="bg-surface border border-line rounded p-4 relative inline-block mt-4">
<div
style={{
width: DISPLAY,
height: DISPLAY,
position: 'relative',
overflow: 'hidden',
}}
>
{/* Product image - prioritize colorPhotos over imageUrl */}
{customPhoto && side === 'front' ? (
<img
src={customPhoto}
alt="Product front"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : side === 'front' && product.colorPhotos?.[selectedColor] && product.colorPhotos[selectedColor] !== product.imageUrl ? (
<img
src={product.colorPhotos[selectedColor]}
alt="Product front"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : side === 'front' && product.imageUrl ? (
<img
src={product.imageUrl}
alt="Product front"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : side === 'back' && product.colorPhotosBack?.[selectedColor] ? (
<img
src={product.colorPhotosBack[selectedColor]}
alt="Product back"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : side === 'back' && product.imageUrlBack && product.imageUrlBack !== product.imageUrl ? (
<img
src={product.imageUrlBack}
alt="Product back"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : side === 'back' && product.colorPhotos?.[selectedColor] ? (
<img
src={product.colorPhotos[selectedColor]}
alt="Product back (using front color photo)"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : side === 'back' && product.imageUrl ? (
<img
src={product.imageUrl}
alt="Product back (using front image)"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
<ProductMockup mockup={product.mockup} color={selectedColor} />
)}
{/* Print area guide */}
<div
style={{
position: 'absolute',
top: `${printArea.yPct * DISPLAY}px`,
left: `${printArea.xPct * DISPLAY}px`,
width: `${printArea.wPct * DISPLAY}px`,
height: `${printArea.hPct * DISPLAY}px`,
border: '2px dashed #E5227E',
boxShadow: 'inset 0 0 0 1px rgba(229, 34, 126, 0.3)',
backgroundColor: 'rgba(229, 34, 126, 0.02)',
pointerEvents: 'none',
}}
/>
{/* Front Canvas */}
{side === 'front' && (
<canvas
ref={frontCanvasRef}
width={canvasW}
height={canvasH}
onClick={handleCanvasClick}
onMouseDown={handleCanvasMouseDown}
style={{
position: 'absolute',
top: `${printArea.yPct * DISPLAY}px`,
left: `${printArea.xPct * DISPLAY}px`,
backgroundColor: 'transparent',
cursor: selectedId ? 'grab' : 'pointer',
}}
/>
)}
{/* Back Canvas */}
{side === 'back' && (
<canvas
ref={backCanvasRef}
width={canvasW}
height={canvasH}
onClick={handleCanvasClick}
onMouseDown={handleCanvasMouseDown}
style={{
position: 'absolute',
top: `${printArea.yPct * DISPLAY}px`,
left: `${printArea.xPct * DISPLAY}px`,
backgroundColor: 'transparent',
cursor: selectedId ? 'grab' : 'pointer',
}}
/>
)}
</div>
</div>
<div className="mt-4 flex gap-2">
<button
onClick={addTextElement}
className="px-4 py-2 bg-clay text-paper rounded hover:bg-clay-dark"
>
Add Text
</button>
<button
onClick={addImageElement}
className="px-4 py-2 bg-clay text-paper rounded hover:bg-clay-dark"
>
Add Image
</button>
</div>
</div>
{/* Properties Panel */}
<div className="bg-line rounded p-4 space-y-4 h-fit">
<div className="text-sm font-medium">Properties</div>
{selectedElement ? (
<>
<div>
<label className="text-xs font-medium">Element Type</label>
<div className="text-xs text-muted capitalize mt-1">
{selectedElement.type === 'text' ? 'Text' : 'Image'}
</div>
</div>
{selectedElement.type === 'text' && (
<>
<div>
<label className="text-xs font-medium">Text</label>
<input
type="text"
value={selectedElement.text}
onChange={(e) => updateElement(selectedElement.id, { text: e.target.value })}
className="w-full text-xs border border-line rounded px-2 py-1 mt-1 text-ink"
/>
</div>
<div>
<label className="text-xs font-medium">Font</label>
<select
value={selectedElement.fontFamily}
onChange={(e) => updateElement(selectedElement.id, { fontFamily: e.target.value })}
className="w-full text-xs border border-line rounded px-2 py-1 mt-1 text-ink"
>
{FONTS.map((font) => (
<option key={font} value={font}>
{font}
</option>
))}
</select>
</div>
<div>
<label className="text-xs font-medium">Size</label>
<div className="flex gap-2 mt-1">
<input
type="number"
value={selectedElement.fontSize}
onChange={(e) => updateElement(selectedElement.id, { fontSize: parseInt(e.target.value) })}
className="flex-1 text-xs border border-line rounded px-2 py-1 text-ink"
min="8"
max="72"
/>
<span className="text-xs text-muted self-center">px</span>
</div>
<p className="text-xs text-muted mt-1">Drag corners on canvas to resize</p>
</div>
<div>
<label className="text-xs font-medium">Color</label>
<input
type="color"
value={selectedElement.fill}
onChange={(e) => updateElement(selectedElement.id, { fill: e.target.value })}
className="w-full h-8 border border-line rounded mt-1 cursor-pointer"
/>
</div>
</>
)}
{selectedElement.type === 'image' && (
<>
<div>
<label className="text-xs font-medium">Width</label>
<input
type="number"
value={selectedElement.width}
onChange={(e) => updateElement(selectedElement.id, { width: parseInt(e.target.value) })}
className="w-full text-xs border border-line rounded px-2 py-1 mt-1 text-ink"
min="20"
max="300"
/>
</div>
<div>
<label className="text-xs font-medium">Height</label>
<input
type="number"
value={selectedElement.height}
onChange={(e) => updateElement(selectedElement.id, { height: parseInt(e.target.value) })}
className="w-full text-xs border border-line rounded px-2 py-1 mt-1 text-ink"
min="20"
max="300"
/>
</div>
<p className="text-xs text-muted">Drag corners on canvas to resize</p>
</>
)}
{pendingId === selectedElement.id && (
<div className="flex gap-2">
<button
onClick={confirmElement}
className="flex-1 px-3 py-2 bg-splash-blue text-paper rounded text-xs font-medium hover:bg-splash-blue/90"
>
Confirm
</button>
<button
onClick={cancelElement}
className="flex-1 px-3 py-2 bg-splash-pink text-paper rounded text-xs font-medium hover:bg-splash-pink/90"
>
Cancel
</button>
</div>
)}
{pendingId !== selectedElement.id && (
<button
onClick={() => deleteElement(selectedElement.id)}
className="w-full px-3 py-2 bg-splash-pink text-paper rounded text-xs font-medium hover:bg-splash-pink/90"
>
Delete Element
</button>
)}
</>
) : (
<div className="text-xs text-muted">Click on text or image to edit</div>
)}
</div>
</div>
{/* Add to Cart */}
<div className="flex gap-4 items-center pt-6 border-t border-line mt-6">
<Price cents={getEffectivePrice(product, [], new Date(), 'personalised').price} />
<button
onClick={handleAddToCart}
className="px-6 py-3 bg-clay text-paper rounded font-medium hover:bg-clay-dark"
>
Add to Cart
</button>
</div>
</div>
</div>
);
}
+153 -23
View File
@@ -57,6 +57,11 @@ export default function FabricDesignCanvasV2({ product, customPhoto }: FabricDes
const [history, setHistory] = useState<Array<{ front: DesignElement[]; back: DesignElement[] }>>([]); const [history, setHistory] = useState<Array<{ front: DesignElement[]; back: DesignElement[] }>>([]);
const [historyIndex, setHistoryIndex] = useState(-1); const [historyIndex, setHistoryIndex] = useState(-1);
const [isLoaded, setIsLoaded] = useState(false); const [isLoaded, setIsLoaded] = useState(false);
const [selectedObject, setSelectedObject] = useState<any>(null);
const [selectedFont, setSelectedFont] = useState<string>('Arial');
const [selectedSize, setSelectedSize] = useState<number>(24);
const [selectedFill, setSelectedFill] = useState<string>('#000000');
const [isUploadingImage, setIsUploadingImage] = useState(false);
// Track client mounting to avoid hydration issues // Track client mounting to avoid hydration issues
useEffect(() => { useEffect(() => {
@@ -94,6 +99,35 @@ export default function FabricDesignCanvasV2({ product, customPhoto }: FabricDes
fabricCanvas.on('object:modified', () => saveHistory()); fabricCanvas.on('object:modified', () => saveHistory());
fabricCanvas.on('object:removed', () => saveHistory()); fabricCanvas.on('object:removed', () => saveHistory());
// Track selected object for editing
fabricCanvas.on('selection:created', (e: any) => {
const selected = e.selected?.[0];
if (selected) {
setSelectedObject(selected);
if (selected.type === 'text') {
setSelectedFont(selected.fontFamily || 'Arial');
setSelectedSize(selected.fontSize || 24);
setSelectedFill(selected.fill || '#000000');
}
}
});
fabricCanvas.on('selection:updated', (e: any) => {
const selected = e.selected?.[0];
if (selected) {
setSelectedObject(selected);
if (selected.type === 'text') {
setSelectedFont(selected.fontFamily || 'Arial');
setSelectedSize(selected.fontSize || 24);
setSelectedFill(selected.fill || '#000000');
}
}
});
fabricCanvas.on('selection:cleared', () => {
setSelectedObject(null);
});
if (mounted) { if (mounted) {
setIsLoaded(true); setIsLoaded(true);
} }
@@ -267,32 +301,63 @@ export default function FabricDesignCanvasV2({ product, customPhoto }: FabricDes
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
input.accept = 'image/*'; input.accept = 'image/*';
input.onchange = (e) => { input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]; const file = (e.target as HTMLInputElement).files?.[0];
if (file) { if (!file) return;
const reader = new FileReader();
reader.onload = (evt) => { setIsUploadingImage(true);
const newElement: DesignElement = { try {
id: uuid(), const formData = new FormData();
type: 'image', formData.append('image', file);
src: evt.target?.result as string, const res = await fetch('/api/design-uploads', { method: 'POST', body: formData });
x: DISPLAY / 2 - 50, const data = await res.json();
y: DISPLAY / 2 - 50, if (!res.ok) throw new Error(data.error || 'Upload failed');
width: 100,
height: 100, const newElement: DesignElement = {
rotation: 0, id: uuid(),
}; type: 'image',
setDesign((prev) => ({ src: data.url,
...prev, x: DISPLAY / 2 - 50,
[side]: [...prev[side], newElement], y: DISPLAY / 2 - 50,
})); width: 100,
height: 100,
rotation: 0,
}; };
reader.readAsDataURL(file); setDesign((prev) => ({
...prev,
[side]: [...prev[side], newElement],
}));
} catch (err) {
console.error('Failed to upload image:', err);
alert('Could not upload that image — please try again.');
} finally {
setIsUploadingImage(false);
} }
}; };
input.click(); input.click();
}; };
const updateTextProperty = (property: string, value: any) => {
if (!selectedObject || selectedObject.type !== 'text') return;
selectedObject.set({ [property]: value });
fabricCanvasRef.current?.renderAll();
// Update state for UI
if (property === 'fontFamily') setSelectedFont(value);
if (property === 'fontSize') setSelectedSize(value);
if (property === 'fill') setSelectedFill(value);
};
const deleteSelected = () => {
if (!selectedObject || !fabricCanvasRef.current) return;
fabricCanvasRef.current.remove(selectedObject);
fabricCanvasRef.current.renderAll();
setSelectedObject(null);
saveHistory();
};
return ( return (
<div className="mx-auto max-w-6xl px-6 py-8"> <div className="mx-auto max-w-6xl px-6 py-8">
<div className="grid gap-8 lg:grid-cols-3"> <div className="grid gap-8 lg:grid-cols-3">
@@ -362,9 +427,10 @@ export default function FabricDesignCanvasV2({ product, customPhoto }: FabricDes
</button> </button>
<button <button
onClick={addImage} onClick={addImage}
className="rounded bg-clay px-4 py-2 text-sm font-medium text-paper hover:bg-clay/90" disabled={isUploadingImage}
className="rounded bg-clay px-4 py-2 text-sm font-medium text-paper hover:bg-clay/90 disabled:cursor-not-allowed disabled:opacity-50"
> >
Add Image {isUploadingImage ? 'Uploading...' : 'Add Image'}
</button> </button>
</div> </div>
</div> </div>
@@ -373,8 +439,72 @@ export default function FabricDesignCanvasV2({ product, customPhoto }: FabricDes
<div> <div>
<div className="rounded border border-line bg-surface p-4"> <div className="rounded border border-line bg-surface p-4">
<h3 className="mb-4 font-medium">Properties</h3> <h3 className="mb-4 font-medium">Properties</h3>
<p className="text-sm text-muted">Click on text or image to edit</p>
<div className="mt-4"> {selectedObject && selectedObject.type === 'text' ? (
<div className="space-y-4">
{/* Font Selection */}
<div>
<label className="tag-label mb-2 block">Font</label>
<select
value={selectedFont}
onChange={(e) => updateTextProperty('fontFamily', e.target.value)}
className="w-full border border-line bg-paper px-2 py-1 text-sm"
>
{FONTS.map((font) => (
<option key={font} value={font}>
{font}
</option>
))}
</select>
</div>
{/* Size Slider */}
<div>
<label className="tag-label mb-2 block">
Size: {selectedSize}px
</label>
<input
type="range"
min="8"
max="96"
value={selectedSize}
onChange={(e) => updateTextProperty('fontSize', parseInt(e.target.value))}
className="w-full"
/>
</div>
{/* Color Picker */}
<div>
<label className="tag-label mb-2 block">Color</label>
<div className="flex gap-2">
<input
type="color"
value={selectedFill}
onChange={(e) => updateTextProperty('fill', e.target.value)}
className="h-10 w-12 cursor-pointer border border-line"
/>
<input
type="text"
value={selectedFill}
onChange={(e) => updateTextProperty('fill', e.target.value)}
className="flex-1 border border-line bg-paper px-2 py-1 text-sm"
/>
</div>
</div>
{/* Delete Button */}
<button
onClick={deleteSelected}
className="w-full rounded bg-splash-pink px-4 py-2 text-sm font-medium text-paper hover:bg-splash-pink/90"
>
Delete
</button>
</div>
) : (
<p className="text-sm text-muted">Click on text to edit</p>
)}
<div className="mt-8 space-y-3 border-t border-line pt-4">
<p className="mb-4 font-display text-xl"> <p className="mb-4 font-display text-xl">
£{(product.effectivePrice / 100).toFixed(2)} £{(product.effectivePrice / 100).toFixed(2)}
</p> </p>
+2 -2
View File
@@ -5,7 +5,7 @@ import Link from 'next/link';
interface GalleryPhoto { interface GalleryPhoto {
id: string; id: string;
imageDataUrl: string; imageUrl: string | null;
caption: string | null; caption: string | null;
} }
@@ -45,7 +45,7 @@ export default function RandomGalleryHero({ galleryPhotos }: Props) {
<div key={photo.id} className={`flex-1 transition-opacity duration-500 ${idx === 1 ? 'translate-y-6' : ''}`}> <div key={photo.id} className={`flex-1 transition-opacity duration-500 ${idx === 1 ? 'translate-y-6' : ''}`}>
<div className="border-2 border-line rounded-lg overflow-hidden aspect-square"> <div className="border-2 border-line rounded-lg overflow-hidden aspect-square">
<img <img
src={photo.imageDataUrl} src={photo.imageUrl ?? ''}
alt={photo.caption || 'Customer design'} alt={photo.caption || 'Customer design'}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
+1 -1
View File
@@ -48,7 +48,7 @@ export function toProofDTO(p: PrismaDesignProof & { product: PrismaProduct }): P
color: p.color, color: p.color,
quantity: p.quantity, quantity: p.quantity,
unitPrice: p.unitPrice, unitPrice: p.unitPrice,
imageDataUrl: p.imageDataUrl, imageUrl: p.imageUrl ?? '',
note: p.note, note: p.note,
status: p.status as ProofDTO['status'], status: p.status as ProofDTO['status'],
createdAt: p.createdAt.toISOString(), createdAt: p.createdAt.toISOString(),
+116
View File
@@ -0,0 +1,116 @@
import { randomUUID } from 'crypto';
import { mkdir, writeFile } from 'fs/promises';
import path from 'path';
import sharp from 'sharp';
// Every user-uploaded image used to be stored as a base64 data URL directly in
// Postgres — no size cap, no compression. One gallery photo alone was ~11MB and
// got embedded multiple times per page (once server-rendered, once serialized
// into a client component's RSC payload), producing a 71MB homepage. This module
// writes uploads to disk under public/uploads instead, so DB columns only ever
// hold a short URL string.
export type UploadCategory =
| 'gallery'
| 'products'
| 'custom-requests'
| 'proofs'
| 'invoices'
| 'receipts'
| 'designs'
| 'design-placements'
| 'design-uploads';
type ImagePreset = {
maxWidth: number;
format: 'jpeg' | 'png';
quality?: number;
preserveTransparency?: boolean;
};
// Print-critical fields (designs) stay lossless PNG; everything else is JPEG —
// these are reference/marketing photos where bandwidth matters more than fidelity.
const IMAGE_PRESETS: Record<UploadCategory, ImagePreset> = {
gallery: { maxWidth: 2000, format: 'jpeg', quality: 82 },
products: { maxWidth: 2000, format: 'jpeg', quality: 85, preserveTransparency: true },
'custom-requests': { maxWidth: 2000, format: 'jpeg', quality: 82 },
proofs: { maxWidth: 2000, format: 'jpeg', quality: 85 },
invoices: { maxWidth: 2000, format: 'jpeg', quality: 80 },
receipts: { maxWidth: 2000, format: 'jpeg', quality: 80 },
designs: { maxWidth: 4000, format: 'png', quality: 100, preserveTransparency: true },
'design-placements': { maxWidth: 2000, format: 'jpeg', quality: 85 },
'design-uploads': { maxWidth: 3000, format: 'jpeg', quality: 90, preserveTransparency: true },
};
const RAW_MAX_BYTES = 15 * 1024 * 1024;
function datedRelDir(category: UploadCategory) {
const d = new Date();
return path.posix.join(category, String(d.getFullYear()), String(d.getMonth() + 1).padStart(2, '0'));
}
async function persist(buf: Buffer, category: UploadCategory, ext: string): Promise<string> {
const relDir = datedRelDir(category);
await mkdir(path.join(process.cwd(), 'public', 'uploads', relDir), { recursive: true });
const relPath = path.posix.join('uploads', relDir, `${randomUUID()}.${ext}`);
await writeFile(path.join(process.cwd(), 'public', relPath), buf);
return `/${relPath}`;
}
async function processImage(buf: Buffer, category: UploadCategory): Promise<{ buf: Buffer; ext: string }> {
const preset = IMAGE_PRESETS[category];
const img = sharp(buf, { failOn: 'none' })
.rotate() // EXIF auto-orient; sharp strips remaining metadata (incl. GPS) by default
.resize({ width: preset.maxWidth, fit: 'inside', withoutEnlargement: true });
const hasAlpha = !!(await img.metadata()).hasAlpha;
if (preset.format === 'png' || (preset.preserveTransparency && hasAlpha)) {
return { buf: await img.png({ compressionLevel: 9, adaptiveFiltering: true }).toBuffer(), ext: 'png' };
}
return { buf: await img.jpeg({ quality: preset.quality ?? 82, mozjpeg: true }).toBuffer(), ext: 'jpeg' };
}
/** Saves a FormData File upload to disk, returning its public URL. */
export async function saveUploadedFile(
file: File,
category: UploadCategory,
opts: { maxBytes?: number; allowPdf?: boolean } = {},
): Promise<string> {
const maxBytes = opts.maxBytes ?? RAW_MAX_BYTES;
if (file.size > maxBytes) {
throw new Error(`File is too large — keep it under ${(maxBytes / 1024 / 1024).toFixed(0)}MB.`);
}
const buf = Buffer.from(await file.arrayBuffer());
if (opts.allowPdf && file.type === 'application/pdf') {
return persist(buf, category, 'pdf');
}
const { buf: out, ext } = await processImage(buf, category);
return persist(out, category, ext);
}
/**
* Saves a base64 data URL (from a canvas/Konva export or an existing DB row
* being migrated) to disk, returning its public URL. Pass-through for strings
* that are already a disk URL, so callers can safely re-run this idempotently.
*/
export async function saveDataUrl(
dataUrl: string,
category: UploadCategory,
opts: { maxBytes?: number; allowPdf?: boolean } = {},
): Promise<string> {
if (!dataUrl.startsWith('data:')) return dataUrl;
const match = /^data:([^;]+);base64,([\s\S]*)$/.exec(dataUrl);
if (!match) throw new Error('Malformed data URL.');
const [, mime, b64] = match;
const buf = Buffer.from(b64, 'base64');
const maxBytes = opts.maxBytes ?? RAW_MAX_BYTES;
if (buf.length > maxBytes) throw new Error('Image payload too large.');
if (opts.allowPdf && mime === 'application/pdf') {
return persist(buf, category, 'pdf');
}
const { buf: out, ext } = await processImage(buf, category);
return persist(out, category, ext);
}
export function isPdfUrl(url: string | null | undefined) {
return !!url && url.toLowerCase().endsWith('.pdf');
}
+1 -1
View File
@@ -89,7 +89,7 @@ export type ProofDTO = {
color: string; color: string;
quantity: number; quantity: number;
unitPrice: number; unitPrice: number;
imageDataUrl: string; imageUrl: string;
note: string | null; note: string | null;
status: 'PENDING' | 'APPROVED'; status: 'PENDING' | 'APPROVED';
createdAt: string; createdAt: string;
+1 -1
View File
File diff suppressed because one or more lines are too long