@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,286 @@
1
+ import {
2
+ BackButton,
3
+ Button,
4
+ cn,
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuItem,
8
+ DropdownMenuSeparator,
9
+ DropdownMenuTrigger,
10
+ useConfirm,
11
+ useCopyToClipboard,
12
+ useScrolled,
13
+ } from '@spree/dashboard-ui'
14
+ import type { JsonPreviewDrawerProps } from '@spree/dashboard-ui/spree/json-preview-drawer'
15
+ import { BracesIcon, CheckIcon, CopyIcon, EllipsisVerticalIcon, TrashIcon } from 'lucide-react'
16
+ import { lazy, type ReactNode, Suspense, useState } from 'react'
17
+ import { useTranslation } from 'react-i18next'
18
+ import { Slot } from './slot'
19
+
20
+ // JSON drawer is a developer-only feature; pulling its tree (which includes
21
+ // @uiw/react-json-view at ~30KB gzip) into the route bundle is wasteful.
22
+ // Lazy-load on first open so the chunk only ships to admins who use it.
23
+ // Deep subpath import bypasses the dashboard-ui barrel so the JSON view
24
+ // libs only live in the chunk Rollup splits off here.
25
+ const JsonPreviewDrawer = lazy(() =>
26
+ import('@spree/dashboard-ui/spree/json-preview-drawer').then((m) => ({
27
+ default: m.JsonPreviewDrawer,
28
+ })),
29
+ )
30
+
31
+ /** Subset of `JsonPreviewDrawerProps` callers supply; PageHeader manages open state. */
32
+ export type PageHeaderJsonPreview = Pick<
33
+ JsonPreviewDrawerProps,
34
+ 'title' | 'fetch' | 'endpoint' | 'resolveLink'
35
+ >
36
+
37
+ /**
38
+ * Resource-shaped value PageHeader inspects to wire the more-actions dropdown.
39
+ * Anything not present is simply omitted — no errors, no required fields beyond `id`.
40
+ */
41
+ export interface PageHeaderResource {
42
+ id: string
43
+ /** Display number for "Copy number". Used on orders. */
44
+ number?: string | null
45
+ }
46
+
47
+ interface PageHeaderProps {
48
+ /** Main title (left of any badges). */
49
+ title: ReactNode
50
+ /** Small text below or beside the title (updated_at badge, customer email, etc.). */
51
+ subtitle?: ReactNode
52
+ /** Back button target — passed to <BackButton fallback="..."/>. Omit for top-level pages. */
53
+ backTo?: string
54
+ /** Status badges rendered next to the title. Use <StatusBadge /> for consistency. */
55
+ badges?: ReactNode
56
+ /** Primary action buttons (rightmost, before the dropdown). Most pages use this for "Save". */
57
+ actions?: ReactNode
58
+ /**
59
+ * Domain-specific items to render at the top of the more-actions dropdown
60
+ * (e.g., Complete/Approve/Cancel on orders). Rendered above the
61
+ * `page.actions_dropdown` slot and the auto-wired Copy ID / Delete items.
62
+ */
63
+ dropdownItems?: ReactNode
64
+ /**
65
+ * The resource being edited. When supplied, PageHeader renders the legacy
66
+ * "more actions" dropdown (Copy ID, Copy number, Delete). When omitted, the
67
+ * dropdown is only rendered if a plugin has registered into `page.actions_dropdown`.
68
+ */
69
+ resource?: PageHeaderResource
70
+ /** Slot context name. Defaults to inferring from `resource` keys. Optional. */
71
+ slotContext?: Record<string, unknown>
72
+ /**
73
+ * Called after the user confirms the auto-rendered Delete action.
74
+ * When provided, the Delete item is enabled. The confirmation prompt is
75
+ * fixed ("Are you sure? This action cannot be undone.") — pass `dropdownItems`
76
+ * directly if you need a custom delete flow.
77
+ */
78
+ onDelete?: () => void | Promise<void>
79
+ /** Override the destructive label ("Delete order", "Delete product", etc.). */
80
+ deleteLabel?: string
81
+ /**
82
+ * When supplied, the more-actions dropdown gains a "View as JSON" item that
83
+ * opens a developer-style drawer with the resource payload.
84
+ */
85
+ jsonPreview?: PageHeaderJsonPreview
86
+ }
87
+
88
+ /**
89
+ * Top-of-page chrome: back button + title + badges + subtitle, with primary
90
+ * actions and a more-actions dropdown on the right.
91
+ *
92
+ * Mirrors `spree/admin/app/views/spree/admin/shared/_content_header.html.erb`:
93
+ * the legacy header yields `:page_title`, `:page_actions`, `:page_actions_dropdown`
94
+ * via `content_for`. Here those become props + the `page.actions` and
95
+ * `page.actions_dropdown` slots, so plugins extend without overriding.
96
+ */
97
+ export function PageHeader({
98
+ title,
99
+ subtitle,
100
+ backTo,
101
+ badges,
102
+ actions,
103
+ dropdownItems,
104
+ resource,
105
+ slotContext,
106
+ onDelete,
107
+ deleteLabel,
108
+ jsonPreview,
109
+ }: PageHeaderProps) {
110
+ const { t } = useTranslation()
111
+ const slotCtx = { ...slotContext, resource }
112
+ const showDropdown = Boolean(resource || onDelete || dropdownItems || jsonPreview)
113
+ const [jsonOpen, setJsonOpen] = useState(false)
114
+ // Latches true on first open so the drawer (and its lazy JsonView chunk)
115
+ // doesn't mount until the user actually invokes it, but stays mounted
116
+ // afterwards so the close animation plays.
117
+ const [jsonEverOpened, setJsonEverOpened] = useState(false)
118
+ const openJson = () => {
119
+ setJsonEverOpened(true)
120
+ setJsonOpen(true)
121
+ }
122
+ const scrolled = useScrolled()
123
+
124
+ return (
125
+ // Sticky below the TopBar (which sticks at `top-0`, height
126
+ // `--spacing-header-height`) so the title, badges, and primary actions
127
+ // (notably Save on form pages) stay reachable as the user scrolls long
128
+ // detail pages. `bg-background` masks the content scrolling behind it;
129
+ // `-mx-4 px-4 lg:-mx-6 lg:px-6` and `-mt-4 lg:-mt-6 pt-4 lg:pt-6` undo
130
+ // and re-apply the parent padding so the sticky band runs edge-to-edge
131
+ // and there's no transparent gap between the TopBar and the header.
132
+ //
133
+ // The `::after` pseudo-element is the bottom hairline — it fades in
134
+ // once the user scrolls (so the header blends at rest, separates when
135
+ // content slides under it). A horizontal `mask-image` gradient feathers
136
+ // the hairline's left/right edges to transparent so it doesn't visually
137
+ // collide with the page edges; the `border-border` color it carries is
138
+ // the same hairline used elsewhere in the app.
139
+ <header
140
+ className={cn(
141
+ 'sticky top-header-height z-20 -mx-4 -mt-4 flex items-start gap-3 bg-background px-4 pt-4 pb-3 lg:-mx-6 lg:-mt-6 lg:px-6 lg:pt-6',
142
+ 'after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-border after:opacity-0 after:transition-opacity after:duration-150 after:[mask-image:linear-gradient(to_right,transparent,black_8%,black_92%,transparent)]',
143
+ scrolled && 'after:opacity-100',
144
+ )}
145
+ >
146
+ {backTo && <BackButton fallback={backTo} />}
147
+
148
+ {/* Title + badges share the first row; subtitle drops to its own line
149
+ underneath so descriptive prose doesn't get crammed alongside the
150
+ heading. Short metadata subtitles (customer location, order status
151
+ hint) read fine on the second line too. */}
152
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
153
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
154
+ <h1 className="text-2xl font-medium leading-tight">{title}</h1>
155
+ {badges}
156
+ </div>
157
+ {subtitle && <span className="text-sm text-muted-foreground">{subtitle}</span>}
158
+ </div>
159
+
160
+ <div className="ml-auto flex items-center gap-2">
161
+ <Slot name="page.actions" context={slotCtx} />
162
+ {actions}
163
+ {showDropdown && (
164
+ <PageActionsDropdown
165
+ resource={resource}
166
+ slotContext={slotCtx}
167
+ dropdownItems={dropdownItems}
168
+ onDelete={onDelete}
169
+ deleteLabel={deleteLabel ?? t('admin.actions.delete')}
170
+ onOpenJson={jsonPreview ? openJson : undefined}
171
+ />
172
+ )}
173
+ </div>
174
+
175
+ {jsonPreview && jsonEverOpened && (
176
+ <Suspense fallback={null}>
177
+ <JsonPreviewDrawer open={jsonOpen} onOpenChange={setJsonOpen} {...jsonPreview} />
178
+ </Suspense>
179
+ )}
180
+ </header>
181
+ )
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // More-actions dropdown
186
+ // ---------------------------------------------------------------------------
187
+
188
+ interface PageActionsDropdownProps {
189
+ resource?: PageHeaderResource
190
+ slotContext: Record<string, unknown>
191
+ dropdownItems?: ReactNode
192
+ onDelete?: () => void | Promise<void>
193
+ deleteLabel: string
194
+ onOpenJson?: () => void
195
+ }
196
+
197
+ function PageActionsDropdown({
198
+ resource,
199
+ slotContext,
200
+ dropdownItems,
201
+ onDelete,
202
+ deleteLabel,
203
+ onOpenJson,
204
+ }: PageActionsDropdownProps) {
205
+ const { t } = useTranslation()
206
+ const confirm = useConfirm()
207
+
208
+ return (
209
+ <DropdownMenu>
210
+ <DropdownMenuTrigger asChild>
211
+ <Button size="icon-sm" variant="outline" aria-label={t('admin.actions.more_actions')}>
212
+ <EllipsisVerticalIcon className="size-4" />
213
+ </Button>
214
+ </DropdownMenuTrigger>
215
+ <DropdownMenuContent align="end">
216
+ {dropdownItems}
217
+ <Slot name="page.actions_dropdown" context={slotContext} />
218
+
219
+ {onOpenJson && (
220
+ <DropdownMenuItem onClick={onOpenJson}>
221
+ <BracesIcon className="size-4" />
222
+ {t('admin.components.page_header.view_as_json')}
223
+ </DropdownMenuItem>
224
+ )}
225
+ {resource?.number && (
226
+ <CopyToClipboardItem
227
+ label={t('admin.components.page_header.copy_number')}
228
+ value={resource.number}
229
+ />
230
+ )}
231
+ {resource && (
232
+ <CopyToClipboardItem
233
+ label={t('admin.components.page_header.copy_id')}
234
+ value={resource.id}
235
+ />
236
+ )}
237
+
238
+ {onDelete && (
239
+ <>
240
+ <DropdownMenuSeparator />
241
+ <DropdownMenuItem
242
+ variant="destructive"
243
+ onClick={async () => {
244
+ if (
245
+ await confirm({
246
+ message: t('admin.common.delete_confirm_message'),
247
+ variant: 'destructive',
248
+ confirmLabel: deleteLabel,
249
+ })
250
+ ) {
251
+ await onDelete()
252
+ }
253
+ }}
254
+ >
255
+ <TrashIcon className="size-4" />
256
+ {deleteLabel}
257
+ </DropdownMenuItem>
258
+ </>
259
+ )}
260
+ </DropdownMenuContent>
261
+ </DropdownMenu>
262
+ )
263
+ }
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // Copy-to-clipboard menu item
267
+ // ---------------------------------------------------------------------------
268
+
269
+ function CopyToClipboardItem({ label, value }: { label: string; value: string }) {
270
+ const { t } = useTranslation()
271
+ const { copied, copy } = useCopyToClipboard()
272
+
273
+ return (
274
+ <DropdownMenuItem
275
+ // Keep the menu open after click so the user sees the confirmation flash.
276
+ closeOnClick={false}
277
+ onClick={(e) => {
278
+ e.preventDefault()
279
+ copy(value)
280
+ }}
281
+ >
282
+ {copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
283
+ {copied ? t('admin.actions.copied') : label}
284
+ </DropdownMenuItem>
285
+ )
286
+ }
@@ -0,0 +1,78 @@
1
+ import { cn } from '@spree/dashboard-ui'
2
+ import { Link, type LinkProps, useLocation, useRouter } from '@tanstack/react-router'
3
+ import type { ReactNode } from 'react'
4
+ import { Slot } from './slot'
5
+
6
+ export interface PageTab {
7
+ /** Stable key used for React lists. */
8
+ key: string
9
+ /** Visible label. */
10
+ label: ReactNode
11
+ /** Link target — same shape as TanStack Router <Link>. */
12
+ to: LinkProps['to']
13
+ /** Path/search params for the target route. */
14
+ params?: LinkProps['params']
15
+ search?: LinkProps['search']
16
+ /**
17
+ * Match strategy. `"exact"` (default) matches only when the pathname is
18
+ * identical; `"prefix"` matches when the current pathname starts with `to`.
19
+ */
20
+ match?: 'exact' | 'prefix'
21
+ }
22
+
23
+ interface PageTabsProps {
24
+ tabs: PageTab[]
25
+ /** Slot name for plugin-injected tabs. Default `'page.tabs'`. */
26
+ slotName?: string
27
+ /** Slot context. Receives `{ tabs }` so plugins can inspect built-ins. */
28
+ slotContext?: Record<string, unknown>
29
+ className?: string
30
+ }
31
+
32
+ /**
33
+ * Sub-route–aware tab strip. Each tab is a TanStack Router `<Link>`; the
34
+ * active tab is the one whose `to` matches the current pathname.
35
+ *
36
+ * Replaces `shared/_page_tabs.html.erb` and the various `_*_nav.html.erb`
37
+ * partials. Plugins extend by registering into the `page.tabs` slot.
38
+ */
39
+ export function PageTabs({ tabs, slotName = 'page.tabs', slotContext, className }: PageTabsProps) {
40
+ const location = useLocation()
41
+ const router = useRouter()
42
+
43
+ return (
44
+ <nav className={cn('flex items-center gap-1 border-b border-border', className)}>
45
+ {tabs.map((tab) => {
46
+ // Resolve to an absolute pathname so prefix-mode doesn't match every tab
47
+ // when `to` is an object/relative form. Mirrors what <Link> itself does.
48
+ const target =
49
+ typeof tab.to === 'string'
50
+ ? tab.to
51
+ : router.buildLocation({ to: tab.to, params: tab.params, search: tab.search }).pathname
52
+ const active =
53
+ tab.match === 'prefix'
54
+ ? location.pathname.startsWith(target)
55
+ : location.pathname === target
56
+ return (
57
+ <Link
58
+ key={tab.key}
59
+ // <Link> generics need a literal route path; PageTab is a generic
60
+ // declarative API where `to` is unknown at this site.
61
+ to={tab.to as never}
62
+ params={tab.params}
63
+ search={tab.search}
64
+ className={cn(
65
+ 'inline-flex items-center px-3 py-2 -mb-px text-sm font-medium border-b-2 transition-colors',
66
+ active
67
+ ? 'border-foreground text-foreground'
68
+ : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border',
69
+ )}
70
+ >
71
+ {tab.label}
72
+ </Link>
73
+ )
74
+ })}
75
+ <Slot name={slotName} context={{ tabs, ...slotContext }} />
76
+ </nav>
77
+ )
78
+ }