@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,235 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Avatar,
|
|
3
|
+
AvatarFallback,
|
|
4
|
+
AvatarImage,
|
|
5
|
+
Button,
|
|
6
|
+
Checkbox,
|
|
7
|
+
InputGroup,
|
|
8
|
+
InputGroupAddon,
|
|
9
|
+
InputGroupInput,
|
|
10
|
+
Sheet,
|
|
11
|
+
SheetContent,
|
|
12
|
+
SheetDescription,
|
|
13
|
+
SheetFooter,
|
|
14
|
+
SheetHeader,
|
|
15
|
+
SheetTitle,
|
|
16
|
+
} from '@spree/dashboard-ui'
|
|
17
|
+
import { useQuery } from '@tanstack/react-query'
|
|
18
|
+
import { ImageIcon, Loader2Icon, SearchIcon, XIcon } from 'lucide-react'
|
|
19
|
+
import { useDeferredValue, useMemo, useState } from 'react'
|
|
20
|
+
import { useTranslation } from 'react-i18next'
|
|
21
|
+
|
|
22
|
+
export interface PickerOption {
|
|
23
|
+
id: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ResourcePickerSheetProps<T extends PickerOption> {
|
|
27
|
+
open: boolean
|
|
28
|
+
onOpenChange: (open: boolean) => void
|
|
29
|
+
/** IDs already chosen (e.g. products already in the category). Pre-checked & not re-addable. */
|
|
30
|
+
selectedIds: string[]
|
|
31
|
+
/** Called with the freshly-picked IDs (excludes `selectedIds`) when the user confirms. */
|
|
32
|
+
onConfirm: (ids: string[], options: T[]) => void | Promise<void>
|
|
33
|
+
/** Paginated async search. Called with the trimmed query (empty = initial list). */
|
|
34
|
+
search: (query: string) => Promise<{ data: T[] }>
|
|
35
|
+
getOptionLabel: (option: T) => string
|
|
36
|
+
/** Optional thumbnail URL shown as an avatar. */
|
|
37
|
+
getOptionImageUrl?: (option: T) => string | null | undefined
|
|
38
|
+
/** Optional secondary line under the label. */
|
|
39
|
+
getOptionSubtitle?: (option: T) => string | null | undefined
|
|
40
|
+
/** Cache-isolation key (one per picker instance / resource type). */
|
|
41
|
+
queryKey: string
|
|
42
|
+
title?: string
|
|
43
|
+
description?: string
|
|
44
|
+
searchPlaceholder?: string
|
|
45
|
+
confirmLabel?: string
|
|
46
|
+
/** Called if `onConfirm` rejects. The sheet stays open with the staged selection. */
|
|
47
|
+
onConfirmError?: (error: unknown) => void
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Universal multi-select resource picker rendered in a Sheet. Designed to be
|
|
52
|
+
* reused across products-in-category, products-in-collection, products-in-
|
|
53
|
+
* price-list, etc. — pass `search`/`getOptionLabel` for the resource.
|
|
54
|
+
*
|
|
55
|
+
* Why a sheet (not an inline combobox): with hundreds of rows an inline
|
|
56
|
+
* autocomplete is unusable and re-renders the host form on every keystroke.
|
|
57
|
+
* Here the search list loads async inside the sheet, the staging selection is
|
|
58
|
+
* local, and the host form only hears about the result once, on confirm.
|
|
59
|
+
*/
|
|
60
|
+
export function ResourcePickerSheet<T extends PickerOption>({
|
|
61
|
+
open,
|
|
62
|
+
onOpenChange,
|
|
63
|
+
selectedIds,
|
|
64
|
+
onConfirm,
|
|
65
|
+
search,
|
|
66
|
+
getOptionLabel,
|
|
67
|
+
getOptionImageUrl,
|
|
68
|
+
getOptionSubtitle,
|
|
69
|
+
queryKey,
|
|
70
|
+
title,
|
|
71
|
+
description,
|
|
72
|
+
searchPlaceholder,
|
|
73
|
+
confirmLabel,
|
|
74
|
+
onConfirmError,
|
|
75
|
+
}: ResourcePickerSheetProps<T>) {
|
|
76
|
+
const { t } = useTranslation()
|
|
77
|
+
|
|
78
|
+
const [input, setInput] = useState('')
|
|
79
|
+
const deferredInput = useDeferredValue(input)
|
|
80
|
+
const trimmedQuery = deferredInput.trim()
|
|
81
|
+
|
|
82
|
+
// Staging selection (ids picked in this session) + a label cache so the
|
|
83
|
+
// "selected" chips keep their text even after the search results change.
|
|
84
|
+
const [staged, setStaged] = useState<Map<string, T>>(new Map())
|
|
85
|
+
const alreadyIn = useMemo(() => new Set(selectedIds), [selectedIds])
|
|
86
|
+
const [submitting, setSubmitting] = useState(false)
|
|
87
|
+
|
|
88
|
+
const { data, isFetching } = useQuery({
|
|
89
|
+
queryKey: [queryKey, 'picker-search', trimmedQuery],
|
|
90
|
+
queryFn: () => search(trimmedQuery),
|
|
91
|
+
enabled: open,
|
|
92
|
+
staleTime: 30_000,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const results = data?.data ?? []
|
|
96
|
+
|
|
97
|
+
function toggle(option: T) {
|
|
98
|
+
if (alreadyIn.has(option.id)) return
|
|
99
|
+
setStaged((prev) => {
|
|
100
|
+
const next = new Map(prev)
|
|
101
|
+
if (next.has(option.id)) next.delete(option.id)
|
|
102
|
+
else next.set(option.id, option)
|
|
103
|
+
return next
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function confirm() {
|
|
108
|
+
if (staged.size === 0) return
|
|
109
|
+
setSubmitting(true)
|
|
110
|
+
try {
|
|
111
|
+
await onConfirm(Array.from(staged.keys()), Array.from(staged.values()))
|
|
112
|
+
setStaged(new Map())
|
|
113
|
+
setInput('')
|
|
114
|
+
onOpenChange(false)
|
|
115
|
+
} catch (error) {
|
|
116
|
+
// The mutation reports its own error toast; swallow so the click handler
|
|
117
|
+
// doesn't reject, and keep the sheet open with the staged selection so the
|
|
118
|
+
// user can retry. Callers can observe failures via onConfirmError.
|
|
119
|
+
onConfirmError?.(error)
|
|
120
|
+
} finally {
|
|
121
|
+
setSubmitting(false)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function handleOpenChange(next: boolean) {
|
|
126
|
+
if (!next) {
|
|
127
|
+
setStaged(new Map())
|
|
128
|
+
setInput('')
|
|
129
|
+
}
|
|
130
|
+
onOpenChange(next)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<Sheet open={open} onOpenChange={handleOpenChange}>
|
|
135
|
+
<SheetContent className="w-full gap-0 p-0 sm:max-w-lg">
|
|
136
|
+
<SheetHeader>
|
|
137
|
+
<SheetTitle>{title ?? t('admin.resource_picker.title')}</SheetTitle>
|
|
138
|
+
{description && <SheetDescription>{description}</SheetDescription>}
|
|
139
|
+
</SheetHeader>
|
|
140
|
+
|
|
141
|
+
<div className="border-b border-border p-4">
|
|
142
|
+
<InputGroup>
|
|
143
|
+
<InputGroupAddon>
|
|
144
|
+
<SearchIcon className="size-4 text-muted-foreground" />
|
|
145
|
+
</InputGroupAddon>
|
|
146
|
+
<InputGroupInput
|
|
147
|
+
type="search"
|
|
148
|
+
value={input}
|
|
149
|
+
onChange={(e) => setInput(e.target.value)}
|
|
150
|
+
// Enter has no action here; swallow it so it can't submit a host form.
|
|
151
|
+
onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()}
|
|
152
|
+
placeholder={searchPlaceholder ?? t('admin.resource_picker.search_placeholder')}
|
|
153
|
+
/>
|
|
154
|
+
</InputGroup>
|
|
155
|
+
{staged.size > 0 && (
|
|
156
|
+
<p className="mt-2 text-muted-foreground text-xs">
|
|
157
|
+
{t('admin.resource_picker.selected_count', { count: staged.size })}
|
|
158
|
+
</p>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
163
|
+
{isFetching && results.length === 0 ? (
|
|
164
|
+
<div className="flex items-center justify-center py-10 text-muted-foreground">
|
|
165
|
+
<Loader2Icon className="size-5 animate-spin" />
|
|
166
|
+
</div>
|
|
167
|
+
) : results.length === 0 ? (
|
|
168
|
+
<p className="p-6 text-center text-muted-foreground text-sm">
|
|
169
|
+
{t('admin.resource_picker.empty')}
|
|
170
|
+
</p>
|
|
171
|
+
) : (
|
|
172
|
+
<ul className="divide-y divide-border">
|
|
173
|
+
{results.map((option) => {
|
|
174
|
+
const inList = alreadyIn.has(option.id)
|
|
175
|
+
const checked = inList || staged.has(option.id)
|
|
176
|
+
const imageUrl = getOptionImageUrl?.(option)
|
|
177
|
+
const subtitle = getOptionSubtitle?.(option)
|
|
178
|
+
return (
|
|
179
|
+
<li key={option.id}>
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
disabled={inList}
|
|
183
|
+
onClick={() => toggle(option)}
|
|
184
|
+
className="flex w-full items-center gap-3 px-4 py-2.5 text-left hover:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-60"
|
|
185
|
+
>
|
|
186
|
+
<Checkbox
|
|
187
|
+
checked={checked}
|
|
188
|
+
disabled={inList}
|
|
189
|
+
className="pointer-events-none"
|
|
190
|
+
/>
|
|
191
|
+
{getOptionImageUrl && (
|
|
192
|
+
<Avatar className="size-9 rounded-md">
|
|
193
|
+
{imageUrl ? (
|
|
194
|
+
<AvatarImage src={imageUrl} alt="" className="object-cover" />
|
|
195
|
+
) : null}
|
|
196
|
+
<AvatarFallback className="rounded-md bg-muted">
|
|
197
|
+
<ImageIcon className="size-4 text-muted-foreground" />
|
|
198
|
+
</AvatarFallback>
|
|
199
|
+
</Avatar>
|
|
200
|
+
)}
|
|
201
|
+
<span className="min-w-0 flex-1">
|
|
202
|
+
<span className="block truncate text-sm">{getOptionLabel(option)}</span>
|
|
203
|
+
{subtitle && (
|
|
204
|
+
<span className="block truncate text-muted-foreground text-xs">
|
|
205
|
+
{subtitle}
|
|
206
|
+
</span>
|
|
207
|
+
)}
|
|
208
|
+
</span>
|
|
209
|
+
{inList && (
|
|
210
|
+
<span className="shrink-0 text-muted-foreground text-xs">
|
|
211
|
+
{t('admin.resource_picker.already_added')}
|
|
212
|
+
</span>
|
|
213
|
+
)}
|
|
214
|
+
</button>
|
|
215
|
+
</li>
|
|
216
|
+
)
|
|
217
|
+
})}
|
|
218
|
+
</ul>
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
<SheetFooter className="flex-row items-center justify-between border-t border-border">
|
|
223
|
+
<Button type="button" variant="ghost" onClick={() => handleOpenChange(false)}>
|
|
224
|
+
<XIcon className="size-4" />
|
|
225
|
+
{t('admin.actions.cancel')}
|
|
226
|
+
</Button>
|
|
227
|
+
<Button type="button" onClick={confirm} disabled={staged.size === 0 || submitting}>
|
|
228
|
+
{submitting && <Loader2Icon className="size-4 animate-spin" />}
|
|
229
|
+
{confirmLabel ?? t('admin.resource_picker.add_count', { count: staged.size })}
|
|
230
|
+
</Button>
|
|
231
|
+
</SheetFooter>
|
|
232
|
+
</SheetContent>
|
|
233
|
+
</Sheet>
|
|
234
|
+
)
|
|
235
|
+
}
|