@traky-app/ui 1.6.1 → 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 (66) 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/internal-chat-mention.json +1 -1
  8. package/registry/traky/json/internal-chat-panel.json +1 -1
  9. package/registry/traky/json/lib-geo-city-codes.json +14 -0
  10. package/registry/traky/json/lib-geo-countries.json +14 -0
  11. package/registry/traky/json/lib-geo-csc.json +17 -0
  12. package/registry/traky/json/lib-geo-locations.json +14 -0
  13. package/registry/traky/json/lib-geo-overrides.json +14 -0
  14. package/registry/traky/json/lib-geo-types.json +14 -0
  15. package/registry/traky/json/lib-google-maps-address.json +14 -0
  16. package/registry/traky/json/lib-phone-countries.json +14 -0
  17. package/registry/traky/json/lib-phone-e164.json +14 -0
  18. package/registry/traky/json/lib-utils.json +1 -1
  19. package/registry/traky/json/office-address-form-dialog.json +17 -0
  20. package/registry/traky/json/office-address-form.json +31 -0
  21. package/registry/traky/json/phone-number-input.json +25 -0
  22. package/registry/traky/json/state-select.json +19 -0
  23. package/registry/traky/lib/geo-city-codes.ts +234 -0
  24. package/registry/traky/lib/geo-countries.ts +75 -0
  25. package/registry/traky/lib/geo-csc.ts +82 -0
  26. package/registry/traky/lib/geo-locations.ts +171 -0
  27. package/registry/traky/lib/geo-overrides.ts +375 -0
  28. package/registry/traky/lib/geo-types.ts +34 -0
  29. package/registry/traky/lib/google-maps-address.ts +100 -0
  30. package/registry/traky/lib/phone-countries.ts +380 -0
  31. package/registry/traky/lib/phone-e164.ts +66 -0
  32. package/registry/traky/lib/utils.ts +24 -0
  33. package/registry/traky/registry.json +180 -0
  34. package/registry/traky/ui/city-select.tsx +113 -0
  35. package/registry/traky/ui/country-select.tsx +233 -0
  36. package/registry/traky/ui/geo-search-select.tsx +136 -0
  37. package/registry/traky/ui/geo-selectors.tsx +162 -0
  38. package/registry/traky/ui/google-maps-address-picker.tsx +422 -0
  39. package/registry/traky/ui/internal-chat-mention.ts +34 -3
  40. package/registry/traky/ui/internal-chat-panel.tsx +147 -12
  41. package/registry/traky/ui/office-address-form-dialog.tsx +16 -0
  42. package/registry/traky/ui/office-address-form.tsx +868 -0
  43. package/registry/traky/ui/phone-number-input.tsx +253 -0
  44. package/registry/traky/ui/state-select.tsx +107 -0
  45. package/src/lib/geo-city-codes.ts +234 -0
  46. package/src/lib/geo-countries.ts +75 -0
  47. package/src/lib/geo-csc.ts +82 -0
  48. package/src/lib/geo-locations.ts +171 -0
  49. package/src/lib/geo-overrides.ts +375 -0
  50. package/src/lib/geo-types.ts +34 -0
  51. package/src/lib/google-maps-address.ts +100 -0
  52. package/src/lib/phone-countries.ts +380 -0
  53. package/src/lib/phone-e164.ts +66 -0
  54. package/src/test/setup.ts +31 -0
  55. package/src/ui/__tests__/geo-selects.test.tsx +504 -0
  56. package/src/ui/city-select.tsx +113 -0
  57. package/src/ui/country-select.tsx +233 -0
  58. package/src/ui/geo-search-select.tsx +136 -0
  59. package/src/ui/geo-selectors.tsx +162 -0
  60. package/src/ui/google-maps-address-picker.tsx +422 -0
  61. package/src/ui/internal-chat-mention.ts +34 -3
  62. package/src/ui/internal-chat-panel.tsx +147 -12
  63. package/src/ui/office-address-form-dialog.tsx +16 -0
  64. package/src/ui/office-address-form.tsx +877 -0
  65. package/src/ui/phone-number-input.tsx +253 -0
  66. package/src/ui/state-select.tsx +107 -0
@@ -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
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "office-address-form",
4
+ "type": "registry:ui",
5
+ "author": "Traky (https://traky.app)",
6
+ "dependencies": [
7
+ "@tabler/icons-react"
8
+ ],
9
+ "registryDependencies": [
10
+ "alert-dialog",
11
+ "button",
12
+ "card",
13
+ "entity-icon",
14
+ "google-maps-address-picker",
15
+ "input",
16
+ "label",
17
+ "lib-google-maps-address",
18
+ "select",
19
+ "spinner",
20
+ "switch",
21
+ "textarea"
22
+ ],
23
+ "files": [
24
+ {
25
+ "path": "ui/office-address-form.tsx",
26
+ "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { IconEdit, IconMapPin, IconPlus } from \"@tabler/icons-react\"\nimport type { Icon } from \"@tabler/icons-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogTrigger,\n} from \"@/components/ui/alert-dialog\"\nimport { Button } from \"@/components/ui/button\"\nimport { Spinner } from \"@/components/ui/spinner\"\nimport { googleMapsSelectionToFormPatch } from \"@/lib/google-maps-address\"\nimport {\n Card,\n CardContent,\n CardDescription,\n CardFooter,\n CardHeader,\n CardTitle,\n} from \"@/components/ui/card\"\nimport { EntityIcon } from \"@/components/ui/entity-icon\"\nimport { GoogleMapsAddressPicker } from \"@/components/ui/google-maps-address-picker\"\nimport { Input } from \"@/components/ui/input\"\nimport { Label } from \"@/components/ui/label\"\nimport {\n Select,\n SelectContent,\n SelectGroup,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\"\nimport { Switch } from \"@/components/ui/switch\"\nimport { Textarea } from \"@/components/ui/textarea\"\n\nexport type OfficeAddressEntryMode = \"manual\" | \"gps\"\n\nfunction OfficeAddressEntryModeSwitch({\n mode,\n onModeChange,\n disabled = false,\n className,\n}: {\n mode: OfficeAddressEntryMode\n onModeChange: (mode: OfficeAddressEntryMode) => void\n disabled?: boolean\n className?: string\n}) {\n const isGps = mode === \"gps\"\n\n return (\n <div\n className={cn(\n \"flex shrink-0 items-center gap-2 text-xs text-muted-foreground\",\n className,\n )}\n role=\"group\"\n aria-label=\"Modo de ingreso de dirección\"\n >\n <span className={cn(!isGps && \"font-semibold text-foreground\")}>\n Manual\n </span>\n <Switch\n checked={isGps}\n disabled={disabled}\n aria-label={isGps ? \"Modo GPS\" : \"Modo manual\"}\n onCheckedChange={(checked) =>\n onModeChange(checked ? \"gps\" : \"manual\")\n }\n />\n <span className={cn(isGps && \"font-semibold text-foreground\")}>GPS</span>\n </div>\n )\n}\n\n/** `customer` = dirección de envío (contacto/portal). `enterprise` = sucursal o bodega. */\nexport type OfficeAddressFormVariant = \"customer\" | \"enterprise\"\n\nconst OFFICE_ADDRESS_COUNTRY_OPTIONS: { code: string; label: string }[] = [\n { code: \"EC\", label: \"Ecuador\" },\n { code: \"US\", label: \"Estados Unidos\" },\n { code: \"MX\", label: \"México\" },\n { code: \"CO\", label: \"Colombia\" },\n { code: \"PE\", label: \"Perú\" },\n { code: \"CL\", label: \"Chile\" },\n { code: \"PA\", label: \"Panamá\" },\n { code: \"CR\", label: \"Costa Rica\" },\n]\n\nconst FAX_COUNTRY_CODES = new Set([\"US\", \"CA\", \"PR\"])\n\nexport function isOfficeAddressFaxCountry(countryCode: string | undefined) {\n return FAX_COUNTRY_CODES.has(countryCode?.trim().toUpperCase() ?? \"\")\n}\n\nexport type OfficeAddressFormValues = {\n countryCode: string\n address: string\n suiteOrApt: string\n city: string\n stateOrProvince: string\n postalCode: string\n phoneNumber: string\n fax: string\n note: string\n isActive: boolean\n isMain: boolean\n isWarehouseOnly: boolean\n latitude: number | null\n longitude: number | null\n addressGeocodeSource: string | null\n}\n\nexport const EMPTY_OFFICE_ADDRESS_FORM_VALUES: OfficeAddressFormValues = {\n countryCode: \"\",\n address: \"\",\n suiteOrApt: \"\",\n city: \"\",\n stateOrProvince: \"\",\n postalCode: \"\",\n phoneNumber: \"\",\n fax: \"\",\n note: \"\",\n isActive: true,\n isMain: false,\n isWarehouseOnly: false,\n latitude: null,\n longitude: null,\n addressGeocodeSource: null,\n}\n\nexport function mergeOfficeAddressFormValues(\n initial?: Partial<OfficeAddressFormValues>,\n): OfficeAddressFormValues {\n return {\n ...EMPTY_OFFICE_ADDRESS_FORM_VALUES,\n ...initial,\n isActive: initial?.isActive ?? true,\n isMain: initial?.isMain ?? false,\n }\n}\n\nexport function resolveOfficeAddressEntryMode(\n initial?: Partial<OfficeAddressFormValues>,\n initialEntryMode?: OfficeAddressEntryMode,\n): OfficeAddressEntryMode {\n if (initialEntryMode) return initialEntryMode\n return initial?.addressGeocodeSource === \"google_maps\" ? \"gps\" : \"manual\"\n}\n\nexport function useOfficeAddressForm(options?: {\n initialValues?: Partial<OfficeAddressFormValues>\n initialEntryMode?: OfficeAddressEntryMode\n}) {\n const [form, setForm] = React.useState<OfficeAddressFormValues>(() =>\n mergeOfficeAddressFormValues(options?.initialValues),\n )\n const [addressEntryMode, setAddressEntryMode] =\n React.useState<OfficeAddressEntryMode>(() =>\n resolveOfficeAddressEntryMode(\n options?.initialValues,\n options?.initialEntryMode,\n ),\n )\n\n const reset = React.useCallback(\n (\n nextInitial?: Partial<OfficeAddressFormValues>,\n nextMode?: OfficeAddressEntryMode,\n ) => {\n setForm(mergeOfficeAddressFormValues(nextInitial))\n setAddressEntryMode(\n resolveOfficeAddressEntryMode(nextInitial, nextMode),\n )\n },\n [],\n )\n\n const setAddressEntryModeAndClearGps = React.useCallback(\n (mode: OfficeAddressEntryMode) => {\n setAddressEntryMode(mode)\n if (mode === \"manual\") {\n setForm((prev) => ({\n ...prev,\n latitude: null,\n longitude: null,\n addressGeocodeSource: null,\n }))\n }\n },\n [],\n )\n\n return {\n form,\n setForm,\n addressEntryMode,\n setAddressEntryMode: setAddressEntryModeAndClearGps,\n reset,\n }\n}\n\nexport type OfficeAddressFormCardProps = {\n title: string\n description?: string\n icon?: Icon\n form: OfficeAddressFormValues\n onFormChange: React.Dispatch<React.SetStateAction<OfficeAddressFormValues>>\n addressEntryMode: OfficeAddressEntryMode\n onAddressEntryModeChange: (mode: OfficeAddressEntryMode) => void\n disabled?: boolean\n regionCode?: string\n googleMapsApiKey?: string\n /** Estable para remontar GoogleMaps al editar. */\n pickerKey?: string\n footer?: React.ReactNode\n className?: string\n /**\n * `dialog`: card plana dentro de AlertDialog (sin borde/sombra exterior).\n * `page`: card completa para rutas de edición.\n */\n layout?: \"dialog\" | \"page\"\n contentClassName?: string\n /** customer = envío; enterprise = sucursal/bodega (campos extra). */\n variant?: OfficeAddressFormVariant\n /** Reemplaza el selector de país por defecto (p. ej. CountrySelect en la app). */\n countryField?: React.ReactNode\n phoneField?: React.ReactNode\n faxField?: React.ReactNode\n}\n\nexport function OfficeAddressFormCard({\n title,\n description,\n icon: HeaderIcon = IconMapPin,\n form,\n onFormChange,\n addressEntryMode,\n onAddressEntryModeChange,\n disabled = false,\n regionCode,\n googleMapsApiKey,\n pickerKey = \"office-address-gps\",\n footer,\n className,\n layout = \"page\",\n contentClassName,\n variant = \"customer\",\n countryField,\n phoneField,\n faxField,\n}: OfficeAddressFormCardProps) {\n const busy = disabled\n const isCustomer = variant === \"customer\"\n const mapRegion =\n form.countryCode?.trim().toUpperCase() ||\n regionCode?.trim().toUpperCase() ||\n undefined\n const showFax =\n Boolean(faxField) ||\n isOfficeAddressFaxCountry(form.countryCode) ||\n form.fax.trim().length > 0\n\n const handleChange = (\n e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,\n ) => {\n const { name, value } = e.target\n onFormChange((prev) => ({ ...prev, [name]: value }))\n }\n\n const embedded = layout === \"dialog\"\n\n const countryControl =\n countryField ?? (\n <Select\n value={form.countryCode || undefined}\n disabled={busy}\n onValueChange={(code) =>\n onFormChange((prev) => ({ ...prev, countryCode: code }))\n }\n >\n <SelectTrigger className=\"w-full\" aria-label=\"País\">\n <SelectValue placeholder=\"Selecciona el país\" />\n </SelectTrigger>\n <SelectContent>\n <SelectGroup>\n {OFFICE_ADDRESS_COUNTRY_OPTIONS.map((c) => (\n <SelectItem key={c.code} value={c.code}>\n {c.label}\n </SelectItem>\n ))}\n </SelectGroup>\n </SelectContent>\n </Select>\n )\n\n return (\n <Card\n className={cn(\n \"gap-0 py-0\",\n embedded\n ? \"border-0 bg-transparent shadow-none\"\n : \"border-border shadow-sm\",\n className,\n )}\n >\n <CardHeader\n className={cn(\n \"flex flex-row flex-wrap items-center justify-between gap-3 space-y-0 px-5 pt-5 pb-3.5 sm:px-6\",\n embedded && \"border-b border-border/60\",\n !embedded && \"border-b border-border/60\",\n )}\n >\n <div className=\"flex min-w-0 flex-1 items-start gap-3\">\n <EntityIcon icon={HeaderIcon} size=\"sm\" className=\"mb-0\" />\n <div className=\"min-w-0 space-y-0.5\">\n <CardTitle className=\"text-sm font-semibold tracking-tight\">\n {title}\n </CardTitle>\n {description ? (\n <CardDescription className=\"text-xs\">{description}</CardDescription>\n ) : null}\n </div>\n </div>\n <OfficeAddressEntryModeSwitch\n mode={addressEntryMode}\n disabled={busy}\n onModeChange={onAddressEntryModeChange}\n />\n </CardHeader>\n\n <CardContent\n className={cn(\n \"grid min-h-0 grid-cols-1 gap-4 px-5 py-4 sm:grid-cols-2 sm:px-6\",\n embedded && \"max-h-[min(60dvh,28rem)] overflow-y-auto overscroll-contain\",\n contentClassName,\n )}\n >\n {addressEntryMode === \"gps\" ? (\n <div className=\"col-span-1 sm:col-span-2\">\n <GoogleMapsAddressPicker\n key={pickerKey}\n apiKey={googleMapsApiKey}\n regionCode={mapRegion}\n initialLatitude={form.latitude ?? undefined}\n initialLongitude={form.longitude ?? undefined}\n initialFormattedAddress={form.address}\n onLocationSelect={(selection) => {\n const patch = googleMapsSelectionToFormPatch(selection)\n onFormChange((prev) => ({\n ...prev,\n address: patch.address,\n city: patch.city,\n stateOrProvince: patch.stateOrProvince,\n postalCode: patch.postalCode,\n countryCode: patch.countryCode || prev.countryCode,\n latitude: patch.latitude,\n longitude: patch.longitude,\n addressGeocodeSource: patch.addressGeocodeSource,\n }))\n }}\n onLocationClear={() => {\n onFormChange((prev) => ({\n ...prev,\n address: \"\",\n city: \"\",\n stateOrProvince: \"\",\n postalCode: \"\",\n latitude: null,\n longitude: null,\n addressGeocodeSource: null,\n }))\n }}\n />\n </div>\n ) : null}\n\n <div className=\"col-span-1 space-y-1 sm:col-span-2\">\n <Label htmlFor=\"office-country\">País</Label>\n {countryControl}\n </div>\n\n {addressEntryMode === \"manual\" ? (\n <>\n <div className=\"col-span-1 space-y-1 sm:col-span-2\">\n <Label htmlFor=\"office-address\">Dirección</Label>\n <Input\n id=\"office-address\"\n name=\"address\"\n value={form.address}\n onChange={handleChange}\n placeholder=\"Calle y número\"\n disabled={busy}\n />\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"office-city\">Ciudad</Label>\n <Input\n id=\"office-city\"\n name=\"city\"\n value={form.city}\n onChange={handleChange}\n disabled={busy}\n />\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"office-state\">Estado / Provincia</Label>\n <Input\n id=\"office-state\"\n name=\"stateOrProvince\"\n value={form.stateOrProvince}\n onChange={handleChange}\n disabled={busy}\n />\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"office-postal\">Código postal</Label>\n <Input\n id=\"office-postal\"\n name=\"postalCode\"\n value={form.postalCode}\n onChange={handleChange}\n disabled={busy}\n />\n </div>\n </>\n ) : null}\n\n {addressEntryMode === \"gps\" && form.address.trim() ? (\n <div className=\"col-span-1 rounded-lg border border-border bg-muted/30 px-3 py-2.5 text-sm sm:col-span-2\">\n <p className=\"font-medium text-foreground\">{form.address}</p>\n <p className=\"text-muted-foreground\">\n {[form.city, form.stateOrProvince, form.postalCode, form.countryCode]\n .filter(Boolean)\n .join(\" · \")}\n </p>\n </div>\n ) : null}\n\n <div className=\"col-span-1 space-y-1 sm:col-span-2\">\n <Label htmlFor=\"office-suite\">Suite / apt</Label>\n <Input\n id=\"office-suite\"\n name=\"suiteOrApt\"\n value={form.suiteOrApt}\n onChange={handleChange}\n disabled={busy}\n />\n </div>\n\n {phoneField ?? (\n <div className=\"space-y-1\">\n <Label htmlFor=\"office-phone\">Teléfono</Label>\n <Input\n id=\"office-phone\"\n name=\"phoneNumber\"\n type=\"tel\"\n value={form.phoneNumber}\n onChange={handleChange}\n placeholder=\"+593 …\"\n disabled={busy}\n />\n </div>\n )}\n\n {showFax\n ? (faxField ?? (\n <div className=\"space-y-1\">\n <Label htmlFor=\"office-fax\">Fax</Label>\n <Input\n id=\"office-fax\"\n name=\"fax\"\n type=\"tel\"\n value={form.fax}\n onChange={handleChange}\n disabled={busy}\n />\n </div>\n ))\n : null}\n\n <div className=\"col-span-1 space-y-1 sm:col-span-2\">\n <Label htmlFor=\"office-note\">Nota</Label>\n <Textarea\n id=\"office-note\"\n name=\"note\"\n value={form.note}\n onChange={handleChange}\n placeholder=\"Información adicional sobre esta dirección\"\n className=\"min-h-20 resize-y\"\n disabled={busy}\n />\n </div>\n\n <div className=\"space-y-1\">\n <Label htmlFor=\"office-active\">Estado</Label>\n <Select\n value={form.isActive.toString()}\n disabled={busy}\n onValueChange={(value) =>\n onFormChange((prev) => ({\n ...prev,\n isActive: value === \"true\",\n }))\n }\n >\n <SelectTrigger id=\"office-active\" className=\"w-full\">\n <SelectValue placeholder=\"Seleccione el estado\" />\n </SelectTrigger>\n <SelectContent>\n <SelectGroup>\n <SelectItem value=\"true\">Activo</SelectItem>\n <SelectItem value=\"false\">Inactivo</SelectItem>\n </SelectGroup>\n </SelectContent>\n </Select>\n </div>\n\n {!isCustomer ? (\n <>\n <div className=\"col-span-1 space-y-1 sm:col-span-2\">\n <Label>Tipo de ubicación</Label>\n <Select\n value={form.isWarehouseOnly ? \"warehouse\" : \"agency\"}\n disabled={busy}\n onValueChange={(value) => {\n const isWarehouseOnly = value === \"warehouse\"\n onFormChange((prev) => ({\n ...prev,\n isWarehouseOnly,\n isMain: isWarehouseOnly ? false : prev.isMain,\n }))\n }}\n >\n <SelectTrigger className=\"w-full\">\n <SelectValue placeholder=\"Selecciona el tipo\" />\n </SelectTrigger>\n <SelectContent>\n <SelectGroup>\n <SelectItem value=\"agency\">Es sucursal</SelectItem>\n <SelectItem value=\"warehouse\">Es solo bodega</SelectItem>\n </SelectGroup>\n </SelectContent>\n </Select>\n </div>\n <div className=\"space-y-1\">\n <Label>Dirección principal</Label>\n <Select\n value={form.isMain.toString()}\n disabled={busy || form.isWarehouseOnly}\n onValueChange={(value) =>\n onFormChange((prev) => ({\n ...prev,\n isMain: value === \"true\",\n }))\n }\n >\n <SelectTrigger className=\"w-full\">\n <SelectValue placeholder=\"¿Es dirección principal?\" />\n </SelectTrigger>\n <SelectContent>\n <SelectGroup>\n <SelectItem value=\"true\">Sí</SelectItem>\n <SelectItem value=\"false\">No</SelectItem>\n </SelectGroup>\n </SelectContent>\n </Select>\n </div>\n </>\n ) : null}\n </CardContent>\n\n {footer ? (\n <CardFooter\n className={cn(\n \"border-t border-border/60 px-5 py-3 sm:px-6\",\n embedded && \"bg-background\",\n )}\n >\n {footer}\n </CardFooter>\n ) : null}\n </Card>\n )\n}\n\ntype OfficeAddressFormSharedProps = {\n title?: string\n titleCreate?: string\n titleEdit?: string\n description?: string\n initialValues?: Partial<OfficeAddressFormValues>\n initialEntryMode?: OfficeAddressEntryMode\n regionCode?: string\n googleMapsApiKey?: string\n pickerKey?: string\n className?: string\n contentClassName?: string\n icon?: Icon\n disabled?: boolean\n variant?: OfficeAddressFormVariant\n countryField?: React.ReactNode\n phoneField?: React.ReactNode\n faxField?: React.ReactNode\n}\n\ntype OfficeAddressFormInlineProps = OfficeAddressFormSharedProps & {\n /** Card en página o sección (por defecto). */\n surface?: \"inline\"\n form?: OfficeAddressFormValues\n onFormChange?: React.Dispatch<React.SetStateAction<OfficeAddressFormValues>>\n addressEntryMode?: OfficeAddressEntryMode\n onAddressEntryModeChange?: (mode: OfficeAddressEntryMode) => void\n footer?: React.ReactNode\n}\n\ntype OfficeAddressFormModalProps = OfficeAddressFormSharedProps & {\n /** Misma card dentro de AlertDialog (contactos, selectores, etc.). */\n surface: \"modal\"\n type?: \"create\" | \"edit\"\n open?: boolean\n defaultOpen?: boolean\n onOpenChange?: (open: boolean) => void\n onSubmit?: (values: OfficeAddressFormValues) => void | Promise<void>\n trigger?: React.ReactNode\n hideTrigger?: boolean\n loading?: boolean\n dialogClassName?: string\n form?: OfficeAddressFormValues\n onFormChange?: React.Dispatch<React.SetStateAction<OfficeAddressFormValues>>\n addressEntryMode?: OfficeAddressEntryMode\n onAddressEntryModeChange?: (mode: OfficeAddressEntryMode) => void\n}\n\nexport type OfficeAddressFormProps =\n | OfficeAddressFormInlineProps\n | OfficeAddressFormModalProps\n\n/**\n * Formulario de dirección unificado: card en página (`surface=\"inline\"`) o en modal (`surface=\"modal\"`).\n * Usa `OfficeAddressFormCard` + `useOfficeAddressForm` por debajo.\n */\nexport function OfficeAddressForm(props: OfficeAddressFormProps) {\n if (props.surface === \"modal\") {\n return <OfficeAddressFormModal {...props} />\n }\n return <OfficeAddressFormInline {...props} />\n}\n\nfunction OfficeAddressFormInline({\n title = \"Dirección\",\n description,\n initialValues,\n initialEntryMode,\n form: controlledForm,\n onFormChange: controlledOnFormChange,\n addressEntryMode: controlledMode,\n onAddressEntryModeChange: controlledOnModeChange,\n footer,\n regionCode,\n googleMapsApiKey,\n pickerKey = \"office-address-inline\",\n className,\n contentClassName,\n icon,\n disabled,\n variant,\n countryField,\n phoneField,\n faxField,\n}: OfficeAddressFormInlineProps) {\n const internal = useOfficeAddressForm({ initialValues, initialEntryMode })\n const form = controlledForm ?? internal.form\n const setForm = controlledOnFormChange ?? internal.setForm\n const addressEntryMode = controlledMode ?? internal.addressEntryMode\n const setAddressEntryMode =\n controlledOnModeChange ?? internal.setAddressEntryMode\n\n return (\n <OfficeAddressFormCard\n layout=\"page\"\n title={title}\n description={description}\n icon={icon}\n form={form}\n onFormChange={setForm}\n addressEntryMode={addressEntryMode}\n onAddressEntryModeChange={setAddressEntryMode}\n disabled={disabled}\n regionCode={regionCode}\n googleMapsApiKey={googleMapsApiKey}\n pickerKey={pickerKey}\n footer={footer}\n className={className}\n contentClassName={contentClassName}\n variant={variant}\n countryField={countryField}\n phoneField={phoneField}\n faxField={faxField}\n />\n )\n}\n\nfunction OfficeAddressFormModal({\n type = \"create\",\n open: controlledOpen,\n defaultOpen = false,\n onOpenChange,\n initialValues,\n initialEntryMode,\n onSubmit,\n trigger,\n hideTrigger = false,\n loading = false,\n titleCreate = \"Crear dirección\",\n titleEdit = \"Editar dirección\",\n description,\n regionCode,\n googleMapsApiKey,\n className,\n dialogClassName,\n variant,\n countryField,\n phoneField,\n faxField,\n icon,\n form: controlledForm,\n onFormChange: controlledOnFormChange,\n addressEntryMode: controlledMode,\n onAddressEntryModeChange: controlledOnModeChange,\n}: OfficeAddressFormModalProps) {\n const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)\n const isControlled = controlledOpen !== undefined\n const open = isControlled ? Boolean(controlledOpen) : uncontrolledOpen\n\n const internal = useOfficeAddressForm({ initialValues, initialEntryMode })\n const form = controlledForm ?? internal.form\n const setForm = controlledOnFormChange ?? internal.setForm\n const addressEntryMode = controlledMode ?? internal.addressEntryMode\n const setAddressEntryMode =\n controlledOnModeChange ?? internal.setAddressEntryMode\n const reset = internal.reset\n\n const [submitting, setSubmitting] = React.useState(false)\n\n const setOpen = (next: boolean) => {\n if (!isControlled) setUncontrolledOpen(next)\n onOpenChange?.(next)\n }\n\n React.useEffect(() => {\n if (!open) return\n reset(initialValues, initialEntryMode)\n }, [open, initialValues, initialEntryMode, reset])\n\n const modalTitle = type === \"edit\" ? titleEdit : titleCreate\n const busy = loading || submitting\n\n const handleSubmit = async () => {\n setSubmitting(true)\n try {\n await onSubmit?.(form)\n setOpen(false)\n } finally {\n setSubmitting(false)\n }\n }\n\n const pickerKey =\n type === \"edit\" && initialValues\n ? `gps-modal-${String((initialValues as { id?: string }).id ?? \"edit\")}`\n : \"gps-modal-create\"\n\n return (\n <AlertDialog open={open} onOpenChange={setOpen}>\n {!hideTrigger ? (\n <AlertDialogTrigger asChild>\n {trigger ??\n (type === \"edit\" ? (\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"outline\"\n disabled={busy}\n aria-label=\"Editar dirección\"\n className={className}\n >\n {busy ? <Spinner className=\"size-4\" /> : <IconEdit size={16} />}\n </Button>\n ) : (\n <Button type=\"button\" disabled={busy} className={className}>\n {busy ? <Spinner /> : <IconPlus />}\n Nueva dirección\n </Button>\n ))}\n </AlertDialogTrigger>\n ) : null}\n\n <AlertDialogContent\n className={cn(\n \"flex max-h-[min(90dvh,48rem)] w-[calc(100%-1.5rem)] max-w-2xl flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl\",\n dialogClassName,\n )}\n >\n <OfficeAddressFormCard\n layout=\"dialog\"\n title={modalTitle}\n description={description}\n icon={icon}\n variant={variant}\n countryField={countryField}\n phoneField={phoneField}\n faxField={faxField}\n form={form}\n onFormChange={setForm}\n addressEntryMode={addressEntryMode}\n onAddressEntryModeChange={setAddressEntryMode}\n disabled={busy}\n regionCode={regionCode}\n googleMapsApiKey={googleMapsApiKey}\n pickerKey={pickerKey}\n footer={\n <div className=\"flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end\">\n <AlertDialogCancel type=\"button\" disabled={busy} className=\"mt-0\">\n Cancelar\n </AlertDialogCancel>\n <AlertDialogAction\n type=\"button\"\n disabled={busy}\n className=\"mt-0\"\n onClick={(e) => {\n e.preventDefault()\n void handleSubmit()\n }}\n >\n {busy ? (\n <>\n <Spinner className=\"mr-2 size-4\" />\n Guardando…\n </>\n ) : type === \"edit\" ? (\n \"Guardar cambios\"\n ) : (\n \"Crear dirección\"\n )}\n </AlertDialogAction>\n </div>\n }\n />\n </AlertDialogContent>\n </AlertDialog>\n )\n}\n\n/** @deprecated Usa `OfficeAddressForm` con `surface=\"modal\"`. */\nexport const OfficeAddressFormDialog = (\n props: Omit<OfficeAddressFormModalProps, \"surface\">,\n) => <OfficeAddressForm {...props} surface=\"modal\" />\n\nexport type OfficeAddressFormDialogProps = Omit<\n OfficeAddressFormModalProps,\n \"surface\"\n>\n",
27
+ "type": "registry:ui",
28
+ "target": ""
29
+ }
30
+ ]
31
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "phone-number-input",
4
+ "type": "registry:ui",
5
+ "author": "Traky (https://traky.app)",
6
+ "dependencies": [
7
+ "@tabler/icons-react"
8
+ ],
9
+ "registryDependencies": [
10
+ "button",
11
+ "command",
12
+ "input",
13
+ "lib-phone-countries",
14
+ "lib-phone-e164",
15
+ "popover"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "ui/phone-number-input.tsx",
20
+ "content": "\"use client\"\n\nimport { IconCheck, IconSelector } from \"@tabler/icons-react\"\nimport { useEffect, useMemo, useState } from \"react\"\n\nimport {\n DEFAULT_PHONE_COUNTRY_ISO,\n getPhoneCountryByIso,\n PHONE_COUNTRIES,\n type PhoneCountry,\n} from \"@/lib/phone-countries\"\nimport {\n buildPhoneE164,\n digitsOnly,\n isNationalNumberValidForCountry,\n parsePhoneE164,\n type ParsedPhone,\n} from \"@/lib/phone-e164\"\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 { Input } from \"@/components/ui/input\"\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\"\n\nexport type PhoneNumberValue = {\n countryCode: string\n countryName: string\n dialCode: string\n nationalNumber: string\n e164: string\n isValid: boolean\n}\n\nfunction sanitizeNational(country: PhoneCountry, raw: string): string {\n let digits = digitsOnly(raw)\n if (country.iso === \"EC\" && digits.startsWith(\"0\")) {\n digits = digits.slice(1)\n }\n return digits.slice(0, country.nationalMaxLength)\n}\n\n/** Acepta E.164 o dígitos nacionales (usa `defaultCountryIso` si no hay `+`). */\nfunction parseIncomingPhone(\n raw: string,\n defaultCountryIso: string,\n): ParsedPhone | null {\n const trimmed = raw.trim()\n if (!trimmed) {\n return parsePhoneE164(\"\", defaultCountryIso)\n }\n if (trimmed.startsWith(\"+\") || trimmed.startsWith(\"00\")) {\n return parsePhoneE164(trimmed, defaultCountryIso)\n }\n\n const country =\n getPhoneCountryByIso(defaultCountryIso) ??\n getPhoneCountryByIso(DEFAULT_PHONE_COUNTRY_ISO)\n if (!country) return null\n\n const nationalNumber = sanitizeNational(country, trimmed)\n return {\n country,\n nationalNumber,\n e164: buildPhoneE164(country, nationalNumber),\n }\n}\n\nfunction CountryFlag({ iso, name }: { iso: string; name: string }) {\n const [visible, setVisible] = useState(true)\n if (!visible || !iso) return null\n\n return (\n <img\n src={`/assets/flags/${iso.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\nfunction toPhoneValue(\n country: PhoneCountry,\n nationalNumber: string,\n): PhoneNumberValue {\n const digits = digitsOnly(nationalNumber)\n return {\n countryCode: country.iso,\n countryName: country.name,\n dialCode: country.dialCode,\n nationalNumber: digits,\n e164: buildPhoneE164(country, digits),\n isValid: isNationalNumberValidForCountry(country, digits),\n }\n}\n\nexport type PhoneNumberInputProps = {\n id?: string\n /** Valor controlado en E.164 (`+593980061377`). */\n value?: string\n defaultCountryIso?: string\n onChange?: (value: PhoneNumberValue) => void\n disabled?: boolean\n className?: string\n placeholder?: string\n searchPlaceholder?: string\n}\n\nexport function PhoneNumberInput({\n id,\n value,\n defaultCountryIso = DEFAULT_PHONE_COUNTRY_ISO,\n onChange,\n disabled = false,\n className,\n placeholder,\n searchPlaceholder = \"Buscar país…\",\n}: PhoneNumberInputProps) {\n const [open, setOpen] = useState(false)\n const [countryIso, setCountryIso] = useState(() => {\n const parsed = parseIncomingPhone(value ?? \"\", defaultCountryIso)\n return parsed?.country.iso ?? defaultCountryIso\n })\n const [national, setNational] = useState(() => {\n const parsed = parseIncomingPhone(value ?? \"\", defaultCountryIso)\n return parsed?.nationalNumber ?? \"\"\n })\n\n const country = useMemo(\n () =>\n PHONE_COUNTRIES.find((c) => c.iso === countryIso) ??\n PHONE_COUNTRIES.find((c) => c.iso === DEFAULT_PHONE_COUNTRY_ISO)!,\n [countryIso],\n )\n\n useEffect(() => {\n if (value == null) return\n if (value === \"\") {\n setNational(\"\")\n return\n }\n const parsed = parseIncomingPhone(value, defaultCountryIso)\n if (!parsed) return\n setCountryIso(parsed.country.iso)\n setNational(parsed.nationalNumber)\n }, [value, defaultCountryIso])\n\n const emit = (nextCountry: PhoneCountry, nextNational: string) => {\n const digits = sanitizeNational(nextCountry, nextNational)\n setCountryIso(nextCountry.iso)\n setNational(digits)\n onChange?.(toPhoneValue(nextCountry, digits))\n }\n\n const resolvedPlaceholder =\n placeholder ??\n (country.nationalMinLength === country.nationalMaxLength\n ? \"9\".repeat(country.nationalMaxLength)\n : `${country.nationalMinLength}-${country.nationalMaxLength} dígitos`)\n\n const isValid = isNationalNumberValidForCountry(country, national)\n\n return (\n <div className={cn(\"flex w-full items-stretch gap-2\", className)}>\n <Popover open={open} onOpenChange={disabled ? undefined : setOpen}>\n <PopoverTrigger asChild>\n <Button\n type=\"button\"\n variant=\"outline\"\n disabled={disabled}\n role=\"combobox\"\n aria-expanded={open}\n aria-label={`País: ${country.name} ${country.dialCode}`}\n className=\"h-10 w-[7.75rem] shrink-0 justify-between gap-1.5 px-2.5 font-normal\"\n >\n <span className=\"flex min-w-0 items-center gap-1.5 overflow-hidden\">\n <CountryFlag iso={country.iso} name={country.name} />\n <span className=\"truncate text-sm tabular-nums\">\n {country.dialCode}\n </span>\n </span>\n <IconSelector className=\"size-3.5 shrink-0 opacity-50\" />\n </Button>\n </PopoverTrigger>\n <PopoverContent className=\"w-[min(100vw-2rem,18rem)] p-0\" align=\"start\">\n <Command filter={commandFilterAccentInsensitive}>\n <CommandInput placeholder={searchPlaceholder} />\n <CommandList className=\"max-h-72\">\n <CommandEmpty>No se encontró el país.</CommandEmpty>\n <CommandGroup heading={`${PHONE_COUNTRIES.length} países`}>\n {PHONE_COUNTRIES.map((item) => (\n <CommandItem\n key={item.iso}\n value={`${item.name} ${item.iso} ${item.dialCode}`}\n onSelect={() => {\n setOpen(false)\n emit(item, national)\n }}\n className=\"gap-2\"\n >\n <CountryFlag iso={item.iso} name={item.name} />\n <span className=\"min-w-0 flex-1 truncate\">{item.name}</span>\n <span className=\"text-xs tabular-nums text-muted-foreground\">\n {item.dialCode}\n </span>\n {country.iso === item.iso ? (\n <IconCheck className=\"size-4 shrink-0 text-primary\" />\n ) : null}\n </CommandItem>\n ))}\n </CommandGroup>\n </CommandList>\n </Command>\n </PopoverContent>\n </Popover>\n\n <Input\n id={id}\n type=\"tel\"\n inputMode=\"numeric\"\n autoComplete=\"tel-national\"\n disabled={disabled}\n value={national}\n maxLength={country.nationalMaxLength}\n placeholder={resolvedPlaceholder}\n aria-invalid={national.length > 0 && !isValid}\n className=\"h-10 flex-1\"\n onChange={(e) => emit(country, e.target.value)}\n />\n </div>\n )\n}\n\nexport function formatPhoneHint(country: PhoneCountry): string {\n if (country.nationalMinLength === country.nationalMaxLength) {\n return `Ingresa ${country.nationalMaxLength} dígitos después de ${country.dialCode}`\n }\n return `Ingresa entre ${country.nationalMinLength} y ${country.nationalMaxLength} dígitos después de ${country.dialCode}`\n}\n",
21
+ "type": "registry:ui",
22
+ "target": ""
23
+ }
24
+ ]
25
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "state-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/state-select.tsx",
14
+ "content": "\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { fetchStates } from \"@/lib/geo-locations\";\nimport type { GeoOption } from \"@/lib/geo-types\";\nimport { GeoSearchSelect } from \"@/components/ui/geo-search-select\";\n\nexport type StateSelectProps = {\n id?: string;\n /** ISO-2 del país. Sin este código el selector queda inactivo. */\n countryCode?: 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: GeoOption | null) => void;\n disabled?: boolean;\n className?: string;\n placeholder?: string;\n showCode?: boolean;\n};\n\nexport function StateSelect({\n id,\n countryCode,\n value,\n defaultValue,\n onChange,\n disabled = false,\n className,\n placeholder = \"Buscar estado, región o municipio…\",\n showCode = true,\n}: StateSelectProps) {\n const cc = countryCode?.trim().toUpperCase() ?? \"\";\n const isControlled = value !== undefined;\n const [options, setOptions] = useState<GeoOption[]>([]);\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 prevCc = useRef(cc);\n\n onChangeRef.current = onChange;\n controlledRef.current = isControlled;\n selectionRef.current = (isControlled ? value : internal) ?? \"\";\n\n useEffect(() => {\n const previous = prevCc.current;\n if (previous === cc) return;\n prevCc.current = cc;\n if (!previous) return;\n if (!controlledRef.current) setInternal(\"\");\n if (selectionRef.current) onChangeRef.current?.(null);\n }, [cc]);\n\n useEffect(() => {\n if (!cc) {\n setOptions([]);\n setIsLoading(false);\n return;\n }\n\n let cancelled = false;\n setOptions([]);\n setIsLoading(true);\n void fetchStates(cc)\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]);\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={cc ? placeholder : \"Indica un país\"}\n searchPlaceholder=\"Buscar estado, región o municipio…\"\n emptyMessage=\"Sin estados, regiones ni municipios.\"\n options={options}\n value={selected?.code}\n displayValue={selected?.name}\n onChange={(option) => {\n if (!isControlled) setInternal(option.code);\n onChange?.(option);\n }}\n disabled={disabled || !cc}\n isLoading={Boolean(cc) && isLoading}\n showCode={showCode}\n />\n );\n}\n",
15
+ "type": "registry:ui",
16
+ "target": ""
17
+ }
18
+ ]
19
+ }