@spree/dashboard-core 0.13.0 → 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 (126) 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 +97 -36
  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 +425 -131
  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 +63 -22
  36. package/src/components/table-toolbar.tsx +1373 -442
  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 +261 -14
  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 +21 -0
  74. package/src/lib/search-registry.ts +6 -2
  75. package/src/lib/settings-nav-registry.ts +104 -11
  76. package/src/lib/storefront.ts +19 -0
  77. package/src/lib/table-registry.ts +56 -0
  78. package/src/lib/type-labels.test.ts +68 -0
  79. package/src/lib/type-labels.ts +78 -0
  80. package/src/locales/ar.json +1889 -516
  81. package/src/locales/de.json +1883 -515
  82. package/src/locales/en.json +1886 -516
  83. package/src/locales/fr.json +1884 -516
  84. package/src/locales/pl.json +1913 -516
  85. package/src/locales/zh-CN.json +1883 -516
  86. package/src/pages/package-types.tsx +484 -0
  87. package/src/pages/settings-index.tsx +112 -0
  88. package/src/pages/stock-locations.tsx +674 -0
  89. package/src/plugin.ts +29 -0
  90. package/src/products/add-video-dialog.tsx +100 -0
  91. package/src/products/bulk-variants-dialog.tsx +301 -0
  92. package/src/products/carton-size.test.ts +51 -0
  93. package/src/products/carton-size.ts +20 -0
  94. package/src/products/currency-parts.ts +21 -0
  95. package/src/products/inventory-section.tsx +259 -0
  96. package/src/products/media-edit-sheet.tsx +336 -0
  97. package/src/products/normalize-customs.test.ts +39 -0
  98. package/src/products/normalize-customs.ts +27 -0
  99. package/src/products/normalize-money.test.ts +58 -0
  100. package/src/products/normalize-money.ts +73 -0
  101. package/src/products/normalize-quantity.test.ts +32 -0
  102. package/src/products/normalize-quantity.ts +31 -0
  103. package/src/products/product-bulk-price-editor.tsx +165 -0
  104. package/src/products/product-form-cards.tsx +1167 -0
  105. package/src/products/product-form-mapping.ts +212 -0
  106. package/src/products/product-schema.ts +318 -0
  107. package/src/products/product-types.ts +130 -0
  108. package/src/products/use-product-form-data.ts +273 -0
  109. package/src/products/variant-edit-sheet.tsx +725 -0
  110. package/src/products/variants-matrix.test.ts +154 -0
  111. package/src/products/variants-matrix.ts +244 -0
  112. package/src/products/variants-options-builder.tsx +571 -0
  113. package/src/products/variants-section.tsx +535 -0
  114. package/src/products/video-url.test.ts +71 -0
  115. package/src/products/video-url.ts +83 -0
  116. package/src/providers/auth-provider.tsx +70 -12
  117. package/src/providers/permission-provider.tsx +68 -28
  118. package/src/providers/sticky-header-provider.tsx +134 -0
  119. package/src/providers/store-provider.tsx +36 -3
  120. package/src/providers/tenant-provider.tsx +33 -0
  121. package/src/schemas/package-type.ts +95 -0
  122. package/src/schemas/stock-location.ts +126 -0
  123. package/src/tables/package-types.tsx +74 -0
  124. package/src/tables/stock-locations.tsx +124 -0
  125. package/src/vite/index.ts +40 -7
  126. package/src/components/top-bar.tsx +0 -210
@@ -13,9 +13,10 @@ import {
13
13
  import { useQuery } from '@tanstack/react-query'
14
14
  import { useMemo, useState } from 'react'
15
15
  import { useTranslation } from 'react-i18next'
16
- import { adminClient } from '../client'
16
+ import { getApiClient } from '../api-client'
17
17
  import { useAuth } from '../hooks/use-auth'
18
18
  import type { TaggableType } from '../lib/table-registry'
19
+ import { useTenantId } from '../providers/tenant-provider'
19
20
 
20
21
  export type { TaggableType }
21
22
 
@@ -35,11 +36,18 @@ export function TagCombobox({
35
36
  const anchorRef = useComboboxAnchor()
36
37
  const [inputValue, setInputValue] = useState('')
37
38
  const { isAuthenticated } = useAuth()
39
+ const tenantId = useTenantId()
40
+
41
+ // Through the registered client, so a panel without a tag vocabulary
42
+ // simply has none rather than calling an API it holds no key for.
43
+ const tagsClient = getApiClient().tags
38
44
 
39
45
  const { data } = useQuery({
40
- queryKey: ['admin-tags', taggableType],
41
- queryFn: () => adminClient.tags.list({ taggable_type: taggableType }),
42
- enabled: isAuthenticated,
46
+ // Tenant-scoped: tag vocabularies are per-store, so a shared key would
47
+ // serve one store's tags to the next for as long as the result stays fresh.
48
+ queryKey: ['panel-tags', tenantId, taggableType],
49
+ queryFn: () => tagsClient?.list({ taggable_type: taggableType }),
50
+ enabled: isAuthenticated && Boolean(tagsClient),
43
51
  staleTime: 60_000,
44
52
  })
45
53
 
@@ -0,0 +1,235 @@
1
+ import {
2
+ Breadcrumb,
3
+ BreadcrumbItem,
4
+ BreadcrumbLink,
5
+ BreadcrumbList,
6
+ BreadcrumbPage,
7
+ BreadcrumbSeparator,
8
+ } from '@spree/dashboard-ui'
9
+ import { MenuIcon } from '@spree/dashboard-ui/icons'
10
+ import { Link, useParams, useRouterState } from '@tanstack/react-router'
11
+ import { Fragment, type ReactNode, useMemo } from 'react'
12
+ import { useTranslation } from 'react-i18next'
13
+ import { isPathWithin, type NavEntry, resolveNavLabel, useNavEntries } from '../lib/nav-registry'
14
+ import { type SettingsNavEntry, useSettingsNav } from '../lib/settings-nav-registry'
15
+
16
+ interface Crumb {
17
+ label: string
18
+ /** Omitted on the final crumb, which renders as plain text. */
19
+ to?: string
20
+ }
21
+
22
+ /**
23
+ * Location trail for the TopBar, derived from the current path by matching it
24
+ * against the nav registries. Fills the empty space beside the search box and
25
+ * gives deep pages the orientation the collapsed sidebars stop providing.
26
+ *
27
+ * Only segments the registries know are named. A record-detail path
28
+ * (`/settings/delivery-profiles/dp_123`) resolves as far as its section and
29
+ * stops there — inventing a crumb for an id the shell can't name would be
30
+ * worse than a shorter trail, and the page's own `PageHeader` carries the
31
+ * record's title anyway.
32
+ */
33
+ export function TopBarBreadcrumbs({ tenantId }: { tenantId?: string } = {}) {
34
+ const trail = useCrumbs(tenantId)
35
+ if (!trail) return null
36
+
37
+ return <CrumbTrail crumbs={trail.crumbs} pathname={trail.pathname} className="min-w-0" />
38
+ }
39
+
40
+ /**
41
+ * Breadcrumbs for a narrow screen, as a row beneath `MobileTopBar` rather
42
+ * than inside it — that bar has no width to spare on a phone, so the trail
43
+ * lives on its own line and scrolls sideways instead of wrapping.
44
+ *
45
+ * Only rendered where it earns the vertical space: inside settings, and on
46
+ * record pages. A top-level list page would only repeat its own heading.
47
+ *
48
+ * Inside settings the leading crumb is a button rather than a link — it opens
49
+ * the settings nav sheet, which is the fastest way between two settings pages
50
+ * on a screen with no settings sidebar.
51
+ */
52
+ export function MobileBreadcrumbBar({
53
+ onOpenSettingsNav,
54
+ tenantId,
55
+ }: {
56
+ onOpenSettingsNav?: () => void
57
+ /** See `SettingsSidebar` — a seller id on the marketplace panel. */
58
+ tenantId?: string
59
+ }) {
60
+ const trail = useCrumbs(tenantId)
61
+ if (!trail) return null
62
+
63
+ const { crumbs, pathname, inSettings } = trail
64
+
65
+ return (
66
+ <div className="flex h-11 shrink-0 items-center border-b border-border bg-background px-4 md:hidden">
67
+ <CrumbTrail
68
+ crumbs={crumbs}
69
+ pathname={pathname}
70
+ className="min-w-0 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
71
+ // In settings the leading crumb opens the nav sheet instead of
72
+ // navigating — it is the only way between settings pages on a phone.
73
+ renderFirst={
74
+ inSettings && onOpenSettingsNav
75
+ ? (crumb) => (
76
+ <button
77
+ type="button"
78
+ onClick={onOpenSettingsNav}
79
+ // 44px tall via the row; the negative inline margin keeps the
80
+ // text on the page gutter while the tap area overhangs it.
81
+ className="-ms-2 inline-flex h-11 items-center gap-1 rounded-lg px-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
82
+ >
83
+ <MenuIcon className="size-4" />
84
+ {crumb.label}
85
+ </button>
86
+ )
87
+ : undefined
88
+ }
89
+ />
90
+ </div>
91
+ )
92
+ }
93
+
94
+ function CrumbTrail({
95
+ crumbs,
96
+ pathname,
97
+ className,
98
+ renderFirst,
99
+ }: {
100
+ crumbs: Crumb[]
101
+ pathname: string
102
+ className?: string
103
+ /** Replaces the leading crumb's content — used for the settings nav trigger. */
104
+ renderFirst?: (crumb: Crumb) => ReactNode
105
+ }) {
106
+ return (
107
+ <Breadcrumb className={className}>
108
+ <BreadcrumbList className="flex-nowrap">
109
+ {crumbs.map((crumb, index) => {
110
+ // The trail ends in a link, not plain text, when the last crumb names
111
+ // a section the current page sits *inside* (a record page, whose own
112
+ // name the registries can't supply) — there it is the way back up.
113
+ const isCurrentPage = crumb.to === pathname
114
+ const custom = index === 0 ? renderFirst?.(crumb) : undefined
115
+ return (
116
+ // Crumbs are a fixed trail for one path — no stable id beyond
117
+ // position. The separator is a sibling `<li>`, never a child of
118
+ // BreadcrumbItem — nesting one list item inside another is invalid.
119
+ // biome-ignore lint/suspicious/noArrayIndexKey: positional by nature
120
+ <Fragment key={index}>
121
+ <BreadcrumbItem className="min-w-0">
122
+ {custom ??
123
+ (isCurrentPage || !crumb.to ? (
124
+ <BreadcrumbPage className="truncate">{crumb.label}</BreadcrumbPage>
125
+ ) : (
126
+ <BreadcrumbLink asChild>
127
+ <Link to={crumb.to} className="truncate">
128
+ {crumb.label}
129
+ </Link>
130
+ </BreadcrumbLink>
131
+ ))}
132
+ </BreadcrumbItem>
133
+ {index < crumbs.length - 1 && <BreadcrumbSeparator />}
134
+ </Fragment>
135
+ )
136
+ })}
137
+ </BreadcrumbList>
138
+ </Breadcrumb>
139
+ )
140
+ }
141
+
142
+ /** Resolves the trail for the current path, or null when there is nothing worth showing. */
143
+ function useCrumbs(tenantId?: string): {
144
+ crumbs: Crumb[]
145
+ pathname: string
146
+ inSettings: boolean
147
+ } | null {
148
+ const { t } = useTranslation()
149
+ const { storeId: routeStoreId } = useParams({ strict: false }) as { storeId?: string }
150
+ const storeId = tenantId ?? routeStoreId
151
+ const pathname = useRouterState({ select: (s) => s.location.pathname })
152
+ const { main, bottom } = useNavEntries()
153
+ const settings = useSettingsNav()
154
+
155
+ // Memoized because both breadcrumb surfaces are mounted at once (the
156
+ // responsive split is CSS, not conditional mounting), so an unmemoized pass
157
+ // would run twice on every render of the app-wide TopBar.
158
+ return useMemo(() => {
159
+ if (!storeId) return null
160
+
161
+ const crumbs = buildCrumbs({ pathname, storeId, main, bottom, settings: settings.all, t })
162
+ // A lone crumb naming the page you are already on says nothing the
163
+ // PageHeader doesn't. A lone crumb naming the *section* of a record page
164
+ // does — it is the way back up — so that one stays.
165
+ if (crumbs.length === 0) return null
166
+ if (crumbs.length === 1 && crumbs[0].to === pathname) return null
167
+
168
+ return { crumbs, pathname, inSettings: isPathWithin(pathname, `/${storeId}/settings`) }
169
+ }, [pathname, storeId, main, bottom, settings.all, t])
170
+ }
171
+
172
+ function buildCrumbs({
173
+ pathname,
174
+ storeId,
175
+ main,
176
+ bottom,
177
+ settings,
178
+ t,
179
+ }: {
180
+ pathname: string
181
+ storeId: string
182
+ main: NavEntry[]
183
+ bottom: NavEntry[]
184
+ settings: SettingsNavEntry[]
185
+ t: (key: string) => string
186
+ }): Crumb[] {
187
+ const settingsRoot = `/${storeId}/settings`
188
+
189
+ if (isPathWithin(pathname, settingsRoot)) {
190
+ const crumbs: Crumb[] = [{ label: t('admin.settings_page.title'), to: settingsRoot }]
191
+ const entry = bestMatch(settings, (e) => `${settingsRoot}${e.path}`, pathname)
192
+ if (entry) {
193
+ crumbs.push({ label: resolveNavLabel(entry, t), to: `${settingsRoot}${entry.path}` })
194
+ }
195
+ return crumbs
196
+ }
197
+
198
+ // One pass handles both a page under its parent's path and a child that sits
199
+ // outside it (Returns lives at `/returns` but nests under Orders), so a
200
+ // matching child alone is enough to claim the trail.
201
+ for (const parent of [...main, ...bottom]) {
202
+ const parentUrl = `/${storeId}${parent.path}`
203
+ const child = bestMatch(parent.children ?? [], (c) => `/${storeId}${c.path}`, pathname)
204
+ const parentMatches = parent.path !== '/' && isPathWithin(pathname, parentUrl)
205
+ if (!child && !parentMatches) continue
206
+
207
+ const crumbs: Crumb[] = [{ label: resolveNavLabel(parent, t), to: parentUrl }]
208
+ if (child) crumbs.push({ label: resolveNavLabel(child, t), to: `/${storeId}${child.path}` })
209
+ return crumbs
210
+ }
211
+
212
+ return []
213
+ }
214
+
215
+ /**
216
+ * The matching entry whose URL is longest, so `/webhooks/wh_1` prefers
217
+ * `/webhooks` over a shorter entry that also prefixes it. Written as a scan
218
+ * rather than sort-then-find: this runs for every entry on every navigation,
219
+ * and the ordering is needed only to break ties.
220
+ */
221
+ function bestMatch<T>(
222
+ entries: readonly T[],
223
+ urlOf: (entry: T) => string,
224
+ pathname: string,
225
+ ): T | undefined {
226
+ let best: T | undefined
227
+ let bestLength = -1
228
+ for (const entry of entries) {
229
+ const url = urlOf(entry)
230
+ if (!isPathWithin(pathname, url) || url.length <= bestLength) continue
231
+ best = entry
232
+ bestLength = url.length
233
+ }
234
+ return best
235
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ // The decision table both wizards close by: which reasons ask before
4
+ // discarding and which pass straight through. The components wire this to Base
5
+ // UI's `onOpenChange` reason; `catalogs.spec.ts` covers that wiring in a
6
+ // browser, and this pins the rules themselves.
7
+ function makeHandler(
8
+ hasUnsavedChanges: boolean,
9
+ confirm: () => Promise<boolean>,
10
+ close: () => void,
11
+ ) {
12
+ return async (next: boolean, details?: { reason?: string }) => {
13
+ if (next) return
14
+ if (details?.reason === 'escape-key' || details?.reason === 'outside-press') {
15
+ if (hasUnsavedChanges && !(await confirm())) return
16
+ close()
17
+ return
18
+ }
19
+ close()
20
+ }
21
+ }
22
+
23
+ describe('wizard close guard', () => {
24
+ it('asks before Escape discards unsaved work', async () => {
25
+ const confirm = vi.fn().mockResolvedValue(false)
26
+ const close = vi.fn()
27
+ await makeHandler(true, confirm, close)(false, { reason: 'escape-key' })
28
+ expect(confirm).toHaveBeenCalledOnce()
29
+ expect(close).not.toHaveBeenCalled()
30
+ })
31
+
32
+ it('closes on Escape once discarding is confirmed', async () => {
33
+ const confirm = vi.fn().mockResolvedValue(true)
34
+ const close = vi.fn()
35
+ await makeHandler(true, confirm, close)(false, { reason: 'escape-key' })
36
+ expect(close).toHaveBeenCalledOnce()
37
+ })
38
+
39
+ it('guards a click outside the wizard too', async () => {
40
+ const confirm = vi.fn().mockResolvedValue(false)
41
+ const close = vi.fn()
42
+ await makeHandler(true, confirm, close)(false, { reason: 'outside-press' })
43
+ expect(confirm).toHaveBeenCalledOnce()
44
+ expect(close).not.toHaveBeenCalled()
45
+ })
46
+
47
+ it('does not nag when nothing has been entered', async () => {
48
+ const confirm = vi.fn()
49
+ const close = vi.fn()
50
+ await makeHandler(false, confirm, close)(false, { reason: 'escape-key' })
51
+ expect(confirm).not.toHaveBeenCalled()
52
+ expect(close).toHaveBeenCalledOnce()
53
+ })
54
+ })
@@ -0,0 +1,57 @@
1
+ import { useSidebar } from '@spree/dashboard-ui'
2
+ import { useEffect, useRef } from 'react'
3
+
4
+ /**
5
+ * Collapses the primary sidebar to its icon rail while `active` is true, and
6
+ * restores the previous state when it goes false.
7
+ *
8
+ * Used by the settings area, where a second full-width nav appears beside the
9
+ * primary one: two stacked 220px columns leave the settings forms squeezed, so
10
+ * the primary nav folds down to icons for the duration.
11
+ *
12
+ * Only the entering and leaving transitions are driven — a manual toggle while
13
+ * `active` stays true is left alone, and is what gets remembered on the way
14
+ * out, so the merchant always keeps the last word.
15
+ */
16
+ export function useAutoCollapseSidebar(active: boolean) {
17
+ // Transient on purpose: this collapse is the app's doing, not the
18
+ // merchant's, so it must not overwrite their remembered preference.
19
+ const { open, setOpenTransient, isMobile } = useSidebar()
20
+ // Reading `open` through a ref keeps it out of the effect's dependencies:
21
+ // the effect must run on the `active` edges only, never on every toggle.
22
+ const openRef = useRef(open)
23
+ openRef.current = open
24
+ const restoreTo = useRef<boolean | null>(null)
25
+
26
+ useEffect(() => {
27
+ // On mobile the primary nav is an overlay sheet, not a column competing
28
+ // for width, so there is nothing to reclaim. Undo any collapse this hook
29
+ // already made before bailing out: `useIsMobile` reports false on its
30
+ // first render, so a phone loading straight into settings collapses the
31
+ // rail and only then learns it is mobile. Dropping the snapshot without
32
+ // restoring would strand the rail collapsed for the whole session.
33
+ if (isMobile) {
34
+ if (restoreTo.current) setOpenTransient(true)
35
+ restoreTo.current = null
36
+ return
37
+ }
38
+
39
+ if (active) {
40
+ // Only snapshot on the way in. Re-running while already active (e.g.
41
+ // navigating between two settings pages) must not re-capture a state
42
+ // this hook itself imposed.
43
+ if (restoreTo.current === null) restoreTo.current = openRef.current
44
+ if (openRef.current) setOpenTransient(false)
45
+ return
46
+ }
47
+
48
+ if (restoreTo.current !== null) {
49
+ const wasOpenOnEntry = restoreTo.current
50
+ restoreTo.current = null
51
+ // Restore only if the rail is still collapsed — i.e. still as this hook
52
+ // left it. A merchant who expanded it during settings has said what they
53
+ // want, and forcing the entry-time value back would overrule them.
54
+ if (wasOpenOnEntry && !openRef.current) setOpenTransient(true)
55
+ }
56
+ }, [active, isMobile, setOpenTransient])
57
+ }
@@ -32,3 +32,16 @@ export function useCommandPalette(): CommandPaletteState {
32
32
  }
33
33
  return ctx
34
34
  }
35
+
36
+ /**
37
+ * The palette state when one is mounted, `null` otherwise.
38
+ *
39
+ * The shared app shell offers a search affordance, but not every panel has a
40
+ * palette behind it — the seller panel deliberately mounts none. This lets the
41
+ * shell omit the trigger there rather than render a button that throws on
42
+ * click, and keeps `useCommandPalette` strict for callers that genuinely
43
+ * require the provider.
44
+ */
45
+ export function useOptionalCommandPalette(): CommandPaletteState | null {
46
+ return useContext(CommandPaletteContext)
47
+ }
@@ -0,0 +1,56 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { type PanelApiClient, setApiClient } from '../api-client'
3
+ import { loadCountries, resetCountriesCache } from './use-countries'
4
+
5
+ function registerClient(listCountries?: PanelApiClient['listCountries']) {
6
+ setApiClient({ listCountries } as PanelApiClient)
7
+ }
8
+
9
+ afterEach(() => {
10
+ resetCountriesCache()
11
+ })
12
+
13
+ describe('loadCountries', () => {
14
+ it('calls the API once and reuses the stored list', async () => {
15
+ const data = [{ iso: 'US', iso3: 'USA', name: 'United States' }]
16
+ const listCountries = vi.fn().mockResolvedValue({ data })
17
+ registerClient(listCountries)
18
+
19
+ const first = await loadCountries()
20
+ const second = await loadCountries()
21
+
22
+ expect(listCountries).toHaveBeenCalledTimes(1)
23
+ expect(first).toBe(second)
24
+ expect(first.data).toEqual(data)
25
+ })
26
+
27
+ it('shares one in-flight request across overlapping callers', async () => {
28
+ let resolveList!: (value: { data: [] }) => void
29
+ const listCountries = vi.fn(
30
+ () =>
31
+ new Promise<{ data: [] }>((resolve) => {
32
+ resolveList = resolve
33
+ }),
34
+ )
35
+ registerClient(listCountries)
36
+
37
+ const first = loadCountries()
38
+ const second = loadCountries()
39
+ resolveList({ data: [] })
40
+
41
+ expect(await first).toBe(await second)
42
+ expect(listCountries).toHaveBeenCalledTimes(1)
43
+ })
44
+
45
+ it('retries after a failed load', async () => {
46
+ const listCountries = vi
47
+ .fn()
48
+ .mockRejectedValueOnce(new Error('network'))
49
+ .mockResolvedValueOnce({ data: [] })
50
+ registerClient(listCountries)
51
+
52
+ await expect(loadCountries()).rejects.toThrow('network')
53
+ await expect(loadCountries()).resolves.toEqual({ data: [] })
54
+ expect(listCountries).toHaveBeenCalledTimes(2)
55
+ })
56
+ })
@@ -1,15 +1,61 @@
1
- import type { Country } from '@spree/admin-sdk'
2
1
  import { useQuery } from '@tanstack/react-query'
3
- import { adminClient } from '../client'
2
+ import { getApiClient, type PanelCountry } from '../api-client'
4
3
 
4
+ /**
5
+ * One process-wide load of the country table.
6
+ *
7
+ * Countries are reference data — the same ~250 rows for every store, every
8
+ * panel, every session — so the first successful fetch is reused for the
9
+ * rest of the page. React Query's cache alone is not enough: it is dropped
10
+ * when the last picker unmounts (`gcTime`) and wiped on logout
11
+ * (`queryClient.clear()`), which is why a country field on every sheet was
12
+ * hitting the API again.
13
+ *
14
+ * A failed load is not stored, so the next picker can retry.
15
+ */
16
+ let countriesRequest: Promise<{ data: PanelCountry[] }> | null = null
17
+
18
+ /**
19
+ * The country list, loaded at most once per page. Subsequent callers share
20
+ * the same in-flight or resolved promise.
21
+ *
22
+ * @returns The panel's country payload (`{ data }`).
23
+ */
24
+ export function loadCountries(): Promise<{ data: PanelCountry[] }> {
25
+ if (!countriesRequest) {
26
+ countriesRequest = (getApiClient().listCountries?.() ?? Promise.resolve({ data: [] })).catch(
27
+ (error: unknown) => {
28
+ countriesRequest = null
29
+ throw error
30
+ },
31
+ )
32
+ }
33
+
34
+ return countriesRequest
35
+ }
36
+
37
+ /** Drops the stored country list so the next read hits the API. For tests. */
38
+ export function resetCountriesCache(): void {
39
+ countriesRequest = null
40
+ }
41
+
42
+ /**
43
+ * Countries for the shared address form, through the registered panel client.
44
+ *
45
+ * Deliberately not `adminClient`: this hook backs the one address form both
46
+ * panels use, and importing the operator's client made a seller's country
47
+ * list come back empty — the form rendered, and "No countries found" was the
48
+ * only symptom.
49
+ */
5
50
  export function useCountries() {
6
51
  const { data, isLoading } = useQuery({
7
52
  queryKey: ['countries'],
8
- queryFn: () => adminClient.countries.list({ expand: ['states'] }),
9
- staleTime: 1000 * 60 * 30, // 30 minutes — countries rarely change
53
+ queryFn: loadCountries,
54
+ staleTime: Number.POSITIVE_INFINITY,
55
+ gcTime: Number.POSITIVE_INFINITY,
10
56
  })
11
57
 
12
- const countries: Country[] = data?.data ?? []
58
+ const countries: PanelCountry[] = data?.data ?? []
13
59
 
14
60
  return { countries, isLoading }
15
61
  }
@@ -1,6 +1,6 @@
1
1
  import { useMutation } from '@tanstack/react-query'
2
2
  import SparkMD5 from 'spark-md5'
3
- import { adminClient } from '../client'
3
+ import { getApiClient } from '../api-client'
4
4
 
5
5
  function computeMD5Checksum(file: File): Promise<string> {
6
6
  return new Promise((resolve, reject) => {
@@ -51,7 +51,16 @@ function sameOriginIfRailsDisk(url: string): string {
51
51
  }
52
52
  }
53
53
 
54
- export function useDirectUpload() {
54
+ interface DirectUploadOptions {
55
+ /**
56
+ * Upload to private storage instead of the public bucket. Required for files
57
+ * that are only ever served through an authorized, signed link — attaching a
58
+ * blob never moves it between services, so this has to be decided up front.
59
+ */
60
+ private?: boolean
61
+ }
62
+
63
+ export function useDirectUpload(options: DirectUploadOptions = {}) {
55
64
  return useMutation({
56
65
  mutationFn: async (file: File): Promise<UploadResult> => {
57
66
  const checksum = await computeMD5Checksum(file)
@@ -62,13 +71,17 @@ export function useDirectUpload() {
62
71
  signed_id: string
63
72
  }
64
73
  try {
65
- response = await adminClient.directUploads.create({
74
+ const presign = getApiClient().createDirectUpload
75
+ if (!presign) throw new Error('This panel cannot upload files.')
76
+
77
+ response = await presign({
66
78
  blob: {
67
79
  filename: file.name,
68
80
  byte_size: file.size,
69
81
  checksum,
70
82
  content_type: file.type || 'application/octet-stream',
71
83
  },
84
+ ...(options.private ? { private: true } : {}),
72
85
  })
73
86
  } catch (err) {
74
87
  throw new Error(`Presign failed: ${err instanceof Error ? err.message : err}`)
@@ -1,11 +1,30 @@
1
- import type { Export, ExportCreateParams } from '@spree/admin-sdk'
1
+ import { toastManager } from '@spree/dashboard-ui'
2
2
  import { useMutation } from '@tanstack/react-query'
3
- import { toast } from 'sonner'
4
- import { adminClient } from '../client'
3
+ import { getApiClient, type PanelExport, type PanelExportCreateParams } from '../api-client'
5
4
  import { downloadFromApi } from '../lib/download'
6
5
  import { i18n } from '../lib/i18n'
7
6
  import { useAuth } from './use-auth'
8
7
 
8
+ /**
9
+ * The registered client's export methods.
10
+ *
11
+ * Throws rather than returning undefined: reaching here means a panel rendered
12
+ * the export dialog without registering the methods behind it, and every call
13
+ * would otherwise fail further away with a less useful message.
14
+ */
15
+ function exportsApi() {
16
+ const api = getApiClient().exports
17
+
18
+ if (!api) {
19
+ throw new Error(
20
+ '@spree/dashboard-core: this panel registered no `exports` on its API client, so it ' +
21
+ 'cannot queue an export. Register one, or do not render <ExportButton>.',
22
+ )
23
+ }
24
+
25
+ return api
26
+ }
27
+
9
28
  const POLL_INTERVAL_MS = 2000
10
29
  const POLL_TIMEOUT_MS = 5 * 60 * 1000
11
30
 
@@ -16,11 +35,11 @@ class ExportTimeoutError extends Error {
16
35
  }
17
36
  }
18
37
 
19
- async function pollUntilDone(id: string): Promise<Export> {
38
+ async function pollUntilDone(id: string): Promise<PanelExport> {
20
39
  const deadline = Date.now() + POLL_TIMEOUT_MS
21
40
 
22
41
  while (Date.now() < deadline) {
23
- const exp = await adminClient.exports.get(id)
42
+ const exp = await exportsApi().get(id)
24
43
  if (exp.done) return exp
25
44
  await new Promise<void>((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
26
45
  }
@@ -28,10 +47,15 @@ async function pollUntilDone(id: string): Promise<Export> {
28
47
  throw new ExportTimeoutError()
29
48
  }
30
49
 
31
- async function downloadExportFile(exp: Export, token: string | null): Promise<void> {
50
+ async function downloadExportFile(exp: PanelExport, token: string | null): Promise<void> {
32
51
  if (!exp.download_url) throw new Error('Export has no download_url')
33
52
 
34
- await downloadFromApi(token, exp.download_url, exp.filename ?? 'export.csv')
53
+ await downloadFromApi(
54
+ token,
55
+ exp.download_url,
56
+ exp.filename ?? 'export.csv',
57
+ getApiClient().downloadHeaders?.() ?? {},
58
+ )
35
59
  }
36
60
 
37
61
  /**
@@ -43,30 +67,41 @@ export function useExport() {
43
67
  const { token } = useAuth()
44
68
 
45
69
  return useMutation({
46
- mutationFn: async (params: ExportCreateParams) => {
70
+ mutationFn: async (params: PanelExportCreateParams) => {
47
71
  // Per-invocation id so concurrent exports don't collide on a single
48
72
  // sticky toast.
49
73
  const toastId = `export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
50
- toast.loading(i18n.t('admin.components.export_button.preparing'), { id: toastId })
74
+ toastManager.add({
75
+ type: 'loading',
76
+ title: i18n.t('admin.components.export_button.preparing'),
77
+ id: toastId,
78
+ })
51
79
 
52
80
  try {
53
- const created = await adminClient.exports.create(params)
81
+ const created = await exportsApi().create(params)
54
82
  const finished = await pollUntilDone(created.id)
55
83
  await downloadExportFile(finished, token)
56
- toast.success(i18n.t('admin.components.export_button.downloaded'), { id: toastId })
84
+ toastManager.add({
85
+ type: 'success',
86
+ title: i18n.t('admin.components.export_button.downloaded'),
87
+ id: toastId,
88
+ })
57
89
  return finished
58
90
  } catch (err) {
59
91
  if (err instanceof ExportTimeoutError) {
60
- toast.info(i18n.t('admin.components.export_button.email_fallback'), {
92
+ toastManager.add({
93
+ type: 'info',
94
+ title: i18n.t('admin.components.export_button.email_fallback'),
61
95
  id: toastId,
62
96
  })
63
97
  } else {
64
- toast.error(
65
- i18n.t('admin.components.export_button.failed', {
98
+ toastManager.add({
99
+ type: 'error',
100
+ title: i18n.t('admin.components.export_button.failed', {
66
101
  message: err instanceof Error ? err.message : String(err),
67
102
  }),
68
- { id: toastId },
69
- )
103
+ id: toastId,
104
+ })
70
105
  }
71
106
  throw err
72
107
  }