@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/CHANGELOG.md +4 -0
- package/lib/admin.js +375 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/storefront.js +795 -0
- package/lib/vendor/MANIFEST.json +3 -3
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +14 -2
- package/lib/vendor/blamejs/lib/crypto-field.js +69 -0
- package/lib/vendor/blamejs/lib/mail-store-fts.js +40 -18
- package/lib/vendor/blamejs/lib/mail-store.js +188 -2
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.14.10.json +54 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +17 -6
- package/lib/vendor/blamejs/test/layer-0-primitives/crypto-field-derived-hash.test.js +44 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-store-fts.test.js +208 -0
- package/package.json +1 -1
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 || "")) + " → " + esc(String(x.replacement_sku || "")) +
|
|
3588
|
+
(date ? " · <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 →</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)) : "—") + "</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
|