@spree/dashboard-ui 0.1.0
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/LICENSE +21 -0
- package/README.md +26 -0
- package/package.json +86 -0
- package/src/hooks/use-copy-to-clipboard.ts +44 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-scrolled.ts +30 -0
- package/src/index.ts +99 -0
- package/src/lib/date-locale.ts +29 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/validation-messages.ts +19 -0
- package/src/spree/address-block.tsx +74 -0
- package/src/spree/back-button.tsx +48 -0
- package/src/spree/bulk-dialog.tsx +71 -0
- package/src/spree/bulk-price-table.tsx +254 -0
- package/src/spree/calculator-summary.tsx +120 -0
- package/src/spree/color-picker.tsx +202 -0
- package/src/spree/confirm-dialog.tsx +82 -0
- package/src/spree/copy-to-clipboard-button.tsx +90 -0
- package/src/spree/country-flag.tsx +30 -0
- package/src/spree/data-grid/cells.tsx +415 -0
- package/src/spree/data-grid/context.ts +59 -0
- package/src/spree/data-grid/data-grid.tsx +214 -0
- package/src/spree/data-grid/fill-handle.tsx +153 -0
- package/src/spree/data-grid/helpers.ts +18 -0
- package/src/spree/data-grid/index.ts +5 -0
- package/src/spree/data-grid/types.ts +36 -0
- package/src/spree/data-grid/use-data-grid-keyboard.ts +254 -0
- package/src/spree/data-grid/use-stable-cell-registration.ts +45 -0
- package/src/spree/drag-handle.tsx +51 -0
- package/src/spree/form-actions.tsx +140 -0
- package/src/spree/json-preview-drawer.tsx +254 -0
- package/src/spree/json-value-view.tsx +149 -0
- package/src/spree/language-menu-items.tsx +54 -0
- package/src/spree/metadata/metadata-card.tsx +62 -0
- package/src/spree/relative-time.tsx +49 -0
- package/src/spree/resource-combobox.tsx +108 -0
- package/src/spree/resource-layout.tsx +31 -0
- package/src/spree/resource-multi-autocomplete.tsx +180 -0
- package/src/spree/resource-name-cell.tsx +44 -0
- package/src/spree/route-error-boundary.tsx +102 -0
- package/src/spree/row-actions.tsx +85 -0
- package/src/spree/row-click-bridge.tsx +17 -0
- package/src/spree/secret-input.tsx +137 -0
- package/src/spree/storefront-visible-switch.tsx +58 -0
- package/src/spree/tag-list.tsx +26 -0
- package/src/spree/theme-provider.tsx +83 -0
- package/src/spree/theme-toggle.tsx +35 -0
- package/src/styles.css +338 -0
- package/src/ui/attachment.tsx +187 -0
- package/src/ui/avatar.tsx +94 -0
- package/src/ui/badge.tsx +161 -0
- package/src/ui/breadcrumb.tsx +109 -0
- package/src/ui/button.tsx +60 -0
- package/src/ui/calendar.tsx +66 -0
- package/src/ui/card.tsx +72 -0
- package/src/ui/chart.tsx +94 -0
- package/src/ui/checkbox.tsx +50 -0
- package/src/ui/collapsible.tsx +18 -0
- package/src/ui/combobox.tsx +270 -0
- package/src/ui/command.tsx +172 -0
- package/src/ui/data-table.tsx +60 -0
- package/src/ui/date-picker.tsx +253 -0
- package/src/ui/date-range-picker.tsx +150 -0
- package/src/ui/dialog.tsx +214 -0
- package/src/ui/dropdown-menu.tsx +345 -0
- package/src/ui/empty.tsx +94 -0
- package/src/ui/field.tsx +221 -0
- package/src/ui/input-group.tsx +141 -0
- package/src/ui/input.tsx +19 -0
- package/src/ui/label.tsx +29 -0
- package/src/ui/pagination.tsx +123 -0
- package/src/ui/popover.tsx +76 -0
- package/src/ui/progress.tsx +73 -0
- package/src/ui/radio-group.tsx +42 -0
- package/src/ui/rich-text-editor.tsx +263 -0
- package/src/ui/select.tsx +213 -0
- package/src/ui/separator.tsx +31 -0
- package/src/ui/sheet.tsx +208 -0
- package/src/ui/sidebar.tsx +679 -0
- package/src/ui/skeleton.tsx +13 -0
- package/src/ui/slot.tsx +35 -0
- package/src/ui/sonner.tsx +47 -0
- package/src/ui/switch.tsx +39 -0
- package/src/ui/textarea.tsx +18 -0
- package/src/ui/tooltip.tsx +100 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vendo Connect Inc., Vendo Sp. z o.o.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# @spree/dashboard-ui
|
|
2
|
+
|
|
3
|
+
Spree Dashboard design system. Shadcn primitives + headless composed components + design tokens.
|
|
4
|
+
|
|
5
|
+
> **Developer Preview.** APIs may change between 0.x releases. Docs: [React Dashboard](https://spreecommerce.org/docs/developer/dashboard/overview).
|
|
6
|
+
|
|
7
|
+
## What's inside
|
|
8
|
+
|
|
9
|
+
- `src/ui/` — shadcn primitives (Button, Input, Dialog, Sheet, Table, …). Copy-paste owned: fork into your own project as needed.
|
|
10
|
+
- `src/spree/` — composed components built on the primitives (PageHeader, ResourceTable, AppSidebar, …). **All headless: data and callbacks come in via props.** No provider imports, no hook calls, no SDK access.
|
|
11
|
+
- `src/styles.css` — design tokens + Tailwind theme. Import once from your Vite app.
|
|
12
|
+
- `src/lib/` — generic helpers (`cn`, formatters, …).
|
|
13
|
+
|
|
14
|
+
The headless rule is what makes the components composable from outside `@spree/dashboard` — a plugin author, an app developer building a vendor panel, or a third-party using just `@spree/dashboard-ui` can all instantiate the same components with their own data sources.
|
|
15
|
+
|
|
16
|
+
## Source-only package
|
|
17
|
+
|
|
18
|
+
This package ships **TypeScript source**, not a compiled bundle. Consumers compile it through their own Vite + Tailwind setup. This is required because Tailwind v4 scans source files in the consuming app for utility classes; a pre-compiled CSS bundle would miss classes only used by source-imported components. The `spreeDashboardPlugin` from `@spree/dashboard-core/vite` wires the scanning up for you.
|
|
19
|
+
|
|
20
|
+
## Peer dependencies
|
|
21
|
+
|
|
22
|
+
The consuming app provides `react`, `react-dom`, `@tanstack/react-router`, `@tanstack/react-hotkeys`, `react-hook-form`, `i18next`, and `react-i18next` — see `peerDependencies` in `package.json` for the exact ranges.
|
|
23
|
+
|
|
24
|
+
## What's NOT here
|
|
25
|
+
|
|
26
|
+
Anything that needs a provider (auth, store, theme), a TanStack Query hook, or the Admin SDK lives in `@spree/dashboard-core`. The split is: **dashboard-ui is the design system, dashboard-core is the framework**.
|
package/package.json
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@spree/dashboard-ui",
|
|
3
|
+
"license": "MIT",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Spree Dashboard design system — shadcn primitives, headless composed components, and design tokens. Source-only package; consumers compile via their own Vite/Tailwind setup. Developer Preview.",
|
|
6
|
+
"author": "Vendo Connect Inc.",
|
|
7
|
+
"homepage": "https://spreecommerce.org",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/spree/spree.git",
|
|
11
|
+
"directory": "packages/dashboard-ui"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"spree",
|
|
15
|
+
"commerce",
|
|
16
|
+
"ecommerce",
|
|
17
|
+
"admin",
|
|
18
|
+
"dashboard",
|
|
19
|
+
"react"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"main": "./src/index.ts",
|
|
23
|
+
"module": "./src/index.ts",
|
|
24
|
+
"types": "./src/index.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": "./src/index.ts",
|
|
27
|
+
"./styles.css": "./src/styles.css",
|
|
28
|
+
"./ui/*": "./src/ui/*.tsx",
|
|
29
|
+
"./spree/*": "./src/spree/*.tsx",
|
|
30
|
+
"./lib/*": "./src/lib/*.ts",
|
|
31
|
+
"./hooks/*": "./src/hooks/*.ts"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"src",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@tanstack/react-hotkeys": "^0.10.0",
|
|
40
|
+
"@tanstack/react-router": "^1.169.2",
|
|
41
|
+
"i18next": "^26.2.0",
|
|
42
|
+
"react": "^19.2.6",
|
|
43
|
+
"react-dom": "^19.2.6",
|
|
44
|
+
"react-hook-form": "^7.75.0",
|
|
45
|
+
"react-i18next": "^17.0.8"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@base-ui/react": "^1.4.1",
|
|
49
|
+
"@dnd-kit/core": "^6.3.1",
|
|
50
|
+
"@dnd-kit/sortable": "^10.0.0",
|
|
51
|
+
"@dnd-kit/utilities": "^3.2.2",
|
|
52
|
+
"@fontsource-variable/geist": "^5.2.8",
|
|
53
|
+
"@fontsource-variable/geist-mono": "^5.2.7",
|
|
54
|
+
"@tanstack/react-table": "^8.21.3",
|
|
55
|
+
"@tiptap/extension-link": "^3.22.5",
|
|
56
|
+
"@tiptap/extension-placeholder": "^3.22.5",
|
|
57
|
+
"@tiptap/react": "^3.22.5",
|
|
58
|
+
"@tiptap/starter-kit": "^3.22.5",
|
|
59
|
+
"@uiw/react-json-view": "^1.12",
|
|
60
|
+
"class-variance-authority": "^0.7.1",
|
|
61
|
+
"clsx": "^2.1.1",
|
|
62
|
+
"cmdk": "^1.1.1",
|
|
63
|
+
"date-fns": "^4.1.0",
|
|
64
|
+
"date-fns-tz": "^3.2.0",
|
|
65
|
+
"flag-icons": "^7.5.0",
|
|
66
|
+
"lucide-react": "^1.14.0",
|
|
67
|
+
"react-colorful": "^5.7.0",
|
|
68
|
+
"react-day-picker": "^9.14.0",
|
|
69
|
+
"recharts": "^3.8.1",
|
|
70
|
+
"shadcn": "^4.7.0",
|
|
71
|
+
"sonner": "^2.0.7",
|
|
72
|
+
"tailwind-merge": "^3.5.0",
|
|
73
|
+
"tw-animate-css": "^1.4.0"
|
|
74
|
+
},
|
|
75
|
+
"devDependencies": {
|
|
76
|
+
"@types/react": "^19.2.14",
|
|
77
|
+
"@types/react-dom": "^19.2.3",
|
|
78
|
+
"typescript": "~6.0.3"
|
|
79
|
+
},
|
|
80
|
+
"scripts": {
|
|
81
|
+
"lint": "biome check src",
|
|
82
|
+
"lint:fix": "biome check --fix src",
|
|
83
|
+
"format": "biome format --write src",
|
|
84
|
+
"typecheck": "tsc --noEmit"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
interface UseCopyToClipboardOptions {
|
|
4
|
+
/** Reset `copied` back to false after this many ms. Default 1200. */
|
|
5
|
+
resetMs?: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Wraps `navigator.clipboard.writeText` with a `copied` flag that flashes true
|
|
10
|
+
* for `resetMs` after a successful copy. Failure (insecure context, denied
|
|
11
|
+
* permissions) is swallowed; the flag stays false.
|
|
12
|
+
*
|
|
13
|
+
* The reset timer is cleared on unmount so the component doesn't try to
|
|
14
|
+
* `setState` after it's gone.
|
|
15
|
+
*/
|
|
16
|
+
export function useCopyToClipboard({ resetMs = 1200 }: UseCopyToClipboardOptions = {}) {
|
|
17
|
+
const [copied, setCopied] = useState(false)
|
|
18
|
+
const timerRef = useRef<number | null>(null)
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
return () => {
|
|
22
|
+
if (timerRef.current !== null) clearTimeout(timerRef.current)
|
|
23
|
+
}
|
|
24
|
+
}, [])
|
|
25
|
+
|
|
26
|
+
const copy = useCallback(
|
|
27
|
+
async (value: string) => {
|
|
28
|
+
try {
|
|
29
|
+
await navigator.clipboard.writeText(value)
|
|
30
|
+
setCopied(true)
|
|
31
|
+
if (timerRef.current !== null) clearTimeout(timerRef.current)
|
|
32
|
+
timerRef.current = window.setTimeout(() => {
|
|
33
|
+
setCopied(false)
|
|
34
|
+
timerRef.current = null
|
|
35
|
+
}, resetMs)
|
|
36
|
+
} catch {
|
|
37
|
+
// Clipboard API can fail in insecure contexts; ignore silently.
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
[resetMs],
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
return { copied, copy }
|
|
44
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
|
|
3
|
+
const MOBILE_BREAKPOINT = 768
|
|
4
|
+
|
|
5
|
+
export function useIsMobile() {
|
|
6
|
+
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
|
7
|
+
|
|
8
|
+
React.useEffect(() => {
|
|
9
|
+
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
|
10
|
+
const onChange = () => {
|
|
11
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
12
|
+
}
|
|
13
|
+
mql.addEventListener('change', onChange)
|
|
14
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
15
|
+
return () => mql.removeEventListener('change', onChange)
|
|
16
|
+
}, [])
|
|
17
|
+
|
|
18
|
+
return !!isMobile
|
|
19
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns true once the document has been scrolled past `threshold` pixels
|
|
5
|
+
* from the top, false at rest. Used to fade in subtle elevation on sticky
|
|
6
|
+
* headers, top bars, etc. so they don't look heavy when nothing is scrolled
|
|
7
|
+
* behind them.
|
|
8
|
+
*
|
|
9
|
+
* Listens passively to `window` scroll. Default threshold is 4px to avoid
|
|
10
|
+
* flickering at the boundary on devices that report fractional scroll
|
|
11
|
+
* positions. Initial value is computed synchronously on mount so the first
|
|
12
|
+
* paint matches the actual scroll position (e.g., after browser scroll
|
|
13
|
+
* restoration on navigation).
|
|
14
|
+
*/
|
|
15
|
+
export function useScrolled(threshold = 4) {
|
|
16
|
+
// Always start `false` so a future SSR render and the first client paint
|
|
17
|
+
// agree (no hydration mismatch). The effect below resyncs against the
|
|
18
|
+
// actual scroll position on mount, so any restored scroll position is
|
|
19
|
+
// reflected as soon as the effect runs.
|
|
20
|
+
const [scrolled, setScrolled] = React.useState(false)
|
|
21
|
+
|
|
22
|
+
React.useEffect(() => {
|
|
23
|
+
const onScroll = () => setScrolled(window.scrollY > threshold)
|
|
24
|
+
onScroll()
|
|
25
|
+
window.addEventListener('scroll', onScroll, { passive: true })
|
|
26
|
+
return () => window.removeEventListener('scroll', onScroll)
|
|
27
|
+
}, [threshold])
|
|
28
|
+
|
|
29
|
+
return scrolled
|
|
30
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// @spree/dashboard-ui — Spree Dashboard design system.
|
|
2
|
+
//
|
|
3
|
+
// Shadcn primitives + headless composed components + design tokens.
|
|
4
|
+
// Consumers import flat from this barrel:
|
|
5
|
+
//
|
|
6
|
+
// import { Button, Card, PageHeader } from '@spree/dashboard-ui'
|
|
7
|
+
//
|
|
8
|
+
// Or import a specific file (for forking, lazy-loading, or to skip the barrel):
|
|
9
|
+
//
|
|
10
|
+
// import { Button } from '@spree/dashboard-ui/ui/button'
|
|
11
|
+
//
|
|
12
|
+
// CSS lives at `@spree/dashboard-ui/styles.css` — import once from your Vite app.
|
|
13
|
+
|
|
14
|
+
export * from './hooks/use-copy-to-clipboard'
|
|
15
|
+
export { useIsMobile } from './hooks/use-mobile'
|
|
16
|
+
export * from './hooks/use-scrolled'
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Helpers + hooks
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
export { cn } from './lib/utils'
|
|
21
|
+
export { requiredMessage } from './lib/validation-messages'
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Spree composed components — headless, accept data via props
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
export * from './spree/address-block'
|
|
26
|
+
export * from './spree/back-button'
|
|
27
|
+
export * from './spree/bulk-dialog'
|
|
28
|
+
export * from './spree/bulk-price-table'
|
|
29
|
+
export * from './spree/calculator-summary'
|
|
30
|
+
export * from './spree/color-picker'
|
|
31
|
+
export * from './spree/confirm-dialog'
|
|
32
|
+
export * from './spree/copy-to-clipboard-button'
|
|
33
|
+
export * from './spree/country-flag'
|
|
34
|
+
export * from './spree/data-grid'
|
|
35
|
+
export * from './spree/drag-handle'
|
|
36
|
+
export * from './spree/form-actions'
|
|
37
|
+
|
|
38
|
+
// JsonPreviewDrawer and JsonValueView are intentionally NOT re-exported from
|
|
39
|
+
// this barrel: they pull in `@uiw/react-json-view` (~30KB gzip), and
|
|
40
|
+
// code-splitting only works when consumers can deep-import via
|
|
41
|
+
// `@spree/dashboard-ui/spree/json-preview-drawer` and
|
|
42
|
+
// `@spree/dashboard-ui/spree/json-value-view`. Types are available the same
|
|
43
|
+
// way — `import { type JsonPreviewDrawerProps } from '@spree/dashboard-ui/spree/json-preview-drawer'`.
|
|
44
|
+
|
|
45
|
+
export * from './spree/language-menu-items'
|
|
46
|
+
export * from './spree/metadata/metadata-card'
|
|
47
|
+
export * from './spree/relative-time'
|
|
48
|
+
export * from './spree/resource-combobox'
|
|
49
|
+
export * from './spree/resource-layout'
|
|
50
|
+
export * from './spree/resource-multi-autocomplete'
|
|
51
|
+
export * from './spree/resource-name-cell'
|
|
52
|
+
export * from './spree/route-error-boundary'
|
|
53
|
+
export * from './spree/row-actions'
|
|
54
|
+
export * from './spree/row-click-bridge'
|
|
55
|
+
export * from './spree/secret-input'
|
|
56
|
+
export * from './spree/storefront-visible-switch'
|
|
57
|
+
export * from './spree/tag-list'
|
|
58
|
+
export * from './spree/theme-provider'
|
|
59
|
+
export * from './spree/theme-toggle'
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// UI primitives (shadcn) — see ./ui/*
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
export * from './ui/attachment'
|
|
64
|
+
export * from './ui/avatar'
|
|
65
|
+
export * from './ui/badge'
|
|
66
|
+
export * from './ui/breadcrumb'
|
|
67
|
+
export * from './ui/button'
|
|
68
|
+
export * from './ui/calendar'
|
|
69
|
+
export * from './ui/card'
|
|
70
|
+
export * from './ui/chart'
|
|
71
|
+
export * from './ui/checkbox'
|
|
72
|
+
export * from './ui/collapsible'
|
|
73
|
+
export * from './ui/combobox'
|
|
74
|
+
export * from './ui/command'
|
|
75
|
+
export * from './ui/data-table'
|
|
76
|
+
export * from './ui/date-picker'
|
|
77
|
+
export * from './ui/date-range-picker'
|
|
78
|
+
export * from './ui/dialog'
|
|
79
|
+
export * from './ui/dropdown-menu'
|
|
80
|
+
export * from './ui/empty'
|
|
81
|
+
export * from './ui/field'
|
|
82
|
+
export * from './ui/input'
|
|
83
|
+
export * from './ui/input-group'
|
|
84
|
+
export * from './ui/label'
|
|
85
|
+
export * from './ui/pagination'
|
|
86
|
+
export * from './ui/popover'
|
|
87
|
+
export * from './ui/progress'
|
|
88
|
+
export * from './ui/radio-group'
|
|
89
|
+
export * from './ui/rich-text-editor'
|
|
90
|
+
export * from './ui/select'
|
|
91
|
+
export * from './ui/separator'
|
|
92
|
+
export * from './ui/sheet'
|
|
93
|
+
export * from './ui/sidebar'
|
|
94
|
+
export * from './ui/skeleton'
|
|
95
|
+
export * from './ui/slot'
|
|
96
|
+
export * from './ui/sonner'
|
|
97
|
+
export * from './ui/switch'
|
|
98
|
+
export * from './ui/textarea'
|
|
99
|
+
export * from './ui/tooltip'
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as dateFnsLocales from 'date-fns/locale'
|
|
2
|
+
import i18n from 'i18next'
|
|
3
|
+
import type { Locale } from 'react-day-picker'
|
|
4
|
+
|
|
5
|
+
// date-fns ships ~96 locales; we resolve one by the active i18n language code
|
|
6
|
+
// instead of maintaining a hand-kept map (which silently drifted to English
|
|
7
|
+
// whenever a new UI bundle shipped without a matching entry here). The whole
|
|
8
|
+
// locale set is imported, so any language a translation bundle adds is covered
|
|
9
|
+
// with no edit to this file.
|
|
10
|
+
const LOCALES = dateFnsLocales as unknown as Record<string, Locale>
|
|
11
|
+
|
|
12
|
+
// i18next codes are BCP-47-ish (`en`, `pt-BR`, `zh-CN`); date-fns export names
|
|
13
|
+
// are camelCase (`enUS`, `ptBR`, `zhCN`). Convert `-x` → `X` to match.
|
|
14
|
+
function dateFnsKey(code: string): string {
|
|
15
|
+
return code.replace(/-(\w)/g, (_, c: string) => c.toUpperCase())
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Resolve a date-fns Locale for an i18n language code. Tries the exact code,
|
|
19
|
+
// then the base language (`pt-BR` → `pt`), and finally falls back to `enUS`.
|
|
20
|
+
// `en` has no bare date-fns export, so it resolves through the fallback.
|
|
21
|
+
function resolveDateLocale(code: string): Locale {
|
|
22
|
+
const base = code.split('-')[0]
|
|
23
|
+
return LOCALES[dateFnsKey(code)] ?? LOCALES[base] ?? LOCALES.enUS
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** date-fns locale for the active admin UI language (falls back to English). */
|
|
27
|
+
export function activeDateLocale(): Locale {
|
|
28
|
+
return resolveDateLocale(i18n.language ?? 'en')
|
|
29
|
+
}
|
package/src/lib/utils.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import i18n from 'i18next'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lazy "<Field> is required" message for Zod v4 schemas. Wrap in `{ error: ... }`
|
|
5
|
+
* so the message is resolved per validation (not at module load) — keeps the
|
|
6
|
+
* literal out of the schema and lets locale changes pick up.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* z.string().min(1, { error: requiredMessage('name') })
|
|
10
|
+
* z.string().min(1, { error: requiredMessage('payment_method.type') })
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* `field` is a dot-path under `admin.fields.*` resolving to a `.label` key —
|
|
14
|
+
* `'name'` → `admin.fields.name.label`, `'payment_method.type'` →
|
|
15
|
+
* `admin.fields.payment_method.type.label`.
|
|
16
|
+
*/
|
|
17
|
+
export function requiredMessage(field: string): () => string {
|
|
18
|
+
return () => i18n.t('admin.validation.required', { field: i18n.t(`admin.fields.${field}.label`) })
|
|
19
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useTranslation } from 'react-i18next'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Structural shape of the fields `<AddressBlock>` renders. Lets the design
|
|
5
|
+
* system stay decoupled from `@spree/admin-sdk`'s `Address` type — any
|
|
6
|
+
* object carrying these fields renders fine.
|
|
7
|
+
*/
|
|
8
|
+
export interface AddressBlockValue {
|
|
9
|
+
full_name?: string | null
|
|
10
|
+
company?: string | null
|
|
11
|
+
address1?: string | null
|
|
12
|
+
address2?: string | null
|
|
13
|
+
city?: string | null
|
|
14
|
+
state_text?: string | null
|
|
15
|
+
postal_code?: string | null
|
|
16
|
+
country_iso?: string | null
|
|
17
|
+
country_name?: string | null
|
|
18
|
+
phone?: string | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolves a country name localized to the admin UI language via
|
|
23
|
+
* `Intl.DisplayNames`, falling back to the API-provided name (then the ISO
|
|
24
|
+
* code) when the ISO is missing or the runtime lacks coverage.
|
|
25
|
+
*/
|
|
26
|
+
function localizedCountryName(
|
|
27
|
+
locale: string,
|
|
28
|
+
iso?: string | null,
|
|
29
|
+
name?: string | null,
|
|
30
|
+
): string | null {
|
|
31
|
+
if (iso) {
|
|
32
|
+
try {
|
|
33
|
+
const localized = new Intl.DisplayNames([locale, 'en'], { type: 'region' }).of(iso)
|
|
34
|
+
if (localized) return localized
|
|
35
|
+
} catch {
|
|
36
|
+
// Runtime without Intl.DisplayNames coverage — fall back below.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return name ?? iso ?? null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function AddressBlock({
|
|
43
|
+
title,
|
|
44
|
+
address,
|
|
45
|
+
}: {
|
|
46
|
+
title: string
|
|
47
|
+
address: AddressBlockValue | null | undefined
|
|
48
|
+
}) {
|
|
49
|
+
const { t, i18n } = useTranslation()
|
|
50
|
+
return (
|
|
51
|
+
<div>
|
|
52
|
+
<h6 className="font-semibold text-sm mb-1.5">{title}</h6>
|
|
53
|
+
{address ? (
|
|
54
|
+
<div className="text-sm text-muted-foreground flex flex-col gap-0.5">
|
|
55
|
+
<div>{address.full_name}</div>
|
|
56
|
+
{address.company && <div>{address.company}</div>}
|
|
57
|
+
<div>{address.address1}</div>
|
|
58
|
+
{address.address2 && <div>{address.address2}</div>}
|
|
59
|
+
<div>
|
|
60
|
+
{[address.city, address.state_text, address.postal_code].filter(Boolean).join(', ')}
|
|
61
|
+
</div>
|
|
62
|
+
<div>
|
|
63
|
+
{localizedCountryName(i18n.language, address.country_iso, address.country_name)}
|
|
64
|
+
</div>
|
|
65
|
+
{address.phone && <div>{address.phone}</div>}
|
|
66
|
+
</div>
|
|
67
|
+
) : (
|
|
68
|
+
<span className="text-sm text-muted-foreground">
|
|
69
|
+
{t('admin.components.address_block.not_provided')}
|
|
70
|
+
</span>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useParams, useRouter } from '@tanstack/react-router'
|
|
2
|
+
import { ArrowLeftIcon } from 'lucide-react'
|
|
3
|
+
import { useTranslation } from 'react-i18next'
|
|
4
|
+
|
|
5
|
+
interface BackButtonProps {
|
|
6
|
+
/**
|
|
7
|
+
* Fallback path segment under the current store when there is no history to go back to.
|
|
8
|
+
* Example: "products" → navigates to `/$storeId/products`
|
|
9
|
+
*/
|
|
10
|
+
fallback: string
|
|
11
|
+
className?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Back button that goes to the previous page in history (preserving that page's
|
|
16
|
+
* state — filters, column selection, etc.), falling back to `/$storeId/{fallback}`
|
|
17
|
+
* when there's nothing to go back to (e.g. deep-link, new tab).
|
|
18
|
+
*/
|
|
19
|
+
export function BackButton({ fallback, className }: BackButtonProps) {
|
|
20
|
+
const { t } = useTranslation()
|
|
21
|
+
const router = useRouter()
|
|
22
|
+
const { storeId } = useParams({ strict: false }) as { storeId?: string }
|
|
23
|
+
|
|
24
|
+
const handleClick = (e: React.MouseEvent) => {
|
|
25
|
+
e.preventDefault()
|
|
26
|
+
// If there's history within the app, go back to preserve previous state
|
|
27
|
+
if (window.history.length > 1 && document.referrer.includes(window.location.host)) {
|
|
28
|
+
router.history.back()
|
|
29
|
+
} else {
|
|
30
|
+
// Fallback to the list page
|
|
31
|
+
router.navigate({ to: `/${storeId}/${fallback}` as string })
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
onClick={handleClick}
|
|
39
|
+
className={
|
|
40
|
+
className ??
|
|
41
|
+
'inline-flex items-center justify-center rounded-lg p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors'
|
|
42
|
+
}
|
|
43
|
+
aria-label={t('admin.actions.back')}
|
|
44
|
+
>
|
|
45
|
+
<ArrowLeftIcon className="size-5" />
|
|
46
|
+
</button>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import { Button } from '../ui/button'
|
|
4
|
+
import {
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogBody,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogDescription,
|
|
9
|
+
DialogFooter,
|
|
10
|
+
DialogHeader,
|
|
11
|
+
DialogTitle,
|
|
12
|
+
} from '../ui/dialog'
|
|
13
|
+
import { FieldGroup } from '../ui/field'
|
|
14
|
+
|
|
15
|
+
interface BulkDialogProps {
|
|
16
|
+
title: string
|
|
17
|
+
description?: string
|
|
18
|
+
submitLabel: string
|
|
19
|
+
submitDisabled?: boolean
|
|
20
|
+
onCancel: () => void
|
|
21
|
+
onSubmit: () => void
|
|
22
|
+
children: ReactNode
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Modal dialog used by `BulkActionBar` form actions — title/description header,
|
|
27
|
+
* scrollable body wrapping a single `<FieldGroup>`, footer with Cancel/Submit.
|
|
28
|
+
* The standard shape for "pick a value and apply to N selected rows" flows;
|
|
29
|
+
* use a Sheet only when the form needs more space than a centered modal.
|
|
30
|
+
*/
|
|
31
|
+
export function BulkDialog({
|
|
32
|
+
title,
|
|
33
|
+
description,
|
|
34
|
+
submitLabel,
|
|
35
|
+
submitDisabled,
|
|
36
|
+
onCancel,
|
|
37
|
+
onSubmit,
|
|
38
|
+
children,
|
|
39
|
+
}: BulkDialogProps) {
|
|
40
|
+
const { t } = useTranslation()
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Dialog open onOpenChange={(o) => !o && onCancel()}>
|
|
44
|
+
<DialogContent>
|
|
45
|
+
<DialogHeader>
|
|
46
|
+
<DialogTitle>{title}</DialogTitle>
|
|
47
|
+
{description && <DialogDescription>{description}</DialogDescription>}
|
|
48
|
+
</DialogHeader>
|
|
49
|
+
<form
|
|
50
|
+
onSubmit={(event) => {
|
|
51
|
+
event.preventDefault()
|
|
52
|
+
if (!submitDisabled) onSubmit()
|
|
53
|
+
}}
|
|
54
|
+
className="flex min-h-0 flex-1 flex-col"
|
|
55
|
+
>
|
|
56
|
+
<DialogBody>
|
|
57
|
+
<FieldGroup>{children}</FieldGroup>
|
|
58
|
+
</DialogBody>
|
|
59
|
+
<DialogFooter>
|
|
60
|
+
<Button type="button" variant="outline" size="sm" onClick={onCancel}>
|
|
61
|
+
{t('admin.actions.cancel')}
|
|
62
|
+
</Button>
|
|
63
|
+
<Button type="submit" size="sm" disabled={submitDisabled}>
|
|
64
|
+
{submitLabel}
|
|
65
|
+
</Button>
|
|
66
|
+
</DialogFooter>
|
|
67
|
+
</form>
|
|
68
|
+
</DialogContent>
|
|
69
|
+
</Dialog>
|
|
70
|
+
)
|
|
71
|
+
}
|