@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.
Files changed (66) hide show
  1. package/package.json +15 -1
  2. package/registry/traky/json/city-select.json +19 -0
  3. package/registry/traky/json/country-select.json +24 -0
  4. package/registry/traky/json/geo-search-select.json +24 -0
  5. package/registry/traky/json/geo-selectors.json +21 -0
  6. package/registry/traky/json/google-maps-address-picker.json +22 -0
  7. package/registry/traky/json/internal-chat-mention.json +1 -1
  8. package/registry/traky/json/internal-chat-panel.json +1 -1
  9. package/registry/traky/json/lib-geo-city-codes.json +14 -0
  10. package/registry/traky/json/lib-geo-countries.json +14 -0
  11. package/registry/traky/json/lib-geo-csc.json +17 -0
  12. package/registry/traky/json/lib-geo-locations.json +14 -0
  13. package/registry/traky/json/lib-geo-overrides.json +14 -0
  14. package/registry/traky/json/lib-geo-types.json +14 -0
  15. package/registry/traky/json/lib-google-maps-address.json +14 -0
  16. package/registry/traky/json/lib-phone-countries.json +14 -0
  17. package/registry/traky/json/lib-phone-e164.json +14 -0
  18. package/registry/traky/json/lib-utils.json +1 -1
  19. package/registry/traky/json/office-address-form-dialog.json +17 -0
  20. package/registry/traky/json/office-address-form.json +31 -0
  21. package/registry/traky/json/phone-number-input.json +25 -0
  22. package/registry/traky/json/state-select.json +19 -0
  23. package/registry/traky/lib/geo-city-codes.ts +234 -0
  24. package/registry/traky/lib/geo-countries.ts +75 -0
  25. package/registry/traky/lib/geo-csc.ts +82 -0
  26. package/registry/traky/lib/geo-locations.ts +171 -0
  27. package/registry/traky/lib/geo-overrides.ts +375 -0
  28. package/registry/traky/lib/geo-types.ts +34 -0
  29. package/registry/traky/lib/google-maps-address.ts +100 -0
  30. package/registry/traky/lib/phone-countries.ts +380 -0
  31. package/registry/traky/lib/phone-e164.ts +66 -0
  32. package/registry/traky/lib/utils.ts +24 -0
  33. package/registry/traky/registry.json +180 -0
  34. package/registry/traky/ui/city-select.tsx +113 -0
  35. package/registry/traky/ui/country-select.tsx +233 -0
  36. package/registry/traky/ui/geo-search-select.tsx +136 -0
  37. package/registry/traky/ui/geo-selectors.tsx +162 -0
  38. package/registry/traky/ui/google-maps-address-picker.tsx +422 -0
  39. package/registry/traky/ui/internal-chat-mention.ts +34 -3
  40. package/registry/traky/ui/internal-chat-panel.tsx +147 -12
  41. package/registry/traky/ui/office-address-form-dialog.tsx +16 -0
  42. package/registry/traky/ui/office-address-form.tsx +868 -0
  43. package/registry/traky/ui/phone-number-input.tsx +253 -0
  44. package/registry/traky/ui/state-select.tsx +107 -0
  45. package/src/lib/geo-city-codes.ts +234 -0
  46. package/src/lib/geo-countries.ts +75 -0
  47. package/src/lib/geo-csc.ts +82 -0
  48. package/src/lib/geo-locations.ts +171 -0
  49. package/src/lib/geo-overrides.ts +375 -0
  50. package/src/lib/geo-types.ts +34 -0
  51. package/src/lib/google-maps-address.ts +100 -0
  52. package/src/lib/phone-countries.ts +380 -0
  53. package/src/lib/phone-e164.ts +66 -0
  54. package/src/test/setup.ts +31 -0
  55. package/src/ui/__tests__/geo-selects.test.tsx +504 -0
  56. package/src/ui/city-select.tsx +113 -0
  57. package/src/ui/country-select.tsx +233 -0
  58. package/src/ui/geo-search-select.tsx +136 -0
  59. package/src/ui/geo-selectors.tsx +162 -0
  60. package/src/ui/google-maps-address-picker.tsx +422 -0
  61. package/src/ui/internal-chat-mention.ts +34 -3
  62. package/src/ui/internal-chat-panel.tsx +147 -12
  63. package/src/ui/office-address-form-dialog.tsx +16 -0
  64. package/src/ui/office-address-form.tsx +877 -0
  65. package/src/ui/phone-number-input.tsx +253 -0
  66. 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
+ }