create-brainerce-store 1.34.4 → 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.
@@ -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
  }
@@ -1,134 +1,145 @@
1
- 'use client';
2
-
3
- import { useState } from 'react';
4
- import type { Cart } from 'brainerce';
5
- import { getClient } from '@/lib/brainerce';
6
- import { useTranslations } from '@/lib/translations';
7
- import { LoadingSpinner } from '@/components/shared/loading-spinner';
8
- import { cn } from '@/lib/utils';
9
-
10
- interface CouponInputProps {
11
- cart: Cart;
12
- onUpdate: () => void;
13
- className?: string;
14
- }
15
-
16
- export function CouponInput({ cart, onUpdate, className }: CouponInputProps) {
17
- const t = useTranslations('coupon');
18
- const tc = useTranslations('common');
19
- const [code, setCode] = useState('');
20
- const [applying, setApplying] = useState(false);
21
- const [removing, setRemoving] = useState(false);
22
- const [error, setError] = useState<string | null>(null);
23
-
24
- const appliedCoupon = cart.couponCode || null;
25
-
26
- async function handleApply() {
27
- const trimmed = code.trim();
28
- if (!trimmed || applying) return;
29
-
30
- try {
31
- setApplying(true);
32
- setError(null);
33
- const client = getClient();
34
- await client.applyCoupon(cart.id, trimmed);
35
- setCode('');
36
- onUpdate();
37
- } catch (err) {
38
- const message = err instanceof Error ? err.message : t('invalidCode');
39
- setError(message);
40
- } finally {
41
- setApplying(false);
42
- }
43
- }
44
-
45
- async function handleRemove() {
46
- if (removing) return;
47
-
48
- try {
49
- setRemoving(true);
50
- setError(null);
51
- const client = getClient();
52
- await client.removeCoupon(cart.id);
53
- onUpdate();
54
- } catch (err) {
55
- console.error('Failed to remove coupon:', err);
56
- } finally {
57
- setRemoving(false);
58
- }
59
- }
60
-
61
- // Show applied coupon
62
- if (appliedCoupon) {
63
- return (
64
- <div className={cn('space-y-2', className)}>
65
- <div className="bg-muted flex items-center justify-between rounded px-3 py-2">
66
- <div className="flex items-center gap-2">
67
- <svg
68
- className="text-primary h-4 w-4 flex-shrink-0"
69
- fill="none"
70
- viewBox="0 0 24 24"
71
- stroke="currentColor"
72
- >
73
- <path
74
- strokeLinecap="round"
75
- strokeLinejoin="round"
76
- strokeWidth={2}
77
- d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
78
- />
79
- </svg>
80
- <span className="text-foreground text-sm font-medium">{appliedCoupon}</span>
81
- </div>
82
- <button
83
- type="button"
84
- onClick={handleRemove}
85
- disabled={removing}
86
- className="text-destructive hover:text-destructive/80 text-xs transition-colors disabled:opacity-40"
87
- >
88
- {removing ? tc('removing') : tc('remove')}
89
- </button>
90
- </div>
91
- </div>
92
- );
93
- }
94
-
95
- return (
96
- <div className={cn('space-y-2', className)}>
97
- <div className="flex gap-2">
98
- <input
99
- type="text"
100
- value={code}
101
- onChange={(e) => {
102
- setCode(e.target.value);
103
- if (error) setError(null);
104
- }}
105
- onKeyDown={(e) => {
106
- if (e.key === 'Enter') {
107
- e.preventDefault();
108
- handleApply();
109
- }
110
- }}
111
- placeholder={t('placeholder')}
112
- className={cn(
113
- 'bg-background text-foreground placeholder:text-muted-foreground focus:ring-primary/20 focus:border-primary h-9 flex-1 rounded border px-3 text-sm focus:outline-none focus:ring-2',
114
- error ? 'border-destructive' : 'border-border'
115
- )}
116
- />
117
- <button
118
- type="button"
119
- onClick={handleApply}
120
- disabled={applying || !code.trim()}
121
- className="border-border bg-background text-foreground hover:bg-muted h-9 rounded border px-4 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40"
122
- >
123
- {applying ? (
124
- <LoadingSpinner size="sm" className="border-muted-foreground/30 border-t-foreground" />
125
- ) : (
126
- tc('apply')
127
- )}
128
- </button>
129
- </div>
130
-
131
- {error && <p className="text-destructive text-xs">{error}</p>}
132
- </div>
133
- );
134
- }
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import type { Cart } from 'brainerce';
5
+ import { getClient } from '@/lib/brainerce';
6
+ import { useTranslations } from '@/lib/translations';
7
+ import { LoadingSpinner } from '@/components/shared/loading-spinner';
8
+ import { cn } from '@/lib/utils';
9
+
10
+ interface CouponInputProps {
11
+ cart: Cart;
12
+ /** When provided, uses applyCheckoutCoupon/removeCheckoutCoupon instead of
13
+ * applyCoupon/removeCoupon. Always pass this when a checkout session exists. */
14
+ checkoutId?: string;
15
+ onUpdate: () => void;
16
+ className?: string;
17
+ }
18
+
19
+ export function CouponInput({ cart, checkoutId, onUpdate, className }: CouponInputProps) {
20
+ const t = useTranslations('coupon');
21
+ const tc = useTranslations('common');
22
+ const [code, setCode] = useState('');
23
+ const [applying, setApplying] = useState(false);
24
+ const [removing, setRemoving] = useState(false);
25
+ const [error, setError] = useState<string | null>(null);
26
+
27
+ const appliedCoupon = cart.couponCode || null;
28
+
29
+ async function handleApply() {
30
+ const trimmed = code.trim();
31
+ if (!trimmed || applying) return;
32
+
33
+ try {
34
+ setApplying(true);
35
+ setError(null);
36
+ const client = getClient();
37
+ if (checkoutId) {
38
+ await client.applyCheckoutCoupon(checkoutId, trimmed);
39
+ } else {
40
+ await client.applyCoupon(cart.id, trimmed);
41
+ }
42
+ setCode('');
43
+ onUpdate();
44
+ } catch (err) {
45
+ const message = err instanceof Error ? err.message : t('invalidCode');
46
+ setError(message);
47
+ } finally {
48
+ setApplying(false);
49
+ }
50
+ }
51
+
52
+ async function handleRemove() {
53
+ if (removing) return;
54
+
55
+ try {
56
+ setRemoving(true);
57
+ setError(null);
58
+ const client = getClient();
59
+ if (checkoutId) {
60
+ await client.removeCheckoutCoupon(checkoutId);
61
+ } else {
62
+ await client.removeCoupon(cart.id);
63
+ }
64
+ onUpdate();
65
+ } catch (err) {
66
+ console.error('Failed to remove coupon:', err);
67
+ } finally {
68
+ setRemoving(false);
69
+ }
70
+ }
71
+
72
+ // Show applied coupon
73
+ if (appliedCoupon) {
74
+ return (
75
+ <div className={cn('space-y-2', className)}>
76
+ <div className="bg-muted flex items-center justify-between rounded px-3 py-2">
77
+ <div className="flex items-center gap-2">
78
+ <svg
79
+ className="text-primary h-4 w-4 flex-shrink-0"
80
+ fill="none"
81
+ viewBox="0 0 24 24"
82
+ stroke="currentColor"
83
+ >
84
+ <path
85
+ strokeLinecap="round"
86
+ strokeLinejoin="round"
87
+ strokeWidth={2}
88
+ d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
89
+ />
90
+ </svg>
91
+ <span className="text-foreground text-sm font-medium">{appliedCoupon}</span>
92
+ </div>
93
+ <button
94
+ type="button"
95
+ onClick={handleRemove}
96
+ disabled={removing}
97
+ className="text-destructive hover:text-destructive/80 text-xs transition-colors disabled:opacity-40"
98
+ >
99
+ {removing ? tc('removing') : tc('remove')}
100
+ </button>
101
+ </div>
102
+ </div>
103
+ );
104
+ }
105
+
106
+ return (
107
+ <div className={cn('space-y-2', className)}>
108
+ <div className="flex gap-2">
109
+ <input
110
+ type="text"
111
+ value={code}
112
+ onChange={(e) => {
113
+ setCode(e.target.value);
114
+ if (error) setError(null);
115
+ }}
116
+ onKeyDown={(e) => {
117
+ if (e.key === 'Enter') {
118
+ e.preventDefault();
119
+ handleApply();
120
+ }
121
+ }}
122
+ placeholder={t('placeholder')}
123
+ className={cn(
124
+ 'bg-background text-foreground placeholder:text-muted-foreground focus:ring-primary/20 focus:border-primary h-9 flex-1 rounded border px-3 text-sm focus:outline-none focus:ring-2',
125
+ error ? 'border-destructive' : 'border-border'
126
+ )}
127
+ />
128
+ <button
129
+ type="button"
130
+ onClick={handleApply}
131
+ disabled={applying || !code.trim()}
132
+ className="border-border bg-background text-foreground hover:bg-muted h-9 rounded border px-4 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40"
133
+ >
134
+ {applying ? (
135
+ <LoadingSpinner size="sm" className="border-muted-foreground/30 border-t-foreground" />
136
+ ) : (
137
+ tc('apply')
138
+ )}
139
+ </button>
140
+ </div>
141
+
142
+ {error && <p className="text-destructive text-xs">{error}</p>}
143
+ </div>
144
+ );
145
+ }
@@ -20,12 +20,21 @@ interface ProductCardProps {
20
20
  function VariantPriceRange({ product }: { product: Product }) {
21
21
  const { storeInfo } = useStoreInfo();
22
22
  const currency = storeInfo?.currency || 'USD';
23
- const variants = product.variants ?? [];
24
- if (variants.length === 0) return null;
25
23
 
26
- const prices = variants.map((v) => getVariantPrice(v, product.basePrice));
27
- const min = Math.min(...prices);
28
- const max = Math.max(...prices);
24
+ let min: number;
25
+ let max: number;
26
+ if (product.priceMin && product.priceMax) {
27
+ min = parseFloat(product.priceMin);
28
+ max = parseFloat(product.priceMax);
29
+ } else {
30
+ const variants = product.variants ?? [];
31
+ if (variants.length === 0) return null;
32
+ const prices = variants.map((v) => getVariantPrice(v, product.basePrice));
33
+ min = Math.min(...prices);
34
+ max = Math.max(...prices);
35
+ }
36
+
37
+ if (isNaN(min) || isNaN(max)) return null;
29
38
 
30
39
  return (
31
40
  <span className="text-foreground text-sm font-medium">
@@ -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
+ }
@@ -14,7 +14,32 @@ export async function ProductJsonLd({ product, url, currency = 'USD' }: ProductJ
14
14
  const imageUrl = product.images?.[0]?.url;
15
15
  const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
16
16
 
17
- const productJsonLd = {
17
+ const availability =
18
+ product.inventory?.canPurchase !== false
19
+ ? 'https://schema.org/InStock'
20
+ : 'https://schema.org/OutOfStock';
21
+
22
+ const isVariable = product.type === 'VARIABLE';
23
+ const offers =
24
+ isVariable && product.priceMin
25
+ ? {
26
+ '@type': 'AggregateOffer',
27
+ lowPrice: product.priceMin,
28
+ highPrice: product.priceMax ?? product.priceMin,
29
+ offerCount: product.variants?.length ?? 1,
30
+ priceCurrency: currency,
31
+ availability,
32
+ url,
33
+ }
34
+ : {
35
+ '@type': 'Offer',
36
+ price: priceInfo.price,
37
+ priceCurrency: currency,
38
+ availability,
39
+ url,
40
+ };
41
+
42
+ const productJsonLd: Record<string, unknown> = {
18
43
  '@context': 'https://schema.org',
19
44
  '@type': 'Product',
20
45
  name: product.name,
@@ -22,18 +47,21 @@ export async function ProductJsonLd({ product, url, currency = 'USD' }: ProductJ
22
47
  image: imageUrl,
23
48
  url,
24
49
  sku: product.sku || product.id,
25
- offers: {
26
- '@type': 'Offer',
27
- price: priceInfo.price,
28
- priceCurrency: currency,
29
- availability:
30
- product.inventory?.canPurchase !== false
31
- ? 'https://schema.org/InStock'
32
- : 'https://schema.org/OutOfStock',
33
- url,
34
- },
50
+ offers,
35
51
  };
36
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
+
37
65
  const breadcrumbJsonLd = {
38
66
  '@context': 'https://schema.org',
39
67
  '@type': 'BreadcrumbList',