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
|
@@ -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
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
<
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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 &&
|
|
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
|
+
}
|