@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
@@ -3,22 +3,34 @@ import { useSyncExternalStore } from 'react'
3
3
  import type { SubjectName } from './permissions'
4
4
 
5
5
  /**
6
- * Dashboard route contributed by a plugin. Mounted under the dashboard's
7
- * `/_authenticated/$storeId/_plugins/$` catch-all, which reads this registry
8
- * at render time and dispatches based on the splat path.
6
+ * Where a registry route mounts:
7
+ *
8
+ * - `store` (default) — inside a store, under `/$storeId`, with the store's
9
+ * sidebar and top bar.
10
+ * - `authenticated` — at the root, for signed-in users, outside any store
11
+ * (account or onboarding pages). Signed-out visitors go to the login page.
12
+ * - `public` — at the root, with no sign-in required (a sign-up or terms page).
13
+ */
14
+ export type RouteScope = 'store' | 'authenticated' | 'public'
15
+
16
+ /**
17
+ * Dashboard route contributed by a plugin. Store routes mount under the
18
+ * dashboard's `/_authenticated/$storeId/$` catch-all, which reads this
19
+ * registry at render time and dispatches based on the splat path.
9
20
  *
10
21
  * Path patterns use TanStack-Router-style param tokens (`$brandId`) inside the
11
22
  * plugin's namespace. The dashboard strips the `/$storeId/` prefix before
12
23
  * matching, so a plugin pattern of `/brands/$brandId` matches the URL
13
24
  * `/store_xyz/brands/br_abc123` with `params: { brandId: 'br_abc123' }`.
14
25
  */
15
- export interface RouteEntry {
26
+ export interface StoreRouteEntry {
16
27
  /**
17
28
  * Stable identifier — used for register/remove/update and as the React key.
18
29
  * Mirrors the slug a plugin would use in its `nav.add({ key })` call so the
19
30
  * sidebar entry and route stay paired.
20
31
  */
21
32
  key: string
33
+ scope?: 'store'
22
34
  /**
23
35
  * Path pattern relative to `/$storeId`. Examples: `/brands`,
24
36
  * `/brands/$brandId`, `/wishlists/$wishlistId/items`.
@@ -30,7 +42,7 @@ export interface RouteEntry {
30
42
  * The component to render. Receives:
31
43
  * - `params`: extracted path params (e.g. matching `/brands/$brandId`
32
44
  * against `/brands/br_abc` yields `{ brandId: 'br_abc' }`)
33
- * - `storeId`: the current store — every plugin route is implicitly scoped
45
+ * - `storeId`: the current store — every store route is implicitly scoped
34
46
  * under `/$storeId/...`
35
47
  * - `searchParams`: the URL search-state object from TanStack Router. Use
36
48
  * this with `<ResourceTable searchParams={searchParams} ... />` so
@@ -49,6 +61,27 @@ export interface RouteEntry {
49
61
  subject?: SubjectName
50
62
  }
51
63
 
64
+ /**
65
+ * Route mounted at the dashboard's root, outside any store. Unlike store
66
+ * routes these become real router routes when `createDashboardRouter` runs,
67
+ * so they must be registered before it — `plugins.ts` and installed plugins
68
+ * always are.
69
+ */
70
+ export interface RootRouteEntry {
71
+ key: string
72
+ scope: 'authenticated' | 'public'
73
+ /** Full URL path, e.g. `/sign-up` or `/account/$section`. Must start with `/`. */
74
+ path: string
75
+ component: ComponentType<{
76
+ params: Record<string, string>
77
+ searchParams: Record<string, unknown>
78
+ }>
79
+ }
80
+
81
+ export type RouteEntry = StoreRouteEntry | RootRouteEntry
82
+
83
+ const ROUTE_SCOPES: readonly RouteScope[] = ['store', 'authenticated', 'public']
84
+
52
85
  const entries: RouteEntry[] = []
53
86
  const listeners = new Set<() => void>()
54
87
 
@@ -63,10 +96,6 @@ function subscribe(listener: () => void): () => void {
63
96
  }
64
97
  }
65
98
 
66
- function getSnapshot(): readonly RouteEntry[] {
67
- return entries
68
- }
69
-
70
99
  interface RouteMutator {
71
100
  /** Register a route. Throws if `key` is already registered. */
72
101
  add(entry: RouteEntry): void
@@ -82,6 +111,11 @@ export const pluginRoutes: RouteMutator = {
82
111
  if (!entry.path.startsWith('/')) {
83
112
  throw new Error(`Plugin route "${entry.key}" path must start with "/", got "${entry.path}".`)
84
113
  }
114
+ if (entry.scope !== undefined && !ROUTE_SCOPES.includes(entry.scope)) {
115
+ throw new Error(
116
+ `Plugin route "${entry.key}" scope must be one of ${ROUTE_SCOPES.join(', ')}, got "${entry.scope}".`,
117
+ )
118
+ }
85
119
  entries.push(entry)
86
120
  notify()
87
121
  },
@@ -93,17 +127,22 @@ export const pluginRoutes: RouteMutator = {
93
127
  },
94
128
  }
95
129
 
130
+ /** Every registered route, read outside React. */
131
+ export function getPluginRoutes(): readonly RouteEntry[] {
132
+ return entries
133
+ }
134
+
96
135
  /**
97
136
  * Subscribe to plugin route updates. Returns the full list of registered
98
137
  * routes; the catch-all renderer matches against this on every navigation.
99
138
  */
100
139
  export function usePluginRoutes(): readonly RouteEntry[] {
101
- return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
140
+ return useSyncExternalStore(subscribe, getPluginRoutes, getPluginRoutes)
102
141
  }
103
142
 
104
143
  /**
105
144
  * Match a URL splat (the bit after `/$storeId/`) against the registered
106
- * routes. Returns the matched entry plus the extracted path params, or
145
+ * store routes. Returns the matched entry plus the extracted path params, or
107
146
  * `null` if nothing matched.
108
147
  *
109
148
  * Path patterns support TanStack-style `$param` tokens. A pattern segment
@@ -117,12 +156,12 @@ export function usePluginRoutes(): readonly RouteEntry[] {
117
156
  export function matchPluginRoute(
118
157
  splat: string,
119
158
  routes: readonly RouteEntry[],
120
- ): { entry: RouteEntry; params: Record<string, string> } | null {
159
+ ): { entry: StoreRouteEntry; params: Record<string, string> } | null {
121
160
  // Normalize: strip leading slash, drop empty trailing segments.
122
161
  const urlSegments = splat.replace(/^\/+/, '').split('/').filter(Boolean)
123
- const bySpecificity = [...routes].sort(
124
- (a, b) => countDynamicSegments(a.path) - countDynamicSegments(b.path),
125
- )
162
+ const bySpecificity = routes
163
+ .filter(isStoreRoute)
164
+ .sort((a, b) => countDynamicSegments(a.path) - countDynamicSegments(b.path))
126
165
  for (const entry of bySpecificity) {
127
166
  const patternSegments = entry.path.replace(/^\/+/, '').split('/').filter(Boolean)
128
167
  if (patternSegments.length !== urlSegments.length) continue
@@ -144,6 +183,10 @@ export function matchPluginRoute(
144
183
  return null
145
184
  }
146
185
 
186
+ function isStoreRoute(entry: RouteEntry): entry is StoreRouteEntry {
187
+ return entry.scope === undefined || entry.scope === 'store'
188
+ }
189
+
147
190
  function countDynamicSegments(path: string): number {
148
191
  return path
149
192
  .replace(/^\/+/, '')
@@ -44,6 +44,16 @@ export interface NoStoreAccessSlotContext {
44
44
  signOut: () => Promise<void>
45
45
  }
46
46
 
47
+ /**
48
+ * The page a signed-in user lands on at `/`, before entering a store. By
49
+ * default it opens their first store, or shows the `no_store_access` screen
50
+ * when they have none. Registering an entry replaces the whole page — for
51
+ * example with a list of stores to choose from and a way to create one.
52
+ */
53
+ export const STORE_SELECTION_SLOT = 'store_selection'
54
+
55
+ export type StoreSelectionSlotContext = NoStoreAccessSlotContext
56
+
47
57
  // ============================================================================
48
58
  // Registry — module-singleton keyed by slot name
49
59
  // ============================================================================
@@ -5,14 +5,14 @@ import type { Store } from '@spree/admin-sdk'
5
5
  * or `null` when no storefront URL is configured.
6
6
  *
7
7
  * The serialized `store.url` always resolves — the backend falls back to the
8
- * store's formatted URL — so gate on the explicit `preferred_storefront_url`
8
+ * store's formatted URL — so gate on the explicit `storefront_url`
9
9
  * preference: only return a link once a real storefront has been set.
10
10
  *
11
11
  * @param store the current store, or null/undefined while it loads
12
12
  * @returns an absolute URL, or null when the store has no configured storefront
13
13
  */
14
14
  export function storefrontHref(store: Store | null | undefined): string | null {
15
- if (!store?.preferred_storefront_url) return null
15
+ if (!store?.storefront_url) return null
16
16
 
17
17
  // Best-effort: prefix with https if the URL is just a hostname.
18
18
  return /^https?:\/\//.test(store.url) ? store.url : `https://${store.url}`
@@ -1463,11 +1463,13 @@
1463
1463
  "seeded_report_read_only": "هذا تقرير مدمج ولا يمكن تغييره. احفظ نسخة بدلاً من ذلك.",
1464
1464
  "taken": "مستخدم بالفعل",
1465
1465
  "too_long": "طويل جدًا (الحد الأقصى {{count}} حرفًا)",
1466
+ "too_many_decimals": "يحتوي على أكثر من {{count}} منازل عشرية",
1466
1467
  "too_short": "قصير جدًا (الحد الأدنى {{count}} حرفًا)",
1467
1468
  "wrong_length": "الطول غير صحيح (يجب أن يكون {{count}} حرفًا)"
1468
1469
  },
1469
1470
  "country_required": "البلد مطلوب",
1470
1471
  "currency_required": "العملة مطلوبة",
1472
+ "locale_required": "اللغة مطلوبة",
1471
1473
  "first_name_required": "الاسم الأول مطلوب",
1472
1474
  "invalid_email": "أدخل بريدًا إلكترونيًا صالحًا",
1473
1475
  "invalid_email_or_password": "بريد إلكتروني أو كلمة مرور غير صالحة",
@@ -1479,7 +1481,8 @@
1479
1481
  "passwords_dont_match": "كلمتا المرور غير متطابقتين",
1480
1482
  "positive_number": "يجب أن يكون {{field}} أكبر من 0",
1481
1483
  "required": "{{field}} مطلوب",
1482
- "store_name_required": "اسم المتجر مطلوب"
1484
+ "store_name_required": "اسم المتجر مطلوب",
1485
+ "too_many_decimals": "يمكن أن يحتوي {{field}} على {{count}} منازل عشرية كحد أقصى"
1483
1486
  },
1484
1487
  "products": {
1485
1488
  "autocomplete": {
@@ -1463,11 +1463,13 @@
1463
1463
  "seeded_report_read_only": "Dies ist ein integrierter Bericht und kann nicht geändert werden. Speichern Sie stattdessen eine Kopie.",
1464
1464
  "taken": "ist bereits vergeben",
1465
1465
  "too_long": "ist zu lang (maximal {{count}} Zeichen)",
1466
+ "too_many_decimals": "hat mehr als {{count}} Nachkommastellen",
1466
1467
  "too_short": "ist zu kurz (mindestens {{count}} Zeichen)",
1467
1468
  "wrong_length": "hat die falsche Länge (sollte {{count}} Zeichen haben)"
1468
1469
  },
1469
1470
  "country_required": "Land ist erforderlich",
1470
1471
  "currency_required": "Währung ist erforderlich",
1472
+ "locale_required": "Sprache ist erforderlich",
1471
1473
  "first_name_required": "Vorname ist erforderlich",
1472
1474
  "invalid_email": "Geben Sie eine gültige E-Mail-Adresse ein",
1473
1475
  "invalid_email_or_password": "Ungültige E-Mail-Adresse oder ungültiges Passwort",
@@ -1479,7 +1481,8 @@
1479
1481
  "passwords_dont_match": "Die Passwörter stimmen nicht überein",
1480
1482
  "positive_number": "{{field}} muss größer als 0 sein",
1481
1483
  "required": "{{field}} ist erforderlich",
1482
- "store_name_required": "Shopname ist erforderlich"
1484
+ "store_name_required": "Shopname ist erforderlich",
1485
+ "too_many_decimals": "{{field}} darf höchstens {{count}} Nachkommastellen haben"
1483
1486
  },
1484
1487
  "products": {
1485
1488
  "autocomplete": {
@@ -1465,11 +1465,13 @@
1465
1465
  "seeded_report_read_only": "This is a built-in report and cannot be changed. Save a copy instead.",
1466
1466
  "taken": "has already been taken",
1467
1467
  "too_long": "is too long (maximum is {{count}} characters)",
1468
+ "too_many_decimals": "has more than {{count}} decimal places",
1468
1469
  "too_short": "is too short (minimum is {{count}} characters)",
1469
1470
  "wrong_length": "is the wrong length (should be {{count}} characters)"
1470
1471
  },
1471
1472
  "country_required": "Country is required",
1472
1473
  "currency_required": "Currency is required",
1474
+ "locale_required": "Language is required",
1473
1475
  "first_name_required": "First name is required",
1474
1476
  "invalid_email": "Enter a valid email",
1475
1477
  "invalid_email_or_password": "Invalid email or password",
@@ -1481,7 +1483,8 @@
1481
1483
  "passwords_dont_match": "Passwords do not match",
1482
1484
  "positive_number": "{{field}} must be greater than 0",
1483
1485
  "required": "{{field}} is required",
1484
- "store_name_required": "Store name is required"
1486
+ "store_name_required": "Store name is required",
1487
+ "too_many_decimals": "{{field}} can have at most {{count}} decimal places"
1485
1488
  },
1486
1489
  "products": {
1487
1490
  "autocomplete": {
@@ -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",
@@ -1463,11 +1463,13 @@
1463
1463
  "seeded_report_read_only": "Ce rapport est intégré et ne peut pas être modifié. Enregistrez plutôt une copie.",
1464
1464
  "taken": "est déjà utilisé",
1465
1465
  "too_long": "est trop long ({{count}} caractères maximum)",
1466
+ "too_many_decimals": "a plus de {{count}} décimales",
1466
1467
  "too_short": "est trop court ({{count}} caractères minimum)",
1467
1468
  "wrong_length": "n'a pas la bonne longueur ({{count}} caractères attendus)"
1468
1469
  },
1469
1470
  "country_required": "Le pays est requis",
1470
1471
  "currency_required": "La devise est requise",
1472
+ "locale_required": "La langue est requise",
1471
1473
  "first_name_required": "Le prénom est obligatoire",
1472
1474
  "invalid_email": "Saisissez une adresse e-mail valide",
1473
1475
  "invalid_email_or_password": "E-mail ou mot de passe invalide",
@@ -1479,7 +1481,8 @@
1479
1481
  "passwords_dont_match": "Les mots de passe ne correspondent pas",
1480
1482
  "positive_number": "{{field}} doit être supérieur à 0",
1481
1483
  "required": "{{field}} est obligatoire",
1482
- "store_name_required": "Le nom de la boutique est requis"
1484
+ "store_name_required": "Le nom de la boutique est requis",
1485
+ "too_many_decimals": "{{field}} peut avoir au plus {{count}} décimales"
1483
1486
  },
1484
1487
  "products": {
1485
1488
  "autocomplete": {
@@ -1480,11 +1480,13 @@
1480
1480
  "seeded_report_read_only": "To raport wbudowany i nie można go zmienić. Zapisz zamiast tego kopię.",
1481
1481
  "taken": "jest już zajęte",
1482
1482
  "too_long": "jest za długie (maksymalnie {{count}} znaków)",
1483
+ "too_many_decimals": "ma więcej niż {{count}} miejsca po przecinku",
1483
1484
  "too_short": "jest za krótkie (minimalnie {{count}} znaków)",
1484
1485
  "wrong_length": "ma nieprawidłową długość (powinno mieć {{count}} znaków)"
1485
1486
  },
1486
1487
  "country_required": "Kraj jest wymagany",
1487
1488
  "currency_required": "Waluta jest wymagana",
1489
+ "locale_required": "Język jest wymagany",
1488
1490
  "first_name_required": "Imię jest wymagane",
1489
1491
  "invalid_email": "Wprowadź prawidłowy adres e-mail",
1490
1492
  "invalid_email_or_password": "Nieprawidłowy adres e-mail lub hasło",
@@ -1496,7 +1498,8 @@
1496
1498
  "passwords_dont_match": "Hasła nie są zgodne",
1497
1499
  "positive_number": "Pole {{field}} musi być większe niż 0",
1498
1500
  "required": "Pole {{field}} jest wymagane",
1499
- "store_name_required": "Nazwa sklepu jest wymagana"
1501
+ "store_name_required": "Nazwa sklepu jest wymagana",
1502
+ "too_many_decimals": "{{field}} może mieć najwyżej {{count}} miejsca po przecinku"
1500
1503
  },
1501
1504
  "products": {
1502
1505
  "autocomplete": {
@@ -1463,11 +1463,13 @@
1463
1463
  "seeded_report_read_only": "这是内置报表,无法修改。请改为保存副本。",
1464
1464
  "taken": "已被占用",
1465
1465
  "too_long": "太长(最多 {{count}} 个字符)",
1466
+ "too_many_decimals": "超过 {{count}} 位小数",
1466
1467
  "too_short": "太短(最少 {{count}} 个字符)",
1467
1468
  "wrong_length": "长度不正确(应为 {{count}} 个字符)"
1468
1469
  },
1469
1470
  "country_required": "请选择国家/地区",
1470
1471
  "currency_required": "请选择货币",
1472
+ "locale_required": "请选择语言",
1471
1473
  "first_name_required": "名字为必填项",
1472
1474
  "invalid_email": "请输入有效的邮箱地址",
1473
1475
  "invalid_email_or_password": "邮箱或密码无效",
@@ -1479,7 +1481,8 @@
1479
1481
  "passwords_dont_match": "两次输入的密码不一致",
1480
1482
  "positive_number": "{{field}} 必须大于 0",
1481
1483
  "required": "{{field}} 为必填项",
1482
- "store_name_required": "商店名称为必填项"
1484
+ "store_name_required": "商店名称为必填项",
1485
+ "too_many_decimals": "{{field}}最多只能有 {{count}} 位小数"
1483
1486
  },
1484
1487
  "products": {
1485
1488
  "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
  )
@@ -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.