@blamejs/blamejs-shop 0.3.35 → 0.3.37
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 +1381 -11
- 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
|
@@ -30,6 +30,12 @@ var pricing = require("./pricing");
|
|
|
30
30
|
var currencyDisplayModule = require("./currency-display");
|
|
31
31
|
var translationsModule = require("./translations");
|
|
32
32
|
var securityMiddleware = require("./security-middleware");
|
|
33
|
+
var giftRegistryModule = require("./gift-registry");
|
|
34
|
+
|
|
35
|
+
// Registry occasion values, sourced from the gift-registry primitive so the
|
|
36
|
+
// storefront's <select> options never drift from the column CHECK enum the
|
|
37
|
+
// migration enforces. The primitive is the single source of truth.
|
|
38
|
+
var REGISTRY_OCCASIONS = giftRegistryModule.OCCASIONS;
|
|
33
39
|
var { AsyncLocalStorage } = require("node:async_hooks"); // allow:non-shop-require — Node-core per-request context (no npm dep); the framework itself composes it in db-role-context / log. No b.* request-context primitive exists to wrap it.
|
|
34
40
|
|
|
35
41
|
var b = require("./vendor/blamejs");
|
|
@@ -2644,6 +2650,391 @@ function renderSharedWishlist(opts) {
|
|
|
2644
2650
|
});
|
|
2645
2651
|
}
|
|
2646
2652
|
|
|
2653
|
+
// ---- gift registry -------------------------------------------------------
|
|
2654
|
+
//
|
|
2655
|
+
// Owner side (renderRegistryList / renderRegistryManage) is gated on the
|
|
2656
|
+
// session customer; the public giver view (renderRegistryPublic) takes no
|
|
2657
|
+
// auth — the slug (resolved through the privacy gate) is the access.
|
|
2658
|
+
|
|
2659
|
+
// Human label for an occasion enum value. Unknown values fall back to the
|
|
2660
|
+
// raw token (never throws) so a future migration that adds an occasion the
|
|
2661
|
+
// renderer hasn't been taught still shows something readable.
|
|
2662
|
+
var REGISTRY_OCCASION_LABELS = {
|
|
2663
|
+
wedding: "Wedding",
|
|
2664
|
+
baby: "Baby",
|
|
2665
|
+
housewarming: "Housewarming",
|
|
2666
|
+
birthday: "Birthday",
|
|
2667
|
+
graduation: "Graduation",
|
|
2668
|
+
anniversary: "Anniversary",
|
|
2669
|
+
other: "Other",
|
|
2670
|
+
};
|
|
2671
|
+
function _registryOccasionLabel(occasion) {
|
|
2672
|
+
return REGISTRY_OCCASION_LABELS[occasion] || (occasion || "Registry");
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
// Build a <select> of occasion options. `selected` highlights the current
|
|
2676
|
+
// value (the edit form); absent it the first option is the browser default.
|
|
2677
|
+
function _registryOccasionSelect(name, selected, esc) {
|
|
2678
|
+
var opts = "";
|
|
2679
|
+
for (var i = 0; i < REGISTRY_OCCASIONS.length; i += 1) {
|
|
2680
|
+
var v = REGISTRY_OCCASIONS[i];
|
|
2681
|
+
var sel = v === selected ? " selected" : "";
|
|
2682
|
+
opts += "<option value=\"" + esc(v) + "\"" + sel + ">" + esc(_registryOccasionLabel(v)) + "</option>";
|
|
2683
|
+
}
|
|
2684
|
+
return "<select name=\"" + esc(name) + "\" required>" + opts + "</select>";
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
// Build a <select> of privacy options with inline help text.
|
|
2688
|
+
var REGISTRY_PRIVACY_LABELS = {
|
|
2689
|
+
private: "Private — only you can see it",
|
|
2690
|
+
unlisted: "Unlisted — anyone with the link can see it",
|
|
2691
|
+
public: "Public — discoverable in the shop",
|
|
2692
|
+
};
|
|
2693
|
+
function _registryPrivacySelect(name, selected, esc) {
|
|
2694
|
+
var order = ["unlisted", "public", "private"];
|
|
2695
|
+
var opts = "";
|
|
2696
|
+
for (var i = 0; i < order.length; i += 1) {
|
|
2697
|
+
var v = order[i];
|
|
2698
|
+
var sel = v === selected ? " selected" : "";
|
|
2699
|
+
opts += "<option value=\"" + esc(v) + "\"" + sel + ">" + esc(REGISTRY_PRIVACY_LABELS[v] || v) + "</option>";
|
|
2700
|
+
}
|
|
2701
|
+
return "<select name=\"" + esc(name) + "\" required>" + opts + "</select>";
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
// Owner-facing list of registries (`GET /account/registry`). `opts.rows` is
|
|
2705
|
+
// each registry decoded by `listForOwner`, decorated with its `progress`
|
|
2706
|
+
// rollup ({ overall_percent, total_desired, total_purchased }). The create
|
|
2707
|
+
// form posts to /account/registry. `opts.notice` surfaces a created/closed/
|
|
2708
|
+
// updated confirmation. A per-customer page — noindex.
|
|
2709
|
+
var REGISTRY_LIST_NOTICES = {
|
|
2710
|
+
created: "Registry created. Add the items you're hoping for below.",
|
|
2711
|
+
closed: "Registry closed. It's still viewable so you can send thank-you notes.",
|
|
2712
|
+
updated: "Registry updated.",
|
|
2713
|
+
removed: "Item removed from your registry.",
|
|
2714
|
+
added: "Item added to your registry.",
|
|
2715
|
+
};
|
|
2716
|
+
function renderRegistryList(opts) {
|
|
2717
|
+
opts = opts || {};
|
|
2718
|
+
var esc = b.template.escapeHtml;
|
|
2719
|
+
var rows = opts.rows || [];
|
|
2720
|
+
var rowsHtml = "";
|
|
2721
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
2722
|
+
var r = rows[i];
|
|
2723
|
+
var reg = r.registry;
|
|
2724
|
+
var prog = r.progress || { overall_percent: 0, total_purchased: 0, total_desired: 0 };
|
|
2725
|
+
var statusBadge = reg.status === "closed"
|
|
2726
|
+
? "<span class=\"registry-card__status registry-card__status--closed\">Closed</span>"
|
|
2727
|
+
: "<span class=\"registry-card__status registry-card__status--active\">Active</span>";
|
|
2728
|
+
rowsHtml +=
|
|
2729
|
+
"<li class=\"registry-card\">" +
|
|
2730
|
+
"<div class=\"registry-card__head\">" +
|
|
2731
|
+
"<a class=\"registry-card__title\" href=\"/account/registry/" + esc(reg.slug) + "\">" + esc(reg.title) + "</a>" +
|
|
2732
|
+
statusBadge +
|
|
2733
|
+
"</div>" +
|
|
2734
|
+
"<p class=\"registry-card__meta\">" + esc(_registryOccasionLabel(reg.occasion)) + " · " +
|
|
2735
|
+
esc(REGISTRY_PRIVACY_LABELS[reg.privacy] || reg.privacy) + "</p>" +
|
|
2736
|
+
"<p class=\"registry-card__progress\">" + esc(String(prog.overall_percent)) + "% fulfilled" +
|
|
2737
|
+
" (" + esc(String(prog.total_purchased)) + " of " + esc(String(prog.total_desired)) + ")</p>" +
|
|
2738
|
+
"<a class=\"card-link\" href=\"/account/registry/" + esc(reg.slug) + "\">Manage registry →</a>" +
|
|
2739
|
+
"</li>";
|
|
2740
|
+
}
|
|
2741
|
+
var list = rowsHtml
|
|
2742
|
+
? "<ul class=\"registry-list\">" + rowsHtml + "</ul>"
|
|
2743
|
+
: "<div class=\"account-empty\">" +
|
|
2744
|
+
"<p class=\"account-empty__lede\">You haven't created a registry yet. Start one below for a wedding, a baby, a housewarming, or any occasion — then share the link with friends and family.</p>" +
|
|
2745
|
+
"</div>";
|
|
2746
|
+
var noticeMsg = REGISTRY_LIST_NOTICES[opts.notice];
|
|
2747
|
+
var notice = noticeMsg
|
|
2748
|
+
? "<p class=\"form-notice form-notice--ok\" role=\"status\">" + esc(noticeMsg) + "</p>"
|
|
2749
|
+
: "";
|
|
2750
|
+
var errMsg = (typeof opts.error === "string" && opts.error.length) ? opts.error : "";
|
|
2751
|
+
var errBlock = errMsg
|
|
2752
|
+
? "<p class=\"form-notice form-notice--err\" role=\"alert\">" + esc(errMsg) + "</p>"
|
|
2753
|
+
: "";
|
|
2754
|
+
// The slug field is operator-authored (lowercase alnum + dash) — the
|
|
2755
|
+
// backend validates it; the form pattern is a UX hint only (backend
|
|
2756
|
+
// remains authoritative, never the client).
|
|
2757
|
+
var createForm =
|
|
2758
|
+
"<section class=\"registry-create\" aria-labelledby=\"registry-create-heading\">" +
|
|
2759
|
+
"<h2 id=\"registry-create-heading\" class=\"registry-create__title\">Create a registry</h2>" +
|
|
2760
|
+
errBlock +
|
|
2761
|
+
"<form class=\"registry-create__form\" method=\"post\" action=\"/account/registry\">" +
|
|
2762
|
+
"<label class=\"field\"><span class=\"field__label\">Title</span>" +
|
|
2763
|
+
"<input type=\"text\" name=\"title\" required maxlength=\"200\" placeholder=\"Alice & Bob's Wedding\"></label>" +
|
|
2764
|
+
"<label class=\"field\"><span class=\"field__label\">Registry link (lowercase letters, numbers, dashes)</span>" +
|
|
2765
|
+
"<input type=\"text\" name=\"slug\" required maxlength=\"200\" pattern=\"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\" placeholder=\"alice-and-bob-2026\"></label>" +
|
|
2766
|
+
"<label class=\"field\"><span class=\"field__label\">Recipient name</span>" +
|
|
2767
|
+
"<input type=\"text\" name=\"recipient_name\" required maxlength=\"200\" placeholder=\"Alice & Bob\"></label>" +
|
|
2768
|
+
"<label class=\"field\"><span class=\"field__label\">Occasion</span>" +
|
|
2769
|
+
_registryOccasionSelect("occasion", "wedding", esc) + "</label>" +
|
|
2770
|
+
"<label class=\"field\"><span class=\"field__label\">Event date (optional)</span>" +
|
|
2771
|
+
"<input type=\"date\" name=\"event_date\"></label>" +
|
|
2772
|
+
"<label class=\"field\"><span class=\"field__label\">Who can see it</span>" +
|
|
2773
|
+
_registryPrivacySelect("privacy", "unlisted", esc) + "</label>" +
|
|
2774
|
+
"<button type=\"submit\" class=\"btn-primary\">Create registry</button>" +
|
|
2775
|
+
"</form>" +
|
|
2776
|
+
"</section>";
|
|
2777
|
+
var body =
|
|
2778
|
+
"<section class=\"account-registry\">" +
|
|
2779
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
2780
|
+
"<li><a href=\"/account\">Account</a></li>" +
|
|
2781
|
+
"<li aria-current=\"page\">Gift registry</li>" +
|
|
2782
|
+
"</ol></nav>" +
|
|
2783
|
+
"<h1 class=\"account-registry__title\">Your gift registries</h1>" +
|
|
2784
|
+
notice +
|
|
2785
|
+
list +
|
|
2786
|
+
createForm +
|
|
2787
|
+
"</section>";
|
|
2788
|
+
return _wrap({
|
|
2789
|
+
title: "Gift registry",
|
|
2790
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
2791
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
2792
|
+
theme_css: opts.theme_css,
|
|
2793
|
+
// Per-customer management surface — keep it out of the index.
|
|
2794
|
+
robots: "noindex",
|
|
2795
|
+
body: body,
|
|
2796
|
+
});
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
// Owner-facing manage page for one registry (`GET /account/registry/:slug`).
|
|
2800
|
+
// `opts.registry` is the decoded row; `opts.items` is each item decorated
|
|
2801
|
+
// with { product, hero_media, purchased, remaining, desired }; `opts.share_url`
|
|
2802
|
+
// is the absolute public URL (built from the request origin by the route).
|
|
2803
|
+
// Carries the add-item / edit / close forms. Per-customer — noindex.
|
|
2804
|
+
function renderRegistryManage(opts) {
|
|
2805
|
+
opts = opts || {};
|
|
2806
|
+
var esc = b.template.escapeHtml;
|
|
2807
|
+
var prefix = opts.asset_prefix || "/assets/";
|
|
2808
|
+
var reg = opts.registry || {};
|
|
2809
|
+
var items = opts.items || [];
|
|
2810
|
+
var closed = reg.status === "closed";
|
|
2811
|
+
var rowsHtml = "";
|
|
2812
|
+
for (var i = 0; i < items.length; i += 1) {
|
|
2813
|
+
var it = items[i];
|
|
2814
|
+
var media = it.hero_media
|
|
2815
|
+
? "<img src=\"" + esc(prefix + it.hero_media.r2_key) + "\" alt=\"" + esc(it.hero_media.alt_text || (it.product && it.product.title) || it.sku) + "\" loading=\"lazy\">"
|
|
2816
|
+
: "<span class=\"registry-item__mark\" aria-hidden=\"true\">" + esc(((it.product && it.product.title) || it.sku || "?").trim().charAt(0).toUpperCase() || "?") + "</span>";
|
|
2817
|
+
var titleHtml = it.product
|
|
2818
|
+
? "<a class=\"registry-item__title\" href=\"/products/" + esc(it.product.slug) + "\">" + esc(it.product.title) + "</a>"
|
|
2819
|
+
: "<span class=\"registry-item__title\">" + esc(it.sku) + " (no longer in the catalog)</span>";
|
|
2820
|
+
var removeForm = closed ? "" :
|
|
2821
|
+
"<form class=\"registry-item__remove\" method=\"post\" action=\"/account/registry/" + esc(reg.slug) + "/items/" + esc(it.item_id) + "/remove\">" +
|
|
2822
|
+
"<button type=\"submit\" class=\"btn-ghost\">Remove</button>" +
|
|
2823
|
+
"</form>";
|
|
2824
|
+
rowsHtml +=
|
|
2825
|
+
"<li class=\"registry-item\">" +
|
|
2826
|
+
"<span class=\"registry-item__media\">" + media + "</span>" +
|
|
2827
|
+
"<div class=\"registry-item__body\">" +
|
|
2828
|
+
titleHtml +
|
|
2829
|
+
"<p class=\"registry-item__progress\">" + esc(String(it.purchased)) + " of " + esc(String(it.desired)) +
|
|
2830
|
+
" purchased · " + esc(String(it.remaining)) + " still needed</p>" +
|
|
2831
|
+
"</div>" +
|
|
2832
|
+
removeForm +
|
|
2833
|
+
"</li>";
|
|
2834
|
+
}
|
|
2835
|
+
var list = rowsHtml
|
|
2836
|
+
? "<ul class=\"registry-item-list\">" + rowsHtml + "</ul>"
|
|
2837
|
+
: "<div class=\"account-empty\"><p class=\"account-empty__lede\">No items yet. Add the products you're hoping for below.</p></div>";
|
|
2838
|
+
|
|
2839
|
+
var addForm = closed ? "" :
|
|
2840
|
+
"<section class=\"registry-add\" aria-labelledby=\"registry-add-heading\">" +
|
|
2841
|
+
"<h2 id=\"registry-add-heading\" class=\"registry-add__title\">Add an item</h2>" +
|
|
2842
|
+
"<form class=\"registry-add__form\" method=\"post\" action=\"/account/registry/" + esc(reg.slug) + "/items\">" +
|
|
2843
|
+
"<label class=\"field\"><span class=\"field__label\">Product SKU</span>" +
|
|
2844
|
+
"<input type=\"text\" name=\"sku\" required maxlength=\"200\" placeholder=\"BLENDER-12CUP\"></label>" +
|
|
2845
|
+
"<label class=\"field\"><span class=\"field__label\">Quantity wanted</span>" +
|
|
2846
|
+
"<input type=\"number\" name=\"quantity_desired\" required min=\"1\" max=\"999\" value=\"1\"></label>" +
|
|
2847
|
+
"<label class=\"field\"><span class=\"field__label\">Priority (1 = most wanted)</span>" +
|
|
2848
|
+
"<input type=\"number\" name=\"priority\" min=\"1\" max=\"5\" value=\"3\"></label>" +
|
|
2849
|
+
"<button type=\"submit\" class=\"btn-secondary\">Add to registry</button>" +
|
|
2850
|
+
"</form>" +
|
|
2851
|
+
"</section>";
|
|
2852
|
+
|
|
2853
|
+
var eventDateValue = (typeof reg.event_date === "number")
|
|
2854
|
+
? new Date(reg.event_date).toISOString().slice(0, 10)
|
|
2855
|
+
: "";
|
|
2856
|
+
var editForm = closed ? "" :
|
|
2857
|
+
"<section class=\"registry-edit\" aria-labelledby=\"registry-edit-heading\">" +
|
|
2858
|
+
"<h2 id=\"registry-edit-heading\" class=\"registry-edit__title\">Registry details</h2>" +
|
|
2859
|
+
"<form class=\"registry-edit__form\" method=\"post\" action=\"/account/registry/" + esc(reg.slug) + "/edit\">" +
|
|
2860
|
+
"<label class=\"field\"><span class=\"field__label\">Title</span>" +
|
|
2861
|
+
"<input type=\"text\" name=\"title\" required maxlength=\"200\" value=\"" + esc(reg.title || "") + "\"></label>" +
|
|
2862
|
+
"<label class=\"field\"><span class=\"field__label\">Recipient name</span>" +
|
|
2863
|
+
"<input type=\"text\" name=\"recipient_name\" required maxlength=\"200\" value=\"" + esc(reg.recipient_name || "") + "\"></label>" +
|
|
2864
|
+
"<label class=\"field\"><span class=\"field__label\">Event date</span>" +
|
|
2865
|
+
"<input type=\"date\" name=\"event_date\" value=\"" + esc(eventDateValue) + "\"></label>" +
|
|
2866
|
+
"<label class=\"field\"><span class=\"field__label\">Who can see it</span>" +
|
|
2867
|
+
_registryPrivacySelect("privacy", reg.privacy, esc) + "</label>" +
|
|
2868
|
+
"<button type=\"submit\" class=\"btn-secondary\">Save details</button>" +
|
|
2869
|
+
"</form>" +
|
|
2870
|
+
"</section>";
|
|
2871
|
+
|
|
2872
|
+
var closeForm = closed
|
|
2873
|
+
? "<p class=\"registry-manage__closed-note\">This registry is closed. It stays viewable so you can review what arrived.</p>"
|
|
2874
|
+
: "<form class=\"registry-close__form\" method=\"post\" action=\"/account/registry/" + esc(reg.slug) + "/close\">" +
|
|
2875
|
+
"<button type=\"submit\" class=\"btn-ghost registry-close__btn\">Close this registry</button>" +
|
|
2876
|
+
"</form>";
|
|
2877
|
+
|
|
2878
|
+
// The shareable public URL — shown only for a non-private registry (a
|
|
2879
|
+
// private registry isn't publicly viewable, so there's no link to share).
|
|
2880
|
+
var shareBlock = "";
|
|
2881
|
+
if (reg.privacy !== "private" && opts.share_url) {
|
|
2882
|
+
shareBlock =
|
|
2883
|
+
"<section class=\"registry-share\" aria-labelledby=\"registry-share-heading\">" +
|
|
2884
|
+
"<h2 id=\"registry-share-heading\" class=\"registry-share__title\">Share this registry</h2>" +
|
|
2885
|
+
"<p class=\"registry-share__lede\">Send this link to friends and family. They can see what you're hoping for and mark items as purchased — buyers stay anonymous unless they choose otherwise.</p>" +
|
|
2886
|
+
"<label class=\"registry-share__label\" for=\"registry-share-url\">Public link</label>" +
|
|
2887
|
+
"<input id=\"registry-share-url\" class=\"registry-share__url\" type=\"text\" readonly value=\"" + esc(opts.share_url) + "\" " +
|
|
2888
|
+
"aria-label=\"Public registry link\" onfocus=\"this.select()\">" +
|
|
2889
|
+
"</section>";
|
|
2890
|
+
} else if (reg.privacy === "private") {
|
|
2891
|
+
shareBlock = "<p class=\"registry-share__private-note\">This registry is private — no public link. Change it to unlisted or public above to share it.</p>";
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
var noticeMsg = REGISTRY_LIST_NOTICES[opts.notice];
|
|
2895
|
+
var notice = noticeMsg
|
|
2896
|
+
? "<p class=\"form-notice form-notice--ok\" role=\"status\">" + esc(noticeMsg) + "</p>"
|
|
2897
|
+
: "";
|
|
2898
|
+
var errMsg = (typeof opts.error === "string" && opts.error.length) ? opts.error : "";
|
|
2899
|
+
var errBlock = errMsg
|
|
2900
|
+
? "<p class=\"form-notice form-notice--err\" role=\"alert\">" + esc(errMsg) + "</p>"
|
|
2901
|
+
: "";
|
|
2902
|
+
|
|
2903
|
+
var body =
|
|
2904
|
+
"<section class=\"account-registry-manage\">" +
|
|
2905
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
2906
|
+
"<li><a href=\"/account\">Account</a></li>" +
|
|
2907
|
+
"<li><a href=\"/account/registry\">Gift registry</a></li>" +
|
|
2908
|
+
"<li aria-current=\"page\">" + esc(reg.title || "Registry") + "</li>" +
|
|
2909
|
+
"</ol></nav>" +
|
|
2910
|
+
"<h1 class=\"account-registry-manage__title\">" + esc(reg.title || "Registry") + "</h1>" +
|
|
2911
|
+
"<p class=\"account-registry-manage__meta\">" + esc(_registryOccasionLabel(reg.occasion)) + " · " +
|
|
2912
|
+
esc(REGISTRY_PRIVACY_LABELS[reg.privacy] || reg.privacy) + (closed ? " · Closed" : "") + "</p>" +
|
|
2913
|
+
notice +
|
|
2914
|
+
errBlock +
|
|
2915
|
+
shareBlock +
|
|
2916
|
+
list +
|
|
2917
|
+
addForm +
|
|
2918
|
+
editForm +
|
|
2919
|
+
closeForm +
|
|
2920
|
+
"</section>";
|
|
2921
|
+
return _wrap({
|
|
2922
|
+
title: reg.title || "Registry",
|
|
2923
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
2924
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
2925
|
+
theme_css: opts.theme_css,
|
|
2926
|
+
robots: "noindex",
|
|
2927
|
+
body: body,
|
|
2928
|
+
});
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
// Confirmation copy after a public giver action. Unknown keys render nothing
|
|
2932
|
+
// so a forged ?ok= query can't inject arbitrary copy onto the page.
|
|
2933
|
+
var REGISTRY_PUBLIC_NOTICES = {
|
|
2934
|
+
gifted: "Thank you! We've marked that item as purchased.",
|
|
2935
|
+
};
|
|
2936
|
+
|
|
2937
|
+
// Public, no-auth giver view of a registry (`GET /registry/:slug`). The route
|
|
2938
|
+
// resolves the registry ONLY through getBySlug (which honors the privacy
|
|
2939
|
+
// gate — a private registry returns null and the route 404s); the owner's
|
|
2940
|
+
// customer id + shipping address + buyer identities are NEVER carried into
|
|
2941
|
+
// this shape (the route passes title/occasion/event_date + per-item desired /
|
|
2942
|
+
// purchased / remaining counts + product cards only). Each not-yet-fulfilled
|
|
2943
|
+
// item carries a "mark purchased" form (records a gift via purchaseItem —
|
|
2944
|
+
// buyer anonymous by default) and an "add to cart" link so the giver can buy
|
|
2945
|
+
// it normally through the regular cart/checkout. Carries a noindex robots
|
|
2946
|
+
// directive — a personal registry is not search-index material.
|
|
2947
|
+
function renderRegistryPublic(opts) {
|
|
2948
|
+
opts = opts || {};
|
|
2949
|
+
var esc = b.template.escapeHtml;
|
|
2950
|
+
var prefix = opts.asset_prefix || "/assets/";
|
|
2951
|
+
var reg = opts.registry || {};
|
|
2952
|
+
var items = opts.items || [];
|
|
2953
|
+
var heading = (reg.title && String(reg.title).length) ? reg.title : "A gift registry";
|
|
2954
|
+
var occasionLine = esc(_registryOccasionLabel(reg.occasion));
|
|
2955
|
+
if (typeof reg.event_date === "number") {
|
|
2956
|
+
var dt = new Date(reg.event_date);
|
|
2957
|
+
if (!isNaN(dt.getTime())) {
|
|
2958
|
+
occasionLine += " · " + esc(dt.toISOString().slice(0, 10));
|
|
2959
|
+
}
|
|
2960
|
+
}
|
|
2961
|
+
var rowsHtml = "";
|
|
2962
|
+
for (var i = 0; i < items.length; i += 1) {
|
|
2963
|
+
var it = items[i];
|
|
2964
|
+
var media = it.hero_media
|
|
2965
|
+
? "<img src=\"" + esc(prefix + it.hero_media.r2_key) + "\" alt=\"" + esc(it.hero_media.alt_text || (it.product && it.product.title) || it.sku) + "\" loading=\"lazy\">"
|
|
2966
|
+
: "<span class=\"registry-item__mark\" aria-hidden=\"true\">" + esc(((it.product && it.product.title) || it.sku || "?").trim().charAt(0).toUpperCase() || "?") + "</span>";
|
|
2967
|
+
var titleHtml = it.product
|
|
2968
|
+
? "<a class=\"registry-item__title\" href=\"/products/" + esc(it.product.slug) + "\">" + esc(it.product.title) + "</a>"
|
|
2969
|
+
: "<span class=\"registry-item__title\">" + esc(it.sku) + "</span>";
|
|
2970
|
+
// Progress line — desired vs already purchased. A fully-purchased item
|
|
2971
|
+
// shows a "fully gifted" badge and offers no purchase control.
|
|
2972
|
+
var fulfilled = it.remaining <= 0;
|
|
2973
|
+
var progressHtml = fulfilled
|
|
2974
|
+
? "<p class=\"registry-item__progress registry-item__progress--done\">Fully gifted — thank you!</p>"
|
|
2975
|
+
: "<p class=\"registry-item__progress\">" + esc(String(it.purchased)) + " of " + esc(String(it.desired)) +
|
|
2976
|
+
" purchased · " + esc(String(it.remaining)) + " still needed</p>";
|
|
2977
|
+
// Giver controls: only for an item that's both buyable (resolves to a
|
|
2978
|
+
// catalog variant) and not yet fully gifted, and only while the registry
|
|
2979
|
+
// is active (a closed registry refuses purchaseItem).
|
|
2980
|
+
var controls = "";
|
|
2981
|
+
if (!fulfilled && reg.status === "active") {
|
|
2982
|
+
var markForm =
|
|
2983
|
+
"<form class=\"registry-item__gift\" method=\"post\" action=\"/registry/" + esc(reg.slug) + "/items/" + esc(it.item_id) + "/purchase\">" +
|
|
2984
|
+
"<input type=\"hidden\" name=\"quantity\" value=\"1\">" +
|
|
2985
|
+
"<label class=\"registry-item__reveal\"><input type=\"checkbox\" name=\"reveal\" value=\"1\"> Let the recipient see it was from me (requires sign-in)</label>" +
|
|
2986
|
+
"<button type=\"submit\" class=\"btn-secondary\">Mark as purchased</button>" +
|
|
2987
|
+
"</form>";
|
|
2988
|
+
var cartForm = it.variant_id
|
|
2989
|
+
? "<form class=\"registry-item__cart\" method=\"post\" action=\"/cart/lines\">" +
|
|
2990
|
+
"<input type=\"hidden\" name=\"variant_id\" value=\"" + esc(it.variant_id) + "\">" +
|
|
2991
|
+
"<input type=\"hidden\" name=\"qty\" value=\"1\">" +
|
|
2992
|
+
"<button type=\"submit\" class=\"btn-ghost\">Add to cart</button>" +
|
|
2993
|
+
"</form>"
|
|
2994
|
+
: "";
|
|
2995
|
+
controls = "<div class=\"registry-item__actions\">" + cartForm + markForm + "</div>";
|
|
2996
|
+
}
|
|
2997
|
+
rowsHtml +=
|
|
2998
|
+
"<li class=\"registry-item\">" +
|
|
2999
|
+
"<span class=\"registry-item__media\">" + media + "</span>" +
|
|
3000
|
+
"<div class=\"registry-item__body\">" +
|
|
3001
|
+
titleHtml +
|
|
3002
|
+
progressHtml +
|
|
3003
|
+
controls +
|
|
3004
|
+
"</div>" +
|
|
3005
|
+
"</li>";
|
|
3006
|
+
}
|
|
3007
|
+
var inner = rowsHtml
|
|
3008
|
+
? "<ul class=\"registry-item-list\">" + rowsHtml + "</ul>"
|
|
3009
|
+
: "<div class=\"account-empty\"><p class=\"account-empty__lede\">This registry doesn't have any items yet — check back soon.</p>" +
|
|
3010
|
+
"<a class=\"btn-secondary\" href=\"/\">Browse the shop →</a></div>";
|
|
3011
|
+
var noticeMsg = REGISTRY_PUBLIC_NOTICES[opts.notice];
|
|
3012
|
+
var notice = noticeMsg
|
|
3013
|
+
? "<p class=\"form-notice form-notice--ok\" role=\"status\">" + esc(noticeMsg) + "</p>"
|
|
3014
|
+
: "";
|
|
3015
|
+
var messageBlock = (reg.message && String(reg.message).length)
|
|
3016
|
+
? "<p class=\"registry-public__message\">" + esc(reg.message) + "</p>"
|
|
3017
|
+
: "";
|
|
3018
|
+
var body =
|
|
3019
|
+
"<section class=\"registry-public\">" +
|
|
3020
|
+
"<p class=\"eyebrow\">Gift registry</p>" +
|
|
3021
|
+
"<h1 class=\"registry-public__title\">" + esc(heading) + "</h1>" +
|
|
3022
|
+
"<p class=\"registry-public__meta\">" + occasionLine + "</p>" +
|
|
3023
|
+
messageBlock +
|
|
3024
|
+
notice +
|
|
3025
|
+
inner +
|
|
3026
|
+
"</section>";
|
|
3027
|
+
return _wrap({
|
|
3028
|
+
title: heading,
|
|
3029
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
3030
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
3031
|
+
theme_css: opts.theme_css,
|
|
3032
|
+
// A personal registry is not search-index material.
|
|
3033
|
+
robots: "noindex",
|
|
3034
|
+
body: body,
|
|
3035
|
+
});
|
|
3036
|
+
}
|
|
3037
|
+
|
|
2647
3038
|
// Compare page notice banner — surfaced after a toggle / a full-basket
|
|
2648
3039
|
// refusal. The route passes a short message key; unknown keys render
|
|
2649
3040
|
// nothing so a forged `?notice=` query can't inject arbitrary copy.
|
|
@@ -3462,6 +3853,206 @@ function renderReturns(opts) {
|
|
|
3462
3853
|
});
|
|
3463
3854
|
}
|
|
3464
3855
|
|
|
3856
|
+
// Customer-facing reasons for an exchange. The values mirror the
|
|
3857
|
+
// order-exchanges module's REASONS allow-list; the backend validates the
|
|
3858
|
+
// submitted value against its own list, so a forged value is refused
|
|
3859
|
+
// there. (No-longer-needed isn't an exchange reason — that's a return.)
|
|
3860
|
+
var EXCHANGE_REASONS = [
|
|
3861
|
+
["defective", "Defective / doesn't work"],
|
|
3862
|
+
["wrong-item", "Wrong item received"],
|
|
3863
|
+
["wrong-size", "Wrong size"],
|
|
3864
|
+
["wrong-colour", "Wrong colour"],
|
|
3865
|
+
["damaged-in-transit", "Damaged in transit"],
|
|
3866
|
+
["not-as-described", "Not as described"],
|
|
3867
|
+
["other", "Other"],
|
|
3868
|
+
];
|
|
3869
|
+
|
|
3870
|
+
// Customer-facing phrasing for an exchange's FSM status. The module's
|
|
3871
|
+
// enum (pending / approved / shipped / delivered / received / closed /
|
|
3872
|
+
// rejected) is operator vocabulary; these read for the shopper.
|
|
3873
|
+
var EXCHANGE_STATUS_LABELS = {
|
|
3874
|
+
pending: "Requested",
|
|
3875
|
+
approved: "Approved",
|
|
3876
|
+
shipped: "Replacement shipped",
|
|
3877
|
+
delivered: "Replacement delivered",
|
|
3878
|
+
received: "Return received",
|
|
3879
|
+
closed: "Completed",
|
|
3880
|
+
rejected: "Declined",
|
|
3881
|
+
};
|
|
3882
|
+
|
|
3883
|
+
function _exchangeStatusBadge(status) {
|
|
3884
|
+
var esc = b.template.escapeHtml;
|
|
3885
|
+
var label = EXCHANGE_STATUS_LABELS[status] || status;
|
|
3886
|
+
return "<span class=\"return-status return-status--" + esc(String(status)) + "\">" +
|
|
3887
|
+
esc(String(label)) + "</span>";
|
|
3888
|
+
}
|
|
3889
|
+
|
|
3890
|
+
// Customer-facing exchange-request form for one owned order. `opts.lines`
|
|
3891
|
+
// is the order's order_lines, each decorated with `replacement_options`
|
|
3892
|
+
// (the sibling variants of that line's product the shopper can swap to).
|
|
3893
|
+
// `opts.notice` is an optional error bounced back from a failed POST.
|
|
3894
|
+
// Reuses the returns-form layout classes — no new CSS.
|
|
3895
|
+
function renderExchangeForm(opts) {
|
|
3896
|
+
var esc = b.template.escapeHtml;
|
|
3897
|
+
var order = opts.order;
|
|
3898
|
+
var lines = opts.lines || [];
|
|
3899
|
+
var lineRows = "";
|
|
3900
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
3901
|
+
var l = lines[i];
|
|
3902
|
+
var maxQty = Number(l.qty) || 1;
|
|
3903
|
+
var replOpts = (l.replacement_options || []).map(function (o) {
|
|
3904
|
+
return "<option value=\"" + esc(String(o.id)) + "\">" + esc(String(o.label)) + "</option>";
|
|
3905
|
+
}).join("");
|
|
3906
|
+
// A line with no resolvable sibling variants can't be exchanged for a
|
|
3907
|
+
// different option — offer a same-SKU swap (replace a defective unit)
|
|
3908
|
+
// so the line is never silently undisplayable.
|
|
3909
|
+
var replaceField = replOpts
|
|
3910
|
+
? "<label class=\"return-line__qty\">Swap for " +
|
|
3911
|
+
"<select name=\"replacement_" + esc(l.id) + "\">" + replOpts + "</select>" +
|
|
3912
|
+
"</label>"
|
|
3913
|
+
: "<p class=\"return-line__of\">Same item, replacement unit.</p>";
|
|
3914
|
+
lineRows +=
|
|
3915
|
+
"<li class=\"return-line\">" +
|
|
3916
|
+
"<label class=\"return-line__pick\">" +
|
|
3917
|
+
"<input type=\"radio\" name=\"line_id\" value=\"" + esc(l.id) + "\"" + (i === 0 ? " checked" : "") + ">" +
|
|
3918
|
+
"<span class=\"return-line__sku\"><code>" + esc(l.sku) + "</code></span>" +
|
|
3919
|
+
"</label>" +
|
|
3920
|
+
"<label class=\"return-line__qty\">Qty to exchange " +
|
|
3921
|
+
"<input type=\"number\" name=\"qty_" + esc(l.id) + "\" value=\"1\" min=\"1\" max=\"" + maxQty + "\">" +
|
|
3922
|
+
" <span class=\"return-line__of\">of " + maxQty + "</span>" +
|
|
3923
|
+
"</label>" +
|
|
3924
|
+
replaceField +
|
|
3925
|
+
"</li>";
|
|
3926
|
+
}
|
|
3927
|
+
var reasonOpts = EXCHANGE_REASONS.map(function (r) {
|
|
3928
|
+
return "<option value=\"" + esc(r[0]) + "\">" + esc(r[1]) + "</option>";
|
|
3929
|
+
}).join("");
|
|
3930
|
+
var notice = opts.notice
|
|
3931
|
+
? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
|
|
3932
|
+
: "";
|
|
3933
|
+
var body =
|
|
3934
|
+
"<section class=\"return-form-page\">" +
|
|
3935
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
3936
|
+
"<li><a href=\"/account\">Account</a></li>" +
|
|
3937
|
+
"<li><a href=\"/account/exchanges\">Exchanges</a></li>" +
|
|
3938
|
+
"<li aria-current=\"page\">Request an exchange</li>" +
|
|
3939
|
+
"</ol></nav>" +
|
|
3940
|
+
"<h1 class=\"return-form-page__title\">Request an exchange</h1>" +
|
|
3941
|
+
"<p class=\"return-form-page__order\">Order <code>" + esc(order.id) + "</code></p>" +
|
|
3942
|
+
notice +
|
|
3943
|
+
"<form class=\"return-form form-stack\" method=\"post\" action=\"/account/orders/" + esc(order.id) + "/exchange\">" +
|
|
3944
|
+
"<fieldset class=\"return-form__lines\"><legend>Which item?</legend>" +
|
|
3945
|
+
"<ul class=\"return-line-list\">" + lineRows + "</ul>" +
|
|
3946
|
+
"</fieldset>" +
|
|
3947
|
+
"<label class=\"form-field\"><span class=\"form-field__label\">Reason</span>" +
|
|
3948
|
+
"<select name=\"reason\" required>" + reasonOpts + "</select></label>" +
|
|
3949
|
+
"<button type=\"submit\" class=\"btn-primary\">Request exchange</button>" +
|
|
3950
|
+
"</form>" +
|
|
3951
|
+
"</section>";
|
|
3952
|
+
return _wrap({
|
|
3953
|
+
title: "Request an exchange",
|
|
3954
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
3955
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
3956
|
+
theme_css: opts.theme_css,
|
|
3957
|
+
body: body,
|
|
3958
|
+
});
|
|
3959
|
+
}
|
|
3960
|
+
|
|
3961
|
+
// The signed-in customer's exchange list. `opts.exchanges` is the
|
|
3962
|
+
// ownership-scoped set (already filtered to this customer's orders); each
|
|
3963
|
+
// links to its status detail.
|
|
3964
|
+
function renderExchanges(opts) {
|
|
3965
|
+
var esc = b.template.escapeHtml;
|
|
3966
|
+
var exchanges = opts.exchanges || [];
|
|
3967
|
+
var rowsHtml = "";
|
|
3968
|
+
for (var i = 0; i < exchanges.length; i += 1) {
|
|
3969
|
+
var x = exchanges[i];
|
|
3970
|
+
var date = x.created_at ? new Date(Number(x.created_at)).toISOString().slice(0, 10) : "";
|
|
3971
|
+
rowsHtml +=
|
|
3972
|
+
"<li class=\"return-card\">" +
|
|
3973
|
+
"<div class=\"return-card__head\">" +
|
|
3974
|
+
"<a class=\"return-card__rma\" href=\"/account/exchanges/" + esc(String(x.id)) + "\"><code>" + esc(String(x.id).slice(0, 8)) + "</code></a>" +
|
|
3975
|
+
_exchangeStatusBadge(x.status) +
|
|
3976
|
+
"</div>" +
|
|
3977
|
+
"<p class=\"return-card__meta\">" +
|
|
3978
|
+
esc(String(x.return_sku || "")) + " → " + esc(String(x.replacement_sku || "")) +
|
|
3979
|
+
(date ? " · <time datetime=\"" + esc(date) + "\">" + esc(date) + "</time>" : "") +
|
|
3980
|
+
"</p>" +
|
|
3981
|
+
(x.status === "rejected" && x.reject_reason ? "<p class=\"return-card__reject\">" + esc(String(x.reject_reason)) + "</p>" : "") +
|
|
3982
|
+
"</li>";
|
|
3983
|
+
}
|
|
3984
|
+
var inner = rowsHtml
|
|
3985
|
+
? "<ul class=\"return-list\">" + rowsHtml + "</ul>"
|
|
3986
|
+
: "<div class=\"account-empty\">" +
|
|
3987
|
+
"<p class=\"account-empty__lede\">No exchanges yet. Start one from an order in your account.</p>" +
|
|
3988
|
+
"<a class=\"btn-secondary\" href=\"/account/orders\">View your orders →</a>" +
|
|
3989
|
+
"</div>";
|
|
3990
|
+
var notice = opts.requested
|
|
3991
|
+
? "<p class=\"form-notice form-notice--ok\" role=\"status\">Exchange request received — we'll review it and email you next steps.</p>"
|
|
3992
|
+
: "";
|
|
3993
|
+
var body =
|
|
3994
|
+
"<section class=\"account-returns\">" +
|
|
3995
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
3996
|
+
"<li><a href=\"/account\">Account</a></li>" +
|
|
3997
|
+
"<li aria-current=\"page\">Exchanges</li>" +
|
|
3998
|
+
"</ol></nav>" +
|
|
3999
|
+
"<h1 class=\"account-returns__title\">Exchanges</h1>" +
|
|
4000
|
+
notice +
|
|
4001
|
+
inner +
|
|
4002
|
+
"</section>";
|
|
4003
|
+
return _wrap({
|
|
4004
|
+
title: "Exchanges",
|
|
4005
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
4006
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
4007
|
+
theme_css: opts.theme_css,
|
|
4008
|
+
body: body,
|
|
4009
|
+
});
|
|
4010
|
+
}
|
|
4011
|
+
|
|
4012
|
+
// One exchange's status detail. Ownership-scoped (the route only renders
|
|
4013
|
+
// this for an exchange whose parent order belongs to the session
|
|
4014
|
+
// customer). Surfaces the swap, the reason, the live status, the
|
|
4015
|
+
// rejection reason (if declined), and the outbound tracking number once
|
|
4016
|
+
// the replacement has shipped.
|
|
4017
|
+
function renderExchangeDetail(opts) {
|
|
4018
|
+
var esc = b.template.escapeHtml;
|
|
4019
|
+
var x = opts.exchange;
|
|
4020
|
+
var date = x.created_at ? new Date(Number(x.created_at)).toISOString().slice(0, 10) : "";
|
|
4021
|
+
var rows = "";
|
|
4022
|
+
function _row(label, value) {
|
|
4023
|
+
return "<p class=\"return-card__meta\"><span class=\"form-field__label\">" + esc(label) + "</span> " +
|
|
4024
|
+
(value ? esc(String(value)) : "—") + "</p>";
|
|
4025
|
+
}
|
|
4026
|
+
rows += _row("Item returned", x.return_sku + " ×" + x.return_qty);
|
|
4027
|
+
rows += _row("Replacement", x.replacement_sku + " ×" + x.replacement_qty);
|
|
4028
|
+
rows += _row("Reason", x.reason);
|
|
4029
|
+
if (date) rows += _row("Requested", date);
|
|
4030
|
+
if (x.status === "rejected" && x.reject_reason) rows += _row("Why it was declined", x.reject_reason);
|
|
4031
|
+
if (x.tracking_number) {
|
|
4032
|
+
rows += _row("Replacement tracking", x.carrier ? (x.carrier + " · " + x.tracking_number) : x.tracking_number);
|
|
4033
|
+
}
|
|
4034
|
+
var body =
|
|
4035
|
+
"<section class=\"account-returns\">" +
|
|
4036
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
4037
|
+
"<li><a href=\"/account\">Account</a></li>" +
|
|
4038
|
+
"<li><a href=\"/account/exchanges\">Exchanges</a></li>" +
|
|
4039
|
+
"<li aria-current=\"page\">" + esc(String(x.id).slice(0, 8)) + "</li>" +
|
|
4040
|
+
"</ol></nav>" +
|
|
4041
|
+
"<div class=\"return-card__head\">" +
|
|
4042
|
+
"<h1 class=\"account-returns__title\">Exchange <code>" + esc(String(x.id).slice(0, 8)) + "</code></h1>" +
|
|
4043
|
+
_exchangeStatusBadge(x.status) +
|
|
4044
|
+
"</div>" +
|
|
4045
|
+
"<div class=\"return-card\">" + rows + "</div>" +
|
|
4046
|
+
"</section>";
|
|
4047
|
+
return _wrap({
|
|
4048
|
+
title: "Exchange " + String(x.id).slice(0, 8),
|
|
4049
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
4050
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
4051
|
+
theme_css: opts.theme_css,
|
|
4052
|
+
body: body,
|
|
4053
|
+
});
|
|
4054
|
+
}
|
|
4055
|
+
|
|
3465
4056
|
// Support ticket — the categories a shopper can pick when raising one,
|
|
3466
4057
|
// and the human label for each. The values mirror the support-tickets
|
|
3467
4058
|
// module's ALLOWED_CATEGORIES; the backend validates the submitted value
|
|
@@ -5255,6 +5846,17 @@ function _orderEligibleForReturn(status) {
|
|
|
5255
5846
|
status === "shipped" || status === "delivered";
|
|
5256
5847
|
}
|
|
5257
5848
|
|
|
5849
|
+
// An exchange (swap the item for a different size / colour / unit at the
|
|
5850
|
+
// same value) is offered on the same window as a return: a paid (or
|
|
5851
|
+
// further-along) order, never a still-pending (unpaid) one nor the
|
|
5852
|
+
// terminal off-ramps (cancelled / refunded). The customer ships the
|
|
5853
|
+
// original back AND receives a replacement — no refund — so the window
|
|
5854
|
+
// matches the goods being in the customer's hands.
|
|
5855
|
+
function _orderEligibleForExchange(status) {
|
|
5856
|
+
return status === "paid" || status === "fulfilling" ||
|
|
5857
|
+
status === "shipped" || status === "delivered";
|
|
5858
|
+
}
|
|
5859
|
+
|
|
5258
5860
|
// Reorder is offered for any order that represents a real purchase the
|
|
5259
5861
|
// customer might want to repeat — paid through delivered, plus the
|
|
5260
5862
|
// terminal refunded/cancelled states (a cancelled or refunded order is a
|
|
@@ -5378,6 +5980,10 @@ function _orderActionsBlock(o) {
|
|
|
5378
5980
|
btns.push(
|
|
5379
5981
|
"<a class=\"btn-secondary order-action\" href=\"/account/orders/" + esc(String(o.id)) + "/return\">Request a return</a>");
|
|
5380
5982
|
}
|
|
5983
|
+
if (_orderEligibleForExchange(o.status)) {
|
|
5984
|
+
btns.push(
|
|
5985
|
+
"<a class=\"btn-secondary order-action\" href=\"/account/orders/" + esc(String(o.id)) + "/exchange\">Request an exchange</a>");
|
|
5986
|
+
}
|
|
5381
5987
|
if (_orderEligibleForCancel(o.status)) {
|
|
5382
5988
|
btns.push(
|
|
5383
5989
|
"<form class=\"order-action\" method=\"post\" action=\"/orders/" + esc(String(o.id)) + "/cancel\">" +
|
|
@@ -6641,6 +7247,7 @@ var ACCOUNT_DASH_PAGE =
|
|
|
6641
7247
|
" <a class=\"btn-secondary\" href=\"/account/recently-viewed\">Recently viewed</a>\n" +
|
|
6642
7248
|
" <a class=\"btn-secondary\" href=\"/account/addresses\">Addresses</a>\n" +
|
|
6643
7249
|
" <a class=\"btn-secondary\" href=\"/account/returns\">Returns</a>\n" +
|
|
7250
|
+
" <a class=\"btn-secondary\" href=\"/account/exchanges\">Exchanges</a>\n" +
|
|
6644
7251
|
" <a class=\"btn-secondary\" href=\"/account/support\">Support</a>\n" +
|
|
6645
7252
|
" <a class=\"btn-secondary\" href=\"/account/loyalty\">Rewards</a>\n" +
|
|
6646
7253
|
" <a class=\"btn-secondary\" href=\"/account/credit\">Store credit</a>\n" +
|
|
@@ -11050,25 +11657,554 @@ function mount(router, deps) {
|
|
|
11050
11657
|
});
|
|
11051
11658
|
}
|
|
11052
11659
|
|
|
11053
|
-
//
|
|
11054
|
-
//
|
|
11055
|
-
|
|
11056
|
-
|
|
11660
|
+
// ---- gift registry --------------------------------------------------
|
|
11661
|
+
//
|
|
11662
|
+
// Owner side (gated on the session customer, CSRF-protected by the
|
|
11663
|
+
// container form chokepoint): list the customer's registries with
|
|
11664
|
+
// progress, create one, then a per-registry manage page (add / remove
|
|
11665
|
+
// items, edit details, close) plus the shareable public URL. Every owner
|
|
11666
|
+
// route is scoped to the session customer — a registry whose
|
|
11667
|
+
// `owner_customer_id` isn't the signed-in customer resolves to a clean
|
|
11668
|
+
// 404 (no IDOR), because the gift-registry primitive moves a registry by
|
|
11669
|
+
// slug alone and has no notion of the requesting customer.
|
|
11670
|
+
//
|
|
11671
|
+
// Public side (NO auth): `GET /registry/:slug` resolves the registry ONLY
|
|
11672
|
+
// through `getBySlug(slug)` — never a guessable owner/registry id from the
|
|
11673
|
+
// path or query — and the route honors the privacy gate: a `private`
|
|
11674
|
+
// registry (and an unknown slug) 404s identically, with no existence
|
|
11675
|
+
// oracle. The view renders the title / occasion /
|
|
11676
|
+
// event date + per-item desired-vs-purchased counts + product links, and
|
|
11677
|
+
// NEVER carries the owner's customer id / shipping address or any buyer
|
|
11678
|
+
// identity into the page (the primitive's getBySlug surfaces items +
|
|
11679
|
+
// aggregate counts only; the per-buyer purchase rows stay owner-internal).
|
|
11680
|
+
// A giver marks an item purchased (records a gift via purchaseItem,
|
|
11681
|
+
// anonymous by default) or adds it to their own cart to buy normally.
|
|
11682
|
+
// noindex (a personal registry isn't index material).
|
|
11683
|
+
if (deps.giftRegistry) {
|
|
11684
|
+
// Load a registry the SESSION customer owns, or null. The primitive's
|
|
11685
|
+
// getRegistry moves by slug alone, so the route owns the ownership
|
|
11686
|
+
// decision: a registry whose owner_customer_id isn't the session
|
|
11687
|
+
// customer is treated identically to a missing one (clean 404, no
|
|
11688
|
+
// oracle on existence). A malformed slug throws inside the primitive's
|
|
11689
|
+
// slug validator — caught by the caller and mapped to a 404.
|
|
11690
|
+
async function _ownedRegistry(slug, customerId) {
|
|
11691
|
+
var reg;
|
|
11692
|
+
try { reg = await deps.giftRegistry.getRegistry(slug); }
|
|
11693
|
+
catch (_e) { return null; } // malformed slug → not found
|
|
11694
|
+
if (!reg || reg.owner_customer_id !== customerId) return null;
|
|
11695
|
+
return reg;
|
|
11696
|
+
}
|
|
11697
|
+
|
|
11698
|
+
// Resolve a registry item's sku to its catalog product + hero image +
|
|
11699
|
+
// buyable variant id. Returns { product, hero_media, variant_id } —
|
|
11700
|
+
// any field null when the sku no longer resolves (the item still shows,
|
|
11701
|
+
// with no buy control). Best-effort: a catalog read failure degrades to
|
|
11702
|
+
// an undecorated item rather than throwing.
|
|
11703
|
+
async function _decorateRegistryItem(item) {
|
|
11704
|
+
var out = {
|
|
11705
|
+
item_id: item.id,
|
|
11706
|
+
sku: item.sku,
|
|
11707
|
+
variant_id: null,
|
|
11708
|
+
product: null,
|
|
11709
|
+
hero_media: null,
|
|
11710
|
+
};
|
|
11711
|
+
try {
|
|
11712
|
+
var variant = await deps.catalog.variants.bySku(item.sku);
|
|
11713
|
+
if (variant) {
|
|
11714
|
+
out.variant_id = variant.id;
|
|
11715
|
+
var product = await deps.catalog.products.get(variant.product_id);
|
|
11716
|
+
if (product && product.status === "active") {
|
|
11717
|
+
out.product = product;
|
|
11718
|
+
var media = await deps.catalog.media.listForProduct(product.id);
|
|
11719
|
+
out.hero_media = media.length ? media[0] : null;
|
|
11720
|
+
}
|
|
11721
|
+
}
|
|
11722
|
+
} catch (_e) { /* drop-silent — an undecorated item still renders */ }
|
|
11723
|
+
return out;
|
|
11724
|
+
}
|
|
11725
|
+
|
|
11726
|
+
// GET /account/registry — the session customer's registries, each with
|
|
11727
|
+
// its progress rollup, plus the create form.
|
|
11728
|
+
router.get("/account/registry", async function (req, res) {
|
|
11057
11729
|
var auth;
|
|
11058
11730
|
try { auth = _currentCustomer(req); }
|
|
11059
11731
|
catch (e) {
|
|
11060
|
-
if (e && e.code === "vault/not-initialized")
|
|
11732
|
+
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
11061
11733
|
throw e;
|
|
11062
11734
|
}
|
|
11063
11735
|
if (!auth) {
|
|
11064
11736
|
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
11065
|
-
res.end ? res.end() : res.send("");
|
|
11066
|
-
return null;
|
|
11737
|
+
return res.end ? res.end() : res.send("");
|
|
11067
11738
|
}
|
|
11068
|
-
|
|
11069
|
-
|
|
11070
|
-
|
|
11071
|
-
|
|
11739
|
+
var regs = [];
|
|
11740
|
+
try { regs = await deps.giftRegistry.listForOwner(auth.customer_id); }
|
|
11741
|
+
catch (_e) { regs = []; }
|
|
11742
|
+
var rows = [];
|
|
11743
|
+
for (var i = 0; i < regs.length; i += 1) {
|
|
11744
|
+
var prog = null;
|
|
11745
|
+
try { prog = await deps.giftRegistry.progressFor(regs[i].slug); }
|
|
11746
|
+
catch (_e) { prog = null; }
|
|
11747
|
+
rows.push({ registry: regs[i], progress: prog });
|
|
11748
|
+
}
|
|
11749
|
+
var listUrl = req.url ? new URL(req.url, "http://localhost") : null;
|
|
11750
|
+
_send(res, 200, renderRegistryList({
|
|
11751
|
+
rows: rows,
|
|
11752
|
+
notice: listUrl ? listUrl.searchParams.get("ok") : null,
|
|
11753
|
+
shop_name: shopName,
|
|
11754
|
+
cart_count: await _cartCountForReq(req),
|
|
11755
|
+
asset_prefix: deps.asset_prefix || "/assets/",
|
|
11756
|
+
}));
|
|
11757
|
+
});
|
|
11758
|
+
|
|
11759
|
+
// POST /account/registry — create a registry owned by the session
|
|
11760
|
+
// customer. The owner id comes from the session, never the body, so a
|
|
11761
|
+
// shopper can only ever create a registry under their own id.
|
|
11762
|
+
router.post("/account/registry", async function (req, res) {
|
|
11763
|
+
var auth;
|
|
11764
|
+
try { auth = _currentCustomer(req); }
|
|
11765
|
+
catch (e) {
|
|
11766
|
+
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
11767
|
+
throw e;
|
|
11768
|
+
}
|
|
11769
|
+
if (!auth) {
|
|
11770
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
11771
|
+
return res.end ? res.end() : res.send("");
|
|
11772
|
+
}
|
|
11773
|
+
var body = req.body || {};
|
|
11774
|
+
// event_date arrives as a yyyy-mm-dd string from <input type=date>;
|
|
11775
|
+
// convert to epoch-ms (midnight UTC) or null. A malformed value
|
|
11776
|
+
// becomes null rather than 400-ing — the date is optional.
|
|
11777
|
+
var eventDate = null;
|
|
11778
|
+
if (typeof body.event_date === "string" && body.event_date.length) {
|
|
11779
|
+
var parsed = Date.parse(body.event_date + "T00:00:00Z");
|
|
11780
|
+
if (!isNaN(parsed)) eventDate = parsed;
|
|
11781
|
+
}
|
|
11782
|
+
try {
|
|
11783
|
+
await deps.giftRegistry.createRegistry({
|
|
11784
|
+
owner_customer_id: auth.customer_id,
|
|
11785
|
+
slug: String(body.slug || ""),
|
|
11786
|
+
title: String(body.title || ""),
|
|
11787
|
+
recipient_name: String(body.recipient_name || ""),
|
|
11788
|
+
occasion: String(body.occasion || ""),
|
|
11789
|
+
privacy: String(body.privacy || ""),
|
|
11790
|
+
event_date: eventDate,
|
|
11791
|
+
});
|
|
11792
|
+
} catch (e) {
|
|
11793
|
+
// A bad slug / title / occasion / duplicate slug is operator error
|
|
11794
|
+
// (TypeError) — re-render the list with the message; anything else
|
|
11795
|
+
// is a 500.
|
|
11796
|
+
if (e instanceof TypeError) {
|
|
11797
|
+
var regs = [];
|
|
11798
|
+
try { regs = await deps.giftRegistry.listForOwner(auth.customer_id); }
|
|
11799
|
+
catch (_e2) { regs = []; }
|
|
11800
|
+
var rows = [];
|
|
11801
|
+
for (var i = 0; i < regs.length; i += 1) rows.push({ registry: regs[i], progress: null });
|
|
11802
|
+
return _send(res, 400, renderRegistryList({
|
|
11803
|
+
rows: rows,
|
|
11804
|
+
error: (e && e.message) || "We couldn't create that registry.",
|
|
11805
|
+
shop_name: shopName,
|
|
11806
|
+
cart_count: await _cartCountForReq(req),
|
|
11807
|
+
asset_prefix: deps.asset_prefix || "/assets/",
|
|
11808
|
+
}));
|
|
11809
|
+
}
|
|
11810
|
+
res.status(500);
|
|
11811
|
+
return res.end ? res.end("Error") : res.send("Error");
|
|
11812
|
+
}
|
|
11813
|
+
res.status(303);
|
|
11814
|
+
res.setHeader && res.setHeader("location", "/account/registry/" + encodeURIComponent(String(body.slug || "")) + "?ok=created");
|
|
11815
|
+
return res.end ? res.end() : res.send("");
|
|
11816
|
+
});
|
|
11817
|
+
|
|
11818
|
+
// GET /account/registry/:slug — manage one registry the session
|
|
11819
|
+
// customer owns. A registry the customer doesn't own (or an unknown
|
|
11820
|
+
// slug) 404s.
|
|
11821
|
+
router.get("/account/registry/:slug", async function (req, res) {
|
|
11822
|
+
var auth;
|
|
11823
|
+
try { auth = _currentCustomer(req); }
|
|
11824
|
+
catch (e) {
|
|
11825
|
+
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
11826
|
+
throw e;
|
|
11827
|
+
}
|
|
11828
|
+
if (!auth) {
|
|
11829
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
11830
|
+
return res.end ? res.end() : res.send("");
|
|
11831
|
+
}
|
|
11832
|
+
var slug = (req.params && req.params.slug) || "";
|
|
11833
|
+
var reg = await _ownedRegistry(slug, auth.customer_id);
|
|
11834
|
+
if (!reg) {
|
|
11835
|
+
return _send(res, 404, renderNotFound({ shop_name: shopName, cart_count: await _cartCountForReq(req) }));
|
|
11836
|
+
}
|
|
11837
|
+
// Items + their progress. getBySlug returns items with their
|
|
11838
|
+
// purchased-aggregate; progressFor gives the remaining count.
|
|
11839
|
+
var view = null;
|
|
11840
|
+
try { view = await deps.giftRegistry.getBySlug(slug); }
|
|
11841
|
+
catch (_e) { view = null; }
|
|
11842
|
+
var prog = null;
|
|
11843
|
+
try { prog = await deps.giftRegistry.progressFor(slug); }
|
|
11844
|
+
catch (_e) { prog = null; }
|
|
11845
|
+
var remainingById = Object.create(null);
|
|
11846
|
+
var purchasedById = Object.create(null);
|
|
11847
|
+
var desiredById = Object.create(null);
|
|
11848
|
+
if (prog && prog.items) {
|
|
11849
|
+
for (var p = 0; p < prog.items.length; p += 1) {
|
|
11850
|
+
remainingById[prog.items[p].item_id] = prog.items[p].remaining;
|
|
11851
|
+
purchasedById[prog.items[p].item_id] = prog.items[p].purchased;
|
|
11852
|
+
desiredById[prog.items[p].item_id] = prog.items[p].quantity_desired;
|
|
11853
|
+
}
|
|
11854
|
+
}
|
|
11855
|
+
var items = [];
|
|
11856
|
+
var rawItems = (view && view.items) || [];
|
|
11857
|
+
for (var i = 0; i < rawItems.length; i += 1) {
|
|
11858
|
+
var dec = await _decorateRegistryItem(rawItems[i]);
|
|
11859
|
+
dec.desired = desiredById[rawItems[i].id] != null ? desiredById[rawItems[i].id] : rawItems[i].quantity_desired;
|
|
11860
|
+
dec.purchased = purchasedById[rawItems[i].id] || 0;
|
|
11861
|
+
dec.remaining = remainingById[rawItems[i].id] != null ? remainingById[rawItems[i].id] : dec.desired;
|
|
11862
|
+
items.push(dec);
|
|
11863
|
+
}
|
|
11864
|
+
// The shareable public URL — built from this request's ORIGIN (scheme
|
|
11865
|
+
// + host), never by trimming a path off the canonical (this GET lands
|
|
11866
|
+
// on /account/registry/:slug, so a trim would mangle the link).
|
|
11867
|
+
var shareUrl = "";
|
|
11868
|
+
if (reg.privacy !== "private") {
|
|
11869
|
+
var origin = "";
|
|
11870
|
+
try { origin = new URL(_requestUrls(req).canonical_url).origin; }
|
|
11871
|
+
catch (_e) { origin = ""; }
|
|
11872
|
+
shareUrl = origin + "/registry/" + encodeURIComponent(reg.slug);
|
|
11873
|
+
}
|
|
11874
|
+
var manageUrl = req.url ? new URL(req.url, "http://localhost") : null;
|
|
11875
|
+
_send(res, 200, renderRegistryManage({
|
|
11876
|
+
registry: reg,
|
|
11877
|
+
items: items,
|
|
11878
|
+
share_url: shareUrl,
|
|
11879
|
+
notice: manageUrl ? manageUrl.searchParams.get("ok") : null,
|
|
11880
|
+
shop_name: shopName,
|
|
11881
|
+
cart_count: await _cartCountForReq(req),
|
|
11882
|
+
asset_prefix: deps.asset_prefix || "/assets/",
|
|
11883
|
+
}));
|
|
11884
|
+
});
|
|
11885
|
+
|
|
11886
|
+
// POST /account/registry/:slug/items — add an item to a registry the
|
|
11887
|
+
// session customer owns. Ownership-scoped (404 on a foreign / unknown
|
|
11888
|
+
// registry).
|
|
11889
|
+
router.post("/account/registry/:slug/items", async function (req, res) {
|
|
11890
|
+
var auth = _registryAuthOrRedirect(req, res);
|
|
11891
|
+
if (!auth) return;
|
|
11892
|
+
var slug = (req.params && req.params.slug) || "";
|
|
11893
|
+
var reg = await _ownedRegistry(slug, auth.customer_id);
|
|
11894
|
+
if (!reg) {
|
|
11895
|
+
return _send(res, 404, renderNotFound({ shop_name: shopName, cart_count: await _cartCountForReq(req) }));
|
|
11896
|
+
}
|
|
11897
|
+
var body = req.body || {};
|
|
11898
|
+
var qty = parseInt(body.quantity_desired, 10);
|
|
11899
|
+
var priorityRaw = parseInt(body.priority, 10);
|
|
11900
|
+
try {
|
|
11901
|
+
await deps.giftRegistry.addItem({
|
|
11902
|
+
registry_slug: slug,
|
|
11903
|
+
sku: String(body.sku || ""),
|
|
11904
|
+
quantity_desired: Number.isFinite(qty) ? qty : 0,
|
|
11905
|
+
priority: Number.isFinite(priorityRaw) ? priorityRaw : 3,
|
|
11906
|
+
});
|
|
11907
|
+
} catch (e) {
|
|
11908
|
+
return _registryManageError(req, res, reg, e);
|
|
11909
|
+
}
|
|
11910
|
+
res.status(303);
|
|
11911
|
+
res.setHeader && res.setHeader("location", "/account/registry/" + encodeURIComponent(slug) + "?ok=added");
|
|
11912
|
+
return res.end ? res.end() : res.send("");
|
|
11913
|
+
});
|
|
11914
|
+
|
|
11915
|
+
// POST /account/registry/:slug/items/:item_id/remove — archive an item
|
|
11916
|
+
// on a registry the session customer owns.
|
|
11917
|
+
router.post("/account/registry/:slug/items/:item_id/remove", async function (req, res) {
|
|
11918
|
+
var auth = _registryAuthOrRedirect(req, res);
|
|
11919
|
+
if (!auth) return;
|
|
11920
|
+
var slug = (req.params && req.params.slug) || "";
|
|
11921
|
+
var reg = await _ownedRegistry(slug, auth.customer_id);
|
|
11922
|
+
if (!reg) {
|
|
11923
|
+
return _send(res, 404, renderNotFound({ shop_name: shopName, cart_count: await _cartCountForReq(req) }));
|
|
11924
|
+
}
|
|
11925
|
+
try {
|
|
11926
|
+
await deps.giftRegistry.removeItem({
|
|
11927
|
+
registry_slug: slug,
|
|
11928
|
+
item_id: (req.params && req.params.item_id) || "",
|
|
11929
|
+
});
|
|
11930
|
+
} catch (e) {
|
|
11931
|
+
return _registryManageError(req, res, reg, e);
|
|
11932
|
+
}
|
|
11933
|
+
res.status(303);
|
|
11934
|
+
res.setHeader && res.setHeader("location", "/account/registry/" + encodeURIComponent(slug) + "?ok=removed");
|
|
11935
|
+
return res.end ? res.end() : res.send("");
|
|
11936
|
+
});
|
|
11937
|
+
|
|
11938
|
+
// POST /account/registry/:slug/edit — update registry details
|
|
11939
|
+
// (title / recipient_name / event_date / privacy) on a registry the
|
|
11940
|
+
// session customer owns.
|
|
11941
|
+
router.post("/account/registry/:slug/edit", async function (req, res) {
|
|
11942
|
+
var auth = _registryAuthOrRedirect(req, res);
|
|
11943
|
+
if (!auth) return;
|
|
11944
|
+
var slug = (req.params && req.params.slug) || "";
|
|
11945
|
+
var reg = await _ownedRegistry(slug, auth.customer_id);
|
|
11946
|
+
if (!reg) {
|
|
11947
|
+
return _send(res, 404, renderNotFound({ shop_name: shopName, cart_count: await _cartCountForReq(req) }));
|
|
11948
|
+
}
|
|
11949
|
+
var body = req.body || {};
|
|
11950
|
+
var patch = {
|
|
11951
|
+
title: String(body.title || ""),
|
|
11952
|
+
recipient_name: String(body.recipient_name || ""),
|
|
11953
|
+
privacy: String(body.privacy || ""),
|
|
11954
|
+
};
|
|
11955
|
+
if (typeof body.event_date === "string" && body.event_date.length) {
|
|
11956
|
+
var parsed = Date.parse(body.event_date + "T00:00:00Z");
|
|
11957
|
+
patch.event_date = isNaN(parsed) ? null : parsed;
|
|
11958
|
+
} else {
|
|
11959
|
+
patch.event_date = null;
|
|
11960
|
+
}
|
|
11961
|
+
try {
|
|
11962
|
+
await deps.giftRegistry.update(slug, patch);
|
|
11963
|
+
} catch (e) {
|
|
11964
|
+
return _registryManageError(req, res, reg, e);
|
|
11965
|
+
}
|
|
11966
|
+
res.status(303);
|
|
11967
|
+
res.setHeader && res.setHeader("location", "/account/registry/" + encodeURIComponent(slug) + "?ok=updated");
|
|
11968
|
+
return res.end ? res.end() : res.send("");
|
|
11969
|
+
});
|
|
11970
|
+
|
|
11971
|
+
// POST /account/registry/:slug/close — close a registry the session
|
|
11972
|
+
// customer owns (the only FSM transition; refuses further mutation).
|
|
11973
|
+
router.post("/account/registry/:slug/close", async function (req, res) {
|
|
11974
|
+
var auth = _registryAuthOrRedirect(req, res);
|
|
11975
|
+
if (!auth) return;
|
|
11976
|
+
var slug = (req.params && req.params.slug) || "";
|
|
11977
|
+
var reg = await _ownedRegistry(slug, auth.customer_id);
|
|
11978
|
+
if (!reg) {
|
|
11979
|
+
return _send(res, 404, renderNotFound({ shop_name: shopName, cart_count: await _cartCountForReq(req) }));
|
|
11980
|
+
}
|
|
11981
|
+
try {
|
|
11982
|
+
await deps.giftRegistry.closeRegistry(slug);
|
|
11983
|
+
} catch (e) {
|
|
11984
|
+
return _registryManageError(req, res, reg, e);
|
|
11985
|
+
}
|
|
11986
|
+
res.status(303);
|
|
11987
|
+
res.setHeader && res.setHeader("location", "/account/registry/" + encodeURIComponent(slug) + "?ok=closed");
|
|
11988
|
+
return res.end ? res.end() : res.send("");
|
|
11989
|
+
});
|
|
11990
|
+
|
|
11991
|
+
// GET /registry/:slug — the public, no-auth giver view. Resolves the
|
|
11992
|
+
// registry ONLY through getBySlug (never a guessable owner/registry id),
|
|
11993
|
+
// then enforces the privacy gate in the route: a `private` registry
|
|
11994
|
+
// 404s exactly like an unknown slug — no existence oracle. The owner's
|
|
11995
|
+
// identity / shipping address / per-buyer purchase rows are NEVER
|
|
11996
|
+
// carried into this shape. noindex.
|
|
11997
|
+
router.get("/registry/:slug", async function (req, res) {
|
|
11998
|
+
var slug = (req.params && req.params.slug) || "";
|
|
11999
|
+
var view = null;
|
|
12000
|
+
try {
|
|
12001
|
+
view = await deps.giftRegistry.getBySlug(slug);
|
|
12002
|
+
} catch (_e) {
|
|
12003
|
+
// A malformed slug (TypeError) renders the same 404 as an unknown
|
|
12004
|
+
// one — no oracle on why.
|
|
12005
|
+
return _send(res, 404, renderNotFound({ shop_name: shopName, cart_count: await _cartCountForReq(req) }));
|
|
12006
|
+
}
|
|
12007
|
+
// Honor the privacy gate: a missing registry AND a `private` one are
|
|
12008
|
+
// not publicly viewable — both 404 identically (no existence oracle).
|
|
12009
|
+
// `private` resolves only through the owner's /account/registry surface
|
|
12010
|
+
// (scoped to the session customer); `unlisted` + `public` are reachable
|
|
12011
|
+
// by anyone who knows the slug.
|
|
12012
|
+
if (!view || !view.registry || view.registry.privacy === "private") {
|
|
12013
|
+
return _send(res, 404, renderNotFound({ shop_name: shopName, cart_count: await _cartCountForReq(req) }));
|
|
12014
|
+
}
|
|
12015
|
+
var prog = null;
|
|
12016
|
+
try { prog = await deps.giftRegistry.progressFor(slug); }
|
|
12017
|
+
catch (_e) { prog = null; }
|
|
12018
|
+
var remainingById = Object.create(null);
|
|
12019
|
+
var purchasedById = Object.create(null);
|
|
12020
|
+
var desiredById = Object.create(null);
|
|
12021
|
+
if (prog && prog.items) {
|
|
12022
|
+
for (var p = 0; p < prog.items.length; p += 1) {
|
|
12023
|
+
remainingById[prog.items[p].item_id] = prog.items[p].remaining;
|
|
12024
|
+
purchasedById[prog.items[p].item_id] = prog.items[p].purchased;
|
|
12025
|
+
desiredById[prog.items[p].item_id] = prog.items[p].quantity_desired;
|
|
12026
|
+
}
|
|
12027
|
+
}
|
|
12028
|
+
var items = [];
|
|
12029
|
+
var rawItems = view.items || [];
|
|
12030
|
+
for (var i = 0; i < rawItems.length; i += 1) {
|
|
12031
|
+
var dec = await _decorateRegistryItem(rawItems[i]);
|
|
12032
|
+
dec.desired = desiredById[rawItems[i].id] != null ? desiredById[rawItems[i].id] : rawItems[i].quantity_desired;
|
|
12033
|
+
dec.purchased = purchasedById[rawItems[i].id] || 0;
|
|
12034
|
+
dec.remaining = remainingById[rawItems[i].id] != null ? remainingById[rawItems[i].id] : dec.desired;
|
|
12035
|
+
items.push(dec);
|
|
12036
|
+
}
|
|
12037
|
+
var pubUrl = req.url ? new URL(req.url, "http://localhost") : null;
|
|
12038
|
+
// Build a redacted, public-safe registry view — only the fields a
|
|
12039
|
+
// giver may see (title / occasion / event_date / message / privacy /
|
|
12040
|
+
// status). The owner_customer_id + shipping_address_id are dropped
|
|
12041
|
+
// here so they can NEVER reach the rendered HTML.
|
|
12042
|
+
var reg = view.registry;
|
|
12043
|
+
var publicReg = {
|
|
12044
|
+
slug: reg.slug,
|
|
12045
|
+
title: reg.title,
|
|
12046
|
+
occasion: reg.occasion,
|
|
12047
|
+
event_date: reg.event_date,
|
|
12048
|
+
message: reg.message,
|
|
12049
|
+
privacy: reg.privacy,
|
|
12050
|
+
status: reg.status,
|
|
12051
|
+
};
|
|
12052
|
+
_send(res, 200, renderRegistryPublic({
|
|
12053
|
+
registry: publicReg,
|
|
12054
|
+
items: items,
|
|
12055
|
+
notice: pubUrl ? pubUrl.searchParams.get("ok") : null,
|
|
12056
|
+
shop_name: shopName,
|
|
12057
|
+
cart_count: await _cartCountForReq(req),
|
|
12058
|
+
asset_prefix: deps.asset_prefix || "/assets/",
|
|
12059
|
+
}));
|
|
12060
|
+
});
|
|
12061
|
+
|
|
12062
|
+
// POST /registry/:slug/items/:item_id/purchase — a giver records a gift
|
|
12063
|
+
// (marks the item purchased). This is the mark-purchased / record-a-gift
|
|
12064
|
+
// action the primitive's purchaseItem implements: it decrements the
|
|
12065
|
+
// remaining-needed qty and tracks who bought it (anonymous by default).
|
|
12066
|
+
// It carries NO payment — a giver who wants to actually buy the item
|
|
12067
|
+
// adds it to their cart (the per-item "Add to cart" form posts to
|
|
12068
|
+
// /cart/lines) and pays through the regular Stripe-gated checkout. A
|
|
12069
|
+
// dedicated registry-funds settlement (charge the giver, route the money
|
|
12070
|
+
// to the owner) is intentionally not wired: the normal cart/checkout path
|
|
12071
|
+
// already lets a giver buy a registry item end-to-end, so it only lands
|
|
12072
|
+
// if an operator needs registry-scoped fulfillment (ship-to-owner +
|
|
12073
|
+
// gift-message capture) the standard order flow doesn't cover. The action
|
|
12074
|
+
// is slug+item gated to the registry, NOT customer-scoped (a giver need
|
|
12075
|
+
// not be logged in); it is shape-safe — a closed registry, an
|
|
12076
|
+
// over-purchase, an unknown item, or a malformed slug all resolve to a
|
|
12077
|
+
// clean redirect
|
|
12078
|
+
// / 404, never a 500.
|
|
12079
|
+
router.post("/registry/:slug/items/:item_id/purchase", async function (req, res) {
|
|
12080
|
+
var slug = (req.params && req.params.slug) || "";
|
|
12081
|
+
var itemId = (req.params && req.params.item_id) || "";
|
|
12082
|
+
// Resolve through getBySlug so a private / unknown registry is never
|
|
12083
|
+
// purchasable (the same privacy gate the GET view uses) — a giver can
|
|
12084
|
+
// only act on a registry they could legitimately reach by link.
|
|
12085
|
+
var view = null;
|
|
12086
|
+
try { view = await deps.giftRegistry.getBySlug(slug); }
|
|
12087
|
+
catch (_e) { view = null; }
|
|
12088
|
+
// Same privacy gate as the GET view — a private (or unknown) registry
|
|
12089
|
+
// is never publicly purchasable.
|
|
12090
|
+
if (!view || !view.registry || view.registry.privacy === "private") {
|
|
12091
|
+
return _send(res, 404, renderNotFound({ shop_name: shopName, cart_count: await _cartCountForReq(req) }));
|
|
12092
|
+
}
|
|
12093
|
+
var body = req.body || {};
|
|
12094
|
+
var qty = parseInt(body.quantity, 10);
|
|
12095
|
+
if (!Number.isFinite(qty) || qty < 1) qty = 1;
|
|
12096
|
+
// `reveal` requires a signed-in giver — an anonymous reveal makes no
|
|
12097
|
+
// sense (there's no identity to surface). When the box is ticked and
|
|
12098
|
+
// the giver is logged in, attribute the gift to them; otherwise the
|
|
12099
|
+
// purchase is anonymous.
|
|
12100
|
+
var reveal = (body.reveal === "1" || body.reveal === "on" || body.reveal === true);
|
|
12101
|
+
var buyerId = null;
|
|
12102
|
+
if (reveal) {
|
|
12103
|
+
var giverAuth = null;
|
|
12104
|
+
try { giverAuth = _currentCustomer(req); } catch (_e) { giverAuth = null; }
|
|
12105
|
+
if (giverAuth && giverAuth.customer_id) buyerId = giverAuth.customer_id;
|
|
12106
|
+
else reveal = false; // not signed in → fall back to anonymous
|
|
12107
|
+
}
|
|
12108
|
+
var purchaseInput = {
|
|
12109
|
+
registry_slug: slug,
|
|
12110
|
+
item_id: itemId,
|
|
12111
|
+
quantity: qty,
|
|
12112
|
+
reveal_buyer: reveal,
|
|
12113
|
+
};
|
|
12114
|
+
if (buyerId) purchaseInput.buyer_customer_id = buyerId;
|
|
12115
|
+
try {
|
|
12116
|
+
await deps.giftRegistry.purchaseItem(purchaseInput);
|
|
12117
|
+
} catch (_e) {
|
|
12118
|
+
// A closed registry, an over-purchase, an archived / unknown item,
|
|
12119
|
+
// or a malformed slug/item id are all giver-recoverable: bounce
|
|
12120
|
+
// back to the public registry page (no item landed) rather than
|
|
12121
|
+
// 500. A truly unexpected error (non-TypeError, uncoded) still
|
|
12122
|
+
// surfaces as a redirect — the page re-render shows current state.
|
|
12123
|
+
res.status(303);
|
|
12124
|
+
res.setHeader && res.setHeader("location", "/registry/" + encodeURIComponent(slug));
|
|
12125
|
+
return res.end ? res.end() : res.send("");
|
|
12126
|
+
}
|
|
12127
|
+
res.status(303);
|
|
12128
|
+
res.setHeader && res.setHeader("location", "/registry/" + encodeURIComponent(slug) + "?ok=gifted");
|
|
12129
|
+
return res.end ? res.end() : res.send("");
|
|
12130
|
+
});
|
|
12131
|
+
|
|
12132
|
+
// Shared owner-route auth gate: resolve the session customer or send the
|
|
12133
|
+
// redirect / 503 and return null. Mirrors the wishlist `_savedAuth`
|
|
12134
|
+
// shape so every owner registry write funnels through one check.
|
|
12135
|
+
function _registryAuthOrRedirect(req, res) {
|
|
12136
|
+
var auth;
|
|
12137
|
+
try { auth = _currentCustomer(req); }
|
|
12138
|
+
catch (e) {
|
|
12139
|
+
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
12140
|
+
throw e;
|
|
12141
|
+
}
|
|
12142
|
+
if (!auth) {
|
|
12143
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
12144
|
+
res.end ? res.end() : res.send("");
|
|
12145
|
+
return null;
|
|
12146
|
+
}
|
|
12147
|
+
return auth;
|
|
12148
|
+
}
|
|
12149
|
+
|
|
12150
|
+
// Re-render the manage page with an inline error after a refused owner
|
|
12151
|
+
// mutation (a bad sku, a duplicate item, a closed-registry write). A
|
|
12152
|
+
// TypeError / coded over-purchase is operator-recoverable → 400 with the
|
|
12153
|
+
// message; anything else is a 500.
|
|
12154
|
+
async function _registryManageError(req, res, reg, e) {
|
|
12155
|
+
var status = (e instanceof TypeError || (e && e.code === "GIFT_REGISTRY_OVER_PURCHASE")) ? 400 : 500;
|
|
12156
|
+
// Reload current items so the error page shows live state.
|
|
12157
|
+
var items = [];
|
|
12158
|
+
try {
|
|
12159
|
+
var view = await deps.giftRegistry.getBySlug(reg.slug);
|
|
12160
|
+
var prog = await deps.giftRegistry.progressFor(reg.slug);
|
|
12161
|
+
var remById = Object.create(null), purById = Object.create(null), desById = Object.create(null);
|
|
12162
|
+
if (prog && prog.items) {
|
|
12163
|
+
for (var p = 0; p < prog.items.length; p += 1) {
|
|
12164
|
+
remById[prog.items[p].item_id] = prog.items[p].remaining;
|
|
12165
|
+
purById[prog.items[p].item_id] = prog.items[p].purchased;
|
|
12166
|
+
desById[prog.items[p].item_id] = prog.items[p].quantity_desired;
|
|
12167
|
+
}
|
|
12168
|
+
}
|
|
12169
|
+
var rawItems = (view && view.items) || [];
|
|
12170
|
+
for (var i = 0; i < rawItems.length; i += 1) {
|
|
12171
|
+
var dec = await _decorateRegistryItem(rawItems[i]);
|
|
12172
|
+
dec.desired = desById[rawItems[i].id] != null ? desById[rawItems[i].id] : rawItems[i].quantity_desired;
|
|
12173
|
+
dec.purchased = purById[rawItems[i].id] || 0;
|
|
12174
|
+
dec.remaining = remById[rawItems[i].id] != null ? remById[rawItems[i].id] : dec.desired;
|
|
12175
|
+
items.push(dec);
|
|
12176
|
+
}
|
|
12177
|
+
} catch (_e) { items = []; }
|
|
12178
|
+
return _send(res, status, renderRegistryManage({
|
|
12179
|
+
registry: reg,
|
|
12180
|
+
items: items,
|
|
12181
|
+
error: status === 400 ? ((e && e.message) || "We couldn't make that change.") : "Something went wrong. Please try again.",
|
|
12182
|
+
shop_name: shopName,
|
|
12183
|
+
cart_count: await _cartCountForReq(req),
|
|
12184
|
+
asset_prefix: deps.asset_prefix || "/assets/",
|
|
12185
|
+
}));
|
|
12186
|
+
}
|
|
12187
|
+
}
|
|
12188
|
+
|
|
12189
|
+
// Save for later — move a cart line into a per-customer holding
|
|
12190
|
+
// list and back. Login required (the list is per-customer).
|
|
12191
|
+
if (deps.saveForLater) {
|
|
12192
|
+
function _savedAuth(req, res) {
|
|
12193
|
+
var auth;
|
|
12194
|
+
try { auth = _currentCustomer(req); }
|
|
12195
|
+
catch (e) {
|
|
12196
|
+
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
12197
|
+
throw e;
|
|
12198
|
+
}
|
|
12199
|
+
if (!auth) {
|
|
12200
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
12201
|
+
res.end ? res.end() : res.send("");
|
|
12202
|
+
return null;
|
|
12203
|
+
}
|
|
12204
|
+
return auth;
|
|
12205
|
+
}
|
|
12206
|
+
|
|
12207
|
+
// POST /cart/lines/:line_id/save — move the line out of the cart
|
|
11072
12208
|
// into the customer's saved list. Redirects back to /cart.
|
|
11073
12209
|
router.post("/cart/lines/:line_id/save", async function (req, res) {
|
|
11074
12210
|
var auth = _savedAuth(req, res);
|
|
@@ -11769,6 +12905,240 @@ function mount(router, deps) {
|
|
|
11769
12905
|
});
|
|
11770
12906
|
}
|
|
11771
12907
|
|
|
12908
|
+
// Self-serve exchanges — a customer requests a same-value item SWAP
|
|
12909
|
+
// (different size / colour / unit) against one of their own orders and
|
|
12910
|
+
// tracks its status; operators action it via the admin /admin/exchanges
|
|
12911
|
+
// queue. Distinct from returns (which end in a refund): an exchange
|
|
12912
|
+
// ships the original back AND a replacement out, no money refunded.
|
|
12913
|
+
//
|
|
12914
|
+
// Ownership / IDOR: an order_exchanges row carries `order_id` but NOT
|
|
12915
|
+
// `customer_id` — the customer→order linkage lives on the order. So
|
|
12916
|
+
// every per-exchange / per-order route here loads the parent order and
|
|
12917
|
+
// refuses (clean 404) unless `order.customer_id === auth.customer_id`,
|
|
12918
|
+
// exactly like the returns + support gates. The exchange primitive
|
|
12919
|
+
// moves a row by id alone, so the route owns the ownership decision.
|
|
12920
|
+
if (deps.orderExchanges && deps.order) {
|
|
12921
|
+
function _exchangeAuth(req, res) {
|
|
12922
|
+
var auth;
|
|
12923
|
+
try { auth = _currentCustomer(req); }
|
|
12924
|
+
catch (e) {
|
|
12925
|
+
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
12926
|
+
throw e;
|
|
12927
|
+
}
|
|
12928
|
+
if (!auth) {
|
|
12929
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
12930
|
+
res.end ? res.end() : res.send("");
|
|
12931
|
+
return null;
|
|
12932
|
+
}
|
|
12933
|
+
return auth;
|
|
12934
|
+
}
|
|
12935
|
+
|
|
12936
|
+
// Load the order named in :order_id and confirm it belongs to the
|
|
12937
|
+
// signed-in customer. A malformed id (guardUuid TypeError), a missing
|
|
12938
|
+
// order, or someone else's order all return 404 — never a 500, never
|
|
12939
|
+
// a leak. This is the ownership gate the request form + POST funnel
|
|
12940
|
+
// through.
|
|
12941
|
+
async function _ownedOrderForExchange(req, res, auth) {
|
|
12942
|
+
var order;
|
|
12943
|
+
try { order = await deps.order.get(req.params && req.params.order_id); }
|
|
12944
|
+
catch (e) {
|
|
12945
|
+
if (e instanceof TypeError) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
|
|
12946
|
+
throw e;
|
|
12947
|
+
}
|
|
12948
|
+
if (!order || order.customer_id !== auth.customer_id) {
|
|
12949
|
+
_send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
12950
|
+
return null;
|
|
12951
|
+
}
|
|
12952
|
+
return order;
|
|
12953
|
+
}
|
|
12954
|
+
|
|
12955
|
+
// Load the exchange named in :id, then load its parent order and
|
|
12956
|
+
// confirm THAT order belongs to the signed-in customer. The exchange
|
|
12957
|
+
// row has no customer_id, so ownership is asserted transitively
|
|
12958
|
+
// through the order. A malformed id (TypeError), an unknown exchange,
|
|
12959
|
+
// an exchange whose order is gone, or an exchange owned by someone
|
|
12960
|
+
// else ALL return 404 — never a 500, never a cross-customer reveal.
|
|
12961
|
+
async function _ownedExchange(req, res, auth) {
|
|
12962
|
+
var exchange;
|
|
12963
|
+
try { exchange = await deps.orderExchanges.getExchange(req.params && req.params.id); }
|
|
12964
|
+
catch (e) {
|
|
12965
|
+
if (e instanceof TypeError) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
|
|
12966
|
+
throw e;
|
|
12967
|
+
}
|
|
12968
|
+
if (!exchange) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
|
|
12969
|
+
var order;
|
|
12970
|
+
try { order = await deps.order.get(exchange.order_id); }
|
|
12971
|
+
catch (e) {
|
|
12972
|
+
if (e instanceof TypeError) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
|
|
12973
|
+
throw e;
|
|
12974
|
+
}
|
|
12975
|
+
if (!order || order.customer_id !== auth.customer_id) {
|
|
12976
|
+
_send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
12977
|
+
return null;
|
|
12978
|
+
}
|
|
12979
|
+
return exchange;
|
|
12980
|
+
}
|
|
12981
|
+
|
|
12982
|
+
// Resolve the sibling variants a line's product can be swapped to:
|
|
12983
|
+
// every active variant of the same product, labelled by its option
|
|
12984
|
+
// values (falling back to the SKU). Best-effort — absent the catalog
|
|
12985
|
+
// handle (or a read failure) the line offers a same-SKU swap.
|
|
12986
|
+
async function _replacementOptions(line) {
|
|
12987
|
+
if (!deps.catalog || !line || !line.variant_id) return [];
|
|
12988
|
+
try {
|
|
12989
|
+
var v = await deps.catalog.variants.get(line.variant_id);
|
|
12990
|
+
if (!v) return [];
|
|
12991
|
+
var siblings = await deps.catalog.variants.listForProduct(v.product_id);
|
|
12992
|
+
return (siblings || []).map(function (s) {
|
|
12993
|
+
var optLabel = s.options
|
|
12994
|
+
? Object.keys(s.options).map(function (k) { return s.options[k]; }).join(" / ")
|
|
12995
|
+
: "";
|
|
12996
|
+
var label = (optLabel ? optLabel + " — " : "") + s.sku;
|
|
12997
|
+
return { id: s.id, sku: s.sku, label: label };
|
|
12998
|
+
});
|
|
12999
|
+
} catch (_e) { return []; }
|
|
13000
|
+
}
|
|
13001
|
+
|
|
13002
|
+
// The customer's own exchange list, scoped to their orders. The
|
|
13003
|
+
// primitive resolves the customer→order linkage through the injected
|
|
13004
|
+
// order handle, so a foreign order's exchange never appears here.
|
|
13005
|
+
router.get("/account/exchanges", async function (req, res) {
|
|
13006
|
+
var auth = _exchangeAuth(req, res); if (!auth) return;
|
|
13007
|
+
var exchanges = [];
|
|
13008
|
+
try { exchanges = await deps.orderExchanges.exchangesForCustomer(auth.customer_id, { limit: 100 }); }
|
|
13009
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; exchanges = []; }
|
|
13010
|
+
var cartCount = await _cartCountForReq(req);
|
|
13011
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
13012
|
+
_send(res, 200, renderExchanges({
|
|
13013
|
+
exchanges: exchanges,
|
|
13014
|
+
requested: url && url.searchParams.get("ok") === "1",
|
|
13015
|
+
shop_name: shopName,
|
|
13016
|
+
cart_count: cartCount,
|
|
13017
|
+
}));
|
|
13018
|
+
});
|
|
13019
|
+
|
|
13020
|
+
// One exchange's status detail. Ownership-scoped through the parent
|
|
13021
|
+
// order (foreign / unknown → 404).
|
|
13022
|
+
router.get("/account/exchanges/:id", async function (req, res) {
|
|
13023
|
+
var auth = _exchangeAuth(req, res); if (!auth) return;
|
|
13024
|
+
var exchange = await _ownedExchange(req, res, auth); if (!exchange) return;
|
|
13025
|
+
var cartCount = await _cartCountForReq(req);
|
|
13026
|
+
_send(res, 200, renderExchangeDetail({ exchange: exchange, shop_name: shopName, cart_count: cartCount }));
|
|
13027
|
+
});
|
|
13028
|
+
|
|
13029
|
+
// The exchange-request form for one of the customer's own orders,
|
|
13030
|
+
// gated on the same eligibility window as a return.
|
|
13031
|
+
router.get("/account/orders/:order_id/exchange", async function (req, res) {
|
|
13032
|
+
var auth = _exchangeAuth(req, res); if (!auth) return;
|
|
13033
|
+
var order = await _ownedOrderForExchange(req, res, auth); if (!order) return;
|
|
13034
|
+
var cartCount = await _cartCountForReq(req);
|
|
13035
|
+
if (!_orderEligibleForExchange(order.status)) {
|
|
13036
|
+
return _send(res, 400, renderExchangeForm({
|
|
13037
|
+
order: order, lines: [], notice: "This order isn't eligible for an exchange.",
|
|
13038
|
+
shop_name: shopName, cart_count: cartCount,
|
|
13039
|
+
}));
|
|
13040
|
+
}
|
|
13041
|
+
var lines = [];
|
|
13042
|
+
var orderLines = order.lines || [];
|
|
13043
|
+
for (var i = 0; i < orderLines.length; i += 1) {
|
|
13044
|
+
var ol = orderLines[i];
|
|
13045
|
+
lines.push(Object.assign({}, ol, { replacement_options: await _replacementOptions(ol) }));
|
|
13046
|
+
}
|
|
13047
|
+
_send(res, 200, renderExchangeForm({ order: order, lines: lines, shop_name: shopName, cart_count: cartCount }));
|
|
13048
|
+
});
|
|
13049
|
+
|
|
13050
|
+
router.post("/account/orders/:order_id/exchange", async function (req, res) {
|
|
13051
|
+
var auth = _exchangeAuth(req, res); if (!auth) return;
|
|
13052
|
+
var order = await _ownedOrderForExchange(req, res, auth); if (!order) return;
|
|
13053
|
+
var body = req.body || {};
|
|
13054
|
+
var cartCount = await _cartCountForReq(req);
|
|
13055
|
+
|
|
13056
|
+
// Re-decorate the lines so a failed POST re-renders the same form
|
|
13057
|
+
// (with the replacement pickers intact) rather than a bare page.
|
|
13058
|
+
async function _decoratedLines() {
|
|
13059
|
+
var out = [];
|
|
13060
|
+
var ols = order.lines || [];
|
|
13061
|
+
for (var i = 0; i < ols.length; i += 1) {
|
|
13062
|
+
out.push(Object.assign({}, ols[i], { replacement_options: await _replacementOptions(ols[i]) }));
|
|
13063
|
+
}
|
|
13064
|
+
return out;
|
|
13065
|
+
}
|
|
13066
|
+
function _badForm(notice, status) {
|
|
13067
|
+
return _decoratedLines().then(function (lines) {
|
|
13068
|
+
return _send(res, status || 400, renderExchangeForm({
|
|
13069
|
+
order: order, lines: lines, notice: notice,
|
|
13070
|
+
shop_name: shopName, cart_count: cartCount,
|
|
13071
|
+
}));
|
|
13072
|
+
});
|
|
13073
|
+
}
|
|
13074
|
+
|
|
13075
|
+
if (!_orderEligibleForExchange(order.status)) {
|
|
13076
|
+
return _badForm("This order isn't eligible for an exchange.");
|
|
13077
|
+
}
|
|
13078
|
+
|
|
13079
|
+
// Resolve the picked line from the order's OWN lines (authoritative
|
|
13080
|
+
// sku/qty) — never trust a client-supplied sku. The radio carries
|
|
13081
|
+
// the order_line id.
|
|
13082
|
+
var orderLines = order.lines || [];
|
|
13083
|
+
var picked = null;
|
|
13084
|
+
for (var i = 0; i < orderLines.length; i += 1) {
|
|
13085
|
+
if (orderLines[i].id === body.line_id) { picked = orderLines[i]; break; }
|
|
13086
|
+
}
|
|
13087
|
+
if (!picked) return _badForm("Pick the item you'd like to exchange.");
|
|
13088
|
+
|
|
13089
|
+
var wantedQty = parseInt(body["qty_" + picked.id], 10);
|
|
13090
|
+
var maxQty = Number(picked.qty) || 1;
|
|
13091
|
+
var qty = Number.isFinite(wantedQty) && wantedQty >= 1 && wantedQty <= maxQty ? wantedQty : maxQty;
|
|
13092
|
+
|
|
13093
|
+
// The replacement variant: the chosen sibling variant of the same
|
|
13094
|
+
// product. The select carries the variant id; resolve it back to a
|
|
13095
|
+
// sku through the catalog (never trust a client sku). Absent a
|
|
13096
|
+
// selection (a same-SKU swap — replace a defective unit) the
|
|
13097
|
+
// replacement defaults to the line's own sku/variant.
|
|
13098
|
+
var replacementSku = picked.sku;
|
|
13099
|
+
var replacementVariantId = picked.variant_id;
|
|
13100
|
+
var chosen = body["replacement_" + picked.id];
|
|
13101
|
+
if (chosen && deps.catalog) {
|
|
13102
|
+
// The replacement MUST be one of the sibling variants offered for
|
|
13103
|
+
// the purchased line's product. Validate the chosen id against the
|
|
13104
|
+
// same option set the form was built from (_replacementOptions) —
|
|
13105
|
+
// resolving it through a bare variants.get would accept ANY catalog
|
|
13106
|
+
// variant, letting a forged replacement_<id> swap a cheap item for
|
|
13107
|
+
// any variant in the catalog. The option carries the catalog-
|
|
13108
|
+
// resolved sku/id, so a client sku is never trusted.
|
|
13109
|
+
var options = await _replacementOptions(picked);
|
|
13110
|
+
var match = null;
|
|
13111
|
+
for (var oi = 0; oi < options.length; oi += 1) {
|
|
13112
|
+
if (options[oi].id === chosen) { match = options[oi]; break; }
|
|
13113
|
+
}
|
|
13114
|
+
if (!match) return _badForm("That replacement variant isn't available — pick another.");
|
|
13115
|
+
replacementSku = match.sku;
|
|
13116
|
+
replacementVariantId = match.id;
|
|
13117
|
+
}
|
|
13118
|
+
|
|
13119
|
+
try {
|
|
13120
|
+
await deps.orderExchanges.requestExchange({
|
|
13121
|
+
order_id: order.id,
|
|
13122
|
+
line_id: picked.id,
|
|
13123
|
+
return_sku: picked.sku,
|
|
13124
|
+
return_qty: qty,
|
|
13125
|
+
replacement_sku: replacementSku,
|
|
13126
|
+
replacement_variant_id: replacementVariantId,
|
|
13127
|
+
replacement_qty: qty,
|
|
13128
|
+
reason: body.reason,
|
|
13129
|
+
});
|
|
13130
|
+
} catch (e) {
|
|
13131
|
+
if (e instanceof TypeError) {
|
|
13132
|
+
return _badForm((e && e.message || "").replace(/^order-exchanges[.:]\s*/, "") || "Please check your exchange request.");
|
|
13133
|
+
}
|
|
13134
|
+
throw e;
|
|
13135
|
+
}
|
|
13136
|
+
res.status(303);
|
|
13137
|
+
res.setHeader && res.setHeader("location", "/account/exchanges?ok=1");
|
|
13138
|
+
return res.end ? res.end() : res.send("");
|
|
13139
|
+
});
|
|
13140
|
+
}
|
|
13141
|
+
|
|
11772
13142
|
// Support tickets — the signed-in customer raises a ticket, lists
|
|
11773
13143
|
// their own, reads a thread, and replies. EVERY route is login-gated
|
|
11774
13144
|
// AND scoped to the session customer's id: the support primitive
|