Files
pak-jabit/docs/napkin-storefront.md
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.0 KiB

🛒 Storefront — Napkin Notes

Ideas and landmines for the shopper-facing side. Raised by abang 2026-09-15, after the night we wired the region anchor and put the cart on the customer.


1. Currency chosen after the total, not before 💡

The idea: let someone shop, reach the total, and then pick what currency to pay in. More natural than being asked up front, before they know what anything costs.

Why it's hard as things stand: Spree fixes a cart's currency and market at creation and neither can be changed afterwards. That is exactly why the anchor had to come first — see switchCurrency in CartProvider, which cannot convert a cart and instead builds a new one and re-adds every line.

So "choose at the end" is really "rebuild the basket at the end." That already works (the switcher does it), it just gets more expensive the fuller the basket, and the totals move under the customer at the worst moment.

Worth trying: show every currency's total on the cart/checkout page as a read-only comparison, and only rebuild when they commit. They get the "see it first" benefit without a silent re-price. The price rows already exist — all 160 products carry ZAR, USD and MYR.


2. 🔴 LANDMINE — one person, addresses in several countries

The case: a customer lives in South Africa, has an office in Kuala Lumpur, and family in the US. Perfectly ordinary. Our model does not survive it.

The mechanism. The region anchor ties three things into one choice, because a Spree market is all three:

currency  ->  market  ->  shippable countries  ->  shipping methods

So a customer anchored to ZAR can only deliver to ZA. To send something to their KL office they must switch to MYR, which rebuilds the cart and re-prices the whole basket — even though the only thing they wanted to change was where the parcel goes.

Worse, it is silent about the real reason. They'd read it as "this shop won't deliver to my office," not "delivery country is welded to display currency."

What is actually separable: currency and delivery country are independent in principle — you can price in ZAR and ship to Malaysia. What welds them here is that shipping methods are per-zone AND their calculators are per-currency (see MOCKS.md, the bug that killed Malaysian checkout). A ZAR order finds no rate in the Malaysia zone because every method there quotes in MYR.

Possible shapes, none built:

  • Delivery country separate from currency. Give each zone a method in every supported currency. A ZAR order shipping to MY charges a ZAR rate. Most honest to how people actually shop; most rows to maintain.
  • Ask on the address, not in the header. The customer picks a delivery address first and the store says plainly: "Delivering to Malaysia switches your prices to MYR." Same restriction, but named instead of hidden.
  • Single market, international rates. Drop the multi-market idea, price everything in ZAR, treat non-SA as international shipping. Simplest by far, and it matches the actual NSH brief — a South African store.

⚠️ Decide this before more countries get added, not after. Every product carries three price rows already; that is three times the pricing surface to keep honest for a market nobody has confirmed we sell to.


3. Dark / light mode selection

The storefront is dark-only right now (#0E0D0B ground, #EDE6DA text, #C8873A ochre). Abang shops in Brave with a dark setup, so dark stays the default — this is about offering the choice, not flipping it.

  • Respect prefers-color-scheme on first visit, then let an explicit toggle win
  • The palette is hardcoded as hex literals all through the components. A light theme means lifting those into CSS custom properties first — that refactor is most of the work, the theming itself is small after it
  • Store the choice next to the region anchor: localStorage for guests, public_metadata for signed-in customers (the plumbing for that exists now)

4. Admin — stock and restock, actually tested

Never exercised. Known state:

  • Two stock locations: Default (no country) and Shop location (US), both active, both propagate_all_variants = false
  • 161 products, every one a single variant — the S/M/L/XL buttons on the product page are decorative, there is no Spree::Variant behind them
  • No restock flow has been run, and no out-of-stock path has been seen end to end

Worth testing, in order:

  1. Set a variant to 0 on hand → does the storefront hide it, or let it be added to a cart and fail at checkout?
  2. Backorderable on/off — which one do we want for Lisa's stock?
  3. Restock through admin → does the storefront pick it up, and how fast?
  4. Two locations with nothing propagated: which one is a new product stocked in? This is the likeliest silent trap.

Variants matter here too: real S/M/L/XL means per-size stock, which is the whole point of restocking apparel. Right now "medium out of stock" cannot be expressed.


Started 2026-09-15 from abang's list. Add to it rather than remembering.