@blamejs/blamejs-shop 0.0.46 → 0.0.48

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,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.0.x
10
10
 
11
+ - v0.0.48 (2026-05-22) — **Home page — featured-product callout between the primitives marquee and the collections grid.** The home page rhythm went marquee → 6-tile collections grid → framework band, so a single product never got more than the four-tile catalog card's surface area to introduce itself. This release inserts a split callout (square hero image on the left, copy column with `Featured` eyebrow + title + description + accent-coloured price + `View product →` CTA on the right) between the marquee and the collections grid. The render picks the first product with attached media; operators wrapping `renderHome` can override the selection via `opts.featured`. **Added:** *`.featured-product` section* — Sits between the primitives marquee and the collections grid. Two-column grid at desktop (square hero + copy column), stacks to image-above-copy at <64rem with a 16/10 image aspect ratio. Title is a `clamp(1.75rem, 3vw, 2.5rem)` headline; price renders in the display font at `--text-2xl` with the accent colour + tabular-nums. Image is wrapped in an anchor to the PDP and has a hover-scale of `1.03` driven through the existing `--duration-slow` ease curve. The card itself has a hairline border on `var(--paper)` so it reads as a content tile, not a hero band — keeps the dark hero above and the framework band below as the page's main rhythm changes. · *`storefront.renderHome({ ..., featured? })` selection override* — When `opts.featured` is supplied the renderer uses it verbatim (shape: `{ title, description, price, slug, image_url, image_alt }`). When absent, the renderer scans the active-products list for the first row with `image_url` and uses that as the featured pick. Products without media are skipped; when no product has media the callout renders as an empty string and the page rhythm falls back to marquee → collections directly. **Changed:** *Product data shape — `description` carried through* — The renderHome `products.map(...)` projection now includes `description` (from the catalog products row) so the featured callout's lede can pull product-specific copy. Existing callers that didn't pass a description continue to render — the callout falls back to a stock 'Server-rendered, PQC-secured, shipped from origin' line.
12
+
13
+ - v0.0.47 (2026-05-22) — **Account dashboard — 4-stat row + recent-orders table with per-order product thumbnails.** The dashboard surfaced a bare three-column orders table (order id, status, total). This release adds a 4-stat row above the body (orders / lifetime spend / member since / passkeys) and a new 'Items' column on the orders table that renders the first four product thumbnails per order with a `+N` overflow chip when there are more. Visitors land on a richer surface that summarises their relationship with the shop instead of just listing opaque order ids. **Added:** *Stats row — orders, lifetime spend, member since, passkeys* — Four-up grid above the orders table. `Orders` is the count of returned orders (capped at 10 by the listForCustomer page; future patch could surface the true count via a `count()` shape on the order primitive). `Lifetime spend` sums `grand_total_minor` across orders in the dominant currency (falls back to '—' if multiple currencies appear, since silently mis-summing them is worse than declining to compute). `Member since` reads `customer.created_at` when present, otherwise the earliest order's `created_at` — both as an ISO date. `Passkeys` calls `deps.customers.listPasskeys(customer.id)` (drop-silent on `TypeError` if the primitive build doesn't expose the listing surface yet). · *`Items` column — per-order product thumbnails* — Per-order, the route walks `order.lines`, collapses to unique variant ids, and resolves each through cached `catalog.variants.get` → `catalog.products.get` + `catalog.media.listForProduct`. The variant cache is held across orders so a customer with multiple orders containing the same SKU only hits the catalog once. The table cell surfaces the first four thumbs (`.account-order__thumb`, 2.5rem rounded tiles); orders with more than four show a `+N` overflow chip. Status renders as the same `.pdp__badge`-style pill the order page uses — green-bordered `--ok` variant for `completed` / `shipped` / `delivered`. **Changed:** *`storefront.renderAccount({ customer, orders, order_product_lookup?, passkey_count?, asset_prefix? })`* — New optional opts. `order_product_lookup` is `{ order_id: [{ product, hero_media }, ...] }` — the route bundles it in. `passkey_count` is the integer count of enrolled passkeys (0 when the customers primitive can't enumerate them on this build). `asset_prefix` defaults to `/assets/` for image URL composition. All three are optional — calling with just `customer` + `orders` continues to render cleanly, the stat row just shows `0` / `—` for the missing fields.
14
+
11
15
  - v0.0.46 (2026-05-22) — **Order confirmation page — line items render thumbnails + product titles (same pattern as the cart).** The post-checkout `/orders/:order_id` page used the read-only `CART_LINE` template that emitted just SKU + qty + unit + total. The order confirmation now reuses the same product-cell treatment v0.0.45 shipped on the live cart — small thumbnail (from `catalog.media.listForProduct`) + product title + SKU chip in a slug-linked anchor — so the page a customer lands on after a successful purchase shows what they bought visually, not just a column of opaque SKU codes. **Changed:** *`CART_LINE` (read-only, used by the order page) — same product cell as the editable cart line* — Markup mirrors `CART_LINE_EDITABLE` minus the qty / update / remove forms. First cell is `<a class="cart-line__product-link" href="/products/<slug>">` wrapping the thumbnail + title + SKU chip; remaining cells (qty / unit / total) are unchanged. Reuses the existing `.cart-line__thumb` + `.cart-line__product-*` CSS — no new theme rules. · *`storefront.renderOrder({ ..., product_lookup })` accepts the variant→product map* — Mirrors `renderCart`'s contract. The route handler bundles a `{ variant_id: { product, hero_media } }` cache built per-unique-variant; lines whose variant is missing fall through to SKU-as-title + the placeholder tile. The order page table header is now 'Product' instead of 'SKU'. · *`GET /orders/:order_id` walks the order's frozen lines once* — After loading the order, the route walks `o.lines` and for each unique `variant_id` pulls `catalog.variants.get` → `catalog.products.get` + `catalog.media.listForProduct`. The cache prevents a multi-quantity-same-variant order from hitting the catalog more than once per variant. The lookup is passed to `renderOrder` so the line rendering matches the cart's pattern byte-for-byte.
12
16
 
13
17
  - v0.0.45 (2026-05-22) — **Cart line items — thumbnail + product title + slug-linked anchor alongside the SKU chip.** Cart rows previously rendered only the SKU code, qty, unit, and total — the visitor had to remember what they actually added. Each line now leads with a 3.5rem rounded thumbnail (from the product's first `catalog.media` row), the product title in a bold inline label, and the SKU as a small monospace chip below. The whole product cell is an anchor back to the PDP so a visitor can re-enter the product page directly from the cart. Lines whose variant is missing media render the placeholder tile (dashed-border + the diagonal-stripe pattern the catalog empty-state uses). **Changed:** *`CART_LINE_EDITABLE` template — product cell replaces SKU-only cell* — First cell is now `<a class="cart-line__product-link" href="/products/<slug>">` wrapping a thumbnail + a `<span class="cart-line__product-meta">` that stacks the product title (bold) and SKU chip (monospace, on a `--bg-2` background). Hover on the link tints both the title and the link affordance to the accent color. The qty / unit / total / action cells are unchanged. · *`storefront.renderCart({ ..., product_lookup })` accepts a variant→product lookup* — Routes pass a `{ variant_id: { product, hero_media } }` map; lines render with the matching product's title + slug-linked URL + hero-media thumbnail. Lines without a lookup match (variant deleted, media not attached yet) fall through to a SKU-as-title display with the placeholder tile. The cart-table column header is now 'Product' instead of 'SKU'. · *`GET /cart` builds the product lookup* — After listing the cart's lines, the route walks each unique `variant_id` (cached by id so a cart with the same variant twice only hits the catalog once), pulls `catalog.variants.get` → `catalog.products.get` + `catalog.media.listForProduct`, and bundles the result for the renderer.
package/lib/storefront.js CHANGED
@@ -318,6 +318,8 @@ var HOME_HERO =
318
318
  " </div>\n" +
319
319
  "</section>\n" +
320
320
  "\n" +
321
+ "RAW_FEATURED_CALLOUT\n" +
322
+ "\n" +
321
323
  "<section class=\"collections\" aria-labelledby=\"collections-title\">\n" +
322
324
  " <header class=\"section-head\">\n" +
323
325
  " <p class=\"eyebrow\">Featured collections</p>\n" +
@@ -453,7 +455,14 @@ function renderHome(opts) {
453
455
  // products render the text-only PRODUCT_CARD fallback below.
454
456
  var imageUrl = p.hero_media ? assetPrefix + p.hero_media.r2_key : null;
455
457
  var imageAlt = p.hero_media ? (p.hero_media.alt_text || p.title) : null;
456
- return { title: p.title, price: priceStr, slug: p.slug, image_url: imageUrl, image_alt: imageAlt };
458
+ return {
459
+ title: p.title,
460
+ description: p.description || "",
461
+ price: priceStr,
462
+ slug: p.slug,
463
+ image_url: imageUrl,
464
+ image_alt: imageAlt,
465
+ };
457
466
  });
458
467
  if (opts.theme) {
459
468
  return opts.theme.render("home", {
@@ -473,7 +482,46 @@ function renderHome(opts) {
473
482
  // actual catalog size, not a stale hardcoded number. Falls back
474
483
  // to a typographic em-dash when the catalog hasn't been seeded.
475
484
  var heroProductCount = products.length === 0 ? "—" : String(products.length);
476
- var hero = _render(HOME_HERO, { product_count: heroProductCount });
485
+
486
+ // Featured-product callout — pick the first product that has
487
+ // attached media. Surfaces a single product in a wider treatment
488
+ // than the dense 6-tile collections grid below. Operators that
489
+ // want a different selection rule (top-seller, newest, manually
490
+ // pinned) wrap renderHome and override `opts.featured`.
491
+ function _esc(s) {
492
+ return String(s == null ? "" : s)
493
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
494
+ }
495
+ var featuredProduct = null;
496
+ if (opts.featured) {
497
+ featuredProduct = opts.featured;
498
+ } else {
499
+ for (var fi = 0; fi < products.length; fi += 1) {
500
+ if (products[fi].image_url) { featuredProduct = products[fi]; break; }
501
+ }
502
+ }
503
+ var featuredHtml = "";
504
+ if (featuredProduct) {
505
+ var fpDesc = featuredProduct.description || "Server-rendered, PQC-secured, shipped from origin. Composed on the vendored blamejs framework.";
506
+ featuredHtml =
507
+ "<section class=\"featured-product\" aria-labelledby=\"featured-title\">\n" +
508
+ " <div class=\"featured-product__inner\">\n" +
509
+ " <a class=\"featured-product__media\" href=\"/products/" + _esc(featuredProduct.slug) + "\">\n" +
510
+ " <img src=\"" + _esc(featuredProduct.image_url) + "\" alt=\"" + _esc(featuredProduct.image_alt || featuredProduct.title) + "\" loading=\"lazy\">\n" +
511
+ " </a>\n" +
512
+ " <div class=\"featured-product__copy\">\n" +
513
+ " <p class=\"eyebrow\">Featured</p>\n" +
514
+ " <h2 id=\"featured-title\" class=\"featured-product__title\">" + _esc(featuredProduct.title) + "</h2>\n" +
515
+ " <p class=\"featured-product__lede\">" + _esc(fpDesc) + "</p>\n" +
516
+ " <p class=\"featured-product__price\">" + _esc(featuredProduct.price) + "</p>\n" +
517
+ " <a class=\"btn-primary\" href=\"/products/" + _esc(featuredProduct.slug) + "\">View product <span aria-hidden=\"true\">→</span></a>\n" +
518
+ " </div>\n" +
519
+ " </div>\n" +
520
+ "</section>";
521
+ }
522
+
523
+ var hero = _render(HOME_HERO, { product_count: heroProductCount })
524
+ .replace("RAW_FEATURED_CALLOUT", featuredHtml);
477
525
  // The hero + value band + catalog section give the home page a
478
526
  // designed surface even when no products are loaded yet —
479
527
  // visitors land on the storefront shell, not a tech demo.
@@ -1447,15 +1495,21 @@ var ACCOUNT_DASH_PAGE =
1447
1495
  " <div>\n" +
1448
1496
  " <p class=\"eyebrow\">Account</p>\n" +
1449
1497
  " <h1 class=\"section-head__title\">Hi, {{display_name}}</h1>\n" +
1450
- " <p class=\"section-head__lede\">Your recent orders + account controls.</p>\n" +
1498
+ " <p class=\"section-head__lede\">Your orders + account controls. Every order ships from origin with a Stripe-secured receipt.</p>\n" +
1451
1499
  " </div>\n" +
1452
1500
  " <form method=\"post\" action=\"/account/logout\"><button type=\"submit\" class=\"btn-ghost\">Sign out</button></form>\n" +
1453
1501
  " </header>\n" +
1502
+ " <dl class=\"account-dash__stats\">\n" +
1503
+ " <div><dt>Orders</dt><dd>{{order_count}}</dd></div>\n" +
1504
+ " <div><dt>Lifetime spend</dt><dd>{{lifetime_spend}}</dd></div>\n" +
1505
+ " <div><dt>Member since</dt><dd>{{member_since}}</dd></div>\n" +
1506
+ " <div><dt>Passkeys</dt><dd>{{passkey_count}}</dd></div>\n" +
1507
+ " </dl>\n" +
1454
1508
  " <div class=\"account-dash__body\">\n" +
1455
1509
  " <h2 class=\"pdp__variants-title\">Recent orders</h2>\n" +
1456
1510
  " <div class=\"table-scroll\">\n" +
1457
- " <table>\n" +
1458
- " <thead><tr><th>Order</th><th>Status</th><th>Total</th></tr></thead>\n" +
1511
+ " <table class=\"account-orders-table\">\n" +
1512
+ " <thead><tr><th>Order</th><th>Items</th><th>Status</th><th>Total</th></tr></thead>\n" +
1459
1513
  " <tbody>{{order_rows}}</tbody>\n" +
1460
1514
  " </table>\n" +
1461
1515
  " </div>\n" +
@@ -1463,22 +1517,82 @@ var ACCOUNT_DASH_PAGE =
1463
1517
  "</section>\n";
1464
1518
 
1465
1519
  var ACCOUNT_DASH_ORDER_ROW =
1466
- "<tr><td><a href=\"/orders/{{order_id}}\">{{order_id_short}}</a></td><td>{{status}}</td><td class=\"price\">{{total}}</td></tr>\n";
1520
+ "<tr>\n" +
1521
+ " <td><a href=\"/orders/{{order_id}}\" class=\"account-order__id\"><code>{{order_id_short}}</code></a></td>\n" +
1522
+ " <td class=\"account-order__items\">RAW_ACCOUNT_ORDER_THUMBS</td>\n" +
1523
+ " <td><span class=\"pdp__badge {{status_class}}\">{{status}}</span></td>\n" +
1524
+ " <td class=\"price\">{{total}}</td>\n" +
1525
+ "</tr>\n";
1467
1526
 
1468
1527
  function renderAccount(opts) {
1469
1528
  if (!opts || !opts.customer) throw new TypeError("storefront.renderAccount: opts.customer required");
1470
- var rows = (opts.orders || []).map(function (o) {
1529
+ var orders = opts.orders || [];
1530
+ var assetPrefix = opts.asset_prefix || "/assets/";
1531
+ var orderLookup = opts.order_product_lookup || {}; // { order_id: [{ product, hero_media }, ...] }
1532
+ var passkeyCount = opts.passkey_count == null ? 0 : opts.passkey_count;
1533
+
1534
+ // Lifetime spend = sum of grand_total_minor across orders, in
1535
+ // the dominant currency (defaults to USD; mixed-currency
1536
+ // customers are rare in the demo but worth handling — we
1537
+ // fallback to "—" when currencies disagree so the stat doesn't
1538
+ // silently misrepresent the total).
1539
+ var currencies = new Set(orders.map(function (o) { return o.currency || "USD"; }));
1540
+ var lifetimeStr = "—";
1541
+ if (currencies.size <= 1) {
1542
+ var total = orders.reduce(function (acc, o) { return acc + (o.grand_total_minor || 0); }, 0);
1543
+ var ccy = orders.length ? (orders[0].currency || "USD") : "USD";
1544
+ lifetimeStr = pricing.format(total, ccy);
1545
+ }
1546
+
1547
+ // Member-since — earliest known order date, or "—" if none.
1548
+ // The customer row may carry a `created_at` epoch ms; if so
1549
+ // prefer that (it's authoritative).
1550
+ var memberSince = "—";
1551
+ if (opts.customer.created_at) {
1552
+ memberSince = new Date(opts.customer.created_at).toISOString().slice(0, 10);
1553
+ } else if (orders.length) {
1554
+ var earliest = orders.reduce(function (acc, o) {
1555
+ var t = o.created_at || Infinity;
1556
+ return t < acc ? t : acc;
1557
+ }, Infinity);
1558
+ if (isFinite(earliest)) memberSince = new Date(earliest).toISOString().slice(0, 10);
1559
+ }
1560
+
1561
+ function _statusClass(s) {
1562
+ if (s === "completed" || s === "shipped" || s === "delivered") return "pdp__badge--ok";
1563
+ return "";
1564
+ }
1565
+ function _escAttr(s) {
1566
+ return String(s == null ? "" : s)
1567
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1568
+ }
1569
+
1570
+ var rows = orders.map(function (o) {
1571
+ var products = orderLookup[o.id] || [];
1572
+ var thumbs = products.slice(0, 4).map(function (entry) {
1573
+ if (entry && entry.hero_media) {
1574
+ return "<span class=\"account-order__thumb\"><img src=\"" + _escAttr(assetPrefix + entry.hero_media.r2_key) + "\" alt=\"" + _escAttr(entry.hero_media.alt_text || (entry.product && entry.product.title) || "") + "\" loading=\"lazy\"></span>";
1575
+ }
1576
+ return "<span class=\"account-order__thumb account-order__thumb--empty\" aria-hidden=\"true\"></span>";
1577
+ }).join("");
1578
+ if (!thumbs) thumbs = "<span class=\"account-order__thumb account-order__thumb--empty\" aria-hidden=\"true\"></span>";
1579
+ var moreCount = products.length > 4 ? "<span class=\"account-order__more\">+" + (products.length - 4) + "</span>" : "";
1471
1580
  return _render(ACCOUNT_DASH_ORDER_ROW, {
1472
1581
  order_id: o.id,
1473
1582
  order_id_short: o.id.slice(0, 8),
1474
1583
  status: o.status,
1584
+ status_class: _statusClass(o.status),
1475
1585
  total: pricing.format(o.grand_total_minor, o.currency),
1476
- });
1586
+ }).replace("RAW_ACCOUNT_ORDER_THUMBS", thumbs + moreCount);
1477
1587
  }).join("");
1478
- if (!rows) rows = "<tr><td colspan=\"3\" class=\"empty\">No orders yet.</td></tr>";
1588
+ if (!rows) rows = "<tr><td colspan=\"4\" class=\"empty\">No orders yet. Browse the shop and your first order shows up here.</td></tr>";
1479
1589
  var body = _render(ACCOUNT_DASH_PAGE, {
1480
- display_name: opts.customer.display_name,
1481
- order_rows: "RAW_ORDER_ROWS",
1590
+ display_name: opts.customer.display_name,
1591
+ order_count: String(orders.length),
1592
+ lifetime_spend: lifetimeStr,
1593
+ member_since: memberSince,
1594
+ passkey_count: String(passkeyCount),
1595
+ order_rows: "RAW_ORDER_ROWS",
1482
1596
  }).replace("RAW_ORDER_ROWS", rows);
1483
1597
  return _wrap({
1484
1598
  title: "Account",
@@ -2125,9 +2239,52 @@ function mount(router, deps) {
2125
2239
  var page = await deps.order.listForCustomer(customer.id, { limit: 10 });
2126
2240
  orders = page.rows;
2127
2241
  }
2242
+ // Per-order product thumbnails. For each order, walk its
2243
+ // frozen lines, collapse to unique variant ids, then resolve
2244
+ // each through cached `catalog.variants.get` →
2245
+ // `catalog.products.get` + `catalog.media.listForProduct`.
2246
+ // The per-order list is capped at the first four entries the
2247
+ // renderer surfaces (with a "+N" overflow chip when there are
2248
+ // more) so a multi-line order doesn't blow up the table row.
2249
+ var orderProductLookup = {};
2250
+ var variantCache = {};
2251
+ for (var oi = 0; oi < orders.length; oi += 1) {
2252
+ var ord = orders[oi];
2253
+ var entries = [];
2254
+ var seen = {};
2255
+ for (var li = 0; li < (ord.lines || []).length; li += 1) {
2256
+ var vId = ord.lines[li].variant_id;
2257
+ if (seen[vId]) continue;
2258
+ seen[vId] = true;
2259
+ if (!variantCache[vId]) {
2260
+ var v = await deps.catalog.variants.get(vId);
2261
+ if (!v) { variantCache[vId] = null; continue; }
2262
+ var prod = await deps.catalog.products.get(v.product_id);
2263
+ var media = await deps.catalog.media.listForProduct(v.product_id);
2264
+ variantCache[vId] = {
2265
+ product: prod,
2266
+ hero_media: media.length ? media[0] : null,
2267
+ };
2268
+ }
2269
+ if (variantCache[vId]) entries.push(variantCache[vId]);
2270
+ }
2271
+ orderProductLookup[ord.id] = entries;
2272
+ }
2273
+ // Passkey count — how many devices the customer has enrolled.
2274
+ var passkeyCount = 0;
2275
+ try {
2276
+ var pks = await deps.customers.listPasskeys(customer.id);
2277
+ passkeyCount = Array.isArray(pks) ? pks.length : 0;
2278
+ } catch (_e) { /* drop-silent — primitive may not expose listPasskeys on every build */ }
2279
+
2128
2280
  var cartCount = await _cartCountForReq(req);
2129
2281
  _send(res, 200, renderAccount({
2130
- customer: customer, orders: orders, shop_name: shopName, cart_count: cartCount,
2282
+ customer: customer,
2283
+ orders: orders,
2284
+ order_product_lookup: orderProductLookup,
2285
+ passkey_count: passkeyCount,
2286
+ shop_name: shopName,
2287
+ cart_count: cartCount,
2131
2288
  }));
2132
2289
  });
2133
2290
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.0.46",
3
+ "version": "0.0.48",
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": {