@open-mercato/ui 0.6.1-develop.3276.1.1fcd145078 → 0.6.1-develop.3287.1.450f4ffb56

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 (131) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +23 -14
  3. package/dist/backend/AppShell.js +8 -32
  4. package/dist/backend/AppShell.js.map +2 -2
  5. package/dist/backend/CrudForm.js +18 -0
  6. package/dist/backend/CrudForm.js.map +2 -2
  7. package/dist/backend/DataTable.js +149 -53
  8. package/dist/backend/DataTable.js.map +2 -2
  9. package/dist/backend/FilterBar.js +11 -14
  10. package/dist/backend/FilterBar.js.map +2 -2
  11. package/dist/backend/columns/ColumnChooserPanel.js +4 -6
  12. package/dist/backend/columns/ColumnChooserPanel.js.map +2 -2
  13. package/dist/backend/filters/ActiveFilterChips.js +142 -0
  14. package/dist/backend/filters/ActiveFilterChips.js.map +7 -0
  15. package/dist/backend/filters/AdvancedFilterBuilder.js +689 -163
  16. package/dist/backend/filters/AdvancedFilterBuilder.js.map +2 -2
  17. package/dist/backend/filters/AdvancedFilterPanel.js +354 -0
  18. package/dist/backend/filters/AdvancedFilterPanel.js.map +7 -0
  19. package/dist/backend/filters/FilterEmptyState.js +41 -0
  20. package/dist/backend/filters/FilterEmptyState.js.map +7 -0
  21. package/dist/backend/filters/FilterFieldPicker.js +148 -0
  22. package/dist/backend/filters/FilterFieldPicker.js.map +7 -0
  23. package/dist/backend/filters/FilteredEmptyResults.js +26 -0
  24. package/dist/backend/filters/FilteredEmptyResults.js.map +7 -0
  25. package/dist/backend/filters/QuickFilters.js +41 -0
  26. package/dist/backend/filters/QuickFilters.js.map +7 -0
  27. package/dist/backend/filters/filterValidation.js +47 -0
  28. package/dist/backend/filters/filterValidation.js.map +7 -0
  29. package/dist/backend/filters/treeReducer.js +159 -0
  30. package/dist/backend/filters/treeReducer.js.map +7 -0
  31. package/dist/backend/hooks/useAdvancedFilter.js +82 -1
  32. package/dist/backend/hooks/useAdvancedFilter.js.map +2 -2
  33. package/dist/backend/inputs/ComboboxInput.js +11 -7
  34. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  35. package/dist/backend/inputs/EventPatternInput.js +5 -2
  36. package/dist/backend/inputs/EventPatternInput.js.map +2 -2
  37. package/dist/backend/inputs/EventSelect.js +10 -4
  38. package/dist/backend/inputs/EventSelect.js.map +2 -2
  39. package/dist/backend/inputs/LookupSelect.js +51 -47
  40. package/dist/backend/inputs/LookupSelect.js.map +2 -2
  41. package/dist/backend/inputs/PhoneNumberField.js +196 -45
  42. package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
  43. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
  44. package/dist/backend/messages/message-compose-form-groups.js +2 -2
  45. package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
  46. package/dist/backend/utils/api.js +18 -1
  47. package/dist/backend/utils/api.js.map +2 -2
  48. package/dist/backend/utils/serverErrors.js +6 -2
  49. package/dist/backend/utils/serverErrors.js.map +2 -2
  50. package/dist/backend/utils/useAutoDiscoveredFields.js +1 -0
  51. package/dist/backend/utils/useAutoDiscoveredFields.js.map +2 -2
  52. package/dist/backend/utils/useCurrentUserId.js +31 -0
  53. package/dist/backend/utils/useCurrentUserId.js.map +7 -0
  54. package/dist/portal/utils/nav.js +17 -0
  55. package/dist/portal/utils/nav.js.map +2 -2
  56. package/dist/primitives/amount-input.js +128 -0
  57. package/dist/primitives/amount-input.js.map +7 -0
  58. package/dist/primitives/button-input.js +47 -0
  59. package/dist/primitives/button-input.js.map +7 -0
  60. package/dist/primitives/card-input.js +133 -0
  61. package/dist/primitives/card-input.js.map +7 -0
  62. package/dist/primitives/dialog.js +7 -5
  63. package/dist/primitives/dialog.js.map +2 -2
  64. package/dist/primitives/email-input.js +29 -0
  65. package/dist/primitives/email-input.js.map +7 -0
  66. package/dist/primitives/password-input.js +64 -0
  67. package/dist/primitives/password-input.js.map +7 -0
  68. package/dist/primitives/search-input.js +57 -0
  69. package/dist/primitives/search-input.js.map +7 -0
  70. package/dist/primitives/select.js +43 -5
  71. package/dist/primitives/select.js.map +2 -2
  72. package/dist/primitives/tag.js +4 -2
  73. package/dist/primitives/tag.js.map +2 -2
  74. package/dist/primitives/website-input.js +56 -0
  75. package/dist/primitives/website-input.js.map +7 -0
  76. package/package.json +3 -3
  77. package/src/backend/AppShell.tsx +7 -30
  78. package/src/backend/CrudForm.tsx +17 -0
  79. package/src/backend/DataTable.tsx +307 -63
  80. package/src/backend/FilterBar.tsx +5 -6
  81. package/src/backend/__tests__/DataTable.extensions.test.tsx +170 -0
  82. package/src/backend/__tests__/serverErrors.test.ts +79 -1
  83. package/src/backend/columns/ColumnChooserPanel.tsx +4 -5
  84. package/src/backend/filters/ActiveFilterChips.tsx +179 -0
  85. package/src/backend/filters/AdvancedFilterBuilder.tsx +840 -237
  86. package/src/backend/filters/AdvancedFilterPanel.tsx +456 -0
  87. package/src/backend/filters/FilterEmptyState.tsx +51 -0
  88. package/src/backend/filters/FilterFieldPicker.tsx +139 -0
  89. package/src/backend/filters/FilteredEmptyResults.tsx +40 -0
  90. package/src/backend/filters/QuickFilters.tsx +62 -0
  91. package/src/backend/filters/__tests__/ActiveFilterChips.test.tsx +75 -0
  92. package/src/backend/filters/__tests__/AdvancedFilterBuilder.test.tsx +210 -0
  93. package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +212 -0
  94. package/src/backend/filters/__tests__/FilterEmptyState.test.tsx +31 -0
  95. package/src/backend/filters/__tests__/FilterFieldPicker.test.tsx +61 -0
  96. package/src/backend/filters/__tests__/FilteredEmptyResults.test.tsx +35 -0
  97. package/src/backend/filters/__tests__/QuickFilters.test.tsx +34 -0
  98. package/src/backend/filters/__tests__/filterValidation.test.ts +69 -0
  99. package/src/backend/filters/__tests__/treeReducer.test.ts +189 -0
  100. package/src/backend/filters/filterValidation.ts +61 -0
  101. package/src/backend/filters/treeReducer.ts +201 -0
  102. package/src/backend/hooks/__tests__/useAdvancedFilter.test.tsx +213 -0
  103. package/src/backend/hooks/useAdvancedFilter.ts +130 -0
  104. package/src/backend/inputs/ComboboxInput.tsx +32 -26
  105. package/src/backend/inputs/EventPatternInput.tsx +11 -2
  106. package/src/backend/inputs/EventSelect.tsx +13 -4
  107. package/src/backend/inputs/LookupSelect.tsx +75 -53
  108. package/src/backend/inputs/PhoneNumberField.tsx +237 -47
  109. package/src/backend/inputs/SwitchableMarkdownInput.tsx +30 -0
  110. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +4 -0
  111. package/src/backend/inputs/__tests__/PhoneNumberField.test.tsx +4 -0
  112. package/src/backend/messages/message-compose-form-groups.tsx +2 -2
  113. package/src/backend/utils/api.ts +27 -2
  114. package/src/backend/utils/serverErrors.ts +15 -8
  115. package/src/backend/utils/useAutoDiscoveredFields.ts +2 -0
  116. package/src/backend/utils/useCurrentUserId.ts +40 -0
  117. package/src/portal/utils/__tests__/nav.test.ts +80 -0
  118. package/src/portal/utils/nav.ts +23 -1
  119. package/src/primitives/__tests__/password-input.test.tsx +93 -0
  120. package/src/primitives/__tests__/search-input.test.tsx +95 -0
  121. package/src/primitives/__tests__/zindex-overlay.test.tsx +26 -0
  122. package/src/primitives/amount-input.tsx +184 -0
  123. package/src/primitives/button-input.tsx +68 -0
  124. package/src/primitives/card-input.tsx +193 -0
  125. package/src/primitives/dialog.tsx +18 -7
  126. package/src/primitives/email-input.tsx +36 -0
  127. package/src/primitives/password-input.tsx +100 -0
  128. package/src/primitives/search-input.tsx +87 -0
  129. package/src/primitives/select.tsx +98 -11
  130. package/src/primitives/tag.tsx +3 -1
  131. package/src/primitives/website-input.tsx +72 -0
@@ -0,0 +1,456 @@
1
+ 'use client'
2
+ import * as React from 'react'
3
+ import { Popover, PopoverAnchor, PopoverContent } from '../../primitives/popover'
4
+ import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
5
+ import { Alert, AlertDescription } from '../../primitives/alert'
6
+ import { Button } from '../../primitives/button'
7
+ import { Input } from '../../primitives/input'
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogDescription,
12
+ DialogFooter,
13
+ DialogHeader,
14
+ DialogTitle,
15
+ } from '../../primitives/dialog'
16
+ import { Filter, Save, Trash2 } from 'lucide-react'
17
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
18
+ import {
19
+ deserializeTreeFromPersist,
20
+ isPersistedFilterTree,
21
+ serializeTreeForPersist,
22
+ type AdvancedFilterTree,
23
+ type PersistedFilterTree,
24
+ } from '@open-mercato/shared/lib/query/advanced-filter-tree'
25
+ import type { FilterFieldDef } from '@open-mercato/shared/lib/query/advanced-filter'
26
+ import { getDefaultOperator } from '@open-mercato/shared/lib/query/advanced-filter'
27
+ import type { ValidationError } from './filterValidation'
28
+ import { AdvancedFilterBuilder } from './AdvancedFilterBuilder'
29
+ import { FilterEmptyState } from './FilterEmptyState'
30
+ import { QuickFilters, type FilterPreset } from './QuickFilters'
31
+ import { FilterFieldPicker } from './FilterFieldPicker'
32
+ import { treeReducer } from './treeReducer'
33
+ import { flash } from '../FlashMessages'
34
+
35
+ export type AdvancedFilterPanelProps = {
36
+ fields: FilterFieldDef[]
37
+ value: AdvancedFilterTree
38
+ onChange: (t: AdvancedFilterTree) => void
39
+ onApply: () => void
40
+ onClear: () => void
41
+ onFlush?: () => void
42
+ pendingErrors: ValidationError[]
43
+ userId: string
44
+ presets: FilterPreset[]
45
+ open: boolean
46
+ onOpenChange: (open: boolean) => void
47
+ triggerRef?: React.RefObject<HTMLElement | null>
48
+ aiSlot?: React.ReactNode
49
+ savedFilterStorageKey?: string
50
+ }
51
+
52
+ type SavedAdvancedFilter = {
53
+ id: string
54
+ name: string
55
+ tree: PersistedFilterTree
56
+ createdAt: string
57
+ updatedAt: string
58
+ }
59
+
60
+ /**
61
+ * Versioned envelope for saved-filter records in localStorage. The `v` discriminator
62
+ * lets future schema changes (rename fields, retention policy, sharing flags) migrate
63
+ * existing records instead of silently dropping them. Bump `v` when adding a breaking
64
+ * change and add a read-old migration branch in `readSavedFilters`.
65
+ */
66
+ type SavedFiltersEnvelopeV1 = {
67
+ v: 1
68
+ filters: SavedAdvancedFilter[]
69
+ }
70
+
71
+ const SAVED_FILTER_STORAGE_PREFIX = 'open-mercato:advanced-filters:'
72
+ const MAX_SAVED_FILTERS = 20
73
+ const CURRENT_SAVED_FILTERS_VERSION = 1 as const
74
+
75
+ function makeSavedFilterStorageKey(key?: string): string | null {
76
+ const trimmed = key?.trim()
77
+ return trimmed ? `${SAVED_FILTER_STORAGE_PREFIX}${trimmed}` : null
78
+ }
79
+
80
+ function isSavedAdvancedFilter(value: unknown): value is SavedAdvancedFilter {
81
+ if (!value || typeof value !== 'object') return false
82
+ const record = value as Record<string, unknown>
83
+ return (
84
+ typeof record.id === 'string'
85
+ && typeof record.name === 'string'
86
+ && typeof record.createdAt === 'string'
87
+ && typeof record.updatedAt === 'string'
88
+ && isPersistedFilterTree(record.tree)
89
+ )
90
+ }
91
+
92
+ function readSavedFilters(storageKey: string): SavedAdvancedFilter[] {
93
+ if (typeof window === 'undefined') return []
94
+ try {
95
+ const raw = window.localStorage.getItem(storageKey)
96
+ if (!raw) return []
97
+ const parsed = JSON.parse(raw)
98
+ // v1 envelope (current shape).
99
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
100
+ const envelope = parsed as { v?: unknown; filters?: unknown }
101
+ if (envelope.v === 1 && Array.isArray(envelope.filters)) {
102
+ return envelope.filters.filter(isSavedAdvancedFilter)
103
+ }
104
+ }
105
+ // Pre-envelope shape (bare array) — migrate forward on next write.
106
+ if (Array.isArray(parsed)) {
107
+ return parsed.filter(isSavedAdvancedFilter)
108
+ }
109
+ return []
110
+ } catch {
111
+ return []
112
+ }
113
+ }
114
+
115
+ function writeSavedFilters(storageKey: string, filters: SavedAdvancedFilter[]) {
116
+ if (typeof window === 'undefined') return
117
+ const envelope: SavedFiltersEnvelopeV1 = { v: CURRENT_SAVED_FILTERS_VERSION, filters }
118
+ window.localStorage.setItem(storageKey, JSON.stringify(envelope))
119
+ }
120
+
121
+ function SaveFilterDialog({
122
+ open,
123
+ onOpenChange,
124
+ onSave,
125
+ t,
126
+ }: {
127
+ open: boolean
128
+ onOpenChange: (open: boolean) => void
129
+ onSave: (name: string) => Promise<void>
130
+ t: (key: string, fallback: string) => string
131
+ }) {
132
+ const [name, setName] = React.useState('')
133
+ const [saving, setSaving] = React.useState(false)
134
+ const [error, setError] = React.useState<string | null>(null)
135
+ const trimmed = name.trim()
136
+
137
+ React.useEffect(() => {
138
+ if (!open) {
139
+ setName('')
140
+ setSaving(false)
141
+ setError(null)
142
+ }
143
+ }, [open])
144
+
145
+ const submit = React.useCallback(async () => {
146
+ if (!trimmed || saving) return
147
+ setSaving(true)
148
+ setError(null)
149
+ try {
150
+ await onSave(trimmed)
151
+ onOpenChange(false)
152
+ } catch (err: unknown) {
153
+ setError(err instanceof Error ? err.message : t('ui.advancedFilter.saveFilter.error', 'Failed to save filter'))
154
+ } finally {
155
+ setSaving(false)
156
+ }
157
+ }, [onSave, onOpenChange, saving, t, trimmed])
158
+
159
+ return (
160
+ <Dialog open={open} onOpenChange={onOpenChange}>
161
+ {/* `elevated` lifts this dialog above the popover (z-popover=45) — at default
162
+ z-modal=40 the dialog is occluded by the popover that opens it. */}
163
+ <DialogContent elevated className="sm:max-w-md" data-advanced-filter-portal>
164
+ <form
165
+ className="contents"
166
+ onSubmit={(event) => {
167
+ event.preventDefault()
168
+ void submit()
169
+ }}
170
+ >
171
+ <DialogHeader>
172
+ <DialogTitle>{t('ui.advancedFilter.saveFilter.title', 'Save filter')}</DialogTitle>
173
+ <DialogDescription>
174
+ {t('ui.advancedFilter.saveFilter.description', 'Name this filter so you can reuse the current conditions later.')}
175
+ </DialogDescription>
176
+ </DialogHeader>
177
+ <div className="space-y-2">
178
+ <Input
179
+ value={name}
180
+ onChange={(event) => setName(event.target.value)}
181
+ placeholder={t('ui.advancedFilter.saveFilter.placeholder', 'Filter name')}
182
+ aria-label={t('ui.advancedFilter.saveFilter.nameAriaLabel', 'Filter name')}
183
+ autoFocus
184
+ />
185
+ {error ? <p className="text-sm text-status-error-text">{error}</p> : null}
186
+ </div>
187
+ <DialogFooter>
188
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
189
+ {t('common.cancel', 'Cancel')}
190
+ </Button>
191
+ <Button type="submit" disabled={!trimmed || saving}>
192
+ {saving ? t('ui.advancedFilter.saveFilter.saving', 'Saving...') : t('ui.advancedFilter.saveFilter.submit', 'Save filter')}
193
+ </Button>
194
+ </DialogFooter>
195
+ </form>
196
+ </DialogContent>
197
+ </Dialog>
198
+ )
199
+ }
200
+
201
+ function SavedFiltersSection({
202
+ filters,
203
+ canSaveCurrent,
204
+ onSaveClick,
205
+ onApply,
206
+ onDelete,
207
+ t,
208
+ }: {
209
+ filters: SavedAdvancedFilter[]
210
+ canSaveCurrent: boolean
211
+ onSaveClick: () => void
212
+ onApply: (filter: SavedAdvancedFilter) => void
213
+ onDelete: (filterId: string) => void
214
+ t: (key: string, fallback: string) => string
215
+ }) {
216
+ if (!canSaveCurrent && filters.length === 0) return null
217
+ return (
218
+ <div className="flex flex-col gap-2" data-testid="advanced-filter-saved-filters">
219
+ <div className="flex items-center justify-between gap-3">
220
+ <div className="text-overline font-medium uppercase tracking-widest text-muted-foreground">
221
+ {t('ui.advancedFilter.savedFilters.heading', 'Saved filters')}
222
+ </div>
223
+ {canSaveCurrent ? (
224
+ <Button
225
+ data-testid="advanced-filter-save-trigger"
226
+ type="button"
227
+ variant="outline"
228
+ size="sm"
229
+ className="h-8 rounded-full gap-1.5"
230
+ onClick={onSaveClick}
231
+ >
232
+ <Save className="size-3.5 text-muted-foreground" />
233
+ {t('ui.advancedFilter.saveFilter.button', 'Save filter')}
234
+ </Button>
235
+ ) : null}
236
+ </div>
237
+ {filters.length ? (
238
+ <div className="flex flex-wrap gap-2">
239
+ {filters.map((filter) => (
240
+ <div key={filter.id} className="inline-flex max-w-full items-center overflow-hidden rounded-full border border-border bg-background">
241
+ <Button
242
+ type="button"
243
+ variant="ghost"
244
+ size="sm"
245
+ className="h-8 min-w-0 rounded-r-none px-3 gap-1.5"
246
+ title={filter.name}
247
+ onClick={() => onApply(filter)}
248
+ >
249
+ <Filter className="size-3.5 shrink-0 text-muted-foreground" />
250
+ <span className="truncate">{filter.name}</span>
251
+ </Button>
252
+ <Button
253
+ type="button"
254
+ variant="ghost"
255
+ size="sm"
256
+ className="h-8 w-8 rounded-l-none px-0 text-muted-foreground hover:text-status-error-text"
257
+ aria-label={`${t('ui.advancedFilter.savedFilters.delete', 'Delete saved filter')}: ${filter.name}`}
258
+ onClick={() => onDelete(filter.id)}
259
+ >
260
+ <Trash2 className="size-3.5" />
261
+ </Button>
262
+ </div>
263
+ ))}
264
+ </div>
265
+ ) : (
266
+ <p className="text-xs text-muted-foreground">
267
+ {t('ui.advancedFilter.savedFilters.empty', 'No saved filters yet')}
268
+ </p>
269
+ )}
270
+ </div>
271
+ )
272
+ }
273
+
274
+ export function AdvancedFilterPanel(props: AdvancedFilterPanelProps) {
275
+ const t = useT()
276
+ const empty = props.value.root.children.length === 0
277
+ const [pickerOpen, setPickerOpen] = React.useState(false)
278
+ const [saveDialogOpen, setSaveDialogOpen] = React.useState(false)
279
+ const [savedFilters, setSavedFilters] = React.useState<SavedAdvancedFilter[]>([])
280
+ const addConditionRef = React.useRef<HTMLButtonElement>(null)
281
+ const savedFilterStorageKey = React.useMemo(
282
+ () => makeSavedFilterStorageKey(props.savedFilterStorageKey),
283
+ [props.savedFilterStorageKey],
284
+ )
285
+
286
+ // Flush on close — the hook's useEffect on close also flushes; this is belt-and-suspenders.
287
+ const wasOpenRef = React.useRef(props.open)
288
+ React.useEffect(() => {
289
+ if (wasOpenRef.current && !props.open && props.onFlush) props.onFlush()
290
+ wasOpenRef.current = props.open
291
+ }, [props.open, props.onFlush])
292
+
293
+ const handlePresetApply = (tree: AdvancedFilterTree) => {
294
+ props.onChange(tree)
295
+ props.onOpenChange(false)
296
+ }
297
+
298
+ React.useEffect(() => {
299
+ if (!savedFilterStorageKey) {
300
+ setSavedFilters([])
301
+ return
302
+ }
303
+ if (props.open) {
304
+ setSavedFilters(readSavedFilters(savedFilterStorageKey))
305
+ }
306
+ }, [props.open, savedFilterStorageKey])
307
+
308
+ const persistSavedFilters = React.useCallback((next: SavedAdvancedFilter[]) => {
309
+ if (!savedFilterStorageKey) return
310
+ setSavedFilters(next)
311
+ writeSavedFilters(savedFilterStorageKey, next)
312
+ }, [savedFilterStorageKey])
313
+
314
+ const handleSaveFilter = React.useCallback(async (name: string) => {
315
+ if (!savedFilterStorageKey) return
316
+ const now = new Date().toISOString()
317
+ const normalizedName = name.trim()
318
+ const existing = savedFilters.find((item) => item.name.trim().toLowerCase() === normalizedName.toLowerCase())
319
+ const record: SavedAdvancedFilter = {
320
+ id: existing?.id ?? crypto.randomUUID(),
321
+ name: normalizedName,
322
+ tree: serializeTreeForPersist(props.value),
323
+ createdAt: existing?.createdAt ?? now,
324
+ updatedAt: now,
325
+ }
326
+ const next = [
327
+ record,
328
+ ...savedFilters.filter((item) => item.id !== record.id),
329
+ ].slice(0, MAX_SAVED_FILTERS)
330
+ persistSavedFilters(next)
331
+ flash(t('ui.advancedFilter.saveFilter.success', 'Filter saved'), 'success')
332
+ }, [persistSavedFilters, props.value, savedFilterStorageKey, savedFilters, t])
333
+
334
+ const handleApplySavedFilter = React.useCallback((filter: SavedAdvancedFilter) => {
335
+ const tree = deserializeTreeFromPersist(filter.tree)
336
+ if (!tree) return
337
+ props.onChange(tree)
338
+ props.onOpenChange(false)
339
+ }, [props.onChange, props.onOpenChange])
340
+
341
+ const handleDeleteSavedFilter = React.useCallback((filterId: string) => {
342
+ persistSavedFilters(savedFilters.filter((item) => item.id !== filterId))
343
+ }, [persistSavedFilters, savedFilters])
344
+
345
+ const shouldShowSavedFilters = Boolean(savedFilterStorageKey && (!empty || savedFilters.length > 0))
346
+ const savedFiltersSection = shouldShowSavedFilters ? (
347
+ <SavedFiltersSection
348
+ filters={savedFilters}
349
+ canSaveCurrent={!empty}
350
+ onSaveClick={() => setSaveDialogOpen(true)}
351
+ onApply={handleApplySavedFilter}
352
+ onDelete={handleDeleteSavedFilter}
353
+ t={t}
354
+ />
355
+ ) : null
356
+
357
+ const emptyStateShortcuts = props.presets.length || savedFiltersSection ? (
358
+ <div className="flex flex-col gap-4">
359
+ {props.presets.length ? <QuickFilters presets={props.presets} userId={props.userId} onApply={handlePresetApply} /> : null}
360
+ {savedFiltersSection}
361
+ </div>
362
+ ) : null
363
+
364
+ const builderErrors = React.useMemo(
365
+ () => props.pendingErrors.map(e => ({ ruleId: e.ruleId, messageKey: e.messageKey, message: e.message })),
366
+ [props.pendingErrors],
367
+ )
368
+ const ignoreAdvancedFilterPortalInteractions = React.useCallback((event: { target: EventTarget | null; preventDefault: () => void }) => {
369
+ const target = event.target
370
+ if (!(target instanceof Element)) return
371
+ if (target.closest('[data-advanced-filter-portal]')) {
372
+ event.preventDefault()
373
+ }
374
+ }, [])
375
+
376
+ return (
377
+ <Popover open={props.open} onOpenChange={props.onOpenChange}>
378
+ {props.triggerRef ? (
379
+ <PopoverAnchor virtualRef={props.triggerRef as React.RefObject<{ getBoundingClientRect(): DOMRect }>} />
380
+ ) : null}
381
+ <PopoverContent
382
+ className="w-[min(calc(100vw-2rem),780px)] max-h-[min(calc(100vh-6rem),720px)] overflow-y-auto p-0 gap-0"
383
+ align="end"
384
+ sideOffset={8}
385
+ data-testid="advanced-filter-panel"
386
+ onPointerDownOutside={ignoreAdvancedFilterPortalInteractions}
387
+ onFocusOutside={ignoreAdvancedFilterPortalInteractions}
388
+ onInteractOutside={ignoreAdvancedFilterPortalInteractions}
389
+ >
390
+ <VisuallyHidden>
391
+ <h2>{t('ui.advancedFilter.dialog.title', 'Advanced filter')}</h2>
392
+ </VisuallyHidden>
393
+ <div className="flex flex-col">
394
+ {empty ? (
395
+ <FilterEmptyState
396
+ onAddCondition={() => setPickerOpen(true)}
397
+ addConditionRef={addConditionRef}
398
+ aiSlot={props.aiSlot}
399
+ quickFilters={emptyStateShortcuts}
400
+ />
401
+ ) : (
402
+ <>
403
+ <AdvancedFilterBuilder
404
+ fields={props.fields}
405
+ value={props.value}
406
+ onChange={props.onChange}
407
+ onApply={props.onApply}
408
+ onClear={props.onClear}
409
+ pendingErrors={builderErrors}
410
+ />
411
+ {savedFiltersSection ? (
412
+ <div className="border-t border-border p-3">
413
+ {savedFiltersSection}
414
+ </div>
415
+ ) : null}
416
+ </>
417
+ )}
418
+ {props.pendingErrors.length > 0 ? (
419
+ <div className="p-3 border-t border-border" data-testid="filter-validation-banner">
420
+ <Alert variant="destructive">
421
+ <AlertDescription>
422
+ {props.pendingErrors.length === 1
423
+ ? t('ui.advancedFilter.banner.incomplete', '{count} filter is incomplete — filters won\'t apply until all values are picked.', { count: 1 })
424
+ : t('ui.advancedFilter.banner.incompletePlural', '{count} filters are incomplete — filters won\'t apply until all values are picked.', { count: props.pendingErrors.length })}
425
+ </AlertDescription>
426
+ </Alert>
427
+ </div>
428
+ ) : null}
429
+ </div>
430
+ </PopoverContent>
431
+ <FilterFieldPicker
432
+ fields={props.fields}
433
+ open={pickerOpen}
434
+ onOpenChange={setPickerOpen}
435
+ onSelect={(field) => {
436
+ // Pass defaultOperator so the rule starts with a valid operator for the field's
437
+ // type — otherwise emptyRule defaults to 'contains', which only matches text.
438
+ const next = treeReducer(props.value, {
439
+ type: 'addRule',
440
+ groupId: props.value.root.id,
441
+ defaultField: field.key,
442
+ defaultOperator: getDefaultOperator(field.type),
443
+ })
444
+ props.onChange(next)
445
+ }}
446
+ triggerRef={addConditionRef}
447
+ />
448
+ <SaveFilterDialog
449
+ open={saveDialogOpen}
450
+ onOpenChange={setSaveDialogOpen}
451
+ onSave={handleSaveFilter}
452
+ t={t}
453
+ />
454
+ </Popover>
455
+ )
456
+ }
@@ -0,0 +1,51 @@
1
+ 'use client'
2
+ import * as React from 'react'
3
+ import { Filter, Plus } from 'lucide-react'
4
+ import { Button } from '../../primitives/button'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+
7
+ export type FilterEmptyStateProps = {
8
+ onAddCondition: () => void
9
+ addConditionRef: React.RefObject<HTMLButtonElement | null>
10
+ aiSlot?: React.ReactNode
11
+ quickFilters?: React.ReactNode
12
+ }
13
+
14
+ export function FilterEmptyState({ onAddCondition, addConditionRef, aiSlot, quickFilters }: FilterEmptyStateProps) {
15
+ const t = useT()
16
+ return (
17
+ <div className="flex flex-col items-stretch gap-4 p-2" data-testid="filter-empty-state">
18
+ <div className="flex flex-col items-center text-center py-6 gap-3">
19
+ <div className="flex size-14 items-center justify-center rounded-full bg-brand-violet/10">
20
+ <Filter className="size-6 text-brand-violet" />
21
+ </div>
22
+ <div className="flex flex-col gap-1">
23
+ <div className="text-base font-semibold">
24
+ {t('ui.advancedFilter.empty.title', 'No filters applied')}
25
+ </div>
26
+ <div className="text-sm text-muted-foreground max-w-md">
27
+ {t('ui.advancedFilter.empty.subtitle', 'Add a condition to narrow down the list, or describe what you want in natural language.')}
28
+ </div>
29
+ </div>
30
+ <div className="flex items-center gap-2">
31
+ <Button
32
+ type="button"
33
+ ref={addConditionRef}
34
+ onClick={onAddCondition}
35
+ className="bg-brand-violet text-brand-violet-foreground hover:bg-brand-violet/90"
36
+ >
37
+ <Plus className="size-4" />
38
+ {t('ui.advancedFilter.empty.addCondition', 'Add condition')}
39
+ </Button>
40
+ {aiSlot}
41
+ </div>
42
+ </div>
43
+ {quickFilters ? (
44
+ <>
45
+ <div className="border-t border-border" />
46
+ {quickFilters}
47
+ </>
48
+ ) : null}
49
+ </div>
50
+ )
51
+ }
@@ -0,0 +1,139 @@
1
+ // packages/ui/src/backend/filters/FilterFieldPicker.tsx
2
+ 'use client'
3
+ import * as React from 'react'
4
+ import { Search, Activity, Calendar, Hash, Tag, ArrowRight, ALargeSmall, UserRound, Mail, Phone, Filter, type LucideIcon } from 'lucide-react'
5
+ import { Popover, PopoverContent, PopoverAnchor } from '../../primitives/popover'
6
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
7
+ import type { FilterFieldDef, FilterFieldType } from '@open-mercato/shared/lib/query/advanced-filter'
8
+
9
+ const TYPE_ICON: Record<FilterFieldType, LucideIcon> = {
10
+ text: ALargeSmall,
11
+ select: Activity,
12
+ tags: Tag,
13
+ date: Calendar,
14
+ number: Hash,
15
+ boolean: ArrowRight,
16
+ }
17
+
18
+ const NAMED_ICONS: Record<string, LucideIcon> = {
19
+ 'user-round': UserRound,
20
+ mail: Mail,
21
+ phone: Phone,
22
+ tag: Tag,
23
+ filter: Filter,
24
+ }
25
+
26
+ function resolveIcon(field: FilterFieldDef): LucideIcon {
27
+ if (field.iconName && NAMED_ICONS[field.iconName]) return NAMED_ICONS[field.iconName]
28
+ return TYPE_ICON[field.type] ?? ALargeSmall
29
+ }
30
+
31
+ export type FilterFieldPickerProps = {
32
+ fields: FilterFieldDef[]
33
+ open: boolean
34
+ onOpenChange: (open: boolean) => void
35
+ onSelect: (field: FilterFieldDef) => void
36
+ triggerRef: React.RefObject<HTMLElement | null>
37
+ }
38
+
39
+ export function FilterFieldPicker({ fields, open, onOpenChange, onSelect, triggerRef }: FilterFieldPickerProps) {
40
+ const t = useT()
41
+ const [query, setQuery] = React.useState('')
42
+ const [activeIdx, setActiveIdx] = React.useState(0)
43
+ const searchRef = React.useRef<HTMLInputElement>(null)
44
+
45
+ React.useEffect(() => { if (open) { setQuery(''); setActiveIdx(0); searchRef.current?.focus() } }, [open])
46
+
47
+ const filtered = React.useMemo(() => {
48
+ if (!query.trim()) return fields
49
+ const q = query.trim().toLowerCase()
50
+ return fields.filter(f => f.label.toLowerCase().includes(q) || f.key.toLowerCase().includes(q))
51
+ }, [fields, query])
52
+
53
+ const grouped = React.useMemo(() => {
54
+ const map = new Map<string, FilterFieldDef[]>()
55
+ const order: string[] = []
56
+ for (const f of filtered) {
57
+ const g = f.group ?? 'More'
58
+ if (!map.has(g)) { map.set(g, []); order.push(g) }
59
+ map.get(g)!.push(f)
60
+ }
61
+ return order.map(g => ({ group: g, items: map.get(g)! }))
62
+ }, [filtered])
63
+
64
+ const flatVisible = React.useMemo(() => grouped.flatMap(g => g.items), [grouped])
65
+
66
+ React.useEffect(() => {
67
+ if (!open) return
68
+ function onKey(e: KeyboardEvent) {
69
+ if (e.key === 'Escape') { onOpenChange(false); return }
70
+ if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIdx(i => Math.min(i + 1, flatVisible.length - 1)) }
71
+ if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIdx(i => Math.max(i - 1, 0)) }
72
+ if (e.key === 'Enter') {
73
+ e.preventDefault()
74
+ const f = flatVisible[activeIdx]
75
+ if (f) { onSelect(f); onOpenChange(false) }
76
+ }
77
+ if (e.key === '/' && document.activeElement !== searchRef.current) {
78
+ e.preventDefault(); searchRef.current?.focus()
79
+ }
80
+ }
81
+ window.addEventListener('keydown', onKey)
82
+ return () => window.removeEventListener('keydown', onKey)
83
+ }, [open, flatVisible, activeIdx, onSelect, onOpenChange])
84
+
85
+ return (
86
+ <Popover open={open} onOpenChange={onOpenChange}>
87
+ {/* Radix expects RefObject<Measurable>; our prop accepts null. Narrow cast covers the React 19 ↔ Radix variance. */}
88
+ <PopoverAnchor virtualRef={triggerRef as React.RefObject<{ getBoundingClientRect(): DOMRect }>} />
89
+ <PopoverContent className="w-80 p-0" align="start" data-advanced-filter-portal="">
90
+ <div className="flex flex-col">
91
+ <div className="relative p-2 border-b border-border">
92
+ <Search className="absolute left-4 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
93
+ <input
94
+ ref={searchRef}
95
+ type="text"
96
+ value={query}
97
+ onChange={(e) => { setQuery(e.target.value); setActiveIdx(0) }}
98
+ placeholder={t('ui.advancedFilter.fieldPicker.search', 'Search field…')}
99
+ className="h-8 w-full rounded-md border border-input bg-background pl-8 pr-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50"
100
+ aria-label={t('ui.advancedFilter.fieldPicker.search', 'Search field…')}
101
+ />
102
+ </div>
103
+ <div className="max-h-[400px] overflow-y-auto py-1" role="listbox">
104
+ {grouped.map(({ group, items }) => (
105
+ <div key={group}>
106
+ <div className="px-3 pt-2 pb-1 text-overline font-semibold uppercase tracking-widest text-muted-foreground">{group}</div>
107
+ {items.map((f) => {
108
+ const flatIdx = flatVisible.indexOf(f)
109
+ const Icon = resolveIcon(f)
110
+ const active = flatIdx === activeIdx
111
+ return (
112
+ // Raw <button> required: needs role="option" inside the listbox, which <Button>
113
+ // would override (Button forces role="button"). a11y semantics take precedence
114
+ // over the primitive contract for listbox items.
115
+ <button
116
+ key={f.key}
117
+ type="button"
118
+ role="option"
119
+ aria-selected={active}
120
+ onClick={() => { onSelect(f); onOpenChange(false) }}
121
+ onMouseEnter={() => setActiveIdx(flatIdx)}
122
+ className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-accent ${active ? 'bg-secondary' : ''}`}
123
+ >
124
+ <Icon className="size-4 text-muted-foreground" />
125
+ <span>{f.label}</span>
126
+ </button>
127
+ )
128
+ })}
129
+ </div>
130
+ ))}
131
+ </div>
132
+ <div className="px-3 py-2 border-t border-border text-xs text-muted-foreground">
133
+ {t('ui.advancedFilter.fieldPicker.hint', 'Tip: Type to search, ↑↓ to navigate, Enter to select')}
134
+ </div>
135
+ </div>
136
+ </PopoverContent>
137
+ </Popover>
138
+ )
139
+ }
@@ -0,0 +1,40 @@
1
+ 'use client'
2
+ import * as React from 'react'
3
+ import { SearchX, Undo2 } from 'lucide-react'
4
+ import { Button } from '../../primitives/button'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+
7
+ export type FilteredEmptyResultsProps = {
8
+ entityNamePlural: string
9
+ canRemoveLast: boolean
10
+ onClearAll: () => void
11
+ onRemoveLast: () => void
12
+ }
13
+
14
+ export function FilteredEmptyResults({ entityNamePlural, canRemoveLast, onClearAll, onRemoveLast }: FilteredEmptyResultsProps) {
15
+ const t = useT()
16
+ return (
17
+ <div className="flex flex-col items-center justify-center gap-4 py-12" data-testid="filtered-empty-results">
18
+ <div className="flex size-14 items-center justify-center rounded-full bg-muted">
19
+ <SearchX className="size-6 text-muted-foreground" />
20
+ </div>
21
+ <div className="flex flex-col items-center gap-1">
22
+ <div className="text-base font-semibold">
23
+ {t('ui.advancedFilter.empty.noMatches', 'No {entity} match these filters', { entity: entityNamePlural })}
24
+ </div>
25
+ <div className="text-sm text-muted-foreground max-w-md text-center">
26
+ {t('ui.advancedFilter.empty.tryRemoving', 'Try removing the most restrictive filter, or clear all filters to see everyone.')}
27
+ </div>
28
+ </div>
29
+ <div className="flex items-center gap-2">
30
+ <Button type="button" onClick={onClearAll}>
31
+ {t('ui.advancedFilter.empty.clearAll', 'Clear all filters')}
32
+ </Button>
33
+ <Button type="button" variant="outline" disabled={!canRemoveLast} onClick={onRemoveLast}>
34
+ <Undo2 className="size-4" />
35
+ {t('ui.advancedFilter.empty.removeLast', 'Remove last filter')}
36
+ </Button>
37
+ </div>
38
+ </div>
39
+ )
40
+ }