@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,484 @@
1
+ import { zodResolver } from '@hookform/resolvers/zod'
2
+ import {
3
+ Button,
4
+ Field,
5
+ FieldDescription,
6
+ FieldError,
7
+ FieldGroup,
8
+ FieldLabel,
9
+ Input,
10
+ RowActions,
11
+ Select,
12
+ SelectContent,
13
+ SelectItem,
14
+ SelectTrigger,
15
+ SelectValue,
16
+ Sheet,
17
+ SheetContent,
18
+ SheetDescription,
19
+ SheetFooter,
20
+ SheetHeader,
21
+ SheetTitle,
22
+ Switch,
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 { 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 { PanelPackageType } from '../api-client'
33
+ import { Can } from '../components/can'
34
+ import { ResourceTable, resourceSearchSchema } from '../components/resource-table'
35
+ import {
36
+ canDeletePackageTypes,
37
+ canWritePackageTypes,
38
+ listPackageTypes,
39
+ useCreatePackageType,
40
+ useDeletePackageType,
41
+ usePackageType,
42
+ useUpdatePackageType,
43
+ } from '../hooks/use-package-types'
44
+ import { mapSpreeErrorsToForm } from '../lib/form-errors'
45
+ import { Subject } from '../lib/permissions'
46
+ import { usePermissions } from '../providers/permission-provider'
47
+ import {
48
+ PACKAGE_DIMENSION_UNITS,
49
+ PACKAGE_TYPE_DEFAULTS,
50
+ PACKAGE_TYPE_KINDS,
51
+ PACKAGE_WEIGHT_UNITS,
52
+ type PackageTypeFormValues,
53
+ packageTypeFormSchema,
54
+ packageTypeFormValuesToParams,
55
+ packageTypeToFormValues,
56
+ } from '../schemas/package-type'
57
+ import '../tables/package-types'
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 packageTypesSearchSchema = resourceSearchSchema.extend({
69
+ edit: z.string().optional(),
70
+ new: z.coerce.boolean().optional(),
71
+ })
72
+
73
+ export type PackageTypesSearch = z.infer<typeof packageTypesSearchSchema>
74
+
75
+ /**
76
+ * Manage the packaging this panel can see: the boxes parcels ship in, the
77
+ * cartons products are packed into, the pallets a wholesale order leaves on.
78
+ *
79
+ * Shared by the operator's dashboard and the marketplace seller panel. The
80
+ * scoping is server-side — the operator's API answers with every row in the
81
+ * store and a seller's with their own plus the marketplace's shared
82
+ * vocabulary — so this page renders whatever it is given
83
+ * (docs/plans/6.0-seller-package-types.md).
84
+ *
85
+ * One row can be listed but not written: a seller sees the marketplace's
86
+ * packaging so they know what they may pack into, and it reports
87
+ * `editable: false`. The operator's serializer carries no such field because
88
+ * every row there is theirs, so an absent flag reads as editable.
89
+ */
90
+ function noop() {}
91
+
92
+ export function PackageTypesPage({ search }: { search: PackageTypesSearch }) {
93
+ const { t } = useTranslation()
94
+ const navigate = useNavigate()
95
+ const confirm = useConfirm()
96
+ const deleteMutation = useDeletePackageType()
97
+ const { permissions } = usePermissions()
98
+
99
+ // A panel may register `packageTypes.list` alone for the variant editor's
100
+ // carton picker. Reading it here keeps this page honest on such a panel:
101
+ // no add button, no row actions, and no sheet whose save could not work.
102
+ const writable = canWritePackageTypes()
103
+
104
+ // Neither sheet opens on a read-only panel. No mutual exclusion needed:
105
+ // openCreate strips `edit` and openEdit strips `new`, so a link can only
106
+ // carry one.
107
+ const isCreating = writable && !!search.new
108
+ const editId = writable ? search.edit : undefined
109
+
110
+ function closeSheet() {
111
+ navigate({
112
+ search: (prev: Record<string, unknown>) => {
113
+ const { edit: _e, new: _n, ...rest } = prev
114
+ return rest as never
115
+ },
116
+ })
117
+ }
118
+
119
+ function openCreate() {
120
+ navigate({
121
+ search: (prev: Record<string, unknown>) => {
122
+ const { edit: _e, ...rest } = prev
123
+ return { ...rest, new: true } as never
124
+ },
125
+ })
126
+ }
127
+
128
+ function openEdit(id: string) {
129
+ navigate({
130
+ search: (prev: Record<string, unknown>) => {
131
+ const { new: _n, ...rest } = prev
132
+ return { ...rest, edit: id } as never
133
+ },
134
+ })
135
+ }
136
+
137
+ // Guarded on `writable` as well: on a read-only panel no sheet can open, so
138
+ // a click would only push an `edit` param nothing consumes or clears.
139
+ useRowClickBridge('data-package-type-id', writable ? openEdit : noop)
140
+
141
+ async function handleDelete(packageType: PanelPackageType) {
142
+ const ok = await confirm({
143
+ title: t('admin.package_types.delete_confirm.title'),
144
+ message: t('admin.package_types.delete_confirm.message', { name: packageType.name }),
145
+ variant: 'destructive',
146
+ confirmLabel: t('admin.actions.delete'),
147
+ })
148
+ if (!ok) return
149
+ await deleteMutation.mutateAsync(packageType.id).catch(() => undefined)
150
+ }
151
+
152
+ return (
153
+ <>
154
+ <ResourceTable<PanelPackageType>
155
+ tableKey="package-types"
156
+ queryKey="package-types"
157
+ queryFn={listPackageTypes}
158
+ searchParams={search}
159
+ rowActions={(packageType) => {
160
+ // A marketplace row in a seller's list is read-only, so it gets no
161
+ // actions at all rather than actions that 404 on click. Same for
162
+ // every row when this panel's client registered reads only.
163
+ if (packageType.editable === false || !writable) return null
164
+
165
+ return (
166
+ <RowActions
167
+ actions={[
168
+ {
169
+ key: 'edit',
170
+ visible: permissions.can('update', Subject.PackageType),
171
+ onSelect: () => openEdit(packageType.id),
172
+ },
173
+ {
174
+ key: 'delete',
175
+ destructive: true,
176
+ visible:
177
+ canDeletePackageTypes() && permissions.can('destroy', Subject.PackageType),
178
+ disabled: deleteMutation.isPending,
179
+ onSelect: () => handleDelete(packageType),
180
+ },
181
+ ]}
182
+ />
183
+ )
184
+ }}
185
+ actions={
186
+ writable ? (
187
+ <Can I="create" a={Subject.PackageType}>
188
+ <Button size="sm" className="h-[2.125rem]" onClick={openCreate}>
189
+ <PlusIcon className="size-4" />
190
+ {t('admin.package_types.add_cta')}
191
+ </Button>
192
+ </Can>
193
+ ) : null
194
+ }
195
+ />
196
+
197
+ {isCreating && <CreatePackageTypeSheet open onOpenChange={(o) => !o && closeSheet()} />}
198
+ {editId && <EditPackageTypeSheet id={editId} open onOpenChange={(o) => !o && closeSheet()} />}
199
+ </>
200
+ )
201
+ }
202
+
203
+ function CreatePackageTypeSheet({
204
+ open,
205
+ onOpenChange,
206
+ }: {
207
+ open: boolean
208
+ onOpenChange: (open: boolean) => void
209
+ }) {
210
+ const { t } = useTranslation()
211
+ const createMutation = useCreatePackageType()
212
+ const form = useForm<PackageTypeFormValues>({
213
+ resolver: zodResolver(packageTypeFormSchema),
214
+ defaultValues: PACKAGE_TYPE_DEFAULTS,
215
+ })
216
+
217
+ async function onSubmit(values: PackageTypeFormValues) {
218
+ try {
219
+ await createMutation.mutateAsync(packageTypeFormValuesToParams(values))
220
+ form.reset(PACKAGE_TYPE_DEFAULTS)
221
+ onOpenChange(false)
222
+ } catch (err) {
223
+ if (!mapSpreeErrorsToForm(err, form.setError)) throw err
224
+ }
225
+ }
226
+
227
+ return (
228
+ <Sheet
229
+ open={open}
230
+ onOpenChange={(next) => {
231
+ if (!next) form.reset(PACKAGE_TYPE_DEFAULTS)
232
+ onOpenChange(next)
233
+ }}
234
+ >
235
+ <SheetContent>
236
+ <SheetHeader>
237
+ <SheetTitle>{t('admin.pages.settings.package_types.add_sheet_title')}</SheetTitle>
238
+ <SheetDescription>{t('admin.package_types.create_description')}</SheetDescription>
239
+ </SheetHeader>
240
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex min-h-0 flex-1 flex-col">
241
+ <div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
242
+ <PackageTypeFormFields form={form} />
243
+ </div>
244
+ <SheetFooter>
245
+ <Button type="submit" disabled={createMutation.isPending}>
246
+ {t('admin.actions.save')}
247
+ </Button>
248
+ </SheetFooter>
249
+ </form>
250
+ </SheetContent>
251
+ </Sheet>
252
+ )
253
+ }
254
+
255
+ function EditPackageTypeSheet({
256
+ id,
257
+ open,
258
+ onOpenChange,
259
+ }: {
260
+ id: string
261
+ open: boolean
262
+ onOpenChange: (open: boolean) => void
263
+ }) {
264
+ const { t } = useTranslation()
265
+ const { data: packageType } = usePackageType(id)
266
+ const updateMutation = useUpdatePackageType(id)
267
+ // A pasted link can name a row this panel may read but not write — the
268
+ // marketplace's packaging in a seller's panel. Closing rather than
269
+ // rendering a form whose save could only 404.
270
+ const readOnly = packageType?.editable === false
271
+ const form = useForm<PackageTypeFormValues>({
272
+ resolver: zodResolver(packageTypeFormSchema),
273
+ defaultValues: PACKAGE_TYPE_DEFAULTS,
274
+ values: packageType ? packageTypeToFormValues(packageType) : undefined,
275
+ })
276
+
277
+ async function onSubmit(values: PackageTypeFormValues) {
278
+ try {
279
+ await updateMutation.mutateAsync(packageTypeFormValuesToParams(values))
280
+ onOpenChange(false)
281
+ } catch (err) {
282
+ if (!mapSpreeErrorsToForm(err, form.setError)) throw err
283
+ }
284
+ }
285
+
286
+ useEffect(() => {
287
+ if (readOnly) onOpenChange(false)
288
+ }, [readOnly, onOpenChange])
289
+
290
+ if (readOnly) return null
291
+
292
+ return (
293
+ <Sheet open={open} onOpenChange={onOpenChange}>
294
+ <SheetContent>
295
+ <SheetHeader>
296
+ <SheetTitle>{packageType?.name ?? t('admin.package_types.edit_sheet_title')}</SheetTitle>
297
+ <SheetDescription>{t('admin.package_types.edit_description')}</SheetDescription>
298
+ </SheetHeader>
299
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex min-h-0 flex-1 flex-col">
300
+ <div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
301
+ <PackageTypeFormFields form={form} />
302
+ </div>
303
+ <SheetFooter>
304
+ <Button type="submit" disabled={updateMutation.isPending}>
305
+ {t('admin.actions.save')}
306
+ </Button>
307
+ </SheetFooter>
308
+ </form>
309
+ </SheetContent>
310
+ </Sheet>
311
+ )
312
+ }
313
+
314
+ function PackageTypeFormFields({ form }: { form: UseFormReturn<PackageTypeFormValues> }) {
315
+ const { t } = useTranslation()
316
+ const errors = form.formState.errors
317
+
318
+ return (
319
+ <FieldGroup>
320
+ <Field>
321
+ <FieldLabel htmlFor="package-type-name">
322
+ {t('admin.fields.package_type.name.label')}
323
+ </FieldLabel>
324
+ <Input id="package-type-name" {...form.register('name')} aria-invalid={!!errors.name} />
325
+ <FieldError errors={[errors.name]} />
326
+ </Field>
327
+
328
+ <Field>
329
+ <FieldLabel htmlFor="package-type-kind">
330
+ {t('admin.fields.package_type.kind.label')}
331
+ </FieldLabel>
332
+ <Controller
333
+ control={form.control}
334
+ name="kind"
335
+ render={({ field }) => {
336
+ const options = PACKAGE_TYPE_KINDS.map((kind) => ({
337
+ value: kind,
338
+ label: t(`admin.package_types.kinds.${kind}`),
339
+ }))
340
+ return (
341
+ <Select items={options} value={field.value} onValueChange={field.onChange}>
342
+ <SelectTrigger id="package-type-kind">
343
+ <SelectValue />
344
+ </SelectTrigger>
345
+ <SelectContent>
346
+ {options.map((option) => (
347
+ <SelectItem key={option.value} value={option.value}>
348
+ {option.label}
349
+ </SelectItem>
350
+ ))}
351
+ </SelectContent>
352
+ </Select>
353
+ )
354
+ }}
355
+ />
356
+ <FieldDescription>{t('admin.fields.package_type.kind.help')}</FieldDescription>
357
+ </Field>
358
+
359
+ <div className="grid grid-cols-[1fr_1fr_1fr_110px] gap-3">
360
+ {(['length', 'width', 'height'] as const).map((side) => (
361
+ <Field key={side}>
362
+ <FieldLabel htmlFor={`package-type-${side}`}>
363
+ {t(`admin.fields.package_type.${side}.label`)}
364
+ </FieldLabel>
365
+ <Input
366
+ id={`package-type-${side}`}
367
+ type="number"
368
+ min="0"
369
+ step="0.01"
370
+ aria-invalid={!!errors[side]}
371
+ {...form.register(side)}
372
+ />
373
+ <FieldError errors={[errors[side]]} />
374
+ </Field>
375
+ ))}
376
+ <UnitField
377
+ form={form}
378
+ name="dimensions_unit"
379
+ units={PACKAGE_DIMENSION_UNITS}
380
+ label={t('admin.fields.package_type.dimensions_unit.label')}
381
+ />
382
+ </div>
383
+ <FieldDescription>{t('admin.fields.package_type.dimensions.help')}</FieldDescription>
384
+
385
+ <div className="grid grid-cols-[1fr_1fr_110px] gap-3">
386
+ <Field>
387
+ <FieldLabel htmlFor="package-type-weight">
388
+ {t('admin.fields.package_type.weight.label')}
389
+ </FieldLabel>
390
+ <Input
391
+ id="package-type-weight"
392
+ type="number"
393
+ min="0"
394
+ step="0.01"
395
+ aria-invalid={!!errors.weight}
396
+ {...form.register('weight')}
397
+ />
398
+ <FieldError errors={[errors.weight]} />
399
+ <FieldDescription>{t('admin.fields.package_type.weight.help')}</FieldDescription>
400
+ </Field>
401
+ <Field>
402
+ <FieldLabel htmlFor="package-type-max-weight">
403
+ {t('admin.fields.package_type.max_weight.label')}
404
+ </FieldLabel>
405
+ <Input
406
+ id="package-type-max-weight"
407
+ type="number"
408
+ min="0"
409
+ step="0.01"
410
+ aria-invalid={!!errors.max_weight}
411
+ {...form.register('max_weight')}
412
+ />
413
+ <FieldError errors={[errors.max_weight]} />
414
+ <FieldDescription>{t('admin.fields.package_type.max_weight.help')}</FieldDescription>
415
+ </Field>
416
+ <UnitField
417
+ form={form}
418
+ name="weight_unit"
419
+ units={PACKAGE_WEIGHT_UNITS}
420
+ label={t('admin.fields.package_type.weight_unit.label')}
421
+ />
422
+ </div>
423
+
424
+ <Field>
425
+ <div className="flex items-start justify-between gap-4">
426
+ <FieldLabel htmlFor="package-type-default" className="cursor-pointer">
427
+ {t('admin.fields.package_type.default.label')}
428
+ </FieldLabel>
429
+ <Controller
430
+ control={form.control}
431
+ name="default"
432
+ render={({ field }) => (
433
+ <Switch
434
+ id="package-type-default"
435
+ checked={!!field.value}
436
+ onCheckedChange={field.onChange}
437
+ />
438
+ )}
439
+ />
440
+ </div>
441
+ <FieldDescription>{t('admin.fields.package_type.default.help')}</FieldDescription>
442
+ </Field>
443
+ </FieldGroup>
444
+ )
445
+ }
446
+
447
+ function UnitField({
448
+ form,
449
+ name,
450
+ units,
451
+ label,
452
+ }: {
453
+ form: UseFormReturn<PackageTypeFormValues>
454
+ name: 'dimensions_unit' | 'weight_unit'
455
+ units: readonly string[]
456
+ label: string
457
+ }) {
458
+ return (
459
+ <Field>
460
+ <FieldLabel htmlFor={`package-type-${name}`}>{label}</FieldLabel>
461
+ <Controller
462
+ control={form.control}
463
+ name={name}
464
+ render={({ field }) => {
465
+ const options = units.map((unit) => ({ value: unit, label: unit }))
466
+ return (
467
+ <Select items={options} value={field.value ?? ''} onValueChange={field.onChange}>
468
+ <SelectTrigger id={`package-type-${name}`}>
469
+ <SelectValue />
470
+ </SelectTrigger>
471
+ <SelectContent>
472
+ {options.map((option) => (
473
+ <SelectItem key={option.value} value={option.value}>
474
+ {option.label}
475
+ </SelectItem>
476
+ ))}
477
+ </SelectContent>
478
+ </Select>
479
+ )
480
+ }}
481
+ />
482
+ </Field>
483
+ )
484
+ }
@@ -0,0 +1,112 @@
1
+ import { Card, SearchInput } from '@spree/dashboard-ui'
2
+ import { PackageIcon } from '@spree/dashboard-ui/icons'
3
+ import { Link } from '@tanstack/react-router'
4
+ import { useMemo, useState } from 'react'
5
+ import { useTranslation } from 'react-i18next'
6
+ import { PageHeader } from '../components/page-header'
7
+ import { resolveNavDescription, resolveNavLabel } from '../lib/nav-registry'
8
+ import {
9
+ filterSettingsByPermissions,
10
+ filterSettingsByQuery,
11
+ type SettingsNavEntry,
12
+ useSettingsNav,
13
+ } from '../lib/settings-nav-registry'
14
+ import { usePermissions } from '../providers/permission-provider'
15
+
16
+ /**
17
+ * Settings landing page — a card grid of every settings area the current
18
+ * member can reach, grouped the same way the sidebar groups them.
19
+ *
20
+ * Shared by the operator dashboard and the marketplace seller panel. Each
21
+ * host passes its tenant id so the cards link under that panel's prefix
22
+ * (`/$storeId/settings/…` or `/$sellerId/settings/…`).
23
+ *
24
+ * It doubles as the settings navigation below `lg`, where `SettingsSidebar` is
25
+ * hidden: on a narrow viewport this grid is the only way into the area, so it
26
+ * must never be replaced by a redirect to a specific page.
27
+ */
28
+ export function SettingsIndexPage({ tenantId }: { tenantId: string }) {
29
+ const { t } = useTranslation()
30
+ const snapshot = useSettingsNav()
31
+ const { permissions } = usePermissions()
32
+ const [query, setQuery] = useState('')
33
+
34
+ // Permission filtering depends only on the snapshot, so it survives typing.
35
+ const allowed = useMemo(
36
+ () => filterSettingsByPermissions(snapshot, permissions),
37
+ [snapshot, permissions],
38
+ )
39
+ const groups = useMemo(() => filterSettingsByQuery(allowed, query, t).groups, [allowed, query, t])
40
+
41
+ return (
42
+ <div className="flex flex-col gap-6">
43
+ <PageHeader
44
+ title={t('admin.settings_page.title')}
45
+ subtitle={t('admin.settings_page.description')}
46
+ />
47
+
48
+ {/* Hidden from `lg`, where the settings sidebar is on screen listing the
49
+ same entries and carrying the same search. Below that the sidebar is
50
+ gone, so this is the only way to filter — and the only navigation. */}
51
+ <div className="lg:hidden">
52
+ <SearchInput
53
+ value={query}
54
+ onValueChange={setQuery}
55
+ placeholder={t('admin.settings_page.search_placeholder')}
56
+ aria-label={t('admin.settings_page.search_placeholder')}
57
+ clearLabel={t('admin.common.clear')}
58
+ className="h-11 max-w-md"
59
+ />
60
+ </div>
61
+
62
+ {groups.length === 0 ? (
63
+ <p className="text-sm text-muted-foreground">
64
+ {t('admin.settings_page.no_results', { query: query.trim() })}
65
+ </p>
66
+ ) : (
67
+ groups.map(({ group, entries }) => (
68
+ <section key={group.key} className="flex flex-col gap-3">
69
+ <h2 className="text-sm font-medium text-muted-foreground">
70
+ {resolveNavLabel(group, t)}
71
+ </h2>
72
+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
73
+ {entries.map((entry) => (
74
+ <SettingsCard key={entry.key} entry={entry} tenantId={tenantId} />
75
+ ))}
76
+ </div>
77
+ </section>
78
+ ))
79
+ )}
80
+ </div>
81
+ )
82
+ }
83
+
84
+ function SettingsCard({ entry, tenantId }: { entry: SettingsNavEntry; tenantId: string }) {
85
+ const { t } = useTranslation()
86
+ const Icon = entry.icon ?? PackageIcon
87
+ const label = resolveNavLabel(entry, t)
88
+ const description = resolveNavDescription(entry, t)
89
+
90
+ return (
91
+ <Card className="p-0 transition-colors hover:bg-accent/80 hover:border-border">
92
+ {/* The whole card is the target — a small title-only link would fail the
93
+ 44px minimum on touch and leaves most of the card dead to a click. */}
94
+ {/* Paths come from a runtime registry plugins extend, so they can't be
95
+ checked against the generated route tree. */}
96
+ <Link
97
+ to={`/${tenantId}/settings${entry.path}` as never}
98
+ // The whole card is the target, so without a replacement for the
99
+ // suppressed outline a keyboard user tabbing the grid cannot tell which
100
+ // card they are on. A solid ring rather than the border pair buttons
101
+ // use: this link draws no border of its own to recolour.
102
+ className="flex h-full items-start gap-3 rounded-xl p-4 outline-none focus-visible:shadow-[0_0_0_2px_var(--ring)]"
103
+ >
104
+ <Icon className="mt-0.5 size-6 shrink-0 text-muted-foreground opacity-80 hover:opacity-100" />
105
+ <span className="flex min-w-0 flex-col gap-1">
106
+ <span className="text-sm font-medium leading-none">{label}</span>
107
+ {description && <span className="text-sm text-muted-foreground">{description}</span>}
108
+ </span>
109
+ </Link>
110
+ </Card>
111
+ )
112
+ }