@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.
- package/dist/vite/discover.js +5 -2
- package/package.json +13 -16
- 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 +18 -13
- package/src/components/file-upload-field.tsx +4 -4
- package/src/components/import-button.tsx +4 -1
- package/src/components/import-wizard-dialog.tsx +8 -11
- package/src/components/mobile-top-bar.tsx +6 -0
- package/src/components/money.tsx +23 -0
- package/src/components/preferences-form.tsx +263 -54
- package/src/components/resource-combobox.tsx +5 -0
- package/src/components/resource-picker-sheet.tsx +18 -12
- package/src/components/resource-table.tsx +7 -3
- package/src/components/table-toolbar.tsx +8 -5
- 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 +32 -13
- 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 +47 -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/permissions.ts +2 -0
- 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/settings-nav-registry.test.ts +34 -0
- package/src/lib/settings-nav-registry.ts +21 -4
- package/src/lib/slot-registry.ts +10 -0
- package/src/lib/storefront.ts +2 -2
- package/src/locales/ar.json +7 -2
- package/src/locales/de.json +7 -2
- package/src/locales/en.json +7 -2
- package/src/locales/es.json +7 -2
- package/src/locales/fr.json +7 -2
- package/src/locales/pl.json +7 -2
- package/src/locales/zh-CN.json +7 -2
- package/src/plugin.ts +5 -5
- package/src/products/bulk-variants-dialog.tsx +2 -8
- package/src/products/inventory-section.tsx +2 -2
- 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/products/variants-options-builder.tsx +30 -27
- package/src/products/variants-section.tsx +31 -25
- 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 +19 -2
- 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
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",
|
|
@@ -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",
|
package/src/locales/fr.json
CHANGED
|
@@ -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": {
|
package/src/locales/pl.json
CHANGED
|
@@ -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": {
|
package/src/locales/zh-CN.json
CHANGED
|
@@ -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
|
|
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
|
)
|
|
@@ -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<
|
|
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 {
|
|
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.
|
|
@@ -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?:
|
|
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?:
|
|
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?:
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
</
|
|
147
|
-
<
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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>
|