@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
@@ -1608,11 +1608,13 @@
1608
1608
  "seeded_report_read_only": "Este es un reporte incorporado y no se puede modificar. Guardá una copia.",
1609
1609
  "taken": "ya está en uso",
1610
1610
  "too_long": "es demasiado largo (máximo {{count}} caracteres)",
1611
+ "too_many_decimals": "tiene más de {{count}} decimales",
1611
1612
  "too_short": "es demasiado corto (mínimo {{count}} caracteres)",
1612
1613
  "wrong_length": "tiene un largo incorrecto (debería ser {{count}} caracteres)"
1613
1614
  },
1614
1615
  "country_required": "El país es obligatorio",
1615
1616
  "currency_required": "La moneda es obligatoria",
1617
+ "locale_required": "El idioma es obligatorio",
1616
1618
  "first_name_required": "El nombre es obligatorio",
1617
1619
  "invalid_email": "Ingresá un email válido",
1618
1620
  "invalid_email_or_password": "Email o contraseña inválidos",
@@ -1624,7 +1626,8 @@
1624
1626
  "passwords_dont_match": "Las contraseñas no coinciden",
1625
1627
  "positive_number": "{{field}} debe ser mayor que 0",
1626
1628
  "required": "{{field}} es obligatorio",
1627
- "store_name_required": "El nombre de la tienda es obligatorio"
1629
+ "store_name_required": "El nombre de la tienda es obligatorio",
1630
+ "too_many_decimals": "{{field}} puede tener como máximo {{count}} decimales"
1628
1631
  },
1629
1632
  "common": {
1630
1633
  "active": "Activo",
@@ -1662,7 +1665,9 @@
1662
1665
  "searching": "Buscando…",
1663
1666
  "select_placeholder": "Seleccionar…",
1664
1667
  "unsaved_changes": "Tenés cambios sin guardar.",
1665
- "yes": "Sí"
1668
+ "yes": "Sí",
1669
+ "done": "Listo",
1670
+ "error": "Algo salió mal."
1666
1671
  },
1667
1672
  "messages": {
1668
1673
  "address_removed": "Dirección eliminada",
@@ -183,7 +183,9 @@
183
183
  "searching": "Recherche…",
184
184
  "select_placeholder": "Sélectionner…",
185
185
  "unsaved_changes": "Vous avez des modifications non enregistrées.",
186
- "yes": "Oui"
186
+ "yes": "Oui",
187
+ "done": "Terminé",
188
+ "error": "Une erreur s'est produite."
187
189
  },
188
190
  "components": {
189
191
  "address_block": {
@@ -1463,11 +1465,13 @@
1463
1465
  "seeded_report_read_only": "Ce rapport est intégré et ne peut pas être modifié. Enregistrez plutôt une copie.",
1464
1466
  "taken": "est déjà utilisé",
1465
1467
  "too_long": "est trop long ({{count}} caractères maximum)",
1468
+ "too_many_decimals": "a plus de {{count}} décimales",
1466
1469
  "too_short": "est trop court ({{count}} caractères minimum)",
1467
1470
  "wrong_length": "n'a pas la bonne longueur ({{count}} caractères attendus)"
1468
1471
  },
1469
1472
  "country_required": "Le pays est requis",
1470
1473
  "currency_required": "La devise est requise",
1474
+ "locale_required": "La langue est requise",
1471
1475
  "first_name_required": "Le prénom est obligatoire",
1472
1476
  "invalid_email": "Saisissez une adresse e-mail valide",
1473
1477
  "invalid_email_or_password": "E-mail ou mot de passe invalide",
@@ -1479,7 +1483,8 @@
1479
1483
  "passwords_dont_match": "Les mots de passe ne correspondent pas",
1480
1484
  "positive_number": "{{field}} doit être supérieur à 0",
1481
1485
  "required": "{{field}} est obligatoire",
1482
- "store_name_required": "Le nom de la boutique est requis"
1486
+ "store_name_required": "Le nom de la boutique est requis",
1487
+ "too_many_decimals": "{{field}} peut avoir au plus {{count}} décimales"
1483
1488
  },
1484
1489
  "products": {
1485
1490
  "autocomplete": {
@@ -184,7 +184,9 @@
184
184
  "searching": "Wyszukiwanie…",
185
185
  "select_placeholder": "Wybierz…",
186
186
  "unsaved_changes": "Masz niezapisane zmiany.",
187
- "yes": "Tak"
187
+ "yes": "Tak",
188
+ "done": "Gotowe",
189
+ "error": "Coś poszło nie tak."
188
190
  },
189
191
  "components": {
190
192
  "address_block": {
@@ -1480,11 +1482,13 @@
1480
1482
  "seeded_report_read_only": "To raport wbudowany i nie można go zmienić. Zapisz zamiast tego kopię.",
1481
1483
  "taken": "jest już zajęte",
1482
1484
  "too_long": "jest za długie (maksymalnie {{count}} znaków)",
1485
+ "too_many_decimals": "ma więcej niż {{count}} miejsca po przecinku",
1483
1486
  "too_short": "jest za krótkie (minimalnie {{count}} znaków)",
1484
1487
  "wrong_length": "ma nieprawidłową długość (powinno mieć {{count}} znaków)"
1485
1488
  },
1486
1489
  "country_required": "Kraj jest wymagany",
1487
1490
  "currency_required": "Waluta jest wymagana",
1491
+ "locale_required": "Język jest wymagany",
1488
1492
  "first_name_required": "Imię jest wymagane",
1489
1493
  "invalid_email": "Wprowadź prawidłowy adres e-mail",
1490
1494
  "invalid_email_or_password": "Nieprawidłowy adres e-mail lub hasło",
@@ -1496,7 +1500,8 @@
1496
1500
  "passwords_dont_match": "Hasła nie są zgodne",
1497
1501
  "positive_number": "Pole {{field}} musi być większe niż 0",
1498
1502
  "required": "Pole {{field}} jest wymagane",
1499
- "store_name_required": "Nazwa sklepu jest wymagana"
1503
+ "store_name_required": "Nazwa sklepu jest wymagana",
1504
+ "too_many_decimals": "{{field}} może mieć najwyżej {{count}} miejsca po przecinku"
1500
1505
  },
1501
1506
  "products": {
1502
1507
  "autocomplete": {
@@ -183,7 +183,9 @@
183
183
  "searching": "搜索中…",
184
184
  "select_placeholder": "请选择…",
185
185
  "unsaved_changes": "您有未保存的更改。",
186
- "yes": "是"
186
+ "yes": "是",
187
+ "done": "完成",
188
+ "error": "出了点问题。"
187
189
  },
188
190
  "components": {
189
191
  "address_block": {
@@ -1463,11 +1465,13 @@
1463
1465
  "seeded_report_read_only": "这是内置报表,无法修改。请改为保存副本。",
1464
1466
  "taken": "已被占用",
1465
1467
  "too_long": "太长(最多 {{count}} 个字符)",
1468
+ "too_many_decimals": "超过 {{count}} 位小数",
1466
1469
  "too_short": "太短(最少 {{count}} 个字符)",
1467
1470
  "wrong_length": "长度不正确(应为 {{count}} 个字符)"
1468
1471
  },
1469
1472
  "country_required": "请选择国家/地区",
1470
1473
  "currency_required": "请选择货币",
1474
+ "locale_required": "请选择语言",
1471
1475
  "first_name_required": "名字为必填项",
1472
1476
  "invalid_email": "请输入有效的邮箱地址",
1473
1477
  "invalid_email_or_password": "邮箱或密码无效",
@@ -1479,7 +1483,8 @@
1479
1483
  "passwords_dont_match": "两次输入的密码不一致",
1480
1484
  "positive_number": "{{field}} 必须大于 0",
1481
1485
  "required": "{{field}} 为必填项",
1482
- "store_name_required": "商店名称为必填项"
1486
+ "store_name_required": "商店名称为必填项",
1487
+ "too_many_decimals": "{{field}}最多只能有 {{count}} 位小数"
1483
1488
  },
1484
1489
  "products": {
1485
1490
  "autocomplete": {
package/src/plugin.ts CHANGED
@@ -106,11 +106,11 @@ export interface DashboardPluginConfig {
106
106
  /** Table mutations keyed by table key (see `defineTable`). */
107
107
  tables?: Record<string, TableMutations>
108
108
  /**
109
- * Custom routes mounted under `/$storeId/`. Each entry's `path` is relative
110
- * (e.g. `/brands`, `/brands/$brandId`). The dashboard's catch-all route at
111
- * `/$storeId/*` dispatches based on the splat — your `component` renders
112
- * exactly like any first-party route, including TanStack Router's
113
- * `useParams()` for path params.
109
+ * Custom routes. By default mounted under `/$storeId/`, with a relative
110
+ * `path` (e.g. `/brands`, `/brands/$brandId`) — the dashboard's catch-all
111
+ * route at `/$storeId/*` dispatches based on the splat. Set `scope` to
112
+ * `'authenticated'` or `'public'` to mount a page at the root, outside any
113
+ * store.
114
114
  */
115
115
  routes?: RouteEntry[]
116
116
  /**
@@ -63,7 +63,7 @@ export function BulkVariantsDialog({ form, open, onOpenChange }: Props) {
63
63
  () =>
64
64
  countries.map((country) => ({
65
65
  value: country.iso,
66
- label: countryName({ iso: country.iso, iso3: country.iso3, name: country.name }),
66
+ label: countryName(country),
67
67
  })),
68
68
  [countries, countryName],
69
69
  )
@@ -215,13 +215,7 @@ export function BulkVariantsDialog({ form, open, onOpenChange }: Props) {
215
215
 
216
216
  return (
217
217
  <Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : handleCancel())} modal>
218
- <DialogContent
219
- // Edge-to-edge minus a 3-unit gutter, same overrides as the bulk
220
- // price dialog (see BulkPriceEditorDialog for why each is needed).
221
- className="!inset-3 !w-auto !max-w-none !translate-x-0 !translate-y-0 flex flex-col p-0"
222
- style={{ maxHeight: 'none' }}
223
- showCloseButton={false}
224
- >
218
+ <DialogContent fullScreen showCloseButton={false}>
225
219
  <DialogHeader className="flex flex-row items-center justify-between gap-3 space-y-0 border-b p-3">
226
220
  <DialogTitle className="truncate">
227
221
  {t('admin.products.variants.bulk_edit.title')}
@@ -2,13 +2,13 @@ import type { ProductFormValues, StockLevelFormValues } from '@spree/dashboard-c
2
2
  import { useStockLocations } from '@spree/dashboard-core'
3
3
  import {
4
4
  DataGrid,
5
+ type DataGridColumn,
5
6
  editableRowIndex,
6
7
  NumberCell,
7
8
  ReadOnlyCell,
8
9
  SwitchCell,
9
10
  } from '@spree/dashboard-ui'
10
11
  import { ExternalLinkIcon } from '@spree/dashboard-ui/icons'
11
- import type { ColumnDef } from '@tanstack/react-table'
12
12
  import { useCallback, useMemo } from 'react'
13
13
  import { type UseFormReturn, useWatch } from 'react-hook-form'
14
14
  import { useTranslation } from 'react-i18next'
@@ -135,7 +135,7 @@ export function InventorySection({ form, stockLocationHref }: InventorySectionPr
135
135
  [form],
136
136
  )
137
137
 
138
- const columns = useMemo<ColumnDef<InventoryRow>[]>(
138
+ const columns = useMemo<DataGridColumn<InventoryRow>[]>(
139
139
  () => [
140
140
  {
141
141
  id: 'location',
@@ -1,21 +1,20 @@
1
+ import type { CurrencyCode } from '@spree/admin-sdk'
1
2
  import type { ProductFormValues, VariantPriceFormValues } from '@spree/dashboard-core'
2
3
  import { type BulkPriceRow, BulkPriceTable } from '@spree/dashboard-ui'
3
4
  import { useCallback, useMemo } from 'react'
4
5
  import { type UseFormReturn, useWatch } from 'react-hook-form'
5
6
  import { useTranslation } from 'react-i18next'
7
+ import { useMoneyLocale } from '../hooks/use-money-locale'
8
+ import { currencyParts } from '../lib/currency-parts'
9
+ import { normalizeMoneyInput } from '../lib/normalize-money'
6
10
  import { composeOptionsText } from '../products/variants-matrix'
7
- import { currencyParts } from './currency-parts'
8
- import { normalizeMoneyInput } from './normalize-money'
9
- import {
10
- useFormCurrencyLocale as useCurrencyLocale,
11
- useFormOptionTypes as useOptionTypes,
12
- } from './use-product-form-data'
11
+ import { useFormOptionTypes as useOptionTypes } from './use-product-form-data'
13
12
 
14
13
  interface Props {
15
14
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
16
15
  form: UseFormReturn<ProductFormValues, any, any>
17
16
  /** ISO currency code (e.g. "USD", "EUR"). */
18
- currency: string
17
+ currency: CurrencyCode
19
18
  /** Display label for the product, used as the table's section header. */
20
19
  productName: string
21
20
  }
@@ -27,33 +26,23 @@ interface Props {
27
26
  * in form state the same way. No server fetch, no edit-tracking map: the
28
27
  * parent form's `isDirty` is the single source of truth for save gating.
29
28
  *
30
- * Amounts are STRINGS, not numbers. The merchant's raw input flows straight
31
- * through to the API; the backend's `Spree::LocalizedNumber.parse` handles
32
- * locale-aware parsing (comma decimals, grouped digits, etc.) so the
33
- * frontend doesn't have to reimplement it. Frontend coercion via `Number()`
34
- * would silently mangle inputs like `"1.234,56"` into `NaN` and drop the
35
- * price entirely.
29
+ * Amounts are STRINGS, not numbers: canonical `"1234.56"` in form state and
30
+ * on the wire, normalized from the admin's own number format as each cell
31
+ * commits. `Number()` coercion would lose precision and turn `"1.234,56"`
32
+ * into `NaN`.
36
33
  */
37
34
  export function ProductBulkPriceEditor({ form, currency, productName }: Props) {
38
35
  const { t } = useTranslation()
39
36
  const variants = useWatch({ control: form.control, name: 'variants' }) ?? []
40
37
  const { data: optionTypesData } = useOptionTypes()
41
38
  const optionTypes = useMemo(() => optionTypesData?.data ?? [], [optionTypesData])
42
- const localeForCurrency = useCurrencyLocale()
43
-
44
- // Format the grid in the currency's market locale (e.g. EUR → `de`, comma
45
- // decimal). The same locale normalizes the merchant's input back to canonical
46
- // form on commit (see `handleChange`), so form state — like the API value it
47
- // hydrates from — is ALWAYS canonical `"1234.56"`. Untouched prices therefore
48
- // never get re-normalized on save.
49
- //
50
- // Fall back to `en` (canonical period-decimal), NOT the UI language: money
51
- // formatting/parsing must never depend on the dashboard's language, or a USD
52
- // value under a German UI would be parsed as `40.00` → 4000.
53
- const marketLocale = localeForCurrency(currency) || 'en'
39
+ // Shown and read in the person's own number format (see useMoneyLocale). Form
40
+ // state, like the API value it hydrates from, is always canonical "1234.56",
41
+ // so an untouched price is never re-read on save.
42
+ const moneyLocale = useMoneyLocale()
54
43
  const { symbol, decimal } = useMemo(
55
- () => currencyParts(currency, marketLocale),
56
- [currency, marketLocale],
44
+ () => currencyParts(currency, moneyLocale),
45
+ [currency, moneyLocale],
57
46
  )
58
47
 
59
48
  // Project the form's variants into BulkPriceRow[] for the picked currency.
@@ -95,7 +84,7 @@ export function ProductBulkPriceEditor({ form, currency, productName }: Props) {
95
84
  // whitespace, OR malformed input (normalizes to `''`) all mean "no value"
96
85
  // — never persist `''` as a real amount.
97
86
  const trimmed = next == null ? '' : next.trim()
98
- const normalized = trimmed === '' ? '' : normalizeMoneyInput(trimmed, marketLocale)
87
+ const normalized = trimmed === '' ? '' : normalizeMoneyInput(trimmed, moneyLocale)
99
88
  const raw = normalized === '' ? null : normalized
100
89
 
101
90
  const nextPrices: VariantPriceFormValues[] = [...current]
@@ -132,7 +121,7 @@ export function ProductBulkPriceEditor({ form, currency, productName }: Props) {
132
121
  shouldDirty: true,
133
122
  })
134
123
  },
135
- [form, currency, marketLocale],
124
+ [form, currency, moneyLocale],
136
125
  )
137
126
 
138
127
  return (
@@ -13,6 +13,7 @@ import {
13
13
  useSortable,
14
14
  } from '@dnd-kit/sortable'
15
15
  import { CSS } from '@dnd-kit/utilities'
16
+ import type { CurrencyCode } from '@spree/admin-sdk'
16
17
  import type { MediaType, ProductFormValues } from '@spree/dashboard-core'
17
18
  import {
18
19
  getApiClient,
@@ -20,7 +21,7 @@ import {
20
21
  parseVideoUrl,
21
22
  ResourceMultiAutocomplete,
22
23
  TagCombobox,
23
- useDirectUpload,
24
+ useFileUpload,
24
25
  useOptionalStore,
25
26
  } from '@spree/dashboard-core'
26
27
  import {
@@ -198,8 +199,8 @@ export function PricesCard({
198
199
  * StoreProvider; a seller's tenant is a seller and the store is derived
199
200
  * server-side, so it passes the currencies its profile reports.
200
201
  */
201
- currencies?: string[]
202
- defaultCurrency?: string
202
+ currencies?: CurrencyCode[]
203
+ defaultCurrency?: CurrencyCode
203
204
  }) {
204
205
  const { t } = useTranslation()
205
206
  const store = useOptionalStore()
@@ -208,7 +209,7 @@ export function PricesCard({
208
209
  // Not `useState(defaultCurrency)` alone: the store (or the seller's profile)
209
210
  // arrives after the first render, so a state seeded from the fallback would
210
211
  // stick and price everything in the wrong currency.
211
- const [selectedCurrency, setCurrency] = useState<string | null>(null)
212
+ const [selectedCurrency, setCurrency] = useState<CurrencyCode | null>(null)
212
213
  const currency = selectedCurrency ?? defaultCurrency
213
214
 
214
215
  // Until the real default arrives there is no currency to price in, and an
@@ -224,7 +225,10 @@ export function PricesCard({
224
225
  {t('admin.common.prices')}
225
226
  </CardTitle>
226
227
  {currencies.length > 1 && (
227
- <Select value={currency} onValueChange={setCurrency}>
228
+ <Select
229
+ value={currency}
230
+ onValueChange={(next) => setCurrency(currencies.find((code) => code === next) ?? null)}
231
+ >
228
232
  <SelectTrigger size="sm" className="w-24">
229
233
  <SelectValue />
230
234
  </SelectTrigger>
@@ -296,7 +300,7 @@ export function MediaCard({
296
300
  form: UseFormReturn<ProductFormValues, any, any>
297
301
  }) {
298
302
  const { t } = useTranslation()
299
- const directUpload = useDirectUpload()
303
+ const fileUpload = useFileUpload()
300
304
  const deleteMedia = useDeleteProductMedia(productId ?? '')
301
305
  const confirm = useConfirm()
302
306
  const [pending, setPending] = useState<PendingUpload[]>([])
@@ -349,7 +353,7 @@ export function MediaCard({
349
353
  const preview = URL.createObjectURL(file)
350
354
  setPending((prev) => [...prev, { id: uploadId, file, preview, progress: 'uploading' }])
351
355
  try {
352
- const result = await directUpload.mutateAsync(file)
356
+ const result = await fileUpload.mutateAsync(file)
353
357
  const current = form.getValues('media') ?? []
354
358
  form.setValue(
355
359
  'media',
@@ -384,7 +388,7 @@ export function MediaCard({
384
388
  }
385
389
  }
386
390
  },
387
- [directUpload, form, t],
391
+ [fileUpload, form, t],
388
392
  )
389
393
 
390
394
  const handleDrop = useCallback(
@@ -46,17 +46,21 @@ export function variantToFormValues(variant: PanelVariant, position: number): Va
46
46
  tax_category_id: variant.tax_category_id ?? null,
47
47
  // Base prices only: this array is posted back as the variant's base
48
48
  // prices, so a price-list row taken in here returns as the shop price.
49
- prices: (variant.prices ?? [])
50
- .filter((p) => p.currency != null && p.price_list_id == null)
51
- .map((p) => ({
52
- currency: p.currency as string,
53
- // Keep amounts as the canonical decimal strings the API returns.
54
- // The bulk price editor displays them with the locale's decimal
55
- // separator and ships the raw user input unchanged on submit;
56
- // `Spree::LocalizedNumber.parse` handles locale-aware parsing.
57
- amount: p.amount != null ? String(p.amount) : '',
58
- compare_at_amount: p.compare_at_amount != null ? String(p.compare_at_amount) : null,
59
- })),
49
+ prices: (variant.prices ?? []).flatMap(
50
+ ({ currency, price_list_id, amount, compare_at_amount }) =>
51
+ currency != null && price_list_id == null
52
+ ? [
53
+ {
54
+ currency,
55
+ // Keep amounts as the canonical decimal strings the API returns;
56
+ // the price editor shows them in the admin's number format and
57
+ // normalizes edits back before they reach the form.
58
+ amount: amount != null ? String(amount) : '',
59
+ compare_at_amount: compare_at_amount != null ? String(compare_at_amount) : null,
60
+ },
61
+ ]
62
+ : [],
63
+ ),
60
64
  stock_levels: (variant.stock_levels ?? []).map((si) => ({
61
65
  id: si.id,
62
66
  stock_location_id: si.stock_location_id ?? si.stock_location?.id ?? '',
@@ -1,6 +1,7 @@
1
1
  import { requiredMessage } from '@spree/dashboard-ui'
2
2
  import i18n from 'i18next'
3
3
  import { z } from 'zod/v4'
4
+ import { countryCodeSchema, currencyCodeSchema } from '../lib/iso-codes'
4
5
  import { normalizeQuantityRule } from './normalize-quantity'
5
6
  import { isSupportedVideoUrl } from './video-url'
6
7
 
@@ -33,13 +34,10 @@ export const variantOptionPairSchema = z.object({
33
34
 
34
35
  export type VariantOptionPair = z.infer<typeof variantOptionPairSchema>
35
36
 
36
- // Form-side prices use raw STRING amounts (the merchant's typed input).
37
- // The backend's `Spree::LocalizedNumber.parse` handles locale-aware parsing
38
- // (comma decimals, grouped digits, etc.), so the frontend ships exactly what
39
- // the merchant typed — no `Number(...)` coercion that mangles `"1.234,56"`
40
- // into `NaN` and silently drops the price.
37
+ // Form-side prices are canonical decimal STRINGS ("1234.56"), never numbers:
38
+ // `Number(...)` coercion loses precision and turns `"1.234,56"` into `NaN`.
41
39
  export const variantPriceFormSchema = z.object({
42
- currency: z.string(),
40
+ currency: currencyCodeSchema,
43
41
  amount: z.string(),
44
42
  compare_at_amount: z.string().nullable().optional(),
45
43
  })
@@ -61,7 +59,7 @@ export const variantFormSchema = z.object({
61
59
  weight_unit: z.string().nullable().optional(),
62
60
  dimensions_unit: z.string().nullable().optional(),
63
61
  hs_code: z.string().nullable().optional(),
64
- country_of_origin: z.string().nullable().optional(),
62
+ country_of_origin: countryCodeSchema.nullable().optional(),
65
63
  customs_description: z.string().nullable().optional(),
66
64
  // Held as strings so an unusable entry can be reported rather than
67
65
  // silently coerced away: normalizing `0` straight to null would clear the
@@ -101,7 +99,7 @@ export const customFieldFormSchema = z.object({
101
99
  export type CustomFieldFormValues = z.infer<typeof customFieldFormSchema>
102
100
 
103
101
  // Media in form state. Persisted entries carry an `id`; pre-save entries carry
104
- // a `signed_id` (from a completed ActiveStorage direct upload) or a
102
+ // a `signed_id` (from a completed upload) or a
105
103
  // `source_media_id` (a file picked from the library). All can hold edits to
106
104
  // alt, position, and variant_ids that the parent product PATCH/POST ships
107
105
  // inline. previewUrl + uploadId are UI-only; stripped at submit.
@@ -1,3 +1,5 @@
1
+ import type { CountryCode, CurrencyCode } from '@spree/admin-sdk'
2
+
1
3
  /**
2
4
  * What the shared product form reads off a product, its variants and its
3
5
  * media.
@@ -42,12 +44,12 @@ export interface PanelVariant {
42
44
  price?: { amount?: string | null; currency?: string | null } | null
43
45
  prices?: Array<{
44
46
  amount?: string | null
45
- currency?: string | null
47
+ currency?: CurrencyCode | null
46
48
  compare_at_amount?: string | null
47
49
  price_list_id?: string | null
48
50
  }>
49
51
  cost_price?: string | null
50
- cost_currency?: string | null
52
+ cost_currency?: CurrencyCode | null
51
53
  weight?: number | null
52
54
  height?: number | null
53
55
  width?: number | null
@@ -55,7 +57,7 @@ export interface PanelVariant {
55
57
  weight_unit?: string | null
56
58
  dimensions_unit?: string | null
57
59
  hs_code?: string | null
58
- country_of_origin?: string | null
60
+ country_of_origin?: CountryCode | null
59
61
  customs_description?: string | null
60
62
  minimum_order_quantity?: number | null
61
63
  order_multiple?: number | null
@@ -2,7 +2,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
2
2
  import type {
3
3
  PanelCollection,
4
4
  PanelDeliveryProfile,
5
- PanelMarket,
6
5
  PanelNamedRecord,
7
6
  PanelOptionType,
8
7
  PanelPackageType,
@@ -244,30 +243,3 @@ export function collectionAutocompleteProps(queryKey: string) {
244
243
  getOptionLabel: (collection: PanelCollection) => collection.name ?? collection.id,
245
244
  }
246
245
  }
247
-
248
- /**
249
- * Resolves a currency to the locale its amounts are typed and shown in, so a
250
- * EUR field accepts `1.234,56` and normalizes it to `1234.56`. Mirrors the
251
- * operator dashboard's `useCurrencyLocale`, reading through the registered
252
- * client so the shared price editor works in either panel; a panel with no
253
- * markets answers undefined and callers fall back to the UI language.
254
- */
255
- export function useFormCurrencyLocale(): (currency: string | undefined) => string | undefined {
256
- const client = getApiClient()
257
-
258
- const { data } = useQuery({
259
- queryKey: useResourceKey('markets', 'panel-form'),
260
- queryFn: async (): Promise<{ data: PanelMarket[] }> =>
261
- (await client.markets?.list({ limit: 100 })) ?? { data: [] },
262
- enabled: Boolean(client.markets),
263
- staleTime: FIVE_MINUTES,
264
- })
265
-
266
- const markets = data?.data ?? []
267
-
268
- return (currency) => {
269
- if (!currency) return undefined
270
-
271
- return markets.find((market) => market.currency === currency)?.default_locale ?? undefined
272
- }
273
- }
@@ -4,6 +4,10 @@ import {
4
4
  Field,
5
5
  FieldLabel,
6
6
  Input,
7
+ Item,
8
+ ItemActions,
9
+ ItemContent,
10
+ ItemTitle,
7
11
  Select,
8
12
  SelectContent,
9
13
  SelectItem,
@@ -130,39 +134,38 @@ export function VariantsOptionsBuilder({ selected, onChange, extraOptionTypes }:
130
134
  )
131
135
  }
132
136
  return (
133
- <li
134
- key={ot.id}
135
- className="flex items-center gap-3 rounded-lg border border-border-subtle px-3 py-2"
136
- >
137
- <div className="min-w-0 flex-1">
138
- <div className="text-sm font-medium">{ot.label}</div>
139
- <div className="mt-1 flex flex-wrap gap-1">
137
+ <Item key={ot.id} variant="outline" render={<li />} className="border-border-subtle">
138
+ <ItemContent className="min-w-0">
139
+ <ItemTitle>{ot.label}</ItemTitle>
140
+ <div className="flex flex-wrap gap-1">
140
141
  {ot.values.map((v) => (
141
142
  <Badge key={v.name} variant="secondary">
142
143
  {v.label ?? v.name}
143
144
  </Badge>
144
145
  ))}
145
146
  </div>
146
- </div>
147
- <Button
148
- type="button"
149
- variant="ghost"
150
- size="icon-sm"
151
- aria-label={t('admin.actions.edit')}
152
- onClick={() => setEditingIndex(i)}
153
- >
154
- <PencilIcon />
155
- </Button>
156
- <Button
157
- type="button"
158
- variant="ghost"
159
- size="icon-sm"
160
- aria-label={t('admin.actions.remove')}
161
- onClick={() => removeAt(i)}
162
- >
163
- <XIcon />
164
- </Button>
165
- </li>
147
+ </ItemContent>
148
+ <ItemActions>
149
+ <Button
150
+ type="button"
151
+ variant="ghost"
152
+ size="icon-sm"
153
+ aria-label={t('admin.actions.edit')}
154
+ onClick={() => setEditingIndex(i)}
155
+ >
156
+ <PencilIcon />
157
+ </Button>
158
+ <Button
159
+ type="button"
160
+ variant="ghost"
161
+ size="icon-sm"
162
+ aria-label={t('admin.actions.remove')}
163
+ onClick={() => removeAt(i)}
164
+ >
165
+ <XIcon />
166
+ </Button>
167
+ </ItemActions>
168
+ </Item>
166
169
  )
167
170
  })}
168
171
  </ul>