create-brainerce-store 1.57.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.57.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.57.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
+ }
@@ -77,6 +77,13 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
77
77
  const { storeInfo } = useStoreInfo();
78
78
 
79
79
  const [paymentIntent, setPaymentIntent] = useState<PaymentIntent | null>(null);
80
+
81
+ // The provider's payment URL. `clientSdk.renderArg` is the URL; `clientSecret`
82
+ // is only a fallback for providers that duplicate it there. Reading
83
+ // clientSecret alone breaks providers that return a real identifier (MAX,
84
+ // Takbull) — the iframe/link silently points at an id instead of a page.
85
+ const paymentIntentUrl =
86
+ paymentIntent?.clientSdk?.renderArg || paymentIntent?.clientSecret || '';
80
87
  const [preloadedSdk, setPreloadedSdk] = useState<PaymentClientSdk | null>(null);
81
88
  const [loading, setLoading] = useState(true);
82
89
  const [error, setError] = useState<string | null>(null);
@@ -368,12 +375,20 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
368
375
  // Sandbox mode — no SDK to load, UI handles it
369
376
  if (sdk.renderType === 'sandbox') return;
370
377
 
378
+ // The URL to send the customer to is `renderArg`; `clientSecret` is only
379
+ // a fallback for providers that duplicate the URL into it. Reading
380
+ // clientSecret first silently breaks any provider that puts a real
381
+ // identifier there (MAX, Takbull) — the host check rejects the id and the
382
+ // customer never reaches the payment page. Mirrors the sdk-widget branch
383
+ // above, which already prefers renderArg.
384
+ const paymentUrl = sdk.renderArg || intent.clientSecret;
385
+
371
386
  if (sdk.renderType === 'redirect') {
372
- if (!isAllowedPaymentUrl(intent.clientSecret)) {
387
+ if (!isAllowedPaymentUrl(paymentUrl)) {
373
388
  setError(t('paymentRedirectBlocked'));
374
389
  return;
375
390
  }
376
- safePaymentRedirect(intent.clientSecret);
391
+ safePaymentRedirect(paymentUrl);
377
392
  return;
378
393
  }
379
394
 
@@ -383,13 +398,13 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
383
398
  // (2) a Brainerce-hosted embed page on an allowlisted payment host
384
399
  // that wraps provider-specific logic (e.g. Cardcom OpenFields).
385
400
  if (sdk.renderType === 'iframe') {
386
- if (!isAllowedPaymentUrl(intent.clientSecret)) {
401
+ if (!isAllowedPaymentUrl(paymentUrl)) {
387
402
  setError(t('paymentRedirectBlocked'));
388
403
  return;
389
404
  }
390
405
  const iframeOrigin = (() => {
391
406
  try {
392
- return new URL(intent.clientSecret).origin;
407
+ return new URL(paymentUrl).origin;
393
408
  } catch {
394
409
  return '';
395
410
  }
@@ -622,7 +637,7 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
622
637
  return (
623
638
  <div className={cn('w-full', className)}>
624
639
  <iframe
625
- src={paymentIntent.clientSecret}
640
+ src={paymentIntentUrl}
626
641
  className="block w-full border-0"
627
642
  style={iframeStyle}
628
643
  title={t('payment')}
@@ -681,7 +696,7 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
681
696
  </div>
682
697
  {/* Iframe body */}
683
698
  <iframe
684
- src={paymentIntent.clientSecret}
699
+ src={paymentIntentUrl}
685
700
  className="w-full border-0"
686
701
  style={iframeStyle}
687
702
  title={t('payment')}
@@ -724,7 +739,7 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
724
739
  <p className="text-muted-foreground mt-4 text-sm">{t('redirectingToPayment')}</p>
725
740
  <p className="text-muted-foreground mt-2 text-xs">
726
741
  {t('redirectingHint')}
727
- <a href={paymentIntent.clientSecret} className="text-primary hover:underline">
742
+ <a href={paymentIntentUrl} className="text-primary hover:underline">
728
743
  {t('clickHere')}
729
744
  </a>
730
745
  .