@blamejs/blamejs-shop 0.0.126 → 0.0.128
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +5 -1
- package/lib/admin.js +113 -0
- package/lib/returns.js +51 -0
- package/lib/storefront.js +339 -0
- package/lib/vendor/MANIFEST.json +3 -3
- package/lib/vendor/blamejs/CHANGELOG.md +14 -0
- package/lib/vendor/blamejs/README.md +6 -0
- package/lib/vendor/blamejs/SECURITY.md +4 -0
- package/lib/vendor/blamejs/api-snapshot.json +424 -2
- package/lib/vendor/blamejs/index.js +6 -0
- package/lib/vendor/blamejs/lib/cose.js +547 -0
- package/lib/vendor/blamejs/lib/cwt.js +244 -0
- package/lib/vendor/blamejs/lib/eat.js +240 -0
- package/lib/vendor/blamejs/lib/scitt.js +243 -0
- package/lib/vendor/blamejs/lib/tsa.js +688 -0
- package/lib/vendor/blamejs/lib/vc.js +328 -0
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.33.json +31 -0
- package/lib/vendor/blamejs/release-notes/v0.12.34.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.12.35.json +22 -0
- package/lib/vendor/blamejs/release-notes/v0.12.36.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.12.37.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.12.38.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.12.39.json +18 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +24 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cose.test.js +213 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cwt.test.js +137 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/eat.test.js +111 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/scitt.test.js +158 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/tsa.test.js +373 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/vc.test.js +188 -0
- 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.0.x
|
|
10
10
|
|
|
11
|
+
- v0.0.128 (2026-05-25) — **Collections — browse curated and smart product lists at /collections.** The storefront now has real collection pages. `/collections` lists the shop's active collections, and `/collections/:slug` shows a collection's products as a grid — resolving both operator-curated manual collections (hand-picked members) and smart collections (rule-matched against the catalog). The footer links to it from every page, so collections are a first-class browse entry point rather than a search query. Unknown or malformed collection slugs return 404, never a 500. **Added:** *Collection browse pages* — `GET /collections` renders the active collections as cards (title, description, hero); `GET /collections/:slug` renders the collection's product grid, reusing the standard product card (image, title, price, link to the PDP). Public pages — no sign-in. · *Manual + smart resolution* — Manual collections list their hand-picked members; smart collections evaluate their stored rules against the active catalog and apply the collection's sort strategy. The page resolves each product fresh, so archived products drop out of the grid. · *Footer entry point* — The footer's Shop column links to `/collections` on every storefront page (edge- and container-rendered alike), making collections a real browse path. A bad or unknown slug is a 404.
|
|
12
|
+
|
|
13
|
+
- v0.0.127 (2026-05-24) — **Self-serve returns — customers request RMAs on their orders, operators approve and refund.** Signed-in customers can request a return against one of their own orders — pick the items and a reason at the order, then track status at /account/returns. Operators work the queue at /admin/returns: approve (with a refund amount), mark received, refund, or reject with a reason, following the pending → approved → received → refunded lifecycle. The customer request route loads the order and confirms it belongs to the signed-in customer before showing it, and builds the return lines from the order's own records, so a foreign or guessed order id returns 404 and a client can't return items it never bought. **Added:** *Customer return requests + status* — `/account/orders/:order_id/return` shows the order's items with a reason picker; `/account/returns` lists the customer's RMAs with status (pending / approved / received / refunded / rejected) and any rejection reason. The account dashboard links to it. Empty selection or a bad reason re-renders the form with the message. · *Operator return queue* — `GET /admin/returns?status=pending` lists the queue across all orders; `GET /admin/returns/:id` reads one; `POST /admin/returns/:id/approve` (with refund amount), `/received`, `/refund`, and `/reject` (with reason) walk the lifecycle. Bearer-token-gated; an illegal transition is a 409 and a bad id a 404, never a 500. · *`returns.listByStatus(status, opts)`* — Lists return authorizations across all orders by status, newest first, with the same opaque cursor as `listForCustomer`. Backs the operator queue.
|
|
14
|
+
|
|
11
15
|
- v0.0.126 (2026-05-24) — **Address book — saved shipping and billing addresses on the account page.** Signed-in customers can now keep a book of addresses at /account/addresses: add, edit, set a default shipping or billing address, and remove. Addresses are per-customer; every action that targets a specific address first confirms it belongs to the signed-in customer, so a guessed id can't read or change someone else's address. **Added:** *Address book at `/account/addresses`* — List, add, and edit saved addresses (recipient, company, two street lines, city, region, postal code, ISO country, phone). The account dashboard links to it. A blank product catalog isn't required — this is account surface only. · *Default shipping / billing* — Mark one address as the default shipping and one as the default billing; promoting a new default clears the previous one. Surfaced as badges on each card. · *Ownership-checked mutations* — Edit, update, set-default, and remove all resolve the address by id and verify it belongs to the signed-in customer before acting — a foreign or guessed id returns 404, never another customer's data. Add/edit re-render the form with the validation message on bad input.
|
|
12
16
|
|
|
13
17
|
- v0.0.124 (2026-05-24) — **Save for later — move cart items into a holding list and back.** Each cart line now has a "Save for later" control that moves the item out of the cart into a per-customer holding list without losing it. Saved items live on a new account page where they can be moved back to the cart or removed. Moving an item back reprices it to the current catalog price and checks stock first, so a saved item that sold out (and isn't backorderable) can't silently re-enter the cart. Login-required, since the list is scoped to one customer. **Added:** *Save-for-later control on cart lines* — Each editable cart line gets a Save-for-later control. `POST /cart/lines/:line_id/save` moves the line out of the cart into the customer's saved list (`moveFromCart`). Login-gated — a signed-out shopper is redirected to sign in. · *`/account/saved` — the holding list* — A new account page lists saved items with a thumbnail, the saved price for reference, and Move-to-cart / Remove controls. Items whose product was archived render as "no longer available" rather than breaking the list. The account dashboard links to it (alongside Wishlist). · *Move back to cart, repriced + stock-checked* — `POST /saved/:save_id/move-to-cart` returns the item to the session cart at the current catalog price (not the stale snapshot) and refuses if the SKU is out of stock and not backorderable. `POST /saved/:save_id/remove` drops a saved row.
|
package/README.md
CHANGED
|
@@ -67,9 +67,11 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
67
67
|
| **`lib/wishlist.js`** | Per-customer saved products. The PDP renders a login-gated "Save to wishlist" toggle and a "N shoppers saved this" social-proof count; `/account/wishlist` lists saved items (remove + reopen, orphan-tolerant when a product is archived). `POST /wishlist/toggle` is idempotent (`INSERT OR IGNORE`) and redirects to the canonical product slug or a safe same-origin `return_to`. UUID-shape-validated ids, `b.pagination` HMAC cursors. |
|
|
68
68
|
| **`lib/save-for-later.js`** | Per-customer cart holding list. Each cart line gets a login-gated "Save for later" control (`POST /cart/lines/:id/save` → `moveFromCart`); `/account/saved` lists items with Move-to-cart / Remove. `moveToCart` reprices to the current catalog price and stock-gates (out-of-stock + non-backorderable is refused). Composes `catalog.inventory` + `catalog.prices` + `catalog.variants`. |
|
|
69
69
|
| **`lib/addresses.js`** | Per-customer address book at `/account/addresses` — add / edit / set default shipping or billing / remove. One-default-per-role invariant (promoting clears the prior). Every by-id route confirms the address belongs to the signed-in customer before acting (a guessed id returns 404). `b.guardUuid` ids, 2-char ISO country. |
|
|
70
|
+
| **`lib/returns.js`** | Self-serve RMAs. Customer requests a return against their own order at `/account/orders/:id/return` (items + reason, ownership-checked, lines built from the order's own records) and tracks status at `/account/returns`. Operators work `/admin/returns` — approve (refund amount) / mark received / refund / reject — over the pending → approved → received → refunded FSM; illegal transitions are 409, bad ids 404. |
|
|
71
|
+
| **`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. |
|
|
70
72
|
| **`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. |
|
|
71
73
|
| **`lib/newsletter.js`** | Operator-collected email broadcast list — `signup({ email, source })` composes `b.guardEmail` for shape validation, `b.crypto.namespaceHash` for the dedup key, and `INSERT OR IGNORE` for idempotency. Storefront POST `/newsletter` route renders a designed thank-you card with separate copy for the `new` vs `dedup` branches. |
|
|
72
|
-
| **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. |
|
|
74
|
+
| **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. |
|
|
73
75
|
| **`lib/catalog-import.js`** | Bulk CSV import — `POST /admin/catalog/import` accepts a `text/csv` body, parses via `b.csv`, content-safety-filters every cell through `b.guardCsv` (formula-injection / bidi / control / dangerous-function denylist), validates exact header order, de-dupes rows by `product_slug`, returns per-row errors without aborting. Default 1 MiB / 10000 rows caps. |
|
|
74
76
|
| **`lib/theme.js`** | File-backed templates with fallback chain. Operators register a named theme under `<themesDir>/<name>/*.html` and the storefront dispatches every renderer through it. `assetUrl(path)` resolves to `/assets/themes/<name>/<path>`. The shipped `default` theme is the fallback. |
|
|
75
77
|
|
|
@@ -88,6 +90,8 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
88
90
|
- `migrations-d1/0012_wishlist.sql` — per-customer saved products (unique customer + product + variant)
|
|
89
91
|
- `migrations-d1/0041_save_for_later.sql` — per-customer cart holding list (price snapshot + source line)
|
|
90
92
|
- `migrations-d1/0026_customer_addresses.sql` — per-customer address book (default shipping/billing flags)
|
|
93
|
+
- `migrations-d1/0023_returns.sql` — return authorizations + lines (RMA lifecycle FSM)
|
|
94
|
+
- `migrations-d1/0043_collections.sql` — manual + smart product collections (members + rules + sort strategy)
|
|
91
95
|
|
|
92
96
|
### Demo seed
|
|
93
97
|
|
package/lib/admin.js
CHANGED
|
@@ -163,6 +163,7 @@ function mount(router, deps) {
|
|
|
163
163
|
var assetPrefix = typeof deps.asset_prefix === "string" ? deps.asset_prefix : "/assets/";
|
|
164
164
|
var catalogImport = deps.catalogImport || null; // bulk-import route disabled when absent
|
|
165
165
|
var reviews = deps.reviews || null; // moderation endpoints disabled when absent
|
|
166
|
+
var returns = deps.returns || null; // RMA moderation endpoints disabled when absent
|
|
166
167
|
|
|
167
168
|
try { _b().audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
168
169
|
|
|
@@ -580,6 +581,118 @@ function mount(router, deps) {
|
|
|
580
581
|
}));
|
|
581
582
|
}
|
|
582
583
|
|
|
584
|
+
// ---- returns (moderation) -------------------------------------------
|
|
585
|
+
|
|
586
|
+
// Operator-side RMA moderation. The queue lists return
|
|
587
|
+
// authorizations across all orders in one status (defaults to
|
|
588
|
+
// `pending`); approve / received / refund / reject walk the same FSM
|
|
589
|
+
// the customer-facing request path leaves in `pending`. A bad state
|
|
590
|
+
// transition (e.g. refund-from-pending) and a malformed :id both
|
|
591
|
+
// surface as client errors (4xx), never a 500. Endpoints are omitted
|
|
592
|
+
// entirely when no returns primitive is wired.
|
|
593
|
+
if (returns) {
|
|
594
|
+
function _returnsClientError(e) {
|
|
595
|
+
// A transition refused by the FSM or a not-found row is the
|
|
596
|
+
// caller's problem, not the server's. `_currentStatus` raises a
|
|
597
|
+
// not-found TypeError; `_assertTransition` raises an Error tagged
|
|
598
|
+
// RMA_TRANSITION_REFUSED. Map both to 4xx. (Bad-shape input is a
|
|
599
|
+
// plain TypeError, which the wrapper already maps to 400.)
|
|
600
|
+
if (!e) return null;
|
|
601
|
+
if (e.code === "RMA_NOT_FOUND") return { status: 404, slug: "return-not-found" };
|
|
602
|
+
if (e.code === "RMA_TRANSITION_REFUSED") return { status: 409, slug: "return-transition-refused" };
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
router.get("/admin/returns", R(async function (req, res) {
|
|
607
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
608
|
+
var status = (url && url.searchParams.get("status")) || "pending";
|
|
609
|
+
var cursor = url && url.searchParams.get("cursor");
|
|
610
|
+
var limitS = url && url.searchParams.get("limit");
|
|
611
|
+
var limit = limitS == null ? undefined : parseInt(limitS, 10);
|
|
612
|
+
var page = await returns.listByStatus(status, { cursor: cursor || undefined, limit: limit });
|
|
613
|
+
_json(res, 200, page);
|
|
614
|
+
}));
|
|
615
|
+
|
|
616
|
+
router.get("/admin/returns/:id", R(async function (req, res) {
|
|
617
|
+
var rma;
|
|
618
|
+
try {
|
|
619
|
+
rma = await returns.get(req.params.id);
|
|
620
|
+
} catch (e) {
|
|
621
|
+
// A non-UUID :id raises a guardUuid TypeError — surface it as a
|
|
622
|
+
// 404 (the route is a defensive request-shape reader, never a
|
|
623
|
+
// 500). Re-raise anything that isn't the bad-id shape so the
|
|
624
|
+
// wrapper's generic handling applies.
|
|
625
|
+
if (e instanceof TypeError) return _problem(res, 404, "return-not-found");
|
|
626
|
+
throw e;
|
|
627
|
+
}
|
|
628
|
+
if (!rma) return _problem(res, 404, "return-not-found");
|
|
629
|
+
_json(res, 200, rma);
|
|
630
|
+
}));
|
|
631
|
+
|
|
632
|
+
router.post("/admin/returns/:id/approve", W("return.approve", async function (req, res) {
|
|
633
|
+
var body = req.body || {};
|
|
634
|
+
var rma;
|
|
635
|
+
try {
|
|
636
|
+
rma = await returns.approve(req.params.id, {
|
|
637
|
+
refund_amount_minor: body.refund_amount_minor,
|
|
638
|
+
refund_currency: body.refund_currency,
|
|
639
|
+
operator_notes: body.operator_notes,
|
|
640
|
+
});
|
|
641
|
+
} catch (e) {
|
|
642
|
+
var ce = _returnsClientError(e);
|
|
643
|
+
if (ce) return _problem(res, ce.status, ce.slug, e.message);
|
|
644
|
+
throw e;
|
|
645
|
+
}
|
|
646
|
+
_json(res, 200, rma);
|
|
647
|
+
return rma;
|
|
648
|
+
}));
|
|
649
|
+
|
|
650
|
+
router.post("/admin/returns/:id/received", W("return.received", async function (req, res) {
|
|
651
|
+
var body = req.body || {};
|
|
652
|
+
var rma;
|
|
653
|
+
try {
|
|
654
|
+
rma = await returns.markReceived(req.params.id, { operator_notes: body.operator_notes });
|
|
655
|
+
} catch (e) {
|
|
656
|
+
var ce = _returnsClientError(e);
|
|
657
|
+
if (ce) return _problem(res, ce.status, ce.slug, e.message);
|
|
658
|
+
throw e;
|
|
659
|
+
}
|
|
660
|
+
_json(res, 200, rma);
|
|
661
|
+
return rma;
|
|
662
|
+
}));
|
|
663
|
+
|
|
664
|
+
router.post("/admin/returns/:id/refund", W("return.refund", async function (req, res) {
|
|
665
|
+
var body = req.body || {};
|
|
666
|
+
var rma;
|
|
667
|
+
try {
|
|
668
|
+
rma = await returns.refund(req.params.id, { operator_notes: body.operator_notes });
|
|
669
|
+
} catch (e) {
|
|
670
|
+
var ce = _returnsClientError(e);
|
|
671
|
+
if (ce) return _problem(res, ce.status, ce.slug, e.message);
|
|
672
|
+
throw e;
|
|
673
|
+
}
|
|
674
|
+
_json(res, 200, rma);
|
|
675
|
+
return rma;
|
|
676
|
+
}));
|
|
677
|
+
|
|
678
|
+
router.post("/admin/returns/:id/reject", W("return.reject", async function (req, res) {
|
|
679
|
+
var body = req.body || {};
|
|
680
|
+
var rma;
|
|
681
|
+
try {
|
|
682
|
+
rma = await returns.reject(req.params.id, {
|
|
683
|
+
rejected_reason: body.rejected_reason,
|
|
684
|
+
operator_notes: body.operator_notes,
|
|
685
|
+
});
|
|
686
|
+
} catch (e) {
|
|
687
|
+
var ce = _returnsClientError(e);
|
|
688
|
+
if (ce) return _problem(res, ce.status, ce.slug, e.message);
|
|
689
|
+
throw e;
|
|
690
|
+
}
|
|
691
|
+
_json(res, 200, rma);
|
|
692
|
+
return rma;
|
|
693
|
+
}));
|
|
694
|
+
}
|
|
695
|
+
|
|
583
696
|
// ---- config ---------------------------------------------------------
|
|
584
697
|
|
|
585
698
|
var config = deps.config || null;
|
package/lib/returns.js
CHANGED
|
@@ -516,6 +516,57 @@ function create(opts) {
|
|
|
516
516
|
return { rows: rows, next_cursor: next };
|
|
517
517
|
},
|
|
518
518
|
|
|
519
|
+
listByStatus: async function (status, listOpts) {
|
|
520
|
+
var statusFilter = _status(status);
|
|
521
|
+
listOpts = listOpts || {};
|
|
522
|
+
var limit = listOpts.limit == null ? DEFAULT_LIST_LIMIT : listOpts.limit;
|
|
523
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIST_LIMIT) {
|
|
524
|
+
throw new TypeError("returns.listByStatus: limit must be 1..." + MAX_LIST_LIMIT);
|
|
525
|
+
}
|
|
526
|
+
var cursorVals = null;
|
|
527
|
+
if (listOpts.cursor != null) {
|
|
528
|
+
if (typeof listOpts.cursor !== "string") {
|
|
529
|
+
throw new TypeError("returns.listByStatus: cursor must be an opaque string or null");
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
var state = _b().pagination.decodeCursor(listOpts.cursor, cursorSecret);
|
|
533
|
+
if (JSON.stringify(state.orderKey) !== JSON.stringify(RMA_ORDER_KEY)) {
|
|
534
|
+
throw new TypeError("returns.listByStatus: cursor orderKey mismatch");
|
|
535
|
+
}
|
|
536
|
+
cursorVals = state.vals;
|
|
537
|
+
} catch (e) {
|
|
538
|
+
if (e instanceof TypeError) throw e;
|
|
539
|
+
throw new TypeError("returns.listByStatus: cursor — " + (e && e.message || "malformed"));
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
var sql, params;
|
|
544
|
+
if (cursorVals) {
|
|
545
|
+
sql = "SELECT * FROM return_authorizations WHERE status = ?1 AND " +
|
|
546
|
+
"(created_at < ?2 OR (created_at = ?2 AND id < ?3)) " +
|
|
547
|
+
"ORDER BY created_at DESC, id DESC LIMIT ?4";
|
|
548
|
+
params = [statusFilter, cursorVals[0], cursorVals[1], limit];
|
|
549
|
+
} else {
|
|
550
|
+
sql = "SELECT * FROM return_authorizations WHERE status = ?1 " +
|
|
551
|
+
"ORDER BY created_at DESC, id DESC LIMIT ?2";
|
|
552
|
+
params = [statusFilter, limit];
|
|
553
|
+
}
|
|
554
|
+
var rows = (await query(sql, params)).rows;
|
|
555
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
556
|
+
await _hydrate(rows[i]);
|
|
557
|
+
}
|
|
558
|
+
var last = rows[rows.length - 1];
|
|
559
|
+
var next = null;
|
|
560
|
+
if (last && rows.length === limit) {
|
|
561
|
+
next = _b().pagination.encodeCursor({
|
|
562
|
+
orderKey: RMA_ORDER_KEY,
|
|
563
|
+
vals: [last.created_at, last.id],
|
|
564
|
+
forward: true,
|
|
565
|
+
}, cursorSecret);
|
|
566
|
+
}
|
|
567
|
+
return { rows: rows, next_cursor: next };
|
|
568
|
+
},
|
|
569
|
+
|
|
519
570
|
summaryForOperator: async function (input) {
|
|
520
571
|
input = input || {};
|
|
521
572
|
var from = _epochOrNull(input.from, "from");
|
package/lib/storefront.js
CHANGED
|
@@ -137,6 +137,7 @@ var LAYOUT =
|
|
|
137
137
|
" <h4>Shop</h4>\n" +
|
|
138
138
|
" <ul>\n" +
|
|
139
139
|
" <li><a href=\"/\">All products</a></li>\n" +
|
|
140
|
+
" <li><a href=\"/collections\">Collections</a></li>\n" +
|
|
140
141
|
" <li><a href=\"/?sort=new\">New arrivals</a></li>\n" +
|
|
141
142
|
" <li><a href=\"/?sort=sale\">On sale</a></li>\n" +
|
|
142
143
|
" <li><a href=\"/cart\">Cart</a></li>\n" +
|
|
@@ -1134,6 +1135,184 @@ function renderAddresses(opts) {
|
|
|
1134
1135
|
});
|
|
1135
1136
|
}
|
|
1136
1137
|
|
|
1138
|
+
// Storefront collection index — operator-curated + smart product lists.
|
|
1139
|
+
function renderCollectionList(opts) {
|
|
1140
|
+
var esc = _b().template.escapeHtml;
|
|
1141
|
+
var cols = opts.collections || [];
|
|
1142
|
+
var cardsHtml = "";
|
|
1143
|
+
for (var i = 0; i < cols.length; i += 1) {
|
|
1144
|
+
var c = cols[i];
|
|
1145
|
+
var media = c.hero_image_url
|
|
1146
|
+
? "<figure class=\"collection-index-card__media\"><img src=\"" + esc((opts.asset_prefix || "/assets/") + c.hero_image_url) + "\" alt=\"" + esc(c.title) + "\" loading=\"lazy\"></figure>"
|
|
1147
|
+
: "<figure class=\"collection-index-card__media collection-index-card__media--empty\" aria-hidden=\"true\"></figure>";
|
|
1148
|
+
cardsHtml +=
|
|
1149
|
+
"<a class=\"collection-index-card\" href=\"/collections/" + esc(c.slug) + "\">" +
|
|
1150
|
+
media +
|
|
1151
|
+
"<div class=\"collection-index-card__meta\">" +
|
|
1152
|
+
"<h2 class=\"collection-index-card__title\">" + esc(c.title) + "</h2>" +
|
|
1153
|
+
(c.description ? "<p class=\"collection-index-card__desc\">" + esc(c.description) + "</p>" : "") +
|
|
1154
|
+
"</div>" +
|
|
1155
|
+
"</a>";
|
|
1156
|
+
}
|
|
1157
|
+
var inner = cardsHtml
|
|
1158
|
+
? "<div class=\"collection-index-grid\">" + cardsHtml + "</div>"
|
|
1159
|
+
: "<p class=\"collection-empty\">No collections yet.</p>";
|
|
1160
|
+
var body =
|
|
1161
|
+
"<section class=\"collection-index\">" +
|
|
1162
|
+
"<header class=\"section-head\"><p class=\"eyebrow\">Browse</p>" +
|
|
1163
|
+
"<h1 class=\"section-head__title\">Collections</h1></header>" +
|
|
1164
|
+
inner +
|
|
1165
|
+
"</section>";
|
|
1166
|
+
return _wrap({
|
|
1167
|
+
title: "Collections", shop_name: opts.shop_name || "blamejs.shop",
|
|
1168
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count, theme_css: opts.theme_css, body: body,
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// A single collection's page — title + description + product grid. The
|
|
1173
|
+
// route resolves each member product into the { slug, title, price,
|
|
1174
|
+
// image_url } shape `_buildProductCard` expects.
|
|
1175
|
+
function renderCollection(opts) {
|
|
1176
|
+
var esc = _b().template.escapeHtml;
|
|
1177
|
+
var col = opts.collection;
|
|
1178
|
+
var products = opts.products || [];
|
|
1179
|
+
var cards = products.map(function (p) { return _buildProductCard(p); }).join("");
|
|
1180
|
+
var grid = cards
|
|
1181
|
+
? "<div class=\"catalog-grid collection-grid\">" + cards + "</div>"
|
|
1182
|
+
: "<p class=\"collection-empty\">No products in this collection yet.</p>";
|
|
1183
|
+
var body =
|
|
1184
|
+
"<section class=\"collection-page\">" +
|
|
1185
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
1186
|
+
"<li><a href=\"/\">Shop</a></li>" +
|
|
1187
|
+
"<li><a href=\"/collections\">Collections</a></li>" +
|
|
1188
|
+
"<li aria-current=\"page\">" + esc(col.title) + "</li>" +
|
|
1189
|
+
"</ol></nav>" +
|
|
1190
|
+
"<header class=\"collection-page__head\">" +
|
|
1191
|
+
"<h1 class=\"collection-page__title\">" + esc(col.title) + "</h1>" +
|
|
1192
|
+
(col.description ? "<p class=\"collection-page__desc\">" + esc(col.description) + "</p>" : "") +
|
|
1193
|
+
"</header>" +
|
|
1194
|
+
grid +
|
|
1195
|
+
"</section>";
|
|
1196
|
+
return _wrap({
|
|
1197
|
+
title: col.title, shop_name: opts.shop_name || "blamejs.shop",
|
|
1198
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count, theme_css: opts.theme_css, body: body,
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
var RETURN_REASONS = [
|
|
1203
|
+
["defective", "Defective / doesn't work"],
|
|
1204
|
+
["wrong-item", "Wrong item received"],
|
|
1205
|
+
["not-as-described", "Not as described"],
|
|
1206
|
+
["no-longer-needed", "No longer needed"],
|
|
1207
|
+
["damaged-in-transit", "Damaged in transit"],
|
|
1208
|
+
["other", "Other"],
|
|
1209
|
+
];
|
|
1210
|
+
|
|
1211
|
+
function _returnStatusBadge(status) {
|
|
1212
|
+
return "<span class=\"return-status return-status--" + _b().template.escapeHtml(String(status)) + "\">" +
|
|
1213
|
+
_b().template.escapeHtml(String(status)) + "</span>";
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// Customer-facing return-request form for one order. `opts.order` is the
|
|
1217
|
+
// order row, `opts.lines` its order_lines. `opts.notice` is an optional
|
|
1218
|
+
// error bounced back from a failed POST.
|
|
1219
|
+
function renderReturnForm(opts) {
|
|
1220
|
+
var esc = _b().template.escapeHtml;
|
|
1221
|
+
var order = opts.order;
|
|
1222
|
+
var lines = opts.lines || [];
|
|
1223
|
+
var lineRows = "";
|
|
1224
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
1225
|
+
var l = lines[i];
|
|
1226
|
+
lineRows +=
|
|
1227
|
+
"<li class=\"return-line\">" +
|
|
1228
|
+
"<label class=\"return-line__pick\">" +
|
|
1229
|
+
"<input type=\"checkbox\" name=\"return_" + esc(l.id) + "\" value=\"1\">" +
|
|
1230
|
+
"<span class=\"return-line__sku\"><code>" + esc(l.sku) + "</code></span>" +
|
|
1231
|
+
"</label>" +
|
|
1232
|
+
"<label class=\"return-line__qty\">Qty to return " +
|
|
1233
|
+
"<input type=\"number\" name=\"qty_" + esc(l.id) + "\" value=\"" + (Number(l.qty) || 1) + "\" min=\"1\" max=\"" + (Number(l.qty) || 1) + "\">" +
|
|
1234
|
+
" <span class=\"return-line__of\">of " + (Number(l.qty) || 1) + "</span>" +
|
|
1235
|
+
"</label>" +
|
|
1236
|
+
"</li>";
|
|
1237
|
+
}
|
|
1238
|
+
var reasonOpts = RETURN_REASONS.map(function (r) {
|
|
1239
|
+
return "<option value=\"" + esc(r[0]) + "\">" + esc(r[1]) + "</option>";
|
|
1240
|
+
}).join("");
|
|
1241
|
+
var notice = opts.notice
|
|
1242
|
+
? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
|
|
1243
|
+
: "";
|
|
1244
|
+
var body =
|
|
1245
|
+
"<section class=\"return-form-page\">" +
|
|
1246
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
1247
|
+
"<li><a href=\"/account\">Account</a></li>" +
|
|
1248
|
+
"<li><a href=\"/account/returns\">Returns</a></li>" +
|
|
1249
|
+
"<li aria-current=\"page\">Request a return</li>" +
|
|
1250
|
+
"</ol></nav>" +
|
|
1251
|
+
"<h1 class=\"return-form-page__title\">Request a return</h1>" +
|
|
1252
|
+
"<p class=\"return-form-page__order\">Order <code>" + esc(order.id) + "</code></p>" +
|
|
1253
|
+
notice +
|
|
1254
|
+
"<form class=\"return-form form-stack\" method=\"post\" action=\"/account/orders/" + esc(order.id) + "/return\">" +
|
|
1255
|
+
"<fieldset class=\"return-form__lines\"><legend>Which items?</legend>" +
|
|
1256
|
+
"<ul class=\"return-line-list\">" + lineRows + "</ul>" +
|
|
1257
|
+
"</fieldset>" +
|
|
1258
|
+
"<label class=\"form-field\"><span class=\"form-field__label\">Reason</span>" +
|
|
1259
|
+
"<select name=\"reason\" required>" + reasonOpts + "</select></label>" +
|
|
1260
|
+
"<label class=\"form-field\"><span class=\"form-field__label\">Notes (optional)</span>" +
|
|
1261
|
+
"<textarea name=\"customer_notes\" maxlength=\"2000\" rows=\"4\"></textarea></label>" +
|
|
1262
|
+
"<button type=\"submit\" class=\"btn-primary\">Request return</button>" +
|
|
1263
|
+
"</form>" +
|
|
1264
|
+
"</section>";
|
|
1265
|
+
return _wrap({
|
|
1266
|
+
title: "Request a return",
|
|
1267
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
1268
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
1269
|
+
theme_css: opts.theme_css,
|
|
1270
|
+
body: body,
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// Customer's return-authorization list.
|
|
1275
|
+
function renderReturns(opts) {
|
|
1276
|
+
var esc = _b().template.escapeHtml;
|
|
1277
|
+
var rmas = opts.rmas || [];
|
|
1278
|
+
var rowsHtml = "";
|
|
1279
|
+
for (var i = 0; i < rmas.length; i += 1) {
|
|
1280
|
+
var r = rmas[i];
|
|
1281
|
+
var date = r.created_at ? new Date(Number(r.created_at)).toISOString().slice(0, 10) : "";
|
|
1282
|
+
rowsHtml +=
|
|
1283
|
+
"<li class=\"return-card\">" +
|
|
1284
|
+
"<div class=\"return-card__head\">" +
|
|
1285
|
+
"<code class=\"return-card__rma\">" + esc(r.rma_code) + "</code>" +
|
|
1286
|
+
_returnStatusBadge(r.status) +
|
|
1287
|
+
"</div>" +
|
|
1288
|
+
"<p class=\"return-card__meta\">" + esc(String(r.reason || "")) +
|
|
1289
|
+
(date ? " · <time datetime=\"" + esc(date) + "\">" + esc(date) + "</time>" : "") +
|
|
1290
|
+
(Number(r.refund_amount_minor) > 0 ? " · refund " + esc(pricing.format(Number(r.refund_amount_minor), r.refund_currency || "USD")) : "") +
|
|
1291
|
+
"</p>" +
|
|
1292
|
+
(r.status === "rejected" && r.rejected_reason ? "<p class=\"return-card__reject\">" + esc(String(r.rejected_reason)) + "</p>" : "") +
|
|
1293
|
+
"</li>";
|
|
1294
|
+
}
|
|
1295
|
+
var inner = rowsHtml
|
|
1296
|
+
? "<ul class=\"return-list\">" + rowsHtml + "</ul>"
|
|
1297
|
+
: "<p class=\"return-empty\">No returns yet. Start one from an order in your account.</p>";
|
|
1298
|
+
var body =
|
|
1299
|
+
"<section class=\"account-returns\">" +
|
|
1300
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
1301
|
+
"<li><a href=\"/account\">Account</a></li>" +
|
|
1302
|
+
"<li aria-current=\"page\">Returns</li>" +
|
|
1303
|
+
"</ol></nav>" +
|
|
1304
|
+
"<h1 class=\"account-returns__title\">Returns</h1>" +
|
|
1305
|
+
inner +
|
|
1306
|
+
"</section>";
|
|
1307
|
+
return _wrap({
|
|
1308
|
+
title: "Returns",
|
|
1309
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
1310
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
1311
|
+
theme_css: opts.theme_css,
|
|
1312
|
+
body: body,
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1137
1316
|
// Product-level "Save to wishlist" control + social-proof count.
|
|
1138
1317
|
// Byte-compatible with the edge renderer (`worker/render/product.js`)
|
|
1139
1318
|
// so both paths emit identical markup. Action-only label — the toggle
|
|
@@ -2066,6 +2245,7 @@ var ACCOUNT_DASH_PAGE =
|
|
|
2066
2245
|
" <a class=\"btn-secondary\" href=\"/account/wishlist\">Wishlist</a>\n" +
|
|
2067
2246
|
" <a class=\"btn-secondary\" href=\"/account/saved\">Saved for later</a>\n" +
|
|
2068
2247
|
" <a class=\"btn-secondary\" href=\"/account/addresses\">Addresses</a>\n" +
|
|
2248
|
+
" <a class=\"btn-secondary\" href=\"/account/returns\">Returns</a>\n" +
|
|
2069
2249
|
" <form method=\"post\" action=\"/account/logout\"><button type=\"submit\" class=\"btn-ghost\">Sign out</button></form>\n" +
|
|
2070
2250
|
" </div>\n" +
|
|
2071
2251
|
" </header>\n" +
|
|
@@ -2356,6 +2536,69 @@ function mount(router, deps) {
|
|
|
2356
2536
|
_send(res, 200, html);
|
|
2357
2537
|
});
|
|
2358
2538
|
|
|
2539
|
+
// Collections — operator-curated + smart product lists. Public browse
|
|
2540
|
+
// pages; mounted when the collections primitive is wired.
|
|
2541
|
+
if (deps.collections) {
|
|
2542
|
+
var _collAssetPrefix = deps.asset_prefix || "/assets/";
|
|
2543
|
+
|
|
2544
|
+
// Decorate a product id into the { slug, title, price, image_url }
|
|
2545
|
+
// shape _buildProductCard expects. Returns null for an archived /
|
|
2546
|
+
// missing product so it drops out of the grid.
|
|
2547
|
+
async function _decorateCollectionProduct(pid) {
|
|
2548
|
+
var product = await deps.catalog.products.get(pid);
|
|
2549
|
+
if (!product || product.status !== "active") return null;
|
|
2550
|
+
var priceStr = "—";
|
|
2551
|
+
var variants = await deps.catalog.variants.listForProduct(pid);
|
|
2552
|
+
if (variants.length) {
|
|
2553
|
+
var pr = await deps.catalog.prices.current(variants[0].id, "USD");
|
|
2554
|
+
if (pr) priceStr = pricing.format(pr.amount_minor, pr.currency);
|
|
2555
|
+
}
|
|
2556
|
+
var media = await deps.catalog.media.listForProduct(pid);
|
|
2557
|
+
var hero = media.length ? media[0] : null;
|
|
2558
|
+
return {
|
|
2559
|
+
slug: product.slug,
|
|
2560
|
+
title: product.title,
|
|
2561
|
+
price: priceStr,
|
|
2562
|
+
image_url: hero ? (_collAssetPrefix + hero.r2_key) : null,
|
|
2563
|
+
image_alt: hero ? (hero.alt_text || product.title) : null,
|
|
2564
|
+
};
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
router.get("/collections", async function (req, res) {
|
|
2568
|
+
var cols = await deps.collections.list({ active_only: true });
|
|
2569
|
+
var cartCount = await _cartCountForReq(req);
|
|
2570
|
+
_send(res, 200, renderCollectionList({
|
|
2571
|
+
collections: cols, shop_name: shopName, cart_count: cartCount, asset_prefix: _collAssetPrefix,
|
|
2572
|
+
}));
|
|
2573
|
+
});
|
|
2574
|
+
|
|
2575
|
+
router.get("/collections/:slug", async function (req, res) {
|
|
2576
|
+
var slug = req.params && req.params.slug;
|
|
2577
|
+
// get() and productsIn() both throw on a malformed slug (the
|
|
2578
|
+
// primitive validates shape). A bad path segment is a 404, not a
|
|
2579
|
+
// 500 — the route is a defensive request-shape reader.
|
|
2580
|
+
var col, result;
|
|
2581
|
+
try {
|
|
2582
|
+
col = slug ? await deps.collections.get(slug) : null;
|
|
2583
|
+
if (!col || col.archived_at != null) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
2584
|
+
result = await deps.collections.productsIn({ slug: slug, limit: 24 });
|
|
2585
|
+
} catch (e) {
|
|
2586
|
+
if (e instanceof TypeError) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
2587
|
+
throw e;
|
|
2588
|
+
}
|
|
2589
|
+
var products = [];
|
|
2590
|
+
for (var i = 0; i < result.rows.length; i += 1) {
|
|
2591
|
+
var pid = result.rows[i].product_id || result.rows[i].id;
|
|
2592
|
+
var card = await _decorateCollectionProduct(pid);
|
|
2593
|
+
if (card) products.push(card);
|
|
2594
|
+
}
|
|
2595
|
+
var cartCount = await _cartCountForReq(req);
|
|
2596
|
+
_send(res, 200, renderCollection({
|
|
2597
|
+
collection: col, products: products, shop_name: shopName, cart_count: cartCount,
|
|
2598
|
+
}));
|
|
2599
|
+
});
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2359
2602
|
router.get("/cart", async function (req, res) {
|
|
2360
2603
|
var sid = _readSidCookie(req);
|
|
2361
2604
|
if (!sid) {
|
|
@@ -3214,6 +3457,102 @@ function mount(router, deps) {
|
|
|
3214
3457
|
_addrAction("archive", function (id) { return deps.addresses.archive(id); });
|
|
3215
3458
|
}
|
|
3216
3459
|
|
|
3460
|
+
// Self-serve returns — a customer requests an RMA against one of
|
|
3461
|
+
// their own orders and tracks its status. Operators action it via
|
|
3462
|
+
// the admin /admin/returns queue. Needs the returns primitive + an
|
|
3463
|
+
// order handle (to load + ownership-check the order being returned).
|
|
3464
|
+
if (deps.returns && deps.order) {
|
|
3465
|
+
function _returnsAuth(req, res) {
|
|
3466
|
+
var auth;
|
|
3467
|
+
try { auth = _currentCustomer(req); }
|
|
3468
|
+
catch (e) {
|
|
3469
|
+
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
3470
|
+
throw e;
|
|
3471
|
+
}
|
|
3472
|
+
if (!auth) {
|
|
3473
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
3474
|
+
res.end ? res.end() : res.send("");
|
|
3475
|
+
return null;
|
|
3476
|
+
}
|
|
3477
|
+
return auth;
|
|
3478
|
+
}
|
|
3479
|
+
// Load the order named in :order_id and confirm it belongs to the
|
|
3480
|
+
// signed-in customer. A malformed id (guardUuid TypeError), a
|
|
3481
|
+
// missing order, or someone else's order all return 404 — never a
|
|
3482
|
+
// 500, never a leak of another customer's order.
|
|
3483
|
+
async function _ownedOrder(req, res, auth) {
|
|
3484
|
+
var order;
|
|
3485
|
+
try { order = await deps.order.get(req.params && req.params.order_id); }
|
|
3486
|
+
catch (e) {
|
|
3487
|
+
if (e instanceof TypeError) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
|
|
3488
|
+
throw e;
|
|
3489
|
+
}
|
|
3490
|
+
if (!order || order.customer_id !== auth.customer_id) {
|
|
3491
|
+
_send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
3492
|
+
return null;
|
|
3493
|
+
}
|
|
3494
|
+
return order;
|
|
3495
|
+
}
|
|
3496
|
+
|
|
3497
|
+
router.get("/account/returns", async function (req, res) {
|
|
3498
|
+
var auth = _returnsAuth(req, res); if (!auth) return;
|
|
3499
|
+
var page = await deps.returns.listForCustomer(auth.customer_id, { limit: 50 });
|
|
3500
|
+
var cartCount = await _cartCountForReq(req);
|
|
3501
|
+
_send(res, 200, renderReturns({ rmas: page.rows, shop_name: shopName, cart_count: cartCount }));
|
|
3502
|
+
});
|
|
3503
|
+
|
|
3504
|
+
router.get("/account/orders/:order_id/return", async function (req, res) {
|
|
3505
|
+
var auth = _returnsAuth(req, res); if (!auth) return;
|
|
3506
|
+
var order = await _ownedOrder(req, res, auth); if (!order) return;
|
|
3507
|
+
var cartCount = await _cartCountForReq(req);
|
|
3508
|
+
_send(res, 200, renderReturnForm({ order: order, lines: order.lines || [], shop_name: shopName, cart_count: cartCount }));
|
|
3509
|
+
});
|
|
3510
|
+
|
|
3511
|
+
router.post("/account/orders/:order_id/return", async function (req, res) {
|
|
3512
|
+
var auth = _returnsAuth(req, res); if (!auth) return;
|
|
3513
|
+
var order = await _ownedOrder(req, res, auth); if (!order) return;
|
|
3514
|
+
var body = req.body || {};
|
|
3515
|
+
var cartCount = await _cartCountForReq(req);
|
|
3516
|
+
// Build the return lines from the order's own lines (authoritative
|
|
3517
|
+
// sku/qty), keyed by the checkboxes the customer ticked — never
|
|
3518
|
+
// trust a client-supplied sku.
|
|
3519
|
+
var orderLines = order.lines || [];
|
|
3520
|
+
var picked = [];
|
|
3521
|
+
for (var i = 0; i < orderLines.length; i += 1) {
|
|
3522
|
+
var ol = orderLines[i];
|
|
3523
|
+
if (body["return_" + ol.id] !== "1") continue;
|
|
3524
|
+
var wanted = parseInt(body["qty_" + ol.id], 10);
|
|
3525
|
+
var qty = Number.isFinite(wanted) && wanted >= 1 && wanted <= ol.qty ? wanted : ol.qty;
|
|
3526
|
+
picked.push({ order_line_id: ol.id, sku: ol.sku, qty: qty });
|
|
3527
|
+
}
|
|
3528
|
+
if (picked.length === 0) {
|
|
3529
|
+
return _send(res, 400, renderReturnForm({
|
|
3530
|
+
order: order, lines: orderLines, notice: "Select at least one item to return.",
|
|
3531
|
+
shop_name: shopName, cart_count: cartCount,
|
|
3532
|
+
}));
|
|
3533
|
+
}
|
|
3534
|
+
try {
|
|
3535
|
+
await deps.returns.request({
|
|
3536
|
+
order_id: order.id,
|
|
3537
|
+
customer_id: auth.customer_id,
|
|
3538
|
+
reason: body.reason,
|
|
3539
|
+
customer_notes: body.customer_notes,
|
|
3540
|
+
lines: picked,
|
|
3541
|
+
});
|
|
3542
|
+
} catch (e) {
|
|
3543
|
+
if (e instanceof TypeError) {
|
|
3544
|
+
return _send(res, 400, renderReturnForm({
|
|
3545
|
+
order: order, lines: orderLines, notice: (e && e.message) || "Please check your return request.",
|
|
3546
|
+
shop_name: shopName, cart_count: cartCount,
|
|
3547
|
+
}));
|
|
3548
|
+
}
|
|
3549
|
+
throw e;
|
|
3550
|
+
}
|
|
3551
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/returns");
|
|
3552
|
+
return res.end ? res.end() : res.send("");
|
|
3553
|
+
});
|
|
3554
|
+
}
|
|
3555
|
+
|
|
3217
3556
|
// Product reviews — submission requires a logged-in customer AND a
|
|
3218
3557
|
// verified purchase of the product (the gate, not just a badge).
|
|
3219
3558
|
// Only mounts when both the reviews primitive and an order handle
|
package/lib/vendor/MANIFEST.json
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
"_about": "blamejs.shop vendors a single framework — blamejs — which itself bundles every server-side crypto/identity dependency. The transitive packages blamejs ships are surfaced in its own MANIFEST.json at lib/vendor/blamejs/lib/vendor/MANIFEST.json — Trivy / Grype rely on that nested data for CVE attribution.",
|
|
4
4
|
"packages": {
|
|
5
5
|
"blamejs": {
|
|
6
|
-
"version": "0.12.
|
|
7
|
-
"tag": "v0.12.
|
|
6
|
+
"version": "0.12.39",
|
|
7
|
+
"tag": "v0.12.39",
|
|
8
8
|
"license": "Apache-2.0",
|
|
9
9
|
"author": "blamejs contributors",
|
|
10
10
|
"source": "https://github.com/blamejs/blamejs",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"server": "lib/vendor/blamejs/"
|
|
14
14
|
},
|
|
15
15
|
"bundler": "shallow git clone of release tag from github.com/blamejs/blamejs",
|
|
16
|
-
"bundledAt": "2026-05-
|
|
16
|
+
"bundledAt": "2026-05-25"
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
}
|