Initial commit: Craft2Prints with Stages 1-3

This commit is contained in:
Andymick
2026-07-15 18:09:59 +01:00
commit 41937ffc15
158 changed files with 16054 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000
}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"Read",
"Edit",
"Write",
"Glob",
"Grep",
"Bash(git *)",
"Bash(npm *)",
"Bash(npx *)"
]
}
}
+18
View File
@@ -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 ' *)"
]
}
}
+51
View File
@@ -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
View File
@@ -0,0 +1,10 @@
node_modules/
.next/
.env
.env.local
prisma/dev.db
prisma/dev.db-*
*.log
.DS_Store
.claude/settings.local.json
dist/
+3
View File
@@ -0,0 +1,3 @@
{
"git.ignoreLimitWarning": true
}
+127
View File
@@ -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.
+491
View File
@@ -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 (XSXXL) 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.
+5
View File
@@ -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.
+13
View File
@@ -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;
+2686
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+367
View File
@@ -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
View File
@@ -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();
});
BIN
View File
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

+136
View File
@@ -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');
}
+47
View File
@@ -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&apos;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&apos;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>
);
}
+61
View File
@@ -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>
);
}
+95
View File
@@ -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>
);
}
+80
View File
@@ -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>
);
}
+91
View File
@@ -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>
);
}
+75
View File
@@ -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>
);
}
+32
View File
@@ -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." };
}
+91
View File
@@ -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');
}
+28
View File
@@ -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&apos;s email matches a
customer account, the sale is linked to them automatically.
</p>
<ManualSaleForm products={products} />
</div>
);
}
+223
View File
@@ -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>
);
}
+123
View File
@@ -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&apos;s stored with the purchase. Lines matched to a product add straight to
its stock count.
</p>
<PurchaseForm products={products} />
</div>
);
}
+66
View File
@@ -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>
);
}
+77
View File
@@ -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&apos;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>
);
}
+63
View File
@@ -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');
}
+59
View File
@@ -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>
);
}
+79
View File
@@ -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&apos;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>
);
}
+51
View File
@@ -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');
}
+53
View File
@@ -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>
);
}
+72
View File
@@ -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 &quot;Occasions&quot; and &quot;Gifts For&quot; menus, and are tagged onto
products from each product&apos;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&apos;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>
);
}
+115
View File
@@ -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>
);
}
+25
View File
@@ -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');
}
+53
View File
@@ -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>
);
}
+51
View File
@@ -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}`);
}
+109
View File
@@ -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&apos;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>
);
}
+82
View File
@@ -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>
);
}
+58
View File
@@ -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&apos;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>
);
}
+70
View File
@@ -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>
);
}
+60
View File
@@ -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>
);
}
+48
View File
@@ -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>
);
}
+51
View File
@@ -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>
);
}
+35
View File
@@ -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');
}
+54
View File
@@ -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>
);
}
+25
View File
@@ -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');
}
+76
View File
@@ -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 &quot;we&apos;re updating&quot; 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>
);
}
+146
View File
@@ -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>
);
}
+37
View File
@@ -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');
}
+80
View File
@@ -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>
);
}
+322
View File
@@ -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&apos;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>
);
}
+305
View File
@@ -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');
}
+270
View File
@@ -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&apos;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>
);
}
+80
View File
@@ -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&apos;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>
);
}
+100
View File
@@ -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>
);
}
+77
View File
@@ -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');
}
+84
View File
@@ -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>
);
}
+74
View File
@@ -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>
);
}
+68
View File
@@ -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&apos;t sent automatically.</p>
<p className="mt-1 text-muted">
This usually means SMTP isn&apos;t configured yet in <code className="font-mono">.env</code> (see
the README&apos;s &quot;Sending emails&quot; 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>
);
}
+14
View File
@@ -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');
}
+57
View File
@@ -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'}`);
}
+151
View File
@@ -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&apos;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>
);
}
+64
View File
@@ -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>
);
}
+136
View File
@@ -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 });
}
}
+16
View File
@@ -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 });
}
+51
View File
@@ -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);
}
+67
View File
@@ -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 });
}
+209
View File
@@ -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&apos;s shown in your preview above spelling, wording, and
placement included. Once your order&apos;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&apos;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&apos;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>
);
}
+17
View File
@@ -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>
);
}
+74
View File
@@ -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&apos;t find the order details on this page, but
you&apos;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>
);
}
+28
View File
@@ -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');
}
+90
View File
@@ -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&apos;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&apos;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>
);
}
+92
View File
@@ -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&apos;t see a cookie
consent banner on this site: UK law only requires consent for cookies that aren&apos;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 &quot;HTTP-only&quot; (scripts on the page can&apos;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&apos;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&apos;re taken to Stripe&apos;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&apos;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&apos;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&apos;ll be signed out and will need to log in again. If we ever add
non-essential cookies (such as analytics), we&apos;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>
);
}
+60
View File
@@ -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}`);
}
+121
View File
@@ -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>
);
}
+38
View File
@@ -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>
);
}
+73
View File
@@ -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&apos;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>
);
}
+91
View File
@@ -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;
}
}
+78
View File
@@ -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>
);
}
+45
View File
@@ -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>
);
}
+249
View File
@@ -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>
);
}
+332
View File
@@ -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&apos;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&apos;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>
);
}
+132
View File
@@ -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 &quot;data controller&quot; 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&apos;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&apos;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&apos;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&apos;ll respond within one month. If
you&apos;re unhappy with how we handle your data, you can complain to the Information
Commissioner&apos;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&apos;ll update
this page and the date at the top before the change takes effect.
</p>
</LegalPage>
);
}
+43
View File
@@ -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>
);
}
+258
View File
@@ -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
&quot;Products catalog&quot;.
</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>
);
}
+71
View File
@@ -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>
);
}
+12
View File
@@ -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&apos;re still
setting this section up.
</p>
</div>
);
}
+80
View File
@@ -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&apos;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&apos;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&apos;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&apos;s needed to inspect it.
</li>
<li>
We&apos;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&apos;t apply to them this is a standard exemption under
UK law for personalised goods. That&apos;s why we show your exact print preview in the bag
before checkout: what&apos;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
&quot;faulty items&quot; section above applies in full and we&apos;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&apos;ve processed a refund it typically appears in your account within 510 working
days, depending on your bank.
</p>
<p>
Anything unclear, or a problem not covered here? <Link href="/contact">Get in touch</Link>{' '}
we&apos;re a small business and we&apos;d always rather sort it out directly.
</p>
</LegalPage>
);
}
+115
View File
@@ -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 &amp; 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&apos;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&apos;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&apos;s rights.
</li>
<li>
We won&apos;t print material that is illegal, hateful, or that we reasonably believe
infringes someone else&apos;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&apos;t arrived within a reasonable time of the estimate,{' '}
<Link href="/contact">contact us</Link> and we&apos;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 &amp; 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&apos;t allow liability to be limited (for example, for
death or personal injury caused by negligence, or fraud). We aren&apos;t responsible for
losses that weren&apos;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&apos;d much rather you{' '}
<Link href="/contact">talk to us</Link> first.
</li>
</ul>
</LegalPage>
);
}
+82
View File
@@ -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&apos;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