@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +5 -1
  3. package/SECURITY.md +11 -2
  4. package/lib/admin.js +51 -0
  5. package/lib/order.js +23 -0
  6. package/lib/reviews.js +29 -0
  7. package/lib/storefront.js +538 -10
  8. package/lib/vendor/MANIFEST.json +2 -2
  9. package/lib/vendor/blamejs/CHANGELOG.md +16 -0
  10. package/lib/vendor/blamejs/README.md +3 -0
  11. package/lib/vendor/blamejs/SECURITY.md +2 -0
  12. package/lib/vendor/blamejs/api-snapshot.json +220 -2
  13. package/lib/vendor/blamejs/index.js +3 -0
  14. package/lib/vendor/blamejs/lib/ai-capability.js +482 -0
  15. package/lib/vendor/blamejs/lib/ai-disclosure.js +107 -0
  16. package/lib/vendor/blamejs/lib/ai-dp.js +539 -0
  17. package/lib/vendor/blamejs/lib/ai-quota.js +526 -0
  18. package/lib/vendor/blamejs/lib/backup/index.js +210 -1
  19. package/lib/vendor/blamejs/lib/compliance.js +48 -1
  20. package/lib/vendor/blamejs/lib/crypto.js +9 -2
  21. package/lib/vendor/blamejs/package.json +1 -1
  22. package/lib/vendor/blamejs/release-notes/v0.12.22.json +18 -0
  23. package/lib/vendor/blamejs/release-notes/v0.12.23.json +18 -0
  24. package/lib/vendor/blamejs/release-notes/v0.12.24.json +18 -0
  25. package/lib/vendor/blamejs/release-notes/v0.12.25.json +18 -0
  26. package/lib/vendor/blamejs/release-notes/v0.12.26.json +30 -0
  27. package/lib/vendor/blamejs/release-notes/v0.12.27.json +26 -0
  28. package/lib/vendor/blamejs/release-notes/v0.12.28.json +26 -0
  29. package/lib/vendor/blamejs/release-notes/v0.12.29.json +31 -0
  30. package/lib/vendor/blamejs/test/layer-0-primitives/ai-capability.test.js +228 -0
  31. package/lib/vendor/blamejs/test/layer-0-primitives/ai-disclosure-apply-all.test.js +126 -0
  32. package/lib/vendor/blamejs/test/layer-0-primitives/ai-dp.test.js +167 -0
  33. package/lib/vendor/blamejs/test/layer-0-primitives/ai-quota.test.js +264 -0
  34. package/lib/vendor/blamejs/test/layer-0-primitives/backup-clone-bundle.test.js +178 -0
  35. package/lib/vendor/blamejs/test/layer-0-primitives/backup-find-bundles.test.js +104 -0
  36. package/lib/vendor/blamejs/test/layer-0-primitives/backup-rewrap-all.test.js +168 -0
  37. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +60 -0
  38. package/lib/vendor/blamejs/test/layer-0-primitives/compliance-eu-ai-act-posture.test.js +93 -0
  39. package/lib/vendor/blamejs/test/layer-0-primitives/crypto-random-int.test.js +21 -0
  40. 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
- " <form method=\"post\" action=\"/account/logout\"><button type=\"submit\" class=\"btn-ghost\">Sign out</button></form>\n" +
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: product,
1802
- variants: variants,
1803
- prices: prices,
1804
- media: media,
1805
- shop_name: shopName,
1806
- cart_count: cartCount,
1807
- theme: theme,
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
@@ -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.21",
7
- "tag": "v0.12.21",
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",