@blamejs/blamejs-shop 0.3.36 → 0.3.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/storefront.js CHANGED
@@ -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");
@@ -1938,6 +1944,115 @@ function _pdpShippingNote(availability) {
1938
1944
  "See our <a href=\"/terms\">shipping &amp; returns policy</a>.</p>";
1939
1945
  }
1940
1946
 
1947
+ // Pre-order CTA — replaces the add-to-cart buy box on a PDP whose lead SKU
1948
+ // has an OPEN pre-order campaign (a SKU that isn't released yet, so it's not
1949
+ // normally purchasable). `preorder` is the resolved shape the route loads:
1950
+ // { product_slug, release_date_iso, remaining_units (null = unlimited),
1951
+ // full_price_str, deposit_str (null when no deposit), sold_out,
1952
+ // reserve_form }
1953
+ // A reservation is INTENT, not a charge — the form POSTs to the container
1954
+ // /products/:slug/preorder route, which pins the reservation to the signed-in
1955
+ // session customer and converts it into a regular (Stripe-gated) order at
1956
+ // launch. The reserve POST is CSRF-protected + auth-gated, so the POST FORM is
1957
+ // rendered ONLY by the container (where the per-request `_csrf` token is
1958
+ // injected): `reserve_form` is true on the container render, false on the edge.
1959
+ // The edge render shows the same pre-order info + a sign-in affordance instead
1960
+ // of a token-less form — a logged-in customer's session-cookie request skips
1961
+ // the edge cache and routes to the container, so they always reach the real
1962
+ // (tokened) form; an anonymous edge visitor can't reserve anyway (the route
1963
+ // 303s guests to login). This mirrors how cart-count / session chrome is
1964
+ // handled on edge-cached pages. The non-form parts stay byte-identical to the
1965
+ // edge builder. A sold-out campaign renders a disabled control + an honest
1966
+ // note, mirroring the out-of-stock add control.
1967
+ function _buildPreorderCta(preorder, escAttr) {
1968
+ var soldOut = !!preorder.sold_out;
1969
+ var remaining = preorder.remaining_units;
1970
+ var availLine = remaining == null
1971
+ ? "<p class=\"pdp__preorder-avail\" role=\"status\">Open for pre-order.</p>"
1972
+ : (remaining > 0
1973
+ ? "<p class=\"pdp__preorder-avail\" role=\"status\"><span class=\"dot dot--live\" aria-hidden=\"true\"></span> " + escAttr(String(remaining)) + " of " + escAttr(String(preorder.max_units_available)) + " reservations remaining.</p>"
1974
+ : "<p class=\"pdp__preorder-avail\" role=\"status\">All reservations are spoken for.</p>");
1975
+ var depositLine = preorder.deposit_str
1976
+ ? "<p class=\"pdp__preorder-deposit\">Reserve with a " + escAttr(preorder.deposit_str) + " deposit · " + escAttr(preorder.full_price_str) + " total at launch.</p>"
1977
+ : "<p class=\"pdp__preorder-deposit\">No payment due now · " + escAttr(preorder.full_price_str) + " charged when it ships.</p>";
1978
+ var head =
1979
+ "<div class=\"pdp__buybox pdp__buybox--preorder\">\n" +
1980
+ " <p class=\"pdp__badge pdp__badge--preorder\">Pre-order · ships " + escAttr(preorder.release_date_iso) + "</p>\n" +
1981
+ " <p class=\"featured-product__price\">" + escAttr(preorder.full_price_str) + "</p>\n" +
1982
+ " " + availLine + "\n" +
1983
+ " " + depositLine + "\n";
1984
+ // Sold-out: the same disabled control in both substrates (no POST either way).
1985
+ if (soldOut) {
1986
+ return head +
1987
+ " <button type=\"submit\" class=\"btn-primary cart-page__checkout\" disabled aria-disabled=\"true\">Pre-orders full</button>\n" +
1988
+ " <p class=\"pdp__soldout-note\" role=\"status\">Every pre-order reservation has been claimed.</p>\n" +
1989
+ " </div>";
1990
+ }
1991
+ if (!preorder.reserve_form) {
1992
+ // Edge render — no per-session CSRF token here, so render a sign-in
1993
+ // affordance instead of a token-less POST form. A signed-in customer's
1994
+ // request skips the edge cache and gets the container form below.
1995
+ return head +
1996
+ " <a class=\"btn-primary cart-page__checkout\" href=\"/account/login\">Sign in to reserve</a>\n" +
1997
+ " <p class=\"pdp__preorder-note\">A reservation holds your unit. We charge through secure checkout when it launches.</p>\n" +
1998
+ " </div>";
1999
+ }
2000
+ return head +
2001
+ " <form method=\"post\" action=\"/products/" + escAttr(preorder.product_slug) + "/preorder\">\n" +
2002
+ " <label class=\"pdp__variants-title\" for=\"preorder-qty\">Quantity</label>\n" +
2003
+ " <input id=\"preorder-qty\" type=\"number\" name=\"qty\" value=\"1\" min=\"1\" max=\"99\" class=\"variant-row__qty\" aria-label=\"Quantity\">\n" +
2004
+ " <button type=\"submit\" class=\"btn-primary cart-page__checkout\">Reserve your pre-order</button>\n" +
2005
+ " <p class=\"pdp__preorder-note\">A reservation holds your unit. We charge through secure checkout when it launches.</p>\n" +
2006
+ " </form>\n" +
2007
+ " </div>";
2008
+ }
2009
+
2010
+ // Build the renderProduct `preorder` opts shape from a campaign row + the
2011
+ // primitive's `availability` read + a price formatter. Returns null unless the
2012
+ // campaign is OPEN (status 'active') — a launched / closed campaign is no
2013
+ // longer reservable, so its PDP renders the standard buy box. Shared so the
2014
+ // container route + the edge handler derive the identical shape and the
2015
+ // dual-rendered CTA stays byte-consistent. `fmt(minor, currency)` is the
2016
+ // page's price formatter; `slug` is the product (URL) slug the reserve form
2017
+ // posts to.
2018
+ function preorderCtaShape(campaign, availability, fmt, slug) {
2019
+ if (!campaign || campaign.status !== "active") return null;
2020
+ var remaining = availability ? availability.remaining_units : null;
2021
+ var max = campaign.max_units_available == null ? null : campaign.max_units_available;
2022
+ var deposit = Number(campaign.deposit_minor) || 0;
2023
+ return {
2024
+ product_slug: slug,
2025
+ release_date_iso: new Date(Number(campaign.launch_at)).toISOString().slice(0, 10),
2026
+ remaining_units: remaining,
2027
+ max_units_available: max,
2028
+ full_price_str: fmt(campaign.full_price_minor, campaign.currency),
2029
+ deposit_str: deposit > 0 ? fmt(deposit, campaign.currency) : null,
2030
+ sold_out: remaining != null && remaining <= 0,
2031
+ // The container injects the per-request `_csrf` token into POST forms, so
2032
+ // the container render carries the real reserve form. The edge twin
2033
+ // (worker/render/product.js#preorderCtaShape) sets this false and renders a
2034
+ // sign-in affordance — see _buildPreorderCta.
2035
+ reserve_form: true,
2036
+ };
2037
+ }
2038
+
2039
+ // The reserve-PRG banner, keyed off the closed ?preorder marker set so a
2040
+ // forged query can never inject copy. Empty string for an absent / unknown
2041
+ // marker. Mirrored byte-for-byte by worker/render/product.js#_buildPreorderNotice.
2042
+ var _PREORDER_NOTICES = {
2043
+ reserved: { kind: "ok", copy: "Reserved. We'll email you when it ships and charge your card through secure checkout." },
2044
+ unavailable: { kind: "error", copy: "This pre-order couldn't be reserved — it may be full or closed. Nothing was charged." },
2045
+ closed: { kind: "error", copy: "This pre-order is no longer open." },
2046
+ };
2047
+ function _buildPreorderNotice(marker) {
2048
+ var n = marker && Object.prototype.hasOwnProperty.call(_PREORDER_NOTICES, marker)
2049
+ ? _PREORDER_NOTICES[marker] : null;
2050
+ if (!n) return "";
2051
+ var cls = n.kind === "error" ? "form-notice form-notice--error" : "form-notice form-notice--ok";
2052
+ var role = n.kind === "error" ? "alert" : "status";
2053
+ return "<p class=\"" + cls + "\" role=\"" + role + "\">" + b.template.escapeHtml(n.copy) + "</p>\n ";
2054
+ }
2055
+
1941
2056
  var PRODUCT_PAGE =
1942
2057
  "<section class=\"pdp\">\n" +
1943
2058
  " <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\">\n" +
@@ -2644,6 +2759,391 @@ function renderSharedWishlist(opts) {
2644
2759
  });
2645
2760
  }
2646
2761
 
2762
+ // ---- gift registry -------------------------------------------------------
2763
+ //
2764
+ // Owner side (renderRegistryList / renderRegistryManage) is gated on the
2765
+ // session customer; the public giver view (renderRegistryPublic) takes no
2766
+ // auth — the slug (resolved through the privacy gate) is the access.
2767
+
2768
+ // Human label for an occasion enum value. Unknown values fall back to the
2769
+ // raw token (never throws) so a future migration that adds an occasion the
2770
+ // renderer hasn't been taught still shows something readable.
2771
+ var REGISTRY_OCCASION_LABELS = {
2772
+ wedding: "Wedding",
2773
+ baby: "Baby",
2774
+ housewarming: "Housewarming",
2775
+ birthday: "Birthday",
2776
+ graduation: "Graduation",
2777
+ anniversary: "Anniversary",
2778
+ other: "Other",
2779
+ };
2780
+ function _registryOccasionLabel(occasion) {
2781
+ return REGISTRY_OCCASION_LABELS[occasion] || (occasion || "Registry");
2782
+ }
2783
+
2784
+ // Build a <select> of occasion options. `selected` highlights the current
2785
+ // value (the edit form); absent it the first option is the browser default.
2786
+ function _registryOccasionSelect(name, selected, esc) {
2787
+ var opts = "";
2788
+ for (var i = 0; i < REGISTRY_OCCASIONS.length; i += 1) {
2789
+ var v = REGISTRY_OCCASIONS[i];
2790
+ var sel = v === selected ? " selected" : "";
2791
+ opts += "<option value=\"" + esc(v) + "\"" + sel + ">" + esc(_registryOccasionLabel(v)) + "</option>";
2792
+ }
2793
+ return "<select name=\"" + esc(name) + "\" required>" + opts + "</select>";
2794
+ }
2795
+
2796
+ // Build a <select> of privacy options with inline help text.
2797
+ var REGISTRY_PRIVACY_LABELS = {
2798
+ private: "Private — only you can see it",
2799
+ unlisted: "Unlisted — anyone with the link can see it",
2800
+ public: "Public — discoverable in the shop",
2801
+ };
2802
+ function _registryPrivacySelect(name, selected, esc) {
2803
+ var order = ["unlisted", "public", "private"];
2804
+ var opts = "";
2805
+ for (var i = 0; i < order.length; i += 1) {
2806
+ var v = order[i];
2807
+ var sel = v === selected ? " selected" : "";
2808
+ opts += "<option value=\"" + esc(v) + "\"" + sel + ">" + esc(REGISTRY_PRIVACY_LABELS[v] || v) + "</option>";
2809
+ }
2810
+ return "<select name=\"" + esc(name) + "\" required>" + opts + "</select>";
2811
+ }
2812
+
2813
+ // Owner-facing list of registries (`GET /account/registry`). `opts.rows` is
2814
+ // each registry decoded by `listForOwner`, decorated with its `progress`
2815
+ // rollup ({ overall_percent, total_desired, total_purchased }). The create
2816
+ // form posts to /account/registry. `opts.notice` surfaces a created/closed/
2817
+ // updated confirmation. A per-customer page — noindex.
2818
+ var REGISTRY_LIST_NOTICES = {
2819
+ created: "Registry created. Add the items you're hoping for below.",
2820
+ closed: "Registry closed. It's still viewable so you can send thank-you notes.",
2821
+ updated: "Registry updated.",
2822
+ removed: "Item removed from your registry.",
2823
+ added: "Item added to your registry.",
2824
+ };
2825
+ function renderRegistryList(opts) {
2826
+ opts = opts || {};
2827
+ var esc = b.template.escapeHtml;
2828
+ var rows = opts.rows || [];
2829
+ var rowsHtml = "";
2830
+ for (var i = 0; i < rows.length; i += 1) {
2831
+ var r = rows[i];
2832
+ var reg = r.registry;
2833
+ var prog = r.progress || { overall_percent: 0, total_purchased: 0, total_desired: 0 };
2834
+ var statusBadge = reg.status === "closed"
2835
+ ? "<span class=\"registry-card__status registry-card__status--closed\">Closed</span>"
2836
+ : "<span class=\"registry-card__status registry-card__status--active\">Active</span>";
2837
+ rowsHtml +=
2838
+ "<li class=\"registry-card\">" +
2839
+ "<div class=\"registry-card__head\">" +
2840
+ "<a class=\"registry-card__title\" href=\"/account/registry/" + esc(reg.slug) + "\">" + esc(reg.title) + "</a>" +
2841
+ statusBadge +
2842
+ "</div>" +
2843
+ "<p class=\"registry-card__meta\">" + esc(_registryOccasionLabel(reg.occasion)) + " · " +
2844
+ esc(REGISTRY_PRIVACY_LABELS[reg.privacy] || reg.privacy) + "</p>" +
2845
+ "<p class=\"registry-card__progress\">" + esc(String(prog.overall_percent)) + "% fulfilled" +
2846
+ " (" + esc(String(prog.total_purchased)) + " of " + esc(String(prog.total_desired)) + ")</p>" +
2847
+ "<a class=\"card-link\" href=\"/account/registry/" + esc(reg.slug) + "\">Manage registry →</a>" +
2848
+ "</li>";
2849
+ }
2850
+ var list = rowsHtml
2851
+ ? "<ul class=\"registry-list\">" + rowsHtml + "</ul>"
2852
+ : "<div class=\"account-empty\">" +
2853
+ "<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>" +
2854
+ "</div>";
2855
+ var noticeMsg = REGISTRY_LIST_NOTICES[opts.notice];
2856
+ var notice = noticeMsg
2857
+ ? "<p class=\"form-notice form-notice--ok\" role=\"status\">" + esc(noticeMsg) + "</p>"
2858
+ : "";
2859
+ var errMsg = (typeof opts.error === "string" && opts.error.length) ? opts.error : "";
2860
+ var errBlock = errMsg
2861
+ ? "<p class=\"form-notice form-notice--err\" role=\"alert\">" + esc(errMsg) + "</p>"
2862
+ : "";
2863
+ // The slug field is operator-authored (lowercase alnum + dash) — the
2864
+ // backend validates it; the form pattern is a UX hint only (backend
2865
+ // remains authoritative, never the client).
2866
+ var createForm =
2867
+ "<section class=\"registry-create\" aria-labelledby=\"registry-create-heading\">" +
2868
+ "<h2 id=\"registry-create-heading\" class=\"registry-create__title\">Create a registry</h2>" +
2869
+ errBlock +
2870
+ "<form class=\"registry-create__form\" method=\"post\" action=\"/account/registry\">" +
2871
+ "<label class=\"field\"><span class=\"field__label\">Title</span>" +
2872
+ "<input type=\"text\" name=\"title\" required maxlength=\"200\" placeholder=\"Alice &amp; Bob's Wedding\"></label>" +
2873
+ "<label class=\"field\"><span class=\"field__label\">Registry link (lowercase letters, numbers, dashes)</span>" +
2874
+ "<input type=\"text\" name=\"slug\" required maxlength=\"200\" pattern=\"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\" placeholder=\"alice-and-bob-2026\"></label>" +
2875
+ "<label class=\"field\"><span class=\"field__label\">Recipient name</span>" +
2876
+ "<input type=\"text\" name=\"recipient_name\" required maxlength=\"200\" placeholder=\"Alice &amp; Bob\"></label>" +
2877
+ "<label class=\"field\"><span class=\"field__label\">Occasion</span>" +
2878
+ _registryOccasionSelect("occasion", "wedding", esc) + "</label>" +
2879
+ "<label class=\"field\"><span class=\"field__label\">Event date (optional)</span>" +
2880
+ "<input type=\"date\" name=\"event_date\"></label>" +
2881
+ "<label class=\"field\"><span class=\"field__label\">Who can see it</span>" +
2882
+ _registryPrivacySelect("privacy", "unlisted", esc) + "</label>" +
2883
+ "<button type=\"submit\" class=\"btn-primary\">Create registry</button>" +
2884
+ "</form>" +
2885
+ "</section>";
2886
+ var body =
2887
+ "<section class=\"account-registry\">" +
2888
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
2889
+ "<li><a href=\"/account\">Account</a></li>" +
2890
+ "<li aria-current=\"page\">Gift registry</li>" +
2891
+ "</ol></nav>" +
2892
+ "<h1 class=\"account-registry__title\">Your gift registries</h1>" +
2893
+ notice +
2894
+ list +
2895
+ createForm +
2896
+ "</section>";
2897
+ return _wrap({
2898
+ title: "Gift registry",
2899
+ shop_name: opts.shop_name || "blamejs.shop",
2900
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
2901
+ theme_css: opts.theme_css,
2902
+ // Per-customer management surface — keep it out of the index.
2903
+ robots: "noindex",
2904
+ body: body,
2905
+ });
2906
+ }
2907
+
2908
+ // Owner-facing manage page for one registry (`GET /account/registry/:slug`).
2909
+ // `opts.registry` is the decoded row; `opts.items` is each item decorated
2910
+ // with { product, hero_media, purchased, remaining, desired }; `opts.share_url`
2911
+ // is the absolute public URL (built from the request origin by the route).
2912
+ // Carries the add-item / edit / close forms. Per-customer — noindex.
2913
+ function renderRegistryManage(opts) {
2914
+ opts = opts || {};
2915
+ var esc = b.template.escapeHtml;
2916
+ var prefix = opts.asset_prefix || "/assets/";
2917
+ var reg = opts.registry || {};
2918
+ var items = opts.items || [];
2919
+ var closed = reg.status === "closed";
2920
+ var rowsHtml = "";
2921
+ for (var i = 0; i < items.length; i += 1) {
2922
+ var it = items[i];
2923
+ var media = it.hero_media
2924
+ ? "<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\">"
2925
+ : "<span class=\"registry-item__mark\" aria-hidden=\"true\">" + esc(((it.product && it.product.title) || it.sku || "?").trim().charAt(0).toUpperCase() || "?") + "</span>";
2926
+ var titleHtml = it.product
2927
+ ? "<a class=\"registry-item__title\" href=\"/products/" + esc(it.product.slug) + "\">" + esc(it.product.title) + "</a>"
2928
+ : "<span class=\"registry-item__title\">" + esc(it.sku) + " (no longer in the catalog)</span>";
2929
+ var removeForm = closed ? "" :
2930
+ "<form class=\"registry-item__remove\" method=\"post\" action=\"/account/registry/" + esc(reg.slug) + "/items/" + esc(it.item_id) + "/remove\">" +
2931
+ "<button type=\"submit\" class=\"btn-ghost\">Remove</button>" +
2932
+ "</form>";
2933
+ rowsHtml +=
2934
+ "<li class=\"registry-item\">" +
2935
+ "<span class=\"registry-item__media\">" + media + "</span>" +
2936
+ "<div class=\"registry-item__body\">" +
2937
+ titleHtml +
2938
+ "<p class=\"registry-item__progress\">" + esc(String(it.purchased)) + " of " + esc(String(it.desired)) +
2939
+ " purchased · " + esc(String(it.remaining)) + " still needed</p>" +
2940
+ "</div>" +
2941
+ removeForm +
2942
+ "</li>";
2943
+ }
2944
+ var list = rowsHtml
2945
+ ? "<ul class=\"registry-item-list\">" + rowsHtml + "</ul>"
2946
+ : "<div class=\"account-empty\"><p class=\"account-empty__lede\">No items yet. Add the products you're hoping for below.</p></div>";
2947
+
2948
+ var addForm = closed ? "" :
2949
+ "<section class=\"registry-add\" aria-labelledby=\"registry-add-heading\">" +
2950
+ "<h2 id=\"registry-add-heading\" class=\"registry-add__title\">Add an item</h2>" +
2951
+ "<form class=\"registry-add__form\" method=\"post\" action=\"/account/registry/" + esc(reg.slug) + "/items\">" +
2952
+ "<label class=\"field\"><span class=\"field__label\">Product SKU</span>" +
2953
+ "<input type=\"text\" name=\"sku\" required maxlength=\"200\" placeholder=\"BLENDER-12CUP\"></label>" +
2954
+ "<label class=\"field\"><span class=\"field__label\">Quantity wanted</span>" +
2955
+ "<input type=\"number\" name=\"quantity_desired\" required min=\"1\" max=\"999\" value=\"1\"></label>" +
2956
+ "<label class=\"field\"><span class=\"field__label\">Priority (1 = most wanted)</span>" +
2957
+ "<input type=\"number\" name=\"priority\" min=\"1\" max=\"5\" value=\"3\"></label>" +
2958
+ "<button type=\"submit\" class=\"btn-secondary\">Add to registry</button>" +
2959
+ "</form>" +
2960
+ "</section>";
2961
+
2962
+ var eventDateValue = (typeof reg.event_date === "number")
2963
+ ? new Date(reg.event_date).toISOString().slice(0, 10)
2964
+ : "";
2965
+ var editForm = closed ? "" :
2966
+ "<section class=\"registry-edit\" aria-labelledby=\"registry-edit-heading\">" +
2967
+ "<h2 id=\"registry-edit-heading\" class=\"registry-edit__title\">Registry details</h2>" +
2968
+ "<form class=\"registry-edit__form\" method=\"post\" action=\"/account/registry/" + esc(reg.slug) + "/edit\">" +
2969
+ "<label class=\"field\"><span class=\"field__label\">Title</span>" +
2970
+ "<input type=\"text\" name=\"title\" required maxlength=\"200\" value=\"" + esc(reg.title || "") + "\"></label>" +
2971
+ "<label class=\"field\"><span class=\"field__label\">Recipient name</span>" +
2972
+ "<input type=\"text\" name=\"recipient_name\" required maxlength=\"200\" value=\"" + esc(reg.recipient_name || "") + "\"></label>" +
2973
+ "<label class=\"field\"><span class=\"field__label\">Event date</span>" +
2974
+ "<input type=\"date\" name=\"event_date\" value=\"" + esc(eventDateValue) + "\"></label>" +
2975
+ "<label class=\"field\"><span class=\"field__label\">Who can see it</span>" +
2976
+ _registryPrivacySelect("privacy", reg.privacy, esc) + "</label>" +
2977
+ "<button type=\"submit\" class=\"btn-secondary\">Save details</button>" +
2978
+ "</form>" +
2979
+ "</section>";
2980
+
2981
+ var closeForm = closed
2982
+ ? "<p class=\"registry-manage__closed-note\">This registry is closed. It stays viewable so you can review what arrived.</p>"
2983
+ : "<form class=\"registry-close__form\" method=\"post\" action=\"/account/registry/" + esc(reg.slug) + "/close\">" +
2984
+ "<button type=\"submit\" class=\"btn-ghost registry-close__btn\">Close this registry</button>" +
2985
+ "</form>";
2986
+
2987
+ // The shareable public URL — shown only for a non-private registry (a
2988
+ // private registry isn't publicly viewable, so there's no link to share).
2989
+ var shareBlock = "";
2990
+ if (reg.privacy !== "private" && opts.share_url) {
2991
+ shareBlock =
2992
+ "<section class=\"registry-share\" aria-labelledby=\"registry-share-heading\">" +
2993
+ "<h2 id=\"registry-share-heading\" class=\"registry-share__title\">Share this registry</h2>" +
2994
+ "<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>" +
2995
+ "<label class=\"registry-share__label\" for=\"registry-share-url\">Public link</label>" +
2996
+ "<input id=\"registry-share-url\" class=\"registry-share__url\" type=\"text\" readonly value=\"" + esc(opts.share_url) + "\" " +
2997
+ "aria-label=\"Public registry link\" onfocus=\"this.select()\">" +
2998
+ "</section>";
2999
+ } else if (reg.privacy === "private") {
3000
+ 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>";
3001
+ }
3002
+
3003
+ var noticeMsg = REGISTRY_LIST_NOTICES[opts.notice];
3004
+ var notice = noticeMsg
3005
+ ? "<p class=\"form-notice form-notice--ok\" role=\"status\">" + esc(noticeMsg) + "</p>"
3006
+ : "";
3007
+ var errMsg = (typeof opts.error === "string" && opts.error.length) ? opts.error : "";
3008
+ var errBlock = errMsg
3009
+ ? "<p class=\"form-notice form-notice--err\" role=\"alert\">" + esc(errMsg) + "</p>"
3010
+ : "";
3011
+
3012
+ var body =
3013
+ "<section class=\"account-registry-manage\">" +
3014
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
3015
+ "<li><a href=\"/account\">Account</a></li>" +
3016
+ "<li><a href=\"/account/registry\">Gift registry</a></li>" +
3017
+ "<li aria-current=\"page\">" + esc(reg.title || "Registry") + "</li>" +
3018
+ "</ol></nav>" +
3019
+ "<h1 class=\"account-registry-manage__title\">" + esc(reg.title || "Registry") + "</h1>" +
3020
+ "<p class=\"account-registry-manage__meta\">" + esc(_registryOccasionLabel(reg.occasion)) + " · " +
3021
+ esc(REGISTRY_PRIVACY_LABELS[reg.privacy] || reg.privacy) + (closed ? " · Closed" : "") + "</p>" +
3022
+ notice +
3023
+ errBlock +
3024
+ shareBlock +
3025
+ list +
3026
+ addForm +
3027
+ editForm +
3028
+ closeForm +
3029
+ "</section>";
3030
+ return _wrap({
3031
+ title: reg.title || "Registry",
3032
+ shop_name: opts.shop_name || "blamejs.shop",
3033
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
3034
+ theme_css: opts.theme_css,
3035
+ robots: "noindex",
3036
+ body: body,
3037
+ });
3038
+ }
3039
+
3040
+ // Confirmation copy after a public giver action. Unknown keys render nothing
3041
+ // so a forged ?ok= query can't inject arbitrary copy onto the page.
3042
+ var REGISTRY_PUBLIC_NOTICES = {
3043
+ gifted: "Thank you! We've marked that item as purchased.",
3044
+ };
3045
+
3046
+ // Public, no-auth giver view of a registry (`GET /registry/:slug`). The route
3047
+ // resolves the registry ONLY through getBySlug (which honors the privacy
3048
+ // gate — a private registry returns null and the route 404s); the owner's
3049
+ // customer id + shipping address + buyer identities are NEVER carried into
3050
+ // this shape (the route passes title/occasion/event_date + per-item desired /
3051
+ // purchased / remaining counts + product cards only). Each not-yet-fulfilled
3052
+ // item carries a "mark purchased" form (records a gift via purchaseItem —
3053
+ // buyer anonymous by default) and an "add to cart" link so the giver can buy
3054
+ // it normally through the regular cart/checkout. Carries a noindex robots
3055
+ // directive — a personal registry is not search-index material.
3056
+ function renderRegistryPublic(opts) {
3057
+ opts = opts || {};
3058
+ var esc = b.template.escapeHtml;
3059
+ var prefix = opts.asset_prefix || "/assets/";
3060
+ var reg = opts.registry || {};
3061
+ var items = opts.items || [];
3062
+ var heading = (reg.title && String(reg.title).length) ? reg.title : "A gift registry";
3063
+ var occasionLine = esc(_registryOccasionLabel(reg.occasion));
3064
+ if (typeof reg.event_date === "number") {
3065
+ var dt = new Date(reg.event_date);
3066
+ if (!isNaN(dt.getTime())) {
3067
+ occasionLine += " · " + esc(dt.toISOString().slice(0, 10));
3068
+ }
3069
+ }
3070
+ var rowsHtml = "";
3071
+ for (var i = 0; i < items.length; i += 1) {
3072
+ var it = items[i];
3073
+ var media = it.hero_media
3074
+ ? "<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\">"
3075
+ : "<span class=\"registry-item__mark\" aria-hidden=\"true\">" + esc(((it.product && it.product.title) || it.sku || "?").trim().charAt(0).toUpperCase() || "?") + "</span>";
3076
+ var titleHtml = it.product
3077
+ ? "<a class=\"registry-item__title\" href=\"/products/" + esc(it.product.slug) + "\">" + esc(it.product.title) + "</a>"
3078
+ : "<span class=\"registry-item__title\">" + esc(it.sku) + "</span>";
3079
+ // Progress line — desired vs already purchased. A fully-purchased item
3080
+ // shows a "fully gifted" badge and offers no purchase control.
3081
+ var fulfilled = it.remaining <= 0;
3082
+ var progressHtml = fulfilled
3083
+ ? "<p class=\"registry-item__progress registry-item__progress--done\">Fully gifted — thank you!</p>"
3084
+ : "<p class=\"registry-item__progress\">" + esc(String(it.purchased)) + " of " + esc(String(it.desired)) +
3085
+ " purchased · " + esc(String(it.remaining)) + " still needed</p>";
3086
+ // Giver controls: only for an item that's both buyable (resolves to a
3087
+ // catalog variant) and not yet fully gifted, and only while the registry
3088
+ // is active (a closed registry refuses purchaseItem).
3089
+ var controls = "";
3090
+ if (!fulfilled && reg.status === "active") {
3091
+ var markForm =
3092
+ "<form class=\"registry-item__gift\" method=\"post\" action=\"/registry/" + esc(reg.slug) + "/items/" + esc(it.item_id) + "/purchase\">" +
3093
+ "<input type=\"hidden\" name=\"quantity\" value=\"1\">" +
3094
+ "<label class=\"registry-item__reveal\"><input type=\"checkbox\" name=\"reveal\" value=\"1\"> Let the recipient see it was from me (requires sign-in)</label>" +
3095
+ "<button type=\"submit\" class=\"btn-secondary\">Mark as purchased</button>" +
3096
+ "</form>";
3097
+ var cartForm = it.variant_id
3098
+ ? "<form class=\"registry-item__cart\" method=\"post\" action=\"/cart/lines\">" +
3099
+ "<input type=\"hidden\" name=\"variant_id\" value=\"" + esc(it.variant_id) + "\">" +
3100
+ "<input type=\"hidden\" name=\"qty\" value=\"1\">" +
3101
+ "<button type=\"submit\" class=\"btn-ghost\">Add to cart</button>" +
3102
+ "</form>"
3103
+ : "";
3104
+ controls = "<div class=\"registry-item__actions\">" + cartForm + markForm + "</div>";
3105
+ }
3106
+ rowsHtml +=
3107
+ "<li class=\"registry-item\">" +
3108
+ "<span class=\"registry-item__media\">" + media + "</span>" +
3109
+ "<div class=\"registry-item__body\">" +
3110
+ titleHtml +
3111
+ progressHtml +
3112
+ controls +
3113
+ "</div>" +
3114
+ "</li>";
3115
+ }
3116
+ var inner = rowsHtml
3117
+ ? "<ul class=\"registry-item-list\">" + rowsHtml + "</ul>"
3118
+ : "<div class=\"account-empty\"><p class=\"account-empty__lede\">This registry doesn't have any items yet — check back soon.</p>" +
3119
+ "<a class=\"btn-secondary\" href=\"/\">Browse the shop →</a></div>";
3120
+ var noticeMsg = REGISTRY_PUBLIC_NOTICES[opts.notice];
3121
+ var notice = noticeMsg
3122
+ ? "<p class=\"form-notice form-notice--ok\" role=\"status\">" + esc(noticeMsg) + "</p>"
3123
+ : "";
3124
+ var messageBlock = (reg.message && String(reg.message).length)
3125
+ ? "<p class=\"registry-public__message\">" + esc(reg.message) + "</p>"
3126
+ : "";
3127
+ var body =
3128
+ "<section class=\"registry-public\">" +
3129
+ "<p class=\"eyebrow\">Gift registry</p>" +
3130
+ "<h1 class=\"registry-public__title\">" + esc(heading) + "</h1>" +
3131
+ "<p class=\"registry-public__meta\">" + occasionLine + "</p>" +
3132
+ messageBlock +
3133
+ notice +
3134
+ inner +
3135
+ "</section>";
3136
+ return _wrap({
3137
+ title: heading,
3138
+ shop_name: opts.shop_name || "blamejs.shop",
3139
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
3140
+ theme_css: opts.theme_css,
3141
+ // A personal registry is not search-index material.
3142
+ robots: "noindex",
3143
+ body: body,
3144
+ });
3145
+ }
3146
+
2647
3147
  // Compare page notice banner — surfaced after a toggle / a full-basket
2648
3148
  // refusal. The route passes a short message key; unknown keys render
2649
3149
  // nothing so a forged `?notice=` query can't inject arbitrary copy.
@@ -4567,6 +5067,76 @@ function renderAccountSubscriptions(opts) {
4567
5067
  });
4568
5068
  }
4569
5069
 
5070
+ // The customer's pre-order reservations (`GET /account/preorders`). Each row
5071
+ // shows the campaign (the SKU's product context isn't loaded here — the
5072
+ // campaign slug + release date + status are the durable identity), its
5073
+ // release date, the reserved quantity, and the reservation status pill. An
5074
+ // ACTIVE reservation offers a Cancel control (ownership-scoped POST); a
5075
+ // converted (now an order) / cancelled reservation is read-only. `reservations`
5076
+ // rows are decorated with `.campaign` by the route. JS-off-native.
5077
+ function renderAccountPreorders(opts) {
5078
+ var esc = b.template.escapeHtml;
5079
+ var rows = opts.reservations || [];
5080
+ var rowsHtml = "";
5081
+ for (var i = 0; i < rows.length; i += 1) {
5082
+ var r = rows[i];
5083
+ var c = r.campaign || null;
5084
+ var releaseStr = c && c.launch_at
5085
+ ? new Date(Number(c.launch_at)).toISOString().slice(0, 10)
5086
+ : "";
5087
+ var priceStr = "";
5088
+ if (c) {
5089
+ try { priceStr = esc(pricing.format(Number(c.full_price_minor), String(c.currency || "USD"))) + " · "; }
5090
+ catch (_e) { priceStr = ""; }
5091
+ }
5092
+ var status = String(r.status || "active");
5093
+ var statusLabel = status === "converted" ? "Ordered" : status === "cancelled" ? "Canceled" : "Reserved";
5094
+ var releaseLine = releaseStr
5095
+ ? "Ships <time datetime=\"" + esc(releaseStr) + "\">" + esc(releaseStr) + "</time>"
5096
+ : "";
5097
+ var cancelControl = status === "active"
5098
+ ? "<form class=\"preorder-card__control\" method=\"post\" action=\"/account/preorders/" + esc(String(r.id)) + "/cancel\">" +
5099
+ "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Cancel reservation</button>" +
5100
+ "</form>"
5101
+ : "";
5102
+ rowsHtml +=
5103
+ "<li class=\"preorder-card preorder-card--" + esc(status) + "\">" +
5104
+ "<div class=\"preorder-card__head\">" +
5105
+ "<span class=\"preorder-card__title\">" + esc(c ? String(c.slug) : String(r.campaign_slug)) + "</span>" +
5106
+ "<span class=\"status-pill preorder-status--" + esc(status) + "\">" + esc(statusLabel) + "</span>" +
5107
+ "</div>" +
5108
+ "<p class=\"preorder-card__meta\">" + priceStr + "Qty " + esc(String(r.quantity)) + (releaseLine ? " · " + releaseLine : "") + "</p>" +
5109
+ cancelControl +
5110
+ "</li>";
5111
+ }
5112
+ var inner = rowsHtml
5113
+ ? "<ul class=\"preorder-list\">" + rowsHtml + "</ul>"
5114
+ : "<div class=\"account-empty\">" +
5115
+ "<p class=\"account-empty__lede\">You have no pre-order reservations.</p>" +
5116
+ "<a class=\"btn-secondary\" href=\"/\">Browse the shop →</a>" +
5117
+ "</div>";
5118
+ var noticeHtml = opts.notice
5119
+ ? "<p class=\"form-notice form-notice--ok\" role=\"status\">" + esc(String(opts.notice)) + "</p>"
5120
+ : "";
5121
+ var body =
5122
+ "<section class=\"account-preorders\">" +
5123
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
5124
+ "<li><a href=\"/account\">Account</a></li>" +
5125
+ "<li aria-current=\"page\">Pre-orders</li>" +
5126
+ "</ol></nav>" +
5127
+ "<h1 class=\"account-preorders__title\">Pre-orders</h1>" +
5128
+ noticeHtml +
5129
+ inner +
5130
+ "</section>";
5131
+ return _wrap({
5132
+ title: "Pre-orders",
5133
+ shop_name: opts.shop_name || "blamejs.shop",
5134
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
5135
+ theme_css: opts.theme_css,
5136
+ body: body,
5137
+ });
5138
+ }
5139
+
4570
5140
  // Server-rendered confirmation step for canceling a subscription. CSP
4571
5141
  // forbids an inline confirm() dialog, so the destructive choice is gated
4572
5142
  // by this page: it spells out the period-end date and, for the
@@ -4807,7 +5377,25 @@ function renderProduct(opts) {
4807
5377
  // loads ({ sku: { stock_on_hand, stock_held } }); absent it, the
4808
5378
  // product reads as in stock (never-block-on-missing-inventory stance).
4809
5379
  var availability = _resolveAvailability(variants, opts.inventory);
4810
- var buyboxHtml = _buildBuyBox(rendered, b.template.escapeHtml, availability);
5380
+ // An OPEN pre-order campaign for the lead SKU swaps the add-to-cart buy box
5381
+ // for the reservation CTA — a not-yet-released SKU isn't normally
5382
+ // purchasable, so the only honest action is to reserve a unit. The route
5383
+ // threads `opts.preorder_campaign` ({ campaign, remaining_units }) from a
5384
+ // live D1 read; the shape is built here with the page's own `fmt` so the
5385
+ // CTA's prices track the active currency context exactly as the buy-box
5386
+ // prices do, and the container + edge render byte-identically. Absent a
5387
+ // campaign (or a non-active one), the standard buy box renders unchanged.
5388
+ var preorderShape = opts.preorder_campaign
5389
+ ? preorderCtaShape(opts.preorder_campaign.campaign, { remaining_units: opts.preorder_campaign.remaining_units }, fmt, opts.product.slug)
5390
+ : null;
5391
+ var buyboxHtml = preorderShape
5392
+ ? _buildPreorderCta(preorderShape, b.template.escapeHtml)
5393
+ : _buildBuyBox(rendered, b.template.escapeHtml, availability);
5394
+ // The reserve PRG lands the shopper back on the PDP with a fixed
5395
+ // ?preorder=<reserved|unavailable|closed> marker; the banner prepends the
5396
+ // buy box. The marker set is closed (built from a lookup, never the raw
5397
+ // query), so a forged query can't inject copy. Mirrored at the edge.
5398
+ buyboxHtml = _buildPreorderNotice(opts.preorder_notice) + buyboxHtml;
4811
5399
  var availabilityHtml = _buildAvailability(availability);
4812
5400
  var shippingNoteHtml = _pdpShippingNote(availability);
4813
5401
  var galleryHtml = _buildPdpGallery(opts.product, opts.media || [], opts.asset_prefix || "/assets/");
@@ -6862,6 +7450,7 @@ var ACCOUNT_DASH_PAGE =
6862
7450
  " <a class=\"btn-secondary\" href=\"/account/credit\">Store credit</a>\n" +
6863
7451
  " <a class=\"btn-secondary\" href=\"/account/referrals\">Refer a friend</a>\n" +
6864
7452
  " <a class=\"btn-secondary\" href=\"/account/subscriptions\">Subscriptions</a>\n" +
7453
+ " RAW_PREORDER_LINK\n" +
6865
7454
  // begin: profile + passkey management actions
6866
7455
  " <a class=\"btn-secondary\" href=\"/account/profile\">Edit profile</a>\n" +
6867
7456
  " <a class=\"btn-secondary\" href=\"/account/passkeys\">Manage passkeys</a>\n" +
@@ -6966,7 +7555,13 @@ function renderAccount(opts) {
6966
7555
  member_since: memberSince,
6967
7556
  passkey_count: String(passkeyCount),
6968
7557
  order_rows: "RAW_ORDER_ROWS",
6969
- }).replace("RAW_ORDER_ROWS", rows);
7558
+ }).replace("RAW_ORDER_ROWS", rows)
7559
+ // The Pre-orders link only renders when the preorder primitive is wired
7560
+ // (the /account/preorders route is mounted) — a deploy without it never
7561
+ // links to a 404.
7562
+ .replace("RAW_PREORDER_LINK", opts.preorders_enabled
7563
+ ? "<a class=\"btn-secondary\" href=\"/account/preorders\">Pre-orders</a>"
7564
+ : "");
6970
7565
  return _wrap({
6971
7566
  title: "Account",
6972
7567
  shop_name: opts.shop_name || "blamejs.shop",
@@ -7573,6 +8168,12 @@ function mount(router, deps) {
7573
8168
  // cancel route additionally needs the payment handle (cancel composes
7574
8169
  // Stripe via the primitive); without it the list stays read-only.
7575
8170
  var subscriptions = deps.subscriptions || null;
8171
+ // Pre-order reservations — opts in the PDP reserve route + the
8172
+ // /account/preorders surface. The PDP swaps the add-to-cart buy box for a
8173
+ // reservation CTA when the lead SKU has an OPEN campaign; the reserve POST
8174
+ // pins the reservation to the signed-in session customer (no charge — the
8175
+ // launch flow converts the reservation into a Stripe-gated order).
8176
+ var preorder = deps.preorder || null;
7576
8177
 
7577
8178
  // Active cookie-consent policy version. `_liveConsentPolicy()` reads it
7578
8179
  // from the consent primitive per request so a runtime `policyVersion`
@@ -9091,10 +9692,32 @@ function mount(router, deps) {
9091
9692
  // mirrored at the edge). Best-effort inside the helper; an empty list
9092
9693
  // hides the rail.
9093
9694
  var related = await _relatedProductsFor(product.id, 4);
9695
+ // Pre-order campaign for the lead SKU — when an OPEN campaign exists, the
9696
+ // renderer swaps the add-to-cart buy box for the reservation CTA (release
9697
+ // date + remaining availability). One indexed read; degrades to "no
9698
+ // campaign" (the standard buy box) on a missing table / read failure so
9699
+ // the buy path renders regardless. Mirrors the edge resolution so the
9700
+ // dual-rendered CTA agrees across substrates.
9701
+ var preorderCampaign = null;
9702
+ if (deps.preorder && firstVariant) {
9703
+ try {
9704
+ var openCampaign = await deps.preorder.openCampaignForSku(firstVariant.sku);
9705
+ if (openCampaign) {
9706
+ var avail = await deps.preorder.availability({ slug: openCampaign.slug });
9707
+ preorderCampaign = { campaign: openCampaign, remaining_units: avail ? avail.remaining_units : null };
9708
+ }
9709
+ } catch (_e) { preorderCampaign = null; }
9710
+ }
9711
+ // The reserve PRG lands here with a ?preorder=<marker> the renderer maps
9712
+ // to a banner; only meaningful when a campaign is present.
9713
+ var pdpUrl = req.url ? new URL(req.url, "http://localhost") : null;
9714
+ var preorderNotice = pdpUrl ? pdpUrl.searchParams.get("preorder") : null;
9094
9715
  var html = renderProduct(Object.assign({
9095
9716
  product: product,
9096
9717
  variants: variants,
9097
9718
  prices: prices,
9719
+ preorder_campaign: preorderCampaign,
9720
+ preorder_notice: preorderNotice,
9098
9721
  media: media,
9099
9722
  inventory: inventory,
9100
9723
  review_summary: reviewSummary,
@@ -10445,6 +11068,7 @@ function mount(router, deps) {
10445
11068
  orders: orders,
10446
11069
  order_product_lookup: orderProductLookup,
10447
11070
  passkey_count: passkeyCount,
11071
+ preorders_enabled: !!preorder,
10448
11072
  shop_name: shopName,
10449
11073
  cart_count: cartCount,
10450
11074
  }));
@@ -11266,6 +11890,535 @@ function mount(router, deps) {
11266
11890
  });
11267
11891
  }
11268
11892
 
11893
+ // ---- gift registry --------------------------------------------------
11894
+ //
11895
+ // Owner side (gated on the session customer, CSRF-protected by the
11896
+ // container form chokepoint): list the customer's registries with
11897
+ // progress, create one, then a per-registry manage page (add / remove
11898
+ // items, edit details, close) plus the shareable public URL. Every owner
11899
+ // route is scoped to the session customer — a registry whose
11900
+ // `owner_customer_id` isn't the signed-in customer resolves to a clean
11901
+ // 404 (no IDOR), because the gift-registry primitive moves a registry by
11902
+ // slug alone and has no notion of the requesting customer.
11903
+ //
11904
+ // Public side (NO auth): `GET /registry/:slug` resolves the registry ONLY
11905
+ // through `getBySlug(slug)` — never a guessable owner/registry id from the
11906
+ // path or query — and the route honors the privacy gate: a `private`
11907
+ // registry (and an unknown slug) 404s identically, with no existence
11908
+ // oracle. The view renders the title / occasion /
11909
+ // event date + per-item desired-vs-purchased counts + product links, and
11910
+ // NEVER carries the owner's customer id / shipping address or any buyer
11911
+ // identity into the page (the primitive's getBySlug surfaces items +
11912
+ // aggregate counts only; the per-buyer purchase rows stay owner-internal).
11913
+ // A giver marks an item purchased (records a gift via purchaseItem,
11914
+ // anonymous by default) or adds it to their own cart to buy normally.
11915
+ // noindex (a personal registry isn't index material).
11916
+ if (deps.giftRegistry) {
11917
+ // Load a registry the SESSION customer owns, or null. The primitive's
11918
+ // getRegistry moves by slug alone, so the route owns the ownership
11919
+ // decision: a registry whose owner_customer_id isn't the session
11920
+ // customer is treated identically to a missing one (clean 404, no
11921
+ // oracle on existence). A malformed slug throws inside the primitive's
11922
+ // slug validator — caught by the caller and mapped to a 404.
11923
+ async function _ownedRegistry(slug, customerId) {
11924
+ var reg;
11925
+ try { reg = await deps.giftRegistry.getRegistry(slug); }
11926
+ catch (_e) { return null; } // malformed slug → not found
11927
+ if (!reg || reg.owner_customer_id !== customerId) return null;
11928
+ return reg;
11929
+ }
11930
+
11931
+ // Resolve a registry item's sku to its catalog product + hero image +
11932
+ // buyable variant id. Returns { product, hero_media, variant_id } —
11933
+ // any field null when the sku no longer resolves (the item still shows,
11934
+ // with no buy control). Best-effort: a catalog read failure degrades to
11935
+ // an undecorated item rather than throwing.
11936
+ async function _decorateRegistryItem(item) {
11937
+ var out = {
11938
+ item_id: item.id,
11939
+ sku: item.sku,
11940
+ variant_id: null,
11941
+ product: null,
11942
+ hero_media: null,
11943
+ };
11944
+ try {
11945
+ var variant = await deps.catalog.variants.bySku(item.sku);
11946
+ if (variant) {
11947
+ out.variant_id = variant.id;
11948
+ var product = await deps.catalog.products.get(variant.product_id);
11949
+ if (product && product.status === "active") {
11950
+ out.product = product;
11951
+ var media = await deps.catalog.media.listForProduct(product.id);
11952
+ out.hero_media = media.length ? media[0] : null;
11953
+ }
11954
+ }
11955
+ } catch (_e) { /* drop-silent — an undecorated item still renders */ }
11956
+ return out;
11957
+ }
11958
+
11959
+ // GET /account/registry — the session customer's registries, each with
11960
+ // its progress rollup, plus the create form.
11961
+ router.get("/account/registry", async function (req, res) {
11962
+ var auth;
11963
+ try { auth = _currentCustomer(req); }
11964
+ catch (e) {
11965
+ if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
11966
+ throw e;
11967
+ }
11968
+ if (!auth) {
11969
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
11970
+ return res.end ? res.end() : res.send("");
11971
+ }
11972
+ var regs = [];
11973
+ try { regs = await deps.giftRegistry.listForOwner(auth.customer_id); }
11974
+ catch (_e) { regs = []; }
11975
+ var rows = [];
11976
+ for (var i = 0; i < regs.length; i += 1) {
11977
+ var prog = null;
11978
+ try { prog = await deps.giftRegistry.progressFor(regs[i].slug); }
11979
+ catch (_e) { prog = null; }
11980
+ rows.push({ registry: regs[i], progress: prog });
11981
+ }
11982
+ var listUrl = req.url ? new URL(req.url, "http://localhost") : null;
11983
+ _send(res, 200, renderRegistryList({
11984
+ rows: rows,
11985
+ notice: listUrl ? listUrl.searchParams.get("ok") : null,
11986
+ shop_name: shopName,
11987
+ cart_count: await _cartCountForReq(req),
11988
+ asset_prefix: deps.asset_prefix || "/assets/",
11989
+ }));
11990
+ });
11991
+
11992
+ // POST /account/registry — create a registry owned by the session
11993
+ // customer. The owner id comes from the session, never the body, so a
11994
+ // shopper can only ever create a registry under their own id.
11995
+ router.post("/account/registry", async function (req, res) {
11996
+ var auth;
11997
+ try { auth = _currentCustomer(req); }
11998
+ catch (e) {
11999
+ if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
12000
+ throw e;
12001
+ }
12002
+ if (!auth) {
12003
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
12004
+ return res.end ? res.end() : res.send("");
12005
+ }
12006
+ var body = req.body || {};
12007
+ // event_date arrives as a yyyy-mm-dd string from <input type=date>;
12008
+ // convert to epoch-ms (midnight UTC) or null. A malformed value
12009
+ // becomes null rather than 400-ing — the date is optional.
12010
+ var eventDate = null;
12011
+ if (typeof body.event_date === "string" && body.event_date.length) {
12012
+ var parsed = Date.parse(body.event_date + "T00:00:00Z");
12013
+ if (!isNaN(parsed)) eventDate = parsed;
12014
+ }
12015
+ try {
12016
+ await deps.giftRegistry.createRegistry({
12017
+ owner_customer_id: auth.customer_id,
12018
+ slug: String(body.slug || ""),
12019
+ title: String(body.title || ""),
12020
+ recipient_name: String(body.recipient_name || ""),
12021
+ occasion: String(body.occasion || ""),
12022
+ privacy: String(body.privacy || ""),
12023
+ event_date: eventDate,
12024
+ });
12025
+ } catch (e) {
12026
+ // A bad slug / title / occasion / duplicate slug is operator error
12027
+ // (TypeError) — re-render the list with the message; anything else
12028
+ // is a 500.
12029
+ if (e instanceof TypeError) {
12030
+ var regs = [];
12031
+ try { regs = await deps.giftRegistry.listForOwner(auth.customer_id); }
12032
+ catch (_e2) { regs = []; }
12033
+ var rows = [];
12034
+ for (var i = 0; i < regs.length; i += 1) rows.push({ registry: regs[i], progress: null });
12035
+ return _send(res, 400, renderRegistryList({
12036
+ rows: rows,
12037
+ error: (e && e.message) || "We couldn't create that registry.",
12038
+ shop_name: shopName,
12039
+ cart_count: await _cartCountForReq(req),
12040
+ asset_prefix: deps.asset_prefix || "/assets/",
12041
+ }));
12042
+ }
12043
+ res.status(500);
12044
+ return res.end ? res.end("Error") : res.send("Error");
12045
+ }
12046
+ res.status(303);
12047
+ res.setHeader && res.setHeader("location", "/account/registry/" + encodeURIComponent(String(body.slug || "")) + "?ok=created");
12048
+ return res.end ? res.end() : res.send("");
12049
+ });
12050
+
12051
+ // GET /account/registry/:slug — manage one registry the session
12052
+ // customer owns. A registry the customer doesn't own (or an unknown
12053
+ // slug) 404s.
12054
+ router.get("/account/registry/:slug", async function (req, res) {
12055
+ var auth;
12056
+ try { auth = _currentCustomer(req); }
12057
+ catch (e) {
12058
+ if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
12059
+ throw e;
12060
+ }
12061
+ if (!auth) {
12062
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
12063
+ return res.end ? res.end() : res.send("");
12064
+ }
12065
+ var slug = (req.params && req.params.slug) || "";
12066
+ var reg = await _ownedRegistry(slug, auth.customer_id);
12067
+ if (!reg) {
12068
+ return _send(res, 404, renderNotFound({ shop_name: shopName, cart_count: await _cartCountForReq(req) }));
12069
+ }
12070
+ // Items + their progress. getBySlug returns items with their
12071
+ // purchased-aggregate; progressFor gives the remaining count.
12072
+ var view = null;
12073
+ try { view = await deps.giftRegistry.getBySlug(slug); }
12074
+ catch (_e) { view = null; }
12075
+ var prog = null;
12076
+ try { prog = await deps.giftRegistry.progressFor(slug); }
12077
+ catch (_e) { prog = null; }
12078
+ var remainingById = Object.create(null);
12079
+ var purchasedById = Object.create(null);
12080
+ var desiredById = Object.create(null);
12081
+ if (prog && prog.items) {
12082
+ for (var p = 0; p < prog.items.length; p += 1) {
12083
+ remainingById[prog.items[p].item_id] = prog.items[p].remaining;
12084
+ purchasedById[prog.items[p].item_id] = prog.items[p].purchased;
12085
+ desiredById[prog.items[p].item_id] = prog.items[p].quantity_desired;
12086
+ }
12087
+ }
12088
+ var items = [];
12089
+ var rawItems = (view && view.items) || [];
12090
+ for (var i = 0; i < rawItems.length; i += 1) {
12091
+ var dec = await _decorateRegistryItem(rawItems[i]);
12092
+ dec.desired = desiredById[rawItems[i].id] != null ? desiredById[rawItems[i].id] : rawItems[i].quantity_desired;
12093
+ dec.purchased = purchasedById[rawItems[i].id] || 0;
12094
+ dec.remaining = remainingById[rawItems[i].id] != null ? remainingById[rawItems[i].id] : dec.desired;
12095
+ items.push(dec);
12096
+ }
12097
+ // The shareable public URL — built from this request's ORIGIN (scheme
12098
+ // + host), never by trimming a path off the canonical (this GET lands
12099
+ // on /account/registry/:slug, so a trim would mangle the link).
12100
+ var shareUrl = "";
12101
+ if (reg.privacy !== "private") {
12102
+ var origin = "";
12103
+ try { origin = new URL(_requestUrls(req).canonical_url).origin; }
12104
+ catch (_e) { origin = ""; }
12105
+ shareUrl = origin + "/registry/" + encodeURIComponent(reg.slug);
12106
+ }
12107
+ var manageUrl = req.url ? new URL(req.url, "http://localhost") : null;
12108
+ _send(res, 200, renderRegistryManage({
12109
+ registry: reg,
12110
+ items: items,
12111
+ share_url: shareUrl,
12112
+ notice: manageUrl ? manageUrl.searchParams.get("ok") : null,
12113
+ shop_name: shopName,
12114
+ cart_count: await _cartCountForReq(req),
12115
+ asset_prefix: deps.asset_prefix || "/assets/",
12116
+ }));
12117
+ });
12118
+
12119
+ // POST /account/registry/:slug/items — add an item to a registry the
12120
+ // session customer owns. Ownership-scoped (404 on a foreign / unknown
12121
+ // registry).
12122
+ router.post("/account/registry/:slug/items", async function (req, res) {
12123
+ var auth = _registryAuthOrRedirect(req, res);
12124
+ if (!auth) return;
12125
+ var slug = (req.params && req.params.slug) || "";
12126
+ var reg = await _ownedRegistry(slug, auth.customer_id);
12127
+ if (!reg) {
12128
+ return _send(res, 404, renderNotFound({ shop_name: shopName, cart_count: await _cartCountForReq(req) }));
12129
+ }
12130
+ var body = req.body || {};
12131
+ var qty = parseInt(body.quantity_desired, 10);
12132
+ var priorityRaw = parseInt(body.priority, 10);
12133
+ try {
12134
+ await deps.giftRegistry.addItem({
12135
+ registry_slug: slug,
12136
+ sku: String(body.sku || ""),
12137
+ quantity_desired: Number.isFinite(qty) ? qty : 0,
12138
+ priority: Number.isFinite(priorityRaw) ? priorityRaw : 3,
12139
+ });
12140
+ } catch (e) {
12141
+ return _registryManageError(req, res, reg, e);
12142
+ }
12143
+ res.status(303);
12144
+ res.setHeader && res.setHeader("location", "/account/registry/" + encodeURIComponent(slug) + "?ok=added");
12145
+ return res.end ? res.end() : res.send("");
12146
+ });
12147
+
12148
+ // POST /account/registry/:slug/items/:item_id/remove — archive an item
12149
+ // on a registry the session customer owns.
12150
+ router.post("/account/registry/:slug/items/:item_id/remove", async function (req, res) {
12151
+ var auth = _registryAuthOrRedirect(req, res);
12152
+ if (!auth) return;
12153
+ var slug = (req.params && req.params.slug) || "";
12154
+ var reg = await _ownedRegistry(slug, auth.customer_id);
12155
+ if (!reg) {
12156
+ return _send(res, 404, renderNotFound({ shop_name: shopName, cart_count: await _cartCountForReq(req) }));
12157
+ }
12158
+ try {
12159
+ await deps.giftRegistry.removeItem({
12160
+ registry_slug: slug,
12161
+ item_id: (req.params && req.params.item_id) || "",
12162
+ });
12163
+ } catch (e) {
12164
+ return _registryManageError(req, res, reg, e);
12165
+ }
12166
+ res.status(303);
12167
+ res.setHeader && res.setHeader("location", "/account/registry/" + encodeURIComponent(slug) + "?ok=removed");
12168
+ return res.end ? res.end() : res.send("");
12169
+ });
12170
+
12171
+ // POST /account/registry/:slug/edit — update registry details
12172
+ // (title / recipient_name / event_date / privacy) on a registry the
12173
+ // session customer owns.
12174
+ router.post("/account/registry/:slug/edit", async function (req, res) {
12175
+ var auth = _registryAuthOrRedirect(req, res);
12176
+ if (!auth) return;
12177
+ var slug = (req.params && req.params.slug) || "";
12178
+ var reg = await _ownedRegistry(slug, auth.customer_id);
12179
+ if (!reg) {
12180
+ return _send(res, 404, renderNotFound({ shop_name: shopName, cart_count: await _cartCountForReq(req) }));
12181
+ }
12182
+ var body = req.body || {};
12183
+ var patch = {
12184
+ title: String(body.title || ""),
12185
+ recipient_name: String(body.recipient_name || ""),
12186
+ privacy: String(body.privacy || ""),
12187
+ };
12188
+ if (typeof body.event_date === "string" && body.event_date.length) {
12189
+ var parsed = Date.parse(body.event_date + "T00:00:00Z");
12190
+ patch.event_date = isNaN(parsed) ? null : parsed;
12191
+ } else {
12192
+ patch.event_date = null;
12193
+ }
12194
+ try {
12195
+ await deps.giftRegistry.update(slug, patch);
12196
+ } catch (e) {
12197
+ return _registryManageError(req, res, reg, e);
12198
+ }
12199
+ res.status(303);
12200
+ res.setHeader && res.setHeader("location", "/account/registry/" + encodeURIComponent(slug) + "?ok=updated");
12201
+ return res.end ? res.end() : res.send("");
12202
+ });
12203
+
12204
+ // POST /account/registry/:slug/close — close a registry the session
12205
+ // customer owns (the only FSM transition; refuses further mutation).
12206
+ router.post("/account/registry/:slug/close", async function (req, res) {
12207
+ var auth = _registryAuthOrRedirect(req, res);
12208
+ if (!auth) return;
12209
+ var slug = (req.params && req.params.slug) || "";
12210
+ var reg = await _ownedRegistry(slug, auth.customer_id);
12211
+ if (!reg) {
12212
+ return _send(res, 404, renderNotFound({ shop_name: shopName, cart_count: await _cartCountForReq(req) }));
12213
+ }
12214
+ try {
12215
+ await deps.giftRegistry.closeRegistry(slug);
12216
+ } catch (e) {
12217
+ return _registryManageError(req, res, reg, e);
12218
+ }
12219
+ res.status(303);
12220
+ res.setHeader && res.setHeader("location", "/account/registry/" + encodeURIComponent(slug) + "?ok=closed");
12221
+ return res.end ? res.end() : res.send("");
12222
+ });
12223
+
12224
+ // GET /registry/:slug — the public, no-auth giver view. Resolves the
12225
+ // registry ONLY through getBySlug (never a guessable owner/registry id),
12226
+ // then enforces the privacy gate in the route: a `private` registry
12227
+ // 404s exactly like an unknown slug — no existence oracle. The owner's
12228
+ // identity / shipping address / per-buyer purchase rows are NEVER
12229
+ // carried into this shape. noindex.
12230
+ router.get("/registry/:slug", async function (req, res) {
12231
+ var slug = (req.params && req.params.slug) || "";
12232
+ var view = null;
12233
+ try {
12234
+ view = await deps.giftRegistry.getBySlug(slug);
12235
+ } catch (_e) {
12236
+ // A malformed slug (TypeError) renders the same 404 as an unknown
12237
+ // one — no oracle on why.
12238
+ return _send(res, 404, renderNotFound({ shop_name: shopName, cart_count: await _cartCountForReq(req) }));
12239
+ }
12240
+ // Honor the privacy gate: a missing registry AND a `private` one are
12241
+ // not publicly viewable — both 404 identically (no existence oracle).
12242
+ // `private` resolves only through the owner's /account/registry surface
12243
+ // (scoped to the session customer); `unlisted` + `public` are reachable
12244
+ // by anyone who knows the slug.
12245
+ if (!view || !view.registry || view.registry.privacy === "private") {
12246
+ return _send(res, 404, renderNotFound({ shop_name: shopName, cart_count: await _cartCountForReq(req) }));
12247
+ }
12248
+ var prog = null;
12249
+ try { prog = await deps.giftRegistry.progressFor(slug); }
12250
+ catch (_e) { prog = null; }
12251
+ var remainingById = Object.create(null);
12252
+ var purchasedById = Object.create(null);
12253
+ var desiredById = Object.create(null);
12254
+ if (prog && prog.items) {
12255
+ for (var p = 0; p < prog.items.length; p += 1) {
12256
+ remainingById[prog.items[p].item_id] = prog.items[p].remaining;
12257
+ purchasedById[prog.items[p].item_id] = prog.items[p].purchased;
12258
+ desiredById[prog.items[p].item_id] = prog.items[p].quantity_desired;
12259
+ }
12260
+ }
12261
+ var items = [];
12262
+ var rawItems = view.items || [];
12263
+ for (var i = 0; i < rawItems.length; i += 1) {
12264
+ var dec = await _decorateRegistryItem(rawItems[i]);
12265
+ dec.desired = desiredById[rawItems[i].id] != null ? desiredById[rawItems[i].id] : rawItems[i].quantity_desired;
12266
+ dec.purchased = purchasedById[rawItems[i].id] || 0;
12267
+ dec.remaining = remainingById[rawItems[i].id] != null ? remainingById[rawItems[i].id] : dec.desired;
12268
+ items.push(dec);
12269
+ }
12270
+ var pubUrl = req.url ? new URL(req.url, "http://localhost") : null;
12271
+ // Build a redacted, public-safe registry view — only the fields a
12272
+ // giver may see (title / occasion / event_date / message / privacy /
12273
+ // status). The owner_customer_id + shipping_address_id are dropped
12274
+ // here so they can NEVER reach the rendered HTML.
12275
+ var reg = view.registry;
12276
+ var publicReg = {
12277
+ slug: reg.slug,
12278
+ title: reg.title,
12279
+ occasion: reg.occasion,
12280
+ event_date: reg.event_date,
12281
+ message: reg.message,
12282
+ privacy: reg.privacy,
12283
+ status: reg.status,
12284
+ };
12285
+ _send(res, 200, renderRegistryPublic({
12286
+ registry: publicReg,
12287
+ items: items,
12288
+ notice: pubUrl ? pubUrl.searchParams.get("ok") : null,
12289
+ shop_name: shopName,
12290
+ cart_count: await _cartCountForReq(req),
12291
+ asset_prefix: deps.asset_prefix || "/assets/",
12292
+ }));
12293
+ });
12294
+
12295
+ // POST /registry/:slug/items/:item_id/purchase — a giver records a gift
12296
+ // (marks the item purchased). This is the mark-purchased / record-a-gift
12297
+ // action the primitive's purchaseItem implements: it decrements the
12298
+ // remaining-needed qty and tracks who bought it (anonymous by default).
12299
+ // It carries NO payment — a giver who wants to actually buy the item
12300
+ // adds it to their cart (the per-item "Add to cart" form posts to
12301
+ // /cart/lines) and pays through the regular Stripe-gated checkout. A
12302
+ // dedicated registry-funds settlement (charge the giver, route the money
12303
+ // to the owner) is intentionally not wired: the normal cart/checkout path
12304
+ // already lets a giver buy a registry item end-to-end, so it only lands
12305
+ // if an operator needs registry-scoped fulfillment (ship-to-owner +
12306
+ // gift-message capture) the standard order flow doesn't cover. The action
12307
+ // is slug+item gated to the registry, NOT customer-scoped (a giver need
12308
+ // not be logged in); it is shape-safe — a closed registry, an
12309
+ // over-purchase, an unknown item, or a malformed slug all resolve to a
12310
+ // clean redirect
12311
+ // / 404, never a 500.
12312
+ router.post("/registry/:slug/items/:item_id/purchase", async function (req, res) {
12313
+ var slug = (req.params && req.params.slug) || "";
12314
+ var itemId = (req.params && req.params.item_id) || "";
12315
+ // Resolve through getBySlug so a private / unknown registry is never
12316
+ // purchasable (the same privacy gate the GET view uses) — a giver can
12317
+ // only act on a registry they could legitimately reach by link.
12318
+ var view = null;
12319
+ try { view = await deps.giftRegistry.getBySlug(slug); }
12320
+ catch (_e) { view = null; }
12321
+ // Same privacy gate as the GET view — a private (or unknown) registry
12322
+ // is never publicly purchasable.
12323
+ if (!view || !view.registry || view.registry.privacy === "private") {
12324
+ return _send(res, 404, renderNotFound({ shop_name: shopName, cart_count: await _cartCountForReq(req) }));
12325
+ }
12326
+ var body = req.body || {};
12327
+ var qty = parseInt(body.quantity, 10);
12328
+ if (!Number.isFinite(qty) || qty < 1) qty = 1;
12329
+ // `reveal` requires a signed-in giver — an anonymous reveal makes no
12330
+ // sense (there's no identity to surface). When the box is ticked and
12331
+ // the giver is logged in, attribute the gift to them; otherwise the
12332
+ // purchase is anonymous.
12333
+ var reveal = (body.reveal === "1" || body.reveal === "on" || body.reveal === true);
12334
+ var buyerId = null;
12335
+ if (reveal) {
12336
+ var giverAuth = null;
12337
+ try { giverAuth = _currentCustomer(req); } catch (_e) { giverAuth = null; }
12338
+ if (giverAuth && giverAuth.customer_id) buyerId = giverAuth.customer_id;
12339
+ else reveal = false; // not signed in → fall back to anonymous
12340
+ }
12341
+ var purchaseInput = {
12342
+ registry_slug: slug,
12343
+ item_id: itemId,
12344
+ quantity: qty,
12345
+ reveal_buyer: reveal,
12346
+ };
12347
+ if (buyerId) purchaseInput.buyer_customer_id = buyerId;
12348
+ try {
12349
+ await deps.giftRegistry.purchaseItem(purchaseInput);
12350
+ } catch (_e) {
12351
+ // A closed registry, an over-purchase, an archived / unknown item,
12352
+ // or a malformed slug/item id are all giver-recoverable: bounce
12353
+ // back to the public registry page (no item landed) rather than
12354
+ // 500. A truly unexpected error (non-TypeError, uncoded) still
12355
+ // surfaces as a redirect — the page re-render shows current state.
12356
+ res.status(303);
12357
+ res.setHeader && res.setHeader("location", "/registry/" + encodeURIComponent(slug));
12358
+ return res.end ? res.end() : res.send("");
12359
+ }
12360
+ res.status(303);
12361
+ res.setHeader && res.setHeader("location", "/registry/" + encodeURIComponent(slug) + "?ok=gifted");
12362
+ return res.end ? res.end() : res.send("");
12363
+ });
12364
+
12365
+ // Shared owner-route auth gate: resolve the session customer or send the
12366
+ // redirect / 503 and return null. Mirrors the wishlist `_savedAuth`
12367
+ // shape so every owner registry write funnels through one check.
12368
+ function _registryAuthOrRedirect(req, res) {
12369
+ var auth;
12370
+ try { auth = _currentCustomer(req); }
12371
+ catch (e) {
12372
+ if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
12373
+ throw e;
12374
+ }
12375
+ if (!auth) {
12376
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
12377
+ res.end ? res.end() : res.send("");
12378
+ return null;
12379
+ }
12380
+ return auth;
12381
+ }
12382
+
12383
+ // Re-render the manage page with an inline error after a refused owner
12384
+ // mutation (a bad sku, a duplicate item, a closed-registry write). A
12385
+ // TypeError / coded over-purchase is operator-recoverable → 400 with the
12386
+ // message; anything else is a 500.
12387
+ async function _registryManageError(req, res, reg, e) {
12388
+ var status = (e instanceof TypeError || (e && e.code === "GIFT_REGISTRY_OVER_PURCHASE")) ? 400 : 500;
12389
+ // Reload current items so the error page shows live state.
12390
+ var items = [];
12391
+ try {
12392
+ var view = await deps.giftRegistry.getBySlug(reg.slug);
12393
+ var prog = await deps.giftRegistry.progressFor(reg.slug);
12394
+ var remById = Object.create(null), purById = Object.create(null), desById = Object.create(null);
12395
+ if (prog && prog.items) {
12396
+ for (var p = 0; p < prog.items.length; p += 1) {
12397
+ remById[prog.items[p].item_id] = prog.items[p].remaining;
12398
+ purById[prog.items[p].item_id] = prog.items[p].purchased;
12399
+ desById[prog.items[p].item_id] = prog.items[p].quantity_desired;
12400
+ }
12401
+ }
12402
+ var rawItems = (view && view.items) || [];
12403
+ for (var i = 0; i < rawItems.length; i += 1) {
12404
+ var dec = await _decorateRegistryItem(rawItems[i]);
12405
+ dec.desired = desById[rawItems[i].id] != null ? desById[rawItems[i].id] : rawItems[i].quantity_desired;
12406
+ dec.purchased = purById[rawItems[i].id] || 0;
12407
+ dec.remaining = remById[rawItems[i].id] != null ? remById[rawItems[i].id] : dec.desired;
12408
+ items.push(dec);
12409
+ }
12410
+ } catch (_e) { items = []; }
12411
+ return _send(res, status, renderRegistryManage({
12412
+ registry: reg,
12413
+ items: items,
12414
+ error: status === 400 ? ((e && e.message) || "We couldn't make that change.") : "Something went wrong. Please try again.",
12415
+ shop_name: shopName,
12416
+ cart_count: await _cartCountForReq(req),
12417
+ asset_prefix: deps.asset_prefix || "/assets/",
12418
+ }));
12419
+ }
12420
+ }
12421
+
11269
12422
  // Save for later — move a cart line into a per-customer holding
11270
12423
  // list and back. Login required (the list is per-customer).
11271
12424
  if (deps.saveForLater) {
@@ -11878,6 +13031,165 @@ function mount(router, deps) {
11878
13031
  }
11879
13032
  }
11880
13033
 
13034
+ // Pre-order reservations — the PDP reserve POST + the customer's
13035
+ // /account/preorders surface (list + cancel). A reservation is INTENT, not
13036
+ // a charge: reserve() writes a row pinned to the SIGNED-IN SESSION
13037
+ // customer (never a body/query id) + decrements the campaign's capacity;
13038
+ // the launch flow later converts it into a regular (Stripe-gated) order.
13039
+ // Mounts only when the preorder primitive is wired.
13040
+ if (preorder) {
13041
+ function _preorderAuth(req, res) {
13042
+ var auth;
13043
+ try { auth = _currentCustomer(req); }
13044
+ catch (e) {
13045
+ if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
13046
+ throw e;
13047
+ }
13048
+ if (!auth) {
13049
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
13050
+ res.end ? res.end() : res.send("");
13051
+ return null;
13052
+ }
13053
+ return auth;
13054
+ }
13055
+
13056
+ // Defensive request-shape reader for the reserve form's quantity. A
13057
+ // missing / non-numeric / non-positive value defaults to 1 so a no-JS
13058
+ // submit still reserves a unit; the primitive is authoritative on the
13059
+ // cap (it refuses an over-cap quantity), and a >99 paste clamps to 99 to
13060
+ // match the form's max attribute.
13061
+ function _preorderQty(raw) {
13062
+ var n = parseInt(String(raw == null ? "" : raw), 10);
13063
+ if (!Number.isFinite(n) || n <= 0) return 1;
13064
+ return n > 99 ? 99 : n;
13065
+ }
13066
+
13067
+ // POST /products/:slug/preorder — reserve a unit of the lead SKU's OPEN
13068
+ // campaign. Auth-gated; the reservation is pinned to the session
13069
+ // customer (auth.customer_id), NEVER a body/query id. The campaign is
13070
+ // resolved from the product's lead SKU (not a client-supplied slug), so
13071
+ // a shopper can only reserve against the campaign the PDP actually
13072
+ // shows. Over-cap / closed / missing campaign → a clean 4xx PRG back to
13073
+ // the PDP with a fixed ?preorder error code (no raw error text).
13074
+ router.post("/products/:slug/preorder", async function (req, res) {
13075
+ var auth = _preorderAuth(req, res); if (!auth) return;
13076
+ var slug = req.params && req.params.slug;
13077
+ var enc = encodeURIComponent(slug || "");
13078
+ var product = slug ? await deps.catalog.products.bySlug(slug) : null;
13079
+ if (!product) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
13080
+ var variants = await deps.catalog.variants.listForProduct(product.id);
13081
+ var lead = variants[0] || null;
13082
+ // No lead variant, or no OPEN campaign for it → there's nothing
13083
+ // reservable here; bounce to the PDP with the closed marker.
13084
+ var campaign = null;
13085
+ if (lead) {
13086
+ try { campaign = await preorder.openCampaignForSku(lead.sku); }
13087
+ catch (_e) { campaign = null; }
13088
+ }
13089
+ if (!campaign) {
13090
+ res.status(303); res.setHeader && res.setHeader("location", "/products/" + enc + "?preorder=closed");
13091
+ return res.end ? res.end() : res.send("");
13092
+ }
13093
+ var qty = _preorderQty((req.body || {}).qty);
13094
+ try {
13095
+ await preorder.reserve({
13096
+ campaign_slug: campaign.slug,
13097
+ customer_id: auth.customer_id,
13098
+ quantity: qty,
13099
+ });
13100
+ } catch (e) {
13101
+ // A capacity / closed-campaign / shape refusal is the customer's
13102
+ // problem to see, not a 500 — map every TypeError to a fixed
13103
+ // ?preorder error marker (the reason copy is rendered on the PDP,
13104
+ // never the raw message). Anything else rethrows (a real 500).
13105
+ if (!(e instanceof TypeError)) throw e;
13106
+ res.status(303); res.setHeader && res.setHeader("location", "/products/" + enc + "?preorder=unavailable");
13107
+ return res.end ? res.end() : res.send("");
13108
+ }
13109
+ res.status(303); res.setHeader && res.setHeader("location", "/products/" + enc + "?preorder=reserved");
13110
+ return res.end ? res.end() : res.send("");
13111
+ });
13112
+
13113
+ // Load the reservation named in :id and confirm it belongs to the
13114
+ // signed-in customer. A malformed id (guardUuid TypeError), a missing
13115
+ // row, or another customer's reservation all return 404 after sending
13116
+ // it — never a 500, never a cross-customer cancel. The reservation
13117
+ // primitive cancels by id alone, so the route owns the ownership
13118
+ // decision.
13119
+ async function _ownedReservation(req, res, auth) {
13120
+ var resv;
13121
+ try { resv = await preorder.getReservation(req.params && req.params.id); }
13122
+ catch (e) {
13123
+ if (e instanceof TypeError) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
13124
+ throw e;
13125
+ }
13126
+ if (!resv || resv.customer_id !== auth.customer_id) {
13127
+ _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
13128
+ return null;
13129
+ }
13130
+ return resv;
13131
+ }
13132
+
13133
+ // The customer's reservations, decorated with each campaign's status +
13134
+ // release date so the list reads "Operator Tee — ships 2026-09-01 —
13135
+ // active". Campaigns are batched: one getCampaign per distinct slug,
13136
+ // cached across rows. A read failure (table not migrated) degrades to an
13137
+ // empty list rather than 500-ing the account page.
13138
+ async function _preordersForCustomer(customerId) {
13139
+ var rows;
13140
+ try { rows = await preorder.reservationsForCustomer(customerId); }
13141
+ catch (e) {
13142
+ if (e instanceof TypeError) return [];
13143
+ throw e;
13144
+ }
13145
+ var campaignCache = {};
13146
+ for (var i = 0; i < rows.length; i += 1) {
13147
+ var cslug = rows[i].campaign_slug;
13148
+ if (cslug != null && !Object.prototype.hasOwnProperty.call(campaignCache, cslug)) {
13149
+ try { campaignCache[cslug] = await preorder.getCampaign(cslug); }
13150
+ catch (_e) { campaignCache[cslug] = null; }
13151
+ }
13152
+ rows[i].campaign = cslug != null ? campaignCache[cslug] : null;
13153
+ }
13154
+ return rows;
13155
+ }
13156
+
13157
+ router.get("/account/preorders", async function (req, res) {
13158
+ var auth = _preorderAuth(req, res); if (!auth) return;
13159
+ var rows = await _preordersForCustomer(auth.customer_id);
13160
+ var cartCount = await _cartCountForReq(req);
13161
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
13162
+ var okKind = url ? url.searchParams.get("ok") : null;
13163
+ var notice = okKind === "canceled" ? "Your pre-order reservation has been canceled." : null;
13164
+ _send(res, 200, renderAccountPreorders({
13165
+ reservations: rows,
13166
+ notice: notice,
13167
+ shop_name: shopName,
13168
+ cart_count: cartCount,
13169
+ }));
13170
+ });
13171
+
13172
+ // POST /account/preorders/:id/cancel — cancel the customer's own active
13173
+ // reservation, freeing the held capacity. Ownership-scoped: a malformed
13174
+ // / unknown / foreign reservation id 404s before any write. A
13175
+ // non-active reservation (already converted / cancelled) is a clean PRG
13176
+ // back to the list, not a 500.
13177
+ router.post("/account/preorders/:id/cancel", async function (req, res) {
13178
+ var auth = _preorderAuth(req, res); if (!auth) return;
13179
+ var resv = await _ownedReservation(req, res, auth); if (!resv) return;
13180
+ try {
13181
+ await preorder.cancelReservation({ reservation_id: resv.id, reason: "customer-cancelled" });
13182
+ } catch (e) {
13183
+ if (!(e instanceof TypeError)) throw e;
13184
+ // Already converted/cancelled → nothing to do; bounce back clean.
13185
+ res.status(303); res.setHeader && res.setHeader("location", "/account/preorders");
13186
+ return res.end ? res.end() : res.send("");
13187
+ }
13188
+ res.status(303); res.setHeader && res.setHeader("location", "/account/preorders?ok=canceled");
13189
+ return res.end ? res.end() : res.send("");
13190
+ });
13191
+ }
13192
+
11881
13193
  // Self-serve returns — a customer requests an RMA against one of
11882
13194
  // their own orders and tracks its status. Operators action it via
11883
13195
  // the admin /admin/returns queue. Needs the returns primitive + an
@@ -13737,6 +15049,7 @@ module.exports = {
13737
15049
  renderHome: renderHome,
13738
15050
  renderSearch: renderSearch,
13739
15051
  renderProduct: renderProduct,
15052
+ preorderCtaShape: preorderCtaShape,
13740
15053
  renderCollectionList: renderCollectionList,
13741
15054
  renderCollection: renderCollection,
13742
15055
  renderCategoryIndex: renderCategoryIndex,