@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.
- package/CHANGELOG.md +4 -0
- package/README.md +4 -1
- package/SECURITY.md +9 -0
- package/lib/admin.js +317 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/product-qa.js +88 -0
- package/lib/storefront.js +640 -1
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +10 -0
- package/lib/vendor/blamejs/README.md +4 -0
- package/lib/vendor/blamejs/api-snapshot.json +116 -2
- package/lib/vendor/blamejs/index.js +8 -0
- package/lib/vendor/blamejs/lib/acme.js +2 -2
- package/lib/vendor/blamejs/lib/auth/dpop.js +14 -44
- package/lib/vendor/blamejs/lib/base32.js +154 -0
- package/lib/vendor/blamejs/lib/dbsc.js +5 -18
- package/lib/vendor/blamejs/lib/json-schema.js +740 -0
- package/lib/vendor/blamejs/lib/jwk.js +127 -0
- package/lib/vendor/blamejs/lib/middleware/bot-guard.js +43 -6
- package/lib/vendor/blamejs/lib/totp.js +10 -31
- package/lib/vendor/blamejs/lib/uri-template.js +286 -0
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.64.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.12.65.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.12.66.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.12.68.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.12.69.json +27 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/base32.test.js +79 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/bot-guard.test.js +102 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +4 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/json-schema.test.js +134 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/jwk.test.js +72 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/uri-template.test.js +99 -0
- 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 & 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("
|
|
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
|