@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.
Files changed (58) hide show
  1. package/dist/vite/discover.js +9 -14
  2. package/package.json +3 -5
  3. package/src/api-client.ts +22 -34
  4. package/src/components/address-form-dialog.test.ts +4 -4
  5. package/src/components/address-form-dialog.tsx +10 -10
  6. package/src/components/app-shell.tsx +12 -1
  7. package/src/components/app-sidebar.tsx +18 -2
  8. package/src/components/country-combobox.tsx +52 -29
  9. package/src/components/currency-select.tsx +16 -10
  10. package/src/components/file-upload-field.tsx +4 -4
  11. package/src/components/import-button.tsx +4 -1
  12. package/src/components/mobile-top-bar.tsx +6 -0
  13. package/src/components/money.tsx +23 -0
  14. package/src/components/preferences-form.tsx +247 -52
  15. package/src/components/resource-table.tsx +7 -3
  16. package/src/hooks/use-display-name.ts +11 -3
  17. package/src/hooks/use-file-upload.ts +51 -0
  18. package/src/hooks/use-import.ts +5 -5
  19. package/src/hooks/use-money-locale.ts +14 -0
  20. package/src/index.ts +8 -3
  21. package/src/lib/currencies.ts +5 -2
  22. package/src/lib/form-mappers.ts +25 -5
  23. package/src/lib/formatters.ts +34 -4
  24. package/src/lib/fulfillment-items.test.ts +2 -1
  25. package/src/lib/fulfillment-items.ts +9 -3
  26. package/src/lib/i18n.ts +4 -4
  27. package/src/lib/iso-codes.ts +45 -0
  28. package/src/lib/money.test.ts +63 -0
  29. package/src/lib/money.ts +99 -0
  30. package/src/{products → lib}/normalize-money.ts +2 -2
  31. package/src/lib/return-refunds.test.ts +62 -0
  32. package/src/lib/return-refunds.ts +70 -0
  33. package/src/lib/route-registry.ts +58 -15
  34. package/src/lib/slot-registry.ts +10 -0
  35. package/src/lib/storefront.ts +2 -2
  36. package/src/locales/ar.json +4 -1
  37. package/src/locales/de.json +4 -1
  38. package/src/locales/en.json +4 -1
  39. package/src/locales/es.json +4 -1
  40. package/src/locales/fr.json +4 -1
  41. package/src/locales/pl.json +4 -1
  42. package/src/locales/zh-CN.json +4 -1
  43. package/src/plugin.ts +5 -5
  44. package/src/products/bulk-variants-dialog.tsx +1 -1
  45. package/src/products/product-bulk-price-editor.tsx +18 -29
  46. package/src/products/product-form-cards.tsx +12 -8
  47. package/src/products/product-form-mapping.ts +15 -11
  48. package/src/products/product-schema.ts +6 -8
  49. package/src/products/product-types.ts +5 -3
  50. package/src/products/use-product-form-data.ts +0 -28
  51. package/src/providers/store-provider.tsx +9 -9
  52. package/src/schemas/stock-location.ts +3 -2
  53. package/src/tables/package-types.tsx +1 -1
  54. package/src/tables/stock-locations.tsx +1 -1
  55. package/src/vite/discover.ts +33 -28
  56. package/src/hooks/use-direct-upload.ts +0 -117
  57. /package/src/{products → lib}/currency-parts.ts +0 -0
  58. /package/src/{products → lib}/normalize-money.test.ts +0 -0
@@ -20,10 +20,13 @@ function discoverDashboardPluginManifests({ root, onWarn }, names) {
20
20
  manifests.push({ name });
21
21
  continue;
22
22
  }
23
- const routes = pkg.spree?.dashboard?.routes;
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 ? linkedPackagePath(root, name, path.resolve(path.dirname(manifestPath), routes)) : void 0
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 linkedPackagePath(root, packageName, resolvedPath) {
67
- const realPath = fs.realpathSync(resolvedPath);
68
- for (let dir = root; ; dir = path.dirname(dir)) {
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
- linkedPackagePath
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.1",
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/admin-sdk": "^1.0.0-rc.1",
85
- "@spree/dashboard-ui": "^1.0.0-rc.1"
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
- * ActiveStorage signed id to set the photo, or `null` to remove it. Omit to
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
- avatar?: string | null
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
- * Exchanges blob metadata for a presigned upload URL.
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
- createDirectUpload?(params: {
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
- preferred_delimiter?: string
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
- /** Signed blob id of the already direct-uploaded CSV. */
469
- attachment: string
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
- preferred_delimiter?: PanelImportDelimiter
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?: string | null
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: string
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 empty when neither the address nor the store has a country', () => {
21
- expect(resolveAddressCountryCode(null, null)).toBe('')
22
- expect(resolveAddressCountryCode(undefined, undefined)).toBe('')
23
- expect(resolveAddressCountryCode({ country_code: '' }, '')).toBe('')
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: string
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: z
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
- ): string {
143
- return address?.country_code || storeDefaultCountryCode || ''
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 uiLocales={uiLocales} onEditProfile={onEditProfile} />
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
- <SidebarUser uiLocales={uiLocales} onEditProfile={onEditProfile} />
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: string; iso3: string; name: string }
24
+ export type CountryOption = { iso: CountryCode; iso3: string }
23
25
 
24
26
  /**
25
- * Resolves a country's display name in the admin UI language (keyed on the
26
- * stable ISO code), falling back to the API's backend-locale `name` when the
27
- * runtime lacks coverage. Exported so every country-labelled surface
28
- * (pickers, grid cells) reads the same.
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: CountryOption) => displayName(country.iso) ?? country.name,
39
+ (country: { iso: string }) => displayName(country.iso) ?? country.iso,
38
40
  [displayName],
39
41
  )
40
42
  }
41
43
 
42
44
  /**
43
- * Case-insensitive substring match against name, iso, and iso3 so the user
44
- * can find a country by typing "United States", "US", or "USA". Both pickers
45
- * filter the cached `useCountries()` list client-side — countries are a
46
- * ~250-row static set, so there's no remote search. Mirrors the server-side
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 countryFilter(item: CountryOption, query: string): boolean {
50
- const q = query.trim().toLowerCase()
51
- if (!q) return true
52
- return (
53
- item.name.toLowerCase().includes(q) ||
54
- item.iso.toLowerCase().includes(q) ||
55
- item.iso3.toLowerCase().includes(q)
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
- * Filter that also matches the LOCALIZED country name shown to the user (not
61
- * just the raw backend `name` / ISO codes), so typing a translated label finds
62
- * its country. Memoized on the display-name resolver.
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 countryName(item).toLowerCase().includes(q) || countryFilter(item, query)
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
- /** Maps the cached country list onto the flat option shape both pickers use. */
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
- () => countries.map((c) => ({ iso: c.iso, iso3: c.iso3, name: c.name })),
96
- [countries],
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
- onValueChange: (iso: string) => void
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: string[]) => void
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
- () => value.map((iso) => items.find((c) => c.iso === iso) ?? { iso, iso3: '', name: iso }),
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: string) => void
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?: string[]
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: string) => {
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: string, query: string) => {
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
- onValueChange={(next: string | null) => handleChange(next ?? '')}
142
- itemToStringLabel={(code: string | null) => (code ? renderOption(code) : '')}
143
- itemToStringValue={(code: string | null) => code ?? ''}
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: string) => (
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 { useDirectUpload } from '../hooks/use-direct-upload'
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 ActiveStorage direct upload.
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 directUpload = useDirectUpload({ private: privateStorage })
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 directUpload.mutateAsync(file)
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 direct-uploads on pick via
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>