@traky-app/ui 1.6.3 → 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/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/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/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 "@/components/ui/button"
|
|
21
|
+
import {
|
|
22
|
+
Command,
|
|
23
|
+
CommandEmpty,
|
|
24
|
+
CommandGroup,
|
|
25
|
+
CommandInput,
|
|
26
|
+
CommandItem,
|
|
27
|
+
CommandList,
|
|
28
|
+
} from "@/components/ui/command"
|
|
29
|
+
import { Input } from "@/components/ui/input"
|
|
30
|
+
import {
|
|
31
|
+
Popover,
|
|
32
|
+
PopoverContent,
|
|
33
|
+
PopoverTrigger,
|
|
34
|
+
} from "@/components/ui/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 "@/components/ui/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
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Códigos cortos de ciudad (logística / etiquetas).
|
|
3
|
+
* Clave: `{ISO2}:{NombreCiudad}` → código de 3 letras.
|
|
4
|
+
*/
|
|
5
|
+
const CITY_CODE_OVERRIDES: Record<string, string> = {
|
|
6
|
+
// Ecuador
|
|
7
|
+
"EC:Guayaquil": "GYE",
|
|
8
|
+
"EC:Quito": "UIO",
|
|
9
|
+
"EC:Cuenca": "CUE",
|
|
10
|
+
"EC:Machala": "MCH",
|
|
11
|
+
"EC:Manta": "MEC",
|
|
12
|
+
"EC:Portoviejo": "PVO",
|
|
13
|
+
"EC:Ambato": "ATF",
|
|
14
|
+
"EC:Loja": "LOH",
|
|
15
|
+
"EC:Esmeraldas": "ESM",
|
|
16
|
+
"EC:Ibarra": "IBB",
|
|
17
|
+
"EC:Riobamba": "RIB",
|
|
18
|
+
"EC:Santo Domingo": "STD",
|
|
19
|
+
"EC:Milagro": "MLG",
|
|
20
|
+
"EC:Durán": "DUR",
|
|
21
|
+
"EC:Samborondón": "SBO",
|
|
22
|
+
// Colombia
|
|
23
|
+
"CO:Bogotá": "BOG",
|
|
24
|
+
"CO:Bogota": "BOG",
|
|
25
|
+
"CO:Medellín": "MDE",
|
|
26
|
+
"CO:Medellin": "MDE",
|
|
27
|
+
"CO:Cali": "CAL",
|
|
28
|
+
"CO:Barranquilla": "BAQ",
|
|
29
|
+
"CO:Cartagena": "CTG",
|
|
30
|
+
"CO:Bucaramanga": "BGA",
|
|
31
|
+
"CO:Pereira": "PEI",
|
|
32
|
+
"CO:Manizales": "MZL",
|
|
33
|
+
"CO:Cúcuta": "CUC",
|
|
34
|
+
"CO:Cucuta": "CUC",
|
|
35
|
+
"CO:Ibagué": "IBE",
|
|
36
|
+
"CO:Ibague": "IBE",
|
|
37
|
+
"CO:Santa Marta": "SMR",
|
|
38
|
+
// México
|
|
39
|
+
"MX:Ciudad de México": "CDM",
|
|
40
|
+
"MX:Guadalajara": "GDL",
|
|
41
|
+
"MX:Monterrey": "MTY",
|
|
42
|
+
"MX:Puebla": "PBC",
|
|
43
|
+
"MX:Tijuana": "TIJ",
|
|
44
|
+
"MX:León": "BJX",
|
|
45
|
+
"MX:Leon": "BJX",
|
|
46
|
+
// Perú
|
|
47
|
+
"PE:Lima": "LIM",
|
|
48
|
+
"PE:Arequipa": "AQP",
|
|
49
|
+
"PE:Trujillo": "TRU",
|
|
50
|
+
"PE:Cusco": "CUZ",
|
|
51
|
+
// Chile
|
|
52
|
+
"CL:Santiago": "SCL",
|
|
53
|
+
"CL:Valparaíso": "VAP",
|
|
54
|
+
"CL:Valparaiso": "VAP",
|
|
55
|
+
"CL:Concepción": "CCP",
|
|
56
|
+
"CL:Concepcion": "CCP",
|
|
57
|
+
// Argentina
|
|
58
|
+
"AR:Buenos Aires": "BUE",
|
|
59
|
+
"AR:Córdoba": "COR",
|
|
60
|
+
"AR:Cordoba": "COR",
|
|
61
|
+
"AR:Rosario": "ROS",
|
|
62
|
+
"AR:Mendoza": "MDZ",
|
|
63
|
+
// USA (casilleros)
|
|
64
|
+
"US:Miami": "MIA",
|
|
65
|
+
"US:New York": "NYC",
|
|
66
|
+
"US:Los Angeles": "LAX",
|
|
67
|
+
"US:Houston": "HOU",
|
|
68
|
+
"US:Chicago": "ORD",
|
|
69
|
+
// Nicaragua
|
|
70
|
+
"NI:Chontales": "CHO",
|
|
71
|
+
"NI:Juigalpa": "JUI",
|
|
72
|
+
"NI:Managua": "MGA",
|
|
73
|
+
"NI:León": "LEN",
|
|
74
|
+
"NI:Leon": "LEN",
|
|
75
|
+
"NI:Granada": "GRA",
|
|
76
|
+
"NI:Masaya": "MSY",
|
|
77
|
+
"NI:Matagalpa": "MTG",
|
|
78
|
+
// Reino Unido
|
|
79
|
+
"GB:London": "LON",
|
|
80
|
+
"GB:Greater London": "LON",
|
|
81
|
+
"GB:Manchester": "MAN",
|
|
82
|
+
"GB:Birmingham": "BHX",
|
|
83
|
+
"GB:Liverpool": "LIV",
|
|
84
|
+
"GB:Leeds": "LDS",
|
|
85
|
+
"GB:Glasgow": "GLA",
|
|
86
|
+
"GB:Edinburgh": "EDI",
|
|
87
|
+
"GB:Cardiff": "CDF",
|
|
88
|
+
"GB:Belfast": "BEL",
|
|
89
|
+
"GB:England": "ENG",
|
|
90
|
+
"GB:Scotland": "SCT",
|
|
91
|
+
"GB:Wales": "WLS",
|
|
92
|
+
"GB:Northern Ireland": "NIR",
|
|
93
|
+
// España
|
|
94
|
+
"ES:Madrid": "MAD",
|
|
95
|
+
"ES:Barcelona": "BCN",
|
|
96
|
+
"ES:L'Hospitalet de Llobregat": "LHO",
|
|
97
|
+
"ES:LHospitalet de Llobregat": "LHO",
|
|
98
|
+
"ES:Hospitalet de Llobregat": "LHO",
|
|
99
|
+
"ES:Valencia": "VLC",
|
|
100
|
+
"ES:Sevilla": "SVQ",
|
|
101
|
+
"ES:Zaragoza": "ZAZ",
|
|
102
|
+
"ES:Bilbao": "BIO",
|
|
103
|
+
"ES:Malaga": "AGP",
|
|
104
|
+
"ES:Málaga": "AGP",
|
|
105
|
+
"ES:Murcia": "MJV",
|
|
106
|
+
"ES:Palma": "PMI",
|
|
107
|
+
"ES:Las Palmas de Gran Canaria": "LPA",
|
|
108
|
+
"ES:Las Palmas": "LPA",
|
|
109
|
+
"ES:Alicante": "ALC",
|
|
110
|
+
"ES:Cordoba": "COR",
|
|
111
|
+
"ES:Córdoba": "COR",
|
|
112
|
+
"ES:Valladolid": "VLL",
|
|
113
|
+
"ES:Vigo": "VGO",
|
|
114
|
+
"ES:Gijon": "GIJ",
|
|
115
|
+
"ES:Gijón": "GIJ",
|
|
116
|
+
"ES:Oviedo": "OVD",
|
|
117
|
+
"ES:Leon": "LEN",
|
|
118
|
+
"ES:León": "LEN",
|
|
119
|
+
"ES:Burgos": "BUR",
|
|
120
|
+
"ES:Salamanca": "SLM",
|
|
121
|
+
"ES:San Sebastian": "EAS",
|
|
122
|
+
"ES:San Sebastián": "EAS",
|
|
123
|
+
"ES:Santander": "SDR",
|
|
124
|
+
"ES:Pamplona": "PNA",
|
|
125
|
+
"ES:Granada": "GRX",
|
|
126
|
+
"ES:Toledo": "TOL",
|
|
127
|
+
"ES:Badajoz": "BJZ",
|
|
128
|
+
"ES:Castilla y Leon": "CLY",
|
|
129
|
+
"ES:Castilla y León": "CLY",
|
|
130
|
+
"ES:Andalucia": "AND",
|
|
131
|
+
"ES:Andalucía": "AND",
|
|
132
|
+
"ES:Cataluna": "CAT",
|
|
133
|
+
"ES:Cataluña": "CAT",
|
|
134
|
+
"ES:Comunidad Valenciana": "VAL",
|
|
135
|
+
"ES:Comunidad de Madrid": "MAD",
|
|
136
|
+
"ES:Region de Murcia": "MUR",
|
|
137
|
+
"ES:Región de Murcia": "MUR",
|
|
138
|
+
"ES:Pais Vasco": "PV",
|
|
139
|
+
"ES:País Vasco": "PV",
|
|
140
|
+
"ES:Galicia": "GAL",
|
|
141
|
+
"ES:Extremadura": "EXT",
|
|
142
|
+
"ES:Asturias": "AST",
|
|
143
|
+
"ES:Navarra": "NAV",
|
|
144
|
+
"ES:Cantabria": "CAN",
|
|
145
|
+
"ES:La Rioja": "RIO",
|
|
146
|
+
"ES:Islas Baleares": "BAL",
|
|
147
|
+
"ES:Canarias": "CNR",
|
|
148
|
+
"ES:Ceuta": "CEU",
|
|
149
|
+
"ES:Melilla": "MEL",
|
|
150
|
+
// Venezuela
|
|
151
|
+
"VE:Caracas": "CCS",
|
|
152
|
+
"VE:Maracaibo": "MAR",
|
|
153
|
+
"VE:Valencia": "VLN",
|
|
154
|
+
"VE:Barquisimeto": "BRM",
|
|
155
|
+
"VE:Maracay": "MYC",
|
|
156
|
+
"VE:San Cristobal": "SCI",
|
|
157
|
+
"VE:San Cristóbal": "SCI",
|
|
158
|
+
"VE:Puerto La Cruz": "PLC",
|
|
159
|
+
"VE:Maturin": "MUN",
|
|
160
|
+
"VE:Maturín": "MUN",
|
|
161
|
+
"VE:Barcelona": "BLA",
|
|
162
|
+
"VE:Ciudad Guayana": "CGY",
|
|
163
|
+
"VE:Puerto Ordaz": "PZO",
|
|
164
|
+
"VE:Merida": "MER",
|
|
165
|
+
"VE:Mérida": "MER",
|
|
166
|
+
"VE:Punto Fijo": "PFJ",
|
|
167
|
+
"VE:Porlamar": "PMV",
|
|
168
|
+
"VE:San Fernando de Apure": "SFA",
|
|
169
|
+
"VE:Naguanagua": "NGN",
|
|
170
|
+
"VE:Lecheria": "LEC",
|
|
171
|
+
"VE:Lechería": "LEC",
|
|
172
|
+
"VE:Cumana": "CUM",
|
|
173
|
+
"VE:Cumaná": "CUM",
|
|
174
|
+
"VE:Coro": "COR",
|
|
175
|
+
"VE:Barinas": "BAR",
|
|
176
|
+
"VE:Trujillo": "TRU",
|
|
177
|
+
"VE:Valera": "VLR",
|
|
178
|
+
"VE:San Felipe": "SFP",
|
|
179
|
+
"VE:La Guaira": "LGR",
|
|
180
|
+
"VE:Maiquetia": "MAI",
|
|
181
|
+
"VE:Maiquetía": "MAI",
|
|
182
|
+
"VE:Distrito Capital": "CCS",
|
|
183
|
+
"VE:Apure": "APU",
|
|
184
|
+
"VE:Anzoategui": "ANZ",
|
|
185
|
+
"VE:Anzoátegui": "ANZ",
|
|
186
|
+
"VE:Aragua": "ARA",
|
|
187
|
+
"VE:Bolivar": "BOL",
|
|
188
|
+
"VE:Bolívar": "BOL",
|
|
189
|
+
"VE:Carabobo": "CAR",
|
|
190
|
+
"VE:Miranda": "MIR",
|
|
191
|
+
"VE:Zulia": "ZUL",
|
|
192
|
+
"VE:Tachira": "TAC",
|
|
193
|
+
"VE:Táchira": "TAC",
|
|
194
|
+
"VE:Lara": "LAR",
|
|
195
|
+
"VE:Monagas": "MON",
|
|
196
|
+
"VE:Nueva Esparta": "NEP",
|
|
197
|
+
"VE:Falcon": "FAL",
|
|
198
|
+
"VE:Falcón": "FAL",
|
|
199
|
+
"VE:Guarico": "GUA",
|
|
200
|
+
"VE:Guárico": "GUA",
|
|
201
|
+
"VE:Portuguesa": "POR",
|
|
202
|
+
"VE:Sucre": "SUC",
|
|
203
|
+
"VE:Yaracuy": "YAR",
|
|
204
|
+
"VE:Amazonas": "AMZ",
|
|
205
|
+
"VE:Cojedes": "COJ",
|
|
206
|
+
"VE:Delta Amacuro": "DAM",
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
function normalizeCityKey(cityName: string): string {
|
|
210
|
+
return cityName
|
|
211
|
+
.normalize("NFD")
|
|
212
|
+
.replace(/\p{M}/gu, "")
|
|
213
|
+
.replace(/[''`´’]/g, "")
|
|
214
|
+
.trim();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function fallbackCityCode(cityName: string): string {
|
|
218
|
+
const letters = normalizeCityKey(cityName).replace(/[^a-zA-Z]/g, "");
|
|
219
|
+
if (letters.length >= 3) return letters.slice(0, 3).toUpperCase();
|
|
220
|
+
return letters.toUpperCase().padEnd(3, "X");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function resolveCityCode(
|
|
224
|
+
countryCode: string,
|
|
225
|
+
cityName: string,
|
|
226
|
+
): string {
|
|
227
|
+
const cc = countryCode.trim().toUpperCase();
|
|
228
|
+
const normalized = normalizeCityKey(cityName);
|
|
229
|
+
const direct = CITY_CODE_OVERRIDES[`${cc}:${normalized}`];
|
|
230
|
+
if (direct) return direct;
|
|
231
|
+
const withoutAccents = CITY_CODE_OVERRIDES[`${cc}:${cityName.trim()}`];
|
|
232
|
+
if (withoutAccents) return withoutAccents;
|
|
233
|
+
return fallbackCityCode(cityName);
|
|
234
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** Países para UI: nombres vía Intl; catálogo vía `@countrystatecity/countries-browser`. */
|
|
2
|
+
|
|
3
|
+
export interface CountryOption {
|
|
4
|
+
code: string;
|
|
5
|
+
short: string;
|
|
6
|
+
long: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** @deprecated Usar `CountryOption`. */
|
|
10
|
+
export type LatinCountry = CountryOption;
|
|
11
|
+
|
|
12
|
+
export function spanishCountryName(isoCode: string, fallback?: string): string {
|
|
13
|
+
const code = isoCode.trim().toUpperCase();
|
|
14
|
+
if (!code) return fallback?.trim() || "";
|
|
15
|
+
try {
|
|
16
|
+
const name = new Intl.DisplayNames(["es"], { type: "region" }).of(code);
|
|
17
|
+
return name?.trim() || fallback?.trim() || code;
|
|
18
|
+
} catch {
|
|
19
|
+
return fallback?.trim() || code;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Mapea la respuesta CSC a opciones de UI (nombre en español, orden A–Z). */
|
|
24
|
+
export function mapGeoCountries(
|
|
25
|
+
apiCountries: Array<{ code: string; name: string }>,
|
|
26
|
+
): CountryOption[] {
|
|
27
|
+
const byCode = new Map<string, CountryOption>();
|
|
28
|
+
|
|
29
|
+
for (const c of apiCountries) {
|
|
30
|
+
const code = c.code.trim().toUpperCase();
|
|
31
|
+
if (!code) continue;
|
|
32
|
+
byCode.set(code, {
|
|
33
|
+
code,
|
|
34
|
+
short: code,
|
|
35
|
+
long: spanishCountryName(code, c.name),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return [...byCode.values()].sort((a, b) =>
|
|
40
|
+
a.long.localeCompare(b.long, "es"),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @deprecated Usar `mapGeoCountries`. */
|
|
45
|
+
export const mergeGeoCountries = mapGeoCountries;
|
|
46
|
+
|
|
47
|
+
export function getCountryByCode(
|
|
48
|
+
countryCode?: string | null,
|
|
49
|
+
): CountryOption | undefined {
|
|
50
|
+
if (!countryCode?.trim()) return undefined;
|
|
51
|
+
const normalized = countryCode.trim().toUpperCase();
|
|
52
|
+
return {
|
|
53
|
+
code: normalized,
|
|
54
|
+
short: normalized,
|
|
55
|
+
long: spanishCountryName(normalized, normalized),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Nombre en español a partir del código ISO; si no hay match, usa el fallback o el código. */
|
|
60
|
+
export function getCountryDisplayName(
|
|
61
|
+
countryCode?: string | null,
|
|
62
|
+
fallbackName?: string | null,
|
|
63
|
+
): string {
|
|
64
|
+
if (!countryCode?.trim()) {
|
|
65
|
+
if (fallbackName?.trim()) return fallbackName.trim();
|
|
66
|
+
return "—";
|
|
67
|
+
}
|
|
68
|
+
return spanishCountryName(countryCode, fallbackName ?? undefined);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Fax en oficinas/direcciones cuando hay país ISO-2. */
|
|
72
|
+
export function isCommonFaxCountry(countryCode?: string | null): boolean {
|
|
73
|
+
const code = countryCode?.trim().toUpperCase();
|
|
74
|
+
return Boolean(code && /^[A-Z]{2}$/.test(code));
|
|
75
|
+
}
|