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).
# For Postgres later, change provider in prisma/schema.prisma and set this to your Postgres URL.
DATABASE_URL="file:./dev.db"
# Database — PostgreSQL connection string. prisma/schema.prisma is fixed to the
# postgresql provider, so this must point at a real Postgres instance (local
# install, Docker container, or managed service) — a SQLite file path won't work.
DATABASE_URL="postgresql://user:password@localhost:5432/craft2prints?schema=public"
# Stripe — get these from https://dashboard.stripe.com/apikeys
STRIPE_SECRET_KEY="sk_live_or_test_..."
+1
View File
@@ -9,3 +9,4 @@ prisma/dev.db-*
.claude/settings.local.json
dist/
database-backups/
public/uploads/
+14 -6
View File
@@ -7,7 +7,7 @@ A complete self-hosted e-commerce platform for personalized products (apparel, d
### Customer Experience
- **Design Tool** — In-browser tool to customize templates with text and images
- **Product Catalog** — Browse and filter by category, color, size, and price
- **Shopping Cart** — Persistent cart stored in IndexedDB (handles large design files)
- **Shopping Cart** — Persistent cart stored in IndexedDB
- **Customer Accounts** — Optional sign-up for order history and faster checkout
- **Dark Mode** — Full dark mode support with system preference detection
- **Mobile Responsive** — Works seamlessly on all devices
@@ -42,12 +42,13 @@ A complete self-hosted e-commerce platform for personalized products (apparel, d
- **Session Expiry** — Auto-logout when browser closes
### Technical Stack
- **Frontend** — Next.js 14 (App Router), React, Tailwind CSS
- **Frontend** — Next.js 16 (App Router, Turbopack), React, Tailwind CSS
- **Backend** — Next.js server actions, API routes
- **Database** — SQLite with Prisma ORM
- **Database** — PostgreSQL with Prisma ORM
- **File storage** — Uploaded images/documents are compressed with `sharp` and saved as files on disk (`public/uploads/`), not as base64 in the database — see [Image & File Storage](#image--file-storage) below
- **Payments** — Stripe (production-ready)
- **Email** — SMTP (Gmail, Outlook, SendGrid, etc.)
- **Design** — SVG-based mockups, Canvas for rendering
- **Design** — Fabric.js/Konva canvas for the design tool, SVG-based product mockups
## Quick Start
@@ -80,7 +81,7 @@ A complete self-hosted e-commerce platform for personalized products (apparel, d
## Configuration
**Essential (.env):**
- `DATABASE_URL` — SQLite path (default: `file:./dev.db`)
- `DATABASE_URL` — PostgreSQL connection string
- `ADMIN_USER` / `ADMIN_PASSWORD` — Admin login credentials (⚠️ change before deployment)
- `STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET` — Stripe payment keys
@@ -103,11 +104,18 @@ A product can appear on both catalogs simultaneously.
Prices stored in **GBP pence** internally. Customer-facing header currency selector (£/$/€) changes display only — no actual conversion happens until checkout is configured for it.
### Cart Storage
Cart persists to **IndexedDB**, not localStorage, because design items carry large image files. IndexedDB has no 5-10MB limit like localStorage.
Cart persists to **IndexedDB**, not localStorage, since it comfortably holds the design tool's cart items without the ~5-10MB ceiling localStorage imposes.
### Color Names
Colors display as friendly names to customers (Navy, Red, Forest Green) instead of hex codes. Admin still sees hex for reference.
### Image & File Storage
Every uploaded image or document (product photos, gallery photos, custom-request photos, design-approval previews, purchase invoices, expense receipts, images added inside the design tool) is saved as a real file on disk under `public/uploads/<category>/<yyyy>/<mm>/<uuid>.<ext>`, compressed and resized on the way in with `sharp`. Database columns only ever hold the resulting short URL (e.g. `/uploads/gallery/2026/07/<uuid>.jpeg`) — never a base64 data URL. PDFs (invoices/receipts) pass through untouched.
This matters operationally: `public/uploads/` is gitignored but lives permanently on the server's disk, so **backups must cover it alongside the database** (see [SETUP_AND_BUILD.md](./SETUP_AND_BUILD.md)). No web server config is needed to serve these files — Next.js serves everything under `public/` automatically.
See `src/lib/storage.ts` for the upload/compression helper and its per-category presets.
## Accounting Features
### Financial Reports
+65 -22
View File
@@ -7,10 +7,11 @@ This document covers everything needed to develop, build, and deploy Craft2Print
1. [Local Development Setup](#local-development-setup)
2. [Configuration Reference](#configuration-reference)
3. [Database Management](#database-management)
4. [Feature Setup](#feature-setup)
5. [Building for Production](#building-for-production)
6. [Deployment](#deployment)
7. [Troubleshooting](#troubleshooting)
4. [Image & File Storage](#image--file-storage)
5. [Feature Setup](#feature-setup)
6. [Building for Production](#building-for-production)
7. [Deployment](#deployment)
8. [Troubleshooting](#troubleshooting)
---
@@ -25,6 +26,8 @@ node -v # Check your version
If not installed, download from https://nodejs.org (LTS version recommended).
**PostgreSQL** — the app connects to a Postgres database (`prisma/schema.prisma`'s `datasource` is fixed to `postgresql`). Install it locally (https://www.postgresql.org/download/) or point `DATABASE_URL` at any reachable Postgres instance (a Docker container, a managed database, another machine on your network).
### Step 1: Clone and Install
```bash
@@ -46,7 +49,7 @@ cp .env.example .env
Edit `.env` and configure at minimum:
```env
DATABASE_URL="file:./dev.db"
DATABASE_URL="postgresql://user:password@localhost:5432/craft2prints?schema=public"
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
ADMIN_USER="admin"
ADMIN_PASSWORD="changeme"
@@ -62,7 +65,8 @@ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
### Step 3: Database Setup
```bash
# Create SQLite database and run migrations
# Create the database (make sure the DB named in DATABASE_URL already exists — e.g. `createdb craft2prints`)
# then sync the schema to it
npm run db:push
# Load starter products
@@ -87,7 +91,7 @@ Open http://localhost:3000 in your browser.
| Variable | Description | Example |
|----------|-------------|---------|
| `DATABASE_URL` | SQLite database path | `file:./dev.db` |
| `DATABASE_URL` | PostgreSQL connection string | `postgresql://user:pass@host:5432/craft2prints?schema=public` |
| `ADMIN_USER` | Admin login username | `admin` |
| `ADMIN_PASSWORD` | Admin login password | `changeme` |
| `ADMIN_SESSION_SECRET` | Session signing key (random) | 32-byte hex string |
@@ -147,17 +151,18 @@ NEXT_PUBLIC_SITE_URL="http://localhost:3000" # For design approval links
## Database Management
The app uses `prisma db push` (schema-driven sync), not versioned `prisma migrate` files — `prisma/schema.prisma` is the single source of truth for the database shape.
### Common Tasks
**Create database and migrations:**
**Sync the schema to the database:**
```bash
npm run db:push
```
**Reset database (⚠️ deletes all data):**
```bash
rm dev.db
npm run db:push
npx prisma db push --force-reset
npm run db:seed
```
@@ -165,6 +170,8 @@ npm run db:seed
1. Edit `prisma/schema.prisma`
2. Run `npm run db:push` to sync
Because there's no migration history, renaming or dropping a column that already holds data is destructive — `db push` will refuse (or require `--accept-data-loss`) rather than silently drop it. For a column that needs to change shape on a live database, do it in three steps: add the new column alongside the old one (non-destructive `db push`), run a one-off script to copy/transform the data into it, update the app code to use the new column, then drop the old column in a second `db push` once you've confirmed nothing still depends on it.
**Seed starter products:**
```bash
npm run db:seed
@@ -174,11 +181,47 @@ Updating the seed is safe — it only touches the 6 starter products by their sl
**Backup database:**
```bash
cp dev.db dev.db.backup-$(date +%Y%m%d-%H%M%S)
pg_dump -h <host> -p <port> -U <user> -d craft2prints -F p -f "database-backups/craft2prints_$(date +%Y-%m-%d_%H-%M-%S).sql"
```
`backup-database.ps1` in the repo root automates this — check its `$dbHost`/`$dbPort`/`$dbUser` match your actual `DATABASE_URL` before relying on it (it defaults to `localhost:5432`, which may not be where your database actually lives). **Backups only cover the database** — uploaded images/files live on disk under `public/uploads/` and need their own backup (see [Image & File Storage](#image--file-storage)).
---
## Image & File Storage
Every uploaded image or document — gallery photos, product photos, custom-request photos, design proofs, design-approval previews, order design previews, purchase invoices, expense receipts, and images added inside the design tool — is saved as a **file on disk**, not as base64 text in the database.
### How it works
1. A file arrives either as a `multipart/form-data` upload (admin forms, customer photo submissions) or as a base64 data URL generated client-side by the design tool's canvas export (`stage.toDataURL()`/`canvas.toDataURL()`).
2. `src/lib/storage.ts` decodes/receives it, and — unless it's a PDF — runs it through [`sharp`](https://sharp.pixelplumbing.com/) to resize (capped per category, never upscaled) and re-encode it (JPEG for photos, lossless PNG where transparency must be preserved, e.g. print-ready design previews).
3. The result is written to `public/uploads/<category>/<yyyy>/<mm>/<uuid>.<ext>` and the database column stores only the resulting path, e.g. `/uploads/gallery/2026/07/3f9c1e2a-....jpeg`.
Categories and their presets (`IMAGE_PRESETS` in `src/lib/storage.ts`):
| Category | Used for | Format | Max width |
|---|---|---|---|
| `gallery` | Public gallery photos | JPEG | 2000px |
| `products` | Product photos (main + per-color) | JPEG, or PNG if the source has transparency | 2000px |
| `custom-requests` | Customer-submitted photos | JPEG | 2000px |
| `proofs` | Admin-uploaded design proofs | JPEG | 2000px |
| `invoices` / `receipts` | Purchase/expense attachments | JPEG, or passthrough if PDF | 2000px |
| `designs` | Print-ready design previews (transparent) | PNG (lossless) | 4000px |
| `design-placements` | Design-on-product reference composites | JPEG | 2000px |
| `design-uploads` | Photos a customer adds inside the design tool | JPEG, or PNG if transparent | 3000px |
### Why this matters operationally
- **`public/uploads/` is gitignored but lives permanently on the server's disk.** It is not part of the database and is not covered by a `pg_dump` backup — back it up separately (a simple `rsync`/copy of the directory alongside your database backup schedule is enough).
- **No web server configuration is needed.** Next.js serves everything under `public/` automatically, in both `next dev` and `next start` — the same mechanism that already serves `public/logo.png`.
- **On a fresh server, make sure the Node process can write to `public/uploads/`** (it's created automatically on first upload, but the parent directory must be writable).
- **Uploads endpoint for the design tool**: images added inside the design canvas are uploaded via `POST /api/design-uploads` as soon as they're added, rather than embedded as base64 in the design's saved JSON — keeping cart/order/design-approval records small regardless of how many images a customer adds.
### Troubleshooting
**"sharp module could not be loaded" / native binding errors on the server**
Sharp ships platform-specific native bindings — never copy `node_modules` between machines (e.g. from a Windows dev box to a Linux server). Run a fresh `npm install` on the target machine (the standard deploy flow below already does this).
## Feature Setup
### Stripe Payments
@@ -274,7 +317,8 @@ MAIL_FROM="Craft2Prints <noreply@yourdomain.com>"
**Hardware Requirements:**
- Server with Node.js 18+ support
- 2GB+ RAM (more for concurrent traffic)
- SQLite storage, or PostgreSQL for larger deployments
- A reachable PostgreSQL database (local install, Docker container, or managed service)
- Persistent disk for `public/uploads/` — uploaded images/files live here, not in the database (see [Image & File Storage](#image--file-storage))
**Setup:**
@@ -366,8 +410,9 @@ CMD ["npm", "start"]
```bash
docker build -t craft2prints .
docker run -p 3000:3000 --env-file .env craft2prints
docker run -p 3000:3000 --env-file .env -v craft2prints-uploads:/app/public/uploads craft2prints
```
The `-v` volume mount is required — without it, `public/uploads/` lives inside the container's writable layer and every uploaded image/file is lost when the container is recreated.
### Post-Deployment Checklist
@@ -379,6 +424,7 @@ docker run -p 3000:3000 --env-file .env craft2prints
- [ ] Email SMTP tested (send test order confirmation)
- [ ] CAPTCHA working on public forms
- [ ] Database backups scheduled
- [ ] `public/uploads/` backups scheduled (separate from the database — see [Image & File Storage](#image--file-storage))
- [ ] Log monitoring set up
- [ ] Rate limiting verified
- [ ] Staging environment created for testing
@@ -406,10 +452,9 @@ npm run dev -- -p 3001 # Use port 3001 instead
npm run db:seed # Load starter products
```
**Database corrupted**
**Database out of sync / corrupted state**
```bash
rm dev.db
npm run db:push
npx prisma db push --force-reset # ⚠️ drops and recreates all tables
npm run db:seed
```
@@ -434,10 +479,8 @@ node -e "const smtp = require('nodemailer'); console.log(JSON.stringify(process.
**Database connection issues**
```bash
# Test connection
psql $DATABASE_URL # If PostgreSQL
# Or
sqlite3 dev.db ".tables" # If SQLite
# Test connection and list tables
psql $DATABASE_URL -c '\dt'
```
---
@@ -451,7 +494,7 @@ sqlite3 dev.db ".tables" # If SQLite
- Check payment processing (Stripe dashboard)
**Weekly:**
- Backup database
- Backup database and `public/uploads/`
- Review admin activity logs
- Check storage usage
@@ -521,4 +564,4 @@ npm run build
---
*Last updated: 2025-03-17*
*Last updated: 2026-07-22*
+1 -1
View File
@@ -1,7 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
serverExternalPackages: ['@prisma/client'],
serverExternalPackages: ['@prisma/client', 'sharp'],
experimental: {
// Invoice uploads (phone photos/PDFs) and product photo uploads go through
// 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-dom": "18.3.1",
"react-konva": "18.2.10",
"sharp": "^0.35.3",
"stripe": "16.2.0",
"tesseract.js": "^7.0.0",
"uuid": "9.0.1",
+5 -20
View File
@@ -72,21 +72,6 @@ model Product {
manualSaleItems ManualSaleItem[]
stockLevels StockLevel[]
purchaseItems PurchaseItem[]
productImages ProductImage[]
}
// Bulk-uploaded product images with auto-detected colors. Supports any product type
// (tees, hoodies, mugs, etc.). User uploads a folder, colors are extracted, then saved
// as color-swatch references for the product's color palette.
model ProductImage {
id String @id @default(cuid())
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
color String // hex color detected from the image, e.g. "#1EA7E0"
imageDataUrl String // the uploaded image itself as a data URL
createdAt DateTime @default(now())
@@unique([productId, color])
}
// Quantity on hand for one product/colour/size combination. Purchases increase
@@ -114,7 +99,7 @@ model Purchase {
id String @id @default(cuid())
supplierName String
purchasedAt DateTime @default(now()) // invoice date, editable — not entry time
invoiceDataUrl String? // the uploaded invoice itself, as a data URL
invoiceUrl String? // disk path under /uploads/invoices/...
invoiceFileName String?
total Int // pence — sum of line totals, recomputed server-side
notes String?
@@ -147,7 +132,7 @@ model Expense {
description String
category String @default("Stock purchases") // "Stock purchases", "Postage", others as user enters them
amount Int // pence
receiptDataUrl String? // uploaded receipt as data URL
receiptUrl String? // disk path under /uploads/receipts/...
receiptFileName String?
notes String?
purchaseId String? @unique // null = standalone expense; set = auto-created from a Purchase (one-to-one)
@@ -167,7 +152,7 @@ model DesignProof {
color String
quantity Int @default(1)
unitPrice Int // cents — defaults to the product's base price if not overridden
imageDataUrl String // the finished design image you uploaded, shown as-is to the customer
imageUrl String // disk path under /uploads/proofs/...
note String? // optional message shown alongside the design
status String @default("PENDING") // PENDING | APPROVED
createdAt DateTime @default(now())
@@ -333,7 +318,7 @@ model CustomRequest {
customerName String
customerEmail String
customerPhone String?
imageDataUrl String // the photo they want us to work with
imageUrl String // disk path under /uploads/custom-requests/...
note String?
status String @default("NEW") // NEW | DONE
createdAt DateTime @default(now())
@@ -430,7 +415,7 @@ model GalleryCategory {
model GalleryPhoto {
id String @id @default(cuid())
imageDataUrl String // base64 data URL
imageUrl String // disk path under /uploads/gallery/...
caption String?
categoryId String? // nullable — a photo can be uncategorized
category GalleryCategory? @relation(fields: [categoryId], references: [id])
+3 -2
View File
@@ -10,11 +10,12 @@ const ERROR_MESSAGES: Record<string, string> = {
invalid_current: 'Current password is incorrect.',
};
export default async function ChangePasswordPage({ searchParams }: { searchParams: { error?: string } }) {
export default async function ChangePasswordPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const { error: errorParam } = await searchParams;
const customer = await getCurrentCustomer();
if (!customer) redirect('/account/login');
const error = searchParams.error ? ERROR_MESSAGES[searchParams.error] : null;
const error = errorParam ? ERROR_MESSAGES[errorParam] : null;
return (
<div className="mx-auto max-w-2xl px-6 py-12">
+3 -2
View File
@@ -1,8 +1,9 @@
import Link from 'next/link';
import { requestPasswordReset } from '../actions';
export default function ForgotPasswordPage({ searchParams }: { searchParams: { sent?: string } }) {
const sent = searchParams.sent === '1';
export default async function ForgotPasswordPage({ searchParams }: { searchParams: Promise<{ sent?: string }> }) {
const params = await searchParams;
const sent = params.sent === '1';
return (
<div className="mx-auto max-w-md px-6 py-16">
+4 -3
View File
@@ -6,9 +6,10 @@ const ERROR_MESSAGES: Record<string, string> = {
rate_limited: 'Too many attempts — please wait a few minutes and try again.',
};
export default function LoginPage({ searchParams }: { searchParams: { error?: string; next?: string } }) {
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null;
const next = searchParams.next ?? '/account';
export default async function LoginPage({ searchParams }: { searchParams: Promise<{ error?: string; next?: string }> }) {
const params = await searchParams;
const error = params.error ? (ERROR_MESSAGES[params.error] ?? 'Something went wrong.') : null;
const next = params.next ?? '/account';
return (
<div className="mx-auto max-w-md px-6 py-16">
+3 -2
View File
@@ -21,12 +21,13 @@ function statusClasses(status: string, trackingNumber?: string | null) {
return 'bg-splash-orange/10 text-splash-orange';
}
export default async function AccountOrderDetailPage({ params }: { params: { id: string } }) {
export default async function AccountOrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const customer = await getCurrentCustomer();
if (!customer) redirect('/account/login');
const order = await prisma.order.findUnique({
where: { id: params.id },
where: { id },
include: { items: true },
});
if (!order || order.customerId !== customer.id) notFound();
+3 -2
View File
@@ -29,11 +29,12 @@ const SUCCESS_MESSAGES: Record<string, string> = {
password_changed: 'Password updated successfully.',
};
export default async function AccountPage({ searchParams }: { searchParams: { success?: string } }) {
export default async function AccountPage({ searchParams }: { searchParams: Promise<{ success?: string }> }) {
const { success: successParam } = await searchParams;
const customer = await getCurrentCustomer();
if (!customer) redirect('/account/login');
const success = searchParams.success ? SUCCESS_MESSAGES[searchParams.success] : null;
const success = successParam ? SUCCESS_MESSAGES[successParam] : null;
const orders = await prisma.order.findMany({
where: { customerId: customer.id },
+4 -3
View File
@@ -10,9 +10,10 @@ const ERROR_MESSAGES: Record<string, string> = {
captcha: 'CAPTCHA verification failed — please try again.',
};
export default function RegisterPage({ searchParams }: { searchParams: { error?: string; next?: string } }) {
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null;
const next = searchParams.next ?? '/account';
export default async function RegisterPage({ searchParams }: { searchParams: Promise<{ error?: string; next?: string }> }) {
const params = await searchParams;
const error = params.error ? (ERROR_MESSAGES[params.error] ?? 'Something went wrong.') : null;
const next = params.next ?? '/account';
return (
<div className="mx-auto max-w-md px-6 py-16">
+4 -3
View File
@@ -8,9 +8,10 @@ const ERROR_MESSAGES: Record<string, string> = {
weak: 'Password must be at least 8 characters.',
};
export default function ResetPasswordPage({ searchParams }: { searchParams: { token?: string; error?: string } }) {
const token = searchParams.token ?? '';
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null;
export default async function ResetPasswordPage({ searchParams }: { searchParams: Promise<{ token?: string; error?: string }> }) {
const params = await searchParams;
const token = params.token ?? '';
const error = params.error ? (ERROR_MESSAGES[params.error] ?? 'Something went wrong.') : null;
if (!token) {
return (
@@ -3,12 +3,14 @@ import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import Price from '@/components/Price';
import { isPdfUrl } from '@/lib/storage';
export default async function ExpenseDetailPage({ params }: { params: { id: string } }) {
const expense = await prisma.expense.findUnique({ where: { id: params.id } });
export default async function ExpenseDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const expense = await prisma.expense.findUnique({ where: { id } });
if (!expense) notFound();
const isPdf = expense.receiptDataUrl?.startsWith('data:application/pdf');
const isPdf = isPdfUrl(expense.receiptUrl);
return (
<div className="mx-auto max-w-2xl px-6 py-12">
@@ -30,20 +32,20 @@ export default async function ExpenseDetailPage({ params }: { params: { id: stri
{expense.notes && <p className="border-l-2 border-splash-orange pl-4 text-sm italic">{expense.notes}</p>}
</div>
{expense.receiptDataUrl && (
{expense.receiptUrl && (
<div className="mt-8">
<p className="tag-label mb-3">Receipt{expense.receiptFileName ? `${expense.receiptFileName}` : ''}</p>
{isPdf ? (
<iframe src={expense.receiptDataUrl} title="Receipt" className="h-[600px] w-full border border-line" />
<iframe src={expense.receiptUrl} title="Receipt" className="h-[600px] w-full border border-line" />
) : (
<img
src={expense.receiptDataUrl}
src={expense.receiptUrl}
alt="Receipt"
className="max-h-[600px] w-full border border-line object-contain"
/>
)}
<a
href={expense.receiptDataUrl}
href={expense.receiptUrl}
download={expense.receiptFileName ?? 'receipt'}
className="mt-2 inline-block tag-label text-clay hover:text-clay-dark"
>
+4 -8
View File
@@ -2,6 +2,7 @@
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { saveUploadedFile } from '@/lib/storage';
export async function createExpense(formData: FormData) {
const dateInput = String(formData.get('date') ?? '').trim();
@@ -15,15 +16,10 @@ export async function createExpense(formData: FormData) {
const amount = Math.max(0, Math.round(Number(amountInput || '0') * 100));
if (!amount) throw new Error('Amount must be greater than 0.');
let receiptDataUrl: string | null = null;
let receiptUrl: string | null = null;
let receiptFileName: string | null = null;
if (receipt && receipt.size > 0) {
if (receipt.size > 8 * 1024 * 1024) {
throw new Error('Receipt file is too large — keep it under 8 MB.');
}
const bytes = Buffer.from(await receipt.arrayBuffer());
const mimeType = receipt.type || 'application/octet-stream';
receiptDataUrl = `data:${mimeType};base64,${bytes.toString('base64')}`;
receiptUrl = await saveUploadedFile(receipt, 'receipts', { maxBytes: 8 * 1024 * 1024, allowPdf: true });
receiptFileName = receipt.name || 'receipt';
}
@@ -33,7 +29,7 @@ export async function createExpense(formData: FormData) {
description,
category,
amount,
receiptDataUrl,
receiptUrl,
receiptFileName,
notes: notes || null,
},
+1 -1
View File
@@ -44,7 +44,7 @@ export default async function ExpensesPage() {
<span className="w-20 shrink-0 text-right font-mono text-sm">
<Price cents={e.amount} />
</span>
{e.receiptDataUrl && (
{e.receiptUrl && (
<Link href={`/admin/accounts/expenses/${e.id}`} className="tag-label text-muted hover:text-ink">
View
</Link>
+10 -9
View File
@@ -26,12 +26,13 @@ type LedgerEntry = {
export default async function AccountsPage({
searchParams,
}: {
searchParams: { type?: string; from?: string; to?: string };
searchParams: Promise<{ type?: string; from?: string; to?: string }>;
}) {
const type = searchParams.type === 'web' || searchParams.type === 'manual' ? searchParams.type : 'all';
const from = searchParams.from ? new Date(searchParams.from) : null;
const sp = await searchParams;
const type = sp.type === 'web' || sp.type === 'manual' ? sp.type : 'all';
const from = sp.from ? new Date(sp.from) : null;
// Include the whole "to" day, not just its midnight.
const to = searchParams.to ? new Date(`${searchParams.to}T23:59:59`) : null;
const to = sp.to ? new Date(`${sp.to}T23:59:59`) : null;
let orders: any[] = [];
let manualSales: any[] = [];
@@ -88,8 +89,8 @@ export default async function AccountsPage({
const filterHref = (t: string) => {
const params = new URLSearchParams();
if (t !== 'all') params.set('type', t);
if (searchParams.from) params.set('from', searchParams.from);
if (searchParams.to) params.set('to', searchParams.to);
if (sp.from) params.set('from', sp.from);
if (sp.to) params.set('to', sp.to);
const qs = params.toString();
return `/admin/accounts${qs ? `?${qs}` : ''}`;
};
@@ -160,7 +161,7 @@ export default async function AccountsPage({
<input
type="date"
name="from"
defaultValue={searchParams.from ?? ''}
defaultValue={sp.from ?? ''}
className="border border-line bg-paper px-2 py-1.5"
/>
</label>
@@ -169,14 +170,14 @@ export default async function AccountsPage({
<input
type="date"
name="to"
defaultValue={searchParams.to ?? ''}
defaultValue={sp.to ?? ''}
className="border border-line bg-paper px-2 py-1.5"
/>
</label>
<button type="submit" className="border border-line px-3 py-1.5 hover:border-clay hover:text-clay">
Apply
</button>
{(searchParams.from || searchParams.to) && (
{(sp.from || sp.to) && (
<Link href={filterHref(type)} className="tag-label text-muted hover:text-ink">
Clear dates
</Link>
@@ -3,15 +3,17 @@ import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import Price from '@/components/Price';
import { isPdfUrl } from '@/lib/storage';
export default async function PurchaseDetailPage({ params }: { params: { id: string } }) {
export default async function PurchaseDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const purchase = await prisma.purchase.findUnique({
where: { id: params.id },
where: { id },
include: { items: { include: { product: true } } },
});
if (!purchase) notFound();
const isPdf = purchase.invoiceDataUrl?.startsWith('data:application/pdf');
const isPdf = isPdfUrl(purchase.invoiceUrl);
return (
<div className="mx-auto max-w-2xl px-6 py-12">
@@ -50,20 +52,20 @@ export default async function PurchaseDetailPage({ params }: { params: { id: str
{purchase.notes && <div className="mt-6 border-l-2 border-splash-orange pl-4 text-sm italic">{purchase.notes}</div>}
{purchase.invoiceDataUrl && (
{purchase.invoiceUrl && (
<div className="mt-8">
<p className="tag-label mb-3">Invoice{purchase.invoiceFileName ? `${purchase.invoiceFileName}` : ''}</p>
{isPdf ? (
<iframe src={purchase.invoiceDataUrl} title="Invoice" className="h-[600px] w-full border border-line" />
<iframe src={purchase.invoiceUrl} title="Invoice" className="h-[600px] w-full border border-line" />
) : (
<img
src={purchase.invoiceDataUrl}
src={purchase.invoiceUrl}
alt="Invoice"
className="max-h-[600px] w-full border border-line object-contain"
/>
)}
<a
href={purchase.invoiceDataUrl}
href={purchase.invoiceUrl}
download={purchase.invoiceFileName ?? 'invoice'}
className="mt-2 inline-block tag-label text-clay hover:text-clay-dark"
>
+4 -8
View File
@@ -4,6 +4,7 @@ import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { applyStockMovement } from '@/lib/stock';
import { logFinancialChange } from '@/lib/financialAudit';
import { saveUploadedFile } from '@/lib/storage';
export type PurchaseItemInput = {
productId: string | null; // null = loose material line (ink, packaging…)
@@ -42,15 +43,10 @@ export async function createPurchase(formData: FormData) {
}
}
let invoiceDataUrl: string | null = null;
let invoiceUrl: string | null = null;
let invoiceFileName: string | null = null;
if (invoice && invoice.size > 0) {
if (invoice.size > MAX_INVOICE_BYTES) {
throw new Error('Invoice file is too large — keep it under 8 MB.');
}
const bytes = Buffer.from(await invoice.arrayBuffer());
const mimeType = invoice.type || 'application/octet-stream';
invoiceDataUrl = `data:${mimeType};base64,${bytes.toString('base64')}`;
invoiceUrl = await saveUploadedFile(invoice, 'invoices', { maxBytes: MAX_INVOICE_BYTES, allowPdf: true });
invoiceFileName = invoice.name || 'invoice';
}
@@ -62,7 +58,7 @@ export async function createPurchase(formData: FormData) {
data: {
supplierName,
purchasedAt: purchaseDate,
invoiceDataUrl,
invoiceUrl,
invoiceFileName,
total,
notes: notes || null,
+1 -1
View File
@@ -44,7 +44,7 @@ export default async function PurchasesPage() {
{p.items.map((i) => `${i.quantity}× ${i.description}`).join(', ')}
</p>
</div>
{p.invoiceDataUrl && <span className="tag-label hidden text-muted sm:inline">Invoice attached</span>}
{p.invoiceUrl && <span className="tag-label hidden text-muted sm:inline">Invoice attached</span>}
<span className="w-20 shrink-0 text-right font-mono text-sm">
<Price cents={p.total} />
</span>
@@ -9,14 +9,14 @@ import ReconciliationClient from './client';
export default async function ReconciliationPage({
searchParams,
}: {
searchParams: { id?: string };
searchParams: Promise<{ id?: string }>;
}) {
const isAdmin = await isAdminAuthenticated();
if (!isAdmin) {
return <div className="px-6 py-12 text-center">Unauthorized</div>;
}
const statementId = searchParams.id;
const { id: statementId } = await searchParams;
if (!statementId) {
// Show list of statements
+4 -3
View File
@@ -3,12 +3,13 @@ import { prisma } from '@/lib/prisma';
import { updateCategory } from '../../actions';
import AdminNav from '@/components/AdminNav';
export default async function EditCategoryPage({ params }: { params: { id: string } }) {
const category = await prisma.category.findUnique({ where: { id: params.id } });
export default async function EditCategoryPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const category = await prisma.category.findUnique({ where: { id } });
if (!category) notFound();
const parentCategories = await prisma.category.findMany({
where: { parentId: null, id: { not: params.id } },
where: { parentId: null, id: { not: id } },
orderBy: { name: 'asc' },
});
+3 -2
View File
@@ -3,7 +3,8 @@ import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import { deleteCategory, updateCategoryVisibility } from './actions';
export default async function CategoriesPage({ searchParams }: { searchParams: { error?: string } }) {
export default async function CategoriesPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const { error } = await searchParams;
const categories = await prisma.category.findMany({ orderBy: { createdAt: 'asc' } });
const counts = await prisma.product.groupBy({ by: ['category'], _count: true });
const countFor = (slug: string) => counts.find((c) => c.category === slug)?._count ?? 0;
@@ -21,7 +22,7 @@ export default async function CategoriesPage({ searchParams }: { searchParams: {
</Link>
</div>
{searchParams.error === 'in_use' && (
{error === 'in_use' && (
<div className="mt-6 border border-splash-orange bg-splash-orange/5 p-4 text-sm">
Can&apos;t delete that category there are still products using it. Move or delete those
products first.
+3 -2
View File
@@ -10,11 +10,12 @@ const ERROR_MESSAGES: Record<string, string> = {
invalid_current: 'Current password is incorrect.',
};
export default async function ChangeAdminPasswordPage({ searchParams }: { searchParams: { error?: string } }) {
export default async function ChangeAdminPasswordPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const { error: errorParam } = await searchParams;
const isAuthenticated = await isAdminAuthenticated();
if (!isAuthenticated) redirect('/admin/login');
const error = searchParams.error ? ERROR_MESSAGES[searchParams.error] : null;
const error = errorParam ? ERROR_MESSAGES[errorParam] : null;
return (
<div className="mx-auto max-w-2xl px-6 py-12">
+3 -2
View File
@@ -3,7 +3,8 @@ import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import { deleteCollection } from './actions';
export default async function CollectionsPage({ searchParams }: { searchParams: { error?: string } }) {
export default async function CollectionsPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const { error } = await searchParams;
const collections = await prisma.collection.findMany({ orderBy: { createdAt: 'asc' } });
const occasions = collections.filter((c) => c.group === 'OCCASION');
const recipients = collections.filter((c) => c.group === 'RECIPIENT');
@@ -58,7 +59,7 @@ export default async function CollectionsPage({ searchParams }: { searchParams:
products from each product&apos;s edit page.
</p>
{searchParams.error === 'in_use' && (
{error === 'in_use' && (
<div className="mt-6 border border-splash-orange bg-splash-orange/5 p-4 text-sm">
Can&apos;t delete that there are still products tagged with it. Untag those products
first.
+5 -4
View File
@@ -6,9 +6,10 @@ import ShareButtons from '@/components/ShareButtons';
import DownloadPhotoButton from '@/components/DownloadPhotoButton';
import { deleteRequest, markRequestDone, markRequestNew } from '../actions';
export default async function CustomRequestDetailPage({ params }: { params: { id: string } }) {
export default async function CustomRequestDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const request = await prisma.customRequest.findUnique({
where: { id: params.id },
where: { id },
include: { product: true },
});
if (!request) notFound();
@@ -33,7 +34,7 @@ export default async function CustomRequestDetailPage({ params }: { params: { id
</div>
<div className="mt-6 border border-line bg-surface p-6">
<img src={request.imageDataUrl} alt="Customer's photo" className="mx-auto max-h-96 object-contain" />
<img src={request.imageUrl ?? ''} alt="Customer's photo" className="mx-auto max-h-96 object-contain" />
</div>
{request.note && (
@@ -54,7 +55,7 @@ export default async function CustomRequestDetailPage({ params }: { params: { id
</div>
<div className="mt-8 flex flex-wrap gap-3">
<DownloadPhotoButton photoUrl={request.imageDataUrl} customerName={request.customerName} />
<DownloadPhotoButton photoUrl={request.imageUrl ?? ''} customerName={request.customerName} />
<Link
href={`/admin/custom-requests/${request.id}/start-design`}
@@ -3,9 +3,10 @@ import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import { startDesignWithProduct } from './actions';
export default async function StartDesignPage({ params }: { params: { id: string } }) {
export default async function StartDesignPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const request = await prisma.customRequest.findUnique({
where: { id: params.id },
where: { id },
include: { product: true },
});
if (!request) notFound();
@@ -22,7 +23,7 @@ export default async function StartDesignPage({ params }: { params: { id: string
<p className="mt-2 text-sm text-muted">Select which product to design this photo on</p>
<div className="mt-6 border border-line bg-surface p-6">
<img src={request.imageDataUrl} alt="Customer's photo" className="mx-auto max-h-64 object-contain" />
<img src={request.imageUrl ?? ''} alt="Customer's photo" className="mx-auto max-h-64 object-contain" />
</div>
<div className="mt-6 border border-line bg-surface p-6">
+1 -1
View File
@@ -24,7 +24,7 @@ export default async function CustomRequestsPage() {
{requests.map((r) => (
<div key={r.id} className="flex items-center gap-4 py-4 hover:bg-surface">
<Link href={`/admin/custom-requests/${r.id}`} className="flex flex-1 items-center gap-4">
<img src={r.imageDataUrl} alt="" className="h-14 w-14 border border-line object-cover" />
<img src={r.imageUrl ?? ''} alt="" className="h-14 w-14 border border-line object-cover" />
<div className="flex-1">
<p className="font-medium">{r.customerName}</p>
<p className="truncate text-sm text-muted">{r.customerEmail}</p>
+5 -4
View File
@@ -12,8 +12,9 @@ const SOURCE_LABELS: Record<string, string> = {
export default async function CustomersPage({
searchParams,
}: {
searchParams: { sent?: string; failed?: string };
searchParams: Promise<{ sent?: string; failed?: string }>;
}) {
const params = await searchParams;
let entries: any[] = [];
try {
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.
</p>
{searchParams.sent !== undefined && (
{params.sent !== undefined && (
<div className="mt-6 border border-splash-blue bg-splash-blue/5 p-4 text-sm">
Sent to {searchParams.sent} recipient{searchParams.sent === '1' ? '' : 's'}.
{Number(searchParams.failed) > 0 && ` ${searchParams.failed} failed to send.`}
Sent to {params.sent} recipient{params.sent === '1' ? '' : 's'}.
{Number(params.failed) > 0 && ` ${params.failed} failed to send.`}
</div>
)}
+9 -8
View File
@@ -6,17 +6,18 @@ import Link from 'next/link';
export default async function FinancialAuditPage({
searchParams,
}: {
searchParams: { type?: string; action?: string; from?: string; to?: string };
searchParams: Promise<{ type?: string; action?: string; from?: string; to?: string }>;
}) {
const isAdmin = await isAdminAuthenticated();
if (!isAdmin) {
return <div className="px-6 py-12 text-center">Unauthorized</div>;
}
const entityType = searchParams.type as any;
const action = searchParams.action as any;
const fromDate = searchParams.from ? new Date(searchParams.from) : null;
const toDate = searchParams.to ? new Date(`${searchParams.to}T23:59:59`) : null;
const sp = await searchParams;
const entityType = sp.type as any;
const action = sp.action as any;
const fromDate = sp.from ? new Date(sp.from) : null;
const toDate = sp.to ? new Date(`${sp.to}T23:59:59`) : null;
const logs = await prisma.financialAuditLog.findMany({
where: {
@@ -110,7 +111,7 @@ export default async function FinancialAuditPage({
<input
type="date"
name="from"
defaultValue={searchParams.from ?? ''}
defaultValue={sp.from ?? ''}
className="border border-line bg-paper px-3 py-2 text-sm"
placeholder="From"
/>
@@ -118,7 +119,7 @@ export default async function FinancialAuditPage({
<input
type="date"
name="to"
defaultValue={searchParams.to ?? ''}
defaultValue={sp.to ?? ''}
className="border border-line bg-paper px-3 py-2 text-sm"
placeholder="To"
/>
@@ -130,7 +131,7 @@ export default async function FinancialAuditPage({
Filter
</button>
{(entityType || action || searchParams.from || searchParams.to) && (
{(entityType || action || sp.from || sp.to) && (
<Link href="/admin/financial-audit" className="text-sm text-muted hover:text-ink">
Clear filters
</Link>
+4 -3
View File
@@ -4,9 +4,10 @@ import AdminNav from '@/components/AdminNav';
import { updateGalleryPhoto } from '../../actions';
import { notFound } from 'next/navigation';
export default async function EditGalleryPhotoPage({ params }: { params: { id: string } }) {
export default async function EditGalleryPhotoPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const photo = await prisma.galleryPhoto.findUnique({
where: { id: params.id },
where: { id },
include: { category: true },
});
@@ -25,7 +26,7 @@ export default async function EditGalleryPhotoPage({ params }: { params: { id: s
</p>
<div className="mt-8 flex gap-6">
<img src={photo.imageDataUrl} alt="" className="h-32 w-32 border border-line object-cover flex-shrink-0" />
<img src={photo.imageUrl ?? ''} alt="" className="h-32 w-32 border border-line object-cover flex-shrink-0" />
<div className="flex-1 space-y-6">
<form action={updateGalleryPhoto}>
<input type="hidden" name="id" value={photo.id} />
+5 -10
View File
@@ -2,6 +2,7 @@
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { saveUploadedFile } from '@/lib/storage';
function slugify(input: string) {
return input
@@ -21,12 +22,6 @@ async function uniqueSlug(base: string) {
return slug;
}
async function fileToDataUrl(file: File): Promise<string> {
const bytes = Buffer.from(await file.arrayBuffer());
const mimeType = file.type || 'image/png';
return `data:${mimeType};base64,${bytes.toString('base64')}`;
}
export async function uploadGalleryPhoto(formData: FormData) {
const caption = String(formData.get('caption') ?? '').trim();
const categoryId = String(formData.get('categoryId') ?? '').trim() || null;
@@ -36,11 +31,11 @@ export async function uploadGalleryPhoto(formData: FormData) {
throw new Error('A photo is required.');
}
const imageDataUrl = await fileToDataUrl(file);
const imageUrl = await saveUploadedFile(file, 'gallery');
await prisma.galleryPhoto.create({
data: {
imageDataUrl,
imageUrl,
caption: caption || null,
categoryId,
},
@@ -113,10 +108,10 @@ export async function uploadGalleryPhotoBulk(formData: FormData) {
if (file.size === 0) continue;
try {
const imageDataUrl = await fileToDataUrl(file);
const imageUrl = await saveUploadedFile(file, 'gallery');
const photo = await prisma.galleryPhoto.create({
data: {
imageDataUrl,
imageUrl,
caption: null,
categoryId,
},
@@ -1,12 +1,13 @@
import AdminNav from '@/components/AdminNav';
import { createGalleryCategory } from '../../actions';
export default function NewGalleryCategoryPage({
export default async function NewGalleryCategoryPage({
searchParams,
}: {
searchParams: { redirectTo?: string };
searchParams: Promise<{ redirectTo?: string }>;
}) {
const redirectTo = searchParams.redirectTo || '/admin/gallery/categories';
const { redirectTo: redirectToParam } = await searchParams;
const redirectTo = redirectToParam || '/admin/gallery/categories';
return (
<div className="mx-auto max-w-md px-6 py-12">
+3 -2
View File
@@ -3,7 +3,8 @@ import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import { deleteGalleryCategory } from '../actions';
export default async function GalleryCategoriesPage({ searchParams }: { searchParams: { error?: string } }) {
export default async function GalleryCategoriesPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const { error } = await searchParams;
const categories = await prisma.galleryCategory.findMany({ orderBy: { createdAt: 'asc' } });
const counts = await prisma.galleryPhoto.groupBy({ by: ['categoryId'], _count: true });
const countFor = (id: string) => counts.find((c) => c.categoryId === id)?._count ?? 0;
@@ -24,7 +25,7 @@ export default async function GalleryCategoriesPage({ searchParams }: { searchPa
Used to group photos on the public gallery separate from your product categories.
</p>
{searchParams.error === 'in_use' && (
{error === 'in_use' && (
<div className="mt-6 border border-splash-orange bg-splash-orange/5 p-4 text-sm">
Can&apos;t delete that category there are still photos using it. Move or delete those
photos first.
+1 -1
View File
@@ -41,7 +41,7 @@ export default async function AdminGalleryPage() {
<div className="mt-8 divide-y divide-line border-t border-line">
{photos.map((p) => (
<div key={p.id} className="flex items-center gap-4 py-4">
<img src={p.imageDataUrl} alt="" className="h-16 w-16 border border-line object-cover" />
<img src={p.imageUrl ?? ''} alt="" className="h-16 w-16 border border-line object-cover" />
<div className="flex-1">
<p className="font-medium">{p.caption || <span className="text-muted">No caption</span>}</p>
<p className="tag-label mt-0.5">
+4 -3
View File
@@ -8,9 +8,10 @@ function formatPrice(cents: number) {
return `£${(cents / 100).toFixed(2)}`;
}
export default async function AdminThreadPage({ params }: { params: { id: string } }) {
export default async function AdminThreadPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const proof = await prisma.designProof.findUnique({
where: { id: params.id },
where: { id },
include: { product: true },
});
if (!proof) notFound();
@@ -20,7 +21,7 @@ export default async function AdminThreadPage({ params }: { params: { id: string
<AdminNav />
<div className="flex items-center gap-4 border border-line bg-surface p-4">
<img src={proof.imageDataUrl} alt="" className="h-16 w-16 border border-line object-cover" />
<img src={proof.imageUrl ?? ''} alt="" className="h-16 w-16 border border-line object-cover" />
<div className="flex-1">
<p className="font-medium">{proof.customerName || proof.customerEmail}</p>
<p className="text-sm text-muted">
+1 -1
View File
@@ -32,7 +32,7 @@ export default async function InboxPage() {
href={`/admin/inbox/${p.id}`}
className="flex items-center gap-4 py-4 hover:bg-surface"
>
<img src={p.imageDataUrl} alt="" className="h-14 w-14 border border-line object-cover" />
<img src={p.imageUrl ?? ''} alt="" className="h-14 w-14 border border-line object-cover" />
<div className="flex-1">
<p className="font-medium">{p.customerName || p.customerEmail}</p>
<p className="truncate text-sm text-muted">
+3 -2
View File
@@ -8,9 +8,10 @@ function formatPrice(cents: number) {
return `£${(cents / 100).toFixed(2)}`;
}
export default async function OrderDetailPage({ params }: { params: { id: string } }) {
export default async function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const order = await prisma.order.findUnique({
where: { id: params.id },
where: { id },
include: { items: true },
});
if (!order) notFound();
+6 -5
View File
@@ -26,14 +26,15 @@ const SUCCESS_MESSAGES: Record<string, string> = {
password_changed: 'Password updated successfully.',
};
export default async function OrdersPage({ searchParams }: { searchParams: { archived?: string; personalised?: string; success?: string; page?: string } }) {
export default async function OrdersPage({ searchParams }: { searchParams: Promise<{ archived?: string; personalised?: string; success?: string; page?: string }> }) {
await autoArchiveOldOrders();
const success = searchParams.success ? SUCCESS_MESSAGES[searchParams.success] : null;
const sp = await searchParams;
const success = sp.success ? SUCCESS_MESSAGES[sp.success] : null;
const showArchived = searchParams.archived === '1';
const showOnlyPersonalised = searchParams.personalised === '1';
const page = Math.max(1, parseInt(searchParams.page || '1'));
const showArchived = sp.archived === '1';
const showOnlyPersonalised = sp.personalised === '1';
const page = Math.max(1, parseInt(sp.page || '1'));
const pageSize = 50;
const skip = (page - 1) * pageSize;
+7 -12
View File
@@ -3,6 +3,7 @@
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { DEFAULT_PRINT_AREAS } from '@/lib/mockupDefaults';
import { saveUploadedFile } from '@/lib/storage';
function slugify(input: string) {
return input
@@ -22,15 +23,9 @@ async function uniqueSlug(base: string) {
return slug;
}
async function fileToDataUrl(file: File): Promise<string> {
const bytes = Buffer.from(await file.arrayBuffer());
const mimeType = file.type || 'image/png';
return `data:${mimeType};base64,${bytes.toString('base64')}`;
}
// Reads the per-color photo file inputs the ColorPicker renders (named
// "colorPhoto__<hexWithoutHash>" for front, "colorPhotoBack__<hexWithoutHash>" for
// back) and builds { hex: dataUrl } for whichever colors actually got a file attached.
// back) and builds { hex: url } for whichever colors actually got a file attached.
async function extractColorPhotos(
formData: FormData,
colors: string[],
@@ -41,7 +36,7 @@ async function extractColorPhotos(
for (const hex of colors) {
const file = formData.get(`${prefix}${hex.replace('#', '')}`) as File | null;
if (file && file.size > 0) {
result[hex] = await fileToDataUrl(file);
result[hex] = await saveUploadedFile(file, 'products');
}
}
return result;
@@ -103,8 +98,8 @@ export async function createProduct(formData: FormData) {
const weightKgInput = String(formData.get('weightKg') ?? '').trim();
const weightGrams = weightKgInput ? Math.round(Number(weightKgInput) * 1000) : null;
const imageUrl = imageFile && imageFile.size > 0 ? await fileToDataUrl(imageFile) : null;
const imageUrlBack = imageBackFile && imageBackFile.size > 0 ? await fileToDataUrl(imageBackFile) : null;
const imageUrl = imageFile && imageFile.size > 0 ? await saveUploadedFile(imageFile, 'products') : null;
const imageUrlBack = imageBackFile && imageBackFile.size > 0 ? await saveUploadedFile(imageBackFile, 'products') : null;
const colorPhotos = await extractColorPhotos(formData, colors, 'front');
const colorPhotosBack = await extractColorPhotos(formData, colors, 'back');
const saleFields = parseSaleFields(formData);
@@ -310,7 +305,7 @@ export async function updateProduct(formData: FormData) {
data.imageUrl = null;
data.printArea = JSON.stringify(DEFAULT_PRINT_AREAS[mockup] ?? DEFAULT_PRINT_AREAS.tshirt);
} else if (imageFile && imageFile.size > 0) {
data.imageUrl = await fileToDataUrl(imageFile);
data.imageUrl = await saveUploadedFile(imageFile, 'products');
data.printArea = JSON.stringify(
hasCustomPrintArea
? {
@@ -341,7 +336,7 @@ export async function updateProduct(formData: FormData) {
data.imageUrlBack = null;
data.printAreaBack = null;
} else if (imageBackFile && imageBackFile.size > 0) {
data.imageUrlBack = await fileToDataUrl(imageBackFile);
data.imageUrlBack = await saveUploadedFile(imageBackFile, 'products');
if (hasCustomPrintAreaBack) {
data.printAreaBack = JSON.stringify({
xPct: Number(formData.get('printXBack') ?? 0.3),
+3 -2
View File
@@ -4,9 +4,10 @@ import AdminNav from '@/components/AdminNav';
import PromotionScopePicker from '@/components/PromotionScopePicker';
import { updatePromotion } from '../../actions';
export default async function EditPromotionPage({ params }: { params: { id: string } }) {
export default async function EditPromotionPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [promotion, products] = await Promise.all([
prisma.promotion.findUnique({ where: { id: params.id }, include: { products: { select: { id: true } } } }),
prisma.promotion.findUnique({ where: { id }, include: { products: { select: { id: true } } } }),
prisma.product.findMany({ orderBy: { name: 'asc' } }),
]);
if (!promotion) notFound();
+8 -6
View File
@@ -7,18 +7,20 @@ export default async function ProofSentPage({
params,
searchParams,
}: {
params: { id: string };
searchParams: { emailed?: string };
params: Promise<{ id: string }>;
searchParams: Promise<{ emailed?: string }>;
}) {
const { id } = await params;
const { emailed: emailedParam } = await searchParams;
const proof = await prisma.designProof.findUnique({
where: { id: params.id },
where: { id },
include: { product: true },
});
if (!proof) notFound();
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
const link = `${siteUrl}/proofs/${proof.id}`;
const emailed = searchParams.emailed === '1';
const emailed = emailedParam === '1';
return (
<div className="mx-auto max-w-xl px-6 py-16 text-center">
@@ -43,9 +45,9 @@ export default async function ProofSentPage({
)}
<div className="mt-8 border border-line bg-surface p-6">
{proof.imageDataUrl ? (
{proof.imageUrl ? (
<img
src={proof.imageDataUrl}
src={proof.imageUrl}
alt="Uploaded design"
className="mx-auto max-h-64 object-contain"
/>
+3 -4
View File
@@ -3,6 +3,7 @@
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { sendProofEmail } from '@/lib/mail';
import { saveUploadedFile } from '@/lib/storage';
export async function createProof(formData: FormData) {
const productId = String(formData.get('productId') ?? '');
@@ -21,9 +22,7 @@ export async function createProof(formData: FormData) {
const product = await prisma.product.findUnique({ where: { id: productId } });
if (!product) throw new Error('Product not found.');
const bytes = Buffer.from(await file.arrayBuffer());
const mimeType = file.type || 'image/png';
const imageDataUrl = `data:${mimeType};base64,${bytes.toString('base64')}`;
const imageUrl = await saveUploadedFile(file, 'proofs');
const unitPrice = priceOverride && String(priceOverride).trim() !== ''
? Math.round(Number(priceOverride) * 100)
@@ -37,7 +36,7 @@ export async function createProof(formData: FormData) {
color,
quantity,
unitPrice,
imageDataUrl,
imageUrl,
note: note || null,
},
});
+5 -4
View File
@@ -5,8 +5,9 @@ import AdminNav from '@/components/AdminNav';
export default async function NewProofPage({
searchParams,
}: {
searchParams: { productId?: string; customerName?: string; customerEmail?: string };
searchParams: Promise<{ productId?: string; customerName?: string; customerEmail?: string }>;
}) {
const params = await searchParams;
const products = await prisma.product.findMany({ orderBy: { name: 'asc' } });
return (
@@ -27,7 +28,7 @@ export default async function NewProofPage({
id="productId"
name="productId"
required
defaultValue={searchParams.productId}
defaultValue={params.productId}
className="w-full border border-line px-3 py-2 text-sm"
>
{products.map((p) => (
@@ -46,7 +47,7 @@ export default async function NewProofPage({
<input
id="customerName"
name="customerName"
defaultValue={searchParams.customerName}
defaultValue={params.customerName}
className="w-full border border-line px-3 py-2 text-sm"
placeholder="Jane Doe"
/>
@@ -60,7 +61,7 @@ export default async function NewProofPage({
name="customerEmail"
type="email"
required
defaultValue={searchParams.customerEmail}
defaultValue={params.customerEmail}
className="w-full border border-line px-3 py-2 text-sm"
placeholder="jane@example.com"
/>
+1 -1
View File
@@ -29,7 +29,7 @@ export default async function ProofsListPage() {
<div className="mt-8 divide-y divide-line border-t border-line">
{proofs.map((p) => (
<div key={p.id} className="flex items-center gap-4 py-4">
<img src={p.imageDataUrl} alt="" className="h-16 w-16 border border-line object-cover" />
<img src={p.imageUrl ?? ''} alt="" className="h-16 w-16 border border-line object-cover" />
<div className="flex-1">
<p className="font-medium">{p.customerName || p.customerEmail}</p>
<p className="text-sm text-muted">
+3 -2
View File
@@ -20,8 +20,9 @@ async function deleteReview(reviewId: string) {
redirect('/admin/reviews?status=pending');
}
export default async function AdminReviewsPage({ searchParams }: { searchParams: { status?: string } }) {
const status = searchParams.status === 'approved' ? 'approved' : 'pending';
export default async function AdminReviewsPage({ searchParams }: { searchParams: Promise<{ status?: string }> }) {
const { status: statusParam } = await searchParams;
const status = statusParam === 'approved' ? 'approved' : 'pending';
const reviews = await prisma.review.findMany({
where: {
+26 -17
View File
@@ -7,6 +7,11 @@ import { fetchActivePromotions } from '@/lib/promotions';
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
import { getRoyalMailShippingQuotes } from '@/lib/royalmail-shipping';
import { cleanupPendingOrders } from '@/lib/cleanupOrders';
import { saveDataUrl } from '@/lib/storage';
async function saveIfPresent(dataUrl: string | null | undefined, category: 'designs' | 'design-placements') {
return dataUrl ? saveDataUrl(dataUrl, category) : dataUrl;
}
type CheckoutCartItem = {
productId: string;
@@ -179,6 +184,26 @@ export async function POST(req: Request) {
const total = subtotal + shippingCost;
// Design previews arrive from the client as base64 data URLs (canvas/Konva
// export) — write them to disk now so the Order row only ever stores a short URL.
const orderItems = await Promise.all(
pricedItems.map(async (i) => ({
productId: i.productId,
productName: i.name,
quantity: i.quantity,
color: i.color,
size: i.size,
unitPrice: i.unitPrice,
designJson: JSON.stringify(i.designJson),
designPreviewUrl: await saveDataUrl(i.designPreviewUrl, 'designs'),
designPreviewUrlBack: await saveIfPresent(i.designPreviewUrlBack, 'designs'),
placementPreviewUrl: await saveIfPresent(i.placementPreviewUrl, 'design-placements'),
placementPreviewUrlBack: await saveIfPresent(i.placementPreviewUrlBack, 'design-placements'),
designWidthCm: i.designWidthCm,
designHeightCm: i.designHeightCm,
})),
);
// Save the order up front, before redirecting to Stripe — this is what makes
// the design files (and the order itself) retrievable afterward, whether or
// not the customer ever makes it back to the success page.
@@ -194,23 +219,7 @@ export async function POST(req: Request) {
guestName: guest?.name,
guestPhone: guest?.phone,
isCollectionPickup: isCollection,
items: {
create: pricedItems.map((i) => ({
productId: i.productId,
productName: i.name,
quantity: i.quantity,
color: i.color,
size: i.size,
unitPrice: i.unitPrice,
designJson: JSON.stringify(i.designJson),
designPreviewUrl: i.designPreviewUrl,
designPreviewUrlBack: i.designPreviewUrlBack,
placementPreviewUrl: i.placementPreviewUrl,
placementPreviewUrlBack: i.placementPreviewUrlBack,
designWidthCm: i.designWidthCm,
designHeightCm: i.designHeightCm,
})),
},
items: { create: orderItems },
},
});
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { saveUploadedFile } from '@/lib/storage';
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
// Images a customer drops into the design tool (photos to print on a product).
// Uploaded immediately on add so the design JSON only ever stores a short URL,
// instead of the base64 data URL it held before — same fix as the other image
// fields, just for images embedded inside a design rather than a DB column.
export async function POST(req: Request) {
const ip = getClientIp(req.headers);
const { allowed } = await checkRateLimit(`design-upload:${ip}`, 60, 10 * 60 * 1000);
if (!allowed) {
return NextResponse.json({ error: 'Too many uploads — please wait a moment and try again.' }, { status: 429 });
}
const formData = await req.formData();
const file = formData.get('image') as File | null;
if (!file || file.size === 0) {
return NextResponse.json({ error: 'No image provided.' }, { status: 400 });
}
try {
const url = await saveUploadedFile(file, 'design-uploads');
return NextResponse.json({ url });
} catch (err) {
const message = err instanceof Error ? err.message : 'Could not process that image.';
return NextResponse.json({ error: message }, { status: 400 });
}
}
+5 -4
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { saveDataUrl } from '@/lib/storage';
export async function POST(
req: Request,
@@ -29,10 +30,10 @@ export async function POST(
where: { id: id },
data: {
...(designJson && { designJson }),
...(designPreviewUrl && { designPreviewUrl }),
...(designPreviewUrlBack && { designPreviewUrlBack }),
...(placementPreviewUrl && { placementPreviewUrl }),
...(placementPreviewUrlBack && { placementPreviewUrlBack }),
...(designPreviewUrl && { designPreviewUrl: await saveDataUrl(designPreviewUrl, 'designs') }),
...(designPreviewUrlBack && { designPreviewUrlBack: await saveDataUrl(designPreviewUrlBack, 'designs') }),
...(placementPreviewUrl && { placementPreviewUrl: await saveDataUrl(placementPreviewUrl, 'design-placements') }),
...(placementPreviewUrlBack && { placementPreviewUrlBack: await saveDataUrl(placementPreviewUrlBack, 'design-placements') }),
...(designWidthCm !== undefined && { designWidthCm }),
...(designHeightCm !== undefined && { designHeightCm }),
},
+9 -4
View File
@@ -2,6 +2,11 @@ import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { sendDesignSubmissionNotification } from '@/lib/mail';
import { getCurrentCustomer } from '@/lib/auth';
import { saveDataUrl } from '@/lib/storage';
async function saveIfPresent(dataUrl: string | null | undefined, category: 'designs' | 'design-placements') {
return dataUrl ? saveDataUrl(dataUrl, category) : dataUrl;
}
export async function POST(req: Request) {
try {
@@ -49,10 +54,10 @@ export async function POST(req: Request) {
customerName: customer.name,
productId,
designJson,
designPreviewUrl,
designPreviewUrlBack,
placementPreviewUrl,
placementPreviewUrlBack,
designPreviewUrl: await saveDataUrl(designPreviewUrl, 'designs'),
designPreviewUrlBack: await saveIfPresent(designPreviewUrlBack, 'designs'),
placementPreviewUrl: await saveIfPresent(placementPreviewUrl, 'design-placements'),
placementPreviewUrlBack: await saveIfPresent(placementPreviewUrlBack, 'design-placements'),
designWidthCm: designWidthCm || null,
designHeightCm: designHeightCm || null,
designImageDimensions: designImageDimensions && designImageDimensions.length > 0 ? JSON.stringify(designImageDimensions) : null,
+2 -2
View File
@@ -6,9 +6,9 @@ import Price from '@/components/Price';
export default async function CheckoutSuccessPage({
searchParams,
}: {
searchParams: { session_id?: string };
searchParams: Promise<{ session_id?: string }>;
}) {
const sessionId = searchParams.session_id;
const { session_id: sessionId } = await searchParams;
let order = null;
if (sessionId) {
try {
+4 -3
View File
@@ -10,10 +10,11 @@ const ERROR_MESSAGES: Record<string, string> = {
export default async function ContactPage({
searchParams,
}: {
searchParams: { sent?: string; error?: string };
searchParams: Promise<{ sent?: string; error?: string }>;
}) {
const params = await searchParams;
const customer = await getCurrentCustomer();
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null;
const error = params.error ? (ERROR_MESSAGES[params.error] ?? 'Something went wrong.') : null;
return (
<div className="mx-auto max-w-2xl px-6 py-12">
@@ -24,7 +25,7 @@ export default async function ContactPage({
back to you.
</p>
{searchParams.sent === '1' ? (
{params.sent === '1' ? (
<div className="mt-10 border border-splash-blue bg-splash-blue/5 p-6 text-sm">
Thanks we&apos;ve got your message and will be in touch soon.
</div>
+3 -4
View File
@@ -6,6 +6,7 @@ import { prisma } from '@/lib/prisma';
import { sendCustomRequestNotification } from '@/lib/mail';
import { verifyTurnstileToken } from '@/lib/turnstile';
import { getClientIp } from '@/lib/rateLimit';
import { saveUploadedFile } from '@/lib/storage';
export async function createCustomRequest(formData: FormData) {
const customerId = String(formData.get('customerId') ?? '').trim() || null;
@@ -30,9 +31,7 @@ export async function createCustomRequest(formData: FormData) {
const product = await prisma.product.findUnique({ where: { id: productId } });
if (!product) throw new Error('Product not found.');
const bytes = Buffer.from(await file.arrayBuffer());
const mimeType = file.type || 'image/png';
const imageDataUrl = `data:${mimeType};base64,${bytes.toString('base64')}`;
const imageUrl = await saveUploadedFile(file, 'custom-requests');
const request = await prisma.customRequest.create({
data: {
@@ -41,7 +40,7 @@ export async function createCustomRequest(formData: FormData) {
customerName,
customerEmail,
customerPhone: customerPhone || null,
imageDataUrl,
imageUrl,
note: note || null,
},
});
+4 -3
View File
@@ -5,9 +5,10 @@ import { prisma } from '@/lib/prisma';
export default async function CustomRequestSubmittedPage({
searchParams,
}: {
searchParams: { id?: string };
searchParams: Promise<{ id?: string }>;
}) {
const id = searchParams.id ?? '';
const params = await searchParams;
const id = params.id ?? '';
const request = id
? await prisma.customRequest.findUnique({ where: { id }, include: { product: true } })
: null;
@@ -23,7 +24,7 @@ export default async function CustomRequestSubmittedPage({
<div className="mt-8 border border-line bg-surface p-6">
<img
src={request.imageDataUrl}
src={request.imageUrl ?? ''}
alt="Your submitted photo"
className="mx-auto max-h-64 object-contain"
/>
+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">
<div className="aspect-square w-full overflow-hidden">
<img
src={p.imageDataUrl}
src={p.imageUrl ?? ''}
alt={p.caption ?? 'Completed work'}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
+1 -1
View File
@@ -34,7 +34,7 @@ export default async function ProductDetailPage({
where: { id: customRequestId },
});
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({
take: 6,
orderBy: { createdAt: 'desc' },
select: { id: true, imageUrl: true, caption: true },
});
const products = await prisma.product.findMany({
@@ -244,7 +245,7 @@ export default async function HomePage() {
className="group block aspect-square overflow-hidden border border-line bg-surface"
>
<img
src={p.imageDataUrl}
src={p.imageUrl ?? ''}
alt={p.caption ?? 'Completed work'}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
+3 -2
View File
@@ -5,10 +5,11 @@ import { fetchActivePromotions } from '@/lib/promotions';
import StandardProductView from '@/components/StandardProductView';
import ProductCard from '@/components/ProductCard';
export default async function StandardProductPage({ params }: { params: { slug: string } }) {
export default async function StandardProductPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const activePromotions = await fetchActivePromotions();
const row = await prisma.product.findUnique({ where: { slug: params.slug } });
const row = await prisma.product.findUnique({ where: { slug } });
if (!row || !row.showAsReadyMade) notFound();
const product = toProductDTO(row, activePromotions, 'plain');
+3 -2
View File
@@ -3,9 +3,10 @@ import { prisma } from '@/lib/prisma';
import ReviewForm from '@/components/ReviewForm';
import { notFound } from 'next/navigation';
export default async function ProductReviewsPage({ params }: { params: { slug: string } }) {
export default async function ProductReviewsPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const product = await prisma.product.findUnique({
where: { slug: params.slug },
where: { slug },
include: {
reviews: {
where: { approved: true },
+3 -2
View File
@@ -9,8 +9,9 @@ type SearchParams = {
const PRODUCTS_PER_PAGE = 20;
export default async function ReadyMadeProductsPage({ searchParams }: { searchParams: SearchParams }) {
const currentPage = Math.max(1, Number(searchParams.page) || 1);
export default async function ReadyMadeProductsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams;
const currentPage = Math.max(1, Number(params.page) || 1);
const activePromotions = await fetchActivePromotions();
const [rows, totalCount] = await Promise.all([
+5 -4
View File
@@ -5,9 +5,10 @@ import ApproveSection from '@/components/ApproveSection';
import Chat from '@/components/Chat';
import Price from '@/components/Price';
export default async function ProofPage({ params }: { params: { id: string } }) {
export default async function ProofPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const row = await prisma.designProof.findUnique({
where: { id: params.id },
where: { id },
include: { product: true },
});
if (!row) notFound();
@@ -22,8 +23,8 @@ export default async function ProofPage({ params }: { params: { id: string } })
<div className="mt-10 grid gap-10 md:grid-cols-2">
<div className="border border-line bg-surface p-6">
{proof.imageDataUrl ? (
<img src={proof.imageDataUrl} alt={`${proof.productName} design`} className="w-full object-contain" />
{proof.imageUrl ? (
<img src={proof.imageUrl} alt={`${proof.productName} design`} className="w-full object-contain" />
) : (
<p className="text-center text-sm text-muted">No image was uploaded with this design.</p>
)}
+2 -2
View File
@@ -27,9 +27,9 @@ export default function ApproveSection({ proof }: { proof: ProofDTO }) {
quantity: proof.quantity,
unitPrice: proof.unitPrice,
designJson: { front: [], back: [] } as any,
designPreviewUrl: proof.imageDataUrl,
designPreviewUrl: proof.imageUrl,
designPreviewUrlBack: null,
placementPreviewUrl: proof.imageDataUrl,
placementPreviewUrl: proof.imageUrl,
placementPreviewUrlBack: null,
designWidthCm: null,
designHeightCm: null,
+49 -38
View File
@@ -523,6 +523,7 @@ export default function DesignCanvas({
const [submissionMessage, setSubmissionMessage] = useState<string | null>(null);
const [imageDimensions, setImageDimensions] = useState<Record<string, { widthCm: number | null; heightCm: number | null }>>({});
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
const [isUploadingImage, setIsUploadingImage] = useState(false);
const toggleSection = (section: string) => {
setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] }));
@@ -715,44 +716,53 @@ export default function DesignCanvas({
const handleUploadClick = () => fileInputRef.current?.click();
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const src = reader.result as string;
const img = new window.Image();
img.onload = () => {
// Clear old approval status when starting a new design
if (approvalStatus) {
setApprovalStatus(null);
setDesignApprovalId(null);
if (typeof window !== 'undefined') {
localStorage.removeItem(`designApproval_${product.id}`);
}
}
const maxDim = Math.min(activeStageW, activeStageH) * 0.7;
const scale = Math.min(maxDim / img.width, maxDim / img.height, 1);
const id = uuid();
setActiveElements((prev) => [
...prev,
{
id,
type: 'image',
x: activeStageW / 2 - (img.width * scale) / 2,
y: activeStageH / 2 - (img.height * scale) / 2,
rotation: 0,
width: img.width * scale,
height: img.height * scale,
src,
},
]);
setSelectedId(id);
};
img.src = src;
};
reader.readAsDataURL(file);
e.target.value = '';
if (!file) return;
setIsUploadingImage(true);
try {
const formData = new FormData();
formData.append('image', file);
const res = await fetch('/api/design-uploads', { method: 'POST', body: formData });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Upload failed');
const src: string = data.url;
const img = await loadImage(src);
// Clear old approval status when starting a new design
if (approvalStatus) {
setApprovalStatus(null);
setDesignApprovalId(null);
if (typeof window !== 'undefined') {
localStorage.removeItem(`designApproval_${product.id}`);
}
}
const maxDim = Math.min(activeStageW, activeStageH) * 0.7;
const scale = Math.min(maxDim / img.width, maxDim / img.height, 1);
const id = uuid();
setActiveElements((prev) => [
...prev,
{
id,
type: 'image',
x: activeStageW / 2 - (img.width * scale) / 2,
y: activeStageH / 2 - (img.height * scale) / 2,
rotation: 0,
width: img.width * scale,
height: img.height * scale,
src,
},
]);
setSelectedId(id);
} catch (err) {
console.error('Failed to upload image:', err);
alert('Could not upload that image — please try again.');
} finally {
setIsUploadingImage(false);
}
};
const deleteSelected = () => {
@@ -1566,9 +1576,10 @@ export default function DesignCanvas({
</button>
<button
onClick={handleUploadClick}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
disabled={isUploadingImage}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper disabled:cursor-not-allowed disabled:opacity-50"
>
+ Upload image
{isUploadingImage ? 'Uploading...' : '+ Upload image'}
</button>
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFile} />
{selected && (
-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 [historyIndex, setHistoryIndex] = useState(-1);
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
useEffect(() => {
@@ -94,6 +99,35 @@ export default function FabricDesignCanvasV2({ product, customPhoto }: FabricDes
fabricCanvas.on('object:modified', () => 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) {
setIsLoaded(true);
}
@@ -267,32 +301,63 @@ export default function FabricDesignCanvasV2({ product, customPhoto }: FabricDes
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = (e) => {
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (evt) => {
const newElement: DesignElement = {
id: uuid(),
type: 'image',
src: evt.target?.result as string,
x: DISPLAY / 2 - 50,
y: DISPLAY / 2 - 50,
width: 100,
height: 100,
rotation: 0,
};
setDesign((prev) => ({
...prev,
[side]: [...prev[side], newElement],
}));
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 newElement: DesignElement = {
id: uuid(),
type: 'image',
src: data.url,
x: DISPLAY / 2 - 50,
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();
};
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 (
<div className="mx-auto max-w-6xl px-6 py-8">
<div className="grid gap-8 lg:grid-cols-3">
@@ -362,9 +427,10 @@ export default function FabricDesignCanvasV2({ product, customPhoto }: FabricDes
</button>
<button
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>
</div>
</div>
@@ -373,8 +439,72 @@ export default function FabricDesignCanvasV2({ product, customPhoto }: FabricDes
<div>
<div className="rounded border border-line bg-surface p-4">
<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">
£{(product.effectivePrice / 100).toFixed(2)}
</p>
+2 -2
View File
@@ -5,7 +5,7 @@ import Link from 'next/link';
interface GalleryPhoto {
id: string;
imageDataUrl: string;
imageUrl: 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 className="border-2 border-line rounded-lg overflow-hidden aspect-square">
<img
src={photo.imageDataUrl}
src={photo.imageUrl ?? ''}
alt={photo.caption || 'Customer design'}
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,
quantity: p.quantity,
unitPrice: p.unitPrice,
imageDataUrl: p.imageDataUrl,
imageUrl: p.imageUrl ?? '',
note: p.note,
status: p.status as ProofDTO['status'],
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;
quantity: number;
unitPrice: number;
imageDataUrl: string;
imageUrl: string;
note: string | null;
status: 'PENDING' | 'APPROVED';
createdAt: string;
+1 -1
View File
File diff suppressed because one or more lines are too long