create-brainerce-store 1.68.0 → 1.71.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 +22 -2
- package/messages/en.json +52 -2
- package/messages/he.json +52 -2
- package/package.json +1 -1
- package/templates/nextjs/base/src/app/checkout/page.tsx +1018 -1017
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +1 -1
- package/templates/nextjs/base/src/app/register/page.tsx +67 -64
- package/templates/nextjs/base/src/components/account/profile-section.tsx +303 -226
- package/templates/nextjs/base/src/components/auth/register-form.tsx +326 -245
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +306 -294
- package/templates/nextjs/base/src/components/checkout/date-picker.tsx +13 -1
- package/templates/nextjs/base/src/components/checkout/datetime-picker.tsx +61 -21
- package/templates/nextjs/base/src/components/shared/birthday-picker.tsx +258 -0
- package/templates/nextjs/base/src/core/lib/auth.ts +162 -154
- package/templates/nextjs/base/src/core/lib/birthday.ts +74 -0
- package/templates/nextjs/base/src/core/lib/store-info.ts +10 -0
- package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +143 -0
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +18 -2
- package/templates/nextjs/base/src/ui/product/back-in-stock-form.tsx +173 -0
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +484 -455
- package/templates/nextjs/base/src/ui/product/review-form.tsx +136 -12
- package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +139 -108
- package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +155 -142
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +500 -477
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +135 -11
- package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +179 -148
- package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +122 -0
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +87 -83
- package/templates/nextjs/ui-canvas/product/back-in-stock-form.tsx +151 -0
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +373 -352
- package/templates/nextjs/ui-canvas/product/review-form.tsx +129 -11
- package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +127 -96
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from 'react';
|
|
4
|
-
import { Star } from 'lucide-react';
|
|
4
|
+
import { Star, X } from 'lucide-react';
|
|
5
5
|
import { getClient } from '@/core/lib/brainerce';
|
|
6
6
|
import { checkAuthStatus } from '@/core/lib/auth';
|
|
7
7
|
import { Link } from '@/core/lib/navigation';
|
|
@@ -9,18 +9,28 @@ import { useTranslations } from '@/core/lib/translations';
|
|
|
9
9
|
import { Button } from '@/components/ui/button';
|
|
10
10
|
import { Label } from '@/components/ui/label';
|
|
11
11
|
import { Textarea } from '@/components/ui/textarea';
|
|
12
|
-
import type { MyProductReview, ProductReview } from 'brainerce';
|
|
12
|
+
import type { MyProductReview, ProductReview, ReviewPhotoUpload } from 'brainerce';
|
|
13
13
|
|
|
14
14
|
interface ReviewFormProps {
|
|
15
15
|
productId: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
type PhotoPolicy = MyProductReview['photos'];
|
|
19
|
+
|
|
18
20
|
type Stage =
|
|
19
21
|
| { kind: 'loading' }
|
|
20
22
|
| { kind: 'signed_out' }
|
|
21
23
|
| { kind: 'not_eligible'; reason: MyProductReview['reason'] }
|
|
22
|
-
| { kind: 'submit' }
|
|
23
|
-
|
|
24
|
+
| { kind: 'submit'; photos: PhotoPolicy }
|
|
25
|
+
// `existingPhotos` comes from myImages, which INCLUDES photos still awaiting
|
|
26
|
+
// the merchant — so a customer editing their review sees the one they already
|
|
27
|
+
// uploaded instead of a form that looks like it lost it.
|
|
28
|
+
| {
|
|
29
|
+
kind: 'edit';
|
|
30
|
+
review: ProductReview;
|
|
31
|
+
photos: PhotoPolicy;
|
|
32
|
+
existingPhotos: ReviewPhotoUpload[];
|
|
33
|
+
};
|
|
24
34
|
|
|
25
35
|
/**
|
|
26
36
|
* Single component that decides which UI to render for a customer on a PDP:
|
|
@@ -50,11 +60,17 @@ export function ReviewForm({ productId }: ReviewFormProps) {
|
|
|
50
60
|
setStage({ kind: 'not_eligible', reason: me.reason });
|
|
51
61
|
return;
|
|
52
62
|
}
|
|
63
|
+
const existingPhotos: ReviewPhotoUpload[] = me.myImages.map((img) => ({
|
|
64
|
+
key: img.assetKey,
|
|
65
|
+
url: img.thumbnailUrl ?? img.url,
|
|
66
|
+
width: img.width,
|
|
67
|
+
height: img.height,
|
|
68
|
+
}));
|
|
53
69
|
if (me.myReview) {
|
|
54
|
-
setStage({ kind: 'edit', review: me.myReview });
|
|
70
|
+
setStage({ kind: 'edit', review: me.myReview, photos: me.photos, existingPhotos });
|
|
55
71
|
return;
|
|
56
72
|
}
|
|
57
|
-
setStage({ kind: 'submit' });
|
|
73
|
+
setStage({ kind: 'submit', photos: me.photos });
|
|
58
74
|
} catch {
|
|
59
75
|
if (!cancelled) setStage({ kind: 'not_eligible', reason: 'product_not_found' });
|
|
60
76
|
}
|
|
@@ -91,12 +107,14 @@ export function ReviewForm({ productId }: ReviewFormProps) {
|
|
|
91
107
|
<EditReviewBlock
|
|
92
108
|
productId={productId}
|
|
93
109
|
initial={stage.review}
|
|
94
|
-
|
|
110
|
+
photos={stage.photos}
|
|
111
|
+
existingPhotos={stage.existingPhotos}
|
|
112
|
+
onDeleted={() => setStage({ kind: 'submit', photos: stage.photos })}
|
|
95
113
|
/>
|
|
96
114
|
);
|
|
97
115
|
}
|
|
98
116
|
|
|
99
|
-
return <SubmitReviewBlock productId={productId} />;
|
|
117
|
+
return <SubmitReviewBlock productId={productId} photos={stage.photos} />;
|
|
100
118
|
}
|
|
101
119
|
|
|
102
120
|
function NotEligibleMessage({ reason }: { reason: MyProductReview['reason'] }) {
|
|
@@ -119,17 +137,30 @@ function NotEligibleMessage({ reason }: { reason: MyProductReview['reason'] }) {
|
|
|
119
137
|
);
|
|
120
138
|
}
|
|
121
139
|
|
|
122
|
-
function SubmitReviewBlock({ productId }: { productId: string }) {
|
|
123
|
-
return
|
|
140
|
+
function SubmitReviewBlock({ productId, photos }: { productId: string; photos: PhotoPolicy }) {
|
|
141
|
+
return (
|
|
142
|
+
<ReviewEditor
|
|
143
|
+
productId={productId}
|
|
144
|
+
mode="create"
|
|
145
|
+
initialRating={5}
|
|
146
|
+
initialBody=""
|
|
147
|
+
photos={photos}
|
|
148
|
+
initialPhotos={[]}
|
|
149
|
+
/>
|
|
150
|
+
);
|
|
124
151
|
}
|
|
125
152
|
|
|
126
153
|
function EditReviewBlock({
|
|
127
154
|
productId,
|
|
128
155
|
initial,
|
|
156
|
+
photos,
|
|
157
|
+
existingPhotos,
|
|
129
158
|
onDeleted,
|
|
130
159
|
}: {
|
|
131
160
|
productId: string;
|
|
132
161
|
initial: ProductReview;
|
|
162
|
+
photos: PhotoPolicy;
|
|
163
|
+
existingPhotos: ReviewPhotoUpload[];
|
|
133
164
|
onDeleted: () => void;
|
|
134
165
|
}) {
|
|
135
166
|
const [deleting, setDeleting] = useState(false);
|
|
@@ -158,6 +189,8 @@ function EditReviewBlock({
|
|
|
158
189
|
mode="edit"
|
|
159
190
|
initialRating={initial.rating}
|
|
160
191
|
initialBody={initial.body ?? ''}
|
|
192
|
+
photos={photos}
|
|
193
|
+
initialPhotos={existingPhotos}
|
|
161
194
|
/>
|
|
162
195
|
<div className="flex items-center justify-between rounded-md border border-red-100 bg-red-50/50 p-3">
|
|
163
196
|
<p className="text-xs text-red-700">{t('startOver')}</p>
|
|
@@ -189,11 +222,15 @@ function ReviewEditor({
|
|
|
189
222
|
mode,
|
|
190
223
|
initialRating,
|
|
191
224
|
initialBody,
|
|
225
|
+
photos,
|
|
226
|
+
initialPhotos,
|
|
192
227
|
}: {
|
|
193
228
|
productId: string;
|
|
194
229
|
mode: 'create' | 'edit';
|
|
195
230
|
initialRating: number;
|
|
196
231
|
initialBody: string;
|
|
232
|
+
photos: PhotoPolicy;
|
|
233
|
+
initialPhotos: ReviewPhotoUpload[];
|
|
197
234
|
}) {
|
|
198
235
|
const [rating, setRating] = useState(initialRating);
|
|
199
236
|
const t = useTranslations('reviews');
|
|
@@ -201,6 +238,34 @@ function ReviewEditor({
|
|
|
201
238
|
const [submitting, setSubmitting] = useState(false);
|
|
202
239
|
const [error, setError] = useState<string | null>(null);
|
|
203
240
|
const [success, setSuccess] = useState(false);
|
|
241
|
+
const [uploads, setUploads] = useState<ReviewPhotoUpload[]>(initialPhotos);
|
|
242
|
+
const [uploading, setUploading] = useState(false);
|
|
243
|
+
const roomLeft = photos.maxPerReview - uploads.length;
|
|
244
|
+
|
|
245
|
+
async function handleFiles(event: React.ChangeEvent<HTMLInputElement>) {
|
|
246
|
+
const picked = Array.from(event.target.files ?? []);
|
|
247
|
+
// Reset the input so picking the same file twice still fires a change event.
|
|
248
|
+
event.target.value = '';
|
|
249
|
+
if (picked.length === 0) return;
|
|
250
|
+
|
|
251
|
+
const tooBig = picked.some((file) => file.size > photos.maxBytes);
|
|
252
|
+
const accepted = picked.filter((file) => file.size <= photos.maxBytes).slice(0, roomLeft);
|
|
253
|
+
if (tooBig) setError(t('photoTooLarge'));
|
|
254
|
+
|
|
255
|
+
if (accepted.length === 0) return;
|
|
256
|
+
setUploading(true);
|
|
257
|
+
try {
|
|
258
|
+
const done = await Promise.all(
|
|
259
|
+
accepted.map((file) => getClient().uploadReviewPhoto(productId, file))
|
|
260
|
+
);
|
|
261
|
+
setUploads((prev) => [...prev, ...done]);
|
|
262
|
+
if (!tooBig) setError(null);
|
|
263
|
+
} catch {
|
|
264
|
+
setError(t('photoUploadFailed'));
|
|
265
|
+
} finally {
|
|
266
|
+
setUploading(false);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
204
269
|
|
|
205
270
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
|
206
271
|
event.preventDefault();
|
|
@@ -208,7 +273,13 @@ function ReviewEditor({
|
|
|
208
273
|
setSubmitting(true);
|
|
209
274
|
try {
|
|
210
275
|
const client = getClient();
|
|
211
|
-
|
|
276
|
+
// imageKeys REPLACES the photo set, so send every key we still want. Keys,
|
|
277
|
+
// never urls — the url on an upload is only good for the local preview.
|
|
278
|
+
const input = {
|
|
279
|
+
rating,
|
|
280
|
+
body: body.trim() || undefined,
|
|
281
|
+
...(photos.enabled ? { imageKeys: uploads.map((upload) => upload.key) } : {}),
|
|
282
|
+
};
|
|
212
283
|
if (mode === 'edit') {
|
|
213
284
|
await client.updateMyProductReview(productId, input);
|
|
214
285
|
} else {
|
|
@@ -274,13 +345,66 @@ function ReviewEditor({
|
|
|
274
345
|
/>
|
|
275
346
|
</Label>
|
|
276
347
|
|
|
348
|
+
{photos.enabled && (
|
|
349
|
+
<fieldset className="space-y-2">
|
|
350
|
+
<legend className="text-sm">
|
|
351
|
+
{t('addPhotos')}{' '}
|
|
352
|
+
<span className="text-muted-foreground text-xs">
|
|
353
|
+
({uploads.length}/{photos.maxPerReview})
|
|
354
|
+
</span>
|
|
355
|
+
</legend>
|
|
356
|
+
|
|
357
|
+
{uploads.length > 0 && (
|
|
358
|
+
<ul className="flex flex-wrap gap-2">
|
|
359
|
+
{uploads.map((upload) => (
|
|
360
|
+
<li key={upload.key} className="relative">
|
|
361
|
+
<img
|
|
362
|
+
src={upload.url}
|
|
363
|
+
alt=""
|
|
364
|
+
width={upload.width ?? undefined}
|
|
365
|
+
height={upload.height ?? undefined}
|
|
366
|
+
className="h-20 w-20 rounded border object-cover"
|
|
367
|
+
/>
|
|
368
|
+
<button
|
|
369
|
+
type="button"
|
|
370
|
+
onClick={() =>
|
|
371
|
+
setUploads((prev) => prev.filter((item) => item.key !== upload.key))
|
|
372
|
+
}
|
|
373
|
+
aria-label={t('removePhoto')}
|
|
374
|
+
className="rounded-bs-none rounded-be absolute end-0 top-0 bg-black/60 px-1 text-xs text-white"
|
|
375
|
+
>
|
|
376
|
+
<X className="h-3 w-3" aria-hidden="true" />
|
|
377
|
+
</button>
|
|
378
|
+
</li>
|
|
379
|
+
))}
|
|
380
|
+
</ul>
|
|
381
|
+
)}
|
|
382
|
+
|
|
383
|
+
<input
|
|
384
|
+
type="file"
|
|
385
|
+
accept="image/jpeg,image/png,image/webp,image/gif"
|
|
386
|
+
multiple
|
|
387
|
+
disabled={uploading || roomLeft <= 0}
|
|
388
|
+
onChange={handleFiles}
|
|
389
|
+
className="block text-sm"
|
|
390
|
+
/>
|
|
391
|
+
|
|
392
|
+
{uploading && <p className="text-muted-foreground text-xs">{t('photoUploading')}</p>}
|
|
393
|
+
{/* Say it BEFORE they submit. A shopper who uploads a photo, submits, and
|
|
394
|
+
then cannot see it will conclude the site is broken. */}
|
|
395
|
+
{photos.requiresApproval && (
|
|
396
|
+
<p className="text-muted-foreground text-xs">{t('photosNeedApproval')}</p>
|
|
397
|
+
)}
|
|
398
|
+
</fieldset>
|
|
399
|
+
)}
|
|
400
|
+
|
|
277
401
|
{error && (
|
|
278
402
|
<p role="alert" className="text-sm text-red-700">
|
|
279
403
|
{error}
|
|
280
404
|
</p>
|
|
281
405
|
)}
|
|
282
406
|
|
|
283
|
-
<Button type="submit" disabled={submitting}>
|
|
407
|
+
<Button type="submit" disabled={submitting || uploading}>
|
|
284
408
|
{submitting
|
|
285
409
|
? mode === 'edit'
|
|
286
410
|
? t('saving')
|
|
@@ -1,108 +1,139 @@
|
|
|
1
|
-
import { Star } from 'lucide-react';
|
|
2
|
-
import type { ProductReview } from 'brainerce';
|
|
3
|
-
import { getServerClient } from '@/core/lib/brainerce.server';
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
{review.
|
|
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
|
-
|
|
1
|
+
import { Star } from 'lucide-react';
|
|
2
|
+
import type { ProductReview } from 'brainerce';
|
|
3
|
+
import { getServerClient } from '@/core/lib/brainerce.server';
|
|
4
|
+
import { CdnImage } from '@/ui/shared/cdn-image';
|
|
5
|
+
import { Badge } from '@/components/ui/badge';
|
|
6
|
+
<% if (i18nEnabled) { %>import { getMessages, defaultLocale } from '@/i18n';<% } else { %>import { defaultLocale, messages } from '@/i18n';<% } %>
|
|
7
|
+
import { ReviewForm } from './review-form';
|
|
8
|
+
|
|
9
|
+
// Server component — it renders before any client i18n context exists, so the
|
|
10
|
+
// wrapper strings (title / empty state / verified badge) are read straight
|
|
11
|
+
// from the `reviews` namespace in messages/, keyed by the store locale.
|
|
12
|
+
type ReviewStrings = Record<string, string>;
|
|
13
|
+
|
|
14
|
+
<% if (i18nEnabled) { %>async function getReviewStrings(locale: string): Promise<ReviewStrings> {
|
|
15
|
+
return ((await getMessages(locale)).reviews ?? {}) as ReviewStrings;
|
|
16
|
+
}<% } else { %>async function getReviewStrings(_locale: string): Promise<ReviewStrings> {
|
|
17
|
+
return messages.reviews as ReviewStrings;
|
|
18
|
+
}<% } %>
|
|
19
|
+
|
|
20
|
+
interface ReviewsSectionProps {
|
|
21
|
+
productId: string;
|
|
22
|
+
initialReviews?: ProductReview[];
|
|
23
|
+
locale?: string;
|
|
24
|
+
/** Where to send users back to after submit (defaults to the current product URL). */
|
|
25
|
+
returnUrl?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Renders the visible reviews for a product plus a submit form.
|
|
30
|
+
*
|
|
31
|
+
* Server component — fetches with the SDK on the server so reviews are in
|
|
32
|
+
* the initial HTML payload (good for SEO; JSON-LD on the same page already
|
|
33
|
+
* carries `aggregateRating` when reviewCount > 0).
|
|
34
|
+
*/
|
|
35
|
+
export async function ReviewsSection({
|
|
36
|
+
productId,
|
|
37
|
+
initialReviews,
|
|
38
|
+
locale = defaultLocale,
|
|
39
|
+
}: ReviewsSectionProps) {
|
|
40
|
+
const t = await getReviewStrings(locale);
|
|
41
|
+
let reviews: ProductReview[] = initialReviews ?? [];
|
|
42
|
+
|
|
43
|
+
if (!initialReviews) {
|
|
44
|
+
try {
|
|
45
|
+
const result = await (await getServerClient()).listProductReviews(productId, { page: 1, limit: 20, sort: 'photos_first' });
|
|
46
|
+
reviews = result.data;
|
|
47
|
+
} catch {
|
|
48
|
+
// Review listing disabled at store level or network error — render the form anyway.
|
|
49
|
+
reviews = [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<section className="mt-8" aria-labelledby="reviews-heading">
|
|
55
|
+
<h2 id="reviews-heading" className="mb-4 text-xl font-semibold">
|
|
56
|
+
{t.title}
|
|
57
|
+
</h2>
|
|
58
|
+
|
|
59
|
+
{reviews.length === 0 ? (
|
|
60
|
+
<p className="text-muted-foreground mb-6 text-sm">{t.noReviews}</p>
|
|
61
|
+
) : (
|
|
62
|
+
<ul className="mb-6 space-y-4">
|
|
63
|
+
{reviews.map((review) => (
|
|
64
|
+
<ReviewCard key={review.id} review={review} verifiedLabel={t.verifiedPurchase} />
|
|
65
|
+
))}
|
|
66
|
+
</ul>
|
|
67
|
+
)}
|
|
68
|
+
|
|
69
|
+
<ReviewForm productId={productId} />
|
|
70
|
+
</section>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function ReviewCard({ review, verifiedLabel }: { review: ProductReview; verifiedLabel: string }) {
|
|
75
|
+
return (
|
|
76
|
+
<li className="rounded-md border p-4">
|
|
77
|
+
<header className="flex flex-wrap items-center gap-2">
|
|
78
|
+
<Stars rating={review.rating} />
|
|
79
|
+
<span className="text-sm font-medium">{review.authorName}</span>
|
|
80
|
+
{review.verifiedPurchase && (
|
|
81
|
+
<Badge
|
|
82
|
+
variant="outline"
|
|
83
|
+
className="rounded border-transparent bg-emerald-50 px-2 py-0.5 font-normal text-emerald-700"
|
|
84
|
+
>
|
|
85
|
+
{verifiedLabel}
|
|
86
|
+
</Badge>
|
|
87
|
+
)}
|
|
88
|
+
<time className="text-muted-foreground ms-auto text-xs">
|
|
89
|
+
{new Date(review.createdAt).toLocaleDateString()}
|
|
90
|
+
</time>
|
|
91
|
+
</header>
|
|
92
|
+
{review.body && <p className="mt-2 whitespace-pre-line break-words text-sm">{review.body}</p>}
|
|
93
|
+
<ReviewPhotos review={review} />
|
|
94
|
+
</li>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Photos the reviewer attached. `review.images` is always an array and already
|
|
100
|
+
* carries only what shoppers are allowed to see, so there is nothing to filter.
|
|
101
|
+
*
|
|
102
|
+
* The intrinsic width/height come back from the API — passing them through is
|
|
103
|
+
* what stops the page jumping as the photos load.
|
|
104
|
+
*/
|
|
105
|
+
function ReviewPhotos({ review }: { review: ProductReview }) {
|
|
106
|
+
if (review.images.length === 0) return null;
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<ul className="mt-3 flex flex-wrap gap-2">
|
|
110
|
+
{review.images.map((image) => (
|
|
111
|
+
<li key={image.id}>
|
|
112
|
+
<a href={image.url} target="_blank" rel="noopener noreferrer">
|
|
113
|
+
<CdnImage
|
|
114
|
+
src={image.thumbnailUrl ?? image.url}
|
|
115
|
+
alt=""
|
|
116
|
+
width={image.width ?? 96}
|
|
117
|
+
height={image.height ?? 96}
|
|
118
|
+
className="h-24 w-24 rounded-md border object-cover"
|
|
119
|
+
/>
|
|
120
|
+
</a>
|
|
121
|
+
</li>
|
|
122
|
+
))}
|
|
123
|
+
</ul>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function Stars({ rating }: { rating: number }) {
|
|
128
|
+
return (
|
|
129
|
+
<span aria-label={`${rating} of 5 stars`} className="inline-flex">
|
|
130
|
+
{[1, 2, 3, 4, 5].map((n) => (
|
|
131
|
+
<Star
|
|
132
|
+
key={n}
|
|
133
|
+
className={`h-4 w-4 fill-current ${n <= rating ? 'text-yellow-500' : 'text-gray-300'}`}
|
|
134
|
+
aria-hidden="true"
|
|
135
|
+
/>
|
|
136
|
+
))}
|
|
137
|
+
</span>
|
|
138
|
+
);
|
|
139
|
+
}
|