@spree/dashboard-core 1.0.0-rc.2 → 1.0.0-rc.4

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 (68) hide show
  1. package/dist/vite/discover.js +5 -2
  2. package/package.json +13 -16
  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 +18 -13
  10. package/src/components/file-upload-field.tsx +4 -4
  11. package/src/components/import-button.tsx +4 -1
  12. package/src/components/import-wizard-dialog.tsx +8 -11
  13. package/src/components/mobile-top-bar.tsx +6 -0
  14. package/src/components/money.tsx +23 -0
  15. package/src/components/preferences-form.tsx +263 -54
  16. package/src/components/resource-combobox.tsx +5 -0
  17. package/src/components/resource-picker-sheet.tsx +18 -12
  18. package/src/components/resource-table.tsx +7 -3
  19. package/src/components/table-toolbar.tsx +8 -5
  20. package/src/hooks/use-display-name.ts +11 -3
  21. package/src/hooks/use-file-upload.ts +51 -0
  22. package/src/hooks/use-import.ts +5 -5
  23. package/src/hooks/use-money-locale.ts +14 -0
  24. package/src/index.ts +8 -3
  25. package/src/lib/currencies.ts +32 -13
  26. package/src/lib/form-mappers.ts +25 -5
  27. package/src/lib/formatters.ts +34 -4
  28. package/src/lib/fulfillment-items.test.ts +2 -1
  29. package/src/lib/fulfillment-items.ts +9 -3
  30. package/src/lib/i18n.ts +4 -4
  31. package/src/lib/iso-codes.ts +47 -0
  32. package/src/lib/money.test.ts +63 -0
  33. package/src/lib/money.ts +99 -0
  34. package/src/{products → lib}/normalize-money.ts +2 -2
  35. package/src/lib/permissions.ts +2 -0
  36. package/src/lib/return-refunds.test.ts +62 -0
  37. package/src/lib/return-refunds.ts +70 -0
  38. package/src/lib/route-registry.ts +58 -15
  39. package/src/lib/settings-nav-registry.test.ts +34 -0
  40. package/src/lib/settings-nav-registry.ts +21 -4
  41. package/src/lib/slot-registry.ts +10 -0
  42. package/src/lib/storefront.ts +2 -2
  43. package/src/locales/ar.json +7 -2
  44. package/src/locales/de.json +7 -2
  45. package/src/locales/en.json +7 -2
  46. package/src/locales/es.json +7 -2
  47. package/src/locales/fr.json +7 -2
  48. package/src/locales/pl.json +7 -2
  49. package/src/locales/zh-CN.json +7 -2
  50. package/src/plugin.ts +5 -5
  51. package/src/products/bulk-variants-dialog.tsx +2 -8
  52. package/src/products/inventory-section.tsx +2 -2
  53. package/src/products/product-bulk-price-editor.tsx +18 -29
  54. package/src/products/product-form-cards.tsx +12 -8
  55. package/src/products/product-form-mapping.ts +15 -11
  56. package/src/products/product-schema.ts +6 -8
  57. package/src/products/product-types.ts +5 -3
  58. package/src/products/use-product-form-data.ts +0 -28
  59. package/src/products/variants-options-builder.tsx +30 -27
  60. package/src/products/variants-section.tsx +31 -25
  61. package/src/providers/store-provider.tsx +9 -9
  62. package/src/schemas/stock-location.ts +3 -2
  63. package/src/tables/package-types.tsx +1 -1
  64. package/src/tables/stock-locations.tsx +1 -1
  65. package/src/vite/discover.ts +19 -2
  66. package/src/hooks/use-direct-upload.ts +0 -117
  67. /package/src/{products → lib}/currency-parts.ts +0 -0
  68. /package/src/{products → lib}/normalize-money.test.ts +0 -0
@@ -56,6 +56,7 @@ import {
56
56
  import { createPortal } from 'react-dom'
57
57
  import { useTranslation } from 'react-i18next'
58
58
  import { z } from 'zod/v4'
59
+ import { runtimeListParams } from '../api-client'
59
60
  import { useAuth } from '../hooks/use-auth'
60
61
  import { useVisualViewportOffset } from '../hooks/use-visual-viewport-offset'
61
62
  import { filtersToRansack } from '../lib/filters-to-ransack'
@@ -176,8 +177,11 @@ interface ResourceTableProps<T> {
176
177
  * `[webhookDeliveriesTableKey(endpointId)]`).
177
178
  */
178
179
  queryKey: string | readonly unknown[]
179
- /** Function that calls the SDK to fetch data */
180
- queryFn: (params: Record<string, unknown>) => Promise<{ data: T[]; meta: PaginationMeta }>
180
+ /**
181
+ * Function that calls the SDK to fetch data. Its params are built at runtime
182
+ * from the table's columns and filters, so any typed SDK list method fits.
183
+ */
184
+ queryFn: (params: never) => Promise<{ data: T[]; meta: PaginationMeta }>
181
185
  /** Current search params from the route */
182
186
  searchParams: ResourceSearch
183
187
  /** Title displayed in the toolbar header. Overrides the table definition's title. */
@@ -401,7 +405,7 @@ export function ResourceTable<T extends Record<string, any>>({
401
405
 
402
406
  Object.assign(params, filtersToRansack(filters as FilterRule[], allColumns))
403
407
 
404
- return queryFn(params)
408
+ return queryFn(runtimeListParams(params))
405
409
  },
406
410
  enabled: !!token,
407
411
  })
@@ -1177,17 +1177,19 @@ function InlineValueList({
1177
1177
  }))
1178
1178
  }, [isCurrency, currencies, isTags, tags.data, resources.data, column.filterResource, query])
1179
1179
 
1180
- // Selected values ride at the top so a choice made before searching does not
1181
- // disappear the moment the query stops matching it.
1180
+ // Values already selected when the list opens ride at the top, so an active
1181
+ // filter is visible without scrolling. The set is frozen at mount: ticking a
1182
+ // box must not move rows under the pointer.
1183
+ const [pinned] = useState(() => selected)
1182
1184
  const ordered = useMemo(() => {
1183
- const chosen = options.filter((option) => selected.includes(option.value))
1184
- const rest = options.filter((option) => !selected.includes(option.value))
1185
+ const chosen = options.filter((option) => pinned.includes(option.value))
1186
+ const rest = options.filter((option) => !pinned.includes(option.value))
1185
1187
  // Not truncated: a list cut at ten looks like a list of ten, and a
1186
1188
  // vocabulary of forty tags would silently lose thirty with nothing on
1187
1189
  // screen to say so. The container scrolls instead, so the scrollbar
1188
1190
  // reports how much more there is — and searching narrows it.
1189
1191
  return [...chosen, ...rest]
1190
- }, [options, selected])
1192
+ }, [options, pinned])
1191
1193
 
1192
1194
  if (!isCurrency && (tags.isLoading || resources.isLoading)) {
1193
1195
  return (
@@ -1507,6 +1509,7 @@ function FilterPanel({
1507
1509
  behind the panel is already showing the result. */}
1508
1510
  {field && multi && column && (
1509
1511
  <InlineValueList
1512
+ key={column.key}
1510
1513
  column={column}
1511
1514
  tenantId={tenantId}
1512
1515
  query={query}
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'
4
4
  /**
5
5
  * Resolves a code to its display name via `Intl.DisplayNames`, localized to a
6
6
  * given UI language. Returns `undefined` when the runtime lacks coverage so
7
- * callers can apply their own fallback (the API name, the raw code, etc.).
7
+ * callers can apply their own fallback (usually the raw code).
8
8
  *
9
9
  * Standalone (non-hook) variant for module-level use outside React.
10
10
  */
@@ -25,7 +25,7 @@ export function intlDisplayName(
25
25
  * type (`currency`, `language`, `region`, …), localized to the **admin UI
26
26
  * language** (`i18n.language`) — NOT the store's content locale — so names
27
27
  * match the rest of the dashboard chrome. Returns `undefined` on a miss; the
28
- * caller supplies the fallback (e.g. `?? code`, `?? country.name`).
28
+ * caller supplies the fallback (e.g. `?? code`).
29
29
  */
30
30
  export function useDisplayName(type: Intl.DisplayNamesType) {
31
31
  const { i18n: instance } = useTranslation()
@@ -37,6 +37,14 @@ export function useDisplayName(type: Intl.DisplayNamesType) {
37
37
  } catch {
38
38
  formatter = undefined
39
39
  }
40
- return (code: string) => formatter?.of(code)
40
+ // `of` throws a RangeError for a malformed code (a legacy `english`), so
41
+ // such a code reads as unnamed rather than breaking the page.
42
+ return (code: string) => {
43
+ try {
44
+ return formatter?.of(code)
45
+ } catch {
46
+ return undefined
47
+ }
48
+ }
41
49
  }, [locale, type])
42
50
  }
@@ -0,0 +1,51 @@
1
+ import { useMutation } from '@tanstack/react-query'
2
+ import { getApiClient } from '../api-client'
3
+
4
+ interface UploadResult {
5
+ signedId: string
6
+ previewUrl: string
7
+ }
8
+
9
+ // Active Storage's Disk service issues an absolute URL against the Rails
10
+ // origin (e.g. http://localhost:3000/rails/active_storage/disk/...). In dev the
11
+ // SPA runs on a different port and that controller doesn't speak CORS, so the
12
+ // browser blocks the cross-origin PUT before any response ("Failed to fetch").
13
+ // When the URL points at a `/rails/active_storage/` path we drop the host so
14
+ // the request goes through Vite's `/rails` proxy and stays same-origin.
15
+ // Production storage URLs are untouched.
16
+ function sameOriginIfRailsDisk(url: string): string {
17
+ try {
18
+ const parsed = new URL(url)
19
+ if (parsed.pathname.startsWith('/rails/active_storage/')) {
20
+ return parsed.pathname + parsed.search
21
+ }
22
+ return url
23
+ } catch {
24
+ return url
25
+ }
26
+ }
27
+
28
+ interface FileUploadOptions {
29
+ /**
30
+ * `private` for files only ever served through an authorized link
31
+ * (documents, imports, digital products). Attaching a file never moves it
32
+ * between storage services, so this has to be decided up front.
33
+ */
34
+ visibility?: 'public' | 'private'
35
+ }
36
+
37
+ export function useFileUpload(options: FileUploadOptions = {}) {
38
+ return useMutation({
39
+ mutationFn: async (file: File): Promise<UploadResult> => {
40
+ const uploadFile = getApiClient().uploadFile
41
+ if (!uploadFile) throw new Error('This panel cannot upload files.')
42
+
43
+ const { signed_id } = await uploadFile(file, {
44
+ visibility: options.visibility ?? 'public',
45
+ resolveUploadUrl: sameOriginIfRailsDisk,
46
+ })
47
+
48
+ return { signedId: signed_id, previewUrl: URL.createObjectURL(file) }
49
+ },
50
+ })
51
+ }
@@ -90,12 +90,12 @@ function imports() {
90
90
  /** Create-time parameters beyond the ones every import shares. */
91
91
  export type ImportCreateExtras = Omit<
92
92
  PanelImportCreateParams,
93
- 'type' | 'attachment' | 'preferred_delimiter' | 'results_url'
93
+ 'type' | 'attachment_signed_id' | 'delimiter' | 'results_url'
94
94
  >
95
95
 
96
96
  export interface CreateImportInput {
97
97
  type: string
98
- /** Signed blob id of the already direct-uploaded CSV (see `FileUploadField`). */
98
+ /** The `signed_id` of the already uploaded CSV (see `FileUploadField`). */
99
99
  signedId: string
100
100
  preferredDelimiter?: PanelImportDelimiter
101
101
  /**
@@ -111,7 +111,7 @@ export interface CreateImportInput {
111
111
  }
112
112
 
113
113
  /**
114
- * Creates the import from a direct-uploaded CSV; the response is in the
114
+ * Creates the import from an uploaded CSV; the response is in the
115
115
  * `mapping` state and carries the mapping payload.
116
116
  */
117
117
  export function useCreateImport() {
@@ -128,8 +128,8 @@ export function useCreateImport() {
128
128
  imports().create({
129
129
  ...params,
130
130
  type,
131
- attachment: signedId,
132
- preferred_delimiter: preferredDelimiter,
131
+ attachment_signed_id: signedId,
132
+ delimiter: preferredDelimiter,
133
133
  // The import-done email deep-links back to the wizard (`?import=<id>`
134
134
  // appended server-side). Only honored when this origin is on the
135
135
  // store's allowed-origins list.
@@ -0,0 +1,14 @@
1
+ import { useTranslation } from 'react-i18next'
2
+
3
+ /**
4
+ * The locale money is shown and typed in: the dashboard language of the person
5
+ * using it, whatever the currency or market. Someone who writes `1.234,56`
6
+ * types that for dollars too, and the field reads it back the same way it
7
+ * displayed it. The API only ever receives canonical `"1234.56"`.
8
+ *
9
+ * @returns a BCP 47 locale such as `de` or `en`
10
+ */
11
+ export function useMoneyLocale(): string {
12
+ const { i18n } = useTranslation()
13
+ return i18n.language || 'en'
14
+ }
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ export * from './components/locale-select'
33
33
  export * from './components/market-combobox'
34
34
  export * from './components/media-picker-sheet'
35
35
  export * from './components/mobile-top-bar'
36
+ export * from './components/money'
36
37
  export * from './components/nav-main'
37
38
  export * from './components/page-header'
38
39
  export * from './components/page-tabs'
@@ -62,12 +63,13 @@ export * from './hooks/use-auto-collapse-sidebar'
62
63
  export * from './hooks/use-command-palette'
63
64
  export * from './hooks/use-countries'
64
65
  export * from './hooks/use-custom-fields'
65
- export * from './hooks/use-direct-upload'
66
66
  export * from './hooks/use-display-name'
67
67
  export * from './hooks/use-export'
68
+ export * from './hooks/use-file-upload'
68
69
  export * from './hooks/use-global-search'
69
70
  export * from './hooks/use-host-form'
70
71
  export * from './hooks/use-import'
72
+ export * from './hooks/use-money-locale'
71
73
  export * from './hooks/use-package-types'
72
74
  export * from './hooks/use-resource-mutation'
73
75
  export * from './hooks/use-stock-locations'
@@ -79,6 +81,7 @@ export * from './lib/create-action-registry'
79
81
  // Registries — pluggable extension points (nav, route, slot, table,
80
82
  // settings-nav, form fields, custom field components)
81
83
  // ---------------------------------------------------------------------------
84
+ export * from './lib/currency-parts'
82
85
  export * from './lib/custom-field-components'
83
86
  export * from './lib/date-presets'
84
87
  export * from './lib/delivery-form'
@@ -98,12 +101,16 @@ export * from './lib/fulfillment-items'
98
101
  // `i18n.addResourceBundle`) get it via the named re-export.
99
102
  export * from './lib/i18n'
100
103
  export * from './lib/import-types'
104
+ export * from './lib/iso-codes'
101
105
  export * from './lib/label-upload-form'
106
+ export * from './lib/money'
102
107
  export * from './lib/nav-registry'
108
+ export * from './lib/normalize-money'
103
109
  export * from './lib/permissions'
104
110
  export * from './lib/progress'
105
111
  export * from './lib/query-client'
106
112
  export * from './lib/query-keys'
113
+ export * from './lib/return-refunds'
107
114
  export * from './lib/route-registry'
108
115
  export * from './lib/search-registry'
109
116
  export * from './lib/settings-nav-registry'
@@ -118,9 +125,7 @@ export * from './pages/stock-locations'
118
125
  // Plugin facade — re-exported for convenience; same API as `/plugin` subpath
119
126
  // ---------------------------------------------------------------------------
120
127
  export * from './plugin'
121
- export * from './products/currency-parts'
122
128
  export * from './products/normalize-customs'
123
- export * from './products/normalize-money'
124
129
  export * from './products/normalize-quantity'
125
130
  export * from './products/product-form-cards'
126
131
  export * from './products/product-form-mapping'
@@ -1,15 +1,34 @@
1
+ import { CurrencyCode } from '@spree/admin-sdk'
2
+
3
+ // Replaced currencies Spree still accepts so orders priced in them stay
4
+ // valid, but which no merchant should pick for new pricing.
5
+ const RETIRED_CURRENCY_CODES: ReadonlySet<string> = new Set([
6
+ 'BYR',
7
+ 'CUC',
8
+ 'EEK',
9
+ 'HRK',
10
+ 'LTL',
11
+ 'LVL',
12
+ 'MRO',
13
+ 'MTL',
14
+ 'SKK',
15
+ 'SLL',
16
+ 'STD',
17
+ 'TMM',
18
+ 'VEF',
19
+ 'ZMK',
20
+ 'ZWD',
21
+ 'ZWL',
22
+ 'ZWN',
23
+ 'ZWR',
24
+ ])
25
+
1
26
  /**
2
- * Every ISO 4217 currency code the runtime knows about — the active-currency
3
- * counterpart to the Rails admin's `Money::Currency.table` list. Used for
4
- * contexts where the merchant can pick *any* currency (a market's currency)
5
- * rather than one the store already supports. Degrades to an empty list on
6
- * runtimes without `Intl.supportedValuesOf`, so callers fall back to
7
- * `supported_currencies`.
27
+ * Every currency Spree accepts that is still in use. Used for contexts where
28
+ * the merchant can pick *any* currency (a market's currency) rather than one
29
+ * the store already supports. The list ships with the SDK, so a currency a
30
+ * newer server adds appears once the dashboard is upgraded with it.
8
31
  */
9
- export const ALL_CURRENCY_CODES: string[] = (() => {
10
- try {
11
- return Intl.supportedValuesOf('currency')
12
- } catch {
13
- return []
14
- }
15
- })()
32
+ export const ALL_CURRENCY_CODES: readonly CurrencyCode[] = Object.values(CurrencyCode).filter(
33
+ (code) => !RETIRED_CURRENCY_CODES.has(code),
34
+ )
@@ -1,3 +1,4 @@
1
+ import type { CountryCode } from '@spree/admin-sdk'
1
2
  import type { PanelAccountParams } from '../api-client'
2
3
 
3
4
  /**
@@ -91,7 +92,7 @@ export function editableAddressToStockLocationParams(values: {
91
92
  address2?: string | null
92
93
  city?: string | null
93
94
  postal_code?: string | null
94
- country_code?: string | null
95
+ country_code?: CountryCode | null
95
96
  state_code?: string | null
96
97
  phone?: string | null
97
98
  }) {
@@ -135,12 +136,31 @@ export function accountFormToParams(values: {
135
136
  first_name: values.first_name ?? undefined,
136
137
  last_name: values.last_name ?? undefined,
137
138
  selected_locale: blankToUndefined(values.selected_locale),
138
- ...avatarParam(values.avatar_signed_id, values.avatar_cleared),
139
+ ...signedIdParam('avatar', values.avatar_signed_id, values.avatar_cleared),
139
140
  }
140
141
  }
141
142
 
142
- function avatarParam(signedId: string | null, cleared: boolean) {
143
- if (signedId) return { avatar: signedId }
144
- if (cleared) return { avatar: null }
143
+ /**
144
+ * The write for one attachment slot, under the API's `<slot>_signed_id` name:
145
+ * a fresh upload sends its signed id, an explicit clear sends `null` to remove
146
+ * the file, and an untouched field is left out entirely.
147
+ */
148
+ export function signedIdParam<Slot extends string>(
149
+ slot: Slot,
150
+ signedId: string | null | undefined,
151
+ ): Partial<Record<`${Slot}_signed_id`, string>>
152
+ export function signedIdParam<Slot extends string>(
153
+ slot: Slot,
154
+ signedId: string | null | undefined,
155
+ cleared: boolean | undefined,
156
+ ): Partial<Record<`${Slot}_signed_id`, string | null>>
157
+ export function signedIdParam<Slot extends string>(
158
+ slot: Slot,
159
+ signedId: string | null | undefined,
160
+ cleared?: boolean,
161
+ ): Partial<Record<`${Slot}_signed_id`, string | null>> {
162
+ const key = `${slot}_signed_id` as `${Slot}_signed_id`
163
+ if (signedId) return { [key]: signedId } as Record<`${Slot}_signed_id`, string>
164
+ if (cleared) return { [key]: null } as Record<`${Slot}_signed_id`, null>
145
165
  return {}
146
166
  }
@@ -1,10 +1,40 @@
1
- import type { Price } from '@spree/admin-sdk'
1
+ import { formatMoney, type Price } from '@spree/admin-sdk'
2
2
  import { parseISO } from 'date-fns'
3
3
  import { formatInTimeZone } from 'date-fns-tz'
4
+ import i18n from 'i18next'
4
5
 
5
- export function formatPrice(price: Pick<Price, 'amount' | 'currency' | 'display_amount'> | null) {
6
- if (!price) return '—'
7
- return price.display_amount ?? `${price.currency} ${price.amount}`
6
+ export { formatMoney }
7
+
8
+ /**
9
+ * A price's amount in its currency, in the admin's own number format. A unit
10
+ * price keeps a fraction of a cent. `display_amount` is accepted and ignored
11
+ * so callers built against the old shape keep compiling.
12
+ */
13
+ export function formatPrice(
14
+ price: (Pick<Price, 'amount' | 'currency'> & { display_amount?: string | null }) | null,
15
+ locale: string = i18n.language || 'en',
16
+ ) {
17
+ if (!price?.amount || !price.currency) return '—'
18
+ return formatMoney(price.amount, price.currency, locale, { unitPrice: true })
19
+ }
20
+
21
+ /**
22
+ * Formats an API amount (a decimal string) in its currency, falling back to
23
+ * the bare number for a currency `Intl` does not know, and to `fallback` when
24
+ * either is missing.
25
+ */
26
+ export function formatAmount(
27
+ amount: string | null | undefined,
28
+ currency: string | null | undefined,
29
+ locale?: string,
30
+ fallback = '—',
31
+ ): string {
32
+ if (amount === null || amount === undefined || amount === '' || !currency) return fallback
33
+ try {
34
+ return formatMoney(amount, currency, locale) || fallback
35
+ } catch {
36
+ return `${amount} ${currency}`
37
+ }
8
38
  }
9
39
 
10
40
  export function formatStoreDateTime(iso: string, timezone: string) {
@@ -14,7 +14,8 @@ function lineItem(overrides: Partial<GroupableLineItem> = {}): GroupableLineItem
14
14
  options_text: 'Blue / M',
15
15
  quantity: 2,
16
16
  thumbnail_url: 'https://example.test/shirt.jpg',
17
- display_price: '$10.00',
17
+ price: '10.00',
18
+ currency: 'USD',
18
19
  ...overrides,
19
20
  }
20
21
  }
@@ -1,3 +1,6 @@
1
+ import i18n from 'i18next'
2
+ import { formatMoney } from './formatters'
3
+
1
4
  /**
2
5
  * Which line items sit in which fulfillment, and which units nobody has
3
6
  * claimed yet. The order payload describes the two halves separately —
@@ -14,8 +17,8 @@ export interface GroupableLineItem {
14
17
  options_text?: string | null
15
18
  quantity: number
16
19
  thumbnail_url?: string | null
17
- display_price?: string | null
18
- display_total?: string | null
20
+ price?: string | null
21
+ currency?: string | null
19
22
  }
20
23
 
21
24
  /** The fulfillment-item fields the grouping reads. */
@@ -130,7 +133,10 @@ function buildRow(
130
133
  name: copy?.name ?? fallback?.name ?? '',
131
134
  optionsText: copy?.options_text ?? fallback?.options_text ?? null,
132
135
  thumbnailUrl: copy?.thumbnail_url ?? null,
133
- displayPrice: copy?.display_price ?? null,
136
+ displayPrice:
137
+ copy?.price && copy.currency
138
+ ? formatMoney(copy.price, copy.currency, i18n.language || 'en', { unitPrice: true })
139
+ : null,
134
140
  quantity,
135
141
  }
136
142
  }
package/src/lib/i18n.ts CHANGED
@@ -10,7 +10,7 @@ import en from '../locales/en.json'
10
10
  // made anywhere is honored on the next paint.
11
11
  export const ADMIN_LOCALE_STORAGE_KEY = 'spree-admin-locale'
12
12
 
13
- // Marks the locale key as auto-applied from a store's `preferred_admin_locale`
13
+ // Marks the locale key as auto-applied from a store's `admin_locale`
14
14
  // (vs an explicit choice). Holds the storeId whose default is currently in
15
15
  // effect, so crossing into a different store can re-apply that store's default
16
16
  // while a genuine choice (switcher/profile/login) still wins everywhere. Mirrors
@@ -69,10 +69,10 @@ function readStoredLocale(): string {
69
69
  }
70
70
 
71
71
  /**
72
- * Reconcile the admin UI language against a store's `preferred_admin_locale`
72
+ * Reconcile the admin UI language against a store's `admin_locale`
73
73
  * fallback (legacy `base_controller` parity), for an admin with no genuine
74
74
  * personal choice. Encodes the precedence:
75
- * account selected_locale > genuine stored choice > store preferred_admin_locale > 'en'
75
+ * account selected_locale > genuine stored choice > store admin_locale > 'en'
76
76
  *
77
77
  * No-op when a higher tier owns the language (an account locale, or a genuine
78
78
  * stored choice — locale key set without an auto-marker). Otherwise drives the
@@ -83,7 +83,7 @@ function readStoredLocale(): string {
83
83
  * - already matching → nothing changes.
84
84
  * Reloads (via the boot path) only when the displayed language actually changes.
85
85
  *
86
- * @param code the store's current `preferred_admin_locale`
86
+ * @param code the store's current `admin_locale`
87
87
  * @param storeId the store being entered
88
88
  * @param accountLocale the account's `selected_locale` (null when unset)
89
89
  * @param supported locale codes the dashboard ships a bundle for
@@ -0,0 +1,47 @@
1
+ import type { CountryCode, CurrencyCode, LocaleCode } from '@spree/admin-sdk'
2
+ import i18n from 'i18next'
3
+ import { z } from 'zod/v4'
4
+
5
+ /**
6
+ * Whether a value has the shape of an ISO 3166-1 alpha-2 country code.
7
+ *
8
+ * Shape only: the connected server holds the authoritative list and rejects
9
+ * a code it does not know, while the SDK's `CountryCode` list is fixed when it
10
+ * is built. Checking against that copy would hide or reject a code an older or
11
+ * newer server accepts. This is the one place a plain string becomes a
12
+ * `CountryCode`.
13
+ */
14
+ export function isCountryCode(value: unknown): value is CountryCode {
15
+ return typeof value === 'string' && /^[A-Z]{2}$/.test(value)
16
+ }
17
+
18
+ /** Whether a value has the shape of an ISO 4217 currency code. See `isCountryCode`. */
19
+ export function isCurrencyCode(value: unknown): value is CurrencyCode {
20
+ return typeof value === 'string' && /^[A-Z]{3}$/.test(value)
21
+ }
22
+
23
+ /**
24
+ * Whether a value has the shape of a locale code: an ISO 639-1 language,
25
+ * optionally with a region (`en`, `pt-BR`, `es-419`). See `isCountryCode`.
26
+ */
27
+ export function isLocaleCode(value: unknown): value is LocaleCode {
28
+ return typeof value === 'string' && /^[a-z]{2,3}(-[A-Z0-9]{2,3})?$/.test(value)
29
+ }
30
+
31
+ /**
32
+ * A required country field. A form that allows leaving it blank widens it
33
+ * with `.or(z.literal(''))` and sends `value || null` (or `|| undefined`).
34
+ */
35
+ export const countryCodeSchema = z.custom<CountryCode>(isCountryCode, {
36
+ error: () => i18n.t('admin.validation.country_required'),
37
+ })
38
+
39
+ /** A required currency field. See `countryCodeSchema`. */
40
+ export const currencyCodeSchema = z.custom<CurrencyCode>(isCurrencyCode, {
41
+ error: () => i18n.t('admin.validation.currency_required'),
42
+ })
43
+
44
+ /** A required locale field. See `countryCodeSchema`. */
45
+ export const localeCodeSchema = z.custom<LocaleCode>(isLocaleCode, {
46
+ error: () => i18n.t('admin.validation.locale_required'),
47
+ })
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ fractionToPercent,
4
+ isPositiveMoney,
5
+ percentOf,
6
+ percentToFraction,
7
+ prorateMoney,
8
+ shiftDecimalPoint,
9
+ } from './money'
10
+
11
+ describe('shiftDecimalPoint', () => {
12
+ it('moves the point exactly, without float noise', () => {
13
+ expect(shiftDecimalPoint('0.077', 2)).toBe('7.7')
14
+ expect(shiftDecimalPoint('7.7', -2)).toBe('0.077')
15
+ expect(shiftDecimalPoint('0.07', 2)).toBe('7')
16
+ expect(shiftDecimalPoint('1', 2)).toBe('100')
17
+ expect(shiftDecimalPoint('100', -2)).toBe('1')
18
+ expect(shiftDecimalPoint('-0.5', 2)).toBe('-50')
19
+ expect(shiftDecimalPoint('0', -2)).toBe('0')
20
+ })
21
+
22
+ it('leaves a value that is not a decimal string alone', () => {
23
+ expect(shiftDecimalPoint('abc', 2)).toBe('abc')
24
+ })
25
+ })
26
+
27
+ describe('fractionToPercent / percentToFraction', () => {
28
+ it('round-trips and keeps blanks blank', () => {
29
+ expect(fractionToPercent('0.23')).toBe('23')
30
+ expect(percentToFraction('23')).toBe('0.23')
31
+ expect(percentToFraction('7.25')).toBe('0.0725')
32
+ expect(fractionToPercent(null)).toBe('')
33
+ expect(percentToFraction('')).toBe('')
34
+ })
35
+ })
36
+
37
+ describe('percentOf', () => {
38
+ it('rounds to the currency decimals', () => {
39
+ expect(percentOf('19.99', '10', 'USD')).toBe('2.00')
40
+ expect(percentOf('100', '12.5', 'USD')).toBe('12.50')
41
+ expect(percentOf('1000', '0.05', 'JPY')).toBe('1')
42
+ expect(percentOf('1.005', '50', 'KWD')).toBe('0.503')
43
+ })
44
+ })
45
+
46
+ describe('isPositiveMoney', () => {
47
+ it('is true only for an amount above zero', () => {
48
+ expect(isPositiveMoney('0.01')).toBe(true)
49
+ expect(isPositiveMoney('0.00')).toBe(false)
50
+ expect(isPositiveMoney('-1')).toBe(false)
51
+ expect(isPositiveMoney(null)).toBe(false)
52
+ expect(isPositiveMoney('abc')).toBe(false)
53
+ })
54
+ })
55
+
56
+ describe('prorateMoney', () => {
57
+ it('shares an amount across units, rounded to the currency', () => {
58
+ expect(prorateMoney('10.00', 1, 3, 'USD')).toBe('3.33')
59
+ expect(prorateMoney('10.00', 2, 3, 'USD')).toBe('6.67')
60
+ expect(prorateMoney('10.00', 3, 3, 'USD')).toBe('10.00')
61
+ expect(prorateMoney('1000', 1, 3, 'JPY')).toBe('333')
62
+ })
63
+ })