@blamejs/blamejs-shop 0.0.121 → 0.0.123
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +5 -1
- package/SECURITY.md +11 -2
- package/lib/admin.js +51 -0
- package/lib/order.js +23 -0
- package/lib/reviews.js +29 -0
- package/lib/storefront.js +538 -10
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +16 -0
- package/lib/vendor/blamejs/README.md +3 -0
- package/lib/vendor/blamejs/SECURITY.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +220 -2
- package/lib/vendor/blamejs/index.js +3 -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-dp.js +539 -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/lib/crypto.js +9 -2
- 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/release-notes/v0.12.29.json +31 -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-dp.test.js +167 -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 +60 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/compliance-eu-ai-act-posture.test.js +93 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/crypto-random-int.test.js +21 -0
- package/package.json +1 -1
package/lib/storefront.js
CHANGED
|
@@ -676,8 +676,10 @@ var PRODUCT_PAGE =
|
|
|
676
676
|
" </table>\n" +
|
|
677
677
|
" </div>\n" +
|
|
678
678
|
" </div>\n" +
|
|
679
|
+
" RAW_WISHLIST_PLACEHOLDER\n" +
|
|
679
680
|
" </div>\n" +
|
|
680
681
|
" </div>\n" +
|
|
682
|
+
" RAW_REVIEWS_PLACEHOLDER\n" +
|
|
681
683
|
"</section>\n";
|
|
682
684
|
|
|
683
685
|
// PDP gallery markup — composed once per render call from the
|
|
@@ -720,6 +722,280 @@ function _buildPdpGallery(product, media, assetPrefix) {
|
|
|
720
722
|
return heroImg + "<ul class=\"pdp__thumbs\" aria-hidden=\"true\">" + thumbs.join("") + "</ul>";
|
|
721
723
|
}
|
|
722
724
|
|
|
725
|
+
// Accessible star glyph row — the precise figure rides in a visually-
|
|
726
|
+
// hidden label so a screen reader announces "4.3 out of 5 stars" while
|
|
727
|
+
// sighted users see the rounded glyph fill. Mirrors the edge renderer
|
|
728
|
+
// (`worker/render/product.js`) so both paths emit identical markup.
|
|
729
|
+
function _reviewStars(value, label) {
|
|
730
|
+
var esc = _b().template.escapeHtml;
|
|
731
|
+
var filled = Math.round(value);
|
|
732
|
+
if (filled < 0) filled = 0;
|
|
733
|
+
if (filled > 5) filled = 5;
|
|
734
|
+
var glyphs = "";
|
|
735
|
+
for (var i = 1; i <= 5; i += 1) {
|
|
736
|
+
glyphs += "<span class=\"star" + (i <= filled ? " star--on" : "") + "\">" +
|
|
737
|
+
(i <= filled ? "★" : "☆") + "</span>";
|
|
738
|
+
}
|
|
739
|
+
return "<span class=\"stars\" aria-hidden=\"true\">" + glyphs + "</span>" +
|
|
740
|
+
"<span class=\"sr-only\">" + esc(label) + "</span>";
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function _reviewDate(ts) {
|
|
744
|
+
var n = Number(ts);
|
|
745
|
+
if (!Number.isFinite(n) || n <= 0) return "";
|
|
746
|
+
return new Date(n).toISOString().slice(0, 10);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Builds the PDP reviews block from the published aggregate + list.
|
|
750
|
+
// Renders the "no reviews yet" empty state when the product has none;
|
|
751
|
+
// `ctaHtml` is the operator/customer call-to-action (a "Write a review"
|
|
752
|
+
// link, or "Sign in to review", resolved by the route). Mirrors the
|
|
753
|
+
// edge renderer byte-for-byte so the two render paths stay in sync.
|
|
754
|
+
function _buildReviews(summary, reviews, ctaHtml) {
|
|
755
|
+
var esc = _b().template.escapeHtml;
|
|
756
|
+
summary = summary || { count: 0, avg_rating: 0, distribution: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 } };
|
|
757
|
+
reviews = reviews || [];
|
|
758
|
+
var count = Number(summary.count) || 0;
|
|
759
|
+
|
|
760
|
+
var head;
|
|
761
|
+
if (count > 0) {
|
|
762
|
+
var avg = Number(summary.avg_rating) || 0;
|
|
763
|
+
var avgStr = avg.toFixed(1);
|
|
764
|
+
var dist = summary.distribution || {};
|
|
765
|
+
var bars = "";
|
|
766
|
+
for (var s = 5; s >= 1; s -= 1) {
|
|
767
|
+
var n = Number(dist[s]) || 0;
|
|
768
|
+
var pct = count > 0 ? Math.round((n / count) * 100) : 0;
|
|
769
|
+
bars +=
|
|
770
|
+
"<li class=\"rating-bar\">" +
|
|
771
|
+
"<span class=\"rating-bar__label\">" + s + " star</span>" +
|
|
772
|
+
"<span class=\"rating-bar__track\"><span class=\"rating-bar__fill\" style=\"width:" + pct + "%\"></span></span>" +
|
|
773
|
+
"<span class=\"rating-bar__count\">" + n + "</span>" +
|
|
774
|
+
"</li>";
|
|
775
|
+
}
|
|
776
|
+
head =
|
|
777
|
+
"<div class=\"reviews__summary\">" +
|
|
778
|
+
"<div class=\"reviews__average\">" +
|
|
779
|
+
"<span class=\"reviews__average-num\">" + esc(avgStr) + "</span>" +
|
|
780
|
+
_reviewStars(avg, avgStr + " out of 5 stars") +
|
|
781
|
+
"<span class=\"reviews__count\">" + count + (count === 1 ? " review" : " reviews") + "</span>" +
|
|
782
|
+
"</div>" +
|
|
783
|
+
"<ul class=\"reviews__distribution\">" + bars + "</ul>" +
|
|
784
|
+
"</div>";
|
|
785
|
+
} else {
|
|
786
|
+
head = "<p class=\"reviews__empty\">No reviews yet. Be the first to review this product.</p>";
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
var list = "";
|
|
790
|
+
for (var i = 0; i < reviews.length; i += 1) {
|
|
791
|
+
var r = reviews[i];
|
|
792
|
+
var rating = Number(r.rating) || 0;
|
|
793
|
+
var verified = Number(r.verified_purchase) === 1
|
|
794
|
+
? "<span class=\"review__verified\">Verified buyer</span>"
|
|
795
|
+
: "";
|
|
796
|
+
var date = _reviewDate(r.created_at);
|
|
797
|
+
var bodyHtml = r.body
|
|
798
|
+
? "<p class=\"review__body\">" + esc(String(r.body)) + "</p>"
|
|
799
|
+
: "";
|
|
800
|
+
list +=
|
|
801
|
+
"<li class=\"review\">" +
|
|
802
|
+
"<div class=\"review__head\">" +
|
|
803
|
+
_reviewStars(rating, rating + " out of 5 stars") +
|
|
804
|
+
"<h3 class=\"review__title\">" + esc(String(r.title || "")) + "</h3>" +
|
|
805
|
+
"</div>" +
|
|
806
|
+
"<div class=\"review__meta\">" + verified +
|
|
807
|
+
(date ? "<time class=\"review__date\" datetime=\"" + esc(date) + "\">" + esc(date) + "</time>" : "") +
|
|
808
|
+
"</div>" +
|
|
809
|
+
bodyHtml +
|
|
810
|
+
"</li>";
|
|
811
|
+
}
|
|
812
|
+
var listHtml = list ? "<ul class=\"reviews__list\">" + list + "</ul>" : "";
|
|
813
|
+
|
|
814
|
+
return "<section class=\"reviews\" aria-labelledby=\"reviews-title\">" +
|
|
815
|
+
"<h2 id=\"reviews-title\" class=\"reviews__heading\">Customer reviews</h2>" +
|
|
816
|
+
head +
|
|
817
|
+
listHtml +
|
|
818
|
+
(ctaHtml || "") +
|
|
819
|
+
"</section>";
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
var REVIEW_FORM_PAGE =
|
|
823
|
+
"<section class=\"review-form-page\">\n" +
|
|
824
|
+
" <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\">\n" +
|
|
825
|
+
" <ol>\n" +
|
|
826
|
+
" <li><a href=\"/\">Shop</a></li>\n" +
|
|
827
|
+
" <li><a href=\"/products/{{slug}}\">{{title}}</a></li>\n" +
|
|
828
|
+
" <li aria-current=\"page\">Write a review</li>\n" +
|
|
829
|
+
" </ol>\n" +
|
|
830
|
+
" </nav>\n" +
|
|
831
|
+
" <h1 class=\"review-form-page__title\">Review {{title}}</h1>\n" +
|
|
832
|
+
" RAW_NOTICE_PLACEHOLDER\n" +
|
|
833
|
+
" <form class=\"review-form\" method=\"post\" action=\"/products/{{slug}}/review\">\n" +
|
|
834
|
+
" <fieldset class=\"review-form__rating\">\n" +
|
|
835
|
+
" <legend>Your rating</legend>\n" +
|
|
836
|
+
" RAW_STARS_PLACEHOLDER\n" +
|
|
837
|
+
" </fieldset>\n" +
|
|
838
|
+
" <label class=\"form-field\">\n" +
|
|
839
|
+
" <span class=\"form-field__label\">Title</span>\n" +
|
|
840
|
+
" <input type=\"text\" name=\"title\" maxlength=\"120\" required autocomplete=\"off\">\n" +
|
|
841
|
+
" </label>\n" +
|
|
842
|
+
" <label class=\"form-field\">\n" +
|
|
843
|
+
" <span class=\"form-field__label\">Your review</span>\n" +
|
|
844
|
+
" <textarea name=\"body\" maxlength=\"4000\" rows=\"6\"></textarea>\n" +
|
|
845
|
+
" </label>\n" +
|
|
846
|
+
" <button type=\"submit\" class=\"btn-primary\">Submit review</button>\n" +
|
|
847
|
+
" </form>\n" +
|
|
848
|
+
"</section>\n";
|
|
849
|
+
|
|
850
|
+
// Auth-gated review form. `opts.product` carries { title, slug };
|
|
851
|
+
// `opts.notice` is an optional error string rendered above the form
|
|
852
|
+
// (e.g. a validation rejection bounced back from POST).
|
|
853
|
+
function renderReviewForm(opts) {
|
|
854
|
+
var esc = _b().template.escapeHtml;
|
|
855
|
+
var slug = opts.product.slug;
|
|
856
|
+
var stars = "";
|
|
857
|
+
for (var rv = 5; rv >= 1; rv -= 1) {
|
|
858
|
+
stars +=
|
|
859
|
+
"<label class=\"star-radio\">" +
|
|
860
|
+
"<input type=\"radio\" name=\"rating\" value=\"" + rv + "\" required>" +
|
|
861
|
+
"<span>" + rv + (rv === 1 ? " star" : " stars") + "</span>" +
|
|
862
|
+
"</label>";
|
|
863
|
+
}
|
|
864
|
+
var notice = opts.notice
|
|
865
|
+
? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
|
|
866
|
+
: "";
|
|
867
|
+
var body = _render(REVIEW_FORM_PAGE, {
|
|
868
|
+
title: opts.product.title,
|
|
869
|
+
slug: slug,
|
|
870
|
+
})
|
|
871
|
+
.replace("RAW_NOTICE_PLACEHOLDER", notice)
|
|
872
|
+
.replace("RAW_STARS_PLACEHOLDER", stars);
|
|
873
|
+
return _wrap({
|
|
874
|
+
title: "Review " + opts.product.title,
|
|
875
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
876
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
877
|
+
theme_css: opts.theme_css,
|
|
878
|
+
body: body,
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Generic single-message page for the review flow (purchase-gate
|
|
883
|
+
// refusal, submission thank-you). `cta` is an optional { href, label }.
|
|
884
|
+
function _reviewMessagePage(opts, heading, message, cta) {
|
|
885
|
+
var esc = _b().template.escapeHtml;
|
|
886
|
+
var ctaHtml = cta
|
|
887
|
+
? "<a class=\"btn-primary\" href=\"" + esc(cta.href) + "\">" + esc(cta.label) + "</a>"
|
|
888
|
+
: "";
|
|
889
|
+
var body =
|
|
890
|
+
"<section class=\"review-message\">" +
|
|
891
|
+
"<h1 class=\"review-message__title\">" + esc(heading) + "</h1>" +
|
|
892
|
+
"<p class=\"review-message__lede\">" + esc(message) + "</p>" +
|
|
893
|
+
ctaHtml +
|
|
894
|
+
"</section>";
|
|
895
|
+
return _wrap({
|
|
896
|
+
title: heading,
|
|
897
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
898
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
899
|
+
theme_css: opts.theme_css,
|
|
900
|
+
body: body,
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Remove control for a wishlist entry — a form POST back through the
|
|
905
|
+
// toggle route with `return_to` so the customer lands back on the
|
|
906
|
+
// account page (not the product PDP the default toggle returns to).
|
|
907
|
+
function _wishlistRemoveForm(productId, esc) {
|
|
908
|
+
return "<form class=\"wishlist-item__remove\" method=\"post\" action=\"/wishlist/toggle\">" +
|
|
909
|
+
"<input type=\"hidden\" name=\"product_id\" value=\"" + esc(productId) + "\">" +
|
|
910
|
+
"<input type=\"hidden\" name=\"return_to\" value=\"/account/wishlist\">" +
|
|
911
|
+
"<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Remove</button>" +
|
|
912
|
+
"</form>";
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Account "Saved items" page. `opts.items` is a resolved list:
|
|
916
|
+
// { product, hero_media } for live products, or { product: null,
|
|
917
|
+
// product_id } for entries whose product was archived/deleted (the
|
|
918
|
+
// wishlist row is orphan-tolerant by design — render "unavailable",
|
|
919
|
+
// never crash the listing).
|
|
920
|
+
function renderWishlist(opts) {
|
|
921
|
+
var esc = _b().template.escapeHtml;
|
|
922
|
+
var items = opts.items || [];
|
|
923
|
+
var prefix = opts.asset_prefix || "/assets/";
|
|
924
|
+
var rowsHtml = "";
|
|
925
|
+
for (var i = 0; i < items.length; i += 1) {
|
|
926
|
+
var it = items[i];
|
|
927
|
+
if (!it.product) {
|
|
928
|
+
rowsHtml +=
|
|
929
|
+
"<li class=\"wishlist-item wishlist-item--gone\">" +
|
|
930
|
+
"<span class=\"wishlist-item__title\">This item is no longer available.</span>" +
|
|
931
|
+
_wishlistRemoveForm(it.product_id, esc) +
|
|
932
|
+
"</li>";
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
var slug = esc(it.product.slug);
|
|
936
|
+
var thumb = it.hero_media
|
|
937
|
+
? "<img src=\"" + esc(prefix + it.hero_media.r2_key) + "\" alt=\"" + esc(it.hero_media.alt_text || it.product.title) + "\" loading=\"lazy\">"
|
|
938
|
+
: "<span class=\"wishlist-item__mark\" aria-hidden=\"true\">" + esc((it.product.title || "?").trim().charAt(0).toUpperCase() || "?") + "</span>";
|
|
939
|
+
rowsHtml +=
|
|
940
|
+
"<li class=\"wishlist-item\">" +
|
|
941
|
+
"<a class=\"wishlist-item__media\" href=\"/products/" + slug + "\">" + thumb + "</a>" +
|
|
942
|
+
"<div class=\"wishlist-item__body\">" +
|
|
943
|
+
"<a class=\"wishlist-item__title\" href=\"/products/" + slug + "\">" + esc(it.product.title) + "</a>" +
|
|
944
|
+
"<a class=\"wishlist-item__view card-link\" href=\"/products/" + slug + "\">View product →</a>" +
|
|
945
|
+
"</div>" +
|
|
946
|
+
_wishlistRemoveForm(it.product.id, esc) +
|
|
947
|
+
"</li>";
|
|
948
|
+
}
|
|
949
|
+
var inner = rowsHtml
|
|
950
|
+
? "<ul class=\"wishlist-list\">" + rowsHtml + "</ul>"
|
|
951
|
+
: "<p class=\"wishlist-empty\">You haven't saved anything yet. Browse the shop and tap <strong>Save to wishlist</strong> on products you want to keep an eye on.</p>";
|
|
952
|
+
var body =
|
|
953
|
+
"<section class=\"account-wishlist\">" +
|
|
954
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
955
|
+
"<li><a href=\"/account\">Account</a></li>" +
|
|
956
|
+
"<li aria-current=\"page\">Saved items</li>" +
|
|
957
|
+
"</ol></nav>" +
|
|
958
|
+
"<h1 class=\"account-wishlist__title\">Saved items</h1>" +
|
|
959
|
+
inner +
|
|
960
|
+
"</section>";
|
|
961
|
+
return _wrap({
|
|
962
|
+
title: "Saved items",
|
|
963
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
964
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
965
|
+
theme_css: opts.theme_css,
|
|
966
|
+
body: body,
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// Product-level "Save to wishlist" control + social-proof count.
|
|
971
|
+
// Byte-compatible with the edge renderer (`worker/render/product.js`)
|
|
972
|
+
// so both paths emit identical markup. Action-only label — the toggle
|
|
973
|
+
// route resolves add/remove server-side against the sealed session.
|
|
974
|
+
function _buildWishlist(productId, count) {
|
|
975
|
+
var esc = _b().template.escapeHtml;
|
|
976
|
+
var n = Number(count) || 0;
|
|
977
|
+
var countHtml = n > 0
|
|
978
|
+
? "<span class=\"wishlist__count\">" + n + (n === 1 ? " shopper saved this" : " shoppers saved this") + "</span>"
|
|
979
|
+
: "";
|
|
980
|
+
return "<div class=\"wishlist\">" +
|
|
981
|
+
"<form class=\"wishlist__form\" method=\"post\" action=\"/wishlist/toggle\">" +
|
|
982
|
+
"<input type=\"hidden\" name=\"product_id\" value=\"" + esc(productId) + "\">" +
|
|
983
|
+
"<button type=\"submit\" class=\"btn-secondary wishlist__btn\">" +
|
|
984
|
+
"<span class=\"wishlist__heart\" aria-hidden=\"true\">♡</span> Save to wishlist" +
|
|
985
|
+
"</button>" +
|
|
986
|
+
"</form>" +
|
|
987
|
+
countHtml +
|
|
988
|
+
"</div>";
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Schema.org JSON-LD block. JSON.stringify covers the standard escapes;
|
|
992
|
+
// the `</` → `<\/` rewrite neutralises any literal `</script>` in a
|
|
993
|
+
// value. Mirrors the edge renderer's `jsonLdScript`.
|
|
994
|
+
function _jsonLdScript(data) {
|
|
995
|
+
var serialised = JSON.stringify(data).replace(/<\/(?=script>)/gi, "<\\/");
|
|
996
|
+
return "<script type=\"application/ld+json\">" + serialised + "</script>";
|
|
997
|
+
}
|
|
998
|
+
|
|
723
999
|
function renderProduct(opts) {
|
|
724
1000
|
if (!opts || !opts.product) throw new TypeError("storefront.renderProduct: opts.product required");
|
|
725
1001
|
var variants = opts.variants || [];
|
|
@@ -741,6 +1017,11 @@ function renderProduct(opts) {
|
|
|
741
1017
|
product: { title: opts.product.title, description: description },
|
|
742
1018
|
variants: rendered,
|
|
743
1019
|
has_variants: rendered.length > 0,
|
|
1020
|
+
// Pre-rendered reviews block (internally HTML-escaped) for the
|
|
1021
|
+
// theme's `{{{ reviews_html }}}` raw slot. The bundled themes
|
|
1022
|
+
// include the slot; a custom theme opts in by adding it.
|
|
1023
|
+
reviews_html: _buildReviews(opts.review_summary, opts.reviews, opts.review_cta),
|
|
1024
|
+
wishlist_html: _buildWishlist(opts.product.id, opts.wishlist_count),
|
|
744
1025
|
asset_css_main: opts.theme.assetUrl("css/main.css"),
|
|
745
1026
|
});
|
|
746
1027
|
}
|
|
@@ -749,18 +1030,75 @@ function renderProduct(opts) {
|
|
|
749
1030
|
}).join("");
|
|
750
1031
|
if (!rows) rows = "<tr><td colspan=\"4\" class=\"empty\">No variants available.</td></tr>";
|
|
751
1032
|
var galleryHtml = _buildPdpGallery(opts.product, opts.media || [], opts.asset_prefix || "/assets/");
|
|
1033
|
+
var reviewsHtml = _buildReviews(opts.review_summary, opts.reviews, opts.review_cta);
|
|
1034
|
+
var wishlistHtml = _buildWishlist(opts.product.id, opts.wishlist_count);
|
|
752
1035
|
var body = _render(PRODUCT_PAGE, {
|
|
753
1036
|
title: opts.product.title,
|
|
754
1037
|
description: description,
|
|
755
1038
|
variant_rows: "RAW_ROWS_PLACEHOLDER",
|
|
756
1039
|
})
|
|
757
1040
|
.replace("RAW_GALLERY_PLACEHOLDER", galleryHtml)
|
|
758
|
-
.replace("RAW_ROWS_PLACEHOLDER", rows)
|
|
1041
|
+
.replace("RAW_ROWS_PLACEHOLDER", rows)
|
|
1042
|
+
.replace("RAW_WISHLIST_PLACEHOLDER", wishlistHtml)
|
|
1043
|
+
.replace("RAW_REVIEWS_PLACEHOLDER", reviewsHtml);
|
|
759
1044
|
// Product-specific OpenGraph + Twitter Card values so shares
|
|
760
1045
|
// unfurl as "Operator Tee — blamejs.shop" with the SVG hero, not
|
|
761
1046
|
// the default shop-level description + brand logo.
|
|
762
1047
|
var heroMedia = (opts.media && opts.media[0]) || null;
|
|
763
1048
|
var ogImage = heroMedia ? ((opts.asset_prefix || "/assets/") + heroMedia.r2_key) : "/assets/brand/logo.png";
|
|
1049
|
+
|
|
1050
|
+
// Product + AggregateOffer JSON-LD, with AggregateRating folded in
|
|
1051
|
+
// when published reviews exist. Kept byte-compatible with the edge
|
|
1052
|
+
// renderer so the structured data is identical whichever substrate
|
|
1053
|
+
// serves the PDP. AggregateRating is omitted (not null) at zero
|
|
1054
|
+
// reviews — Google rejects `reviewCount: 0`.
|
|
1055
|
+
var priceList = variants
|
|
1056
|
+
.map(function (v) { return prices[v.id] ? prices[v.id].amount_minor : null; })
|
|
1057
|
+
.filter(function (n) { return Number.isInteger(n); });
|
|
1058
|
+
var jsonLd = null;
|
|
1059
|
+
if (priceList.length > 0) {
|
|
1060
|
+
var lowMinor = Math.min.apply(null, priceList);
|
|
1061
|
+
var hiMinor = Math.max.apply(null, priceList);
|
|
1062
|
+
var currency = (prices[variants[0].id] && prices[variants[0].id].currency) || "USD";
|
|
1063
|
+
var divisor = currency === "JPY" || currency === "KRW" ? 1 : 100;
|
|
1064
|
+
var aggregateRating;
|
|
1065
|
+
if (opts.review_summary && Number(opts.review_summary.count) > 0) {
|
|
1066
|
+
aggregateRating = {
|
|
1067
|
+
"@type": "AggregateRating",
|
|
1068
|
+
"ratingValue": (Number(opts.review_summary.avg_rating) || 0).toFixed(1),
|
|
1069
|
+
"reviewCount": Number(opts.review_summary.count),
|
|
1070
|
+
"bestRating": 5,
|
|
1071
|
+
"worstRating": 1,
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
jsonLd = _jsonLdScript({
|
|
1075
|
+
"@context": "https://schema.org",
|
|
1076
|
+
"@type": "Product",
|
|
1077
|
+
"name": opts.product.title,
|
|
1078
|
+
"description": description || ("Browse " + opts.product.title + " on " + shopName + "."),
|
|
1079
|
+
"image": heroMedia ? [ogImage] : undefined,
|
|
1080
|
+
"sku": variants[0] && variants[0].sku,
|
|
1081
|
+
"aggregateRating": aggregateRating,
|
|
1082
|
+
"offers": {
|
|
1083
|
+
"@type": "AggregateOffer",
|
|
1084
|
+
"priceCurrency": currency,
|
|
1085
|
+
"lowPrice": (lowMinor / divisor).toFixed(divisor === 1 ? 0 : 2),
|
|
1086
|
+
"highPrice": (hiMinor / divisor).toFixed(divisor === 1 ? 0 : 2),
|
|
1087
|
+
"offerCount": variants.length,
|
|
1088
|
+
"availability": "https://schema.org/InStock",
|
|
1089
|
+
},
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
var breadcrumbJsonLd = _jsonLdScript({
|
|
1093
|
+
"@context": "https://schema.org",
|
|
1094
|
+
"@type": "BreadcrumbList",
|
|
1095
|
+
"itemListElement": [
|
|
1096
|
+
{ "@type": "ListItem", "position": 1, "name": "Shop", "item": "/" },
|
|
1097
|
+
{ "@type": "ListItem", "position": 2, "name": opts.product.title, "item": "/products/" + opts.product.slug },
|
|
1098
|
+
],
|
|
1099
|
+
});
|
|
1100
|
+
jsonLd = (jsonLd || "") + breadcrumbJsonLd;
|
|
1101
|
+
|
|
764
1102
|
return _wrap({
|
|
765
1103
|
title: opts.product.title,
|
|
766
1104
|
shop_name: shopName,
|
|
@@ -770,7 +1108,7 @@ function renderProduct(opts) {
|
|
|
770
1108
|
og_title: opts.product.title + " — " + shopName,
|
|
771
1109
|
og_description: description || ("Browse " + opts.product.title + " on " + shopName + "."),
|
|
772
1110
|
og_image: ogImage,
|
|
773
|
-
body: body,
|
|
1111
|
+
body: body + jsonLd,
|
|
774
1112
|
});
|
|
775
1113
|
}
|
|
776
1114
|
|
|
@@ -1548,7 +1886,10 @@ var ACCOUNT_DASH_PAGE =
|
|
|
1548
1886
|
" <h1 class=\"section-head__title\">Hi, {{display_name}}</h1>\n" +
|
|
1549
1887
|
" <p class=\"section-head__lede\">Your orders + account controls. Every order ships from origin with a Stripe-secured receipt.</p>\n" +
|
|
1550
1888
|
" </div>\n" +
|
|
1551
|
-
" <
|
|
1889
|
+
" <div class=\"account-dash__actions\">\n" +
|
|
1890
|
+
" <a class=\"btn-secondary\" href=\"/account/wishlist\">Saved items</a>\n" +
|
|
1891
|
+
" <form method=\"post\" action=\"/account/logout\"><button type=\"submit\" class=\"btn-ghost\">Sign out</button></form>\n" +
|
|
1892
|
+
" </div>\n" +
|
|
1552
1893
|
" </header>\n" +
|
|
1553
1894
|
" <dl class=\"account-dash__stats\">\n" +
|
|
1554
1895
|
" <div><dt>Orders</dt><dd>{{order_count}}</dd></div>\n" +
|
|
@@ -1797,14 +2138,42 @@ function mount(router, deps) {
|
|
|
1797
2138
|
cartCount = lines.length;
|
|
1798
2139
|
}
|
|
1799
2140
|
}
|
|
2141
|
+
// Published reviews aggregate + list. A failed read (e.g. the
|
|
2142
|
+
// reviews table not yet migrated) degrades to the empty state
|
|
2143
|
+
// rather than 500-ing the whole PDP — reviews are supplementary
|
|
2144
|
+
// to the buy path. Mirrors the edge renderer's missing-table
|
|
2145
|
+
// resilience.
|
|
2146
|
+
var reviewSummary, reviewRows, reviewCta;
|
|
2147
|
+
if (deps.reviews) {
|
|
2148
|
+
try {
|
|
2149
|
+
reviewSummary = await deps.reviews.summaryForProduct(product.id);
|
|
2150
|
+
reviewRows = (await deps.reviews.listForProduct(product.id, { limit: 10 })).rows;
|
|
2151
|
+
} catch (_e) { reviewSummary = undefined; reviewRows = []; }
|
|
2152
|
+
// The form route enforces auth + the verified-purchase gate, so
|
|
2153
|
+
// the CTA links there unconditionally; logged-out shoppers get
|
|
2154
|
+
// redirected to login, non-purchasers get a clear "not eligible".
|
|
2155
|
+
reviewCta = "<a class=\"btn-secondary reviews__cta\" href=\"/products/" +
|
|
2156
|
+
_b().template.escapeHtml(product.slug) + "/review\">Write a review</a>";
|
|
2157
|
+
}
|
|
2158
|
+
// Wishlist social-proof count — degrades to 0 on a read failure
|
|
2159
|
+
// (e.g. table not yet migrated) rather than 500-ing the PDP.
|
|
2160
|
+
var wishlistCount = 0;
|
|
2161
|
+
if (deps.wishlist) {
|
|
2162
|
+
try { wishlistCount = await deps.wishlist.countForProduct(product.id); }
|
|
2163
|
+
catch (_e) { wishlistCount = 0; }
|
|
2164
|
+
}
|
|
1800
2165
|
var html = renderProduct({
|
|
1801
|
-
product:
|
|
1802
|
-
variants:
|
|
1803
|
-
prices:
|
|
1804
|
-
media:
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
2166
|
+
product: product,
|
|
2167
|
+
variants: variants,
|
|
2168
|
+
prices: prices,
|
|
2169
|
+
media: media,
|
|
2170
|
+
review_summary: reviewSummary,
|
|
2171
|
+
reviews: reviewRows,
|
|
2172
|
+
review_cta: reviewCta,
|
|
2173
|
+
wishlist_count: wishlistCount,
|
|
2174
|
+
shop_name: shopName,
|
|
2175
|
+
cart_count: cartCount,
|
|
2176
|
+
theme: theme,
|
|
1808
2177
|
});
|
|
1809
2178
|
_send(res, 200, html);
|
|
1810
2179
|
});
|
|
@@ -2339,6 +2708,165 @@ function mount(router, deps) {
|
|
|
2339
2708
|
res.status(303); res.setHeader && res.setHeader("location", "/");
|
|
2340
2709
|
return res.end ? res.end() : res.send("");
|
|
2341
2710
|
});
|
|
2711
|
+
|
|
2712
|
+
// Wishlist — saved products scoped to the logged-in customer.
|
|
2713
|
+
// Mounts when the wishlist primitive is wired.
|
|
2714
|
+
if (deps.wishlist) {
|
|
2715
|
+
// POST /wishlist/toggle — add the product if not saved, remove it
|
|
2716
|
+
// if already saved. Login required (the wishlist is per-customer).
|
|
2717
|
+
// Redirects to `return_to` when it's a safe same-origin path
|
|
2718
|
+
// (the account page's Remove uses it), otherwise back to the
|
|
2719
|
+
// product PDP (the canonical slug is resolved from product_id, so
|
|
2720
|
+
// a forged slug can't drive an open redirect).
|
|
2721
|
+
router.post("/wishlist/toggle", async function (req, res) {
|
|
2722
|
+
var auth;
|
|
2723
|
+
try { auth = _currentCustomer(req); }
|
|
2724
|
+
catch (e) {
|
|
2725
|
+
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
2726
|
+
throw e;
|
|
2727
|
+
}
|
|
2728
|
+
if (!auth) {
|
|
2729
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
2730
|
+
return res.end ? res.end() : res.send("");
|
|
2731
|
+
}
|
|
2732
|
+
var productId = (req.body || {}).product_id;
|
|
2733
|
+
try {
|
|
2734
|
+
var already = await deps.wishlist.isWishlisted({ customer_id: auth.customer_id, product_id: productId });
|
|
2735
|
+
if (already) await deps.wishlist.remove({ customer_id: auth.customer_id, product_id: productId });
|
|
2736
|
+
else await deps.wishlist.add({ customer_id: auth.customer_id, product_id: productId });
|
|
2737
|
+
} catch (e) {
|
|
2738
|
+
res.status(e instanceof TypeError ? 400 : 500);
|
|
2739
|
+
return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
|
|
2740
|
+
}
|
|
2741
|
+
var rt = (req.body || {}).return_to;
|
|
2742
|
+
var dest;
|
|
2743
|
+
if (typeof rt === "string" && /^\/[^/]/.test(rt)) {
|
|
2744
|
+
dest = rt;
|
|
2745
|
+
} else {
|
|
2746
|
+
var product = null;
|
|
2747
|
+
try { product = await deps.catalog.products.get(productId); } catch (_e) { product = null; }
|
|
2748
|
+
dest = product ? ("/products/" + encodeURIComponent(product.slug)) : "/account/wishlist";
|
|
2749
|
+
}
|
|
2750
|
+
res.status(303); res.setHeader && res.setHeader("location", dest);
|
|
2751
|
+
return res.end ? res.end() : res.send("");
|
|
2752
|
+
});
|
|
2753
|
+
|
|
2754
|
+
// GET /account/wishlist — the customer's saved items. Each entry
|
|
2755
|
+
// resolves its product + hero image; an entry whose product was
|
|
2756
|
+
// archived renders as "unavailable" (the row is orphan-tolerant).
|
|
2757
|
+
router.get("/account/wishlist", async function (req, res) {
|
|
2758
|
+
var auth;
|
|
2759
|
+
try { auth = _currentCustomer(req); }
|
|
2760
|
+
catch (e) {
|
|
2761
|
+
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
2762
|
+
throw e;
|
|
2763
|
+
}
|
|
2764
|
+
if (!auth) {
|
|
2765
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
2766
|
+
return res.end ? res.end() : res.send("");
|
|
2767
|
+
}
|
|
2768
|
+
var page = await deps.wishlist.listForCustomer(auth.customer_id, { limit: 50 });
|
|
2769
|
+
var items = [];
|
|
2770
|
+
for (var i = 0; i < page.rows.length; i += 1) {
|
|
2771
|
+
var entry = page.rows[i];
|
|
2772
|
+
var product = null;
|
|
2773
|
+
try { product = await deps.catalog.products.get(entry.product_id); } catch (_e) { product = null; }
|
|
2774
|
+
if (!product) { items.push({ product: null, product_id: entry.product_id }); continue; }
|
|
2775
|
+
var media = await deps.catalog.media.listForProduct(product.id);
|
|
2776
|
+
items.push({ product: product, hero_media: media.length ? media[0] : null });
|
|
2777
|
+
}
|
|
2778
|
+
var cartCount = await _cartCountForReq(req);
|
|
2779
|
+
_send(res, 200, renderWishlist({
|
|
2780
|
+
items: items,
|
|
2781
|
+
shop_name: shopName,
|
|
2782
|
+
cart_count: cartCount,
|
|
2783
|
+
asset_prefix: deps.asset_prefix || "/assets/",
|
|
2784
|
+
}));
|
|
2785
|
+
});
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2788
|
+
// Product reviews — submission requires a logged-in customer AND a
|
|
2789
|
+
// verified purchase of the product (the gate, not just a badge).
|
|
2790
|
+
// Only mounts when both the reviews primitive and an order handle
|
|
2791
|
+
// (for the purchase check) are wired.
|
|
2792
|
+
if (deps.reviews && deps.order) {
|
|
2793
|
+
async function _reviewGateContext(req, res) {
|
|
2794
|
+
var slug = req.params && req.params.slug;
|
|
2795
|
+
var product = slug ? await deps.catalog.products.bySlug(slug) : null;
|
|
2796
|
+
if (!product) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
|
|
2797
|
+
var auth;
|
|
2798
|
+
try { auth = _currentCustomer(req); }
|
|
2799
|
+
catch (e) {
|
|
2800
|
+
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
2801
|
+
throw e;
|
|
2802
|
+
}
|
|
2803
|
+
if (!auth) {
|
|
2804
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
2805
|
+
res.end ? res.end() : res.send("");
|
|
2806
|
+
return null;
|
|
2807
|
+
}
|
|
2808
|
+
var cartCount = await _cartCountForReq(req);
|
|
2809
|
+
var purchased = await deps.order.hasPurchasedProduct(auth.customer_id, product.id);
|
|
2810
|
+
return { product: product, auth: auth, cartCount: cartCount, purchased: purchased };
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
function _reviewIneligible(res, ctx, code) {
|
|
2814
|
+
return _send(res, code, _reviewMessagePage(
|
|
2815
|
+
{ shop_name: shopName, cart_count: ctx.cartCount },
|
|
2816
|
+
"Only verified buyers can review",
|
|
2817
|
+
"We can only accept a review for a product you've purchased. Make sure you're signed in with the account you ordered with.",
|
|
2818
|
+
{ href: "/products/" + ctx.product.slug, label: "Back to product" },
|
|
2819
|
+
));
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
router.get("/products/:slug/review", async function (req, res) {
|
|
2823
|
+
var ctx = await _reviewGateContext(req, res);
|
|
2824
|
+
if (!ctx) return;
|
|
2825
|
+
if (!ctx.purchased) return _reviewIneligible(res, ctx, 200);
|
|
2826
|
+
_send(res, 200, renderReviewForm({
|
|
2827
|
+
product: { title: ctx.product.title, slug: ctx.product.slug },
|
|
2828
|
+
shop_name: shopName,
|
|
2829
|
+
cart_count: ctx.cartCount,
|
|
2830
|
+
}));
|
|
2831
|
+
});
|
|
2832
|
+
|
|
2833
|
+
router.post("/products/:slug/review", async function (req, res) {
|
|
2834
|
+
var ctx = await _reviewGateContext(req, res);
|
|
2835
|
+
if (!ctx) return;
|
|
2836
|
+
// Re-check the gate on write — a client can POST directly
|
|
2837
|
+
// without ever fetching the form.
|
|
2838
|
+
if (!ctx.purchased) return _reviewIneligible(res, ctx, 403);
|
|
2839
|
+
var body = req.body || {};
|
|
2840
|
+
try {
|
|
2841
|
+
await deps.reviews.submit({
|
|
2842
|
+
product_id: ctx.product.id,
|
|
2843
|
+
customer_id: ctx.auth.customer_id,
|
|
2844
|
+
rating: parseInt(body.rating, 10),
|
|
2845
|
+
title: body.title,
|
|
2846
|
+
body: body.body,
|
|
2847
|
+
verified_purchase: 1,
|
|
2848
|
+
});
|
|
2849
|
+
} catch (e) {
|
|
2850
|
+
// Shape rejections bounce back to the form with the reason;
|
|
2851
|
+
// anything else is a real 500.
|
|
2852
|
+
if (e instanceof TypeError) {
|
|
2853
|
+
return _send(res, 400, renderReviewForm({
|
|
2854
|
+
product: { title: ctx.product.title, slug: ctx.product.slug },
|
|
2855
|
+
notice: (e && e.message) || "Please check your review and try again.",
|
|
2856
|
+
shop_name: shopName,
|
|
2857
|
+
cart_count: ctx.cartCount,
|
|
2858
|
+
}));
|
|
2859
|
+
}
|
|
2860
|
+
throw e;
|
|
2861
|
+
}
|
|
2862
|
+
_send(res, 200, _reviewMessagePage(
|
|
2863
|
+
{ shop_name: shopName, cart_count: ctx.cartCount },
|
|
2864
|
+
"Thanks for your review",
|
|
2865
|
+
"Your review has been submitted and is pending moderation. It will appear on the product page once an operator approves it.",
|
|
2866
|
+
{ href: "/products/" + ctx.product.slug, label: "Back to product" },
|
|
2867
|
+
));
|
|
2868
|
+
});
|
|
2869
|
+
}
|
|
2342
2870
|
}
|
|
2343
2871
|
|
|
2344
2872
|
// 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.29",
|
|
7
|
+
"tag": "v0.12.29",
|
|
8
8
|
"license": "Apache-2.0",
|
|
9
9
|
"author": "blamejs contributors",
|
|
10
10
|
"source": "https://github.com/blamejs/blamejs",
|