@traky-app/ui 1.6.3 → 1.6.5
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/internal-chat-panel.json +1 -1
- 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 +35 -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/internal-chat-panel.tsx +1417 -71
- package/registry/traky/ui/office-address-form-dialog.tsx +16 -0
- package/registry/traky/ui/office-address-form.tsx +914 -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/internal-chat-panel.tsx +1417 -71
- package/src/ui/office-address-form-dialog.tsx +16 -0
- package/src/ui/office-address-form.tsx +914 -0
- package/src/ui/phone-number-input.tsx +253 -0
- package/src/ui/state-select.tsx +107 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@traky-app/ui",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.5",
|
|
4
4
|
"description": "Componentes Traky UI para importar desde @traky-app/ui, más el registro copy-paste.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -13,10 +13,12 @@
|
|
|
13
13
|
"exports": {
|
|
14
14
|
"./registry/*": "./registry/*",
|
|
15
15
|
"./schema/components.json": "./schema/components.json",
|
|
16
|
+
"./lib/*": "./src/lib/*.ts",
|
|
16
17
|
"./*": "./src/ui/*.tsx"
|
|
17
18
|
},
|
|
18
19
|
"scripts": {
|
|
19
20
|
"registry:build": "node ./scripts/build-registry.mjs",
|
|
21
|
+
"test": "vitest run",
|
|
20
22
|
"prepack": "pnpm registry:build"
|
|
21
23
|
},
|
|
22
24
|
"repository": {
|
|
@@ -35,6 +37,7 @@
|
|
|
35
37
|
"react-dom": "^19.0.0"
|
|
36
38
|
},
|
|
37
39
|
"dependencies": {
|
|
40
|
+
"@countrystatecity/countries-browser": "^1.0.4",
|
|
38
41
|
"@radix-ui/react-accordion": "^1.2.20",
|
|
39
42
|
"@radix-ui/react-alert-dialog": "^1.1.23",
|
|
40
43
|
"@radix-ui/react-avatar": "^1.2.6",
|
|
@@ -53,8 +56,10 @@
|
|
|
53
56
|
"@radix-ui/react-toggle": "^1.1.18",
|
|
54
57
|
"@radix-ui/react-toggle-group": "^1.1.19",
|
|
55
58
|
"@radix-ui/react-tooltip": "^1.2.16",
|
|
59
|
+
"@react-google-maps/api": "^2.20.8",
|
|
56
60
|
"@tabler/icons-react": "^3.46.0",
|
|
57
61
|
"@types/geojson": "^7946.0.16",
|
|
62
|
+
"@types/google.maps": "^3.58.1",
|
|
58
63
|
"@types/react": "^19.3.0",
|
|
59
64
|
"@types/react-dom": "^19.3.0",
|
|
60
65
|
"class-variance-authority": "^0.7.1",
|
|
@@ -71,5 +76,14 @@
|
|
|
71
76
|
"sonner": "^2.0.8",
|
|
72
77
|
"tailwind-merge": "^3.7.0",
|
|
73
78
|
"vaul": "^1.1.2"
|
|
79
|
+
},
|
|
80
|
+
"devDependencies": {
|
|
81
|
+
"@testing-library/jest-dom": "^7.0.1",
|
|
82
|
+
"@testing-library/react": "^16.3.3",
|
|
83
|
+
"@testing-library/user-event": "^14.6.8",
|
|
84
|
+
"jsdom": "^31.0.0",
|
|
85
|
+
"react": "^19.0.0",
|
|
86
|
+
"react-dom": "^19.0.0",
|
|
87
|
+
"vitest": "^5.1.0"
|
|
74
88
|
}
|
|
75
89
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "city-select",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"author": "Traky (https://traky.app)",
|
|
6
|
+
"registryDependencies": [
|
|
7
|
+
"geo-search-select",
|
|
8
|
+
"lib-geo-locations",
|
|
9
|
+
"lib-geo-types"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "ui/city-select.tsx",
|
|
14
|
+
"content": "\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { fetchCities } from \"@/lib/geo-locations\";\nimport type { GeoCityOption, GeoOption } from \"@/lib/geo-types\";\nimport { GeoSearchSelect } from \"@/components/ui/geo-search-select\";\n\nexport type CitySelectProps = {\n id?: string;\n /** ISO-2 del país. */\n countryCode?: string | null;\n /** Código de provincia / estado. */\n stateCode?: string | null;\n /** Código o nombre controlado. `undefined` deja el selector sin controlar; `\"\"` lo deja vacío. */\n value?: string;\n /** Código o nombre inicial cuando `value` no se pasa. */\n defaultValue?: string;\n onChange?: (option: GeoCityOption | null) => void;\n disabled?: boolean;\n className?: string;\n placeholder?: string;\n showCode?: boolean;\n};\n\nexport function CitySelect({\n id,\n countryCode,\n stateCode,\n value,\n defaultValue,\n onChange,\n disabled = false,\n className,\n placeholder = \"Buscar ciudad, pueblo o distrito…\",\n showCode = true,\n}: CitySelectProps) {\n const cc = countryCode?.trim().toUpperCase() ?? \"\";\n const sc = stateCode?.trim() ?? \"\";\n const ready = Boolean(cc && sc);\n const isControlled = value !== undefined;\n const [options, setOptions] = useState<GeoCityOption[]>([]);\n const [isLoading, setIsLoading] = useState(false);\n const [internal, setInternal] = useState(defaultValue ?? \"\");\n const onChangeRef = useRef(onChange);\n const controlledRef = useRef(isControlled);\n const selectionRef = useRef(\"\");\n const prevContext = useRef({ cc, sc });\n\n onChangeRef.current = onChange;\n controlledRef.current = isControlled;\n selectionRef.current = (isControlled ? value : internal) ?? \"\";\n\n useEffect(() => {\n const previous = prevContext.current;\n if (previous.cc === cc && previous.sc === sc) return;\n prevContext.current = { cc, sc };\n if (!previous.cc || !previous.sc) return;\n if (!controlledRef.current) setInternal(\"\");\n if (selectionRef.current) onChangeRef.current?.(null);\n }, [cc, sc]);\n\n useEffect(() => {\n if (!ready) {\n setOptions([]);\n setIsLoading(false);\n return;\n }\n\n let cancelled = false;\n setOptions([]);\n setIsLoading(true);\n void fetchCities(cc, sc)\n .then((list) => {\n if (!cancelled) setOptions(list);\n })\n .finally(() => {\n if (!cancelled) setIsLoading(false);\n });\n\n return () => {\n cancelled = true;\n };\n }, [cc, sc, ready]);\n\n const current = isControlled ? (value ?? \"\") : internal;\n const selected = useMemo(() => {\n if (!current) return undefined;\n return options.find(\n (option) => option.code === current || option.name === current,\n );\n }, [current, options]);\n\n return (\n <GeoSearchSelect\n id={id}\n className={className}\n placeholder={ready ? placeholder : \"Indica país y estado, región o municipio\"}\n searchPlaceholder=\"Buscar ciudad, pueblo o distrito…\"\n emptyMessage=\"Sin ciudades, pueblos ni distritos.\"\n options={options}\n value={selected?.code}\n displayValue={selected?.name}\n onChange={(option: GeoOption) => {\n const city = option as GeoCityOption;\n if (!isControlled) setInternal(city.code);\n onChange?.(city);\n }}\n disabled={disabled || !ready}\n isLoading={ready && isLoading}\n showCode={showCode}\n />\n );\n}\n",
|
|
15
|
+
"type": "registry:ui",
|
|
16
|
+
"target": ""
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "country-select",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"author": "Traky (https://traky.app)",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@tabler/icons-react"
|
|
8
|
+
],
|
|
9
|
+
"registryDependencies": [
|
|
10
|
+
"button",
|
|
11
|
+
"command",
|
|
12
|
+
"lib-geo-countries",
|
|
13
|
+
"lib-geo-csc",
|
|
14
|
+
"popover"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "ui/country-select.tsx",
|
|
19
|
+
"content": "\"use client\";\n\nimport { IconCheck, IconSelector } from \"@tabler/icons-react\";\n\nimport { useEffect, useMemo, useState } from \"react\";\nimport { getGeoCountries } from \"@/lib/geo-csc\";\nimport { cn, commandFilterAccentInsensitive } from \"@/lib/utils\";\nimport {\n getCountryByCode,\n mapGeoCountries,\n type CountryOption,\n} from \"@/lib/geo-countries\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n CommandSeparator,\n} from \"@/components/ui/command\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\n\nexport type Country = CountryOption;\n\nfunction countryLabel(c: Country, size: \"sm\" | \"md\" | \"lg\"): string {\n if (size === \"sm\") return c.short;\n if (size === \"md\") return c.code;\n return c.long;\n}\n\n/** Bandera local `/assets/flags/{ISO}.svg`; si no existe, no muestra nada. */\nfunction CountryFlag({\n code,\n name,\n}: {\n code: string;\n name: string;\n}) {\n const [visible, setVisible] = useState(true);\n if (!visible || !code) return null;\n\n return (\n <img\n src={`/assets/flags/${code.toUpperCase()}.svg`}\n alt=\"\"\n title={name}\n width={22}\n height={14}\n className=\"h-[14px] w-[22px] shrink-0 rounded object-cover\"\n onError={() => setVisible(false)}\n />\n );\n}\n\nexport type CountrySelectProps = {\n id?: string;\n /** ISO-2 controlado. `undefined` deja el selector sin controlar; `\"\"` lo deja vacío. */\n value?: string;\n /** ISO-2 inicial cuando `value` no se pasa. */\n defaultValue?: string;\n onChange?: (value: Country) => void;\n /** Si se elige la opción vacía (requiere `allowClear`). */\n onClear?: () => void;\n disabled?: boolean;\n fileName?: string;\n className?: string;\n size?: \"sm\" | \"md\" | \"lg\";\n searchPlaceholder?: string;\n /** Si se define, solo se listan estos ISO-2 (en este orden). */\n allowedCodes?: readonly string[];\n allowClear?: boolean;\n clearLabel?: string;\n};\n\nexport function CountrySelect({\n id,\n value,\n defaultValue,\n onChange,\n onClear,\n disabled,\n fileName,\n className,\n size = \"md\",\n searchPlaceholder = \"Escribe o busca un país...\",\n allowedCodes,\n allowClear = false,\n clearLabel = \"Sin país\",\n}: CountrySelectProps) {\n const [open, setOpen] = useState(false);\n const [catalog, setCatalog] = useState<Country[]>([]);\n const [loadingCatalog, setLoadingCatalog] = useState(true);\n const isControlled = value !== undefined;\n const [uncontrolled, setUncontrolled] = useState<Country | undefined>(() =>\n getCountryByCode(defaultValue),\n );\n\n useEffect(() => {\n let cancelled = false;\n setLoadingCatalog(true);\n void (async () => {\n const apiCountries = await getGeoCountries();\n if (cancelled) return;\n setCatalog(mapGeoCountries(apiCountries));\n setLoadingCatalog(false);\n })();\n return () => {\n cancelled = true;\n };\n }, []);\n\n const countries = useMemo(() => {\n if (!allowedCodes?.length) return catalog;\n const byCode = new Map(catalog.map((country) => [country.code, country]));\n return allowedCodes\n .map((code) => byCode.get(code.trim().toUpperCase()))\n .filter((country): country is Country => Boolean(country));\n }, [allowedCodes, catalog]);\n\n const selected = useMemo(() => {\n if (isControlled) {\n const code = value?.trim().toUpperCase() ?? \"\";\n if (!code) return undefined;\n return catalog.find((c) => c.code === code) ?? getCountryByCode(code);\n }\n if (!uncontrolled) return undefined;\n return catalog.find((c) => c.code === uncontrolled.code) ?? uncontrolled;\n }, [catalog, isControlled, uncontrolled, value]);\n\n const placeholder = fileName || \"País\";\n\n const triggerLabel = useMemo(() => {\n if (!selected) return null;\n return countryLabel(selected, size);\n }, [selected, size]);\n\n const handleSelect = (country: Country) => {\n if (!isControlled) setUncontrolled(country);\n setOpen(false);\n onChange?.(country);\n };\n\n const handleClear = () => {\n if (!isControlled) setUncontrolled(undefined);\n setOpen(false);\n onClear?.();\n };\n\n return (\n <Popover open={open} onOpenChange={disabled ? undefined : setOpen}>\n <PopoverTrigger asChild>\n <Button\n id={id}\n type=\"button\"\n variant=\"outline\"\n disabled={disabled}\n role=\"combobox\"\n aria-expanded={open}\n className={cn(\n \"h-10 w-full justify-between gap-2 font-normal\",\n className,\n )}\n >\n {selected && triggerLabel ? (\n <span className=\"flex min-w-0 flex-1 items-center gap-2 overflow-hidden\">\n <CountryFlag code={selected.code} name={selected.long} />\n <span className=\"truncate\">{triggerLabel}</span>\n </span>\n ) : (\n <span className=\"truncate text-muted-foreground\">{placeholder}</span>\n )}\n <IconSelector className=\"size-4 shrink-0 opacity-50\" />\n </Button>\n </PopoverTrigger>\n\n {!disabled && (\n <PopoverContent className=\"w-[var(--radix-popover-trigger-width)] p-0\" align=\"start\">\n <Command\n className=\"border border-border\"\n filter={commandFilterAccentInsensitive}\n >\n <CommandInput placeholder={searchPlaceholder} />\n <CommandList>\n <CommandEmpty>\n {loadingCatalog\n ? \"Cargando países…\"\n : \"No se encontró el país.\"}\n </CommandEmpty>\n {allowClear ? (\n <CommandGroup>\n <CommandItem\n value={`${clearLabel} sin pais none`}\n onSelect={handleClear}\n className=\"gap-2\"\n >\n <span className=\"min-w-0 flex-1 truncate\">{clearLabel}</span>\n {!selected ? (\n <IconCheck className=\"size-4 shrink-0 text-primary\" />\n ) : null}\n </CommandItem>\n </CommandGroup>\n ) : null}\n {allowClear ? <CommandSeparator /> : null}\n <CommandGroup>\n {countries.map((c) => (\n <CommandItem\n key={c.code}\n value={`${c.long} ${c.code} ${c.short}`}\n onSelect={() => handleSelect(c)}\n className=\"gap-2\"\n >\n <CountryFlag code={c.code} name={c.long} />\n <span className=\"min-w-0 flex-1 truncate\">{c.long}</span>\n <span className=\"text-xs text-muted-foreground\">{c.code}</span>\n {selected?.code === c.code ? (\n <IconCheck className=\"size-4 shrink-0 text-primary\" />\n ) : null}\n </CommandItem>\n ))}\n </CommandGroup>\n </CommandList>\n </Command>\n </PopoverContent>\n )}\n </Popover>\n );\n}\n",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"target": ""
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "geo-search-select",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"author": "Traky (https://traky.app)",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@tabler/icons-react"
|
|
8
|
+
],
|
|
9
|
+
"registryDependencies": [
|
|
10
|
+
"button",
|
|
11
|
+
"command",
|
|
12
|
+
"lib-geo-types",
|
|
13
|
+
"popover",
|
|
14
|
+
"spinner"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "ui/geo-search-select.tsx",
|
|
19
|
+
"content": "\"use client\";\n\nimport { IconCheck, IconSelector } from \"@tabler/icons-react\";\nimport { useMemo, useState } from \"react\";\nimport { cn, commandFilterAccentInsensitive } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n} from \"@/components/ui/command\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport type { GeoOption } from \"@/lib/geo-types\";\n\ntype GeoSearchSelectProps = {\n id?: string;\n label?: string;\n placeholder?: string;\n searchPlaceholder?: string;\n emptyMessage?: string;\n options: GeoOption[];\n value?: string;\n displayValue?: string;\n onChange?: (option: GeoOption) => void;\n disabled?: boolean;\n isLoading?: boolean;\n className?: string;\n showCode?: boolean;\n};\n\nexport function GeoSearchSelect({\n id,\n placeholder = \"Seleccionar…\",\n searchPlaceholder = \"Buscar…\",\n emptyMessage = \"No se encontraron resultados.\",\n options,\n value,\n displayValue,\n onChange,\n disabled = false,\n isLoading = false,\n className,\n showCode = false,\n}: GeoSearchSelectProps) {\n const [open, setOpen] = useState(false);\n\n const selected = useMemo(() => {\n if (!value && !displayValue) return undefined;\n if (value) {\n return options.find((o) => o.code === value || o.name === value);\n }\n return options.find((o) => o.name === displayValue);\n }, [options, value, displayValue]);\n\n const triggerLabel = selected?.name ?? displayValue;\n\n return (\n <Popover modal open={open} onOpenChange={disabled ? undefined : setOpen}>\n <PopoverTrigger asChild>\n <Button\n id={id}\n type=\"button\"\n variant=\"outline\"\n disabled={disabled || isLoading}\n role=\"combobox\"\n aria-expanded={open}\n className={cn(\n \"h-10 w-full justify-between gap-2 font-normal\",\n className,\n )}\n >\n {isLoading ? (\n <span className=\"flex items-center gap-2 text-muted-foreground\">\n <Spinner className=\"size-4\" />\n Cargando…\n </span>\n ) : triggerLabel ? (\n <span className=\"truncate\">{triggerLabel}</span>\n ) : (\n <span className=\"truncate text-muted-foreground\">{placeholder}</span>\n )}\n <IconSelector className=\"size-4 shrink-0 opacity-50\" />\n </Button>\n </PopoverTrigger>\n\n {!disabled && !isLoading && (\n <PopoverContent\n className=\"w-[var(--radix-popover-trigger-width)] p-0\"\n align=\"start\"\n >\n <Command\n className=\"border border-border\"\n filter={commandFilterAccentInsensitive}\n >\n <CommandInput placeholder={searchPlaceholder} />\n <CommandList>\n <CommandEmpty>{emptyMessage}</CommandEmpty>\n <CommandGroup>\n {options.map((option) => (\n <CommandItem\n key={`${option.code}-${option.name}`}\n value={`${option.name} ${option.code}`}\n onSelect={() => {\n onChange?.(option);\n setOpen(false);\n }}\n className=\"gap-2\"\n >\n <span className=\"min-w-0 flex-1 truncate\">{option.name}</span>\n {showCode ? (\n <span className=\"text-xs text-muted-foreground\">\n {option.code}\n </span>\n ) : null}\n {selected?.code === option.code &&\n selected?.name === option.name ? (\n <IconCheck className=\"size-4 shrink-0 text-primary\" />\n ) : null}\n </CommandItem>\n ))}\n </CommandGroup>\n </CommandList>\n </Command>\n </PopoverContent>\n )}\n </Popover>\n );\n}\n",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"target": ""
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "geo-selectors",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"author": "Traky (https://traky.app)",
|
|
6
|
+
"registryDependencies": [
|
|
7
|
+
"city-select",
|
|
8
|
+
"country-select",
|
|
9
|
+
"label",
|
|
10
|
+
"lib-geo-types",
|
|
11
|
+
"state-select"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "ui/geo-selectors.tsx",
|
|
16
|
+
"content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { CountrySelect, type Country } from \"@/components/ui/country-select\";\nimport { StateSelect } from \"@/components/ui/state-select\";\nimport { CitySelect } from \"@/components/ui/city-select\";\nimport type { GeoCityOption, GeoOption } from \"@/lib/geo-types\";\nimport { cn } from \"@/lib/utils\";\nimport { Label } from \"@/components/ui/label\";\n\nexport type GeoSelectorLevel = \"country\" | \"state\" | \"city\";\n\nexport type GeoSelectorsValue = {\n country: Country | null;\n state: GeoOption | null;\n city: GeoCityOption | null;\n};\n\nconst ALL_LEVELS: readonly GeoSelectorLevel[] = [\"country\", \"state\", \"city\"];\n\nconst EMPTY_VALUE: GeoSelectorsValue = {\n country: null,\n state: null,\n city: null,\n};\n\ntype GeoSelectorsProps = {\n /**\n * Niveles visibles. Omite los que no quieras.\n * Si no está \"country\", pasa `countryCode`.\n * Si no está \"state\" y sí \"city\", pasa `stateCode`.\n */\n levels?: readonly GeoSelectorLevel[];\n /** País fijo cuando el select de país no se muestra. */\n countryCode?: string | null;\n /** Provincia fija cuando el select de provincia no se muestra. */\n stateCode?: string | null;\n onChange?: (value: GeoSelectorsValue) => void;\n disabled?: boolean;\n className?: string;\n idPrefix?: string;\n};\n\nexport function GeoSelectors({\n levels = ALL_LEVELS,\n countryCode,\n stateCode,\n onChange,\n disabled = false,\n className,\n idPrefix = \"geo\",\n}: GeoSelectorsProps) {\n const showCountry = levels.includes(\"country\");\n const showState = levels.includes(\"state\");\n const showCity = levels.includes(\"city\");\n const [selection, setSelection] = useState<GeoSelectorsValue>(EMPTY_VALUE);\n\n const resolvedCountry = showCountry\n ? (selection.country?.code ?? \"\")\n : (countryCode?.trim().toUpperCase() ?? \"\");\n const resolvedState = showState\n ? (selection.state?.code ?? \"\")\n : (stateCode?.trim() ?? \"\");\n\n const externalCountry = countryCode?.trim().toUpperCase() ?? \"\";\n const externalState = stateCode?.trim() ?? \"\";\n\n useEffect(() => {\n if (showCountry) return;\n setSelection((prev) => {\n if (!prev.state && !prev.city) return prev;\n return { ...prev, state: null, city: null };\n });\n }, [externalCountry, showCountry]);\n\n useEffect(() => {\n if (showState) return;\n setSelection((prev) => {\n if (!prev.city) return prev;\n return { ...prev, city: null };\n });\n }, [externalState, showState]);\n\n const publish = (next: GeoSelectorsValue) => {\n setSelection(next);\n onChange?.(next);\n };\n\n const visible = [showCountry, showState, showCity].filter(Boolean).length;\n if (visible === 0) return null;\n\n return (\n <div\n className={cn(\n \"grid gap-4\",\n visible >= 3\n ? \"md:grid-cols-3\"\n : visible === 2\n ? \"md:grid-cols-2\"\n : \"max-w-sm\",\n className,\n )}\n >\n {showCountry ? (\n <div className=\"space-y-1\">\n <Label htmlFor={`${idPrefix}-country`}>País</Label>\n <CountrySelect\n id={`${idPrefix}-country`}\n className=\"w-full\"\n size=\"lg\"\n value={selection.country?.code}\n disabled={disabled}\n onChange={(country) =>\n publish({ country, state: null, city: null })\n }\n allowClear\n onClear={() => publish(EMPTY_VALUE)}\n />\n </div>\n ) : null}\n\n {showState ? (\n <div className=\"space-y-1\">\n <Label htmlFor={`${idPrefix}-state`}>Estado / Región / Municipio</Label>\n <StateSelect\n id={`${idPrefix}-state`}\n countryCode={resolvedCountry}\n value={selection.state?.code ?? \"\"}\n disabled={disabled}\n onChange={(state) =>\n publish({\n country: selection.country,\n state,\n city: null,\n })\n }\n />\n </div>\n ) : null}\n\n {showCity ? (\n <div className=\"space-y-1\">\n <Label htmlFor={`${idPrefix}-city`}>Ciudad / Pueblo / Distrito</Label>\n <CitySelect\n id={`${idPrefix}-city`}\n countryCode={resolvedCountry}\n stateCode={resolvedState}\n value={selection.city?.code ?? selection.city?.name ?? \"\"}\n disabled={disabled}\n onChange={(city) =>\n publish({\n country: selection.country,\n state: selection.state,\n city,\n })\n }\n />\n </div>\n ) : null}\n </div>\n );\n}\n",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"target": ""
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "google-maps-address-picker",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"author": "Traky (https://traky.app)",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@react-google-maps/api",
|
|
8
|
+
"@tabler/icons-react"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"label",
|
|
12
|
+
"lib-google-maps-address"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "ui/google-maps-address-picker.tsx",
|
|
17
|
+
"content": "\"use client\"\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\"\nimport { useJsApiLoader, GoogleMap, Marker } from \"@react-google-maps/api\"\nimport { IconLoader } from \"@tabler/icons-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n parseGoogleAddressComponents,\n type GoogleMapsAddressSelection,\n} from \"@/lib/google-maps-address\"\nimport { Label } from \"@/components/ui/label\"\n\nconst MAP_LIBRARIES: (\"places\")[] = [\"places\"]\nconst DEFAULT_CENTER = { lat: -1.8312, lng: -78.1834 }\nconst DEFAULT_ZOOM = 6\nconst SELECTED_ZOOM = 16\nconst MAP_CONTAINER_STYLE = { width: \"100%\", height: \"min(52dvh, 360px)\" }\n\nfunction applyGooglePlacesLightTheme(\n element: google.maps.places.PlaceAutocompleteElement,\n) {\n element.style.setProperty(\"color-scheme\", \"light\")\n element.style.setProperty(\"background-color\", \"#ffffff\")\n element.style.setProperty(\"color\", \"#141414\")\n element.style.setProperty(\"border\", \"1px solid #e2e8f0\")\n element.style.setProperty(\"border-radius\", \"0.375rem\")\n element.style.setProperty(\"width\", \"100%\")\n element.style.setProperty(\"min-height\", \"2.75rem\")\n element.style.setProperty(\"font-size\", \"0.875rem\")\n}\n\nfunction parsePlaceInstance(\n place: google.maps.places.Place,\n): GoogleMapsAddressSelection | null {\n const lat = place.location?.lat()\n const lng = place.location?.lng()\n if (lat == null || lng == null) return null\n\n const parsed = parseGoogleAddressComponents(\n place.addressComponents as Parameters<\n typeof parseGoogleAddressComponents\n >[0],\n place.displayName || place.formattedAddress || \"\",\n lat,\n lng,\n )\n if (!parsed) return null\n return {\n ...parsed,\n formattedAddress: place.formattedAddress?.trim() || undefined,\n }\n}\n\nexport type GoogleMapsAddressPickerProps = {\n onLocationSelect: (address: GoogleMapsAddressSelection) => void\n onLocationClear?: () => void\n regionCode?: string\n className?: string\n initialLatitude?: number\n initialLongitude?: number\n initialFormattedAddress?: string\n /** Prioridad sobre `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY`. */\n apiKey?: string\n}\n\nfunction GoogleMapsAddressPickerInner({\n onLocationSelect,\n onLocationClear,\n regionCode,\n className,\n initialLatitude,\n initialLongitude,\n initialFormattedAddress,\n}: Omit<GoogleMapsAddressPickerProps, \"apiKey\">) {\n const autocompleteHostRef = useRef<HTMLDivElement>(null)\n const autocompleteElementRef =\n useRef<google.maps.places.PlaceAutocompleteElement | null>(null)\n const geocoderRef = useRef<google.maps.Geocoder | null>(null)\n\n const [searchLabel, setSearchLabel] = useState(\"\")\n const [isGeocoding, setIsGeocoding] = useState(false)\n const [geocodeError, setGeocodeError] = useState<string | null>(null)\n const [marker, setMarker] = useState<google.maps.LatLngLiteral | null>(() => {\n if (\n typeof initialLatitude === \"number\" &&\n typeof initialLongitude === \"number\"\n ) {\n return { lat: initialLatitude, lng: initialLongitude }\n }\n return null\n })\n const [mapCenter, setMapCenter] = useState<google.maps.LatLngLiteral>(\n marker ?? DEFAULT_CENTER,\n )\n const [mapZoom, setMapZoom] = useState(marker ? SELECTED_ZOOM : DEFAULT_ZOOM)\n\n useEffect(() => {\n const lat =\n typeof initialLatitude === \"number\" && Number.isFinite(initialLatitude)\n ? initialLatitude\n : null\n const lng =\n typeof initialLongitude === \"number\" && Number.isFinite(initialLongitude)\n ? initialLongitude\n : null\n if (lat == null || lng == null) return\n const position = { lat, lng }\n setMarker(position)\n setMapCenter(position)\n setMapZoom(SELECTED_ZOOM)\n }, [initialLatitude, initialLongitude])\n\n useEffect(() => {\n const label = initialFormattedAddress?.trim()\n if (!label) return\n setSearchLabel(label)\n if (autocompleteElementRef.current) {\n autocompleteElementRef.current.value = label\n }\n }, [initialFormattedAddress])\n\n const mapOptions = useMemo<google.maps.MapOptions>(\n () => ({\n streetViewControl: false,\n mapTypeControl: false,\n fullscreenControl: false,\n zoomControl: true,\n }),\n [],\n )\n\n const applySelection = useCallback(\n (address: GoogleMapsAddressSelection, label: string) => {\n const position = { lat: address.latitude, lng: address.longitude }\n setMarker(position)\n setMapCenter(position)\n setMapZoom(SELECTED_ZOOM)\n setSearchLabel(label)\n setGeocodeError(null)\n if (autocompleteElementRef.current) {\n autocompleteElementRef.current.value = label\n }\n onLocationSelect({\n ...address,\n formattedAddress: label.trim() || address.formattedAddress,\n })\n },\n [onLocationSelect],\n )\n\n const reverseGeocode = useCallback(\n (position: google.maps.LatLngLiteral) =>\n new Promise<{ address: GoogleMapsAddressSelection; label: string } | null>(\n (resolve) => {\n if (!geocoderRef.current) {\n geocoderRef.current = new google.maps.Geocoder()\n }\n geocoderRef.current.geocode({ location: position }, (results, status) => {\n if (status !== \"OK\" || !results?.[0]) {\n resolve(null)\n return\n }\n const result = results[0]\n const parsed = parseGoogleAddressComponents(\n result.address_components,\n result.formatted_address || \"\",\n position.lat,\n position.lng,\n )\n if (!parsed) {\n resolve(null)\n return\n }\n const label = result.formatted_address?.trim() || \"\"\n resolve({\n address: {\n ...parsed,\n formattedAddress: label || undefined,\n },\n label,\n })\n })\n },\n ),\n [],\n )\n\n const handlePositionChange = useCallback(\n async (position: google.maps.LatLngLiteral) => {\n setMarker(position)\n setMapCenter(position)\n setMapZoom(SELECTED_ZOOM)\n setIsGeocoding(true)\n setGeocodeError(null)\n\n const resolved = await reverseGeocode(position)\n setIsGeocoding(false)\n\n if (resolved) {\n applySelection(resolved.address, resolved.label)\n return\n }\n\n setGeocodeError(\n \"No pudimos resolver esa ubicación. Elige una dirección en el buscador o activa Geocoding API en Google Cloud.\",\n )\n },\n [applySelection, reverseGeocode],\n )\n\n useEffect(() => {\n const host = autocompleteHostRef.current\n if (!host) return\n\n let cancelled = false\n let placeAutocomplete: google.maps.places.PlaceAutocompleteElement | null =\n null\n\n const onSelect: EventListener = (event) => {\n void (async () => {\n const selectEvent =\n event as google.maps.places.PlacePredictionSelectEvent\n const place = selectEvent.placePrediction.toPlace()\n await place.fetchFields({\n fields: [\n \"displayName\",\n \"formattedAddress\",\n \"location\",\n \"addressComponents\",\n ],\n })\n\n const parsed = parsePlaceInstance(place)\n if (!parsed) return\n\n const label =\n place.formattedAddress?.trim() ||\n [\n parsed.streetNumber,\n parsed.street,\n parsed.city,\n parsed.state,\n parsed.postalCode,\n ]\n .filter(Boolean)\n .join(\", \")\n\n applySelection(parsed, label)\n })()\n }\n\n void (async () => {\n await google.maps.importLibrary(\"places\")\n\n if (cancelled || !autocompleteHostRef.current) return\n\n const region = regionCode?.trim().toLowerCase()\n placeAutocomplete = new google.maps.places.PlaceAutocompleteElement({\n ...(region ? { includedRegionCodes: [region] } : {}),\n placeholder: \"Busca tu dirección en Google Maps…\",\n })\n\n placeAutocomplete.className = \"gmp-place-autocomplete-host block w-full\"\n applyGooglePlacesLightTheme(placeAutocomplete)\n\n const savedLabel = initialFormattedAddress?.trim()\n if (savedLabel) {\n placeAutocomplete.value = savedLabel\n setSearchLabel(savedLabel)\n }\n\n host.replaceChildren(placeAutocomplete)\n autocompleteElementRef.current = placeAutocomplete\n placeAutocomplete.addEventListener(\"gmp-select\", onSelect)\n })()\n\n return () => {\n cancelled = true\n if (placeAutocomplete) {\n placeAutocomplete.removeEventListener(\"gmp-select\", onSelect)\n }\n host.replaceChildren()\n autocompleteElementRef.current = null\n }\n }, [applySelection, initialFormattedAddress, regionCode])\n\n const handleClear = () => {\n setMarker(null)\n setSearchLabel(\"\")\n setGeocodeError(null)\n setIsGeocoding(false)\n setMapCenter(DEFAULT_CENTER)\n setMapZoom(DEFAULT_ZOOM)\n if (autocompleteElementRef.current) {\n autocompleteElementRef.current.value = \"\"\n }\n onLocationClear?.()\n }\n\n return (\n <div className={cn(\"space-y-3\", className)}>\n <div className=\"space-y-1.5\">\n <Label>Buscar dirección</Label>\n <div\n ref={autocompleteHostRef}\n className=\"gmp-autocomplete-wrapper w-full rounded-md bg-background\"\n />\n </div>\n\n <div className=\"overflow-hidden rounded-lg border border-border\">\n <GoogleMap\n mapContainerStyle={MAP_CONTAINER_STYLE}\n center={mapCenter}\n zoom={mapZoom}\n onClick={(event) => {\n const latLng = event.latLng\n if (!latLng) return\n void handlePositionChange({ lat: latLng.lat(), lng: latLng.lng() })\n }}\n options={mapOptions}\n >\n {marker ? (\n <Marker\n position={marker}\n draggable\n onDragEnd={(event) => {\n const latLng = event.latLng\n if (!latLng) return\n void handlePositionChange({\n lat: latLng.lat(),\n lng: latLng.lng(),\n })\n }}\n />\n ) : null}\n </GoogleMap>\n </div>\n\n {geocodeError ? (\n <p className=\"text-xs text-destructive\">{geocodeError}</p>\n ) : null}\n\n {marker ? (\n <div className=\"flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground\">\n <span className=\"min-w-0 break-words\">\n {isGeocoding ? (\n <>\n <IconLoader className=\"mr-1 inline size-3 animate-spin\" />\n Actualizando dirección…\n </>\n ) : (\n searchLabel ||\n `${marker.lat.toFixed(5)}, ${marker.lng.toFixed(5)}`\n )}\n </span>\n <button\n type=\"button\"\n className=\"shrink-0 text-primary underline underline-offset-2\"\n onClick={handleClear}\n >\n Limpiar\n </button>\n </div>\n ) : (\n <p className=\"text-xs text-muted-foreground\">\n Busca una dirección o haz clic en el mapa para fijar el punto.\n </p>\n )}\n </div>\n )\n}\n\nfunction resolveGoogleMapsApiKey(apiKey?: string): string {\n if (apiKey?.trim()) return apiKey.trim()\n if (typeof process !== \"undefined\" && process.env) {\n return process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY?.trim() ?? \"\"\n }\n return \"\"\n}\n\nexport function GoogleMapsAddressPicker({\n apiKey,\n ...props\n}: GoogleMapsAddressPickerProps) {\n const resolvedKey = resolveGoogleMapsApiKey(apiKey)\n\n const { isLoaded, loadError } = useJsApiLoader({\n googleMapsApiKey: resolvedKey,\n libraries: MAP_LIBRARIES,\n })\n\n if (!resolvedKey) {\n return (\n <div className=\"rounded-lg border border-dashed border-border bg-muted/30 p-4 text-sm text-muted-foreground\">\n Configura{\" \"}\n <code className=\"text-xs\">NEXT_PUBLIC_GOOGLE_MAPS_API_KEY</code> (solo\n desarrollo) para usar búsqueda y mapa de Google.\n </div>\n )\n }\n\n if (loadError) {\n return (\n <div className=\"rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive\">\n No se pudo cargar Google Maps. Habilita Maps JavaScript API, Places API\n (New) y Geocoding API para esta clave.\n </div>\n )\n }\n\n if (!isLoaded) {\n return (\n <div className=\"flex h-[min(52dvh,360px)] items-center justify-center rounded-lg border bg-muted/30 text-sm text-muted-foreground\">\n <IconLoader className=\"mr-2 size-4 animate-spin\" />\n Cargando mapa…\n </div>\n )\n }\n\n return <GoogleMapsAddressPickerInner {...props} />\n}\n",
|
|
18
|
+
"type": "registry:ui",
|
|
19
|
+
"target": ""
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|