@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,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
|
+
}
|