@spree/dashboard-ui 0.13.1 → 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.
Files changed (96) hide show
  1. package/README.md +1 -1
  2. package/package.json +15 -9
  3. package/src/hooks/use-prefers-reduced-motion.ts +26 -0
  4. package/src/hooks/use-scrolled.ts +34 -14
  5. package/src/index.ts +39 -1
  6. package/src/lib/date-range-presets.ts +79 -0
  7. package/src/lib/same-rich-text.ts +145 -0
  8. package/src/spree/address-block.tsx +5 -4
  9. package/src/spree/address-book-row.tsx +96 -0
  10. package/src/spree/address-map.tsx +62 -0
  11. package/src/spree/back-button.tsx +14 -9
  12. package/src/spree/bulk-dialog.tsx +2 -2
  13. package/src/spree/bulk-price-table.tsx +145 -14
  14. package/src/spree/bulk-variants-table.tsx +434 -0
  15. package/src/spree/calculator-summary.tsx +5 -1
  16. package/src/spree/claim-resolve-dialog.tsx +219 -0
  17. package/src/spree/confirm-dialog.tsx +1 -1
  18. package/src/spree/copy-to-clipboard-button.tsx +1 -1
  19. package/src/spree/data-grid/cells.tsx +199 -25
  20. package/src/spree/data-grid/context.ts +6 -0
  21. package/src/spree/data-grid/data-grid.tsx +149 -7
  22. package/src/spree/data-grid/helpers.ts +6 -2
  23. package/src/spree/data-grid/index.ts +9 -1
  24. package/src/spree/data-grid/use-data-grid-keyboard.ts +4 -2
  25. package/src/spree/delivery-form-dialog.tsx +166 -0
  26. package/src/spree/drag-handle.tsx +2 -2
  27. package/src/spree/editable-note-card.tsx +103 -0
  28. package/src/spree/external-link.tsx +56 -0
  29. package/src/spree/file-type-icon.tsx +68 -0
  30. package/src/spree/form-actions.tsx +2 -3
  31. package/src/spree/form-section.tsx +46 -0
  32. package/src/spree/fulfill-items-form.tsx +282 -0
  33. package/src/spree/fulfillment-edit-dialog.tsx +211 -0
  34. package/src/spree/fulfillment-item-list.tsx +61 -0
  35. package/src/spree/fulfillment-panel.tsx +53 -0
  36. package/src/spree/icons.ts +167 -0
  37. package/src/spree/json-preview-drawer.tsx +4 -4
  38. package/src/spree/label-upload-dialog.tsx +195 -0
  39. package/src/spree/language-menu-items.tsx +37 -30
  40. package/src/spree/metadata/metadata-card.tsx +1 -1
  41. package/src/spree/order-cancel-dialog.tsx +149 -0
  42. package/src/spree/post-sale-create-dialogs.tsx +253 -0
  43. package/src/spree/post-sale-fields.tsx +133 -0
  44. package/src/spree/product-membership-list.tsx +453 -0
  45. package/src/spree/products/status-card.tsx +79 -0
  46. package/src/spree/quantity-tier-editor.tsx +162 -0
  47. package/src/spree/resource-combobox.tsx +12 -1
  48. package/src/spree/resource-name-cell.tsx +21 -4
  49. package/src/spree/return-dialogs.tsx +238 -0
  50. package/src/spree/route-error-boundary.tsx +1 -1
  51. package/src/spree/row-actions.tsx +2 -2
  52. package/src/spree/search-input.tsx +76 -0
  53. package/src/spree/secret-input.tsx +26 -40
  54. package/src/spree/tax-identifiers-card.tsx +418 -0
  55. package/src/spree/theme-toggle.tsx +36 -23
  56. package/src/spree/wizard.tsx +254 -0
  57. package/src/styles.css +750 -75
  58. package/src/ui/accordion.tsx +83 -0
  59. package/src/ui/alert.tsx +79 -0
  60. package/src/ui/attachment.tsx +1 -1
  61. package/src/ui/badge.tsx +198 -56
  62. package/src/ui/breadcrumb.tsx +1 -1
  63. package/src/ui/button.tsx +22 -10
  64. package/src/ui/calendar.tsx +1 -1
  65. package/src/ui/card.tsx +52 -6
  66. package/src/ui/checkbox.tsx +13 -5
  67. package/src/ui/combobox.tsx +125 -6
  68. package/src/ui/command.tsx +149 -23
  69. package/src/ui/context-menu.tsx +99 -0
  70. package/src/ui/data-table.tsx +448 -9
  71. package/src/ui/date-picker.tsx +18 -3
  72. package/src/ui/date-range-picker.tsx +47 -60
  73. package/src/ui/dialog.tsx +6 -6
  74. package/src/ui/dropdown-menu.tsx +18 -11
  75. package/src/ui/field.tsx +10 -2
  76. package/src/ui/input-group.tsx +1 -1
  77. package/src/ui/input.tsx +1 -1
  78. package/src/ui/map.tsx +2110 -0
  79. package/src/ui/media-preview.tsx +105 -0
  80. package/src/ui/pagination.tsx +19 -11
  81. package/src/ui/popover.tsx +4 -2
  82. package/src/ui/progress.tsx +5 -2
  83. package/src/ui/radio-group.tsx +4 -4
  84. package/src/ui/rich-text-editor.tsx +214 -88
  85. package/src/ui/scroll-area.tsx +65 -0
  86. package/src/ui/select.tsx +17 -10
  87. package/src/ui/separator.tsx +1 -1
  88. package/src/ui/sheet.tsx +49 -7
  89. package/src/ui/sidebar.tsx +138 -37
  90. package/src/ui/skeleton.tsx +3 -1
  91. package/src/ui/tabs.tsx +84 -0
  92. package/src/ui/textarea.tsx +1 -1
  93. package/src/ui/thumbnail.tsx +80 -0
  94. package/src/ui/toast.tsx +112 -0
  95. package/src/ui/tooltip.tsx +3 -1
  96. 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 vendor panel, or a third-party using just `@spree/dashboard-ui` can all instantiate the same components with their own data sources.
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.13.1",
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-link": "^3.22.5",
56
- "@tiptap/extension-placeholder": "^3.22.5",
57
- "@tiptap/react": "^3.22.5",
58
- "@tiptap/starter-kit": "^3.22.5",
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.7.0",
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
- "typescript": "~6.0.3"
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 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.
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 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).
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 = () => setScrolled(window.scrollY > threshold)
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
- window.addEventListener('scroll', onScroll, { passive: true })
26
- return () => window.removeEventListener('scroll', onScroll)
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(/&nbsp;/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(/&nbsp;/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
- country_iso?: string | null
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
- title: string
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.country_iso, address.country_name)}
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
+ }