@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,139 @@
1
+ import {
2
+ Badge,
3
+ cn,
4
+ SidebarGroup,
5
+ SidebarGroupLabel,
6
+ SidebarMenu,
7
+ SidebarMenuButton,
8
+ SidebarMenuItem,
9
+ } from '@spree/dashboard-ui'
10
+ import { Link, useParams, useRouterState } from '@tanstack/react-router'
11
+ import { PackageIcon } from 'lucide-react'
12
+ import { useTranslation } from 'react-i18next'
13
+ import {
14
+ type SettingsNavEntry,
15
+ type SettingsNavSnapshot,
16
+ useSettingsNav,
17
+ } from '../lib/settings-nav-registry'
18
+ import { type Permissions, usePermissions } from '../providers/permission-provider'
19
+ import { NavIcon } from './nav-main'
20
+
21
+ /**
22
+ * Secondary settings sidebar. Always mounted as a sibling to the primary
23
+ * sidebar so it can extend full-height (top of viewport to bottom, beside
24
+ * the TopBar rather than below it). Width animates between `0` and
25
+ * `--spacing-sidebar-width` driven by the `open` prop, so entering and
26
+ * leaving the settings area gets a slide-in/slide-out transition.
27
+ *
28
+ * Entries reuse the same shadcn primitives (`SidebarMenuButton`, `NavIcon`,
29
+ * `SidebarGroupLabel`) as the primary sidebar so hover/active states and
30
+ * spacing stay perfectly consistent.
31
+ *
32
+ * Hidden below `lg` regardless of `open` — settings on narrow viewports
33
+ * still need a separate solution.
34
+ */
35
+ export function SettingsSidebar({ open }: { open: boolean }) {
36
+ const { t } = useTranslation()
37
+ const { storeId } = useParams({ strict: false }) as { storeId?: string }
38
+ const id = storeId ?? 'default'
39
+ const snapshot = useSettingsNav()
40
+ const { permissions } = usePermissions()
41
+ const visible = filterByPermissions(snapshot, permissions)
42
+
43
+ // `sticky top-0 h-svh` keeps the nav at full viewport height as the page
44
+ // scrolls. `overflow-hidden` clips the inner fixed-width content while the
45
+ // outer `width` animates between 0 and `--spacing-sidebar-width`. `aria-hidden`
46
+ // while closed prevents screen-reader and keyboard access to hidden links.
47
+ return (
48
+ <aside
49
+ aria-label={t('admin.a11y.settings_navigation')}
50
+ aria-hidden={!open}
51
+ data-state={open ? 'open' : 'closed'}
52
+ className={cn(
53
+ 'sticky top-0 z-30 hidden h-svh shrink-0 self-start overflow-hidden bg-sidebar text-sidebar-foreground transition-[width,border-color] duration-200 ease-out lg:block',
54
+ open
55
+ ? 'lg:w-(--spacing-sidebar-width) border-e border-border/50'
56
+ : 'lg:w-0 border-e-0 border-transparent',
57
+ )}
58
+ >
59
+ <div
60
+ className={cn(
61
+ 'flex h-full w-(--spacing-sidebar-width) flex-col gap-2 overflow-y-auto py-2 transition-opacity duration-200',
62
+ open ? 'opacity-100 delay-100' : 'pointer-events-none opacity-0',
63
+ )}
64
+ >
65
+ {visible.groups.map(({ group, entries }) => (
66
+ <SidebarGroup key={group.key}>
67
+ <SidebarGroupLabel>
68
+ {group.labelKey ? t(group.labelKey) : group.label}
69
+ </SidebarGroupLabel>
70
+ <SidebarMenu>
71
+ {entries.map((entry) => (
72
+ <SettingsItem
73
+ key={entry.key}
74
+ entry={entry}
75
+ storeId={id}
76
+ // While closed, keep the items out of the tab order — the
77
+ // `aria-hidden` above does not, by itself, prevent focus.
78
+ tabIndex={open ? 0 : -1}
79
+ />
80
+ ))}
81
+ </SidebarMenu>
82
+ </SidebarGroup>
83
+ ))}
84
+ </div>
85
+ </aside>
86
+ )
87
+ }
88
+
89
+ function SettingsItem({
90
+ entry,
91
+ storeId,
92
+ tabIndex,
93
+ }: {
94
+ entry: SettingsNavEntry
95
+ storeId: string
96
+ tabIndex: number
97
+ }) {
98
+ const { t } = useTranslation()
99
+ const routerState = useRouterState()
100
+ const currentPath = routerState.location.pathname
101
+ const url = `/${storeId}/settings${entry.path}`
102
+ const isActive = currentPath === url || currentPath.startsWith(`${url}/`)
103
+ const Icon = entry.icon ?? PackageIcon
104
+ const label = entry.labelKey ? t(entry.labelKey) : entry.label
105
+
106
+ return (
107
+ <SidebarMenuItem>
108
+ {/* No `tooltip` prop — it keys off the primary sidebar's collapsed state
109
+ via `useSidebar()`, which would fire spuriously in this secondary nav. */}
110
+ <SidebarMenuButton asChild isActive={isActive}>
111
+ <Link to={url} tabIndex={tabIndex}>
112
+ <NavIcon icon={Icon} isActive={isActive} />
113
+ <span>{label}</span>
114
+ {entry.comingSoon && (
115
+ <Badge className="ms-auto h-5 bg-sidebar-accent px-1.5 py-0 text-[10px] font-normal text-sidebar-foreground/70">
116
+ Soon
117
+ </Badge>
118
+ )}
119
+ </Link>
120
+ </SidebarMenuButton>
121
+ </SidebarMenuItem>
122
+ )
123
+ }
124
+
125
+ function filterByPermissions(
126
+ snapshot: SettingsNavSnapshot,
127
+ permissions: Permissions,
128
+ ): SettingsNavSnapshot {
129
+ const groups = snapshot.groups
130
+ .map(({ group, entries }) => ({
131
+ group,
132
+ entries: entries.filter((e) => !e.subject || permissions.can('read', e.subject)),
133
+ }))
134
+ .filter((g) => g.entries.length > 0)
135
+ return {
136
+ groups,
137
+ all: groups.flatMap((g) => g.entries),
138
+ }
139
+ }
@@ -0,0 +1,50 @@
1
+ import { Fragment } from 'react'
2
+ import { type SlotAmbientContext, useSlotEntries } from '../lib/slot-registry'
3
+
4
+ interface SlotProps<TContext> {
5
+ /** Slot name, e.g. "product.form_sidebar" or "order.dropdown". */
6
+ name: string
7
+ /** Slot-specific context passed to every registered component. */
8
+ context?: TContext
9
+ /**
10
+ * Rendered when no entries are registered or all are gated out by `if`.
11
+ * Useful when a slot is the only thing in a section (avoid empty headings).
12
+ */
13
+ fallback?: React.ReactNode
14
+ }
15
+
16
+ /**
17
+ * Renders all components registered to a slot, sorted by `position`,
18
+ * filtered by each entry's `if` predicate. Each component receives `context`
19
+ * as props plus ambient `{ permissions, store, user }` (populated as the
20
+ * providers come online).
21
+ */
22
+ export function Slot<TContext extends object = object>({
23
+ name,
24
+ context,
25
+ fallback,
26
+ }: SlotProps<TContext>) {
27
+ const entries = useSlotEntries(name)
28
+
29
+ // Ambient context — wired up properly once SlotProvider lands. For now
30
+ // an empty object so plugin `if` predicates can destructure safely.
31
+ const ambient: SlotAmbientContext = {}
32
+ const merged = { ...ambient, ...(context ?? {}) } as TContext & SlotAmbientContext
33
+
34
+ const visible = entries.filter((entry) => (entry.if ? entry.if(merged) : true))
35
+
36
+ if (visible.length === 0) return fallback ?? null
37
+
38
+ return (
39
+ <>
40
+ {visible.map((entry) => {
41
+ const Component = entry.component
42
+ return (
43
+ <Fragment key={entry.id}>
44
+ <Component {...merged} />
45
+ </Fragment>
46
+ )
47
+ })}
48
+ </>
49
+ )
50
+ }
@@ -0,0 +1,18 @@
1
+ import { DatePicker, type DatePickerProps } from '@spree/dashboard-ui'
2
+ import { useStore } from '../providers/store-provider'
3
+
4
+ /**
5
+ * Drop-in `<DatePicker>` that pulls the timezone from `<StoreProvider>` so
6
+ * every datetime in the admin SPA is interpreted in the store's timezone
7
+ * regardless of where the admin is logged in from.
8
+ *
9
+ * Use this in every admin form, filter, and sheet that touches a date.
10
+ * Only reach for the bare `<DatePicker>` when you need to override the
11
+ * timezone explicitly (rare).
12
+ */
13
+ function StoreDatePicker(props: Omit<DatePickerProps, 'timezone'>) {
14
+ const { timezone } = useStore()
15
+ return <DatePicker {...props} timezone={timezone} />
16
+ }
17
+
18
+ export { StoreDatePicker }
@@ -0,0 +1,70 @@
1
+ import {
2
+ Avatar,
3
+ AvatarFallback,
4
+ AvatarImage,
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuItem,
8
+ DropdownMenuTrigger,
9
+ SidebarMenu,
10
+ SidebarMenuItem,
11
+ Skeleton,
12
+ useSidebar,
13
+ } from '@spree/dashboard-ui'
14
+ import { ChevronsUpDownIcon, ExternalLinkIcon } from 'lucide-react'
15
+ import { useTranslation } from 'react-i18next'
16
+ import { useStore } from '../providers/store-provider'
17
+
18
+ export function StoreSwitcher() {
19
+ const { t } = useTranslation()
20
+ const { isMobile, state } = useSidebar()
21
+ const isCollapsed = state === 'collapsed'
22
+
23
+ const { store, isLoading } = useStore()
24
+
25
+ if (isLoading) return <Skeleton className="h-header-height w-full rounded-xl" />
26
+
27
+ const storeInitials = store?.name
28
+ .split(' ')
29
+ .map((name) => name[0])
30
+ .join('')
31
+
32
+ return (
33
+ <SidebarMenu>
34
+ <SidebarMenuItem className="h-header-height flex items-center">
35
+ <DropdownMenu>
36
+ <DropdownMenuTrigger asChild className="flex w-full items-center">
37
+ <button
38
+ type="button"
39
+ className="rounded-xl outline-hidden transition-colors duration-100 hover:bg-sidebar-accent data-[state=open]:bg-sidebar-accent gap-2 p-1.5"
40
+ >
41
+ <Avatar>
42
+ {store?.logo_url && <AvatarImage src={store.logo_url} />}
43
+ <AvatarFallback>{storeInitials}</AvatarFallback>
44
+ </Avatar>
45
+ {!isCollapsed && (
46
+ <>
47
+ <div className="grid flex-1 text-left text-sm leading-tight">
48
+ <span className="truncate font-medium text-foreground">{store?.name}</span>
49
+ </div>
50
+ <ChevronsUpDownIcon className="ml-auto size-4 text-muted-foreground" />
51
+ </>
52
+ )}
53
+ </button>
54
+ </DropdownMenuTrigger>
55
+ <DropdownMenuContent
56
+ className="min-w-48"
57
+ side={isMobile ? 'bottom' : 'right'}
58
+ align="start"
59
+ sideOffset={8}
60
+ >
61
+ <DropdownMenuItem>
62
+ <ExternalLinkIcon className="size-4" />
63
+ {t('admin.account.view_store')}
64
+ </DropdownMenuItem>
65
+ </DropdownMenuContent>
66
+ </DropdownMenu>
67
+ </SidebarMenuItem>
68
+ </SidebarMenu>
69
+ )
70
+ }