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
|
@@ -5,18 +5,28 @@ 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
|
|
|
10
10
|
interface ReviewFormProps {
|
|
11
11
|
productId: string;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
type PhotoPolicy = MyProductReview['photos'];
|
|
15
|
+
|
|
14
16
|
type Stage =
|
|
15
17
|
| { kind: 'loading' }
|
|
16
18
|
| { kind: 'signed_out' }
|
|
17
19
|
| { kind: 'not_eligible'; reason: MyProductReview['reason'] }
|
|
18
|
-
| { kind: 'submit' }
|
|
19
|
-
|
|
20
|
+
| { kind: 'submit'; photos: PhotoPolicy }
|
|
21
|
+
// `existingPhotos` comes from myImages, which INCLUDES photos still awaiting
|
|
22
|
+
// the merchant — so a customer editing their review sees the one they already
|
|
23
|
+
// uploaded instead of a form that looks like it lost it.
|
|
24
|
+
| {
|
|
25
|
+
kind: 'edit';
|
|
26
|
+
review: ProductReview;
|
|
27
|
+
photos: PhotoPolicy;
|
|
28
|
+
existingPhotos: ReviewPhotoUpload[];
|
|
29
|
+
};
|
|
20
30
|
|
|
21
31
|
/**
|
|
22
32
|
* Single component that decides which UI to render for a customer on a PDP:
|
|
@@ -51,11 +61,17 @@ export function ReviewForm({ productId }: ReviewFormProps) {
|
|
|
51
61
|
setStage({ kind: 'not_eligible', reason: me.reason });
|
|
52
62
|
return;
|
|
53
63
|
}
|
|
64
|
+
const existingPhotos: ReviewPhotoUpload[] = me.myImages.map((img) => ({
|
|
65
|
+
key: img.assetKey,
|
|
66
|
+
url: img.thumbnailUrl ?? img.url,
|
|
67
|
+
width: img.width,
|
|
68
|
+
height: img.height,
|
|
69
|
+
}));
|
|
54
70
|
if (me.myReview) {
|
|
55
|
-
setStage({ kind: 'edit', review: me.myReview });
|
|
71
|
+
setStage({ kind: 'edit', review: me.myReview, photos: me.photos, existingPhotos });
|
|
56
72
|
return;
|
|
57
73
|
}
|
|
58
|
-
setStage({ kind: 'submit' });
|
|
74
|
+
setStage({ kind: 'submit', photos: me.photos });
|
|
59
75
|
} catch {
|
|
60
76
|
if (!cancelled) setStage({ kind: 'not_eligible', reason: 'product_not_found' });
|
|
61
77
|
}
|
|
@@ -87,12 +103,14 @@ export function ReviewForm({ productId }: ReviewFormProps) {
|
|
|
87
103
|
<EditReviewBlock
|
|
88
104
|
productId={productId}
|
|
89
105
|
initial={stage.review}
|
|
90
|
-
|
|
106
|
+
photos={stage.photos}
|
|
107
|
+
existingPhotos={stage.existingPhotos}
|
|
108
|
+
onDeleted={() => setStage({ kind: 'submit', photos: stage.photos })}
|
|
91
109
|
/>
|
|
92
110
|
);
|
|
93
111
|
}
|
|
94
112
|
|
|
95
|
-
return <SubmitReviewBlock productId={productId} />;
|
|
113
|
+
return <SubmitReviewBlock productId={productId} photos={stage.photos} />;
|
|
96
114
|
}
|
|
97
115
|
|
|
98
116
|
function NotEligibleMessage({ reason }: { reason: MyProductReview['reason'] }) {
|
|
@@ -111,17 +129,30 @@ function NotEligibleMessage({ reason }: { reason: MyProductReview['reason'] }) {
|
|
|
111
129
|
return <p>{message}</p>;
|
|
112
130
|
}
|
|
113
131
|
|
|
114
|
-
function SubmitReviewBlock({ productId }: { productId: string }) {
|
|
115
|
-
return
|
|
132
|
+
function SubmitReviewBlock({ productId, photos }: { productId: string; photos: PhotoPolicy }) {
|
|
133
|
+
return (
|
|
134
|
+
<ReviewEditor
|
|
135
|
+
productId={productId}
|
|
136
|
+
mode="create"
|
|
137
|
+
initialRating={5}
|
|
138
|
+
initialBody=""
|
|
139
|
+
photos={photos}
|
|
140
|
+
initialPhotos={[]}
|
|
141
|
+
/>
|
|
142
|
+
);
|
|
116
143
|
}
|
|
117
144
|
|
|
118
145
|
function EditReviewBlock({
|
|
119
146
|
productId,
|
|
120
147
|
initial,
|
|
148
|
+
photos,
|
|
149
|
+
existingPhotos,
|
|
121
150
|
onDeleted,
|
|
122
151
|
}: {
|
|
123
152
|
productId: string;
|
|
124
153
|
initial: ProductReview;
|
|
154
|
+
photos: PhotoPolicy;
|
|
155
|
+
existingPhotos: ReviewPhotoUpload[];
|
|
125
156
|
onDeleted: () => void;
|
|
126
157
|
}) {
|
|
127
158
|
const [deleting, setDeleting] = useState(false);
|
|
@@ -150,6 +181,8 @@ function EditReviewBlock({
|
|
|
150
181
|
mode="edit"
|
|
151
182
|
initialRating={initial.rating}
|
|
152
183
|
initialBody={initial.body ?? ''}
|
|
184
|
+
photos={photos}
|
|
185
|
+
initialPhotos={existingPhotos}
|
|
153
186
|
/>
|
|
154
187
|
<div className="flex items-center gap-2">
|
|
155
188
|
<p>{t('startOver')}</p>
|
|
@@ -172,11 +205,15 @@ function ReviewEditor({
|
|
|
172
205
|
mode,
|
|
173
206
|
initialRating,
|
|
174
207
|
initialBody,
|
|
208
|
+
photos,
|
|
209
|
+
initialPhotos,
|
|
175
210
|
}: {
|
|
176
211
|
productId: string;
|
|
177
212
|
mode: 'create' | 'edit';
|
|
178
213
|
initialRating: number;
|
|
179
214
|
initialBody: string;
|
|
215
|
+
photos: PhotoPolicy;
|
|
216
|
+
initialPhotos: ReviewPhotoUpload[];
|
|
180
217
|
}) {
|
|
181
218
|
const [rating, setRating] = useState(initialRating);
|
|
182
219
|
const t = useTranslations('reviews');
|
|
@@ -184,6 +221,34 @@ function ReviewEditor({
|
|
|
184
221
|
const [submitting, setSubmitting] = useState(false);
|
|
185
222
|
const [error, setError] = useState<string | null>(null);
|
|
186
223
|
const [success, setSuccess] = useState(false);
|
|
224
|
+
const [uploads, setUploads] = useState<ReviewPhotoUpload[]>(initialPhotos);
|
|
225
|
+
const [uploading, setUploading] = useState(false);
|
|
226
|
+
const roomLeft = photos.maxPerReview - uploads.length;
|
|
227
|
+
|
|
228
|
+
async function handleFiles(event: React.ChangeEvent<HTMLInputElement>) {
|
|
229
|
+
const picked = Array.from(event.target.files ?? []);
|
|
230
|
+
// Reset the input so picking the same file twice still fires a change event.
|
|
231
|
+
event.target.value = '';
|
|
232
|
+
if (picked.length === 0) return;
|
|
233
|
+
|
|
234
|
+
const tooBig = picked.some((file) => file.size > photos.maxBytes);
|
|
235
|
+
const accepted = picked.filter((file) => file.size <= photos.maxBytes).slice(0, roomLeft);
|
|
236
|
+
if (tooBig) setError(t('photoTooLarge'));
|
|
237
|
+
|
|
238
|
+
if (accepted.length === 0) return;
|
|
239
|
+
setUploading(true);
|
|
240
|
+
try {
|
|
241
|
+
const done = await Promise.all(
|
|
242
|
+
accepted.map((file) => getClient().uploadReviewPhoto(productId, file))
|
|
243
|
+
);
|
|
244
|
+
setUploads((prev) => [...prev, ...done]);
|
|
245
|
+
if (!tooBig) setError(null);
|
|
246
|
+
} catch {
|
|
247
|
+
setError(t('photoUploadFailed'));
|
|
248
|
+
} finally {
|
|
249
|
+
setUploading(false);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
187
252
|
|
|
188
253
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
|
189
254
|
event.preventDefault();
|
|
@@ -191,7 +256,13 @@ function ReviewEditor({
|
|
|
191
256
|
setSubmitting(true);
|
|
192
257
|
try {
|
|
193
258
|
const client = getClient();
|
|
194
|
-
|
|
259
|
+
// imageKeys REPLACES the photo set, so send every key we still want. Keys,
|
|
260
|
+
// never urls — the url on an upload is only good for the local preview.
|
|
261
|
+
const input = {
|
|
262
|
+
rating,
|
|
263
|
+
body: body.trim() || undefined,
|
|
264
|
+
...(photos.enabled ? { imageKeys: uploads.map((upload) => upload.key) } : {}),
|
|
265
|
+
};
|
|
195
266
|
if (mode === 'edit') {
|
|
196
267
|
await client.updateMyProductReview(productId, input);
|
|
197
268
|
} else {
|
|
@@ -251,9 +322,56 @@ function ReviewEditor({
|
|
|
251
322
|
/>
|
|
252
323
|
</label>
|
|
253
324
|
|
|
325
|
+
{/* DESIGN ME — review photo picker. Renders only when the store allows
|
|
326
|
+
photos; every limit comes from `photos`, never hard-coded. */}
|
|
327
|
+
{photos.enabled && (
|
|
328
|
+
<fieldset>
|
|
329
|
+
<legend>
|
|
330
|
+
{t('addPhotos')} ({uploads.length}/{photos.maxPerReview})
|
|
331
|
+
</legend>
|
|
332
|
+
|
|
333
|
+
{uploads.length > 0 && (
|
|
334
|
+
<ul>
|
|
335
|
+
{uploads.map((upload) => (
|
|
336
|
+
<li key={upload.key}>
|
|
337
|
+
<img
|
|
338
|
+
src={upload.url}
|
|
339
|
+
alt=""
|
|
340
|
+
width={upload.width ?? undefined}
|
|
341
|
+
height={upload.height ?? undefined}
|
|
342
|
+
/>
|
|
343
|
+
<button
|
|
344
|
+
type="button"
|
|
345
|
+
onClick={() =>
|
|
346
|
+
setUploads((prev) => prev.filter((item) => item.key !== upload.key))
|
|
347
|
+
}
|
|
348
|
+
aria-label={t('removePhoto')}
|
|
349
|
+
>
|
|
350
|
+
×
|
|
351
|
+
</button>
|
|
352
|
+
</li>
|
|
353
|
+
))}
|
|
354
|
+
</ul>
|
|
355
|
+
)}
|
|
356
|
+
|
|
357
|
+
<input
|
|
358
|
+
type="file"
|
|
359
|
+
accept="image/jpeg,image/png,image/webp,image/gif"
|
|
360
|
+
multiple
|
|
361
|
+
disabled={uploading || roomLeft <= 0}
|
|
362
|
+
onChange={handleFiles}
|
|
363
|
+
/>
|
|
364
|
+
|
|
365
|
+
{uploading && <p>{t('photoUploading')}</p>}
|
|
366
|
+
{/* Said BEFORE they submit: a shopper who uploads, submits, and cannot
|
|
367
|
+
find their photo will conclude the site is broken. */}
|
|
368
|
+
{photos.requiresApproval && <p>{t('photosNeedApproval')}</p>}
|
|
369
|
+
</fieldset>
|
|
370
|
+
)}
|
|
371
|
+
|
|
254
372
|
{error && <p role="alert">{error}</p>}
|
|
255
373
|
|
|
256
|
-
<button type="submit" disabled={submitting}>
|
|
374
|
+
<button type="submit" disabled={submitting || uploading}>
|
|
257
375
|
{submitting
|
|
258
376
|
? mode === 'edit'
|
|
259
377
|
? t('saving')
|
|
@@ -1,96 +1,127 @@
|
|
|
1
|
-
import type { ProductReview } from 'brainerce';
|
|
2
|
-
import { getServerClient } from '@/core/lib/brainerce.server';
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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) { %>import { getMessages, defaultLocale } from '@/i18n';<% } else { %>import { defaultLocale, messages } from '@/i18n';<% } %>
|
|
5
|
+
import { ReviewForm } from './review-form';
|
|
6
|
+
|
|
7
|
+
// Server component — it renders before any client i18n context exists, so the
|
|
8
|
+
// wrapper strings (title / empty state / verified badge) are read straight
|
|
9
|
+
// from the `reviews` namespace in messages/, keyed by the store locale.
|
|
10
|
+
type ReviewStrings = Record<string, string>;
|
|
11
|
+
|
|
12
|
+
<% if (i18nEnabled) { %>async function getReviewStrings(locale: string): Promise<ReviewStrings> {
|
|
13
|
+
return ((await getMessages(locale)).reviews ?? {}) as ReviewStrings;
|
|
14
|
+
}<% } else { %>async function getReviewStrings(_locale: string): Promise<ReviewStrings> {
|
|
15
|
+
return messages.reviews as ReviewStrings;
|
|
16
|
+
}<% } %>
|
|
17
|
+
|
|
18
|
+
interface ReviewsSectionProps {
|
|
19
|
+
productId: string;
|
|
20
|
+
initialReviews?: ProductReview[];
|
|
21
|
+
locale?: string;
|
|
22
|
+
/** Where to send users back to after submit (defaults to the current product URL). */
|
|
23
|
+
returnUrl?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Renders the visible reviews for a product plus a submit form.
|
|
28
|
+
*
|
|
29
|
+
* Server component — fetches with the SDK on the server so reviews are in
|
|
30
|
+
* the initial HTML payload (good for SEO; JSON-LD on the same page already
|
|
31
|
+
* carries `aggregateRating` when reviewCount > 0).
|
|
32
|
+
*/
|
|
33
|
+
export async function ReviewsSection({
|
|
34
|
+
productId,
|
|
35
|
+
initialReviews,
|
|
36
|
+
locale = defaultLocale,
|
|
37
|
+
}: ReviewsSectionProps) {
|
|
38
|
+
const t = await getReviewStrings(locale);
|
|
39
|
+
let reviews: ProductReview[] = initialReviews ?? [];
|
|
40
|
+
|
|
41
|
+
if (!initialReviews) {
|
|
42
|
+
try {
|
|
43
|
+
const result = await (await getServerClient()).listProductReviews(productId, { page: 1, limit: 20, sort: 'photos_first' });
|
|
44
|
+
reviews = result.data;
|
|
45
|
+
} catch {
|
|
46
|
+
// Review listing disabled at store level or network error — render the form anyway.
|
|
47
|
+
reviews = [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<section aria-labelledby="reviews-heading">
|
|
53
|
+
{/* DESIGN ME — product reviews: server-fetched review list (SDK listProductReviews) + eligibility-aware submit form. Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
|
|
54
|
+
<h2 id="reviews-heading">{t.title}</h2>
|
|
55
|
+
|
|
56
|
+
{reviews.length === 0 ? (
|
|
57
|
+
<p>{t.noReviews}</p>
|
|
58
|
+
) : (
|
|
59
|
+
<ul>
|
|
60
|
+
{reviews.map((review) => (
|
|
61
|
+
<ReviewCard key={review.id} review={review} verifiedLabel={t.verifiedPurchase} />
|
|
62
|
+
))}
|
|
63
|
+
</ul>
|
|
64
|
+
)}
|
|
65
|
+
|
|
66
|
+
<ReviewForm productId={productId} />
|
|
67
|
+
</section>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function ReviewCard({ review, verifiedLabel }: { review: ProductReview; verifiedLabel: string }) {
|
|
72
|
+
return (
|
|
73
|
+
<li>
|
|
74
|
+
<article>
|
|
75
|
+
<header className="flex flex-wrap items-center gap-2">
|
|
76
|
+
<Stars rating={review.rating} />
|
|
77
|
+
<span>{review.authorName}</span>
|
|
78
|
+
{review.verifiedPurchase && <span>{verifiedLabel}</span>}
|
|
79
|
+
<time dateTime={review.createdAt}>{new Date(review.createdAt).toLocaleDateString()}</time>
|
|
80
|
+
</header>
|
|
81
|
+
{review.body && <p className="whitespace-pre-line break-words">{review.body}</p>}
|
|
82
|
+
<ReviewPhotos review={review} />
|
|
83
|
+
</article>
|
|
84
|
+
</li>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Photos the reviewer attached. `review.images` is always an array and already
|
|
90
|
+
* carries only what shoppers are allowed to see, so there is nothing to filter.
|
|
91
|
+
*
|
|
92
|
+
* The intrinsic width/height come back from the API — passing them through is
|
|
93
|
+
* what stops the page jumping as the photos load.
|
|
94
|
+
*/
|
|
95
|
+
function ReviewPhotos({ review }: { review: ProductReview }) {
|
|
96
|
+
if (review.images.length === 0) return null;
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<ul className="mt-3 flex flex-wrap gap-2">
|
|
100
|
+
{review.images.map((image) => (
|
|
101
|
+
<li key={image.id}>
|
|
102
|
+
<a href={image.url} target="_blank" rel="noopener noreferrer">
|
|
103
|
+
<CdnImage
|
|
104
|
+
src={image.thumbnailUrl ?? image.url}
|
|
105
|
+
alt=""
|
|
106
|
+
width={image.width ?? 96}
|
|
107
|
+
height={image.height ?? 96}
|
|
108
|
+
className="h-24 w-24 rounded-md border object-cover"
|
|
109
|
+
/>
|
|
110
|
+
</a>
|
|
111
|
+
</li>
|
|
112
|
+
))}
|
|
113
|
+
</ul>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function Stars({ rating }: { rating: number }) {
|
|
118
|
+
return (
|
|
119
|
+
<span aria-label={`${rating} of 5 stars`}>
|
|
120
|
+
{[1, 2, 3, 4, 5].map((n) => (
|
|
121
|
+
<span key={n} aria-hidden="true">
|
|
122
|
+
{n <= rating ? '★' : '☆'}
|
|
123
|
+
</span>
|
|
124
|
+
))}
|
|
125
|
+
</span>
|
|
126
|
+
);
|
|
127
|
+
}
|