@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,376 @@
1
+ import { zodResolver } from '@hookform/resolvers/zod'
2
+ import type { Address } from '@spree/admin-sdk'
3
+ import {
4
+ Button,
5
+ Checkbox,
6
+ Field,
7
+ FieldError,
8
+ FieldGroup,
9
+ FieldLabel,
10
+ Input,
11
+ requiredMessage,
12
+ Sheet,
13
+ SheetContent,
14
+ SheetDescription,
15
+ SheetFooter,
16
+ SheetHeader,
17
+ SheetTitle,
18
+ } from '@spree/dashboard-ui'
19
+ import { useEffect, useRef } from 'react'
20
+ import { Controller, useForm } from 'react-hook-form'
21
+ import { useTranslation } from 'react-i18next'
22
+ import { z } from 'zod/v4'
23
+ import { mapSpreeErrorsToForm } from '../lib/form-errors'
24
+ import { CountryCombobox } from './country-combobox'
25
+ import { StateCombobox, useCountryStates } from './country-state-fields'
26
+
27
+ export interface AddressParams {
28
+ first_name: string
29
+ last_name: string
30
+ address1: string
31
+ address2: string
32
+ city: string
33
+ postal_code: string
34
+ country_iso: string
35
+ state_abbr: string
36
+ phone: string
37
+ label?: string
38
+ is_default_billing?: boolean
39
+ is_default_shipping?: boolean
40
+ }
41
+
42
+ const addressFormSchema = z.object({
43
+ first_name: z
44
+ .string()
45
+ .trim()
46
+ .min(1, { error: requiredMessage('first_name') }),
47
+ last_name: z
48
+ .string()
49
+ .trim()
50
+ .min(1, { error: requiredMessage('last_name') }),
51
+ address1: z
52
+ .string()
53
+ .trim()
54
+ .min(1, { error: requiredMessage('address.address1') }),
55
+ address2: z.string(),
56
+ city: z
57
+ .string()
58
+ .trim()
59
+ .min(1, { error: requiredMessage('city') }),
60
+ postal_code: z
61
+ .string()
62
+ .trim()
63
+ .min(1, { error: requiredMessage('address.postal_code') }),
64
+ country_iso: z
65
+ .string()
66
+ .trim()
67
+ .min(1, { error: requiredMessage('country_iso') }),
68
+ state_abbr: z.string(),
69
+ phone: z.string(),
70
+ label: z.string(),
71
+ is_default_billing: z.boolean(),
72
+ is_default_shipping: z.boolean(),
73
+ })
74
+
75
+ type AddressFormValues = z.infer<typeof addressFormSchema>
76
+
77
+ function buildDefaults(address: Address | null | undefined): AddressFormValues {
78
+ return {
79
+ first_name: address?.first_name ?? '',
80
+ last_name: address?.last_name ?? '',
81
+ address1: address?.address1 ?? '',
82
+ address2: address?.address2 ?? '',
83
+ city: address?.city ?? '',
84
+ postal_code: address?.postal_code ?? '',
85
+ country_iso: address?.country_iso ?? '',
86
+ state_abbr: address?.state_abbr ?? '',
87
+ phone: address?.phone ?? '',
88
+ label: address?.label ?? '',
89
+ is_default_billing: address?.is_default_billing ?? false,
90
+ is_default_shipping: address?.is_default_shipping ?? false,
91
+ }
92
+ }
93
+
94
+ export function AddressFormDialog({
95
+ address,
96
+ open,
97
+ onOpenChange,
98
+ onSave,
99
+ title,
100
+ isPending = false,
101
+ showLabel = false,
102
+ showDefaultFlags = false,
103
+ }: {
104
+ address: Address | null | undefined
105
+ open: boolean
106
+ onOpenChange: (open: boolean) => void
107
+ onSave: (address: AddressParams) => void | Promise<void>
108
+ title?: string
109
+ isPending?: boolean
110
+ showLabel?: boolean
111
+ showDefaultFlags?: boolean
112
+ }) {
113
+ const { t } = useTranslation()
114
+ const resolvedTitle = title ?? t('admin.components.address_form_dialog.edit_title')
115
+ const form = useForm<AddressFormValues>({
116
+ defaultValues: buildDefaults(address),
117
+ resolver: zodResolver(addressFormSchema),
118
+ })
119
+ const { errors } = form.formState
120
+
121
+ // The parent keys the dialog on the address id so a fresh instance mounts
122
+ // for each open, but `address` can also stream in async (loaded after the
123
+ // sheet mounts). Reset only when the *record identity* changes — otherwise a
124
+ // re-render that creates a new `address` object literal would clobber edits
125
+ // mid-flow.
126
+ const prevAddressIdRef = useRef<string | null | undefined>(address?.id)
127
+ useEffect(() => {
128
+ if (address?.id !== prevAddressIdRef.current) {
129
+ prevAddressIdRef.current = address?.id
130
+ form.reset(buildDefaults(address))
131
+ }
132
+ }, [address, form])
133
+
134
+ const countryIso = form.watch('country_iso')
135
+ const { states, statesRequired } = useCountryStates(countryIso)
136
+ const useStateCombobox = statesRequired && states.length > 0
137
+
138
+ async function onSubmit(values: AddressFormValues) {
139
+ try {
140
+ await onSave({
141
+ first_name: values.first_name,
142
+ last_name: values.last_name,
143
+ address1: values.address1,
144
+ address2: values.address2,
145
+ city: values.city,
146
+ postal_code: values.postal_code,
147
+ country_iso: values.country_iso,
148
+ state_abbr: values.state_abbr,
149
+ phone: values.phone,
150
+ ...(showLabel && { label: values.label || undefined }),
151
+ ...(showDefaultFlags && {
152
+ is_default_billing: values.is_default_billing,
153
+ is_default_shipping: values.is_default_shipping,
154
+ }),
155
+ })
156
+ } catch (err) {
157
+ // Surface server-side 422 validation errors on the matching fields so
158
+ // the dialog reflects whatever the API rejected (e.g. "phone is too
159
+ // short"). Non-validation errors bubble to the parent's toast.
160
+ if (!mapSpreeErrorsToForm(err, form.setError)) throw err
161
+ }
162
+ }
163
+
164
+ // Prevent Enter in combobox inputs from submitting the form. RHF's
165
+ // handleSubmit fires on submit events, so swallow Enter inside any
166
+ // role=combobox before it bubbles into the form.
167
+ function handleKeyDown(e: React.KeyboardEvent) {
168
+ const target = e.target as HTMLElement
169
+ if (e.key === 'Enter' && target.getAttribute('role') === 'combobox') {
170
+ e.preventDefault()
171
+ }
172
+ }
173
+
174
+ return (
175
+ <Sheet open={open} onOpenChange={(o) => onOpenChange(o as boolean)}>
176
+ <SheetContent side="right">
177
+ <SheetHeader>
178
+ <SheetTitle>{resolvedTitle}</SheetTitle>
179
+ <SheetDescription>
180
+ {t('admin.components.address_form_dialog.description')}
181
+ </SheetDescription>
182
+ </SheetHeader>
183
+ <form
184
+ onSubmit={form.handleSubmit(onSubmit)}
185
+ onKeyDown={handleKeyDown}
186
+ className="flex flex-col flex-1 overflow-hidden"
187
+ >
188
+ <div className="flex-1 overflow-y-auto p-4">
189
+ <FieldGroup>
190
+ {showLabel && (
191
+ <Field>
192
+ <FieldLabel htmlFor="addr-label">
193
+ {t('admin.fields.address.label.label')}
194
+ </FieldLabel>
195
+ <Input
196
+ id="addr-label"
197
+ placeholder={t('admin.fields.address.label.placeholder')}
198
+ {...form.register('label')}
199
+ />
200
+ </Field>
201
+ )}
202
+ <div className="grid grid-cols-2 gap-3">
203
+ <Field>
204
+ <FieldLabel htmlFor="addr-fn">{t('admin.fields.first_name.label')}</FieldLabel>
205
+ <Input
206
+ id="addr-fn"
207
+ aria-invalid={!!errors.first_name || undefined}
208
+ {...form.register('first_name')}
209
+ />
210
+ <FieldError errors={[errors.first_name]} />
211
+ </Field>
212
+ <Field>
213
+ <FieldLabel htmlFor="addr-ln">{t('admin.fields.last_name.label')}</FieldLabel>
214
+ <Input
215
+ id="addr-ln"
216
+ aria-invalid={!!errors.last_name || undefined}
217
+ {...form.register('last_name')}
218
+ />
219
+ <FieldError errors={[errors.last_name]} />
220
+ </Field>
221
+ </div>
222
+ <Field>
223
+ <FieldLabel htmlFor="addr-a1">
224
+ {t('admin.fields.address.address1.label')}
225
+ </FieldLabel>
226
+ <Input
227
+ id="addr-a1"
228
+ aria-invalid={!!errors.address1 || undefined}
229
+ {...form.register('address1')}
230
+ />
231
+ <FieldError errors={[errors.address1]} />
232
+ </Field>
233
+ <Field>
234
+ <FieldLabel htmlFor="addr-a2">
235
+ {t('admin.fields.address.address2.label')}
236
+ </FieldLabel>
237
+ <Input
238
+ id="addr-a2"
239
+ aria-invalid={!!errors.address2 || undefined}
240
+ {...form.register('address2')}
241
+ />
242
+ <FieldError errors={[errors.address2]} />
243
+ </Field>
244
+ <Field>
245
+ <FieldLabel>{t('admin.fields.country_iso.label')}</FieldLabel>
246
+ <Controller
247
+ name="country_iso"
248
+ control={form.control}
249
+ render={({ field }) => (
250
+ <CountryCombobox
251
+ value={field.value}
252
+ onValueChange={(iso) => {
253
+ field.onChange(iso)
254
+ form.setValue('state_abbr', '', { shouldDirty: true })
255
+ }}
256
+ />
257
+ )}
258
+ />
259
+ <FieldError errors={[errors.country_iso]} />
260
+ </Field>
261
+ <div className="grid grid-cols-2 gap-3">
262
+ <Field>
263
+ <FieldLabel htmlFor="addr-city">{t('admin.fields.city.label')}</FieldLabel>
264
+ <Input
265
+ id="addr-city"
266
+ aria-invalid={!!errors.city || undefined}
267
+ {...form.register('city')}
268
+ />
269
+ <FieldError errors={[errors.city]} />
270
+ </Field>
271
+ {useStateCombobox ? (
272
+ <Field>
273
+ <FieldLabel>{t('admin.fields.state_abbr.label')}</FieldLabel>
274
+ <Controller
275
+ name="state_abbr"
276
+ control={form.control}
277
+ render={({ field }) => (
278
+ <StateCombobox
279
+ countryIso={countryIso}
280
+ states={states}
281
+ value={field.value}
282
+ onValueChange={field.onChange}
283
+ />
284
+ )}
285
+ />
286
+ <FieldError errors={[errors.state_abbr]} />
287
+ </Field>
288
+ ) : (
289
+ <Field>
290
+ <FieldLabel htmlFor="addr-state-abbr">
291
+ {t('admin.fields.state_abbr.label')}
292
+ </FieldLabel>
293
+ <Input
294
+ id="addr-state-abbr"
295
+ aria-invalid={!!errors.state_abbr || undefined}
296
+ {...form.register('state_abbr')}
297
+ />
298
+ <FieldError errors={[errors.state_abbr]} />
299
+ </Field>
300
+ )}
301
+ </div>
302
+ <Field>
303
+ <FieldLabel htmlFor="addr-zip">
304
+ {t('admin.fields.address.postal_code.label')}
305
+ </FieldLabel>
306
+ <Input
307
+ id="addr-zip"
308
+ aria-invalid={!!errors.postal_code || undefined}
309
+ {...form.register('postal_code')}
310
+ />
311
+ <FieldError errors={[errors.postal_code]} />
312
+ </Field>
313
+ <Field>
314
+ <FieldLabel htmlFor="addr-phone">{t('admin.fields.phone.label')}</FieldLabel>
315
+ <Input
316
+ id="addr-phone"
317
+ aria-invalid={!!errors.phone || undefined}
318
+ {...form.register('phone')}
319
+ />
320
+ <FieldError errors={[errors.phone]} />
321
+ </Field>
322
+ {showDefaultFlags && (
323
+ <>
324
+ <Field>
325
+ <div className="flex items-start justify-between gap-4">
326
+ <FieldLabel htmlFor="addr-default-billing" className="cursor-pointer">
327
+ {t('admin.fields.address.is_default_billing.label')}
328
+ </FieldLabel>
329
+ <Controller
330
+ name="is_default_billing"
331
+ control={form.control}
332
+ render={({ field }) => (
333
+ <Checkbox
334
+ id="addr-default-billing"
335
+ checked={!!field.value}
336
+ onCheckedChange={field.onChange}
337
+ />
338
+ )}
339
+ />
340
+ </div>
341
+ </Field>
342
+ <Field>
343
+ <div className="flex items-start justify-between gap-4">
344
+ <FieldLabel htmlFor="addr-default-shipping" className="cursor-pointer">
345
+ {t('admin.fields.address.is_default_shipping.label')}
346
+ </FieldLabel>
347
+ <Controller
348
+ name="is_default_shipping"
349
+ control={form.control}
350
+ render={({ field }) => (
351
+ <Checkbox
352
+ id="addr-default-shipping"
353
+ checked={!!field.value}
354
+ onCheckedChange={field.onChange}
355
+ />
356
+ )}
357
+ />
358
+ </div>
359
+ </Field>
360
+ </>
361
+ )}
362
+ </FieldGroup>
363
+ </div>
364
+ <SheetFooter>
365
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
366
+ {t('admin.actions.cancel')}
367
+ </Button>
368
+ <Button type="submit" disabled={isPending}>
369
+ {isPending ? t('admin.actions.saving') : t('admin.actions.save')}
370
+ </Button>
371
+ </SheetFooter>
372
+ </form>
373
+ </SheetContent>
374
+ </Sheet>
375
+ )
376
+ }
@@ -0,0 +1,70 @@
1
+ import { Sidebar, SidebarContent, SidebarHeader } from '@spree/dashboard-ui'
2
+ import { useParams } from '@tanstack/react-router'
3
+ import { PackageIcon } from 'lucide-react'
4
+ import type { ComponentProps } from 'react'
5
+ import { useAuth } from '../hooks/use-auth'
6
+ import { primarySidebarSide, useTranslation } from '../lib/i18n'
7
+ import { type NavEntry, useNavEntries } from '../lib/nav-registry'
8
+ import { type Permissions, usePermissions } from '../providers/permission-provider'
9
+ import { useStore } from '../providers/store-provider'
10
+ import { type NavItem, NavMain } from './nav-main'
11
+ import { StoreSwitcher } from './store-switcher'
12
+
13
+ function entryToNavItem(entry: NavEntry, storeId: string): NavItem {
14
+ const pathFor = (path: string) => (path === '/' ? `/${storeId}` : `/${storeId}${path}`)
15
+ return {
16
+ title: entry.label,
17
+ url: pathFor(entry.path),
18
+ icon: entry.icon ?? PackageIcon,
19
+ subject: entry.subject,
20
+ badge: entry.badge,
21
+ items: entry.children?.map((child) => ({
22
+ title: child.label,
23
+ url: pathFor(child.path),
24
+ subject: child.subject,
25
+ })),
26
+ }
27
+ }
28
+
29
+ /** Hide items the user can't `read`. */
30
+ function filterByPermissions(items: NavItem[], permissions: Permissions): NavItem[] {
31
+ return items
32
+ .filter((item) => !item.subject || permissions.can('read', item.subject))
33
+ .map((item) => ({
34
+ ...item,
35
+ items: item.items?.filter((sub) => !sub.subject || permissions.can('read', sub.subject)),
36
+ }))
37
+ }
38
+
39
+ export function AppSidebar(props: ComponentProps<typeof Sidebar>) {
40
+ const { i18n } = useTranslation()
41
+ const { storeId } = useParams({ strict: false }) as { storeId?: string }
42
+ const { permissions } = usePermissions()
43
+ const { store } = useStore()
44
+ const { user } = useAuth()
45
+ const id = storeId || 'default'
46
+ const { main, bottom } = useNavEntries()
47
+
48
+ const visibilityContext = { permissions, store, user }
49
+ const visible = (entry: NavEntry) => !entry.if || entry.if(visibilityContext)
50
+
51
+ const navItems = filterByPermissions(
52
+ main.filter(visible).map((e) => entryToNavItem(e, id)),
53
+ permissions,
54
+ )
55
+ const bottomItems = filterByPermissions(
56
+ bottom.filter(visible).map((e) => entryToNavItem(e, id)),
57
+ permissions,
58
+ )
59
+
60
+ return (
61
+ <Sidebar collapsible="icon" side={primarySidebarSide(i18n.language)} {...props}>
62
+ <SidebarHeader>
63
+ <StoreSwitcher />
64
+ </SidebarHeader>
65
+ <SidebarContent>
66
+ <NavMain items={navItems} bottomItems={bottomItems} />
67
+ </SidebarContent>
68
+ </Sidebar>
69
+ )
70
+ }