create-brainerce-store 1.72.0 → 1.74.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 +37 -4
- package/messages/en.json +30 -1
- package/messages/he.json +30 -1
- package/package.json +1 -1
- package/templates/nextjs/base/AGENTS.md.ejs +63 -16
- package/templates/nextjs/base/AI-GUIDE.md +17 -4
- package/templates/nextjs/base/CLAUDE.md.ejs +67 -20
- package/templates/nextjs/base/TRANSLATIONS.md +207 -207
- package/templates/nextjs/base/src/app/account/page.tsx +168 -122
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +10 -1
- package/templates/nextjs/base/src/app/checkout/page.tsx +1179 -1074
- package/templates/nextjs/base/src/app/layout.tsx.ejs +48 -4
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +98 -3
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
- package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +226 -0
- package/templates/nextjs/base/src/components/account/order-history.tsx +422 -371
- package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +85 -85
- package/templates/nextjs/base/src/components/account/saved-payment-methods.tsx +104 -0
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +618 -612
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +354 -306
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +141 -74
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +218 -127
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +63 -51
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +379 -295
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +33 -1
- package/templates/nextjs/base/src/core/lib/capabilities.ts +65 -0
- package/templates/nextjs/base/src/core/lib/display-price.ts +139 -0
- package/templates/nextjs/base/src/core/lib/region.server.ts +112 -0
- package/templates/nextjs/base/src/core/lib/region.ts +112 -0
- package/templates/nextjs/base/src/core/lib/store-info.ts +19 -0
- package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +423 -243
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +174 -164
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +109 -110
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +219 -176
- package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +191 -0
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +137 -137
- package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +108 -0
- package/templates/nextjs/base/src/ui/home/home-client.tsx +27 -29
- package/templates/nextjs/base/src/ui/layout/header-search.tsx +249 -0
- package/templates/nextjs/base/src/ui/layout/region-switcher.tsx +59 -0
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +171 -152
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +197 -191
- package/templates/nextjs/base/src/ui/product/product-card.tsx +25 -52
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +12 -3
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +460 -446
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +108 -102
- package/templates/nextjs/base/src/ui/product/review-form.tsx +33 -11
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +15 -3
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +48 -4
- package/templates/nextjs/designs/atelier/messages-patch/en.json +21 -25
- package/templates/nextjs/designs/atelier/messages-patch/he.json +21 -25
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +177 -181
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +168 -158
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +106 -107
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +229 -184
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +131 -131
- package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +108 -0
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +10 -1
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +178 -174
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +4 -6
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +150 -78
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +79 -46
- package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +264 -0
- package/templates/nextjs/designs/atelier/ui/layout/region-switcher.tsx +44 -0
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +5 -0
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +202 -196
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +27 -52
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +12 -3
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +332 -260
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +101 -95
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +30 -10
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +17 -2
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +9 -0
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +137 -137
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +98 -99
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +140 -140
- package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +142 -0
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +124 -124
- package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +104 -0
- package/templates/nextjs/ui-canvas/home/home-client.tsx +24 -26
- package/templates/nextjs/ui-canvas/layout/region-switcher.tsx +51 -0
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +3 -0
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +174 -168
- package/templates/nextjs/ui-canvas/product/product-card.tsx +9 -51
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +5 -3
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +105 -99
- package/templates/nextjs/ui-canvas/product/review-form.tsx +9 -1
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +17 -2
|
@@ -1,306 +1,354 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState } from 'react';
|
|
4
|
-
import type { CheckoutCustomFieldDefinition } from 'brainerce';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
*
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* the
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
value={(value as string) ?? ''}
|
|
163
|
-
onChange={(e) =>
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
{field.
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
/>
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from 'react';
|
|
4
|
+
import type { CheckoutCustomFieldDefinition } from 'brainerce';
|
|
5
|
+
import { isDateValueAllowed } from 'brainerce';
|
|
6
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
7
|
+
import { cn } from '@/core/lib/utils';
|
|
8
|
+
import { DatePicker } from '@/components/checkout/date-picker';
|
|
9
|
+
import { DateTimePicker } from '@/components/checkout/datetime-picker';
|
|
10
|
+
|
|
11
|
+
/** Inline, dependency-free — this file lives in the base template and must
|
|
12
|
+
* not assume any design pack (e.g. Atelier's icon set) is present; canvas
|
|
13
|
+
* scaffolds skip design packs entirely. */
|
|
14
|
+
const INPUT_CLASS =
|
|
15
|
+
'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';
|
|
16
|
+
|
|
17
|
+
interface CustomFieldsStepProps {
|
|
18
|
+
fields: CheckoutCustomFieldDefinition[];
|
|
19
|
+
values: Record<string, unknown>;
|
|
20
|
+
onChange: (key: string, value: unknown) => void;
|
|
21
|
+
onApply: () => void;
|
|
22
|
+
onUploadFile?: (file: File) => Promise<{ url: string; key: string }>;
|
|
23
|
+
/**
|
|
24
|
+
* The store's IANA timezone (`storeInfo.timezone`). DATE/DATETIME fields need
|
|
25
|
+
* it to apply a field's leadTimeMinutes/cutoffTime/maxDaysAhead: the SDK
|
|
26
|
+
* skips those bounds when no clock reaches it, so the picker would offer days
|
|
27
|
+
* the API rejects.
|
|
28
|
+
*/
|
|
29
|
+
timezone?: string;
|
|
30
|
+
loading?: boolean;
|
|
31
|
+
className?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
|
|
35
|
+
const ACCEPTED_IMAGE_TYPES = 'image/jpeg,image/png,image/webp,image/gif';
|
|
36
|
+
|
|
37
|
+
export function CustomFieldsStep({
|
|
38
|
+
fields,
|
|
39
|
+
values,
|
|
40
|
+
onChange,
|
|
41
|
+
onApply,
|
|
42
|
+
onUploadFile,
|
|
43
|
+
timezone,
|
|
44
|
+
loading = false,
|
|
45
|
+
className,
|
|
46
|
+
}: CustomFieldsStepProps) {
|
|
47
|
+
const t = useTranslations('checkout');
|
|
48
|
+
const [uploadingKeys, setUploadingKeys] = useState<Set<string>>(new Set());
|
|
49
|
+
|
|
50
|
+
const isMissing = fields.some((f) => {
|
|
51
|
+
if (!f.required) return false;
|
|
52
|
+
const v = values[f.key];
|
|
53
|
+
return v === undefined || v === null || v === '';
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ---- Pre-submit date guard ----
|
|
57
|
+
//
|
|
58
|
+
// The pickers disable unavailable days and times at the moment they render,
|
|
59
|
+
// but a shopper can sit on this step long enough for a `leadTimeMinutes` or
|
|
60
|
+
// `cutoffTime` bound to overtake the value they already picked. Submitting
|
|
61
|
+
// it then 400s AFTER they have committed. `isDateValueAllowed` is the
|
|
62
|
+
// authoritative client-side re-check, so run it on every keystroke-free
|
|
63
|
+
// render and keep the button shut while anything is stale.
|
|
64
|
+
//
|
|
65
|
+
// The clock is what applies leadTimeMinutes / cutoffTime / maxDaysAhead, and
|
|
66
|
+
// `isDateValueAllowed` requires a real IANA `timezone` (it is typed
|
|
67
|
+
// `string`, not optional). `timezone` on this component IS optional, so when
|
|
68
|
+
// the store has none we skip the guard entirely rather than pass an empty
|
|
69
|
+
// string, which would evaluate the bounds against a bogus zone.
|
|
70
|
+
const staleDateKeys = useMemo(() => {
|
|
71
|
+
const stale = new Set<string>();
|
|
72
|
+
if (!timezone) return stale;
|
|
73
|
+
|
|
74
|
+
for (const field of fields) {
|
|
75
|
+
if (field.type !== 'DATE' && field.type !== 'DATETIME') continue;
|
|
76
|
+
if (!field.dateAvailability) continue;
|
|
77
|
+
|
|
78
|
+
const raw = values[field.key];
|
|
79
|
+
if (typeof raw !== 'string' || raw === '') continue;
|
|
80
|
+
|
|
81
|
+
// A DATE value is "YYYY-MM-DD", which parses to UTC midnight — exactly
|
|
82
|
+
// what isDateValueAllowed reads back with getUTC* for the DATE branch.
|
|
83
|
+
const instant = new Date(raw);
|
|
84
|
+
if (Number.isNaN(instant.getTime())) continue;
|
|
85
|
+
|
|
86
|
+
const verdict = isDateValueAllowed(instant, field.dateAvailability, field.type, timezone);
|
|
87
|
+
if (!verdict.allowed) {
|
|
88
|
+
stale.add(field.key);
|
|
89
|
+
// `reason` is untranslated English prose meant for developers — log
|
|
90
|
+
// it, never render it. The shopper gets the localized line below.
|
|
91
|
+
console.warn(`Checkout field "${field.key}" is no longer available:`, verdict.reason);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return stale;
|
|
95
|
+
}, [fields, values, timezone]);
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div className={cn('space-y-4', className)}>
|
|
99
|
+
<p className="text-muted-foreground text-sm">{t('customFieldsSubtitle')}</p>
|
|
100
|
+
|
|
101
|
+
{fields.map((field) => {
|
|
102
|
+
const value = values[field.key];
|
|
103
|
+
const labelEl = (
|
|
104
|
+
<label
|
|
105
|
+
htmlFor={`cf-${field.key}`}
|
|
106
|
+
className="text-foreground mb-1 block text-sm font-medium"
|
|
107
|
+
>
|
|
108
|
+
{field.name}
|
|
109
|
+
{field.required && <span className="text-destructive ms-1">*</span>}
|
|
110
|
+
</label>
|
|
111
|
+
);
|
|
112
|
+
const helpEl = field.description ? (
|
|
113
|
+
<p className="text-muted-foreground mt-1 whitespace-pre-line text-xs">
|
|
114
|
+
{field.description}
|
|
115
|
+
</p>
|
|
116
|
+
) : null;
|
|
117
|
+
|
|
118
|
+
switch (field.type) {
|
|
119
|
+
case 'TEXT':
|
|
120
|
+
return (
|
|
121
|
+
<div key={field.key}>
|
|
122
|
+
{labelEl}
|
|
123
|
+
<input
|
|
124
|
+
id={`cf-${field.key}`}
|
|
125
|
+
type="text"
|
|
126
|
+
value={(value as string) ?? ''}
|
|
127
|
+
onChange={(e) => onChange(field.key, e.target.value)}
|
|
128
|
+
required={field.required}
|
|
129
|
+
minLength={field.minLength ?? undefined}
|
|
130
|
+
maxLength={field.maxLength ?? undefined}
|
|
131
|
+
className={INPUT_CLASS}
|
|
132
|
+
/>
|
|
133
|
+
{helpEl}
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
case 'TEXTAREA':
|
|
138
|
+
return (
|
|
139
|
+
<div key={field.key}>
|
|
140
|
+
{labelEl}
|
|
141
|
+
<textarea
|
|
142
|
+
id={`cf-${field.key}`}
|
|
143
|
+
value={(value as string) ?? ''}
|
|
144
|
+
onChange={(e) => onChange(field.key, e.target.value)}
|
|
145
|
+
required={field.required}
|
|
146
|
+
minLength={field.minLength ?? undefined}
|
|
147
|
+
maxLength={field.maxLength ?? undefined}
|
|
148
|
+
rows={3}
|
|
149
|
+
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"
|
|
150
|
+
/>
|
|
151
|
+
{helpEl}
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
case 'NUMBER':
|
|
156
|
+
return (
|
|
157
|
+
<div key={field.key}>
|
|
158
|
+
{labelEl}
|
|
159
|
+
<input
|
|
160
|
+
id={`cf-${field.key}`}
|
|
161
|
+
type="number"
|
|
162
|
+
value={(value as number | string) ?? ''}
|
|
163
|
+
onChange={(e) =>
|
|
164
|
+
onChange(field.key, e.target.value === '' ? '' : Number(e.target.value))
|
|
165
|
+
}
|
|
166
|
+
required={field.required}
|
|
167
|
+
min={field.minValue ?? undefined}
|
|
168
|
+
max={field.maxValue ?? undefined}
|
|
169
|
+
className={INPUT_CLASS}
|
|
170
|
+
/>
|
|
171
|
+
{helpEl}
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
case 'BOOLEAN':
|
|
176
|
+
return (
|
|
177
|
+
<div key={field.key} className="flex items-start gap-2">
|
|
178
|
+
<input
|
|
179
|
+
id={`cf-${field.key}`}
|
|
180
|
+
type="checkbox"
|
|
181
|
+
checked={value === true}
|
|
182
|
+
onChange={(e) => onChange(field.key, e.target.checked)}
|
|
183
|
+
className="mt-1"
|
|
184
|
+
/>
|
|
185
|
+
<div className="flex-1">
|
|
186
|
+
<label
|
|
187
|
+
htmlFor={`cf-${field.key}`}
|
|
188
|
+
className="text-foreground text-sm font-medium"
|
|
189
|
+
>
|
|
190
|
+
{field.name}
|
|
191
|
+
{field.required && <span className="text-destructive ms-1">*</span>}
|
|
192
|
+
</label>
|
|
193
|
+
{helpEl}
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
case 'SELECT':
|
|
199
|
+
return (
|
|
200
|
+
<div key={field.key}>
|
|
201
|
+
{labelEl}
|
|
202
|
+
<select
|
|
203
|
+
id={`cf-${field.key}`}
|
|
204
|
+
value={(value as string) ?? ''}
|
|
205
|
+
onChange={(e) => onChange(field.key, e.target.value)}
|
|
206
|
+
required={field.required}
|
|
207
|
+
className={INPUT_CLASS}
|
|
208
|
+
>
|
|
209
|
+
<option value="">{t('customFieldsSelectPlaceholder')}</option>
|
|
210
|
+
{field.options?.map((opt) => (
|
|
211
|
+
<option key={opt.value} value={opt.value}>
|
|
212
|
+
{opt.label}
|
|
213
|
+
</option>
|
|
214
|
+
))}
|
|
215
|
+
</select>
|
|
216
|
+
{helpEl}
|
|
217
|
+
</div>
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
case 'DATE':
|
|
221
|
+
return (
|
|
222
|
+
<div key={field.key}>
|
|
223
|
+
{labelEl}
|
|
224
|
+
<DatePicker
|
|
225
|
+
id={`cf-${field.key}`}
|
|
226
|
+
value={(value as string) ?? ''}
|
|
227
|
+
onChange={(v) => onChange(field.key, v)}
|
|
228
|
+
required={field.required}
|
|
229
|
+
availability={field.dateAvailability}
|
|
230
|
+
timezone={timezone}
|
|
231
|
+
placeholder={t('customFieldsDatePlaceholder')}
|
|
232
|
+
todayLabel={t('customFieldsDateToday')}
|
|
233
|
+
clearLabel={t('customFieldsDateClear')}
|
|
234
|
+
prevMonthLabel={t('customFieldsDatePrevMonth')}
|
|
235
|
+
nextMonthLabel={t('customFieldsDateNextMonth')}
|
|
236
|
+
/>
|
|
237
|
+
{helpEl}
|
|
238
|
+
{staleDateKeys.has(field.key) && (
|
|
239
|
+
<p className="text-destructive mt-1 text-xs">{t('customFieldsTimeNoneLeft')}</p>
|
|
240
|
+
)}
|
|
241
|
+
</div>
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
case 'DATETIME':
|
|
245
|
+
return (
|
|
246
|
+
<div key={field.key}>
|
|
247
|
+
{labelEl}
|
|
248
|
+
<DateTimePicker
|
|
249
|
+
id={`cf-${field.key}`}
|
|
250
|
+
value={(value as string) ?? ''}
|
|
251
|
+
onChange={(v) => onChange(field.key, v)}
|
|
252
|
+
required={field.required}
|
|
253
|
+
availability={field.dateAvailability}
|
|
254
|
+
timezone={timezone}
|
|
255
|
+
placeholder={t('customFieldsDatePlaceholder')}
|
|
256
|
+
todayLabel={t('customFieldsDateToday')}
|
|
257
|
+
clearLabel={t('customFieldsDateClear')}
|
|
258
|
+
prevMonthLabel={t('customFieldsDatePrevMonth')}
|
|
259
|
+
nextMonthLabel={t('customFieldsDateNextMonth')}
|
|
260
|
+
timeLabel={t('customFieldsTimeLabel')}
|
|
261
|
+
closedLabel={t('customFieldsTimeClosed')}
|
|
262
|
+
noTimesLabel={t('customFieldsTimeNoneLeft')}
|
|
263
|
+
toLabel={t('customFieldsTimeTo')}
|
|
264
|
+
/>
|
|
265
|
+
{helpEl}
|
|
266
|
+
{staleDateKeys.has(field.key) && (
|
|
267
|
+
<p className="text-destructive mt-1 text-xs">{t('customFieldsTimeNoneLeft')}</p>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
case 'IMAGE': {
|
|
273
|
+
const isUploading = uploadingKeys.has(field.key);
|
|
274
|
+
return (
|
|
275
|
+
<div key={field.key}>
|
|
276
|
+
{labelEl}
|
|
277
|
+
{value ? (
|
|
278
|
+
<div className="relative inline-block">
|
|
279
|
+
<img
|
|
280
|
+
src={value as string}
|
|
281
|
+
alt={field.name}
|
|
282
|
+
className="border-border max-h-32 rounded border object-contain"
|
|
283
|
+
/>
|
|
284
|
+
<button
|
|
285
|
+
type="button"
|
|
286
|
+
onClick={() => onChange(field.key, '')}
|
|
287
|
+
className="bg-background/80 text-foreground absolute end-1 top-1 rounded-full p-1 text-xs leading-none"
|
|
288
|
+
aria-label={t('customFieldsImageRemove')}
|
|
289
|
+
>
|
|
290
|
+
✕
|
|
291
|
+
</button>
|
|
292
|
+
</div>
|
|
293
|
+
) : (
|
|
294
|
+
<label
|
|
295
|
+
htmlFor={`cf-${field.key}`}
|
|
296
|
+
className={cn(
|
|
297
|
+
'border-border flex cursor-pointer flex-col items-center gap-2 rounded border-2 border-dashed p-6 text-center transition-colors',
|
|
298
|
+
isUploading ? 'opacity-50' : 'hover:border-primary/40'
|
|
299
|
+
)}
|
|
300
|
+
>
|
|
301
|
+
<span className="text-muted-foreground text-sm">
|
|
302
|
+
{isUploading ? t('customFieldsImageUploading') : t('customFieldsImageUpload')}
|
|
303
|
+
</span>
|
|
304
|
+
<input
|
|
305
|
+
id={`cf-${field.key}`}
|
|
306
|
+
type="file"
|
|
307
|
+
accept={ACCEPTED_IMAGE_TYPES}
|
|
308
|
+
disabled={isUploading || !onUploadFile}
|
|
309
|
+
className="hidden"
|
|
310
|
+
onChange={async (e) => {
|
|
311
|
+
const file = e.target.files?.[0];
|
|
312
|
+
if (!file || !onUploadFile) return;
|
|
313
|
+
if (file.size > MAX_IMAGE_SIZE) {
|
|
314
|
+
alert(t('customFieldsImageTooLarge'));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
setUploadingKeys((prev) => new Set(prev).add(field.key));
|
|
318
|
+
try {
|
|
319
|
+
const result = await onUploadFile(file);
|
|
320
|
+
onChange(field.key, result.url);
|
|
321
|
+
} catch {
|
|
322
|
+
// Upload failed — user can retry
|
|
323
|
+
} finally {
|
|
324
|
+
setUploadingKeys((prev) => {
|
|
325
|
+
const next = new Set(prev);
|
|
326
|
+
next.delete(field.key);
|
|
327
|
+
return next;
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}}
|
|
331
|
+
/>
|
|
332
|
+
</label>
|
|
333
|
+
)}
|
|
334
|
+
{helpEl}
|
|
335
|
+
</div>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
default:
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
})}
|
|
343
|
+
|
|
344
|
+
<button
|
|
345
|
+
type="button"
|
|
346
|
+
onClick={onApply}
|
|
347
|
+
disabled={loading || isMissing || staleDateKeys.size > 0}
|
|
348
|
+
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"
|
|
349
|
+
>
|
|
350
|
+
{loading ? t('customFieldsApplying') : t('customFieldsApply')}
|
|
351
|
+
</button>
|
|
352
|
+
</div>
|
|
353
|
+
);
|
|
354
|
+
}
|