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