Initial commit: Craft2Prints with Stages 1-3
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Read",
|
||||
"Edit",
|
||||
"Write",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Bash(git *)",
|
||||
"Bash(npm *)",
|
||||
"Bash(npx *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Skill(update-config)",
|
||||
"Skill(update-config:*)",
|
||||
"Bash(npx prisma *)",
|
||||
"Bash(xargs grep -l \"effectivePrice\")",
|
||||
"Bash(npx tsc *)",
|
||||
"Bash(powershell -Command \"Get-ChildItem D:\\\\Craft2Prints -Filter '.env*' -Force | Select-Object Name\")",
|
||||
"Bash(dir /a D:\\\\Craft2Prints\\\\.env*)",
|
||||
"Bash(git add *)",
|
||||
"Bash(git restore *)",
|
||||
"Bash(git reset *)",
|
||||
"Bash(git checkout *)",
|
||||
"Bash(git commit -m ' *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# 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"
|
||||
|
||||
# Stripe — get these from https://dashboard.stripe.com/apikeys
|
||||
STRIPE_SECRET_KEY="sk_live_or_test_..."
|
||||
STRIPE_WEBHOOK_SECRET="whsec_..."
|
||||
|
||||
# Public base URL of your deployed site (used for Stripe redirect URLs)
|
||||
NEXT_PUBLIC_SITE_URL="https://yourdomain.com"
|
||||
|
||||
# Your admin login credentials, checked on the /admin/login page.
|
||||
# CHANGE THESE before deploying — the defaults are public in this codebase.
|
||||
ADMIN_USER="admin"
|
||||
ADMIN_PASSWORD="changeme"
|
||||
|
||||
# Signs your admin login session (separate from the customer login below).
|
||||
# Any long random string works — e.g. run `openssl rand -base64 32` in a terminal,
|
||||
# or just mash the keyboard for 40+ characters. CHANGE THIS before deploying.
|
||||
ADMIN_SESSION_SECRET="replace-with-a-long-random-string"
|
||||
|
||||
# Signs customer account login sessions (separate from the admin login above).
|
||||
# Any long random string works — e.g. run `openssl rand -base64 32` in a terminal,
|
||||
# or just mash the keyboard for 40+ characters. CHANGE THIS before deploying.
|
||||
SESSION_SECRET="replace-with-a-long-random-string"
|
||||
|
||||
# SMTP — used to email customers their design-approval link directly.
|
||||
# Leave SMTP_HOST blank to skip emailing; you'll just get a link to copy/send yourself.
|
||||
# Example values for Gmail: host smtp.gmail.com, port 587, user your address,
|
||||
# password an "app password" (not your normal Gmail password).
|
||||
SMTP_HOST=""
|
||||
SMTP_PORT="587"
|
||||
SMTP_USER=""
|
||||
SMTP_PASSWORD=""
|
||||
MAIL_FROM="Craft2Prints <hello@yourdomain.com>"
|
||||
|
||||
# Where "new photo request" alerts get sent when a customer submits a photo at
|
||||
# /custom-request. Leave blank to default to SMTP_USER (your SMTP login address).
|
||||
ADMIN_NOTIFY_EMAIL=""
|
||||
|
||||
# Optional — lets the "Share via Messenger" button work. See README's
|
||||
# "Sharing via Messenger" section for how to get this. Leave blank to hide that button
|
||||
# (WhatsApp sharing works with no setup either way).
|
||||
NEXT_PUBLIC_FACEBOOK_APP_ID=""
|
||||
|
||||
# Cloudflare Turnstile — CAPTCHA on the custom-request, registration, newsletter,
|
||||
# and contact forms. Get a free site key + secret at https://dash.cloudflare.com
|
||||
# (Turnstile section). Leave both blank to skip CAPTCHA entirely — forms work
|
||||
# normally, just unprotected.
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=""
|
||||
TURNSTILE_SECRET_KEY=""
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
.next/
|
||||
.env
|
||||
.env.local
|
||||
prisma/dev.db
|
||||
prisma/dev.db-*
|
||||
*.log
|
||||
.DS_Store
|
||||
.claude/settings.local.json
|
||||
dist/
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"git.ignoreLimitWarning": true
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
# Craft2Prints — Handoff Brief
|
||||
|
||||
This project was built in a series of conversations with Claude (chat interface,
|
||||
zip-file delivery). The person is moving to **Claude Code** to work on it directly.
|
||||
This doc summarizes where things stand so nothing gets lost in the switch.
|
||||
|
||||
## What this is
|
||||
|
||||
An e-commerce site with two parallel catalogs:
|
||||
- **Personalised** (`/made-to-order`) — customers design their own version of a
|
||||
product using an in-browser design tool (drag text/images onto the product).
|
||||
- **Products** (`/products`) — same underlying products, but a plain "pick a
|
||||
color/size, buy as-is" page, no design tool.
|
||||
|
||||
A single product can be flagged to appear on either catalog, or both, via
|
||||
`showOnPersonalised` / `showOnProducts` booleans — same photos either way, nothing
|
||||
duplicated.
|
||||
|
||||
Business context: UK-based, GBP is the base/canonical currency, sources some
|
||||
products from a supplier called Firelabel.
|
||||
|
||||
## Tech stack
|
||||
|
||||
- Next.js 14 (App Router), TypeScript, Tailwind CSS
|
||||
- Prisma + SQLite (`prisma/dev.db`) — easy to swap to Postgres later by changing
|
||||
`provider` in `prisma/schema.prisma` and `DATABASE_URL`
|
||||
- Zustand for cart state, persisted to **IndexedDB** (not localStorage — see
|
||||
Gotchas below for why)
|
||||
- Konva / react-konva for the design tool canvas
|
||||
- Stripe for checkout
|
||||
- Nodemailer for design-approval emails (optional, SMTP-based)
|
||||
|
||||
## Person's environment (important context)
|
||||
|
||||
- **Windows**, project lives at `D:\Craft2Prints`
|
||||
- Dev server has repeatedly ended up on **port 3001** instead of 3000 because a
|
||||
stale `node.exe` process keeps holding port 3000 / locking Prisma's engine
|
||||
binary. Recurring fix: Task Manager → kill all `node.exe` → re-run
|
||||
`npx prisma generate`. If that keeps happening, it may be worth just settling on
|
||||
3001 as the working port rather than fighting it every time.
|
||||
- Was previously working **inside OneDrive**, which caused `.next` build errors
|
||||
(`EINVAL: invalid argument, readlink`) — project was moved out of OneDrive to fix
|
||||
this. If it ever moves again, keep it outside any cloud-sync folder.
|
||||
- Has hit `Watchpack Error` noise on Windows a few times — `next.config.mjs` is
|
||||
already configured to fall back to polling-based file watching, which resolved it.
|
||||
- Not deeply technical with the terminal — needs exact copy-pasteable commands, not
|
||||
assumed familiarity with npm/Prisma/Stripe CLI workflows.
|
||||
|
||||
## What's built and working
|
||||
|
||||
- Full product management: add/edit/delete, admin-managed categories (each
|
||||
scopable to either catalog), admin-managed sizes, colors as swatches (not hex
|
||||
text), real photo upload with a drag-to-mark print-area box, optional back
|
||||
photo (own print area), photo-per-color (front AND back), and an optional
|
||||
black/white photo "tint" recolor mode as a fallback when there's no per-color
|
||||
photo.
|
||||
- The design tool: front/back personalization, drag/resize/rotate text and
|
||||
uploaded images, 11 font choices, color swatches, size picker. Exports two
|
||||
images per side: a clean transparent print-ready PNG, and a "placement
|
||||
reference" image (design composited onto the actual product photo, for print
|
||||
placement — only generated for photo-based products, not illustration-only
|
||||
ones).
|
||||
- Cart (`/cart`), real Stripe Checkout, webhook-confirmed order persistence,
|
||||
admin order management (`/admin/orders`) with print files downloadable per
|
||||
order, and order archiving (manual button + auto-archive after 30 days).
|
||||
- Custom design-approval flow for one-off jobs: admin uploads a finished design
|
||||
for a specific customer (`/admin/proofs/new`), customer reviews/approves via a
|
||||
link, with an in-page chat thread and optional email notification.
|
||||
- Currency selector (GBP/USD/EUR) — display only, fixed approximate rates, not
|
||||
live; actual checkout always charges in GBP regardless of what's displayed.
|
||||
- Dark mode, admin area behind HTTP Basic Auth (`ADMIN_USER`/`ADMIN_PASSWORD` in
|
||||
`.env`).
|
||||
|
||||
## Known gaps / explicitly not built yet
|
||||
|
||||
- **Order confirmation emails** — customer only sees the on-screen success page;
|
||||
no automated email receipt yet.
|
||||
- **"Buy it now"** — everything currently goes through the cart, no skip-to-checkout
|
||||
for a single item.
|
||||
- **`/products` catalog** has no illustration fallback for placement references —
|
||||
only real uploaded photos get a placement-reference image.
|
||||
- Checkout's `shipping_address_collection` allowed countries is a short hardcoded
|
||||
list in `src/app/api/checkout/route.ts` — worth revisiting for the actual
|
||||
shipping footprint.
|
||||
- No multi-currency *charging* — only display formatting. Stripe currently always
|
||||
charges GBP.
|
||||
|
||||
## Gotchas specific to this codebase
|
||||
|
||||
- **Environment/session resets happened at least once during the build** — a chunk
|
||||
of work (categories, sizes, product editing) briefly vanished from the working
|
||||
copy and had to be reconstructed from conversation history. It was fully
|
||||
recovered, but if anything ever seems to have "reverted" unexpectedly, grep for
|
||||
the feature in question before assuming it was never built — it may just need
|
||||
re-syncing rather than rebuilding from scratch.
|
||||
- **`prisma/seed.ts` is idempotent and safe to re-run** — it only touches the 6
|
||||
starter products (matched by slug) and 3 starter categories, never anything
|
||||
added through the admin panel. Useful for resetting demo data without wiping
|
||||
real work.
|
||||
- **Cart storage**: moved from `localStorage` to IndexedDB after a real
|
||||
`QuotaExceededError` crash — each cart item can carry up to 4 full-size images
|
||||
(print files + placement refs), which blew past localStorage's ~5-10MB cap with
|
||||
just 2-3 items. Don't move this back to localStorage.
|
||||
- **The image-node-ready race condition**: uploaded images in the design tool load
|
||||
asynchronously, unlike text. There's a `nodeReadyTick` mechanism in
|
||||
`DesignCanvas.tsx` specifically to re-sync the resize-handle Transformer once an
|
||||
image's Konva node actually mounts. If resize handles ever stop appearing on
|
||||
images again, this is the first place to check — and watch out for calling a
|
||||
state setter unconditionally inside an inline Konva `ref` callback, since that
|
||||
callback re-fires on every render (this caused an infinite-loop bug once
|
||||
already; the fix was guarding on whether the node reference actually changed).
|
||||
|
||||
## Setup (for a fresh Claude Code session to verify environment)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env # fill in Stripe keys, admin password, etc. — see README
|
||||
npx prisma generate
|
||||
npm run db:push
|
||||
npm run db:seed
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Full details, including Stripe test-mode setup with the Stripe CLI, are in
|
||||
`README.md` at the project root — it's been kept up to date throughout the build
|
||||
and is the more detailed reference; treat this file as the high-level orientation
|
||||
and README.md as the how-to.
|
||||
@@ -0,0 +1,491 @@
|
||||
# Craft2Prints
|
||||
|
||||
A personalized e-commerce site: browse templates (apparel, drinkware, phone cases),
|
||||
customize them with your own text/images in an in-browser design tool, and check out
|
||||
with real Stripe payments. Built with Next.js (App Router), Prisma, and Stripe.
|
||||
|
||||
## Sharing via WhatsApp / Messenger
|
||||
|
||||
Instead of (or alongside) email, the confirmation screen after uploading a design
|
||||
gives you two direct share buttons:
|
||||
|
||||
- **WhatsApp** works immediately, no setup — opens WhatsApp with the message and
|
||||
link pre-filled, you just pick who to send it to.
|
||||
- **Messenger** needs a one-time setup: create a free app at
|
||||
https://developers.facebook.com/apps, add your site's domain under the app's
|
||||
settings, copy the App ID, and set `NEXT_PUBLIC_FACEBOOK_APP_ID` in `.env`. Until
|
||||
that's set, the Messenger button just won't appear — WhatsApp sharing still works
|
||||
either way.
|
||||
|
||||
## Important: testing links on your phone
|
||||
|
||||
Right now `NEXT_PUBLIC_SITE_URL` is `http://localhost:3000`, which only works
|
||||
**on the same computer that's running `npm run dev`**. If you open a design-approval
|
||||
link on your phone or another device, it will fail to load — `localhost` always
|
||||
means "this device," never your computer over the network. Some phones/carriers
|
||||
redirect failed addresses like that to random landing pages, which can look like
|
||||
a broken or suspicious link.
|
||||
|
||||
This isn't a bug to fix — it resolves itself once the site is deployed to a real
|
||||
domain (later, when we cover deployment). Until then, only test approval links on
|
||||
the same computer running the dev server.
|
||||
|
||||
## Sending emails
|
||||
|
||||
To have the approval link emailed to the customer automatically (instead of just
|
||||
copying it yourself), add SMTP credentials to `.env`:
|
||||
|
||||
```
|
||||
SMTP_HOST="smtp.gmail.com"
|
||||
SMTP_PORT="587"
|
||||
SMTP_USER="youraddress@gmail.com"
|
||||
SMTP_PASSWORD="your app password"
|
||||
MAIL_FROM="Craft2Prints <youraddress@gmail.com>"
|
||||
```
|
||||
|
||||
For Gmail, `SMTP_PASSWORD` needs to be an **app password**, not your normal login
|
||||
password — generate one at https://myaccount.google.com/apppasswords (requires
|
||||
2-factor authentication to be turned on). Most other providers (Outlook, a business
|
||||
email host, SendGrid, etc.) work the same way — check their SMTP settings page.
|
||||
|
||||
If `SMTP_HOST` is left blank, nothing breaks — the design still gets a shareable
|
||||
link, you just have to copy and send it yourself instead of it emailing automatically.
|
||||
|
||||
If you upload a photo, drag the pink box that appears over it to mark exactly where
|
||||
the design tool should let customers place their text/artwork — no more relying on
|
||||
a generic preset. The preview crops to a square (matching how the design tool
|
||||
displays it), so the box you draw lines up correctly for the customer later.
|
||||
|
||||
Optionally, upload a real photo instead of using the illustration. Worth knowing:
|
||||
the color swatches recolor the illustration live, but a photo is fixed — picking a
|
||||
different color still gets saved with the order, it just won't change the picture.
|
||||
Leave the photo field blank to keep the illustration and its live color-recoloring.
|
||||
|
||||
#### Personalizing the back too
|
||||
|
||||
If you set a back photo, customers get a Front/Back toggle on the design page and
|
||||
can add a completely separate design to each side — their own text/images on the
|
||||
front, different ones on the back. When you upload the back photo, drag a print-area
|
||||
box for it just like the front (defaults to the same box as the front if you skip
|
||||
this). "Add to bag" captures both sides.
|
||||
|
||||
## Real photo per color
|
||||
|
||||
Each color's photo slot now has both a **Front** and **Back** upload — the "Photo per
|
||||
color" section shows both side by side. Attach a back photo for a color and it shows
|
||||
correctly when a customer views that color's back (falls back to the product's main
|
||||
back photo, or the illustration, if left blank).
|
||||
|
||||
For the most accurate look, attach an actual photo for specific colors instead of
|
||||
relying on the tint. Once you've added colors above, a "Photo per color" section
|
||||
appears — attach a file to any color and that exact photo is shown the instant a
|
||||
customer picks that color on the design page. Colors left blank fall back to the
|
||||
front photo (tinted or fixed) or the illustration. This is the best option when you
|
||||
have real supplier/product photography for each color — no tinting compromise, just
|
||||
the true photo.
|
||||
|
||||
## Two separate product pages now
|
||||
|
||||
- `/made-to-order/[slug]` — the full design tool (only reachable for products with
|
||||
"Personalised catalog" checked)
|
||||
- `/products/[slug]` — a plain "pick a color/size, buy as-is" page, no design tool,
|
||||
no print-area box (only reachable for products with "Products catalog" checked)
|
||||
|
||||
A product checked for both catalogs shows the design tool when reached via
|
||||
`/made-to-order` and the plain buy-as-is page when reached via `/products` — same
|
||||
photos and colors either way, just a different experience depending on which
|
||||
catalog someone came from.
|
||||
|
||||
## Listing a product on both catalogs
|
||||
|
||||
On the product form there's a "Show on" section with two checkboxes:
|
||||
|
||||
- **Personalised catalog** (`/made-to-order`) — customers design their own version
|
||||
- **Products catalog** (`/products`) — same photos, browse and buy as-is
|
||||
|
||||
Check either one or both. The same product record — same photos, colors, price —
|
||||
can appear on both pages at once; nothing needs to be duplicated. `/products` now
|
||||
has its own real search/category/color/price filters, same as `/made-to-order`, just
|
||||
scoped to whichever products have that checkbox on. Each links to its own kind of
|
||||
detail page — see "Two separate product pages" above.
|
||||
|
||||
## Currency
|
||||
|
||||
Prices are stored as an integer in **GBP pence** — that's the canonical currency for
|
||||
the whole site. A currency selector in the header (£ GBP / $ USD / € EUR) lets
|
||||
customers switch the *display* currency; it remembers their choice in the browser.
|
||||
|
||||
Important: the conversion rates in `src/lib/currency.ts` are fixed approximations,
|
||||
not live exchange rates, and this only changes what's *displayed* — there's no
|
||||
checkout yet, so no real payment currency conversion happens. Update those rates
|
||||
periodically if you want displayed foreign-currency prices to stay roughly current,
|
||||
and revisit this properly once Stripe checkout is built (Stripe can charge in
|
||||
whichever currency you configure, which may or may not want to match what's shown
|
||||
here).
|
||||
|
||||
Admin pages (product forms, proofs, order management) always show plain £ — they're
|
||||
managing the actual stored value, not a customer-facing display, so they're not tied
|
||||
to the selector.
|
||||
|
||||
## Cart storage
|
||||
|
||||
The cart is persisted to **IndexedDB**, not `localStorage`. This matters because
|
||||
each personalized item can carry up to 4 full-size images (print files + placement
|
||||
references) — that adds up fast, and `localStorage` has a hard ~5-10MB cap per site
|
||||
that a cart with even 2-3 custom items can blow past, causing a hard crash
|
||||
(`QuotaExceededError`) right when someone tries to check out. IndexedDB doesn't have
|
||||
that low ceiling, so this is a real fix, not a workaround.
|
||||
|
||||
One thing worth knowing as the cart/checkout evolves further: the checkout request
|
||||
itself sends the full cart (images included) from the browser to the server in one
|
||||
go. That's fine at normal sizes, but if you ever see checkout failing specifically
|
||||
with very large/many-image carts, that request payload size is the next place to
|
||||
look — some hosts cap request body size (this isn't an issue on a normal self-hosted
|
||||
Node server, but would be on some serverless platforms).
|
||||
|
||||
## Archiving orders
|
||||
|
||||
Paid or failed orders older than 30 days archive automatically — this happens as a
|
||||
sweep each time you open Admin → Orders, not a background job, but it's effectively
|
||||
invisible either way. You can also archive any order manually right away with the
|
||||
button on its row or its detail page, no need to wait. Archived orders move out of
|
||||
the main list; view them at Admin → Orders → "View archived," with an Unarchive
|
||||
button to bring one back. Orders still `PENDING` are never auto-archived, only
|
||||
`PAID` or `FAILED` ones.
|
||||
|
||||
## Checkout — setting it up
|
||||
|
||||
Checkout is now real: the cart page creates an Order in the database, sends the
|
||||
customer to Stripe's hosted payment page, and a webhook marks the order paid once
|
||||
Stripe confirms it. Here's what you need to actually make it work:
|
||||
|
||||
### 1. Get Stripe test keys
|
||||
|
||||
Sign up / log in at https://dashboard.stripe.com, make sure you're in **test mode**
|
||||
(toggle top-right), then go to Developers → API keys. Copy the **Secret key**
|
||||
(`sk_test_...`) into `.env`:
|
||||
|
||||
```
|
||||
STRIPE_SECRET_KEY="sk_test_..."
|
||||
```
|
||||
|
||||
### 2. Set up the webhook for local testing
|
||||
|
||||
Stripe needs to reach your webhook endpoint, but `localhost` isn't reachable from
|
||||
the internet. For local development, use the Stripe CLI:
|
||||
|
||||
1. Install it: https://docs.stripe.com/stripe-cli (or `brew install stripe/stripe-cli/stripe` on Mac)
|
||||
2. Run `stripe login` once
|
||||
3. While your dev server is running, in another terminal: `stripe listen --forward-to localhost:3000/api/webhook`
|
||||
4. It'll print a webhook signing secret starting `whsec_...` — copy that into `.env`:
|
||||
```
|
||||
STRIPE_WEBHOOK_SECRET="whsec_..."
|
||||
```
|
||||
5. Keep that `stripe listen` command running in the background whenever you're testing checkout locally — it forwards Stripe's events to your local server.
|
||||
|
||||
(Once deployed to a real domain, you'd instead create a proper webhook endpoint in
|
||||
the Stripe Dashboard pointing at `https://yourdomain.com/api/webhook` and use that
|
||||
endpoint's signing secret instead — the CLI approach is just for local dev.)
|
||||
|
||||
### 3. Test a payment
|
||||
|
||||
Add something to your bag, go to `/cart`, click Checkout. On Stripe's page, use a
|
||||
test card: `4242 4242 4242 4242`, any future expiry date, any 3-digit CVC, any
|
||||
postcode. It'll redirect to `/checkout/success`, and — assuming `stripe listen` is
|
||||
running — the order should show as **PAID** within a second or two. Check
|
||||
Admin → Orders to see it, download the print-ready PNGs, etc.
|
||||
|
||||
### 4. Going live later
|
||||
|
||||
When you're ready for real payments: switch to your live Stripe keys (toggle out of
|
||||
test mode in the dashboard), set up a real webhook endpoint in the Stripe Dashboard
|
||||
for your live domain, and update `.env` with the live secret key and that webhook's
|
||||
signing secret. Also worth adding: order confirmation emails to the customer (not
|
||||
built yet — currently they only see the on-screen confirmation and whatever Stripe
|
||||
itself emails them), and probably restricting `shipping_address_collection` in
|
||||
`src/app/api/checkout/route.ts` to just the countries you actually ship to.
|
||||
|
||||
## Print-ready design files
|
||||
|
||||
The exported design image (used for the cart preview, and downloadable from the
|
||||
cart page and from Admin → Orders) is just the customer's artwork — transparent
|
||||
background, no shirt, no print-area guide marks baked in. Every order's items show
|
||||
"Download front PNG" / "Download back PNG" links on its Admin → Orders detail page,
|
||||
so once a real order comes in, that's where to get the print files from.
|
||||
|
||||
**Two images per item, two different jobs:** alongside that clean print file, there's
|
||||
also a "placement reference" image — the design shown composited on the actual
|
||||
product photo, so you can see exactly where it sits before you print it. That's the
|
||||
larger thumbnail shown on the order detail page (and on the cart/success pages);
|
||||
the download links next to it are the clean, print-only files. For products using
|
||||
the illustration (no real photo uploaded), there's no placement reference to show —
|
||||
only real photos can be composited this way.
|
||||
|
||||
## Adding products
|
||||
|
||||
Go to Admin → Add product (or `/admin/products/new`). Fill in the name, category
|
||||
(pulled from your manageable category list — see below), price, and colors (click a
|
||||
swatch to add it, no hex-typing needed). Pick a template shape (t-shirt, hoodie, mug,
|
||||
tumbler, or phone case) to use the built-in illustration, or upload your own front —
|
||||
and optionally back — photo instead. It goes straight into the personalized catalog
|
||||
at `/made-to-order`.
|
||||
|
||||
Manage what's there at `/admin/products` — view, **edit** (change anything, including
|
||||
swapping the photo or colors later), or delete. Deleting is blocked if a product
|
||||
already has orders or design proofs tied to it, to avoid orphaning that history.
|
||||
|
||||
### Recoloring a photo (front/back + live color tint)
|
||||
|
||||
If you upload a photo, drag the pink box that appears over it to mark exactly where
|
||||
the design tool should let customers place their text/artwork. You can also add a
|
||||
**back photo** — customers get a Front/Back toggle (the design tool only applies to
|
||||
the front).
|
||||
|
||||
Check **"Let the color swatches recolor this photo"** to have the color picker
|
||||
actually retint the photo live, instead of just being a label. This only looks good
|
||||
for photos that are black, white, or gray — the color is applied as a multiply-blend
|
||||
tint, so a photo that's already colored (like a red mug) will just look muddy if you
|
||||
turn this on. For black-photo products specifically: expect punchy mid-tone colors
|
||||
(oranges, blues, greens) to look great, but very light colors (white, pastels) will
|
||||
come out as a muted gray rather than true white — that's a real limit of tinting a
|
||||
photo that has no bright highlight data to begin with, not a bug. If you need a true
|
||||
white variant to look right, upload a separate white-photo product instead of relying
|
||||
on the tint.
|
||||
|
||||
## Scoping categories to a catalog
|
||||
|
||||
Categories can now be scoped too, same idea as products: on Admin → Add category,
|
||||
two checkboxes control where a category shows up as a filter — Personalised
|
||||
(`/made-to-order`), Products (`/products`), or both (the default). Toggle these on
|
||||
existing categories from Admin → All categories. This only affects which catalog's
|
||||
filter sidebar shows the category — it doesn't restrict which products can use it.
|
||||
|
||||
## Managing categories
|
||||
|
||||
Categories are managed from Admin → Add category / All categories, not hardcoded. A
|
||||
new category shows up immediately in: the product form's category dropdown, the
|
||||
shop's filter sidebar, and the "Personalised" menu in the header. Deleting a category
|
||||
is blocked if any product is still using it.
|
||||
|
||||
## Sizes
|
||||
|
||||
On the product form, add sizes via the preset buttons (XS–XXL) or type a custom one
|
||||
(for anything non-apparel-shaped, like "One Size" or a numeric size) and hit Enter.
|
||||
Leave it empty for products that don't need sizing — mugs, phone cases, etc. When a
|
||||
product has sizes, a size picker automatically appears on its design page, and the
|
||||
chosen size is saved with the cart item.
|
||||
|
||||
This only applies to the personalized catalog (`/made-to-order`) for now — the
|
||||
ready-made catalog (`/products`) is still just a placeholder page, so there's nowhere
|
||||
for a size picker to live yet.
|
||||
|
||||
## Dark mode
|
||||
|
||||
There's a toggle in the header (sun/moon icon) that switches between light and dark.
|
||||
It respects the visitor's system preference on first visit, then remembers whatever
|
||||
they pick in `localStorage` after that. Most of the site flips automatically since
|
||||
the color system is built on a handful of tokens (background, text, borders, card
|
||||
surfaces) that swap together — new pages you add will pick this up for free as long
|
||||
as they use the existing `bg-paper` / `text-ink` / `border-line` / `bg-surface`
|
||||
classes rather than hardcoded colors like `bg-white`.
|
||||
|
||||
## Chat with customers
|
||||
|
||||
Each design proof has its own chat thread. The customer sees it right on their
|
||||
approval page (`/proofs/[id]`) and can ask for changes without emailing back and
|
||||
forth. You see and reply to every conversation at `/admin/inbox`.
|
||||
|
||||
It's polling-based (checks for new messages every few seconds) rather than true
|
||||
real-time — simple and reliable to self-host, just a couple seconds of lag instead
|
||||
of instant delivery. The initial email still goes out when you upload a design, so
|
||||
the customer gets pinged even if they're not looking at the page; the chat is for
|
||||
the conversation that follows.
|
||||
|
||||
## Custom design approvals (you upload, customer approves)
|
||||
|
||||
For one-off custom jobs — you design a T-shirt yourself and want a specific customer
|
||||
to review and buy it — there's a separate flow from the self-serve design tool:
|
||||
|
||||
1. Go to `http://localhost:3000/admin/proofs/new` (password-protected, see below)
|
||||
2. Pick the product template, enter the customer's email, upload your finished
|
||||
design image, set color/quantity/price
|
||||
3. You'll get a shareable link like `/proofs/abc123`
|
||||
4. Send that link to the customer any way you like (email, text)
|
||||
5. They open it, see the design, and click "Approve & add to bag" — it goes straight
|
||||
into their cart, ready for checkout
|
||||
|
||||
See everything you've sent at `/admin/proofs`.
|
||||
|
||||
**Change the admin password before deploying.** The `/admin` pages are protected by a
|
||||
login page at `/admin/login`, checked against `ADMIN_USER` / `ADMIN_PASSWORD` in `.env`.
|
||||
The defaults (`admin` / `changeme`) are fine for local testing but must be changed
|
||||
before this site is reachable from the internet. Once logged in, "Log out" is in the
|
||||
Admin dropdown in the header. This uses its own session cookie, signed with
|
||||
`ADMIN_SESSION_SECRET` — **change that to a random value before deploying too** (a
|
||||
fresh one has already been generated into your local `.env`).
|
||||
|
||||
## Customer photo requests (they upload, you turn it into a design)
|
||||
|
||||
The other direction from the flow above — a customer can send you their own photo
|
||||
for you to work with, instead of you already having a finished design ready:
|
||||
|
||||
1. They visit `/custom-request`, pick a product, upload their photo, and leave their
|
||||
contact info (name, email, optional phone) and a note
|
||||
2. You get an email alert (see "Sending emails" — same SMTP setup) and can browse all
|
||||
submissions at `/admin/custom-requests`
|
||||
3. Open a submission, and once you've worked on it, click "Start a design for this" —
|
||||
it takes you straight to `/admin/proofs/new` with the product/customer details
|
||||
pre-filled, so you just upload the finished result and send it back through the
|
||||
normal proof-approval flow above
|
||||
4. Each submission also has a one-click WhatsApp/Messenger button (opens pre-filled,
|
||||
for you to send/forward manually) and, if they left a phone number, a direct
|
||||
"Message on WhatsApp" link addressed to them
|
||||
|
||||
Set `ADMIN_NOTIFY_EMAIL` in `.env` to choose where new-submission alerts go — leave it
|
||||
blank and it defaults to your `SMTP_USER` address.
|
||||
|
||||
## Customer accounts
|
||||
|
||||
Separate from the admin login above, customers can create their own accounts to see
|
||||
their order history:
|
||||
|
||||
- `/account/register` and `/account/login` — sign up / sign in
|
||||
- `/account` — order history (only orders placed while logged in, plus any past guest
|
||||
orders that used the same email — those get linked in automatically the first time
|
||||
someone registers or logs in)
|
||||
- `/account/forgot-password` — emails a reset link (uses the same SMTP setup as
|
||||
design-approval emails, see "Sending emails" above; if SMTP isn't configured you'll
|
||||
just see a generic confirmation with nothing actually sent)
|
||||
|
||||
Checkout still works without an account — customer accounts are optional, not
|
||||
required to buy.
|
||||
|
||||
This uses its own login cookie (`SESSION_SECRET` in `.env`), completely separate from
|
||||
the admin `ADMIN_USER`/`ADMIN_PASSWORD` — logging into one never grants access to the
|
||||
other. **Change `SESSION_SECRET` to a random value before deploying**, same as the
|
||||
admin password (a fresh one has already been generated into your local `.env`, so
|
||||
this only matters when you deploy somewhere new).
|
||||
|
||||
## Security: rate limiting & CAPTCHA
|
||||
|
||||
- **Login rate limiting** works with no setup — customer login (`/account/login`),
|
||||
admin login (`/admin/login`), and the checkout API are all limited per IP address
|
||||
(5 attempts / 15 min for logins, 20 requests / 10 min for checkout), backed by the
|
||||
database so it works the same on a single server or serverless. Nothing to configure.
|
||||
- **CAPTCHA** (Cloudflare Turnstile) protects the custom-request, account registration,
|
||||
newsletter signup, and contact forms — but only once you set `NEXT_PUBLIC_TURNSTILE_SITE_KEY`
|
||||
and `TURNSTILE_SECRET_KEY` in `.env`. Get a free site key + secret at
|
||||
[dash.cloudflare.com](https://dash.cloudflare.com) under Turnstile. Until those are
|
||||
set, the forms work normally with no CAPTCHA shown — it's opt-in, not required to
|
||||
run the site.
|
||||
|
||||
## What's built so far
|
||||
|
||||
- Homepage (`/`) with a split path between Personalised and Products
|
||||
- Personalized product catalog with search, category, color, and price filters (`/made-to-order`)
|
||||
- Product detail page with the design tool, reviews, and related products (`/made-to-order/[slug]`)
|
||||
- Full product management: add, edit, delete, with admin-managed categories and sizes (`/admin/products`, `/admin/categories`)
|
||||
- Custom design approval flow (`/admin/proofs/new`, `/admin/proofs`, `/proofs/[id]`)
|
||||
- Per-design chat between you and the customer (`/admin/inbox`)
|
||||
- Dark mode
|
||||
- Cart page (`/cart`) — view, adjust quantity, remove items, see total
|
||||
- Real checkout via Stripe, order persistence, and admin order management (`/admin/orders`)
|
||||
|
||||
Still to come: order confirmation emails, and a "buy it now" flow that skips
|
||||
straight to checkout for a single item (right now everything goes through the bag).
|
||||
|
||||
## Updating an existing install
|
||||
|
||||
Already had this running before? After pulling in new files:
|
||||
|
||||
```bash
|
||||
npx prisma generate
|
||||
npm run db:push
|
||||
npm run db:seed
|
||||
```
|
||||
|
||||
`db:push` is safe to re-run — it only adds what's changed (new tables/columns), it
|
||||
won't wipe your existing data. `db:seed` is also safe to re-run: it only touches the
|
||||
6 starter products that ship with this repo (matched by their slug — classic-tee,
|
||||
heavyweight-hoodie, etc.), syncing them to whatever's currently in `prisma/seed.ts`.
|
||||
Any products or categories you've added yourself through the admin panel are never
|
||||
touched by it. Also check `.env.example` for any new variables and add them to your
|
||||
own `.env`.
|
||||
|
||||
## 1. Install prerequisites
|
||||
|
||||
You need **Node.js 18 or later**. Check with:
|
||||
|
||||
```bash
|
||||
node -v
|
||||
```
|
||||
|
||||
If you don't have it, install it from https://nodejs.org (the LTS version is fine).
|
||||
|
||||
## 2. Install dependencies
|
||||
|
||||
From the project folder (where this README lives):
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
This will also automatically run `prisma generate` (via the `postinstall` script).
|
||||
|
||||
## 3. Set up your environment file
|
||||
|
||||
Copy the example file:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Open `.env` and fill in:
|
||||
- `DATABASE_URL` — leave as `file:./dev.db` for now (SQLite, zero config)
|
||||
- `STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET` — get these later from
|
||||
https://dashboard.stripe.com/apikeys once we build checkout. Not needed yet to run
|
||||
the homepage/catalog.
|
||||
- `NEXT_PUBLIC_SITE_URL` — `http://localhost:3000` for local dev
|
||||
|
||||
## 4. Create the database and load starter products
|
||||
|
||||
```bash
|
||||
npm run db:push
|
||||
npm run db:seed
|
||||
```
|
||||
|
||||
`db:push` creates the SQLite database file from `prisma/schema.prisma`.
|
||||
`db:seed` loads the 6 starter products (tee, hoodie, mug, tumbler, 2 phone cases).
|
||||
|
||||
## 5. Run it
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open http://localhost:3000 in your browser. You should see the homepage; click
|
||||
"Shop all" to see the catalog page with filters.
|
||||
|
||||
Any time you change code, the page auto-reloads. If you change `prisma/schema.prisma`,
|
||||
re-run `npm run db:push`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Cannot find module '@prisma/client'"** — run `npx prisma generate` manually.
|
||||
- **Port 3000 already in use** — stop whatever else is running on that port, or run
|
||||
`npm run dev -- -p 3001` to use a different port.
|
||||
- **Blank product grid** — make sure you ran `npm run db:seed`.
|
||||
|
||||
## Deploying to your own server later
|
||||
|
||||
This app is a standard Next.js app, so once it's finished it can run anywhere that
|
||||
supports Node.js:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run start # serves on port 3000 by default
|
||||
```
|
||||
|
||||
Put it behind nginx (or similar) as a reverse proxy, and use a process manager like
|
||||
`pm2` to keep it running. We'll cover this in detail once the whole app is built and
|
||||
ready to deploy.
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@@ -0,0 +1,13 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
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' },
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+2686
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "personalize-studio",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "prisma generate && next build",
|
||||
"start": "next start -p 3000",
|
||||
"postinstall": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "5.16.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"idb-keyval": "6.2.1",
|
||||
"jose": "^6.2.3",
|
||||
"konva": "9.3.14",
|
||||
"next": "14.2.5",
|
||||
"nodemailer": "6.9.14",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-konva": "18.2.10",
|
||||
"stripe": "16.2.0",
|
||||
"uuid": "9.0.1",
|
||||
"zustand": "4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "20.14.9",
|
||||
"@types/nodemailer": "6.4.15",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"@types/uuid": "9.0.8",
|
||||
"autoprefixer": "10.4.19",
|
||||
"postcss": "8.4.39",
|
||||
"prisma": "5.16.1",
|
||||
"tailwindcss": "3.4.4",
|
||||
"tsx": "4.16.2",
|
||||
"typescript": "5.5.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,367 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
// Swap to "postgresql" for production scale — just change provider + DATABASE_URL.
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// Categories are their own table so you can add/rename/remove them from the admin
|
||||
// panel. Product.category stays a plain string (matching Category.slug) rather
|
||||
// than a foreign key, so nothing breaks if a category is renamed after the fact.
|
||||
model Category {
|
||||
id String @id @default(cuid())
|
||||
name String // display label, e.g. "Phone Cases"
|
||||
slug String @unique // stored key, e.g. "PHONE_CASE" — matches Product.category
|
||||
showOnPersonalised Boolean @default(true) // appears as a filter on /made-to-order
|
||||
showOnProducts Boolean @default(true) // appears as a filter on /products
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model Product {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
name String
|
||||
category String
|
||||
description String
|
||||
basePrice Int // cents — used for ready-made items on /products
|
||||
personalisedPrice Int? // cents — used for made-to-order items on /made-to-order; falls back to basePrice if not set
|
||||
colors String // JSON array of hex strings, e.g. ["#17181C","#FBF9F5"]
|
||||
colorPhotos String? // JSON object mapping hex -> photo data URL, e.g. {"#17181C":"data:..."}.
|
||||
colorPhotosBack String? // same idea, for the back view of each color
|
||||
// Lets a product show the real photo for whichever color is selected, instead of
|
||||
// tinting one photo. Falls back to imageUrl/photoRecolorable/mockup for any color
|
||||
// that doesn't have its own photo here.
|
||||
sizes String? // JSON array of size labels, e.g. ["S","M","L","XL"] — leave null for products with no sizing (mugs, phone cases)
|
||||
mockup String // which SVG mockup component to render, e.g. "tshirt" | "hoodie" | "mug" | "phonecase"
|
||||
printArea String // JSON: {"xPct":..,"yPct":..,"wPct":..,"hPct":..}
|
||||
printAreaBack String? // same shape, for the back print area — only used when imageUrlBack is set
|
||||
imageUrl String? // optional real product photo — shown instead of the illustration when set
|
||||
imageUrlBack String? // optional back-view photo, toggled alongside the front photo
|
||||
photoRecolorable Boolean @default(false) // if true, the photo is treated as a grayscale
|
||||
// "shading map" and multiplied by the selected color swatch live in the browser,
|
||||
// instead of being shown as a fixed image. Only looks right for black/white/gray
|
||||
// source photos — recoloring an already-colored photo produces muddy results.
|
||||
showOnPersonalised Boolean @default(true) // list on /made-to-order
|
||||
showOnProducts Boolean @default(false) // list on /products — same product/photo can show on both
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
salePrice Int? // cents — set alongside a date window below to put this product on sale
|
||||
saleStartsAt DateTime? // leave null to start the sale immediately
|
||||
saleEndsAt DateTime? // leave null for an open-ended sale
|
||||
|
||||
orderItems OrderItem[]
|
||||
proofs DesignProof[]
|
||||
customRequests CustomRequest[]
|
||||
promotions Promotion[] // only relevant to promotions scoped to specific items, not "all items"
|
||||
collections Collection[] // occasion/recipient tags, e.g. "Christmas", "For Teachers"
|
||||
manualSaleItems ManualSaleItem[]
|
||||
stockLevels StockLevel[]
|
||||
purchaseItems PurchaseItem[]
|
||||
}
|
||||
|
||||
// Quantity on hand for one product/colour/size combination. Purchases increase
|
||||
// it, paid website orders and manual sales decrease it. Info-only: counts can
|
||||
// go negative (shown highlighted in admin as "your records are out of sync")
|
||||
// and never block anything on the shop side.
|
||||
model StockLevel {
|
||||
id String @id @default(cuid())
|
||||
productId String
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
color String
|
||||
size String @default("") // '' = product has no sizes — a real empty value, not NULL, so the unique index below actually dedupes (SQLite treats NULLs as distinct)
|
||||
quantity Int @default(0)
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([productId, color, size])
|
||||
}
|
||||
|
||||
// Stock coming in: one supplier invoice, entered by hand with the invoice
|
||||
// file (PDF or photo) attached for record-keeping. Product lines add to
|
||||
// StockLevel; material lines (ink, vinyl, packaging) are just logged — they
|
||||
// have no running count, and will feed the expenses list in a later stage.
|
||||
// Every Purchase auto-creates an Expense on creation.
|
||||
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
|
||||
invoiceFileName String?
|
||||
total Int // pence — sum of line totals, recomputed server-side
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
items PurchaseItem[]
|
||||
expense Expense? // auto-created on Purchase creation; cascade delete if Purchase is deleted
|
||||
}
|
||||
|
||||
model PurchaseItem {
|
||||
id String @id @default(cuid())
|
||||
purchaseId String
|
||||
purchase Purchase @relation(fields: [purchaseId], references: [id], onDelete: Cascade)
|
||||
productId String? // null = loose material line (no stock tracking)
|
||||
product Product? @relation(fields: [productId], references: [id])
|
||||
description String // product name snapshot, or the material description
|
||||
color String?
|
||||
size String?
|
||||
quantity Int
|
||||
unitCost Int // pence
|
||||
}
|
||||
|
||||
// Expenses: everything that goes out. Auto-created from purchases (category =
|
||||
// "Stock purchases"), plus manual entries for postage, utilities, equipment, etc.
|
||||
// Receipt can be the invoice file or a standalone attachment. Pence storage like
|
||||
// everything else.
|
||||
model Expense {
|
||||
id String @id @default(cuid())
|
||||
date DateTime @default(now())
|
||||
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
|
||||
receiptFileName String?
|
||||
notes String?
|
||||
purchaseId String? @unique // null = standalone expense; set = auto-created from a Purchase (one-to-one)
|
||||
purchase Purchase? @relation(fields: [purchaseId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
// A design you (the shop owner) upload for one specific customer to review and buy —
|
||||
// separate from the self-serve design tool. You create one of these per custom job,
|
||||
// send the customer the link, and they approve it straight into their cart.
|
||||
model DesignProof {
|
||||
id String @id @default(cuid())
|
||||
productId String
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
customerName String?
|
||||
customerEmail String
|
||||
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
|
||||
note String? // optional message shown alongside the design
|
||||
status String @default("PENDING") // PENDING | APPROVED
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
messages Message[]
|
||||
}
|
||||
|
||||
// One chat thread per design proof — simple polling-based chat, no websockets needed.
|
||||
model Message {
|
||||
id String @id @default(cuid())
|
||||
proofId String
|
||||
proof DesignProof @relation(fields: [proofId], references: [id], onDelete: Cascade)
|
||||
sender String // "ADMIN" | "CUSTOMER"
|
||||
body String
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model Order {
|
||||
id String @id @default(cuid())
|
||||
stripeSessionId String? @unique
|
||||
email String?
|
||||
shippingAddress String? // JSON — collected by Stripe Checkout, saved once payment completes
|
||||
status String @default("PENDING") // PENDING | PAID | FAILED
|
||||
archived Boolean @default(false)
|
||||
total Int // cents
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
customerId String? // set when the buyer was logged in at checkout — null for guest orders
|
||||
customer Customer? @relation(fields: [customerId], references: [id])
|
||||
|
||||
items OrderItem[]
|
||||
}
|
||||
|
||||
// A customer's own account — separate from the ADMIN_USER/ADMIN_PASSWORD admin login.
|
||||
// Optional: checkout still works as a guest with no Customer record at all.
|
||||
model Customer {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
name String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
orders Order[]
|
||||
passwordResetToken PasswordResetToken?
|
||||
customRequests CustomRequest[]
|
||||
manualSales ManualSale[]
|
||||
}
|
||||
|
||||
// A sale that didn't go through the website checkout — cash at a fair, a bank
|
||||
// transfer arranged over Facebook, etc. Recorded by the admin from
|
||||
// /admin/accounts so every sale (web + offline) lives in one ledger.
|
||||
model ManualSale {
|
||||
id String @id @default(cuid())
|
||||
soldAt DateTime @default(now()) // when the sale actually happened (editable, not just entry time)
|
||||
buyerName String? // free text — a cash buyer doesn't need an account
|
||||
buyerEmail String?
|
||||
customerId String? // auto-linked when buyerEmail matches a Customer account
|
||||
customer Customer? @relation(fields: [customerId], references: [id])
|
||||
paymentMethod String // CASH | BANK_TRANSFER
|
||||
total Int // pence — sum of line totals, computed server-side on save
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
items ManualSaleItem[]
|
||||
}
|
||||
|
||||
model ManualSaleItem {
|
||||
id String @id @default(cuid())
|
||||
saleId String
|
||||
sale ManualSale @relation(fields: [saleId], references: [id], onDelete: Cascade)
|
||||
productId String? // null for free-text one-offs (e.g. "custom commission")
|
||||
product Product? @relation(fields: [productId], references: [id])
|
||||
description String // product name snapshot, or the free text itself
|
||||
color String?
|
||||
size String?
|
||||
quantity Int
|
||||
unitPrice Int // pence
|
||||
}
|
||||
|
||||
// One active reset token per customer at a time — requesting a new one replaces it.
|
||||
model PasswordResetToken {
|
||||
id String @id @default(cuid())
|
||||
customerId String @unique
|
||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||
token String @unique
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
// A customer's own photo, submitted for the shop to design something with —
|
||||
// separate from the self-serve design tool and from DesignProof (which is the
|
||||
// finished result you send back once you've worked on it). No FK link between
|
||||
// the two: turning a request into a proof is a one-time action, not a tracked relation.
|
||||
model CustomRequest {
|
||||
id String @id @default(cuid())
|
||||
customerId String?
|
||||
customer Customer? @relation(fields: [customerId], references: [id])
|
||||
productId String
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
customerName String
|
||||
customerEmail String
|
||||
customerPhone String?
|
||||
imageDataUrl String // the photo they want us to work with
|
||||
note String?
|
||||
status String @default("NEW") // NEW | DONE
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
// A promotion: shown as a banner on every page while its date window is active,
|
||||
// AND actually discounts prices — either every product ("ALL") or a hand-picked
|
||||
// set ("SPECIFIC", via the products relation below). Kept as a list rather than
|
||||
// a single settings row so past promotions aren't destroyed when you create a
|
||||
// new one. Price discounts stack with a product's own manual sale price by
|
||||
// taking whichever is lower — see getEffectivePrice in src/lib/pricing.ts.
|
||||
model Promotion {
|
||||
id String @id @default(cuid())
|
||||
message String
|
||||
discountPercent Int // 1-100
|
||||
scope String @default("ALL") // ALL | SPECIFIC
|
||||
startsAt DateTime? // leave null to start immediately
|
||||
endsAt DateTime? // leave null for an open-ended promotion
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
products Product[] // only consulted when scope = SPECIFIC
|
||||
}
|
||||
|
||||
// Occasion ("Christmas", "Birthdays") and recipient ("Teachers", "School leavers") tags
|
||||
// for the two nav dropdowns of the same name. One model with a group discriminator
|
||||
// (rather than two models) so there's a single set of admin pages — split into
|
||||
// sections by group instead. A product can carry any number of both, hence the
|
||||
// many-to-many (unlike Product.category, which is a single string).
|
||||
model Collection {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
slug String @unique
|
||||
group String // "OCCASION" | "RECIPIENT"
|
||||
createdAt DateTime @default(now())
|
||||
products Product[]
|
||||
}
|
||||
|
||||
// Unified mailing list — fed by three separate sources (customer registration,
|
||||
// the footer newsletter signup, and guest checkout emails) so a single admin
|
||||
// broadcast can reach all of them without querying three different shapes.
|
||||
// `subscribed` is the admin's opt-out switch and is deliberately never
|
||||
// overwritten by a later upsert from any source, so it always wins.
|
||||
model MailingListEntry {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String?
|
||||
source String // "CUSTOMER" | "NEWSLETTER" | "GUEST" — where this address first came from
|
||||
subscribed Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
// Backs a small DB-based rate limiter (src/lib/rateLimit.ts) — chosen over an
|
||||
// in-memory counter because that would silently reset on every serverless
|
||||
// cold start and not share state across instances. Rows older than 24h are
|
||||
// opportunistically pruned by the limiter itself, no separate cron needed.
|
||||
model RateLimitAttempt {
|
||||
id String @id @default(cuid())
|
||||
key String // e.g. "login:customer:<ip>", "checkout:<ip>"
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([key, createdAt])
|
||||
}
|
||||
|
||||
// Site-wide settings — a single row (fixed id "singleton"). Currently just the
|
||||
// maintenance-mode toggle: when on, customers see a "we're updating" page while
|
||||
// logged-in admins still see the real site. Read on every page render (cheap
|
||||
// single-row lookup), written only from the admin maintenance page.
|
||||
model SiteSetting {
|
||||
id String @id @default("singleton")
|
||||
maintenanceMode Boolean @default(false)
|
||||
maintenanceMessage String?
|
||||
}
|
||||
|
||||
// Footer email signup — just captures addresses for you to use once you pick an
|
||||
// email tool (Mailchimp, Klaviyo, etc.). Not wired up to send anything itself.
|
||||
model NewsletterSubscriber {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
// Photo gallery of completed work, shown on the public /gallery page. Photos are
|
||||
// uploaded manually in the admin (base64 data URLs, same as products/proofs).
|
||||
// Categories here are their own thing, separate from product Category — a photo's
|
||||
// category is optional. (Future: an official Facebook Graph API import could add a
|
||||
// nullable source/attribution column here — not built yet.)
|
||||
model GalleryCategory {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
slug String @unique
|
||||
createdAt DateTime @default(now())
|
||||
photos GalleryPhoto[]
|
||||
}
|
||||
|
||||
model GalleryPhoto {
|
||||
id String @id @default(cuid())
|
||||
imageDataUrl String // base64 data URL
|
||||
caption String?
|
||||
categoryId String? // nullable — a photo can be uncategorized
|
||||
category GalleryCategory? @relation(fields: [categoryId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model OrderItem {
|
||||
id String @id @default(cuid())
|
||||
orderId String
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||
productId String
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
productName String // snapshot at time of order, in case the product changes later
|
||||
quantity Int
|
||||
color String
|
||||
size String?
|
||||
unitPrice Int // cents
|
||||
designJson String // serialized {front:[...], back:[...]} design elements
|
||||
designPreviewUrl String // base64 PNG data URL — front design, transparent, print-ready
|
||||
designPreviewUrlBack String? // same, for the back design if there is one
|
||||
placementPreviewUrl String? // front design shown composited on the actual product photo, for reference — where does this go when printing
|
||||
placementPreviewUrlBack String? // same, for the back
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const categories = [
|
||||
{ name: 'Apparel', slug: 'APPAREL' },
|
||||
{ name: 'Drinkware', slug: 'DRINKWARE' },
|
||||
{ name: 'Phone case', slug: 'PHONE_CASE' },
|
||||
];
|
||||
|
||||
const products: Array<{
|
||||
slug: string;
|
||||
name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
basePrice: number;
|
||||
colors: string[];
|
||||
sizes: string[];
|
||||
mockup: string;
|
||||
printArea: { xPct: number; yPct: number; wPct: number; hPct: number };
|
||||
imageUrl: string | null;
|
||||
imageUrlBack: string | null;
|
||||
photoRecolorable: boolean;
|
||||
}> = [
|
||||
{
|
||||
slug: 'classic-tee',
|
||||
name: 'Classic Tee',
|
||||
category: 'APPAREL',
|
||||
description:
|
||||
'Heavyweight 220gsm combed cotton. Your artwork, printed dead-center on the chest.',
|
||||
basePrice: 2900,
|
||||
colors: ['#FFFFFF', '#17181C', '#2B4C3F', '#8A8578', '#1EA7E0', '#E5227E', '#F5871F', '#5B2C86', '#7A1F1F', '#D8D2C2'],
|
||||
sizes: ['S', 'M', 'L', 'XL', 'XXL'],
|
||||
mockup: 'tshirt',
|
||||
printArea: { xPct: 0.32, yPct: 0.27, wPct: 0.36, hPct: 0.34 },
|
||||
imageUrl: '/seed/tee-front.png',
|
||||
imageUrlBack: '/seed/tee-back.png',
|
||||
photoRecolorable: true,
|
||||
},
|
||||
{
|
||||
slug: 'heavyweight-hoodie',
|
||||
name: 'Heavyweight Hoodie',
|
||||
category: 'APPAREL',
|
||||
description: 'Brushed-fleece interior, boxy fit. Built for a bold front print.',
|
||||
basePrice: 5400,
|
||||
colors: ['#17181C', '#8A8578', '#2B4C3F', '#7A1F1F', '#1EA7E0', '#D8D2C2', '#5B2C86'],
|
||||
sizes: ['S', 'M', 'L', 'XL', 'XXL'],
|
||||
mockup: 'hoodie',
|
||||
printArea: { xPct: 0.33, yPct: 0.34, wPct: 0.34, hPct: 0.28 },
|
||||
imageUrl: null,
|
||||
imageUrlBack: null,
|
||||
photoRecolorable: false,
|
||||
},
|
||||
{
|
||||
slug: 'ceramic-mug',
|
||||
name: 'Ceramic Mug',
|
||||
category: 'DRINKWARE',
|
||||
description: '11oz glossy ceramic. Dishwasher-safe, wraparound print.',
|
||||
basePrice: 1800,
|
||||
colors: ['#FFFFFF', '#17181C', '#1EA7E0', '#E5227E', '#F5871F', '#5B2C86'],
|
||||
sizes: [],
|
||||
mockup: 'mug',
|
||||
printArea: { xPct: 0.22, yPct: 0.32, wPct: 0.56, hPct: 0.28 },
|
||||
imageUrl: null,
|
||||
imageUrlBack: null,
|
||||
photoRecolorable: false,
|
||||
},
|
||||
{
|
||||
slug: 'travel-tumbler',
|
||||
name: 'Travel Tumbler',
|
||||
category: 'DRINKWARE',
|
||||
description: '16oz double-wall stainless steel, keeps drinks cold for 24 hours.',
|
||||
basePrice: 3200,
|
||||
colors: ['#17181C', '#8A8578', '#C9A227', '#1EA7E0', '#E5227E', '#2B4C3F'],
|
||||
sizes: [],
|
||||
mockup: 'tumbler',
|
||||
printArea: { xPct: 0.26, yPct: 0.28, wPct: 0.48, hPct: 0.36 },
|
||||
imageUrl: null,
|
||||
imageUrlBack: null,
|
||||
photoRecolorable: false,
|
||||
},
|
||||
{
|
||||
slug: 'snap-phone-case',
|
||||
name: 'Snap Phone Case',
|
||||
category: 'PHONE_CASE',
|
||||
description: 'Slim polycarbonate snap case with a matte finish.',
|
||||
basePrice: 2200,
|
||||
colors: ['#FFFFFF', '#17181C', '#2B4C3F', '#E5227E', '#1EA7E0', '#F5871F'],
|
||||
sizes: [],
|
||||
mockup: 'phonecase',
|
||||
printArea: { xPct: 0.28, yPct: 0.16, wPct: 0.44, hPct: 0.5 },
|
||||
imageUrl: null,
|
||||
imageUrlBack: null,
|
||||
photoRecolorable: false,
|
||||
},
|
||||
{
|
||||
slug: 'tough-phone-case',
|
||||
name: 'Tough Phone Case',
|
||||
category: 'PHONE_CASE',
|
||||
description: 'Dual-layer shock-resistant case with raised edges.',
|
||||
basePrice: 2900,
|
||||
colors: ['#17181C', '#8A8578', '#5B2C86', '#7A1F1F'],
|
||||
sizes: [],
|
||||
mockup: 'phonecase',
|
||||
printArea: { xPct: 0.3, yPct: 0.18, wPct: 0.4, hPct: 0.46 },
|
||||
imageUrl: null,
|
||||
imageUrlBack: null,
|
||||
photoRecolorable: false,
|
||||
},
|
||||
{
|
||||
slug: 'shaker-bottle',
|
||||
name: 'Shaker Bottle',
|
||||
category: 'DRINKWARE',
|
||||
description: '20oz insulated shaker bottle with a secure flip-top lid. Your artwork, printed on the body.',
|
||||
basePrice: 1500,
|
||||
colors: ['#FFFFFF'],
|
||||
sizes: [],
|
||||
mockup: 'tumbler',
|
||||
printArea: { xPct: 0.32, yPct: 0.36, wPct: 0.36, hPct: 0.38 },
|
||||
imageUrl: '/seed/shaker-bottle.png',
|
||||
imageUrlBack: null,
|
||||
photoRecolorable: false,
|
||||
},
|
||||
];
|
||||
|
||||
async function main() {
|
||||
for (const c of categories) {
|
||||
await prisma.category.upsert({
|
||||
where: { slug: c.slug },
|
||||
update: {},
|
||||
create: c,
|
||||
});
|
||||
}
|
||||
|
||||
for (const p of products) {
|
||||
const data = {
|
||||
name: p.name,
|
||||
category: p.category,
|
||||
description: p.description,
|
||||
basePrice: p.basePrice,
|
||||
colors: JSON.stringify(p.colors),
|
||||
sizes: p.sizes.length > 0 ? JSON.stringify(p.sizes) : null,
|
||||
mockup: p.mockup,
|
||||
printArea: JSON.stringify(p.printArea),
|
||||
imageUrl: p.imageUrl,
|
||||
imageUrlBack: p.imageUrlBack,
|
||||
photoRecolorable: p.photoRecolorable,
|
||||
};
|
||||
await prisma.product.upsert({
|
||||
where: { slug: p.slug },
|
||||
update: data, // keeps these 6 starter products in sync when the seed file changes
|
||||
create: { slug: p.slug, ...data },
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Seeded ${categories.length} categories and ${products.length} products.`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
@@ -0,0 +1,136 @@
|
||||
'use server';
|
||||
|
||||
import { randomBytes } from 'crypto';
|
||||
import { headers } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { createSession, clearSession, hashPassword, verifyPassword } from '@/lib/auth';
|
||||
import { sendPasswordResetEmail } from '@/lib/mail';
|
||||
import { upsertMailingListEntry } from '@/lib/mailingList';
|
||||
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
|
||||
import { verifyTurnstileToken } from '@/lib/turnstile';
|
||||
|
||||
const RESET_TOKEN_DURATION_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
// Pulls in any past guest-checkout orders placed under this email so they show
|
||||
// up in the customer's order history once they have an account.
|
||||
async function linkGuestOrders(customerId: string, email: string) {
|
||||
await prisma.order.updateMany({
|
||||
where: { email, customerId: null },
|
||||
data: { customerId },
|
||||
});
|
||||
}
|
||||
|
||||
function safeNext(next: FormDataEntryValue | null) {
|
||||
const path = String(next ?? '');
|
||||
return path.startsWith('/') && !path.startsWith('//') ? path : '/account';
|
||||
}
|
||||
|
||||
export async function registerCustomer(formData: FormData) {
|
||||
const name = String(formData.get('name') ?? '').trim();
|
||||
const email = String(formData.get('email') ?? '').trim().toLowerCase();
|
||||
const password = String(formData.get('password') ?? '');
|
||||
const confirmPassword = String(formData.get('confirmPassword') ?? '');
|
||||
const next = safeNext(formData.get('next'));
|
||||
|
||||
if (!name || !email || !password) redirect('/account/register?error=missing');
|
||||
if (password !== confirmPassword) redirect('/account/register?error=mismatch');
|
||||
if (password.length < 8) redirect('/account/register?error=weak');
|
||||
|
||||
const ip = getClientIp(headers());
|
||||
const turnstileToken = String(formData.get('cf-turnstile-response') ?? '');
|
||||
const { success } = await verifyTurnstileToken(turnstileToken, ip);
|
||||
if (!success) redirect('/account/register?error=captcha');
|
||||
|
||||
const existing = await prisma.customer.findUnique({ where: { email } });
|
||||
if (existing) redirect('/account/register?error=taken');
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const customer = await prisma.customer.create({
|
||||
data: { email, passwordHash, name },
|
||||
});
|
||||
|
||||
await upsertMailingListEntry({ email: customer.email, name: customer.name, source: 'CUSTOMER' });
|
||||
await linkGuestOrders(customer.id, customer.email);
|
||||
await createSession(customer.id);
|
||||
redirect(next);
|
||||
}
|
||||
|
||||
export async function loginCustomer(formData: FormData) {
|
||||
const email = String(formData.get('email') ?? '').trim().toLowerCase();
|
||||
const password = String(formData.get('password') ?? '');
|
||||
const next = safeNext(formData.get('next'));
|
||||
|
||||
const ip = getClientIp(headers());
|
||||
const { allowed } = await checkRateLimit(`login:customer:${ip}`, 5, 15 * 60 * 1000);
|
||||
if (!allowed) {
|
||||
redirect(`/account/login?error=rate_limited&next=${encodeURIComponent(next)}`);
|
||||
}
|
||||
|
||||
const customer = email ? await prisma.customer.findUnique({ where: { email } }) : null;
|
||||
const valid = customer ? await verifyPassword(password, customer.passwordHash) : false;
|
||||
|
||||
if (!customer || !valid) {
|
||||
redirect(`/account/login?error=invalid&next=${encodeURIComponent(next)}`);
|
||||
}
|
||||
|
||||
await linkGuestOrders(customer.id, customer.email);
|
||||
await createSession(customer.id);
|
||||
redirect(next);
|
||||
}
|
||||
|
||||
export async function logoutCustomer() {
|
||||
clearSession();
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
export async function requestPasswordReset(formData: FormData) {
|
||||
const email = String(formData.get('email') ?? '').trim().toLowerCase();
|
||||
const customer = email ? await prisma.customer.findUnique({ where: { email } }) : null;
|
||||
|
||||
if (customer) {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + RESET_TOKEN_DURATION_MS);
|
||||
|
||||
await prisma.passwordResetToken.deleteMany({ where: { customerId: customer.id } });
|
||||
await prisma.passwordResetToken.create({
|
||||
data: { customerId: customer.id, token, expiresAt },
|
||||
});
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
|
||||
await sendPasswordResetEmail({
|
||||
to: customer.email,
|
||||
name: customer.name,
|
||||
link: `${siteUrl}/account/reset-password?token=${token}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Same outcome whether or not the email matched an account, so we don't
|
||||
// reveal which emails are registered.
|
||||
redirect('/account/forgot-password?sent=1');
|
||||
}
|
||||
|
||||
export async function resetPassword(formData: FormData) {
|
||||
const token = String(formData.get('token') ?? '');
|
||||
const password = String(formData.get('password') ?? '');
|
||||
const confirmPassword = String(formData.get('confirmPassword') ?? '');
|
||||
|
||||
if (!token) redirect('/account/reset-password?error=invalid');
|
||||
if (password !== confirmPassword) redirect(`/account/reset-password?token=${token}&error=mismatch`);
|
||||
if (password.length < 8) redirect(`/account/reset-password?token=${token}&error=weak`);
|
||||
|
||||
const resetToken = await prisma.passwordResetToken.findUnique({ where: { token } });
|
||||
if (!resetToken || resetToken.expiresAt < new Date()) {
|
||||
redirect('/account/reset-password?error=expired');
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
await prisma.customer.update({
|
||||
where: { id: resetToken.customerId },
|
||||
data: { passwordHash },
|
||||
});
|
||||
await prisma.passwordResetToken.delete({ where: { token } });
|
||||
|
||||
await createSession(resetToken.customerId);
|
||||
redirect('/account');
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Link from 'next/link';
|
||||
import { requestPasswordReset } from '../actions';
|
||||
|
||||
export default function ForgotPasswordPage({ searchParams }: { searchParams: { sent?: string } }) {
|
||||
const sent = searchParams.sent === '1';
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md px-6 py-16">
|
||||
<h1 className="font-display text-3xl">Reset your password</h1>
|
||||
|
||||
{sent ? (
|
||||
<p className="mt-6 border border-line bg-surface px-4 py-3 text-sm">
|
||||
If that email has an account, we've sent a link to reset the password. It expires in 1 hour.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
Enter your email and we'll send you a link to reset your password.
|
||||
</p>
|
||||
<form action={requestPasswordReset} className="mt-8 space-y-6">
|
||||
<div>
|
||||
<label htmlFor="email" className="tag-label mb-2 block">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
className="w-full border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
|
||||
Send reset link
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="mt-6 text-sm">
|
||||
<Link href="/account/login" className="text-clay hover:text-clay-dark">
|
||||
Back to log in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import Link from 'next/link';
|
||||
import { loginCustomer } from '../actions';
|
||||
|
||||
const ERROR_MESSAGES: Record<string, string> = {
|
||||
invalid: 'Incorrect email or password.',
|
||||
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';
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md px-6 py-16">
|
||||
<h1 className="font-display text-3xl">Log in</h1>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
New here?{' '}
|
||||
<Link href={`/account/register?next=${encodeURIComponent(next)}`} className="text-clay hover:text-clay-dark">
|
||||
Create an account
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p className="mt-6 border border-splash-pink/40 bg-splash-pink/10 px-4 py-3 text-sm text-splash-pink">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form action={loginCustomer} className="mt-8 space-y-6">
|
||||
<input type="hidden" name="next" value={next} />
|
||||
<div>
|
||||
<label htmlFor="email" className="tag-label mb-2 block">
|
||||
Email
|
||||
</label>
|
||||
<input id="email" name="email" type="email" required className="w-full border border-line px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="tag-label mb-2 block">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
className="w-full border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
|
||||
Log in
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-sm">
|
||||
<Link href="/account/forgot-password" className="text-clay hover:text-clay-dark">
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { getCurrentCustomer } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
function formatPrice(cents: number) {
|
||||
return `£${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
if (status === 'PAID') return 'Confirmed';
|
||||
if (status === 'FAILED') return 'Failed';
|
||||
return 'Processing';
|
||||
}
|
||||
|
||||
function statusClasses(status: string) {
|
||||
if (status === 'PAID') return 'bg-splash-blue/10 text-splash-blue';
|
||||
if (status === 'FAILED') return 'bg-splash-pink/10 text-splash-pink';
|
||||
return 'bg-splash-orange/10 text-splash-orange';
|
||||
}
|
||||
|
||||
export default async function AccountOrderDetailPage({ params }: { params: { id: string } }) {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect('/account/login');
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: params.id },
|
||||
include: { items: true },
|
||||
});
|
||||
if (!order || order.customerId !== customer.id) notFound();
|
||||
|
||||
const shipping = order.shippingAddress ? JSON.parse(order.shippingAddress) : null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-12">
|
||||
<Link href="/account" className="tag-label text-muted hover:text-clay">
|
||||
← My account
|
||||
</Link>
|
||||
<div className="mt-4 flex items-baseline justify-between gap-3">
|
||||
<h1 className="font-display text-3xl">Order</h1>
|
||||
<span className={`tag-label rounded-full px-3 py-1 ${statusClasses(order.status)}`}>{statusLabel(order.status)}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 border border-line bg-surface p-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="tag-label mb-1">Placed</p>
|
||||
<p className="text-sm">
|
||||
{new Date(order.createdAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}
|
||||
</p>
|
||||
</div>
|
||||
{shipping && (
|
||||
<div className="sm:col-span-2">
|
||||
<p className="tag-label mb-1">Shipping address</p>
|
||||
<p className="text-sm">
|
||||
{shipping.name}
|
||||
<br />
|
||||
{[shipping.address?.line1, shipping.address?.line2].filter(Boolean).join(', ')}
|
||||
<br />
|
||||
{[shipping.address?.city, shipping.address?.postal_code].filter(Boolean).join(', ')}
|
||||
<br />
|
||||
{shipping.address?.country}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 divide-y divide-line border-t border-line">
|
||||
{order.items.map((item) => (
|
||||
<div key={item.id} className="flex flex-wrap items-center justify-between gap-4 py-6">
|
||||
<img
|
||||
src={item.placementPreviewUrl || item.designPreviewUrl}
|
||||
alt={item.productName}
|
||||
className="h-24 w-24 border border-line bg-paper object-contain"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="font-display text-lg">{item.productName}</p>
|
||||
<p className="text-sm text-muted">
|
||||
{item.color}
|
||||
{item.size ? `, ${item.size}` : ''} · qty {item.quantity}
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-mono text-sm">{formatPrice(item.unitPrice * item.quantity)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="tag-label">Total</span>
|
||||
<span className="font-mono text-xl">{formatPrice(order.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCurrentCustomer } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { logoutCustomer } from './actions';
|
||||
|
||||
function formatPrice(cents: number) {
|
||||
return `£${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
if (status === 'PAID') return 'Confirmed';
|
||||
if (status === 'FAILED') return 'Failed';
|
||||
return 'Processing';
|
||||
}
|
||||
|
||||
function statusClasses(status: string) {
|
||||
if (status === 'PAID') return 'bg-splash-blue/10 text-splash-blue';
|
||||
if (status === 'FAILED') return 'bg-splash-pink/10 text-splash-pink';
|
||||
return 'bg-splash-orange/10 text-splash-orange';
|
||||
}
|
||||
|
||||
export default async function AccountPage() {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect('/account/login');
|
||||
|
||||
const orders = await prisma.order.findMany({
|
||||
where: { customerId: customer.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { items: true },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-12">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl">My account</h1>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
{customer.name ? `${customer.name} · ` : ''}
|
||||
{customer.email}
|
||||
</p>
|
||||
</div>
|
||||
<form action={logoutCustomer}>
|
||||
<button
|
||||
type="submit"
|
||||
className="tag-label border border-line px-3 py-1.5 hover:border-clay hover:text-clay"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</form>
|
||||
</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>
|
||||
) : (
|
||||
<div className="mt-4 divide-y divide-line border-t border-line">
|
||||
{orders.map((order) => (
|
||||
<Link
|
||||
key={order.id}
|
||||
href={`/account/orders/${order.id}`}
|
||||
className="flex flex-wrap items-center justify-between gap-2 py-4 hover:bg-surface"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm">
|
||||
{new Date(order.createdAt).toLocaleDateString(undefined, { dateStyle: 'medium' })} ·{' '}
|
||||
{order.items.length} item{order.items.length === 1 ? '' : 's'}
|
||||
</p>
|
||||
<span className={`tag-label mt-1 inline-block rounded-full px-3 py-1 ${statusClasses(order.status)}`}>
|
||||
{statusLabel(order.status)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono text-sm">{formatPrice(order.total)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import Link from 'next/link';
|
||||
import { registerCustomer } from '../actions';
|
||||
import Turnstile from '@/components/Turnstile';
|
||||
|
||||
const ERROR_MESSAGES: Record<string, string> = {
|
||||
missing: 'Name, email, and password are required.',
|
||||
mismatch: "Passwords don't match.",
|
||||
weak: 'Password must be at least 8 characters.',
|
||||
taken: 'An account with that email already exists.',
|
||||
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';
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md px-6 py-16">
|
||||
<h1 className="font-display text-3xl">Create an account</h1>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
Already have one?{' '}
|
||||
<Link href={`/account/login?next=${encodeURIComponent(next)}`} className="text-clay hover:text-clay-dark">
|
||||
Log in
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p className="mt-6 border border-splash-pink/40 bg-splash-pink/10 px-4 py-3 text-sm text-splash-pink">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form action={registerCustomer} className="mt-8 space-y-6">
|
||||
<input type="hidden" name="next" value={next} />
|
||||
<div>
|
||||
<label htmlFor="name" className="tag-label mb-2 block">
|
||||
Name
|
||||
</label>
|
||||
<input id="name" name="name" required className="w-full border border-line px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="tag-label mb-2 block">
|
||||
Email
|
||||
</label>
|
||||
<input id="email" name="email" type="email" required className="w-full border border-line px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="tag-label mb-2 block">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="tag-label mb-2 block">
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Turnstile />
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Create account
|
||||
</button>
|
||||
<p className="text-xs text-muted">
|
||||
By creating an account you agree to our{' '}
|
||||
<Link href="/privacy" className="text-clay hover:text-clay-dark">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import Link from 'next/link';
|
||||
import { resetPassword } from '../actions';
|
||||
|
||||
const ERROR_MESSAGES: Record<string, string> = {
|
||||
invalid: 'This reset link is invalid.',
|
||||
expired: 'This reset link has expired — request a new one.',
|
||||
mismatch: "Passwords don't match.",
|
||||
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;
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="mx-auto max-w-md px-6 py-16">
|
||||
<h1 className="font-display text-3xl">Reset your password</h1>
|
||||
<p className="mt-6 border border-splash-pink/40 bg-splash-pink/10 px-4 py-3 text-sm text-splash-pink">
|
||||
This reset link is invalid or incomplete.
|
||||
</p>
|
||||
<p className="mt-6 text-sm">
|
||||
<Link href="/account/forgot-password" className="text-clay hover:text-clay-dark">
|
||||
Request a new link
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md px-6 py-16">
|
||||
<h1 className="font-display text-3xl">Choose a new password</h1>
|
||||
|
||||
{error && (
|
||||
<p className="mt-6 border border-splash-pink/40 bg-splash-pink/10 px-4 py-3 text-sm text-splash-pink">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form action={resetPassword} className="mt-8 space-y-6">
|
||||
<input type="hidden" name="token" value={token} />
|
||||
<div>
|
||||
<label htmlFor="password" className="tag-label mb-2 block">
|
||||
New password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="tag-label mb-2 block">
|
||||
Confirm new password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
|
||||
Set new password
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
'use server';
|
||||
|
||||
import { headers } from 'next/headers';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { upsertMailingListEntry } from '@/lib/mailingList';
|
||||
import { verifyTurnstileToken } from '@/lib/turnstile';
|
||||
import { getClientIp } from '@/lib/rateLimit';
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export async function subscribeToNewsletter(email: string, turnstileToken: string) {
|
||||
const trimmed = email.trim().toLowerCase();
|
||||
if (!EMAIL_RE.test(trimmed)) {
|
||||
return { ok: false, message: 'Enter a valid email address.' };
|
||||
}
|
||||
|
||||
const ip = getClientIp(headers());
|
||||
const { success } = await verifyTurnstileToken(turnstileToken, ip);
|
||||
if (!success) {
|
||||
return { ok: false, message: 'CAPTCHA verification failed — please try again.' };
|
||||
}
|
||||
|
||||
await prisma.newsletterSubscriber.upsert({
|
||||
where: { email: trimmed },
|
||||
create: { email: trimmed },
|
||||
update: {},
|
||||
});
|
||||
|
||||
await upsertMailingListEntry({ email: trimmed, source: 'NEWSLETTER' });
|
||||
|
||||
return { ok: true, message: "Thanks — you're on the list." };
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { applyStockMovement } from '@/lib/stock';
|
||||
|
||||
export type ManualSaleItemInput = {
|
||||
productId: string | null; // null = free-text one-off
|
||||
description: string;
|
||||
color: string | null;
|
||||
size: string | null;
|
||||
quantity: number;
|
||||
unitPrice: number; // pence
|
||||
};
|
||||
|
||||
export async function createManualSale(formData: FormData) {
|
||||
const soldAtInput = String(formData.get('soldAt') ?? '').trim();
|
||||
const buyerName = String(formData.get('buyerName') ?? '').trim();
|
||||
const buyerEmail = String(formData.get('buyerEmail') ?? '').trim().toLowerCase();
|
||||
const paymentMethod = String(formData.get('paymentMethod') ?? 'CASH');
|
||||
const notes = String(formData.get('notes') ?? '').trim();
|
||||
const itemsJson = String(formData.get('items') ?? '[]');
|
||||
|
||||
if (!['CASH', 'BANK_TRANSFER'].includes(paymentMethod)) {
|
||||
throw new Error('Unknown payment method.');
|
||||
}
|
||||
|
||||
let items: ManualSaleItemInput[];
|
||||
try {
|
||||
items = JSON.parse(itemsJson);
|
||||
} catch {
|
||||
throw new Error('Could not read the sale items.');
|
||||
}
|
||||
items = items.filter((i) => i.description.trim() && i.quantity > 0);
|
||||
if (items.length === 0) {
|
||||
throw new Error('A sale needs at least one item.');
|
||||
}
|
||||
for (const item of items) {
|
||||
if (!Number.isInteger(item.quantity) || item.quantity < 1 || item.quantity > 10000) {
|
||||
throw new Error('Quantities must be whole numbers.');
|
||||
}
|
||||
if (!Number.isInteger(item.unitPrice) || item.unitPrice < 0) {
|
||||
throw new Error('Prices cannot be negative.');
|
||||
}
|
||||
}
|
||||
|
||||
// Total always recomputed here — never trusted from the client.
|
||||
const total = items.reduce((sum, i) => sum + i.quantity * i.unitPrice, 0);
|
||||
|
||||
// Auto-link to an existing customer account when the email matches, so this
|
||||
// sale shows up in their combined purchase history later.
|
||||
const customer = buyerEmail ? await prisma.customer.findUnique({ where: { email: buyerEmail } }) : null;
|
||||
|
||||
await prisma.manualSale.create({
|
||||
data: {
|
||||
soldAt: soldAtInput ? new Date(soldAtInput) : new Date(),
|
||||
buyerName: buyerName || null,
|
||||
buyerEmail: buyerEmail || null,
|
||||
customerId: customer?.id ?? null,
|
||||
paymentMethod,
|
||||
total,
|
||||
notes: notes || null,
|
||||
items: {
|
||||
create: items.map((i) => ({
|
||||
productId: i.productId,
|
||||
description: i.description.trim(),
|
||||
color: i.color,
|
||||
size: i.size,
|
||||
quantity: i.quantity,
|
||||
unitPrice: i.unitPrice,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Product lines reduce stock; free-text lines carry none.
|
||||
await applyStockMovement(items, -1);
|
||||
|
||||
redirect('/admin/accounts');
|
||||
}
|
||||
|
||||
export async function deleteManualSale(formData: FormData) {
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
const sale = await prisma.manualSale.findUnique({ where: { id }, include: { items: true } });
|
||||
if (!sale) return;
|
||||
await prisma.manualSale.delete({ where: { id } });
|
||||
// Deleting a sale puts its stock back — the sale never happened.
|
||||
await applyStockMovement(sale.items, 1);
|
||||
redirect('/admin/accounts');
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import ManualSaleForm from '@/components/ManualSaleForm';
|
||||
|
||||
export default async function NewManualSalePage() {
|
||||
const rows = await prisma.product.findMany({ orderBy: { name: 'asc' } });
|
||||
const products = rows.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
basePrice: p.basePrice,
|
||||
colors: JSON.parse(p.colors) as string[],
|
||||
sizes: p.sizes ? (JSON.parse(p.sizes) as string[]) : [],
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-12">
|
||||
<AdminNav />
|
||||
<h1 className="font-display text-3xl">Record a sale</h1>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
For anything sold outside the website — cash at a fair, a bank transfer arranged over
|
||||
Facebook. It lands in the same ledger as website orders. If the buyer's email matches a
|
||||
customer account, the sale is linked to them automatically.
|
||||
</p>
|
||||
|
||||
<ManualSaleForm products={products} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import Link from 'next/link';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import AccountsNav from '@/components/AccountsNav';
|
||||
import Price from '@/components/Price';
|
||||
import { deleteManualSale } from './actions';
|
||||
|
||||
const PAYMENT_LABELS: Record<string, string> = {
|
||||
CASH: 'Cash',
|
||||
BANK_TRANSFER: 'Bank transfer',
|
||||
};
|
||||
|
||||
// One row of the merged ledger — a website order and a manual sale flattened
|
||||
// to the same shape so they can be sorted and totalled together.
|
||||
type LedgerEntry = {
|
||||
id: string;
|
||||
kind: 'WEB' | 'MANUAL';
|
||||
date: Date;
|
||||
who: string;
|
||||
what: string;
|
||||
method: string;
|
||||
total: number;
|
||||
href: string | null; // detail link (web orders only for now)
|
||||
};
|
||||
|
||||
export default async function AccountsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { 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;
|
||||
// Include the whole "to" day, not just its midnight.
|
||||
const to = searchParams.to ? new Date(`${searchParams.to}T23:59:59`) : null;
|
||||
|
||||
const [orders, manualSales] = await Promise.all([
|
||||
type === 'manual'
|
||||
? []
|
||||
: prisma.order.findMany({
|
||||
where: {
|
||||
status: 'PAID',
|
||||
...(from || to ? { createdAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } } : {}),
|
||||
},
|
||||
include: { items: true, customer: true },
|
||||
}),
|
||||
type === 'web'
|
||||
? []
|
||||
: prisma.manualSale.findMany({
|
||||
where: from || to ? { soldAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } } : {},
|
||||
include: { items: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const entries: LedgerEntry[] = [
|
||||
...orders.map((o) => ({
|
||||
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(', '),
|
||||
method: 'Card (website)',
|
||||
total: o.total,
|
||||
href: `/admin/orders`,
|
||||
})),
|
||||
...manualSales.map((s) => ({
|
||||
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(', '),
|
||||
method: PAYMENT_LABELS[s.paymentMethod] ?? s.paymentMethod,
|
||||
total: s.total,
|
||||
href: null,
|
||||
})),
|
||||
].sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||
|
||||
const totalAll = entries.reduce((sum, e) => sum + e.total, 0);
|
||||
const totalWeb = entries.filter((e) => e.kind === 'WEB').reduce((sum, e) => sum + e.total, 0);
|
||||
const totalManual = totalAll - totalWeb;
|
||||
|
||||
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);
|
||||
const qs = params.toString();
|
||||
return `/admin/accounts${qs ? `?${qs}` : ''}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-6 py-12">
|
||||
<AdminNav />
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<h1 className="font-display text-3xl">Accounts</h1>
|
||||
<Link
|
||||
href="/admin/accounts/new"
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
+ Record a sale
|
||||
</Link>
|
||||
</div>
|
||||
<AccountsNav active="/admin/accounts" />
|
||||
<p className="mt-4 text-sm text-muted">
|
||||
Every sale in one place — paid website orders plus anything you record by hand (cash, bank
|
||||
transfer). Website orders appear here automatically once paid.
|
||||
</p>
|
||||
|
||||
{/* Totals for whatever the current filter shows */}
|
||||
<div className="mt-6 grid gap-3 sm:grid-cols-3">
|
||||
<div className="border border-line bg-surface p-4">
|
||||
<p className="tag-label">Total</p>
|
||||
<p className="mt-1 font-mono text-xl">
|
||||
<Price cents={totalAll} />
|
||||
</p>
|
||||
</div>
|
||||
<div className="border border-line bg-surface p-4">
|
||||
<p className="tag-label">Website</p>
|
||||
<p className="mt-1 font-mono text-xl">
|
||||
<Price cents={totalWeb} />
|
||||
</p>
|
||||
</div>
|
||||
<div className="border border-line bg-surface p-4">
|
||||
<p className="tag-label">Manual</p>
|
||||
<p className="mt-1 font-mono text-xl">
|
||||
<Price cents={totalManual} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mt-6 flex flex-wrap items-center gap-4">
|
||||
<div className="inline-flex border border-line">
|
||||
{(
|
||||
[
|
||||
['all', 'All'],
|
||||
['web', 'Website'],
|
||||
['manual', 'Manual'],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<Link
|
||||
key={value}
|
||||
href={filterHref(value)}
|
||||
className={`px-4 py-1.5 text-sm ${type === value ? 'bg-ink text-paper' : 'hover:text-clay'}`}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<form method="get" className="flex flex-wrap items-center gap-2 text-sm">
|
||||
{type !== 'all' && <input type="hidden" name="type" value={type} />}
|
||||
<label className="flex items-center gap-1 text-muted">
|
||||
From
|
||||
<input
|
||||
type="date"
|
||||
name="from"
|
||||
defaultValue={searchParams.from ?? ''}
|
||||
className="border border-line bg-paper px-2 py-1.5"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1 text-muted">
|
||||
To
|
||||
<input
|
||||
type="date"
|
||||
name="to"
|
||||
defaultValue={searchParams.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) && (
|
||||
<Link href={filterHref(type)} className="tag-label text-muted hover:text-ink">
|
||||
Clear dates
|
||||
</Link>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Ledger */}
|
||||
{entries.length === 0 ? (
|
||||
<p className="mt-8 text-sm text-muted">No sales in this range yet.</p>
|
||||
) : (
|
||||
<div className="mt-8 divide-y divide-line border-t border-line">
|
||||
{entries.map((e) => (
|
||||
<div key={`${e.kind}-${e.id}`} className="flex flex-wrap items-center gap-x-4 gap-y-1 py-4">
|
||||
<span
|
||||
className={`tag-label shrink-0 rounded-full px-3 py-1 ${
|
||||
e.kind === 'WEB' ? 'bg-splash-blue/10 text-splash-blue' : 'bg-splash-orange/10 text-splash-orange'
|
||||
}`}
|
||||
>
|
||||
{e.kind === 'WEB' ? 'Website' : 'Manual'}
|
||||
</span>
|
||||
<span className="w-24 shrink-0 font-mono text-xs text-muted">
|
||||
{e.date.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{e.who}</p>
|
||||
<p className="truncate text-xs text-muted">{e.what}</p>
|
||||
</div>
|
||||
<span className="tag-label hidden text-muted sm:inline">{e.method}</span>
|
||||
<span className="w-20 shrink-0 text-right font-mono text-sm">
|
||||
<Price cents={e.total} />
|
||||
</span>
|
||||
{e.kind === 'MANUAL' ? (
|
||||
<form action={deleteManualSale}>
|
||||
<input type="hidden" name="id" value={e.id} />
|
||||
<button type="submit" className="tag-label text-muted hover:text-splash-pink">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<Link href={e.href!} className="tag-label text-muted hover:text-ink">
|
||||
View
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import Price from '@/components/Price';
|
||||
|
||||
export default async function PurchaseDetailPage({ params }: { params: { id: string } }) {
|
||||
const purchase = await prisma.purchase.findUnique({
|
||||
where: { id: params.id },
|
||||
include: { items: { include: { product: true } } },
|
||||
});
|
||||
if (!purchase) notFound();
|
||||
|
||||
const isPdf = purchase.invoiceDataUrl?.startsWith('data:application/pdf');
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-12">
|
||||
<AdminNav />
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<h1 className="font-display text-3xl">{purchase.supplierName}</h1>
|
||||
<span className="font-mono text-sm text-muted">
|
||||
{purchase.purchasedAt.toLocaleDateString('en-GB', { day: '2-digit', month: 'long', year: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 divide-y divide-line border-t border-line">
|
||||
{purchase.items.map((item) => (
|
||||
<div key={item.id} className="flex flex-wrap items-center gap-x-4 gap-y-1 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{item.description}</p>
|
||||
<p className="text-xs text-muted">
|
||||
{item.productId
|
||||
? [item.color, item.size].filter(Boolean).join(' · ') || 'Stock item'
|
||||
: 'Material / other — not stock-counted'}
|
||||
</p>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-muted">
|
||||
{item.quantity} × <Price cents={item.unitCost} />
|
||||
</span>
|
||||
<span className="w-20 shrink-0 text-right font-mono text-sm">
|
||||
<Price cents={item.quantity * item.unitCost} />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-right font-mono text-lg">
|
||||
Total <Price cents={purchase.total} />
|
||||
</p>
|
||||
|
||||
{purchase.notes && <div className="mt-6 border-l-2 border-splash-orange pl-4 text-sm italic">{purchase.notes}</div>}
|
||||
|
||||
{purchase.invoiceDataUrl && (
|
||||
<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" />
|
||||
) : (
|
||||
<img
|
||||
src={purchase.invoiceDataUrl}
|
||||
alt="Invoice"
|
||||
className="max-h-[600px] w-full border border-line object-contain"
|
||||
/>
|
||||
)}
|
||||
<a
|
||||
href={purchase.invoiceDataUrl}
|
||||
download={purchase.invoiceFileName ?? 'invoice'}
|
||||
className="mt-2 inline-block tag-label text-clay hover:text-clay-dark"
|
||||
>
|
||||
Download invoice
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link href="/admin/accounts/purchases" className="mt-8 inline-block tag-label hover:text-ink">
|
||||
← All purchases
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { applyStockMovement } from '@/lib/stock';
|
||||
|
||||
export type PurchaseItemInput = {
|
||||
productId: string | null; // null = loose material line (ink, packaging…)
|
||||
description: string;
|
||||
color: string | null;
|
||||
size: string | null;
|
||||
quantity: number;
|
||||
unitCost: number; // pence
|
||||
};
|
||||
|
||||
const MAX_INVOICE_BYTES = 8 * 1024 * 1024; // keep data-URL rows sane
|
||||
|
||||
export async function createPurchase(formData: FormData) {
|
||||
const supplierName = String(formData.get('supplierName') ?? '').trim();
|
||||
const purchasedAtInput = String(formData.get('purchasedAt') ?? '').trim();
|
||||
const notes = String(formData.get('notes') ?? '').trim();
|
||||
const itemsJson = String(formData.get('items') ?? '[]');
|
||||
const invoice = formData.get('invoice') as File | null;
|
||||
|
||||
if (!supplierName) throw new Error('Supplier name is required.');
|
||||
|
||||
let items: PurchaseItemInput[];
|
||||
try {
|
||||
items = JSON.parse(itemsJson);
|
||||
} catch {
|
||||
throw new Error('Could not read the purchase items.');
|
||||
}
|
||||
items = items.filter((i) => i.description.trim() && i.quantity > 0);
|
||||
if (items.length === 0) throw new Error('A purchase needs at least one item.');
|
||||
for (const item of items) {
|
||||
if (!Number.isInteger(item.quantity) || item.quantity < 1 || item.quantity > 100000) {
|
||||
throw new Error('Quantities must be whole numbers.');
|
||||
}
|
||||
if (!Number.isInteger(item.unitCost) || item.unitCost < 0) {
|
||||
throw new Error('Costs cannot be negative.');
|
||||
}
|
||||
}
|
||||
|
||||
let invoiceDataUrl: 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')}`;
|
||||
invoiceFileName = invoice.name || 'invoice';
|
||||
}
|
||||
|
||||
const total = items.reduce((sum, i) => sum + i.quantity * i.unitCost, 0);
|
||||
|
||||
const purchaseDate = purchasedAtInput ? new Date(purchasedAtInput) : new Date();
|
||||
|
||||
await prisma.purchase.create({
|
||||
data: {
|
||||
supplierName,
|
||||
purchasedAt: purchaseDate,
|
||||
invoiceDataUrl,
|
||||
invoiceFileName,
|
||||
total,
|
||||
notes: notes || null,
|
||||
items: {
|
||||
create: items.map((i) => ({
|
||||
productId: i.productId,
|
||||
description: i.description.trim(),
|
||||
color: i.color,
|
||||
size: i.size,
|
||||
quantity: i.quantity,
|
||||
unitCost: i.unitCost,
|
||||
})),
|
||||
},
|
||||
// Auto-create an Expense entry for accounting — every purchase is an expense.
|
||||
expense: {
|
||||
create: {
|
||||
date: purchaseDate,
|
||||
description: `Stock purchase: ${supplierName}`,
|
||||
category: 'Stock purchases',
|
||||
amount: total,
|
||||
notes: notes || null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Product lines add to stock; material lines are just logged.
|
||||
await applyStockMovement(items, 1);
|
||||
|
||||
redirect('/admin/accounts/purchases');
|
||||
}
|
||||
|
||||
export async function deletePurchase(formData: FormData) {
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
const purchase = await prisma.purchase.findUnique({ where: { id }, include: { items: true } });
|
||||
if (!purchase) return;
|
||||
await prisma.purchase.delete({ where: { id } });
|
||||
// Removing a purchase takes its stock back out.
|
||||
await applyStockMovement(purchase.items, -1);
|
||||
redirect('/admin/accounts/purchases');
|
||||
}
|
||||
|
||||
// Direct correction from the stock page — "I counted the shelf and there are
|
||||
// actually N". Sets the absolute value rather than nudging by a delta.
|
||||
export async function setStockQuantity(formData: FormData) {
|
||||
const productId = String(formData.get('productId') ?? '');
|
||||
const color = String(formData.get('color') ?? '');
|
||||
const size = String(formData.get('size') ?? '');
|
||||
const quantity = Math.trunc(Number(formData.get('quantity')));
|
||||
if (!productId || !color || !Number.isFinite(quantity)) return;
|
||||
|
||||
await prisma.stockLevel.upsert({
|
||||
where: { productId_color_size: { productId, color, size } },
|
||||
create: { productId, color, size, quantity },
|
||||
update: { quantity },
|
||||
});
|
||||
redirect('/admin/accounts/stock');
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import PurchaseForm from '@/components/PurchaseForm';
|
||||
|
||||
export default async function NewPurchasePage() {
|
||||
const rows = await prisma.product.findMany({ orderBy: { name: 'asc' } });
|
||||
const products = rows.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
colors: JSON.parse(p.colors) as string[],
|
||||
sizes: p.sizes ? (JSON.parse(p.sizes) as string[]) : [],
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-12">
|
||||
<AdminNav />
|
||||
<h1 className="font-display text-3xl">Record a purchase</h1>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
A supplier invoice — blanks, materials, anything you bought for the business. Attach the
|
||||
invoice file and it's stored with the purchase. Lines matched to a product add straight to
|
||||
its stock count.
|
||||
</p>
|
||||
|
||||
<PurchaseForm products={products} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Link from 'next/link';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import AccountsNav from '@/components/AccountsNav';
|
||||
import Price from '@/components/Price';
|
||||
import { deletePurchase } from './actions';
|
||||
|
||||
export default async function PurchasesPage() {
|
||||
const purchases = await prisma.purchase.findMany({
|
||||
include: { items: true },
|
||||
orderBy: { purchasedAt: 'desc' },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-6 py-12">
|
||||
<AdminNav />
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<h1 className="font-display text-3xl">Purchases</h1>
|
||||
<Link
|
||||
href="/admin/accounts/purchases/new"
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
+ Record a purchase
|
||||
</Link>
|
||||
</div>
|
||||
<AccountsNav active="/admin/accounts/purchases" />
|
||||
<p className="mt-4 text-sm text-muted">
|
||||
Stock coming in from suppliers, with the invoice attached. Product lines add to stock
|
||||
counts automatically; deleting a purchase takes them back out.
|
||||
</p>
|
||||
|
||||
{purchases.length === 0 ? (
|
||||
<p className="mt-8 text-sm text-muted">No purchases recorded yet.</p>
|
||||
) : (
|
||||
<div className="mt-8 divide-y divide-line border-t border-line">
|
||||
{purchases.map((p) => (
|
||||
<div key={p.id} className="flex flex-wrap items-center gap-x-4 gap-y-1 py-4">
|
||||
<span className="w-24 shrink-0 font-mono text-xs text-muted">
|
||||
{p.purchasedAt.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{p.supplierName}</p>
|
||||
<p className="truncate text-xs text-muted">
|
||||
{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>}
|
||||
<span className="w-20 shrink-0 text-right font-mono text-sm">
|
||||
<Price cents={p.total} />
|
||||
</span>
|
||||
<Link href={`/admin/accounts/purchases/${p.id}`} className="tag-label text-muted hover:text-ink">
|
||||
View
|
||||
</Link>
|
||||
<form action={deletePurchase}>
|
||||
<input type="hidden" name="id" value={p.id} />
|
||||
<button type="submit" className="tag-label text-muted hover:text-splash-pink">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import AccountsNav from '@/components/AccountsNav';
|
||||
import StockAdjustForm from '@/components/StockAdjustForm';
|
||||
|
||||
export default async function StockPage() {
|
||||
const [levels, productRows] = await Promise.all([
|
||||
prisma.stockLevel.findMany({
|
||||
include: { product: true },
|
||||
orderBy: [{ product: { name: 'asc' } }, { color: 'asc' }, { size: 'asc' }],
|
||||
}),
|
||||
prisma.product.findMany({ orderBy: { name: 'asc' } }),
|
||||
]);
|
||||
|
||||
const products = productRows.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
colors: JSON.parse(p.colors) as string[],
|
||||
sizes: p.sizes ? (JSON.parse(p.sizes) as string[]) : [],
|
||||
}));
|
||||
|
||||
// Group rows under their product for a readable table.
|
||||
const grouped = new Map<string, typeof levels>();
|
||||
for (const level of levels) {
|
||||
const list = grouped.get(level.productId) ?? [];
|
||||
list.push(level);
|
||||
grouped.set(level.productId, list);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-6 py-12">
|
||||
<AdminNav />
|
||||
<h1 className="font-display text-3xl">Stock</h1>
|
||||
<AccountsNav active="/admin/accounts/stock" />
|
||||
<p className="mt-4 text-sm text-muted">
|
||||
Counts go up when you record a purchase and down when something sells (website or manual).
|
||||
A <span className="text-splash-pink">negative count</span> means a sale happened that your
|
||||
purchases don't cover — enter the missing invoice or set the real count below. Nothing
|
||||
here ever blocks an order.
|
||||
</p>
|
||||
|
||||
<StockAdjustForm products={products} />
|
||||
|
||||
{levels.length === 0 ? (
|
||||
<p className="mt-8 text-sm text-muted">
|
||||
No stock recorded yet — record a purchase, or set a count above to do an initial stock take.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-8 space-y-8">
|
||||
{[...grouped.entries()].map(([productId, rows]) => (
|
||||
<div key={productId}>
|
||||
<h2 className="font-display text-xl">{rows[0].product.name}</h2>
|
||||
<div className="mt-2 divide-y divide-line border-t border-line">
|
||||
{rows.map((row) => (
|
||||
<div key={row.id} className="flex flex-wrap items-center gap-x-4 gap-y-1 py-2.5">
|
||||
<span
|
||||
className="h-5 w-5 shrink-0 rounded-full border border-line"
|
||||
style={{ backgroundColor: row.color }}
|
||||
title={row.color}
|
||||
/>
|
||||
<span className="w-20 font-mono text-xs text-muted">{row.color}</span>
|
||||
<span className="w-12 text-sm">{row.size || '—'}</span>
|
||||
<span
|
||||
className={`ml-auto font-mono text-sm ${row.quantity < 0 ? 'font-medium text-splash-pink' : ''}`}
|
||||
>
|
||||
{row.quantity} in stock
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
function slugify(input: string) {
|
||||
return input
|
||||
.toUpperCase()
|
||||
.trim()
|
||||
.replace(/[^A-Z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
}
|
||||
|
||||
async function uniqueSlug(base: string) {
|
||||
let slug = base || 'CATEGORY';
|
||||
let n = 2;
|
||||
while (await prisma.category.findUnique({ where: { slug } })) {
|
||||
slug = `${base}_${n}`;
|
||||
n += 1;
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
export async function createCategory(formData: FormData) {
|
||||
const name = String(formData.get('name') ?? '').trim();
|
||||
if (!name) throw new Error('Category name is required.');
|
||||
|
||||
const showOnPersonalised = String(formData.get('showOnPersonalised') ?? '') === '1';
|
||||
const showOnProducts = String(formData.get('showOnProducts') ?? '') === '1';
|
||||
|
||||
const slug = await uniqueSlug(slugify(name));
|
||||
|
||||
await prisma.category.create({ data: { name, slug, showOnPersonalised, showOnProducts } });
|
||||
|
||||
redirect('/admin/categories');
|
||||
}
|
||||
|
||||
export async function updateCategoryVisibility(formData: FormData) {
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
|
||||
const showOnPersonalised = String(formData.get('showOnPersonalised') ?? '') === '1';
|
||||
const showOnProducts = String(formData.get('showOnProducts') ?? '') === '1';
|
||||
|
||||
await prisma.category.update({ where: { id }, data: { showOnPersonalised, showOnProducts } });
|
||||
redirect('/admin/categories');
|
||||
}
|
||||
|
||||
export async function deleteCategory(formData: FormData) {
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
|
||||
const category = await prisma.category.findUnique({ where: { id } });
|
||||
if (!category) return;
|
||||
|
||||
const inUse = await prisma.product.count({ where: { category: category.slug } });
|
||||
if (inUse > 0) {
|
||||
redirect('/admin/categories?error=in_use');
|
||||
}
|
||||
|
||||
await prisma.category.delete({ where: { id } });
|
||||
redirect('/admin/categories');
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createCategory } from '../actions';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
|
||||
export default function NewCategoryPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-md px-6 py-12">
|
||||
<AdminNav />
|
||||
<h1 className="font-display text-3xl">Add a category</h1>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
New categories show up right away in the product form, the shop filters, and the main nav.
|
||||
</p>
|
||||
|
||||
<form action={createCategory} className="mt-8 space-y-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="tag-label mb-2 block">
|
||||
Category name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
placeholder="Tote bags"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="tag-label mb-2 block">Show as a filter on</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="showOnPersonalised"
|
||||
value="1"
|
||||
defaultChecked
|
||||
className="h-4 w-4 accent-clay"
|
||||
/>
|
||||
Personalised catalog (/made-to-order)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="showOnProducts"
|
||||
value="1"
|
||||
defaultChecked
|
||||
className="h-4 w-4 accent-clay"
|
||||
/>
|
||||
Products catalog (/products)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
|
||||
Add category
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Link from 'next/link';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import { deleteCategory, updateCategoryVisibility } from './actions';
|
||||
|
||||
export default async function CategoriesPage({ searchParams }: { searchParams: { error?: string } }) {
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-12">
|
||||
<AdminNav />
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h1 className="font-display text-3xl">Categories</h1>
|
||||
<Link
|
||||
href="/admin/categories/new"
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
+ Add category
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{searchParams.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.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 divide-y divide-line border-t border-line">
|
||||
{categories.map((c) => (
|
||||
<div key={c.id} className="flex items-center gap-4 py-4">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{c.name}</p>
|
||||
<p className="tag-label mt-0.5">
|
||||
{countFor(c.slug)} product{countFor(c.slug) === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form action={updateCategoryVisibility} className="flex items-center gap-3">
|
||||
<input type="hidden" name="id" value={c.id} />
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="showOnPersonalised"
|
||||
value="1"
|
||||
defaultChecked={c.showOnPersonalised}
|
||||
className="h-3.5 w-3.5 accent-clay"
|
||||
/>
|
||||
Personalised
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="showOnProducts"
|
||||
value="1"
|
||||
defaultChecked={c.showOnProducts}
|
||||
className="h-3.5 w-3.5 accent-clay"
|
||||
/>
|
||||
Products
|
||||
</label>
|
||||
<button type="submit" className="tag-label hover:text-clay">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form action={deleteCategory}>
|
||||
<input type="hidden" name="id" value={c.id} />
|
||||
<button type="submit" className="tag-label text-muted hover:text-splash-pink">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
function slugify(input: string) {
|
||||
return input
|
||||
.toUpperCase()
|
||||
.trim()
|
||||
.replace(/[^A-Z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
}
|
||||
|
||||
async function uniqueSlug(base: string) {
|
||||
let slug = base || 'COLLECTION';
|
||||
let n = 2;
|
||||
while (await prisma.collection.findUnique({ where: { slug } })) {
|
||||
slug = `${base}_${n}`;
|
||||
n += 1;
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
export async function createCollection(formData: FormData) {
|
||||
const name = String(formData.get('name') ?? '').trim();
|
||||
if (!name) throw new Error('Name is required.');
|
||||
|
||||
const group = String(formData.get('group') ?? '');
|
||||
if (group !== 'OCCASION' && group !== 'RECIPIENT') {
|
||||
throw new Error('Choose Occasion or Recipient.');
|
||||
}
|
||||
|
||||
const slug = await uniqueSlug(slugify(name));
|
||||
|
||||
await prisma.collection.create({ data: { name, slug, group } });
|
||||
|
||||
redirect('/admin/collections');
|
||||
}
|
||||
|
||||
export async function deleteCollection(formData: FormData) {
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
|
||||
const inUse = await prisma.product.count({ where: { collections: { some: { id } } } });
|
||||
if (inUse > 0) {
|
||||
redirect('/admin/collections?error=in_use');
|
||||
}
|
||||
|
||||
await prisma.collection.delete({ where: { id } });
|
||||
redirect('/admin/collections');
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createCollection } from '../actions';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
|
||||
export default function NewCollectionPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-md px-6 py-12">
|
||||
<AdminNav />
|
||||
<h1 className="font-display text-3xl">Add a collection</h1>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
New collections show up right away in the product form and the Occasions/Gifts For menus.
|
||||
</p>
|
||||
|
||||
<form action={createCollection} className="mt-8 space-y-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="tag-label mb-2 block">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
placeholder="Christmas"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="tag-label mb-2 block">Type</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input type="radio" name="group" value="OCCASION" required className="mt-1 h-4 w-4 accent-clay" />
|
||||
<span>
|
||||
Occasion
|
||||
<span className="mt-0.5 block text-xs text-muted">e.g. Christmas, birthdays, holidays</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input type="radio" name="group" value="RECIPIENT" required className="mt-1 h-4 w-4 accent-clay" />
|
||||
<span>
|
||||
Recipient
|
||||
<span className="mt-0.5 block text-xs text-muted">e.g. teachers, school leavers</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
|
||||
Add collection
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Link from 'next/link';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import { deleteCollection } from './actions';
|
||||
|
||||
export default async function CollectionsPage({ searchParams }: { searchParams: { error?: string } }) {
|
||||
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');
|
||||
|
||||
const counts = await Promise.all(
|
||||
collections.map((c) => prisma.product.count({ where: { collections: { some: { id: c.id } } } })),
|
||||
);
|
||||
const countFor = (id: string) => counts[collections.findIndex((c) => c.id === id)] ?? 0;
|
||||
|
||||
const Section = ({ title, items }: { title: string; items: typeof collections }) => (
|
||||
<div>
|
||||
<h2 className="tag-label mt-10 mb-3 text-ink">{title}</h2>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-muted">None yet.</p>
|
||||
) : (
|
||||
<div className="divide-y divide-line border-t border-line">
|
||||
{items.map((c) => (
|
||||
<div key={c.id} className="flex items-center gap-4 py-4">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{c.name}</p>
|
||||
<p className="tag-label mt-0.5">
|
||||
{countFor(c.id)} product{countFor(c.id) === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
<form action={deleteCollection}>
|
||||
<input type="hidden" name="id" value={c.id} />
|
||||
<button type="submit" className="tag-label text-muted hover:text-splash-pink">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-12">
|
||||
<AdminNav />
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h1 className="font-display text-3xl">Collections</h1>
|
||||
<Link
|
||||
href="/admin/collections/new"
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
+ Add collection
|
||||
</Link>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
These power the "Occasions" and "Gifts For" menus, and are tagged onto
|
||||
products from each product's edit page.
|
||||
</p>
|
||||
|
||||
{searchParams.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.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Section title="Occasions" items={occasions} />
|
||||
<Section title="Gifts for" items={recipients} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import ShareButtons from '@/components/ShareButtons';
|
||||
import { deleteRequest, markRequestDone, markRequestNew } from '../actions';
|
||||
|
||||
export default async function CustomRequestDetailPage({ params }: { params: { id: string } }) {
|
||||
const request = await prisma.customRequest.findUnique({
|
||||
where: { id: params.id },
|
||||
include: { product: true },
|
||||
});
|
||||
if (!request) notFound();
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
|
||||
const adminLink = `${siteUrl}/admin/custom-requests/${request.id}`;
|
||||
const whatsappDigits = request.customerPhone ? request.customerPhone.replace(/\D/g, '') : null;
|
||||
const whatsappMessage = `Hi ${request.customerName}, thanks for sending over your photo for a ${request.product.name} — we're working on it!`;
|
||||
|
||||
const newProofParams = new URLSearchParams({
|
||||
productId: request.productId,
|
||||
customerName: request.customerName,
|
||||
customerEmail: request.customerEmail,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-12">
|
||||
<AdminNav />
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<h1 className="font-display text-3xl">Photo request</h1>
|
||||
<span
|
||||
className={`tag-label rounded-full px-3 py-1 ${
|
||||
request.status === 'NEW' ? 'bg-splash-orange/10 text-splash-orange' : 'bg-splash-blue/10 text-splash-blue'
|
||||
}`}
|
||||
>
|
||||
{request.status}
|
||||
</span>
|
||||
</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" />
|
||||
</div>
|
||||
|
||||
{request.note && (
|
||||
<div className="mt-6 border-l-2 border-splash-pink pl-4 text-sm italic">"{request.note}"</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="tag-label mb-1">Customer</p>
|
||||
<p className="text-sm">{request.customerName}</p>
|
||||
<p className="text-sm text-muted">{request.customerEmail}</p>
|
||||
{request.customerPhone && <p className="text-sm text-muted">{request.customerPhone}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<p className="tag-label mb-1">For</p>
|
||||
<p className="text-sm">{request.product.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href={`/admin/proofs/new?${newProofParams.toString()}`}
|
||||
className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark"
|
||||
>
|
||||
Start a design for this
|
||||
</Link>
|
||||
|
||||
{whatsappDigits && (
|
||||
<a
|
||||
href={`https://wa.me/${whatsappDigits}?text=${encodeURIComponent(whatsappMessage)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="border border-ink px-6 py-3 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
Message {request.customerName} on WhatsApp
|
||||
</a>
|
||||
)}
|
||||
|
||||
<form action={request.status === 'NEW' ? markRequestDone : markRequestNew}>
|
||||
<input type="hidden" name="id" value={request.id} />
|
||||
<button
|
||||
type="submit"
|
||||
className="tag-label border border-line px-4 py-3 hover:border-clay hover:text-clay"
|
||||
>
|
||||
{request.status === 'NEW' ? 'Mark as done' : 'Reopen'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form action={deleteRequest}>
|
||||
<input type="hidden" name="id" value={request.id} />
|
||||
<button
|
||||
type="submit"
|
||||
className="tag-label border border-line px-4 py-3 text-muted hover:border-splash-pink hover:text-splash-pink"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 border-t border-line pt-6">
|
||||
<p className="tag-label mb-3">Notify yourself / a colleague</p>
|
||||
<ShareButtons
|
||||
message={`New photo request from ${request.customerName} for a ${request.product.name}: ${adminLink}`}
|
||||
link={adminLink}
|
||||
redirectUri={adminLink}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Link href="/admin/custom-requests" className="mt-8 inline-block tag-label hover:text-ink">
|
||||
← All photo requests
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function markRequestDone(formData: FormData) {
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
await prisma.customRequest.update({ where: { id }, data: { status: 'DONE' } });
|
||||
redirect('/admin/custom-requests');
|
||||
}
|
||||
|
||||
export async function markRequestNew(formData: FormData) {
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
await prisma.customRequest.update({ where: { id }, data: { status: 'NEW' } });
|
||||
redirect('/admin/custom-requests');
|
||||
}
|
||||
|
||||
export async function deleteRequest(formData: FormData) {
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
await prisma.customRequest.delete({ where: { id } });
|
||||
redirect('/admin/custom-requests');
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Link from 'next/link';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import { deleteRequest } from './actions';
|
||||
|
||||
export default async function CustomRequestsPage() {
|
||||
const rows = await prisma.customRequest.findMany({
|
||||
include: { product: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// NEW requests first, most recent within each group (Array.sort is stable).
|
||||
const requests = [...rows].sort((a, b) => (a.status === b.status ? 0 : a.status === 'NEW' ? -1 : 1));
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-12">
|
||||
<AdminNav />
|
||||
<h1 className="font-display text-3xl">Photo requests</h1>
|
||||
|
||||
{requests.length === 0 ? (
|
||||
<p className="mt-8 text-sm text-muted">No photo requests yet.</p>
|
||||
) : (
|
||||
<div className="mt-8 divide-y divide-line border-t border-line">
|
||||
{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" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{r.customerName}</p>
|
||||
<p className="truncate text-sm text-muted">{r.customerEmail}</p>
|
||||
</div>
|
||||
<span className="tag-label">{r.product.name}</span>
|
||||
<span
|
||||
className={`tag-label rounded-full px-3 py-1 ${
|
||||
r.status === 'NEW' ? 'bg-splash-orange/10 text-splash-orange' : 'bg-splash-blue/10 text-splash-blue'
|
||||
}`}
|
||||
>
|
||||
{r.status}
|
||||
</span>
|
||||
</Link>
|
||||
<form action={deleteRequest}>
|
||||
<input type="hidden" name="id" value={r.id} />
|
||||
<button type="submit" className="tag-label text-muted hover:text-splash-pink">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { sendCustomerBroadcast } from '@/lib/mail';
|
||||
|
||||
export async function updateSubscribed(formData: FormData) {
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
|
||||
const subscribed = String(formData.get('subscribed') ?? '') === '1';
|
||||
|
||||
await prisma.mailingListEntry.update({ where: { id }, data: { subscribed } });
|
||||
redirect('/admin/customers');
|
||||
}
|
||||
|
||||
export async function sendBulkEmail(formData: FormData) {
|
||||
const subject = String(formData.get('subject') ?? '').trim();
|
||||
const message = String(formData.get('message') ?? '').trim();
|
||||
if (!subject || !message) {
|
||||
throw new Error('Subject and message are required.');
|
||||
}
|
||||
|
||||
const attachmentFile = formData.get('attachment') as File | null;
|
||||
const attachment =
|
||||
attachmentFile && attachmentFile.size > 0
|
||||
? {
|
||||
filename: attachmentFile.name,
|
||||
content: Buffer.from(await attachmentFile.arrayBuffer()),
|
||||
contentType: attachmentFile.type || undefined,
|
||||
}
|
||||
: null;
|
||||
|
||||
const recipients = await prisma.mailingListEntry.findMany({ where: { subscribed: true } });
|
||||
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
for (const recipient of recipients) {
|
||||
const result = await sendCustomerBroadcast({
|
||||
to: recipient.email,
|
||||
name: recipient.name,
|
||||
subject,
|
||||
message,
|
||||
attachment,
|
||||
});
|
||||
if (result.sent) sent += 1;
|
||||
else failed += 1;
|
||||
}
|
||||
|
||||
redirect(`/admin/customers?sent=${sent}&failed=${failed}`);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import { updateSubscribed, sendBulkEmail } from './actions';
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
CUSTOMER: 'Account',
|
||||
NEWSLETTER: 'Newsletter',
|
||||
GUEST: 'Guest order',
|
||||
};
|
||||
|
||||
export default async function CustomersPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { sent?: string; failed?: string };
|
||||
}) {
|
||||
const entries = await prisma.mailingListEntry.findMany({ orderBy: { createdAt: 'desc' } });
|
||||
const subscribedCount = entries.filter((e) => e.subscribed).length;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-12">
|
||||
<AdminNav />
|
||||
<h1 className="font-display text-3xl">Email customers</h1>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
Everyone who's made an account, signed up for the newsletter, or checked out as a
|
||||
guest, in one list. Untick Subscribed to leave someone out of future mailings.
|
||||
</p>
|
||||
|
||||
{searchParams.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.`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2 className="tag-label mt-10 mb-3 text-ink">
|
||||
Mailing list ({subscribedCount} of {entries.length} subscribed)
|
||||
</h2>
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-sm text-muted">No one on the list yet.</p>
|
||||
) : (
|
||||
<div className="divide-y divide-line border-t border-line">
|
||||
{entries.map((e) => (
|
||||
<div key={e.id} className="flex items-center gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{e.name || e.email}</p>
|
||||
{e.name && <p className="text-xs text-muted">{e.email}</p>}
|
||||
</div>
|
||||
<span className="tag-label text-muted">{SOURCE_LABELS[e.source] ?? e.source}</span>
|
||||
<form action={updateSubscribed} className="flex items-center gap-2">
|
||||
<input type="hidden" name="id" value={e.id} />
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="subscribed"
|
||||
value="1"
|
||||
defaultChecked={e.subscribed}
|
||||
className="h-3.5 w-3.5 accent-clay"
|
||||
/>
|
||||
Subscribed
|
||||
</label>
|
||||
<button type="submit" className="tag-label hover:text-clay">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2 className="tag-label mt-10 mb-3 text-ink">Send an email</h2>
|
||||
<form action={sendBulkEmail} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="subject" className="tag-label mb-2 block">
|
||||
Subject
|
||||
</label>
|
||||
<input
|
||||
id="subject"
|
||||
name="subject"
|
||||
required
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
placeholder="What's new at Craft2Prints"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="message" className="tag-label mb-2 block">
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
required
|
||||
rows={8}
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
placeholder="Write your message here…"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="attachment" className="tag-label mb-2 block">
|
||||
Attachment (optional)
|
||||
</label>
|
||||
<input id="attachment" name="attachment" type="file" className="w-full text-sm" />
|
||||
</div>
|
||||
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
|
||||
Send to {subscribedCount} subscribed recipient{subscribedCount === 1 ? '' : 's'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
function slugify(input: string) {
|
||||
return input
|
||||
.toUpperCase()
|
||||
.trim()
|
||||
.replace(/[^A-Z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
}
|
||||
|
||||
async function uniqueSlug(base: string) {
|
||||
let slug = base || 'GALLERY';
|
||||
let n = 2;
|
||||
while (await prisma.galleryCategory.findUnique({ where: { slug } })) {
|
||||
slug = `${base}_${n}`;
|
||||
n += 1;
|
||||
}
|
||||
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;
|
||||
const file = formData.get('image') as File | null;
|
||||
|
||||
if (!file || file.size === 0) {
|
||||
throw new Error('A photo is required.');
|
||||
}
|
||||
|
||||
const imageDataUrl = await fileToDataUrl(file);
|
||||
|
||||
await prisma.galleryPhoto.create({
|
||||
data: {
|
||||
imageDataUrl,
|
||||
caption: caption || null,
|
||||
categoryId,
|
||||
},
|
||||
});
|
||||
|
||||
redirect('/admin/gallery');
|
||||
}
|
||||
|
||||
export async function deleteGalleryPhoto(formData: FormData) {
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
|
||||
await prisma.galleryPhoto.delete({ where: { id } });
|
||||
redirect('/admin/gallery');
|
||||
}
|
||||
|
||||
export async function createGalleryCategory(formData: FormData) {
|
||||
const name = String(formData.get('name') ?? '').trim();
|
||||
if (!name) throw new Error('Category name is required.');
|
||||
|
||||
const slug = await uniqueSlug(slugify(name));
|
||||
|
||||
await prisma.galleryCategory.create({ data: { name, slug } });
|
||||
|
||||
redirect('/admin/gallery/categories');
|
||||
}
|
||||
|
||||
export async function deleteGalleryCategory(formData: FormData) {
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
|
||||
const inUse = await prisma.galleryPhoto.count({ where: { categoryId: id } });
|
||||
if (inUse > 0) {
|
||||
redirect('/admin/gallery/categories?error=in_use');
|
||||
}
|
||||
|
||||
await prisma.galleryCategory.delete({ where: { id } });
|
||||
redirect('/admin/gallery/categories');
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import { createGalleryCategory } from '../../actions';
|
||||
|
||||
export default function NewGalleryCategoryPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-md px-6 py-12">
|
||||
<AdminNav />
|
||||
<h1 className="font-display text-3xl">Add a gallery category</h1>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
Show up right away as a filter on the public gallery and in the photo upload form.
|
||||
</p>
|
||||
|
||||
<form action={createGalleryCategory} className="mt-8 space-y-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="tag-label mb-2 block">
|
||||
Category name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
placeholder="Weddings"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
|
||||
Add category
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Link from 'next/link';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import { deleteGalleryCategory } from '../actions';
|
||||
|
||||
export default async function GalleryCategoriesPage({ searchParams }: { searchParams: { error?: string } }) {
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-12">
|
||||
<AdminNav />
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h1 className="font-display text-3xl">Gallery categories</h1>
|
||||
<Link
|
||||
href="/admin/gallery/categories/new"
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
+ Add category
|
||||
</Link>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
Used to group photos on the public gallery — separate from your product categories.
|
||||
</p>
|
||||
|
||||
{searchParams.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.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{categories.length === 0 ? (
|
||||
<p className="mt-8 text-sm text-muted">No gallery categories yet.</p>
|
||||
) : (
|
||||
<div className="mt-8 divide-y divide-line border-t border-line">
|
||||
{categories.map((c) => (
|
||||
<div key={c.id} className="flex items-center gap-4 py-4">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{c.name}</p>
|
||||
<p className="tag-label mt-0.5">
|
||||
{countFor(c.id)} photo{countFor(c.id) === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
<form action={deleteGalleryCategory}>
|
||||
<input type="hidden" name="id" value={c.id} />
|
||||
<button type="submit" className="tag-label text-muted hover:text-splash-pink">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import { uploadGalleryPhoto } from '../actions';
|
||||
|
||||
export default async function NewGalleryPhotoPage() {
|
||||
const categories = await prisma.galleryCategory.findMany({ orderBy: { name: 'asc' } });
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-12">
|
||||
<AdminNav />
|
||||
<h1 className="font-display text-3xl">Add a photo</h1>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
Upload a photo of completed work to show on the public gallery.
|
||||
</p>
|
||||
|
||||
<form action={uploadGalleryPhoto} className="mt-10 space-y-6">
|
||||
<div>
|
||||
<label htmlFor="image" className="tag-label mb-2 block">
|
||||
Photo
|
||||
</label>
|
||||
<input
|
||||
id="image"
|
||||
name="image"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
required
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="caption" className="tag-label mb-2 block">
|
||||
Caption (optional)
|
||||
</label>
|
||||
<input
|
||||
id="caption"
|
||||
name="caption"
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
placeholder="Personalised wedding mugs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="categoryId" className="tag-label mb-2 block">
|
||||
Category (optional)
|
||||
</label>
|
||||
<select
|
||||
id="categoryId"
|
||||
name="categoryId"
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">— Uncategorized —</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<a href="/admin/gallery/categories/new" className="mt-1 inline-block text-xs text-clay hover:text-clay-dark">
|
||||
+ Add a new category
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
|
||||
Add photo
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import Link from 'next/link';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import { deleteGalleryPhoto } from './actions';
|
||||
|
||||
export default async function AdminGalleryPage() {
|
||||
const photos = await prisma.galleryPhoto.findMany({
|
||||
include: { category: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-12">
|
||||
<AdminNav />
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h1 className="font-display text-3xl">Gallery</h1>
|
||||
<Link
|
||||
href="/admin/gallery/new"
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
+ Add photo
|
||||
</Link>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
Photos of completed work, shown on the public <Link href="/gallery" className="text-clay hover:text-clay-dark">gallery page</Link>.
|
||||
Manage the categories under{' '}
|
||||
<Link href="/admin/gallery/categories" className="text-clay hover:text-clay-dark">Gallery categories</Link>.
|
||||
</p>
|
||||
|
||||
{photos.length === 0 ? (
|
||||
<p className="mt-8 text-sm text-muted">No photos yet.</p>
|
||||
) : (
|
||||
<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" />
|
||||
<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">
|
||||
{new Date(p.createdAt).toLocaleDateString(undefined, { dateStyle: 'medium' })}
|
||||
</p>
|
||||
</div>
|
||||
{p.category ? (
|
||||
<span className="tag-label rounded-full bg-surface px-3 py-1">{p.category.name}</span>
|
||||
) : (
|
||||
<span className="tag-label text-muted">Uncategorized</span>
|
||||
)}
|
||||
<form action={deleteGalleryPhoto}>
|
||||
<input type="hidden" name="id" value={p.id} />
|
||||
<button type="submit" className="tag-label text-muted hover:text-splash-pink">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import Chat from '@/components/Chat';
|
||||
|
||||
function formatPrice(cents: number) {
|
||||
return `£${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
export default async function AdminThreadPage({ params }: { params: { id: string } }) {
|
||||
const proof = await prisma.designProof.findUnique({
|
||||
where: { id: params.id },
|
||||
include: { product: true },
|
||||
});
|
||||
if (!proof) notFound();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-12">
|
||||
<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" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{proof.customerName || proof.customerEmail}</p>
|
||||
<p className="text-sm text-muted">
|
||||
{proof.product.name} · qty {proof.quantity} · {formatPrice(proof.unitPrice * proof.quantity)}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`tag-label rounded-full px-3 py-1 ${
|
||||
proof.status === 'APPROVED' ? 'bg-splash-blue/10 text-splash-blue' : 'bg-splash-orange/10 text-splash-orange'
|
||||
}`}
|
||||
>
|
||||
{proof.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Link href={`/proofs/${proof.id}`} className="mt-3 inline-block tag-label hover:text-ink">
|
||||
View customer page →
|
||||
</Link>
|
||||
|
||||
<div className="mt-6">
|
||||
<Chat proofId={proof.id} role="ADMIN" otherPartyLabel={proof.customerName || proof.customerEmail} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Link from 'next/link';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
|
||||
export default async function InboxPage() {
|
||||
const proofs = await prisma.designProof.findMany({
|
||||
include: {
|
||||
product: true,
|
||||
messages: { orderBy: { createdAt: 'desc' }, take: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
// Only show threads that actually have at least one message, most recent first.
|
||||
const withMessages = proofs
|
||||
.filter((p) => p.messages.length > 0)
|
||||
.sort((a, b) => b.messages[0].createdAt.getTime() - a.messages[0].createdAt.getTime());
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-12">
|
||||
<AdminNav />
|
||||
<h1 className="font-display text-3xl">Inbox</h1>
|
||||
|
||||
{withMessages.length === 0 ? (
|
||||
<p className="mt-8 text-sm text-muted">No conversations yet.</p>
|
||||
) : (
|
||||
<div className="mt-8 divide-y divide-line border-t border-line">
|
||||
{withMessages.map((p) => {
|
||||
const last = p.messages[0];
|
||||
return (
|
||||
<Link
|
||||
key={p.id}
|
||||
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" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{p.customerName || p.customerEmail}</p>
|
||||
<p className="truncate text-sm text-muted">
|
||||
{last.sender === 'ADMIN' ? 'You: ' : ''}
|
||||
{last.body}
|
||||
</p>
|
||||
</div>
|
||||
<span className="tag-label">{p.product.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use server';
|
||||
|
||||
import { headers } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { createAdminSession, clearAdminSession } from '@/lib/adminAuth';
|
||||
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
|
||||
|
||||
function safeNext(next: FormDataEntryValue | null) {
|
||||
const path = String(next ?? '');
|
||||
return path.startsWith('/') && !path.startsWith('//') ? path : '/admin/orders';
|
||||
}
|
||||
|
||||
export async function loginAdmin(formData: FormData) {
|
||||
const username = String(formData.get('username') ?? '');
|
||||
const password = String(formData.get('password') ?? '');
|
||||
const next = safeNext(formData.get('next'));
|
||||
|
||||
const ip = getClientIp(headers());
|
||||
const { allowed } = await checkRateLimit(`login:admin:${ip}`, 5, 15 * 60 * 1000);
|
||||
if (!allowed) {
|
||||
redirect(`/admin/login?error=rate_limited&next=${encodeURIComponent(next)}`);
|
||||
}
|
||||
|
||||
if (username !== process.env.ADMIN_USER || password !== process.env.ADMIN_PASSWORD) {
|
||||
redirect(`/admin/login?error=invalid&next=${encodeURIComponent(next)}`);
|
||||
}
|
||||
|
||||
await createAdminSession();
|
||||
redirect(next);
|
||||
}
|
||||
|
||||
export async function logoutAdmin() {
|
||||
clearAdminSession();
|
||||
redirect('/admin/login');
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { loginAdmin } from './actions';
|
||||
|
||||
const ERROR_MESSAGES: Record<string, string> = {
|
||||
invalid: 'Incorrect username or password.',
|
||||
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/orders';
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md px-6 py-16">
|
||||
<h1 className="font-display text-3xl">Admin login</h1>
|
||||
|
||||
{error && (
|
||||
<p className="mt-6 border border-splash-pink/40 bg-splash-pink/10 px-4 py-3 text-sm text-splash-pink">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form action={loginAdmin} className="mt-8 space-y-6">
|
||||
<input type="hidden" name="next" value={next} />
|
||||
<div>
|
||||
<label htmlFor="username" className="tag-label mb-2 block">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
required
|
||||
autoFocus
|
||||
className="w-full border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="tag-label mb-2 block">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
className="w-full border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
|
||||
Log in
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { SITE_SETTINGS_ID } from '@/lib/settings';
|
||||
|
||||
export async function setMaintenance(formData: FormData) {
|
||||
const enable = String(formData.get('enable') ?? '') === '1';
|
||||
const message = String(formData.get('message') ?? '').trim();
|
||||
|
||||
await prisma.siteSetting.upsert({
|
||||
where: { id: SITE_SETTINGS_ID },
|
||||
create: {
|
||||
id: SITE_SETTINGS_ID,
|
||||
maintenanceMode: enable,
|
||||
maintenanceMessage: message || null,
|
||||
},
|
||||
update: {
|
||||
maintenanceMode: enable,
|
||||
maintenanceMessage: message || null,
|
||||
},
|
||||
});
|
||||
|
||||
redirect('/admin/maintenance');
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import { getSiteSettings, DEFAULT_MAINTENANCE_MESSAGE } from '@/lib/settings';
|
||||
import { setMaintenance } from './actions';
|
||||
|
||||
export default async function MaintenancePage() {
|
||||
const { maintenanceMode, maintenanceMessage } = await getSiteSettings();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-12">
|
||||
<AdminNav />
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<h1 className="font-display text-3xl">Maintenance mode</h1>
|
||||
<span
|
||||
className={`tag-label rounded-full px-3 py-1 ${
|
||||
maintenanceMode ? 'bg-splash-pink/10 text-splash-pink' : 'bg-splash-blue/10 text-splash-blue'
|
||||
}`}
|
||||
>
|
||||
{maintenanceMode ? 'ON — site is hidden' : 'OFF — site is live'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
When on, customers see a "we're updating" page instead of the shop. You (while
|
||||
logged in here) still see the real site, and can always get back to this page to turn it off.
|
||||
</p>
|
||||
|
||||
<form action={setMaintenance} className="mt-10 space-y-6">
|
||||
<div>
|
||||
<label htmlFor="message" className="tag-label mb-2 block">
|
||||
Message shown to customers
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
rows={3}
|
||||
defaultValue={maintenanceMessage ?? ''}
|
||||
placeholder={DEFAULT_MAINTENANCE_MESSAGE}
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted">Leave blank to use the default message shown above.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{maintenanceMode ? (
|
||||
<>
|
||||
<button
|
||||
type="submit"
|
||||
name="enable"
|
||||
value="0"
|
||||
className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark"
|
||||
>
|
||||
Turn maintenance OFF
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
name="enable"
|
||||
value="1"
|
||||
className="border border-line px-6 py-3 text-sm hover:border-clay hover:text-clay"
|
||||
>
|
||||
Save message (keep ON)
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
name="enable"
|
||||
value="1"
|
||||
className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark"
|
||||
>
|
||||
Turn maintenance ON
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import { archiveOrder, unarchiveOrder } from '../actions';
|
||||
|
||||
function formatPrice(cents: number) {
|
||||
return `£${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
export default async function OrderDetailPage({ params }: { params: { id: string } }) {
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: params.id },
|
||||
include: { items: true },
|
||||
});
|
||||
if (!order) notFound();
|
||||
|
||||
const shipping = order.shippingAddress ? JSON.parse(order.shippingAddress) : null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-12">
|
||||
<AdminNav />
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<h1 className="font-display text-3xl">Order</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
{order.archived && <span className="tag-label">Archived</span>}
|
||||
<form action={order.archived ? unarchiveOrder : archiveOrder}>
|
||||
<input type="hidden" name="id" value={order.id} />
|
||||
<button type="submit" className="tag-label border border-line px-3 py-1.5 hover:border-clay hover:text-clay">
|
||||
{order.archived ? 'Unarchive' : 'Archive'}
|
||||
</button>
|
||||
</form>
|
||||
<span
|
||||
className={`tag-label rounded-full px-3 py-1 ${
|
||||
order.status === 'PAID'
|
||||
? 'bg-splash-blue/10 text-splash-blue'
|
||||
: order.status === 'FAILED'
|
||||
? 'bg-splash-pink/10 text-splash-pink'
|
||||
: 'bg-splash-orange/10 text-splash-orange'
|
||||
}`}
|
||||
>
|
||||
{order.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 border border-line bg-surface p-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="tag-label mb-1">Customer</p>
|
||||
<p className="text-sm">{order.email ?? 'Not yet available'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="tag-label mb-1">Placed</p>
|
||||
<p className="text-sm">
|
||||
{new Date(order.createdAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}
|
||||
</p>
|
||||
</div>
|
||||
{shipping && (
|
||||
<div className="sm:col-span-2">
|
||||
<p className="tag-label mb-1">Shipping address</p>
|
||||
<p className="text-sm">
|
||||
{shipping.name}
|
||||
<br />
|
||||
{[shipping.address?.line1, shipping.address?.line2].filter(Boolean).join(', ')}
|
||||
<br />
|
||||
{[shipping.address?.city, shipping.address?.postal_code].filter(Boolean).join(', ')}
|
||||
<br />
|
||||
{shipping.address?.country}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 divide-y divide-line border-t border-line">
|
||||
{order.items.map((item) => {
|
||||
const design = JSON.parse(item.designJson) as { front: unknown[]; back: unknown[] };
|
||||
const hasFrontDesign = design.front.length > 0;
|
||||
const hasBackDesign = design.back.length > 0;
|
||||
const frontPlacement = item.placementPreviewUrl || item.designPreviewUrl;
|
||||
return (
|
||||
<div key={item.id} className="py-8">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<p className="font-display text-xl">{item.productName}</p>
|
||||
<p className="text-sm text-muted">
|
||||
{item.color}
|
||||
{item.size ? `, ${item.size}` : ''} · qty {item.quantity} ·{' '}
|
||||
{formatPrice(item.unitPrice * item.quantity)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<img
|
||||
src={frontPlacement || undefined}
|
||||
alt={`${item.productName} — where the design sits`}
|
||||
className="w-full max-w-md border border-line bg-paper object-contain"
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<p className="tag-label">{item.placementPreviewUrl ? 'On product — front' : 'Front'}</p>
|
||||
{hasFrontDesign && (
|
||||
<a
|
||||
href={item.designPreviewUrl}
|
||||
download={`order-${order.id}-${item.id}-front.png`}
|
||||
className="text-xs text-clay hover:text-clay-dark"
|
||||
>
|
||||
Download print file
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.placementPreviewUrlBack && (
|
||||
<div>
|
||||
<img
|
||||
src={item.placementPreviewUrlBack}
|
||||
alt={`${item.productName} back — where the design sits`}
|
||||
className="w-full max-w-md border border-line bg-paper object-contain"
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<p className="tag-label">On product — back</p>
|
||||
{hasBackDesign && item.designPreviewUrlBack && (
|
||||
<a
|
||||
href={item.designPreviewUrlBack}
|
||||
download={`order-${order.id}-${item.id}-back.png`}
|
||||
className="text-xs text-clay hover:text-clay-dark"
|
||||
>
|
||||
Download print file
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="tag-label">Total</span>
|
||||
<span className="font-mono text-xl">{formatPrice(order.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
const AUTO_ARCHIVE_DAYS = 30;
|
||||
|
||||
// Runs on every visit to the orders list — sweeps anything past the cutoff into
|
||||
// the archive. No cron/scheduled task needed for a tool at this scale; this is
|
||||
// "close enough" to real auto-archiving without extra infrastructure.
|
||||
export async function autoArchiveOldOrders() {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - AUTO_ARCHIVE_DAYS);
|
||||
|
||||
await prisma.order.updateMany({
|
||||
where: {
|
||||
archived: false,
|
||||
status: { in: ['PAID', 'FAILED'] }, // never auto-archive something still PENDING
|
||||
createdAt: { lt: cutoff },
|
||||
},
|
||||
data: { archived: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveOrder(formData: FormData) {
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
await prisma.order.update({ where: { id }, data: { archived: true } });
|
||||
redirect('/admin/orders');
|
||||
}
|
||||
|
||||
export async function unarchiveOrder(formData: FormData) {
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
await prisma.order.update({ where: { id }, data: { archived: false } });
|
||||
redirect('/admin/orders?archived=1');
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Link from 'next/link';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import { autoArchiveOldOrders, archiveOrder, unarchiveOrder } from './actions';
|
||||
|
||||
function formatPrice(cents: number) {
|
||||
return `£${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
PAID: 'bg-splash-blue/10 text-splash-blue',
|
||||
PENDING: 'bg-splash-orange/10 text-splash-orange',
|
||||
FAILED: 'bg-splash-pink/10 text-splash-pink',
|
||||
};
|
||||
|
||||
export default async function OrdersPage({ searchParams }: { searchParams: { archived?: string } }) {
|
||||
await autoArchiveOldOrders();
|
||||
|
||||
const showArchived = searchParams.archived === '1';
|
||||
|
||||
const orders = await prisma.order.findMany({
|
||||
where: {
|
||||
status: { in: ['PAID', 'PENDING', 'FAILED'] },
|
||||
archived: showArchived,
|
||||
},
|
||||
include: { items: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-6 py-12">
|
||||
<AdminNav />
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h1 className="font-display text-3xl">{showArchived ? 'Archived orders' : 'Orders'}</h1>
|
||||
<Link href={showArchived ? '/admin/orders' : '/admin/orders?archived=1'} className="tag-label hover:text-ink">
|
||||
{showArchived ? '← Back to active orders' : 'View archived →'}
|
||||
</Link>
|
||||
</div>
|
||||
{!showArchived && (
|
||||
<p className="mt-2 text-xs text-muted">
|
||||
Paid or failed orders older than 30 days archive automatically. Use the button on any order
|
||||
to archive it sooner, or wait for it to happen on its own.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{orders.length === 0 ? (
|
||||
<p className="mt-10 text-sm text-muted">{showArchived ? 'Nothing archived yet.' : 'No orders yet.'}</p>
|
||||
) : (
|
||||
<div className="mt-8 divide-y divide-line border-t border-line">
|
||||
{orders.map((order) => (
|
||||
<div key={order.id} className="flex items-center gap-4 py-4">
|
||||
<Link href={`/admin/orders/${order.id}`} className="flex flex-1 items-center gap-4 hover:opacity-80">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{order.email ?? 'No email yet'}</p>
|
||||
<p className="text-sm text-muted">
|
||||
{order.items.length} item{order.items.length === 1 ? '' : 's'} ·{' '}
|
||||
{new Date(order.createdAt).toLocaleString(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<span className="font-mono text-sm">{formatPrice(order.total)}</span>
|
||||
<span className={`tag-label rounded-full px-3 py-1 ${STATUS_STYLES[order.status] ?? ''}`}>
|
||||
{order.status}
|
||||
</span>
|
||||
</Link>
|
||||
<form action={showArchived ? unarchiveOrder : archiveOrder}>
|
||||
<input type="hidden" name="id" value={order.id} />
|
||||
<button type="submit" className="tag-label text-muted hover:text-clay">
|
||||
{showArchived ? 'Unarchive' : 'Archive'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { toProductDTO } from '@/lib/product';
|
||||
import { fetchActivePromotions } from '@/lib/promotions';
|
||||
import { updateProduct } from '../../actions';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import ColorPicker from '@/components/ColorPicker';
|
||||
import SizePicker from '@/components/SizePicker';
|
||||
import PhotoPrintAreaField from '@/components/PhotoPrintAreaField';
|
||||
import { MOCKUP_OPTIONS } from '@/lib/mockupDefaults';
|
||||
|
||||
export default async function EditProductPage({ params }: { params: { id: string } }) {
|
||||
const activePromotions = await fetchActivePromotions();
|
||||
const row = await prisma.product.findUnique({ where: { id: params.id }, include: { collections: true } });
|
||||
if (!row) notFound();
|
||||
const product = toProductDTO(row, activePromotions);
|
||||
|
||||
const categories = await prisma.category.findMany({ orderBy: { name: 'asc' } });
|
||||
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');
|
||||
const selectedCollectionIds = new Set(row.collections.map((c) => c.id));
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-12">
|
||||
<AdminNav />
|
||||
<h1 className="font-display text-3xl">Edit {product.name}</h1>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
Changes apply immediately to the live product at /made-to-order/{product.slug}.
|
||||
</p>
|
||||
|
||||
<form action={updateProduct} className="mt-10 space-y-6" encType="multipart/form-data">
|
||||
<input type="hidden" name="id" value={product.id} />
|
||||
|
||||
<div>
|
||||
<label className="tag-label mb-2 block">Show on</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="showOnPersonalised"
|
||||
value="1"
|
||||
defaultChecked={product.showOnPersonalised}
|
||||
className="h-4 w-4 accent-clay"
|
||||
/>
|
||||
Personalised catalog (/made-to-order) — customers design their own
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="showOnProducts"
|
||||
value="1"
|
||||
defaultChecked={product.showOnProducts}
|
||||
className="h-4 w-4 accent-clay"
|
||||
/>
|
||||
Products catalog (/products) — same photos, browse and buy as-is
|
||||
</label>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted">Check both to list the same product on both pages.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="name" className="tag-label mb-2 block">
|
||||
Product name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
defaultValue={product.name}
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="category" className="tag-label mb-2 block">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
name="category"
|
||||
required
|
||||
defaultValue={product.category}
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.slug}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="mockup" className="tag-label mb-2 block">
|
||||
Shape (used if no photo)
|
||||
</label>
|
||||
<select
|
||||
id="mockup"
|
||||
name="mockup"
|
||||
required
|
||||
defaultValue={product.mockup}
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
>
|
||||
{MOCKUP_OPTIONS.map((m) => (
|
||||
<option key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(occasions.length > 0 || recipients.length > 0) && (
|
||||
<div>
|
||||
<label className="tag-label mb-2 block">Collections (optional)</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="mb-2 text-xs text-muted">Occasions</p>
|
||||
<div className="space-y-1.5">
|
||||
{occasions.map((c) => (
|
||||
<label key={c.id} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="collections"
|
||||
value={c.id}
|
||||
defaultChecked={selectedCollectionIds.has(c.id)}
|
||||
className="h-4 w-4 accent-clay"
|
||||
/>
|
||||
{c.name}
|
||||
</label>
|
||||
))}
|
||||
{occasions.length === 0 && <p className="text-xs text-muted">None yet.</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-xs text-muted">Gifts for</p>
|
||||
<div className="space-y-1.5">
|
||||
{recipients.map((c) => (
|
||||
<label key={c.id} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="collections"
|
||||
value={c.id}
|
||||
defaultChecked={selectedCollectionIds.has(c.id)}
|
||||
className="h-4 w-4 accent-clay"
|
||||
/>
|
||||
{c.name}
|
||||
</label>
|
||||
))}
|
||||
{recipients.length === 0 && <p className="text-xs text-muted">None yet.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/admin/collections/new" className="mt-1 inline-block text-xs text-clay hover:text-clay-dark">
|
||||
+ Add a new collection
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="price" className="tag-label mb-2 block">
|
||||
Price (£)
|
||||
</label>
|
||||
<input
|
||||
id="price"
|
||||
name="price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
required
|
||||
defaultValue={(product.basePrice / 100).toFixed(2)}
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="salePrice" className="tag-label mb-2 block">
|
||||
Sale price (£, optional)
|
||||
</label>
|
||||
<input
|
||||
id="salePrice"
|
||||
name="salePrice"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
defaultValue={row.salePrice != null ? (row.salePrice / 100).toFixed(2) : ''}
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
placeholder="Leave blank for no sale"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
Set a start/end date below too — outside that window the normal price shows instead.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="saleStartsAt" className="tag-label mb-2 block">
|
||||
Sale starts (optional)
|
||||
</label>
|
||||
<input
|
||||
id="saleStartsAt"
|
||||
name="saleStartsAt"
|
||||
type="date"
|
||||
defaultValue={row.saleStartsAt ? row.saleStartsAt.toISOString().slice(0, 10) : ''}
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="saleEndsAt" className="tag-label mb-2 block">
|
||||
Sale ends (optional)
|
||||
</label>
|
||||
<input
|
||||
id="saleEndsAt"
|
||||
name="saleEndsAt"
|
||||
type="date"
|
||||
defaultValue={row.saleEndsAt ? row.saleEndsAt.toISOString().slice(0, 10) : ''}
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="personalisedPrice" className="tag-label mb-2 block">
|
||||
Personalised price (£, optional)
|
||||
</label>
|
||||
<input
|
||||
id="personalisedPrice"
|
||||
name="personalisedPrice"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
defaultValue={row.personalisedPrice != null ? (row.personalisedPrice / 100).toFixed(2) : ''}
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
placeholder="Leave blank to use the regular price"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
Higher price for made-to-order items. Leave blank to use the same price as ready-made.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="tag-label mb-2 block">Front photo</label>
|
||||
{product.imageUrl && (
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<img src={product.imageUrl} alt={product.name} className="h-20 w-20 border border-line object-contain" />
|
||||
<p className="text-xs text-muted">Current photo. Upload a new one below to replace it.</p>
|
||||
</div>
|
||||
)}
|
||||
<PhotoPrintAreaField />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="tag-label mb-2 block">Back photo</label>
|
||||
{product.imageUrlBack && (
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<img
|
||||
src={product.imageUrlBack}
|
||||
alt={`${product.name} back`}
|
||||
className="h-20 w-20 border border-line object-contain"
|
||||
/>
|
||||
<p className="text-xs text-muted">Current back photo and print area. Upload a new one below to replace both.</p>
|
||||
</div>
|
||||
)}
|
||||
<PhotoPrintAreaField variant="back" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="photoRecolorable"
|
||||
value="1"
|
||||
defaultChecked={product.photoRecolorable}
|
||||
className="mt-1 h-4 w-4 accent-clay"
|
||||
/>
|
||||
<span>
|
||||
Tint this photo per color (fallback)
|
||||
<span className="mt-1 block text-xs text-muted">
|
||||
Only works well for black, white, or gray photos — the color is applied as a live
|
||||
tint. If you have real photos for specific colors, add them below instead — this
|
||||
only kicks in for colors that don't have their own photo.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="tag-label mb-2 block">Colors</label>
|
||||
<ColorPicker
|
||||
name="colors"
|
||||
defaultColors={product.colors}
|
||||
defaultColorPhotos={product.colorPhotos}
|
||||
defaultColorPhotosBack={product.colorPhotosBack}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="tag-label mb-2 block">Sizes (optional)</label>
|
||||
<SizePicker name="sizes" defaultSizes={product.sizes} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="description" className="tag-label mb-2 block">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows={3}
|
||||
defaultValue={product.description}
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
|
||||
Save changes
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { DEFAULT_PRINT_AREAS } from '@/lib/mockupDefaults';
|
||||
|
||||
function slugify(input: string) {
|
||||
return input
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
async function uniqueSlug(base: string) {
|
||||
let slug = base || 'product';
|
||||
let n = 2;
|
||||
while (await prisma.product.findUnique({ where: { slug } })) {
|
||||
slug = `${base}-${n}`;
|
||||
n += 1;
|
||||
}
|
||||
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.
|
||||
async function extractColorPhotos(
|
||||
formData: FormData,
|
||||
colors: string[],
|
||||
variant: 'front' | 'back' = 'front',
|
||||
): Promise<Record<string, string>> {
|
||||
const prefix = variant === 'front' ? 'colorPhoto__' : 'colorPhotoBack__';
|
||||
const result: Record<string, string> = {};
|
||||
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);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Shared by createProduct/updateProduct — empty inputs mean "no sale". The end
|
||||
// date is pushed to the end of that day so the sale stays active through it.
|
||||
function parseSaleFields(formData: FormData) {
|
||||
const salePriceInput = String(formData.get('salePrice') ?? '').trim();
|
||||
const startInput = String(formData.get('saleStartsAt') ?? '').trim();
|
||||
const endInput = String(formData.get('saleEndsAt') ?? '').trim();
|
||||
|
||||
return {
|
||||
salePrice: salePriceInput ? Math.round(Number(salePriceInput) * 100) : null,
|
||||
saleStartsAt: startInput ? new Date(startInput) : null,
|
||||
saleEndsAt: endInput ? new Date(`${endInput}T23:59:59`) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createProduct(formData: FormData) {
|
||||
const name = String(formData.get('name') ?? '').trim();
|
||||
const slugInput = String(formData.get('slug') ?? '').trim();
|
||||
const category = String(formData.get('category') ?? '');
|
||||
const description = String(formData.get('description') ?? '').trim();
|
||||
const priceDollars = Number(formData.get('price') ?? 0);
|
||||
const personalisedPriceDollars = String(formData.get('personalisedPrice') ?? '').trim();
|
||||
const mockup = String(formData.get('mockup') ?? 'tshirt');
|
||||
const colorsInput = String(formData.get('colors') ?? '');
|
||||
const sizesInput = String(formData.get('sizes') ?? '');
|
||||
const imageFile = formData.get('image') as File | null;
|
||||
const imageBackFile = formData.get('imageBack') as File | null;
|
||||
const photoRecolorable = String(formData.get('photoRecolorable') ?? '') === '1';
|
||||
const showOnPersonalised = String(formData.get('showOnPersonalised') ?? '') === '1';
|
||||
const showOnProducts = String(formData.get('showOnProducts') ?? '') === '1';
|
||||
const collectionIds = formData.getAll('collections').map((v) => String(v));
|
||||
|
||||
if (!name || !category || !priceDollars) {
|
||||
throw new Error('Name, category, and price are required.');
|
||||
}
|
||||
|
||||
const colors = colorsInput
|
||||
.split(',')
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (colors.length === 0) {
|
||||
throw new Error('At least one color is required.');
|
||||
}
|
||||
|
||||
const sizes = sizesInput
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const imageUrl = imageFile && imageFile.size > 0 ? await fileToDataUrl(imageFile) : null;
|
||||
const imageUrlBack = imageBackFile && imageBackFile.size > 0 ? await fileToDataUrl(imageBackFile) : null;
|
||||
const colorPhotos = await extractColorPhotos(formData, colors, 'front');
|
||||
const colorPhotosBack = await extractColorPhotos(formData, colors, 'back');
|
||||
const saleFields = parseSaleFields(formData);
|
||||
|
||||
const slug = await uniqueSlug(slugify(slugInput || name));
|
||||
|
||||
const hasCustomPrintArea = String(formData.get('hasCustomPrintArea') ?? '') === '1' && imageUrl;
|
||||
const printArea = hasCustomPrintArea
|
||||
? {
|
||||
xPct: Number(formData.get('printX') ?? 0.3),
|
||||
yPct: Number(formData.get('printY') ?? 0.25),
|
||||
wPct: Number(formData.get('printW') ?? 0.4),
|
||||
hPct: Number(formData.get('printH') ?? 0.35),
|
||||
}
|
||||
: DEFAULT_PRINT_AREAS[mockup] ?? DEFAULT_PRINT_AREAS.tshirt;
|
||||
|
||||
const hasCustomPrintAreaBack = String(formData.get('hasCustomPrintAreaBack') ?? '') === '1' && imageUrlBack;
|
||||
const printAreaBack = hasCustomPrintAreaBack
|
||||
? {
|
||||
xPct: Number(formData.get('printXBack') ?? 0.3),
|
||||
yPct: Number(formData.get('printYBack') ?? 0.25),
|
||||
wPct: Number(formData.get('printWBack') ?? 0.4),
|
||||
hPct: Number(formData.get('printHBack') ?? 0.35),
|
||||
}
|
||||
: null;
|
||||
|
||||
await prisma.product.create({
|
||||
data: {
|
||||
slug,
|
||||
name,
|
||||
category,
|
||||
description: description || `${name} — personalize it your way.`,
|
||||
basePrice: Math.round(priceDollars * 100),
|
||||
personalisedPrice: personalisedPriceDollars ? Math.round(Number(personalisedPriceDollars) * 100) : null,
|
||||
...saleFields,
|
||||
colors: JSON.stringify(colors),
|
||||
colorPhotos: Object.keys(colorPhotos).length > 0 ? JSON.stringify(colorPhotos) : null,
|
||||
colorPhotosBack: Object.keys(colorPhotosBack).length > 0 ? JSON.stringify(colorPhotosBack) : null,
|
||||
sizes: sizes.length > 0 ? JSON.stringify(sizes) : null,
|
||||
mockup,
|
||||
printArea: JSON.stringify(printArea),
|
||||
printAreaBack: printAreaBack ? JSON.stringify(printAreaBack) : null,
|
||||
imageUrl,
|
||||
imageUrlBack,
|
||||
photoRecolorable,
|
||||
showOnPersonalised,
|
||||
showOnProducts,
|
||||
collections: { connect: collectionIds.map((id) => ({ id })) },
|
||||
},
|
||||
});
|
||||
|
||||
redirect('/admin/products');
|
||||
}
|
||||
|
||||
export async function updateProduct(formData: FormData) {
|
||||
const id = String(formData.get('id') ?? '');
|
||||
const name = String(formData.get('name') ?? '').trim();
|
||||
const category = String(formData.get('category') ?? '');
|
||||
const description = String(formData.get('description') ?? '').trim();
|
||||
const priceDollars = Number(formData.get('price') ?? 0);
|
||||
const personalisedPriceDollars = String(formData.get('personalisedPrice') ?? '').trim();
|
||||
const mockup = String(formData.get('mockup') ?? 'tshirt');
|
||||
const colorsInput = String(formData.get('colors') ?? '');
|
||||
const sizesInput = String(formData.get('sizes') ?? '');
|
||||
const imageFile = formData.get('image') as File | null;
|
||||
const imageBackFile = formData.get('imageBack') as File | null;
|
||||
const photoRecolorable = String(formData.get('photoRecolorable') ?? '') === '1';
|
||||
const showOnPersonalised = String(formData.get('showOnPersonalised') ?? '') === '1';
|
||||
const showOnProducts = String(formData.get('showOnProducts') ?? '') === '1';
|
||||
const collectionIds = formData.getAll('collections').map((v) => String(v));
|
||||
|
||||
if (!id) throw new Error('Missing product id.');
|
||||
if (!name || !category || !priceDollars) {
|
||||
throw new Error('Name, category, and price are required.');
|
||||
}
|
||||
|
||||
const colors = colorsInput
|
||||
.split(',')
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (colors.length === 0) {
|
||||
throw new Error('At least one color is required.');
|
||||
}
|
||||
|
||||
const sizes = sizesInput
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const existing = await prisma.product.findUnique({ where: { id } });
|
||||
if (!existing) throw new Error('Product not found.');
|
||||
|
||||
const existingColorPhotos: Record<string, string> = existing.colorPhotos
|
||||
? JSON.parse(existing.colorPhotos)
|
||||
: {};
|
||||
const existingColorPhotosBack: Record<string, string> = existing.colorPhotosBack
|
||||
? JSON.parse(existing.colorPhotosBack)
|
||||
: {};
|
||||
const newColorPhotos = await extractColorPhotos(formData, colors, 'front');
|
||||
const newColorPhotosBack = await extractColorPhotos(formData, colors, 'back');
|
||||
// Merge: newly uploaded photos override, everything else keeps whatever was
|
||||
// already saved (and only for colors still in the list).
|
||||
const mergedColorPhotos: Record<string, string> = {};
|
||||
const mergedColorPhotosBack: Record<string, string> = {};
|
||||
for (const hex of colors) {
|
||||
if (newColorPhotos[hex]) mergedColorPhotos[hex] = newColorPhotos[hex];
|
||||
else if (existingColorPhotos[hex]) mergedColorPhotos[hex] = existingColorPhotos[hex];
|
||||
|
||||
if (newColorPhotosBack[hex]) mergedColorPhotosBack[hex] = newColorPhotosBack[hex];
|
||||
else if (existingColorPhotosBack[hex]) mergedColorPhotosBack[hex] = existingColorPhotosBack[hex];
|
||||
}
|
||||
|
||||
const data: {
|
||||
name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
basePrice: number;
|
||||
personalisedPrice: number | null;
|
||||
salePrice: number | null;
|
||||
saleStartsAt: Date | null;
|
||||
saleEndsAt: Date | null;
|
||||
colors: string;
|
||||
colorPhotos: string | null;
|
||||
colorPhotosBack: string | null;
|
||||
sizes: string | null;
|
||||
mockup: string;
|
||||
photoRecolorable: boolean;
|
||||
showOnPersonalised: boolean;
|
||||
showOnProducts: boolean;
|
||||
imageUrl?: string;
|
||||
imageUrlBack?: string;
|
||||
printArea?: string;
|
||||
printAreaBack?: string;
|
||||
} = {
|
||||
name,
|
||||
category,
|
||||
description: description || `${name} — personalize it your way.`,
|
||||
basePrice: Math.round(priceDollars * 100),
|
||||
personalisedPrice: personalisedPriceDollars ? Math.round(Number(personalisedPriceDollars) * 100) : null,
|
||||
...parseSaleFields(formData),
|
||||
colors: JSON.stringify(colors),
|
||||
colorPhotos: Object.keys(mergedColorPhotos).length > 0 ? JSON.stringify(mergedColorPhotos) : null,
|
||||
colorPhotosBack: Object.keys(mergedColorPhotosBack).length > 0 ? JSON.stringify(mergedColorPhotosBack) : null,
|
||||
sizes: sizes.length > 0 ? JSON.stringify(sizes) : null,
|
||||
mockup,
|
||||
photoRecolorable,
|
||||
showOnPersonalised,
|
||||
showOnProducts,
|
||||
};
|
||||
|
||||
// Only touch the front photo/print area if a new photo was actually uploaded —
|
||||
// otherwise leave whatever's already there alone.
|
||||
if (imageFile && imageFile.size > 0) {
|
||||
data.imageUrl = await fileToDataUrl(imageFile);
|
||||
|
||||
const hasCustomPrintArea = String(formData.get('hasCustomPrintArea') ?? '') === '1';
|
||||
data.printArea = JSON.stringify(
|
||||
hasCustomPrintArea
|
||||
? {
|
||||
xPct: Number(formData.get('printX') ?? 0.3),
|
||||
yPct: Number(formData.get('printY') ?? 0.25),
|
||||
wPct: Number(formData.get('printW') ?? 0.4),
|
||||
hPct: Number(formData.get('printH') ?? 0.35),
|
||||
}
|
||||
: DEFAULT_PRINT_AREAS[mockup] ?? DEFAULT_PRINT_AREAS.tshirt,
|
||||
);
|
||||
}
|
||||
|
||||
if (imageBackFile && imageBackFile.size > 0) {
|
||||
data.imageUrlBack = await fileToDataUrl(imageBackFile);
|
||||
|
||||
const hasCustomPrintAreaBack = String(formData.get('hasCustomPrintAreaBack') ?? '') === '1';
|
||||
if (hasCustomPrintAreaBack) {
|
||||
data.printAreaBack = JSON.stringify({
|
||||
xPct: Number(formData.get('printXBack') ?? 0.3),
|
||||
yPct: Number(formData.get('printYBack') ?? 0.25),
|
||||
wPct: Number(formData.get('printWBack') ?? 0.4),
|
||||
hPct: Number(formData.get('printHBack') ?? 0.35),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.product.update({
|
||||
where: { id },
|
||||
data: { ...data, collections: { set: collectionIds.map((cid) => ({ id: cid })) } },
|
||||
});
|
||||
|
||||
redirect('/admin/products');
|
||||
}
|
||||
|
||||
export async function deleteProduct(formData: FormData) {
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await prisma.product.delete({ where: { id } });
|
||||
} catch {
|
||||
// Product has existing orders/design proofs tied to it — deleting would
|
||||
// orphan that history, so we block it instead of cascading.
|
||||
redirect('/admin/products?error=in_use');
|
||||
}
|
||||
|
||||
redirect('/admin/products');
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { createProduct } from '../actions';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import ColorPicker from '@/components/ColorPicker';
|
||||
import SizePicker from '@/components/SizePicker';
|
||||
import PhotoPrintAreaField from '@/components/PhotoPrintAreaField';
|
||||
import { MOCKUP_OPTIONS } from '@/lib/mockupDefaults';
|
||||
|
||||
export default async function NewProductPage() {
|
||||
const categories = await prisma.category.findMany({ orderBy: { name: 'asc' } });
|
||||
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');
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-12">
|
||||
<AdminNav />
|
||||
<h1 className="font-display text-3xl">Add a product</h1>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
Choose which catalog(s) this shows up in below — the personalized one where customers
|
||||
design their own version, the ready-made one where they just buy it as pictured, or both.
|
||||
</p>
|
||||
|
||||
<form action={createProduct} className="mt-10 space-y-6" encType="multipart/form-data">
|
||||
<div>
|
||||
<label className="tag-label mb-2 block">Show on</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="showOnPersonalised" value="1" defaultChecked className="h-4 w-4 accent-clay" />
|
||||
Personalised catalog (/made-to-order) — customers design their own
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="showOnProducts" value="1" className="h-4 w-4 accent-clay" />
|
||||
Products catalog (/products) — same photos, browse and buy as-is
|
||||
</label>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted">Check both to list the same product on both pages.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="name" className="tag-label mb-2 block">
|
||||
Product name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
placeholder="Classic Tee"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="slug" className="tag-label mb-2 block">
|
||||
URL slug (optional)
|
||||
</label>
|
||||
<input
|
||||
id="slug"
|
||||
name="slug"
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
placeholder="Leave blank to generate from the name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="category" className="tag-label mb-2 block">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
name="category"
|
||||
required
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.slug}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<a href="/admin/categories/new" className="mt-1 inline-block text-xs text-clay hover:text-clay-dark">
|
||||
+ Add a new category
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="mockup" className="tag-label mb-2 block">
|
||||
Shape (used if no photo)
|
||||
</label>
|
||||
<select
|
||||
id="mockup"
|
||||
name="mockup"
|
||||
required
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
>
|
||||
{MOCKUP_OPTIONS.map((m) => (
|
||||
<option key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(occasions.length > 0 || recipients.length > 0) && (
|
||||
<div>
|
||||
<label className="tag-label mb-2 block">Collections (optional)</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="mb-2 text-xs text-muted">Occasions</p>
|
||||
<div className="space-y-1.5">
|
||||
{occasions.map((c) => (
|
||||
<label key={c.id} className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="collections" value={c.id} className="h-4 w-4 accent-clay" />
|
||||
{c.name}
|
||||
</label>
|
||||
))}
|
||||
{occasions.length === 0 && <p className="text-xs text-muted">None yet.</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-xs text-muted">Gifts for</p>
|
||||
<div className="space-y-1.5">
|
||||
{recipients.map((c) => (
|
||||
<label key={c.id} className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="collections" value={c.id} className="h-4 w-4 accent-clay" />
|
||||
{c.name}
|
||||
</label>
|
||||
))}
|
||||
{recipients.length === 0 && <p className="text-xs text-muted">None yet.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/admin/collections/new" className="mt-1 inline-block text-xs text-clay hover:text-clay-dark">
|
||||
+ Add a new collection
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="price" className="tag-label mb-2 block">
|
||||
Price (£)
|
||||
</label>
|
||||
<input
|
||||
id="price"
|
||||
name="price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
required
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
placeholder="29.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="salePrice" className="tag-label mb-2 block">
|
||||
Sale price (£, optional)
|
||||
</label>
|
||||
<input
|
||||
id="salePrice"
|
||||
name="salePrice"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
placeholder="Leave blank for no sale"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
Set a start/end date below too — outside that window the normal price shows instead.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="saleStartsAt" className="tag-label mb-2 block">
|
||||
Sale starts (optional)
|
||||
</label>
|
||||
<input
|
||||
id="saleStartsAt"
|
||||
name="saleStartsAt"
|
||||
type="date"
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="saleEndsAt" className="tag-label mb-2 block">
|
||||
Sale ends (optional)
|
||||
</label>
|
||||
<input
|
||||
id="saleEndsAt"
|
||||
name="saleEndsAt"
|
||||
type="date"
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="personalisedPrice" className="tag-label mb-2 block">
|
||||
Personalised price (£, optional)
|
||||
</label>
|
||||
<input
|
||||
id="personalisedPrice"
|
||||
name="personalisedPrice"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
placeholder="Leave blank to use the regular price"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
Higher price for made-to-order items. Leave blank to use the same price as ready-made.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="tag-label mb-2 block">Front photo (optional)</label>
|
||||
<PhotoPrintAreaField />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="tag-label mb-2 block">Back photo (optional)</label>
|
||||
<PhotoPrintAreaField variant="back" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input type="checkbox" name="photoRecolorable" value="1" className="mt-1 h-4 w-4 accent-clay" />
|
||||
<span>
|
||||
Tint this photo per color (fallback)
|
||||
<span className="mt-1 block text-xs text-muted">
|
||||
Only works well for black, white, or gray photos — the color is applied as a live
|
||||
tint. If you have real photos for specific colors, add them below instead — this
|
||||
only kicks in for colors that don't have their own photo.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="tag-label mb-2 block">Colors</label>
|
||||
<ColorPicker name="colors" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="tag-label mb-2 block">Sizes (optional)</label>
|
||||
<SizePicker name="sizes" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="description" className="tag-label mb-2 block">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows={3}
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
placeholder="Heavyweight combed cotton, printed dead-center on the chest."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
|
||||
Add product
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Link from 'next/link';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { toProductDTO } from '@/lib/product';
|
||||
import { fetchActivePromotions } from '@/lib/promotions';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import ProductMockup from '@/components/ProductMockup';
|
||||
import { deleteProduct } from './actions';
|
||||
|
||||
export default async function AdminProductsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { error?: string };
|
||||
}) {
|
||||
const activePromotions = await fetchActivePromotions();
|
||||
const rows = await prisma.product.findMany({ orderBy: { createdAt: 'desc' } });
|
||||
const products = rows.map((p) => toProductDTO(p, activePromotions));
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-6 py-12">
|
||||
<AdminNav />
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h1 className="font-display text-3xl">Products</h1>
|
||||
<Link
|
||||
href="/admin/products/new"
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
+ Add product
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{searchParams.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>
|
||||
)}
|
||||
|
||||
{products.length === 0 ? (
|
||||
<p className="mt-10 text-sm text-muted">No products yet.</p>
|
||||
) : (
|
||||
<div className="mt-8 divide-y divide-line border-t border-line">
|
||||
{products.map((p) => (
|
||||
<div key={p.id} className="flex items-center gap-4 py-4">
|
||||
<div className="h-16 w-16 border border-line bg-surface p-1">
|
||||
{(p.colorPhotos[p.colors[0]] ?? p.imageUrl) ? (
|
||||
<img
|
||||
src={p.colorPhotos[p.colors[0]] ?? p.imageUrl!}
|
||||
alt={p.name}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<ProductMockup mockup={p.mockup} color={p.colors[0]} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{p.name}</p>
|
||||
<p className="text-sm text-muted">
|
||||
{p.category.replace('_', ' ')} · £${(p.basePrice / 100).toFixed(2)} · {p.colors.length}{' '}
|
||||
color{p.colors.length === 1 ? '' : 's'}
|
||||
{p.sizes.length > 0 && ` · ${p.sizes.join('/')}`}
|
||||
</p>
|
||||
</div>
|
||||
<Link href={`/admin/products/${p.id}/edit`} className="tag-label hover:text-ink">
|
||||
Edit →
|
||||
</Link>
|
||||
<Link href={`/made-to-order/${p.slug}`} className="tag-label hover:text-ink">
|
||||
View →
|
||||
</Link>
|
||||
<form action={deleteProduct}>
|
||||
<input type="hidden" name="id" value={p.id} />
|
||||
<button type="submit" className="tag-label text-muted hover:text-splash-pink">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import PromotionScopePicker from '@/components/PromotionScopePicker';
|
||||
import { updatePromotion } from '../../actions';
|
||||
|
||||
export default async function EditPromotionPage({ params }: { params: { id: string } }) {
|
||||
const [promotion, products] = await Promise.all([
|
||||
prisma.promotion.findUnique({ where: { id: params.id }, include: { products: { select: { id: true } } } }),
|
||||
prisma.product.findMany({ orderBy: { name: 'asc' } }),
|
||||
]);
|
||||
if (!promotion) notFound();
|
||||
|
||||
const selectedIds = new Set(promotion.products.map((p) => p.id));
|
||||
const toDateInput = (d: Date | null) => (d ? d.toISOString().slice(0, 10) : '');
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-12">
|
||||
<AdminNav />
|
||||
<h1 className="font-display text-3xl">Edit promotion</h1>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
Shown as a banner at the top of every page while its date window is active, and actually
|
||||
discounts the price of whatever it covers — every item, or specific ones you pick.
|
||||
</p>
|
||||
|
||||
<form action={updatePromotion} className="mt-10 space-y-6">
|
||||
<input type="hidden" name="id" value={promotion.id} />
|
||||
|
||||
<div>
|
||||
<label htmlFor="message" className="tag-label mb-2 block">
|
||||
Banner message
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
required
|
||||
rows={2}
|
||||
defaultValue={promotion.message}
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="discountPercent" className="tag-label mb-2 block">
|
||||
Discount (%)
|
||||
</label>
|
||||
<input
|
||||
id="discountPercent"
|
||||
name="discountPercent"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
required
|
||||
defaultValue={promotion.discountPercent}
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PromotionScopePicker
|
||||
products={products}
|
||||
defaultScope={promotion.scope as 'ALL' | 'SPECIFIC'}
|
||||
defaultSelectedIds={[...selectedIds]}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="startsAt" className="tag-label mb-2 block">
|
||||
Starts (optional)
|
||||
</label>
|
||||
<input
|
||||
id="startsAt"
|
||||
name="startsAt"
|
||||
type="date"
|
||||
defaultValue={toDateInput(promotion.startsAt)}
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted">Leave blank to start immediately.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="endsAt" className="tag-label mb-2 block">
|
||||
Ends (optional)
|
||||
</label>
|
||||
<input
|
||||
id="endsAt"
|
||||
name="endsAt"
|
||||
type="date"
|
||||
defaultValue={toDateInput(promotion.endsAt)}
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted">Leave blank for no end date.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
|
||||
Save changes
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function createPromotion(formData: FormData) {
|
||||
const message = String(formData.get('message') ?? '').trim();
|
||||
const discountPercent = Math.round(Number(formData.get('discountPercent') ?? 0));
|
||||
const scope = String(formData.get('scope') ?? 'ALL') === 'SPECIFIC' ? 'SPECIFIC' : 'ALL';
|
||||
const productIds = formData.getAll('productIds').map(String).filter(Boolean);
|
||||
const startInput = String(formData.get('startsAt') ?? '').trim();
|
||||
const endInput = String(formData.get('endsAt') ?? '').trim();
|
||||
|
||||
if (!message) throw new Error('A banner message is required.');
|
||||
if (!discountPercent || discountPercent < 1 || discountPercent > 100) {
|
||||
throw new Error('Discount must be between 1 and 100 percent.');
|
||||
}
|
||||
if (scope === 'SPECIFIC' && productIds.length === 0) {
|
||||
throw new Error('Pick at least one product, or choose "All items" instead.');
|
||||
}
|
||||
|
||||
await prisma.promotion.create({
|
||||
data: {
|
||||
message,
|
||||
discountPercent,
|
||||
scope,
|
||||
startsAt: startInput ? new Date(startInput) : null,
|
||||
endsAt: endInput ? new Date(`${endInput}T23:59:59`) : null,
|
||||
products: scope === 'SPECIFIC' ? { connect: productIds.map((id) => ({ id })) } : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
redirect('/admin/promotions');
|
||||
}
|
||||
|
||||
export async function updatePromotion(formData: FormData) {
|
||||
const id = String(formData.get('id') ?? '');
|
||||
const message = String(formData.get('message') ?? '').trim();
|
||||
const discountPercent = Math.round(Number(formData.get('discountPercent') ?? 0));
|
||||
const scope = String(formData.get('scope') ?? 'ALL') === 'SPECIFIC' ? 'SPECIFIC' : 'ALL';
|
||||
const productIds = formData.getAll('productIds').map(String).filter(Boolean);
|
||||
const startInput = String(formData.get('startsAt') ?? '').trim();
|
||||
const endInput = String(formData.get('endsAt') ?? '').trim();
|
||||
|
||||
if (!id) throw new Error('Missing promotion id.');
|
||||
if (!message) throw new Error('A banner message is required.');
|
||||
if (!discountPercent || discountPercent < 1 || discountPercent > 100) {
|
||||
throw new Error('Discount must be between 1 and 100 percent.');
|
||||
}
|
||||
if (scope === 'SPECIFIC' && productIds.length === 0) {
|
||||
throw new Error('Pick at least one product, or choose "All items" instead.');
|
||||
}
|
||||
|
||||
await prisma.promotion.update({
|
||||
where: { id },
|
||||
data: {
|
||||
message,
|
||||
discountPercent,
|
||||
scope,
|
||||
startsAt: startInput ? new Date(startInput) : null,
|
||||
endsAt: endInput ? new Date(`${endInput}T23:59:59`) : null,
|
||||
// `set` replaces the full connection list in one go — correctly handles
|
||||
// switching scope (SPECIFIC -> ALL clears it) and adding/removing products.
|
||||
products: { set: scope === 'SPECIFIC' ? productIds.map((pid) => ({ id: pid })) : [] },
|
||||
},
|
||||
});
|
||||
|
||||
redirect('/admin/promotions');
|
||||
}
|
||||
|
||||
export async function deletePromotion(formData: FormData) {
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
|
||||
await prisma.promotion.delete({ where: { id } });
|
||||
redirect('/admin/promotions');
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import PromotionScopePicker from '@/components/PromotionScopePicker';
|
||||
import { createPromotion } from '../actions';
|
||||
|
||||
export default async function NewPromotionPage() {
|
||||
const products = await prisma.product.findMany({ orderBy: { name: 'asc' } });
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-12">
|
||||
<AdminNav />
|
||||
<h1 className="font-display text-3xl">Add a promotion</h1>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
Shown as a banner at the top of every page while its date window is active, and actually
|
||||
discounts the price of whatever it covers — every item, or specific ones you pick.
|
||||
</p>
|
||||
|
||||
<form action={createPromotion} className="mt-10 space-y-6">
|
||||
<div>
|
||||
<label htmlFor="message" className="tag-label mb-2 block">
|
||||
Banner message
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
required
|
||||
rows={2}
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
placeholder="20% off everything this weekend!"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="discountPercent" className="tag-label mb-2 block">
|
||||
Discount (%)
|
||||
</label>
|
||||
<input
|
||||
id="discountPercent"
|
||||
name="discountPercent"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
required
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
placeholder="20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PromotionScopePicker products={products} />
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="startsAt" className="tag-label mb-2 block">
|
||||
Starts (optional)
|
||||
</label>
|
||||
<input
|
||||
id="startsAt"
|
||||
name="startsAt"
|
||||
type="date"
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted">Leave blank to start immediately.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="endsAt" className="tag-label mb-2 block">
|
||||
Ends (optional)
|
||||
</label>
|
||||
<input
|
||||
id="endsAt"
|
||||
name="endsAt"
|
||||
type="date"
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted">Leave blank for no end date.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
|
||||
Add promotion
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import Link from 'next/link';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import { deletePromotion } from './actions';
|
||||
|
||||
function statusFor(promotion: { startsAt: Date | null; endsAt: Date | null }, now: Date) {
|
||||
if (promotion.startsAt && promotion.startsAt > now) return { label: 'Scheduled', className: 'bg-splash-orange/10 text-splash-orange' };
|
||||
if (promotion.endsAt && promotion.endsAt < now) return { label: 'Ended', className: 'bg-muted/10 text-muted' };
|
||||
return { label: 'Active now', className: 'bg-splash-blue/10 text-splash-blue' };
|
||||
}
|
||||
|
||||
function formatDate(d: Date | null) {
|
||||
if (!d) return null;
|
||||
return d.toLocaleDateString(undefined, { dateStyle: 'medium' });
|
||||
}
|
||||
|
||||
export default async function PromotionsPage() {
|
||||
const promotions = await prisma.promotion.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { products: { select: { id: true } } },
|
||||
});
|
||||
const now = new Date();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-12">
|
||||
<AdminNav />
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h1 className="font-display text-3xl">Promotions</h1>
|
||||
<Link
|
||||
href="/admin/promotions/new"
|
||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||
>
|
||||
+ Add promotion
|
||||
</Link>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
A banner shown at the top of every page while its date window is active. If more than one
|
||||
is active at once, the most recently created one shows.
|
||||
</p>
|
||||
|
||||
{promotions.length === 0 ? (
|
||||
<p className="mt-8 text-sm text-muted">No promotions yet.</p>
|
||||
) : (
|
||||
<div className="mt-8 divide-y divide-line border-t border-line">
|
||||
{promotions.map((p) => {
|
||||
const status = statusFor(p, now);
|
||||
return (
|
||||
<div key={p.id} className="flex items-center gap-4 py-4">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{p.message}</p>
|
||||
<p className="tag-label mt-0.5">
|
||||
{p.discountPercent}% off — {p.scope === 'ALL' ? 'All items' : `${p.products.length} item${p.products.length === 1 ? '' : 's'}`}
|
||||
{' · '}
|
||||
{formatDate(p.startsAt) ?? 'Starts immediately'} — {formatDate(p.endsAt) ?? 'no end date'}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`tag-label rounded-full px-3 py-1 ${status.className}`}>{status.label}</span>
|
||||
<Link href={`/admin/promotions/${p.id}/edit`} className="tag-label hover:text-clay">
|
||||
Edit
|
||||
</Link>
|
||||
<form action={deletePromotion}>
|
||||
<input type="hidden" name="id" value={p.id} />
|
||||
<button type="submit" className="tag-label text-muted hover:text-splash-pink">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import ShareButtons from '@/components/ShareButtons';
|
||||
|
||||
export default async function ProofSentPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: { id: string };
|
||||
searchParams: { emailed?: string };
|
||||
}) {
|
||||
const proof = await prisma.designProof.findUnique({
|
||||
where: { id: params.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';
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl px-6 py-16 text-center">
|
||||
<p className="tag-label text-splash-pink">Design uploaded</p>
|
||||
<h1 className="mt-2 font-display text-3xl">
|
||||
{emailed ? 'Sent to your customer' : 'Ready to send'}
|
||||
</h1>
|
||||
|
||||
{emailed ? (
|
||||
<p className="mt-3 text-sm text-muted">
|
||||
We emailed {proof.customerEmail} a link to review and approve this design. You can also send
|
||||
it another way below.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-3 border border-splash-orange bg-splash-orange/5 p-4 text-left text-sm">
|
||||
<p className="font-medium text-ink">Email wasn't sent automatically.</p>
|
||||
<p className="mt-1 text-muted">
|
||||
This usually means SMTP isn't configured yet in <code className="font-mono">.env</code> (see
|
||||
the README's "Sending emails" section). Use one of the options below instead.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 border border-line bg-surface p-6">
|
||||
{proof.imageDataUrl ? (
|
||||
<img
|
||||
src={proof.imageDataUrl}
|
||||
alt="Uploaded design"
|
||||
className="mx-auto max-h-64 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted">No image was saved with this upload.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ShareButtons
|
||||
message={`Here's your ${proof.product.name} design to review and approve: ${link}`}
|
||||
link={link}
|
||||
redirectUri={link}
|
||||
/>
|
||||
|
||||
<Link href="/admin/proofs/new" className="mt-8 inline-block tag-label hover:text-ink">
|
||||
← Upload another design
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function deleteProof(formData: FormData) {
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
|
||||
// Chat messages cascade-delete automatically (set up on the Message relation).
|
||||
await prisma.designProof.delete({ where: { id } });
|
||||
|
||||
redirect('/admin/proofs');
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { sendProofEmail } from '@/lib/mail';
|
||||
|
||||
export async function createProof(formData: FormData) {
|
||||
const productId = String(formData.get('productId') ?? '');
|
||||
const customerName = String(formData.get('customerName') ?? '').trim();
|
||||
const customerEmail = String(formData.get('customerEmail') ?? '').trim();
|
||||
const color = String(formData.get('color') ?? '');
|
||||
const quantity = Math.max(1, Number(formData.get('quantity') ?? 1));
|
||||
const note = String(formData.get('note') ?? '').trim();
|
||||
const priceOverride = formData.get('price');
|
||||
const file = formData.get('image') as File | null;
|
||||
|
||||
if (!productId || !customerEmail || !color || !file || file.size === 0) {
|
||||
throw new Error('Missing required fields: product, customer email, color, and an image are all required.');
|
||||
}
|
||||
|
||||
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 unitPrice = priceOverride && String(priceOverride).trim() !== ''
|
||||
? Math.round(Number(priceOverride) * 100)
|
||||
: product.basePrice;
|
||||
|
||||
const proof = await prisma.designProof.create({
|
||||
data: {
|
||||
productId,
|
||||
customerName: customerName || null,
|
||||
customerEmail,
|
||||
color,
|
||||
quantity,
|
||||
unitPrice,
|
||||
imageDataUrl,
|
||||
note: note || null,
|
||||
},
|
||||
});
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
|
||||
const link = `${siteUrl}/proofs/${proof.id}`;
|
||||
|
||||
const result = await sendProofEmail({
|
||||
to: customerEmail,
|
||||
customerName: customerName || null,
|
||||
link,
|
||||
productName: product.name,
|
||||
note: note || null,
|
||||
});
|
||||
|
||||
redirect(`/admin/proofs/${proof.id}/sent?emailed=${result.sent ? '1' : '0'}`);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { createProof } from './actions';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
|
||||
export default async function NewProofPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { productId?: string; customerName?: string; customerEmail?: string };
|
||||
}) {
|
||||
const products = await prisma.product.findMany({ orderBy: { name: 'asc' } });
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-12">
|
||||
<AdminNav />
|
||||
<h1 className="font-display text-3xl">Upload a design for approval</h1>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
Upload a finished mockup for one customer. We'll give you a link to send them — they view
|
||||
it, approve it, and it goes straight to their cart.
|
||||
</p>
|
||||
|
||||
<form action={createProof} className="mt-10 space-y-6">
|
||||
<div>
|
||||
<label htmlFor="productId" className="tag-label mb-2 block">
|
||||
Product template
|
||||
</label>
|
||||
<select
|
||||
id="productId"
|
||||
name="productId"
|
||||
required
|
||||
defaultValue={searchParams.productId}
|
||||
className="w-full border border-line px-3 py-2 text-sm"
|
||||
>
|
||||
{products.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="customerName" className="tag-label mb-2 block">
|
||||
Customer name (optional)
|
||||
</label>
|
||||
<input
|
||||
id="customerName"
|
||||
name="customerName"
|
||||
defaultValue={searchParams.customerName}
|
||||
className="w-full border border-line px-3 py-2 text-sm"
|
||||
placeholder="Jane Doe"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="customerEmail" className="tag-label mb-2 block">
|
||||
Customer email
|
||||
</label>
|
||||
<input
|
||||
id="customerEmail"
|
||||
name="customerEmail"
|
||||
type="email"
|
||||
required
|
||||
defaultValue={searchParams.customerEmail}
|
||||
className="w-full border border-line px-3 py-2 text-sm"
|
||||
placeholder="jane@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label htmlFor="color" className="tag-label mb-2 block">
|
||||
Color shown
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="color"
|
||||
name="color"
|
||||
type="color"
|
||||
required
|
||||
defaultValue="#17181C"
|
||||
className="h-10 w-14 cursor-pointer border border-line bg-paper p-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="quantity" className="tag-label mb-2 block">
|
||||
Quantity
|
||||
</label>
|
||||
<input
|
||||
id="quantity"
|
||||
name="quantity"
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={1}
|
||||
className="w-full border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="price" className="tag-label mb-2 block">
|
||||
Price override (£)
|
||||
</label>
|
||||
<input
|
||||
id="price"
|
||||
name="price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
placeholder="Uses template price"
|
||||
className="w-full border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="image" className="tag-label mb-2 block">
|
||||
Finished design image
|
||||
</label>
|
||||
<input
|
||||
id="image"
|
||||
name="image"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
required
|
||||
className="w-full border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
Upload the actual finished mockup — this is shown to the customer exactly as-is.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="note" className="tag-label mb-2 block">
|
||||
Note to customer (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="note"
|
||||
name="note"
|
||||
rows={3}
|
||||
className="w-full border border-line px-3 py-2 text-sm"
|
||||
placeholder="Here's the design we talked about — let me know if you'd like anything changed."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
|
||||
Create approval link
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import Link from 'next/link';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import { deleteProof } from './actions';
|
||||
|
||||
function formatPrice(cents: number) {
|
||||
return `£${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
export default async function ProofsListPage() {
|
||||
const proofs = await prisma.designProof.findMany({
|
||||
include: { product: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-6 py-12">
|
||||
<AdminNav />
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h1 className="font-display text-3xl">Design approvals</h1>
|
||||
<Link href="/admin/proofs/new" className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper">
|
||||
+ Upload new design
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{proofs.length === 0 ? (
|
||||
<p className="mt-10 text-sm text-muted">Nothing uploaded yet.</p>
|
||||
) : (
|
||||
<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" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{p.customerName || p.customerEmail}</p>
|
||||
<p className="text-sm text-muted">
|
||||
{p.product.name} · qty {p.quantity} · {formatPrice(p.unitPrice * p.quantity)}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`tag-label rounded-full px-3 py-1 ${
|
||||
p.status === 'APPROVED' ? 'bg-splash-blue/10 text-splash-blue' : 'bg-splash-orange/10 text-splash-orange'
|
||||
}`}
|
||||
>
|
||||
{p.status}
|
||||
</span>
|
||||
<Link href={`/admin/inbox/${p.id}`} className="tag-label hover:text-ink">
|
||||
Chat →
|
||||
</Link>
|
||||
<Link href={`/proofs/${p.id}`} className="tag-label hover:text-ink">
|
||||
View →
|
||||
</Link>
|
||||
<form action={deleteProof}>
|
||||
<input type="hidden" name="id" value={p.id} />
|
||||
<button type="submit" className="tag-label text-muted hover:text-splash-pink">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { getCurrentCustomer } from '@/lib/auth';
|
||||
import { getEffectivePrice } from '@/lib/pricing';
|
||||
import { fetchActivePromotions } from '@/lib/promotions';
|
||||
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
|
||||
|
||||
type CheckoutCartItem = {
|
||||
productId: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
color: string;
|
||||
size: string | null;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
designJson: { front: unknown[]; back: unknown[] };
|
||||
designPreviewUrl: string;
|
||||
designPreviewUrlBack: string | null;
|
||||
placementPreviewUrl: string | null;
|
||||
placementPreviewUrlBack: string | null;
|
||||
};
|
||||
|
||||
export async function POST(req: Request) {
|
||||
if (!process.env.STRIPE_SECRET_KEY) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Stripe is not configured on this server yet. Add STRIPE_SECRET_KEY to .env.' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const ip = getClientIp(req.headers);
|
||||
const { allowed } = await checkRateLimit(`checkout:${ip}`, 20, 10 * 60 * 1000);
|
||||
if (!allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many requests — please wait a moment and try again.' },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const items: CheckoutCartItem[] = body.items ?? [];
|
||||
|
||||
if (items.length === 0) {
|
||||
return NextResponse.json({ error: 'Your bag is empty.' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Never trust the client-submitted unitPrice for the actual charge — look up
|
||||
// each product and recompute its current price (including any active sale)
|
||||
// server-side. The client's unitPrice is only used for display before this point.
|
||||
const products = await prisma.product.findMany({
|
||||
where: { id: { in: items.map((i) => i.productId) } },
|
||||
});
|
||||
const productsById = new Map(products.map((p) => [p.id, p]));
|
||||
|
||||
for (const i of items) {
|
||||
if (!productsById.has(i.productId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'One of the items in your bag is no longer available.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const activePromotions = await fetchActivePromotions();
|
||||
const pricedItems = items.map((i) => ({
|
||||
...i,
|
||||
unitPrice: getEffectivePrice(productsById.get(i.productId)!, activePromotions).price,
|
||||
}));
|
||||
|
||||
const total = pricedItems.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0);
|
||||
const customer = await getCurrentCustomer();
|
||||
|
||||
// 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.
|
||||
const order = await prisma.order.create({
|
||||
data: {
|
||||
status: 'PENDING',
|
||||
total,
|
||||
customerId: customer?.id,
|
||||
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,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
|
||||
|
||||
try {
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: 'payment',
|
||||
line_items: pricedItems.map((i) => ({
|
||||
price_data: {
|
||||
currency: 'gbp',
|
||||
unit_amount: i.unitPrice,
|
||||
product_data: {
|
||||
name: `${i.name} — ${i.color}${i.size ? `, ${i.size}` : ''}`,
|
||||
},
|
||||
},
|
||||
quantity: i.quantity,
|
||||
})),
|
||||
shipping_address_collection: {
|
||||
allowed_countries: ['GB', 'IE', 'US', 'CA', 'AU', 'NZ'],
|
||||
},
|
||||
customer_email: customer?.email,
|
||||
success_url: `${siteUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${siteUrl}/checkout/cancel`,
|
||||
metadata: { orderId: order.id },
|
||||
});
|
||||
|
||||
await prisma.order.update({
|
||||
where: { id: order.id },
|
||||
data: { stripeSessionId: session.id },
|
||||
});
|
||||
|
||||
return NextResponse.json({ url: session.url });
|
||||
} catch (err) {
|
||||
// Stripe call failed — don't leave an orphaned PENDING order with no way to pay it.
|
||||
await prisma.order.delete({ where: { id: order.id } });
|
||||
const message = err instanceof Error ? err.message : 'Could not start checkout.';
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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 } });
|
||||
if (!proof) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.designProof.update({
|
||||
where: { id: params.id },
|
||||
data: { status: 'APPROVED' },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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 } }) {
|
||||
const rows = await prisma.message.findMany({
|
||||
where: { proofId: params.id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
const messages: MessageDTO[] = rows.map((m) => ({
|
||||
id: m.id,
|
||||
proofId: m.proofId,
|
||||
sender: m.sender as MessageDTO['sender'],
|
||||
body: m.body,
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
return NextResponse.json(messages);
|
||||
}
|
||||
|
||||
export async function POST(req: Request, { params }: { params: { id: string } }) {
|
||||
const { sender, body } = await req.json();
|
||||
|
||||
if (sender !== 'ADMIN' && sender !== 'CUSTOMER') {
|
||||
return NextResponse.json({ error: 'Invalid sender' }, { status: 400 });
|
||||
}
|
||||
const trimmed = String(body ?? '').trim();
|
||||
if (!trimmed) {
|
||||
return NextResponse.json({ error: 'Message body is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const proof = await prisma.designProof.findUnique({ where: { id: params.id } });
|
||||
if (!proof) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const created = await prisma.message.create({
|
||||
data: { proofId: params.id, sender, body: trimmed },
|
||||
});
|
||||
|
||||
const message: MessageDTO = {
|
||||
id: created.id,
|
||||
proofId: created.proofId,
|
||||
sender: created.sender as MessageDTO['sender'],
|
||||
body: created.body,
|
||||
createdAt: created.createdAt.toISOString(),
|
||||
};
|
||||
|
||||
return NextResponse.json(message);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { upsertMailingListEntry } from '@/lib/mailingList';
|
||||
import { applyStockMovement } from '@/lib/stock';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const signature = req.headers.get('stripe-signature');
|
||||
const rawBody = await req.text();
|
||||
|
||||
if (!process.env.STRIPE_WEBHOOK_SECRET || !signature) {
|
||||
return NextResponse.json({ error: 'Webhook not configured.' }, { status: 400 });
|
||||
}
|
||||
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Invalid signature';
|
||||
return NextResponse.json({ error: `Webhook signature verification failed: ${message}` }, { status: 400 });
|
||||
}
|
||||
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
const orderId = session.metadata?.orderId;
|
||||
|
||||
if (orderId) {
|
||||
const shipping = (session as unknown as { shipping_details?: Stripe.Checkout.Session.ShippingDetails })
|
||||
.shipping_details;
|
||||
|
||||
// Stripe retries webhooks, so the same completed event can arrive more
|
||||
// than once — remember whether this is the first PENDING→PAID flip and
|
||||
// only move stock on that one.
|
||||
const before = await prisma.order.findUnique({ where: { id: orderId } });
|
||||
const firstTimePaid = before != null && before.status !== 'PAID';
|
||||
|
||||
const updated = await prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
status: 'PAID',
|
||||
email: session.customer_details?.email ?? undefined,
|
||||
shippingAddress: shipping ? JSON.stringify(shipping) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (firstTimePaid) {
|
||||
const items = await prisma.orderItem.findMany({ where: { orderId } });
|
||||
await applyStockMovement(items, -1);
|
||||
}
|
||||
|
||||
if (!updated.customerId && updated.email) {
|
||||
await upsertMailingListEntry({ email: updated.email, source: 'GUEST' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === 'checkout.session.expired' || event.type === 'checkout.session.async_payment_failed') {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
const orderId = session.metadata?.orderId;
|
||||
if (orderId) {
|
||||
await prisma.order.update({ where: { id: orderId }, data: { status: 'FAILED' } });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true });
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useCart } from '@/lib/cartStore';
|
||||
import Price from '@/components/Price';
|
||||
|
||||
export default function CartPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [checkingOut, setCheckingOut] = useState(false);
|
||||
const [checkoutError, setCheckoutError] = useState<string | null>(null);
|
||||
const items = useCart((s) => s.items);
|
||||
const removeItem = useCart((s) => s.removeItem);
|
||||
const setQuantity = useCart((s) => s.setQuantity);
|
||||
const total = useCart((s) => s.total());
|
||||
const hasCustomDesign = items.some((item) => item.designJson.front.length > 0 || item.designJson.back.length > 0);
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
const handleCheckout = async () => {
|
||||
setCheckingOut(true);
|
||||
setCheckoutError(null);
|
||||
try {
|
||||
const res = await fetch('/api/checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ items }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.url) {
|
||||
throw new Error(data.error ?? 'Something went wrong starting checkout.');
|
||||
}
|
||||
window.location.href = data.url;
|
||||
} catch (err) {
|
||||
setCheckoutError(err instanceof Error ? err.message : 'Something went wrong.');
|
||||
setCheckingOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!mounted) return null; // avoid a hydration mismatch on first paint
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-20 text-center">
|
||||
<h1 className="font-display text-4xl">Your bag</h1>
|
||||
<p className="mt-4 text-muted">Nothing in here yet.</p>
|
||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/made-to-order"
|
||||
className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark"
|
||||
>
|
||||
Start designing
|
||||
</Link>
|
||||
<Link
|
||||
href="/products"
|
||||
className="border-2 border-ink px-6 py-3 text-sm font-medium text-ink hover:border-clay hover:text-clay"
|
||||
>
|
||||
Shop ready-made
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-6 py-12">
|
||||
<h1 className="font-display text-4xl">Your bag</h1>
|
||||
|
||||
<div className="mt-8 divide-y divide-line border-t border-line">
|
||||
{items.map((item) => (
|
||||
<div key={item.cartItemId} className="flex flex-wrap items-center gap-4 py-6">
|
||||
<div className="h-24 w-24 shrink-0 border border-line bg-surface p-1">
|
||||
{item.placementPreviewUrl || item.designPreviewUrl ? (
|
||||
<img
|
||||
src={item.placementPreviewUrl ?? item.designPreviewUrl}
|
||||
alt={item.name}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-xs text-muted">No preview</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-[160px] flex-1">
|
||||
<p className="font-display text-lg">{item.name}</p>
|
||||
<div className="mt-1 flex items-center gap-3 text-sm text-muted">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-3.5 w-3.5 rounded-full border border-line"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
{item.color}
|
||||
</span>
|
||||
{item.size && <span>Size {item.size}</span>}
|
||||
</div>
|
||||
{(item.designJson.front.length > 0 || item.designJson.back.length > 0) && (
|
||||
<div className="mt-1">
|
||||
<p className="text-xs text-muted">
|
||||
Custom design
|
||||
{item.designJson.front.length > 0 && item.designJson.back.length > 0
|
||||
? ' — front & back'
|
||||
: item.designJson.back.length > 0
|
||||
? ' — back'
|
||||
: ' — front'}
|
||||
</p>
|
||||
<div className="mt-1 flex gap-3">
|
||||
{item.designJson.front.length > 0 && item.designPreviewUrl && (
|
||||
<a
|
||||
href={item.designPreviewUrl}
|
||||
download={`${item.slug}-front-print.png`}
|
||||
className="text-xs text-clay hover:text-clay-dark"
|
||||
>
|
||||
Download front PNG
|
||||
</a>
|
||||
)}
|
||||
{item.designJson.back.length > 0 && item.designPreviewUrlBack && (
|
||||
<a
|
||||
href={item.designPreviewUrlBack}
|
||||
download={`${item.slug}-back-print.png`}
|
||||
className="text-xs text-clay hover:text-clay-dark"
|
||||
>
|
||||
Download back PNG
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center border border-ink">
|
||||
<button
|
||||
onClick={() => setQuantity(item.cartItemId, item.quantity - 1)}
|
||||
className="px-3 py-2 text-sm"
|
||||
aria-label="Decrease quantity"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-10 text-center font-mono text-sm">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => setQuantity(item.cartItemId, item.quantity + 1)}
|
||||
className="px-3 py-2 text-sm"
|
||||
aria-label="Increase quantity"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-24 text-right font-mono text-sm">
|
||||
<Price cents={item.unitPrice * item.quantity} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => removeItem(item.cartItemId)}
|
||||
className="tag-label text-muted hover:text-splash-pink"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasCustomDesign && (
|
||||
<div className="mt-8 border border-splash-orange bg-splash-orange/5 px-4 py-3 text-sm">
|
||||
<p className="font-medium text-ink">Please check your design before checking out</p>
|
||||
<p className="mt-1 text-muted">
|
||||
We print exactly what's shown in your preview above — spelling, wording, and
|
||||
placement included. Once your order's placed, that design goes straight to print,
|
||||
so take a moment to make sure everything looks right.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex flex-col items-end gap-4">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="tag-label">Subtotal</span>
|
||||
<span className="font-mono text-xl">
|
||||
<Price cents={total} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-xs">
|
||||
<div className="mb-4 rounded border border-splash-blue/40 bg-splash-blue/5 px-3 py-2 text-xs text-muted">
|
||||
Your cart will be saved. You'll need to log in again to complete your order.
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCheckout}
|
||||
disabled={checkingOut}
|
||||
className="w-full bg-clay px-6 py-3 text-sm font-medium text-paper transition-colors hover:bg-clay-dark disabled:opacity-60"
|
||||
>
|
||||
{checkingOut ? 'Redirecting to checkout…' : 'Checkout'}
|
||||
</button>
|
||||
{checkoutError && <p className="mt-2 text-center text-xs text-splash-pink">{checkoutError}</p>}
|
||||
<p className="mt-2 text-center text-xs text-muted">You'll enter payment details on the next step.</p>
|
||||
<p className="mt-1 text-center text-xs text-muted">
|
||||
By placing an order you agree to our{' '}
|
||||
<Link href="/terms" className="text-clay hover:text-clay-dark">
|
||||
Terms
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link href="/returns" className="text-clay hover:text-clay-dark">
|
||||
Returns policy
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function CheckoutCancelPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-lg px-6 py-20 text-center">
|
||||
<p className="tag-label text-splash-orange">Checkout cancelled</p>
|
||||
<h1 className="mt-2 font-display text-4xl">No charge was made</h1>
|
||||
<p className="mt-4 text-muted">Your bag is still here, exactly as you left it.</p>
|
||||
<Link
|
||||
href="/cart"
|
||||
className="mt-8 inline-block bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark"
|
||||
>
|
||||
Back to your bag
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import Link from 'next/link';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import ClearCartOnMount from '@/components/ClearCartOnMount';
|
||||
import Price from '@/components/Price';
|
||||
|
||||
export default async function CheckoutSuccessPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { session_id?: string };
|
||||
}) {
|
||||
const sessionId = searchParams.session_id;
|
||||
const order = sessionId
|
||||
? await prisma.order.findUnique({
|
||||
where: { stripeSessionId: sessionId },
|
||||
include: { items: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-20 text-center">
|
||||
<ClearCartOnMount />
|
||||
<p className="tag-label text-splash-pink">Order confirmed</p>
|
||||
<h1 className="mt-2 font-display text-4xl">Thank you!</h1>
|
||||
|
||||
{!order ? (
|
||||
<p className="mt-4 text-muted">
|
||||
Your payment went through. We couldn't find the order details on this page, but
|
||||
you'll get a confirmation from Stripe by email.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-4 text-muted">
|
||||
{order.status === 'PAID'
|
||||
? "We've got your order and we'll get started on it."
|
||||
: "We're just confirming your payment — this updates automatically, no need to refresh."}
|
||||
</p>
|
||||
|
||||
<div className="mt-10 divide-y divide-line border-t border-line text-left">
|
||||
{order.items.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-4 py-4">
|
||||
<img
|
||||
src={item.placementPreviewUrl || item.designPreviewUrl || undefined}
|
||||
alt={item.productName}
|
||||
className="h-16 w-16 border border-line bg-surface object-contain"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{item.productName}</p>
|
||||
<p className="text-sm text-muted">
|
||||
{item.color}
|
||||
{item.size ? `, ${item.size}` : ''} · qty {item.quantity}
|
||||
</p>
|
||||
</div>
|
||||
<Price cents={item.unitPrice * item.quantity} className="font-mono text-sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="tag-label">Total</span>
|
||||
<span className="font-mono text-xl">
|
||||
<Price cents={order.total} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Link href="/" className="mt-10 inline-block tag-label hover:text-ink">
|
||||
← Back to home
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
'use server';
|
||||
|
||||
import { headers } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { sendContactMessageNotification } from '@/lib/mail';
|
||||
import { verifyTurnstileToken } from '@/lib/turnstile';
|
||||
import { getClientIp } from '@/lib/rateLimit';
|
||||
|
||||
export async function sendContactMessage(formData: FormData) {
|
||||
const name = String(formData.get('name') ?? '').trim();
|
||||
const email = String(formData.get('email') ?? '').trim();
|
||||
const message = String(formData.get('message') ?? '').trim();
|
||||
|
||||
if (!name || !email || !message) {
|
||||
redirect('/contact?error=missing');
|
||||
}
|
||||
|
||||
const ip = getClientIp(headers());
|
||||
const turnstileToken = String(formData.get('cf-turnstile-response') ?? '');
|
||||
const { success } = await verifyTurnstileToken(turnstileToken, ip);
|
||||
if (!success) {
|
||||
redirect('/contact?error=captcha');
|
||||
}
|
||||
|
||||
await sendContactMessageNotification({ name, email, message });
|
||||
|
||||
redirect('/contact?sent=1');
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { getCurrentCustomer } from '@/lib/auth';
|
||||
import { sendContactMessage } from './actions';
|
||||
import Turnstile from '@/components/Turnstile';
|
||||
|
||||
const ERROR_MESSAGES: Record<string, string> = {
|
||||
missing: 'Name, email, and a message are required.',
|
||||
captcha: 'CAPTCHA verification failed — please try again.',
|
||||
};
|
||||
|
||||
export default async function ContactPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { sent?: string; error?: string };
|
||||
}) {
|
||||
const customer = await getCurrentCustomer();
|
||||
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-12">
|
||||
<p className="tag-label text-splash-pink">Contact</p>
|
||||
<h1 className="mt-2 font-display text-3xl">Get in touch</h1>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
Question about an order, a template, or anything else? Send us a message and we'll get
|
||||
back to you.
|
||||
</p>
|
||||
|
||||
{searchParams.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>
|
||||
) : (
|
||||
<form action={sendContactMessage} className="mt-10 space-y-6">
|
||||
{error && (
|
||||
<p className="border border-splash-pink/40 bg-splash-pink/10 px-4 py-3 text-sm text-splash-pink">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="tag-label mb-2 block">
|
||||
Your name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
defaultValue={customer?.name ?? ''}
|
||||
className="w-full border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="tag-label mb-2 block">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
defaultValue={customer?.email ?? ''}
|
||||
className="w-full border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="message" className="tag-label mb-2 block">
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
required
|
||||
rows={5}
|
||||
className="w-full border border-line px-3 py-2 text-sm"
|
||||
placeholder="How can we help?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Turnstile />
|
||||
|
||||
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
|
||||
Send message
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import Link from 'next/link';
|
||||
import LegalPage from '@/components/LegalPage';
|
||||
|
||||
export const metadata = { title: 'Cookie Policy — Craft2Prints' };
|
||||
|
||||
export default function CookiesPage() {
|
||||
return (
|
||||
<LegalPage tag="Legal" title="Cookie Policy" lastUpdated="13 July 2026">
|
||||
<h2>The short version</h2>
|
||||
<p>
|
||||
We use two cookies, both strictly necessary to make logging in work. We use no advertising,
|
||||
analytics, or tracking cookies of any kind — which is why you don't see a cookie
|
||||
consent banner on this site: UK law only requires consent for cookies that aren't
|
||||
essential, and ours all are.
|
||||
</p>
|
||||
|
||||
<h2>The cookies we set</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line">
|
||||
<th className="py-2 pr-4 font-medium">Cookie</th>
|
||||
<th className="py-2 pr-4 font-medium">Purpose</th>
|
||||
<th className="py-2 font-medium">Lasts</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-muted">
|
||||
<tr className="border-b border-line">
|
||||
<td className="py-2 pr-4 font-mono text-xs">customer_session</td>
|
||||
<td className="py-2 pr-4">Keeps you signed in to your customer account</td>
|
||||
<td className="py-2">30 days</td>
|
||||
</tr>
|
||||
<tr className="border-b border-line">
|
||||
<td className="py-2 pr-4 font-mono text-xs">admin_session</td>
|
||||
<td className="py-2 pr-4">Keeps shop staff signed in to the admin area (only set if you log in as staff)</td>
|
||||
<td className="py-2">Until logout</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p>
|
||||
Both are "HTTP-only" (scripts on the page can't read them) and contain only a
|
||||
signed session token — no personal details.
|
||||
</p>
|
||||
|
||||
<h2>Other storage on your device</h2>
|
||||
<p>
|
||||
Like most modern shops, we also keep a few things in your browser's own storage rather
|
||||
than in cookies. None of this is sent to us until you act (e.g. check out):
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Your bag</strong> — the items and designs in your bag are stored on your device
|
||||
so they survive a page refresh.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Preferences</strong> — your light/dark mode choice and display currency.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Third-party cookies</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Stripe</strong> — when you proceed to payment you're taken to Stripe's
|
||||
secure checkout page, which sets its own cookies for fraud prevention. See{' '}
|
||||
<a href="https://stripe.com/gb/legal/cookies-policy" target="_blank" rel="noopener noreferrer">
|
||||
Stripe's cookie policy
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Cloudflare Turnstile</strong> — the bot-protection check on our forms may use
|
||||
similar technologies to tell humans from bots. It doesn't track you across other
|
||||
sites.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Managing cookies</h2>
|
||||
<p>
|
||||
You can delete or block cookies in your browser settings at any time — the only effect on
|
||||
this site is that you'll be signed out and will need to log in again. If we ever add
|
||||
non-essential cookies (such as analytics), we'll update this page and ask for your
|
||||
consent first.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Questions? See our <Link href="/privacy">Privacy Policy</Link> or{' '}
|
||||
<Link href="/contact">get in touch</Link>.
|
||||
</p>
|
||||
</LegalPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
'use server';
|
||||
|
||||
import { headers } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { sendCustomRequestNotification } from '@/lib/mail';
|
||||
import { verifyTurnstileToken } from '@/lib/turnstile';
|
||||
import { getClientIp } from '@/lib/rateLimit';
|
||||
|
||||
export async function createCustomRequest(formData: FormData) {
|
||||
const customerId = String(formData.get('customerId') ?? '').trim() || null;
|
||||
const productId = String(formData.get('productId') ?? '');
|
||||
const customerName = String(formData.get('customerName') ?? '').trim();
|
||||
const customerEmail = String(formData.get('customerEmail') ?? '').trim();
|
||||
const customerPhone = String(formData.get('customerPhone') ?? '').trim();
|
||||
const note = String(formData.get('note') ?? '').trim();
|
||||
const file = formData.get('image') as File | null;
|
||||
|
||||
if (!productId || !customerName || !customerEmail || !file || file.size === 0) {
|
||||
throw new Error('Missing required fields: product, name, email, and a photo are all required.');
|
||||
}
|
||||
|
||||
const ip = getClientIp(headers());
|
||||
const turnstileToken = String(formData.get('cf-turnstile-response') ?? '');
|
||||
const { success } = await verifyTurnstileToken(turnstileToken, ip);
|
||||
if (!success) {
|
||||
throw new Error('CAPTCHA verification failed — please try again.');
|
||||
}
|
||||
|
||||
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 request = await prisma.customRequest.create({
|
||||
data: {
|
||||
customerId,
|
||||
productId,
|
||||
customerName,
|
||||
customerEmail,
|
||||
customerPhone: customerPhone || null,
|
||||
imageDataUrl,
|
||||
note: note || null,
|
||||
},
|
||||
});
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
|
||||
const adminLink = `${siteUrl}/admin/custom-requests/${request.id}`;
|
||||
|
||||
await sendCustomRequestNotification({
|
||||
customerName,
|
||||
productName: product.name,
|
||||
note: note || null,
|
||||
adminLink,
|
||||
});
|
||||
|
||||
redirect(`/custom-request/submitted?id=${request.id}`);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getCurrentCustomer } from '@/lib/auth';
|
||||
import { createCustomRequest } from './actions';
|
||||
import Turnstile from '@/components/Turnstile';
|
||||
|
||||
export default async function CustomRequestPage() {
|
||||
const [products, customer] = await Promise.all([
|
||||
prisma.product.findMany({ orderBy: { name: 'asc' } }),
|
||||
getCurrentCustomer(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-12">
|
||||
<p className="tag-label text-splash-pink">Custom request</p>
|
||||
<h1 className="mt-2 font-display text-3xl">Send us your photo</h1>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
Have a photo or artwork you'd like turned into something we can print? Send it over and
|
||||
we'll get back to you with a finished design to review.
|
||||
</p>
|
||||
|
||||
<form action={createCustomRequest} className="mt-10 space-y-6">
|
||||
{customer && <input type="hidden" name="customerId" value={customer.id} />}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="customerName" className="tag-label mb-2 block">
|
||||
Your name
|
||||
</label>
|
||||
<input
|
||||
id="customerName"
|
||||
name="customerName"
|
||||
required
|
||||
defaultValue={customer?.name ?? ''}
|
||||
className="w-full border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="customerEmail" className="tag-label mb-2 block">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="customerEmail"
|
||||
name="customerEmail"
|
||||
type="email"
|
||||
required
|
||||
defaultValue={customer?.email ?? ''}
|
||||
className="w-full border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="customerPhone" className="tag-label mb-2 block">
|
||||
Phone (optional)
|
||||
</label>
|
||||
<input
|
||||
id="customerPhone"
|
||||
name="customerPhone"
|
||||
type="tel"
|
||||
placeholder="For a faster reply via WhatsApp"
|
||||
className="w-full border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="productId" className="tag-label mb-2 block">
|
||||
What should we put it on?
|
||||
</label>
|
||||
<select
|
||||
id="productId"
|
||||
name="productId"
|
||||
required
|
||||
className="w-full border border-line px-3 py-2 text-sm"
|
||||
>
|
||||
{products.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="image" className="tag-label mb-2 block">
|
||||
Your photo
|
||||
</label>
|
||||
<input
|
||||
id="image"
|
||||
name="image"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
required
|
||||
className="w-full border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="note" className="tag-label mb-2 block">
|
||||
Anything we should know? (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="note"
|
||||
name="note"
|
||||
rows={3}
|
||||
className="w-full border border-line px-3 py-2 text-sm"
|
||||
placeholder="Colors, sizing, where you'd like it placed..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Turnstile />
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Send it over
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export default async function CustomRequestSubmittedPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { id?: string };
|
||||
}) {
|
||||
const id = searchParams.id ?? '';
|
||||
const request = id
|
||||
? await prisma.customRequest.findUnique({ where: { id }, include: { product: true } })
|
||||
: null;
|
||||
if (!request) notFound();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl px-6 py-16 text-center">
|
||||
<p className="tag-label text-splash-pink">Got it</p>
|
||||
<h1 className="mt-2 font-display text-3xl">Thanks — we've got your photo</h1>
|
||||
<p className="mt-3 text-sm text-muted">
|
||||
We'll take a look and get back to you at {request.customerEmail} with a design to review.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 border border-line bg-surface p-6">
|
||||
<img
|
||||
src={request.imageDataUrl}
|
||||
alt="Your submitted photo"
|
||||
className="mx-auto max-h-64 object-contain"
|
||||
/>
|
||||
<p className="mt-3 text-sm text-muted">For a {request.product.name}</p>
|
||||
</div>
|
||||
|
||||
<Link href="/" className="mt-8 inline-block tag-label hover:text-ink">
|
||||
← Back to the shop
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Link from 'next/link';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import Reveal from '@/components/Reveal';
|
||||
|
||||
export default async function GalleryPage({ searchParams }: { searchParams: { category?: string } }) {
|
||||
const categories = await prisma.galleryCategory.findMany({ orderBy: { name: 'asc' } });
|
||||
|
||||
const activeSlug = searchParams.category ?? null;
|
||||
const activeCategory = activeSlug ? categories.find((c) => c.slug === activeSlug) ?? null : null;
|
||||
|
||||
const photos = await prisma.galleryPhoto.findMany({
|
||||
where: activeCategory ? { categoryId: activeCategory.id } : {},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const chipClass = (isActive: boolean) =>
|
||||
`border px-3 py-1.5 text-xs transition-colors ${
|
||||
isActive ? 'border-clay bg-clay text-paper' : 'border-line hover:border-clay hover:text-clay'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-12">
|
||||
<p className="font-script text-3xl text-splash-pink">Our work</p>
|
||||
<h1 className="mt-2 font-display text-4xl">Gallery</h1>
|
||||
<p className="mt-3 max-w-xl text-muted">
|
||||
A look at pieces we've made. Browse by category, or see everything.
|
||||
</p>
|
||||
|
||||
{categories.length > 0 && (
|
||||
<div className="mt-8 flex flex-wrap gap-2">
|
||||
<Link href="/gallery" className={chipClass(activeCategory === null)}>
|
||||
All
|
||||
</Link>
|
||||
{categories.map((c) => (
|
||||
<Link key={c.id} href={`/gallery?category=${c.slug}`} className={chipClass(activeCategory?.id === c.id)}>
|
||||
{c.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{photos.length === 0 ? (
|
||||
<div className="mt-10 border border-line bg-surface p-12 text-center">
|
||||
<p className="font-display text-xl">
|
||||
{activeCategory ? 'Nothing in this category yet' : 'Gallery coming soon'}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
{activeCategory
|
||||
? 'Try another category, or view all.'
|
||||
: "We're putting together a collection of our completed work — check back soon."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Reveal className="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{photos.map((p) => (
|
||||
<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}
|
||||
alt={p.caption ?? 'Completed work'}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
{p.caption && (
|
||||
<figcaption className="px-4 py-3 text-sm text-muted">{p.caption}</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
))}
|
||||
</Reveal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--color-paper: 255 253 248;
|
||||
--color-ink: 26 26 26;
|
||||
--color-line: 234 230 221;
|
||||
--color-surface: 255 255 255;
|
||||
--color-muted: 138 133 120;
|
||||
}
|
||||
.dark {
|
||||
--color-paper: 34 30 26;
|
||||
--color-ink: 240 235 224;
|
||||
--color-line: 64 58 48;
|
||||
--color-surface: 46 41 35;
|
||||
--color-muted: 173 164 148;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
body {
|
||||
@apply bg-paper text-ink font-body antialiased transition-colors duration-200;
|
||||
}
|
||||
::selection {
|
||||
@apply bg-splash-pink text-paper;
|
||||
}
|
||||
:focus-visible {
|
||||
outline: 2px solid #e5227e;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.tag-label {
|
||||
@apply font-mono text-[11px] uppercase tracking-tag text-muted;
|
||||
}
|
||||
.hairline {
|
||||
@apply border-line;
|
||||
}
|
||||
.link-cta {
|
||||
@apply inline-flex items-center gap-1 text-sm font-semibold text-clay transition-all hover:gap-2.5 hover:text-clay-dark;
|
||||
}
|
||||
.brand-gradient {
|
||||
background-image: linear-gradient(90deg, #f5871f 0%, #e5227e 35%, #1ea7e0 70%, #5b2c86 100%);
|
||||
}
|
||||
.brand-gradient-text {
|
||||
background-image: linear-gradient(90deg, #f5871f 0%, #e5227e 35%, #1ea7e0 70%, #5b2c86 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes drift {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) rotate(var(--drift-rotate, 0deg));
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-8px) rotate(calc(var(--drift-rotate, 0deg) + 8deg));
|
||||
}
|
||||
}
|
||||
|
||||
.animate-drift {
|
||||
animation: drift 9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Fraunces, Inter, JetBrains_Mono, Caveat } from 'next/font/google';
|
||||
import { headers } from 'next/headers';
|
||||
import './globals.css';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import PromotionBanner from '@/components/PromotionBanner';
|
||||
import MaintenancePage from '@/components/MaintenancePage';
|
||||
import { CurrencyProvider } from '@/lib/CurrencyContext';
|
||||
import { getSiteSettings } from '@/lib/settings';
|
||||
import { isAdminAuthenticated } from '@/lib/adminAuth';
|
||||
|
||||
const fraunces = Fraunces({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-fraunces',
|
||||
axes: ['opsz', 'SOFT', 'WONK'],
|
||||
});
|
||||
|
||||
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });
|
||||
|
||||
const mono = JetBrains_Mono({ subsets: ['latin'], variable: '--font-mono' });
|
||||
|
||||
const caveat = Caveat({ subsets: ['latin'], variable: '--font-script', weight: ['500', '700'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Craft2Prints — Ink it. Print it. Love it.',
|
||||
description:
|
||||
'Design your own apparel, drinkware, and phone cases. Personalize a template, we print and ship it.',
|
||||
};
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const { maintenanceMode, maintenanceMessage } = await getSiteSettings();
|
||||
const pathname = 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.
|
||||
const showMaintenance =
|
||||
maintenanceMode && !pathname.startsWith('/admin') && !(await isAdminAuthenticated());
|
||||
|
||||
return (
|
||||
<html lang="en" className={`${fraunces.variable} ${inter.variable} ${mono.variable} ${caveat.variable}`}>
|
||||
<head>
|
||||
{/* Extra fonts available in the design tool's text options (loaded by real
|
||||
family name so the canvas can reference them directly). */}
|
||||
<link
|
||||
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 className="flex min-h-screen flex-col">
|
||||
<CurrencyProvider>
|
||||
{showMaintenance ? (
|
||||
<MaintenancePage message={maintenanceMessage} />
|
||||
) : (
|
||||
<>
|
||||
<PromotionBanner />
|
||||
<Header />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
</>
|
||||
)}
|
||||
</CurrencyProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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';
|
||||
import Reviews from '@/components/Reviews';
|
||||
|
||||
const DesignCanvas = dynamic(() => import('@/components/DesignCanvas'), { ssr: false });
|
||||
|
||||
export default async function ProductDetailPage({ params }: { params: { slug: string } }) {
|
||||
const activePromotions = await fetchActivePromotions();
|
||||
|
||||
const row = await prisma.product.findUnique({ where: { slug: params.slug } });
|
||||
if (!row || !row.showOnPersonalised) notFound();
|
||||
|
||||
const product = toProductDTO(row, activePromotions);
|
||||
|
||||
const relatedRows = await prisma.product.findMany({
|
||||
where: { category: product.category, showOnPersonalised: true, NOT: { id: product.id } },
|
||||
take: 4,
|
||||
});
|
||||
const related = relatedRows.map((p) => toProductDTO(p, activePromotions));
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-12">
|
||||
<DesignCanvas product={product} />
|
||||
|
||||
<section className="mt-20 border-t border-line pt-12">
|
||||
<Reviews />
|
||||
</section>
|
||||
|
||||
{related.length > 0 && (
|
||||
<section className="mt-20 border-t border-line pt-12">
|
||||
<h2 className="mb-8 font-display text-3xl">You might also like</h2>
|
||||
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{related.map((p) => (
|
||||
<ProductCard key={p.id} product={p} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { toProductDTO } from '@/lib/product';
|
||||
import { fetchActivePromotions } from '@/lib/promotions';
|
||||
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;
|
||||
};
|
||||
|
||||
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 },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
const CATEGORIES = categoryRows.map((c) => ({ value: c.slug, label: c.name }));
|
||||
|
||||
const collectionRows = await prisma.collection.findMany({ 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;
|
||||
|
||||
// Full palette across every product, for the swatch filter — independent of
|
||||
// whatever else is currently filtered, so switching colors doesn't hide options.
|
||||
const allProducts = await prisma.product.findMany({
|
||||
where: { showOnPersonalised: true },
|
||||
select: { colors: true },
|
||||
});
|
||||
const paletteSet = new Set<string>();
|
||||
for (const p of allProducts) {
|
||||
(JSON.parse(p.colors) as string[]).forEach((c) => paletteSet.add(c));
|
||||
}
|
||||
const palette = Array.from(paletteSet);
|
||||
|
||||
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) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const activePromotions = await fetchActivePromotions();
|
||||
const rows = await prisma.product.findMany({ where, orderBy: { createdAt: 'asc' } });
|
||||
const products = rows.map((p) => toProductDTO(p, activePromotions));
|
||||
|
||||
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">
|
||||
<h1 className="font-display text-4xl">Shop all</h1>
|
||||
<p className="tag-label">
|
||||
{products.length} template{products.length === 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>
|
||||
|
||||
<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="/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 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>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import Link from 'next/link';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { toProductDTO } from '@/lib/product';
|
||||
import { getCurrentCustomer } from '@/lib/auth';
|
||||
import { fetchActivePromotions } from '@/lib/promotions';
|
||||
import ProductCard from '@/components/ProductCard';
|
||||
import ProductMockup from '@/components/ProductMockup';
|
||||
import CherryBlossom from '@/components/CherryBlossom';
|
||||
import Reveal from '@/components/Reveal';
|
||||
|
||||
const REASSURANCES = [
|
||||
{ label: 'Made to order', body: 'Printed after you check out — nothing sits pre-made on a shelf.' },
|
||||
{ label: 'Ships from the UK', body: 'Every order goes out from us, start to finish.' },
|
||||
{ label: 'Secure checkout', body: 'Payments handled securely via Stripe.' },
|
||||
{ label: 'Questions?', body: 'sales@craft2prints.co.uk' },
|
||||
];
|
||||
|
||||
export default async function HomePage() {
|
||||
const activePromotions = await fetchActivePromotions();
|
||||
|
||||
const galleryPhotos = await prisma.galleryPhoto.findMany({
|
||||
take: 6,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const products = await prisma.product.findMany({
|
||||
where: { showOnPersonalised: true },
|
||||
take: 4,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
const featured = products.map((p) => toProductDTO(p, activePromotions));
|
||||
|
||||
const readyMadeRows = await prisma.product.findMany({
|
||||
where: { showOnProducts: true },
|
||||
take: 4,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
const readyMade = readyMadeRows.map((p) => toProductDTO(p, activePromotions));
|
||||
const readyMadeCount = readyMade.length;
|
||||
|
||||
const categoryRows = await prisma.category.findMany({
|
||||
where: { showOnPersonalised: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
const categories = categoryRows.map((c) => ({ label: c.name, value: c.slug }));
|
||||
|
||||
const customer = await getCurrentCustomer();
|
||||
|
||||
const steps = [
|
||||
{
|
||||
n: '01',
|
||||
title: 'Pick your product',
|
||||
body: 'Choose a ready-made favorite, or start from a template to design your own.',
|
||||
},
|
||||
{
|
||||
n: '02',
|
||||
title: 'Make it yours (if you want)',
|
||||
body: 'Personalising? Add your own text and images right on the product in our design tool. Buying ready-made? Skip straight to checkout.',
|
||||
},
|
||||
{
|
||||
n: '03',
|
||||
title: 'We print & ship',
|
||||
body: 'Personalised pieces are printed after you check out. Ready-made ships as-is — either way, it comes straight from us.',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Hero */}
|
||||
<section className="relative z-0 mx-auto max-w-6xl px-6 pb-14 pt-14 md:pt-20">
|
||||
{/* Decorative cherry blossoms — subtle, behind the content, ignored by screen readers/clicks */}
|
||||
<div className="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
|
||||
<CherryBlossom size={26} className="animate-drift absolute left-[4%] top-6 opacity-20" style={{ '--drift-rotate': '-8deg' } as React.CSSProperties} />
|
||||
<CherryBlossom size={18} className="animate-drift absolute left-[42%] top-28 opacity-10" style={{ animationDelay: '2s', '--drift-rotate': '15deg' } as React.CSSProperties} />
|
||||
<CherryBlossom size={34} className="animate-drift absolute bottom-4 left-[12%] opacity-20" style={{ animationDelay: '1s', '--drift-rotate': '25deg' } as React.CSSProperties} />
|
||||
<CherryBlossom size={20} className="animate-drift absolute right-[32%] top-2 opacity-15" style={{ animationDelay: '3s', '--drift-rotate': '-20deg' } as React.CSSProperties} />
|
||||
<CherryBlossom size={28} className="animate-drift absolute bottom-10 right-[6%] opacity-20" style={{ animationDelay: '1.5s', '--drift-rotate': '10deg' } as React.CSSProperties} />
|
||||
<CherryBlossom size={16} className="animate-drift absolute right-[18%] top-1/2 opacity-10" style={{ animationDelay: '4s', '--drift-rotate': '-35deg' } as React.CSSProperties} />
|
||||
</div>
|
||||
|
||||
<div className="grid items-center gap-12 md:grid-cols-2">
|
||||
<Reveal>
|
||||
<p className="font-script text-3xl text-splash-pink">Ink it. Print it. Love it.</p>
|
||||
<h1 className="mt-3 font-display text-5xl leading-[1.05] md:text-6xl">
|
||||
Design something only <span className="brand-gradient-text">you</span> would make.
|
||||
</h1>
|
||||
<p className="mt-5 max-w-md text-muted">
|
||||
Start from a template and make it your own — or shop pieces we've already made.
|
||||
Either way, it ships from us.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap items-center gap-3">
|
||||
<Link
|
||||
href="/made-to-order"
|
||||
className="bg-clay px-7 py-3 text-sm font-medium text-paper transition-colors hover:bg-clay-dark"
|
||||
>
|
||||
Start designing
|
||||
</Link>
|
||||
<Link
|
||||
href="/products"
|
||||
className="border-2 border-ink px-7 py-3 text-sm font-medium text-ink transition-colors hover:border-clay hover:text-clay"
|
||||
>
|
||||
Shop ready-made
|
||||
</Link>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delayMs={150} className="relative mx-auto h-[380px] w-full max-w-[420px]">
|
||||
<div className="absolute inset-0 rounded-full brand-gradient opacity-40 blur-3xl" />
|
||||
<div className="absolute inset-8 rounded-full brand-gradient opacity-20 blur-2xl" />
|
||||
<div
|
||||
className="animate-float absolute left-0 top-6 h-40 w-40 -rotate-6 border-2 bg-surface p-3 shadow-md"
|
||||
style={{ borderColor: '#F5871F' }}
|
||||
>
|
||||
<ProductMockup mockup="tshirt" color="#F5871F" />
|
||||
</div>
|
||||
<div
|
||||
className="animate-float absolute right-2 top-0 h-36 w-36 rotate-6 border-2 bg-surface p-3 shadow-md"
|
||||
style={{ borderColor: '#1EA7E0', animationDelay: '1.5s' }}
|
||||
>
|
||||
<ProductMockup mockup="mug" color="#1EA7E0" />
|
||||
</div>
|
||||
<div
|
||||
className="animate-float absolute bottom-0 left-16 h-44 w-44 rotate-3 border-2 bg-surface p-3 shadow-md"
|
||||
style={{ borderColor: '#5B2C86', animationDelay: '3s' }}
|
||||
>
|
||||
<ProductMockup mockup="phonecase" color="#5B2C86" />
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Reassurance strip */}
|
||||
<section className="border-y border-line bg-surface">
|
||||
<div className="mx-auto grid max-w-6xl gap-6 px-6 py-8 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{REASSURANCES.map((r) => (
|
||||
<div key={r.label}>
|
||||
<p className="tag-label text-ink">{r.label}</p>
|
||||
<p className="mt-1 text-sm text-muted">{r.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Choose your path — equal-weight split so this isn't just a funnel into personalizing */}
|
||||
<section className="mx-auto max-w-6xl px-6 py-16">
|
||||
<Reveal className="grid gap-5 md:grid-cols-2">
|
||||
{/* Personalise card */}
|
||||
<div className="group relative overflow-hidden border-2 border-ink bg-surface p-8">
|
||||
<div className="absolute -right-10 -top-10 h-40 w-40 rounded-full brand-gradient opacity-20 blur-2xl" />
|
||||
<p className="tag-label text-splash-pink">Design it yourself</p>
|
||||
<h2 className="mt-2 font-display text-3xl">Personalise</h2>
|
||||
<p className="mt-3 max-w-sm text-sm text-muted">
|
||||
Pick a template and make it yours with your own text and artwork, right in the
|
||||
browser.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{categories.map((c) => (
|
||||
<Link
|
||||
key={c.value}
|
||||
href={`/made-to-order?category=${c.value}`}
|
||||
className="border border-line px-3 py-1.5 text-xs hover:border-clay hover:text-clay"
|
||||
>
|
||||
{c.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<Link href="/made-to-order" className="link-cta mt-6">
|
||||
Start designing <span aria-hidden>→</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Ready-made card */}
|
||||
<div className="group relative overflow-hidden border-2 border-line bg-surface p-8">
|
||||
<div className="absolute -right-10 -top-10 h-40 w-40 rounded-full bg-splash-blue opacity-10 blur-2xl" />
|
||||
{readyMadeCount === 0 && (
|
||||
<span className="tag-label border border-line px-2 py-1 text-splash-blue">Coming soon</span>
|
||||
)}
|
||||
<h2 className="mt-3 font-display text-3xl">Ready-made</h2>
|
||||
<p className="mt-3 max-w-sm text-sm text-muted">
|
||||
{readyMadeCount === 0
|
||||
? "Prefer to skip the design step? We're putting together a collection of ready-to-buy favorites — no personalizing required."
|
||||
: 'Prefer to skip the design step? Browse pieces that are ready to buy as-is — no personalizing required.'}
|
||||
</p>
|
||||
<Link href="/products" className="link-cta mt-6">
|
||||
Take a look <span aria-hidden>→</span>
|
||||
</Link>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Gallery preview — completed work, prominent placement near the top. Always
|
||||
shown (with a "coming soon" state when empty) so it never disappears
|
||||
entirely — a link this prominent shouldn't come and go with photo count. */}
|
||||
<section className="mx-auto max-w-6xl px-6 pb-16">
|
||||
<Reveal>
|
||||
<div className="mb-8 flex items-baseline justify-between">
|
||||
<div>
|
||||
<p className="tag-label text-splash-pink">Our work</p>
|
||||
<h2 className="mt-1 font-display text-3xl">From the gallery</h2>
|
||||
</div>
|
||||
<Link href="/gallery" className="link-cta">
|
||||
View gallery <span aria-hidden>→</span>
|
||||
</Link>
|
||||
</div>
|
||||
{galleryPhotos.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{galleryPhotos.map((p) => (
|
||||
<Link
|
||||
key={p.id}
|
||||
href="/gallery"
|
||||
className="group block aspect-square overflow-hidden border border-line bg-surface"
|
||||
>
|
||||
<img
|
||||
src={p.imageDataUrl}
|
||||
alt={p.caption ?? 'Completed work'}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
href="/gallery"
|
||||
className="block border border-line bg-surface p-10 text-center transition-colors hover:border-clay"
|
||||
>
|
||||
<p className="font-display text-xl">Photos coming soon</p>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
We're putting together a gallery of finished work — check back soon.
|
||||
</p>
|
||||
</Link>
|
||||
)}
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Custom photo request — only shown to logged-in customers */}
|
||||
{customer && (
|
||||
<section className="mx-auto max-w-6xl px-6 pb-16">
|
||||
<Reveal className="flex flex-col items-start gap-4 border-2 border-splash-pink/40 bg-surface p-8 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="tag-label text-splash-pink">Got your own photo?</p>
|
||||
<h2 className="mt-2 font-display text-2xl">Send it our way and we'll work our magic</h2>
|
||||
<p className="mt-2 max-w-lg text-sm text-muted">
|
||||
Upload a photo and tell us what you'd like it on — we'll turn it into a design for
|
||||
you to review.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/custom-request"
|
||||
className="shrink-0 bg-clay px-6 py-3 text-sm font-medium text-paper transition-colors hover:bg-clay-dark"
|
||||
>
|
||||
Send us your photo
|
||||
</Link>
|
||||
</Reveal>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Featured products */}
|
||||
<section className="mx-auto max-w-6xl px-6 pb-20">
|
||||
<Reveal>
|
||||
<div className="mb-8 flex items-baseline justify-between">
|
||||
<h2 className="font-display text-3xl">Popular templates</h2>
|
||||
<Link href="/made-to-order" className="link-cta">
|
||||
View all <span aria-hidden>→</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{featured.map((p) => (
|
||||
<ProductCard key={p.id} product={p} />
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Ready-made favourites */}
|
||||
{readyMadeCount > 0 && (
|
||||
<section className="mx-auto max-w-6xl px-6 pb-20">
|
||||
<Reveal>
|
||||
<div className="mb-8 flex items-baseline justify-between">
|
||||
<h2 className="font-display text-3xl">Ready-made favorites</h2>
|
||||
<Link href="/products" className="link-cta">
|
||||
View all <span aria-hidden>→</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{readyMade.map((p) => (
|
||||
<ProductCard key={p.id} product={p} hrefBase="/products" />
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* How it works */}
|
||||
<div className="h-1 w-full brand-gradient" />
|
||||
<section id="how-it-works" className="bg-surface">
|
||||
<Reveal className="mx-auto max-w-6xl px-6 py-20">
|
||||
<h2 className="font-display text-3xl">How it works</h2>
|
||||
<div className="mt-10 grid gap-10 md:grid-cols-3">
|
||||
{steps.map((s) => (
|
||||
<div key={s.n}>
|
||||
<p className="font-display text-4xl brand-gradient-text">{s.n}</p>
|
||||
<p className="mt-3 text-lg font-medium">{s.title}</p>
|
||||
<p className="mt-2 text-sm text-muted">{s.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* CTA banner */}
|
||||
<section className="brand-gradient">
|
||||
<Reveal className="mx-auto flex max-w-6xl flex-col items-center gap-5 px-6 py-16 text-center text-white">
|
||||
<h2 className="font-display text-3xl md:text-4xl">Ready to make it yours?</h2>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/made-to-order"
|
||||
className="bg-clay px-7 py-3 text-sm font-medium text-paper transition-colors hover:bg-white hover:text-clay-dark"
|
||||
>
|
||||
Browse templates
|
||||
</Link>
|
||||
<Link
|
||||
href="/products"
|
||||
className="border-2 border-white px-7 py-3 text-sm font-medium text-white transition-colors hover:bg-white hover:text-clay-dark"
|
||||
>
|
||||
See ready-made
|
||||
</Link>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import Link from 'next/link';
|
||||
import LegalPage from '@/components/LegalPage';
|
||||
|
||||
export const metadata = { title: 'Privacy Policy — Craft2Prints' };
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<LegalPage tag="Legal" title="Privacy Policy" lastUpdated="13 July 2026">
|
||||
<h2>Who we are</h2>
|
||||
<p>
|
||||
Craft2Prints is a UK-based sole trader business selling personalised and ready-made printed
|
||||
products at craft2prints.co.uk. For anything in this policy, you can reach us at{' '}
|
||||
<a href="mailto:sales@craft2prints.co.uk">sales@craft2prints.co.uk</a> or by post at
|
||||
[BUSINESS ADDRESS]. We are the "data controller" for the personal information
|
||||
described below, and we handle it in line with UK data protection law (the UK GDPR and the
|
||||
Data Protection Act 2018).
|
||||
</p>
|
||||
|
||||
<h2>What we collect, and why</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Orders</strong> — the items you buy, any design you created or uploaded (including
|
||||
photos), your email address, and the delivery address you give at checkout. We need these
|
||||
to make and ship your order (legal basis: performing our contract with you).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Accounts</strong> — your name, email address, and password if you create an
|
||||
account. Passwords are stored only in securely hashed form — we never store or see the
|
||||
actual password (legal basis: contract).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Custom photo requests</strong> — your name, email, the photo you send us, an
|
||||
optional phone number, and any notes, so we can create your design and reply (legal
|
||||
basis: taking steps at your request before a contract).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Messages</strong> — messages you send through our contact form (emailed to us,
|
||||
not stored on the website) and design-approval chat messages (legal basis: legitimate
|
||||
interest in answering you and keeping a record of design approvals).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Mailing list</strong> — your email (and name, if we have it) if you sign up for
|
||||
the newsletter, make an account, or place an order. Every mailing includes context on why
|
||||
you're receiving it, and you can be removed at any time by contacting us (legal
|
||||
basis: legitimate interest / consent for newsletter sign-ups).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Security logs</strong> — your IP address is briefly recorded when you log in or
|
||||
check out, purely to block automated attacks. These records are automatically deleted
|
||||
within 24 hours (legal basis: legitimate interest in keeping the site secure).
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>What we never collect</h2>
|
||||
<p>
|
||||
Your card details never touch our servers. Payment is handled entirely by Stripe, a
|
||||
regulated payment processor — we only receive confirmation that payment succeeded, plus the
|
||||
email and delivery address you enter on Stripe's checkout page. We also run no
|
||||
advertising or analytics tracking of any kind.
|
||||
</p>
|
||||
|
||||
<h2>Who we share data with</h2>
|
||||
<p>We never sell your data. It is shared only with the services that make the shop work:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Stripe</strong> — processes payments (see{' '}
|
||||
<a href="https://stripe.com/gb/privacy" target="_blank" rel="noopener noreferrer">
|
||||
Stripe's privacy policy
|
||||
</a>
|
||||
).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Cloudflare</strong> — provides the bot-protection check (Turnstile) on our public
|
||||
forms.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Our email provider</strong> ([EMAIL PROVIDER]) — delivers order and account
|
||||
emails to you.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Our hosting provider</strong> ([HOSTING PROVIDER]) — runs the website and stores
|
||||
its database.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>How long we keep it</h2>
|
||||
<ul>
|
||||
<li>Order records — kept for 6 years, as required for UK tax and accounting purposes.</li>
|
||||
<li>Account details — kept while your account exists; deleted on request.</li>
|
||||
<li>Custom-request photos and design chats — kept while we work on your request and for a
|
||||
reasonable period afterwards in case you return to order.</li>
|
||||
<li>Mailing list entries — kept until you ask to be removed.</li>
|
||||
<li>Security logs (IP addresses) — deleted automatically within 24 hours.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Your rights</h2>
|
||||
<p>Under UK GDPR you have the right to:</p>
|
||||
<ul>
|
||||
<li>Ask for a copy of the personal data we hold about you (access)</li>
|
||||
<li>Have inaccurate data corrected (rectification)</li>
|
||||
<li>Have your data deleted where we no longer need it (erasure)</li>
|
||||
<li>Receive your data in a portable format (portability)</li>
|
||||
<li>Object to or restrict certain uses of your data</li>
|
||||
<li>Withdraw consent at any time, where consent is the basis we rely on</li>
|
||||
</ul>
|
||||
<p>
|
||||
To exercise any of these, email{' '}
|
||||
<a href="mailto:sales@craft2prints.co.uk">sales@craft2prints.co.uk</a> or use the{' '}
|
||||
<Link href="/contact">contact form</Link> — we'll respond within one month. If
|
||||
you're unhappy with how we handle your data, you can complain to the Information
|
||||
Commissioner's Office at{' '}
|
||||
<a href="https://ico.org.uk" target="_blank" rel="noopener noreferrer">
|
||||
ico.org.uk
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<h2>How we protect your data</h2>
|
||||
<p>
|
||||
The site is served over HTTPS, passwords are stored only as secure hashes, login sessions
|
||||
use signed, HTTP-only cookies, and login and checkout endpoints are rate-limited to block
|
||||
automated attacks. Access to customer data is limited to the shop owner.
|
||||
</p>
|
||||
|
||||
<h2>Changes to this policy</h2>
|
||||
<p>
|
||||
If we change how we handle your data — for example, by adding analytics — we'll update
|
||||
this page and the date at the top before the change takes effect.
|
||||
</p>
|
||||
</LegalPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { toProductDTO } from '@/lib/product';
|
||||
import { fetchActivePromotions } from '@/lib/promotions';
|
||||
import StandardProductView from '@/components/StandardProductView';
|
||||
import ProductCard from '@/components/ProductCard';
|
||||
import Reviews from '@/components/Reviews';
|
||||
|
||||
export default async function StandardProductPage({ params }: { params: { slug: string } }) {
|
||||
const activePromotions = await fetchActivePromotions();
|
||||
|
||||
const row = await prisma.product.findUnique({ where: { slug: params.slug } });
|
||||
if (!row || !row.showOnProducts) notFound();
|
||||
|
||||
const product = toProductDTO(row, activePromotions);
|
||||
|
||||
const relatedRows = await prisma.product.findMany({
|
||||
where: { category: product.category, showOnProducts: true, NOT: { id: product.id } },
|
||||
take: 4,
|
||||
});
|
||||
const related = relatedRows.map((p) => toProductDTO(p, activePromotions));
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-12">
|
||||
<StandardProductView product={product} />
|
||||
|
||||
<section className="mt-20 border-t border-line pt-12">
|
||||
<Reviews />
|
||||
</section>
|
||||
|
||||
{related.length > 0 && (
|
||||
<section className="mt-20 border-t border-line pt-12">
|
||||
<h2 className="mb-8 font-display text-3xl">You might also like</h2>
|
||||
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{related.map((p) => (
|
||||
<ProductCard key={p.id} product={p} hrefBase="/products" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { toProductDTO } from '@/lib/product';
|
||||
import { fetchActivePromotions } from '@/lib/promotions';
|
||||
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;
|
||||
};
|
||||
|
||||
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 },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
const CATEGORIES = categoryRows.map((c) => ({ value: c.slug, label: c.name }));
|
||||
|
||||
const collectionRows = await prisma.collection.findMany({ 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;
|
||||
|
||||
// Full palette across ready-made products only, for the swatch filter.
|
||||
const allProducts = await prisma.product.findMany({
|
||||
where: { showOnProducts: true },
|
||||
select: { colors: true },
|
||||
});
|
||||
const paletteSet = new Set<string>();
|
||||
for (const p of allProducts) {
|
||||
(JSON.parse(p.colors) as string[]).forEach((c) => paletteSet.add(c));
|
||||
}
|
||||
const palette = Array.from(paletteSet);
|
||||
|
||||
const where: Prisma.ProductWhereInput = { showOnProducts: 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) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const activePromotions = await fetchActivePromotions();
|
||||
const rows = await prisma.product.findMany({ where, orderBy: { createdAt: 'asc' } });
|
||||
const products = rows.map((p) => toProductDTO(p, activePromotions));
|
||||
|
||||
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">
|
||||
<h1 className="font-display text-4xl">Products</h1>
|
||||
<p className="tag-label">
|
||||
{products.length} item{products.length === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{products.length === 0 && !hasFilters ? (
|
||||
<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">
|
||||
Ready-made items will show up here once added — check Admin → Add product and enable
|
||||
"Products catalog".
|
||||
</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>
|
||||
<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
|
||||
</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 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>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { toProofDTO } from '@/lib/product';
|
||||
import ApproveSection from '@/components/ApproveSection';
|
||||
import Chat from '@/components/Chat';
|
||||
import Price from '@/components/Price';
|
||||
|
||||
export default async function ProofPage({ params }: { params: { id: string } }) {
|
||||
const row = await prisma.designProof.findUnique({
|
||||
where: { id: params.id },
|
||||
include: { product: true },
|
||||
});
|
||||
if (!row) notFound();
|
||||
|
||||
const proof = toProofDTO(row);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-6 py-14">
|
||||
<p className="tag-label text-splash-pink">Your design is ready to review</p>
|
||||
<h1 className="mt-2 font-display text-4xl">{proof.productName}</h1>
|
||||
{proof.customerName && <p className="mt-1 text-muted">Hi {proof.customerName} — here's what we made.</p>}
|
||||
|
||||
<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" />
|
||||
) : (
|
||||
<p className="text-center text-sm text-muted">No image was uploaded with this design.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{proof.note && (
|
||||
<div className="border-l-2 border-splash-pink pl-4 text-sm italic text-ink">"{proof.note}"</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between border-b border-line pb-2">
|
||||
<span className="text-muted">Color</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-4 w-4 rounded-full border border-line"
|
||||
style={{ backgroundColor: proof.color }}
|
||||
/>
|
||||
{proof.color}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-line pb-2">
|
||||
<span className="text-muted">Quantity</span>
|
||||
<span>{proof.quantity}</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-line pb-2">
|
||||
<span className="text-muted">Price</span>
|
||||
<span className="font-mono"><Price cents={proof.unitPrice * proof.quantity} /></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ApproveSection proof={proof} />
|
||||
|
||||
<p className="text-center text-sm text-muted">
|
||||
Not quite right? Send a message below and we'll fix it up.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10">
|
||||
<Chat proofId={proof.id} role="CUSTOMER" otherPartyLabel="Craft2Prints" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export default function ReadyMadePage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-20 text-center">
|
||||
<p className="tag-label text-splash-pink">Ready-made</p>
|
||||
<h1 className="mt-2 font-display text-4xl">Coming soon</h1>
|
||||
<p className="mx-auto mt-4 max-w-md text-muted">
|
||||
This is where ready-to-buy items — no personalization needed — will live. We're still
|
||||
setting this section up.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Link from 'next/link';
|
||||
import LegalPage from '@/components/LegalPage';
|
||||
|
||||
export const metadata = { title: 'Returns & Refunds — Craft2Prints' };
|
||||
|
||||
export default function ReturnsPage() {
|
||||
return (
|
||||
<LegalPage tag="Legal" title="Returns & Refunds" lastUpdated="13 July 2026">
|
||||
<h2>The short version</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Faulty or wrong items</strong> — always our problem to fix, personalised or not.
|
||||
Full refund or free replacement.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Ready-made items</strong> — you can change your mind within 14 days of delivery.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Personalised items</strong> — made just for you, so they can't be returned
|
||||
for a change of mind. Please check your design preview carefully before ordering.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Faulty, damaged, or incorrect items</h2>
|
||||
<p>
|
||||
If your item arrives damaged, misprinted (different from the design preview you approved),
|
||||
or isn't what you ordered, <Link href="/contact">contact us</Link> within 30 days of
|
||||
delivery with your order details and a photo of the problem. We'll send a free
|
||||
replacement or give you a full refund, including delivery — your choice. This applies to
|
||||
every item we sell, personalised or not, and is your right under the Consumer Rights Act
|
||||
2015.
|
||||
</p>
|
||||
|
||||
<h2>Changed your mind? (ready-made items)</h2>
|
||||
<ul>
|
||||
<li>
|
||||
You can cancel an order for ready-made (non-personalised) items up to 14 days after the
|
||||
day you receive them — no reason needed. This is your right under the Consumer Contracts
|
||||
Regulations 2013.
|
||||
</li>
|
||||
<li>
|
||||
Tell us within those 14 days via the <Link href="/contact">contact form</Link> or{' '}
|
||||
<a href="mailto:sales@craft2prints.co.uk">sales@craft2prints.co.uk</a>, then send the
|
||||
items back within 14 days of telling us. Return postage is your responsibility unless the
|
||||
item is faulty.
|
||||
</li>
|
||||
<li>
|
||||
Items should come back unworn, unwashed, and in their original condition. We may reduce
|
||||
the refund if an item has been used beyond what's needed to inspect it.
|
||||
</li>
|
||||
<li>
|
||||
We'll refund you (including standard outbound delivery) within 14 days of receiving
|
||||
the items back, to your original payment method.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Personalised items</h2>
|
||||
<p>
|
||||
Items printed with your own text, images, or design are made to your specification, so the
|
||||
14-day change-of-mind right doesn't apply to them — this is a standard exemption under
|
||||
UK law for personalised goods. That's why we show your exact print preview in the bag
|
||||
before checkout: what's in the preview, spelling and all, is what gets printed. If we
|
||||
made a mistake — the print differs from your approved preview, or the item is faulty — the
|
||||
"faulty items" section above applies in full and we'll make it right.
|
||||
</p>
|
||||
|
||||
<h2>How refunds are paid</h2>
|
||||
<p>
|
||||
All refunds go back to the payment method you used at checkout, handled by Stripe. Once
|
||||
we've processed a refund it typically appears in your account within 5–10 working
|
||||
days, depending on your bank.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Anything unclear, or a problem not covered here? <Link href="/contact">Get in touch</Link>{' '}
|
||||
— we're a small business and we'd always rather sort it out directly.
|
||||
</p>
|
||||
</LegalPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import Link from 'next/link';
|
||||
import LegalPage from '@/components/LegalPage';
|
||||
|
||||
export const metadata = { title: 'Terms & Conditions — Craft2Prints' };
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<LegalPage tag="Legal" title="Terms & Conditions" lastUpdated="13 July 2026">
|
||||
<h2>About us</h2>
|
||||
<p>
|
||||
These terms apply to every order placed at craft2prints.co.uk. Craft2Prints is a UK-based
|
||||
sole trader business. You can contact us at{' '}
|
||||
<a href="mailto:sales@craft2prints.co.uk">sales@craft2prints.co.uk</a> or by post at
|
||||
[BUSINESS ADDRESS]. By placing an order you agree to these terms — please also read our{' '}
|
||||
<Link href="/returns">Returns & Refunds policy</Link> and{' '}
|
||||
<Link href="/privacy">Privacy Policy</Link>.
|
||||
</p>
|
||||
|
||||
<h2>Ordering</h2>
|
||||
<ul>
|
||||
<li>
|
||||
Placing an order is an offer to buy. A contract is formed when your payment is accepted
|
||||
and we confirm the order.
|
||||
</li>
|
||||
<li>
|
||||
Personalised items are printed after you order, exactly as shown in your design preview —
|
||||
including spelling, wording, and placement. Please check your preview carefully before
|
||||
checking out; what you approve is what we print.
|
||||
</li>
|
||||
<li>
|
||||
For designs we prepare for you (custom photo requests), we'll send you a proof to
|
||||
review first. Approving the proof counts as approving the final design for print.
|
||||
</li>
|
||||
<li>
|
||||
We may decline or cancel an order at our discretion — for example if a design breaches
|
||||
the content rules below, an item is unavailable, or there was an obvious pricing error.
|
||||
If we cancel, you'll receive a full refund.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Your designs and uploads</h2>
|
||||
<ul>
|
||||
<li>
|
||||
You keep ownership of anything you upload. You grant us permission to use it solely to
|
||||
produce, preview, and fulfil your order.
|
||||
</li>
|
||||
<li>
|
||||
By uploading an image or text, you confirm you own it or have the right to use it —
|
||||
including photographs, logos, characters, and brand names. You are responsible for any
|
||||
claim that a design you supplied infringes someone else's rights.
|
||||
</li>
|
||||
<li>
|
||||
We won't print material that is illegal, hateful, or that we reasonably believe
|
||||
infringes someone else's intellectual property, and we may cancel (and refund) such
|
||||
orders.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Prices and payment</h2>
|
||||
<ul>
|
||||
<li>
|
||||
All prices are in pounds sterling (GBP) and you will be charged in GBP. Prices shown in
|
||||
other currencies are approximate conversions for convenience only.
|
||||
</li>
|
||||
<li>
|
||||
The price charged is the listed price (including any active sale or promotion) at the
|
||||
moment you check out, calculated on our servers.
|
||||
</li>
|
||||
<li>
|
||||
Payment is taken securely by Stripe at checkout. We never see or store your card details.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Delivery</h2>
|
||||
<ul>
|
||||
<li>
|
||||
Personalised items are made to order, so please allow production time before dispatch.
|
||||
Any timescales shown are our best estimates, not guarantees.
|
||||
</li>
|
||||
<li>
|
||||
If your order hasn't arrived within a reasonable time of the estimate,{' '}
|
||||
<Link href="/contact">contact us</Link> and we'll put it right.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Returns and faulty items</h2>
|
||||
<p>
|
||||
Your rights differ between personalised and ready-made items — see the full{' '}
|
||||
<Link href="/returns">Returns & Refunds policy</Link>. Nothing in these terms affects
|
||||
your statutory rights as a UK consumer.
|
||||
</p>
|
||||
|
||||
<h2>Our liability</h2>
|
||||
<p>
|
||||
If we get something wrong, our liability to you is limited to the amount you paid for the
|
||||
order, except where the law doesn't allow liability to be limited (for example, for
|
||||
death or personal injury caused by negligence, or fraud). We aren't responsible for
|
||||
losses that weren't reasonably foreseeable when the order was placed.
|
||||
</p>
|
||||
|
||||
<h2>General</h2>
|
||||
<ul>
|
||||
<li>
|
||||
We may update these terms from time to time; the version published when you order is the
|
||||
one that applies to that order.
|
||||
</li>
|
||||
<li>
|
||||
These terms are governed by the law of England and Wales, and any dispute belongs to the
|
||||
courts of England and Wales — though we'd much rather you{' '}
|
||||
<Link href="/contact">talk to us</Link> first.
|
||||
</li>
|
||||
</ul>
|
||||
</LegalPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { logoutCustomer } from '@/app/account/actions';
|
||||
|
||||
type AccountMenuProps = {
|
||||
customer: { name: string | null; email: string } | null;
|
||||
};
|
||||
|
||||
export default function AccountMenu({ customer }: AccountMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onClickOutside = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onClickOutside);
|
||||
return () => document.removeEventListener('mousedown', onClickOutside);
|
||||
}, []);
|
||||
|
||||
if (!customer) {
|
||||
return (
|
||||
<Link href="/account/login" className="tag-label hover:text-ink">
|
||||
Login
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="tag-label flex items-center gap-1 hover:text-ink"
|
||||
>
|
||||
{customer.name ?? 'Account'}
|
||||
<span className={`inline-block transition-transform ${open ? 'rotate-180' : ''}`}>▾</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-2 w-48 border border-line bg-paper shadow-sm">
|
||||
<Link href="/account" onClick={() => setOpen(false)} className="block px-4 py-3 text-sm hover:bg-surface">
|
||||
My account
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setShowLogoutConfirm(true)}
|
||||
className="block w-full px-4 py-3 text-left text-sm hover:bg-surface"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showLogoutConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div className="w-96 border border-line bg-paper p-6 shadow-lg">
|
||||
<h2 className="font-display text-lg">Log out?</h2>
|
||||
<p className="mt-3 text-sm text-muted">
|
||||
Your cart will be saved. You'll need to log in again to complete your order.
|
||||
</p>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowLogoutConfirm(false)}
|
||||
className="flex-1 border border-line px-4 py-2 text-sm hover:bg-surface"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<form action={logoutCustomer} className="flex-1">
|
||||
<button type="submit" className="w-full bg-clay px-4 py-2 text-sm font-medium text-paper hover:bg-clay-dark">
|
||||
Log out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user