@spree/dashboard-core 1.0.0-rc.1 → 1.0.0-rc.3
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/dist/vite/discover.js +9 -14
- package/package.json +3 -5
- package/src/api-client.ts +22 -34
- package/src/components/address-form-dialog.test.ts +4 -4
- package/src/components/address-form-dialog.tsx +10 -10
- package/src/components/app-shell.tsx +12 -1
- package/src/components/app-sidebar.tsx +18 -2
- package/src/components/country-combobox.tsx +52 -29
- package/src/components/currency-select.tsx +16 -10
- package/src/components/file-upload-field.tsx +4 -4
- package/src/components/import-button.tsx +4 -1
- package/src/components/mobile-top-bar.tsx +6 -0
- package/src/components/money.tsx +23 -0
- package/src/components/preferences-form.tsx +247 -52
- package/src/components/resource-table.tsx +7 -3
- package/src/hooks/use-display-name.ts +11 -3
- package/src/hooks/use-file-upload.ts +51 -0
- package/src/hooks/use-import.ts +5 -5
- package/src/hooks/use-money-locale.ts +14 -0
- package/src/index.ts +8 -3
- package/src/lib/currencies.ts +5 -2
- package/src/lib/form-mappers.ts +25 -5
- package/src/lib/formatters.ts +34 -4
- package/src/lib/fulfillment-items.test.ts +2 -1
- package/src/lib/fulfillment-items.ts +9 -3
- package/src/lib/i18n.ts +4 -4
- package/src/lib/iso-codes.ts +45 -0
- package/src/lib/money.test.ts +63 -0
- package/src/lib/money.ts +99 -0
- package/src/{products → lib}/normalize-money.ts +2 -2
- package/src/lib/return-refunds.test.ts +62 -0
- package/src/lib/return-refunds.ts +70 -0
- package/src/lib/route-registry.ts +58 -15
- package/src/lib/slot-registry.ts +10 -0
- package/src/lib/storefront.ts +2 -2
- package/src/locales/ar.json +4 -1
- package/src/locales/de.json +4 -1
- package/src/locales/en.json +4 -1
- package/src/locales/es.json +4 -1
- package/src/locales/fr.json +4 -1
- package/src/locales/pl.json +4 -1
- package/src/locales/zh-CN.json +4 -1
- package/src/plugin.ts +5 -5
- package/src/products/bulk-variants-dialog.tsx +1 -1
- package/src/products/product-bulk-price-editor.tsx +18 -29
- package/src/products/product-form-cards.tsx +12 -8
- package/src/products/product-form-mapping.ts +15 -11
- package/src/products/product-schema.ts +6 -8
- package/src/products/product-types.ts +5 -3
- package/src/products/use-product-form-data.ts +0 -28
- package/src/providers/store-provider.tsx +9 -9
- package/src/schemas/stock-location.ts +3 -2
- package/src/tables/package-types.tsx +1 -1
- package/src/tables/stock-locations.tsx +1 -1
- package/src/vite/discover.ts +33 -28
- package/src/hooks/use-direct-upload.ts +0 -117
- /package/src/{products → lib}/currency-parts.ts +0 -0
- /package/src/{products → lib}/normalize-money.test.ts +0 -0
package/dist/vite/discover.js
CHANGED
|
@@ -20,10 +20,13 @@ function discoverDashboardPluginManifests({ root, onWarn }, names) {
|
|
|
20
20
|
manifests.push({ name });
|
|
21
21
|
continue;
|
|
22
22
|
}
|
|
23
|
-
const
|
|
23
|
+
const marker = pkg.spree?.dashboard;
|
|
24
|
+
const resolveDir = (dir) => dir ? path.resolve(path.dirname(manifestPath), dir) : void 0;
|
|
24
25
|
manifests.push({
|
|
25
26
|
name,
|
|
26
|
-
routesDir: routes
|
|
27
|
+
routesDir: resolveDir(marker?.routes),
|
|
28
|
+
authenticatedRoutesDir: resolveDir(marker?.authenticatedRoutes),
|
|
29
|
+
publicRoutesDir: resolveDir(marker?.publicRoutes)
|
|
27
30
|
});
|
|
28
31
|
}
|
|
29
32
|
return manifests;
|
|
@@ -63,17 +66,9 @@ function discoverDashboardPlugins({ root, onWarn }) {
|
|
|
63
66
|
}
|
|
64
67
|
return discovered;
|
|
65
68
|
}
|
|
66
|
-
function
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
const link = path.join(dir, "node_modules", packageName);
|
|
70
|
-
if (fs.existsSync(path.join(link, "package.json"))) {
|
|
71
|
-
const inside = path.relative(fs.realpathSync(link), realPath);
|
|
72
|
-
if (inside.startsWith("..") || path.isAbsolute(inside)) return resolvedPath;
|
|
73
|
-
return path.join(link, inside);
|
|
74
|
-
}
|
|
75
|
-
if (path.dirname(dir) === dir) return resolvedPath;
|
|
76
|
-
}
|
|
69
|
+
function resolveShellRoutesDir(root, packageName) {
|
|
70
|
+
const require2 = createRequire(path.join(root, "package.json"));
|
|
71
|
+
return path.join(path.dirname(fs.realpathSync(require2.resolve(packageName))), "routes");
|
|
77
72
|
}
|
|
78
73
|
function resolveManifest(pkg, require2) {
|
|
79
74
|
try {
|
|
@@ -98,5 +93,5 @@ function resolveManifest(pkg, require2) {
|
|
|
98
93
|
export {
|
|
99
94
|
discoverDashboardPluginManifests,
|
|
100
95
|
discoverDashboardPlugins,
|
|
101
|
-
|
|
96
|
+
resolveShellRoutesDir
|
|
102
97
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spree/dashboard-core",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "1.0.0-rc.
|
|
4
|
+
"version": "1.0.0-rc.3",
|
|
5
5
|
"description": "Spree Dashboard framework — registries (table, nav, slot, settings-nav), providers (auth, permission, store), admin SDK client, generic infra hooks, and the defineDashboardPlugin extension API. Developer Preview.",
|
|
6
6
|
"author": "Vendo Connect Inc.",
|
|
7
7
|
"homepage": "https://spreecommerce.org",
|
|
@@ -67,11 +67,9 @@
|
|
|
67
67
|
"@dnd-kit/sortable": "^10.0.0",
|
|
68
68
|
"@dnd-kit/utilities": "^3.2.2",
|
|
69
69
|
"@tailwindcss/vite": "^4.2.4",
|
|
70
|
-
"@types/spark-md5": "^3.0.5",
|
|
71
70
|
"date-fns": "^4.1.0",
|
|
72
71
|
"date-fns-tz": "^3.2.0",
|
|
73
72
|
"lucide-react": "^1.47.0",
|
|
74
|
-
"spark-md5": "^3.0.2",
|
|
75
73
|
"zod": "^4.4.3"
|
|
76
74
|
},
|
|
77
75
|
"devDependencies": {
|
|
@@ -81,8 +79,8 @@
|
|
|
81
79
|
"typescript": "~6.0.3",
|
|
82
80
|
"vite": "^8.0.16",
|
|
83
81
|
"vitest": "^4.1.5",
|
|
84
|
-
"@spree/
|
|
85
|
-
"@spree/
|
|
82
|
+
"@spree/dashboard-ui": "^1.0.0-rc.3",
|
|
83
|
+
"@spree/admin-sdk": "^1.0.0-rc.2"
|
|
86
84
|
},
|
|
87
85
|
"scripts": {
|
|
88
86
|
"lint": "biome check src",
|
package/src/api-client.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* promise the seller panel methods its API does not have.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import type { PermissionRule } from '@spree/admin-sdk'
|
|
17
|
+
import type { CountryCode, PermissionRule, UploadFileOptions } from '@spree/admin-sdk'
|
|
18
18
|
|
|
19
19
|
/** What a panel may change on the signed-in person's own account. */
|
|
20
20
|
export interface PanelAccountParams {
|
|
@@ -23,11 +23,11 @@ export interface PanelAccountParams {
|
|
|
23
23
|
/** The panel's UI language, as a bundle code the panel ships (e.g. `de`). */
|
|
24
24
|
selected_locale?: string
|
|
25
25
|
/**
|
|
26
|
-
*
|
|
26
|
+
* A `signed_id` from an upload to set the photo, or `null` to remove it. Omit to
|
|
27
27
|
* leave the current one alone — the only field here where `null` means
|
|
28
28
|
* something the API acts on.
|
|
29
29
|
*/
|
|
30
|
-
|
|
30
|
+
avatar_signed_id?: string | null
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/**
|
|
@@ -105,19 +105,15 @@ export interface PanelApiClient {
|
|
|
105
105
|
* fulfillments, so they retire one by deactivating it.
|
|
106
106
|
*/
|
|
107
107
|
/**
|
|
108
|
-
*
|
|
108
|
+
* Uploads a file through the panel's own `POST /files` and resolves to its
|
|
109
|
+
* `signed_id`.
|
|
109
110
|
*
|
|
110
111
|
* Registered rather than imported for the same reason as everything else
|
|
111
112
|
* here: the shared upload field would otherwise work only in the
|
|
112
113
|
* operator's panel, and a seller uploading an onboarding document would
|
|
113
114
|
* get a 401 from an API they hold no key for.
|
|
114
115
|
*/
|
|
115
|
-
|
|
116
|
-
blob: { filename: string; byte_size: number; checksum: string; content_type: string }
|
|
117
|
-
}): Promise<{
|
|
118
|
-
direct_upload: { url: string; headers: Record<string, string> }
|
|
119
|
-
signed_id: string
|
|
120
|
-
}>
|
|
116
|
+
uploadFile?(file: File, options: UploadFileOptions): Promise<{ signed_id: string }>
|
|
121
117
|
/**
|
|
122
118
|
* Writes the signed-in person's own account — their name, photo and the
|
|
123
119
|
* panel's language.
|
|
@@ -304,16 +300,6 @@ export interface PanelApiClient {
|
|
|
304
300
|
tags?: {
|
|
305
301
|
list(params?: Record<string, unknown>): Promise<{ data: Array<{ name: string }> }>
|
|
306
302
|
}
|
|
307
|
-
/**
|
|
308
|
-
* Currency-to-locale pairs, which is what tells a money field whether
|
|
309
|
-
* `34,56` is thirty-four and a half or three thousand. The operator's
|
|
310
|
-
* markets answer it; a panel that registers none falls back to the UI
|
|
311
|
-
* language, the same answer the operator's hook gives for an unmatched
|
|
312
|
-
* currency.
|
|
313
|
-
*/
|
|
314
|
-
markets?: {
|
|
315
|
-
list(params?: Record<string, unknown>): Promise<{ data: PanelMarket[] }>
|
|
316
|
-
}
|
|
317
303
|
}
|
|
318
304
|
|
|
319
305
|
export interface PanelDeliveryProfile extends PanelNamedRecord {
|
|
@@ -427,7 +413,7 @@ export interface PanelImport {
|
|
|
427
413
|
completed_rows_count: number
|
|
428
414
|
failed_rows_count: number
|
|
429
415
|
processing_errors?: string | null
|
|
430
|
-
|
|
416
|
+
delimiter?: string
|
|
431
417
|
schema_fields: Array<{ name: string; label: string; required: boolean }>
|
|
432
418
|
csv_headers: string[]
|
|
433
419
|
sample_row: Record<string, string | null>
|
|
@@ -465,10 +451,10 @@ export interface PanelImportRow {
|
|
|
465
451
|
|
|
466
452
|
export interface PanelImportCreateParams {
|
|
467
453
|
type: string
|
|
468
|
-
/**
|
|
469
|
-
|
|
454
|
+
/** The `signed_id` of the already uploaded CSV. */
|
|
455
|
+
attachment_signed_id: string
|
|
470
456
|
/** CSV column separator — the four both APIs accept. */
|
|
471
|
-
|
|
457
|
+
delimiter?: PanelImportDelimiter
|
|
472
458
|
/** The price list a `price_list_prices` import merges into. */
|
|
473
459
|
price_list_id?: string
|
|
474
460
|
results_url?: string
|
|
@@ -481,13 +467,6 @@ export interface PanelImportCompleteMappingParams {
|
|
|
481
467
|
mappings?: Array<{ schema_field: string; file_column: string | null }>
|
|
482
468
|
}
|
|
483
469
|
|
|
484
|
-
/** A market, as the money fields read it. */
|
|
485
|
-
export interface PanelMarket {
|
|
486
|
-
id: string
|
|
487
|
-
currency?: string | null
|
|
488
|
-
default_locale?: string | null
|
|
489
|
-
}
|
|
490
|
-
|
|
491
470
|
/**
|
|
492
471
|
* What either panel sends to queue an export.
|
|
493
472
|
*
|
|
@@ -593,7 +572,7 @@ export interface PanelStockLocation {
|
|
|
593
572
|
zipcode?: string | null
|
|
594
573
|
phone?: string | null
|
|
595
574
|
company?: string | null
|
|
596
|
-
country_code?:
|
|
575
|
+
country_code?: CountryCode | null
|
|
597
576
|
state_code?: string | null
|
|
598
577
|
state_name?: string | null
|
|
599
578
|
/** Rendered read-only in the table; either panel's serializer derives it. */
|
|
@@ -635,9 +614,8 @@ export type PanelStockLocationCreateParams = PanelStockLocationParams & { name:
|
|
|
635
614
|
* install.
|
|
636
615
|
*/
|
|
637
616
|
export interface PanelCountry {
|
|
638
|
-
iso:
|
|
617
|
+
iso: CountryCode
|
|
639
618
|
iso3: string
|
|
640
|
-
name: string
|
|
641
619
|
states_required?: boolean
|
|
642
620
|
zipcode_required?: boolean
|
|
643
621
|
states?: Array<{ abbr: string; name: string }>
|
|
@@ -684,3 +662,13 @@ export function getApiClient(): PanelApiClient {
|
|
|
684
662
|
export function hasApiClient(): boolean {
|
|
685
663
|
return registered !== null
|
|
686
664
|
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Hands filters built at runtime to a typed SDK list method. A table builds
|
|
668
|
+
* its filters from its column definitions, and a page shared by both panels
|
|
669
|
+
* sends the same keys to two APIs with different filter types, so neither can
|
|
670
|
+
* be checked by the compiler; the API validates them instead.
|
|
671
|
+
*/
|
|
672
|
+
export function runtimeListParams<P>(params?: Record<string, unknown>): P {
|
|
673
|
+
return params as P
|
|
674
|
+
}
|
|
@@ -17,9 +17,9 @@ describe('resolveAddressCountryCode', () => {
|
|
|
17
17
|
expect(resolveAddressCountryCode({ country_code: null }, 'FR')).toBe('FR')
|
|
18
18
|
})
|
|
19
19
|
|
|
20
|
-
it('stays
|
|
21
|
-
expect(resolveAddressCountryCode(null, null)).
|
|
22
|
-
expect(resolveAddressCountryCode(undefined, undefined)).
|
|
23
|
-
expect(resolveAddressCountryCode({ country_code: '' }, '')).
|
|
20
|
+
it('stays unset when neither the address nor the store has a country', () => {
|
|
21
|
+
expect(resolveAddressCountryCode(null, null)).toBeUndefined()
|
|
22
|
+
expect(resolveAddressCountryCode(undefined, undefined)).toBeUndefined()
|
|
23
|
+
expect(resolveAddressCountryCode({ country_code: '' }, '')).toBeUndefined()
|
|
24
24
|
})
|
|
25
25
|
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { zodResolver } from '@hookform/resolvers/zod'
|
|
2
|
+
import type { CountryCode } from '@spree/admin-sdk'
|
|
2
3
|
import {
|
|
3
4
|
Button,
|
|
4
5
|
Checkbox,
|
|
@@ -17,10 +18,11 @@ import {
|
|
|
17
18
|
SheetTitle,
|
|
18
19
|
} from '@spree/dashboard-ui'
|
|
19
20
|
import { useEffect, useRef } from 'react'
|
|
20
|
-
import { Controller, useForm } from 'react-hook-form'
|
|
21
|
+
import { Controller, type DefaultValues, useForm } from 'react-hook-form'
|
|
21
22
|
import { useTranslation } from 'react-i18next'
|
|
22
23
|
import { z } from 'zod/v4'
|
|
23
24
|
import { mapSpreeErrorsToForm } from '../lib/form-errors'
|
|
25
|
+
import { countryCodeSchema, isCountryCode } from '../lib/iso-codes'
|
|
24
26
|
import { useOptionalStore } from '../providers/store-provider'
|
|
25
27
|
import { CountryCombobox } from './country-combobox'
|
|
26
28
|
import { StateCombobox, useCountryStates } from './country-state-fields'
|
|
@@ -33,7 +35,7 @@ export interface AddressParams {
|
|
|
33
35
|
address2: string
|
|
34
36
|
city: string
|
|
35
37
|
postal_code: string
|
|
36
|
-
country_code:
|
|
38
|
+
country_code: CountryCode
|
|
37
39
|
state_code: string
|
|
38
40
|
phone: string
|
|
39
41
|
label?: string
|
|
@@ -55,10 +57,7 @@ const addressFormFields = {
|
|
|
55
57
|
.string()
|
|
56
58
|
.trim()
|
|
57
59
|
.min(1, { error: requiredMessage('address.postal_code') }),
|
|
58
|
-
country_code:
|
|
59
|
-
.string()
|
|
60
|
-
.trim()
|
|
61
|
-
.min(1, { error: requiredMessage('country_code') }),
|
|
60
|
+
country_code: countryCodeSchema,
|
|
62
61
|
state_code: z.string(),
|
|
63
62
|
phone: z.string(),
|
|
64
63
|
label: z.string(),
|
|
@@ -139,14 +138,15 @@ export interface EditableAddress {
|
|
|
139
138
|
export function resolveAddressCountryCode(
|
|
140
139
|
address: EditableAddress | null | undefined,
|
|
141
140
|
storeDefaultCountryCode?: string | null,
|
|
142
|
-
):
|
|
143
|
-
|
|
141
|
+
): CountryCode | undefined {
|
|
142
|
+
const countryCode = address?.country_code || storeDefaultCountryCode
|
|
143
|
+
return isCountryCode(countryCode) ? countryCode : undefined
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
function buildDefaults(
|
|
147
147
|
address: EditableAddress | null | undefined,
|
|
148
148
|
storeDefaultCountryCode?: string | null,
|
|
149
|
-
): AddressFormValues {
|
|
149
|
+
): DefaultValues<AddressFormValues> {
|
|
150
150
|
return {
|
|
151
151
|
first_name: address?.first_name ?? '',
|
|
152
152
|
last_name: address?.last_name ?? '',
|
|
@@ -228,7 +228,7 @@ export function AddressFormDialog({
|
|
|
228
228
|
// picked a different country is not overwritten.
|
|
229
229
|
useEffect(() => {
|
|
230
230
|
if (address?.country_code) return
|
|
231
|
-
if (!storeDefaultCountryCode) return
|
|
231
|
+
if (!isCountryCode(storeDefaultCountryCode)) return
|
|
232
232
|
if (form.getValues('country_code')) return
|
|
233
233
|
form.setValue('country_code', storeDefaultCountryCode)
|
|
234
234
|
}, [address?.country_code, storeDefaultCountryCode, form])
|
|
@@ -31,6 +31,7 @@ export function AppShell({
|
|
|
31
31
|
inSettings,
|
|
32
32
|
uiLocales,
|
|
33
33
|
onEditProfile,
|
|
34
|
+
notifications,
|
|
34
35
|
children,
|
|
35
36
|
}: {
|
|
36
37
|
/** Prefix for the nav links. Defaults to the route's `storeId`. */
|
|
@@ -43,6 +44,11 @@ export function AppShell({
|
|
|
43
44
|
uiLocales?: ReadonlyArray<{ code: string; name: string }>
|
|
44
45
|
/** Opens the app's edit-profile dialog from the account menu. */
|
|
45
46
|
onEditProfile?: () => void
|
|
47
|
+
/**
|
|
48
|
+
* A control shown beside the account menu, such as the dashboard's
|
|
49
|
+
* notifications bell. The seller panel passes none.
|
|
50
|
+
*/
|
|
51
|
+
notifications?: ReactNode
|
|
46
52
|
children: ReactNode
|
|
47
53
|
}) {
|
|
48
54
|
// The settings area brings a full-width nav of its own, and two stacked
|
|
@@ -83,6 +89,7 @@ export function AppShell({
|
|
|
83
89
|
header={sidebarHeader}
|
|
84
90
|
uiLocales={uiLocales}
|
|
85
91
|
onEditProfile={onEditProfile}
|
|
92
|
+
notifications={notifications}
|
|
86
93
|
/>
|
|
87
94
|
{/* The sheet is exactly as tall as the viewport and scrolls internally,
|
|
88
95
|
rather than growing with the page and letting the document scroll.
|
|
@@ -129,7 +136,11 @@ export function AppShell({
|
|
|
129
136
|
tabIndex={-1}
|
|
130
137
|
className="themed-scrollbar flex min-w-0 flex-1 flex-col overflow-y-auto me-[5px] outline-none"
|
|
131
138
|
>
|
|
132
|
-
<MobileTopBar
|
|
139
|
+
<MobileTopBar
|
|
140
|
+
uiLocales={uiLocales}
|
|
141
|
+
onEditProfile={onEditProfile}
|
|
142
|
+
notifications={notifications}
|
|
143
|
+
/>
|
|
133
144
|
<SheetTopBar tenantId={tenantId} inSettings={inSettings} />
|
|
134
145
|
<MobileBreadcrumbBar
|
|
135
146
|
tenantId={tenantId}
|
|
@@ -129,6 +129,7 @@ export function AppSidebar({
|
|
|
129
129
|
header,
|
|
130
130
|
uiLocales,
|
|
131
131
|
onEditProfile,
|
|
132
|
+
notifications,
|
|
132
133
|
...props
|
|
133
134
|
}: ComponentProps<typeof Sidebar> & {
|
|
134
135
|
/**
|
|
@@ -142,11 +143,13 @@ export function AppSidebar({
|
|
|
142
143
|
uiLocales?: ReadonlyArray<{ code: string; name: string }>
|
|
143
144
|
/** Opens the app's edit-profile dialog from the account menu. */
|
|
144
145
|
onEditProfile?: () => void
|
|
146
|
+
/** Shown beside the account menu, such as a notifications bell. */
|
|
147
|
+
notifications?: ReactNode
|
|
145
148
|
}) {
|
|
146
149
|
const { i18n } = useTranslation()
|
|
147
150
|
const { storeId } = useParams({ strict: false }) as { storeId?: string }
|
|
148
151
|
const { navItems, bottomItems, isLoading } = useNavItems(tenantId ?? storeId ?? 'default')
|
|
149
|
-
const { isMobile } = useSidebar()
|
|
152
|
+
const { isMobile, state } = useSidebar()
|
|
150
153
|
|
|
151
154
|
return (
|
|
152
155
|
<Sidebar collapsible="icon" variant="inset" side={primarySidebarSide(i18n.language)} {...props}>
|
|
@@ -190,7 +193,20 @@ export function AppSidebar({
|
|
|
190
193
|
list fades out above it rather than scrolling it away. */}
|
|
191
194
|
{!isLoading && !isMobile && <NavBottom items={bottomItems} className="py-2" />}
|
|
192
195
|
<SidebarFooter className="hidden md:flex">
|
|
193
|
-
|
|
196
|
+
{/* In icon mode the rail is too narrow for a row, so the control
|
|
197
|
+
stacks above the avatar. */}
|
|
198
|
+
<div
|
|
199
|
+
className={
|
|
200
|
+
state === 'collapsed'
|
|
201
|
+
? 'flex flex-col-reverse items-center gap-1'
|
|
202
|
+
: 'flex items-center gap-1'
|
|
203
|
+
}
|
|
204
|
+
>
|
|
205
|
+
<div className="min-w-0 flex-1">
|
|
206
|
+
<SidebarUser uiLocales={uiLocales} onEditProfile={onEditProfile} />
|
|
207
|
+
</div>
|
|
208
|
+
{notifications}
|
|
209
|
+
</div>
|
|
194
210
|
</SidebarFooter>
|
|
195
211
|
</Sidebar>
|
|
196
212
|
)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CountryCode } from '@spree/admin-sdk'
|
|
1
2
|
import {
|
|
2
3
|
Combobox,
|
|
3
4
|
ComboboxButtonTrigger,
|
|
@@ -18,14 +19,15 @@ import { useCallback, useMemo, useState } from 'react'
|
|
|
18
19
|
import { useTranslation } from 'react-i18next'
|
|
19
20
|
import { useCountries } from '../hooks/use-countries'
|
|
20
21
|
import { useDisplayName } from '../hooks/use-display-name'
|
|
22
|
+
import { isCountryCode } from '../lib/iso-codes'
|
|
21
23
|
|
|
22
|
-
export type CountryOption = { iso:
|
|
24
|
+
export type CountryOption = { iso: CountryCode; iso3: string }
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
|
-
* Resolves a country's display name in the admin UI language
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
27
|
+
* Resolves a country's display name in the admin UI language from its ISO
|
|
28
|
+
* code, falling back to the code when the runtime has no name for it.
|
|
29
|
+
* Exported so every country-labelled surface (pickers, grid cells) reads the
|
|
30
|
+
* same.
|
|
29
31
|
*
|
|
30
32
|
* The resolver is referentially stable across renders — callers feed it into
|
|
31
33
|
* `useMemo` deps, and a fresh closure each render would rebuild every derived
|
|
@@ -34,32 +36,37 @@ export type CountryOption = { iso: string; iso3: string; name: string }
|
|
|
34
36
|
export function useCountryDisplayName() {
|
|
35
37
|
const displayName = useDisplayName('region')
|
|
36
38
|
return useCallback(
|
|
37
|
-
(country:
|
|
39
|
+
(country: { iso: string }) => displayName(country.iso) ?? country.iso,
|
|
38
40
|
[displayName],
|
|
39
41
|
)
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* `search` Ransack scope (`Spree::Country.search`).
|
|
45
|
+
* Sorts any list of countries by the name the admin reads, collated in the
|
|
46
|
+
* admin's language (a Swedish admin sees Österrike after Zimbabwe). Each name
|
|
47
|
+
* is resolved once per sort. The API cannot do this: it answers in ISO order
|
|
48
|
+
* and does not know the reader's language.
|
|
48
49
|
*/
|
|
49
|
-
export function
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
50
|
+
export function useSortByCountryName() {
|
|
51
|
+
const countryName = useCountryDisplayName()
|
|
52
|
+
const { i18n } = useTranslation()
|
|
53
|
+
const collator = useMemo(() => new Intl.Collator(i18n.language), [i18n.language])
|
|
54
|
+
return useCallback(
|
|
55
|
+
<T,>(items: readonly T[], isoOf: (item: T) => string): T[] =>
|
|
56
|
+
items
|
|
57
|
+
.map((item) => ({ item, name: countryName({ iso: isoOf(item) }) }))
|
|
58
|
+
.sort((left, right) => collator.compare(left.name, right.name))
|
|
59
|
+
.map(({ item }) => item),
|
|
60
|
+
[countryName, collator],
|
|
56
61
|
)
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
65
|
+
* Case-insensitive substring match against the country's name in the admin's
|
|
66
|
+
* language, its ISO code and its ISO3 code, so typing "Deutschland", "DE" or
|
|
67
|
+
* "DEU" finds it. Both pickers filter the cached `useCountries()` list
|
|
68
|
+
* client-side — countries are a ~250-row static set, so there's no remote
|
|
69
|
+
* search. Memoized on the display-name resolver.
|
|
63
70
|
*/
|
|
64
71
|
function useCountryFilter() {
|
|
65
72
|
const countryName = useCountryDisplayName()
|
|
@@ -67,7 +74,11 @@ function useCountryFilter() {
|
|
|
67
74
|
() => (item: CountryOption, query: string) => {
|
|
68
75
|
const q = query.trim().toLowerCase()
|
|
69
76
|
if (!q) return true
|
|
70
|
-
return
|
|
77
|
+
return (
|
|
78
|
+
countryName(item).toLowerCase().includes(q) ||
|
|
79
|
+
item.iso.toLowerCase().includes(q) ||
|
|
80
|
+
item.iso3.toLowerCase().includes(q)
|
|
81
|
+
)
|
|
71
82
|
},
|
|
72
83
|
[countryName],
|
|
73
84
|
)
|
|
@@ -88,12 +99,20 @@ function CountryRow({ country, label }: { country: CountryOption; label: string
|
|
|
88
99
|
)
|
|
89
100
|
}
|
|
90
101
|
|
|
91
|
-
/**
|
|
102
|
+
/**
|
|
103
|
+
* Maps the cached country list onto the flat option shape both pickers use,
|
|
104
|
+
* sorted by the name the admin reads (the API answers in ISO order).
|
|
105
|
+
*/
|
|
92
106
|
function useCountryOptions(): CountryOption[] {
|
|
93
107
|
const { countries } = useCountries()
|
|
108
|
+
const sortByName = useSortByCountryName()
|
|
94
109
|
return useMemo(
|
|
95
|
-
() =>
|
|
96
|
-
|
|
110
|
+
() =>
|
|
111
|
+
sortByName(
|
|
112
|
+
countries.map((c) => ({ iso: c.iso, iso3: c.iso3 })),
|
|
113
|
+
(c) => c.iso,
|
|
114
|
+
),
|
|
115
|
+
[countries, sortByName],
|
|
97
116
|
)
|
|
98
117
|
}
|
|
99
118
|
|
|
@@ -118,7 +137,8 @@ export function CountryCombobox({
|
|
|
118
137
|
/** Forwarded to the trigger so a `<FieldLabel htmlFor>` can target it. */
|
|
119
138
|
id?: string
|
|
120
139
|
value: string | null | undefined
|
|
121
|
-
|
|
140
|
+
/** Fires with the picked code, or `''` when the selection is cleared. */
|
|
141
|
+
onValueChange: (iso: CountryCode | '') => void
|
|
122
142
|
/** Forwarded to the trigger so RHF's `<Controller>` can track touched state. */
|
|
123
143
|
onBlur?: () => void
|
|
124
144
|
/** Trigger text while nothing is selected. */
|
|
@@ -204,9 +224,9 @@ export function CountryMultiCombobox({
|
|
|
204
224
|
disabled = false,
|
|
205
225
|
}: {
|
|
206
226
|
/** Selected 2-letter ISO codes. */
|
|
207
|
-
value: string[]
|
|
227
|
+
value: readonly string[]
|
|
208
228
|
/** Fires with the next array of ISO codes. */
|
|
209
|
-
onValueChange: (isos:
|
|
229
|
+
onValueChange: (isos: CountryCode[]) => void
|
|
210
230
|
placeholder?: string
|
|
211
231
|
emptyText?: string
|
|
212
232
|
disabled?: boolean
|
|
@@ -222,7 +242,10 @@ export function CountryMultiCombobox({
|
|
|
222
242
|
// strings. Selected ISOs not yet present in `items` (list still loading)
|
|
223
243
|
// fall back to a stub so chips don't vanish on first render.
|
|
224
244
|
const selected = useMemo<CountryOption[]>(
|
|
225
|
-
() =>
|
|
245
|
+
() =>
|
|
246
|
+
value
|
|
247
|
+
.filter(isCountryCode)
|
|
248
|
+
.map((iso) => items.find((c) => c.iso === iso) ?? { iso, iso3: '' }),
|
|
226
249
|
[value, items],
|
|
227
250
|
)
|
|
228
251
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CurrencyCode } from '@spree/admin-sdk'
|
|
1
2
|
import {
|
|
2
3
|
Combobox,
|
|
3
4
|
ComboboxButtonTrigger,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
import { useMemo, useState } from 'react'
|
|
12
13
|
import { useTranslation } from 'react-i18next'
|
|
13
14
|
import { useDisplayName } from '../hooks/use-display-name'
|
|
15
|
+
import { isCurrencyCode } from '../lib/iso-codes'
|
|
14
16
|
import { useOptionalStore } from '../providers/store-provider'
|
|
15
17
|
|
|
16
18
|
export { ALL_CURRENCY_CODES } from '../lib/currencies'
|
|
@@ -29,14 +31,14 @@ interface CurrencySelectProps {
|
|
|
29
31
|
/** Controlled value. Pair with `onChange` to lift state out. */
|
|
30
32
|
value?: string
|
|
31
33
|
/** Fires on every selection change. */
|
|
32
|
-
onChange?: (currency:
|
|
34
|
+
onChange?: (currency: CurrencyCode) => void
|
|
33
35
|
/**
|
|
34
36
|
* Currency codes to pick from. Defaults to the current store's
|
|
35
37
|
* `supported_currencies`. Pass `ALL_CURRENCY_CODES` (or any custom list) for
|
|
36
38
|
* contexts where the merchant chooses a currency the store doesn't yet
|
|
37
39
|
* support — e.g. a market's currency.
|
|
38
40
|
*/
|
|
39
|
-
options?:
|
|
41
|
+
options?: readonly CurrencyCode[]
|
|
40
42
|
/**
|
|
41
43
|
* Announces the field as required to assistive technology (`aria-required`).
|
|
42
44
|
* It does not enforce anything: the trigger is a button, so there is no
|
|
@@ -106,10 +108,10 @@ export function CurrencySelect({
|
|
|
106
108
|
// falls back to the store's currencies rather than an empty picker.
|
|
107
109
|
const items = useMemo(() => {
|
|
108
110
|
const base = options?.length ? options : currencies
|
|
109
|
-
return value && !base.includes(value) ? [value, ...base] : base
|
|
111
|
+
return isCurrencyCode(value) && !base.includes(value) ? [value, ...base] : base
|
|
110
112
|
}, [options, currencies, value])
|
|
111
113
|
|
|
112
|
-
const handleChange = (next:
|
|
114
|
+
const handleChange = (next: CurrencyCode) => {
|
|
113
115
|
if (!isControlled) setInternalValue(next)
|
|
114
116
|
onChange?.(next)
|
|
115
117
|
}
|
|
@@ -122,7 +124,7 @@ export function CurrencySelect({
|
|
|
122
124
|
|
|
123
125
|
// Case-insensitive match against the code and its localized name so typing
|
|
124
126
|
// "EUR" or "Euro" both find it.
|
|
125
|
-
const filter = (code:
|
|
127
|
+
const filter = (code: CurrencyCode, query: string) => {
|
|
126
128
|
const q = query.trim().toLowerCase()
|
|
127
129
|
if (!q) return true
|
|
128
130
|
return code.toLowerCase().includes(q) || renderOption(code).toLowerCase().includes(q)
|
|
@@ -137,10 +139,14 @@ export function CurrencySelect({
|
|
|
137
139
|
{hiddenInput}
|
|
138
140
|
<Combobox
|
|
139
141
|
items={items}
|
|
140
|
-
value={value}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
value={isCurrencyCode(value) ? value : null}
|
|
143
|
+
// Nothing on the picker clears it, so a null selection is not a choice
|
|
144
|
+
// to report.
|
|
145
|
+
onValueChange={(next: CurrencyCode | null) => {
|
|
146
|
+
if (next) handleChange(next)
|
|
147
|
+
}}
|
|
148
|
+
itemToStringLabel={(code: CurrencyCode | null) => (code ? renderOption(code) : '')}
|
|
149
|
+
itemToStringValue={(code: CurrencyCode | null) => code ?? ''}
|
|
144
150
|
filter={filter}
|
|
145
151
|
disabled={disabled}
|
|
146
152
|
>
|
|
@@ -169,7 +175,7 @@ export function CurrencySelect({
|
|
|
169
175
|
/>
|
|
170
176
|
<ComboboxEmpty>{t('admin.components.currency_select.empty')}</ComboboxEmpty>
|
|
171
177
|
<ComboboxList>
|
|
172
|
-
{(code:
|
|
178
|
+
{(code: CurrencyCode) => (
|
|
173
179
|
<ComboboxItem key={code} value={code}>
|
|
174
180
|
{renderOption(code)}
|
|
175
181
|
</ComboboxItem>
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
import { FileIcon, UploadCloudIcon, XIcon } from '@spree/dashboard-ui/icons'
|
|
16
16
|
import { type ReactNode, useState } from 'react'
|
|
17
17
|
import { useTranslation } from 'react-i18next'
|
|
18
|
-
import {
|
|
18
|
+
import { useFileUpload } from '../hooks/use-file-upload'
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Controlled upload state. A field is in exactly one of three states:
|
|
@@ -89,7 +89,7 @@ export interface FileUploadFieldProps {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
|
-
* Universal single-file upload field backed by
|
|
92
|
+
* Universal single-file upload field backed by `POST /files`.
|
|
93
93
|
* Picking or dropping a file uploads it immediately; the attached file (or
|
|
94
94
|
* the persisted server-side one) renders as an `Attachment` with a remove
|
|
95
95
|
* action. Fully controlled — the consumer maps {@link FileUploadValue} onto
|
|
@@ -115,7 +115,7 @@ export function FileUploadField({
|
|
|
115
115
|
onUploadingChange,
|
|
116
116
|
}: FileUploadFieldProps) {
|
|
117
117
|
const { t } = useTranslation()
|
|
118
|
-
const
|
|
118
|
+
const fileUpload = useFileUpload({ visibility: privateStorage ? 'private' : 'public' })
|
|
119
119
|
const [pending, setPending] = useState<File | null>(null)
|
|
120
120
|
|
|
121
121
|
const isImage = variant === 'image'
|
|
@@ -153,7 +153,7 @@ export function FileUploadField({
|
|
|
153
153
|
setPending(file)
|
|
154
154
|
onUploadingChange?.(true)
|
|
155
155
|
try {
|
|
156
|
-
const result = await
|
|
156
|
+
const result = await fileUpload.mutateAsync(file)
|
|
157
157
|
// Revoke a preview we're replacing in-place; the object URL otherwise
|
|
158
158
|
// lives in caller-owned state, so its lifetime is their responsibility
|
|
159
159
|
// (a hidden-then-shown card must not remount with a dead URL).
|
|
@@ -75,7 +75,7 @@ interface ImportButtonProps {
|
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
77
|
* Toolbar entry point for CSV imports: opens a Sheet with the upload form
|
|
78
|
-
* (file, delimiter, template download). The CSV
|
|
78
|
+
* (file, delimiter, template download). The CSV uploads on pick via
|
|
79
79
|
* `FileUploadField`; submitting creates the import from the signed blob id
|
|
80
80
|
* and hands it to `onCreated`.
|
|
81
81
|
*/
|
|
@@ -190,6 +190,9 @@ export function ImportButton({
|
|
|
190
190
|
// Windows browsers report `.csv` as `application/vnd.ms-excel`,
|
|
191
191
|
// which the server's content-type validation rejects.
|
|
192
192
|
transformFile={(picked) => new File([picked], picked.name, { type: 'text/csv' })}
|
|
193
|
+
// Imports are kept on private storage: a CSV can carry
|
|
194
|
+
// customer data.
|
|
195
|
+
private
|
|
193
196
|
/>
|
|
194
197
|
|
|
195
198
|
<Field>
|