@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
package/src/plugin.ts ADDED
@@ -0,0 +1,222 @@
1
+ // `@spree/dashboard-core/plugin` — the single import plugin authors use.
2
+ //
3
+ // A plugin's entry module calls `defineDashboardPlugin({...})` at import time;
4
+ // the dashboard app imports plugin entries in its bootstrap before the first
5
+ // render. Each registry uses `useSyncExternalStore`, so late registration
6
+ // (after the app has mounted) still re-renders consumers.
7
+ //
8
+ // Example:
9
+ //
10
+ // import { defineDashboardPlugin } from '@spree/dashboard-core/plugin'
11
+ // import { Card } from '@spree/dashboard-ui'
12
+ //
13
+ // function WishlistCount({ product }: { product: { wishlist_count: number } }) {
14
+ // return <Card>Wishlists: {product.wishlist_count}</Card>
15
+ // }
16
+ //
17
+ // defineDashboardPlugin({
18
+ // nav: [
19
+ // { key: 'wishlists', label: 'Wishlists', path: '/wishlists', position: 50 },
20
+ // ],
21
+ // slots: {
22
+ // 'product.form_sidebar': [
23
+ // { id: 'wishlist-count', component: WishlistCount, position: 50 },
24
+ // ],
25
+ // },
26
+ // tables: {
27
+ // products: { add: [{ key: 'wishlist_count', label: 'Wishlists', sortable: true }] },
28
+ // },
29
+ // settingsNav: [
30
+ // { key: 'wishlist-settings', label: 'Wishlists', path: '/wishlists', group: 'integrations' },
31
+ // ],
32
+ // })
33
+
34
+ import type { CustomFieldComponent } from './lib/custom-field-components'
35
+ import { customFieldComponents } from './lib/custom-field-components'
36
+ import type { FormFieldRegistration } from './lib/form-fields-registry'
37
+ import { formFields } from './lib/form-fields-registry'
38
+ import type { NavEntry } from './lib/nav-registry'
39
+ import { nav } from './lib/nav-registry'
40
+ import type { RouteEntry } from './lib/route-registry'
41
+ import { pluginRoutes } from './lib/route-registry'
42
+ import type { SettingsNavEntry, SettingsNavGroup } from './lib/settings-nav-registry'
43
+ import { settingsNav } from './lib/settings-nav-registry'
44
+ import type { SlotEntry } from './lib/slot-registry'
45
+ import { registerSlot } from './lib/slot-registry'
46
+ import type { ColumnDef } from './lib/table-registry'
47
+ import { tables } from './lib/table-registry'
48
+
49
+ export interface TableMutations {
50
+ /** Columns to append to the table. Keys must not already exist. */
51
+ add?: ColumnDef[]
52
+ /** Column keys to remove. No-op when the column is already gone. */
53
+ remove?: string[]
54
+ /** Column patches keyed by column key — `{ totals: { label: 'Net total' } }`. */
55
+ update?: Record<string, Partial<ColumnDef>>
56
+ }
57
+
58
+ export interface NavMutations {
59
+ /** Sidebar entries to add. Each must have a unique `key`. */
60
+ add?: NavEntry[]
61
+ /** Entry keys to remove — built-in or plugin. No-op when already gone. */
62
+ remove?: string[]
63
+ /**
64
+ * Patches keyed by entry key — reorder via `position`, rename via `label`,
65
+ * gate via `subject`. Throws when the key isn't registered, so patch only
66
+ * entries you know exist (built-ins register before any plugin runs).
67
+ */
68
+ update?: Record<string, Partial<Omit<NavEntry, 'key'>>>
69
+ /**
70
+ * Children to append under existing top-level entries, keyed by parent key
71
+ * — e.g. `{ products: [{ key: 'products.brands', … }] }` nests an item in
72
+ * the built-in Products menu. Preserves the parent's existing children.
73
+ * Throws when the parent is missing or a child key already exists there.
74
+ */
75
+ addChildren?: Record<string, NavEntry[]>
76
+ }
77
+
78
+ export interface SettingsNavMutations {
79
+ /** Settings sub-shell entries to add. */
80
+ add?: SettingsNavEntry[]
81
+ /** Entry keys to remove. No-op when already gone. */
82
+ remove?: string[]
83
+ /** Patches keyed by entry key. Throws when the key isn't registered. */
84
+ update?: Record<string, Partial<Omit<SettingsNavEntry, 'key'>>>
85
+ }
86
+
87
+ export interface DashboardPluginConfig {
88
+ /**
89
+ * Sidebar entries. The array form adds entries; the object form also
90
+ * removes or patches existing ones (built-ins included):
91
+ *
92
+ * nav: {
93
+ * add: [{ key: 'reviews', label: 'Reviews', path: '/reviews' }],
94
+ * remove: ['gift-cards'],
95
+ * update: { products: { position: 10 } },
96
+ * }
97
+ */
98
+ nav?: NavEntry[] | NavMutations
99
+ /** Settings sub-shell groups (defined before any entry that uses the group key). */
100
+ settingsNavGroups?: SettingsNavGroup[]
101
+ /** Settings sub-shell entries — array adds; object form also removes/patches. */
102
+ settingsNav?: SettingsNavEntry[] | SettingsNavMutations
103
+ /** Slot extensions keyed by slot name. Each entry must have a unique `id`. */
104
+ slots?: Record<string, SlotEntry[]>
105
+ /** Table mutations keyed by table key (see `defineTable`). */
106
+ tables?: Record<string, TableMutations>
107
+ /**
108
+ * Custom routes mounted under `/$storeId/`. Each entry's `path` is relative
109
+ * (e.g. `/brands`, `/brands/$brandId`). The dashboard's catch-all route at
110
+ * `/$storeId/*` dispatches based on the splat — your `component` renders
111
+ * exactly like any first-party route, including TanStack Router's
112
+ * `useParams()` for path params.
113
+ */
114
+ routes?: RouteEntry[]
115
+ /**
116
+ * Extension fields on built-in resource forms, keyed by form key (e.g.
117
+ * `'product'`). Each field hydrates from the fetched resource via `from`
118
+ * and persists through the form's own Save — render its input from a slot
119
+ * widget bound via `useHostForm()`.
120
+ */
121
+ formFields?: Record<string, FormFieldRegistration[]>
122
+ /**
123
+ * Input components for specific custom field definitions, keyed by the
124
+ * definition's `namespace.key` (e.g. `'specs.color'`). Replaces the
125
+ * default widget for that definition's `field_type` wherever the
126
+ * custom-fields card renders it.
127
+ */
128
+ customFieldComponents?: Record<string, CustomFieldComponent>
129
+ }
130
+
131
+ /**
132
+ * Register all the extensions in `config` against the dashboard's registries.
133
+ * Call it once per plugin entry module — each registry enforces uniqueness on
134
+ * its keys, so re-running the same config (or colliding with another plugin's
135
+ * `key`/`id`) throws with a useful message rather than double-registering.
136
+ *
137
+ * Plugins typically call this once from their entry module (e.g.
138
+ * `src/index.ts`). The dashboard app imports plugin entries during bootstrap.
139
+ *
140
+ * @returns nothing — registries are module singletons. To unregister, call the
141
+ * relevant `nav.remove` / `removeSlot` / `tables.<key>.removeColumn` /
142
+ * `settingsNav.remove` directly.
143
+ */
144
+ export function defineDashboardPlugin(config: DashboardPluginConfig): void {
145
+ // Collect errors across every registry call and rethrow once at the end.
146
+ // A duplicate nav key shouldn't stop the rest of the config (slots, tables,
147
+ // other nav entries) from registering — plugin authors should see every
148
+ // problem in their config at once, not whack-a-mole through reload cycles.
149
+ // Matches the deferred-flush semantics in `table-registry.ts`.
150
+ const errors: unknown[] = []
151
+ const safely = <T extends unknown[]>(fn: (...args: T) => void, ...args: T) => {
152
+ try {
153
+ fn(...args)
154
+ } catch (err) {
155
+ errors.push(err)
156
+ }
157
+ }
158
+
159
+ if (config.nav) {
160
+ const m: NavMutations = Array.isArray(config.nav) ? { add: config.nav } : config.nav
161
+ // add before addChildren so a plugin can add a parent then nest under it.
162
+ for (const entry of m.add ?? []) safely(nav.add, entry)
163
+ for (const [parentKey, children] of Object.entries(m.addChildren ?? {})) {
164
+ for (const child of children) safely(nav.addChild, parentKey, child)
165
+ }
166
+ for (const key of m.remove ?? []) safely(nav.remove, key)
167
+ for (const [key, patch] of Object.entries(m.update ?? {})) safely(nav.update, key, patch)
168
+ }
169
+
170
+ if (config.settingsNavGroups) {
171
+ for (const group of config.settingsNavGroups) safely(settingsNav.addGroup, group)
172
+ }
173
+
174
+ if (config.settingsNav) {
175
+ const m: SettingsNavMutations = Array.isArray(config.settingsNav)
176
+ ? { add: config.settingsNav }
177
+ : config.settingsNav
178
+ for (const entry of m.add ?? []) safely(settingsNav.add, entry)
179
+ for (const key of m.remove ?? []) safely(settingsNav.remove, key)
180
+ for (const [key, patch] of Object.entries(m.update ?? {})) {
181
+ safely(settingsNav.update, key, patch)
182
+ }
183
+ }
184
+
185
+ if (config.slots) {
186
+ for (const [name, list] of Object.entries(config.slots)) {
187
+ for (const entry of list) safely(registerSlot, name, entry)
188
+ }
189
+ }
190
+
191
+ if (config.tables) {
192
+ for (const [tableKey, mutations] of Object.entries(config.tables)) {
193
+ const mutator = tables[tableKey]
194
+ for (const column of mutations.add ?? []) safely(mutator.addColumn, column)
195
+ for (const key of mutations.remove ?? []) safely(mutator.removeColumn, key)
196
+ for (const [key, patch] of Object.entries(mutations.update ?? {})) {
197
+ safely(mutator.updateColumn, key, patch)
198
+ }
199
+ }
200
+ }
201
+
202
+ if (config.routes) {
203
+ for (const entry of config.routes) safely(pluginRoutes.add, entry)
204
+ }
205
+
206
+ if (config.formFields) {
207
+ for (const [formKey, fields] of Object.entries(config.formFields)) {
208
+ for (const field of fields) safely(formFields.register, formKey, field)
209
+ }
210
+ }
211
+
212
+ if (config.customFieldComponents) {
213
+ for (const [key, component] of Object.entries(config.customFieldComponents)) {
214
+ safely(customFieldComponents.register, key, component)
215
+ }
216
+ }
217
+
218
+ if (errors.length === 1) throw errors[0]
219
+ if (errors.length > 1) {
220
+ throw new AggregateError(errors, `${errors.length} registration(s) failed`)
221
+ }
222
+ }
@@ -0,0 +1,188 @@
1
+ import type {
2
+ AdminUser,
3
+ AuthTokens,
4
+ InvitationAcceptParams,
5
+ PasswordResetParams,
6
+ } from '@spree/admin-sdk'
7
+ import { createContext, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
8
+ import { adminClient } from '../client'
9
+ import { ADMIN_LOCALE_STORAGE_KEY, switchLocale } from '../lib/i18n'
10
+
11
+ interface AuthContextValue {
12
+ user: AdminUser | null
13
+ token: string | null
14
+ isAuthenticated: boolean
15
+ isInitializing: boolean
16
+ isLoading: boolean
17
+ login: (email: string, password: string) => Promise<void>
18
+ logout: () => Promise<void>
19
+ acceptInvitation: (id: string, token: string, params: InvitationAcceptParams) => Promise<void>
20
+ /**
21
+ * Consume a password reset token, set the new password, and sign in — the
22
+ * endpoint issues a session just like login. `requestPasswordReset` (the
23
+ * step that sends the email) is unauthenticated and lives on `adminClient`.
24
+ */
25
+ resetPassword: (token: string, params: PasswordResetParams) => Promise<void>
26
+ /**
27
+ * Merge updated fields into the authenticated user (e.g. after a profile
28
+ * save) so context consumers like the top-bar reflect the change immediately
29
+ * instead of waiting for the next token refresh. No-op when signed out.
30
+ */
31
+ updateUser: (changes: Partial<AdminUser>) => void
32
+ }
33
+
34
+ export const AuthContext = createContext<AuthContextValue | null>(null)
35
+
36
+ // Refresh ~30s before the JWT expires (default 5min TTL).
37
+ const REFRESH_INTERVAL_MS = 4 * 60 * 1000 + 30 * 1000
38
+
39
+ export function AuthProvider({ children }: { children: ReactNode }) {
40
+ const [token, setToken] = useState<string | null>(null)
41
+ const [user, setUser] = useState<AdminUser | null>(null)
42
+ const [isInitializing, setIsInitializing] = useState(true)
43
+ const [isLoading, setIsLoading] = useState(false)
44
+ const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
45
+ // Serialize concurrent refresh calls so StrictMode/HMR/401-retry don't double-rotate.
46
+ const refreshPromiseRef = useRef<Promise<boolean> | null>(null)
47
+
48
+ const clearRefreshTimer = useCallback(() => {
49
+ if (refreshTimerRef.current) {
50
+ clearTimeout(refreshTimerRef.current)
51
+ refreshTimerRef.current = null
52
+ }
53
+ }, [])
54
+
55
+ const applySession = useCallback((accessToken: string, authUser: AdminUser) => {
56
+ adminClient.setToken(accessToken)
57
+ setToken(accessToken)
58
+ setUser(authUser)
59
+ // The account's saved admin language is the source of truth across devices.
60
+ // Compare against the persisted choice (not the live i18n.language): if they
61
+ // already agree, the page booted in the right language and no reload is
62
+ // needed — this also prevents a reload loop on the periodic token refresh.
63
+ const code = authUser.selected_locale
64
+ const stored =
65
+ typeof localStorage !== 'undefined'
66
+ ? (localStorage.getItem(ADMIN_LOCALE_STORAGE_KEY) ?? 'en')
67
+ : 'en'
68
+ if (code && code !== stored) switchLocale(code)
69
+ }, [])
70
+
71
+ const updateUser = useCallback((changes: Partial<AdminUser>) => {
72
+ setUser((current) => (current ? { ...current, ...changes } : current))
73
+ }, [])
74
+
75
+ const clearSession = useCallback(() => {
76
+ adminClient.setToken('')
77
+ setToken(null)
78
+ setUser(null)
79
+ clearRefreshTimer()
80
+ }, [clearRefreshTimer])
81
+
82
+ const doRefresh = useCallback(async (): Promise<boolean> => {
83
+ try {
84
+ const res = await adminClient.auth.refresh()
85
+ applySession(res.token, res.user)
86
+ return true
87
+ } catch {
88
+ clearSession()
89
+ return false
90
+ }
91
+ }, [applySession, clearSession])
92
+
93
+ const refreshAccessToken = useCallback((): Promise<boolean> => {
94
+ if (refreshPromiseRef.current) return refreshPromiseRef.current
95
+ const promise = doRefresh().finally(() => {
96
+ refreshPromiseRef.current = null
97
+ })
98
+ refreshPromiseRef.current = promise
99
+ return promise
100
+ }, [doRefresh])
101
+
102
+ const scheduleRefresh = useCallback(() => {
103
+ clearRefreshTimer()
104
+ refreshTimerRef.current = setTimeout(async () => {
105
+ const success = await refreshAccessToken()
106
+ if (success) scheduleRefresh()
107
+ }, REFRESH_INTERVAL_MS)
108
+ }, [refreshAccessToken, clearRefreshTimer])
109
+
110
+ const establish = useCallback(
111
+ async (req: Promise<AuthTokens>) => {
112
+ setIsLoading(true)
113
+ try {
114
+ const res = await req
115
+ applySession(res.token, res.user)
116
+ scheduleRefresh()
117
+ } finally {
118
+ setIsLoading(false)
119
+ }
120
+ },
121
+ [applySession, scheduleRefresh],
122
+ )
123
+
124
+ const login = useCallback(
125
+ (email: string, password: string) => establish(adminClient.auth.login({ email, password })),
126
+ [establish],
127
+ )
128
+
129
+ const acceptInvitation = useCallback(
130
+ (id: string, token: string, params: InvitationAcceptParams) =>
131
+ establish(adminClient.auth.acceptInvitation(id, token, params)),
132
+ [establish],
133
+ )
134
+
135
+ const resetPassword = useCallback(
136
+ (token: string, params: PasswordResetParams) =>
137
+ establish(adminClient.auth.resetPassword(token, params)),
138
+ [establish],
139
+ )
140
+
141
+ const logout = useCallback(async () => {
142
+ try {
143
+ await adminClient.auth.logout()
144
+ } catch {
145
+ // Server unreachable — clear locally; the row will expire naturally.
146
+ } finally {
147
+ clearSession()
148
+ }
149
+ }, [clearSession])
150
+
151
+ // biome-ignore lint/correctness/useExhaustiveDependencies: only run on mount
152
+ useEffect(() => {
153
+ adminClient.onUnauthorized(async () => {
154
+ const success = await refreshAccessToken()
155
+ if (success) scheduleRefresh()
156
+ return success
157
+ })
158
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
159
+
160
+ // biome-ignore lint/correctness/useExhaustiveDependencies: only run on mount
161
+ useEffect(() => {
162
+ refreshAccessToken()
163
+ .then((success) => {
164
+ if (success) scheduleRefresh()
165
+ })
166
+ .finally(() => setIsInitializing(false))
167
+ return clearRefreshTimer
168
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
169
+
170
+ return (
171
+ <AuthContext.Provider
172
+ value={{
173
+ user,
174
+ token,
175
+ isAuthenticated: !!token,
176
+ isInitializing,
177
+ isLoading,
178
+ login,
179
+ logout,
180
+ acceptInvitation,
181
+ resetPassword,
182
+ updateUser,
183
+ }}
184
+ >
185
+ {children}
186
+ </AuthContext.Provider>
187
+ )
188
+ }
@@ -0,0 +1,118 @@
1
+ import type { PermissionRule } from '@spree/admin-sdk'
2
+ import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react'
3
+ import { adminClient } from '../client'
4
+ import { useAuth } from '../hooks/use-auth'
5
+ import type { ActionName, SubjectName } from '../lib/permissions'
6
+
7
+ /**
8
+ * Matcher that mirrors CanCanCan semantics:
9
+ * - `manage` action matches any action
10
+ * - `all` subject matches any subject
11
+ * - Later rules override earlier rules (last-matching-wins)
12
+ *
13
+ * Per-record conditions are NOT evaluated — they stay on the server. The
14
+ * `isConditional` flag indicates that a rule may still be rejected at the
15
+ * per-record level, so the UI should expect possible 403 from the API.
16
+ */
17
+ export interface Permissions {
18
+ can: (action: ActionName, subject: SubjectName) => boolean
19
+ cannot: (action: ActionName, subject: SubjectName) => boolean
20
+ /** True if the matching rule has per-record conditions. Caller should expect possible 403. */
21
+ isConditional: (action: ActionName, subject: SubjectName) => boolean
22
+ }
23
+
24
+ interface PermissionContextValue {
25
+ permissions: Permissions
26
+ rules: PermissionRule[]
27
+ isLoading: boolean
28
+ }
29
+
30
+ const PermissionContext = createContext<PermissionContextValue | null>(null)
31
+
32
+ function ruleMatches(rule: PermissionRule, action: string, subject: string): boolean {
33
+ const actionMatch = rule.actions.includes(action) || rule.actions.includes('manage')
34
+ const subjectMatch = rule.subjects.includes(subject) || rule.subjects.includes('all')
35
+ return actionMatch && subjectMatch
36
+ }
37
+
38
+ export function buildPermissions(rules: PermissionRule[]): Permissions {
39
+ const can = (action: ActionName, subject: SubjectName): boolean => {
40
+ // Walk rules in order — later matching rules override earlier ones
41
+ let allowed = false
42
+ for (const rule of rules) {
43
+ if (ruleMatches(rule, action, subject)) {
44
+ allowed = rule.allow
45
+ }
46
+ }
47
+ return allowed
48
+ }
49
+
50
+ return {
51
+ can,
52
+ cannot: (action, subject) => !can(action, subject),
53
+ isConditional: (action, subject) => {
54
+ let conditional = false
55
+ for (const rule of rules) {
56
+ if (ruleMatches(rule, action, subject) && rule.allow && rule.has_conditions) {
57
+ conditional = true
58
+ }
59
+ }
60
+ return conditional
61
+ },
62
+ }
63
+ }
64
+
65
+ /** Empty permissions that deny everything — used before rules are loaded. */
66
+ const EMPTY_PERMISSIONS: Permissions = {
67
+ can: () => false,
68
+ cannot: () => true,
69
+ isConditional: () => false,
70
+ }
71
+
72
+ export function PermissionProvider({ children }: { children: ReactNode }) {
73
+ const { isAuthenticated } = useAuth()
74
+ const [rules, setRules] = useState<PermissionRule[]>([])
75
+ const [isLoading, setIsLoading] = useState(false)
76
+
77
+ useEffect(() => {
78
+ if (!isAuthenticated) {
79
+ setRules([])
80
+ return
81
+ }
82
+
83
+ let cancelled = false
84
+ setIsLoading(true)
85
+ adminClient.me
86
+ .get()
87
+ .then((res) => {
88
+ if (!cancelled) setRules(res.permissions)
89
+ })
90
+ .catch(() => {
91
+ if (!cancelled) setRules([])
92
+ })
93
+ .finally(() => {
94
+ if (!cancelled) setIsLoading(false)
95
+ })
96
+
97
+ return () => {
98
+ cancelled = true
99
+ }
100
+ }, [isAuthenticated])
101
+
102
+ const permissions = useMemo(
103
+ () => (rules.length > 0 ? buildPermissions(rules) : EMPTY_PERMISSIONS),
104
+ [rules],
105
+ )
106
+
107
+ return (
108
+ <PermissionContext.Provider value={{ permissions, rules, isLoading }}>
109
+ {children}
110
+ </PermissionContext.Provider>
111
+ )
112
+ }
113
+
114
+ export function usePermissions(): PermissionContextValue {
115
+ const ctx = useContext(PermissionContext)
116
+ if (!ctx) throw new Error('usePermissions must be used within a PermissionProvider')
117
+ return ctx
118
+ }
@@ -0,0 +1,111 @@
1
+ import type { Store } from '@spree/admin-sdk'
2
+ import { useQuery } from '@tanstack/react-query'
3
+ import { createContext, type ReactNode, useContext, useEffect, useRef } from 'react'
4
+ import { adminClient } from '../client'
5
+ import { useAuth } from '../hooks/use-auth'
6
+ import { coreLocaleCodes, reconcileStoreDefaultLocale } from '../lib/i18n'
7
+
8
+ /**
9
+ * Resource segment of the store query key: the current store lives in the
10
+ * TanStack cache under `['store', storeId]` (the canonical
11
+ * `[resource, storeId]` shape). Mutations that change anything the store
12
+ * payload derives from — setup-task completion, supported locales/currencies —
13
+ * add `[[STORE_QUERY_RESOURCE]]` to their `useResourceMutation` invalidate
14
+ * list so every consumer (nav badge, Getting Started, pickers) refreshes.
15
+ */
16
+ export const STORE_QUERY_RESOURCE = 'store'
17
+
18
+ interface StoreContextValue {
19
+ store: Store | null
20
+ storeId: string
21
+ isLoading: boolean
22
+ currencies: string[]
23
+ /** Locales this store is configured to translate content into. */
24
+ locales: string[]
25
+ /** Every locale a merchant may translate content into — the full canonical
26
+ * set, independent of which locales the store currently uses. Drives locale
27
+ * pickers so a new locale can always be added. */
28
+ availableLocales: string[]
29
+ defaultCurrency: string
30
+ defaultLocale: string
31
+ /** IANA timezone for the store (e.g. `Europe/Berlin`). Falls back to the
32
+ * browser's resolved timezone when the store hasn't loaded yet. */
33
+ timezone: string
34
+ refetch: () => Promise<unknown>
35
+ }
36
+
37
+ const StoreContext = createContext<StoreContextValue | null>(null)
38
+
39
+ export function StoreProvider({ storeId, children }: { storeId: string; children: ReactNode }) {
40
+ const { user } = useAuth()
41
+
42
+ // Read the account locale through a ref so a `user` identity change (frequent —
43
+ // every auth update) doesn't re-trigger the locale-fallback effect below.
44
+ const accountLocaleRef = useRef<string | null>(user?.selected_locale ?? null)
45
+ accountLocaleRef.current = user?.selected_locale ?? null
46
+ // Run the store-default fallback only on the FIRST load of each store. A
47
+ // later refetch (e.g. after saving store settings) must not re-apply it:
48
+ // that path already owns the locale via `switchAdminLocale`, and re-applying
49
+ // would race that PATCH and could reload before it lands. Tracking the
50
+ // storeId it last ran for re-arms it per store, so a multi-store admin still
51
+ // inherits each store's preferred_admin_locale.
52
+ const localeFallbackStoreRef = useRef<string | null>(null)
53
+
54
+ const query = useQuery({
55
+ // Keyed per store, so a slow in-flight fetch for a store the route already
56
+ // left can never clobber the current store's cache entry.
57
+ queryKey: [STORE_QUERY_RESOURCE, storeId],
58
+ queryFn: () => adminClient.store.get(),
59
+ })
60
+ const store = query.data ?? null
61
+
62
+ // Reconcile the admin language against this store's default — adopting it,
63
+ // or dropping a now-stale auto-applied default — only when no account locale
64
+ // or genuine personal choice owns it (legacy base_controller parity).
65
+ useEffect(() => {
66
+ if (!store || localeFallbackStoreRef.current === storeId) return
67
+
68
+ localeFallbackStoreRef.current = storeId
69
+ reconcileStoreDefaultLocale(
70
+ store.preferred_admin_locale,
71
+ storeId,
72
+ accountLocaleRef.current,
73
+ coreLocaleCodes(),
74
+ )
75
+ }, [store, storeId])
76
+
77
+ const currencies = store?.supported_currencies ?? []
78
+ const locales = store?.supported_locales ?? []
79
+ const availableLocales = store?.available_locales ?? []
80
+ const defaultCurrency = store?.default_currency ?? 'USD'
81
+ const defaultLocale = store?.default_locale ?? 'en'
82
+ const timezone =
83
+ store?.preferred_timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC'
84
+
85
+ return (
86
+ <StoreContext.Provider
87
+ value={{
88
+ store,
89
+ storeId,
90
+ isLoading: query.isLoading,
91
+ currencies,
92
+ locales,
93
+ availableLocales,
94
+ defaultCurrency,
95
+ defaultLocale,
96
+ timezone,
97
+ refetch: query.refetch,
98
+ }}
99
+ >
100
+ {children}
101
+ </StoreContext.Provider>
102
+ )
103
+ }
104
+
105
+ export function useStore(): StoreContextValue {
106
+ const context = useContext(StoreContext)
107
+ if (!context) {
108
+ throw new Error('useStore must be used within a StoreProvider')
109
+ }
110
+ return context
111
+ }
@@ -0,0 +1,5 @@
1
+ // Ambient declaration for the module synthesized by `spreeDashboardPlugin()`
2
+ // (see src/vite/index.ts). Referenced from src/index.ts so any program that
3
+ // imports @spree/dashboard-core can `import 'virtual:spree-dashboard-plugins'`
4
+ // without its own shim.
5
+ declare module 'virtual:spree-dashboard-plugins'