@blamejs/blamejs-shop 0.0.46 → 0.0.47

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.0.x
10
10
 
11
+ - 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.
12
+
11
13
  - 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
14
 
13
15
  - 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
@@ -1447,15 +1447,21 @@ var ACCOUNT_DASH_PAGE =
1447
1447
  " <div>\n" +
1448
1448
  " <p class=\"eyebrow\">Account</p>\n" +
1449
1449
  " <h1 class=\"section-head__title\">Hi, {{display_name}}</h1>\n" +
1450
- " <p class=\"section-head__lede\">Your recent orders + account controls.</p>\n" +
1450
+ " <p class=\"section-head__lede\">Your orders + account controls. Every order ships from origin with a Stripe-secured receipt.</p>\n" +
1451
1451
  " </div>\n" +
1452
1452
  " <form method=\"post\" action=\"/account/logout\"><button type=\"submit\" class=\"btn-ghost\">Sign out</button></form>\n" +
1453
1453
  " </header>\n" +
1454
+ " <dl class=\"account-dash__stats\">\n" +
1455
+ " <div><dt>Orders</dt><dd>{{order_count}}</dd></div>\n" +
1456
+ " <div><dt>Lifetime spend</dt><dd>{{lifetime_spend}}</dd></div>\n" +
1457
+ " <div><dt>Member since</dt><dd>{{member_since}}</dd></div>\n" +
1458
+ " <div><dt>Passkeys</dt><dd>{{passkey_count}}</dd></div>\n" +
1459
+ " </dl>\n" +
1454
1460
  " <div class=\"account-dash__body\">\n" +
1455
1461
  " <h2 class=\"pdp__variants-title\">Recent orders</h2>\n" +
1456
1462
  " <div class=\"table-scroll\">\n" +
1457
- " <table>\n" +
1458
- " <thead><tr><th>Order</th><th>Status</th><th>Total</th></tr></thead>\n" +
1463
+ " <table class=\"account-orders-table\">\n" +
1464
+ " <thead><tr><th>Order</th><th>Items</th><th>Status</th><th>Total</th></tr></thead>\n" +
1459
1465
  " <tbody>{{order_rows}}</tbody>\n" +
1460
1466
  " </table>\n" +
1461
1467
  " </div>\n" +
@@ -1463,22 +1469,82 @@ var ACCOUNT_DASH_PAGE =
1463
1469
  "</section>\n";
1464
1470
 
1465
1471
  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";
1472
+ "<tr>\n" +
1473
+ " <td><a href=\"/orders/{{order_id}}\" class=\"account-order__id\"><code>{{order_id_short}}</code></a></td>\n" +
1474
+ " <td class=\"account-order__items\">RAW_ACCOUNT_ORDER_THUMBS</td>\n" +
1475
+ " <td><span class=\"pdp__badge {{status_class}}\">{{status}}</span></td>\n" +
1476
+ " <td class=\"price\">{{total}}</td>\n" +
1477
+ "</tr>\n";
1467
1478
 
1468
1479
  function renderAccount(opts) {
1469
1480
  if (!opts || !opts.customer) throw new TypeError("storefront.renderAccount: opts.customer required");
1470
- var rows = (opts.orders || []).map(function (o) {
1481
+ var orders = opts.orders || [];
1482
+ var assetPrefix = opts.asset_prefix || "/assets/";
1483
+ var orderLookup = opts.order_product_lookup || {}; // { order_id: [{ product, hero_media }, ...] }
1484
+ var passkeyCount = opts.passkey_count == null ? 0 : opts.passkey_count;
1485
+
1486
+ // Lifetime spend = sum of grand_total_minor across orders, in
1487
+ // the dominant currency (defaults to USD; mixed-currency
1488
+ // customers are rare in the demo but worth handling — we
1489
+ // fallback to "—" when currencies disagree so the stat doesn't
1490
+ // silently misrepresent the total).
1491
+ var currencies = new Set(orders.map(function (o) { return o.currency || "USD"; }));
1492
+ var lifetimeStr = "—";
1493
+ if (currencies.size <= 1) {
1494
+ var total = orders.reduce(function (acc, o) { return acc + (o.grand_total_minor || 0); }, 0);
1495
+ var ccy = orders.length ? (orders[0].currency || "USD") : "USD";
1496
+ lifetimeStr = pricing.format(total, ccy);
1497
+ }
1498
+
1499
+ // Member-since — earliest known order date, or "—" if none.
1500
+ // The customer row may carry a `created_at` epoch ms; if so
1501
+ // prefer that (it's authoritative).
1502
+ var memberSince = "—";
1503
+ if (opts.customer.created_at) {
1504
+ memberSince = new Date(opts.customer.created_at).toISOString().slice(0, 10);
1505
+ } else if (orders.length) {
1506
+ var earliest = orders.reduce(function (acc, o) {
1507
+ var t = o.created_at || Infinity;
1508
+ return t < acc ? t : acc;
1509
+ }, Infinity);
1510
+ if (isFinite(earliest)) memberSince = new Date(earliest).toISOString().slice(0, 10);
1511
+ }
1512
+
1513
+ function _statusClass(s) {
1514
+ if (s === "completed" || s === "shipped" || s === "delivered") return "pdp__badge--ok";
1515
+ return "";
1516
+ }
1517
+ function _escAttr(s) {
1518
+ return String(s == null ? "" : s)
1519
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1520
+ }
1521
+
1522
+ var rows = orders.map(function (o) {
1523
+ var products = orderLookup[o.id] || [];
1524
+ var thumbs = products.slice(0, 4).map(function (entry) {
1525
+ if (entry && entry.hero_media) {
1526
+ 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>";
1527
+ }
1528
+ return "<span class=\"account-order__thumb account-order__thumb--empty\" aria-hidden=\"true\"></span>";
1529
+ }).join("");
1530
+ if (!thumbs) thumbs = "<span class=\"account-order__thumb account-order__thumb--empty\" aria-hidden=\"true\"></span>";
1531
+ var moreCount = products.length > 4 ? "<span class=\"account-order__more\">+" + (products.length - 4) + "</span>" : "";
1471
1532
  return _render(ACCOUNT_DASH_ORDER_ROW, {
1472
1533
  order_id: o.id,
1473
1534
  order_id_short: o.id.slice(0, 8),
1474
1535
  status: o.status,
1536
+ status_class: _statusClass(o.status),
1475
1537
  total: pricing.format(o.grand_total_minor, o.currency),
1476
- });
1538
+ }).replace("RAW_ACCOUNT_ORDER_THUMBS", thumbs + moreCount);
1477
1539
  }).join("");
1478
- if (!rows) rows = "<tr><td colspan=\"3\" class=\"empty\">No orders yet.</td></tr>";
1540
+ 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
1541
  var body = _render(ACCOUNT_DASH_PAGE, {
1480
- display_name: opts.customer.display_name,
1481
- order_rows: "RAW_ORDER_ROWS",
1542
+ display_name: opts.customer.display_name,
1543
+ order_count: String(orders.length),
1544
+ lifetime_spend: lifetimeStr,
1545
+ member_since: memberSince,
1546
+ passkey_count: String(passkeyCount),
1547
+ order_rows: "RAW_ORDER_ROWS",
1482
1548
  }).replace("RAW_ORDER_ROWS", rows);
1483
1549
  return _wrap({
1484
1550
  title: "Account",
@@ -2125,9 +2191,52 @@ function mount(router, deps) {
2125
2191
  var page = await deps.order.listForCustomer(customer.id, { limit: 10 });
2126
2192
  orders = page.rows;
2127
2193
  }
2194
+ // Per-order product thumbnails. For each order, walk its
2195
+ // frozen lines, collapse to unique variant ids, then resolve
2196
+ // each through cached `catalog.variants.get` →
2197
+ // `catalog.products.get` + `catalog.media.listForProduct`.
2198
+ // The per-order list is capped at the first four entries the
2199
+ // renderer surfaces (with a "+N" overflow chip when there are
2200
+ // more) so a multi-line order doesn't blow up the table row.
2201
+ var orderProductLookup = {};
2202
+ var variantCache = {};
2203
+ for (var oi = 0; oi < orders.length; oi += 1) {
2204
+ var ord = orders[oi];
2205
+ var entries = [];
2206
+ var seen = {};
2207
+ for (var li = 0; li < (ord.lines || []).length; li += 1) {
2208
+ var vId = ord.lines[li].variant_id;
2209
+ if (seen[vId]) continue;
2210
+ seen[vId] = true;
2211
+ if (!variantCache[vId]) {
2212
+ var v = await deps.catalog.variants.get(vId);
2213
+ if (!v) { variantCache[vId] = null; continue; }
2214
+ var prod = await deps.catalog.products.get(v.product_id);
2215
+ var media = await deps.catalog.media.listForProduct(v.product_id);
2216
+ variantCache[vId] = {
2217
+ product: prod,
2218
+ hero_media: media.length ? media[0] : null,
2219
+ };
2220
+ }
2221
+ if (variantCache[vId]) entries.push(variantCache[vId]);
2222
+ }
2223
+ orderProductLookup[ord.id] = entries;
2224
+ }
2225
+ // Passkey count — how many devices the customer has enrolled.
2226
+ var passkeyCount = 0;
2227
+ try {
2228
+ var pks = await deps.customers.listPasskeys(customer.id);
2229
+ passkeyCount = Array.isArray(pks) ? pks.length : 0;
2230
+ } catch (_e) { /* drop-silent — primitive may not expose listPasskeys on every build */ }
2231
+
2128
2232
  var cartCount = await _cartCountForReq(req);
2129
2233
  _send(res, 200, renderAccount({
2130
- customer: customer, orders: orders, shop_name: shopName, cart_count: cartCount,
2234
+ customer: customer,
2235
+ orders: orders,
2236
+ order_product_lookup: orderProductLookup,
2237
+ passkey_count: passkeyCount,
2238
+ shop_name: shopName,
2239
+ cart_count: cartCount,
2131
2240
  }));
2132
2241
  });
2133
2242
 
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.47",
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": {