@blamejs/blamejs-shop 0.3.34 → 0.3.36

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/lib/storefront.js CHANGED
@@ -2488,6 +2488,12 @@ function renderWishlist(opts) {
2488
2488
  var notice = okMsg
2489
2489
  ? "<p class=\"form-notice form-notice--ok\" role=\"status\">" + esc(okMsg) + "</p>"
2490
2490
  : "";
2491
+ // The owner's share panel — a "Share this wishlist" control plus the
2492
+ // list of active share links each with a Revoke action. The route
2493
+ // builds the panel HTML (it owns the share-link reads); absent it (the
2494
+ // sharing primitive isn't wired, or a unit test calling the renderer
2495
+ // directly) the panel is empty so the page renders unchanged.
2496
+ var sharePanel = opts.share_panel || "";
2491
2497
  var body =
2492
2498
  "<section class=\"account-wishlist\">" +
2493
2499
  "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
@@ -2496,6 +2502,7 @@ function renderWishlist(opts) {
2496
2502
  "</ol></nav>" +
2497
2503
  "<h1 class=\"account-wishlist__title\">Saved items</h1>" +
2498
2504
  notice +
2505
+ sharePanel +
2499
2506
  inner +
2500
2507
  "</section>";
2501
2508
  return _wrap({
@@ -2507,6 +2514,136 @@ function renderWishlist(opts) {
2507
2514
  });
2508
2515
  }
2509
2516
 
2517
+ // The owner-side wishlist share panel (mounts on /account/wishlist). It
2518
+ // renders a "Create a share link" control plus the owner's active share
2519
+ // links, each with a status + view count and a Revoke form. `opts.shares`
2520
+ // is the owner's links from `listSharesForOwner` (already scoped to the
2521
+ // session customer by the route). A revoked / expired link still appears
2522
+ // (greyed, no Revoke) so the owner sees the history; only an active link
2523
+ // carries a Revoke control.
2524
+ //
2525
+ // The plaintext share token is surfaced EXACTLY ONCE — at create time, via
2526
+ // `opts.fresh_url` (the full public URL of the just-minted link). The list
2527
+ // rows never carry the URL because the token isn't persisted; the owner
2528
+ // copies the link from this one-time confirmation. `opts.notice` surfaces a
2529
+ // created/revoked confirmation via role="status".
2530
+ var WISHLIST_SHARE_NOTICES = {
2531
+ created: "Share link created. Copy it below — it's shown only once.",
2532
+ revoked: "Share link revoked. The link no longer works.",
2533
+ };
2534
+ function _wishlistSharePanel(opts) {
2535
+ opts = opts || {};
2536
+ var esc = b.template.escapeHtml;
2537
+ var shares = opts.shares || [];
2538
+ var nowTs = Date.now();
2539
+ var rows = "";
2540
+ for (var i = 0; i < shares.length; i += 1) {
2541
+ var s = shares[i];
2542
+ var revoked = s.revoked_at != null;
2543
+ var expired = !revoked && s.expires_at != null && Number(s.expires_at) < nowTs;
2544
+ var inactive = revoked || expired;
2545
+ var statusLabel = revoked ? "Revoked" : (expired ? "Expired" : "Active");
2546
+ var statusCls = revoked ? "wishlist-share__status--revoked"
2547
+ : (expired ? "wishlist-share__status--expired" : "wishlist-share__status--active");
2548
+ var revokeForm = inactive
2549
+ ? ""
2550
+ : "<form class=\"wishlist-share__revoke\" method=\"post\" action=\"/wishlist/share/" + esc(s.id) + "/revoke\">" +
2551
+ "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Revoke</button>" +
2552
+ "</form>";
2553
+ var viewN = Number(s.view_count) || 0;
2554
+ rows +=
2555
+ "<li class=\"wishlist-share" + (inactive ? " wishlist-share--inactive" : "") + "\">" +
2556
+ "<div class=\"wishlist-share__head\">" +
2557
+ "<span class=\"wishlist-share__status " + statusCls + "\">" + esc(statusLabel) + "</span>" +
2558
+ "<span class=\"wishlist-share__views\">" + esc(String(viewN)) + " view" + (viewN === 1 ? "" : "s") + "</span>" +
2559
+ "</div>" +
2560
+ revokeForm +
2561
+ "</li>";
2562
+ }
2563
+ var list = rows ? "<ul class=\"wishlist-share__list\">" + rows + "</ul>" : "";
2564
+ var noticeMsg = WISHLIST_SHARE_NOTICES[opts.notice];
2565
+ var notice = noticeMsg
2566
+ ? "<p class=\"form-notice form-notice--ok\" role=\"status\">" + esc(noticeMsg) + "</p>"
2567
+ : "";
2568
+ // The one-time URL block — present only on the create confirmation. A
2569
+ // read-only text field the owner can copy; it never re-renders after the
2570
+ // owner navigates away (the token isn't persisted).
2571
+ var freshUrl = opts.fresh_url
2572
+ ? "<div class=\"wishlist-share__fresh\">" +
2573
+ "<label class=\"wishlist-share__fresh-label\" for=\"wishlist-share-url\">Your share link</label>" +
2574
+ "<input id=\"wishlist-share-url\" class=\"wishlist-share__url\" type=\"text\" readonly value=\"" + esc(opts.fresh_url) + "\" " +
2575
+ "aria-label=\"Shareable wishlist link\" onfocus=\"this.select()\">" +
2576
+ "</div>"
2577
+ : "";
2578
+ return "<section class=\"wishlist-share-panel\" aria-labelledby=\"wishlist-share-heading\">" +
2579
+ "<h2 id=\"wishlist-share-heading\" class=\"wishlist-share-panel__title\">Share this wishlist</h2>" +
2580
+ "<p class=\"wishlist-share-panel__lede\">Create a link so friends and family can see what you're hoping for. The link shows your saved products only — it never reveals your account.</p>" +
2581
+ notice +
2582
+ freshUrl +
2583
+ "<form class=\"wishlist-share-panel__create\" method=\"post\" action=\"/wishlist/share\">" +
2584
+ "<button type=\"submit\" class=\"btn-secondary\">Create a share link</button>" +
2585
+ "</form>" +
2586
+ list +
2587
+ "</section>";
2588
+ }
2589
+
2590
+ // Public, no-auth shared-wishlist page (`GET /wishlist/shared/:token`). It
2591
+ // renders ONLY the shared product cards (title, image, link to each PDP) —
2592
+ // the owner's identity, the per-entry private notes, and the owner customer
2593
+ // id are NEVER emitted, mirroring what the primitive's `viewShared` is
2594
+ // trusted to surface to a giver. `opts.entries` is the `viewShared` entry
2595
+ // list resolved to products; `opts.title` is the share's display title.
2596
+ // Carries a noindex robots directive — a personal shared wishlist is not a
2597
+ // page search engines should index. An unknown / revoked / expired token
2598
+ // never reaches this renderer (the route 404s first via renderNotFound).
2599
+ function renderSharedWishlist(opts) {
2600
+ opts = opts || {};
2601
+ var esc = b.template.escapeHtml;
2602
+ var items = opts.items || [];
2603
+ var prefix = opts.asset_prefix || "/assets/";
2604
+ var heading = (opts.title && String(opts.title).length) ? opts.title : "A shared wishlist";
2605
+ var rowsHtml = "";
2606
+ for (var i = 0; i < items.length; i += 1) {
2607
+ var it = items[i];
2608
+ if (!it.product) continue; // archived/deleted product → drop the card (no owner data to leak)
2609
+ var slug = esc(it.product.slug);
2610
+ var thumb = it.hero_media
2611
+ ? "<img src=\"" + esc(prefix + it.hero_media.r2_key) + "\" alt=\"" + esc(it.hero_media.alt_text || it.product.title) + "\" loading=\"lazy\">"
2612
+ : "<span class=\"wishlist-item__mark\" aria-hidden=\"true\">" + esc((it.product.title || "?").trim().charAt(0).toUpperCase() || "?") + "</span>";
2613
+ rowsHtml +=
2614
+ "<li class=\"wishlist-item\">" +
2615
+ "<a class=\"wishlist-item__media\" href=\"/products/" + slug + "\">" + thumb + "</a>" +
2616
+ "<div class=\"wishlist-item__body\">" +
2617
+ "<a class=\"wishlist-item__title\" href=\"/products/" + slug + "\">" + esc(it.product.title) + "</a>" +
2618
+ "<a class=\"wishlist-item__view card-link\" href=\"/products/" + slug + "\">View product →</a>" +
2619
+ "</div>" +
2620
+ "</li>";
2621
+ }
2622
+ var inner = rowsHtml
2623
+ ? "<ul class=\"wishlist-list\">" + rowsHtml + "</ul>"
2624
+ : "<div class=\"account-empty\">" +
2625
+ "<p class=\"account-empty__lede\">This wishlist is empty right now — nothing's been saved yet.</p>" +
2626
+ "<a class=\"btn-secondary\" href=\"/\">Browse the shop →</a>" +
2627
+ "</div>";
2628
+ var body =
2629
+ "<section class=\"account-wishlist\">" +
2630
+ "<p class=\"eyebrow\">Shared with you</p>" +
2631
+ "<h1 class=\"account-wishlist__title\">" + esc(heading) + "</h1>" +
2632
+ inner +
2633
+ "</section>";
2634
+ return _wrap({
2635
+ title: heading,
2636
+ shop_name: opts.shop_name || "blamejs.shop",
2637
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
2638
+ theme_css: opts.theme_css,
2639
+ // A personal shared wishlist is not search-index material — keep it
2640
+ // out of the index. `noindex` maps to noindex,nofollow in _wrap; the
2641
+ // page needs no canonical (a crawler won't index it to dedupe).
2642
+ robots: "noindex",
2643
+ body: body,
2644
+ });
2645
+ }
2646
+
2510
2647
  // Compare page notice banner — surfaced after a toggle / a full-basket
2511
2648
  // refusal. The route passes a short message key; unknown keys render
2512
2649
  // nothing so a forged `?notice=` query can't inject arbitrary copy.
@@ -3325,6 +3462,206 @@ function renderReturns(opts) {
3325
3462
  });
3326
3463
  }
3327
3464
 
3465
+ // Customer-facing reasons for an exchange. The values mirror the
3466
+ // order-exchanges module's REASONS allow-list; the backend validates the
3467
+ // submitted value against its own list, so a forged value is refused
3468
+ // there. (No-longer-needed isn't an exchange reason — that's a return.)
3469
+ var EXCHANGE_REASONS = [
3470
+ ["defective", "Defective / doesn't work"],
3471
+ ["wrong-item", "Wrong item received"],
3472
+ ["wrong-size", "Wrong size"],
3473
+ ["wrong-colour", "Wrong colour"],
3474
+ ["damaged-in-transit", "Damaged in transit"],
3475
+ ["not-as-described", "Not as described"],
3476
+ ["other", "Other"],
3477
+ ];
3478
+
3479
+ // Customer-facing phrasing for an exchange's FSM status. The module's
3480
+ // enum (pending / approved / shipped / delivered / received / closed /
3481
+ // rejected) is operator vocabulary; these read for the shopper.
3482
+ var EXCHANGE_STATUS_LABELS = {
3483
+ pending: "Requested",
3484
+ approved: "Approved",
3485
+ shipped: "Replacement shipped",
3486
+ delivered: "Replacement delivered",
3487
+ received: "Return received",
3488
+ closed: "Completed",
3489
+ rejected: "Declined",
3490
+ };
3491
+
3492
+ function _exchangeStatusBadge(status) {
3493
+ var esc = b.template.escapeHtml;
3494
+ var label = EXCHANGE_STATUS_LABELS[status] || status;
3495
+ return "<span class=\"return-status return-status--" + esc(String(status)) + "\">" +
3496
+ esc(String(label)) + "</span>";
3497
+ }
3498
+
3499
+ // Customer-facing exchange-request form for one owned order. `opts.lines`
3500
+ // is the order's order_lines, each decorated with `replacement_options`
3501
+ // (the sibling variants of that line's product the shopper can swap to).
3502
+ // `opts.notice` is an optional error bounced back from a failed POST.
3503
+ // Reuses the returns-form layout classes — no new CSS.
3504
+ function renderExchangeForm(opts) {
3505
+ var esc = b.template.escapeHtml;
3506
+ var order = opts.order;
3507
+ var lines = opts.lines || [];
3508
+ var lineRows = "";
3509
+ for (var i = 0; i < lines.length; i += 1) {
3510
+ var l = lines[i];
3511
+ var maxQty = Number(l.qty) || 1;
3512
+ var replOpts = (l.replacement_options || []).map(function (o) {
3513
+ return "<option value=\"" + esc(String(o.id)) + "\">" + esc(String(o.label)) + "</option>";
3514
+ }).join("");
3515
+ // A line with no resolvable sibling variants can't be exchanged for a
3516
+ // different option — offer a same-SKU swap (replace a defective unit)
3517
+ // so the line is never silently undisplayable.
3518
+ var replaceField = replOpts
3519
+ ? "<label class=\"return-line__qty\">Swap for " +
3520
+ "<select name=\"replacement_" + esc(l.id) + "\">" + replOpts + "</select>" +
3521
+ "</label>"
3522
+ : "<p class=\"return-line__of\">Same item, replacement unit.</p>";
3523
+ lineRows +=
3524
+ "<li class=\"return-line\">" +
3525
+ "<label class=\"return-line__pick\">" +
3526
+ "<input type=\"radio\" name=\"line_id\" value=\"" + esc(l.id) + "\"" + (i === 0 ? " checked" : "") + ">" +
3527
+ "<span class=\"return-line__sku\"><code>" + esc(l.sku) + "</code></span>" +
3528
+ "</label>" +
3529
+ "<label class=\"return-line__qty\">Qty to exchange " +
3530
+ "<input type=\"number\" name=\"qty_" + esc(l.id) + "\" value=\"1\" min=\"1\" max=\"" + maxQty + "\">" +
3531
+ " <span class=\"return-line__of\">of " + maxQty + "</span>" +
3532
+ "</label>" +
3533
+ replaceField +
3534
+ "</li>";
3535
+ }
3536
+ var reasonOpts = EXCHANGE_REASONS.map(function (r) {
3537
+ return "<option value=\"" + esc(r[0]) + "\">" + esc(r[1]) + "</option>";
3538
+ }).join("");
3539
+ var notice = opts.notice
3540
+ ? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
3541
+ : "";
3542
+ var body =
3543
+ "<section class=\"return-form-page\">" +
3544
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
3545
+ "<li><a href=\"/account\">Account</a></li>" +
3546
+ "<li><a href=\"/account/exchanges\">Exchanges</a></li>" +
3547
+ "<li aria-current=\"page\">Request an exchange</li>" +
3548
+ "</ol></nav>" +
3549
+ "<h1 class=\"return-form-page__title\">Request an exchange</h1>" +
3550
+ "<p class=\"return-form-page__order\">Order <code>" + esc(order.id) + "</code></p>" +
3551
+ notice +
3552
+ "<form class=\"return-form form-stack\" method=\"post\" action=\"/account/orders/" + esc(order.id) + "/exchange\">" +
3553
+ "<fieldset class=\"return-form__lines\"><legend>Which item?</legend>" +
3554
+ "<ul class=\"return-line-list\">" + lineRows + "</ul>" +
3555
+ "</fieldset>" +
3556
+ "<label class=\"form-field\"><span class=\"form-field__label\">Reason</span>" +
3557
+ "<select name=\"reason\" required>" + reasonOpts + "</select></label>" +
3558
+ "<button type=\"submit\" class=\"btn-primary\">Request exchange</button>" +
3559
+ "</form>" +
3560
+ "</section>";
3561
+ return _wrap({
3562
+ title: "Request an exchange",
3563
+ shop_name: opts.shop_name || "blamejs.shop",
3564
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
3565
+ theme_css: opts.theme_css,
3566
+ body: body,
3567
+ });
3568
+ }
3569
+
3570
+ // The signed-in customer's exchange list. `opts.exchanges` is the
3571
+ // ownership-scoped set (already filtered to this customer's orders); each
3572
+ // links to its status detail.
3573
+ function renderExchanges(opts) {
3574
+ var esc = b.template.escapeHtml;
3575
+ var exchanges = opts.exchanges || [];
3576
+ var rowsHtml = "";
3577
+ for (var i = 0; i < exchanges.length; i += 1) {
3578
+ var x = exchanges[i];
3579
+ var date = x.created_at ? new Date(Number(x.created_at)).toISOString().slice(0, 10) : "";
3580
+ rowsHtml +=
3581
+ "<li class=\"return-card\">" +
3582
+ "<div class=\"return-card__head\">" +
3583
+ "<a class=\"return-card__rma\" href=\"/account/exchanges/" + esc(String(x.id)) + "\"><code>" + esc(String(x.id).slice(0, 8)) + "</code></a>" +
3584
+ _exchangeStatusBadge(x.status) +
3585
+ "</div>" +
3586
+ "<p class=\"return-card__meta\">" +
3587
+ esc(String(x.return_sku || "")) + " &rarr; " + esc(String(x.replacement_sku || "")) +
3588
+ (date ? " &middot; <time datetime=\"" + esc(date) + "\">" + esc(date) + "</time>" : "") +
3589
+ "</p>" +
3590
+ (x.status === "rejected" && x.reject_reason ? "<p class=\"return-card__reject\">" + esc(String(x.reject_reason)) + "</p>" : "") +
3591
+ "</li>";
3592
+ }
3593
+ var inner = rowsHtml
3594
+ ? "<ul class=\"return-list\">" + rowsHtml + "</ul>"
3595
+ : "<div class=\"account-empty\">" +
3596
+ "<p class=\"account-empty__lede\">No exchanges yet. Start one from an order in your account.</p>" +
3597
+ "<a class=\"btn-secondary\" href=\"/account/orders\">View your orders &rarr;</a>" +
3598
+ "</div>";
3599
+ var notice = opts.requested
3600
+ ? "<p class=\"form-notice form-notice--ok\" role=\"status\">Exchange request received — we'll review it and email you next steps.</p>"
3601
+ : "";
3602
+ var body =
3603
+ "<section class=\"account-returns\">" +
3604
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
3605
+ "<li><a href=\"/account\">Account</a></li>" +
3606
+ "<li aria-current=\"page\">Exchanges</li>" +
3607
+ "</ol></nav>" +
3608
+ "<h1 class=\"account-returns__title\">Exchanges</h1>" +
3609
+ notice +
3610
+ inner +
3611
+ "</section>";
3612
+ return _wrap({
3613
+ title: "Exchanges",
3614
+ shop_name: opts.shop_name || "blamejs.shop",
3615
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
3616
+ theme_css: opts.theme_css,
3617
+ body: body,
3618
+ });
3619
+ }
3620
+
3621
+ // One exchange's status detail. Ownership-scoped (the route only renders
3622
+ // this for an exchange whose parent order belongs to the session
3623
+ // customer). Surfaces the swap, the reason, the live status, the
3624
+ // rejection reason (if declined), and the outbound tracking number once
3625
+ // the replacement has shipped.
3626
+ function renderExchangeDetail(opts) {
3627
+ var esc = b.template.escapeHtml;
3628
+ var x = opts.exchange;
3629
+ var date = x.created_at ? new Date(Number(x.created_at)).toISOString().slice(0, 10) : "";
3630
+ var rows = "";
3631
+ function _row(label, value) {
3632
+ return "<p class=\"return-card__meta\"><span class=\"form-field__label\">" + esc(label) + "</span> " +
3633
+ (value ? esc(String(value)) : "&mdash;") + "</p>";
3634
+ }
3635
+ rows += _row("Item returned", x.return_sku + " ×" + x.return_qty);
3636
+ rows += _row("Replacement", x.replacement_sku + " ×" + x.replacement_qty);
3637
+ rows += _row("Reason", x.reason);
3638
+ if (date) rows += _row("Requested", date);
3639
+ if (x.status === "rejected" && x.reject_reason) rows += _row("Why it was declined", x.reject_reason);
3640
+ if (x.tracking_number) {
3641
+ rows += _row("Replacement tracking", x.carrier ? (x.carrier + " · " + x.tracking_number) : x.tracking_number);
3642
+ }
3643
+ var body =
3644
+ "<section class=\"account-returns\">" +
3645
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
3646
+ "<li><a href=\"/account\">Account</a></li>" +
3647
+ "<li><a href=\"/account/exchanges\">Exchanges</a></li>" +
3648
+ "<li aria-current=\"page\">" + esc(String(x.id).slice(0, 8)) + "</li>" +
3649
+ "</ol></nav>" +
3650
+ "<div class=\"return-card__head\">" +
3651
+ "<h1 class=\"account-returns__title\">Exchange <code>" + esc(String(x.id).slice(0, 8)) + "</code></h1>" +
3652
+ _exchangeStatusBadge(x.status) +
3653
+ "</div>" +
3654
+ "<div class=\"return-card\">" + rows + "</div>" +
3655
+ "</section>";
3656
+ return _wrap({
3657
+ title: "Exchange " + String(x.id).slice(0, 8),
3658
+ shop_name: opts.shop_name || "blamejs.shop",
3659
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
3660
+ theme_css: opts.theme_css,
3661
+ body: body,
3662
+ });
3663
+ }
3664
+
3328
3665
  // Support ticket — the categories a shopper can pick when raising one,
3329
3666
  // and the human label for each. The values mirror the support-tickets
3330
3667
  // module's ALLOWED_CATEGORIES; the backend validates the submitted value
@@ -5118,6 +5455,17 @@ function _orderEligibleForReturn(status) {
5118
5455
  status === "shipped" || status === "delivered";
5119
5456
  }
5120
5457
 
5458
+ // An exchange (swap the item for a different size / colour / unit at the
5459
+ // same value) is offered on the same window as a return: a paid (or
5460
+ // further-along) order, never a still-pending (unpaid) one nor the
5461
+ // terminal off-ramps (cancelled / refunded). The customer ships the
5462
+ // original back AND receives a replacement — no refund — so the window
5463
+ // matches the goods being in the customer's hands.
5464
+ function _orderEligibleForExchange(status) {
5465
+ return status === "paid" || status === "fulfilling" ||
5466
+ status === "shipped" || status === "delivered";
5467
+ }
5468
+
5121
5469
  // Reorder is offered for any order that represents a real purchase the
5122
5470
  // customer might want to repeat — paid through delivered, plus the
5123
5471
  // terminal refunded/cancelled states (a cancelled or refunded order is a
@@ -5241,6 +5589,10 @@ function _orderActionsBlock(o) {
5241
5589
  btns.push(
5242
5590
  "<a class=\"btn-secondary order-action\" href=\"/account/orders/" + esc(String(o.id)) + "/return\">Request a return</a>");
5243
5591
  }
5592
+ if (_orderEligibleForExchange(o.status)) {
5593
+ btns.push(
5594
+ "<a class=\"btn-secondary order-action\" href=\"/account/orders/" + esc(String(o.id)) + "/exchange\">Request an exchange</a>");
5595
+ }
5244
5596
  if (_orderEligibleForCancel(o.status)) {
5245
5597
  btns.push(
5246
5598
  "<form class=\"order-action\" method=\"post\" action=\"/orders/" + esc(String(o.id)) + "/cancel\">" +
@@ -6504,6 +6856,7 @@ var ACCOUNT_DASH_PAGE =
6504
6856
  " <a class=\"btn-secondary\" href=\"/account/recently-viewed\">Recently viewed</a>\n" +
6505
6857
  " <a class=\"btn-secondary\" href=\"/account/addresses\">Addresses</a>\n" +
6506
6858
  " <a class=\"btn-secondary\" href=\"/account/returns\">Returns</a>\n" +
6859
+ " <a class=\"btn-secondary\" href=\"/account/exchanges\">Exchanges</a>\n" +
6507
6860
  " <a class=\"btn-secondary\" href=\"/account/support\">Support</a>\n" +
6508
6861
  " <a class=\"btn-secondary\" href=\"/account/loyalty\">Rewards</a>\n" +
6509
6862
  " <a class=\"btn-secondary\" href=\"/account/credit\">Store credit</a>\n" +
@@ -10695,9 +11048,29 @@ function mount(router, deps) {
10695
11048
  }
10696
11049
  var cartCount = await _cartCountForReq(req);
10697
11050
  var wlUrl = req.url ? new URL(req.url, "http://localhost") : null;
11051
+ // Owner share panel — only when the sharing primitive is wired.
11052
+ // The share-link list read is SCOPED to the session customer's id,
11053
+ // so the owner only ever sees their own links. A reads failure
11054
+ // (missing table on a fresh deploy) degrades to no panel rather
11055
+ // than 500-ing the page.
11056
+ var sharePanel = "";
11057
+ if (deps.wishlistSharing) {
11058
+ // Scoped to the session customer — the panel read keys on the
11059
+ // signed-in customer's id, so the owner only ever sees their own
11060
+ // links (this list surface carries no path id, so no IDOR).
11061
+ var sessionOwnerId = auth.customer_id;
11062
+ var ownShares = [];
11063
+ try { ownShares = await deps.wishlistSharing.listSharesForOwner(sessionOwnerId); }
11064
+ catch (_e) { ownShares = []; }
11065
+ sharePanel = _wishlistSharePanel({
11066
+ shares: ownShares,
11067
+ notice: wlUrl ? wlUrl.searchParams.get("share") : null,
11068
+ });
11069
+ }
10698
11070
  _send(res, 200, renderWishlist({
10699
11071
  items: items,
10700
11072
  notice: wlUrl ? wlUrl.searchParams.get("ok") : null,
11073
+ share_panel: sharePanel,
10701
11074
  shop_name: shopName,
10702
11075
  cart_count: cartCount,
10703
11076
  asset_prefix: deps.asset_prefix || "/assets/",
@@ -10705,6 +11078,194 @@ function mount(router, deps) {
10705
11078
  });
10706
11079
  }
10707
11080
 
11081
+ // ---- wishlist sharing -----------------------------------------------
11082
+ //
11083
+ // Owner-scoped share links on the customer's own wishlist, plus the
11084
+ // public, no-auth shared view a giver opens. Mounts only when the
11085
+ // sharing primitive is wired (it composes the wishlist primitive, so a
11086
+ // store with sharing also has the solo wishlist above).
11087
+ //
11088
+ // Owner side (gated on the session customer, CSRF-protected by the
11089
+ // container form chokepoint): create a share link, see active links,
11090
+ // revoke a link. Every owner action is scoped to the session customer —
11091
+ // the create keys on the signed-in customer's id; the revoke loads the
11092
+ // session customer's links by their id and refuses a link that isn't
11093
+ // the session customer's (clean 404, no IDOR).
11094
+ //
11095
+ // Public side (NO auth): `GET /wishlist/shared/:token` resolves the
11096
+ // wishlist ONLY through `viewShared(token)` — never by a guessable
11097
+ // wishlist/customer id — renders the saved products (redacting the
11098
+ // owner's identity + private notes), records the view, and 404s an
11099
+ // unknown / revoked / expired token. noindex (a personal wishlist isn't
11100
+ // index material).
11101
+ if (deps.wishlistSharing) {
11102
+ // Build the owner's share panel from links scoped to the session
11103
+ // customer. `opts.fresh_url` (the one-time URL of a just-created link)
11104
+ // and `opts.notice` (created / revoked) thread through to the panel.
11105
+ // A reads failure (missing table on a fresh deploy) degrades to a
11106
+ // panel with no list rather than throwing.
11107
+ async function _buildSharePanel(customerId, opts) {
11108
+ opts = opts || {};
11109
+ var shareRows = [];
11110
+ try { shareRows = await deps.wishlistSharing.listSharesForOwner(customerId); }
11111
+ catch (_e) { shareRows = []; }
11112
+ return _wishlistSharePanel({
11113
+ shares: shareRows,
11114
+ notice: opts.notice || null,
11115
+ fresh_url: opts.fresh_url || null,
11116
+ });
11117
+ }
11118
+
11119
+ // Re-render the /account/wishlist page (used by the create POST so the
11120
+ // one-time share URL is shown inline, and on a revoke). Resolves the
11121
+ // same saved-items list the GET route renders.
11122
+ async function _renderWishlistWithPanel(req, res, customerId, panelOpts) {
11123
+ var page = await deps.wishlist.listForCustomer(customerId, { limit: 50 });
11124
+ var items = [];
11125
+ for (var i = 0; i < page.rows.length; i += 1) {
11126
+ var entry = page.rows[i];
11127
+ var product = null;
11128
+ try { product = await deps.catalog.products.get(entry.product_id); } catch (_e) { product = null; }
11129
+ if (!product) { items.push({ product: null, product_id: entry.product_id }); continue; }
11130
+ var media = await deps.catalog.media.listForProduct(product.id);
11131
+ items.push({ product: product, hero_media: media.length ? media[0] : null });
11132
+ }
11133
+ var cartCount = await _cartCountForReq(req);
11134
+ var panel = await _buildSharePanel(customerId, panelOpts || {});
11135
+ _send(res, 200, renderWishlist({
11136
+ items: items,
11137
+ share_panel: panel,
11138
+ shop_name: shopName,
11139
+ cart_count: cartCount,
11140
+ asset_prefix: deps.asset_prefix || "/assets/",
11141
+ }));
11142
+ }
11143
+
11144
+ // POST /wishlist/share — mint a share link for the SESSION customer's
11145
+ // wishlist. The link is owner-scoped (keyed on `auth.customer_id`);
11146
+ // the plaintext token is returned once and surfaced inline as the
11147
+ // public URL the owner copies (never persisted). `unlisted` is the
11148
+ // default privacy: link-bearer access, no friends-graph requirement,
11149
+ // not advertised. The page re-renders directly (200) rather than a
11150
+ // redirect so the one-time URL is shown.
11151
+ router.post("/wishlist/share", async function (req, res) {
11152
+ var auth;
11153
+ try { auth = _currentCustomer(req); }
11154
+ catch (e) {
11155
+ if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
11156
+ throw e;
11157
+ }
11158
+ if (!auth) {
11159
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
11160
+ return res.end ? res.end() : res.send("");
11161
+ }
11162
+ var created;
11163
+ try {
11164
+ created = await deps.wishlistSharing.createShareLink({
11165
+ owner_customer_id: auth.customer_id,
11166
+ privacy: "unlisted",
11167
+ });
11168
+ } catch (e) {
11169
+ res.status(e instanceof TypeError ? 400 : 500);
11170
+ return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
11171
+ }
11172
+ // The full public URL the giver opens. Built from this request's
11173
+ // ORIGIN (scheme + host) so the one-time link is a correct absolute
11174
+ // URL — this POST lands on /wishlist/share, so trimming a path off
11175
+ // the canonical URL would mangle the link (and the token shows once).
11176
+ var shareOrigin = "";
11177
+ try { shareOrigin = new URL(_requestUrls(req).canonical_url).origin; }
11178
+ catch (_e) { shareOrigin = ""; /* unparseable — fall back to a host-relative link */ }
11179
+ var freshUrl = shareOrigin + "/wishlist/shared/" + encodeURIComponent(created.plaintext_token);
11180
+ await _renderWishlistWithPanel(req, res, auth.customer_id, { notice: "created", fresh_url: freshUrl });
11181
+ });
11182
+
11183
+ // POST /wishlist/share/:share_id/revoke — revoke one of the SESSION
11184
+ // customer's share links. The primitive's revokeShareLink moves a link
11185
+ // by id alone (no owner notion), so the route owns the ownership
11186
+ // decision: it loads the session customer's own links and refuses a
11187
+ // share_id that isn't among them (clean 404). Without that scope, any
11188
+ // signed-in shopper could revoke another customer's link by guessing
11189
+ // its id (IDOR).
11190
+ router.post("/wishlist/share/:share_id/revoke", async function (req, res) {
11191
+ var auth;
11192
+ try { auth = _currentCustomer(req); }
11193
+ catch (e) {
11194
+ if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
11195
+ throw e;
11196
+ }
11197
+ if (!auth) {
11198
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
11199
+ return res.end ? res.end() : res.send("");
11200
+ }
11201
+ var shareId = (req.params && req.params.share_id) || "";
11202
+ // Ownership scope: the link must belong to the session customer.
11203
+ var owned = [];
11204
+ try { owned = await deps.wishlistSharing.listSharesForOwner(auth.customer_id); }
11205
+ catch (_e) { owned = []; }
11206
+ var match = null;
11207
+ for (var i = 0; i < owned.length; i += 1) {
11208
+ if (owned[i].id === shareId) { match = owned[i]; break; }
11209
+ }
11210
+ if (!match) {
11211
+ // Unknown id, malformed id, or a link owned by someone else all
11212
+ // resolve the same way — a clean 404, no act, no leak.
11213
+ return _send(res, 404, renderNotFound({ shop_name: shopName, cart_count: await _cartCountForReq(req) }));
11214
+ }
11215
+ try {
11216
+ await deps.wishlistSharing.revokeShareLink({ link_id: shareId, reason: "owner revoked from account" });
11217
+ } catch (e) {
11218
+ res.status(e instanceof TypeError ? 400 : 500);
11219
+ return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
11220
+ }
11221
+ res.status(303); res.setHeader && res.setHeader("location", "/account/wishlist?share=revoked");
11222
+ return res.end ? res.end() : res.send("");
11223
+ });
11224
+
11225
+ // GET /wishlist/shared/:token — the public, no-auth shared view. The
11226
+ // wishlist is resolved ONLY via viewShared(token); a giver with the
11227
+ // link sees the saved products (with the owner identity + private
11228
+ // notes redacted — the renderer emits product cards only). An unknown
11229
+ // / revoked / expired token 404s exactly like an unknown route. The
11230
+ // view is recorded best-effort (analytics for the owner) and never
11231
+ // blocks the render. noindex.
11232
+ router.get("/wishlist/shared/:token", async function (req, res) {
11233
+ var token = (req.params && req.params.token) || "";
11234
+ var view = null;
11235
+ try {
11236
+ view = await deps.wishlistSharing.viewShared({ token: token });
11237
+ } catch (_e) {
11238
+ // A malformed token (TypeError) and a not-found / revoked / expired
11239
+ // token (coded errors) all render the same 404 — no oracle on why.
11240
+ return _send(res, 404, renderNotFound({ shop_name: shopName, cart_count: await _cartCountForReq(req) }));
11241
+ }
11242
+ // Record the open for the owner's view counter. Best-effort: a
11243
+ // record failure never breaks the giver's page.
11244
+ try { await deps.wishlistSharing.recordView({ token: token }); }
11245
+ catch (_e) { /* drop-silent — analytics, not the render path */ }
11246
+ // Resolve each shared entry to its product + hero image. The entry's
11247
+ // owner customer_id + private notes are NOT carried into the view
11248
+ // (the renderer emits product cards only) so the giver never sees
11249
+ // the owner's identity.
11250
+ var items = [];
11251
+ var entries = (view && view.entries) || [];
11252
+ for (var i = 0; i < entries.length; i += 1) {
11253
+ var product = null;
11254
+ try { product = await deps.catalog.products.get(entries[i].product_id); } catch (_e) { product = null; }
11255
+ if (!product) continue;
11256
+ var media = await deps.catalog.media.listForProduct(product.id);
11257
+ items.push({ product: product, hero_media: media.length ? media[0] : null });
11258
+ }
11259
+ _send(res, 200, renderSharedWishlist({
11260
+ items: items,
11261
+ title: (view && view.share && view.share.title) || "A shared wishlist",
11262
+ shop_name: shopName,
11263
+ cart_count: await _cartCountForReq(req),
11264
+ asset_prefix: deps.asset_prefix || "/assets/",
11265
+ }));
11266
+ });
11267
+ }
11268
+
10708
11269
  // Save for later — move a cart line into a per-customer holding
10709
11270
  // list and back. Login required (the list is per-customer).
10710
11271
  if (deps.saveForLater) {
@@ -11424,6 +11985,240 @@ function mount(router, deps) {
11424
11985
  });
11425
11986
  }
11426
11987
 
11988
+ // Self-serve exchanges — a customer requests a same-value item SWAP
11989
+ // (different size / colour / unit) against one of their own orders and
11990
+ // tracks its status; operators action it via the admin /admin/exchanges
11991
+ // queue. Distinct from returns (which end in a refund): an exchange
11992
+ // ships the original back AND a replacement out, no money refunded.
11993
+ //
11994
+ // Ownership / IDOR: an order_exchanges row carries `order_id` but NOT
11995
+ // `customer_id` — the customer→order linkage lives on the order. So
11996
+ // every per-exchange / per-order route here loads the parent order and
11997
+ // refuses (clean 404) unless `order.customer_id === auth.customer_id`,
11998
+ // exactly like the returns + support gates. The exchange primitive
11999
+ // moves a row by id alone, so the route owns the ownership decision.
12000
+ if (deps.orderExchanges && deps.order) {
12001
+ function _exchangeAuth(req, res) {
12002
+ var auth;
12003
+ try { auth = _currentCustomer(req); }
12004
+ catch (e) {
12005
+ if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
12006
+ throw e;
12007
+ }
12008
+ if (!auth) {
12009
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
12010
+ res.end ? res.end() : res.send("");
12011
+ return null;
12012
+ }
12013
+ return auth;
12014
+ }
12015
+
12016
+ // Load the order named in :order_id and confirm it belongs to the
12017
+ // signed-in customer. A malformed id (guardUuid TypeError), a missing
12018
+ // order, or someone else's order all return 404 — never a 500, never
12019
+ // a leak. This is the ownership gate the request form + POST funnel
12020
+ // through.
12021
+ async function _ownedOrderForExchange(req, res, auth) {
12022
+ var order;
12023
+ try { order = await deps.order.get(req.params && req.params.order_id); }
12024
+ catch (e) {
12025
+ if (e instanceof TypeError) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
12026
+ throw e;
12027
+ }
12028
+ if (!order || order.customer_id !== auth.customer_id) {
12029
+ _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
12030
+ return null;
12031
+ }
12032
+ return order;
12033
+ }
12034
+
12035
+ // Load the exchange named in :id, then load its parent order and
12036
+ // confirm THAT order belongs to the signed-in customer. The exchange
12037
+ // row has no customer_id, so ownership is asserted transitively
12038
+ // through the order. A malformed id (TypeError), an unknown exchange,
12039
+ // an exchange whose order is gone, or an exchange owned by someone
12040
+ // else ALL return 404 — never a 500, never a cross-customer reveal.
12041
+ async function _ownedExchange(req, res, auth) {
12042
+ var exchange;
12043
+ try { exchange = await deps.orderExchanges.getExchange(req.params && req.params.id); }
12044
+ catch (e) {
12045
+ if (e instanceof TypeError) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
12046
+ throw e;
12047
+ }
12048
+ if (!exchange) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
12049
+ var order;
12050
+ try { order = await deps.order.get(exchange.order_id); }
12051
+ catch (e) {
12052
+ if (e instanceof TypeError) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
12053
+ throw e;
12054
+ }
12055
+ if (!order || order.customer_id !== auth.customer_id) {
12056
+ _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
12057
+ return null;
12058
+ }
12059
+ return exchange;
12060
+ }
12061
+
12062
+ // Resolve the sibling variants a line's product can be swapped to:
12063
+ // every active variant of the same product, labelled by its option
12064
+ // values (falling back to the SKU). Best-effort — absent the catalog
12065
+ // handle (or a read failure) the line offers a same-SKU swap.
12066
+ async function _replacementOptions(line) {
12067
+ if (!deps.catalog || !line || !line.variant_id) return [];
12068
+ try {
12069
+ var v = await deps.catalog.variants.get(line.variant_id);
12070
+ if (!v) return [];
12071
+ var siblings = await deps.catalog.variants.listForProduct(v.product_id);
12072
+ return (siblings || []).map(function (s) {
12073
+ var optLabel = s.options
12074
+ ? Object.keys(s.options).map(function (k) { return s.options[k]; }).join(" / ")
12075
+ : "";
12076
+ var label = (optLabel ? optLabel + " — " : "") + s.sku;
12077
+ return { id: s.id, sku: s.sku, label: label };
12078
+ });
12079
+ } catch (_e) { return []; }
12080
+ }
12081
+
12082
+ // The customer's own exchange list, scoped to their orders. The
12083
+ // primitive resolves the customer→order linkage through the injected
12084
+ // order handle, so a foreign order's exchange never appears here.
12085
+ router.get("/account/exchanges", async function (req, res) {
12086
+ var auth = _exchangeAuth(req, res); if (!auth) return;
12087
+ var exchanges = [];
12088
+ try { exchanges = await deps.orderExchanges.exchangesForCustomer(auth.customer_id, { limit: 100 }); }
12089
+ catch (e) { if (!(e instanceof TypeError)) throw e; exchanges = []; }
12090
+ var cartCount = await _cartCountForReq(req);
12091
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
12092
+ _send(res, 200, renderExchanges({
12093
+ exchanges: exchanges,
12094
+ requested: url && url.searchParams.get("ok") === "1",
12095
+ shop_name: shopName,
12096
+ cart_count: cartCount,
12097
+ }));
12098
+ });
12099
+
12100
+ // One exchange's status detail. Ownership-scoped through the parent
12101
+ // order (foreign / unknown → 404).
12102
+ router.get("/account/exchanges/:id", async function (req, res) {
12103
+ var auth = _exchangeAuth(req, res); if (!auth) return;
12104
+ var exchange = await _ownedExchange(req, res, auth); if (!exchange) return;
12105
+ var cartCount = await _cartCountForReq(req);
12106
+ _send(res, 200, renderExchangeDetail({ exchange: exchange, shop_name: shopName, cart_count: cartCount }));
12107
+ });
12108
+
12109
+ // The exchange-request form for one of the customer's own orders,
12110
+ // gated on the same eligibility window as a return.
12111
+ router.get("/account/orders/:order_id/exchange", async function (req, res) {
12112
+ var auth = _exchangeAuth(req, res); if (!auth) return;
12113
+ var order = await _ownedOrderForExchange(req, res, auth); if (!order) return;
12114
+ var cartCount = await _cartCountForReq(req);
12115
+ if (!_orderEligibleForExchange(order.status)) {
12116
+ return _send(res, 400, renderExchangeForm({
12117
+ order: order, lines: [], notice: "This order isn't eligible for an exchange.",
12118
+ shop_name: shopName, cart_count: cartCount,
12119
+ }));
12120
+ }
12121
+ var lines = [];
12122
+ var orderLines = order.lines || [];
12123
+ for (var i = 0; i < orderLines.length; i += 1) {
12124
+ var ol = orderLines[i];
12125
+ lines.push(Object.assign({}, ol, { replacement_options: await _replacementOptions(ol) }));
12126
+ }
12127
+ _send(res, 200, renderExchangeForm({ order: order, lines: lines, shop_name: shopName, cart_count: cartCount }));
12128
+ });
12129
+
12130
+ router.post("/account/orders/:order_id/exchange", async function (req, res) {
12131
+ var auth = _exchangeAuth(req, res); if (!auth) return;
12132
+ var order = await _ownedOrderForExchange(req, res, auth); if (!order) return;
12133
+ var body = req.body || {};
12134
+ var cartCount = await _cartCountForReq(req);
12135
+
12136
+ // Re-decorate the lines so a failed POST re-renders the same form
12137
+ // (with the replacement pickers intact) rather than a bare page.
12138
+ async function _decoratedLines() {
12139
+ var out = [];
12140
+ var ols = order.lines || [];
12141
+ for (var i = 0; i < ols.length; i += 1) {
12142
+ out.push(Object.assign({}, ols[i], { replacement_options: await _replacementOptions(ols[i]) }));
12143
+ }
12144
+ return out;
12145
+ }
12146
+ function _badForm(notice, status) {
12147
+ return _decoratedLines().then(function (lines) {
12148
+ return _send(res, status || 400, renderExchangeForm({
12149
+ order: order, lines: lines, notice: notice,
12150
+ shop_name: shopName, cart_count: cartCount,
12151
+ }));
12152
+ });
12153
+ }
12154
+
12155
+ if (!_orderEligibleForExchange(order.status)) {
12156
+ return _badForm("This order isn't eligible for an exchange.");
12157
+ }
12158
+
12159
+ // Resolve the picked line from the order's OWN lines (authoritative
12160
+ // sku/qty) — never trust a client-supplied sku. The radio carries
12161
+ // the order_line id.
12162
+ var orderLines = order.lines || [];
12163
+ var picked = null;
12164
+ for (var i = 0; i < orderLines.length; i += 1) {
12165
+ if (orderLines[i].id === body.line_id) { picked = orderLines[i]; break; }
12166
+ }
12167
+ if (!picked) return _badForm("Pick the item you'd like to exchange.");
12168
+
12169
+ var wantedQty = parseInt(body["qty_" + picked.id], 10);
12170
+ var maxQty = Number(picked.qty) || 1;
12171
+ var qty = Number.isFinite(wantedQty) && wantedQty >= 1 && wantedQty <= maxQty ? wantedQty : maxQty;
12172
+
12173
+ // The replacement variant: the chosen sibling variant of the same
12174
+ // product. The select carries the variant id; resolve it back to a
12175
+ // sku through the catalog (never trust a client sku). Absent a
12176
+ // selection (a same-SKU swap — replace a defective unit) the
12177
+ // replacement defaults to the line's own sku/variant.
12178
+ var replacementSku = picked.sku;
12179
+ var replacementVariantId = picked.variant_id;
12180
+ var chosen = body["replacement_" + picked.id];
12181
+ if (chosen && deps.catalog) {
12182
+ // The replacement MUST be one of the sibling variants offered for
12183
+ // the purchased line's product. Validate the chosen id against the
12184
+ // same option set the form was built from (_replacementOptions) —
12185
+ // resolving it through a bare variants.get would accept ANY catalog
12186
+ // variant, letting a forged replacement_<id> swap a cheap item for
12187
+ // any variant in the catalog. The option carries the catalog-
12188
+ // resolved sku/id, so a client sku is never trusted.
12189
+ var options = await _replacementOptions(picked);
12190
+ var match = null;
12191
+ for (var oi = 0; oi < options.length; oi += 1) {
12192
+ if (options[oi].id === chosen) { match = options[oi]; break; }
12193
+ }
12194
+ if (!match) return _badForm("That replacement variant isn't available — pick another.");
12195
+ replacementSku = match.sku;
12196
+ replacementVariantId = match.id;
12197
+ }
12198
+
12199
+ try {
12200
+ await deps.orderExchanges.requestExchange({
12201
+ order_id: order.id,
12202
+ line_id: picked.id,
12203
+ return_sku: picked.sku,
12204
+ return_qty: qty,
12205
+ replacement_sku: replacementSku,
12206
+ replacement_variant_id: replacementVariantId,
12207
+ replacement_qty: qty,
12208
+ reason: body.reason,
12209
+ });
12210
+ } catch (e) {
12211
+ if (e instanceof TypeError) {
12212
+ return _badForm((e && e.message || "").replace(/^order-exchanges[.:]\s*/, "") || "Please check your exchange request.");
12213
+ }
12214
+ throw e;
12215
+ }
12216
+ res.status(303);
12217
+ res.setHeader && res.setHeader("location", "/account/exchanges?ok=1");
12218
+ return res.end ? res.end() : res.send("");
12219
+ });
12220
+ }
12221
+
11427
12222
  // Support tickets — the signed-in customer raises a ticket, lists
11428
12223
  // their own, reads a thread, and replies. EVERY route is login-gated
11429
12224
  // AND scoped to the session customer's id: the support primitive