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