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
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""
Reprice all products from the source MYR catalogue.
Replaces the prices written by fix_prices.py, which multiplied by 4.8 instead of
dividing — putting every product on the store at ~23x its real price.
Writes BOTH currency rows the store supports:
ZAR (store default) = price_myr * MYR_TO_ZAR
USD (still in supported_currencies) = price_myr * MYR_TO_USD
The USD row is corrected in the same pass on purpose: leaving the old 23x rows
behind means anyone switching the store currency to USD sees the broken prices
again, with nothing to warn them.
Rates are fetched live and printed. No hardcoded rate, no fallback rate — if the
fetch fails this exits rather than pricing a live store off a guess.
Usage:
reprice_zar.py --dry-run # show the changes, write nothing (do this first)
reprice_zar.py # apply, then read back and check every row
"""
import json
import sys
import time
from pathlib import Path
import requests
import os as _os
def _spree_admin_key() -> str:
"""The Spree admin API key. Required — never defaulted."""
key = _os.environ.get("SPREE_ADMIN_KEY", "").strip()
if not key:
raise SystemExit(
"SPREE_ADMIN_KEY is not set.\n"
" export SPREE_ADMIN_KEY=<the admin API key>\n"
"It is deliberately not stored in this file — see README.md."
)
return key
API = "https://api.jux-lab.com/api/v3/admin"
H = {"X-Spree-Api-Key": _spree_admin_key(), "Content-Type": "application/json"}
PRODUCTS = Path("/root/Opus/Projects/PeakyBuildr/scraped-catalogue/products")
DRY = "--dry-run" in sys.argv
def fatal(msg):
print(f"\nFATAL: {msg}", file=sys.stderr)
sys.exit(1)
# --- rates -------------------------------------------------------------------
def get_rates():
"""Live MYR base rates. Loud failure beats a stale constant — see fix_prices.py."""
try:
d = requests.get("https://open.er-api.com/v6/latest/MYR", timeout=20).json()
except Exception as e:
fatal(f"rate fetch failed: {e}")
r = d.get("rates") or {}
zar, usd = r.get("ZAR"), r.get("USD")
if not zar or not usd:
fatal(f"rate response missing ZAR/USD: {d}")
print(f"rates 1 MYR = {zar} ZAR = {usd} USD ({d.get('time_last_update_utc')})")
return float(zar), float(usd)
# --- source catalogue --------------------------------------------------------
def load_source():
prices = {}
for folder in sorted(PRODUCTS.iterdir()):
info = folder / "info.json"
if not info.exists():
continue
myr = json.loads(info.read_text()).get("price_myr")
if myr is None:
fatal(f"{folder.name}/info.json has no price_myr")
prices[folder.name] = float(myr)
if not prices:
fatal(f"no source prices found under {PRODUCTS}")
print(f"source {len(prices)} products, MYR {min(prices.values()):.2f}-{max(prices.values()):.2f}")
return prices
def fetch_products():
items, page = [], 1
while True:
d = requests.get(f"{API}/products?per_page=100&page={page}", headers=H, timeout=60).json()
items += d.get("data", [])
if page >= d.get("meta", {}).get("pages", 1):
break
page += 1
return items
def main():
zar_rate, usd_rate = get_rates()
src = load_source()
items = fetch_products()
print(f"store {len(items)} products")
planned, missing = {}, []
for p in items:
slug = p["slug"]
myr = src.get(slug)
if myr is None:
missing.append(slug)
continue
planned[p["id"]] = {
"slug": slug,
"myr": myr,
"ZAR": round(myr * zar_rate, 2),
"USD": round(myr * usd_rate, 2),
}
if missing:
# Not fatal: these keep their current prices. But they must be named, not
# silently skipped — a product left at 23x is the whole bug again.
print(f"\n{len(missing)} products have NO source price and will be LEFT UNCHANGED:")
for s in missing:
print(f" ! {s}")
sample = list(planned.values())[:5]
print(f"\n{len(planned)} to reprice. Sample:")
for s in sample:
print(f" MYR {s['myr']:>7.2f} -> ZAR {s['ZAR']:>8.2f} / USD {s['USD']:>7.2f} {s['slug'][:44]}")
zars = [v["ZAR"] for v in planned.values()]
print(f" new ZAR range: {min(zars):.2f} - {max(zars):.2f}")
if DRY:
print("\n--dry-run: nothing written.")
return
# --- apply ---------------------------------------------------------------
print()
ok = fail = 0
for n, (pid, s) in enumerate(planned.items(), 1):
body = {"prices": [
{"amount": s["ZAR"], "currency": "ZAR"},
{"amount": s["USD"], "currency": "USD"},
]}
try:
r = requests.patch(f"{API}/products/{pid}", headers=H, json=body, timeout=60).json()
except Exception as e:
fail += 1
print(f" ERROR {s['slug'][:40]}: {e}")
continue
if "error" in r:
fail += 1
print(f" FAIL {s['slug'][:40]}: {r['error'].get('message')}")
else:
ok += 1
if n % 40 == 0:
print(f" {n}/{len(planned)}")
time.sleep(0.12)
print(f"patched ok={ok} fail={fail}")
# --- read back -----------------------------------------------------------
# The store returns 200 for a price write that never created a row in the
# store currency. Trusting that 200 is what hid 130 products for an hour.
print("\nreading back...")
time.sleep(2)
bad = []
for p in fetch_products():
want = planned.get(p["id"])
if not want:
continue
a = p.get("attributes", p)
got = a.get("price")
cur = a.get("currency")
if got is None or abs(float(got) - want["ZAR"]) > 0.01 or cur != "ZAR":
bad.append((want["slug"], want["ZAR"], got, cur))
if bad:
print(f"\n{len(bad)} products did NOT take the ZAR price:")
for slug, want, got, cur in bad[:15]:
print(f" ! {slug[:40]} want ZAR {want} got {got} {cur}")
sys.exit(1)
print(f"check ok: all {len(planned)} products report the expected ZAR price.")
if __name__ == "__main__":
main()