@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +23 -14
- package/dist/backend/AppShell.js +8 -32
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +18 -0
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +149 -53
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/FilterBar.js +11 -14
- package/dist/backend/FilterBar.js.map +2 -2
- package/dist/backend/columns/ColumnChooserPanel.js +4 -6
- package/dist/backend/columns/ColumnChooserPanel.js.map +2 -2
- package/dist/backend/filters/ActiveFilterChips.js +142 -0
- package/dist/backend/filters/ActiveFilterChips.js.map +7 -0
- package/dist/backend/filters/AdvancedFilterBuilder.js +689 -163
- package/dist/backend/filters/AdvancedFilterBuilder.js.map +2 -2
- package/dist/backend/filters/AdvancedFilterPanel.js +354 -0
- package/dist/backend/filters/AdvancedFilterPanel.js.map +7 -0
- package/dist/backend/filters/FilterEmptyState.js +41 -0
- package/dist/backend/filters/FilterEmptyState.js.map +7 -0
- package/dist/backend/filters/FilterFieldPicker.js +148 -0
- package/dist/backend/filters/FilterFieldPicker.js.map +7 -0
- package/dist/backend/filters/FilteredEmptyResults.js +26 -0
- package/dist/backend/filters/FilteredEmptyResults.js.map +7 -0
- package/dist/backend/filters/QuickFilters.js +41 -0
- package/dist/backend/filters/QuickFilters.js.map +7 -0
- package/dist/backend/filters/filterValidation.js +47 -0
- package/dist/backend/filters/filterValidation.js.map +7 -0
- package/dist/backend/filters/treeReducer.js +159 -0
- package/dist/backend/filters/treeReducer.js.map +7 -0
- package/dist/backend/hooks/useAdvancedFilter.js +82 -1
- package/dist/backend/hooks/useAdvancedFilter.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +11 -7
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/inputs/EventPatternInput.js +5 -2
- package/dist/backend/inputs/EventPatternInput.js.map +2 -2
- package/dist/backend/inputs/EventSelect.js +10 -4
- package/dist/backend/inputs/EventSelect.js.map +2 -2
- package/dist/backend/inputs/LookupSelect.js +51 -47
- package/dist/backend/inputs/LookupSelect.js.map +2 -2
- package/dist/backend/inputs/PhoneNumberField.js +196 -45
- package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
- package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
- package/dist/backend/messages/message-compose-form-groups.js +2 -2
- package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
- package/dist/backend/utils/api.js +18 -1
- package/dist/backend/utils/api.js.map +2 -2
- package/dist/backend/utils/serverErrors.js +6 -2
- package/dist/backend/utils/serverErrors.js.map +2 -2
- package/dist/backend/utils/useAutoDiscoveredFields.js +1 -0
- package/dist/backend/utils/useAutoDiscoveredFields.js.map +2 -2
- package/dist/backend/utils/useCurrentUserId.js +31 -0
- package/dist/backend/utils/useCurrentUserId.js.map +7 -0
- package/dist/portal/utils/nav.js +17 -0
- package/dist/portal/utils/nav.js.map +2 -2
- package/dist/primitives/amount-input.js +128 -0
- package/dist/primitives/amount-input.js.map +7 -0
- package/dist/primitives/button-input.js +47 -0
- package/dist/primitives/button-input.js.map +7 -0
- package/dist/primitives/card-input.js +133 -0
- package/dist/primitives/card-input.js.map +7 -0
- package/dist/primitives/dialog.js +7 -5
- package/dist/primitives/dialog.js.map +2 -2
- package/dist/primitives/email-input.js +29 -0
- package/dist/primitives/email-input.js.map +7 -0
- package/dist/primitives/password-input.js +64 -0
- package/dist/primitives/password-input.js.map +7 -0
- package/dist/primitives/search-input.js +57 -0
- package/dist/primitives/search-input.js.map +7 -0
- package/dist/primitives/select.js +43 -5
- package/dist/primitives/select.js.map +2 -2
- package/dist/primitives/tag.js +4 -2
- package/dist/primitives/tag.js.map +2 -2
- package/dist/primitives/website-input.js +56 -0
- package/dist/primitives/website-input.js.map +7 -0
- package/package.json +3 -3
- package/src/backend/AppShell.tsx +7 -30
- package/src/backend/CrudForm.tsx +17 -0
- package/src/backend/DataTable.tsx +307 -63
- package/src/backend/FilterBar.tsx +5 -6
- package/src/backend/__tests__/DataTable.extensions.test.tsx +170 -0
- package/src/backend/__tests__/serverErrors.test.ts +79 -1
- package/src/backend/columns/ColumnChooserPanel.tsx +4 -5
- package/src/backend/filters/ActiveFilterChips.tsx +179 -0
- package/src/backend/filters/AdvancedFilterBuilder.tsx +840 -237
- package/src/backend/filters/AdvancedFilterPanel.tsx +456 -0
- package/src/backend/filters/FilterEmptyState.tsx +51 -0
- package/src/backend/filters/FilterFieldPicker.tsx +139 -0
- package/src/backend/filters/FilteredEmptyResults.tsx +40 -0
- package/src/backend/filters/QuickFilters.tsx +62 -0
- package/src/backend/filters/__tests__/ActiveFilterChips.test.tsx +75 -0
- package/src/backend/filters/__tests__/AdvancedFilterBuilder.test.tsx +210 -0
- package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +212 -0
- package/src/backend/filters/__tests__/FilterEmptyState.test.tsx +31 -0
- package/src/backend/filters/__tests__/FilterFieldPicker.test.tsx +61 -0
- package/src/backend/filters/__tests__/FilteredEmptyResults.test.tsx +35 -0
- package/src/backend/filters/__tests__/QuickFilters.test.tsx +34 -0
- package/src/backend/filters/__tests__/filterValidation.test.ts +69 -0
- package/src/backend/filters/__tests__/treeReducer.test.ts +189 -0
- package/src/backend/filters/filterValidation.ts +61 -0
- package/src/backend/filters/treeReducer.ts +201 -0
- package/src/backend/hooks/__tests__/useAdvancedFilter.test.tsx +213 -0
- package/src/backend/hooks/useAdvancedFilter.ts +130 -0
- package/src/backend/inputs/ComboboxInput.tsx +32 -26
- package/src/backend/inputs/EventPatternInput.tsx +11 -2
- package/src/backend/inputs/EventSelect.tsx +13 -4
- package/src/backend/inputs/LookupSelect.tsx +75 -53
- package/src/backend/inputs/PhoneNumberField.tsx +237 -47
- package/src/backend/inputs/SwitchableMarkdownInput.tsx +30 -0
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +4 -0
- package/src/backend/inputs/__tests__/PhoneNumberField.test.tsx +4 -0
- package/src/backend/messages/message-compose-form-groups.tsx +2 -2
- package/src/backend/utils/api.ts +27 -2
- package/src/backend/utils/serverErrors.ts +15 -8
- package/src/backend/utils/useAutoDiscoveredFields.ts +2 -0
- package/src/backend/utils/useCurrentUserId.ts +40 -0
- package/src/portal/utils/__tests__/nav.test.ts +80 -0
- package/src/portal/utils/nav.ts +23 -1
- package/src/primitives/__tests__/password-input.test.tsx +93 -0
- package/src/primitives/__tests__/search-input.test.tsx +95 -0
- package/src/primitives/__tests__/zindex-overlay.test.tsx +26 -0
- package/src/primitives/amount-input.tsx +184 -0
- package/src/primitives/button-input.tsx +68 -0
- package/src/primitives/card-input.tsx +193 -0
- package/src/primitives/dialog.tsx +18 -7
- package/src/primitives/email-input.tsx +36 -0
- package/src/primitives/password-input.tsx +100 -0
- package/src/primitives/search-input.tsx +87 -0
- package/src/primitives/select.tsx +98 -11
- package/src/primitives/tag.tsx +3 -1
- package/src/primitives/website-input.tsx +72 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
-
import { Loader2, Search, X } from 'lucide-react'
|
|
4
|
+
import { Check, Loader2, Search, X } from 'lucide-react'
|
|
5
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
5
6
|
import { Button } from '../../primitives/button'
|
|
6
7
|
import { cn } from '@open-mercato/shared/lib/utils'
|
|
7
8
|
|
|
@@ -50,19 +51,32 @@ export function LookupSelect({
|
|
|
50
51
|
actionSlot,
|
|
51
52
|
onReady,
|
|
52
53
|
placeholder,
|
|
53
|
-
searchPlaceholder
|
|
54
|
-
clearLabel
|
|
55
|
-
emptyLabel
|
|
56
|
-
loadingLabel
|
|
57
|
-
selectLabel
|
|
58
|
-
selectedLabel
|
|
54
|
+
searchPlaceholder,
|
|
55
|
+
clearLabel,
|
|
56
|
+
emptyLabel,
|
|
57
|
+
loadingLabel,
|
|
58
|
+
selectLabel,
|
|
59
|
+
selectedLabel,
|
|
59
60
|
minQueryHintLabel,
|
|
60
|
-
startTypingLabel
|
|
61
|
+
startTypingLabel,
|
|
61
62
|
selectedHintLabel,
|
|
62
63
|
disabled = false,
|
|
63
64
|
loading: loadingProp = false,
|
|
64
65
|
defaultOpen = false,
|
|
65
66
|
}: LookupSelectProps) {
|
|
67
|
+
const t = useT()
|
|
68
|
+
const resolvedSearchPlaceholder = searchPlaceholder ?? placeholder ?? t('ui.lookupSelect.searchPlaceholder', 'Search…')
|
|
69
|
+
const resolvedClearLabel = clearLabel ?? t('ui.lookupSelect.clearSelection', 'Clear selection')
|
|
70
|
+
const resolvedEmptyLabel = emptyLabel ?? t('ui.lookupSelect.noResults', 'No results')
|
|
71
|
+
const resolvedLoadingLabel = loadingLabel ?? t('ui.lookupSelect.searching', 'Searching…')
|
|
72
|
+
const resolvedSelectLabel = selectLabel ?? t('ui.lookupSelect.select', 'Select')
|
|
73
|
+
const resolvedSelectedLabel = selectedLabel ?? t('ui.lookupSelect.selected', 'Selected')
|
|
74
|
+
const resolvedStartTypingLabel = startTypingLabel ?? t('ui.lookupSelect.startTyping', 'Start typing to search.')
|
|
75
|
+
const resolvedMinQueryHintLabel = minQueryHintLabel ?? t(
|
|
76
|
+
'ui.lookupSelect.minQueryHint',
|
|
77
|
+
'Type at least {minQuery} characters or paste an id to search.',
|
|
78
|
+
{ minQuery: String(minQuery) }
|
|
79
|
+
)
|
|
66
80
|
const [query, setQuery] = React.useState('')
|
|
67
81
|
const [items, setItems] = React.useState<LookupSelectItem[]>(options ?? [])
|
|
68
82
|
const [loading, setLoading] = React.useState(false)
|
|
@@ -139,15 +153,15 @@ export function LookupSelect({
|
|
|
139
153
|
<div className="space-y-3">
|
|
140
154
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
|
141
155
|
<div className="relative flex-1">
|
|
142
|
-
<Search className="pointer-events-none absolute left-
|
|
156
|
+
<Search className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
143
157
|
<input
|
|
144
|
-
className="w-full rounded border pl-
|
|
158
|
+
className="w-full h-10 rounded-lg border border-input bg-background pl-10 pr-3 text-sm shadow-xs transition-colors outline-none placeholder:text-muted-foreground hover:border-foreground/20 focus-visible:shadow-focus focus-visible:border-brand-violet disabled:bg-bg-disabled disabled:border-border-disabled disabled:text-muted-foreground disabled:cursor-not-allowed"
|
|
145
159
|
value={query}
|
|
146
160
|
onChange={(event) => {
|
|
147
161
|
setQuery(event.target.value)
|
|
148
162
|
setHasTyped(true)
|
|
149
163
|
}}
|
|
150
|
-
placeholder={
|
|
164
|
+
placeholder={resolvedSearchPlaceholder}
|
|
151
165
|
disabled={disabled}
|
|
152
166
|
/>
|
|
153
167
|
</div>
|
|
@@ -158,71 +172,79 @@ export function LookupSelect({
|
|
|
158
172
|
{loading || loadingProp ? (
|
|
159
173
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
160
174
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
161
|
-
{
|
|
175
|
+
{resolvedLoadingLabel}
|
|
162
176
|
</div>
|
|
163
177
|
) : null}
|
|
164
178
|
{!loading && !loadingProp && !items.length ? (
|
|
165
|
-
<p className="text-xs text-muted-foreground">{
|
|
179
|
+
<p className="text-xs text-muted-foreground">{resolvedEmptyLabel}</p>
|
|
166
180
|
) : null}
|
|
167
|
-
<div className="
|
|
181
|
+
<div className="flex flex-col gap-1.5 max-h-80 overflow-y-auto -mx-0.5 px-0.5 py-0.5">
|
|
168
182
|
{items.map((item) => {
|
|
169
183
|
const isSelected = value === item.id
|
|
184
|
+
const isInteractive = !item.disabled || isSelected
|
|
170
185
|
return (
|
|
171
186
|
<div
|
|
172
187
|
key={item.id}
|
|
173
188
|
className={cn(
|
|
174
|
-
'flex gap-
|
|
175
|
-
|
|
189
|
+
'group flex items-center gap-4 rounded-xl border p-4 transition-all duration-150 focus-visible:outline-none focus-visible:shadow-focus',
|
|
190
|
+
isInteractive ? 'cursor-pointer' : 'cursor-not-allowed opacity-60',
|
|
191
|
+
isSelected
|
|
192
|
+
? 'border-brand-violet bg-brand-violet/5 shadow-sm'
|
|
193
|
+
: 'border-input bg-card hover:border-foreground/20 hover:bg-muted/30 hover:shadow-sm'
|
|
176
194
|
)}
|
|
177
195
|
role="button"
|
|
178
196
|
tabIndex={item.disabled ? -1 : 0}
|
|
179
197
|
onClick={() => {
|
|
180
|
-
if (
|
|
198
|
+
if (!isInteractive) return
|
|
181
199
|
onChange(item.id)
|
|
182
200
|
}}
|
|
183
201
|
onKeyDown={(event) => {
|
|
184
202
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
185
203
|
event.preventDefault()
|
|
186
|
-
if (
|
|
204
|
+
if (!isInteractive) return
|
|
187
205
|
onChange(item.id)
|
|
188
206
|
}
|
|
189
207
|
}}
|
|
190
208
|
aria-pressed={isSelected}
|
|
209
|
+
aria-disabled={item.disabled && !isSelected ? true : undefined}
|
|
210
|
+
title={isSelected ? resolvedSelectedLabel : resolvedSelectLabel}
|
|
191
211
|
>
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
212
|
+
{item.icon ? (
|
|
213
|
+
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden [&>svg]:size-6 [&_svg]:text-muted-foreground">
|
|
214
|
+
{item.icon}
|
|
215
|
+
</div>
|
|
216
|
+
) : (
|
|
217
|
+
<div className={cn(
|
|
218
|
+
'flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-lg border transition-colors',
|
|
219
|
+
isSelected
|
|
220
|
+
? 'border-brand-violet/40 bg-brand-violet/10 text-brand-violet'
|
|
221
|
+
: 'border-input bg-muted text-muted-foreground group-hover:border-foreground/20'
|
|
222
|
+
)}>
|
|
223
|
+
<span className="text-base font-semibold uppercase">{item.title.slice(0, 1)}</span>
|
|
224
|
+
</div>
|
|
225
|
+
)}
|
|
226
|
+
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
227
|
+
<div className="flex items-center justify-between gap-3">
|
|
228
|
+
<div className="truncate text-sm font-semibold text-foreground">{item.title}</div>
|
|
206
229
|
{item.rightLabel ? (
|
|
207
|
-
<div className="shrink-0 text-
|
|
230
|
+
<div className="shrink-0 text-overline font-medium uppercase tracking-wider text-muted-foreground">
|
|
231
|
+
{item.rightLabel}
|
|
232
|
+
</div>
|
|
208
233
|
) : null}
|
|
209
234
|
</div>
|
|
210
|
-
|
|
211
|
-
<
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
{isSelected ? selectedLabel : selectLabel}
|
|
224
|
-
</Button>
|
|
225
|
-
</div>
|
|
235
|
+
{item.subtitle ? (
|
|
236
|
+
<div className="text-xs text-muted-foreground truncate">{item.subtitle}</div>
|
|
237
|
+
) : null}
|
|
238
|
+
{item.description ? (
|
|
239
|
+
<div className="text-xs text-muted-foreground/70 truncate">{item.description}</div>
|
|
240
|
+
) : null}
|
|
241
|
+
</div>
|
|
242
|
+
<div className="flex shrink-0 items-center justify-center">
|
|
243
|
+
{isSelected ? (
|
|
244
|
+
<Check className="size-5 text-brand-violet" aria-hidden="true" />
|
|
245
|
+
) : (
|
|
246
|
+
<div className="size-5" aria-hidden="true" />
|
|
247
|
+
)}
|
|
226
248
|
</div>
|
|
227
249
|
</div>
|
|
228
250
|
)
|
|
@@ -237,18 +259,18 @@ export function LookupSelect({
|
|
|
237
259
|
onClick={() => onChange(null)}
|
|
238
260
|
>
|
|
239
261
|
<X className="h-4 w-4" />
|
|
240
|
-
{
|
|
262
|
+
{resolvedClearLabel}
|
|
241
263
|
</Button>
|
|
242
264
|
) : null}
|
|
243
265
|
</div>
|
|
244
266
|
) : hasTyped ? (
|
|
245
267
|
<p className="text-xs text-muted-foreground">
|
|
246
|
-
{
|
|
268
|
+
{resolvedMinQueryHintLabel}
|
|
247
269
|
</p>
|
|
248
270
|
) : (
|
|
249
|
-
<p className="text-xs text-muted-foreground">{
|
|
271
|
+
<p className="text-xs text-muted-foreground">{resolvedStartTypingLabel}</p>
|
|
250
272
|
)}
|
|
251
|
-
{error ? <p className="text-xs text-
|
|
273
|
+
{error ? <p className="text-xs text-status-error-text" role="alert">{resolvedEmptyLabel}</p> : null}
|
|
252
274
|
</div>
|
|
253
275
|
)
|
|
254
276
|
}
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
4
5
|
import { extractPhoneDigits, validatePhoneNumber } from '@open-mercato/shared/lib/phone'
|
|
5
|
-
import {
|
|
6
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
7
|
+
import {
|
|
8
|
+
Select,
|
|
9
|
+
SelectContent,
|
|
10
|
+
SelectItem,
|
|
11
|
+
SelectItemLeading,
|
|
12
|
+
SelectTrigger,
|
|
13
|
+
} from '../../primitives/select'
|
|
6
14
|
|
|
7
15
|
export type PhoneDuplicateMatch = {
|
|
8
16
|
id: string
|
|
@@ -10,6 +18,76 @@ export type PhoneDuplicateMatch = {
|
|
|
10
18
|
href: string
|
|
11
19
|
}
|
|
12
20
|
|
|
21
|
+
export type PhoneCountry = {
|
|
22
|
+
/** ISO 3166-1 alpha-2 code. */
|
|
23
|
+
iso2: string
|
|
24
|
+
/** International dial code (with `+`). */
|
|
25
|
+
dialCode: string
|
|
26
|
+
/** Human-readable country name (English). Override per surface for i18n. */
|
|
27
|
+
label: string
|
|
28
|
+
/** Emoji flag (no asset dependency). */
|
|
29
|
+
flag: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Static country list — focused on EU + Americas markets where Open Mercato
|
|
34
|
+
* currently operates. Extend as needed; longest dial codes MUST appear before
|
|
35
|
+
* their shorter ancestors (e.g. `+380` before `+38`) to keep prefix matching
|
|
36
|
+
* deterministic. The default fallback is the first entry.
|
|
37
|
+
*/
|
|
38
|
+
export const PHONE_COUNTRIES: PhoneCountry[] = [
|
|
39
|
+
{ iso2: 'US', dialCode: '+1', label: 'United States', flag: '🇺🇸' },
|
|
40
|
+
{ iso2: 'CA', dialCode: '+1', label: 'Canada', flag: '🇨🇦' },
|
|
41
|
+
{ iso2: 'GB', dialCode: '+44', label: 'United Kingdom', flag: '🇬🇧' },
|
|
42
|
+
{ iso2: 'PL', dialCode: '+48', label: 'Poland', flag: '🇵🇱' },
|
|
43
|
+
{ iso2: 'DE', dialCode: '+49', label: 'Germany', flag: '🇩🇪' },
|
|
44
|
+
{ iso2: 'FR', dialCode: '+33', label: 'France', flag: '🇫🇷' },
|
|
45
|
+
{ iso2: 'ES', dialCode: '+34', label: 'Spain', flag: '🇪🇸' },
|
|
46
|
+
{ iso2: 'IT', dialCode: '+39', label: 'Italy', flag: '🇮🇹' },
|
|
47
|
+
{ iso2: 'NL', dialCode: '+31', label: 'Netherlands', flag: '🇳🇱' },
|
|
48
|
+
{ iso2: 'SE', dialCode: '+46', label: 'Sweden', flag: '🇸🇪' },
|
|
49
|
+
{ iso2: 'AT', dialCode: '+43', label: 'Austria', flag: '🇦🇹' },
|
|
50
|
+
{ iso2: 'CH', dialCode: '+41', label: 'Switzerland', flag: '🇨🇭' },
|
|
51
|
+
{ iso2: 'PT', dialCode: '+351', label: 'Portugal', flag: '🇵🇹' },
|
|
52
|
+
{ iso2: 'CZ', dialCode: '+420', label: 'Czechia', flag: '🇨🇿' },
|
|
53
|
+
{ iso2: 'RO', dialCode: '+40', label: 'Romania', flag: '🇷🇴' },
|
|
54
|
+
{ iso2: 'UA', dialCode: '+380', label: 'Ukraine', flag: '🇺🇦' },
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
const DEFAULT_COUNTRY = PHONE_COUNTRIES[0]
|
|
58
|
+
// Match longer prefixes first to avoid e.g. `+1` shadowing `+1234` if any.
|
|
59
|
+
const COUNTRIES_BY_DIAL_LENGTH = [...PHONE_COUNTRIES].sort(
|
|
60
|
+
(a, b) => b.dialCode.length - a.dialCode.length,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
function findCountryByIso(iso2: string): PhoneCountry | undefined {
|
|
64
|
+
return PHONE_COUNTRIES.find((c) => c.iso2 === iso2)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseCountryFromValue(value: string | null | undefined): PhoneCountry | null {
|
|
68
|
+
if (!value) return null
|
|
69
|
+
const trimmed = value.trim()
|
|
70
|
+
if (!trimmed.startsWith('+')) return null
|
|
71
|
+
for (const country of COUNTRIES_BY_DIAL_LENGTH) {
|
|
72
|
+
if (trimmed.startsWith(country.dialCode)) return country
|
|
73
|
+
}
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function extractLocalNumber(value: string | null | undefined, dialCode: string): string {
|
|
78
|
+
if (!value) return ''
|
|
79
|
+
const trimmed = value.trim()
|
|
80
|
+
if (!trimmed.startsWith(dialCode)) return trimmed
|
|
81
|
+
return trimmed.slice(dialCode.length).trim()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function composeValue(country: PhoneCountry, localNumber: string): string {
|
|
85
|
+
const trimmed = localNumber.trim()
|
|
86
|
+
if (!trimmed) return ''
|
|
87
|
+
if (trimmed.startsWith('+')) return trimmed
|
|
88
|
+
return `${country.dialCode} ${trimmed}`
|
|
89
|
+
}
|
|
90
|
+
|
|
13
91
|
export type PhoneNumberFieldProps = {
|
|
14
92
|
id?: string
|
|
15
93
|
value?: string | null
|
|
@@ -27,10 +105,14 @@ export type PhoneNumberFieldProps = {
|
|
|
27
105
|
duplicateLinkLabel?: string
|
|
28
106
|
invalidLabel?: string
|
|
29
107
|
onDuplicateLookup?: (normalizedValue: string) => Promise<PhoneDuplicateMatch | null>
|
|
108
|
+
/** Override the static country list (e.g. limit to specific markets). */
|
|
109
|
+
countries?: PhoneCountry[]
|
|
110
|
+
/** Initial country shown when `value` is empty / unparseable. Defaults to US. */
|
|
111
|
+
defaultCountryIso2?: string
|
|
30
112
|
}
|
|
31
113
|
|
|
32
114
|
const DEFAULT_MIN_DIGITS = 6
|
|
33
|
-
const
|
|
115
|
+
const DEFAULT_PLACEHOLDER = '(555) 000-0000'
|
|
34
116
|
|
|
35
117
|
export function PhoneNumberField({
|
|
36
118
|
id,
|
|
@@ -49,39 +131,74 @@ export function PhoneNumberField({
|
|
|
49
131
|
duplicateLinkLabel,
|
|
50
132
|
invalidLabel,
|
|
51
133
|
onDuplicateLookup,
|
|
134
|
+
countries: countriesProp,
|
|
135
|
+
defaultCountryIso2,
|
|
52
136
|
}: PhoneNumberFieldProps) {
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
137
|
+
const t = useT()
|
|
138
|
+
const resolvedInvalidLabel = invalidLabel ?? t(
|
|
139
|
+
'ui.inputs.phoneNumberField.invalid',
|
|
140
|
+
'Enter a valid phone number with country code (e.g. +1 212 555 1234)'
|
|
141
|
+
)
|
|
142
|
+
const resolvedCheckingLabel = checkingLabel ?? t(
|
|
143
|
+
'ui.inputs.phoneNumberField.checking',
|
|
144
|
+
'Checking for duplicates…'
|
|
145
|
+
)
|
|
146
|
+
const resolvedDuplicateLinkLabel = duplicateLinkLabel ?? t(
|
|
147
|
+
'ui.inputs.phoneNumberField.duplicateLink',
|
|
148
|
+
'Open record'
|
|
149
|
+
)
|
|
150
|
+
const resolvedPlaceholder = placeholder ?? DEFAULT_PLACEHOLDER
|
|
151
|
+
const countries = countriesProp ?? PHONE_COUNTRIES
|
|
152
|
+
const fallbackCountry = React.useMemo(
|
|
153
|
+
() => (defaultCountryIso2 && findCountryByIso(defaultCountryIso2)) || DEFAULT_COUNTRY,
|
|
154
|
+
[defaultCountryIso2],
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
const initialCountry = React.useMemo(
|
|
158
|
+
() => parseCountryFromValue(value) ?? fallbackCountry,
|
|
159
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
160
|
+
[],
|
|
161
|
+
)
|
|
162
|
+
const [country, setCountry] = React.useState<PhoneCountry>(initialCountry)
|
|
163
|
+
const [localNumber, setLocalNumber] = React.useState<string>(() =>
|
|
164
|
+
extractLocalNumber(value, initialCountry.dialCode),
|
|
165
|
+
)
|
|
57
166
|
const [duplicate, setDuplicate] = React.useState<PhoneDuplicateMatch | null>(null)
|
|
58
167
|
const [checking, setChecking] = React.useState(false)
|
|
59
168
|
const [validationHint, setValidationHint] = React.useState<string | null>(null)
|
|
169
|
+
const [focused, setFocused] = React.useState(false)
|
|
60
170
|
const userEditingRef = React.useRef(false)
|
|
171
|
+
|
|
61
172
|
const externalFieldError = externalError && externalError.trim().length > 0 ? externalError : null
|
|
62
173
|
const errorMessage = externalFieldError ?? validationHint
|
|
63
174
|
const errorId = errorMessage && id ? `${id}-error` : undefined
|
|
64
175
|
const describedBy = [ariaDescribedBy, errorId].filter((part): part is string => Boolean(part)).join(' ') || undefined
|
|
176
|
+
const composedValue = composeValue(country, localNumber)
|
|
65
177
|
|
|
178
|
+
// Sync local state when `value` updates externally and the user is not editing.
|
|
66
179
|
React.useEffect(() => {
|
|
67
180
|
if (userEditingRef.current) return
|
|
68
181
|
if (value == null || value === '') {
|
|
69
|
-
|
|
182
|
+
setLocalNumber('')
|
|
70
183
|
onDigitsChange?.(null)
|
|
71
184
|
return
|
|
72
185
|
}
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
186
|
+
const parsed = parseCountryFromValue(value)
|
|
187
|
+
if (parsed) setCountry(parsed)
|
|
188
|
+
const next = extractLocalNumber(value, parsed?.dialCode ?? country.dialCode)
|
|
189
|
+
setLocalNumber(next)
|
|
190
|
+
onDigitsChange?.(extractPhoneDigits(String(value)) || null)
|
|
191
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
192
|
+
}, [value])
|
|
77
193
|
|
|
194
|
+
// Debounced duplicate lookup.
|
|
78
195
|
React.useEffect(() => {
|
|
79
196
|
if (!onDuplicateLookup || disabled) {
|
|
80
197
|
setDuplicate(null)
|
|
81
198
|
setChecking(false)
|
|
82
199
|
return
|
|
83
200
|
}
|
|
84
|
-
const digits = extractPhoneDigits(
|
|
201
|
+
const digits = extractPhoneDigits(composedValue)
|
|
85
202
|
if (!digits || digits.length < minDigits) {
|
|
86
203
|
setDuplicate(null)
|
|
87
204
|
setChecking(false)
|
|
@@ -105,74 +222,147 @@ export function PhoneNumberField({
|
|
|
105
222
|
cancelled = true
|
|
106
223
|
window.clearTimeout(handle)
|
|
107
224
|
}
|
|
108
|
-
}, [
|
|
225
|
+
}, [composedValue, disabled, minDigits, onDuplicateLookup])
|
|
109
226
|
|
|
110
|
-
const
|
|
227
|
+
const emit = React.useCallback(
|
|
228
|
+
(nextCountry: PhoneCountry, nextLocal: string) => {
|
|
229
|
+
const composed = composeValue(nextCountry, nextLocal)
|
|
230
|
+
onValueChange(composed.length ? composed : undefined)
|
|
231
|
+
onDigitsChange?.(extractPhoneDigits(composed) || null)
|
|
232
|
+
},
|
|
233
|
+
[onDigitsChange, onValueChange],
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
const handleCountryChange = React.useCallback(
|
|
237
|
+
(iso2: string) => {
|
|
238
|
+
const next = findCountryByIso(iso2)
|
|
239
|
+
if (!next) return
|
|
240
|
+
userEditingRef.current = true
|
|
241
|
+
setCountry(next)
|
|
242
|
+
setValidationHint(null)
|
|
243
|
+
emit(next, localNumber)
|
|
244
|
+
},
|
|
245
|
+
[emit, localNumber],
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
const handleLocalChange = React.useCallback(
|
|
111
249
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
|
112
250
|
const next = event.target.value
|
|
113
|
-
const cleanDigits = extractPhoneDigits(next)
|
|
114
251
|
userEditingRef.current = true
|
|
115
|
-
|
|
252
|
+
setLocalNumber(next)
|
|
116
253
|
setValidationHint(null)
|
|
117
|
-
|
|
118
|
-
onDigitsChange?.(cleanDigits.length ? cleanDigits : null)
|
|
254
|
+
emit(country, next)
|
|
119
255
|
},
|
|
120
|
-
[
|
|
256
|
+
[country, emit],
|
|
121
257
|
)
|
|
122
258
|
|
|
123
259
|
const handleBlur = React.useCallback(() => {
|
|
124
260
|
userEditingRef.current = false
|
|
125
|
-
|
|
261
|
+
setFocused(false)
|
|
262
|
+
const trimmed = localNumber.trim()
|
|
126
263
|
if (!trimmed) {
|
|
127
|
-
|
|
264
|
+
setLocalNumber('')
|
|
128
265
|
setValidationHint(null)
|
|
129
266
|
onValueChange(undefined)
|
|
130
267
|
onDigitsChange?.(null)
|
|
131
268
|
return
|
|
132
269
|
}
|
|
133
|
-
const
|
|
270
|
+
const composed = composeValue(country, trimmed)
|
|
271
|
+
const result = validatePhoneNumber(composed)
|
|
134
272
|
if (result.valid) {
|
|
135
|
-
|
|
273
|
+
const normalizedLocal = extractLocalNumber(result.normalized ?? composed, country.dialCode)
|
|
274
|
+
setLocalNumber(normalizedLocal)
|
|
136
275
|
setValidationHint(null)
|
|
137
276
|
onValueChange(result.normalized || undefined)
|
|
138
277
|
onDigitsChange?.(result.digits || null)
|
|
139
278
|
} else {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
onValueChange(trimmed)
|
|
279
|
+
setValidationHint(resolvedInvalidLabel)
|
|
280
|
+
onValueChange(composed)
|
|
143
281
|
onDigitsChange?.(result.digits || null)
|
|
144
282
|
}
|
|
145
|
-
}, [
|
|
283
|
+
}, [country, localNumber, onDigitsChange, onValueChange, resolvedInvalidLabel])
|
|
284
|
+
|
|
285
|
+
const containerErrorBorder = errorMessage ? 'border-status-error-icon' : 'border-input'
|
|
286
|
+
const containerFocusBorder = errorMessage
|
|
287
|
+
? 'border-status-error-icon shadow-focus'
|
|
288
|
+
: 'border-brand-violet shadow-focus'
|
|
146
289
|
|
|
147
290
|
return (
|
|
148
291
|
<div className="space-y-2">
|
|
149
|
-
<
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
aria-label={ariaLabel}
|
|
159
|
-
aria-describedby={describedBy}
|
|
292
|
+
<div
|
|
293
|
+
className={cn(
|
|
294
|
+
'flex items-stretch w-full rounded-md border bg-background shadow-xs transition-colors',
|
|
295
|
+
disabled
|
|
296
|
+
? 'bg-bg-disabled border-border-disabled cursor-not-allowed'
|
|
297
|
+
: focused
|
|
298
|
+
? containerFocusBorder
|
|
299
|
+
: `${containerErrorBorder} hover:border-foreground/30`,
|
|
300
|
+
)}
|
|
160
301
|
aria-invalid={errorMessage ? 'true' : undefined}
|
|
161
|
-
|
|
162
|
-
|
|
302
|
+
>
|
|
303
|
+
<Select value={country.iso2} onValueChange={handleCountryChange} disabled={disabled}>
|
|
304
|
+
<SelectTrigger
|
|
305
|
+
aria-label={ariaLabel ? `${ariaLabel} country` : 'Country code'}
|
|
306
|
+
className={cn(
|
|
307
|
+
'h-auto w-auto shrink-0 gap-1.5 rounded-none rounded-l-md border-0 bg-transparent px-2.5 py-2 shadow-none',
|
|
308
|
+
'hover:bg-muted/40 focus:bg-muted/40 focus-visible:shadow-none focus-visible:border-0',
|
|
309
|
+
'disabled:bg-transparent disabled:hover:bg-transparent',
|
|
310
|
+
)}
|
|
311
|
+
onFocus={() => setFocused(true)}
|
|
312
|
+
onBlur={() => setFocused(false)}
|
|
313
|
+
>
|
|
314
|
+
<span className="text-base leading-none" aria-hidden="true">{country.flag}</span>
|
|
315
|
+
<span className="text-sm text-foreground tabular-nums">{country.dialCode}</span>
|
|
316
|
+
</SelectTrigger>
|
|
317
|
+
<SelectContent align="start">
|
|
318
|
+
{countries.map((c) => (
|
|
319
|
+
<SelectItem key={`${c.iso2}-${c.dialCode}`} value={c.iso2}>
|
|
320
|
+
<SelectItemLeading>
|
|
321
|
+
<span className="text-base leading-none">{c.flag}</span>
|
|
322
|
+
</SelectItemLeading>
|
|
323
|
+
<span className="flex-1 truncate">{c.label}</span>
|
|
324
|
+
<span className="ml-2 text-xs text-muted-foreground tabular-nums">{c.dialCode}</span>
|
|
325
|
+
</SelectItem>
|
|
326
|
+
))}
|
|
327
|
+
</SelectContent>
|
|
328
|
+
</Select>
|
|
329
|
+
<div aria-hidden="true" className="w-px self-stretch bg-input" />
|
|
330
|
+
<input
|
|
331
|
+
type="tel"
|
|
332
|
+
inputMode="tel"
|
|
333
|
+
autoComplete="tel-national"
|
|
334
|
+
value={localNumber}
|
|
335
|
+
onChange={handleLocalChange}
|
|
336
|
+
onBlur={handleBlur}
|
|
337
|
+
onFocus={() => setFocused(true)}
|
|
338
|
+
placeholder={resolvedPlaceholder}
|
|
339
|
+
autoFocus={autoFocus}
|
|
340
|
+
disabled={disabled}
|
|
341
|
+
id={id}
|
|
342
|
+
aria-label={ariaLabel}
|
|
343
|
+
aria-describedby={describedBy}
|
|
344
|
+
aria-invalid={errorMessage ? 'true' : undefined}
|
|
345
|
+
data-crud-focus-target=""
|
|
346
|
+
className={cn(
|
|
347
|
+
'flex-1 min-w-0 bg-transparent px-3 py-2 text-sm leading-5 outline-none',
|
|
348
|
+
'placeholder:text-muted-foreground',
|
|
349
|
+
'disabled:cursor-not-allowed disabled:text-muted-foreground',
|
|
350
|
+
)}
|
|
351
|
+
/>
|
|
352
|
+
</div>
|
|
163
353
|
{errorMessage ? (
|
|
164
|
-
<p id={errorId} className="text-xs text-
|
|
354
|
+
<p id={errorId} role="alert" className="text-xs text-status-error-text">{errorMessage}</p>
|
|
165
355
|
) : null}
|
|
166
|
-
{!disabled && duplicate && duplicateLabel
|
|
167
|
-
<p className="text-xs text-
|
|
356
|
+
{!disabled && duplicate && duplicateLabel ? (
|
|
357
|
+
<p className="text-xs text-status-warning-text">
|
|
168
358
|
{duplicateLabel(duplicate)}{' '}
|
|
169
|
-
<a className="font-medium text-
|
|
170
|
-
{
|
|
359
|
+
<a className="font-medium text-brand-violet underline underline-offset-2" href={duplicate.href}>
|
|
360
|
+
{resolvedDuplicateLinkLabel}
|
|
171
361
|
</a>
|
|
172
362
|
</p>
|
|
173
363
|
) : null}
|
|
174
|
-
{!disabled && !duplicate && checking
|
|
175
|
-
<p className="text-xs text-muted-foreground">{
|
|
364
|
+
{!disabled && !duplicate && checking ? (
|
|
365
|
+
<p className="text-xs text-muted-foreground">{resolvedCheckingLabel}</p>
|
|
176
366
|
) : null}
|
|
177
367
|
</div>
|
|
178
368
|
)
|
|
@@ -1,3 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @deprecated Prefer the DS `RichEditor` primitive from
|
|
3
|
+
* `@open-mercato/ui/primitives/rich-editor` for any new rich-text input.
|
|
4
|
+
*
|
|
5
|
+
* `RichEditor` produces sanitized HTML (with the DS toolbar, color palette,
|
|
6
|
+
* Figma-aligned styling, and auto-overflow toolbar variants). The DS direction
|
|
7
|
+
* is to consolidate on a single rich-text format (HTML) so that user-authored
|
|
8
|
+
* content renders consistently across email, exports, and the customer portal.
|
|
9
|
+
*
|
|
10
|
+
* `SwitchableMarkdownInput` is kept as a backward-compatibility shim for
|
|
11
|
+
* existing Markdown-backed surfaces (notably `customers` Notes). It will be
|
|
12
|
+
* removed once those surfaces migrate their storage format from Markdown to
|
|
13
|
+
* sanitized HTML — track the migration in the spec referenced from
|
|
14
|
+
* `customers/AGENTS.md`.
|
|
15
|
+
*
|
|
16
|
+
* Migration:
|
|
17
|
+
*
|
|
18
|
+
* ```diff
|
|
19
|
+
* - import { SwitchableMarkdownInput } from '@open-mercato/ui/backend/inputs/SwitchableMarkdownInput'
|
|
20
|
+
* - <SwitchableMarkdownInput value={md} onChange={setMd} isMarkdownEnabled={enabled} />
|
|
21
|
+
* + import { RichEditor } from '@open-mercato/ui/primitives/rich-editor'
|
|
22
|
+
* + <RichEditor value={html} onChange={setHtml} variant="basic" />
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* For surfaces that genuinely need plain Markdown storage (e.g. agent prompts,
|
|
26
|
+
* code-adjacent docs), keep using `SwitchableMarkdownInput` until a Markdown
|
|
27
|
+
* mode lands on `RichEditor` — but do NOT introduce new Markdown surfaces.
|
|
28
|
+
*/
|
|
1
29
|
"use client"
|
|
2
30
|
|
|
3
31
|
import * as React from 'react'
|
|
@@ -7,6 +35,7 @@ import { LoadingMessage } from '../detail/LoadingMessage'
|
|
|
7
35
|
import { useMarkdownRemarkPlugins } from '../markdown/useMarkdownRemarkPlugins'
|
|
8
36
|
import { useTheme } from '../../theme'
|
|
9
37
|
|
|
38
|
+
/** @deprecated See module-level JSDoc. */
|
|
10
39
|
export type SwitchableMarkdownInputProps = {
|
|
11
40
|
value: string
|
|
12
41
|
onChange: (value: string) => void
|
|
@@ -55,6 +84,7 @@ const UiMarkdownEditor = isTestEnv
|
|
|
55
84
|
),
|
|
56
85
|
}) as unknown as React.ComponentType<UiMarkdownEditorProps>)
|
|
57
86
|
|
|
87
|
+
/** @deprecated Use `RichEditor` from `@open-mercato/ui/primitives/rich-editor` for new code. See module JSDoc. */
|
|
58
88
|
export function SwitchableMarkdownInput({
|
|
59
89
|
value,
|
|
60
90
|
onChange,
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
|
|
2
|
+
useT: () => (_key: string, fallback: string) => fallback,
|
|
3
|
+
}))
|
|
4
|
+
|
|
1
5
|
import * as React from 'react'
|
|
2
6
|
import { act, render, fireEvent, waitFor } from '@testing-library/react'
|
|
3
7
|
import { ComboboxInput } from '../ComboboxInput'
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/** @jest-environment jsdom */
|
|
2
2
|
|
|
3
|
+
jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
|
|
4
|
+
useT: () => (_key: string, fallback: string) => fallback,
|
|
5
|
+
}))
|
|
6
|
+
|
|
3
7
|
jest.mock('@open-mercato/shared/lib/phone', () => ({
|
|
4
8
|
extractPhoneDigits: (value: string | null | undefined) => {
|
|
5
9
|
if (typeof value !== 'string') return ''
|