@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@traky-app/ui",
3
- "version": "1.6.3",
3
+ "version": "1.6.4",
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
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "lib-geo-city-codes",
4
+ "type": "registry:lib",
5
+ "author": "Traky (https://traky.app)",
6
+ "files": [
7
+ {
8
+ "path": "lib/geo-city-codes.ts",
9
+ "content": "/**\n * Códigos cortos de ciudad (logística / etiquetas).\n * Clave: `{ISO2}:{NombreCiudad}` → código de 3 letras.\n */\nconst CITY_CODE_OVERRIDES: Record<string, string> = {\n // Ecuador\n \"EC:Guayaquil\": \"GYE\",\n \"EC:Quito\": \"UIO\",\n \"EC:Cuenca\": \"CUE\",\n \"EC:Machala\": \"MCH\",\n \"EC:Manta\": \"MEC\",\n \"EC:Portoviejo\": \"PVO\",\n \"EC:Ambato\": \"ATF\",\n \"EC:Loja\": \"LOH\",\n \"EC:Esmeraldas\": \"ESM\",\n \"EC:Ibarra\": \"IBB\",\n \"EC:Riobamba\": \"RIB\",\n \"EC:Santo Domingo\": \"STD\",\n \"EC:Milagro\": \"MLG\",\n \"EC:Durán\": \"DUR\",\n \"EC:Samborondón\": \"SBO\",\n // Colombia\n \"CO:Bogotá\": \"BOG\",\n \"CO:Bogota\": \"BOG\",\n \"CO:Medellín\": \"MDE\",\n \"CO:Medellin\": \"MDE\",\n \"CO:Cali\": \"CAL\",\n \"CO:Barranquilla\": \"BAQ\",\n \"CO:Cartagena\": \"CTG\",\n \"CO:Bucaramanga\": \"BGA\",\n \"CO:Pereira\": \"PEI\",\n \"CO:Manizales\": \"MZL\",\n \"CO:Cúcuta\": \"CUC\",\n \"CO:Cucuta\": \"CUC\",\n \"CO:Ibagué\": \"IBE\",\n \"CO:Ibague\": \"IBE\",\n \"CO:Santa Marta\": \"SMR\",\n // México\n \"MX:Ciudad de México\": \"CDM\",\n \"MX:Guadalajara\": \"GDL\",\n \"MX:Monterrey\": \"MTY\",\n \"MX:Puebla\": \"PBC\",\n \"MX:Tijuana\": \"TIJ\",\n \"MX:León\": \"BJX\",\n \"MX:Leon\": \"BJX\",\n // Perú\n \"PE:Lima\": \"LIM\",\n \"PE:Arequipa\": \"AQP\",\n \"PE:Trujillo\": \"TRU\",\n \"PE:Cusco\": \"CUZ\",\n // Chile\n \"CL:Santiago\": \"SCL\",\n \"CL:Valparaíso\": \"VAP\",\n \"CL:Valparaiso\": \"VAP\",\n \"CL:Concepción\": \"CCP\",\n \"CL:Concepcion\": \"CCP\",\n // Argentina\n \"AR:Buenos Aires\": \"BUE\",\n \"AR:Córdoba\": \"COR\",\n \"AR:Cordoba\": \"COR\",\n \"AR:Rosario\": \"ROS\",\n \"AR:Mendoza\": \"MDZ\",\n // USA (casilleros)\n \"US:Miami\": \"MIA\",\n \"US:New York\": \"NYC\",\n \"US:Los Angeles\": \"LAX\",\n \"US:Houston\": \"HOU\",\n \"US:Chicago\": \"ORD\",\n // Nicaragua\n \"NI:Chontales\": \"CHO\",\n \"NI:Juigalpa\": \"JUI\",\n \"NI:Managua\": \"MGA\",\n \"NI:León\": \"LEN\",\n \"NI:Leon\": \"LEN\",\n \"NI:Granada\": \"GRA\",\n \"NI:Masaya\": \"MSY\",\n \"NI:Matagalpa\": \"MTG\",\n // Reino Unido\n \"GB:London\": \"LON\",\n \"GB:Greater London\": \"LON\",\n \"GB:Manchester\": \"MAN\",\n \"GB:Birmingham\": \"BHX\",\n \"GB:Liverpool\": \"LIV\",\n \"GB:Leeds\": \"LDS\",\n \"GB:Glasgow\": \"GLA\",\n \"GB:Edinburgh\": \"EDI\",\n \"GB:Cardiff\": \"CDF\",\n \"GB:Belfast\": \"BEL\",\n \"GB:England\": \"ENG\",\n \"GB:Scotland\": \"SCT\",\n \"GB:Wales\": \"WLS\",\n \"GB:Northern Ireland\": \"NIR\",\n // España\n \"ES:Madrid\": \"MAD\",\n \"ES:Barcelona\": \"BCN\",\n \"ES:L'Hospitalet de Llobregat\": \"LHO\",\n \"ES:LHospitalet de Llobregat\": \"LHO\",\n \"ES:Hospitalet de Llobregat\": \"LHO\",\n \"ES:Valencia\": \"VLC\",\n \"ES:Sevilla\": \"SVQ\",\n \"ES:Zaragoza\": \"ZAZ\",\n \"ES:Bilbao\": \"BIO\",\n \"ES:Malaga\": \"AGP\",\n \"ES:Málaga\": \"AGP\",\n \"ES:Murcia\": \"MJV\",\n \"ES:Palma\": \"PMI\",\n \"ES:Las Palmas de Gran Canaria\": \"LPA\",\n \"ES:Las Palmas\": \"LPA\",\n \"ES:Alicante\": \"ALC\",\n \"ES:Cordoba\": \"COR\",\n \"ES:Córdoba\": \"COR\",\n \"ES:Valladolid\": \"VLL\",\n \"ES:Vigo\": \"VGO\",\n \"ES:Gijon\": \"GIJ\",\n \"ES:Gijón\": \"GIJ\",\n \"ES:Oviedo\": \"OVD\",\n \"ES:Leon\": \"LEN\",\n \"ES:León\": \"LEN\",\n \"ES:Burgos\": \"BUR\",\n \"ES:Salamanca\": \"SLM\",\n \"ES:San Sebastian\": \"EAS\",\n \"ES:San Sebastián\": \"EAS\",\n \"ES:Santander\": \"SDR\",\n \"ES:Pamplona\": \"PNA\",\n \"ES:Granada\": \"GRX\",\n \"ES:Toledo\": \"TOL\",\n \"ES:Badajoz\": \"BJZ\",\n \"ES:Castilla y Leon\": \"CLY\",\n \"ES:Castilla y León\": \"CLY\",\n \"ES:Andalucia\": \"AND\",\n \"ES:Andalucía\": \"AND\",\n \"ES:Cataluna\": \"CAT\",\n \"ES:Cataluña\": \"CAT\",\n \"ES:Comunidad Valenciana\": \"VAL\",\n \"ES:Comunidad de Madrid\": \"MAD\",\n \"ES:Region de Murcia\": \"MUR\",\n \"ES:Región de Murcia\": \"MUR\",\n \"ES:Pais Vasco\": \"PV\",\n \"ES:País Vasco\": \"PV\",\n \"ES:Galicia\": \"GAL\",\n \"ES:Extremadura\": \"EXT\",\n \"ES:Asturias\": \"AST\",\n \"ES:Navarra\": \"NAV\",\n \"ES:Cantabria\": \"CAN\",\n \"ES:La Rioja\": \"RIO\",\n \"ES:Islas Baleares\": \"BAL\",\n \"ES:Canarias\": \"CNR\",\n \"ES:Ceuta\": \"CEU\",\n \"ES:Melilla\": \"MEL\",\n // Venezuela\n \"VE:Caracas\": \"CCS\",\n \"VE:Maracaibo\": \"MAR\",\n \"VE:Valencia\": \"VLN\",\n \"VE:Barquisimeto\": \"BRM\",\n \"VE:Maracay\": \"MYC\",\n \"VE:San Cristobal\": \"SCI\",\n \"VE:San Cristóbal\": \"SCI\",\n \"VE:Puerto La Cruz\": \"PLC\",\n \"VE:Maturin\": \"MUN\",\n \"VE:Maturín\": \"MUN\",\n \"VE:Barcelona\": \"BLA\",\n \"VE:Ciudad Guayana\": \"CGY\",\n \"VE:Puerto Ordaz\": \"PZO\",\n \"VE:Merida\": \"MER\",\n \"VE:Mérida\": \"MER\",\n \"VE:Punto Fijo\": \"PFJ\",\n \"VE:Porlamar\": \"PMV\",\n \"VE:San Fernando de Apure\": \"SFA\",\n \"VE:Naguanagua\": \"NGN\",\n \"VE:Lecheria\": \"LEC\",\n \"VE:Lechería\": \"LEC\",\n \"VE:Cumana\": \"CUM\",\n \"VE:Cumaná\": \"CUM\",\n \"VE:Coro\": \"COR\",\n \"VE:Barinas\": \"BAR\",\n \"VE:Trujillo\": \"TRU\",\n \"VE:Valera\": \"VLR\",\n \"VE:San Felipe\": \"SFP\",\n \"VE:La Guaira\": \"LGR\",\n \"VE:Maiquetia\": \"MAI\",\n \"VE:Maiquetía\": \"MAI\",\n \"VE:Distrito Capital\": \"CCS\",\n \"VE:Apure\": \"APU\",\n \"VE:Anzoategui\": \"ANZ\",\n \"VE:Anzoátegui\": \"ANZ\",\n \"VE:Aragua\": \"ARA\",\n \"VE:Bolivar\": \"BOL\",\n \"VE:Bolívar\": \"BOL\",\n \"VE:Carabobo\": \"CAR\",\n \"VE:Miranda\": \"MIR\",\n \"VE:Zulia\": \"ZUL\",\n \"VE:Tachira\": \"TAC\",\n \"VE:Táchira\": \"TAC\",\n \"VE:Lara\": \"LAR\",\n \"VE:Monagas\": \"MON\",\n \"VE:Nueva Esparta\": \"NEP\",\n \"VE:Falcon\": \"FAL\",\n \"VE:Falcón\": \"FAL\",\n \"VE:Guarico\": \"GUA\",\n \"VE:Guárico\": \"GUA\",\n \"VE:Portuguesa\": \"POR\",\n \"VE:Sucre\": \"SUC\",\n \"VE:Yaracuy\": \"YAR\",\n \"VE:Amazonas\": \"AMZ\",\n \"VE:Cojedes\": \"COJ\",\n \"VE:Delta Amacuro\": \"DAM\",\n};\n\nfunction normalizeCityKey(cityName: string): string {\n return cityName\n .normalize(\"NFD\")\n .replace(/\\p{M}/gu, \"\")\n .replace(/[''`´’]/g, \"\")\n .trim();\n}\n\nfunction fallbackCityCode(cityName: string): string {\n const letters = normalizeCityKey(cityName).replace(/[^a-zA-Z]/g, \"\");\n if (letters.length >= 3) return letters.slice(0, 3).toUpperCase();\n return letters.toUpperCase().padEnd(3, \"X\");\n}\n\nexport function resolveCityCode(\n countryCode: string,\n cityName: string,\n): string {\n const cc = countryCode.trim().toUpperCase();\n const normalized = normalizeCityKey(cityName);\n const direct = CITY_CODE_OVERRIDES[`${cc}:${normalized}`];\n if (direct) return direct;\n const withoutAccents = CITY_CODE_OVERRIDES[`${cc}:${cityName.trim()}`];\n if (withoutAccents) return withoutAccents;\n return fallbackCityCode(cityName);\n}\n",
10
+ "type": "registry:lib",
11
+ "target": ""
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "lib-geo-countries",
4
+ "type": "registry:lib",
5
+ "author": "Traky (https://traky.app)",
6
+ "files": [
7
+ {
8
+ "path": "lib/geo-countries.ts",
9
+ "content": "/** Países para UI: nombres vía Intl; catálogo vía `@countrystatecity/countries-browser`. */\n\nexport interface CountryOption {\n code: string;\n short: string;\n long: string;\n}\n\n/** @deprecated Usar `CountryOption`. */\nexport type LatinCountry = CountryOption;\n\nexport function spanishCountryName(isoCode: string, fallback?: string): string {\n const code = isoCode.trim().toUpperCase();\n if (!code) return fallback?.trim() || \"\";\n try {\n const name = new Intl.DisplayNames([\"es\"], { type: \"region\" }).of(code);\n return name?.trim() || fallback?.trim() || code;\n } catch {\n return fallback?.trim() || code;\n }\n}\n\n/** Mapea la respuesta CSC a opciones de UI (nombre en español, orden A–Z). */\nexport function mapGeoCountries(\n apiCountries: Array<{ code: string; name: string }>,\n): CountryOption[] {\n const byCode = new Map<string, CountryOption>();\n\n for (const c of apiCountries) {\n const code = c.code.trim().toUpperCase();\n if (!code) continue;\n byCode.set(code, {\n code,\n short: code,\n long: spanishCountryName(code, c.name),\n });\n }\n\n return [...byCode.values()].sort((a, b) =>\n a.long.localeCompare(b.long, \"es\"),\n );\n}\n\n/** @deprecated Usar `mapGeoCountries`. */\nexport const mergeGeoCountries = mapGeoCountries;\n\nexport function getCountryByCode(\n countryCode?: string | null,\n): CountryOption | undefined {\n if (!countryCode?.trim()) return undefined;\n const normalized = countryCode.trim().toUpperCase();\n return {\n code: normalized,\n short: normalized,\n long: spanishCountryName(normalized, normalized),\n };\n}\n\n/** Nombre en español a partir del código ISO; si no hay match, usa el fallback o el código. */\nexport function getCountryDisplayName(\n countryCode?: string | null,\n fallbackName?: string | null,\n): string {\n if (!countryCode?.trim()) {\n if (fallbackName?.trim()) return fallbackName.trim();\n return \"—\";\n }\n return spanishCountryName(countryCode, fallbackName ?? undefined);\n}\n\n/** Fax en oficinas/direcciones cuando hay país ISO-2. */\nexport function isCommonFaxCountry(countryCode?: string | null): boolean {\n const code = countryCode?.trim().toUpperCase();\n return Boolean(code && /^[A-Z]{2}$/.test(code));\n}\n",
10
+ "type": "registry:lib",
11
+ "target": ""
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "lib-geo-csc",
4
+ "type": "registry:lib",
5
+ "author": "Traky (https://traky.app)",
6
+ "dependencies": [
7
+ "@countrystatecity/countries-browser"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "lib/geo-csc.ts",
12
+ "content": "/**\n * Catálogo geo vía `@countrystatecity/countries-browser` (CDN / fetch).\n * Países, provincias y ciudades; sin JSON embebido en el repo.\n */\nimport {\n getCitiesOfState,\n getCountries,\n getStatesOfCountry,\n} from \"@countrystatecity/countries-browser\";\n\nexport type GeoApiOption = {\n code: string;\n name: string;\n};\n\nexport type GeoApiCityOption = GeoApiOption & {\n stateCode?: string;\n stateName?: string;\n};\n\nexport async function getGeoCountries(): Promise<GeoApiOption[]> {\n try {\n const countries = await getCountries();\n return (countries ?? []).map((c) => ({\n code: c.iso2,\n name: c.name,\n }));\n } catch (error) {\n console.error(\"getGeoCountries:\", error);\n return [];\n }\n}\n\nexport async function getGeoStates(\n countryCode: string,\n): Promise<GeoApiOption[]> {\n const cc = countryCode.trim().toUpperCase();\n if (!cc) return [];\n try {\n const states = await getStatesOfCountry(cc);\n return (states ?? [])\n .filter((s) => Boolean(s.iso2?.trim() && s.name?.trim()))\n .map((s) => ({\n code: s.iso2.trim(),\n name: s.name.trim(),\n }));\n } catch (error) {\n console.error(\"getGeoStates:\", cc, error);\n return [];\n }\n}\n\nexport async function getGeoCities(params: {\n countryCode: string;\n stateCode?: string | null;\n q?: string | null;\n limit?: number;\n}): Promise<GeoApiCityOption[]> {\n const cc = params.countryCode.trim().toUpperCase();\n const stateCode = params.stateCode?.trim();\n if (!cc || !stateCode) return [];\n\n try {\n const cities = await getCitiesOfState(cc, stateCode);\n const q = params.q?.trim().toLowerCase();\n let list = cities ?? [];\n if (q && q.length >= 2) {\n list = list.filter((c) => c.name.toLowerCase().includes(q));\n }\n if (params.limit != null && params.limit > 0) {\n list = list.slice(0, params.limit);\n }\n return list.map((c) => ({\n code: c.name,\n name: c.name,\n stateCode,\n }));\n } catch (error) {\n console.error(\"getGeoCities:\", error);\n return [];\n }\n}\n",
13
+ "type": "registry:lib",
14
+ "target": ""
15
+ }
16
+ ]
17
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "lib-geo-locations",
4
+ "type": "registry:lib",
5
+ "author": "Traky (https://traky.app)",
6
+ "files": [
7
+ {
8
+ "path": "lib/geo-locations.ts",
9
+ "content": "import { getGeoCities, getGeoStates } from \"./geo-csc\";\nimport { resolveCityCode } from \"./geo-city-codes\";\nimport {\n filterStatesForCountry,\n mergeExtraCitiesForState,\n resolveCscStateCode,\n resolveStateDisplayName,\n} from \"./geo-overrides\";\nimport {\n DEPARTMENT_NAME_AS_CITY_COUNTRY_CODES,\n type GeoCityOption,\n type GeoOption,\n} from \"./geo-types\";\nimport { normalizeSearchText } from \"./utils\";\n\nfunction mapApiCity(\n countryCode: string,\n city: {\n name: string;\n stateCode?: string;\n stateName?: string;\n },\n stateNameByCode: Map<string, string>,\n): GeoCityOption {\n const stateCode = city.stateCode ?? \"\";\n return {\n code: resolveCityCode(countryCode, city.name),\n name: city.name,\n stateCode: stateCode || undefined,\n stateName:\n city.stateName ??\n (stateCode ? stateNameByCode.get(stateCode) : undefined),\n };\n}\n\nexport async function fetchStates(countryCode: string): Promise<GeoOption[]> {\n const cc = countryCode.trim().toUpperCase();\n if (!cc) return [];\n const states = await getGeoStates(cc);\n return filterStatesForCountry(\n cc,\n states.map((s) => ({ code: s.code, name: s.name })),\n );\n}\n\nasync function fetchCitiesForStateCode(\n cc: string,\n stateCode: string,\n): Promise<\n Array<{ name: string; stateCode?: string; stateName?: string }>\n> {\n const primary = await getGeoCities({\n countryCode: cc,\n stateCode,\n });\n if (primary.length > 0) return primary;\n\n const mapped = resolveCscStateCode(cc, stateCode);\n if (mapped !== stateCode) {\n return getGeoCities({\n countryCode: cc,\n stateCode: mapped,\n });\n }\n return [];\n}\n\nexport async function fetchCities(\n countryCode: string,\n stateCode?: string | null,\n): Promise<GeoCityOption[]> {\n const cc = countryCode.trim().toUpperCase();\n if (!cc) return [];\n\n // Sin estado no listamos el país entero (catálogo grande).\n if (!stateCode?.trim()) return [];\n\n const code = stateCode.trim();\n const states = await fetchStates(cc);\n const stateNameByCode = new Map(\n states.map((s) => [\n s.code,\n resolveStateDisplayName(cc, s.code, s.name),\n ]),\n );\n\n const apiStateCode = resolveCscStateCode(cc, code);\n const state =\n states.find((s) => s.code === code) ||\n states.find((s) => s.code === apiStateCode);\n\n const fromCatalogRaw = await fetchCitiesForStateCode(cc, code);\n const fromCatalog = fromCatalogRaw.map((c) =>\n mapApiCity(cc, c, stateNameByCode),\n );\n\n const cities = mergeExtraCitiesForState(\n cc,\n code,\n mergeExtraCitiesForState(cc, apiStateCode, fromCatalog),\n );\n\n const withDepartment = (() => {\n if (\n !DEPARTMENT_NAME_AS_CITY_COUNTRY_CODES.has(cc) ||\n !state?.name?.trim()\n ) {\n return cities;\n }\n\n const departmentName = resolveStateDisplayName(\n cc,\n state.code,\n state.name,\n ).trim();\n const alreadyListed = cities.some(\n (city) => city.name.trim().toLowerCase() === departmentName.toLowerCase(),\n );\n if (alreadyListed) return cities;\n\n return [\n ...cities,\n {\n code: resolveCityCode(cc, departmentName),\n name: departmentName,\n stateCode: state.code,\n stateName: state.name,\n },\n ].sort((a, b) => a.name.localeCompare(b.name, \"es\"));\n })();\n\n if (cc === \"ES\" && code !== apiStateCode) {\n const postalName = resolveStateDisplayName(\n cc,\n code,\n state?.name ?? code,\n );\n return withDepartment.map((city) => ({\n ...city,\n stateCode: code,\n stateName: postalName,\n }));\n }\n\n return withDepartment;\n}\n\nfunction geoNameKey(value: string): string {\n return normalizeSearchText(value).replace(/[^a-z0-9]/g, \"\");\n}\n\nexport function findStateByName(\n states: GeoOption[],\n name?: string | null,\n): GeoOption | undefined {\n if (!name?.trim()) return undefined;\n const q = geoNameKey(name);\n const code = name.trim().toUpperCase();\n return states.find(\n (s) => geoNameKey(s.name) === q || s.code.toUpperCase() === code,\n );\n}\n\nexport function findCityByName(\n cities: GeoCityOption[],\n name?: string | null,\n): GeoCityOption | undefined {\n if (!name?.trim()) return undefined;\n const q = geoNameKey(name);\n return cities.find((c) => geoNameKey(c.name) === q);\n}\n",
10
+ "type": "registry:lib",
11
+ "target": ""
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "lib-geo-overrides",
4
+ "type": "registry:lib",
5
+ "author": "Traky (https://traky.app)",
6
+ "files": [
7
+ {
8
+ "path": "lib/geo-overrides.ts",
9
+ "content": "import type { GeoCityOption, GeoOption } from \"./geo-types\";\n\n/**\n * Provincias de España (ISO 3166-2) para direcciones postales.\n * `cscStateCode` es el código de comunidad en el catálogo geo (API / dataset).\n * Castilla y León no trae ciudades en `CL`; el catálogo las agrupa en `LE`.\n */\nexport const ES_PROVINCES: Array<{\n code: string;\n name: string;\n cscStateCode: string;\n}> = [\n { code: \"C\", name: \"A Coruña\", cscStateCode: \"GA\" },\n { code: \"VI\", name: \"Álava\", cscStateCode: \"PV\" },\n { code: \"AB\", name: \"Albacete\", cscStateCode: \"CM\" },\n { code: \"A\", name: \"Alicante\", cscStateCode: \"VC\" },\n { code: \"AL\", name: \"Almería\", cscStateCode: \"AN\" },\n { code: \"O\", name: \"Asturias\", cscStateCode: \"AS\" },\n { code: \"AV\", name: \"Ávila\", cscStateCode: \"LE\" },\n { code: \"BA\", name: \"Badajoz\", cscStateCode: \"EX\" },\n { code: \"PM\", name: \"Baleares\", cscStateCode: \"PM\" },\n { code: \"B\", name: \"Barcelona\", cscStateCode: \"CT\" },\n { code: \"BU\", name: \"Burgos\", cscStateCode: \"LE\" },\n { code: \"CC\", name: \"Cáceres\", cscStateCode: \"EX\" },\n { code: \"CA\", name: \"Cádiz\", cscStateCode: \"AN\" },\n { code: \"S\", name: \"Cantabria\", cscStateCode: \"CB\" },\n { code: \"CS\", name: \"Castellón\", cscStateCode: \"VC\" },\n { code: \"CE\", name: \"Ceuta\", cscStateCode: \"CE\" },\n { code: \"CR\", name: \"Ciudad Real\", cscStateCode: \"CM\" },\n { code: \"CO\", name: \"Córdoba\", cscStateCode: \"AN\" },\n { code: \"CU\", name: \"Cuenca\", cscStateCode: \"CM\" },\n { code: \"SS\", name: \"Gipuzkoa\", cscStateCode: \"PV\" },\n { code: \"GI\", name: \"Girona\", cscStateCode: \"CT\" },\n { code: \"GR\", name: \"Granada\", cscStateCode: \"AN\" },\n { code: \"GU\", name: \"Guadalajara\", cscStateCode: \"CM\" },\n { code: \"H\", name: \"Huelva\", cscStateCode: \"AN\" },\n { code: \"HU\", name: \"Huesca\", cscStateCode: \"AR\" },\n { code: \"J\", name: \"Jaén\", cscStateCode: \"AN\" },\n { code: \"LO\", name: \"La Rioja\", cscStateCode: \"RI\" },\n { code: \"GC\", name: \"Las Palmas\", cscStateCode: \"CN\" },\n { code: \"LE\", name: \"León\", cscStateCode: \"LE\" },\n { code: \"L\", name: \"Lleida\", cscStateCode: \"CT\" },\n { code: \"LU\", name: \"Lugo\", cscStateCode: \"GA\" },\n { code: \"M\", name: \"Madrid\", cscStateCode: \"MD\" },\n { code: \"MA\", name: \"Málaga\", cscStateCode: \"AN\" },\n { code: \"ML\", name: \"Melilla\", cscStateCode: \"ML\" },\n { code: \"MU\", name: \"Murcia\", cscStateCode: \"MC\" },\n { code: \"NA\", name: \"Navarra\", cscStateCode: \"NC\" },\n { code: \"OR\", name: \"Ourense\", cscStateCode: \"GA\" },\n { code: \"P\", name: \"Palencia\", cscStateCode: \"LE\" },\n { code: \"PO\", name: \"Pontevedra\", cscStateCode: \"GA\" },\n { code: \"SA\", name: \"Salamanca\", cscStateCode: \"LE\" },\n { code: \"TF\", name: \"Santa Cruz de Tenerife\", cscStateCode: \"CN\" },\n { code: \"SG\", name: \"Segovia\", cscStateCode: \"LE\" },\n { code: \"SE\", name: \"Sevilla\", cscStateCode: \"AN\" },\n { code: \"SO\", name: \"Soria\", cscStateCode: \"LE\" },\n { code: \"T\", name: \"Tarragona\", cscStateCode: \"CT\" },\n { code: \"TE\", name: \"Teruel\", cscStateCode: \"AR\" },\n { code: \"TO\", name: \"Toledo\", cscStateCode: \"CM\" },\n { code: \"V\", name: \"Valencia\", cscStateCode: \"VC\" },\n { code: \"VA\", name: \"Valladolid\", cscStateCode: \"LE\" },\n { code: \"BI\", name: \"Bizkaia\", cscStateCode: \"PV\" },\n { code: \"ZA\", name: \"Zamora\", cscStateCode: \"LE\" },\n { code: \"Z\", name: \"Zaragoza\", cscStateCode: \"AR\" },\n];\n\n/** Limita el selector de región a códigos concretos (p. ej. naciones del Reino Unido). */\nexport const STATE_LIST_FILTER_BY_COUNTRY: Partial<Record<string, string[]>> = {\n GB: [\"ENG\", \"SCT\", \"WLS\", \"NIR\"],\n VE: [\n \"Z\",\n \"B\",\n \"C\",\n \"D\",\n \"E\",\n \"F\",\n \"G\",\n \"H\",\n \"Y\",\n \"A\",\n \"I\",\n \"J\",\n \"X\",\n \"K\",\n \"M\",\n \"N\",\n \"L\",\n \"O\",\n \"P\",\n \"R\",\n \"T\",\n \"S\",\n \"U\",\n \"V\",\n ],\n};\n\n/** Nombres de región en español para el selector. */\nexport const STATE_DISPLAY_NAME_OVERRIDES: Partial<\n Record<string, Record<string, string>>\n> = {\n ES: {\n AN: \"Andalucía\",\n AR: \"Aragón\",\n AS: \"Asturias\",\n PM: \"Islas Baleares\",\n PV: \"País Vasco\",\n CN: \"Canarias\",\n CB: \"Cantabria\",\n CL: \"Castilla y León\",\n CM: \"Castilla-La Mancha\",\n CT: \"Cataluña\",\n CE: \"Ceuta\",\n EX: \"Extremadura\",\n GA: \"Galicia\",\n RI: \"La Rioja\",\n MD: \"Comunidad de Madrid\",\n ML: \"Melilla\",\n MC: \"Región de Murcia\",\n NC: \"Navarra\",\n VC: \"Comunidad Valenciana\",\n },\n VE: {\n X: \"La Guaira\",\n },\n};\n\n/** Ciudades que faltan en el catálogo para una región concreta. */\nexport const EXTRA_CITIES_BY_STATE: Partial<\n Record<string, Partial<Record<string, GeoCityOption[]>>>\n> = {\n ES: {\n B: [\n {\n code: \"LHO\",\n name: \"L'Hospitalet de Llobregat\",\n stateCode: \"B\",\n stateName: \"Barcelona\",\n },\n {\n code: \"BDL\",\n name: \"Badalona\",\n stateCode: \"B\",\n stateName: \"Barcelona\",\n },\n {\n code: \"BCN\",\n name: \"Barcelona\",\n stateCode: \"B\",\n stateName: \"Barcelona\",\n },\n ],\n AS: [\n {\n code: \"OVD\",\n name: \"Oviedo\",\n stateCode: \"AS\",\n stateName: \"Asturias\",\n },\n {\n code: \"GIJ\",\n name: \"Gijón\",\n stateCode: \"AS\",\n stateName: \"Asturias\",\n },\n {\n code: \"AVS\",\n name: \"Avilés\",\n stateCode: \"AS\",\n stateName: \"Asturias\",\n },\n ],\n CL: [\n {\n code: \"LEN\",\n name: \"León\",\n stateCode: \"CL\",\n stateName: \"Castilla y León\",\n },\n {\n code: \"BUR\",\n name: \"Burgos\",\n stateCode: \"CL\",\n stateName: \"Castilla y León\",\n },\n {\n code: \"SLM\",\n name: \"Salamanca\",\n stateCode: \"CL\",\n stateName: \"Castilla y León\",\n },\n {\n code: \"VLL\",\n name: \"Valladolid\",\n stateCode: \"CL\",\n stateName: \"Castilla y León\",\n },\n {\n code: \"SEG\",\n name: \"Segovia\",\n stateCode: \"CL\",\n stateName: \"Castilla y León\",\n },\n {\n code: \"SOR\",\n name: \"Soria\",\n stateCode: \"CL\",\n stateName: \"Castilla y León\",\n },\n {\n code: \"ZMR\",\n name: \"Zamora\",\n stateCode: \"CL\",\n stateName: \"Castilla y León\",\n },\n {\n code: \"AVL\",\n name: \"Ávila\",\n stateCode: \"CL\",\n stateName: \"Castilla y León\",\n },\n {\n code: \"PAL\",\n name: \"Palencia\",\n stateCode: \"CL\",\n stateName: \"Castilla y León\",\n },\n ],\n },\n VE: {\n C: [\n {\n code: \"SFA\",\n name: \"San Fernando de Apure\",\n stateCode: \"C\",\n stateName: \"Apure\",\n },\n {\n code: \"GUA\",\n name: \"Guasdualito\",\n stateCode: \"C\",\n stateName: \"Apure\",\n },\n ],\n G: [\n {\n code: \"NGN\",\n name: \"Naguanagua\",\n stateCode: \"G\",\n stateName: \"Carabobo\",\n },\n ],\n B: [\n {\n code: \"LEC\",\n name: \"Lechería\",\n stateCode: \"B\",\n stateName: \"Anzoátegui\",\n },\n ],\n F: [\n {\n code: \"PZO\",\n name: \"Puerto Ordaz\",\n stateCode: \"F\",\n stateName: \"Bolívar\",\n },\n ],\n },\n GB: {\n NIR: [\n {\n code: \"BEL\",\n name: \"Belfast\",\n stateCode: \"NIR\",\n stateName: \"Northern Ireland\",\n },\n {\n code: \"DRY\",\n name: \"Derry\",\n stateCode: \"NIR\",\n stateName: \"Northern Ireland\",\n },\n {\n code: \"LSB\",\n name: \"Lisburn\",\n stateCode: \"NIR\",\n stateName: \"Northern Ireland\",\n },\n {\n code: \"NEW\",\n name: \"Newry\",\n stateCode: \"NIR\",\n stateName: \"Northern Ireland\",\n },\n ],\n },\n};\n\nexport function resolveCscStateCode(\n countryCode: string,\n stateCode: string,\n): string {\n const cc = countryCode.trim().toUpperCase();\n const code = stateCode.trim();\n if (!code) return code;\n if (cc === \"ES\") {\n return (\n ES_PROVINCES.find((province) => province.code === code)?.cscStateCode ??\n code\n );\n }\n return code;\n}\n\nexport function resolveStateDisplayName(\n countryCode: string,\n stateCode: string,\n defaultName: string,\n): string {\n const cc = countryCode.trim().toUpperCase();\n const code = stateCode.trim();\n const provinceName = ES_PROVINCES.find(\n (province) => cc === \"ES\" && province.code === code,\n )?.name;\n return (\n provinceName ||\n STATE_DISPLAY_NAME_OVERRIDES[cc]?.[code]?.trim() ||\n defaultName\n );\n}\n\nexport function filterStatesForCountry(\n countryCode: string,\n states: GeoOption[],\n): GeoOption[] {\n const cc = countryCode.trim().toUpperCase();\n if (cc === \"ES\") {\n return ES_PROVINCES.map((province) => ({\n code: province.code,\n name: province.name,\n }));\n }\n\n const allowed = STATE_LIST_FILTER_BY_COUNTRY[cc];\n const filtered = allowed?.length\n ? states.filter((state) => new Set(allowed).has(state.code))\n : states;\n\n return filtered.map((state) => ({\n ...state,\n name: resolveStateDisplayName(cc, state.code, state.name),\n }));\n}\n\nexport function mergeExtraCitiesForState(\n countryCode: string,\n stateCode: string,\n cities: GeoCityOption[],\n): GeoCityOption[] {\n const extras = EXTRA_CITIES_BY_STATE[countryCode]?.[stateCode];\n if (!extras?.length) return cities;\n\n const seen = new Set(cities.map((city) => city.name.trim().toLowerCase()));\n const merged = [...cities];\n\n for (const city of extras) {\n const key = city.name.trim().toLowerCase();\n if (seen.has(key)) continue;\n seen.add(key);\n merged.push(city);\n }\n\n return merged.sort((a, b) => a.name.localeCompare(b.name, \"es\"));\n}\n",
10
+ "type": "registry:lib",
11
+ "target": ""
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "lib-geo-types",
4
+ "type": "registry:lib",
5
+ "author": "Traky (https://traky.app)",
6
+ "files": [
7
+ {
8
+ "path": "lib/geo-types.ts",
9
+ "content": "export type GeoOption = {\n code: string;\n name: string;\n};\n\nexport type GeoCityOption = GeoOption & {\n stateCode?: string;\n stateName?: string;\n};\n\n/**\n * Países donde el formulario usa catálogo CSC (CDN) con provincia/estado primero.\n */\nexport function usesGeoSearch(countryCode?: string | null): boolean {\n return Boolean(countryCode?.trim() && countryCode.trim().length === 2);\n}\n\n/** Provincia/estado antes que ciudad (evita listar ciudades de país entero). */\nexport function isStateFirstCountry(countryCode?: string | null): boolean {\n return usesGeoSearch(countryCode);\n}\n\n/**\n * Países donde el nombre del departamento se ofrece también como ciudad\n * (p. ej. Chontales en Nicaragua no existe como municipio en el catálogo).\n */\nexport const DEPARTMENT_NAME_AS_CITY_COUNTRY_CODES = new Set([\n \"CO\",\n \"ES\",\n \"GB\",\n \"NI\",\n \"PE\",\n \"VE\",\n]);\n",
10
+ "type": "registry:lib",
11
+ "target": ""
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "lib-google-maps-address",
4
+ "type": "registry:lib",
5
+ "author": "Traky (https://traky.app)",
6
+ "files": [
7
+ {
8
+ "path": "lib/google-maps-address.ts",
9
+ "content": "export const OFFICE_ADDRESS_GEOCODE_SOURCE_GOOGLE_MAPS = \"google_maps\" as const\n\nexport type GoogleMapsAddressSelection = {\n street: string\n streetNumber?: string\n city: string\n state?: string\n postalCode?: string\n countryCode?: string\n formattedAddress?: string\n latitude: number\n longitude: number\n}\n\ntype AddressComponentLike = {\n types: string[]\n long_name?: string\n short_name?: string\n longText?: string\n shortText?: string\n}\n\nexport function parseGoogleAddressComponents(\n components: AddressComponentLike[] | undefined,\n fallbackStreet: string,\n latitude: number,\n longitude: number,\n): GoogleMapsAddressSelection | null {\n let streetNumber = \"\"\n let route = \"\"\n let city = \"\"\n let state = \"\"\n let postalCode = \"\"\n let countryCode = \"\"\n\n for (const component of components ?? []) {\n const types = component.types\n const longName = component.long_name ?? component.longText ?? \"\"\n const shortName = component.short_name ?? component.shortText ?? \"\"\n\n if (types.includes(\"street_number\")) streetNumber = longName\n if (types.includes(\"route\")) route = longName\n if (types.includes(\"locality\")) city = longName\n else if (\n !city &&\n (types.includes(\"sublocality\") || types.includes(\"postal_town\"))\n ) {\n city = longName\n }\n if (types.includes(\"administrative_area_level_1\")) {\n state = longName || shortName\n }\n if (types.includes(\"postal_code\")) postalCode = longName\n if (types.includes(\"country\")) countryCode = shortName.toUpperCase()\n }\n\n const street = route || fallbackStreet\n if (!street.trim() && !fallbackStreet.trim()) return null\n\n return {\n street: street.trim() || fallbackStreet.trim(),\n streetNumber: streetNumber.trim() || undefined,\n city: city.trim(),\n state: state.trim() || undefined,\n postalCode: postalCode.trim() || undefined,\n countryCode: countryCode.trim() || undefined,\n latitude,\n longitude,\n }\n}\n\nexport function googleMapsSelectionToFormPatch(\n selection: GoogleMapsAddressSelection,\n): Pick<\n GoogleMapsAddressSelection,\n \"latitude\" | \"longitude\"\n> & {\n address: string\n city: string\n stateOrProvince: string\n postalCode: string\n countryCode: string\n addressGeocodeSource: string\n} {\n const addressLine =\n [selection.streetNumber, selection.street].filter(Boolean).join(\" \").trim() ||\n selection.formattedAddress?.split(\",\")[0]?.trim() ||\n selection.street\n\n return {\n address: addressLine,\n city: selection.city,\n stateOrProvince: selection.state ?? \"\",\n postalCode: selection.postalCode ?? \"\",\n countryCode: selection.countryCode?.trim().toUpperCase() ?? \"\",\n latitude: selection.latitude,\n longitude: selection.longitude,\n addressGeocodeSource: OFFICE_ADDRESS_GEOCODE_SOURCE_GOOGLE_MAPS,\n }\n}\n",
10
+ "type": "registry:lib",
11
+ "target": ""
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "lib-phone-countries",
4
+ "type": "registry:lib",
5
+ "author": "Traky (https://traky.app)",
6
+ "files": [
7
+ {
8
+ "path": "lib/phone-countries.ts",
9
+ "content": "export type PhoneCountry = {\n iso: string;\n name: string;\n dialCode: string;\n /** Solo dígitos nacionales (sin código de país). */\n nationalMinLength: number;\n nationalMaxLength: number;\n};\n\n/**\n * Catálogo mundial de códigos telefónicos (flags en /public/assets/flags).\n * Prioriza LATAM / uso frecuente arriba; el resto va A–Z en español.\n */\nexport const PHONE_COUNTRIES: PhoneCountry[] = [\n { iso: \"EC\", name: \"Ecuador\", dialCode: \"+593\", nationalMinLength: 9, nationalMaxLength: 9 },\n { iso: \"CO\", name: \"Colombia\", dialCode: \"+57\", nationalMinLength: 10, nationalMaxLength: 10 },\n { iso: \"MX\", name: \"México\", dialCode: \"+52\", nationalMinLength: 10, nationalMaxLength: 11 },\n { iso: \"PE\", name: \"Perú\", dialCode: \"+51\", nationalMinLength: 9, nationalMaxLength: 9 },\n { iso: \"CL\", name: \"Chile\", dialCode: \"+56\", nationalMinLength: 9, nationalMaxLength: 9 },\n { iso: \"AR\", name: \"Argentina\", dialCode: \"+54\", nationalMinLength: 10, nationalMaxLength: 11 },\n { iso: \"US\", name: \"Estados Unidos\", dialCode: \"+1\", nationalMinLength: 10, nationalMaxLength: 10 },\n { iso: \"CA\", name: \"Canadá\", dialCode: \"+1\", nationalMinLength: 10, nationalMaxLength: 10 },\n { iso: \"ES\", name: \"España\", dialCode: \"+34\", nationalMinLength: 9, nationalMaxLength: 9 },\n { iso: \"BR\", name: \"Brasil\", dialCode: \"+55\", nationalMinLength: 10, nationalMaxLength: 11 },\n { iso: \"VE\", name: \"Venezuela\", dialCode: \"+58\", nationalMinLength: 10, nationalMaxLength: 10 },\n { iso: \"PA\", name: \"Panamá\", dialCode: \"+507\", nationalMinLength: 8, nationalMaxLength: 8 },\n { iso: \"CR\", name: \"Costa Rica\", dialCode: \"+506\", nationalMinLength: 8, nationalMaxLength: 8 },\n { iso: \"GT\", name: \"Guatemala\", dialCode: \"+502\", nationalMinLength: 8, nationalMaxLength: 8 },\n { iso: \"HN\", name: \"Honduras\", dialCode: \"+504\", nationalMinLength: 8, nationalMaxLength: 8 },\n { iso: \"SV\", name: \"El Salvador\", dialCode: \"+503\", nationalMinLength: 8, nationalMaxLength: 8 },\n { iso: \"NI\", name: \"Nicaragua\", dialCode: \"+505\", nationalMinLength: 8, nationalMaxLength: 8 },\n { iso: \"BO\", name: \"Bolivia\", dialCode: \"+591\", nationalMinLength: 8, nationalMaxLength: 8 },\n { iso: \"PY\", name: \"Paraguay\", dialCode: \"+595\", nationalMinLength: 9, nationalMaxLength: 9 },\n { iso: \"UY\", name: \"Uruguay\", dialCode: \"+598\", nationalMinLength: 8, nationalMaxLength: 8 },\n { iso: \"DO\", name: \"Rep. Dominicana\", dialCode: \"+1\", nationalMinLength: 10, nationalMaxLength: 10 },\n { iso: \"PR\", name: \"Puerto Rico\", dialCode: \"+1\", nationalMinLength: 10, nationalMaxLength: 10 },\n { iso: \"AF\", name: \"Afganistán\", dialCode: \"+93\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"AX\", name: \"Aland Islands\", dialCode: \"+358\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"AL\", name: \"Albania\", dialCode: \"+355\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"DE\", name: \"Alemania\", dialCode: \"+49\", nationalMinLength: 10, nationalMaxLength: 11 },\n { iso: \"AD\", name: \"Andorra\", dialCode: \"+376\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"AO\", name: \"Angola\", dialCode: \"+244\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"AI\", name: \"Anguila\", dialCode: \"+1\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"AG\", name: \"Antigua y Barbuda\", dialCode: \"+1\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"SA\", name: \"Arabia Saudita\", dialCode: \"+966\", nationalMinLength: 9, nationalMaxLength: 9 },\n { iso: \"DZ\", name: \"Argelia\", dialCode: \"+213\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"AM\", name: \"Armenia\", dialCode: \"+374\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"AW\", name: \"Aruba\", dialCode: \"+297\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"AU\", name: \"Australia\", dialCode: \"+61\", nationalMinLength: 9, nationalMaxLength: 9 },\n { iso: \"AT\", name: \"Austria\", dialCode: \"+43\", nationalMinLength: 10, nationalMaxLength: 11 },\n { iso: \"AZ\", name: \"Azerbaiyán\", dialCode: \"+994\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"BS\", name: \"Bahamas\", dialCode: \"+1\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"BD\", name: \"Bangladés\", dialCode: \"+880\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"BB\", name: \"Barbados\", dialCode: \"+1\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"BH\", name: \"Baréin\", dialCode: \"+973\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"BE\", name: \"Bélgica\", dialCode: \"+32\", nationalMinLength: 9, nationalMaxLength: 9 },\n { iso: \"BZ\", name: \"Belice\", dialCode: \"+501\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"BJ\", name: \"Benín\", dialCode: \"+229\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"BM\", name: \"Bermudas\", dialCode: \"+1\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"BY\", name: \"Bielorrusia\", dialCode: \"+375\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"BQ\", name: \"Bonaire, Sint Eustatius and Saba\", dialCode: \"+599\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"BA\", name: \"Bosnia y Herzegovina\", dialCode: \"+387\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"BW\", name: \"Botsuana\", dialCode: \"+267\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"BV\", name: \"Bouvet Island\", dialCode: \"+0055\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"IO\", name: \"British Indian Ocean Territory\", dialCode: \"+246\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"BN\", name: \"Brunéi\", dialCode: \"+673\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"BG\", name: \"Bulgaria\", dialCode: \"+359\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"BF\", name: \"Burkina Faso\", dialCode: \"+226\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"BI\", name: \"Burundi\", dialCode: \"+257\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"BT\", name: \"Bután\", dialCode: \"+975\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"CV\", name: \"Cabo Verde\", dialCode: \"+238\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"KH\", name: \"Camboya\", dialCode: \"+855\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"CM\", name: \"Camerún\", dialCode: \"+237\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"QA\", name: \"Catar\", dialCode: \"+974\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"KY\", name: \"Cayman Islands\", dialCode: \"+1\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"TD\", name: \"Chad\", dialCode: \"+235\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"CN\", name: \"China\", dialCode: \"+86\", nationalMinLength: 11, nationalMaxLength: 11 },\n { iso: \"CY\", name: \"Chipre\", dialCode: \"+357\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"CX\", name: \"Christmas Island\", dialCode: \"+61\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"VA\", name: \"Ciudad del Vaticano\", dialCode: \"+379\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"CC\", name: \"Cocos (Keeling) Islands\", dialCode: \"+61\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"KM\", name: \"Comoras\", dialCode: \"+269\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"CG\", name: \"Congo\", dialCode: \"+242\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"CD\", name: \"Congo (RDC)\", dialCode: \"+243\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"CK\", name: \"Cook Islands\", dialCode: \"+682\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"KP\", name: \"Corea del Norte\", dialCode: \"+850\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"KR\", name: \"Corea del Sur\", dialCode: \"+82\", nationalMinLength: 9, nationalMaxLength: 10 },\n { iso: \"CI\", name: \"Costa de Marfil\", dialCode: \"+225\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"HR\", name: \"Croacia\", dialCode: \"+385\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"CU\", name: \"Cuba\", dialCode: \"+53\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"CW\", name: \"Curazao\", dialCode: \"+599\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"DK\", name: \"Dinamarca\", dialCode: \"+45\", nationalMinLength: 8, nationalMaxLength: 8 },\n { iso: \"DM\", name: \"Dominica\", dialCode: \"+1\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"EG\", name: \"Egipto\", dialCode: \"+20\", nationalMinLength: 10, nationalMaxLength: 10 },\n { iso: \"AE\", name: \"Emiratos Árabes Unidos\", dialCode: \"+971\", nationalMinLength: 9, nationalMaxLength: 9 },\n { iso: \"ER\", name: \"Eritrea\", dialCode: \"+291\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"SK\", name: \"Eslovaquia\", dialCode: \"+421\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"SI\", name: \"Eslovenia\", dialCode: \"+386\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"EE\", name: \"Estonia\", dialCode: \"+372\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"ET\", name: \"Etiopía\", dialCode: \"+251\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"FK\", name: \"Falkland Islands\", dialCode: \"+500\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"FO\", name: \"Faroe Islands\", dialCode: \"+298\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"PH\", name: \"Filipinas\", dialCode: \"+63\", nationalMinLength: 10, nationalMaxLength: 10 },\n { iso: \"FI\", name: \"Finlandia\", dialCode: \"+358\", nationalMinLength: 9, nationalMaxLength: 10 },\n { iso: \"FJ\", name: \"Fiyi\", dialCode: \"+679\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"FR\", name: \"Francia\", dialCode: \"+33\", nationalMinLength: 9, nationalMaxLength: 9 },\n { iso: \"TF\", name: \"French Southern Territories\", dialCode: \"+262\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"GA\", name: \"Gabón\", dialCode: \"+241\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"GM\", name: \"Gambia\", dialCode: \"+220\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"GE\", name: \"Georgia\", dialCode: \"+995\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"GH\", name: \"Ghana\", dialCode: \"+233\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"GI\", name: \"Gibraltar\", dialCode: \"+350\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"GD\", name: \"Granada\", dialCode: \"+1\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"GR\", name: \"Grecia\", dialCode: \"+30\", nationalMinLength: 10, nationalMaxLength: 10 },\n { iso: \"GL\", name: \"Groenlandia\", dialCode: \"+299\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"GP\", name: \"Guadalupe\", dialCode: \"+590\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"GU\", name: \"Guam\", dialCode: \"+1\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"GF\", name: \"Guayana Francesa\", dialCode: \"+594\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"GG\", name: \"Guernsey\", dialCode: \"+44\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"GN\", name: \"Guinea\", dialCode: \"+224\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"GQ\", name: \"Guinea Ecuatorial\", dialCode: \"+240\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"GW\", name: \"Guinea-Bisáu\", dialCode: \"+245\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"GY\", name: \"Guyana\", dialCode: \"+592\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"HT\", name: \"Haití\", dialCode: \"+509\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"HM\", name: \"Heard Island and McDonald Islands\", dialCode: \"+672\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"HK\", name: \"Hong Kong\", dialCode: \"+852\", nationalMinLength: 8, nationalMaxLength: 8 },\n { iso: \"HU\", name: \"Hungría\", dialCode: \"+36\", nationalMinLength: 9, nationalMaxLength: 9 },\n { iso: \"IN\", name: \"India\", dialCode: \"+91\", nationalMinLength: 10, nationalMaxLength: 10 },\n { iso: \"ID\", name: \"Indonesia\", dialCode: \"+62\", nationalMinLength: 9, nationalMaxLength: 12 },\n { iso: \"IQ\", name: \"Irak\", dialCode: \"+964\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"IR\", name: \"Irán\", dialCode: \"+98\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"IE\", name: \"Irlanda\", dialCode: \"+353\", nationalMinLength: 9, nationalMaxLength: 9 },\n { iso: \"IS\", name: \"Islandia\", dialCode: \"+354\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"IL\", name: \"Israel\", dialCode: \"+972\", nationalMinLength: 9, nationalMaxLength: 9 },\n { iso: \"IT\", name: \"Italia\", dialCode: \"+39\", nationalMinLength: 9, nationalMaxLength: 10 },\n { iso: \"JM\", name: \"Jamaica\", dialCode: \"+1\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"JP\", name: \"Japón\", dialCode: \"+81\", nationalMinLength: 10, nationalMaxLength: 10 },\n { iso: \"JE\", name: \"Jersey\", dialCode: \"+44\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"JO\", name: \"Jordania\", dialCode: \"+962\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"KZ\", name: \"Kazajistán\", dialCode: \"+7\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"KE\", name: \"Kenia\", dialCode: \"+254\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"KG\", name: \"Kirguistán\", dialCode: \"+996\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"KI\", name: \"Kiribati\", dialCode: \"+686\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"XK\", name: \"Kosovo\", dialCode: \"+383\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"KW\", name: \"Kuwait\", dialCode: \"+965\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"LA\", name: \"Laos\", dialCode: \"+856\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"LS\", name: \"Lesoto\", dialCode: \"+266\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"LV\", name: \"Letonia\", dialCode: \"+371\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"LB\", name: \"Líbano\", dialCode: \"+961\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"LR\", name: \"Liberia\", dialCode: \"+231\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"LY\", name: \"Libia\", dialCode: \"+218\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"LI\", name: \"Liechtenstein\", dialCode: \"+423\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"LT\", name: \"Lituania\", dialCode: \"+370\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"LU\", name: \"Luxemburgo\", dialCode: \"+352\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"MO\", name: \"Macao\", dialCode: \"+853\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"MK\", name: \"Macedonia del Norte\", dialCode: \"+389\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"MG\", name: \"Madagascar\", dialCode: \"+261\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"MY\", name: \"Malasia\", dialCode: \"+60\", nationalMinLength: 9, nationalMaxLength: 10 },\n { iso: \"MW\", name: \"Malaui\", dialCode: \"+265\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"MV\", name: \"Maldivas\", dialCode: \"+960\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"ML\", name: \"Malí\", dialCode: \"+223\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"MT\", name: \"Malta\", dialCode: \"+356\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"IM\", name: \"Man (Isle of)\", dialCode: \"+44\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"MA\", name: \"Marruecos\", dialCode: \"+212\", nationalMinLength: 9, nationalMaxLength: 9 },\n { iso: \"MH\", name: \"Marshall Islands\", dialCode: \"+692\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"MQ\", name: \"Martinica\", dialCode: \"+596\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"MU\", name: \"Mauricio\", dialCode: \"+230\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"MR\", name: \"Mauritania\", dialCode: \"+222\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"YT\", name: \"Mayotte\", dialCode: \"+262\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"FM\", name: \"Micronesia\", dialCode: \"+691\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"MD\", name: \"Moldavia\", dialCode: \"+373\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"MC\", name: \"Mónaco\", dialCode: \"+377\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"MN\", name: \"Mongolia\", dialCode: \"+976\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"ME\", name: \"Montenegro\", dialCode: \"+382\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"MS\", name: \"Montserrat\", dialCode: \"+1\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"MZ\", name: \"Mozambique\", dialCode: \"+258\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"MM\", name: \"Myanmar\", dialCode: \"+95\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"NA\", name: \"Namibia\", dialCode: \"+264\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"NR\", name: \"Nauru\", dialCode: \"+674\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"NP\", name: \"Nepal\", dialCode: \"+977\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"NE\", name: \"Níger\", dialCode: \"+227\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"NG\", name: \"Nigeria\", dialCode: \"+234\", nationalMinLength: 10, nationalMaxLength: 10 },\n { iso: \"NU\", name: \"Niue\", dialCode: \"+683\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"NF\", name: \"Norfolk Island\", dialCode: \"+672\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"MP\", name: \"Northern Mariana Islands\", dialCode: \"+1\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"NO\", name: \"Noruega\", dialCode: \"+47\", nationalMinLength: 8, nationalMaxLength: 8 },\n { iso: \"NC\", name: \"Nueva Caledonia\", dialCode: \"+687\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"NZ\", name: \"Nueva Zelanda\", dialCode: \"+64\", nationalMinLength: 8, nationalMaxLength: 10 },\n { iso: \"OM\", name: \"Omán\", dialCode: \"+968\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"NL\", name: \"Países Bajos\", dialCode: \"+31\", nationalMinLength: 9, nationalMaxLength: 9 },\n { iso: \"PK\", name: \"Pakistán\", dialCode: \"+92\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"PW\", name: \"Palaos\", dialCode: \"+680\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"PS\", name: \"Palestina\", dialCode: \"+970\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"PG\", name: \"Papúa Nueva Guinea\", dialCode: \"+675\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"PN\", name: \"Pitcairn Island\", dialCode: \"+870\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"PF\", name: \"Polinesia Francesa\", dialCode: \"+689\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"PL\", name: \"Polonia\", dialCode: \"+48\", nationalMinLength: 9, nationalMaxLength: 9 },\n { iso: \"PT\", name: \"Portugal\", dialCode: \"+351\", nationalMinLength: 9, nationalMaxLength: 9 },\n { iso: \"GB\", name: \"Reino Unido\", dialCode: \"+44\", nationalMinLength: 10, nationalMaxLength: 10 },\n { iso: \"CZ\", name: \"Rep. Checa\", dialCode: \"+420\", nationalMinLength: 9, nationalMaxLength: 9 },\n { iso: \"CF\", name: \"República Centroafricana\", dialCode: \"+236\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"RE\", name: \"Reunión\", dialCode: \"+262\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"RW\", name: \"Ruanda\", dialCode: \"+250\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"RO\", name: \"Rumanía\", dialCode: \"+40\", nationalMinLength: 9, nationalMaxLength: 9 },\n { iso: \"RU\", name: \"Rusia\", dialCode: \"+7\", nationalMinLength: 10, nationalMaxLength: 10 },\n { iso: \"EH\", name: \"Sáhara Occidental\", dialCode: \"+212\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"WS\", name: \"Samoa\", dialCode: \"+685\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"AS\", name: \"Samoa Americana\", dialCode: \"+1\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"BL\", name: \"San Bartolomé\", dialCode: \"+590\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"KN\", name: \"San Cristóbal y Nieves\", dialCode: \"+1\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"SM\", name: \"San Marino\", dialCode: \"+378\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"MF\", name: \"San Martín\", dialCode: \"+590\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"PM\", name: \"San Pedro y Miquelón\", dialCode: \"+508\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"VC\", name: \"San Vicente y las Granadinas\", dialCode: \"+1\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"SH\", name: \"Santa Elena\", dialCode: \"+290\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"LC\", name: \"Santa Lucía\", dialCode: \"+1\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"ST\", name: \"Santo Tomé y Príncipe\", dialCode: \"+239\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"SN\", name: \"Senegal\", dialCode: \"+221\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"RS\", name: \"Serbia\", dialCode: \"+381\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"SC\", name: \"Seychelles\", dialCode: \"+248\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"SL\", name: \"Sierra Leona\", dialCode: \"+232\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"SG\", name: \"Singapur\", dialCode: \"+65\", nationalMinLength: 8, nationalMaxLength: 8 },\n { iso: \"SX\", name: \"Sint Maarten\", dialCode: \"+1721\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"SY\", name: \"Siria\", dialCode: \"+963\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"SB\", name: \"Solomon Islands\", dialCode: \"+677\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"SO\", name: \"Somalia\", dialCode: \"+252\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"GS\", name: \"South Georgia\", dialCode: \"+500\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"LK\", name: \"Sri Lanka\", dialCode: \"+94\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"SZ\", name: \"Suazilandia\", dialCode: \"+268\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"ZA\", name: \"Sudáfrica\", dialCode: \"+27\", nationalMinLength: 9, nationalMaxLength: 9 },\n { iso: \"SD\", name: \"Sudán\", dialCode: \"+249\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"SS\", name: \"Sudán del Sur\", dialCode: \"+211\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"SE\", name: \"Suecia\", dialCode: \"+46\", nationalMinLength: 9, nationalMaxLength: 9 },\n { iso: \"CH\", name: \"Suiza\", dialCode: \"+41\", nationalMinLength: 9, nationalMaxLength: 9 },\n { iso: \"SR\", name: \"Surinam\", dialCode: \"+597\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"SJ\", name: \"Svalbard and Jan Mayen Islands\", dialCode: \"+47\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"TH\", name: \"Tailandia\", dialCode: \"+66\", nationalMinLength: 9, nationalMaxLength: 9 },\n { iso: \"TW\", name: \"Taiwán\", dialCode: \"+886\", nationalMinLength: 9, nationalMaxLength: 9 },\n { iso: \"TZ\", name: \"Tanzania\", dialCode: \"+255\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"TJ\", name: \"Tayikistán\", dialCode: \"+992\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"TL\", name: \"Timor Oriental\", dialCode: \"+670\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"TG\", name: \"Togo\", dialCode: \"+228\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"TK\", name: \"Tokelau\", dialCode: \"+690\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"TO\", name: \"Tonga\", dialCode: \"+676\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"TT\", name: \"Trinidad y Tobago\", dialCode: \"+1\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"TN\", name: \"Túnez\", dialCode: \"+216\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"TM\", name: \"Turkmenistán\", dialCode: \"+993\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"TC\", name: \"Turks and Caicos Islands\", dialCode: \"+1\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"TR\", name: \"Turquía\", dialCode: \"+90\", nationalMinLength: 10, nationalMaxLength: 10 },\n { iso: \"TV\", name: \"Tuvalu\", dialCode: \"+688\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"UA\", name: \"Ucrania\", dialCode: \"+380\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"UG\", name: \"Uganda\", dialCode: \"+256\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"UM\", name: \"United States Minor Outlying Islands\", dialCode: \"+1\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"UZ\", name: \"Uzbekistán\", dialCode: \"+998\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"VU\", name: \"Vanuatu\", dialCode: \"+678\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"VN\", name: \"Vietnam\", dialCode: \"+84\", nationalMinLength: 9, nationalMaxLength: 10 },\n { iso: \"VG\", name: \"Virgin Islands (British)\", dialCode: \"+1\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"VI\", name: \"Virgin Islands (US)\", dialCode: \"+1\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"WF\", name: \"Wallis and Futuna Islands\", dialCode: \"+681\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"YE\", name: \"Yemen\", dialCode: \"+967\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"DJ\", name: \"Yibuti\", dialCode: \"+253\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"ZM\", name: \"Zambia\", dialCode: \"+260\", nationalMinLength: 7, nationalMaxLength: 15 },\n { iso: \"ZW\", name: \"Zimbabue\", dialCode: \"+263\", nationalMinLength: 7, nationalMaxLength: 15 },\n];\n\nexport const DEFAULT_PHONE_COUNTRY_ISO = \"EC\";\n\nexport function getPhoneCountryByIso(\n iso?: string | null,\n): PhoneCountry | undefined {\n if (!iso) return undefined;\n return PHONE_COUNTRIES.find((c) => c.iso === iso);\n}\n\nexport function getPhoneCountryByDialCode(dialCode: string): PhoneCountry | undefined {\n const normalized = dialCode.startsWith(\"+\") ? dialCode : `+${dialCode}`;\n return [...PHONE_COUNTRIES]\n .sort((a, b) => b.dialCode.length - a.dialCode.length)\n .find((c) => normalized.startsWith(c.dialCode));\n}\n\nfunction digitsOnly(value: string): string {\n return value.replace(/\\D/g, \"\");\n}\n\nfunction findCountryByDialDigits(digits: string): PhoneCountry | undefined {\n return [...PHONE_COUNTRIES]\n .sort((a, b) => b.dialCode.length - a.dialCode.length)\n .find((country) => digits.startsWith(digitsOnly(country.dialCode)));\n}\n\n/** Quita el 0 de red después del código de país (+59309… → +5939…). */\nfunction stripTrunkZeroAfterDialCode(digits: string): string {\n const matched = matchInternationalE164(digits);\n return matched ? digitsOnly(matched) : digits;\n}\n\n/**\n * Si los dígitos ya traen código de país y el tramo nacional cierra,\n * devuelve E.164. Evita tratar 3001234567 (Colombia) como +30 Grecia.\n */\nfunction matchInternationalE164(digits: string): string | null {\n const country = findCountryByDialDigits(digits);\n if (!country) return null;\n const dial = digitsOnly(country.dialCode);\n if (digits.length <= dial.length) return null;\n let national = digits.slice(dial.length);\n if (national.startsWith(\"0\")) national = national.slice(1);\n if (\n national.length >= country.nationalMinLength &&\n national.length <= country.nationalMaxLength\n ) {\n return `+${dial}${national}`;\n }\n return null;\n}\n\nexport function looksLikePhoneNumber(value: string): boolean {\n const trimmed = value.trim();\n if (!trimmed) return false;\n if (/^https?:\\/\\//i.test(trimmed)) return false;\n if (/www\\./i.test(trimmed)) return false;\n if (/\\.[a-z]{2,}(\\/|$)/i.test(trimmed) && !trimmed.startsWith(\"+\")) {\n return false;\n }\n\n const digits = digitsOnly(trimmed);\n if (digits.length < 7 || digits.length > 15) return false;\n\n return /^\\+?[\\d\\s().-]+$/.test(trimmed);\n}\n\n/** Formato E.164 (+593…) al guardar prospectos de marketing. */\nexport function normalizePhoneForStorage(\n raw: string,\n countryCode?: string | null,\n): string {\n const trimmed = raw.trim();\n if (!trimmed) return \"\";\n\n const international =\n trimmed.startsWith(\"+\") || trimmed.startsWith(\"00\");\n let digits = digitsOnly(\n international && trimmed.startsWith(\"00\") ? trimmed.slice(2) : trimmed,\n );\n if (!digits) return trimmed;\n\n const asInternational = matchInternationalE164(digits);\n if (asInternational) return asInternational;\n\n if (international) {\n return `+${stripTrunkZeroAfterDialCode(digits)}`;\n }\n\n const phoneCountry =\n getPhoneCountryByIso(countryCode) ??\n getPhoneCountryByIso(DEFAULT_PHONE_COUNTRY_ISO)!;\n const dialDigits = digitsOnly(phoneCountry.dialCode);\n\n if (digits.startsWith(dialDigits)) {\n return `+${stripTrunkZeroAfterDialCode(digits)}`;\n }\n\n if (digits.startsWith(\"0\")) {\n digits = digits.slice(1);\n }\n\n if (\n digits.length >= phoneCountry.nationalMinLength &&\n digits.length <= phoneCountry.nationalMaxLength\n ) {\n return `${phoneCountry.dialCode}${digits}`;\n }\n\n if (digits.length >= 10) {\n return `+${stripTrunkZeroAfterDialCode(digits)}`;\n }\n\n return `${phoneCountry.dialCode}${digits}`;\n}\n",
10
+ "type": "registry:lib",
11
+ "target": ""
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "lib-phone-e164",
4
+ "type": "registry:lib",
5
+ "author": "Traky (https://traky.app)",
6
+ "files": [
7
+ {
8
+ "path": "lib/phone-e164.ts",
9
+ "content": "import {\n DEFAULT_PHONE_COUNTRY_ISO,\n getPhoneCountryByDialCode,\n getPhoneCountryByIso,\n type PhoneCountry,\n} from \"./phone-countries\";\n\n/** Normaliza a E.164 básico para auth (+593..., +1..., etc.). */\nexport function normalizePhoneToE164(raw: string): string {\n const trimmed = raw.trim().replace(/\\s/g, \"\");\n if (!trimmed) return \"\";\n if (trimmed.startsWith(\"+\")) return trimmed;\n if (trimmed.startsWith(\"00\")) return `+${trimmed.slice(2)}`;\n return `+${trimmed}`;\n}\n\nexport function digitsOnly(value: string): string {\n return value.replace(/\\D/g, \"\");\n}\n\nexport function buildPhoneE164(country: PhoneCountry, nationalNumber: string): string {\n const national = digitsOnly(nationalNumber);\n if (!national) return \"\";\n return `${country.dialCode}${national}`;\n}\n\nexport type ParsedPhone = {\n country: PhoneCountry;\n nationalNumber: string;\n e164: string;\n};\n\nexport function parsePhoneE164(\n raw: string,\n fallbackIso = DEFAULT_PHONE_COUNTRY_ISO,\n): ParsedPhone | null {\n const e164 = normalizePhoneToE164(raw);\n const fallback = getPhoneCountryByIso(fallbackIso) ?? getPhoneCountryByIso(\"EC\");\n if (!fallback) return null;\n\n if (!e164) {\n return { country: fallback, nationalNumber: \"\", e164: \"\" };\n }\n\n const country = getPhoneCountryByDialCode(e164) ?? fallback;\n const nationalNumber = digitsOnly(e164.slice(country.dialCode.length));\n return {\n country,\n nationalNumber,\n e164: nationalNumber ? buildPhoneE164(country, nationalNumber) : \"\",\n };\n}\n\nexport function isNationalNumberValidForCountry(\n country: PhoneCountry,\n nationalNumber: string,\n): boolean {\n const len = digitsOnly(nationalNumber).length;\n return len >= country.nationalMinLength && len <= country.nationalMaxLength;\n}\n\nexport function isPhoneE164ReadyForSms(raw: string): boolean {\n const parsed = parsePhoneE164(raw);\n if (!parsed?.e164 || !parsed.nationalNumber) return false;\n return isNationalNumberValidForCountry(parsed.country, parsed.nationalNumber);\n}\n",
10
+ "type": "registry:lib",
11
+ "target": ""
12
+ }
13
+ ]
14
+ }
@@ -10,7 +10,7 @@
10
10
  "files": [
11
11
  {
12
12
  "path": "lib/utils.ts",
13
- "content": "import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs))\n}\n",
13
+ "content": "import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs))\n}\n\n/** Quita tildes, apóstrofos, comas y pasa a minúsculas (méx → mex, mexico, → mexico). */\nexport function normalizeSearchText(value: string): string {\n return value\n .normalize(\"NFD\")\n .replace(/[\\u0300-\\u036f]/g, \"\")\n .replace(/[''`´’]/g, \"\")\n .replace(/[,.;:¡!¿?()[\\]{}«»\"\"„]/g, \" \")\n .replace(/\\s+/g, \" \")\n .toLowerCase()\n .trim()\n}\n\n/** Filtro cmdk insensible a acentos y mayúsculas. */\nexport function commandFilterAccentInsensitive(\n value: string,\n search: string,\n): number {\n const normalizedValue = normalizeSearchText(value)\n const normalizedSearch = normalizeSearchText(search)\n if (!normalizedSearch) return 1\n return normalizedValue.includes(normalizedSearch) ? 1 : 0\n}\n\n",
14
14
  "type": "registry:lib",
15
15
  "target": ""
16
16
  }
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "office-address-form-dialog",
4
+ "type": "registry:ui",
5
+ "author": "Traky (https://traky.app)",
6
+ "registryDependencies": [
7
+ "office-address-form"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "ui/office-address-form-dialog.tsx",
12
+ "content": "\"use client\"\n\n/** Compat: importa desde `@traky-app/ui/office-address-form` (`surface=\"modal\"`). */\nexport {\n OfficeAddressForm,\n OfficeAddressForm as OfficeAddressFormDialog,\n OfficeAddressFormCard,\n useOfficeAddressForm,\n EMPTY_OFFICE_ADDRESS_FORM_VALUES,\n mergeOfficeAddressFormValues,\n type OfficeAddressFormProps,\n type OfficeAddressFormDialogProps,\n type OfficeAddressFormValues,\n type OfficeAddressEntryMode,\n type OfficeAddressFormCardProps,\n} from \"@/components/ui/office-address-form\"\n",
13
+ "type": "registry:ui",
14
+ "target": ""
15
+ }
16
+ ]
17
+ }