@brainerce/mcp-server 3.5.1 → 3.7.0

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/dist/bin/http.js CHANGED
@@ -924,6 +924,175 @@ function ProductPage({ product, storeInfo }: { product: Product; storeInfo: Stor
924
924
  }
925
925
  \`\`\`
926
926
 
927
+ ### Variant Attribute Selector with Swatches (color + size **mix**)
928
+
929
+ The simple text-button picker above works, but loses the merchant-configured
930
+ **swatch metadata**. A single product often mixes one attribute that should
931
+ render as **color swatches** (e.g. \`Color\`) and another that should render
932
+ as **text buttons** (e.g. \`Size\`). The product response includes everything
933
+ you need:
934
+
935
+ - \`product.productAttributeOptions[]\` \u2014 the swatch definitions: \`attribute.name\`,
936
+ \`attribute.displayType\` (the \`AttributeDisplayType\` enum: \`DEFAULT\` | \`COLOR_SWATCH\` | \`IMAGE_SWATCH\` | \`MIXED_SWATCH\`),
937
+ \`attributeOption.name\`, \`swatchColor\` (hex), \`swatchColor2\` (hex \u2014 used for dual-color swatches
938
+ like Black & White), \`swatchImageUrl\` (for fabric / pattern thumbnails).
939
+ \`MIXED_SWATCH\` means each option in the group can independently pick image OR color
940
+ (the merchant decides per option). Treat any unrecognized value as \`DEFAULT\`.
941
+ - \`variant.attributes\` \u2014 the selected value per attribute on each variant.
942
+
943
+ Use \`getProductSwatches(product)\` to get a render-ready, grouped structure:
944
+
945
+ \`\`\`typescript
946
+ import { getProductSwatches } from 'brainerce';
947
+
948
+ const groups = getProductSwatches(product);
949
+ // [
950
+ // { attributeName: 'color', displayType: 'COLOR_SWATCH', options: [
951
+ // { name: 'Full Color', swatchColor: '#1D9435', swatchColor2: null, swatchImageUrl: null },
952
+ // { name: 'Black & White', swatchColor: '#000000', swatchColor2: '#FFFFFF', swatchImageUrl: null },
953
+ // ]},
954
+ // { attributeName: 'size', displayType: 'DEFAULT', options: [
955
+ // { name: '16" \xD7 20"' }, { name: '24" \xD7 32"' }, { name: '36" \xD7 48"' },
956
+ // ]},
957
+ // ]
958
+ \`\`\`
959
+
960
+ Render one branch per \`displayType\`. The same loop handles all-swatch, all-text,
961
+ and the mix case:
962
+
963
+ \`\`\`typescript
964
+ function VariantPicker({
965
+ product,
966
+ selections,
967
+ onSelect,
968
+ }: {
969
+ product: Product;
970
+ selections: Record<string, string>; // { color: 'Full Color', size: '24" \xD7 32"' }
971
+ onSelect: (attrName: string, value: string) => void;
972
+ }) {
973
+ const groups = getProductSwatches(product);
974
+ if (groups.length === 0) return null;
975
+
976
+ return (
977
+ <div className="space-y-4">
978
+ {groups.map((group) => (
979
+ <div key={group.attributeName}>
980
+ <label className="block mb-2 text-sm font-medium">
981
+ {group.attributeName}: <span className="text-gray-500">{selections[group.attributeName] ?? ''}</span>
982
+ </label>
983
+ <div className="flex flex-wrap gap-2">
984
+ {group.options.map((opt) => {
985
+ const isSelected = selections[group.attributeName] === opt.name;
986
+
987
+ // COLOR_SWATCH \u2014 round button; dual-color uses a 50/50 gradient.
988
+ if (group.displayType === 'COLOR_SWATCH' && opt.swatchColor) {
989
+ const bg = opt.swatchColor2
990
+ ? \`linear-gradient(135deg, \${opt.swatchColor} 50%, \${opt.swatchColor2} 50%)\`
991
+ : opt.swatchColor;
992
+ return (
993
+ <button
994
+ key={opt.name}
995
+ type="button"
996
+ title={opt.name}
997
+ aria-pressed={isSelected}
998
+ onClick={() => onSelect(group.attributeName, opt.name)}
999
+ className={\`h-9 w-9 rounded-full border-2 \${isSelected ? 'border-black ring-2 ring-black/30' : 'border-gray-300 hover:border-black'}\`}
1000
+ style={{ background: bg }}
1001
+ />
1002
+ );
1003
+ }
1004
+
1005
+ // IMAGE_SWATCH \u2014 square thumbnail (fabric/pattern preview).
1006
+ if (group.displayType === 'IMAGE_SWATCH' && opt.swatchImageUrl) {
1007
+ return (
1008
+ <button
1009
+ key={opt.name}
1010
+ type="button"
1011
+ aria-pressed={isSelected}
1012
+ onClick={() => onSelect(group.attributeName, opt.name)}
1013
+ className={\`h-10 w-10 overflow-hidden rounded border-2 \${isSelected ? 'border-black' : 'border-gray-300'}\`}
1014
+ >
1015
+ <img src={opt.swatchImageUrl} alt={opt.name} className="h-full w-full object-cover" />
1016
+ </button>
1017
+ );
1018
+ }
1019
+
1020
+ // MIXED_SWATCH \u2014 each option is independently image OR color (image wins if present).
1021
+ if (group.displayType === 'MIXED_SWATCH') {
1022
+ if (opt.swatchImageUrl) {
1023
+ return (
1024
+ <button
1025
+ key={opt.name}
1026
+ type="button"
1027
+ aria-pressed={isSelected}
1028
+ onClick={() => onSelect(group.attributeName, opt.name)}
1029
+ className={\`h-10 w-10 overflow-hidden rounded border-2 \${isSelected ? 'border-black' : 'border-gray-300'}\`}
1030
+ >
1031
+ <img src={opt.swatchImageUrl} alt={opt.name} className="h-full w-full object-cover" />
1032
+ </button>
1033
+ );
1034
+ }
1035
+ if (opt.swatchColor) {
1036
+ const bg = opt.swatchColor2
1037
+ ? \`linear-gradient(135deg, \${opt.swatchColor} 50%, \${opt.swatchColor2} 50%)\`
1038
+ : opt.swatchColor;
1039
+ return (
1040
+ <button
1041
+ key={opt.name}
1042
+ type="button"
1043
+ title={opt.name}
1044
+ aria-pressed={isSelected}
1045
+ onClick={() => onSelect(group.attributeName, opt.name)}
1046
+ className={\`h-9 w-9 rounded-full border-2 \${isSelected ? 'border-black ring-2 ring-black/30' : 'border-gray-300 hover:border-black'}\`}
1047
+ style={{ background: bg }}
1048
+ />
1049
+ );
1050
+ }
1051
+ // option has neither image nor color \u2192 fall through to text button below.
1052
+ }
1053
+
1054
+ // DEFAULT (or any swatch type missing its data) \u2014 plain text button.
1055
+ return (
1056
+ <button
1057
+ key={opt.name}
1058
+ type="button"
1059
+ aria-pressed={isSelected}
1060
+ onClick={() => onSelect(group.attributeName, opt.name)}
1061
+ className={\`rounded border px-3 py-1.5 text-sm \${isSelected ? 'bg-black text-white border-black' : 'border-gray-300 hover:border-black'}\`}
1062
+ >
1063
+ {opt.name}
1064
+ </button>
1065
+ );
1066
+ })}
1067
+ </div>
1068
+ </div>
1069
+ ))}
1070
+ </div>
1071
+ );
1072
+ }
1073
+ \`\`\`
1074
+
1075
+ Wire it up in the product page: keep \`selections\` in state, find the matching
1076
+ variant after each pick, and update price / image / stock from it.
1077
+
1078
+ \`\`\`typescript
1079
+ const [selections, setSelections] = useState<Record<string, string>>({});
1080
+
1081
+ const matchedVariant = useMemo(() => (
1082
+ product.variants?.find((v) =>
1083
+ Object.entries(selections).every(([k, val]) => v.attributes?.[k] === val)
1084
+ ) ?? null
1085
+ ), [product.variants, selections]);
1086
+
1087
+ // Greying-out unavailable combinations: for each candidate value, run
1088
+ // the same find() and check \`inventory.canPurchase\`. Disable the button
1089
+ // if no purchasable variant exists under that selection.
1090
+ \`\`\`
1091
+
1092
+ **i18n:** \`getProductSwatches\` returns translated \`attributeName\` and
1093
+ \`option.name\` automatically when \`client.setLocale()\` is active. No
1094
+ client-side overlay needed. Swatch colors are language-agnostic.
1095
+
927
1096
  ### Description Rendering (handles HTML vs text)
928
1097
 
929
1098
  \`\`\`typescript
@@ -1555,6 +1724,65 @@ The merchant can hide a group entirely for one variant by setting \`maxOverride:
1555
1724
 
1556
1725
  Allergens (informational chips), scheduled availability windows, nested combos (depth \u2264 3 via \`nestedByModifierId\`), and downsell modifiers (negative \`priceDelta\`) are all built on the same data model. See INTEGRATION-OPTIONAL.md "Restaurant / build-your-own products" for those flows.`;
1557
1726
  }
1727
+ function getProductReviewsSection() {
1728
+ return `## Product Reviews (\u2605 ratings + JSON-LD for SEO)
1729
+
1730
+ **Purchaser-only.** Only authenticated customers who purchased the product can submit. Each customer writes one review per product and can edit/delete it. Reviews publish immediately \u2014 no PENDING queue.
1731
+
1732
+ \`\`\`typescript
1733
+ // PDP \u2014 list visible reviews (public)
1734
+ const { data } = await getClient().listProductReviews(productId, { page: 1, limit: 20 });
1735
+
1736
+ // Decide which UI to render for the current customer
1737
+ getClient().setCustomerToken(authToken); // after login
1738
+ const { eligible, reason, myReview } = await getClient().getMyProductReview(productId);
1739
+
1740
+ if (!eligible) {
1741
+ // reason: 'no_eligible_order' | 'reviews_disabled' | 'product_not_found'
1742
+ // \u2192 "only customers who purchased this product can leave a review"
1743
+ } else if (myReview) {
1744
+ // \u2192 edit form prefilled with myReview.rating + myReview.body
1745
+ } else {
1746
+ // \u2192 submit form (rating + body only \u2014 name/email come from customer profile)
1747
+ }
1748
+
1749
+ await getClient().submitProductReview(productId, { rating: 5, body: 'Loved it!' });
1750
+ await getClient().updateMyProductReview(productId, { rating: 4, body: 'Updated.' });
1751
+ await getClient().deleteMyProductReview(productId); // can submit a new one after
1752
+ \`\`\`
1753
+
1754
+ Each \`Product\` carries denormalized rollups: \`product.avgRating\`, \`product.reviewCount\`. Use these on PLP cards.
1755
+
1756
+ ### JSON-LD (mandatory for SEO stars in Google)
1757
+
1758
+ Emit \`aggregateRating\` ONLY when \`product.reviewCount > 0\`:
1759
+
1760
+ \`\`\`typescript
1761
+ const productJsonLd = {
1762
+ '@context': 'https://schema.org',
1763
+ '@type': 'Product',
1764
+ name: product.name,
1765
+ // \u2026existing fields
1766
+ ...(product.reviewCount > 0 && {
1767
+ aggregateRating: {
1768
+ '@type': 'AggregateRating',
1769
+ ratingValue: product.avgRating,
1770
+ reviewCount: product.reviewCount,
1771
+ bestRating: 5,
1772
+ worstRating: 1,
1773
+ },
1774
+ }),
1775
+ };
1776
+ \`\`\`
1777
+
1778
+ ### Rules
1779
+ - Rating must be an integer 1-5 (DB constraint).
1780
+ - Customer must be logged in AND have purchased the product (\`SHIPPED+\` for physical, \`PAID+\` for downloadable).
1781
+ - One review per (productId, customerId). Duplicate submit \u2192 409 \u2014 use updateMyProductReview instead.
1782
+ - Submit throttled 3 / 60s / IP; edit/delete 5 / 60s / IP.
1783
+ - \`verifiedPurchase\` is always true for customer-submitted reviews.
1784
+ - Stores with \`reviewsEnabled = false\` return 403 on storefront endpoints.`;
1785
+ }
1558
1786
  function getInventorySection() {
1559
1787
  return `## Inventory, Stock Display & Reservation Countdown
1560
1788
 
@@ -2501,6 +2729,8 @@ function getSectionByTopic(topic, connectionId, currency) {
2501
2729
  return getDiscountsSection();
2502
2730
  case "recommendations":
2503
2731
  return getRecommendationsSection();
2732
+ case "reviews":
2733
+ return getProductReviewsSection();
2504
2734
  case "product-customization-fields":
2505
2735
  return getProductCustomizationFieldsSection();
2506
2736
  case "modifier-groups":
@@ -2573,6 +2803,10 @@ function getSectionByTopic(topic, connectionId, currency) {
2573
2803
  "",
2574
2804
  "---",
2575
2805
  "",
2806
+ getProductReviewsSection(),
2807
+ "",
2808
+ "---",
2809
+ "",
2576
2810
  getDiscountsSection(),
2577
2811
  "",
2578
2812
  "---",
@@ -2624,6 +2858,7 @@ var GET_SDK_DOCS_SCHEMA = {
2624
2858
  "inventory",
2625
2859
  "discounts",
2626
2860
  "recommendations",
2861
+ "reviews",
2627
2862
  "product-customization-fields",
2628
2863
  "tax",
2629
2864
  "critical-rules",
@@ -2641,9 +2876,7 @@ var GET_SDK_DOCS_SCHEMA = {
2641
2876
  async function handleGetSdkDocs(args) {
2642
2877
  const id = args.salesChannelId ?? args.connectionId;
2643
2878
  if (!args.salesChannelId && args.connectionId) {
2644
- console.warn(
2645
- "get-sdk-docs: `connectionId` is deprecated \u2014 use `salesChannelId` instead"
2646
- );
2879
+ console.warn("get-sdk-docs: `connectionId` is deprecated \u2014 use `salesChannelId` instead");
2647
2880
  }
2648
2881
  const content = getSectionByTopic(args.topic, id, args.currency);
2649
2882
  return {
@@ -3355,6 +3588,42 @@ interface CartBundleOffer {
3355
3588
  totalOriginalPrice: string;
3356
3589
  totalDiscountedPrice: string;
3357
3590
  }`;
3591
+ var REVIEWS_TYPES = `// ---- Product Reviews ----
3592
+
3593
+ interface ProductReview {
3594
+ id: string;
3595
+ productId: string;
3596
+ authorName: string; // max 100 chars
3597
+ rating: number; // integer 1-5
3598
+ body: string | null; // plain text, max 5000 chars
3599
+ verifiedPurchase: boolean; // derived server-side from DELIVERED orders
3600
+ hiddenAt?: string | null; // ISO datetime; admin responses only \u2014 null = visible
3601
+ createdAt: string; // ISO datetime
3602
+ }
3603
+
3604
+ interface ProductReviewAdmin extends ProductReview {
3605
+ customerId: string | null;
3606
+ authorEmail: string | null;
3607
+ orderId: string | null;
3608
+ updatedAt: string;
3609
+ }
3610
+
3611
+ interface WriteProductReviewInput {
3612
+ rating: number; // 1-5
3613
+ body?: string; // optional, max 5000 chars
3614
+ }
3615
+
3616
+ interface MyProductReview {
3617
+ eligible: boolean;
3618
+ // Machine-readable reason when not eligible. null when eligible=true.
3619
+ reason: 'no_eligible_order' | 'reviews_disabled' | 'product_not_found' | null;
3620
+ myReview: ProductReview | null;
3621
+ }
3622
+
3623
+ // Each Product carries denormalized review stats:
3624
+ // product.avgRating: number // 0 when reviewCount is 0
3625
+ // product.reviewCount: number // count of visible (hiddenAt IS NULL) reviews
3626
+ `;
3358
3627
  var INQUIRIES_TYPES = `// ---- Contact Inquiries & Forms ----
3359
3628
 
3360
3629
  // Two shapes \u2014 legacy and flexible. The two may be mixed; \`fields\` wins on key collision.
@@ -3598,6 +3867,7 @@ var TYPES_BY_DOMAIN = {
3598
3867
  payments: PAYMENTS_TYPES,
3599
3868
  helpers: HELPERS_TYPES,
3600
3869
  inquiries: INQUIRIES_TYPES,
3870
+ reviews: REVIEWS_TYPES,
3601
3871
  "modifier-groups": MODIFIER_GROUPS_TYPES
3602
3872
  };
3603
3873
  function getTypesByDomain(domain) {
@@ -3628,6 +3898,7 @@ var GET_TYPE_DEFINITIONS_SCHEMA = {
3628
3898
  "payments",
3629
3899
  "helpers",
3630
3900
  "inquiries",
3901
+ "reviews",
3631
3902
  "all"
3632
3903
  ]).describe(
3633
3904
  'The domain of types to retrieve. Use "helpers" for helper function signatures and common types like StoreInfo, PaginatedResponse.'
@@ -3649,6 +3920,7 @@ var GET_CODE_EXAMPLE_SCHEMA = {
3649
3920
  "sdk-init",
3650
3921
  "cart-read-and-mutate",
3651
3922
  "variant-selection",
3923
+ "variant-selector-swatches",
3652
3924
  "checkout-address-and-shipping",
3653
3925
  "checkout-payment-providers",
3654
3926
  "checkout-stripe-confirm",
@@ -3718,28 +3990,148 @@ for (const item of cart.items) {
3718
3990
  const unitPrice = parseFloat(item.unitPrice); // prices are strings!
3719
3991
  }`,
3720
3992
  "variant-selection": `// Variant selection with live stock + price updates.
3993
+ // Variants expose attributes as a flat object: { color: 'Red', size: 'M' }.
3994
+ // NOT 'options' \u2014 that field does not exist on ProductVariant.
3721
3995
  import { getVariantPrice, getStockStatus, getVariantOptions } from 'brainerce';
3722
3996
  import type { Product, ProductVariant } from 'brainerce';
3723
3997
 
3724
3998
  function selectVariant(product: Product, selections: Record<string, string>) {
3725
- // selections = { Size: 'M', Color: 'Red' }
3726
- const variant = product.variants.find((v) =>
3727
- v.options.every((o) => selections[o.name] === o.value)
3999
+ // selections = { size: 'M', color: 'Red' } (keys match variant.attributes keys)
4000
+ const variant = product.variants?.find((v) =>
4001
+ Object.entries(selections).every(([k, val]) => v.attributes?.[k] === val)
3728
4002
  );
3729
4003
  if (!variant) return null;
3730
4004
 
3731
- const price = getVariantPrice(product, variant); // handles compare-at, currency
3732
- const stock = getStockStatus(variant); // 'IN_STOCK' | 'LOW_STOCK' | 'OUT_OF_STOCK'
3733
- const canPurchase = stock !== 'OUT_OF_STOCK';
4005
+ const price = getVariantPrice(variant, product.basePrice); // string, fallback-aware
4006
+ const stock = getStockStatus(variant.inventory); // 'in-stock' | 'low-stock' | 'out-of-stock' | 'unavailable'
4007
+ const canPurchase = variant.inventory?.canPurchase !== false;
3734
4008
 
3735
4009
  // Switch the main product image to the variant's image if it has one
3736
- const image = variant.image ?? product.images[0];
4010
+ const image = variant.image ?? product.images?.[0];
3737
4011
 
3738
4012
  return { variant, price, stock, canPurchase, image };
3739
4013
  }
3740
4014
 
3741
- // Build the option picker from getVariantOptions(product) \u2014 returns
3742
- // [{ name: 'Size', values: ['S','M','L'] }, ...]`,
4015
+ // Build the option picker by iterating product.variants and grouping
4016
+ // variant.attributes into { color: ['Red', 'Blue'], size: ['S','M','L'] }.
4017
+ // For each variant you can also call getVariantOptions(variant) which returns
4018
+ // the same key/value pairs as an array with translated names when setLocale() is active.
4019
+ // For swatch metadata (color hex, dual-color, image), use the
4020
+ // 'variant-selector-swatches' operation instead.`,
4021
+ "variant-selector-swatches": `// Variant picker that mixes color swatches and text buttons in one product.
4022
+ // Uses product.productAttributeOptions[] \u2014 the merchant-configured swatch
4023
+ // metadata. displayType is the AttributeDisplayType Prisma enum, one of:
4024
+ // 'DEFAULT' \u2014 plain text button
4025
+ // 'COLOR_SWATCH' \u2014 swatchColor (+ optional swatchColor2 dual-color gradient)
4026
+ // 'IMAGE_SWATCH' \u2014 swatchImageUrl thumbnail
4027
+ // 'MIXED_SWATCH' \u2014 per-option hybrid: image if swatchImageUrl is set,
4028
+ // otherwise fall back to swatchColor
4029
+ // getProductSwatches() groups options by attribute name so you can render each
4030
+ // attribute with its own displayType \u2014 a single product can have Color as
4031
+ // swatches AND Size as text buttons.
4032
+ import { getProductSwatches } from 'brainerce';
4033
+ import type { Product } from 'brainerce';
4034
+
4035
+ function VariantPicker({
4036
+ product,
4037
+ selections,
4038
+ onSelect,
4039
+ }: {
4040
+ product: Product;
4041
+ selections: Record<string, string>;
4042
+ onSelect: (attrName: string, value: string) => void;
4043
+ }) {
4044
+ const groups = getProductSwatches(product);
4045
+ // groups: [{ attributeName, displayType, options: [{ name, swatchColor, swatchColor2, swatchImageUrl }] }]
4046
+
4047
+ return (
4048
+ <>
4049
+ {groups.map((group) => (
4050
+ <div key={group.attributeName}>
4051
+ <label>{group.attributeName}: {selections[group.attributeName] ?? ''}</label>
4052
+ <div style={{ display: 'flex', gap: 8 }}>
4053
+ {group.options.map((opt) => {
4054
+ const selected = selections[group.attributeName] === opt.name;
4055
+ const onClick = () => onSelect(group.attributeName, opt.name);
4056
+
4057
+ // COLOR_SWATCH: round button. swatchColor2 = dual-color gradient.
4058
+ if (group.displayType === 'COLOR_SWATCH' && opt.swatchColor) {
4059
+ const bg = opt.swatchColor2
4060
+ ? \`linear-gradient(135deg, \${opt.swatchColor} 50%, \${opt.swatchColor2} 50%)\`
4061
+ : opt.swatchColor;
4062
+ return (
4063
+ <button
4064
+ key={opt.name}
4065
+ type="button"
4066
+ title={opt.name}
4067
+ aria-pressed={selected}
4068
+ onClick={onClick}
4069
+ style={{
4070
+ width: 36,
4071
+ height: 36,
4072
+ borderRadius: '50%',
4073
+ background: bg,
4074
+ outline: selected ? '2px solid black' : 'none',
4075
+ }}
4076
+ />
4077
+ );
4078
+ }
4079
+
4080
+ // IMAGE_SWATCH: square thumbnail.
4081
+ if (group.displayType === 'IMAGE_SWATCH' && opt.swatchImageUrl) {
4082
+ return (
4083
+ <button key={opt.name} type="button" onClick={onClick} aria-pressed={selected}>
4084
+ <img src={opt.swatchImageUrl} alt={opt.name} width={40} height={40} />
4085
+ </button>
4086
+ );
4087
+ }
4088
+
4089
+ // MIXED_SWATCH: per-option hybrid (image wins if present).
4090
+ if (group.displayType === 'MIXED_SWATCH') {
4091
+ if (opt.swatchImageUrl) {
4092
+ return (
4093
+ <button key={opt.name} type="button" onClick={onClick} aria-pressed={selected}>
4094
+ <img src={opt.swatchImageUrl} alt={opt.name} width={40} height={40} />
4095
+ </button>
4096
+ );
4097
+ }
4098
+ if (opt.swatchColor) {
4099
+ const bg = opt.swatchColor2
4100
+ ? \`linear-gradient(135deg, \${opt.swatchColor} 50%, \${opt.swatchColor2} 50%)\`
4101
+ : opt.swatchColor;
4102
+ return (
4103
+ <button
4104
+ key={opt.name}
4105
+ type="button"
4106
+ title={opt.name}
4107
+ aria-pressed={selected}
4108
+ onClick={onClick}
4109
+ style={{ width: 36, height: 36, borderRadius: '50%', background: bg }}
4110
+ />
4111
+ );
4112
+ }
4113
+ }
4114
+
4115
+ // DEFAULT (or any swatch type missing its data): text button.
4116
+ return (
4117
+ <button key={opt.name} type="button" onClick={onClick} aria-pressed={selected}>
4118
+ {opt.name}
4119
+ </button>
4120
+ );
4121
+ })}
4122
+ </div>
4123
+ </div>
4124
+ ))}
4125
+ </>
4126
+ );
4127
+ }
4128
+
4129
+ // After the user picks a value, find the matching ProductVariant:
4130
+ // product.variants?.find((v) =>
4131
+ // Object.entries(selections).every(([k, val]) => v.attributes?.[k] === val)
4132
+ // )
4133
+ // Then read variant.price, variant.image, variant.inventory.canPurchase
4134
+ // to update the rest of the PDP.`,
3743
4135
  "checkout-address-and-shipping": `// Step 1-3 of the checkout flow. Never reorder or skip.
3744
4136
  import { client } from './brainerce';
3745
4137
 
@@ -4531,6 +4923,138 @@ function renderCartItem(item: CartItem): string {
4531
4923
  // multiply unitPrice \xD7 quantity directly.
4532
4924
  function lineTotal(item: CartItem): number {
4533
4925
  return item.unitPrice * item.quantity;
4926
+ }`,
4927
+ "product-reviews": `// Purchaser-only reviews. Customer must be logged in AND have purchased
4928
+ // the product. The component switches between sign-in CTA / not-eligible /
4929
+ // submit form / edit form based on getMyProductReview().
4930
+ import { getClient, getServerClient } from '@/lib/brainerce';
4931
+ import { checkAuthStatus } from '@/lib/auth';
4932
+ import type { Product, ProductReview, MyProductReview } from 'brainerce';
4933
+
4934
+ // Server component \u2014 fetches existing reviews + emits JSON-LD aggregateRating.
4935
+ export async function ProductReviewsSection({ product }: { product: Product }) {
4936
+ const { data } = await getServerClient().listProductReviews(product.id, { page: 1, limit: 20 });
4937
+
4938
+ return (
4939
+ <section>
4940
+ <h2>Reviews ({product.reviewCount ?? 0})</h2>
4941
+ {data.length === 0 && <p>No reviews yet.</p>}
4942
+ {data.map((r) => (
4943
+ <article key={r.id}>
4944
+ <strong>{r.authorName}</strong>
4945
+ {r.verifiedPurchase && <span> \xB7 Verified purchase</span>}
4946
+ <div>{'\u2605'.repeat(r.rating)}{'\u2606'.repeat(5 - r.rating)}</div>
4947
+ {r.body && <p>{r.body}</p>}
4948
+ </article>
4949
+ ))}
4950
+ <ReviewForm productId={product.id} />
4951
+ </section>
4952
+ );
4953
+ }
4954
+
4955
+ // JSON-LD \u2014 emit aggregateRating only when there is data
4956
+ function productJsonLd(product: Product, url: string) {
4957
+ return {
4958
+ '@context': 'https://schema.org',
4959
+ '@type': 'Product',
4960
+ name: product.name,
4961
+ image: product.images?.[0]?.url,
4962
+ url,
4963
+ sku: product.sku ?? product.id,
4964
+ ...(product.reviewCount && product.reviewCount > 0 && {
4965
+ aggregateRating: {
4966
+ '@type': 'AggregateRating',
4967
+ ratingValue: product.avgRating,
4968
+ reviewCount: product.reviewCount,
4969
+ bestRating: 5,
4970
+ worstRating: 1,
4971
+ },
4972
+ }),
4973
+ };
4974
+ }
4975
+
4976
+ // Client-side form
4977
+ 'use client';
4978
+ import { useEffect, useState } from 'react';
4979
+
4980
+ type Stage =
4981
+ | { kind: 'loading' }
4982
+ | { kind: 'signed_out' }
4983
+ | { kind: 'not_eligible'; reason: MyProductReview['reason'] }
4984
+ | { kind: 'submit' }
4985
+ | { kind: 'edit'; review: ProductReview };
4986
+
4987
+ function ReviewForm({ productId }: { productId: string }) {
4988
+ const [stage, setStage] = useState<Stage>({ kind: 'loading' });
4989
+ const [rating, setRating] = useState(5);
4990
+ const [body, setBody] = useState('');
4991
+ const [error, setError] = useState<string | null>(null);
4992
+
4993
+ useEffect(() => {
4994
+ (async () => {
4995
+ const auth = await checkAuthStatus();
4996
+ if (!auth.isLoggedIn) return setStage({ kind: 'signed_out' });
4997
+ const me = await getClient().getMyProductReview(productId);
4998
+ if (!me.eligible) return setStage({ kind: 'not_eligible', reason: me.reason });
4999
+ if (me.myReview) {
5000
+ setRating(me.myReview.rating);
5001
+ setBody(me.myReview.body ?? '');
5002
+ return setStage({ kind: 'edit', review: me.myReview });
5003
+ }
5004
+ setStage({ kind: 'submit' });
5005
+ })();
5006
+ }, [productId]);
5007
+
5008
+ if (stage.kind === 'loading') return <p>Loading\u2026</p>;
5009
+ if (stage.kind === 'signed_out')
5010
+ return <p><a href="/account/login">Sign in</a> to leave a review.</p>;
5011
+ if (stage.kind === 'not_eligible')
5012
+ return <p>Only customers who purchased this product can leave a review.</p>;
5013
+
5014
+ async function submit(e: React.FormEvent) {
5015
+ e.preventDefault();
5016
+ setError(null);
5017
+ const client = getClient();
5018
+ const input = { rating, body: body || undefined };
5019
+ try {
5020
+ if (stage.kind === 'edit') {
5021
+ await client.updateMyProductReview(productId, input);
5022
+ } else {
5023
+ await client.submitProductReview(productId, input);
5024
+ }
5025
+ window.location.reload();
5026
+ } catch (err: any) {
5027
+ if (err?.status === 403) setError('Only customers who purchased this product can leave a review.');
5028
+ else if (err?.status === 409) setError('You already reviewed this product.');
5029
+ else if (err?.status === 429) setError('Too many submissions. Please wait a moment.');
5030
+ else setError('Could not save your review. Please try again.');
5031
+ }
5032
+ }
5033
+
5034
+ async function remove() {
5035
+ if (!confirm('Delete your review?')) return;
5036
+ await getClient().deleteMyProductReview(productId);
5037
+ window.location.reload();
5038
+ }
5039
+
5040
+ return (
5041
+ <form onSubmit={submit}>
5042
+ <h3>{stage.kind === 'edit' ? 'Edit your review' : 'Write a review'}</h3>
5043
+ <div>
5044
+ {[1,2,3,4,5].map(n => (
5045
+ <button type="button" key={n} onClick={() => setRating(n)}>
5046
+ {n <= rating ? '\u2605' : '\u2606'}
5047
+ </button>
5048
+ ))}
5049
+ </div>
5050
+ <textarea value={body} onChange={(e) => setBody(e.target.value)} maxLength={5000} />
5051
+ <button type="submit">{stage.kind === 'edit' ? 'Save changes' : 'Submit review'}</button>
5052
+ {stage.kind === 'edit' && (
5053
+ <button type="button" onClick={remove}>Delete review</button>
5054
+ )}
5055
+ {error && <p role="alert">{error}</p>}
5056
+ </form>
5057
+ );
4534
5058
  }`
4535
5059
  };
4536
5060
  async function handleGetCodeExample(args) {
@@ -5333,6 +5857,13 @@ var FEATURES = [
5333
5857
  sdk: "client.getProductBySlug(slug). Helpers: getProductPriceInfo, getVariantPrice, getStockStatus, getVariantOptions, getProductSwatches, getDescriptionContent, getProductMetafieldValue.",
5334
5858
  mandatory: "mandatory"
5335
5859
  },
5860
+ {
5861
+ id: "product-reviews",
5862
+ title: "Display + collect product reviews (purchaser-only) with stars + JSON-LD",
5863
+ description: 'On the PDP show a Reviews section: list of customer reviews with star rating, author name (from customer profile), verified-purchase badge, body, and date \u2014 public, no login needed. Below the list, a form that adapts to the current customer: signed-in + purchased + no review yet \u2192 submit form (rating 1-5 + optional body, no name/email \u2014 those are derived from the customer profile); signed-in + purchased + already has review \u2192 edit form prefilled with rating/body plus a delete button; signed-in but did NOT purchase \u2192 "only customers who purchased this product can leave a review"; not signed in \u2192 sign-in CTA. Eligibility: physical products require an order in SHIPPED+; downloadable products additionally accept PAID/PROCESSING. On PLP cards show \u2605 avgRating and (reviewCount). Emit Product JSON-LD with aggregateRating ONLY when product.reviewCount > 0 \u2014 this is what unlocks the star rich snippet in Google. Reviews publish immediately; merchants moderate (hide/show) from the admin surface. Rate-limited 3 submits / 60s / IP.',
5864
+ sdk: "client.listProductReviews(productId) for the public list; client.getMyProductReview(productId) for the form-state decision; client.submitProductReview / updateMyProductReview / deleteMyProductReview for the three customer actions. All four customer methods require setCustomerToken(...) after login. Product.avgRating + Product.reviewCount are denormalized rollups returned on every product fetch.",
5865
+ mandatory: "mandatory"
5866
+ },
5336
5867
  {
5337
5868
  id: "product-customization-fields",
5338
5869
  title: "Render buyer-input customization fields on the product page",