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