Pak Jabit: full stack — storefront, Spree customisations, catalogue tooling

A South African men's apparel store on Spree 5.6 + Next.js 15. 160 products
live, three markets, live Malaysian courier rates, customer-owned carts.

What is here:
  storefront/  the Next.js app
  backend/     ONLY the custom Spree code. Spree itself is a prebuilt image
               that mounts no app code, so every file here needs an explicit
               volume entry in docker-compose.yml or it is silently ignored.
  catalogue/   the scripts that loaded and maintain the products
  docs/        manifest, mock registry, napkins

Real .env files are committed deliberately. This lives on the house's private
Gitea, and a clone that runs beats a clone that needs a secrets handover.
LANDMINE.md maps every key, what it opens, and what must be rotated before this
repo goes anywhere less private.

The admin API key used to be a literal in six catalogue scripts. They now read
SPREE_ADMIN_KEY from the environment and refuse to run without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-15 22:09:55 +08:00
co-authored by Claude Opus 5
commit a58a61b98c
98 changed files with 15524 additions and 0 deletions
+149
View File
@@ -0,0 +1,149 @@
# 🧵 Pak Jabit — men's apparel store
A South African e-commerce stack built for NSH. Spree Commerce backend, Next.js
storefront, 160 real products.
| | Live | What it is |
|---|---|---|
| Storefront | https://shop.jux-lab.com | Next.js 15 (App Router), systemd `pak-jabit-store` :3200 |
| Store API | https://api.jux-lab.com | Spree 5.6 in Docker |
| Admin | https://admin.jux-lab.com | Same container, split by hostname |
| Image Studio | https://admin.jux-lab.com/admin/image_studio | Custom admin page, Photoroom pipeline |
🔴 **Read [`LANDMINE.md`](LANDMINE.md) before touching anything.** Real
credentials are committed here on purpose (private Gitea), and several traps in
this stack fail *silently* rather than erroring.
---
## Layout
```
storefront/ Next.js app. Everything a shopper sees.
backend/ ONLY the custom Spree code + compose file. Spree itself is a
prebuilt image — see LANDMINE.md on bind mounts.
catalogue/ Scripts that loaded and maintain the 160 products.
docs/ Manifest, mock registry, and the napkins (ideas + open questions).
```
## Running it
**Backend** — on `pak-jabit-backend`:
```bash
cd ~/pak-jabit
docker compose up -d
docker compose exec web bin/rails runner 'puts Spree::Product.count'
```
**Storefront** — on `celest-proto`:
```bash
cd ~/pak-jabit-store
npm ci
npm run build
sudo systemctl restart pak-jabit-store
```
**Catalogue scripts** — they need the admin key in the environment and refuse to
run without it:
```bash
export SPREE_ADMIN_KEY=$(grep SPREE_ADMIN_KEY catalogue/.env | cut -d= -f2)
python3 catalogue/reprice_zar.py --dry-run
```
---
## How it fits together
### Region anchor — one choice, three consequences
A customer picks a **currency**. In Spree a market *is* currency + shippable
countries + shipping methods, so that single choice sets all three:
| Currency | Market | Ships to | Delivery |
|---|---|---|---|
| ZAR | South Africa | ZA | R99 flat (placeholder) |
| MYR | Malaysia | MY | Live EasyParcel courier rates |
| USD | United States | US | Free |
The choice lives in `localStorage` for guests and in the customer's
`public_metadata` when signed in; the profile wins.
⚠️ **Spree fixes a cart's currency AND market at creation and neither can change
afterwards.** That is why the anchor exists at all, and why switching currency
*rebuilds* the cart and re-adds every line rather than converting it.
🔴 This model has a known hole — one person with addresses in several countries.
Written up in [`docs/napkin-storefront.md`](docs/napkin-storefront.md) §2.
### Cart belongs to the customer
A guest cart lives in `localStorage`. On sign-in it is **claimed**
(`PATCH /carts/:id/associate`) and from then on the cart is fetched from the
server, so it follows the customer to any device.
### EasyParcel shipping is real, and verified server-side
Malaysian addresses get live courier quotes — 20 couriers, real prices. The
chosen one becomes the actual charge through a custom calculator
(`backend/config/initializers/easyparcel_calculator.rb`).
🔴 The price is confirmed against a fresh quote inside
`/api/shipping/select` before it can become a cost, and `/api/cart` **rejects**
any body carrying `metadata`. Both guards exist because cart metadata is
client-writable — without them a customer could set their own shipping to one
sen. Do not loosen either.
### Image Studio
Upload phone photos in the admin → Photoroom (background removal, lighting,
crop) → review before/after → attach to a product or create a new listing.
Nothing touches the catalogue until Apply.
---
## Traps that bite
Full list in [`docs/pak-jabit-manifest.md`](docs/pak-jabit-manifest.md) and
[`LANDMINE.md`](LANDMINE.md). The pattern worth internalising:
> **Spree's store API answers `200` and silently saves nothing** for fields it
> does not permit.
That cost three separate debugging rounds in one night — address `label`,
customer `public_metadata`, and product `price`. If a field vanishes after a
successful write, it was never permitted; the fix is a decorator in
`backend/config/initializers/`.
Others:
- **A shipping rate only applies when its calculator's currency matches the
order's.** A mismatch produces no rate, no fulfillment, and a checkout with
nothing to charge — and no error anywhere.
- **`POST /admin/products` drops a scalar `price`.** It needs
`prices: [{amount, currency}]`. A product with no price row in the store
currency is invisible on the storefront.
- **Product visibility is per-channel** via `Spree::ProductPublication`, not
`status`. No publication row → the join drops it, however active and priced.
- **New assets default to position 1** — they silently become the thumbnail.
- **`require` in a Rails initializer dies under Zeitwerk.** Define classes inside
`config.to_prepare` instead. It took the container down once.
- **`metadata` in the cart API maps to `private_metadata`**, not `public_metadata`.
- **`curl -o` loses binary bodies on the backend box** — 200, correct
`content-length`, zero-byte file. Use Python for binary downloads.
---
## Where it stands
**Working:** catalogue, currencies, cart, addresses, checkout in all three
markets, live MY shipping, saved cards (sandbox), Image Studio, nightly backups,
fail2ban.
**Not done:** real payment gateway (Payfast, gated on NSH merchant
registration), SA courier account (Bob Go), product variants — the S/M/L/XL
buttons are decorative, and stock/restock has never been tested.
See `docs/napkin-*.md` for the open questions and ideas.