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:
co-authored by
Claude Sonnet 5
parent
e23f201d3d
commit
396347b982
+4
-3
@@ -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_..."
|
||||
|
||||
@@ -9,3 +9,4 @@ prisma/dev.db-*
|
||||
.claude/settings.local.json
|
||||
dist/
|
||||
database-backups/
|
||||
public/uploads/
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Generated
+721
-135
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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])
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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't delete that category — there are still products using it. Move or delete those
|
||||
products first.
|
||||
|
||||
@@ -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,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'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't delete that — there are still products tagged with it. Untag those products
|
||||
first.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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,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} />
|
||||
|
||||
@@ -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,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't delete that category — there are still photos using it. Move or delete those
|
||||
photos first.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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,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"
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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've got your message and will be in touch soon.
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -34,7 +34,7 @@ export default async function ProductDetailPage({
|
||||
where: { id: customRequestId },
|
||||
});
|
||||
if (customRequest) {
|
||||
customPhoto = customRequest.imageDataUrl;
|
||||
customPhoto = customRequest.imageUrl ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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,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 },
|
||||
|
||||
@@ -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,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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,14 +716,22 @@ 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];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const src = reader.result as string;
|
||||
const img = new window.Image();
|
||||
img.onload = () => {
|
||||
|
||||
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);
|
||||
@@ -748,11 +757,12 @@ export default function DesignCanvas({
|
||||
},
|
||||
]);
|
||||
setSelectedId(id);
|
||||
};
|
||||
img.src = src;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
e.target.value = '';
|
||||
} 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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,15 +301,22 @@ 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) => {
|
||||
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: evt.target?.result as string,
|
||||
src: data.url,
|
||||
x: DISPLAY / 2 - 50,
|
||||
y: DISPLAY / 2 - 50,
|
||||
width: 100,
|
||||
@@ -286,13 +327,37 @@ export default function FabricDesignCanvasV2({ product, customPhoto }: FabricDes
|
||||
...prev,
|
||||
[side]: [...prev[side], newElement],
|
||||
}));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} 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>
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user