@spree/dashboard-core 0.13.1 → 1.0.0-beta.2
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/README.md +4 -0
- package/dist/vite/index.js +17 -3
- package/package.json +7 -7
- package/src/api-client.ts +643 -0
- package/src/components/address-form-dialog.test.ts +25 -0
- package/src/components/address-form-dialog.tsx +203 -79
- package/src/components/app-shell.tsx +171 -0
- package/src/components/app-sidebar.tsx +184 -27
- package/src/components/bulk-action-bar.tsx +89 -157
- package/src/components/country-combobox.tsx +69 -34
- package/src/components/country-state-fields.tsx +43 -11
- package/src/components/currency-select.tsx +81 -66
- package/src/components/export-button.tsx +62 -5
- package/src/components/file-upload-field.tsx +32 -10
- package/src/components/import-button.tsx +52 -27
- package/src/components/import-wizard-dialog.tsx +710 -0
- package/src/components/locale-select.tsx +70 -57
- package/src/components/media-picker-sheet.tsx +307 -0
- package/src/components/mobile-top-bar.tsx +75 -0
- package/src/components/nav-main.tsx +197 -27
- package/src/components/page-header.tsx +203 -61
- package/src/components/page-tabs.tsx +4 -1
- package/src/components/preferences-form.tsx +98 -4
- package/src/components/resource-combobox.tsx +52 -4
- package/src/components/resource-picker-sheet.tsx +209 -73
- package/src/components/resource-table.tsx +383 -127
- package/src/components/route-announcer.tsx +64 -0
- package/src/components/section-heading.tsx +95 -0
- package/src/components/settings-sidebar.tsx +290 -60
- package/src/components/sheet-top-bar.tsx +44 -0
- package/src/components/sidebar-search.tsx +83 -0
- package/src/components/sidebar-user.tsx +213 -0
- package/src/components/skip-link.tsx +36 -0
- package/src/components/store-date-picker.tsx +6 -2
- package/src/components/store-switcher.tsx +19 -14
- package/src/components/table-toolbar.tsx +1357 -440
- package/src/components/tag-combobox.tsx +12 -4
- package/src/components/top-bar-breadcrumbs.tsx +235 -0
- package/src/components/wizard-close-guard.test.ts +54 -0
- package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
- package/src/hooks/use-command-palette.tsx +13 -0
- package/src/hooks/use-countries.test.ts +56 -0
- package/src/hooks/use-countries.ts +51 -5
- package/src/hooks/use-direct-upload.ts +16 -3
- package/src/hooks/use-export.ts +51 -16
- package/src/hooks/use-import.ts +241 -16
- package/src/hooks/use-package-types.ts +132 -0
- package/src/hooks/use-resource-mutation.ts +74 -13
- package/src/hooks/use-stock-locations.ts +131 -0
- package/src/hooks/use-switch-admin-locale.ts +2 -2
- package/src/hooks/use-visual-viewport-offset.ts +61 -0
- package/src/index.ts +45 -1
- package/src/lib/cancel-order-form.ts +27 -0
- package/src/lib/create-action-registry.ts +278 -0
- package/src/lib/date-presets.test.ts +86 -0
- package/src/lib/date-presets.ts +98 -0
- package/src/lib/delivery-form.ts +15 -0
- package/src/lib/docs.ts +32 -0
- package/src/lib/download.ts +13 -1
- package/src/lib/filters-to-ransack.test.ts +113 -0
- package/src/lib/filters-to-ransack.ts +22 -1
- package/src/lib/form-errors.ts +122 -10
- package/src/lib/form-mappers.ts +76 -0
- package/src/lib/fulfillment-items.test.ts +213 -0
- package/src/lib/fulfillment-items.ts +171 -0
- package/src/lib/i18n.ts +19 -11
- package/src/lib/import-types.ts +25 -0
- package/src/lib/label-upload-form.ts +22 -0
- package/src/lib/nav-registry.ts +71 -3
- package/src/lib/permissions.ts +46 -1
- package/src/lib/progress.ts +18 -0
- package/src/lib/query-keys.ts +13 -13
- package/src/lib/sample-csv.ts +9 -6
- package/src/lib/search-registry.ts +6 -2
- package/src/lib/settings-nav-registry.ts +104 -11
- package/src/lib/table-registry.ts +50 -0
- package/src/lib/type-labels.test.ts +68 -0
- package/src/lib/type-labels.ts +78 -0
- package/src/locales/ar.json +1889 -517
- package/src/locales/de.json +1884 -517
- package/src/locales/en.json +1886 -519
- package/src/locales/fr.json +1884 -517
- package/src/locales/pl.json +1913 -517
- package/src/locales/zh-CN.json +1883 -517
- package/src/pages/package-types.tsx +484 -0
- package/src/pages/settings-index.tsx +112 -0
- package/src/pages/stock-locations.tsx +674 -0
- package/src/plugin.ts +29 -0
- package/src/products/add-video-dialog.tsx +100 -0
- package/src/products/bulk-variants-dialog.tsx +301 -0
- package/src/products/carton-size.test.ts +51 -0
- package/src/products/carton-size.ts +20 -0
- package/src/products/currency-parts.ts +21 -0
- package/src/products/inventory-section.tsx +259 -0
- package/src/products/media-edit-sheet.tsx +336 -0
- package/src/products/normalize-customs.test.ts +39 -0
- package/src/products/normalize-customs.ts +27 -0
- package/src/products/normalize-money.test.ts +58 -0
- package/src/products/normalize-money.ts +73 -0
- package/src/products/normalize-quantity.test.ts +32 -0
- package/src/products/normalize-quantity.ts +31 -0
- package/src/products/product-bulk-price-editor.tsx +165 -0
- package/src/products/product-form-cards.tsx +1167 -0
- package/src/products/product-form-mapping.ts +212 -0
- package/src/products/product-schema.ts +318 -0
- package/src/products/product-types.ts +130 -0
- package/src/products/use-product-form-data.ts +273 -0
- package/src/products/variant-edit-sheet.tsx +725 -0
- package/src/products/variants-matrix.test.ts +154 -0
- package/src/products/variants-matrix.ts +244 -0
- package/src/products/variants-options-builder.tsx +571 -0
- package/src/products/variants-section.tsx +535 -0
- package/src/products/video-url.test.ts +71 -0
- package/src/products/video-url.ts +83 -0
- package/src/providers/auth-provider.tsx +70 -12
- package/src/providers/permission-provider.tsx +68 -28
- package/src/providers/sticky-header-provider.tsx +134 -0
- package/src/providers/store-provider.tsx +36 -3
- package/src/providers/tenant-provider.tsx +33 -0
- package/src/schemas/package-type.ts +95 -0
- package/src/schemas/stock-location.ts +126 -0
- package/src/tables/package-types.tsx +74 -0
- package/src/tables/stock-locations.tsx +124 -0
- package/src/vite/index.ts +40 -7
- package/src/components/top-bar.tsx +0 -209
|
@@ -3,10 +3,12 @@ import type {
|
|
|
3
3
|
AuthTokens,
|
|
4
4
|
InvitationAcceptParams,
|
|
5
5
|
PasswordResetParams,
|
|
6
|
+
SetupParams,
|
|
6
7
|
} from '@spree/admin-sdk'
|
|
7
8
|
import { createContext, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
|
8
|
-
import {
|
|
9
|
+
import { getApiClient, type PanelSession } from '../api-client'
|
|
9
10
|
import { ADMIN_LOCALE_STORAGE_KEY, switchLocale } from '../lib/i18n'
|
|
11
|
+
import { queryClient } from '../lib/query-client'
|
|
10
12
|
|
|
11
13
|
interface AuthContextValue {
|
|
12
14
|
user: AdminUser | null
|
|
@@ -14,15 +16,27 @@ interface AuthContextValue {
|
|
|
14
16
|
isAuthenticated: boolean
|
|
15
17
|
isInitializing: boolean
|
|
16
18
|
isLoading: boolean
|
|
17
|
-
login: (email: string, password: string) => Promise<
|
|
19
|
+
login: (email: string, password: string) => Promise<AuthTokens>
|
|
18
20
|
logout: () => Promise<void>
|
|
19
|
-
acceptInvitation: (
|
|
21
|
+
acceptInvitation: (
|
|
22
|
+
id: string,
|
|
23
|
+
token: string,
|
|
24
|
+
params: InvitationAcceptParams,
|
|
25
|
+
) => Promise<AuthTokens>
|
|
26
|
+
/**
|
|
27
|
+
* Complete first-run setup (create the first admin account) and sign in —
|
|
28
|
+
* the endpoint issues a session just like login. `setupStatus` (the
|
|
29
|
+
* availability check) is unauthenticated and lives on `adminClient`.
|
|
30
|
+
* Resolves with the session so the caller can route straight to the store
|
|
31
|
+
* it just claimed.
|
|
32
|
+
*/
|
|
33
|
+
completeSetup: (params: SetupParams) => Promise<AuthTokens>
|
|
20
34
|
/**
|
|
21
35
|
* Consume a password reset token, set the new password, and sign in — the
|
|
22
36
|
* endpoint issues a session just like login. `requestPasswordReset` (the
|
|
23
37
|
* step that sends the email) is unauthenticated and lives on `adminClient`.
|
|
24
38
|
*/
|
|
25
|
-
resetPassword: (token: string, params: PasswordResetParams) => Promise<
|
|
39
|
+
resetPassword: (token: string, params: PasswordResetParams) => Promise<AuthTokens>
|
|
26
40
|
/**
|
|
27
41
|
* Merge updated fields into the authenticated user (e.g. after a profile
|
|
28
42
|
* save) so context consumers like the top-bar reflect the change immediately
|
|
@@ -36,6 +50,27 @@ export const AuthContext = createContext<AuthContextValue | null>(null)
|
|
|
36
50
|
// Refresh ~30s before the JWT expires (default 5min TTL).
|
|
37
51
|
const REFRESH_INTERVAL_MS = 4 * 60 * 1000 + 30 * 1000
|
|
38
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Reaches an admin-only sign-in flow. A seller's panel has none of these, so
|
|
55
|
+
* calling one there is a bug in the host rather than a runtime condition to
|
|
56
|
+
* handle — it fails here with a name rather than as `undefined is not a
|
|
57
|
+
* function` somewhere further away.
|
|
58
|
+
*/
|
|
59
|
+
function requireAuthMethod<K extends 'acceptInvitation' | 'resetPassword' | 'completeSetup'>(
|
|
60
|
+
name: K,
|
|
61
|
+
// The three flows take different arguments; their concrete types live on
|
|
62
|
+
// each panel's own client, so this signature stays deliberately loose.
|
|
63
|
+
): (...args: any[]) => Promise<PanelSession> {
|
|
64
|
+
const method = getApiClient().auth[name] as
|
|
65
|
+
| ((...args: any[]) => Promise<PanelSession>)
|
|
66
|
+
| undefined
|
|
67
|
+
if (typeof method !== 'function') {
|
|
68
|
+
throw new Error(`@spree/dashboard-core: this panel's API client has no auth.${name}()`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return method.bind(getApiClient().auth) as (...args: any[]) => Promise<PanelSession>
|
|
72
|
+
}
|
|
73
|
+
|
|
39
74
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
40
75
|
const [token, setToken] = useState<string | null>(null)
|
|
41
76
|
const [user, setUser] = useState<AdminUser | null>(null)
|
|
@@ -53,7 +88,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|
|
53
88
|
}, [])
|
|
54
89
|
|
|
55
90
|
const applySession = useCallback((accessToken: string, authUser: AdminUser) => {
|
|
56
|
-
|
|
91
|
+
getApiClient().setToken(accessToken)
|
|
57
92
|
setToken(accessToken)
|
|
58
93
|
setUser(authUser)
|
|
59
94
|
// The account's saved admin language is the source of truth across devices.
|
|
@@ -73,7 +108,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|
|
73
108
|
}, [])
|
|
74
109
|
|
|
75
110
|
const clearSession = useCallback(() => {
|
|
76
|
-
|
|
111
|
+
const client = getApiClient()
|
|
112
|
+
client.setToken('')
|
|
113
|
+
// The tenant header is session state too — the store on the admin panel,
|
|
114
|
+
// the seller on a seller's. Left set, it would ride into the next
|
|
115
|
+
// principal's first requests (permissions, the index redirect) and 403
|
|
116
|
+
// them against a tenant they may hold no role on.
|
|
117
|
+
client.clearTenant?.()
|
|
118
|
+
// Everything fetched as the previous principal, permissions included. The
|
|
119
|
+
// permission query stops running once `isAuthenticated` goes false, but its
|
|
120
|
+
// cached rules survive — so the next admin to sign in on this browser would
|
|
121
|
+
// render the last one's navigation and action buttons until the refetch
|
|
122
|
+
// landed. Server-side authorization is unaffected either way; this is about
|
|
123
|
+
// not showing one principal what another may do.
|
|
124
|
+
queryClient.clear()
|
|
77
125
|
setToken(null)
|
|
78
126
|
setUser(null)
|
|
79
127
|
clearRefreshTimer()
|
|
@@ -81,7 +129,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|
|
81
129
|
|
|
82
130
|
const doRefresh = useCallback(async (): Promise<boolean> => {
|
|
83
131
|
try {
|
|
84
|
-
const res = await
|
|
132
|
+
const res = await getApiClient().auth.refresh()
|
|
85
133
|
applySession(res.token, res.user)
|
|
86
134
|
return true
|
|
87
135
|
} catch {
|
|
@@ -107,6 +155,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|
|
107
155
|
}, REFRESH_INTERVAL_MS)
|
|
108
156
|
}, [refreshAccessToken, clearRefreshTimer])
|
|
109
157
|
|
|
158
|
+
// Returns the response so callers that need something from it — the setup
|
|
159
|
+
// screen reads the new store's id to land on — don't have to wait for the
|
|
160
|
+
// provider's state to settle.
|
|
110
161
|
const establish = useCallback(
|
|
111
162
|
async (req: Promise<AuthTokens>) => {
|
|
112
163
|
setIsLoading(true)
|
|
@@ -114,6 +165,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|
|
114
165
|
const res = await req
|
|
115
166
|
applySession(res.token, res.user)
|
|
116
167
|
scheduleRefresh()
|
|
168
|
+
return res
|
|
117
169
|
} finally {
|
|
118
170
|
setIsLoading(false)
|
|
119
171
|
}
|
|
@@ -122,25 +174,30 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|
|
122
174
|
)
|
|
123
175
|
|
|
124
176
|
const login = useCallback(
|
|
125
|
-
(email: string, password: string) => establish(
|
|
177
|
+
(email: string, password: string) => establish(getApiClient().auth.login({ email, password })),
|
|
126
178
|
[establish],
|
|
127
179
|
)
|
|
128
180
|
|
|
129
181
|
const acceptInvitation = useCallback(
|
|
130
182
|
(id: string, token: string, params: InvitationAcceptParams) =>
|
|
131
|
-
establish(
|
|
183
|
+
establish(requireAuthMethod('acceptInvitation')(id, token, params)),
|
|
132
184
|
[establish],
|
|
133
185
|
)
|
|
134
186
|
|
|
135
187
|
const resetPassword = useCallback(
|
|
136
188
|
(token: string, params: PasswordResetParams) =>
|
|
137
|
-
establish(
|
|
189
|
+
establish(requireAuthMethod('resetPassword')(token, params)),
|
|
190
|
+
[establish],
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
const completeSetup = useCallback(
|
|
194
|
+
(params: SetupParams) => establish(requireAuthMethod('completeSetup')(params)),
|
|
138
195
|
[establish],
|
|
139
196
|
)
|
|
140
197
|
|
|
141
198
|
const logout = useCallback(async () => {
|
|
142
199
|
try {
|
|
143
|
-
await
|
|
200
|
+
await getApiClient().auth.logout()
|
|
144
201
|
} catch {
|
|
145
202
|
// Server unreachable — clear locally; the row will expire naturally.
|
|
146
203
|
} finally {
|
|
@@ -150,7 +207,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|
|
150
207
|
|
|
151
208
|
// biome-ignore lint/correctness/useExhaustiveDependencies: only run on mount
|
|
152
209
|
useEffect(() => {
|
|
153
|
-
|
|
210
|
+
getApiClient().onUnauthorized(async () => {
|
|
154
211
|
const success = await refreshAccessToken()
|
|
155
212
|
if (success) scheduleRefresh()
|
|
156
213
|
return success
|
|
@@ -178,6 +235,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|
|
178
235
|
login,
|
|
179
236
|
logout,
|
|
180
237
|
acceptInvitation,
|
|
238
|
+
completeSetup,
|
|
181
239
|
resetPassword,
|
|
182
240
|
updateUser,
|
|
183
241
|
}}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { PermissionRule } from '@spree/admin-sdk'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
3
|
+
import { createContext, type ReactNode, useCallback, useContext, useMemo } from 'react'
|
|
4
|
+
import { getApiClient } from '../api-client'
|
|
4
5
|
import { useAuth } from '../hooks/use-auth'
|
|
5
6
|
import type { ActionName, SubjectName } from '../lib/permissions'
|
|
7
|
+
import { useResourceKey } from '../lib/query-keys'
|
|
8
|
+
import { useTenantId } from './tenant-provider'
|
|
6
9
|
|
|
7
10
|
/**
|
|
8
11
|
* Matcher that mirrors CanCanCan semantics:
|
|
@@ -24,7 +27,18 @@ export interface Permissions {
|
|
|
24
27
|
interface PermissionContextValue {
|
|
25
28
|
permissions: Permissions
|
|
26
29
|
rules: PermissionRule[]
|
|
30
|
+
/**
|
|
31
|
+
* The flat expanded catalog permission keys the user holds on the current
|
|
32
|
+
* store (`read_orders`, `write_products`, …) — the same vocabulary as API
|
|
33
|
+
* key scopes and the role editor.
|
|
34
|
+
*/
|
|
35
|
+
permissionKeys: string[]
|
|
27
36
|
isLoading: boolean
|
|
37
|
+
/**
|
|
38
|
+
* Reloads `/me`. Permissions are store-scoped (roles are held per store),
|
|
39
|
+
* so the store route calls this whenever the active store changes.
|
|
40
|
+
*/
|
|
41
|
+
refresh: () => Promise<void>
|
|
28
42
|
}
|
|
29
43
|
|
|
30
44
|
const PermissionContext = createContext<PermissionContextValue | null>(null)
|
|
@@ -62,6 +76,17 @@ export function buildPermissions(rules: PermissionRule[]): Permissions {
|
|
|
62
76
|
}
|
|
63
77
|
}
|
|
64
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Query resource name for the permission fetch. Exported so anything else
|
|
81
|
+
* reading the same `/me` response can share this cache entry rather than
|
|
82
|
+
* issuing a second identical request.
|
|
83
|
+
*/
|
|
84
|
+
export const PERMISSIONS_RESOURCE = 'permissions'
|
|
85
|
+
|
|
86
|
+
/** Stable identities, so `data ?? []` doesn't produce a new array each render. */
|
|
87
|
+
const EMPTY_RULES: PermissionRule[] = []
|
|
88
|
+
const EMPTY_KEYS: string[] = []
|
|
89
|
+
|
|
65
90
|
/** Empty permissions that deny everything — used before rules are loaded. */
|
|
66
91
|
const EMPTY_PERMISSIONS: Permissions = {
|
|
67
92
|
can: () => false,
|
|
@@ -71,33 +96,33 @@ const EMPTY_PERMISSIONS: Permissions = {
|
|
|
71
96
|
|
|
72
97
|
export function PermissionProvider({ children }: { children: ReactNode }) {
|
|
73
98
|
const { isAuthenticated } = useAuth()
|
|
74
|
-
const
|
|
75
|
-
|
|
99
|
+
const queryClient = useQueryClient()
|
|
100
|
+
// Store-scoped by construction: `useResourceKey` folds the tenant id in, so
|
|
101
|
+
// switching store changes the key and TanStack refetches on its own — no
|
|
102
|
+
// manual reload, and no chance of one store's rules being shown for another.
|
|
103
|
+
const queryKey = useResourceKey(PERMISSIONS_RESOURCE)
|
|
76
104
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
105
|
+
const query = useQuery({
|
|
106
|
+
queryKey,
|
|
107
|
+
queryFn: () => getApiClient().fetchPermissions(),
|
|
108
|
+
enabled: isAuthenticated,
|
|
109
|
+
// Permissions change when an admin's role does, which is rare and happens
|
|
110
|
+
// elsewhere. Refetching them on every window focus costs a request on the
|
|
111
|
+
// shell's critical path and almost never returns anything new.
|
|
112
|
+
staleTime: 5 * 60 * 1000,
|
|
113
|
+
})
|
|
82
114
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (!cancelled) setIsLoading(false)
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
return () => {
|
|
98
|
-
cancelled = true
|
|
99
|
-
}
|
|
100
|
-
}, [isAuthenticated])
|
|
115
|
+
const rules = query.data?.rules ?? EMPTY_RULES
|
|
116
|
+
const permissionKeys = query.data?.keys ?? EMPTY_KEYS
|
|
117
|
+
|
|
118
|
+
// `useResourceKey` returns a fresh array each render, so depending on it
|
|
119
|
+
// directly would give `refresh` a new identity every render — and a consumer
|
|
120
|
+
// holding it in an effect's dependencies would then re-run that effect
|
|
121
|
+
// forever. Depend on the tenant id (a string) and rebuild the key inside.
|
|
122
|
+
const tenantId = useTenantId()
|
|
123
|
+
const refresh = useCallback(async () => {
|
|
124
|
+
await queryClient.invalidateQueries({ queryKey: [PERMISSIONS_RESOURCE, tenantId] })
|
|
125
|
+
}, [queryClient, tenantId])
|
|
101
126
|
|
|
102
127
|
const permissions = useMemo(
|
|
103
128
|
() => (rules.length > 0 ? buildPermissions(rules) : EMPTY_PERMISSIONS),
|
|
@@ -105,7 +130,22 @@ export function PermissionProvider({ children }: { children: ReactNode }) {
|
|
|
105
130
|
)
|
|
106
131
|
|
|
107
132
|
return (
|
|
108
|
-
<PermissionContext.Provider
|
|
133
|
+
<PermissionContext.Provider
|
|
134
|
+
value={{
|
|
135
|
+
permissions,
|
|
136
|
+
rules,
|
|
137
|
+
permissionKeys,
|
|
138
|
+
// The first load only, which is what the nav's skeleton is for. Not
|
|
139
|
+
// `isFetching` alone: that is also true while a stale query refetches
|
|
140
|
+
// in the background, so an already-rendered nav would drop back to
|
|
141
|
+
// placeholders on every refresh — the flicker the skeleton exists to
|
|
142
|
+
// remove. Not `isPending` alone either: it stays true for a disabled
|
|
143
|
+
// query, leaving the sign-in screen loading forever. Both together
|
|
144
|
+
// mean "signed in, nothing cached yet, a request in flight".
|
|
145
|
+
isLoading: isAuthenticated && query.isPending && query.isFetching,
|
|
146
|
+
refresh,
|
|
147
|
+
}}
|
|
148
|
+
>
|
|
109
149
|
{children}
|
|
110
150
|
</PermissionContext.Provider>
|
|
111
151
|
)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from 'react'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The CSS variable every pinned band offsets itself by — the table's pinned
|
|
14
|
+
* column row, the bulk-action bar, scroll-margin on linked cards. It is
|
|
15
|
+
* declared in `styles.css` with a fallback for pages that have no
|
|
16
|
+
* `PageHeader`, and overwritten here with the real measurement when one is
|
|
17
|
+
* mounted.
|
|
18
|
+
*/
|
|
19
|
+
const OFFSET_VARIABLE = '--spacing-header-height'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Tracks a mounted `PageHeader` and publishes its height as the shared sticky
|
|
23
|
+
* offset.
|
|
24
|
+
*
|
|
25
|
+
* The dashboard has no top bar: a page's own header is the only chrome above
|
|
26
|
+
* the scrolling content, so it is also the only thing other pinned bands need
|
|
27
|
+
* to clear. Its height is not a constant — badges, a subtitle and wrapped
|
|
28
|
+
* actions all change it, and so does a narrow viewport — so it is measured
|
|
29
|
+
* rather than assumed. A `ResizeObserver` keeps the variable current as the
|
|
30
|
+
* header reflows.
|
|
31
|
+
*
|
|
32
|
+
* The variable is set on `document.documentElement` because the elements
|
|
33
|
+
* reading it are not all descendants of the header (the pinned table row lives
|
|
34
|
+
* further down the tree, and Tailwind resolves `top-header-height` against
|
|
35
|
+
* whatever the cascade provides).
|
|
36
|
+
*
|
|
37
|
+
* @param enabled false for a header that does not pin — it then publishes no
|
|
38
|
+
* offset at all, so the bands below it pin to the top of the scroll
|
|
39
|
+
* container rather than clearing a header that never occupies that space.
|
|
40
|
+
* @returns a ref to attach to the `PageHeader` element
|
|
41
|
+
*/
|
|
42
|
+
export function useStickyHeaderOffset(enabled = true): (node: HTMLElement | null) => void {
|
|
43
|
+
const observerRef = useRef<ResizeObserver | null>(null)
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
// Only on unmount: leaving a measured height behind would make the next
|
|
47
|
+
// page — which may have no header at all — pin its table that far down.
|
|
48
|
+
return () => {
|
|
49
|
+
observerRef.current?.disconnect()
|
|
50
|
+
document.documentElement.style.removeProperty(OFFSET_VARIABLE)
|
|
51
|
+
}
|
|
52
|
+
}, [])
|
|
53
|
+
|
|
54
|
+
return useCallback(
|
|
55
|
+
(node: HTMLElement | null) => {
|
|
56
|
+
observerRef.current?.disconnect()
|
|
57
|
+
|
|
58
|
+
if (!node || !enabled) {
|
|
59
|
+
document.documentElement.style.removeProperty(OFFSET_VARIABLE)
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const publish = () => {
|
|
64
|
+
const { height } = node.getBoundingClientRect()
|
|
65
|
+
// Sub-pixel heights round up: rounding down leaves a hairline of the
|
|
66
|
+
// scrolling row visible above the pinned one.
|
|
67
|
+
document.documentElement.style.setProperty(OFFSET_VARIABLE, `${Math.ceil(height)}px`)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
publish()
|
|
71
|
+
|
|
72
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
73
|
+
const observer = new ResizeObserver(publish)
|
|
74
|
+
observer.observe(node)
|
|
75
|
+
observerRef.current = observer
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
[enabled],
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Page-header presence — heading levels
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
const PageHeaderPresenceContext = createContext<{
|
|
87
|
+
hasPageHeader: boolean
|
|
88
|
+
register: () => () => void
|
|
89
|
+
} | null>(null)
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Tracks whether a `PageHeader` is mounted on the current page.
|
|
93
|
+
*
|
|
94
|
+
* This is not about layout — it is about heading levels. When a page has a
|
|
95
|
+
* `PageHeader` it owns the `h1`, so a table heading below it must be an `h2`;
|
|
96
|
+
* on a page without one the table's own title is the first heading. Getting
|
|
97
|
+
* that wrong produces a document with two `h1`s or none, which is exactly what
|
|
98
|
+
* a screen-reader user navigates by.
|
|
99
|
+
*/
|
|
100
|
+
export function StickyHeaderProvider({ children }: { children: ReactNode }) {
|
|
101
|
+
const [count, setCount] = useState(0)
|
|
102
|
+
|
|
103
|
+
const value = useMemo(
|
|
104
|
+
() => ({
|
|
105
|
+
hasPageHeader: count > 0,
|
|
106
|
+
register: () => {
|
|
107
|
+
setCount((n) => n + 1)
|
|
108
|
+
return () => setCount((n) => n - 1)
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
[count],
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<PageHeaderPresenceContext.Provider value={value}>
|
|
116
|
+
{children}
|
|
117
|
+
</PageHeaderPresenceContext.Provider>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Whether a `PageHeader` is mounted. Returns an inert default outside a
|
|
123
|
+
* provider so components stay usable standalone (tests, plugin previews).
|
|
124
|
+
*/
|
|
125
|
+
export function useStickyHeader(): { hasPageHeader: boolean } {
|
|
126
|
+
return useContext(PageHeaderPresenceContext) ?? { hasPageHeader: false }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Announces a mounted `PageHeader` for the lifetime of the calling component. */
|
|
130
|
+
export function useRegisterPageHeader(): void {
|
|
131
|
+
const ctx = useContext(PageHeaderPresenceContext)
|
|
132
|
+
const register = ctx?.register
|
|
133
|
+
useEffect(() => register?.(), [register])
|
|
134
|
+
}
|
|
@@ -28,12 +28,28 @@ interface StoreContextValue {
|
|
|
28
28
|
availableLocales: string[]
|
|
29
29
|
defaultCurrency: string
|
|
30
30
|
defaultLocale: string
|
|
31
|
+
/** ISO country the store sells from — used to pre-fill new address forms. */
|
|
32
|
+
defaultCountry: string
|
|
31
33
|
/** 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
|
|
34
|
+
* browser's resolved timezone when the store hasn't loaded yet, or when
|
|
35
|
+
* the store's preference is not a name this platform recognizes. */
|
|
33
36
|
timezone: string
|
|
34
37
|
refetch: () => Promise<unknown>
|
|
35
38
|
}
|
|
36
39
|
|
|
40
|
+
const browserTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
|
41
|
+
|
|
42
|
+
/** The store's zone when it is an IANA name the platform knows, else the browser's. */
|
|
43
|
+
function resolveTimezone(preferred: string | null | undefined): string {
|
|
44
|
+
if (!preferred) return browserTimezone()
|
|
45
|
+
try {
|
|
46
|
+
Intl.DateTimeFormat('en-US', { timeZone: preferred })
|
|
47
|
+
return preferred
|
|
48
|
+
} catch {
|
|
49
|
+
return browserTimezone()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
37
53
|
const StoreContext = createContext<StoreContextValue | null>(null)
|
|
38
54
|
|
|
39
55
|
export function StoreProvider({ storeId, children }: { storeId: string; children: ReactNode }) {
|
|
@@ -79,8 +95,11 @@ export function StoreProvider({ storeId, children }: { storeId: string; children
|
|
|
79
95
|
const availableLocales = store?.available_locales ?? []
|
|
80
96
|
const defaultCurrency = store?.default_currency ?? 'USD'
|
|
81
97
|
const defaultLocale = store?.default_locale ?? 'en'
|
|
82
|
-
const
|
|
83
|
-
|
|
98
|
+
const defaultCountry = store?.default_country_code ?? ''
|
|
99
|
+
// A store's zone preference defaults to the host app's `Time.zone.name`,
|
|
100
|
+
// which may be a Rails name ("Eastern Time (US & Canada)") that Intl and
|
|
101
|
+
// date-fns-tz reject — falling back keeps every date control working.
|
|
102
|
+
const timezone = resolveTimezone(store?.preferred_timezone)
|
|
84
103
|
|
|
85
104
|
return (
|
|
86
105
|
<StoreContext.Provider
|
|
@@ -93,6 +112,7 @@ export function StoreProvider({ storeId, children }: { storeId: string; children
|
|
|
93
112
|
availableLocales,
|
|
94
113
|
defaultCurrency,
|
|
95
114
|
defaultLocale,
|
|
115
|
+
defaultCountry,
|
|
96
116
|
timezone,
|
|
97
117
|
refetch: query.refetch,
|
|
98
118
|
}}
|
|
@@ -109,3 +129,16 @@ export function useStore(): StoreContextValue {
|
|
|
109
129
|
}
|
|
110
130
|
return context
|
|
111
131
|
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The store context if a `StoreProvider` is mounted, else `null`.
|
|
135
|
+
*
|
|
136
|
+
* For code that runs in more than one panel. The operator's dashboard always
|
|
137
|
+
* has a store; the seller panel has none — its tenant is a seller, and the
|
|
138
|
+
* store is derived server-side. Shared pieces (the nav visibility context)
|
|
139
|
+
* take the store when it is there and carry on when it is not, rather than
|
|
140
|
+
* forcing every panel to mount a provider for a concept it does not have.
|
|
141
|
+
*/
|
|
142
|
+
export function useOptionalStore(): StoreContextValue | null {
|
|
143
|
+
return useContext(StoreContext)
|
|
144
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createContext, type ReactNode, useContext, useMemo } from 'react'
|
|
2
|
+
import { useOptionalStore } from './store-provider'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Which tenant the signed-in principal is currently acting as — a store in the
|
|
6
|
+
* operator's dashboard, a seller in the marketplace panel.
|
|
7
|
+
*
|
|
8
|
+
* Query keys have to be scoped to it (a cached list must never survive a
|
|
9
|
+
* switch and be shown under the next tenant), and shared pages cannot reach
|
|
10
|
+
* for `useStore` to get it: a seller panel mounts no `StoreProvider`, so that
|
|
11
|
+
* throws. `useTenantId` reads this provider, falls back to the store when only
|
|
12
|
+
* a `StoreProvider` is mounted, and answers `'default'` when neither is —
|
|
13
|
+
* which keeps the operator's dashboard working with no change at all.
|
|
14
|
+
*/
|
|
15
|
+
const TenantContext = createContext<string | null>(null)
|
|
16
|
+
|
|
17
|
+
export function TenantProvider({ id, children }: { id: string; children: ReactNode }) {
|
|
18
|
+
const value = useMemo(() => id, [id])
|
|
19
|
+
|
|
20
|
+
return <TenantContext.Provider value={value}>{children}</TenantContext.Provider>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The current tenant's id, for query-key scoping.
|
|
25
|
+
*
|
|
26
|
+
* @returns the tenant id, or `'default'` when no provider is mounted
|
|
27
|
+
*/
|
|
28
|
+
export function useTenantId(): string {
|
|
29
|
+
const explicit = useContext(TenantContext)
|
|
30
|
+
const store = useOptionalStore()
|
|
31
|
+
|
|
32
|
+
return explicit ?? store?.storeId ?? 'default'
|
|
33
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { requiredMessage } from '@spree/dashboard-ui'
|
|
2
|
+
import i18n from 'i18next'
|
|
3
|
+
import { z } from 'zod/v4'
|
|
4
|
+
import type { PanelPackageType, PanelPackageTypeCreateParams } from '../api-client'
|
|
5
|
+
|
|
6
|
+
// The vocabulary the server validates against; typing anything else could
|
|
7
|
+
// only fail on save.
|
|
8
|
+
export const PACKAGE_TYPE_KINDS = ['box', 'envelope', 'carton', 'pallet', 'container'] as const
|
|
9
|
+
export const PACKAGE_DIMENSION_UNITS = ['mm', 'cm', 'in', 'ft'] as const
|
|
10
|
+
export const PACKAGE_WEIGHT_UNITS = ['g', 'kg', 'lb', 'oz'] as const
|
|
11
|
+
|
|
12
|
+
export type PackageTypeKind = (typeof PACKAGE_TYPE_KINDS)[number]
|
|
13
|
+
export type PackageDimensionUnit = (typeof PACKAGE_DIMENSION_UNITS)[number]
|
|
14
|
+
export type PackageWeightUnit = (typeof PACKAGE_WEIGHT_UNITS)[number]
|
|
15
|
+
|
|
16
|
+
// Blank is how a merchant says "not measured", so every number is optional
|
|
17
|
+
// and an empty string reaches the API as null rather than zero — a box
|
|
18
|
+
// recorded as zero long, zero wide is not the same as one nobody measured.
|
|
19
|
+
const optionalNumber = z
|
|
20
|
+
.union([z.string(), z.number()])
|
|
21
|
+
.optional()
|
|
22
|
+
// `NaN >= 0` is already false, so the comparison alone rejects garbage.
|
|
23
|
+
.refine((value) => value === '' || value === undefined || Number(value) >= 0, {
|
|
24
|
+
error: () => i18n.t('admin.package_types.validation.non_negative'),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export const packageTypeFormSchema = z.object({
|
|
28
|
+
name: z
|
|
29
|
+
.string()
|
|
30
|
+
.trim()
|
|
31
|
+
.min(1, { error: requiredMessage('package_type.name') }),
|
|
32
|
+
kind: z.enum(PACKAGE_TYPE_KINDS),
|
|
33
|
+
length: optionalNumber,
|
|
34
|
+
width: optionalNumber,
|
|
35
|
+
height: optionalNumber,
|
|
36
|
+
dimensions_unit: z.enum(PACKAGE_DIMENSION_UNITS).optional(),
|
|
37
|
+
weight: optionalNumber,
|
|
38
|
+
max_weight: optionalNumber,
|
|
39
|
+
weight_unit: z.enum(PACKAGE_WEIGHT_UNITS).optional(),
|
|
40
|
+
default: z.boolean().optional(),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
export type PackageTypeFormValues = z.input<typeof packageTypeFormSchema>
|
|
44
|
+
|
|
45
|
+
export const PACKAGE_TYPE_DEFAULTS: PackageTypeFormValues = {
|
|
46
|
+
name: '',
|
|
47
|
+
kind: 'box',
|
|
48
|
+
length: '',
|
|
49
|
+
width: '',
|
|
50
|
+
height: '',
|
|
51
|
+
dimensions_unit: 'cm',
|
|
52
|
+
weight: '',
|
|
53
|
+
max_weight: '',
|
|
54
|
+
weight_unit: 'kg',
|
|
55
|
+
default: false,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Blank erases a measurement, so it has to reach the API as an explicit null;
|
|
60
|
+
* undefined would leave whatever was recorded before untouched.
|
|
61
|
+
*/
|
|
62
|
+
export function packageTypeFormValuesToParams(
|
|
63
|
+
values: PackageTypeFormValues,
|
|
64
|
+
): PanelPackageTypeCreateParams {
|
|
65
|
+
const measurement = (value: string | number | undefined) =>
|
|
66
|
+
value === '' || value === undefined ? null : Number(value)
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
name: values.name,
|
|
70
|
+
kind: values.kind,
|
|
71
|
+
length: measurement(values.length),
|
|
72
|
+
width: measurement(values.width),
|
|
73
|
+
height: measurement(values.height),
|
|
74
|
+
weight: measurement(values.weight),
|
|
75
|
+
max_weight: measurement(values.max_weight),
|
|
76
|
+
dimensions_unit: values.dimensions_unit,
|
|
77
|
+
weight_unit: values.weight_unit,
|
|
78
|
+
default: values.default,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function packageTypeToFormValues(packageType: PanelPackageType): PackageTypeFormValues {
|
|
83
|
+
return {
|
|
84
|
+
name: packageType.name,
|
|
85
|
+
kind: (packageType.kind ?? 'box') as PackageTypeKind,
|
|
86
|
+
length: packageType.length ?? '',
|
|
87
|
+
width: packageType.width ?? '',
|
|
88
|
+
height: packageType.height ?? '',
|
|
89
|
+
dimensions_unit: (packageType.dimensions_unit ?? 'cm') as PackageDimensionUnit,
|
|
90
|
+
weight: packageType.weight ?? '',
|
|
91
|
+
max_weight: packageType.max_weight ?? '',
|
|
92
|
+
weight_unit: (packageType.weight_unit ?? 'kg') as PackageWeightUnit,
|
|
93
|
+
default: packageType.default ?? false,
|
|
94
|
+
}
|
|
95
|
+
}
|