@blamejs/blamejs-shop 0.3.47 → 0.3.49

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,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.3.x
10
10
 
11
+ - v0.3.49 (2026-06-02) — **Real collection tiles on the home page, a working mobile nav, Collections/Categories in the header, and From-price on multi-variant products.** The home page's 'Featured collections' band linked to fixed keyword searches that landed on empty results unless a product happened to contain those words, the header navigation disappeared entirely on phones with no menu to replace it, and the two real catalog-browse surfaces (Collections and Categories) were reachable only from the footer. This release resolves those: the home tiles now render the store's actual collections with real links, the header gains a no-JavaScript mobile menu, and Collections and Categories appear in the primary nav. Multi-variant products now show a 'From <lowest price>' headline instead of always showing the first variant's price, the non-functional 'New arrivals'/'On sale' sort links are removed, and two small accessibility fixes land (the survey required-field marker and a dark color-scheme hint for native form controls). **Changed:** *Home 'Featured collections' tiles render real collections* — The featured-collections band on the home page now lists the store's actual collections with links to each collection page, instead of fixed keyword searches that could land on an empty results page. If no collections are defined, the band is omitted rather than shown empty. Both the cached and dynamically rendered home pages produce identical markup. · *Mobile navigation menu (no JavaScript required)* — On narrow screens the header now offers a disclosure menu that reveals the navigation links, which previously vanished entirely below a tablet width. It is a pure-CSS menu, so it works with JavaScript disabled. · *Collections and Categories in the header navigation* — The two catalog-browse surfaces — Collections and Categories — are now in the primary header navigation (and inside the mobile menu), not only in the footer. · *Multi-variant products show a 'From' price* — A product whose variants differ in price now shows a 'From <lowest price>' headline so the prominent price is no longer misleading when a shopper selects a different variant; each variant chip still shows its own exact price. · *Removed non-functional sort links* — The 'New arrivals' and 'On sale' links did nothing — the home page does not yet support sorting — so they have been removed rather than left as dead controls. They will return if and when catalog sorting is added. · *Accessibility: survey required marker and dark color-scheme* — The survey form's required-field marker is now announced correctly to screen readers, and the theme declares a dark color-scheme so native form controls, scrollbars, and autofill render correctly on the dark surface.
12
+
13
+ - v0.3.48 (2026-06-02) — **Customers can export or request deletion of their personal data, and the admin console gets a privacy-request queue to fulfil it.** The framework had a complete data-subject-request engine but nothing reached it, so a store handling EU/California personal data had no way for a shopper to exercise their right of access or erasure and no operator path to fulfil one. A signed-in customer can now request a full export of their data — account, addresses, orders, subscriptions, loyalty, and support history, streamed as a downloadable JSON bundle — and can file a deletion request. The admin console gets a Privacy requests screen where an operator reviews each request, downloads the export, previews a deletion (a dry run that changes nothing), and then executes it. Erasure anonymizes the customer's account and archives their addresses and subscriptions while retaining orders, loyalty ledger, and support tickets that must be kept for tax and legal records. A customer can only ever see or act on their own requests; deletion is always filed by the customer and carried out by an operator. **Added:** *Customer data export and deletion requests* — A signed-in customer gets a Privacy & data area under their account where they can request a full export of their personal data and file a deletion request. The export is streamed as a downloadable JSON bundle covering their account, addresses, orders, subscriptions, loyalty balance and ledger, and support history. A customer can only access their own requests and downloads; a request belonging to anyone else returns not-found. · *Privacy-request queue in the admin console* — A new Privacy requests screen lists every data-subject request with its status. An operator can download a request's export, preview a deletion as a dry run that reports what would change without modifying anything, and then execute the deletion behind a confirmation step. Deletion anonymizes the customer's account and archives their addresses and subscriptions, while orders, the loyalty ledger, and support tickets are retained for tax and legal record-keeping. Every customer- and operator-entered field shown in either console is HTML-escaped.
14
+
11
15
  - v0.3.47 (2026-06-01) — **Admin product detail, collections list, and order shipment panels load with fewer database round trips.** Three admin-console screens re-derived data with one database query per item: the product-detail screen ran a query per variant per currency for current and historical prices, the collections list pulled up to 200 member rows for each collection just to count them, and the order-detail shipment panel hydrated each shipment and its labels sequentially. These now read in batched or parallel queries. The displayed data is unchanged, with one correctness improvement: a manual collection with more than 200 members now shows its true product count instead of a number capped at 200. **Changed:** *Batched admin product-detail price hydration* — The admin product-detail screen now loads every variant's current and historical prices across all currencies in a single batched query instead of one query per variant per currency. The screen renders identically. · *Collections list counts without pulling member rows* — The admin collections list now counts a manual collection's members with a COUNT query instead of fetching up to 200 member rows per collection. A manual collection with more than 200 members now shows its true count (previously the displayed count was capped at 200); rule-based collections keep their existing bounded preview count. · *Parallel shipment hydration on order detail* — The order-detail shipment panel now hydrates each shipment and its labels in parallel rather than one after another, so an order with several shipments opens faster.
12
16
 
13
17
  - v0.3.46 (2026-06-01) — **Storefront pages render in far fewer database round trips, and the search collection filter is consistent across cache tiers.** The dynamically rendered storefront (the path that serves returning, signed-in, or non-default-currency shoppers) re-derived product data with one database query per item — a home or search page could issue dozens to hundreds of round trips, where the cached edge already did the same work in a single batched query. The home grid, search results, product page, related-products rail, and cart now read product, variant, price, inventory, and media data in batched queries that mirror the edge exactly, so those pages render with far fewer round trips and identical output. As part of this, the search 'collection' filter now reflects manual collection membership consistently whether a page is served from the cache or rendered dynamically. **Changed:** *Batched product reads on storefront pages* — The home grid, search results, product detail, related-products rail, and cart now load product/variant/price/inventory/media data in batched database queries instead of one query per item. On a cache miss, a signed-in session, or a non-default currency the dynamic render path previously issued a query per product (and per variant); these pages now match the single-query shape the cached edge already used. Rendered pages are unchanged. · *Consistent search collection filter across cache tiers* — The 'collection' filter on the search page now reflects manual collection membership on both the cached and the dynamically rendered paths. Previously the dynamic path could additionally surface smart-collection rule matches in that filter, so a shopper could see different collection filter options depending on which path served the page. If you relied on smart (rule-based) collections appearing as search filters, they now appear only where a product is an explicit member; this aligns the two render paths.
package/lib/admin.js CHANGED
@@ -511,6 +511,7 @@ function mount(router, deps) {
511
511
  var splitShipments = deps.splitShipments || null; // order split-shipment planner disabled when absent
512
512
  var salesTaxFilings = deps.salesTaxFilings || null; // sales-tax-filing remittance console disabled when absent
513
513
  var supportTickets = deps.supportTickets || null; // support-ticket queue + thread console disabled when absent
514
+ var complianceExport = deps.complianceExport || null; // DSR queue (export/erasure) disabled when absent
514
515
  var orderExchanges = deps.orderExchanges || null; // exchange queue + FSM-action console disabled when absent
515
516
  var orderRatings = deps.orderRatings || null; // per-order rating moderation queue (flag/clear + public reply) disabled when absent
516
517
  var preorder = deps.preorder || null; // pre-order campaign console (define/launch/close) disabled when absent
@@ -521,7 +522,7 @@ function mount(router, deps) {
521
522
  // `reports` is always present in the nav (read-only sales summary needs no
522
523
  // extra dep); its route mounts unconditionally and renders an unconfigured
523
524
  // notice when the salesReports primitive isn't wired.
524
- var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, orderExport: !!orderExport };
525
+ var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, orderExport: !!orderExport };
525
526
 
526
527
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
527
528
 
@@ -4101,6 +4102,211 @@ function mount(router, deps) {
4101
4102
  _supportTransition("reopen", "reopened");
4102
4103
  }
4103
4104
 
4105
+ // ---- subject-access-request (DSR) queue -----------------------------
4106
+ // The operator side of the GDPR / CCPA / LGPD lifecycle: list + detail +
4107
+ // fulfil/dispatch/preview/execute/dismiss + a streaming export download.
4108
+ // Content-negotiated like the other screens: bearer → JSON; signed-in
4109
+ // browser → the HTML console. The primitive throws TypeError ONLY (no
4110
+ // `.code`-bearing FSM error — confirmed across lib/compliance-export.js):
4111
+ // a malformed id, a not-found row, and a bad-status transition are ALL
4112
+ // TypeError, so the catch maps TypeError → a 4xx notice and there is no
4113
+ // `.code` branch to order. If a future status-conflict error gains a
4114
+ // `.code`, add the branch BEFORE the TypeError catch so it isn't collapsed.
4115
+ if (complianceExport) {
4116
+ var dsr = complianceExport;
4117
+ var dsrReaders = deps.complianceExportReaders || {};
4118
+ var dsrSections = deps.complianceExportSections || {};
4119
+ var streamDsr = deps.streamDsrBundle;
4120
+
4121
+ // Resolve the ?status= chip into a list. "all" / a bad chip → the whole
4122
+ // board; a real status → the filtered list.
4123
+ async function _dsrRows(filter) {
4124
+ if (filter && filter !== "all" && DSR_STATUS_FILTERS.indexOf(filter) !== -1) {
4125
+ return { rows: await dsr.listRequests({ status: filter, limit: 200 }), filter: filter };
4126
+ }
4127
+ return { rows: await dsr.listRequests({ limit: 200 }), filter: "all" };
4128
+ }
4129
+
4130
+ router.get("/admin/dsr", _pageOrApi(true,
4131
+ R(async function (req, res) {
4132
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4133
+ var filter = (url && url.searchParams.get("status")) || null;
4134
+ var resolved = await _dsrRows(filter);
4135
+ _json(res, 200, { rows: resolved.rows, status: resolved.filter });
4136
+ }),
4137
+ async function (req, res) {
4138
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4139
+ var filter = (url && url.searchParams.get("status")) || null;
4140
+ var resolved = await _dsrRows(filter);
4141
+ _sendHtml(res, 200, renderAdminDsr({
4142
+ shop_name: deps.shop_name, nav_available: navAvailable,
4143
+ requests: resolved.rows, status: resolved.filter,
4144
+ }));
4145
+ },
4146
+ ));
4147
+
4148
+ // Resolve a request by :id, surfacing a malformed / unknown id as a 404
4149
+ // (defensive request-shape reader, never a 500). getRequest throws
4150
+ // TypeError on a bad uuid → 404; a null row → 404.
4151
+ async function _dsrOr404(req, res, isJson) {
4152
+ var row;
4153
+ try { row = await dsr.getRequest(req.params.id); }
4154
+ catch (e) {
4155
+ if (e instanceof TypeError) {
4156
+ if (isJson) { _problem(res, 404, "dsr-request-not-found"); return null; }
4157
+ _sendHtml(res, 404, renderAdminDsr({ shop_name: deps.shop_name, nav_available: navAvailable, requests: [], status: "all", notice: "Request not found." }));
4158
+ return null;
4159
+ }
4160
+ throw e;
4161
+ }
4162
+ if (!row) {
4163
+ if (isJson) { _problem(res, 404, "dsr-request-not-found"); return null; }
4164
+ _sendHtml(res, 404, renderAdminDsr({ shop_name: deps.shop_name, nav_available: navAvailable, requests: [], status: "all", notice: "Request not found." }));
4165
+ return null;
4166
+ }
4167
+ return row;
4168
+ }
4169
+
4170
+ router.get("/admin/dsr/:id", _pageOrApi(true,
4171
+ R(async function (req, res) {
4172
+ var row = await _dsrOr404(req, res, true); if (!row) return;
4173
+ var history = await dsr.auditForCustomer(row.customer_id);
4174
+ _json(res, 200, { request: row, history: history });
4175
+ }),
4176
+ async function (req, res) {
4177
+ var row = await _dsrOr404(req, res, false); if (!row) return;
4178
+ var history = await dsr.auditForCustomer(row.customer_id);
4179
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4180
+ // A deletion request's ?preview=1 renders the dry-run blast radius —
4181
+ // processDeletion({ dry_run: true }) NEVER mutates (the primitive's
4182
+ // only safety net). A bad-status row (already terminal) throws
4183
+ // TypeError → drop the preview, render the detail without it.
4184
+ var preview = null;
4185
+ if (url && url.searchParams.get("preview") === "1" && row.request_kind === "deletion") {
4186
+ try { preview = await dsr.processDeletion({ request_id: row.id, dry_run: true }); }
4187
+ catch (e) { if (!(e instanceof TypeError)) throw e; preview = null; }
4188
+ }
4189
+ _sendHtml(res, 200, renderAdminDsrDetail({
4190
+ shop_name: deps.shop_name, nav_available: navAvailable,
4191
+ request: row, history: history, preview: preview,
4192
+ moved: url && url.searchParams.get("moved"),
4193
+ notice: url && url.searchParams.get("err") ? "That action couldn't be completed for this request." : null,
4194
+ }));
4195
+ },
4196
+ ));
4197
+
4198
+ // Streaming export download. Gated: the row must be an export in status
4199
+ // fulfilled / delivered (an un-fulfilled / deletion / unknown row → 404).
4200
+ // Status + ownership are validated BEFORE the first write (the bundle
4201
+ // streams header-first; status can't change mid-stream). The route
4202
+ // re-reads the readers rather than re-running fulfillRequest (which would
4203
+ // re-flip status on every download).
4204
+ router.get("/admin/dsr/:id/export.json", _pageOrApi(true,
4205
+ R(async function (req, res) {
4206
+ var row = await _dsrOr404(req, res, true); if (!row) return;
4207
+ if (row.request_kind !== "export" || (row.status !== "fulfilled" && row.status !== "delivered")) {
4208
+ return _problem(res, 404, "dsr-export-not-ready");
4209
+ }
4210
+ var sections = dsrSections[row.scope] || dsrSections.full || [];
4211
+ await streamDsr(res, dsrReaders, sections, row);
4212
+ }),
4213
+ async function (req, res) {
4214
+ var row = await _dsrOr404(req, res, false); if (!row) return;
4215
+ if (row.request_kind !== "export" || (row.status !== "fulfilled" && row.status !== "delivered")) {
4216
+ return _sendHtml(res, 404, renderAdminDsr({ shop_name: deps.shop_name, nav_available: navAvailable, requests: [], status: "all", notice: "Export not ready for download." }));
4217
+ }
4218
+ var sections = dsrSections[row.scope] || dsrSections.full || [];
4219
+ await streamDsr(res, dsrReaders, sections, row);
4220
+ },
4221
+ ));
4222
+
4223
+ // Per-request action helper: run `opFn(id, body)`, then PRG back to the
4224
+ // detail. The primitive throws TypeError for not-found / bad-status / bad
4225
+ // input — all become an ?err=1 notice, never a 500. There is no
4226
+ // `.code`-bearing error to branch on (see the block-head comment).
4227
+ function _dsrAction(jsonHandler, auditEvent, opFn) {
4228
+ return _pageOrApi(false, jsonHandler, async function (req, res) {
4229
+ var id = req.params.id;
4230
+ try { await opFn(id, req.body || {}); }
4231
+ catch (e) {
4232
+ if (e instanceof TypeError) {
4233
+ return _redirect(res, "/admin/dsr/" + encodeURIComponent(id) + "?err=1");
4234
+ }
4235
+ throw e;
4236
+ }
4237
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + "." + auditEvent, outcome: "success", metadata: { id: id } });
4238
+ _redirect(res, "/admin/dsr/" + encodeURIComponent(id) + "?moved=1");
4239
+ });
4240
+ }
4241
+
4242
+ router.post("/admin/dsr/:id/fulfill", _dsrAction(
4243
+ R(async function (req, res) {
4244
+ try { var bundle = await dsr.fulfillRequest({ request_id: req.params.id }); _json(res, 200, bundle); }
4245
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
4246
+ }),
4247
+ "dsr.fulfill",
4248
+ function (id) { return dsr.fulfillRequest({ request_id: id }); },
4249
+ ));
4250
+
4251
+ router.post("/admin/dsr/:id/dispatch", _dsrAction(
4252
+ R(async function (req, res) {
4253
+ var body = req.body || {};
4254
+ try { var row = await dsr.dispatchExport({ request_id: req.params.id, delivery_method: body.delivery_method, delivery_address: body.delivery_address }); _json(res, 200, row); }
4255
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
4256
+ }),
4257
+ "dsr.dispatch",
4258
+ function (id, body) { return dsr.dispatchExport({ request_id: id, delivery_method: body.delivery_method, delivery_address: body.delivery_address }); },
4259
+ ));
4260
+
4261
+ router.post("/admin/dsr/:id/dismiss", _dsrAction(
4262
+ R(async function (req, res) {
4263
+ var body = req.body || {};
4264
+ try { var row = await dsr.dismissRequest({ request_id: req.params.id, dismiss_reason: body.dismiss_reason }); _json(res, 200, row); }
4265
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
4266
+ }),
4267
+ "dsr.dismiss",
4268
+ function (id, body) { return dsr.dismissRequest({ request_id: id, dismiss_reason: body.dismiss_reason }); },
4269
+ ));
4270
+
4271
+ // Execute erasure — irreversible, so it routes through the confirm
4272
+ // interstitial. The detail's "Execute erasure…" button POSTs here; this
4273
+ // browser handler renders renderAdminConfirm, whose Confirm button POSTs
4274
+ // back here too (now carrying the confirmed flag via a hidden field), at
4275
+ // which point processDeletion runs for real (dry_run: false). The bearer
4276
+ // JSON path executes directly (tooling has no interstitial).
4277
+ router.post("/admin/dsr/:id/delete/confirm", _pageOrApi(false,
4278
+ R(async function (req, res) {
4279
+ try { var result = await dsr.processDeletion({ request_id: req.params.id, dry_run: false }); _json(res, 200, result); }
4280
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
4281
+ }),
4282
+ async function (req, res) {
4283
+ var id = req.params.id;
4284
+ var body = req.body || {};
4285
+ if (body.confirmed !== "1") {
4286
+ // First POST (from the detail button) — render the consequence.
4287
+ return _sendHtml(res, 200, renderAdminConfirm({
4288
+ shop_name: deps.shop_name, nav_available: navAvailable, active: "dsr",
4289
+ heading: "Execute erasure",
4290
+ consequence: "This permanently anonymizes the customer and archives their addresses + subscriptions. It cannot be undone.",
4291
+ detail: "Orders, the loyalty ledger, and support tickets are retained for legal/accounting reasons. Continue?",
4292
+ action: "/admin/dsr/" + encodeURIComponent(id) + "/delete/confirm",
4293
+ fields: { confirmed: "1" },
4294
+ confirm_label: "Execute erasure",
4295
+ cancel_href: "/admin/dsr/" + encodeURIComponent(id),
4296
+ }));
4297
+ }
4298
+ // Confirmed — run it for real, then PRG to the detail.
4299
+ try { await dsr.processDeletion({ request_id: id, dry_run: false }); }
4300
+ catch (e) {
4301
+ if (e instanceof TypeError) return _redirect(res, "/admin/dsr/" + encodeURIComponent(id) + "?err=1");
4302
+ throw e;
4303
+ }
4304
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".dsr.delete", outcome: "success", metadata: { id: id } });
4305
+ _redirect(res, "/admin/dsr/" + encodeURIComponent(id) + "?moved=1");
4306
+ },
4307
+ ));
4308
+ }
4309
+
4104
4310
  // ---- order exchanges ------------------------------------------------
4105
4311
  //
4106
4312
  // The operator side of a customer-requested item swap: the FIFO queue of
@@ -9700,6 +9906,7 @@ var ADMIN_NAV_ITEMS = [
9700
9906
  { key: "segments", href: "/admin/segments", label: "Segments", requires: "customerSegments" },
9701
9907
  { key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
9702
9908
  { key: "support", href: "/admin/support", label: "Support", requires: "supportTickets" },
9909
+ { key: "dsr", href: "/admin/dsr", label: "Privacy requests", requires: "complianceExport" },
9703
9910
  { key: "exchanges", href: "/admin/exchanges", label: "Exchanges", requires: "orderExchanges" },
9704
9911
  { key: "ratings", href: "/admin/ratings", label: "Ratings", requires: "orderRatings" },
9705
9912
  { key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
@@ -11617,6 +11824,182 @@ function renderAdminSupportTicket(opts) {
11617
11824
  return _renderAdminShell(opts.shop_name, "Ticket", body, "support", opts.nav_available);
11618
11825
  }
11619
11826
 
11827
+ // ---- subject-access-request (DSR) queue --------------------------------
11828
+ //
11829
+ // The operator side of the GDPR / CCPA / LGPD request lifecycle: a
11830
+ // status-filterable list of export + deletion requests, one request's
11831
+ // detail with its per-customer audit history + the action forms (fulfil +
11832
+ // download an export, mark delivered, preview a deletion dry-run, execute
11833
+ // the erasure behind a confirm interstitial, dismiss). Models the support
11834
+ // console shape. Every dynamic value — including operator/customer free
11835
+ // text (reason / dismiss_reason / delivery_address) — is _htmlEscape'd:
11836
+ // the primitive bounds length + strips control bytes but does NOT strip
11837
+ // HTML, so escape-at-render is the XSS gate.
11838
+ var DSR_STATUS_FILTERS = ["all", "received", "processing", "fulfilled", "delivered", "dismissed"];
11839
+
11840
+ function _dsrPillClass(status) {
11841
+ if (status === "fulfilled" || status === "delivered") return "paid";
11842
+ if (status === "dismissed") return "cancelled";
11843
+ return "pending"; // received / processing
11844
+ }
11845
+
11846
+ // Operator queue. Status chips filter the board; each row links to the
11847
+ // request detail. Renders the customer (short id), kind, jurisdiction,
11848
+ // scope, status pill, and when it was requested.
11849
+ function renderAdminDsr(opts) {
11850
+ opts = opts || {};
11851
+ var requests = opts.requests || [];
11852
+ var active = opts.status || "all";
11853
+ var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
11854
+
11855
+ var chips = "<div class=\"order-filters\">" +
11856
+ DSR_STATUS_FILTERS.map(function (s) {
11857
+ return "<a class=\"chip" + (active === s ? " chip--on" : "") + "\" href=\"/admin/dsr?status=" + encodeURIComponent(s) + "\">" + _htmlEscape(s) + "</a>";
11858
+ }).join("") +
11859
+ "</div>";
11860
+
11861
+ var rows = requests.map(function (r) {
11862
+ return "<tr>" +
11863
+ "<td><a class=\"order-id\" href=\"/admin/dsr/" + _htmlEscape(r.id) + "\">" + _htmlEscape(String(r.customer_id).slice(0, 8)) + "</a></td>" +
11864
+ "<td>" + _htmlEscape(r.request_kind) + "</td>" +
11865
+ "<td>" + _htmlEscape(r.jurisdiction) + "</td>" +
11866
+ "<td>" + _htmlEscape(r.scope || "—") + "</td>" +
11867
+ "<td><span class=\"status-pill " + _dsrPillClass(r.status) + "\">" + _htmlEscape(r.status) + "</span></td>" +
11868
+ "<td>" + _htmlEscape(_fmtDate(r.requested_at)) + "</td>" +
11869
+ "</tr>";
11870
+ }).join("");
11871
+
11872
+ var table = requests.length
11873
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Customer</th><th scope=\"col\">Kind</th><th scope=\"col\">Jurisdiction</th><th scope=\"col\">Scope</th><th scope=\"col\">Status</th><th scope=\"col\">Requested</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
11874
+ : "<p class=\"empty\">No “" + _htmlEscape(active) + "” privacy requests.</p>";
11875
+
11876
+ var body = "<section><h2>Privacy requests</h2>" +
11877
+ "<p class=\"meta\">Subject-access requests (data export) and erasure requests under GDPR / CCPA / LGPD.</p>" +
11878
+ notice + chips + table + "</section>";
11879
+ return _renderAdminShell(opts.shop_name, "Privacy requests", body, "dsr", opts.nav_available);
11880
+ }
11881
+
11882
+ // Request detail — the lifecycle, the customer's full request history, and
11883
+ // the action forms gated on kind + status. A deletion dry-run preview
11884
+ // (?preview=1) renders the per-domain blast-radius counts WITHOUT mutating.
11885
+ function renderAdminDsrDetail(opts) {
11886
+ opts = opts || {};
11887
+ var r = opts.request;
11888
+ var history = opts.history || [];
11889
+ var preview = opts.preview || null; // processDeletion dry-run result
11890
+ var moved = opts.moved ? "<div class=\"banner banner--ok\">Request updated.</div>" : "";
11891
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
11892
+
11893
+ function _field(label, value) {
11894
+ return "<p><span class=\"meta\">" + _htmlEscape(label) + "</span><br>" + (value ? _htmlEscape(String(value)) : "<span class=\"meta\">—</span>") + "</p>";
11895
+ }
11896
+
11897
+ var isExport = r.request_kind === "export";
11898
+ var isDeletion = r.request_kind === "deletion";
11899
+ var openState = r.status === "received" || r.status === "processing";
11900
+
11901
+ // Action forms.
11902
+ var actions = "";
11903
+ if (isExport && openState) {
11904
+ actions +=
11905
+ "<form method=\"post\" action=\"/admin/dsr/" + _htmlEscape(r.id) + "/fulfill\" class=\"form-inline\">" +
11906
+ "<button class=\"btn\" type=\"submit\">Fulfil request</button>" +
11907
+ "</form>";
11908
+ }
11909
+ if (isExport && (r.status === "fulfilled" || r.status === "delivered")) {
11910
+ actions +=
11911
+ "<a class=\"btn\" href=\"/admin/dsr/" + _htmlEscape(r.id) + "/export.json\">Download export</a>";
11912
+ }
11913
+ if (isExport && r.status === "fulfilled") {
11914
+ actions +=
11915
+ "<form method=\"post\" action=\"/admin/dsr/" + _htmlEscape(r.id) + "/dispatch\" class=\"return-action\">" +
11916
+ "<h4>Mark delivered</h4>" +
11917
+ _setupField("Delivery method", "delivery_method", r.delivery_method || "", "text", "How the bundle reached the requester (e.g. email, secure-link).", " maxlength=\"64\" required") +
11918
+ _setupField("Delivery address", "delivery_address", r.delivery_address || "", "text", "Where it was sent (email address / link / postal address).", " maxlength=\"1000\" required") +
11919
+ "<button class=\"btn\" type=\"submit\">Mark delivered</button>" +
11920
+ "</form>";
11921
+ }
11922
+ if (isDeletion && openState) {
11923
+ actions +=
11924
+ "<a class=\"btn\" href=\"/admin/dsr/" + _htmlEscape(r.id) + "?preview=1\">Preview erasure (dry run)</a>";
11925
+ // Execute routes through the confirm interstitial (irreversible; the CSP
11926
+ // forbids a client confirm()). The button POSTs to /delete/confirm which
11927
+ // renders renderAdminConfirm; that page's Confirm POSTs the real execute.
11928
+ actions +=
11929
+ "<form method=\"post\" action=\"/admin/dsr/" + _htmlEscape(r.id) + "/delete/confirm\" class=\"form-inline\">" +
11930
+ "<button class=\"btn btn--danger\" type=\"submit\">Execute erasure…</button>" +
11931
+ "</form>";
11932
+ }
11933
+ if (r.status !== "delivered" && r.status !== "dismissed") {
11934
+ actions +=
11935
+ "<form method=\"post\" action=\"/admin/dsr/" + _htmlEscape(r.id) + "/dismiss\" class=\"return-action\">" +
11936
+ "<h4>Dismiss</h4>" +
11937
+ "<label class=\"form-field\"><span>Reason</span><textarea name=\"dismiss_reason\" rows=\"3\" maxlength=\"4000\" required></textarea></label>" +
11938
+ "<button class=\"btn btn--danger\" type=\"submit\">Dismiss request</button>" +
11939
+ "</form>";
11940
+ }
11941
+ var actionsHtml = actions
11942
+ ? "<div class=\"return-actions\">" + actions + "</div>"
11943
+ : "<span class=\"meta\">This request is in a terminal state — no further actions.</span>";
11944
+
11945
+ // Deletion dry-run preview panel (per-domain counts; never mutates).
11946
+ var previewHtml = "";
11947
+ if (preview) {
11948
+ var dRows = (preview.domains || []).map(function (d) {
11949
+ var note = d.note ? " <span class=\"meta\">(" + _htmlEscape(d.note) + ")</span>" : "";
11950
+ return "<tr><td>" + _htmlEscape(d.table || d.domain) + "</td><td>" + _htmlEscape(String(d.deleted)) + note + "</td></tr>";
11951
+ }).join("");
11952
+ var absent = (preview.domains_absent || []).map(function (n) { return _htmlEscape(n); }).join(", ");
11953
+ previewHtml =
11954
+ "<div class=\"panel mt\"><h3 class=\"subhead\">Erasure preview (dry run — nothing was changed)</h3>" +
11955
+ (dRows
11956
+ ? "<table><thead><tr><th scope=\"col\">Table</th><th scope=\"col\">Affected</th></tr></thead><tbody>" + dRows + "</tbody></table>"
11957
+ : "<p class=\"empty\">No per-domain handlers wired.</p>") +
11958
+ (absent ? "<p class=\"meta\">Domains without a deletion handler: " + absent + "</p>" : "") +
11959
+ "<p class=\"meta\">Total affected: " + _htmlEscape(String(preview.total_affected)) + "</p>" +
11960
+ "</div>";
11961
+ }
11962
+
11963
+ var histRows = history.map(function (h) {
11964
+ return "<tr>" +
11965
+ "<td>" + _htmlEscape(String(h.id).slice(0, 8)) + "</td>" +
11966
+ "<td>" + _htmlEscape(h.request_kind) + "</td>" +
11967
+ "<td><span class=\"status-pill " + _dsrPillClass(h.status) + "\">" + _htmlEscape(h.status) + "</span></td>" +
11968
+ "<td>" + _htmlEscape(_fmtDate(h.requested_at)) + "</td>" +
11969
+ "</tr>";
11970
+ }).join("");
11971
+ var historyHtml = history.length
11972
+ ? "<table><thead><tr><th scope=\"col\">Request</th><th scope=\"col\">Kind</th><th scope=\"col\">Status</th><th scope=\"col\">Requested</th></tr></thead><tbody>" + histRows + "</tbody></table>"
11973
+ : "<p class=\"empty\">No prior requests.</p>";
11974
+
11975
+ var body =
11976
+ "<section class=\"mw-48\">" +
11977
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/dsr\">&larr; Privacy requests</a></div>" +
11978
+ "<h2>" + _htmlEscape(r.request_kind === "export" ? "Data export" : "Erasure") + " request " +
11979
+ "<span class=\"status-pill " + _dsrPillClass(r.status) + "\">" + _htmlEscape(r.status) + "</span></h2>" +
11980
+ "<p class=\"meta\">Requested " + _htmlEscape(_fmtDate(r.requested_at)) +
11981
+ " · by " + _htmlEscape(r.requested_by) +
11982
+ " · customer <code>" + _htmlEscape(String(r.customer_id).slice(0, 8)) + "</code></p>" +
11983
+ moved + notice +
11984
+ "<div class=\"two-col\">" +
11985
+ "<div class=\"panel\"><h3 class=\"subhead\">Details</h3>" +
11986
+ _field("Jurisdiction", r.jurisdiction) +
11987
+ _field("Scope", r.scope) +
11988
+ _field("Reason", r.reason) +
11989
+ _field("Dismiss reason", r.dismiss_reason) +
11990
+ _field("Delivery method", r.delivery_method) +
11991
+ _field("Delivery address", r.delivery_address) +
11992
+ _field("Fulfilled", r.fulfilled_at ? _fmtDate(r.fulfilled_at) : null) +
11993
+ _field("Delivered", r.delivered_at ? _fmtDate(r.delivered_at) : null) +
11994
+ "</div>" +
11995
+ "<div class=\"panel\"><h3 class=\"subhead\">Customer request history</h3>" + historyHtml + "</div>" +
11996
+ "</div>" +
11997
+ previewHtml +
11998
+ "<div class=\"panel mt\"><h3 class=\"subhead\">Actions</h3>" + actionsHtml + "</div>" +
11999
+ "</section>";
12000
+ return _renderAdminShell(opts.shop_name, "Privacy request", body, "dsr", opts.nav_available);
12001
+ }
12002
+
11620
12003
  // The non-terminal exchange states the operator can filter the queue by —
11621
12004
  // lifecycle order (the terminal closed / rejected don't appear in the open
11622
12005
  // queue, so they're not filter chips).
@@ -1,13 +1,13 @@
1
1
  {
2
- "version": "0.3.47",
2
+ "version": "0.3.49",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
- "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
6
- "fingerprinted": "css/admin.72c808274ebcefd9.css"
5
+ "integrity": "sha384-/NizdENQkTc96romD0kmnl8220NAvW32QI0aSFuGiPbWE5lYbRxLXU5shsjlx5Ek",
6
+ "fingerprinted": "css/admin.f028b2e410387546.css"
7
7
  },
8
8
  "css/main.css": {
9
- "integrity": "sha384-kbtcIsmD7X/DDsTJIYQ49JexBKXkz/GU1aQ8430XEyHHW/hcOPMWqbgZ7wRqvsbT",
10
- "fingerprinted": "css/main.37860144b12a94fe.css"
9
+ "integrity": "sha384-9rtEz2wKJbpfALccIipRdo/BWVQZi5O4IufqaLO/7jFyLK1xxCaDc/65Apjy8dbs",
10
+ "fingerprinted": "css/main.1a1a379d6f58e2c0.css"
11
11
  },
12
12
  "js/announcement.js": {
13
13
  "integrity": "sha384-z4zcEMn+tScoVnYRE4nEf8N/oyvpxdpaxTNrT4QO/jURChid4+qjAvWkzatCaAPq",
package/lib/storefront.js CHANGED
@@ -306,12 +306,7 @@ var LAYOUT =
306
306
  " <button type=\"submit\">{{search_submit}}</button>\n" +
307
307
  " </div>\n" +
308
308
  " </form>\n" +
309
- " <nav class=\"site-nav\" aria-label=\"Primary\">\n" +
310
- " <a class=\"site-nav__link\" href=\"/\">{{nav_shop}}</a>\n" +
311
- " <a class=\"site-nav__link\" href=\"/#framework\">{{nav_framework}}</a>\n" +
312
- " <a class=\"site-nav__icon\" href=\"/account\" aria-label=\"{{nav_account}}\"><svg viewBox=\"0 0 24 24\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 9a7 7 0 0 1 14 0\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\"/></svg></a>\n" +
313
- " <a class=\"cart-pill\" href=\"/cart\" aria-label=\"{{nav_cart_aria}}\"><svg viewBox=\"0 0 24 24\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M3 4h2l2.4 12.1a2 2 0 0 0 2 1.6h7.6a2 2 0 0 0 1.95-1.55L21 8H6\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/><circle cx=\"10\" cy=\"21\" r=\"1.4\" fill=\"currentColor\"/><circle cx=\"17\" cy=\"21\" r=\"1.4\" fill=\"currentColor\"/></svg><span class=\"cart-pill__count\">{{cart_count}}</span></a>\n" +
314
- " </nav>\n" +
309
+ " RAW_PRIMARY_NAV\n" +
315
310
  " </div>\n" +
316
311
  " </header>\n" +
317
312
  "\n" +
@@ -349,8 +344,6 @@ var LAYOUT =
349
344
  " <li><a href=\"/\">{{footer_shop_all}}</a></li>\n" +
350
345
  " <li><a href=\"/collections\">{{footer_shop_collections}}</a></li>\n" +
351
346
  " <li><a href=\"/categories\">{{footer_shop_categories}}</a></li>\n" +
352
- " <li><a href=\"/?sort=new\">{{footer_shop_new}}</a></li>\n" +
353
- " <li><a href=\"/?sort=sale\">{{footer_shop_sale}}</a></li>\n" +
354
347
  " <li><a href=\"/compare\">{{footer_shop_compare}}</a></li>\n" +
355
348
  " <li><a href=\"/cart\">{{footer_shop_cart}}</a></li>\n" +
356
349
  " <li><a href=\"/terms\">{{footer_shop_shipping}}</a></li>\n" +
@@ -683,6 +676,57 @@ function _requestUrls(req) {
683
676
  return { canonical_url: origin + path, og_url: origin + path + search };
684
677
  }
685
678
 
679
+ // Whether the Collections / Categories routes are mounted (deps wired).
680
+ // The header links + the mobile-disclosure entries render only when their
681
+ // route exists, so a store that hasn't wired the primitive never shows a
682
+ // link that 404s. Set once at mount (alongside `_ccyEnabled`), read in the
683
+ // nav builder. Default true so a pure render of a page (a unit test calling
684
+ // `renderHome` without mounting) shows the full nav; the route-level
685
+ // conditional only suppresses a link when the mount explicitly lacks the dep.
686
+ var _hasCollections = true;
687
+ var _hasCategoryNav = true;
688
+
689
+ // The account-icon + cart-pill SVGs, lifted verbatim from the prior LAYOUT
690
+ // nav so the builder emits the same chrome. Kept byte-identical to the edge
691
+ // headers (worker/render/*.js).
692
+ var _NAV_ACCOUNT_SVG = "<svg viewBox=\"0 0 24 24\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 9a7 7 0 0 1 14 0\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\"/></svg>";
693
+ var _NAV_CART_SVG = "<svg viewBox=\"0 0 24 24\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M3 4h2l2.4 12.1a2 2 0 0 0 2 1.6h7.6a2 2 0 0 0 1.95-1.55L21 8H6\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/><circle cx=\"10\" cy=\"21\" r=\"1.4\" fill=\"currentColor\"/><circle cx=\"17\" cy=\"21\" r=\"1.4\" fill=\"currentColor\"/></svg>";
694
+ var _NAV_MENU_SVG = "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\"><path d=\"M4 7h16M4 12h16M4 17h16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\"/></svg>";
695
+
696
+ // Primary nav builder — a desktop link row (`.site-nav__links`) PLUS a
697
+ // CSP-safe <details>/<summary> disclosure (`.site-nav__menu`) carrying the
698
+ // same links for narrow viewports. The CSS shows one or the other by
699
+ // breakpoint (the inline row hides at <=48rem; the disclosure hides above
700
+ // it). No JS — the browser drives open/close. Collections / Categories
701
+ // render only when `hasCollections` / `hasCategories` (their routes mounted).
702
+ // All labels are chrome strings, escaped at the sink. Kept byte-identical to
703
+ // the static nav in every worker/render/*.js header (the edge renders the
704
+ // two extra links unconditionally — accepted divergence; the byte-compare
705
+ // test wires both container deps so the markup matches).
706
+ function _buildNavLinks(chrome, hasCollections, hasCategories, indent) {
707
+ var esc = b.template.escapeHtml;
708
+ return indent + "<a class=\"site-nav__link\" href=\"/\">" + esc(chrome.nav_shop) + "</a>\n" +
709
+ (hasCollections ? indent + "<a class=\"site-nav__link\" href=\"/collections\">" + esc(chrome.nav_collections) + "</a>\n" : "") +
710
+ (hasCategories ? indent + "<a class=\"site-nav__link\" href=\"/categories\">" + esc(chrome.nav_categories) + "</a>\n" : "") +
711
+ indent + "<a class=\"site-nav__link\" href=\"/#framework\">" + esc(chrome.nav_framework) + "</a>\n";
712
+ }
713
+ function _buildPrimaryNav(chrome, cartCount, cartAria, hasCollections, hasCategories) {
714
+ var esc = b.template.escapeHtml;
715
+ return "<nav class=\"site-nav\" aria-label=\"Primary\">\n" +
716
+ " <div class=\"site-nav__links\">\n" +
717
+ _buildNavLinks(chrome, hasCollections, hasCategories, " ") +
718
+ " </div>\n" +
719
+ " <details class=\"site-nav__menu\">\n" +
720
+ " <summary class=\"site-nav__menu-toggle\" aria-label=\"" + esc(chrome.nav_menu) + "\">" + _NAV_MENU_SVG + "<span class=\"site-nav__menu-label\">" + esc(chrome.nav_menu) + "</span></summary>\n" +
721
+ " <div class=\"site-nav__drawer\">\n" +
722
+ _buildNavLinks(chrome, hasCollections, hasCategories, " ") +
723
+ " </div>\n" +
724
+ " </details>\n" +
725
+ " <a class=\"site-nav__icon\" href=\"/account\" aria-label=\"" + esc(chrome.nav_account) + "\">" + _NAV_ACCOUNT_SVG + "</a>\n" +
726
+ " <a class=\"cart-pill\" href=\"/cart\" aria-label=\"" + esc(cartAria) + "\">" + _NAV_CART_SVG + "<span class=\"cart-pill__count\">" + esc(String(cartCount)) + "</span></a>\n" +
727
+ " </nav>";
728
+ }
729
+
686
730
  function _wrap(opts) {
687
731
  var themeCss = (opts && typeof opts.theme_css === "string" && opts.theme_css.length)
688
732
  ? opts.theme_css
@@ -758,7 +802,6 @@ function _wrap(opts) {
758
802
  var vars = {
759
803
  title: opts.title,
760
804
  shop_name: shopName,
761
- cart_count: cartCount,
762
805
  year: _COPYRIGHT_YEAR,
763
806
  search_q: opts.search_q == null ? "" : opts.search_q,
764
807
  theme_css: themeCss,
@@ -770,19 +813,24 @@ function _wrap(opts) {
770
813
  canonical_url: canonicalUrl,
771
814
  lang: localeCtx.lang || "en",
772
815
  dir: localeCtx.dir || "ltr",
773
- nav_cart_aria: cartAria,
774
816
  body: "RAW_BODY_PLACEHOLDER",
775
817
  };
776
- // Layer in every chrome string the LAYOUT references. `nav_cart_aria`
777
- // is handled above (it needs the count interpolated); the two
778
- // `locale_switcher_*` strings live only inside the switcher form
779
- // (spliced via RAW_LOCALE_SWITCHER), not in the LAYOUT body — the
780
- // strict `_render` refuses an unused placeholder, so they're skipped
781
- // here.
818
+ // Layer in every chrome string the LAYOUT references. The nav chrome
819
+ // strings (`nav_shop` / `nav_collections` / `nav_categories` /
820
+ // `nav_framework` / `nav_account` / `nav_menu` / `nav_cart_aria`) are
821
+ // NOT referenced by the LAYOUT const — the nav is raw-spliced via
822
+ // `RAW_PRIMARY_NAV` from `_buildPrimaryNav` so the Collections/Categories
823
+ // links can render conditionally. The strict `_render` refuses an unused
824
+ // placeholder, so those keys are skipped here. Likewise the two
825
+ // `locale_switcher_*` strings live only inside the switcher form (spliced
826
+ // via RAW_LOCALE_SWITCHER).
782
827
  var chromeKeys = Object.keys(chrome);
783
828
  for (var ci = 0; ci < chromeKeys.length; ci += 1) {
784
829
  var ck = chromeKeys[ci];
785
- if (ck === "nav_cart_aria" || ck === "locale_switcher_label" || ck === "locale_switcher_submit") continue;
830
+ if (ck === "nav_shop" || ck === "nav_collections" || ck === "nav_categories" ||
831
+ ck === "nav_framework" || ck === "nav_account" || ck === "nav_menu" ||
832
+ ck === "nav_cart_aria" ||
833
+ ck === "locale_switcher_label" || ck === "locale_switcher_submit") continue;
786
834
  vars[ck] = chrome[ck];
787
835
  }
788
836
 
@@ -821,6 +869,14 @@ function _wrap(opts) {
821
869
  // Matches the edge renderers' `spliceRaw` so the dual-render stays
822
870
  // byte-consistent under a `$`-bearing announcement. See `_spliceRaw`.
823
871
  assembled = _spliceRaw(assembled, "RAW_ANNOUNCEMENT_BAR", announcementBarHtml);
872
+ // Primary nav — a raw splice (post strict-render) so the chrome strings
873
+ // it consumes (nav_shop / nav_collections / nav_categories /
874
+ // nav_framework / nav_account / nav_menu / nav_cart_aria) need no LAYOUT
875
+ // `{{}}` placeholder, and the Collections/Categories links render only
876
+ // when their routes are mounted. Escaped values are spliced via the
877
+ // replacer-function helper so a `$`-sequence lands literally.
878
+ assembled = _spliceRaw(assembled, "RAW_PRIMARY_NAV",
879
+ _buildPrimaryNav(chrome, cartCount, cartAria, _hasCollections, _hasCategoryNav));
824
880
  // The body is RAW HTML (already rendered + escaped at the
825
881
  // per-fragment level). The placeholder swap is post-render so the
826
882
  // outer renderer's HTML-escape doesn't double-escape the inner
@@ -905,6 +961,60 @@ function _buildRelatedProducts(related) {
905
961
  "</section>";
906
962
  }
907
963
 
964
+ // Home "Featured collections" band — the six decorative card-art SVGs
965
+ // lifted verbatim from the prior static band, indexed 1..6. The dynamic
966
+ // builder rotates through them by position so an operator's collections
967
+ // keep the band's designed look without per-collection iconography. Kept
968
+ // byte-identical to worker/render/home.js#COLLECTION_BAND_ART so the dual
969
+ // render agrees.
970
+ var COLLECTION_BAND_ART = [
971
+ "<div class=\"collection-card__art collection-card__art--1\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M70 38 C74 44 86 44 90 38 L104 44 L112 58 L100 64 L96 58 L96 92 L64 92 L64 58 L60 64 L48 58 L56 44 Z\"/><path d=\"M71 40 C75 47 85 47 89 40\" stroke=\"#732A8D\" stroke-width=\"2\"/><path d=\"M73 76 H87\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-dasharray=\"2 3\"/></svg></div>",
972
+ "<div class=\"collection-card__art collection-card__art--2\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"58\" y=\"38\" width=\"44\" height=\"44\" rx=\"4\"/><rect x=\"70\" y=\"50\" width=\"20\" height=\"20\" rx=\"2\" stroke=\"#732A8D\"/><circle cx=\"80\" cy=\"60\" r=\"3\" fill=\"#AD38DB\" stroke=\"none\"/><path d=\"M66 38 V30 M76 38 V30 M86 38 V30 M96 38 V30\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M66 82 V90 M76 82 V90 M86 82 V90 M96 82 V90\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M58 48 H50 M58 60 H50 M58 72 H50\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M102 48 H110 M102 60 H110 M102 72 H110\" stroke=\"currentColor\" stroke-width=\"2\"/></svg></div>",
973
+ "<div class=\"collection-card__art collection-card__art--3\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M60 74 C50 74 48 62 57 59 C56 47 73 43 79 53 C88 47 100 54 97 64 C107 65 107 74 99 74 Z\" stroke=\"#732A8D\"/><path d=\"M78 60 V86\"/><path d=\"M70 78 L78 88 L86 78\"/><path d=\"M64 98 H92\" stroke=\"currentColor\" stroke-width=\"2\"/></svg></div>",
974
+ "<div class=\"collection-card__art collection-card__art--4\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M56 50 A26 26 0 0 1 104 56\"/><path d=\"M104 70 A26 26 0 0 1 56 64\"/><path d=\"M104 42 L106 57 L91 55\" stroke=\"#AD38DB\"/><path d=\"M56 78 L54 63 L69 65\" stroke=\"#AD38DB\"/><circle cx=\"80\" cy=\"60\" r=\"6\" stroke=\"#732A8D\"/><circle cx=\"80\" cy=\"60\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\"/></svg></div>",
975
+ "<div class=\"collection-card__art collection-card__art--5\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M86 36 L106 44 L106 64 L86 72 L66 64 L66 44 Z\" stroke=\"#732A8D\"/><path d=\"M66 44 L86 52 L106 44 M86 52 V72\" stroke=\"#732A8D\"/><path d=\"M70 56 L90 64 L90 88 L70 96 L50 88 L50 64 Z\"/><path d=\"M50 64 L70 72 L90 64 M70 72 V96\"/><path d=\"M70 72 V96\" stroke=\"currentColor\" stroke-width=\"2\"/></svg></div>",
976
+ "<div class=\"collection-card__art collection-card__art--6\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"48\" y=\"48\" width=\"64\" height=\"42\" rx=\"6\"/><path d=\"M80 48 V90\" stroke=\"#732A8D\"/><path d=\"M80 48 C71 36 56 39 62 49 C57 52 61 57 71 53 C76 51 80 50 80 48 Z\"/><path d=\"M80 48 C89 36 104 39 98 49 C103 52 99 57 89 53 C84 51 80 50 80 48 Z\"/><circle cx=\"80\" cy=\"48\" r=\"2.4\" fill=\"#AD38DB\" stroke=\"none\"/><rect x=\"56\" y=\"70\" width=\"11\" height=\"8\" rx=\"1.6\" stroke=\"currentColor\" stroke-width=\"1.8\"/></svg></div>",
977
+ ];
978
+
979
+ // Home "Featured collections" band builder — operator collections (active,
980
+ // newest-curated first via collections.list, capped at 6) rendered as the
981
+ // existing collection-card grid. Each card links to /collections/<slug>;
982
+ // the decorative per-card art rotates through COLLECTION_BAND_ART by index.
983
+ // Returns "" when there are no collections to show — the caller drops the
984
+ // whole section, so a store with no collections shows hero → catalog with
985
+ // no empty band. Operator slug/title/description are escaped at the sink
986
+ // (escape-by-default; cross-customer free-text rendered to every visitor).
987
+ // A theme that owns its own home template never reaches this band; the
988
+ // data is still threaded so a theme can add a collections slot later.
989
+ // Kept byte-identical to worker/render/home.js#_buildCollectionsBand.
990
+ function _buildCollectionsBand(collections) {
991
+ var esc = b.template.escapeHtml;
992
+ var cols = Array.isArray(collections) ? collections.slice(0, 6) : [];
993
+ if (cols.length === 0) return "";
994
+ var cards = "";
995
+ for (var i = 0; i < cols.length; i += 1) {
996
+ var c = cols[i];
997
+ var art = COLLECTION_BAND_ART[i % COLLECTION_BAND_ART.length];
998
+ cards +=
999
+ " <a class=\"collection-card\" href=\"/collections/" + esc(c.slug) + "\">\n" +
1000
+ " " + art + "\n" +
1001
+ " <div class=\"collection-card__meta\">\n" +
1002
+ " <h3>" + esc(c.title) + "</h3>\n" +
1003
+ (c.description ? " <p>" + esc(c.description) + "</p>\n" : "") +
1004
+ " </div>\n" +
1005
+ " </a>\n";
1006
+ }
1007
+ return "<section class=\"collections\" aria-labelledby=\"collections-title\">\n" +
1008
+ " <header class=\"section-head\">\n" +
1009
+ " <p class=\"eyebrow\">Featured collections</p>\n" +
1010
+ " <h2 id=\"collections-title\" class=\"section-head__title\">Browse the catalog by collection.</h2>\n" +
1011
+ " </header>\n" +
1012
+ " <div class=\"collections__grid\">\n" +
1013
+ cards +
1014
+ " </div>\n" +
1015
+ "</section>\n";
1016
+ }
1017
+
908
1018
  var HOME_HERO =
909
1019
  "<section class=\"hero hero--dark\">\n" +
910
1020
  " <div class=\"hero__bg\" aria-hidden=\"true\">\n" +
@@ -983,57 +1093,7 @@ var HOME_HERO =
983
1093
  "\n" +
984
1094
  "RAW_FEATURED_CALLOUT\n" +
985
1095
  "\n" +
986
- "<section class=\"collections\" aria-labelledby=\"collections-title\">\n" +
987
- " <header class=\"section-head\">\n" +
988
- " <p class=\"eyebrow\">Featured collections</p>\n" +
989
- " <h2 id=\"collections-title\" class=\"section-head__title\">Shaped for a storefront that ships from day one.</h2>\n" +
990
- " <p class=\"section-head__lede\">Drop products into any of these starting categories — or define your own taxonomy through the catalog admin and the framework will server-render the grids, filters, and PDP routes for free.</p>\n" +
991
- " </header>\n" +
992
- " <div class=\"collections__grid\">\n" +
993
- " <a class=\"collection-card\" href=\"/search?q=tee\">\n" +
994
- " <div class=\"collection-card__art collection-card__art--1\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M70 38 C74 44 86 44 90 38 L104 44 L112 58 L100 64 L96 58 L96 92 L64 92 L64 58 L60 64 L48 58 L56 44 Z\"/><path d=\"M71 40 C75 47 85 47 89 40\" stroke=\"#732A8D\" stroke-width=\"2\"/><path d=\"M73 76 H87\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-dasharray=\"2 3\"/></svg></div>\n" +
995
- " <div class=\"collection-card__meta\">\n" +
996
- " <h3>Apparel</h3>\n" +
997
- " <p>Sized, colored, inventoried.</p>\n" +
998
- " </div>\n" +
999
- " </a>\n" +
1000
- " <a class=\"collection-card\" href=\"/search?q=edge\">\n" +
1001
- " <div class=\"collection-card__art collection-card__art--2\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"58\" y=\"38\" width=\"44\" height=\"44\" rx=\"4\"/><rect x=\"70\" y=\"50\" width=\"20\" height=\"20\" rx=\"2\" stroke=\"#732A8D\"/><circle cx=\"80\" cy=\"60\" r=\"3\" fill=\"#AD38DB\" stroke=\"none\"/><path d=\"M66 38 V30 M76 38 V30 M86 38 V30 M96 38 V30\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M66 82 V90 M76 82 V90 M86 82 V90 M96 82 V90\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M58 48 H50 M58 60 H50 M58 72 H50\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M102 48 H110 M102 60 H110 M102 72 H110\" stroke=\"currentColor\" stroke-width=\"2\"/></svg></div>\n" +
1002
- " <div class=\"collection-card__meta\">\n" +
1003
- " <h3>Hardware</h3>\n" +
1004
- " <p>Serialized, warranty-tracked.</p>\n" +
1005
- " </div>\n" +
1006
- " </a>\n" +
1007
- " <a class=\"collection-card\" href=\"/search?q=license\">\n" +
1008
- " <div class=\"collection-card__art collection-card__art--3\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M60 74 C50 74 48 62 57 59 C56 47 73 43 79 53 C88 47 100 54 97 64 C107 65 107 74 99 74 Z\" stroke=\"#732A8D\"/><path d=\"M78 60 V86\"/><path d=\"M70 78 L78 88 L86 78\"/><path d=\"M64 98 H92\" stroke=\"currentColor\" stroke-width=\"2\"/></svg></div>\n" +
1009
- " <div class=\"collection-card__meta\">\n" +
1010
- " <h3>Digital</h3>\n" +
1011
- " <p>License-key fulfillment.</p>\n" +
1012
- " </div>\n" +
1013
- " </a>\n" +
1014
- " <a class=\"collection-card\" href=\"/search?q=subscription\">\n" +
1015
- " <div class=\"collection-card__art collection-card__art--4\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M56 50 A26 26 0 0 1 104 56\"/><path d=\"M104 70 A26 26 0 0 1 56 64\"/><path d=\"M104 42 L106 57 L91 55\" stroke=\"#AD38DB\"/><path d=\"M56 78 L54 63 L69 65\" stroke=\"#AD38DB\"/><circle cx=\"80\" cy=\"60\" r=\"6\" stroke=\"#732A8D\"/><circle cx=\"80\" cy=\"60\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\"/></svg></div>\n" +
1016
- " <div class=\"collection-card__meta\">\n" +
1017
- " <h3>Subscriptions</h3>\n" +
1018
- " <p>Stripe-backed recurring.</p>\n" +
1019
- " </div>\n" +
1020
- " </a>\n" +
1021
- " <a class=\"collection-card\" href=\"/search?q=bundle\">\n" +
1022
- " <div class=\"collection-card__art collection-card__art--5\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M86 36 L106 44 L106 64 L86 72 L66 64 L66 44 Z\" stroke=\"#732A8D\"/><path d=\"M66 44 L86 52 L106 44 M86 52 V72\" stroke=\"#732A8D\"/><path d=\"M70 56 L90 64 L90 88 L70 96 L50 88 L50 64 Z\"/><path d=\"M50 64 L70 72 L90 64 M70 72 V96\"/><path d=\"M70 72 V96\" stroke=\"currentColor\" stroke-width=\"2\"/></svg></div>\n" +
1023
- " <div class=\"collection-card__meta\">\n" +
1024
- " <h3>Bundles</h3>\n" +
1025
- " <p>Composite SKUs, atomic stock.</p>\n" +
1026
- " </div>\n" +
1027
- " </a>\n" +
1028
- " <a class=\"collection-card\" href=\"/search?q=gift\">\n" +
1029
- " <div class=\"collection-card__art collection-card__art--6\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"48\" y=\"48\" width=\"64\" height=\"42\" rx=\"6\"/><path d=\"M80 48 V90\" stroke=\"#732A8D\"/><path d=\"M80 48 C71 36 56 39 62 49 C57 52 61 57 71 53 C76 51 80 50 80 48 Z\"/><path d=\"M80 48 C89 36 104 39 98 49 C103 52 99 57 89 53 C84 51 80 50 80 48 Z\"/><circle cx=\"80\" cy=\"48\" r=\"2.4\" fill=\"#AD38DB\" stroke=\"none\"/><rect x=\"56\" y=\"70\" width=\"11\" height=\"8\" rx=\"1.6\" stroke=\"currentColor\" stroke-width=\"1.8\"/></svg></div>\n" +
1030
- " <div class=\"collection-card__meta\">\n" +
1031
- " <h3>Gift cards</h3>\n" +
1032
- " <p>PQC-signed redemption codes.</p>\n" +
1033
- " </div>\n" +
1034
- " </a>\n" +
1035
- " </div>\n" +
1036
- "</section>\n" +
1096
+ "RAW_COLLECTIONS_BAND\n" +
1037
1097
  "\n" +
1038
1098
  "<section id=\"framework\" class=\"framework-band\" aria-labelledby=\"framework-title\">\n" +
1039
1099
  " <div class=\"framework-band__inner\">\n" +
@@ -1092,13 +1152,12 @@ var CATALOG_EMPTY =
1092
1152
 
1093
1153
  var CATALOG_HEAD =
1094
1154
  "<section id=\"catalog\" class=\"catalog-section\">\n" +
1095
- " <header class=\"section-head section-head--with-link\">\n" +
1155
+ " <header class=\"section-head\">\n" +
1096
1156
  " <div>\n" +
1097
1157
  " <p class=\"eyebrow\">Catalog</p>\n" +
1098
1158
  " <h2 class=\"section-head__title\">Products in store</h2>\n" +
1099
1159
  " <p class=\"section-head__lede\">Server-rendered listings — every card, price, and link arrived on the wire as complete HTML.</p>\n" +
1100
1160
  " </div>\n" +
1101
- " <a class=\"link-arrow\" href=\"/?sort=new\">New arrivals <span aria-hidden=\"true\">→</span></a>\n" +
1102
1161
  " </header>\n" +
1103
1162
  " <div class=\"grid\">{{cards}}</div>\n" +
1104
1163
  "</section>\n";
@@ -1181,8 +1240,15 @@ function renderHome(opts) {
1181
1240
  "</section>";
1182
1241
  }
1183
1242
 
1184
- var hero = _render(HOME_HERO, { product_count: heroProductCount })
1185
- .replace("RAW_FEATURED_CALLOUT", featuredHtml);
1243
+ // Featured-collections band operator collections (active, newest
1244
+ // first, capped at 6); the band is dropped entirely when there are
1245
+ // none. Spliced raw (replacer-function form, $-safe) since the
1246
+ // operator slug/title/description are escaped at the band's own sink.
1247
+ var collectionsBand = _buildCollectionsBand(opts.collections || []);
1248
+ var hero = _spliceRaw(
1249
+ _render(HOME_HERO, { product_count: heroProductCount })
1250
+ .replace("RAW_FEATURED_CALLOUT", featuredHtml),
1251
+ "RAW_COLLECTIONS_BAND", collectionsBand);
1186
1252
  // The hero + value band + catalog section give the home page a
1187
1253
  // designed surface even when no products are loaded yet —
1188
1254
  // visitors land on the storefront shell, not a tech demo.
@@ -1754,7 +1820,40 @@ var VARIANT_ROW =
1754
1820
  // worker/render/product.js#_buildBuyBox.
1755
1821
  var BUYBOX_CHIP_LIMIT = 12;
1756
1822
 
1757
- function _buildBuyBox(variants, escAttr, availability) {
1823
+ // Compute the PDP buy-box headline price string. `rendered` is the
1824
+ // formatted-variant list ([{ id, sku, title, price }]); `variants` + the
1825
+ // `prices` map ({ variant_id: { currency, amount_minor } }) carry the
1826
+ // integer minor-units. Multi-variant products with >1 distinct price read
1827
+ // "From <lowest>" (min over amount_minor, formatted once via `fmt`); a
1828
+ // single-variant, no-price, or all-equal-price product keeps the lead
1829
+ // variant's exact formatted price. Money discipline: the minimum is taken
1830
+ // over integers, never over formatted strings; the result is formatted once.
1831
+ // Kept byte-identical to worker/render/product.js#_buildHeadlinePrice.
1832
+ function _buildHeadlinePrice(rendered, variants, prices, fmt) {
1833
+ var leadPrice = (rendered[0] && rendered[0].price) || "—";
1834
+ if (!Array.isArray(rendered) || rendered.length <= 1) return leadPrice;
1835
+ var lowMinor = null;
1836
+ var lowCurrency = null;
1837
+ for (var i = 0; i < variants.length; i += 1) {
1838
+ var p = prices && prices[variants[i].id];
1839
+ if (!p || !Number.isInteger(p.amount_minor)) continue;
1840
+ if (lowMinor === null || p.amount_minor < lowMinor) {
1841
+ lowMinor = p.amount_minor;
1842
+ lowCurrency = p.currency || "USD";
1843
+ }
1844
+ }
1845
+ if (lowMinor === null) return leadPrice;
1846
+ // All variants share one price → keep the exact figure (no "From").
1847
+ var allEqual = true;
1848
+ for (var j = 0; j < variants.length; j += 1) {
1849
+ var pj = prices && prices[variants[j].id];
1850
+ if (pj && Number.isInteger(pj.amount_minor) && pj.amount_minor !== lowMinor) { allEqual = false; break; }
1851
+ }
1852
+ if (allEqual) return leadPrice;
1853
+ return "From " + fmt(lowMinor, lowCurrency);
1854
+ }
1855
+
1856
+ function _buildBuyBox(variants, escAttr, availability, headlinePrice) {
1758
1857
  var inStock = !availability || availability.in_stock !== false;
1759
1858
  if (!variants || variants.length === 0) {
1760
1859
  return "<div class=\"pdp__variants\">\n" +
@@ -1835,8 +1934,9 @@ function _buildBuyBox(variants, escAttr, availability) {
1835
1934
  ? "<button type=\"submit\" class=\"btn-primary cart-page__checkout\">$ add to cart</button>"
1836
1935
  : soldOutBtn;
1837
1936
 
1937
+ var headline = (headlinePrice != null && headlinePrice !== "") ? headlinePrice : lead.price;
1838
1938
  return "<div class=\"pdp__buybox\">\n" +
1839
- " <p class=\"featured-product__price\">" + escAttr(lead.price) + "</p>\n" +
1939
+ " <p class=\"featured-product__price\">" + escAttr(headline) + "</p>\n" +
1840
1940
  " <form method=\"post\" action=\"/cart/lines\">\n" +
1841
1941
  " " + variantBlock + "\n" +
1842
1942
  " <label class=\"pdp__variants-title\" for=\"buybox-qty\">Quantity</label>\n" +
@@ -4515,6 +4615,163 @@ function renderSupportTicket(opts) {
4515
4615
  });
4516
4616
  }
4517
4617
 
4618
+ // ---- privacy & data (customer self-service DSR) ------------------------
4619
+ //
4620
+ // The signed-in customer files an export request (a copy of their data) OR
4621
+ // a deletion request (erasure), sees their own request history, and
4622
+ // downloads a fulfilled export bundle. Self-service FILES the request; an
4623
+ // operator reviews + executes it from the admin queue (identity
4624
+ // verification is a controller obligation under every jurisdiction). Every
4625
+ // dynamic value — including the customer's own free-text deletion reason
4626
+ // replayed in the history — is escaped (b.template.escapeHtml); the
4627
+ // primitive bounds length but does NOT strip HTML.
4628
+ var _DSR_JURISDICTIONS = [
4629
+ ["gdpr", "GDPR (EU / UK)"],
4630
+ ["ccpa", "CCPA (California)"],
4631
+ ["lgpd", "LGPD (Brazil)"],
4632
+ ["other", "Other"],
4633
+ ];
4634
+ var _DSR_SCOPES = [
4635
+ ["full", "Everything we hold on you"],
4636
+ ["orders_only", "Orders only"],
4637
+ ["identity_only", "Profile + addresses only"],
4638
+ ];
4639
+
4640
+ function _dsrStatusBadge(status) {
4641
+ var esc = b.template.escapeHtml;
4642
+ return "<span class=\"pdp__badge dsr-status--" + esc(String(status)) + "\">" + esc(String(status)) + "</span>";
4643
+ }
4644
+
4645
+ function renderAccountPrivacy(opts) {
4646
+ var esc = b.template.escapeHtml;
4647
+ var history = opts.history || [];
4648
+ var notice = "";
4649
+ if (opts.ok === "export") {
4650
+ notice = "<p class=\"form-notice form-notice--ok\" role=\"status\">Your data export request has been filed. We'll prepare it and let you know when it's ready to download.</p>";
4651
+ } else if (opts.ok === "deletion") {
4652
+ notice = "<p class=\"form-notice form-notice--ok\" role=\"status\">Your erasure request has been filed. We'll verify your identity and confirm once it's complete.</p>";
4653
+ } else if (opts.notice) {
4654
+ notice = "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>";
4655
+ }
4656
+
4657
+ var jurisdictionOpts = _DSR_JURISDICTIONS.map(function (j) {
4658
+ return "<option value=\"" + esc(j[0]) + "\">" + esc(j[1]) + "</option>";
4659
+ }).join("");
4660
+ var scopeOpts = _DSR_SCOPES.map(function (s) {
4661
+ return "<option value=\"" + esc(s[0]) + "\">" + esc(s[1]) + "</option>";
4662
+ }).join("");
4663
+
4664
+ var rowsHtml = "";
4665
+ for (var i = 0; i < history.length; i += 1) {
4666
+ var r = history[i];
4667
+ var date = r.requested_at ? new Date(Number(r.requested_at)).toISOString().slice(0, 10) : "";
4668
+ var kindLabel = r.request_kind === "export" ? "Data export" : "Erasure";
4669
+ var download = (r.request_kind === "export" && (r.status === "fulfilled" || r.status === "delivered"))
4670
+ ? " &middot; <a href=\"/account/privacy/" + esc(String(r.id)) + "/export.json\">Download</a>"
4671
+ : "";
4672
+ var reason = r.reason
4673
+ ? "<p class=\"return-card__meta\">Reason: " + esc(String(r.reason)) + "</p>"
4674
+ : "";
4675
+ rowsHtml +=
4676
+ "<li class=\"return-card\">" +
4677
+ "<div class=\"return-card__head\">" +
4678
+ "<span class=\"return-card__rma\">" + esc(kindLabel) + "</span>" +
4679
+ _dsrStatusBadge(r.status) +
4680
+ "</div>" +
4681
+ "<p class=\"return-card__meta\">" + esc(String(r.jurisdiction).toUpperCase()) +
4682
+ (date ? " &middot; <time datetime=\"" + esc(date) + "\">" + esc(date) + "</time>" : "") +
4683
+ download +
4684
+ "</p>" +
4685
+ reason +
4686
+ "</li>";
4687
+ }
4688
+ var historyInner = rowsHtml
4689
+ ? "<ul class=\"return-list\">" + rowsHtml + "</ul>"
4690
+ : "<div class=\"account-empty\"><p class=\"account-empty__lede\">You haven't filed any privacy requests yet.</p></div>";
4691
+
4692
+ var body =
4693
+ "<section class=\"account-returns\">" +
4694
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
4695
+ "<li><a href=\"/account\">Account</a></li>" +
4696
+ "<li aria-current=\"page\">Privacy &amp; data</li>" +
4697
+ "</ol></nav>" +
4698
+ "<h1 class=\"account-returns__title\">Privacy &amp; data</h1>" +
4699
+ "<p class=\"section-head__lede\">Request a copy of the personal data we hold on you, or ask us to erase it. " +
4700
+ "Most requests are completed within the statutory window (GDPR: one month; CCPA: 45 days; LGPD: 15 days).</p>" +
4701
+ notice +
4702
+ "<div class=\"panel\">" +
4703
+ "<h2 class=\"pdp__variants-title\">Request a copy of your data</h2>" +
4704
+ "<form class=\"return-form form-stack\" method=\"post\" action=\"/account/privacy/export\">" +
4705
+ "<label class=\"form-field\"><span class=\"form-field__label\">Applicable law</span>" +
4706
+ "<select name=\"jurisdiction\" required>" + jurisdictionOpts + "</select></label>" +
4707
+ "<label class=\"form-field\"><span class=\"form-field__label\">What to include</span>" +
4708
+ "<select name=\"scope\" required>" + scopeOpts + "</select></label>" +
4709
+ "<button type=\"submit\" class=\"btn-primary\">Request my data</button>" +
4710
+ "</form>" +
4711
+ "</div>" +
4712
+ "<div class=\"panel\">" +
4713
+ "<h2 class=\"pdp__variants-title\">Erase your data</h2>" +
4714
+ "<p class=\"return-card__meta\">This files a request to permanently erase your personal data. " +
4715
+ "We'll verify your identity before acting on it.</p>" +
4716
+ "<a class=\"btn-secondary\" href=\"/account/delete\">Request erasure</a>" +
4717
+ "</div>" +
4718
+ "<h2 class=\"pdp__variants-title\">Your requests</h2>" +
4719
+ historyInner +
4720
+ "</section>";
4721
+ return _wrap({
4722
+ title: "Privacy & data",
4723
+ shop_name: opts.shop_name || "blamejs.shop",
4724
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
4725
+ theme_css: opts.theme_css,
4726
+ body: body,
4727
+ });
4728
+ }
4729
+
4730
+ // Server-rendered erasure-confirm interstitial (the CSP forbids a client
4731
+ // confirm()). Filing requires a non-empty reason + a jurisdiction. A Cancel
4732
+ // link returns to the privacy page.
4733
+ function renderAccountDelete(opts) {
4734
+ var esc = b.template.escapeHtml;
4735
+ var v = opts.values || {};
4736
+ var notice = opts.notice
4737
+ ? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
4738
+ : "";
4739
+ var jurisdictionOpts = _DSR_JURISDICTIONS.map(function (j) {
4740
+ var sel = v.jurisdiction === j[0] ? " selected" : "";
4741
+ return "<option value=\"" + esc(j[0]) + "\"" + sel + ">" + esc(j[1]) + "</option>";
4742
+ }).join("");
4743
+ var body =
4744
+ "<section class=\"return-form-page\">" +
4745
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
4746
+ "<li><a href=\"/account\">Account</a></li>" +
4747
+ "<li><a href=\"/account/privacy\">Privacy &amp; data</a></li>" +
4748
+ "<li aria-current=\"page\">Erase my data</li>" +
4749
+ "</ol></nav>" +
4750
+ "<h1 class=\"return-form-page__title\">Request data erasure</h1>" +
4751
+ "<p class=\"form-notice\" role=\"note\">Filing this request asks us to permanently erase the personal data we hold on you. " +
4752
+ "Orders and other records we're legally required to keep are retained but de-linked from your identity. " +
4753
+ "We'll verify your identity before acting on it. This cannot be undone.</p>" +
4754
+ notice +
4755
+ "<form class=\"return-form form-stack\" method=\"post\" action=\"/account/delete\">" +
4756
+ "<label class=\"form-field\"><span class=\"form-field__label\">Applicable law</span>" +
4757
+ "<select name=\"jurisdiction\" required>" + jurisdictionOpts + "</select></label>" +
4758
+ "<label class=\"form-field\"><span class=\"form-field__label\">Reason for your request</span>" +
4759
+ "<textarea name=\"reason\" required maxlength=\"4000\" rows=\"4\">" + esc(v.reason == null ? "" : String(v.reason)) + "</textarea></label>" +
4760
+ "<div class=\"actions-row\">" +
4761
+ "<button type=\"submit\" class=\"btn-primary\">File erasure request</button>" +
4762
+ "<a class=\"btn-secondary\" href=\"/account/privacy\">Cancel</a>" +
4763
+ "</div>" +
4764
+ "</form>" +
4765
+ "</section>";
4766
+ return _wrap({
4767
+ title: "Erase my data",
4768
+ shop_name: opts.shop_name || "blamejs.shop",
4769
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
4770
+ theme_css: opts.theme_css,
4771
+ body: body,
4772
+ });
4773
+ }
4774
+
4518
4775
  // Loyalty transaction-type pill — reuses the `pdp__badge` class the
4519
4776
  // theme already styles. The type is one of the ledger's closed enum
4520
4777
  // (earn / redeem / expire / adjust / tier-bonus).
@@ -5532,9 +5789,18 @@ function renderProduct(opts) {
5532
5789
  var preorderShape = opts.preorder_campaign
5533
5790
  ? preorderCtaShape(opts.preorder_campaign.campaign, { remaining_units: opts.preorder_campaign.remaining_units }, fmt, opts.product.slug)
5534
5791
  : null;
5792
+ // Multi-variant headline price — "From <lowest>" so the buy box never
5793
+ // advertises a price that isn't the cheapest buyable variant (variants[0]
5794
+ // may not be the cheapest). The minimum is computed over the integer
5795
+ // amount_minor map and formatted ONCE via the page's own `fmt` (never by
5796
+ // comparing formatted strings — "$9.99" < "$10.00" sorts wrong as text).
5797
+ // Single-variant, no-price, and all-equal-price products keep the lead
5798
+ // variant's exact price string ("From X" when there's only one X is
5799
+ // noise). Kept byte-identical to worker/render/product.js. See UX-5.
5800
+ var headlinePrice = _buildHeadlinePrice(rendered, variants, prices, fmt);
5535
5801
  var buyboxHtml = preorderShape
5536
5802
  ? _buildPreorderCta(preorderShape, b.template.escapeHtml)
5537
- : _buildBuyBox(rendered, b.template.escapeHtml, availability);
5803
+ : _buildBuyBox(rendered, b.template.escapeHtml, availability, headlinePrice);
5538
5804
  // The reserve PRG lands the shopper back on the PDP with a fixed
5539
5805
  // ?preorder=<reserved|unavailable|closed> marker; the banner prepends the
5540
5806
  // buy box. The marker set is closed (built from a lookup, never the raw
@@ -7736,6 +8002,7 @@ var ACCOUNT_DASH_PAGE =
7736
8002
  " RAW_PREORDER_LINK\n" +
7737
8003
  // begin: profile + passkey management actions
7738
8004
  " <a class=\"btn-secondary\" href=\"/account/profile\">Edit profile</a>\n" +
8005
+ " <a class=\"btn-secondary\" href=\"/account/privacy\">Privacy &amp; data</a>\n" +
7739
8006
  " <a class=\"btn-secondary\" href=\"/account/passkeys\">Manage passkeys</a>\n" +
7740
8007
  // end: profile + passkey management actions
7741
8008
  " <form method=\"post\" action=\"/account/logout\"><button type=\"submit\" class=\"btn-ghost\">Sign out</button></form>\n" +
@@ -8142,7 +8409,9 @@ function renderOrderList(opts) {
8142
8409
  function _surveyQuestion(q) {
8143
8410
  var esc = function (s) { return b.template.escapeHtml(String(s == null ? "" : s)); };
8144
8411
  var name = "q_" + esc(q.id);
8145
- var req = q.required ? " <abbr class=\"survey-req\" title=\"Required\">*</abbr>" : "";
8412
+ var req = q.required
8413
+ ? " <span class=\"survey-req\" aria-hidden=\"true\">*</span><span class=\"sr-only\">(required)</span>"
8414
+ : "";
8146
8415
  var control = "";
8147
8416
  if (q.kind === "rating") {
8148
8417
  var lo = (q.max >= 9) ? 0 : 1; // 0..10 for NPS-scale, else 1..max
@@ -8472,6 +8741,14 @@ function mount(router, deps) {
8472
8741
  }
8473
8742
  _liveConsentPolicy();
8474
8743
 
8744
+ // Header nav gating — the Collections / Categories links + their mobile-
8745
+ // disclosure entries render only when the matching routes are mounted
8746
+ // (deps wired). Module-scoped so `_buildPrimaryNav` (called from the
8747
+ // module-level `_wrap`, which doesn't close over `deps`) can read them,
8748
+ // matching the `_ccyEnabled` / `_activeConsentPolicy` pattern.
8749
+ _hasCollections = !!deps.collections;
8750
+ _hasCategoryNav = !!deps.categoryNavigation;
8751
+
8475
8752
  function _send(res, status, html) {
8476
8753
  res.status(status);
8477
8754
  res.setHeader && res.setHeader("content-type", "text/html; charset=utf-8");
@@ -9744,8 +10021,19 @@ function mount(router, deps) {
9744
10021
  // id DESC) matches the prior `products.list` order.
9745
10022
  var decorated = await deps.catalog.batch.decoratedActive({ currency: "USD", limit: 24 });
9746
10023
  var products = decorated.rows;
10024
+ // Featured-collections band data — active collections, newest-curated
10025
+ // first (collections.list orders updated_at DESC, slug DESC; the band
10026
+ // caps to 6). Best-effort: a band read failure degrades to no band, it
10027
+ // never blocks the home page. The band itself drops when the list is
10028
+ // empty, so an unwired collections dep also yields hero → catalog.
10029
+ var collections = [];
10030
+ if (deps.collections && typeof deps.collections.list === "function") {
10031
+ try {
10032
+ collections = await deps.collections.list({ active_only: true });
10033
+ } catch (_e) { collections = []; }
10034
+ }
9747
10035
  var ccy = await _currencyForReq(req);
9748
- var html = renderHome(Object.assign({ products: products, shop_name: shopName, theme: theme }, _requestUrls(req), ccy));
10036
+ var html = renderHome(Object.assign({ products: products, collections: collections, shop_name: shopName, theme: theme }, _requestUrls(req), ccy));
9749
10037
  _send(res, 200, html);
9750
10038
  });
9751
10039
 
@@ -14144,6 +14432,128 @@ function mount(router, deps) {
14144
14432
  });
14145
14433
  }
14146
14434
 
14435
+ // Privacy & data — the signed-in customer's self-service DSR surface.
14436
+ // Filing an export or deletion request pins customer_id to the SESSION
14437
+ // (never the form); requested_by is the fixed "customer-self-service"
14438
+ // string (the session env carries no email — _currentCustomerEnv returns
14439
+ // { customer_id, exp } only — and the primitive requires a non-empty
14440
+ // requested_by). Self-service FILES the request; an operator reviews +
14441
+ // executes it from /admin/dsr (PD-3). The export download is
14442
+ // ownership-scoped (a stranger → 404) and streams the bundle.
14443
+ if (deps.complianceExport && deps.customers) {
14444
+ var dsr = deps.complianceExport;
14445
+ var dsrReaders = deps.complianceExportReaders || {};
14446
+ var dsrSections = deps.complianceExportSections || {};
14447
+ var streamDsr = deps.streamDsrBundle;
14448
+
14449
+ router.get("/account/privacy", async function (req, res) {
14450
+ var auth = _accountAuth(req, res); if (!auth) return;
14451
+ var history = [];
14452
+ try { history = await dsr.auditForCustomer(auth.customer_id); } catch (_e) { history = []; }
14453
+ var cartCount = await _cartCountForReq(req);
14454
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
14455
+ _send(res, 200, renderAccountPrivacy({
14456
+ history: history,
14457
+ ok: url && url.searchParams.get("ok"),
14458
+ shop_name: shopName,
14459
+ cart_count: cartCount,
14460
+ }));
14461
+ });
14462
+
14463
+ // File a data-export request. customer_id is pinned to the session.
14464
+ // A bad enum (jurisdiction / scope) throws TypeError → a 400 re-render
14465
+ // with a notice, never a 500. No row is created on a bad enum (the
14466
+ // primitive validates before INSERT).
14467
+ router.post("/account/privacy/export", async function (req, res) {
14468
+ var auth = _accountAuth(req, res); if (!auth) return;
14469
+ var body = req.body || {};
14470
+ try {
14471
+ await dsr.requestExport({
14472
+ customer_id: auth.customer_id,
14473
+ requested_by: "customer-self-service",
14474
+ jurisdiction: body.jurisdiction,
14475
+ scope: body.scope,
14476
+ });
14477
+ } catch (e) {
14478
+ if (e instanceof TypeError) {
14479
+ var history = [];
14480
+ try { history = await dsr.auditForCustomer(auth.customer_id); } catch (_e2) { history = []; }
14481
+ var cc = await _cartCountForReq(req);
14482
+ return _send(res, 400, renderAccountPrivacy({
14483
+ history: history,
14484
+ notice: "Please choose a valid law and scope and try again.",
14485
+ shop_name: shopName,
14486
+ cart_count: cc,
14487
+ }));
14488
+ }
14489
+ throw e;
14490
+ }
14491
+ res.status(303);
14492
+ res.setHeader && res.setHeader("location", "/account/privacy?ok=export");
14493
+ return res.end ? res.end() : res.send("");
14494
+ });
14495
+
14496
+ router.get("/account/delete", async function (req, res) {
14497
+ var auth = _accountAuth(req, res); if (!auth) return;
14498
+ var cartCount = await _cartCountForReq(req);
14499
+ _send(res, 200, renderAccountDelete({ shop_name: shopName, cart_count: cartCount }));
14500
+ });
14501
+
14502
+ // File a deletion request. customer_id is pinned to the session; this
14503
+ // FILES the request (status received) — it does NOT execute the erasure
14504
+ // (the operator reviews identity + runs processDeletion from the admin
14505
+ // queue). A bad enum / empty reason throws TypeError → a 400 re-render.
14506
+ router.post("/account/delete", async function (req, res) {
14507
+ var auth = _accountAuth(req, res); if (!auth) return;
14508
+ var body = req.body || {};
14509
+ try {
14510
+ await dsr.requestDeletion({
14511
+ customer_id: auth.customer_id,
14512
+ requested_by: "customer-self-service",
14513
+ jurisdiction: body.jurisdiction,
14514
+ reason: body.reason,
14515
+ });
14516
+ } catch (e) {
14517
+ if (e instanceof TypeError) {
14518
+ var cc = await _cartCountForReq(req);
14519
+ return _send(res, 400, renderAccountDelete({
14520
+ values: body,
14521
+ notice: "Please choose a valid law and give a reason, then try again.",
14522
+ shop_name: shopName,
14523
+ cart_count: cc,
14524
+ }));
14525
+ }
14526
+ throw e;
14527
+ }
14528
+ res.status(303);
14529
+ res.setHeader && res.setHeader("location", "/account/privacy?ok=deletion");
14530
+ return res.end ? res.end() : res.send("");
14531
+ });
14532
+
14533
+ // Ownership-scoped streaming export download. Resolve the row, verify
14534
+ // it belongs to THIS session customer (IDOR gate — a stranger / unknown
14535
+ // / malformed id → 404, never another customer's bundle), confirm it's
14536
+ // a fulfilled/delivered export, then stream. Status + ownership are
14537
+ // validated BEFORE the first write (the bundle streams header-first).
14538
+ router.get("/account/privacy/:id/export.json", async function (req, res) {
14539
+ var auth = _accountAuth(req, res); if (!auth) return;
14540
+ var row;
14541
+ try { row = await dsr.getRequest(req.params && req.params.id); }
14542
+ catch (e) {
14543
+ if (e instanceof TypeError) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return; }
14544
+ throw e;
14545
+ }
14546
+ if (!row || row.customer_id !== auth.customer_id ||
14547
+ row.request_kind !== "export" ||
14548
+ (row.status !== "fulfilled" && row.status !== "delivered")) {
14549
+ _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
14550
+ return;
14551
+ }
14552
+ var sections = dsrSections[row.scope] || dsrSections.full || [];
14553
+ await streamDsr(res, dsrReaders, sections, row);
14554
+ });
14555
+ }
14556
+
14147
14557
  // Loyalty — the signed-in customer's points balance + tier, the
14148
14558
  // earn/redeem ledger, how points are earned, and (when a reward
14149
14559
  // catalog + redemption primitive are wired) a redeem-a-reward
@@ -15567,6 +15977,7 @@ module.exports = {
15567
15977
  renderProfile: renderProfile,
15568
15978
  renderAccountSubscriptions: renderAccountSubscriptions,
15569
15979
  renderCookiePreferences: renderCookiePreferences,
15980
+ renderSurveyPage: renderSurveyPage,
15570
15981
  renderNewsletterError: renderNewsletterError,
15571
15982
  renderNotFound: renderNotFound,
15572
15983
  // Layout exposed so operators forking the framework can override.
@@ -571,8 +571,12 @@ var CHROME_DEFAULTS = Object.freeze({
571
571
  search_submit: "Search",
572
572
 
573
573
  nav_shop: "Shop",
574
+ nav_collections: "Collections",
575
+ nav_categories: "Categories",
574
576
  nav_framework: "Framework",
575
577
  nav_account: "Account",
578
+ // The narrow-viewport disclosure's <summary> label.
579
+ nav_menu: "Menu",
576
580
  // The cart aria-label carries the live item count; `{count}` is
577
581
  // interpolated by b.i18n at render time.
578
582
  nav_cart_aria: "Cart, {count} items",
@@ -589,8 +593,6 @@ var CHROME_DEFAULTS = Object.freeze({
589
593
  footer_shop_all: "All products",
590
594
  footer_shop_collections: "Collections",
591
595
  footer_shop_categories: "Categories",
592
- footer_shop_new: "New arrivals",
593
- footer_shop_sale: "On sale",
594
596
  footer_shop_compare: "Compare",
595
597
  footer_shop_cart: "Cart",
596
598
  footer_shop_shipping: "Shipping & returns",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.47",
3
+ "version": "0.3.49",
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": {