@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.
- package/dist/vite/discover.js +9 -14
- package/package.json +3 -5
- package/src/api-client.ts +22 -34
- package/src/components/address-form-dialog.test.ts +4 -4
- package/src/components/address-form-dialog.tsx +10 -10
- package/src/components/app-shell.tsx +12 -1
- package/src/components/app-sidebar.tsx +18 -2
- package/src/components/country-combobox.tsx +52 -29
- package/src/components/currency-select.tsx +16 -10
- package/src/components/file-upload-field.tsx +4 -4
- package/src/components/import-button.tsx +4 -1
- package/src/components/mobile-top-bar.tsx +6 -0
- package/src/components/money.tsx +23 -0
- package/src/components/preferences-form.tsx +247 -52
- package/src/components/resource-table.tsx +7 -3
- package/src/hooks/use-display-name.ts +11 -3
- package/src/hooks/use-file-upload.ts +51 -0
- package/src/hooks/use-import.ts +5 -5
- package/src/hooks/use-money-locale.ts +14 -0
- package/src/index.ts +8 -3
- package/src/lib/currencies.ts +5 -2
- package/src/lib/form-mappers.ts +25 -5
- package/src/lib/formatters.ts +34 -4
- package/src/lib/fulfillment-items.test.ts +2 -1
- package/src/lib/fulfillment-items.ts +9 -3
- package/src/lib/i18n.ts +4 -4
- package/src/lib/iso-codes.ts +45 -0
- package/src/lib/money.test.ts +63 -0
- package/src/lib/money.ts +99 -0
- package/src/{products → lib}/normalize-money.ts +2 -2
- package/src/lib/return-refunds.test.ts +62 -0
- package/src/lib/return-refunds.ts +70 -0
- package/src/lib/route-registry.ts +58 -15
- package/src/lib/slot-registry.ts +10 -0
- package/src/lib/storefront.ts +2 -2
- package/src/locales/ar.json +4 -1
- package/src/locales/de.json +4 -1
- package/src/locales/en.json +4 -1
- package/src/locales/es.json +4 -1
- package/src/locales/fr.json +4 -1
- package/src/locales/pl.json +4 -1
- package/src/locales/zh-CN.json +4 -1
- package/src/plugin.ts +5 -5
- package/src/products/bulk-variants-dialog.tsx +1 -1
- package/src/products/product-bulk-price-editor.tsx +18 -29
- package/src/products/product-form-cards.tsx +12 -8
- package/src/products/product-form-mapping.ts +15 -11
- package/src/products/product-schema.ts +6 -8
- package/src/products/product-types.ts +5 -3
- package/src/products/use-product-form-data.ts +0 -28
- package/src/providers/store-provider.tsx +9 -9
- package/src/schemas/stock-location.ts +3 -2
- package/src/tables/package-types.tsx +1 -1
- package/src/tables/stock-locations.tsx +1 -1
- package/src/vite/discover.ts +33 -28
- package/src/hooks/use-direct-upload.ts +0 -117
- /package/src/{products → lib}/currency-parts.ts +0 -0
- /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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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
|
|
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,
|
|
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:
|
|
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 =
|
|
124
|
-
|
|
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(/^\/+/, '')
|
package/src/lib/slot-registry.ts
CHANGED
|
@@ -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
|
// ============================================================================
|
package/src/lib/storefront.ts
CHANGED
|
@@ -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 `
|
|
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?.
|
|
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}`
|
package/src/locales/ar.json
CHANGED
|
@@ -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/locales/de.json
CHANGED
|
@@ -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": {
|
package/src/locales/en.json
CHANGED
|
@@ -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": {
|
package/src/locales/es.json
CHANGED
|
@@ -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",
|
package/src/locales/fr.json
CHANGED
|
@@ -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": {
|
package/src/locales/pl.json
CHANGED
|
@@ -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": {
|
package/src/locales/zh-CN.json
CHANGED
|
@@ -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
|
|
110
|
-
* (e.g. `/brands`, `/brands/$brandId`)
|
|
111
|
-
* `/$storeId/*` dispatches based on the splat
|
|
112
|
-
*
|
|
113
|
-
*
|
|
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(
|
|
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 {
|
|
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:
|
|
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
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
//
|
|
45
|
-
|
|
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,
|
|
56
|
-
[currency,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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?:
|
|
202
|
-
defaultCurrency?:
|
|
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<
|
|
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
|
|
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
|
|
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
|
|
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
|
-
[
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
37
|
-
//
|
|
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:
|
|
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:
|
|
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
|
|
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.
|