@blamejs/blamejs-shop 0.1.29 → 0.1.31

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +4 -1
  3. package/SECURITY.md +9 -0
  4. package/lib/admin.js +317 -1
  5. package/lib/asset-manifest.json +1 -1
  6. package/lib/product-qa.js +88 -0
  7. package/lib/storefront.js +640 -1
  8. package/lib/vendor/MANIFEST.json +2 -2
  9. package/lib/vendor/blamejs/CHANGELOG.md +10 -0
  10. package/lib/vendor/blamejs/README.md +4 -0
  11. package/lib/vendor/blamejs/api-snapshot.json +116 -2
  12. package/lib/vendor/blamejs/index.js +8 -0
  13. package/lib/vendor/blamejs/lib/acme.js +2 -2
  14. package/lib/vendor/blamejs/lib/auth/dpop.js +14 -44
  15. package/lib/vendor/blamejs/lib/base32.js +154 -0
  16. package/lib/vendor/blamejs/lib/dbsc.js +5 -18
  17. package/lib/vendor/blamejs/lib/json-schema.js +740 -0
  18. package/lib/vendor/blamejs/lib/jwk.js +127 -0
  19. package/lib/vendor/blamejs/lib/middleware/bot-guard.js +43 -6
  20. package/lib/vendor/blamejs/lib/totp.js +10 -31
  21. package/lib/vendor/blamejs/lib/uri-template.js +286 -0
  22. package/lib/vendor/blamejs/package.json +1 -1
  23. package/lib/vendor/blamejs/release-notes/v0.12.64.json +18 -0
  24. package/lib/vendor/blamejs/release-notes/v0.12.65.json +27 -0
  25. package/lib/vendor/blamejs/release-notes/v0.12.66.json +18 -0
  26. package/lib/vendor/blamejs/release-notes/v0.12.68.json +27 -0
  27. package/lib/vendor/blamejs/release-notes/v0.12.69.json +27 -0
  28. package/lib/vendor/blamejs/test/layer-0-primitives/base32.test.js +79 -0
  29. package/lib/vendor/blamejs/test/layer-0-primitives/bot-guard.test.js +102 -0
  30. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +4 -1
  31. package/lib/vendor/blamejs/test/layer-0-primitives/json-schema.test.js +134 -0
  32. package/lib/vendor/blamejs/test/layer-0-primitives/jwk.test.js +72 -0
  33. package/lib/vendor/blamejs/test/layer-0-primitives/uri-template.test.js +99 -0
  34. package/package.json +1 -1
package/lib/storefront.js CHANGED
@@ -165,6 +165,7 @@ var LAYOUT =
165
165
  " <li><a href=\"/categories\">Categories</a></li>\n" +
166
166
  " <li><a href=\"/?sort=new\">New arrivals</a></li>\n" +
167
167
  " <li><a href=\"/?sort=sale\">On sale</a></li>\n" +
168
+ " <li><a href=\"/compare\">Compare</a></li>\n" +
168
169
  " <li><a href=\"/cart\">Cart</a></li>\n" +
169
170
  " </ul>\n" +
170
171
  " </div>\n" +
@@ -745,9 +746,11 @@ var PRODUCT_PAGE =
745
746
  " </div>\n" +
746
747
  " </div>\n" +
747
748
  " RAW_WISHLIST_PLACEHOLDER\n" +
749
+ " RAW_COMPARE_PLACEHOLDER\n" +
748
750
  " </div>\n" +
749
751
  " </div>\n" +
750
752
  " RAW_REVIEWS_PLACEHOLDER\n" +
753
+ " RAW_QA_PLACEHOLDER\n" +
751
754
  "</section>\n";
752
755
 
753
756
  // PDP gallery markup — composed once per render call from the
@@ -969,6 +972,132 @@ function _reviewMessagePage(opts, heading, message, cta) {
969
972
  });
970
973
  }
971
974
 
975
+ // Builds the PDP Product Q&A block from the published questions + their
976
+ // approved answers. Renders the "no questions yet" empty state when the
977
+ // product has none; `ctaHtml` is the "Ask a question" call-to-action
978
+ // (resolved by the route). Reuses the reviews section's theme classes
979
+ // so no new CSS ships. Mirrors the edge renderer
980
+ // (`worker/render/product.js`) byte-for-byte so both render paths stay
981
+ // in sync.
982
+ function _buildProductQa(questions, ctaHtml) {
983
+ var esc = b.template.escapeHtml;
984
+ questions = questions || [];
985
+
986
+ var head;
987
+ if (questions.length > 0) {
988
+ head = "<p class=\"reviews__count\">" + questions.length +
989
+ (questions.length === 1 ? " question answered" : " questions answered") + "</p>";
990
+ } else {
991
+ head = "<p class=\"reviews__empty\">No questions yet. Be the first to ask about this product.</p>";
992
+ }
993
+
994
+ var list = "";
995
+ for (var i = 0; i < questions.length; i += 1) {
996
+ var q = questions[i];
997
+ var answers = q.answers || [];
998
+ var answerHtml = "";
999
+ for (var j = 0; j < answers.length; j += 1) {
1000
+ var a = answers[j];
1001
+ var who = Number(a.is_operator) === 1
1002
+ ? "<span class=\"review__verified\">Answered by the seller</span>"
1003
+ : (a.author === "system"
1004
+ ? "<span class=\"review__verified\">Automated answer</span>"
1005
+ : "<span class=\"review__verified\">Customer answer</span>");
1006
+ var pinned = Number(a.pinned) === 1
1007
+ ? "<span class=\"review__verified\">Top answer</span>"
1008
+ : "";
1009
+ answerHtml +=
1010
+ "<li class=\"review qa__answer\">" +
1011
+ "<div class=\"review__meta\">" + who + pinned + "</div>" +
1012
+ "<p class=\"review__body\">" + esc(String(a.body)) + "</p>" +
1013
+ "</li>";
1014
+ }
1015
+ var answerList = answerHtml
1016
+ ? "<ul class=\"reviews__list qa__answers\">" + answerHtml + "</ul>"
1017
+ : "<p class=\"reviews__empty\">Awaiting an answer.</p>";
1018
+ list +=
1019
+ "<li class=\"review qa__question\">" +
1020
+ "<div class=\"review__head\">" +
1021
+ "<h3 class=\"review__title\">" + esc(String(q.body)) + "</h3>" +
1022
+ "</div>" +
1023
+ answerList +
1024
+ "</li>";
1025
+ }
1026
+ var listHtml = list ? "<ul class=\"reviews__list\">" + list + "</ul>" : "";
1027
+
1028
+ return "<section class=\"reviews qa\" aria-labelledby=\"qa-title\">" +
1029
+ "<h2 id=\"qa-title\" class=\"reviews__heading\">Questions &amp; answers</h2>" +
1030
+ head +
1031
+ listHtml +
1032
+ (ctaHtml || "") +
1033
+ "</section>";
1034
+ }
1035
+
1036
+ var QA_FORM_PAGE =
1037
+ "<section class=\"review-form-page\">\n" +
1038
+ " <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\">\n" +
1039
+ " <ol>\n" +
1040
+ " <li><a href=\"/\">Shop</a></li>\n" +
1041
+ " <li><a href=\"/products/{{slug}}\">{{title}}</a></li>\n" +
1042
+ " <li aria-current=\"page\">Ask a question</li>\n" +
1043
+ " </ol>\n" +
1044
+ " </nav>\n" +
1045
+ " <h1 class=\"review-form-page__title\">Ask about {{title}}</h1>\n" +
1046
+ " RAW_NOTICE_PLACEHOLDER\n" +
1047
+ " <form class=\"review-form\" method=\"post\" action=\"/products/{{slug}}/question\">\n" +
1048
+ " <label class=\"form-field\">\n" +
1049
+ " <span class=\"form-field__label\">Your question</span>\n" +
1050
+ " <textarea name=\"body\" maxlength=\"4000\" rows=\"6\" required></textarea>\n" +
1051
+ " </label>\n" +
1052
+ " <button type=\"submit\" class=\"btn-primary\">Submit question</button>\n" +
1053
+ " </form>\n" +
1054
+ "</section>\n";
1055
+
1056
+ // Auth-gated question form. `opts.product` carries { title, slug };
1057
+ // `opts.notice` is an optional error string rendered above the form
1058
+ // (e.g. a validation rejection bounced back from POST).
1059
+ function renderQuestionForm(opts) {
1060
+ var esc = b.template.escapeHtml;
1061
+ var notice = opts.notice
1062
+ ? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
1063
+ : "";
1064
+ var body = _render(QA_FORM_PAGE, {
1065
+ title: opts.product.title,
1066
+ slug: opts.product.slug,
1067
+ })
1068
+ .replace("RAW_NOTICE_PLACEHOLDER", notice);
1069
+ return _wrap({
1070
+ title: "Ask about " + opts.product.title,
1071
+ shop_name: opts.shop_name || "blamejs.shop",
1072
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
1073
+ theme_css: opts.theme_css,
1074
+ body: body,
1075
+ });
1076
+ }
1077
+
1078
+ // Generic single-message page for the Q&A flow (submission thank-you).
1079
+ // `cta` is an optional { href, label }. Reuses the review-message
1080
+ // layout classes.
1081
+ function _qaMessagePage(opts, heading, message, cta) {
1082
+ var esc = b.template.escapeHtml;
1083
+ var ctaHtml = cta
1084
+ ? "<a class=\"btn-primary\" href=\"" + esc(cta.href) + "\">" + esc(cta.label) + "</a>"
1085
+ : "";
1086
+ var body =
1087
+ "<section class=\"review-message\">" +
1088
+ "<h1 class=\"review-message__title\">" + esc(heading) + "</h1>" +
1089
+ "<p class=\"review-message__lede\">" + esc(message) + "</p>" +
1090
+ ctaHtml +
1091
+ "</section>";
1092
+ return _wrap({
1093
+ title: heading,
1094
+ shop_name: opts.shop_name || "blamejs.shop",
1095
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
1096
+ theme_css: opts.theme_css,
1097
+ body: body,
1098
+ });
1099
+ }
1100
+
972
1101
  // Remove control for a wishlist entry — a form POST back through the
973
1102
  // toggle route with `return_to` so the customer lands back on the
974
1103
  // account page (not the product PDP the default toggle returns to).
@@ -1035,6 +1164,177 @@ function renderWishlist(opts) {
1035
1164
  });
1036
1165
  }
1037
1166
 
1167
+ // Compare page notice banner — surfaced after a toggle / a full-basket
1168
+ // refusal. The route passes a short message key; unknown keys render
1169
+ // nothing so a forged `?notice=` query can't inject arbitrary copy.
1170
+ var COMPARE_NOTICES = {
1171
+ added: "Added to your comparison.",
1172
+ removed: "Removed from your comparison.",
1173
+ full: "Your comparison is full (4 products max). Remove one to add another.",
1174
+ cleared: "Comparison cleared.",
1175
+ };
1176
+ function _compareNotice(key) {
1177
+ var msg = COMPARE_NOTICES[key];
1178
+ if (!msg) return "";
1179
+ return "<p class=\"compare-page__notice\" role=\"status\">" + b.template.escapeHtml(msg) + "</p>";
1180
+ }
1181
+
1182
+ // Side-by-side comparison table. `opts.columns` is the resolved per-
1183
+ // product column list in basket order:
1184
+ // { product_id, product, hero_media, price, available }
1185
+ // where `product` is null for a product archived / deleted between the
1186
+ // add and this render (orphan-tolerant — the column renders "no longer
1187
+ // available" and a remove control, never crashes the table). `opts.rows`
1188
+ // is the attribute matrix from product-compare's `compareTable`:
1189
+ // [ { attribute: { slug, label, format }, values_per_product: [...] } ]
1190
+ // each `values_per_product` entry positionally aligned with `columns`.
1191
+ // The currency/number/boolean formatting happens here (the display
1192
+ // layer), not in the primitive.
1193
+ function _compareCellValue(value, format) {
1194
+ var esc = b.template.escapeHtml;
1195
+ if (value == null) return "<span class=\"compare-cell--empty\" aria-hidden=\"true\">—</span><span class=\"sr-only\">Not specified</span>";
1196
+ if (format === "currency") {
1197
+ var minor = Number(value);
1198
+ if (!Number.isFinite(minor)) return esc(String(value));
1199
+ return esc(pricing.format(Math.round(minor), "USD"));
1200
+ }
1201
+ if (format === "boolean") {
1202
+ return value ? "Yes" : "No";
1203
+ }
1204
+ return esc(String(value));
1205
+ }
1206
+
1207
+ function renderCompare(opts) {
1208
+ var esc = b.template.escapeHtml;
1209
+ var columns = opts.columns || [];
1210
+ var attrRows = opts.rows || [];
1211
+ var prefix = opts.asset_prefix || "/assets/";
1212
+ var notice = _compareNotice(opts.notice);
1213
+
1214
+ if (!columns.length) {
1215
+ var empty =
1216
+ "<section class=\"compare-page\">" +
1217
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
1218
+ "<li><a href=\"/\">Shop</a></li>" +
1219
+ "<li aria-current=\"page\">Compare</li>" +
1220
+ "</ol></nav>" +
1221
+ "<h1 class=\"compare-page__title\">Compare products</h1>" +
1222
+ notice +
1223
+ "<p class=\"compare-page__empty\">You haven't added anything to compare yet. Browse the shop and tap " +
1224
+ "<strong>Add to compare</strong> on up to four products to line them up side by side.</p>" +
1225
+ "<p><a class=\"btn-secondary\" href=\"/\">Browse the shop →</a></p>" +
1226
+ "</section>";
1227
+ return _wrap({
1228
+ title: "Compare products",
1229
+ shop_name: opts.shop_name || "blamejs.shop",
1230
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
1231
+ theme_css: opts.theme_css,
1232
+ body: empty,
1233
+ });
1234
+ }
1235
+
1236
+ // Per-column header cell — image + title + remove form. A column whose
1237
+ // product resolved out (archived / deleted) renders the gone-state
1238
+ // header and still offers the remove control so the shopper can clear
1239
+ // the dangling entry.
1240
+ function _headerCell(col) {
1241
+ var removeForm =
1242
+ "<form class=\"compare-col__remove\" method=\"post\" action=\"/compare/toggle\">" +
1243
+ "<input type=\"hidden\" name=\"product_id\" value=\"" + esc(col.product_id) + "\">" +
1244
+ "<input type=\"hidden\" name=\"return_to\" value=\"/compare\">" +
1245
+ "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Remove</button>" +
1246
+ "</form>";
1247
+ if (!col.product) {
1248
+ return "<th scope=\"col\" class=\"compare-col compare-col--gone\">" +
1249
+ "<span class=\"compare-col__title\">No longer available</span>" +
1250
+ removeForm +
1251
+ "</th>";
1252
+ }
1253
+ var slug = esc(col.product.slug);
1254
+ var thumb = col.hero_media
1255
+ ? "<img src=\"" + esc(prefix + col.hero_media.r2_key) + "\" alt=\"" + esc(col.hero_media.alt_text || col.product.title) + "\" loading=\"lazy\">"
1256
+ : "<span class=\"compare-col__mark\" aria-hidden=\"true\">" + esc((col.product.title || "?").trim().charAt(0).toUpperCase() || "?") + "</span>";
1257
+ return "<th scope=\"col\" class=\"compare-col\">" +
1258
+ "<a class=\"compare-col__media\" href=\"/products/" + slug + "\">" + thumb + "</a>" +
1259
+ "<a class=\"compare-col__title\" href=\"/products/" + slug + "\">" + esc(col.product.title) + "</a>" +
1260
+ removeForm +
1261
+ "</th>";
1262
+ }
1263
+
1264
+ var headerCells = "";
1265
+ for (var h = 0; h < columns.length; h += 1) headerCells += _headerCell(columns[h]);
1266
+
1267
+ // Fixed display rows resolved directly (richer than the attribute
1268
+ // matrix): price + availability. Price for a gone column renders "—".
1269
+ function _priceCell(col) {
1270
+ if (!col.product) return "<td class=\"compare-cell compare-cell--gone\">—</td>";
1271
+ return "<td class=\"compare-cell\">" + esc(col.price || "—") + "</td>";
1272
+ }
1273
+ function _availCell(col) {
1274
+ if (!col.product) return "<td class=\"compare-cell compare-cell--gone\">—</td>";
1275
+ return col.available
1276
+ ? "<td class=\"compare-cell\"><span class=\"compare-cell__badge compare-cell__badge--ok\">In stock</span></td>"
1277
+ : "<td class=\"compare-cell\"><span class=\"compare-cell__badge\">Out of stock</span></td>";
1278
+ }
1279
+ var priceRow = "<tr><th scope=\"row\" class=\"compare-row-label\">Price</th>";
1280
+ var availRow = "<tr><th scope=\"row\" class=\"compare-row-label\">Availability</th>";
1281
+ for (var c = 0; c < columns.length; c += 1) {
1282
+ priceRow += _priceCell(columns[c]);
1283
+ availRow += _availCell(columns[c]);
1284
+ }
1285
+ priceRow += "</tr>";
1286
+ availRow += "</tr>";
1287
+
1288
+ // Attribute matrix rows from the primitive. The `price` attribute is
1289
+ // already surfaced as the fixed Price row above, so skip its duplicate
1290
+ // here; every other attribute renders one row, one cell per column.
1291
+ var attrRowsHtml = "";
1292
+ for (var r = 0; r < attrRows.length; r += 1) {
1293
+ var ar = attrRows[r];
1294
+ if (ar.attribute && ar.attribute.slug === "price") continue;
1295
+ var label = (ar.attribute && ar.attribute.label) || "";
1296
+ var fmt = ar.attribute && ar.attribute.format;
1297
+ var cells = "";
1298
+ var vals = ar.values_per_product || [];
1299
+ for (var v = 0; v < columns.length; v += 1) {
1300
+ cells += "<td class=\"compare-cell\">" + _compareCellValue(vals[v], fmt) + "</td>";
1301
+ }
1302
+ attrRowsHtml += "<tr><th scope=\"row\" class=\"compare-row-label\">" + esc(label) + "</th>" + cells + "</tr>";
1303
+ }
1304
+
1305
+ var clearForm =
1306
+ "<form class=\"compare-page__clear\" method=\"post\" action=\"/compare/clear\">" +
1307
+ "<button type=\"submit\" class=\"btn-ghost\">Clear comparison</button>" +
1308
+ "</form>";
1309
+
1310
+ var body =
1311
+ "<section class=\"compare-page\">" +
1312
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
1313
+ "<li><a href=\"/\">Shop</a></li>" +
1314
+ "<li aria-current=\"page\">Compare</li>" +
1315
+ "</ol></nav>" +
1316
+ "<header class=\"compare-page__head\">" +
1317
+ "<h1 class=\"compare-page__title\">Compare products (" + columns.length + ")</h1>" +
1318
+ clearForm +
1319
+ "</header>" +
1320
+ notice +
1321
+ "<div class=\"table-scroll\">" +
1322
+ "<table class=\"compare-table\">" +
1323
+ "<thead><tr><th scope=\"col\" class=\"compare-row-label\"><span class=\"sr-only\">Attribute</span></th>" + headerCells + "</tr></thead>" +
1324
+ "<tbody>" + priceRow + availRow + attrRowsHtml + "</tbody>" +
1325
+ "</table>" +
1326
+ "</div>" +
1327
+ "</section>";
1328
+
1329
+ return _wrap({
1330
+ title: "Compare products",
1331
+ shop_name: opts.shop_name || "blamejs.shop",
1332
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
1333
+ theme_css: opts.theme_css,
1334
+ body: body,
1335
+ });
1336
+ }
1337
+
1038
1338
  // Account "Saved for later" page. `opts.items` is a resolved list:
1039
1339
  // { save, product, hero_media } for live products, or { save,
1040
1340
  // product: null } when the variant/product behind a saved row was
@@ -1966,6 +2266,27 @@ function _buildWishlist(productId, count) {
1966
2266
  "</div>";
1967
2267
  }
1968
2268
 
2269
+ // Product-level "Add to compare" control. Byte-compatible with the
2270
+ // edge renderer (`worker/render/product.js`) so both paths emit
2271
+ // identical markup. Action-only label — the toggle route resolves
2272
+ // add/remove server-side against the sealed session, and a guest who
2273
+ // has never compared anything still sees the same neutral control the
2274
+ // edge cache serves. The link to the compare table sits beside the
2275
+ // toggle so a shopper mid-basket can jump straight to the side-by-side
2276
+ // view.
2277
+ function _buildCompare(productId) {
2278
+ var esc = b.template.escapeHtml;
2279
+ return "<div class=\"compare\">" +
2280
+ "<form class=\"compare__form\" method=\"post\" action=\"/compare/toggle\">" +
2281
+ "<input type=\"hidden\" name=\"product_id\" value=\"" + esc(productId) + "\">" +
2282
+ "<button type=\"submit\" class=\"btn-secondary compare__btn\">" +
2283
+ "<span class=\"compare__icon\" aria-hidden=\"true\">⇄</span> Add to compare" +
2284
+ "</button>" +
2285
+ "</form>" +
2286
+ "<a class=\"compare__link card-link\" href=\"/compare\">View compare →</a>" +
2287
+ "</div>";
2288
+ }
2289
+
1969
2290
  // Schema.org JSON-LD block. JSON.stringify covers the standard escapes;
1970
2291
  // the `</` → `<\/` rewrite neutralises any literal `</script>` in a
1971
2292
  // value. Mirrors the edge renderer's `jsonLdScript`.
@@ -1999,7 +2320,9 @@ function renderProduct(opts) {
1999
2320
  // theme's `{{{ reviews_html }}}` raw slot. The bundled themes
2000
2321
  // include the slot; a custom theme opts in by adding it.
2001
2322
  reviews_html: _buildReviews(opts.review_summary, opts.reviews, opts.review_cta),
2323
+ qa_html: _buildProductQa(opts.qa_questions, opts.qa_cta),
2002
2324
  wishlist_html: _buildWishlist(opts.product.id, opts.wishlist_count),
2325
+ compare_html: _buildCompare(opts.product.id),
2003
2326
  asset_css_main: opts.theme.assetUrl("css/main.css"),
2004
2327
  });
2005
2328
  }
@@ -2009,7 +2332,9 @@ function renderProduct(opts) {
2009
2332
  if (!rows) rows = "<tr><td colspan=\"4\" class=\"empty\">No variants available.</td></tr>";
2010
2333
  var galleryHtml = _buildPdpGallery(opts.product, opts.media || [], opts.asset_prefix || "/assets/");
2011
2334
  var reviewsHtml = _buildReviews(opts.review_summary, opts.reviews, opts.review_cta);
2335
+ var qaHtml = _buildProductQa(opts.qa_questions, opts.qa_cta);
2012
2336
  var wishlistHtml = _buildWishlist(opts.product.id, opts.wishlist_count);
2337
+ var compareHtml = _buildCompare(opts.product.id);
2013
2338
  var body = _render(PRODUCT_PAGE, {
2014
2339
  title: opts.product.title,
2015
2340
  description: description,
@@ -2018,7 +2343,9 @@ function renderProduct(opts) {
2018
2343
  .replace("RAW_GALLERY_PLACEHOLDER", galleryHtml)
2019
2344
  .replace("RAW_ROWS_PLACEHOLDER", rows)
2020
2345
  .replace("RAW_WISHLIST_PLACEHOLDER", wishlistHtml)
2021
- .replace("RAW_REVIEWS_PLACEHOLDER", reviewsHtml);
2346
+ .replace("RAW_COMPARE_PLACEHOLDER", compareHtml)
2347
+ .replace("RAW_REVIEWS_PLACEHOLDER", reviewsHtml)
2348
+ .replace("RAW_QA_PLACEHOLDER", qaHtml);
2022
2349
  // Product-specific OpenGraph + Twitter Card values so shares
2023
2350
  // unfurl as "Operator Tee — blamejs.shop" with the SVG hero, not
2024
2351
  // the default shop-level description + brand logo.
@@ -3407,6 +3734,27 @@ function mount(router, deps) {
3407
3734
  try { wishlistCount = await deps.wishlist.countForProduct(product.id); }
3408
3735
  catch (_e) { wishlistCount = 0; }
3409
3736
  }
3737
+ // Published Q&A — approved questions + their approved answers. A
3738
+ // failed read (e.g. the product_qa tables not yet migrated) degrades
3739
+ // to the empty state rather than 500-ing the PDP — Q&A is
3740
+ // supplementary to the buy path, like reviews. Mirrors the edge
3741
+ // renderer's missing-table resilience.
3742
+ var qaQuestions, qaCta;
3743
+ if (deps.productQa) {
3744
+ qaQuestions = [];
3745
+ try {
3746
+ var qList = (await deps.productQa.questionsForProduct({ product_id: product.id, limit: 20 })).rows;
3747
+ for (var qi = 0; qi < qList.length; qi += 1) {
3748
+ var qrow = qList[qi];
3749
+ qrow.answers = await deps.productQa.answersForQuestion(qrow.id, { limit: 20 });
3750
+ qaQuestions.push(qrow);
3751
+ }
3752
+ } catch (_e) { qaQuestions = []; }
3753
+ // The form route enforces auth, so the CTA links there
3754
+ // unconditionally; logged-out shoppers get redirected to login.
3755
+ qaCta = "<a class=\"btn-secondary reviews__cta\" href=\"/products/" +
3756
+ b.template.escapeHtml(product.slug) + "/question\">Ask a question</a>";
3757
+ }
3410
3758
  // Log the view for a signed-in customer so it surfaces on their
3411
3759
  // "Recently viewed" account page. Drop-silent — a recording failure
3412
3760
  // (table not migrated, write contention) must never break the PDP
@@ -3428,6 +3776,8 @@ function mount(router, deps) {
3428
3776
  review_summary: reviewSummary,
3429
3777
  reviews: reviewRows,
3430
3778
  review_cta: reviewCta,
3779
+ qa_questions: qaQuestions,
3780
+ qa_cta: qaCta,
3431
3781
  wishlist_count: wishlistCount,
3432
3782
  shop_name: shopName,
3433
3783
  cart_count: cartCount,
@@ -3436,6 +3786,224 @@ function mount(router, deps) {
3436
3786
  _send(res, 200, html);
3437
3787
  });
3438
3788
 
3789
+ // Product compare — the guest-or-customer side-by-side basket. Mounts
3790
+ // when the productCompare primitive is wired. The basket is keyed on
3791
+ // the same `shop_sid` session cookie the cart uses (a routing key that
3792
+ // grants zero authority — the primitive namespace-hashes it before it
3793
+ // touches the database), so a shopper compares without authenticating;
3794
+ // a logged-in shopper's customer_id rides alongside so the operator's
3795
+ // account widget could resume the basket on another device. All three
3796
+ // routes (toggle / clear / view) are writes-or-session-reads, so they
3797
+ // live in the container — the edge forwards them (and any request
3798
+ // carrying shop_sid skips the edge cache, so the view always reflects
3799
+ // the live basket).
3800
+ if (deps.productCompare) {
3801
+ // Resolve the compare basket's session id. Reads the cart session
3802
+ // cookie; when `mint` is true and none exists, allocates one + sets
3803
+ // the cookie so the basket has a stable key across requests. A
3804
+ // read-only path (the view) passes mint=false and returns null when
3805
+ // there's no session — that renders the empty state without minting
3806
+ // a cookie on a bare GET.
3807
+ function _compareSid(req, res, mint) {
3808
+ var sid = _readSidCookie(req);
3809
+ if (sid) return sid;
3810
+ if (!mint) return null;
3811
+ sid = b.uuid.v7();
3812
+ _setSidCookie(res, sid);
3813
+ return sid;
3814
+ }
3815
+
3816
+ function _compareRedirect(res, dest, notice) {
3817
+ var sep = dest.indexOf("?") === -1 ? "?" : "&";
3818
+ var to = notice ? (dest + sep + "notice=" + encodeURIComponent(notice)) : dest;
3819
+ res.status(303);
3820
+ res.setHeader && res.setHeader("location", to);
3821
+ return res.end ? res.end() : res.send("");
3822
+ }
3823
+
3824
+ // POST /compare/toggle — add the product if it isn't in the basket,
3825
+ // remove it if it is. Idempotent (the primitive collapses a repeat
3826
+ // add / a remove of an absent id). Redirects to `return_to` when it's
3827
+ // a safe same-origin path (the compare page's per-column Remove uses
3828
+ // it), otherwise back to the product PDP (the canonical slug is
3829
+ // resolved from product_id, so a forged slug can't drive an open
3830
+ // redirect). No auth required — guests compare too.
3831
+ router.post("/compare/toggle", async function (req, res) {
3832
+ var productId = (req.body || {}).product_id;
3833
+ var sid = _compareSid(req, res, true);
3834
+ var custEnv = _currentCustomerEnv(req);
3835
+ var customerId = custEnv ? custEnv.customer_id : null;
3836
+
3837
+ // Resolve the safe redirect destination up front so a validation
3838
+ // failure still lands the shopper somewhere sane.
3839
+ var rt = (req.body || {}).return_to;
3840
+ var dest = null;
3841
+ if (typeof rt === "string" && /^\/[^/]/.test(rt)) dest = rt;
3842
+
3843
+ var notice;
3844
+ try {
3845
+ var list = await deps.productCompare.getCompareList({ session_id: sid });
3846
+ var inBasket = list.product_ids.indexOf(productId) !== -1;
3847
+ if (inBasket) {
3848
+ await deps.productCompare.removeFromCompare({ session_id: sid, product_id: productId });
3849
+ notice = "removed";
3850
+ } else {
3851
+ await deps.productCompare.addToCompare({ session_id: sid, product_id: productId, customer_id: customerId });
3852
+ notice = "added";
3853
+ // Best-effort impression telemetry — drop-silent, never block
3854
+ // the toggle on the merchandising ledger write.
3855
+ try {
3856
+ await deps.productCompare.recordImpression({ session_id: sid, product_id: productId, source_kind: "product_page" });
3857
+ } catch (_e) { /* drop-silent — impression telemetry is supplementary */ }
3858
+ }
3859
+ } catch (e) {
3860
+ if (e && e.code === "COMPARE_FULL") {
3861
+ // Cap reached — refuse the add with a notice rather than a
3862
+ // crash. The basket is unchanged; the shopper removes one to
3863
+ // make room.
3864
+ if (!dest) {
3865
+ var capProduct = null;
3866
+ try { capProduct = await deps.catalog.products.get(productId); } catch (_e2) { capProduct = null; }
3867
+ dest = capProduct ? ("/products/" + encodeURIComponent(capProduct.slug)) : "/compare";
3868
+ }
3869
+ return _compareRedirect(res, dest, "full");
3870
+ }
3871
+ // A malformed product id (or session id) is a client error.
3872
+ res.status(e instanceof TypeError ? 400 : 500);
3873
+ return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
3874
+ }
3875
+
3876
+ if (!dest) {
3877
+ var product = null;
3878
+ try { product = await deps.catalog.products.get(productId); } catch (_e) { product = null; }
3879
+ dest = product ? ("/products/" + encodeURIComponent(product.slug)) : "/compare";
3880
+ }
3881
+ return _compareRedirect(res, dest, notice);
3882
+ });
3883
+
3884
+ // POST /compare/clear — drop the whole basket. Idempotent (clearing
3885
+ // an empty basket is a no-op). Always redirects back to the compare
3886
+ // page so the shopper sees the emptied state.
3887
+ router.post("/compare/clear", async function (req, res) {
3888
+ var sid = _compareSid(req, res, false);
3889
+ if (sid) {
3890
+ try { await deps.productCompare.clearCompareList({ session_id: sid }); }
3891
+ catch (e) {
3892
+ if (!(e instanceof TypeError)) {
3893
+ res.status(500);
3894
+ return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
3895
+ }
3896
+ // A malformed session cookie can't address a basket — treat
3897
+ // the clear as a no-op rather than a 400 (the cookie grants
3898
+ // zero authority; a stale/garbage value just means "no basket").
3899
+ }
3900
+ }
3901
+ return _compareRedirect(res, "/compare", "cleared");
3902
+ });
3903
+
3904
+ // GET /compare — the side-by-side comparison table for the session's
3905
+ // basket. Empty basket → the friendly empty state. Each product id is
3906
+ // resolved through the catalog; a product archived / deleted between
3907
+ // the add and this render resolves out gracefully (the column renders
3908
+ // "no longer available" with a remove control). The attribute matrix
3909
+ // comes from the primitive's compareTable, fed a getProduct adapter
3910
+ // enriched with the product's first variant + current price so the
3911
+ // baked-in price/sku/weight attributes resolve against this catalog's
3912
+ // schema.
3913
+ router.get("/compare", async function (req, res) {
3914
+ var sid = _compareSid(req, res, false);
3915
+ var cartCount = await _cartCountForReq(req);
3916
+ // Read the post-toggle notice key from the query string the same
3917
+ // way the search route reads `?q=` — via the parsed URL, not
3918
+ // `req.query` (which the router only populates when a route
3919
+ // declares a query validator). renderCompare gates the key
3920
+ // against a fixed allowlist, so a forged value renders nothing.
3921
+ var compareUrl = req.url ? new URL(req.url, "http://localhost") : null;
3922
+ var noticeKey = compareUrl ? compareUrl.searchParams.get("notice") : null;
3923
+
3924
+ if (!sid) {
3925
+ return _send(res, 200, renderCompare({
3926
+ columns: [], rows: [], shop_name: shopName, cart_count: cartCount,
3927
+ asset_prefix: _cardAssetPrefix, notice: noticeKey,
3928
+ }));
3929
+ }
3930
+
3931
+ // Per-request product cache so the header-column decoration and the
3932
+ // attribute-matrix adapter don't double-fetch the same product.
3933
+ var enrichedCache = Object.create(null);
3934
+ async function _enrich(pid) {
3935
+ if (Object.prototype.hasOwnProperty.call(enrichedCache, pid)) return enrichedCache[pid];
3936
+ var result = { product: null, variant: null, price: null, hero_media: null, available: false };
3937
+ var product = null;
3938
+ // get() throws a TypeError on a malformed id; a basket can only
3939
+ // hold strict-UUID ids (the primitive sanitises on add), but stay
3940
+ // defensive — a bad id resolves to the gone-state column.
3941
+ try { product = await deps.catalog.products.get(pid); } catch (_e) { product = null; }
3942
+ if (product && product.status === "active") {
3943
+ result.product = product;
3944
+ var variants = await deps.catalog.variants.listForProduct(pid);
3945
+ if (variants.length) {
3946
+ result.variant = variants[0];
3947
+ var pr = await deps.catalog.prices.current(variants[0].id, "USD");
3948
+ if (pr) result.price = pricing.format(pr.amount_minor, pr.currency);
3949
+ // Availability — best-effort inventory read on the first
3950
+ // variant. A product with no inventory row reads as available
3951
+ // (operators who don't track stock still sell), an explicit
3952
+ // zero-on-hand reads as out of stock.
3953
+ try {
3954
+ var inv = await deps.catalog.inventory.get(variants[0].sku);
3955
+ result.available = !inv || (Number(inv.stock_on_hand) - Number(inv.stock_held)) > 0;
3956
+ } catch (_e) { result.available = true; }
3957
+ } else {
3958
+ result.available = false;
3959
+ }
3960
+ var media = await deps.catalog.media.listForProduct(pid);
3961
+ result.hero_media = media.length ? media[0] : null;
3962
+ }
3963
+ enrichedCache[pid] = result;
3964
+ return result;
3965
+ }
3966
+
3967
+ var list = await deps.productCompare.getCompareList({ session_id: sid });
3968
+ var columns = [];
3969
+ for (var i = 0; i < list.product_ids.length; i += 1) {
3970
+ var pid = list.product_ids[i];
3971
+ var e = await _enrich(pid);
3972
+ columns.push({
3973
+ product_id: pid,
3974
+ product: e.product,
3975
+ hero_media: e.hero_media,
3976
+ price: e.price,
3977
+ available: e.available,
3978
+ });
3979
+ }
3980
+
3981
+ // Attribute matrix from the primitive. compareTable walks the
3982
+ // basket's product ids through the `catalog` adapter wired into the
3983
+ // primitive at create time (server.js) — that adapter enriches each
3984
+ // product with a `variants` array (price as `price_minor`, `weight`
3985
+ // from `weight_grams`) so the baked-in variant-sourced attributes
3986
+ // resolve against this catalog's column shape. compareTable refuses
3987
+ // (throws) when no catalog adapter was wired; a resolution failure
3988
+ // degrades to no attribute rows — the fixed Price/Availability rows
3989
+ // still render the side-by-side view.
3990
+ var attrRows = [];
3991
+ try {
3992
+ var table = await deps.productCompare.compareTable({ session_id: sid });
3993
+ attrRows = table.rows || [];
3994
+ } catch (_e) { attrRows = []; }
3995
+
3996
+ _send(res, 200, renderCompare({
3997
+ columns: columns,
3998
+ rows: attrRows,
3999
+ shop_name: shopName,
4000
+ cart_count: cartCount,
4001
+ asset_prefix: _cardAssetPrefix,
4002
+ notice: noticeKey,
4003
+ }));
4004
+ });
4005
+ }
4006
+
3439
4007
  // Collections — operator-curated + smart product lists. Public browse
3440
4008
  // pages; mounted when the collections primitive is wired.
3441
4009
  if (deps.collections) {
@@ -5475,6 +6043,77 @@ function mount(router, deps) {
5475
6043
  ));
5476
6044
  });
5477
6045
  }
6046
+
6047
+ // Product Q&A — asking a question requires a logged-in customer (no
6048
+ // verified-purchase gate; any signed-in shopper can ask). The
6049
+ // question lands `pending` and surfaces on the PDP once an operator
6050
+ // approves it. Customer-authored answers aren't accepted from the
6051
+ // storefront — answering is an operator action in the admin console
6052
+ // (the lib models customer answers, but exposing a public answer
6053
+ // form invites an unmoderated reply surface we don't ship in v1;
6054
+ // operators post the authoritative answer). Only mounts when the
6055
+ // productQa primitive is wired.
6056
+ if (deps.productQa) {
6057
+ async function _qaGateContext(req, res) {
6058
+ var slug = req.params && req.params.slug;
6059
+ var product = slug ? await deps.catalog.products.bySlug(slug) : null;
6060
+ if (!product) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
6061
+ var auth;
6062
+ try { auth = _currentCustomer(req); }
6063
+ catch (e) {
6064
+ if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
6065
+ throw e;
6066
+ }
6067
+ if (!auth) {
6068
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
6069
+ res.end ? res.end() : res.send("");
6070
+ return null;
6071
+ }
6072
+ var cartCount = await _cartCountForReq(req);
6073
+ return { product: product, auth: auth, cartCount: cartCount };
6074
+ }
6075
+
6076
+ router.get("/products/:slug/question", async function (req, res) {
6077
+ var ctx = await _qaGateContext(req, res);
6078
+ if (!ctx) return;
6079
+ _send(res, 200, renderQuestionForm({
6080
+ product: { title: ctx.product.title, slug: ctx.product.slug },
6081
+ shop_name: shopName,
6082
+ cart_count: ctx.cartCount,
6083
+ }));
6084
+ });
6085
+
6086
+ router.post("/products/:slug/question", async function (req, res) {
6087
+ var ctx = await _qaGateContext(req, res);
6088
+ if (!ctx) return;
6089
+ var body = req.body || {};
6090
+ try {
6091
+ await deps.productQa.submitQuestion({
6092
+ product_id: ctx.product.id,
6093
+ customer_id: ctx.auth.customer_id,
6094
+ body: body.body,
6095
+ });
6096
+ } catch (e) {
6097
+ // Shape rejections bounce back to the form with the reason;
6098
+ // anything else is a real 500.
6099
+ if (e instanceof TypeError) {
6100
+ return _send(res, 400, renderQuestionForm({
6101
+ product: { title: ctx.product.title, slug: ctx.product.slug },
6102
+ notice: (e && e.message) || "Please check your question and try again.",
6103
+ shop_name: shopName,
6104
+ cart_count: ctx.cartCount,
6105
+ }));
6106
+ }
6107
+ throw e;
6108
+ }
6109
+ _send(res, 200, _qaMessagePage(
6110
+ { shop_name: shopName, cart_count: ctx.cartCount },
6111
+ "Thanks for your question",
6112
+ "Your question has been submitted and is pending moderation. It will appear on the product page once an operator approves and answers it.",
6113
+ { href: "/products/" + ctx.product.slug, label: "Back to product" },
6114
+ ));
6115
+ });
6116
+ }
5478
6117
  }
5479
6118
 
5480
6119
  // POST /cart/lines — add a line. Reads variant_id + qty from the