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.
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/templates/nextjs/base/src/app/checkout/page.tsx +981 -975
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +2 -0
- package/templates/nextjs/base/src/components/cart/coupon-input.tsx +145 -134
- package/templates/nextjs/base/src/components/products/product-card.tsx +14 -5
- 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 +39 -11
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
const [
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
error
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
{
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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',
|