@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.
- package/README.md +4 -0
- package/dist/vite/index.js +17 -3
- package/package.json +7 -7
- package/src/api-client.ts +643 -0
- package/src/components/address-form-dialog.test.ts +25 -0
- package/src/components/address-form-dialog.tsx +203 -79
- package/src/components/app-shell.tsx +171 -0
- package/src/components/app-sidebar.tsx +184 -27
- package/src/components/bulk-action-bar.tsx +89 -157
- package/src/components/country-combobox.tsx +69 -34
- package/src/components/country-state-fields.tsx +43 -11
- package/src/components/currency-select.tsx +81 -66
- package/src/components/export-button.tsx +62 -5
- package/src/components/file-upload-field.tsx +32 -10
- package/src/components/import-button.tsx +97 -36
- package/src/components/import-wizard-dialog.tsx +710 -0
- package/src/components/locale-select.tsx +70 -57
- package/src/components/media-picker-sheet.tsx +307 -0
- package/src/components/mobile-top-bar.tsx +75 -0
- package/src/components/nav-main.tsx +197 -27
- package/src/components/page-header.tsx +203 -61
- package/src/components/page-tabs.tsx +4 -1
- package/src/components/preferences-form.tsx +98 -4
- package/src/components/resource-combobox.tsx +52 -4
- package/src/components/resource-picker-sheet.tsx +209 -73
- package/src/components/resource-table.tsx +425 -131
- package/src/components/route-announcer.tsx +64 -0
- package/src/components/section-heading.tsx +95 -0
- package/src/components/settings-sidebar.tsx +290 -60
- package/src/components/sheet-top-bar.tsx +44 -0
- package/src/components/sidebar-search.tsx +83 -0
- package/src/components/sidebar-user.tsx +213 -0
- package/src/components/skip-link.tsx +36 -0
- package/src/components/store-date-picker.tsx +6 -2
- package/src/components/store-switcher.tsx +63 -22
- package/src/components/table-toolbar.tsx +1373 -442
- package/src/components/tag-combobox.tsx +12 -4
- package/src/components/top-bar-breadcrumbs.tsx +235 -0
- package/src/components/wizard-close-guard.test.ts +54 -0
- package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
- package/src/hooks/use-command-palette.tsx +13 -0
- package/src/hooks/use-countries.test.ts +56 -0
- package/src/hooks/use-countries.ts +51 -5
- package/src/hooks/use-direct-upload.ts +16 -3
- package/src/hooks/use-export.ts +51 -16
- package/src/hooks/use-import.ts +261 -14
- package/src/hooks/use-package-types.ts +132 -0
- package/src/hooks/use-resource-mutation.ts +74 -13
- package/src/hooks/use-stock-locations.ts +131 -0
- package/src/hooks/use-switch-admin-locale.ts +2 -2
- package/src/hooks/use-visual-viewport-offset.ts +61 -0
- package/src/index.ts +45 -1
- package/src/lib/cancel-order-form.ts +27 -0
- package/src/lib/create-action-registry.ts +278 -0
- package/src/lib/date-presets.test.ts +86 -0
- package/src/lib/date-presets.ts +98 -0
- package/src/lib/delivery-form.ts +15 -0
- package/src/lib/docs.ts +32 -0
- package/src/lib/download.ts +13 -1
- package/src/lib/filters-to-ransack.test.ts +113 -0
- package/src/lib/filters-to-ransack.ts +22 -1
- package/src/lib/form-errors.ts +122 -10
- package/src/lib/form-mappers.ts +76 -0
- package/src/lib/fulfillment-items.test.ts +213 -0
- package/src/lib/fulfillment-items.ts +171 -0
- package/src/lib/i18n.ts +19 -11
- package/src/lib/import-types.ts +25 -0
- package/src/lib/label-upload-form.ts +22 -0
- package/src/lib/nav-registry.ts +71 -3
- package/src/lib/permissions.ts +46 -1
- package/src/lib/progress.ts +18 -0
- package/src/lib/query-keys.ts +13 -13
- package/src/lib/sample-csv.ts +21 -0
- package/src/lib/search-registry.ts +6 -2
- package/src/lib/settings-nav-registry.ts +104 -11
- package/src/lib/storefront.ts +19 -0
- package/src/lib/table-registry.ts +56 -0
- package/src/lib/type-labels.test.ts +68 -0
- package/src/lib/type-labels.ts +78 -0
- package/src/locales/ar.json +1889 -516
- package/src/locales/de.json +1883 -515
- package/src/locales/en.json +1886 -516
- package/src/locales/fr.json +1884 -516
- package/src/locales/pl.json +1913 -516
- package/src/locales/zh-CN.json +1883 -516
- package/src/pages/package-types.tsx +484 -0
- package/src/pages/settings-index.tsx +112 -0
- package/src/pages/stock-locations.tsx +674 -0
- package/src/plugin.ts +29 -0
- package/src/products/add-video-dialog.tsx +100 -0
- package/src/products/bulk-variants-dialog.tsx +301 -0
- package/src/products/carton-size.test.ts +51 -0
- package/src/products/carton-size.ts +20 -0
- package/src/products/currency-parts.ts +21 -0
- package/src/products/inventory-section.tsx +259 -0
- package/src/products/media-edit-sheet.tsx +336 -0
- package/src/products/normalize-customs.test.ts +39 -0
- package/src/products/normalize-customs.ts +27 -0
- package/src/products/normalize-money.test.ts +58 -0
- package/src/products/normalize-money.ts +73 -0
- package/src/products/normalize-quantity.test.ts +32 -0
- package/src/products/normalize-quantity.ts +31 -0
- package/src/products/product-bulk-price-editor.tsx +165 -0
- package/src/products/product-form-cards.tsx +1167 -0
- package/src/products/product-form-mapping.ts +212 -0
- package/src/products/product-schema.ts +318 -0
- package/src/products/product-types.ts +130 -0
- package/src/products/use-product-form-data.ts +273 -0
- package/src/products/variant-edit-sheet.tsx +725 -0
- package/src/products/variants-matrix.test.ts +154 -0
- package/src/products/variants-matrix.ts +244 -0
- package/src/products/variants-options-builder.tsx +571 -0
- package/src/products/variants-section.tsx +535 -0
- package/src/products/video-url.test.ts +71 -0
- package/src/products/video-url.ts +83 -0
- package/src/providers/auth-provider.tsx +70 -12
- package/src/providers/permission-provider.tsx +68 -28
- package/src/providers/sticky-header-provider.tsx +134 -0
- package/src/providers/store-provider.tsx +36 -3
- package/src/providers/tenant-provider.tsx +33 -0
- package/src/schemas/package-type.ts +95 -0
- package/src/schemas/stock-location.ts +126 -0
- package/src/tables/package-types.tsx +74 -0
- package/src/tables/stock-locations.tsx +124 -0
- package/src/vite/index.ts +40 -7
- package/src/components/top-bar.tsx +0 -210
|
@@ -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 {
|
|
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 {
|
|
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
|
|
24
|
-
*
|
|
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({
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
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
|
-
// `
|
|
44
|
-
// scrolls
|
|
45
|
-
//
|
|
46
|
-
// while
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
{
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
)
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
352
|
+
const isActive = isPathWithin(currentPath, url)
|
|
103
353
|
const Icon = entry.icon ?? PackageIcon
|
|
104
|
-
const label = entry
|
|
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
|
+
}
|