@blamejs/blamejs-shop 0.3.55 → 0.3.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.3.x
10
10
 
11
+ - v0.3.56 (2026-06-02) — **Opt-in wishlist alert and digest emails, passwordless magic-link sign-in, and a documented list of planned integrations.** Two opt-in capabilities are wired, and the operator docs now state plainly which integrations are planned-but-not-yet-available so nothing reads as silently missing. A customer can opt in to wishlist price-drop and back-in-stock alerts and a periodic wishlist digest; and a passwordless email magic-link offers an account sign-in path alongside passkeys and OAuth. Both stay inert until an operator configures email sending, so a store with no mailer behaves exactly as before. **Added:** *Opt-in wishlist alert and digest emails* — A customer can opt in, from their wishlist, to price-drop and back-in-stock alerts and to a periodic wishlist digest. Scheduled sweeps send them. The feature is off until an operator configures email sending and an email resolver; with no mailer it is a no-op. Digest emails are rendered field-by-field with output escaping. · *Passwordless magic-link sign-in* — When a mailer is configured, a customer can request a single-use, time-limited sign-in link by email, in addition to passkeys and OAuth. The request response is identical whether or not the email matches an account (no account enumeration), and the link establishes a sealed session on use. **Changed:** *Documented planned integrations* — The README and SECURITY docs now list the integrations that are intentionally not yet available — address validation, live chat, web-push notifications, in-console payment-key configuration, an age gate, and per-page promo banners — each with the condition under which it would be built, so operators know what is and isn't supported rather than discovering a gap.
12
+
11
13
  - v0.3.55 (2026-06-02) — **Accessibility: field-level form-error messages and horizontally scrollable admin tables.** Two accessibility improvements. When a storefront form is rejected, the specific field at fault is now marked for assistive technology with an inline error message tied to that input, in addition to the existing summary at the top of the form. And wide tables in the admin console now scroll horizontally inside their own container instead of overflowing the page on a narrow or zoomed viewport. **Fixed:** *Field-level error messages on storefront forms* — When the address, review, product-question, or support-ticket form is rejected, the offending field is now marked aria-invalid with an inline error message associated to it (aria-describedby), so a screen-reader user is taken to the exact field to fix rather than only hearing a top-of-form summary. The server remains the only validator; this only changes how a rejected field is presented. Forms that fail on a whole-form condition continue to show the summary message. · *Admin tables reflow on narrow screens* — Wide data tables in the admin console (orders, products, inventory, and the rest) are now wrapped in a horizontal-scroll container, so columns stay reachable on a smaller laptop window, a split screen, or at high zoom instead of overflowing the layout. Column header semantics are preserved.
12
14
 
13
15
  - v0.3.54 (2026-06-02) — **In-store pickup, saved payment methods, and gift wrapping at checkout.** Three more built-but-unreachable capabilities are now wired end to end. Operators can offer in-store pickup with managed pickup locations and a fulfilment queue; signed-in customers can save a card and reuse it at checkout; and operators can offer gift wrapping with a per-order gift message. The gift-wrap fee is added as a real cart line so it is included in the charged total. **Added:** *In-store pickup (click and collect)* — Operators can define pickup locations and work a pickup queue in the admin console (filtered by location), and shoppers can choose 'Pick up in store' at checkout. The order page and a new account pickups view show the pickup status. A not-found pickup or location returns a clear not-found rather than a generic error. · *Saved payment methods* — When Stripe is configured, a signed-in customer can save a card via a secure setup flow (loaded through a route-scoped policy and a same-origin script, with no inline script), manage their saved cards under their account, set a default, and reuse a saved card at checkout. A customer can only ever see or change their own saved cards. · *Gift wrapping at checkout* — Operators can define gift-wrap options (with a fee) in the admin console, and shoppers can add a wrap plus a gift message and recipient at checkout. The wrap fee is added as a real cart line, so it is included in the order total and the amount charged — never applied after the charge.
package/README.md CHANGED
@@ -69,12 +69,12 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
69
69
  | **`lib/payment.js`** | Payment adapters — **Stripe** (verify webhook HMAC-SHA256 via upstream `b.webhook.verify`, create / retrieve / confirm / cancel PaymentIntent, refund, register / list payment-method domains for Apple/Google Pay) and **PayPal** (`adapter: "paypal"` — OAuth2 client-credentials token, create / capture / get / refund Orders v2, webhook verify via PayPal's verify-webhook-signature API). No `stripe` / `paypal` npm dep — outbound through `b.httpClient` (SSRF-gated, retried, circuit-broken). |
70
70
  | **`lib/order.js`** | FSM-driven post-checkout record via upstream `b.fsm`. States: pending → paid → fulfilling → shipped → delivered (+ refunded / cancelled). Every transition appends to `order_transitions`. |
71
71
  | **`lib/checkout.js`** | Orchestrator. `quote()` returns priced quote; `confirm()` creates a Stripe PaymentIntent + persists order pending; `handleStripeEvent()` verifies webhook + fires the FSM transition. PayPal path: `createPaypalOrder()` opens a PayPal order + persists pending, `capturePaypalOrder()` captures → paid, `handlePaypalEvent()` is the webhook backstop. All idempotent on re-delivery. |
72
- | **`lib/email.js`** | Transactional templates — order receipt, ship notification, refund confirmation. Strict `{{var}}` renderer with HTML escape + refusal of unknown / unused placeholders. Composed on `b.mail` (DKIM/SPF/DMARC/BIMI upstream). |
72
+ | **`lib/email.js`** | Transactional templates — order receipt, ship notification, refund confirmation, wishlist price-drop, abandoned-cart, review request, back-in-stock, **wishlist digest** (the periodic saved-items rollup, rendered per-line from the structured digest so every title / price is independently escaped), and **email magic-link sign-in**. Strict `{{var}}` renderer with HTML escape + refusal of unknown / unused placeholders. Composed on `b.mail` (DKIM/SPF/DMARC/BIMI upstream). |
73
73
  | **`lib/storefront.js`** | Server-rendered HTML — utility bar + sticky header + dark hero with code-preview card + primitives marquee + featured-product callout + collections grid + framework feature band + designed catalog grid + newsletter band + four-column footer. Designed surfaces also for PDP, cart, checkout, pay, order, account login / register / dashboard, search results, `/admin` API landing, 404. Image-bearing cards on the home + search grids pull from `catalog.media`. The default theme stylesheet is external (R2-served `themes/default/assets/css/main.css`) and CSP-compliant; the typeface (Inter / Inter Tight) is self-hosted from `themes/default/assets/fonts`, so no page loads a cross-origin font. Operators override by uploading a replacement at the same key, by passing `opts.theme_css` to renderers, or by registering a named theme through the `theme` primitive. |
74
74
  | **`lib/customers.js`** | Customer accounts — passkey (WebAuthn) + **Sign in with Google / Apple** (OIDC). Email is stored hash-only (`b.crypto.namespaceHash` namespace `customer-email`); the raw address never lands in D1. Passkey credentials carry CBOR-encoded public keys, transport hints, and SHA3-512-fingerprinted attestation. `signInWithOIDC` keys federated accounts on the provider `(provider, subject)` and links an existing account only on a provider-verified email (never on an unverified one). `mintAppleClientSecret` produces Apple's required ES256 client-secret JWT from a Services-ID `.p8` key (the one classical signature the protocol mandates; the PQC default doesn't apply to an external IdP's wire format). Account routes (`/account/login`, `/account/register`, `/account`, `/account/login/google`, `/account/login/apple`) ship as designed cards on the storefront; signed-in customers manage their own passkeys (`/account/passkeys` — list, add another, confirm-gated revoke scoped to the account with a last-sign-in-method guard) and edit their profile (`/account/profile`). |
75
75
  | **`lib/reviews.js`** | Operator-moderated product ratings. Submission requires a signed-in customer **and** a verified purchase — `/products/:slug/review` confirms a completed order for the product (via `order.hasPurchasedProduct`) before accepting, re-checked on POST; reviews land `pending`. Author identity is hash-only (`b.crypto.namespaceHash`); the raw email is never stored. The PDP renders the average, per-star distribution, and published reviews with `AggregateRating` JSON-LD. `/admin/reviews` is the moderation queue (`listByStatus` → publish / reject). |
76
76
  | **`lib/product-qa.js`** | Customer questions and operator/customer answers per product, operator-moderated, distinct from the rating-based reviews. A signed-in shopper asks at `/products/:slug/question`; questions land `pending` and surface only after approval. Author identity is the customer id (verified against the customers primitive) or a hash-only email — the raw address is never stored. The product page renders approved questions with their approved answers (seller / customer / system badge, pinned 'top answer' first) in both the edge and container paths. `/admin/questions` is the moderation console: the cross-product queue (`listQuestionsByStatus`), and a per-question detail to approve / reject the question, post the seller answer (`submitAnswer`), approve / reject / pin answers. |
77
- | **`lib/wishlist.js`** | Per-customer saved products. The PDP renders a login-gated "Save to wishlist" toggle and a "N shoppers saved this" social-proof count; `/account/wishlist` lists saved items (remove + reopen, orphan-tolerant when a product is archived). `POST /wishlist/toggle` is idempotent (`INSERT OR IGNORE`) and redirects to the canonical product slug or a safe same-origin `return_to`. UUID-shape-validated ids, `b.pagination` HMAC cursors. |
77
+ | **`lib/wishlist.js`** + **`lib/wishlist-alerts.js`** + **`lib/wishlist-digest.js`** | Per-customer saved products. The PDP renders a login-gated "Save to wishlist" toggle and a "N shoppers saved this" social-proof count; `/account/wishlist` lists saved items (remove + reopen, orphan-tolerant when a product is archived) and carries a per-customer opt-in panel for **sale + restock alerts** (price-drop / back-in-stock, event-driven) and the **periodic digest** (the saved-items rollup on a weekly / monthly schedule). `POST /wishlist/toggle` is idempotent (`INSERT OR IGNORE`) and redirects to the canonical product slug or a safe same-origin `return_to`. Both notification paths are off by default and require a configured mailer plus an email-address resolver to actually send (the customer store keeps only an email hash) — see *Optional integrations*. UUID-shape-validated ids, `b.pagination` HMAC cursors; prices rendered through `pricing.format` (locale + zero-decimal-currency correct). |
78
78
  | **`lib/save-for-later.js`** | Per-customer cart holding list. Each cart line gets a login-gated "Save for later" control (`POST /cart/lines/:id/save` → `moveFromCart`); `/account/saved` lists items with Move-to-cart / Remove. `moveToCart` reprices to the current catalog price and stock-gates (out-of-stock + non-backorderable is refused). Composes `catalog.inventory` + `catalog.prices` + `catalog.variants`. |
79
79
  | **`lib/addresses.js`** | Per-customer address book at `/account/addresses` — add / edit / set default shipping or billing / remove. One-default-per-role invariant (promoting clears the prior). Every by-id route confirms the address belongs to the signed-in customer before acting (a guessed id returns 404). `b.guardUuid` ids, 2-char ISO country. |
80
80
  | **`lib/returns.js`** | Self-serve RMAs. Customer requests a return against their own order at `/account/orders/:id/return` (items + reason, ownership-checked, lines built from the order's own records) and tracks status at `/account/returns`. Operators work `/admin/returns` — approve (refund amount) / mark received / refund / reject — over the pending → approved → received → refunded FSM; illegal transitions are 409, bad ids 404. |
@@ -198,12 +198,49 @@ variables. A signed-in operator can see the live on/off status of each at
198
198
  | **Sign in with Google** | A *Continue with Google* button on `/account/login` (OIDC). | `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET`, `SHOP_ORIGIN` (e.g. `https://shop.example.com`) | Create a Google Cloud **OAuth 2.0 Web** client; add `<SHOP_ORIGIN>/account/auth/google/callback` as an Authorized redirect URI; consent-screen scopes `openid email profile`. The button appears only when all three are set. |
199
199
  | **Sign in with Apple** | A *Continue with Apple* button on `/account/login` (OIDC). | `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_CLIENT_ID` (your **Services ID**), `APPLE_PRIVATE_KEY` (the `.p8` key contents), `SHOP_ORIGIN` | Needs an **Apple Developer Program** membership. Create a Services ID, enable Sign in with Apple, add `<SHOP_ORIGIN>/account/auth/apple/callback` as a Return URL, and create a Sign-in-with-Apple key (`.p8`). The shop mints Apple's ES256 client secret from the key at boot (re-minted each deploy, inside Apple's 6-month window). The button appears only when all five are set. |
200
200
  | **PayPal checkout** | A native PayPal button on `/checkout` (PayPal Orders v2 — create / approve / capture), distinct from PayPal-through-Stripe. | `PAYPAL_CLIENT_ID`, `PAYPAL_SECRET` (a PayPal REST app), `PAYPAL_WEBHOOK_ID`, `PAYPAL_ENV` (`sandbox`\|`live`); Stripe checkout must also be live | The shop exchanges the OAuth2 token and creates / captures orders server-side; the button drives `/checkout/paypal/create` + `/checkout/paypal/capture`. Point a PayPal webhook at `/api/webhooks/paypal` (verified through PayPal's API). Allow `www.paypal.com` in your CSP `script-src` / `frame-src` (as you would `js.stripe.com`). |
201
+ | **Transactional email (SMTP)** | Order/ship/refund mail, abandoned-cart recovery, back-in-stock alerts, **wishlist sale + restock alerts and the periodic wishlist digest** (opt-in per customer on `/account/wishlist`), and **email magic-link sign-in** (a *Email me a sign-in link* option on `/account/login` for shoppers without a passkey or social login). | `SMTP_HOST`, `MAIL_FROM` (plus optional `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS`) | Without a mailer these surfaces stay inert — the wishlist crons scan nothing, the magic-link page reports email sign-in unavailable, and passkey / social login are unchanged. **Wishlist alerts + digests are additionally gated on an email-address resolver:** the customer store keeps only a salted email *hash* (never the plaintext), so out of the box there is no deliverable address and the crons send nothing even with SMTP set. They begin sending once you supply a resolver that maps a customer id to a deliverable address from your own plaintext-address store — the same hook abandoned-cart recovery uses. |
201
202
 
202
203
  **Planned / not available:**
203
204
 
204
205
  - **Shop Pay / "Sign in with Shop"** — **not available** to a self-hosted,
205
206
  non-Shopify store: the credentials only issue from a Shopify Admin and payment
206
207
  flows through Shopify Payments. There is no path to enable it here.
208
+ - **Address verification at checkout** — the address cache/record layer ships,
209
+ but no verification provider is bundled (verifying a postal address requires a
210
+ paid third-party lookup). *Re-opens* when you supply a provider adapter (USPS,
211
+ Smarty, Lob, Google, Melissa, …); the checkout address step then validates
212
+ against it. No provider, no behavior change.
213
+ - **Live chat** — a synchronous chat widget is not wired. The strict storefront
214
+ CSP (`script-src 'self'`, no inline script) means a chat island must be served
215
+ first-party from the container, and a realtime transport is a deployment-cost
216
+ decision. *Re-opens* on operator demand: a container-served polling island
217
+ (poll endpoint, admin queue, idle-session cleanup) is the minimal path;
218
+ WebSocket is a later upgrade.
219
+ - **Web push notifications** — the device registry + consent matrix + dispatcher
220
+ ship, and a PWA manifest + service worker are served, but no push subscription
221
+ endpoint or VAPID send is wired. *Re-opens* once you generate a VAPID keypair
222
+ (stored as a deployment secret, like the Stripe keys) and enable a subscribe
223
+ endpoint; a registered service worker is the prerequisite.
224
+ - **Payment keys are deployment secrets, by design** — Stripe / PayPal keys are
225
+ set as deployment secrets (`STRIPE_API_KEY`, …), **not** entered through the
226
+ admin console. A console wizard that wrote payment secrets into the database
227
+ would widen the blast radius (a database dump or a leaked bridge secret would
228
+ then expose payment keys; deployment secret bindings never sit in the
229
+ database). This is deliberate and is not planned to change for a single-store
230
+ deploy; a multi-tenant model with per-tenant operator-self-service keys would
231
+ be a separate build.
232
+ - **Age-restricted catalogs / age gate** — **not supported.** The catalog has no
233
+ age-restricted product class and the storefront enforces no age interstitial.
234
+ *Re-opens* as a real build (a `requires_age_check` product attribute + a
235
+ server-enforced edge + container interstitial) if an age-restricted category
236
+ enters the catalog.
237
+ - **Placement-specific promo banners** — the sitewide promo/notice strip is the
238
+ **announcement bar** (managed at `/admin/announcements`); it is the single
239
+ source for the top-of-page strip. Additional marketing *placements*
240
+ (homepage hero, PDP-side, cart-side, empty-search, footer) are a future
241
+ additive surface. *Re-opens* when you want placement-specific marketing beyond
242
+ the sitewide strip; the top strip stays the announcement bar's (running two
243
+ competing sitewide strips is avoided by design).
207
244
 
208
245
  ## Vendoring blamejs
209
246
 
package/SECURITY.md CHANGED
@@ -198,6 +198,20 @@ node -e "
198
198
  emails are stored hash-only (`b.crypto.namespaceHash`), and the
199
199
  account leaderboard exposes rank plus initials only, never an email or
200
200
  account id.
201
+ - **Email magic-link sign-in is single-use and hashed-at-rest.** The
202
+ optional *Email me a sign-in link* path (mounts only when a mailer is
203
+ configured) mints a 256-bit token whose plaintext is emailed once and
204
+ never persisted — only its `namespaceHash` digest is stored. Redemption
205
+ re-hashes the submitted token, compares in constant time, and flips the
206
+ row to `consumed` under a `status = 'issued'` guard, so a token works
207
+ exactly once (a replayed link bounces to login). Tokens carry a short
208
+ default lifetime (15 minutes) re-checked at redeem time, and a
209
+ background tick durably expires stale rows. The request form is not an
210
+ account-existence oracle: a known and an unknown address return the
211
+ identical "if an account exists we've emailed a link" confirmation, and
212
+ an unknown / expired / reused token bounces to login identically. On
213
+ success the sealed `shop_auth` session cookie is set the same way the
214
+ passkey and OAuth paths set it; passkey and social login are unchanged.
201
215
  - **Privileged actions are recorded and reviewable.** Every mutating
202
216
  admin action and every catalog-API error is appended to a
203
217
  tamper-evident, hash-chained audit log (the framework's `b.audit`
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.55",
2
+ "version": "0.3.56",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
package/lib/email.js CHANGED
@@ -187,6 +187,75 @@ var ABANDONED_CART_TEXT_AFTER_LINES =
187
187
  "{{notes}}\n\n" +
188
188
  "Return to cart: {{cart_url}}\n";
189
189
 
190
+ // Wishlist digest — the periodic rollup of the customer's saved items
191
+ // (price + in/out-of-stock marker per line). The line set is rendered
192
+ // through the strict renderer once per line so each line's `title` /
193
+ // `price` / `stock` is HTML-escaped independently, then concatenated
194
+ // between the BEFORE/AFTER halves. The structured `lines[]` from
195
+ // composeDigest is the source — the pre-built `digest.html` is NEVER
196
+ // spliced in (that would double-handle escaping); rendering from the
197
+ // structured array keeps the escape-by-default path owning escaping
198
+ // end-to-end (the <script>-in-title XSS guard).
199
+
200
+ var WISHLIST_DIGEST_HTML_BEFORE_LINES =
201
+ "<!DOCTYPE html>\n" +
202
+ "<html lang=\"en\"><head><meta charset=\"utf-8\"><title>Your wishlist update</title></head>" +
203
+ "<body style=\"margin:0;background:#ffffff;color:#0d0d0d;font-family:system-ui,sans-serif;\">\n" +
204
+ "<div style=\"max-width:560px;margin:0 auto;padding:24px;\">\n" +
205
+ " <h1 style=\"color:#0d0d0d;margin:0 0 12px;\">Your wishlist this period</h1>\n" +
206
+ " <p style=\"margin:0 0 16px;\">Here's where your saved items stand right now:</p>\n" +
207
+ " <ul style=\"padding-left:20px;margin:0 0 16px;color:#0d0d0d;\">\n";
208
+
209
+ var WISHLIST_DIGEST_HTML_LINE =
210
+ " <li><a href=\"{{product_url}}\" style=\"color:#fa4f09;\">{{title}}</a> — {{price}}{{stock}}</li>\n";
211
+
212
+ var WISHLIST_DIGEST_HTML_EMPTY =
213
+ " <li>No items in your wishlist right now.</li>\n";
214
+
215
+ var WISHLIST_DIGEST_HTML_AFTER_LINES =
216
+ " </ul>\n" +
217
+ " <p style=\"margin:16px 0;color:#0d0d0d;font-size:13px;\">You're receiving this because you opted into the wishlist digest.</p>\n" +
218
+ "</div>\n" +
219
+ "</body></html>\n";
220
+
221
+ var WISHLIST_DIGEST_TEXT_BEFORE_LINES =
222
+ "Your wishlist this period\n=========================\n\n" +
223
+ "Here's where your saved items stand right now:\n\n";
224
+
225
+ var WISHLIST_DIGEST_TEXT_LINE =
226
+ " {{title}} — {{price}}{{stock}}\n {{product_url}}\n";
227
+
228
+ var WISHLIST_DIGEST_TEXT_EMPTY =
229
+ " No items in your wishlist right now.\n";
230
+
231
+ var WISHLIST_DIGEST_TEXT_AFTER_LINES =
232
+ "\nYou're receiving this because you opted into the wishlist digest.\n";
233
+
234
+ // Magic-link sign-in — a single-use, time-limited link a customer
235
+ // follows to sign in without a passkey or password. The `link_url`
236
+ // carries the plaintext portal token; it's HTML-escaped through the
237
+ // strict renderer like every other value (the URL is built server-side
238
+ // but escape-by-default still applies).
239
+
240
+ var MAGIC_LINK_HTML =
241
+ "<!DOCTYPE html>\n" +
242
+ "<html lang=\"en\"><head><meta charset=\"utf-8\"><title>Your sign-in link</title></head>" +
243
+ "<body style=\"margin:0;background:#ffffff;color:#0d0d0d;font-family:system-ui,sans-serif;\">\n" +
244
+ "<div style=\"max-width:560px;margin:0 auto;padding:24px;\">\n" +
245
+ " <h1 style=\"color:#0d0d0d;margin:0 0 12px;\">Sign in to your account</h1>\n" +
246
+ " <p style=\"margin:0 0 16px;\">Use the button below to sign in. This link works once and expires shortly.</p>\n" +
247
+ " <p style=\"margin:24px 0;\"><a href=\"{{link_url}}\" style=\"background:#fa4f09;color:#ffffff;padding:12px 20px;text-decoration:none;display:inline-block;font-weight:bold;\">Sign in</a></p>\n" +
248
+ " <p style=\"margin:0;color:#0d0d0d;font-size:13px;\">If the button doesn't work, paste this link into your browser: {{link_url}}</p>\n" +
249
+ " <p style=\"margin:16px 0 0;color:#0d0d0d;font-size:13px;\">If you didn't request this, you can ignore this email — no one can sign in without the link.</p>\n" +
250
+ "</div>\n" +
251
+ "</body></html>\n";
252
+
253
+ var MAGIC_LINK_TEXT =
254
+ "Sign in to your account\n\n" +
255
+ "Use this single-use link to sign in (it expires shortly):\n" +
256
+ "{{link_url}}\n\n" +
257
+ "If you didn't request this, you can ignore this email.\n";
258
+
190
259
  // Review request — sent ~7 days after ship. Per-product review
191
260
  // links use the same before/lines/after split as the abandoned
192
261
  // cart so the strict renderer keeps its escape-everything property.
@@ -443,6 +512,96 @@ function create(opts) {
443
512
  );
444
513
  },
445
514
 
515
+ // Wishlist digest — the periodic rollup of the customer's saved
516
+ // items. The caller (the wishlistDigest dispatcher) passes the
517
+ // structured `lines[]` composeDigest built; this renders each line
518
+ // through the strict renderer once so every `title` / `price` is
519
+ // HTML-escaped independently, then concatenates between the
520
+ // BEFORE/AFTER halves. The pre-built `digest.html` / `digest.text`
521
+ // are accepted by the caller's shape but NEVER spliced in raw —
522
+ // rendering from the structured array keeps the escape-by-default
523
+ // path owning escaping end-to-end (the <script>-in-title XSS guard).
524
+ // `lines` MAY be empty (the "no items" digest); validation is
525
+ // entry-point tier (throw TypeError so the cron caller's try/catch
526
+ // drops the row silently).
527
+ sendWishlistDigest: async function (input) {
528
+ if (!input) throw new TypeError("email.sendWishlistDigest: input object required");
529
+ if (typeof input.customer_email !== "string" || !input.customer_email) {
530
+ throw new TypeError("email.sendWishlistDigest: customer_email required");
531
+ }
532
+ if (!Array.isArray(input.lines)) {
533
+ throw new TypeError("email.sendWishlistDigest: lines array required (may be empty)");
534
+ }
535
+ var i;
536
+ for (i = 0; i < input.lines.length; i += 1) {
537
+ var ln = input.lines[i];
538
+ if (!ln || typeof ln !== "object") {
539
+ throw new TypeError("email.sendWishlistDigest: lines[" + i + "] must be an object");
540
+ }
541
+ if (typeof ln.title !== "string" || !ln.title) {
542
+ throw new TypeError("email.sendWishlistDigest: lines[" + i + "].title required");
543
+ }
544
+ }
545
+ var htmlLines = "";
546
+ var textLines = "";
547
+ if (input.lines.length === 0) {
548
+ htmlLines = WISHLIST_DIGEST_HTML_EMPTY;
549
+ textLines = WISHLIST_DIGEST_TEXT_EMPTY;
550
+ } else {
551
+ for (i = 0; i < input.lines.length; i += 1) {
552
+ var line = input.lines[i];
553
+ // The in/out-of-stock marker is derived here (not trusted from
554
+ // the caller as raw HTML) so it rides the strict renderer like
555
+ // every other field.
556
+ var stockMarker = line.in_stock === true ? " (in stock)"
557
+ : line.in_stock === false ? " (out of stock)"
558
+ : "";
559
+ var lineVars = {
560
+ title: line.title,
561
+ price: line.price == null ? "—" : String(line.price),
562
+ stock: stockMarker,
563
+ product_url: typeof line.product_url === "string" && line.product_url
564
+ ? line.product_url
565
+ : "#",
566
+ };
567
+ htmlLines += _render(WISHLIST_DIGEST_HTML_LINE, lineVars);
568
+ textLines += _render(WISHLIST_DIGEST_TEXT_LINE, lineVars);
569
+ }
570
+ }
571
+ var html =
572
+ WISHLIST_DIGEST_HTML_BEFORE_LINES +
573
+ htmlLines +
574
+ WISHLIST_DIGEST_HTML_AFTER_LINES;
575
+ var text =
576
+ WISHLIST_DIGEST_TEXT_BEFORE_LINES +
577
+ textLines +
578
+ WISHLIST_DIGEST_TEXT_AFTER_LINES;
579
+ return await _send(
580
+ input.customer_email,
581
+ "Your wishlist update",
582
+ html, text, input.replyTo
583
+ );
584
+ },
585
+
586
+ // Magic-link sign-in — emails a single-use portal link. The
587
+ // `link_url` carries the plaintext token; it's escaped through the
588
+ // strict renderer like every other value. Entry-point tier: throw
589
+ // TypeError on a bad shape so the caller's try/catch keeps the
590
+ // surface enumeration-safe (the generic confirmation shows either way).
591
+ sendMagicLink: async function (input) {
592
+ if (!input) throw new TypeError("email.sendMagicLink: input object required");
593
+ if (typeof input.customer_email !== "string" || !input.customer_email) {
594
+ throw new TypeError("email.sendMagicLink: customer_email required");
595
+ }
596
+ if (typeof input.link_url !== "string" || !input.link_url) {
597
+ throw new TypeError("email.sendMagicLink: link_url required");
598
+ }
599
+ var vars = { link_url: input.link_url };
600
+ var html = _render(MAGIC_LINK_HTML, vars);
601
+ var text = _render(MAGIC_LINK_TEXT, vars);
602
+ return await _send(input.customer_email, "Your sign-in link", html, text, input.replyTo);
603
+ },
604
+
446
605
  // Review request — sent ~7 days after ship. Per-product review
447
606
  // links are derived from review_base_url + "/" + slug + "/review",
448
607
  // then HTML-escaped through the strict renderer with the rest of
@@ -575,6 +734,16 @@ module.exports = {
575
734
  ABANDONED_CART_TEXT_BEFORE_LINES: ABANDONED_CART_TEXT_BEFORE_LINES,
576
735
  ABANDONED_CART_TEXT_LINE: ABANDONED_CART_TEXT_LINE,
577
736
  ABANDONED_CART_TEXT_AFTER_LINES: ABANDONED_CART_TEXT_AFTER_LINES,
737
+ WISHLIST_DIGEST_HTML_BEFORE_LINES: WISHLIST_DIGEST_HTML_BEFORE_LINES,
738
+ WISHLIST_DIGEST_HTML_LINE: WISHLIST_DIGEST_HTML_LINE,
739
+ WISHLIST_DIGEST_HTML_EMPTY: WISHLIST_DIGEST_HTML_EMPTY,
740
+ WISHLIST_DIGEST_HTML_AFTER_LINES: WISHLIST_DIGEST_HTML_AFTER_LINES,
741
+ WISHLIST_DIGEST_TEXT_BEFORE_LINES: WISHLIST_DIGEST_TEXT_BEFORE_LINES,
742
+ WISHLIST_DIGEST_TEXT_LINE: WISHLIST_DIGEST_TEXT_LINE,
743
+ WISHLIST_DIGEST_TEXT_EMPTY: WISHLIST_DIGEST_TEXT_EMPTY,
744
+ WISHLIST_DIGEST_TEXT_AFTER_LINES: WISHLIST_DIGEST_TEXT_AFTER_LINES,
745
+ MAGIC_LINK_HTML: MAGIC_LINK_HTML,
746
+ MAGIC_LINK_TEXT: MAGIC_LINK_TEXT,
578
747
  REVIEW_REQUEST_HTML_BEFORE_LINES: REVIEW_REQUEST_HTML_BEFORE_LINES,
579
748
  REVIEW_REQUEST_HTML_LINE: REVIEW_REQUEST_HTML_LINE,
580
749
  REVIEW_REQUEST_HTML_AFTER_LINES: REVIEW_REQUEST_HTML_AFTER_LINES,
package/lib/storefront.js CHANGED
@@ -2805,6 +2805,11 @@ function renderWishlist(opts) {
2805
2805
  // sharing primitive isn't wired, or a unit test calling the renderer
2806
2806
  // directly) the panel is empty so the page renders unchanged.
2807
2807
  var sharePanel = opts.share_panel || "";
2808
+ // Alert + digest opt-in panel (server-rendered, no client JS). The
2809
+ // route builds it (it owns the per-trigger / per-schedule prefs reads);
2810
+ // absent it (the alerts/digest primitives aren't wired, or a unit test
2811
+ // calling the renderer directly) the panel is empty.
2812
+ var prefsPanel = opts.prefs_panel || "";
2808
2813
  var body =
2809
2814
  "<section class=\"account-wishlist\">" +
2810
2815
  "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
@@ -2813,6 +2818,7 @@ function renderWishlist(opts) {
2813
2818
  "</ol></nav>" +
2814
2819
  "<h1 class=\"account-wishlist__title\">Saved items</h1>" +
2815
2820
  notice +
2821
+ prefsPanel +
2816
2822
  sharePanel +
2817
2823
  inner +
2818
2824
  "</section>";
@@ -2898,6 +2904,81 @@ function _wishlistSharePanel(opts) {
2898
2904
  "</section>";
2899
2905
  }
2900
2906
 
2907
+ // The wishlist alert + digest opt-in panel (mounts on /account/wishlist
2908
+ // when the alerts / digest primitives are wired). Server-rendered, no
2909
+ // client JS: each toggle is a `<form method="post">` whose hidden `on`
2910
+ // field flips on submit (the submit button carries the new state). The
2911
+ // `_csrf` token is injected automatically by the `_wrap` form chokepoint
2912
+ // (_injectCsrfFields) like every other container POST form — the panel
2913
+ // emits no token itself.
2914
+ //
2915
+ // opts.alerts — [{ trigger, label, subscribed }] (subscribed drives
2916
+ // the button's on/off action)
2917
+ // opts.digests — [{ slug, label, enrolled, enrollment_id }]
2918
+ // opts.notice — "alerts" | "digest" → a saved confirmation
2919
+ //
2920
+ // Every operator-controlled string (a schedule slug used as a label) is
2921
+ // escaped via the storefront `esc()` path.
2922
+ var WISHLIST_PREFS_NOTICES = {
2923
+ alerts: "Alert preferences saved.",
2924
+ digest: "Digest subscription updated.",
2925
+ };
2926
+ function _wishlistPrefsPanel(opts) {
2927
+ opts = opts || {};
2928
+ var esc = b.template.escapeHtml;
2929
+ var alerts = opts.alerts || [];
2930
+ var digests = opts.digests || [];
2931
+ if (alerts.length === 0 && digests.length === 0) return "";
2932
+ var noticeMsg = WISHLIST_PREFS_NOTICES[opts.notice];
2933
+ var notice = noticeMsg
2934
+ ? "<p class=\"form-notice form-notice--ok\" role=\"status\">" + esc(noticeMsg) + "</p>"
2935
+ : "";
2936
+
2937
+ var alertRows = "";
2938
+ for (var i = 0; i < alerts.length; i += 1) {
2939
+ var a = alerts[i];
2940
+ var on = a.subscribed === true;
2941
+ // Submitting flips the state: a subscribed trigger posts on="" (off);
2942
+ // an unsubscribed one posts on="1".
2943
+ alertRows +=
2944
+ "<form class=\"wishlist-prefs__row\" method=\"post\" action=\"/account/wishlist/alerts\">" +
2945
+ "<input type=\"hidden\" name=\"trigger\" value=\"" + esc(a.trigger) + "\">" +
2946
+ "<input type=\"hidden\" name=\"on\" value=\"" + (on ? "" : "1") + "\">" +
2947
+ "<span class=\"wishlist-prefs__label\">" + esc(a.label || a.trigger) + "</span>" +
2948
+ "<span class=\"wishlist-prefs__state\">" + (on ? "On" : "Off") + "</span>" +
2949
+ "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">" + (on ? "Turn off" : "Turn on") + "</button>" +
2950
+ "</form>";
2951
+ }
2952
+ var alertsBlock = alertRows
2953
+ ? "<h3 class=\"wishlist-prefs__subhead\">Price-drop &amp; back-in-stock alerts</h3>" + alertRows
2954
+ : "";
2955
+
2956
+ var digestRows = "";
2957
+ for (var j = 0; j < digests.length; j += 1) {
2958
+ var d = digests[j];
2959
+ var enrolled = d.enrolled === true;
2960
+ digestRows +=
2961
+ "<form class=\"wishlist-prefs__row\" method=\"post\" action=\"/account/wishlist/digest\">" +
2962
+ "<input type=\"hidden\" name=\"schedule_slug\" value=\"" + esc(d.slug) + "\">" +
2963
+ "<input type=\"hidden\" name=\"on\" value=\"" + (enrolled ? "" : "1") + "\">" +
2964
+ "<span class=\"wishlist-prefs__label\">" + esc(d.label || d.slug) + "</span>" +
2965
+ "<span class=\"wishlist-prefs__state\">" + (enrolled ? "Subscribed" : "Not subscribed") + "</span>" +
2966
+ "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">" + (enrolled ? "Unsubscribe" : "Subscribe") + "</button>" +
2967
+ "</form>";
2968
+ }
2969
+ var digestBlock = digestRows
2970
+ ? "<h3 class=\"wishlist-prefs__subhead\">Periodic wishlist digest</h3>" + digestRows
2971
+ : "";
2972
+
2973
+ return "<section class=\"wishlist-prefs-panel\" aria-labelledby=\"wishlist-prefs-heading\">" +
2974
+ "<h2 id=\"wishlist-prefs-heading\" class=\"wishlist-prefs-panel__title\">Wishlist notifications</h2>" +
2975
+ "<p class=\"wishlist-prefs-panel__lede\">Choose how you'd like to hear about your saved items. We'll only email you if your store has email delivery configured.</p>" +
2976
+ notice +
2977
+ alertsBlock +
2978
+ digestBlock +
2979
+ "</section>";
2980
+ }
2981
+
2901
2982
  // Public, no-auth shared-wishlist page (`GET /wishlist/shared/:token`). It
2902
2983
  // renders ONLY the shared product cards (title, image, link to each PDP) —
2903
2984
  // the owner's identity, the per-entry private notes, and the owner customer
@@ -8602,6 +8683,7 @@ var ACCOUNT_LOGIN_PAGE =
8602
8683
  " <p id=\"login-message\" class=\"auth-form__message\"></p>\n" +
8603
8684
  " </form>\n" +
8604
8685
  " RAW_LOGIN_OAUTH\n" +
8686
+ " RAW_LOGIN_MAGIC\n" +
8605
8687
  " <p class=\"auth-card__alt\">New here? <a href=\"/account/register\">Create an account →</a></p>\n" +
8606
8688
  " </div>\n" +
8607
8689
  " RAW_LOGIN_SCRIPT\n" +
@@ -8610,6 +8692,7 @@ var ACCOUNT_LOGIN_PAGE =
8610
8692
  var LOGIN_ERROR_MESSAGES = {
8611
8693
  oauth: "We couldn't complete that sign-in. Please try again.",
8612
8694
  "email-conflict": "That email already has an account — sign in with your passkey instead.",
8695
+ link: "That sign-in link is invalid or has expired. Request a fresh one.",
8613
8696
  };
8614
8697
 
8615
8698
  function renderAccountLogin(opts) {
@@ -8630,8 +8713,12 @@ function renderAccountLogin(opts) {
8630
8713
  var errHtml = (opts.error && LOGIN_ERROR_MESSAGES[opts.error])
8631
8714
  ? "<p class=\"auth-form__message auth-form__message--err\">" + b.template.escapeHtml(LOGIN_ERROR_MESSAGES[opts.error]) + "</p>"
8632
8715
  : "";
8716
+ var magicHtml = opts.magic_link_enabled
8717
+ ? "<p class=\"auth-card__alt\"><a href=\"/account/login/link\">Email me a sign-in link instead →</a></p>"
8718
+ : "";
8633
8719
  var body = ACCOUNT_LOGIN_PAGE
8634
8720
  .replace("RAW_LOGIN_OAUTH", oauthHtml)
8721
+ .replace("RAW_LOGIN_MAGIC", magicHtml)
8635
8722
  .replace("RAW_LOGIN_ERROR", errHtml)
8636
8723
  // Login captcha is gated separately (CAPTCHA_GATE_LOGIN): the widget
8637
8724
  // renders only when the operator has a provider active AND opted login
@@ -8648,6 +8735,50 @@ function renderAccountLogin(opts) {
8648
8735
  });
8649
8736
  }
8650
8737
 
8738
+ // Magic-link sign-in — a passwordless entry for shoppers without a
8739
+ // passkey or a social login. The page is a single email field that POSTs
8740
+ // to /account/login/link. The response is always the same enumeration-
8741
+ // safe confirmation ("if an account exists, we've emailed a link")
8742
+ // regardless of whether the address resolves — no account-existence
8743
+ // oracle. Server-rendered, no client JS.
8744
+ var ACCOUNT_MAGIC_LINK_PAGE =
8745
+ "<section class=\"auth-page\">\n" +
8746
+ " <div class=\"auth-card\">\n" +
8747
+ " <p class=\"eyebrow\">Sign in by email</p>\n" +
8748
+ " <h1 class=\"auth-card__title\">Email me a sign-in link</h1>\n" +
8749
+ " <p class=\"auth-card__lede\">No passkey or social login? Enter your email and we'll send a single-use link that signs you in.</p>\n" +
8750
+ " RAW_MAGIC_LINK_NOTICE\n" +
8751
+ " <form method=\"post\" action=\"/account/login/link\" class=\"form-stack auth-form\">\n" +
8752
+ " <div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Email</span><input type=\"email\" name=\"email\" required autocomplete=\"email\" autofocus></label></div>\n" +
8753
+ " <div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary auth-form__submit\">Email me a link</button></div>\n" +
8754
+ " </form>\n" +
8755
+ " <p class=\"auth-card__alt\"><a href=\"/account/login\">← Back to sign in</a></p>\n" +
8756
+ " </div>\n" +
8757
+ "</section>\n";
8758
+
8759
+ function renderMagicLinkPage(opts) {
8760
+ opts = opts || {};
8761
+ var esc = b.template.escapeHtml;
8762
+ // The post-submit confirmation (sent=1) is the enumeration-safe message
8763
+ // — identical whether or not the address matched an account. The
8764
+ // unconfigured-mailer case carries its own honest notice.
8765
+ var noticeHtml = "";
8766
+ if (opts.sent) {
8767
+ noticeHtml = "<p class=\"form-notice form-notice--ok\" role=\"status\">If an account exists for that email, we've sent a sign-in link. Check your inbox.</p>";
8768
+ } else if (opts.unavailable) {
8769
+ noticeHtml = "<p class=\"auth-form__message auth-form__message--err\">Email sign-in isn't available on this store. Use a passkey or social login instead.</p>";
8770
+ }
8771
+ var body = ACCOUNT_MAGIC_LINK_PAGE.replace("RAW_MAGIC_LINK_NOTICE", noticeHtml);
8772
+ void esc;
8773
+ return _wrap({
8774
+ title: "Email sign-in",
8775
+ shop_name: opts.shop_name || "blamejs.shop",
8776
+ cart_count: opts.cart_count,
8777
+ theme_css: opts.theme_css,
8778
+ body: body,
8779
+ });
8780
+ }
8781
+
8651
8782
  var ACCOUNT_REGISTER_PAGE =
8652
8783
  "<section class=\"auth-page\">\n" +
8653
8784
  " <div class=\"auth-card\">\n" +
@@ -12626,6 +12757,7 @@ function mount(router, deps) {
12626
12757
  cart_count: cartCount,
12627
12758
  google_enabled: !!deps.oauthGoogle,
12628
12759
  apple_enabled: !!deps.oauthApple,
12760
+ magic_link_enabled: !!(deps.customerPortal && deps.customerPortalEmail),
12629
12761
  error: url && url.searchParams.get("error"),
12630
12762
  captcha_kind: captchaLoginOn ? captchaKind : null,
12631
12763
  captcha_public_key: captchaLoginOn ? captchaPubKey : null,
@@ -12645,6 +12777,105 @@ function mount(router, deps) {
12645
12777
  }));
12646
12778
  });
12647
12779
 
12780
+ // ---- magic-link sign-in (passwordless email entry) ------------------
12781
+ //
12782
+ // A minimal passwordless login for shoppers without a passkey or a
12783
+ // social login. Composes the customer-portal primitive: createSession
12784
+ // mints a single-use, hashed-at-rest token; the link is emailed; the
12785
+ // GET redemption verifies (single-use) and sets the sealed shop_auth
12786
+ // cookie. The whole surface mounts only when BOTH the portal primitive
12787
+ // AND a transactional mailer are wired — absent either, /account/login/
12788
+ // link renders an "unavailable" state and passkey / OAuth are unchanged.
12789
+ if (deps.customerPortal && deps.customerPortalEmail) {
12790
+ // GET — the email-entry form.
12791
+ router.get("/account/login/link", async function (req, res) {
12792
+ var cartCount = await _cartCountForReq(req);
12793
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
12794
+ _send(res, 200, renderMagicLinkPage({
12795
+ shop_name: shopName,
12796
+ cart_count: cartCount,
12797
+ sent: url ? url.searchParams.get("sent") === "1" : false,
12798
+ }));
12799
+ });
12800
+
12801
+ // POST — resolve the email to a customer, mint a portal session,
12802
+ // email the link. The response is ALWAYS the same enumeration-safe
12803
+ // confirmation (sent=1) regardless of whether the address matched —
12804
+ // no account-existence oracle. A bad-shaped email re-renders the
12805
+ // form. Every send is best-effort: a mailer failure still shows the
12806
+ // generic confirmation (the link simply doesn't arrive).
12807
+ router.post("/account/login/link", async function (req, res) {
12808
+ var body = req.body || {};
12809
+ var emailRaw = typeof body.email === "string" ? body.email : "";
12810
+ var customerId = null;
12811
+ // Resolve the customer by email hash. A malformed address (hashEmail
12812
+ // throws on bad shape) or a no-match both fall through to the
12813
+ // generic confirmation — no oracle.
12814
+ try {
12815
+ var hash = deps.customers.hashEmail(emailRaw);
12816
+ var cust = await deps.customers.byEmailHash(hash);
12817
+ if (cust && cust.id) customerId = cust.id;
12818
+ } catch (_e) { customerId = null; }
12819
+
12820
+ if (customerId) {
12821
+ try {
12822
+ var minted = await deps.customerPortal.createSession({
12823
+ customer_id: customerId,
12824
+ scope: "full",
12825
+ });
12826
+ // Build the absolute redemption link from this request's origin.
12827
+ var origin = "";
12828
+ try { origin = new URL(_requestUrls(req).canonical_url).origin; }
12829
+ catch (_e2) { origin = ""; }
12830
+ var linkUrl = origin + "/account/portal/" + encodeURIComponent(minted.plaintext_token);
12831
+ // The customer's plaintext address: the portal flow needs a
12832
+ // deliverable address. The customers store keeps only the hash,
12833
+ // so reuse the submitted address (the customer just typed it);
12834
+ // the email handle validates the address shape.
12835
+ await deps.customerPortalEmail.sendMagicLink({
12836
+ customer_email: emailRaw,
12837
+ link_url: linkUrl,
12838
+ });
12839
+ } catch (_e3) { /* drop-silent — generic confirmation regardless */ }
12840
+ }
12841
+ // 303 to the GET with the generic confirmation flag.
12842
+ res.status(303);
12843
+ res.setHeader && res.setHeader("location", "/account/login/link?sent=1");
12844
+ return res.end ? res.end() : res.send("");
12845
+ });
12846
+
12847
+ // GET — redeem the magic-link token. verifyToken is single-use (flips
12848
+ // the row to consumed) and re-checks expiry; on success set the sealed
12849
+ // shop_auth cookie + 303 to /account. An unknown / expired / already-
12850
+ // used token bounces to login with a soft error (no oracle on why).
12851
+ router.get("/account/portal/:token", async function (req, res) {
12852
+ var token = (req.params && req.params.token) || "";
12853
+ var rv = null;
12854
+ try { rv = await deps.customerPortal.verifyToken(token); }
12855
+ catch (e) {
12856
+ if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
12857
+ rv = null;
12858
+ }
12859
+ if (!rv || !rv.customer_id) {
12860
+ res.status(303);
12861
+ res.setHeader && res.setHeader("location", "/account/login?error=link");
12862
+ return res.end ? res.end() : res.send("");
12863
+ }
12864
+ // Adopt the guest cart into the now-authenticated account, mirroring
12865
+ // the OAuth / passkey login paths.
12866
+ var sid = _readSidCookie(req);
12867
+ if (sid) {
12868
+ try {
12869
+ var anonCart = await deps.cart.bySession(sid);
12870
+ if (anonCart) await deps.cart.setCustomer(anonCart.id, rv.customer_id);
12871
+ } catch (_e) { /* best-effort merge; sign-in itself succeeds */ }
12872
+ }
12873
+ _setAuthCookie(req, res, { customer_id: rv.customer_id, exp: Date.now() + b.constants.TIME.days(14) });
12874
+ res.status(303); res.setHeader && res.setHeader("location", "/account");
12875
+ return res.end ? res.end() : res.send("");
12876
+ });
12877
+ }
12878
+
12648
12879
  router.post("/account/passkey/register-begin", async function (req, res) {
12649
12880
  try {
12650
12881
  var body = _readJsonBody(req);
@@ -13754,15 +13985,152 @@ function mount(router, deps) {
13754
13985
  notice: wlUrl ? wlUrl.searchParams.get("share") : null,
13755
13986
  });
13756
13987
  }
13988
+ // Alert + digest opt-in panel — only when those primitives are
13989
+ // wired (a mailer-configured deploy). The current per-trigger
13990
+ // opt-out + per-schedule enrollment state drives each toggle's
13991
+ // checked/unchecked render. A reads failure degrades to no panel
13992
+ // rather than 500-ing the page.
13993
+ var prefsPanel = await _buildWishlistPrefsPanel(
13994
+ auth.customer_id,
13995
+ wlUrl ? wlUrl.searchParams.get("prefs") : null,
13996
+ );
13757
13997
  _send(res, 200, renderWishlist({
13758
13998
  items: items,
13759
13999
  notice: wlUrl ? wlUrl.searchParams.get("ok") : null,
14000
+ prefs_panel: prefsPanel,
13760
14001
  share_panel: sharePanel,
13761
14002
  shop_name: shopName,
13762
14003
  cart_count: cartCount,
13763
14004
  asset_prefix: deps.asset_prefix || "/assets/",
13764
14005
  }));
13765
14006
  });
14007
+
14008
+ // Build the alert + digest opt-in panel for a signed-in customer.
14009
+ // Reads the current per-trigger opt-out state (isUnsubscribedFrom
14010
+ // Trigger, now exported) for each scannable alert trigger, and the
14011
+ // active-enrollment state per live digest schedule. Each read is
14012
+ // best-effort: a missing table / read error drops that section
14013
+ // rather than throwing. Returns "" when neither primitive is wired.
14014
+ async function _buildWishlistPrefsPanel(customerId, notice) {
14015
+ var alerts = [];
14016
+ var digests = [];
14017
+ if (deps.wishlistAlerts) {
14018
+ var ALERT_TRIGGERS = [
14019
+ { trigger: "price_drop", label: "Email me when a saved item drops in price" },
14020
+ { trigger: "back_in_stock", label: "Email me when a saved item is back in stock" },
14021
+ ];
14022
+ for (var i = 0; i < ALERT_TRIGGERS.length; i += 1) {
14023
+ var t = ALERT_TRIGGERS[i];
14024
+ var unsub = true;
14025
+ try { unsub = await deps.wishlistAlerts.isUnsubscribedFromTrigger(customerId, t.trigger); }
14026
+ catch (_e) { unsub = false; }
14027
+ alerts.push({ trigger: t.trigger, label: t.label, subscribed: !unsub });
14028
+ }
14029
+ }
14030
+ if (deps.wishlistDigest) {
14031
+ var schedules = [];
14032
+ try { schedules = await deps.wishlistDigest.listSchedules({ active_only: true }); }
14033
+ catch (_e) { schedules = []; }
14034
+ var enrollments = [];
14035
+ try { enrollments = await deps.wishlistDigest.enrollmentsForCustomer(customerId); }
14036
+ catch (_e) { enrollments = []; }
14037
+ var activeBySlug = {};
14038
+ for (var k = 0; k < enrollments.length; k += 1) {
14039
+ var en = enrollments[k];
14040
+ if (en.status === "active") activeBySlug[en.schedule_slug] = en.id;
14041
+ }
14042
+ for (var s = 0; s < schedules.length; s += 1) {
14043
+ var sch = schedules[s];
14044
+ var label = (sch.frequency === "weekly" ? "Weekly" : "Monthly") +
14045
+ " digest (" + sch.slug + ")";
14046
+ digests.push({
14047
+ slug: sch.slug,
14048
+ label: label,
14049
+ enrolled: Object.prototype.hasOwnProperty.call(activeBySlug, sch.slug),
14050
+ enrollment_id: activeBySlug[sch.slug] || null,
14051
+ });
14052
+ }
14053
+ }
14054
+ return _wishlistPrefsPanel({ alerts: alerts, digests: digests, notice: notice });
14055
+ }
14056
+
14057
+ // POST /account/wishlist/alerts — flip a per-trigger alert opt-out.
14058
+ // Body { trigger, on }: a falsey `on` unsubscribes (insert opt-out
14059
+ // row); a truthy `on` re-subscribes (delete the opt-out row). Gated
14060
+ // on the session customer; CSRF rides the container form chokepoint.
14061
+ if (deps.wishlistAlerts) {
14062
+ router.post("/account/wishlist/alerts", async function (req, res) {
14063
+ var auth;
14064
+ try { auth = _currentCustomer(req); }
14065
+ catch (e) {
14066
+ if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
14067
+ throw e;
14068
+ }
14069
+ if (!auth) {
14070
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
14071
+ return res.end ? res.end() : res.send("");
14072
+ }
14073
+ var body = req.body || {};
14074
+ var trigger = body.trigger;
14075
+ var on = body.on === "1" || body.on === "on" || body.on === true;
14076
+ try {
14077
+ if (on) {
14078
+ await deps.wishlistAlerts.resubscribeToAlertKind({ customer_id: auth.customer_id, trigger: trigger });
14079
+ } else {
14080
+ await deps.wishlistAlerts.unsubscribeFromAlertKind({ customer_id: auth.customer_id, trigger: trigger });
14081
+ }
14082
+ } catch (e) {
14083
+ res.status(e instanceof TypeError ? 400 : 500);
14084
+ return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
14085
+ }
14086
+ res.status(303); res.setHeader && res.setHeader("location", "/account/wishlist?prefs=alerts");
14087
+ return res.end ? res.end() : res.send("");
14088
+ });
14089
+ }
14090
+
14091
+ // POST /account/wishlist/digest — enroll / pause a digest schedule.
14092
+ // Body { schedule_slug, on }: a truthy `on` enrolls; a falsey `on`
14093
+ // pauses the active enrollment for that slug (looked up via
14094
+ // enrollmentsForCustomer). Gated on the session customer.
14095
+ if (deps.wishlistDigest) {
14096
+ router.post("/account/wishlist/digest", async function (req, res) {
14097
+ var auth;
14098
+ try { auth = _currentCustomer(req); }
14099
+ catch (e) {
14100
+ if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
14101
+ throw e;
14102
+ }
14103
+ if (!auth) {
14104
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
14105
+ return res.end ? res.end() : res.send("");
14106
+ }
14107
+ var body = req.body || {};
14108
+ var scheduleSlug = body.schedule_slug;
14109
+ var on = body.on === "1" || body.on === "on" || body.on === true;
14110
+ try {
14111
+ if (on) {
14112
+ await deps.wishlistDigest.enrollCustomer({ customer_id: auth.customer_id, schedule_slug: scheduleSlug });
14113
+ } else {
14114
+ // Find the active enrollment for this slug and pause it.
14115
+ var enrollments = await deps.wishlistDigest.enrollmentsForCustomer(auth.customer_id);
14116
+ var target = null;
14117
+ for (var i = 0; i < enrollments.length; i += 1) {
14118
+ if (enrollments[i].schedule_slug === scheduleSlug && enrollments[i].status === "active") {
14119
+ target = enrollments[i]; break;
14120
+ }
14121
+ }
14122
+ if (target) {
14123
+ await deps.wishlistDigest.pauseEnrollment({ enrollment_id: target.id, reason: "customer opted out" });
14124
+ }
14125
+ }
14126
+ } catch (e) {
14127
+ res.status(e instanceof TypeError ? 400 : 500);
14128
+ return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
14129
+ }
14130
+ res.status(303); res.setHeader && res.setHeader("location", "/account/wishlist?prefs=digest");
14131
+ return res.end ? res.end() : res.send("");
14132
+ });
14133
+ }
13766
14134
  }
13767
14135
 
13768
14136
  // ---- wishlist sharing -----------------------------------------------
@@ -404,6 +404,26 @@ function create(opts) {
404
404
  return r.rows.length > 0;
405
405
  }
406
406
 
407
+ // Customer re-opt-in lever — the inverse of unsubscribeFromAlertKind.
408
+ // DELETEs the (customer_id, trigger) opt-out row so the dispatcher
409
+ // resumes firing that trigger for the customer. Idempotent: deleting a
410
+ // row that isn't there returns `{ status: "already-subscribed" }`.
411
+ // Same input validation as the unsubscribe verb (UUID-shape customer,
412
+ // enum trigger) so a bad caller fails with a typed error the route
413
+ // turns into HTTP 400.
414
+ async function resubscribeToAlertKind(input) {
415
+ if (!input || typeof input !== "object") {
416
+ throw new TypeError("wishlistAlerts.resubscribeToAlertKind: input object required");
417
+ }
418
+ var customerId = _uuid(input.customer_id, "customer_id");
419
+ var trigger = _trigger(input.trigger);
420
+ var r = await query(
421
+ "DELETE FROM wishlist_alert_unsubscribes WHERE customer_id = ?1 AND trigger = ?2",
422
+ [customerId, trigger],
423
+ );
424
+ return { status: Number(r.rowCount || 0) > 0 ? "resubscribed" : "already-subscribed" };
425
+ }
426
+
407
427
  // ---- ledger reads -----------------------------------------------------
408
428
 
409
429
  async function customerAlertHistory(input) {
@@ -830,6 +850,8 @@ function create(opts) {
830
850
  markAlertSent: markAlertSent,
831
851
  customerAlertHistory: customerAlertHistory,
832
852
  unsubscribeFromAlertKind: unsubscribeFromAlertKind,
853
+ resubscribeToAlertKind: resubscribeToAlertKind,
854
+ isUnsubscribedFromTrigger: isUnsubscribedFromTrigger,
833
855
  metricsForPolicy: metricsForPolicy,
834
856
  };
835
857
  }
@@ -622,6 +622,23 @@ function create(opts) {
622
622
  return _shapeSchedule(await _getScheduleBySlug(slug));
623
623
  }
624
624
 
625
+ // List the defined schedules, newest-first by created_at. With
626
+ // `active_only` (default) only non-archived schedules are returned —
627
+ // the customer-portal toggle renders a digest opt-in per LIVE schedule
628
+ // slug; the admin console passes `active_only: false` to show archived
629
+ // rows too. Pure read; no writes.
630
+ async function listSchedules(listOpts) {
631
+ listOpts = listOpts || {};
632
+ var activeOnly = listOpts.active_only !== false;
633
+ var sql = "SELECT * FROM wishlist_digest_schedules ";
634
+ if (activeOnly) sql += "WHERE archived_at IS NULL ";
635
+ sql += "ORDER BY created_at DESC, slug ASC LIMIT ?1";
636
+ var r = await query(sql, [MAX_LIMIT]);
637
+ var out = [];
638
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_shapeSchedule(r.rows[i]));
639
+ return out;
640
+ }
641
+
625
642
  // ---- enrollCustomer ----------------------------------------------
626
643
 
627
644
  async function enrollCustomer(input) {
@@ -1056,6 +1073,7 @@ function create(opts) {
1056
1073
 
1057
1074
  defineSchedule: defineSchedule,
1058
1075
  getSchedule: getSchedule,
1076
+ listSchedules: listSchedules,
1059
1077
  enrollCustomer: enrollCustomer,
1060
1078
  pauseEnrollment: pauseEnrollment,
1061
1079
  resumeEnrollment: resumeEnrollment,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.55",
3
+ "version": "0.3.56",
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": {