@brainerce/mcp-server 3.5.1 → 3.6.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 +181 -0
- package/dist/bin/stdio.js +181 -0
- package/dist/index.js +181 -0
- package/dist/index.mjs +181 -0
- package/package.json +1 -1
package/dist/bin/http.js
CHANGED
|
@@ -1555,6 +1555,53 @@ The merchant can hide a group entirely for one variant by setting \`maxOverride:
|
|
|
1555
1555
|
|
|
1556
1556
|
Allergens (informational chips), scheduled availability windows, nested combos (depth \u2264 3 via \`nestedByModifierId\`), and downsell modifiers (negative \`priceDelta\`) are all built on the same data model. See INTEGRATION-OPTIONAL.md "Restaurant / build-your-own products" for those flows.`;
|
|
1557
1557
|
}
|
|
1558
|
+
function getProductReviewsSection() {
|
|
1559
|
+
return `## Product Reviews (\u2605 ratings + JSON-LD for SEO)
|
|
1560
|
+
|
|
1561
|
+
Reviews publish immediately on submit. Merchants hide individual reviews via the admin surface \u2014 no PENDING queue.
|
|
1562
|
+
|
|
1563
|
+
\`\`\`typescript
|
|
1564
|
+
// PDP \u2014 list visible reviews
|
|
1565
|
+
const { data, meta } = await client.listProductReviews(productId, { page: 1, limit: 20 });
|
|
1566
|
+
data.forEach(r => render(r.authorName, r.rating, r.body, r.verifiedPurchase));
|
|
1567
|
+
|
|
1568
|
+
// PDP \u2014 submit form
|
|
1569
|
+
await client.submitProductReview(productId, {
|
|
1570
|
+
authorName, authorEmail, rating: 5, body,
|
|
1571
|
+
});
|
|
1572
|
+
\`\`\`
|
|
1573
|
+
|
|
1574
|
+
Each \`Product\` carries denormalized rollups: \`product.avgRating\`, \`product.reviewCount\`. Use these on PLP cards.
|
|
1575
|
+
|
|
1576
|
+
### JSON-LD (mandatory for SEO stars in Google)
|
|
1577
|
+
|
|
1578
|
+
Emit \`aggregateRating\` ONLY when \`product.reviewCount > 0\`:
|
|
1579
|
+
|
|
1580
|
+
\`\`\`typescript
|
|
1581
|
+
const productJsonLd = {
|
|
1582
|
+
'@context': 'https://schema.org',
|
|
1583
|
+
'@type': 'Product',
|
|
1584
|
+
name: product.name,
|
|
1585
|
+
// \u2026existing fields
|
|
1586
|
+
...(product.reviewCount > 0 && {
|
|
1587
|
+
aggregateRating: {
|
|
1588
|
+
'@type': 'AggregateRating',
|
|
1589
|
+
ratingValue: product.avgRating,
|
|
1590
|
+
reviewCount: product.reviewCount,
|
|
1591
|
+
bestRating: 5,
|
|
1592
|
+
worstRating: 1,
|
|
1593
|
+
},
|
|
1594
|
+
}),
|
|
1595
|
+
};
|
|
1596
|
+
\`\`\`
|
|
1597
|
+
|
|
1598
|
+
### Rules
|
|
1599
|
+
- Rating must be an integer 1-5 (DB constraint).
|
|
1600
|
+
- Duplicate from same email \u2192 409 Conflict.
|
|
1601
|
+
- Submit endpoint is throttled to 3 / 60s / IP \u2014 surface a friendly "please wait" on 429.
|
|
1602
|
+
- \`verifiedPurchase\` is derived server-side from DELIVERED orders \u2014 DO NOT try to set it.
|
|
1603
|
+
- Stores with \`reviewsEnabled = false\` return 403 on storefront endpoints.`;
|
|
1604
|
+
}
|
|
1558
1605
|
function getInventorySection() {
|
|
1559
1606
|
return `## Inventory, Stock Display & Reservation Countdown
|
|
1560
1607
|
|
|
@@ -2501,6 +2548,8 @@ function getSectionByTopic(topic, connectionId, currency) {
|
|
|
2501
2548
|
return getDiscountsSection();
|
|
2502
2549
|
case "recommendations":
|
|
2503
2550
|
return getRecommendationsSection();
|
|
2551
|
+
case "reviews":
|
|
2552
|
+
return getProductReviewsSection();
|
|
2504
2553
|
case "product-customization-fields":
|
|
2505
2554
|
return getProductCustomizationFieldsSection();
|
|
2506
2555
|
case "modifier-groups":
|
|
@@ -2573,6 +2622,10 @@ function getSectionByTopic(topic, connectionId, currency) {
|
|
|
2573
2622
|
"",
|
|
2574
2623
|
"---",
|
|
2575
2624
|
"",
|
|
2625
|
+
getProductReviewsSection(),
|
|
2626
|
+
"",
|
|
2627
|
+
"---",
|
|
2628
|
+
"",
|
|
2576
2629
|
getDiscountsSection(),
|
|
2577
2630
|
"",
|
|
2578
2631
|
"---",
|
|
@@ -2624,6 +2677,7 @@ var GET_SDK_DOCS_SCHEMA = {
|
|
|
2624
2677
|
"inventory",
|
|
2625
2678
|
"discounts",
|
|
2626
2679
|
"recommendations",
|
|
2680
|
+
"reviews",
|
|
2627
2681
|
"product-customization-fields",
|
|
2628
2682
|
"tax",
|
|
2629
2683
|
"critical-rules",
|
|
@@ -3355,6 +3409,37 @@ interface CartBundleOffer {
|
|
|
3355
3409
|
totalOriginalPrice: string;
|
|
3356
3410
|
totalDiscountedPrice: string;
|
|
3357
3411
|
}`;
|
|
3412
|
+
var REVIEWS_TYPES = `// ---- Product Reviews ----
|
|
3413
|
+
|
|
3414
|
+
interface ProductReview {
|
|
3415
|
+
id: string;
|
|
3416
|
+
productId: string;
|
|
3417
|
+
authorName: string; // max 100 chars
|
|
3418
|
+
rating: number; // integer 1-5
|
|
3419
|
+
body: string | null; // plain text, max 5000 chars
|
|
3420
|
+
verifiedPurchase: boolean; // derived server-side from DELIVERED orders
|
|
3421
|
+
hiddenAt?: string | null; // ISO datetime; admin responses only \u2014 null = visible
|
|
3422
|
+
createdAt: string; // ISO datetime
|
|
3423
|
+
}
|
|
3424
|
+
|
|
3425
|
+
interface ProductReviewAdmin extends ProductReview {
|
|
3426
|
+
customerId: string | null;
|
|
3427
|
+
authorEmail: string | null;
|
|
3428
|
+
orderId: string | null;
|
|
3429
|
+
updatedAt: string;
|
|
3430
|
+
}
|
|
3431
|
+
|
|
3432
|
+
interface SubmitProductReviewInput {
|
|
3433
|
+
authorName: string;
|
|
3434
|
+
authorEmail?: string; // recommended \u2014 used for guest dedupe + verified-purchase derivation
|
|
3435
|
+
rating: number; // 1-5
|
|
3436
|
+
body?: string; // optional
|
|
3437
|
+
}
|
|
3438
|
+
|
|
3439
|
+
// Each Product carries denormalized review stats:
|
|
3440
|
+
// product.avgRating: number // 0 when reviewCount is 0
|
|
3441
|
+
// product.reviewCount: number // count of visible (hiddenAt IS NULL) reviews
|
|
3442
|
+
`;
|
|
3358
3443
|
var INQUIRIES_TYPES = `// ---- Contact Inquiries & Forms ----
|
|
3359
3444
|
|
|
3360
3445
|
// Two shapes \u2014 legacy and flexible. The two may be mixed; \`fields\` wins on key collision.
|
|
@@ -3598,6 +3683,7 @@ var TYPES_BY_DOMAIN = {
|
|
|
3598
3683
|
payments: PAYMENTS_TYPES,
|
|
3599
3684
|
helpers: HELPERS_TYPES,
|
|
3600
3685
|
inquiries: INQUIRIES_TYPES,
|
|
3686
|
+
reviews: REVIEWS_TYPES,
|
|
3601
3687
|
"modifier-groups": MODIFIER_GROUPS_TYPES
|
|
3602
3688
|
};
|
|
3603
3689
|
function getTypesByDomain(domain) {
|
|
@@ -3628,6 +3714,7 @@ var GET_TYPE_DEFINITIONS_SCHEMA = {
|
|
|
3628
3714
|
"payments",
|
|
3629
3715
|
"helpers",
|
|
3630
3716
|
"inquiries",
|
|
3717
|
+
"reviews",
|
|
3631
3718
|
"all"
|
|
3632
3719
|
]).describe(
|
|
3633
3720
|
'The domain of types to retrieve. Use "helpers" for helper function signatures and common types like StoreInfo, PaginatedResponse.'
|
|
@@ -4531,6 +4618,93 @@ function renderCartItem(item: CartItem): string {
|
|
|
4531
4618
|
// multiply unitPrice \xD7 quantity directly.
|
|
4532
4619
|
function lineTotal(item: CartItem): number {
|
|
4533
4620
|
return item.unitPrice * item.quantity;
|
|
4621
|
+
}`,
|
|
4622
|
+
"product-reviews": `// PDP: list visible reviews + submit form + JSON-LD aggregateRating
|
|
4623
|
+
import { client } from '@/lib/brainerce';
|
|
4624
|
+
import type { Product, ProductReview, SubmitProductReviewInput } from 'brainerce';
|
|
4625
|
+
|
|
4626
|
+
export async function ProductReviewsSection({ product }: { product: Product }) {
|
|
4627
|
+
const { data } = await client.listProductReviews(product.id, { page: 1, limit: 20 });
|
|
4628
|
+
|
|
4629
|
+
return (
|
|
4630
|
+
<section>
|
|
4631
|
+
<h2>Reviews ({product.reviewCount ?? 0})</h2>
|
|
4632
|
+
{data.length === 0 && <p>No reviews yet \u2014 be the first.</p>}
|
|
4633
|
+
{data.map((r) => (
|
|
4634
|
+
<article key={r.id}>
|
|
4635
|
+
<strong>{r.authorName}</strong>
|
|
4636
|
+
{r.verifiedPurchase && <span> \xB7 Verified</span>}
|
|
4637
|
+
<div>{'\u2605'.repeat(r.rating)}{'\u2606'.repeat(5 - r.rating)}</div>
|
|
4638
|
+
{r.body && <p>{r.body}</p>}
|
|
4639
|
+
</article>
|
|
4640
|
+
))}
|
|
4641
|
+
<ReviewForm productId={product.id} />
|
|
4642
|
+
</section>
|
|
4643
|
+
);
|
|
4644
|
+
}
|
|
4645
|
+
|
|
4646
|
+
// JSON-LD \u2014 emit aggregateRating only when there is data
|
|
4647
|
+
function productJsonLd(product: Product, url: string) {
|
|
4648
|
+
return {
|
|
4649
|
+
'@context': 'https://schema.org',
|
|
4650
|
+
'@type': 'Product',
|
|
4651
|
+
name: product.name,
|
|
4652
|
+
image: product.images?.[0]?.url,
|
|
4653
|
+
url,
|
|
4654
|
+
sku: product.sku ?? product.id,
|
|
4655
|
+
...(product.reviewCount && product.reviewCount > 0 && {
|
|
4656
|
+
aggregateRating: {
|
|
4657
|
+
'@type': 'AggregateRating',
|
|
4658
|
+
ratingValue: product.avgRating,
|
|
4659
|
+
reviewCount: product.reviewCount,
|
|
4660
|
+
bestRating: 5,
|
|
4661
|
+
worstRating: 1,
|
|
4662
|
+
},
|
|
4663
|
+
}),
|
|
4664
|
+
};
|
|
4665
|
+
}
|
|
4666
|
+
|
|
4667
|
+
// Client-side form
|
|
4668
|
+
'use client';
|
|
4669
|
+
import { useState } from 'react';
|
|
4670
|
+
|
|
4671
|
+
function ReviewForm({ productId }: { productId: string }) {
|
|
4672
|
+
const [rating, setRating] = useState(5);
|
|
4673
|
+
const [body, setBody] = useState('');
|
|
4674
|
+
const [name, setName] = useState('');
|
|
4675
|
+
const [email, setEmail] = useState('');
|
|
4676
|
+
const [error, setError] = useState<string | null>(null);
|
|
4677
|
+
|
|
4678
|
+
async function submit(e: React.FormEvent) {
|
|
4679
|
+
e.preventDefault();
|
|
4680
|
+
setError(null);
|
|
4681
|
+
try {
|
|
4682
|
+
await client.submitProductReview(productId, {
|
|
4683
|
+
authorName: name,
|
|
4684
|
+
authorEmail: email,
|
|
4685
|
+
rating,
|
|
4686
|
+
body: body || undefined,
|
|
4687
|
+
});
|
|
4688
|
+
window.location.reload();
|
|
4689
|
+
} catch (err: any) {
|
|
4690
|
+
if (err?.status === 409) setError("You've already reviewed this product.");
|
|
4691
|
+
else if (err?.status === 429) setError('Too many submissions. Please wait a moment.');
|
|
4692
|
+
else setError('Could not submit review. Please try again.');
|
|
4693
|
+
}
|
|
4694
|
+
}
|
|
4695
|
+
|
|
4696
|
+
return (
|
|
4697
|
+
<form onSubmit={submit}>
|
|
4698
|
+
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Your name" required maxLength={100} />
|
|
4699
|
+
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" />
|
|
4700
|
+
<select value={rating} onChange={(e) => setRating(Number(e.target.value))}>
|
|
4701
|
+
{[5,4,3,2,1].map(n => <option key={n} value={n}>{'\u2605'.repeat(n)}</option>)}
|
|
4702
|
+
</select>
|
|
4703
|
+
<textarea value={body} onChange={(e) => setBody(e.target.value)} maxLength={5000} placeholder="Tell us what you think (optional)" />
|
|
4704
|
+
<button type="submit">Submit review</button>
|
|
4705
|
+
{error && <p role="alert">{error}</p>}
|
|
4706
|
+
</form>
|
|
4707
|
+
);
|
|
4534
4708
|
}`
|
|
4535
4709
|
};
|
|
4536
4710
|
async function handleGetCodeExample(args) {
|
|
@@ -5333,6 +5507,13 @@ var FEATURES = [
|
|
|
5333
5507
|
sdk: "client.getProductBySlug(slug). Helpers: getProductPriceInfo, getVariantPrice, getStockStatus, getVariantOptions, getProductSwatches, getDescriptionContent, getProductMetafieldValue.",
|
|
5334
5508
|
mandatory: "mandatory"
|
|
5335
5509
|
},
|
|
5510
|
+
{
|
|
5511
|
+
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. 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.',
|
|
5514
|
+
sdk: "client.listProductReviews(productId), client.submitProductReview(productId, { authorName, authorEmail, rating, body }). Product.avgRating + Product.reviewCount are denormalized rollups returned on every product fetch.",
|
|
5515
|
+
mandatory: "mandatory"
|
|
5516
|
+
},
|
|
5336
5517
|
{
|
|
5337
5518
|
id: "product-customization-fields",
|
|
5338
5519
|
title: "Render buyer-input customization fields on the product page",
|
package/dist/bin/stdio.js
CHANGED
|
@@ -1553,6 +1553,53 @@ The merchant can hide a group entirely for one variant by setting \`maxOverride:
|
|
|
1553
1553
|
|
|
1554
1554
|
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.`;
|
|
1555
1555
|
}
|
|
1556
|
+
function getProductReviewsSection() {
|
|
1557
|
+
return `## Product Reviews (\u2605 ratings + JSON-LD for SEO)
|
|
1558
|
+
|
|
1559
|
+
Reviews publish immediately on submit. Merchants hide individual reviews via the admin surface \u2014 no PENDING queue.
|
|
1560
|
+
|
|
1561
|
+
\`\`\`typescript
|
|
1562
|
+
// PDP \u2014 list visible reviews
|
|
1563
|
+
const { data, meta } = await client.listProductReviews(productId, { page: 1, limit: 20 });
|
|
1564
|
+
data.forEach(r => render(r.authorName, r.rating, r.body, r.verifiedPurchase));
|
|
1565
|
+
|
|
1566
|
+
// PDP \u2014 submit form
|
|
1567
|
+
await client.submitProductReview(productId, {
|
|
1568
|
+
authorName, authorEmail, rating: 5, body,
|
|
1569
|
+
});
|
|
1570
|
+
\`\`\`
|
|
1571
|
+
|
|
1572
|
+
Each \`Product\` carries denormalized rollups: \`product.avgRating\`, \`product.reviewCount\`. Use these on PLP cards.
|
|
1573
|
+
|
|
1574
|
+
### JSON-LD (mandatory for SEO stars in Google)
|
|
1575
|
+
|
|
1576
|
+
Emit \`aggregateRating\` ONLY when \`product.reviewCount > 0\`:
|
|
1577
|
+
|
|
1578
|
+
\`\`\`typescript
|
|
1579
|
+
const productJsonLd = {
|
|
1580
|
+
'@context': 'https://schema.org',
|
|
1581
|
+
'@type': 'Product',
|
|
1582
|
+
name: product.name,
|
|
1583
|
+
// \u2026existing fields
|
|
1584
|
+
...(product.reviewCount > 0 && {
|
|
1585
|
+
aggregateRating: {
|
|
1586
|
+
'@type': 'AggregateRating',
|
|
1587
|
+
ratingValue: product.avgRating,
|
|
1588
|
+
reviewCount: product.reviewCount,
|
|
1589
|
+
bestRating: 5,
|
|
1590
|
+
worstRating: 1,
|
|
1591
|
+
},
|
|
1592
|
+
}),
|
|
1593
|
+
};
|
|
1594
|
+
\`\`\`
|
|
1595
|
+
|
|
1596
|
+
### Rules
|
|
1597
|
+
- Rating must be an integer 1-5 (DB constraint).
|
|
1598
|
+
- Duplicate from same email \u2192 409 Conflict.
|
|
1599
|
+
- Submit endpoint is throttled to 3 / 60s / IP \u2014 surface a friendly "please wait" on 429.
|
|
1600
|
+
- \`verifiedPurchase\` is derived server-side from DELIVERED orders \u2014 DO NOT try to set it.
|
|
1601
|
+
- Stores with \`reviewsEnabled = false\` return 403 on storefront endpoints.`;
|
|
1602
|
+
}
|
|
1556
1603
|
function getInventorySection() {
|
|
1557
1604
|
return `## Inventory, Stock Display & Reservation Countdown
|
|
1558
1605
|
|
|
@@ -2499,6 +2546,8 @@ function getSectionByTopic(topic, connectionId, currency) {
|
|
|
2499
2546
|
return getDiscountsSection();
|
|
2500
2547
|
case "recommendations":
|
|
2501
2548
|
return getRecommendationsSection();
|
|
2549
|
+
case "reviews":
|
|
2550
|
+
return getProductReviewsSection();
|
|
2502
2551
|
case "product-customization-fields":
|
|
2503
2552
|
return getProductCustomizationFieldsSection();
|
|
2504
2553
|
case "modifier-groups":
|
|
@@ -2571,6 +2620,10 @@ function getSectionByTopic(topic, connectionId, currency) {
|
|
|
2571
2620
|
"",
|
|
2572
2621
|
"---",
|
|
2573
2622
|
"",
|
|
2623
|
+
getProductReviewsSection(),
|
|
2624
|
+
"",
|
|
2625
|
+
"---",
|
|
2626
|
+
"",
|
|
2574
2627
|
getDiscountsSection(),
|
|
2575
2628
|
"",
|
|
2576
2629
|
"---",
|
|
@@ -2622,6 +2675,7 @@ var GET_SDK_DOCS_SCHEMA = {
|
|
|
2622
2675
|
"inventory",
|
|
2623
2676
|
"discounts",
|
|
2624
2677
|
"recommendations",
|
|
2678
|
+
"reviews",
|
|
2625
2679
|
"product-customization-fields",
|
|
2626
2680
|
"tax",
|
|
2627
2681
|
"critical-rules",
|
|
@@ -3353,6 +3407,37 @@ interface CartBundleOffer {
|
|
|
3353
3407
|
totalOriginalPrice: string;
|
|
3354
3408
|
totalDiscountedPrice: string;
|
|
3355
3409
|
}`;
|
|
3410
|
+
var REVIEWS_TYPES = `// ---- Product Reviews ----
|
|
3411
|
+
|
|
3412
|
+
interface ProductReview {
|
|
3413
|
+
id: string;
|
|
3414
|
+
productId: string;
|
|
3415
|
+
authorName: string; // max 100 chars
|
|
3416
|
+
rating: number; // integer 1-5
|
|
3417
|
+
body: string | null; // plain text, max 5000 chars
|
|
3418
|
+
verifiedPurchase: boolean; // derived server-side from DELIVERED orders
|
|
3419
|
+
hiddenAt?: string | null; // ISO datetime; admin responses only \u2014 null = visible
|
|
3420
|
+
createdAt: string; // ISO datetime
|
|
3421
|
+
}
|
|
3422
|
+
|
|
3423
|
+
interface ProductReviewAdmin extends ProductReview {
|
|
3424
|
+
customerId: string | null;
|
|
3425
|
+
authorEmail: string | null;
|
|
3426
|
+
orderId: string | null;
|
|
3427
|
+
updatedAt: string;
|
|
3428
|
+
}
|
|
3429
|
+
|
|
3430
|
+
interface SubmitProductReviewInput {
|
|
3431
|
+
authorName: string;
|
|
3432
|
+
authorEmail?: string; // recommended \u2014 used for guest dedupe + verified-purchase derivation
|
|
3433
|
+
rating: number; // 1-5
|
|
3434
|
+
body?: string; // optional
|
|
3435
|
+
}
|
|
3436
|
+
|
|
3437
|
+
// Each Product carries denormalized review stats:
|
|
3438
|
+
// product.avgRating: number // 0 when reviewCount is 0
|
|
3439
|
+
// product.reviewCount: number // count of visible (hiddenAt IS NULL) reviews
|
|
3440
|
+
`;
|
|
3356
3441
|
var INQUIRIES_TYPES = `// ---- Contact Inquiries & Forms ----
|
|
3357
3442
|
|
|
3358
3443
|
// Two shapes \u2014 legacy and flexible. The two may be mixed; \`fields\` wins on key collision.
|
|
@@ -3596,6 +3681,7 @@ var TYPES_BY_DOMAIN = {
|
|
|
3596
3681
|
payments: PAYMENTS_TYPES,
|
|
3597
3682
|
helpers: HELPERS_TYPES,
|
|
3598
3683
|
inquiries: INQUIRIES_TYPES,
|
|
3684
|
+
reviews: REVIEWS_TYPES,
|
|
3599
3685
|
"modifier-groups": MODIFIER_GROUPS_TYPES
|
|
3600
3686
|
};
|
|
3601
3687
|
function getTypesByDomain(domain) {
|
|
@@ -3626,6 +3712,7 @@ var GET_TYPE_DEFINITIONS_SCHEMA = {
|
|
|
3626
3712
|
"payments",
|
|
3627
3713
|
"helpers",
|
|
3628
3714
|
"inquiries",
|
|
3715
|
+
"reviews",
|
|
3629
3716
|
"all"
|
|
3630
3717
|
]).describe(
|
|
3631
3718
|
'The domain of types to retrieve. Use "helpers" for helper function signatures and common types like StoreInfo, PaginatedResponse.'
|
|
@@ -4529,6 +4616,93 @@ function renderCartItem(item: CartItem): string {
|
|
|
4529
4616
|
// multiply unitPrice \xD7 quantity directly.
|
|
4530
4617
|
function lineTotal(item: CartItem): number {
|
|
4531
4618
|
return item.unitPrice * item.quantity;
|
|
4619
|
+
}`,
|
|
4620
|
+
"product-reviews": `// PDP: list visible reviews + submit form + JSON-LD aggregateRating
|
|
4621
|
+
import { client } from '@/lib/brainerce';
|
|
4622
|
+
import type { Product, ProductReview, SubmitProductReviewInput } from 'brainerce';
|
|
4623
|
+
|
|
4624
|
+
export async function ProductReviewsSection({ product }: { product: Product }) {
|
|
4625
|
+
const { data } = await client.listProductReviews(product.id, { page: 1, limit: 20 });
|
|
4626
|
+
|
|
4627
|
+
return (
|
|
4628
|
+
<section>
|
|
4629
|
+
<h2>Reviews ({product.reviewCount ?? 0})</h2>
|
|
4630
|
+
{data.length === 0 && <p>No reviews yet \u2014 be the first.</p>}
|
|
4631
|
+
{data.map((r) => (
|
|
4632
|
+
<article key={r.id}>
|
|
4633
|
+
<strong>{r.authorName}</strong>
|
|
4634
|
+
{r.verifiedPurchase && <span> \xB7 Verified</span>}
|
|
4635
|
+
<div>{'\u2605'.repeat(r.rating)}{'\u2606'.repeat(5 - r.rating)}</div>
|
|
4636
|
+
{r.body && <p>{r.body}</p>}
|
|
4637
|
+
</article>
|
|
4638
|
+
))}
|
|
4639
|
+
<ReviewForm productId={product.id} />
|
|
4640
|
+
</section>
|
|
4641
|
+
);
|
|
4642
|
+
}
|
|
4643
|
+
|
|
4644
|
+
// JSON-LD \u2014 emit aggregateRating only when there is data
|
|
4645
|
+
function productJsonLd(product: Product, url: string) {
|
|
4646
|
+
return {
|
|
4647
|
+
'@context': 'https://schema.org',
|
|
4648
|
+
'@type': 'Product',
|
|
4649
|
+
name: product.name,
|
|
4650
|
+
image: product.images?.[0]?.url,
|
|
4651
|
+
url,
|
|
4652
|
+
sku: product.sku ?? product.id,
|
|
4653
|
+
...(product.reviewCount && product.reviewCount > 0 && {
|
|
4654
|
+
aggregateRating: {
|
|
4655
|
+
'@type': 'AggregateRating',
|
|
4656
|
+
ratingValue: product.avgRating,
|
|
4657
|
+
reviewCount: product.reviewCount,
|
|
4658
|
+
bestRating: 5,
|
|
4659
|
+
worstRating: 1,
|
|
4660
|
+
},
|
|
4661
|
+
}),
|
|
4662
|
+
};
|
|
4663
|
+
}
|
|
4664
|
+
|
|
4665
|
+
// Client-side form
|
|
4666
|
+
'use client';
|
|
4667
|
+
import { useState } from 'react';
|
|
4668
|
+
|
|
4669
|
+
function ReviewForm({ productId }: { productId: string }) {
|
|
4670
|
+
const [rating, setRating] = useState(5);
|
|
4671
|
+
const [body, setBody] = useState('');
|
|
4672
|
+
const [name, setName] = useState('');
|
|
4673
|
+
const [email, setEmail] = useState('');
|
|
4674
|
+
const [error, setError] = useState<string | null>(null);
|
|
4675
|
+
|
|
4676
|
+
async function submit(e: React.FormEvent) {
|
|
4677
|
+
e.preventDefault();
|
|
4678
|
+
setError(null);
|
|
4679
|
+
try {
|
|
4680
|
+
await client.submitProductReview(productId, {
|
|
4681
|
+
authorName: name,
|
|
4682
|
+
authorEmail: email,
|
|
4683
|
+
rating,
|
|
4684
|
+
body: body || undefined,
|
|
4685
|
+
});
|
|
4686
|
+
window.location.reload();
|
|
4687
|
+
} catch (err: any) {
|
|
4688
|
+
if (err?.status === 409) setError("You've already reviewed this product.");
|
|
4689
|
+
else if (err?.status === 429) setError('Too many submissions. Please wait a moment.');
|
|
4690
|
+
else setError('Could not submit review. Please try again.');
|
|
4691
|
+
}
|
|
4692
|
+
}
|
|
4693
|
+
|
|
4694
|
+
return (
|
|
4695
|
+
<form onSubmit={submit}>
|
|
4696
|
+
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Your name" required maxLength={100} />
|
|
4697
|
+
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" />
|
|
4698
|
+
<select value={rating} onChange={(e) => setRating(Number(e.target.value))}>
|
|
4699
|
+
{[5,4,3,2,1].map(n => <option key={n} value={n}>{'\u2605'.repeat(n)}</option>)}
|
|
4700
|
+
</select>
|
|
4701
|
+
<textarea value={body} onChange={(e) => setBody(e.target.value)} maxLength={5000} placeholder="Tell us what you think (optional)" />
|
|
4702
|
+
<button type="submit">Submit review</button>
|
|
4703
|
+
{error && <p role="alert">{error}</p>}
|
|
4704
|
+
</form>
|
|
4705
|
+
);
|
|
4532
4706
|
}`
|
|
4533
4707
|
};
|
|
4534
4708
|
async function handleGetCodeExample(args) {
|
|
@@ -5331,6 +5505,13 @@ var FEATURES = [
|
|
|
5331
5505
|
sdk: "client.getProductBySlug(slug). Helpers: getProductPriceInfo, getVariantPrice, getStockStatus, getVariantOptions, getProductSwatches, getDescriptionContent, getProductMetafieldValue.",
|
|
5332
5506
|
mandatory: "mandatory"
|
|
5333
5507
|
},
|
|
5508
|
+
{
|
|
5509
|
+
id: "product-reviews",
|
|
5510
|
+
title: "Display + collect product reviews with stars + JSON-LD",
|
|
5511
|
+
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.',
|
|
5512
|
+
sdk: "client.listProductReviews(productId), client.submitProductReview(productId, { authorName, authorEmail, rating, body }). Product.avgRating + Product.reviewCount are denormalized rollups returned on every product fetch.",
|
|
5513
|
+
mandatory: "mandatory"
|
|
5514
|
+
},
|
|
5334
5515
|
{
|
|
5335
5516
|
id: "product-customization-fields",
|
|
5336
5517
|
title: "Render buyer-input customization fields on the product page",
|
package/dist/index.js
CHANGED
|
@@ -1579,6 +1579,53 @@ The merchant can hide a group entirely for one variant by setting \`maxOverride:
|
|
|
1579
1579
|
|
|
1580
1580
|
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.`;
|
|
1581
1581
|
}
|
|
1582
|
+
function getProductReviewsSection() {
|
|
1583
|
+
return `## Product Reviews (\u2605 ratings + JSON-LD for SEO)
|
|
1584
|
+
|
|
1585
|
+
Reviews publish immediately on submit. Merchants hide individual reviews via the admin surface \u2014 no PENDING queue.
|
|
1586
|
+
|
|
1587
|
+
\`\`\`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));
|
|
1591
|
+
|
|
1592
|
+
// PDP \u2014 submit form
|
|
1593
|
+
await client.submitProductReview(productId, {
|
|
1594
|
+
authorName, authorEmail, rating: 5, body,
|
|
1595
|
+
});
|
|
1596
|
+
\`\`\`
|
|
1597
|
+
|
|
1598
|
+
Each \`Product\` carries denormalized rollups: \`product.avgRating\`, \`product.reviewCount\`. Use these on PLP cards.
|
|
1599
|
+
|
|
1600
|
+
### JSON-LD (mandatory for SEO stars in Google)
|
|
1601
|
+
|
|
1602
|
+
Emit \`aggregateRating\` ONLY when \`product.reviewCount > 0\`:
|
|
1603
|
+
|
|
1604
|
+
\`\`\`typescript
|
|
1605
|
+
const productJsonLd = {
|
|
1606
|
+
'@context': 'https://schema.org',
|
|
1607
|
+
'@type': 'Product',
|
|
1608
|
+
name: product.name,
|
|
1609
|
+
// \u2026existing fields
|
|
1610
|
+
...(product.reviewCount > 0 && {
|
|
1611
|
+
aggregateRating: {
|
|
1612
|
+
'@type': 'AggregateRating',
|
|
1613
|
+
ratingValue: product.avgRating,
|
|
1614
|
+
reviewCount: product.reviewCount,
|
|
1615
|
+
bestRating: 5,
|
|
1616
|
+
worstRating: 1,
|
|
1617
|
+
},
|
|
1618
|
+
}),
|
|
1619
|
+
};
|
|
1620
|
+
\`\`\`
|
|
1621
|
+
|
|
1622
|
+
### Rules
|
|
1623
|
+
- 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.
|
|
1627
|
+
- Stores with \`reviewsEnabled = false\` return 403 on storefront endpoints.`;
|
|
1628
|
+
}
|
|
1582
1629
|
function getInventorySection() {
|
|
1583
1630
|
return `## Inventory, Stock Display & Reservation Countdown
|
|
1584
1631
|
|
|
@@ -2525,6 +2572,8 @@ function getSectionByTopic(topic, connectionId, currency) {
|
|
|
2525
2572
|
return getDiscountsSection();
|
|
2526
2573
|
case "recommendations":
|
|
2527
2574
|
return getRecommendationsSection();
|
|
2575
|
+
case "reviews":
|
|
2576
|
+
return getProductReviewsSection();
|
|
2528
2577
|
case "product-customization-fields":
|
|
2529
2578
|
return getProductCustomizationFieldsSection();
|
|
2530
2579
|
case "modifier-groups":
|
|
@@ -2597,6 +2646,10 @@ function getSectionByTopic(topic, connectionId, currency) {
|
|
|
2597
2646
|
"",
|
|
2598
2647
|
"---",
|
|
2599
2648
|
"",
|
|
2649
|
+
getProductReviewsSection(),
|
|
2650
|
+
"",
|
|
2651
|
+
"---",
|
|
2652
|
+
"",
|
|
2600
2653
|
getDiscountsSection(),
|
|
2601
2654
|
"",
|
|
2602
2655
|
"---",
|
|
@@ -2648,6 +2701,7 @@ var GET_SDK_DOCS_SCHEMA = {
|
|
|
2648
2701
|
"inventory",
|
|
2649
2702
|
"discounts",
|
|
2650
2703
|
"recommendations",
|
|
2704
|
+
"reviews",
|
|
2651
2705
|
"product-customization-fields",
|
|
2652
2706
|
"tax",
|
|
2653
2707
|
"critical-rules",
|
|
@@ -3379,6 +3433,37 @@ interface CartBundleOffer {
|
|
|
3379
3433
|
totalOriginalPrice: string;
|
|
3380
3434
|
totalDiscountedPrice: string;
|
|
3381
3435
|
}`;
|
|
3436
|
+
var REVIEWS_TYPES = `// ---- Product Reviews ----
|
|
3437
|
+
|
|
3438
|
+
interface ProductReview {
|
|
3439
|
+
id: string;
|
|
3440
|
+
productId: string;
|
|
3441
|
+
authorName: string; // max 100 chars
|
|
3442
|
+
rating: number; // integer 1-5
|
|
3443
|
+
body: string | null; // plain text, max 5000 chars
|
|
3444
|
+
verifiedPurchase: boolean; // derived server-side from DELIVERED orders
|
|
3445
|
+
hiddenAt?: string | null; // ISO datetime; admin responses only \u2014 null = visible
|
|
3446
|
+
createdAt: string; // ISO datetime
|
|
3447
|
+
}
|
|
3448
|
+
|
|
3449
|
+
interface ProductReviewAdmin extends ProductReview {
|
|
3450
|
+
customerId: string | null;
|
|
3451
|
+
authorEmail: string | null;
|
|
3452
|
+
orderId: string | null;
|
|
3453
|
+
updatedAt: string;
|
|
3454
|
+
}
|
|
3455
|
+
|
|
3456
|
+
interface SubmitProductReviewInput {
|
|
3457
|
+
authorName: string;
|
|
3458
|
+
authorEmail?: string; // recommended \u2014 used for guest dedupe + verified-purchase derivation
|
|
3459
|
+
rating: number; // 1-5
|
|
3460
|
+
body?: string; // optional
|
|
3461
|
+
}
|
|
3462
|
+
|
|
3463
|
+
// Each Product carries denormalized review stats:
|
|
3464
|
+
// product.avgRating: number // 0 when reviewCount is 0
|
|
3465
|
+
// product.reviewCount: number // count of visible (hiddenAt IS NULL) reviews
|
|
3466
|
+
`;
|
|
3382
3467
|
var INQUIRIES_TYPES = `// ---- Contact Inquiries & Forms ----
|
|
3383
3468
|
|
|
3384
3469
|
// Two shapes \u2014 legacy and flexible. The two may be mixed; \`fields\` wins on key collision.
|
|
@@ -3622,6 +3707,7 @@ var TYPES_BY_DOMAIN = {
|
|
|
3622
3707
|
payments: PAYMENTS_TYPES,
|
|
3623
3708
|
helpers: HELPERS_TYPES,
|
|
3624
3709
|
inquiries: INQUIRIES_TYPES,
|
|
3710
|
+
reviews: REVIEWS_TYPES,
|
|
3625
3711
|
"modifier-groups": MODIFIER_GROUPS_TYPES
|
|
3626
3712
|
};
|
|
3627
3713
|
function getTypesByDomain(domain) {
|
|
@@ -3652,6 +3738,7 @@ var GET_TYPE_DEFINITIONS_SCHEMA = {
|
|
|
3652
3738
|
"payments",
|
|
3653
3739
|
"helpers",
|
|
3654
3740
|
"inquiries",
|
|
3741
|
+
"reviews",
|
|
3655
3742
|
"all"
|
|
3656
3743
|
]).describe(
|
|
3657
3744
|
'The domain of types to retrieve. Use "helpers" for helper function signatures and common types like StoreInfo, PaginatedResponse.'
|
|
@@ -4555,6 +4642,93 @@ function renderCartItem(item: CartItem): string {
|
|
|
4555
4642
|
// multiply unitPrice \xD7 quantity directly.
|
|
4556
4643
|
function lineTotal(item: CartItem): number {
|
|
4557
4644
|
return item.unitPrice * item.quantity;
|
|
4645
|
+
}`,
|
|
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
|
+
|
|
4650
|
+
export async function ProductReviewsSection({ product }: { product: Product }) {
|
|
4651
|
+
const { data } = await client.listProductReviews(product.id, { page: 1, limit: 20 });
|
|
4652
|
+
|
|
4653
|
+
return (
|
|
4654
|
+
<section>
|
|
4655
|
+
<h2>Reviews ({product.reviewCount ?? 0})</h2>
|
|
4656
|
+
{data.length === 0 && <p>No reviews yet \u2014 be the first.</p>}
|
|
4657
|
+
{data.map((r) => (
|
|
4658
|
+
<article key={r.id}>
|
|
4659
|
+
<strong>{r.authorName}</strong>
|
|
4660
|
+
{r.verifiedPurchase && <span> \xB7 Verified</span>}
|
|
4661
|
+
<div>{'\u2605'.repeat(r.rating)}{'\u2606'.repeat(5 - r.rating)}</div>
|
|
4662
|
+
{r.body && <p>{r.body}</p>}
|
|
4663
|
+
</article>
|
|
4664
|
+
))}
|
|
4665
|
+
<ReviewForm productId={product.id} />
|
|
4666
|
+
</section>
|
|
4667
|
+
);
|
|
4668
|
+
}
|
|
4669
|
+
|
|
4670
|
+
// JSON-LD \u2014 emit aggregateRating only when there is data
|
|
4671
|
+
function productJsonLd(product: Product, url: string) {
|
|
4672
|
+
return {
|
|
4673
|
+
'@context': 'https://schema.org',
|
|
4674
|
+
'@type': 'Product',
|
|
4675
|
+
name: product.name,
|
|
4676
|
+
image: product.images?.[0]?.url,
|
|
4677
|
+
url,
|
|
4678
|
+
sku: product.sku ?? product.id,
|
|
4679
|
+
...(product.reviewCount && product.reviewCount > 0 && {
|
|
4680
|
+
aggregateRating: {
|
|
4681
|
+
'@type': 'AggregateRating',
|
|
4682
|
+
ratingValue: product.avgRating,
|
|
4683
|
+
reviewCount: product.reviewCount,
|
|
4684
|
+
bestRating: 5,
|
|
4685
|
+
worstRating: 1,
|
|
4686
|
+
},
|
|
4687
|
+
}),
|
|
4688
|
+
};
|
|
4689
|
+
}
|
|
4690
|
+
|
|
4691
|
+
// Client-side form
|
|
4692
|
+
'use client';
|
|
4693
|
+
import { useState } from 'react';
|
|
4694
|
+
|
|
4695
|
+
function ReviewForm({ productId }: { productId: string }) {
|
|
4696
|
+
const [rating, setRating] = useState(5);
|
|
4697
|
+
const [body, setBody] = useState('');
|
|
4698
|
+
const [name, setName] = useState('');
|
|
4699
|
+
const [email, setEmail] = useState('');
|
|
4700
|
+
const [error, setError] = useState<string | null>(null);
|
|
4701
|
+
|
|
4702
|
+
async function submit(e: React.FormEvent) {
|
|
4703
|
+
e.preventDefault();
|
|
4704
|
+
setError(null);
|
|
4705
|
+
try {
|
|
4706
|
+
await client.submitProductReview(productId, {
|
|
4707
|
+
authorName: name,
|
|
4708
|
+
authorEmail: email,
|
|
4709
|
+
rating,
|
|
4710
|
+
body: body || undefined,
|
|
4711
|
+
});
|
|
4712
|
+
window.location.reload();
|
|
4713
|
+
} catch (err: any) {
|
|
4714
|
+
if (err?.status === 409) setError("You've already reviewed this product.");
|
|
4715
|
+
else if (err?.status === 429) setError('Too many submissions. Please wait a moment.');
|
|
4716
|
+
else setError('Could not submit review. Please try again.');
|
|
4717
|
+
}
|
|
4718
|
+
}
|
|
4719
|
+
|
|
4720
|
+
return (
|
|
4721
|
+
<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>
|
|
4729
|
+
{error && <p role="alert">{error}</p>}
|
|
4730
|
+
</form>
|
|
4731
|
+
);
|
|
4558
4732
|
}`
|
|
4559
4733
|
};
|
|
4560
4734
|
async function handleGetCodeExample(args) {
|
|
@@ -5357,6 +5531,13 @@ var FEATURES = [
|
|
|
5357
5531
|
sdk: "client.getProductBySlug(slug). Helpers: getProductPriceInfo, getVariantPrice, getStockStatus, getVariantOptions, getProductSwatches, getDescriptionContent, getProductMetafieldValue.",
|
|
5358
5532
|
mandatory: "mandatory"
|
|
5359
5533
|
},
|
|
5534
|
+
{
|
|
5535
|
+
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.",
|
|
5539
|
+
mandatory: "mandatory"
|
|
5540
|
+
},
|
|
5360
5541
|
{
|
|
5361
5542
|
id: "product-customization-fields",
|
|
5362
5543
|
title: "Render buyer-input customization fields on the product page",
|
package/dist/index.mjs
CHANGED
|
@@ -1547,6 +1547,53 @@ The merchant can hide a group entirely for one variant by setting \`maxOverride:
|
|
|
1547
1547
|
|
|
1548
1548
|
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
1549
|
}
|
|
1550
|
+
function getProductReviewsSection() {
|
|
1551
|
+
return `## Product Reviews (\u2605 ratings + JSON-LD for SEO)
|
|
1552
|
+
|
|
1553
|
+
Reviews publish immediately on submit. Merchants hide individual reviews via the admin surface \u2014 no PENDING queue.
|
|
1554
|
+
|
|
1555
|
+
\`\`\`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));
|
|
1559
|
+
|
|
1560
|
+
// PDP \u2014 submit form
|
|
1561
|
+
await client.submitProductReview(productId, {
|
|
1562
|
+
authorName, authorEmail, rating: 5, body,
|
|
1563
|
+
});
|
|
1564
|
+
\`\`\`
|
|
1565
|
+
|
|
1566
|
+
Each \`Product\` carries denormalized rollups: \`product.avgRating\`, \`product.reviewCount\`. Use these on PLP cards.
|
|
1567
|
+
|
|
1568
|
+
### JSON-LD (mandatory for SEO stars in Google)
|
|
1569
|
+
|
|
1570
|
+
Emit \`aggregateRating\` ONLY when \`product.reviewCount > 0\`:
|
|
1571
|
+
|
|
1572
|
+
\`\`\`typescript
|
|
1573
|
+
const productJsonLd = {
|
|
1574
|
+
'@context': 'https://schema.org',
|
|
1575
|
+
'@type': 'Product',
|
|
1576
|
+
name: product.name,
|
|
1577
|
+
// \u2026existing fields
|
|
1578
|
+
...(product.reviewCount > 0 && {
|
|
1579
|
+
aggregateRating: {
|
|
1580
|
+
'@type': 'AggregateRating',
|
|
1581
|
+
ratingValue: product.avgRating,
|
|
1582
|
+
reviewCount: product.reviewCount,
|
|
1583
|
+
bestRating: 5,
|
|
1584
|
+
worstRating: 1,
|
|
1585
|
+
},
|
|
1586
|
+
}),
|
|
1587
|
+
};
|
|
1588
|
+
\`\`\`
|
|
1589
|
+
|
|
1590
|
+
### Rules
|
|
1591
|
+
- 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.
|
|
1595
|
+
- Stores with \`reviewsEnabled = false\` return 403 on storefront endpoints.`;
|
|
1596
|
+
}
|
|
1550
1597
|
function getInventorySection() {
|
|
1551
1598
|
return `## Inventory, Stock Display & Reservation Countdown
|
|
1552
1599
|
|
|
@@ -2493,6 +2540,8 @@ function getSectionByTopic(topic, connectionId, currency) {
|
|
|
2493
2540
|
return getDiscountsSection();
|
|
2494
2541
|
case "recommendations":
|
|
2495
2542
|
return getRecommendationsSection();
|
|
2543
|
+
case "reviews":
|
|
2544
|
+
return getProductReviewsSection();
|
|
2496
2545
|
case "product-customization-fields":
|
|
2497
2546
|
return getProductCustomizationFieldsSection();
|
|
2498
2547
|
case "modifier-groups":
|
|
@@ -2565,6 +2614,10 @@ function getSectionByTopic(topic, connectionId, currency) {
|
|
|
2565
2614
|
"",
|
|
2566
2615
|
"---",
|
|
2567
2616
|
"",
|
|
2617
|
+
getProductReviewsSection(),
|
|
2618
|
+
"",
|
|
2619
|
+
"---",
|
|
2620
|
+
"",
|
|
2568
2621
|
getDiscountsSection(),
|
|
2569
2622
|
"",
|
|
2570
2623
|
"---",
|
|
@@ -2616,6 +2669,7 @@ var GET_SDK_DOCS_SCHEMA = {
|
|
|
2616
2669
|
"inventory",
|
|
2617
2670
|
"discounts",
|
|
2618
2671
|
"recommendations",
|
|
2672
|
+
"reviews",
|
|
2619
2673
|
"product-customization-fields",
|
|
2620
2674
|
"tax",
|
|
2621
2675
|
"critical-rules",
|
|
@@ -3347,6 +3401,37 @@ interface CartBundleOffer {
|
|
|
3347
3401
|
totalOriginalPrice: string;
|
|
3348
3402
|
totalDiscountedPrice: string;
|
|
3349
3403
|
}`;
|
|
3404
|
+
var REVIEWS_TYPES = `// ---- Product Reviews ----
|
|
3405
|
+
|
|
3406
|
+
interface ProductReview {
|
|
3407
|
+
id: string;
|
|
3408
|
+
productId: string;
|
|
3409
|
+
authorName: string; // max 100 chars
|
|
3410
|
+
rating: number; // integer 1-5
|
|
3411
|
+
body: string | null; // plain text, max 5000 chars
|
|
3412
|
+
verifiedPurchase: boolean; // derived server-side from DELIVERED orders
|
|
3413
|
+
hiddenAt?: string | null; // ISO datetime; admin responses only \u2014 null = visible
|
|
3414
|
+
createdAt: string; // ISO datetime
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
interface ProductReviewAdmin extends ProductReview {
|
|
3418
|
+
customerId: string | null;
|
|
3419
|
+
authorEmail: string | null;
|
|
3420
|
+
orderId: string | null;
|
|
3421
|
+
updatedAt: string;
|
|
3422
|
+
}
|
|
3423
|
+
|
|
3424
|
+
interface SubmitProductReviewInput {
|
|
3425
|
+
authorName: string;
|
|
3426
|
+
authorEmail?: string; // recommended \u2014 used for guest dedupe + verified-purchase derivation
|
|
3427
|
+
rating: number; // 1-5
|
|
3428
|
+
body?: string; // optional
|
|
3429
|
+
}
|
|
3430
|
+
|
|
3431
|
+
// Each Product carries denormalized review stats:
|
|
3432
|
+
// product.avgRating: number // 0 when reviewCount is 0
|
|
3433
|
+
// product.reviewCount: number // count of visible (hiddenAt IS NULL) reviews
|
|
3434
|
+
`;
|
|
3350
3435
|
var INQUIRIES_TYPES = `// ---- Contact Inquiries & Forms ----
|
|
3351
3436
|
|
|
3352
3437
|
// Two shapes \u2014 legacy and flexible. The two may be mixed; \`fields\` wins on key collision.
|
|
@@ -3590,6 +3675,7 @@ var TYPES_BY_DOMAIN = {
|
|
|
3590
3675
|
payments: PAYMENTS_TYPES,
|
|
3591
3676
|
helpers: HELPERS_TYPES,
|
|
3592
3677
|
inquiries: INQUIRIES_TYPES,
|
|
3678
|
+
reviews: REVIEWS_TYPES,
|
|
3593
3679
|
"modifier-groups": MODIFIER_GROUPS_TYPES
|
|
3594
3680
|
};
|
|
3595
3681
|
function getTypesByDomain(domain) {
|
|
@@ -3620,6 +3706,7 @@ var GET_TYPE_DEFINITIONS_SCHEMA = {
|
|
|
3620
3706
|
"payments",
|
|
3621
3707
|
"helpers",
|
|
3622
3708
|
"inquiries",
|
|
3709
|
+
"reviews",
|
|
3623
3710
|
"all"
|
|
3624
3711
|
]).describe(
|
|
3625
3712
|
'The domain of types to retrieve. Use "helpers" for helper function signatures and common types like StoreInfo, PaginatedResponse.'
|
|
@@ -4523,6 +4610,93 @@ function renderCartItem(item: CartItem): string {
|
|
|
4523
4610
|
// multiply unitPrice \xD7 quantity directly.
|
|
4524
4611
|
function lineTotal(item: CartItem): number {
|
|
4525
4612
|
return item.unitPrice * item.quantity;
|
|
4613
|
+
}`,
|
|
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
|
+
|
|
4618
|
+
export async function ProductReviewsSection({ product }: { product: Product }) {
|
|
4619
|
+
const { data } = await client.listProductReviews(product.id, { page: 1, limit: 20 });
|
|
4620
|
+
|
|
4621
|
+
return (
|
|
4622
|
+
<section>
|
|
4623
|
+
<h2>Reviews ({product.reviewCount ?? 0})</h2>
|
|
4624
|
+
{data.length === 0 && <p>No reviews yet \u2014 be the first.</p>}
|
|
4625
|
+
{data.map((r) => (
|
|
4626
|
+
<article key={r.id}>
|
|
4627
|
+
<strong>{r.authorName}</strong>
|
|
4628
|
+
{r.verifiedPurchase && <span> \xB7 Verified</span>}
|
|
4629
|
+
<div>{'\u2605'.repeat(r.rating)}{'\u2606'.repeat(5 - r.rating)}</div>
|
|
4630
|
+
{r.body && <p>{r.body}</p>}
|
|
4631
|
+
</article>
|
|
4632
|
+
))}
|
|
4633
|
+
<ReviewForm productId={product.id} />
|
|
4634
|
+
</section>
|
|
4635
|
+
);
|
|
4636
|
+
}
|
|
4637
|
+
|
|
4638
|
+
// JSON-LD \u2014 emit aggregateRating only when there is data
|
|
4639
|
+
function productJsonLd(product: Product, url: string) {
|
|
4640
|
+
return {
|
|
4641
|
+
'@context': 'https://schema.org',
|
|
4642
|
+
'@type': 'Product',
|
|
4643
|
+
name: product.name,
|
|
4644
|
+
image: product.images?.[0]?.url,
|
|
4645
|
+
url,
|
|
4646
|
+
sku: product.sku ?? product.id,
|
|
4647
|
+
...(product.reviewCount && product.reviewCount > 0 && {
|
|
4648
|
+
aggregateRating: {
|
|
4649
|
+
'@type': 'AggregateRating',
|
|
4650
|
+
ratingValue: product.avgRating,
|
|
4651
|
+
reviewCount: product.reviewCount,
|
|
4652
|
+
bestRating: 5,
|
|
4653
|
+
worstRating: 1,
|
|
4654
|
+
},
|
|
4655
|
+
}),
|
|
4656
|
+
};
|
|
4657
|
+
}
|
|
4658
|
+
|
|
4659
|
+
// Client-side form
|
|
4660
|
+
'use client';
|
|
4661
|
+
import { useState } from 'react';
|
|
4662
|
+
|
|
4663
|
+
function ReviewForm({ productId }: { productId: string }) {
|
|
4664
|
+
const [rating, setRating] = useState(5);
|
|
4665
|
+
const [body, setBody] = useState('');
|
|
4666
|
+
const [name, setName] = useState('');
|
|
4667
|
+
const [email, setEmail] = useState('');
|
|
4668
|
+
const [error, setError] = useState<string | null>(null);
|
|
4669
|
+
|
|
4670
|
+
async function submit(e: React.FormEvent) {
|
|
4671
|
+
e.preventDefault();
|
|
4672
|
+
setError(null);
|
|
4673
|
+
try {
|
|
4674
|
+
await client.submitProductReview(productId, {
|
|
4675
|
+
authorName: name,
|
|
4676
|
+
authorEmail: email,
|
|
4677
|
+
rating,
|
|
4678
|
+
body: body || undefined,
|
|
4679
|
+
});
|
|
4680
|
+
window.location.reload();
|
|
4681
|
+
} catch (err: any) {
|
|
4682
|
+
if (err?.status === 409) setError("You've already reviewed this product.");
|
|
4683
|
+
else if (err?.status === 429) setError('Too many submissions. Please wait a moment.');
|
|
4684
|
+
else setError('Could not submit review. Please try again.');
|
|
4685
|
+
}
|
|
4686
|
+
}
|
|
4687
|
+
|
|
4688
|
+
return (
|
|
4689
|
+
<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>
|
|
4697
|
+
{error && <p role="alert">{error}</p>}
|
|
4698
|
+
</form>
|
|
4699
|
+
);
|
|
4526
4700
|
}`
|
|
4527
4701
|
};
|
|
4528
4702
|
async function handleGetCodeExample(args) {
|
|
@@ -5325,6 +5499,13 @@ var FEATURES = [
|
|
|
5325
5499
|
sdk: "client.getProductBySlug(slug). Helpers: getProductPriceInfo, getVariantPrice, getStockStatus, getVariantOptions, getProductSwatches, getDescriptionContent, getProductMetafieldValue.",
|
|
5326
5500
|
mandatory: "mandatory"
|
|
5327
5501
|
},
|
|
5502
|
+
{
|
|
5503
|
+
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.",
|
|
5507
|
+
mandatory: "mandatory"
|
|
5508
|
+
},
|
|
5328
5509
|
{
|
|
5329
5510
|
id: "product-customization-fields",
|
|
5330
5511
|
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.6.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"
|