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:
@@ -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.
|
||||
Reference in New Issue
Block a user