@traky-app/ui 1.6.3 → 1.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) 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-panel.json +1 -1
  8. package/registry/traky/json/lib-geo-city-codes.json +14 -0
  9. package/registry/traky/json/lib-geo-countries.json +14 -0
  10. package/registry/traky/json/lib-geo-csc.json +17 -0
  11. package/registry/traky/json/lib-geo-locations.json +14 -0
  12. package/registry/traky/json/lib-geo-overrides.json +14 -0
  13. package/registry/traky/json/lib-geo-types.json +14 -0
  14. package/registry/traky/json/lib-google-maps-address.json +14 -0
  15. package/registry/traky/json/lib-phone-countries.json +14 -0
  16. package/registry/traky/json/lib-phone-e164.json +14 -0
  17. package/registry/traky/json/lib-utils.json +1 -1
  18. package/registry/traky/json/office-address-form-dialog.json +17 -0
  19. package/registry/traky/json/office-address-form.json +35 -0
  20. package/registry/traky/json/phone-number-input.json +25 -0
  21. package/registry/traky/json/state-select.json +19 -0
  22. package/registry/traky/lib/geo-city-codes.ts +234 -0
  23. package/registry/traky/lib/geo-countries.ts +75 -0
  24. package/registry/traky/lib/geo-csc.ts +82 -0
  25. package/registry/traky/lib/geo-locations.ts +171 -0
  26. package/registry/traky/lib/geo-overrides.ts +375 -0
  27. package/registry/traky/lib/geo-types.ts +34 -0
  28. package/registry/traky/lib/google-maps-address.ts +100 -0
  29. package/registry/traky/lib/phone-countries.ts +380 -0
  30. package/registry/traky/lib/phone-e164.ts +66 -0
  31. package/registry/traky/lib/utils.ts +24 -0
  32. package/registry/traky/registry.json +180 -0
  33. package/registry/traky/ui/city-select.tsx +113 -0
  34. package/registry/traky/ui/country-select.tsx +233 -0
  35. package/registry/traky/ui/geo-search-select.tsx +136 -0
  36. package/registry/traky/ui/geo-selectors.tsx +162 -0
  37. package/registry/traky/ui/google-maps-address-picker.tsx +422 -0
  38. package/registry/traky/ui/internal-chat-panel.tsx +1417 -71
  39. package/registry/traky/ui/office-address-form-dialog.tsx +16 -0
  40. package/registry/traky/ui/office-address-form.tsx +914 -0
  41. package/registry/traky/ui/phone-number-input.tsx +253 -0
  42. package/registry/traky/ui/state-select.tsx +107 -0
  43. package/src/lib/geo-city-codes.ts +234 -0
  44. package/src/lib/geo-countries.ts +75 -0
  45. package/src/lib/geo-csc.ts +82 -0
  46. package/src/lib/geo-locations.ts +171 -0
  47. package/src/lib/geo-overrides.ts +375 -0
  48. package/src/lib/geo-types.ts +34 -0
  49. package/src/lib/google-maps-address.ts +100 -0
  50. package/src/lib/phone-countries.ts +380 -0
  51. package/src/lib/phone-e164.ts +66 -0
  52. package/src/test/setup.ts +31 -0
  53. package/src/ui/__tests__/geo-selects.test.tsx +504 -0
  54. package/src/ui/city-select.tsx +113 -0
  55. package/src/ui/country-select.tsx +233 -0
  56. package/src/ui/geo-search-select.tsx +136 -0
  57. package/src/ui/geo-selectors.tsx +162 -0
  58. package/src/ui/google-maps-address-picker.tsx +422 -0
  59. package/src/ui/internal-chat-panel.tsx +1417 -71
  60. package/src/ui/office-address-form-dialog.tsx +16 -0
  61. package/src/ui/office-address-form.tsx +914 -0
  62. package/src/ui/phone-number-input.tsx +253 -0
  63. 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-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,35 @@
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
+ "city-select",
14
+ "country-select",
15
+ "entity-icon",
16
+ "google-maps-address-picker",
17
+ "input",
18
+ "label",
19
+ "lib-geo-locations",
20
+ "lib-google-maps-address",
21
+ "select",
22
+ "spinner",
23
+ "state-select",
24
+ "switch",
25
+ "textarea"
26
+ ],
27
+ "files": [
28
+ {
29
+ "path": "ui/office-address-form.tsx",
30
+ "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 { fetchStates } from \"@/lib/geo-locations\"\nimport { googleMapsSelectionToFormPatch } from \"@/lib/google-maps-address\"\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 { StateSelect } from \"@/components/ui/state-select\"\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 { CitySelect } from \"@/components/ui/city-select\"\nimport { CountrySelect } from \"@/components/ui/country-select\"\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 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 manualFields?: 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 manualFields,\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 const countryCode = form.countryCode?.trim().toUpperCase() ?? \"\"\n const [stateCode, setStateCode] = React.useState(\"\")\n\n React.useEffect(() => {\n const stateName = form.stateOrProvince?.trim() ?? \"\"\n if (!countryCode || !stateName) {\n setStateCode(\"\")\n return\n }\n\n let cancelled = false\n void fetchStates(countryCode).then((list) => {\n if (cancelled) return\n const wanted = stateName.toLowerCase()\n const match = list.find(\n (item) =>\n item.name.toLowerCase() === wanted ||\n item.code.toLowerCase() === wanted,\n )\n setStateCode(match?.code ?? \"\")\n })\n\n return () => {\n cancelled = true\n }\n }, [countryCode, form.stateOrProvince])\n\n const countryControl =\n countryField ?? (\n <CountrySelect\n className=\"w-full\"\n size=\"lg\"\n value={form.countryCode}\n disabled={busy}\n searchPlaceholder=\"Escribe el país…\"\n onChange={(country) => {\n setStateCode(\"\")\n onFormChange((prev) => ({\n ...prev,\n countryCode: country.code,\n stateOrProvince: \"\",\n city: \"\",\n }))\n }}\n />\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 ? (manualFields ?? (\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-state\">\n Estado / Región / Municipio\n </Label>\n <StateSelect\n id=\"office-state\"\n className=\"w-full\"\n countryCode={countryCode}\n value={form.stateOrProvince}\n disabled={busy}\n onChange={(option) => {\n setStateCode(option?.code ?? \"\")\n onFormChange((prev) => ({\n ...prev,\n stateOrProvince: option?.name ?? \"\",\n city: \"\",\n }))\n }}\n />\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"office-city\">\n Ciudad / Pueblo / Distrito\n </Label>\n <CitySelect\n id=\"office-city\"\n className=\"w-full\"\n countryCode={countryCode}\n stateCode={stateCode}\n value={form.city}\n disabled={busy}\n onChange={(option) => {\n onFormChange((prev) => ({\n ...prev,\n city: option?.name ?? \"\",\n ...(option?.stateName\n ? { stateOrProvince: option.stateName }\n : {}),\n }))\n }}\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 ))\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 manualFields?: 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 manualFields,\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 manualFields={manualFields}\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 manualFields,\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 manualFields={manualFields}\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",
31
+ "type": "registry:ui",
32
+ "target": ""
33
+ }
34
+ ]
35
+ }
@@ -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
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Códigos cortos de ciudad (logística / etiquetas).
3
+ * Clave: `{ISO2}:{NombreCiudad}` → código de 3 letras.
4
+ */
5
+ const CITY_CODE_OVERRIDES: Record<string, string> = {
6
+ // Ecuador
7
+ "EC:Guayaquil": "GYE",
8
+ "EC:Quito": "UIO",
9
+ "EC:Cuenca": "CUE",
10
+ "EC:Machala": "MCH",
11
+ "EC:Manta": "MEC",
12
+ "EC:Portoviejo": "PVO",
13
+ "EC:Ambato": "ATF",
14
+ "EC:Loja": "LOH",
15
+ "EC:Esmeraldas": "ESM",
16
+ "EC:Ibarra": "IBB",
17
+ "EC:Riobamba": "RIB",
18
+ "EC:Santo Domingo": "STD",
19
+ "EC:Milagro": "MLG",
20
+ "EC:Durán": "DUR",
21
+ "EC:Samborondón": "SBO",
22
+ // Colombia
23
+ "CO:Bogotá": "BOG",
24
+ "CO:Bogota": "BOG",
25
+ "CO:Medellín": "MDE",
26
+ "CO:Medellin": "MDE",
27
+ "CO:Cali": "CAL",
28
+ "CO:Barranquilla": "BAQ",
29
+ "CO:Cartagena": "CTG",
30
+ "CO:Bucaramanga": "BGA",
31
+ "CO:Pereira": "PEI",
32
+ "CO:Manizales": "MZL",
33
+ "CO:Cúcuta": "CUC",
34
+ "CO:Cucuta": "CUC",
35
+ "CO:Ibagué": "IBE",
36
+ "CO:Ibague": "IBE",
37
+ "CO:Santa Marta": "SMR",
38
+ // México
39
+ "MX:Ciudad de México": "CDM",
40
+ "MX:Guadalajara": "GDL",
41
+ "MX:Monterrey": "MTY",
42
+ "MX:Puebla": "PBC",
43
+ "MX:Tijuana": "TIJ",
44
+ "MX:León": "BJX",
45
+ "MX:Leon": "BJX",
46
+ // Perú
47
+ "PE:Lima": "LIM",
48
+ "PE:Arequipa": "AQP",
49
+ "PE:Trujillo": "TRU",
50
+ "PE:Cusco": "CUZ",
51
+ // Chile
52
+ "CL:Santiago": "SCL",
53
+ "CL:Valparaíso": "VAP",
54
+ "CL:Valparaiso": "VAP",
55
+ "CL:Concepción": "CCP",
56
+ "CL:Concepcion": "CCP",
57
+ // Argentina
58
+ "AR:Buenos Aires": "BUE",
59
+ "AR:Córdoba": "COR",
60
+ "AR:Cordoba": "COR",
61
+ "AR:Rosario": "ROS",
62
+ "AR:Mendoza": "MDZ",
63
+ // USA (casilleros)
64
+ "US:Miami": "MIA",
65
+ "US:New York": "NYC",
66
+ "US:Los Angeles": "LAX",
67
+ "US:Houston": "HOU",
68
+ "US:Chicago": "ORD",
69
+ // Nicaragua
70
+ "NI:Chontales": "CHO",
71
+ "NI:Juigalpa": "JUI",
72
+ "NI:Managua": "MGA",
73
+ "NI:León": "LEN",
74
+ "NI:Leon": "LEN",
75
+ "NI:Granada": "GRA",
76
+ "NI:Masaya": "MSY",
77
+ "NI:Matagalpa": "MTG",
78
+ // Reino Unido
79
+ "GB:London": "LON",
80
+ "GB:Greater London": "LON",
81
+ "GB:Manchester": "MAN",
82
+ "GB:Birmingham": "BHX",
83
+ "GB:Liverpool": "LIV",
84
+ "GB:Leeds": "LDS",
85
+ "GB:Glasgow": "GLA",
86
+ "GB:Edinburgh": "EDI",
87
+ "GB:Cardiff": "CDF",
88
+ "GB:Belfast": "BEL",
89
+ "GB:England": "ENG",
90
+ "GB:Scotland": "SCT",
91
+ "GB:Wales": "WLS",
92
+ "GB:Northern Ireland": "NIR",
93
+ // España
94
+ "ES:Madrid": "MAD",
95
+ "ES:Barcelona": "BCN",
96
+ "ES:L'Hospitalet de Llobregat": "LHO",
97
+ "ES:LHospitalet de Llobregat": "LHO",
98
+ "ES:Hospitalet de Llobregat": "LHO",
99
+ "ES:Valencia": "VLC",
100
+ "ES:Sevilla": "SVQ",
101
+ "ES:Zaragoza": "ZAZ",
102
+ "ES:Bilbao": "BIO",
103
+ "ES:Malaga": "AGP",
104
+ "ES:Málaga": "AGP",
105
+ "ES:Murcia": "MJV",
106
+ "ES:Palma": "PMI",
107
+ "ES:Las Palmas de Gran Canaria": "LPA",
108
+ "ES:Las Palmas": "LPA",
109
+ "ES:Alicante": "ALC",
110
+ "ES:Cordoba": "COR",
111
+ "ES:Córdoba": "COR",
112
+ "ES:Valladolid": "VLL",
113
+ "ES:Vigo": "VGO",
114
+ "ES:Gijon": "GIJ",
115
+ "ES:Gijón": "GIJ",
116
+ "ES:Oviedo": "OVD",
117
+ "ES:Leon": "LEN",
118
+ "ES:León": "LEN",
119
+ "ES:Burgos": "BUR",
120
+ "ES:Salamanca": "SLM",
121
+ "ES:San Sebastian": "EAS",
122
+ "ES:San Sebastián": "EAS",
123
+ "ES:Santander": "SDR",
124
+ "ES:Pamplona": "PNA",
125
+ "ES:Granada": "GRX",
126
+ "ES:Toledo": "TOL",
127
+ "ES:Badajoz": "BJZ",
128
+ "ES:Castilla y Leon": "CLY",
129
+ "ES:Castilla y León": "CLY",
130
+ "ES:Andalucia": "AND",
131
+ "ES:Andalucía": "AND",
132
+ "ES:Cataluna": "CAT",
133
+ "ES:Cataluña": "CAT",
134
+ "ES:Comunidad Valenciana": "VAL",
135
+ "ES:Comunidad de Madrid": "MAD",
136
+ "ES:Region de Murcia": "MUR",
137
+ "ES:Región de Murcia": "MUR",
138
+ "ES:Pais Vasco": "PV",
139
+ "ES:País Vasco": "PV",
140
+ "ES:Galicia": "GAL",
141
+ "ES:Extremadura": "EXT",
142
+ "ES:Asturias": "AST",
143
+ "ES:Navarra": "NAV",
144
+ "ES:Cantabria": "CAN",
145
+ "ES:La Rioja": "RIO",
146
+ "ES:Islas Baleares": "BAL",
147
+ "ES:Canarias": "CNR",
148
+ "ES:Ceuta": "CEU",
149
+ "ES:Melilla": "MEL",
150
+ // Venezuela
151
+ "VE:Caracas": "CCS",
152
+ "VE:Maracaibo": "MAR",
153
+ "VE:Valencia": "VLN",
154
+ "VE:Barquisimeto": "BRM",
155
+ "VE:Maracay": "MYC",
156
+ "VE:San Cristobal": "SCI",
157
+ "VE:San Cristóbal": "SCI",
158
+ "VE:Puerto La Cruz": "PLC",
159
+ "VE:Maturin": "MUN",
160
+ "VE:Maturín": "MUN",
161
+ "VE:Barcelona": "BLA",
162
+ "VE:Ciudad Guayana": "CGY",
163
+ "VE:Puerto Ordaz": "PZO",
164
+ "VE:Merida": "MER",
165
+ "VE:Mérida": "MER",
166
+ "VE:Punto Fijo": "PFJ",
167
+ "VE:Porlamar": "PMV",
168
+ "VE:San Fernando de Apure": "SFA",
169
+ "VE:Naguanagua": "NGN",
170
+ "VE:Lecheria": "LEC",
171
+ "VE:Lechería": "LEC",
172
+ "VE:Cumana": "CUM",
173
+ "VE:Cumaná": "CUM",
174
+ "VE:Coro": "COR",
175
+ "VE:Barinas": "BAR",
176
+ "VE:Trujillo": "TRU",
177
+ "VE:Valera": "VLR",
178
+ "VE:San Felipe": "SFP",
179
+ "VE:La Guaira": "LGR",
180
+ "VE:Maiquetia": "MAI",
181
+ "VE:Maiquetía": "MAI",
182
+ "VE:Distrito Capital": "CCS",
183
+ "VE:Apure": "APU",
184
+ "VE:Anzoategui": "ANZ",
185
+ "VE:Anzoátegui": "ANZ",
186
+ "VE:Aragua": "ARA",
187
+ "VE:Bolivar": "BOL",
188
+ "VE:Bolívar": "BOL",
189
+ "VE:Carabobo": "CAR",
190
+ "VE:Miranda": "MIR",
191
+ "VE:Zulia": "ZUL",
192
+ "VE:Tachira": "TAC",
193
+ "VE:Táchira": "TAC",
194
+ "VE:Lara": "LAR",
195
+ "VE:Monagas": "MON",
196
+ "VE:Nueva Esparta": "NEP",
197
+ "VE:Falcon": "FAL",
198
+ "VE:Falcón": "FAL",
199
+ "VE:Guarico": "GUA",
200
+ "VE:Guárico": "GUA",
201
+ "VE:Portuguesa": "POR",
202
+ "VE:Sucre": "SUC",
203
+ "VE:Yaracuy": "YAR",
204
+ "VE:Amazonas": "AMZ",
205
+ "VE:Cojedes": "COJ",
206
+ "VE:Delta Amacuro": "DAM",
207
+ };
208
+
209
+ function normalizeCityKey(cityName: string): string {
210
+ return cityName
211
+ .normalize("NFD")
212
+ .replace(/\p{M}/gu, "")
213
+ .replace(/[''`´’]/g, "")
214
+ .trim();
215
+ }
216
+
217
+ function fallbackCityCode(cityName: string): string {
218
+ const letters = normalizeCityKey(cityName).replace(/[^a-zA-Z]/g, "");
219
+ if (letters.length >= 3) return letters.slice(0, 3).toUpperCase();
220
+ return letters.toUpperCase().padEnd(3, "X");
221
+ }
222
+
223
+ export function resolveCityCode(
224
+ countryCode: string,
225
+ cityName: string,
226
+ ): string {
227
+ const cc = countryCode.trim().toUpperCase();
228
+ const normalized = normalizeCityKey(cityName);
229
+ const direct = CITY_CODE_OVERRIDES[`${cc}:${normalized}`];
230
+ if (direct) return direct;
231
+ const withoutAccents = CITY_CODE_OVERRIDES[`${cc}:${cityName.trim()}`];
232
+ if (withoutAccents) return withoutAccents;
233
+ return fallbackCityCode(cityName);
234
+ }
@@ -0,0 +1,75 @@
1
+ /** Países para UI: nombres vía Intl; catálogo vía `@countrystatecity/countries-browser`. */
2
+
3
+ export interface CountryOption {
4
+ code: string;
5
+ short: string;
6
+ long: string;
7
+ }
8
+
9
+ /** @deprecated Usar `CountryOption`. */
10
+ export type LatinCountry = CountryOption;
11
+
12
+ export function spanishCountryName(isoCode: string, fallback?: string): string {
13
+ const code = isoCode.trim().toUpperCase();
14
+ if (!code) return fallback?.trim() || "";
15
+ try {
16
+ const name = new Intl.DisplayNames(["es"], { type: "region" }).of(code);
17
+ return name?.trim() || fallback?.trim() || code;
18
+ } catch {
19
+ return fallback?.trim() || code;
20
+ }
21
+ }
22
+
23
+ /** Mapea la respuesta CSC a opciones de UI (nombre en español, orden A–Z). */
24
+ export function mapGeoCountries(
25
+ apiCountries: Array<{ code: string; name: string }>,
26
+ ): CountryOption[] {
27
+ const byCode = new Map<string, CountryOption>();
28
+
29
+ for (const c of apiCountries) {
30
+ const code = c.code.trim().toUpperCase();
31
+ if (!code) continue;
32
+ byCode.set(code, {
33
+ code,
34
+ short: code,
35
+ long: spanishCountryName(code, c.name),
36
+ });
37
+ }
38
+
39
+ return [...byCode.values()].sort((a, b) =>
40
+ a.long.localeCompare(b.long, "es"),
41
+ );
42
+ }
43
+
44
+ /** @deprecated Usar `mapGeoCountries`. */
45
+ export const mergeGeoCountries = mapGeoCountries;
46
+
47
+ export function getCountryByCode(
48
+ countryCode?: string | null,
49
+ ): CountryOption | undefined {
50
+ if (!countryCode?.trim()) return undefined;
51
+ const normalized = countryCode.trim().toUpperCase();
52
+ return {
53
+ code: normalized,
54
+ short: normalized,
55
+ long: spanishCountryName(normalized, normalized),
56
+ };
57
+ }
58
+
59
+ /** Nombre en español a partir del código ISO; si no hay match, usa el fallback o el código. */
60
+ export function getCountryDisplayName(
61
+ countryCode?: string | null,
62
+ fallbackName?: string | null,
63
+ ): string {
64
+ if (!countryCode?.trim()) {
65
+ if (fallbackName?.trim()) return fallbackName.trim();
66
+ return "—";
67
+ }
68
+ return spanishCountryName(countryCode, fallbackName ?? undefined);
69
+ }
70
+
71
+ /** Fax en oficinas/direcciones cuando hay país ISO-2. */
72
+ export function isCommonFaxCountry(countryCode?: string | null): boolean {
73
+ const code = countryCode?.trim().toUpperCase();
74
+ return Boolean(code && /^[A-Z]{2}$/.test(code));
75
+ }