Compare commits
11
Commits
ac10b33207
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
396347b982 | ||
|
|
e23f201d3d | ||
|
|
d41aebc4f1 | ||
|
|
b4af94cb31 | ||
|
|
9dbb288fb3 | ||
|
|
bdb5955447 | ||
|
|
e4a84b1979 | ||
|
|
0f119b5db5 | ||
|
|
234f03b4dd | ||
|
|
dc9bceb494 | ||
|
|
e716e8256c |
+2
-1
@@ -5,7 +5,8 @@
|
||||
"name": "dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3000
|
||||
"port": 3000,
|
||||
"autoPort": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -159,7 +159,35 @@
|
||||
"Bash(curl -s http://127.0.0.1:3000/products/awd-hoodie)",
|
||||
"Bash(curl -s http://127.0.0.1:3000/made-to-order)",
|
||||
"Bash(curl -s \"http://127.0.0.1:3000/products/awd-hoodie\")",
|
||||
"Bash(git commit -m 'Fix: NavDropdown link navigation with stopPropagation *)"
|
||||
"Bash(git commit -m 'Fix: NavDropdown link navigation with stopPropagation *)",
|
||||
"Bash(git commit -m 'Feat: Remove all filters from products pages *)",
|
||||
"Bash(git commit -m 'Feat: Add loading spinner for made-to-order page *)",
|
||||
"Bash(git commit -m 'Revert: Remove loading spinner - was slowing down page *)",
|
||||
"Bash(git commit -m 'Fix: Show approved designs in customer account so they can order *)",
|
||||
"Bash(git commit -m 'Fix: Link approved designs to review page, not personalization *)",
|
||||
"Bash(git commit -m 'Feat: Show estimated delivery date on design review page *)",
|
||||
"Bash(git commit -m 'Fix: Approve & Add to Bag button not working *)",
|
||||
"Bash(git commit -m 'Feat: Add checkout options modal after adding design to bag *)",
|
||||
"PowerShell(Get-Process node -ErrorAction SilentlyContinue)",
|
||||
"PowerShell(Stop-Process -Force -ErrorAction SilentlyContinue)",
|
||||
"PowerShell(npx prisma generate)",
|
||||
"Bash(npm view *)",
|
||||
"Bash(nc -zv 192.168.0.190 5432)",
|
||||
"Bash(psql --version)",
|
||||
"Bash(ssh -o StrictHostKeyChecking=no postgres@192.168.0.190 \"psql -U postgres -c 'SHOW max_connections;'\")",
|
||||
"Bash(ssh -p 22 -o StrictHostKeyChecking=no postgres@192.168.0.190 \"psql -U postgres -c 'SHOW max_connections;'\")",
|
||||
"Bash(ssh -p 22 andymick01@192.168.0.190 \"sudo -u postgres psql -c 'SHOW max_connections;'\")",
|
||||
"Bash(ssh -i ~/.ssh/sshkey -p 22 andymick01@192.168.0.190 \"sudo -u postgres psql -c 'SHOW max_connections;'\")",
|
||||
"Bash(ssh -vv -i ~/.ssh/sshkey -p 22 andymick01@192.168.0.190 \"echo test\")",
|
||||
"Bash(ssh git@192.168.0.190 \"sudo -u postgres psql -c 'SHOW max_connections;'\")",
|
||||
"Bash(ssh -p 22 git@192.168.0.190 \"sudo -u postgres psql -c 'SHOW max_connections;'\")",
|
||||
"Bash(ssh -i ~/.ssh/id_ed25519 -p 22 git@192.168.0.190 \"sudo -u postgres psql -c 'SHOW max_connections;'\")",
|
||||
"Bash(psql -h 192.168.0.190 -p 6432 -U postgres -d craft2prints -c \"SELECT 1 as connection_test;\")",
|
||||
"Bash(npm list *)",
|
||||
"Bash(npm ls *)",
|
||||
"Bash(node -e \"const mod = require\\('fabric'\\); console.log\\(Object.keys\\(mod\\).slice\\(0, 20\\)\\)\")",
|
||||
"Bash(timeout 5 curl -s http://localhost:3000)",
|
||||
"Bash(curl -s http://localhost:3000/made-to-order/awd-t-shirt-at001)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -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*
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const product = await prisma.product.findUnique({
|
||||
where: { slug: 'awd-t-shirt-at001' },
|
||||
select: {
|
||||
colorPhotos: true,
|
||||
colorPhotosBack: true
|
||||
}
|
||||
});
|
||||
|
||||
const front = JSON.parse(product.colorPhotos);
|
||||
const back = JSON.parse(product.colorPhotosBack || '{}');
|
||||
|
||||
console.log('Front colors:', Object.keys(front).sort());
|
||||
console.log('Back colors:', Object.keys(back).sort());
|
||||
|
||||
const allColors = new Set([...Object.keys(front), ...Object.keys(back)]);
|
||||
|
||||
console.log('\nMissing in back:');
|
||||
Object.keys(front).forEach(color => {
|
||||
if (!back[color]) {
|
||||
console.log(` ${color} (front has it, back is MISSING)`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\nExtra in back (no front):');
|
||||
Object.keys(back).forEach(color => {
|
||||
if (!front[color]) {
|
||||
console.log(` ${color} (back has it, front is missing)`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const products = await prisma.product.findMany({
|
||||
where: {
|
||||
slug: {
|
||||
in: ['awd-t-shirt-at001', 'awd-hoodie']
|
||||
}
|
||||
},
|
||||
select: {
|
||||
'slug': true,
|
||||
'name': true,
|
||||
'imageUrl': true,
|
||||
'imageUrlBack': true,
|
||||
'colorPhotos': true,
|
||||
'colorPhotosBack': true,
|
||||
'printArea': true,
|
||||
'printAreaBack': true
|
||||
}
|
||||
});
|
||||
|
||||
products.forEach(p => {
|
||||
console.log(`\n${p.name} (${p.slug}):`);
|
||||
console.log(` Base Images: front=${!!p.imageUrl}, back=${!!p.imageUrlBack}`);
|
||||
const colorPhotos = p.colorPhotos ? JSON.parse(p.colorPhotos) : {};
|
||||
const colorPhotosBack = p.colorPhotosBack ? JSON.parse(p.colorPhotosBack) : {};
|
||||
console.log(` Color Photos: front=${Object.keys(colorPhotos).length}, back=${Object.keys(colorPhotosBack).length}`);
|
||||
console.log(` Print Areas: front=${!!p.printArea}, back=${!!p.printAreaBack}`);
|
||||
});
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const products = await prisma.product.findMany({
|
||||
where: {
|
||||
slug: {
|
||||
in: ['awd-t-shirt-at001', 'awd-hoodie']
|
||||
}
|
||||
},
|
||||
select: {
|
||||
'slug': true,
|
||||
'name': true,
|
||||
'printArea': true,
|
||||
'printAreaBack': true,
|
||||
'mockup': true
|
||||
}
|
||||
});
|
||||
|
||||
products.forEach(p => {
|
||||
console.log(`\n${p.name} (${p.slug}):`);
|
||||
console.log(` Mockup: ${p.mockup}`);
|
||||
const front = JSON.parse(p.printArea);
|
||||
console.log(` Front Print Area: x=${front.xPct}, y=${front.yPct}, w=${front.wPct}, h=${front.hPct}`);
|
||||
if (p.printAreaBack) {
|
||||
const back = JSON.parse(p.printAreaBack);
|
||||
console.log(` Back Print Area: x=${back.xPct}, y=${back.yPct}, w=${back.wPct}, h=${back.hPct}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const product = await prisma.product.findUnique({
|
||||
where: { slug: 'awd-t-shirt-at001' }
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
console.log('Product not found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\n=== T-Shirt Images ===');
|
||||
console.log(`\nColors: ${product.colors}`);
|
||||
console.log(`\nimagUrl: ${product.imageUrl ? 'SET' : 'NOT SET'}`);
|
||||
console.log(`imageUrlBack: ${product.imageUrlBack ? 'SET' : 'NOT SET'}`);
|
||||
|
||||
if (product.colorPhotos) {
|
||||
const colors = JSON.parse(product.colorPhotos);
|
||||
console.log(`\ncolorPhotos: ${Object.keys(colors).length} colors have photos`);
|
||||
Object.keys(colors).forEach(color => {
|
||||
console.log(` - ${color}: ${colors[color] ? 'HAS IMAGE' : 'NO IMAGE'}`);
|
||||
});
|
||||
} else {
|
||||
console.log('\ncolorPhotos: NOT SET');
|
||||
}
|
||||
|
||||
if (product.colorPhotosBack) {
|
||||
const colors = JSON.parse(product.colorPhotosBack);
|
||||
console.log(`\ncolorPhotosBack: ${Object.keys(colors).length} colors have back photos`);
|
||||
} else {
|
||||
console.log('\ncolorPhotosBack: NOT SET');
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const products = await prisma.product.findMany({
|
||||
where: {
|
||||
slug: {
|
||||
in: ['awd-t-shirt-at001', 'awd-hoodie']
|
||||
}
|
||||
},
|
||||
select: {
|
||||
'id': true,
|
||||
'slug': true,
|
||||
'name': true,
|
||||
'imageUrl': true,
|
||||
'imageUrlBack': true
|
||||
}
|
||||
});
|
||||
|
||||
products.forEach(p => {
|
||||
console.log(`\n${p.name} (${p.slug}):`);
|
||||
console.log(` Front: ${p.imageUrl ? p.imageUrl.substring(0, 60) + '...' : 'NOT SET'}`);
|
||||
console.log(` Back: ${p.imageUrlBack ? p.imageUrlBack.substring(0, 60) + '...' : 'NOT SET'}`);
|
||||
});
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Vendored
+2
-1
@@ -1,5 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
+1
-2
@@ -1,9 +1,8 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
serverExternalPackages: ['@prisma/client', 'sharp'],
|
||||
experimental: {
|
||||
serverComponentsExternalPackages: ['@prisma/client'],
|
||||
// Invoice uploads (phone photos/PDFs) and product photo uploads go through
|
||||
// server actions — the 1MB default rejects typical phone photos.
|
||||
serverActions: { bodySizeLimit: '10mb' },
|
||||
|
||||
Generated
+2264
-102
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -14,15 +14,17 @@
|
||||
"dependencies": {
|
||||
"@prisma/client": "5.16.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"fabric": "^7.4.0",
|
||||
"idb-keyval": "6.2.1",
|
||||
"jose": "^6.2.3",
|
||||
"konva": "9.3.14",
|
||||
"next": "14.2.5",
|
||||
"next": "^16.2.11",
|
||||
"nodemailer": "6.9.14",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-konva": "18.2.10",
|
||||
"sharp": "^0.35.3",
|
||||
"stripe": "16.2.0",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"uuid": "9.0.1",
|
||||
|
||||
+5
-20
@@ -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])
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function registerCustomer(formData: FormData) {
|
||||
if (password !== confirmPassword) redirect('/account/register?error=mismatch');
|
||||
if (password.length < 8) redirect('/account/register?error=weak');
|
||||
|
||||
const ip = getClientIp(headers());
|
||||
const ip = getClientIp(await headers());
|
||||
const turnstileToken = String(formData.get('cf-turnstile-response') ?? '');
|
||||
const { success } = await verifyTurnstileToken(turnstileToken, ip);
|
||||
if (!success) redirect('/account/register?error=captcha');
|
||||
@@ -61,8 +61,9 @@ export async function loginCustomer(formData: FormData) {
|
||||
const password = String(formData.get('password') ?? '');
|
||||
const next = safeNext(formData.get('next'));
|
||||
|
||||
const ip = getClientIp(headers());
|
||||
const userAgent = headers().get('user-agent') ?? undefined;
|
||||
const headersList = await headers();
|
||||
const ip = getClientIp(headersList);
|
||||
const userAgent = headersList.get('user-agent') ?? undefined;
|
||||
|
||||
const { allowed } = await checkRateLimit(`login:customer:${ip}`, 5, 15 * 60 * 1000);
|
||||
if (!allowed) {
|
||||
@@ -95,7 +96,7 @@ export async function loginCustomer(formData: FormData) {
|
||||
}
|
||||
|
||||
export async function logoutCustomer() {
|
||||
clearSession();
|
||||
await clearSession();
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
@@ -47,6 +48,12 @@ export default async function AccountPage({ searchParams }: { searchParams: { su
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const approvedDesigns = await prisma.designApproval.findMany({
|
||||
where: { customerEmail: customer.email, status: 'APPROVED' },
|
||||
include: { product: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// Separate recent (< 7 days) and older (>= 7 days) orders
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
@@ -121,6 +128,39 @@ export default async function AccountPage({ searchParams }: { searchParams: { su
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2 className="mt-10 font-display text-xl">Ready to order</h2>
|
||||
{approvedDesigns.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-muted">No approved designs ready to order.</p>
|
||||
) : (
|
||||
<div className="mt-4 divide-y divide-line border-t border-line">
|
||||
{approvedDesigns.map((design) => (
|
||||
<Link
|
||||
key={design.id}
|
||||
href={`/designs/${design.id}/review`}
|
||||
className="flex items-center gap-4 py-4 hover:bg-surface"
|
||||
>
|
||||
<img
|
||||
src={design.designPreviewUrl}
|
||||
alt="design preview"
|
||||
className="h-14 w-14 border border-line object-cover"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{design.product.name}</p>
|
||||
<p className="text-sm text-muted">
|
||||
Approved {new Date(design.createdAt).toLocaleDateString(undefined, { dateStyle: 'medium' })}
|
||||
</p>
|
||||
{design.expectedDeliveryDate && (
|
||||
<p className="text-xs text-muted mt-1">
|
||||
Est. delivery: {new Date(design.expectedDeliveryDate).toLocaleDateString(undefined, { dateStyle: 'medium' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-clay">Order →</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2 className="mt-10 font-display text-xl">Order history</h2>
|
||||
{orders.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-muted">No orders yet.</p>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ export async function subscribeToNewsletter(email: string, turnstileToken: strin
|
||||
return { ok: false, message: 'Enter a valid email address.' };
|
||||
}
|
||||
|
||||
const ip = getClientIp(headers());
|
||||
const ip = getClientIp(await headers());
|
||||
const { success } = await verifyTurnstileToken(turnstileToken, ip);
|
||||
if (!success) {
|
||||
return { ok: false, message: 'CAPTCHA verification failed — please try again.' };
|
||||
|
||||
@@ -3,12 +3,14 @@ import { notFound } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import Price from '@/components/Price';
|
||||
import { isPdfUrl } from '@/lib/storage';
|
||||
|
||||
export default async function ExpenseDetailPage({ params }: { params: { id: string } }) {
|
||||
const expense = await prisma.expense.findUnique({ where: { id: params.id } });
|
||||
export default async function ExpenseDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const expense = await prisma.expense.findUnique({ where: { id } });
|
||||
if (!expense) notFound();
|
||||
|
||||
const isPdf = expense.receiptDataUrl?.startsWith('data:application/pdf');
|
||||
const isPdf = isPdfUrl(expense.receiptUrl);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-12">
|
||||
@@ -30,20 +32,20 @@ export default async function ExpenseDetailPage({ params }: { params: { id: stri
|
||||
{expense.notes && <p className="border-l-2 border-splash-orange pl-4 text-sm italic">{expense.notes}</p>}
|
||||
</div>
|
||||
|
||||
{expense.receiptDataUrl && (
|
||||
{expense.receiptUrl && (
|
||||
<div className="mt-8">
|
||||
<p className="tag-label mb-3">Receipt{expense.receiptFileName ? ` — ${expense.receiptFileName}` : ''}</p>
|
||||
{isPdf ? (
|
||||
<iframe src={expense.receiptDataUrl} title="Receipt" className="h-[600px] w-full border border-line" />
|
||||
<iframe src={expense.receiptUrl} title="Receipt" className="h-[600px] w-full border border-line" />
|
||||
) : (
|
||||
<img
|
||||
src={expense.receiptDataUrl}
|
||||
src={expense.receiptUrl}
|
||||
alt="Receipt"
|
||||
className="max-h-[600px] w-full border border-line object-contain"
|
||||
/>
|
||||
)}
|
||||
<a
|
||||
href={expense.receiptDataUrl}
|
||||
href={expense.receiptUrl}
|
||||
download={expense.receiptFileName ?? 'receipt'}
|
||||
className="mt-2 inline-block tag-label text-clay hover:text-clay-dark"
|
||||
>
|
||||
|
||||
@@ -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,15 +26,16 @@ type LedgerEntry = {
|
||||
export default async function AccountsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { type?: string; from?: string; to?: string };
|
||||
searchParams: Promise<{ type?: string; from?: string; to?: string }>;
|
||||
}) {
|
||||
const type = searchParams.type === 'web' || searchParams.type === 'manual' ? searchParams.type : 'all';
|
||||
const from = searchParams.from ? new Date(searchParams.from) : null;
|
||||
const sp = await searchParams;
|
||||
const type = sp.type === 'web' || sp.type === 'manual' ? sp.type : 'all';
|
||||
const from = sp.from ? new Date(sp.from) : null;
|
||||
// Include the whole "to" day, not just its midnight.
|
||||
const to = searchParams.to ? new Date(`${searchParams.to}T23:59:59`) : null;
|
||||
const to = sp.to ? new Date(`${sp.to}T23:59:59`) : null;
|
||||
|
||||
let orders = [];
|
||||
let manualSales = [];
|
||||
let orders: any[] = [];
|
||||
let manualSales: any[] = [];
|
||||
|
||||
try {
|
||||
[orders, manualSales] = await Promise.all([
|
||||
@@ -59,22 +60,22 @@ export default async function AccountsPage({
|
||||
}
|
||||
|
||||
const entries: LedgerEntry[] = [
|
||||
...orders.map((o) => ({
|
||||
...orders.map((o: any) => ({
|
||||
id: o.id,
|
||||
kind: 'WEB' as const,
|
||||
date: o.createdAt,
|
||||
who: o.customer?.name ?? o.email ?? 'Guest',
|
||||
what: o.items.map((i) => `${i.quantity}× ${i.productName}`).join(', '),
|
||||
what: o.items.map((i: any) => `${i.quantity}× ${i.productName}`).join(', '),
|
||||
method: 'Card (website)',
|
||||
total: o.total,
|
||||
href: `/admin/orders`,
|
||||
})),
|
||||
...manualSales.map((s) => ({
|
||||
...manualSales.map((s: any) => ({
|
||||
id: s.id,
|
||||
kind: 'MANUAL' as const,
|
||||
date: s.soldAt,
|
||||
who: s.buyerName || s.buyerEmail || 'Unnamed buyer',
|
||||
what: s.items.map((i) => `${i.quantity}× ${i.description}`).join(', '),
|
||||
what: s.items.map((i: any) => `${i.quantity}× ${i.description}`).join(', '),
|
||||
method: PAYMENT_LABELS[s.paymentMethod] ?? s.paymentMethod,
|
||||
total: s.total,
|
||||
href: null,
|
||||
@@ -88,8 +89,8 @@ export default async function AccountsPage({
|
||||
const filterHref = (t: string) => {
|
||||
const params = new URLSearchParams();
|
||||
if (t !== 'all') params.set('type', t);
|
||||
if (searchParams.from) params.set('from', searchParams.from);
|
||||
if (searchParams.to) params.set('to', searchParams.to);
|
||||
if (sp.from) params.set('from', sp.from);
|
||||
if (sp.to) params.set('to', sp.to);
|
||||
const qs = params.toString();
|
||||
return `/admin/accounts${qs ? `?${qs}` : ''}`;
|
||||
};
|
||||
@@ -160,7 +161,7 @@ export default async function AccountsPage({
|
||||
<input
|
||||
type="date"
|
||||
name="from"
|
||||
defaultValue={searchParams.from ?? ''}
|
||||
defaultValue={sp.from ?? ''}
|
||||
className="border border-line bg-paper px-2 py-1.5"
|
||||
/>
|
||||
</label>
|
||||
@@ -169,14 +170,14 @@ export default async function AccountsPage({
|
||||
<input
|
||||
type="date"
|
||||
name="to"
|
||||
defaultValue={searchParams.to ?? ''}
|
||||
defaultValue={sp.to ?? ''}
|
||||
className="border border-line bg-paper px-2 py-1.5"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="border border-line px-3 py-1.5 hover:border-clay hover:text-clay">
|
||||
Apply
|
||||
</button>
|
||||
{(searchParams.from || searchParams.to) && (
|
||||
{(sp.from || sp.to) && (
|
||||
<Link href={filterHref(type)} className="tag-label text-muted hover:text-ink">
|
||||
Clear dates
|
||||
</Link>
|
||||
|
||||
@@ -3,15 +3,17 @@ import { notFound } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import Price from '@/components/Price';
|
||||
import { isPdfUrl } from '@/lib/storage';
|
||||
|
||||
export default async function PurchaseDetailPage({ params }: { params: { id: string } }) {
|
||||
export default async function PurchaseDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const purchase = await prisma.purchase.findUnique({
|
||||
where: { id: params.id },
|
||||
where: { id },
|
||||
include: { items: { include: { product: true } } },
|
||||
});
|
||||
if (!purchase) notFound();
|
||||
|
||||
const isPdf = purchase.invoiceDataUrl?.startsWith('data:application/pdf');
|
||||
const isPdf = isPdfUrl(purchase.invoiceUrl);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-12">
|
||||
@@ -50,20 +52,20 @@ export default async function PurchaseDetailPage({ params }: { params: { id: str
|
||||
|
||||
{purchase.notes && <div className="mt-6 border-l-2 border-splash-orange pl-4 text-sm italic">{purchase.notes}</div>}
|
||||
|
||||
{purchase.invoiceDataUrl && (
|
||||
{purchase.invoiceUrl && (
|
||||
<div className="mt-8">
|
||||
<p className="tag-label mb-3">Invoice{purchase.invoiceFileName ? ` — ${purchase.invoiceFileName}` : ''}</p>
|
||||
{isPdf ? (
|
||||
<iframe src={purchase.invoiceDataUrl} title="Invoice" className="h-[600px] w-full border border-line" />
|
||||
<iframe src={purchase.invoiceUrl} title="Invoice" className="h-[600px] w-full border border-line" />
|
||||
) : (
|
||||
<img
|
||||
src={purchase.invoiceDataUrl}
|
||||
src={purchase.invoiceUrl}
|
||||
alt="Invoice"
|
||||
className="max-h-[600px] w-full border border-line object-contain"
|
||||
/>
|
||||
)}
|
||||
<a
|
||||
href={purchase.invoiceDataUrl}
|
||||
href={purchase.invoiceUrl}
|
||||
download={purchase.invoiceFileName ?? 'invoice'}
|
||||
className="mt-2 inline-block tag-label text-clay hover:text-clay-dark"
|
||||
>
|
||||
|
||||
@@ -4,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
|
||||
|
||||
@@ -4,8 +4,8 @@ import AccountsNav from '@/components/AccountsNav';
|
||||
import StockAdjustForm from '@/components/StockAdjustForm';
|
||||
|
||||
export default async function StockPage() {
|
||||
let levels = [];
|
||||
let productRows = [];
|
||||
let levels: any[] = [];
|
||||
let productRows: any[] = [];
|
||||
|
||||
try {
|
||||
[levels, productRows] = await Promise.all([
|
||||
@@ -18,7 +18,7 @@ export default async function StockPage() {
|
||||
console.error('Error fetching stock data:', err);
|
||||
}
|
||||
|
||||
const products = productRows.map((p) => ({
|
||||
const products = productRows.map((p: any) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
colors: JSON.parse(p.colors) as string[],
|
||||
@@ -27,7 +27,7 @@ export default async function StockPage() {
|
||||
|
||||
// Group rows under their product for a readable table.
|
||||
const grouped = new Map<string, typeof levels>();
|
||||
for (const level of levels.sort((a, b) => {
|
||||
for (const level of levels.sort((a: any, b: any) => {
|
||||
if (a.product.name !== b.product.name) return a.product.name.localeCompare(b.product.name);
|
||||
if (a.color !== b.color) return a.color.localeCompare(b.color);
|
||||
return (a.size || '').localeCompare(b.size || '');
|
||||
|
||||
@@ -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,16 +12,17 @@ const SOURCE_LABELS: Record<string, string> = {
|
||||
export default async function CustomersPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { sent?: string; failed?: string };
|
||||
searchParams: Promise<{ sent?: string; failed?: string }>;
|
||||
}) {
|
||||
let entries = [];
|
||||
const params = await searchParams;
|
||||
let entries: any[] = [];
|
||||
try {
|
||||
entries = await prisma.mailingListEntry.findMany();
|
||||
entries.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
entries.sort((a: any, b: any) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
} catch (err) {
|
||||
console.error('Error fetching mailing list:', err);
|
||||
}
|
||||
const subscribedCount = entries.filter((e) => e.subscribed).length;
|
||||
const subscribedCount = entries.filter((e: any) => e.subscribed).length;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-12">
|
||||
@@ -32,10 +33,10 @@ export default async function CustomersPage({
|
||||
guest, in one list. Untick Subscribed to leave someone out of future mailings.
|
||||
</p>
|
||||
|
||||
{searchParams.sent !== undefined && (
|
||||
{params.sent !== undefined && (
|
||||
<div className="mt-6 border border-splash-blue bg-splash-blue/5 p-4 text-sm">
|
||||
Sent to {searchParams.sent} recipient{searchParams.sent === '1' ? '' : 's'}.
|
||||
{Number(searchParams.failed) > 0 && ` ${searchParams.failed} failed to send.`}
|
||||
Sent to {params.sent} recipient{params.sent === '1' ? '' : 's'}.
|
||||
{Number(params.failed) > 0 && ` ${params.failed} failed to send.`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,33 +4,14 @@ import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import dynamic from 'next/dynamic';
|
||||
import type { ProductDTO } from '@/lib/types';
|
||||
|
||||
interface DesignApproval {
|
||||
id: string;
|
||||
customerEmail: string;
|
||||
customerName: string | null;
|
||||
productId: string;
|
||||
product: {
|
||||
id: string;
|
||||
name: string;
|
||||
basePrice: number;
|
||||
personalisedPrice: number;
|
||||
onSale: boolean;
|
||||
effectivePrice: number;
|
||||
colors: string[];
|
||||
sizes: string[];
|
||||
showOnPersonalised: boolean;
|
||||
imageUrl: string | null;
|
||||
imageUrlBack: string | null;
|
||||
photoRecolorable: boolean;
|
||||
colorPhotos: Record<string, string>;
|
||||
colorPhotosBack: Record<string, string>;
|
||||
printArea: string;
|
||||
printAreaBack: string | null;
|
||||
mockup: string;
|
||||
referenceWidthCm: number | null;
|
||||
referenceHeightCm: number | null;
|
||||
};
|
||||
product: ProductDTO;
|
||||
designJson: string;
|
||||
color: string;
|
||||
size: string | null;
|
||||
|
||||
@@ -57,6 +57,7 @@ export default function DesignDetailPage({ params }: { params: { id: string } })
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
setDesign(updated);
|
||||
alert(`✓ Design approved! Customer will receive an email with a link to order.${expectedDeliveryDate ? ` Estimated delivery: ${new Date(expectedDeliveryDate).toLocaleDateString()}` : ''}`);
|
||||
}
|
||||
} finally {
|
||||
setApproving(false);
|
||||
|
||||
@@ -4,7 +4,7 @@ import AdminNav from '@/components/AdminNav';
|
||||
import DeleteDesignButton from '@/components/DeleteDesignButton';
|
||||
|
||||
export default async function DesignsPage() {
|
||||
let designs = [];
|
||||
let designs: any[] = [];
|
||||
try {
|
||||
designs = await prisma.designApproval.findMany({
|
||||
select: {
|
||||
@@ -20,15 +20,15 @@ export default async function DesignsPage() {
|
||||
});
|
||||
|
||||
// Fetch products separately to avoid relation issues
|
||||
const productIds = [...new Set(designs.map(d => d.productId))];
|
||||
const productIds = [...new Set(designs.map((d: any) => d.productId))];
|
||||
const products = await prisma.product.findMany({
|
||||
where: { id: { in: productIds } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const productMap = Object.fromEntries(products.map(p => [p.id, p]));
|
||||
const productMap = Object.fromEntries(products.map((p: any) => [p.id, p]));
|
||||
|
||||
// Add product to each design
|
||||
designs = designs.map(d => ({
|
||||
designs = designs.map((d: any) => ({
|
||||
...d,
|
||||
product: productMap[d.productId],
|
||||
messages: [],
|
||||
@@ -37,9 +37,9 @@ export default async function DesignsPage() {
|
||||
console.error('Error fetching designs:', err);
|
||||
}
|
||||
|
||||
const pending = designs.filter((d) => d.status === 'PENDING');
|
||||
const approved = designs.filter((d) => d.status === 'APPROVED');
|
||||
const rejected = designs.filter((d) => d.status === 'REJECTED');
|
||||
const pending = designs.filter((d: any) => d.status === 'PENDING');
|
||||
const approved = designs.filter((d: any) => d.status === 'APPROVED');
|
||||
const rejected = designs.filter((d: any) => d.status === 'REJECTED');
|
||||
|
||||
const DesignList = ({ items, status }: { items: typeof designs; status: string }) => (
|
||||
<div className="mt-8">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -17,8 +17,9 @@ export async function loginAdmin(formData: FormData) {
|
||||
const password = String(formData.get('password') ?? '');
|
||||
const next = safeNext(formData.get('next'));
|
||||
|
||||
const ip = getClientIp(headers());
|
||||
const userAgent = headers().get('user-agent') ?? undefined;
|
||||
const headersList = await headers();
|
||||
const ip = getClientIp(headersList);
|
||||
const userAgent = headersList.get('user-agent') ?? undefined;
|
||||
|
||||
const { allowed } = await checkRateLimit(`login:admin:${ip}`, 5, 15 * 60 * 1000);
|
||||
if (!allowed) {
|
||||
|
||||
@@ -5,9 +5,9 @@ const ERROR_MESSAGES: Record<string, string> = {
|
||||
rate_limited: 'Too many attempts — please wait a few minutes and try again.',
|
||||
};
|
||||
|
||||
export default function AdminLoginPage({ searchParams }: { searchParams: { error?: string; next?: string } }) {
|
||||
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null;
|
||||
const next = searchParams.next ?? '/admin/dashboard';
|
||||
export default async function AdminLoginPage({ searchParams }: { searchParams: Promise<{ error?: string; next?: string }> }) {
|
||||
const { error: errorKey, next } = await searchParams;
|
||||
const error = errorKey ? (ERROR_MESSAGES[errorKey] ?? 'Something went wrong.') : null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md px-6 py-16">
|
||||
|
||||
@@ -8,9 +8,10 @@ function formatPrice(cents: number) {
|
||||
return `£${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
export default async function OrderDetailPage({ params }: { params: { id: string } }) {
|
||||
export default async function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: params.id },
|
||||
where: { id },
|
||||
include: { items: true },
|
||||
});
|
||||
if (!order) notFound();
|
||||
@@ -237,8 +238,9 @@ export default async function OrderDetailPage({ params }: { params: { id: string
|
||||
{order.items.map((item) => {
|
||||
let hasFrontDesign = false;
|
||||
let hasBackDesign = false;
|
||||
let design: { front: unknown[]; back: unknown[] } = { front: [], back: [] };
|
||||
try {
|
||||
const design = JSON.parse(item.designJson) as { front: unknown[]; back: unknown[] };
|
||||
design = JSON.parse(item.designJson) as { front: unknown[]; back: unknown[] };
|
||||
hasFrontDesign = (design.front?.length ?? 0) > 0;
|
||||
hasBackDesign = (design.back?.length ?? 0) > 0;
|
||||
} catch {
|
||||
|
||||
@@ -26,18 +26,19 @@ const SUCCESS_MESSAGES: Record<string, string> = {
|
||||
password_changed: 'Password updated successfully.',
|
||||
};
|
||||
|
||||
export default async function OrdersPage({ searchParams }: { searchParams: { archived?: string; personalised?: string; success?: string; page?: string } }) {
|
||||
export default async function OrdersPage({ searchParams }: { searchParams: Promise<{ archived?: string; personalised?: string; success?: string; page?: string }> }) {
|
||||
await autoArchiveOldOrders();
|
||||
|
||||
const success = searchParams.success ? SUCCESS_MESSAGES[searchParams.success] : null;
|
||||
const sp = await searchParams;
|
||||
const success = sp.success ? SUCCESS_MESSAGES[sp.success] : null;
|
||||
|
||||
const showArchived = searchParams.archived === '1';
|
||||
const showOnlyPersonalised = searchParams.personalised === '1';
|
||||
const page = Math.max(1, parseInt(searchParams.page || '1'));
|
||||
const showArchived = sp.archived === '1';
|
||||
const showOnlyPersonalised = sp.personalised === '1';
|
||||
const page = Math.max(1, parseInt(sp.page || '1'));
|
||||
const pageSize = 50;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
let orders = [];
|
||||
let orders: any[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
// Fetch orders and total count in parallel
|
||||
@@ -80,8 +81,8 @@ export default async function OrdersPage({ searchParams }: { searchParams: { arc
|
||||
|
||||
// Filter to personalised orders if requested
|
||||
if (showOnlyPersonalised) {
|
||||
orders = orders.filter((order) =>
|
||||
order.items.some((item) => {
|
||||
orders = orders.filter((order: any) =>
|
||||
order.items.some((item: any) => {
|
||||
const design = JSON.parse(item.designJson) as { front: unknown[]; back: unknown[] };
|
||||
return design.front.length > 0 || design.back.length > 0;
|
||||
})
|
||||
@@ -148,8 +149,8 @@ export default async function OrdersPage({ searchParams }: { searchParams: { arc
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-8 divide-y divide-line border-t border-line">
|
||||
{orders.map((order) => {
|
||||
const hasPersonalised = order.items.some((item) => {
|
||||
{orders.map((order: any) => {
|
||||
const hasPersonalised = order.items.some((item: any) => {
|
||||
try {
|
||||
const design = JSON.parse(item.designJson) as { front: unknown[]; back: unknown[] };
|
||||
return (design.front?.length ?? 0) > 0 || (design.back?.length ?? 0) > 0;
|
||||
|
||||
@@ -9,25 +9,22 @@ import SizePicker from '@/components/SizePicker';
|
||||
import PhotoPrintAreaField, { type Box } from '@/components/PhotoPrintAreaField';
|
||||
import CategoryPicker from '@/components/CategoryPicker';
|
||||
|
||||
export default async function EditProductPage({ params }: { params: { id: string } }) {
|
||||
export default async function EditProductPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const activePromotions = await fetchActivePromotions();
|
||||
const row = await prisma.product.findUnique({ where: { id: params.id }, include: { collections: true } });
|
||||
const row = await prisma.product.findUnique({ where: { id }, include: { collections: true } });
|
||||
if (!row) notFound();
|
||||
const product = toProductDTO(row, activePromotions);
|
||||
|
||||
const categories = await prisma.category.findMany({
|
||||
where: { parentId: null },
|
||||
orderBy: { name: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
include: {
|
||||
children: {
|
||||
select: { id: true, name: true, slug: true },
|
||||
orderBy: { name: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}) as any;
|
||||
const collections = await prisma.collection.findMany({ orderBy: { name: 'asc' } });
|
||||
const occasions = collections.filter((c) => c.group === 'OCCASION');
|
||||
const recipients = collections.filter((c) => c.group === 'RECIPIENT');
|
||||
@@ -49,6 +46,10 @@ export default async function EditProductPage({ params }: { params: { id: string
|
||||
hPct: printAreaBack.hPct * 100,
|
||||
} : undefined;
|
||||
|
||||
// Parse color photos for preview if no base image
|
||||
const colorPhotos = row.colorPhotos ? JSON.parse(row.colorPhotos) : undefined;
|
||||
const colorPhotosBack = row.colorPhotosBack ? JSON.parse(row.colorPhotosBack) : undefined;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-12">
|
||||
<AdminNav />
|
||||
@@ -279,12 +280,12 @@ export default async function EditProductPage({ params }: { params: { id: string
|
||||
|
||||
<div>
|
||||
<label className="tag-label mb-2 block">Front photo</label>
|
||||
<PhotoPrintAreaField existingImageUrl={product.imageUrl} existingPrintArea={existingPrintArea} />
|
||||
<PhotoPrintAreaField existingImageUrl={product.imageUrl ?? undefined} existingPrintArea={existingPrintArea} colorPhotosPreview={colorPhotos} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="tag-label mb-2 block">Back photo</label>
|
||||
<PhotoPrintAreaField variant="back" existingImageUrl={product.imageUrlBack} existingPrintArea={existingPrintAreaBack} />
|
||||
<PhotoPrintAreaField variant="back" existingImageUrl={product.imageUrlBack ?? undefined} existingPrintArea={existingPrintAreaBack} colorPhotosPreview={colorPhotosBack} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { DEFAULT_PRINT_AREAS } from '@/lib/mockupDefaults';
|
||||
import { saveUploadedFile } from '@/lib/storage';
|
||||
|
||||
function slugify(input: string) {
|
||||
return input
|
||||
@@ -22,15 +23,9 @@ async function uniqueSlug(base: string) {
|
||||
return slug;
|
||||
}
|
||||
|
||||
async function fileToDataUrl(file: File): Promise<string> {
|
||||
const bytes = Buffer.from(await file.arrayBuffer());
|
||||
const mimeType = file.type || 'image/png';
|
||||
return `data:${mimeType};base64,${bytes.toString('base64')}`;
|
||||
}
|
||||
|
||||
// Reads the per-color photo file inputs the ColorPicker renders (named
|
||||
// "colorPhoto__<hexWithoutHash>" for front, "colorPhotoBack__<hexWithoutHash>" for
|
||||
// back) and builds { hex: dataUrl } for whichever colors actually got a file attached.
|
||||
// back) and builds { hex: url } for whichever colors actually got a file attached.
|
||||
async function extractColorPhotos(
|
||||
formData: FormData,
|
||||
colors: string[],
|
||||
@@ -41,7 +36,7 @@ async function extractColorPhotos(
|
||||
for (const hex of colors) {
|
||||
const file = formData.get(`${prefix}${hex.replace('#', '')}`) as File | null;
|
||||
if (file && file.size > 0) {
|
||||
result[hex] = await fileToDataUrl(file);
|
||||
result[hex] = await saveUploadedFile(file, 'products');
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -103,8 +98,8 @@ export async function createProduct(formData: FormData) {
|
||||
const weightKgInput = String(formData.get('weightKg') ?? '').trim();
|
||||
const weightGrams = weightKgInput ? Math.round(Number(weightKgInput) * 1000) : null;
|
||||
|
||||
const imageUrl = imageFile && imageFile.size > 0 ? await fileToDataUrl(imageFile) : null;
|
||||
const imageUrlBack = imageBackFile && imageBackFile.size > 0 ? await fileToDataUrl(imageBackFile) : null;
|
||||
const imageUrl = imageFile && imageFile.size > 0 ? await saveUploadedFile(imageFile, 'products') : null;
|
||||
const imageUrlBack = imageBackFile && imageBackFile.size > 0 ? await saveUploadedFile(imageBackFile, 'products') : null;
|
||||
const colorPhotos = await extractColorPhotos(formData, colors, 'front');
|
||||
const colorPhotosBack = await extractColorPhotos(formData, colors, 'back');
|
||||
const saleFields = parseSaleFields(formData);
|
||||
@@ -276,10 +271,10 @@ export async function updateProduct(formData: FormData) {
|
||||
referenceWidthCm: number | null;
|
||||
referenceHeightCm: number | null;
|
||||
weightGrams: number | null;
|
||||
imageUrl?: string;
|
||||
imageUrlBack?: string;
|
||||
imageUrl?: string | null;
|
||||
imageUrlBack?: string | null;
|
||||
printArea?: string;
|
||||
printAreaBack?: string;
|
||||
printAreaBack?: string | null;
|
||||
} = {
|
||||
name,
|
||||
category,
|
||||
@@ -310,7 +305,7 @@ export async function updateProduct(formData: FormData) {
|
||||
data.imageUrl = null;
|
||||
data.printArea = JSON.stringify(DEFAULT_PRINT_AREAS[mockup] ?? DEFAULT_PRINT_AREAS.tshirt);
|
||||
} else if (imageFile && imageFile.size > 0) {
|
||||
data.imageUrl = await fileToDataUrl(imageFile);
|
||||
data.imageUrl = await saveUploadedFile(imageFile, 'products');
|
||||
data.printArea = JSON.stringify(
|
||||
hasCustomPrintArea
|
||||
? {
|
||||
@@ -341,7 +336,7 @@ export async function updateProduct(formData: FormData) {
|
||||
data.imageUrlBack = null;
|
||||
data.printAreaBack = null;
|
||||
} else if (imageBackFile && imageBackFile.size > 0) {
|
||||
data.imageUrlBack = await fileToDataUrl(imageBackFile);
|
||||
data.imageUrlBack = await saveUploadedFile(imageBackFile, 'products');
|
||||
if (hasCustomPrintAreaBack) {
|
||||
data.printAreaBack = JSON.stringify({
|
||||
xPct: Number(formData.get('printXBack') ?? 0.3),
|
||||
|
||||
@@ -10,16 +10,12 @@ export default async function NewProductPage() {
|
||||
const categories = await prisma.category.findMany({
|
||||
where: { parentId: null },
|
||||
orderBy: { name: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
include: {
|
||||
children: {
|
||||
select: { id: true, name: true, slug: true },
|
||||
orderBy: { name: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}) as any;
|
||||
const collections = await prisma.collection.findMany({ orderBy: { name: 'asc' } });
|
||||
const occasions = collections.filter((c) => c.group === 'OCCASION');
|
||||
const recipients = collections.filter((c) => c.group === 'RECIPIENT');
|
||||
|
||||
@@ -9,13 +9,14 @@ import { deleteProduct } from './actions';
|
||||
export default async function AdminProductsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { error?: string };
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const { error } = await searchParams;
|
||||
const activePromotions = await fetchActivePromotions();
|
||||
let products = [];
|
||||
let products: any[] = [];
|
||||
try {
|
||||
const rows = await prisma.product.findMany();
|
||||
products = rows.map((p) => toProductDTO(p, activePromotions)).sort((a, b) =>
|
||||
products = rows.map((p: any) => toProductDTO(p, activePromotions)).sort((a: any, b: any) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -35,7 +36,7 @@ export default async function AdminProductsPage({
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{searchParams.error === 'in_use' && (
|
||||
{error === 'in_use' && (
|
||||
<div className="mt-6 border border-splash-orange bg-splash-orange/5 p-4 text-sm">
|
||||
Can't delete that product — it has existing orders or design proofs tied to it.
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
@@ -130,7 +135,7 @@ export async function POST(req: Request) {
|
||||
|
||||
// Calculate shipping cost (skip for collection orders)
|
||||
let shippingCost = 0;
|
||||
let selectedShippingService = shippingService;
|
||||
let selectedShippingService: string | undefined | null = shippingService;
|
||||
|
||||
if (isCollection) {
|
||||
console.log('Collection order - no shipping cost');
|
||||
@@ -179,6 +184,26 @@ export async function POST(req: Request) {
|
||||
|
||||
const total = subtotal + shippingCost;
|
||||
|
||||
// Design previews arrive from the client as base64 data URLs (canvas/Konva
|
||||
// export) — write them to disk now so the Order row only ever stores a short URL.
|
||||
const orderItems = await Promise.all(
|
||||
pricedItems.map(async (i) => ({
|
||||
productId: i.productId,
|
||||
productName: i.name,
|
||||
quantity: i.quantity,
|
||||
color: i.color,
|
||||
size: i.size,
|
||||
unitPrice: i.unitPrice,
|
||||
designJson: JSON.stringify(i.designJson),
|
||||
designPreviewUrl: await saveDataUrl(i.designPreviewUrl, 'designs'),
|
||||
designPreviewUrlBack: await saveIfPresent(i.designPreviewUrlBack, 'designs'),
|
||||
placementPreviewUrl: await saveIfPresent(i.placementPreviewUrl, 'design-placements'),
|
||||
placementPreviewUrlBack: await saveIfPresent(i.placementPreviewUrlBack, 'design-placements'),
|
||||
designWidthCm: i.designWidthCm,
|
||||
designHeightCm: i.designHeightCm,
|
||||
})),
|
||||
);
|
||||
|
||||
// Save the order up front, before redirecting to Stripe — this is what makes
|
||||
// the design files (and the order itself) retrievable afterward, whether or
|
||||
// not the customer ever makes it back to the success page.
|
||||
@@ -193,24 +218,8 @@ export async function POST(req: Request) {
|
||||
email: guest?.email ?? customer?.email,
|
||||
guestName: guest?.name,
|
||||
guestPhone: guest?.phone,
|
||||
isCollectionPickup,
|
||||
items: {
|
||||
create: pricedItems.map((i) => ({
|
||||
productId: i.productId,
|
||||
productName: i.name,
|
||||
quantity: i.quantity,
|
||||
color: i.color,
|
||||
size: i.size,
|
||||
unitPrice: i.unitPrice,
|
||||
designJson: JSON.stringify(i.designJson),
|
||||
designPreviewUrl: i.designPreviewUrl,
|
||||
designPreviewUrlBack: i.designPreviewUrlBack,
|
||||
placementPreviewUrl: i.placementPreviewUrl,
|
||||
placementPreviewUrlBack: i.placementPreviewUrlBack,
|
||||
designWidthCm: i.designWidthCm,
|
||||
designHeightCm: i.designHeightCm,
|
||||
})),
|
||||
},
|
||||
isCollectionPickup: isCollection,
|
||||
items: { create: orderItems },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { saveUploadedFile } from '@/lib/storage';
|
||||
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
|
||||
|
||||
// Images a customer drops into the design tool (photos to print on a product).
|
||||
// Uploaded immediately on add so the design JSON only ever stores a short URL,
|
||||
// instead of the base64 data URL it held before — same fix as the other image
|
||||
// fields, just for images embedded inside a design rather than a DB column.
|
||||
export async function POST(req: Request) {
|
||||
const ip = getClientIp(req.headers);
|
||||
const { allowed } = await checkRateLimit(`design-upload:${ip}`, 60, 10 * 60 * 1000);
|
||||
if (!allowed) {
|
||||
return NextResponse.json({ error: 'Too many uploads — please wait a moment and try again.' }, { status: 429 });
|
||||
}
|
||||
|
||||
const formData = await req.formData();
|
||||
const file = formData.get('image') as File | null;
|
||||
if (!file || file.size === 0) {
|
||||
return NextResponse.json({ error: 'No image provided.' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const url = await saveUploadedFile(file, 'design-uploads');
|
||||
return NextResponse.json({ url });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Could not process that image.';
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,15 @@ import { toProductDTO } from '@/lib/product';
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
const { expectedDeliveryDate } = body;
|
||||
|
||||
const design = await prisma.designApproval.findUnique({
|
||||
where: { id: params.id },
|
||||
where: { id },
|
||||
include: { product: true },
|
||||
});
|
||||
|
||||
@@ -21,7 +22,7 @@ export async function POST(
|
||||
}
|
||||
|
||||
const updated = await prisma.designApproval.update({
|
||||
where: { id: params.id },
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'APPROVED',
|
||||
expectedDeliveryDate: expectedDeliveryDate ? new Date(expectedDeliveryDate) : null,
|
||||
|
||||
@@ -4,11 +4,12 @@ import type { DesignApprovalMessage } from '@prisma/client';
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const messages = await prisma.designApprovalMessage.findMany({
|
||||
where: { designId: params.id },
|
||||
where: { designId: id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
@@ -24,9 +25,10 @@ export async function GET(
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { sender, body, role, message, messageType } = await req.json();
|
||||
|
||||
// Support both old format (sender/body) and new format (role/message/messageType)
|
||||
@@ -43,7 +45,7 @@ export async function POST(
|
||||
|
||||
const created = await prisma.designApprovalMessage.create({
|
||||
data: {
|
||||
designId: params.id,
|
||||
designId: id,
|
||||
sender: finalSender,
|
||||
body: finalBody,
|
||||
messageType: finalMessageType,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { saveDataUrl } from '@/lib/storage';
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const {
|
||||
designJson,
|
||||
designPreviewUrl,
|
||||
@@ -17,7 +19,7 @@ export async function POST(
|
||||
} = await req.json();
|
||||
|
||||
const design = await prisma.designApproval.findUnique({
|
||||
where: { id: params.id },
|
||||
where: { id: id },
|
||||
});
|
||||
|
||||
if (!design) {
|
||||
@@ -25,13 +27,13 @@ export async function POST(
|
||||
}
|
||||
|
||||
const updated = await prisma.designApproval.update({
|
||||
where: { id: params.id },
|
||||
where: { id: id },
|
||||
data: {
|
||||
...(designJson && { designJson }),
|
||||
...(designPreviewUrl && { designPreviewUrl }),
|
||||
...(designPreviewUrlBack && { designPreviewUrlBack }),
|
||||
...(placementPreviewUrl && { placementPreviewUrl }),
|
||||
...(placementPreviewUrlBack && { placementPreviewUrlBack }),
|
||||
...(designPreviewUrl && { designPreviewUrl: await saveDataUrl(designPreviewUrl, 'designs') }),
|
||||
...(designPreviewUrlBack && { designPreviewUrlBack: await saveDataUrl(designPreviewUrlBack, 'designs') }),
|
||||
...(placementPreviewUrl && { placementPreviewUrl: await saveDataUrl(placementPreviewUrl, 'design-placements') }),
|
||||
...(placementPreviewUrlBack && { placementPreviewUrlBack: await saveDataUrl(placementPreviewUrlBack, 'design-placements') }),
|
||||
...(designWidthCm !== undefined && { designWidthCm }),
|
||||
...(designHeightCm !== undefined && { designHeightCm }),
|
||||
},
|
||||
|
||||
@@ -5,11 +5,12 @@ import { toProductDTO } from '@/lib/product';
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const design = await prisma.designApproval.findUnique({
|
||||
where: { id: params.id },
|
||||
where: { id },
|
||||
include: { product: true, messages: { orderBy: { createdAt: 'asc' } } },
|
||||
});
|
||||
|
||||
@@ -32,9 +33,10 @@ export async function GET(
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
@@ -43,7 +45,7 @@ export async function PATCH(
|
||||
const { status } = await req.json();
|
||||
|
||||
const updated = await prisma.designApproval.update({
|
||||
where: { id: params.id },
|
||||
where: { id },
|
||||
data: { status },
|
||||
include: { product: true, messages: { orderBy: { createdAt: 'asc' } } },
|
||||
});
|
||||
@@ -63,17 +65,18 @@ export async function PATCH(
|
||||
|
||||
export async function DELETE(
|
||||
req: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
// Delete associated messages first
|
||||
await prisma.designApprovalMessage.deleteMany({
|
||||
where: { designId: params.id },
|
||||
where: { designId: id },
|
||||
});
|
||||
|
||||
// Delete the design approval
|
||||
await prisma.designApproval.delete({
|
||||
where: { id: params.id },
|
||||
where: { id: id },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
|
||||
@@ -4,13 +4,14 @@ import { sendDesignReadyForReview } from '@/lib/mail';
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { designPreviewUrl, designPreviewUrlBack, placementPreviewUrl, placementPreviewUrlBack } = await req.json().catch(() => ({}));
|
||||
|
||||
const design = await prisma.designApproval.findUnique({
|
||||
where: { id: params.id },
|
||||
where: { id: id },
|
||||
include: { product: true },
|
||||
});
|
||||
|
||||
@@ -26,7 +27,7 @@ export async function POST(
|
||||
if (placementPreviewUrlBack) updateData.placementPreviewUrlBack = placementPreviewUrlBack;
|
||||
|
||||
const updated = await prisma.designApproval.update({
|
||||
where: { id: params.id },
|
||||
where: { id: id },
|
||||
data: updateData,
|
||||
include: { product: true, messages: { orderBy: { createdAt: 'asc' } } },
|
||||
});
|
||||
@@ -36,7 +37,7 @@ export async function POST(
|
||||
customerName: design.customerName || 'Customer',
|
||||
customerEmail: design.customerEmail,
|
||||
productName: design.product.name,
|
||||
reviewLink: `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}/designs/${params.id}/review`,
|
||||
reviewLink: `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}/designs/${id}/review`,
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET(req: Request, { params }: { params: { id: string } }) {
|
||||
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = params;
|
||||
const { id } = await params;
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id },
|
||||
include: { items: true },
|
||||
@@ -292,7 +292,7 @@ export async function GET(req: Request, { params }: { params: { id: string } })
|
||||
${item.designImageDimensions ? (() => {
|
||||
try {
|
||||
const imageDims = JSON.parse(item.designImageDimensions);
|
||||
return imageDims && imageDims.length > 0 ? `<p><strong>Image Dimensions:</strong><ul style="margin: 5px 0 0 20px;">${imageDims.map((img, idx) => `<li>Image ${idx + 1}: ${img.widthCm} cm × ${img.heightCm} cm</li>`).join('')}</ul></p>` : '';
|
||||
return imageDims && imageDims.length > 0 ? `<p><strong>Image Dimensions:</strong><ul style="margin: 5px 0 0 20px;">${imageDims.map((img: any, idx: any) => `<li>Image ${idx + 1}: ${img.widthCm} cm × ${img.heightCm} cm</li>`).join('')}</ul></p>` : '';
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ import { fetchRoyalMailTracking, mapTrackingStatusToOrderStatus } from '@/lib/ro
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: params.id },
|
||||
where: { id: id },
|
||||
});
|
||||
|
||||
if (!order) {
|
||||
@@ -31,13 +32,13 @@ export async function POST(
|
||||
|
||||
// Update order with new status
|
||||
const updated = await prisma.order.update({
|
||||
where: { id: params.id },
|
||||
where: { id: id },
|
||||
data: {
|
||||
status: newStatus,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✓ Order ${params.id} tracking synced: ${trackingData.status} → ${newStatus}`);
|
||||
console.log(`✓ Order ${id} tracking synced: ${trackingData.status} → ${newStatus}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function POST(_req: Request, { params }: { params: { id: string } }) {
|
||||
const proof = await prisma.designProof.findUnique({ where: { id: params.id } });
|
||||
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const proof = await prisma.designProof.findUnique({ where: { id } });
|
||||
if (!proof) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.designProof.update({
|
||||
where: { id: params.id },
|
||||
where: { id },
|
||||
data: { status: 'APPROVED' },
|
||||
});
|
||||
|
||||
|
||||
@@ -2,13 +2,14 @@ import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import type { MessageDTO } from '@/lib/types';
|
||||
|
||||
export async function GET(_req: Request, { params }: { params: { id: string } }) {
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const rows = await prisma.message.findMany({
|
||||
where: { proofId: params.id },
|
||||
where: { proofId: id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
const messages: MessageDTO[] = rows.map((m) => ({
|
||||
const messages: MessageDTO[] = rows.map((m: any) => ({
|
||||
id: m.id,
|
||||
proofId: m.proofId,
|
||||
sender: m.sender as MessageDTO['sender'],
|
||||
@@ -19,7 +20,8 @@ export async function GET(_req: Request, { params }: { params: { id: string } })
|
||||
return NextResponse.json(messages);
|
||||
}
|
||||
|
||||
export async function POST(req: Request, { params }: { params: { id: string } }) {
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const { sender, body } = await req.json();
|
||||
|
||||
if (sender !== 'ADMIN' && sender !== 'CUSTOMER') {
|
||||
@@ -30,13 +32,13 @@ export async function POST(req: Request, { params }: { params: { id: string } })
|
||||
return NextResponse.json({ error: 'Message body is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const proof = await prisma.designProof.findUnique({ where: { id: params.id } });
|
||||
const proof = await prisma.designProof.findUnique({ where: { id } });
|
||||
if (!proof) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const created = await prisma.message.create({
|
||||
data: { proofId: params.id, sender, body: trimmed },
|
||||
data: { proofId: id, sender, body: trimmed },
|
||||
});
|
||||
|
||||
const message: MessageDTO = {
|
||||
|
||||
@@ -3,11 +3,12 @@ import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function DELETE(
|
||||
req: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const review = await prisma.review.delete({
|
||||
where: { id: params.id },
|
||||
where: { id: id },
|
||||
});
|
||||
|
||||
return NextResponse.json(review);
|
||||
@@ -22,14 +23,15 @@ export async function DELETE(
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
const { approved } = body;
|
||||
|
||||
const review = await prisma.review.update({
|
||||
where: { id: params.id },
|
||||
where: { id: id },
|
||||
data: { approved },
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function sendContactMessage(formData: FormData) {
|
||||
redirect('/contact?error=missing');
|
||||
}
|
||||
|
||||
const ip = getClientIp(headers());
|
||||
const ip = getClientIp(await headers());
|
||||
const turnstileToken = String(formData.get('cf-turnstile-response') ?? '');
|
||||
const { success } = await verifyTurnstileToken(turnstileToken, ip);
|
||||
if (!success) {
|
||||
|
||||
@@ -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;
|
||||
@@ -20,7 +21,7 @@ export async function createCustomRequest(formData: FormData) {
|
||||
throw new Error('Missing required fields: product, name, email, and a photo are all required.');
|
||||
}
|
||||
|
||||
const ip = getClientIp(headers());
|
||||
const ip = getClientIp(await headers());
|
||||
const turnstileToken = String(formData.get('cf-turnstile-response') ?? '');
|
||||
const { success } = await verifyTurnstileToken(turnstileToken, ip);
|
||||
if (!success) {
|
||||
@@ -30,9 +31,7 @@ export async function createCustomRequest(formData: FormData) {
|
||||
const product = await prisma.product.findUnique({ where: { id: productId } });
|
||||
if (!product) throw new Error('Product not found.');
|
||||
|
||||
const bytes = Buffer.from(await file.arrayBuffer());
|
||||
const mimeType = file.type || 'image/png';
|
||||
const imageDataUrl = `data:${mimeType};base64,${bytes.toString('base64')}`;
|
||||
const imageUrl = await saveUploadedFile(file, 'custom-requests');
|
||||
|
||||
const request = await prisma.customRequest.create({
|
||||
data: {
|
||||
@@ -41,7 +40,7 @@ export async function createCustomRequest(formData: FormData) {
|
||||
customerName,
|
||||
customerEmail,
|
||||
customerPhone: customerPhone || null,
|
||||
imageDataUrl,
|
||||
imageUrl,
|
||||
note: note || null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCart } from '@/lib/cartStore';
|
||||
|
||||
interface DesignApproval {
|
||||
@@ -18,18 +19,23 @@ interface DesignApproval {
|
||||
};
|
||||
designJson: string;
|
||||
designPreviewUrl: string;
|
||||
designPreviewUrlBack: string | null;
|
||||
placementPreviewUrl: string | null;
|
||||
placementPreviewUrlBack: string | null;
|
||||
color: string;
|
||||
size: string | null;
|
||||
status: string;
|
||||
expectedDeliveryDate: string | null;
|
||||
}
|
||||
|
||||
export default function DesignReviewPage({ params }: { params: { id: string } }) {
|
||||
const router = useRouter();
|
||||
const [design, setDesign] = useState<DesignApproval | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [approving, setApproving] = useState(false);
|
||||
const [changingMessage, setChangingMessage] = useState('');
|
||||
const [sendingChanges, setSendingChanges] = useState(false);
|
||||
const [showCheckoutOptions, setShowCheckoutOptions] = useState(false);
|
||||
const addItem = useCart((s) => s.addItem);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -43,7 +49,11 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
|
||||
if (!design) return;
|
||||
setApproving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/designs/${params.id}/approve`, { method: 'POST' });
|
||||
const res = await fetch(`/api/designs/${params.id}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
setDesign(updated);
|
||||
@@ -59,7 +69,7 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
|
||||
size: design.size,
|
||||
quantity: 1,
|
||||
unitPrice: design.product.personalisedPrice ?? design.product.effectivePrice,
|
||||
designJson: design.designJson,
|
||||
designJson: JSON.parse(design.designJson),
|
||||
designPreviewUrl: design.designPreviewUrl,
|
||||
designPreviewUrlBack: design.designPreviewUrlBack,
|
||||
placementPreviewUrl: design.placementPreviewUrl,
|
||||
@@ -68,8 +78,13 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
|
||||
designHeightCm: null,
|
||||
});
|
||||
|
||||
alert('Design approved! Added to your bag.');
|
||||
setShowCheckoutOptions(true);
|
||||
} else {
|
||||
alert('Failed to approve design. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error approving design:', error);
|
||||
alert('An error occurred. Please try again.');
|
||||
} finally {
|
||||
setApproving(false);
|
||||
}
|
||||
@@ -167,6 +182,11 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
|
||||
<p className="border-t border-line pt-3">
|
||||
<strong>Price:</strong> £{((design.product.personalisedPrice ?? design.product.effectivePrice) / 100).toFixed(2)}
|
||||
</p>
|
||||
{design.expectedDeliveryDate && (
|
||||
<p className="text-xs text-muted pt-2">
|
||||
<strong>Est. delivery:</strong> {new Date(design.expectedDeliveryDate).toLocaleDateString(undefined, { dateStyle: 'medium' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -197,6 +217,32 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Checkout Options Modal */}
|
||||
{showCheckoutOptions && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div className="bg-paper border border-line rounded-lg p-8 max-w-md mx-4 shadow-lg">
|
||||
<h2 className="font-display text-2xl mb-2">✓ Design approved!</h2>
|
||||
<p className="text-sm text-muted mb-6">
|
||||
Your design has been added to your bag. What would you like to do next?
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => router.push('/checkout')}
|
||||
className="w-full bg-clay px-4 py-3 text-sm font-medium text-paper hover:bg-clay-dark rounded"
|
||||
>
|
||||
Go to Checkout
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCheckoutOptions(false)}
|
||||
className="w-full border border-ink px-4 py-3 text-sm font-medium text-ink hover:border-clay hover:bg-clay hover:text-paper rounded"
|
||||
>
|
||||
Keep Shopping
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ import Link from 'next/link';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import Reveal from '@/components/Reveal';
|
||||
|
||||
export default async function GalleryPage({ searchParams }: { searchParams: { category?: string } }) {
|
||||
export default async function GalleryPage({ searchParams }: { searchParams: Promise<{ category?: string }> }) {
|
||||
const { category } = await searchParams;
|
||||
const categories = await prisma.galleryCategory.findMany({ orderBy: { name: 'asc' } });
|
||||
|
||||
const activeSlug = searchParams.category ?? null;
|
||||
const activeSlug = category ?? null;
|
||||
const activeCategory = activeSlug ? categories.find((c) => c.slug === activeSlug) ?? null : null;
|
||||
|
||||
const photos = await prisma.galleryPhoto.findMany({
|
||||
@@ -56,7 +57,7 @@ export default async function GalleryPage({ searchParams }: { searchParams: { ca
|
||||
<figure key={p.id} className="group flex w-full flex-col overflow-hidden border border-line bg-surface">
|
||||
<div className="aspect-square w-full overflow-hidden">
|
||||
<img
|
||||
src={p.imageDataUrl}
|
||||
src={p.imageUrl ?? ''}
|
||||
alt={p.caption ?? 'Completed work'}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
|
||||
+3
-14
@@ -7,6 +7,7 @@ import Footer from '@/components/Footer';
|
||||
import PromotionBanner from '@/components/PromotionBanner';
|
||||
import MaintenancePage from '@/components/MaintenancePage';
|
||||
import LogoutOnUnload from '@/components/LogoutOnUnload';
|
||||
import ThemeInitializer from '@/components/ThemeInitializer';
|
||||
import { CurrencyProvider } from '@/lib/CurrencyContext';
|
||||
import { getSiteSettings } from '@/lib/settings';
|
||||
import { isAdminAuthenticated } from '@/lib/adminAuth';
|
||||
@@ -31,7 +32,7 @@ export const metadata: Metadata = {
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const { maintenanceMode, maintenanceMessage } = await getSiteSettings();
|
||||
const pathname = headers().get('x-pathname') ?? '';
|
||||
const pathname = (await headers()).get('x-pathname') ?? '';
|
||||
// Admins bypass maintenance (they can keep working); /admin/* is always
|
||||
// reachable so the owner can log in and toggle it back off.
|
||||
// Also allow /checkout/* pages so customers can complete purchases and see results.
|
||||
@@ -50,21 +51,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600&family=Oswald:wght@500&family=Bebas+Neue&family=Pacifico&family=Permanent+Marker&family=Caveat:wght@600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
{/* Runs before paint so there's no flash of the wrong theme on load. */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
var stored = localStorage.getItem('theme');
|
||||
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
var isDark = stored ? stored === 'dark' : prefersDark;
|
||||
if (isDark) document.documentElement.classList.add('dark');
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body suppressHydrationWarning className="flex min-h-screen flex-col">
|
||||
<ThemeInitializer />
|
||||
<LogoutOnUnload />
|
||||
<CurrencyProvider>
|
||||
{showMaintenance ? (
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { toProductDTO } from '@/lib/product';
|
||||
import { fetchActivePromotions } from '@/lib/promotions';
|
||||
import ProductCard from '@/components/ProductCard';
|
||||
|
||||
const DesignCanvas = dynamic(() => import('@/components/DesignCanvas'), { ssr: false });
|
||||
import DesignCanvasWrapper from '@/components/DesignCanvasWrapper';
|
||||
|
||||
export default async function ProductDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: { slug: string };
|
||||
searchParams: { customRequestId?: string };
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{ customRequestId?: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const { customRequestId } = await searchParams;
|
||||
const activePromotions = await fetchActivePromotions();
|
||||
|
||||
const row = await prisma.product.findUnique({ where: { slug: params.slug } });
|
||||
const row = await prisma.product.findUnique({ where: { slug } });
|
||||
if (!row || !row.showOnPersonalised) notFound();
|
||||
|
||||
const product = toProductDTO(row, activePromotions, 'personalised');
|
||||
@@ -29,18 +29,18 @@ export default async function ProductDetailPage({
|
||||
|
||||
// Fetch custom photo from request if customRequestId is provided
|
||||
let customPhoto: string | undefined;
|
||||
if (searchParams.customRequestId) {
|
||||
if (customRequestId) {
|
||||
const customRequest = await prisma.customRequest.findUnique({
|
||||
where: { id: searchParams.customRequestId },
|
||||
where: { id: customRequestId },
|
||||
});
|
||||
if (customRequest) {
|
||||
customPhoto = customRequest.imageDataUrl;
|
||||
customPhoto = customRequest.imageUrl ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-12">
|
||||
<DesignCanvas product={product} customPhoto={customPhoto} />
|
||||
<DesignCanvasWrapper product={product} customPhoto={customPhoto} />
|
||||
|
||||
{related.length > 0 && (
|
||||
<section className="mt-20 border-t border-line pt-12">
|
||||
|
||||
+38
-257
@@ -1,300 +1,81 @@
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { toProductDTO } from '@/lib/product';
|
||||
import { fetchActivePromotions } from '@/lib/promotions';
|
||||
import { getPersonalisedPaletteColors } from '@/lib/cache';
|
||||
import ProductCard from '@/components/ProductCard';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
type SearchParams = {
|
||||
category?: string | string[];
|
||||
collection?: string | string[];
|
||||
color?: string | string[];
|
||||
min?: string;
|
||||
max?: string;
|
||||
q?: string;
|
||||
page?: string;
|
||||
};
|
||||
|
||||
const PRODUCTS_PER_PAGE = 20;
|
||||
|
||||
function toArray(v?: string | string[]) {
|
||||
if (!v) return [];
|
||||
return Array.isArray(v) ? v : [v];
|
||||
}
|
||||
|
||||
export default async function ProductsPage({ searchParams }: { searchParams: SearchParams }) {
|
||||
const categoryRows = await prisma.category.findMany({
|
||||
where: { showOnPersonalised: true, parentId: null },
|
||||
orderBy: { name: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
children: {
|
||||
where: { showOnPersonalised: true },
|
||||
select: { id: true, name: true, slug: true },
|
||||
orderBy: { name: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
// Build flat list of all categories (parents and children) for filtering
|
||||
const allCategories = categoryRows.flatMap((parent) => [
|
||||
{ value: parent.slug, label: parent.name },
|
||||
...parent.children.map((child) => ({ value: child.slug, label: `${parent.name} > ${child.name}` })),
|
||||
]);
|
||||
const CATEGORIES = allCategories;
|
||||
|
||||
const collectionRows = await prisma.collection.findMany({
|
||||
select: { slug: true, name: true, group: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
const OCCASIONS = collectionRows.filter((c) => c.group === 'OCCASION').map((c) => ({ value: c.slug, label: c.name }));
|
||||
const RECIPIENTS = collectionRows.filter((c) => c.group === 'RECIPIENT').map((c) => ({ value: c.slug, label: c.name }));
|
||||
|
||||
const selectedCategories = toArray(searchParams.category);
|
||||
const selectedCollections = toArray(searchParams.collection);
|
||||
const selectedColors = toArray(searchParams.color);
|
||||
const q = searchParams.q?.trim() ?? '';
|
||||
const minDollars = searchParams.min ? Number(searchParams.min) : undefined;
|
||||
const maxDollars = searchParams.max ? Number(searchParams.max) : undefined;
|
||||
const currentPage = Math.max(1, Number(searchParams.page) || 1);
|
||||
|
||||
// Cached palette across every personalised product
|
||||
const palette = await getPersonalisedPaletteColors();
|
||||
|
||||
const where: Prisma.ProductWhereInput = { showOnPersonalised: true };
|
||||
if (selectedCategories.length) {
|
||||
where.category = { in: selectedCategories };
|
||||
}
|
||||
if (selectedCollections.length) {
|
||||
where.collections = { some: { slug: { in: selectedCollections } } };
|
||||
}
|
||||
if (selectedColors.length) {
|
||||
where.AND = [{ OR: selectedColors.map((c) => ({ colors: { contains: c } })) }];
|
||||
}
|
||||
if (q) {
|
||||
where.name = { contains: q };
|
||||
}
|
||||
if (minDollars !== undefined || maxDollars !== undefined) {
|
||||
where.basePrice = {
|
||||
...(minDollars !== undefined ? { gte: Math.round(minDollars * 100) } : {}),
|
||||
...(maxDollars !== undefined ? { lte: Math.round(maxDollars * 100) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProductsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
const { page } = await searchParams;
|
||||
const currentPage = Math.max(1, Number(page) || 1);
|
||||
const activePromotions = await fetchActivePromotions();
|
||||
|
||||
const [rows, totalCount] = await Promise.all([
|
||||
prisma.product.findMany({
|
||||
where,
|
||||
where: { showOnPersonalised: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: PRODUCTS_PER_PAGE,
|
||||
skip: (currentPage - 1) * PRODUCTS_PER_PAGE,
|
||||
}),
|
||||
prisma.product.count({ where }),
|
||||
prisma.product.count({ where: { showOnPersonalised: true } }),
|
||||
]);
|
||||
|
||||
const products = rows.map((p) => toProductDTO(p, activePromotions, 'personalised'));
|
||||
const totalPages = Math.ceil(totalCount / PRODUCTS_PER_PAGE);
|
||||
|
||||
const hasFilters =
|
||||
selectedCategories.length > 0 ||
|
||||
selectedCollections.length > 0 ||
|
||||
selectedColors.length > 0 ||
|
||||
q ||
|
||||
minDollars !== undefined ||
|
||||
maxDollars !== undefined;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-12">
|
||||
<div className="mb-8 flex items-baseline justify-between">
|
||||
<div className="mb-12 flex items-baseline justify-between">
|
||||
<h1 className="font-display text-4xl">Shop all</h1>
|
||||
<p className="tag-label">
|
||||
{totalCount} template{totalCount === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="GET" className="grid gap-10 md:grid-cols-[220px_1fr]">
|
||||
{/* Sidebar filters */}
|
||||
<aside className="space-y-8">
|
||||
<div>
|
||||
<label htmlFor="q" className="tag-label mb-2 block">
|
||||
Search
|
||||
</label>
|
||||
<input
|
||||
id="q"
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="Search templates…"
|
||||
className="w-full border border-line px-3 py-2 text-sm focus:border-ink"
|
||||
/>
|
||||
<div>
|
||||
{products.length === 0 ? (
|
||||
<div className="border border-line bg-surface p-12 text-center">
|
||||
<p className="font-display text-xl">Nothing here yet</p>
|
||||
<p className="mt-2 text-sm text-muted">Templates will show up here once created.</p>
|
||||
</div>
|
||||
|
||||
) : (
|
||||
<div>
|
||||
<p className="tag-label mb-3">Category</p>
|
||||
<div className="space-y-2">
|
||||
{CATEGORIES.map((c) => (
|
||||
<label key={c.value} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="category"
|
||||
value={c.value}
|
||||
defaultChecked={selectedCategories.includes(c.value)}
|
||||
className="h-4 w-4 accent-splash-pink"
|
||||
/>
|
||||
{c.label}
|
||||
</label>
|
||||
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{products.map((p) => (
|
||||
<ProductCard key={p.id} product={p} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{OCCASIONS.length > 0 && (
|
||||
<div>
|
||||
<p className="tag-label mb-3">Occasion</p>
|
||||
<div className="space-y-2">
|
||||
{OCCASIONS.map((c) => (
|
||||
<label key={c.value} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="collection"
|
||||
value={c.value}
|
||||
defaultChecked={selectedCollections.includes(c.value)}
|
||||
className="h-4 w-4 accent-splash-pink"
|
||||
/>
|
||||
{c.label}
|
||||
</label>
|
||||
))}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-12 flex items-center justify-center gap-4 border-t border-line pt-8">
|
||||
{currentPage > 1 && (
|
||||
<a
|
||||
href={`/made-to-order?page=${currentPage - 1}`}
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
← Previous
|
||||
</a>
|
||||
)}
|
||||
<p className="text-sm text-muted">
|
||||
Page {currentPage} of {totalPages}
|
||||
</p>
|
||||
{currentPage < totalPages && (
|
||||
<a
|
||||
href={`/made-to-order?page=${currentPage + 1}`}
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
Next →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{RECIPIENTS.length > 0 && (
|
||||
<div>
|
||||
<p className="tag-label mb-3">Gifts for</p>
|
||||
<div className="space-y-2">
|
||||
{RECIPIENTS.map((c) => (
|
||||
<label key={c.value} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="collection"
|
||||
value={c.value}
|
||||
defaultChecked={selectedCollections.includes(c.value)}
|
||||
className="h-4 w-4 accent-splash-pink"
|
||||
/>
|
||||
{c.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="tag-label mb-3">Color</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{palette.map((hex) => {
|
||||
const checked = selectedColors.includes(hex);
|
||||
return (
|
||||
<label key={hex} className="cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="color"
|
||||
value={hex}
|
||||
defaultChecked={checked}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<span
|
||||
title={hex}
|
||||
className={`block h-8 w-8 rounded-full border transition-transform peer-checked:scale-110 peer-checked:border-ink peer-checked:ring-2 peer-checked:ring-ink peer-checked:ring-offset-2 peer-checked:ring-offset-paper ${
|
||||
checked ? 'border-ink' : 'border-line'
|
||||
}`}
|
||||
style={{ backgroundColor: hex }}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="tag-label mb-3">Price</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
name="min"
|
||||
min={0}
|
||||
placeholder="Min"
|
||||
defaultValue={searchParams.min ?? ''}
|
||||
className="w-full border border-line px-2 py-2 text-sm focus:border-ink"
|
||||
/>
|
||||
<span className="text-muted">–</span>
|
||||
<input
|
||||
type="number"
|
||||
name="max"
|
||||
min={0}
|
||||
placeholder="Max"
|
||||
defaultValue={searchParams.max ?? ''}
|
||||
className="w-full border border-line px-2 py-2 text-sm focus:border-ink"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
Apply filters
|
||||
</button>
|
||||
{hasFilters && (
|
||||
<a href="/made-to-order" className="text-center text-sm text-muted hover:text-ink">
|
||||
Clear filters
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Grid */}
|
||||
<div>
|
||||
{products.length === 0 ? (
|
||||
<div className="border border-line bg-surface p-12 text-center">
|
||||
<p className="font-display text-xl">No templates match those filters</p>
|
||||
<p className="mt-2 text-sm text-muted">Try widening your price range or clearing a filter.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{products.map((p) => (
|
||||
<ProductCard key={p.id} product={p} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-12 flex items-center justify-center gap-4 border-t border-line pt-8">
|
||||
{currentPage > 1 && (
|
||||
<a
|
||||
href={`/made-to-order?page=${currentPage - 1}${selectedCategories.length ? `&category=${selectedCategories.join('&category=')}` : ''}${selectedCollections.length ? `&collection=${selectedCollections.join('&collection=')}` : ''}${selectedColors.length ? `&color=${selectedColors.join('&color=')}` : ''}${q ? `&q=${encodeURIComponent(q)}` : ''}${minDollars !== undefined ? `&min=${minDollars}` : ''}${maxDollars !== undefined ? `&max=${maxDollars}` : ''}`}
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
← Previous
|
||||
</a>
|
||||
)}
|
||||
<p className="text-sm text-muted">
|
||||
Page {currentPage} of {totalPages}
|
||||
</p>
|
||||
{currentPage < totalPages && (
|
||||
<a
|
||||
href={`/made-to-order?page=${currentPage + 1}${selectedCategories.length ? `&category=${selectedCategories.join('&category=')}` : ''}${selectedCollections.length ? `&collection=${selectedCollections.join('&collection=')}` : ''}${selectedColors.length ? `&color=${selectedColors.join('&color=')}` : ''}${q ? `&q=${encodeURIComponent(q)}` : ''}${minDollars !== undefined ? `&min=${minDollars}` : ''}${maxDollars !== undefined ? `&max=${maxDollars}` : ''}`}
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
Next →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-1
@@ -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 },
|
||||
|
||||
+35
-263
@@ -1,120 +1,42 @@
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { toProductDTO } from '@/lib/product';
|
||||
import { fetchActivePromotions } from '@/lib/promotions';
|
||||
import { getPaletteColors } from '@/lib/cache';
|
||||
import ProductCard from '@/components/ProductCard';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
type SearchParams = {
|
||||
category?: string | string[];
|
||||
collection?: string | string[];
|
||||
color?: string | string[];
|
||||
min?: string;
|
||||
max?: string;
|
||||
q?: string;
|
||||
page?: string;
|
||||
};
|
||||
|
||||
const PRODUCTS_PER_PAGE = 20;
|
||||
|
||||
function toArray(v?: string | string[]) {
|
||||
if (!v) return [];
|
||||
return Array.isArray(v) ? v : [v];
|
||||
}
|
||||
|
||||
export default async function ReadyMadeProductsPage({ searchParams }: { searchParams: SearchParams }) {
|
||||
const categoryRows = await prisma.category.findMany({
|
||||
where: { showOnProducts: true, parentId: null },
|
||||
orderBy: { name: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
children: {
|
||||
where: { showOnProducts: true },
|
||||
select: { id: true, name: true, slug: true },
|
||||
orderBy: { name: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
// Build flat list of all categories (parents and children) for filtering
|
||||
const allCategories = categoryRows.flatMap((parent) => [
|
||||
{ value: parent.slug, label: parent.name },
|
||||
...parent.children.map((child) => ({ value: child.slug, label: `${parent.name} > ${child.name}` })),
|
||||
]);
|
||||
const CATEGORIES = allCategories;
|
||||
|
||||
const collectionRows = await prisma.collection.findMany({
|
||||
select: { slug: true, name: true, group: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
const OCCASIONS = collectionRows.filter((c) => c.group === 'OCCASION').map((c) => ({ value: c.slug, label: c.name }));
|
||||
const RECIPIENTS = collectionRows.filter((c) => c.group === 'RECIPIENT').map((c) => ({ value: c.slug, label: c.name }));
|
||||
|
||||
const selectedCategories = toArray(searchParams.category);
|
||||
const selectedCollections = toArray(searchParams.collection);
|
||||
const selectedColors = toArray(searchParams.color);
|
||||
const q = searchParams.q?.trim() ?? '';
|
||||
const minDollars = searchParams.min ? Number(searchParams.min) : undefined;
|
||||
const maxDollars = searchParams.max ? Number(searchParams.max) : undefined;
|
||||
const currentPage = Math.max(1, Number(searchParams.page) || 1);
|
||||
|
||||
// Cached palette across ready-made products for the swatch filter
|
||||
const palette = await getPaletteColors();
|
||||
|
||||
const where: Prisma.ProductWhereInput = { showAsReadyMade: true };
|
||||
if (selectedCategories.length) {
|
||||
where.category = { in: selectedCategories };
|
||||
}
|
||||
if (selectedCollections.length) {
|
||||
where.collections = { some: { slug: { in: selectedCollections } } };
|
||||
}
|
||||
if (selectedColors.length) {
|
||||
where.AND = [{ OR: selectedColors.map((c) => ({ colors: { contains: c } })) }];
|
||||
}
|
||||
if (q) {
|
||||
where.name = { contains: q };
|
||||
}
|
||||
if (minDollars !== undefined || maxDollars !== undefined) {
|
||||
where.basePrice = {
|
||||
...(minDollars !== undefined ? { gte: Math.round(minDollars * 100) } : {}),
|
||||
...(maxDollars !== undefined ? { lte: Math.round(maxDollars * 100) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ReadyMadeProductsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
const params = await searchParams;
|
||||
const currentPage = Math.max(1, Number(params.page) || 1);
|
||||
const activePromotions = await fetchActivePromotions();
|
||||
|
||||
const [rows, totalCount] = await Promise.all([
|
||||
prisma.product.findMany({
|
||||
where,
|
||||
where: { showAsReadyMade: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: PRODUCTS_PER_PAGE,
|
||||
skip: (currentPage - 1) * PRODUCTS_PER_PAGE,
|
||||
}),
|
||||
prisma.product.count({ where }),
|
||||
prisma.product.count({ where: { showAsReadyMade: true } }),
|
||||
]);
|
||||
|
||||
const products = rows.map((p) => toProductDTO(p, activePromotions, 'plain'));
|
||||
const totalPages = Math.ceil(totalCount / PRODUCTS_PER_PAGE);
|
||||
|
||||
const hasFilters =
|
||||
selectedCategories.length > 0 ||
|
||||
selectedCollections.length > 0 ||
|
||||
selectedColors.length > 0 ||
|
||||
q ||
|
||||
minDollars !== undefined ||
|
||||
maxDollars !== undefined;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-12">
|
||||
<div className="mb-8 flex items-baseline justify-between">
|
||||
<div className="mb-12 flex items-baseline justify-between">
|
||||
<h1 className="font-display text-4xl">Products</h1>
|
||||
<p className="tag-label">
|
||||
{products.length} item{products.length === 1 ? '' : 's'}
|
||||
{totalCount} item{totalCount === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{products.length === 0 && !hasFilters ? (
|
||||
{products.length === 0 ? (
|
||||
<div className="border border-line bg-surface p-12 text-center">
|
||||
<p className="font-display text-xl">Nothing here yet</p>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
@@ -123,187 +45,37 @@ export default async function ReadyMadeProductsPage({ searchParams }: { searchPa
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form method="GET" className="grid gap-10 md:grid-cols-[220px_1fr]">
|
||||
{/* Sidebar filters */}
|
||||
<aside className="space-y-8">
|
||||
<div>
|
||||
<label htmlFor="q" className="tag-label mb-2 block">
|
||||
Search
|
||||
</label>
|
||||
<input
|
||||
id="q"
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="Search products…"
|
||||
className="w-full border border-line px-3 py-2 text-sm focus:border-ink"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{products.map((p) => (
|
||||
<ProductCard key={p.id} product={p} hrefBase="/products" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="tag-label mb-3">Category</p>
|
||||
<div className="space-y-2">
|
||||
{CATEGORIES.map((c) => (
|
||||
<label key={c.value} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="category"
|
||||
value={c.value}
|
||||
defaultChecked={selectedCategories.includes(c.value)}
|
||||
className="h-4 w-4 accent-splash-pink"
|
||||
/>
|
||||
{c.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{OCCASIONS.length > 0 && (
|
||||
<div>
|
||||
<p className="tag-label mb-3">Occasion</p>
|
||||
<div className="space-y-2">
|
||||
{OCCASIONS.map((c) => (
|
||||
<label key={c.value} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="collection"
|
||||
value={c.value}
|
||||
defaultChecked={selectedCollections.includes(c.value)}
|
||||
className="h-4 w-4 accent-splash-pink"
|
||||
/>
|
||||
{c.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{RECIPIENTS.length > 0 && (
|
||||
<div>
|
||||
<p className="tag-label mb-3">Gifts for</p>
|
||||
<div className="space-y-2">
|
||||
{RECIPIENTS.map((c) => (
|
||||
<label key={c.value} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="collection"
|
||||
value={c.value}
|
||||
defaultChecked={selectedCollections.includes(c.value)}
|
||||
className="h-4 w-4 accent-splash-pink"
|
||||
/>
|
||||
{c.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="tag-label mb-3">Color</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{palette.map((hex) => {
|
||||
const checked = selectedColors.includes(hex);
|
||||
return (
|
||||
<label key={hex} className="cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="color"
|
||||
value={hex}
|
||||
defaultChecked={checked}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<span
|
||||
title={hex}
|
||||
className={`block h-8 w-8 rounded-full border transition-transform peer-checked:scale-110 peer-checked:border-ink peer-checked:ring-2 peer-checked:ring-ink peer-checked:ring-offset-2 peer-checked:ring-offset-paper ${
|
||||
checked ? 'border-ink' : 'border-line'
|
||||
}`}
|
||||
style={{ backgroundColor: hex }}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="tag-label mb-3">Price</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
name="min"
|
||||
min={0}
|
||||
placeholder="Min"
|
||||
defaultValue={searchParams.min ?? ''}
|
||||
className="w-full border border-line px-2 py-2 text-sm focus:border-ink"
|
||||
/>
|
||||
<span className="text-muted">–</span>
|
||||
<input
|
||||
type="number"
|
||||
name="max"
|
||||
min={0}
|
||||
placeholder="Max"
|
||||
defaultValue={searchParams.max ?? ''}
|
||||
className="w-full border border-line px-2 py-2 text-sm focus:border-ink"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
Apply filters
|
||||
</button>
|
||||
{hasFilters && (
|
||||
<a href="/products" className="text-center text-sm text-muted hover:text-ink">
|
||||
Clear filters
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-12 flex items-center justify-center gap-4 border-t border-line pt-8">
|
||||
{currentPage > 1 && (
|
||||
<a
|
||||
href={`/products?page=${currentPage - 1}`}
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
← Previous
|
||||
</a>
|
||||
)}
|
||||
<p className="text-sm text-muted">
|
||||
Page {currentPage} of {totalPages}
|
||||
</p>
|
||||
{currentPage < totalPages && (
|
||||
<a
|
||||
href={`/products?page=${currentPage + 1}`}
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
Next →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Grid */}
|
||||
<div>
|
||||
{products.length === 0 ? (
|
||||
<div className="border border-line bg-surface p-12 text-center">
|
||||
<p className="font-display text-xl">No products match those filters</p>
|
||||
<p className="mt-2 text-sm text-muted">Try widening your price range or clearing a filter.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{products.map((p) => (
|
||||
<ProductCard key={p.id} product={p} hrefBase="/products" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-12 flex items-center justify-center gap-4 border-t border-line pt-8">
|
||||
{currentPage > 1 && (
|
||||
<a
|
||||
href={`/products?page=${currentPage - 1}${selectedCategories.length ? `&category=${selectedCategories.join('&category=')}` : ''}${selectedCollections.length ? `&collection=${selectedCollections.join('&collection=')}` : ''}${selectedColors.length ? `&color=${selectedColors.join('&color=')}` : ''}${q ? `&q=${encodeURIComponent(q)}` : ''}${minDollars !== undefined ? `&min=${minDollars}` : ''}${maxDollars !== undefined ? `&max=${maxDollars}` : ''}`}
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
← Previous
|
||||
</a>
|
||||
)}
|
||||
<p className="text-sm text-muted">
|
||||
Page {currentPage} of {totalPages}
|
||||
</p>
|
||||
{currentPage < totalPages && (
|
||||
<a
|
||||
href={`/products?page=${currentPage + 1}${selectedCategories.length ? `&category=${selectedCategories.join('&category=')}` : ''}${selectedCollections.length ? `&collection=${selectedCollections.join('&collection=')}` : ''}${selectedColors.length ? `&color=${selectedColors.join('&color=')}` : ''}${q ? `&q=${encodeURIComponent(q)}` : ''}${minDollars !== undefined ? `&min=${minDollars}` : ''}${maxDollars !== undefined ? `&max=${maxDollars}` : ''}`}
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
Next →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,9 +5,10 @@ import ApproveSection from '@/components/ApproveSection';
|
||||
import Chat from '@/components/Chat';
|
||||
import Price from '@/components/Price';
|
||||
|
||||
export default async function ProofPage({ params }: { params: { id: string } }) {
|
||||
export default async function ProofPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const row = await prisma.designProof.findUnique({
|
||||
where: { id: params.id },
|
||||
where: { id },
|
||||
include: { product: true },
|
||||
});
|
||||
if (!row) notFound();
|
||||
@@ -22,8 +23,8 @@ export default async function ProofPage({ params }: { params: { id: string } })
|
||||
|
||||
<div className="mt-10 grid gap-10 md:grid-cols-2">
|
||||
<div className="border border-line bg-surface p-6">
|
||||
{proof.imageDataUrl ? (
|
||||
<img src={proof.imageDataUrl} alt={`${proof.productName} design`} className="w-full object-contain" />
|
||||
{proof.imageUrl ? (
|
||||
<img src={proof.imageUrl} alt={`${proof.productName} design`} className="w-full object-contain" />
|
||||
) : (
|
||||
<p className="text-center text-sm text-muted">No image was uploaded with this design.</p>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { Stage, Layer, Text, Image as KonvaImage, Transformer } from 'react-konva';
|
||||
import type Konva from 'konva';
|
||||
import Konva from 'konva';
|
||||
import type { DesignElement } from '@/lib/types';
|
||||
|
||||
interface AdminDesignEditorProps {
|
||||
|
||||
@@ -26,11 +26,13 @@ export default function ApproveSection({ proof }: { proof: ProofDTO }) {
|
||||
size: null,
|
||||
quantity: proof.quantity,
|
||||
unitPrice: proof.unitPrice,
|
||||
designJson: { front: [], back: [] },
|
||||
designPreviewUrl: proof.imageDataUrl,
|
||||
designJson: { front: [], back: [] } as any,
|
||||
designPreviewUrl: proof.imageUrl,
|
||||
designPreviewUrlBack: null,
|
||||
placementPreviewUrl: proof.imageDataUrl,
|
||||
placementPreviewUrl: proof.imageUrl,
|
||||
placementPreviewUrlBack: null,
|
||||
designWidthCm: null,
|
||||
designHeightCm: null,
|
||||
});
|
||||
setStatus('APPROVED');
|
||||
router.push('/cart');
|
||||
|
||||
@@ -325,12 +325,13 @@ function PrintSurface({
|
||||
/>
|
||||
{elements.map((el) =>
|
||||
el.type === 'text' ? (
|
||||
el.curvedPath ? (
|
||||
(el as any).curvedPath ? (
|
||||
// Curved text - TextPath with dragging via offset
|
||||
(() => {
|
||||
const elAny = el as any;
|
||||
const offsetX = el.x - 280;
|
||||
const offsetY = el.y - 280;
|
||||
const pathData = generateCurvePath(el.curvedPath.type, el.curvedPath.radius, el.curvedPath.reverse, offsetX, offsetY);
|
||||
const pathData = generateCurvePath(elAny.curvedPath.type, elAny.curvedPath.radius, elAny.curvedPath.reverse, offsetX, offsetY);
|
||||
console.log(`🔄 Rendering curved text ${el.text}: position (${el.x}, ${el.y}), offset (${offsetX}, ${offsetY})`);
|
||||
return (
|
||||
<TextPath
|
||||
@@ -522,6 +523,7 @@ export default function DesignCanvas({
|
||||
const [submissionMessage, setSubmissionMessage] = useState<string | null>(null);
|
||||
const [imageDimensions, setImageDimensions] = useState<Record<string, { widthCm: number | null; heightCm: number | null }>>({});
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
|
||||
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] }));
|
||||
@@ -714,44 +716,53 @@ export default function DesignCanvas({
|
||||
|
||||
const handleUploadClick = () => fileInputRef.current?.click();
|
||||
|
||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const src = reader.result as string;
|
||||
const img = new window.Image();
|
||||
img.onload = () => {
|
||||
// Clear old approval status when starting a new design
|
||||
if (approvalStatus) {
|
||||
setApprovalStatus(null);
|
||||
setDesignApprovalId(null);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem(`designApproval_${product.id}`);
|
||||
}
|
||||
}
|
||||
const maxDim = Math.min(activeStageW, activeStageH) * 0.7;
|
||||
const scale = Math.min(maxDim / img.width, maxDim / img.height, 1);
|
||||
const id = uuid();
|
||||
setActiveElements((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
type: 'image',
|
||||
x: activeStageW / 2 - (img.width * scale) / 2,
|
||||
y: activeStageH / 2 - (img.height * scale) / 2,
|
||||
rotation: 0,
|
||||
width: img.width * scale,
|
||||
height: img.height * scale,
|
||||
src,
|
||||
},
|
||||
]);
|
||||
setSelectedId(id);
|
||||
};
|
||||
img.src = src;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
setIsUploadingImage(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
const res = await fetch('/api/design-uploads', { method: 'POST', body: formData });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Upload failed');
|
||||
|
||||
const src: string = data.url;
|
||||
const img = await loadImage(src);
|
||||
|
||||
// Clear old approval status when starting a new design
|
||||
if (approvalStatus) {
|
||||
setApprovalStatus(null);
|
||||
setDesignApprovalId(null);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem(`designApproval_${product.id}`);
|
||||
}
|
||||
}
|
||||
const maxDim = Math.min(activeStageW, activeStageH) * 0.7;
|
||||
const scale = Math.min(maxDim / img.width, maxDim / img.height, 1);
|
||||
const id = uuid();
|
||||
setActiveElements((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
type: 'image',
|
||||
x: activeStageW / 2 - (img.width * scale) / 2,
|
||||
y: activeStageH / 2 - (img.height * scale) / 2,
|
||||
rotation: 0,
|
||||
width: img.width * scale,
|
||||
height: img.height * scale,
|
||||
src,
|
||||
},
|
||||
]);
|
||||
setSelectedId(id);
|
||||
} catch (err) {
|
||||
console.error('Failed to upload image:', err);
|
||||
alert('Could not upload that image — please try again.');
|
||||
} finally {
|
||||
setIsUploadingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSelected = () => {
|
||||
@@ -773,10 +784,10 @@ export default function DesignCanvas({
|
||||
salePrice: product.onSale ? product.effectivePrice : null,
|
||||
saleStartsAt: null,
|
||||
saleEndsAt: null,
|
||||
},
|
||||
} as any,
|
||||
[],
|
||||
new Date(),
|
||||
true,
|
||||
'personalised',
|
||||
).price
|
||||
: product.effectivePrice;
|
||||
|
||||
@@ -1251,10 +1262,10 @@ export default function DesignCanvas({
|
||||
salePrice: product.onSale ? product.effectivePrice : null,
|
||||
saleStartsAt: null,
|
||||
saleEndsAt: null,
|
||||
},
|
||||
} as any,
|
||||
[],
|
||||
new Date(),
|
||||
true,
|
||||
'personalised',
|
||||
);
|
||||
return (
|
||||
<>
|
||||
@@ -1493,20 +1504,24 @@ export default function DesignCanvas({
|
||||
<label className="flex items-center gap-2 text-sm text-muted mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={text.curvedPath ? true : false}
|
||||
onChange={(e) => updateElement(text.id, { curvedPath: e.target.checked ? { type: 'circle', radius: 80 } : null })}
|
||||
checked={(text as any).curvedPath ? true : false}
|
||||
onChange={(e) => updateElement(text.id, { curvedPath: e.target.checked ? { type: 'circle', radius: 80 } : null } as any)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
Curved text
|
||||
</label>
|
||||
|
||||
{text.curvedPath && (
|
||||
{(text as any).curvedPath && (
|
||||
<div className="space-y-3">
|
||||
{(() => {
|
||||
const textAny = text as any;
|
||||
return (
|
||||
<>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted">Curve type</span>
|
||||
<select
|
||||
value={text.curvedPath.type || 'circle'}
|
||||
onChange={(e) => updateElement(text.id, { curvedPath: { ...text.curvedPath, type: e.target.value as any } })}
|
||||
value={textAny.curvedPath.type || 'circle'}
|
||||
onChange={(e) => updateElement(text.id, { curvedPath: { ...textAny.curvedPath, type: e.target.value as any } } as any)}
|
||||
className="border border-line bg-paper px-2 py-1 text-xs"
|
||||
>
|
||||
<option value="circle">Circle</option>
|
||||
@@ -1523,23 +1538,26 @@ export default function DesignCanvas({
|
||||
type="range"
|
||||
min={20}
|
||||
max={200}
|
||||
value={text.curvedPath.radius || 80}
|
||||
onChange={(e) => updateElement(text.id, { curvedPath: { ...text.curvedPath, radius: Number(e.target.value) } })}
|
||||
value={textAny.curvedPath.radius || 80}
|
||||
onChange={(e) => updateElement(text.id, { curvedPath: { ...textAny.curvedPath, radius: Number(e.target.value) } } as any)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="w-12 text-right font-mono text-xs">{text.curvedPath.radius || 80}</span>
|
||||
<span className="w-12 text-right font-mono text-xs">{textAny.curvedPath.radius || 80}</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={text.curvedPath.reverse ? true : false}
|
||||
onChange={(e) => updateElement(text.id, { curvedPath: { ...text.curvedPath, reverse: e.target.checked } })}
|
||||
checked={textAny.curvedPath.reverse ? true : false}
|
||||
onChange={(e) => updateElement(text.id, { curvedPath: { ...textAny.curvedPath, reverse: e.target.checked } } as any)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<span className="text-muted">Reverse direction</span>
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1558,9 +1576,10 @@ export default function DesignCanvas({
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUploadClick}
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
disabled={isUploadingImage}
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
+ Upload image
|
||||
{isUploadingImage ? 'Uploading...' : '+ Upload image'}
|
||||
</button>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFile} />
|
||||
{selected && (
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
class DesignCanvasErrorBoundary extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center rounded-lg border border-line bg-paper">
|
||||
<div className="text-center">
|
||||
<p className="mb-2 font-medium text-ink">Design Canvas Loading...</p>
|
||||
<p className="text-sm text-muted">Please refresh the page if this persists</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default DesignCanvasErrorBoundary;
|
||||
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import FabricDesignCanvasV2 from './FabricDesignCanvasV2';
|
||||
import type { ProductDTO } from '@/lib/types';
|
||||
|
||||
interface DesignCanvasWrapperProps {
|
||||
product: ProductDTO;
|
||||
customPhoto?: string;
|
||||
}
|
||||
|
||||
export default function DesignCanvasWrapper({ product, customPhoto }: DesignCanvasWrapperProps) {
|
||||
return <FabricDesignCanvasV2 product={product} customPhoto={customPhoto} />;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user