@traky-app/ui 1.6.1 → 1.6.4
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/package.json +15 -1
- package/registry/traky/json/city-select.json +19 -0
- package/registry/traky/json/country-select.json +24 -0
- package/registry/traky/json/geo-search-select.json +24 -0
- package/registry/traky/json/geo-selectors.json +21 -0
- package/registry/traky/json/google-maps-address-picker.json +22 -0
- package/registry/traky/json/internal-chat-mention.json +1 -1
- package/registry/traky/json/internal-chat-panel.json +1 -1
- package/registry/traky/json/lib-geo-city-codes.json +14 -0
- package/registry/traky/json/lib-geo-countries.json +14 -0
- package/registry/traky/json/lib-geo-csc.json +17 -0
- package/registry/traky/json/lib-geo-locations.json +14 -0
- package/registry/traky/json/lib-geo-overrides.json +14 -0
- package/registry/traky/json/lib-geo-types.json +14 -0
- package/registry/traky/json/lib-google-maps-address.json +14 -0
- package/registry/traky/json/lib-phone-countries.json +14 -0
- package/registry/traky/json/lib-phone-e164.json +14 -0
- package/registry/traky/json/lib-utils.json +1 -1
- package/registry/traky/json/office-address-form-dialog.json +17 -0
- package/registry/traky/json/office-address-form.json +31 -0
- package/registry/traky/json/phone-number-input.json +25 -0
- package/registry/traky/json/state-select.json +19 -0
- package/registry/traky/lib/geo-city-codes.ts +234 -0
- package/registry/traky/lib/geo-countries.ts +75 -0
- package/registry/traky/lib/geo-csc.ts +82 -0
- package/registry/traky/lib/geo-locations.ts +171 -0
- package/registry/traky/lib/geo-overrides.ts +375 -0
- package/registry/traky/lib/geo-types.ts +34 -0
- package/registry/traky/lib/google-maps-address.ts +100 -0
- package/registry/traky/lib/phone-countries.ts +380 -0
- package/registry/traky/lib/phone-e164.ts +66 -0
- package/registry/traky/lib/utils.ts +24 -0
- package/registry/traky/registry.json +180 -0
- package/registry/traky/ui/city-select.tsx +113 -0
- package/registry/traky/ui/country-select.tsx +233 -0
- package/registry/traky/ui/geo-search-select.tsx +136 -0
- package/registry/traky/ui/geo-selectors.tsx +162 -0
- package/registry/traky/ui/google-maps-address-picker.tsx +422 -0
- package/registry/traky/ui/internal-chat-mention.ts +34 -3
- package/registry/traky/ui/internal-chat-panel.tsx +147 -12
- package/registry/traky/ui/office-address-form-dialog.tsx +16 -0
- package/registry/traky/ui/office-address-form.tsx +868 -0
- package/registry/traky/ui/phone-number-input.tsx +253 -0
- package/registry/traky/ui/state-select.tsx +107 -0
- package/src/lib/geo-city-codes.ts +234 -0
- package/src/lib/geo-countries.ts +75 -0
- package/src/lib/geo-csc.ts +82 -0
- package/src/lib/geo-locations.ts +171 -0
- package/src/lib/geo-overrides.ts +375 -0
- package/src/lib/geo-types.ts +34 -0
- package/src/lib/google-maps-address.ts +100 -0
- package/src/lib/phone-countries.ts +380 -0
- package/src/lib/phone-e164.ts +66 -0
- package/src/test/setup.ts +31 -0
- package/src/ui/__tests__/geo-selects.test.tsx +504 -0
- package/src/ui/city-select.tsx +113 -0
- package/src/ui/country-select.tsx +233 -0
- package/src/ui/geo-search-select.tsx +136 -0
- package/src/ui/geo-selectors.tsx +162 -0
- package/src/ui/google-maps-address-picker.tsx +422 -0
- package/src/ui/internal-chat-mention.ts +34 -3
- package/src/ui/internal-chat-panel.tsx +147 -12
- package/src/ui/office-address-form-dialog.tsx +16 -0
- package/src/ui/office-address-form.tsx +877 -0
- package/src/ui/phone-number-input.tsx +253 -0
- package/src/ui/state-select.tsx +107 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { IconCheck, IconSelector } from "@tabler/icons-react"
|
|
4
|
+
import { useEffect, useMemo, useState } from "react"
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_PHONE_COUNTRY_ISO,
|
|
8
|
+
getPhoneCountryByIso,
|
|
9
|
+
PHONE_COUNTRIES,
|
|
10
|
+
type PhoneCountry,
|
|
11
|
+
} from "../lib/phone-countries"
|
|
12
|
+
import {
|
|
13
|
+
buildPhoneE164,
|
|
14
|
+
digitsOnly,
|
|
15
|
+
isNationalNumberValidForCountry,
|
|
16
|
+
parsePhoneE164,
|
|
17
|
+
type ParsedPhone,
|
|
18
|
+
} from "../lib/phone-e164"
|
|
19
|
+
import { cn, commandFilterAccentInsensitive } from "../lib/utils"
|
|
20
|
+
import { Button } from "./button"
|
|
21
|
+
import {
|
|
22
|
+
Command,
|
|
23
|
+
CommandEmpty,
|
|
24
|
+
CommandGroup,
|
|
25
|
+
CommandInput,
|
|
26
|
+
CommandItem,
|
|
27
|
+
CommandList,
|
|
28
|
+
} from "./command"
|
|
29
|
+
import { Input } from "./input"
|
|
30
|
+
import {
|
|
31
|
+
Popover,
|
|
32
|
+
PopoverContent,
|
|
33
|
+
PopoverTrigger,
|
|
34
|
+
} from "./popover"
|
|
35
|
+
|
|
36
|
+
export type PhoneNumberValue = {
|
|
37
|
+
countryCode: string
|
|
38
|
+
countryName: string
|
|
39
|
+
dialCode: string
|
|
40
|
+
nationalNumber: string
|
|
41
|
+
e164: string
|
|
42
|
+
isValid: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sanitizeNational(country: PhoneCountry, raw: string): string {
|
|
46
|
+
let digits = digitsOnly(raw)
|
|
47
|
+
if (country.iso === "EC" && digits.startsWith("0")) {
|
|
48
|
+
digits = digits.slice(1)
|
|
49
|
+
}
|
|
50
|
+
return digits.slice(0, country.nationalMaxLength)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Acepta E.164 o dígitos nacionales (usa `defaultCountryIso` si no hay `+`). */
|
|
54
|
+
function parseIncomingPhone(
|
|
55
|
+
raw: string,
|
|
56
|
+
defaultCountryIso: string,
|
|
57
|
+
): ParsedPhone | null {
|
|
58
|
+
const trimmed = raw.trim()
|
|
59
|
+
if (!trimmed) {
|
|
60
|
+
return parsePhoneE164("", defaultCountryIso)
|
|
61
|
+
}
|
|
62
|
+
if (trimmed.startsWith("+") || trimmed.startsWith("00")) {
|
|
63
|
+
return parsePhoneE164(trimmed, defaultCountryIso)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const country =
|
|
67
|
+
getPhoneCountryByIso(defaultCountryIso) ??
|
|
68
|
+
getPhoneCountryByIso(DEFAULT_PHONE_COUNTRY_ISO)
|
|
69
|
+
if (!country) return null
|
|
70
|
+
|
|
71
|
+
const nationalNumber = sanitizeNational(country, trimmed)
|
|
72
|
+
return {
|
|
73
|
+
country,
|
|
74
|
+
nationalNumber,
|
|
75
|
+
e164: buildPhoneE164(country, nationalNumber),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function CountryFlag({ iso, name }: { iso: string; name: string }) {
|
|
80
|
+
const [visible, setVisible] = useState(true)
|
|
81
|
+
if (!visible || !iso) return null
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<img
|
|
85
|
+
src={`/assets/flags/${iso.toUpperCase()}.svg`}
|
|
86
|
+
alt=""
|
|
87
|
+
title={name}
|
|
88
|
+
width={22}
|
|
89
|
+
height={14}
|
|
90
|
+
className="h-[14px] w-[22px] shrink-0 rounded object-cover"
|
|
91
|
+
onError={() => setVisible(false)}
|
|
92
|
+
/>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function toPhoneValue(
|
|
97
|
+
country: PhoneCountry,
|
|
98
|
+
nationalNumber: string,
|
|
99
|
+
): PhoneNumberValue {
|
|
100
|
+
const digits = digitsOnly(nationalNumber)
|
|
101
|
+
return {
|
|
102
|
+
countryCode: country.iso,
|
|
103
|
+
countryName: country.name,
|
|
104
|
+
dialCode: country.dialCode,
|
|
105
|
+
nationalNumber: digits,
|
|
106
|
+
e164: buildPhoneE164(country, digits),
|
|
107
|
+
isValid: isNationalNumberValidForCountry(country, digits),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type PhoneNumberInputProps = {
|
|
112
|
+
id?: string
|
|
113
|
+
/** Valor controlado en E.164 (`+593980061377`). */
|
|
114
|
+
value?: string
|
|
115
|
+
defaultCountryIso?: string
|
|
116
|
+
onChange?: (value: PhoneNumberValue) => void
|
|
117
|
+
disabled?: boolean
|
|
118
|
+
className?: string
|
|
119
|
+
placeholder?: string
|
|
120
|
+
searchPlaceholder?: string
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function PhoneNumberInput({
|
|
124
|
+
id,
|
|
125
|
+
value,
|
|
126
|
+
defaultCountryIso = DEFAULT_PHONE_COUNTRY_ISO,
|
|
127
|
+
onChange,
|
|
128
|
+
disabled = false,
|
|
129
|
+
className,
|
|
130
|
+
placeholder,
|
|
131
|
+
searchPlaceholder = "Buscar país…",
|
|
132
|
+
}: PhoneNumberInputProps) {
|
|
133
|
+
const [open, setOpen] = useState(false)
|
|
134
|
+
const [countryIso, setCountryIso] = useState(() => {
|
|
135
|
+
const parsed = parseIncomingPhone(value ?? "", defaultCountryIso)
|
|
136
|
+
return parsed?.country.iso ?? defaultCountryIso
|
|
137
|
+
})
|
|
138
|
+
const [national, setNational] = useState(() => {
|
|
139
|
+
const parsed = parseIncomingPhone(value ?? "", defaultCountryIso)
|
|
140
|
+
return parsed?.nationalNumber ?? ""
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const country = useMemo(
|
|
144
|
+
() =>
|
|
145
|
+
PHONE_COUNTRIES.find((c) => c.iso === countryIso) ??
|
|
146
|
+
PHONE_COUNTRIES.find((c) => c.iso === DEFAULT_PHONE_COUNTRY_ISO)!,
|
|
147
|
+
[countryIso],
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (value == null) return
|
|
152
|
+
if (value === "") {
|
|
153
|
+
setNational("")
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
const parsed = parseIncomingPhone(value, defaultCountryIso)
|
|
157
|
+
if (!parsed) return
|
|
158
|
+
setCountryIso(parsed.country.iso)
|
|
159
|
+
setNational(parsed.nationalNumber)
|
|
160
|
+
}, [value, defaultCountryIso])
|
|
161
|
+
|
|
162
|
+
const emit = (nextCountry: PhoneCountry, nextNational: string) => {
|
|
163
|
+
const digits = sanitizeNational(nextCountry, nextNational)
|
|
164
|
+
setCountryIso(nextCountry.iso)
|
|
165
|
+
setNational(digits)
|
|
166
|
+
onChange?.(toPhoneValue(nextCountry, digits))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const resolvedPlaceholder =
|
|
170
|
+
placeholder ??
|
|
171
|
+
(country.nationalMinLength === country.nationalMaxLength
|
|
172
|
+
? "9".repeat(country.nationalMaxLength)
|
|
173
|
+
: `${country.nationalMinLength}-${country.nationalMaxLength} dígitos`)
|
|
174
|
+
|
|
175
|
+
const isValid = isNationalNumberValidForCountry(country, national)
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<div className={cn("flex w-full items-stretch gap-2", className)}>
|
|
179
|
+
<Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
|
|
180
|
+
<PopoverTrigger asChild>
|
|
181
|
+
<Button
|
|
182
|
+
type="button"
|
|
183
|
+
variant="outline"
|
|
184
|
+
disabled={disabled}
|
|
185
|
+
role="combobox"
|
|
186
|
+
aria-expanded={open}
|
|
187
|
+
aria-label={`País: ${country.name} ${country.dialCode}`}
|
|
188
|
+
className="h-10 w-[7.75rem] shrink-0 justify-between gap-1.5 px-2.5 font-normal"
|
|
189
|
+
>
|
|
190
|
+
<span className="flex min-w-0 items-center gap-1.5 overflow-hidden">
|
|
191
|
+
<CountryFlag iso={country.iso} name={country.name} />
|
|
192
|
+
<span className="truncate text-sm tabular-nums">
|
|
193
|
+
{country.dialCode}
|
|
194
|
+
</span>
|
|
195
|
+
</span>
|
|
196
|
+
<IconSelector className="size-3.5 shrink-0 opacity-50" />
|
|
197
|
+
</Button>
|
|
198
|
+
</PopoverTrigger>
|
|
199
|
+
<PopoverContent className="w-[min(100vw-2rem,18rem)] p-0" align="start">
|
|
200
|
+
<Command filter={commandFilterAccentInsensitive}>
|
|
201
|
+
<CommandInput placeholder={searchPlaceholder} />
|
|
202
|
+
<CommandList className="max-h-72">
|
|
203
|
+
<CommandEmpty>No se encontró el país.</CommandEmpty>
|
|
204
|
+
<CommandGroup heading={`${PHONE_COUNTRIES.length} países`}>
|
|
205
|
+
{PHONE_COUNTRIES.map((item) => (
|
|
206
|
+
<CommandItem
|
|
207
|
+
key={item.iso}
|
|
208
|
+
value={`${item.name} ${item.iso} ${item.dialCode}`}
|
|
209
|
+
onSelect={() => {
|
|
210
|
+
setOpen(false)
|
|
211
|
+
emit(item, national)
|
|
212
|
+
}}
|
|
213
|
+
className="gap-2"
|
|
214
|
+
>
|
|
215
|
+
<CountryFlag iso={item.iso} name={item.name} />
|
|
216
|
+
<span className="min-w-0 flex-1 truncate">{item.name}</span>
|
|
217
|
+
<span className="text-xs tabular-nums text-muted-foreground">
|
|
218
|
+
{item.dialCode}
|
|
219
|
+
</span>
|
|
220
|
+
{country.iso === item.iso ? (
|
|
221
|
+
<IconCheck className="size-4 shrink-0 text-primary" />
|
|
222
|
+
) : null}
|
|
223
|
+
</CommandItem>
|
|
224
|
+
))}
|
|
225
|
+
</CommandGroup>
|
|
226
|
+
</CommandList>
|
|
227
|
+
</Command>
|
|
228
|
+
</PopoverContent>
|
|
229
|
+
</Popover>
|
|
230
|
+
|
|
231
|
+
<Input
|
|
232
|
+
id={id}
|
|
233
|
+
type="tel"
|
|
234
|
+
inputMode="numeric"
|
|
235
|
+
autoComplete="tel-national"
|
|
236
|
+
disabled={disabled}
|
|
237
|
+
value={national}
|
|
238
|
+
maxLength={country.nationalMaxLength}
|
|
239
|
+
placeholder={resolvedPlaceholder}
|
|
240
|
+
aria-invalid={national.length > 0 && !isValid}
|
|
241
|
+
className="h-10 flex-1"
|
|
242
|
+
onChange={(e) => emit(country, e.target.value)}
|
|
243
|
+
/>
|
|
244
|
+
</div>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function formatPhoneHint(country: PhoneCountry): string {
|
|
249
|
+
if (country.nationalMinLength === country.nationalMaxLength) {
|
|
250
|
+
return `Ingresa ${country.nationalMaxLength} dígitos después de ${country.dialCode}`
|
|
251
|
+
}
|
|
252
|
+
return `Ingresa entre ${country.nationalMinLength} y ${country.nationalMaxLength} dígitos después de ${country.dialCode}`
|
|
253
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { fetchStates } from "../lib/geo-locations";
|
|
5
|
+
import type { GeoOption } from "../lib/geo-types";
|
|
6
|
+
import { GeoSearchSelect } from "./geo-search-select";
|
|
7
|
+
|
|
8
|
+
export type StateSelectProps = {
|
|
9
|
+
id?: string;
|
|
10
|
+
/** ISO-2 del país. Sin este código el selector queda inactivo. */
|
|
11
|
+
countryCode?: string | null;
|
|
12
|
+
/** Código o nombre controlado. `undefined` deja el selector sin controlar; `""` lo deja vacío. */
|
|
13
|
+
value?: string;
|
|
14
|
+
/** Código o nombre inicial cuando `value` no se pasa. */
|
|
15
|
+
defaultValue?: string;
|
|
16
|
+
onChange?: (option: GeoOption | null) => void;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
className?: string;
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
showCode?: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function StateSelect({
|
|
24
|
+
id,
|
|
25
|
+
countryCode,
|
|
26
|
+
value,
|
|
27
|
+
defaultValue,
|
|
28
|
+
onChange,
|
|
29
|
+
disabled = false,
|
|
30
|
+
className,
|
|
31
|
+
placeholder = "Buscar estado, región o municipio…",
|
|
32
|
+
showCode = true,
|
|
33
|
+
}: StateSelectProps) {
|
|
34
|
+
const cc = countryCode?.trim().toUpperCase() ?? "";
|
|
35
|
+
const isControlled = value !== undefined;
|
|
36
|
+
const [options, setOptions] = useState<GeoOption[]>([]);
|
|
37
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
38
|
+
const [internal, setInternal] = useState(defaultValue ?? "");
|
|
39
|
+
const onChangeRef = useRef(onChange);
|
|
40
|
+
const controlledRef = useRef(isControlled);
|
|
41
|
+
const selectionRef = useRef("");
|
|
42
|
+
const prevCc = useRef(cc);
|
|
43
|
+
|
|
44
|
+
onChangeRef.current = onChange;
|
|
45
|
+
controlledRef.current = isControlled;
|
|
46
|
+
selectionRef.current = (isControlled ? value : internal) ?? "";
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const previous = prevCc.current;
|
|
50
|
+
if (previous === cc) return;
|
|
51
|
+
prevCc.current = cc;
|
|
52
|
+
if (!previous) return;
|
|
53
|
+
if (!controlledRef.current) setInternal("");
|
|
54
|
+
if (selectionRef.current) onChangeRef.current?.(null);
|
|
55
|
+
}, [cc]);
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!cc) {
|
|
59
|
+
setOptions([]);
|
|
60
|
+
setIsLoading(false);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let cancelled = false;
|
|
65
|
+
setOptions([]);
|
|
66
|
+
setIsLoading(true);
|
|
67
|
+
void fetchStates(cc)
|
|
68
|
+
.then((list) => {
|
|
69
|
+
if (!cancelled) setOptions(list);
|
|
70
|
+
})
|
|
71
|
+
.finally(() => {
|
|
72
|
+
if (!cancelled) setIsLoading(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return () => {
|
|
76
|
+
cancelled = true;
|
|
77
|
+
};
|
|
78
|
+
}, [cc]);
|
|
79
|
+
|
|
80
|
+
const current = isControlled ? (value ?? "") : internal;
|
|
81
|
+
const selected = useMemo(() => {
|
|
82
|
+
if (!current) return undefined;
|
|
83
|
+
return options.find(
|
|
84
|
+
(option) => option.code === current || option.name === current,
|
|
85
|
+
);
|
|
86
|
+
}, [current, options]);
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<GeoSearchSelect
|
|
90
|
+
id={id}
|
|
91
|
+
className={className}
|
|
92
|
+
placeholder={cc ? placeholder : "Indica un país"}
|
|
93
|
+
searchPlaceholder="Buscar estado, región o municipio…"
|
|
94
|
+
emptyMessage="Sin estados, regiones ni municipios."
|
|
95
|
+
options={options}
|
|
96
|
+
value={selected?.code}
|
|
97
|
+
displayValue={selected?.name}
|
|
98
|
+
onChange={(option) => {
|
|
99
|
+
if (!isControlled) setInternal(option.code);
|
|
100
|
+
onChange?.(option);
|
|
101
|
+
}}
|
|
102
|
+
disabled={disabled || !cc}
|
|
103
|
+
isLoading={Boolean(cc) && isLoading}
|
|
104
|
+
showCode={showCode}
|
|
105
|
+
/>
|
|
106
|
+
);
|
|
107
|
+
}
|