From fcc3d7b2ff16f46cc7423eaf3b5455e3d41ad8b9 Mon Sep 17 00:00:00 2001 From: type-two Date: Tue, 23 Jun 2026 00:37:04 +1000 Subject: [PATCH] =?UTF-8?q?feat(storefront):=20P0=20=E2=80=94=20public=20B?= =?UTF-8?q?rowse=20(faceted=20release=20catalog)=20+=20Release=20detail=20?= =?UTF-8?q?pages=20+=20/shop/{browse,facets,release}=20API;=20Woo-transact?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- RECORDGOD_STOREFRONT_PLAN.md | 94 +++++++++++++++++++++++++++++ app/main.py | 4 +- app/shop_routes.py | 77 ++++++++++++++++++++++++ site/records.html | 112 +++++++++++++++++++++++++++++++++++ site/release.html | 83 ++++++++++++++++++++++++++ 5 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 RECORDGOD_STOREFRONT_PLAN.md create mode 100644 site/records.html create mode 100644 site/release.html diff --git a/RECORDGOD_STOREFRONT_PLAN.md b/RECORDGOD_STOREFRONT_PLAN.md new file mode 100644 index 0000000..134eac0 --- /dev/null +++ b/RECORDGOD_STOREFRONT_PLAN.md @@ -0,0 +1,94 @@ +# RecordGod — public storefront + component page editor + +Port WowPlatter's **customer-facing** storefront to RecordGod as a **component-based page system** with a +**page editor** (arrange the blocks). Studied: `wowplatter/public/{templates,partials,handlers}` + +`shortcodes/{entities,components,navigation,templates}`. Legend: 🟢 build · 🟡 confirm · 🔵 new (no RecordGod equiv yet). + +--- + +## 1. The key insight — it's already component-based + +WowPlatter renders public pages by composing **shortcode "blocks"**, not monolithic templates: + +**Entity blocks** (`shortcodes/entities/`): `release · artist · label · genre · style · format · identifier · +inventory · price · market · wantlist`. **Component blocks** (`shortcodes/components/`): `items (grid) · filter +(facets) · media (audio/video) · credits`. **Navigation** (`shortcodes/navigation/`): `breadcrumb · pagination · +search`. Pages (`public/templates/*.php`) assemble these. + +→ **So the page editor is natural:** a page = an ordered list of component blocks (each with config), stored as +JSON, editable by dragging/reordering. This is exactly "page edit style so I can arrange the components." + +--- + +## 2. The public pages (mapped) + +| Page | WowPlatter file | Components / sections | Data | Verdict | +|---|---|---|---|---| +| **Browse / Records** (the shop) | `records.php` (1378), `browse.php` | **faceted filter** (genre/style/format/year/price) + sort + **items grid** + pagination | `disc_release` mirror + `inventory` (in-stock, price) | 🟢 the core catalog page | +| **Release detail** | `release.php` (1701), `mobile-release.php` | cover, detail panels, **tracklist**, **credits**, price/inventory (buy), AI blurb, Apple/YT links, related | `disc_release` + `disc_release_track/artist/label/format/image` + `inventory` | 🟢 the hero page | +| **Artist** | `artist.php` (270) | artist header (name/bio/image) + their **releases grid** | `disc_artist` + `disc_release_artist` → releases | 🟢 | +| **Label** | `label.php` | label header + releases grid | `disc_label` + `disc_release_label` | 🟢 | +| **Genre / Style** | `genre.php` (278), style template | genre/style header + filtered releases grid | `disc_genre/style` + `disc_release_genre/style` | 🟢 | +| **Search** | `search-shortcode`, public-search | search box → results grid (reuse items grid) | inventory + `disc_release` | 🟢 (extends the admin search infra) | +| **Wantlist** | `wantlist` entity + public-wantlist partial | customer's wants + match-to-stock | `customer` + a `wantlist` table (intake-match — see audit) | 🟡 ties to the Wantlist cream item | +| **Login / Signup** | `login.php` (334), `signup.php`, forgot/reset | username/password forms, redirect, remember | **customer accounts** | 🔵 NEW — RecordGod has admin-token auth only, no customer accounts yet | +| **Profile / Loyalty** | `profile.php`, `loyalty-dashboard.php` | account info, order history, loyalty (inactive) | `customer` + `sales` | 🟡 (Loyalty inactive per audit) | +| **Listen** | `listen.php` | audio-preview player + crate map | audio previews (Audio cream) | 🟡 (ties to Audio previews) | +| Header / Footer / FAQ | `header/footer/faq.php` | chrome blocks | store_config | 🟢 fold into the builder | + +--- + +## 3. RecordGod build — component library + page editor + +**a) Component library** (RecordGod renders these; reusable across pages): +`release-card · release-detail · tracklist · credits · price-buy · items-grid · facet-filter · artist-header · +label-header · genre-header · search-box · breadcrumb · pagination · hero/banner · text/markdown · audio-preview`. +Each is a small template + a data contract, themed by the builder tokens. + +**b) Page-type layouts** (the editor's output): a page-type (`browse / release / artist / label / genre / style / +search / account`) has a **layout = ordered `[{component, config}]`** in `store_config` jsonb (already exists). +The public renderer reads the layout for the page-type + the entity data → composes the blocks. + +**c) The page editor** (the ask): in `/builder`, a **per-page-type canvas**: pick a page type → see its block list → +**drag to reorder, add/remove blocks, edit each block's config** (e.g. items-grid: columns, sort default; facet- +filter: which facets; release-detail: which panels). Live preview using a sample entity. Saves the layout JSON. +(Builds on the existing `/builder` theme/brand-import + element editor — this adds the *structural* layer.) + +**d) Data layer** — already mostly there: +- `disc_release` mirror (+ genre/style/label/format/artist/track/image) = all entity data, local. ✓ +- `inventory` = what's in stock + price (the buyable layer). ✓ +- Images = `/img/r/` (self-hosted WebP). ✓ +- Public read endpoints: extend `/shop/catalog` + add `/shop/release/`, `/shop/artist/`, `/shop/browse?facets…`. + +**e) Routing** — pretty public URLs: `/records` (browse), `/release/-`, `/artist/`, `/label/`, +`/genre/`, `/style/`, `/search`, `/account`. (WowPlatter uses rewrite rules; RecordGod adds FastAPI routes.) + +**f) Customer auth** 🔵 — the one genuinely NEW piece: customer accounts (signup/login/forgot/profile) — a +`customer_auth` (email + password hash + session) layer separate from the admin token. WooCommerce currently owns +customer accounts; decide: RecordGod own accounts, or SSO/keep Woo for checkout (see §5). + +--- + +## 4. How it connects to what exists +- **`/builder`** (theme + brand-import + element editor) → gains the **page-structure editor** (this plan's core). +- **`/shop`** (themed storefront reading `/shop/config` + `/shop/catalog`) → becomes the rendered output of the layouts. +- **disc_release mirror + inventory + /img/r** → the data + images, already built. +- **Customers + Wantlist** → power the account + wantlist pages. + +--- + +## 5. Open questions (confirm before building) +1. **Customer accounts + checkout**: does RecordGod own customer auth + cart/checkout, or does **WooCommerce** stay + the commerce/account backend (RecordGod renders, Woo transacts)? Big architecture fork. +2. **Editor depth**: full drag-drop block canvas, or a simpler ordered list with show/hide + config per block (faster + to ship, 90% of the value)? +3. **Which pages first**: Browse + Release (the 80% — catalog + detail) before Artist/Label/Genre/Style + account? +4. **Listen/audio previews** on the storefront — in this phase or with the Audio internalization work? + +## 6. Phases +- **P0 — read API + Release/Browse render** (no editor yet): `/shop/release/` + `/shop/browse` rendering the + component blocks with a default layout. The 80% value, proves the data + components. +- **P1 — the page editor**: per-page-type layout editor in `/builder` (reorder/add/remove/config blocks) + live preview. +- **P2 — Artist/Label/Genre/Style** entity pages (reuse items-grid + header blocks). +- **P3 — customer accounts** (signup/login/profile) — pending the §5.1 decision. +- **P4 — Wantlist + Search + Listen** storefront pages. diff --git a/app/main.py b/app/main.py index dd5c08b..d1bc215 100644 --- a/app/main.py +++ b/app/main.py @@ -103,8 +103,10 @@ def _page(filename): return _serve -for _stub in ("shop", "builder", "admin", "dash", "pos", "search"): +for _stub in ("shop", "builder", "admin", "dash", "pos", "search", "records"): app.add_api_route(f"/{_stub}", _page(_stub + ".html"), methods=["GET"], include_in_schema=False) +# public storefront release detail — release.html reads the id from the path +app.add_api_route("/release/{release_id}", _page("release.html"), methods=["GET"], include_in_schema=False) # the login landing too (no-cache), matched before the "/" static mount below app.add_api_route("/", _page("index.html"), methods=["GET"], include_in_schema=False) diff --git a/app/shop_routes.py b/app/shop_routes.py index 8ded1da..60b00f1 100644 --- a/app/shop_routes.py +++ b/app/shop_routes.py @@ -41,3 +41,80 @@ async def shop_catalog(q: str = Query(""), page: int = Query(1, ge=1), db=Depend f"SELECT count(*) FROM inventory i LEFT JOIN disc_cache dc ON dc.release_id=i.release_id WHERE {w}" ), cparams)).scalar() return {"items": items, "page": page, "per": per, "total": total} + + +@router.get("/browse") +async def shop_browse(q: str = Query(""), genre: str = Query(""), style: str = Query(""), + fmt: str = Query(""), year_min: int | None = None, year_max: int | None = None, + price_min: float | None = None, price_max: float | None = None, + sort: str = Query("new"), page: int = Query(1, ge=1), db=Depends(get_db)): + """Faceted, release-grouped catalog — the public Browse page. One row per release with its cheapest copy.""" + per = 24 + where = ["i.in_stock", "i.store_id = 1", "i.release_id IS NOT NULL"] + params = {"per": per, "off": (page - 1) * per} + if q: + where.append("(coalesce(i.title, dr.title) ILIKE :q OR dr.artists_sort ILIKE :q)"); params["q"] = f"%{q}%" + if genre: + where.append("EXISTS (SELECT 1 FROM disc_release_genre g WHERE g.release_id=i.release_id AND g.genre_name=:genre)"); params["genre"] = genre + if style: + where.append("EXISTS (SELECT 1 FROM disc_release_style s WHERE s.release_id=i.release_id AND s.style_name=:style)"); params["style"] = style + if fmt: + where.append("EXISTS (SELECT 1 FROM disc_release_format f WHERE f.release_id=i.release_id AND f.name ILIKE :fmt)"); params["fmt"] = f"%{fmt}%" + if year_min: + where.append("dr.year >= :ymin"); params["ymin"] = year_min + if year_max: + where.append("dr.year <= :ymax"); params["ymax"] = year_max + if price_min: + where.append("i.price >= :pmin"); params["pmin"] = price_min + if price_max: + where.append("i.price <= :pmax"); params["pmax"] = price_max + w = " AND ".join(where) + order = {"new": "max(i.updated_at) DESC NULLS LAST", "price_asc": "min(i.price) ASC", + "price_desc": "min(i.price) DESC", "year": "dr.year DESC NULLS LAST", + "artist": "dr.artists_sort", "title": "min(coalesce(i.title, dr.title))"}.get(sort, "max(i.updated_at) DESC NULLS LAST") + items = [dict(r) for r in (await db.execute(text(f""" + SELECT i.release_id, coalesce(min(i.title), dr.title) AS title, dr.artists_sort AS artist, + dr.year, dr.country, min(i.price)::float AS price, count(*) AS copies, + (SELECT string_agg(DISTINCT genre_name, ', ') FROM disc_release_genre g WHERE g.release_id=i.release_id) AS genre + FROM inventory i JOIN disc_release dr ON dr.id = i.release_id + WHERE {w} + GROUP BY i.release_id, dr.title, dr.artists_sort, dr.year, dr.country + ORDER BY {order} LIMIT :per OFFSET :off"""), params)).mappings()] + cp = {k: v for k, v in params.items() if k not in ("per", "off")} + total = (await db.execute(text( + f"SELECT count(DISTINCT i.release_id) FROM inventory i JOIN disc_release dr ON dr.id=i.release_id WHERE {w}" + ), cp)).scalar() + return {"items": items, "page": page, "per": per, "total": total} + + +@router.get("/facets") +async def shop_facets(db=Depends(get_db)): + async def top(tbl, col): + return [dict(r) for r in (await db.execute(text(f""" + SELECT x.{col} AS name, count(DISTINCT i.release_id) AS n + FROM {tbl} x JOIN inventory i ON i.release_id = x.release_id + WHERE i.in_stock AND i.store_id=1 AND x.{col} IS NOT NULL AND x.{col} <> '' + GROUP BY x.{col} ORDER BY n DESC LIMIT 24"""))).mappings()] + return {"genres": await top("disc_release_genre", "genre_name"), + "styles": await top("disc_release_style", "style_name"), + "formats": await top("disc_release_format", "name")} + + +@router.get("/release/{release_id}") +async def shop_release(release_id: int, db=Depends(get_db)): + """Public release detail — metadata + tracklist + in-stock copies (with the Woo buy link).""" + dr = (await db.execute(text(""" + SELECT dr.id, dr.title, dr.artists_sort AS artist, dr.country, dr.year, dr.notes, dr.master_id, + (SELECT string_agg(label_name || coalesce(' ('||catno||')',''), ', ') FROM disc_release_label WHERE release_id=dr.id) AS label, + (SELECT string_agg(name || coalesce(' '||descriptions,''), ', ') FROM disc_release_format WHERE release_id=dr.id) AS format, + (SELECT string_agg(DISTINCT genre_name, ', ') FROM disc_release_genre WHERE release_id=dr.id) AS genre, + (SELECT string_agg(DISTINCT style_name, ', ') FROM disc_release_style WHERE release_id=dr.id) AS style + FROM disc_release dr WHERE dr.id = :r"""), {"r": release_id})).mappings().first() + tracks = [dict(r) for r in (await db.execute(text(""" + SELECT position, title, duration FROM disc_release_track WHERE release_id=:r ORDER BY sequence NULLS LAST, position + """), {"r": release_id})).mappings()] + copies = [dict(r) for r in (await db.execute(text(""" + SELECT sku, price::float AS price, condition, sleeve_cond, product_url + FROM inventory WHERE release_id=:r AND in_stock AND store_id=1 ORDER BY price + """), {"r": release_id})).mappings()] + return {"release": dict(dr) if dr else None, "tracks": tracks, "copies": copies} diff --git a/site/records.html b/site/records.html new file mode 100644 index 0000000..889d855 --- /dev/null +++ b/site/records.html @@ -0,0 +1,112 @@ + + + + + +Records + + + +
+ + +
+
+
+ +
+
loading…
+
+
+
+
+ + + diff --git a/site/release.html b/site/release.html new file mode 100644 index 0000000..5eaa625 --- /dev/null +++ b/site/release.html @@ -0,0 +1,83 @@ + + + + + +Release + + + +
+ + +
+
loading…
+ + +