@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.
- package/LICENSE +21 -0
- package/README.md +418 -0
- package/package.json +84 -0
- package/src/client.ts +25 -0
- package/src/components/address-form-dialog.tsx +376 -0
- package/src/components/app-sidebar.tsx +70 -0
- package/src/components/bulk-action-bar.tsx +504 -0
- package/src/components/can.tsx +30 -0
- package/src/components/country-combobox.tsx +238 -0
- package/src/components/country-state-fields.tsx +86 -0
- package/src/components/currency-select.tsx +181 -0
- package/src/components/export-button.tsx +156 -0
- package/src/components/file-upload-field.tsx +265 -0
- package/src/components/image-upload-field.tsx +53 -0
- package/src/components/import-button.tsx +202 -0
- package/src/components/locale-select.tsx +268 -0
- package/src/components/market-combobox.tsx +89 -0
- package/src/components/nav-main.tsx +186 -0
- package/src/components/page-header.tsx +286 -0
- package/src/components/page-tabs.tsx +78 -0
- package/src/components/preferences-form.tsx +404 -0
- package/src/components/resource-combobox.tsx +126 -0
- package/src/components/resource-multi-autocomplete.tsx +176 -0
- package/src/components/resource-picker-sheet.tsx +235 -0
- package/src/components/resource-table.tsx +652 -0
- package/src/components/settings-sidebar.tsx +139 -0
- package/src/components/slot.tsx +50 -0
- package/src/components/store-date-picker.tsx +18 -0
- package/src/components/store-switcher.tsx +70 -0
- package/src/components/table-toolbar.tsx +818 -0
- package/src/components/tag-combobox.tsx +99 -0
- package/src/components/top-bar.tsx +210 -0
- package/src/hooks/use-auth.ts +10 -0
- package/src/hooks/use-command-palette.tsx +34 -0
- package/src/hooks/use-countries.ts +15 -0
- package/src/hooks/use-custom-fields.ts +142 -0
- package/src/hooks/use-direct-upload.ts +104 -0
- package/src/hooks/use-display-name.ts +42 -0
- package/src/hooks/use-export.ts +75 -0
- package/src/hooks/use-global-search.ts +79 -0
- package/src/hooks/use-host-form.ts +34 -0
- package/src/hooks/use-import.ts +62 -0
- package/src/hooks/use-resource-mutation.ts +99 -0
- package/src/hooks/use-switch-admin-locale.ts +55 -0
- package/src/index.ts +97 -0
- package/src/lib/custom-field-components.ts +56 -0
- package/src/lib/download.ts +49 -0
- package/src/lib/filters-to-ransack.ts +47 -0
- package/src/lib/form-errors.ts +103 -0
- package/src/lib/form-fields-registry.ts +102 -0
- package/src/lib/form-mappers.ts +30 -0
- package/src/lib/formatters.ts +19 -0
- package/src/lib/i18n.ts +210 -0
- package/src/lib/nav-registry.ts +224 -0
- package/src/lib/permissions.ts +55 -0
- package/src/lib/query-client.ts +11 -0
- package/src/lib/query-keys.ts +69 -0
- package/src/lib/route-registry.ts +158 -0
- package/src/lib/search-registry.ts +129 -0
- package/src/lib/settings-nav-registry.ts +158 -0
- package/src/lib/slot-registry.ts +116 -0
- package/src/lib/table-registry.ts +274 -0
- package/src/lib/timestamp-columns.tsx +58 -0
- package/src/locales/ar.json +629 -0
- package/src/locales/de.json +629 -0
- package/src/locales/en.json +629 -0
- package/src/locales/fr.json +629 -0
- package/src/locales/pl.json +629 -0
- package/src/locales/zh-CN.json +629 -0
- package/src/plugin.ts +222 -0
- package/src/providers/auth-provider.tsx +188 -0
- package/src/providers/permission-provider.tsx +118 -0
- package/src/providers/store-provider.tsx +111 -0
- package/src/virtual.d.ts +5 -0
- package/src/vite/discover.ts +170 -0
- 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
|
+
}
|