@spree/dashboard-ui 6.0.2 → 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.
Files changed (84) hide show
  1. package/package.json +20 -19
  2. package/src/index.ts +5 -0
  3. package/src/lib/field-trigger.ts +10 -0
  4. package/src/lib/same-rich-text.ts +12 -3
  5. package/src/lib/utils.ts +14 -1
  6. package/src/spree/address-block.tsx +3 -3
  7. package/src/spree/address-book-row.tsx +88 -0
  8. package/src/spree/bulk-price-table.tsx +3 -3
  9. package/src/spree/claim-resolve-dialog.tsx +1 -1
  10. package/src/spree/collapsible-card.tsx +2 -2
  11. package/src/spree/color-picker.tsx +1 -1
  12. package/src/spree/currency-label.tsx +71 -0
  13. package/src/spree/data-grid/cells.tsx +2 -2
  14. package/src/spree/data-grid/data-grid.tsx +2 -2
  15. package/src/spree/delivery-form-dialog.tsx +1 -1
  16. package/src/spree/drag-handle.tsx +1 -1
  17. package/src/spree/editable-note-card.tsx +2 -2
  18. package/src/spree/form-actions.tsx +32 -10
  19. package/src/spree/fulfill-items-form.tsx +20 -22
  20. package/src/spree/fulfillment-edit-dialog.tsx +1 -1
  21. package/src/spree/fulfillment-item-list.tsx +87 -28
  22. package/src/spree/fulfillment-panel.tsx +2 -2
  23. package/src/spree/icons.ts +8 -0
  24. package/src/spree/json-preview-drawer.tsx +32 -7
  25. package/src/spree/json-value-view.tsx +14 -4
  26. package/src/spree/label-upload-dialog.tsx +1 -1
  27. package/src/spree/language-menu-items.tsx +1 -1
  28. package/src/spree/metadata/metadata-card.tsx +2 -2
  29. package/src/spree/order-cancel-dialog.tsx +3 -3
  30. package/src/spree/post-sale-create-dialogs.tsx +36 -33
  31. package/src/spree/post-sale-fields.tsx +30 -1
  32. package/src/spree/product-identity.tsx +74 -0
  33. package/src/spree/product-membership-list.tsx +79 -41
  34. package/src/spree/products/status-card.tsx +1 -1
  35. package/src/spree/quantity-tier-editor.tsx +1 -1
  36. package/src/spree/return-dialogs.tsx +2 -2
  37. package/src/spree/search-input.tsx +3 -1
  38. package/src/spree/secret-input.tsx +1 -1
  39. package/src/spree/section.tsx +55 -0
  40. package/src/spree/stat-item.tsx +27 -0
  41. package/src/spree/tax-identifiers-card.tsx +117 -76
  42. package/src/spree/theme-toggle.tsx +1 -1
  43. package/src/spree/wizard.tsx +2 -2
  44. package/src/styles.css +226 -120
  45. package/src/ui/alert.tsx +20 -17
  46. package/src/ui/attachment.tsx +1 -1
  47. package/src/ui/avatar.tsx +2 -2
  48. package/src/ui/badge.tsx +1 -1
  49. package/src/ui/breadcrumb.tsx +1 -1
  50. package/src/ui/button.tsx +14 -11
  51. package/src/ui/calendar.tsx +2 -2
  52. package/src/ui/card.tsx +14 -37
  53. package/src/ui/chart.tsx +1 -1
  54. package/src/ui/checkbox.tsx +2 -2
  55. package/src/ui/code-editor.tsx +6 -2
  56. package/src/ui/collapsible.tsx +21 -2
  57. package/src/ui/combobox.tsx +12 -16
  58. package/src/ui/command.tsx +8 -6
  59. package/src/ui/context-menu.tsx +1 -1
  60. package/src/ui/data-table.tsx +58 -15
  61. package/src/ui/date-picker.tsx +3 -3
  62. package/src/ui/date-range-panel.tsx +102 -0
  63. package/src/ui/date-range-picker.tsx +33 -72
  64. package/src/ui/dialog.tsx +3 -3
  65. package/src/ui/dropdown-menu.tsx +6 -6
  66. package/src/ui/empty.tsx +3 -3
  67. package/src/ui/field.tsx +33 -12
  68. package/src/ui/input-group.tsx +17 -6
  69. package/src/ui/input.tsx +148 -4
  70. package/src/ui/item.tsx +7 -4
  71. package/src/ui/label.tsx +1 -1
  72. package/src/ui/media-preview.tsx +40 -5
  73. package/src/ui/pagination.tsx +16 -4
  74. package/src/ui/progress.tsx +2 -2
  75. package/src/ui/radio-group.tsx +2 -2
  76. package/src/ui/rich-text-editor.tsx +3 -3
  77. package/src/ui/select.tsx +7 -16
  78. package/src/ui/sheet.tsx +13 -5
  79. package/src/ui/sidebar.tsx +5 -5
  80. package/src/ui/switch.tsx +8 -2
  81. package/src/ui/tabs.tsx +45 -9
  82. package/src/ui/textarea.tsx +1 -1
  83. package/src/ui/toast.tsx +2 -2
  84. 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.2",
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
- "@tanstack/react-hotkeys": "^0.16.0",
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.0",
67
- "@tiptap/extension-image": "3.32.0",
68
- "@tiptap/extension-link": "3.32.0",
69
- "@tiptap/extension-placeholder": "3.32.0",
70
- "@tiptap/pm": "3.32.0",
71
- "@tiptap/react": "3.32.0",
72
- "@tiptap/starter-kit": "3.32.0",
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.60.0",
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.2"
86
+ "@spree/core": "^6.0.4"
86
87
  },
87
88
  "devDependencies": {
88
- "@tanstack/react-router": "^1.170.45",
89
+ "@tanstack/react-router": "^1.170.46",
89
90
  "@types/react": "^19.2.14",
90
91
  "@types/react-dom": "^19.2.3",
91
- "i18next": "^26.5.3",
92
+ "i18next": "^27.1.1",
92
93
  "react": "^19.3.1",
93
94
  "react-dom": "^19.3.1",
94
- "react-hook-form": "^7.90.0",
95
- "react-i18next": "^17.1.0",
96
- "tailwindcss": "^4.2.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.4"
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 = looksLikeHtml(previous.trim()) ? previous : `<p>${previous}</p>`
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
- if (!looksLikeHtml(withoutTrailingEmpty)) return `<p>${withoutTrailingEmpty}</p>`
33
- return withoutTrailingEmpty
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 { twMerge } from 'tailwind-merge'
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-sm mb-1.5">{title}</h6>}
50
+ {title && <h6 className="font-semibold text-base mb-1.5">{title}</h6>}
51
51
  {address ? (
52
- <div className="text-sm text-muted-foreground flex flex-col gap-0.5">
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-sm text-muted-foreground">
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-sm text-muted-foreground">{labels.loading}</p>
353
+ <p className="text-base text-muted-foreground">{labels.loading}</p>
354
354
  ) : isEmpty ? (
355
- <p className="text-sm text-muted-foreground">
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-sm">
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-1.5', !isOpen && 'border-b-0')}
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-lg border border-border bg-popover p-3 text-popover-foreground shadow-md',
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-sm shadow-none ring-inset transition-colors focus:border-0 focus:shadow-none',
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-sm', className)}>{children}</div>
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-sm outline-none [&_td]:border [&_th]:border [&_td]:border-border [&_th]:border-border',
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-sm">
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-sm" role="alert">
92
+ <p className="text-destructive text-base" role="alert">
93
93
  {errors.root.message}
94
94
  </p>
95
95
  )}
@@ -45,7 +45,7 @@ export function DragHandle({ attributes, listeners, className, ...props }: DragH
45
45
  {...listeners}
46
46
  {...props}
47
47
  >
48
- <GripVerticalIcon className="size-4" />
48
+ <GripVerticalIcon className="size-4 shrink-0" />
49
49
  </button>
50
50
  )
51
51
  }
@@ -79,7 +79,7 @@ export function EditableNoteCard({
79
79
  </div>
80
80
  </form>
81
81
  ) : (
82
- (children ?? <p className="text-muted-foreground text-sm">{t('admin.common.none')}</p>)
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-sm">{value}</p>
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
- <Button
82
- type="button"
83
- variant="outline"
84
- disabled={isSubmitting}
85
- onClick={onDiscard ?? (() => form.reset())}
86
- >
87
- {label ?? t('admin.actions.discard')}
88
- </Button>
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 form={form} label={saveLabel} savingLabel={savingLabel} dirty={dirty} />
133
+ <FormSaveButton
134
+ form={form}
135
+ label={saveLabel}
136
+ savingLabel={savingLabel}
137
+ dirty={dirty}
138
+ disabled={disabled}
139
+ />
118
140
  </>
119
141
  )
120
142
  }