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>
150 lines
5.5 KiB
Markdown
150 lines
5.5 KiB
Markdown
# 🧵 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.
|