@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/bin/http.js +405 -55
- package/dist/bin/stdio.js +405 -55
- package/dist/index.js +405 -55
- package/dist/index.mjs +405 -55
- package/package.json +1 -1
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
|
|
@@ -1558,17 +1727,28 @@ Allergens (informational chips), scheduled availability windows, nested combos (
|
|
|
1558
1727
|
function getProductReviewsSection() {
|
|
1559
1728
|
return `## Product Reviews (\u2605 ratings + JSON-LD for SEO)
|
|
1560
1729
|
|
|
1561
|
-
|
|
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.
|
|
1562
1731
|
|
|
1563
1732
|
\`\`\`typescript
|
|
1564
|
-
// PDP \u2014 list visible reviews
|
|
1565
|
-
const { data
|
|
1566
|
-
|
|
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
|
+
}
|
|
1567
1748
|
|
|
1568
|
-
|
|
1569
|
-
await
|
|
1570
|
-
|
|
1571
|
-
});
|
|
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
|
|
1572
1752
|
\`\`\`
|
|
1573
1753
|
|
|
1574
1754
|
Each \`Product\` carries denormalized rollups: \`product.avgRating\`, \`product.reviewCount\`. Use these on PLP cards.
|
|
@@ -1597,9 +1777,10 @@ const productJsonLd = {
|
|
|
1597
1777
|
|
|
1598
1778
|
### Rules
|
|
1599
1779
|
- Rating must be an integer 1-5 (DB constraint).
|
|
1600
|
-
-
|
|
1601
|
-
-
|
|
1602
|
-
-
|
|
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.
|
|
1603
1784
|
- Stores with \`reviewsEnabled = false\` return 403 on storefront endpoints.`;
|
|
1604
1785
|
}
|
|
1605
1786
|
function getInventorySection() {
|
|
@@ -2695,9 +2876,7 @@ var GET_SDK_DOCS_SCHEMA = {
|
|
|
2695
2876
|
async function handleGetSdkDocs(args) {
|
|
2696
2877
|
const id = args.salesChannelId ?? args.connectionId;
|
|
2697
2878
|
if (!args.salesChannelId && args.connectionId) {
|
|
2698
|
-
console.warn(
|
|
2699
|
-
"get-sdk-docs: `connectionId` is deprecated \u2014 use `salesChannelId` instead"
|
|
2700
|
-
);
|
|
2879
|
+
console.warn("get-sdk-docs: `connectionId` is deprecated \u2014 use `salesChannelId` instead");
|
|
2701
2880
|
}
|
|
2702
2881
|
const content = getSectionByTopic(args.topic, id, args.currency);
|
|
2703
2882
|
return {
|
|
@@ -3429,11 +3608,16 @@ interface ProductReviewAdmin extends ProductReview {
|
|
|
3429
3608
|
updatedAt: string;
|
|
3430
3609
|
}
|
|
3431
3610
|
|
|
3432
|
-
interface
|
|
3433
|
-
authorName: string;
|
|
3434
|
-
authorEmail?: string; // recommended \u2014 used for guest dedupe + verified-purchase derivation
|
|
3611
|
+
interface WriteProductReviewInput {
|
|
3435
3612
|
rating: number; // 1-5
|
|
3436
|
-
body?: string; // optional
|
|
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;
|
|
3437
3621
|
}
|
|
3438
3622
|
|
|
3439
3623
|
// Each Product carries denormalized review stats:
|
|
@@ -3736,6 +3920,7 @@ var GET_CODE_EXAMPLE_SCHEMA = {
|
|
|
3736
3920
|
"sdk-init",
|
|
3737
3921
|
"cart-read-and-mutate",
|
|
3738
3922
|
"variant-selection",
|
|
3923
|
+
"variant-selector-swatches",
|
|
3739
3924
|
"checkout-address-and-shipping",
|
|
3740
3925
|
"checkout-payment-providers",
|
|
3741
3926
|
"checkout-stripe-confirm",
|
|
@@ -3805,28 +3990,148 @@ for (const item of cart.items) {
|
|
|
3805
3990
|
const unitPrice = parseFloat(item.unitPrice); // prices are strings!
|
|
3806
3991
|
}`,
|
|
3807
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.
|
|
3808
3995
|
import { getVariantPrice, getStockStatus, getVariantOptions } from 'brainerce';
|
|
3809
3996
|
import type { Product, ProductVariant } from 'brainerce';
|
|
3810
3997
|
|
|
3811
3998
|
function selectVariant(product: Product, selections: Record<string, string>) {
|
|
3812
|
-
// selections = {
|
|
3813
|
-
const variant = product.variants
|
|
3814
|
-
|
|
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)
|
|
3815
4002
|
);
|
|
3816
4003
|
if (!variant) return null;
|
|
3817
4004
|
|
|
3818
|
-
const price = getVariantPrice(
|
|
3819
|
-
const stock = getStockStatus(variant); // '
|
|
3820
|
-
const canPurchase =
|
|
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;
|
|
3821
4008
|
|
|
3822
4009
|
// Switch the main product image to the variant's image if it has one
|
|
3823
|
-
const image = variant.image ?? product.images[0];
|
|
4010
|
+
const image = variant.image ?? product.images?.[0];
|
|
3824
4011
|
|
|
3825
4012
|
return { variant, price, stock, canPurchase, image };
|
|
3826
4013
|
}
|
|
3827
4014
|
|
|
3828
|
-
// Build the option picker
|
|
3829
|
-
//
|
|
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.`,
|
|
3830
4135
|
"checkout-address-and-shipping": `// Step 1-3 of the checkout flow. Never reorder or skip.
|
|
3831
4136
|
import { client } from './brainerce';
|
|
3832
4137
|
|
|
@@ -4619,21 +4924,25 @@ function renderCartItem(item: CartItem): string {
|
|
|
4619
4924
|
function lineTotal(item: CartItem): number {
|
|
4620
4925
|
return item.unitPrice * item.quantity;
|
|
4621
4926
|
}`,
|
|
4622
|
-
"product-reviews": `//
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
|
|
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.
|
|
4626
4935
|
export async function ProductReviewsSection({ product }: { product: Product }) {
|
|
4627
|
-
const { data } = await
|
|
4936
|
+
const { data } = await getServerClient().listProductReviews(product.id, { page: 1, limit: 20 });
|
|
4628
4937
|
|
|
4629
4938
|
return (
|
|
4630
4939
|
<section>
|
|
4631
4940
|
<h2>Reviews ({product.reviewCount ?? 0})</h2>
|
|
4632
|
-
{data.length === 0 && <p>No reviews yet
|
|
4941
|
+
{data.length === 0 && <p>No reviews yet.</p>}
|
|
4633
4942
|
{data.map((r) => (
|
|
4634
4943
|
<article key={r.id}>
|
|
4635
4944
|
<strong>{r.authorName}</strong>
|
|
4636
|
-
{r.verifiedPurchase && <span> \xB7 Verified</span>}
|
|
4945
|
+
{r.verifiedPurchase && <span> \xB7 Verified purchase</span>}
|
|
4637
4946
|
<div>{'\u2605'.repeat(r.rating)}{'\u2606'.repeat(5 - r.rating)}</div>
|
|
4638
4947
|
{r.body && <p>{r.body}</p>}
|
|
4639
4948
|
</article>
|
|
@@ -4666,42 +4975,83 @@ function productJsonLd(product: Product, url: string) {
|
|
|
4666
4975
|
|
|
4667
4976
|
// Client-side form
|
|
4668
4977
|
'use client';
|
|
4669
|
-
import { useState } from 'react';
|
|
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 };
|
|
4670
4986
|
|
|
4671
4987
|
function ReviewForm({ productId }: { productId: string }) {
|
|
4988
|
+
const [stage, setStage] = useState<Stage>({ kind: 'loading' });
|
|
4672
4989
|
const [rating, setRating] = useState(5);
|
|
4673
4990
|
const [body, setBody] = useState('');
|
|
4674
|
-
const [name, setName] = useState('');
|
|
4675
|
-
const [email, setEmail] = useState('');
|
|
4676
4991
|
const [error, setError] = useState<string | null>(null);
|
|
4677
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
|
+
|
|
4678
5014
|
async function submit(e: React.FormEvent) {
|
|
4679
5015
|
e.preventDefault();
|
|
4680
5016
|
setError(null);
|
|
5017
|
+
const client = getClient();
|
|
5018
|
+
const input = { rating, body: body || undefined };
|
|
4681
5019
|
try {
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
});
|
|
5020
|
+
if (stage.kind === 'edit') {
|
|
5021
|
+
await client.updateMyProductReview(productId, input);
|
|
5022
|
+
} else {
|
|
5023
|
+
await client.submitProductReview(productId, input);
|
|
5024
|
+
}
|
|
4688
5025
|
window.location.reload();
|
|
4689
5026
|
} catch (err: any) {
|
|
4690
|
-
if (err?.status ===
|
|
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.');
|
|
4691
5029
|
else if (err?.status === 429) setError('Too many submissions. Please wait a moment.');
|
|
4692
|
-
else setError('Could not
|
|
5030
|
+
else setError('Could not save your review. Please try again.');
|
|
4693
5031
|
}
|
|
4694
5032
|
}
|
|
4695
5033
|
|
|
5034
|
+
async function remove() {
|
|
5035
|
+
if (!confirm('Delete your review?')) return;
|
|
5036
|
+
await getClient().deleteMyProductReview(productId);
|
|
5037
|
+
window.location.reload();
|
|
5038
|
+
}
|
|
5039
|
+
|
|
4696
5040
|
return (
|
|
4697
5041
|
<form onSubmit={submit}>
|
|
4698
|
-
<
|
|
4699
|
-
<
|
|
4700
|
-
|
|
4701
|
-
|
|
4702
|
-
|
|
4703
|
-
|
|
4704
|
-
|
|
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
|
+
)}
|
|
4705
5055
|
{error && <p role="alert">{error}</p>}
|
|
4706
5056
|
</form>
|
|
4707
5057
|
);
|
|
@@ -5509,9 +5859,9 @@ var FEATURES = [
|
|
|
5509
5859
|
},
|
|
5510
5860
|
{
|
|
5511
5861
|
id: "product-reviews",
|
|
5512
|
-
title: "Display + collect product reviews with stars + JSON-LD",
|
|
5513
|
-
description: 'On the PDP show a Reviews section: list of customer reviews with star rating, author name, verified-purchase badge, body, and date.
|
|
5514
|
-
sdk: "client.listProductReviews(productId)
|
|
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.",
|
|
5515
5865
|
mandatory: "mandatory"
|
|
5516
5866
|
},
|
|
5517
5867
|
{
|