@spree/dashboard-core 1.0.0-beta.3 → 1.0.0-beta.5
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/package.json +7 -5
- package/src/api-client.ts +43 -0
- package/src/components/address-form-dialog.tsx +5 -2
- package/src/components/app-sidebar.tsx +16 -2
- package/src/components/currency-select.tsx +1 -15
- package/src/components/nav-main.tsx +50 -37
- package/src/components/sheet-top-bar.tsx +1 -1
- package/src/components/table-toolbar.tsx +9 -63
- package/src/components/top-bar-breadcrumbs.tsx +9 -2
- package/src/hooks/use-switch-admin-locale.ts +11 -2
- package/src/lib/currencies.ts +15 -0
- package/src/lib/form-mappers.ts +40 -0
- package/src/lib/fulfillment-items.test.ts +29 -0
- package/src/lib/fulfillment-items.ts +35 -17
- package/src/lib/i18n.ts +79 -4
- package/src/lib/permissions.ts +1 -1
- package/src/lib/settings-nav-registry.ts +27 -0
- package/src/lib/slot-registry.ts +18 -0
- package/src/lib/table-registry.ts +2 -2
- package/src/locales/ar.json +32 -2
- package/src/locales/de.json +32 -2
- package/src/locales/en.json +32 -2
- package/src/locales/es.json +2030 -0
- package/src/locales/fr.json +32 -2
- package/src/locales/pl.json +32 -2
- package/src/locales/zh-CN.json +32 -2
- package/src/pages/package-types.tsx +19 -17
- package/src/products/normalize-money.test.ts +9 -0
- package/src/products/normalize-money.ts +12 -0
- package/src/products/product-form-cards.tsx +40 -16
- package/src/products/product-form-mapping.test.ts +72 -0
- package/src/products/product-form-mapping.ts +3 -1
- package/src/products/product-types.ts +1 -0
- package/src/products/variants-section.tsx +5 -2
- package/src/providers/auth-provider.tsx +3 -3
- package/src/schemas/package-type.ts +7 -4
package/src/locales/fr.json
CHANGED
|
@@ -125,6 +125,11 @@
|
|
|
125
125
|
"title": "Bon retour"
|
|
126
126
|
},
|
|
127
127
|
"logout": "Se déconnecter",
|
|
128
|
+
"no_store_access": {
|
|
129
|
+
"description": "Vous êtes connecté, mais votre compte n'est encore membre d'aucune boutique. Demandez à un administrateur de boutique de vous inviter, ou connectez-vous avec un autre compte.",
|
|
130
|
+
"signed_in_as": "Connecté en tant que {{email}}",
|
|
131
|
+
"title": "Vous n'avez accès à aucune boutique"
|
|
132
|
+
},
|
|
128
133
|
"reset_password": {
|
|
129
134
|
"confirm_password_label": "Confirmer le nouveau mot de passe",
|
|
130
135
|
"error": "Une erreur s'est produite. Veuillez réessayer.",
|
|
@@ -429,7 +434,6 @@
|
|
|
429
434
|
"filter_select_placeholder": "Sélectionner…",
|
|
430
435
|
"filter_text_placeholder": "Filtrer…",
|
|
431
436
|
"filter_to_placeholder": "Filtrer sur…",
|
|
432
|
-
"more_conditions": "Autres conditions",
|
|
433
437
|
"operators": {
|
|
434
438
|
"after": "après",
|
|
435
439
|
"before": "avant",
|
|
@@ -686,6 +690,32 @@
|
|
|
686
690
|
"quantity": {
|
|
687
691
|
"label": "Quantité"
|
|
688
692
|
},
|
|
693
|
+
"setup": {
|
|
694
|
+
"country_code": {
|
|
695
|
+
"help": "D'où vous expédiez. Votre boutique peut vendre dans le monde entier — ceci définit seulement la devise, l'entrepôt et les zones de livraison de départ, tous modifiables ensuite.",
|
|
696
|
+
"label": "Pays",
|
|
697
|
+
"placeholder": "Rechercher un pays"
|
|
698
|
+
},
|
|
699
|
+
"currency": {
|
|
700
|
+
"help": "Votre devise par défaut. Ajoutez-en d’autres quand vous voulez.",
|
|
701
|
+
"label": "Devise",
|
|
702
|
+
"placeholder": "Rechercher une devise"
|
|
703
|
+
},
|
|
704
|
+
"locale": {
|
|
705
|
+
"help": "Votre langue par défaut. Ajoutez-en d’autres quand vous voulez.",
|
|
706
|
+
"label": "Langue"
|
|
707
|
+
},
|
|
708
|
+
"password_confirmation": {
|
|
709
|
+
"label": "Confirmer le mot de passe"
|
|
710
|
+
},
|
|
711
|
+
"sample_data": {
|
|
712
|
+
"help": "Ajoute en arrière-plan des produits, catégories, clients et commandes de démonstration pour explorer le tableau de bord. Le chargement prend quelques minutes et télécharge les images des produits.",
|
|
713
|
+
"label": "Charger des données d’exemple"
|
|
714
|
+
},
|
|
715
|
+
"store_name": {
|
|
716
|
+
"label": "Nom de la boutique"
|
|
717
|
+
}
|
|
718
|
+
},
|
|
689
719
|
"shipping": {
|
|
690
720
|
"label": "Livraison"
|
|
691
721
|
},
|
|
@@ -1882,7 +1912,7 @@
|
|
|
1882
1912
|
},
|
|
1883
1913
|
"create_line_items": {
|
|
1884
1914
|
"name": "Créer des lignes d'article",
|
|
1885
|
-
"description": "Ajoute au panier la quantité spécifiée de la variante"
|
|
1915
|
+
"description": "Ajoute gratuitement au panier la quantité spécifiée de la variante"
|
|
1886
1916
|
},
|
|
1887
1917
|
"free_shipping": {
|
|
1888
1918
|
"name": "Livraison gratuite",
|
package/src/locales/pl.json
CHANGED
|
@@ -125,6 +125,11 @@
|
|
|
125
125
|
"title": "Witaj ponownie"
|
|
126
126
|
},
|
|
127
127
|
"logout": "Wyloguj się",
|
|
128
|
+
"no_store_access": {
|
|
129
|
+
"description": "Jesteś zalogowany, ale Twoje konto nie należy jeszcze do żadnego sklepu. Poproś administratora sklepu o zaproszenie lub zaloguj się na inne konto.",
|
|
130
|
+
"signed_in_as": "Zalogowano jako {{email}}",
|
|
131
|
+
"title": "Nie masz dostępu do żadnego sklepu"
|
|
132
|
+
},
|
|
128
133
|
"reset_password": {
|
|
129
134
|
"confirm_password_label": "Potwierdź nowe hasło",
|
|
130
135
|
"error": "Coś poszło nie tak. Spróbuj ponownie.",
|
|
@@ -430,7 +435,6 @@
|
|
|
430
435
|
"filter_select_placeholder": "Wybierz…",
|
|
431
436
|
"filter_text_placeholder": "Filtruj…",
|
|
432
437
|
"filter_to_placeholder": "Filtruj do…",
|
|
433
|
-
"more_conditions": "Więcej warunków",
|
|
434
438
|
"operators": {
|
|
435
439
|
"after": "po",
|
|
436
440
|
"before": "przed",
|
|
@@ -687,6 +691,32 @@
|
|
|
687
691
|
"quantity": {
|
|
688
692
|
"label": "Ilość"
|
|
689
693
|
},
|
|
694
|
+
"setup": {
|
|
695
|
+
"country_code": {
|
|
696
|
+
"help": "Skąd wysyłasz. Twój sklep może sprzedawać na całym świecie — to ustala tylko początkową walutę, magazyn i strefy dostawy, które później zmienisz.",
|
|
697
|
+
"label": "Kraj",
|
|
698
|
+
"placeholder": "Szukaj krajów"
|
|
699
|
+
},
|
|
700
|
+
"currency": {
|
|
701
|
+
"help": "Twoja domyślna waluta. Kolejne dodasz w każdej chwili.",
|
|
702
|
+
"label": "Waluta",
|
|
703
|
+
"placeholder": "Szukaj walut"
|
|
704
|
+
},
|
|
705
|
+
"locale": {
|
|
706
|
+
"help": "Twój domyślny język. Kolejne dodasz w każdej chwili.",
|
|
707
|
+
"label": "Język"
|
|
708
|
+
},
|
|
709
|
+
"password_confirmation": {
|
|
710
|
+
"label": "Potwierdź hasło"
|
|
711
|
+
},
|
|
712
|
+
"sample_data": {
|
|
713
|
+
"help": "Dodaje w tle przykładowe produkty, kategorie, klientów i zamówienia, aby można było poznać panel. Trwa kilka minut i pobiera zdjęcia produktów.",
|
|
714
|
+
"label": "Wczytaj dane przykładowe"
|
|
715
|
+
},
|
|
716
|
+
"store_name": {
|
|
717
|
+
"label": "Nazwa sklepu"
|
|
718
|
+
}
|
|
719
|
+
},
|
|
690
720
|
"shipping": {
|
|
691
721
|
"label": "Wysyłka"
|
|
692
722
|
},
|
|
@@ -1911,7 +1941,7 @@
|
|
|
1911
1941
|
},
|
|
1912
1942
|
"create_line_items": {
|
|
1913
1943
|
"name": "Utwórz pozycje zamówienia",
|
|
1914
|
-
"description": "Dodaje do koszyka określoną ilość wariantu"
|
|
1944
|
+
"description": "Dodaje do koszyka określoną ilość wariantu za darmo"
|
|
1915
1945
|
},
|
|
1916
1946
|
"free_shipping": {
|
|
1917
1947
|
"name": "Darmowa wysyłka",
|
package/src/locales/zh-CN.json
CHANGED
|
@@ -125,6 +125,11 @@
|
|
|
125
125
|
"title": "欢迎回来"
|
|
126
126
|
},
|
|
127
127
|
"logout": "退出登录",
|
|
128
|
+
"no_store_access": {
|
|
129
|
+
"description": "您已登录,但您的账户尚未加入任何店铺。请联系店铺管理员邀请您,或使用其他账户登录。",
|
|
130
|
+
"signed_in_as": "当前登录账户:{{email}}",
|
|
131
|
+
"title": "您无权访问任何店铺"
|
|
132
|
+
},
|
|
128
133
|
"reset_password": {
|
|
129
134
|
"confirm_password_label": "确认新密码",
|
|
130
135
|
"error": "出了点问题,请重试。",
|
|
@@ -429,7 +434,6 @@
|
|
|
429
434
|
"filter_select_placeholder": "选择…",
|
|
430
435
|
"filter_text_placeholder": "筛选…",
|
|
431
436
|
"filter_to_placeholder": "筛选至…",
|
|
432
|
-
"more_conditions": "更多条件",
|
|
433
437
|
"operators": {
|
|
434
438
|
"after": "晚于",
|
|
435
439
|
"before": "早于",
|
|
@@ -686,6 +690,32 @@
|
|
|
686
690
|
"quantity": {
|
|
687
691
|
"label": "数量"
|
|
688
692
|
},
|
|
693
|
+
"setup": {
|
|
694
|
+
"country_code": {
|
|
695
|
+
"help": "您的发货地。您的商店可以面向全球销售——这只设置初始货币、仓库和配送区域,之后均可修改。",
|
|
696
|
+
"label": "国家/地区",
|
|
697
|
+
"placeholder": "搜索国家/地区"
|
|
698
|
+
},
|
|
699
|
+
"currency": {
|
|
700
|
+
"help": "您的默认货币。随时可添加更多。",
|
|
701
|
+
"label": "货币",
|
|
702
|
+
"placeholder": "搜索货币"
|
|
703
|
+
},
|
|
704
|
+
"locale": {
|
|
705
|
+
"help": "您的默认语言。随时可添加更多。",
|
|
706
|
+
"label": "语言"
|
|
707
|
+
},
|
|
708
|
+
"password_confirmation": {
|
|
709
|
+
"label": "确认密码"
|
|
710
|
+
},
|
|
711
|
+
"sample_data": {
|
|
712
|
+
"help": "在后台添加演示商品、分类、客户和订单,方便您浏览后台。需要几分钟并会下载商品图片。",
|
|
713
|
+
"label": "加载示例数据"
|
|
714
|
+
},
|
|
715
|
+
"store_name": {
|
|
716
|
+
"label": "商店名称"
|
|
717
|
+
}
|
|
718
|
+
},
|
|
689
719
|
"shipping": {
|
|
690
720
|
"label": "运费"
|
|
691
721
|
},
|
|
@@ -1881,7 +1911,7 @@
|
|
|
1881
1911
|
},
|
|
1882
1912
|
"create_line_items": {
|
|
1883
1913
|
"name": "创建订单项",
|
|
1884
|
-
"description": "
|
|
1914
|
+
"description": "将指定数量的变体免费加入购物车"
|
|
1885
1915
|
},
|
|
1886
1916
|
"free_shipping": {
|
|
1887
1917
|
"name": "免运费",
|
|
@@ -461,23 +461,25 @@ function UnitField({
|
|
|
461
461
|
<Controller
|
|
462
462
|
control={form.control}
|
|
463
463
|
name={name}
|
|
464
|
-
render={({ field }) =>
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
464
|
+
render={({ field }) => (
|
|
465
|
+
<Select
|
|
466
|
+
value={field.value ?? ''}
|
|
467
|
+
onValueChange={(value) => field.onChange(value || undefined)}
|
|
468
|
+
>
|
|
469
|
+
<SelectTrigger id={`package-type-${name}`}>
|
|
470
|
+
{/* Nothing picked reads as an em dash rather than a blank
|
|
471
|
+
trigger, the way the variant editor's units do. */}
|
|
472
|
+
<SelectValue>{(value) => (value as string) || '—'}</SelectValue>
|
|
473
|
+
</SelectTrigger>
|
|
474
|
+
<SelectContent>
|
|
475
|
+
{units.map((unit) => (
|
|
476
|
+
<SelectItem key={unit} value={unit}>
|
|
477
|
+
{unit}
|
|
478
|
+
</SelectItem>
|
|
479
|
+
))}
|
|
480
|
+
</SelectContent>
|
|
481
|
+
</Select>
|
|
482
|
+
)}
|
|
481
483
|
/>
|
|
482
484
|
</Field>
|
|
483
485
|
)
|
|
@@ -14,6 +14,15 @@ describe('normalizeMoneyInput', () => {
|
|
|
14
14
|
expect(normalizeMoneyInput('1.000.000,00', 'de')).toBe('1000000.00')
|
|
15
15
|
})
|
|
16
16
|
|
|
17
|
+
it('reads a lone group separator not followed by three digits as the decimal', () => {
|
|
18
|
+
expect(normalizeMoneyInput('19.50', 'de')).toBe('19.50')
|
|
19
|
+
expect(normalizeMoneyInput('0.5', 'de')).toBe('0.5')
|
|
20
|
+
expect(normalizeMoneyInput('19.50 €', 'de')).toBe('19.50')
|
|
21
|
+
expect(normalizeMoneyInput('19,50', 'en')).toBe('19.50')
|
|
22
|
+
expect(normalizeMoneyInput('1.500 €', 'de')).toBe('1500')
|
|
23
|
+
expect(normalizeMoneyInput('1.234.567', 'de')).toBe('1234567')
|
|
24
|
+
})
|
|
25
|
+
|
|
17
26
|
it('handles fr narrow-space grouping', () => {
|
|
18
27
|
// fr groups with U+202F (narrow no-break space) and uses a comma decimal.
|
|
19
28
|
expect(normalizeMoneyInput('1 234,56', 'fr')).toBe('1234.56')
|
|
@@ -30,6 +30,7 @@ function separatorsFor(locale: string): { decimal: string; group: string } {
|
|
|
30
30
|
* - `"1.234,56"` under `de` → `"1234.56"`
|
|
31
31
|
* - `"1 234,56"` under `fr` → `"1234.56"` (narrow-space grouping)
|
|
32
32
|
* - `"19.99"` under `en` → `"19.99"`
|
|
33
|
+
* - `"19.50"` under `de` → `"19.50"` (a period can't be grouping before two digits)
|
|
33
34
|
* - `""` / whitespace → `""`
|
|
34
35
|
*
|
|
35
36
|
* Strips the locale's group separator and any whitespace, standardizes the
|
|
@@ -48,6 +49,17 @@ export function normalizeMoneyInput(raw: string | null | undefined, locale: stri
|
|
|
48
49
|
const { decimal, group } = separatorsFor(locale)
|
|
49
50
|
|
|
50
51
|
let out = trimmed
|
|
52
|
+
// A group separator is always followed by exactly three digits, so a lone
|
|
53
|
+
// one that is not (`19.50` under `de`) is the merchant typing the other
|
|
54
|
+
// decimal mark. `1.500` stays ambiguous and is read the locale's way.
|
|
55
|
+
const groupParts = group.trim() === '' ? [] : out.split(group)
|
|
56
|
+
if (
|
|
57
|
+
groupParts.length === 2 &&
|
|
58
|
+
!out.includes(decimal) &&
|
|
59
|
+
groupParts[1].replace(/\D/g, '').length !== 3
|
|
60
|
+
) {
|
|
61
|
+
out = groupParts.join(decimal)
|
|
62
|
+
}
|
|
51
63
|
// Remove the locale's grouping separator (explicit char) plus any whitespace
|
|
52
64
|
// (covers narrow/no-break spaces used by some locales as the group char).
|
|
53
65
|
if (group) out = out.split(group).join('')
|
|
@@ -52,14 +52,21 @@ import {
|
|
|
52
52
|
useConfirm,
|
|
53
53
|
} from '@spree/dashboard-ui'
|
|
54
54
|
import {
|
|
55
|
+
BanknoteIcon,
|
|
55
56
|
DownloadIcon,
|
|
57
|
+
EyeIcon,
|
|
56
58
|
FilmIcon,
|
|
59
|
+
FolderTreeIcon,
|
|
60
|
+
ImageIcon,
|
|
57
61
|
ImagePlusIcon,
|
|
58
62
|
LibraryIcon,
|
|
59
63
|
Loader2Icon,
|
|
60
64
|
PencilIcon,
|
|
61
65
|
PlayIcon,
|
|
66
|
+
ReceiptTextIcon,
|
|
67
|
+
SearchIcon,
|
|
62
68
|
TrashIcon,
|
|
69
|
+
WarehouseIcon,
|
|
63
70
|
} from '@spree/dashboard-ui/icons'
|
|
64
71
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
65
72
|
import { type Control, Controller, type UseFormReturn, useWatch } from 'react-hook-form'
|
|
@@ -118,9 +125,6 @@ export function GeneralCard({
|
|
|
118
125
|
const { errors } = form.formState
|
|
119
126
|
return (
|
|
120
127
|
<Card>
|
|
121
|
-
<CardHeader>
|
|
122
|
-
<CardTitle>{t('admin.pages.products.section_basics')}</CardTitle>
|
|
123
|
-
</CardHeader>
|
|
124
128
|
<CardContent className="flex flex-col gap-4">
|
|
125
129
|
<Field>
|
|
126
130
|
<FieldLabel htmlFor="product-name">{t('admin.fields.name.label')}</FieldLabel>
|
|
@@ -204,7 +208,10 @@ export function PricesCard({
|
|
|
204
208
|
return (
|
|
205
209
|
<Card>
|
|
206
210
|
<CardHeader className="flex flex-row items-center justify-between gap-3 space-y-0">
|
|
207
|
-
<CardTitle>
|
|
211
|
+
<CardTitle>
|
|
212
|
+
<BanknoteIcon className="size-4" />
|
|
213
|
+
{t('admin.common.prices')}
|
|
214
|
+
</CardTitle>
|
|
208
215
|
{currencies.length > 1 && (
|
|
209
216
|
<Select value={currency} onValueChange={setCurrency}>
|
|
210
217
|
<SelectTrigger size="sm" className="w-24">
|
|
@@ -220,12 +227,11 @@ export function PricesCard({
|
|
|
220
227
|
</Select>
|
|
221
228
|
)}
|
|
222
229
|
</CardHeader>
|
|
223
|
-
|
|
224
|
-
cell borders to the card's edges, and padding here insets it into a
|
|
225
|
-
floating block with a gutter on all four sides. */}
|
|
226
|
-
<CardContent className="p-0">
|
|
230
|
+
<CardContent>
|
|
227
231
|
{currencyResolved ? (
|
|
228
|
-
<
|
|
232
|
+
<div className="border rounded overflow-hidden">
|
|
233
|
+
<ProductBulkPriceEditor form={form} currency={currency} productName={productName} />
|
|
234
|
+
</div>
|
|
229
235
|
) : (
|
|
230
236
|
<div className="m-3 h-24 animate-pulse rounded-md bg-muted" />
|
|
231
237
|
)}
|
|
@@ -504,7 +510,10 @@ export function MediaCard({
|
|
|
504
510
|
<>
|
|
505
511
|
<Card className="scroll-mt-[calc(var(--spacing-header-height)+1.5rem)]">
|
|
506
512
|
<CardHeader>
|
|
507
|
-
<CardTitle>
|
|
513
|
+
<CardTitle>
|
|
514
|
+
<ImageIcon className="size-4" />
|
|
515
|
+
{t('admin.pages.products.section_media')}
|
|
516
|
+
</CardTitle>
|
|
508
517
|
</CardHeader>
|
|
509
518
|
<CardContent className="flex flex-col gap-4">
|
|
510
519
|
{(items.length > 0 || pending.length > 0) && (
|
|
@@ -771,11 +780,16 @@ export function InventoryCard({
|
|
|
771
780
|
return (
|
|
772
781
|
<Card>
|
|
773
782
|
<CardHeader>
|
|
774
|
-
<CardTitle>
|
|
783
|
+
<CardTitle>
|
|
784
|
+
<WarehouseIcon className="size-4" />
|
|
785
|
+
{t('admin.pages.products.section_inventory')}
|
|
786
|
+
</CardTitle>
|
|
775
787
|
{actions && <CardAction>{actions}</CardAction>}
|
|
776
788
|
</CardHeader>
|
|
777
|
-
<CardContent
|
|
778
|
-
<
|
|
789
|
+
<CardContent>
|
|
790
|
+
<div className="border rounded overflow-hidden">
|
|
791
|
+
<InventorySection form={form} stockLocationHref={stockLocationHref} />
|
|
792
|
+
</div>
|
|
779
793
|
</CardContent>
|
|
780
794
|
</Card>
|
|
781
795
|
)
|
|
@@ -804,7 +818,10 @@ export function SEOCard({ form, product }: FormCardProps & { product?: Product }
|
|
|
804
818
|
return (
|
|
805
819
|
<Card>
|
|
806
820
|
<CardHeader>
|
|
807
|
-
<CardTitle>
|
|
821
|
+
<CardTitle>
|
|
822
|
+
<SearchIcon className="size-4" />
|
|
823
|
+
{t('admin.pages.products.section_seo')}
|
|
824
|
+
</CardTitle>
|
|
808
825
|
</CardHeader>
|
|
809
826
|
<CardContent className="flex flex-col gap-4">
|
|
810
827
|
<div className="rounded-lg border border-border p-4 space-y-1">
|
|
@@ -892,6 +909,7 @@ export function StatusCard({
|
|
|
892
909
|
control={form.control as Control<ProductFormValues>}
|
|
893
910
|
name="status"
|
|
894
911
|
title={t('admin.fields.status.label')}
|
|
912
|
+
icon={<EyeIcon className="size-4" />}
|
|
895
913
|
label={t('admin.fields.status.label')}
|
|
896
914
|
disabled={awaitingDecision}
|
|
897
915
|
description={awaitingDecision ? t('admin.pages.products.status_under_review') : undefined}
|
|
@@ -978,7 +996,10 @@ export function CategorizationCard({ form }: FormCardProps) {
|
|
|
978
996
|
return (
|
|
979
997
|
<Card>
|
|
980
998
|
<CardHeader>
|
|
981
|
-
<CardTitle>
|
|
999
|
+
<CardTitle>
|
|
1000
|
+
<FolderTreeIcon className="size-4" />
|
|
1001
|
+
{t('admin.pages.products.section_categorization')}
|
|
1002
|
+
</CardTitle>
|
|
982
1003
|
</CardHeader>
|
|
983
1004
|
<CardContent className="flex flex-col gap-4">
|
|
984
1005
|
{client.productTypes && (
|
|
@@ -1132,7 +1153,10 @@ export function TaxCard({ form }: FormCardProps) {
|
|
|
1132
1153
|
return (
|
|
1133
1154
|
<Card>
|
|
1134
1155
|
<CardHeader>
|
|
1135
|
-
<CardTitle>
|
|
1156
|
+
<CardTitle>
|
|
1157
|
+
<ReceiptTextIcon className="size-4" />
|
|
1158
|
+
{t('admin.fields.tax.label')}
|
|
1159
|
+
</CardTitle>
|
|
1136
1160
|
</CardHeader>
|
|
1137
1161
|
<CardContent>
|
|
1138
1162
|
<Field>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { variantToFormValues, variantToWirePayload } from './product-form-mapping'
|
|
3
|
+
import type { PanelVariant } from './product-types'
|
|
4
|
+
|
|
5
|
+
// variantToFormValues reads a handful of fields; cast a minimal fixture rather
|
|
6
|
+
// than fabricating the whole serialized variant it ignores.
|
|
7
|
+
function variantWith(prices: PanelVariant['prices']): PanelVariant {
|
|
8
|
+
return { id: 'variant_1', sku: 'ESP-SEMI', option_values: [], prices } as unknown as PanelVariant
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('variantToFormValues prices', () => {
|
|
12
|
+
it('keeps the shop price in each currency', () => {
|
|
13
|
+
const values = variantToFormValues(
|
|
14
|
+
variantWith([
|
|
15
|
+
{ currency: 'USD', amount: '549.99', price_list_id: null },
|
|
16
|
+
{ currency: 'EUR', amount: '509.99', price_list_id: null },
|
|
17
|
+
]),
|
|
18
|
+
0,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
expect(values.prices).toEqual([
|
|
22
|
+
{ currency: 'USD', amount: '549.99', compare_at_amount: null },
|
|
23
|
+
{ currency: 'EUR', amount: '509.99', compare_at_amount: null },
|
|
24
|
+
])
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// The form posts this array straight back as the variant's base prices, so a
|
|
28
|
+
// price-list row taken in here returns as the shop price and overwrites it —
|
|
29
|
+
// and a list's null-amount placeholder returns as "no price" and deletes it.
|
|
30
|
+
it('drops what a price list charges, whatever order the rows arrive in', () => {
|
|
31
|
+
const values = variantToFormValues(
|
|
32
|
+
variantWith([
|
|
33
|
+
{ currency: 'USD', amount: '329.99', price_list_id: 'pl_1' },
|
|
34
|
+
{ currency: 'USD', amount: '549.99', price_list_id: null },
|
|
35
|
+
{ currency: 'EUR', amount: null, price_list_id: 'pl_1' },
|
|
36
|
+
{ currency: 'EUR', amount: '509.99', price_list_id: null },
|
|
37
|
+
{ currency: 'USD', amount: '249.99', price_list_id: 'pl_1' },
|
|
38
|
+
]),
|
|
39
|
+
0,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
expect(values.prices).toEqual([
|
|
43
|
+
{ currency: 'USD', amount: '549.99', compare_at_amount: null },
|
|
44
|
+
{ currency: 'EUR', amount: '509.99', compare_at_amount: null },
|
|
45
|
+
])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('ships only the shop prices back to the API', () => {
|
|
49
|
+
const values = variantToFormValues(
|
|
50
|
+
variantWith([
|
|
51
|
+
{ currency: 'USD', amount: '549.99', price_list_id: null },
|
|
52
|
+
{ currency: 'USD', amount: '329.99', price_list_id: 'pl_1' },
|
|
53
|
+
]),
|
|
54
|
+
0,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
expect(variantToWirePayload(values, 0).prices).toEqual([
|
|
58
|
+
{ currency: 'USD', amount: '549.99', compare_at_amount: null },
|
|
59
|
+
])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// A variant a price list prices but the shop does not has no shop price, and
|
|
63
|
+
// an empty array is how the form says so.
|
|
64
|
+
it('leaves no price when only a price list prices the variant', () => {
|
|
65
|
+
const values = variantToFormValues(
|
|
66
|
+
variantWith([{ currency: 'USD', amount: '89.99', price_list_id: 'pl_1' }]),
|
|
67
|
+
0,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
expect(values.prices).toEqual([])
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -44,8 +44,10 @@ export function variantToFormValues(variant: PanelVariant, position: number): Va
|
|
|
44
44
|
preorder_ships_at: variant.preorder_ships_at ?? null,
|
|
45
45
|
backorder_limit: variant.backorder_limit ?? null,
|
|
46
46
|
tax_category_id: variant.tax_category_id ?? null,
|
|
47
|
+
// Base prices only: this array is posted back as the variant's base
|
|
48
|
+
// prices, so a price-list row taken in here returns as the shop price.
|
|
47
49
|
prices: (variant.prices ?? [])
|
|
48
|
-
.filter((p) => p.currency != null)
|
|
50
|
+
.filter((p) => p.currency != null && p.price_list_id == null)
|
|
49
51
|
.map((p) => ({
|
|
50
52
|
currency: p.currency as string,
|
|
51
53
|
// Keep amounts as the canonical decimal strings the API returns.
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
TableHeader,
|
|
32
32
|
TableRow,
|
|
33
33
|
} from '@spree/dashboard-ui'
|
|
34
|
-
import { PencilIcon, XIcon } from '@spree/dashboard-ui/icons'
|
|
34
|
+
import { PencilIcon, SwatchBookIcon, XIcon } from '@spree/dashboard-ui/icons'
|
|
35
35
|
import { type CSSProperties, useEffect, useMemo, useState } from 'react'
|
|
36
36
|
import { type UseFormReturn, useFieldArray, useWatch } from 'react-hook-form'
|
|
37
37
|
import { useTranslation } from 'react-i18next'
|
|
@@ -343,7 +343,10 @@ export function VariantsSection({ form, seedFromType = false }: Props) {
|
|
|
343
343
|
return (
|
|
344
344
|
<Card>
|
|
345
345
|
<CardHeader className="flex flex-row items-center justify-between gap-3 space-y-0">
|
|
346
|
-
<CardTitle>
|
|
346
|
+
<CardTitle>
|
|
347
|
+
<SwatchBookIcon className="size-4" />
|
|
348
|
+
{t('admin.products.variants.title')}
|
|
349
|
+
</CardTitle>
|
|
347
350
|
{fields.length > 0 && (
|
|
348
351
|
<Button type="button" variant="outline" size="sm" onClick={() => setBulkOpen(true)}>
|
|
349
352
|
{t('admin.products.variants.bulk_edit.trigger')}
|
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
} from '@spree/admin-sdk'
|
|
8
8
|
import { createContext, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
|
9
9
|
import { getApiClient, type PanelSession } from '../api-client'
|
|
10
|
-
import { ADMIN_LOCALE_STORAGE_KEY, switchLocale } from '../lib/i18n'
|
|
10
|
+
import { ADMIN_LOCALE_STORAGE_KEY, sessionLocale, switchLocale } from '../lib/i18n'
|
|
11
11
|
import { queryClient } from '../lib/query-client'
|
|
12
12
|
|
|
13
13
|
interface AuthContextValue {
|
|
@@ -95,12 +95,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|
|
95
95
|
// Compare against the persisted choice (not the live i18n.language): if they
|
|
96
96
|
// already agree, the page booted in the right language and no reload is
|
|
97
97
|
// needed — this also prevents a reload loop on the periodic token refresh.
|
|
98
|
-
const code = authUser.selected_locale
|
|
99
98
|
const stored =
|
|
100
99
|
typeof localStorage !== 'undefined'
|
|
101
100
|
? (localStorage.getItem(ADMIN_LOCALE_STORAGE_KEY) ?? 'en')
|
|
102
101
|
: 'en'
|
|
103
|
-
|
|
102
|
+
const target = sessionLocale(authUser.selected_locale, stored)
|
|
103
|
+
if (target) switchLocale(target)
|
|
104
104
|
}, [])
|
|
105
105
|
|
|
106
106
|
const updateUser = useCallback((changes: Partial<AdminUser>) => {
|
|
@@ -48,10 +48,13 @@ export const PACKAGE_TYPE_DEFAULTS: PackageTypeFormValues = {
|
|
|
48
48
|
length: '',
|
|
49
49
|
width: '',
|
|
50
50
|
height: '',
|
|
51
|
-
|
|
51
|
+
// Left unset, as the variant editor leaves a variant's. A unit reaches the
|
|
52
|
+
// API only when the merchant picked one; otherwise the store's own units
|
|
53
|
+
// are recorded on the row
|
|
54
|
+
dimensions_unit: undefined,
|
|
52
55
|
weight: '',
|
|
53
56
|
max_weight: '',
|
|
54
|
-
weight_unit:
|
|
57
|
+
weight_unit: undefined,
|
|
55
58
|
default: false,
|
|
56
59
|
}
|
|
57
60
|
|
|
@@ -86,10 +89,10 @@ export function packageTypeToFormValues(packageType: PanelPackageType): PackageT
|
|
|
86
89
|
length: packageType.length ?? '',
|
|
87
90
|
width: packageType.width ?? '',
|
|
88
91
|
height: packageType.height ?? '',
|
|
89
|
-
dimensions_unit: (packageType.dimensions_unit ??
|
|
92
|
+
dimensions_unit: (packageType.dimensions_unit ?? undefined) as PackageDimensionUnit | undefined,
|
|
90
93
|
weight: packageType.weight ?? '',
|
|
91
94
|
max_weight: packageType.max_weight ?? '',
|
|
92
|
-
weight_unit: (packageType.weight_unit ??
|
|
95
|
+
weight_unit: (packageType.weight_unit ?? undefined) as PackageWeightUnit | undefined,
|
|
93
96
|
default: packageType.default ?? false,
|
|
94
97
|
}
|
|
95
98
|
}
|