@blamejs/blamejs-shop 0.0.121 → 0.0.122
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 +2 -0
- package/README.md +3 -1
- package/SECURITY.md +9 -0
- package/lib/admin.js +51 -0
- package/lib/order.js +23 -0
- package/lib/reviews.js +29 -0
- package/lib/storefront.js +359 -9
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +14 -0
- package/lib/vendor/blamejs/README.md +2 -0
- package/lib/vendor/blamejs/SECURITY.md +1 -0
- package/lib/vendor/blamejs/api-snapshot.json +195 -2
- package/lib/vendor/blamejs/index.js +2 -0
- package/lib/vendor/blamejs/lib/ai-capability.js +482 -0
- package/lib/vendor/blamejs/lib/ai-disclosure.js +107 -0
- package/lib/vendor/blamejs/lib/ai-quota.js +526 -0
- package/lib/vendor/blamejs/lib/backup/index.js +210 -1
- package/lib/vendor/blamejs/lib/compliance.js +48 -1
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.22.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.12.23.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.12.24.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.12.25.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.12.26.json +30 -0
- package/lib/vendor/blamejs/release-notes/v0.12.27.json +26 -0
- package/lib/vendor/blamejs/release-notes/v0.12.28.json +26 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ai-capability.test.js +228 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ai-disclosure-apply-all.test.js +126 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ai-quota.test.js +264 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/backup-clone-bundle.test.js +178 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/backup-find-bundles.test.js +104 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/backup-rewrap-all.test.js +168 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +48 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/compliance-eu-ai-act-posture.test.js +93 -0
- package/package.json +1 -1
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.122 (2026-05-24) — **Product reviews on the storefront — verified-buyer submission, operator moderation, and rich-snippet ratings.** The product page now shows customer reviews: an average rating, a per-star distribution, and the published review list, with `AggregateRating` structured data so star ratings can surface in search results. Submission is gated to signed-in customers who have actually purchased the product — the route confirms a completed order for that product before accepting a review, and re-checks on submit. Reviews land in a pending state; operators publish or reject them through new bearer-token admin endpoints. Display and structured data are identical whether the page is served at the edge or from the container. **Added:** *Reviews on the product page* — The PDP renders an average rating, a per-star distribution bar chart, and the published reviews (newest first, verified-buyer badge, date). Products with no reviews show an invite to be the first. Rendered server-side on both the edge and container paths. · *Verified-buyer review submission* — `GET /products/:slug/review` shows the review form to a signed-in customer who has purchased the product; everyone else is redirected to sign in or told only verified buyers can review. `POST /products/:slug/review` re-checks the purchase before accepting the review, so a direct POST can't bypass the gate. Submitted reviews start pending. · *Operator review moderation* — `GET /admin/reviews?status=pending` lists the moderation queue across all products; `GET /admin/reviews/:id` reads one; `POST /admin/reviews/:id/publish` and `POST /admin/reviews/:id/reject` move it. Bearer-token-gated like the rest of the admin API. Pending and rejected reviews never appear on the storefront. · *`AggregateRating` structured data* — The product page emits Schema.org `AggregateRating` (rating value + review count) nested in the existing `Product` JSON-LD when published reviews exist, so eligible products can show star ratings in search results. Omitted entirely at zero reviews to stay valid. The container render path now emits the full `Product` + `AggregateOffer` + `BreadcrumbList` JSON-LD that the edge already did. · *`order.hasPurchasedProduct(customerId, productId)`* — Existence check — true when the customer has an order line for any variant of the product in an order that reached `paid` or later (excludes `pending` and `cancelled`). Backs the review purchase gate. · *`reviews.listByStatus(status, opts)`* — Lists reviews across all products by status, newest first, with the same opaque tuple cursor as `listForProduct`. Backs the admin moderation queue.
|
|
12
|
+
|
|
11
13
|
- v0.0.121 (2026-05-24) — **Codebase-patterns detector blocks SHA3 primitives in `worker/` — prevents the regression that broke newsletter signup.** v0.0.120 fixed the newsletter signup by routing the POST to the container instead of computing `b.crypto.namespaceHash` (SHA3-512) at the edge — Workers' `nodejs_compat` doesn't expose SHA3-family digests. New `worker-uses-sha3-primitive` codebase-patterns detector flags any `b.crypto.{sha3Hash, hmacSha3, namespaceHash, shake256, shake512, hkdfSha3}(...)` call under `worker/` so the next operator can't re-introduce the substrate-mismatch bug. Catalog grows 121 → 122. Detector targets the exact SHA3-family primitive names rather than a general regex match — Worker code that legitimately uses `b.crypto.toBase64Url`, `b.crypto.timingSafeEqual`, `b.crypto.generateBytes`, `b.crypto.hmacSha256` (the Stripe webhook augment), etc. is unaffected. **Added:** *`worker-uses-sha3-primitive` detector* — Flags `b.crypto.sha3Hash(...)`, `hmacSha3(...)`, `namespaceHash(...)`, `shake256(...)`, `shake512(...)`, and `hkdfSha3(...)` in any file under `worker/`. Catches the substrate-mismatch class that broke v0.0.92's edge newsletter handler (live for 28 versions before being routed back to the container in v0.0.120). When edge code needs a stable hash: route to the container OR use `b.crypto.hmacSha256` IFF both sides read with the same SHA-256 path. Never have one substrate write SHA3 and another read SHA-256 — silent divergence breaks cross-substrate lookups (e.g. unsubscribe-by-email_hash).
|
|
12
14
|
|
|
13
15
|
- v0.0.120 (2026-05-24) — **Route POST /newsletter through the container — Workers `nodejs_compat` can't compute SHA3-512 (`b.crypto.namespaceHash`).** The v0.0.92 edge-served newsletter handler was 500-ing every submission with `Error: Digest method not supported`. Root cause surfaced via the temporary `X-Newsletter-Diag` header (added in v0.0.119): Cloudflare Workers' `nodejs_compat` runtime exposes `node:crypto` but the supported digest set is a subset of full Node — `createHash("sha3-512")` isn't in it. Working around with a Web-Crypto-API SHA-256 fallback would silently diverge the Worker's `email_hash` values from the container's SHA3-512 values, breaking the unsubscribe lookup (different hash → different row → silent unsubscribe failure). The edge handler was the wrong substrate for this primitive. The POST falls through to `_forwardToContainer` so the framework's SHA3-512 path runs server-side and the `email_hash` column stays consistent across reads. The ~200ms container hop on signup submit is paid back by a working unsubscribe flow. The dead `_edgeNewsletter` function + its `renderNewsletterThanks` / `renderNewsletterError` imports + the diagnostic `X-Newsletter-Diag` header are all removed in the same patch. **Fixed:** *Newsletter signup now uses the framework's SHA3-512 hash via the container* — Removed: `_edgeNewsletter` handler (123 lines), `renderNewsletterThanks` / `renderNewsletterError` imports in `worker/index.js`, the `X-Newsletter-Diag` diagnostic response header (its job is done). The dispatch comment above the now-removed `if (pathname === "/newsletter" && ...)` block documents the substrate decision + the SHA3-512 / Workers-runtime constraint so the next operator doesn't re-introduce the edge handler.
|
package/README.md
CHANGED
|
@@ -63,9 +63,10 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
63
63
|
| **`lib/email.js`** | Transactional templates — order receipt, ship notification, refund confirmation. Strict `{{var}}` renderer with HTML escape + refusal of unknown / unused placeholders. Composed on `b.mail` (DKIM/SPF/DMARC/BIMI upstream). |
|
|
64
64
|
| **`lib/storefront.js`** | Server-rendered HTML — utility bar + sticky header + dark hero with code-preview card + primitives marquee + featured-product callout + collections grid + framework feature band + designed catalog grid + newsletter band + four-column footer. Designed surfaces also for PDP, cart, checkout, pay, order, account login / register / dashboard, search results, `/admin` API landing, 404. Image-bearing cards on the home + search grids pull from `catalog.media`. The default theme stylesheet is external (R2-served `themes/default/assets/css/main.css`) and CSP-compliant — operators override by uploading a replacement at the same key, by passing `opts.theme_css` to renderers, or by registering a named theme through the `theme` primitive. |
|
|
65
65
|
| **`lib/customers.js`** | Customer accounts — passkey-only (WebAuthn). Email is stored hash-only (`b.crypto.namespaceHash` namespace `customer-email`); the raw address never lands in D1. Passkey credentials carry CBOR-encoded public keys, transport hints, and SHA3-512-fingerprinted attestation. Account routes (`/account/login`, `/account/register`, `/account`) ship as designed cards on the storefront. |
|
|
66
|
+
| **`lib/reviews.js`** | Operator-moderated product ratings. Submission requires a signed-in customer **and** a verified purchase — `/products/:slug/review` confirms a completed order for the product (via `order.hasPurchasedProduct`) before accepting, re-checked on POST; reviews land `pending`. Author identity is hash-only (`b.crypto.namespaceHash`); the raw email is never stored. The PDP renders the average, per-star distribution, and published reviews with `AggregateRating` JSON-LD. `/admin/reviews` is the moderation queue (`listByStatus` → publish / reject). |
|
|
66
67
|
| **`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. |
|
|
67
68
|
| **`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. |
|
|
68
|
-
| **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. |
|
|
69
|
+
| **`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. |
|
|
69
70
|
| **`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. |
|
|
70
71
|
| **`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. |
|
|
71
72
|
|
|
@@ -80,6 +81,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
80
81
|
- `migrations-d1/0008_inventory_thresholds.sql` — low-stock alert thresholds + alerts
|
|
81
82
|
- `migrations-d1/0009_subscriptions.sql` — subscription_plans + subscriptions (Stripe-mirrored)
|
|
82
83
|
- `migrations-d1/0010_newsletter_signups.sql` — email signups with hash-based dedup
|
|
84
|
+
- `migrations-d1/0011_reviews.sql` — operator-moderated product reviews (hash-only author identity)
|
|
83
85
|
|
|
84
86
|
### Demo seed
|
|
85
87
|
|
package/SECURITY.md
CHANGED
|
@@ -139,3 +139,12 @@ node -e "
|
|
|
139
139
|
framework's at-rest key material. Operators MUST rotate the
|
|
140
140
|
passphrase via `b.vault.rotate` after any container image rebuild
|
|
141
141
|
that changed a vendored crypto dependency.
|
|
142
|
+
- **Reviews are purchase-gated and operator-moderated.** A review can
|
|
143
|
+
only be submitted by a signed-in customer who has a completed order
|
|
144
|
+
for that product; the route confirms the purchase before accepting
|
|
145
|
+
and re-checks it on the POST, so a direct request can't plant a
|
|
146
|
+
review for a product the account never bought. Submissions land in a
|
|
147
|
+
`pending` state and never reach the storefront until an operator
|
|
148
|
+
publishes them through `/admin/reviews`. The author identity is
|
|
149
|
+
stored hash-only (`b.crypto.namespaceHash`) — the raw email is never
|
|
150
|
+
persisted.
|
package/lib/admin.js
CHANGED
|
@@ -162,6 +162,7 @@ function mount(router, deps) {
|
|
|
162
162
|
var r2 = deps.r2_bridge || null; // media-upload endpoint disabled when absent
|
|
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
|
+
var reviews = deps.reviews || null; // moderation endpoints disabled when absent
|
|
165
166
|
|
|
166
167
|
try { _b().audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
167
168
|
|
|
@@ -529,6 +530,56 @@ function mount(router, deps) {
|
|
|
529
530
|
}));
|
|
530
531
|
}
|
|
531
532
|
|
|
533
|
+
// ---- reviews (moderation) -------------------------------------------
|
|
534
|
+
|
|
535
|
+
// Operator-side review moderation. The queue lists reviews across all
|
|
536
|
+
// products in one status (defaults to `pending`); publish / reject
|
|
537
|
+
// drive the same transitions the storefront submit path leaves in
|
|
538
|
+
// `pending`. Endpoints are omitted entirely when no reviews primitive
|
|
539
|
+
// is wired.
|
|
540
|
+
if (reviews) {
|
|
541
|
+
router.get("/admin/reviews", R(async function (req, res) {
|
|
542
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
543
|
+
var status = (url && url.searchParams.get("status")) || "pending";
|
|
544
|
+
var cursor = url && url.searchParams.get("cursor");
|
|
545
|
+
var limitS = url && url.searchParams.get("limit");
|
|
546
|
+
var limit = limitS == null ? undefined : parseInt(limitS, 10);
|
|
547
|
+
var page = await reviews.listByStatus(status, { cursor: cursor || undefined, limit: limit });
|
|
548
|
+
_json(res, 200, page);
|
|
549
|
+
}));
|
|
550
|
+
|
|
551
|
+
router.get("/admin/reviews/:id", R(async function (req, res) {
|
|
552
|
+
var rev = await reviews.get(req.params.id);
|
|
553
|
+
if (!rev) return _problem(res, 404, "review-not-found");
|
|
554
|
+
_json(res, 200, rev);
|
|
555
|
+
}));
|
|
556
|
+
|
|
557
|
+
router.post("/admin/reviews/:id/publish", W("review.publish", async function (req, res) {
|
|
558
|
+
var rev;
|
|
559
|
+
try {
|
|
560
|
+
rev = await reviews.publish(req.params.id);
|
|
561
|
+
} catch (e) {
|
|
562
|
+
if (e && e.code === "REVIEW_NOT_FOUND") return _problem(res, 404, "review-not-found");
|
|
563
|
+
throw e;
|
|
564
|
+
}
|
|
565
|
+
_json(res, 200, rev);
|
|
566
|
+
return rev;
|
|
567
|
+
}));
|
|
568
|
+
|
|
569
|
+
router.post("/admin/reviews/:id/reject", W("review.reject", async function (req, res) {
|
|
570
|
+
var body = req.body || {};
|
|
571
|
+
var rev;
|
|
572
|
+
try {
|
|
573
|
+
rev = await reviews.reject(req.params.id, body.reason);
|
|
574
|
+
} catch (e) {
|
|
575
|
+
if (e && e.code === "REVIEW_NOT_FOUND") return _problem(res, 404, "review-not-found");
|
|
576
|
+
throw e;
|
|
577
|
+
}
|
|
578
|
+
_json(res, 200, rev);
|
|
579
|
+
return rev;
|
|
580
|
+
}));
|
|
581
|
+
}
|
|
582
|
+
|
|
532
583
|
// ---- config ---------------------------------------------------------
|
|
533
584
|
|
|
534
585
|
var config = deps.config || null;
|
package/lib/order.js
CHANGED
|
@@ -368,6 +368,29 @@ function create(opts) {
|
|
|
368
368
|
if (r.rowCount === 0) return null;
|
|
369
369
|
return await this.get(orderId);
|
|
370
370
|
},
|
|
371
|
+
|
|
372
|
+
// Has this customer purchased this product? True iff an order
|
|
373
|
+
// line for any variant of the product sits in an order owned by
|
|
374
|
+
// the customer whose status is a real purchase — anything except
|
|
375
|
+
// pending (never captured) or cancelled (reversed before
|
|
376
|
+
// fulfillment). paid|fulfilling|shipped|delivered|refunded all
|
|
377
|
+
// count as purchased. The review gate composes this so only
|
|
378
|
+
// verified buyers can leave a review. Existence check, one round
|
|
379
|
+
// trip.
|
|
380
|
+
hasPurchasedProduct: async function (customerId, productId) {
|
|
381
|
+
_uuid(customerId, "customer id");
|
|
382
|
+
_uuid(productId, "product id");
|
|
383
|
+
var rows = (await query(
|
|
384
|
+
"SELECT 1 FROM order_lines ol " +
|
|
385
|
+
"JOIN orders o ON o.id = ol.order_id " +
|
|
386
|
+
"JOIN variants v ON v.id = ol.variant_id " +
|
|
387
|
+
"WHERE o.customer_id = ?1 AND v.product_id = ?2 " +
|
|
388
|
+
"AND o.status NOT IN ('pending','cancelled') " +
|
|
389
|
+
"LIMIT 1",
|
|
390
|
+
[customerId, productId],
|
|
391
|
+
)).rows;
|
|
392
|
+
return rows.length > 0;
|
|
393
|
+
},
|
|
371
394
|
};
|
|
372
395
|
}
|
|
373
396
|
|
package/lib/reviews.js
CHANGED
|
@@ -349,6 +349,35 @@ function create(opts) {
|
|
|
349
349
|
return { rows: r.rows, next_cursor: _encodeNext(r.rows, REVIEW_ORDER_KEY, limit) };
|
|
350
350
|
},
|
|
351
351
|
|
|
352
|
+
// Cross-product moderation listing — every review in one status
|
|
353
|
+
// regardless of product. The moderation queue needs all `pending`
|
|
354
|
+
// rows globally, which listForProduct (per-product) can't serve.
|
|
355
|
+
// status is required here (the queue is always status-scoped). Same
|
|
356
|
+
// (created_at DESC, id DESC) tuple cursor as listForProduct.
|
|
357
|
+
listByStatus: async function (status, listOpts) {
|
|
358
|
+
status = _statusFilter(status);
|
|
359
|
+
if (status === undefined) {
|
|
360
|
+
throw new TypeError("reviews.listByStatus: status is required (one of " + ALLOWED_STATUSES.join(", ") + ")");
|
|
361
|
+
}
|
|
362
|
+
listOpts = listOpts || {};
|
|
363
|
+
var limit = _limit(listOpts.limit);
|
|
364
|
+
var cursorVals = _decodeCursor(listOpts.cursor, REVIEW_ORDER_KEY, "listByStatus");
|
|
365
|
+
|
|
366
|
+
var sql, params;
|
|
367
|
+
if (cursorVals) {
|
|
368
|
+
sql = "SELECT * FROM reviews WHERE status = ?1 " +
|
|
369
|
+
"AND (created_at < ?2 OR (created_at = ?2 AND id < ?3)) " +
|
|
370
|
+
"ORDER BY created_at DESC, id DESC LIMIT ?4";
|
|
371
|
+
params = [status, cursorVals[0], cursorVals[1], limit];
|
|
372
|
+
} else {
|
|
373
|
+
sql = "SELECT * FROM reviews WHERE status = ?1 " +
|
|
374
|
+
"ORDER BY created_at DESC, id DESC LIMIT ?2";
|
|
375
|
+
params = [status, limit];
|
|
376
|
+
}
|
|
377
|
+
var r = await query(sql, params);
|
|
378
|
+
return { rows: r.rows, next_cursor: _encodeNext(r.rows, REVIEW_ORDER_KEY, limit) };
|
|
379
|
+
},
|
|
380
|
+
|
|
352
381
|
// Aggregate ratings — published-only by construction; counts
|
|
353
382
|
// every star bucket so the storefront can draw the distribution
|
|
354
383
|
// bar chart without a second query.
|
package/lib/storefront.js
CHANGED
|
@@ -678,6 +678,7 @@ var PRODUCT_PAGE =
|
|
|
678
678
|
" </div>\n" +
|
|
679
679
|
" </div>\n" +
|
|
680
680
|
" </div>\n" +
|
|
681
|
+
" RAW_REVIEWS_PLACEHOLDER\n" +
|
|
681
682
|
"</section>\n";
|
|
682
683
|
|
|
683
684
|
// PDP gallery markup — composed once per render call from the
|
|
@@ -720,6 +721,193 @@ function _buildPdpGallery(product, media, assetPrefix) {
|
|
|
720
721
|
return heroImg + "<ul class=\"pdp__thumbs\" aria-hidden=\"true\">" + thumbs.join("") + "</ul>";
|
|
721
722
|
}
|
|
722
723
|
|
|
724
|
+
// Accessible star glyph row — the precise figure rides in a visually-
|
|
725
|
+
// hidden label so a screen reader announces "4.3 out of 5 stars" while
|
|
726
|
+
// sighted users see the rounded glyph fill. Mirrors the edge renderer
|
|
727
|
+
// (`worker/render/product.js`) so both paths emit identical markup.
|
|
728
|
+
function _reviewStars(value, label) {
|
|
729
|
+
var esc = _b().template.escapeHtml;
|
|
730
|
+
var filled = Math.round(value);
|
|
731
|
+
if (filled < 0) filled = 0;
|
|
732
|
+
if (filled > 5) filled = 5;
|
|
733
|
+
var glyphs = "";
|
|
734
|
+
for (var i = 1; i <= 5; i += 1) {
|
|
735
|
+
glyphs += "<span class=\"star" + (i <= filled ? " star--on" : "") + "\">" +
|
|
736
|
+
(i <= filled ? "★" : "☆") + "</span>";
|
|
737
|
+
}
|
|
738
|
+
return "<span class=\"stars\" aria-hidden=\"true\">" + glyphs + "</span>" +
|
|
739
|
+
"<span class=\"sr-only\">" + esc(label) + "</span>";
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function _reviewDate(ts) {
|
|
743
|
+
var n = Number(ts);
|
|
744
|
+
if (!Number.isFinite(n) || n <= 0) return "";
|
|
745
|
+
return new Date(n).toISOString().slice(0, 10);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Builds the PDP reviews block from the published aggregate + list.
|
|
749
|
+
// Renders the "no reviews yet" empty state when the product has none;
|
|
750
|
+
// `ctaHtml` is the operator/customer call-to-action (a "Write a review"
|
|
751
|
+
// link, or "Sign in to review", resolved by the route). Mirrors the
|
|
752
|
+
// edge renderer byte-for-byte so the two render paths stay in sync.
|
|
753
|
+
function _buildReviews(summary, reviews, ctaHtml) {
|
|
754
|
+
var esc = _b().template.escapeHtml;
|
|
755
|
+
summary = summary || { count: 0, avg_rating: 0, distribution: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 } };
|
|
756
|
+
reviews = reviews || [];
|
|
757
|
+
var count = Number(summary.count) || 0;
|
|
758
|
+
|
|
759
|
+
var head;
|
|
760
|
+
if (count > 0) {
|
|
761
|
+
var avg = Number(summary.avg_rating) || 0;
|
|
762
|
+
var avgStr = avg.toFixed(1);
|
|
763
|
+
var dist = summary.distribution || {};
|
|
764
|
+
var bars = "";
|
|
765
|
+
for (var s = 5; s >= 1; s -= 1) {
|
|
766
|
+
var n = Number(dist[s]) || 0;
|
|
767
|
+
var pct = count > 0 ? Math.round((n / count) * 100) : 0;
|
|
768
|
+
bars +=
|
|
769
|
+
"<li class=\"rating-bar\">" +
|
|
770
|
+
"<span class=\"rating-bar__label\">" + s + " star</span>" +
|
|
771
|
+
"<span class=\"rating-bar__track\"><span class=\"rating-bar__fill\" style=\"width:" + pct + "%\"></span></span>" +
|
|
772
|
+
"<span class=\"rating-bar__count\">" + n + "</span>" +
|
|
773
|
+
"</li>";
|
|
774
|
+
}
|
|
775
|
+
head =
|
|
776
|
+
"<div class=\"reviews__summary\">" +
|
|
777
|
+
"<div class=\"reviews__average\">" +
|
|
778
|
+
"<span class=\"reviews__average-num\">" + esc(avgStr) + "</span>" +
|
|
779
|
+
_reviewStars(avg, avgStr + " out of 5 stars") +
|
|
780
|
+
"<span class=\"reviews__count\">" + count + (count === 1 ? " review" : " reviews") + "</span>" +
|
|
781
|
+
"</div>" +
|
|
782
|
+
"<ul class=\"reviews__distribution\">" + bars + "</ul>" +
|
|
783
|
+
"</div>";
|
|
784
|
+
} else {
|
|
785
|
+
head = "<p class=\"reviews__empty\">No reviews yet. Be the first to review this product.</p>";
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
var list = "";
|
|
789
|
+
for (var i = 0; i < reviews.length; i += 1) {
|
|
790
|
+
var r = reviews[i];
|
|
791
|
+
var rating = Number(r.rating) || 0;
|
|
792
|
+
var verified = Number(r.verified_purchase) === 1
|
|
793
|
+
? "<span class=\"review__verified\">Verified buyer</span>"
|
|
794
|
+
: "";
|
|
795
|
+
var date = _reviewDate(r.created_at);
|
|
796
|
+
var bodyHtml = r.body
|
|
797
|
+
? "<p class=\"review__body\">" + esc(String(r.body)) + "</p>"
|
|
798
|
+
: "";
|
|
799
|
+
list +=
|
|
800
|
+
"<li class=\"review\">" +
|
|
801
|
+
"<div class=\"review__head\">" +
|
|
802
|
+
_reviewStars(rating, rating + " out of 5 stars") +
|
|
803
|
+
"<h3 class=\"review__title\">" + esc(String(r.title || "")) + "</h3>" +
|
|
804
|
+
"</div>" +
|
|
805
|
+
"<div class=\"review__meta\">" + verified +
|
|
806
|
+
(date ? "<time class=\"review__date\" datetime=\"" + esc(date) + "\">" + esc(date) + "</time>" : "") +
|
|
807
|
+
"</div>" +
|
|
808
|
+
bodyHtml +
|
|
809
|
+
"</li>";
|
|
810
|
+
}
|
|
811
|
+
var listHtml = list ? "<ul class=\"reviews__list\">" + list + "</ul>" : "";
|
|
812
|
+
|
|
813
|
+
return "<section class=\"reviews\" aria-labelledby=\"reviews-title\">" +
|
|
814
|
+
"<h2 id=\"reviews-title\" class=\"reviews__heading\">Customer reviews</h2>" +
|
|
815
|
+
head +
|
|
816
|
+
listHtml +
|
|
817
|
+
(ctaHtml || "") +
|
|
818
|
+
"</section>";
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
var REVIEW_FORM_PAGE =
|
|
822
|
+
"<section class=\"review-form-page\">\n" +
|
|
823
|
+
" <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\">\n" +
|
|
824
|
+
" <ol>\n" +
|
|
825
|
+
" <li><a href=\"/\">Shop</a></li>\n" +
|
|
826
|
+
" <li><a href=\"/products/{{slug}}\">{{title}}</a></li>\n" +
|
|
827
|
+
" <li aria-current=\"page\">Write a review</li>\n" +
|
|
828
|
+
" </ol>\n" +
|
|
829
|
+
" </nav>\n" +
|
|
830
|
+
" <h1 class=\"review-form-page__title\">Review {{title}}</h1>\n" +
|
|
831
|
+
" RAW_NOTICE_PLACEHOLDER\n" +
|
|
832
|
+
" <form class=\"review-form\" method=\"post\" action=\"/products/{{slug}}/review\">\n" +
|
|
833
|
+
" <fieldset class=\"review-form__rating\">\n" +
|
|
834
|
+
" <legend>Your rating</legend>\n" +
|
|
835
|
+
" RAW_STARS_PLACEHOLDER\n" +
|
|
836
|
+
" </fieldset>\n" +
|
|
837
|
+
" <label class=\"form-field\">\n" +
|
|
838
|
+
" <span class=\"form-field__label\">Title</span>\n" +
|
|
839
|
+
" <input type=\"text\" name=\"title\" maxlength=\"120\" required autocomplete=\"off\">\n" +
|
|
840
|
+
" </label>\n" +
|
|
841
|
+
" <label class=\"form-field\">\n" +
|
|
842
|
+
" <span class=\"form-field__label\">Your review</span>\n" +
|
|
843
|
+
" <textarea name=\"body\" maxlength=\"4000\" rows=\"6\"></textarea>\n" +
|
|
844
|
+
" </label>\n" +
|
|
845
|
+
" <button type=\"submit\" class=\"btn-primary\">Submit review</button>\n" +
|
|
846
|
+
" </form>\n" +
|
|
847
|
+
"</section>\n";
|
|
848
|
+
|
|
849
|
+
// Auth-gated review form. `opts.product` carries { title, slug };
|
|
850
|
+
// `opts.notice` is an optional error string rendered above the form
|
|
851
|
+
// (e.g. a validation rejection bounced back from POST).
|
|
852
|
+
function renderReviewForm(opts) {
|
|
853
|
+
var esc = _b().template.escapeHtml;
|
|
854
|
+
var slug = opts.product.slug;
|
|
855
|
+
var stars = "";
|
|
856
|
+
for (var rv = 5; rv >= 1; rv -= 1) {
|
|
857
|
+
stars +=
|
|
858
|
+
"<label class=\"star-radio\">" +
|
|
859
|
+
"<input type=\"radio\" name=\"rating\" value=\"" + rv + "\" required>" +
|
|
860
|
+
"<span>" + rv + (rv === 1 ? " star" : " stars") + "</span>" +
|
|
861
|
+
"</label>";
|
|
862
|
+
}
|
|
863
|
+
var notice = opts.notice
|
|
864
|
+
? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
|
|
865
|
+
: "";
|
|
866
|
+
var body = _render(REVIEW_FORM_PAGE, {
|
|
867
|
+
title: opts.product.title,
|
|
868
|
+
slug: slug,
|
|
869
|
+
})
|
|
870
|
+
.replace("RAW_NOTICE_PLACEHOLDER", notice)
|
|
871
|
+
.replace("RAW_STARS_PLACEHOLDER", stars);
|
|
872
|
+
return _wrap({
|
|
873
|
+
title: "Review " + opts.product.title,
|
|
874
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
875
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
876
|
+
theme_css: opts.theme_css,
|
|
877
|
+
body: body,
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Generic single-message page for the review flow (purchase-gate
|
|
882
|
+
// refusal, submission thank-you). `cta` is an optional { href, label }.
|
|
883
|
+
function _reviewMessagePage(opts, heading, message, cta) {
|
|
884
|
+
var esc = _b().template.escapeHtml;
|
|
885
|
+
var ctaHtml = cta
|
|
886
|
+
? "<a class=\"btn-primary\" href=\"" + esc(cta.href) + "\">" + esc(cta.label) + "</a>"
|
|
887
|
+
: "";
|
|
888
|
+
var body =
|
|
889
|
+
"<section class=\"review-message\">" +
|
|
890
|
+
"<h1 class=\"review-message__title\">" + esc(heading) + "</h1>" +
|
|
891
|
+
"<p class=\"review-message__lede\">" + esc(message) + "</p>" +
|
|
892
|
+
ctaHtml +
|
|
893
|
+
"</section>";
|
|
894
|
+
return _wrap({
|
|
895
|
+
title: heading,
|
|
896
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
897
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
898
|
+
theme_css: opts.theme_css,
|
|
899
|
+
body: body,
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Schema.org JSON-LD block. JSON.stringify covers the standard escapes;
|
|
904
|
+
// the `</` → `<\/` rewrite neutralises any literal `</script>` in a
|
|
905
|
+
// value. Mirrors the edge renderer's `jsonLdScript`.
|
|
906
|
+
function _jsonLdScript(data) {
|
|
907
|
+
var serialised = JSON.stringify(data).replace(/<\/(?=script>)/gi, "<\\/");
|
|
908
|
+
return "<script type=\"application/ld+json\">" + serialised + "</script>";
|
|
909
|
+
}
|
|
910
|
+
|
|
723
911
|
function renderProduct(opts) {
|
|
724
912
|
if (!opts || !opts.product) throw new TypeError("storefront.renderProduct: opts.product required");
|
|
725
913
|
var variants = opts.variants || [];
|
|
@@ -741,6 +929,10 @@ function renderProduct(opts) {
|
|
|
741
929
|
product: { title: opts.product.title, description: description },
|
|
742
930
|
variants: rendered,
|
|
743
931
|
has_variants: rendered.length > 0,
|
|
932
|
+
// Pre-rendered reviews block (internally HTML-escaped) for the
|
|
933
|
+
// theme's `{{{ reviews_html }}}` raw slot. The bundled themes
|
|
934
|
+
// include the slot; a custom theme opts in by adding it.
|
|
935
|
+
reviews_html: _buildReviews(opts.review_summary, opts.reviews, opts.review_cta),
|
|
744
936
|
asset_css_main: opts.theme.assetUrl("css/main.css"),
|
|
745
937
|
});
|
|
746
938
|
}
|
|
@@ -749,18 +941,73 @@ function renderProduct(opts) {
|
|
|
749
941
|
}).join("");
|
|
750
942
|
if (!rows) rows = "<tr><td colspan=\"4\" class=\"empty\">No variants available.</td></tr>";
|
|
751
943
|
var galleryHtml = _buildPdpGallery(opts.product, opts.media || [], opts.asset_prefix || "/assets/");
|
|
944
|
+
var reviewsHtml = _buildReviews(opts.review_summary, opts.reviews, opts.review_cta);
|
|
752
945
|
var body = _render(PRODUCT_PAGE, {
|
|
753
946
|
title: opts.product.title,
|
|
754
947
|
description: description,
|
|
755
948
|
variant_rows: "RAW_ROWS_PLACEHOLDER",
|
|
756
949
|
})
|
|
757
950
|
.replace("RAW_GALLERY_PLACEHOLDER", galleryHtml)
|
|
758
|
-
.replace("RAW_ROWS_PLACEHOLDER", rows)
|
|
951
|
+
.replace("RAW_ROWS_PLACEHOLDER", rows)
|
|
952
|
+
.replace("RAW_REVIEWS_PLACEHOLDER", reviewsHtml);
|
|
759
953
|
// Product-specific OpenGraph + Twitter Card values so shares
|
|
760
954
|
// unfurl as "Operator Tee — blamejs.shop" with the SVG hero, not
|
|
761
955
|
// the default shop-level description + brand logo.
|
|
762
956
|
var heroMedia = (opts.media && opts.media[0]) || null;
|
|
763
957
|
var ogImage = heroMedia ? ((opts.asset_prefix || "/assets/") + heroMedia.r2_key) : "/assets/brand/logo.png";
|
|
958
|
+
|
|
959
|
+
// Product + AggregateOffer JSON-LD, with AggregateRating folded in
|
|
960
|
+
// when published reviews exist. Kept byte-compatible with the edge
|
|
961
|
+
// renderer so the structured data is identical whichever substrate
|
|
962
|
+
// serves the PDP. AggregateRating is omitted (not null) at zero
|
|
963
|
+
// reviews — Google rejects `reviewCount: 0`.
|
|
964
|
+
var priceList = variants
|
|
965
|
+
.map(function (v) { return prices[v.id] ? prices[v.id].amount_minor : null; })
|
|
966
|
+
.filter(function (n) { return Number.isInteger(n); });
|
|
967
|
+
var jsonLd = null;
|
|
968
|
+
if (priceList.length > 0) {
|
|
969
|
+
var lowMinor = Math.min.apply(null, priceList);
|
|
970
|
+
var hiMinor = Math.max.apply(null, priceList);
|
|
971
|
+
var currency = (prices[variants[0].id] && prices[variants[0].id].currency) || "USD";
|
|
972
|
+
var divisor = currency === "JPY" || currency === "KRW" ? 1 : 100;
|
|
973
|
+
var aggregateRating;
|
|
974
|
+
if (opts.review_summary && Number(opts.review_summary.count) > 0) {
|
|
975
|
+
aggregateRating = {
|
|
976
|
+
"@type": "AggregateRating",
|
|
977
|
+
"ratingValue": (Number(opts.review_summary.avg_rating) || 0).toFixed(1),
|
|
978
|
+
"reviewCount": Number(opts.review_summary.count),
|
|
979
|
+
"bestRating": 5,
|
|
980
|
+
"worstRating": 1,
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
jsonLd = _jsonLdScript({
|
|
984
|
+
"@context": "https://schema.org",
|
|
985
|
+
"@type": "Product",
|
|
986
|
+
"name": opts.product.title,
|
|
987
|
+
"description": description || ("Browse " + opts.product.title + " on " + shopName + "."),
|
|
988
|
+
"image": heroMedia ? [ogImage] : undefined,
|
|
989
|
+
"sku": variants[0] && variants[0].sku,
|
|
990
|
+
"aggregateRating": aggregateRating,
|
|
991
|
+
"offers": {
|
|
992
|
+
"@type": "AggregateOffer",
|
|
993
|
+
"priceCurrency": currency,
|
|
994
|
+
"lowPrice": (lowMinor / divisor).toFixed(divisor === 1 ? 0 : 2),
|
|
995
|
+
"highPrice": (hiMinor / divisor).toFixed(divisor === 1 ? 0 : 2),
|
|
996
|
+
"offerCount": variants.length,
|
|
997
|
+
"availability": "https://schema.org/InStock",
|
|
998
|
+
},
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
var breadcrumbJsonLd = _jsonLdScript({
|
|
1002
|
+
"@context": "https://schema.org",
|
|
1003
|
+
"@type": "BreadcrumbList",
|
|
1004
|
+
"itemListElement": [
|
|
1005
|
+
{ "@type": "ListItem", "position": 1, "name": "Shop", "item": "/" },
|
|
1006
|
+
{ "@type": "ListItem", "position": 2, "name": opts.product.title, "item": "/products/" + opts.product.slug },
|
|
1007
|
+
],
|
|
1008
|
+
});
|
|
1009
|
+
jsonLd = (jsonLd || "") + breadcrumbJsonLd;
|
|
1010
|
+
|
|
764
1011
|
return _wrap({
|
|
765
1012
|
title: opts.product.title,
|
|
766
1013
|
shop_name: shopName,
|
|
@@ -770,7 +1017,7 @@ function renderProduct(opts) {
|
|
|
770
1017
|
og_title: opts.product.title + " — " + shopName,
|
|
771
1018
|
og_description: description || ("Browse " + opts.product.title + " on " + shopName + "."),
|
|
772
1019
|
og_image: ogImage,
|
|
773
|
-
body: body,
|
|
1020
|
+
body: body + jsonLd,
|
|
774
1021
|
});
|
|
775
1022
|
}
|
|
776
1023
|
|
|
@@ -1797,14 +2044,34 @@ function mount(router, deps) {
|
|
|
1797
2044
|
cartCount = lines.length;
|
|
1798
2045
|
}
|
|
1799
2046
|
}
|
|
2047
|
+
// Published reviews aggregate + list. A failed read (e.g. the
|
|
2048
|
+
// reviews table not yet migrated) degrades to the empty state
|
|
2049
|
+
// rather than 500-ing the whole PDP — reviews are supplementary
|
|
2050
|
+
// to the buy path. Mirrors the edge renderer's missing-table
|
|
2051
|
+
// resilience.
|
|
2052
|
+
var reviewSummary, reviewRows, reviewCta;
|
|
2053
|
+
if (deps.reviews) {
|
|
2054
|
+
try {
|
|
2055
|
+
reviewSummary = await deps.reviews.summaryForProduct(product.id);
|
|
2056
|
+
reviewRows = (await deps.reviews.listForProduct(product.id, { limit: 10 })).rows;
|
|
2057
|
+
} catch (_e) { reviewSummary = undefined; reviewRows = []; }
|
|
2058
|
+
// The form route enforces auth + the verified-purchase gate, so
|
|
2059
|
+
// the CTA links there unconditionally; logged-out shoppers get
|
|
2060
|
+
// redirected to login, non-purchasers get a clear "not eligible".
|
|
2061
|
+
reviewCta = "<a class=\"btn-secondary reviews__cta\" href=\"/products/" +
|
|
2062
|
+
_b().template.escapeHtml(product.slug) + "/review\">Write a review</a>";
|
|
2063
|
+
}
|
|
1800
2064
|
var html = renderProduct({
|
|
1801
|
-
product:
|
|
1802
|
-
variants:
|
|
1803
|
-
prices:
|
|
1804
|
-
media:
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
2065
|
+
product: product,
|
|
2066
|
+
variants: variants,
|
|
2067
|
+
prices: prices,
|
|
2068
|
+
media: media,
|
|
2069
|
+
review_summary: reviewSummary,
|
|
2070
|
+
reviews: reviewRows,
|
|
2071
|
+
review_cta: reviewCta,
|
|
2072
|
+
shop_name: shopName,
|
|
2073
|
+
cart_count: cartCount,
|
|
2074
|
+
theme: theme,
|
|
1808
2075
|
});
|
|
1809
2076
|
_send(res, 200, html);
|
|
1810
2077
|
});
|
|
@@ -2339,6 +2606,89 @@ function mount(router, deps) {
|
|
|
2339
2606
|
res.status(303); res.setHeader && res.setHeader("location", "/");
|
|
2340
2607
|
return res.end ? res.end() : res.send("");
|
|
2341
2608
|
});
|
|
2609
|
+
|
|
2610
|
+
// Product reviews — submission requires a logged-in customer AND a
|
|
2611
|
+
// verified purchase of the product (the gate, not just a badge).
|
|
2612
|
+
// Only mounts when both the reviews primitive and an order handle
|
|
2613
|
+
// (for the purchase check) are wired.
|
|
2614
|
+
if (deps.reviews && deps.order) {
|
|
2615
|
+
async function _reviewGateContext(req, res) {
|
|
2616
|
+
var slug = req.params && req.params.slug;
|
|
2617
|
+
var product = slug ? await deps.catalog.products.bySlug(slug) : null;
|
|
2618
|
+
if (!product) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
|
|
2619
|
+
var auth;
|
|
2620
|
+
try { auth = _currentCustomer(req); }
|
|
2621
|
+
catch (e) {
|
|
2622
|
+
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
2623
|
+
throw e;
|
|
2624
|
+
}
|
|
2625
|
+
if (!auth) {
|
|
2626
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
2627
|
+
res.end ? res.end() : res.send("");
|
|
2628
|
+
return null;
|
|
2629
|
+
}
|
|
2630
|
+
var cartCount = await _cartCountForReq(req);
|
|
2631
|
+
var purchased = await deps.order.hasPurchasedProduct(auth.customer_id, product.id);
|
|
2632
|
+
return { product: product, auth: auth, cartCount: cartCount, purchased: purchased };
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
function _reviewIneligible(res, ctx, code) {
|
|
2636
|
+
return _send(res, code, _reviewMessagePage(
|
|
2637
|
+
{ shop_name: shopName, cart_count: ctx.cartCount },
|
|
2638
|
+
"Only verified buyers can review",
|
|
2639
|
+
"We can only accept a review for a product you've purchased. Make sure you're signed in with the account you ordered with.",
|
|
2640
|
+
{ href: "/products/" + ctx.product.slug, label: "Back to product" },
|
|
2641
|
+
));
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
router.get("/products/:slug/review", async function (req, res) {
|
|
2645
|
+
var ctx = await _reviewGateContext(req, res);
|
|
2646
|
+
if (!ctx) return;
|
|
2647
|
+
if (!ctx.purchased) return _reviewIneligible(res, ctx, 200);
|
|
2648
|
+
_send(res, 200, renderReviewForm({
|
|
2649
|
+
product: { title: ctx.product.title, slug: ctx.product.slug },
|
|
2650
|
+
shop_name: shopName,
|
|
2651
|
+
cart_count: ctx.cartCount,
|
|
2652
|
+
}));
|
|
2653
|
+
});
|
|
2654
|
+
|
|
2655
|
+
router.post("/products/:slug/review", async function (req, res) {
|
|
2656
|
+
var ctx = await _reviewGateContext(req, res);
|
|
2657
|
+
if (!ctx) return;
|
|
2658
|
+
// Re-check the gate on write — a client can POST directly
|
|
2659
|
+
// without ever fetching the form.
|
|
2660
|
+
if (!ctx.purchased) return _reviewIneligible(res, ctx, 403);
|
|
2661
|
+
var body = req.body || {};
|
|
2662
|
+
try {
|
|
2663
|
+
await deps.reviews.submit({
|
|
2664
|
+
product_id: ctx.product.id,
|
|
2665
|
+
customer_id: ctx.auth.customer_id,
|
|
2666
|
+
rating: parseInt(body.rating, 10),
|
|
2667
|
+
title: body.title,
|
|
2668
|
+
body: body.body,
|
|
2669
|
+
verified_purchase: 1,
|
|
2670
|
+
});
|
|
2671
|
+
} catch (e) {
|
|
2672
|
+
// Shape rejections bounce back to the form with the reason;
|
|
2673
|
+
// anything else is a real 500.
|
|
2674
|
+
if (e instanceof TypeError) {
|
|
2675
|
+
return _send(res, 400, renderReviewForm({
|
|
2676
|
+
product: { title: ctx.product.title, slug: ctx.product.slug },
|
|
2677
|
+
notice: (e && e.message) || "Please check your review and try again.",
|
|
2678
|
+
shop_name: shopName,
|
|
2679
|
+
cart_count: ctx.cartCount,
|
|
2680
|
+
}));
|
|
2681
|
+
}
|
|
2682
|
+
throw e;
|
|
2683
|
+
}
|
|
2684
|
+
_send(res, 200, _reviewMessagePage(
|
|
2685
|
+
{ shop_name: shopName, cart_count: ctx.cartCount },
|
|
2686
|
+
"Thanks for your review",
|
|
2687
|
+
"Your review has been submitted and is pending moderation. It will appear on the product page once an operator approves it.",
|
|
2688
|
+
{ href: "/products/" + ctx.product.slug, label: "Back to product" },
|
|
2689
|
+
));
|
|
2690
|
+
});
|
|
2691
|
+
}
|
|
2342
2692
|
}
|
|
2343
2693
|
|
|
2344
2694
|
// POST /cart/lines — add a line. Reads variant_id + qty from the
|
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.28",
|
|
7
|
+
"tag": "v0.12.28",
|
|
8
8
|
"license": "Apache-2.0",
|
|
9
9
|
"author": "blamejs contributors",
|
|
10
10
|
"source": "https://github.com/blamejs/blamejs",
|