@blamejs/blamejs-shop 0.2.20 → 0.2.22

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.2.x
10
10
 
11
+ - v0.2.22 (2026-05-29) — **See your full order history, track shipments, reorder, and request returns.** The customer order surface is now complete end-to-end. A new order-history list shows every order (cursor-paginated), and each order page gains a status timeline, a carrier tracking panel (tracking number + the carrier's public tracking link), a one-click Reorder that rebuilds a cart from the order, and a Request-a-return button for eligible orders — the same Reorder/Return actions also appear per row on the account dashboard and the history list. On the operator side, the admin order screen gets a shipment panel to attach a carrier + tracking number and record shipment events, and the returns console's Refund now issues the payment-provider refund first (then records the RMA) whenever the order has a captured charge, so a return can't be marked refunded while the customer is left un-refunded; manual/guest orders with no charge fall back to an explicit record-only step. **Added:** *Order history + tracking + reorder + returns (customer)* — `/account/orders` lists all your orders with pagination; each order page shows a status timeline and carrier tracking, plus Reorder (`POST /orders/:id/reorder` rebuilds a cart from the order's lines at current prices) and Request-a-return for eligible orders. The dashboard and history rows carry the same actions. · *Shipment + tracking panel (admin)* — The admin order detail screen can attach a shipment (carrier + tracking number) and record shipment events; marking delivered advances the order's lifecycle. Tracking the order surfaces the carrier's public tracking link to the customer. **Changed:** *Return refunds move the money* — The returns console Refund action now issues the payment-provider refund first and records the RMA only on success when the order has a captured charge, so an approved return is never marked refunded without the customer actually being refunded. Orders with no captured charge use an explicit record-only step that points to the order.
12
+
13
+ - v0.2.21 (2026-05-29) — **Related products ("You may also like") on the product page.** Product pages now show a related-products rail. The picks are the other products in the product's primary collection, ordered deterministically and capped at four, rendered with the same product-card markup the home and search grids use — identical on the edge and origin paths. The rail is hidden when a product has no related items. This surfaces existing catalog relationships to shoppers without any new data or configuration. **Added:** *Related-products rail on the PDP* — A "You may also like" section on each product page lists other active products from the same primary collection (deterministic order, up to four), using the standard product-card markup byte-identically across the edge and origin renders. Hidden when there are no related products.
14
+
11
15
  - v0.2.20 (2026-05-29) — **Manage a subscription yourself — pause, skip, change, or reactivate.** The subscriptions account screen previously only let a customer cancel. It now exposes the full self-service set: pause and resume, skip the next shipment, change the quantity, change the delivery frequency, and reactivate a recently-cancelled subscription within its grace window. Each action is state-gated (only the controls that apply to a subscription's current state are shown), confirm-aware where it changes billing, and reports back with a status notice. No more cancelling just to make a change. **Added:** *Subscription self-management* — `/account/subscriptions` gains Pause / Resume, Skip next shipment, Change quantity, Change frequency, and Reactivate (within the grace window) alongside Cancel. Controls are shown only when valid for the subscription's state; quantity and frequency are validated server-side; pause and cancel go through a confirmation step.
12
16
 
13
17
  - v0.2.19 (2026-05-29) — **Search results render the same product cards as the rest of the store.** A product with no image showed an outdated, text-only card on the search-results page when that page was served from the edge, while the home and category grids (and the origin-rendered search page) used the current placeholder-illustration card. The edge search page now emits the same card markup as every other grid, so a no-image product looks identical wherever it appears. A render-level parity check now pins the search page's markup byte-for-byte across the edge and origin paths so this kind of drift is caught automatically. **Fixed:** *Consistent no-image product card on search* — The edge-rendered search results page used a stale text-only card for products without a hero image; it now renders the placeholder-illustration card used across the rest of the storefront, byte-identical to the origin render.
package/README.md CHANGED
@@ -87,7 +87,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
87
87
  | **`lib/giftcards.js`** | Prepaid bearer gift cards. `issue({ amount_minor, currency })` generates a 16-char code (32-glyph alphabet, no ambiguous letters) via `b.crypto.generateBytes`, stores only its `namespaceHash` digest + a 4-char hint, and returns the plaintext code once. `balance(code)` / `lookup(code)` resolve a code to its live balance (constant-time hash compare); `redeem({ code, order_id, amount_minor })` decrements the balance with an atomic `balance >= amount` SQL guard so concurrent spends can't overdraw. Redeemed at checkout as a credit against the order grand total: the amount due drops by the applied balance (never below zero), the order still records the full total it owed, and the debit is recorded once per order — a card that fully covers the order is marked paid with no Stripe charge. Customers check a balance at `GET /gift-cards`; the page is not a code-existence oracle (unknown / malformed / expired all return the same generic not-found). |
88
88
  | **`lib/gift-card-ledger.js`** | Append-only credit / debit / expire history per gift card, with a denormalized `balance_after_minor` snapshot for O(1) balance reads. `credit` / `debit` / `expire` write one row each; `history(id)` paginates a card's transactions; `transactionsForOrder(id)` lists a card's movements for one order. The audit trail behind the admin gift-card ledger console; overdraft is refused at the primitive layer. |
89
89
  | **`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. |
90
- | **`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, and each product opens a management screen that edits its fields, adds / edits / removes variants, sets a variant's price and shows its price history, and attaches / uploads / removes images — the full path to a sellable product; **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; **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); **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; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **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; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), open a card to see its full credit / debit / expire ledger, and void an active card through a confirmation step; **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. **Tax** (`/admin/tax-rates`), **Shipping** (`/admin/shipping`), and **Discounts** (`/admin/discounts`) configure tax rates per jurisdiction, shipping zones + rates, and automatic-discount rules + coupon-stacking policies — create / edit / archive each. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, and Discounts 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. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
90
+ | **`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, and each product opens a management screen that edits its fields, adds / edits / removes variants, sets a variant's price and shows its price history, and attaches / uploads / removes images — the full path to a sellable product; **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, and attaches a shipment (carrier + tracking number) with recorded shipment events that surface a public tracking link to the customer; **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); **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; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **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; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), open a card to see its full credit / debit / expire ledger, and void an active card through a confirmation step; **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. **Tax** (`/admin/tax-rates`), **Shipping** (`/admin/shipping`), and **Discounts** (`/admin/discounts`) configure tax rates per jurisdiction, shipping zones + rates, and automatic-discount rules + coupon-stacking policies — create / edit / archive each. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, and Discounts 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. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
91
91
  | **`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. |
92
92
  | **`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. |
93
93
 
package/lib/admin.js CHANGED
@@ -315,6 +315,7 @@ function mount(router, deps) {
315
315
  var productQa = deps.productQa || null; // Q&A moderation endpoints disabled when absent
316
316
  var returns = deps.returns || null; // RMA moderation endpoints disabled when absent
317
317
  var customers = deps.customers || null; // read-only customers console disabled when absent
318
+ var orderTracking = deps.orderTracking || null; // shipment/tracking panel disabled when absent
318
319
 
319
320
  // Which optional console sections are wired — gates their nav links so a
320
321
  // signed-in admin is never sent to a route that wasn't mounted. Passed
@@ -1175,6 +1176,21 @@ function mount(router, deps) {
1175
1176
  shop_name: deps.shop_name, nav_available: navAvailable, orders: [], notice: "Order not found.",
1176
1177
  }));
1177
1178
  var url = req.url ? new URL(req.url, "http://localhost") : null;
1179
+ // Shipment + carrier-event ledger for the tracking panel. Best-effort:
1180
+ // the shipments table may be unmigrated on a given deploy, so a read
1181
+ // failure degrades to "no shipments yet" rather than 500-ing the
1182
+ // detail. Each shipment is hydrated via getShipment so the panel can
1183
+ // list its carrier events.
1184
+ var shipments = [];
1185
+ if (orderTracking) {
1186
+ try {
1187
+ var shipRows = await orderTracking.listForOrder(o.id);
1188
+ for (var si = 0; si < shipRows.length; si += 1) {
1189
+ var full = await orderTracking.getShipment(shipRows[si].id);
1190
+ shipments.push(full || shipRows[si]);
1191
+ }
1192
+ } catch (_e) { shipments = []; }
1193
+ }
1178
1194
  _sendHtml(res, 200, renderAdminOrder({
1179
1195
  shop_name: deps.shop_name,
1180
1196
  nav_available: navAvailable,
@@ -1183,12 +1199,106 @@ function mount(router, deps) {
1183
1199
  // Refund moves money, so the console only offers it when a payment
1184
1200
  // provider is wired AND the order has a captured intent to refund.
1185
1201
  can_refund: !!(payment && o.payment_intent_id),
1202
+ // Shipment/tracking panel only renders when the tracking primitive
1203
+ // is wired; the carrier + status enums drive its form selects.
1204
+ can_track: !!orderTracking,
1205
+ shipments: shipments,
1206
+ carriers: orderTracking ? orderTracking.CARRIERS : null,
1207
+ statuses: orderTracking ? orderTracking.STATUSES : null,
1186
1208
  moved: url && url.searchParams.get("moved"),
1209
+ ship_done: url && url.searchParams.get("ship"),
1187
1210
  notice: url && url.searchParams.get("err") ? "That action couldn't be completed for this order." : null,
1188
1211
  }));
1189
1212
  },
1190
1213
  ));
1191
1214
 
1215
+ // ---- shipment tracking (operator-managed) ---------------------------
1216
+ //
1217
+ // Attach a shipment to an order (carrier + optional tracking number) and
1218
+ // record carrier events against it. Composes order-tracking; mounted
1219
+ // only when that primitive is wired. Both the JSON API and the browser
1220
+ // console funnel through `W()` so writes carry the CSRF + audit
1221
+ // discipline of the rest of the console. The browser side redirects back
1222
+ // to the order detail (PRG); a bad shape (TypeError) surfaces as a
1223
+ // notice, never a 500.
1224
+ if (orderTracking) {
1225
+ router.post("/admin/orders/:id/shipments", _pageOrApi(false,
1226
+ W("order.shipment.create", async function (req, res) {
1227
+ var body = req.body || {};
1228
+ var ship;
1229
+ try {
1230
+ ship = await orderTracking.createShipment({
1231
+ order_id: req.params.id,
1232
+ carrier: body.carrier,
1233
+ carrier_other_name: body.carrier_other_name || undefined,
1234
+ tracking_number: body.tracking_number || undefined,
1235
+ });
1236
+ } catch (e) {
1237
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
1238
+ throw e;
1239
+ }
1240
+ _json(res, 201, ship);
1241
+ return { id: req.params.id };
1242
+ }),
1243
+ async function (req, res) {
1244
+ var id = req.params.id;
1245
+ var enc = encodeURIComponent(id);
1246
+ var body = req.body || {};
1247
+ try {
1248
+ await orderTracking.createShipment({
1249
+ order_id: id,
1250
+ carrier: body.carrier,
1251
+ carrier_other_name: (body.carrier_other_name && body.carrier_other_name.length) ? body.carrier_other_name : undefined,
1252
+ tracking_number: (body.tracking_number && body.tracking_number.length) ? body.tracking_number : undefined,
1253
+ });
1254
+ } catch (e) {
1255
+ if (!(e instanceof TypeError)) throw e;
1256
+ return _redirect(res, "/admin/orders/" + enc + "?err=1");
1257
+ }
1258
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".order.shipment.create", outcome: "success", metadata: { id: id } });
1259
+ _redirect(res, "/admin/orders/" + enc + "?ship=1");
1260
+ },
1261
+ ));
1262
+
1263
+ router.post("/admin/orders/:id/shipments/:shipmentId/events", _pageOrApi(false,
1264
+ W("order.shipment.event", async function (req, res) {
1265
+ var body = req.body || {};
1266
+ var ev;
1267
+ try {
1268
+ ev = await orderTracking.recordEvent({
1269
+ shipment_id: req.params.shipmentId,
1270
+ status: body.status,
1271
+ location: body.location || undefined,
1272
+ detail: body.detail || undefined,
1273
+ });
1274
+ } catch (e) {
1275
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
1276
+ throw e;
1277
+ }
1278
+ _json(res, 200, ev);
1279
+ return { id: req.params.id };
1280
+ }),
1281
+ async function (req, res) {
1282
+ var id = req.params.id;
1283
+ var enc = encodeURIComponent(id);
1284
+ var body = req.body || {};
1285
+ try {
1286
+ await orderTracking.recordEvent({
1287
+ shipment_id: req.params.shipmentId,
1288
+ status: body.status,
1289
+ location: (body.location && body.location.length) ? body.location : undefined,
1290
+ detail: (body.detail && body.detail.length) ? body.detail : undefined,
1291
+ });
1292
+ } catch (e) {
1293
+ if (!(e instanceof TypeError)) throw e;
1294
+ return _redirect(res, "/admin/orders/" + enc + "?err=1");
1295
+ }
1296
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".order.shipment.event", outcome: "success", metadata: { id: id, shipment_id: req.params.shipmentId } });
1297
+ _redirect(res, "/admin/orders/" + enc + "?ship=1");
1298
+ },
1299
+ ));
1300
+ }
1301
+
1192
1302
  router.post("/admin/orders/:id/transition", _pageOrApi(false,
1193
1303
  W("order.transition", async function (req, res) {
1194
1304
  var body = req.body || {};
@@ -1720,17 +1830,37 @@ function mount(router, deps) {
1720
1830
  shop_name: deps.shop_name, nav_available: navAvailable, returns: [], status: "pending", notice: "Return not found.",
1721
1831
  }));
1722
1832
  var url = req.url ? new URL(req.url, "http://localhost") : null;
1833
+ var rmaCtx = await _rmaProviderContext(rma);
1723
1834
  _sendHtml(res, 200, renderAdminReturn({
1724
1835
  shop_name: deps.shop_name,
1725
1836
  nav_available: navAvailable,
1726
1837
  rma: rma,
1727
1838
  transitions: returns.transitionsFrom(rma.status),
1839
+ // When a payment provider is wired AND the linked order has a
1840
+ // captured intent, the Refund action moves money through the
1841
+ // provider via a confirm interstitial. Absent either, it stays
1842
+ // record-only with a pointer to the order page for the money side.
1843
+ can_provider_refund: rmaCtx.canProviderRefund,
1728
1844
  moved: url && url.searchParams.get("moved"),
1729
1845
  notice: url && url.searchParams.get("err") ? "That action couldn't be completed for this return." : null,
1730
1846
  }));
1731
1847
  },
1732
1848
  ));
1733
1849
 
1850
+ // Resolve whether an RMA can be refunded through the payment provider:
1851
+ // the provider must be wired AND the linked order must carry a captured
1852
+ // payment intent. Returns the linked order too so the caller can issue
1853
+ // the refund without a second fetch. Best-effort — a bad/legacy order id
1854
+ // degrades to "record-only" rather than throwing.
1855
+ async function _rmaProviderContext(rma) {
1856
+ var ctx = { order: null, canProviderRefund: false };
1857
+ if (!payment || !rma || !rma.order_id) return ctx;
1858
+ try { ctx.order = await order.get(rma.order_id); }
1859
+ catch (_e) { ctx.order = null; }
1860
+ ctx.canProviderRefund = !!(ctx.order && ctx.order.payment_intent_id);
1861
+ return ctx;
1862
+ }
1863
+
1734
1864
  // The browser side of an RMA action: run `opFn(id, body)`, then PRG
1735
1865
  // back to the detail. A bad id / shape (TypeError) or an FSM refusal /
1736
1866
  // not-found (mapped by _returnsClientError) becomes a notice on the
@@ -1803,22 +1933,124 @@ function mount(router, deps) {
1803
1933
  function (id, body) { return returns.markReceived(id, { operator_notes: body.operator_notes || undefined }); },
1804
1934
  ));
1805
1935
 
1806
- router.post("/admin/returns/:id/refund", _returnAction(
1936
+ // Issue the provider refund for an RMA (when a captured intent exists),
1937
+ // then record the RMA refund. The provider call runs FIRST — only if it
1938
+ // succeeds does the RMA move to refunded — so the queue never marks a
1939
+ // return refunded with the customer never paid back. The refund amount
1940
+ // is the RMA's approved refund_amount_minor (set at approve time);
1941
+ // absent one, the provider issues a full refund of the intent.
1942
+ async function _rmaProviderRefund(rma, order2, body) {
1943
+ var idem = "rma-refund:" + rma.id + ":" + (body.idempotency_suffix || b.uuid.v7());
1944
+ var refund = await payment.refund({
1945
+ payment_intent: order2.payment_intent_id,
1946
+ amount_minor: (rma.refund_amount_minor != null && rma.refund_amount_minor > 0) ? rma.refund_amount_minor : undefined,
1947
+ reason: "requested_by_customer",
1948
+ metadata: { order_id: order2.id, rma_id: rma.id, rma_code: rma.rma_code || "" },
1949
+ }, idem);
1950
+ var updated = await returns.refund(rma.id, {
1951
+ operator_notes: (body.operator_notes && body.operator_notes.length)
1952
+ ? body.operator_notes
1953
+ : ("provider refund " + refund.id),
1954
+ });
1955
+ return { refund: refund, rma: updated };
1956
+ }
1957
+
1958
+ // Browser confirmation interstitial for a provider-backed RMA refund —
1959
+ // it moves money + advances the RMA, so the console makes the operator
1960
+ // confirm (the CSP forbids a client confirm() dialog). Only meaningful
1961
+ // when a captured intent exists; otherwise it bounces to the detail.
1962
+ router.post("/admin/returns/:id/refund/confirm", _pageOrApi(false,
1963
+ R(async function (_req, res) {
1964
+ return _problem(res, 405, "use-canonical-endpoint", "POST /admin/returns/:id/refund directly for the JSON API");
1965
+ }),
1966
+ async function (req, res) {
1967
+ var id = req.params.id;
1968
+ var enc = encodeURIComponent(id);
1969
+ var rma;
1970
+ try { rma = await returns.get(id); }
1971
+ catch (e) { if (!(e instanceof TypeError)) throw e; rma = null; }
1972
+ if (!rma) return _redirect(res, "/admin/returns/" + enc + "?err=1");
1973
+ var rmaCtx = await _rmaProviderContext(rma);
1974
+ if (!rmaCtx.canProviderRefund) {
1975
+ // No captured intent — there's nothing to confirm; the detail's
1976
+ // record-only form handles it.
1977
+ return _redirect(res, "/admin/returns/" + enc + "?err=1");
1978
+ }
1979
+ var amount = (rma.refund_amount_minor != null && rma.refund_amount_minor > 0)
1980
+ ? pricing.format(rma.refund_amount_minor, rma.refund_currency || "USD")
1981
+ : pricing.format(rmaCtx.order.grand_total_minor, rmaCtx.order.currency);
1982
+ _sendHtml(res, 200, renderAdminConfirm({
1983
+ shop_name: deps.shop_name, nav_available: navAvailable, active: "returns",
1984
+ heading: "Refund return " + _htmlEscape(rma.rma_code || rma.id.slice(0, 8)) + "?",
1985
+ consequence: "This issues a refund of " + amount + " through the payment provider and cannot be undone.",
1986
+ detail: "The provider refund runs first; only if it succeeds does the return move to a refunded state.",
1987
+ action: "/admin/returns/" + _htmlEscape(rma.id) + "/refund",
1988
+ confirm_label: "Refund " + amount,
1989
+ cancel_href: "/admin/returns/" + enc,
1990
+ }));
1991
+ },
1992
+ ));
1993
+
1994
+ router.post("/admin/returns/:id/refund", _pageOrApi(false,
1807
1995
  W("return.refund", async function (req, res) {
1808
1996
  var body = req.body || {};
1809
1997
  var rma;
1998
+ try { rma = await returns.get(req.params.id); }
1999
+ catch (e) {
2000
+ if (e instanceof TypeError) return _problem(res, 404, "return-not-found", e.message);
2001
+ throw e;
2002
+ }
2003
+ if (!rma) return _problem(res, 404, "return-not-found");
2004
+ var rmaCtx = await _rmaProviderContext(rma);
2005
+ // Provider-backed path: move money first, then record the RMA.
2006
+ if (rmaCtx.canProviderRefund) {
2007
+ var result;
2008
+ try { result = await _rmaProviderRefund(rma, rmaCtx.order, body); }
2009
+ catch (e) {
2010
+ var ce = _returnsClientError(e);
2011
+ if (ce) return _problem(res, ce.status, ce.slug, e.message);
2012
+ return _problem(res, 502, "provider-refund-failed", (e && e.message) || String(e));
2013
+ }
2014
+ _json(res, 200, result.rma);
2015
+ return result.rma;
2016
+ }
2017
+ // Record-only path (no captured intent / no provider wired).
2018
+ var recorded;
1810
2019
  try {
1811
- rma = await returns.refund(req.params.id, { operator_notes: body.operator_notes });
2020
+ recorded = await returns.refund(req.params.id, { operator_notes: body.operator_notes });
1812
2021
  } catch (e) {
1813
- var ce = _returnsClientError(e);
1814
- if (ce) return _problem(res, ce.status, ce.slug, e.message);
2022
+ var ce2 = _returnsClientError(e);
2023
+ if (ce2) return _problem(res, ce2.status, ce2.slug, e.message);
1815
2024
  throw e;
1816
2025
  }
1817
- _json(res, 200, rma);
1818
- return rma;
2026
+ _json(res, 200, recorded);
2027
+ return recorded;
1819
2028
  }),
1820
- "return.refund",
1821
- function (id, body) { return returns.refund(id, { operator_notes: body.operator_notes || undefined }); },
2029
+ async function (req, res) {
2030
+ var id = req.params.id;
2031
+ var enc = encodeURIComponent(id);
2032
+ var rma;
2033
+ try { rma = await returns.get(id); }
2034
+ catch (e) { if (!(e instanceof TypeError)) throw e; rma = null; }
2035
+ if (!rma) return _redirect(res, "/admin/returns/" + enc + "?err=1");
2036
+ var rmaCtx = await _rmaProviderContext(rma);
2037
+ try {
2038
+ if (rmaCtx.canProviderRefund) {
2039
+ await _rmaProviderRefund(rma, rmaCtx.order, req.body || {});
2040
+ } else {
2041
+ await returns.refund(id, { operator_notes: (req.body || {}).operator_notes || undefined });
2042
+ }
2043
+ } catch (e) {
2044
+ if (e instanceof TypeError || _returnsClientError(e)) {
2045
+ return _redirect(res, "/admin/returns/" + enc + "?err=1");
2046
+ }
2047
+ // A provider-refund failure leaves the RMA untouched (the record
2048
+ // step only runs after a successful refund) — surface as a notice.
2049
+ return _redirect(res, "/admin/returns/" + enc + "?err=1");
2050
+ }
2051
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".return.refund", outcome: "success", metadata: { id: id, provider: rmaCtx.canProviderRefund } });
2052
+ _redirect(res, "/admin/returns/" + enc + "?moved=1");
2053
+ },
1822
2054
  ));
1823
2055
 
1824
2056
  router.post("/admin/returns/:id/reject", _returnAction(
@@ -4389,6 +4621,7 @@ function renderAdminOrder(opts) {
4389
4621
  var o = opts.order;
4390
4622
  var transitions = opts.transitions || [];
4391
4623
  var moved = opts.moved ? "<div class=\"banner banner--ok\">Order updated.</div>" : "";
4624
+ var shipOk = opts.ship_done ? "<div class=\"banner banner--ok\">Shipment updated.</div>" : "";
4392
4625
  var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
4393
4626
 
4394
4627
  var lineRows = (o.lines || []).map(function (l) {
@@ -4444,6 +4677,72 @@ function renderAdminOrder(opts) {
4444
4677
  }).filter(Boolean).join(" ");
4445
4678
  var actions = actionForms || "<span class=\"meta\">This order is in a final state — no further changes.</span>";
4446
4679
 
4680
+ // Shipment + tracking panel. Renders only when the tracking primitive is
4681
+ // wired (`can_track`). For each existing shipment: the carrier, status,
4682
+ // tracking number (linked to the carrier's public URL when known), and
4683
+ // its carrier-event log, plus a single-select form to record the next
4684
+ // event. A bottom form attaches a NEW shipment (carrier + optional
4685
+ // tracking number). Carrier + status option lists come from the
4686
+ // primitive's frozen enums (deps.carriers / deps.statuses).
4687
+ var trackingPanel = "";
4688
+ if (opts.can_track) {
4689
+ var carriers = opts.carriers || [];
4690
+ var statuses = opts.statuses || [];
4691
+ var carrierOpts = carriers.map(function (c) {
4692
+ return "<option value=\"" + _htmlEscape(c) + "\">" + _htmlEscape(c) + "</option>";
4693
+ }).join("");
4694
+ var shipments = opts.shipments || [];
4695
+ var shipBlocks = shipments.map(function (s) {
4696
+ var carrierLabel = s.carrier === "other" ? (s.carrier_other_name || "other") : s.carrier;
4697
+ var trackingCell = s.tracking_number
4698
+ ? (s.tracking_url
4699
+ ? "<a class=\"order-id\" href=\"" + _htmlEscape(String(s.tracking_url)) + "\" rel=\"noopener nofollow\" target=\"_blank\">" + _htmlEscape(String(s.tracking_number)) + " ↗</a>"
4700
+ : "<code class=\"order-id\">" + _htmlEscape(String(s.tracking_number)) + "</code>")
4701
+ : "<span class=\"meta\">—</span>";
4702
+ var eventRows = (s.events || []).map(function (e) {
4703
+ return "<tr><td>" + _htmlEscape(String(e.status)) + "</td>" +
4704
+ "<td>" + (e.location ? _htmlEscape(String(e.location)) : "<span class=\"meta\">—</span>") + "</td>" +
4705
+ "<td>" + _htmlEscape(_fmtDate(e.occurred_at)) + "</td></tr>";
4706
+ }).join("");
4707
+ var eventsTable = (s.events && s.events.length)
4708
+ ? "<table><thead><tr><th scope=\"col\">Status</th><th scope=\"col\">Location</th><th scope=\"col\">When</th></tr></thead><tbody>" + eventRows + "</tbody></table>"
4709
+ : "<p class=\"empty\">No carrier events yet.</p>";
4710
+ var statusOpts = statuses.map(function (st) {
4711
+ return "<option value=\"" + _htmlEscape(st) + "\">" + _htmlEscape(st) + "</option>";
4712
+ }).join("");
4713
+ var eventForm =
4714
+ "<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(o.id) + "/shipments/" + _htmlEscape(s.id) + "/events\" class=\"return-action\">" +
4715
+ "<h4>Record event</h4>" +
4716
+ "<label class=\"form-field\"><span>Status</span><select name=\"status\" required>" + statusOpts + "</select></label>" +
4717
+ _setupField("Location", "location", "", "text", "Carrier-reported, optional.", " maxlength=\"128\"") +
4718
+ _setupField("Detail", "detail", "", "text", "Optional note.", " maxlength=\"512\"") +
4719
+ "<button class=\"btn\" type=\"submit\">Record event</button>" +
4720
+ "</form>";
4721
+ return "<div class=\"panel mt\">" +
4722
+ "<div class=\"order-shipment-head\">" +
4723
+ "<strong>" + _htmlEscape(String(carrierLabel)) + "</strong> " +
4724
+ "<span class=\"status-pill " + _htmlEscape(s.status) + "\">" + _htmlEscape(s.status) + "</span>" +
4725
+ "</div>" +
4726
+ "<p class=\"meta\">Tracking: " + trackingCell + "</p>" +
4727
+ eventsTable +
4728
+ eventForm +
4729
+ "</div>";
4730
+ }).join("");
4731
+ var addShipmentForm =
4732
+ "<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(o.id) + "/shipments\" class=\"return-action\">" +
4733
+ "<h4>Add a shipment</h4>" +
4734
+ "<label class=\"form-field\"><span>Carrier</span><select name=\"carrier\" required>" + carrierOpts + "</select></label>" +
4735
+ _setupField("Carrier name (if “other”)", "carrier_other_name", "", "text", "Required only when carrier is “other”.", " maxlength=\"64\"") +
4736
+ _setupField("Tracking number", "tracking_number", "", "text", "Optional — links to the carrier's tracking page.", " maxlength=\"64\"") +
4737
+ "<button class=\"btn\" type=\"submit\">Add shipment</button>" +
4738
+ "</form>";
4739
+ trackingPanel =
4740
+ "<div class=\"panel mt\"><h3 class=\"subhead\">Shipments &amp; tracking</h3>" +
4741
+ (shipments.length ? shipBlocks : "<p class=\"empty\">No shipments yet.</p>") +
4742
+ addShipmentForm +
4743
+ "</div>";
4744
+ }
4745
+
4447
4746
  var body =
4448
4747
  "<section class=\"mw-48\">" +
4449
4748
  "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/orders\">&larr; Orders</a></div>" +
@@ -4451,7 +4750,7 @@ function renderAdminOrder(opts) {
4451
4750
  "<span class=\"status-pill " + _htmlEscape(o.status) + "\">" + _htmlEscape(o.status) + "</span></h2>" +
4452
4751
  "<p class=\"meta\">Placed " + _htmlEscape(_fmtDate(o.created_at)) + " · last updated " + _htmlEscape(_fmtDate(o.updated_at)) +
4453
4752
  (o.payment_intent_id ? " · payment <code class=\"order-id\">" + _htmlEscape(o.payment_intent_id) + "</code>" : "") + "</p>" +
4454
- moved + notice +
4753
+ moved + shipOk + notice +
4455
4754
  "<div class=\"two-col\">" +
4456
4755
  "<div class=\"panel\"><h3 class=\"subhead\">Items</h3>" + linesTable + "</div>" +
4457
4756
  "<div class=\"panel\"><h3 class=\"subhead\">Ship to</h3>" +
@@ -4462,6 +4761,7 @@ function renderAdminOrder(opts) {
4462
4761
  "<div class=\"panel mt\"><h3 class=\"subhead\">Actions</h3>" +
4463
4762
  "<div class=\"order-actions\">" + actions + "</div>" +
4464
4763
  "</div>" +
4764
+ trackingPanel +
4465
4765
  "</section>";
4466
4766
  return _renderAdminShell(opts.shop_name, "Order " + o.id.slice(0, 8), body, "orders", opts.nav_available);
4467
4767
  }
@@ -4612,12 +4912,29 @@ function renderAdminReturn(opts) {
4612
4912
  "</form>");
4613
4913
  }
4614
4914
  if (has("refund")) {
4615
- actionBlocks.push(
4616
- "<form method=\"post\" action=\"/admin/returns/" + _htmlEscape(r.id) + "/refund\" class=\"return-action\">" +
4617
- "<h4>Refund</h4><p class=\"meta\">Record the refund" + (refundShown ? " of " + _htmlEscape(refundShown) : "") + " for this return.</p>" +
4618
- _setupField("Operator notes", "operator_notes", "", "text", "", " maxlength=\"500\"") +
4619
- "<button class=\"btn\" type=\"submit\">Refund</button>" +
4620
- "</form>");
4915
+ if (opts.can_provider_refund) {
4916
+ // A captured payment intent is linked the Refund button moves money
4917
+ // through the provider. It POSTs to the confirm interstitial (states
4918
+ // the amount + warns it's irreversible), whose Confirm button issues
4919
+ // the provider refund THEN records the RMA refund.
4920
+ actionBlocks.push(
4921
+ "<form method=\"post\" action=\"/admin/returns/" + _htmlEscape(r.id) + "/refund/confirm\" class=\"return-action\">" +
4922
+ "<h4>Refund</h4><p class=\"meta\">Issues the refund" + (refundShown ? " of " + _htmlEscape(refundShown) : "") + " through the payment provider, then marks this return refunded.</p>" +
4923
+ "<button class=\"btn btn--danger\" type=\"submit\">Refund through provider</button>" +
4924
+ "</form>");
4925
+ } else {
4926
+ // No captured intent / no provider wired — the action records the RMA
4927
+ // refund only. The money side is issued separately (manual provider
4928
+ // refund, or the order page when the order carries an intent), made
4929
+ // explicit so the operator never assumes this moved money.
4930
+ actionBlocks.push(
4931
+ "<form method=\"post\" action=\"/admin/returns/" + _htmlEscape(r.id) + "/refund\" class=\"return-action\">" +
4932
+ "<h4>Refund</h4><p class=\"meta\">Record-only: marks the refund" + (refundShown ? " of " + _htmlEscape(refundShown) : "") + " as issued. No money moves from here — issue the refund manually or from the " +
4933
+ "<a href=\"/admin/orders/" + _htmlEscape(String(r.order_id)) + "\">linked order</a>.</p>" +
4934
+ _setupField("Operator notes", "operator_notes", "", "text", "", " maxlength=\"500\"") +
4935
+ "<button class=\"btn\" type=\"submit\">Mark refunded (record only)</button>" +
4936
+ "</form>");
4937
+ }
4621
4938
  }
4622
4939
  if (has("reject")) {
4623
4940
  actionBlocks.push(
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.2.20",
2
+ "version": "0.2.22",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
package/lib/storefront.js CHANGED
@@ -725,6 +725,22 @@ function _buildProductCard(p) {
725
725
  });
726
726
  }
727
727
 
728
+ // PDP "You may also like" rail. `related` is the pre-decorated card
729
+ // list [{ slug, title, price, image_url, image_alt }] the PDP renderer
730
+ // builds from the same-collection picks. Reuses the catalog grid +
731
+ // product-card markup so it inherits the storefront's card styling.
732
+ // Returns "" when there's nothing to show so the PDP renders no empty
733
+ // rail. Mirrored byte-for-byte by worker/render/product.js#_buildRelatedProducts.
734
+ function _buildRelatedProducts(related) {
735
+ related = related || [];
736
+ if (related.length === 0) return "";
737
+ var cards = related.map(function (p) { return _buildProductCard(p); }).join("");
738
+ return "<section class=\"catalog-section pdp-recommendations\" aria-labelledby=\"pdp-related-title\">" +
739
+ "<header class=\"section-head\"><h2 id=\"pdp-related-title\" class=\"section-head__title\">You may also like</h2></header>" +
740
+ "<div class=\"grid\">" + cards + "</div>" +
741
+ "</section>";
742
+ }
743
+
728
744
  var HOME_HERO =
729
745
  "<section class=\"hero hero--dark\">\n" +
730
746
  " <div class=\"hero__bg\" aria-hidden=\"true\">\n" +
@@ -1535,7 +1551,8 @@ var PRODUCT_PAGE =
1535
1551
  " RAW_BUNDLES_PLACEHOLDER\n" +
1536
1552
  " RAW_REVIEWS_PLACEHOLDER\n" +
1537
1553
  " RAW_QA_PLACEHOLDER\n" +
1538
- "</section>\n";
1554
+ "</section>\n" +
1555
+ "RAW_RELATED_PLACEHOLDER";
1539
1556
 
1540
1557
  // PDP gallery markup — composed once per render call from the
1541
1558
  // product's media rows. When media is present, the first row drives
@@ -2796,7 +2813,7 @@ function renderReturns(opts) {
2796
2813
  : "<div class=\"account-empty\">" +
2797
2814
  "<p class=\"account-empty__icon\" aria-hidden=\"true\"><svg class=\"empty-illu\" viewBox=\"0 0 200 132\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M66 64 L100 50 L134 64 L100 78 Z\"/><path d=\"M66 64 V92 L100 106 L134 92 V64 M100 78 V106\"/><path d=\"M100 36 A20 20 0 0 1 120 56\" stroke=\"#732A8D\" stroke-width=\"2.4\"/><path d=\"M100 28 L100 40 L110 38\" stroke=\"#732A8D\" stroke-width=\"2.4\"/><path d=\"M88 70 H112\" stroke=\"currentColor\" stroke-opacity=\"0.45\" stroke-width=\"1.8\" stroke-dasharray=\"2 4\"/></svg></p>" +
2798
2815
  "<p class=\"account-empty__lede\">No returns yet. Start one from an order in your account.</p>" +
2799
- "<a class=\"btn-secondary\" href=\"/account/orders\">View your orders →</a>" +
2816
+ "<a class=\"btn-secondary\" href=\"/account/orders\">View your orders &rarr;</a>" +
2800
2817
  "</div>";
2801
2818
  // Success confirmation after submitting a return request. The RMA code
2802
2819
  // round-trips on the ?ok=<code> redirect so the notice can echo it back
@@ -3565,6 +3582,27 @@ function renderProduct(opts) {
3565
3582
  var vTitle = v.title || (Object.keys(v.options || {}).map(function (k) { return v.options[k]; }).join(" / ") || "Default");
3566
3583
  return { id: v.id, sku: v.sku, title: vTitle, price: priceStr };
3567
3584
  });
3585
+ // "You may also like" rail — same-collection picks the route decorated
3586
+ // with each card's hero media + first-variant price (minor units). The
3587
+ // price string is formatted here with the page's own `fmt` so it tracks
3588
+ // the active currency context exactly as the buy-box prices do. Mirrors
3589
+ // the edge renderer (`worker/render/product.js`) so the section is
3590
+ // byte-identical across substrates. Built once, shared by the theme
3591
+ // branch's raw slot and the inline-template branch's placeholder.
3592
+ var relatedAssetPrefix = opts.asset_prefix || "/assets/";
3593
+ var relatedCards = (opts.related || []).map(function (r) {
3594
+ var priceStr = Number.isInteger(r.price_minor)
3595
+ ? fmt(r.price_minor, r.price_currency || "USD")
3596
+ : "—";
3597
+ return {
3598
+ slug: r.slug,
3599
+ title: r.title,
3600
+ price: priceStr,
3601
+ image_url: r.hero_r2_key ? (relatedAssetPrefix + r.hero_r2_key) : null,
3602
+ image_alt: r.hero_r2_key ? (r.hero_alt_text || r.title) : null,
3603
+ };
3604
+ });
3605
+ var relatedHtml = _buildRelatedProducts(relatedCards);
3568
3606
  if (opts.theme) {
3569
3607
  return opts.theme.render("product", {
3570
3608
  title: opts.product.title,
@@ -3582,6 +3620,10 @@ function renderProduct(opts) {
3582
3620
  qty_breaks_html: _buildQtyBreaks(opts.qty_breaks),
3583
3621
  wishlist_html: _buildWishlist(opts.product.id, opts.wishlist_count),
3584
3622
  compare_html: _buildCompare(opts.product.id),
3623
+ // Pre-rendered "You may also like" rail for the theme's
3624
+ // `{{{ related_html }}}` raw slot. Empty string when the product
3625
+ // has no same-collection siblings to show.
3626
+ related_html: relatedHtml,
3585
3627
  asset_css_main: opts.theme.assetUrl("css/main.css"),
3586
3628
  });
3587
3629
  }
@@ -3614,7 +3656,8 @@ function renderProduct(opts) {
3614
3656
  .replace("RAW_COMPARE_PLACEHOLDER", compareHtml)
3615
3657
  .replace("RAW_BUNDLES_PLACEHOLDER", bundlesHtml)
3616
3658
  .replace("RAW_REVIEWS_PLACEHOLDER", reviewsHtml)
3617
- .replace("RAW_QA_PLACEHOLDER", qaHtml);
3659
+ .replace("RAW_QA_PLACEHOLDER", qaHtml)
3660
+ .replace("RAW_RELATED_PLACEHOLDER", relatedHtml);
3618
3661
  // Product-specific OpenGraph + Twitter Card values so shares
3619
3662
  // unfurl as "Operator Tee — blamejs.shop" with the SVG hero, not
3620
3663
  // the default shop-level description + brand logo.
@@ -4136,6 +4179,7 @@ var ORDER_PAGE =
4136
4179
  " <h1 class=\"section-head__title\">Order <code class=\"inline-code\">{{order_id}}</code></h1>\n" +
4137
4180
  " <p class=\"section-head__lede\">Status: <span class=\"pdp__badge pdp__badge--ok\"><span class=\"dot dot--live\" aria-hidden=\"true\"></span> {{status}}</span></p>\n" +
4138
4181
  " </header>\n" +
4182
+ " RAW_REORDER_NOTICE" +
4139
4183
  " <div class=\"order-page__grid\">\n" +
4140
4184
  " <div class=\"order-page__items\">\n" +
4141
4185
  " <h2 class=\"pdp__variants-title\">Items</h2>\n" +
@@ -4145,8 +4189,11 @@ var ORDER_PAGE =
4145
4189
  " <tbody>{{line_rows}}</tbody>\n" +
4146
4190
  " </table>\n" +
4147
4191
  " </div>\n" +
4192
+ " RAW_ORDER_ACTIONS" +
4148
4193
  " </div>\n" +
4149
4194
  " <aside class=\"order-page__totals\">\n" +
4195
+ " RAW_ORDER_TIMELINE" +
4196
+ " RAW_ORDER_TRACKING" +
4150
4197
  " <h2 class=\"pdp__variants-title\">Totals</h2>\n" +
4151
4198
  " <dl class=\"totals-list\">\n" +
4152
4199
  " <div><dt>Subtotal</dt><dd>{{subtotal}}</dd></div>\n" +
@@ -4179,6 +4226,142 @@ function _shipToAddressBlock(s) {
4179
4226
  "</div>";
4180
4227
  }
4181
4228
 
4229
+ // The customer-facing order lifecycle, in display order. Mirrors the
4230
+ // happy-path edges of the order FSM (pending → paid → fulfilling →
4231
+ // shipped → delivered); the terminal off-ramps (refunded / cancelled)
4232
+ // are surfaced as a distinct final step rather than a position on the
4233
+ // rail. Kept as a small ordered list so the timeline highlights every
4234
+ // step up to and including the current status.
4235
+ var ORDER_TIMELINE_STEPS = [
4236
+ { status: "pending", label: "Order placed" },
4237
+ { status: "paid", label: "Payment confirmed" },
4238
+ { status: "fulfilling", label: "Preparing your order" },
4239
+ { status: "shipped", label: "Shipped" },
4240
+ { status: "delivered", label: "Delivered" },
4241
+ ];
4242
+
4243
+ // A paid (or further-along) order is the customer's window to start a
4244
+ // return; pending (unpaid) and the terminal off-ramps (cancelled /
4245
+ // refunded) are not. Mirrors the operator-facing returns policy: you
4246
+ // can't return what you haven't paid for or what's already refunded.
4247
+ function _orderEligibleForReturn(status) {
4248
+ return status === "paid" || status === "fulfilling" ||
4249
+ status === "shipped" || status === "delivered";
4250
+ }
4251
+
4252
+ // Reorder is offered for any order that represents a real purchase the
4253
+ // customer might want to repeat — paid through delivered, plus the
4254
+ // terminal refunded/cancelled states (a cancelled or refunded order is a
4255
+ // perfectly good "buy this again" candidate). Only a still-pending
4256
+ // (never-paid) order is excluded, since its cart was never charged.
4257
+ function _orderEligibleForReorder(status) {
4258
+ return status !== "pending";
4259
+ }
4260
+
4261
+ // Render the lifecycle timeline. Every step up to and including the
4262
+ // current status is marked done; the current step is also marked
4263
+ // current. A terminal off-ramp (refunded / cancelled) collapses the rail
4264
+ // to a single explanatory step so we never imply a cancelled order is
4265
+ // "delivered". All labels are static framework copy (no operator input),
4266
+ // but escaped at the sink for consistency with the rest of the file.
4267
+ function _orderTimelineBlock(status) {
4268
+ var esc = b.template.escapeHtml;
4269
+ if (status === "cancelled" || status === "refunded") {
4270
+ var finalLabel = status === "cancelled" ? "Order cancelled" : "Order refunded";
4271
+ return "<div class=\"order-timeline order-timeline--terminal\">" +
4272
+ "<h2 class=\"pdp__variants-title\">Status</h2>" +
4273
+ "<ol class=\"order-timeline__steps\">" +
4274
+ "<li class=\"order-timeline__step is-current is-terminal\">" +
4275
+ "<span class=\"order-timeline__dot\" aria-hidden=\"true\"></span>" +
4276
+ "<span class=\"order-timeline__label\">" + esc(finalLabel) + "</span>" +
4277
+ "</li>" +
4278
+ "</ol></div>";
4279
+ }
4280
+ var currentIdx = -1;
4281
+ for (var i = 0; i < ORDER_TIMELINE_STEPS.length; i += 1) {
4282
+ if (ORDER_TIMELINE_STEPS[i].status === status) { currentIdx = i; break; }
4283
+ }
4284
+ var steps = ORDER_TIMELINE_STEPS.map(function (step, idx) {
4285
+ var done = currentIdx >= 0 && idx <= currentIdx;
4286
+ var current = idx === currentIdx;
4287
+ var cls = "order-timeline__step" +
4288
+ (done ? " is-done" : "") + (current ? " is-current" : "");
4289
+ return "<li class=\"" + cls + "\">" +
4290
+ "<span class=\"order-timeline__dot\" aria-hidden=\"true\"></span>" +
4291
+ "<span class=\"order-timeline__label\">" + esc(step.label) + "</span>" +
4292
+ "</li>";
4293
+ }).join("");
4294
+ return "<div class=\"order-timeline\">" +
4295
+ "<h2 class=\"pdp__variants-title\">Status</h2>" +
4296
+ "<ol class=\"order-timeline__steps\">" + steps + "</ol></div>";
4297
+ }
4298
+
4299
+ // Render the shipment + carrier-tracking panel from order-tracking's
4300
+ // listForOrder() rows. Each shipment shows its carrier, status, the
4301
+ // tracking number (linked to the carrier's public tracking URL when one
4302
+ // is known), and the most-recent carrier event. Empty/absent shipments
4303
+ // render nothing so a digital or not-yet-shipped order shows no panel.
4304
+ function _orderTrackingBlock(shipments) {
4305
+ if (!Array.isArray(shipments) || !shipments.length) return "";
4306
+ var esc = b.template.escapeHtml;
4307
+ var cards = shipments.map(function (s) {
4308
+ var carrier = s.carrier === "other"
4309
+ ? (s.carrier_other_name || "Carrier")
4310
+ : s.carrier;
4311
+ var trackingHtml = "";
4312
+ if (s.tracking_number) {
4313
+ trackingHtml = s.tracking_url
4314
+ ? "<a class=\"order-shipment__track\" href=\"" + esc(String(s.tracking_url)) +
4315
+ "\" rel=\"noopener nofollow\" target=\"_blank\">" + esc(String(s.tracking_number)) + " ↗</a>"
4316
+ : "<span class=\"order-shipment__track\">" + esc(String(s.tracking_number)) + "</span>";
4317
+ }
4318
+ // Latest carrier event (events arrive oldest-first from listForOrder's
4319
+ // getShipment ordering; the panel's per-shipment events array, when
4320
+ // hydrated, is the same order — take the last).
4321
+ var events = Array.isArray(s.events) ? s.events : [];
4322
+ var latest = events.length ? events[events.length - 1] : null;
4323
+ var latestHtml = latest
4324
+ ? "<p class=\"order-shipment__event\">" +
4325
+ esc(String(latest.status)) +
4326
+ (latest.location ? " &middot; " + esc(String(latest.location)) : "") +
4327
+ "</p>"
4328
+ : "";
4329
+ return "<li class=\"order-shipment\">" +
4330
+ "<div class=\"order-shipment__head\">" +
4331
+ "<span class=\"order-shipment__carrier\">" + esc(String(carrier)) + "</span>" +
4332
+ "<span class=\"pdp__badge\">" + esc(String(s.status)) + "</span>" +
4333
+ "</div>" +
4334
+ (trackingHtml ? "<p class=\"order-shipment__tracking\">Tracking: " + trackingHtml + "</p>" : "") +
4335
+ latestHtml +
4336
+ "</li>";
4337
+ }).join("");
4338
+ return "<div class=\"order-tracking-panel\">" +
4339
+ "<h2 class=\"pdp__variants-title\">Tracking</h2>" +
4340
+ "<ul class=\"order-shipment-list\">" + cards + "</ul></div>";
4341
+ }
4342
+
4343
+ // Request-a-return + Reorder affordances for an order, gated on its
4344
+ // status. Reorder is a POST (it mutates the cart) carrying the order id
4345
+ // in the path; Request-a-return links to the existing return form. The
4346
+ // same builder feeds the order page and (via _orderRowActions) the
4347
+ // dashboard rows so the eligibility rules live in one place.
4348
+ function _orderActionsBlock(o) {
4349
+ var esc = b.template.escapeHtml;
4350
+ var btns = [];
4351
+ if (_orderEligibleForReorder(o.status)) {
4352
+ btns.push(
4353
+ "<form class=\"order-action\" method=\"post\" action=\"/orders/" + esc(String(o.id)) + "/reorder\">" +
4354
+ "<button type=\"submit\" class=\"btn-secondary\">Reorder</button>" +
4355
+ "</form>");
4356
+ }
4357
+ if (_orderEligibleForReturn(o.status)) {
4358
+ btns.push(
4359
+ "<a class=\"btn-secondary order-action\" href=\"/account/orders/" + esc(String(o.id)) + "/return\">Request a return</a>");
4360
+ }
4361
+ if (!btns.length) return "";
4362
+ return "<div class=\"order-page__actions\">" + btns.join("") + "</div>";
4363
+ }
4364
+
4182
4365
  function renderOrder(opts) {
4183
4366
  if (!opts || !opts.order) throw new TypeError("storefront.renderOrder: opts.order required");
4184
4367
  var o = opts.order;
@@ -4212,6 +4395,14 @@ function renderOrder(opts) {
4212
4395
  var shipping = pricing.format(o.shipping_minor, o.currency);
4213
4396
  var total = pricing.format(o.grand_total_minor, o.currency);
4214
4397
  var recs = opts.recommendations || [];
4398
+ // Post-handoff shipment + carrier tracking (order-tracking.listForOrder
4399
+ // rows the route bundles in; absent / empty → no panel). The lifecycle
4400
+ // timeline + the Request-a-return / Reorder affordances are derived from
4401
+ // the order status alone, so they render even without tracking wired.
4402
+ var shipments = opts.shipments || [];
4403
+ var timelineHtml = _orderTimelineBlock(o.status);
4404
+ var trackingHtml = _orderTrackingBlock(shipments);
4405
+ var actionsHtml = _orderActionsBlock(o);
4215
4406
  if (opts.theme) {
4216
4407
  return opts.theme.render("order", {
4217
4408
  title: "Order " + o.id,
@@ -4226,6 +4417,11 @@ function renderOrder(opts) {
4226
4417
  shipping: shipping,
4227
4418
  total: total,
4228
4419
  ship_to: o.ship_to || null,
4420
+ timeline_html: timelineHtml,
4421
+ tracking_html: trackingHtml,
4422
+ actions_html: actionsHtml,
4423
+ can_return: _orderEligibleForReturn(o.status),
4424
+ can_reorder: _orderEligibleForReorder(o.status),
4229
4425
  recommendations: recs,
4230
4426
  has_recommendations: recs.length > 0,
4231
4427
  asset_css_main: opts.theme.assetUrl("css/main.css"),
@@ -4246,6 +4442,12 @@ function renderOrder(opts) {
4246
4442
  }).replace("RAW_ORDER_LINE_THUMB", thumb);
4247
4443
  }).join("");
4248
4444
  if (!rows) rows = "<tr><td colspan=\"4\" class=\"empty\">No items.</td></tr>";
4445
+ // Confirmation banner after a successful reorder (the POST redirects to
4446
+ // ?reordered=1). Static copy, no untrusted input — but the cart link
4447
+ // gives the customer a one-click path to the rebuilt cart.
4448
+ var reorderNotice = opts.reordered
4449
+ ? "<p class=\"form-notice form-notice--ok\" role=\"status\">Items from this order were added to your cart. <a href=\"/cart\">View cart →</a></p>"
4450
+ : "";
4249
4451
  var body = _render(ORDER_PAGE, {
4250
4452
  order_id: o.id,
4251
4453
  status: o.status,
@@ -4254,7 +4456,12 @@ function renderOrder(opts) {
4254
4456
  tax: tax,
4255
4457
  shipping: shipping,
4256
4458
  total: total,
4257
- }).replace("RAW_LINES", rows).replace("RAW_SHIP_TO", _shipToAddressBlock(o.ship_to));
4459
+ }).replace("RAW_LINES", rows)
4460
+ .replace("RAW_REORDER_NOTICE", reorderNotice)
4461
+ .replace("RAW_ORDER_TIMELINE", timelineHtml)
4462
+ .replace("RAW_ORDER_TRACKING", trackingHtml)
4463
+ .replace("RAW_ORDER_ACTIONS", actionsHtml)
4464
+ .replace("RAW_SHIP_TO", _shipToAddressBlock(o.ship_to));
4258
4465
  // Post-purchase cross-sell rail — reuses the catalog grid + product-card
4259
4466
  // markup (so it inherits the storefront's card styling), rendered only
4260
4467
  // when the picker returned something.
@@ -5143,10 +5350,11 @@ var ACCOUNT_DASH_PAGE =
5143
5350
  " <h2 class=\"pdp__variants-title\">Recent orders</h2>\n" +
5144
5351
  " <div class=\"table-scroll\">\n" +
5145
5352
  " <table class=\"account-orders-table\">\n" +
5146
- " <thead><tr><th>Order</th><th>Items</th><th>Status</th><th>Total</th></tr></thead>\n" +
5353
+ " <thead><tr><th>Order</th><th>Items</th><th>Status</th><th>Total</th><th>Actions</th></tr></thead>\n" +
5147
5354
  " <tbody>{{order_rows}}</tbody>\n" +
5148
5355
  " </table>\n" +
5149
5356
  " </div>\n" +
5357
+ " <p class=\"account-dash__all-orders\"><a href=\"/account/orders\">View all orders &rarr;</a></p>\n" +
5150
5358
  " </div>\n" +
5151
5359
  "</section>\n";
5152
5360
 
@@ -5156,6 +5364,7 @@ var ACCOUNT_DASH_ORDER_ROW =
5156
5364
  " <td class=\"account-order__items\" data-label=\"Items\">RAW_ACCOUNT_ORDER_THUMBS</td>\n" +
5157
5365
  " <td data-label=\"Status\"><span class=\"pdp__badge {{status_class}}\">{{status}}</span></td>\n" +
5158
5366
  " <td class=\"price\" data-label=\"Total\">{{total}}</td>\n" +
5367
+ " <td data-label=\"Actions\">RAW_ACCOUNT_ORDER_ACTIONS</td>\n" +
5159
5368
  "</tr>\n";
5160
5369
 
5161
5370
  function renderAccount(opts) {
@@ -5208,15 +5417,19 @@ function renderAccount(opts) {
5208
5417
  }).join("");
5209
5418
  if (!thumbs) thumbs = "<span class=\"account-order__thumb account-order__thumb--empty\" aria-hidden=\"true\"></span>";
5210
5419
  var moreCount = products.length > 4 ? "<span class=\"account-order__more\">+" + (products.length - 4) + "</span>" : "";
5420
+ // Per-row Reorder / Request-a-return affordances (status-gated, shared
5421
+ // with the order page + order-history list). Localized to this cell —
5422
+ // it does not touch the dashboard's header action nav.
5211
5423
  return _render(ACCOUNT_DASH_ORDER_ROW, {
5212
5424
  order_id: o.id,
5213
5425
  order_id_short: o.id.slice(0, 8),
5214
5426
  status: o.status,
5215
5427
  status_class: _statusClass(o.status),
5216
5428
  total: pricing.format(o.grand_total_minor, o.currency),
5217
- }).replace("RAW_ACCOUNT_ORDER_THUMBS", thumbs + moreCount);
5429
+ }).replace("RAW_ACCOUNT_ORDER_THUMBS", thumbs + moreCount)
5430
+ .replace("RAW_ACCOUNT_ORDER_ACTIONS", _orderRowActions(o));
5218
5431
  }).join("");
5219
- if (!rows) rows = "<tr><td colspan=\"4\" class=\"empty\">No orders yet. Browse the shop and your first order shows up here.</td></tr>";
5432
+ if (!rows) rows = "<tr><td colspan=\"5\" class=\"empty\">No orders yet. Browse the shop and your first order shows up here.</td></tr>";
5220
5433
  var body = _render(ACCOUNT_DASH_PAGE, {
5221
5434
  display_name: opts.customer.display_name,
5222
5435
  order_count: String(orders.length),
@@ -5428,6 +5641,87 @@ function renderProfile(opts) {
5428
5641
  });
5429
5642
  }
5430
5643
 
5644
+ // Compact per-row Reorder / Request-a-return affordances for an order
5645
+ // table row (dashboard + order-history list). Shares the eligibility
5646
+ // rules with the order page's _orderActionsBlock so a status that offers
5647
+ // a return on the order page offers it in the row too. Reorder is a POST
5648
+ // (mutates the cart); Request-a-return is a link to the form.
5649
+ function _orderRowActions(o) {
5650
+ var esc = b.template.escapeHtml;
5651
+ var acts = [];
5652
+ if (_orderEligibleForReorder(o.status)) {
5653
+ acts.push(
5654
+ "<form method=\"post\" action=\"/orders/" + esc(String(o.id)) + "/reorder\" class=\"order-row-action\">" +
5655
+ "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Reorder</button>" +
5656
+ "</form>");
5657
+ }
5658
+ if (_orderEligibleForReturn(o.status)) {
5659
+ acts.push(
5660
+ "<a class=\"btn-ghost btn-ghost--sm order-row-action\" href=\"/account/orders/" + esc(String(o.id)) + "/return\">Return</a>");
5661
+ }
5662
+ if (!acts.length) return "<span class=\"meta\">—</span>";
5663
+ return "<div class=\"order-row-actions\">" + acts.join("") + "</div>";
5664
+ }
5665
+
5666
+ var ORDER_LIST_PAGE =
5667
+ "<section class=\"account-orders\">\n" +
5668
+ " <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>\n" +
5669
+ " <li><a href=\"/account\">Account</a></li>\n" +
5670
+ " <li aria-current=\"page\">Orders</li>\n" +
5671
+ " </ol></nav>\n" +
5672
+ " <h1 class=\"account-orders__title\">Your orders</h1>\n" +
5673
+ " <div class=\"table-scroll\">\n" +
5674
+ " <table class=\"account-orders-table\">\n" +
5675
+ " <thead><tr><th>Order</th><th>Placed</th><th>Status</th><th>Total</th><th>Actions</th></tr></thead>\n" +
5676
+ " <tbody>{{order_rows}}</tbody>\n" +
5677
+ " </table>\n" +
5678
+ " </div>\n" +
5679
+ " RAW_ORDER_LIST_PAGER" +
5680
+ "</section>\n";
5681
+
5682
+ // Full order-history list for the signed-in customer, backed by
5683
+ // order.listForCustomer (cursor-paginated). Each row links to the order
5684
+ // page and carries the per-row Reorder / Request-a-return affordances.
5685
+ // An opaque next-cursor (when the page filled) renders a "Load more"
5686
+ // link that threads ?cursor=. Empty history shows a designed empty state
5687
+ // rather than a bare table.
5688
+ function renderOrderList(opts) {
5689
+ opts = opts || {};
5690
+ var esc = b.template.escapeHtml;
5691
+ var orders = opts.orders || [];
5692
+ var rows = orders.map(function (o) {
5693
+ var placed = o.created_at ? new Date(Number(o.created_at)).toISOString().slice(0, 10) : "";
5694
+ var statusClass = (o.status === "completed" || o.status === "shipped" || o.status === "delivered")
5695
+ ? "pdp__badge--ok" : "";
5696
+ return "<tr>" +
5697
+ "<td data-label=\"Order\"><a class=\"account-order__id\" href=\"/orders/" + esc(String(o.id)) +
5698
+ "\"><code>" + esc(String(o.id).slice(0, 8)) + "</code></a></td>" +
5699
+ "<td data-label=\"Placed\">" + (placed ? "<time datetime=\"" + esc(placed) + "\">" + esc(placed) + "</time>" : "—") + "</td>" +
5700
+ "<td data-label=\"Status\"><span class=\"pdp__badge " + statusClass + "\">" + esc(String(o.status)) + "</span></td>" +
5701
+ "<td class=\"price\" data-label=\"Total\">" + esc(pricing.format(o.grand_total_minor, o.currency)) + "</td>" +
5702
+ "<td data-label=\"Actions\">" + _orderRowActions(o) + "</td>" +
5703
+ "</tr>";
5704
+ }).join("");
5705
+ var pager = "";
5706
+ if (!rows) {
5707
+ rows = "<tr><td colspan=\"5\" class=\"empty\">No orders yet. <a href=\"/\">Browse the shop →</a></td></tr>";
5708
+ } else if (opts.next_cursor) {
5709
+ pager = "<div class=\"account-orders__pager\">" +
5710
+ "<a class=\"btn-secondary\" href=\"/account/orders?cursor=" + esc(encodeURIComponent(String(opts.next_cursor))) +
5711
+ "\">Load more orders →</a></div>";
5712
+ }
5713
+ var body = _render(ORDER_LIST_PAGE, { order_rows: "RAW_ORDER_ROWS" })
5714
+ .replace("RAW_ORDER_ROWS", rows)
5715
+ .replace("RAW_ORDER_LIST_PAGER", pager);
5716
+ return _wrap({
5717
+ title: "Your orders",
5718
+ shop_name: opts.shop_name || "blamejs.shop",
5719
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
5720
+ theme_css: opts.theme_css,
5721
+ body: body,
5722
+ });
5723
+ }
5724
+
5431
5725
  // ---- customer survey page ----------------------------------------------
5432
5726
 
5433
5727
  // Render one survey question as a fieldset. Rating → a 0/1..max radio
@@ -6152,6 +6446,66 @@ function mount(router, deps) {
6152
6446
  };
6153
6447
  }
6154
6448
 
6449
+ // "You may also like" picks for a PDP. Deterministic same-collection
6450
+ // query so the edge (worker/data/catalog.js#listRelatedProducts) and
6451
+ // the container produce the SAME ordered list and the rail renders
6452
+ // byte-identically wherever the PDP is served: the source product's
6453
+ // primary collection (lowest membership position), then the other
6454
+ // active members of that collection ordered by membership position
6455
+ // then product id, self excluded, capped at `limit`. The signal-based
6456
+ // recommendations engine (co-purchase + RANDOM filler) is intentionally
6457
+ // NOT used here — its order isn't reproducible at the edge, which has
6458
+ // no engine handle and can't replay RANDOM(). Each pick is decorated
6459
+ // with its first variant's current USD price (minor units) + first
6460
+ // media row; the renderer formats the price string with the page's own
6461
+ // currency formatter so the rail tracks the active display currency.
6462
+ // Best-effort: a read failure (tables not migrated) returns [] so the
6463
+ // PDP renders without the rail rather than 500-ing.
6464
+ async function _relatedProductsFor(productId, limit) {
6465
+ var query = function (sql, params) { return b.externalDb.query(sql, params); };
6466
+ try {
6467
+ var primary = (await query(
6468
+ "SELECT collection_slug FROM collection_members WHERE product_id = ?1 " +
6469
+ "ORDER BY position ASC, id ASC LIMIT 1",
6470
+ [productId],
6471
+ )).rows[0];
6472
+ if (!primary) return [];
6473
+ var siblingRows = (await query(
6474
+ "SELECT cm.product_id AS pid FROM collection_members cm " +
6475
+ "JOIN products p ON p.id = cm.product_id " +
6476
+ "WHERE cm.collection_slug = ?1 AND cm.product_id != ?2 AND p.status = 'active' " +
6477
+ "ORDER BY cm.position ASC, cm.product_id ASC LIMIT ?3",
6478
+ [primary.collection_slug, productId, limit],
6479
+ )).rows;
6480
+ var out = [];
6481
+ for (var i = 0; i < siblingRows.length; i += 1) {
6482
+ var pid = siblingRows[i].pid;
6483
+ var prod = await deps.catalog.products.get(pid);
6484
+ if (!prod || prod.status !== "active") continue;
6485
+ var priceMinor = null;
6486
+ var priceCurrency = "USD";
6487
+ var variants = await deps.catalog.variants.listForProduct(pid);
6488
+ if (variants.length) {
6489
+ var pr = await deps.catalog.prices.current(variants[0].id, "USD");
6490
+ if (pr) { priceMinor = pr.amount_minor; priceCurrency = pr.currency; }
6491
+ }
6492
+ var media = await deps.catalog.media.listForProduct(pid);
6493
+ var hero = media.length ? media[0] : null;
6494
+ out.push({
6495
+ slug: prod.slug,
6496
+ title: prod.title,
6497
+ hero_r2_key: hero ? hero.r2_key : null,
6498
+ hero_alt_text: hero ? (hero.alt_text || prod.title) : null,
6499
+ price_minor: priceMinor,
6500
+ price_currency: priceCurrency,
6501
+ });
6502
+ }
6503
+ return out;
6504
+ } catch (_e) {
6505
+ return [];
6506
+ }
6507
+ }
6508
+
6155
6509
  // Resolve the cart for this request — read session_id from the
6156
6510
  // sealed cookie, create one (and the cart) if absent. Returns
6157
6511
  // the cart row OR null when the cart was just created (caller can
@@ -6660,6 +7014,10 @@ function mount(router, deps) {
6660
7014
  var qtyBreaks = firstVariant && firstPrice
6661
7015
  ? await _resolveQtyBreaks(firstVariant.sku, firstPrice.amount_minor, firstPrice.currency, ccyFmt)
6662
7016
  : [];
7017
+ // "You may also like" — same-collection picks (deterministic order,
7018
+ // mirrored at the edge). Best-effort inside the helper; an empty list
7019
+ // hides the rail.
7020
+ var related = await _relatedProductsFor(product.id, 4);
6663
7021
  var html = renderProduct(Object.assign({
6664
7022
  product: product,
6665
7023
  variants: variants,
@@ -6674,6 +7032,7 @@ function mount(router, deps) {
6674
7032
  bundle_offers: bundleOffers,
6675
7033
  qty_breaks: qtyBreaks,
6676
7034
  wishlist_count: wishlistCount,
7035
+ related: related,
6677
7036
  shop_name: shopName,
6678
7037
  cart_count: cartCount,
6679
7038
  theme: theme,
@@ -7454,10 +7813,30 @@ function mount(router, deps) {
7454
7813
  }
7455
7814
  } catch (_e) { recommendations = []; }
7456
7815
  }
7816
+ // Post-handoff shipment + carrier tracking. Best-effort: the
7817
+ // shipments table may not be migrated on every deploy, so a read
7818
+ // failure degrades to "no tracking panel" rather than 500-ing the
7819
+ // order page. getShipment hydrates each shipment's events, but
7820
+ // listForOrder doesn't — so fetch the full shipment per row to drive
7821
+ // the latest-event line. Bounded by the order's shipment count
7822
+ // (typically 1, a handful at most for split shipments).
7823
+ var shipments = [];
7824
+ if (deps.orderTracking) {
7825
+ try {
7826
+ var shipRows = await deps.orderTracking.listForOrder(o.id);
7827
+ for (var si = 0; si < shipRows.length; si += 1) {
7828
+ var full = await deps.orderTracking.getShipment(shipRows[si].id);
7829
+ shipments.push(full || shipRows[si]);
7830
+ }
7831
+ } catch (_e) { shipments = []; }
7832
+ }
7833
+ var ordUrl = req.url ? new URL(req.url, "http://localhost") : null;
7457
7834
  _send(res, 200, renderOrder({
7458
7835
  order: o,
7459
7836
  product_lookup: productLookup,
7460
7837
  recommendations: recommendations,
7838
+ shipments: shipments,
7839
+ reordered: ordUrl ? ordUrl.searchParams.get("reordered") === "1" : false,
7461
7840
  shop_name: shopName,
7462
7841
  theme: theme,
7463
7842
  }));
@@ -7927,6 +8306,48 @@ function mount(router, deps) {
7927
8306
  }));
7928
8307
  });
7929
8308
 
8309
+ // Full order history for the signed-in customer, cursor-paginated.
8310
+ // The dashboard shows only the most-recent ten; this is the complete
8311
+ // list the "View your orders" link (and the returns empty-state CTA)
8312
+ // point at. Each row links to the order page + carries the per-row
8313
+ // Reorder / Request-a-return affordances. Mounted only when an order
8314
+ // handle is wired.
8315
+ if (deps.order) {
8316
+ router.get("/account/orders", async function (req, res) {
8317
+ var ordersAuth;
8318
+ try { ordersAuth = _currentCustomer(req); }
8319
+ catch (e) {
8320
+ if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
8321
+ throw e;
8322
+ }
8323
+ if (!ordersAuth) {
8324
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
8325
+ return res.end ? res.end() : res.send("");
8326
+ }
8327
+ var listUrl = req.url ? new URL(req.url, "http://localhost") : null;
8328
+ var cursor = listUrl ? listUrl.searchParams.get("cursor") : null;
8329
+ var page;
8330
+ try {
8331
+ page = await deps.order.listForCustomer(ordersAuth.customer_id, {
8332
+ limit: 20,
8333
+ cursor: cursor || undefined,
8334
+ });
8335
+ } catch (e) {
8336
+ // A tampered / malformed cursor throws TypeError — restart the
8337
+ // list from the top rather than 500-ing.
8338
+ if (!(e instanceof TypeError)) throw e;
8339
+ page = await deps.order.listForCustomer(ordersAuth.customer_id, { limit: 20 });
8340
+ }
8341
+ var listCartCount = await _cartCountForReq(req);
8342
+ _send(res, 200, renderOrderList({
8343
+ orders: page.rows,
8344
+ next_cursor: page.next_cursor || null,
8345
+ shop_name: shopName,
8346
+ cart_count: listCartCount,
8347
+ }));
8348
+ });
8349
+ }
8350
+
7930
8351
  router.post("/account/logout", function (_req, res) {
7931
8352
  _clearAuthCookie(res);
7932
8353
  res.status(303); res.setHeader && res.setHeader("location", "/");
@@ -9684,6 +10105,60 @@ function mount(router, deps) {
9684
10105
  }
9685
10106
  }
9686
10107
 
10108
+ // POST /orders/:id/reorder — rebuild a cart from a past order's frozen
10109
+ // lines. Top-level (needs only cart + order, not checkout) so the Reorder
10110
+ // affordance on the dashboard / order-history list works even on a store
10111
+ // without a payment provider configured. Login is NOT required — a guest
10112
+ // order's capability URL is the auth, mirroring the order page's own
10113
+ // ownership gate: an order that BELONGS to a customer is reorderable only
10114
+ // by that signed-in customer; a guest order (no customer_id) is
10115
+ // reorderable by anyone holding its URL. Each line's CURRENT catalog
10116
+ // price applies (cart.addLine reprices from the catalog) — never the
10117
+ // frozen historical price. Variants that no longer exist / are unbuyable
10118
+ // are skipped silently so a partly-discontinued order still reorders
10119
+ // what's still available. Eligibility gates out a never-paid pending
10120
+ // order (no captured purchase to repeat).
10121
+ if (deps.order) {
10122
+ router.post("/orders/:order_id/reorder", async function (req, res) {
10123
+ var orderId = req.params && req.params.order_id;
10124
+ var o;
10125
+ try { o = orderId ? await deps.order.get(orderId) : null; }
10126
+ catch (e) { if (e instanceof TypeError) { o = null; } else throw e; }
10127
+ if (!o) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
10128
+ var reAuth = _currentCustomerEnv(req);
10129
+ if (o.customer_id && (!reAuth || o.customer_id !== reAuth.customer_id)) {
10130
+ return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
10131
+ }
10132
+ if (!_orderEligibleForReorder(o.status)) {
10133
+ // A still-pending (never-paid) order has no captured purchase to
10134
+ // repeat — bounce back to its page rather than rebuild a cart from
10135
+ // an unpaid order.
10136
+ res.status(303);
10137
+ res.setHeader && res.setHeader("location", "/orders/" + encodeURIComponent(o.id));
10138
+ return res.end ? res.end() : res.send("");
10139
+ }
10140
+ var resolved = await _getOrCreateCart(req, res, o.currency || "USD");
10141
+ var lines = o.lines || [];
10142
+ for (var rli = 0; rli < lines.length; rli += 1) {
10143
+ var line = lines[rli];
10144
+ var wantQty = Number.isInteger(line.qty) && line.qty >= 1 ? line.qty : 1;
10145
+ if (wantQty > 99) wantQty = 99;
10146
+ try {
10147
+ await deps.cart.addLine(resolved.cart.id, { variant_id: line.variant_id, qty: wantQty });
10148
+ } catch (_e) {
10149
+ // Skip a discontinued / unbuyable / out-of-stock variant rather
10150
+ // than fail the whole reorder — the customer still gets a cart of
10151
+ // everything that's still available.
10152
+ }
10153
+ }
10154
+ // Back to the order page with a confirmation banner + a cart link
10155
+ // (PRG so a refresh doesn't re-add the lines).
10156
+ res.status(303);
10157
+ res.setHeader && res.setHeader("location", "/orders/" + encodeURIComponent(o.id) + "?reordered=1");
10158
+ return res.end ? res.end() : res.send("");
10159
+ });
10160
+ }
10161
+
9687
10162
  // POST /cart/lines — add a line. Reads variant_id + qty from the
9688
10163
  // form body (b.middleware.bodyParser parses it into req.body).
9689
10164
  // CSRF token validation is the responsibility of the csrfProtect
@@ -10224,6 +10699,7 @@ module.exports = {
10224
10699
  renderGiftCardBalance: renderGiftCardBalance,
10225
10700
  renderPayPage: renderPayPage,
10226
10701
  renderOrder: renderOrder,
10702
+ renderOrderList: renderOrderList,
10227
10703
  renderAccountLogin: renderAccountLogin,
10228
10704
  renderAccountRegister: renderAccountRegister,
10229
10705
  renderAccount: renderAccount,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.2.20",
3
+ "version": "0.2.22",
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": {