create-brainerce-store 1.58.0 → 1.59.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 CHANGED
@@ -31,7 +31,7 @@ var require_package = __commonJS({
31
31
  "package.json"(exports2, module2) {
32
32
  module2.exports = {
33
33
  name: "create-brainerce-store",
34
- version: "1.58.0",
34
+ version: "1.59.0",
35
35
  description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
36
36
  bin: {
37
37
  "create-brainerce-store": "dist/index.js"
package/messages/en.json CHANGED
@@ -245,6 +245,11 @@
245
245
  "customFieldsImageRemove": "Remove",
246
246
  "customFieldsImageUploading": "Uploading...",
247
247
  "customFieldsImageTooLarge": "File must be under 5MB",
248
+ "customFieldsDatePlaceholder": "Select a date",
249
+ "customFieldsDateToday": "Today",
250
+ "customFieldsDateClear": "Clear",
251
+ "customFieldsDatePrevMonth": "Previous month",
252
+ "customFieldsDateNextMonth": "Next month",
248
253
  "changeOptions": "Change options",
249
254
  "surcharges": "Additional charges"
250
255
  },
package/messages/he.json CHANGED
@@ -245,6 +245,11 @@
245
245
  "customFieldsImageRemove": "הסר",
246
246
  "customFieldsImageUploading": "...מעלה",
247
247
  "customFieldsImageTooLarge": "הקובץ חייב להיות מתחת ל-5MB",
248
+ "customFieldsDatePlaceholder": "בחרו תאריך",
249
+ "customFieldsDateToday": "היום",
250
+ "customFieldsDateClear": "נקה",
251
+ "customFieldsDatePrevMonth": "חודש קודם",
252
+ "customFieldsDateNextMonth": "חודש הבא",
248
253
  "changeOptions": "שנה אפשרויות",
249
254
  "surcharges": "תוספות"
250
255
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.58.0",
3
+ "version": "1.59.0",
4
4
  "description": "Scaffold a production-ready e-commerce storefront connected to Brainerce",
5
5
  "bin": {
6
6
  "create-brainerce-store": "dist/index.js"
@@ -1,258 +1,270 @@
1
- 'use client';
2
-
3
- import { useState } from 'react';
4
- import type { CheckoutCustomFieldDefinition } from 'brainerce';
5
- import { useTranslations } from '@/core/lib/translations';
6
- import { cn } from '@/core/lib/utils';
7
-
8
- interface CustomFieldsStepProps {
9
- fields: CheckoutCustomFieldDefinition[];
10
- values: Record<string, unknown>;
11
- onChange: (key: string, value: unknown) => void;
12
- onApply: () => void;
13
- onUploadFile?: (file: File) => Promise<{ url: string; key: string }>;
14
- loading?: boolean;
15
- className?: string;
16
- }
17
-
18
- const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
19
- const ACCEPTED_IMAGE_TYPES = 'image/jpeg,image/png,image/webp,image/gif';
20
-
21
- export function CustomFieldsStep({
22
- fields,
23
- values,
24
- onChange,
25
- onApply,
26
- onUploadFile,
27
- loading = false,
28
- className,
29
- }: CustomFieldsStepProps) {
30
- const t = useTranslations('checkout');
31
- const [uploadingKeys, setUploadingKeys] = useState<Set<string>>(new Set());
32
-
33
- const isMissing = fields.some((f) => {
34
- if (!f.required) return false;
35
- const v = values[f.key];
36
- return v === undefined || v === null || v === '';
37
- });
38
-
39
- return (
40
- <div className={cn('space-y-4', className)}>
41
- <p className="text-muted-foreground text-sm">{t('customFieldsSubtitle')}</p>
42
-
43
- {fields.map((field) => {
44
- const value = values[field.key];
45
- const labelEl = (
46
- <label
47
- htmlFor={`cf-${field.key}`}
48
- className="text-foreground mb-1 block text-sm font-medium"
49
- >
50
- {field.name}
51
- {field.required && <span className="text-destructive ms-1">*</span>}
52
- </label>
53
- );
54
- const helpEl = field.description ? (
55
- <p className="text-muted-foreground mt-1 text-xs">{field.description}</p>
56
- ) : null;
57
-
58
- switch (field.type) {
59
- case 'TEXT':
60
- return (
61
- <div key={field.key}>
62
- {labelEl}
63
- <input
64
- id={`cf-${field.key}`}
65
- type="text"
66
- value={(value as string) ?? ''}
67
- onChange={(e) => onChange(field.key, e.target.value)}
68
- required={field.required}
69
- minLength={field.minLength ?? undefined}
70
- maxLength={field.maxLength ?? undefined}
71
- className="border-border bg-background text-foreground w-full rounded border px-3 py-2 text-sm"
72
- />
73
- {helpEl}
74
- </div>
75
- );
76
-
77
- case 'TEXTAREA':
78
- return (
79
- <div key={field.key}>
80
- {labelEl}
81
- <textarea
82
- id={`cf-${field.key}`}
83
- value={(value as string) ?? ''}
84
- onChange={(e) => onChange(field.key, e.target.value)}
85
- required={field.required}
86
- minLength={field.minLength ?? undefined}
87
- maxLength={field.maxLength ?? undefined}
88
- rows={3}
89
- className="border-border bg-background text-foreground w-full rounded border px-3 py-2 text-sm"
90
- />
91
- {helpEl}
92
- </div>
93
- );
94
-
95
- case 'NUMBER':
96
- return (
97
- <div key={field.key}>
98
- {labelEl}
99
- <input
100
- id={`cf-${field.key}`}
101
- type="number"
102
- value={(value as number | string) ?? ''}
103
- onChange={(e) =>
104
- onChange(field.key, e.target.value === '' ? '' : Number(e.target.value))
105
- }
106
- required={field.required}
107
- min={field.minValue ?? undefined}
108
- max={field.maxValue ?? undefined}
109
- className="border-border bg-background text-foreground w-full rounded border px-3 py-2 text-sm"
110
- />
111
- {helpEl}
112
- </div>
113
- );
114
-
115
- case 'BOOLEAN':
116
- return (
117
- <div key={field.key} className="flex items-start gap-2">
118
- <input
119
- id={`cf-${field.key}`}
120
- type="checkbox"
121
- checked={value === true}
122
- onChange={(e) => onChange(field.key, e.target.checked)}
123
- className="mt-1"
124
- />
125
- <div className="flex-1">
126
- <label
127
- htmlFor={`cf-${field.key}`}
128
- className="text-foreground text-sm font-medium"
129
- >
130
- {field.name}
131
- {field.required && <span className="text-destructive ms-1">*</span>}
132
- </label>
133
- {helpEl}
134
- </div>
135
- </div>
136
- );
137
-
138
- case 'SELECT':
139
- return (
140
- <div key={field.key}>
141
- {labelEl}
142
- <select
143
- id={`cf-${field.key}`}
144
- value={(value as string) ?? ''}
145
- onChange={(e) => onChange(field.key, e.target.value)}
146
- required={field.required}
147
- className="border-border bg-background text-foreground w-full rounded border px-3 py-2 text-sm"
148
- >
149
- <option value="">{t('customFieldsSelectPlaceholder')}</option>
150
- {field.options?.map((opt) => (
151
- <option key={opt.value} value={opt.value}>
152
- {opt.label}
153
- </option>
154
- ))}
155
- </select>
156
- {helpEl}
157
- </div>
158
- );
159
-
160
- case 'DATE':
161
- return (
162
- <div key={field.key}>
163
- {labelEl}
164
- <input
165
- id={`cf-${field.key}`}
166
- type="date"
167
- value={(value as string) ?? ''}
168
- onChange={(e) => onChange(field.key, e.target.value)}
169
- required={field.required}
170
- className="border-border bg-background text-foreground w-full rounded border px-3 py-2 text-sm"
171
- />
172
- {helpEl}
173
- </div>
174
- );
175
-
176
- case 'IMAGE': {
177
- const isUploading = uploadingKeys.has(field.key);
178
- return (
179
- <div key={field.key}>
180
- {labelEl}
181
- {value ? (
182
- <div className="relative inline-block">
183
- <img
184
- src={value as string}
185
- alt={field.name}
186
- className="border-border max-h-32 rounded border object-contain"
187
- />
188
- <button
189
- type="button"
190
- onClick={() => onChange(field.key, '')}
191
- className="bg-background/80 text-foreground absolute end-1 top-1 rounded-full p-1 text-xs leading-none"
192
- aria-label={t('customFieldsImageRemove')}
193
- >
194
-
195
- </button>
196
- </div>
197
- ) : (
198
- <label
199
- htmlFor={`cf-${field.key}`}
200
- className={cn(
201
- 'border-border flex cursor-pointer flex-col items-center gap-2 rounded border-2 border-dashed p-6 text-center transition-colors',
202
- isUploading ? 'opacity-50' : 'hover:border-primary/40',
203
- )}
204
- >
205
- <span className="text-muted-foreground text-sm">
206
- {isUploading ? t('customFieldsImageUploading') : t('customFieldsImageUpload')}
207
- </span>
208
- <input
209
- id={`cf-${field.key}`}
210
- type="file"
211
- accept={ACCEPTED_IMAGE_TYPES}
212
- disabled={isUploading || !onUploadFile}
213
- className="hidden"
214
- onChange={async (e) => {
215
- const file = e.target.files?.[0];
216
- if (!file || !onUploadFile) return;
217
- if (file.size > MAX_IMAGE_SIZE) {
218
- alert(t('customFieldsImageTooLarge'));
219
- return;
220
- }
221
- setUploadingKeys((prev) => new Set(prev).add(field.key));
222
- try {
223
- const result = await onUploadFile(file);
224
- onChange(field.key, result.url);
225
- } catch {
226
- // Upload failed — user can retry
227
- } finally {
228
- setUploadingKeys((prev) => {
229
- const next = new Set(prev);
230
- next.delete(field.key);
231
- return next;
232
- });
233
- }
234
- }}
235
- />
236
- </label>
237
- )}
238
- {helpEl}
239
- </div>
240
- );
241
- }
242
-
243
- default:
244
- return null;
245
- }
246
- })}
247
-
248
- <button
249
- type="button"
250
- onClick={onApply}
251
- disabled={loading || isMissing}
252
- className="bg-primary text-primary-foreground w-full rounded px-4 py-3 text-sm font-medium transition-opacity hover:opacity-90 disabled:opacity-50"
253
- >
254
- {loading ? t('customFieldsApplying') : t('customFieldsApply')}
255
- </button>
256
- </div>
257
- );
258
- }
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import type { CheckoutCustomFieldDefinition } from 'brainerce';
5
+ import { useTranslations } from '@/core/lib/translations';
6
+ import { cn } from '@/core/lib/utils';
7
+ import { DatePicker } from '@/components/checkout/date-picker';
8
+
9
+ /** Inline, dependency-free — this file lives in the base template and must
10
+ * not assume any design pack (e.g. Atelier's icon set) is present; canvas
11
+ * scaffolds skip design packs entirely. */
12
+ const INPUT_CLASS =
13
+ 'h-11 w-full rounded-lg border bg-background px-3.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary';
14
+
15
+ interface CustomFieldsStepProps {
16
+ fields: CheckoutCustomFieldDefinition[];
17
+ values: Record<string, unknown>;
18
+ onChange: (key: string, value: unknown) => void;
19
+ onApply: () => void;
20
+ onUploadFile?: (file: File) => Promise<{ url: string; key: string }>;
21
+ loading?: boolean;
22
+ className?: string;
23
+ }
24
+
25
+ const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
26
+ const ACCEPTED_IMAGE_TYPES = 'image/jpeg,image/png,image/webp,image/gif';
27
+
28
+ export function CustomFieldsStep({
29
+ fields,
30
+ values,
31
+ onChange,
32
+ onApply,
33
+ onUploadFile,
34
+ loading = false,
35
+ className,
36
+ }: CustomFieldsStepProps) {
37
+ const t = useTranslations('checkout');
38
+ const [uploadingKeys, setUploadingKeys] = useState<Set<string>>(new Set());
39
+
40
+ const isMissing = fields.some((f) => {
41
+ if (!f.required) return false;
42
+ const v = values[f.key];
43
+ return v === undefined || v === null || v === '';
44
+ });
45
+
46
+ return (
47
+ <div className={cn('space-y-4', className)}>
48
+ <p className="text-muted-foreground text-sm">{t('customFieldsSubtitle')}</p>
49
+
50
+ {fields.map((field) => {
51
+ const value = values[field.key];
52
+ const labelEl = (
53
+ <label
54
+ htmlFor={`cf-${field.key}`}
55
+ className="text-foreground mb-1 block text-sm font-medium"
56
+ >
57
+ {field.name}
58
+ {field.required && <span className="text-destructive ms-1">*</span>}
59
+ </label>
60
+ );
61
+ const helpEl = field.description ? (
62
+ <p className="text-muted-foreground mt-1 text-xs">{field.description}</p>
63
+ ) : null;
64
+
65
+ switch (field.type) {
66
+ case 'TEXT':
67
+ return (
68
+ <div key={field.key}>
69
+ {labelEl}
70
+ <input
71
+ id={`cf-${field.key}`}
72
+ type="text"
73
+ value={(value as string) ?? ''}
74
+ onChange={(e) => onChange(field.key, e.target.value)}
75
+ required={field.required}
76
+ minLength={field.minLength ?? undefined}
77
+ maxLength={field.maxLength ?? undefined}
78
+ className={INPUT_CLASS}
79
+ />
80
+ {helpEl}
81
+ </div>
82
+ );
83
+
84
+ case 'TEXTAREA':
85
+ return (
86
+ <div key={field.key}>
87
+ {labelEl}
88
+ <textarea
89
+ id={`cf-${field.key}`}
90
+ value={(value as string) ?? ''}
91
+ onChange={(e) => onChange(field.key, e.target.value)}
92
+ required={field.required}
93
+ minLength={field.minLength ?? undefined}
94
+ maxLength={field.maxLength ?? undefined}
95
+ rows={3}
96
+ className="border-border bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-primary w-full rounded-lg border px-3.5 py-2.5 text-sm focus-visible:outline-none focus-visible:ring-2"
97
+ />
98
+ {helpEl}
99
+ </div>
100
+ );
101
+
102
+ case 'NUMBER':
103
+ return (
104
+ <div key={field.key}>
105
+ {labelEl}
106
+ <input
107
+ id={`cf-${field.key}`}
108
+ type="number"
109
+ value={(value as number | string) ?? ''}
110
+ onChange={(e) =>
111
+ onChange(field.key, e.target.value === '' ? '' : Number(e.target.value))
112
+ }
113
+ required={field.required}
114
+ min={field.minValue ?? undefined}
115
+ max={field.maxValue ?? undefined}
116
+ className={INPUT_CLASS}
117
+ />
118
+ {helpEl}
119
+ </div>
120
+ );
121
+
122
+ case 'BOOLEAN':
123
+ return (
124
+ <div key={field.key} className="flex items-start gap-2">
125
+ <input
126
+ id={`cf-${field.key}`}
127
+ type="checkbox"
128
+ checked={value === true}
129
+ onChange={(e) => onChange(field.key, e.target.checked)}
130
+ className="mt-1"
131
+ />
132
+ <div className="flex-1">
133
+ <label
134
+ htmlFor={`cf-${field.key}`}
135
+ className="text-foreground text-sm font-medium"
136
+ >
137
+ {field.name}
138
+ {field.required && <span className="text-destructive ms-1">*</span>}
139
+ </label>
140
+ {helpEl}
141
+ </div>
142
+ </div>
143
+ );
144
+
145
+ case 'SELECT':
146
+ return (
147
+ <div key={field.key}>
148
+ {labelEl}
149
+ <select
150
+ id={`cf-${field.key}`}
151
+ value={(value as string) ?? ''}
152
+ onChange={(e) => onChange(field.key, e.target.value)}
153
+ required={field.required}
154
+ className={INPUT_CLASS}
155
+ >
156
+ <option value="">{t('customFieldsSelectPlaceholder')}</option>
157
+ {field.options?.map((opt) => (
158
+ <option key={opt.value} value={opt.value}>
159
+ {opt.label}
160
+ </option>
161
+ ))}
162
+ </select>
163
+ {helpEl}
164
+ </div>
165
+ );
166
+
167
+ case 'DATE':
168
+ return (
169
+ <div key={field.key}>
170
+ {labelEl}
171
+ <DatePicker
172
+ id={`cf-${field.key}`}
173
+ value={(value as string) ?? ''}
174
+ onChange={(v) => onChange(field.key, v)}
175
+ required={field.required}
176
+ minDate={field.dateAvailability?.minDate ?? undefined}
177
+ maxDate={field.dateAvailability?.maxDate ?? undefined}
178
+ placeholder={t('customFieldsDatePlaceholder')}
179
+ todayLabel={t('customFieldsDateToday')}
180
+ clearLabel={t('customFieldsDateClear')}
181
+ prevMonthLabel={t('customFieldsDatePrevMonth')}
182
+ nextMonthLabel={t('customFieldsDateNextMonth')}
183
+ />
184
+ {helpEl}
185
+ </div>
186
+ );
187
+
188
+ case 'IMAGE': {
189
+ const isUploading = uploadingKeys.has(field.key);
190
+ return (
191
+ <div key={field.key}>
192
+ {labelEl}
193
+ {value ? (
194
+ <div className="relative inline-block">
195
+ <img
196
+ src={value as string}
197
+ alt={field.name}
198
+ className="border-border max-h-32 rounded border object-contain"
199
+ />
200
+ <button
201
+ type="button"
202
+ onClick={() => onChange(field.key, '')}
203
+ className="bg-background/80 text-foreground absolute end-1 top-1 rounded-full p-1 text-xs leading-none"
204
+ aria-label={t('customFieldsImageRemove')}
205
+ >
206
+
207
+ </button>
208
+ </div>
209
+ ) : (
210
+ <label
211
+ htmlFor={`cf-${field.key}`}
212
+ className={cn(
213
+ 'border-border flex cursor-pointer flex-col items-center gap-2 rounded border-2 border-dashed p-6 text-center transition-colors',
214
+ isUploading ? 'opacity-50' : 'hover:border-primary/40'
215
+ )}
216
+ >
217
+ <span className="text-muted-foreground text-sm">
218
+ {isUploading ? t('customFieldsImageUploading') : t('customFieldsImageUpload')}
219
+ </span>
220
+ <input
221
+ id={`cf-${field.key}`}
222
+ type="file"
223
+ accept={ACCEPTED_IMAGE_TYPES}
224
+ disabled={isUploading || !onUploadFile}
225
+ className="hidden"
226
+ onChange={async (e) => {
227
+ const file = e.target.files?.[0];
228
+ if (!file || !onUploadFile) return;
229
+ if (file.size > MAX_IMAGE_SIZE) {
230
+ alert(t('customFieldsImageTooLarge'));
231
+ return;
232
+ }
233
+ setUploadingKeys((prev) => new Set(prev).add(field.key));
234
+ try {
235
+ const result = await onUploadFile(file);
236
+ onChange(field.key, result.url);
237
+ } catch {
238
+ // Upload failed — user can retry
239
+ } finally {
240
+ setUploadingKeys((prev) => {
241
+ const next = new Set(prev);
242
+ next.delete(field.key);
243
+ return next;
244
+ });
245
+ }
246
+ }}
247
+ />
248
+ </label>
249
+ )}
250
+ {helpEl}
251
+ </div>
252
+ );
253
+ }
254
+
255
+ default:
256
+ return null;
257
+ }
258
+ })}
259
+
260
+ <button
261
+ type="button"
262
+ onClick={onApply}
263
+ disabled={loading || isMissing}
264
+ className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-12 w-full select-none items-center justify-center gap-2 whitespace-nowrap rounded-full px-8 text-base font-semibold transition-colors duration-200 disabled:pointer-events-none disabled:opacity-50"
265
+ >
266
+ {loading ? t('customFieldsApplying') : t('customFieldsApply')}
267
+ </button>
268
+ </div>
269
+ );
270
+ }
@@ -0,0 +1,305 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { cn } from '@/core/lib/utils';
5
+
6
+ /**
7
+ * Custom calendar dropdown for checkout DATE custom fields — replaces the
8
+ * native `<input type="date">`, whose OS/browser-rendered popup can't be
9
+ * restyled with CSS in any browser (only the closed field can be).
10
+ *
11
+ * Lives next to `custom-fields-step.tsx` (not under `src/ui/`) and stays
12
+ * dependency-free from any design pack for the same reason that file does:
13
+ * canvas scaffolds wipe `src/ui` and ship no pack, but never touch
14
+ * `src/components/`, so this must work without either.
15
+ *
16
+ * `minDate`/`maxDate` ("YYYY-MM-DD") disable individual out-of-range days.
17
+ * Blocked weekdays/dates and DATETIME business-hours slots are out of scope —
18
+ * `dateAvailability` isn't read here.
19
+ */
20
+ interface DatePickerProps {
21
+ id?: string;
22
+ value: string; // 'YYYY-MM-DD' or ''
23
+ onChange: (value: string) => void;
24
+ required?: boolean;
25
+ minDate?: string;
26
+ maxDate?: string;
27
+ placeholder: string;
28
+ todayLabel: string;
29
+ clearLabel: string;
30
+ prevMonthLabel: string;
31
+ nextMonthLabel: string;
32
+ className?: string;
33
+ }
34
+
35
+ function pad(n: number): string {
36
+ return String(n).padStart(2, '0');
37
+ }
38
+
39
+ function toYMD(y: number, m: number, d: number): string {
40
+ return `${y}-${pad(m + 1)}-${pad(d)}`;
41
+ }
42
+
43
+ function parseYMD(value: string): { y: number; m: number; d: number } | null {
44
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
45
+ if (!match) return null;
46
+ return { y: Number(match[1]), m: Number(match[2]) - 1, d: Number(match[3]) };
47
+ }
48
+
49
+ export function DatePicker({
50
+ id,
51
+ value,
52
+ onChange,
53
+ required,
54
+ minDate,
55
+ maxDate,
56
+ placeholder,
57
+ todayLabel,
58
+ clearLabel,
59
+ prevMonthLabel,
60
+ nextMonthLabel,
61
+ className,
62
+ }: DatePickerProps) {
63
+ const [open, setOpen] = useState(false);
64
+ const [locale, setLocale] = useState<string | undefined>(undefined);
65
+ const [rtl, setRtl] = useState(false);
66
+ const rootRef = useRef<HTMLDivElement>(null);
67
+ const triggerRef = useRef<HTMLButtonElement>(null);
68
+
69
+ useEffect(() => {
70
+ setLocale(document.documentElement.lang || undefined);
71
+ setRtl(document.documentElement.dir === 'rtl');
72
+ }, []);
73
+
74
+ const selected = parseYMD(value);
75
+ const min = minDate ? parseYMD(minDate) : null;
76
+ const max = maxDate ? parseYMD(maxDate) : null;
77
+ const today = new Date();
78
+ const [view, setView] = useState(() => {
79
+ const initial = selected ?? { y: today.getFullYear(), m: today.getMonth() };
80
+ return { y: initial.y, m: initial.m };
81
+ });
82
+
83
+ useEffect(() => {
84
+ if (!open) return;
85
+ function onDocClick(e: MouseEvent) {
86
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
87
+ }
88
+ function onKey(e: KeyboardEvent) {
89
+ if (e.key === 'Escape') {
90
+ setOpen(false);
91
+ triggerRef.current?.focus();
92
+ }
93
+ }
94
+ document.addEventListener('mousedown', onDocClick);
95
+ document.addEventListener('keydown', onKey);
96
+ return () => {
97
+ document.removeEventListener('mousedown', onDocClick);
98
+ document.removeEventListener('keydown', onKey);
99
+ };
100
+ }, [open]);
101
+
102
+ function isDisabled(y: number, m: number, d: number): boolean {
103
+ if (min && (y < min.y || (y === min.y && (m < min.m || (m === min.m && d < min.d))))) {
104
+ return true;
105
+ }
106
+ if (max && (y > max.y || (y === max.y && (m > max.m || (m === max.m && d > max.d))))) {
107
+ return true;
108
+ }
109
+ return false;
110
+ }
111
+
112
+ function selectDay(y: number, m: number, d: number) {
113
+ if (isDisabled(y, m, d)) return;
114
+ onChange(toYMD(y, m, d));
115
+ setView({ y, m });
116
+ setOpen(false);
117
+ triggerRef.current?.focus();
118
+ }
119
+
120
+ function shiftMonth(delta: number) {
121
+ setView((v) => {
122
+ let m = v.m + delta;
123
+ let y = v.y;
124
+ if (m < 0) {
125
+ m = 11;
126
+ y -= 1;
127
+ } else if (m > 11) {
128
+ m = 0;
129
+ y += 1;
130
+ }
131
+ return { y, m };
132
+ });
133
+ }
134
+
135
+ const monthLabel = new Date(view.y, view.m, 1).toLocaleDateString(locale, {
136
+ month: 'long',
137
+ year: 'numeric',
138
+ });
139
+ const weekdayLabels = Array.from({ length: 7 }, (_, i) => {
140
+ // A Sunday-anchored week (2023-01-01 was a Sunday) formatted with the
141
+ // page's own locale — matches whatever week-start convention that
142
+ // locale's date formatting implies.
143
+ const d = new Date(2023, 0, 1 + i);
144
+ return d.toLocaleDateString(locale, { weekday: 'narrow' });
145
+ });
146
+
147
+ const firstOfMonth = new Date(view.y, view.m, 1);
148
+ const startWeekday = firstOfMonth.getDay();
149
+ const daysInMonth = new Date(view.y, view.m + 1, 0).getDate();
150
+ const daysInPrevMonth = new Date(view.y, view.m, 0).getDate();
151
+
152
+ const cells: Array<{ day: number; muted: boolean; y: number; m: number }> = [];
153
+ for (let i = startWeekday - 1; i >= 0; i--) {
154
+ const m = view.m === 0 ? 11 : view.m - 1;
155
+ const y = view.m === 0 ? view.y - 1 : view.y;
156
+ cells.push({ day: daysInPrevMonth - i, muted: true, y, m });
157
+ }
158
+ for (let d = 1; d <= daysInMonth; d++) {
159
+ cells.push({ day: d, muted: false, y: view.y, m: view.m });
160
+ }
161
+ while (cells.length % 7 !== 0) {
162
+ const idx = cells.length - startWeekday - daysInMonth + 1;
163
+ const m = view.m === 11 ? 0 : view.m + 1;
164
+ const y = view.m === 11 ? view.y + 1 : view.y;
165
+ cells.push({ day: idx, muted: true, y, m });
166
+ }
167
+
168
+ const displayValue = selected
169
+ ? new Date(selected.y, selected.m, selected.d).toLocaleDateString(locale)
170
+ : '';
171
+
172
+ return (
173
+ <div ref={rootRef} className={cn('relative', className)}>
174
+ <button
175
+ ref={triggerRef}
176
+ type="button"
177
+ id={id}
178
+ onClick={() => setOpen((o) => !o)}
179
+ aria-haspopup="dialog"
180
+ aria-expanded={open}
181
+ aria-required={required}
182
+ className={cn(
183
+ 'bg-background focus-visible:ring-primary flex h-11 w-full items-center rounded-lg border px-3.5 pe-11 text-start text-sm focus-visible:outline-none focus-visible:ring-2',
184
+ displayValue ? 'text-foreground' : 'text-muted-foreground'
185
+ )}
186
+ >
187
+ {displayValue || placeholder}
188
+ <svg
189
+ aria-hidden="true"
190
+ width={18}
191
+ height={18}
192
+ viewBox="0 0 24 24"
193
+ fill="none"
194
+ stroke="currentColor"
195
+ strokeWidth={1.75}
196
+ strokeLinecap="round"
197
+ strokeLinejoin="round"
198
+ className="text-muted-foreground pointer-events-none absolute end-3.5 top-1/2 -translate-y-1/2"
199
+ >
200
+ <rect x="3.5" y="5" width="17" height="15.5" rx="2.5" />
201
+ <path d="M3.5 9.5h17M8 3v3.5M16 3v3.5" />
202
+ </svg>
203
+ </button>
204
+
205
+ {open && (
206
+ <div
207
+ role="dialog"
208
+ aria-label={placeholder}
209
+ className="border-border bg-background absolute start-0 z-20 mt-2 w-[19rem] rounded-2xl border p-4 shadow-xl"
210
+ >
211
+ <div className="mb-3 flex items-center justify-between">
212
+ <div className="flex gap-1">
213
+ <button
214
+ type="button"
215
+ onClick={() => shiftMonth(rtl ? 1 : -1)}
216
+ aria-label={prevMonthLabel}
217
+ className="border-border hover:bg-secondary flex h-8 w-8 items-center justify-center rounded-full border text-sm"
218
+ >
219
+ {rtl ? '›' : '‹'}
220
+ </button>
221
+ <button
222
+ type="button"
223
+ onClick={() => shiftMonth(rtl ? -1 : 1)}
224
+ aria-label={nextMonthLabel}
225
+ className="border-border hover:bg-secondary flex h-8 w-8 items-center justify-center rounded-full border text-sm"
226
+ >
227
+ {rtl ? '‹' : '›'}
228
+ </button>
229
+ </div>
230
+ <div className="font-display text-sm font-medium capitalize">{monthLabel}</div>
231
+ </div>
232
+
233
+ <div className="mb-1 grid grid-cols-7">
234
+ {weekdayLabels.map((w, i) => (
235
+ <span
236
+ key={i}
237
+ className="text-muted-foreground py-1 text-center text-[0.7rem] font-semibold"
238
+ >
239
+ {w}
240
+ </span>
241
+ ))}
242
+ </div>
243
+
244
+ <div className="grid grid-cols-7 gap-0.5">
245
+ {cells.map((c, i) => {
246
+ const disabled = c.muted || isDisabled(c.y, c.m, c.day);
247
+ const isToday =
248
+ !c.muted &&
249
+ c.y === today.getFullYear() &&
250
+ c.m === today.getMonth() &&
251
+ c.day === today.getDate();
252
+ const isSelected =
253
+ selected &&
254
+ !c.muted &&
255
+ c.y === selected.y &&
256
+ c.m === selected.m &&
257
+ c.day === selected.d;
258
+ return (
259
+ <button
260
+ key={i}
261
+ type="button"
262
+ disabled={disabled}
263
+ onClick={() => selectDay(c.y, c.m, c.day)}
264
+ className={cn(
265
+ 'flex aspect-square items-center justify-center rounded-full text-sm tabular-nums',
266
+ c.muted && 'text-muted-foreground/40',
267
+ disabled && !c.muted && 'text-muted-foreground/40 cursor-not-allowed',
268
+ !disabled && !isSelected && 'hover:bg-secondary',
269
+ isToday && !isSelected && 'ring-primary font-semibold ring-1',
270
+ isSelected && 'bg-primary text-primary-foreground font-bold'
271
+ )}
272
+ >
273
+ {c.day}
274
+ </button>
275
+ );
276
+ })}
277
+ </div>
278
+
279
+ <div className="border-border mt-3 flex justify-between border-t pt-3">
280
+ <button
281
+ type="button"
282
+ onClick={() => {
283
+ const t = new Date();
284
+ selectDay(t.getFullYear(), t.getMonth(), t.getDate());
285
+ }}
286
+ className="text-primary text-sm font-semibold hover:underline"
287
+ >
288
+ {todayLabel}
289
+ </button>
290
+ <button
291
+ type="button"
292
+ onClick={() => {
293
+ onChange('');
294
+ setOpen(false);
295
+ }}
296
+ className="text-primary text-sm font-semibold hover:underline"
297
+ >
298
+ {clearLabel}
299
+ </button>
300
+ </div>
301
+ </div>
302
+ )}
303
+ </div>
304
+ );
305
+ }