create-brainerce-store 1.34.5 → 1.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +2 -0
- package/templates/nextjs/base/src/components/reviews/review-form.tsx +133 -0
- package/templates/nextjs/base/src/components/reviews/reviews-section.tsx +87 -0
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +13 -1
package/dist/index.js
CHANGED
|
@@ -31,7 +31,7 @@ var require_package = __commonJS({
|
|
|
31
31
|
"package.json"(exports2, module2) {
|
|
32
32
|
module2.exports = {
|
|
33
33
|
name: "create-brainerce-store",
|
|
34
|
-
version: "1.
|
|
34
|
+
version: "1.35.0",
|
|
35
35
|
description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
|
|
36
36
|
bin: {
|
|
37
37
|
"create-brainerce-store": "dist/index.js"
|
package/package.json
CHANGED
|
@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
|
|
|
2
2
|
import { notFound } from 'next/navigation';
|
|
3
3
|
import { getServerClient } from '@/lib/brainerce';
|
|
4
4
|
import { ProductJsonLd } from '@/components/seo/product-json-ld';
|
|
5
|
+
import { ReviewsSection } from '@/components/reviews/reviews-section';
|
|
5
6
|
import { ProductClientSection } from './product-client-section';
|
|
6
7
|
|
|
7
8
|
type Props = {
|
|
@@ -68,6 +69,7 @@ export default async function ProductDetailPage({ params }: Props) {
|
|
|
68
69
|
<>
|
|
69
70
|
<ProductJsonLd product={product} url={productUrl} currency={currency} />
|
|
70
71
|
<ProductClientSection product={product} />
|
|
72
|
+
<ReviewsSection productId={product.id} />
|
|
71
73
|
</>
|
|
72
74
|
);
|
|
73
75
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { client } from '@/lib/brainerce';
|
|
5
|
+
|
|
6
|
+
interface ReviewFormProps {
|
|
7
|
+
productId: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function ReviewForm({ productId }: ReviewFormProps) {
|
|
11
|
+
const [rating, setRating] = useState(5);
|
|
12
|
+
const [authorName, setAuthorName] = useState('');
|
|
13
|
+
const [authorEmail, setAuthorEmail] = useState('');
|
|
14
|
+
const [body, setBody] = useState('');
|
|
15
|
+
const [submitting, setSubmitting] = useState(false);
|
|
16
|
+
const [error, setError] = useState<string | null>(null);
|
|
17
|
+
const [success, setSuccess] = useState(false);
|
|
18
|
+
|
|
19
|
+
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
|
20
|
+
event.preventDefault();
|
|
21
|
+
setError(null);
|
|
22
|
+
setSubmitting(true);
|
|
23
|
+
try {
|
|
24
|
+
await client.submitProductReview(productId, {
|
|
25
|
+
authorName: authorName.trim(),
|
|
26
|
+
authorEmail: authorEmail.trim() || undefined,
|
|
27
|
+
rating,
|
|
28
|
+
body: body.trim() || undefined,
|
|
29
|
+
});
|
|
30
|
+
setSuccess(true);
|
|
31
|
+
setAuthorName('');
|
|
32
|
+
setAuthorEmail('');
|
|
33
|
+
setBody('');
|
|
34
|
+
setRating(5);
|
|
35
|
+
// The reviews list is server-rendered — refresh to pick up the new review.
|
|
36
|
+
setTimeout(() => window.location.reload(), 1200);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
const status = (err as { status?: number })?.status;
|
|
39
|
+
if (status === 409) {
|
|
40
|
+
setError("You've already submitted a review for this product.");
|
|
41
|
+
} else if (status === 429) {
|
|
42
|
+
setError('Too many submissions. Please try again in a few minutes.');
|
|
43
|
+
} else if (status === 403) {
|
|
44
|
+
setError('Reviews are not accepted for this product right now.');
|
|
45
|
+
} else {
|
|
46
|
+
setError('Could not submit your review. Please try again.');
|
|
47
|
+
}
|
|
48
|
+
} finally {
|
|
49
|
+
setSubmitting(false);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (success) {
|
|
54
|
+
return (
|
|
55
|
+
<p className="text-sm text-emerald-700 rounded-md border border-emerald-200 bg-emerald-50 p-3">
|
|
56
|
+
Thanks! Your review has been submitted.
|
|
57
|
+
</p>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<form onSubmit={handleSubmit} className="border rounded-md p-4 space-y-3">
|
|
63
|
+
<h3 className="font-medium">Write a review</h3>
|
|
64
|
+
|
|
65
|
+
<fieldset>
|
|
66
|
+
<legend className="text-sm mb-1">Your rating</legend>
|
|
67
|
+
<div className="inline-flex gap-1" role="radiogroup" aria-label="Star rating">
|
|
68
|
+
{[1, 2, 3, 4, 5].map((n) => (
|
|
69
|
+
<button
|
|
70
|
+
key={n}
|
|
71
|
+
type="button"
|
|
72
|
+
role="radio"
|
|
73
|
+
aria-checked={rating === n}
|
|
74
|
+
onClick={() => setRating(n)}
|
|
75
|
+
className={`text-2xl leading-none ${n <= rating ? 'text-yellow-500' : 'text-gray-300'}`}
|
|
76
|
+
>
|
|
77
|
+
★
|
|
78
|
+
</button>
|
|
79
|
+
))}
|
|
80
|
+
</div>
|
|
81
|
+
</fieldset>
|
|
82
|
+
|
|
83
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
84
|
+
<label className="block text-sm">
|
|
85
|
+
Name
|
|
86
|
+
<input
|
|
87
|
+
type="text"
|
|
88
|
+
value={authorName}
|
|
89
|
+
onChange={(event) => setAuthorName(event.target.value)}
|
|
90
|
+
required
|
|
91
|
+
maxLength={100}
|
|
92
|
+
className="mt-1 block w-full rounded border px-2 py-1.5 text-sm"
|
|
93
|
+
/>
|
|
94
|
+
</label>
|
|
95
|
+
<label className="block text-sm">
|
|
96
|
+
Email <span className="text-muted-foreground text-xs">(optional)</span>
|
|
97
|
+
<input
|
|
98
|
+
type="email"
|
|
99
|
+
value={authorEmail}
|
|
100
|
+
onChange={(event) => setAuthorEmail(event.target.value)}
|
|
101
|
+
maxLength={200}
|
|
102
|
+
className="mt-1 block w-full rounded border px-2 py-1.5 text-sm"
|
|
103
|
+
/>
|
|
104
|
+
</label>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<label className="block text-sm">
|
|
108
|
+
Your review <span className="text-muted-foreground text-xs">(optional)</span>
|
|
109
|
+
<textarea
|
|
110
|
+
value={body}
|
|
111
|
+
onChange={(event) => setBody(event.target.value)}
|
|
112
|
+
maxLength={5000}
|
|
113
|
+
rows={4}
|
|
114
|
+
className="mt-1 block w-full rounded border px-2 py-1.5 text-sm"
|
|
115
|
+
/>
|
|
116
|
+
</label>
|
|
117
|
+
|
|
118
|
+
{error && (
|
|
119
|
+
<p role="alert" className="text-sm text-red-700">
|
|
120
|
+
{error}
|
|
121
|
+
</p>
|
|
122
|
+
)}
|
|
123
|
+
|
|
124
|
+
<button
|
|
125
|
+
type="submit"
|
|
126
|
+
disabled={submitting || !authorName.trim()}
|
|
127
|
+
className="inline-flex items-center rounded bg-black px-4 py-2 text-sm text-white disabled:opacity-50"
|
|
128
|
+
>
|
|
129
|
+
{submitting ? 'Submitting…' : 'Submit review'}
|
|
130
|
+
</button>
|
|
131
|
+
</form>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ProductReview } from 'brainerce';
|
|
2
|
+
import { client } from '@/lib/brainerce';
|
|
3
|
+
import { ReviewForm } from './review-form';
|
|
4
|
+
|
|
5
|
+
interface ReviewsSectionProps {
|
|
6
|
+
productId: string;
|
|
7
|
+
initialReviews?: ProductReview[];
|
|
8
|
+
/** Where to send users back to after submit (defaults to the current product URL). */
|
|
9
|
+
returnUrl?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Renders the visible reviews for a product plus a submit form.
|
|
14
|
+
*
|
|
15
|
+
* Server component — fetches with the SDK on the server so reviews are in
|
|
16
|
+
* the initial HTML payload (good for SEO; JSON-LD on the same page already
|
|
17
|
+
* carries `aggregateRating` when reviewCount > 0).
|
|
18
|
+
*/
|
|
19
|
+
export async function ReviewsSection({ productId, initialReviews }: ReviewsSectionProps) {
|
|
20
|
+
let reviews: ProductReview[] = initialReviews ?? [];
|
|
21
|
+
|
|
22
|
+
if (!initialReviews) {
|
|
23
|
+
try {
|
|
24
|
+
const result = await client.listProductReviews(productId, { page: 1, limit: 20 });
|
|
25
|
+
reviews = result.data;
|
|
26
|
+
} catch {
|
|
27
|
+
// Reviews disabled at store level or network error — render the form anyway.
|
|
28
|
+
reviews = [];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<section className="mt-8" aria-labelledby="reviews-heading">
|
|
34
|
+
<h2 id="reviews-heading" className="text-xl font-semibold mb-4">
|
|
35
|
+
Reviews
|
|
36
|
+
</h2>
|
|
37
|
+
|
|
38
|
+
{reviews.length === 0 ? (
|
|
39
|
+
<p className="text-sm text-muted-foreground mb-6">
|
|
40
|
+
No reviews yet — be the first to share your experience.
|
|
41
|
+
</p>
|
|
42
|
+
) : (
|
|
43
|
+
<ul className="space-y-4 mb-6">
|
|
44
|
+
{reviews.map((review) => (
|
|
45
|
+
<ReviewCard key={review.id} review={review} />
|
|
46
|
+
))}
|
|
47
|
+
</ul>
|
|
48
|
+
)}
|
|
49
|
+
|
|
50
|
+
<ReviewForm productId={productId} />
|
|
51
|
+
</section>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function ReviewCard({ review }: { review: ProductReview }) {
|
|
56
|
+
return (
|
|
57
|
+
<li className="border rounded-md p-4">
|
|
58
|
+
<header className="flex items-center gap-2 flex-wrap">
|
|
59
|
+
<Stars rating={review.rating} />
|
|
60
|
+
<span className="font-medium text-sm">{review.authorName}</span>
|
|
61
|
+
{review.verifiedPurchase && (
|
|
62
|
+
<span className="text-xs px-2 py-0.5 rounded bg-emerald-50 text-emerald-700">
|
|
63
|
+
Verified purchase
|
|
64
|
+
</span>
|
|
65
|
+
)}
|
|
66
|
+
<time className="text-xs text-muted-foreground ms-auto">
|
|
67
|
+
{new Date(review.createdAt).toLocaleDateString()}
|
|
68
|
+
</time>
|
|
69
|
+
</header>
|
|
70
|
+
{review.body && (
|
|
71
|
+
<p className="mt-2 text-sm whitespace-pre-line break-words">{review.body}</p>
|
|
72
|
+
)}
|
|
73
|
+
</li>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function Stars({ rating }: { rating: number }) {
|
|
78
|
+
return (
|
|
79
|
+
<span aria-label={`${rating} of 5 stars`} className="inline-flex">
|
|
80
|
+
{[1, 2, 3, 4, 5].map((n) => (
|
|
81
|
+
<span key={n} className={n <= rating ? 'text-yellow-500' : 'text-gray-300'}>
|
|
82
|
+
★
|
|
83
|
+
</span>
|
|
84
|
+
))}
|
|
85
|
+
</span>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -39,7 +39,7 @@ export async function ProductJsonLd({ product, url, currency = 'USD' }: ProductJ
|
|
|
39
39
|
url,
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
-
const productJsonLd = {
|
|
42
|
+
const productJsonLd: Record<string, unknown> = {
|
|
43
43
|
'@context': 'https://schema.org',
|
|
44
44
|
'@type': 'Product',
|
|
45
45
|
name: product.name,
|
|
@@ -50,6 +50,18 @@ export async function ProductJsonLd({ product, url, currency = 'USD' }: ProductJ
|
|
|
50
50
|
offers,
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
+
// Emit aggregateRating only when there is real review data — Google rejects
|
|
54
|
+
// self-serving / faked ratings and stores with 0 reviews shouldn't claim any.
|
|
55
|
+
if (product.reviewCount && product.reviewCount > 0) {
|
|
56
|
+
productJsonLd.aggregateRating = {
|
|
57
|
+
'@type': 'AggregateRating',
|
|
58
|
+
ratingValue: product.avgRating,
|
|
59
|
+
reviewCount: product.reviewCount,
|
|
60
|
+
bestRating: 5,
|
|
61
|
+
worstRating: 1,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
53
65
|
const breadcrumbJsonLd = {
|
|
54
66
|
'@context': 'https://schema.org',
|
|
55
67
|
'@type': 'BreadcrumbList',
|