@blamejs/blamejs-shop 0.1.26 → 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/lib/storefront.js CHANGED
@@ -1413,6 +1413,319 @@ function renderReturns(opts) {
1413
1413
  });
1414
1414
  }
1415
1415
 
1416
+ // Loyalty transaction-type pill — reuses the `pdp__badge` class the
1417
+ // theme already styles. The type is one of the ledger's closed enum
1418
+ // (earn / redeem / expire / adjust / tier-bonus).
1419
+ function _loyaltyTxBadge(type) {
1420
+ var esc = b.template.escapeHtml;
1421
+ return "<span class=\"pdp__badge loyalty-tx--" + esc(String(type)) + "\">" + esc(String(type)) + "</span>";
1422
+ }
1423
+
1424
+ // Human label for a reward `kind` + its value payload. Keeps the
1425
+ // catalog row readable without leaking the raw value_json shape.
1426
+ function _loyaltyRewardValue(reward) {
1427
+ var v = reward.value_json || {};
1428
+ if (reward.kind === "discount_percent") return (Number(v.percent) || 0) + "% off";
1429
+ if (reward.kind === "discount_amount") return pricing.format(Number(v.amount_minor) || 0, "USD") + " off";
1430
+ if (reward.kind === "free_shipping") return "Free shipping";
1431
+ if (reward.kind === "free_product") return "Free product";
1432
+ return reward.kind;
1433
+ }
1434
+
1435
+ // Earn-rule trigger → customer-facing phrase. Operators see slugs;
1436
+ // customers see plain language. Unknown triggers fall back to the raw
1437
+ // trigger so a future enum addition still renders something.
1438
+ var LOYALTY_TRIGGER_LABELS = {
1439
+ per_dollar_spent: "per $1 spent",
1440
+ per_purchase: "per order",
1441
+ per_review: "per review you write",
1442
+ per_referral_redeemed: "per friend you refer",
1443
+ birthday: "on your birthday",
1444
+ signup_bonus: "when you sign up",
1445
+ first_purchase: "on your first order",
1446
+ abandoned_cart_recovered: "when you complete a saved cart",
1447
+ };
1448
+
1449
+ // The signed-in customer's loyalty surface: balance + tier, how points
1450
+ // are earned (active earn rules), the reward catalog (redeem control
1451
+ // when wired), the earn/redeem ledger (paginated), and past
1452
+ // redemptions. Reuses the account/returns layout classes — no new CSS.
1453
+ function renderLoyalty(opts) {
1454
+ opts = opts || {};
1455
+ var esc = b.template.escapeHtml;
1456
+ var bal = opts.balance || { balance: 0, lifetime: 0, tier: "bronze" };
1457
+ var ratio = Number(opts.redemption_points_per_usd) || 100;
1458
+
1459
+ var notice = opts.notice
1460
+ ? "<p class=\"form-notice" + (opts.notice_kind === "error" ? " form-notice--error" : "") + "\" role=\"alert\">" +
1461
+ esc(String(opts.notice)) + "</p>"
1462
+ : "";
1463
+
1464
+ // Stats strip — balance / tier / lifetime / spendable value.
1465
+ var spendableValue = pricing.format(Math.floor((Number(bal.balance) || 0) * 100 / ratio), "USD");
1466
+ var stats =
1467
+ "<dl class=\"account-dash__stats\">" +
1468
+ "<div><dt>Points balance</dt><dd>" + esc(String(Number(bal.balance) || 0)) + "</dd></div>" +
1469
+ "<div><dt>Tier</dt><dd>" + esc(String(bal.tier || "bronze")) + "</dd></div>" +
1470
+ "<div><dt>Lifetime points</dt><dd>" + esc(String(Number(bal.lifetime) || 0)) + "</dd></div>" +
1471
+ "<div><dt>Worth</dt><dd>" + esc(spendableValue) + "</dd></div>" +
1472
+ "</dl>";
1473
+
1474
+ // How points are earned.
1475
+ var rules = opts.earn_rules || [];
1476
+ var earnInner = "";
1477
+ for (var i = 0; i < rules.length; i += 1) {
1478
+ var rule = rules[i];
1479
+ var label = LOYALTY_TRIGGER_LABELS[rule.trigger] || rule.trigger;
1480
+ earnInner +=
1481
+ "<li class=\"return-card\"><div class=\"return-card__head\">" +
1482
+ "<span>" + esc(String(rule.points_per_unit)) + " points " + esc(label) + "</span>" +
1483
+ "</div></li>";
1484
+ }
1485
+ var earnSection = earnInner
1486
+ ? "<h2 class=\"pdp__variants-title\">How you earn points</h2><ul class=\"return-list\">" + earnInner + "</ul>"
1487
+ : "<h2 class=\"pdp__variants-title\">How you earn points</h2>" +
1488
+ "<p class=\"return-empty\">Earn points on every order — your balance grows as you shop.</p>";
1489
+
1490
+ // Reward catalog + redeem control.
1491
+ var rewards = opts.rewards || [];
1492
+ var rewardSection = "";
1493
+ if (rewards.length) {
1494
+ var rewardItems = "";
1495
+ for (var r = 0; r < rewards.length; r += 1) {
1496
+ var rw = rewards[r];
1497
+ var affordable = (Number(bal.balance) || 0) >= Number(rw.point_cost);
1498
+ var action;
1499
+ if (opts.can_redeem) {
1500
+ action = "<form method=\"post\" action=\"/account/loyalty/redeem\">" +
1501
+ "<input type=\"hidden\" name=\"reward_slug\" value=\"" + esc(rw.slug) + "\">" +
1502
+ "<button type=\"submit\" class=\"btn-primary\"" + (affordable ? "" : " disabled") + ">" +
1503
+ (affordable ? "Redeem" : "Not enough points") + "</button></form>";
1504
+ } else {
1505
+ action = "";
1506
+ }
1507
+ rewardItems +=
1508
+ "<li class=\"return-card\"><div class=\"return-card__head\">" +
1509
+ "<span class=\"return-card__rma\">" + esc(rw.title) + "</span>" +
1510
+ "<span class=\"pdp__badge\">" + esc(_loyaltyRewardValue(rw)) + "</span>" +
1511
+ "</div>" +
1512
+ "<p class=\"return-card__meta\">" + esc(String(rw.point_cost)) + " points</p>" +
1513
+ action +
1514
+ "</li>";
1515
+ }
1516
+ rewardSection =
1517
+ "<h2 class=\"pdp__variants-title\">Redeem your points</h2>" +
1518
+ "<ul class=\"return-list\">" + rewardItems + "</ul>";
1519
+ }
1520
+
1521
+ // Past redemptions.
1522
+ var reds = opts.redemptions || [];
1523
+ var redSection = "";
1524
+ if (reds.length) {
1525
+ var redItems = "";
1526
+ for (var d = 0; d < reds.length; d += 1) {
1527
+ var red = reds[d];
1528
+ var rdate = red.redeemed_at ? new Date(Number(red.redeemed_at)).toISOString().slice(0, 10) : "";
1529
+ redItems +=
1530
+ "<li class=\"return-card\"><div class=\"return-card__head\">" +
1531
+ "<span class=\"return-card__rma\">" + esc(red.reward_slug) + "</span>" +
1532
+ "<span class=\"pdp__badge loyalty-tx--" + esc(String(red.status)) + "\">" + esc(String(red.status)) + "</span>" +
1533
+ "</div>" +
1534
+ "<p class=\"return-card__meta\">" + esc(String(red.points_debited)) + " points" +
1535
+ (rdate ? " &middot; <time datetime=\"" + esc(rdate) + "\">" + esc(rdate) + "</time>" : "") +
1536
+ (red.coupon_code ? " &middot; code <code>" + esc(red.coupon_code) + "</code>" : "") +
1537
+ "</p></li>";
1538
+ }
1539
+ redSection = "<h2 class=\"pdp__variants-title\">Your redemptions</h2><ul class=\"return-list\">" + redItems + "</ul>";
1540
+ }
1541
+
1542
+ // Earn/redeem ledger (paginated).
1543
+ var hist = opts.history || [];
1544
+ var histInner = "";
1545
+ for (var h = 0; h < hist.length; h += 1) {
1546
+ var tx = hist[h];
1547
+ var tdate = tx.occurred_at ? new Date(Number(tx.occurred_at)).toISOString().slice(0, 10) : "";
1548
+ var pts = Number(tx.points) || 0;
1549
+ var ptsStr = (pts > 0 ? "+" : "") + pts;
1550
+ histInner +=
1551
+ "<li class=\"return-card\"><div class=\"return-card__head\">" +
1552
+ _loyaltyTxBadge(tx.transaction_type) +
1553
+ "<span class=\"return-card__rma\">" + esc(ptsStr) + " points</span>" +
1554
+ "</div>" +
1555
+ "<p class=\"return-card__meta\">" + esc(String(tx.source || "")) +
1556
+ (tdate ? " &middot; <time datetime=\"" + esc(tdate) + "\">" + esc(tdate) + "</time>" : "") +
1557
+ (tx.notes ? " &middot; " + esc(String(tx.notes)) : "") +
1558
+ "</p></li>";
1559
+ }
1560
+ var historySection;
1561
+ if (histInner) {
1562
+ var more = opts.history_next_cursor != null
1563
+ ? "<p class=\"loyalty-more\"><a class=\"btn-secondary\" href=\"/account/loyalty?cursor=" +
1564
+ esc(String(opts.history_next_cursor)) + "\">Older activity</a></p>"
1565
+ : "";
1566
+ historySection = "<h2 class=\"pdp__variants-title\">Activity</h2><ul class=\"return-list\">" + histInner + "</ul>" + more;
1567
+ } else {
1568
+ historySection = "<h2 class=\"pdp__variants-title\">Activity</h2>" +
1569
+ "<p class=\"return-empty\">No points activity yet. Place an order to start earning.</p>";
1570
+ }
1571
+
1572
+ var body =
1573
+ "<section class=\"account-returns\">" +
1574
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
1575
+ "<li><a href=\"/account\">Account</a></li>" +
1576
+ "<li aria-current=\"page\">Rewards</li>" +
1577
+ "</ol></nav>" +
1578
+ "<h1 class=\"account-returns__title\">Rewards</h1>" +
1579
+ notice +
1580
+ stats +
1581
+ rewardSection +
1582
+ earnSection +
1583
+ redSection +
1584
+ historySection +
1585
+ "</section>";
1586
+
1587
+ return _wrap({
1588
+ title: "Rewards",
1589
+ shop_name: opts.shop_name || "blamejs.shop",
1590
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
1591
+ theme_css: opts.theme_css,
1592
+ body: body,
1593
+ });
1594
+ }
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 ? " &middot; 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
+
1416
1729
  // Subscription status pill — mirrors `_returnStatusBadge`. The status
1417
1730
  // string is one of Stripe's enum values (active, trialing, past_due,
1418
1731
  // canceled, …), surfaced as a CSS-classed badge the theme can style.
@@ -1748,6 +2061,28 @@ var CHECKOUT_PAGE =
1748
2061
  " </form>\n" +
1749
2062
  "</section>\n";
1750
2063
 
2064
+ // Redeem-points-at-checkout field — appended to the checkout form for a
2065
+ // signed-in customer with a spendable balance. The customer types how
2066
+ // many points to spend; the server caps the credit at the order total
2067
+ // and at the balance (the `loyalty_redeem_points` field rides the same
2068
+ // POST as the gift-card code). The max attribute is advisory client-
2069
+ // side polish; the backend is authoritative.
2070
+ function _loyaltyCheckoutField(bal, perUsd) {
2071
+ var esc = b.template.escapeHtml;
2072
+ var points = Number(bal.balance) || 0;
2073
+ var ratio = Number(perUsd) || 100;
2074
+ // Minor-unit value of the full balance (points / ratio dollars), for
2075
+ // the helper line. Floored to a whole point's worth.
2076
+ var worth = pricing.format(Math.floor((points * 100) / ratio), "USD");
2077
+ return "<div class=\"form-row\"><label class=\"form-field\">" +
2078
+ "<span class=\"form-field__label\">Redeem loyalty points <span class=\"small\">(optional)</span></span>" +
2079
+ "<input type=\"number\" name=\"loyalty_redeem_points\" min=\"0\" step=\"1\" max=\"" + points + "\" " +
2080
+ "inputmode=\"numeric\" autocomplete=\"off\" placeholder=\"0\">" +
2081
+ "<span class=\"form-field__req\">You have " + esc(String(points)) + " points (worth " + esc(worth) +
2082
+ ") &middot; " + esc(String(ratio)) + " points = $1</span>" +
2083
+ "</label></div>";
2084
+ }
2085
+
1751
2086
  function renderCheckoutForm(opts) {
1752
2087
  if (!opts) throw new TypeError("storefront.renderCheckoutForm: opts required");
1753
2088
  var lines = opts.lines || [];
@@ -1764,6 +2099,19 @@ function renderCheckoutForm(opts) {
1764
2099
  });
1765
2100
  }
1766
2101
  var body = _render(CHECKOUT_PAGE, { subtotal: subtotal });
2102
+ // Signed-in customer with a spendable points balance — surface a
2103
+ // redeem-at-checkout field. The block is appended as raw HTML (the
2104
+ // balance + value are numbers we control, the conversion ratio is the
2105
+ // ledger's own constant) so it slots into the existing form via a
2106
+ // small client island that copies the field into the POST. Rendered
2107
+ // only when there's a balance to spend; absent that the checkout is
2108
+ // unchanged for guests + zero-balance customers.
2109
+ if (opts.loyalty_balance && opts.loyalty_balance.balance > 0) {
2110
+ body = body.replace(
2111
+ "</form>",
2112
+ _loyaltyCheckoutField(opts.loyalty_balance, opts.loyalty_points_per_usd) + "</form>",
2113
+ );
2114
+ }
1767
2115
  // When PayPal is configured, append its button below the card form. The
1768
2116
  // block is built as raw HTML (appended after the strict render) so the SDK
1769
2117
  // script + handlers survive; the client-id is the only interpolation and is
@@ -2402,6 +2750,14 @@ var PAY_COOKIE_NAME = "shop_pay";
2402
2750
  // SameSite=Lax so it survives the provider's top-level GET redirect back.
2403
2751
  var OAUTH_COOKIE_NAME = "shop_oauth";
2404
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
+
2405
2761
  // Shape of a valid session id — mirrors cart.js's SESSION_ID_RE.
2406
2762
  var SID_SHAPE_RE = /^[A-Za-z0-9_-]{16,64}$/;
2407
2763
 
@@ -2471,6 +2827,22 @@ function _readChallengeEnv(req) {
2471
2827
  try { return JSON.parse(raw); } catch (_e) { return null; }
2472
2828
  }
2473
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
+
2474
2846
  // ---- account-page renderers --------------------------------------------
2475
2847
 
2476
2848
  var ACCOUNT_LOGIN_PAGE =
@@ -2569,6 +2941,8 @@ var ACCOUNT_DASH_PAGE =
2569
2941
  " <a class=\"btn-secondary\" href=\"/account/recently-viewed\">Recently viewed</a>\n" +
2570
2942
  " <a class=\"btn-secondary\" href=\"/account/addresses\">Addresses</a>\n" +
2571
2943
  " <a class=\"btn-secondary\" href=\"/account/returns\">Returns</a>\n" +
2944
+ " <a class=\"btn-secondary\" href=\"/account/loyalty\">Rewards</a>\n" +
2945
+ " <a class=\"btn-secondary\" href=\"/account/referrals\">Refer a friend</a>\n" +
2572
2946
  " <a class=\"btn-secondary\" href=\"/account/subscriptions\">Subscriptions</a>\n" +
2573
2947
  " <form method=\"post\" action=\"/account/logout\"><button type=\"submit\" class=\"btn-ghost\">Sign out</button></form>\n" +
2574
2948
  " </div>\n" +
@@ -2694,6 +3068,20 @@ function mount(router, deps) {
2694
3068
  res.end ? res.end(html) : res.send(html);
2695
3069
  }
2696
3070
 
3071
+ // Defensive request-shape reader for the optional loyalty-points
3072
+ // redeem field on the checkout form. Returns undefined for missing /
3073
+ // empty / zero / non-numeric input (checkout treats undefined as "no
3074
+ // redemption"); a positive integer otherwise. The backend's
3075
+ // _resolveLoyaltyCredit is authoritative on balance + cap — this only
3076
+ // shapes the wire value so a blank field doesn't become a "0 points"
3077
+ // validation error.
3078
+ function _parseRedeemPoints(raw) {
3079
+ if (raw == null || raw === "") return undefined;
3080
+ var n = parseInt(String(raw), 10);
3081
+ if (!Number.isFinite(n) || n <= 0) return undefined;
3082
+ return n;
3083
+ }
3084
+
2697
3085
  // Cart-count read shared across every handler that wraps a page in
2698
3086
  // the layout — the header's cart pill renders the count. Returns 0
2699
3087
  // for visitors with no session cookie. Defined at the top of mount
@@ -2708,6 +3096,23 @@ function mount(router, deps) {
2708
3096
  return lines.length;
2709
3097
  }
2710
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
+
2711
3116
  // The signed-in customer's sealed-cookie envelope, or null. Shared by
2712
3117
  // the PDP view recorder (mounted outside the `if (deps.customers)`
2713
3118
  // block) and the account routes inside it, so there's one auth-cookie
@@ -3021,7 +3426,23 @@ function mount(router, deps) {
3021
3426
  return res.end ? res.end() : res.send("");
3022
3427
  }
3023
3428
  var totals = pricing.totals(c, lines, {});
3024
- _send(res, 200, renderCheckoutForm({ lines: lines, totals: totals, shop_name: shopName, theme: theme, paypal_client_id: deps.paypal ? deps.paypal_client_id : null }));
3429
+ // Loyalty balance only for a signed-in customer with loyalty
3430
+ // wired. A read failure (table not migrated) degrades to no
3431
+ // redeem field rather than 500-ing checkout.
3432
+ var loyaltyBalance = null;
3433
+ if (deps.loyalty) {
3434
+ var loyAuth = _currentCustomerEnv(req);
3435
+ if (loyAuth) {
3436
+ try { loyaltyBalance = await deps.loyalty.balance(loyAuth.customer_id); }
3437
+ catch (_e) { loyaltyBalance = null; }
3438
+ }
3439
+ }
3440
+ _send(res, 200, renderCheckoutForm({
3441
+ lines: lines, totals: totals, shop_name: shopName, theme: theme,
3442
+ paypal_client_id: deps.paypal ? deps.paypal_client_id : null,
3443
+ loyalty_balance: loyaltyBalance,
3444
+ loyalty_points_per_usd: deps.loyalty ? deps.loyalty.REDEMPTION_POINTS_PER_USD : null,
3445
+ }));
3025
3446
  });
3026
3447
 
3027
3448
  router.post("/checkout", async function (req, res) {
@@ -3063,6 +3484,7 @@ function mount(router, deps) {
3063
3484
  selected_shipping_id: defaultShipId || "std",
3064
3485
  customer: { email: body.email, name: body.name },
3065
3486
  gift_card_code: body.gift_card_code || undefined,
3487
+ loyalty_redeem_points: _parseRedeemPoints(body.loyalty_redeem_points),
3066
3488
  idempotency_key: "checkout:" + c.id + ":" + b.uuid.v7(),
3067
3489
  });
3068
3490
  // When a gift card fully covered the order there's no Stripe
@@ -3083,11 +3505,13 @@ function mount(router, deps) {
3083
3505
  res.setHeader && res.setHeader("location", "/pay/" + result.order.id);
3084
3506
  return res.end ? res.end() : res.send("");
3085
3507
  } catch (e) {
3086
- // A bad customer input (malformed shape OR a gift-card code the
3087
- // customer typed that can't be applied) is a 400, not a 500 —
3088
- // the gift-card errors carry a GIFTCARD_* code so a fat-fingered
3089
- // code re-prompts rather than 500-ing the checkout.
3090
- var clientErr = (e instanceof TypeError) || (e && typeof e.code === "string" && e.code.indexOf("GIFTCARD_") === 0);
3508
+ // A bad customer input (malformed shape, a gift-card code, or a
3509
+ // loyalty-points request the customer entered that can't be
3510
+ // applied) is a 400, not a 500 — the gift-card errors carry a
3511
+ // GIFTCARD_* code and the loyalty errors a LOYALTY_* code so a
3512
+ // fat-fingered value re-prompts rather than 500-ing checkout.
3513
+ var code = (e && typeof e.code === "string") ? e.code : "";
3514
+ var clientErr = (e instanceof TypeError) || code.indexOf("GIFTCARD_") === 0 || code.indexOf("LOYALTY_") === 0;
3091
3515
  res.status(clientErr ? 400 : 500);
3092
3516
  var msg = (e && e.message) || "checkout failed";
3093
3517
  return res.end ? res.end(msg) : res.send(msg);
@@ -3382,6 +3806,33 @@ function mount(router, deps) {
3382
3806
  return res.end ? res.end(msg) : res.send(msg);
3383
3807
  }
3384
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
+
3385
3836
  function _readJsonBody(req) {
3386
3837
  // b.middleware.bodyParser leaves JSON in req.body when the
3387
3838
  // request's content-type is application/json. Some test
@@ -3444,6 +3895,12 @@ function mount(router, deps) {
3444
3895
  customer_id: customer.id,
3445
3896
  challenge: startOpts.challenge,
3446
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,
3447
3904
  });
3448
3905
  res.status(200);
3449
3906
  res.setHeader && res.setHeader("content-type", "application/json");
@@ -3492,6 +3949,18 @@ function mount(router, deps) {
3492
3949
  customer_id: env.customer_id,
3493
3950
  exp: Date.now() + b.constants.TIME.days(14),
3494
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
+ }
3495
3964
  res.status(200);
3496
3965
  return res.end ? res.end("ok") : res.send("ok");
3497
3966
  } catch (e) {
@@ -3767,6 +4236,14 @@ function mount(router, deps) {
3767
4236
  await deps.order.linkGuestOrdersByEmailHash(rv.customer.id, deps.customers.hashEmail(claims.email));
3768
4237
  } catch (_e) { /* best-effort reconciliation; sign-in succeeds regardless */ }
3769
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
+ }
3770
4247
  _setAuthCookie(res, { customer_id: rv.customer.id, exp: Date.now() + b.constants.TIME.days(14) });
3771
4248
  res.status(303); res.setHeader && res.setHeader("location", "/account");
3772
4249
  return res.end ? res.end() : res.send("");
@@ -3873,6 +4350,13 @@ function mount(router, deps) {
3873
4350
  await deps.order.linkGuestOrdersByEmailHash(rv.customer.id, deps.customers.hashEmail(claims.email));
3874
4351
  } catch (_e) { /* best-effort reconciliation; sign-in succeeds regardless */ }
3875
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
+ }
3876
4360
  _setAuthCookie(res, { customer_id: rv.customer.id, exp: Date.now() + b.constants.TIME.days(14) });
3877
4361
  res.status(303); res.setHeader && res.setHeader("location", "/account");
3878
4362
  return res.end ? res.end() : res.send("");
@@ -4414,6 +4898,285 @@ function mount(router, deps) {
4414
4898
  });
4415
4899
  }
4416
4900
 
4901
+ // Loyalty — the signed-in customer's points balance + tier, the
4902
+ // earn/redeem ledger, how points are earned, and (when a reward
4903
+ // catalog + redemption primitive are wired) a redeem-a-reward
4904
+ // control. Login-gated; a read failure on any optional sub-read
4905
+ // degrades that section to empty rather than 500-ing the page.
4906
+ if (deps.loyalty) {
4907
+ function _loyaltyAuth(req, res) {
4908
+ var auth;
4909
+ try { auth = _currentCustomer(req); }
4910
+ catch (e) {
4911
+ if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
4912
+ throw e;
4913
+ }
4914
+ if (!auth) {
4915
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
4916
+ res.end ? res.end() : res.send("");
4917
+ return null;
4918
+ }
4919
+ return auth;
4920
+ }
4921
+
4922
+ // Build the full /account/loyalty render context for a customer.
4923
+ // `opts2.cursor` is the optional history-page cursor (epoch-ms).
4924
+ // Each optional read (earn rules, reward catalog, redemptions) is
4925
+ // best-effort so a not-migrated table degrades that panel rather
4926
+ // than the page.
4927
+ async function _loyaltyView(req, auth, opts2) {
4928
+ opts2 = opts2 || {};
4929
+ var bal = await deps.loyalty.balance(auth.customer_id);
4930
+ var hist = await deps.loyalty.history(auth.customer_id, { limit: 20, cursor: opts2.cursor });
4931
+ var rules = [];
4932
+ if (deps.loyaltyEarnRules) {
4933
+ try { rules = await deps.loyaltyEarnRules.listRules({ active_only: true, limit: 50 }); }
4934
+ catch (_e) { rules = []; }
4935
+ }
4936
+ var rewards = [];
4937
+ var redemptions = [];
4938
+ if (deps.loyaltyRedemption) {
4939
+ try { rewards = await deps.loyaltyRedemption.listRewards({ active_only: true, limit: 50 }); }
4940
+ catch (_e) { rewards = []; }
4941
+ try {
4942
+ var rpage = await deps.loyaltyRedemption.redemptionsForCustomer(auth.customer_id, { limit: 20 });
4943
+ redemptions = rpage.rows;
4944
+ } catch (_e) { redemptions = []; }
4945
+ }
4946
+ var cartCount = await _cartCountForReq(req);
4947
+ return {
4948
+ balance: bal,
4949
+ tiers: deps.loyalty.TIERS,
4950
+ tier_thresholds: deps.loyalty.TIER_THRESHOLDS,
4951
+ redemption_points_per_usd: deps.loyalty.REDEMPTION_POINTS_PER_USD,
4952
+ history: hist.rows,
4953
+ history_next_cursor: hist.next_cursor,
4954
+ earn_rules: rules,
4955
+ rewards: rewards,
4956
+ redemptions: redemptions,
4957
+ can_redeem: !!deps.loyaltyRedemption,
4958
+ notice: opts2.notice || null,
4959
+ notice_kind: opts2.notice_kind || null,
4960
+ shop_name: shopName,
4961
+ cart_count: cartCount,
4962
+ };
4963
+ }
4964
+
4965
+ router.get("/account/loyalty", async function (req, res) {
4966
+ var auth = _loyaltyAuth(req, res); if (!auth) return;
4967
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4968
+ var cursorRaw = url && url.searchParams.get("cursor");
4969
+ var cursor;
4970
+ if (cursorRaw != null && cursorRaw !== "") {
4971
+ var n = parseInt(cursorRaw, 10);
4972
+ // A malformed cursor degrades to the first page (the lib
4973
+ // would TypeError on a non-integer) — never 500 the page.
4974
+ cursor = Number.isFinite(n) && n >= 0 ? n : undefined;
4975
+ }
4976
+ var view = await _loyaltyView(req, auth, { cursor: cursor });
4977
+ _send(res, 200, renderLoyalty(view));
4978
+ });
4979
+
4980
+ // Redeem a reward from the catalog. Mounts only when the
4981
+ // redemption primitive is wired. The reward debits points via the
4982
+ // composed loyalty ledger; insufficient balance / cap reached /
4983
+ // not-redeemable surface as a 400 re-render, never a 500.
4984
+ if (deps.loyaltyRedemption) {
4985
+ router.post("/account/loyalty/redeem", async function (req, res) {
4986
+ var auth = _loyaltyAuth(req, res); if (!auth) return;
4987
+ var body = req.body || {};
4988
+ var rewardSlug = body.reward_slug;
4989
+ try {
4990
+ await deps.loyaltyRedemption.redeemForCustomer({
4991
+ customer_id: auth.customer_id,
4992
+ reward_slug: rewardSlug,
4993
+ });
4994
+ } catch (e) {
4995
+ // Customer-facing refusals (TypeError on a bad slug,
4996
+ // LOYALTY_INSUFFICIENT_BALANCE, REWARD_NOT_REDEEMABLE,
4997
+ // REDEMPTION_CAP_REACHED) re-render the page with the
4998
+ // reason; anything else propagates as a 500.
4999
+ var code = (e && typeof e.code === "string") ? e.code : "";
5000
+ var clientErr = (e instanceof TypeError)
5001
+ || code === "LOYALTY_INSUFFICIENT_BALANCE"
5002
+ || code === "REWARD_NOT_REDEEMABLE"
5003
+ || code === "REDEMPTION_CAP_REACHED";
5004
+ if (!clientErr) throw e;
5005
+ var msg;
5006
+ if (code === "LOYALTY_INSUFFICIENT_BALANCE") msg = "You don't have enough points for that reward yet.";
5007
+ else if (code === "REWARD_NOT_REDEEMABLE") msg = "That reward isn't available right now.";
5008
+ else if (code === "REDEMPTION_CAP_REACHED") msg = "You've reached the redemption limit for that reward.";
5009
+ else msg = "We couldn't redeem that reward — please pick one from the list.";
5010
+ var failView = await _loyaltyView(req, auth, { notice: msg, notice_kind: "error" });
5011
+ return _send(res, 400, renderLoyalty(failView));
5012
+ }
5013
+ res.status(303); res.setHeader && res.setHeader("location", "/account/loyalty");
5014
+ return res.end ? res.end() : res.send("");
5015
+ });
5016
+ }
5017
+ }
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
+
4417
5180
  // Recently viewed — the signed-in customer's newest-first browse
4418
5181
  // history. Views are recorded server-side on the (container-rendered)
4419
5182
  // PDP; this surface lets the customer review + clear that history.