492 lines
23 KiB
Markdown
492 lines
23 KiB
Markdown
# Craft2Prints
|
||
|
||
A personalized e-commerce site: browse templates (apparel, drinkware, phone cases),
|
||
customize them with your own text/images in an in-browser design tool, and check out
|
||
with real Stripe payments. Built with Next.js (App Router), Prisma, and Stripe.
|
||
|
||
## Sharing via WhatsApp / Messenger
|
||
|
||
Instead of (or alongside) email, the confirmation screen after uploading a design
|
||
gives you two direct share buttons:
|
||
|
||
- **WhatsApp** works immediately, no setup — opens WhatsApp with the message and
|
||
link pre-filled, you just pick who to send it to.
|
||
- **Messenger** needs a one-time setup: create a free app at
|
||
https://developers.facebook.com/apps, add your site's domain under the app's
|
||
settings, copy the App ID, and set `NEXT_PUBLIC_FACEBOOK_APP_ID` in `.env`. Until
|
||
that's set, the Messenger button just won't appear — WhatsApp sharing still works
|
||
either way.
|
||
|
||
## Important: testing links on your phone
|
||
|
||
Right now `NEXT_PUBLIC_SITE_URL` is `http://localhost:3000`, which only works
|
||
**on the same computer that's running `npm run dev`**. If you open a design-approval
|
||
link on your phone or another device, it will fail to load — `localhost` always
|
||
means "this device," never your computer over the network. Some phones/carriers
|
||
redirect failed addresses like that to random landing pages, which can look like
|
||
a broken or suspicious link.
|
||
|
||
This isn't a bug to fix — it resolves itself once the site is deployed to a real
|
||
domain (later, when we cover deployment). Until then, only test approval links on
|
||
the same computer running the dev server.
|
||
|
||
## Sending emails
|
||
|
||
To have the approval link emailed to the customer automatically (instead of just
|
||
copying it yourself), add SMTP credentials to `.env`:
|
||
|
||
```
|
||
SMTP_HOST="smtp.gmail.com"
|
||
SMTP_PORT="587"
|
||
SMTP_USER="youraddress@gmail.com"
|
||
SMTP_PASSWORD="your app password"
|
||
MAIL_FROM="Craft2Prints <youraddress@gmail.com>"
|
||
```
|
||
|
||
For Gmail, `SMTP_PASSWORD` needs to be an **app password**, not your normal login
|
||
password — generate one at https://myaccount.google.com/apppasswords (requires
|
||
2-factor authentication to be turned on). Most other providers (Outlook, a business
|
||
email host, SendGrid, etc.) work the same way — check their SMTP settings page.
|
||
|
||
If `SMTP_HOST` is left blank, nothing breaks — the design still gets a shareable
|
||
link, you just have to copy and send it yourself instead of it emailing automatically.
|
||
|
||
If you upload a photo, drag the pink box that appears over it to mark exactly where
|
||
the design tool should let customers place their text/artwork — no more relying on
|
||
a generic preset. The preview crops to a square (matching how the design tool
|
||
displays it), so the box you draw lines up correctly for the customer later.
|
||
|
||
Optionally, upload a real photo instead of using the illustration. Worth knowing:
|
||
the color swatches recolor the illustration live, but a photo is fixed — picking a
|
||
different color still gets saved with the order, it just won't change the picture.
|
||
Leave the photo field blank to keep the illustration and its live color-recoloring.
|
||
|
||
#### Personalizing the back too
|
||
|
||
If you set a back photo, customers get a Front/Back toggle on the design page and
|
||
can add a completely separate design to each side — their own text/images on the
|
||
front, different ones on the back. When you upload the back photo, drag a print-area
|
||
box for it just like the front (defaults to the same box as the front if you skip
|
||
this). "Add to bag" captures both sides.
|
||
|
||
## Real photo per color
|
||
|
||
Each color's photo slot now has both a **Front** and **Back** upload — the "Photo per
|
||
color" section shows both side by side. Attach a back photo for a color and it shows
|
||
correctly when a customer views that color's back (falls back to the product's main
|
||
back photo, or the illustration, if left blank).
|
||
|
||
For the most accurate look, attach an actual photo for specific colors instead of
|
||
relying on the tint. Once you've added colors above, a "Photo per color" section
|
||
appears — attach a file to any color and that exact photo is shown the instant a
|
||
customer picks that color on the design page. Colors left blank fall back to the
|
||
front photo (tinted or fixed) or the illustration. This is the best option when you
|
||
have real supplier/product photography for each color — no tinting compromise, just
|
||
the true photo.
|
||
|
||
## Two separate product pages now
|
||
|
||
- `/made-to-order/[slug]` — the full design tool (only reachable for products with
|
||
"Personalised catalog" checked)
|
||
- `/products/[slug]` — a plain "pick a color/size, buy as-is" page, no design tool,
|
||
no print-area box (only reachable for products with "Products catalog" checked)
|
||
|
||
A product checked for both catalogs shows the design tool when reached via
|
||
`/made-to-order` and the plain buy-as-is page when reached via `/products` — same
|
||
photos and colors either way, just a different experience depending on which
|
||
catalog someone came from.
|
||
|
||
## Listing a product on both catalogs
|
||
|
||
On the product form there's a "Show on" section with two checkboxes:
|
||
|
||
- **Personalised catalog** (`/made-to-order`) — customers design their own version
|
||
- **Products catalog** (`/products`) — same photos, browse and buy as-is
|
||
|
||
Check either one or both. The same product record — same photos, colors, price —
|
||
can appear on both pages at once; nothing needs to be duplicated. `/products` now
|
||
has its own real search/category/color/price filters, same as `/made-to-order`, just
|
||
scoped to whichever products have that checkbox on. Each links to its own kind of
|
||
detail page — see "Two separate product pages" above.
|
||
|
||
## Currency
|
||
|
||
Prices are stored as an integer in **GBP pence** — that's the canonical currency for
|
||
the whole site. A currency selector in the header (£ GBP / $ USD / € EUR) lets
|
||
customers switch the *display* currency; it remembers their choice in the browser.
|
||
|
||
Important: the conversion rates in `src/lib/currency.ts` are fixed approximations,
|
||
not live exchange rates, and this only changes what's *displayed* — there's no
|
||
checkout yet, so no real payment currency conversion happens. Update those rates
|
||
periodically if you want displayed foreign-currency prices to stay roughly current,
|
||
and revisit this properly once Stripe checkout is built (Stripe can charge in
|
||
whichever currency you configure, which may or may not want to match what's shown
|
||
here).
|
||
|
||
Admin pages (product forms, proofs, order management) always show plain £ — they're
|
||
managing the actual stored value, not a customer-facing display, so they're not tied
|
||
to the selector.
|
||
|
||
## Cart storage
|
||
|
||
The cart is persisted to **IndexedDB**, not `localStorage`. This matters because
|
||
each personalized item can carry up to 4 full-size images (print files + placement
|
||
references) — that adds up fast, and `localStorage` has a hard ~5-10MB cap per site
|
||
that a cart with even 2-3 custom items can blow past, causing a hard crash
|
||
(`QuotaExceededError`) right when someone tries to check out. IndexedDB doesn't have
|
||
that low ceiling, so this is a real fix, not a workaround.
|
||
|
||
One thing worth knowing as the cart/checkout evolves further: the checkout request
|
||
itself sends the full cart (images included) from the browser to the server in one
|
||
go. That's fine at normal sizes, but if you ever see checkout failing specifically
|
||
with very large/many-image carts, that request payload size is the next place to
|
||
look — some hosts cap request body size (this isn't an issue on a normal self-hosted
|
||
Node server, but would be on some serverless platforms).
|
||
|
||
## Archiving orders
|
||
|
||
Paid or failed orders older than 30 days archive automatically — this happens as a
|
||
sweep each time you open Admin → Orders, not a background job, but it's effectively
|
||
invisible either way. You can also archive any order manually right away with the
|
||
button on its row or its detail page, no need to wait. Archived orders move out of
|
||
the main list; view them at Admin → Orders → "View archived," with an Unarchive
|
||
button to bring one back. Orders still `PENDING` are never auto-archived, only
|
||
`PAID` or `FAILED` ones.
|
||
|
||
## Checkout — setting it up
|
||
|
||
Checkout is now real: the cart page creates an Order in the database, sends the
|
||
customer to Stripe's hosted payment page, and a webhook marks the order paid once
|
||
Stripe confirms it. Here's what you need to actually make it work:
|
||
|
||
### 1. Get Stripe test keys
|
||
|
||
Sign up / log in at https://dashboard.stripe.com, make sure you're in **test mode**
|
||
(toggle top-right), then go to Developers → API keys. Copy the **Secret key**
|
||
(`sk_test_...`) into `.env`:
|
||
|
||
```
|
||
STRIPE_SECRET_KEY="sk_test_..."
|
||
```
|
||
|
||
### 2. Set up the webhook for local testing
|
||
|
||
Stripe needs to reach your webhook endpoint, but `localhost` isn't reachable from
|
||
the internet. For local development, use the Stripe CLI:
|
||
|
||
1. Install it: https://docs.stripe.com/stripe-cli (or `brew install stripe/stripe-cli/stripe` on Mac)
|
||
2. Run `stripe login` once
|
||
3. While your dev server is running, in another terminal: `stripe listen --forward-to localhost:3000/api/webhook`
|
||
4. It'll print a webhook signing secret starting `whsec_...` — copy that into `.env`:
|
||
```
|
||
STRIPE_WEBHOOK_SECRET="whsec_..."
|
||
```
|
||
5. Keep that `stripe listen` command running in the background whenever you're testing checkout locally — it forwards Stripe's events to your local server.
|
||
|
||
(Once deployed to a real domain, you'd instead create a proper webhook endpoint in
|
||
the Stripe Dashboard pointing at `https://yourdomain.com/api/webhook` and use that
|
||
endpoint's signing secret instead — the CLI approach is just for local dev.)
|
||
|
||
### 3. Test a payment
|
||
|
||
Add something to your bag, go to `/cart`, click Checkout. On Stripe's page, use a
|
||
test card: `4242 4242 4242 4242`, any future expiry date, any 3-digit CVC, any
|
||
postcode. It'll redirect to `/checkout/success`, and — assuming `stripe listen` is
|
||
running — the order should show as **PAID** within a second or two. Check
|
||
Admin → Orders to see it, download the print-ready PNGs, etc.
|
||
|
||
### 4. Going live later
|
||
|
||
When you're ready for real payments: switch to your live Stripe keys (toggle out of
|
||
test mode in the dashboard), set up a real webhook endpoint in the Stripe Dashboard
|
||
for your live domain, and update `.env` with the live secret key and that webhook's
|
||
signing secret. Also worth adding: order confirmation emails to the customer (not
|
||
built yet — currently they only see the on-screen confirmation and whatever Stripe
|
||
itself emails them), and probably restricting `shipping_address_collection` in
|
||
`src/app/api/checkout/route.ts` to just the countries you actually ship to.
|
||
|
||
## Print-ready design files
|
||
|
||
The exported design image (used for the cart preview, and downloadable from the
|
||
cart page and from Admin → Orders) is just the customer's artwork — transparent
|
||
background, no shirt, no print-area guide marks baked in. Every order's items show
|
||
"Download front PNG" / "Download back PNG" links on its Admin → Orders detail page,
|
||
so once a real order comes in, that's where to get the print files from.
|
||
|
||
**Two images per item, two different jobs:** alongside that clean print file, there's
|
||
also a "placement reference" image — the design shown composited on the actual
|
||
product photo, so you can see exactly where it sits before you print it. That's the
|
||
larger thumbnail shown on the order detail page (and on the cart/success pages);
|
||
the download links next to it are the clean, print-only files. For products using
|
||
the illustration (no real photo uploaded), there's no placement reference to show —
|
||
only real photos can be composited this way.
|
||
|
||
## Adding products
|
||
|
||
Go to Admin → Add product (or `/admin/products/new`). Fill in the name, category
|
||
(pulled from your manageable category list — see below), price, and colors (click a
|
||
swatch to add it, no hex-typing needed). Pick a template shape (t-shirt, hoodie, mug,
|
||
tumbler, or phone case) to use the built-in illustration, or upload your own front —
|
||
and optionally back — photo instead. It goes straight into the personalized catalog
|
||
at `/made-to-order`.
|
||
|
||
Manage what's there at `/admin/products` — view, **edit** (change anything, including
|
||
swapping the photo or colors later), or delete. Deleting is blocked if a product
|
||
already has orders or design proofs tied to it, to avoid orphaning that history.
|
||
|
||
### Recoloring a photo (front/back + live color tint)
|
||
|
||
If you upload a photo, drag the pink box that appears over it to mark exactly where
|
||
the design tool should let customers place their text/artwork. You can also add a
|
||
**back photo** — customers get a Front/Back toggle (the design tool only applies to
|
||
the front).
|
||
|
||
Check **"Let the color swatches recolor this photo"** to have the color picker
|
||
actually retint the photo live, instead of just being a label. This only looks good
|
||
for photos that are black, white, or gray — the color is applied as a multiply-blend
|
||
tint, so a photo that's already colored (like a red mug) will just look muddy if you
|
||
turn this on. For black-photo products specifically: expect punchy mid-tone colors
|
||
(oranges, blues, greens) to look great, but very light colors (white, pastels) will
|
||
come out as a muted gray rather than true white — that's a real limit of tinting a
|
||
photo that has no bright highlight data to begin with, not a bug. If you need a true
|
||
white variant to look right, upload a separate white-photo product instead of relying
|
||
on the tint.
|
||
|
||
## Scoping categories to a catalog
|
||
|
||
Categories can now be scoped too, same idea as products: on Admin → Add category,
|
||
two checkboxes control where a category shows up as a filter — Personalised
|
||
(`/made-to-order`), Products (`/products`), or both (the default). Toggle these on
|
||
existing categories from Admin → All categories. This only affects which catalog's
|
||
filter sidebar shows the category — it doesn't restrict which products can use it.
|
||
|
||
## Managing categories
|
||
|
||
Categories are managed from Admin → Add category / All categories, not hardcoded. A
|
||
new category shows up immediately in: the product form's category dropdown, the
|
||
shop's filter sidebar, and the "Personalised" menu in the header. Deleting a category
|
||
is blocked if any product is still using it.
|
||
|
||
## Sizes
|
||
|
||
On the product form, add sizes via the preset buttons (XS–XXL) or type a custom one
|
||
(for anything non-apparel-shaped, like "One Size" or a numeric size) and hit Enter.
|
||
Leave it empty for products that don't need sizing — mugs, phone cases, etc. When a
|
||
product has sizes, a size picker automatically appears on its design page, and the
|
||
chosen size is saved with the cart item.
|
||
|
||
This only applies to the personalized catalog (`/made-to-order`) for now — the
|
||
ready-made catalog (`/products`) is still just a placeholder page, so there's nowhere
|
||
for a size picker to live yet.
|
||
|
||
## Dark mode
|
||
|
||
There's a toggle in the header (sun/moon icon) that switches between light and dark.
|
||
It respects the visitor's system preference on first visit, then remembers whatever
|
||
they pick in `localStorage` after that. Most of the site flips automatically since
|
||
the color system is built on a handful of tokens (background, text, borders, card
|
||
surfaces) that swap together — new pages you add will pick this up for free as long
|
||
as they use the existing `bg-paper` / `text-ink` / `border-line` / `bg-surface`
|
||
classes rather than hardcoded colors like `bg-white`.
|
||
|
||
## Chat with customers
|
||
|
||
Each design proof has its own chat thread. The customer sees it right on their
|
||
approval page (`/proofs/[id]`) and can ask for changes without emailing back and
|
||
forth. You see and reply to every conversation at `/admin/inbox`.
|
||
|
||
It's polling-based (checks for new messages every few seconds) rather than true
|
||
real-time — simple and reliable to self-host, just a couple seconds of lag instead
|
||
of instant delivery. The initial email still goes out when you upload a design, so
|
||
the customer gets pinged even if they're not looking at the page; the chat is for
|
||
the conversation that follows.
|
||
|
||
## Custom design approvals (you upload, customer approves)
|
||
|
||
For one-off custom jobs — you design a T-shirt yourself and want a specific customer
|
||
to review and buy it — there's a separate flow from the self-serve design tool:
|
||
|
||
1. Go to `http://localhost:3000/admin/proofs/new` (password-protected, see below)
|
||
2. Pick the product template, enter the customer's email, upload your finished
|
||
design image, set color/quantity/price
|
||
3. You'll get a shareable link like `/proofs/abc123`
|
||
4. Send that link to the customer any way you like (email, text)
|
||
5. They open it, see the design, and click "Approve & add to bag" — it goes straight
|
||
into their cart, ready for checkout
|
||
|
||
See everything you've sent at `/admin/proofs`.
|
||
|
||
**Change the admin password before deploying.** The `/admin` pages are protected by a
|
||
login page at `/admin/login`, checked against `ADMIN_USER` / `ADMIN_PASSWORD` in `.env`.
|
||
The defaults (`admin` / `changeme`) are fine for local testing but must be changed
|
||
before this site is reachable from the internet. Once logged in, "Log out" is in the
|
||
Admin dropdown in the header. This uses its own session cookie, signed with
|
||
`ADMIN_SESSION_SECRET` — **change that to a random value before deploying too** (a
|
||
fresh one has already been generated into your local `.env`).
|
||
|
||
## Customer photo requests (they upload, you turn it into a design)
|
||
|
||
The other direction from the flow above — a customer can send you their own photo
|
||
for you to work with, instead of you already having a finished design ready:
|
||
|
||
1. They visit `/custom-request`, pick a product, upload their photo, and leave their
|
||
contact info (name, email, optional phone) and a note
|
||
2. You get an email alert (see "Sending emails" — same SMTP setup) and can browse all
|
||
submissions at `/admin/custom-requests`
|
||
3. Open a submission, and once you've worked on it, click "Start a design for this" —
|
||
it takes you straight to `/admin/proofs/new` with the product/customer details
|
||
pre-filled, so you just upload the finished result and send it back through the
|
||
normal proof-approval flow above
|
||
4. Each submission also has a one-click WhatsApp/Messenger button (opens pre-filled,
|
||
for you to send/forward manually) and, if they left a phone number, a direct
|
||
"Message on WhatsApp" link addressed to them
|
||
|
||
Set `ADMIN_NOTIFY_EMAIL` in `.env` to choose where new-submission alerts go — leave it
|
||
blank and it defaults to your `SMTP_USER` address.
|
||
|
||
## Customer accounts
|
||
|
||
Separate from the admin login above, customers can create their own accounts to see
|
||
their order history:
|
||
|
||
- `/account/register` and `/account/login` — sign up / sign in
|
||
- `/account` — order history (only orders placed while logged in, plus any past guest
|
||
orders that used the same email — those get linked in automatically the first time
|
||
someone registers or logs in)
|
||
- `/account/forgot-password` — emails a reset link (uses the same SMTP setup as
|
||
design-approval emails, see "Sending emails" above; if SMTP isn't configured you'll
|
||
just see a generic confirmation with nothing actually sent)
|
||
|
||
Checkout still works without an account — customer accounts are optional, not
|
||
required to buy.
|
||
|
||
This uses its own login cookie (`SESSION_SECRET` in `.env`), completely separate from
|
||
the admin `ADMIN_USER`/`ADMIN_PASSWORD` — logging into one never grants access to the
|
||
other. **Change `SESSION_SECRET` to a random value before deploying**, same as the
|
||
admin password (a fresh one has already been generated into your local `.env`, so
|
||
this only matters when you deploy somewhere new).
|
||
|
||
## Security: rate limiting & CAPTCHA
|
||
|
||
- **Login rate limiting** works with no setup — customer login (`/account/login`),
|
||
admin login (`/admin/login`), and the checkout API are all limited per IP address
|
||
(5 attempts / 15 min for logins, 20 requests / 10 min for checkout), backed by the
|
||
database so it works the same on a single server or serverless. Nothing to configure.
|
||
- **CAPTCHA** (Cloudflare Turnstile) protects the custom-request, account registration,
|
||
newsletter signup, and contact forms — but only once you set `NEXT_PUBLIC_TURNSTILE_SITE_KEY`
|
||
and `TURNSTILE_SECRET_KEY` in `.env`. Get a free site key + secret at
|
||
[dash.cloudflare.com](https://dash.cloudflare.com) under Turnstile. Until those are
|
||
set, the forms work normally with no CAPTCHA shown — it's opt-in, not required to
|
||
run the site.
|
||
|
||
## What's built so far
|
||
|
||
- Homepage (`/`) with a split path between Personalised and Products
|
||
- Personalized product catalog with search, category, color, and price filters (`/made-to-order`)
|
||
- Product detail page with the design tool, reviews, and related products (`/made-to-order/[slug]`)
|
||
- Full product management: add, edit, delete, with admin-managed categories and sizes (`/admin/products`, `/admin/categories`)
|
||
- Custom design approval flow (`/admin/proofs/new`, `/admin/proofs`, `/proofs/[id]`)
|
||
- Per-design chat between you and the customer (`/admin/inbox`)
|
||
- Dark mode
|
||
- Cart page (`/cart`) — view, adjust quantity, remove items, see total
|
||
- Real checkout via Stripe, order persistence, and admin order management (`/admin/orders`)
|
||
|
||
Still to come: order confirmation emails, and a "buy it now" flow that skips
|
||
straight to checkout for a single item (right now everything goes through the bag).
|
||
|
||
## Updating an existing install
|
||
|
||
Already had this running before? After pulling in new files:
|
||
|
||
```bash
|
||
npx prisma generate
|
||
npm run db:push
|
||
npm run db:seed
|
||
```
|
||
|
||
`db:push` is safe to re-run — it only adds what's changed (new tables/columns), it
|
||
won't wipe your existing data. `db:seed` is also safe to re-run: it only touches the
|
||
6 starter products that ship with this repo (matched by their slug — classic-tee,
|
||
heavyweight-hoodie, etc.), syncing them to whatever's currently in `prisma/seed.ts`.
|
||
Any products or categories you've added yourself through the admin panel are never
|
||
touched by it. Also check `.env.example` for any new variables and add them to your
|
||
own `.env`.
|
||
|
||
## 1. Install prerequisites
|
||
|
||
You need **Node.js 18 or later**. Check with:
|
||
|
||
```bash
|
||
node -v
|
||
```
|
||
|
||
If you don't have it, install it from https://nodejs.org (the LTS version is fine).
|
||
|
||
## 2. Install dependencies
|
||
|
||
From the project folder (where this README lives):
|
||
|
||
```bash
|
||
npm install
|
||
```
|
||
|
||
This will also automatically run `prisma generate` (via the `postinstall` script).
|
||
|
||
## 3. Set up your environment file
|
||
|
||
Copy the example file:
|
||
|
||
```bash
|
||
cp .env.example .env
|
||
```
|
||
|
||
Open `.env` and fill in:
|
||
- `DATABASE_URL` — leave as `file:./dev.db` for now (SQLite, zero config)
|
||
- `STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET` — get these later from
|
||
https://dashboard.stripe.com/apikeys once we build checkout. Not needed yet to run
|
||
the homepage/catalog.
|
||
- `NEXT_PUBLIC_SITE_URL` — `http://localhost:3000` for local dev
|
||
|
||
## 4. Create the database and load starter products
|
||
|
||
```bash
|
||
npm run db:push
|
||
npm run db:seed
|
||
```
|
||
|
||
`db:push` creates the SQLite database file from `prisma/schema.prisma`.
|
||
`db:seed` loads the 6 starter products (tee, hoodie, mug, tumbler, 2 phone cases).
|
||
|
||
## 5. Run it
|
||
|
||
```bash
|
||
npm run dev
|
||
```
|
||
|
||
Open http://localhost:3000 in your browser. You should see the homepage; click
|
||
"Shop all" to see the catalog page with filters.
|
||
|
||
Any time you change code, the page auto-reloads. If you change `prisma/schema.prisma`,
|
||
re-run `npm run db:push`.
|
||
|
||
## Troubleshooting
|
||
|
||
- **"Cannot find module '@prisma/client'"** — run `npx prisma generate` manually.
|
||
- **Port 3000 already in use** — stop whatever else is running on that port, or run
|
||
`npm run dev -- -p 3001` to use a different port.
|
||
- **Blank product grid** — make sure you ran `npm run db:seed`.
|
||
|
||
## Deploying to your own server later
|
||
|
||
This app is a standard Next.js app, so once it's finished it can run anywhere that
|
||
supports Node.js:
|
||
|
||
```bash
|
||
npm run build
|
||
npm run start # serves on port 3000 by default
|
||
```
|
||
|
||
Put it behind nginx (or similar) as a reverse proxy, and use a process manager like
|
||
`pm2` to keep it running. We'll cover this in detail once the whole app is built and
|
||
ready to deploy.
|