@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.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +26 -0
  3. package/package.json +86 -0
  4. package/src/hooks/use-copy-to-clipboard.ts +44 -0
  5. package/src/hooks/use-mobile.ts +19 -0
  6. package/src/hooks/use-scrolled.ts +30 -0
  7. package/src/index.ts +99 -0
  8. package/src/lib/date-locale.ts +29 -0
  9. package/src/lib/utils.ts +6 -0
  10. package/src/lib/validation-messages.ts +19 -0
  11. package/src/spree/address-block.tsx +74 -0
  12. package/src/spree/back-button.tsx +48 -0
  13. package/src/spree/bulk-dialog.tsx +71 -0
  14. package/src/spree/bulk-price-table.tsx +254 -0
  15. package/src/spree/calculator-summary.tsx +120 -0
  16. package/src/spree/color-picker.tsx +202 -0
  17. package/src/spree/confirm-dialog.tsx +82 -0
  18. package/src/spree/copy-to-clipboard-button.tsx +90 -0
  19. package/src/spree/country-flag.tsx +30 -0
  20. package/src/spree/data-grid/cells.tsx +415 -0
  21. package/src/spree/data-grid/context.ts +59 -0
  22. package/src/spree/data-grid/data-grid.tsx +214 -0
  23. package/src/spree/data-grid/fill-handle.tsx +153 -0
  24. package/src/spree/data-grid/helpers.ts +18 -0
  25. package/src/spree/data-grid/index.ts +5 -0
  26. package/src/spree/data-grid/types.ts +36 -0
  27. package/src/spree/data-grid/use-data-grid-keyboard.ts +254 -0
  28. package/src/spree/data-grid/use-stable-cell-registration.ts +45 -0
  29. package/src/spree/drag-handle.tsx +51 -0
  30. package/src/spree/form-actions.tsx +140 -0
  31. package/src/spree/json-preview-drawer.tsx +254 -0
  32. package/src/spree/json-value-view.tsx +149 -0
  33. package/src/spree/language-menu-items.tsx +54 -0
  34. package/src/spree/metadata/metadata-card.tsx +62 -0
  35. package/src/spree/relative-time.tsx +49 -0
  36. package/src/spree/resource-combobox.tsx +108 -0
  37. package/src/spree/resource-layout.tsx +31 -0
  38. package/src/spree/resource-multi-autocomplete.tsx +180 -0
  39. package/src/spree/resource-name-cell.tsx +44 -0
  40. package/src/spree/route-error-boundary.tsx +102 -0
  41. package/src/spree/row-actions.tsx +85 -0
  42. package/src/spree/row-click-bridge.tsx +17 -0
  43. package/src/spree/secret-input.tsx +137 -0
  44. package/src/spree/storefront-visible-switch.tsx +58 -0
  45. package/src/spree/tag-list.tsx +26 -0
  46. package/src/spree/theme-provider.tsx +83 -0
  47. package/src/spree/theme-toggle.tsx +35 -0
  48. package/src/styles.css +338 -0
  49. package/src/ui/attachment.tsx +187 -0
  50. package/src/ui/avatar.tsx +94 -0
  51. package/src/ui/badge.tsx +161 -0
  52. package/src/ui/breadcrumb.tsx +109 -0
  53. package/src/ui/button.tsx +60 -0
  54. package/src/ui/calendar.tsx +66 -0
  55. package/src/ui/card.tsx +72 -0
  56. package/src/ui/chart.tsx +94 -0
  57. package/src/ui/checkbox.tsx +50 -0
  58. package/src/ui/collapsible.tsx +18 -0
  59. package/src/ui/combobox.tsx +270 -0
  60. package/src/ui/command.tsx +172 -0
  61. package/src/ui/data-table.tsx +60 -0
  62. package/src/ui/date-picker.tsx +253 -0
  63. package/src/ui/date-range-picker.tsx +150 -0
  64. package/src/ui/dialog.tsx +214 -0
  65. package/src/ui/dropdown-menu.tsx +345 -0
  66. package/src/ui/empty.tsx +94 -0
  67. package/src/ui/field.tsx +221 -0
  68. package/src/ui/input-group.tsx +141 -0
  69. package/src/ui/input.tsx +19 -0
  70. package/src/ui/label.tsx +29 -0
  71. package/src/ui/pagination.tsx +123 -0
  72. package/src/ui/popover.tsx +76 -0
  73. package/src/ui/progress.tsx +73 -0
  74. package/src/ui/radio-group.tsx +42 -0
  75. package/src/ui/rich-text-editor.tsx +263 -0
  76. package/src/ui/select.tsx +213 -0
  77. package/src/ui/separator.tsx +31 -0
  78. package/src/ui/sheet.tsx +208 -0
  79. package/src/ui/sidebar.tsx +679 -0
  80. package/src/ui/skeleton.tsx +13 -0
  81. package/src/ui/slot.tsx +35 -0
  82. package/src/ui/sonner.tsx +47 -0
  83. package/src/ui/switch.tsx +39 -0
  84. package/src/ui/textarea.tsx +18 -0
  85. 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
+ }
@@ -0,0 +1,6 @@
1
+ import { type ClassValue, clsx } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
@@ -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
+ }