@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 +2 -0
- package/README.md +39 -2
- package/SECURITY.md +14 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/email.js +169 -0
- package/lib/storefront.js +368 -0
- package/lib/wishlist-alerts.js +22 -0
- package/lib/wishlist-digest.js +18 -0
- package/package.json +1 -1
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
|
|
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`
|
package/lib/asset-manifest.json
CHANGED
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 & 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 -----------------------------------------------
|
package/lib/wishlist-alerts.js
CHANGED
|
@@ -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
|
}
|
package/lib/wishlist-digest.js
CHANGED
|
@@ -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