@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,233 @@
1
+ "use client";
2
+
3
+ import { IconCheck, IconSelector } from "@tabler/icons-react";
4
+
5
+ import { useEffect, useMemo, useState } from "react";
6
+ import { getGeoCountries } from "../lib/geo-csc";
7
+ import { cn, commandFilterAccentInsensitive } from "../lib/utils";
8
+ import {
9
+ getCountryByCode,
10
+ mapGeoCountries,
11
+ type CountryOption,
12
+ } from "../lib/geo-countries";
13
+ import { Button } from "./button";
14
+ import {
15
+ Command,
16
+ CommandEmpty,
17
+ CommandGroup,
18
+ CommandInput,
19
+ CommandItem,
20
+ CommandList,
21
+ CommandSeparator,
22
+ } from "./command";
23
+ import {
24
+ Popover,
25
+ PopoverContent,
26
+ PopoverTrigger,
27
+ } from "./popover";
28
+
29
+ export type Country = CountryOption;
30
+
31
+ function countryLabel(c: Country, size: "sm" | "md" | "lg"): string {
32
+ if (size === "sm") return c.short;
33
+ if (size === "md") return c.code;
34
+ return c.long;
35
+ }
36
+
37
+ /** Bandera local `/assets/flags/{ISO}.svg`; si no existe, no muestra nada. */
38
+ function CountryFlag({
39
+ code,
40
+ name,
41
+ }: {
42
+ code: string;
43
+ name: string;
44
+ }) {
45
+ const [visible, setVisible] = useState(true);
46
+ if (!visible || !code) return null;
47
+
48
+ return (
49
+ <img
50
+ src={`/assets/flags/${code.toUpperCase()}.svg`}
51
+ alt=""
52
+ title={name}
53
+ width={22}
54
+ height={14}
55
+ className="h-[14px] w-[22px] shrink-0 rounded object-cover"
56
+ onError={() => setVisible(false)}
57
+ />
58
+ );
59
+ }
60
+
61
+ export type CountrySelectProps = {
62
+ id?: string;
63
+ /** ISO-2 controlado. `undefined` deja el selector sin controlar; `""` lo deja vacío. */
64
+ value?: string;
65
+ /** ISO-2 inicial cuando `value` no se pasa. */
66
+ defaultValue?: string;
67
+ onChange?: (value: Country) => void;
68
+ /** Si se elige la opción vacía (requiere `allowClear`). */
69
+ onClear?: () => void;
70
+ disabled?: boolean;
71
+ fileName?: string;
72
+ className?: string;
73
+ size?: "sm" | "md" | "lg";
74
+ searchPlaceholder?: string;
75
+ /** Si se define, solo se listan estos ISO-2 (en este orden). */
76
+ allowedCodes?: readonly string[];
77
+ allowClear?: boolean;
78
+ clearLabel?: string;
79
+ };
80
+
81
+ export function CountrySelect({
82
+ id,
83
+ value,
84
+ defaultValue,
85
+ onChange,
86
+ onClear,
87
+ disabled,
88
+ fileName,
89
+ className,
90
+ size = "md",
91
+ searchPlaceholder = "Escribe o busca un país...",
92
+ allowedCodes,
93
+ allowClear = false,
94
+ clearLabel = "Sin país",
95
+ }: CountrySelectProps) {
96
+ const [open, setOpen] = useState(false);
97
+ const [catalog, setCatalog] = useState<Country[]>([]);
98
+ const [loadingCatalog, setLoadingCatalog] = useState(true);
99
+ const isControlled = value !== undefined;
100
+ const [uncontrolled, setUncontrolled] = useState<Country | undefined>(() =>
101
+ getCountryByCode(defaultValue),
102
+ );
103
+
104
+ useEffect(() => {
105
+ let cancelled = false;
106
+ setLoadingCatalog(true);
107
+ void (async () => {
108
+ const apiCountries = await getGeoCountries();
109
+ if (cancelled) return;
110
+ setCatalog(mapGeoCountries(apiCountries));
111
+ setLoadingCatalog(false);
112
+ })();
113
+ return () => {
114
+ cancelled = true;
115
+ };
116
+ }, []);
117
+
118
+ const countries = useMemo(() => {
119
+ if (!allowedCodes?.length) return catalog;
120
+ const byCode = new Map(catalog.map((country) => [country.code, country]));
121
+ return allowedCodes
122
+ .map((code) => byCode.get(code.trim().toUpperCase()))
123
+ .filter((country): country is Country => Boolean(country));
124
+ }, [allowedCodes, catalog]);
125
+
126
+ const selected = useMemo(() => {
127
+ if (isControlled) {
128
+ const code = value?.trim().toUpperCase() ?? "";
129
+ if (!code) return undefined;
130
+ return catalog.find((c) => c.code === code) ?? getCountryByCode(code);
131
+ }
132
+ if (!uncontrolled) return undefined;
133
+ return catalog.find((c) => c.code === uncontrolled.code) ?? uncontrolled;
134
+ }, [catalog, isControlled, uncontrolled, value]);
135
+
136
+ const placeholder = fileName || "País";
137
+
138
+ const triggerLabel = useMemo(() => {
139
+ if (!selected) return null;
140
+ return countryLabel(selected, size);
141
+ }, [selected, size]);
142
+
143
+ const handleSelect = (country: Country) => {
144
+ if (!isControlled) setUncontrolled(country);
145
+ setOpen(false);
146
+ onChange?.(country);
147
+ };
148
+
149
+ const handleClear = () => {
150
+ if (!isControlled) setUncontrolled(undefined);
151
+ setOpen(false);
152
+ onClear?.();
153
+ };
154
+
155
+ return (
156
+ <Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
157
+ <PopoverTrigger asChild>
158
+ <Button
159
+ id={id}
160
+ type="button"
161
+ variant="outline"
162
+ disabled={disabled}
163
+ role="combobox"
164
+ aria-expanded={open}
165
+ className={cn(
166
+ "h-10 w-full justify-between gap-2 font-normal",
167
+ className,
168
+ )}
169
+ >
170
+ {selected && triggerLabel ? (
171
+ <span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
172
+ <CountryFlag code={selected.code} name={selected.long} />
173
+ <span className="truncate">{triggerLabel}</span>
174
+ </span>
175
+ ) : (
176
+ <span className="truncate text-muted-foreground">{placeholder}</span>
177
+ )}
178
+ <IconSelector className="size-4 shrink-0 opacity-50" />
179
+ </Button>
180
+ </PopoverTrigger>
181
+
182
+ {!disabled && (
183
+ <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
184
+ <Command
185
+ className="border border-border"
186
+ filter={commandFilterAccentInsensitive}
187
+ >
188
+ <CommandInput placeholder={searchPlaceholder} />
189
+ <CommandList>
190
+ <CommandEmpty>
191
+ {loadingCatalog
192
+ ? "Cargando países…"
193
+ : "No se encontró el país."}
194
+ </CommandEmpty>
195
+ {allowClear ? (
196
+ <CommandGroup>
197
+ <CommandItem
198
+ value={`${clearLabel} sin pais none`}
199
+ onSelect={handleClear}
200
+ className="gap-2"
201
+ >
202
+ <span className="min-w-0 flex-1 truncate">{clearLabel}</span>
203
+ {!selected ? (
204
+ <IconCheck className="size-4 shrink-0 text-primary" />
205
+ ) : null}
206
+ </CommandItem>
207
+ </CommandGroup>
208
+ ) : null}
209
+ {allowClear ? <CommandSeparator /> : null}
210
+ <CommandGroup>
211
+ {countries.map((c) => (
212
+ <CommandItem
213
+ key={c.code}
214
+ value={`${c.long} ${c.code} ${c.short}`}
215
+ onSelect={() => handleSelect(c)}
216
+ className="gap-2"
217
+ >
218
+ <CountryFlag code={c.code} name={c.long} />
219
+ <span className="min-w-0 flex-1 truncate">{c.long}</span>
220
+ <span className="text-xs text-muted-foreground">{c.code}</span>
221
+ {selected?.code === c.code ? (
222
+ <IconCheck className="size-4 shrink-0 text-primary" />
223
+ ) : null}
224
+ </CommandItem>
225
+ ))}
226
+ </CommandGroup>
227
+ </CommandList>
228
+ </Command>
229
+ </PopoverContent>
230
+ )}
231
+ </Popover>
232
+ );
233
+ }
@@ -0,0 +1,136 @@
1
+ "use client";
2
+
3
+ import { IconCheck, IconSelector } from "@tabler/icons-react";
4
+ import { useMemo, useState } from "react";
5
+ import { cn, commandFilterAccentInsensitive } from "../lib/utils";
6
+ import { Button } from "./button";
7
+ import {
8
+ Command,
9
+ CommandEmpty,
10
+ CommandGroup,
11
+ CommandInput,
12
+ CommandItem,
13
+ CommandList,
14
+ } from "./command";
15
+ import {
16
+ Popover,
17
+ PopoverContent,
18
+ PopoverTrigger,
19
+ } from "./popover";
20
+ import { Spinner } from "./spinner";
21
+ import type { GeoOption } from "../lib/geo-types";
22
+
23
+ type GeoSearchSelectProps = {
24
+ id?: string;
25
+ label?: string;
26
+ placeholder?: string;
27
+ searchPlaceholder?: string;
28
+ emptyMessage?: string;
29
+ options: GeoOption[];
30
+ value?: string;
31
+ displayValue?: string;
32
+ onChange?: (option: GeoOption) => void;
33
+ disabled?: boolean;
34
+ isLoading?: boolean;
35
+ className?: string;
36
+ showCode?: boolean;
37
+ };
38
+
39
+ export function GeoSearchSelect({
40
+ id,
41
+ placeholder = "Seleccionar…",
42
+ searchPlaceholder = "Buscar…",
43
+ emptyMessage = "No se encontraron resultados.",
44
+ options,
45
+ value,
46
+ displayValue,
47
+ onChange,
48
+ disabled = false,
49
+ isLoading = false,
50
+ className,
51
+ showCode = false,
52
+ }: GeoSearchSelectProps) {
53
+ const [open, setOpen] = useState(false);
54
+
55
+ const selected = useMemo(() => {
56
+ if (!value && !displayValue) return undefined;
57
+ if (value) {
58
+ return options.find((o) => o.code === value || o.name === value);
59
+ }
60
+ return options.find((o) => o.name === displayValue);
61
+ }, [options, value, displayValue]);
62
+
63
+ const triggerLabel = selected?.name ?? displayValue;
64
+
65
+ return (
66
+ <Popover modal open={open} onOpenChange={disabled ? undefined : setOpen}>
67
+ <PopoverTrigger asChild>
68
+ <Button
69
+ id={id}
70
+ type="button"
71
+ variant="outline"
72
+ disabled={disabled || isLoading}
73
+ role="combobox"
74
+ aria-expanded={open}
75
+ className={cn(
76
+ "h-10 w-full justify-between gap-2 font-normal",
77
+ className,
78
+ )}
79
+ >
80
+ {isLoading ? (
81
+ <span className="flex items-center gap-2 text-muted-foreground">
82
+ <Spinner className="size-4" />
83
+ Cargando…
84
+ </span>
85
+ ) : triggerLabel ? (
86
+ <span className="truncate">{triggerLabel}</span>
87
+ ) : (
88
+ <span className="truncate text-muted-foreground">{placeholder}</span>
89
+ )}
90
+ <IconSelector className="size-4 shrink-0 opacity-50" />
91
+ </Button>
92
+ </PopoverTrigger>
93
+
94
+ {!disabled && !isLoading && (
95
+ <PopoverContent
96
+ className="w-[var(--radix-popover-trigger-width)] p-0"
97
+ align="start"
98
+ >
99
+ <Command
100
+ className="border border-border"
101
+ filter={commandFilterAccentInsensitive}
102
+ >
103
+ <CommandInput placeholder={searchPlaceholder} />
104
+ <CommandList>
105
+ <CommandEmpty>{emptyMessage}</CommandEmpty>
106
+ <CommandGroup>
107
+ {options.map((option) => (
108
+ <CommandItem
109
+ key={`${option.code}-${option.name}`}
110
+ value={`${option.name} ${option.code}`}
111
+ onSelect={() => {
112
+ onChange?.(option);
113
+ setOpen(false);
114
+ }}
115
+ className="gap-2"
116
+ >
117
+ <span className="min-w-0 flex-1 truncate">{option.name}</span>
118
+ {showCode ? (
119
+ <span className="text-xs text-muted-foreground">
120
+ {option.code}
121
+ </span>
122
+ ) : null}
123
+ {selected?.code === option.code &&
124
+ selected?.name === option.name ? (
125
+ <IconCheck className="size-4 shrink-0 text-primary" />
126
+ ) : null}
127
+ </CommandItem>
128
+ ))}
129
+ </CommandGroup>
130
+ </CommandList>
131
+ </Command>
132
+ </PopoverContent>
133
+ )}
134
+ </Popover>
135
+ );
136
+ }
@@ -0,0 +1,162 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { CountrySelect, type Country } from "./country-select";
5
+ import { StateSelect } from "./state-select";
6
+ import { CitySelect } from "./city-select";
7
+ import type { GeoCityOption, GeoOption } from "../lib/geo-types";
8
+ import { cn } from "../lib/utils";
9
+ import { Label } from "./label";
10
+
11
+ export type GeoSelectorLevel = "country" | "state" | "city";
12
+
13
+ export type GeoSelectorsValue = {
14
+ country: Country | null;
15
+ state: GeoOption | null;
16
+ city: GeoCityOption | null;
17
+ };
18
+
19
+ const ALL_LEVELS: readonly GeoSelectorLevel[] = ["country", "state", "city"];
20
+
21
+ const EMPTY_VALUE: GeoSelectorsValue = {
22
+ country: null,
23
+ state: null,
24
+ city: null,
25
+ };
26
+
27
+ type GeoSelectorsProps = {
28
+ /**
29
+ * Niveles visibles. Omite los que no quieras.
30
+ * Si no está "country", pasa `countryCode`.
31
+ * Si no está "state" y sí "city", pasa `stateCode`.
32
+ */
33
+ levels?: readonly GeoSelectorLevel[];
34
+ /** País fijo cuando el select de país no se muestra. */
35
+ countryCode?: string | null;
36
+ /** Provincia fija cuando el select de provincia no se muestra. */
37
+ stateCode?: string | null;
38
+ onChange?: (value: GeoSelectorsValue) => void;
39
+ disabled?: boolean;
40
+ className?: string;
41
+ idPrefix?: string;
42
+ };
43
+
44
+ export function GeoSelectors({
45
+ levels = ALL_LEVELS,
46
+ countryCode,
47
+ stateCode,
48
+ onChange,
49
+ disabled = false,
50
+ className,
51
+ idPrefix = "geo",
52
+ }: GeoSelectorsProps) {
53
+ const showCountry = levels.includes("country");
54
+ const showState = levels.includes("state");
55
+ const showCity = levels.includes("city");
56
+ const [selection, setSelection] = useState<GeoSelectorsValue>(EMPTY_VALUE);
57
+
58
+ const resolvedCountry = showCountry
59
+ ? (selection.country?.code ?? "")
60
+ : (countryCode?.trim().toUpperCase() ?? "");
61
+ const resolvedState = showState
62
+ ? (selection.state?.code ?? "")
63
+ : (stateCode?.trim() ?? "");
64
+
65
+ const externalCountry = countryCode?.trim().toUpperCase() ?? "";
66
+ const externalState = stateCode?.trim() ?? "";
67
+
68
+ useEffect(() => {
69
+ if (showCountry) return;
70
+ setSelection((prev) => {
71
+ if (!prev.state && !prev.city) return prev;
72
+ return { ...prev, state: null, city: null };
73
+ });
74
+ }, [externalCountry, showCountry]);
75
+
76
+ useEffect(() => {
77
+ if (showState) return;
78
+ setSelection((prev) => {
79
+ if (!prev.city) return prev;
80
+ return { ...prev, city: null };
81
+ });
82
+ }, [externalState, showState]);
83
+
84
+ const publish = (next: GeoSelectorsValue) => {
85
+ setSelection(next);
86
+ onChange?.(next);
87
+ };
88
+
89
+ const visible = [showCountry, showState, showCity].filter(Boolean).length;
90
+ if (visible === 0) return null;
91
+
92
+ return (
93
+ <div
94
+ className={cn(
95
+ "grid gap-4",
96
+ visible >= 3
97
+ ? "md:grid-cols-3"
98
+ : visible === 2
99
+ ? "md:grid-cols-2"
100
+ : "max-w-sm",
101
+ className,
102
+ )}
103
+ >
104
+ {showCountry ? (
105
+ <div className="space-y-1">
106
+ <Label htmlFor={`${idPrefix}-country`}>País</Label>
107
+ <CountrySelect
108
+ id={`${idPrefix}-country`}
109
+ className="w-full"
110
+ size="lg"
111
+ value={selection.country?.code}
112
+ disabled={disabled}
113
+ onChange={(country) =>
114
+ publish({ country, state: null, city: null })
115
+ }
116
+ allowClear
117
+ onClear={() => publish(EMPTY_VALUE)}
118
+ />
119
+ </div>
120
+ ) : null}
121
+
122
+ {showState ? (
123
+ <div className="space-y-1">
124
+ <Label htmlFor={`${idPrefix}-state`}>Estado / Región / Municipio</Label>
125
+ <StateSelect
126
+ id={`${idPrefix}-state`}
127
+ countryCode={resolvedCountry}
128
+ value={selection.state?.code ?? ""}
129
+ disabled={disabled}
130
+ onChange={(state) =>
131
+ publish({
132
+ country: selection.country,
133
+ state,
134
+ city: null,
135
+ })
136
+ }
137
+ />
138
+ </div>
139
+ ) : null}
140
+
141
+ {showCity ? (
142
+ <div className="space-y-1">
143
+ <Label htmlFor={`${idPrefix}-city`}>Ciudad / Pueblo / Distrito</Label>
144
+ <CitySelect
145
+ id={`${idPrefix}-city`}
146
+ countryCode={resolvedCountry}
147
+ stateCode={resolvedState}
148
+ value={selection.city?.code ?? selection.city?.name ?? ""}
149
+ disabled={disabled}
150
+ onChange={(city) =>
151
+ publish({
152
+ country: selection.country,
153
+ state: selection.state,
154
+ city,
155
+ })
156
+ }
157
+ />
158
+ </div>
159
+ ) : null}
160
+ </div>
161
+ );
162
+ }