@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
@@ -1,9 +1,9 @@
1
1
  import { zodResolver } from '@hookform/resolvers/zod'
2
- import type { Address } from '@spree/admin-sdk'
3
2
  import {
4
3
  Button,
5
4
  Checkbox,
6
5
  Field,
6
+ FieldDescription,
7
7
  FieldError,
8
8
  FieldGroup,
9
9
  FieldLabel,
@@ -21,33 +21,27 @@ import { Controller, useForm } from 'react-hook-form'
21
21
  import { useTranslation } from 'react-i18next'
22
22
  import { z } from 'zod/v4'
23
23
  import { mapSpreeErrorsToForm } from '../lib/form-errors'
24
+ import { useOptionalStore } from '../providers/store-provider'
24
25
  import { CountryCombobox } from './country-combobox'
25
26
  import { StateCombobox, useCountryStates } from './country-state-fields'
26
27
 
27
28
  export interface AddressParams {
28
29
  first_name: string
29
30
  last_name: string
31
+ company?: string
30
32
  address1: string
31
33
  address2: string
32
34
  city: string
33
35
  postal_code: string
34
- country_iso: string
35
- state_abbr: string
36
+ country_code: string
37
+ state_code: string
36
38
  phone: string
37
39
  label?: string
38
40
  is_default_billing?: boolean
39
41
  is_default_shipping?: boolean
40
42
  }
41
43
 
42
- const addressFormSchema = z.object({
43
- first_name: z
44
- .string()
45
- .trim()
46
- .min(1, { error: requiredMessage('first_name') }),
47
- last_name: z
48
- .string()
49
- .trim()
50
- .min(1, { error: requiredMessage('last_name') }),
44
+ const addressFormFields = {
51
45
  address1: z
52
46
  .string()
53
47
  .trim()
@@ -61,29 +55,103 @@ const addressFormSchema = z.object({
61
55
  .string()
62
56
  .trim()
63
57
  .min(1, { error: requiredMessage('address.postal_code') }),
64
- country_iso: z
58
+ country_code: z
65
59
  .string()
66
60
  .trim()
67
- .min(1, { error: requiredMessage('country_iso') }),
68
- state_abbr: z.string(),
61
+ .min(1, { error: requiredMessage('country_code') }),
62
+ state_code: z.string(),
69
63
  phone: z.string(),
70
64
  label: z.string(),
71
65
  is_default_billing: z.boolean(),
72
66
  is_default_shipping: z.boolean(),
67
+ }
68
+
69
+ /**
70
+ * Whose address this is.
71
+ *
72
+ * A person's address is addressed to them, so it needs their name. A
73
+ * business's is addressed to the company — there is no person to name, and
74
+ * the company is the part that cannot be left out. Mirrors the same
75
+ * distinction `Spree::Address` makes server-side, so the form asks for
76
+ * exactly what the model will accept.
77
+ */
78
+ const personalSchema = z.object({
79
+ ...addressFormFields,
80
+ first_name: z
81
+ .string()
82
+ .trim()
83
+ .min(1, { error: requiredMessage('first_name') }),
84
+ last_name: z
85
+ .string()
86
+ .trim()
87
+ .min(1, { error: requiredMessage('last_name') }),
88
+ company: z.string(),
89
+ })
90
+
91
+ const businessSchema = z.object({
92
+ ...addressFormFields,
93
+ first_name: z.string(),
94
+ last_name: z.string(),
95
+ company: z
96
+ .string()
97
+ .trim()
98
+ .min(1, { error: requiredMessage('company') }),
73
99
  })
74
100
 
75
- type AddressFormValues = z.infer<typeof addressFormSchema>
101
+ type AddressFormValues = z.infer<typeof personalSchema>
102
+
103
+ /**
104
+ * Just the fields this dialog reads.
105
+ *
106
+ * Structural rather than the Admin SDK's `Address`, which carries operator
107
+ * data (metadata, customer_id, timestamps) that a seller's serializer
108
+ * deliberately withholds — binding core to the wider type would stop the
109
+ * seller panel from reusing the one address form.
110
+ */
111
+ export interface EditableAddress {
112
+ /** Re-seeds the form when the dialog is pointed at a different address. */
113
+ id?: string
114
+ first_name?: string | null
115
+ last_name?: string | null
116
+ company?: string | null
117
+ address1?: string | null
118
+ address2?: string | null
119
+ city?: string | null
120
+ postal_code?: string | null
121
+ country_code?: string | null
122
+ state_code?: string | null
123
+ phone?: string | null
124
+ label?: string | null
125
+ is_default_billing?: boolean | null
126
+ is_default_shipping?: boolean | null
127
+ }
76
128
 
77
- function buildDefaults(address: Address | null | undefined): AddressFormValues {
129
+ /**
130
+ * Country a new address starts with. An existing record keeps whatever it
131
+ * stored; a new one takes the store's default so the merchant is not asked
132
+ * to pick the same country every time.
133
+ */
134
+ export function resolveAddressCountryCode(
135
+ address: EditableAddress | null | undefined,
136
+ storeDefaultCountryCode?: string | null,
137
+ ): string {
138
+ return address?.country_code || storeDefaultCountryCode || ''
139
+ }
140
+
141
+ function buildDefaults(
142
+ address: EditableAddress | null | undefined,
143
+ storeDefaultCountryCode?: string | null,
144
+ ): AddressFormValues {
78
145
  return {
79
146
  first_name: address?.first_name ?? '',
80
147
  last_name: address?.last_name ?? '',
148
+ company: address?.company ?? '',
81
149
  address1: address?.address1 ?? '',
82
150
  address2: address?.address2 ?? '',
83
151
  city: address?.city ?? '',
84
152
  postal_code: address?.postal_code ?? '',
85
- country_iso: address?.country_iso ?? '',
86
- state_abbr: address?.state_abbr ?? '',
153
+ country_code: resolveAddressCountryCode(address, storeDefaultCountryCode),
154
+ state_code: address?.state_code ?? '',
87
155
  phone: address?.phone ?? '',
88
156
  label: address?.label ?? '',
89
157
  is_default_billing: address?.is_default_billing ?? false,
@@ -100,8 +168,11 @@ export function AddressFormDialog({
100
168
  isPending = false,
101
169
  showLabel = false,
102
170
  showDefaultFlags = false,
171
+ business = false,
172
+ companyReadOnly = false,
173
+ description,
103
174
  }: {
104
- address: Address | null | undefined
175
+ address: EditableAddress | null | undefined
105
176
  open: boolean
106
177
  onOpenChange: (open: boolean) => void
107
178
  onSave: (address: AddressParams) => void | Promise<void>
@@ -109,12 +180,25 @@ export function AddressFormDialog({
109
180
  isPending?: boolean
110
181
  showLabel?: boolean
111
182
  showDefaultFlags?: boolean
183
+ /** Addressed to a company rather than a person — see the schemas above. */
184
+ business?: boolean
185
+ /**
186
+ * Show the company line but do not let it be edited. For an address whose
187
+ * owner already answers the question — a company node names itself, so
188
+ * there is nothing for the merchant to type.
189
+ */
190
+ companyReadOnly?: boolean
191
+ /** Overrides the standard "update the address details" blurb. */
192
+ description?: string
112
193
  }) {
113
194
  const { t } = useTranslation()
114
195
  const resolvedTitle = title ?? t('admin.components.address_form_dialog.edit_title')
196
+ // Optional: the seller panel has no store context, so a new address there
197
+ // stays country-empty rather than refusing to render.
198
+ const storeDefaultCountryCode = useOptionalStore()?.defaultCountry
115
199
  const form = useForm<AddressFormValues>({
116
- defaultValues: buildDefaults(address),
117
- resolver: zodResolver(addressFormSchema),
200
+ defaultValues: buildDefaults(address, storeDefaultCountryCode),
201
+ resolver: zodResolver(business ? businessSchema : personalSchema),
118
202
  })
119
203
  const { errors } = form.formState
120
204
 
@@ -127,12 +211,22 @@ export function AddressFormDialog({
127
211
  useEffect(() => {
128
212
  if (address?.id !== prevAddressIdRef.current) {
129
213
  prevAddressIdRef.current = address?.id
130
- form.reset(buildDefaults(address))
214
+ form.reset(buildDefaults(address, storeDefaultCountryCode))
131
215
  }
132
- }, [address, form])
216
+ }, [address, form, storeDefaultCountryCode])
217
+
218
+ // The store payload can arrive after the sheet mounts. Fill country on a
219
+ // new record only while the field is still empty so a merchant who already
220
+ // picked a different country is not overwritten.
221
+ useEffect(() => {
222
+ if (address?.country_code) return
223
+ if (!storeDefaultCountryCode) return
224
+ if (form.getValues('country_code')) return
225
+ form.setValue('country_code', storeDefaultCountryCode)
226
+ }, [address?.country_code, storeDefaultCountryCode, form])
133
227
 
134
- const countryIso = form.watch('country_iso')
135
- const { states, statesRequired } = useCountryStates(countryIso)
228
+ const countryCode = form.watch('country_code')
229
+ const { states, statesRequired } = useCountryStates(countryCode)
136
230
  const useStateCombobox = statesRequired && states.length > 0
137
231
 
138
232
  async function onSubmit(values: AddressFormValues) {
@@ -140,12 +234,13 @@ export function AddressFormDialog({
140
234
  await onSave({
141
235
  first_name: values.first_name,
142
236
  last_name: values.last_name,
237
+ company: values.company,
143
238
  address1: values.address1,
144
239
  address2: values.address2,
145
240
  city: values.city,
146
241
  postal_code: values.postal_code,
147
- country_iso: values.country_iso,
148
- state_abbr: values.state_abbr,
242
+ country_code: values.country_code,
243
+ state_code: values.state_code,
149
244
  phone: values.phone,
150
245
  ...(showLabel && { label: values.label || undefined }),
151
246
  ...(showDefaultFlags && {
@@ -177,7 +272,7 @@ export function AddressFormDialog({
177
272
  <SheetHeader>
178
273
  <SheetTitle>{resolvedTitle}</SheetTitle>
179
274
  <SheetDescription>
180
- {t('admin.components.address_form_dialog.description')}
275
+ {description ?? t('admin.components.address_form_dialog.description')}
181
276
  </SheetDescription>
182
277
  </SheetHeader>
183
278
  <form
@@ -199,26 +294,43 @@ export function AddressFormDialog({
199
294
  />
200
295
  </Field>
201
296
  )}
202
- <div className="grid grid-cols-2 gap-3">
297
+ {business ? (
203
298
  <Field>
204
- <FieldLabel htmlFor="addr-fn">{t('admin.fields.first_name.label')}</FieldLabel>
299
+ <FieldLabel htmlFor="addr-co">{t('admin.fields.company.label')}</FieldLabel>
205
300
  <Input
206
- id="addr-fn"
207
- aria-invalid={!!errors.first_name || undefined}
208
- {...form.register('first_name')}
301
+ id="addr-co"
302
+ aria-invalid={!!errors.company || undefined}
303
+ disabled={companyReadOnly}
304
+ readOnly={companyReadOnly}
305
+ {...form.register('company')}
209
306
  />
210
- <FieldError errors={[errors.first_name]} />
211
- </Field>
212
- <Field>
213
- <FieldLabel htmlFor="addr-ln">{t('admin.fields.last_name.label')}</FieldLabel>
214
- <Input
215
- id="addr-ln"
216
- aria-invalid={!!errors.last_name || undefined}
217
- {...form.register('last_name')}
218
- />
219
- <FieldError errors={[errors.last_name]} />
307
+ {companyReadOnly && (
308
+ <FieldDescription>{t('admin.fields.company.help')}</FieldDescription>
309
+ )}
310
+ <FieldError errors={[errors.company]} />
220
311
  </Field>
221
- </div>
312
+ ) : (
313
+ <div className="grid grid-cols-2 gap-3">
314
+ <Field>
315
+ <FieldLabel htmlFor="addr-fn">{t('admin.fields.first_name.label')}</FieldLabel>
316
+ <Input
317
+ id="addr-fn"
318
+ aria-invalid={!!errors.first_name || undefined}
319
+ {...form.register('first_name')}
320
+ />
321
+ <FieldError errors={[errors.first_name]} />
322
+ </Field>
323
+ <Field>
324
+ <FieldLabel htmlFor="addr-ln">{t('admin.fields.last_name.label')}</FieldLabel>
325
+ <Input
326
+ id="addr-ln"
327
+ aria-invalid={!!errors.last_name || undefined}
328
+ {...form.register('last_name')}
329
+ />
330
+ <FieldError errors={[errors.last_name]} />
331
+ </Field>
332
+ </div>
333
+ )}
222
334
  <Field>
223
335
  <FieldLabel htmlFor="addr-a1">
224
336
  {t('admin.fields.address.address1.label')}
@@ -242,21 +354,26 @@ export function AddressFormDialog({
242
354
  <FieldError errors={[errors.address2]} />
243
355
  </Field>
244
356
  <Field>
245
- <FieldLabel>{t('admin.fields.country_iso.label')}</FieldLabel>
357
+ <FieldLabel htmlFor="addr-country">
358
+ {t('admin.fields.country_code.label')}
359
+ </FieldLabel>
246
360
  <Controller
247
- name="country_iso"
361
+ name="country_code"
248
362
  control={form.control}
249
363
  render={({ field }) => (
250
364
  <CountryCombobox
365
+ id="addr-country"
366
+ invalid={!!errors.country_code}
251
367
  value={field.value}
368
+ onBlur={field.onBlur}
252
369
  onValueChange={(iso) => {
253
370
  field.onChange(iso)
254
- form.setValue('state_abbr', '', { shouldDirty: true })
371
+ form.setValue('state_code', '', { shouldDirty: true })
255
372
  }}
256
373
  />
257
374
  )}
258
375
  />
259
- <FieldError errors={[errors.country_iso]} />
376
+ <FieldError errors={[errors.country_code]} />
260
377
  </Field>
261
378
  <div className="grid grid-cols-2 gap-3">
262
379
  <Field>
@@ -270,32 +387,37 @@ export function AddressFormDialog({
270
387
  </Field>
271
388
  {useStateCombobox ? (
272
389
  <Field>
273
- <FieldLabel>{t('admin.fields.state_abbr.label')}</FieldLabel>
390
+ <FieldLabel htmlFor="addr-state">
391
+ {t('admin.fields.state_code.label')}
392
+ </FieldLabel>
274
393
  <Controller
275
- name="state_abbr"
394
+ name="state_code"
276
395
  control={form.control}
277
396
  render={({ field }) => (
278
397
  <StateCombobox
279
- countryIso={countryIso}
398
+ id="addr-state"
399
+ invalid={!!errors.state_code}
400
+ countryCode={countryCode}
280
401
  states={states}
281
402
  value={field.value}
403
+ onBlur={field.onBlur}
282
404
  onValueChange={field.onChange}
283
405
  />
284
406
  )}
285
407
  />
286
- <FieldError errors={[errors.state_abbr]} />
408
+ <FieldError errors={[errors.state_code]} />
287
409
  </Field>
288
410
  ) : (
289
411
  <Field>
290
412
  <FieldLabel htmlFor="addr-state-abbr">
291
- {t('admin.fields.state_abbr.label')}
413
+ {t('admin.fields.state_code.label')}
292
414
  </FieldLabel>
293
415
  <Input
294
416
  id="addr-state-abbr"
295
- aria-invalid={!!errors.state_abbr || undefined}
296
- {...form.register('state_abbr')}
417
+ aria-invalid={!!errors.state_code || undefined}
418
+ {...form.register('state_code')}
297
419
  />
298
- <FieldError errors={[errors.state_abbr]} />
420
+ <FieldError errors={[errors.state_code]} />
299
421
  </Field>
300
422
  )}
301
423
  </div>
@@ -322,40 +444,42 @@ export function AddressFormDialog({
322
444
  {showDefaultFlags && (
323
445
  <>
324
446
  <Field>
325
- <div className="flex items-start justify-between gap-4">
326
- <FieldLabel htmlFor="addr-default-billing" className="cursor-pointer">
327
- {t('admin.fields.address.is_default_billing.label')}
328
- </FieldLabel>
329
- <Controller
330
- name="is_default_billing"
331
- control={form.control}
332
- render={({ field }) => (
447
+ <Controller
448
+ name="is_default_billing"
449
+ control={form.control}
450
+ render={({ field }) => (
451
+ <label
452
+ htmlFor="addr-default-billing"
453
+ className="flex cursor-pointer items-center gap-2 text-sm"
454
+ >
333
455
  <Checkbox
334
456
  id="addr-default-billing"
335
457
  checked={!!field.value}
336
458
  onCheckedChange={field.onChange}
337
459
  />
338
- )}
339
- />
340
- </div>
460
+ {t('admin.fields.address.is_default_billing.label')}
461
+ </label>
462
+ )}
463
+ />
341
464
  </Field>
342
465
  <Field>
343
- <div className="flex items-start justify-between gap-4">
344
- <FieldLabel htmlFor="addr-default-shipping" className="cursor-pointer">
345
- {t('admin.fields.address.is_default_shipping.label')}
346
- </FieldLabel>
347
- <Controller
348
- name="is_default_shipping"
349
- control={form.control}
350
- render={({ field }) => (
466
+ <Controller
467
+ name="is_default_shipping"
468
+ control={form.control}
469
+ render={({ field }) => (
470
+ <label
471
+ htmlFor="addr-default-shipping"
472
+ className="flex cursor-pointer items-center gap-2 text-sm"
473
+ >
351
474
  <Checkbox
352
475
  id="addr-default-shipping"
353
476
  checked={!!field.value}
354
477
  onCheckedChange={field.onChange}
355
478
  />
356
- )}
357
- />
358
- </div>
479
+ {t('admin.fields.address.is_default_shipping.label')}
480
+ </label>
481
+ )}
482
+ />
359
483
  </Field>
360
484
  </>
361
485
  )}
@@ -0,0 +1,171 @@
1
+ import { SidebarInset, SidebarProvider } from '@spree/dashboard-ui'
2
+ import { useRouterState } from '@tanstack/react-router'
3
+ import type { ReactNode } from 'react'
4
+ import { useEffect, useRef, useState } from 'react'
5
+ import { useAutoCollapseSidebar } from '../hooks/use-auto-collapse-sidebar'
6
+ import { StickyHeaderProvider } from '../providers/sticky-header-provider'
7
+ import { AppSidebar } from './app-sidebar'
8
+ import { MobileTopBar } from './mobile-top-bar'
9
+ import { SettingsNavSheet, SettingsSidebar } from './settings-sidebar'
10
+ import { SheetTopBar } from './sheet-top-bar'
11
+ import { SkipLink } from './skip-link'
12
+ import { MobileBreadcrumbBar } from './top-bar-breadcrumbs'
13
+
14
+ /**
15
+ * The frame every panel renders inside: the nav rail, and the page as a sheet
16
+ * beside it.
17
+ *
18
+ * There is no top bar. The rail carries what one used to — tenant switcher,
19
+ * search, account — so the only chrome above a page is the page's own header,
20
+ * and the content sits in an inset sheet rather than running to the window
21
+ * edge.
22
+ *
23
+ * Both panels mount this rather than composing the same pieces twice: the
24
+ * seller panel previously hand-rolled its own `<header>`, which had already
25
+ * drifted from the dashboard's (a different border, a translucent fill the
26
+ * dashboard had deliberately abandoned). One frame means one place to fix.
27
+ */
28
+ export function AppShell({
29
+ tenantId,
30
+ sidebarHeader,
31
+ inSettings,
32
+ uiLocales,
33
+ onEditProfile,
34
+ children,
35
+ }: {
36
+ /** Prefix for the nav links. Defaults to the route's `storeId`. */
37
+ tenantId?: string
38
+ /** Sidebar header — a store switcher by default, a seller switcher here. */
39
+ sidebarHeader?: ReactNode
40
+ /** True on a settings route, which brings its own secondary rail. */
41
+ inSettings: boolean
42
+ /** Admin UI languages for the account menu's language switcher. */
43
+ uiLocales?: ReadonlyArray<{ code: string; name: string }>
44
+ /** Opens the app's edit-profile dialog from the account menu. */
45
+ onEditProfile?: () => void
46
+ children: ReactNode
47
+ }) {
48
+ // The settings area brings a full-width nav of its own, and two stacked
49
+ // columns leave the content squeezed, so the primary rail folds to icons
50
+ // while the user is in there.
51
+ useAutoCollapseSidebar(inSettings)
52
+
53
+ // Below `lg` the settings rail is hidden, so its sheet is the only way
54
+ // between two settings pages. The trigger lives in the breadcrumb bar.
55
+ const [settingsNavOpen, setSettingsNavOpen] = useState(false)
56
+
57
+ // Put focus on the scroll container after each navigation, so Page Up/Down,
58
+ // Home and End work on a freshly opened page without clicking into it first.
59
+ // Those keys act on the focused element, and the shell's scroller is the only
60
+ // thing that scrolls — left on <body>, they would have nothing to move.
61
+ //
62
+ // Skipped when focus is already inside the sheet: a route change that came
63
+ // from typing in a filter must not pull the caret out of the field.
64
+ const scrollerRef = useRef<HTMLDivElement | null>(null)
65
+ const pathname = useRouterState({ select: (state) => state.location.pathname })
66
+ useEffect(() => {
67
+ // Read so the dependency is a real one: this effect exists to run on every
68
+ // navigation, and `pathname` is what changes.
69
+ if (!pathname) return
70
+ const scroller = scrollerRef.current
71
+ if (!scroller) return
72
+ if (scroller.contains(document.activeElement)) return
73
+ scroller.focus({ preventScroll: true })
74
+ }, [pathname])
75
+
76
+ return (
77
+ <>
78
+ {/* First in the tab order by construction — it has to precede the
79
+ sidebar's thirty-odd links to be able to skip them. */}
80
+ <SkipLink />
81
+ <AppSidebar
82
+ tenantId={tenantId}
83
+ header={sidebarHeader}
84
+ uiLocales={uiLocales}
85
+ onEditProfile={onEditProfile}
86
+ />
87
+ {/* The sheet is exactly as tall as the viewport and scrolls internally,
88
+ rather than growing with the page and letting the document scroll.
89
+ Both of its rounded ends then stay on screen — with a document-length
90
+ sheet the bottom two corners sit below the fold and the shape reads as
91
+ a panel that has come unstuck. It also gives the page's sticky header
92
+ a scroll container of its own to pin inside, so it can never ride up
93
+ over the rail's switcher.
94
+
95
+ `flex-row` so the secondary sidebar sits flush against the primary and
96
+ spans the sheet's full height. */}
97
+ <SidebarInset className="min-h-0 flex-row overflow-hidden">
98
+ <SettingsSidebar open={inSettings} tenantId={tenantId} />
99
+ {/* `tabIndex={-1}`: Page Up/Down, Home and End act on the focused
100
+ element, and this is the element that scrolls. Without it focus
101
+ stays on <body>, which has nothing to scroll now that the sheet
102
+ owns the overflow — so those keys did nothing at all. `-1` keeps it
103
+ out of the tab order while still letting a click or the skip link
104
+ put focus here. `outline-none` because it is a scroll container
105
+ rather than a control; the focus ring belongs to what is inside.
106
+
107
+ `themed-scrollbar` because this is the last scroll container still
108
+ on the browser default: the sheet owns the page's overflow, so its
109
+ bar is the one a merchant sees most, and a chrome-grey native bar
110
+ down the edge of the white sheet was the one piece of the frame
111
+ that did not belong to the design. Not the quiet variant the
112
+ settings nav uses — that one hides its thumb until hover, which
113
+ suits chrome but not the surface being read, where the thumb is
114
+ how you know where you are in a long list.
115
+
116
+ `me-[5px]` insets the bar from the sheet's right edge. An arbitrary
117
+ value rather than a scale step because the scale has no 5px rung —
118
+ `1.5` is 6px — and this is measured against the sheet's edge by eye,
119
+ so the exact figure is the point. A native
120
+ scrollbar is painted by the browser at the scroll container's own
121
+ border edge and no property can offset it, so the box itself has to
122
+ end short of the sheet — margin does that, where padding would not:
123
+ padding is inside the box, so the container would still end flush
124
+ with the sheet and only the content would move, leaving the bar
125
+ exactly where it was. Logical `me-` rather than `mr-` so the inset
126
+ follows the bar to the left edge under RTL. */}
127
+ <div
128
+ ref={scrollerRef}
129
+ tabIndex={-1}
130
+ className="themed-scrollbar flex min-w-0 flex-1 flex-col overflow-y-auto me-[5px] outline-none"
131
+ >
132
+ <MobileTopBar uiLocales={uiLocales} onEditProfile={onEditProfile} />
133
+ <SheetTopBar tenantId={tenantId} inSettings={inSettings} />
134
+ <MobileBreadcrumbBar
135
+ tenantId={tenantId}
136
+ onOpenSettingsNav={() => setSettingsNavOpen(true)}
137
+ />
138
+ <SettingsNavSheet
139
+ open={settingsNavOpen}
140
+ onOpenChange={setSettingsNavOpen}
141
+ tenantId={tenantId}
142
+ />
143
+ {/* Settings pages own their container so the secondary rail can sit
144
+ flush; everything else gets the shell's. */}
145
+ {inSettings ? (
146
+ children
147
+ ) : (
148
+ <div className="container mx-auto flex flex-1 flex-col gap-4 p-4 lg:p-6">
149
+ {children}
150
+ </div>
151
+ )}
152
+ </div>
153
+ </SidebarInset>
154
+ </>
155
+ )
156
+ }
157
+
158
+ /**
159
+ * `AppShell` under the sidebar provider it needs.
160
+ *
161
+ * Split so the shell itself can call `useAutoCollapseSidebar`, which only
162
+ * works inside the provider. `StickyHeaderProvider` rides along: it tracks
163
+ * whether a page has a `PageHeader`, which decides heading levels below it.
164
+ */
165
+ export function AppShellProvider({ children }: { children: ReactNode }) {
166
+ return (
167
+ <StickyHeaderProvider>
168
+ <SidebarProvider>{children}</SidebarProvider>
169
+ </StickyHeaderProvider>
170
+ )
171
+ }