create-brainerce-store 1.68.0 → 1.72.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 (50) hide show
  1. package/README.md +31 -10
  2. package/dist/index.js +197 -105
  3. package/messages/en.json +63 -3
  4. package/messages/he.json +63 -3
  5. package/package.json +1 -1
  6. package/templates/nextjs/base/TRANSLATIONS.md +14 -7
  7. package/templates/nextjs/base/src/app/checkout/page.tsx +1074 -1017
  8. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +21 -2
  9. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +1 -1
  10. package/templates/nextjs/base/src/app/register/page.tsx +67 -64
  11. package/templates/nextjs/base/src/components/account/order-history.tsx +25 -39
  12. package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +30 -11
  13. package/templates/nextjs/base/src/components/account/profile-section.tsx +303 -226
  14. package/templates/nextjs/base/src/components/auth/register-form.tsx +326 -245
  15. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +306 -294
  16. package/templates/nextjs/base/src/components/checkout/date-picker.tsx +13 -1
  17. package/templates/nextjs/base/src/components/checkout/datetime-picker.tsx +61 -21
  18. package/templates/nextjs/base/src/components/shared/birthday-picker.tsx +258 -0
  19. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +71 -2
  20. package/templates/nextjs/base/src/core/lib/auth.ts +155 -154
  21. package/templates/nextjs/base/src/core/lib/birthday.ts +74 -0
  22. package/templates/nextjs/base/src/core/lib/brainerce.ts.ejs +5 -16
  23. package/templates/nextjs/base/src/core/lib/store-info.ts +10 -0
  24. package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +3 -6
  25. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +19 -1
  26. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +42 -6
  27. package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +52 -10
  28. package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +143 -0
  29. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +18 -2
  30. package/templates/nextjs/base/src/ui/product/back-in-stock-form.tsx +173 -0
  31. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +484 -455
  32. package/templates/nextjs/base/src/ui/product/review-form.tsx +136 -12
  33. package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +139 -108
  34. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +21 -3
  35. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +19 -1
  36. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +44 -7
  37. package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +52 -10
  38. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +155 -142
  39. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +500 -477
  40. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +135 -11
  41. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +179 -148
  42. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +15 -1
  43. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +38 -4
  44. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +54 -11
  45. package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +122 -0
  46. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +87 -83
  47. package/templates/nextjs/ui-canvas/product/back-in-stock-form.tsx +151 -0
  48. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +373 -352
  49. package/templates/nextjs/ui-canvas/product/review-form.tsx +129 -11
  50. 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
- | { kind: 'edit'; review: ProductReview };
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
- onDeleted={() => setStage({ kind: 'submit' })}
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 <ReviewEditor productId={productId} mode="create" initialRating={5} initialBody="" />;
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
- const input = { rating, body: body.trim() || undefined };
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 { Badge } from '@/components/ui/badge';
5
- <% if (i18nEnabled) { %>import { getMessages, defaultLocale } from '@/i18n';<% } else { %>import { defaultLocale, messages } from '@/i18n';<% } %>
6
- import { ReviewForm } from './review-form';
7
-
8
- // Server component — it renders before any client i18n context exists, so the
9
- // wrapper strings (title / empty state / verified badge) are read straight
10
- // from the `reviews` namespace in messages/, keyed by the store locale.
11
- type ReviewStrings = Record<string, string>;
12
-
13
- <% if (i18nEnabled) { %>async function getReviewStrings(locale: string): Promise<ReviewStrings> {
14
- return ((await getMessages(locale)).reviews ?? {}) as ReviewStrings;
15
- }<% } else { %>async function getReviewStrings(_locale: string): Promise<ReviewStrings> {
16
- return messages.reviews as ReviewStrings;
17
- }<% } %>
18
-
19
- interface ReviewsSectionProps {
20
- productId: string;
21
- initialReviews?: ProductReview[];
22
- locale?: string;
23
- /** Where to send users back to after submit (defaults to the current product URL). */
24
- returnUrl?: string;
25
- }
26
-
27
- /**
28
- * Renders the visible reviews for a product plus a submit form.
29
- *
30
- * Server component — fetches with the SDK on the server so reviews are in
31
- * the initial HTML payload (good for SEO; JSON-LD on the same page already
32
- * carries `aggregateRating` when reviewCount > 0).
33
- */
34
- export async function ReviewsSection({
35
- productId,
36
- initialReviews,
37
- locale = defaultLocale,
38
- }: ReviewsSectionProps) {
39
- const t = await getReviewStrings(locale);
40
- let reviews: ProductReview[] = initialReviews ?? [];
41
-
42
- if (!initialReviews) {
43
- try {
44
- const result = await (await getServerClient()).listProductReviews(productId, { page: 1, limit: 20 });
45
- reviews = result.data;
46
- } catch {
47
- // Review listing disabled at store level or network error — render the form anyway.
48
- reviews = [];
49
- }
50
- }
51
-
52
- return (
53
- <section className="mt-8" aria-labelledby="reviews-heading">
54
- <h2 id="reviews-heading" className="mb-4 text-xl font-semibold">
55
- {t.title}
56
- </h2>
57
-
58
- {reviews.length === 0 ? (
59
- <p className="text-muted-foreground mb-6 text-sm">{t.noReviews}</p>
60
- ) : (
61
- <ul className="mb-6 space-y-4">
62
- {reviews.map((review) => (
63
- <ReviewCard key={review.id} review={review} verifiedLabel={t.verifiedPurchase} />
64
- ))}
65
- </ul>
66
- )}
67
-
68
- <ReviewForm productId={productId} />
69
- </section>
70
- );
71
- }
72
-
73
- function ReviewCard({ review, verifiedLabel }: { review: ProductReview; verifiedLabel: string }) {
74
- return (
75
- <li className="rounded-md border p-4">
76
- <header className="flex flex-wrap items-center gap-2">
77
- <Stars rating={review.rating} />
78
- <span className="text-sm font-medium">{review.authorName}</span>
79
- {review.verifiedPurchase && (
80
- <Badge
81
- variant="outline"
82
- className="rounded border-transparent bg-emerald-50 px-2 py-0.5 font-normal text-emerald-700"
83
- >
84
- {verifiedLabel}
85
- </Badge>
86
- )}
87
- <time className="text-muted-foreground ms-auto text-xs">
88
- {new Date(review.createdAt).toLocaleDateString()}
89
- </time>
90
- </header>
91
- {review.body && <p className="mt-2 whitespace-pre-line break-words text-sm">{review.body}</p>}
92
- </li>
93
- );
94
- }
95
-
96
- function Stars({ rating }: { rating: number }) {
97
- return (
98
- <span aria-label={`${rating} of 5 stars`} className="inline-flex">
99
- {[1, 2, 3, 4, 5].map((n) => (
100
- <Star
101
- key={n}
102
- className={`h-4 w-4 fill-current ${n <= rating ? 'text-yellow-500' : 'text-gray-300'}`}
103
- aria-hidden="true"
104
- />
105
- ))}
106
- </span>
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
+ }
@@ -147,9 +147,27 @@ export function CartDrawer() {
147
147
  )}
148
148
  <p className="text-muted-foreground mt-1 text-xs">{t('shippingAtCheckout')}</p>
149
149
  <div className="mt-4 grid gap-2">
150
- <Link href="/checkout" onClick={close} className="btn-primary btn-lg w-full">
151
- {t('proceedToCheckout')}
152
- </Link>
150
+ {/* Same gate as the cart page: a line the server marked
151
+ unavailable blocks checkout. A disabled-looking anchor
152
+ would still navigate, so use a real disabled button. */}
153
+ {items.some((item) => item.isAvailable === false) ? (
154
+ <>
155
+ <button
156
+ type="button"
157
+ disabled
158
+ className="btn-primary btn-lg w-full opacity-50"
159
+ >
160
+ {t('proceedToCheckout')}
161
+ </button>
162
+ <p className="text-destructive text-center text-xs">
163
+ {t('unavailableItemsHint')}
164
+ </p>
165
+ </>
166
+ ) : (
167
+ <Link href="/checkout" onClick={close} className="btn-primary btn-lg w-full">
168
+ {t('proceedToCheckout')}
169
+ </Link>
170
+ )}
153
171
  <Link href="/cart" onClick={close} className="btn-outline btn-lg w-full">
154
172
  {t('viewCart')}
155
173
  </Link>
@@ -34,6 +34,17 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
34
34
  const unitPrice = parseFloat(item.unitPrice);
35
35
  const lineTotal = unitPrice * item.quantity;
36
36
 
37
+ // The server decides purchasability, per line. `isAvailable === false` means
38
+ // this line blocks checkout until it is removed, whether the stock ran out
39
+ // (a released reservation is the common cause) or the product/variant was
40
+ // withdrawn from sale. `unavailableReason` separates the two for the label.
41
+ const isUnavailable = item.isAvailable === false;
42
+ const unavailableLabel = isUnavailable
43
+ ? item.unavailableReason === 'OUT_OF_STOCK'
44
+ ? td('outOfStock')
45
+ : td('unavailable')
46
+ : null;
47
+
37
48
  async function handleQuantityChange(newQuantity: number) {
38
49
  if (newQuantity < 1 || updating) return;
39
50
 
@@ -81,6 +92,13 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
81
92
  {productName}
82
93
  </h3>
83
94
 
95
+ {/* Availability badge. This line blocks checkout while it shows. */}
96
+ {unavailableLabel && (
97
+ <span className="mt-1 inline-flex items-center rounded-full bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive">
98
+ {unavailableLabel}
99
+ </span>
100
+ )}
101
+
84
102
  {/* Variant name */}
85
103
  {variantName && <p className="mt-0.5 text-xs text-muted-foreground">{variantName}</p>}
86
104
 
@@ -111,7 +129,7 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
111
129
  <button
112
130
  type="button"
113
131
  onClick={() => handleQuantityChange(item.quantity + 1)}
114
- disabled={updating}
132
+ disabled={updating || isUnavailable}
115
133
  aria-label={td('increaseQuantity')}
116
134
  className="flex h-full w-9 items-center justify-center rounded-e-full transition-colors hover:bg-secondary disabled:opacity-40"
117
135
  >
@@ -18,7 +18,20 @@ import { IconBag, IconArrowEnd, IconShield } from '@/ui/shared/icons';
18
18
  export function CartView() {
19
19
  const t = useTranslations('cart');
20
20
  const tc = useTranslations('common');
21
- const { cart, cartLoading, refreshCart, itemCount, cartRecs, upgrades, bundles } = useCartPage();
21
+ const tr = useTranslations('reservation');
22
+ const {
23
+ cart,
24
+ cartLoading,
25
+ refreshCart,
26
+ itemCount,
27
+ cartRecs,
28
+ upgrades,
29
+ bundles,
30
+ reservationExpired,
31
+ unavailableItems,
32
+ canProceedToCheckout,
33
+ onReservationExpired,
34
+ } = useCartPage();
22
35
 
23
36
  if (cartLoading) {
24
37
  return <LoadingSpinner size="lg" className="min-h-[60vh]" />;
@@ -52,9 +65,14 @@ export function CartView() {
52
65
  </span>
53
66
  </h1>
54
67
 
55
- {/* Reservation countdown */}
68
+ {/* Reservation countdown. onExpire refreshes the cart and closes the
69
+ checkout gate below. Passing it is what makes expiry mean anything. */}
56
70
  {cart.reservation?.hasReservation && (
57
- <ReservationCountdown reservation={cart.reservation} className="mt-4" />
71
+ <ReservationCountdown
72
+ reservation={cart.reservation}
73
+ onExpire={onReservationExpired}
74
+ className="mt-4"
75
+ />
58
76
  )}
59
77
 
60
78
  <div className="mt-6 grid grid-cols-1 items-start gap-8 lg:grid-cols-3 lg:gap-10">
@@ -112,10 +130,29 @@ export function CartView() {
112
130
  <FreeShippingBar />
113
131
  <CartSummary />
114
132
 
115
- <Link href="/checkout" className="btn-primary btn-lg w-full">
116
- {t('proceedToCheckout')}
117
- <IconArrowEnd size={20} className="rtl-flip" />
118
- </Link>
133
+ {/* Proceed to checkout. When the gate is closed this must be a
134
+ real disabled control, never a styled-down link: an anchor
135
+ ignores `disabled` and would still navigate. */}
136
+ {canProceedToCheckout ? (
137
+ <Link href="/checkout" className="btn-primary btn-lg w-full">
138
+ {t('proceedToCheckout')}
139
+ <IconArrowEnd size={20} className="rtl-flip" />
140
+ </Link>
141
+ ) : (
142
+ <div>
143
+ <button type="button" disabled className="btn-primary btn-lg w-full opacity-50">
144
+ {t('proceedToCheckout')}
145
+ <IconArrowEnd size={20} className="rtl-flip" />
146
+ </button>
147
+ <p className="mt-2 text-center text-xs text-destructive">
148
+ {unavailableItems.length > 0
149
+ ? t('unavailableItemsHint')
150
+ : reservationExpired
151
+ ? tr('expiredHint')
152
+ : null}
153
+ </p>
154
+ </div>
155
+ )}
119
156
 
120
157
  <p className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
121
158
  <IconShield size={16} />