@spree/dashboard-ui 6.0.3 → 6.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +20 -19
- package/src/index.ts +5 -0
- package/src/lib/field-trigger.ts +10 -0
- package/src/lib/same-rich-text.ts +12 -3
- package/src/lib/utils.ts +14 -1
- package/src/spree/address-block.tsx +3 -3
- package/src/spree/address-book-row.tsx +88 -0
- package/src/spree/bulk-price-table.tsx +3 -3
- package/src/spree/claim-resolve-dialog.tsx +1 -1
- package/src/spree/collapsible-card.tsx +2 -2
- package/src/spree/color-picker.tsx +1 -1
- package/src/spree/currency-label.tsx +71 -0
- package/src/spree/data-grid/cells.tsx +2 -2
- package/src/spree/data-grid/data-grid.tsx +2 -2
- package/src/spree/delivery-form-dialog.tsx +1 -1
- package/src/spree/drag-handle.tsx +1 -1
- package/src/spree/editable-note-card.tsx +2 -2
- package/src/spree/form-actions.tsx +32 -10
- package/src/spree/fulfill-items-form.tsx +20 -22
- package/src/spree/fulfillment-edit-dialog.tsx +1 -1
- package/src/spree/fulfillment-item-list.tsx +87 -28
- package/src/spree/fulfillment-panel.tsx +2 -2
- package/src/spree/icons.ts +8 -0
- package/src/spree/json-preview-drawer.tsx +32 -7
- package/src/spree/json-value-view.tsx +14 -4
- package/src/spree/label-upload-dialog.tsx +1 -1
- package/src/spree/language-menu-items.tsx +1 -1
- package/src/spree/metadata/metadata-card.tsx +2 -2
- package/src/spree/order-cancel-dialog.tsx +3 -3
- package/src/spree/post-sale-create-dialogs.tsx +36 -33
- package/src/spree/post-sale-fields.tsx +30 -1
- package/src/spree/product-identity.tsx +74 -0
- package/src/spree/product-membership-list.tsx +79 -41
- package/src/spree/products/status-card.tsx +1 -1
- package/src/spree/quantity-tier-editor.tsx +1 -1
- package/src/spree/return-dialogs.tsx +2 -2
- package/src/spree/search-input.tsx +3 -1
- package/src/spree/secret-input.tsx +1 -1
- package/src/spree/section.tsx +55 -0
- package/src/spree/stat-item.tsx +27 -0
- package/src/spree/tax-identifiers-card.tsx +117 -76
- package/src/spree/theme-toggle.tsx +1 -1
- package/src/spree/wizard.tsx +2 -2
- package/src/styles.css +226 -120
- package/src/ui/alert.tsx +20 -17
- package/src/ui/attachment.tsx +1 -1
- package/src/ui/avatar.tsx +2 -2
- package/src/ui/badge.tsx +1 -1
- package/src/ui/breadcrumb.tsx +1 -1
- package/src/ui/button.tsx +14 -11
- package/src/ui/calendar.tsx +2 -2
- package/src/ui/card.tsx +14 -37
- package/src/ui/chart.tsx +1 -1
- package/src/ui/checkbox.tsx +2 -2
- package/src/ui/code-editor.tsx +6 -2
- package/src/ui/collapsible.tsx +21 -2
- package/src/ui/combobox.tsx +12 -16
- package/src/ui/command.tsx +8 -6
- package/src/ui/context-menu.tsx +1 -1
- package/src/ui/data-table.tsx +58 -15
- package/src/ui/date-picker.tsx +3 -3
- package/src/ui/date-range-panel.tsx +102 -0
- package/src/ui/date-range-picker.tsx +33 -72
- package/src/ui/dialog.tsx +3 -3
- package/src/ui/dropdown-menu.tsx +6 -6
- package/src/ui/empty.tsx +3 -3
- package/src/ui/field.tsx +33 -12
- package/src/ui/input-group.tsx +17 -6
- package/src/ui/input.tsx +148 -4
- package/src/ui/item.tsx +7 -4
- package/src/ui/label.tsx +1 -1
- package/src/ui/media-preview.tsx +40 -5
- package/src/ui/pagination.tsx +16 -4
- package/src/ui/progress.tsx +2 -2
- package/src/ui/radio-group.tsx +2 -2
- package/src/ui/rich-text-editor.tsx +3 -3
- package/src/ui/select.tsx +7 -16
- package/src/ui/sheet.tsx +13 -5
- package/src/ui/sidebar.tsx +5 -5
- package/src/ui/switch.tsx +8 -2
- package/src/ui/tabs.tsx +45 -9
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/toast.tsx +2 -2
- package/src/ui/tooltip.tsx +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spree/dashboard-ui",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "6.0.
|
|
4
|
+
"version": "6.0.4",
|
|
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.",
|
|
6
6
|
"author": "Vendo Connect Inc.",
|
|
7
7
|
"homepage": "https://spreecommerce.org",
|
|
@@ -38,11 +38,11 @@
|
|
|
38
38
|
],
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"@tanstack/react-router": "^1.169.2",
|
|
41
|
-
"i18next": "^26.2.0",
|
|
41
|
+
"i18next": "^26.2.0 || ^27.0.0",
|
|
42
42
|
"react": "^19.2.6",
|
|
43
43
|
"react-dom": "^19.2.6",
|
|
44
44
|
"react-hook-form": "^7.75.0",
|
|
45
|
-
"react-i18next": "^17.0.8",
|
|
45
|
+
"react-i18next": "^17.0.8 || ^18.0.0",
|
|
46
46
|
"tailwindcss": "^4.2.4"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
@@ -61,41 +61,42 @@
|
|
|
61
61
|
"@dnd-kit/utilities": "3.2.2",
|
|
62
62
|
"@fontsource-variable/geist": "5.3.0",
|
|
63
63
|
"@fontsource-variable/geist-mono": "5.3.0",
|
|
64
|
-
"@
|
|
64
|
+
"@fontsource-variable/inter": "5.3.0",
|
|
65
|
+
"@tanstack/react-hotkeys": "0.23.0",
|
|
65
66
|
"@tanstack/react-table": "9.3.1",
|
|
66
|
-
"@tiptap/core": "3.32.
|
|
67
|
-
"@tiptap/extension-image": "3.32.
|
|
68
|
-
"@tiptap/extension-link": "3.32.
|
|
69
|
-
"@tiptap/extension-placeholder": "3.32.
|
|
70
|
-
"@tiptap/pm": "3.32.
|
|
71
|
-
"@tiptap/react": "3.32.
|
|
72
|
-
"@tiptap/starter-kit": "3.32.
|
|
67
|
+
"@tiptap/core": "3.32.2",
|
|
68
|
+
"@tiptap/extension-image": "3.32.2",
|
|
69
|
+
"@tiptap/extension-link": "3.32.2",
|
|
70
|
+
"@tiptap/extension-placeholder": "3.32.2",
|
|
71
|
+
"@tiptap/pm": "3.32.2",
|
|
72
|
+
"@tiptap/react": "3.32.2",
|
|
73
|
+
"@tiptap/starter-kit": "3.32.2",
|
|
73
74
|
"@uiw/react-json-view": "1.12.2",
|
|
74
75
|
"class-variance-authority": "0.7.1",
|
|
75
76
|
"clsx": "2.1.1",
|
|
76
77
|
"date-fns": "4.4.0",
|
|
77
78
|
"date-fns-tz": "3.2.0",
|
|
78
79
|
"flag-icons": "7.6.0",
|
|
79
|
-
"lucide-react": "1.
|
|
80
|
+
"lucide-react": "1.71.0",
|
|
80
81
|
"react-colorful": "5.8.1",
|
|
81
82
|
"react-day-picker": "10.0.2",
|
|
82
83
|
"recharts": "3.10.1",
|
|
83
84
|
"tailwind-merge": "3.7.0",
|
|
84
85
|
"tw-animate-css": "1.4.0",
|
|
85
|
-
"@spree/core": "^6.0.
|
|
86
|
+
"@spree/core": "^6.0.4"
|
|
86
87
|
},
|
|
87
88
|
"devDependencies": {
|
|
88
|
-
"@tanstack/react-router": "^1.170.
|
|
89
|
+
"@tanstack/react-router": "^1.170.46",
|
|
89
90
|
"@types/react": "^19.2.14",
|
|
90
91
|
"@types/react-dom": "^19.2.3",
|
|
91
|
-
"i18next": "^
|
|
92
|
+
"i18next": "^27.1.1",
|
|
92
93
|
"react": "^19.3.1",
|
|
93
94
|
"react-dom": "^19.3.1",
|
|
94
|
-
"react-hook-form": "^7.
|
|
95
|
-
"react-i18next": "^
|
|
96
|
-
"tailwindcss": "^4.
|
|
95
|
+
"react-hook-form": "^7.91.0",
|
|
96
|
+
"react-i18next": "^18.0.1",
|
|
97
|
+
"tailwindcss": "^4.3.5",
|
|
97
98
|
"typescript": "~7.0.2",
|
|
98
|
-
"vite": "^8.3.
|
|
99
|
+
"vite": "^8.3.7"
|
|
99
100
|
},
|
|
100
101
|
"scripts": {
|
|
101
102
|
"lint": "biome check src",
|
package/src/index.ts
CHANGED
|
@@ -44,6 +44,7 @@ export * from './spree/color-picker'
|
|
|
44
44
|
export * from './spree/confirm-dialog'
|
|
45
45
|
export * from './spree/copy-to-clipboard-button'
|
|
46
46
|
export * from './spree/country-flag'
|
|
47
|
+
export * from './spree/currency-label'
|
|
47
48
|
export * from './spree/data-grid'
|
|
48
49
|
export * from './spree/delivery-form-dialog'
|
|
49
50
|
export * from './spree/drag-handle'
|
|
@@ -61,6 +62,8 @@ export * from './spree/post-sale-create-dialogs'
|
|
|
61
62
|
export * from './spree/post-sale-fields'
|
|
62
63
|
export * from './spree/quantity-tier-editor'
|
|
63
64
|
export * from './spree/return-dialogs'
|
|
65
|
+
export * from './spree/section'
|
|
66
|
+
export * from './spree/stat-item'
|
|
64
67
|
|
|
65
68
|
// JsonPreviewDrawer and JsonValueView are intentionally NOT re-exported from
|
|
66
69
|
// this barrel: they pull in `@uiw/react-json-view` (~30KB gzip), and
|
|
@@ -74,6 +77,7 @@ export * from './spree/return-dialogs'
|
|
|
74
77
|
export * from './spree/external-link'
|
|
75
78
|
export * from './spree/language-menu-items'
|
|
76
79
|
export * from './spree/metadata/metadata-card'
|
|
80
|
+
export * from './spree/product-identity'
|
|
77
81
|
export * from './spree/product-membership-list'
|
|
78
82
|
export * from './spree/products/status-card'
|
|
79
83
|
export * from './spree/relative-time'
|
|
@@ -113,6 +117,7 @@ export * from './ui/command'
|
|
|
113
117
|
export * from './ui/context-menu'
|
|
114
118
|
export * from './ui/data-table'
|
|
115
119
|
export * from './ui/date-picker'
|
|
120
|
+
export * from './ui/date-range-panel'
|
|
116
121
|
export * from './ui/date-range-picker'
|
|
117
122
|
export * from './ui/dialog'
|
|
118
123
|
export * from './ui/dropdown-menu'
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The look of a button that opens a picker: a select, a combobox, a date
|
|
3
|
+
* range. Shaped like a text field, so a picker and an input on one row read
|
|
4
|
+
* as the same kind of control. `data-size="sm"` is the toolbar size.
|
|
5
|
+
*
|
|
6
|
+
* `min-h-8` matches `<Input>`, and stops the trigger collapsing to the
|
|
7
|
+
* chevron's height when its value renders empty.
|
|
8
|
+
*/
|
|
9
|
+
export const fieldTriggerClassName =
|
|
10
|
+
"flex w-full min-h-8 cursor-pointer items-center justify-between gap-1.5 rounded-xl border border-field-border shadow-control bg-card py-1.5 pr-2 pl-2.5 text-base font-normal leading-normal text-foreground transition-[color,background-color,border-color,box-shadow] duration-100 ease-out outline-none select-none not-disabled:hover:bg-field-hover focus:border-blue-500 focus:shadow-field-focus disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground aria-invalid:border-destructive data-[size=sm]:h-8 data-[size=sm]:min-h-8 data-[size=sm]:gap-2 data-[size=sm]:px-2 data-[size=sm]:py-1 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
|
@@ -22,15 +22,24 @@ export function shouldEmitRichTextChange(next: string, previous: string): boolea
|
|
|
22
22
|
if (!sameRichText(next, previous)) return true
|
|
23
23
|
if (!visibleText(previous) && !hasImage(previous)) return false
|
|
24
24
|
|
|
25
|
-
const previousHtml =
|
|
25
|
+
const previousHtml = wrapInline(previous.trim())
|
|
26
26
|
return countParagraphs(next) !== countParagraphs(previousHtml)
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
function canonicalizeRichText(html: string): string {
|
|
30
30
|
const withoutTrailingEmpty = stripTrailingEmptyParagraphs((html ?? '').trim())
|
|
31
31
|
if (!visibleText(withoutTrailingEmpty) && !hasImage(withoutTrailingEmpty)) return ''
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
return wrapInline(withoutTrailingEmpty)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Block elements the editor keeps at the top level. Content that starts with
|
|
36
|
+
// anything else — bare text, or inline markup such as `<strong>` — is what the
|
|
37
|
+
// editor wraps in a paragraph on mount, which is not a change.
|
|
38
|
+
const BLOCK_START = /^<(p|h[1-6]|ul|ol|li|blockquote|pre|div|hr|figure|table)[\s>/]/i
|
|
39
|
+
|
|
40
|
+
function wrapInline(html: string): string {
|
|
41
|
+
if (looksLikeHtml(html) && BLOCK_START.test(html)) return html
|
|
42
|
+
return `<p>${html}</p>`
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
function stripTrailingEmptyParagraphs(html: string): string {
|
package/src/lib/utils.ts
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import { type ClassValue, clsx } from 'clsx'
|
|
2
|
-
import {
|
|
2
|
+
import { extendTailwindMerge } from 'tailwind-merge'
|
|
3
|
+
|
|
4
|
+
// Theme values tailwind-merge cannot know about. Unregistered, `rounded-card`
|
|
5
|
+
// and `shadow-card-lift` are treated as unknown classes, so a stock
|
|
6
|
+
// `rounded-lg` or `shadow-xs` beside them is never dropped and whichever comes
|
|
7
|
+
// later in the stylesheet silently wins.
|
|
8
|
+
const twMerge = extendTailwindMerge({
|
|
9
|
+
extend: {
|
|
10
|
+
theme: {
|
|
11
|
+
radius: ['card'],
|
|
12
|
+
shadow: ['card-lift', 'control', 'field-focus'],
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
})
|
|
3
16
|
|
|
4
17
|
export function cn(...inputs: ClassValue[]) {
|
|
5
18
|
return twMerge(clsx(inputs))
|
|
@@ -47,9 +47,9 @@ export function AddressBlock({
|
|
|
47
47
|
const { t, i18n } = useTranslation()
|
|
48
48
|
return (
|
|
49
49
|
<div>
|
|
50
|
-
{title && <h6 className="font-semibold text-
|
|
50
|
+
{title && <h6 className="font-semibold text-base mb-1.5">{title}</h6>}
|
|
51
51
|
{address ? (
|
|
52
|
-
<div className="text-
|
|
52
|
+
<div className="text-base text-muted-foreground flex flex-col gap-0.5">
|
|
53
53
|
<div>{address.full_name}</div>
|
|
54
54
|
{address.company && <div>{address.company}</div>}
|
|
55
55
|
<div>{address.address1}</div>
|
|
@@ -63,7 +63,7 @@ export function AddressBlock({
|
|
|
63
63
|
{address.phone && <div>{address.phone}</div>}
|
|
64
64
|
</div>
|
|
65
65
|
) : (
|
|
66
|
-
<span className="text-
|
|
66
|
+
<span className="text-base text-muted-foreground">
|
|
67
67
|
{emptyText ?? t('admin.components.address_block.not_provided')}
|
|
68
68
|
</span>
|
|
69
69
|
)}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
1
2
|
import { useTranslation } from 'react-i18next'
|
|
2
3
|
import { Badge } from '../ui/badge'
|
|
4
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/data-table'
|
|
3
5
|
import { Item, ItemActions, ItemContent, ItemTitle } from '../ui/item'
|
|
4
6
|
import { AddressBlock, type AddressBlockValue } from './address-block'
|
|
5
7
|
import { RowActions } from './row-actions'
|
|
@@ -94,3 +96,89 @@ export function AddressBookRow({
|
|
|
94
96
|
</Item>
|
|
95
97
|
)
|
|
96
98
|
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* An address book as a table, for a detail page's main column: one row per
|
|
102
|
+
* address, its labels and defaults beside it, the actions at the end. Fill it
|
|
103
|
+
* with `AddressBookTableRow`s; the card around it is the caller's.
|
|
104
|
+
*/
|
|
105
|
+
export function AddressBookTable({ children }: { children: ReactNode }) {
|
|
106
|
+
const { t } = useTranslation()
|
|
107
|
+
return (
|
|
108
|
+
<Table roundedTop>
|
|
109
|
+
<TableHeader>
|
|
110
|
+
<TableRow>
|
|
111
|
+
<TableHead>{t('admin.address_book.columns.address')}</TableHead>
|
|
112
|
+
<TableHead>{t('admin.address_book.columns.defaults')}</TableHead>
|
|
113
|
+
<TableHead className="w-10" />
|
|
114
|
+
</TableRow>
|
|
115
|
+
</TableHeader>
|
|
116
|
+
<TableBody>{children}</TableBody>
|
|
117
|
+
</Table>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** One address in an `AddressBookTable`; takes the same props as `AddressBookRow`. */
|
|
122
|
+
export function AddressBookTableRow({
|
|
123
|
+
address,
|
|
124
|
+
canEdit = true,
|
|
125
|
+
inheritedFrom,
|
|
126
|
+
onEdit,
|
|
127
|
+
onRemove,
|
|
128
|
+
onSetDefaultBilling,
|
|
129
|
+
onSetDefaultShipping,
|
|
130
|
+
}: Parameters<typeof AddressBookRow>[0]) {
|
|
131
|
+
const { t } = useTranslation()
|
|
132
|
+
const inherited = inheritedFrom != null
|
|
133
|
+
const isDefaultBilling = !inherited && address.is_default_billing
|
|
134
|
+
const isDefaultShipping = !inherited && address.is_default_shipping
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<TableRow>
|
|
138
|
+
<TableCell>
|
|
139
|
+
{address.label && <div className="font-medium text-base">{address.label}</div>}
|
|
140
|
+
<AddressBlock address={address} />
|
|
141
|
+
</TableCell>
|
|
142
|
+
<TableCell>
|
|
143
|
+
<span className="flex flex-wrap gap-1.5">
|
|
144
|
+
{inherited && (
|
|
145
|
+
<Badge variant="secondary">
|
|
146
|
+
{t('admin.address_book.inherited_from', { name: inheritedFrom })}
|
|
147
|
+
</Badge>
|
|
148
|
+
)}
|
|
149
|
+
{isDefaultBilling && (
|
|
150
|
+
<Badge variant="outline">{t('admin.address_book.default_billing')}</Badge>
|
|
151
|
+
)}
|
|
152
|
+
{isDefaultShipping && (
|
|
153
|
+
<Badge variant="outline">{t('admin.address_book.default_shipping')}</Badge>
|
|
154
|
+
)}
|
|
155
|
+
{!inherited && !isDefaultBilling && !isDefaultShipping && (
|
|
156
|
+
<span className="text-muted-foreground">—</span>
|
|
157
|
+
)}
|
|
158
|
+
</span>
|
|
159
|
+
</TableCell>
|
|
160
|
+
<TableCell className="w-10 text-right">
|
|
161
|
+
{canEdit && !inherited && (
|
|
162
|
+
<RowActions
|
|
163
|
+
actions={[
|
|
164
|
+
{ key: 'edit', onSelect: onEdit },
|
|
165
|
+
{
|
|
166
|
+
key: 'set_default_billing',
|
|
167
|
+
label: t('admin.address_book.set_default_billing'),
|
|
168
|
+
visible: !!onSetDefaultBilling && !address.is_default_billing,
|
|
169
|
+
onSelect: () => onSetDefaultBilling?.(),
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
key: 'set_default_shipping',
|
|
173
|
+
label: t('admin.address_book.set_default_shipping'),
|
|
174
|
+
visible: !!onSetDefaultShipping && !address.is_default_shipping,
|
|
175
|
+
onSelect: () => onSetDefaultShipping?.(),
|
|
176
|
+
},
|
|
177
|
+
{ key: 'delete', destructive: true, onSelect: onRemove },
|
|
178
|
+
]}
|
|
179
|
+
/>
|
|
180
|
+
)}
|
|
181
|
+
</TableCell>
|
|
182
|
+
</TableRow>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
@@ -290,7 +290,7 @@ export function BulkPriceTable({
|
|
|
290
290
|
return (
|
|
291
291
|
<Button
|
|
292
292
|
type="button"
|
|
293
|
-
variant="ghost"
|
|
293
|
+
variant="destructive-ghost"
|
|
294
294
|
aria-label={labels.tierRemove}
|
|
295
295
|
className="size-full min-h-9 rounded-none text-muted-foreground"
|
|
296
296
|
onClick={() => onTierRemove(r.id)}
|
|
@@ -350,9 +350,9 @@ export function BulkPriceTable({
|
|
|
350
350
|
nests two scrollbars over the same content. */}
|
|
351
351
|
<div className="min-h-0 flex-1">
|
|
352
352
|
{isLoading ? (
|
|
353
|
-
<p className="text-
|
|
353
|
+
<p className="text-base text-muted-foreground">{labels.loading}</p>
|
|
354
354
|
) : isEmpty ? (
|
|
355
|
-
<p className="text-
|
|
355
|
+
<p className="text-base text-muted-foreground">
|
|
356
356
|
{hasSearch
|
|
357
357
|
? (labels.emptySearchMessage ?? labels.emptyMessage ?? '')
|
|
358
358
|
: (labels.emptyMessage ?? '')}
|
|
@@ -173,7 +173,7 @@ export function ClaimResolveDialog({
|
|
|
173
173
|
|
|
174
174
|
{sendingReplacement && (
|
|
175
175
|
<div className="flex flex-col gap-2">
|
|
176
|
-
<span className="font-medium text-
|
|
176
|
+
<span className="font-medium text-base">
|
|
177
177
|
{t('admin.pages.orders.detail.claims.replacement_items')}
|
|
178
178
|
</span>
|
|
179
179
|
{lines.map((line) => (
|
|
@@ -41,10 +41,10 @@ export function CollapsibleCard({
|
|
|
41
41
|
<Card>
|
|
42
42
|
<Collapsible open={isOpen} onOpenChange={setOpen}>
|
|
43
43
|
<CardHeader
|
|
44
|
-
className={cn('flex flex-row items-center gap-2 py-
|
|
44
|
+
className={cn('flex flex-row items-center gap-2 py-2.5', !isOpen && 'border-b-0')}
|
|
45
45
|
>
|
|
46
46
|
<CollapsibleTrigger className="group -mx-1 flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md px-1 py-1 text-left hover:bg-accent/50">
|
|
47
|
-
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[panel-open]:rotate-90 motion-reduce:transition-none" />
|
|
47
|
+
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform duration-200 ease-out group-data-[panel-open]:rotate-90 motion-reduce:transition-none" />
|
|
48
48
|
{/* CardTitle's styling on a span: a button may only hold phrasing content. */}
|
|
49
49
|
<span
|
|
50
50
|
data-slot="card-title"
|
|
@@ -163,7 +163,7 @@ export function ColorPicker({
|
|
|
163
163
|
role="dialog"
|
|
164
164
|
aria-label={t('admin.a11y.pick_color')}
|
|
165
165
|
className={cn(
|
|
166
|
-
'absolute top-full z-50 mt-2 min-w-min rounded-
|
|
166
|
+
'absolute top-full z-50 mt-2 min-w-min rounded-xl border border-border bg-popover p-3 text-popover-foreground shadow-md',
|
|
167
167
|
panelAlign === 'end' ? 'right-0' : 'left-0',
|
|
168
168
|
)}
|
|
169
169
|
>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import i18n from 'i18next'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { cn } from '../lib/utils'
|
|
4
|
+
import { BanknoteIcon } from './icons'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A currency's own symbol in the admin's language — `$`, `€`, `zł`, `R$` — or
|
|
8
|
+
* null when it has none beyond its code (CHF, AED), so a caller never shows
|
|
9
|
+
* the code twice.
|
|
10
|
+
*/
|
|
11
|
+
export function currencySymbol(code: string, locale: string = i18n.language): string | null {
|
|
12
|
+
try {
|
|
13
|
+
const symbol = new Intl.NumberFormat(locale, {
|
|
14
|
+
style: 'currency',
|
|
15
|
+
currency: code,
|
|
16
|
+
currencyDisplay: 'narrowSymbol',
|
|
17
|
+
})
|
|
18
|
+
.formatToParts(0)
|
|
19
|
+
.find((part) => part.type === 'currency')?.value
|
|
20
|
+
return symbol && symbol !== code ? symbol : null
|
|
21
|
+
} catch {
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The currency's symbol in a small tile. Decorative: the code beside it is
|
|
28
|
+
* what assistive technology reads. A currency with no symbol of its own
|
|
29
|
+
* shows a banknote, so a column of tiles keeps one shape.
|
|
30
|
+
*/
|
|
31
|
+
export function CurrencySymbol({ code, className }: { code: string; className?: string }) {
|
|
32
|
+
const symbol = currencySymbol(code)
|
|
33
|
+
return (
|
|
34
|
+
<span
|
|
35
|
+
aria-hidden
|
|
36
|
+
data-slot="currency-symbol"
|
|
37
|
+
className={cn(
|
|
38
|
+
'inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-md bg-accent-strong-hover px-1 font-medium text-[11px] text-foreground leading-none',
|
|
39
|
+
className,
|
|
40
|
+
)}
|
|
41
|
+
>
|
|
42
|
+
{symbol ?? <BanknoteIcon className="size-3 text-muted-foreground" />}
|
|
43
|
+
</span>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A currency as the dashboard shows it everywhere: its symbol tile, its code
|
|
49
|
+
* and, when given, its name (`[$] USD US Dollar`).
|
|
50
|
+
*/
|
|
51
|
+
export function CurrencyLabel({
|
|
52
|
+
code,
|
|
53
|
+
name,
|
|
54
|
+
className,
|
|
55
|
+
}: {
|
|
56
|
+
code: string
|
|
57
|
+
/** The localized name, shown muted after the code. Skipped when it is the code itself. */
|
|
58
|
+
name?: ReactNode
|
|
59
|
+
className?: string
|
|
60
|
+
}) {
|
|
61
|
+
return (
|
|
62
|
+
<span
|
|
63
|
+
data-slot="currency-label"
|
|
64
|
+
className={cn('inline-flex min-w-0 items-center gap-2', className)}
|
|
65
|
+
>
|
|
66
|
+
<CurrencySymbol code={code} />
|
|
67
|
+
<span className="shrink-0">{code}</span>
|
|
68
|
+
{name && name !== code && <span className="truncate text-muted-foreground">{name}</span>}
|
|
69
|
+
</span>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -544,7 +544,7 @@ export function SelectCell<V extends string>({
|
|
|
544
544
|
className={cn(
|
|
545
545
|
// Strip the Select's standard chrome so the cell reads as a flat
|
|
546
546
|
// grid slot. The cell's own ring/bg comes from below.
|
|
547
|
-
'h-9 min-h-0 w-full cursor-cell rounded-none border-0 bg-transparent px-3 text-
|
|
547
|
+
'h-9 min-h-0 w-full cursor-cell rounded-none border-0 bg-transparent px-3 text-base shadow-none ring-inset transition-colors focus:border-0 focus:shadow-none',
|
|
548
548
|
isSelected && 'bg-blue-500/10',
|
|
549
549
|
isFocused && 'ring-2 ring-blue-500',
|
|
550
550
|
)}
|
|
@@ -579,5 +579,5 @@ interface ReadOnlyCellProps {
|
|
|
579
579
|
/** Plain non-editable cell — first column of a row (e.g. label / link).
|
|
580
580
|
* Not registered with the grid, so Tab/arrows skip it. */
|
|
581
581
|
export function ReadOnlyCell({ children, className }: ReadOnlyCellProps) {
|
|
582
|
-
return <div className={cn('flex h-9 items-center px-3 text-
|
|
582
|
+
return <div className={cn('flex h-9 items-center px-3 text-base', className)}>{children}</div>
|
|
583
583
|
}
|
|
@@ -236,7 +236,7 @@ function DataGridShell<T extends RowData>({
|
|
|
236
236
|
// key after it lands on nothing (docs/plans/6.0-volume-pricing.md).
|
|
237
237
|
tabIndex={-1}
|
|
238
238
|
className={cn(
|
|
239
|
-
'w-full border-collapse text-
|
|
239
|
+
'w-full border-collapse text-base outline-none [&_td]:border [&_th]:border [&_td]:border-border [&_th]:border-border',
|
|
240
240
|
// Internal rules only. `border` on every cell also draws the grid's
|
|
241
241
|
// outer edge — `border-collapse` merges neighbours, so the outermost
|
|
242
242
|
// cells' outer sides become the table's own frame, doubling the
|
|
@@ -347,7 +347,7 @@ function DataGridRow<T extends RowData>({
|
|
|
347
347
|
if (headerContent) {
|
|
348
348
|
return (
|
|
349
349
|
<tr>
|
|
350
|
-
<td colSpan={columnCount} className="bg-muted/60 px-3 py-2 text-
|
|
350
|
+
<td colSpan={columnCount} className="bg-muted/60 px-3 py-2 text-base">
|
|
351
351
|
{headerContent}
|
|
352
352
|
</td>
|
|
353
353
|
</tr>
|
|
@@ -89,7 +89,7 @@ export function DeliveryFormDialog({
|
|
|
89
89
|
|
|
90
90
|
<DialogBody className="flex flex-col gap-4">
|
|
91
91
|
{errors.root?.message && (
|
|
92
|
-
<p className="text-destructive text-
|
|
92
|
+
<p className="text-destructive text-base" role="alert">
|
|
93
93
|
{errors.root.message}
|
|
94
94
|
</p>
|
|
95
95
|
)}
|
|
@@ -79,7 +79,7 @@ export function EditableNoteCard({
|
|
|
79
79
|
</div>
|
|
80
80
|
</form>
|
|
81
81
|
) : (
|
|
82
|
-
(children ?? <p className="text-muted-foreground text-
|
|
82
|
+
(children ?? <p className="text-muted-foreground text-base">{t('admin.common.none')}</p>)
|
|
83
83
|
)}
|
|
84
84
|
</CardContent>
|
|
85
85
|
</Card>
|
|
@@ -88,7 +88,7 @@ export function EditableNoteCard({
|
|
|
88
88
|
|
|
89
89
|
/** A plain-text note as it reads when not being edited. */
|
|
90
90
|
export function NoteText({ value }: { value: string }) {
|
|
91
|
-
return <p className="whitespace-pre-wrap text-muted-foreground text-
|
|
91
|
+
return <p className="whitespace-pre-wrap text-muted-foreground text-base">{value}</p>
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
/** A rich-text note as it reads when not being edited. */
|
|
@@ -2,6 +2,7 @@ import { useHotkey } from '@tanstack/react-hotkeys'
|
|
|
2
2
|
import { useEffect } from 'react'
|
|
3
3
|
import { type FieldValues, type UseFormReturn, useFormState } from 'react-hook-form'
|
|
4
4
|
import { useTranslation } from 'react-i18next'
|
|
5
|
+
import { StatusDot } from '../ui/badge'
|
|
5
6
|
import { Button } from '../ui/button'
|
|
6
7
|
import { Loader2Icon } from './icons'
|
|
7
8
|
|
|
@@ -23,6 +24,7 @@ export function FormSaveButton<TFieldValues extends FieldValues>({
|
|
|
23
24
|
label,
|
|
24
25
|
savingLabel,
|
|
25
26
|
dirty = false,
|
|
27
|
+
disabled = false,
|
|
26
28
|
}: {
|
|
27
29
|
form: UseFormReturn<TFieldValues, any, any>
|
|
28
30
|
label?: string
|
|
@@ -32,12 +34,14 @@ export function FormSaveButton<TFieldValues extends FieldValues>({
|
|
|
32
34
|
* saves from its own submit — counted as dirty alongside the form's own.
|
|
33
35
|
*/
|
|
34
36
|
dirty?: boolean
|
|
37
|
+
/** Holds Save back for a reason the form state cannot see, such as an upload in flight. */
|
|
38
|
+
disabled?: boolean
|
|
35
39
|
}) {
|
|
36
40
|
const { t } = useTranslation()
|
|
37
41
|
const { isDirty, isSubmitting } = useFormState({ control: form.control })
|
|
38
42
|
|
|
39
43
|
return (
|
|
40
|
-
<Button type="submit" disabled={!(isDirty || dirty) || isSubmitting}>
|
|
44
|
+
<Button type="submit" disabled={!(isDirty || dirty) || isSubmitting || disabled}>
|
|
41
45
|
{isSubmitting && <Loader2Icon className="animate-spin" />}
|
|
42
46
|
<span>
|
|
43
47
|
{isSubmitting
|
|
@@ -78,14 +82,23 @@ export function FormDiscardButton<TFieldValues extends FieldValues>({
|
|
|
78
82
|
if (!isDirty && !dirty) return null
|
|
79
83
|
|
|
80
84
|
return (
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
85
|
+
<>
|
|
86
|
+
{/* Says why Save just woke up, so a change made far down a long form is
|
|
87
|
+
not missed in the sticky header. Hidden on phones, where the header
|
|
88
|
+
has no room for it. */}
|
|
89
|
+
<span className="hidden items-center gap-1.5 text-base text-muted-foreground sm:inline-flex">
|
|
90
|
+
<StatusDot status="pending" />
|
|
91
|
+
{t('admin.common.unsaved_label')}
|
|
92
|
+
</span>
|
|
93
|
+
<Button
|
|
94
|
+
type="button"
|
|
95
|
+
variant="outline"
|
|
96
|
+
disabled={isSubmitting}
|
|
97
|
+
onClick={onDiscard ?? (() => form.reset())}
|
|
98
|
+
>
|
|
99
|
+
{label ?? t('admin.actions.discard')}
|
|
100
|
+
</Button>
|
|
101
|
+
</>
|
|
89
102
|
)
|
|
90
103
|
}
|
|
91
104
|
|
|
@@ -99,12 +112,15 @@ export function FormActions<TFieldValues extends FieldValues>({
|
|
|
99
112
|
discardLabel,
|
|
100
113
|
onDiscard,
|
|
101
114
|
dirty,
|
|
115
|
+
disabled,
|
|
102
116
|
}: {
|
|
103
117
|
form: UseFormReturn<TFieldValues, any, any>
|
|
104
118
|
saveLabel?: string
|
|
105
119
|
savingLabel?: string
|
|
106
120
|
discardLabel?: string
|
|
107
121
|
onDiscard?: () => void
|
|
122
|
+
/** Holds Save back, as on `FormSaveButton`. */
|
|
123
|
+
disabled?: boolean
|
|
108
124
|
/**
|
|
109
125
|
* Unsaved changes held outside the form — custom field drafts the page
|
|
110
126
|
* saves from its own submit — counted as dirty alongside the form's own.
|
|
@@ -114,7 +130,13 @@ export function FormActions<TFieldValues extends FieldValues>({
|
|
|
114
130
|
return (
|
|
115
131
|
<>
|
|
116
132
|
<FormDiscardButton form={form} label={discardLabel} onDiscard={onDiscard} dirty={dirty} />
|
|
117
|
-
<FormSaveButton
|
|
133
|
+
<FormSaveButton
|
|
134
|
+
form={form}
|
|
135
|
+
label={saveLabel}
|
|
136
|
+
savingLabel={savingLabel}
|
|
137
|
+
dirty={dirty}
|
|
138
|
+
disabled={disabled}
|
|
139
|
+
/>
|
|
118
140
|
</>
|
|
119
141
|
)
|
|
120
142
|
}
|