@spree/dashboard-core 0.13.0 → 1.0.0-beta.1

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.
Files changed (126) hide show
  1. package/README.md +4 -0
  2. package/dist/vite/index.js +17 -3
  3. package/package.json +7 -7
  4. package/src/api-client.ts +643 -0
  5. package/src/components/address-form-dialog.test.ts +25 -0
  6. package/src/components/address-form-dialog.tsx +203 -79
  7. package/src/components/app-shell.tsx +171 -0
  8. package/src/components/app-sidebar.tsx +184 -27
  9. package/src/components/bulk-action-bar.tsx +89 -157
  10. package/src/components/country-combobox.tsx +69 -34
  11. package/src/components/country-state-fields.tsx +43 -11
  12. package/src/components/currency-select.tsx +81 -66
  13. package/src/components/export-button.tsx +62 -5
  14. package/src/components/file-upload-field.tsx +32 -10
  15. package/src/components/import-button.tsx +97 -36
  16. package/src/components/import-wizard-dialog.tsx +710 -0
  17. package/src/components/locale-select.tsx +70 -57
  18. package/src/components/media-picker-sheet.tsx +307 -0
  19. package/src/components/mobile-top-bar.tsx +75 -0
  20. package/src/components/nav-main.tsx +197 -27
  21. package/src/components/page-header.tsx +203 -61
  22. package/src/components/page-tabs.tsx +4 -1
  23. package/src/components/preferences-form.tsx +98 -4
  24. package/src/components/resource-combobox.tsx +52 -4
  25. package/src/components/resource-picker-sheet.tsx +209 -73
  26. package/src/components/resource-table.tsx +425 -131
  27. package/src/components/route-announcer.tsx +64 -0
  28. package/src/components/section-heading.tsx +95 -0
  29. package/src/components/settings-sidebar.tsx +290 -60
  30. package/src/components/sheet-top-bar.tsx +44 -0
  31. package/src/components/sidebar-search.tsx +83 -0
  32. package/src/components/sidebar-user.tsx +213 -0
  33. package/src/components/skip-link.tsx +36 -0
  34. package/src/components/store-date-picker.tsx +6 -2
  35. package/src/components/store-switcher.tsx +63 -22
  36. package/src/components/table-toolbar.tsx +1373 -442
  37. package/src/components/tag-combobox.tsx +12 -4
  38. package/src/components/top-bar-breadcrumbs.tsx +235 -0
  39. package/src/components/wizard-close-guard.test.ts +54 -0
  40. package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
  41. package/src/hooks/use-command-palette.tsx +13 -0
  42. package/src/hooks/use-countries.test.ts +56 -0
  43. package/src/hooks/use-countries.ts +51 -5
  44. package/src/hooks/use-direct-upload.ts +16 -3
  45. package/src/hooks/use-export.ts +51 -16
  46. package/src/hooks/use-import.ts +261 -14
  47. package/src/hooks/use-package-types.ts +132 -0
  48. package/src/hooks/use-resource-mutation.ts +74 -13
  49. package/src/hooks/use-stock-locations.ts +131 -0
  50. package/src/hooks/use-switch-admin-locale.ts +2 -2
  51. package/src/hooks/use-visual-viewport-offset.ts +61 -0
  52. package/src/index.ts +45 -1
  53. package/src/lib/cancel-order-form.ts +27 -0
  54. package/src/lib/create-action-registry.ts +278 -0
  55. package/src/lib/date-presets.test.ts +86 -0
  56. package/src/lib/date-presets.ts +98 -0
  57. package/src/lib/delivery-form.ts +15 -0
  58. package/src/lib/docs.ts +32 -0
  59. package/src/lib/download.ts +13 -1
  60. package/src/lib/filters-to-ransack.test.ts +113 -0
  61. package/src/lib/filters-to-ransack.ts +22 -1
  62. package/src/lib/form-errors.ts +122 -10
  63. package/src/lib/form-mappers.ts +76 -0
  64. package/src/lib/fulfillment-items.test.ts +213 -0
  65. package/src/lib/fulfillment-items.ts +171 -0
  66. package/src/lib/i18n.ts +19 -11
  67. package/src/lib/import-types.ts +25 -0
  68. package/src/lib/label-upload-form.ts +22 -0
  69. package/src/lib/nav-registry.ts +71 -3
  70. package/src/lib/permissions.ts +46 -1
  71. package/src/lib/progress.ts +18 -0
  72. package/src/lib/query-keys.ts +13 -13
  73. package/src/lib/sample-csv.ts +21 -0
  74. package/src/lib/search-registry.ts +6 -2
  75. package/src/lib/settings-nav-registry.ts +104 -11
  76. package/src/lib/storefront.ts +19 -0
  77. package/src/lib/table-registry.ts +56 -0
  78. package/src/lib/type-labels.test.ts +68 -0
  79. package/src/lib/type-labels.ts +78 -0
  80. package/src/locales/ar.json +1889 -516
  81. package/src/locales/de.json +1883 -515
  82. package/src/locales/en.json +1886 -516
  83. package/src/locales/fr.json +1884 -516
  84. package/src/locales/pl.json +1913 -516
  85. package/src/locales/zh-CN.json +1883 -516
  86. package/src/pages/package-types.tsx +484 -0
  87. package/src/pages/settings-index.tsx +112 -0
  88. package/src/pages/stock-locations.tsx +674 -0
  89. package/src/plugin.ts +29 -0
  90. package/src/products/add-video-dialog.tsx +100 -0
  91. package/src/products/bulk-variants-dialog.tsx +301 -0
  92. package/src/products/carton-size.test.ts +51 -0
  93. package/src/products/carton-size.ts +20 -0
  94. package/src/products/currency-parts.ts +21 -0
  95. package/src/products/inventory-section.tsx +259 -0
  96. package/src/products/media-edit-sheet.tsx +336 -0
  97. package/src/products/normalize-customs.test.ts +39 -0
  98. package/src/products/normalize-customs.ts +27 -0
  99. package/src/products/normalize-money.test.ts +58 -0
  100. package/src/products/normalize-money.ts +73 -0
  101. package/src/products/normalize-quantity.test.ts +32 -0
  102. package/src/products/normalize-quantity.ts +31 -0
  103. package/src/products/product-bulk-price-editor.tsx +165 -0
  104. package/src/products/product-form-cards.tsx +1167 -0
  105. package/src/products/product-form-mapping.ts +212 -0
  106. package/src/products/product-schema.ts +318 -0
  107. package/src/products/product-types.ts +130 -0
  108. package/src/products/use-product-form-data.ts +273 -0
  109. package/src/products/variant-edit-sheet.tsx +725 -0
  110. package/src/products/variants-matrix.test.ts +154 -0
  111. package/src/products/variants-matrix.ts +244 -0
  112. package/src/products/variants-options-builder.tsx +571 -0
  113. package/src/products/variants-section.tsx +535 -0
  114. package/src/products/video-url.test.ts +71 -0
  115. package/src/products/video-url.ts +83 -0
  116. package/src/providers/auth-provider.tsx +70 -12
  117. package/src/providers/permission-provider.tsx +68 -28
  118. package/src/providers/sticky-header-provider.tsx +134 -0
  119. package/src/providers/store-provider.tsx +36 -3
  120. package/src/providers/tenant-provider.tsx +33 -0
  121. package/src/schemas/package-type.ts +95 -0
  122. package/src/schemas/stock-location.ts +126 -0
  123. package/src/tables/package-types.tsx +74 -0
  124. package/src/tables/stock-locations.tsx +124 -0
  125. package/src/vite/index.ts +40 -7
  126. package/src/components/top-bar.tsx +0 -210
@@ -0,0 +1,571 @@
1
+ import {
2
+ Badge,
3
+ Button,
4
+ Field,
5
+ FieldLabel,
6
+ Input,
7
+ Select,
8
+ SelectContent,
9
+ SelectItem,
10
+ SelectTrigger,
11
+ SelectValue,
12
+ toastManager,
13
+ } from '@spree/dashboard-ui'
14
+ import { CheckIcon, PencilIcon, PlusIcon, XIcon } from '@spree/dashboard-ui/icons'
15
+ import { useMemo, useState } from 'react'
16
+ import { useTranslation } from 'react-i18next'
17
+ import { getApiClient, type PanelOptionType as OptionType } from '../api-client'
18
+ import {
19
+ useCreateOptionType,
20
+ useFormOptionTypes as useOptionTypes,
21
+ useUpdateOptionType,
22
+ } from './use-product-form-data'
23
+ import { autoExpandIndex, type SelectedOptionType } from './variants-matrix'
24
+
25
+ interface Props {
26
+ selected: SelectedOptionType[]
27
+ onChange: (next: SelectedOptionType[]) => void
28
+ /**
29
+ * Option types resolved by id for the product's type. Merged over the paged
30
+ * registry so a type referencing one past the first page still renders its
31
+ * row and its values picker.
32
+ */
33
+ extraOptionTypes?: OptionType[]
34
+ }
35
+
36
+ // The options builder lets the merchant choose option types + values that
37
+ // drive the variants matrix. It DOES NOT mutate the product itself — it
38
+ // only updates the local `selected` state. The matrix component owns the
39
+ // reconcile step that turns this state into RHF variant rows.
40
+ export function VariantsOptionsBuilder({ selected, onChange, extraOptionTypes }: Props) {
41
+ const { t } = useTranslation()
42
+ const { data: optionTypesData } = useOptionTypes()
43
+ const allOptionTypes = useMemo(() => {
44
+ const paged = optionTypesData?.data ?? []
45
+ if (!extraOptionTypes?.length) return paged
46
+
47
+ const byId = new Map(paged.map((optionType) => [optionType.id, optionType]))
48
+ extraOptionTypes.forEach((optionType) => {
49
+ if (!byId.has(optionType.id)) byId.set(optionType.id, optionType)
50
+ })
51
+ return [...byId.values()]
52
+ }, [optionTypesData, extraOptionTypes])
53
+
54
+ const [editingIndex, setEditingIndex] = useState<number | null>(null)
55
+ const [isAdding, setIsAdding] = useState(false)
56
+
57
+ // A row with no values yet is one the merchant still has to act on — the
58
+ // product type seeds exactly that. Open the first one so the values picker
59
+ // is in front of them instead of hidden behind a click. Only while nothing
60
+ // else is being edited, so it never steals focus mid-interaction.
61
+ //
62
+ // It is an offer they can decline: without tracking the dismissal, cancelling
63
+ // reopens the row on the next render (it is still empty, so it qualifies
64
+ // again) and its remove action stays unreachable.
65
+ // A set, not one id: a type seeding two option types produces two empty rows,
66
+ // and declining the second must not re-open the first.
67
+ const [dismissedAutoExpand, setDismissedAutoExpand] = useState<ReadonlySet<string>>(new Set())
68
+ const offeredIndex = autoExpandIndex(selected, dismissedAutoExpand)
69
+ const expandedIndex = editingIndex ?? (isAdding || offeredIndex < 0 ? null : offeredIndex)
70
+
71
+ const selectedIds = new Set(selected.map((s) => s.id))
72
+ const availableTypes = allOptionTypes.filter((ot) => !selectedIds.has(ot.id))
73
+
74
+ const removeAt = (index: number) => {
75
+ // Drop the dismissal with the row, so re-adding the same option type gets
76
+ // a fresh offer rather than inheriting a stale one.
77
+ const removed = selected[index]
78
+ if (removed) {
79
+ setDismissedAutoExpand((prev) => {
80
+ if (!prev.has(removed.id)) return prev
81
+
82
+ const next = new Set(prev)
83
+ next.delete(removed.id)
84
+ return next
85
+ })
86
+ }
87
+ onChange(selected.filter((_, i) => i !== index))
88
+ }
89
+
90
+ const upsertAt = (index: number | null, next: SelectedOptionType) => {
91
+ if (index == null) {
92
+ onChange([...selected, { ...next, position: selected.length }])
93
+ } else {
94
+ onChange(selected.map((s, i) => (i === index ? next : s)))
95
+ }
96
+ setEditingIndex(null)
97
+ setIsAdding(false)
98
+ }
99
+
100
+ return (
101
+ <div className="flex flex-col gap-3">
102
+ {selected.length > 0 && (
103
+ <ul className="flex flex-col gap-2">
104
+ {selected.map((ot, i) => {
105
+ const optionType = allOptionTypes.find((t) => t.id === ot.id)
106
+ const isEditing = expandedIndex === i
107
+ if (isEditing && optionType) {
108
+ return (
109
+ <li key={ot.id} className="rounded-lg border border-border-subtle p-3">
110
+ <OptionPicker
111
+ optionType={optionType}
112
+ initialValues={ot.values}
113
+ onCancel={() => {
114
+ setEditingIndex(null)
115
+ if (ot.values.length === 0) {
116
+ setDismissedAutoExpand((prev) => new Set(prev).add(ot.id))
117
+ }
118
+ }}
119
+ onSave={(values) =>
120
+ upsertAt(i, {
121
+ id: optionType.id,
122
+ name: optionType.name,
123
+ label: optionType.label,
124
+ position: ot.position,
125
+ values,
126
+ })
127
+ }
128
+ />
129
+ </li>
130
+ )
131
+ }
132
+ return (
133
+ <li
134
+ key={ot.id}
135
+ className="flex items-center gap-3 rounded-lg border border-border-subtle px-3 py-2"
136
+ >
137
+ <div className="min-w-0 flex-1">
138
+ <div className="text-sm font-medium">{ot.label}</div>
139
+ <div className="mt-1 flex flex-wrap gap-1">
140
+ {ot.values.map((v) => (
141
+ <Badge key={v.name} variant="secondary">
142
+ {v.label ?? v.name}
143
+ </Badge>
144
+ ))}
145
+ </div>
146
+ </div>
147
+ <Button
148
+ type="button"
149
+ variant="ghost"
150
+ size="icon-sm"
151
+ aria-label={t('admin.actions.edit')}
152
+ onClick={() => setEditingIndex(i)}
153
+ >
154
+ <PencilIcon />
155
+ </Button>
156
+ <Button
157
+ type="button"
158
+ variant="ghost"
159
+ size="icon-sm"
160
+ aria-label={t('admin.actions.remove')}
161
+ onClick={() => removeAt(i)}
162
+ >
163
+ <XIcon />
164
+ </Button>
165
+ </li>
166
+ )
167
+ })}
168
+ </ul>
169
+ )}
170
+
171
+ {isAdding ? (
172
+ <div className="rounded-lg border border-border-subtle p-3">
173
+ <AddOptionForm
174
+ availableTypes={availableTypes}
175
+ allOptionTypes={allOptionTypes}
176
+ onCancel={() => setIsAdding(false)}
177
+ onSave={(payload) => upsertAt(null, payload)}
178
+ />
179
+ </div>
180
+ ) : (
181
+ <div>
182
+ <Button type="button" variant="outline" size="sm" onClick={() => setIsAdding(true)}>
183
+ <PlusIcon />
184
+ {t('admin.products.variants.add_option')}
185
+ </Button>
186
+ </div>
187
+ )}
188
+ </div>
189
+ )
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Add-option form: picks an option type (or creates a new one), then values
194
+ // ---------------------------------------------------------------------------
195
+
196
+ interface AddOptionFormProps {
197
+ availableTypes: OptionType[]
198
+ allOptionTypes: OptionType[]
199
+ onSave: (payload: SelectedOptionType) => void
200
+ onCancel: () => void
201
+ }
202
+
203
+ function AddOptionForm({ availableTypes, allOptionTypes, onSave, onCancel }: AddOptionFormProps) {
204
+ const { t } = useTranslation()
205
+ const [pickedTypeId, setPickedTypeId] = useState<string>('')
206
+ const [creatingType, setCreatingType] = useState(false)
207
+ const createOptionType = useCreateOptionType()
208
+ const canCreateOptionType = Boolean(getApiClient().optionTypes?.create)
209
+
210
+ const picked =
211
+ allOptionTypes.find((ot) => ot.id === pickedTypeId) ??
212
+ availableTypes.find((ot) => ot.id === pickedTypeId)
213
+
214
+ if (creatingType) {
215
+ return (
216
+ <CreateOptionTypeInline
217
+ onCancel={() => setCreatingType(false)}
218
+ onCreated={(created) => {
219
+ setPickedTypeId(created.id)
220
+ setCreatingType(false)
221
+ }}
222
+ isPending={createOptionType.isPending}
223
+ createMutation={createOptionType.mutateAsync}
224
+ />
225
+ )
226
+ }
227
+
228
+ if (!picked) {
229
+ return (
230
+ <div className="flex flex-col gap-3">
231
+ <Field>
232
+ <FieldLabel>{t('admin.products.variants.option_type_label')}</FieldLabel>
233
+ <Select value={pickedTypeId} onValueChange={setPickedTypeId}>
234
+ <SelectTrigger className="w-full">
235
+ <SelectValue placeholder={t('admin.products.variants.option_type_placeholder')}>
236
+ {(v) =>
237
+ availableTypes.find((ot) => ot.id === v)?.label ??
238
+ t('admin.products.variants.option_type_placeholder')
239
+ }
240
+ </SelectValue>
241
+ </SelectTrigger>
242
+ <SelectContent>
243
+ {availableTypes.map((ot) => (
244
+ <SelectItem key={ot.id} value={ot.id}>
245
+ {ot.label}
246
+ </SelectItem>
247
+ ))}
248
+ </SelectContent>
249
+ </Select>
250
+ </Field>
251
+ <div className="flex items-center justify-end gap-2">
252
+ {/* Option types are the store's own vocabulary, so only a panel whose
253
+ client can write them offers to add one. A seller picks from what
254
+ the marketplace already defines
255
+ (docs/plans/6.0-seller-product-submission.md). */}
256
+ {canCreateOptionType && (
257
+ <Button
258
+ type="button"
259
+ variant="ghost"
260
+ size="sm"
261
+ className="mr-auto"
262
+ onClick={() => setCreatingType(true)}
263
+ >
264
+ <PlusIcon />
265
+ {t('admin.products.variants.create_option_type')}
266
+ </Button>
267
+ )}
268
+ <Button type="button" variant="outline" size="sm" onClick={onCancel}>
269
+ {t('admin.actions.cancel')}
270
+ </Button>
271
+ </div>
272
+ </div>
273
+ )
274
+ }
275
+
276
+ return (
277
+ <OptionPicker
278
+ optionType={picked}
279
+ initialValues={[]}
280
+ onCancel={onCancel}
281
+ onSave={(values) =>
282
+ onSave({
283
+ id: picked.id,
284
+ name: picked.name,
285
+ label: picked.label,
286
+ position: 0, // overridden by parent on append
287
+ values,
288
+ })
289
+ }
290
+ />
291
+ )
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Option-type picker + values multi-select with inline value creation
296
+ // ---------------------------------------------------------------------------
297
+
298
+ interface OptionPickerProps {
299
+ optionType: OptionType
300
+ initialValues: { name: string; label?: string }[]
301
+ onSave: (values: { name: string; label?: string }[]) => void
302
+ onCancel: () => void
303
+ }
304
+
305
+ function OptionPicker({ optionType, initialValues, onSave, onCancel }: OptionPickerProps) {
306
+ const { t } = useTranslation()
307
+ const updateOptionType = useUpdateOptionType(optionType.id)
308
+ // Adding a value to an option type edits the store's own vocabulary, so it
309
+ // needs the write the create button needs — a panel that can list types but
310
+ // not update them would render an action whose mutation has nowhere to go.
311
+ const canAddValue = Boolean(getApiClient().optionTypes?.update)
312
+ const [pickedNames, setPickedNames] = useState<Set<string>>(
313
+ () => new Set(initialValues.map((v) => v.name)),
314
+ )
315
+ const [creatingValue, setCreatingValue] = useState(false)
316
+
317
+ const available = optionType.option_values ?? []
318
+
319
+ const toggle = (name: string) => {
320
+ setPickedNames((prev) => {
321
+ const next = new Set(prev)
322
+ if (next.has(name)) next.delete(name)
323
+ else next.add(name)
324
+ return next
325
+ })
326
+ }
327
+
328
+ const handleSave = () => {
329
+ const ordered = available.filter((v) => pickedNames.has(v.name))
330
+ onSave(ordered.map((v) => ({ name: v.name, label: v.label })))
331
+ }
332
+
333
+ const handleCreateValue = async (name: string, label: string) => {
334
+ const newValue = { name, label }
335
+ // OptionTypeUpdateParams.option_values replaces the full set — preserve
336
+ // existing values and append the new one.
337
+ const nextValues = [
338
+ ...available.map((v) => ({
339
+ id: v.id,
340
+ name: v.name,
341
+ label: v.label,
342
+ position: v.position,
343
+ color_code: v.color_code,
344
+ })),
345
+ newValue,
346
+ ]
347
+ try {
348
+ await updateOptionType.mutateAsync({ option_values: nextValues })
349
+ setPickedNames((prev) => new Set(prev).add(name))
350
+ setCreatingValue(false)
351
+ } catch (err) {
352
+ const message =
353
+ err instanceof Error
354
+ ? err.message
355
+ : t('admin.products.variants.errors.failed_to_create_value')
356
+ toastManager.add({ type: 'error', title: message })
357
+ }
358
+ }
359
+
360
+ if (creatingValue) {
361
+ return (
362
+ <CreateOptionValueInline
363
+ onCancel={() => setCreatingValue(false)}
364
+ onCreate={handleCreateValue}
365
+ isPending={updateOptionType.isPending}
366
+ />
367
+ )
368
+ }
369
+
370
+ return (
371
+ <div className="flex flex-col gap-3">
372
+ <div className="flex items-center justify-between">
373
+ <div className="text-sm font-medium">{optionType.label}</div>
374
+ </div>
375
+ <div className="flex flex-wrap gap-2">
376
+ {available.map((v) => {
377
+ const isPicked = pickedNames.has(v.name)
378
+ return (
379
+ <button
380
+ key={v.id}
381
+ type="button"
382
+ onClick={() => toggle(v.name)}
383
+ aria-pressed={isPicked}
384
+ className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors ${
385
+ isPicked
386
+ ? 'border-primary bg-primary text-primary-foreground'
387
+ : 'border-border bg-background hover:bg-accent'
388
+ }`}
389
+ >
390
+ {isPicked && <CheckIcon className="size-3" />}
391
+ {v.label || v.name}
392
+ </button>
393
+ )
394
+ })}
395
+ {canAddValue && (
396
+ <button
397
+ type="button"
398
+ onClick={() => setCreatingValue(true)}
399
+ className="inline-flex items-center gap-1.5 rounded-full border border-dashed border-border bg-background px-3 py-1 text-xs hover:bg-accent"
400
+ >
401
+ <PlusIcon className="size-3" />
402
+ {t('admin.products.variants.create_value')}
403
+ </button>
404
+ )}
405
+ </div>
406
+ <div className="flex items-center justify-end gap-2">
407
+ <Button type="button" variant="outline" size="sm" onClick={onCancel}>
408
+ {t('admin.actions.cancel')}
409
+ </Button>
410
+ <Button type="button" size="sm" onClick={handleSave} disabled={pickedNames.size === 0}>
411
+ {t('admin.actions.done')}
412
+ </Button>
413
+ </div>
414
+ </div>
415
+ )
416
+ }
417
+
418
+ // ---------------------------------------------------------------------------
419
+ // Inline: create a new option type
420
+ // ---------------------------------------------------------------------------
421
+
422
+ interface CreateOptionTypeInlineProps {
423
+ onCancel: () => void
424
+ onCreated: (optionType: OptionType) => void
425
+ isPending: boolean
426
+ createMutation: (params: { name: string; label: string; kind: string }) => Promise<OptionType>
427
+ }
428
+
429
+ function CreateOptionTypeInline({
430
+ onCancel,
431
+ onCreated,
432
+ isPending,
433
+ createMutation,
434
+ }: CreateOptionTypeInlineProps) {
435
+ const { t } = useTranslation()
436
+ const [label, setLabel] = useState('')
437
+ const [name, setName] = useState('')
438
+ const [touchedName, setTouchedName] = useState(false)
439
+
440
+ const submit = async () => {
441
+ const finalName = (name || label).trim().toLowerCase().replace(/\s+/g, '_')
442
+ if (!label.trim() || !finalName) return
443
+ try {
444
+ const created = await createMutation({
445
+ name: finalName,
446
+ label: label.trim(),
447
+ kind: 'dropdown',
448
+ })
449
+ onCreated(created)
450
+ } catch (err) {
451
+ const message =
452
+ err instanceof Error
453
+ ? err.message
454
+ : t('admin.products.variants.errors.failed_to_create_option_type')
455
+ toastManager.add({ type: 'error', title: message })
456
+ }
457
+ }
458
+
459
+ return (
460
+ <div className="flex flex-col gap-3">
461
+ <div className="text-sm font-medium">
462
+ {t('admin.products.variants.create_option_type_title')}
463
+ </div>
464
+ <Field>
465
+ <FieldLabel htmlFor="new-option-type-label">
466
+ {t('admin.fields.option_type.label.label')}
467
+ </FieldLabel>
468
+ <Input
469
+ id="new-option-type-label"
470
+ value={label}
471
+ onChange={(e) => {
472
+ setLabel(e.target.value)
473
+ if (!touchedName) {
474
+ setName(e.target.value.trim().toLowerCase().replace(/\s+/g, '_'))
475
+ }
476
+ }}
477
+ placeholder={t('admin.fields.option_type.label.placeholder')}
478
+ />
479
+ </Field>
480
+ <Field>
481
+ <FieldLabel htmlFor="new-option-type-name">
482
+ {t('admin.fields.option_type.name.label')}
483
+ </FieldLabel>
484
+ <Input
485
+ id="new-option-type-name"
486
+ value={name}
487
+ onChange={(e) => {
488
+ setTouchedName(true)
489
+ setName(e.target.value)
490
+ }}
491
+ placeholder={t('admin.fields.option_type.name.placeholder')}
492
+ />
493
+ </Field>
494
+ <div className="flex items-center justify-end gap-2">
495
+ <Button type="button" variant="outline" size="sm" onClick={onCancel} disabled={isPending}>
496
+ {t('admin.actions.cancel')}
497
+ </Button>
498
+ <Button type="button" size="sm" onClick={submit} disabled={isPending || !label.trim()}>
499
+ {isPending ? t('admin.actions.creating') : t('admin.actions.create')}
500
+ </Button>
501
+ </div>
502
+ </div>
503
+ )
504
+ }
505
+
506
+ // ---------------------------------------------------------------------------
507
+ // Inline: create a new option value on the picked option type
508
+ // ---------------------------------------------------------------------------
509
+
510
+ interface CreateOptionValueInlineProps {
511
+ onCancel: () => void
512
+ onCreate: (name: string, label: string) => Promise<void>
513
+ isPending: boolean
514
+ }
515
+
516
+ function CreateOptionValueInline({ onCancel, onCreate, isPending }: CreateOptionValueInlineProps) {
517
+ const { t } = useTranslation()
518
+ const [label, setLabel] = useState('')
519
+ const [name, setName] = useState('')
520
+ const [touchedName, setTouchedName] = useState(false)
521
+
522
+ const submit = async () => {
523
+ const finalName = (name || label).trim().toLowerCase().replace(/\s+/g, '_')
524
+ if (!label.trim() || !finalName) return
525
+ await onCreate(finalName, label.trim())
526
+ }
527
+
528
+ return (
529
+ <div className="flex flex-col gap-3">
530
+ <div className="text-sm font-medium">{t('admin.products.variants.create_value_title')}</div>
531
+ <Field>
532
+ <FieldLabel htmlFor="new-option-value-label">
533
+ {t('admin.fields.option_value.label.label')}
534
+ </FieldLabel>
535
+ <Input
536
+ id="new-option-value-label"
537
+ value={label}
538
+ onChange={(e) => {
539
+ setLabel(e.target.value)
540
+ if (!touchedName) {
541
+ setName(e.target.value.trim().toLowerCase().replace(/\s+/g, '_'))
542
+ }
543
+ }}
544
+ placeholder={t('admin.fields.option_value.label.placeholder')}
545
+ />
546
+ </Field>
547
+ <Field>
548
+ <FieldLabel htmlFor="new-option-value-name">
549
+ {t('admin.fields.option_value.name.label')}
550
+ </FieldLabel>
551
+ <Input
552
+ id="new-option-value-name"
553
+ value={name}
554
+ onChange={(e) => {
555
+ setTouchedName(true)
556
+ setName(e.target.value)
557
+ }}
558
+ placeholder={t('admin.fields.option_value.name.placeholder')}
559
+ />
560
+ </Field>
561
+ <div className="flex items-center justify-end gap-2">
562
+ <Button type="button" variant="outline" size="sm" onClick={onCancel} disabled={isPending}>
563
+ {t('admin.actions.cancel')}
564
+ </Button>
565
+ <Button type="button" size="sm" onClick={submit} disabled={isPending || !label.trim()}>
566
+ {isPending ? t('admin.actions.creating') : t('admin.actions.create')}
567
+ </Button>
568
+ </div>
569
+ </div>
570
+ )
571
+ }