@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
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type ColumnDef,
|
|
2
3
|
flexRender,
|
|
3
|
-
getCoreRowModel,
|
|
4
4
|
type Row,
|
|
5
|
+
type RowData,
|
|
5
6
|
type Table,
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
tableFeatures,
|
|
8
|
+
useTable,
|
|
8
9
|
} from '@tanstack/react-table'
|
|
9
10
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
|
10
11
|
import { cn } from '../../lib/utils'
|
|
@@ -14,9 +15,18 @@ import type { CellCoords, CellKey, CellRegistration, RenderSectionHeader } from
|
|
|
14
15
|
import { cellKey } from './types'
|
|
15
16
|
import { useDataGridKeyboard } from './use-data-grid-keyboard'
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
// The grid renders rows as given — no sorting, filtering or column state — so
|
|
19
|
+
// it needs only the table's core features.
|
|
20
|
+
const dataGridFeatures = tableFeatures({})
|
|
21
|
+
type DataGridFeatures = typeof dataGridFeatures
|
|
22
|
+
|
|
23
|
+
/** A column of a `DataGrid`. */
|
|
24
|
+
// biome-ignore lint/suspicious/noExplicitAny: each column reads a value of its own type
|
|
25
|
+
export type DataGridColumn<T extends RowData> = ColumnDef<DataGridFeatures, T, any>
|
|
26
|
+
|
|
27
|
+
interface DataGridProps<T extends RowData> {
|
|
18
28
|
rows: T[]
|
|
19
|
-
columns:
|
|
29
|
+
columns: DataGridColumn<T>[]
|
|
20
30
|
getRowId: (row: T) => string
|
|
21
31
|
/** Optional grouping. Return a node spanning all columns to render this row
|
|
22
32
|
* as a (non-editable) section header. Return null to render the row as
|
|
@@ -39,7 +49,7 @@ interface DataGridProps<T> {
|
|
|
39
49
|
scrollable?: boolean
|
|
40
50
|
}
|
|
41
51
|
|
|
42
|
-
export function DataGrid<T>({
|
|
52
|
+
export function DataGrid<T extends RowData>({
|
|
43
53
|
rows,
|
|
44
54
|
columns,
|
|
45
55
|
getRowId,
|
|
@@ -48,11 +58,11 @@ export function DataGrid<T>({
|
|
|
48
58
|
'aria-label': ariaLabel,
|
|
49
59
|
scrollable = false,
|
|
50
60
|
}: DataGridProps<T>) {
|
|
51
|
-
const table =
|
|
61
|
+
const table = useTable({
|
|
62
|
+
features: dataGridFeatures,
|
|
52
63
|
data: rows,
|
|
53
64
|
columns,
|
|
54
65
|
getRowId,
|
|
55
|
-
getCoreRowModel: getCoreRowModel(),
|
|
56
66
|
})
|
|
57
67
|
|
|
58
68
|
return (
|
|
@@ -66,14 +76,14 @@ export function DataGrid<T>({
|
|
|
66
76
|
)
|
|
67
77
|
}
|
|
68
78
|
|
|
69
|
-
function DataGridShell<T>({
|
|
79
|
+
function DataGridShell<T extends RowData>({
|
|
70
80
|
table,
|
|
71
81
|
renderSectionHeader,
|
|
72
82
|
className,
|
|
73
83
|
ariaLabel,
|
|
74
84
|
scrollable,
|
|
75
85
|
}: {
|
|
76
|
-
table: Table<T>
|
|
86
|
+
table: Table<DataGridFeatures, T>
|
|
77
87
|
renderSectionHeader?: RenderSectionHeader<T>
|
|
78
88
|
className?: string
|
|
79
89
|
ariaLabel?: string
|
|
@@ -324,12 +334,12 @@ function DataGridShell<T>({
|
|
|
324
334
|
)
|
|
325
335
|
}
|
|
326
336
|
|
|
327
|
-
function DataGridRow<T>({
|
|
337
|
+
function DataGridRow<T extends RowData>({
|
|
328
338
|
row,
|
|
329
339
|
renderSectionHeader,
|
|
330
340
|
columnCount,
|
|
331
341
|
}: {
|
|
332
|
-
row: Row<T>
|
|
342
|
+
row: Row<DataGridFeatures, T>
|
|
333
343
|
renderSectionHeader?: RenderSectionHeader<T>
|
|
334
344
|
columnCount: number
|
|
335
345
|
}) {
|
|
@@ -345,7 +355,7 @@ function DataGridRow<T>({
|
|
|
345
355
|
}
|
|
346
356
|
return (
|
|
347
357
|
<tr>
|
|
348
|
-
{row.
|
|
358
|
+
{row.getAllCells().map((cell) => (
|
|
349
359
|
<td key={cell.id} className="h-9 p-0 align-middle">
|
|
350
360
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
351
361
|
</td>
|
|
@@ -8,6 +8,6 @@ export {
|
|
|
8
8
|
TextCell,
|
|
9
9
|
} from './cells'
|
|
10
10
|
export { useDataGridContext } from './context'
|
|
11
|
-
export { DataGrid } from './data-grid'
|
|
11
|
+
export { DataGrid, type DataGridColumn } from './data-grid'
|
|
12
12
|
export { editableRowIndex } from './helpers'
|
|
13
13
|
export type { CellCoords, RenderSectionHeader } from './types'
|
|
@@ -22,16 +22,22 @@ export function FormSaveButton<TFieldValues extends FieldValues>({
|
|
|
22
22
|
form,
|
|
23
23
|
label,
|
|
24
24
|
savingLabel,
|
|
25
|
+
dirty = false,
|
|
25
26
|
}: {
|
|
26
27
|
form: UseFormReturn<TFieldValues, any, any>
|
|
27
28
|
label?: string
|
|
28
29
|
savingLabel?: string
|
|
30
|
+
/**
|
|
31
|
+
* Unsaved changes held outside the form — custom field drafts the page
|
|
32
|
+
* saves from its own submit — counted as dirty alongside the form's own.
|
|
33
|
+
*/
|
|
34
|
+
dirty?: boolean
|
|
29
35
|
}) {
|
|
30
36
|
const { t } = useTranslation()
|
|
31
37
|
const { isDirty, isSubmitting } = useFormState({ control: form.control })
|
|
32
38
|
|
|
33
39
|
return (
|
|
34
|
-
<Button type="submit" disabled={!isDirty || isSubmitting}>
|
|
40
|
+
<Button type="submit" disabled={!(isDirty || dirty) || isSubmitting}>
|
|
35
41
|
{isSubmitting && <Loader2Icon className="animate-spin" />}
|
|
36
42
|
{isSubmitting
|
|
37
43
|
? (savingLabel ?? t('admin.actions.saving'))
|
|
@@ -52,15 +58,22 @@ export function FormDiscardButton<TFieldValues extends FieldValues>({
|
|
|
52
58
|
form,
|
|
53
59
|
label,
|
|
54
60
|
onDiscard,
|
|
61
|
+
dirty = false,
|
|
55
62
|
}: {
|
|
56
63
|
form: UseFormReturn<TFieldValues, any, any>
|
|
57
64
|
label?: string
|
|
58
65
|
onDiscard?: () => void
|
|
66
|
+
/**
|
|
67
|
+
* Unsaved changes held outside the form — custom field drafts the page
|
|
68
|
+
* saves from its own submit — counted as dirty alongside the form's own.
|
|
69
|
+
* Pass `onDiscard` too, so discarding drops them.
|
|
70
|
+
*/
|
|
71
|
+
dirty?: boolean
|
|
59
72
|
}) {
|
|
60
73
|
const { t } = useTranslation()
|
|
61
74
|
const { isDirty, isSubmitting } = useFormState({ control: form.control })
|
|
62
75
|
|
|
63
|
-
if (!isDirty) return null
|
|
76
|
+
if (!isDirty && !dirty) return null
|
|
64
77
|
|
|
65
78
|
return (
|
|
66
79
|
<Button
|
|
@@ -83,17 +96,23 @@ export function FormActions<TFieldValues extends FieldValues>({
|
|
|
83
96
|
savingLabel,
|
|
84
97
|
discardLabel,
|
|
85
98
|
onDiscard,
|
|
99
|
+
dirty,
|
|
86
100
|
}: {
|
|
87
101
|
form: UseFormReturn<TFieldValues, any, any>
|
|
88
102
|
saveLabel?: string
|
|
89
103
|
savingLabel?: string
|
|
90
104
|
discardLabel?: string
|
|
91
105
|
onDiscard?: () => void
|
|
106
|
+
/**
|
|
107
|
+
* Unsaved changes held outside the form — custom field drafts the page
|
|
108
|
+
* saves from its own submit — counted as dirty alongside the form's own.
|
|
109
|
+
*/
|
|
110
|
+
dirty?: boolean
|
|
92
111
|
}) {
|
|
93
112
|
return (
|
|
94
113
|
<>
|
|
95
|
-
<FormDiscardButton form={form} label={discardLabel} onDiscard={onDiscard} />
|
|
96
|
-
<FormSaveButton form={form} label={saveLabel} savingLabel={savingLabel} />
|
|
114
|
+
<FormDiscardButton form={form} label={discardLabel} onDiscard={onDiscard} dirty={dirty} />
|
|
115
|
+
<FormSaveButton form={form} label={saveLabel} savingLabel={savingLabel} dirty={dirty} />
|
|
97
116
|
</>
|
|
98
117
|
)
|
|
99
118
|
}
|
|
@@ -113,8 +132,11 @@ export function FormActions<TFieldValues extends FieldValues>({
|
|
|
113
132
|
export function useFormSubmitShortcut<TFieldValues extends FieldValues>(
|
|
114
133
|
form: UseFormReturn<TFieldValues, any, any>,
|
|
115
134
|
onSubmit: (values: TFieldValues) => void | Promise<void>,
|
|
135
|
+
/** Unsaved changes held outside the form, as on `FormActions`. */
|
|
136
|
+
{ dirty: externalDirty = false }: { dirty?: boolean } = {},
|
|
116
137
|
) {
|
|
117
|
-
const { isDirty, isSubmitting } = useFormState({ control: form.control })
|
|
138
|
+
const { isDirty: formDirty, isSubmitting } = useFormState({ control: form.control })
|
|
139
|
+
const isDirty = formDirty || externalDirty
|
|
118
140
|
|
|
119
141
|
useHotkey(
|
|
120
142
|
'Mod+S',
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
|
+
import { Item, ItemActions, ItemContent, ItemDescription, ItemTitle } from '../ui/item'
|
|
2
3
|
import { Thumbnail } from '../ui/thumbnail'
|
|
3
4
|
import { PackageIcon } from './icons'
|
|
4
5
|
|
|
@@ -30,17 +31,17 @@ export type FulfillmentItemRowData = {
|
|
|
30
31
|
*/
|
|
31
32
|
function ItemRow({ row }: { row: FulfillmentItemRowData }) {
|
|
32
33
|
return (
|
|
33
|
-
<
|
|
34
|
+
<Item className="flex-nowrap gap-3 rounded-none border-0 p-3">
|
|
34
35
|
<Thumbnail src={row.thumbnailUrl} fallback={<PackageIcon />} />
|
|
35
36
|
|
|
36
|
-
<
|
|
37
|
-
<
|
|
37
|
+
<ItemContent className="min-w-0 gap-0">
|
|
38
|
+
<ItemTitle className="block truncate">{row.name}</ItemTitle>
|
|
38
39
|
{row.optionsText && (
|
|
39
|
-
<
|
|
40
|
+
<ItemDescription className="line-clamp-1 text-xs">{row.optionsText}</ItemDescription>
|
|
40
41
|
)}
|
|
41
|
-
</
|
|
42
|
+
</ItemContent>
|
|
42
43
|
|
|
43
|
-
<
|
|
44
|
+
<ItemActions className="min-w-0 shrink flex-col items-end gap-0 text-right text-muted-foreground text-sm">
|
|
44
45
|
{row.displayPrice ? (
|
|
45
46
|
<span className="whitespace-nowrap">
|
|
46
47
|
{row.displayPrice} × {row.quantity}
|
|
@@ -48,9 +49,9 @@ function ItemRow({ row }: { row: FulfillmentItemRowData }) {
|
|
|
48
49
|
) : (
|
|
49
50
|
<span className="whitespace-nowrap">× {row.quantity}</span>
|
|
50
51
|
)}
|
|
51
|
-
{row.priceSource && <div className="truncate text-xs">{row.priceSource}</div>}
|
|
52
|
-
</
|
|
53
|
-
</
|
|
52
|
+
{row.priceSource && <div className="max-w-full truncate text-xs">{row.priceSource}</div>}
|
|
53
|
+
</ItemActions>
|
|
54
|
+
</Item>
|
|
54
55
|
)
|
|
55
56
|
}
|
|
56
57
|
|
package/src/spree/icons.ts
CHANGED
|
@@ -21,9 +21,11 @@ export {
|
|
|
21
21
|
BellIcon,
|
|
22
22
|
BoldIcon,
|
|
23
23
|
BookOpenIcon,
|
|
24
|
+
BotIcon,
|
|
24
25
|
BoxesIcon,
|
|
25
26
|
BracesIcon,
|
|
26
27
|
Building2Icon,
|
|
28
|
+
CalculatorIcon,
|
|
27
29
|
CalendarIcon,
|
|
28
30
|
CalendarOffIcon,
|
|
29
31
|
ChartColumnIcon,
|
|
@@ -36,6 +38,7 @@ export {
|
|
|
36
38
|
ChevronsLeftIcon,
|
|
37
39
|
ChevronsRightIcon,
|
|
38
40
|
ChevronsUpDownIcon,
|
|
41
|
+
CircleAlertIcon,
|
|
39
42
|
CircleCheckIcon,
|
|
40
43
|
CircleDotIcon,
|
|
41
44
|
CircleIcon,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useTranslation } from 'react-i18next'
|
|
2
2
|
import { Field, FieldLabel } from '../ui/field'
|
|
3
3
|
import { Input } from '../ui/input'
|
|
4
|
+
import { Item, ItemActions, ItemContent, ItemTitle } from '../ui/item'
|
|
4
5
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
|
5
6
|
|
|
6
7
|
/** A unit on the order that a return, exchange or claim can name. */
|
|
@@ -31,26 +32,27 @@ export function QuantityPicker({
|
|
|
31
32
|
return (
|
|
32
33
|
<div className="flex flex-col gap-2">
|
|
33
34
|
{units.map((unit) => (
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
35
|
+
<Item key={unit.id} variant="outline" className="flex-nowrap border-border-subtle">
|
|
36
|
+
<ItemContent className="min-w-0">
|
|
37
|
+
<ItemTitle className="block truncate font-normal">{unit.label}</ItemTitle>
|
|
38
|
+
</ItemContent>
|
|
39
|
+
<ItemActions>
|
|
40
|
+
<Input
|
|
41
|
+
type="number"
|
|
42
|
+
min={0}
|
|
43
|
+
max={unit.quantity}
|
|
44
|
+
className="w-20"
|
|
45
|
+
value={selection[unit.id] ?? 0}
|
|
46
|
+
aria-label={unit.label}
|
|
47
|
+
onChange={(event) =>
|
|
48
|
+
onChange({
|
|
49
|
+
...selection,
|
|
50
|
+
[unit.id]: Math.max(0, Math.min(Number(event.target.value), unit.quantity)),
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
/>
|
|
54
|
+
</ItemActions>
|
|
55
|
+
</Item>
|
|
54
56
|
))}
|
|
55
57
|
</div>
|
|
56
58
|
)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ReactNode, useState } from 'react'
|
|
2
2
|
import {
|
|
3
3
|
Combobox,
|
|
4
4
|
ComboboxContent,
|
|
@@ -84,14 +84,30 @@ export function ResourceCombobox<T extends ComboboxOption>({
|
|
|
84
84
|
disabled,
|
|
85
85
|
id,
|
|
86
86
|
}: ResourceComboboxProps<T>) {
|
|
87
|
-
|
|
87
|
+
// The picked record is remembered so it stays selected while `items` is
|
|
88
|
+
// between fetches. Without it, the selection drops out of the list for a
|
|
89
|
+
// moment, Base UI reads that as the value being cleared, empties the input,
|
|
90
|
+
// and the field flickers between labels until the queries settle.
|
|
91
|
+
const [picked, setPicked] = useState<T | null>(null)
|
|
92
|
+
const selected =
|
|
93
|
+
items.find((r) => r.id === value) ?? (picked && picked.id === value ? picked : null)
|
|
88
94
|
|
|
89
95
|
return (
|
|
90
96
|
<Combobox
|
|
91
97
|
items={items}
|
|
92
98
|
value={selected}
|
|
93
|
-
|
|
94
|
-
|
|
99
|
+
// Compared by id: a refetch hands back equal records as new objects.
|
|
100
|
+
isItemEqualToValue={(item: T, current: T) => item.id === current.id}
|
|
101
|
+
onValueChange={(record: T | null) => {
|
|
102
|
+
setPicked(record)
|
|
103
|
+
onChange(record?.id, record)
|
|
104
|
+
}}
|
|
105
|
+
// Picking an option writes its label into the input. That is not a
|
|
106
|
+
// search, so the query goes back to the empty list instead of searching
|
|
107
|
+
// for the full label of what was just chosen.
|
|
108
|
+
onInputValueChange={(next, details) =>
|
|
109
|
+
onInputChange?.(details.reason === 'item-press' ? '' : next)
|
|
110
|
+
}
|
|
95
111
|
itemToStringLabel={(record: T | null) => (record ? getOptionLabel(record) : '')}
|
|
96
112
|
itemToStringValue={(record: T | null) => record?.id ?? ''}
|
|
97
113
|
// Filtering is server-side: the caller's `onInputChange` already narrows
|
|
@@ -2,7 +2,8 @@ import { useState } from 'react'
|
|
|
2
2
|
import { useTranslation } from 'react-i18next'
|
|
3
3
|
import { Button } from '../ui/button'
|
|
4
4
|
import { Field, FieldLabel } from '../ui/field'
|
|
5
|
-
import {
|
|
5
|
+
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '../ui/input-group'
|
|
6
|
+
import { EyeIcon, EyeOffIcon } from './icons'
|
|
6
7
|
|
|
7
8
|
// Server masks stored secrets as `••••…` on read so admins can see "yes,
|
|
8
9
|
// something is stored" without exposing the actual value. Detecting that
|
|
@@ -35,7 +36,9 @@ interface SecretInputProps {
|
|
|
35
36
|
* mask (`••••3K9z`) and renders as a read-only badge with a "Replace"
|
|
36
37
|
* button — never as an input, and never with a reveal toggle, because the
|
|
37
38
|
* server does not send the secret and there is nothing to unhide. Replace
|
|
38
|
-
* switches to an empty password input so the admin opts in to rotation;
|
|
39
|
+
* switches to an empty password input so the admin opts in to rotation; that
|
|
40
|
+
* input does carry a reveal toggle, since what it hides is what the admin is
|
|
41
|
+
* typing right now;
|
|
39
42
|
* Cancel reverts to the badge so the next save preserves the existing
|
|
40
43
|
* secret via the backend's masked round-trip guard.
|
|
41
44
|
*
|
|
@@ -57,6 +60,7 @@ export function SecretInput({
|
|
|
57
60
|
// after `onChange('')` has cleared the parent value.
|
|
58
61
|
const [pendingMask, setPendingMask] = useState<string | null>(null)
|
|
59
62
|
const replacing = pendingMask !== null
|
|
63
|
+
const [revealed, setRevealed] = useState(false)
|
|
60
64
|
|
|
61
65
|
// A stored secret always renders as the badge: the server sent a mask,
|
|
62
66
|
// not the value, so there is nothing a reveal toggle could show.
|
|
@@ -93,17 +97,29 @@ export function SecretInput({
|
|
|
93
97
|
<Field>
|
|
94
98
|
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
|
95
99
|
<div className="flex items-center gap-2">
|
|
96
|
-
<
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
100
|
+
<InputGroup className="flex-1">
|
|
101
|
+
<InputGroupInput
|
|
102
|
+
id={id}
|
|
103
|
+
type={revealed ? 'text' : 'password'}
|
|
104
|
+
autoComplete="new-password"
|
|
105
|
+
spellCheck={false}
|
|
106
|
+
placeholder={
|
|
107
|
+
replacing ? t('admin.components.secret_input.new_value_placeholder') : placeholder
|
|
108
|
+
}
|
|
109
|
+
value={(value as string) ?? ''}
|
|
110
|
+
onChange={(e) => onChange(e.target.value)}
|
|
111
|
+
/>
|
|
112
|
+
<InputGroupAddon align="inline-end">
|
|
113
|
+
<InputGroupButton
|
|
114
|
+
size="icon-xs"
|
|
115
|
+
aria-label={revealed ? t('admin.actions.hide') : t('admin.actions.show')}
|
|
116
|
+
aria-pressed={revealed}
|
|
117
|
+
onClick={() => setRevealed((visible) => !visible)}
|
|
118
|
+
>
|
|
119
|
+
{revealed ? <EyeOffIcon /> : <EyeIcon />}
|
|
120
|
+
</InputGroupButton>
|
|
121
|
+
</InputGroupAddon>
|
|
122
|
+
</InputGroup>
|
|
107
123
|
{replacing && (
|
|
108
124
|
<Button
|
|
109
125
|
type="button"
|
|
@@ -15,17 +15,20 @@ import {
|
|
|
15
15
|
ComboboxInput,
|
|
16
16
|
ComboboxItem,
|
|
17
17
|
ComboboxList,
|
|
18
|
-
DropdownMenu,
|
|
19
|
-
DropdownMenuContent,
|
|
20
|
-
DropdownMenuItem,
|
|
21
|
-
DropdownMenuTrigger,
|
|
22
18
|
Field,
|
|
23
19
|
FieldDescription,
|
|
24
20
|
FieldError,
|
|
25
21
|
FieldGroup,
|
|
26
22
|
FieldLabel,
|
|
27
23
|
Input,
|
|
24
|
+
Item,
|
|
25
|
+
ItemActions,
|
|
26
|
+
ItemContent,
|
|
27
|
+
ItemDescription,
|
|
28
|
+
ItemGroup,
|
|
29
|
+
ItemTitle,
|
|
28
30
|
RelativeTime,
|
|
31
|
+
RowActions,
|
|
29
32
|
Sheet,
|
|
30
33
|
SheetContent,
|
|
31
34
|
SheetDescription,
|
|
@@ -35,7 +38,7 @@ import {
|
|
|
35
38
|
StatusBadge,
|
|
36
39
|
useConfirm,
|
|
37
40
|
} from '../index'
|
|
38
|
-
import { BadgeCheckIcon,
|
|
41
|
+
import { BadgeCheckIcon, PlusIcon } from './icons'
|
|
39
42
|
|
|
40
43
|
/** One selectable registration kind. */
|
|
41
44
|
interface KindOption {
|
|
@@ -153,81 +156,71 @@ export function TaxIdentifiersCard({
|
|
|
153
156
|
<p className="text-muted-foreground text-sm">{t('admin.tax_identifiers.empty')}</p>
|
|
154
157
|
</CardContent>
|
|
155
158
|
) : (
|
|
156
|
-
<CardContent
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
key={identifier.id}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
defaultValue: identifier.kind,
|
|
168
|
-
})}
|
|
169
|
-
</Badge>
|
|
170
|
-
{identifier.validation_status ? (
|
|
171
|
-
<StatusBadge
|
|
172
|
-
status={identifier.validation_status}
|
|
173
|
-
label={t(
|
|
174
|
-
`admin.tax_identifiers.validation_status.${identifier.validation_status}`,
|
|
175
|
-
{ defaultValue: identifier.validation_status },
|
|
176
|
-
)}
|
|
177
|
-
/>
|
|
178
|
-
) : (
|
|
179
|
-
<Badge variant="secondary">
|
|
180
|
-
{t('admin.tax_identifiers.validation_status.not_checked')}
|
|
159
|
+
<CardContent>
|
|
160
|
+
<ItemGroup className="gap-3">
|
|
161
|
+
{identifiers.map((identifier) => (
|
|
162
|
+
<Item key={identifier.id} role="listitem" variant="outline" className="items-start">
|
|
163
|
+
<ItemContent className="min-w-0">
|
|
164
|
+
<ItemTitle className="flex-wrap">
|
|
165
|
+
{identifier.value}
|
|
166
|
+
<Badge variant="outline">
|
|
167
|
+
{t(`admin.tax_identifiers.kinds.${identifier.kind}`, {
|
|
168
|
+
defaultValue: identifier.kind,
|
|
169
|
+
})}
|
|
181
170
|
</Badge>
|
|
171
|
+
{identifier.validation_status ? (
|
|
172
|
+
<StatusBadge
|
|
173
|
+
status={identifier.validation_status}
|
|
174
|
+
label={t(
|
|
175
|
+
`admin.tax_identifiers.validation_status.${identifier.validation_status}`,
|
|
176
|
+
{ defaultValue: identifier.validation_status },
|
|
177
|
+
)}
|
|
178
|
+
/>
|
|
179
|
+
) : (
|
|
180
|
+
<Badge variant="secondary">
|
|
181
|
+
{t('admin.tax_identifiers.validation_status.not_checked')}
|
|
182
|
+
</Badge>
|
|
183
|
+
)}
|
|
184
|
+
</ItemTitle>
|
|
185
|
+
{identifier.validated_at && (
|
|
186
|
+
<ItemDescription className="text-xs">
|
|
187
|
+
<RelativeTime
|
|
188
|
+
iso={identifier.validated_at}
|
|
189
|
+
prefix={t('admin.tax_identifiers.checked_prefix')}
|
|
190
|
+
/>
|
|
191
|
+
</ItemDescription>
|
|
192
|
+
)}
|
|
193
|
+
{!identifier.validatable && (
|
|
194
|
+
<ItemDescription className="text-xs">
|
|
195
|
+
{t('admin.tax_identifiers.not_validatable')}
|
|
196
|
+
</ItemDescription>
|
|
182
197
|
)}
|
|
183
|
-
</
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
198
|
+
</ItemContent>
|
|
199
|
+
|
|
200
|
+
{canEdit && (
|
|
201
|
+
<ItemActions>
|
|
202
|
+
<RowActions
|
|
203
|
+
actions={[
|
|
204
|
+
{
|
|
205
|
+
key: 'validate',
|
|
206
|
+
label: t('admin.tax_identifiers.validate_action'),
|
|
207
|
+
icon: <BadgeCheckIcon className="size-4" />,
|
|
208
|
+
disabled: !identifier.validatable || mutations.isValidating,
|
|
209
|
+
onSelect: () => mutations.validate(identifier.id).catch(() => undefined),
|
|
210
|
+
},
|
|
211
|
+
{ key: 'edit', onSelect: () => setEditing(identifier) },
|
|
212
|
+
{
|
|
213
|
+
key: 'delete',
|
|
214
|
+
destructive: true,
|
|
215
|
+
onSelect: () => handleDelete(identifier),
|
|
216
|
+
},
|
|
217
|
+
]}
|
|
189
218
|
/>
|
|
190
|
-
</
|
|
191
|
-
)}
|
|
192
|
-
{!identifier.validatable && (
|
|
193
|
-
<span className="text-muted-foreground text-xs">
|
|
194
|
-
{t('admin.tax_identifiers.not_validatable')}
|
|
195
|
-
</span>
|
|
219
|
+
</ItemActions>
|
|
196
220
|
)}
|
|
197
|
-
</
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
<DropdownMenu>
|
|
201
|
-
<DropdownMenuTrigger asChild>
|
|
202
|
-
<Button variant="ghost" size="icon-xs">
|
|
203
|
-
<EllipsisVerticalIcon className="size-4" />
|
|
204
|
-
<span className="sr-only">{t('admin.actions.actions_menu')}</span>
|
|
205
|
-
</Button>
|
|
206
|
-
</DropdownMenuTrigger>
|
|
207
|
-
<DropdownMenuContent align="end">
|
|
208
|
-
<DropdownMenuItem
|
|
209
|
-
disabled={!identifier.validatable || mutations.isValidating}
|
|
210
|
-
onClick={() => mutations.validate(identifier.id).catch(() => undefined)}
|
|
211
|
-
>
|
|
212
|
-
<BadgeCheckIcon className="size-4" />
|
|
213
|
-
{t('admin.tax_identifiers.validate_action')}
|
|
214
|
-
</DropdownMenuItem>
|
|
215
|
-
<DropdownMenuItem onClick={() => setEditing(identifier)}>
|
|
216
|
-
<PencilIcon className="size-4" />
|
|
217
|
-
{t('admin.actions.edit')}
|
|
218
|
-
</DropdownMenuItem>
|
|
219
|
-
<DropdownMenuItem
|
|
220
|
-
className="text-destructive focus:text-destructive"
|
|
221
|
-
onClick={() => handleDelete(identifier)}
|
|
222
|
-
>
|
|
223
|
-
<TrashIcon className="size-4" />
|
|
224
|
-
{t('admin.actions.delete')}
|
|
225
|
-
</DropdownMenuItem>
|
|
226
|
-
</DropdownMenuContent>
|
|
227
|
-
</DropdownMenu>
|
|
228
|
-
)}
|
|
229
|
-
</div>
|
|
230
|
-
))}
|
|
221
|
+
</Item>
|
|
222
|
+
))}
|
|
223
|
+
</ItemGroup>
|
|
231
224
|
</CardContent>
|
|
232
225
|
)}
|
|
233
226
|
|
package/src/spree/wizard.tsx
CHANGED
|
@@ -188,13 +188,7 @@ export function Wizard({
|
|
|
188
188
|
}}
|
|
189
189
|
modal
|
|
190
190
|
>
|
|
191
|
-
<DialogContent
|
|
192
|
-
// Edge-to-edge minus a gutter, like the import wizard and the bulk
|
|
193
|
-
// price editor — every inset/translate/max is overridden.
|
|
194
|
-
className="!inset-3 !w-auto !max-w-none !translate-x-0 !translate-y-0 flex flex-col p-0"
|
|
195
|
-
style={{ maxHeight: 'none' }}
|
|
196
|
-
showCloseButton={false}
|
|
197
|
-
>
|
|
191
|
+
<DialogContent fullScreen showCloseButton={false}>
|
|
198
192
|
<DialogHeader className="h-14 shrink-0 flex-row items-center gap-3 space-y-0 border-b border-border-subtle bg-background px-4 py-0">
|
|
199
193
|
<div className="flex min-w-0 items-center gap-3">
|
|
200
194
|
<DialogTitle className="truncate text-base">{title}</DialogTitle>
|
package/src/ui/badge.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { BuiltInStatus } from '@spree/core'
|
|
1
2
|
import { cva, type VariantProps } from 'class-variance-authority'
|
|
2
3
|
import type * as React from 'react'
|
|
3
4
|
import { useTranslation } from 'react-i18next'
|
|
@@ -80,6 +81,7 @@ const statusToneMap: Record<string, StatusTone> = {
|
|
|
80
81
|
authorized: 'info',
|
|
81
82
|
complete: 'success',
|
|
82
83
|
completed: 'success',
|
|
84
|
+
placed: 'success',
|
|
83
85
|
paid: 'success',
|
|
84
86
|
fulfilled: 'success',
|
|
85
87
|
delivered: 'success',
|
|
@@ -157,18 +159,36 @@ const statusToneMap: Record<string, StatusTone> = {
|
|
|
157
159
|
ready_to_ship: 'warning',
|
|
158
160
|
ordered: 'info',
|
|
159
161
|
partially_received: 'info',
|
|
162
|
+
// More arrived than was ordered: done, but worth a look.
|
|
163
|
+
over_received: 'warning',
|
|
160
164
|
// Gift cards.
|
|
161
165
|
partially_redeemed: 'info',
|
|
162
166
|
redeemed: 'neutral',
|
|
163
167
|
// Price lists — `scheduled` is live-but-not-yet, which is the same shape as
|
|
164
168
|
// any other "waiting on the clock" state.
|
|
165
169
|
scheduled: 'info',
|
|
170
|
+
// The rest of the API's own statuses, on the same rule as above.
|
|
171
|
+
accepted: 'success',
|
|
172
|
+
waived: 'neutral',
|
|
173
|
+
withdrawn: 'neutral',
|
|
174
|
+
awaiting_confirmation: 'warning',
|
|
175
|
+
lapsed: 'neutral',
|
|
176
|
+
mapping: 'warning',
|
|
177
|
+
completed_mapping: 'info',
|
|
178
|
+
unresolved: 'destructive',
|
|
179
|
+
on_hand: 'success',
|
|
180
|
+
backordered: 'warning',
|
|
181
|
+
shipped: 'success',
|
|
182
|
+
checkout: 'neutral',
|
|
183
|
+
invalid: 'destructive',
|
|
184
|
+
purchased: 'success',
|
|
185
|
+
refund_requested: 'warning',
|
|
166
186
|
// Boolean filter values. A yes/no pair is a state like any other — "in
|
|
167
187
|
// stock: no" is the row an operator is looking for, and reading it in the
|
|
168
188
|
// same green/red the rest of the app uses saves them parsing the word.
|
|
169
189
|
true: 'success',
|
|
170
190
|
false: 'destructive',
|
|
171
|
-
}
|
|
191
|
+
} satisfies Record<BuiltInStatus, StatusTone> & Record<string, StatusTone>
|
|
172
192
|
|
|
173
193
|
// Dot fills, by tone. These read the same `--status-*` ramps the tinted Badge
|
|
174
194
|
// variants use, but take the *reading* step rather than the fill step: a 6px
|