@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.
- package/README.md +4 -0
- package/dist/vite/index.js +17 -3
- package/package.json +7 -7
- package/src/api-client.ts +643 -0
- package/src/components/address-form-dialog.test.ts +25 -0
- package/src/components/address-form-dialog.tsx +203 -79
- package/src/components/app-shell.tsx +171 -0
- package/src/components/app-sidebar.tsx +184 -27
- package/src/components/bulk-action-bar.tsx +89 -157
- package/src/components/country-combobox.tsx +69 -34
- package/src/components/country-state-fields.tsx +43 -11
- package/src/components/currency-select.tsx +81 -66
- package/src/components/export-button.tsx +62 -5
- package/src/components/file-upload-field.tsx +32 -10
- package/src/components/import-button.tsx +52 -27
- package/src/components/import-wizard-dialog.tsx +710 -0
- package/src/components/locale-select.tsx +70 -57
- package/src/components/media-picker-sheet.tsx +307 -0
- package/src/components/mobile-top-bar.tsx +75 -0
- package/src/components/nav-main.tsx +197 -27
- package/src/components/page-header.tsx +203 -61
- package/src/components/page-tabs.tsx +4 -1
- package/src/components/preferences-form.tsx +98 -4
- package/src/components/resource-combobox.tsx +52 -4
- package/src/components/resource-picker-sheet.tsx +209 -73
- package/src/components/resource-table.tsx +383 -127
- package/src/components/route-announcer.tsx +64 -0
- package/src/components/section-heading.tsx +95 -0
- package/src/components/settings-sidebar.tsx +290 -60
- package/src/components/sheet-top-bar.tsx +44 -0
- package/src/components/sidebar-search.tsx +83 -0
- package/src/components/sidebar-user.tsx +213 -0
- package/src/components/skip-link.tsx +36 -0
- package/src/components/store-date-picker.tsx +6 -2
- package/src/components/store-switcher.tsx +19 -14
- package/src/components/table-toolbar.tsx +1357 -440
- package/src/components/tag-combobox.tsx +12 -4
- package/src/components/top-bar-breadcrumbs.tsx +235 -0
- package/src/components/wizard-close-guard.test.ts +54 -0
- package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
- package/src/hooks/use-command-palette.tsx +13 -0
- package/src/hooks/use-countries.test.ts +56 -0
- package/src/hooks/use-countries.ts +51 -5
- package/src/hooks/use-direct-upload.ts +16 -3
- package/src/hooks/use-export.ts +51 -16
- package/src/hooks/use-import.ts +241 -16
- package/src/hooks/use-package-types.ts +132 -0
- package/src/hooks/use-resource-mutation.ts +74 -13
- package/src/hooks/use-stock-locations.ts +131 -0
- package/src/hooks/use-switch-admin-locale.ts +2 -2
- package/src/hooks/use-visual-viewport-offset.ts +61 -0
- package/src/index.ts +45 -1
- package/src/lib/cancel-order-form.ts +27 -0
- package/src/lib/create-action-registry.ts +278 -0
- package/src/lib/date-presets.test.ts +86 -0
- package/src/lib/date-presets.ts +98 -0
- package/src/lib/delivery-form.ts +15 -0
- package/src/lib/docs.ts +32 -0
- package/src/lib/download.ts +13 -1
- package/src/lib/filters-to-ransack.test.ts +113 -0
- package/src/lib/filters-to-ransack.ts +22 -1
- package/src/lib/form-errors.ts +122 -10
- package/src/lib/form-mappers.ts +76 -0
- package/src/lib/fulfillment-items.test.ts +213 -0
- package/src/lib/fulfillment-items.ts +171 -0
- package/src/lib/i18n.ts +19 -11
- package/src/lib/import-types.ts +25 -0
- package/src/lib/label-upload-form.ts +22 -0
- package/src/lib/nav-registry.ts +71 -3
- package/src/lib/permissions.ts +46 -1
- package/src/lib/progress.ts +18 -0
- package/src/lib/query-keys.ts +13 -13
- package/src/lib/sample-csv.ts +9 -6
- package/src/lib/search-registry.ts +6 -2
- package/src/lib/settings-nav-registry.ts +104 -11
- package/src/lib/table-registry.ts +50 -0
- package/src/lib/type-labels.test.ts +68 -0
- package/src/lib/type-labels.ts +78 -0
- package/src/locales/ar.json +1889 -517
- package/src/locales/de.json +1884 -517
- package/src/locales/en.json +1886 -519
- package/src/locales/fr.json +1884 -517
- package/src/locales/pl.json +1913 -517
- package/src/locales/zh-CN.json +1883 -517
- package/src/pages/package-types.tsx +484 -0
- package/src/pages/settings-index.tsx +112 -0
- package/src/pages/stock-locations.tsx +674 -0
- package/src/plugin.ts +29 -0
- package/src/products/add-video-dialog.tsx +100 -0
- package/src/products/bulk-variants-dialog.tsx +301 -0
- package/src/products/carton-size.test.ts +51 -0
- package/src/products/carton-size.ts +20 -0
- package/src/products/currency-parts.ts +21 -0
- package/src/products/inventory-section.tsx +259 -0
- package/src/products/media-edit-sheet.tsx +336 -0
- package/src/products/normalize-customs.test.ts +39 -0
- package/src/products/normalize-customs.ts +27 -0
- package/src/products/normalize-money.test.ts +58 -0
- package/src/products/normalize-money.ts +73 -0
- package/src/products/normalize-quantity.test.ts +32 -0
- package/src/products/normalize-quantity.ts +31 -0
- package/src/products/product-bulk-price-editor.tsx +165 -0
- package/src/products/product-form-cards.tsx +1167 -0
- package/src/products/product-form-mapping.ts +212 -0
- package/src/products/product-schema.ts +318 -0
- package/src/products/product-types.ts +130 -0
- package/src/products/use-product-form-data.ts +273 -0
- package/src/products/variant-edit-sheet.tsx +725 -0
- package/src/products/variants-matrix.test.ts +154 -0
- package/src/products/variants-matrix.ts +244 -0
- package/src/products/variants-options-builder.tsx +571 -0
- package/src/products/variants-section.tsx +535 -0
- package/src/products/video-url.test.ts +71 -0
- package/src/products/video-url.ts +83 -0
- package/src/providers/auth-provider.tsx +70 -12
- package/src/providers/permission-provider.tsx +68 -28
- package/src/providers/sticky-header-provider.tsx +134 -0
- package/src/providers/store-provider.tsx +36 -3
- package/src/providers/tenant-provider.tsx +33 -0
- package/src/schemas/package-type.ts +95 -0
- package/src/schemas/stock-location.ts +126 -0
- package/src/tables/package-types.tsx +74 -0
- package/src/tables/stock-locations.tsx +124 -0
- package/src/vite/index.ts +40 -7
- 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
|
-
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
58
|
+
country_code: z
|
|
65
59
|
.string()
|
|
66
60
|
.trim()
|
|
67
|
-
.min(1, { error: requiredMessage('
|
|
68
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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:
|
|
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(
|
|
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
|
|
135
|
-
const { states, statesRequired } = useCountryStates(
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
297
|
+
{business ? (
|
|
203
298
|
<Field>
|
|
204
|
-
<FieldLabel htmlFor="addr-
|
|
299
|
+
<FieldLabel htmlFor="addr-co">{t('admin.fields.company.label')}</FieldLabel>
|
|
205
300
|
<Input
|
|
206
|
-
id="addr-
|
|
207
|
-
aria-invalid={!!errors.
|
|
208
|
-
{
|
|
301
|
+
id="addr-co"
|
|
302
|
+
aria-invalid={!!errors.company || undefined}
|
|
303
|
+
disabled={companyReadOnly}
|
|
304
|
+
readOnly={companyReadOnly}
|
|
305
|
+
{...form.register('company')}
|
|
209
306
|
/>
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
<
|
|
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
|
-
|
|
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>
|
|
357
|
+
<FieldLabel htmlFor="addr-country">
|
|
358
|
+
{t('admin.fields.country_code.label')}
|
|
359
|
+
</FieldLabel>
|
|
246
360
|
<Controller
|
|
247
|
-
name="
|
|
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('
|
|
371
|
+
form.setValue('state_code', '', { shouldDirty: true })
|
|
255
372
|
}}
|
|
256
373
|
/>
|
|
257
374
|
)}
|
|
258
375
|
/>
|
|
259
|
-
<FieldError errors={[errors.
|
|
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>
|
|
390
|
+
<FieldLabel htmlFor="addr-state">
|
|
391
|
+
{t('admin.fields.state_code.label')}
|
|
392
|
+
</FieldLabel>
|
|
274
393
|
<Controller
|
|
275
|
-
name="
|
|
394
|
+
name="state_code"
|
|
276
395
|
control={form.control}
|
|
277
396
|
render={({ field }) => (
|
|
278
397
|
<StateCombobox
|
|
279
|
-
|
|
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.
|
|
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.
|
|
413
|
+
{t('admin.fields.state_code.label')}
|
|
292
414
|
</FieldLabel>
|
|
293
415
|
<Input
|
|
294
416
|
id="addr-state-abbr"
|
|
295
|
-
aria-invalid={!!errors.
|
|
296
|
-
{...form.register('
|
|
417
|
+
aria-invalid={!!errors.state_code || undefined}
|
|
418
|
+
{...form.register('state_code')}
|
|
297
419
|
/>
|
|
298
|
-
<FieldError errors={[errors.
|
|
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
|
-
<
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
460
|
+
{t('admin.fields.address.is_default_billing.label')}
|
|
461
|
+
</label>
|
|
462
|
+
)}
|
|
463
|
+
/>
|
|
341
464
|
</Field>
|
|
342
465
|
<Field>
|
|
343
|
-
<
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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
|
+
}
|