feat(storefront): P0 — public Browse (faceted release catalog) + Release detail pages + /shop/{browse,facets,release} API; Woo-transacted

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-23 00:37:04 +10:00
parent 0269decb87
commit fcc3d7b2ff
5 changed files with 369 additions and 1 deletions

View File

@ -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/<release_id>` (self-hosted WebP). ✓
- Public read endpoints: extend `/shop/catalog` + add `/shop/release/<id>`, `/shop/artist/<id>`, `/shop/browse?facets…`.
**e) Routing** — pretty public URLs: `/records` (browse), `/release/<id>-<slug>`, `/artist/<id>`, `/label/<id>`,
`/genre/<g>`, `/style/<s>`, `/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/<id>` + `/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.

View File

@ -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)

View File

@ -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}

112
site/records.html Normal file
View File

@ -0,0 +1,112 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Records</title>
<style>
:root{--primary:#ff5db1;--accent:#46d18a;--bg:#0c0c0e;--panel:#141418;--text:#f0f0f2;--mut:#8a8a98;--line:#26262c;--radius:12px;--cols:4;--font:system-ui}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--text);font-family:var(--font),system-ui,sans-serif}
a{color:inherit;text-decoration:none}
header{display:flex;align-items:center;gap:16px;padding:14px 22px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:10}
.logo{font-weight:800;font-size:20px}.logo b{color:var(--primary)}
.logo img{height:30px;vertical-align:middle}
nav.menu{display:flex;gap:16px;flex:1}nav.menu a{color:var(--mut);font-size:14px}nav.menu a:hover{color:var(--text)}
.sbox{display:flex;gap:6px}.sbox input{padding:9px 12px;border:1px solid var(--line);border-radius:9px;background:var(--panel);color:var(--text);width:220px;font:inherit}
button,.btn{cursor:pointer;border:0;border-radius:9px;font:600 13px var(--font),system-ui;padding:9px 13px;background:var(--primary);color:#10070c}
.ghost{background:var(--panel);color:var(--text);border:1px solid var(--line)}
.wrap{display:grid;grid-template-columns:230px 1fr;gap:22px;max-width:1340px;margin:0 auto;padding:20px}
.filters{align-self:start;position:sticky;top:74px}
.fgroup{margin-bottom:16px}.fgroup h4{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--mut);margin:0 0 8px}
.chip{display:inline-block;padding:4px 9px;margin:2px 3px 2px 0;border:1px solid var(--line);border-radius:20px;font-size:12px;cursor:pointer;color:var(--mut)}
.chip:hover{color:var(--text)}.chip.on{background:var(--primary);color:#10070c;border-color:var(--primary)}
.fnum{display:flex;gap:6px}.fnum input{width:100%;padding:7px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--text);font:inherit}
.topbar{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}
select{padding:8px 10px;border:1px solid var(--line);border-radius:9px;background:var(--panel);color:var(--text);font:inherit}
.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:16px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden;transition:transform .12s,box-shadow .12s}
.card:hover{transform:translateY(-3px);box-shadow:0 8px 24px rgba(0,0,0,.35)}
.card .cov{width:100%;aspect-ratio:1;object-fit:cover;background:#222;display:block}
.card .cb{padding:10px}
.card .ti{font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card .ar{color:var(--mut);font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card .ge{color:var(--mut);font-size:11px;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card .pr{color:var(--primary);font-weight:800;font-size:16px;margin-top:6px}
.card .cp{color:var(--mut);font-size:11px}
.pg{display:flex;gap:8px;align-items:center;justify-content:center;margin:22px 0}
.muted{color:var(--mut)}
</style>
</head>
<body>
<header>
<a class="logo" href="/records" id="logo">Record<b>God</b></a>
<nav class="menu" id="menu"></nav>
<div class="sbox"><input id="q" placeholder="search records…"><button onclick="applyQ()">Search</button></div>
</header>
<div class="wrap">
<aside class="filters">
<div class="fgroup"><h4>Sort</h4>
<select id="sort" onchange="reload()"><option value="new">Newest in</option><option value="price_asc">Price ↑</option><option value="price_desc">Price ↓</option><option value="year">Year</option><option value="artist">Artist AZ</option><option value="title">Title AZ</option></select></div>
<div class="fgroup"><h4>Price</h4><div class="fnum"><input id="pmin" type="number" placeholder="$ min" oninput="debReload()"><input id="pmax" type="number" placeholder="$ max" oninput="debReload()"></div></div>
<div class="fgroup"><h4>Year</h4><div class="fnum"><input id="ymin" type="number" placeholder="from" oninput="debReload()"><input id="ymax" type="number" placeholder="to" oninput="debReload()"></div></div>
<div class="fgroup"><h4>Genre</h4><div id="fGenres"></div></div>
<div class="fgroup"><h4>Style</h4><div id="fStyles"></div></div>
<div class="fgroup"><h4>Format</h4><div id="fFormats"></div></div>
</aside>
<main>
<div class="topbar"><div class="muted" id="count">loading…</div><button class="ghost" onclick="clearAll()">clear filters</button></div>
<div class="grid" id="grid"></div>
<div class="pg" id="pg"></div>
</main>
</div>
<script>
const $=s=>document.querySelector(s);
const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
let CFG={}, ST={q:'',genre:'',style:'',fmt:'',page:1};
const money=n=>n==null?'':'$'+Number(n).toFixed(2);
async function boot(){
CFG=await fetch('/shop/config').then(r=>r.json()).catch(()=>({}));
const t=CFG.theme||{}; const R=document.documentElement.style;
for(const [k,v] of Object.entries({'--primary':t.primary,'--accent':t.accent,'--bg':t.bg,'--panel':t.panel,'--text':t.text,'--font':t.font})) if(v) R.setProperty(k,v);
if(t.radius) R.setProperty('--radius',t.radius+'px');
if(t.cardCols) R.setProperty('--cols',t.cardCols);
if(t.logo) $('#logo').innerHTML=`<img src="${esc(t.logo)}">`;
$('#menu').innerHTML=(CFG.menu||[]).map(m=>`<a href="${esc(m.href||'#')}">${esc(m.label||'')}</a>`).join('');
loadFacets(); reload();
}
async function loadFacets(){
const f=await fetch('/shop/facets').then(r=>r.json()).catch(()=>({}));
const chip=(arr,key)=>(arr||[]).map(x=>`<span class="chip" data-k="${key}" data-v="${esc(x.name)}" onclick="toggleFacet('${key}','${esc(x.name).replace(/'/g,"&#39;")}')">${esc(x.name)} <span class="muted">${x.n}</span></span>`).join('');
$('#fGenres').innerHTML=chip(f.genres,'genre'); $('#fStyles').innerHTML=chip(f.styles,'style'); $('#fFormats').innerHTML=chip(f.formats,'fmt');
}
function toggleFacet(k,v){ ST[k]=ST[k]===v?'':v; ST.page=1; syncChips(); reload(); }
function syncChips(){ document.querySelectorAll('.chip').forEach(c=>c.classList.toggle('on', ST[c.dataset.k]===c.dataset.v)); }
function applyQ(){ ST.q=$('#q').value.trim(); ST.page=1; reload(); }
$('#q')&&$('#q').addEventListener('keydown',e=>{ if(e.key==='Enter') applyQ(); });
let dt; function debReload(){ clearTimeout(dt); dt=setTimeout(reload,400); }
function clearAll(){ ST={q:'',genre:'',style:'',fmt:'',page:1}; $('#q').value=''; ['pmin','pmax','ymin','ymax'].forEach(i=>$('#'+i).value=''); syncChips(); reload(); }
function qs(){
const p=new URLSearchParams(); if(ST.q)p.set('q',ST.q); if(ST.genre)p.set('genre',ST.genre); if(ST.style)p.set('style',ST.style); if(ST.fmt)p.set('fmt',ST.fmt);
const g=(id,k)=>{ const v=$('#'+id).value; if(v)p.set(k,v); }; g('pmin','price_min');g('pmax','price_max');g('ymin','year_min');g('ymax','year_max');
p.set('sort',$('#sort').value); p.set('page',ST.page); return p.toString();
}
async function reload(){
const d=await fetch('/shop/browse?'+qs()).then(r=>r.json());
const show=k=>(CFG.card||['cover','title','artist','price','condition']).includes(k);
$('#count').textContent=`${d.total.toLocaleString()} records`;
$('#grid').innerHTML=(d.items||[]).map(r=>`<a class="card" href="/release/${r.release_id}">
${show('cover')?`<img class="cov" src="/img/r/${r.release_id}" loading="lazy" onerror="this.style.visibility='hidden'">`:''}
<div class="cb">${show('title')?`<div class="ti">${esc(r.title||'')}</div>`:''}${show('artist')?`<div class="ar">${esc(r.artist||'')}</div>`:''}
${show('genre')?`<div class="ge">${esc(r.genre||'')}${r.year?' · '+r.year:''}</div>`:''}
${show('price')?`<div class="pr">${money(r.price)}${r.copies>1?` <span class="cp">· ${r.copies} copies</span>`:''}</div>`:''}</div></a>`).join('')
|| '<div class="muted" style="grid-column:1/-1;padding:30px;text-align:center">no records match</div>';
const pages=Math.max(1,Math.ceil(d.total/d.per));
$('#pg').innerHTML=`<button class="ghost" ${ST.page<=1?'disabled':''} onclick="goPage(-1)"> prev</button><span class="muted">page ${ST.page} / ${pages}</span><button class="ghost" ${ST.page>=pages?'disabled':''} onclick="goPage(1)">next </button>`;
}
function goPage(n){ ST.page+=n; window.scrollTo(0,0); reload(); }
boot();
</script>
</body>
</html>

83
site/release.html Normal file
View File

@ -0,0 +1,83 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Release</title>
<style>
:root{--primary:#ff5db1;--accent:#46d18a;--bg:#0c0c0e;--panel:#141418;--text:#f0f0f2;--mut:#8a8a98;--line:#26262c;--radius:12px;--font:system-ui}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--text);font-family:var(--font),system-ui,sans-serif}
a{color:inherit;text-decoration:none}
header{display:flex;align-items:center;gap:16px;padding:14px 22px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:10}
.logo{font-weight:800;font-size:20px}.logo b{color:var(--primary)}.logo img{height:30px;vertical-align:middle}
nav.menu{display:flex;gap:16px;flex:1}nav.menu a{color:var(--mut);font-size:14px}nav.menu a:hover{color:var(--text)}
button,.btn{cursor:pointer;border:0;border-radius:9px;font:600 14px var(--font),system-ui;padding:11px 16px;background:var(--primary);color:#10070c}
.ghost{background:var(--panel);color:var(--text);border:1px solid var(--line)}
.wrap{max-width:1080px;margin:0 auto;padding:24px}
.crumb{color:var(--mut);font-size:13px;margin-bottom:14px}.crumb a:hover{color:var(--text)}
.top{display:grid;grid-template-columns:340px 1fr;gap:28px}
.cov{width:100%;aspect-ratio:1;object-fit:cover;border-radius:var(--radius);background:#222;border:1px solid var(--line)}
h1{font-size:26px;margin:0 0 2px}.artist{font-size:18px;color:var(--mut);margin-bottom:14px}
.meta{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px}
.pill{background:var(--panel);border:1px solid var(--line);border-radius:20px;padding:4px 11px;font-size:12px;color:var(--mut)}
.copies{margin:16px 0}
.copy{display:flex;align-items:center;gap:12px;background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:12px 14px;margin-bottom:8px}
.copy .cd{flex:1}.copy .pr{font-size:20px;font-weight:800;color:var(--primary)}
.sec{margin-top:26px}.sec h3{font-size:15px;border-bottom:1px solid var(--line);padding-bottom:6px}
table{width:100%;border-collapse:collapse;font-size:14px}td{padding:6px 4px;border-bottom:1px solid var(--line)}
.muted{color:var(--mut)}.desc{color:#c8c8d0;line-height:1.6;font-size:14px;white-space:pre-wrap}
@media(max-width:760px){.top{grid-template-columns:1fr}}
</style>
</head>
<body>
<header>
<a class="logo" href="/records" id="logo">Record<b>God</b></a>
<nav class="menu" id="menu"></nav>
</header>
<div class="wrap" id="app"><div class="muted">loading…</div></div>
<script>
const $=s=>document.querySelector(s);
const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const money=n=>n==null?'':'$'+Number(n).toFixed(2);
const RID=(location.pathname.match(/\/release\/(\d+)/)||[])[1] || new URLSearchParams(location.search).get('id');
let CFG={};
async function boot(){
CFG=await fetch('/shop/config').then(r=>r.json()).catch(()=>({}));
const t=CFG.theme||{}, R=document.documentElement.style;
for(const [k,v] of Object.entries({'--primary':t.primary,'--accent':t.accent,'--bg':t.bg,'--panel':t.panel,'--text':t.text,'--font':t.font})) if(v) R.setProperty(k,v);
if(t.radius) R.setProperty('--radius',t.radius+'px');
if(t.logo) $('#logo').innerHTML=`<img src="${esc(t.logo)}">`;
$('#menu').innerHTML=(CFG.menu||[]).map(m=>`<a href="${esc(m.href||'#')}">${esc(m.label||'')}</a>`).join('');
render();
}
async function render(){
if(!RID){ $('#app').innerHTML='<div class="muted">no release</div>'; return; }
const d=await fetch('/shop/release/'+RID).then(r=>r.json());
const r=d.release;
const show=k=>(CFG.product_page||['cover','title','artist','price','condition','genre','tracklist','cart']).includes(k);
if(!r){ $('#app').innerHTML=`<div class="muted">release ${esc(RID)} not found</div>`; return; }
const meta=[]; if(show('label')&&r.label)meta.push(r.label); if(show('format')&&r.format)meta.push(r.format);
if(show('country')&&r.country)meta.push(r.country); if(show('year')&&r.year)meta.push(r.year);
const tags=[]; if(show('genre')&&r.genre)tags.push(...r.genre.split(', ')); if(r.style)tags.push(...r.style.split(', '));
const buy=c=> c.product_url ? `<a class="btn" href="${esc(c.product_url)}" target="_blank">Buy online</a>` : `<span class="muted">in store</span>`;
$('#app').innerHTML=`
<div class="crumb"><a href="/records">Records</a> ${esc(r.artist||'')}</div>
<div class="top">
<div>${show('cover')?`<img class="cov" src="/img/r/${r.id}" onerror="this.style.visibility='hidden'">`:''}</div>
<div>
${show('title')?`<h1>${esc(r.title||'')}</h1>`:''}${show('artist')?`<div class="artist">${esc(r.artist||'')}</div>`:''}
<div class="meta">${meta.map(m=>`<span class="pill">${esc(String(m))}</span>`).join('')}</div>
<div class="meta">${tags.map(t=>`<span class="pill">${esc(t)}</span>`).join('')}</div>
${show('cart')||show('price')?`<div class="copies">${(d.copies||[]).map(c=>`<div class="copy">
<div class="cd"><div class="pr">${money(c.price)}</div><div class="muted">${esc(c.condition||'')}${c.sleeve_cond?' / '+esc(c.sleeve_cond):''} · ${esc(c.sku)}</div></div>${buy(c)}</div>`).join('')||'<div class="muted">no copies in stock</div>'}</div>`:''}
</div>
</div>
${show('tracklist')&&d.tracks.length?`<div class="sec"><h3>Tracklist</h3><table>${d.tracks.map(t=>`<tr><td class="muted" style="width:40px">${esc(t.position||'')}</td><td>${esc(t.title||'')}</td><td class="muted" style="width:50px;text-align:right">${esc(t.duration||'')}</td></tr>`).join('')}</table></div>`:''}
${show('description')&&r.notes?`<div class="sec"><h3>Notes</h3><div class="desc">${esc(r.notes)}</div></div>`:''}`;
}
boot();
</script>
</body>
</html>