@blamejs/blamejs-shop 0.3.26 → 0.3.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/lib/asset-manifest.json +1 -1
- package/lib/storefront.js +334 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.3.x
|
|
10
10
|
|
|
11
|
+
- v0.3.28 (2026-05-31) — **Customers can now see their store-credit balance and history.** Operators could grant or deduct a customer's store credit, but the customer had no way to see it — the balance only ever surfaced as a silent discount at checkout. A new Store credit page in the account area shows the current balance, a callout for any credit about to expire, and the full ledger of credits, deductions, and expirations with amounts, reasons, and dates. The page is read-only and shows only the signed-in customer's own balance. **Added:** *Store-credit wallet in the account area* — A Store credit page lists a customer's current balance, any credit expiring soon, and the full history of credits, deductions, and expirations — each with its amount, the reason, and the date. It is reachable from the account dashboard and reflects the store credit an operator grants from the customer screen, so a shopper can finally see credit they have been given instead of only discovering it at checkout. The page is read-only and scoped to the signed-in customer.
|
|
12
|
+
|
|
13
|
+
- v0.3.27 (2026-05-31) — **Shared product and page links now show a preview image, and category pages get breadcrumb rich results.** Social-share previews and search rich results were emitting relative image paths, which Facebook, Slack, Twitter, iMessage, and Google all drop — so a shared product or page link showed no preview image, and product rich results had no image. Every og:image, twitter:image, and Product/Article structured-data image is now a fully-qualified URL built from the page's own origin. Collection and category pages, which already showed an on-page breadcrumb, now also emit BreadcrumbList structured data so the trail can appear in search results, matching what product pages already did. And operator-supplied page keywords or announcement text containing a dollar-sign sequence now render literally instead of being garbled. **Fixed:** *Share and rich-result images are now absolute URLs* — The og:image, twitter:image, and the image in Product and blog-article structured data were relative paths, so social platforms and Google could not load them — a shared link showed no preview image. They are now fully-qualified against the page's origin on every page, in both the edge and container renderers, so shares unfurl with the right image and product rich results carry one. Images an operator already hosts at an absolute URL pass through unchanged. · *Breadcrumb structured data on collection and category pages* — Collection and category pages showed a breadcrumb trail on the page but did not expose it as BreadcrumbList structured data, so search engines could not render the trail in results. They now emit it with absolute URLs, the same way product pages already do. · *Dollar-sign sequences in page metadata render literally* — Page meta-keywords and announcement-bar text containing a dollar-sign sequence (for example a price written with a special character) could be garbled in the page head because of how the value was substituted into the template. The substitution now treats the value as literal text, so it renders exactly as entered.
|
|
14
|
+
|
|
11
15
|
- v0.3.26 (2026-05-31) — **Customers can raise support tickets and operators work them from a queue.** The support-ticket system had a complete backend — threaded messages, assignment, tags, and a status workflow — but no way in or out: a shopper could not open a ticket and an operator could not see one. Both sides are now wired. A logged-in customer can raise a ticket from their account (with an optional link to one of their own orders), see the thread, and reply while it is open. Operators get a Support queue in the console with an unassigned-triage view, the full thread for each ticket, and controls to reply (with an internal-note option that the customer never sees), assign, tag, and move the ticket through its status workflow. A customer only ever sees their own tickets, and replying to a closed ticket is refused. **Added:** *Customer support tickets* — A Support section in the account area lets a signed-in customer open a ticket (subject, message, category, and an optional reference to one of their own orders), list their tickets with status, read the full message thread, and reply while the ticket is open. Internal operator notes are never shown to the customer, a customer can only see and reply to their own tickets, and a reply to a closed ticket is refused. · *Support queue in the admin console* — A Support screen lists inbound tickets by status with an unassigned-triage view. Opening a ticket shows the full thread, the requester, the current status, the assignee, and tags. From there an operator can reply (optionally as an internal note), assign the ticket, tag it, and move it through its status workflow — in progress, waiting on the customer, resolved, closed, or reopened.
|
|
12
16
|
|
|
13
17
|
- v0.3.25 (2026-05-30) — **Open an individual customer to see their orders and manage store credit, notes, and loyalty.** The customers list was view-only — there was no way to open a single customer. Each customer now has a detail screen showing their identity, recent orders (linked), store-credit balance with a grant/deduct control, loyalty balance and tier (with a link to adjust points), internal operator notes, and the segments they belong to. A store-credit grant or deduction requires a reason and is written to the store-credit ledger, an over-deduction is refused, and segment membership is shown read-only because it is computed from purchase behavior rather than set by hand. **Added:** *Customer detail screen* — Opening a customer from the roster now shows their record, recent orders, store-credit balance with a reason-gated grant/deduct (recorded in the store-credit ledger; an over-deduction is refused), their loyalty balance and tier with a link to adjust points, and an internal notes log you can add to. The segments a customer belongs to are listed read-only, since membership is derived from their behavior. Customer identity fields remain read-only by design.
|
package/lib/asset-manifest.json
CHANGED
package/lib/storefront.js
CHANGED
|
@@ -693,7 +693,6 @@ function _wrap(opts) {
|
|
|
693
693
|
var ogType = opts.og_type || "website";
|
|
694
694
|
var ogTitle = opts.og_title || (opts.title ? opts.title + " — " + shopName : shopName);
|
|
695
695
|
var ogDescription = opts.og_description || "Open-source ecommerce framework built on blamejs. Server-rendered HTML, post-quantum crypto, zero npm runtime dependencies.";
|
|
696
|
-
var ogImage = opts.og_image || "/assets/brand/logo.png";
|
|
697
696
|
// Absolute request URL drives both `og:url` (full URL incl. query) and
|
|
698
697
|
// the canonical link (query stripped — the canonical names the page,
|
|
699
698
|
// not the filtered/sorted view). Renderers thread `opts.canonical_url`
|
|
@@ -702,6 +701,14 @@ function _wrap(opts) {
|
|
|
702
701
|
// rather than emit a bogus host-less URL.
|
|
703
702
|
var canonicalUrl = opts.canonical_url || "";
|
|
704
703
|
var ogUrl = opts.og_url || canonicalUrl;
|
|
704
|
+
// og:image / twitter:image carry a FULLY-QUALIFIED URL — a relative
|
|
705
|
+
// `/assets/...` (the brand-logo default, or a product hero) is dropped by
|
|
706
|
+
// every social-share crawler (Facebook / Slack / Twitter / iMessage) and
|
|
707
|
+
// by Google's rich result. Absolutize against the page origin so the
|
|
708
|
+
// share preview resolves; an operator-hosted `http(s)://` image passes
|
|
709
|
+
// through unchanged. Every container page funnels through `_wrap`, so
|
|
710
|
+
// this is the single absolutization site for the storefront's meta tags.
|
|
711
|
+
var ogImage = _absolutizeOgImage(opts.og_image || "/assets/brand/logo.png", canonicalUrl, shopName);
|
|
705
712
|
// Multi-currency display switcher — populated only when the operator
|
|
706
713
|
// configured >1 display currency (opts.currency_options). The block is
|
|
707
714
|
// empty otherwise, so a single-currency store renders unchanged.
|
|
@@ -796,12 +803,18 @@ function _wrap(opts) {
|
|
|
796
803
|
var assembled = _render(LAYOUT, vars)
|
|
797
804
|
.replace("RAW_CSS_INTEGRITY", themeCssIntegrity)
|
|
798
805
|
.replace("RAW_ROBOTS_META", robotsMeta)
|
|
799
|
-
.replace("RAW_ANNOUNCEMENT_BAR", announcementBarHtml)
|
|
800
806
|
.replace("RAW_CONSENT_SCRIPT", _islandScript("consent.js", { id: "consent-island", policy: _activeConsentPolicy }))
|
|
801
807
|
.replace("RAW_CART_COUNT_SCRIPT", _islandScript("cart-count.js", { id: "cart-count-island" }))
|
|
802
808
|
.replace("RAW_ANNOUNCEMENT_SCRIPT", announcementScript)
|
|
803
809
|
.replace("RAW_CURRENCY_SWITCHER", switcherHtml)
|
|
804
810
|
.replace("RAW_LOCALE_SWITCHER", localeCtx.switcher_html || "");
|
|
811
|
+
// The announcement bar carries operator-supplied message text (HTML-
|
|
812
|
+
// escaped, but `$` is not an escaped character), so splice it via the
|
|
813
|
+
// replacer-function helper — a `$&` / `` $` `` / `$N` in the message must
|
|
814
|
+
// land literally, not trigger `String.replace`'s dollar substitution.
|
|
815
|
+
// Matches the edge renderers' `spliceRaw` so the dual-render stays
|
|
816
|
+
// byte-consistent under a `$`-bearing announcement. See `_spliceRaw`.
|
|
817
|
+
assembled = _spliceRaw(assembled, "RAW_ANNOUNCEMENT_BAR", announcementBarHtml);
|
|
805
818
|
// The body is RAW HTML (already rendered + escaped at the
|
|
806
819
|
// per-fragment level). The placeholder swap is post-render so the
|
|
807
820
|
// outer renderer's HTML-escape doesn't double-escape the inner
|
|
@@ -1204,6 +1217,30 @@ function _absoluteBase(canonicalUrl, shopName) {
|
|
|
1204
1217
|
return "https://" + String(shopName || "blamejs.shop").replace(/^https?:\/\//, "");
|
|
1205
1218
|
}
|
|
1206
1219
|
|
|
1220
|
+
// Absolutize an og:image / twitter:image / JSON-LD image value against the
|
|
1221
|
+
// page origin. A relative `/assets/...` path (the brand-logo default, or a
|
|
1222
|
+
// hero R2 key joined onto the asset prefix) becomes `<origin>/assets/...`
|
|
1223
|
+
// so every social-share crawler and rich-result fetch resolves it — a
|
|
1224
|
+
// relative path is dropped by Facebook / Slack / Twitter / iMessage and by
|
|
1225
|
+
// Google's product rich result. An already-absolute `http(s)://` value is
|
|
1226
|
+
// left unchanged (an operator-hosted image), and a value that is neither a
|
|
1227
|
+
// `/`-rooted path nor an absolute URL is returned as-is (nothing safe to
|
|
1228
|
+
// prefix). Absolutizes only against a reliable origin: with a canonical URL
|
|
1229
|
+
// the request origin is used; without one, the shop-name host is used ONLY
|
|
1230
|
+
// when it is usable as a host (no whitespace) — a display-name shop such as
|
|
1231
|
+
// "Test Shop" would otherwise emit an invalid "https://Test Shop/..." URL,
|
|
1232
|
+
// so the path is left relative (it still resolves against the page on a
|
|
1233
|
+
// crawler fetch). Mirrors the edge's worker/render/_lib.js `absolutizeOgImage`
|
|
1234
|
+
// so the two substrates emit identical absolute image URLs.
|
|
1235
|
+
function _absolutizeOgImage(value, canonicalUrl, shopName) {
|
|
1236
|
+
var v = (value == null) ? "" : String(value);
|
|
1237
|
+
if (/^https?:\/\//i.test(v)) return v;
|
|
1238
|
+
if (v.charAt(0) !== "/") return v;
|
|
1239
|
+
var hasCanonical = typeof canonicalUrl === "string" && canonicalUrl.length > 0;
|
|
1240
|
+
if (!hasCanonical && /\s/.test(String(shopName == null ? "" : shopName))) return v;
|
|
1241
|
+
return _absoluteBase(canonicalUrl, shopName) + v;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1207
1244
|
// Schema.org Organization + WebSite JSON-LD for the home page. Shared by
|
|
1208
1245
|
// the container `renderHome` and mirrored by the edge
|
|
1209
1246
|
// `worker/render/home.js` — keep the two byte-identical. The base URL is
|
|
@@ -2860,9 +2897,25 @@ function renderCollection(opts) {
|
|
|
2860
2897
|
"</header>" +
|
|
2861
2898
|
grid +
|
|
2862
2899
|
"</section>";
|
|
2900
|
+
// BreadcrumbList JSON-LD mirroring the on-page `<nav class="breadcrumb">`
|
|
2901
|
+
// trail (Shop → Collections → this collection). Google's result panel
|
|
2902
|
+
// renders the trail above the title; the `item` URLs are absolute so the
|
|
2903
|
+
// structured data is fully-qualified. Mirrors the PDP's breadcrumb shape.
|
|
2904
|
+
var shopName = opts.shop_name || "blamejs.shop";
|
|
2905
|
+
var absoluteBase = _absoluteBase(opts.canonical_url, shopName);
|
|
2906
|
+
var breadcrumbJsonLd = _jsonLdScript({
|
|
2907
|
+
"@context": "https://schema.org",
|
|
2908
|
+
"@type": "BreadcrumbList",
|
|
2909
|
+
"itemListElement": [
|
|
2910
|
+
{ "@type": "ListItem", "position": 1, "name": "Shop", "item": absoluteBase + "/" },
|
|
2911
|
+
{ "@type": "ListItem", "position": 2, "name": "Collections", "item": absoluteBase + "/collections" },
|
|
2912
|
+
{ "@type": "ListItem", "position": 3, "name": col.title, "item": absoluteBase + "/collections/" + col.slug },
|
|
2913
|
+
],
|
|
2914
|
+
});
|
|
2863
2915
|
return _wrap({
|
|
2864
|
-
title: col.title, shop_name:
|
|
2865
|
-
cart_count: opts.cart_count == null ? 0 : opts.cart_count, theme_css: opts.theme_css,
|
|
2916
|
+
title: col.title, shop_name: shopName,
|
|
2917
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count, theme_css: opts.theme_css,
|
|
2918
|
+
body: body + breadcrumbJsonLd,
|
|
2866
2919
|
canonical_url: opts.canonical_url, og_url: opts.og_url,
|
|
2867
2920
|
});
|
|
2868
2921
|
}
|
|
@@ -2971,15 +3024,40 @@ function renderCategory(opts) {
|
|
|
2971
3024
|
"</section>";
|
|
2972
3025
|
// Per-page meta description: the category's own description when set,
|
|
2973
3026
|
// otherwise a "Shop {category}…" pitch.
|
|
3027
|
+
var shopName = opts.shop_name || "blamejs.shop";
|
|
2974
3028
|
var catMetaDescription = (cat.description && String(cat.description).trim().length)
|
|
2975
3029
|
? String(cat.description)
|
|
2976
|
-
: ("Shop " + cat.title + " at " +
|
|
3030
|
+
: ("Shop " + cat.title + " at " + shopName + ".");
|
|
3031
|
+
// BreadcrumbList JSON-LD mirroring the on-page `<nav class="breadcrumb">`
|
|
3032
|
+
// chain (Shop → Categories → …root→current). Google's result panel
|
|
3033
|
+
// renders the trail above the title; the `item` URLs are absolute so the
|
|
3034
|
+
// structured data is fully-qualified. The breadcrumb chain's last entry
|
|
3035
|
+
// is the current category (rendered as plain text on-page), included here
|
|
3036
|
+
// as the trailing list item with its own URL. Mirrors the PDP shape.
|
|
3037
|
+
var absoluteBase = _absoluteBase(opts.canonical_url, shopName);
|
|
3038
|
+
var crumbItems = [
|
|
3039
|
+
{ "@type": "ListItem", "position": 1, "name": "Shop", "item": absoluteBase + "/" },
|
|
3040
|
+
{ "@type": "ListItem", "position": 2, "name": "Categories", "item": absoluteBase + "/categories" },
|
|
3041
|
+
];
|
|
3042
|
+
for (var bi = 0; bi < crumbs.length; bi += 1) {
|
|
3043
|
+
crumbItems.push({
|
|
3044
|
+
"@type": "ListItem",
|
|
3045
|
+
"position": bi + 3,
|
|
3046
|
+
"name": crumbs[bi].title,
|
|
3047
|
+
"item": absoluteBase + "/categories/" + crumbs[bi].slug,
|
|
3048
|
+
});
|
|
3049
|
+
}
|
|
3050
|
+
var breadcrumbJsonLd = _jsonLdScript({
|
|
3051
|
+
"@context": "https://schema.org",
|
|
3052
|
+
"@type": "BreadcrumbList",
|
|
3053
|
+
"itemListElement": crumbItems,
|
|
3054
|
+
});
|
|
2977
3055
|
return _wrap({
|
|
2978
|
-
title: cat.title, shop_name:
|
|
3056
|
+
title: cat.title, shop_name: shopName,
|
|
2979
3057
|
cart_count: opts.cart_count == null ? 0 : opts.cart_count, theme_css: opts.theme_css,
|
|
2980
3058
|
og_description: catMetaDescription,
|
|
2981
3059
|
canonical_url: opts.canonical_url, og_url: opts.og_url,
|
|
2982
|
-
body: body,
|
|
3060
|
+
body: body + breadcrumbJsonLd,
|
|
2983
3061
|
});
|
|
2984
3062
|
}
|
|
2985
3063
|
|
|
@@ -3547,6 +3625,147 @@ function renderLoyalty(opts) {
|
|
|
3547
3625
|
});
|
|
3548
3626
|
}
|
|
3549
3627
|
|
|
3628
|
+
// Store credit is account-bound and carries no per-row currency — the
|
|
3629
|
+
// ledger stores integer minor units in the shop's single display
|
|
3630
|
+
// currency. The storefront's other account surfaces (loyalty "worth",
|
|
3631
|
+
// the order-history total fallback) format in USD, so the wallet does
|
|
3632
|
+
// too; a future multi-currency store threads the resolved code in here.
|
|
3633
|
+
var STORE_CREDIT_CURRENCY = "USD";
|
|
3634
|
+
|
|
3635
|
+
// A human label for the credit-row provenance enum
|
|
3636
|
+
// (store_credit_ledger.source). Falls back to the raw code so a future
|
|
3637
|
+
// source the renderer hasn't learned still shows something sensible.
|
|
3638
|
+
var STORE_CREDIT_SOURCE_LABELS = {
|
|
3639
|
+
refund: "Refund",
|
|
3640
|
+
goodwill: "Goodwill",
|
|
3641
|
+
promotional: "Promotion",
|
|
3642
|
+
manual: "Manual adjustment",
|
|
3643
|
+
loyalty_redemption: "Loyalty redemption",
|
|
3644
|
+
};
|
|
3645
|
+
|
|
3646
|
+
// A ledger row's amount, signed by its kind: credit adds (+), debit and
|
|
3647
|
+
// expire subtract (−). The amount is always stored positive, so the
|
|
3648
|
+
// sign comes from `kind`. The minor-unit amount is formatted through the
|
|
3649
|
+
// shared `pricing.format` money helper (Intl-backed, ISO 4217 exponent),
|
|
3650
|
+
// never hand-rolled.
|
|
3651
|
+
function _storeCreditSignedAmount(row) {
|
|
3652
|
+
var minor = Number(row.amount_minor) || 0;
|
|
3653
|
+
var money = pricing.format(minor, STORE_CREDIT_CURRENCY);
|
|
3654
|
+
if (row.kind === "credit") return "+" + money;
|
|
3655
|
+
return "−" + money; // U+2212 MINUS SIGN
|
|
3656
|
+
}
|
|
3657
|
+
|
|
3658
|
+
// A ledger row's reason text. Credit rows carry an operator-authored
|
|
3659
|
+
// free-form `source_ref` plus the provenance enum; expire rows carry the
|
|
3660
|
+
// operator's justification in `source_ref` (the primitive writes the
|
|
3661
|
+
// `reason` there). Debit rows settle an order and carry neither — they
|
|
3662
|
+
// read as a plain "Spent". The caller escapes on output.
|
|
3663
|
+
function _storeCreditReasonText(row) {
|
|
3664
|
+
if (row.kind === "credit") {
|
|
3665
|
+
var label = STORE_CREDIT_SOURCE_LABELS[row.source] || String(row.source || "Credit");
|
|
3666
|
+
return row.source_ref ? (label + " — " + String(row.source_ref)) : label;
|
|
3667
|
+
}
|
|
3668
|
+
if (row.kind === "expire") {
|
|
3669
|
+
return row.source_ref ? ("Expired — " + String(row.source_ref)) : "Expired";
|
|
3670
|
+
}
|
|
3671
|
+
return "Spent"; // debit — settles an order
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3674
|
+
// The signed-in customer's store-credit wallet: the current balance, an
|
|
3675
|
+
// expiring-soon callout when any credit is within the warning window,
|
|
3676
|
+
// and the credit/debit/expire ledger (paginated, newest first). A wallet
|
|
3677
|
+
// with zero balance + no history renders a clean empty state, not an
|
|
3678
|
+
// error. Read-only — granting/deducting is operator-only on the admin
|
|
3679
|
+
// customer-detail screen. Every amount, reason, and date is escaped on
|
|
3680
|
+
// output. Reuses the account/returns + loyalty layout classes — no new
|
|
3681
|
+
// CSS.
|
|
3682
|
+
function renderAccountStoreCredit(opts) {
|
|
3683
|
+
opts = opts || {};
|
|
3684
|
+
var esc = b.template.escapeHtml;
|
|
3685
|
+
|
|
3686
|
+
var balanceMinor = Number(opts.balance_minor) || 0;
|
|
3687
|
+
var balanceStr = pricing.format(balanceMinor, STORE_CREDIT_CURRENCY);
|
|
3688
|
+
|
|
3689
|
+
// Balance stat strip.
|
|
3690
|
+
var stats =
|
|
3691
|
+
"<dl class=\"account-dash__stats\">" +
|
|
3692
|
+
"<div><dt>Store credit</dt><dd>" + esc(balanceStr) + "</dd></div>" +
|
|
3693
|
+
"</dl>";
|
|
3694
|
+
|
|
3695
|
+
// Expiring-soon callout — only when a credit row is inside the warning
|
|
3696
|
+
// window. Sums the exposed (still-spendable) amounts and names the
|
|
3697
|
+
// soonest deadline.
|
|
3698
|
+
var expiring = opts.expiring || [];
|
|
3699
|
+
var expiringSection = "";
|
|
3700
|
+
if (expiring.length) {
|
|
3701
|
+
var expMinor = 0;
|
|
3702
|
+
var soonest = null;
|
|
3703
|
+
for (var e = 0; e < expiring.length; e += 1) {
|
|
3704
|
+
expMinor += Number(expiring[e].amount_minor) || 0;
|
|
3705
|
+
var ea = Number(expiring[e].expires_at);
|
|
3706
|
+
if (isFinite(ea) && (soonest == null || ea < soonest)) soonest = ea;
|
|
3707
|
+
}
|
|
3708
|
+
var expStr = pricing.format(expMinor, STORE_CREDIT_CURRENCY);
|
|
3709
|
+
var expDate = soonest != null ? new Date(soonest).toISOString().slice(0, 10) : "";
|
|
3710
|
+
expiringSection =
|
|
3711
|
+
"<p class=\"form-notice\" role=\"status\">" +
|
|
3712
|
+
esc(expStr) + " of your store credit expires soon" +
|
|
3713
|
+
(expDate ? " — use it by <time datetime=\"" + esc(expDate) + "\">" + esc(expDate) + "</time>" : "") +
|
|
3714
|
+
"</p>";
|
|
3715
|
+
}
|
|
3716
|
+
|
|
3717
|
+
// The credit/debit/expire ledger, newest first.
|
|
3718
|
+
var hist = opts.history || [];
|
|
3719
|
+
var histInner = "";
|
|
3720
|
+
for (var h = 0; h < hist.length; h += 1) {
|
|
3721
|
+
var row = hist[h];
|
|
3722
|
+
var tdate = row.occurred_at ? new Date(Number(row.occurred_at)).toISOString().slice(0, 10) : "";
|
|
3723
|
+
var rowExpiry = (row.kind === "credit" && row.expires_at != null)
|
|
3724
|
+
? new Date(Number(row.expires_at)).toISOString().slice(0, 10)
|
|
3725
|
+
: "";
|
|
3726
|
+
histInner +=
|
|
3727
|
+
"<li class=\"return-card\"><div class=\"return-card__head\">" +
|
|
3728
|
+
"<span class=\"pdp__badge store-credit-tx--" + esc(String(row.kind)) + "\">" + esc(String(row.kind)) + "</span>" +
|
|
3729
|
+
"<span class=\"return-card__rma\">" + esc(_storeCreditSignedAmount(row)) + "</span>" +
|
|
3730
|
+
"</div>" +
|
|
3731
|
+
"<p class=\"return-card__meta\">" + esc(_storeCreditReasonText(row)) +
|
|
3732
|
+
(tdate ? " · <time datetime=\"" + esc(tdate) + "\">" + esc(tdate) + "</time>" : "") +
|
|
3733
|
+
(rowExpiry ? " · expires <time datetime=\"" + esc(rowExpiry) + "\">" + esc(rowExpiry) + "</time>" : "") +
|
|
3734
|
+
"</p></li>";
|
|
3735
|
+
}
|
|
3736
|
+
var historySection;
|
|
3737
|
+
if (histInner) {
|
|
3738
|
+
var more = opts.history_next_cursor != null
|
|
3739
|
+
? "<p class=\"loyalty-more\"><a class=\"btn-secondary\" href=\"/account/credit?cursor=" +
|
|
3740
|
+
esc(String(opts.history_next_cursor)) + "\">Older activity</a></p>"
|
|
3741
|
+
: "";
|
|
3742
|
+
historySection = "<h2 class=\"pdp__variants-title\">Activity</h2><ul class=\"return-list\">" + histInner + "</ul>" + more;
|
|
3743
|
+
} else {
|
|
3744
|
+
historySection = "<h2 class=\"pdp__variants-title\">Activity</h2>" +
|
|
3745
|
+
"<p class=\"return-empty\">No store credit yet. Refunds and goodwill credit will show up here.</p>";
|
|
3746
|
+
}
|
|
3747
|
+
|
|
3748
|
+
var body =
|
|
3749
|
+
"<section class=\"account-returns\">" +
|
|
3750
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
3751
|
+
"<li><a href=\"/account\">Account</a></li>" +
|
|
3752
|
+
"<li aria-current=\"page\">Store credit</li>" +
|
|
3753
|
+
"</ol></nav>" +
|
|
3754
|
+
"<h1 class=\"account-returns__title\">Store credit</h1>" +
|
|
3755
|
+
stats +
|
|
3756
|
+
expiringSection +
|
|
3757
|
+
historySection +
|
|
3758
|
+
"</section>";
|
|
3759
|
+
|
|
3760
|
+
return _wrap({
|
|
3761
|
+
title: "Store credit",
|
|
3762
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
3763
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
3764
|
+
theme_css: opts.theme_css,
|
|
3765
|
+
body: body,
|
|
3766
|
+
});
|
|
3767
|
+
}
|
|
3768
|
+
|
|
3550
3769
|
// Human-readable funnel stage for a referred friend. The referred
|
|
3551
3770
|
// email is stored hash-only, so a row never carries an address — the
|
|
3552
3771
|
// surface shows the stage + dates only.
|
|
@@ -4191,12 +4410,21 @@ function renderProduct(opts) {
|
|
|
4191
4410
|
// unfurl as "Operator Tee — blamejs.shop" with the SVG hero, not
|
|
4192
4411
|
// the default shop-level description + brand logo.
|
|
4193
4412
|
var heroMedia = (opts.media && opts.media[0]) || null;
|
|
4194
|
-
var ogImage = heroMedia ? ((opts.asset_prefix || "/assets/") + heroMedia.r2_key) : "/assets/brand/logo.png";
|
|
4195
4413
|
// Absolute base for the BreadcrumbList `item` URLs — derived from the
|
|
4196
4414
|
// PDP's own canonical (origin stripped of the /products/slug path) so
|
|
4197
4415
|
// the structured-data trail carries fully-qualified URLs. Falls back to
|
|
4198
4416
|
// the shop-name host when the renderer is called without a request URL.
|
|
4199
4417
|
var absoluteBase = _absoluteBase(opts.canonical_url, shopName);
|
|
4418
|
+
// og:image / twitter:image / the Product JSON-LD `image` all carry a
|
|
4419
|
+
// FULLY-QUALIFIED URL — a relative hero path (or the brand-logo default)
|
|
4420
|
+
// is dropped by social-share crawlers and by Google's product rich
|
|
4421
|
+
// result. Absolutize once here so the JSON-LD `image` (built below,
|
|
4422
|
+
// before `_wrap`) and the meta tags (`_wrap` re-runs the idempotent
|
|
4423
|
+
// absolutizer) both carry the resolved URL.
|
|
4424
|
+
var ogImage = _absolutizeOgImage(
|
|
4425
|
+
heroMedia ? ((opts.asset_prefix || "/assets/") + heroMedia.r2_key) : "/assets/brand/logo.png",
|
|
4426
|
+
opts.canonical_url, shopName
|
|
4427
|
+
);
|
|
4200
4428
|
|
|
4201
4429
|
// Product + AggregateOffer JSON-LD, with AggregateRating folded in
|
|
4202
4430
|
// when published reviews exist. Kept byte-compatible with the edge
|
|
@@ -6189,6 +6417,7 @@ var ACCOUNT_DASH_PAGE =
|
|
|
6189
6417
|
" <a class=\"btn-secondary\" href=\"/account/returns\">Returns</a>\n" +
|
|
6190
6418
|
" <a class=\"btn-secondary\" href=\"/account/support\">Support</a>\n" +
|
|
6191
6419
|
" <a class=\"btn-secondary\" href=\"/account/loyalty\">Rewards</a>\n" +
|
|
6420
|
+
" <a class=\"btn-secondary\" href=\"/account/credit\">Store credit</a>\n" +
|
|
6192
6421
|
" <a class=\"btn-secondary\" href=\"/account/referrals\">Refer a friend</a>\n" +
|
|
6193
6422
|
" <a class=\"btn-secondary\" href=\"/account/subscriptions\">Subscriptions</a>\n" +
|
|
6194
6423
|
// begin: profile + passkey management actions
|
|
@@ -11065,6 +11294,99 @@ function mount(router, deps) {
|
|
|
11065
11294
|
}
|
|
11066
11295
|
}
|
|
11067
11296
|
|
|
11297
|
+
// Store-credit wallet — the signed-in customer's READ-ONLY view of
|
|
11298
|
+
// their account-bound store credit. One surface:
|
|
11299
|
+
// * GET /account/credit — the current balance, an expiring-soon
|
|
11300
|
+
// callout, and the credit/debit/expire ledger (paginated). A
|
|
11301
|
+
// browser gets the server-rendered page; an API client (Accept:
|
|
11302
|
+
// application/json) gets the balance + ledger payload.
|
|
11303
|
+
//
|
|
11304
|
+
// SESSION-SCOPED, no IDOR surface: the route reads the customer id
|
|
11305
|
+
// from the signed-in session (`auth.customer_id`) and passes ONLY
|
|
11306
|
+
// that id to storeCredit.balance / .history / .expiringWithin. There
|
|
11307
|
+
// is no `:id` path param and the route never reads a customer id from
|
|
11308
|
+
// the query string or body, so a signed-in shopper can only ever see
|
|
11309
|
+
// their OWN wallet. Granting / deducting credit is operator-only on
|
|
11310
|
+
// the admin customer-detail screen — this surface writes nothing.
|
|
11311
|
+
if (deps.storeCredit) {
|
|
11312
|
+
function _storeCreditAuth(req, res) {
|
|
11313
|
+
var auth;
|
|
11314
|
+
try { auth = _currentCustomer(req); }
|
|
11315
|
+
catch (e) {
|
|
11316
|
+
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
11317
|
+
throw e;
|
|
11318
|
+
}
|
|
11319
|
+
if (!auth) {
|
|
11320
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
11321
|
+
res.end ? res.end() : res.send("");
|
|
11322
|
+
return null;
|
|
11323
|
+
}
|
|
11324
|
+
return auth;
|
|
11325
|
+
}
|
|
11326
|
+
|
|
11327
|
+
// Build the /account/credit render context for the SESSION
|
|
11328
|
+
// customer. `opts2.cursor` is the optional history-page cursor
|
|
11329
|
+
// (epoch-ms). The expiring-soon read is best-effort so an
|
|
11330
|
+
// unmigrated table or a read error degrades that callout rather
|
|
11331
|
+
// than the page; balance + history are the core reads.
|
|
11332
|
+
async function _storeCreditView(req, auth, opts2) {
|
|
11333
|
+
opts2 = opts2 || {};
|
|
11334
|
+
var bal = await deps.storeCredit.balance(auth.customer_id);
|
|
11335
|
+
var hist = await deps.storeCredit.history({
|
|
11336
|
+
customer_id: auth.customer_id,
|
|
11337
|
+
limit: 20,
|
|
11338
|
+
cursor: opts2.cursor,
|
|
11339
|
+
});
|
|
11340
|
+
var expiring = [];
|
|
11341
|
+
try {
|
|
11342
|
+
expiring = await deps.storeCredit.expiringWithin({
|
|
11343
|
+
customer_id: auth.customer_id,
|
|
11344
|
+
days: 30,
|
|
11345
|
+
});
|
|
11346
|
+
} catch (_e) { expiring = []; }
|
|
11347
|
+
var cartCount = await _cartCountForReq(req);
|
|
11348
|
+
return {
|
|
11349
|
+
balance_minor: bal.balance_minor,
|
|
11350
|
+
history: hist.rows,
|
|
11351
|
+
history_next_cursor: hist.next_cursor,
|
|
11352
|
+
expiring: expiring,
|
|
11353
|
+
shop_name: shopName,
|
|
11354
|
+
cart_count: cartCount,
|
|
11355
|
+
};
|
|
11356
|
+
}
|
|
11357
|
+
|
|
11358
|
+
router.get("/account/credit", async function (req, res) {
|
|
11359
|
+
var auth = _storeCreditAuth(req, res); if (!auth) return;
|
|
11360
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
11361
|
+
var cursorRaw = url && url.searchParams.get("cursor");
|
|
11362
|
+
var cursor;
|
|
11363
|
+
if (cursorRaw != null && cursorRaw !== "") {
|
|
11364
|
+
var n = parseInt(cursorRaw, 10);
|
|
11365
|
+
// A malformed cursor degrades to the first page (the lib would
|
|
11366
|
+
// TypeError on a non-integer) — never 500 the page.
|
|
11367
|
+
cursor = Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
11368
|
+
}
|
|
11369
|
+
var view = await _storeCreditView(req, auth, { cursor: cursor });
|
|
11370
|
+
// Content negotiation: an API client (Accept: application/json)
|
|
11371
|
+
// gets the balance + ledger payload; a browser gets the rendered
|
|
11372
|
+
// wallet page. Both read the SAME session customer id.
|
|
11373
|
+
var accept = (req.headers && (req.headers.accept || req.headers.Accept)) || "";
|
|
11374
|
+
if (/\bapplication\/json\b/.test(String(accept))) {
|
|
11375
|
+
res.status(200);
|
|
11376
|
+
res.setHeader && res.setHeader("content-type", "application/json; charset=utf-8");
|
|
11377
|
+
var payload = JSON.stringify({
|
|
11378
|
+
balance_minor: view.balance_minor,
|
|
11379
|
+
currency: "USD",
|
|
11380
|
+
expiring: view.expiring,
|
|
11381
|
+
ledger: view.history,
|
|
11382
|
+
history_next_cursor: view.history_next_cursor,
|
|
11383
|
+
});
|
|
11384
|
+
return res.end ? res.end(payload) : res.send(payload);
|
|
11385
|
+
}
|
|
11386
|
+
_send(res, 200, renderAccountStoreCredit(view));
|
|
11387
|
+
});
|
|
11388
|
+
}
|
|
11389
|
+
|
|
11068
11390
|
// Referrals — refer-a-friend. Three surfaces:
|
|
11069
11391
|
// * GET /r/:code — the attribution landing. Sets a
|
|
11070
11392
|
// short-lived sealed first-party cookie naming the code, records
|
|
@@ -12166,6 +12488,10 @@ module.exports = {
|
|
|
12166
12488
|
renderHome: renderHome,
|
|
12167
12489
|
renderSearch: renderSearch,
|
|
12168
12490
|
renderProduct: renderProduct,
|
|
12491
|
+
renderCollectionList: renderCollectionList,
|
|
12492
|
+
renderCollection: renderCollection,
|
|
12493
|
+
renderCategoryIndex: renderCategoryIndex,
|
|
12494
|
+
renderCategory: renderCategory,
|
|
12169
12495
|
renderCart: renderCart,
|
|
12170
12496
|
renderCheckoutForm: renderCheckoutForm,
|
|
12171
12497
|
renderCheckoutError: renderCheckoutError,
|
package/package.json
CHANGED