@spree/dashboard-core 0.13.1 → 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 (125) 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 +52 -27
  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 +383 -127
  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 +19 -14
  36. package/src/components/table-toolbar.tsx +1357 -440
  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 +241 -16
  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 +9 -6
  74. package/src/lib/search-registry.ts +6 -2
  75. package/src/lib/settings-nav-registry.ts +104 -11
  76. package/src/lib/table-registry.ts +50 -0
  77. package/src/lib/type-labels.test.ts +68 -0
  78. package/src/lib/type-labels.ts +78 -0
  79. package/src/locales/ar.json +1889 -517
  80. package/src/locales/de.json +1884 -517
  81. package/src/locales/en.json +1886 -519
  82. package/src/locales/fr.json +1884 -517
  83. package/src/locales/pl.json +1913 -517
  84. package/src/locales/zh-CN.json +1883 -517
  85. package/src/pages/package-types.tsx +484 -0
  86. package/src/pages/settings-index.tsx +112 -0
  87. package/src/pages/stock-locations.tsx +674 -0
  88. package/src/plugin.ts +29 -0
  89. package/src/products/add-video-dialog.tsx +100 -0
  90. package/src/products/bulk-variants-dialog.tsx +301 -0
  91. package/src/products/carton-size.test.ts +51 -0
  92. package/src/products/carton-size.ts +20 -0
  93. package/src/products/currency-parts.ts +21 -0
  94. package/src/products/inventory-section.tsx +259 -0
  95. package/src/products/media-edit-sheet.tsx +336 -0
  96. package/src/products/normalize-customs.test.ts +39 -0
  97. package/src/products/normalize-customs.ts +27 -0
  98. package/src/products/normalize-money.test.ts +58 -0
  99. package/src/products/normalize-money.ts +73 -0
  100. package/src/products/normalize-quantity.test.ts +32 -0
  101. package/src/products/normalize-quantity.ts +31 -0
  102. package/src/products/product-bulk-price-editor.tsx +165 -0
  103. package/src/products/product-form-cards.tsx +1167 -0
  104. package/src/products/product-form-mapping.ts +212 -0
  105. package/src/products/product-schema.ts +318 -0
  106. package/src/products/product-types.ts +130 -0
  107. package/src/products/use-product-form-data.ts +273 -0
  108. package/src/products/variant-edit-sheet.tsx +725 -0
  109. package/src/products/variants-matrix.test.ts +154 -0
  110. package/src/products/variants-matrix.ts +244 -0
  111. package/src/products/variants-options-builder.tsx +571 -0
  112. package/src/products/variants-section.tsx +535 -0
  113. package/src/products/video-url.test.ts +71 -0
  114. package/src/products/video-url.ts +83 -0
  115. package/src/providers/auth-provider.tsx +70 -12
  116. package/src/providers/permission-provider.tsx +68 -28
  117. package/src/providers/sticky-header-provider.tsx +134 -0
  118. package/src/providers/store-provider.tsx +36 -3
  119. package/src/providers/tenant-provider.tsx +33 -0
  120. package/src/schemas/package-type.ts +95 -0
  121. package/src/schemas/stock-location.ts +126 -0
  122. package/src/tables/package-types.tsx +74 -0
  123. package/src/tables/stock-locations.tsx +124 -0
  124. package/src/vite/index.ts +40 -7
  125. package/src/components/top-bar.tsx +0 -209
@@ -0,0 +1,674 @@
1
+ import { zodResolver } from '@hookform/resolvers/zod'
2
+ import {
3
+ Button,
4
+ Field,
5
+ FieldError,
6
+ FieldLabel,
7
+ FormSection,
8
+ Input,
9
+ RowActions,
10
+ Select,
11
+ SelectContent,
12
+ SelectItem,
13
+ SelectTrigger,
14
+ SelectValue,
15
+ Sheet,
16
+ SheetContent,
17
+ SheetDescription,
18
+ SheetFooter,
19
+ SheetHeader,
20
+ SheetTitle,
21
+ Switch,
22
+ Textarea,
23
+ useConfirm,
24
+ useRowClickBridge,
25
+ } from '@spree/dashboard-ui'
26
+ import { PlusIcon } from '@spree/dashboard-ui/icons'
27
+ import { useNavigate } from '@tanstack/react-router'
28
+ import { type ComponentType, useEffect } from 'react'
29
+ import { Controller, type UseFormReturn, useForm } from 'react-hook-form'
30
+ import { useTranslation } from 'react-i18next'
31
+ import { z } from 'zod/v4'
32
+ import type { PanelStockLocation } from '../api-client'
33
+ import { Can } from '../components/can'
34
+ import { CountryCombobox } from '../components/country-combobox'
35
+ import { StateCombobox, useCountryStates } from '../components/country-state-fields'
36
+ import { ResourceTable, resourceSearchSchema } from '../components/resource-table'
37
+ import {
38
+ canDeleteStockLocations,
39
+ listStockLocations,
40
+ useCreateStockLocation,
41
+ useDeleteStockLocation,
42
+ useStockLocation,
43
+ useUpdateStockLocation,
44
+ } from '../hooks/use-stock-locations'
45
+ import { mapSpreeErrorsToForm } from '../lib/form-errors'
46
+ import { Subject } from '../lib/permissions'
47
+ import { usePermissions } from '../providers/permission-provider'
48
+ import {
49
+ formValuesToParams,
50
+ PICKUP_STOCK_POLICIES,
51
+ STOCK_LOCATION_DEFAULTS,
52
+ STOCK_LOCATION_KINDS,
53
+ type StockLocationFormValues,
54
+ stockLocationFormSchema,
55
+ stockLocationToFormValues,
56
+ } from '../schemas/stock-location'
57
+ import '../tables/stock-locations'
58
+
59
+ /**
60
+ * Adds `?edit=<id>` and `?new=1` on top of the standard table search schema,
61
+ * so the create and edit sheets can be deep-linked.
62
+ *
63
+ * Exported because each app owns its own route file — the route path differs
64
+ * per panel (`/$storeId/settings/...` against `/$sellerId/settings/...`) and
65
+ * TanStack's file routes are generated per app — while everything the page
66
+ * *does* lives here.
67
+ */
68
+ export const stockLocationsSearchSchema = resourceSearchSchema.extend({
69
+ edit: z.string().optional(),
70
+ new: z.coerce.boolean().optional(),
71
+ })
72
+
73
+ export type StockLocationsSearch = z.infer<typeof stockLocationsSearchSchema>
74
+
75
+ /**
76
+ * Manage the stock locations this panel can see.
77
+ *
78
+ * Shared by the operator's dashboard and the marketplace seller panel. Neither
79
+ * owns the records differently — the scoping is server-side, so the operator's
80
+ * API answers with every location in the store and a seller's with only their
81
+ * own, and this page renders whatever it is given.
82
+ *
83
+ * Three things a panel supplies rather than this page assuming them: the API
84
+ * resource, registered through `setApiClient`, and the two injected panels —
85
+ * the on-hand editor and the movement history, both of which read the Admin
86
+ * API and link to the operator's product pages, so a seller's panel simply
87
+ * passes nothing and gets a page without them.
88
+ */
89
+ export function StockLocationsPage({
90
+ search,
91
+ stockLevelsPanel: StockLevelsPanel,
92
+ activityPanel: ActivityPanel,
93
+ }: {
94
+ search: StockLocationsSearch
95
+ /** Rendered inside the edit sheet, below the form. Optional — see above. */
96
+ stockLevelsPanel?: ComponentType<{ stockLocationId: string }>
97
+ /** What changed in this warehouse, for reconciliation. Optional. */
98
+ activityPanel?: ComponentType<{ stockLocationId: string }>
99
+ }) {
100
+ const { t } = useTranslation()
101
+ const navigate = useNavigate()
102
+ const confirm = useConfirm()
103
+ const deleteMutation = useDeleteStockLocation()
104
+ const { permissions } = usePermissions()
105
+
106
+ const editId = search.edit
107
+ const isCreating = !!search.new
108
+
109
+ function closeSheet() {
110
+ navigate({
111
+ search: (prev: Record<string, unknown>) => {
112
+ const { edit: _e, new: _n, ...rest } = prev
113
+ return rest as never
114
+ },
115
+ })
116
+ }
117
+
118
+ function openCreate() {
119
+ navigate({ search: (prev: Record<string, unknown>) => ({ ...prev, new: true }) as never })
120
+ }
121
+
122
+ function openEdit(id: string) {
123
+ navigate({ search: (prev: Record<string, unknown>) => ({ ...prev, edit: id }) as never })
124
+ }
125
+
126
+ useRowClickBridge('data-stock-location-id', openEdit)
127
+
128
+ async function handleDelete(location: PanelStockLocation) {
129
+ const ok = await confirm({
130
+ title: t('admin.stock_locations.delete_confirm.title'),
131
+ message: t('admin.stock_locations.delete_confirm.message', { name: location.name ?? '' }),
132
+ variant: 'destructive',
133
+ confirmLabel: t('admin.actions.delete'),
134
+ })
135
+ if (!ok) return
136
+ await deleteMutation.mutateAsync(location.id).catch(() => undefined)
137
+ }
138
+
139
+ return (
140
+ <>
141
+ <ResourceTable<PanelStockLocation>
142
+ tableKey="stock-locations"
143
+ queryKey="stock-locations"
144
+ queryFn={(params) => listStockLocations(params)}
145
+ searchParams={search}
146
+ rowActions={(location) => (
147
+ <RowActions
148
+ actions={[
149
+ { key: 'edit', onSelect: () => openEdit(location.id) },
150
+ {
151
+ key: 'delete',
152
+ destructive: true,
153
+ // Both must hold: the role allows it, and this panel's API
154
+ // offers it at all — a seller's does not.
155
+ visible:
156
+ canDeleteStockLocations() && permissions.can('destroy', Subject.StockLocation),
157
+ disabled: deleteMutation.isPending,
158
+ onSelect: () => handleDelete(location),
159
+ },
160
+ ]}
161
+ />
162
+ )}
163
+ actions={
164
+ <Can I="create" a={Subject.StockLocation}>
165
+ <Button size="sm" className="h-[2.125rem]" onClick={openCreate}>
166
+ <PlusIcon className="size-4" />
167
+ {t('admin.stock_locations.add_cta')}
168
+ </Button>
169
+ </Can>
170
+ }
171
+ />
172
+
173
+ {isCreating && <CreateStockLocationSheet open onOpenChange={(o) => !o && closeSheet()} />}
174
+ {editId && (
175
+ <EditStockLocationSheet
176
+ id={editId}
177
+ open
178
+ onOpenChange={(o) => !o && closeSheet()}
179
+ stockLevelsPanel={StockLevelsPanel}
180
+ activityPanel={ActivityPanel}
181
+ />
182
+ )}
183
+ </>
184
+ )
185
+ }
186
+
187
+ // ============================================================================
188
+ // Create Sheet
189
+ // ============================================================================
190
+
191
+ function CreateStockLocationSheet({
192
+ open,
193
+ onOpenChange,
194
+ }: {
195
+ open: boolean
196
+ onOpenChange: (open: boolean) => void
197
+ }) {
198
+ const { t } = useTranslation()
199
+ const createMutation = useCreateStockLocation()
200
+
201
+ const form = useForm<StockLocationFormValues>({
202
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
203
+ resolver: zodResolver(stockLocationFormSchema) as any,
204
+ defaultValues: STOCK_LOCATION_DEFAULTS,
205
+ })
206
+
207
+ async function onSubmit(values: StockLocationFormValues) {
208
+ try {
209
+ const params = formValuesToParams(values)
210
+ await createMutation.mutateAsync(params)
211
+ form.reset(STOCK_LOCATION_DEFAULTS)
212
+ onOpenChange(false)
213
+ } catch (err) {
214
+ if (!mapSpreeErrorsToForm(err, form.setError)) throw err
215
+ }
216
+ }
217
+
218
+ return (
219
+ <Sheet
220
+ open={open}
221
+ onOpenChange={(next) => {
222
+ if (!next) form.reset(STOCK_LOCATION_DEFAULTS)
223
+ onOpenChange(next)
224
+ }}
225
+ >
226
+ <SheetContent>
227
+ <SheetHeader>
228
+ <SheetTitle>{t('admin.pages.settings.stock_locations.add_sheet_title')}</SheetTitle>
229
+ <SheetDescription>{t('admin.stock_locations.create_description')}</SheetDescription>
230
+ </SheetHeader>
231
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex min-h-0 flex-1 flex-col">
232
+ <div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
233
+ <StockLocationFormFields form={form} />
234
+ </div>
235
+ <SheetFooter>
236
+ <Button
237
+ type="button"
238
+ variant="outline"
239
+ onClick={() => onOpenChange(false)}
240
+ disabled={form.formState.isSubmitting}
241
+ >
242
+ {t('admin.actions.cancel')}
243
+ </Button>
244
+ <Button type="submit" disabled={form.formState.isSubmitting}>
245
+ {form.formState.isSubmitting
246
+ ? t('admin.actions.creating')
247
+ : t('admin.stock_locations.create_label')}
248
+ </Button>
249
+ </SheetFooter>
250
+ </form>
251
+ </SheetContent>
252
+ </Sheet>
253
+ )
254
+ }
255
+
256
+ // ============================================================================
257
+ // Edit Sheet
258
+ // ============================================================================
259
+
260
+ function EditStockLocationSheet({
261
+ id,
262
+ open,
263
+ onOpenChange,
264
+ stockLevelsPanel: StockLevelsPanel,
265
+ activityPanel: ActivityPanel,
266
+ }: {
267
+ id: string
268
+ open: boolean
269
+ onOpenChange: (open: boolean) => void
270
+ stockLevelsPanel?: ComponentType<{ stockLocationId: string }>
271
+ activityPanel?: ComponentType<{ stockLocationId: string }>
272
+ }) {
273
+ const { t } = useTranslation()
274
+ const { data: stockLocation, isLoading } = useStockLocation(id)
275
+ const updateMutation = useUpdateStockLocation(id)
276
+
277
+ const form = useForm<StockLocationFormValues>({
278
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
279
+ resolver: zodResolver(stockLocationFormSchema) as any,
280
+ defaultValues: STOCK_LOCATION_DEFAULTS,
281
+ })
282
+
283
+ // Reset form when the loaded resource arrives — keeps the inputs in sync
284
+ // with whatever the server last persisted (including external edits).
285
+ useEffect(() => {
286
+ if (stockLocation) {
287
+ form.reset(stockLocationToFormValues(stockLocation))
288
+ }
289
+ }, [stockLocation, form])
290
+
291
+ async function onSubmit(values: StockLocationFormValues) {
292
+ try {
293
+ const params = formValuesToParams(values)
294
+ await updateMutation.mutateAsync(params)
295
+ form.reset(values)
296
+ onOpenChange(false)
297
+ } catch (err) {
298
+ if (!mapSpreeErrorsToForm(err, form.setError)) throw err
299
+ }
300
+ }
301
+
302
+ return (
303
+ <Sheet open={open} onOpenChange={onOpenChange}>
304
+ <SheetContent>
305
+ <SheetHeader>
306
+ <SheetTitle>
307
+ {stockLocation?.name ?? t('admin.pages.settings.stock_locations.edit_sheet_title')}
308
+ </SheetTitle>
309
+ <SheetDescription>{t('admin.stock_locations.edit_description')}</SheetDescription>
310
+ </SheetHeader>
311
+ {isLoading ? (
312
+ <div className="p-4 text-sm text-muted-foreground">{t('admin.common.loading')}</div>
313
+ ) : (
314
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex min-h-0 flex-1 flex-col">
315
+ <div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
316
+ <StockLocationFormFields form={form} />
317
+ {StockLevelsPanel && <StockLevelsPanel stockLocationId={id} />}
318
+ {ActivityPanel && <ActivityPanel stockLocationId={id} />}
319
+ </div>
320
+ <SheetFooter>
321
+ <Button
322
+ type="button"
323
+ variant="outline"
324
+ onClick={() => onOpenChange(false)}
325
+ disabled={form.formState.isSubmitting}
326
+ >
327
+ {t('admin.actions.cancel')}
328
+ </Button>
329
+ <Button
330
+ type="submit"
331
+ disabled={form.formState.isSubmitting || !form.formState.isDirty}
332
+ >
333
+ {form.formState.isSubmitting ? t('admin.actions.saving') : t('admin.actions.save')}
334
+ </Button>
335
+ </SheetFooter>
336
+ </form>
337
+ )}
338
+ </SheetContent>
339
+ </Sheet>
340
+ )
341
+ }
342
+
343
+ // ============================================================================
344
+ // Shared form fields
345
+ // ============================================================================
346
+
347
+ function StockLocationFormFields({ form }: { form: UseFormReturn<StockLocationFormValues> }) {
348
+ const { t } = useTranslation()
349
+ const { errors } = form.formState
350
+ const countryCode = form.watch('country_code')
351
+ const { states } = useCountryStates(countryCode)
352
+ const pickupEnabled = form.watch('pickup_enabled')
353
+
354
+ return (
355
+ // Sections are siblings: each is its own fieldset, so nesting them inside
356
+ // one group would make every field a member of a group it does not belong
357
+ // to as well as its own.
358
+ <div className="flex flex-col gap-6">
359
+ {errors.root?.message && (
360
+ <p className="text-sm text-destructive" role="alert">
361
+ {errors.root.message}
362
+ </p>
363
+ )}
364
+
365
+ <FormSection title={t('admin.stock_locations.section_details')}>
366
+ <Field>
367
+ <FieldLabel htmlFor="name">{t('admin.fields.name.label')}</FieldLabel>
368
+ <Input
369
+ id="name"
370
+ autoFocus
371
+ placeholder={t('admin.fields.stock_location.name.placeholder')}
372
+ aria-invalid={!!errors.name || undefined}
373
+ {...form.register('name')}
374
+ />
375
+ <FieldError errors={[errors.name]} />
376
+ </Field>
377
+
378
+ <Field>
379
+ <FieldLabel htmlFor="admin-name">
380
+ {t('admin.fields.stock_location.admin_name.label')}
381
+ </FieldLabel>
382
+ <Input
383
+ id="admin-name"
384
+ placeholder={t('admin.fields.stock_location.admin_name.placeholder')}
385
+ aria-invalid={!!errors.admin_name || undefined}
386
+ {...form.register('admin_name')}
387
+ />
388
+ <FieldError errors={[errors.admin_name]} />
389
+ </Field>
390
+
391
+ <Field>
392
+ <FieldLabel htmlFor="kind">{t('admin.fields.stock_location.kind.label')}</FieldLabel>
393
+ <Controller
394
+ name="kind"
395
+ control={form.control}
396
+ render={({ field }) => {
397
+ const items = STOCK_LOCATION_KINDS.map((value) => ({
398
+ value,
399
+ label: t(`admin.stock_locations.kinds.${value}`),
400
+ }))
401
+ return (
402
+ <Select items={items as never} value={field.value} onValueChange={field.onChange}>
403
+ <SelectTrigger id="kind">
404
+ <SelectValue />
405
+ </SelectTrigger>
406
+ <SelectContent>
407
+ {items.map((o) => (
408
+ <SelectItem key={o.value} value={o.value}>
409
+ {o.label}
410
+ </SelectItem>
411
+ ))}
412
+ </SelectContent>
413
+ </Select>
414
+ )
415
+ }}
416
+ />
417
+ <FieldError errors={[errors.kind]} />
418
+ </Field>
419
+ </FormSection>
420
+
421
+ <FormSection title={t('admin.stock_locations.section_behavior')}>
422
+ <BooleanRow
423
+ id="active"
424
+ label={t('admin.fields.stock_location.active.label')}
425
+ help={t('admin.fields.stock_location.active.help')}
426
+ form={form}
427
+ name="active"
428
+ />
429
+ <BooleanRow
430
+ id="default"
431
+ label={t('admin.fields.stock_location.default.label')}
432
+ help={t('admin.fields.stock_location.default.help')}
433
+ form={form}
434
+ name="default"
435
+ />
436
+ <BooleanRow
437
+ id="backorderable-default"
438
+ label={t('admin.fields.stock_location.backorderable_default.label')}
439
+ help={t('admin.fields.stock_location.backorderable_default.help')}
440
+ form={form}
441
+ name="backorderable_default"
442
+ />
443
+ <BooleanRow
444
+ id="propagate-all-variants"
445
+ label={t('admin.fields.stock_location.propagate_all_variants.label')}
446
+ help={t('admin.fields.stock_location.propagate_all_variants.help')}
447
+ form={form}
448
+ name="propagate_all_variants"
449
+ />
450
+ </FormSection>
451
+
452
+ <FormSection title={t('admin.fields.address.address1.label')}>
453
+ <Field>
454
+ <FieldLabel htmlFor="address1">{t('admin.fields.address1.label')}</FieldLabel>
455
+ <Input
456
+ id="address1"
457
+ aria-invalid={!!errors.address1 || undefined}
458
+ {...form.register('address1')}
459
+ />
460
+ <FieldError errors={[errors.address1]} />
461
+ </Field>
462
+ <Field>
463
+ <FieldLabel htmlFor="address2">{t('admin.fields.address2.label')}</FieldLabel>
464
+ <Input
465
+ id="address2"
466
+ aria-invalid={!!errors.address2 || undefined}
467
+ {...form.register('address2')}
468
+ />
469
+ <FieldError errors={[errors.address2]} />
470
+ </Field>
471
+ <div className="grid grid-cols-2 gap-3">
472
+ <Field>
473
+ <FieldLabel htmlFor="city">{t('admin.fields.city.label')}</FieldLabel>
474
+ <Input id="city" aria-invalid={!!errors.city || undefined} {...form.register('city')} />
475
+ <FieldError errors={[errors.city]} />
476
+ </Field>
477
+ <Field>
478
+ <FieldLabel htmlFor="zipcode">{t('admin.fields.zipcode.label')}</FieldLabel>
479
+ <Input
480
+ id="zipcode"
481
+ aria-invalid={!!errors.zipcode || undefined}
482
+ {...form.register('zipcode')}
483
+ />
484
+ <FieldError errors={[errors.zipcode]} />
485
+ </Field>
486
+ </div>
487
+ <Field>
488
+ <FieldLabel>{t('admin.fields.country_code.label')}</FieldLabel>
489
+ <Controller
490
+ name="country_code"
491
+ control={form.control}
492
+ render={({ field }) => (
493
+ <CountryCombobox
494
+ value={field.value}
495
+ onValueChange={(iso) => {
496
+ field.onChange(iso)
497
+ form.setValue('state_code', '', { shouldDirty: true })
498
+ form.setValue('state_name', '', { shouldDirty: true })
499
+ }}
500
+ />
501
+ )}
502
+ />
503
+ <FieldError errors={[errors.country_code]} />
504
+ </Field>
505
+ {states.length > 0 ? (
506
+ <Field>
507
+ <FieldLabel>{t('admin.fields.state_code.label')}</FieldLabel>
508
+ <Controller
509
+ name="state_code"
510
+ control={form.control}
511
+ render={({ field }) => (
512
+ <StateCombobox
513
+ countryCode={countryCode}
514
+ states={states}
515
+ value={field.value}
516
+ onValueChange={field.onChange}
517
+ />
518
+ )}
519
+ />
520
+ <FieldError errors={[errors.state_code]} />
521
+ </Field>
522
+ ) : (
523
+ <Field>
524
+ <FieldLabel htmlFor="state-name">{t('admin.fields.state_name.label')}</FieldLabel>
525
+ <Input
526
+ id="state-name"
527
+ aria-invalid={!!errors.state_name || undefined}
528
+ {...form.register('state_name')}
529
+ />
530
+ <FieldError errors={[errors.state_name]} />
531
+ </Field>
532
+ )}
533
+ <Field>
534
+ <FieldLabel htmlFor="phone">{t('admin.fields.phone.label')}</FieldLabel>
535
+ <Input
536
+ id="phone"
537
+ aria-invalid={!!errors.phone || undefined}
538
+ {...form.register('phone')}
539
+ />
540
+ <FieldError errors={[errors.phone]} />
541
+ </Field>
542
+ <Field>
543
+ <FieldLabel htmlFor="company">{t('admin.fields.company.label')}</FieldLabel>
544
+ <Input
545
+ id="company"
546
+ aria-invalid={!!errors.company || undefined}
547
+ {...form.register('company')}
548
+ />
549
+ <FieldError errors={[errors.company]} />
550
+ </Field>
551
+ </FormSection>
552
+
553
+ <FormSection title={t('admin.stock_locations.section_returns')}>
554
+ <BooleanRow
555
+ id="returns-enabled"
556
+ label={t('admin.fields.stock_location.returns_enabled.label')}
557
+ help={t('admin.fields.stock_location.returns_enabled.help')}
558
+ form={form}
559
+ name="returns_enabled"
560
+ />
561
+ </FormSection>
562
+
563
+ <FormSection title={t('admin.stock_locations.section_pickup')}>
564
+ <BooleanRow
565
+ id="pickup-enabled"
566
+ label={t('admin.fields.stock_location.pickup_enabled.label')}
567
+ help={t('admin.fields.stock_location.pickup_enabled.help')}
568
+ form={form}
569
+ name="pickup_enabled"
570
+ />
571
+ {pickupEnabled && (
572
+ <>
573
+ <Field>
574
+ <FieldLabel htmlFor="pickup-stock-policy">
575
+ {t('admin.fields.stock_location.pickup_stock_policy.label')}
576
+ </FieldLabel>
577
+ <Controller
578
+ name="pickup_stock_policy"
579
+ control={form.control}
580
+ render={({ field }) => {
581
+ const items = PICKUP_STOCK_POLICIES.map((value) => ({
582
+ value,
583
+ label: t(`admin.stock_locations.pickup_stock_policies.${value}`),
584
+ }))
585
+ return (
586
+ <Select
587
+ items={items as never}
588
+ value={field.value}
589
+ onValueChange={field.onChange}
590
+ >
591
+ <SelectTrigger id="pickup-stock-policy">
592
+ <SelectValue />
593
+ </SelectTrigger>
594
+ <SelectContent>
595
+ {items.map((o) => (
596
+ <SelectItem key={o.value} value={o.value}>
597
+ {o.label}
598
+ </SelectItem>
599
+ ))}
600
+ </SelectContent>
601
+ </Select>
602
+ )
603
+ }}
604
+ />
605
+ <FieldError errors={[errors.pickup_stock_policy]} />
606
+ </Field>
607
+ <Field>
608
+ <FieldLabel htmlFor="pickup-ready-in-minutes">
609
+ {t('admin.fields.stock_location.pickup_ready_in_minutes.label')}
610
+ </FieldLabel>
611
+ <Input
612
+ id="pickup-ready-in-minutes"
613
+ type="number"
614
+ min={0}
615
+ step={5}
616
+ placeholder={t('admin.fields.stock_location.pickup_ready_in_minutes.placeholder')}
617
+ aria-invalid={!!errors.pickup_ready_in_minutes || undefined}
618
+ {...form.register('pickup_ready_in_minutes')}
619
+ />
620
+ <FieldError errors={[errors.pickup_ready_in_minutes]} />
621
+ </Field>
622
+ <Field>
623
+ <FieldLabel htmlFor="pickup-instructions">
624
+ {t('admin.fields.stock_location.pickup_instructions.label')}
625
+ </FieldLabel>
626
+ <Textarea
627
+ id="pickup-instructions"
628
+ rows={3}
629
+ placeholder={t('admin.fields.stock_location.pickup_instructions.placeholder')}
630
+ aria-invalid={!!errors.pickup_instructions || undefined}
631
+ {...form.register('pickup_instructions')}
632
+ />
633
+ <FieldError errors={[errors.pickup_instructions]} />
634
+ </Field>
635
+ </>
636
+ )}
637
+ </FormSection>
638
+ </div>
639
+ )
640
+ }
641
+
642
+ function BooleanRow({
643
+ id,
644
+ label,
645
+ help,
646
+ form,
647
+ name,
648
+ }: {
649
+ id: string
650
+ label: string
651
+ help?: string
652
+ form: UseFormReturn<StockLocationFormValues>
653
+ name: keyof StockLocationFormValues
654
+ }) {
655
+ return (
656
+ <Field>
657
+ <div className="flex items-start justify-between gap-4">
658
+ <div className="flex flex-col">
659
+ <FieldLabel htmlFor={id} className="cursor-pointer">
660
+ {label}
661
+ </FieldLabel>
662
+ {help && <span className="text-xs text-muted-foreground">{help}</span>}
663
+ </div>
664
+ <Controller
665
+ name={name as never}
666
+ control={form.control}
667
+ render={({ field }) => (
668
+ <Switch id={id} checked={!!field.value} onCheckedChange={field.onChange} />
669
+ )}
670
+ />
671
+ </div>
672
+ </Field>
673
+ )
674
+ }