@blamejs/blamejs-shop 0.1.27 → 0.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.1.x
10
10
 
11
+ - v0.1.29 (2026-05-26) — **Category navigation goes live — public browse pages for the storefront's category tree, with breadcrumbs and sub-category grids.** The category tree now has a customer-facing surface. A new /categories index lists the active top-level categories as a card grid, and a new /categories/<slug> page renders a single category: its title and optional description, a breadcrumb chain from the catalog root down to the current category, and a grid of the category's direct child sub-categories. Each page reads fresh against the active tree, so archived or unpublished categories drop out of every surface. An unknown, archived, or malformed slug returns a 404, and a category with no children renders a graceful empty state. The pages are linked from the site footer's Shop column. **Added:** *Category index at /categories* — A public page listing the active top-level categories as a card grid, each card carrying the category's title, optional description, and optional hero image, and linking into its browse page. Archived and unpublished categories are excluded. Linked from the site footer's Shop column. · *Category browse page at /categories/<slug>* — A public page for a single category showing its title and optional description, a breadcrumb chain from the catalog root down to the current category (each ancestor linked, the current category shown as plain text), an optional hero image, and a card grid of the category's direct child sub-categories. A category with no children renders a graceful empty state. An unknown, archived, or malformed slug returns a 404.
12
+
13
+ - v0.1.28 (2026-05-25) — **Refer-a-friend goes live — a shareable link, attributed signups, and conversions recorded when a referred friend's first order is paid.** The referrals ledger now has a customer-facing surface. A new /account/referrals page gives the signed-in customer a personal referral code, an absolute shareable link, the list of friends they've referred with each one's funnel stage, and an in-account top-referrer leaderboard. A new /r/<code> landing captures an inbound referral: it sets a short-lived sealed first-party cookie naming the code and sends the visitor home. When that visitor creates an account — by passkey, Google, or Apple — the new customer is attributed to the referrer, first-touch, with a self-referral guard so a code can never refer its own owner. When a referred customer's first order reaches paid, the order primitive marks that referral converted and bumps the referrer's count (the leaderboard key) — automatically, off the request path, and recorded once per order so a re-delivered payment webhook can't double-count. Issuing the actual reward (a gift card, store credit, or loyalty points — operator's choice) stays an explicit operator action through the referrals reward API, so the payout instrument and any fraud review are the operator's to control. **Added:** *Customer referrals page at /account/referrals* — A login-gated page showing the customer's referral code and an absolute shareable link (derived from the configured shop origin, falling back to the request host), the count of friends invited / joined / converted, the list of referred friends with each one's funnel stage (invited → visited → joined → converted) and dates, and an in-account top-referrer leaderboard. The friend list carries no personal data — referred emails are stored hash-only — and the leaderboard shows rank plus initials only, never names, emails, or account ids, with the signed-in customer's own row marked "You". Linked from the account dashboard as "Refer a friend". A customer with no code yet sees a one-click create control; a customer with one already gets the same page (creating is idempotent). · *Referral attribution landing at /r/<code>* — A public landing that captures an inbound referral: it sets a short-lived (30-day) sealed, first-party cookie naming the code, records the visit, and redirects the visitor to the home page. Attribution is first-touch — the first referral link a visitor follows wins, and a later link doesn't overwrite it. An unknown, malformed, or disabled code is silently ignored: the visitor still lands on the home page with no error and no cookie, so the route is never a code-existence oracle. · *Signups attributed to the referrer* — When a visitor who arrived through a referral link creates an account — passkey, Sign in with Google, or Sign in with Apple — the new customer is attributed to the referrer named in the referral cookie. A self-referral (the code belongs to the new account) is refused, a customer is attributed only once, and an existing customer signing in through Google or Apple is never attributed (only a genuinely new account is). Attribution is best-effort and never blocks account creation; the referral cookie is cleared after one signup so a stale link can't attribute a later one. · *Referral conversion recorded when a referred friend's first order is paid* — When a referred customer's first order reaches paid, the order primitive marks that referral converted and bumps the referrer's referral count (the leaderboard key). The update rides every paid path (card, wallet, PayPal, gift-card-covered), runs fire-and-forget off the request path, and is recorded once per order so a re-delivered payment webhook collapses onto a single conversion. Only orders carrying a customer account qualify; a guest order records nothing, and a customer's second and later orders don't re-trigger it. Issuing the payout itself — a gift card, store credit, or loyalty points — is a separate operator action through the referrals reward API, so the operator controls the reward instrument and can review before paying out.
14
+
11
15
  - v0.1.27 (2026-05-25) — **Loyalty rewards go live — points earned on purchase, viewed in the account, and redeemed at checkout.** The loyalty points ledger now has a customer-facing surface. A new /account/loyalty page shows the signed-in customer's balance, tier, and earn/redeem activity, explains how points are earned, and lists the reward catalog with a one-click redeem. Paid orders award points automatically: the order primitive fans its paid transition into the earn rules (per-dollar-spent + per-order), fire-and-forget and deduplicated so a re-delivered payment webhook never double-credits. At checkout, a signed-in customer can spend points for a storefront credit against the order total — capped at the order's value and the balance, recorded once, with the same atomic double-spend guard the gift-card credit uses. **Added:** *Customer rewards page at /account/loyalty* — A login-gated page showing the points balance, current tier, lifetime points, and the cash value of the balance; how points are earned (the active earn rules in plain language); the reward catalog with a Redeem control per reward (disabled when the balance is short); the customer's past redemptions; and the earn/redeem ledger, paginated newest-first. Linked from the account dashboard as "Rewards". A redeem that the customer can't complete (not enough points, reward unavailable, per-customer limit reached) re-renders the page with the reason rather than erroring; a malformed history cursor degrades to the first page. · *Points earned automatically on paid orders* — When an order reaches paid, the configured earn rules award points to the customer's balance — per dollar of order subtotal and a flat per-order bonus, whichever rules the operator has defined. The award rides the order's paid transition (so every paid path — card, PayPal, gift-card-covered, admin — earns), runs fire-and-forget off the request path, and is keyed to the order so a re-delivered payment webhook collapses onto a single award instead of double-crediting. Guest orders (no customer account) earn nothing, by design. · *Redeem points at checkout as a credit* — A signed-in customer with a balance sees a "Redeem loyalty points" field on the checkout form. The points are valued via the ledger's redemption ratio (100 points = $1 by default), capped at the order total so the credit never exceeds what the order is worth, and the credit reduces the amount due. The order still records the full grand total it owed; the points are debited once, after the order row exists, behind the same balance-guarded SQL decrement that prevents a double-spend. Points worth more than the order spend only what the granted credit is worth — the surplus stays in the balance. A request for more points than the balance, or from a guest cart, is refused with a clean re-prompt. A points credit stacks with a gift-card credit; together they can pay an order in full with no card charge.
12
16
 
13
17
  - v0.1.26 (2026-05-25) — **Fix the container build gate that rejected a manifest absent from the image.** The asset-integrity drift gate added last release checked both the container and edge copies of the manifest. The container image excludes the edge worker directory from its build context, so when the gate runs inside the image build it can't see the worker copy and read it as empty — failing the build. The gate now skips a manifest that isn't present in the current build context; its drift is still caught wherever the file exists (the full-tree CI run checks both). **Fixed:** *Asset-manifest drift gate no longer fails the container build* — `generate-asset-manifest.js --check` compared both manifest copies (`lib/` for the container, `worker/` for the edge) against a fresh rebuild. The container image's build context excludes the `worker/` tree, so inside the image the worker copy is absent and the gate read it as empty — reporting false drift and failing `node test/smoke.js`, which is a build gate, so the container image never built. The check now treats a manifest that isn't present in the current context as not-applicable and skips it (requiring at least one copy to be present), so the same drift detection still runs in CI and at the edge build where both files exist, without breaking the container image build.
package/README.md CHANGED
@@ -69,9 +69,11 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
69
69
  | **`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. |
70
70
  | **`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. |
71
71
  | **`lib/loyalty.js`** + **`lib/loyalty-earn-rules.js`** + **`lib/loyalty-redemption.js`** | Customer rewards. `loyalty` owns the points balance, lifetime total, tier (bronze → platinum on operator-tunable thresholds), and an audited transaction ledger. `loyalty-earn-rules` defines how points are minted (per-dollar-spent, per-order, signup, birthday, …) keyed to lifecycle events; `loyalty-redemption` is the reward catalog customers spend points against. Customers see all of it at `/account/loyalty` — balance + tier, the earn rules in plain language, the reward catalog with a one-click Redeem, past redemptions, and the paginated earn/redeem ledger (login-gated). Paid orders award points automatically: the order FSM's paid transition fans out to the earn rules fire-and-forget, deduped on the order id so a re-delivered payment webhook never double-credits. At checkout a signed-in customer can spend points for a credit against the order total — valued by the redemption ratio (100 points = $1 default), capped at the order's worth and the balance, debited once behind a balance-guarded SQL decrement, stacking with any gift-card credit; surplus points stay in the balance. |
72
+ | **`lib/referrals.js`** + **`lib/referral-leaderboard.js`** | Refer-a-friend with two-sided rewards. `referrals` issues each customer an 8-character code (32-glyph confusion-resistant alphabet via `b.crypto.generateBytes`) and tracks each referred friend through the funnel (invited → visited → joined → converted); referred emails are stored hash-only (`b.crypto.namespaceHash`), never plaintext. `referral-leaderboard` sits on top for top-referrer rankings + operator-tunable tier bonuses. Customers see all of it at `/account/referrals` — their code, an absolute shareable link, the friends they've referred (funnel stage + dates, no personal data), and an in-account leaderboard (rank + initials only). A public `/r/<code>` landing captures an inbound referral into a short-lived sealed first-party cookie (first-touch), which attributes the new customer to the referrer when they sign up by passkey / Google / Apple — self-referral and double-attribution guarded. When a referred customer's first order reaches paid, the order FSM's paid transition marks that referral converted and bumps the referrer's count fire-and-forget, deduped on the order id so a re-delivered webhook never double-counts; guest orders and a customer's later orders don't qualify. Issuing the payout itself (gift card / store credit / loyalty points) is an explicit operator action via the referrals reward API, so the reward instrument and any fraud review stay the operator's. |
72
73
  | **`lib/recently-viewed.js`** | Signed-in customer browse history. A product-page visit records the view server-side against the customer's account (drop-silent — never blocks the page); `/account/recently-viewed` lists them newest-first as a grid with a Clear-history control. De-duped + capped per customer, archived products drop out, login-gated. Guest/session history is opt-in (a client beacon) and not shipped — the lib's `forSession` + `merge` support it. |
73
74
  | **`lib/recommendations.js`** | Product-recommendation engine. Operator-curated override pins first (`setOverride` — "when viewing A, show B", kind-scoped + weight-ordered), then a signal-based fallback: co-purchase (products bought in the same orders), category-popular, and in-stock-random filler. `recommendForProduct` / `recommendForCart` / `recommendForCustomer` / `recommendForCategory` each return renderable picks (active + in-stock, source product excluded). The order confirmation page (`/orders/:id`) renders a "Customers also bought" rail from it — best-effort, anchored on the order's items, excluding what was just bought. |
74
75
  | **`lib/collections.js`** | Curated + smart product groupings. `GET /collections` lists the shop's active collections; `GET /collections/:slug` renders the grid — manual collections list hand-picked members, smart collections evaluate stored rules against the active catalog and apply the collection's sort strategy. Each product resolves fresh, so archived products drop out. Public, no sign-in; a bad or unknown slug is a 404 (never a 500). Linked from the footer on every page. |
76
+ | **`lib/category-navigation.js`** | Hierarchical category tree surfaced as public browse pages. `GET /categories` lists the active top-level categories as a card grid; `GET /categories/:slug` renders one category — its title and optional description, a breadcrumb chain from the catalog root down to the current category, an optional hero image, and a grid of the category's direct child sub-categories. Each page reads fresh against the active tree, so archived / unpublished categories drop out of every surface. Public, no sign-in; an unknown, archived, or malformed slug is a 404 (never a 500), and a category with no children renders a graceful empty state. Linked from the footer on every page. The tree itself (define / move / reorder / archive, with cycle defense bounded by `MAX_TREE_DEPTH`) is operator-managed through the primitive's write API. |
75
77
  | **`lib/subscriptions.js`** | Stripe-backed recurring billing — `subscription_plans` (interval / amount / trial) + `subscriptions` (mirrors Stripe's object byte-for-byte). `subscriptions.create` POSTs to Stripe via the payment dep, then persists the returned object locally. `handleStripeEvent` replays `customer.subscription.*` events into the local row so the shop has an authoritative view without round-tripping. Customers view + cancel their own subscriptions at `/account/subscriptions` (ownership-checked; cancel mounts when the payment handle is wired). |
76
78
  | **`lib/giftcards.js`** | Prepaid bearer gift cards. `issue({ amount_minor, currency })` generates a 16-char code (32-glyph alphabet, no ambiguous letters) via `b.crypto.generateBytes`, stores only its `namespaceHash` digest + a 4-char hint, and returns the plaintext code once. `balance(code)` / `lookup(code)` resolve a code to its live balance (constant-time hash compare); `redeem({ code, order_id, amount_minor })` decrements the balance with an atomic `balance >= amount` SQL guard so concurrent spends can't overdraw. Redeemed at checkout as a credit against the order grand total: the amount due drops by the applied balance (never below zero), the order still records the full total it owed, and the debit is recorded once per order — a card that fully covers the order is marked paid with no Stripe charge. Customers check a balance at `GET /gift-cards`; the page is not a code-existence oracle (unknown / malformed / expired all return the same generic not-found). |
77
79
  | **`lib/gift-card-ledger.js`** | Append-only credit / debit / expire history per gift card, with a denormalized `balance_after_minor` snapshot for O(1) balance reads. `credit` / `debit` / `expire` write one row each; `history(id)` paginates a card's transactions; `transactionsForOrder(id)` lists a card's movements for one order. The audit trail behind the admin gift-card ledger console; overdraft is refused at the primitive layer. |
@@ -106,6 +108,9 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
106
108
  - `migrations-d1/0022_loyalty.sql` — loyalty accounts (balance + lifetime + tier) + audited points transactions
107
109
  - `migrations-d1/0085_loyalty_redemptions.sql` — reward catalog + per-redemption records (FSM: active → consumed / expired / cancelled)
108
110
  - `migrations-d1/0163_loyalty_earn_rules.sql` — per-event earn rules + dedup-keyed award log
111
+ - `migrations-d1/0025_referrals.sql` — referral codes (one active per customer) + invitation funnel (hash-only referred email)
112
+ - `migrations-d1/0182_referral_leaderboard.sql` — referral tier thresholds (singleton config) + tiered-bonus payout log
113
+ - `migrations-d1/0201_category_navigation.sql` — hierarchical category tree (self-referential parent_slug, sibling position, soft-delete tombstone)
109
114
 
110
115
  ### Demo seed
111
116
 
package/SECURITY.md CHANGED
@@ -174,3 +174,18 @@ node -e "
174
174
  the order total and the live balance (a request for more than either
175
175
  is refused, not silently clamped to a windfall), and a guest cart
176
176
  with no account can't redeem at all.
177
+ - **Referral rewards are fraud-guarded — no self-referral, no
178
+ double-attribution, no double-reward.** The `/r/<code>` landing only
179
+ sets the attribution cookie for a live, active code and never reveals
180
+ whether a guessed code exists (unknown / malformed / disabled all
181
+ redirect home identically). A new account is attributed to the
182
+ referrer named in the sealed cookie, with a self-referral guard so a
183
+ code can never refer its own owner, and each customer is attributed
184
+ only once (first-touch); an existing customer signing back in is never
185
+ re-attributed. The reward fires only when a referred customer's first
186
+ order reaches paid, rides the order's paid transition fire-and-forget,
187
+ and is recorded once per order so a re-delivered payment webhook can't
188
+ double-reward — guest orders and later orders don't qualify. Referred
189
+ emails are stored hash-only (`b.crypto.namespaceHash`), and the
190
+ account leaderboard exposes rank plus initials only, never an email or
191
+ account id.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.27",
2
+ "version": "0.1.29",
3
3
  "assets": {
4
4
  "css/admin.css": "sha384-rvjL1QySBgNJGEUDusbbMCkL3fsjmzcwUvTp9pljo/0Nn/lErxq2AbY8EYvDF9Ch",
5
5
  "css/main.css": "sha384-qBY97FWZDGOploZRppwrDi0A/+R4R3iCFtyS1dfrAlqHNEPluPmY0R3HB9cJntyV",
package/lib/order.js CHANGED
@@ -150,6 +150,18 @@ function create(opts) {
150
150
  // transition has already persisted, so a loyalty read/write must not
151
151
  // add latency to (or fail) the order transition.
152
152
  var loyaltyEarnRules = opts.loyaltyEarnRules || null;
153
+ // Optional referrals dispatcher — when present, an order reaching
154
+ // `paid` records the buyer's first qualifying purchase against any
155
+ // referral invitation pinned to their account, which completes the
156
+ // referrer's funnel (bumps referrals_count → the leaderboard). Opt-in
157
+ // like `webhooks` / `loyaltyEarnRules` so callers and the test suite
158
+ // run without standing up a referrals primitive. `trackPurchase` is
159
+ // idempotent (first_purchase_at is set only once) and a no-op when the
160
+ // buyer was never referred, so a re-delivered mark_paid can't double-
161
+ // reward and an organic purchase is silently skipped. The award runs
162
+ // fire-and-forget on the same detached-promise discipline as the
163
+ // loyalty fan-out: the transition has already persisted.
164
+ var referrals = opts.referrals || null;
153
165
  // Pagination cursors for listForCustomer are HMAC-tagged via
154
166
  // b.pagination so an operator can't hand-craft one to skip past a
155
167
  // hidden order or replay across deployments. The secret defaults
@@ -348,6 +360,24 @@ function create(opts) {
348
360
  });
349
361
  }).catch(function () { /* drop-silent — loyalty ledger holds its own audit trail */ });
350
362
  }
363
+ // Referral reward-on-first-order fan-out — fire-and-forget, same
364
+ // discipline as the loyalty block. Only on the paid transition and
365
+ // only for an order carrying a customer_id (a guest order has no
366
+ // account to attribute). trackPurchase pins the buyer's FIRST
367
+ // qualifying purchase to the invitation that referred them and
368
+ // completes the referrer's funnel; it is idempotent (first_purchase_at
369
+ // is written once) so a re-delivered mark_paid never double-rewards,
370
+ // and it is a no-op for a buyer who was never referred. The promise
371
+ // is detached so a referrals failure lives in its own tables, never
372
+ // as an unhandledRejection and never on the transition's latency.
373
+ if (referrals && typeof referrals.trackPurchase === "function"
374
+ && result.to === "paid" && refreshed && refreshed.customer_id) {
375
+ var _refCustomer = refreshed.customer_id;
376
+ var _refOrderId = refreshed.id;
377
+ Promise.resolve().then(function () {
378
+ return referrals.trackPurchase({ customer_id: _refCustomer, order_id: _refOrderId });
379
+ }).catch(function () { /* drop-silent — referral funnel holds its own audit trail */ });
380
+ }
351
381
  return refreshed;
352
382
  },
353
383
 
package/lib/referrals.js CHANGED
@@ -602,6 +602,50 @@ function create(opts) {
602
602
  };
603
603
  },
604
604
 
605
+ // Per-friend funnel rows for one referrer, across every code they
606
+ // hold, newest invitation first. Powers the account page's "friends
607
+ // you've referred" list. No PII crosses this boundary — the referred
608
+ // email is stored hash-only, so a row carries only the funnel
609
+ // timestamps + the reward_status, never an address. `signed_up_at`
610
+ // / `first_purchase_at` being non-null is what the surface renders
611
+ // as "joined" / "converted"; `pending` is invited-but-not-converted.
612
+ invitationsForReferrer: async function (customerId) {
613
+ var id = _uuid(customerId, "customer_id");
614
+ var r = await query(
615
+ "SELECT i.id AS id, i.referral_code_id AS referral_code_id, " +
616
+ "i.invited_at AS invited_at, i.visited_at AS visited_at, " +
617
+ "i.signed_up_at AS signed_up_at, i.first_purchase_at AS first_purchase_at, " +
618
+ "i.reward_status AS reward_status " +
619
+ "FROM referral_invitations i " +
620
+ "JOIN referral_codes c ON c.id = i.referral_code_id " +
621
+ "WHERE c.referrer_customer_id = ?1 " +
622
+ "ORDER BY i.invited_at DESC, i.id DESC",
623
+ [id],
624
+ );
625
+ return r.rows.map(function (row) {
626
+ var visited = row.visited_at != null;
627
+ var signedUp = row.signed_up_at != null;
628
+ var converted = row.first_purchase_at != null;
629
+ // Coarse funnel stage the surface renders. Converted wins over
630
+ // signed-up wins over visited wins over invited — the furthest
631
+ // the friend reached.
632
+ var stage = converted ? "converted"
633
+ : signedUp ? "signed-up"
634
+ : visited ? "visited"
635
+ : "pending";
636
+ return {
637
+ id: row.id,
638
+ referral_code_id: row.referral_code_id,
639
+ invited_at: Number(row.invited_at || 0),
640
+ visited_at: row.visited_at != null ? Number(row.visited_at) : null,
641
+ signed_up_at: row.signed_up_at != null ? Number(row.signed_up_at) : null,
642
+ first_purchase_at: row.first_purchase_at != null ? Number(row.first_purchase_at) : null,
643
+ reward_status: row.reward_status,
644
+ stage: stage,
645
+ };
646
+ });
647
+ },
648
+
605
649
  // Top-N referrers by completed referrals (referrals_count is the
606
650
  // funnel-complete counter; signups without a first purchase don't
607
651
  // count). Defaults to 10; capped at 100 so an operator can't
package/lib/storefront.js CHANGED
@@ -162,6 +162,7 @@ var LAYOUT =
162
162
  " <ul>\n" +
163
163
  " <li><a href=\"/\">All products</a></li>\n" +
164
164
  " <li><a href=\"/collections\">Collections</a></li>\n" +
165
+ " <li><a href=\"/categories\">Categories</a></li>\n" +
165
166
  " <li><a href=\"/?sort=new\">New arrivals</a></li>\n" +
166
167
  " <li><a href=\"/?sort=sale\">On sale</a></li>\n" +
167
168
  " <li><a href=\"/cart\">Cart</a></li>\n" +
@@ -1265,6 +1266,123 @@ function renderCollection(opts) {
1265
1266
  });
1266
1267
  }
1267
1268
 
1269
+ // Storefront category index — the top-level category tree. Each card
1270
+ // links into a category browse page. Reuses the collection-index card
1271
+ // shell (same visual contract) so no new CSS ships. `opts.categories`
1272
+ // is the hydrated [{ slug, title, description, hero_image_url }] list of
1273
+ // active top-level categories (archived rows are dropped by the lib).
1274
+ function renderCategoryIndex(opts) {
1275
+ var esc = b.template.escapeHtml;
1276
+ var cats = opts.categories || [];
1277
+ var cardsHtml = "";
1278
+ for (var i = 0; i < cats.length; i += 1) {
1279
+ var c = cats[i];
1280
+ var media = c.hero_image_url
1281
+ ? "<figure class=\"collection-index-card__media\"><img src=\"" + esc(_categoryHeroSrc(c.hero_image_url, opts.asset_prefix)) + "\" alt=\"" + esc(c.title) + "\" loading=\"lazy\"></figure>"
1282
+ : "<figure class=\"collection-index-card__media collection-index-card__media--empty\" aria-hidden=\"true\"></figure>";
1283
+ cardsHtml +=
1284
+ "<a class=\"collection-index-card\" href=\"/categories/" + esc(c.slug) + "\">" +
1285
+ media +
1286
+ "<div class=\"collection-index-card__meta\">" +
1287
+ "<h2 class=\"collection-index-card__title\">" + esc(c.title) + "</h2>" +
1288
+ (c.description ? "<p class=\"collection-index-card__desc\">" + esc(c.description) + "</p>" : "") +
1289
+ "</div>" +
1290
+ "</a>";
1291
+ }
1292
+ var inner = cardsHtml
1293
+ ? "<div class=\"collection-index-grid\">" + cardsHtml + "</div>"
1294
+ : "<p class=\"collection-empty\">No categories yet.</p>";
1295
+ var body =
1296
+ "<section class=\"collection-index\">" +
1297
+ "<header class=\"section-head\"><p class=\"eyebrow\">Browse</p>" +
1298
+ "<h1 class=\"section-head__title\">Categories</h1></header>" +
1299
+ inner +
1300
+ "</section>";
1301
+ return _wrap({
1302
+ title: "Categories", shop_name: opts.shop_name || "blamejs.shop",
1303
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count, theme_css: opts.theme_css, body: body,
1304
+ });
1305
+ }
1306
+
1307
+ // A single category's page — breadcrumb (root -> current), title,
1308
+ // optional description + hero, and a grid of the category's direct
1309
+ // child sub-categories. `opts.breadcrumbs` is the root->current chain
1310
+ // (the last entry is the current category, rendered as plain text);
1311
+ // `opts.children` is the hydrated direct-child list (empty -> graceful
1312
+ // empty state). Reuses the collection-page + collection-index card
1313
+ // shells so no new CSS ships.
1314
+ function renderCategory(opts) {
1315
+ var esc = b.template.escapeHtml;
1316
+ var cat = opts.category;
1317
+ var crumbs = opts.breadcrumbs || [];
1318
+ var children = opts.children || [];
1319
+
1320
+ var crumbHtml = "<li><a href=\"/\">Shop</a></li><li><a href=\"/categories\">Categories</a></li>";
1321
+ for (var ci = 0; ci < crumbs.length; ci += 1) {
1322
+ var bc = crumbs[ci];
1323
+ if (ci === crumbs.length - 1) {
1324
+ crumbHtml += "<li aria-current=\"page\">" + esc(bc.title) + "</li>";
1325
+ } else {
1326
+ crumbHtml += "<li><a href=\"/categories/" + esc(bc.slug) + "\">" + esc(bc.title) + "</a></li>";
1327
+ }
1328
+ }
1329
+
1330
+ // Hero reuses the collection-index card media shell (aspect-ratio +
1331
+ // object-fit cover) so no new CSS ships; it's a standalone figure
1332
+ // rather than a card link here.
1333
+ var hero = cat.hero_image_url
1334
+ ? "<figure class=\"collection-index-card__media\"><img src=\"" + esc(_categoryHeroSrc(cat.hero_image_url, opts.asset_prefix)) + "\" alt=\"" + esc(cat.title) + "\"></figure>"
1335
+ : "";
1336
+
1337
+ var cardsHtml = "";
1338
+ for (var i = 0; i < children.length; i += 1) {
1339
+ var ch = children[i];
1340
+ var media = ch.hero_image_url
1341
+ ? "<figure class=\"collection-index-card__media\"><img src=\"" + esc(_categoryHeroSrc(ch.hero_image_url, opts.asset_prefix)) + "\" alt=\"" + esc(ch.title) + "\" loading=\"lazy\"></figure>"
1342
+ : "<figure class=\"collection-index-card__media collection-index-card__media--empty\" aria-hidden=\"true\"></figure>";
1343
+ cardsHtml +=
1344
+ "<a class=\"collection-index-card\" href=\"/categories/" + esc(ch.slug) + "\">" +
1345
+ media +
1346
+ "<div class=\"collection-index-card__meta\">" +
1347
+ "<h2 class=\"collection-index-card__title\">" + esc(ch.title) + "</h2>" +
1348
+ (ch.description ? "<p class=\"collection-index-card__desc\">" + esc(ch.description) + "</p>" : "") +
1349
+ "</div>" +
1350
+ "</a>";
1351
+ }
1352
+ var grid = cardsHtml
1353
+ ? "<div class=\"collection-index-grid\">" + cardsHtml + "</div>"
1354
+ : "<p class=\"collection-empty\">No sub-categories here yet.</p>";
1355
+
1356
+ var body =
1357
+ "<section class=\"collection-page\">" +
1358
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
1359
+ crumbHtml +
1360
+ "</ol></nav>" +
1361
+ "<header class=\"collection-page__head\">" +
1362
+ "<h1 class=\"collection-page__title\">" + esc(cat.title) + "</h1>" +
1363
+ (cat.description ? "<p class=\"collection-page__desc\">" + esc(cat.description) + "</p>" : "") +
1364
+ "</header>" +
1365
+ hero +
1366
+ grid +
1367
+ "</section>";
1368
+ return _wrap({
1369
+ title: cat.title, shop_name: opts.shop_name || "blamejs.shop",
1370
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count, theme_css: opts.theme_css, body: body,
1371
+ });
1372
+ }
1373
+
1374
+ // Resolve a category hero_image_url into a renderable src. The lib gates
1375
+ // hero_image_url to https:// OR a /-rooted absolute path at write time,
1376
+ // so an absolute https URL or a /-rooted path is used as-is; any other
1377
+ // value (a bare R2 key) is prefixed with the card asset prefix the same
1378
+ // way collection hero media resolves.
1379
+ function _categoryHeroSrc(url, assetPrefix) {
1380
+ if (typeof url !== "string" || !url.length) return "";
1381
+ if (url.charCodeAt(0) === 47 /* "/" */) return url;
1382
+ if (/^https:\/\//i.test(url)) return url;
1383
+ return (assetPrefix || "/assets/") + url;
1384
+ }
1385
+
1268
1386
  // Account "Recently viewed" page — a newest-first grid of products the
1269
1387
  // signed-in customer has opened, reusing the standard product card.
1270
1388
  // `opts.products` is a resolved [{ slug, title, price, image_url,
@@ -1593,6 +1711,139 @@ function renderLoyalty(opts) {
1593
1711
  });
1594
1712
  }
1595
1713
 
1714
+ // Human-readable funnel stage for a referred friend. The referred
1715
+ // email is stored hash-only, so a row never carries an address — the
1716
+ // surface shows the stage + dates only.
1717
+ var REFERRAL_STAGE_LABELS = {
1718
+ pending: "Invited",
1719
+ visited: "Visited",
1720
+ "signed-up": "Joined",
1721
+ converted: "Converted",
1722
+ };
1723
+
1724
+ // Initials for a leaderboard row — first letter of each whitespace-
1725
+ // separated word in the display name, up to two, uppercased. Falls back
1726
+ // to a neutral glyph when the name is empty / whitespace. NEVER renders
1727
+ // the full name, the email (which is hash-only anyway), or the customer
1728
+ // id — the public-ish leaderboard surface exposes rank + initials only.
1729
+ function _referralInitials(displayName) {
1730
+ var s = String(displayName == null ? "" : displayName).trim();
1731
+ if (!s) return "–";
1732
+ var words = s.split(/\s+/);
1733
+ var out = "";
1734
+ for (var i = 0; i < words.length && out.length < 2; i += 1) {
1735
+ var w = words[i];
1736
+ if (w.length) out += w.charAt(0);
1737
+ }
1738
+ return out.toUpperCase() || "–";
1739
+ }
1740
+
1741
+ // The signed-in customer's referral surface: their personal code +
1742
+ // shareable link, the friends they've referred (funnel stage + dates,
1743
+ // no PII), the reward funnel summary, and an in-account top-referrer
1744
+ // leaderboard (rank + initials only — never names/emails/ids). Reuses
1745
+ // the account/returns layout classes — no new CSS.
1746
+ function renderReferrals(opts) {
1747
+ opts = opts || {};
1748
+ var esc = b.template.escapeHtml;
1749
+
1750
+ var notice = opts.notice
1751
+ ? "<p class=\"form-notice" + (opts.notice_kind === "error" ? " form-notice--error" : "") + "\" role=\"alert\">" +
1752
+ esc(String(opts.notice)) + "</p>"
1753
+ : "";
1754
+
1755
+ // Code + shareable link. `code`/`link` are null until the customer
1756
+ // mints one (the empty state offers a Create-code button).
1757
+ var code = opts.code || null;
1758
+ var link = opts.link || null;
1759
+ var codeSection;
1760
+ if (code && link) {
1761
+ codeSection =
1762
+ "<dl class=\"account-dash__stats\">" +
1763
+ "<div><dt>Your referral code</dt><dd><code>" + esc(String(code)) + "</code></dd></div>" +
1764
+ "<div><dt>Friends converted</dt><dd>" + esc(String(Number(opts.completed_referrals) || 0)) + "</dd></div>" +
1765
+ "<div><dt>Friends joined</dt><dd>" + esc(String(Number(opts.invitations_signed_up) || 0)) + "</dd></div>" +
1766
+ "<div><dt>Friends invited</dt><dd>" + esc(String(Number(opts.invitations_total) || 0)) + "</dd></div>" +
1767
+ "</dl>" +
1768
+ "<h2 class=\"pdp__variants-title\">Share your link</h2>" +
1769
+ "<p class=\"return-card__meta\">Share this link with friends. Anyone who signs up through it and places their first order is counted here as your referral.</p>" +
1770
+ "<p><a class=\"btn-secondary\" href=\"" + esc(String(link)) + "\">" + esc(String(link)) + "</a></p>";
1771
+ } else {
1772
+ codeSection =
1773
+ "<p class=\"return-empty\">You don't have a referral code yet. Create one to start inviting friends.</p>" +
1774
+ "<form method=\"post\" action=\"/account/referrals/code\">" +
1775
+ "<div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary\">Create my referral code</button></div>" +
1776
+ "</form>";
1777
+ }
1778
+
1779
+ // Friends referred — funnel stage + dates, no identity.
1780
+ var friends = opts.invitations || [];
1781
+ var friendsSection;
1782
+ if (friends.length) {
1783
+ var items = "";
1784
+ for (var i = 0; i < friends.length; i += 1) {
1785
+ var f = friends[i];
1786
+ var stageLabel = REFERRAL_STAGE_LABELS[f.stage] || f.stage || "Invited";
1787
+ var when = f.invited_at ? new Date(Number(f.invited_at)).toISOString().slice(0, 10) : "";
1788
+ var converted = f.first_purchase_at
1789
+ ? new Date(Number(f.first_purchase_at)).toISOString().slice(0, 10)
1790
+ : "";
1791
+ items +=
1792
+ "<li class=\"return-card\"><div class=\"return-card__head\">" +
1793
+ "<span class=\"return-card__rma\">Friend " + esc(String(i + 1)) + "</span>" +
1794
+ "<span class=\"pdp__badge referral-stage--" + esc(String(f.stage || "pending")) + "\">" + esc(stageLabel) + "</span>" +
1795
+ "</div>" +
1796
+ "<p class=\"return-card__meta\">" +
1797
+ (when ? "Invited <time datetime=\"" + esc(when) + "\">" + esc(when) + "</time>" : "") +
1798
+ (converted ? " &middot; converted <time datetime=\"" + esc(converted) + "\">" + esc(converted) + "</time>" : "") +
1799
+ "</p></li>";
1800
+ }
1801
+ friendsSection = "<h2 class=\"pdp__variants-title\">Friends you've referred</h2><ul class=\"return-list\">" + items + "</ul>";
1802
+ } else {
1803
+ friendsSection = "<h2 class=\"pdp__variants-title\">Friends you've referred</h2>" +
1804
+ "<p class=\"return-empty\">No referrals yet. Share your link above to get started.</p>";
1805
+ }
1806
+
1807
+ // In-account leaderboard — rank + initials only. The signed-in
1808
+ // customer's own row is marked "You".
1809
+ var board = opts.leaderboard || [];
1810
+ var boardSection = "";
1811
+ if (board.length) {
1812
+ var rows = "";
1813
+ for (var j = 0; j < board.length; j += 1) {
1814
+ var entry = board[j];
1815
+ var who = entry.is_you ? "You" : _referralInitials(entry.display_name);
1816
+ rows +=
1817
+ "<li class=\"return-card\"><div class=\"return-card__head\">" +
1818
+ "<span class=\"return-card__rma\">#" + esc(String(j + 1)) + " " + esc(who) + "</span>" +
1819
+ "<span class=\"pdp__badge\">" + esc(String(Number(entry.completed_referrals) || 0)) + " referred</span>" +
1820
+ "</div></li>";
1821
+ }
1822
+ boardSection = "<h2 class=\"pdp__variants-title\">Top referrers</h2><ul class=\"return-list\">" + rows + "</ul>";
1823
+ }
1824
+
1825
+ var body =
1826
+ "<section class=\"account-returns\">" +
1827
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
1828
+ "<li><a href=\"/account\">Account</a></li>" +
1829
+ "<li aria-current=\"page\">Refer a friend</li>" +
1830
+ "</ol></nav>" +
1831
+ "<h1 class=\"account-returns__title\">Refer a friend</h1>" +
1832
+ notice +
1833
+ codeSection +
1834
+ friendsSection +
1835
+ boardSection +
1836
+ "</section>";
1837
+
1838
+ return _wrap({
1839
+ title: "Refer a friend",
1840
+ shop_name: opts.shop_name || "blamejs.shop",
1841
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
1842
+ theme_css: opts.theme_css,
1843
+ body: body,
1844
+ });
1845
+ }
1846
+
1596
1847
  // Subscription status pill — mirrors `_returnStatusBadge`. The status
1597
1848
  // string is one of Stripe's enum values (active, trialing, past_due,
1598
1849
  // canceled, …), surfaced as a CSS-classed badge the theme can style.
@@ -2617,6 +2868,14 @@ var PAY_COOKIE_NAME = "shop_pay";
2617
2868
  // SameSite=Lax so it survives the provider's top-level GET redirect back.
2618
2869
  var OAUTH_COOKIE_NAME = "shop_oauth";
2619
2870
 
2871
+ // Short-lived sealed cookie naming the referral code an inbound visitor
2872
+ // arrived through (set by the /r/:code landing). Read at account-creation
2873
+ // to attribute the new customer to the referrer. Path "/" so it survives
2874
+ // the visitor's navigation from the landing to the register / sign-in
2875
+ // flow; SameSite=Lax so a top-level GET from a shared link carries it.
2876
+ // Sealed so it can't be forged to mis-attribute a signup.
2877
+ var REFERRAL_COOKIE_NAME = "shop_ref";
2878
+
2620
2879
  // Shape of a valid session id — mirrors cart.js's SESSION_ID_RE.
2621
2880
  var SID_SHAPE_RE = /^[A-Za-z0-9_-]{16,64}$/;
2622
2881
 
@@ -2686,6 +2945,22 @@ function _readChallengeEnv(req) {
2686
2945
  try { return JSON.parse(raw); } catch (_e) { return null; }
2687
2946
  }
2688
2947
 
2948
+ // Referral-attribution cookie. Carries the code the visitor arrived
2949
+ // through + when it was set, sealed so it can't be forged. Path "/" so
2950
+ // it survives navigation from the landing through register / sign-in.
2951
+ function _setReferralCookie(res, env) {
2952
+ var T = b.constants.TIME;
2953
+ _cookieJar().writeSealed(res, REFERRAL_COOKIE_NAME, JSON.stringify(env), { expires: new Date(Date.now() + T.days(30)) });
2954
+ }
2955
+ function _clearReferralCookie(res) {
2956
+ _cookieJar().clear(res, REFERRAL_COOKIE_NAME);
2957
+ }
2958
+ function _readReferralEnv(req) {
2959
+ var raw = _cookieJar().readSealed(req, REFERRAL_COOKIE_NAME);
2960
+ if (raw === null) return null;
2961
+ try { return JSON.parse(raw); } catch (_e) { return null; }
2962
+ }
2963
+
2689
2964
  // ---- account-page renderers --------------------------------------------
2690
2965
 
2691
2966
  var ACCOUNT_LOGIN_PAGE =
@@ -2785,6 +3060,7 @@ var ACCOUNT_DASH_PAGE =
2785
3060
  " <a class=\"btn-secondary\" href=\"/account/addresses\">Addresses</a>\n" +
2786
3061
  " <a class=\"btn-secondary\" href=\"/account/returns\">Returns</a>\n" +
2787
3062
  " <a class=\"btn-secondary\" href=\"/account/loyalty\">Rewards</a>\n" +
3063
+ " <a class=\"btn-secondary\" href=\"/account/referrals\">Refer a friend</a>\n" +
2788
3064
  " <a class=\"btn-secondary\" href=\"/account/subscriptions\">Subscriptions</a>\n" +
2789
3065
  " <form method=\"post\" action=\"/account/logout\"><button type=\"submit\" class=\"btn-ghost\">Sign out</button></form>\n" +
2790
3066
  " </div>\n" +
@@ -2938,6 +3214,23 @@ function mount(router, deps) {
2938
3214
  return lines.length;
2939
3215
  }
2940
3216
 
3217
+ // Absolute shareable referral link for a code. Prefers the operator's
3218
+ // configured origin (deps.shop_origin / SHOP_ORIGIN) so the link is
3219
+ // stable across the edge/container split; falls back to the request's
3220
+ // Host header when no origin is configured (dev / single-host deploys).
3221
+ // The /r/<code> landing is container-served (not an edge route), so it
3222
+ // always reaches this primitive's attribution handler.
3223
+ function _referralLink(req, code) {
3224
+ var origin = deps.shop_origin
3225
+ ? String(deps.shop_origin).replace(/\/$/, "")
3226
+ : null;
3227
+ if (!origin) {
3228
+ var host = (req && req.headers && (req.headers.host || req.headers.Host)) || "";
3229
+ origin = host ? ("https://" + host) : "";
3230
+ }
3231
+ return origin + "/r/" + encodeURIComponent(code);
3232
+ }
3233
+
2941
3234
  // The signed-in customer's sealed-cookie envelope, or null. Shared by
2942
3235
  // the PDP view recorder (mounted outside the `if (deps.customers)`
2943
3236
  // block) and the account routes inside it, so there's one auth-cookie
@@ -3181,6 +3474,60 @@ function mount(router, deps) {
3181
3474
  });
3182
3475
  }
3183
3476
 
3477
+ // Category navigation — the hierarchical category tree surfaced as
3478
+ // public browse pages. The index lists the top-level categories; each
3479
+ // category page renders its breadcrumb chain + direct child sub-
3480
+ // categories. Mounted when the categoryNavigation primitive is wired.
3481
+ if (deps.categoryNavigation) {
3482
+ router.get("/categories", async function (req, res) {
3483
+ // Top-level categories only (parent_slug omitted). The lib drops
3484
+ // archived rows from every read, so the index is fresh against the
3485
+ // active tree.
3486
+ var cats = await deps.categoryNavigation.categoriesByParent({});
3487
+ var active = [];
3488
+ for (var i = 0; i < cats.length; i += 1) {
3489
+ if (cats[i].active) active.push(cats[i]);
3490
+ }
3491
+ var cartCount = await _cartCountForReq(req);
3492
+ _send(res, 200, renderCategoryIndex({
3493
+ categories: active, shop_name: shopName, cart_count: cartCount, asset_prefix: _cardAssetPrefix,
3494
+ }));
3495
+ });
3496
+
3497
+ router.get("/categories/:slug", async function (req, res) {
3498
+ var slug = req.params && req.params.slug;
3499
+ // getCategory() / breadcrumbsFor() / categoriesByParent() throw a
3500
+ // TypeError on a malformed slug shape (the primitive validates the
3501
+ // slug regex). A bad path segment, an unknown slug, or an archived
3502
+ // category is a 404, not a 500 — this is a defensive request-shape
3503
+ // reader.
3504
+ var cat, crumbs, children;
3505
+ try {
3506
+ cat = slug ? await deps.categoryNavigation.getCategory(slug) : null;
3507
+ if (!cat || !cat.active) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
3508
+ crumbs = await deps.categoryNavigation.breadcrumbsFor({ slug: slug });
3509
+ children = await deps.categoryNavigation.categoriesByParent({ parent_slug: slug });
3510
+ } catch (e) {
3511
+ if (e instanceof TypeError) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
3512
+ // A CATEGORY_NOT_FOUND from breadcrumbsFor / categoriesByParent
3513
+ // (e.g. the row was archived between reads) is also a 404.
3514
+ if (e && e.code === "CATEGORY_NOT_FOUND") return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
3515
+ throw e;
3516
+ }
3517
+ // Only surface active sub-categories (the lib already drops archived
3518
+ // rows; this also hides operator-unpublished ones).
3519
+ var activeChildren = [];
3520
+ for (var i = 0; i < children.length; i += 1) {
3521
+ if (children[i].active) activeChildren.push(children[i]);
3522
+ }
3523
+ var cartCount = await _cartCountForReq(req);
3524
+ _send(res, 200, renderCategory({
3525
+ category: cat, breadcrumbs: crumbs, children: activeChildren,
3526
+ shop_name: shopName, cart_count: cartCount, asset_prefix: _cardAssetPrefix,
3527
+ }));
3528
+ });
3529
+ }
3530
+
3184
3531
  router.get("/cart", async function (req, res) {
3185
3532
  var sid = _readSidCookie(req);
3186
3533
  if (!sid) {
@@ -3631,6 +3978,33 @@ function mount(router, deps) {
3631
3978
  return res.end ? res.end(msg) : res.send(msg);
3632
3979
  }
3633
3980
 
3981
+ // Attribute a freshly-created customer to the referrer named in the
3982
+ // sealed referral cookie, then clear the cookie so it can't attribute
3983
+ // a later signup. Guards:
3984
+ // * no cookie / unknown / disabled code → no-op (silent)
3985
+ // * self-referral (the code belongs to this customer) → no-op
3986
+ // * already-attributed (trackSignup pins to the oldest pending
3987
+ // invitation; a customer with one already pinned won't get a
3988
+ // second) → no-op
3989
+ // Best-effort: a referrals failure never blocks the signup. Mounts
3990
+ // only when the referrals primitive is wired. The cookie is always
3991
+ // cleared (even on a guarded no-op) so a stale code doesn't linger.
3992
+ async function _attributeReferral(req, res, newCustomerId) {
3993
+ if (!deps.referrals) return;
3994
+ var env = null;
3995
+ try { env = _readReferralEnv(req); } catch (_e) { env = null; }
3996
+ // Clear regardless — one shot per signup.
3997
+ try { _clearReferralCookie(res); } catch (_e) { /* best-effort */ }
3998
+ if (!env || !env.code) return;
3999
+ try {
4000
+ var row = await deps.referrals.byCode(env.code);
4001
+ if (!row || row.status !== "active") return;
4002
+ // Self-referral guard — a customer can't refer themselves.
4003
+ if (row.referrer_customer_id === newCustomerId) return;
4004
+ await deps.referrals.trackSignup({ code: env.code, customer_id: newCustomerId });
4005
+ } catch (_e) { /* drop-silent — attribution is best-effort, signup wins */ }
4006
+ }
4007
+
3634
4008
  function _readJsonBody(req) {
3635
4009
  // b.middleware.bodyParser leaves JSON in req.body when the
3636
4010
  // request's content-type is application/json. Some test
@@ -3693,6 +4067,12 @@ function mount(router, deps) {
3693
4067
  customer_id: customer.id,
3694
4068
  challenge: startOpts.challenge,
3695
4069
  created_at: Date.now(),
4070
+ // Whether THIS begin created the customer (vs reused an existing
4071
+ // row for a known email). register-finish gates referral
4072
+ // attribution on it — an existing customer re-enrolling a passkey
4073
+ // through a referral link must not be attributed to a referrer
4074
+ // (it isn't a new signup), mirroring the OIDC `rv.created` gate.
4075
+ is_new: !existing,
3696
4076
  });
3697
4077
  res.status(200);
3698
4078
  res.setHeader && res.setHeader("content-type", "application/json");
@@ -3741,6 +4121,18 @@ function mount(router, deps) {
3741
4121
  customer_id: env.customer_id,
3742
4122
  exp: Date.now() + b.constants.TIME.days(14),
3743
4123
  });
4124
+ // Attribute this signup to a referrer if the visitor arrived
4125
+ // through a /r/<code> link — ONLY for a genuinely new account
4126
+ // (env.is_new, stamped in register-begin). An existing customer
4127
+ // re-enrolling a passkey is not a new signup and must never be
4128
+ // attributed (that would let an existing user mint referral
4129
+ // credit by following their own/a friend's link), matching the
4130
+ // OIDC `rv.created` gate. Best-effort; never blocks the ceremony.
4131
+ if (env.is_new === true) {
4132
+ await _attributeReferral(req, res, env.customer_id);
4133
+ } else {
4134
+ try { _clearReferralCookie(res); } catch (_e) { /* best-effort */ }
4135
+ }
3744
4136
  res.status(200);
3745
4137
  return res.end ? res.end("ok") : res.send("ok");
3746
4138
  } catch (e) {
@@ -4016,6 +4408,14 @@ function mount(router, deps) {
4016
4408
  await deps.order.linkGuestOrdersByEmailHash(rv.customer.id, deps.customers.hashEmail(claims.email));
4017
4409
  } catch (_e) { /* best-effort reconciliation; sign-in succeeds regardless */ }
4018
4410
  }
4411
+ // Attribute a referral ONLY on a genuinely new account
4412
+ // (rv.created) — an existing customer signing in through Google
4413
+ // is not a new signup and must not be attributed to a referrer.
4414
+ if (rv.created === true) {
4415
+ await _attributeReferral(req, res, rv.customer.id);
4416
+ } else {
4417
+ try { _clearReferralCookie(res); } catch (_e) { /* best-effort */ }
4418
+ }
4019
4419
  _setAuthCookie(res, { customer_id: rv.customer.id, exp: Date.now() + b.constants.TIME.days(14) });
4020
4420
  res.status(303); res.setHeader && res.setHeader("location", "/account");
4021
4421
  return res.end ? res.end() : res.send("");
@@ -4122,6 +4522,13 @@ function mount(router, deps) {
4122
4522
  await deps.order.linkGuestOrdersByEmailHash(rv.customer.id, deps.customers.hashEmail(claims.email));
4123
4523
  } catch (_e) { /* best-effort reconciliation; sign-in succeeds regardless */ }
4124
4524
  }
4525
+ // Attribute a referral ONLY on a genuinely new account (rv.created)
4526
+ // — mirrors the Google path.
4527
+ if (rv.created === true) {
4528
+ await _attributeReferral(req, res, rv.customer.id);
4529
+ } else {
4530
+ try { _clearReferralCookie(res); } catch (_e) { /* best-effort */ }
4531
+ }
4125
4532
  _setAuthCookie(res, { customer_id: rv.customer.id, exp: Date.now() + b.constants.TIME.days(14) });
4126
4533
  res.status(303); res.setHeader && res.setHeader("location", "/account");
4127
4534
  return res.end ? res.end() : res.send("");
@@ -4781,6 +5188,167 @@ function mount(router, deps) {
4781
5188
  }
4782
5189
  }
4783
5190
 
5191
+ // Referrals — refer-a-friend. Three surfaces:
5192
+ // * GET /r/:code — the attribution landing. Sets a
5193
+ // short-lived sealed first-party cookie naming the code, records
5194
+ // the visit, then 303s home. An unknown / malformed / disabled
5195
+ // code is silently ignored (no cookie, no error to the visitor).
5196
+ // Public — no sign-in. The cookie is read at account-creation to
5197
+ // attribute the new customer to the referrer.
5198
+ // * GET /account/referrals — the signed-in customer's own code +
5199
+ // shareable link, the friends they've referred (funnel stage,
5200
+ // no PII), and the in-account top-referrer leaderboard.
5201
+ // * POST /account/referrals/code — mint the customer's code (one
5202
+ // active code per customer; idempotent on an existing one).
5203
+ // Attribution is FIRST-TOUCH: trackSignup pins the signup to the
5204
+ // oldest pending invitation under the code, and the landing only
5205
+ // overwrites the cookie when none is already set (below), so the
5206
+ // first referral link a visitor follows wins.
5207
+ if (deps.referrals) {
5208
+ function _referralsAuth(req, res) {
5209
+ var auth;
5210
+ try { auth = _currentCustomer(req); }
5211
+ catch (e) {
5212
+ if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
5213
+ throw e;
5214
+ }
5215
+ if (!auth) {
5216
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
5217
+ res.end ? res.end() : res.send("");
5218
+ return null;
5219
+ }
5220
+ return auth;
5221
+ }
5222
+
5223
+ // The attribution landing. The cookie is path "/" so it survives
5224
+ // the visitor's navigation to the register page, sealed so it
5225
+ // can't be forged to mis-attribute a signup, and short-lived
5226
+ // (30 days) so a stale link doesn't attribute a much-later signup.
5227
+ router.get("/r/:code", async function (req, res) {
5228
+ function _goHome() {
5229
+ res.status(303); res.setHeader && res.setHeader("location", "/");
5230
+ return res.end ? res.end() : res.send("");
5231
+ }
5232
+ var raw = req.params && req.params.code;
5233
+ // Resolve the code to a live row. A bad shape throws TypeError
5234
+ // inside byCode (canonicalization) — swallow it; the visitor
5235
+ // sees a clean redirect home, never an error tied to a guessed
5236
+ // code (no code-existence oracle).
5237
+ var row;
5238
+ try { row = await deps.referrals.byCode(raw); }
5239
+ catch (_e) { row = null; }
5240
+ if (!row || row.status !== "active") return _goHome();
5241
+ // First-touch: don't overwrite an existing referral cookie — the
5242
+ // first link the visitor followed keeps the attribution.
5243
+ var existing = null;
5244
+ try { existing = _readReferralEnv(req); } catch (_e) { existing = null; }
5245
+ if (!existing || !existing.code) {
5246
+ _setReferralCookie(res, { code: row.code, set_at: Date.now() });
5247
+ }
5248
+ // Record the visit on the funnel (best-effort — a tracking
5249
+ // failure must not block the redirect).
5250
+ try { await deps.referrals.trackVisit({ code: row.code }); }
5251
+ catch (_e) { /* drop-silent — the visit stat is non-critical */ }
5252
+ return _goHome();
5253
+ });
5254
+
5255
+ // Build the /account/referrals render context. Each read is
5256
+ // best-effort so a not-migrated dependency degrades that panel
5257
+ // rather than 500-ing the page.
5258
+ async function _referralsView(req, auth, opts2) {
5259
+ opts2 = opts2 || {};
5260
+ var code = null, link = null;
5261
+ var stats = null;
5262
+ try { stats = await deps.referrals.statsForReferrer(auth.customer_id); }
5263
+ catch (_e) { stats = null; }
5264
+ if (stats && stats.codes && stats.codes.length) {
5265
+ // Surface ONLY an active code — the one /r/<code> will honor.
5266
+ // If every code is disabled, leave code/link null so the page
5267
+ // shows the "create a code" state rather than prompting the
5268
+ // customer to share a dead link they can't replace.
5269
+ var active = null;
5270
+ for (var i = 0; i < stats.codes.length; i += 1) {
5271
+ if (stats.codes[i].status === "active") { active = stats.codes[i]; break; }
5272
+ }
5273
+ if (active) {
5274
+ code = active.code;
5275
+ link = _referralLink(req, active.code);
5276
+ }
5277
+ }
5278
+ var invitations = [];
5279
+ try { invitations = await deps.referrals.invitationsForReferrer(auth.customer_id); }
5280
+ catch (_e) { invitations = []; }
5281
+ var leaderboard = [];
5282
+ if (deps.referralLeaderboard) {
5283
+ try {
5284
+ // Lifetime top referrers. The referrals primitive's own
5285
+ // leaderboard returns { referrer_customer_id, completed }; we
5286
+ // resolve each to initials for display (rank + initials only,
5287
+ // never names/emails/ids), and mark the signed-in customer.
5288
+ var top = await deps.referrals.leaderboard({ limit: 10 });
5289
+ for (var k = 0; k < top.length; k += 1) {
5290
+ var rid = top[k].referrer_customer_id;
5291
+ var dn = "";
5292
+ try {
5293
+ var cust = await deps.customers.get(rid);
5294
+ dn = (cust && cust.display_name) || "";
5295
+ } catch (_e) { dn = ""; }
5296
+ leaderboard.push({
5297
+ completed_referrals: top[k].completed_referrals,
5298
+ display_name: dn,
5299
+ is_you: rid === auth.customer_id,
5300
+ });
5301
+ }
5302
+ } catch (_e) { leaderboard = []; }
5303
+ }
5304
+ var cartCount = await _cartCountForReq(req);
5305
+ return {
5306
+ code: code,
5307
+ link: link,
5308
+ invitations: invitations,
5309
+ leaderboard: leaderboard,
5310
+ completed_referrals: stats ? stats.completed_referrals : 0,
5311
+ invitations_total: stats ? stats.invitations_total : 0,
5312
+ invitations_signed_up: stats ? stats.invitations_signed_up : 0,
5313
+ notice: opts2.notice || null,
5314
+ notice_kind: opts2.notice_kind || null,
5315
+ shop_name: shopName,
5316
+ cart_count: cartCount,
5317
+ };
5318
+ }
5319
+
5320
+ router.get("/account/referrals", async function (req, res) {
5321
+ var auth = _referralsAuth(req, res); if (!auth) return;
5322
+ var view = await _referralsView(req, auth, {});
5323
+ _send(res, 200, renderReferrals(view));
5324
+ });
5325
+
5326
+ router.post("/account/referrals/code", async function (req, res) {
5327
+ var auth = _referralsAuth(req, res); if (!auth) return;
5328
+ try {
5329
+ await deps.referrals.issueCode({ referrer_customer_id: auth.customer_id });
5330
+ } catch (e) {
5331
+ // An existing active code is the idempotent happy path — the
5332
+ // customer already has one, so just show the page. Any other
5333
+ // refusal re-renders with a generic notice; never a 500 for a
5334
+ // TypeError on the (cookie-derived) customer id.
5335
+ var code = (e && typeof e.code === "string") ? e.code : "";
5336
+ if (code !== "REFERRAL_CODE_ALREADY_ACTIVE" && !(e instanceof TypeError)) {
5337
+ throw e;
5338
+ }
5339
+ if (code !== "REFERRAL_CODE_ALREADY_ACTIVE") {
5340
+ var failView = await _referralsView(req, auth, {
5341
+ notice: "We couldn't create your referral code — please try again.",
5342
+ notice_kind: "error",
5343
+ });
5344
+ return _send(res, 400, renderReferrals(failView));
5345
+ }
5346
+ }
5347
+ res.status(303); res.setHeader && res.setHeader("location", "/account/referrals");
5348
+ return res.end ? res.end() : res.send("");
5349
+ });
5350
+ }
5351
+
4784
5352
  // Recently viewed — the signed-in customer's newest-first browse
4785
5353
  // history. Views are recorded server-side on the (container-rendered)
4786
5354
  // PDP; this surface lets the customer review + clear that history.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
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": {