@blamejs/blamejs-shop 0.1.27 → 0.1.28
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 +3 -0
- package/SECURITY.md +15 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/order.js +30 -0
- package/lib/referrals.js +44 -0
- package/lib/storefront.js +396 -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.1.x
|
|
10
10
|
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
11
13
|
- 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
14
|
|
|
13
15
|
- 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,6 +69,7 @@ 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. |
|
|
@@ -106,6 +107,8 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
106
107
|
- `migrations-d1/0022_loyalty.sql` — loyalty accounts (balance + lifetime + tier) + audited points transactions
|
|
107
108
|
- `migrations-d1/0085_loyalty_redemptions.sql` — reward catalog + per-redemption records (FSM: active → consumed / expired / cancelled)
|
|
108
109
|
- `migrations-d1/0163_loyalty_earn_rules.sql` — per-event earn rules + dedup-keyed award log
|
|
110
|
+
- `migrations-d1/0025_referrals.sql` — referral codes (one active per customer) + invitation funnel (hash-only referred email)
|
|
111
|
+
- `migrations-d1/0182_referral_leaderboard.sql` — referral tier thresholds (singleton config) + tiered-bonus payout log
|
|
109
112
|
|
|
110
113
|
### Demo seed
|
|
111
114
|
|
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.
|
package/lib/asset-manifest.json
CHANGED
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
|
@@ -1593,6 +1593,139 @@ function renderLoyalty(opts) {
|
|
|
1593
1593
|
});
|
|
1594
1594
|
}
|
|
1595
1595
|
|
|
1596
|
+
// Human-readable funnel stage for a referred friend. The referred
|
|
1597
|
+
// email is stored hash-only, so a row never carries an address — the
|
|
1598
|
+
// surface shows the stage + dates only.
|
|
1599
|
+
var REFERRAL_STAGE_LABELS = {
|
|
1600
|
+
pending: "Invited",
|
|
1601
|
+
visited: "Visited",
|
|
1602
|
+
"signed-up": "Joined",
|
|
1603
|
+
converted: "Converted",
|
|
1604
|
+
};
|
|
1605
|
+
|
|
1606
|
+
// Initials for a leaderboard row — first letter of each whitespace-
|
|
1607
|
+
// separated word in the display name, up to two, uppercased. Falls back
|
|
1608
|
+
// to a neutral glyph when the name is empty / whitespace. NEVER renders
|
|
1609
|
+
// the full name, the email (which is hash-only anyway), or the customer
|
|
1610
|
+
// id — the public-ish leaderboard surface exposes rank + initials only.
|
|
1611
|
+
function _referralInitials(displayName) {
|
|
1612
|
+
var s = String(displayName == null ? "" : displayName).trim();
|
|
1613
|
+
if (!s) return "–";
|
|
1614
|
+
var words = s.split(/\s+/);
|
|
1615
|
+
var out = "";
|
|
1616
|
+
for (var i = 0; i < words.length && out.length < 2; i += 1) {
|
|
1617
|
+
var w = words[i];
|
|
1618
|
+
if (w.length) out += w.charAt(0);
|
|
1619
|
+
}
|
|
1620
|
+
return out.toUpperCase() || "–";
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
// The signed-in customer's referral surface: their personal code +
|
|
1624
|
+
// shareable link, the friends they've referred (funnel stage + dates,
|
|
1625
|
+
// no PII), the reward funnel summary, and an in-account top-referrer
|
|
1626
|
+
// leaderboard (rank + initials only — never names/emails/ids). Reuses
|
|
1627
|
+
// the account/returns layout classes — no new CSS.
|
|
1628
|
+
function renderReferrals(opts) {
|
|
1629
|
+
opts = opts || {};
|
|
1630
|
+
var esc = b.template.escapeHtml;
|
|
1631
|
+
|
|
1632
|
+
var notice = opts.notice
|
|
1633
|
+
? "<p class=\"form-notice" + (opts.notice_kind === "error" ? " form-notice--error" : "") + "\" role=\"alert\">" +
|
|
1634
|
+
esc(String(opts.notice)) + "</p>"
|
|
1635
|
+
: "";
|
|
1636
|
+
|
|
1637
|
+
// Code + shareable link. `code`/`link` are null until the customer
|
|
1638
|
+
// mints one (the empty state offers a Create-code button).
|
|
1639
|
+
var code = opts.code || null;
|
|
1640
|
+
var link = opts.link || null;
|
|
1641
|
+
var codeSection;
|
|
1642
|
+
if (code && link) {
|
|
1643
|
+
codeSection =
|
|
1644
|
+
"<dl class=\"account-dash__stats\">" +
|
|
1645
|
+
"<div><dt>Your referral code</dt><dd><code>" + esc(String(code)) + "</code></dd></div>" +
|
|
1646
|
+
"<div><dt>Friends converted</dt><dd>" + esc(String(Number(opts.completed_referrals) || 0)) + "</dd></div>" +
|
|
1647
|
+
"<div><dt>Friends joined</dt><dd>" + esc(String(Number(opts.invitations_signed_up) || 0)) + "</dd></div>" +
|
|
1648
|
+
"<div><dt>Friends invited</dt><dd>" + esc(String(Number(opts.invitations_total) || 0)) + "</dd></div>" +
|
|
1649
|
+
"</dl>" +
|
|
1650
|
+
"<h2 class=\"pdp__variants-title\">Share your link</h2>" +
|
|
1651
|
+
"<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>" +
|
|
1652
|
+
"<p><a class=\"btn-secondary\" href=\"" + esc(String(link)) + "\">" + esc(String(link)) + "</a></p>";
|
|
1653
|
+
} else {
|
|
1654
|
+
codeSection =
|
|
1655
|
+
"<p class=\"return-empty\">You don't have a referral code yet. Create one to start inviting friends.</p>" +
|
|
1656
|
+
"<form method=\"post\" action=\"/account/referrals/code\">" +
|
|
1657
|
+
"<div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary\">Create my referral code</button></div>" +
|
|
1658
|
+
"</form>";
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// Friends referred — funnel stage + dates, no identity.
|
|
1662
|
+
var friends = opts.invitations || [];
|
|
1663
|
+
var friendsSection;
|
|
1664
|
+
if (friends.length) {
|
|
1665
|
+
var items = "";
|
|
1666
|
+
for (var i = 0; i < friends.length; i += 1) {
|
|
1667
|
+
var f = friends[i];
|
|
1668
|
+
var stageLabel = REFERRAL_STAGE_LABELS[f.stage] || f.stage || "Invited";
|
|
1669
|
+
var when = f.invited_at ? new Date(Number(f.invited_at)).toISOString().slice(0, 10) : "";
|
|
1670
|
+
var converted = f.first_purchase_at
|
|
1671
|
+
? new Date(Number(f.first_purchase_at)).toISOString().slice(0, 10)
|
|
1672
|
+
: "";
|
|
1673
|
+
items +=
|
|
1674
|
+
"<li class=\"return-card\"><div class=\"return-card__head\">" +
|
|
1675
|
+
"<span class=\"return-card__rma\">Friend " + esc(String(i + 1)) + "</span>" +
|
|
1676
|
+
"<span class=\"pdp__badge referral-stage--" + esc(String(f.stage || "pending")) + "\">" + esc(stageLabel) + "</span>" +
|
|
1677
|
+
"</div>" +
|
|
1678
|
+
"<p class=\"return-card__meta\">" +
|
|
1679
|
+
(when ? "Invited <time datetime=\"" + esc(when) + "\">" + esc(when) + "</time>" : "") +
|
|
1680
|
+
(converted ? " · converted <time datetime=\"" + esc(converted) + "\">" + esc(converted) + "</time>" : "") +
|
|
1681
|
+
"</p></li>";
|
|
1682
|
+
}
|
|
1683
|
+
friendsSection = "<h2 class=\"pdp__variants-title\">Friends you've referred</h2><ul class=\"return-list\">" + items + "</ul>";
|
|
1684
|
+
} else {
|
|
1685
|
+
friendsSection = "<h2 class=\"pdp__variants-title\">Friends you've referred</h2>" +
|
|
1686
|
+
"<p class=\"return-empty\">No referrals yet. Share your link above to get started.</p>";
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
// In-account leaderboard — rank + initials only. The signed-in
|
|
1690
|
+
// customer's own row is marked "You".
|
|
1691
|
+
var board = opts.leaderboard || [];
|
|
1692
|
+
var boardSection = "";
|
|
1693
|
+
if (board.length) {
|
|
1694
|
+
var rows = "";
|
|
1695
|
+
for (var j = 0; j < board.length; j += 1) {
|
|
1696
|
+
var entry = board[j];
|
|
1697
|
+
var who = entry.is_you ? "You" : _referralInitials(entry.display_name);
|
|
1698
|
+
rows +=
|
|
1699
|
+
"<li class=\"return-card\"><div class=\"return-card__head\">" +
|
|
1700
|
+
"<span class=\"return-card__rma\">#" + esc(String(j + 1)) + " " + esc(who) + "</span>" +
|
|
1701
|
+
"<span class=\"pdp__badge\">" + esc(String(Number(entry.completed_referrals) || 0)) + " referred</span>" +
|
|
1702
|
+
"</div></li>";
|
|
1703
|
+
}
|
|
1704
|
+
boardSection = "<h2 class=\"pdp__variants-title\">Top referrers</h2><ul class=\"return-list\">" + rows + "</ul>";
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
var body =
|
|
1708
|
+
"<section class=\"account-returns\">" +
|
|
1709
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
1710
|
+
"<li><a href=\"/account\">Account</a></li>" +
|
|
1711
|
+
"<li aria-current=\"page\">Refer a friend</li>" +
|
|
1712
|
+
"</ol></nav>" +
|
|
1713
|
+
"<h1 class=\"account-returns__title\">Refer a friend</h1>" +
|
|
1714
|
+
notice +
|
|
1715
|
+
codeSection +
|
|
1716
|
+
friendsSection +
|
|
1717
|
+
boardSection +
|
|
1718
|
+
"</section>";
|
|
1719
|
+
|
|
1720
|
+
return _wrap({
|
|
1721
|
+
title: "Refer a friend",
|
|
1722
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
1723
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
1724
|
+
theme_css: opts.theme_css,
|
|
1725
|
+
body: body,
|
|
1726
|
+
});
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1596
1729
|
// Subscription status pill — mirrors `_returnStatusBadge`. The status
|
|
1597
1730
|
// string is one of Stripe's enum values (active, trialing, past_due,
|
|
1598
1731
|
// canceled, …), surfaced as a CSS-classed badge the theme can style.
|
|
@@ -2617,6 +2750,14 @@ var PAY_COOKIE_NAME = "shop_pay";
|
|
|
2617
2750
|
// SameSite=Lax so it survives the provider's top-level GET redirect back.
|
|
2618
2751
|
var OAUTH_COOKIE_NAME = "shop_oauth";
|
|
2619
2752
|
|
|
2753
|
+
// Short-lived sealed cookie naming the referral code an inbound visitor
|
|
2754
|
+
// arrived through (set by the /r/:code landing). Read at account-creation
|
|
2755
|
+
// to attribute the new customer to the referrer. Path "/" so it survives
|
|
2756
|
+
// the visitor's navigation from the landing to the register / sign-in
|
|
2757
|
+
// flow; SameSite=Lax so a top-level GET from a shared link carries it.
|
|
2758
|
+
// Sealed so it can't be forged to mis-attribute a signup.
|
|
2759
|
+
var REFERRAL_COOKIE_NAME = "shop_ref";
|
|
2760
|
+
|
|
2620
2761
|
// Shape of a valid session id — mirrors cart.js's SESSION_ID_RE.
|
|
2621
2762
|
var SID_SHAPE_RE = /^[A-Za-z0-9_-]{16,64}$/;
|
|
2622
2763
|
|
|
@@ -2686,6 +2827,22 @@ function _readChallengeEnv(req) {
|
|
|
2686
2827
|
try { return JSON.parse(raw); } catch (_e) { return null; }
|
|
2687
2828
|
}
|
|
2688
2829
|
|
|
2830
|
+
// Referral-attribution cookie. Carries the code the visitor arrived
|
|
2831
|
+
// through + when it was set, sealed so it can't be forged. Path "/" so
|
|
2832
|
+
// it survives navigation from the landing through register / sign-in.
|
|
2833
|
+
function _setReferralCookie(res, env) {
|
|
2834
|
+
var T = b.constants.TIME;
|
|
2835
|
+
_cookieJar().writeSealed(res, REFERRAL_COOKIE_NAME, JSON.stringify(env), { expires: new Date(Date.now() + T.days(30)) });
|
|
2836
|
+
}
|
|
2837
|
+
function _clearReferralCookie(res) {
|
|
2838
|
+
_cookieJar().clear(res, REFERRAL_COOKIE_NAME);
|
|
2839
|
+
}
|
|
2840
|
+
function _readReferralEnv(req) {
|
|
2841
|
+
var raw = _cookieJar().readSealed(req, REFERRAL_COOKIE_NAME);
|
|
2842
|
+
if (raw === null) return null;
|
|
2843
|
+
try { return JSON.parse(raw); } catch (_e) { return null; }
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2689
2846
|
// ---- account-page renderers --------------------------------------------
|
|
2690
2847
|
|
|
2691
2848
|
var ACCOUNT_LOGIN_PAGE =
|
|
@@ -2785,6 +2942,7 @@ var ACCOUNT_DASH_PAGE =
|
|
|
2785
2942
|
" <a class=\"btn-secondary\" href=\"/account/addresses\">Addresses</a>\n" +
|
|
2786
2943
|
" <a class=\"btn-secondary\" href=\"/account/returns\">Returns</a>\n" +
|
|
2787
2944
|
" <a class=\"btn-secondary\" href=\"/account/loyalty\">Rewards</a>\n" +
|
|
2945
|
+
" <a class=\"btn-secondary\" href=\"/account/referrals\">Refer a friend</a>\n" +
|
|
2788
2946
|
" <a class=\"btn-secondary\" href=\"/account/subscriptions\">Subscriptions</a>\n" +
|
|
2789
2947
|
" <form method=\"post\" action=\"/account/logout\"><button type=\"submit\" class=\"btn-ghost\">Sign out</button></form>\n" +
|
|
2790
2948
|
" </div>\n" +
|
|
@@ -2938,6 +3096,23 @@ function mount(router, deps) {
|
|
|
2938
3096
|
return lines.length;
|
|
2939
3097
|
}
|
|
2940
3098
|
|
|
3099
|
+
// Absolute shareable referral link for a code. Prefers the operator's
|
|
3100
|
+
// configured origin (deps.shop_origin / SHOP_ORIGIN) so the link is
|
|
3101
|
+
// stable across the edge/container split; falls back to the request's
|
|
3102
|
+
// Host header when no origin is configured (dev / single-host deploys).
|
|
3103
|
+
// The /r/<code> landing is container-served (not an edge route), so it
|
|
3104
|
+
// always reaches this primitive's attribution handler.
|
|
3105
|
+
function _referralLink(req, code) {
|
|
3106
|
+
var origin = deps.shop_origin
|
|
3107
|
+
? String(deps.shop_origin).replace(/\/$/, "")
|
|
3108
|
+
: null;
|
|
3109
|
+
if (!origin) {
|
|
3110
|
+
var host = (req && req.headers && (req.headers.host || req.headers.Host)) || "";
|
|
3111
|
+
origin = host ? ("https://" + host) : "";
|
|
3112
|
+
}
|
|
3113
|
+
return origin + "/r/" + encodeURIComponent(code);
|
|
3114
|
+
}
|
|
3115
|
+
|
|
2941
3116
|
// The signed-in customer's sealed-cookie envelope, or null. Shared by
|
|
2942
3117
|
// the PDP view recorder (mounted outside the `if (deps.customers)`
|
|
2943
3118
|
// block) and the account routes inside it, so there's one auth-cookie
|
|
@@ -3631,6 +3806,33 @@ function mount(router, deps) {
|
|
|
3631
3806
|
return res.end ? res.end(msg) : res.send(msg);
|
|
3632
3807
|
}
|
|
3633
3808
|
|
|
3809
|
+
// Attribute a freshly-created customer to the referrer named in the
|
|
3810
|
+
// sealed referral cookie, then clear the cookie so it can't attribute
|
|
3811
|
+
// a later signup. Guards:
|
|
3812
|
+
// * no cookie / unknown / disabled code → no-op (silent)
|
|
3813
|
+
// * self-referral (the code belongs to this customer) → no-op
|
|
3814
|
+
// * already-attributed (trackSignup pins to the oldest pending
|
|
3815
|
+
// invitation; a customer with one already pinned won't get a
|
|
3816
|
+
// second) → no-op
|
|
3817
|
+
// Best-effort: a referrals failure never blocks the signup. Mounts
|
|
3818
|
+
// only when the referrals primitive is wired. The cookie is always
|
|
3819
|
+
// cleared (even on a guarded no-op) so a stale code doesn't linger.
|
|
3820
|
+
async function _attributeReferral(req, res, newCustomerId) {
|
|
3821
|
+
if (!deps.referrals) return;
|
|
3822
|
+
var env = null;
|
|
3823
|
+
try { env = _readReferralEnv(req); } catch (_e) { env = null; }
|
|
3824
|
+
// Clear regardless — one shot per signup.
|
|
3825
|
+
try { _clearReferralCookie(res); } catch (_e) { /* best-effort */ }
|
|
3826
|
+
if (!env || !env.code) return;
|
|
3827
|
+
try {
|
|
3828
|
+
var row = await deps.referrals.byCode(env.code);
|
|
3829
|
+
if (!row || row.status !== "active") return;
|
|
3830
|
+
// Self-referral guard — a customer can't refer themselves.
|
|
3831
|
+
if (row.referrer_customer_id === newCustomerId) return;
|
|
3832
|
+
await deps.referrals.trackSignup({ code: env.code, customer_id: newCustomerId });
|
|
3833
|
+
} catch (_e) { /* drop-silent — attribution is best-effort, signup wins */ }
|
|
3834
|
+
}
|
|
3835
|
+
|
|
3634
3836
|
function _readJsonBody(req) {
|
|
3635
3837
|
// b.middleware.bodyParser leaves JSON in req.body when the
|
|
3636
3838
|
// request's content-type is application/json. Some test
|
|
@@ -3693,6 +3895,12 @@ function mount(router, deps) {
|
|
|
3693
3895
|
customer_id: customer.id,
|
|
3694
3896
|
challenge: startOpts.challenge,
|
|
3695
3897
|
created_at: Date.now(),
|
|
3898
|
+
// Whether THIS begin created the customer (vs reused an existing
|
|
3899
|
+
// row for a known email). register-finish gates referral
|
|
3900
|
+
// attribution on it — an existing customer re-enrolling a passkey
|
|
3901
|
+
// through a referral link must not be attributed to a referrer
|
|
3902
|
+
// (it isn't a new signup), mirroring the OIDC `rv.created` gate.
|
|
3903
|
+
is_new: !existing,
|
|
3696
3904
|
});
|
|
3697
3905
|
res.status(200);
|
|
3698
3906
|
res.setHeader && res.setHeader("content-type", "application/json");
|
|
@@ -3741,6 +3949,18 @@ function mount(router, deps) {
|
|
|
3741
3949
|
customer_id: env.customer_id,
|
|
3742
3950
|
exp: Date.now() + b.constants.TIME.days(14),
|
|
3743
3951
|
});
|
|
3952
|
+
// Attribute this signup to a referrer if the visitor arrived
|
|
3953
|
+
// through a /r/<code> link — ONLY for a genuinely new account
|
|
3954
|
+
// (env.is_new, stamped in register-begin). An existing customer
|
|
3955
|
+
// re-enrolling a passkey is not a new signup and must never be
|
|
3956
|
+
// attributed (that would let an existing user mint referral
|
|
3957
|
+
// credit by following their own/a friend's link), matching the
|
|
3958
|
+
// OIDC `rv.created` gate. Best-effort; never blocks the ceremony.
|
|
3959
|
+
if (env.is_new === true) {
|
|
3960
|
+
await _attributeReferral(req, res, env.customer_id);
|
|
3961
|
+
} else {
|
|
3962
|
+
try { _clearReferralCookie(res); } catch (_e) { /* best-effort */ }
|
|
3963
|
+
}
|
|
3744
3964
|
res.status(200);
|
|
3745
3965
|
return res.end ? res.end("ok") : res.send("ok");
|
|
3746
3966
|
} catch (e) {
|
|
@@ -4016,6 +4236,14 @@ function mount(router, deps) {
|
|
|
4016
4236
|
await deps.order.linkGuestOrdersByEmailHash(rv.customer.id, deps.customers.hashEmail(claims.email));
|
|
4017
4237
|
} catch (_e) { /* best-effort reconciliation; sign-in succeeds regardless */ }
|
|
4018
4238
|
}
|
|
4239
|
+
// Attribute a referral ONLY on a genuinely new account
|
|
4240
|
+
// (rv.created) — an existing customer signing in through Google
|
|
4241
|
+
// is not a new signup and must not be attributed to a referrer.
|
|
4242
|
+
if (rv.created === true) {
|
|
4243
|
+
await _attributeReferral(req, res, rv.customer.id);
|
|
4244
|
+
} else {
|
|
4245
|
+
try { _clearReferralCookie(res); } catch (_e) { /* best-effort */ }
|
|
4246
|
+
}
|
|
4019
4247
|
_setAuthCookie(res, { customer_id: rv.customer.id, exp: Date.now() + b.constants.TIME.days(14) });
|
|
4020
4248
|
res.status(303); res.setHeader && res.setHeader("location", "/account");
|
|
4021
4249
|
return res.end ? res.end() : res.send("");
|
|
@@ -4122,6 +4350,13 @@ function mount(router, deps) {
|
|
|
4122
4350
|
await deps.order.linkGuestOrdersByEmailHash(rv.customer.id, deps.customers.hashEmail(claims.email));
|
|
4123
4351
|
} catch (_e) { /* best-effort reconciliation; sign-in succeeds regardless */ }
|
|
4124
4352
|
}
|
|
4353
|
+
// Attribute a referral ONLY on a genuinely new account (rv.created)
|
|
4354
|
+
// — mirrors the Google path.
|
|
4355
|
+
if (rv.created === true) {
|
|
4356
|
+
await _attributeReferral(req, res, rv.customer.id);
|
|
4357
|
+
} else {
|
|
4358
|
+
try { _clearReferralCookie(res); } catch (_e) { /* best-effort */ }
|
|
4359
|
+
}
|
|
4125
4360
|
_setAuthCookie(res, { customer_id: rv.customer.id, exp: Date.now() + b.constants.TIME.days(14) });
|
|
4126
4361
|
res.status(303); res.setHeader && res.setHeader("location", "/account");
|
|
4127
4362
|
return res.end ? res.end() : res.send("");
|
|
@@ -4781,6 +5016,167 @@ function mount(router, deps) {
|
|
|
4781
5016
|
}
|
|
4782
5017
|
}
|
|
4783
5018
|
|
|
5019
|
+
// Referrals — refer-a-friend. Three surfaces:
|
|
5020
|
+
// * GET /r/:code — the attribution landing. Sets a
|
|
5021
|
+
// short-lived sealed first-party cookie naming the code, records
|
|
5022
|
+
// the visit, then 303s home. An unknown / malformed / disabled
|
|
5023
|
+
// code is silently ignored (no cookie, no error to the visitor).
|
|
5024
|
+
// Public — no sign-in. The cookie is read at account-creation to
|
|
5025
|
+
// attribute the new customer to the referrer.
|
|
5026
|
+
// * GET /account/referrals — the signed-in customer's own code +
|
|
5027
|
+
// shareable link, the friends they've referred (funnel stage,
|
|
5028
|
+
// no PII), and the in-account top-referrer leaderboard.
|
|
5029
|
+
// * POST /account/referrals/code — mint the customer's code (one
|
|
5030
|
+
// active code per customer; idempotent on an existing one).
|
|
5031
|
+
// Attribution is FIRST-TOUCH: trackSignup pins the signup to the
|
|
5032
|
+
// oldest pending invitation under the code, and the landing only
|
|
5033
|
+
// overwrites the cookie when none is already set (below), so the
|
|
5034
|
+
// first referral link a visitor follows wins.
|
|
5035
|
+
if (deps.referrals) {
|
|
5036
|
+
function _referralsAuth(req, res) {
|
|
5037
|
+
var auth;
|
|
5038
|
+
try { auth = _currentCustomer(req); }
|
|
5039
|
+
catch (e) {
|
|
5040
|
+
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
5041
|
+
throw e;
|
|
5042
|
+
}
|
|
5043
|
+
if (!auth) {
|
|
5044
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
5045
|
+
res.end ? res.end() : res.send("");
|
|
5046
|
+
return null;
|
|
5047
|
+
}
|
|
5048
|
+
return auth;
|
|
5049
|
+
}
|
|
5050
|
+
|
|
5051
|
+
// The attribution landing. The cookie is path "/" so it survives
|
|
5052
|
+
// the visitor's navigation to the register page, sealed so it
|
|
5053
|
+
// can't be forged to mis-attribute a signup, and short-lived
|
|
5054
|
+
// (30 days) so a stale link doesn't attribute a much-later signup.
|
|
5055
|
+
router.get("/r/:code", async function (req, res) {
|
|
5056
|
+
function _goHome() {
|
|
5057
|
+
res.status(303); res.setHeader && res.setHeader("location", "/");
|
|
5058
|
+
return res.end ? res.end() : res.send("");
|
|
5059
|
+
}
|
|
5060
|
+
var raw = req.params && req.params.code;
|
|
5061
|
+
// Resolve the code to a live row. A bad shape throws TypeError
|
|
5062
|
+
// inside byCode (canonicalization) — swallow it; the visitor
|
|
5063
|
+
// sees a clean redirect home, never an error tied to a guessed
|
|
5064
|
+
// code (no code-existence oracle).
|
|
5065
|
+
var row;
|
|
5066
|
+
try { row = await deps.referrals.byCode(raw); }
|
|
5067
|
+
catch (_e) { row = null; }
|
|
5068
|
+
if (!row || row.status !== "active") return _goHome();
|
|
5069
|
+
// First-touch: don't overwrite an existing referral cookie — the
|
|
5070
|
+
// first link the visitor followed keeps the attribution.
|
|
5071
|
+
var existing = null;
|
|
5072
|
+
try { existing = _readReferralEnv(req); } catch (_e) { existing = null; }
|
|
5073
|
+
if (!existing || !existing.code) {
|
|
5074
|
+
_setReferralCookie(res, { code: row.code, set_at: Date.now() });
|
|
5075
|
+
}
|
|
5076
|
+
// Record the visit on the funnel (best-effort — a tracking
|
|
5077
|
+
// failure must not block the redirect).
|
|
5078
|
+
try { await deps.referrals.trackVisit({ code: row.code }); }
|
|
5079
|
+
catch (_e) { /* drop-silent — the visit stat is non-critical */ }
|
|
5080
|
+
return _goHome();
|
|
5081
|
+
});
|
|
5082
|
+
|
|
5083
|
+
// Build the /account/referrals render context. Each read is
|
|
5084
|
+
// best-effort so a not-migrated dependency degrades that panel
|
|
5085
|
+
// rather than 500-ing the page.
|
|
5086
|
+
async function _referralsView(req, auth, opts2) {
|
|
5087
|
+
opts2 = opts2 || {};
|
|
5088
|
+
var code = null, link = null;
|
|
5089
|
+
var stats = null;
|
|
5090
|
+
try { stats = await deps.referrals.statsForReferrer(auth.customer_id); }
|
|
5091
|
+
catch (_e) { stats = null; }
|
|
5092
|
+
if (stats && stats.codes && stats.codes.length) {
|
|
5093
|
+
// Surface ONLY an active code — the one /r/<code> will honor.
|
|
5094
|
+
// If every code is disabled, leave code/link null so the page
|
|
5095
|
+
// shows the "create a code" state rather than prompting the
|
|
5096
|
+
// customer to share a dead link they can't replace.
|
|
5097
|
+
var active = null;
|
|
5098
|
+
for (var i = 0; i < stats.codes.length; i += 1) {
|
|
5099
|
+
if (stats.codes[i].status === "active") { active = stats.codes[i]; break; }
|
|
5100
|
+
}
|
|
5101
|
+
if (active) {
|
|
5102
|
+
code = active.code;
|
|
5103
|
+
link = _referralLink(req, active.code);
|
|
5104
|
+
}
|
|
5105
|
+
}
|
|
5106
|
+
var invitations = [];
|
|
5107
|
+
try { invitations = await deps.referrals.invitationsForReferrer(auth.customer_id); }
|
|
5108
|
+
catch (_e) { invitations = []; }
|
|
5109
|
+
var leaderboard = [];
|
|
5110
|
+
if (deps.referralLeaderboard) {
|
|
5111
|
+
try {
|
|
5112
|
+
// Lifetime top referrers. The referrals primitive's own
|
|
5113
|
+
// leaderboard returns { referrer_customer_id, completed }; we
|
|
5114
|
+
// resolve each to initials for display (rank + initials only,
|
|
5115
|
+
// never names/emails/ids), and mark the signed-in customer.
|
|
5116
|
+
var top = await deps.referrals.leaderboard({ limit: 10 });
|
|
5117
|
+
for (var k = 0; k < top.length; k += 1) {
|
|
5118
|
+
var rid = top[k].referrer_customer_id;
|
|
5119
|
+
var dn = "";
|
|
5120
|
+
try {
|
|
5121
|
+
var cust = await deps.customers.get(rid);
|
|
5122
|
+
dn = (cust && cust.display_name) || "";
|
|
5123
|
+
} catch (_e) { dn = ""; }
|
|
5124
|
+
leaderboard.push({
|
|
5125
|
+
completed_referrals: top[k].completed_referrals,
|
|
5126
|
+
display_name: dn,
|
|
5127
|
+
is_you: rid === auth.customer_id,
|
|
5128
|
+
});
|
|
5129
|
+
}
|
|
5130
|
+
} catch (_e) { leaderboard = []; }
|
|
5131
|
+
}
|
|
5132
|
+
var cartCount = await _cartCountForReq(req);
|
|
5133
|
+
return {
|
|
5134
|
+
code: code,
|
|
5135
|
+
link: link,
|
|
5136
|
+
invitations: invitations,
|
|
5137
|
+
leaderboard: leaderboard,
|
|
5138
|
+
completed_referrals: stats ? stats.completed_referrals : 0,
|
|
5139
|
+
invitations_total: stats ? stats.invitations_total : 0,
|
|
5140
|
+
invitations_signed_up: stats ? stats.invitations_signed_up : 0,
|
|
5141
|
+
notice: opts2.notice || null,
|
|
5142
|
+
notice_kind: opts2.notice_kind || null,
|
|
5143
|
+
shop_name: shopName,
|
|
5144
|
+
cart_count: cartCount,
|
|
5145
|
+
};
|
|
5146
|
+
}
|
|
5147
|
+
|
|
5148
|
+
router.get("/account/referrals", async function (req, res) {
|
|
5149
|
+
var auth = _referralsAuth(req, res); if (!auth) return;
|
|
5150
|
+
var view = await _referralsView(req, auth, {});
|
|
5151
|
+
_send(res, 200, renderReferrals(view));
|
|
5152
|
+
});
|
|
5153
|
+
|
|
5154
|
+
router.post("/account/referrals/code", async function (req, res) {
|
|
5155
|
+
var auth = _referralsAuth(req, res); if (!auth) return;
|
|
5156
|
+
try {
|
|
5157
|
+
await deps.referrals.issueCode({ referrer_customer_id: auth.customer_id });
|
|
5158
|
+
} catch (e) {
|
|
5159
|
+
// An existing active code is the idempotent happy path — the
|
|
5160
|
+
// customer already has one, so just show the page. Any other
|
|
5161
|
+
// refusal re-renders with a generic notice; never a 500 for a
|
|
5162
|
+
// TypeError on the (cookie-derived) customer id.
|
|
5163
|
+
var code = (e && typeof e.code === "string") ? e.code : "";
|
|
5164
|
+
if (code !== "REFERRAL_CODE_ALREADY_ACTIVE" && !(e instanceof TypeError)) {
|
|
5165
|
+
throw e;
|
|
5166
|
+
}
|
|
5167
|
+
if (code !== "REFERRAL_CODE_ALREADY_ACTIVE") {
|
|
5168
|
+
var failView = await _referralsView(req, auth, {
|
|
5169
|
+
notice: "We couldn't create your referral code — please try again.",
|
|
5170
|
+
notice_kind: "error",
|
|
5171
|
+
});
|
|
5172
|
+
return _send(res, 400, renderReferrals(failView));
|
|
5173
|
+
}
|
|
5174
|
+
}
|
|
5175
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/referrals");
|
|
5176
|
+
return res.end ? res.end() : res.send("");
|
|
5177
|
+
});
|
|
5178
|
+
}
|
|
5179
|
+
|
|
4784
5180
|
// Recently viewed — the signed-in customer's newest-first browse
|
|
4785
5181
|
// history. Views are recorded server-side on the (container-rendered)
|
|
4786
5182
|
// PDP; this surface lets the customer review + clear that history.
|
package/package.json
CHANGED