@open-mercato/ui 0.6.1-develop.3278.1.a4d26475e4 → 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 (72) 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/FilterBar.js +11 -14
  8. package/dist/backend/FilterBar.js.map +2 -2
  9. package/dist/backend/columns/ColumnChooserPanel.js +4 -6
  10. package/dist/backend/columns/ColumnChooserPanel.js.map +2 -2
  11. package/dist/backend/inputs/ComboboxInput.js +11 -7
  12. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  13. package/dist/backend/inputs/EventPatternInput.js +5 -2
  14. package/dist/backend/inputs/EventPatternInput.js.map +2 -2
  15. package/dist/backend/inputs/EventSelect.js +10 -4
  16. package/dist/backend/inputs/EventSelect.js.map +2 -2
  17. package/dist/backend/inputs/LookupSelect.js +51 -47
  18. package/dist/backend/inputs/LookupSelect.js.map +2 -2
  19. package/dist/backend/inputs/PhoneNumberField.js +196 -45
  20. package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
  21. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
  22. package/dist/backend/messages/message-compose-form-groups.js +2 -2
  23. package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
  24. package/dist/backend/utils/serverErrors.js +6 -2
  25. package/dist/backend/utils/serverErrors.js.map +2 -2
  26. package/dist/portal/utils/nav.js +17 -0
  27. package/dist/portal/utils/nav.js.map +2 -2
  28. package/dist/primitives/amount-input.js +128 -0
  29. package/dist/primitives/amount-input.js.map +7 -0
  30. package/dist/primitives/button-input.js +47 -0
  31. package/dist/primitives/button-input.js.map +7 -0
  32. package/dist/primitives/card-input.js +133 -0
  33. package/dist/primitives/card-input.js.map +7 -0
  34. package/dist/primitives/email-input.js +29 -0
  35. package/dist/primitives/email-input.js.map +7 -0
  36. package/dist/primitives/password-input.js +64 -0
  37. package/dist/primitives/password-input.js.map +7 -0
  38. package/dist/primitives/search-input.js +57 -0
  39. package/dist/primitives/search-input.js.map +7 -0
  40. package/dist/primitives/select.js +43 -5
  41. package/dist/primitives/select.js.map +2 -2
  42. package/dist/primitives/website-input.js +56 -0
  43. package/dist/primitives/website-input.js.map +7 -0
  44. package/package.json +3 -3
  45. package/src/backend/AppShell.tsx +7 -30
  46. package/src/backend/CrudForm.tsx +17 -0
  47. package/src/backend/FilterBar.tsx +5 -6
  48. package/src/backend/__tests__/serverErrors.test.ts +79 -1
  49. package/src/backend/columns/ColumnChooserPanel.tsx +4 -5
  50. package/src/backend/inputs/ComboboxInput.tsx +32 -26
  51. package/src/backend/inputs/EventPatternInput.tsx +11 -2
  52. package/src/backend/inputs/EventSelect.tsx +13 -4
  53. package/src/backend/inputs/LookupSelect.tsx +75 -53
  54. package/src/backend/inputs/PhoneNumberField.tsx +237 -47
  55. package/src/backend/inputs/SwitchableMarkdownInput.tsx +30 -0
  56. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +4 -0
  57. package/src/backend/inputs/__tests__/PhoneNumberField.test.tsx +4 -0
  58. package/src/backend/messages/message-compose-form-groups.tsx +2 -2
  59. package/src/backend/utils/serverErrors.ts +15 -8
  60. package/src/portal/utils/__tests__/nav.test.ts +80 -0
  61. package/src/portal/utils/nav.ts +23 -1
  62. package/src/primitives/__tests__/password-input.test.tsx +93 -0
  63. package/src/primitives/__tests__/search-input.test.tsx +95 -0
  64. package/src/primitives/__tests__/zindex-overlay.test.tsx +4 -0
  65. package/src/primitives/amount-input.tsx +184 -0
  66. package/src/primitives/button-input.tsx +68 -0
  67. package/src/primitives/card-input.tsx +193 -0
  68. package/src/primitives/email-input.tsx +36 -0
  69. package/src/primitives/password-input.tsx +100 -0
  70. package/src/primitives/search-input.tsx +87 -0
  71. package/src/primitives/select.tsx +98 -11
  72. 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 = placeholder ?? 'Search…',
54
- clearLabel = 'Clear selection',
55
- emptyLabel = 'No results',
56
- loadingLabel = 'Searching…',
57
- selectLabel = 'Select',
58
- selectedLabel = 'Selected',
54
+ searchPlaceholder,
55
+ clearLabel,
56
+ emptyLabel,
57
+ loadingLabel,
58
+ selectLabel,
59
+ selectedLabel,
59
60
  minQueryHintLabel,
60
- startTypingLabel = 'Start typing to search.',
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-2 top-2.5 h-4 w-4 text-muted-foreground" />
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-8 pr-2 py-2 text-sm"
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={searchPlaceholder}
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
- {loadingLabel}
175
+ {resolvedLoadingLabel}
162
176
  </div>
163
177
  ) : null}
164
178
  {!loading && !loadingProp && !items.length ? (
165
- <p className="text-xs text-muted-foreground">{emptyLabel}</p>
179
+ <p className="text-xs text-muted-foreground">{resolvedEmptyLabel}</p>
166
180
  ) : null}
167
- <div className="space-y-2 max-h-80 overflow-y-auto">
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-3 rounded border bg-card p-3 transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background',
175
- isSelected ? 'border-primary/70 bg-primary/5' : 'hover:border-primary/50'
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 (item.disabled && !isSelected) return
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 (item.disabled && !isSelected) return
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
- <div className="flex h-10 w-10 sm:h-12 sm:w-12 shrink-0 items-center justify-center overflow-hidden rounded border bg-muted">
193
- {item.icon ?? <span className="text-muted-foreground">•</span>}
194
- </div>
195
- <div className="flex min-w-0 flex-1 flex-col gap-1">
196
- <div className="flex items-start justify-between gap-2">
197
- <div className="min-w-0">
198
- <div className="truncate text-sm font-medium">{item.title}</div>
199
- {item.subtitle ? (
200
- <div className="text-xs text-muted-foreground truncate">{item.subtitle}</div>
201
- ) : null}
202
- {item.description ? (
203
- <div className="text-xs text-muted-foreground truncate">{item.description}</div>
204
- ) : null}
205
- </div>
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-xs font-medium text-muted-foreground">{item.rightLabel}</div>
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
- <div className="flex justify-end">
211
- <Button
212
- type="button"
213
- variant={isSelected ? 'secondary' : 'outline'}
214
- size="sm"
215
- className="shrink-0"
216
- onClick={(event) => {
217
- event.stopPropagation()
218
- if (item.disabled && !isSelected) return
219
- onChange(item.id)
220
- }}
221
- disabled={item.disabled && !isSelected}
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
- {clearLabel}
262
+ {resolvedClearLabel}
241
263
  </Button>
242
264
  ) : null}
243
265
  </div>
244
266
  ) : hasTyped ? (
245
267
  <p className="text-xs text-muted-foreground">
246
- {minQueryHintLabel ?? `Type at least ${minQuery} characters or paste an id to search.`}
268
+ {resolvedMinQueryHintLabel}
247
269
  </p>
248
270
  ) : (
249
- <p className="text-xs text-muted-foreground">{startTypingLabel}</p>
271
+ <p className="text-xs text-muted-foreground">{resolvedStartTypingLabel}</p>
250
272
  )}
251
- {error ? <p className="text-xs text-destructive">{emptyLabel}</p> : null}
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 { Input } from '../../primitives/input'
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 DEFAULT_INVALID_LABEL = 'Enter a valid phone number with country code (e.g. +1 212 555 1234)'
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 [local, setLocal] = React.useState<string>(() => {
54
- if (value == null || value === '') return ''
55
- return String(value)
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
- setLocal('')
182
+ setLocalNumber('')
70
183
  onDigitsChange?.(null)
71
184
  return
72
185
  }
73
- const nextValue = String(value)
74
- setLocal(nextValue)
75
- onDigitsChange?.(extractPhoneDigits(nextValue) || null)
76
- }, [value, onDigitsChange])
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(local)
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
- }, [local, disabled, minDigits, onDuplicateLookup])
225
+ }, [composedValue, disabled, minDigits, onDuplicateLookup])
109
226
 
110
- const handleChange = React.useCallback(
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
- setLocal(next)
252
+ setLocalNumber(next)
116
253
  setValidationHint(null)
117
- onValueChange(next.length ? next : undefined)
118
- onDigitsChange?.(cleanDigits.length ? cleanDigits : null)
254
+ emit(country, next)
119
255
  },
120
- [onValueChange, onDigitsChange]
256
+ [country, emit],
121
257
  )
122
258
 
123
259
  const handleBlur = React.useCallback(() => {
124
260
  userEditingRef.current = false
125
- const trimmed = local.trim()
261
+ setFocused(false)
262
+ const trimmed = localNumber.trim()
126
263
  if (!trimmed) {
127
- setLocal('')
264
+ setLocalNumber('')
128
265
  setValidationHint(null)
129
266
  onValueChange(undefined)
130
267
  onDigitsChange?.(null)
131
268
  return
132
269
  }
133
- const result = validatePhoneNumber(trimmed)
270
+ const composed = composeValue(country, trimmed)
271
+ const result = validatePhoneNumber(composed)
134
272
  if (result.valid) {
135
- setLocal(result.normalized ?? '')
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
- setLocal(trimmed)
141
- setValidationHint(invalidLabel ?? DEFAULT_INVALID_LABEL)
142
- onValueChange(trimmed)
279
+ setValidationHint(resolvedInvalidLabel)
280
+ onValueChange(composed)
143
281
  onDigitsChange?.(result.digits || null)
144
282
  }
145
- }, [invalidLabel, local, onDigitsChange, onValueChange])
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
- <Input
150
- type="tel"
151
- value={local}
152
- onChange={handleChange}
153
- onBlur={handleBlur}
154
- placeholder={placeholder}
155
- autoFocus={autoFocus}
156
- disabled={disabled}
157
- id={id}
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
- data-crud-focus-target=""
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-destructive">{errorMessage}</p>
354
+ <p id={errorId} role="alert" className="text-xs text-status-error-text">{errorMessage}</p>
165
355
  ) : null}
166
- {!disabled && duplicate && duplicateLabel && duplicateLinkLabel ? (
167
- <p className="text-xs text-amber-600">
356
+ {!disabled && duplicate && duplicateLabel ? (
357
+ <p className="text-xs text-status-warning-text">
168
358
  {duplicateLabel(duplicate)}{' '}
169
- <a className="font-medium text-primary underline underline-offset-2" href={duplicate.href}>
170
- {duplicateLinkLabel}
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 && checkingLabel ? (
175
- <p className="text-xs text-muted-foreground">{checkingLabel}</p>
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 ''