create-brainerce-store 1.67.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 (40) 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/.env.local.ejs +7 -0
  6. package/templates/nextjs/base/AGENTS.md.ejs +7 -0
  7. package/templates/nextjs/base/CLAUDE.md.ejs +7 -0
  8. package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +8 -2
  9. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +16 -7
  10. package/templates/nextjs/base/src/app/checkout/page.tsx +1018 -1017
  11. package/templates/nextjs/base/src/app/error.tsx.ejs +53 -0
  12. package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +8 -2
  13. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +17 -7
  14. package/templates/nextjs/base/src/app/register/page.tsx +67 -64
  15. package/templates/nextjs/base/src/components/account/profile-section.tsx +303 -226
  16. package/templates/nextjs/base/src/components/auth/register-form.tsx +326 -245
  17. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +306 -294
  18. package/templates/nextjs/base/src/components/checkout/date-picker.tsx +13 -1
  19. package/templates/nextjs/base/src/components/checkout/datetime-picker.tsx +61 -21
  20. package/templates/nextjs/base/src/components/shared/birthday-picker.tsx +258 -0
  21. package/templates/nextjs/base/src/core/lib/auth.ts +162 -154
  22. package/templates/nextjs/base/src/core/lib/birthday.ts +74 -0
  23. package/templates/nextjs/base/src/core/lib/site-url.ts +42 -9
  24. package/templates/nextjs/base/src/core/lib/store-info.ts +10 -0
  25. package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +143 -0
  26. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +18 -2
  27. package/templates/nextjs/base/src/ui/product/back-in-stock-form.tsx +173 -0
  28. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +484 -455
  29. package/templates/nextjs/base/src/ui/product/review-form.tsx +136 -12
  30. package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +139 -108
  31. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +155 -142
  32. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +500 -477
  33. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +135 -11
  34. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +179 -148
  35. package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +122 -0
  36. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +87 -83
  37. package/templates/nextjs/ui-canvas/product/back-in-stock-form.tsx +151 -0
  38. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +373 -352
  39. package/templates/nextjs/ui-canvas/product/review-form.tsx +129 -11
  40. package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +127 -96
@@ -22,10 +22,20 @@ import { DatePicker } from '@/components/checkout/date-picker';
22
22
  * Which times exist comes from the SDK, not from a list hardcoded here:
23
23
  * - `computeAvailableSlots` → discrete slot starts, when the field defines
24
24
  * `slotDurationMinutes`. The submitted time must equal one of them exactly.
25
- * - `getBusinessHoursForDate` → the open/close window, when it doesn't. Slots
25
+ * - `getBusinessHoursForDate` → the open/close windows, when it doesn't. Slots
26
26
  * being empty does NOT mean the day is closed; that distinction is why this
27
27
  * component asks for the windows separately.
28
28
  *
29
+ * Three shapes the naive version of this component gets wrong:
30
+ * - A slot-based field whose slots have all been overtaken by `leadTimeMinutes`
31
+ * returns `[]` on an OPEN day. Falling through to the free time input there
32
+ * would accept any time and 400 on every one of them, so the two cases are
33
+ * split on `slotDurationMinutes` rather than on the slot count.
34
+ * - A weekday may carry SEVERAL windows (mornings and evenings). One input
35
+ * bounded by `windows[0]` would silently hide the second, so every window is
36
+ * rendered.
37
+ * - The relative bounds need `timezone` passed through, or the SDK skips them.
38
+ *
29
39
  * Dependency-free from any design pack, same as `date-picker.tsx`.
30
40
  */
31
41
  interface DateTimePickerProps {
@@ -35,6 +45,8 @@ interface DateTimePickerProps {
35
45
  onChange: (value: string) => void;
36
46
  required?: boolean;
37
47
  availability?: DateAvailabilityConstraints | null;
48
+ /** The store's IANA timezone, from `getStoreInfo().timezone`. Never the browser's. */
49
+ timezone?: string;
38
50
  placeholder: string;
39
51
  todayLabel: string;
40
52
  clearLabel: string;
@@ -42,6 +54,10 @@ interface DateTimePickerProps {
42
54
  nextMonthLabel: string;
43
55
  timeLabel: string;
44
56
  closedLabel: string;
57
+ /** Shown when the day is open but every slot on it has already gone past. */
58
+ noTimesLabel: string;
59
+ /** Joins the two ends of a window when a day trades more than one. */
60
+ toLabel: string;
45
61
  className?: string;
46
62
  }
47
63
 
@@ -56,6 +72,7 @@ export function DateTimePicker({
56
72
  onChange,
57
73
  required,
58
74
  availability,
75
+ timezone,
59
76
  placeholder,
60
77
  todayLabel,
61
78
  clearLabel,
@@ -63,6 +80,8 @@ export function DateTimePicker({
63
80
  nextMonthLabel,
64
81
  timeLabel,
65
82
  closedLabel,
83
+ noTimesLabel,
84
+ toLabel,
66
85
  className,
67
86
  }: DateTimePickerProps) {
68
87
  const submitted = splitValue(value);
@@ -71,11 +90,21 @@ export function DateTimePicker({
71
90
  // satisfied by a half-filled answer.
72
91
  const [date, setDate] = useState(submitted.date);
73
92
 
74
- const slots = date ? computeAvailableSlots(availability, date) : [];
75
- const windows = date ? getBusinessHoursForDate(availability, date) : [];
93
+ // No `now`: the SDK reads the real clock per call, so a page left open past
94
+ // the cutoff closes the day rather than failing at submit.
95
+ const clock = timezone ? { timezone } : undefined;
96
+ const slotBased = !!availability?.slotDurationMinutes;
97
+
98
+ const slots = date ? computeAvailableSlots(availability, date, clock) : [];
99
+ const windows = date ? getBusinessHoursForDate(availability, date, clock) : [];
100
+ // Grey out a day with nothing left on it. For a slot-based field that means
101
+ // no slots survive, which a windows-only check would miss on the lead-time
102
+ // boundary day and leave the shopper on a date with no times to pick.
76
103
  const openDay = (dateYYYYMMDD: string) =>
77
- !availability?.businessHours?.length ||
78
- getBusinessHoursForDate(availability, dateYYYYMMDD).length > 0;
104
+ slotBased
105
+ ? computeAvailableSlots(availability, dateYYYYMMDD, clock).length > 0
106
+ : !availability?.businessHours?.length ||
107
+ getBusinessHoursForDate(availability, dateYYYYMMDD, clock).length > 0;
79
108
 
80
109
  const commit = (nextDate: string, nextTime: string) => {
81
110
  setDate(nextDate);
@@ -90,6 +119,7 @@ export function DateTimePicker({
90
119
  onChange={(d) => commit(d, '')}
91
120
  required={required}
92
121
  availability={availability}
122
+ timezone={timezone}
93
123
  isDateSelectable={openDay}
94
124
  placeholder={placeholder}
95
125
  todayLabel={todayLabel}
@@ -119,24 +149,34 @@ export function DateTimePicker({
119
149
  </div>
120
150
  )}
121
151
 
122
- {date && slots.length === 0 && windows.length > 0 && (
123
- <label className="flex items-center gap-2 text-sm">
124
- <span className="text-muted-foreground">{timeLabel}</span>
125
- <input
126
- type="time"
127
- value={submitted.time}
128
- required={required}
129
- // The window is a half-open interval [open, close) server-side, so
130
- // `max` here is the last minute the API will still accept.
131
- min={windows[0].open}
132
- max={windows[0].close}
133
- onChange={(e) => commit(date, e.target.value)}
134
- className="bg-background focus-visible:ring-primary h-11 rounded-lg border px-3 text-sm focus-visible:outline-none focus-visible:ring-2"
135
- />
136
- </label>
152
+ {date && slotBased && slots.length === 0 && windows.length > 0 && (
153
+ <p className="text-muted-foreground text-xs">{noTimesLabel}</p>
154
+ )}
155
+
156
+ {date && !slotBased && windows.length > 0 && (
157
+ <div className="space-y-2">
158
+ {windows.map((w) => (
159
+ <label key={`${w.open}-${w.close}`} className="flex flex-wrap items-center gap-2 text-sm">
160
+ <span className="text-muted-foreground">
161
+ {windows.length > 1 ? `${timeLabel} (${w.open} ${toLabel} ${w.close})` : timeLabel}
162
+ </span>
163
+ <input
164
+ type="time"
165
+ value={submitted.time >= w.open && submitted.time < w.close ? submitted.time : ''}
166
+ required={required && windows.length === 1}
167
+ // The window is a half-open interval [open, close) server-side,
168
+ // so `max` here is the last minute the API will still accept.
169
+ min={w.open}
170
+ max={w.close}
171
+ onChange={(e) => commit(date, e.target.value)}
172
+ className="bg-background focus-visible:ring-primary h-11 rounded-lg border px-3 text-sm focus-visible:outline-none focus-visible:ring-2"
173
+ />
174
+ </label>
175
+ ))}
176
+ </div>
137
177
  )}
138
178
 
139
- {date && slots.length === 0 && windows.length === 0 && (
179
+ {date && windows.length === 0 && (
140
180
  <p className="text-muted-foreground text-xs">{closedLabel}</p>
141
181
  )}
142
182
  </div>
@@ -0,0 +1,258 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { useTranslations } from '@/core/lib/translations';
5
+ import { cn } from '@/core/lib/utils';
6
+ import {
7
+ BIRTH_MONTH_KEYS,
8
+ birthDayOptions,
9
+ birthMonthKey,
10
+ daysInBirthMonth,
11
+ } from '@/core/lib/birthday';
12
+
13
+ /**
14
+ * Single-field birthday picker: one trigger that reads like an ordinary date
15
+ * field, opening a popover with a month grid and a day grid.
16
+ *
17
+ * There is deliberately NO year anywhere. Brainerce stores a month and a day so
18
+ * the loyalty birthday gift can be sent without keeping anyone's age, which
19
+ * also rules out `<input type="date">` (it demands a year, and its OS-rendered
20
+ * popup cannot be restyled) and any date library.
21
+ *
22
+ * The popover is hand-rolled for the same reason `checkout/date-picker.tsx`
23
+ * hand-rolls its calendar: the scaffold ships no popover primitive, and
24
+ * `src/components/ui/` holds Radix wrappers only with no `react-popover`
25
+ * dependency to wrap. The trigger markup, the calendar icon and the popover
26
+ * container are taken from that file on purpose, so the two fields read as
27
+ * siblings rather than as two different ideas of what a date field looks like.
28
+ *
29
+ * Lives in `src/components/shared/` rather than `src/ui/`: canvas scaffolds
30
+ * wipe `src/ui` and ship no design pack but never touch `src/components/`, and
31
+ * both the account form and the signup form depend on this.
32
+ *
33
+ * The month and the day always leave together. `onChange` emits either a
34
+ * complete pair or two nulls and never one half, because the API rejects a
35
+ * month without a day with HTTP 400. Switching to a month that cannot hold
36
+ * the chosen day clears the birthday rather than moving the day.
37
+ */
38
+ interface BirthdayPickerProps {
39
+ /** Target for the field's `<label htmlFor>`. Lands on the trigger button. */
40
+ id?: string;
41
+ /** Committed month, 1-12, or null when no birthday is stored. */
42
+ month: number | null;
43
+ /** Committed day, 1-31, or null when no birthday is stored. */
44
+ day: number | null;
45
+ /** Emits both halves together. `(null, null)` means the shopper cleared it. */
46
+ onChange: (month: number | null, day: number | null) => void;
47
+ required?: boolean;
48
+ /** Paints the trigger with the error border while a message is showing. */
49
+ invalid?: boolean;
50
+ /** Merged onto the trigger so each form can match its own input sizing. */
51
+ triggerClassName?: string;
52
+ className?: string;
53
+ }
54
+
55
+ export function BirthdayPicker({
56
+ id,
57
+ month,
58
+ day,
59
+ onChange,
60
+ required,
61
+ invalid,
62
+ triggerClassName,
63
+ className,
64
+ }: BirthdayPickerProps) {
65
+ const t = useTranslations('common');
66
+ const [open, setOpen] = useState(false);
67
+ // The month the day grid is currently showing. A month tapped before any day
68
+ // exists cannot be emitted yet, since half a birthday is an HTTP 400, so it
69
+ // waits here until a day joins it.
70
+ const [draftMonth, setDraftMonth] = useState<number | null>(month);
71
+ const rootRef = useRef<HTMLDivElement>(null);
72
+ const triggerRef = useRef<HTMLButtonElement>(null);
73
+
74
+ useEffect(() => {
75
+ if (!open) return;
76
+ // `pointerdown` rather than the `mousedown` the checkout calendar uses: one
77
+ // event covers mouse, pen AND touch, so the popover dismisses on the first
78
+ // tap outside it on a phone instead of waiting for a synthesized mouse
79
+ // event that some mobile browsers delay or never send.
80
+ function onPointerDown(e: PointerEvent) {
81
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
82
+ }
83
+ function onKey(e: KeyboardEvent) {
84
+ if (e.key === 'Escape') {
85
+ setOpen(false);
86
+ triggerRef.current?.focus();
87
+ }
88
+ }
89
+ document.addEventListener('pointerdown', onPointerDown);
90
+ document.addEventListener('keydown', onKey);
91
+ return () => {
92
+ document.removeEventListener('pointerdown', onPointerDown);
93
+ document.removeEventListener('keydown', onKey);
94
+ };
95
+ }, [open]);
96
+
97
+ function toggle() {
98
+ // Re-sync the draft on the way open so a visit that picked a month and then
99
+ // backed out does not leave the day grid on a month nothing committed to.
100
+ if (!open) setDraftMonth(month);
101
+ setOpen((o) => !o);
102
+ }
103
+
104
+ function selectMonth(next: number) {
105
+ setDraftMonth(next);
106
+ if (day === null) return;
107
+ if (day > daysInBirthMonth(next)) {
108
+ // The committed day does not exist in the month just tapped, e.g. the
109
+ // 31st then February. CLEAR it rather than sliding it to the 29th: this
110
+ // field decides which day a gift email goes out on, and a number that
111
+ // quietly moved is far worse than one the shopper re-taps, because they
112
+ // may never notice it changed. The popover deliberately stays open so
113
+ // the narrowed day grid is the obvious next tap. The dashboard picker
114
+ // clears for the same reason, so both surfaces behave alike.
115
+ onChange(null, null);
116
+ return;
117
+ }
118
+ // The day survives the switch, so the pair can be re-emitted right away.
119
+ onChange(next, day);
120
+ }
121
+
122
+ function selectDay(next: number) {
123
+ if (draftMonth === null) return;
124
+ onChange(draftMonth, next);
125
+ setOpen(false);
126
+ triggerRef.current?.focus();
127
+ }
128
+
129
+ function clear() {
130
+ setDraftMonth(null);
131
+ onChange(null, null);
132
+ setOpen(false);
133
+ triggerRef.current?.focus();
134
+ }
135
+
136
+ // Formatted from the per-locale `birthdayDisplay` template rather than with
137
+ // `toLocaleDateString`, whose argument-less locale resolves from Node on the
138
+ // server and from the browser on the client and can disagree at hydration.
139
+ const committedMonthKey = month ? birthMonthKey(month) : null;
140
+ const displayValue =
141
+ committedMonthKey && day
142
+ ? t('birthdayDisplay', { month: t(committedMonthKey), day: String(day) })
143
+ : '';
144
+
145
+ return (
146
+ <div ref={rootRef} className={cn('relative', className)}>
147
+ <button
148
+ ref={triggerRef}
149
+ type="button"
150
+ id={id}
151
+ onClick={toggle}
152
+ aria-haspopup="dialog"
153
+ aria-expanded={open}
154
+ aria-required={required}
155
+ className={cn(
156
+ 'bg-background focus-visible:ring-primary relative flex h-10 w-full items-center rounded border px-3 pe-10 text-start text-sm focus-visible:outline-none focus-visible:ring-2',
157
+ invalid ? 'border-destructive' : 'border-border',
158
+ displayValue ? 'text-foreground' : 'text-muted-foreground',
159
+ triggerClassName
160
+ )}
161
+ >
162
+ {displayValue || t('birthdayPlaceholder')}
163
+ <svg
164
+ aria-hidden="true"
165
+ width={18}
166
+ height={18}
167
+ viewBox="0 0 24 24"
168
+ fill="none"
169
+ stroke="currentColor"
170
+ strokeWidth={1.75}
171
+ strokeLinecap="round"
172
+ strokeLinejoin="round"
173
+ className="text-muted-foreground pointer-events-none absolute end-3 top-1/2 -translate-y-1/2"
174
+ >
175
+ <rect x="3.5" y="5" width="17" height="15.5" rx="2.5" />
176
+ <path d="M3.5 9.5h17M8 3v3.5M16 3v3.5" />
177
+ </svg>
178
+ </button>
179
+
180
+ {open && (
181
+ <div
182
+ role="dialog"
183
+ aria-label={t('birthday')}
184
+ /*
185
+ * `start-0` opens the panel from the field's leading edge in both
186
+ * directions, so it stays on screen under `dir="rtl"` too. The width
187
+ * cap keeps it inside a 375px viewport, and the height cap with
188
+ * `overscroll-contain` lets a thumb scroll the panel itself without
189
+ * dragging the page behind it.
190
+ */
191
+ className="border-border bg-background absolute start-0 z-20 mt-2 max-h-[70vh] w-[19rem] max-w-[calc(100vw-2rem)] overflow-y-auto overscroll-contain rounded-2xl border p-4 shadow-xl"
192
+ >
193
+ <p className="text-muted-foreground mb-2 text-xs font-semibold">{t('birthdayMonth')}</p>
194
+ <div className="mb-4 grid grid-cols-3 gap-1">
195
+ {BIRTH_MONTH_KEYS.map((key, index) => {
196
+ const value = index + 1;
197
+ const isSelected = draftMonth === value;
198
+ return (
199
+ <button
200
+ key={key}
201
+ type="button"
202
+ onClick={() => selectMonth(value)}
203
+ aria-pressed={isSelected}
204
+ className={cn(
205
+ 'h-9 rounded-lg px-1 text-xs',
206
+ isSelected
207
+ ? 'bg-primary text-primary-foreground font-semibold'
208
+ : 'hover:bg-secondary'
209
+ )}
210
+ >
211
+ {t(key)}
212
+ </button>
213
+ );
214
+ })}
215
+ </div>
216
+
217
+ <p className="text-muted-foreground mb-2 text-xs font-semibold">{t('birthdayDay')}</p>
218
+ <div className="grid grid-cols-7 gap-0.5">
219
+ {birthDayOptions(draftMonth).map((value) => {
220
+ // Days stay inert until a month is chosen, so the picker can
221
+ // never hand back a day with no month beside it.
222
+ const disabled = draftMonth === null;
223
+ const isSelected = !disabled && draftMonth === month && day === value;
224
+ return (
225
+ <button
226
+ key={value}
227
+ type="button"
228
+ disabled={disabled}
229
+ onClick={() => selectDay(value)}
230
+ className={cn(
231
+ 'flex aspect-square items-center justify-center rounded-full text-sm tabular-nums',
232
+ disabled && 'text-muted-foreground/40 cursor-not-allowed',
233
+ !disabled && !isSelected && 'hover:bg-secondary',
234
+ isSelected && 'bg-primary text-primary-foreground font-bold'
235
+ )}
236
+ >
237
+ {value}
238
+ </button>
239
+ );
240
+ })}
241
+ </div>
242
+
243
+ {month !== null && day !== null && (
244
+ <div className="border-border mt-3 flex justify-end border-t pt-3">
245
+ <button
246
+ type="button"
247
+ onClick={clear}
248
+ className="text-primary text-sm font-semibold hover:underline"
249
+ >
250
+ {t('birthdayClear')}
251
+ </button>
252
+ </div>
253
+ )}
254
+ </div>
255
+ )}
256
+ </div>
257
+ );
258
+ }