@blamejs/blamejs-shop 0.1.18 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.1.x
10
10
 
11
+ - v0.1.20 (2026-05-25) — **A "Customers also bought" rail on the order confirmation.** The order confirmation page now shows a recommendation rail. After a purchase, `/orders/:id` surfaces up to four products drawn from operator-curated pins first, then co-purchase signals from the order's own items (what other shoppers bought alongside them), then category-popular and in-stock fallbacks — always excluding what was just bought. The rail is best-effort: if the engine isn't wired it simply doesn't render. **Added:** *Order-confirmation recommendation rail* — The post-purchase page renders a "Customers also bought" rail beneath the order summary, anchored on the order's items. Picks come from the recommendations engine: operator-curated overrides first, then a co-purchase signal (products bought in the same orders), then category-popular and in-stock fallbacks to fill the rail; the order's own products are always excluded. Each pick reuses the storefront product-card markup (image, title, price) and links to the product page. The rail renders in both the default and file-backed theme layouts; a store without the recommendations primitive wired renders no rail (and a read failure degrades to none rather than erroring the confirmation page).
12
+
13
+ - v0.1.19 (2026-05-25) — **Admin console — a webhooks screen, and order events now fan out.** Webhooks join the admin console. `/admin/webhooks` registers an endpoint (with a one-time signing-secret reveal), lists endpoints, enables / disables and deletes one, and opens an endpoint's delivery feed to retry a failed delivery. Order lifecycle transitions now fan out to registered endpoints — the order primitive is wired to the webhook dispatcher so a paid / fulfilled / shipped / delivered / cancelled / refunded transition produces a signed delivery. **Added:** *Webhooks management screen* — `/admin/webhooks` registers an outbound endpoint — an https:// URL plus the events to subscribe (or all) — and shows the HMAC-SHA3-512 signing secret once on creation; the secret is never rendered in the endpoint list afterward. The list shows each endpoint's URL, events, status, and per-minute rate, with actions to enable / disable, delete, and open its deliveries. The delivery feed lists each attempt (event, status, response code, attempt count, last error, time) and retries a failed delivery. The JSON API a bearer-token client already used — create / list / update / delete / deliveries / retry — is unchanged; the browser screen content-negotiates on the same paths. · *Order events fan out to webhooks* — The order primitive is now wired to the webhook dispatcher, so an order transition (`order.mark_paid`, `order.start_fulfillment`, `order.mark_shipped`, `order.mark_delivered`, `order.cancel`, `order.refund`) produces a signed delivery to every subscribed endpoint. Dispatch is post-persist — a delivery failure can never roll back the transition that landed — and a failed delivery is recorded for retry from the console rather than surfaced to the request that triggered it. **Changed:** *Console nav gains Webhooks* — The signed-in admin nav now includes Webhooks, shown when the webhooks primitive is wired. Endpoint create, list, enable / disable, delete, the delivery feed, and retry content-negotiate like the other console screens: a bearer-token client gets the JSON API, a signed-in browser gets HTML. A request without the bearer token returns the sign-in form on a GET and redirects on a write. An https-only URL or empty event set re-renders the form with the validator's message, and an unknown endpoint or delivery is a no-op notice rather than a 500.
14
+
11
15
  - v0.1.18 (2026-05-25) — **Admin console — a subscription-plans screen.** Subscription plans join the admin console. `/admin/subscription-plans` lists the recurring-offer catalog — price, interval, trial, and Stripe price id — with an active / archived filter, creates a plan from a signed-in browser, and archives one. As with the other console screens, the same path serves the existing JSON API to a bearer-token client unchanged. **Added:** *Subscription-plans management screen* — `/admin/subscription-plans` renders the plan catalog (price and interval, trial length, Stripe price id, linked variant, status) with an active / archived filter, when opened in a signed-in browser; the same path serves the JSON list to a bearer-token client. A form creates a plan — Stripe price id, interval, interval count, currency, amount, trial days, and an optional variant link — and each active row archives in one submit. A bad-shape create re-renders the form with the validator's message rather than a 500, and archiving an unknown plan is a no-op notice. Archiving is terminal from the console: because a plan mirrors a Stripe price id that can go stale, a retired plan is re-offered by creating a new one against a fresh price id. **Changed:** *Console nav gains Subscriptions* — The signed-in admin nav now includes Subscriptions alongside Products, Inventory, Orders, Returns, and Reviews, shown when the subscriptions primitive is wired. The `/admin/subscription-plans` list, create, and archive endpoints content-negotiate like the other screens: a bearer-token client gets the JSON API, a signed-in browser gets HTML. A request without the bearer token returns the sign-in form on a GET and redirects on a write.
12
16
 
13
17
  - v0.1.17 (2026-05-25) — **Admin console — an inventory screen.** Inventory joins the admin console. `/admin/inventory` lists stock per SKU — on hand, held, and available — with a low-stock filter, restocks a SKU, sets or clears its per-SKU low-stock threshold, and tracks a new SKU, all from a signed-in browser. As with the other console screens, the same path serves the existing JSON API to a bearer-token client unchanged. **Added:** *Inventory management screen* — `/admin/inventory` renders the inventory table (SKU, on-hand, held, available) with a low-stock filter (`?low=1`) that surfaces SKUs at or below their threshold, when opened in a signed-in browser; the same path serves a new JSON list to a bearer-token client. Each row's form restocks (add quantity) and sets the low-stock threshold (blank clears it) in one submit, and a form below tracks a new SKU. The create / restock JSON API is unchanged for tooling; the browser forms post and redirect (PRG), and a bad SKU is a no-op notice rather than a 500. · *catalog.inventory.list* — `catalog.inventory.list({ limit, low_only })` returns inventory rows (SKU ascending), optionally only those at or below their configured low-stock threshold — the read backing the console list and the JSON endpoint. **Changed:** *Console nav gains Inventory* — The signed-in admin nav now includes Inventory alongside Products, Orders, Returns, and Reviews. The `/admin/inventory` list and the create / restock endpoints content-negotiate like the other screens: a bearer-token client gets the JSON API, a signed-in browser gets HTML. A request without the bearer token on these paths now returns the sign-in form on a GET and redirects on a write, matching the other console screens.
package/README.md CHANGED
@@ -69,10 +69,11 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
69
69
  | **`lib/addresses.js`** | Per-customer address book at `/account/addresses` — add / edit / set default shipping or billing / remove. One-default-per-role invariant (promoting clears the prior). Every by-id route confirms the address belongs to the signed-in customer before acting (a guessed id returns 404). `b.guardUuid` ids, 2-char ISO country. |
70
70
  | **`lib/returns.js`** | Self-serve RMAs. Customer requests a return against their own order at `/account/orders/:id/return` (items + reason, ownership-checked, lines built from the order's own records) and tracks status at `/account/returns`. Operators work `/admin/returns` — approve (refund amount) / mark received / refund / reject — over the pending → approved → received → refunded FSM; illegal transitions are 409, bad ids 404. |
71
71
  | **`lib/recently-viewed.js`** | Signed-in customer browse history. A product-page visit records the view server-side against the customer's account (drop-silent — never blocks the page); `/account/recently-viewed` lists them newest-first as a grid with a Clear-history control. De-duped + capped per customer, archived products drop out, login-gated. Guest/session history is opt-in (a client beacon) and not shipped — the lib's `forSession` + `merge` support it. |
72
+ | **`lib/recommendations.js`** | Product-recommendation engine. Operator-curated override pins first (`setOverride` — "when viewing A, show B", kind-scoped + weight-ordered), then a signal-based fallback: co-purchase (products bought in the same orders), category-popular, and in-stock-random filler. `recommendForProduct` / `recommendForCart` / `recommendForCustomer` / `recommendForCategory` each return renderable picks (active + in-stock, source product excluded). The order confirmation page (`/orders/:id`) renders a "Customers also bought" rail from it — best-effort, anchored on the order's items, excluding what was just bought. |
72
73
  | **`lib/collections.js`** | Curated + smart product groupings. `GET /collections` lists the shop's active collections; `GET /collections/:slug` renders the grid — manual collections list hand-picked members, smart collections evaluate stored rules against the active catalog and apply the collection's sort strategy. Each product resolves fresh, so archived products drop out. Public, no sign-in; a bad or unknown slug is a 404 (never a 500). Linked from the footer on every page. |
73
74
  | **`lib/subscriptions.js`** | Stripe-backed recurring billing — `subscription_plans` (interval / amount / trial) + `subscriptions` (mirrors Stripe's object byte-for-byte). `subscriptions.create` POSTs to Stripe via the payment dep, then persists the returned object locally. `handleStripeEvent` replays `customer.subscription.*` events into the local row so the shop has an authoritative view without round-tripping. |
74
75
  | **`lib/newsletter.js`** | Operator-collected email broadcast list — `signup({ email, source })` composes `b.guardEmail` for shape validation, `b.crypto.namespaceHash` for the dedup key, and `INSERT OR IGNORE` for idempotency. Storefront POST `/newsletter` route renders a designed thank-you card with separate copy for the `new` vs `dedup` branches. |
75
- | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM; **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale. The Returns, Reviews, and Subscriptions links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. |
76
+ | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM; **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. The Returns, Reviews, Subscriptions, and Webhooks links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. |
76
77
  | **`lib/catalog-import.js`** | Bulk CSV import — `POST /admin/catalog/import` accepts a `text/csv` body, parses via `b.csv`, content-safety-filters every cell through `b.guardCsv` (formula-injection / bidi / control / dangerous-function denylist), validates exact header order, de-dupes rows by `product_slug`, returns per-row errors without aborting. Default 1 MiB / 10000 rows caps. |
77
78
  | **`lib/theme.js`** | File-backed templates with fallback chain. Operators register a named theme under `<themesDir>/<name>/*.html` and the storefront dispatches every renderer through it. `assetUrl(path)` resolves to `/assets/themes/<name>/<path>`. The shipped `default` theme is the fallback. |
78
79
 
@@ -96,6 +97,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
96
97
  - `migrations-d1/0206_orders_email_hash.sql` — queryable buyer-email hash on orders (guest-order reconciliation key)
97
98
  - `migrations-d1/0043_collections.sql` — manual + smart product collections (members + rules + sort strategy)
98
99
  - `migrations-d1/0050_recently_viewed.sql` — per-customer / per-session product browse history (dedup + per-subject cap)
100
+ - `migrations-d1/0105_recommendations.sql` — operator-curated recommendation overrides (kind-scoped, weight-ordered)
99
101
 
100
102
  ### Demo seed
101
103
 
package/lib/admin.js CHANGED
@@ -225,7 +225,7 @@ function mount(router, deps) {
225
225
  // Which optional console sections are wired — gates their nav links so a
226
226
  // signed-in admin is never sent to a route that wasn't mounted. Passed
227
227
  // into every authed render call as `nav_available`.
228
- var navAvailable = { returns: !!returns, reviews: !!reviews, subscriptions: !!deps.subscriptions };
228
+ var navAvailable = { returns: !!returns, reviews: !!reviews, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks };
229
229
 
230
230
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
231
231
 
@@ -1183,17 +1183,69 @@ function mount(router, deps) {
1183
1183
 
1184
1184
  var webhooks = deps.webhooks || null;
1185
1185
  if (webhooks) {
1186
- router.post("/admin/webhooks", W("webhook.create", async function (req, res) {
1187
- var body = req.body || {};
1188
- var ep = await webhooks.endpoints.create({ url: body.url, events: body.events });
1189
- _json(res, 201, ep);
1190
- return ep;
1191
- }));
1186
+ var KNOWN_WH_EVENTS = webhooks.KNOWN_EVENTS || [];
1192
1187
 
1193
- router.get("/admin/webhooks", R(async function (_req, res) {
1194
- var rows = await webhooks.endpoints.list();
1195
- _json(res, 200, { rows: rows });
1196
- }));
1188
+ // Create content-negotiates: bearer → JSON (unchanged for tooling);
1189
+ // signed-in browser form → create, then a one-time secret reveal page.
1190
+ // The HMAC signing secret is shown once here and never rendered in the
1191
+ // list (endpoints.list returns it, so the list render omits it), the
1192
+ // way Stripe / GitHub surface webhook secrets.
1193
+ router.post("/admin/webhooks", _pageOrApi(false,
1194
+ W("webhook.create", async function (req, res) {
1195
+ var body = req.body || {};
1196
+ var ep = await webhooks.endpoints.create({ url: body.url, events: body.events });
1197
+ _json(res, 201, ep);
1198
+ return ep;
1199
+ }),
1200
+ async function (req, res) {
1201
+ var body = req.body || {};
1202
+ var events;
1203
+ if (body.events_all === "on" || body.events_all === "1") {
1204
+ events = "*";
1205
+ } else {
1206
+ events = KNOWN_WH_EVENTS.filter(function (ev) {
1207
+ var v = body["evt_" + ev];
1208
+ return v === "on" || v === "1";
1209
+ }).join(",");
1210
+ }
1211
+ var ep;
1212
+ try {
1213
+ ep = await webhooks.endpoints.create({ url: (typeof body.url === "string" ? body.url.trim() : body.url), events: events });
1214
+ } catch (e) {
1215
+ if (!(e instanceof TypeError)) throw e;
1216
+ var rows = await webhooks.endpoints.list();
1217
+ return _sendHtml(res, 400, renderAdminWebhooks({
1218
+ shop_name: deps.shop_name, nav_available: navAvailable, endpoints: rows,
1219
+ known_events: KNOWN_WH_EVENTS, notice: e.message.replace(/^webhooks:\s*/, ""),
1220
+ }));
1221
+ }
1222
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".webhook.create", outcome: "success", metadata: { id: ep.id } });
1223
+ // Direct 200, not a redirect — the one-time secret must never land
1224
+ // in a URL / server log / browser history.
1225
+ _sendHtml(res, 200, renderAdminWebhookSecret({
1226
+ shop_name: deps.shop_name, nav_available: navAvailable, endpoint: ep,
1227
+ }));
1228
+ },
1229
+ ));
1230
+
1231
+ router.get("/admin/webhooks", _pageOrApi(true,
1232
+ R(async function (_req, res) {
1233
+ var rows = await webhooks.endpoints.list();
1234
+ _json(res, 200, { rows: rows });
1235
+ }),
1236
+ async function (req, res) {
1237
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
1238
+ var rows = await webhooks.endpoints.list();
1239
+ _sendHtml(res, 200, renderAdminWebhooks({
1240
+ shop_name: deps.shop_name, nav_available: navAvailable, endpoints: rows,
1241
+ known_events: KNOWN_WH_EVENTS,
1242
+ created: url && url.searchParams.get("created"),
1243
+ toggled: url && url.searchParams.get("toggled"),
1244
+ deleted: url && url.searchParams.get("deleted"),
1245
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the endpoint." : null,
1246
+ }));
1247
+ },
1248
+ ));
1197
1249
 
1198
1250
  router.patch("/admin/webhooks/:id", W("webhook.update", async function (req, res) {
1199
1251
  var ep = await webhooks.endpoints.update(req.params.id, req.body || {});
@@ -1209,20 +1261,93 @@ function mount(router, deps) {
1209
1261
  return { id: req.params.id };
1210
1262
  }));
1211
1263
 
1212
- router.get("/admin/webhooks/:id/deliveries", R(async function (req, res) {
1213
- var url = req.url ? new URL(req.url, "http://localhost") : null;
1214
- var limitS = url && url.searchParams.get("limit");
1215
- var limit = limitS == null ? 50 : parseInt(limitS, 10);
1216
- var rows = await webhooks.deliveries.list(req.params.id, { limit: limit });
1217
- _json(res, 200, { rows: rows });
1218
- }));
1264
+ // Browser-form equivalents of PATCH active / DELETE (HTML forms can
1265
+ // only GET/POST). Bearer clients keep using PATCH / DELETE above.
1266
+ router.post("/admin/webhooks/:id/toggle", _pageOrApi(false,
1267
+ W("webhook.update", async function (req, res) {
1268
+ var cur = await webhooks.endpoints.get(req.params.id);
1269
+ if (!cur) return _problem(res, 404, "webhook-not-found");
1270
+ var ep = await webhooks.endpoints.update(req.params.id, { active: cur.active ? false : true });
1271
+ _json(res, 200, ep);
1272
+ return ep;
1273
+ }),
1274
+ async function (req, res) {
1275
+ var ep = null;
1276
+ try {
1277
+ var cur = await webhooks.endpoints.get(req.params.id);
1278
+ if (cur) ep = await webhooks.endpoints.update(req.params.id, { active: cur.active ? false : true });
1279
+ } catch (e) { if (!(e instanceof TypeError)) throw e; }
1280
+ if (!ep) return _redirect(res, "/admin/webhooks?err=1");
1281
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".webhook.update", outcome: "success", metadata: { id: req.params.id } });
1282
+ _redirect(res, "/admin/webhooks?toggled=1");
1283
+ },
1284
+ ));
1219
1285
 
1220
- router.post("/admin/webhooks/deliveries/:id/retry", W("webhook.retry", async function (req, res) {
1221
- var d = await webhooks.deliveries.retry(req.params.id);
1222
- if (!d) return _problem(res, 404, "delivery-not-found");
1223
- _json(res, 200, d);
1224
- return { id: req.params.id };
1225
- }));
1286
+ router.post("/admin/webhooks/:id/delete", _pageOrApi(false,
1287
+ W("webhook.delete", async function (req, res) {
1288
+ var ok = await webhooks.endpoints.delete(req.params.id);
1289
+ if (!ok) return _problem(res, 404, "webhook-not-found");
1290
+ _json(res, 200, { ok: true });
1291
+ return { id: req.params.id };
1292
+ }),
1293
+ async function (req, res) {
1294
+ var ok = false;
1295
+ try { ok = await webhooks.endpoints.delete(req.params.id); }
1296
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
1297
+ if (!ok) return _redirect(res, "/admin/webhooks?err=1");
1298
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".webhook.delete", outcome: "success", metadata: { id: req.params.id } });
1299
+ _redirect(res, "/admin/webhooks?deleted=1");
1300
+ },
1301
+ ));
1302
+
1303
+ router.get("/admin/webhooks/:id/deliveries", _pageOrApi(true,
1304
+ R(async function (req, res) {
1305
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
1306
+ var limitS = url && url.searchParams.get("limit");
1307
+ var limit = limitS == null ? 50 : parseInt(limitS, 10);
1308
+ var rows = await webhooks.deliveries.list(req.params.id, { limit: limit });
1309
+ _json(res, 200, { rows: rows });
1310
+ }),
1311
+ async function (req, res) {
1312
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
1313
+ var ep, rows;
1314
+ try {
1315
+ ep = await webhooks.endpoints.get(req.params.id);
1316
+ rows = ep ? await webhooks.deliveries.list(req.params.id, { limit: 100 }) : [];
1317
+ } catch (e) {
1318
+ if (!(e instanceof TypeError)) throw e;
1319
+ ep = null; rows = [];
1320
+ }
1321
+ if (!ep) return _sendHtml(res, 404, renderAdminWebhookDeliveries({
1322
+ shop_name: deps.shop_name, nav_available: navAvailable, endpoint: null, deliveries: [],
1323
+ }));
1324
+ _sendHtml(res, 200, renderAdminWebhookDeliveries({
1325
+ shop_name: deps.shop_name, nav_available: navAvailable, endpoint: ep, deliveries: rows,
1326
+ retried: url && url.searchParams.get("retried"),
1327
+ notice: (url && url.searchParams.get("err")) ? "That delivery couldn't be retried." : null,
1328
+ }));
1329
+ },
1330
+ ));
1331
+
1332
+ // Retry composes the network transport (re-POSTs to the endpoint), so
1333
+ // a bearer client gets the JSON contract; a browser form retries then
1334
+ // PRGs back to the endpoint's delivery feed.
1335
+ router.post("/admin/webhooks/deliveries/:id/retry", _pageOrApi(false,
1336
+ W("webhook.retry", async function (req, res) {
1337
+ var d = await webhooks.deliveries.retry(req.params.id);
1338
+ if (!d) return _problem(res, 404, "delivery-not-found");
1339
+ _json(res, 200, d);
1340
+ return { id: req.params.id };
1341
+ }),
1342
+ async function (req, res) {
1343
+ var d = null;
1344
+ try { d = await webhooks.deliveries.retry(req.params.id); }
1345
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
1346
+ if (!d) return _redirect(res, "/admin/webhooks?err=1");
1347
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".webhook.retry", outcome: "success", metadata: { id: req.params.id } });
1348
+ _redirect(res, "/admin/webhooks/" + encodeURIComponent(d.endpoint_id) + "/deliveries?retried=1");
1349
+ },
1350
+ ));
1226
1351
  }
1227
1352
 
1228
1353
  // ---- analytics ------------------------------------------------------
@@ -1821,6 +1946,7 @@ var ADMIN_NAV_ITEMS = [
1821
1946
  { key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
1822
1947
  { key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
1823
1948
  { key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
1949
+ { key: "webhooks", href: "/admin/webhooks", label: "Webhooks", requires: "webhooks" },
1824
1950
  { key: "integrations", href: "/admin/integrations", label: "Integrations" },
1825
1951
  { key: "setup", href: "/admin/setup", label: "Setup" },
1826
1952
  ];
@@ -2434,6 +2560,118 @@ function renderAdminSubscriptionPlans(opts) {
2434
2560
  return _renderAdminShell(opts.shop_name, "Subscription plans", bodyHtml, "subscriptions", opts.nav_available);
2435
2561
  }
2436
2562
 
2563
+ function renderAdminWebhooks(opts) {
2564
+ opts = opts || {};
2565
+ var rows = opts.endpoints || [];
2566
+ var known = opts.known_events || [];
2567
+ var toggled = opts.toggled ? "<div class=\"banner banner--ok\">Endpoint updated.</div>" : "";
2568
+ var deleted = opts.deleted ? "<div class=\"banner banner--ok\">Endpoint deleted.</div>" : "";
2569
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
2570
+
2571
+ // The signing secret is intentionally absent from this table — it is
2572
+ // shown once on create (renderAdminWebhookSecret) and never again.
2573
+ var bodyRows = rows.map(function (e) {
2574
+ var isActive = e.active === 1 || e.active === true;
2575
+ var events = e.events === "*" ? "all events" : String(e.events || "").split(",").join(", ");
2576
+ return "<tr>" +
2577
+ "<td><code class=\"order-id\">" + _htmlEscape(e.url) + "</code></td>" +
2578
+ "<td>" + _htmlEscape(events) + "</td>" +
2579
+ "<td><span class=\"status-pill " + (isActive ? "paid" : "cancelled") + "\">" + (isActive ? "active" : "disabled") + "</span></td>" +
2580
+ "<td class=\"num\">" + _htmlEscape(String(e.rate_limit_per_minute)) + "/min</td>" +
2581
+ "<td>" +
2582
+ "<a class=\"btn btn--ghost\" href=\"/admin/webhooks/" + _htmlEscape(e.id) + "/deliveries\">Deliveries</a> " +
2583
+ "<form method=\"post\" action=\"/admin/webhooks/" + _htmlEscape(e.id) + "/toggle\" style=\"display:inline;\"><button class=\"btn btn--ghost\" type=\"submit\">" + (isActive ? "Disable" : "Enable") + "</button></form> " +
2584
+ "<form method=\"post\" action=\"/admin/webhooks/" + _htmlEscape(e.id) + "/delete\" style=\"display:inline;\"><button class=\"btn btn--danger\" type=\"submit\">Delete</button></form>" +
2585
+ "</td></tr>";
2586
+ }).join("");
2587
+
2588
+ var table = rows.length
2589
+ ? "<div class=\"panel\"><table><thead><tr><th>URL</th><th>Events</th><th>Status</th><th class=\"num\">Rate</th><th>Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
2590
+ : "<p class=\"empty\">No webhook endpoints yet.</p>";
2591
+
2592
+ var eventChecks = known.map(function (ev) {
2593
+ return "<label style=\"display:block; margin:.3rem 0;\"><input type=\"checkbox\" name=\"evt_" + _htmlEscape(ev) + "\"> <code>" + _htmlEscape(ev) + "</code></label>";
2594
+ }).join("");
2595
+
2596
+ var createForm =
2597
+ "<div class=\"panel\" style=\"margin-top:1.5rem; max-width:40rem;\">" +
2598
+ "<h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Add an endpoint</h3>" +
2599
+ "<p class=\"meta\">Deliveries are signed (HMAC-SHA3-512); the signing secret is shown once, right after you create the endpoint. Only https:// URLs are accepted.</p>" +
2600
+ "<form method=\"post\" action=\"/admin/webhooks\">" +
2601
+ _setupField("Endpoint URL", "url", "", "url", "Where deliveries are POSTed (https:// only).", " maxlength=\"2048\" required") +
2602
+ "<fieldset style=\"border:1px solid var(--hair); border-radius:.5rem; padding:.75rem 1rem; margin:1rem 0;\">" +
2603
+ "<legend style=\"padding:0 .4rem; font-size:.85rem;\">Events</legend>" +
2604
+ "<label style=\"display:block; margin:.3rem 0;\"><input type=\"checkbox\" name=\"events_all\"> <strong>All events (*)</strong></label>" +
2605
+ eventChecks +
2606
+ "<small style=\"color:var(--mute);\">Pick specific events, or check “All events” to subscribe to everything.</small>" +
2607
+ "</fieldset>" +
2608
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create endpoint</button></div>" +
2609
+ "</form>" +
2610
+ "</div>";
2611
+
2612
+ var body = "<section><h2>Webhooks</h2>" + toggled + deleted + notice + table + createForm + "</section>";
2613
+ return _renderAdminShell(opts.shop_name, "Webhooks", body, "webhooks", opts.nav_available);
2614
+ }
2615
+
2616
+ function renderAdminWebhookSecret(opts) {
2617
+ opts = opts || {};
2618
+ var e = opts.endpoint || {};
2619
+ var body =
2620
+ "<section style=\"max-width:42rem;\">" +
2621
+ "<h2>Endpoint created</h2>" +
2622
+ "<div class=\"banner banner--ok\">Copy the signing secret now — it is shown once and cannot be retrieved again.</div>" +
2623
+ "<div class=\"panel\">" +
2624
+ "<p class=\"meta\">Endpoint</p><p><code class=\"order-id\">" + _htmlEscape(e.url || "") + "</code></p>" +
2625
+ "<p class=\"meta\">Signing secret (HMAC-SHA3-512, key id <code>v1</code>)</p>" +
2626
+ "<pre style=\"white-space:pre-wrap; word-break:break-all; background:var(--bg); border:1px solid var(--hair); border-radius:.5rem; padding:.75rem;\"><code>" + _htmlEscape(e.secret || "") + "</code></pre>" +
2627
+ "<p class=\"meta\">Verify each delivery's signature with this secret using your framework's webhook verifier.</p>" +
2628
+ "</div>" +
2629
+ "<div class=\"actions-row\"><a class=\"btn\" href=\"/admin/webhooks\">Done</a></div>" +
2630
+ "</section>";
2631
+ return _renderAdminShell(opts.shop_name, "Endpoint created", body, "webhooks", opts.nav_available);
2632
+ }
2633
+
2634
+ function renderAdminWebhookDeliveries(opts) {
2635
+ opts = opts || {};
2636
+ var e = opts.endpoint;
2637
+ if (!e) {
2638
+ var nf = "<section><h2>Deliveries</h2><p class=\"empty\">Endpoint not found.</p>" +
2639
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/webhooks\">Back to webhooks</a></div></section>";
2640
+ return _renderAdminShell(opts.shop_name, "Deliveries", nf, "webhooks", opts.nav_available);
2641
+ }
2642
+ var rows = opts.deliveries || [];
2643
+ var retried = opts.retried ? "<div class=\"banner banner--ok\">Delivery retried.</div>" : "";
2644
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
2645
+
2646
+ var bodyRows = rows.map(function (d) {
2647
+ var ok = d.delivered_at != null;
2648
+ var statusCell = ok
2649
+ ? "<span class=\"status-pill paid\">delivered</span>"
2650
+ : "<span class=\"status-pill " + (d.last_error ? "refunded" : "pending") + "\">" + (d.last_error ? "failed" : "pending") + "</span>";
2651
+ var code = d.last_status != null ? _htmlEscape(String(d.last_status)) : "—";
2652
+ var retry = ok ? "<span class=\"meta\">—</span>"
2653
+ : "<form method=\"post\" action=\"/admin/webhooks/deliveries/" + _htmlEscape(d.id) + "/retry\" style=\"display:inline;\"><button class=\"btn btn--ghost\" type=\"submit\">Retry</button></form>";
2654
+ return "<tr>" +
2655
+ "<td>" + _htmlEscape(d.event_type) + "</td>" +
2656
+ "<td>" + statusCell + "</td>" +
2657
+ "<td class=\"num\">" + code + "</td>" +
2658
+ "<td class=\"num\">" + _htmlEscape(String(d.attempts)) + "</td>" +
2659
+ "<td>" + (d.last_error ? "<span class=\"meta\">" + _htmlEscape(d.last_error) + "</span>" : "") + "</td>" +
2660
+ "<td>" + _htmlEscape(_fmtDate(d.created_at)) + "</td>" +
2661
+ "<td>" + retry + "</td>" +
2662
+ "</tr>";
2663
+ }).join("");
2664
+
2665
+ var table = rows.length
2666
+ ? "<div class=\"panel\"><table><thead><tr><th>Event</th><th>Status</th><th class=\"num\">Code</th><th class=\"num\">Attempts</th><th>Last error</th><th>Created</th><th></th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
2667
+ : "<p class=\"empty\">No deliveries recorded for this endpoint yet.</p>";
2668
+
2669
+ var head = "<p class=\"meta\">Endpoint <code class=\"order-id\">" + _htmlEscape(e.url) + "</code></p>";
2670
+ var body = "<section><h2>Deliveries</h2>" + retried + notice + head + table +
2671
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/webhooks\">Back to webhooks</a></div></section>";
2672
+ return _renderAdminShell(opts.shop_name, "Deliveries", body, "webhooks", opts.nav_available);
2673
+ }
2674
+
2437
2675
  module.exports = {
2438
2676
  mount: mount,
2439
2677
  AUDIT_NAMESPACE: AUDIT_NAMESPACE,
package/lib/order.js CHANGED
@@ -280,25 +280,31 @@ function create(opts) {
280
280
  ],
281
281
  );
282
282
  var refreshed = await this.get(orderId);
283
- // Fan-out to merchant webhook subscribers. The dispatch is
284
- // post-persist so a delivery failure can never roll back the
285
- // transition that just landed in the database. Errors from the
286
- // dispatcher are swallowed the failure already lives in
287
- // `webhook_deliveries.last_error` for operator review.
283
+ // Fan-out to merchant webhook subscribers is fire-and-forget. The
284
+ // transition has already persisted; the request must not wait on
285
+ // outbound HTTP, or a slow / unreachable endpoint would block the
286
+ // transition for seconds (the HTTP client's idle timeout plus
287
+ // retry). send() persists a delivery row per subscriber and attempts
288
+ // delivery in the background; a failure lives in
289
+ // `webhook_deliveries.last_error` for retry from the admin console.
290
+ // The promise is detached with a swallowing .catch so a rejection —
291
+ // or a synchronous throw from send() — never becomes an
292
+ // unhandledRejection and never touches the transition's latency.
288
293
  if (webhooks && typeof webhooks.send === "function") {
289
- try {
290
- await webhooks.send("order." + event, {
291
- order: refreshed,
292
- transition: {
293
- from: result.from,
294
- to: result.to,
295
- on_event: event,
296
- reason: (opts2 && opts2.reason) || null,
297
- metadata: (opts2 && opts2.metadata) || {},
298
- occurred_at: ts,
299
- },
300
- });
301
- } catch (_e) { /* drop-silent — delivery rows hold the failure */ }
294
+ var _whPayload = {
295
+ order: refreshed,
296
+ transition: {
297
+ from: result.from,
298
+ to: result.to,
299
+ on_event: event,
300
+ reason: (opts2 && opts2.reason) || null,
301
+ metadata: (opts2 && opts2.metadata) || {},
302
+ occurred_at: ts,
303
+ },
304
+ };
305
+ Promise.resolve().then(function () {
306
+ return webhooks.send("order." + event, _whPayload);
307
+ }).catch(function () { /* drop-silent — delivery rows hold the failure */ });
302
308
  }
303
309
  return refreshed;
304
310
  },
package/lib/storefront.js CHANGED
@@ -1818,20 +1818,23 @@ function renderOrder(opts) {
1818
1818
  var tax = pricing.format(o.tax_minor, o.currency);
1819
1819
  var shipping = pricing.format(o.shipping_minor, o.currency);
1820
1820
  var total = pricing.format(o.grand_total_minor, o.currency);
1821
+ var recs = opts.recommendations || [];
1821
1822
  if (opts.theme) {
1822
1823
  return opts.theme.render("order", {
1823
- title: "Order " + o.id,
1824
- shop_name: shopName,
1825
- cart_count: cartCount,
1826
- order_id: o.id,
1827
- status: o.status,
1828
- lines: rendered,
1829
- has_lines: rendered.length > 0,
1830
- subtotal: subtotal,
1831
- tax: tax,
1832
- shipping: shipping,
1833
- total: total,
1834
- asset_css_main: opts.theme.assetUrl("css/main.css"),
1824
+ title: "Order " + o.id,
1825
+ shop_name: shopName,
1826
+ cart_count: cartCount,
1827
+ order_id: o.id,
1828
+ status: o.status,
1829
+ lines: rendered,
1830
+ has_lines: rendered.length > 0,
1831
+ subtotal: subtotal,
1832
+ tax: tax,
1833
+ shipping: shipping,
1834
+ total: total,
1835
+ recommendations: recs,
1836
+ has_recommendations: recs.length > 0,
1837
+ asset_css_main: opts.theme.assetUrl("css/main.css"),
1835
1838
  });
1836
1839
  }
1837
1840
  function _orderEsc(s) { return b.template.escapeHtml(s); }
@@ -1858,12 +1861,24 @@ function renderOrder(opts) {
1858
1861
  shipping: shipping,
1859
1862
  total: total,
1860
1863
  }).replace("RAW_LINES", rows);
1864
+ // Post-purchase cross-sell rail — reuses the catalog grid + product-card
1865
+ // markup (so it inherits the storefront's card styling), rendered only
1866
+ // when the picker returned something.
1867
+ var railHtml = "";
1868
+ if (recs.length) {
1869
+ var railCards = recs.map(function (p) { return _buildProductCard(p); }).join("");
1870
+ railHtml =
1871
+ "<section class=\"catalog-section order-recommendations\">" +
1872
+ "<header class=\"section-head\"><h2 class=\"section-head__title\">Customers also bought</h2></header>" +
1873
+ "<div class=\"grid\">" + railCards + "</div>" +
1874
+ "</section>";
1875
+ }
1861
1876
  return _wrap({
1862
1877
  title: "Order " + o.id,
1863
1878
  shop_name: shopName,
1864
1879
  cart_count: cartCount,
1865
1880
  theme_css: opts.theme_css,
1866
- body: body,
1881
+ body: body + railHtml,
1867
1882
  });
1868
1883
  }
1869
1884
 
@@ -3019,11 +3034,41 @@ function mount(router, deps) {
3019
3034
  hero_media: media.length ? media[0] : null,
3020
3035
  };
3021
3036
  }
3037
+ // "Customers also bought" rail — co-purchase signals anchored on the
3038
+ // order's own items (and excluding them, so we never recommend what
3039
+ // was just bought). Best-effort: a read failure (engine not wired /
3040
+ // tables not migrated) degrades to no rail rather than 500-ing the
3041
+ // confirmation page.
3042
+ var recommendations = [];
3043
+ if (deps.recommendations) {
3044
+ try {
3045
+ var orderProductIds = [];
3046
+ for (var li = 0; li < (o.lines || []).length; li += 1) {
3047
+ var look = productLookup[o.lines[li].variant_id];
3048
+ if (look && look.product && orderProductIds.indexOf(look.product.id) === -1) {
3049
+ orderProductIds.push(look.product.id);
3050
+ }
3051
+ }
3052
+ if (orderProductIds.length) {
3053
+ // recommendForCart aggregates the co-purchase signal across
3054
+ // EVERY purchased product (not just the first) and pivots the
3055
+ // category-popular fallback off the order's dominant
3056
+ // collection; it also self-excludes the order's own products,
3057
+ // so a multi-item order's rail reflects the whole order.
3058
+ var picks = await deps.recommendations.recommendForCart(orderProductIds, { limit: 4 });
3059
+ for (var pi = 0; pi < picks.length; pi += 1) {
3060
+ var card = await _decorateProductCard(picks[pi].product_id);
3061
+ if (card) recommendations.push(card);
3062
+ }
3063
+ }
3064
+ } catch (_e) { recommendations = []; }
3065
+ }
3022
3066
  _send(res, 200, renderOrder({
3023
- order: o,
3024
- product_lookup: productLookup,
3025
- shop_name: shopName,
3026
- theme: theme,
3067
+ order: o,
3068
+ product_lookup: productLookup,
3069
+ recommendations: recommendations,
3070
+ shop_name: shopName,
3071
+ theme: theme,
3027
3072
  }));
3028
3073
  });
3029
3074
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {