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>
🧵 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
200and 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/productsdrops a scalarprice. It needsprices: [{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, notstatus. No publication row → the join drops it, however active and priced. - New assets default to position 1 — they silently become the thumbnail.
requirein a Rails initializer dies under Zeitwerk. Define classes insideconfig.to_prepareinstead. It took the container down once.metadatain the cart API maps toprivate_metadata, notpublic_metadata.curl -oloses binary bodies on the backend box — 200, correctcontent-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.