@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,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
+ }