@spree/dashboard-ui 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 +1 -1
- package/package.json +15 -9
- package/src/hooks/use-prefers-reduced-motion.ts +26 -0
- package/src/hooks/use-scrolled.ts +34 -14
- package/src/index.ts +39 -1
- package/src/lib/date-range-presets.ts +79 -0
- package/src/lib/same-rich-text.ts +145 -0
- package/src/spree/address-block.tsx +5 -4
- package/src/spree/address-book-row.tsx +96 -0
- package/src/spree/address-map.tsx +62 -0
- package/src/spree/back-button.tsx +14 -9
- package/src/spree/bulk-dialog.tsx +2 -2
- package/src/spree/bulk-price-table.tsx +145 -14
- package/src/spree/bulk-variants-table.tsx +434 -0
- package/src/spree/calculator-summary.tsx +5 -1
- package/src/spree/claim-resolve-dialog.tsx +219 -0
- package/src/spree/confirm-dialog.tsx +1 -1
- package/src/spree/copy-to-clipboard-button.tsx +1 -1
- package/src/spree/data-grid/cells.tsx +199 -25
- package/src/spree/data-grid/context.ts +6 -0
- package/src/spree/data-grid/data-grid.tsx +149 -7
- package/src/spree/data-grid/helpers.ts +6 -2
- package/src/spree/data-grid/index.ts +9 -1
- package/src/spree/data-grid/use-data-grid-keyboard.ts +4 -2
- package/src/spree/delivery-form-dialog.tsx +166 -0
- package/src/spree/drag-handle.tsx +2 -2
- package/src/spree/editable-note-card.tsx +103 -0
- package/src/spree/external-link.tsx +56 -0
- package/src/spree/file-type-icon.tsx +68 -0
- package/src/spree/form-actions.tsx +2 -3
- package/src/spree/form-section.tsx +46 -0
- package/src/spree/fulfill-items-form.tsx +282 -0
- package/src/spree/fulfillment-edit-dialog.tsx +211 -0
- package/src/spree/fulfillment-item-list.tsx +61 -0
- package/src/spree/fulfillment-panel.tsx +53 -0
- package/src/spree/icons.ts +167 -0
- package/src/spree/json-preview-drawer.tsx +4 -4
- package/src/spree/label-upload-dialog.tsx +195 -0
- package/src/spree/language-menu-items.tsx +37 -30
- package/src/spree/metadata/metadata-card.tsx +1 -1
- package/src/spree/order-cancel-dialog.tsx +149 -0
- package/src/spree/post-sale-create-dialogs.tsx +253 -0
- package/src/spree/post-sale-fields.tsx +133 -0
- package/src/spree/product-membership-list.tsx +453 -0
- package/src/spree/products/status-card.tsx +79 -0
- package/src/spree/quantity-tier-editor.tsx +162 -0
- package/src/spree/resource-combobox.tsx +12 -1
- package/src/spree/resource-name-cell.tsx +21 -4
- package/src/spree/return-dialogs.tsx +238 -0
- package/src/spree/route-error-boundary.tsx +1 -1
- package/src/spree/row-actions.tsx +2 -2
- package/src/spree/search-input.tsx +76 -0
- package/src/spree/secret-input.tsx +26 -40
- package/src/spree/tax-identifiers-card.tsx +418 -0
- package/src/spree/theme-toggle.tsx +36 -23
- package/src/spree/wizard.tsx +254 -0
- package/src/styles.css +750 -75
- package/src/ui/accordion.tsx +83 -0
- package/src/ui/alert.tsx +79 -0
- package/src/ui/attachment.tsx +1 -1
- package/src/ui/badge.tsx +198 -56
- package/src/ui/breadcrumb.tsx +1 -1
- package/src/ui/button.tsx +22 -10
- package/src/ui/calendar.tsx +1 -1
- package/src/ui/card.tsx +52 -6
- package/src/ui/checkbox.tsx +13 -5
- package/src/ui/combobox.tsx +125 -6
- package/src/ui/command.tsx +149 -23
- package/src/ui/context-menu.tsx +99 -0
- package/src/ui/data-table.tsx +448 -9
- package/src/ui/date-picker.tsx +18 -3
- package/src/ui/date-range-picker.tsx +47 -60
- package/src/ui/dialog.tsx +6 -6
- package/src/ui/dropdown-menu.tsx +18 -11
- package/src/ui/field.tsx +10 -2
- package/src/ui/input-group.tsx +1 -1
- package/src/ui/input.tsx +1 -1
- package/src/ui/map.tsx +2110 -0
- package/src/ui/media-preview.tsx +105 -0
- package/src/ui/pagination.tsx +19 -11
- package/src/ui/popover.tsx +4 -2
- package/src/ui/progress.tsx +5 -2
- package/src/ui/radio-group.tsx +4 -4
- package/src/ui/rich-text-editor.tsx +214 -88
- package/src/ui/scroll-area.tsx +65 -0
- package/src/ui/select.tsx +17 -10
- package/src/ui/separator.tsx +1 -1
- package/src/ui/sheet.tsx +49 -7
- package/src/ui/sidebar.tsx +138 -37
- package/src/ui/skeleton.tsx +3 -1
- package/src/ui/tabs.tsx +84 -0
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/thumbnail.tsx +80 -0
- package/src/ui/toast.tsx +112 -0
- package/src/ui/tooltip.tsx +3 -1
- package/src/ui/sonner.tsx +0 -47
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Spree Dashboard design system. Shadcn primitives + headless composed components
|
|
|
11
11
|
- `src/styles.css` — design tokens + Tailwind theme. Import once from your Vite app.
|
|
12
12
|
- `src/lib/` — generic helpers (`cn`, formatters, …).
|
|
13
13
|
|
|
14
|
-
The headless rule is what makes the components composable from outside `@spree/dashboard` — a plugin author, an app developer building a
|
|
14
|
+
The headless rule is what makes the components composable from outside `@spree/dashboard` — a plugin author, an app developer building a seller panel, or a third-party using just `@spree/dashboard-ui` can all instantiate the same components with their own data sources.
|
|
15
15
|
|
|
16
16
|
## Source-only package
|
|
17
17
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spree/dashboard-ui",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "1.0.0-beta.1",
|
|
5
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
6
|
"author": "Vendo Connect Inc.",
|
|
7
7
|
"homepage": "https://spreecommerce.org",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"types": "./src/index.ts",
|
|
25
25
|
"exports": {
|
|
26
26
|
".": "./src/index.ts",
|
|
27
|
+
"./icons": "./src/spree/icons.ts",
|
|
27
28
|
"./styles.css": "./src/styles.css",
|
|
28
29
|
"./ui/*": "./src/ui/*.tsx",
|
|
29
30
|
"./spree/*": "./src/spree/*.tsx",
|
|
@@ -42,7 +43,8 @@
|
|
|
42
43
|
"react": "^19.2.6",
|
|
43
44
|
"react-dom": "^19.2.6",
|
|
44
45
|
"react-hook-form": "^7.75.0",
|
|
45
|
-
"react-i18next": "^17.0.8"
|
|
46
|
+
"react-i18next": "^17.0.8",
|
|
47
|
+
"tailwindcss": "^4.2.4"
|
|
46
48
|
},
|
|
47
49
|
"dependencies": {
|
|
48
50
|
"@base-ui/react": "^1.4.1",
|
|
@@ -52,10 +54,11 @@
|
|
|
52
54
|
"@fontsource-variable/geist": "^5.2.8",
|
|
53
55
|
"@fontsource-variable/geist-mono": "^5.2.7",
|
|
54
56
|
"@tanstack/react-table": "^8.21.3",
|
|
55
|
-
"@tiptap/extension-
|
|
56
|
-
"@tiptap/extension-
|
|
57
|
-
"@tiptap/
|
|
58
|
-
"@tiptap/
|
|
57
|
+
"@tiptap/extension-image": "^3.31.3",
|
|
58
|
+
"@tiptap/extension-link": "^3.31.3",
|
|
59
|
+
"@tiptap/extension-placeholder": "^3.31.3",
|
|
60
|
+
"@tiptap/react": "^3.31.3",
|
|
61
|
+
"@tiptap/starter-kit": "^3.31.3",
|
|
59
62
|
"@uiw/react-json-view": "^1.12",
|
|
60
63
|
"class-variance-authority": "^0.7.1",
|
|
61
64
|
"clsx": "^2.1.1",
|
|
@@ -64,18 +67,21 @@
|
|
|
64
67
|
"date-fns-tz": "^3.2.0",
|
|
65
68
|
"flag-icons": "^7.5.0",
|
|
66
69
|
"lucide-react": "^1.14.0",
|
|
70
|
+
"maplibre-gl": "6.4.1",
|
|
67
71
|
"react-colorful": "^5.7.0",
|
|
68
72
|
"react-day-picker": "^9.14.0",
|
|
69
73
|
"recharts": "^3.8.1",
|
|
70
|
-
"shadcn": "^4.
|
|
71
|
-
"sonner": "^2.0.7",
|
|
74
|
+
"shadcn": "^4.21.0",
|
|
72
75
|
"tailwind-merge": "^3.5.0",
|
|
73
76
|
"tw-animate-css": "^1.4.0"
|
|
74
77
|
},
|
|
75
78
|
"devDependencies": {
|
|
79
|
+
"@types/geojson": "^7946.0.16",
|
|
76
80
|
"@types/react": "^19.2.14",
|
|
77
81
|
"@types/react-dom": "^19.2.3",
|
|
78
|
-
"
|
|
82
|
+
"tailwindcss": "^4.2.4",
|
|
83
|
+
"typescript": "~6.0.3",
|
|
84
|
+
"vite": "^8.0.16"
|
|
79
85
|
},
|
|
80
86
|
"scripts": {
|
|
81
87
|
"lint": "biome check src",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tracks the user's `prefers-reduced-motion` setting, live.
|
|
5
|
+
*
|
|
6
|
+
* For motion that CSS alone can't soften. Suppressing a transition in CSS
|
|
7
|
+
* turns a slide into a jump, which is the opposite of what the setting asks
|
|
8
|
+
* for; reading the preference here lets a component drop the movement
|
|
9
|
+
* altogether and simply hold its position.
|
|
10
|
+
*
|
|
11
|
+
* Starts `false` so a future SSR render and the first client paint agree; the
|
|
12
|
+
* effect resyncs on mount.
|
|
13
|
+
*/
|
|
14
|
+
export function usePrefersReducedMotion() {
|
|
15
|
+
const [prefersReducedMotion, setPrefersReducedMotion] = React.useState(false)
|
|
16
|
+
|
|
17
|
+
React.useEffect(() => {
|
|
18
|
+
const mql = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
19
|
+
const onChange = () => setPrefersReducedMotion(mql.matches)
|
|
20
|
+
onChange()
|
|
21
|
+
mql.addEventListener('change', onChange)
|
|
22
|
+
return () => mql.removeEventListener('change', onChange)
|
|
23
|
+
}, [])
|
|
24
|
+
|
|
25
|
+
return prefersReducedMotion
|
|
26
|
+
}
|
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Returns true once the
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* behind them.
|
|
4
|
+
* Returns true once the page has been scrolled past `threshold` pixels from
|
|
5
|
+
* the top, false at rest. Used to fade in subtle elevation on sticky headers
|
|
6
|
+
* so they don't look heavy when nothing is scrolled behind them.
|
|
8
7
|
*
|
|
9
|
-
* Listens
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
8
|
+
* Listens on the document in the capture phase rather than on `window`, and
|
|
9
|
+
* reads the offset from whichever element actually scrolled. The shell's
|
|
10
|
+
* content is an inset sheet that scrolls internally, so `window.scrollY` stays
|
|
11
|
+
* 0 there for the whole life of the page — a window listener would report "not
|
|
12
|
+
* scrolled" no matter how far down a long form the user went. Scroll events do
|
|
13
|
+
* not bubble, which is why this has to capture.
|
|
14
|
+
*
|
|
15
|
+
* Default threshold is 4px to avoid flickering at the boundary on devices that
|
|
16
|
+
* report fractional scroll positions.
|
|
17
|
+
*
|
|
18
|
+
* @param threshold Scroll offset, in pixels, at which the state flips to true.
|
|
19
|
+
* @param releaseThreshold Offset the user must scroll back above before it
|
|
20
|
+
* flips to false again. Defaults to `threshold` (no hysteresis). Set it
|
|
21
|
+
* lower than `threshold` when the flag drives motion: a single boundary
|
|
22
|
+
* makes a slow scroll across it toggle repeatedly, and anything animating
|
|
23
|
+
* off this flag then flickers.
|
|
14
24
|
*/
|
|
15
|
-
export function useScrolled(threshold = 4) {
|
|
25
|
+
export function useScrolled(threshold = 4, releaseThreshold = threshold) {
|
|
16
26
|
// Always start `false` so a future SSR render and the first client paint
|
|
17
27
|
// agree (no hydration mismatch). The effect below resyncs against the
|
|
18
28
|
// actual scroll position on mount, so any restored scroll position is
|
|
@@ -20,11 +30,21 @@ export function useScrolled(threshold = 4) {
|
|
|
20
30
|
const [scrolled, setScrolled] = React.useState(false)
|
|
21
31
|
|
|
22
32
|
React.useEffect(() => {
|
|
23
|
-
const onScroll = () =>
|
|
33
|
+
const onScroll = (event?: Event) => {
|
|
34
|
+
// The scrolled element for a container, `window.scrollY` for the page.
|
|
35
|
+
const target = event?.target
|
|
36
|
+
const offset = target instanceof HTMLElement ? target.scrollTop : window.scrollY
|
|
37
|
+
|
|
38
|
+
// Reading the previous value here (rather than from a dependency) keeps
|
|
39
|
+
// the listener stable while still letting the two thresholds apply
|
|
40
|
+
// directionally: past `threshold` to engage, back above
|
|
41
|
+
// `releaseThreshold` to disengage.
|
|
42
|
+
setScrolled((wasScrolled) => (wasScrolled ? offset > releaseThreshold : offset > threshold))
|
|
43
|
+
}
|
|
24
44
|
onScroll()
|
|
25
|
-
|
|
26
|
-
return () =>
|
|
27
|
-
}, [threshold])
|
|
45
|
+
document.addEventListener('scroll', onScroll, { capture: true, passive: true })
|
|
46
|
+
return () => document.removeEventListener('scroll', onScroll, { capture: true })
|
|
47
|
+
}, [threshold, releaseThreshold])
|
|
28
48
|
|
|
29
49
|
return scrolled
|
|
30
50
|
}
|
package/src/index.ts
CHANGED
|
@@ -13,27 +13,52 @@
|
|
|
13
13
|
|
|
14
14
|
export * from './hooks/use-copy-to-clipboard'
|
|
15
15
|
export { useIsMobile } from './hooks/use-mobile'
|
|
16
|
+
export * from './hooks/use-prefers-reduced-motion'
|
|
16
17
|
export * from './hooks/use-scrolled'
|
|
17
18
|
// ---------------------------------------------------------------------------
|
|
18
19
|
// Helpers + hooks
|
|
19
20
|
// ---------------------------------------------------------------------------
|
|
21
|
+
export {
|
|
22
|
+
type DateRangePresetKey,
|
|
23
|
+
matchDateRangePreset,
|
|
24
|
+
resolveDateRangePreset,
|
|
25
|
+
} from './lib/date-range-presets'
|
|
26
|
+
export { sameRichText, shouldEmitRichTextChange } from './lib/same-rich-text'
|
|
20
27
|
export { cn } from './lib/utils'
|
|
21
28
|
export { requiredMessage } from './lib/validation-messages'
|
|
22
29
|
// ---------------------------------------------------------------------------
|
|
23
30
|
// Spree composed components — headless, accept data via props
|
|
24
31
|
// ---------------------------------------------------------------------------
|
|
25
32
|
export * from './spree/address-block'
|
|
33
|
+
export * from './spree/address-book-row'
|
|
34
|
+
export * from './spree/address-map'
|
|
26
35
|
export * from './spree/back-button'
|
|
27
36
|
export * from './spree/bulk-dialog'
|
|
28
37
|
export * from './spree/bulk-price-table'
|
|
38
|
+
export * from './spree/bulk-variants-table'
|
|
29
39
|
export * from './spree/calculator-summary'
|
|
40
|
+
export * from './spree/claim-resolve-dialog'
|
|
30
41
|
export * from './spree/color-picker'
|
|
31
42
|
export * from './spree/confirm-dialog'
|
|
32
43
|
export * from './spree/copy-to-clipboard-button'
|
|
33
44
|
export * from './spree/country-flag'
|
|
34
45
|
export * from './spree/data-grid'
|
|
46
|
+
export * from './spree/delivery-form-dialog'
|
|
35
47
|
export * from './spree/drag-handle'
|
|
48
|
+
export * from './spree/editable-note-card'
|
|
49
|
+
export * from './spree/file-type-icon'
|
|
36
50
|
export * from './spree/form-actions'
|
|
51
|
+
export * from './spree/form-section'
|
|
52
|
+
export * from './spree/fulfill-items-form'
|
|
53
|
+
export * from './spree/fulfillment-edit-dialog'
|
|
54
|
+
export * from './spree/fulfillment-item-list'
|
|
55
|
+
export * from './spree/fulfillment-panel'
|
|
56
|
+
export * from './spree/label-upload-dialog'
|
|
57
|
+
export * from './spree/order-cancel-dialog'
|
|
58
|
+
export * from './spree/post-sale-create-dialogs'
|
|
59
|
+
export * from './spree/post-sale-fields'
|
|
60
|
+
export * from './spree/quantity-tier-editor'
|
|
61
|
+
export * from './spree/return-dialogs'
|
|
37
62
|
|
|
38
63
|
// JsonPreviewDrawer and JsonValueView are intentionally NOT re-exported from
|
|
39
64
|
// this barrel: they pull in `@uiw/react-json-view` (~30KB gzip), and
|
|
@@ -42,8 +67,11 @@ export * from './spree/form-actions'
|
|
|
42
67
|
// `@spree/dashboard-ui/spree/json-value-view`. Types are available the same
|
|
43
68
|
// way — `import { type JsonPreviewDrawerProps } from '@spree/dashboard-ui/spree/json-preview-drawer'`.
|
|
44
69
|
|
|
70
|
+
export * from './spree/external-link'
|
|
45
71
|
export * from './spree/language-menu-items'
|
|
46
72
|
export * from './spree/metadata/metadata-card'
|
|
73
|
+
export * from './spree/product-membership-list'
|
|
74
|
+
export * from './spree/products/status-card'
|
|
47
75
|
export * from './spree/relative-time'
|
|
48
76
|
export * from './spree/resource-combobox'
|
|
49
77
|
export * from './spree/resource-layout'
|
|
@@ -52,14 +80,19 @@ export * from './spree/resource-name-cell'
|
|
|
52
80
|
export * from './spree/route-error-boundary'
|
|
53
81
|
export * from './spree/row-actions'
|
|
54
82
|
export * from './spree/row-click-bridge'
|
|
83
|
+
export * from './spree/search-input'
|
|
55
84
|
export * from './spree/secret-input'
|
|
56
85
|
export * from './spree/storefront-visible-switch'
|
|
57
86
|
export * from './spree/tag-list'
|
|
87
|
+
export * from './spree/tax-identifiers-card'
|
|
58
88
|
export * from './spree/theme-provider'
|
|
59
89
|
export * from './spree/theme-toggle'
|
|
90
|
+
export * from './spree/wizard'
|
|
60
91
|
// ---------------------------------------------------------------------------
|
|
61
92
|
// UI primitives (shadcn) — see ./ui/*
|
|
62
93
|
// ---------------------------------------------------------------------------
|
|
94
|
+
export * from './ui/accordion'
|
|
95
|
+
export * from './ui/alert'
|
|
63
96
|
export * from './ui/attachment'
|
|
64
97
|
export * from './ui/avatar'
|
|
65
98
|
export * from './ui/badge'
|
|
@@ -72,6 +105,7 @@ export * from './ui/checkbox'
|
|
|
72
105
|
export * from './ui/collapsible'
|
|
73
106
|
export * from './ui/combobox'
|
|
74
107
|
export * from './ui/command'
|
|
108
|
+
export * from './ui/context-menu'
|
|
75
109
|
export * from './ui/data-table'
|
|
76
110
|
export * from './ui/date-picker'
|
|
77
111
|
export * from './ui/date-range-picker'
|
|
@@ -82,18 +116,22 @@ export * from './ui/field'
|
|
|
82
116
|
export * from './ui/input'
|
|
83
117
|
export * from './ui/input-group'
|
|
84
118
|
export * from './ui/label'
|
|
119
|
+
export * from './ui/media-preview'
|
|
85
120
|
export * from './ui/pagination'
|
|
86
121
|
export * from './ui/popover'
|
|
87
122
|
export * from './ui/progress'
|
|
88
123
|
export * from './ui/radio-group'
|
|
89
124
|
export * from './ui/rich-text-editor'
|
|
125
|
+
export * from './ui/scroll-area'
|
|
90
126
|
export * from './ui/select'
|
|
91
127
|
export * from './ui/separator'
|
|
92
128
|
export * from './ui/sheet'
|
|
93
129
|
export * from './ui/sidebar'
|
|
94
130
|
export * from './ui/skeleton'
|
|
95
131
|
export * from './ui/slot'
|
|
96
|
-
export * from './ui/sonner'
|
|
97
132
|
export * from './ui/switch'
|
|
133
|
+
export * from './ui/tabs'
|
|
98
134
|
export * from './ui/textarea'
|
|
135
|
+
export * from './ui/thumbnail'
|
|
136
|
+
export * from './ui/toast'
|
|
99
137
|
export * from './ui/tooltip'
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { format, startOfMonth, startOfYear, subDays } from 'date-fns'
|
|
2
|
+
import { toZonedTime } from 'date-fns-tz'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Named ranges the date-range picker offers. `custom` is a hand-picked
|
|
6
|
+
* span that no named entry produces today.
|
|
7
|
+
*/
|
|
8
|
+
export type DateRangePresetKey = '7d' | '14d' | '30d' | '90d' | 'this_month' | 'ytd' | 'custom'
|
|
9
|
+
|
|
10
|
+
export const DATE_RANGE_PRESET_KEYS: Exclude<DateRangePresetKey, 'custom'>[] = [
|
|
11
|
+
'7d',
|
|
12
|
+
'14d',
|
|
13
|
+
'30d',
|
|
14
|
+
'90d',
|
|
15
|
+
'this_month',
|
|
16
|
+
'ytd',
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
export interface CalendarDateRange {
|
|
20
|
+
from: Date
|
|
21
|
+
to: Date
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a named range on the given calendar.
|
|
26
|
+
*
|
|
27
|
+
* "Last N days" is inclusive of today — last 30 days is 30 dates, so the
|
|
28
|
+
* start is 29 days back. The timezone argument is the merchant's zone:
|
|
29
|
+
* these ranges are relative to *now* where they trade, not where the
|
|
30
|
+
* browser happens to be.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveDateRangePreset(
|
|
33
|
+
preset: Exclude<DateRangePresetKey, 'custom'>,
|
|
34
|
+
timezone: string,
|
|
35
|
+
): CalendarDateRange {
|
|
36
|
+
const now = toZonedTime(new Date(), timezone)
|
|
37
|
+
|
|
38
|
+
switch (preset) {
|
|
39
|
+
case '7d':
|
|
40
|
+
return { from: subDays(now, 6), to: now }
|
|
41
|
+
case '14d':
|
|
42
|
+
return { from: subDays(now, 13), to: now }
|
|
43
|
+
case '30d':
|
|
44
|
+
return { from: subDays(now, 29), to: now }
|
|
45
|
+
case '90d':
|
|
46
|
+
return { from: subDays(now, 89), to: now }
|
|
47
|
+
case 'this_month':
|
|
48
|
+
return { from: startOfMonth(now), to: now }
|
|
49
|
+
case 'ytd':
|
|
50
|
+
return { from: startOfYear(now), to: now }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Recognise which named range a pair of dates came from, so a picker that
|
|
56
|
+
* remounts (or is given a range from outside) re-selects its menu entry
|
|
57
|
+
* instead of always reading as the default.
|
|
58
|
+
*
|
|
59
|
+
* Matching by calendar day rather than storing the key keeps the value a
|
|
60
|
+
* plain range — the preset is a way of writing dates, not a second piece
|
|
61
|
+
* of state that can drift. A range that no longer matches any preset
|
|
62
|
+
* (yesterday's "last 7 days", reopened today) correctly reads as custom.
|
|
63
|
+
*/
|
|
64
|
+
export function matchDateRangePreset(
|
|
65
|
+
range: CalendarDateRange,
|
|
66
|
+
timezone: string,
|
|
67
|
+
): DateRangePresetKey {
|
|
68
|
+
const from = dayKey(range.from)
|
|
69
|
+
const to = dayKey(range.to)
|
|
70
|
+
for (const key of DATE_RANGE_PRESET_KEYS) {
|
|
71
|
+
const candidate = resolveDateRangePreset(key, timezone)
|
|
72
|
+
if (dayKey(candidate.from) === from && dayKey(candidate.to) === to) return key
|
|
73
|
+
}
|
|
74
|
+
return 'custom'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function dayKey(date: Date): string {
|
|
78
|
+
return format(date, 'yyyy-MM-dd')
|
|
79
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether two rich-text strings are the same content after TipTap-style
|
|
3
|
+
* normalization. The editor wraps bare text in `<p>` and emits empty
|
|
4
|
+
* paragraphs (`<p></p>`, `<p><br></p>`) on mount — those must not count as
|
|
5
|
+
* an edit against a stored plain-text or empty baseline.
|
|
6
|
+
*
|
|
7
|
+
* Implemented with linear scans so a long string of empty paragraphs cannot
|
|
8
|
+
* trip a polynomial regular expression (CodeQL js/polynomial-redos).
|
|
9
|
+
*/
|
|
10
|
+
export function sameRichText(left: string, right: string): boolean {
|
|
11
|
+
return canonicalizeRichText(left) === canonicalizeRichText(right)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether the editor should call `onChange` for `next` given the last stored
|
|
16
|
+
* `previous` value. Mount wrapping (`''` → `<p></p>`, bare text → `<p>…</p>`)
|
|
17
|
+
* is ignored. A user pressing Enter is not: that adds an empty paragraph
|
|
18
|
+
* `sameRichText` would otherwise treat as noise.
|
|
19
|
+
*/
|
|
20
|
+
export function shouldEmitRichTextChange(next: string, previous: string): boolean {
|
|
21
|
+
if (next === previous) return false
|
|
22
|
+
if (!sameRichText(next, previous)) return true
|
|
23
|
+
if (!visibleText(previous) && !hasImage(previous)) return false
|
|
24
|
+
|
|
25
|
+
const previousHtml = looksLikeHtml(previous.trim()) ? previous : `<p>${previous}</p>`
|
|
26
|
+
return countParagraphs(next) !== countParagraphs(previousHtml)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function canonicalizeRichText(html: string): string {
|
|
30
|
+
const withoutTrailingEmpty = stripTrailingEmptyParagraphs((html ?? '').trim())
|
|
31
|
+
if (!visibleText(withoutTrailingEmpty) && !hasImage(withoutTrailingEmpty)) return ''
|
|
32
|
+
if (!looksLikeHtml(withoutTrailingEmpty)) return `<p>${withoutTrailingEmpty}</p>`
|
|
33
|
+
return withoutTrailingEmpty
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stripTrailingEmptyParagraphs(html: string): string {
|
|
37
|
+
let end = html.length
|
|
38
|
+
|
|
39
|
+
while (end > 0) {
|
|
40
|
+
while (end > 0 && isAsciiWhitespace(html.charCodeAt(end - 1))) end -= 1
|
|
41
|
+
if (!html.slice(0, end).toLowerCase().endsWith('</p>')) break
|
|
42
|
+
|
|
43
|
+
const open = html.lastIndexOf('<p>', end - 4)
|
|
44
|
+
if (open === -1 || open > end - 4) break
|
|
45
|
+
|
|
46
|
+
const inner = html.slice(open + 3, end - 4).trim()
|
|
47
|
+
if (inner === '' || isBreakOnly(inner) || isNbspOnly(inner)) {
|
|
48
|
+
end = open
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
break
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return html.slice(0, end).trim()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isBreakOnly(inner: string): boolean {
|
|
58
|
+
const lower = inner.toLowerCase()
|
|
59
|
+
return lower === '<br>' || lower === '<br/>' || lower === '<br />'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isNbspOnly(inner: string): boolean {
|
|
63
|
+
return inner.replace(/ /gi, '').trim() === ''
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isAsciiWhitespace(charCode: number): boolean {
|
|
67
|
+
return charCode === 32 || charCode === 9 || charCode === 10 || charCode === 13
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function looksLikeHtml(value: string): boolean {
|
|
71
|
+
const start = value.search(/<\/?[a-z]/i)
|
|
72
|
+
if (start === -1) return false
|
|
73
|
+
return value.indexOf('>', start) !== -1
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** TipTap's image button can produce a paragraph that has no text. */
|
|
77
|
+
function hasImage(html: string): boolean {
|
|
78
|
+
const lower = html.toLowerCase()
|
|
79
|
+
let index = 0
|
|
80
|
+
while (index < lower.length) {
|
|
81
|
+
const open = lower.indexOf('<img', index)
|
|
82
|
+
if (open === -1) return false
|
|
83
|
+
const after = lower.charCodeAt(open + 4)
|
|
84
|
+
if (
|
|
85
|
+
Number.isNaN(after) ||
|
|
86
|
+
after === 32 ||
|
|
87
|
+
after === 9 ||
|
|
88
|
+
after === 10 ||
|
|
89
|
+
after === 13 ||
|
|
90
|
+
after === 47 ||
|
|
91
|
+
after === 62
|
|
92
|
+
) {
|
|
93
|
+
return true
|
|
94
|
+
}
|
|
95
|
+
index = open + 4
|
|
96
|
+
}
|
|
97
|
+
return false
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function countParagraphs(html: string): number {
|
|
101
|
+
const lower = (html ?? '').toLowerCase()
|
|
102
|
+
let count = 0
|
|
103
|
+
let index = 0
|
|
104
|
+
while (index < lower.length) {
|
|
105
|
+
const open = lower.indexOf('<p', index)
|
|
106
|
+
if (open === -1) return count
|
|
107
|
+
const after = lower.charCodeAt(open + 2)
|
|
108
|
+
if (
|
|
109
|
+
Number.isNaN(after) ||
|
|
110
|
+
after === 32 ||
|
|
111
|
+
after === 9 ||
|
|
112
|
+
after === 10 ||
|
|
113
|
+
after === 13 ||
|
|
114
|
+
after === 62
|
|
115
|
+
) {
|
|
116
|
+
count += 1
|
|
117
|
+
}
|
|
118
|
+
index = open + 2
|
|
119
|
+
}
|
|
120
|
+
return count
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function visibleText(html: string): string {
|
|
124
|
+
let text = ''
|
|
125
|
+
let index = 0
|
|
126
|
+
while (index < html.length) {
|
|
127
|
+
const open = html.indexOf('<', index)
|
|
128
|
+
if (open === -1) {
|
|
129
|
+
text += html.slice(index)
|
|
130
|
+
break
|
|
131
|
+
}
|
|
132
|
+
text += html.slice(index, open)
|
|
133
|
+
const close = html.indexOf('>', open + 1)
|
|
134
|
+
if (close === -1) {
|
|
135
|
+
text += html.slice(open)
|
|
136
|
+
break
|
|
137
|
+
}
|
|
138
|
+
text += ' '
|
|
139
|
+
index = close + 1
|
|
140
|
+
}
|
|
141
|
+
return text
|
|
142
|
+
.replace(/ /gi, ' ')
|
|
143
|
+
.replace(/\s+/g, ' ')
|
|
144
|
+
.trim()
|
|
145
|
+
}
|
|
@@ -13,7 +13,7 @@ export interface AddressBlockValue {
|
|
|
13
13
|
city?: string | null
|
|
14
14
|
state_text?: string | null
|
|
15
15
|
postal_code?: string | null
|
|
16
|
-
|
|
16
|
+
country_code?: string | null
|
|
17
17
|
country_name?: string | null
|
|
18
18
|
phone?: string | null
|
|
19
19
|
}
|
|
@@ -43,13 +43,14 @@ export function AddressBlock({
|
|
|
43
43
|
title,
|
|
44
44
|
address,
|
|
45
45
|
}: {
|
|
46
|
-
|
|
46
|
+
/** Omitted where the surrounding card already names the address. */
|
|
47
|
+
title?: string
|
|
47
48
|
address: AddressBlockValue | null | undefined
|
|
48
49
|
}) {
|
|
49
50
|
const { t, i18n } = useTranslation()
|
|
50
51
|
return (
|
|
51
52
|
<div>
|
|
52
|
-
<h6 className="font-semibold text-sm mb-1.5">{title}</h6>
|
|
53
|
+
{title && <h6 className="font-semibold text-sm mb-1.5">{title}</h6>}
|
|
53
54
|
{address ? (
|
|
54
55
|
<div className="text-sm text-muted-foreground flex flex-col gap-0.5">
|
|
55
56
|
<div>{address.full_name}</div>
|
|
@@ -60,7 +61,7 @@ export function AddressBlock({
|
|
|
60
61
|
{[address.city, address.state_text, address.postal_code].filter(Boolean).join(', ')}
|
|
61
62
|
</div>
|
|
62
63
|
<div>
|
|
63
|
-
{localizedCountryName(i18n.language, address.
|
|
64
|
+
{localizedCountryName(i18n.language, address.country_code, address.country_name)}
|
|
64
65
|
</div>
|
|
65
66
|
{address.phone && <div>{address.phone}</div>}
|
|
66
67
|
</div>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useTranslation } from 'react-i18next'
|
|
2
|
+
import { Badge } from '../ui/badge'
|
|
3
|
+
import { Button } from '../ui/button'
|
|
4
|
+
import {
|
|
5
|
+
DropdownMenu,
|
|
6
|
+
DropdownMenuContent,
|
|
7
|
+
DropdownMenuItem,
|
|
8
|
+
DropdownMenuTrigger,
|
|
9
|
+
} from '../ui/dropdown-menu'
|
|
10
|
+
import { AddressBlock, type AddressBlockValue } from './address-block'
|
|
11
|
+
import { EllipsisVerticalIcon, PencilIcon, TrashIcon } from './icons'
|
|
12
|
+
|
|
13
|
+
/** What every address book row needs, whoever owns the book. */
|
|
14
|
+
export interface AddressBookEntry extends AddressBlockValue {
|
|
15
|
+
id: string
|
|
16
|
+
label?: string | null
|
|
17
|
+
is_default_billing?: boolean | null
|
|
18
|
+
is_default_shipping?: boolean | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One row of an address book — the labelled address, which defaults it holds,
|
|
23
|
+
* and the actions on it behind a menu.
|
|
24
|
+
*
|
|
25
|
+
* Shared so a customer's book and a company node's read the same: they are the
|
|
26
|
+
* same thing seen from different owners, and a merchant moving between them
|
|
27
|
+
* should not have to learn two layouts.
|
|
28
|
+
*/
|
|
29
|
+
export function AddressBookRow({
|
|
30
|
+
address,
|
|
31
|
+
canEdit = true,
|
|
32
|
+
onEdit,
|
|
33
|
+
onRemove,
|
|
34
|
+
onSetDefaultBilling,
|
|
35
|
+
onSetDefaultShipping,
|
|
36
|
+
}: {
|
|
37
|
+
address: AddressBookEntry
|
|
38
|
+
canEdit?: boolean
|
|
39
|
+
onEdit: () => void
|
|
40
|
+
onRemove: () => void
|
|
41
|
+
/** Omit either to hide that action — a book with one address has no choice. */
|
|
42
|
+
onSetDefaultBilling?: () => void
|
|
43
|
+
onSetDefaultShipping?: () => void
|
|
44
|
+
}) {
|
|
45
|
+
const { t } = useTranslation()
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="flex items-start justify-between gap-2 rounded-md border p-3">
|
|
49
|
+
<div className="flex min-w-0 flex-col gap-1">
|
|
50
|
+
<span className="flex flex-wrap items-center gap-2">
|
|
51
|
+
{address.label && (
|
|
52
|
+
<span className="font-medium text-foreground text-sm">{address.label}</span>
|
|
53
|
+
)}
|
|
54
|
+
{address.is_default_billing && (
|
|
55
|
+
<Badge variant="outline">{t('admin.address_book.default_billing')}</Badge>
|
|
56
|
+
)}
|
|
57
|
+
{address.is_default_shipping && (
|
|
58
|
+
<Badge variant="outline">{t('admin.address_book.default_shipping')}</Badge>
|
|
59
|
+
)}
|
|
60
|
+
</span>
|
|
61
|
+
<AddressBlock address={address} />
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
{canEdit && (
|
|
65
|
+
<DropdownMenu>
|
|
66
|
+
<DropdownMenuTrigger asChild>
|
|
67
|
+
<Button variant="ghost" size="icon-xs" className="shrink-0">
|
|
68
|
+
<EllipsisVerticalIcon className="size-4" />
|
|
69
|
+
<span className="sr-only">{t('admin.actions.actions_menu')}</span>
|
|
70
|
+
</Button>
|
|
71
|
+
</DropdownMenuTrigger>
|
|
72
|
+
<DropdownMenuContent align="end">
|
|
73
|
+
<DropdownMenuItem onClick={onEdit}>
|
|
74
|
+
<PencilIcon className="size-4" />
|
|
75
|
+
{t('admin.actions.edit')}
|
|
76
|
+
</DropdownMenuItem>
|
|
77
|
+
{onSetDefaultBilling && !address.is_default_billing && (
|
|
78
|
+
<DropdownMenuItem onClick={onSetDefaultBilling}>
|
|
79
|
+
{t('admin.address_book.set_default_billing')}
|
|
80
|
+
</DropdownMenuItem>
|
|
81
|
+
)}
|
|
82
|
+
{onSetDefaultShipping && !address.is_default_shipping && (
|
|
83
|
+
<DropdownMenuItem onClick={onSetDefaultShipping}>
|
|
84
|
+
{t('admin.address_book.set_default_shipping')}
|
|
85
|
+
</DropdownMenuItem>
|
|
86
|
+
)}
|
|
87
|
+
<DropdownMenuItem variant="destructive" onClick={onRemove}>
|
|
88
|
+
<TrashIcon className="size-4" />
|
|
89
|
+
{t('admin.actions.delete')}
|
|
90
|
+
</DropdownMenuItem>
|
|
91
|
+
</DropdownMenuContent>
|
|
92
|
+
</DropdownMenu>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
)
|
|
96
|
+
}
|