@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/CHANGELOG.md +4 -0
- package/README.md +7 -0
- package/SECURITY.md +29 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/checkout.js +125 -12
- package/lib/order.js +72 -0
- package/lib/referrals.js +44 -0
- package/lib/storefront.js +769 -6
- package/package.json +1 -1
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 ? " · <time datetime=\"" + esc(rdate) + "\">" + esc(rdate) + "</time>" : "") +
|
|
1536
|
+
(red.coupon_code ? " · 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 ? " · <time datetime=\"" + esc(tdate) + "\">" + esc(tdate) + "</time>" : "") +
|
|
1557
|
+
(tx.notes ? " · " + 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 ? " · 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
|
+
") · " + 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
|
-
|
|
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
|
|
3087
|
-
// customer
|
|
3088
|
-
// the gift-card errors carry a
|
|
3089
|
-
// code
|
|
3090
|
-
|
|
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.
|