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:
2026-09-15 22:09:55 +08:00
co-authored by Claude Opus 5
commit a58a61b98c
98 changed files with 15524 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
# 🏷️ MOCKS — every fake value in Pak Jabit
**The convention:** anything standing in for a real credential, rate, or record is
tagged `#mock`. Going live = find them all, replace them, delete this list.
```bash
# storefront + backend files
rg '#mock' ~/pak-jabit-store ~/pak-jabit
```
🔴 **`rg` cannot find half of them.** Mocks that live in the Spree **database** —
payment methods, test products, test orders, test users — have no file to grep.
This file is the authority; the inline tags are a convenience. When you add a
mock, add it here first.
⚠️ **Never tag an env var inline.** Docker Compose's `env_file` does not strip
trailing comments — it takes the rest of the line as the value, so
`PHOTOROOM_API_KEY=abc123 #mock` makes the key literally `abc123 #mock` and
Photoroom fails with no error anywhere. The tag goes on its **own line above**,
which is safe in both Compose and dotenv.
---
## Files — `rg '#mock'` finds these
### Storefront `~/pak-jabit-store/.env.production` (celest-proto, 34.21.217.116)
| Var | Why it's mock | Replace with |
|---|---|---|
| `EASYPARCEL_CLIENT_ID` | Malaysia-only courier, left from the MY phase | Bob Go credentials |
| `EASYPARCEL_CLIENT_SECRET` | same | Bob Go credentials |
| `STORE_POSTCODE` | `10150` = Penang, Malaysia | real SA warehouse postcode |
| `STORE_STATE` | `MY-07` = Penang, Malaysia | real SA province |
The whole EasyParcel lane (`src/lib/easyparcel.ts`, `src/app/api/shipping/*`,
`src/app/api/webhooks/easyparcel`) is MY-only and dead for a South African store.
It points at `https://api.easyparcel.com` — the production host, not a sandbox.
It is not wired into the SA checkout path; it just hasn't been removed.
### Backend `~/pak-jabit/.env` (pak-jabit-backend, 35.247.170.3)
| Var | Why it's mock | Replace with |
|---|---|---|
| `PHOTOROOM_API_KEY` | sandbox key — **every output is watermarked** | live key from `/root/.photoroom/credentials` (house box), 10/month |
### Storefront env — `SPREE_CARD_PAYMENT_METHOD_ID`
Tagged in `.env.production`. Holds `pm_EfhxLZ9ck8`, the prefixed id of the Bogus
gateway. Repoint it at the Payfast payment method when that lands. The route that
reads it (`src/app/api/auth/cards/setup/route.ts`) errors loud with an explicit
message when it is unset — it never guesses an id, because a wrong one would
attach cards to whichever gateway happened to match.
That route is itself `#mock`-shaped: it does BOTH halves of card setup in one
request, which only works because Bogus needs no card entry. A real gateway must
split it — server creates the session, the CLIENT collects the card in the
gateway's hosted field against `external_client_secret`, then the server
completes. Card numbers must never reach our server. The file says so at the top.
---
## Database — grep will never find these
### Payment methods
| Name | Class | Why it's mock |
|---|---|---|
| `Test Card (sandbox)` | `Spree::Gateway::Bogus` | Fake card gateway, added 2026-09-15. Accepts `4111111111111111` (Visa), `5555555555554444` (MC), `378282246310005` (Amex), `6011111111111117` (Discover); **anything else is a forced decline**, which is how to test the failure path. `show_in_admin?` is false so it stays out of the admin list. |
| `Test Payment` | `Spree::PaymentMethod::Check` | Offline placeholder. No money moves. |
Both carry `#mock` in their `description` column, visible in admin.
**Replace with:** Payfast, once NSH's merchant registration lands. Payflex after.
Deactivate both of these before launch — a live store must not offer a gateway
that approves every card ending in the right digits.
### Shipping methods
| Name | Why it's mock |
|---|---|
| `Standard Delivery (South Africa)` | Flat **R99**, zone `South Africa` (ZA only), added 2026-09-15. The number is a PLACEHOLDER, not a quoted rate — it exists so SA checkout can complete at all. Replace with Bob Go once the SA courier account is registered. |
Before this, ZA sat in the `Africa` zone with no method attached and every
physical method was bound to Malaysia/US, so a South African buyer got no quote
and checkout dead-ended.
### Shipping rates per market — all placeholders
| Market | Method | Rate |
|---|---|---|
| South Africa (ZAR) | `Standard Delivery (South Africa)` | R99 |
| Malaysia (MYR) | `EasyParcel Domestic` | **live EasyParcel quote** — not mock |
| United States (USD) | `Free Shipping` | $0 |
🔴 **ZA and US are placeholders; Malaysia is real.** MY charges the courier the customer picked, verified server-side against a live EasyParcel quote. They exist so each market can complete a
checkout at all. A shipping rate only applies when its calculator's currency
matches the ORDER's currency — every non-SA method was priced in a currency no
order used, which produced no rate, no fulfillment, and a checkout with nothing
to charge. That, not zones or stock, is why Malaysian checkout died silently.
`Standard Shipping` (USD $15) sits in the Malaysia zone and will never offer —
wrong currency for that market. Harmless, but it is noise to clear when the MY
lane is removed.
### Products
| Slug | Why it's mock |
|---|---|
| `studio-test-chinos` | Built 2026-09-15 proving the Image Studio pipe end to end. Kept deliberately as the worked example for colleagues testing the image editor. R259.11, watermarked sandbox images. **Delete before launch.** |
That's 1 of 161 products. The other 160 are real scraped stock.
### Orders
14 orders, 4 completed, all test. They are priced in the **old currency at the
old 23× rate** — the newest completed one reads `$2,577.12`, which is neither
ZAR nor a real price. They predate both the price fix and the ZAR switch.
**Purge before launch** or the first real report is nonsense.
### Users
Test accounts matching `celeste-*@example.com` — created during API verification
on 2026-09-15. No real customers exist yet.
---
## Not mock, but not real either
These aren't fake credentials; they're claims the store makes that nothing backs.
| Thing | Problem |
|---|---|
| **"Free shipping over R500"** | Hardcoded storefront copy. No shipping rule exists behind it. A buyer who qualifies gets charged anyway — or gets no quote at all, see below. |
| **SA shipping** | ZA is in the `Africa` zone with **no shipping method attached**. Every physical method is bound to Malaysia/US. A South African buyer gets no quote and cannot complete checkout. Blocked on courier registration. |
| **S/M/L/XL buttons** | Decorative. No `Spree::Variant` behind them — every product is one variant. |
---
*Started 2026-09-15. Add to this file when you add a mock, not after.*
+169
View File
@@ -0,0 +1,169 @@
# 🎨 Image Pipeline — Napkin Notes
## What the spec wants
Seller uploads phone photo → system auto-standardizes → consistent product catalogue
- Background removal
- Colour correction
- Lighting normalization
- Resize/crop to exact same dimensions
- Store processed image against product variant
## What we found (FAFO'd 2026-09-13)
### 🥇 Photoroom (recommended for production)
- **URL:** https://app.photoroom.com/create (signed up as `celestjux`)
- **Docs:** https://docs.photoroom.com/
- **Pricing:** Basic $20/mo (BG removal only) · Plus $100/mo (full pipeline) · Enterprise custom
- **Per image:** $0.02 (Basic) / $0.10 (Plus)
- **Apparel-specific features:**
- Ghost Mannequin — makes clothes look worn without a real model
- Virtual Model — AI fashion models wearing the clothes
- Virtual Try-On — customers see clothes on themselves
- AI Ironing — smooth wrinkles on clothes
- AI Relight — fix lighting
- AI Shadows — realistic shadows
- Product Staging, AI Backgrounds, AI Beautifier, AI Expand
- **Batch:** yes, directory/Google Drive/Sheets
- **Sandbox:** 1,000 free watermarked calls + 10 real for testing
- **Verdict:** Does EVERYTHING the spec asks in one API call. $100/mo for 1K images.
Ghost mannequin + AI ironing = killer for men's apparel.
### 🥈 Bria AI (good BG removal, lighter features)
- **API key:** saved at `~/.bria/credentials`
- **Skills installed:** `/root/.claude/skills/bria-skills/`
- **Tested:** ✅ BG removal works (RMBG 2.0), 39% transparent pixels on test image
- **Features:** BG removal, relighting, upscale, product photography, object editing
- **No ghost mannequin, no AI ironing, no virtual try-on**
- **Verdict:** Good for BG removal + basic edits. Fallback to Photoroom for apparel-specific stuff.
### 🥉 rembg (free, self-hosted, offline)
- **Installed:** ✅ via pip, u2net model (170MB) cached at `/root/.rembg/models/`
- **Tested:** ✅ works, decent edges, white-on-light handled ok
- **Limit:** BG removal ONLY. No colour correction, no lighting, no crop.
- **Speed:** ~20s on CPU per image
- **Verdict:** Free forever. Good offline fallback. Need OpenCV/Pillow on top for the rest.
### Quick-RMBG / Claude Background Removal Plugin
- **Repo:** cloned at `/mnt/playground/Playground_V2/Claude-Background-Removal-Plugin/`
- **What it is:** Claude Code skill wrapper around rembg (single/two-pass/batch/Dolphin right-click)
- **pipx install blocked** by ctx git shim — not installed yet
- **Verdict:** Nice wrapper but not needed if we use Photoroom/Bria for the platform.
## Architecture decision
```
Production: Photoroom Plus API ($100/mo)
└─ one call = bg removal + lighting + shadows + crop + resize
└─ ghost mannequin for clothes-on-invisible-model shots
└─ AI ironing for wrinkle-free product photos
Fallback: Bria RMBG 2.0 (free tier / per-image)
└─ BG removal when Photoroom is down or over quota
Offline: rembg + Pillow/OpenCV
└─ dev/testing, batch processing without API costs
```
## ✅ BUILT 2026-09-15 evening — the pipe is real, end to end
```
phone/disk photo -> Image Studio (admin page) -> Photoroom /v2/edit
-> unattached ActiveStorage blob -> review before/after
-> Apply -> Spree product (existing, or created here)
```
- **Admin page:** `/admin/image_studio` (sidebar, between Products and Customers).
Multi-upload from the device, background + size presets, per-file before/after,
then attach to an existing product **or create a new listing** (name, price,
publish toggle). Nothing touches the catalogue until Apply — the result is held
as an unattached blob, so Discard is just navigating away.
- **CLI:** `scraped-catalogue/photoroom_to_spree.py --slug <slug> [--live]`
- **Proven live:** a 6.52 MB phone PNG → 543 KB at 1000×1000; and two uploads →
a new published product visible on the storefront at R259.11.
### The architecture question, settled
Abang's call, and he was right: **the page is needed.** I had argued for a silent
auto-on-upload hook (the spec says *"seller uploads → system standardises"*), but
a seller wants to choose the shot and see the result before it ships. Fully
automatic means a bad cutout goes live silently and the customer finds it first.
**Manual control at the front, programmatic pipe behind it.**
Photoroom's *Shopify Products* connector is a red herring for us — it exists for
merchants with no backend. We have one, so the plain API is the simpler path.
### Money, accurately
| | Reality |
|---|---|
| API free tier | **10 images/month**, not 7 days. 9 left as of 2026-09-15 |
| Sandbox | 1000/month, free, **every output watermarked** |
| 7-day trial | That's the **web app**, a separate meter. Its Batch tool does **250 images at once** through the browser and costs no API credits |
| Plus | $100/mo for the full pipeline |
🔴 **Photoroom adds little to the existing 160 products.** They are scraped
studio shots already on white — background removal removes a background that is
already gone. Across 160 first-images there are only **3 aspect ratios and 4
sizes**; framing is the sole real defect, and Pillow squares them for free,
offline. Photoroom earns its money on *phone photos of new stock*, which is
exactly what Image Studio is for.
### Still open
- [ ] **Batch** — capped at `MAX_PHOTOS = 8`, synchronous, ~3-5s per image. More
needs a background job
- [ ] Currently on the **sandbox** key, so everything is watermarked. Flip
`PHOTOROOM_API_KEY` in the backend `.env` for clean output
- [x] ~~Pillow square-and-pad backfill for the existing 160~~ — **scrapped 2026-09-15,
measured and unnecessary.** See below.
- [ ] Never tested on a real phone photo from Lisa — the actual input this exists
for. Ghost mannequin and AI ironing have nothing to work with on a scraped
studio shot
- [ ] Virtual Try-On — could be a differentiator for the platform
- [ ] Self-hosted option — for v2, when volume justifies building our own pipeline
## 📐 Framing survey — 2026-09-15, the backfill that wasn't needed
I had the Pillow square-and-pad backfill queued as the next job. Measuring the
160 source images first killed it. The real distribution of **first images**:
| Size | Ratio | Count |
|---|---|---|
| 762×1100 | 0.693 | 122 |
| 800×1200 / 801×1200 | 0.667 | 32 |
| 1200×1800 | 0.667 | 4 |
| 1524×2200 | 0.693 | 1 |
| **2400×2400** | **1.000** | **1** |
The grid renders at `aspect-[3/4]` (0.75) with `object-cover`. At 0.693 the crop
takes 7.6% of the height; at 0.667 it takes 11%. The **entire** inconsistency
across 159 products is a 3.4% difference in vertical crop — invisible. Padding
them to square would also be self-defeating: a square displayed in a 3/4 frame
gets re-cropped, so the padding I just added is what gets cut.
🔴 **One image was genuinely broken** — `129-adidas-lifestyle-barreda-shoes-men-white-jp5928`,
the only 1:1 in the catalogue. A square in a 3/4 frame loses **12.5% off each
side**, which cut the heel and the toe off the shoe. Its 22 sibling shoe
listings are all 762×1100 with the subject at 90-95% width and 24-33% height, so
it was reframed to match: crop to the subject bbox, scale to 93% width, centre on
the source's own background (234,238,239). 186KB → 65KB. Applied live via
`reframe_129.py --apply` (upload at position 1, verify by re-read, then delete
the original).
**The lesson, not the fix:** the napkin said "3 aspect ratios and 4 sizes,
framing is the sole real defect" and I turned that into a 160-file job. The
numbers were right and the conclusion was wrong — nobody had checked how far
apart those ratios actually were. Measure the spread, not the count.
### Spree API shape — two gotchas that cost a run
- **Flat objects, not JSON:API.** `/api/v3/admin/products` returns fields on the
object itself (`p["slug"]`), not under `p["attributes"]["slug"]`. The helper in
`photoroom_to_spree.py` only survives because its fallback scan happens to use
the flat form.
- **`per_page` is silently ignored** — it pages on `limit`. Sending `per_page=100`
gets you 25 rows and no error, so a scan that stops at page 1 "finds" nothing.
Meta key is `pages`, not `total_pages`.
**Keys:** `/root/.photoroom/credentials` (chmod 600) on the house box;
`PHOTOROOM_API_KEY` in the backend `.env`.
+109
View File
@@ -0,0 +1,109 @@
# 💳 Payment & Courier — Napkin Notes
## Payment Gateways (SA Market)
### Recommended Stack
| Gateway | What it covers | Fees | Priority |
|---------|---------------|------|----------|
| **Payfast** | Cards (Visa/MC), EFT, SnapScan, Masterpass | 3.5% + R2/tx | Must-have |
| **Payflex** | Buy now pay later (4 instalments) | They pay you upfront | High — big conversion booster for fashion |
| **Yoco** | Cards, tap-to-pay (if physical retail later) | 2.6-2.95%/tx | Nice-to-have |
### Why this stack
- Payfast alone covers ~90% of SA payment methods
- Payflex is the killer feature for apparel — "R1,500 jacket? Pay R375 × 4" removes price friction
- Cards (Visa/MC/Amex) come free with Payfast
- EFT = still king in SA, people trust bank transfers
### NOT needed (yet)
- Stripe — limited SA support, overkill for local-first
- PayPal — high fees, withdrawal pain in SA
- Crypto — niche, not the market
### Integration notes
- Spree has payment provider plugin architecture — write adapter, plug in API keys
- Morne's company (NSH/INET) registers as merchant — they get the API keys
- We build the pipe, they plug in credentials
- Payfast has well-documented REST API + existing Spree community plugins
---
## Courier / Logistics (SA Market)
### Recommended: Bob Go (aggregator)
- **One API** → connects to 10+ SA couriers
- API docs: https://api-docs.bob.co.za/bobgo
- Couriers included: The Courier Guy, Pargo, RAM, SkyNet, Internet Express, DPD/Dawn Wing
- Features: compare rates, print waybills, track parcels
- Shopify integration exists → Spree adapter needed
### What Bob Go solves (all 3 spec blockers)
| Spec Action Item | Status |
|-----------------|--------|
| 🔴 Identify suitable SA courier/API | ✅ Bob Go = one API, all couriers |
| 🔴 Confirm automated shipment creation | ✅ Bob Go API supports it |
| 🔴 Confirm waybill generation | ✅ Bob Go handles waybills |
### Direct courier alternatives (if Bob Go doesn't fit)
- **The Courier Guy** — most popular SA e-commerce courier
- **Internet Express** — free API integration
- **CourierIT** — volume discounts
- **Evership** — another aggregator (Fastway, RAM, Aramex, TCG, DPD)
### Shipping zones consideration
- SA is big — courier coverage varies by province
- Major metros (Joburg, Cape Town, Durban) = next day
- Rural = 3-5 days, some couriers don't cover
- Bob Go handles zone logic per courier
---
## 🏷️ Mock Convention
All test/sandbox values tagged with `#mock` in code and config.
When going live: `rg '#mock'` → find all → replace with production credentials.
```
# Example in config:
PAYFAST_MERCHANT_ID=10000100 #mock
PAYFAST_MERCHANT_KEY=46f0cd694581a #mock
PAYFAST_PASSPHRASE=jt7NOE43FZPn #mock
PAYFAST_MODE=sandbox #mock → change to "live"
BOBGO_API_KEY=test_xxxxxxxxxxxx #mock
PAYFLEX_API_KEY=staging_xxxxx #mock
```
---
## Open Questions
- [ ] Local SA only or international shipping?
- [ ] Currency: ZAR only or multi-currency?
- [ ] Who registers as merchant — NSH or a new entity?
- [ ] Cash on delivery — needed? (Still popular outside metros)
- [ ] Payflex merchant approval — timeline?
---
## Architecture Summary
```
Buyer → Spree Storefront (Next.js)
↓
Checkout
↓
┌────┴────┐
│ Payfast │ → Cards, EFT, SnapScan
│ Payflex │ → Buy now pay later
└────┬────┘
↓
Order Created
↓
Bob Go API → auto-select cheapest courier
↓
┌────┴────┐
│ Waybill │ → PDF generated
│ Tracking│ → number assigned
└────┬────┘
↓
Customer gets tracking link
```
+111
View File
@@ -0,0 +1,111 @@
# 🛒 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.*
+346
View File
@@ -0,0 +1,346 @@
# Pak Jabit Store — Project Manifest
## 🏗️ Architecture
Restructured 2026-09-15: backend and frontend now live on separate VMs, both
under jux-lab.com. Vercel is no longer in the path.
```
shop.jux-lab.com api.jux-lab.com + admin.jux-lab.com
(celest-proto, 34.21.217.116) (pak-jabit-backend, 35.247.170.3)
├── Next.js 16.3.5 storefront ├── Caddy (SSL)
│ systemd: pak-jabit-store └── Docker Compose
│ :3200 behind Caddy ├── Spree 5.6.1 (Rails 8.1.3)
├── Tailwind 4 ├── PostgreSQL 18
└── calls api.jux-lab.com └── Mailpit
```
Also on celest-proto (unrelated): jux-lab.com landing, mmo.jux-lab.com,
vibein.jux-lab.com, purge relay.
## 🔑 Credentials & Connections
| What | Value |
|------|-------|
| **Backend VM** | `pak-jabit-backend` · asia-southeast1-a · e2-medium · 35.247.170.3 |
| **Frontend VM** | `celest-proto` · asia-southeast1-a · e2-custom-4-8192 · 34.21.217.116 |
| **SSH** | `ssh -i /root/.ssh/google_compute_engine ijat@<ip>` |
| **Storefront** | https://shop.jux-lab.com |
| **Spree Admin** | https://admin.jux-lab.com (bare host redirects to the panel) |
| **Admin Login** | juxtapo.opus@gmail.com / ayambasuhkaki |
| **Storefront API** | https://api.jux-lab.com/api/v3/store/ |
| **Admin API** | https://api.jux-lab.com/api/v3/admin/ |
| **Publishable key** | pk_RVVf1fTUW1T4nW9FTFACm92D (header: X-Spree-Api-Key) |
| **Secret key (admin)** | sk_ntDFxFE1KwiDwqGzpCBGTJ8N — all scopes |
| **API Media** | `?expand=media` on product endpoint returns full gallery |
| **Store Currency** | **ZAR** (switched 2026-09-15). Supported: ZAR, USD, MYR — one `spree_markets` row per currency; the `spree_stores.default_currency` COLUMN is ignored, `Spree::Stores::Markets` overrides the reader |
| **Channel (Online Store)** | ch_UkLWZg9DAJ |
| **GitHub Repo** | https://github.com/juxtapo9090/pak-jabit-store (private) |
| **GCloud Project** | project-19f6479b-f9db-4588-92a |
| **DNS** | Cloud DNS zone `jux-lab-com`, same project |
| **Firewall Rule** | `allow-spree` (tcp:80,443) — applies to all instances. :3000 closed 2026-09-15; Caddy reaches Spree on 127.0.0.1:3000 |
## 🐳 Docker on the backend VM
```bash
ssh -i /root/.ssh/google_compute_engine ijat@35.247.170.3
cd ~/pak-jabit
docker compose up -d # start
docker compose down # stop
docker logs pak-jabit-web-1 # spree logs
docker exec pak-jabit-web-1 bin/rails runner "..." # rails console (~30s boot)
```
⚠️ `docker compose restart` does NOT re-read `.env`. Use `docker compose up -d`
after any env change.
## 🖥️ Storefront on celest-proto
```bash
ssh ijat@34.21.217.116
cd ~/pak-jabit-store
npm run build && sudo systemctl restart pak-jabit-store
sudo journalctl -u pak-jabit-store -f
```
⚠️ Delete `.env.local` before building — Next.js gives it precedence over
`.env.production`, and the Vercel-era copy points at the old backend.
## 📦 Products
- **160 products** live (30 original + 130 imported 2026-09-15)
- Scraped from Zalora (source prices are **MYR**), 500+ images in ActiveStorage
- Priced from source at the live rate 2026-09-15: ZAR R99.26-R3185.09 · USD $6.11-$196.02 · MYR RM24.90-RM799.00
- Imported via the Spree Admin API v3 (`scraped-catalogue/bulk_import.py`)
- Brands: G-Shock, H&M, Lacoste, Tommy Hilfiger, Cotton On, GAP, Giordano,
Superdry, Under Armour, Calvin Klein, Camel Active, Nike, Urban Revivo,
Trendyol, Swiss Polo, and more
### 🔴 Five gates a product must pass to appear on the storefront
Learned the hard way twice — 130 products invisible on 2026-09-15 morning, and
again that evening when Image Studio created products that looked perfect and
showed nothing. The scope is `Spree::Product.available(Time.current, currency)`,
defined in `spree_core-5.6.1/app/models/concerns/spree/product_scopes.rb:315`.
Read it rather than guessing; every gate below is a clause in it.
1. **`status: 'active'`** and not discontinued.
2. **A price row in the store currency.** `POST /products` silently ignores a
scalar `"price"`. It needs `"prices": [{"amount": N, "currency": "ZAR"}]`.
The clause is `with_currency(currency)`, which joins
`variants_including_master: :prices` — no row in that exact currency, no
product.
3. **A `Spree::ProductPublication` row for the channel.** Visibility is
**per-channel**, not per-product: `for_channel` INNER JOINs
`spree_product_publications`, so a product with no publication row is dropped
whatever its status. Channels here are `1 Online Store` and `2 Wholesale`.
Creating a product in Rails does **not** create this row.
4. **Purchasable.** Either stock on hand, or `track_inventory: false` on the
master **plus a stock item per active location**. `track_inventory: false`
alone is not enough — with zero stock items `in_stock?` is false,
`purchasable?` is false, and the product is excluded.
5. **Timestamps at or before `cutoff.beginning_of_minute`.** The scope truncates
the cutoff DOWN to the minute, so `published_at = Time.current` reads as the
*future* and the product stays hidden until the next minute ticks over. Set
`Time.current.beginning_of_minute` or it looks broken for up to 59 seconds
and then mysteriously fixes itself.
`Spree::Admin::ImageStudioController#build_new_product` does all five and then
re-checks `available?` before reporting success — copy that shape.
## 🎨 Design Tokens (THANDO)
| Token | Value |
|-------|-------|
| ink | #0E0D0B |
| ochre | #C8873A |
| bone | #EDE6DA |
| bone-light | #F5F1EB |
| ochre-dark | #A86E2D |
| Font | Instrument Sans (Google Fonts) |
| Default theme | Dark |
## ☁️ NextCloudPi (Team File Sharing)
| What | Value |
|------|-------|
| **URL** | https://cloudmi/ |
| **WebDAV** | https://cloudmi/remote.php/dav/files/juxtapo/ |
| **User** | juxtapo |
| **App Password** | Bk7qS-Pe5zk-7gWoY-kpc6E-KWYky |
| **Box** | C-fu's mirah (192.168.1.22) |
## 🚚 EasyParcel (MY Shipping)
| What | Value |
|------|-------|
| **Developer Hub** | https://developer.easyparcel.com |
| **App Name** | Pak Jabit Store |
| **Client ID** | 5be15621-f711-485b-bc79-15256747ce65 |
| **Client Secret** | 9a1e611e-c4e2-4d1a-87c8-f695c1323e63 |
| **Account** | Muhammad Aizat (#10935549) |
| **Auth** | OAuth 2.0 |
| **API Docs** | https://developer.easyparcel.com (Open API Documentation) |
| **Sandbox** | Available (connect from app config page) |
| **Redirect URI** | `https://shop.jux-lab.com/api/easyparcel/callback` — must match the developer hub exactly |
| **Token store** | `~/pak-jabit-store/.easyparcel-token.json` (chmod 600) |
| **Sender address** | `STORE_POSTCODE=10150` / `STORE_STATE=MY-07` (Penang) in `.env.production` |
| **Coverage** | All 16 MY states verified live — 20 couriers peninsular, 14-15 Sabah/Sarawak |
| **Re-auth** | Open the callback URL in a browser; EasyParcel asks for login, then writes the token |
### EasyParcel token handling (fixed 2026-09-15)
The access token lives 10 hours and **cannot** be extended — that is EasyParcel's
server-side policy, and it does not need extending: `getToken()` renews it with the
refresh token automatically, and writes the rotated refresh token back.
🔴 **The original code kept the token in a module-level variable**, so every restart,
redeploy or serverless cold start dropped it and the next quote died with "run OAuth
flow first". On Vercel it would have looked random. Now persisted to disk — verified
by restarting the service mid-session and getting 20 live quotes straight after.
🔴 **`req.nextUrl.origin` is wrong behind Caddy** — it resolved to `localhost:3200`
and produced a redirect URI EasyParcel would reject. Use `APP_BASE_URL` instead.
## ⚠️ Known Issues / TODO
- [x] ~~Currency USD → ZAR~~ done 2026-09-15, all 160 products in ZAR/USD/MYR
- [ ] Payment gateway not wired (Payfast — see napkin-payment-courier.md)
- [ ] Courier: EasyParcel is in for MY; Bob Go still needed for SA
- [ ] No product variants (size/colour) — every product is master-only
- [ ] `track_inventory: false` on all products — fine for demo, not for real stock
- [ ] Admin panel has no lockout or rate limiting — Devise `lockable` is off and
there is no rack-attack, so login attempts are unlimited. Worth an IP
allowlist or basic auth on the admin.jux-lab.com Caddy block before this
goes anywhere real
- [ ] Old Spree containers still on celest-proto (stopped, volumes intact) —
delete once the new backend has proven itself for a few days
- [ ] No backups on the backend VM — daily snapshots exist for celest-proto only
## 🗺️ Planned — customer data & image pipeline
### Customer account data (UI only — the data layer already exists)
Spree ships this; we just haven't surfaced it in the storefront.
| Need | Where it already lives |
|---|---|
| Saved addresses | `spree_addresses` · `GET/POST/PATCH/DELETE /api/v3/store/customers/me/addresses` |
| Saved cards | `spree_credit_cards` · `GET /api/v3/store/customers/me/credit_cards` |
🔴 **Never store raw card data.** `spree_credit_cards` holds `last_digits`,
`cc_type`, `month`/`year`, and `gateway_customer_id` / `gateway_payment_profile_id`
— a **gateway token**, not the PAN, and no CVV column at all. That is the
PCI-correct design and it is already correct. Cards only populate once a gateway
(Payfast) is wired, so this sequences after payment.
### Photoroom batch pipeline
✅ **Built 2026-09-15 evening.** Two ways in, same pipe:
- **Image Studio** — custom Spree admin page at `/admin/image_studio`. Upload
photos from the device (multiple, `accept="image/*"` so a phone opens the
camera roll) → Photoroom → before/after review → attach to an existing product
or create a new listing. Nothing touches the catalogue until Apply; discarding
is just navigating away, since the result is held as an unattached blob.
- **CLI** — `scraped-catalogue/photoroom_to_spree.py --slug <slug> [--live]
[--position 1]`. Same pipe, scriptable, with a read-back check.
🔴 **Batch is NOT built.** One at a time, capped at `MAX_PHOTOS = 8` per request
because each image is a 3-5s synchronous round trip and more starts risking the
web server timeout. A real batch needs a background job.
**Keys** live at `/root/.photoroom/credentials` (chmod 600) on the house box, and
`PHOTOROOM_API_KEY` in the backend `.env`. Currently the **sandbox** key —
1000/month free, every output watermarked. The live key has **10 images/month**
on the free tier (9 left as of 2026-09-15); the 7-day trial is the *web app*, a
separate meter from API credits, and its Batch tool does 250 images at once
through the browser without touching API quota.
⚠️ Photoroom adds little to the scraped catalogue — those images are already
studio shots on white, and the only real inconsistency is framing (2 aspect
ratios, 4 sizes across 160 products). Its value is phone photos of new stock,
which is what Image Studio is for. A free Pillow square-and-pad would fix the
existing 160.
## 📜 Session Log — 2026-09-14 (midnight build)
1. Discovered old `spree-app` VM deleted, `celest-proto` available with jux-lab.com
2. Installed Docker, cloned spree_starter, `docker compose up`
3. Created admin user, loaded 30 products via Rails script
4. Uploaded 76 product images to Spree ActiveStorage
5. Published products to "Online Store" channel (`Spree::ProductPublication`)
6. Scaffolded Next.js storefront with THANDO design tokens
7. Wired storefront to Spree API v3 (`X-Spree-Api-Key`, `?expand=media`)
8. Deployed to Vercel (pak-jabit-store-juxtapo9090.vercel.app)
9. Pushed to GitHub (juxtapo9090/pak-jabit-store)
10. Luc found `?expand=media` param for full image gallery
11. Built Zalora-style vertical thumbnail gallery (ProductImages component)
12. Hardcoded dark theme as default
## 📜 Session Log — 2026-09-15 (restructure)
1. Found the "deleted" `spree-app` VM alive in the `jux-mmo` project — stopped,
then deleted it. Its admin key never matched because each Spree instance
HMACs tokens with its own `SECRET_KEY_BASE`.
2. Imported 130 products via the Spree Admin API v3 (proper API, not Rails
runner — the presentation story is "automated import pipeline").
3. Spent an hour on 130 invisible products. Three stacked causes, all silent:
no `published_at`, not purchasable, and no price row at all.
4. Built `pak-jabit-backend` VM, migrated Postgres + 207MB ActiveStorage.
Kept `SECRET_KEY_BASE` so API keys and image URLs stayed valid.
5. Split the domain: api + admin → backend VM, shop → celest-proto.
6. Moved the storefront off Vercel onto celest-proto (systemd + Caddy).
A leftover `.env.local` from Vercel silently built it against the old
backend twice before we caught it.
7. Deleted the Vercel project. One project, two VMs, one domain.
8. Added `admin.jux-lab.com` — panel split from the API by hostname, so it can be
locked down separately later. Tested in a real browser: no bounce back to api.
9. Security pass: admin email off `spree@example.com`, password off `spree123`,
and closed `:3000` at the firewall (it was open to the internet over plain
HTTP, bypassing Caddy entirely).
10. Cart proven end-to-end on the new stack: create → add 2x item → $5,154.24.
## 📜 Session Log — 2026-09-15 (evening: ZAR, currency switcher, Image Studio, fail2ban)
1. **Backups** — `Projects/PeakyBuildr/backup-pak-jabit.sh`, nightly 04:30 via
`pak-jabit-backup.timer` on the house box → `/root/Backups/pak-jabit`.
pg_dump + ActiveStorage rsync + `.env` + compose + **the bind-mounted custom
code** (none of which is in the database). Restore proven into a throwaway
PG18 cluster: 160 products, 3200 blobs, 0 blobs missing their file.
`RESTORE.md` sits beside the dumps.
2. **The 23x price bug** — `fix_prices.py:6` set `MYR_TO_USD = 4.8` and
*multiplied*. 4.8 is the USD→MYR rate; MYR→USD is a divide. Every product was
listed at 4.8² = **23.04x** its real price (a MYR 537 watch at $2,577).
Repriced all 160 from source at the live rate.
3. **USD → ZAR** — store now quotes rand. 160 rows each in ZAR/USD/MYR.
4. **Currency switcher** on the storefront (ZAR · USD · MYR), URL-param driven so
the server fetches real Spree price rows — nothing converted in the browser.
The cart deliberately ignores it and stays on the store default.
5. **Image Studio** — custom admin page, see the Photoroom section above.
6. **fail2ban** — three jails, see Security below.
## 🔒 Security (2026-09-15 evening)
**fail2ban**, enabled at boot on the backend VM, banning at iptables so offenders
never reach Caddy or Rails:
| Jail | Trigger | Ban |
|---|---|---|
| `caddy-admin-login` | 8 POSTs to `/admin_users/sign_in` in 10 min | 24h |
| `caddy-scan` | 25 × 4xx in 10 min | 1h |
| `sshd` | 5 failed SSH in 10 min | 24h |
`ignoreip = 127.0.0.1/8 ::1 34.21.217.116 27.125.248.0/24` — celest-proto must
never be banned (it pulls product images constantly; banning it takes the shop
down), and the /24 is abang's ISP block, which his public IP rotates inside.
An allports ban on that range would also kill SSH to the box.
Caddy has **no `rate_limit` module** in the standard build, which is why this is
fail2ban and not a Caddy directive.
🔴 **Caddy had no access log until tonight** — errors only, so successful
requests left no trace whatsoever. Now JSON to journald (a file under
`/var/log/caddy/` fails to open even with the directory writable, and Caddy
fails the *whole service* on start rather than skipping the log — it dropped the
site for about a minute finding that out).
⚠️ **Unresolved:** `42.188.122.106` (Windows, Brave 153, carrying a session
cookie) requested `/admin/api_keys`, `/admin/store/edit` and `/admin/admin_users`.
Abang's browser is Linux on `27.125.248.0/24` — different machine. Those specific
requests 502'd mid-restart, but with no access logs before tonight any earlier
success is invisible. Most likely a colleague, since abang expects colleagues to
have access. `sign_in_count` is 0 but Devise trackable looks disabled, so that
number proves nothing.
🔴 **There is exactly ONE admin account**, shared. Every session is
indistinguishable, which is precisely why the above cannot be attributed. Give
each person their own account (Settings → Users) before more people get access.
## 💣 Landmines hit today
Every one of these returned success while being wrong. Worth knowing before
touching this stack again.
| Landmine | What it looked like | The actual cause |
|---|---|---|
| **Scalar `price` is silently dropped** | `POST /products` returns 200, product exists, price is `null` | Spree wants `prices: [{amount, currency}]`. No price row in the store currency → excluded from `available(Time.current, currency)` → invisible on the storefront, no error anywhere |
| **`published_at` left null** | `add_products` returns `{product_count: 130}` | The publication row is created but not live unless you pass `published_at` |
| **Not purchasable** | Product active, available, published | No stock and `track_inventory: true`. Needs stock or `track_inventory: false` |
| **`.env.local` beats `.env.production`** | Build succeeds, only 30 of 160 pages | Next.js precedence. The Vercel-era file pointed at the old backend, which was still running and still serving 30 products — so the build read stale data and reported nothing wrong. Cost two full rebuilds |
| **`docker compose restart` ignores `.env`** | Env edited, behaviour unchanged | `restart` reuses the existing container config. Needs `up -d` |
| **Admin API key looked broken** | `find_by_secret_token` → no match, every time | The key was copied from a *different* Spree instance. Each one HMACs tokens with its own `SECRET_KEY_BASE` |
| **"Deleted" VM was still running** | Manifest said deleted; nothing in the project list | It was in a different GCP project (`jux-mmo`), still billing |
| **OAuth token in a module variable** | Shipping worked, then randomly didn't | Process memory dies on restart/redeploy/cold start. Persist the refresh token to disk or DB |
| **`req.nextUrl.origin` behind a proxy** | OAuth redirect_uri came out as `localhost:3200` | Next.js sees the internal listener, not the public host. Use an explicit `APP_BASE_URL` |
| **100KB API body cap** | Image upload → `request_too_large` on a 115KB jpg | `Spree::Api::Config[:max_request_body_size]` default. 🔴 **The fix recorded here was never live.** `config/initializers/api_body_limit.rb` existed on the host with the 10MB value but the container never loaded it — the compose file mounts no app code, so the file was dead. Fixed by bind-mounting it (2026-09-15 evening). A runtime assignment also does nothing, but not for the reason written before: it isn't read at boot, it just isn't *persisted*, so it only affects the process that ran it |
| **Prebuilt image mounts no app code** | Initializer written, never ran | `ghcr.io/spree/spree` ships its own `/rails`. Anything custom needs an explicit bind mount in `docker-compose.yml`. This silently killed the 10MB body-limit fix for a whole day |
| **`add_routes` from an initializer is a no-op** | Sidebar entry appears, path helper does not exist | Spree draws engine routes BEFORE `config/initializers` run. Routes must go in `config/routes.rb` |
| **New asset defaults to position 1** | Appended an image, it became the product thumbnail | Spree defaults `position` to 1. A watermarked sandbox image went live as a public thumbnail this way. Always send an explicit position |
| **`max(:position)` on `media` misses half the gallery** | Appended image landed mid-gallery at position 2 | A gallery is the UNION of product-level `media` and legacy assets on the master variant. Clear both |
| **`product.stores << store`** | `NoMethodError` on a Relation | Products `belongs_to :store` (singular) in 5.6. Use `store:` on create |
| **`published_at = Time.current` hides the product** | Created, active, priced, stocked — invisible for up to 59s | The scope compares against `cutoff.beginning_of_minute`, truncating DOWN. Use `Time.current.beginning_of_minute` |
| **`curl -o` loses binary bodies here** | HTTP 200, `content-length: 758983`, zero-byte file | The rtk proxy layer eats it. Use Python for binary downloads on the house box |
Full write-up: `/mnt/playground/Server_files/Debug-Logs/2026-09-15_celeste_pak-jabit-restructure-and-invisible-products.md`