@spree/dashboard-core 0.1.0

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 (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +418 -0
  3. package/package.json +84 -0
  4. package/src/client.ts +25 -0
  5. package/src/components/address-form-dialog.tsx +376 -0
  6. package/src/components/app-sidebar.tsx +70 -0
  7. package/src/components/bulk-action-bar.tsx +504 -0
  8. package/src/components/can.tsx +30 -0
  9. package/src/components/country-combobox.tsx +238 -0
  10. package/src/components/country-state-fields.tsx +86 -0
  11. package/src/components/currency-select.tsx +181 -0
  12. package/src/components/export-button.tsx +156 -0
  13. package/src/components/file-upload-field.tsx +265 -0
  14. package/src/components/image-upload-field.tsx +53 -0
  15. package/src/components/import-button.tsx +202 -0
  16. package/src/components/locale-select.tsx +268 -0
  17. package/src/components/market-combobox.tsx +89 -0
  18. package/src/components/nav-main.tsx +186 -0
  19. package/src/components/page-header.tsx +286 -0
  20. package/src/components/page-tabs.tsx +78 -0
  21. package/src/components/preferences-form.tsx +404 -0
  22. package/src/components/resource-combobox.tsx +126 -0
  23. package/src/components/resource-multi-autocomplete.tsx +176 -0
  24. package/src/components/resource-picker-sheet.tsx +235 -0
  25. package/src/components/resource-table.tsx +652 -0
  26. package/src/components/settings-sidebar.tsx +139 -0
  27. package/src/components/slot.tsx +50 -0
  28. package/src/components/store-date-picker.tsx +18 -0
  29. package/src/components/store-switcher.tsx +70 -0
  30. package/src/components/table-toolbar.tsx +818 -0
  31. package/src/components/tag-combobox.tsx +99 -0
  32. package/src/components/top-bar.tsx +210 -0
  33. package/src/hooks/use-auth.ts +10 -0
  34. package/src/hooks/use-command-palette.tsx +34 -0
  35. package/src/hooks/use-countries.ts +15 -0
  36. package/src/hooks/use-custom-fields.ts +142 -0
  37. package/src/hooks/use-direct-upload.ts +104 -0
  38. package/src/hooks/use-display-name.ts +42 -0
  39. package/src/hooks/use-export.ts +75 -0
  40. package/src/hooks/use-global-search.ts +79 -0
  41. package/src/hooks/use-host-form.ts +34 -0
  42. package/src/hooks/use-import.ts +62 -0
  43. package/src/hooks/use-resource-mutation.ts +99 -0
  44. package/src/hooks/use-switch-admin-locale.ts +55 -0
  45. package/src/index.ts +97 -0
  46. package/src/lib/custom-field-components.ts +56 -0
  47. package/src/lib/download.ts +49 -0
  48. package/src/lib/filters-to-ransack.ts +47 -0
  49. package/src/lib/form-errors.ts +103 -0
  50. package/src/lib/form-fields-registry.ts +102 -0
  51. package/src/lib/form-mappers.ts +30 -0
  52. package/src/lib/formatters.ts +19 -0
  53. package/src/lib/i18n.ts +210 -0
  54. package/src/lib/nav-registry.ts +224 -0
  55. package/src/lib/permissions.ts +55 -0
  56. package/src/lib/query-client.ts +11 -0
  57. package/src/lib/query-keys.ts +69 -0
  58. package/src/lib/route-registry.ts +158 -0
  59. package/src/lib/search-registry.ts +129 -0
  60. package/src/lib/settings-nav-registry.ts +158 -0
  61. package/src/lib/slot-registry.ts +116 -0
  62. package/src/lib/table-registry.ts +274 -0
  63. package/src/lib/timestamp-columns.tsx +58 -0
  64. package/src/locales/ar.json +629 -0
  65. package/src/locales/de.json +629 -0
  66. package/src/locales/en.json +629 -0
  67. package/src/locales/fr.json +629 -0
  68. package/src/locales/pl.json +629 -0
  69. package/src/locales/zh-CN.json +629 -0
  70. package/src/plugin.ts +222 -0
  71. package/src/providers/auth-provider.tsx +188 -0
  72. package/src/providers/permission-provider.tsx +118 -0
  73. package/src/providers/store-provider.tsx +111 -0
  74. package/src/virtual.d.ts +5 -0
  75. package/src/vite/discover.ts +170 -0
  76. package/src/vite/index.ts +270 -0
@@ -0,0 +1,238 @@
1
+ import {
2
+ Combobox,
3
+ ComboboxChip,
4
+ ComboboxChips,
5
+ ComboboxChipsInput,
6
+ ComboboxContent,
7
+ ComboboxEmpty,
8
+ ComboboxInput,
9
+ ComboboxItem,
10
+ ComboboxList,
11
+ ComboboxValue,
12
+ CountryFlag,
13
+ InputGroupAddon,
14
+ useComboboxAnchor,
15
+ } from '@spree/dashboard-ui'
16
+ import { useMemo, useState } from 'react'
17
+ import { useTranslation } from 'react-i18next'
18
+ import { useCountries } from '../hooks/use-countries'
19
+ import { useDisplayName } from '../hooks/use-display-name'
20
+
21
+ export type CountryOption = { iso: string; iso3: string; name: string }
22
+
23
+ /**
24
+ * Resolves a country's display name in the admin UI language (keyed on the
25
+ * stable ISO code), falling back to the API's backend-locale `name` when the
26
+ * runtime lacks coverage.
27
+ */
28
+ function useCountryDisplayName() {
29
+ const displayName = useDisplayName('region')
30
+ return (country: CountryOption) => displayName(country.iso) ?? country.name
31
+ }
32
+
33
+ /**
34
+ * Case-insensitive substring match against name, iso, and iso3 so the user
35
+ * can find a country by typing "United States", "US", or "USA". Both pickers
36
+ * filter the cached `useCountries()` list client-side — countries are a
37
+ * ~250-row static set, so there's no remote search. Mirrors the server-side
38
+ * `search` Ransack scope (`Spree::Country.search`).
39
+ */
40
+ export function countryFilter(item: CountryOption, query: string): boolean {
41
+ const q = query.trim().toLowerCase()
42
+ if (!q) return true
43
+ return (
44
+ item.name.toLowerCase().includes(q) ||
45
+ item.iso.toLowerCase().includes(q) ||
46
+ item.iso3.toLowerCase().includes(q)
47
+ )
48
+ }
49
+
50
+ /**
51
+ * Filter that also matches the LOCALIZED country name shown to the user (not
52
+ * just the raw backend `name` / ISO codes), so typing a translated label finds
53
+ * its country. Memoized on the display-name resolver.
54
+ */
55
+ function useCountryFilter() {
56
+ const countryName = useCountryDisplayName()
57
+ return useMemo(
58
+ () => (item: CountryOption, query: string) => {
59
+ const q = query.trim().toLowerCase()
60
+ if (!q) return true
61
+ return countryName(item).toLowerCase().includes(q) || countryFilter(item, query)
62
+ },
63
+ [countryName],
64
+ )
65
+ }
66
+
67
+ /**
68
+ * Shared dropdown row used by both pickers — flag + localized name + ISO code
69
+ * in a muted suffix. The ISO code makes ISO-based matches (typing "US"/"USA")
70
+ * comprehensible and keeps the two pickers visually consistent.
71
+ */
72
+ function CountryRow({ country, label }: { country: CountryOption; label: string }) {
73
+ return (
74
+ <span className="flex items-center gap-3">
75
+ <CountryFlag iso={country.iso} />
76
+ <span>{label}</span>
77
+ {country.iso && <span className="text-xs text-muted-foreground">({country.iso})</span>}
78
+ </span>
79
+ )
80
+ }
81
+
82
+ /**
83
+ * 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.
86
+ */
87
+ export function CountryCombobox({
88
+ value,
89
+ onValueChange,
90
+ placeholder,
91
+ disabled = false,
92
+ }: {
93
+ value: string | null | undefined
94
+ onValueChange: (iso: string) => void
95
+ placeholder?: string
96
+ disabled?: boolean
97
+ }) {
98
+ const { t } = useTranslation()
99
+ const { countries } = useCountries()
100
+ const countryName = useCountryDisplayName()
101
+ const filter = useCountryFilter()
102
+
103
+ const items: CountryOption[] = useMemo(
104
+ () => countries.map((c) => ({ iso: c.iso, iso3: c.iso3, name: c.name })),
105
+ [countries],
106
+ )
107
+
108
+ // The Combobox holds the selected object internally; we adapt to a flat ISO
109
+ // string at the boundary so callers don't have to thread the option shape.
110
+ const selected = useMemo(() => items.find((c) => c.iso === value) ?? null, [items, value])
111
+
112
+ return (
113
+ <Combobox
114
+ items={items}
115
+ value={selected}
116
+ onValueChange={(c: CountryOption | null) => onValueChange(c?.iso ?? '')}
117
+ itemToStringLabel={(c: CountryOption | null) => (c ? countryName(c) : '')}
118
+ itemToStringValue={(c: CountryOption | null) => c?.iso ?? ''}
119
+ disabled={disabled}
120
+ // Matches the localized name, the raw name, and both ISO codes so typing
121
+ // "Allemagne", "Germany", "DE", or "DEU" all find the country.
122
+ filter={filter}
123
+ >
124
+ <ComboboxInput
125
+ placeholder={placeholder ?? t('admin.components.country_combobox.search_placeholder')}
126
+ disabled={disabled}
127
+ >
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">
134
+ <CountryFlag iso={selected.iso} />
135
+ </InputGroupAddon>
136
+ )}
137
+ </ComboboxInput>
138
+ <ComboboxContent>
139
+ <ComboboxEmpty>{t('admin.components.country_combobox.empty')}</ComboboxEmpty>
140
+ <ComboboxList>
141
+ {(country: CountryOption) => (
142
+ <ComboboxItem key={country.iso} value={country}>
143
+ <CountryRow country={country} label={countryName(country)} />
144
+ </ComboboxItem>
145
+ )}
146
+ </ComboboxList>
147
+ </ComboboxContent>
148
+ </Combobox>
149
+ )
150
+ }
151
+
152
+ /**
153
+ * Searchable multi-select country picker. Value is an array of 2-letter ISO
154
+ * 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
157
+ * countries (promotion Country rule, market/zone editors, …).
158
+ */
159
+ export function CountryMultiCombobox({
160
+ value,
161
+ onValueChange,
162
+ placeholder,
163
+ emptyText,
164
+ disabled = false,
165
+ }: {
166
+ /** Selected 2-letter ISO codes. */
167
+ value: string[]
168
+ /** Fires with the next array of ISO codes. */
169
+ onValueChange: (isos: string[]) => void
170
+ placeholder?: string
171
+ emptyText?: string
172
+ disabled?: boolean
173
+ }) {
174
+ const { t } = useTranslation()
175
+ const { countries } = useCountries()
176
+ const countryName = useCountryDisplayName()
177
+ const filter = useCountryFilter()
178
+ const anchorRef = useComboboxAnchor()
179
+ const [inputValue, setInputValue] = useState('')
180
+
181
+ const items: CountryOption[] = useMemo(
182
+ () => countries.map((c) => ({ iso: c.iso, iso3: c.iso3, name: c.name })),
183
+ [countries],
184
+ )
185
+
186
+ // The Combobox holds full option objects; selection round-trips as ISO
187
+ // strings. Selected ISOs not yet present in `items` (list still loading)
188
+ // fall back to a stub so chips don't vanish on first render.
189
+ const selected = useMemo<CountryOption[]>(
190
+ () => value.map((iso) => items.find((c) => c.iso === iso) ?? { iso, iso3: '', name: iso }),
191
+ [value, items],
192
+ )
193
+
194
+ return (
195
+ <Combobox
196
+ multiple
197
+ items={items}
198
+ value={selected}
199
+ onValueChange={(next: CountryOption[]) => {
200
+ onValueChange(next.map((c) => c.iso))
201
+ setInputValue('')
202
+ }}
203
+ itemToStringLabel={(c: CountryOption | null) => (c ? countryName(c) : '')}
204
+ itemToStringValue={(c: CountryOption | null) => c?.iso ?? ''}
205
+ isItemEqualToValue={(a: CountryOption, b: CountryOption) => a.iso === b.iso}
206
+ filter={filter}
207
+ disabled={disabled}
208
+ >
209
+ <ComboboxChips ref={anchorRef}>
210
+ <ComboboxValue>
211
+ {(selectedCountries: CountryOption[]) =>
212
+ selectedCountries.map((c) => (
213
+ <ComboboxChip key={c.iso}>
214
+ {c.iso && <CountryFlag iso={c.iso} className="mr-2" />}
215
+ {countryName(c)}
216
+ </ComboboxChip>
217
+ ))
218
+ }
219
+ </ComboboxValue>
220
+ <ComboboxChipsInput
221
+ placeholder={placeholder ?? t('admin.promotions.rules.country.search_placeholder')}
222
+ value={inputValue}
223
+ onChange={(e) => setInputValue((e.target as HTMLInputElement).value)}
224
+ />
225
+ </ComboboxChips>
226
+ <ComboboxContent anchor={anchorRef}>
227
+ <ComboboxEmpty>{emptyText ?? t('admin.promotions.rules.country.empty')}</ComboboxEmpty>
228
+ <ComboboxList>
229
+ {(country: CountryOption) => (
230
+ <ComboboxItem key={country.iso} value={country}>
231
+ <CountryRow country={country} label={countryName(country)} />
232
+ </ComboboxItem>
233
+ )}
234
+ </ComboboxList>
235
+ </ComboboxContent>
236
+ </Combobox>
237
+ )
238
+ }
@@ -0,0 +1,86 @@
1
+ import type { State } from '@spree/admin-sdk'
2
+ import {
3
+ Combobox,
4
+ ComboboxContent,
5
+ ComboboxEmpty,
6
+ ComboboxInput,
7
+ ComboboxItem,
8
+ ComboboxList,
9
+ } from '@spree/dashboard-ui'
10
+ import { useMemo } from 'react'
11
+ import { useTranslation } from 'react-i18next'
12
+ import { useCountries } from '../hooks/use-countries'
13
+
14
+ type StateOption = { abbr: string; name: string }
15
+
16
+ /**
17
+ * Country/state lookup for the active country selection. `states` is empty
18
+ * when the country doesn't enumerate them (free-text region); `statesRequired`
19
+ * tells callers whether to fall back to a plain text input.
20
+ */
21
+ export function useCountryStates(countryIso: string | null | undefined) {
22
+ const { countries } = useCountries()
23
+ const country = useMemo(
24
+ () => countries.find((c) => c.iso === countryIso) ?? null,
25
+ [countries, countryIso],
26
+ )
27
+ return {
28
+ states: ((country?.states ?? []) as StateOption[]).filter((s) => Boolean(s.abbr)),
29
+ statesRequired: country?.states_required ?? false,
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Searchable state/province picker for a given country. Value is the state
35
+ * abbreviation (e.g. "CA"). Callers should hide this and render a free-text
36
+ * Input when `useCountryStates(...).states` is empty.
37
+ *
38
+ * Keyed on `countryIso` so the internal state is reset when the country
39
+ * changes — prevents a stale highlight from a previous country.
40
+ */
41
+ export function StateCombobox({
42
+ countryIso,
43
+ states,
44
+ value,
45
+ onValueChange,
46
+ placeholder,
47
+ disabled = false,
48
+ }: {
49
+ countryIso: string | null | undefined
50
+ /** State list for the active country (typically from `useCountryStates`). */
51
+ states: Pick<State, 'abbr' | 'name'>[]
52
+ value: string | null | undefined
53
+ onValueChange: (abbr: string) => void
54
+ placeholder?: string
55
+ disabled?: boolean
56
+ }) {
57
+ const { t } = useTranslation()
58
+ const items = states as StateOption[]
59
+ const selected = useMemo(() => items.find((s) => s.abbr === value) ?? null, [items, value])
60
+
61
+ return (
62
+ <Combobox
63
+ key={countryIso ?? 'no-country'}
64
+ items={items}
65
+ value={selected}
66
+ onValueChange={(s: StateOption | null) => onValueChange(s?.abbr ?? '')}
67
+ itemToStringLabel={(s: StateOption | null) => s?.name ?? ''}
68
+ itemToStringValue={(s: StateOption | null) => s?.abbr ?? ''}
69
+ >
70
+ <ComboboxInput
71
+ placeholder={placeholder ?? t('admin.components.state_combobox.search_placeholder')}
72
+ disabled={disabled}
73
+ />
74
+ <ComboboxContent>
75
+ <ComboboxEmpty>{t('admin.components.state_combobox.empty')}</ComboboxEmpty>
76
+ <ComboboxList>
77
+ {(state: StateOption) => (
78
+ <ComboboxItem key={state.abbr} value={state}>
79
+ {state.name}
80
+ </ComboboxItem>
81
+ )}
82
+ </ComboboxList>
83
+ </ComboboxContent>
84
+ </Combobox>
85
+ )
86
+ }
@@ -0,0 +1,181 @@
1
+ import {
2
+ Combobox,
3
+ ComboboxContent,
4
+ ComboboxEmpty,
5
+ ComboboxInput,
6
+ ComboboxItem,
7
+ ComboboxList,
8
+ Select,
9
+ SelectContent,
10
+ SelectItem,
11
+ SelectTrigger,
12
+ SelectValue,
13
+ } from '@spree/dashboard-ui'
14
+ import { useMemo, useState } from 'react'
15
+ import { useTranslation } from 'react-i18next'
16
+ import { useDisplayName } from '../hooks/use-display-name'
17
+ import { useStore } from '../providers/store-provider'
18
+
19
+ /**
20
+ * Every ISO 4217 currency code the runtime knows about — the active-currency
21
+ * counterpart to the Rails admin's `Money::Currency.table` list. Used for
22
+ * contexts where the merchant can pick *any* currency (a market's currency)
23
+ * rather than one the store already supports. Degrades to an empty list on
24
+ * runtimes without `Intl.supportedValuesOf`, so callers fall back to
25
+ * `supported_currencies`.
26
+ */
27
+ export const ALL_CURRENCY_CODES: string[] = (() => {
28
+ try {
29
+ return Intl.supportedValuesOf('currency')
30
+ } catch {
31
+ return []
32
+ }
33
+ })()
34
+
35
+ interface CurrencySelectProps {
36
+ /** ID for the trigger — paired with the parent `<FieldLabel htmlFor>`. */
37
+ id?: string
38
+ /** Submit name — a hidden input mirrors the value so plain `FormData` works. */
39
+ name?: string
40
+ /** Initial selection. Falls back to the store's default currency. */
41
+ defaultValue?: string
42
+ /** Controlled value. Pair with `onChange` to lift state out. */
43
+ value?: string
44
+ /** Fires on every selection change. */
45
+ onChange?: (currency: string) => void
46
+ /**
47
+ * Currency codes to pick from. Defaults to the current store's
48
+ * `supported_currencies`. Pass `ALL_CURRENCY_CODES` (or any custom list) for
49
+ * 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.
52
+ */
53
+ options?: string[]
54
+ /** Marks the field required for screen readers + native form submission. */
55
+ required?: boolean
56
+ disabled?: boolean
57
+ }
58
+
59
+ /** Above this many options a plain `<Select>` is unwieldy — switch to search. */
60
+ const SEARCHABLE_THRESHOLD = 12
61
+
62
+ /**
63
+ * Picker for a currency code. Defaults to the current store's
64
+ * `supported_currencies` (and the store's `default_currency`), so callers
65
+ * choosing among already-configured currencies don't have to wire anything up.
66
+ * Pass `options` (e.g. `ALL_CURRENCY_CODES`) when the merchant may pick any
67
+ * currency, such as a market's currency. Each option reads `CODE — Full Name`
68
+ * (e.g. `USD — US Dollar`), with the name localized to the admin UI language.
69
+ * Long option lists render a searchable combobox.
70
+ */
71
+ export function CurrencySelect({
72
+ id,
73
+ name,
74
+ defaultValue,
75
+ value: controlledValue,
76
+ onChange,
77
+ options,
78
+ required,
79
+ disabled,
80
+ }: CurrencySelectProps) {
81
+ const { t } = useTranslation()
82
+ const { currencies, defaultCurrency } = useStore()
83
+ const [internalValue, setInternalValue] = useState(defaultValue ?? defaultCurrency)
84
+ const isControlled = controlledValue !== undefined
85
+ // Controlled callers that pass an empty value still see the store default
86
+ // in the dropdown. Derive it here without emitting onChange — committing the
87
+ // fallback during render dirties forms and re-triggers effects. The caller
88
+ // gets the real value the first time the merchant interacts.
89
+ const value = isControlled ? controlledValue || defaultCurrency : internalValue
90
+ const displayNameFor = useDisplayName('currency')
91
+
92
+ // Union of the option list and the current value so editing a record whose
93
+ // currency isn't in the list (a store-supported list that later narrowed)
94
+ // never silently drops the selection. An empty `options` (e.g. a runtime
95
+ // without `Intl.supportedValuesOf` yields an empty `ALL_CURRENCY_CODES`)
96
+ // falls back to the store's currencies rather than an empty picker.
97
+ const items = useMemo(() => {
98
+ const base = options?.length ? options : currencies
99
+ return value && !base.includes(value) ? [value, ...base] : base
100
+ }, [options, currencies, value])
101
+
102
+ const handleChange = (next: string) => {
103
+ if (!isControlled) setInternalValue(next)
104
+ onChange?.(next)
105
+ }
106
+
107
+ const renderOption = (code: string) => {
108
+ const currencyName = displayNameFor(code)
109
+ // Avoid `USD — USD` when the resolver falls back to the code itself.
110
+ return currencyName && currencyName !== code ? `${code} — ${currencyName}` : code
111
+ }
112
+
113
+ // Case-insensitive match against the code and its localized name so typing
114
+ // "EUR" or "Euro" both find it.
115
+ const filter = (code: string, query: string) => {
116
+ const q = query.trim().toLowerCase()
117
+ if (!q) return true
118
+ return code.toLowerCase().includes(q) || renderOption(code).toLowerCase().includes(q)
119
+ }
120
+
121
+ const hiddenInput = name ? <input type="hidden" name={name} value={value} /> : null
122
+
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
+ return (
158
+ <>
159
+ {/* Hidden input keeps the parent `<form>` submit / FormData path working
160
+ without each caller having to thread the value through state. */}
161
+ {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>
179
+ </>
180
+ )
181
+ }
@@ -0,0 +1,156 @@
1
+ import type { ExportType } from '@spree/admin-sdk'
2
+ import {
3
+ Button,
4
+ Dialog,
5
+ DialogBody,
6
+ DialogContent,
7
+ DialogDescription,
8
+ DialogFooter,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ Field,
12
+ FieldContent,
13
+ FieldDescription,
14
+ FieldLabel,
15
+ FieldTitle,
16
+ RadioGroup,
17
+ RadioGroupItem,
18
+ } from '@spree/dashboard-ui'
19
+ import type { TFunction } from 'i18next'
20
+ import { DownloadIcon, FilterIcon, GlobeIcon } from 'lucide-react'
21
+ import { useState } from 'react'
22
+ import { useTranslation } from 'react-i18next'
23
+ import { useExport } from '../hooks/use-export'
24
+ import { filtersToRansack } from '../lib/filters-to-ransack'
25
+ import type { ResourceActionsContext } from './resource-table'
26
+
27
+ interface ExportButtonProps extends ResourceActionsContext {
28
+ /** Which dataset to export. Server validates against `Spree::Export.available_types`. */
29
+ type: ExportType
30
+ /** Label shown on the button. Defaults to the translated "Export" action. */
31
+ label?: string
32
+ }
33
+
34
+ type Selection = 'filtered' | 'all'
35
+
36
+ export function ExportButton({
37
+ type,
38
+ label,
39
+ filters,
40
+ search,
41
+ searchParam,
42
+ columns,
43
+ totalCount,
44
+ }: ExportButtonProps) {
45
+ const { t } = useTranslation()
46
+ const [open, setOpen] = useState(false)
47
+ const [selection, setSelection] = useState<Selection>('filtered')
48
+ const exportMutation = useExport()
49
+
50
+ const hasActiveFilter = filters.length > 0 || search.trim().length > 0
51
+
52
+ function handleExport() {
53
+ const search_params: Record<string, unknown> = filtersToRansack(filters, columns)
54
+ if (search.trim()) {
55
+ search_params[searchParam] = search.trim()
56
+ }
57
+
58
+ exportMutation.mutate({
59
+ type,
60
+ record_selection: selection,
61
+ search_params: selection === 'filtered' ? search_params : undefined,
62
+ })
63
+ setOpen(false)
64
+ }
65
+
66
+ return (
67
+ <>
68
+ <Button
69
+ size="sm"
70
+ variant="outline"
71
+ className="h-[2.125rem]"
72
+ onClick={() => setOpen(true)}
73
+ disabled={exportMutation.isPending}
74
+ >
75
+ <DownloadIcon className="size-4" />
76
+ {label ?? t('admin.actions.export')}
77
+ </Button>
78
+
79
+ <Dialog open={open} onOpenChange={setOpen}>
80
+ <DialogContent>
81
+ <DialogHeader>
82
+ <DialogTitle>{t('admin.components.export_button.title')}</DialogTitle>
83
+ <DialogDescription>{t('admin.components.export_button.description')}</DialogDescription>
84
+ </DialogHeader>
85
+
86
+ <DialogBody>
87
+ <RadioGroup
88
+ value={selection}
89
+ onValueChange={(value) => setSelection(value as Selection)}
90
+ >
91
+ <ChoiceCard
92
+ value="filtered"
93
+ icon={<FilterIcon className="size-4" />}
94
+ title={t('admin.components.export_button.filtered.title')}
95
+ description={describeFiltered(t, hasActiveFilter, totalCount)}
96
+ />
97
+ <ChoiceCard
98
+ value="all"
99
+ icon={<GlobeIcon className="size-4" />}
100
+ title={t('admin.components.export_button.all.title')}
101
+ description={t('admin.components.export_button.all.description')}
102
+ />
103
+ </RadioGroup>
104
+ </DialogBody>
105
+
106
+ <DialogFooter>
107
+ <Button variant="outline" onClick={() => setOpen(false)}>
108
+ {t('admin.actions.cancel')}
109
+ </Button>
110
+ <Button onClick={handleExport} disabled={exportMutation.isPending}>
111
+ {exportMutation.isPending ? t('admin.actions.exporting') : t('admin.actions.export')}
112
+ </Button>
113
+ </DialogFooter>
114
+ </DialogContent>
115
+ </Dialog>
116
+ </>
117
+ )
118
+ }
119
+
120
+ function describeFiltered(
121
+ t: TFunction,
122
+ hasActiveFilter: boolean,
123
+ totalCount: number | undefined,
124
+ ): string {
125
+ if (!hasActiveFilter) return t('admin.components.export_button.filtered.no_filter')
126
+ if (totalCount === undefined) return t('admin.components.export_button.filtered.unknown_count')
127
+ return t('admin.components.export_button.filtered.count', {
128
+ count: totalCount,
129
+ formattedCount: totalCount.toLocaleString(),
130
+ })
131
+ }
132
+
133
+ function ChoiceCard({
134
+ value,
135
+ icon,
136
+ title,
137
+ description,
138
+ }: {
139
+ value: string
140
+ icon?: React.ReactNode
141
+ title: string
142
+ description: string
143
+ }) {
144
+ return (
145
+ <FieldLabel>
146
+ <Field orientation="horizontal">
147
+ {icon && <span className="mt-0.5 text-muted-foreground">{icon}</span>}
148
+ <FieldContent>
149
+ <FieldTitle>{title}</FieldTitle>
150
+ <FieldDescription>{description}</FieldDescription>
151
+ </FieldContent>
152
+ <RadioGroupItem value={value} />
153
+ </Field>
154
+ </FieldLabel>
155
+ )
156
+ }