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 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.5",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.34.5",
3
+ "version": "1.35.0",
4
4
  "description": "Scaffold a production-ready e-commerce storefront connected to Brainerce",
5
5
  "bin": {
6
6
  "create-brainerce-store": "dist/index.js"
@@ -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',