@spree/dashboard-core 0.13.1 → 1.0.0-beta.2

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 (125) hide show
  1. package/README.md +4 -0
  2. package/dist/vite/index.js +17 -3
  3. package/package.json +7 -7
  4. package/src/api-client.ts +643 -0
  5. package/src/components/address-form-dialog.test.ts +25 -0
  6. package/src/components/address-form-dialog.tsx +203 -79
  7. package/src/components/app-shell.tsx +171 -0
  8. package/src/components/app-sidebar.tsx +184 -27
  9. package/src/components/bulk-action-bar.tsx +89 -157
  10. package/src/components/country-combobox.tsx +69 -34
  11. package/src/components/country-state-fields.tsx +43 -11
  12. package/src/components/currency-select.tsx +81 -66
  13. package/src/components/export-button.tsx +62 -5
  14. package/src/components/file-upload-field.tsx +32 -10
  15. package/src/components/import-button.tsx +52 -27
  16. package/src/components/import-wizard-dialog.tsx +710 -0
  17. package/src/components/locale-select.tsx +70 -57
  18. package/src/components/media-picker-sheet.tsx +307 -0
  19. package/src/components/mobile-top-bar.tsx +75 -0
  20. package/src/components/nav-main.tsx +197 -27
  21. package/src/components/page-header.tsx +203 -61
  22. package/src/components/page-tabs.tsx +4 -1
  23. package/src/components/preferences-form.tsx +98 -4
  24. package/src/components/resource-combobox.tsx +52 -4
  25. package/src/components/resource-picker-sheet.tsx +209 -73
  26. package/src/components/resource-table.tsx +383 -127
  27. package/src/components/route-announcer.tsx +64 -0
  28. package/src/components/section-heading.tsx +95 -0
  29. package/src/components/settings-sidebar.tsx +290 -60
  30. package/src/components/sheet-top-bar.tsx +44 -0
  31. package/src/components/sidebar-search.tsx +83 -0
  32. package/src/components/sidebar-user.tsx +213 -0
  33. package/src/components/skip-link.tsx +36 -0
  34. package/src/components/store-date-picker.tsx +6 -2
  35. package/src/components/store-switcher.tsx +19 -14
  36. package/src/components/table-toolbar.tsx +1357 -440
  37. package/src/components/tag-combobox.tsx +12 -4
  38. package/src/components/top-bar-breadcrumbs.tsx +235 -0
  39. package/src/components/wizard-close-guard.test.ts +54 -0
  40. package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
  41. package/src/hooks/use-command-palette.tsx +13 -0
  42. package/src/hooks/use-countries.test.ts +56 -0
  43. package/src/hooks/use-countries.ts +51 -5
  44. package/src/hooks/use-direct-upload.ts +16 -3
  45. package/src/hooks/use-export.ts +51 -16
  46. package/src/hooks/use-import.ts +241 -16
  47. package/src/hooks/use-package-types.ts +132 -0
  48. package/src/hooks/use-resource-mutation.ts +74 -13
  49. package/src/hooks/use-stock-locations.ts +131 -0
  50. package/src/hooks/use-switch-admin-locale.ts +2 -2
  51. package/src/hooks/use-visual-viewport-offset.ts +61 -0
  52. package/src/index.ts +45 -1
  53. package/src/lib/cancel-order-form.ts +27 -0
  54. package/src/lib/create-action-registry.ts +278 -0
  55. package/src/lib/date-presets.test.ts +86 -0
  56. package/src/lib/date-presets.ts +98 -0
  57. package/src/lib/delivery-form.ts +15 -0
  58. package/src/lib/docs.ts +32 -0
  59. package/src/lib/download.ts +13 -1
  60. package/src/lib/filters-to-ransack.test.ts +113 -0
  61. package/src/lib/filters-to-ransack.ts +22 -1
  62. package/src/lib/form-errors.ts +122 -10
  63. package/src/lib/form-mappers.ts +76 -0
  64. package/src/lib/fulfillment-items.test.ts +213 -0
  65. package/src/lib/fulfillment-items.ts +171 -0
  66. package/src/lib/i18n.ts +19 -11
  67. package/src/lib/import-types.ts +25 -0
  68. package/src/lib/label-upload-form.ts +22 -0
  69. package/src/lib/nav-registry.ts +71 -3
  70. package/src/lib/permissions.ts +46 -1
  71. package/src/lib/progress.ts +18 -0
  72. package/src/lib/query-keys.ts +13 -13
  73. package/src/lib/sample-csv.ts +9 -6
  74. package/src/lib/search-registry.ts +6 -2
  75. package/src/lib/settings-nav-registry.ts +104 -11
  76. package/src/lib/table-registry.ts +50 -0
  77. package/src/lib/type-labels.test.ts +68 -0
  78. package/src/lib/type-labels.ts +78 -0
  79. package/src/locales/ar.json +1889 -517
  80. package/src/locales/de.json +1884 -517
  81. package/src/locales/en.json +1886 -519
  82. package/src/locales/fr.json +1884 -517
  83. package/src/locales/pl.json +1913 -517
  84. package/src/locales/zh-CN.json +1883 -517
  85. package/src/pages/package-types.tsx +484 -0
  86. package/src/pages/settings-index.tsx +112 -0
  87. package/src/pages/stock-locations.tsx +674 -0
  88. package/src/plugin.ts +29 -0
  89. package/src/products/add-video-dialog.tsx +100 -0
  90. package/src/products/bulk-variants-dialog.tsx +301 -0
  91. package/src/products/carton-size.test.ts +51 -0
  92. package/src/products/carton-size.ts +20 -0
  93. package/src/products/currency-parts.ts +21 -0
  94. package/src/products/inventory-section.tsx +259 -0
  95. package/src/products/media-edit-sheet.tsx +336 -0
  96. package/src/products/normalize-customs.test.ts +39 -0
  97. package/src/products/normalize-customs.ts +27 -0
  98. package/src/products/normalize-money.test.ts +58 -0
  99. package/src/products/normalize-money.ts +73 -0
  100. package/src/products/normalize-quantity.test.ts +32 -0
  101. package/src/products/normalize-quantity.ts +31 -0
  102. package/src/products/product-bulk-price-editor.tsx +165 -0
  103. package/src/products/product-form-cards.tsx +1167 -0
  104. package/src/products/product-form-mapping.ts +212 -0
  105. package/src/products/product-schema.ts +318 -0
  106. package/src/products/product-types.ts +130 -0
  107. package/src/products/use-product-form-data.ts +273 -0
  108. package/src/products/variant-edit-sheet.tsx +725 -0
  109. package/src/products/variants-matrix.test.ts +154 -0
  110. package/src/products/variants-matrix.ts +244 -0
  111. package/src/products/variants-options-builder.tsx +571 -0
  112. package/src/products/variants-section.tsx +535 -0
  113. package/src/products/video-url.test.ts +71 -0
  114. package/src/products/video-url.ts +83 -0
  115. package/src/providers/auth-provider.tsx +70 -12
  116. package/src/providers/permission-provider.tsx +68 -28
  117. package/src/providers/sticky-header-provider.tsx +134 -0
  118. package/src/providers/store-provider.tsx +36 -3
  119. package/src/providers/tenant-provider.tsx +33 -0
  120. package/src/schemas/package-type.ts +95 -0
  121. package/src/schemas/stock-location.ts +126 -0
  122. package/src/tables/package-types.tsx +74 -0
  123. package/src/tables/stock-locations.tsx +124 -0
  124. package/src/vite/index.ts +40 -7
  125. package/src/components/top-bar.tsx +0 -209
@@ -1,19 +1,20 @@
1
1
  import {
2
2
  Combobox,
3
+ ComboboxButtonTrigger,
3
4
  ComboboxChip,
4
5
  ComboboxChips,
5
6
  ComboboxChipsInput,
6
7
  ComboboxContent,
7
8
  ComboboxEmpty,
8
- ComboboxInput,
9
9
  ComboboxItem,
10
10
  ComboboxList,
11
+ ComboboxSearch,
12
+ ComboboxTriggerPlaceholder,
11
13
  ComboboxValue,
12
14
  CountryFlag,
13
- InputGroupAddon,
14
15
  useComboboxAnchor,
15
16
  } from '@spree/dashboard-ui'
16
- import { useMemo, useState } from 'react'
17
+ import { useCallback, useMemo, useState } from 'react'
17
18
  import { useTranslation } from 'react-i18next'
18
19
  import { useCountries } from '../hooks/use-countries'
19
20
  import { useDisplayName } from '../hooks/use-display-name'
@@ -23,11 +24,19 @@ export type CountryOption = { iso: string; iso3: string; name: string }
23
24
  /**
24
25
  * Resolves a country's display name in the admin UI language (keyed on the
25
26
  * stable ISO code), falling back to the API's backend-locale `name` when the
26
- * runtime lacks coverage.
27
+ * runtime lacks coverage. Exported so every country-labelled surface
28
+ * (pickers, grid cells) reads the same.
29
+ *
30
+ * The resolver is referentially stable across renders — callers feed it into
31
+ * `useMemo` deps, and a fresh closure each render would rebuild every derived
32
+ * option list (and, downstream, every grid column) on each keystroke.
27
33
  */
28
- function useCountryDisplayName() {
34
+ export function useCountryDisplayName() {
29
35
  const displayName = useDisplayName('region')
30
- return (country: CountryOption) => displayName(country.iso) ?? country.name
36
+ return useCallback(
37
+ (country: CountryOption) => displayName(country.iso) ?? country.name,
38
+ [displayName],
39
+ )
31
40
  }
32
41
 
33
42
  /**
@@ -79,32 +88,51 @@ function CountryRow({ country, label }: { country: CountryOption; label: string
79
88
  )
80
89
  }
81
90
 
91
+ /** Maps the cached country list onto the flat option shape both pickers use. */
92
+ function useCountryOptions(): CountryOption[] {
93
+ const { countries } = useCountries()
94
+ return useMemo(
95
+ () => countries.map((c) => ({ iso: c.iso, iso3: c.iso3, name: c.name })),
96
+ [countries],
97
+ )
98
+ }
99
+
82
100
  /**
83
101
  * Searchable single-select country picker. Value is the 2-letter ISO code
84
- * (so the field round-trips cleanly with the Spree API). Renders the country
85
- * name + flag in the trigger and `<CountryRow>` in the dropdown.
102
+ * (so the field round-trips cleanly with the Spree API).
103
+ *
104
+ * The field itself is a button showing the selected flag + name, with the
105
+ * search box inside the dropdown — see `<ComboboxButtonTrigger>` for why a
106
+ * text input is the wrong trigger for an address-adjacent picker.
86
107
  */
87
108
  export function CountryCombobox({
109
+ id,
88
110
  value,
89
111
  onValueChange,
112
+ onBlur,
90
113
  placeholder,
114
+ searchPlaceholder,
115
+ invalid,
91
116
  disabled = false,
92
117
  }: {
118
+ /** Forwarded to the trigger so a `<FieldLabel htmlFor>` can target it. */
119
+ id?: string
93
120
  value: string | null | undefined
94
121
  onValueChange: (iso: string) => void
122
+ /** Forwarded to the trigger so RHF's `<Controller>` can track touched state. */
123
+ onBlur?: () => void
124
+ /** Trigger text while nothing is selected. */
95
125
  placeholder?: string
126
+ /** Text in the dropdown's search box. */
127
+ searchPlaceholder?: string
128
+ invalid?: boolean
96
129
  disabled?: boolean
97
130
  }) {
98
131
  const { t } = useTranslation()
99
- const { countries } = useCountries()
132
+ const items = useCountryOptions()
100
133
  const countryName = useCountryDisplayName()
101
134
  const filter = useCountryFilter()
102
135
 
103
- const items: CountryOption[] = useMemo(
104
- () => countries.map((c) => ({ iso: c.iso, iso3: c.iso3, name: c.name })),
105
- [countries],
106
- )
107
-
108
136
  // The Combobox holds the selected object internally; we adapt to a flat ISO
109
137
  // string at the boundary so callers don't have to thread the option shape.
110
138
  const selected = useMemo(() => items.find((c) => c.iso === value) ?? null, [items, value])
@@ -121,21 +149,29 @@ export function CountryCombobox({
121
149
  // "Allemagne", "Germany", "DE", or "DEU" all find the country.
122
150
  filter={filter}
123
151
  >
124
- <ComboboxInput
125
- placeholder={placeholder ?? t('admin.components.country_combobox.search_placeholder')}
152
+ <ComboboxButtonTrigger
153
+ id={id}
154
+ onBlur={onBlur}
126
155
  disabled={disabled}
156
+ aria-invalid={invalid || undefined}
127
157
  >
128
- {/* When a country is picked the InputGroup gets a leading flag —
129
- mirrors the dropdown items so the trigger shows the same shape.
130
- Hidden while empty so the input doesn't lurch when typing a new
131
- search query. */}
132
- {selected?.iso && (
133
- <InputGroupAddon align="inline-start">
158
+ {selected ? (
159
+ <>
134
160
  <CountryFlag iso={selected.iso} />
135
- </InputGroupAddon>
161
+ <span className="truncate">{countryName(selected)}</span>
162
+ </>
163
+ ) : (
164
+ <ComboboxTriggerPlaceholder>
165
+ {placeholder ?? t('admin.components.country_combobox.placeholder')}
166
+ </ComboboxTriggerPlaceholder>
136
167
  )}
137
- </ComboboxInput>
168
+ </ComboboxButtonTrigger>
138
169
  <ComboboxContent>
170
+ <ComboboxSearch
171
+ placeholder={
172
+ searchPlaceholder ?? t('admin.components.country_combobox.search_placeholder')
173
+ }
174
+ />
139
175
  <ComboboxEmpty>{t('admin.components.country_combobox.empty')}</ComboboxEmpty>
140
176
  <ComboboxList>
141
177
  {(country: CountryOption) => (
@@ -152,9 +188,13 @@ export function CountryCombobox({
152
188
  /**
153
189
  * Searchable multi-select country picker. Value is an array of 2-letter ISO
154
190
  * codes. Renders a flag + name per chip and `<CountryRow>` per dropdown row;
155
- * filters the cached `useCountries()` list client-side via `countryFilter`
156
- * (name + both ISO codes). Reused anywhere the admin picks multiple
191
+ * filters the cached `useCountries()` list client-side (localized name, raw
192
+ * name, and both ISO codes). Reused anywhere the admin picks multiple
157
193
  * countries (promotion Country rule, market/zone editors, …).
194
+ *
195
+ * Keeps the chips-with-inline-input shape rather than a button trigger: the
196
+ * chips are the selection display, and typing beside them is how a
197
+ * multi-select is expected to work.
158
198
  */
159
199
  export function CountryMultiCombobox({
160
200
  value,
@@ -172,17 +212,12 @@ export function CountryMultiCombobox({
172
212
  disabled?: boolean
173
213
  }) {
174
214
  const { t } = useTranslation()
175
- const { countries } = useCountries()
215
+ const items = useCountryOptions()
176
216
  const countryName = useCountryDisplayName()
177
217
  const filter = useCountryFilter()
178
218
  const anchorRef = useComboboxAnchor()
179
219
  const [inputValue, setInputValue] = useState('')
180
220
 
181
- const items: CountryOption[] = useMemo(
182
- () => countries.map((c) => ({ iso: c.iso, iso3: c.iso3, name: c.name })),
183
- [countries],
184
- )
185
-
186
221
  // The Combobox holds full option objects; selection round-trips as ISO
187
222
  // strings. Selected ISOs not yet present in `items` (list still loading)
188
223
  // fall back to a stub so chips don't vanish on first render.
@@ -218,13 +253,13 @@ export function CountryMultiCombobox({
218
253
  }
219
254
  </ComboboxValue>
220
255
  <ComboboxChipsInput
221
- placeholder={placeholder ?? t('admin.promotions.rules.country.search_placeholder')}
256
+ placeholder={placeholder ?? t('admin.components.country_combobox.search_placeholder')}
222
257
  value={inputValue}
223
258
  onChange={(e) => setInputValue((e.target as HTMLInputElement).value)}
224
259
  />
225
260
  </ComboboxChips>
226
261
  <ComboboxContent anchor={anchorRef}>
227
- <ComboboxEmpty>{emptyText ?? t('admin.promotions.rules.country.empty')}</ComboboxEmpty>
262
+ <ComboboxEmpty>{emptyText ?? t('admin.components.country_combobox.empty')}</ComboboxEmpty>
228
263
  <ComboboxList>
229
264
  {(country: CountryOption) => (
230
265
  <ComboboxItem key={country.iso} value={country}>
@@ -1,11 +1,13 @@
1
1
  import type { State } from '@spree/admin-sdk'
2
2
  import {
3
3
  Combobox,
4
+ ComboboxButtonTrigger,
4
5
  ComboboxContent,
5
6
  ComboboxEmpty,
6
- ComboboxInput,
7
7
  ComboboxItem,
8
8
  ComboboxList,
9
+ ComboboxSearch,
10
+ ComboboxTriggerPlaceholder,
9
11
  } from '@spree/dashboard-ui'
10
12
  import { useMemo } from 'react'
11
13
  import { useTranslation } from 'react-i18next'
@@ -18,11 +20,11 @@ type StateOption = { abbr: string; name: string }
18
20
  * when the country doesn't enumerate them (free-text region); `statesRequired`
19
21
  * tells callers whether to fall back to a plain text input.
20
22
  */
21
- export function useCountryStates(countryIso: string | null | undefined) {
23
+ export function useCountryStates(countryCode: string | null | undefined) {
22
24
  const { countries } = useCountries()
23
25
  const country = useMemo(
24
- () => countries.find((c) => c.iso === countryIso) ?? null,
25
- [countries, countryIso],
26
+ () => countries.find((c) => c.iso === countryCode) ?? null,
27
+ [countries, countryCode],
26
28
  )
27
29
  return {
28
30
  states: ((country?.states ?? []) as StateOption[]).filter((s) => Boolean(s.abbr)),
@@ -35,23 +37,39 @@ export function useCountryStates(countryIso: string | null | undefined) {
35
37
  * abbreviation (e.g. "CA"). Callers should hide this and render a free-text
36
38
  * Input when `useCountryStates(...).states` is empty.
37
39
  *
38
- * Keyed on `countryIso` so the internal state is reset when the country
40
+ * Shares the country picker's button-trigger shape — the two sit side by side
41
+ * in every address form, so they have to look alike, and the state field is
42
+ * exactly as prone to being buried by the browser's saved-address panel.
43
+ *
44
+ * Keyed on `countryCode` so the internal state is reset when the country
39
45
  * changes — prevents a stale highlight from a previous country.
40
46
  */
41
47
  export function StateCombobox({
42
- countryIso,
48
+ id,
49
+ countryCode,
43
50
  states,
44
51
  value,
45
52
  onValueChange,
53
+ onBlur,
46
54
  placeholder,
55
+ searchPlaceholder,
56
+ invalid,
47
57
  disabled = false,
48
58
  }: {
49
- countryIso: string | null | undefined
59
+ /** Forwarded to the trigger so a `<FieldLabel htmlFor>` can target it. */
60
+ id?: string
61
+ countryCode: string | null | undefined
50
62
  /** State list for the active country (typically from `useCountryStates`). */
51
63
  states: Pick<State, 'abbr' | 'name'>[]
52
64
  value: string | null | undefined
53
65
  onValueChange: (abbr: string) => void
66
+ /** Forwarded to the trigger so RHF's `<Controller>` can track touched state. */
67
+ onBlur?: () => void
68
+ /** Trigger text while nothing is selected. */
54
69
  placeholder?: string
70
+ /** Text in the dropdown's search box. */
71
+ searchPlaceholder?: string
72
+ invalid?: boolean
55
73
  disabled?: boolean
56
74
  }) {
57
75
  const { t } = useTranslation()
@@ -60,18 +78,32 @@ export function StateCombobox({
60
78
 
61
79
  return (
62
80
  <Combobox
63
- key={countryIso ?? 'no-country'}
81
+ key={countryCode ?? 'no-country'}
64
82
  items={items}
65
83
  value={selected}
66
84
  onValueChange={(s: StateOption | null) => onValueChange(s?.abbr ?? '')}
67
85
  itemToStringLabel={(s: StateOption | null) => s?.name ?? ''}
68
86
  itemToStringValue={(s: StateOption | null) => s?.abbr ?? ''}
87
+ disabled={disabled}
69
88
  >
70
- <ComboboxInput
71
- placeholder={placeholder ?? t('admin.components.state_combobox.search_placeholder')}
89
+ <ComboboxButtonTrigger
90
+ id={id}
91
+ onBlur={onBlur}
72
92
  disabled={disabled}
73
- />
93
+ aria-invalid={invalid || undefined}
94
+ >
95
+ {selected ? (
96
+ <span className="truncate">{selected.name}</span>
97
+ ) : (
98
+ <ComboboxTriggerPlaceholder>
99
+ {placeholder ?? t('admin.components.state_combobox.placeholder')}
100
+ </ComboboxTriggerPlaceholder>
101
+ )}
102
+ </ComboboxButtonTrigger>
74
103
  <ComboboxContent>
104
+ <ComboboxSearch
105
+ placeholder={searchPlaceholder ?? t('admin.components.state_combobox.search_placeholder')}
106
+ />
75
107
  <ComboboxEmpty>{t('admin.components.state_combobox.empty')}</ComboboxEmpty>
76
108
  <ComboboxList>
77
109
  {(state: StateOption) => (
@@ -1,20 +1,17 @@
1
1
  import {
2
2
  Combobox,
3
+ ComboboxButtonTrigger,
3
4
  ComboboxContent,
4
5
  ComboboxEmpty,
5
- ComboboxInput,
6
6
  ComboboxItem,
7
7
  ComboboxList,
8
- Select,
9
- SelectContent,
10
- SelectItem,
11
- SelectTrigger,
12
- SelectValue,
8
+ ComboboxSearch,
9
+ ComboboxTriggerPlaceholder,
13
10
  } from '@spree/dashboard-ui'
14
11
  import { useMemo, useState } from 'react'
15
12
  import { useTranslation } from 'react-i18next'
16
13
  import { useDisplayName } from '../hooks/use-display-name'
17
- import { useStore } from '../providers/store-provider'
14
+ import { useOptionalStore } from '../providers/store-provider'
18
15
 
19
16
  /**
20
17
  * Every ISO 4217 currency code the runtime knows about — the active-currency
@@ -33,6 +30,10 @@ export const ALL_CURRENCY_CODES: string[] = (() => {
33
30
  })()
34
31
 
35
32
  interface CurrencySelectProps {
33
+ /** Classes for the trigger — used to match a neighbouring control's height. */
34
+ triggerClassName?: string
35
+ /** `sm` matches the toolbar controls beside it — see `SelectTrigger`. */
36
+ triggerSize?: 'sm' | 'default'
36
37
  /** ID for the trigger — paired with the parent `<FieldLabel htmlFor>`. */
37
38
  id?: string
38
39
  /** Submit name — a hidden input mirrors the value so plain `FormData` works. */
@@ -47,18 +48,24 @@ interface CurrencySelectProps {
47
48
  * Currency codes to pick from. Defaults to the current store's
48
49
  * `supported_currencies`. Pass `ALL_CURRENCY_CODES` (or any custom list) for
49
50
  * contexts where the merchant chooses a currency the store doesn't yet
50
- * support — e.g. a market's currency. Large lists switch to a searchable
51
- * combobox automatically.
51
+ * support — e.g. a market's currency.
52
52
  */
53
53
  options?: string[]
54
- /** Marks the field required for screen readers + native form submission. */
54
+ /**
55
+ * Announces the field as required to assistive technology (`aria-required`).
56
+ * It does not enforce anything: the trigger is a button, so there is no
57
+ * native constraint validation behind it. The consuming form owns
58
+ * enforcement.
59
+ */
55
60
  required?: boolean
56
61
  disabled?: boolean
62
+ /** Text in the dropdown's search box. */
63
+ searchPlaceholder?: string
64
+ invalid?: boolean
65
+ /** Forwarded to the trigger so RHF's `<Controller>` can track touched state. */
66
+ onBlur?: () => void
57
67
  }
58
68
 
59
- /** Above this many options a plain `<Select>` is unwieldy — switch to search. */
60
- const SEARCHABLE_THRESHOLD = 12
61
-
62
69
  /**
63
70
  * Picker for a currency code. Defaults to the current store's
64
71
  * `supported_currencies` (and the store's `default_currency`), so callers
@@ -66,7 +73,11 @@ const SEARCHABLE_THRESHOLD = 12
66
73
  * Pass `options` (e.g. `ALL_CURRENCY_CODES`) when the merchant may pick any
67
74
  * currency, such as a market's currency. Each option reads `CODE — Full Name`
68
75
  * (e.g. `USD — US Dollar`), with the name localized to the admin UI language.
69
- * Long option lists render a searchable combobox.
76
+ *
77
+ * Always searchable, whatever the list length. The picker used to fall back to
78
+ * a plain `<Select>` under a dozen options, which meant the same field behaved
79
+ * differently from one store to the next — typing to filter worked on a store
80
+ * with many currencies and did nothing on a store with three.
70
81
  */
71
82
  export function CurrencySelect({
72
83
  id,
@@ -77,9 +88,22 @@ export function CurrencySelect({
77
88
  options,
78
89
  required,
79
90
  disabled,
91
+ searchPlaceholder,
92
+ invalid,
93
+ onBlur,
94
+ triggerClassName,
95
+ triggerSize,
80
96
  }: CurrencySelectProps) {
81
97
  const { t } = useTranslation()
82
- const { currencies, defaultCurrency } = useStore()
98
+ // Optional: the operator's dashboard always has a store, the seller panel
99
+ // has none — its tenant is a seller and the store is derived server-side.
100
+ // Without one the picker renders from the `options` its caller passes,
101
+ // rather than refusing to render at all. It deliberately does NOT fall back
102
+ // to every ISO code: a currency the store does not trade in saves fine and
103
+ // then quotes nothing.
104
+ const store = useOptionalStore()
105
+ const currencies = store?.currencies ?? []
106
+ const defaultCurrency = store?.defaultCurrency ?? ''
83
107
  const [internalValue, setInternalValue] = useState(defaultValue ?? defaultCurrency)
84
108
  const isControlled = controlledValue !== undefined
85
109
  // Controlled callers that pass an empty value still see the store default
@@ -120,62 +144,53 @@ export function CurrencySelect({
120
144
 
121
145
  const hiddenInput = name ? <input type="hidden" name={name} value={value} /> : null
122
146
 
123
- if (items.length > SEARCHABLE_THRESHOLD) {
124
- return (
125
- <>
126
- {hiddenInput}
127
- <Combobox
128
- items={items}
129
- value={value}
130
- onValueChange={(next: string | null) => handleChange(next ?? '')}
131
- itemToStringLabel={(code: string | null) => (code ? renderOption(code) : '')}
132
- itemToStringValue={(code: string | null) => code ?? ''}
133
- filter={filter}
134
- disabled={disabled}
135
- >
136
- <ComboboxInput
137
- id={id}
138
- aria-required={required}
139
- placeholder={t('admin.components.currency_select.placeholder')}
140
- disabled={disabled}
141
- />
142
- <ComboboxContent>
143
- <ComboboxEmpty>{t('admin.components.currency_select.empty')}</ComboboxEmpty>
144
- <ComboboxList>
145
- {(code: string) => (
146
- <ComboboxItem key={code} value={code}>
147
- {renderOption(code)}
148
- </ComboboxItem>
149
- )}
150
- </ComboboxList>
151
- </ComboboxContent>
152
- </Combobox>
153
- </>
154
- )
155
- }
156
-
157
147
  return (
158
148
  <>
159
149
  {/* Hidden input keeps the parent `<form>` submit / FormData path working
160
150
  without each caller having to thread the value through state. */}
161
151
  {hiddenInput}
162
- <Select value={value} onValueChange={handleChange} disabled={disabled}>
163
- <SelectTrigger id={id} aria-required={required}>
164
- {/* Base UI's `<SelectValue>` defaults to rendering the raw `value`
165
- (the bare ISO code). Use the children render-prop so the
166
- trigger shows the same `CODE — Full Name` as the items. */}
167
- <SelectValue placeholder={t('admin.components.currency_select.placeholder')}>
168
- {(v) => (v ? renderOption(v as string) : (v as string))}
169
- </SelectValue>
170
- </SelectTrigger>
171
- <SelectContent>
172
- {items.map((currency) => (
173
- <SelectItem key={currency} value={currency}>
174
- {renderOption(currency)}
175
- </SelectItem>
176
- ))}
177
- </SelectContent>
178
- </Select>
152
+ <Combobox
153
+ items={items}
154
+ value={value}
155
+ onValueChange={(next: string | null) => handleChange(next ?? '')}
156
+ itemToStringLabel={(code: string | null) => (code ? renderOption(code) : '')}
157
+ itemToStringValue={(code: string | null) => code ?? ''}
158
+ filter={filter}
159
+ disabled={disabled}
160
+ >
161
+ <ComboboxButtonTrigger
162
+ id={id}
163
+ size={triggerSize}
164
+ className={triggerClassName}
165
+ onBlur={onBlur}
166
+ disabled={disabled}
167
+ aria-required={required}
168
+ aria-invalid={invalid || undefined}
169
+ >
170
+ {value ? (
171
+ <span className="truncate">{renderOption(value)}</span>
172
+ ) : (
173
+ <ComboboxTriggerPlaceholder>
174
+ {t('admin.components.currency_select.placeholder')}
175
+ </ComboboxTriggerPlaceholder>
176
+ )}
177
+ </ComboboxButtonTrigger>
178
+ <ComboboxContent>
179
+ <ComboboxSearch
180
+ placeholder={
181
+ searchPlaceholder ?? t('admin.components.currency_select.search_placeholder')
182
+ }
183
+ />
184
+ <ComboboxEmpty>{t('admin.components.currency_select.empty')}</ComboboxEmpty>
185
+ <ComboboxList>
186
+ {(code: string) => (
187
+ <ComboboxItem key={code} value={code}>
188
+ {renderOption(code)}
189
+ </ComboboxItem>
190
+ )}
191
+ </ComboboxList>
192
+ </ComboboxContent>
193
+ </Combobox>
179
194
  </>
180
195
  )
181
196
  }
@@ -1,4 +1,3 @@
1
- import type { ExportType } from '@spree/admin-sdk'
2
1
  import {
3
2
  Button,
4
3
  Dialog,
@@ -16,8 +15,8 @@ import {
16
15
  RadioGroup,
17
16
  RadioGroupItem,
18
17
  } from '@spree/dashboard-ui'
18
+ import { DownloadIcon, FilterIcon, GlobeIcon } from '@spree/dashboard-ui/icons'
19
19
  import type { TFunction } from 'i18next'
20
- import { DownloadIcon, FilterIcon, GlobeIcon } from 'lucide-react'
21
20
  import { useState } from 'react'
22
21
  import { useTranslation } from 'react-i18next'
23
22
  import { useExport } from '../hooks/use-export'
@@ -25,14 +24,61 @@ import { filtersToRansack } from '../lib/filters-to-ransack'
25
24
  import type { ResourceActionsContext } from './resource-table'
26
25
 
27
26
  interface ExportButtonProps extends ResourceActionsContext {
28
- /** Which dataset to export. Server validates against `Spree::Export.available_types`. */
29
- type: ExportType
27
+ /**
28
+ * Which dataset to export. A plain string rather than either SDK's union:
29
+ * the operator's registry and a seller's allowlist are different sets, and
30
+ * the server is what validates the value in both panels.
31
+ */
32
+ type: string
30
33
  /** Label shown on the button. Defaults to the translated "Export" action. */
31
34
  label?: string
32
35
  }
33
36
 
34
37
  type Selection = 'filtered' | 'all'
35
38
 
39
+ interface ExportRecordButtonProps {
40
+ /** Which dataset to export; see `ExportButton`. */
41
+ type: string
42
+ /** Ransack filter naming the records, as `search_params` on the export. */
43
+ searchParams: Record<string, unknown>
44
+ /** Label shown on the button. Defaults to the translated "Export" action. */
45
+ label?: string
46
+ /** Optional size for the button. Defaults to "sm". */
47
+ size?: 'sm' | 'default' | 'lg'
48
+ }
49
+
50
+ /**
51
+ * Export from a record's own page — one price list's prices, one promotion's
52
+ * coupon codes. The page has already decided what the file holds, so one
53
+ * click queues it; the filtered-or-all dialog is the table's concern.
54
+ */
55
+ export function ExportRecordButton({ type, searchParams, label, size }: ExportRecordButtonProps) {
56
+ const { t } = useTranslation()
57
+ const exportMutation = useExport()
58
+
59
+ return (
60
+ <Button
61
+ type="button"
62
+ size={size ?? 'sm'}
63
+ variant="outline"
64
+ onClick={() =>
65
+ exportMutation.mutate({
66
+ type,
67
+ record_selection: 'filtered',
68
+ search_params: searchParams,
69
+ results_url: window.location.href,
70
+ })
71
+ }
72
+ disabled={exportMutation.isPending}
73
+ >
74
+ <DownloadIcon className="size-4" />
75
+ {exportMutation.isPending
76
+ ? t('admin.actions.exporting')
77
+ : (label ?? t('admin.actions.export'))}
78
+ </Button>
79
+ )
80
+ }
81
+
36
82
  export function ExportButton({
37
83
  type,
38
84
  label,
@@ -59,16 +105,27 @@ export function ExportButton({
59
105
  type,
60
106
  record_selection: selection,
61
107
  search_params: selection === 'filtered' ? search_params : undefined,
108
+ // Where the done email sends the user back to, for an export too slow to
109
+ // wait on. Read off the live location rather than built from a
110
+ // configured host: a panel may be mounted under a path (`/sellers`,
111
+ // `/dashboard`) as easily as on a host of its own, and only the page
112
+ // itself knows which. The store's allowed origins decide whether the
113
+ // server trusts it.
114
+ results_url: window.location.href,
62
115
  })
63
116
  setOpen(false)
64
117
  }
65
118
 
66
119
  return (
67
120
  <>
121
+ {/* Desktop only: picking a dataset, waiting on the job and opening
122
+ the file is desk work, and the button is one of the things that
123
+ pushed the toolbar onto a third row on a phone. */}
68
124
  <Button
125
+ type="button"
69
126
  size="sm"
70
127
  variant="outline"
71
- className="h-[2.125rem]"
128
+ className="hidden h-[2.125rem] lg:inline-flex"
72
129
  onClick={() => setOpen(true)}
73
130
  disabled={exportMutation.isPending}
74
131
  >