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

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 (70) hide show
  1. package/package.json +10 -7
  2. package/src/api-client.ts +76 -7
  3. package/src/components/address-form-dialog.tsx +2 -2
  4. package/src/components/country-combobox.tsx +1 -2
  5. package/src/components/currency-amounts-field.tsx +93 -0
  6. package/src/components/currency-select.tsx +6 -7
  7. package/src/components/file-upload-field.tsx +1 -0
  8. package/src/components/locale-select.tsx +22 -23
  9. package/src/components/nav-main.tsx +7 -2
  10. package/src/components/preferences-form.tsx +2 -8
  11. package/src/components/settings-sidebar.tsx +8 -1
  12. package/src/components/slot.tsx +21 -1
  13. package/src/components/table-toolbar.tsx +102 -39
  14. package/src/hooks/use-display-name.ts +1 -18
  15. package/src/hooks/use-file-upload.ts +2 -1
  16. package/src/hooks/use-import.ts +3 -2
  17. package/src/index.ts +17 -3
  18. package/src/lib/currencies.ts +2 -34
  19. package/src/lib/currency-amounts.test.ts +147 -0
  20. package/src/lib/currency-amounts.ts +172 -0
  21. package/src/lib/currency-parts.ts +2 -21
  22. package/src/lib/filters-to-ransack.ts +4 -1
  23. package/src/lib/form-fields-registry.ts +23 -1
  24. package/src/lib/form-mappers.ts +1 -1
  25. package/src/lib/formatters.ts +6 -10
  26. package/src/lib/fulfillment-items.ts +11 -0
  27. package/src/lib/i18n.ts +1 -12
  28. package/src/lib/iso-codes.ts +9 -26
  29. package/src/lib/money.ts +9 -99
  30. package/src/lib/normalize-money.ts +2 -85
  31. package/src/lib/return-refunds.ts +1 -2
  32. package/src/lib/route-registry.ts +29 -0
  33. package/src/lib/settings-nav-registry.ts +6 -1
  34. package/src/lib/store-config.test.ts +93 -0
  35. package/src/lib/store-config.ts +76 -0
  36. package/src/lib/table-registry.ts +16 -5
  37. package/src/locales/ar.json +51 -0
  38. package/src/locales/de.json +51 -0
  39. package/src/locales/en.json +51 -0
  40. package/src/locales/es.json +51 -0
  41. package/src/locales/fr.json +51 -0
  42. package/src/locales/pl.json +51 -0
  43. package/src/locales/zh-CN.json +51 -0
  44. package/src/pages/package-types.tsx +50 -11
  45. package/src/pages/settings-index.tsx +4 -1
  46. package/src/pages/stock-locations.tsx +31 -6
  47. package/src/products/assignable-variants.ts +35 -0
  48. package/src/products/bulk-variants-dialog.tsx +27 -28
  49. package/src/products/digital-asset-edit-sheet.tsx +192 -0
  50. package/src/products/digital-asset-provider-sheet.tsx +88 -0
  51. package/src/products/digital-assets-card.tsx +451 -0
  52. package/src/products/media-edit-sheet.tsx +68 -20
  53. package/src/products/normalize-quantity.ts +2 -3
  54. package/src/products/product-bulk-price-editor.tsx +1 -3
  55. package/src/products/product-form-cards.tsx +67 -37
  56. package/src/products/product-form-mapping.ts +44 -5
  57. package/src/products/product-schema.test.ts +8 -0
  58. package/src/products/product-schema.ts +14 -7
  59. package/src/products/product-types.ts +1 -1
  60. package/src/products/provider-settings-fields.tsx +110 -0
  61. package/src/products/sheet-save-handlers.ts +37 -0
  62. package/src/products/use-digital-assets.ts +66 -0
  63. package/src/products/variant-edit-sheet.tsx +21 -11
  64. package/src/products/variants-section.tsx +4 -1
  65. package/src/providers/store-provider.tsx +31 -7
  66. package/src/schemas/package-type.ts +14 -12
  67. package/src/schemas/stock-location.ts +2 -3
  68. package/src/tables/stock-locations.tsx +5 -8
  69. package/src/lib/money.test.ts +0 -63
  70. package/src/lib/normalize-money.test.ts +0 -67
@@ -1,3 +1,4 @@
1
+ import type { FilterPredicate } from '@spree/core'
1
2
  import {
2
3
  Button,
3
4
  Checkbox,
@@ -36,7 +37,7 @@ import {
36
37
  XIcon,
37
38
  } from '@spree/dashboard-ui/icons'
38
39
  import { useQuery } from '@tanstack/react-query'
39
- import { useCallback, useDeferredValue, useMemo, useState } from 'react'
40
+ import { Fragment, useCallback, useDeferredValue, useMemo, useState } from 'react'
40
41
  import { useTranslation } from 'react-i18next'
41
42
  import { getApiClient } from '../api-client'
42
43
  import { useAuth } from '../hooks/use-auth'
@@ -55,6 +56,7 @@ import {
55
56
  } from '../lib/table-registry'
56
57
  import { useOptionalStore } from '../providers/store-provider'
57
58
  import { useTenantId } from '../providers/tenant-provider'
59
+ import { LocaleLabel, useLocaleLabel } from './locale-select'
58
60
  import { StoreDatePicker } from './store-date-picker'
59
61
 
60
62
  interface TableToolbarProps {
@@ -81,7 +83,16 @@ interface TableToolbarProps {
81
83
 
82
84
  // Operator labels are stored as i18n keys (`admin.components.table_toolbar.operators.*`)
83
85
  // and resolved with `t()` at render time so they follow the active language.
84
- const operatorsByType: Record<string, { value: string; labelKey: string }[]> = {
86
+ type OperatorOption = { value: FilterPredicate; labelKey: string }
87
+
88
+ const storeCodeOperators: OperatorOption[] = [
89
+ { value: 'eq', labelKey: 'admin.components.table_toolbar.operators.is' },
90
+ { value: 'not_eq', labelKey: 'admin.components.table_toolbar.operators.is_not' },
91
+ { value: 'in', labelKey: 'admin.components.table_toolbar.operators.is_any_of' },
92
+ { value: 'not_in', labelKey: 'admin.components.table_toolbar.operators.is_none_of' },
93
+ ]
94
+
95
+ const operatorsByType: Record<string, OperatorOption[]> = {
85
96
  string: [
86
97
  // `i_cont` (case-insensitive) rather than `cont` — an admin typing
87
98
  // "2 years" expects to match "2 Years".
@@ -125,13 +136,9 @@ const operatorsByType: Record<string, { value: string; labelKey: string }[]> = {
125
136
  { value: 'not_in', labelKey: 'admin.components.table_toolbar.operators.is_none_of' },
126
137
  ],
127
138
  // Same shape as `enum` — the value picker just sources its option list
128
- // from the active store's `supported_currencies`.
129
- currency: [
130
- { value: 'eq', labelKey: 'admin.components.table_toolbar.operators.is' },
131
- { value: 'not_eq', labelKey: 'admin.components.table_toolbar.operators.is_not' },
132
- { value: 'in', labelKey: 'admin.components.table_toolbar.operators.is_any_of' },
133
- { value: 'not_in', labelKey: 'admin.components.table_toolbar.operators.is_none_of' },
134
- ],
139
+ // from the currencies / languages of the active store's markets.
140
+ currency: storeCodeOperators,
141
+ locale: storeCodeOperators,
135
142
  }
136
143
 
137
144
  function getOperators(type: string) {
@@ -155,7 +162,7 @@ const noValueOperators = ['present', 'blank']
155
162
 
156
163
  /** Stable empty list, so a panel with no store doesn't rebuild `items` (and
157
164
  * re-emit Base UI Select state) on every render. */
158
- const noCurrencies: string[] = []
165
+ const noCodes: string[] = []
159
166
 
160
167
  // ============================================================================
161
168
  // TableToolbar
@@ -489,6 +496,15 @@ function FilterChip({
489
496
  <ResourceFilterValue value={first.value} config={col.filterResource} />
490
497
  ) : col?.filterType === 'tags' ? (
491
498
  <span>{parseFilterIds(first.value).join(', ')}</span>
499
+ ) : col?.filterType === 'locale' ? (
500
+ <span>
501
+ {parseFilterIds(first.value).map((code, index) => (
502
+ <Fragment key={code}>
503
+ {index > 0 && ', '}
504
+ <LocaleLabel code={code} />
505
+ </Fragment>
506
+ ))}
507
+ </span>
492
508
  ) : col?.filterType === 'enum' && col.filterOptions ? (
493
509
  <span>
494
510
  {parseFilterIds(first.value)
@@ -515,6 +531,25 @@ function FilterChip({
515
531
  )
516
532
  }
517
533
 
534
+ /**
535
+ * The whole option list of a resource small enough to fetch once (`listAll`),
536
+ * cached under `[queryKey, tenant, 'all']` — the key the resource's own
537
+ * full-list hook uses, so filters and the rest of the dashboard share one
538
+ * fetch that the resource's writes invalidate.
539
+ */
540
+ function useListAll(
541
+ config: ColumnDef['filterResource'],
542
+ tenantId: string | undefined,
543
+ enabled = true,
544
+ ) {
545
+ return useQuery({
546
+ queryKey: [config?.queryKey, tenantId, 'all'],
547
+ queryFn: () => config?.listAll?.() ?? Promise.resolve({ data: [] }),
548
+ enabled: enabled && Boolean(config?.listAll),
549
+ staleTime: config?.staleTime ?? 60_000,
550
+ })
551
+ }
552
+
518
553
  /**
519
554
  * Hydrates the CSV id list in a resource filter into human labels. Reuses
520
555
  * the resource's `hydrate` callback so each chip benefits from React Query's
@@ -530,19 +565,21 @@ function ResourceFilterValue({
530
565
  const tenantId = useTenantId()
531
566
  const ids = useMemo(() => parseFilterIds(value), [value])
532
567
 
533
- const { data } = useQuery({
568
+ const all = useListAll(config, tenantId, ids.length > 0)
569
+ const hydrated = useQuery({
534
570
  // Tenant-scope the cache key so chips don't resolve against another
535
571
  // tenant's hydrate results after a store or seller switch.
536
- queryKey: ['filter-chip', config.queryKey, tenantId, ids],
572
+ queryKey: [config.queryKey, tenantId, 'filter-chip', ids],
537
573
  queryFn: () => config.hydrate(ids),
538
- enabled: ids.length > 0,
539
- staleTime: 60_000,
574
+ enabled: ids.length > 0 && !config.listAll,
575
+ staleTime: config.staleTime ?? 60_000,
540
576
  })
577
+ const records = config.listAll ? all.data?.data : hydrated.data?.data
541
578
 
542
579
  if (ids.length === 0) return null
543
580
 
544
581
  const labels = ids.map((id) => {
545
- const record = data?.data.find((r) => r.id === id)
582
+ const record = records?.find((r) => r.id === id)
546
583
  return record ? config.getOptionLabel(record) : id
547
584
  })
548
585
 
@@ -768,12 +805,7 @@ function QuickResourceFilter({
768
805
  [existingValue],
769
806
  )
770
807
 
771
- const { data } = useQuery({
772
- queryKey: ['quick-filter-all', config.queryKey, tenantId],
773
- queryFn: () => config.listAll?.() ?? Promise.resolve({ data: [] }),
774
- enabled: !!config.listAll,
775
- staleTime: 60_000,
776
- })
808
+ const { data } = useListAll(config, tenantId)
777
809
  const options = useMemo(
778
810
  () =>
779
811
  (data?.data ?? []).map((item) => ({ value: item.id, label: config.getOptionLabel(item) })),
@@ -1101,10 +1133,15 @@ function InlineValueList({
1101
1133
  const tagsClient = getApiClient().tags
1102
1134
  const isTags = column.filterType === 'tags'
1103
1135
  const isCurrency = column.filterType === 'currency'
1104
- // `filterType: 'currency'` is only declared by tables in the operator's
1105
- // dashboard, which always has a store. Reading it optionally keeps the panel
1106
- // mountable in one that has none.
1107
- const currencies = useOptionalStore()?.currencies ?? noCurrencies
1136
+ const isLocale = column.filterType === 'locale'
1137
+ // Store codes are listed client-side from the store payload, never searched.
1138
+ const isStoreCode = isCurrency || isLocale
1139
+ // `filterType: 'currency'` / `'locale'` are only declared by tables in the
1140
+ // operator's dashboard, which always has a store. Reading it optionally
1141
+ // keeps the panel mountable in one that has none.
1142
+ const store = useOptionalStore()
1143
+ const storeCodes = (isCurrency ? store?.currencies : isLocale ? store?.locales : null) ?? noCodes
1144
+ const localeLabel = useLocaleLabel()
1108
1145
 
1109
1146
  // Tags arrive as one list and are filtered client-side; resources are
1110
1147
  // searched server-side, since their vocabulary is unbounded.
@@ -1129,18 +1166,23 @@ function InlineValueList({
1129
1166
  // genuinely new query reaches the network.
1130
1167
  const deferredQuery = useDeferredValue(query)
1131
1168
 
1169
+ // A resource listed whole is fetched once and narrowed here as the operator
1170
+ // types; anything larger is searched server-side.
1171
+ const listed = Boolean(column.filterResource?.listAll)
1172
+ const all = useListAll(column.filterResource, tenantId, !isTags && !isStoreCode)
1173
+
1132
1174
  const resources = useQuery({
1133
1175
  // Tenant-scoped so a store or seller switch cannot reuse the previous
1134
1176
  // tenant's results.
1135
- queryKey: ['panel-resource-filter', column.filterResource?.queryKey, tenantId, deferredQuery],
1177
+ queryKey: [column.filterResource?.queryKey, tenantId, 'panel-resource-filter', deferredQuery],
1136
1178
  queryFn: () => column.filterResource?.search(deferredQuery),
1137
- enabled: !isTags && !isCurrency && Boolean(column.filterResource),
1179
+ enabled: !isTags && !isStoreCode && !listed && Boolean(column.filterResource),
1138
1180
  // These lists are reference data an operator reopens repeatedly while
1139
1181
  // narrowing one list, and the result is small now that only the displayed
1140
1182
  // fields are requested. Holding it for the session keeps reopening the
1141
1183
  // panel instant; `gcTime` outlives the unmount between opens.
1142
- staleTime: 5 * 60_000,
1143
- gcTime: 30 * 60_000,
1184
+ staleTime: column.filterResource?.staleTime ?? 5 * 60_000,
1185
+ gcTime: Math.max(30 * 60_000, column.filterResource?.staleTime ?? 0),
1144
1186
  // Keeps the previous matches on screen while the next query resolves, so
1145
1187
  // the list does not blank on every keystroke.
1146
1188
  //
@@ -1149,7 +1191,7 @@ function InlineValueList({
1149
1191
  // drops them instead of briefly offering another tenant's records as
1150
1192
  // though they were this one's.
1151
1193
  placeholderData: (previous, previousQuery) => {
1152
- const [, previousResource, previousTenant] = (previousQuery?.queryKey ?? []) as unknown[]
1194
+ const [previousResource, previousTenant] = (previousQuery?.queryKey ?? []) as unknown[]
1153
1195
  const sameScope =
1154
1196
  previousResource === column.filterResource?.queryKey && previousTenant === tenantId
1155
1197
  return sameScope ? previous : undefined
@@ -1157,11 +1199,14 @@ function InlineValueList({
1157
1199
  })
1158
1200
 
1159
1201
  const options = useMemo(() => {
1160
- if (isCurrency) {
1202
+ if (isStoreCode) {
1161
1203
  const needle = query.trim().toLowerCase()
1162
- return currencies
1163
- .filter((code) => !needle || code.toLowerCase().includes(needle))
1164
- .map((code) => ({ value: code, label: code }))
1204
+ return storeCodes
1205
+ .map((code) => ({
1206
+ value: code,
1207
+ label: isLocale ? localeLabel(code) : code,
1208
+ }))
1209
+ .filter((option) => !needle || option.label.toLowerCase().includes(needle))
1165
1210
  }
1166
1211
  if (isTags) {
1167
1212
  const names = tags.data?.data?.map((tag) => tag.name) ?? []
@@ -1171,11 +1216,29 @@ function InlineValueList({
1171
1216
  }
1172
1217
  const config = column.filterResource
1173
1218
  if (!config) return []
1174
- return (resources.data?.data ?? []).map((record) => ({
1219
+ const records = (listed ? all.data?.data : resources.data?.data) ?? []
1220
+ const resourceOptions = records.map((record) => ({
1175
1221
  value: record.id,
1176
1222
  label: config.getOptionLabel(record),
1177
1223
  }))
1178
- }, [isCurrency, currencies, isTags, tags.data, resources.data, column.filterResource, query])
1224
+ if (!listed) return resourceOptions
1225
+ const needle = query.trim().toLowerCase()
1226
+ return resourceOptions.filter(
1227
+ (option) => !needle || option.label.toLowerCase().includes(needle),
1228
+ )
1229
+ }, [
1230
+ listed,
1231
+ all.data,
1232
+ isStoreCode,
1233
+ isLocale,
1234
+ storeCodes,
1235
+ localeLabel,
1236
+ isTags,
1237
+ tags.data,
1238
+ resources.data,
1239
+ column.filterResource,
1240
+ query,
1241
+ ])
1179
1242
 
1180
1243
  // Values already selected when the list opens ride at the top, so an active
1181
1244
  // filter is visible without scrolling. The set is frozen at mount: ticking a
@@ -1191,7 +1254,7 @@ function InlineValueList({
1191
1254
  return [...chosen, ...rest]
1192
1255
  }, [options, pinned])
1193
1256
 
1194
- if (!isCurrency && (tags.isLoading || resources.isLoading)) {
1257
+ if (!isStoreCode && (tags.isLoading || resources.isLoading || all.isLoading)) {
1195
1258
  return (
1196
1259
  <p className="px-2 py-6 text-center text-muted-foreground">{t('admin.common.loading')}</p>
1197
1260
  )
@@ -1274,7 +1337,7 @@ function FilterPanel({
1274
1337
  // Types whose values are chosen from a list rather than typed. `multi` ones
1275
1338
  // accumulate a set and need an explicit Apply; the single ones commit on the
1276
1339
  // click that picks them.
1277
- const multi = type === 'tags' || type === 'resource' || type === 'currency'
1340
+ const multi = type === 'tags' || type === 'resource' || type === 'currency' || type === 'locale'
1278
1341
  // Dates are their own shape: a list of relative presets, with the calendar
1279
1342
  // behind "custom". Same presets the quick filter offers, so a range means
1280
1343
  // the same thing wherever it was set.
@@ -1653,7 +1716,7 @@ function FilterPanel({
1653
1716
  </Select>
1654
1717
  ) : null}
1655
1718
 
1656
- {/* Everything picked from a list — currency, tags, resources — or
1719
+ {/* Everything picked from a list — currency, locale, tags, resources — or
1657
1720
  from a calendar is handled above; this branch is only the types
1658
1721
  you type into. */}
1659
1722
  {!noValueOperators.includes(operator) && listOptions.length === 0 && (
@@ -1,24 +1,7 @@
1
1
  import { useMemo } from 'react'
2
2
  import { useTranslation } from 'react-i18next'
3
3
 
4
- /**
5
- * Resolves a code to its display name via `Intl.DisplayNames`, localized to a
6
- * given UI language. Returns `undefined` when the runtime lacks coverage so
7
- * callers can apply their own fallback (usually the raw code).
8
- *
9
- * Standalone (non-hook) variant for module-level use outside React.
10
- */
11
- export function intlDisplayName(
12
- type: Intl.DisplayNamesType,
13
- locale: string,
14
- code: string,
15
- ): string | undefined {
16
- try {
17
- return new Intl.DisplayNames([locale, 'en'], { type }).of(code)
18
- } catch {
19
- return undefined
20
- }
21
- }
4
+ export { intlDisplayName } from '@spree/core'
22
5
 
23
6
  /**
24
7
  * Hook returning a memoized `code -> name` resolver for one `Intl.DisplayNames`
@@ -1,3 +1,4 @@
1
+ import type { FileUploadVisibility } from '@spree/core'
1
2
  import { useMutation } from '@tanstack/react-query'
2
3
  import { getApiClient } from '../api-client'
3
4
 
@@ -31,7 +32,7 @@ interface FileUploadOptions {
31
32
  * (documents, imports, digital products). Attaching a file never moves it
32
33
  * between storage services, so this has to be decided up front.
33
34
  */
34
- visibility?: 'public' | 'private'
35
+ visibility?: FileUploadVisibility
35
36
  }
36
37
 
37
38
  export function useFileUpload(options: FileUploadOptions = {}) {
@@ -1,3 +1,4 @@
1
+ import { IMPORT_STATUSES, subsetOf } from '@spree/core'
1
2
  import { toastManager } from '@spree/dashboard-ui'
2
3
  import {
3
4
  type QueryClient,
@@ -20,7 +21,7 @@ import { useTenantId } from '../providers/tenant-provider'
20
21
  import { useAuth } from './use-auth'
21
22
 
22
23
  /** Statuses the wizard polls through — row creation and processing. */
23
- const ACTIVE_STATUSES = new Set(['completed_mapping', 'processing'])
24
+ const ACTIVE_STATUSES = subsetOf(IMPORT_STATUSES, ['completed_mapping', 'processing'])
24
25
 
25
26
  const IMPORT_POLL_INTERVAL_MS = 2000
26
27
  const ROWS_POLL_INTERVAL_MS = 5000
@@ -49,7 +50,7 @@ const IMPORT_TOUCHED_RESOURCES = [
49
50
 
50
51
  /** Whether the import's pipeline is still running (the poll's continue predicate). */
51
52
  export function isImportActive(status: string | undefined): boolean {
52
- return !!status && ACTIVE_STATUSES.has(status)
53
+ return !!status && ACTIVE_STATUSES.includes(status)
53
54
  }
54
55
 
55
56
  /**
package/src/index.ts CHANGED
@@ -11,6 +11,17 @@
11
11
  // import { AuthProvider } from '@spree/dashboard-core/providers/auth-provider'
12
12
  // import { useResourceMutation } from '@spree/dashboard-core/hooks/use-resource-mutation'
13
13
 
14
+ // Money helpers from @spree/core, offered here as before for plugin authors
15
+ export {
16
+ currencyParts,
17
+ fractionToPercent,
18
+ isPositiveMoney,
19
+ normalizeMoneyInput,
20
+ percentOf,
21
+ percentToFraction,
22
+ prorateMoney,
23
+ shiftDecimalPoint,
24
+ } from '@spree/core'
14
25
  export * from './api-client'
15
26
  // ---------------------------------------------------------------------------
16
27
  // Admin SDK client (Vite-aware singleton; reads VITE_SPREE_API_URL at build)
@@ -23,6 +34,7 @@ export * from './components/bulk-action-bar'
23
34
  export * from './components/can'
24
35
  export * from './components/country-combobox'
25
36
  export * from './components/country-state-fields'
37
+ export * from './components/currency-amounts-field'
26
38
  export * from './components/currency-select'
27
39
  export * from './components/export-button'
28
40
  export * from './components/file-upload-field'
@@ -77,11 +89,11 @@ export * from './hooks/use-switch-admin-locale'
77
89
  export * from './hooks/use-visual-viewport-offset'
78
90
  export * from './lib/cancel-order-form'
79
91
  export * from './lib/create-action-registry'
92
+ export * from './lib/currency-amounts'
80
93
  // ---------------------------------------------------------------------------
81
94
  // Registries — pluggable extension points (nav, route, slot, table,
82
95
  // settings-nav, form fields, custom field components)
83
96
  // ---------------------------------------------------------------------------
84
- export * from './lib/currency-parts'
85
97
  export * from './lib/custom-field-components'
86
98
  export * from './lib/date-presets'
87
99
  export * from './lib/delivery-form'
@@ -103,9 +115,7 @@ export * from './lib/i18n'
103
115
  export * from './lib/import-types'
104
116
  export * from './lib/iso-codes'
105
117
  export * from './lib/label-upload-form'
106
- export * from './lib/money'
107
118
  export * from './lib/nav-registry'
108
- export * from './lib/normalize-money'
109
119
  export * from './lib/permissions'
110
120
  export * from './lib/progress'
111
121
  export * from './lib/query-client'
@@ -115,6 +125,7 @@ export * from './lib/route-registry'
115
125
  export * from './lib/search-registry'
116
126
  export * from './lib/settings-nav-registry'
117
127
  export * from './lib/slot-registry'
128
+ export * from './lib/store-config'
118
129
  export * from './lib/table-registry'
119
130
  export { ensureTimestampColumns } from './lib/timestamp-columns'
120
131
  export * from './lib/type-labels'
@@ -125,12 +136,15 @@ export * from './pages/stock-locations'
125
136
  // Plugin facade — re-exported for convenience; same API as `/plugin` subpath
126
137
  // ---------------------------------------------------------------------------
127
138
  export * from './plugin'
139
+ export * from './products/assignable-variants'
140
+ export * from './products/digital-assets-card'
128
141
  export * from './products/normalize-customs'
129
142
  export * from './products/normalize-quantity'
130
143
  export * from './products/product-form-cards'
131
144
  export * from './products/product-form-mapping'
132
145
  export * from './products/product-schema'
133
146
  export * from './products/product-types'
147
+ export type { SheetSaveHandler } from './products/sheet-save-handlers'
134
148
  export * from './products/use-product-form-data'
135
149
  export * from './products/variants-matrix'
136
150
  export * from './products/video-url'
@@ -1,34 +1,2 @@
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
-
26
- /**
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.
31
- */
32
- export const ALL_CURRENCY_CODES: readonly CurrencyCode[] = Object.values(CurrencyCode).filter(
33
- (code) => !RETIRED_CURRENCY_CODES.has(code),
34
- )
1
+ /** @deprecated Import `ACTIVE_CURRENCY_CODES` from `@spree/core`. Removed in 6.1. */
2
+ export { ACTIVE_CURRENCY_CODES as ALL_CURRENCY_CODES } from '@spree/core'
@@ -0,0 +1,147 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ amountForCurrency,
4
+ applyCurrencyAmount,
5
+ hasCurrencyAmount,
6
+ listedAmounts,
7
+ } from './currency-amounts'
8
+
9
+ describe('listedAmounts', () => {
10
+ it('reads the legacy single amount as the store default currency', () => {
11
+ expect(listedAmounts({ amount: '5' }, 'USD')).toEqual([{ amount: '5', currency: 'USD' }])
12
+ })
13
+
14
+ it('attributes the legacy amount to its own currency preference', () => {
15
+ expect(listedAmounts({ amount: '7', currency: 'EUR' }, 'USD')).toEqual([
16
+ { amount: '7', currency: 'EUR' },
17
+ ])
18
+ })
19
+
20
+ it('reads a non-default currency from the amounts hash when the default is empty', () => {
21
+ expect(listedAmounts({ amounts: { EUR: '7' } }, 'USD')).toEqual([
22
+ { amount: '7', currency: 'EUR' },
23
+ ])
24
+ })
25
+
26
+ it('ignores the single amount once the hash holds an amount, as the server does', () => {
27
+ expect(listedAmounts({ amount: '0', amounts: { EUR: '11' } }, 'USD')).toEqual([
28
+ { amount: '11', currency: 'EUR' },
29
+ ])
30
+ })
31
+
32
+ it('keeps each hash amount against the currency it was entered in', () => {
33
+ expect(listedAmounts({ amounts: { GBP: '8', EUR: '7' } }, 'USD')).toEqual([
34
+ { amount: '7', currency: 'EUR' },
35
+ { amount: '8', currency: 'GBP' },
36
+ ])
37
+ })
38
+
39
+ it('lists the default-currency amount first', () => {
40
+ expect(listedAmounts({ amounts: { EUR: '7', USD: '5' } }, 'USD')).toEqual([
41
+ { amount: '5', currency: 'USD' },
42
+ { amount: '7', currency: 'EUR' },
43
+ ])
44
+ })
45
+
46
+ it('lets the amounts hash override the legacy amount for the same currency', () => {
47
+ expect(listedAmounts({ amount: '5', amounts: { USD: '6', EUR: '7' } }, 'USD')).toEqual([
48
+ { amount: '6', currency: 'USD' },
49
+ { amount: '7', currency: 'EUR' },
50
+ ])
51
+ })
52
+
53
+ it('ignores blank hash entries so an emptied currency is not offered', () => {
54
+ expect(listedAmounts({ amounts: { EUR: '', GBP: '8' } }, 'USD')).toEqual([
55
+ { amount: '8', currency: 'GBP' },
56
+ ])
57
+ })
58
+ })
59
+
60
+ describe('amountForCurrency', () => {
61
+ it('puts a pre-6.0 euro amount in the euro row, not the store default', () => {
62
+ const preferences = { amount: '7', currency: 'EUR' }
63
+ expect(amountForCurrency(preferences, 'EUR', 'USD')).toBe('7')
64
+ expect(amountForCurrency(preferences, 'USD', 'USD')).toBeNull()
65
+ })
66
+
67
+ it('reads only the amounts hash once it holds an amount, so the single amount is not a price', () => {
68
+ const preferences = { amount: '0', amounts: { EUR: '11' } }
69
+ expect(amountForCurrency(preferences, 'EUR', 'USD')).toBe('11')
70
+ expect(amountForCurrency(preferences, 'USD', 'USD')).toBeNull()
71
+ })
72
+
73
+ it('treats a blank currency preference as the store default', () => {
74
+ expect(amountForCurrency({ amount: '5' }, 'USD', 'USD')).toBe('5')
75
+ expect(amountForCurrency({ amount: '5' }, 'EUR', 'USD')).toBeNull()
76
+ })
77
+
78
+ it('trims surrounding space on the legacy currency preference', () => {
79
+ const preferences = { amount: '7', currency: ' EUR ' }
80
+ expect(amountForCurrency(preferences, 'EUR', 'USD')).toBe('7')
81
+ expect(amountForCurrency(preferences, 'USD', 'USD')).toBeNull()
82
+ })
83
+
84
+ it('reads a lowercase amounts-hash key as that currency', () => {
85
+ expect(amountForCurrency({ amounts: { eur: '7' } }, 'EUR', 'USD')).toBe('7')
86
+ })
87
+ })
88
+
89
+ describe('applyCurrencyAmount', () => {
90
+ it('moves a pre-6.0 euro price into the hash when the dollar row is edited', () => {
91
+ expect(applyCurrencyAmount({ amount: '7', currency: 'EUR' }, 'USD', '10', 'USD')).toEqual({
92
+ amount: null,
93
+ currency: 'EUR',
94
+ amounts: { EUR: '7', USD: '10' },
95
+ })
96
+ })
97
+
98
+ it('updates the named currency of a pre-6.0 method in the hash', () => {
99
+ expect(applyCurrencyAmount({ amount: '7', currency: 'EUR' }, 'EUR', '11', 'USD')).toEqual({
100
+ amount: null,
101
+ currency: 'EUR',
102
+ amounts: { EUR: '11' },
103
+ })
104
+ })
105
+
106
+ it('writes the store default into the hash like every other currency', () => {
107
+ expect(applyCurrencyAmount({ amount: '5', currency: 'USD' }, 'EUR', '11', 'USD')).toEqual({
108
+ amount: null,
109
+ currency: 'USD',
110
+ amounts: { USD: '5', EUR: '11' },
111
+ })
112
+ expect(applyCurrencyAmount({ amount: null }, 'USD', '9.99', 'USD')).toEqual({
113
+ amount: null,
114
+ amounts: { USD: '9.99' },
115
+ })
116
+ })
117
+
118
+ it('removes a cleared currency, the store default included, so the method is not offered in it', () => {
119
+ expect(applyCurrencyAmount({ amounts: { USD: '5', EUR: '7' } }, 'USD', '', 'USD')).toEqual({
120
+ amount: null,
121
+ amounts: { EUR: '7' },
122
+ })
123
+ })
124
+
125
+ it('updates and clears a lowercase stored hash key without leaving a stale entry', () => {
126
+ expect(applyCurrencyAmount({ amounts: { eur: '7' } }, 'EUR', '11', 'USD')).toEqual({
127
+ amount: null,
128
+ amounts: { EUR: '11' },
129
+ })
130
+ expect(applyCurrencyAmount({ amounts: { eur: '7' } }, 'EUR', '', 'USD')).toEqual({
131
+ amount: null,
132
+ amounts: {},
133
+ })
134
+ })
135
+ })
136
+
137
+ describe('hasCurrencyAmount', () => {
138
+ it('is true for an amount in the hash or a pre-6.0 single amount', () => {
139
+ expect(hasCurrencyAmount({ amounts: { EUR: '0' } })).toBe(true)
140
+ expect(hasCurrencyAmount({ amount: '5', amounts: {} })).toBe(true)
141
+ })
142
+
143
+ it('is false when no currency has an amount', () => {
144
+ expect(hasCurrencyAmount({ amount: null, amounts: { EUR: '' } })).toBe(false)
145
+ expect(hasCurrencyAmount({})).toBe(false)
146
+ })
147
+ })