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
@@ -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
- | { kind: 'edit'; review: ProductReview };
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
- onDeleted={() => setStage({ kind: 'submit' })}
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 <ReviewEditor productId={productId} mode="create" initialRating={5} initialBody="" />;
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
- const input = { rating, body: body.trim() || undefined };
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
+ &times;
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
- <% if (i18nEnabled) { %>import { getMessages, defaultLocale } from '@/i18n';<% } else { %>import { defaultLocale, messages } from '@/i18n';<% } %>
4
- import { ReviewForm } from './review-form';
5
-
6
- // Server component — it renders before any client i18n context exists, so the
7
- // wrapper strings (title / empty state / verified badge) are read straight
8
- // from the `reviews` namespace in messages/, keyed by the store locale.
9
- type ReviewStrings = Record<string, string>;
10
-
11
- <% if (i18nEnabled) { %>async function getReviewStrings(locale: string): Promise<ReviewStrings> {
12
- return ((await getMessages(locale)).reviews ?? {}) as ReviewStrings;
13
- }<% } else { %>async function getReviewStrings(_locale: string): Promise<ReviewStrings> {
14
- return messages.reviews as ReviewStrings;
15
- }<% } %>
16
-
17
- interface ReviewsSectionProps {
18
- productId: string;
19
- initialReviews?: ProductReview[];
20
- locale?: string;
21
- /** Where to send users back to after submit (defaults to the current product URL). */
22
- returnUrl?: string;
23
- }
24
-
25
- /**
26
- * Renders the visible reviews for a product plus a submit form.
27
- *
28
- * Server component — fetches with the SDK on the server so reviews are in
29
- * the initial HTML payload (good for SEO; JSON-LD on the same page already
30
- * carries `aggregateRating` when reviewCount > 0).
31
- */
32
- export async function ReviewsSection({
33
- productId,
34
- initialReviews,
35
- locale = defaultLocale,
36
- }: ReviewsSectionProps) {
37
- const t = await getReviewStrings(locale);
38
- let reviews: ProductReview[] = initialReviews ?? [];
39
-
40
- if (!initialReviews) {
41
- try {
42
- const result = await (await getServerClient()).listProductReviews(productId, { page: 1, limit: 20 });
43
- reviews = result.data;
44
- } catch {
45
- // Review listing disabled at store level or network error — render the form anyway.
46
- reviews = [];
47
- }
48
- }
49
-
50
- return (
51
- <section aria-labelledby="reviews-heading">
52
- {/* 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. */}
53
- <h2 id="reviews-heading">{t.title}</h2>
54
-
55
- {reviews.length === 0 ? (
56
- <p>{t.noReviews}</p>
57
- ) : (
58
- <ul>
59
- {reviews.map((review) => (
60
- <ReviewCard key={review.id} review={review} verifiedLabel={t.verifiedPurchase} />
61
- ))}
62
- </ul>
63
- )}
64
-
65
- <ReviewForm productId={productId} />
66
- </section>
67
- );
68
- }
69
-
70
- function ReviewCard({ review, verifiedLabel }: { review: ProductReview; verifiedLabel: string }) {
71
- return (
72
- <li>
73
- <article>
74
- <header className="flex flex-wrap items-center gap-2">
75
- <Stars rating={review.rating} />
76
- <span>{review.authorName}</span>
77
- {review.verifiedPurchase && <span>{verifiedLabel}</span>}
78
- <time dateTime={review.createdAt}>{new Date(review.createdAt).toLocaleDateString()}</time>
79
- </header>
80
- {review.body && <p className="whitespace-pre-line break-words">{review.body}</p>}
81
- </article>
82
- </li>
83
- );
84
- }
85
-
86
- function Stars({ rating }: { rating: number }) {
87
- return (
88
- <span aria-label={`${rating} of 5 stars`}>
89
- {[1, 2, 3, 4, 5].map((n) => (
90
- <span key={n} aria-hidden="true">
91
- {n <= rating ? '★' : '☆'}
92
- </span>
93
- ))}
94
- </span>
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
+ }