Files
juxtapoandClaude Opus 5 a58a61b98c 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>
2026-09-15 22:09:55 +08:00

5.5 KiB

🧵 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 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:

cd ~/pak-jabit
docker compose up -d
docker compose exec web bin/rails runner 'puts Spree::Product.count'

Storefront — on celest-proto:

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:

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 §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 and 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.