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 +1 -1
- package/messages/en.json +5 -0
- package/messages/he.json +5 -0
- package/package.json +1 -1
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +270 -258
- package/templates/nextjs/base/src/components/checkout/date-picker.tsx +305 -0
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +22 -7
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.
|
|
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,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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
{
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
{
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
<
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
{field.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
{
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
</
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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(
|
|
387
|
+
if (!isAllowedPaymentUrl(paymentUrl)) {
|
|
373
388
|
setError(t('paymentRedirectBlocked'));
|
|
374
389
|
return;
|
|
375
390
|
}
|
|
376
|
-
safePaymentRedirect(
|
|
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(
|
|
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(
|
|
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={
|
|
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={
|
|
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={
|
|
742
|
+
<a href={paymentIntentUrl} className="text-primary hover:underline">
|
|
728
743
|
{t('clickHere')}
|
|
729
744
|
</a>
|
|
730
745
|
.
|