@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 +4 -0
- package/README.md +1 -1
- package/lib/admin.js +332 -15
- package/lib/asset-manifest.json +1 -1
- package/lib/storefront.js +483 -7
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
2020
|
+
recorded = await returns.refund(req.params.id, { operator_notes: body.operator_notes });
|
|
1812
2021
|
} catch (e) {
|
|
1813
|
-
var
|
|
1814
|
-
if (
|
|
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,
|
|
1818
|
-
return
|
|
2026
|
+
_json(res, 200, recorded);
|
|
2027
|
+
return recorded;
|
|
1819
2028
|
}),
|
|
1820
|
-
|
|
1821
|
-
|
|
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 & 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\">← 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
|
-
|
|
4616
|
-
|
|
4617
|
-
|
|
4618
|
-
|
|
4619
|
-
|
|
4620
|
-
|
|
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(
|
package/lib/asset-manifest.json
CHANGED
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
|
|
2816
|
+
"<a class=\"btn-secondary\" href=\"/account/orders\">View your orders →</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 ? " · " + 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)
|
|
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 →</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=\"
|
|
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