@blamejs/blamejs-shop 0.1.28 → 0.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.1.x
10
10
 
11
+ - v0.1.29 (2026-05-26) — **Category navigation goes live — public browse pages for the storefront's category tree, with breadcrumbs and sub-category grids.** The category tree now has a customer-facing surface. A new /categories index lists the active top-level categories as a card grid, and a new /categories/<slug> page renders a single category: its title and optional description, a breadcrumb chain from the catalog root down to the current category, and a grid of the category's direct child sub-categories. Each page reads fresh against the active tree, so archived or unpublished categories drop out of every surface. An unknown, archived, or malformed slug returns a 404, and a category with no children renders a graceful empty state. The pages are linked from the site footer's Shop column. **Added:** *Category index at /categories* — A public page listing the active top-level categories as a card grid, each card carrying the category's title, optional description, and optional hero image, and linking into its browse page. Archived and unpublished categories are excluded. Linked from the site footer's Shop column. · *Category browse page at /categories/<slug>* — A public page for a single category showing its title and optional description, a breadcrumb chain from the catalog root down to the current category (each ancestor linked, the current category shown as plain text), an optional hero image, and a card grid of the category's direct child sub-categories. A category with no children renders a graceful empty state. An unknown, archived, or malformed slug returns a 404.
12
+
11
13
  - v0.1.28 (2026-05-25) — **Refer-a-friend goes live — a shareable link, attributed signups, and conversions recorded when a referred friend's first order is paid.** The referrals ledger now has a customer-facing surface. A new /account/referrals page gives the signed-in customer a personal referral code, an absolute shareable link, the list of friends they've referred with each one's funnel stage, and an in-account top-referrer leaderboard. A new /r/<code> landing captures an inbound referral: it sets a short-lived sealed first-party cookie naming the code and sends the visitor home. When that visitor creates an account — by passkey, Google, or Apple — the new customer is attributed to the referrer, first-touch, with a self-referral guard so a code can never refer its own owner. When a referred customer's first order reaches paid, the order primitive marks that referral converted and bumps the referrer's count (the leaderboard key) — automatically, off the request path, and recorded once per order so a re-delivered payment webhook can't double-count. Issuing the actual reward (a gift card, store credit, or loyalty points — operator's choice) stays an explicit operator action through the referrals reward API, so the payout instrument and any fraud review are the operator's to control. **Added:** *Customer referrals page at /account/referrals* — A login-gated page showing the customer's referral code and an absolute shareable link (derived from the configured shop origin, falling back to the request host), the count of friends invited / joined / converted, the list of referred friends with each one's funnel stage (invited → visited → joined → converted) and dates, and an in-account top-referrer leaderboard. The friend list carries no personal data — referred emails are stored hash-only — and the leaderboard shows rank plus initials only, never names, emails, or account ids, with the signed-in customer's own row marked "You". Linked from the account dashboard as "Refer a friend". A customer with no code yet sees a one-click create control; a customer with one already gets the same page (creating is idempotent). · *Referral attribution landing at /r/<code>* — A public landing that captures an inbound referral: it sets a short-lived (30-day) sealed, first-party cookie naming the code, records the visit, and redirects the visitor to the home page. Attribution is first-touch — the first referral link a visitor follows wins, and a later link doesn't overwrite it. An unknown, malformed, or disabled code is silently ignored: the visitor still lands on the home page with no error and no cookie, so the route is never a code-existence oracle. · *Signups attributed to the referrer* — When a visitor who arrived through a referral link creates an account — passkey, Sign in with Google, or Sign in with Apple — the new customer is attributed to the referrer named in the referral cookie. A self-referral (the code belongs to the new account) is refused, a customer is attributed only once, and an existing customer signing in through Google or Apple is never attributed (only a genuinely new account is). Attribution is best-effort and never blocks account creation; the referral cookie is cleared after one signup so a stale link can't attribute a later one. · *Referral conversion recorded when a referred friend's first order is paid* — When a referred customer's first order reaches paid, the order primitive marks that referral converted and bumps the referrer's referral count (the leaderboard key). The update rides every paid path (card, wallet, PayPal, gift-card-covered), runs fire-and-forget off the request path, and is recorded once per order so a re-delivered payment webhook collapses onto a single conversion. Only orders carrying a customer account qualify; a guest order records nothing, and a customer's second and later orders don't re-trigger it. Issuing the payout itself — a gift card, store credit, or loyalty points — is a separate operator action through the referrals reward API, so the operator controls the reward instrument and can review before paying out.
12
14
 
13
15
  - v0.1.27 (2026-05-25) — **Loyalty rewards go live — points earned on purchase, viewed in the account, and redeemed at checkout.** The loyalty points ledger now has a customer-facing surface. A new /account/loyalty page shows the signed-in customer's balance, tier, and earn/redeem activity, explains how points are earned, and lists the reward catalog with a one-click redeem. Paid orders award points automatically: the order primitive fans its paid transition into the earn rules (per-dollar-spent + per-order), fire-and-forget and deduplicated so a re-delivered payment webhook never double-credits. At checkout, a signed-in customer can spend points for a storefront credit against the order total — capped at the order's value and the balance, recorded once, with the same atomic double-spend guard the gift-card credit uses. **Added:** *Customer rewards page at /account/loyalty* — A login-gated page showing the points balance, current tier, lifetime points, and the cash value of the balance; how points are earned (the active earn rules in plain language); the reward catalog with a Redeem control per reward (disabled when the balance is short); the customer's past redemptions; and the earn/redeem ledger, paginated newest-first. Linked from the account dashboard as "Rewards". A redeem that the customer can't complete (not enough points, reward unavailable, per-customer limit reached) re-renders the page with the reason rather than erroring; a malformed history cursor degrades to the first page. · *Points earned automatically on paid orders* — When an order reaches paid, the configured earn rules award points to the customer's balance — per dollar of order subtotal and a flat per-order bonus, whichever rules the operator has defined. The award rides the order's paid transition (so every paid path — card, PayPal, gift-card-covered, admin — earns), runs fire-and-forget off the request path, and is keyed to the order so a re-delivered payment webhook collapses onto a single award instead of double-crediting. Guest orders (no customer account) earn nothing, by design. · *Redeem points at checkout as a credit* — A signed-in customer with a balance sees a "Redeem loyalty points" field on the checkout form. The points are valued via the ledger's redemption ratio (100 points = $1 by default), capped at the order total so the credit never exceeds what the order is worth, and the credit reduces the amount due. The order still records the full grand total it owed; the points are debited once, after the order row exists, behind the same balance-guarded SQL decrement that prevents a double-spend. Points worth more than the order spend only what the granted credit is worth — the surplus stays in the balance. A request for more points than the balance, or from a guest cart, is refused with a clean re-prompt. A points credit stacks with a gift-card credit; together they can pay an order in full with no card charge.
package/README.md CHANGED
@@ -73,6 +73,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
73
73
  | **`lib/recently-viewed.js`** | Signed-in customer browse history. A product-page visit records the view server-side against the customer's account (drop-silent — never blocks the page); `/account/recently-viewed` lists them newest-first as a grid with a Clear-history control. De-duped + capped per customer, archived products drop out, login-gated. Guest/session history is opt-in (a client beacon) and not shipped — the lib's `forSession` + `merge` support it. |
74
74
  | **`lib/recommendations.js`** | Product-recommendation engine. Operator-curated override pins first (`setOverride` — "when viewing A, show B", kind-scoped + weight-ordered), then a signal-based fallback: co-purchase (products bought in the same orders), category-popular, and in-stock-random filler. `recommendForProduct` / `recommendForCart` / `recommendForCustomer` / `recommendForCategory` each return renderable picks (active + in-stock, source product excluded). The order confirmation page (`/orders/:id`) renders a "Customers also bought" rail from it — best-effort, anchored on the order's items, excluding what was just bought. |
75
75
  | **`lib/collections.js`** | Curated + smart product groupings. `GET /collections` lists the shop's active collections; `GET /collections/:slug` renders the grid — manual collections list hand-picked members, smart collections evaluate stored rules against the active catalog and apply the collection's sort strategy. Each product resolves fresh, so archived products drop out. Public, no sign-in; a bad or unknown slug is a 404 (never a 500). Linked from the footer on every page. |
76
+ | **`lib/category-navigation.js`** | Hierarchical category tree surfaced as public browse pages. `GET /categories` lists the active top-level categories as a card grid; `GET /categories/:slug` renders one category — its title and optional description, a breadcrumb chain from the catalog root down to the current category, an optional hero image, and a grid of the category's direct child sub-categories. Each page reads fresh against the active tree, so archived / unpublished categories drop out of every surface. Public, no sign-in; an unknown, archived, or malformed slug is a 404 (never a 500), and a category with no children renders a graceful empty state. Linked from the footer on every page. The tree itself (define / move / reorder / archive, with cycle defense bounded by `MAX_TREE_DEPTH`) is operator-managed through the primitive's write API. |
76
77
  | **`lib/subscriptions.js`** | Stripe-backed recurring billing — `subscription_plans` (interval / amount / trial) + `subscriptions` (mirrors Stripe's object byte-for-byte). `subscriptions.create` POSTs to Stripe via the payment dep, then persists the returned object locally. `handleStripeEvent` replays `customer.subscription.*` events into the local row so the shop has an authoritative view without round-tripping. Customers view + cancel their own subscriptions at `/account/subscriptions` (ownership-checked; cancel mounts when the payment handle is wired). |
77
78
  | **`lib/giftcards.js`** | Prepaid bearer gift cards. `issue({ amount_minor, currency })` generates a 16-char code (32-glyph alphabet, no ambiguous letters) via `b.crypto.generateBytes`, stores only its `namespaceHash` digest + a 4-char hint, and returns the plaintext code once. `balance(code)` / `lookup(code)` resolve a code to its live balance (constant-time hash compare); `redeem({ code, order_id, amount_minor })` decrements the balance with an atomic `balance >= amount` SQL guard so concurrent spends can't overdraw. Redeemed at checkout as a credit against the order grand total: the amount due drops by the applied balance (never below zero), the order still records the full total it owed, and the debit is recorded once per order — a card that fully covers the order is marked paid with no Stripe charge. Customers check a balance at `GET /gift-cards`; the page is not a code-existence oracle (unknown / malformed / expired all return the same generic not-found). |
78
79
  | **`lib/gift-card-ledger.js`** | Append-only credit / debit / expire history per gift card, with a denormalized `balance_after_minor` snapshot for O(1) balance reads. `credit` / `debit` / `expire` write one row each; `history(id)` paginates a card's transactions; `transactionsForOrder(id)` lists a card's movements for one order. The audit trail behind the admin gift-card ledger console; overdraft is refused at the primitive layer. |
@@ -109,6 +110,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
109
110
  - `migrations-d1/0163_loyalty_earn_rules.sql` — per-event earn rules + dedup-keyed award log
110
111
  - `migrations-d1/0025_referrals.sql` — referral codes (one active per customer) + invitation funnel (hash-only referred email)
111
112
  - `migrations-d1/0182_referral_leaderboard.sql` — referral tier thresholds (singleton config) + tiered-bonus payout log
113
+ - `migrations-d1/0201_category_navigation.sql` — hierarchical category tree (self-referential parent_slug, sibling position, soft-delete tombstone)
112
114
 
113
115
  ### Demo seed
114
116
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.28",
2
+ "version": "0.1.29",
3
3
  "assets": {
4
4
  "css/admin.css": "sha384-rvjL1QySBgNJGEUDusbbMCkL3fsjmzcwUvTp9pljo/0Nn/lErxq2AbY8EYvDF9Ch",
5
5
  "css/main.css": "sha384-qBY97FWZDGOploZRppwrDi0A/+R4R3iCFtyS1dfrAlqHNEPluPmY0R3HB9cJntyV",
package/lib/storefront.js CHANGED
@@ -162,6 +162,7 @@ var LAYOUT =
162
162
  " <ul>\n" +
163
163
  " <li><a href=\"/\">All products</a></li>\n" +
164
164
  " <li><a href=\"/collections\">Collections</a></li>\n" +
165
+ " <li><a href=\"/categories\">Categories</a></li>\n" +
165
166
  " <li><a href=\"/?sort=new\">New arrivals</a></li>\n" +
166
167
  " <li><a href=\"/?sort=sale\">On sale</a></li>\n" +
167
168
  " <li><a href=\"/cart\">Cart</a></li>\n" +
@@ -1265,6 +1266,123 @@ function renderCollection(opts) {
1265
1266
  });
1266
1267
  }
1267
1268
 
1269
+ // Storefront category index — the top-level category tree. Each card
1270
+ // links into a category browse page. Reuses the collection-index card
1271
+ // shell (same visual contract) so no new CSS ships. `opts.categories`
1272
+ // is the hydrated [{ slug, title, description, hero_image_url }] list of
1273
+ // active top-level categories (archived rows are dropped by the lib).
1274
+ function renderCategoryIndex(opts) {
1275
+ var esc = b.template.escapeHtml;
1276
+ var cats = opts.categories || [];
1277
+ var cardsHtml = "";
1278
+ for (var i = 0; i < cats.length; i += 1) {
1279
+ var c = cats[i];
1280
+ var media = c.hero_image_url
1281
+ ? "<figure class=\"collection-index-card__media\"><img src=\"" + esc(_categoryHeroSrc(c.hero_image_url, opts.asset_prefix)) + "\" alt=\"" + esc(c.title) + "\" loading=\"lazy\"></figure>"
1282
+ : "<figure class=\"collection-index-card__media collection-index-card__media--empty\" aria-hidden=\"true\"></figure>";
1283
+ cardsHtml +=
1284
+ "<a class=\"collection-index-card\" href=\"/categories/" + esc(c.slug) + "\">" +
1285
+ media +
1286
+ "<div class=\"collection-index-card__meta\">" +
1287
+ "<h2 class=\"collection-index-card__title\">" + esc(c.title) + "</h2>" +
1288
+ (c.description ? "<p class=\"collection-index-card__desc\">" + esc(c.description) + "</p>" : "") +
1289
+ "</div>" +
1290
+ "</a>";
1291
+ }
1292
+ var inner = cardsHtml
1293
+ ? "<div class=\"collection-index-grid\">" + cardsHtml + "</div>"
1294
+ : "<p class=\"collection-empty\">No categories yet.</p>";
1295
+ var body =
1296
+ "<section class=\"collection-index\">" +
1297
+ "<header class=\"section-head\"><p class=\"eyebrow\">Browse</p>" +
1298
+ "<h1 class=\"section-head__title\">Categories</h1></header>" +
1299
+ inner +
1300
+ "</section>";
1301
+ return _wrap({
1302
+ title: "Categories", shop_name: opts.shop_name || "blamejs.shop",
1303
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count, theme_css: opts.theme_css, body: body,
1304
+ });
1305
+ }
1306
+
1307
+ // A single category's page — breadcrumb (root -> current), title,
1308
+ // optional description + hero, and a grid of the category's direct
1309
+ // child sub-categories. `opts.breadcrumbs` is the root->current chain
1310
+ // (the last entry is the current category, rendered as plain text);
1311
+ // `opts.children` is the hydrated direct-child list (empty -> graceful
1312
+ // empty state). Reuses the collection-page + collection-index card
1313
+ // shells so no new CSS ships.
1314
+ function renderCategory(opts) {
1315
+ var esc = b.template.escapeHtml;
1316
+ var cat = opts.category;
1317
+ var crumbs = opts.breadcrumbs || [];
1318
+ var children = opts.children || [];
1319
+
1320
+ var crumbHtml = "<li><a href=\"/\">Shop</a></li><li><a href=\"/categories\">Categories</a></li>";
1321
+ for (var ci = 0; ci < crumbs.length; ci += 1) {
1322
+ var bc = crumbs[ci];
1323
+ if (ci === crumbs.length - 1) {
1324
+ crumbHtml += "<li aria-current=\"page\">" + esc(bc.title) + "</li>";
1325
+ } else {
1326
+ crumbHtml += "<li><a href=\"/categories/" + esc(bc.slug) + "\">" + esc(bc.title) + "</a></li>";
1327
+ }
1328
+ }
1329
+
1330
+ // Hero reuses the collection-index card media shell (aspect-ratio +
1331
+ // object-fit cover) so no new CSS ships; it's a standalone figure
1332
+ // rather than a card link here.
1333
+ var hero = cat.hero_image_url
1334
+ ? "<figure class=\"collection-index-card__media\"><img src=\"" + esc(_categoryHeroSrc(cat.hero_image_url, opts.asset_prefix)) + "\" alt=\"" + esc(cat.title) + "\"></figure>"
1335
+ : "";
1336
+
1337
+ var cardsHtml = "";
1338
+ for (var i = 0; i < children.length; i += 1) {
1339
+ var ch = children[i];
1340
+ var media = ch.hero_image_url
1341
+ ? "<figure class=\"collection-index-card__media\"><img src=\"" + esc(_categoryHeroSrc(ch.hero_image_url, opts.asset_prefix)) + "\" alt=\"" + esc(ch.title) + "\" loading=\"lazy\"></figure>"
1342
+ : "<figure class=\"collection-index-card__media collection-index-card__media--empty\" aria-hidden=\"true\"></figure>";
1343
+ cardsHtml +=
1344
+ "<a class=\"collection-index-card\" href=\"/categories/" + esc(ch.slug) + "\">" +
1345
+ media +
1346
+ "<div class=\"collection-index-card__meta\">" +
1347
+ "<h2 class=\"collection-index-card__title\">" + esc(ch.title) + "</h2>" +
1348
+ (ch.description ? "<p class=\"collection-index-card__desc\">" + esc(ch.description) + "</p>" : "") +
1349
+ "</div>" +
1350
+ "</a>";
1351
+ }
1352
+ var grid = cardsHtml
1353
+ ? "<div class=\"collection-index-grid\">" + cardsHtml + "</div>"
1354
+ : "<p class=\"collection-empty\">No sub-categories here yet.</p>";
1355
+
1356
+ var body =
1357
+ "<section class=\"collection-page\">" +
1358
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
1359
+ crumbHtml +
1360
+ "</ol></nav>" +
1361
+ "<header class=\"collection-page__head\">" +
1362
+ "<h1 class=\"collection-page__title\">" + esc(cat.title) + "</h1>" +
1363
+ (cat.description ? "<p class=\"collection-page__desc\">" + esc(cat.description) + "</p>" : "") +
1364
+ "</header>" +
1365
+ hero +
1366
+ grid +
1367
+ "</section>";
1368
+ return _wrap({
1369
+ title: cat.title, shop_name: opts.shop_name || "blamejs.shop",
1370
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count, theme_css: opts.theme_css, body: body,
1371
+ });
1372
+ }
1373
+
1374
+ // Resolve a category hero_image_url into a renderable src. The lib gates
1375
+ // hero_image_url to https:// OR a /-rooted absolute path at write time,
1376
+ // so an absolute https URL or a /-rooted path is used as-is; any other
1377
+ // value (a bare R2 key) is prefixed with the card asset prefix the same
1378
+ // way collection hero media resolves.
1379
+ function _categoryHeroSrc(url, assetPrefix) {
1380
+ if (typeof url !== "string" || !url.length) return "";
1381
+ if (url.charCodeAt(0) === 47 /* "/" */) return url;
1382
+ if (/^https:\/\//i.test(url)) return url;
1383
+ return (assetPrefix || "/assets/") + url;
1384
+ }
1385
+
1268
1386
  // Account "Recently viewed" page — a newest-first grid of products the
1269
1387
  // signed-in customer has opened, reusing the standard product card.
1270
1388
  // `opts.products` is a resolved [{ slug, title, price, image_url,
@@ -3356,6 +3474,60 @@ function mount(router, deps) {
3356
3474
  });
3357
3475
  }
3358
3476
 
3477
+ // Category navigation — the hierarchical category tree surfaced as
3478
+ // public browse pages. The index lists the top-level categories; each
3479
+ // category page renders its breadcrumb chain + direct child sub-
3480
+ // categories. Mounted when the categoryNavigation primitive is wired.
3481
+ if (deps.categoryNavigation) {
3482
+ router.get("/categories", async function (req, res) {
3483
+ // Top-level categories only (parent_slug omitted). The lib drops
3484
+ // archived rows from every read, so the index is fresh against the
3485
+ // active tree.
3486
+ var cats = await deps.categoryNavigation.categoriesByParent({});
3487
+ var active = [];
3488
+ for (var i = 0; i < cats.length; i += 1) {
3489
+ if (cats[i].active) active.push(cats[i]);
3490
+ }
3491
+ var cartCount = await _cartCountForReq(req);
3492
+ _send(res, 200, renderCategoryIndex({
3493
+ categories: active, shop_name: shopName, cart_count: cartCount, asset_prefix: _cardAssetPrefix,
3494
+ }));
3495
+ });
3496
+
3497
+ router.get("/categories/:slug", async function (req, res) {
3498
+ var slug = req.params && req.params.slug;
3499
+ // getCategory() / breadcrumbsFor() / categoriesByParent() throw a
3500
+ // TypeError on a malformed slug shape (the primitive validates the
3501
+ // slug regex). A bad path segment, an unknown slug, or an archived
3502
+ // category is a 404, not a 500 — this is a defensive request-shape
3503
+ // reader.
3504
+ var cat, crumbs, children;
3505
+ try {
3506
+ cat = slug ? await deps.categoryNavigation.getCategory(slug) : null;
3507
+ if (!cat || !cat.active) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
3508
+ crumbs = await deps.categoryNavigation.breadcrumbsFor({ slug: slug });
3509
+ children = await deps.categoryNavigation.categoriesByParent({ parent_slug: slug });
3510
+ } catch (e) {
3511
+ if (e instanceof TypeError) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
3512
+ // A CATEGORY_NOT_FOUND from breadcrumbsFor / categoriesByParent
3513
+ // (e.g. the row was archived between reads) is also a 404.
3514
+ if (e && e.code === "CATEGORY_NOT_FOUND") return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
3515
+ throw e;
3516
+ }
3517
+ // Only surface active sub-categories (the lib already drops archived
3518
+ // rows; this also hides operator-unpublished ones).
3519
+ var activeChildren = [];
3520
+ for (var i = 0; i < children.length; i += 1) {
3521
+ if (children[i].active) activeChildren.push(children[i]);
3522
+ }
3523
+ var cartCount = await _cartCountForReq(req);
3524
+ _send(res, 200, renderCategory({
3525
+ category: cat, breadcrumbs: crumbs, children: activeChildren,
3526
+ shop_name: shopName, cart_count: cartCount, asset_prefix: _cardAssetPrefix,
3527
+ }));
3528
+ });
3529
+ }
3530
+
3359
3531
  router.get("/cart", async function (req, res) {
3360
3532
  var sid = _readSidCookie(req);
3361
3533
  if (!sid) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.1.28",
3
+ "version": "0.1.29",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {