@spree/dashboard-ui 1.0.0-rc.4 → 1.0.0-rc.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spree/dashboard-ui",
3
3
  "license": "MIT",
4
- "version": "1.0.0-rc.4",
4
+ "version": "1.0.0-rc.6",
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",
@@ -47,21 +47,21 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@base-ui/react": "1.8.0",
50
- "@codemirror/autocomplete": "6.20.3",
50
+ "@codemirror/autocomplete": "6.20.4",
51
51
  "@codemirror/commands": "6.11.1",
52
52
  "@codemirror/lang-html": "6.4.12",
53
53
  "@codemirror/lang-liquid": "6.3.3",
54
- "@codemirror/language": "6.12.4",
54
+ "@codemirror/language": "6.13.1",
55
55
  "@codemirror/lint": "6.9.7",
56
56
  "@codemirror/search": "6.7.2",
57
57
  "@codemirror/state": "6.7.6",
58
- "@codemirror/view": "6.43.13",
58
+ "@codemirror/view": "6.43.15",
59
59
  "@dnd-kit/core": "6.3.1",
60
60
  "@dnd-kit/sortable": "10.0.0",
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.14.3",
64
+ "@tanstack/react-hotkeys": "^0.16.0",
65
65
  "@tanstack/react-table": "9.3.1",
66
66
  "@tiptap/core": "3.32.0",
67
67
  "@tiptap/extension-bubble-menu": "3.32.0",
@@ -79,13 +79,14 @@
79
79
  "date-fns": "4.4.0",
80
80
  "date-fns-tz": "3.2.0",
81
81
  "flag-icons": "7.6.0",
82
- "lucide-react": "1.58.0",
82
+ "lucide-react": "1.60.0",
83
83
  "react-colorful": "5.8.1",
84
84
  "react-day-picker": "10.0.2",
85
85
  "recharts": "3.10.1",
86
- "shadcn": "4.22.0",
86
+ "shadcn": "4.22.1",
87
87
  "tailwind-merge": "3.7.0",
88
- "tw-animate-css": "1.4.0"
88
+ "tw-animate-css": "1.4.0",
89
+ "@spree/core": "^0.3.0"
89
90
  },
90
91
  "devDependencies": {
91
92
  "@types/react": "^19.2.14",
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'
@@ -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
- if (iso) {
32
- try {
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>
@@ -23,6 +23,7 @@ export interface AddressBookEntry extends AddressBlockValue {
23
23
  export function AddressBookRow({
24
24
  address,
25
25
  canEdit = true,
26
+ inheritedFrom,
26
27
  onEdit,
27
28
  onRemove,
28
29
  onSetDefaultBilling,
@@ -30,6 +31,11 @@ export function AddressBookRow({
30
31
  }: {
31
32
  address: AddressBookEntry
32
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
33
39
  onEdit: () => void
34
40
  onRemove: () => void
35
41
  /** Omit either to hide that action — a book with one address has no choice. */
@@ -37,17 +43,25 @@ export function AddressBookRow({
37
43
  onSetDefaultShipping?: () => void
38
44
  }) {
39
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
40
49
 
41
50
  return (
42
51
  <Item variant="outline" className="items-start">
43
52
  <ItemContent className="min-w-0">
44
- {(address.label || address.is_default_billing || address.is_default_shipping) && (
53
+ {(address.label || inherited || isDefaultBilling || isDefaultShipping) && (
45
54
  <ItemTitle className="flex-wrap">
46
55
  {address.label}
47
- {address.is_default_billing && (
56
+ {inherited && (
57
+ <Badge variant="secondary">
58
+ {t('admin.address_book.inherited_from', { name: inheritedFrom })}
59
+ </Badge>
60
+ )}
61
+ {isDefaultBilling && (
48
62
  <Badge variant="outline">{t('admin.address_book.default_billing')}</Badge>
49
63
  )}
50
- {address.is_default_shipping && (
64
+ {isDefaultShipping && (
51
65
  <Badge variant="outline">{t('admin.address_book.default_shipping')}</Badge>
52
66
  )}
53
67
  </ItemTitle>
@@ -55,7 +69,7 @@ export function AddressBookRow({
55
69
  <AddressBlock address={address} />
56
70
  </ItemContent>
57
71
 
58
- {canEdit && (
72
+ {canEdit && !inherited && (
59
73
  <ItemActions>
60
74
  <RowActions
61
75
  actions={[
@@ -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
+ }
@@ -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') return formatMoney(value, currency)
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'
@@ -16,8 +17,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
16
17
  import { Switch } from '../ui/switch'
17
18
  import type { RefundMethod } from './return-dialogs'
18
19
 
19
- /** How a claim is settled. */
20
- export type ClaimResolution = 'refund' | 'replacement' | 'refund_and_replacement'
20
+ export type { ClaimResolution }
21
21
 
22
22
  /** One claimed line, as the resolve form needs it. */
23
23
  export type ClaimLineForResolve = {
@@ -74,12 +74,10 @@ export function ClaimResolveDialog({
74
74
  .filter(([, on]) => on)
75
75
  .map(([id]) => id)
76
76
 
77
- const resolutionOptions = (['refund', 'replacement', 'refund_and_replacement'] as const).map(
78
- (value) => ({
79
- value,
80
- label: t(`admin.pages.orders.detail.claims.resolutions.${value}`),
81
- }),
82
- )
77
+ const resolutionOptions = CLAIM_RESOLUTIONS.map((value) => ({
78
+ value,
79
+ label: t(`admin.pages.orders.detail.claims.resolutions.${value}`),
80
+ }))
83
81
 
84
82
  const methodOptions = [
85
83
  {
@@ -92,11 +90,11 @@ export function ClaimResolveDialog({
92
90
  },
93
91
  ]
94
92
 
93
+ // A number input keeps `.5` as typed; the API reads only `0.5`.
94
+ const refundAmount = amount.startsWith('.') ? `0${amount}` : amount
95
95
  // Refunding nothing, or replacing nothing, is what the server rejects —
96
96
  // say so here instead of letting the request fail.
97
- // The amount is the canonical decimal string a number input yields, so
98
- // "above zero" is unsigned digits with a non-zero digit among them.
99
- const positiveAmount = /^(\d+(\.\d+)?|\.\d+)$/.test(amount) && /[1-9]/.test(amount)
97
+ const positiveAmount = isPositiveMoney(refundAmount)
100
98
  const ready =
101
99
  (!refunding || positiveAmount) && (!sendingReplacement || chosenReplacements.length > 0)
102
100
 
@@ -211,7 +209,7 @@ export function ClaimResolveDialog({
211
209
  onSubmit({
212
210
  resolution,
213
211
  refundMethod,
214
- amount: refunding ? amount : undefined,
212
+ amount: refunding ? refundAmount : undefined,
215
213
  replacementLineItemIds: chosenReplacements,
216
214
  })
217
215
  }
@@ -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',
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
package/src/ui/field.tsx CHANGED
@@ -186,9 +186,20 @@ function FieldError({
186
186
  return null
187
187
  }
188
188
 
189
- const uniqueErrors = [...new Map(errors.map((error) => [error?.message, error])).values()]
189
+ // `filter` also closes the holes react-hook-form leaves in a per-item
190
+ // error array (`[ <empty>, { message } ]`), which `map` would keep and
191
+ // `new Map` would choke on.
192
+ const uniqueErrors = [
193
+ ...new Map(
194
+ errors.filter((error) => error?.message).map((error) => [error?.message, error]),
195
+ ).values(),
196
+ ]
197
+
198
+ if (!uniqueErrors.length) {
199
+ return null
200
+ }
190
201
 
191
- if (uniqueErrors?.length == 1) {
202
+ if (uniqueErrors.length == 1) {
192
203
  return uniqueErrors[0]?.message
193
204
  }
194
205