@brainerce/mcp-server 3.6.0 → 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/index.js CHANGED
@@ -948,6 +948,175 @@ function ProductPage({ product, storeInfo }: { product: Product; storeInfo: Stor
948
948
  }
949
949
  \`\`\`
950
950
 
951
+ ### Variant Attribute Selector with Swatches (color + size **mix**)
952
+
953
+ The simple text-button picker above works, but loses the merchant-configured
954
+ **swatch metadata**. A single product often mixes one attribute that should
955
+ render as **color swatches** (e.g. \`Color\`) and another that should render
956
+ as **text buttons** (e.g. \`Size\`). The product response includes everything
957
+ you need:
958
+
959
+ - \`product.productAttributeOptions[]\` \u2014 the swatch definitions: \`attribute.name\`,
960
+ \`attribute.displayType\` (the \`AttributeDisplayType\` enum: \`DEFAULT\` | \`COLOR_SWATCH\` | \`IMAGE_SWATCH\` | \`MIXED_SWATCH\`),
961
+ \`attributeOption.name\`, \`swatchColor\` (hex), \`swatchColor2\` (hex \u2014 used for dual-color swatches
962
+ like Black & White), \`swatchImageUrl\` (for fabric / pattern thumbnails).
963
+ \`MIXED_SWATCH\` means each option in the group can independently pick image OR color
964
+ (the merchant decides per option). Treat any unrecognized value as \`DEFAULT\`.
965
+ - \`variant.attributes\` \u2014 the selected value per attribute on each variant.
966
+
967
+ Use \`getProductSwatches(product)\` to get a render-ready, grouped structure:
968
+
969
+ \`\`\`typescript
970
+ import { getProductSwatches } from 'brainerce';
971
+
972
+ const groups = getProductSwatches(product);
973
+ // [
974
+ // { attributeName: 'color', displayType: 'COLOR_SWATCH', options: [
975
+ // { name: 'Full Color', swatchColor: '#1D9435', swatchColor2: null, swatchImageUrl: null },
976
+ // { name: 'Black & White', swatchColor: '#000000', swatchColor2: '#FFFFFF', swatchImageUrl: null },
977
+ // ]},
978
+ // { attributeName: 'size', displayType: 'DEFAULT', options: [
979
+ // { name: '16" \xD7 20"' }, { name: '24" \xD7 32"' }, { name: '36" \xD7 48"' },
980
+ // ]},
981
+ // ]
982
+ \`\`\`
983
+
984
+ Render one branch per \`displayType\`. The same loop handles all-swatch, all-text,
985
+ and the mix case:
986
+
987
+ \`\`\`typescript
988
+ function VariantPicker({
989
+ product,
990
+ selections,
991
+ onSelect,
992
+ }: {
993
+ product: Product;
994
+ selections: Record<string, string>; // { color: 'Full Color', size: '24" \xD7 32"' }
995
+ onSelect: (attrName: string, value: string) => void;
996
+ }) {
997
+ const groups = getProductSwatches(product);
998
+ if (groups.length === 0) return null;
999
+
1000
+ return (
1001
+ <div className="space-y-4">
1002
+ {groups.map((group) => (
1003
+ <div key={group.attributeName}>
1004
+ <label className="block mb-2 text-sm font-medium">
1005
+ {group.attributeName}: <span className="text-gray-500">{selections[group.attributeName] ?? ''}</span>
1006
+ </label>
1007
+ <div className="flex flex-wrap gap-2">
1008
+ {group.options.map((opt) => {
1009
+ const isSelected = selections[group.attributeName] === opt.name;
1010
+
1011
+ // COLOR_SWATCH \u2014 round button; dual-color uses a 50/50 gradient.
1012
+ if (group.displayType === 'COLOR_SWATCH' && opt.swatchColor) {
1013
+ const bg = opt.swatchColor2
1014
+ ? \`linear-gradient(135deg, \${opt.swatchColor} 50%, \${opt.swatchColor2} 50%)\`
1015
+ : opt.swatchColor;
1016
+ return (
1017
+ <button
1018
+ key={opt.name}
1019
+ type="button"
1020
+ title={opt.name}
1021
+ aria-pressed={isSelected}
1022
+ onClick={() => onSelect(group.attributeName, opt.name)}
1023
+ className={\`h-9 w-9 rounded-full border-2 \${isSelected ? 'border-black ring-2 ring-black/30' : 'border-gray-300 hover:border-black'}\`}
1024
+ style={{ background: bg }}
1025
+ />
1026
+ );
1027
+ }
1028
+
1029
+ // IMAGE_SWATCH \u2014 square thumbnail (fabric/pattern preview).
1030
+ if (group.displayType === 'IMAGE_SWATCH' && opt.swatchImageUrl) {
1031
+ return (
1032
+ <button
1033
+ key={opt.name}
1034
+ type="button"
1035
+ aria-pressed={isSelected}
1036
+ onClick={() => onSelect(group.attributeName, opt.name)}
1037
+ className={\`h-10 w-10 overflow-hidden rounded border-2 \${isSelected ? 'border-black' : 'border-gray-300'}\`}
1038
+ >
1039
+ <img src={opt.swatchImageUrl} alt={opt.name} className="h-full w-full object-cover" />
1040
+ </button>
1041
+ );
1042
+ }
1043
+
1044
+ // MIXED_SWATCH \u2014 each option is independently image OR color (image wins if present).
1045
+ if (group.displayType === 'MIXED_SWATCH') {
1046
+ if (opt.swatchImageUrl) {
1047
+ return (
1048
+ <button
1049
+ key={opt.name}
1050
+ type="button"
1051
+ aria-pressed={isSelected}
1052
+ onClick={() => onSelect(group.attributeName, opt.name)}
1053
+ className={\`h-10 w-10 overflow-hidden rounded border-2 \${isSelected ? 'border-black' : 'border-gray-300'}\`}
1054
+ >
1055
+ <img src={opt.swatchImageUrl} alt={opt.name} className="h-full w-full object-cover" />
1056
+ </button>
1057
+ );
1058
+ }
1059
+ if (opt.swatchColor) {
1060
+ const bg = opt.swatchColor2
1061
+ ? \`linear-gradient(135deg, \${opt.swatchColor} 50%, \${opt.swatchColor2} 50%)\`
1062
+ : opt.swatchColor;
1063
+ return (
1064
+ <button
1065
+ key={opt.name}
1066
+ type="button"
1067
+ title={opt.name}
1068
+ aria-pressed={isSelected}
1069
+ onClick={() => onSelect(group.attributeName, opt.name)}
1070
+ className={\`h-9 w-9 rounded-full border-2 \${isSelected ? 'border-black ring-2 ring-black/30' : 'border-gray-300 hover:border-black'}\`}
1071
+ style={{ background: bg }}
1072
+ />
1073
+ );
1074
+ }
1075
+ // option has neither image nor color \u2192 fall through to text button below.
1076
+ }
1077
+
1078
+ // DEFAULT (or any swatch type missing its data) \u2014 plain text button.
1079
+ return (
1080
+ <button
1081
+ key={opt.name}
1082
+ type="button"
1083
+ aria-pressed={isSelected}
1084
+ onClick={() => onSelect(group.attributeName, opt.name)}
1085
+ className={\`rounded border px-3 py-1.5 text-sm \${isSelected ? 'bg-black text-white border-black' : 'border-gray-300 hover:border-black'}\`}
1086
+ >
1087
+ {opt.name}
1088
+ </button>
1089
+ );
1090
+ })}
1091
+ </div>
1092
+ </div>
1093
+ ))}
1094
+ </div>
1095
+ );
1096
+ }
1097
+ \`\`\`
1098
+
1099
+ Wire it up in the product page: keep \`selections\` in state, find the matching
1100
+ variant after each pick, and update price / image / stock from it.
1101
+
1102
+ \`\`\`typescript
1103
+ const [selections, setSelections] = useState<Record<string, string>>({});
1104
+
1105
+ const matchedVariant = useMemo(() => (
1106
+ product.variants?.find((v) =>
1107
+ Object.entries(selections).every(([k, val]) => v.attributes?.[k] === val)
1108
+ ) ?? null
1109
+ ), [product.variants, selections]);
1110
+
1111
+ // Greying-out unavailable combinations: for each candidate value, run
1112
+ // the same find() and check \`inventory.canPurchase\`. Disable the button
1113
+ // if no purchasable variant exists under that selection.
1114
+ \`\`\`
1115
+
1116
+ **i18n:** \`getProductSwatches\` returns translated \`attributeName\` and
1117
+ \`option.name\` automatically when \`client.setLocale()\` is active. No
1118
+ client-side overlay needed. Swatch colors are language-agnostic.
1119
+
951
1120
  ### Description Rendering (handles HTML vs text)
952
1121
 
953
1122
  \`\`\`typescript
@@ -1582,17 +1751,28 @@ Allergens (informational chips), scheduled availability windows, nested combos (
1582
1751
  function getProductReviewsSection() {
1583
1752
  return `## Product Reviews (\u2605 ratings + JSON-LD for SEO)
1584
1753
 
1585
- Reviews publish immediately on submit. Merchants hide individual reviews via the admin surface \u2014 no PENDING queue.
1754
+ **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.
1586
1755
 
1587
1756
  \`\`\`typescript
1588
- // PDP \u2014 list visible reviews
1589
- const { data, meta } = await client.listProductReviews(productId, { page: 1, limit: 20 });
1590
- data.forEach(r => render(r.authorName, r.rating, r.body, r.verifiedPurchase));
1757
+ // PDP \u2014 list visible reviews (public)
1758
+ const { data } = await getClient().listProductReviews(productId, { page: 1, limit: 20 });
1759
+
1760
+ // Decide which UI to render for the current customer
1761
+ getClient().setCustomerToken(authToken); // after login
1762
+ const { eligible, reason, myReview } = await getClient().getMyProductReview(productId);
1763
+
1764
+ if (!eligible) {
1765
+ // reason: 'no_eligible_order' | 'reviews_disabled' | 'product_not_found'
1766
+ // \u2192 "only customers who purchased this product can leave a review"
1767
+ } else if (myReview) {
1768
+ // \u2192 edit form prefilled with myReview.rating + myReview.body
1769
+ } else {
1770
+ // \u2192 submit form (rating + body only \u2014 name/email come from customer profile)
1771
+ }
1591
1772
 
1592
- // PDP \u2014 submit form
1593
- await client.submitProductReview(productId, {
1594
- authorName, authorEmail, rating: 5, body,
1595
- });
1773
+ await getClient().submitProductReview(productId, { rating: 5, body: 'Loved it!' });
1774
+ await getClient().updateMyProductReview(productId, { rating: 4, body: 'Updated.' });
1775
+ await getClient().deleteMyProductReview(productId); // can submit a new one after
1596
1776
  \`\`\`
1597
1777
 
1598
1778
  Each \`Product\` carries denormalized rollups: \`product.avgRating\`, \`product.reviewCount\`. Use these on PLP cards.
@@ -1621,9 +1801,10 @@ const productJsonLd = {
1621
1801
 
1622
1802
  ### Rules
1623
1803
  - Rating must be an integer 1-5 (DB constraint).
1624
- - Duplicate from same email \u2192 409 Conflict.
1625
- - Submit endpoint is throttled to 3 / 60s / IP \u2014 surface a friendly "please wait" on 429.
1626
- - \`verifiedPurchase\` is derived server-side from DELIVERED orders \u2014 DO NOT try to set it.
1804
+ - Customer must be logged in AND have purchased the product (\`SHIPPED+\` for physical, \`PAID+\` for downloadable).
1805
+ - One review per (productId, customerId). Duplicate submit \u2192 409 \u2014 use updateMyProductReview instead.
1806
+ - Submit throttled 3 / 60s / IP; edit/delete 5 / 60s / IP.
1807
+ - \`verifiedPurchase\` is always true for customer-submitted reviews.
1627
1808
  - Stores with \`reviewsEnabled = false\` return 403 on storefront endpoints.`;
1628
1809
  }
1629
1810
  function getInventorySection() {
@@ -2719,9 +2900,7 @@ var GET_SDK_DOCS_SCHEMA = {
2719
2900
  async function handleGetSdkDocs(args) {
2720
2901
  const id = args.salesChannelId ?? args.connectionId;
2721
2902
  if (!args.salesChannelId && args.connectionId) {
2722
- console.warn(
2723
- "get-sdk-docs: `connectionId` is deprecated \u2014 use `salesChannelId` instead"
2724
- );
2903
+ console.warn("get-sdk-docs: `connectionId` is deprecated \u2014 use `salesChannelId` instead");
2725
2904
  }
2726
2905
  const content = getSectionByTopic(args.topic, id, args.currency);
2727
2906
  return {
@@ -3453,11 +3632,16 @@ interface ProductReviewAdmin extends ProductReview {
3453
3632
  updatedAt: string;
3454
3633
  }
3455
3634
 
3456
- interface SubmitProductReviewInput {
3457
- authorName: string;
3458
- authorEmail?: string; // recommended \u2014 used for guest dedupe + verified-purchase derivation
3635
+ interface WriteProductReviewInput {
3459
3636
  rating: number; // 1-5
3460
- body?: string; // optional
3637
+ body?: string; // optional, max 5000 chars
3638
+ }
3639
+
3640
+ interface MyProductReview {
3641
+ eligible: boolean;
3642
+ // Machine-readable reason when not eligible. null when eligible=true.
3643
+ reason: 'no_eligible_order' | 'reviews_disabled' | 'product_not_found' | null;
3644
+ myReview: ProductReview | null;
3461
3645
  }
3462
3646
 
3463
3647
  // Each Product carries denormalized review stats:
@@ -3760,6 +3944,7 @@ var GET_CODE_EXAMPLE_SCHEMA = {
3760
3944
  "sdk-init",
3761
3945
  "cart-read-and-mutate",
3762
3946
  "variant-selection",
3947
+ "variant-selector-swatches",
3763
3948
  "checkout-address-and-shipping",
3764
3949
  "checkout-payment-providers",
3765
3950
  "checkout-stripe-confirm",
@@ -3829,28 +4014,148 @@ for (const item of cart.items) {
3829
4014
  const unitPrice = parseFloat(item.unitPrice); // prices are strings!
3830
4015
  }`,
3831
4016
  "variant-selection": `// Variant selection with live stock + price updates.
4017
+ // Variants expose attributes as a flat object: { color: 'Red', size: 'M' }.
4018
+ // NOT 'options' \u2014 that field does not exist on ProductVariant.
3832
4019
  import { getVariantPrice, getStockStatus, getVariantOptions } from 'brainerce';
3833
4020
  import type { Product, ProductVariant } from 'brainerce';
3834
4021
 
3835
4022
  function selectVariant(product: Product, selections: Record<string, string>) {
3836
- // selections = { Size: 'M', Color: 'Red' }
3837
- const variant = product.variants.find((v) =>
3838
- v.options.every((o) => selections[o.name] === o.value)
4023
+ // selections = { size: 'M', color: 'Red' } (keys match variant.attributes keys)
4024
+ const variant = product.variants?.find((v) =>
4025
+ Object.entries(selections).every(([k, val]) => v.attributes?.[k] === val)
3839
4026
  );
3840
4027
  if (!variant) return null;
3841
4028
 
3842
- const price = getVariantPrice(product, variant); // handles compare-at, currency
3843
- const stock = getStockStatus(variant); // 'IN_STOCK' | 'LOW_STOCK' | 'OUT_OF_STOCK'
3844
- const canPurchase = stock !== 'OUT_OF_STOCK';
4029
+ const price = getVariantPrice(variant, product.basePrice); // string, fallback-aware
4030
+ const stock = getStockStatus(variant.inventory); // 'in-stock' | 'low-stock' | 'out-of-stock' | 'unavailable'
4031
+ const canPurchase = variant.inventory?.canPurchase !== false;
3845
4032
 
3846
4033
  // Switch the main product image to the variant's image if it has one
3847
- const image = variant.image ?? product.images[0];
4034
+ const image = variant.image ?? product.images?.[0];
3848
4035
 
3849
4036
  return { variant, price, stock, canPurchase, image };
3850
4037
  }
3851
4038
 
3852
- // Build the option picker from getVariantOptions(product) \u2014 returns
3853
- // [{ name: 'Size', values: ['S','M','L'] }, ...]`,
4039
+ // Build the option picker by iterating product.variants and grouping
4040
+ // variant.attributes into { color: ['Red', 'Blue'], size: ['S','M','L'] }.
4041
+ // For each variant you can also call getVariantOptions(variant) which returns
4042
+ // the same key/value pairs as an array with translated names when setLocale() is active.
4043
+ // For swatch metadata (color hex, dual-color, image), use the
4044
+ // 'variant-selector-swatches' operation instead.`,
4045
+ "variant-selector-swatches": `// Variant picker that mixes color swatches and text buttons in one product.
4046
+ // Uses product.productAttributeOptions[] \u2014 the merchant-configured swatch
4047
+ // metadata. displayType is the AttributeDisplayType Prisma enum, one of:
4048
+ // 'DEFAULT' \u2014 plain text button
4049
+ // 'COLOR_SWATCH' \u2014 swatchColor (+ optional swatchColor2 dual-color gradient)
4050
+ // 'IMAGE_SWATCH' \u2014 swatchImageUrl thumbnail
4051
+ // 'MIXED_SWATCH' \u2014 per-option hybrid: image if swatchImageUrl is set,
4052
+ // otherwise fall back to swatchColor
4053
+ // getProductSwatches() groups options by attribute name so you can render each
4054
+ // attribute with its own displayType \u2014 a single product can have Color as
4055
+ // swatches AND Size as text buttons.
4056
+ import { getProductSwatches } from 'brainerce';
4057
+ import type { Product } from 'brainerce';
4058
+
4059
+ function VariantPicker({
4060
+ product,
4061
+ selections,
4062
+ onSelect,
4063
+ }: {
4064
+ product: Product;
4065
+ selections: Record<string, string>;
4066
+ onSelect: (attrName: string, value: string) => void;
4067
+ }) {
4068
+ const groups = getProductSwatches(product);
4069
+ // groups: [{ attributeName, displayType, options: [{ name, swatchColor, swatchColor2, swatchImageUrl }] }]
4070
+
4071
+ return (
4072
+ <>
4073
+ {groups.map((group) => (
4074
+ <div key={group.attributeName}>
4075
+ <label>{group.attributeName}: {selections[group.attributeName] ?? ''}</label>
4076
+ <div style={{ display: 'flex', gap: 8 }}>
4077
+ {group.options.map((opt) => {
4078
+ const selected = selections[group.attributeName] === opt.name;
4079
+ const onClick = () => onSelect(group.attributeName, opt.name);
4080
+
4081
+ // COLOR_SWATCH: round button. swatchColor2 = dual-color gradient.
4082
+ if (group.displayType === 'COLOR_SWATCH' && opt.swatchColor) {
4083
+ const bg = opt.swatchColor2
4084
+ ? \`linear-gradient(135deg, \${opt.swatchColor} 50%, \${opt.swatchColor2} 50%)\`
4085
+ : opt.swatchColor;
4086
+ return (
4087
+ <button
4088
+ key={opt.name}
4089
+ type="button"
4090
+ title={opt.name}
4091
+ aria-pressed={selected}
4092
+ onClick={onClick}
4093
+ style={{
4094
+ width: 36,
4095
+ height: 36,
4096
+ borderRadius: '50%',
4097
+ background: bg,
4098
+ outline: selected ? '2px solid black' : 'none',
4099
+ }}
4100
+ />
4101
+ );
4102
+ }
4103
+
4104
+ // IMAGE_SWATCH: square thumbnail.
4105
+ if (group.displayType === 'IMAGE_SWATCH' && opt.swatchImageUrl) {
4106
+ return (
4107
+ <button key={opt.name} type="button" onClick={onClick} aria-pressed={selected}>
4108
+ <img src={opt.swatchImageUrl} alt={opt.name} width={40} height={40} />
4109
+ </button>
4110
+ );
4111
+ }
4112
+
4113
+ // MIXED_SWATCH: per-option hybrid (image wins if present).
4114
+ if (group.displayType === 'MIXED_SWATCH') {
4115
+ if (opt.swatchImageUrl) {
4116
+ return (
4117
+ <button key={opt.name} type="button" onClick={onClick} aria-pressed={selected}>
4118
+ <img src={opt.swatchImageUrl} alt={opt.name} width={40} height={40} />
4119
+ </button>
4120
+ );
4121
+ }
4122
+ if (opt.swatchColor) {
4123
+ const bg = opt.swatchColor2
4124
+ ? \`linear-gradient(135deg, \${opt.swatchColor} 50%, \${opt.swatchColor2} 50%)\`
4125
+ : opt.swatchColor;
4126
+ return (
4127
+ <button
4128
+ key={opt.name}
4129
+ type="button"
4130
+ title={opt.name}
4131
+ aria-pressed={selected}
4132
+ onClick={onClick}
4133
+ style={{ width: 36, height: 36, borderRadius: '50%', background: bg }}
4134
+ />
4135
+ );
4136
+ }
4137
+ }
4138
+
4139
+ // DEFAULT (or any swatch type missing its data): text button.
4140
+ return (
4141
+ <button key={opt.name} type="button" onClick={onClick} aria-pressed={selected}>
4142
+ {opt.name}
4143
+ </button>
4144
+ );
4145
+ })}
4146
+ </div>
4147
+ </div>
4148
+ ))}
4149
+ </>
4150
+ );
4151
+ }
4152
+
4153
+ // After the user picks a value, find the matching ProductVariant:
4154
+ // product.variants?.find((v) =>
4155
+ // Object.entries(selections).every(([k, val]) => v.attributes?.[k] === val)
4156
+ // )
4157
+ // Then read variant.price, variant.image, variant.inventory.canPurchase
4158
+ // to update the rest of the PDP.`,
3854
4159
  "checkout-address-and-shipping": `// Step 1-3 of the checkout flow. Never reorder or skip.
3855
4160
  import { client } from './brainerce';
3856
4161
 
@@ -4643,21 +4948,25 @@ function renderCartItem(item: CartItem): string {
4643
4948
  function lineTotal(item: CartItem): number {
4644
4949
  return item.unitPrice * item.quantity;
4645
4950
  }`,
4646
- "product-reviews": `// PDP: list visible reviews + submit form + JSON-LD aggregateRating
4647
- import { client } from '@/lib/brainerce';
4648
- import type { Product, ProductReview, SubmitProductReviewInput } from 'brainerce';
4649
-
4951
+ "product-reviews": `// Purchaser-only reviews. Customer must be logged in AND have purchased
4952
+ // the product. The component switches between sign-in CTA / not-eligible /
4953
+ // submit form / edit form based on getMyProductReview().
4954
+ import { getClient, getServerClient } from '@/lib/brainerce';
4955
+ import { checkAuthStatus } from '@/lib/auth';
4956
+ import type { Product, ProductReview, MyProductReview } from 'brainerce';
4957
+
4958
+ // Server component \u2014 fetches existing reviews + emits JSON-LD aggregateRating.
4650
4959
  export async function ProductReviewsSection({ product }: { product: Product }) {
4651
- const { data } = await client.listProductReviews(product.id, { page: 1, limit: 20 });
4960
+ const { data } = await getServerClient().listProductReviews(product.id, { page: 1, limit: 20 });
4652
4961
 
4653
4962
  return (
4654
4963
  <section>
4655
4964
  <h2>Reviews ({product.reviewCount ?? 0})</h2>
4656
- {data.length === 0 && <p>No reviews yet \u2014 be the first.</p>}
4965
+ {data.length === 0 && <p>No reviews yet.</p>}
4657
4966
  {data.map((r) => (
4658
4967
  <article key={r.id}>
4659
4968
  <strong>{r.authorName}</strong>
4660
- {r.verifiedPurchase && <span> \xB7 Verified</span>}
4969
+ {r.verifiedPurchase && <span> \xB7 Verified purchase</span>}
4661
4970
  <div>{'\u2605'.repeat(r.rating)}{'\u2606'.repeat(5 - r.rating)}</div>
4662
4971
  {r.body && <p>{r.body}</p>}
4663
4972
  </article>
@@ -4690,42 +4999,83 @@ function productJsonLd(product: Product, url: string) {
4690
4999
 
4691
5000
  // Client-side form
4692
5001
  'use client';
4693
- import { useState } from 'react';
5002
+ import { useEffect, useState } from 'react';
5003
+
5004
+ type Stage =
5005
+ | { kind: 'loading' }
5006
+ | { kind: 'signed_out' }
5007
+ | { kind: 'not_eligible'; reason: MyProductReview['reason'] }
5008
+ | { kind: 'submit' }
5009
+ | { kind: 'edit'; review: ProductReview };
4694
5010
 
4695
5011
  function ReviewForm({ productId }: { productId: string }) {
5012
+ const [stage, setStage] = useState<Stage>({ kind: 'loading' });
4696
5013
  const [rating, setRating] = useState(5);
4697
5014
  const [body, setBody] = useState('');
4698
- const [name, setName] = useState('');
4699
- const [email, setEmail] = useState('');
4700
5015
  const [error, setError] = useState<string | null>(null);
4701
5016
 
5017
+ useEffect(() => {
5018
+ (async () => {
5019
+ const auth = await checkAuthStatus();
5020
+ if (!auth.isLoggedIn) return setStage({ kind: 'signed_out' });
5021
+ const me = await getClient().getMyProductReview(productId);
5022
+ if (!me.eligible) return setStage({ kind: 'not_eligible', reason: me.reason });
5023
+ if (me.myReview) {
5024
+ setRating(me.myReview.rating);
5025
+ setBody(me.myReview.body ?? '');
5026
+ return setStage({ kind: 'edit', review: me.myReview });
5027
+ }
5028
+ setStage({ kind: 'submit' });
5029
+ })();
5030
+ }, [productId]);
5031
+
5032
+ if (stage.kind === 'loading') return <p>Loading\u2026</p>;
5033
+ if (stage.kind === 'signed_out')
5034
+ return <p><a href="/account/login">Sign in</a> to leave a review.</p>;
5035
+ if (stage.kind === 'not_eligible')
5036
+ return <p>Only customers who purchased this product can leave a review.</p>;
5037
+
4702
5038
  async function submit(e: React.FormEvent) {
4703
5039
  e.preventDefault();
4704
5040
  setError(null);
5041
+ const client = getClient();
5042
+ const input = { rating, body: body || undefined };
4705
5043
  try {
4706
- await client.submitProductReview(productId, {
4707
- authorName: name,
4708
- authorEmail: email,
4709
- rating,
4710
- body: body || undefined,
4711
- });
5044
+ if (stage.kind === 'edit') {
5045
+ await client.updateMyProductReview(productId, input);
5046
+ } else {
5047
+ await client.submitProductReview(productId, input);
5048
+ }
4712
5049
  window.location.reload();
4713
5050
  } catch (err: any) {
4714
- if (err?.status === 409) setError("You've already reviewed this product.");
5051
+ if (err?.status === 403) setError('Only customers who purchased this product can leave a review.');
5052
+ else if (err?.status === 409) setError('You already reviewed this product.');
4715
5053
  else if (err?.status === 429) setError('Too many submissions. Please wait a moment.');
4716
- else setError('Could not submit review. Please try again.');
5054
+ else setError('Could not save your review. Please try again.');
4717
5055
  }
4718
5056
  }
4719
5057
 
5058
+ async function remove() {
5059
+ if (!confirm('Delete your review?')) return;
5060
+ await getClient().deleteMyProductReview(productId);
5061
+ window.location.reload();
5062
+ }
5063
+
4720
5064
  return (
4721
5065
  <form onSubmit={submit}>
4722
- <input value={name} onChange={(e) => setName(e.target.value)} placeholder="Your name" required maxLength={100} />
4723
- <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" />
4724
- <select value={rating} onChange={(e) => setRating(Number(e.target.value))}>
4725
- {[5,4,3,2,1].map(n => <option key={n} value={n}>{'\u2605'.repeat(n)}</option>)}
4726
- </select>
4727
- <textarea value={body} onChange={(e) => setBody(e.target.value)} maxLength={5000} placeholder="Tell us what you think (optional)" />
4728
- <button type="submit">Submit review</button>
5066
+ <h3>{stage.kind === 'edit' ? 'Edit your review' : 'Write a review'}</h3>
5067
+ <div>
5068
+ {[1,2,3,4,5].map(n => (
5069
+ <button type="button" key={n} onClick={() => setRating(n)}>
5070
+ {n <= rating ? '\u2605' : '\u2606'}
5071
+ </button>
5072
+ ))}
5073
+ </div>
5074
+ <textarea value={body} onChange={(e) => setBody(e.target.value)} maxLength={5000} />
5075
+ <button type="submit">{stage.kind === 'edit' ? 'Save changes' : 'Submit review'}</button>
5076
+ {stage.kind === 'edit' && (
5077
+ <button type="button" onClick={remove}>Delete review</button>
5078
+ )}
4729
5079
  {error && <p role="alert">{error}</p>}
4730
5080
  </form>
4731
5081
  );
@@ -5533,9 +5883,9 @@ var FEATURES = [
5533
5883
  },
5534
5884
  {
5535
5885
  id: "product-reviews",
5536
- title: "Display + collect product reviews with stars + JSON-LD",
5537
- description: 'On the PDP show a Reviews section: list of customer reviews with star rating, author name, verified-purchase badge, body, and date. Include a submit form (name + email + 1-5 stars + optional body). 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; the merchant moderates from the admin surface. Rate-limited to 3 submits / 60s / IP. Show a friendly "you already reviewed this" message on 409.',
5538
- sdk: "client.listProductReviews(productId), client.submitProductReview(productId, { authorName, authorEmail, rating, body }). Product.avgRating + Product.reviewCount are denormalized rollups returned on every product fetch.",
5886
+ title: "Display + collect product reviews (purchaser-only) with stars + JSON-LD",
5887
+ 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.',
5888
+ 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.",
5539
5889
  mandatory: "mandatory"
5540
5890
  },
5541
5891
  {