@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,55 @@
1
+ /**
2
+ * CanCanCan subject class names — use these constants instead of raw strings
3
+ * to avoid typos in permission checks.
4
+ *
5
+ * Matches the Ruby class names serialized by the /api/v3/admin/me endpoint.
6
+ */
7
+ export const Subject = {
8
+ All: 'all',
9
+ Product: 'Spree::Product',
10
+ Variant: 'Spree::Variant',
11
+ Order: 'Spree::Order',
12
+ Customer: 'Spree::User',
13
+ CustomerGroup: 'Spree::CustomerGroup',
14
+ AdminUser: 'Spree::AdminUser',
15
+ ApiKey: 'Spree::ApiKey',
16
+ AllowedOrigin: 'Spree::AllowedOrigin',
17
+ Store: 'Spree::Store',
18
+ Channel: 'Spree::Channel',
19
+ // Categories are Spree::Category < Spree::Taxon; abilities are published under
20
+ // the Spree::Taxon subject, so the value stays 'Spree::Taxon' while the key
21
+ // reads as the user-facing resource name.
22
+ Category: 'Spree::Taxon',
23
+ OptionType: 'Spree::OptionType',
24
+ OptionValue: 'Spree::OptionValue',
25
+ TaxCategory: 'Spree::TaxCategory',
26
+ CustomFieldDefinition: 'Spree::MetafieldDefinition',
27
+ PaymentMethod: 'Spree::PaymentMethod',
28
+ ShippingMethod: 'Spree::ShippingMethod',
29
+ StockLocation: 'Spree::StockLocation',
30
+ StockItem: 'Spree::StockItem',
31
+ StockTransfer: 'Spree::StockTransfer',
32
+ PriceList: 'Spree::PriceList',
33
+ PriceRule: 'Spree::PriceRule',
34
+ Promotion: 'Spree::Promotion',
35
+ PromotionAction: 'Spree::PromotionAction',
36
+ PromotionRule: 'Spree::PromotionRule',
37
+ GiftCard: 'Spree::GiftCard',
38
+ Market: 'Spree::Market',
39
+ WebhookEndpoint: 'Spree::WebhookEndpoint',
40
+ WebhookDelivery: 'Spree::WebhookDelivery',
41
+ Wishlist: 'Spree::Wishlist',
42
+ } as const
43
+
44
+ export type SubjectName = (typeof Subject)[keyof typeof Subject] | string
45
+
46
+ /** CanCanCan standard actions */
47
+ export const Action = {
48
+ Manage: 'manage',
49
+ Read: 'read',
50
+ Create: 'create',
51
+ Update: 'update',
52
+ Destroy: 'destroy',
53
+ } as const
54
+
55
+ export type ActionName = (typeof Action)[keyof typeof Action] | string
@@ -0,0 +1,11 @@
1
+ import { QueryClient } from '@tanstack/react-query'
2
+
3
+ export const queryClient = new QueryClient({
4
+ defaultOptions: {
5
+ queries: {
6
+ staleTime: 1000 * 60,
7
+ retry: 1,
8
+ refetchOnWindowFocus: false,
9
+ },
10
+ },
11
+ })
@@ -0,0 +1,69 @@
1
+ import type { QueryKey } from '@tanstack/react-query'
2
+ import { useCallback } from 'react'
3
+ import { useStore } from '../providers/store-provider'
4
+
5
+ /**
6
+ * Canonical TanStack query-key shape for every dashboard resource:
7
+ *
8
+ * [resource, storeId, ...rest]
9
+ *
10
+ * Use `useResourceKey` (the hook) from inside React components to build
11
+ * store-scoped keys — it reads `storeId` from `<StoreProvider>` so callers
12
+ * never spell it themselves:
13
+ *
14
+ * useQuery({ queryKey: useResourceKey('channels', id), ... })
15
+ *
16
+ * The bare `resourceKey()` helper is the same builder without the storeId
17
+ * dependency — keep it for non-hook contexts (tests, build-time defaults).
18
+ *
19
+ * Lists: `useResourceKey('channels')`
20
+ * Singletons: `useResourceKey('channels', id)`
21
+ * Nested: `useResourceKey('products', productId, 'variants')`
22
+ *
23
+ * For invalidation inside `useResourceMutation`, the storeId is auto-injected
24
+ * — pass the logical key without it:
25
+ *
26
+ * invalidate: [['channels'], ['channels', id]]
27
+ */
28
+ export function resourceKey(resource: string, ...rest: ReadonlyArray<unknown>): QueryKey {
29
+ return [resource, ...rest]
30
+ }
31
+
32
+ /**
33
+ * Hook variant of `resourceKey` that auto-injects the current `storeId` from
34
+ * `<StoreProvider>`. The default way to build query keys in dashboard hooks.
35
+ */
36
+ export function useResourceKey(resource: string, ...rest: ReadonlyArray<unknown>): QueryKey {
37
+ const { storeId } = useStore()
38
+ return [resource, storeId, ...rest]
39
+ }
40
+
41
+ /**
42
+ * Returns a stable closure that builds store-scoped query keys. Use this when
43
+ * the key parts (typically an id) are only known later — e.g. inside a
44
+ * mutation's `onSuccess` where `variables.id` is per-call.
45
+ *
46
+ * const buildKey = useResourceKeyBuilder()
47
+ * return useResourceMutation({
48
+ * onSuccess: (_data, id) =>
49
+ * queryClient.removeQueries({ queryKey: buildKey('channels', id) }),
50
+ * })
51
+ */
52
+ export function useResourceKeyBuilder() {
53
+ const { storeId } = useStore()
54
+ return useCallback(
55
+ (resource: string, ...rest: ReadonlyArray<unknown>): QueryKey => [resource, storeId, ...rest],
56
+ [storeId],
57
+ )
58
+ }
59
+
60
+ /**
61
+ * Inject `storeId` at position 1 of a query key, leaving the resource name at
62
+ * position 0 and any further scope after it. Idempotent — if the storeId is
63
+ * already at position 1, returns the input unchanged.
64
+ */
65
+ export function withStoreScope(key: QueryKey, storeId: string): QueryKey {
66
+ if (!Array.isArray(key) || key.length === 0) return key
67
+ if (key[1] === storeId) return key
68
+ return [key[0], storeId, ...key.slice(1)]
69
+ }
@@ -0,0 +1,158 @@
1
+ import type { ComponentType } from 'react'
2
+ import { useSyncExternalStore } from 'react'
3
+ import type { SubjectName } from './permissions'
4
+
5
+ /**
6
+ * Dashboard route contributed by a plugin. Mounted under the dashboard's
7
+ * `/_authenticated/$storeId/_plugins/$` catch-all, which reads this registry
8
+ * at render time and dispatches based on the splat path.
9
+ *
10
+ * Path patterns use TanStack-Router-style param tokens (`$brandId`) inside the
11
+ * plugin's namespace. The dashboard strips the `/$storeId/` prefix before
12
+ * matching, so a plugin pattern of `/brands/$brandId` matches the URL
13
+ * `/store_xyz/brands/br_abc123` with `params: { brandId: 'br_abc123' }`.
14
+ */
15
+ export interface RouteEntry {
16
+ /**
17
+ * Stable identifier — used for register/remove/update and as the React key.
18
+ * Mirrors the slug a plugin would use in its `nav.add({ key })` call so the
19
+ * sidebar entry and route stay paired.
20
+ */
21
+ key: string
22
+ /**
23
+ * Path pattern relative to `/$storeId`. Examples: `/brands`,
24
+ * `/brands/$brandId`, `/wishlists/$wishlistId/items`.
25
+ * Must start with `/`. Do NOT include the `/$storeId` prefix; the dashboard
26
+ * adds it at render time.
27
+ */
28
+ path: string
29
+ /**
30
+ * The component to render. Receives:
31
+ * - `params`: extracted path params (e.g. matching `/brands/$brandId`
32
+ * against `/brands/br_abc` yields `{ brandId: 'br_abc' }`)
33
+ * - `storeId`: the current store — every plugin route is implicitly scoped
34
+ * under `/$storeId/...`
35
+ * - `searchParams`: the URL search-state object from TanStack Router. Use
36
+ * this with `<ResourceTable searchParams={searchParams} ... />` so
37
+ * filter/sort/pagination round-trip through the URL.
38
+ */
39
+ component: ComponentType<{
40
+ params: Record<string, string>
41
+ storeId: string
42
+ searchParams: Record<string, unknown>
43
+ }>
44
+ /**
45
+ * Optional CanCanCan subject required to view the route. The catch-all
46
+ * renderer checks `permissions.can('read', subject)` and renders a 403
47
+ * fallback when denied. Omit for routes that don't need permission gating.
48
+ */
49
+ subject?: SubjectName
50
+ }
51
+
52
+ const entries: RouteEntry[] = []
53
+ const listeners = new Set<() => void>()
54
+
55
+ function notify() {
56
+ for (const l of listeners) l()
57
+ }
58
+
59
+ function subscribe(listener: () => void): () => void {
60
+ listeners.add(listener)
61
+ return () => {
62
+ listeners.delete(listener)
63
+ }
64
+ }
65
+
66
+ function getSnapshot(): readonly RouteEntry[] {
67
+ return entries
68
+ }
69
+
70
+ interface RouteMutator {
71
+ /** Register a route. Throws if `key` is already registered. */
72
+ add(entry: RouteEntry): void
73
+ /** Remove a route by key. No-op when missing. */
74
+ remove(key: string): void
75
+ }
76
+
77
+ export const pluginRoutes: RouteMutator = {
78
+ add(entry) {
79
+ if (entries.some((e) => e.key === entry.key)) {
80
+ throw new Error(`Plugin route "${entry.key}" already registered.`)
81
+ }
82
+ if (!entry.path.startsWith('/')) {
83
+ throw new Error(`Plugin route "${entry.key}" path must start with "/", got "${entry.path}".`)
84
+ }
85
+ entries.push(entry)
86
+ notify()
87
+ },
88
+ remove(key) {
89
+ const i = entries.findIndex((e) => e.key === key)
90
+ if (i === -1) return
91
+ entries.splice(i, 1)
92
+ notify()
93
+ },
94
+ }
95
+
96
+ /**
97
+ * Subscribe to plugin route updates. Returns the full list of registered
98
+ * routes; the catch-all renderer matches against this on every navigation.
99
+ */
100
+ export function usePluginRoutes(): readonly RouteEntry[] {
101
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
102
+ }
103
+
104
+ /**
105
+ * Match a URL splat (the bit after `/$storeId/`) against the registered
106
+ * routes. Returns the matched entry plus the extracted path params, or
107
+ * `null` if nothing matched.
108
+ *
109
+ * Path patterns support TanStack-style `$param` tokens. A pattern segment
110
+ * of `$brandId` matches any non-empty URL segment and binds it to
111
+ * `params.brandId`.
112
+ *
113
+ * More-specific patterns win regardless of registration order: `/brands/new`
114
+ * beats `/brands/$brandId` for the URL `/brands/new` because it has fewer
115
+ * dynamic segments.
116
+ */
117
+ export function matchPluginRoute(
118
+ splat: string,
119
+ routes: readonly RouteEntry[],
120
+ ): { entry: RouteEntry; params: Record<string, string> } | null {
121
+ // Normalize: strip leading slash, drop empty trailing segments.
122
+ const urlSegments = splat.replace(/^\/+/, '').split('/').filter(Boolean)
123
+ const bySpecificity = [...routes].sort(
124
+ (a, b) => countDynamicSegments(a.path) - countDynamicSegments(b.path),
125
+ )
126
+ for (const entry of bySpecificity) {
127
+ const patternSegments = entry.path.replace(/^\/+/, '').split('/').filter(Boolean)
128
+ if (patternSegments.length !== urlSegments.length) continue
129
+
130
+ const params: Record<string, string> = {}
131
+ let matched = true
132
+ for (let i = 0; i < patternSegments.length; i++) {
133
+ const p = patternSegments[i]
134
+ const u = urlSegments[i]
135
+ if (p.startsWith('$')) {
136
+ params[p.slice(1)] = u
137
+ } else if (p !== u) {
138
+ matched = false
139
+ break
140
+ }
141
+ }
142
+ if (matched) return { entry, params }
143
+ }
144
+ return null
145
+ }
146
+
147
+ function countDynamicSegments(path: string): number {
148
+ return path
149
+ .replace(/^\/+/, '')
150
+ .split('/')
151
+ .filter((segment) => segment.startsWith('$')).length
152
+ }
153
+
154
+ /** Test-only: clear the registry. */
155
+ export function __resetPluginRoutes(): void {
156
+ entries.length = 0
157
+ notify()
158
+ }
@@ -0,0 +1,129 @@
1
+ import type { ReactNode } from 'react'
2
+ import { useSyncExternalStore } from 'react'
3
+ import type { SubjectName } from './permissions'
4
+
5
+ /**
6
+ * Registry for the command-palette global resource search. Mirrors
7
+ * `nav-registry.ts`: a module-singleton populated at app boot by side-effect
8
+ * modules (see `@/search/default` in the dashboard app), read reactively via
9
+ * `useSearchEntries`.
10
+ *
11
+ * Each entry teaches the palette how to search one resource type: how to fetch
12
+ * matches, how to render a result row, and where a row navigates. Adding a new
13
+ * searchable resource is one `searchRegistry.add(...)` call — no edits to the
14
+ * palette component or the search hook. Plugins extend search the same way they
15
+ * extend the nav.
16
+ *
17
+ * The result type is erased at the registry boundary (`unknown`): the palette
18
+ * never inspects a result directly, it only hands each one back to the entry's
19
+ * own `renderRow`/`getRoute`/`getKey`, which are typed against the resource.
20
+ * Use `defineSearchEntry` to keep that wiring type-safe at the call site.
21
+ */
22
+
23
+ export interface SearchResultRow {
24
+ /** cmdk value — must be unique across the whole list; prefix with the entry key. */
25
+ value: string
26
+ /** Row contents rendered inside the `CommandItem`. */
27
+ content: ReactNode
28
+ }
29
+
30
+ export interface SearchEntry<T = unknown> {
31
+ /** Stable identifier — register/remove key, React key, and result-value prefix. */
32
+ key: string
33
+ /** i18n key for the group heading (resolved with `t` at render time). */
34
+ headingKey: string
35
+ /** CanCanCan subject gating both the query and the rendered group. Omit for always-on. */
36
+ subject?: SubjectName
37
+ /**
38
+ * Lower numbers render first. Built-ins use 100/200/300… so plugins can slot
39
+ * between them. Also orders the parallel queries, purely cosmetically.
40
+ */
41
+ position?: number
42
+ /**
43
+ * Fetch up to `limit` matches for `query`. Returns the raw resource records;
44
+ * the palette passes each back through this entry's `renderRow`/`getRoute`.
45
+ */
46
+ fetch: (query: string, limit: number) => Promise<T[]>
47
+ /** Stable React key for a single result (typically the prefixed `id`). */
48
+ getKey: (item: T) => string
49
+ /** Render one result row's inner content (icon/thumbnail + label + badges). */
50
+ renderRow: (item: T) => ReactNode
51
+ /** Resolve the destination for a clicked result, given the active store. */
52
+ getRoute: (item: T, storeId: string) => { to: string }
53
+ }
54
+
55
+ interface SearchMutator {
56
+ /** Register an entry. Throws if the key is already registered. */
57
+ add<T>(entry: SearchEntry<T>): void
58
+ /** Remove an entry by key. No-op when missing. */
59
+ remove(key: string): void
60
+ /** Patch an existing entry. Throws if the key is missing. */
61
+ update<T>(key: string, patch: Partial<Omit<SearchEntry<T>, 'key'>>): void
62
+ }
63
+
64
+ const entries: SearchEntry[] = []
65
+ const listeners = new Set<() => void>()
66
+ let snapshotCache: SearchEntry[] | null = null
67
+
68
+ function notify() {
69
+ snapshotCache = null
70
+ for (const l of listeners) l()
71
+ }
72
+
73
+ export const searchRegistry: SearchMutator = {
74
+ add(entry) {
75
+ if (entries.some((e) => e.key === entry.key)) {
76
+ throw new Error(
77
+ `Search entry "${entry.key}" already registered. Use searchRegistry.update().`,
78
+ )
79
+ }
80
+ entries.push(entry as SearchEntry)
81
+ notify()
82
+ },
83
+ remove(key) {
84
+ const i = entries.findIndex((e) => e.key === key)
85
+ if (i === -1) return
86
+ entries.splice(i, 1)
87
+ notify()
88
+ },
89
+ update(key, patch) {
90
+ const e = entries.find((x) => x.key === key)
91
+ if (!e) throw new Error(`Search entry "${key}" not found.`)
92
+ Object.assign(e, patch)
93
+ notify()
94
+ },
95
+ }
96
+
97
+ function subscribe(listener: () => void): () => void {
98
+ listeners.add(listener)
99
+ return () => {
100
+ listeners.delete(listener)
101
+ }
102
+ }
103
+
104
+ function getSnapshot(): SearchEntry[] {
105
+ if (!snapshotCache) {
106
+ snapshotCache = [...entries].sort((a, b) => (a.position ?? 100) - (b.position ?? 100))
107
+ }
108
+ return snapshotCache
109
+ }
110
+
111
+ /** Reactively read the sorted search entries. Re-renders on registry mutation. */
112
+ export function useSearchEntries(): SearchEntry[] {
113
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
114
+ }
115
+
116
+ /**
117
+ * Identity helper that infers `T` from `fetch`/`getKey`/`renderRow`/`getRoute`
118
+ * so the callbacks are typed against the resource at the registration site,
119
+ * while the registry stores the erased `SearchEntry`.
120
+ */
121
+ export function defineSearchEntry<T>(entry: SearchEntry<T>): SearchEntry<T> {
122
+ return entry
123
+ }
124
+
125
+ /** Test-only: clear the registry. */
126
+ export function __resetSearchRegistry(): void {
127
+ entries.length = 0
128
+ notify()
129
+ }
@@ -0,0 +1,158 @@
1
+ import type { LucideIcon } from 'lucide-react'
2
+ import { useSyncExternalStore } from 'react'
3
+ import type { SubjectName } from './permissions'
4
+
5
+ /**
6
+ * Registry for the settings sub-shell sidebar. Mirrors `nav-registry.ts` but
7
+ * adds a `group` key so entries cluster under labelled section headers
8
+ * (Store · Localization · Team & Access · Developer …).
9
+ *
10
+ * Plugins extend the settings nav by importing `settingsNav` and calling
11
+ * `settingsNav.add(...)` from a side-effect module loaded at app boot.
12
+ */
13
+
14
+ export interface SettingsNavEntry {
15
+ /** Stable identifier — used for register/remove/update and as the React key. */
16
+ key: string
17
+ /**
18
+ * Visible label. Either `label` (literal) or `labelKey` (i18n key) must be
19
+ * set; built-in entries use `labelKey` so the sidebar re-renders on
20
+ * language change. Plugins can use either.
21
+ */
22
+ label?: string
23
+ /** i18n key passed to `t(...)` at render time. Takes precedence over `label`. */
24
+ labelKey?: string
25
+ /**
26
+ * Path template, prefixed with `/$storeId/settings` at render time. Pass paths
27
+ * like `'/general'` or `'/staff'`, NOT `/store_abc/settings/general`. The
28
+ * leading slash is required.
29
+ */
30
+ path: string
31
+ /** Icon shown next to the label. */
32
+ icon?: LucideIcon
33
+ /** Group identifier — entries with the same group cluster under one header. */
34
+ group: string
35
+ /** Position within the group. Lower numbers render first. Defaults to 100. */
36
+ position?: number
37
+ /** CanCanCan subject required to see this item. Omit for always-visible. */
38
+ subject?: SubjectName
39
+ /** When true, the page is disabled in the sidebar with a "Soon" badge. */
40
+ comingSoon?: boolean
41
+ }
42
+
43
+ export interface SettingsNavGroup {
44
+ /** Group identifier referenced by entries. */
45
+ key: string
46
+ /** Visible header label. Mirrors `SettingsNavEntry.label` — pair with `labelKey` when localising. */
47
+ label?: string
48
+ /** i18n key passed to `t(...)` at render time. Takes precedence over `label`. */
49
+ labelKey?: string
50
+ /** Group ordering. Lower numbers render first. Defaults to 100. */
51
+ position?: number
52
+ }
53
+
54
+ interface SettingsNavMutator {
55
+ /** Register an entry. Throws if the key is already registered. */
56
+ add(entry: SettingsNavEntry): void
57
+ /** Remove an entry by key. No-op when missing. */
58
+ remove(key: string): void
59
+ /** Patch an existing entry. Throws if the key is missing. */
60
+ update(key: string, patch: Partial<Omit<SettingsNavEntry, 'key'>>): void
61
+ /** Register a group. Throws if the key is already registered. */
62
+ addGroup(group: SettingsNavGroup): void
63
+ }
64
+
65
+ const entries: SettingsNavEntry[] = []
66
+ const groups: SettingsNavGroup[] = []
67
+ const listeners = new Set<() => void>()
68
+ let snapshotCache: SettingsNavSnapshot | null = null
69
+
70
+ export interface SettingsNavSnapshot {
71
+ /** Groups, sorted by `position`, each with their entries (also sorted). */
72
+ groups: Array<{ group: SettingsNavGroup; entries: SettingsNavEntry[] }>
73
+ /** Flat list of every entry, sorted by group position then entry position. */
74
+ all: SettingsNavEntry[]
75
+ }
76
+
77
+ function notify() {
78
+ snapshotCache = null
79
+ for (const l of listeners) l()
80
+ }
81
+
82
+ export const settingsNav: SettingsNavMutator = {
83
+ add(entry) {
84
+ if (!entry.label && !entry.labelKey) {
85
+ throw new Error(`Settings nav entry "${entry.key}" must define either "label" or "labelKey".`)
86
+ }
87
+ if (entries.some((e) => e.key === entry.key)) {
88
+ throw new Error(`Settings nav entry "${entry.key}" already registered.`)
89
+ }
90
+ entries.push(entry)
91
+ notify()
92
+ },
93
+ remove(key) {
94
+ const i = entries.findIndex((e) => e.key === key)
95
+ if (i === -1) return
96
+ entries.splice(i, 1)
97
+ notify()
98
+ },
99
+ update(key, patch) {
100
+ const e = entries.find((x) => x.key === key)
101
+ if (!e) throw new Error(`Settings nav entry "${key}" not found.`)
102
+ Object.assign(e, patch)
103
+ // Re-validate the merged entry — the patch could remove both label fields.
104
+ if (!e.label && !e.labelKey) {
105
+ throw new Error(`Settings nav entry "${e.key}" must define either "label" or "labelKey".`)
106
+ }
107
+ notify()
108
+ },
109
+ addGroup(group) {
110
+ if (!group.label && !group.labelKey) {
111
+ throw new Error(`Settings nav group "${group.key}" must define either "label" or "labelKey".`)
112
+ }
113
+ if (groups.some((g) => g.key === group.key)) {
114
+ throw new Error(`Settings nav group "${group.key}" already registered.`)
115
+ }
116
+ groups.push(group)
117
+ notify()
118
+ },
119
+ }
120
+
121
+ function buildSnapshot(): SettingsNavSnapshot {
122
+ const sortedGroups = [...groups].sort((a, b) => (a.position ?? 100) - (b.position ?? 100))
123
+ const grouped = sortedGroups
124
+ .map((group) => ({
125
+ group,
126
+ entries: entries
127
+ .filter((e) => e.group === group.key)
128
+ .sort((a, b) => (a.position ?? 100) - (b.position ?? 100)),
129
+ }))
130
+ .filter((g) => g.entries.length > 0)
131
+ return {
132
+ groups: grouped,
133
+ all: grouped.flatMap((g) => g.entries),
134
+ }
135
+ }
136
+
137
+ function subscribe(listener: () => void): () => void {
138
+ listeners.add(listener)
139
+ return () => {
140
+ listeners.delete(listener)
141
+ }
142
+ }
143
+
144
+ function getSnapshot() {
145
+ if (!snapshotCache) snapshotCache = buildSnapshot()
146
+ return snapshotCache
147
+ }
148
+
149
+ export function useSettingsNav() {
150
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
151
+ }
152
+
153
+ /** Test-only: clear the registry. */
154
+ export function __resetSettingsNavRegistry(): void {
155
+ entries.length = 0
156
+ groups.length = 0
157
+ notify()
158
+ }
@@ -0,0 +1,116 @@
1
+ import type { ComponentType } from 'react'
2
+ import { useSyncExternalStore } from 'react'
3
+
4
+ // ============================================================================
5
+ // Types
6
+ // ============================================================================
7
+
8
+ /**
9
+ * Ambient context every slot receives, on top of slot-specific context.
10
+ * Populated by SlotProvider (see slot.tsx). Plugins reading these don't need
11
+ * to import auth/permission hooks — the slot machinery passes them through.
12
+ */
13
+ export interface SlotAmbientContext {
14
+ permissions?: unknown
15
+ store?: unknown
16
+ user?: unknown
17
+ }
18
+
19
+ export interface SlotEntry<TContext = unknown> {
20
+ /** Unique within a slot. Required for removeSlot/updateSlot. */
21
+ id: string
22
+ component: ComponentType<TContext & SlotAmbientContext>
23
+ /** Lower numbers render first. Built-ins use 100/200/300 to leave gaps. */
24
+ position?: number
25
+ /** Visibility predicate. Receives the merged context. */
26
+ if?: (ctx: TContext & SlotAmbientContext) => boolean
27
+ }
28
+
29
+ // ============================================================================
30
+ // Registry — module-singleton keyed by slot name
31
+ // ============================================================================
32
+
33
+ const registry = new Map<string, SlotEntry[]>()
34
+ const listeners = new Set<() => void>()
35
+
36
+ function notify() {
37
+ for (const listener of listeners) listener()
38
+ }
39
+
40
+ function subscribe(listener: () => void) {
41
+ listeners.add(listener)
42
+ return () => {
43
+ listeners.delete(listener)
44
+ }
45
+ }
46
+
47
+ function getSnapshot(name: string): readonly SlotEntry[] {
48
+ return registry.get(name) ?? EMPTY
49
+ }
50
+
51
+ const EMPTY: readonly SlotEntry[] = Object.freeze([])
52
+
53
+ // ============================================================================
54
+ // Public API
55
+ // ============================================================================
56
+
57
+ export function registerSlot<TContext = unknown>(name: string, entry: SlotEntry<TContext>): void {
58
+ const list = registry.get(name) ?? []
59
+ if (list.some((e) => e.id === entry.id)) {
60
+ throw new Error(
61
+ `Slot "${name}" already has an entry with id "${entry.id}". Use updateSlot() instead.`,
62
+ )
63
+ }
64
+ registry.set(name, [...list, entry as SlotEntry])
65
+ notify()
66
+ }
67
+
68
+ export function removeSlot(name: string, id: string): void {
69
+ const list = registry.get(name)
70
+ if (!list) return
71
+ const next = list.filter((e) => e.id !== id)
72
+ if (next.length === list.length) return
73
+ registry.set(name, next)
74
+ notify()
75
+ }
76
+
77
+ export function updateSlot<TContext = unknown>(
78
+ name: string,
79
+ id: string,
80
+ patch: Partial<Omit<SlotEntry<TContext>, 'id'>>,
81
+ ): void {
82
+ const list = registry.get(name)
83
+ const entry = list?.find((e) => e.id === id)
84
+ if (!list || !entry) {
85
+ throw new Error(`Slot entry "${name}#${id}" not found.`)
86
+ }
87
+ registry.set(
88
+ name,
89
+ list.map((e) => (e.id === id ? ({ ...e, ...patch } as SlotEntry) : e)),
90
+ )
91
+ notify()
92
+ }
93
+
94
+ /**
95
+ * Subscribe to a slot's entries. Re-renders when entries are added/removed/updated
96
+ * so plugins registered after first render still appear.
97
+ *
98
+ * Returned entries are sorted by position (default 100) and stable within ties.
99
+ * Filtering by `if` happens at render time inside <Slot> — the hook returns the
100
+ * full registered list so the caller can decide how to combine context.
101
+ */
102
+ export function useSlotEntries(name: string): readonly SlotEntry[] {
103
+ const entries = useSyncExternalStore(
104
+ subscribe,
105
+ () => getSnapshot(name),
106
+ () => getSnapshot(name),
107
+ )
108
+ // Sort by position (stable). Returned array is fresh; safe to reference in render.
109
+ return [...entries].sort((a, b) => (a.position ?? 100) - (b.position ?? 100))
110
+ }
111
+
112
+ /** Test-only: clear all registered slots. Not exported from the package index. */
113
+ export function __resetSlotRegistry(): void {
114
+ registry.clear()
115
+ notify()
116
+ }