@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 +543 -12
- package/dist/bin/stdio.js +543 -12
- package/dist/index.js +543 -12
- package/dist/index.mjs +543 -12
- package/package.json +1 -1
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
|
|
@@ -1547,6 +1716,65 @@ The merchant can hide a group entirely for one variant by setting \`maxOverride:
|
|
|
1547
1716
|
|
|
1548
1717
|
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.`;
|
|
1549
1718
|
}
|
|
1719
|
+
function getProductReviewsSection() {
|
|
1720
|
+
return `## Product Reviews (\u2605 ratings + JSON-LD for SEO)
|
|
1721
|
+
|
|
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.
|
|
1723
|
+
|
|
1724
|
+
\`\`\`typescript
|
|
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
|
+
}
|
|
1740
|
+
|
|
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
|
|
1744
|
+
\`\`\`
|
|
1745
|
+
|
|
1746
|
+
Each \`Product\` carries denormalized rollups: \`product.avgRating\`, \`product.reviewCount\`. Use these on PLP cards.
|
|
1747
|
+
|
|
1748
|
+
### JSON-LD (mandatory for SEO stars in Google)
|
|
1749
|
+
|
|
1750
|
+
Emit \`aggregateRating\` ONLY when \`product.reviewCount > 0\`:
|
|
1751
|
+
|
|
1752
|
+
\`\`\`typescript
|
|
1753
|
+
const productJsonLd = {
|
|
1754
|
+
'@context': 'https://schema.org',
|
|
1755
|
+
'@type': 'Product',
|
|
1756
|
+
name: product.name,
|
|
1757
|
+
// \u2026existing fields
|
|
1758
|
+
...(product.reviewCount > 0 && {
|
|
1759
|
+
aggregateRating: {
|
|
1760
|
+
'@type': 'AggregateRating',
|
|
1761
|
+
ratingValue: product.avgRating,
|
|
1762
|
+
reviewCount: product.reviewCount,
|
|
1763
|
+
bestRating: 5,
|
|
1764
|
+
worstRating: 1,
|
|
1765
|
+
},
|
|
1766
|
+
}),
|
|
1767
|
+
};
|
|
1768
|
+
\`\`\`
|
|
1769
|
+
|
|
1770
|
+
### Rules
|
|
1771
|
+
- Rating must be an integer 1-5 (DB constraint).
|
|
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.
|
|
1776
|
+
- Stores with \`reviewsEnabled = false\` return 403 on storefront endpoints.`;
|
|
1777
|
+
}
|
|
1550
1778
|
function getInventorySection() {
|
|
1551
1779
|
return `## Inventory, Stock Display & Reservation Countdown
|
|
1552
1780
|
|
|
@@ -2493,6 +2721,8 @@ function getSectionByTopic(topic, connectionId, currency) {
|
|
|
2493
2721
|
return getDiscountsSection();
|
|
2494
2722
|
case "recommendations":
|
|
2495
2723
|
return getRecommendationsSection();
|
|
2724
|
+
case "reviews":
|
|
2725
|
+
return getProductReviewsSection();
|
|
2496
2726
|
case "product-customization-fields":
|
|
2497
2727
|
return getProductCustomizationFieldsSection();
|
|
2498
2728
|
case "modifier-groups":
|
|
@@ -2565,6 +2795,10 @@ function getSectionByTopic(topic, connectionId, currency) {
|
|
|
2565
2795
|
"",
|
|
2566
2796
|
"---",
|
|
2567
2797
|
"",
|
|
2798
|
+
getProductReviewsSection(),
|
|
2799
|
+
"",
|
|
2800
|
+
"---",
|
|
2801
|
+
"",
|
|
2568
2802
|
getDiscountsSection(),
|
|
2569
2803
|
"",
|
|
2570
2804
|
"---",
|
|
@@ -2616,6 +2850,7 @@ var GET_SDK_DOCS_SCHEMA = {
|
|
|
2616
2850
|
"inventory",
|
|
2617
2851
|
"discounts",
|
|
2618
2852
|
"recommendations",
|
|
2853
|
+
"reviews",
|
|
2619
2854
|
"product-customization-fields",
|
|
2620
2855
|
"tax",
|
|
2621
2856
|
"critical-rules",
|
|
@@ -2633,9 +2868,7 @@ var GET_SDK_DOCS_SCHEMA = {
|
|
|
2633
2868
|
async function handleGetSdkDocs(args) {
|
|
2634
2869
|
const id = args.salesChannelId ?? args.connectionId;
|
|
2635
2870
|
if (!args.salesChannelId && args.connectionId) {
|
|
2636
|
-
console.warn(
|
|
2637
|
-
"get-sdk-docs: `connectionId` is deprecated \u2014 use `salesChannelId` instead"
|
|
2638
|
-
);
|
|
2871
|
+
console.warn("get-sdk-docs: `connectionId` is deprecated \u2014 use `salesChannelId` instead");
|
|
2639
2872
|
}
|
|
2640
2873
|
const content = getSectionByTopic(args.topic, id, args.currency);
|
|
2641
2874
|
return {
|
|
@@ -3347,6 +3580,42 @@ interface CartBundleOffer {
|
|
|
3347
3580
|
totalOriginalPrice: string;
|
|
3348
3581
|
totalDiscountedPrice: string;
|
|
3349
3582
|
}`;
|
|
3583
|
+
var REVIEWS_TYPES = `// ---- Product Reviews ----
|
|
3584
|
+
|
|
3585
|
+
interface ProductReview {
|
|
3586
|
+
id: string;
|
|
3587
|
+
productId: string;
|
|
3588
|
+
authorName: string; // max 100 chars
|
|
3589
|
+
rating: number; // integer 1-5
|
|
3590
|
+
body: string | null; // plain text, max 5000 chars
|
|
3591
|
+
verifiedPurchase: boolean; // derived server-side from DELIVERED orders
|
|
3592
|
+
hiddenAt?: string | null; // ISO datetime; admin responses only \u2014 null = visible
|
|
3593
|
+
createdAt: string; // ISO datetime
|
|
3594
|
+
}
|
|
3595
|
+
|
|
3596
|
+
interface ProductReviewAdmin extends ProductReview {
|
|
3597
|
+
customerId: string | null;
|
|
3598
|
+
authorEmail: string | null;
|
|
3599
|
+
orderId: string | null;
|
|
3600
|
+
updatedAt: string;
|
|
3601
|
+
}
|
|
3602
|
+
|
|
3603
|
+
interface WriteProductReviewInput {
|
|
3604
|
+
rating: number; // 1-5
|
|
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;
|
|
3613
|
+
}
|
|
3614
|
+
|
|
3615
|
+
// Each Product carries denormalized review stats:
|
|
3616
|
+
// product.avgRating: number // 0 when reviewCount is 0
|
|
3617
|
+
// product.reviewCount: number // count of visible (hiddenAt IS NULL) reviews
|
|
3618
|
+
`;
|
|
3350
3619
|
var INQUIRIES_TYPES = `// ---- Contact Inquiries & Forms ----
|
|
3351
3620
|
|
|
3352
3621
|
// Two shapes \u2014 legacy and flexible. The two may be mixed; \`fields\` wins on key collision.
|
|
@@ -3590,6 +3859,7 @@ var TYPES_BY_DOMAIN = {
|
|
|
3590
3859
|
payments: PAYMENTS_TYPES,
|
|
3591
3860
|
helpers: HELPERS_TYPES,
|
|
3592
3861
|
inquiries: INQUIRIES_TYPES,
|
|
3862
|
+
reviews: REVIEWS_TYPES,
|
|
3593
3863
|
"modifier-groups": MODIFIER_GROUPS_TYPES
|
|
3594
3864
|
};
|
|
3595
3865
|
function getTypesByDomain(domain) {
|
|
@@ -3620,6 +3890,7 @@ var GET_TYPE_DEFINITIONS_SCHEMA = {
|
|
|
3620
3890
|
"payments",
|
|
3621
3891
|
"helpers",
|
|
3622
3892
|
"inquiries",
|
|
3893
|
+
"reviews",
|
|
3623
3894
|
"all"
|
|
3624
3895
|
]).describe(
|
|
3625
3896
|
'The domain of types to retrieve. Use "helpers" for helper function signatures and common types like StoreInfo, PaginatedResponse.'
|
|
@@ -3641,6 +3912,7 @@ var GET_CODE_EXAMPLE_SCHEMA = {
|
|
|
3641
3912
|
"sdk-init",
|
|
3642
3913
|
"cart-read-and-mutate",
|
|
3643
3914
|
"variant-selection",
|
|
3915
|
+
"variant-selector-swatches",
|
|
3644
3916
|
"checkout-address-and-shipping",
|
|
3645
3917
|
"checkout-payment-providers",
|
|
3646
3918
|
"checkout-stripe-confirm",
|
|
@@ -3710,28 +3982,148 @@ for (const item of cart.items) {
|
|
|
3710
3982
|
const unitPrice = parseFloat(item.unitPrice); // prices are strings!
|
|
3711
3983
|
}`,
|
|
3712
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.
|
|
3713
3987
|
import { getVariantPrice, getStockStatus, getVariantOptions } from 'brainerce';
|
|
3714
3988
|
import type { Product, ProductVariant } from 'brainerce';
|
|
3715
3989
|
|
|
3716
3990
|
function selectVariant(product: Product, selections: Record<string, string>) {
|
|
3717
|
-
// selections = {
|
|
3718
|
-
const variant = product.variants
|
|
3719
|
-
|
|
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)
|
|
3720
3994
|
);
|
|
3721
3995
|
if (!variant) return null;
|
|
3722
3996
|
|
|
3723
|
-
const price = getVariantPrice(
|
|
3724
|
-
const stock = getStockStatus(variant); // '
|
|
3725
|
-
const canPurchase =
|
|
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;
|
|
3726
4000
|
|
|
3727
4001
|
// Switch the main product image to the variant's image if it has one
|
|
3728
|
-
const image = variant.image ?? product.images[0];
|
|
4002
|
+
const image = variant.image ?? product.images?.[0];
|
|
3729
4003
|
|
|
3730
4004
|
return { variant, price, stock, canPurchase, image };
|
|
3731
4005
|
}
|
|
3732
4006
|
|
|
3733
|
-
// Build the option picker
|
|
3734
|
-
//
|
|
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.`,
|
|
3735
4127
|
"checkout-address-and-shipping": `// Step 1-3 of the checkout flow. Never reorder or skip.
|
|
3736
4128
|
import { client } from './brainerce';
|
|
3737
4129
|
|
|
@@ -4523,6 +4915,138 @@ function renderCartItem(item: CartItem): string {
|
|
|
4523
4915
|
// multiply unitPrice \xD7 quantity directly.
|
|
4524
4916
|
function lineTotal(item: CartItem): number {
|
|
4525
4917
|
return item.unitPrice * item.quantity;
|
|
4918
|
+
}`,
|
|
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.
|
|
4927
|
+
export async function ProductReviewsSection({ product }: { product: Product }) {
|
|
4928
|
+
const { data } = await getServerClient().listProductReviews(product.id, { page: 1, limit: 20 });
|
|
4929
|
+
|
|
4930
|
+
return (
|
|
4931
|
+
<section>
|
|
4932
|
+
<h2>Reviews ({product.reviewCount ?? 0})</h2>
|
|
4933
|
+
{data.length === 0 && <p>No reviews yet.</p>}
|
|
4934
|
+
{data.map((r) => (
|
|
4935
|
+
<article key={r.id}>
|
|
4936
|
+
<strong>{r.authorName}</strong>
|
|
4937
|
+
{r.verifiedPurchase && <span> \xB7 Verified purchase</span>}
|
|
4938
|
+
<div>{'\u2605'.repeat(r.rating)}{'\u2606'.repeat(5 - r.rating)}</div>
|
|
4939
|
+
{r.body && <p>{r.body}</p>}
|
|
4940
|
+
</article>
|
|
4941
|
+
))}
|
|
4942
|
+
<ReviewForm productId={product.id} />
|
|
4943
|
+
</section>
|
|
4944
|
+
);
|
|
4945
|
+
}
|
|
4946
|
+
|
|
4947
|
+
// JSON-LD \u2014 emit aggregateRating only when there is data
|
|
4948
|
+
function productJsonLd(product: Product, url: string) {
|
|
4949
|
+
return {
|
|
4950
|
+
'@context': 'https://schema.org',
|
|
4951
|
+
'@type': 'Product',
|
|
4952
|
+
name: product.name,
|
|
4953
|
+
image: product.images?.[0]?.url,
|
|
4954
|
+
url,
|
|
4955
|
+
sku: product.sku ?? product.id,
|
|
4956
|
+
...(product.reviewCount && product.reviewCount > 0 && {
|
|
4957
|
+
aggregateRating: {
|
|
4958
|
+
'@type': 'AggregateRating',
|
|
4959
|
+
ratingValue: product.avgRating,
|
|
4960
|
+
reviewCount: product.reviewCount,
|
|
4961
|
+
bestRating: 5,
|
|
4962
|
+
worstRating: 1,
|
|
4963
|
+
},
|
|
4964
|
+
}),
|
|
4965
|
+
};
|
|
4966
|
+
}
|
|
4967
|
+
|
|
4968
|
+
// Client-side form
|
|
4969
|
+
'use client';
|
|
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 };
|
|
4978
|
+
|
|
4979
|
+
function ReviewForm({ productId }: { productId: string }) {
|
|
4980
|
+
const [stage, setStage] = useState<Stage>({ kind: 'loading' });
|
|
4981
|
+
const [rating, setRating] = useState(5);
|
|
4982
|
+
const [body, setBody] = useState('');
|
|
4983
|
+
const [error, setError] = useState<string | null>(null);
|
|
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
|
+
|
|
5006
|
+
async function submit(e: React.FormEvent) {
|
|
5007
|
+
e.preventDefault();
|
|
5008
|
+
setError(null);
|
|
5009
|
+
const client = getClient();
|
|
5010
|
+
const input = { rating, body: body || undefined };
|
|
5011
|
+
try {
|
|
5012
|
+
if (stage.kind === 'edit') {
|
|
5013
|
+
await client.updateMyProductReview(productId, input);
|
|
5014
|
+
} else {
|
|
5015
|
+
await client.submitProductReview(productId, input);
|
|
5016
|
+
}
|
|
5017
|
+
window.location.reload();
|
|
5018
|
+
} catch (err: any) {
|
|
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.');
|
|
5021
|
+
else if (err?.status === 429) setError('Too many submissions. Please wait a moment.');
|
|
5022
|
+
else setError('Could not save your review. Please try again.');
|
|
5023
|
+
}
|
|
5024
|
+
}
|
|
5025
|
+
|
|
5026
|
+
async function remove() {
|
|
5027
|
+
if (!confirm('Delete your review?')) return;
|
|
5028
|
+
await getClient().deleteMyProductReview(productId);
|
|
5029
|
+
window.location.reload();
|
|
5030
|
+
}
|
|
5031
|
+
|
|
5032
|
+
return (
|
|
5033
|
+
<form onSubmit={submit}>
|
|
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
|
+
)}
|
|
5047
|
+
{error && <p role="alert">{error}</p>}
|
|
5048
|
+
</form>
|
|
5049
|
+
);
|
|
4526
5050
|
}`
|
|
4527
5051
|
};
|
|
4528
5052
|
async function handleGetCodeExample(args) {
|
|
@@ -5325,6 +5849,13 @@ var FEATURES = [
|
|
|
5325
5849
|
sdk: "client.getProductBySlug(slug). Helpers: getProductPriceInfo, getVariantPrice, getStockStatus, getVariantOptions, getProductSwatches, getDescriptionContent, getProductMetafieldValue.",
|
|
5326
5850
|
mandatory: "mandatory"
|
|
5327
5851
|
},
|
|
5852
|
+
{
|
|
5853
|
+
id: "product-reviews",
|
|
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.",
|
|
5857
|
+
mandatory: "mandatory"
|
|
5858
|
+
},
|
|
5328
5859
|
{
|
|
5329
5860
|
id: "product-customization-fields",
|
|
5330
5861
|
title: "Render buyer-input customization fields on the product page",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brainerce/mcp-server",
|
|
3
|
-
"version": "3.
|
|
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"
|