@spree/dashboard-core 0.13.1 → 1.0.0-beta.1

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 (125) hide show
  1. package/README.md +4 -0
  2. package/dist/vite/index.js +17 -3
  3. package/package.json +7 -7
  4. package/src/api-client.ts +643 -0
  5. package/src/components/address-form-dialog.test.ts +25 -0
  6. package/src/components/address-form-dialog.tsx +203 -79
  7. package/src/components/app-shell.tsx +171 -0
  8. package/src/components/app-sidebar.tsx +184 -27
  9. package/src/components/bulk-action-bar.tsx +89 -157
  10. package/src/components/country-combobox.tsx +69 -34
  11. package/src/components/country-state-fields.tsx +43 -11
  12. package/src/components/currency-select.tsx +81 -66
  13. package/src/components/export-button.tsx +62 -5
  14. package/src/components/file-upload-field.tsx +32 -10
  15. package/src/components/import-button.tsx +52 -27
  16. package/src/components/import-wizard-dialog.tsx +710 -0
  17. package/src/components/locale-select.tsx +70 -57
  18. package/src/components/media-picker-sheet.tsx +307 -0
  19. package/src/components/mobile-top-bar.tsx +75 -0
  20. package/src/components/nav-main.tsx +197 -27
  21. package/src/components/page-header.tsx +203 -61
  22. package/src/components/page-tabs.tsx +4 -1
  23. package/src/components/preferences-form.tsx +98 -4
  24. package/src/components/resource-combobox.tsx +52 -4
  25. package/src/components/resource-picker-sheet.tsx +209 -73
  26. package/src/components/resource-table.tsx +383 -127
  27. package/src/components/route-announcer.tsx +64 -0
  28. package/src/components/section-heading.tsx +95 -0
  29. package/src/components/settings-sidebar.tsx +290 -60
  30. package/src/components/sheet-top-bar.tsx +44 -0
  31. package/src/components/sidebar-search.tsx +83 -0
  32. package/src/components/sidebar-user.tsx +213 -0
  33. package/src/components/skip-link.tsx +36 -0
  34. package/src/components/store-date-picker.tsx +6 -2
  35. package/src/components/store-switcher.tsx +19 -14
  36. package/src/components/table-toolbar.tsx +1357 -440
  37. package/src/components/tag-combobox.tsx +12 -4
  38. package/src/components/top-bar-breadcrumbs.tsx +235 -0
  39. package/src/components/wizard-close-guard.test.ts +54 -0
  40. package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
  41. package/src/hooks/use-command-palette.tsx +13 -0
  42. package/src/hooks/use-countries.test.ts +56 -0
  43. package/src/hooks/use-countries.ts +51 -5
  44. package/src/hooks/use-direct-upload.ts +16 -3
  45. package/src/hooks/use-export.ts +51 -16
  46. package/src/hooks/use-import.ts +241 -16
  47. package/src/hooks/use-package-types.ts +132 -0
  48. package/src/hooks/use-resource-mutation.ts +74 -13
  49. package/src/hooks/use-stock-locations.ts +131 -0
  50. package/src/hooks/use-switch-admin-locale.ts +2 -2
  51. package/src/hooks/use-visual-viewport-offset.ts +61 -0
  52. package/src/index.ts +45 -1
  53. package/src/lib/cancel-order-form.ts +27 -0
  54. package/src/lib/create-action-registry.ts +278 -0
  55. package/src/lib/date-presets.test.ts +86 -0
  56. package/src/lib/date-presets.ts +98 -0
  57. package/src/lib/delivery-form.ts +15 -0
  58. package/src/lib/docs.ts +32 -0
  59. package/src/lib/download.ts +13 -1
  60. package/src/lib/filters-to-ransack.test.ts +113 -0
  61. package/src/lib/filters-to-ransack.ts +22 -1
  62. package/src/lib/form-errors.ts +122 -10
  63. package/src/lib/form-mappers.ts +76 -0
  64. package/src/lib/fulfillment-items.test.ts +213 -0
  65. package/src/lib/fulfillment-items.ts +171 -0
  66. package/src/lib/i18n.ts +19 -11
  67. package/src/lib/import-types.ts +25 -0
  68. package/src/lib/label-upload-form.ts +22 -0
  69. package/src/lib/nav-registry.ts +71 -3
  70. package/src/lib/permissions.ts +46 -1
  71. package/src/lib/progress.ts +18 -0
  72. package/src/lib/query-keys.ts +13 -13
  73. package/src/lib/sample-csv.ts +9 -6
  74. package/src/lib/search-registry.ts +6 -2
  75. package/src/lib/settings-nav-registry.ts +104 -11
  76. package/src/lib/table-registry.ts +50 -0
  77. package/src/lib/type-labels.test.ts +68 -0
  78. package/src/lib/type-labels.ts +78 -0
  79. package/src/locales/ar.json +1889 -517
  80. package/src/locales/de.json +1884 -517
  81. package/src/locales/en.json +1886 -519
  82. package/src/locales/fr.json +1884 -517
  83. package/src/locales/pl.json +1913 -517
  84. package/src/locales/zh-CN.json +1883 -517
  85. package/src/pages/package-types.tsx +484 -0
  86. package/src/pages/settings-index.tsx +112 -0
  87. package/src/pages/stock-locations.tsx +674 -0
  88. package/src/plugin.ts +29 -0
  89. package/src/products/add-video-dialog.tsx +100 -0
  90. package/src/products/bulk-variants-dialog.tsx +301 -0
  91. package/src/products/carton-size.test.ts +51 -0
  92. package/src/products/carton-size.ts +20 -0
  93. package/src/products/currency-parts.ts +21 -0
  94. package/src/products/inventory-section.tsx +259 -0
  95. package/src/products/media-edit-sheet.tsx +336 -0
  96. package/src/products/normalize-customs.test.ts +39 -0
  97. package/src/products/normalize-customs.ts +27 -0
  98. package/src/products/normalize-money.test.ts +58 -0
  99. package/src/products/normalize-money.ts +73 -0
  100. package/src/products/normalize-quantity.test.ts +32 -0
  101. package/src/products/normalize-quantity.ts +31 -0
  102. package/src/products/product-bulk-price-editor.tsx +165 -0
  103. package/src/products/product-form-cards.tsx +1167 -0
  104. package/src/products/product-form-mapping.ts +212 -0
  105. package/src/products/product-schema.ts +318 -0
  106. package/src/products/product-types.ts +130 -0
  107. package/src/products/use-product-form-data.ts +273 -0
  108. package/src/products/variant-edit-sheet.tsx +725 -0
  109. package/src/products/variants-matrix.test.ts +154 -0
  110. package/src/products/variants-matrix.ts +244 -0
  111. package/src/products/variants-options-builder.tsx +571 -0
  112. package/src/products/variants-section.tsx +535 -0
  113. package/src/products/video-url.test.ts +71 -0
  114. package/src/products/video-url.ts +83 -0
  115. package/src/providers/auth-provider.tsx +70 -12
  116. package/src/providers/permission-provider.tsx +68 -28
  117. package/src/providers/sticky-header-provider.tsx +134 -0
  118. package/src/providers/store-provider.tsx +36 -3
  119. package/src/providers/tenant-provider.tsx +33 -0
  120. package/src/schemas/package-type.ts +95 -0
  121. package/src/schemas/stock-location.ts +126 -0
  122. package/src/tables/package-types.tsx +74 -0
  123. package/src/tables/stock-locations.tsx +124 -0
  124. package/src/vite/index.ts +40 -7
  125. 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 { adminClient } from '../client'
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<void>
19
+ login: (email: string, password: string) => Promise<AuthTokens>
18
20
  logout: () => Promise<void>
19
- acceptInvitation: (id: string, token: string, params: InvitationAcceptParams) => Promise<void>
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<void>
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
- adminClient.setToken(accessToken)
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
- adminClient.setToken('')
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 adminClient.auth.refresh()
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(adminClient.auth.login({ email, password })),
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(adminClient.auth.acceptInvitation(id, token, params)),
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(adminClient.auth.resetPassword(token, params)),
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 adminClient.auth.logout()
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
- adminClient.onUnauthorized(async () => {
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 { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react'
3
- import { adminClient } from '../client'
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 [rules, setRules] = useState<PermissionRule[]>([])
75
- const [isLoading, setIsLoading] = useState(false)
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
- useEffect(() => {
78
- if (!isAuthenticated) {
79
- setRules([])
80
- return
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
- 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])
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 value={{ permissions, rules, isLoading }}>
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 timezone =
83
- store?.preferred_timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC'
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
+ }