@spree/dashboard-ui 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 +25 -22
- package/src/index.ts +2 -0
- package/src/spree/address-block.tsx +7 -10
- package/src/spree/address-book-row.tsx +52 -52
- package/src/spree/address-select.tsx +105 -0
- package/src/spree/bulk-price-table.tsx +17 -11
- package/src/spree/bulk-variants-table.tsx +12 -5
- package/src/spree/calculator-summary.tsx +9 -14
- package/src/spree/claim-resolve-dialog.tsx +28 -27
- package/src/spree/data-grid/data-grid.tsx +23 -13
- package/src/spree/data-grid/index.ts +1 -1
- package/src/spree/form-actions.tsx +27 -5
- package/src/spree/fulfillment-item-list.tsx +10 -9
- package/src/spree/icons.ts +3 -0
- package/src/spree/post-sale-fields.tsx +22 -20
- package/src/spree/resource-combobox.tsx +20 -4
- package/src/spree/secret-input.tsx +29 -13
- package/src/spree/tax-identifiers-card.tsx +69 -76
- package/src/spree/wizard.tsx +1 -7
- package/src/ui/badge.tsx +21 -1
- package/src/ui/dialog.tsx +11 -2
- package/src/ui/item.tsx +188 -0
- package/src/ui/toast.tsx +99 -45
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spree/dashboard-ui",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "1.0.0-rc.
|
|
4
|
+
"version": "1.0.0-rc.5",
|
|
5
5
|
"description": "Spree Dashboard design system — shadcn primitives, headless composed components, and design tokens. Source-only package; consumers compile via their own Vite/Tailwind setup. Developer Preview.",
|
|
6
6
|
"author": "Vendo Connect Inc.",
|
|
7
7
|
"homepage": "https://spreecommerce.org",
|
|
@@ -37,7 +37,6 @@
|
|
|
37
37
|
"LICENSE"
|
|
38
38
|
],
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"@tanstack/react-hotkeys": "^0.10.0",
|
|
41
40
|
"@tanstack/react-router": "^1.169.2",
|
|
42
41
|
"i18next": "^26.2.0",
|
|
43
42
|
"react": "^19.2.6",
|
|
@@ -60,37 +59,41 @@
|
|
|
60
59
|
"@dnd-kit/core": "6.3.1",
|
|
61
60
|
"@dnd-kit/sortable": "10.0.0",
|
|
62
61
|
"@dnd-kit/utilities": "3.2.2",
|
|
63
|
-
"@fontsource-variable/geist": "5.
|
|
64
|
-
"@fontsource-variable/geist-mono": "5.
|
|
65
|
-
"@tanstack/react-
|
|
66
|
-
"@
|
|
67
|
-
"@tiptap/
|
|
68
|
-
"@tiptap/extension-
|
|
69
|
-
"@tiptap/extension-
|
|
70
|
-
"@tiptap/
|
|
71
|
-
"@tiptap/
|
|
72
|
-
"@tiptap/
|
|
62
|
+
"@fontsource-variable/geist": "5.3.0",
|
|
63
|
+
"@fontsource-variable/geist-mono": "5.3.0",
|
|
64
|
+
"@tanstack/react-hotkeys": "^0.14.3",
|
|
65
|
+
"@tanstack/react-table": "9.3.1",
|
|
66
|
+
"@tiptap/core": "3.32.0",
|
|
67
|
+
"@tiptap/extension-bubble-menu": "3.32.0",
|
|
68
|
+
"@tiptap/extension-floating-menu": "3.32.0",
|
|
69
|
+
"@tiptap/extension-image": "3.32.0",
|
|
70
|
+
"@tiptap/extension-link": "3.32.0",
|
|
71
|
+
"@tiptap/extension-placeholder": "3.32.0",
|
|
72
|
+
"@tiptap/pm": "3.32.0",
|
|
73
|
+
"@tiptap/react": "3.32.0",
|
|
74
|
+
"@tiptap/starter-kit": "3.32.0",
|
|
73
75
|
"@uiw/react-json-view": "1.12.2",
|
|
74
76
|
"class-variance-authority": "0.7.1",
|
|
75
77
|
"clsx": "2.1.1",
|
|
76
78
|
"cmdk": "1.1.1",
|
|
77
|
-
"date-fns": "4.
|
|
79
|
+
"date-fns": "4.4.0",
|
|
78
80
|
"date-fns-tz": "3.2.0",
|
|
79
|
-
"flag-icons": "7.
|
|
80
|
-
"lucide-react": "1.
|
|
81
|
-
"react-colorful": "5.
|
|
82
|
-
"react-day-picker": "
|
|
81
|
+
"flag-icons": "7.6.0",
|
|
82
|
+
"lucide-react": "1.58.0",
|
|
83
|
+
"react-colorful": "5.8.1",
|
|
84
|
+
"react-day-picker": "10.0.2",
|
|
83
85
|
"recharts": "3.10.1",
|
|
84
|
-
"shadcn": "4.
|
|
85
|
-
"tailwind-merge": "3.
|
|
86
|
-
"tw-animate-css": "1.4.0"
|
|
86
|
+
"shadcn": "4.22.0",
|
|
87
|
+
"tailwind-merge": "3.7.0",
|
|
88
|
+
"tw-animate-css": "1.4.0",
|
|
89
|
+
"@spree/core": "^0.2.0"
|
|
87
90
|
},
|
|
88
91
|
"devDependencies": {
|
|
89
92
|
"@types/react": "^19.2.14",
|
|
90
93
|
"@types/react-dom": "^19.2.3",
|
|
91
94
|
"tailwindcss": "^4.2.4",
|
|
92
|
-
"typescript": "~
|
|
93
|
-
"vite": "^8.
|
|
95
|
+
"typescript": "~7.0.2",
|
|
96
|
+
"vite": "^8.3.4"
|
|
94
97
|
},
|
|
95
98
|
"scripts": {
|
|
96
99
|
"lint": "biome check src",
|
package/src/index.ts
CHANGED
|
@@ -32,6 +32,7 @@ export { decimalPlacesMessage, requiredMessage } from './lib/validation-messages
|
|
|
32
32
|
// ---------------------------------------------------------------------------
|
|
33
33
|
export * from './spree/address-block'
|
|
34
34
|
export * from './spree/address-book-row'
|
|
35
|
+
export * from './spree/address-select'
|
|
35
36
|
export * from './spree/back-button'
|
|
36
37
|
export * from './spree/bulk-dialog'
|
|
37
38
|
export * from './spree/bulk-price-table'
|
|
@@ -117,6 +118,7 @@ export * from './ui/empty'
|
|
|
117
118
|
export * from './ui/field'
|
|
118
119
|
export * from './ui/input'
|
|
119
120
|
export * from './ui/input-group'
|
|
121
|
+
export * from './ui/item'
|
|
120
122
|
export * from './ui/label'
|
|
121
123
|
export * from './ui/media-preview'
|
|
122
124
|
export * from './ui/pagination'
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { intlDisplayName } from '@spree/core'
|
|
1
2
|
import { useTranslation } from 'react-i18next'
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -28,24 +29,20 @@ function localizedCountryName(
|
|
|
28
29
|
iso?: string | null,
|
|
29
30
|
name?: string | null,
|
|
30
31
|
): string | null {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const localized = new Intl.DisplayNames([locale, 'en'], { type: 'region' }).of(iso)
|
|
34
|
-
if (localized) return localized
|
|
35
|
-
} catch {
|
|
36
|
-
// Runtime without Intl.DisplayNames coverage — fall back below.
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return name ?? iso ?? null
|
|
32
|
+
const localized = iso ? intlDisplayName('region', locale, iso) : undefined
|
|
33
|
+
return localized || (name ?? iso ?? null)
|
|
40
34
|
}
|
|
41
35
|
|
|
42
36
|
export function AddressBlock({
|
|
43
37
|
title,
|
|
44
38
|
address,
|
|
39
|
+
emptyText,
|
|
45
40
|
}: {
|
|
46
41
|
/** Omitted where the surrounding card already names the address. */
|
|
47
42
|
title?: string
|
|
48
43
|
address: AddressBlockValue | null | undefined
|
|
44
|
+
/** Shown in place of the address when there is none. Defaults to "Not provided". */
|
|
45
|
+
emptyText?: string
|
|
49
46
|
}) {
|
|
50
47
|
const { t, i18n } = useTranslation()
|
|
51
48
|
return (
|
|
@@ -67,7 +64,7 @@ export function AddressBlock({
|
|
|
67
64
|
</div>
|
|
68
65
|
) : (
|
|
69
66
|
<span className="text-sm text-muted-foreground">
|
|
70
|
-
{t('admin.components.address_block.not_provided')}
|
|
67
|
+
{emptyText ?? t('admin.components.address_block.not_provided')}
|
|
71
68
|
</span>
|
|
72
69
|
)}
|
|
73
70
|
</div>
|
|
@@ -1,14 +1,8 @@
|
|
|
1
1
|
import { useTranslation } from 'react-i18next'
|
|
2
2
|
import { Badge } from '../ui/badge'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
DropdownMenu,
|
|
6
|
-
DropdownMenuContent,
|
|
7
|
-
DropdownMenuItem,
|
|
8
|
-
DropdownMenuTrigger,
|
|
9
|
-
} from '../ui/dropdown-menu'
|
|
3
|
+
import { Item, ItemActions, ItemContent, ItemTitle } from '../ui/item'
|
|
10
4
|
import { AddressBlock, type AddressBlockValue } from './address-block'
|
|
11
|
-
import {
|
|
5
|
+
import { RowActions } from './row-actions'
|
|
12
6
|
|
|
13
7
|
/** What every address book row needs, whoever owns the book. */
|
|
14
8
|
export interface AddressBookEntry extends AddressBlockValue {
|
|
@@ -29,6 +23,7 @@ export interface AddressBookEntry extends AddressBlockValue {
|
|
|
29
23
|
export function AddressBookRow({
|
|
30
24
|
address,
|
|
31
25
|
canEdit = true,
|
|
26
|
+
inheritedFrom,
|
|
32
27
|
onEdit,
|
|
33
28
|
onRemove,
|
|
34
29
|
onSetDefaultBilling,
|
|
@@ -36,6 +31,11 @@ export function AddressBookRow({
|
|
|
36
31
|
}: {
|
|
37
32
|
address: AddressBookEntry
|
|
38
33
|
canEdit?: boolean
|
|
34
|
+
/**
|
|
35
|
+
* Names the book this entry is inherited from. An inherited entry is read
|
|
36
|
+
* only here, and its defaults are its owner's, so neither is shown.
|
|
37
|
+
*/
|
|
38
|
+
inheritedFrom?: string | null
|
|
39
39
|
onEdit: () => void
|
|
40
40
|
onRemove: () => void
|
|
41
41
|
/** Omit either to hide that action — a book with one address has no choice. */
|
|
@@ -43,54 +43,54 @@ export function AddressBookRow({
|
|
|
43
43
|
onSetDefaultShipping?: () => void
|
|
44
44
|
}) {
|
|
45
45
|
const { t } = useTranslation()
|
|
46
|
+
const inherited = inheritedFrom != null
|
|
47
|
+
const isDefaultBilling = !inherited && address.is_default_billing
|
|
48
|
+
const isDefaultShipping = !inherited && address.is_default_shipping
|
|
46
49
|
|
|
47
50
|
return (
|
|
48
|
-
<
|
|
49
|
-
<
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
</span>
|
|
61
|
-
<AddressBlock address={address} />
|
|
62
|
-
</div>
|
|
63
|
-
|
|
64
|
-
{canEdit && (
|
|
65
|
-
<DropdownMenu>
|
|
66
|
-
<DropdownMenuTrigger asChild>
|
|
67
|
-
<Button variant="ghost" size="icon-xs" className="shrink-0">
|
|
68
|
-
<EllipsisVerticalIcon className="size-4" />
|
|
69
|
-
<span className="sr-only">{t('admin.actions.actions_menu')}</span>
|
|
70
|
-
</Button>
|
|
71
|
-
</DropdownMenuTrigger>
|
|
72
|
-
<DropdownMenuContent align="end">
|
|
73
|
-
<DropdownMenuItem onClick={onEdit}>
|
|
74
|
-
<PencilIcon className="size-4" />
|
|
75
|
-
{t('admin.actions.edit')}
|
|
76
|
-
</DropdownMenuItem>
|
|
77
|
-
{onSetDefaultBilling && !address.is_default_billing && (
|
|
78
|
-
<DropdownMenuItem onClick={onSetDefaultBilling}>
|
|
79
|
-
{t('admin.address_book.set_default_billing')}
|
|
80
|
-
</DropdownMenuItem>
|
|
51
|
+
<Item variant="outline" className="items-start">
|
|
52
|
+
<ItemContent className="min-w-0">
|
|
53
|
+
{(address.label || inherited || isDefaultBilling || isDefaultShipping) && (
|
|
54
|
+
<ItemTitle className="flex-wrap">
|
|
55
|
+
{address.label}
|
|
56
|
+
{inherited && (
|
|
57
|
+
<Badge variant="secondary">
|
|
58
|
+
{t('admin.address_book.inherited_from', { name: inheritedFrom })}
|
|
59
|
+
</Badge>
|
|
60
|
+
)}
|
|
61
|
+
{isDefaultBilling && (
|
|
62
|
+
<Badge variant="outline">{t('admin.address_book.default_billing')}</Badge>
|
|
81
63
|
)}
|
|
82
|
-
{
|
|
83
|
-
<
|
|
84
|
-
{t('admin.address_book.set_default_shipping')}
|
|
85
|
-
</DropdownMenuItem>
|
|
64
|
+
{isDefaultShipping && (
|
|
65
|
+
<Badge variant="outline">{t('admin.address_book.default_shipping')}</Badge>
|
|
86
66
|
)}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
67
|
+
</ItemTitle>
|
|
68
|
+
)}
|
|
69
|
+
<AddressBlock address={address} />
|
|
70
|
+
</ItemContent>
|
|
71
|
+
|
|
72
|
+
{canEdit && !inherited && (
|
|
73
|
+
<ItemActions>
|
|
74
|
+
<RowActions
|
|
75
|
+
actions={[
|
|
76
|
+
{ key: 'edit', onSelect: onEdit },
|
|
77
|
+
{
|
|
78
|
+
key: 'set_default_billing',
|
|
79
|
+
label: t('admin.address_book.set_default_billing'),
|
|
80
|
+
visible: !!onSetDefaultBilling && !address.is_default_billing,
|
|
81
|
+
onSelect: () => onSetDefaultBilling?.(),
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
key: 'set_default_shipping',
|
|
85
|
+
label: t('admin.address_book.set_default_shipping'),
|
|
86
|
+
visible: !!onSetDefaultShipping && !address.is_default_shipping,
|
|
87
|
+
onSelect: () => onSetDefaultShipping?.(),
|
|
88
|
+
},
|
|
89
|
+
{ key: 'delete', destructive: true, onSelect: onRemove },
|
|
90
|
+
]}
|
|
91
|
+
/>
|
|
92
|
+
</ItemActions>
|
|
93
93
|
)}
|
|
94
|
-
</
|
|
94
|
+
</Item>
|
|
95
95
|
)
|
|
96
96
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { useTranslation } from 'react-i18next'
|
|
2
|
+
import {
|
|
3
|
+
Select,
|
|
4
|
+
SelectContent,
|
|
5
|
+
SelectGroup,
|
|
6
|
+
SelectItem,
|
|
7
|
+
SelectLabel,
|
|
8
|
+
SelectTrigger,
|
|
9
|
+
SelectValue,
|
|
10
|
+
} from '../ui/select'
|
|
11
|
+
import type { AddressBlockValue } from './address-block'
|
|
12
|
+
|
|
13
|
+
/** One entry a picker may offer, from whichever book it came out of. */
|
|
14
|
+
export interface AddressOption extends AddressBlockValue {
|
|
15
|
+
id: string
|
|
16
|
+
label?: string | null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A set of entries under the name of the book they came from — a customer's
|
|
21
|
+
* own addresses, a company's sites. Groups are rendered in the order given.
|
|
22
|
+
*/
|
|
23
|
+
export interface AddressOptionGroup {
|
|
24
|
+
key: string
|
|
25
|
+
heading: string
|
|
26
|
+
addresses: AddressOption[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The one-line form a picker shows: the entry's own name, then the street. */
|
|
30
|
+
export function summarizeAddress(address: AddressOption): string {
|
|
31
|
+
const place = [address.address1, address.city, address.state_text, address.postal_code]
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
.join(', ')
|
|
34
|
+
|
|
35
|
+
const who = address.label || address.company || address.full_name
|
|
36
|
+
|
|
37
|
+
return [who, place].filter(Boolean).join(' — ')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Picks one saved address out of the books a purchase may draw on.
|
|
42
|
+
*
|
|
43
|
+
* Headless: the groups arrive as props, so the same control serves a buyer's
|
|
44
|
+
* own book, a company's sites, and both at once — which is the B2B case, where
|
|
45
|
+
* an order for a company may ship to that company's sites and to the ones it
|
|
46
|
+
* inherits from the companies above it.
|
|
47
|
+
*
|
|
48
|
+
* Base UI resolves the trigger's label from `items`, so the summary line shows
|
|
49
|
+
* instead of the raw prefixed id.
|
|
50
|
+
*/
|
|
51
|
+
export function AddressSelect({
|
|
52
|
+
id,
|
|
53
|
+
value,
|
|
54
|
+
onValueChange,
|
|
55
|
+
groups,
|
|
56
|
+
placeholder,
|
|
57
|
+
emptyLabel,
|
|
58
|
+
disabled,
|
|
59
|
+
}: {
|
|
60
|
+
id?: string
|
|
61
|
+
/** The chosen address id, or `''` for "none picked". */
|
|
62
|
+
value: string
|
|
63
|
+
onValueChange: (value: string) => void
|
|
64
|
+
groups: AddressOptionGroup[]
|
|
65
|
+
placeholder?: string
|
|
66
|
+
/** Offered first so a merchant can take the choice back. */
|
|
67
|
+
emptyLabel?: string
|
|
68
|
+
disabled?: boolean
|
|
69
|
+
}) {
|
|
70
|
+
const { t } = useTranslation()
|
|
71
|
+
const none = emptyLabel ?? t('admin.address_select.none')
|
|
72
|
+
|
|
73
|
+
const items = [
|
|
74
|
+
{ value: '', label: none },
|
|
75
|
+
...groups.flatMap((group) =>
|
|
76
|
+
group.addresses.map((address) => ({
|
|
77
|
+
value: address.id,
|
|
78
|
+
label: summarizeAddress(address),
|
|
79
|
+
})),
|
|
80
|
+
),
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<Select items={items} value={value} onValueChange={onValueChange} disabled={disabled}>
|
|
85
|
+
<SelectTrigger id={id}>
|
|
86
|
+
<SelectValue placeholder={placeholder ?? none} />
|
|
87
|
+
</SelectTrigger>
|
|
88
|
+
<SelectContent>
|
|
89
|
+
<SelectItem value="">{none}</SelectItem>
|
|
90
|
+
{groups
|
|
91
|
+
.filter((group) => group.addresses.length > 0)
|
|
92
|
+
.map((group) => (
|
|
93
|
+
<SelectGroup key={group.key}>
|
|
94
|
+
<SelectLabel>{group.heading}</SelectLabel>
|
|
95
|
+
{group.addresses.map((address) => (
|
|
96
|
+
<SelectItem key={address.id} value={address.id}>
|
|
97
|
+
{summarizeAddress(address)}
|
|
98
|
+
</SelectItem>
|
|
99
|
+
))}
|
|
100
|
+
</SelectGroup>
|
|
101
|
+
))}
|
|
102
|
+
</SelectContent>
|
|
103
|
+
</Select>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
import type { ColumnDef } from '@tanstack/react-table'
|
|
2
1
|
import { PlusIcon, XIcon } from 'lucide-react'
|
|
3
2
|
import { useMemo } from 'react'
|
|
3
|
+
import { Alert, AlertDescription } from '../ui/alert'
|
|
4
4
|
import { Button } from '../ui/button'
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
DataGrid,
|
|
7
|
+
type DataGridColumn,
|
|
8
|
+
DecimalCell,
|
|
9
|
+
editableRowIndex,
|
|
10
|
+
MoneyCell,
|
|
11
|
+
ReadOnlyCell,
|
|
12
|
+
} from './data-grid'
|
|
13
|
+
import { CircleAlertIcon } from './icons'
|
|
6
14
|
import { SearchInput } from './search-input'
|
|
7
15
|
|
|
8
16
|
export interface BulkPriceRow {
|
|
@@ -131,7 +139,7 @@ export function BulkPriceTable({
|
|
|
131
139
|
isLoading,
|
|
132
140
|
error,
|
|
133
141
|
}: BulkPriceTableProps) {
|
|
134
|
-
const columns = useMemo<
|
|
142
|
+
const columns = useMemo<DataGridColumn<BulkPriceRow>[]>(
|
|
135
143
|
() => [
|
|
136
144
|
{
|
|
137
145
|
id: 'variant',
|
|
@@ -203,7 +211,7 @@ export function BulkPriceTable({
|
|
|
203
211
|
/>
|
|
204
212
|
)
|
|
205
213
|
},
|
|
206
|
-
} satisfies
|
|
214
|
+
} satisfies DataGridColumn<BulkPriceRow>,
|
|
207
215
|
]
|
|
208
216
|
: []),
|
|
209
217
|
{
|
|
@@ -291,7 +299,7 @@ export function BulkPriceTable({
|
|
|
291
299
|
</Button>
|
|
292
300
|
)
|
|
293
301
|
},
|
|
294
|
-
} satisfies
|
|
302
|
+
} satisfies DataGridColumn<BulkPriceRow>,
|
|
295
303
|
]
|
|
296
304
|
: []),
|
|
297
305
|
],
|
|
@@ -312,12 +320,10 @@ export function BulkPriceTable({
|
|
|
312
320
|
// table in a card does.
|
|
313
321
|
<div className="flex h-full flex-col gap-3">
|
|
314
322
|
{error && (
|
|
315
|
-
<
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
>
|
|
319
|
-
{error}
|
|
320
|
-
</div>
|
|
323
|
+
<Alert variant="destructive" className="mx-3 w-auto shrink-0">
|
|
324
|
+
<CircleAlertIcon />
|
|
325
|
+
<AlertDescription>{error}</AlertDescription>
|
|
326
|
+
</Alert>
|
|
321
327
|
)}
|
|
322
328
|
{showToolbar && (
|
|
323
329
|
// Always-mounted toolbar. Conditionally rendering the search input
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import type { ColumnDef } from '@tanstack/react-table'
|
|
2
1
|
import { useMemo } from 'react'
|
|
3
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
DataGrid,
|
|
4
|
+
type DataGridColumn,
|
|
5
|
+
DecimalCell,
|
|
6
|
+
ReadOnlyCell,
|
|
7
|
+
SelectCell,
|
|
8
|
+
SwitchCell,
|
|
9
|
+
TextCell,
|
|
10
|
+
} from './data-grid'
|
|
4
11
|
|
|
5
12
|
export interface BulkVariantsRow {
|
|
6
13
|
id: string
|
|
@@ -139,7 +146,7 @@ export function BulkVariantsTable({
|
|
|
139
146
|
cartonOptions,
|
|
140
147
|
onChange,
|
|
141
148
|
}: BulkVariantsTableProps) {
|
|
142
|
-
const columns = useMemo<
|
|
149
|
+
const columns = useMemo<DataGridColumn<BulkVariantsRow>[]>(() => {
|
|
143
150
|
// Keyboard column coordinates, fixed per column: variant label is
|
|
144
151
|
// read-only (unregistered), then sku=1, barcode=2, weight=3, unit=4,
|
|
145
152
|
// height=5, width=6, depth=7, unit=8, preorderable=9, backorder=10,
|
|
@@ -148,7 +155,7 @@ export function BulkVariantsTable({
|
|
|
148
155
|
const numericHeader = (label: string) => () => <span className="block text-right">{label}</span>
|
|
149
156
|
const rowLabel = (r: BulkVariantsRow) => r.variantLabel ?? labels.variantDefault
|
|
150
157
|
|
|
151
|
-
const defs:
|
|
158
|
+
const defs: DataGridColumn<BulkVariantsRow>[] = [
|
|
152
159
|
{
|
|
153
160
|
id: 'variant',
|
|
154
161
|
header: labels.variant,
|
|
@@ -182,7 +189,7 @@ export function BulkVariantsTable({
|
|
|
182
189
|
/>
|
|
183
190
|
),
|
|
184
191
|
},
|
|
185
|
-
...DECIMAL_COLUMNS.map<
|
|
192
|
+
...DECIMAL_COLUMNS.map<DataGridColumn<BulkVariantsRow>>(({ field, col }) => ({
|
|
186
193
|
id: field,
|
|
187
194
|
header: numericHeader(labels[field]),
|
|
188
195
|
cell: ({ row }) => (
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
* Lives on the frontend (rather than as a server-rendered string) so
|
|
14
14
|
* draft edits update the row immediately, before any save.
|
|
15
15
|
*/
|
|
16
|
+
|
|
17
|
+
import { formatMoney, isDecimalString } from '@spree/core'
|
|
18
|
+
|
|
16
19
|
export interface CalculatorPayload {
|
|
17
20
|
/**
|
|
18
21
|
* Display name for the calculator, already localized by the caller — this
|
|
@@ -101,7 +104,12 @@ function formatField(
|
|
|
101
104
|
): string | null {
|
|
102
105
|
if (value === null || value === undefined || value === '') return null
|
|
103
106
|
if (property.format === 'currency') return null
|
|
104
|
-
if (property.format === 'money')
|
|
107
|
+
if (property.format === 'money') {
|
|
108
|
+
const amount = String(value)
|
|
109
|
+
return (
|
|
110
|
+
formatMoney(amount, currency) || (isDecimalString(amount) ? `${amount} ${currency}` : amount)
|
|
111
|
+
)
|
|
112
|
+
}
|
|
105
113
|
if (/(?:^|_)percent$/.test(key)) return `${value}%`
|
|
106
114
|
|
|
107
115
|
const type = (Array.isArray(property.type) ? property.type : [property.type]).find(
|
|
@@ -115,19 +123,6 @@ function formatField(
|
|
|
115
123
|
return `${humanize(key)}: ${value}`
|
|
116
124
|
}
|
|
117
125
|
|
|
118
|
-
function formatMoney(value: unknown, currency: string): string {
|
|
119
|
-
const amount = String(value)
|
|
120
|
-
if (!/^-?\d+(\.\d+)?$/.test(amount)) return amount
|
|
121
|
-
try {
|
|
122
|
-
// `Intl` reads a numeric string exactly; its ES2022 typings only list numbers.
|
|
123
|
-
return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(
|
|
124
|
-
amount as unknown as number,
|
|
125
|
-
)
|
|
126
|
-
} catch {
|
|
127
|
-
return `${amount} ${currency}`
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
126
|
function humanize(key: string): string {
|
|
132
127
|
const spaced = key.replace(/_/g, ' ').trim()
|
|
133
128
|
return spaced.charAt(0).toUpperCase() + spaced.slice(1)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CLAIM_RESOLUTIONS, type ClaimResolution, isPositiveMoney } from '@spree/core'
|
|
1
2
|
import { useState } from 'react'
|
|
2
3
|
import { useTranslation } from 'react-i18next'
|
|
3
4
|
import { Button } from '../ui/button'
|
|
@@ -11,12 +12,12 @@ import {
|
|
|
11
12
|
} from '../ui/dialog'
|
|
12
13
|
import { Field, FieldLabel } from '../ui/field'
|
|
13
14
|
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from '../ui/input-group'
|
|
15
|
+
import { Item, ItemActions, ItemContent, ItemTitle } from '../ui/item'
|
|
14
16
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
|
15
17
|
import { Switch } from '../ui/switch'
|
|
16
18
|
import type { RefundMethod } from './return-dialogs'
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
export type ClaimResolution = 'refund' | 'replacement' | 'refund_and_replacement'
|
|
20
|
+
export type { ClaimResolution }
|
|
20
21
|
|
|
21
22
|
/** One claimed line, as the resolve form needs it. */
|
|
22
23
|
export type ClaimLineForResolve = {
|
|
@@ -73,12 +74,10 @@ export function ClaimResolveDialog({
|
|
|
73
74
|
.filter(([, on]) => on)
|
|
74
75
|
.map(([id]) => id)
|
|
75
76
|
|
|
76
|
-
const resolutionOptions = (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}),
|
|
81
|
-
)
|
|
77
|
+
const resolutionOptions = CLAIM_RESOLUTIONS.map((value) => ({
|
|
78
|
+
value,
|
|
79
|
+
label: t(`admin.pages.orders.detail.claims.resolutions.${value}`),
|
|
80
|
+
}))
|
|
82
81
|
|
|
83
82
|
const methodOptions = [
|
|
84
83
|
{
|
|
@@ -91,11 +90,11 @@ export function ClaimResolveDialog({
|
|
|
91
90
|
},
|
|
92
91
|
]
|
|
93
92
|
|
|
93
|
+
// A number input keeps `.5` as typed; the API reads only `0.5`.
|
|
94
|
+
const refundAmount = amount.startsWith('.') ? `0${amount}` : amount
|
|
94
95
|
// Refunding nothing, or replacing nothing, is what the server rejects —
|
|
95
96
|
// say so here instead of letting the request fail.
|
|
96
|
-
|
|
97
|
-
// "above zero" is unsigned digits with a non-zero digit among them.
|
|
98
|
-
const positiveAmount = /^(\d+(\.\d+)?|\.\d+)$/.test(amount) && /[1-9]/.test(amount)
|
|
97
|
+
const positiveAmount = isPositiveMoney(refundAmount)
|
|
99
98
|
const ready =
|
|
100
99
|
(!refunding || positiveAmount) && (!sendingReplacement || chosenReplacements.length > 0)
|
|
101
100
|
|
|
@@ -178,21 +177,23 @@ export function ClaimResolveDialog({
|
|
|
178
177
|
{t('admin.pages.orders.detail.claims.replacement_items')}
|
|
179
178
|
</span>
|
|
180
179
|
{lines.map((line) => (
|
|
181
|
-
<
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
180
|
+
<Item key={line.id} variant="outline" className="flex-nowrap border-border-subtle">
|
|
181
|
+
<ItemContent className="min-w-0">
|
|
182
|
+
<ItemTitle className="block truncate font-normal">
|
|
183
|
+
{line.label}
|
|
184
|
+
<span className="text-muted-foreground"> ×{line.quantity}</span>
|
|
185
|
+
</ItemTitle>
|
|
186
|
+
</ItemContent>
|
|
187
|
+
<ItemActions>
|
|
188
|
+
<Switch
|
|
189
|
+
aria-label={line.label}
|
|
190
|
+
checked={replacing[line.id] ?? false}
|
|
191
|
+
onCheckedChange={(checked) =>
|
|
192
|
+
setReplacing({ ...replacing, [line.id]: checked })
|
|
193
|
+
}
|
|
194
|
+
/>
|
|
195
|
+
</ItemActions>
|
|
196
|
+
</Item>
|
|
196
197
|
))}
|
|
197
198
|
</div>
|
|
198
199
|
)}
|
|
@@ -208,7 +209,7 @@ export function ClaimResolveDialog({
|
|
|
208
209
|
onSubmit({
|
|
209
210
|
resolution,
|
|
210
211
|
refundMethod,
|
|
211
|
-
amount: refunding ?
|
|
212
|
+
amount: refunding ? refundAmount : undefined,
|
|
212
213
|
replacementLineItemIds: chosenReplacements,
|
|
213
214
|
})
|
|
214
215
|
}
|