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