diff --git a/app/shop_routes.py b/app/shop_routes.py
index dae76c0..57d8f18 100644
--- a/app/shop_routes.py
+++ b/app/shop_routes.py
@@ -111,6 +111,7 @@ async def shop_browse(q: str = Query(""), genre: str = Query(""), style: str = Q
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 thumb FROM disc_cache dc WHERE dc.release_id=i.release_id) AS thumb,
(SELECT artist_id FROM disc_release_artist ra WHERE ra.release_id=i.release_id ORDER BY ra.position LIMIT 1) AS artist_id,
(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
@@ -142,6 +143,7 @@ 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 thumb FROM disc_cache dc WHERE dc.release_id=dr.id) AS thumb,
(SELECT artist_id FROM disc_release_artist ra WHERE ra.release_id=dr.id ORDER BY ra.position LIMIT 1) AS artist_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,
diff --git a/wp-bridge/README.md b/wp-bridge/README.md
new file mode 100644
index 0000000..a6e234b
--- /dev/null
+++ b/wp-bridge/README.md
@@ -0,0 +1,26 @@
+# RecordGod Bridge (WordPress plugin)
+
+Thin successor to WowPlatter. **RecordGod owns the catalog, stock, pricing and shipping**; this plugin is a
+storefront skin + checkout adapter over its `/shop` API. WordPress/WooCommerce is kept lean — Woo only ever
+sees the records people actually buy.
+
+## What it does
+
+| Concern | How |
+|---|---|
+| **Storefront (SEO)** | `class-rg-storefront.php` — server-renders `/records` (browse) and `/release/{id}` inside the theme, with `
`, meta description, and JSON-LD `MusicAlbum`/`Offer`. Pulls `/shop/browse` + `/shop/release`. |
+| **On-the-fly Woo product** | `class-rg-cart.php` — the WowPlatter trick. `/?rg_add=` looks the SKU up (`wc_get_product_id_by_sku`); if absent, mints a hidden `WC_Product_Simple` from `/shop/item` (price, image, stock=1) then adds it to the cart. 25k catalog, ~handful of Woo products. |
+| **Shipping** | `class-rg-shipping.php` — a `WC_Shipping_Method` that quotes postage from `/shop/shipping/quote?units=` (AusPost Parcel/Express flat rate, 280g/record). Add it to a Woo shipping zone. |
+| **Order webhook** | `class-rg-orders.php` — on `order_status_completed`, POSTs the order back to `/shop/woo-order` (with `X-Bridge-Key`). RecordGod marks the SKUs sold + logs the online sale. Idempotent. |
+| **Settings** | Settings → RecordGod Bridge: base URL, bridge key (must match RecordGod's `bridge_key` secret), store id. |
+
+## Install
+
+1. Copy `wp-bridge/` to `wp-content/plugins/recordgod-bridge/` and activate (flushes rewrite rules).
+2. Settings → RecordGod Bridge: set base URL (`https://recordgod.com`) + bridge key.
+3. WooCommerce → Settings → Shipping → add **RecordGod Postage** to your AU zone.
+4. Visit `/records`. (If pages 404, re-save Permalinks once.)
+
+## Not in scope (stays where it is)
+- Woo handles payment + transactional emails.
+- RecordGod sends its own receipts (`mailer.py`) and holds Discogs/import auth — no OAuth in this plugin.
diff --git a/wp-bridge/includes/class-rg-api.php b/wp-bridge/includes/class-rg-api.php
new file mode 100644
index 0000000..a2828c1
--- /dev/null
+++ b/wp-bridge/includes/class-rg-api.php
@@ -0,0 +1,39 @@
+?args — decoded JSON or null. Cached in a transient for $ttl seconds (0 = no cache). */
+ static function get($path, $args = [], $ttl = 120) {
+ $url = self::base() . $path . ($args ? '?' . http_build_query($args) : '');
+ $key = 'rg_' . md5($url);
+ if ($ttl) {
+ $hit = get_transient($key);
+ if ($hit !== false) return $hit;
+ }
+ $r = wp_remote_get($url, ['timeout' => 12, 'headers' => ['Accept' => 'application/json']]);
+ if (is_wp_error($r) || wp_remote_retrieve_response_code($r) !== 200) return null;
+ $data = json_decode(wp_remote_retrieve_body($r), true);
+ if ($ttl && $data !== null) set_transient($key, $data, $ttl);
+ return $data;
+ }
+
+ /** POST JSON to /shop/ with the bridge key header. Returns [code, decoded-body]. */
+ static function post($path, $body) {
+ $r = wp_remote_post(self::base() . $path, [
+ 'timeout' => 15,
+ 'headers' => [
+ 'Content-Type' => 'application/json',
+ 'X-Bridge-Key' => get_option('rg_bridge_key', ''),
+ ],
+ 'body' => wp_json_encode($body),
+ ]);
+ if (is_wp_error($r)) return [0, ['error' => $r->get_error_message()]];
+ return [wp_remote_retrieve_response_code($r), json_decode(wp_remote_retrieve_body($r), true)];
+ }
+}
diff --git a/wp-bridge/includes/class-rg-cart.php b/wp-bridge/includes/class-rg-cart.php
new file mode 100644
index 0000000..5ffc9a1
--- /dev/null
+++ b/wp-bridge/includes/class-rg-cart.php
@@ -0,0 +1,63 @@
+set_name(trim(($item['artist'] ? $item['artist'] . ' — ' : '') . $item['title']));
+ $p->set_sku($sku);
+ $p->set_regular_price((string) $item['price']);
+ $p->set_price((string) $item['price']);
+ $p->set_catalog_visibility('hidden'); // never floods the Woo shop loop
+ $p->set_manage_stock(true);
+ $p->set_stock_quantity(1); // single physical copy
+ $p->set_sold_individually(true);
+ $p->set_weight('0.28'); // 230g record + 50g packaging (Woo fallback; real quote is RG_Shipping)
+ if (!empty($item['release_id'])) $p->update_meta_data('_rg_release_id', $item['release_id']);
+ $id = $p->save();
+
+ if ($id && !empty($item['thumb'])) self::attach_thumb($id, $item['thumb']);
+ return $id;
+ }
+
+ /** GET ?rg_add= → mint product, add to cart, bounce to the cart page. */
+ static function handle_add() {
+ if (empty($_GET['rg_add'])) return;
+ $sku = sanitize_text_field(wp_unslash($_GET['rg_add']));
+ $id = self::ensure_product($sku);
+ if ($id) {
+ WC()->cart->add_to_cart($id, 1);
+ wp_safe_redirect(wc_get_cart_url());
+ } else {
+ wc_add_notice('Sorry, that record has just sold.', 'error');
+ wp_safe_redirect(home_url('/records'));
+ }
+ exit;
+ }
+
+ /** Sideload the Discogs thumb as the product image (once). */
+ static function attach_thumb($product_id, $url) {
+ require_once ABSPATH . 'wp-admin/includes/media.php';
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ require_once ABSPATH . 'wp-admin/includes/image.php';
+ $att = media_sideload_image($url, $product_id, null, 'id');
+ if (!is_wp_error($att)) set_post_thumbnail($product_id, $att);
+ }
+}
diff --git a/wp-bridge/includes/class-rg-orders.php b/wp-bridge/includes/class-rg-orders.php
new file mode 100644
index 0000000..700beda
--- /dev/null
+++ b/wp-bridge/includes/class-rg-orders.php
@@ -0,0 +1,45 @@
+get_meta('_rg_synced')) return; // idempotent (RecordGod also dedups on WC-)
+
+ $items = [];
+ foreach ($order->get_items() as $line) {
+ $product = $line->get_product();
+ $sku = $product ? $product->get_sku() : '';
+ if (!$sku) continue;
+ $items[] = [
+ 'sku' => $sku,
+ 'name' => $line->get_name(),
+ 'qty' => (int) $line->get_quantity(),
+ 'price' => (float) $order->get_item_total($line, false),
+ ];
+ }
+ if (!$items) return;
+
+ [$code, $body] = RG_API::post('/shop/woo-order', [
+ 'order_number' => (string) $order->get_order_number(),
+ 'email' => $order->get_billing_email(),
+ 'name' => trim($order->get_formatted_billing_full_name()),
+ 'total' => (float) $order->get_total(),
+ 'items' => $items,
+ ]);
+
+ if ($code === 200) {
+ $order->update_meta_data('_rg_synced', current_time('mysql'));
+ } else {
+ $order->update_meta_data('_rg_sync_error', "HTTP $code");
+ $order->add_order_note('RecordGod sync failed (HTTP ' . $code . ') — retry from order actions.');
+ }
+ $order->save();
+ }
+}
diff --git a/wp-bridge/includes/class-rg-shipping.php b/wp-bridge/includes/class-rg-shipping.php
new file mode 100644
index 0000000..1ac9ceb
--- /dev/null
+++ b/wp-bridge/includes/class-rg-shipping.php
@@ -0,0 +1,48 @@
+id = 'recordgod';
+ $this->instance_id = absint($instance_id);
+ $this->method_title = 'RecordGod Postage';
+ $this->method_description = 'Live AusPost flat-rate quote from RecordGod (Parcel / Express Post).';
+ $this->supports = ['shipping-zones', 'instance-settings', 'settings'];
+ $this->init();
+ }
+
+ function init() {
+ $this->init_form_fields();
+ $this->init_settings();
+ $this->enabled = $this->get_option('enabled', 'yes');
+ $this->title = $this->get_option('title', 'Postage');
+ add_action('woocommerce_update_options_shipping_' . $this->id, [$this, 'process_admin_options']);
+ }
+
+ function init_form_fields() {
+ $this->instance_form_fields = [
+ 'enabled' => ['title' => 'Enable', 'type' => 'checkbox', 'default' => 'yes'],
+ 'title' => ['title' => 'Title', 'type' => 'text', 'default' => 'Postage'],
+ ];
+ }
+
+ function calculate_shipping($package = []) {
+ $units = 0;
+ foreach ($package['contents'] as $line) $units += (int) $line['quantity'];
+ if ($units < 1) return;
+
+ $q = RG_API::get('/shop/shipping/quote', ['units' => $units], 30);
+ if (!$q || empty($q['options'])) return;
+
+ foreach ($q['options'] as $opt) {
+ $this->add_rate([
+ 'id' => $this->id . ':' . $opt['service'],
+ 'label' => $opt['label'],
+ 'cost' => (float) $opt['price'],
+ 'calc_tax' => 'per_order',
+ ]);
+ }
+ }
+}
diff --git a/wp-bridge/includes/class-rg-storefront.php b/wp-bridge/includes/class-rg-storefront.php
new file mode 100644
index 0000000..a71bb58
--- /dev/null
+++ b/wp-bridge/includes/class-rg-storefront.php
@@ -0,0 +1,189 @@
+, which mints the Woo product on the fly (RG_Cart).
+ */
+class RG_Storefront {
+
+ static function init() {
+ add_action('init', [__CLASS__, 'rewrites']);
+ add_filter('query_vars', function ($v) {
+ return array_merge($v, ['rg_view', 'rg_id']);
+ });
+ add_action('template_redirect', [__CLASS__, 'render']);
+ }
+
+ static function rewrites() {
+ add_rewrite_rule('^records/?$', 'index.php?rg_view=records', 'top');
+ add_rewrite_rule('^release/([0-9]+)/?', 'index.php?rg_view=release&rg_id=$matches[1]', 'top');
+ }
+
+ static function render() {
+ $view = get_query_var('rg_view');
+ if (!$view) return;
+
+ if ($view === 'release') {
+ $id = (int) get_query_var('rg_id');
+ $data = RG_API::get('/shop/release/' . $id, [], 120);
+ if (!$data || empty($data['release'])) { self::not_found(); return; }
+ self::head_for_release($data);
+ get_header();
+ self::release_html($data);
+ get_footer();
+ exit;
+ }
+
+ // browse
+ $q = isset($_GET['q']) ? sanitize_text_field(wp_unslash($_GET['q'])) : '';
+ $genre = isset($_GET['genre']) ? sanitize_text_field(wp_unslash($_GET['genre'])) : '';
+ $page = max(1, (int) ($_GET['page'] ?? 1));
+ $args = array_filter(['q' => $q, 'genre' => $genre, 'page' => $page]);
+ $data = RG_API::get('/shop/browse', $args, 60);
+ self::head_for_browse($q, $genre);
+ get_header();
+ self::browse_html($data ?: ['items' => [], 'total' => 0, 'page' => 1, 'per' => 24], $q, $genre, $page);
+ get_footer();
+ exit;
+ }
+
+ // --- : title / description / JSON-LD --------------------------------
+
+ static function head_for_browse($q, $genre) {
+ $title = trim(($q ?: $genre) ? ($q ?: $genre) . ' — Records' : 'Records') . ' · ' . get_bloginfo('name');
+ add_filter('pre_get_document_title', fn() => $title);
+ add_action('wp_head', function () {
+ echo '' . "\n";
+ });
+ }
+
+ static function head_for_release($data) {
+ $r = $data['release'];
+ $copies = $data['copies'];
+ $title = trim($r['artist'] . ' – ' . $r['title']) . ' · ' . get_bloginfo('name');
+ add_filter('pre_get_document_title', fn() => $title);
+ add_action('wp_head', function () use ($r, $copies) {
+ $price = $copies ? min(array_column($copies, 'price')) : null;
+ $desc = trim(implode(' · ', array_filter([$r['artist'], $r['format'], $r['year'], $r['label']])));
+ echo '' . "\n";
+ $ld = [
+ '@context' => 'https://schema.org',
+ '@type' => 'MusicAlbum',
+ 'name' => $r['title'],
+ 'byArtist' => ['@type' => 'MusicGroup', 'name' => $r['artist']],
+ ];
+ if (!empty($r['thumb'])) $ld['image'] = $r['thumb'];
+ if (!empty($r['genre'])) $ld['genre'] = $r['genre'];
+ if ($price !== null) {
+ $ld['offers'] = [
+ '@type' => 'Offer', 'priceCurrency' => 'AUD', 'price' => $price,
+ 'availability' => $copies ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
+ 'url' => home_url('/release/' . $r['id']),
+ ];
+ }
+ echo '' . "\n";
+ });
+ }
+
+ // --- body -----------------------------------------------------------------
+
+ static function browse_html($d, $q, $genre, $page) {
+ $heading = $q ?: ($genre ?: 'Records');
+ echo '';
+ echo '';
+ echo '' . esc_html($heading) . ' (' . (int) $d['total'] . ')
';
+
+ if (!$d['items']) { echo 'Nothing in stock matching that.
'; return; }
+
+ echo '';
+
+ self::pager($d, $page, $q, $genre);
+ echo '';
+ }
+
+ static function pager($d, $page, $q, $genre) {
+ $pages = (int) ceil($d['total'] / max(1, $d['per']));
+ if ($pages <= 1) return;
+ $link = fn($p) => esc_url(add_query_arg(array_filter(['q' => $q, 'genre' => $genre, 'page' => $p]), home_url('/records')));
+ echo '';
+ if ($page > 1) echo '
← Prev ';
+ echo '
Page ' . $page . ' of ' . $pages . '';
+ if ($page < $pages) echo '
Next →';
+ echo '
';
+ }
+
+ static function release_html($data) {
+ $r = $data['release'];
+ echo '';
+ echo '← Records
';
+ echo '';
+ if (!empty($r['thumb']))
+ echo '
![' . esc_attr($r['title']) . '](' . esc_url($r['thumb']) . ')
';
+ echo '
';
+ echo '
' . esc_html($r['title']) . '
';
+ echo '
' . esc_html($r['artist']) . '
';
+ foreach (['label' => 'Label', 'format' => 'Format', 'country' => 'Country',
+ 'year' => 'Year', 'genre' => 'Genre', 'style' => 'Style'] as $k => $lbl)
+ if (!empty($r[$k]))
+ echo '
' . $lbl . ': ' . esc_html($r[$k]) . '
';
+
+ echo '
In stock
';
+ if (empty($data['copies'])) {
+ echo '
No copies in stock right now.
';
+ } else {
+ echo '
';
+ foreach ($data['copies'] as $c) {
+ $cond = trim(implode(' / ', array_filter([$c['condition'], $c['sleeve_cond'] ?? null])));
+ echo '- ';
+ echo '$' . esc_html(number_format((float) $c['price'], 2));
+ if ($cond) echo ' · ' . esc_html($cond) . '';
+ echo '';
+ echo 'Add to cart';
+ echo '
';
+ }
+ echo '
';
+ }
+ echo '
';
+
+ if (!empty($data['tracks'])) {
+ echo 'Tracklist
';
+ foreach ($data['tracks'] as $t) {
+ echo '- ' . esc_html($t['title']);
+ if (!empty($t['duration'])) echo ' ' . esc_html($t['duration']) . '';
+ echo '
';
+ }
+ echo '
';
+ }
+ echo '';
+ }
+
+ static function not_found() {
+ status_header(404);
+ get_header();
+ echo 'Record not found
'
+ . 'Browse the shop →
';
+ get_footer();
+ exit;
+ }
+}
diff --git a/wp-bridge/recordgod-bridge.php b/wp-bridge/recordgod-bridge.php
new file mode 100644
index 0000000..3e3ecc2
--- /dev/null
+++ b/wp-bridge/recordgod-bridge.php
@@ -0,0 +1,78 @@
+
+
+