@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.
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
@@ -0,0 +1,64 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+
4
+ /**
5
+ * Keeps the document title in step with the page, and announces navigation.
6
+ *
7
+ * A single-page app changes neither by itself. Two things follow: every browser
8
+ * tab, history entry and bookmark reads "Spree Admin" regardless of where the
9
+ * merchant is, and a screen reader says nothing at all when a link swaps the
10
+ * whole page — the reader announces a title change on a document load, and the
11
+ * SPA never performs one.
12
+ *
13
+ * The title is read from the page's own `<h1>` rather than declared per route:
14
+ * there are forty-odd routes and plugins add more, so anything that has to be
15
+ * restated in each one is a list that drifts. The `h1` is already the page's
16
+ * name, already translated, and already carries the record's own name on a
17
+ * detail page ("R1001" rather than a generic "Order").
18
+ *
19
+ * A `MutationObserver` is what makes this reliable. The heading is not in the
20
+ * DOM on the first frame after a navigation — the route's data is still
21
+ * loading and the pending component is showing — so reading once on a path
22
+ * change finds either nothing or the previous page's heading.
23
+ */
24
+ export function RouteAnnouncer({ suffix }: { suffix?: string }) {
25
+ const { t } = useTranslation()
26
+ const [announcement, setAnnouncement] = useState('')
27
+ // The title as last applied, so an unchanged heading doesn't re-announce on
28
+ // every unrelated DOM mutation.
29
+ const lastTitle = useRef<string | null>(null)
30
+ const appName = suffix ?? t('admin.app_name', 'Spree Admin')
31
+
32
+ useEffect(() => {
33
+ const sync = () => {
34
+ const heading = document.querySelector('h1')
35
+ const pageTitle = heading?.textContent?.trim()
36
+ if (!pageTitle) return
37
+
38
+ const next = `${pageTitle} · ${appName}`
39
+ if (next === lastTitle.current) return
40
+
41
+ // The first heading of the session is the page the user just opened
42
+ // directly; the document title announces that on load, so repeating it
43
+ // here would be the same page said twice. Only a *change* of page is
44
+ // news, which is the case a single-page app otherwise says nothing about.
45
+ const isFirstResolve = lastTitle.current === null
46
+ lastTitle.current = next
47
+ document.title = next
48
+ // The heading text alone, not the full title: the app name is repeated
49
+ // on every page and adds nothing when spoken aloud.
50
+ if (!isFirstResolve) setAnnouncement(pageTitle)
51
+ }
52
+
53
+ sync()
54
+ const observer = new MutationObserver(sync)
55
+ observer.observe(document.body, { subtree: true, childList: true, characterData: true })
56
+ return () => observer.disconnect()
57
+ }, [appName])
58
+
59
+ return (
60
+ <div aria-live="polite" aria-atomic="true" className="sr-only">
61
+ {announcement}
62
+ </div>
63
+ )
64
+ }
@@ -0,0 +1,95 @@
1
+ import { CardTitle, cn, ExternalLink } from '@spree/dashboard-ui'
2
+ import { useTranslation } from 'react-i18next'
3
+ import { docsUrl } from '../lib/docs'
4
+
5
+ /**
6
+ * A card's title with an optional line under it saying what the list is for,
7
+ * and a link to the documentation for the feature.
8
+ *
9
+ * Worth writing for anything a merchant meets for the first time — catalogs,
10
+ * price lists, transfers — and worth leaving off a list that explains itself.
11
+ * `ResourceTable` renders this from the table definition; hand-built cards use
12
+ * it directly so both read the same.
13
+ */
14
+ export function SectionHeading({
15
+ title,
16
+ description,
17
+ docsPath,
18
+ className,
19
+ as,
20
+ }: {
21
+ title: React.ReactNode
22
+ description?: React.ReactNode
23
+ /** Relative to the user guide, or a full URL for a plugin's own docs. */
24
+ docsPath?: string
25
+ className?: string
26
+ /**
27
+ * Heading element to render the title as. Defaults to a plain `div`, which
28
+ * is right for a card sitting inside a page that already has its own
29
+ * heading. Pass `h1` when this heading *is* the page's title — a list page
30
+ * built from `ResourceTable` has no other one, so without it the page
31
+ * carries no heading at all and screen-reader users lose both the document
32
+ * outline and heading navigation.
33
+ */
34
+ as?: 'h1' | 'h2' | 'h3'
35
+ }) {
36
+ const { t } = useTranslation()
37
+
38
+ return (
39
+ <div className={cn('flex min-w-0 flex-col gap-1', className)}>
40
+ {/* A heading renders the same styles CardTitle carries, so a page title
41
+ and a card title are visually identical — only the element differs.
42
+ CardTitle is a plain div and cannot forward an element type, so the
43
+ heading is written out rather than routed through it. */}
44
+ {as ? (
45
+ <Heading level={as} className="min-w-0 truncate text-lg">
46
+ {title}
47
+ </Heading>
48
+ ) : (
49
+ <CardTitle className="min-w-0 truncate text-lg">{title}</CardTitle>
50
+ )}
51
+ {(description || docsPath) && (
52
+ <p className="text-muted-foreground text-sm">
53
+ {description}
54
+ {docsPath && (
55
+ <>
56
+ {description ? ' ' : null}
57
+ <ExternalLink href={docsUrl(docsPath)} className="text-sm">
58
+ {t('admin.common.learn_more')}
59
+ </ExternalLink>
60
+ </>
61
+ )}
62
+ </p>
63
+ )}
64
+ </div>
65
+ )
66
+ }
67
+
68
+ /**
69
+ * The title element for a `SectionHeading` that carries a heading level.
70
+ *
71
+ * Repeats CardTitle's own classes rather than importing them: the base
72
+ * stylesheet sizes bare `h1`–`h6` for prose (h1 is `text-4xl`), and a heading
73
+ * dropped into a card toolbar has to opt out of that or it renders at twice
74
+ * the intended size. Stating the classes here also keeps the two branches of
75
+ * `SectionHeading` visually identical by construction.
76
+ */
77
+ function Heading({
78
+ level,
79
+ className,
80
+ children,
81
+ }: {
82
+ level: 'h1' | 'h2' | 'h3'
83
+ className?: string
84
+ children: React.ReactNode
85
+ }) {
86
+ const Tag = level
87
+ return (
88
+ <Tag
89
+ data-slot="card-title"
90
+ className={cn('flex items-center gap-2 font-medium text-base', className)}
91
+ >
92
+ {children}
93
+ </Tag>
94
+ )
95
+ }
@@ -1,27 +1,38 @@
1
1
  import {
2
- Badge,
3
2
  cn,
3
+ mobileDrawerClassName,
4
+ SearchInput,
5
+ Sheet,
6
+ SheetContent,
7
+ SheetHeader,
8
+ SheetTitle,
9
+ SIDEBAR_WIDTH_MOBILE,
4
10
  SidebarGroup,
5
11
  SidebarGroupLabel,
12
+ SidebarHeader,
6
13
  SidebarMenu,
14
+ SidebarMenuBadge,
7
15
  SidebarMenuButton,
8
16
  SidebarMenuItem,
9
17
  } from '@spree/dashboard-ui'
18
+ import { ArrowLeftIcon, PackageIcon } from '@spree/dashboard-ui/icons'
10
19
  import { Link, useParams, useRouterState } from '@tanstack/react-router'
11
- import { PackageIcon } from 'lucide-react'
20
+ import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
12
21
  import { useTranslation } from 'react-i18next'
22
+ import { isPathWithin, resolveNavLabel } from '../lib/nav-registry'
13
23
  import {
24
+ filterSettingsByPermissions,
25
+ filterSettingsByQuery,
14
26
  type SettingsNavEntry,
15
- type SettingsNavSnapshot,
16
27
  useSettingsNav,
17
28
  } from '../lib/settings-nav-registry'
18
- import { type Permissions, usePermissions } from '../providers/permission-provider'
29
+ import { usePermissions } from '../providers/permission-provider'
19
30
  import { NavIcon } from './nav-main'
20
31
 
21
32
  /**
22
33
  * Secondary settings sidebar. Always mounted as a sibling to the primary
23
- * sidebar so it can extend full-height (top of viewport to bottom, beside
24
- * the TopBar rather than below it). Width animates between `0` and
34
+ * sidebar so it can extend the full height of the content sheet. Width
35
+ * animates between `0` and
25
36
  * `--spacing-sidebar-width` driven by the `open` prop, so entering and
26
37
  * leaving the settings area gets a slide-in/slide-out transition.
27
38
  *
@@ -32,60 +43,299 @@ import { NavIcon } from './nav-main'
32
43
  * Hidden below `lg` regardless of `open` — settings on narrow viewports
33
44
  * still need a separate solution.
34
45
  */
35
- export function SettingsSidebar({ open }: { open: boolean }) {
46
+ export function SettingsSidebar({
47
+ open,
48
+ tenantId,
49
+ }: {
50
+ open: boolean
51
+ /**
52
+ * The id the settings links are built under — a store for the operator's
53
+ * dashboard, a seller for the marketplace panel. Falls back to reading
54
+ * `storeId` from the route so the operator's dashboard needs no change;
55
+ * a panel routed on anything else passes its own, exactly as `useNavItems`
56
+ * takes one.
57
+ */
58
+ tenantId?: string
59
+ }) {
36
60
  const { t } = useTranslation()
37
61
  const { storeId } = useParams({ strict: false }) as { storeId?: string }
38
- const id = storeId ?? 'default'
39
- const snapshot = useSettingsNav()
40
- const { permissions } = usePermissions()
41
- const visible = filterByPermissions(snapshot, permissions)
62
+ // Held here rather than in the body: the search box lives in the header,
63
+ // and the list it filters scrolls below it.
64
+ const [query, setQuery] = useState('')
42
65
 
43
- // `sticky top-0 h-svh` keeps the nav at full viewport height as the page
44
- // scrolls. `overflow-hidden` clips the inner fixed-width content while the
45
- // outer `width` animates between 0 and `--spacing-sidebar-width`. `aria-hidden`
46
- // while closed prevents screen-reader and keyboard access to hidden links.
66
+ // `h-full` fills the content sheet, which is itself exactly viewport-height
67
+ // and scrolls internally — so the nav stays put as the page scrolls without
68
+ // needing to be sticky. `overflow-hidden` clips the inner fixed-width content
69
+ // while the outer `width` animates between 0 and `--spacing-sidebar-width`.
70
+ // `aria-hidden` while closed prevents screen-reader and keyboard access to
71
+ // hidden links.
47
72
  return (
48
73
  <aside
49
74
  aria-label={t('admin.a11y.settings_navigation')}
50
75
  aria-hidden={!open}
51
76
  data-state={open ? 'open' : 'closed'}
52
77
  className={cn(
53
- 'sticky top-0 z-30 hidden h-svh shrink-0 self-start overflow-hidden bg-sidebar text-sidebar-foreground transition-[width,border-color] duration-200 ease-out lg:block',
78
+ // `bg-card`, the content sheet's own colour: this rail is a column OF
79
+ // the sheet rather than an extension of the nav rail beside it, so it
80
+ // reads as part of the page it is navigating rather than as a second
81
+ // band of chrome.
82
+ 'z-30 hidden h-full shrink-0 overflow-hidden bg-muted text-sidebar-foreground transition-[width,border-color] duration-200 ease-out lg:block',
54
83
  open
55
- ? 'lg:w-(--spacing-sidebar-width) border-e border-border/50'
84
+ ? 'lg:w-(--spacing-sidebar-width) border-e border-border-subtle'
56
85
  : 'lg:w-0 border-e-0 border-transparent',
57
86
  )}
58
87
  >
59
88
  <div
60
89
  className={cn(
61
- 'flex h-full w-(--spacing-sidebar-width) flex-col gap-2 overflow-y-auto py-2 transition-opacity duration-200',
90
+ 'flex h-full w-(--spacing-sidebar-width) flex-col transition-opacity duration-200',
62
91
  open ? 'opacity-100 delay-100' : 'pointer-events-none opacity-0',
63
92
  )}
64
93
  >
65
- {visible.groups.map(({ group, entries }) => (
66
- <SidebarGroup key={group.key}>
67
- <SidebarGroupLabel>
68
- {group.labelKey ? t(group.labelKey) : group.label}
69
- </SidebarGroupLabel>
70
- <SidebarMenu>
71
- {entries.map((entry) => (
72
- <SettingsItem
73
- key={entry.key}
74
- entry={entry}
75
- storeId={id}
76
- // While closed, keep the items out of the tab order — the
77
- // `aria-hidden` above does not, by itself, prevent focus.
78
- tabIndex={open ? 0 : -1}
79
- />
80
- ))}
81
- </SidebarMenu>
82
- </SidebarGroup>
83
- ))}
94
+ {/* Names the area, offers the way out, and carries the search box —
95
+ the same pairing the primary sidebar's `SidebarHeader` holds, so
96
+ the two rails open to the same silhouette.
97
+
98
+ A sibling of the scroll area rather than a sticky child of it: the
99
+ fade below is a `mask-image` on the scrolling element, and a mask
100
+ applies to sticky descendants too — a header inside it would
101
+ dissolve along with the rows it is meant to stay above. */}
102
+ <SidebarHeader className="shrink-0 gap-2 pb-2">
103
+ {/* `h-rail-header-height` matches the store switcher opposite, so the
104
+ two line up. */}
105
+ <div className="flex h-rail-header-height items-center gap-1">
106
+ <Link
107
+ to={`/${tenantId ?? storeId}` as never}
108
+ tabIndex={open ? 0 : -1}
109
+ aria-label={t('admin.settings_page.back_to_dashboard')}
110
+ className="inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
111
+ >
112
+ <ArrowLeftIcon className="size-4" />
113
+ </Link>
114
+ <span className="truncate font-medium text-sm">{t('admin.settings_page.title')}</span>
115
+ </div>
116
+
117
+ <SettingsNavSearch value={query} onValueChange={setQuery} tabIndex={open ? 0 : -1} />
118
+ </SidebarHeader>
119
+
120
+ {/* `quiet-scrollbar` rather than the browser default: this nav is an
121
+ inset panel beside the primary sidebar, and a full-width native
122
+ scrollbar cuts a heavy grey stripe down the middle of the chrome.
123
+
124
+ `scroll-fade` dissolves the rows into the header above and the
125
+ panel's foot below, and tracks the scroll position — crisp at the
126
+ top until there is something scrolled past, clear again at the
127
+ bottom once the end is reached. Falls back to a static fade on both
128
+ edges where scroll-driven animations are unsupported. */}
129
+ <div className="quiet-scrollbar scroll-fade min-h-0 flex-1 overflow-y-auto">
130
+ <SettingsNavBody
131
+ tabIndex={open ? 0 : -1}
132
+ tenantId={tenantId}
133
+ query={query}
134
+ onQueryChange={setQuery}
135
+ renderSearch={false}
136
+ />
137
+ </div>
84
138
  </div>
85
139
  </aside>
86
140
  )
87
141
  }
88
142
 
143
+ /**
144
+ * The settings nav on a narrow screen, where `SettingsSidebar` is hidden.
145
+ * Without it the only way between two settings pages is a round trip through
146
+ * the landing page.
147
+ *
148
+ * Rendered as a sheet rather than an inline panel because the settings content
149
+ * needs the full width on a phone; `data-mobile` opts its rows into the same
150
+ * touch sizing the primary drawer uses.
151
+ */
152
+ /**
153
+ * Tracks the `lg` breakpoint the settings sheet is bounded by. `useIsMobile`
154
+ * cannot stand in: it is fixed at 768px, and closing the sheet there would
155
+ * strand it open across the 768-1024px band where it is still the only way to
156
+ * navigate settings.
157
+ */
158
+ const SETTINGS_SHEET_BREAKPOINT = 1024
159
+
160
+ function useIsSettingsSheetHidden() {
161
+ return useSyncExternalStore(
162
+ (onChange) => {
163
+ const query = window.matchMedia(`(min-width: ${SETTINGS_SHEET_BREAKPOINT}px)`)
164
+ query.addEventListener('change', onChange)
165
+ return () => query.removeEventListener('change', onChange)
166
+ },
167
+ () => window.matchMedia(`(min-width: ${SETTINGS_SHEET_BREAKPOINT}px)`).matches,
168
+ // Server render: assume the narrow layout so the sheet stays mountable.
169
+ () => false,
170
+ )
171
+ }
172
+
173
+ export function SettingsNavSheet({
174
+ open,
175
+ onOpenChange,
176
+ tenantId,
177
+ }: {
178
+ open: boolean
179
+ onOpenChange: (open: boolean) => void
180
+ /** See `SettingsSidebar` — a seller id on the marketplace panel. */
181
+ tenantId?: string
182
+ }) {
183
+ const { t } = useTranslation()
184
+ // Unmounted above `lg` rather than hidden with a utility class: `lg:hidden`
185
+ // would hide the panel but leave the portal's overlay painted and the modal
186
+ // focus trap armed, with nothing on screen to close it. Widening past the
187
+ // breakpoint while the sheet is open also reports the close, so the caller's
188
+ // state does not stay stuck open behind a desktop layout.
189
+ const isDesktop = useIsSettingsSheetHidden()
190
+
191
+ useEffect(() => {
192
+ if (isDesktop && open) onOpenChange(false)
193
+ }, [isDesktop, open, onOpenChange])
194
+
195
+ if (isDesktop) return null
196
+
197
+ return (
198
+ <Sheet open={open} onOpenChange={onOpenChange}>
199
+ <SheetContent
200
+ side="left"
201
+ data-mobile="true"
202
+ className={cn(mobileDrawerClassName, 'gap-0')}
203
+ style={{ width: SIDEBAR_WIDTH_MOBILE }}
204
+ >
205
+ {/* `h-rail-header-height` matches the store header in the primary drawer, so
206
+ the two nav sheets open to the same silhouette — and it gives the
207
+ close button a full-height touch target rather than a 42px band. */}
208
+ <SheetHeader className="h-rail-header-height justify-center border-b border-sidebar-border px-4 py-0">
209
+ {/* Held at the default body size rather than the larger sheet
210
+ title: this is a nav band matched to the store header, not a
211
+ dialog heading. */}
212
+ <SheetTitle className="text-base">{t('admin.settings_page.title')}</SheetTitle>
213
+ </SheetHeader>
214
+ <div className="quiet-scrollbar flex-1 overflow-y-auto">
215
+ {/* Close as the link is tapped rather than from an effect watching the
216
+ path: this body renders inside the Sheet, so it remounts on every
217
+ open and an effect could not tell "just opened" from "navigated". */}
218
+ <SettingsNavBody
219
+ tabIndex={0}
220
+ onNavigate={() => onOpenChange(false)}
221
+ tenantId={tenantId}
222
+ />
223
+ </div>
224
+ </SheetContent>
225
+ </Sheet>
226
+ )
227
+ }
228
+
229
+ /**
230
+ * The settings search box. Extracted so the desktop rail can render it in its
231
+ * fixed header while the sheet keeps it at the top of its scroll body.
232
+ */
233
+ function SettingsNavSearch({
234
+ value,
235
+ onValueChange,
236
+ tabIndex,
237
+ }: {
238
+ value: string
239
+ onValueChange: (next: string) => void
240
+ tabIndex: number
241
+ }) {
242
+ const { t } = useTranslation()
243
+ return (
244
+ <SearchInput
245
+ value={value}
246
+ onValueChange={onValueChange}
247
+ placeholder={t('admin.settings_page.search_placeholder')}
248
+ aria-label={t('admin.settings_page.search_placeholder')}
249
+ clearLabel={t('admin.common.clear')}
250
+ tabIndex={tabIndex}
251
+ className="h-8 text-sm in-data-[mobile=true]:h-11 in-data-[mobile=true]:text-base"
252
+ />
253
+ )
254
+ }
255
+
256
+ /** Search box plus grouped entries — shared by the desktop aside and the sheet. */
257
+ function SettingsNavBody({
258
+ tabIndex,
259
+ onNavigate,
260
+ tenantId,
261
+ query: controlledQuery,
262
+ onQueryChange,
263
+ renderSearch = true,
264
+ }: {
265
+ tabIndex: number
266
+ /** Called when an entry is tapped — closes the mobile sheet. */
267
+ onNavigate?: () => void
268
+ /** See `SettingsSidebar` — a seller id on the marketplace panel. */
269
+ tenantId?: string
270
+ /**
271
+ * Controlled query, passed by the desktop rail whose search box sits in the
272
+ * header above this body. The sheet leaves both unset and keeps its own
273
+ * state, since its search scrolls with the list.
274
+ */
275
+ query?: string
276
+ onQueryChange?: (next: string) => void
277
+ /** False when the caller renders the search box itself (the desktop rail). */
278
+ renderSearch?: boolean
279
+ }) {
280
+ const { t } = useTranslation()
281
+ const { storeId } = useParams({ strict: false }) as { storeId?: string }
282
+ const id = tenantId ?? storeId ?? 'default'
283
+ const snapshot = useSettingsNav()
284
+ const { permissions } = usePermissions()
285
+ const [uncontrolledQuery, setUncontrolledQuery] = useState('')
286
+ const query = controlledQuery ?? uncontrolledQuery
287
+ const setQuery = onQueryChange ?? setUncontrolledQuery
288
+ // Permission filtering depends only on the snapshot, so it survives typing.
289
+ const allowed = useMemo(
290
+ () => filterSettingsByPermissions(snapshot, permissions),
291
+ [snapshot, permissions],
292
+ )
293
+ const visible = useMemo(() => filterSettingsByQuery(allowed, query, t), [allowed, query, t])
294
+
295
+ return (
296
+ // Delegated rather than per-link: entries render through `asChild` Slots
297
+ // that clone their own `onClick` over the child's.
298
+ // biome-ignore lint/a11y/noStaticElementInteractions: delegated link taps only
299
+ // biome-ignore lint/a11y/useKeyWithClickEvents: links keep their own keyboard behaviour
300
+ <div
301
+ className={cn('flex flex-col gap-2 pb-2', renderSearch && 'pt-2')}
302
+ onClick={(event) => {
303
+ if ((event.target as HTMLElement).closest('a')) onNavigate?.()
304
+ }}
305
+ >
306
+ {renderSearch && (
307
+ <div className="px-2">
308
+ <SettingsNavSearch value={query} onValueChange={setQuery} tabIndex={tabIndex} />
309
+ </div>
310
+ )}
311
+
312
+ {visible.groups.length === 0 && (
313
+ <p className="px-4 py-2 text-sm text-sidebar-foreground/70">
314
+ {t('admin.settings_page.no_results', { query: query.trim() })}
315
+ </p>
316
+ )}
317
+
318
+ {visible.groups.map(({ group, entries }) => (
319
+ <SidebarGroup key={group.key}>
320
+ <SidebarGroupLabel>{resolveNavLabel(group, t)}</SidebarGroupLabel>
321
+ <SidebarMenu>
322
+ {entries.map((entry) => (
323
+ <SettingsItem
324
+ key={entry.key}
325
+ entry={entry}
326
+ storeId={id}
327
+ // While the desktop panel is closed, keep items out of the tab
328
+ // order — `aria-hidden` does not, by itself, prevent focus.
329
+ tabIndex={tabIndex}
330
+ />
331
+ ))}
332
+ </SidebarMenu>
333
+ </SidebarGroup>
334
+ ))}
335
+ </div>
336
+ )
337
+ }
338
+
89
339
  function SettingsItem({
90
340
  entry,
91
341
  storeId,
@@ -99,9 +349,9 @@ function SettingsItem({
99
349
  const routerState = useRouterState()
100
350
  const currentPath = routerState.location.pathname
101
351
  const url = `/${storeId}/settings${entry.path}`
102
- const isActive = currentPath === url || currentPath.startsWith(`${url}/`)
352
+ const isActive = isPathWithin(currentPath, url)
103
353
  const Icon = entry.icon ?? PackageIcon
104
- const label = entry.labelKey ? t(entry.labelKey) : entry.label
354
+ const label = resolveNavLabel(entry, t)
105
355
 
106
356
  return (
107
357
  <SidebarMenuItem>
@@ -111,29 +361,9 @@ function SettingsItem({
111
361
  <Link to={url} tabIndex={tabIndex}>
112
362
  <NavIcon icon={Icon} isActive={isActive} />
113
363
  <span>{label}</span>
114
- {entry.comingSoon && (
115
- <Badge className="ms-auto h-5 bg-sidebar-accent px-1.5 py-0 text-[10px] font-normal text-sidebar-foreground/70">
116
- Soon
117
- </Badge>
118
- )}
364
+ {entry.comingSoon && <SidebarMenuBadge className="ms-auto">Soon</SidebarMenuBadge>}
119
365
  </Link>
120
366
  </SidebarMenuButton>
121
367
  </SidebarMenuItem>
122
368
  )
123
369
  }
124
-
125
- function filterByPermissions(
126
- snapshot: SettingsNavSnapshot,
127
- permissions: Permissions,
128
- ): SettingsNavSnapshot {
129
- const groups = snapshot.groups
130
- .map(({ group, entries }) => ({
131
- group,
132
- entries: entries.filter((e) => !e.subject || permissions.can('read', e.subject)),
133
- }))
134
- .filter((g) => g.entries.length > 0)
135
- return {
136
- groups,
137
- all: groups.flatMap((g) => g.entries),
138
- }
139
- }
@@ -0,0 +1,44 @@
1
+ import { SidebarTrigger, useSidebar } from '@spree/dashboard-ui'
2
+ import { TopBarBreadcrumbs } from './top-bar-breadcrumbs'
3
+
4
+ /**
5
+ * The thin row at the top of the content sheet, shown only while the nav rail
6
+ * is collapsed: the trigger that brings the rail back, and the location trail.
7
+ *
8
+ * Both belong to the same moment. The trigger is here rather than in the rail
9
+ * because a collapsed rail has no room for it, and the control that restores
10
+ * the nav should not live inside the thing that is hidden. The trail answers
11
+ * the question the collapsed rail stops answering — where in the nav am I —
12
+ * so with the rail open, where the active item is already lit, it would only
13
+ * restate what is on screen and cost a row of chrome to do it.
14
+ *
15
+ * One condition for both, so the band appears and disappears as a unit rather
16
+ * than leaving a stray trail floating above the page.
17
+ */
18
+ export function SheetTopBar({
19
+ tenantId,
20
+ inSettings = false,
21
+ }: {
22
+ tenantId?: string
23
+ /**
24
+ * Hides the rail trigger. Inside settings the rail is collapsed by the app
25
+ * rather than by the merchant, and the settings rail beside it carries its
26
+ * own way out — so a control offering to re-expand a rail the area will just
27
+ * re-collapse would not do what it says. The trail still shows.
28
+ */
29
+ inSettings?: boolean
30
+ }) {
31
+ const { isMobile, state } = useSidebar()
32
+ const collapsed = state === 'collapsed' && !isMobile
33
+
34
+ if (!collapsed) return null
35
+
36
+ return (
37
+ <div className="hidden min-h-8 items-center gap-2 px-4 pt-3 md:flex lg:px-6">
38
+ {!inSettings && (
39
+ <SidebarTrigger className="-ms-1 shrink-0 text-muted-foreground hover:bg-accent hover:text-foreground" />
40
+ )}
41
+ <TopBarBreadcrumbs tenantId={tenantId} />
42
+ </div>
43
+ )
44
+ }