@blamejs/blamejs-shop 0.3.36 → 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 CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.3.x
10
10
 
11
+ - v0.3.37 (2026-05-31) — **Gift registries — create one, share it, and let people mark gifts as bought.** A customer can now create a gift registry for an occasion — a wedding, baby, or birthday — add the products they want with the quantity desired, and share a public link. Anyone with the link can open the registry without an account, see what is still needed, add an item to their own cart to buy it through normal checkout, or mark an item as already purchased so it stops showing as needed. Registries can be public, unlisted, or private, and a private registry is not viewable from its link. The shared page never reveals who owns the registry or who bought what, and is not indexed by search engines. **Added:** *Gift registries* — A new Registry section in the account area lets a customer create a registry (title, occasion, event date, and a public/unlisted/private visibility), add and remove the items they want with a desired quantity, edit its details, and close it. Each registry has a shareable public page at its own link where anyone — no account needed — can see the items and how many of each are still needed, add an item to their cart to buy it through the normal checkout, or mark an item as already purchased so the registry's progress updates. A private registry is not viewable from its link, the public page shows only the items (never the owner's identity, address, or who purchased what), and registry pages are excluded from search indexing.
12
+
11
13
  - v0.3.36 (2026-05-31) — **Customers can request an exchange instead of only a refund.** Returning an order could only result in a refund, so a customer who received the wrong size had to refund and buy again. A customer can now request an exchange from the order page — choosing the item to swap, the replacement, and a reason — and operators work those requests from a new Exchanges queue in the admin console, moving each one through approval, shipping the replacement, receiving the returned item, and closing it out. A customer can only request and view exchanges on their own orders. This release also updates the vendored blamejs runtime to v0.14.10. **Added:** *Order exchanges* — An order can now be exchanged, not just refunded. From the order page a customer requests an exchange — picking the line to return, the replacement variant, and a reason — and tracks its status under Exchanges in their account. Operators get an Exchanges queue in the admin console with a status filter and a per-exchange screen that offers only the actions valid at the current step: approve or reject the request, mark the replacement shipped and delivered, mark the returned item received, and close it once both sides are complete. Every customer-facing exchange action is scoped to the customer's own orders, and an order must be in a fulfilled state to be eligible. **Changed:** *Vendored blamejs runtime updated to v0.14.10* — The bundled blamejs runtime is updated to v0.14.10, which routes the framework's full-text-search index token hashing through a keyed MAC (HMAC-SHAKE256) instead of a static-salted digest, so an index token hash cannot be forged or correlated across deployments without the per-deployment key. The change is internal to the runtime and requires no operator action.
12
14
 
13
15
  - v0.3.35 (2026-05-31) — **Share a wishlist with a private link.** A customer's wishlist could only be viewed by the customer themselves. From the wishlist page a shopper can now create a shareable link and send it to someone — a friend buying a gift, for example — who opens it without needing an account and sees the wishlist's products. The link can be revoked at any time, after which it stops working. The shared page shows only the products: it does not reveal who owns the wishlist or any private notes on it, and it is not indexed by search engines. **Added:** *Shareable wishlist links* — The wishlist page now has a Share control that creates a private link to the wishlist. The full link is shown once, to copy and send; anyone with it can open the shared wishlist without an account and see its products, each linking to the product page. The owner sees their active links and can revoke any of them, which immediately stops the link from working. The shared view shows only the products — never the owner's identity or private notes — and is marked not to be indexed by search engines, so a personal wishlist stays private to whoever holds the link.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.36",
2
+ "version": "0.3.37",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
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 &amp; 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 &amp; 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.
@@ -11266,6 +11657,535 @@ function mount(router, deps) {
11266
11657
  });
11267
11658
  }
11268
11659
 
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) {
11729
+ var auth;
11730
+ try { auth = _currentCustomer(req); }
11731
+ catch (e) {
11732
+ if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
11733
+ throw e;
11734
+ }
11735
+ if (!auth) {
11736
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
11737
+ return res.end ? res.end() : res.send("");
11738
+ }
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
+
11269
12189
  // Save for later — move a cart line into a per-customer holding
11270
12190
  // list and back. Login required (the list is per-customer).
11271
12191
  if (deps.saveForLater) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.36",
3
+ "version": "0.3.37",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {