@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.mjs CHANGED
@@ -916,6 +916,175 @@ function ProductPage({ product, storeInfo }: { product: Product; storeInfo: Stor
916
916
  }
917
917
  \`\`\`
918
918
 
919
+ ### Variant Attribute Selector with Swatches (color + size **mix**)
920
+
921
+ The simple text-button picker above works, but loses the merchant-configured
922
+ **swatch metadata**. A single product often mixes one attribute that should
923
+ render as **color swatches** (e.g. \`Color\`) and another that should render
924
+ as **text buttons** (e.g. \`Size\`). The product response includes everything
925
+ you need:
926
+
927
+ - \`product.productAttributeOptions[]\` \u2014 the swatch definitions: \`attribute.name\`,
928
+ \`attribute.displayType\` (the \`AttributeDisplayType\` enum: \`DEFAULT\` | \`COLOR_SWATCH\` | \`IMAGE_SWATCH\` | \`MIXED_SWATCH\`),
929
+ \`attributeOption.name\`, \`swatchColor\` (hex), \`swatchColor2\` (hex \u2014 used for dual-color swatches
930
+ like Black & White), \`swatchImageUrl\` (for fabric / pattern thumbnails).
931
+ \`MIXED_SWATCH\` means each option in the group can independently pick image OR color
932
+ (the merchant decides per option). Treat any unrecognized value as \`DEFAULT\`.
933
+ - \`variant.attributes\` \u2014 the selected value per attribute on each variant.
934
+
935
+ Use \`getProductSwatches(product)\` to get a render-ready, grouped structure:
936
+
937
+ \`\`\`typescript
938
+ import { getProductSwatches } from 'brainerce';
939
+
940
+ const groups = getProductSwatches(product);
941
+ // [
942
+ // { attributeName: 'color', displayType: 'COLOR_SWATCH', options: [
943
+ // { name: 'Full Color', swatchColor: '#1D9435', swatchColor2: null, swatchImageUrl: null },
944
+ // { name: 'Black & White', swatchColor: '#000000', swatchColor2: '#FFFFFF', swatchImageUrl: null },
945
+ // ]},
946
+ // { attributeName: 'size', displayType: 'DEFAULT', options: [
947
+ // { name: '16" \xD7 20"' }, { name: '24" \xD7 32"' }, { name: '36" \xD7 48"' },
948
+ // ]},
949
+ // ]
950
+ \`\`\`
951
+
952
+ Render one branch per \`displayType\`. The same loop handles all-swatch, all-text,
953
+ and the mix case:
954
+
955
+ \`\`\`typescript
956
+ function VariantPicker({
957
+ product,
958
+ selections,
959
+ onSelect,
960
+ }: {
961
+ product: Product;
962
+ selections: Record<string, string>; // { color: 'Full Color', size: '24" \xD7 32"' }
963
+ onSelect: (attrName: string, value: string) => void;
964
+ }) {
965
+ const groups = getProductSwatches(product);
966
+ if (groups.length === 0) return null;
967
+
968
+ return (
969
+ <div className="space-y-4">
970
+ {groups.map((group) => (
971
+ <div key={group.attributeName}>
972
+ <label className="block mb-2 text-sm font-medium">
973
+ {group.attributeName}: <span className="text-gray-500">{selections[group.attributeName] ?? ''}</span>
974
+ </label>
975
+ <div className="flex flex-wrap gap-2">
976
+ {group.options.map((opt) => {
977
+ const isSelected = selections[group.attributeName] === opt.name;
978
+
979
+ // COLOR_SWATCH \u2014 round button; dual-color uses a 50/50 gradient.
980
+ if (group.displayType === 'COLOR_SWATCH' && opt.swatchColor) {
981
+ const bg = opt.swatchColor2
982
+ ? \`linear-gradient(135deg, \${opt.swatchColor} 50%, \${opt.swatchColor2} 50%)\`
983
+ : opt.swatchColor;
984
+ return (
985
+ <button
986
+ key={opt.name}
987
+ type="button"
988
+ title={opt.name}
989
+ aria-pressed={isSelected}
990
+ onClick={() => onSelect(group.attributeName, opt.name)}
991
+ className={\`h-9 w-9 rounded-full border-2 \${isSelected ? 'border-black ring-2 ring-black/30' : 'border-gray-300 hover:border-black'}\`}
992
+ style={{ background: bg }}
993
+ />
994
+ );
995
+ }
996
+
997
+ // IMAGE_SWATCH \u2014 square thumbnail (fabric/pattern preview).
998
+ if (group.displayType === 'IMAGE_SWATCH' && opt.swatchImageUrl) {
999
+ return (
1000
+ <button
1001
+ key={opt.name}
1002
+ type="button"
1003
+ aria-pressed={isSelected}
1004
+ onClick={() => onSelect(group.attributeName, opt.name)}
1005
+ className={\`h-10 w-10 overflow-hidden rounded border-2 \${isSelected ? 'border-black' : 'border-gray-300'}\`}
1006
+ >
1007
+ <img src={opt.swatchImageUrl} alt={opt.name} className="h-full w-full object-cover" />
1008
+ </button>
1009
+ );
1010
+ }
1011
+
1012
+ // MIXED_SWATCH \u2014 each option is independently image OR color (image wins if present).
1013
+ if (group.displayType === 'MIXED_SWATCH') {
1014
+ if (opt.swatchImageUrl) {
1015
+ return (
1016
+ <button
1017
+ key={opt.name}
1018
+ type="button"
1019
+ aria-pressed={isSelected}
1020
+ onClick={() => onSelect(group.attributeName, opt.name)}
1021
+ className={\`h-10 w-10 overflow-hidden rounded border-2 \${isSelected ? 'border-black' : 'border-gray-300'}\`}
1022
+ >
1023
+ <img src={opt.swatchImageUrl} alt={opt.name} className="h-full w-full object-cover" />
1024
+ </button>
1025
+ );
1026
+ }
1027
+ if (opt.swatchColor) {
1028
+ const bg = opt.swatchColor2
1029
+ ? \`linear-gradient(135deg, \${opt.swatchColor} 50%, \${opt.swatchColor2} 50%)\`
1030
+ : opt.swatchColor;
1031
+ return (
1032
+ <button
1033
+ key={opt.name}
1034
+ type="button"
1035
+ title={opt.name}
1036
+ aria-pressed={isSelected}
1037
+ onClick={() => onSelect(group.attributeName, opt.name)}
1038
+ className={\`h-9 w-9 rounded-full border-2 \${isSelected ? 'border-black ring-2 ring-black/30' : 'border-gray-300 hover:border-black'}\`}
1039
+ style={{ background: bg }}
1040
+ />
1041
+ );
1042
+ }
1043
+ // option has neither image nor color \u2192 fall through to text button below.
1044
+ }
1045
+
1046
+ // DEFAULT (or any swatch type missing its data) \u2014 plain text button.
1047
+ return (
1048
+ <button
1049
+ key={opt.name}
1050
+ type="button"
1051
+ aria-pressed={isSelected}
1052
+ onClick={() => onSelect(group.attributeName, opt.name)}
1053
+ className={\`rounded border px-3 py-1.5 text-sm \${isSelected ? 'bg-black text-white border-black' : 'border-gray-300 hover:border-black'}\`}
1054
+ >
1055
+ {opt.name}
1056
+ </button>
1057
+ );
1058
+ })}
1059
+ </div>
1060
+ </div>
1061
+ ))}
1062
+ </div>
1063
+ );
1064
+ }
1065
+ \`\`\`
1066
+
1067
+ Wire it up in the product page: keep \`selections\` in state, find the matching
1068
+ variant after each pick, and update price / image / stock from it.
1069
+
1070
+ \`\`\`typescript
1071
+ const [selections, setSelections] = useState<Record<string, string>>({});
1072
+
1073
+ const matchedVariant = useMemo(() => (
1074
+ product.variants?.find((v) =>
1075
+ Object.entries(selections).every(([k, val]) => v.attributes?.[k] === val)
1076
+ ) ?? null
1077
+ ), [product.variants, selections]);
1078
+
1079
+ // Greying-out unavailable combinations: for each candidate value, run
1080
+ // the same find() and check \`inventory.canPurchase\`. Disable the button
1081
+ // if no purchasable variant exists under that selection.
1082
+ \`\`\`
1083
+
1084
+ **i18n:** \`getProductSwatches\` returns translated \`attributeName\` and
1085
+ \`option.name\` automatically when \`client.setLocale()\` is active. No
1086
+ client-side overlay needed. Swatch colors are language-agnostic.
1087
+
919
1088
  ### Description Rendering (handles HTML vs text)
920
1089
 
921
1090
  \`\`\`typescript
@@ -1550,17 +1719,28 @@ Allergens (informational chips), scheduled availability windows, nested combos (
1550
1719
  function getProductReviewsSection() {
1551
1720
  return `## Product Reviews (\u2605 ratings + JSON-LD for SEO)
1552
1721
 
1553
- Reviews publish immediately on submit. Merchants hide individual reviews via the admin surface \u2014 no PENDING queue.
1722
+ **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.
1554
1723
 
1555
1724
  \`\`\`typescript
1556
- // PDP \u2014 list visible reviews
1557
- const { data, meta } = await client.listProductReviews(productId, { page: 1, limit: 20 });
1558
- data.forEach(r => render(r.authorName, r.rating, r.body, r.verifiedPurchase));
1725
+ // PDP \u2014 list visible reviews (public)
1726
+ const { data } = await getClient().listProductReviews(productId, { page: 1, limit: 20 });
1727
+
1728
+ // Decide which UI to render for the current customer
1729
+ getClient().setCustomerToken(authToken); // after login
1730
+ const { eligible, reason, myReview } = await getClient().getMyProductReview(productId);
1731
+
1732
+ if (!eligible) {
1733
+ // reason: 'no_eligible_order' | 'reviews_disabled' | 'product_not_found'
1734
+ // \u2192 "only customers who purchased this product can leave a review"
1735
+ } else if (myReview) {
1736
+ // \u2192 edit form prefilled with myReview.rating + myReview.body
1737
+ } else {
1738
+ // \u2192 submit form (rating + body only \u2014 name/email come from customer profile)
1739
+ }
1559
1740
 
1560
- // PDP \u2014 submit form
1561
- await client.submitProductReview(productId, {
1562
- authorName, authorEmail, rating: 5, body,
1563
- });
1741
+ await getClient().submitProductReview(productId, { rating: 5, body: 'Loved it!' });
1742
+ await getClient().updateMyProductReview(productId, { rating: 4, body: 'Updated.' });
1743
+ await getClient().deleteMyProductReview(productId); // can submit a new one after
1564
1744
  \`\`\`
1565
1745
 
1566
1746
  Each \`Product\` carries denormalized rollups: \`product.avgRating\`, \`product.reviewCount\`. Use these on PLP cards.
@@ -1589,9 +1769,10 @@ const productJsonLd = {
1589
1769
 
1590
1770
  ### Rules
1591
1771
  - Rating must be an integer 1-5 (DB constraint).
1592
- - Duplicate from same email \u2192 409 Conflict.
1593
- - Submit endpoint is throttled to 3 / 60s / IP \u2014 surface a friendly "please wait" on 429.
1594
- - \`verifiedPurchase\` is derived server-side from DELIVERED orders \u2014 DO NOT try to set it.
1772
+ - Customer must be logged in AND have purchased the product (\`SHIPPED+\` for physical, \`PAID+\` for downloadable).
1773
+ - One review per (productId, customerId). Duplicate submit \u2192 409 \u2014 use updateMyProductReview instead.
1774
+ - Submit throttled 3 / 60s / IP; edit/delete 5 / 60s / IP.
1775
+ - \`verifiedPurchase\` is always true for customer-submitted reviews.
1595
1776
  - Stores with \`reviewsEnabled = false\` return 403 on storefront endpoints.`;
1596
1777
  }
1597
1778
  function getInventorySection() {
@@ -2687,9 +2868,7 @@ var GET_SDK_DOCS_SCHEMA = {
2687
2868
  async function handleGetSdkDocs(args) {
2688
2869
  const id = args.salesChannelId ?? args.connectionId;
2689
2870
  if (!args.salesChannelId && args.connectionId) {
2690
- console.warn(
2691
- "get-sdk-docs: `connectionId` is deprecated \u2014 use `salesChannelId` instead"
2692
- );
2871
+ console.warn("get-sdk-docs: `connectionId` is deprecated \u2014 use `salesChannelId` instead");
2693
2872
  }
2694
2873
  const content = getSectionByTopic(args.topic, id, args.currency);
2695
2874
  return {
@@ -3421,11 +3600,16 @@ interface ProductReviewAdmin extends ProductReview {
3421
3600
  updatedAt: string;
3422
3601
  }
3423
3602
 
3424
- interface SubmitProductReviewInput {
3425
- authorName: string;
3426
- authorEmail?: string; // recommended \u2014 used for guest dedupe + verified-purchase derivation
3603
+ interface WriteProductReviewInput {
3427
3604
  rating: number; // 1-5
3428
- body?: string; // optional
3605
+ body?: string; // optional, max 5000 chars
3606
+ }
3607
+
3608
+ interface MyProductReview {
3609
+ eligible: boolean;
3610
+ // Machine-readable reason when not eligible. null when eligible=true.
3611
+ reason: 'no_eligible_order' | 'reviews_disabled' | 'product_not_found' | null;
3612
+ myReview: ProductReview | null;
3429
3613
  }
3430
3614
 
3431
3615
  // Each Product carries denormalized review stats:
@@ -3728,6 +3912,7 @@ var GET_CODE_EXAMPLE_SCHEMA = {
3728
3912
  "sdk-init",
3729
3913
  "cart-read-and-mutate",
3730
3914
  "variant-selection",
3915
+ "variant-selector-swatches",
3731
3916
  "checkout-address-and-shipping",
3732
3917
  "checkout-payment-providers",
3733
3918
  "checkout-stripe-confirm",
@@ -3797,28 +3982,148 @@ for (const item of cart.items) {
3797
3982
  const unitPrice = parseFloat(item.unitPrice); // prices are strings!
3798
3983
  }`,
3799
3984
  "variant-selection": `// Variant selection with live stock + price updates.
3985
+ // Variants expose attributes as a flat object: { color: 'Red', size: 'M' }.
3986
+ // NOT 'options' \u2014 that field does not exist on ProductVariant.
3800
3987
  import { getVariantPrice, getStockStatus, getVariantOptions } from 'brainerce';
3801
3988
  import type { Product, ProductVariant } from 'brainerce';
3802
3989
 
3803
3990
  function selectVariant(product: Product, selections: Record<string, string>) {
3804
- // selections = { Size: 'M', Color: 'Red' }
3805
- const variant = product.variants.find((v) =>
3806
- v.options.every((o) => selections[o.name] === o.value)
3991
+ // selections = { size: 'M', color: 'Red' } (keys match variant.attributes keys)
3992
+ const variant = product.variants?.find((v) =>
3993
+ Object.entries(selections).every(([k, val]) => v.attributes?.[k] === val)
3807
3994
  );
3808
3995
  if (!variant) return null;
3809
3996
 
3810
- const price = getVariantPrice(product, variant); // handles compare-at, currency
3811
- const stock = getStockStatus(variant); // 'IN_STOCK' | 'LOW_STOCK' | 'OUT_OF_STOCK'
3812
- const canPurchase = stock !== 'OUT_OF_STOCK';
3997
+ const price = getVariantPrice(variant, product.basePrice); // string, fallback-aware
3998
+ const stock = getStockStatus(variant.inventory); // 'in-stock' | 'low-stock' | 'out-of-stock' | 'unavailable'
3999
+ const canPurchase = variant.inventory?.canPurchase !== false;
3813
4000
 
3814
4001
  // Switch the main product image to the variant's image if it has one
3815
- const image = variant.image ?? product.images[0];
4002
+ const image = variant.image ?? product.images?.[0];
3816
4003
 
3817
4004
  return { variant, price, stock, canPurchase, image };
3818
4005
  }
3819
4006
 
3820
- // Build the option picker from getVariantOptions(product) \u2014 returns
3821
- // [{ name: 'Size', values: ['S','M','L'] }, ...]`,
4007
+ // Build the option picker by iterating product.variants and grouping
4008
+ // variant.attributes into { color: ['Red', 'Blue'], size: ['S','M','L'] }.
4009
+ // For each variant you can also call getVariantOptions(variant) which returns
4010
+ // the same key/value pairs as an array with translated names when setLocale() is active.
4011
+ // For swatch metadata (color hex, dual-color, image), use the
4012
+ // 'variant-selector-swatches' operation instead.`,
4013
+ "variant-selector-swatches": `// Variant picker that mixes color swatches and text buttons in one product.
4014
+ // Uses product.productAttributeOptions[] \u2014 the merchant-configured swatch
4015
+ // metadata. displayType is the AttributeDisplayType Prisma enum, one of:
4016
+ // 'DEFAULT' \u2014 plain text button
4017
+ // 'COLOR_SWATCH' \u2014 swatchColor (+ optional swatchColor2 dual-color gradient)
4018
+ // 'IMAGE_SWATCH' \u2014 swatchImageUrl thumbnail
4019
+ // 'MIXED_SWATCH' \u2014 per-option hybrid: image if swatchImageUrl is set,
4020
+ // otherwise fall back to swatchColor
4021
+ // getProductSwatches() groups options by attribute name so you can render each
4022
+ // attribute with its own displayType \u2014 a single product can have Color as
4023
+ // swatches AND Size as text buttons.
4024
+ import { getProductSwatches } from 'brainerce';
4025
+ import type { Product } from 'brainerce';
4026
+
4027
+ function VariantPicker({
4028
+ product,
4029
+ selections,
4030
+ onSelect,
4031
+ }: {
4032
+ product: Product;
4033
+ selections: Record<string, string>;
4034
+ onSelect: (attrName: string, value: string) => void;
4035
+ }) {
4036
+ const groups = getProductSwatches(product);
4037
+ // groups: [{ attributeName, displayType, options: [{ name, swatchColor, swatchColor2, swatchImageUrl }] }]
4038
+
4039
+ return (
4040
+ <>
4041
+ {groups.map((group) => (
4042
+ <div key={group.attributeName}>
4043
+ <label>{group.attributeName}: {selections[group.attributeName] ?? ''}</label>
4044
+ <div style={{ display: 'flex', gap: 8 }}>
4045
+ {group.options.map((opt) => {
4046
+ const selected = selections[group.attributeName] === opt.name;
4047
+ const onClick = () => onSelect(group.attributeName, opt.name);
4048
+
4049
+ // COLOR_SWATCH: round button. swatchColor2 = dual-color gradient.
4050
+ if (group.displayType === 'COLOR_SWATCH' && opt.swatchColor) {
4051
+ const bg = opt.swatchColor2
4052
+ ? \`linear-gradient(135deg, \${opt.swatchColor} 50%, \${opt.swatchColor2} 50%)\`
4053
+ : opt.swatchColor;
4054
+ return (
4055
+ <button
4056
+ key={opt.name}
4057
+ type="button"
4058
+ title={opt.name}
4059
+ aria-pressed={selected}
4060
+ onClick={onClick}
4061
+ style={{
4062
+ width: 36,
4063
+ height: 36,
4064
+ borderRadius: '50%',
4065
+ background: bg,
4066
+ outline: selected ? '2px solid black' : 'none',
4067
+ }}
4068
+ />
4069
+ );
4070
+ }
4071
+
4072
+ // IMAGE_SWATCH: square thumbnail.
4073
+ if (group.displayType === 'IMAGE_SWATCH' && opt.swatchImageUrl) {
4074
+ return (
4075
+ <button key={opt.name} type="button" onClick={onClick} aria-pressed={selected}>
4076
+ <img src={opt.swatchImageUrl} alt={opt.name} width={40} height={40} />
4077
+ </button>
4078
+ );
4079
+ }
4080
+
4081
+ // MIXED_SWATCH: per-option hybrid (image wins if present).
4082
+ if (group.displayType === 'MIXED_SWATCH') {
4083
+ if (opt.swatchImageUrl) {
4084
+ return (
4085
+ <button key={opt.name} type="button" onClick={onClick} aria-pressed={selected}>
4086
+ <img src={opt.swatchImageUrl} alt={opt.name} width={40} height={40} />
4087
+ </button>
4088
+ );
4089
+ }
4090
+ if (opt.swatchColor) {
4091
+ const bg = opt.swatchColor2
4092
+ ? \`linear-gradient(135deg, \${opt.swatchColor} 50%, \${opt.swatchColor2} 50%)\`
4093
+ : opt.swatchColor;
4094
+ return (
4095
+ <button
4096
+ key={opt.name}
4097
+ type="button"
4098
+ title={opt.name}
4099
+ aria-pressed={selected}
4100
+ onClick={onClick}
4101
+ style={{ width: 36, height: 36, borderRadius: '50%', background: bg }}
4102
+ />
4103
+ );
4104
+ }
4105
+ }
4106
+
4107
+ // DEFAULT (or any swatch type missing its data): text button.
4108
+ return (
4109
+ <button key={opt.name} type="button" onClick={onClick} aria-pressed={selected}>
4110
+ {opt.name}
4111
+ </button>
4112
+ );
4113
+ })}
4114
+ </div>
4115
+ </div>
4116
+ ))}
4117
+ </>
4118
+ );
4119
+ }
4120
+
4121
+ // After the user picks a value, find the matching ProductVariant:
4122
+ // product.variants?.find((v) =>
4123
+ // Object.entries(selections).every(([k, val]) => v.attributes?.[k] === val)
4124
+ // )
4125
+ // Then read variant.price, variant.image, variant.inventory.canPurchase
4126
+ // to update the rest of the PDP.`,
3822
4127
  "checkout-address-and-shipping": `// Step 1-3 of the checkout flow. Never reorder or skip.
3823
4128
  import { client } from './brainerce';
3824
4129
 
@@ -4611,21 +4916,25 @@ function renderCartItem(item: CartItem): string {
4611
4916
  function lineTotal(item: CartItem): number {
4612
4917
  return item.unitPrice * item.quantity;
4613
4918
  }`,
4614
- "product-reviews": `// PDP: list visible reviews + submit form + JSON-LD aggregateRating
4615
- import { client } from '@/lib/brainerce';
4616
- import type { Product, ProductReview, SubmitProductReviewInput } from 'brainerce';
4617
-
4919
+ "product-reviews": `// Purchaser-only reviews. Customer must be logged in AND have purchased
4920
+ // the product. The component switches between sign-in CTA / not-eligible /
4921
+ // submit form / edit form based on getMyProductReview().
4922
+ import { getClient, getServerClient } from '@/lib/brainerce';
4923
+ import { checkAuthStatus } from '@/lib/auth';
4924
+ import type { Product, ProductReview, MyProductReview } from 'brainerce';
4925
+
4926
+ // Server component \u2014 fetches existing reviews + emits JSON-LD aggregateRating.
4618
4927
  export async function ProductReviewsSection({ product }: { product: Product }) {
4619
- const { data } = await client.listProductReviews(product.id, { page: 1, limit: 20 });
4928
+ const { data } = await getServerClient().listProductReviews(product.id, { page: 1, limit: 20 });
4620
4929
 
4621
4930
  return (
4622
4931
  <section>
4623
4932
  <h2>Reviews ({product.reviewCount ?? 0})</h2>
4624
- {data.length === 0 && <p>No reviews yet \u2014 be the first.</p>}
4933
+ {data.length === 0 && <p>No reviews yet.</p>}
4625
4934
  {data.map((r) => (
4626
4935
  <article key={r.id}>
4627
4936
  <strong>{r.authorName}</strong>
4628
- {r.verifiedPurchase && <span> \xB7 Verified</span>}
4937
+ {r.verifiedPurchase && <span> \xB7 Verified purchase</span>}
4629
4938
  <div>{'\u2605'.repeat(r.rating)}{'\u2606'.repeat(5 - r.rating)}</div>
4630
4939
  {r.body && <p>{r.body}</p>}
4631
4940
  </article>
@@ -4658,42 +4967,83 @@ function productJsonLd(product: Product, url: string) {
4658
4967
 
4659
4968
  // Client-side form
4660
4969
  'use client';
4661
- import { useState } from 'react';
4970
+ import { useEffect, useState } from 'react';
4971
+
4972
+ type Stage =
4973
+ | { kind: 'loading' }
4974
+ | { kind: 'signed_out' }
4975
+ | { kind: 'not_eligible'; reason: MyProductReview['reason'] }
4976
+ | { kind: 'submit' }
4977
+ | { kind: 'edit'; review: ProductReview };
4662
4978
 
4663
4979
  function ReviewForm({ productId }: { productId: string }) {
4980
+ const [stage, setStage] = useState<Stage>({ kind: 'loading' });
4664
4981
  const [rating, setRating] = useState(5);
4665
4982
  const [body, setBody] = useState('');
4666
- const [name, setName] = useState('');
4667
- const [email, setEmail] = useState('');
4668
4983
  const [error, setError] = useState<string | null>(null);
4669
4984
 
4985
+ useEffect(() => {
4986
+ (async () => {
4987
+ const auth = await checkAuthStatus();
4988
+ if (!auth.isLoggedIn) return setStage({ kind: 'signed_out' });
4989
+ const me = await getClient().getMyProductReview(productId);
4990
+ if (!me.eligible) return setStage({ kind: 'not_eligible', reason: me.reason });
4991
+ if (me.myReview) {
4992
+ setRating(me.myReview.rating);
4993
+ setBody(me.myReview.body ?? '');
4994
+ return setStage({ kind: 'edit', review: me.myReview });
4995
+ }
4996
+ setStage({ kind: 'submit' });
4997
+ })();
4998
+ }, [productId]);
4999
+
5000
+ if (stage.kind === 'loading') return <p>Loading\u2026</p>;
5001
+ if (stage.kind === 'signed_out')
5002
+ return <p><a href="/account/login">Sign in</a> to leave a review.</p>;
5003
+ if (stage.kind === 'not_eligible')
5004
+ return <p>Only customers who purchased this product can leave a review.</p>;
5005
+
4670
5006
  async function submit(e: React.FormEvent) {
4671
5007
  e.preventDefault();
4672
5008
  setError(null);
5009
+ const client = getClient();
5010
+ const input = { rating, body: body || undefined };
4673
5011
  try {
4674
- await client.submitProductReview(productId, {
4675
- authorName: name,
4676
- authorEmail: email,
4677
- rating,
4678
- body: body || undefined,
4679
- });
5012
+ if (stage.kind === 'edit') {
5013
+ await client.updateMyProductReview(productId, input);
5014
+ } else {
5015
+ await client.submitProductReview(productId, input);
5016
+ }
4680
5017
  window.location.reload();
4681
5018
  } catch (err: any) {
4682
- if (err?.status === 409) setError("You've already reviewed this product.");
5019
+ if (err?.status === 403) setError('Only customers who purchased this product can leave a review.');
5020
+ else if (err?.status === 409) setError('You already reviewed this product.');
4683
5021
  else if (err?.status === 429) setError('Too many submissions. Please wait a moment.');
4684
- else setError('Could not submit review. Please try again.');
5022
+ else setError('Could not save your review. Please try again.');
4685
5023
  }
4686
5024
  }
4687
5025
 
5026
+ async function remove() {
5027
+ if (!confirm('Delete your review?')) return;
5028
+ await getClient().deleteMyProductReview(productId);
5029
+ window.location.reload();
5030
+ }
5031
+
4688
5032
  return (
4689
5033
  <form onSubmit={submit}>
4690
- <input value={name} onChange={(e) => setName(e.target.value)} placeholder="Your name" required maxLength={100} />
4691
- <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" />
4692
- <select value={rating} onChange={(e) => setRating(Number(e.target.value))}>
4693
- {[5,4,3,2,1].map(n => <option key={n} value={n}>{'\u2605'.repeat(n)}</option>)}
4694
- </select>
4695
- <textarea value={body} onChange={(e) => setBody(e.target.value)} maxLength={5000} placeholder="Tell us what you think (optional)" />
4696
- <button type="submit">Submit review</button>
5034
+ <h3>{stage.kind === 'edit' ? 'Edit your review' : 'Write a review'}</h3>
5035
+ <div>
5036
+ {[1,2,3,4,5].map(n => (
5037
+ <button type="button" key={n} onClick={() => setRating(n)}>
5038
+ {n <= rating ? '\u2605' : '\u2606'}
5039
+ </button>
5040
+ ))}
5041
+ </div>
5042
+ <textarea value={body} onChange={(e) => setBody(e.target.value)} maxLength={5000} />
5043
+ <button type="submit">{stage.kind === 'edit' ? 'Save changes' : 'Submit review'}</button>
5044
+ {stage.kind === 'edit' && (
5045
+ <button type="button" onClick={remove}>Delete review</button>
5046
+ )}
4697
5047
  {error && <p role="alert">{error}</p>}
4698
5048
  </form>
4699
5049
  );
@@ -5501,9 +5851,9 @@ var FEATURES = [
5501
5851
  },
5502
5852
  {
5503
5853
  id: "product-reviews",
5504
- title: "Display + collect product reviews with stars + JSON-LD",
5505
- 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.',
5506
- sdk: "client.listProductReviews(productId), client.submitProductReview(productId, { authorName, authorEmail, rating, body }). Product.avgRating + Product.reviewCount are denormalized rollups returned on every product fetch.",
5854
+ title: "Display + collect product reviews (purchaser-only) with stars + JSON-LD",
5855
+ 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.',
5856
+ 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.",
5507
5857
  mandatory: "mandatory"
5508
5858
  },
5509
5859
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainerce/mcp-server",
3
- "version": "3.6.0",
3
+ "version": "3.7.0",
4
4
  "description": "Framework-agnostic domain knowledge API for Brainerce. Provides SDK docs, types, business flows, critical rules, and store capabilities to AI tools like Lovable, Cursor, Bolt, v0, and Claude Code. Does NOT provide framework-specific boilerplate — clients build in whatever framework fits.",
5
5
  "bin": {
6
6
  "brainerce-mcp": "dist/bin/stdio.js"