@spree/dashboard-core 1.0.0-rc.3 → 1.0.0-rc.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 +14 -14
- package/src/api-client.ts +12 -7
- package/src/components/address-form-dialog.tsx +2 -2
- package/src/components/country-combobox.tsx +1 -2
- package/src/components/currency-select.tsx +8 -10
- package/src/components/file-upload-field.tsx +1 -0
- package/src/components/import-wizard-dialog.tsx +8 -11
- package/src/components/locale-select.tsx +22 -23
- package/src/components/nav-main.tsx +7 -2
- package/src/components/preferences-form.tsx +18 -10
- package/src/components/resource-combobox.tsx +5 -0
- package/src/components/resource-picker-sheet.tsx +18 -12
- package/src/components/settings-sidebar.tsx +8 -1
- package/src/components/slot.tsx +21 -1
- package/src/components/table-toolbar.tsx +111 -45
- package/src/hooks/use-display-name.ts +1 -18
- package/src/hooks/use-file-upload.ts +2 -1
- package/src/hooks/use-import.ts +3 -2
- package/src/index.ts +13 -3
- package/src/lib/currencies.ts +2 -18
- package/src/lib/currency-parts.ts +2 -21
- package/src/lib/filters-to-ransack.ts +4 -1
- package/src/lib/form-fields-registry.ts +23 -1
- package/src/lib/form-mappers.ts +1 -1
- package/src/lib/formatters.ts +6 -10
- package/src/lib/fulfillment-items.ts +11 -0
- package/src/lib/i18n.ts +1 -12
- package/src/lib/iso-codes.ts +9 -24
- package/src/lib/money.ts +9 -99
- package/src/lib/normalize-money.ts +2 -85
- package/src/lib/permissions.ts +2 -0
- package/src/lib/return-refunds.ts +1 -2
- package/src/lib/route-registry.ts +29 -0
- package/src/lib/settings-nav-registry.test.ts +34 -0
- package/src/lib/settings-nav-registry.ts +27 -5
- package/src/lib/store-config.test.ts +93 -0
- package/src/lib/store-config.ts +76 -0
- package/src/lib/table-registry.ts +16 -5
- package/src/locales/ar.json +7 -1
- package/src/locales/de.json +7 -1
- package/src/locales/en.json +7 -1
- package/src/locales/es.json +7 -1
- package/src/locales/fr.json +7 -1
- package/src/locales/pl.json +7 -1
- package/src/locales/zh-CN.json +7 -1
- package/src/pages/package-types.tsx +50 -11
- package/src/pages/settings-index.tsx +4 -1
- package/src/pages/stock-locations.tsx +31 -6
- package/src/products/bulk-variants-dialog.tsx +28 -35
- package/src/products/inventory-section.tsx +2 -2
- package/src/products/media-edit-sheet.tsx +67 -8
- package/src/products/normalize-quantity.ts +2 -3
- package/src/products/product-bulk-price-editor.tsx +1 -3
- package/src/products/product-form-cards.tsx +67 -37
- package/src/products/product-form-mapping.ts +44 -5
- package/src/products/product-schema.test.ts +8 -0
- package/src/products/product-schema.ts +14 -7
- package/src/products/product-types.ts +1 -1
- package/src/products/sheet-save-handlers.ts +37 -0
- package/src/products/variant-edit-sheet.tsx +21 -11
- package/src/products/variants-options-builder.tsx +30 -27
- package/src/products/variants-section.tsx +35 -26
- package/src/providers/store-provider.tsx +31 -7
- package/src/schemas/package-type.ts +14 -12
- package/src/schemas/stock-location.ts +2 -3
- package/src/tables/stock-locations.tsx +5 -8
- package/src/lib/money.test.ts +0 -63
- package/src/lib/normalize-money.test.ts +0 -67
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { DimensionUnit, WeightUnit } from '@spree/core'
|
|
1
2
|
import type { ProductFormValues, VariantFormValues } from '@spree/dashboard-core'
|
|
2
|
-
import { CountryCombobox, StoreDatePicker } from '@spree/dashboard-core'
|
|
3
|
+
import { CountryCombobox, Slot, StoreDatePicker } from '@spree/dashboard-core'
|
|
3
4
|
import {
|
|
4
5
|
Alert,
|
|
5
6
|
AlertDescription,
|
|
@@ -36,6 +37,7 @@ import { useTenantId } from '../providers/tenant-provider'
|
|
|
36
37
|
import { cartonSize } from './carton-size'
|
|
37
38
|
import { normalizeCustomsDescription, normalizeHsCode } from './normalize-customs'
|
|
38
39
|
import { PURCHASE_UNITS } from './normalize-quantity'
|
|
40
|
+
import { useSheetSaveHandlers } from './sheet-save-handlers'
|
|
39
41
|
import {
|
|
40
42
|
useFormCartonPackageTypes as useCartonPackageTypes,
|
|
41
43
|
useFormOptionTypes as useOptionTypes,
|
|
@@ -43,13 +45,12 @@ import {
|
|
|
43
45
|
} from './use-product-form-data'
|
|
44
46
|
import { variantDisplayLabel } from './variants-matrix'
|
|
45
47
|
|
|
46
|
-
const WEIGHT_UNITS = ['g', 'kg', 'lb', 'oz'] as const
|
|
47
|
-
const DIMENSION_UNITS = ['mm', 'cm', 'in'] as const
|
|
48
|
-
|
|
49
48
|
interface Props {
|
|
50
49
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
51
50
|
form: UseFormReturn<ProductFormValues, any, any>
|
|
52
51
|
variantIndex: number
|
|
52
|
+
/** The product form's key — names the `<formKey>.variant.form_fields` slot. */
|
|
53
|
+
formKey: string
|
|
53
54
|
open: boolean
|
|
54
55
|
onOpenChange: (open: boolean) => void
|
|
55
56
|
}
|
|
@@ -58,7 +59,7 @@ interface Props {
|
|
|
58
59
|
// form — no own form state. Save closes the sheet (changes are already in
|
|
59
60
|
// form state; the product Save button commits them). Cancel restores the
|
|
60
61
|
// snapshot captured when the sheet opened.
|
|
61
|
-
export function VariantEditSheet({ form, variantIndex, open, onOpenChange }: Props) {
|
|
62
|
+
export function VariantEditSheet({ form, variantIndex, formKey, open, onOpenChange }: Props) {
|
|
62
63
|
const { t } = useTranslation()
|
|
63
64
|
const { data: taxCategoriesResponse } = useTaxCategories()
|
|
64
65
|
const taxCategories = taxCategoriesResponse?.data ?? []
|
|
@@ -112,6 +113,8 @@ export function VariantEditSheet({ form, variantIndex, open, onOpenChange }: Pro
|
|
|
112
113
|
: null
|
|
113
114
|
}, [open, variantIndex, form])
|
|
114
115
|
|
|
116
|
+
const { onSave, saveAll, saving } = useSheetSaveHandlers()
|
|
117
|
+
|
|
115
118
|
const variant = form.watch(`variants.${variantIndex}`)
|
|
116
119
|
if (!variant) return null
|
|
117
120
|
|
|
@@ -122,6 +125,8 @@ export function VariantEditSheet({ form, variantIndex, open, onOpenChange }: Pro
|
|
|
122
125
|
)
|
|
123
126
|
|
|
124
127
|
const handleCancel = () => {
|
|
128
|
+
// A save already sent can't be called back, so the sheet stays until it settles.
|
|
129
|
+
if (saving) return
|
|
125
130
|
const snap = snapshotRef.current
|
|
126
131
|
if (snap) {
|
|
127
132
|
// Resolve the target index by stable id when we have one — the array
|
|
@@ -144,8 +149,8 @@ export function VariantEditSheet({ form, variantIndex, open, onOpenChange }: Pro
|
|
|
144
149
|
onOpenChange(false)
|
|
145
150
|
}
|
|
146
151
|
|
|
147
|
-
const handleDone = () => {
|
|
148
|
-
onOpenChange(false)
|
|
152
|
+
const handleDone = async () => {
|
|
153
|
+
if (await saveAll()) onOpenChange(false)
|
|
149
154
|
}
|
|
150
155
|
|
|
151
156
|
return (
|
|
@@ -230,7 +235,7 @@ export function VariantEditSheet({ form, variantIndex, open, onOpenChange }: Pro
|
|
|
230
235
|
<SelectValue>{(v) => (v as string) || '—'}</SelectValue>
|
|
231
236
|
</SelectTrigger>
|
|
232
237
|
<SelectContent>
|
|
233
|
-
{
|
|
238
|
+
{Object.values(WeightUnit).map((u) => (
|
|
234
239
|
<SelectItem key={u} value={u}>
|
|
235
240
|
{u}
|
|
236
241
|
</SelectItem>
|
|
@@ -295,7 +300,7 @@ export function VariantEditSheet({ form, variantIndex, open, onOpenChange }: Pro
|
|
|
295
300
|
<SelectValue>{(v) => (v as string) || '—'}</SelectValue>
|
|
296
301
|
</SelectTrigger>
|
|
297
302
|
<SelectContent>
|
|
298
|
-
{
|
|
303
|
+
{Object.values(DimensionUnit).map((u) => (
|
|
299
304
|
<SelectItem key={u} value={u}>
|
|
300
305
|
{u}
|
|
301
306
|
</SelectItem>
|
|
@@ -709,13 +714,18 @@ export function VariantEditSheet({ form, variantIndex, open, onOpenChange }: Pro
|
|
|
709
714
|
</Field>
|
|
710
715
|
</FormSection>
|
|
711
716
|
)}
|
|
717
|
+
|
|
718
|
+
<Slot
|
|
719
|
+
name={`${formKey}.variant.form_fields`}
|
|
720
|
+
context={{ variant, variantIndex, fieldPrefix: `variants.${variantIndex}`, onSave }}
|
|
721
|
+
/>
|
|
712
722
|
</div>
|
|
713
723
|
|
|
714
724
|
<SheetFooter>
|
|
715
|
-
<Button type="button" variant="outline" onClick={handleCancel}>
|
|
725
|
+
<Button type="button" variant="outline" disabled={saving} onClick={handleCancel}>
|
|
716
726
|
{t('admin.actions.cancel')}
|
|
717
727
|
</Button>
|
|
718
|
-
<Button type="button" onClick={handleDone}>
|
|
728
|
+
<Button type="button" disabled={saving} onClick={handleDone}>
|
|
719
729
|
{t('admin.actions.done')}
|
|
720
730
|
</Button>
|
|
721
731
|
</SheetFooter>
|
|
@@ -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>
|
|
@@ -16,6 +16,9 @@ import {
|
|
|
16
16
|
import { CSS } from '@dnd-kit/utilities'
|
|
17
17
|
import type { ProductFormValues, VariantFormValues } from '@spree/dashboard-core'
|
|
18
18
|
import {
|
|
19
|
+
Alert,
|
|
20
|
+
AlertDescription,
|
|
21
|
+
AlertTitle,
|
|
19
22
|
Button,
|
|
20
23
|
Card,
|
|
21
24
|
CardContent,
|
|
@@ -31,7 +34,7 @@ import {
|
|
|
31
34
|
TableHeader,
|
|
32
35
|
TableRow,
|
|
33
36
|
} from '@spree/dashboard-ui'
|
|
34
|
-
import { PencilIcon, SwatchBookIcon, XIcon } from '@spree/dashboard-ui/icons'
|
|
37
|
+
import { PencilIcon, SwatchBookIcon, TriangleAlertIcon, XIcon } from '@spree/dashboard-ui/icons'
|
|
35
38
|
import { type CSSProperties, useEffect, useMemo, useState } from 'react'
|
|
36
39
|
import { type UseFormReturn, useFieldArray, useWatch } from 'react-hook-form'
|
|
37
40
|
import { useTranslation } from 'react-i18next'
|
|
@@ -61,6 +64,8 @@ interface Props {
|
|
|
61
64
|
* whatever it already has.
|
|
62
65
|
*/
|
|
63
66
|
seedFromType?: boolean
|
|
67
|
+
/** The product form's key — names the variant sheet's slot. */
|
|
68
|
+
formKey?: string
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
// Derive the initial selected option types from current variant rows so the
|
|
@@ -122,7 +127,7 @@ function seedFromProductType(
|
|
|
122
127
|
.filter((entry): entry is SelectedOptionType => entry !== null)
|
|
123
128
|
}
|
|
124
129
|
|
|
125
|
-
export function VariantsSection({ form, seedFromType = false }: Props) {
|
|
130
|
+
export function VariantsSection({ form, seedFromType = false, formKey = 'product' }: Props) {
|
|
126
131
|
const { t } = useTranslation()
|
|
127
132
|
const { data: optionTypesData } = useOptionTypes()
|
|
128
133
|
const allOptionTypes = useMemo(() => optionTypesData?.data ?? [], [optionTypesData])
|
|
@@ -361,31 +366,34 @@ export function VariantsSection({ form, seedFromType = false }: Props) {
|
|
|
361
366
|
/>
|
|
362
367
|
|
|
363
368
|
{orphanedKeys.length > 0 && (
|
|
364
|
-
<
|
|
365
|
-
<
|
|
369
|
+
<Alert variant="warning">
|
|
370
|
+
<TriangleAlertIcon />
|
|
371
|
+
<AlertTitle>
|
|
366
372
|
{t('admin.products.variants.orphan_warning', { count: orphanedKeys.length })}
|
|
367
|
-
</
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
<
|
|
377
|
-
{
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
373
|
+
</AlertTitle>
|
|
374
|
+
<AlertDescription>
|
|
375
|
+
{orphanedEntries.length > 0 && (
|
|
376
|
+
<ul className="list-disc pl-5">
|
|
377
|
+
{orphanedEntries.map((entry) => (
|
|
378
|
+
<li key={entry.key}>{entry.label}</li>
|
|
379
|
+
))}
|
|
380
|
+
</ul>
|
|
381
|
+
)}
|
|
382
|
+
<div className="mt-2 flex items-center justify-end gap-2">
|
|
383
|
+
<Button type="button" variant="ghost" size="sm" onClick={handleKeepOrphans}>
|
|
384
|
+
{t('admin.products.variants.keep')}
|
|
385
|
+
</Button>
|
|
386
|
+
<Button
|
|
387
|
+
type="button"
|
|
388
|
+
variant="destructive"
|
|
389
|
+
size="sm"
|
|
390
|
+
onClick={handleConfirmDropOrphans}
|
|
391
|
+
>
|
|
392
|
+
{t('admin.actions.remove')}
|
|
393
|
+
</Button>
|
|
394
|
+
</div>
|
|
395
|
+
</AlertDescription>
|
|
396
|
+
</Alert>
|
|
389
397
|
)}
|
|
390
398
|
|
|
391
399
|
{fields.length > 0 && !awaitingOptionValues && (
|
|
@@ -441,6 +449,7 @@ export function VariantsSection({ form, seedFromType = false }: Props) {
|
|
|
441
449
|
<VariantEditSheet
|
|
442
450
|
form={form}
|
|
443
451
|
variantIndex={editingIndex}
|
|
452
|
+
formKey={formKey}
|
|
444
453
|
open={sheetOpen}
|
|
445
454
|
onOpenChange={(o) => {
|
|
446
455
|
setSheetOpen(o)
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Store } from '@spree/admin-sdk'
|
|
2
|
+
import type { CurrencyCode, LocaleCode } from '@spree/core'
|
|
2
3
|
import { useQuery } from '@tanstack/react-query'
|
|
3
|
-
import { createContext, type ReactNode, useContext, useEffect, useRef } from 'react'
|
|
4
|
+
import { createContext, type ReactNode, useContext, useEffect, useMemo, useRef } from 'react'
|
|
4
5
|
import { adminClient } from '../client'
|
|
5
6
|
import { useAuth } from '../hooks/use-auth'
|
|
6
7
|
import { coreLocaleCodes, reconcileStoreDefaultLocale } from '../lib/i18n'
|
|
8
|
+
import { listAllMarkets, marketLocalization, STORE_CONFIG_STALE_TIME } from '../lib/store-config'
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* Resource segment of the store query key: the current store lives in the
|
|
@@ -19,14 +21,17 @@ interface StoreContextValue {
|
|
|
19
21
|
store: Store | null
|
|
20
22
|
storeId: string
|
|
21
23
|
isLoading: boolean
|
|
24
|
+
/** Currencies of the store's markets, the default market's first. */
|
|
22
25
|
currencies: CurrencyCode[]
|
|
23
|
-
/**
|
|
26
|
+
/** Languages the store's markets offer — what content is translated into. */
|
|
24
27
|
locales: LocaleCode[]
|
|
25
28
|
/** Every locale a merchant may translate content into — the full canonical
|
|
26
29
|
* set, independent of which locales the store currently uses. Drives locale
|
|
27
30
|
* pickers so a new locale can always be added. */
|
|
28
31
|
availableLocales: LocaleCode[]
|
|
32
|
+
/** The default market's currency. */
|
|
29
33
|
defaultCurrency: CurrencyCode
|
|
34
|
+
/** The default market's language. */
|
|
30
35
|
defaultLocale: LocaleCode
|
|
31
36
|
/** ISO country the store sells from — used to pre-fill new address forms. */
|
|
32
37
|
defaultCountry: string
|
|
@@ -75,6 +80,26 @@ export function StoreProvider({ storeId, children }: { storeId: string; children
|
|
|
75
80
|
})
|
|
76
81
|
const store = query.data ?? null
|
|
77
82
|
|
|
83
|
+
// Same key and request as the dashboard's market list, so the shell and every
|
|
84
|
+
// market picker share one cached fetch that market writes invalidate.
|
|
85
|
+
const marketsQuery = useQuery({
|
|
86
|
+
queryKey: ['markets', storeId, 'all'],
|
|
87
|
+
queryFn: listAllMarkets,
|
|
88
|
+
staleTime: STORE_CONFIG_STALE_TIME,
|
|
89
|
+
})
|
|
90
|
+
// Until the markets arrive, or if they cannot be read, the store payload
|
|
91
|
+
// stands in: the server derives the same values from the same markets, and a
|
|
92
|
+
// hardcoded USD/en would be wrong for most stores.
|
|
93
|
+
const localization = useMemo(() => {
|
|
94
|
+
if (marketsQuery.data) return marketLocalization(marketsQuery.data.data)
|
|
95
|
+
return {
|
|
96
|
+
currencies: store?.supported_currencies ?? [],
|
|
97
|
+
locales: store?.supported_locales ?? [],
|
|
98
|
+
defaultCurrency: store?.default_currency,
|
|
99
|
+
defaultLocale: store?.default_locale,
|
|
100
|
+
}
|
|
101
|
+
}, [marketsQuery.data, store])
|
|
102
|
+
|
|
78
103
|
// Reconcile the admin language against this store's default — adopting it,
|
|
79
104
|
// or dropping a now-stale auto-applied default — only when no account locale
|
|
80
105
|
// or genuine personal choice owns it (legacy base_controller parity).
|
|
@@ -90,11 +115,10 @@ export function StoreProvider({ storeId, children }: { storeId: string; children
|
|
|
90
115
|
)
|
|
91
116
|
}, [store, storeId])
|
|
92
117
|
|
|
93
|
-
const currencies
|
|
94
|
-
const locales = store?.supported_locales ?? []
|
|
118
|
+
const { currencies, locales } = localization
|
|
95
119
|
const availableLocales = store?.available_locales ?? []
|
|
96
|
-
const defaultCurrency =
|
|
97
|
-
const defaultLocale =
|
|
120
|
+
const defaultCurrency = localization.defaultCurrency ?? 'USD'
|
|
121
|
+
const defaultLocale = localization.defaultLocale ?? 'en'
|
|
98
122
|
const defaultCountry = store?.default_country_code ?? ''
|
|
99
123
|
// A store's zone preference defaults to the host app's `Time.zone.name`,
|
|
100
124
|
// which may be a Rails name ("Eastern Time (US & Canada)") that Intl and
|
|
@@ -1,17 +1,19 @@
|
|
|
1
|
+
import { DimensionUnit, PACKAGE_TYPE_KINDS, type PackageTypeKind, WeightUnit } from '@spree/core'
|
|
1
2
|
import { requiredMessage } from '@spree/dashboard-ui'
|
|
2
3
|
import i18n from 'i18next'
|
|
3
4
|
import { z } from 'zod/v4'
|
|
4
5
|
import type { PanelPackageType, PanelPackageTypeCreateParams } from '../api-client'
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
// only fail on save.
|
|
8
|
-
export const PACKAGE_TYPE_KINDS = ['box', 'envelope', 'carton', 'pallet', 'container'] as const
|
|
9
|
-
export const PACKAGE_DIMENSION_UNITS = ['mm', 'cm', 'in', 'ft'] as const
|
|
10
|
-
export const PACKAGE_WEIGHT_UNITS = ['g', 'kg', 'lb', 'oz'] as const
|
|
7
|
+
export { PACKAGE_TYPE_KINDS, type PackageTypeKind }
|
|
11
8
|
|
|
12
|
-
|
|
13
|
-
export
|
|
14
|
-
|
|
9
|
+
/** @deprecated Use `Object.values(DimensionUnit)` from `@spree/admin-sdk`. */
|
|
10
|
+
export const PACKAGE_DIMENSION_UNITS = Object.values(DimensionUnit)
|
|
11
|
+
/** @deprecated Use `Object.values(WeightUnit)` from `@spree/admin-sdk`. */
|
|
12
|
+
export const PACKAGE_WEIGHT_UNITS = Object.values(WeightUnit)
|
|
13
|
+
/** @deprecated Use `DimensionUnit` from `@spree/admin-sdk`. */
|
|
14
|
+
export type PackageDimensionUnit = DimensionUnit
|
|
15
|
+
/** @deprecated Use `WeightUnit` from `@spree/admin-sdk`. */
|
|
16
|
+
export type PackageWeightUnit = WeightUnit
|
|
15
17
|
|
|
16
18
|
// Blank is how a merchant says "not measured", so every number is optional
|
|
17
19
|
// and an empty string reaches the API as null rather than zero — a box
|
|
@@ -33,10 +35,10 @@ export const packageTypeFormSchema = z.object({
|
|
|
33
35
|
length: optionalNumber,
|
|
34
36
|
width: optionalNumber,
|
|
35
37
|
height: optionalNumber,
|
|
36
|
-
dimensions_unit: z.enum(
|
|
38
|
+
dimensions_unit: z.enum(DimensionUnit).optional(),
|
|
37
39
|
weight: optionalNumber,
|
|
38
40
|
max_weight: optionalNumber,
|
|
39
|
-
weight_unit: z.enum(
|
|
41
|
+
weight_unit: z.enum(WeightUnit).optional(),
|
|
40
42
|
default: z.boolean().optional(),
|
|
41
43
|
})
|
|
42
44
|
|
|
@@ -89,10 +91,10 @@ export function packageTypeToFormValues(packageType: PanelPackageType): PackageT
|
|
|
89
91
|
length: packageType.length ?? '',
|
|
90
92
|
width: packageType.width ?? '',
|
|
91
93
|
height: packageType.height ?? '',
|
|
92
|
-
dimensions_unit: (packageType.dimensions_unit ?? undefined) as
|
|
94
|
+
dimensions_unit: (packageType.dimensions_unit ?? undefined) as DimensionUnit | undefined,
|
|
93
95
|
weight: packageType.weight ?? '',
|
|
94
96
|
max_weight: packageType.max_weight ?? '',
|
|
95
|
-
weight_unit: (packageType.weight_unit ?? undefined) as
|
|
97
|
+
weight_unit: (packageType.weight_unit ?? undefined) as WeightUnit | undefined,
|
|
96
98
|
default: packageType.default ?? false,
|
|
97
99
|
}
|
|
98
100
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PICKUP_STOCK_POLICIES, type PickupStockPolicy, STOCK_LOCATION_KINDS } from '@spree/core'
|
|
1
2
|
import { requiredMessage } from '@spree/dashboard-ui'
|
|
2
3
|
import { z } from 'zod/v4'
|
|
3
4
|
import type { PanelStockLocation, PanelStockLocationCreateParams } from '../api-client'
|
|
@@ -7,9 +8,7 @@ import { countryCodeSchema } from '../lib/iso-codes'
|
|
|
7
8
|
// Labels live in `en.json` under `admin.stock_locations.kinds.*` and
|
|
8
9
|
// `admin.stock_locations.pickup_stock_policies.*`. Consumers map these
|
|
9
10
|
// values to translated labels at render time.
|
|
10
|
-
export
|
|
11
|
-
export const PICKUP_STOCK_POLICIES = ['local', 'any'] as const
|
|
12
|
-
export type PickupStockPolicy = (typeof PICKUP_STOCK_POLICIES)[number]
|
|
11
|
+
export { PICKUP_STOCK_POLICIES, type PickupStockPolicy, STOCK_LOCATION_KINDS }
|
|
13
12
|
|
|
14
13
|
export const stockLocationFormSchema = z.object({
|
|
15
14
|
name: z
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { STOCK_LOCATION_KINDS } from '@spree/core'
|
|
1
2
|
import { ActiveBadge, ResourceNameCell } from '@spree/dashboard-ui'
|
|
2
3
|
import { WarehouseIcon } from '@spree/dashboard-ui/icons'
|
|
3
4
|
import i18n from 'i18next'
|
|
@@ -35,14 +36,10 @@ defineTable<PanelStockLocation>('stock-locations', {
|
|
|
35
36
|
filterable: true,
|
|
36
37
|
default: true,
|
|
37
38
|
filterType: 'enum',
|
|
38
|
-
filterOptions:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
value: 'fulfillment_center',
|
|
43
|
-
label: i18n.t('admin.stock_locations.kinds.fulfillment_center'),
|
|
44
|
-
},
|
|
45
|
-
],
|
|
39
|
+
filterOptions: STOCK_LOCATION_KINDS.map((kind) => ({
|
|
40
|
+
value: kind,
|
|
41
|
+
label: i18n.t(`admin.stock_locations.kinds.${kind}`),
|
|
42
|
+
})),
|
|
46
43
|
quickFilter: true,
|
|
47
44
|
render: (sl) => {
|
|
48
45
|
const key = `admin.stock_locations.kinds.${sl.kind}`
|
package/src/lib/money.test.ts
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import {
|
|
3
|
-
fractionToPercent,
|
|
4
|
-
isPositiveMoney,
|
|
5
|
-
percentOf,
|
|
6
|
-
percentToFraction,
|
|
7
|
-
prorateMoney,
|
|
8
|
-
shiftDecimalPoint,
|
|
9
|
-
} from './money'
|
|
10
|
-
|
|
11
|
-
describe('shiftDecimalPoint', () => {
|
|
12
|
-
it('moves the point exactly, without float noise', () => {
|
|
13
|
-
expect(shiftDecimalPoint('0.077', 2)).toBe('7.7')
|
|
14
|
-
expect(shiftDecimalPoint('7.7', -2)).toBe('0.077')
|
|
15
|
-
expect(shiftDecimalPoint('0.07', 2)).toBe('7')
|
|
16
|
-
expect(shiftDecimalPoint('1', 2)).toBe('100')
|
|
17
|
-
expect(shiftDecimalPoint('100', -2)).toBe('1')
|
|
18
|
-
expect(shiftDecimalPoint('-0.5', 2)).toBe('-50')
|
|
19
|
-
expect(shiftDecimalPoint('0', -2)).toBe('0')
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
it('leaves a value that is not a decimal string alone', () => {
|
|
23
|
-
expect(shiftDecimalPoint('abc', 2)).toBe('abc')
|
|
24
|
-
})
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
describe('fractionToPercent / percentToFraction', () => {
|
|
28
|
-
it('round-trips and keeps blanks blank', () => {
|
|
29
|
-
expect(fractionToPercent('0.23')).toBe('23')
|
|
30
|
-
expect(percentToFraction('23')).toBe('0.23')
|
|
31
|
-
expect(percentToFraction('7.25')).toBe('0.0725')
|
|
32
|
-
expect(fractionToPercent(null)).toBe('')
|
|
33
|
-
expect(percentToFraction('')).toBe('')
|
|
34
|
-
})
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
describe('percentOf', () => {
|
|
38
|
-
it('rounds to the currency decimals', () => {
|
|
39
|
-
expect(percentOf('19.99', '10', 'USD')).toBe('2.00')
|
|
40
|
-
expect(percentOf('100', '12.5', 'USD')).toBe('12.50')
|
|
41
|
-
expect(percentOf('1000', '0.05', 'JPY')).toBe('1')
|
|
42
|
-
expect(percentOf('1.005', '50', 'KWD')).toBe('0.503')
|
|
43
|
-
})
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
describe('isPositiveMoney', () => {
|
|
47
|
-
it('is true only for an amount above zero', () => {
|
|
48
|
-
expect(isPositiveMoney('0.01')).toBe(true)
|
|
49
|
-
expect(isPositiveMoney('0.00')).toBe(false)
|
|
50
|
-
expect(isPositiveMoney('-1')).toBe(false)
|
|
51
|
-
expect(isPositiveMoney(null)).toBe(false)
|
|
52
|
-
expect(isPositiveMoney('abc')).toBe(false)
|
|
53
|
-
})
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
describe('prorateMoney', () => {
|
|
57
|
-
it('shares an amount across units, rounded to the currency', () => {
|
|
58
|
-
expect(prorateMoney('10.00', 1, 3, 'USD')).toBe('3.33')
|
|
59
|
-
expect(prorateMoney('10.00', 2, 3, 'USD')).toBe('6.67')
|
|
60
|
-
expect(prorateMoney('10.00', 3, 3, 'USD')).toBe('10.00')
|
|
61
|
-
expect(prorateMoney('1000', 1, 3, 'JPY')).toBe('333')
|
|
62
|
-
})
|
|
63
|
-
})
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { normalizeMoneyInput } from './normalize-money'
|
|
3
|
-
|
|
4
|
-
describe('normalizeMoneyInput', () => {
|
|
5
|
-
it('passes through canonical en input', () => {
|
|
6
|
-
expect(normalizeMoneyInput('19.99', 'en')).toBe('19.99')
|
|
7
|
-
expect(normalizeMoneyInput('1,234.56', 'en')).toBe('1234.56')
|
|
8
|
-
expect(normalizeMoneyInput('1234.56', 'en')).toBe('1234.56')
|
|
9
|
-
})
|
|
10
|
-
|
|
11
|
-
it('converts de comma-decimal + dot grouping to canonical', () => {
|
|
12
|
-
expect(normalizeMoneyInput('19,99', 'de')).toBe('19.99')
|
|
13
|
-
expect(normalizeMoneyInput('1.234,56', 'de')).toBe('1234.56')
|
|
14
|
-
expect(normalizeMoneyInput('1.000.000,00', 'de')).toBe('1000000.00')
|
|
15
|
-
})
|
|
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
|
-
|
|
26
|
-
it('handles fr narrow-space grouping', () => {
|
|
27
|
-
// fr groups with U+202F (narrow no-break space) and uses a comma decimal.
|
|
28
|
-
expect(normalizeMoneyInput('1 234,56', 'fr')).toBe('1234.56')
|
|
29
|
-
expect(normalizeMoneyInput('73,45', 'fr')).toBe('73.45')
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
it('returns empty string for blank input', () => {
|
|
33
|
-
expect(normalizeMoneyInput('', 'de')).toBe('')
|
|
34
|
-
expect(normalizeMoneyInput(' ', 'de')).toBe('')
|
|
35
|
-
expect(normalizeMoneyInput(null, 'de')).toBe('')
|
|
36
|
-
expect(normalizeMoneyInput(undefined, 'de')).toBe('')
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
it('preserves a leading minus', () => {
|
|
40
|
-
expect(normalizeMoneyInput('-5,00', 'de')).toBe('-5.00')
|
|
41
|
-
expect(normalizeMoneyInput('-5.00', 'en')).toBe('-5.00')
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
it('drops stray currency symbols and whitespace', () => {
|
|
45
|
-
expect(normalizeMoneyInput(' € 1.234,56 ', 'de')).toBe('1234.56')
|
|
46
|
-
expect(normalizeMoneyInput('$19.99', 'en')).toBe('19.99')
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
it('keeps no-decimal values intact', () => {
|
|
50
|
-
expect(normalizeMoneyInput('50', 'de')).toBe('50')
|
|
51
|
-
expect(normalizeMoneyInput('1.000', 'de')).toBe('1000') // de: dot is grouping
|
|
52
|
-
expect(normalizeMoneyInput('1,000', 'en')).toBe('1000') // en: comma is grouping
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
it('collapses stray separators to a single decimal point', () => {
|
|
56
|
-
// Malformed multi-dot/interior-minus input must canonicalize, not pass through.
|
|
57
|
-
expect(normalizeMoneyInput('1.2.3', 'en')).toBe('1.23')
|
|
58
|
-
expect(normalizeMoneyInput('12-3.4', 'en')).toBe('123.4')
|
|
59
|
-
expect(normalizeMoneyInput('-12-3.4', 'en')).toBe('-123.4')
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
it('returns empty for inputs with no digits', () => {
|
|
63
|
-
expect(normalizeMoneyInput('.', 'en')).toBe('')
|
|
64
|
-
expect(normalizeMoneyInput('-', 'en')).toBe('')
|
|
65
|
-
expect(normalizeMoneyInput('abc', 'en')).toBe('')
|
|
66
|
-
})
|
|
67
|
-
})
|