feat: Integración completa Bot WhatsApp con IA y Evolution
This commit is contained in:
parent
ec2bd9528e
commit
ac8b971d82
369
package-lock.json
generated
369
package-lock.json
generated
@ -8,6 +8,7 @@
|
|||||||
"name": "almeira-web",
|
"name": "almeira-web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@google/genai": "^2.18.0",
|
||||||
"@livekit/components-react": "^2.9.21",
|
"@livekit/components-react": "^2.9.21",
|
||||||
"@livekit/components-styles": "^1.2.0",
|
"@livekit/components-styles": "^1.2.0",
|
||||||
"@tailwindcss/typography": "^0.5.20",
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
@ -751,6 +752,30 @@
|
|||||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@google/genai": {
|
||||||
|
"version": "2.18.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.18.0.tgz",
|
||||||
|
"integrity": "sha512-uy9gWVTAZXuA/2tld0QJl/QNiGEn4QOmfX4PiRgZQmeFQRQhojpD/Gf41SPnBJmjEnT83a+h4AdU1HHYaYvVDw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"google-auth-library": "^10.3.0",
|
||||||
|
"p-retry": "^4.6.2",
|
||||||
|
"protobufjs": "^7.5.4",
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.25.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@modelcontextprotocol/sdk": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@ -1699,6 +1724,63 @@
|
|||||||
"node": ">=12.4.0"
|
"node": ">=12.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@protobufjs/aspromise": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@protobufjs/base64": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@protobufjs/codegen": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@protobufjs/eventemitter": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@protobufjs/fetch": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@protobufjs/aspromise": "^1.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@protobufjs/float": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@protobufjs/path": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@protobufjs/pool": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@protobufjs/utf8": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/@remirror/core-constants": {
|
"node_modules/@remirror/core-constants": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
|
||||||
@ -2603,7 +2685,6 @@
|
|||||||
"version": "20.19.37",
|
"version": "20.19.37",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
|
||||||
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
|
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
@ -2627,6 +2708,12 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/retry": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/trusted-types": {
|
"node_modules/@types/trusted-types": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
@ -3671,6 +3758,26 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-js": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.7",
|
"version": "2.10.7",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz",
|
||||||
@ -3765,6 +3872,12 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/call-bind": {
|
"node_modules/call-bind": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
||||||
@ -4144,6 +4257,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/data-uri-to-buffer": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/data-urls": {
|
"node_modules/data-urls": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||||
@ -4454,6 +4576,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.313",
|
"version": "1.5.313",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz",
|
||||||
@ -5142,6 +5273,12 @@
|
|||||||
"node": ">=0.8.x"
|
"node": ">=0.8.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/extend": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@ -5212,6 +5349,29 @@
|
|||||||
"reusify": "^1.0.4"
|
"reusify": "^1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fetch-blob": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/jimmywarting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paypal",
|
||||||
|
"url": "https://paypal.me/jimmywarting"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-domexception": "^1.0.0",
|
||||||
|
"web-streams-polyfill": "^3.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20 || >= 14.13"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
@ -5328,6 +5488,18 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/formdata-polyfill": {
|
||||||
|
"version": "4.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||||
|
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fetch-blob": "^3.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fraction.js": {
|
"node_modules/fraction.js": {
|
||||||
"version": "5.3.4",
|
"version": "5.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||||
@ -5382,6 +5554,34 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gaxios": {
|
||||||
|
"version": "7.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz",
|
||||||
|
"integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"extend": "^3.0.2",
|
||||||
|
"https-proxy-agent": "^7.0.1",
|
||||||
|
"node-fetch": "^3.3.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gcp-metadata": {
|
||||||
|
"version": "8.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
|
||||||
|
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"gaxios": "^7.0.0",
|
||||||
|
"google-logging-utils": "^1.0.0",
|
||||||
|
"json-bigint": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/generator-function": {
|
"node_modules/generator-function": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
|
||||||
@ -5523,6 +5723,32 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/google-auth-library": {
|
||||||
|
"version": "10.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz",
|
||||||
|
"integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.0",
|
||||||
|
"ecdsa-sig-formatter": "^1.0.11",
|
||||||
|
"gaxios": "^7.1.4",
|
||||||
|
"gcp-metadata": "8.1.2",
|
||||||
|
"google-logging-utils": "1.1.3",
|
||||||
|
"jws": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/google-logging-utils": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/gopd": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
@ -6450,6 +6676,27 @@
|
|||||||
"setimmediate": "^1.0.5"
|
"setimmediate": "^1.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@ -6897,6 +7144,12 @@
|
|||||||
"url": "https://tidelift.com/funding/github/npm/loglevel"
|
"url": "https://tidelift.com/funding/github/npm/loglevel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/long": {
|
||||||
|
"version": "5.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||||
|
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/loose-envify": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
@ -7244,6 +7497,26 @@
|
|||||||
"node": ">=18.17"
|
"node": ">=18.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-domexception": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||||
|
"deprecated": "Use your platform's native DOMException instead",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/jimmywarting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://paypal.me/jimmywarting"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-exports-info": {
|
"node_modules/node-exports-info": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
|
||||||
@ -7263,6 +7536,24 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "3.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||||
|
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"data-uri-to-buffer": "^4.0.0",
|
||||||
|
"fetch-blob": "^3.1.4",
|
||||||
|
"formdata-polyfill": "^4.0.10"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/node-fetch"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.36",
|
"version": "2.0.36",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
|
||||||
@ -7479,6 +7770,19 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/p-retry": {
|
||||||
|
"version": "4.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
|
||||||
|
"integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/retry": "0.12.0",
|
||||||
|
"retry": "^0.13.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pako": {
|
"node_modules/pako": {
|
||||||
"version": "1.0.11",
|
"version": "1.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
@ -7943,6 +8247,29 @@
|
|||||||
"prosemirror-transform": "^1.1.0"
|
"prosemirror-transform": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/protobufjs": {
|
||||||
|
"version": "7.6.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
|
||||||
|
"integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@protobufjs/aspromise": "^1.1.2",
|
||||||
|
"@protobufjs/base64": "^1.1.2",
|
||||||
|
"@protobufjs/codegen": "^2.0.5",
|
||||||
|
"@protobufjs/eventemitter": "^1.1.1",
|
||||||
|
"@protobufjs/fetch": "^1.1.1",
|
||||||
|
"@protobufjs/float": "^1.0.2",
|
||||||
|
"@protobufjs/path": "^1.1.2",
|
||||||
|
"@protobufjs/pool": "^1.1.0",
|
||||||
|
"@protobufjs/utf8": "^1.1.1",
|
||||||
|
"@types/node": ">=13.7.0",
|
||||||
|
"long": "^5.3.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
@ -8146,6 +8473,15 @@
|
|||||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/retry": {
|
||||||
|
"version": "0.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
||||||
|
"integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reusify": {
|
"node_modules/reusify": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||||
@ -9135,7 +9471,6 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unrs-resolver": {
|
"node_modules/unrs-resolver": {
|
||||||
@ -9313,6 +9648,15 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/web-streams-polyfill": {
|
||||||
|
"version": "3.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||||
|
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||||
@ -9486,6 +9830,27 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xml": {
|
"node_modules/xml": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
"check-config": "node --env-file=.env.local check_config.js"
|
"check-config": "node --env-file=.env.local check_config.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@google/genai": "^2.18.0",
|
||||||
"@livekit/components-react": "^2.9.21",
|
"@livekit/components-react": "^2.9.21",
|
||||||
"@livekit/components-styles": "^1.2.0",
|
"@livekit/components-styles": "^1.2.0",
|
||||||
"@tailwindcss/typography": "^0.5.20",
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
|
|||||||
@ -125,7 +125,12 @@ export async function saveEntityAction(entity: string, formData: FormData) {
|
|||||||
const res = await createDocumentServer(collectionId, data);
|
const res = await createDocumentServer(collectionId, data);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
revalidatePath(`/dashboard/${entity}`);
|
revalidatePath(`/dashboard/${entity}`);
|
||||||
redirect(`/dashboard/${entity}`);
|
const returnUrl = formData.get('returnUrl') as string;
|
||||||
|
if (returnUrl && returnUrl.startsWith('/dashboard/')) {
|
||||||
|
redirect(returnUrl);
|
||||||
|
} else {
|
||||||
|
redirect(`/dashboard/${entity}`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(res.error || 'Error desconocido al crear documento');
|
throw new Error(res.error || 'Error desconocido al crear documento');
|
||||||
}
|
}
|
||||||
@ -153,7 +158,12 @@ export async function updateEntityAction(entity: string, id: string, formData: F
|
|||||||
const res = await updateDocumentServer(collectionId, id, data);
|
const res = await updateDocumentServer(collectionId, id, data);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
revalidatePath(`/dashboard/${entity}`);
|
revalidatePath(`/dashboard/${entity}`);
|
||||||
redirect(`/dashboard/${entity}`);
|
const returnUrl = formData.get('returnUrl') as string;
|
||||||
|
if (returnUrl && returnUrl.startsWith('/dashboard/')) {
|
||||||
|
redirect(returnUrl);
|
||||||
|
} else {
|
||||||
|
redirect(`/dashboard/${entity}`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(res.error || 'Error desconocido al actualizar documento');
|
throw new Error(res.error || 'Error desconocido al actualizar documento');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,15 @@ export const runtime = 'nodejs';
|
|||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
let hostUrl = request.headers.get('x-forwarded-host') || request.headers.get('host') || 'almeiraproducciones.com';
|
||||||
|
// Handle cases where x-forwarded-host might have multiple hosts separated by commas
|
||||||
|
if (hostUrl.includes(',')) {
|
||||||
|
hostUrl = hostUrl.split(',')[0].trim();
|
||||||
|
}
|
||||||
|
const protocol = request.headers.get('x-forwarded-proto') || (hostUrl.includes('localhost') ? 'http' : 'https');
|
||||||
|
const customOrigin = `${protocol}://${hostUrl}`;
|
||||||
|
|
||||||
const q = searchParams.get('q') || '';
|
const q = searchParams.get('q') || '';
|
||||||
const sort = searchParams.get('sort') || 'fecha_publicacion';
|
const sort = searchParams.get('sort') || 'fecha_publicacion';
|
||||||
const dir = searchParams.get('dir') || 'desc';
|
const dir = searchParams.get('dir') || 'desc';
|
||||||
@ -174,14 +183,15 @@ export async function GET(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const externalLink = `${customOrigin}/${post.slug || post.$id}`;
|
||||||
docChildren.push(
|
docChildren.push(
|
||||||
new Paragraph({
|
new Paragraph({
|
||||||
children: [
|
children: [
|
||||||
new ExternalHyperlink({
|
new ExternalHyperlink({
|
||||||
children: [
|
children: [
|
||||||
new TextRun({ text: `https://almeira.com.co/${post.slug || post.$id}`, style: "Hyperlink" }),
|
new TextRun({ text: externalLink, style: "Hyperlink" }),
|
||||||
],
|
],
|
||||||
link: `https://almeira.com.co/${post.slug || post.$id}`,
|
link: externalLink,
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
spacing: { after: 400 }
|
spacing: { after: 400 }
|
||||||
|
|||||||
@ -1,34 +1,166 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { ID, Databases } from 'node-appwrite';
|
import { ID, Query } from 'node-appwrite';
|
||||||
|
import { InputFile } from 'node-appwrite/file';
|
||||||
import { createAdminClient } from '@/lib/appwrite.server';
|
import { createAdminClient } from '@/lib/appwrite.server';
|
||||||
import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
|
import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
|
||||||
|
import { processWhatsAppMessage } from '@/lib/gemini';
|
||||||
|
import { sendWhatsAppMessage, getBase64FromMediaMessage } from '@/lib/evolution';
|
||||||
|
|
||||||
|
// Grupo de publicaciones (tomado del test) o puedes dejarlo abierto a todos los grupos
|
||||||
|
const TARGET_GROUP_JID = '573134848203-1408546044@g.us';
|
||||||
|
|
||||||
|
// Función para obtener el último día del mes actual
|
||||||
|
function getLastDayOfMonth(): string {
|
||||||
|
const date = new Date();
|
||||||
|
const lastDay = new Date(date.getFullYear(), date.getMonth() + 1, 0);
|
||||||
|
return lastDay.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSlug(titulo: string): string {
|
||||||
|
return titulo.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9\s-]/g, '').trim().replace(/\s+/g, '-').replace(/-+/g, '-').substring(0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateString(str: string, length: number): string {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.length > length ? str.substring(0, length - 3) + '...' : str;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const payload = await req.json();
|
const payload = await req.json();
|
||||||
|
const eventType = payload.event;
|
||||||
// Estructura típica de Evolution API
|
|
||||||
const messageData = {
|
|
||||||
sender_name: payload.data?.pushName || payload.data?.key?.remoteJid || 'Usuario WhatsApp',
|
|
||||||
phone_number: payload.data?.key?.remoteJid?.split('@')[0] || 'Desconocido',
|
|
||||||
content: payload.data?.message?.conversation || payload.data?.message?.extendedTextMessage?.text || 'Mensaje Multimedia',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
status: 'pending', // Por defecto entran para moderación
|
|
||||||
platform: 'whatsapp',
|
|
||||||
avatar: payload.data?.profilePicUrl || ''
|
|
||||||
};
|
|
||||||
|
|
||||||
const { databases } = await createAdminClient();
|
// Solo procesamos nuevos mensajes
|
||||||
|
if (eventType !== 'messages.upsert') {
|
||||||
|
return NextResponse.json({ success: true, message: 'Ignored event' }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
// NOTA: Asegúrate de que la colección 'whatsapp_messages' exista en Appwrite
|
const data = payload.data;
|
||||||
await databases.createDocument(
|
const remoteJid = data?.key?.remoteJid;
|
||||||
|
const fromMe = data?.key?.fromMe;
|
||||||
|
|
||||||
|
// Ignorar mensajes enviados por el bot mismo
|
||||||
|
if (fromMe) {
|
||||||
|
return NextResponse.json({ success: true, message: 'Ignored bot message' }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recomendado: filtrar por el grupo específico
|
||||||
|
// if (remoteJid !== TARGET_GROUP_JID) {
|
||||||
|
// return NextResponse.json({ success: true, message: 'Ignored other chat' }, { status: 200 });
|
||||||
|
// }
|
||||||
|
|
||||||
|
const messageData = data?.message || {};
|
||||||
|
|
||||||
|
// Extraer texto del mensaje
|
||||||
|
const text = messageData.conversation || messageData.extendedTextMessage?.text || messageData.imageMessage?.caption || messageData.videoMessage?.caption || '';
|
||||||
|
const lowerText = text.trim().toLowerCase();
|
||||||
|
|
||||||
|
const contextInfo = messageData.extendedTextMessage?.contextInfo;
|
||||||
|
const isReply = !!contextInfo?.stanzaId;
|
||||||
|
const quotedText = contextInfo?.quotedMessage?.conversation || contextInfo?.quotedMessage?.extendedTextMessage?.text || '';
|
||||||
|
|
||||||
|
const { databases, storage } = await createAdminClient();
|
||||||
|
|
||||||
|
// LOGIC 1: APROBAR PUBLICACIÓN
|
||||||
|
if (isReply && lowerText.includes('aprobar')) {
|
||||||
|
// Extraer ID de la referencia en el mensaje del bot
|
||||||
|
const refMatch = quotedText.match(/Ref:\s*([a-zA-Z0-9_-]+)/);
|
||||||
|
if (refMatch && refMatch[1]) {
|
||||||
|
const postId = refMatch[1];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Actualizar Appwrite
|
||||||
|
await databases.updateDocument(
|
||||||
|
APPWRITE_ALMEIRA_CONFIG.databaseId,
|
||||||
|
APPWRITE_ALMEIRA_CONFIG.collections.posts,
|
||||||
|
postId,
|
||||||
|
{ estado: 'publicado' }
|
||||||
|
);
|
||||||
|
|
||||||
|
await sendWhatsAppMessage(remoteJid, `✅ La publicación ha sido aprobada y publicada exitosamente.\n(ID: ${postId})`);
|
||||||
|
return NextResponse.json({ success: true, message: 'Post aprobado' }, { status: 200 });
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Error approving post:", e);
|
||||||
|
await sendWhatsAppMessage(remoteJid, `❌ Error al publicar: ${e.message}`);
|
||||||
|
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LOGIC 2: PROCESAR NUEVO MENSAJE PARA GEMINI
|
||||||
|
// Evitámos tratar de procesar respuestas cortas o mensajes vacíos
|
||||||
|
if (!text && !messageData.imageMessage) {
|
||||||
|
return NextResponse.json({ success: true, message: 'Ignored empty message' }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificamos si tiene multimedia
|
||||||
|
let base64Media = undefined;
|
||||||
|
let mimeType = undefined;
|
||||||
|
let imageId = undefined;
|
||||||
|
|
||||||
|
if (messageData.imageMessage || messageData.videoMessage || messageData.documentMessage) {
|
||||||
|
const media = await getBase64FromMediaMessage(data);
|
||||||
|
if (media) {
|
||||||
|
base64Media = media.base64;
|
||||||
|
mimeType = media.mimetype;
|
||||||
|
|
||||||
|
// Subimos a Appwrite Storage
|
||||||
|
const buffer = Buffer.from(base64Media, 'base64');
|
||||||
|
// Appwrite requiere un InputFile o similar. node-appwrite maneja InputFile.fromBuffer
|
||||||
|
const extension = mimeType.split('/')[1] || 'jpg';
|
||||||
|
const fileName = `wa_${Date.now()}.${extension}`;
|
||||||
|
const file = InputFile.fromBuffer(buffer, fileName);
|
||||||
|
|
||||||
|
const uploadedFile = await storage.createFile(
|
||||||
|
APPWRITE_ALMEIRA_CONFIG.mediaBucketId,
|
||||||
|
ID.unique(),
|
||||||
|
file
|
||||||
|
);
|
||||||
|
imageId = uploadedFile.$id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Llamar a Gemini
|
||||||
|
const parsed = await processWhatsAppMessage(text, base64Media, mimeType);
|
||||||
|
|
||||||
|
// Determinar fecha de vencimiento
|
||||||
|
let fechaVencimiento = parsed.fecha_vencimiento;
|
||||||
|
if ((parsed.tipo === 'evento' || parsed.tipo === 'aviso') && !fechaVencimiento) {
|
||||||
|
fechaVencimiento = getLastDayOfMonth();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear Post en Appwrite
|
||||||
|
const newPost = await databases.createDocument(
|
||||||
APPWRITE_ALMEIRA_CONFIG.databaseId,
|
APPWRITE_ALMEIRA_CONFIG.databaseId,
|
||||||
'whatsapp_messages',
|
APPWRITE_ALMEIRA_CONFIG.collections.posts,
|
||||||
ID.unique(),
|
ID.unique(),
|
||||||
messageData
|
{
|
||||||
|
titulo: truncateString(parsed.titulo, 70),
|
||||||
|
slug: generateSlug(parsed.titulo) + '-' + Date.now().toString().slice(-4),
|
||||||
|
tipo: parsed.tipo,
|
||||||
|
resumen_redes: truncateString(parsed.seo_descripcion, 155),
|
||||||
|
seo_titulo: truncateString(parsed.titulo, 60),
|
||||||
|
seo_descripcion: truncateString(parsed.seo_descripcion, 155),
|
||||||
|
contenido: parsed.contenido,
|
||||||
|
estado: 'borrador', // Se requiere aprobación
|
||||||
|
fecha_publicacion: new Date().toISOString(),
|
||||||
|
...(fechaVencimiento && { fecha_finalizacion: new Date(fechaVencimiento).toISOString() }),
|
||||||
|
...(imageId && { imagen_destacada_id: imageId })
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return NextResponse.json({ success: true, message: 'Mensaje recibido y guardado' }, { status: 200 });
|
// Notificar al grupo de WhatsApp pidiendo aprobación
|
||||||
|
const requestText = `📰 *Publicación Procesada (Pendiente de Aprobación)*\n\n`
|
||||||
|
+ `*Título:* ${parsed.titulo}\n`
|
||||||
|
+ `*Tipo:* ${parsed.tipo.toUpperCase()}\n`
|
||||||
|
+ `*SEO:* ${parsed.seo_descripcion}\n\n`
|
||||||
|
+ `Para aprobar esta publicación, responde a este mismo mensaje con la palabra "Aprobar".\n\n`
|
||||||
|
+ `(Ref: ${newPost.$id})`;
|
||||||
|
|
||||||
|
await sendWhatsAppMessage(remoteJid, requestText);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Mensaje procesado' }, { status: 200 });
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error in WhatsApp Webhook:', error);
|
console.error('Error in WhatsApp Webhook:', error);
|
||||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||||
|
|||||||
@ -8,9 +8,11 @@ import { redirect } from 'next/navigation';
|
|||||||
import { EntityForm } from '@/components/dashboard/EntityForm';
|
import { EntityForm } from '@/components/dashboard/EntityForm';
|
||||||
import type { AuthUser, Post } from '@/types/publications';
|
import type { AuthUser, Post } from '@/types/publications';
|
||||||
|
|
||||||
export default async function EditEntityPage({ params }: { params: Promise<{ entity: string, id: string }> }) {
|
export default async function EditEntityPage({ params, searchParams }: { params: Promise<{ entity: string, id: string }>, searchParams: Promise<{ returnUrl?: string }> }) {
|
||||||
const { entity, id } = await params;
|
const { entity, id } = await params;
|
||||||
|
const sParams = await searchParams;
|
||||||
|
const returnUrl = sParams.returnUrl || '';
|
||||||
|
|
||||||
const collectionId = collectionMap[entity];
|
const collectionId = collectionMap[entity];
|
||||||
const fields = SCHEMA_MAP[entity] || [];
|
const fields = SCHEMA_MAP[entity] || [];
|
||||||
|
|
||||||
@ -36,29 +38,29 @@ export default async function EditEntityPage({ params }: { params: Promise<{ ent
|
|||||||
// Fetch the existing document
|
// Fetch the existing document
|
||||||
let document: any = null;
|
let document: any = null;
|
||||||
try {
|
try {
|
||||||
document = JSON.parse(JSON.stringify(await sessionClient.databases.getDocument(
|
document = JSON.parse(JSON.stringify(await sessionClient.databases.getDocument(
|
||||||
APPWRITE_ALMEIRA_CONFIG.databaseId,
|
APPWRITE_ALMEIRA_CONFIG.databaseId,
|
||||||
collectionId,
|
collectionId,
|
||||||
id
|
id
|
||||||
)));
|
)));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return (
|
return (
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<h1 className="text-red-500 font-bold">No se encontró el registro o no tienes permisos.</h1>
|
<h1 className="text-red-500 font-bold">No se encontró el registro o no tienes permisos.</h1>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch relationship options
|
// Fetch relationship options
|
||||||
const relationData: Record<string, any[]> = {};
|
const relationData: Record<string, any[]> = {};
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
fields.filter(f => f.type === 'relation' && f.relationCollection).map(async (field) => {
|
fields.filter(f => f.type === 'relation' && f.relationCollection).map(async (field) => {
|
||||||
try {
|
try {
|
||||||
const relKey = field.relationCollection as string;
|
const relKey = field.relationCollection as string;
|
||||||
if(!collectionMap[relKey]) return;
|
if (!collectionMap[relKey]) return;
|
||||||
const res = await sessionClient.databases.listDocuments(APPWRITE_ALMEIRA_CONFIG.databaseId, collectionMap[relKey], [Query.limit(100)]);
|
const res = await sessionClient.databases.listDocuments(APPWRITE_ALMEIRA_CONFIG.databaseId, collectionMap[relKey], [Query.limit(100)]);
|
||||||
relationData[field.name] = JSON.parse(JSON.stringify(res.documents));
|
relationData[field.name] = JSON.parse(JSON.stringify(res.documents));
|
||||||
} catch(e) { console.error('Error obteniendo relaciones de', field.name, e); }
|
} catch (e) { console.error('Error obteniendo relaciones de', field.name, e); }
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -80,20 +82,22 @@ export default async function EditEntityPage({ params }: { params: Promise<{ ent
|
|||||||
relationData={relationData}
|
relationData={relationData}
|
||||||
submitAction={boundUpdate}
|
submitAction={boundUpdate}
|
||||||
appwriteConfig={appwriteConfig}
|
appwriteConfig={appwriteConfig}
|
||||||
|
returnUrl={returnUrl}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── OTHER ENTITIES: Use the generic EntityForm ────────
|
// ── OTHER ENTITIES: Use the generic EntityForm ────────
|
||||||
return (
|
return (
|
||||||
<EntityForm
|
<EntityForm
|
||||||
entity={entity}
|
entity={entity}
|
||||||
entityName={entityName}
|
entityName={entityName}
|
||||||
initialData={document}
|
initialData={document}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
relationData={relationData}
|
relationData={relationData}
|
||||||
submitAction={boundUpdate}
|
submitAction={boundUpdate}
|
||||||
appwriteConfig={{ endpoint, projectId }}
|
appwriteConfig={{ endpoint, projectId }}
|
||||||
|
returnUrl={returnUrl}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,10 +12,14 @@ import type { AuthUser } from '@/types/publications';
|
|||||||
*/
|
*/
|
||||||
export default async function NewEntityPage({
|
export default async function NewEntityPage({
|
||||||
params,
|
params,
|
||||||
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ entity: string }>;
|
params: Promise<{ entity: string }>;
|
||||||
|
searchParams: Promise<{ returnUrl?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { entity } = await params;
|
const { entity } = await params;
|
||||||
|
const sParams = await searchParams;
|
||||||
|
const returnUrl = sParams.returnUrl || '';
|
||||||
|
|
||||||
const collectionId = collectionMap[entity];
|
const collectionId = collectionMap[entity];
|
||||||
const fields = SCHEMA_MAP[entity] || [];
|
const fields = SCHEMA_MAP[entity] || [];
|
||||||
@ -33,7 +37,7 @@ export default async function NewEntityPage({
|
|||||||
if (!user) redirect('/login');
|
if (!user) redirect('/login');
|
||||||
|
|
||||||
// Pre-load all relation data server-side
|
// Pre-load all relation data server-side
|
||||||
const relationData: Record<string, Array<{ $id: string; [key: string]: unknown }>> = {};
|
const relationData: Record<string, Array<{ $id: string;[key: string]: unknown }>> = {};
|
||||||
try {
|
try {
|
||||||
const client = await createAdminClient();
|
const client = await createAdminClient();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@ -76,6 +80,7 @@ export default async function NewEntityPage({
|
|||||||
relationData={relationData}
|
relationData={relationData}
|
||||||
submitAction={boundSave}
|
submitAction={boundSave}
|
||||||
appwriteConfig={appwriteConfig}
|
appwriteConfig={appwriteConfig}
|
||||||
|
returnUrl={returnUrl}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -97,6 +102,7 @@ export default async function NewEntityPage({
|
|||||||
relationData={relationData}
|
relationData={relationData}
|
||||||
submitAction={boundSave}
|
submitAction={boundSave}
|
||||||
appwriteConfig={{ endpoint, projectId }}
|
appwriteConfig={{ endpoint, projectId }}
|
||||||
|
returnUrl={returnUrl}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import { Client, Databases } from 'appwrite';
|
|||||||
|
|
||||||
// Minimalistic image preview for relations with 'media'
|
// Minimalistic image preview for relations with 'media'
|
||||||
const MediaPreview = ({ fileId, bucketId, endpoint, projectId }: any) => {
|
const MediaPreview = ({ fileId, bucketId, endpoint, projectId }: any) => {
|
||||||
if(!fileId) return null;
|
if (!fileId) return null;
|
||||||
const url = `${endpoint}/storage/buckets/${bucketId}/files/${fileId}/view?project=${projectId}`;
|
const url = `${endpoint}/storage/buckets/${bucketId}/files/${fileId}/view?project=${projectId}`;
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 rounded-xl overflow-hidden border border-slate-800 bg-slate-950 aspect-video relative group">
|
<div className="mt-2 rounded-xl overflow-hidden border border-slate-800 bg-slate-950 aspect-video relative group">
|
||||||
@ -27,303 +27,306 @@ const MediaPreview = ({ fileId, bucketId, endpoint, projectId }: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface EntityFormProps {
|
interface EntityFormProps {
|
||||||
entity: string;
|
entity: string;
|
||||||
entityName: string;
|
entityName: string;
|
||||||
initialData?: any;
|
initialData?: any;
|
||||||
fields: FieldDefinition[];
|
fields: FieldDefinition[];
|
||||||
relationData: Record<string, any[]>;
|
relationData: Record<string, any[]>;
|
||||||
submitAction: (formData: FormData) => Promise<any>;
|
submitAction: (formData: FormData) => Promise<any>;
|
||||||
appwriteConfig: {
|
appwriteConfig: {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
}
|
};
|
||||||
|
returnUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EntityForm = ({
|
export const EntityForm = ({
|
||||||
entity,
|
entity,
|
||||||
entityName,
|
entityName,
|
||||||
initialData,
|
initialData,
|
||||||
fields,
|
fields,
|
||||||
relationData,
|
relationData,
|
||||||
submitAction,
|
submitAction,
|
||||||
appwriteConfig
|
appwriteConfig,
|
||||||
|
returnUrl
|
||||||
}: EntityFormProps) => {
|
}: EntityFormProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [title, setTitle] = useState(initialData?.titulo || initialData?.nombre || '');
|
const [title, setTitle] = useState(initialData?.titulo || initialData?.nombre || '');
|
||||||
const [slug, setSlug] = useState(initialData?.slug || '');
|
const [slug, setSlug] = useState(initialData?.slug || '');
|
||||||
const [isAutoSlug, setIsAutoSlug] = useState(!initialData?.slug);
|
const [isAutoSlug, setIsAutoSlug] = useState(!initialData?.slug);
|
||||||
const [featuredImageId, setFeaturedImageId] = useState(initialData?.imagen_destacada_id || '');
|
const [featuredImageId, setFeaturedImageId] = useState(initialData?.imagen_destacada_id || '');
|
||||||
const [editorValues, setEditorValues] = useState<Record<string, string>>({});
|
const [editorValues, setEditorValues] = useState<Record<string, string>>({});
|
||||||
const [localRelationData, setLocalRelationData] = useState(relationData);
|
const [localRelationData, setLocalRelationData] = useState(relationData);
|
||||||
const [activeTab, setActiveTab] = useState('');
|
const [activeTab, setActiveTab] = useState('');
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [modalEntity, setModalEntity] = useState('');
|
const [modalEntity, setModalEntity] = useState('');
|
||||||
const [activeRelationField, setActiveRelationField] = useState('');
|
const [activeRelationField, setActiveRelationField] = useState('');
|
||||||
|
|
||||||
// Initialize editor values
|
// Initialize editor values
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initialValues: Record<string, string> = {};
|
const initialValues: Record<string, string> = {};
|
||||||
fields.forEach(f => {
|
fields.forEach(f => {
|
||||||
if (f.type === 'textarea') {
|
if (f.type === 'textarea') {
|
||||||
initialValues[f.name] = initialData?.[f.name] || '';
|
initialValues[f.name] = initialData?.[f.name] || '';
|
||||||
}
|
|
||||||
});
|
|
||||||
setEditorValues(initialValues);
|
|
||||||
}, [fields, initialData]);
|
|
||||||
|
|
||||||
// Update slug automatically if auto-slug is enabled
|
|
||||||
useEffect(() => {
|
|
||||||
if (isAutoSlug) {
|
|
||||||
const generated = title
|
|
||||||
.toLowerCase()
|
|
||||||
.normalize("NFD")
|
|
||||||
.replace(/[\u0300-\u036f]/g, "")
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '');
|
|
||||||
setSlug(generated);
|
|
||||||
}
|
|
||||||
}, [title, isAutoSlug]);
|
|
||||||
|
|
||||||
const sections = Array.from(new Set(fields.map(f => f.section || 'General')));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (sections.length > 0 && !activeTab) {
|
|
||||||
setActiveTab(sections[0]);
|
|
||||||
}
|
|
||||||
}, [sections, activeTab]);
|
|
||||||
|
|
||||||
const refreshRelation = async (field: string, collectionId: string) => {
|
|
||||||
const client = new Client()
|
|
||||||
.setEndpoint(appwriteConfig.endpoint)
|
|
||||||
.setProject(appwriteConfig.projectId);
|
|
||||||
const databases = new Databases(client);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await databases.listDocuments('almeira', collectionId, []);
|
|
||||||
setLocalRelationData(prev => ({ ...prev, [field]: res.documents }));
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error al refrescar relación:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const formData = new FormData(e.currentTarget);
|
|
||||||
|
|
||||||
// Add editor values to formData
|
|
||||||
for (const [key, value] of Object.entries(editorValues)) {
|
|
||||||
formData.set(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure featured image ID is set if present
|
|
||||||
if (featuredImageId) {
|
|
||||||
formData.set('logotipo', featuredImageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
startTransition(async () => {
|
|
||||||
await submitAction(formData);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Organize fields into Main Content and Sidebar Metadata
|
|
||||||
const sidebarFieldNames = ['estado', 'fecha_publicacion', 'fecha_finalizacion', 'slug', 'logotipo', 'categoria_id', 'tipo_publicacion_id'];
|
|
||||||
const mainFields = fields.filter(f => !sidebarFieldNames.includes(f.name));
|
|
||||||
const sidebarFields = fields.filter(f => sidebarFieldNames.includes(f.name));
|
|
||||||
|
|
||||||
const getDefValue = (field: FieldDefinition) => {
|
|
||||||
const val = initialData ? initialData[field.name] : null;
|
|
||||||
|
|
||||||
// Set smart defaults for new items
|
|
||||||
if (!initialData) {
|
|
||||||
if (field.name === 'logotipo') {
|
|
||||||
return featuredImageId;
|
|
||||||
} if (field.name === 'fecha_publicacion' || field.type === 'date') {
|
|
||||||
// Get local ISO string for datetime-local input
|
|
||||||
const now = new Date();
|
|
||||||
return new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
|
||||||
}
|
|
||||||
if (field.type === 'boolean') return 'false';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (val === undefined || val === null) return '';
|
|
||||||
if (field.type === 'boolean') return val ? 'true' : 'false';
|
|
||||||
if (field.type === 'date') {
|
|
||||||
if (!val) return '';
|
|
||||||
try { return new Date(val).toISOString().slice(0, 16); } catch(e) { return ''; }
|
|
||||||
}
|
|
||||||
if (field.type === 'relation') return typeof val === 'object' ? val.$id : val;
|
|
||||||
return val;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<form onSubmit={handleSubmit} className="max-w-7xl mx-auto space-y-8 animate-fade-in pb-20">
|
|
||||||
{/* Header Sticky */}
|
|
||||||
<div className="sticky top-4 z-40 flex items-center justify-between bg-slate-900/80 backdrop-blur-xl border border-slate-800 p-4 rounded-2xl shadow-2xl shadow-cyan-500/10">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Link href={`/dashboard/${entity}`} className="p-2 text-slate-400 hover:text-white bg-slate-800 rounded-xl transition-all">
|
|
||||||
<ArrowLeft size={20} />
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-black uppercase font-outfit tracking-tighter text-white">
|
|
||||||
{initialData ? 'Editando' : 'Nuevo'}: <span className="text-cyan-400">{entityName}</span>
|
|
||||||
</h1>
|
|
||||||
{initialData && <p className="text-[10px] font-mono text-slate-500 uppercase">{initialData.$id}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.back()}
|
|
||||||
className="hidden md:block px-6 py-2.5 rounded-xl text-sm font-bold text-slate-400 hover:text-white transition-colors"
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
Descartar
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isPending}
|
|
||||||
className="flex items-center gap-2 bg-gradient-to-r from-blue-600 to-cyan-500 hover:from-blue-500 hover:to-cyan-400 text-white text-sm font-black uppercase tracking-widest px-8 py-2.5 rounded-xl shadow-[0_0_20px_rgba(6,182,212,0.3)] transition-all hover:scale-105 active:scale-95 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isPending ? <div className="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin"></div> : <Save size={18} />}
|
|
||||||
<span>{initialData ? 'Actualizar' : 'Publicar'}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-1 flex overflow-x-auto gap-1 mb-6 sticky top-24 z-30 shadow-lg">
|
|
||||||
{sections.map(section => (
|
|
||||||
<button
|
|
||||||
key={section}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveTab(section)}
|
|
||||||
className={`px-6 py-2.5 rounded-2xl text-xs font-black uppercase tracking-widest transition-all ${activeTab === section ? 'bg-cyan-500 text-white shadow-lg shadow-cyan-500/20' : 'text-slate-500 hover:text-slate-300 hover:bg-slate-800'}`}
|
|
||||||
>
|
|
||||||
{section}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-1 gap-8">
|
|
||||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-8 shadow-xl relative overflow-hidden">
|
|
||||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-blue-600 to-cyan-500 opacity-50"></div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-12 gap-x-8 gap-y-10">
|
|
||||||
{fields.filter(f => (f.section || 'General') === activeTab).map(field => {
|
|
||||||
const defaultValue = getDefValue(field);
|
|
||||||
const gridSpanMap: Record<number, string> = {
|
|
||||||
1: 'col-span-1', 2: 'col-span-2', 3: 'col-span-3', 4: 'col-span-4',
|
|
||||||
5: 'col-span-5', 6: 'col-span-6', 7: 'col-span-7', 8: 'col-span-8',
|
|
||||||
9: 'col-span-9', 10: 'col-span-10', 11: 'col-span-11', 12: 'col-span-12'
|
|
||||||
};
|
|
||||||
const gridSpan = gridSpanMap[field.gridSize || 12] || 'col-span-12';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={field.name} className={`${gridSpan} flex flex-col gap-2 group`}>
|
|
||||||
<div className="flex justify-between items-center mb-1">
|
|
||||||
<label className="text-[10px] font-black uppercase tracking-widest text-slate-500 group-focus-within:text-cyan-400 transition-colors">
|
|
||||||
{field.label} {field.required && <span className="text-pink-500">*</span>}
|
|
||||||
</label>
|
|
||||||
{field.type === 'relation' && (
|
|
||||||
<span className="text-[8px] font-bold text-slate-600 uppercase tracking-tighter">Double click to create quick +</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{field.name === 'titulo' || field.name === 'nombre_entidad' ? (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name={field.name}
|
|
||||||
required={field.required}
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder={`Nombre de ${field.label}...`}
|
|
||||||
maxLength={250}
|
|
||||||
className="w-full bg-transparent border-b-2 border-slate-800 py-2 text-2xl font-bold text-white focus:outline-none focus:border-cyan-500 transition-all placeholder:text-slate-700"
|
|
||||||
/>
|
|
||||||
) : field.type === 'textarea' ? (
|
|
||||||
<RichTextEditor
|
|
||||||
label={field.label}
|
|
||||||
value={editorValues[field.name] || ''}
|
|
||||||
onChange={(val) => setEditorValues(prev => ({ ...prev, [field.name]: val }))}
|
|
||||||
/>
|
|
||||||
) : field.type === 'relation' ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<select
|
|
||||||
name={field.name}
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
required={field.required}
|
|
||||||
onDoubleClick={() => {
|
|
||||||
setModalEntity(field.relationCollection as string);
|
|
||||||
setActiveRelationField(field.name);
|
|
||||||
setIsModalOpen(true);
|
|
||||||
}}
|
|
||||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-5 py-3 text-sm text-white focus:outline-none focus:border-cyan-500 transition-all appearance-none pr-10 hover:border-slate-700 cursor-pointer"
|
|
||||||
>
|
|
||||||
<option value="">Seleccionar vinculación...</option>
|
|
||||||
{localRelationData[field.name]?.map(relDoc => (
|
|
||||||
<option key={relDoc.$id} value={relDoc.$id}>
|
|
||||||
{relDoc[field.relationLabel || 'titulo'] || relDoc.$id}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{field.relationCollection === 'media' && (
|
|
||||||
<MediaPreview
|
|
||||||
fileId={defaultValue}
|
|
||||||
bucketId="media"
|
|
||||||
endpoint={appwriteConfig.endpoint}
|
|
||||||
projectId={appwriteConfig.projectId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : field.type === 'select' ? (
|
|
||||||
<select name={field.name} defaultValue={defaultValue} required={field.required} className="w-full bg-slate-950 border border-slate-800 rounded-xl px-5 py-3 text-sm text-white focus:outline-none focus:border-cyan-500 transition-all appearance-none pr-10 capitalize">
|
|
||||||
{field.options?.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
|
||||||
</select>
|
|
||||||
) : field.type === 'date' ? (
|
|
||||||
<input type="datetime-local" name={field.name} defaultValue={defaultValue} required={field.required} className="w-full bg-slate-950 border border-slate-800 rounded-xl px-5 py-3 text-sm text-white focus:outline-none focus:border-cyan-500 transition-all [color-scheme:dark]" />
|
|
||||||
) : field.type === 'boolean' ? (
|
|
||||||
<select name={field.name} defaultValue={String(defaultValue)} required={field.required} className="w-full bg-slate-950 border border-slate-800 rounded-xl px-5 py-3 text-sm text-white focus:outline-none focus:border-cyan-500 transition-all">
|
|
||||||
<option value="true">Sí (Activo)</option>
|
|
||||||
<option value="false">No (Inactivo)</option>
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
type={field.type === 'number' ? 'number' : field.type === 'url' ? 'url' : 'text'}
|
|
||||||
name={field.name}
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
required={field.required}
|
|
||||||
maxLength={field.type !== 'number' ? 255 : undefined}
|
|
||||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-5 py-3 text-sm text-white focus:outline-none focus:border-cyan-500 transition-all"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<QuickCreateModal
|
|
||||||
entity={modalEntity}
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
onClose={() => setIsModalOpen(false)}
|
|
||||||
onSuccess={async (newId) => {
|
|
||||||
const relField = fields.find(f => f.name === activeRelationField);
|
|
||||||
if (relField?.relationCollection) {
|
|
||||||
await refreshRelation(activeRelationField, collectionMap[relField.relationCollection]);
|
|
||||||
// Set wait to ensure state has updated options before selecting
|
|
||||||
setTimeout(() => {
|
|
||||||
const selectElement = document.querySelector(`select[name="${activeRelationField}"]`) as HTMLSelectElement;
|
|
||||||
if (selectElement) {
|
|
||||||
selectElement.value = newId;
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
}
|
||||||
}}
|
});
|
||||||
/>
|
setEditorValues(initialValues);
|
||||||
</>
|
}, [fields, initialData]);
|
||||||
);
|
|
||||||
|
// Update slug automatically if auto-slug is enabled
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAutoSlug) {
|
||||||
|
const generated = title
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
setSlug(generated);
|
||||||
|
}
|
||||||
|
}, [title, isAutoSlug]);
|
||||||
|
|
||||||
|
const sections = Array.from(new Set(fields.map(f => f.section || 'General')));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sections.length > 0 && !activeTab) {
|
||||||
|
setActiveTab(sections[0]);
|
||||||
|
}
|
||||||
|
}, [sections, activeTab]);
|
||||||
|
|
||||||
|
const refreshRelation = async (field: string, collectionId: string) => {
|
||||||
|
const client = new Client()
|
||||||
|
.setEndpoint(appwriteConfig.endpoint)
|
||||||
|
.setProject(appwriteConfig.projectId);
|
||||||
|
const databases = new Databases(client);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await databases.listDocuments('almeira', collectionId, []);
|
||||||
|
setLocalRelationData(prev => ({ ...prev, [field]: res.documents }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error al refrescar relación:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
|
||||||
|
// Add editor values to formData
|
||||||
|
for (const [key, value] of Object.entries(editorValues)) {
|
||||||
|
formData.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure featured image ID is set if present
|
||||||
|
if (featuredImageId) {
|
||||||
|
formData.set('logotipo', featuredImageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
await submitAction(formData);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Organize fields into Main Content and Sidebar Metadata
|
||||||
|
const sidebarFieldNames = ['estado', 'fecha_publicacion', 'fecha_finalizacion', 'slug', 'logotipo', 'categoria_id', 'tipo_publicacion_id'];
|
||||||
|
const mainFields = fields.filter(f => !sidebarFieldNames.includes(f.name));
|
||||||
|
const sidebarFields = fields.filter(f => sidebarFieldNames.includes(f.name));
|
||||||
|
|
||||||
|
const getDefValue = (field: FieldDefinition) => {
|
||||||
|
const val = initialData ? initialData[field.name] : null;
|
||||||
|
|
||||||
|
// Set smart defaults for new items
|
||||||
|
if (!initialData) {
|
||||||
|
if (field.name === 'logotipo') {
|
||||||
|
return featuredImageId;
|
||||||
|
} if (field.name === 'fecha_publicacion' || field.type === 'date') {
|
||||||
|
// Get local ISO string for datetime-local input
|
||||||
|
const now = new Date();
|
||||||
|
return new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
|
if (field.type === 'boolean') return 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (val === undefined || val === null) return '';
|
||||||
|
if (field.type === 'boolean') return val ? 'true' : 'false';
|
||||||
|
if (field.type === 'date') {
|
||||||
|
if (!val) return '';
|
||||||
|
try { return new Date(val).toISOString().slice(0, 16); } catch (e) { return ''; }
|
||||||
|
}
|
||||||
|
if (field.type === 'relation') return typeof val === 'object' ? val.$id : val;
|
||||||
|
return val;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<form onSubmit={handleSubmit} className="max-w-7xl mx-auto space-y-8 animate-fade-in pb-20">
|
||||||
|
{returnUrl && <input type="hidden" name="returnUrl" value={returnUrl} />}
|
||||||
|
{/* Header Sticky */}
|
||||||
|
<div className="sticky top-4 z-40 flex items-center justify-between bg-slate-900/80 backdrop-blur-xl border border-slate-800 p-4 rounded-2xl shadow-2xl shadow-cyan-500/10">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href={`/dashboard/${entity}`} className="p-2 text-slate-400 hover:text-white bg-slate-800 rounded-xl transition-all">
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-black uppercase font-outfit tracking-tighter text-white">
|
||||||
|
{initialData ? 'Editando' : 'Nuevo'}: <span className="text-cyan-400">{entityName}</span>
|
||||||
|
</h1>
|
||||||
|
{initialData && <p className="text-[10px] font-mono text-slate-500 uppercase">{initialData.$id}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="hidden md:block px-6 py-2.5 rounded-xl text-sm font-bold text-slate-400 hover:text-white transition-colors"
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
Descartar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="flex items-center gap-2 bg-gradient-to-r from-blue-600 to-cyan-500 hover:from-blue-500 hover:to-cyan-400 text-white text-sm font-black uppercase tracking-widest px-8 py-2.5 rounded-xl shadow-[0_0_20px_rgba(6,182,212,0.3)] transition-all hover:scale-105 active:scale-95 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isPending ? <div className="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin"></div> : <Save size={18} />}
|
||||||
|
<span>{initialData ? 'Actualizar' : 'Publicar'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-1 flex overflow-x-auto gap-1 mb-6 sticky top-24 z-30 shadow-lg">
|
||||||
|
{sections.map(section => (
|
||||||
|
<button
|
||||||
|
key={section}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab(section)}
|
||||||
|
className={`px-6 py-2.5 rounded-2xl text-xs font-black uppercase tracking-widest transition-all ${activeTab === section ? 'bg-cyan-500 text-white shadow-lg shadow-cyan-500/20' : 'text-slate-500 hover:text-slate-300 hover:bg-slate-800'}`}
|
||||||
|
>
|
||||||
|
{section}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-1 gap-8">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-8 shadow-xl relative overflow-hidden">
|
||||||
|
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-blue-600 to-cyan-500 opacity-50"></div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-12 gap-x-8 gap-y-10">
|
||||||
|
{fields.filter(f => (f.section || 'General') === activeTab).map(field => {
|
||||||
|
const defaultValue = getDefValue(field);
|
||||||
|
const gridSpanMap: Record<number, string> = {
|
||||||
|
1: 'col-span-1', 2: 'col-span-2', 3: 'col-span-3', 4: 'col-span-4',
|
||||||
|
5: 'col-span-5', 6: 'col-span-6', 7: 'col-span-7', 8: 'col-span-8',
|
||||||
|
9: 'col-span-9', 10: 'col-span-10', 11: 'col-span-11', 12: 'col-span-12'
|
||||||
|
};
|
||||||
|
const gridSpan = gridSpanMap[field.gridSize || 12] || 'col-span-12';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={field.name} className={`${gridSpan} flex flex-col gap-2 group`}>
|
||||||
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-widest text-slate-500 group-focus-within:text-cyan-400 transition-colors">
|
||||||
|
{field.label} {field.required && <span className="text-pink-500">*</span>}
|
||||||
|
</label>
|
||||||
|
{field.type === 'relation' && (
|
||||||
|
<span className="text-[8px] font-bold text-slate-600 uppercase tracking-tighter">Double click to create quick +</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{field.name === 'titulo' || field.name === 'nombre_entidad' ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={field.name}
|
||||||
|
required={field.required}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder={`Nombre de ${field.label}...`}
|
||||||
|
maxLength={250}
|
||||||
|
className="w-full bg-transparent border-b-2 border-slate-800 py-2 text-2xl font-bold text-white focus:outline-none focus:border-cyan-500 transition-all placeholder:text-slate-700"
|
||||||
|
/>
|
||||||
|
) : field.type === 'textarea' ? (
|
||||||
|
<RichTextEditor
|
||||||
|
label={field.label}
|
||||||
|
value={editorValues[field.name] || ''}
|
||||||
|
onChange={(val) => setEditorValues(prev => ({ ...prev, [field.name]: val }))}
|
||||||
|
/>
|
||||||
|
) : field.type === 'relation' ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<select
|
||||||
|
name={field.name}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
required={field.required}
|
||||||
|
onDoubleClick={() => {
|
||||||
|
setModalEntity(field.relationCollection as string);
|
||||||
|
setActiveRelationField(field.name);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-5 py-3 text-sm text-white focus:outline-none focus:border-cyan-500 transition-all appearance-none pr-10 hover:border-slate-700 cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="">Seleccionar vinculación...</option>
|
||||||
|
{localRelationData[field.name]?.map(relDoc => (
|
||||||
|
<option key={relDoc.$id} value={relDoc.$id}>
|
||||||
|
{relDoc[field.relationLabel || 'titulo'] || relDoc.$id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{field.relationCollection === 'media' && (
|
||||||
|
<MediaPreview
|
||||||
|
fileId={defaultValue}
|
||||||
|
bucketId="media"
|
||||||
|
endpoint={appwriteConfig.endpoint}
|
||||||
|
projectId={appwriteConfig.projectId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : field.type === 'select' ? (
|
||||||
|
<select name={field.name} defaultValue={defaultValue} required={field.required} className="w-full bg-slate-950 border border-slate-800 rounded-xl px-5 py-3 text-sm text-white focus:outline-none focus:border-cyan-500 transition-all appearance-none pr-10 capitalize">
|
||||||
|
{field.options?.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||||
|
</select>
|
||||||
|
) : field.type === 'date' ? (
|
||||||
|
<input type="datetime-local" name={field.name} defaultValue={defaultValue} required={field.required} className="w-full bg-slate-950 border border-slate-800 rounded-xl px-5 py-3 text-sm text-white focus:outline-none focus:border-cyan-500 transition-all [color-scheme:dark]" />
|
||||||
|
) : field.type === 'boolean' ? (
|
||||||
|
<select name={field.name} defaultValue={String(defaultValue)} required={field.required} className="w-full bg-slate-950 border border-slate-800 rounded-xl px-5 py-3 text-sm text-white focus:outline-none focus:border-cyan-500 transition-all">
|
||||||
|
<option value="true">Sí (Activo)</option>
|
||||||
|
<option value="false">No (Inactivo)</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={field.type === 'number' ? 'number' : field.type === 'url' ? 'url' : 'text'}
|
||||||
|
name={field.name}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
required={field.required}
|
||||||
|
maxLength={field.type !== 'number' ? 255 : undefined}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-5 py-3 text-sm text-white focus:outline-none focus:border-cyan-500 transition-all"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<QuickCreateModal
|
||||||
|
entity={modalEntity}
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
onSuccess={async (newId) => {
|
||||||
|
const relField = fields.find(f => f.name === activeRelationField);
|
||||||
|
if (relField?.relationCollection) {
|
||||||
|
await refreshRelation(activeRelationField, collectionMap[relField.relationCollection]);
|
||||||
|
// Set wait to ensure state has updated options before selecting
|
||||||
|
setTimeout(() => {
|
||||||
|
const selectElement = document.querySelector(`select[name="${activeRelationField}"]`) as HTMLSelectElement;
|
||||||
|
if (selectElement) {
|
||||||
|
selectElement.value = newId;
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -80,12 +80,13 @@ const textareaClass = inputClass + ' min-h-[100px] resize-y';
|
|||||||
// MAIN COMPONENT
|
// MAIN COMPONENT
|
||||||
// ─────────────────────────────────────────────
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
|
export const PublicationLayout: React.FC<PublicationLayoutProps & { returnUrl?: string }> = ({
|
||||||
initialData,
|
initialData,
|
||||||
relationData,
|
relationData,
|
||||||
submitAction,
|
submitAction,
|
||||||
user,
|
user,
|
||||||
appwriteConfig,
|
appwriteConfig,
|
||||||
|
returnUrl,
|
||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
@ -110,18 +111,18 @@ export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!seoDirty.titulo && titulo) {
|
if (!seoDirty.titulo && titulo) {
|
||||||
setSeoTitulo(titulo.substring(0, 60));
|
setSeoTitulo(titulo.substring(0, 60));
|
||||||
}
|
}
|
||||||
}, [titulo, seoDirty.titulo]);
|
}, [titulo, seoDirty.titulo]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!seoDirty.descripcion) {
|
if (!seoDirty.descripcion) {
|
||||||
// Strip HTML if it's the rich text
|
// Strip HTML if it's the rich text
|
||||||
const richTextSnippet = contenido?.replace(/<[^>]*>?/gm, '').substring(0, 160) || '';
|
const richTextSnippet = contenido?.replace(/<[^>]*>?/gm, '').substring(0, 160) || '';
|
||||||
const sourceMeta = resumen_redes || richTextSnippet;
|
const sourceMeta = resumen_redes || richTextSnippet;
|
||||||
if (sourceMeta) {
|
if (sourceMeta) {
|
||||||
setSeoDescripcion(sourceMeta.substring(0, 160));
|
setSeoDescripcion(sourceMeta.substring(0, 160));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [contenido, resumen_redes, seoDirty.descripcion]);
|
}, [contenido, resumen_redes, seoDirty.descripcion]);
|
||||||
|
|
||||||
@ -224,16 +225,17 @@ export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
|
{returnUrl && <input type="hidden" name="returnUrl" value={returnUrl} />}
|
||||||
{/* 12-column grid layout: left=8cols content, right=4cols sidebar */}
|
{/* 12-column grid layout: left=8cols content, right=4cols sidebar */}
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-12 gap-8">
|
<div className="grid grid-cols-1 xl:grid-cols-12 gap-8">
|
||||||
|
|
||||||
{/* ── LEFT COLUMN (8/12) ─────────────────────── */}
|
{/* ── LEFT COLUMN (8/12) ─────────────────────── */}
|
||||||
<div className="xl:col-span-8 space-y-8">
|
<div className="xl:col-span-8 space-y-8">
|
||||||
|
|
||||||
{/* SECCIÓN: Datos Básicos */}
|
{/* SECCIÓN: Datos Básicos */}
|
||||||
<div className="bg-slate-900/60 border border-white/8 rounded-2xl p-6">
|
<div className="bg-slate-900/60 border border-white/8 rounded-2xl p-6">
|
||||||
<FormSection title="Datos Básicos" icon={<FileText size={16} />} columns={1}>
|
<FormSection title="Datos Básicos" icon={<FileText size={16} />} columns={1}>
|
||||||
|
|
||||||
{/* Título */}
|
{/* Título */}
|
||||||
<FieldWrapper label="Título" required error={validationErrors.titulo}>
|
<FieldWrapper label="Título" required error={validationErrors.titulo}>
|
||||||
<input
|
<input
|
||||||
@ -276,7 +278,7 @@ export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
{/* Inject initial Author if it's a new post (Autocompletion UX) */}
|
{/* Inject initial Author if it's a new post (Autocompletion UX) */}
|
||||||
{!initialData?.$id && user?.$id && (
|
{!initialData?.$id && user?.$id && (
|
||||||
<input type="hidden" name="autor_id" value={user.$id} />
|
<input type="hidden" name="autor_id" value={user.$id} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</FieldWrapper>
|
</FieldWrapper>
|
||||||
@ -326,8 +328,8 @@ export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
|
|||||||
name="seo_titulo"
|
name="seo_titulo"
|
||||||
value={seoTitulo}
|
value={seoTitulo}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSeoTitulo(e.target.value);
|
setSeoTitulo(e.target.value);
|
||||||
setSeoDirty(prev => ({ ...prev, titulo: true }));
|
setSeoDirty(prev => ({ ...prev, titulo: true }));
|
||||||
}}
|
}}
|
||||||
maxLength={60}
|
maxLength={60}
|
||||||
placeholder="Título para buscadores..."
|
placeholder="Título para buscadores..."
|
||||||
@ -344,8 +346,8 @@ export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
|
|||||||
name="seo_descripcion"
|
name="seo_descripcion"
|
||||||
value={seoDescripcion}
|
value={seoDescripcion}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSeoDescripcion(e.target.value);
|
setSeoDescripcion(e.target.value);
|
||||||
setSeoDirty(prev => ({ ...prev, descripcion: true }));
|
setSeoDirty(prev => ({ ...prev, descripcion: true }));
|
||||||
}}
|
}}
|
||||||
maxLength={160}
|
maxLength={160}
|
||||||
placeholder="Descripción para resultados de búsqueda..."
|
placeholder="Descripción para resultados de búsqueda..."
|
||||||
@ -455,10 +457,10 @@ export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
|
|||||||
<div className="bg-slate-900/60 border border-white/8 rounded-2xl p-6">
|
<div className="bg-slate-900/60 border border-white/8 rounded-2xl p-6">
|
||||||
<FormSection title="Multimedia" icon={<ImageIcon size={16} />} columns={1}>
|
<FormSection title="Multimedia" icon={<ImageIcon size={16} />} columns={1}>
|
||||||
<MediaGridSelector
|
<MediaGridSelector
|
||||||
value={imagenDestacadaId}
|
value={imagenDestacadaId}
|
||||||
onChange={(val) => setImagenDestacadaId(val)}
|
onChange={(val) => setImagenDestacadaId(val)}
|
||||||
label="Imagen Destacada"
|
label="Imagen Destacada"
|
||||||
mode="field"
|
mode="field"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FieldWrapper label="URL de Video (YouTube/Vimeo)" error={validationErrors.video_url}>
|
<FieldWrapper label="URL de Video (YouTube/Vimeo)" error={validationErrors.video_url}>
|
||||||
|
|||||||
@ -34,7 +34,7 @@ import { useToast } from './ToastProvider';
|
|||||||
interface PublicationsListProps {
|
interface PublicationsListProps {
|
||||||
initialPosts: Post[];
|
initialPosts: Post[];
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
relationData: Record<string, Array<{ $id: string; [key: string]: unknown }>>;
|
relationData: Record<string, Array<{ $id: string;[key: string]: unknown }>>;
|
||||||
submitEditAction: (id: string, formData: FormData) => Promise<{ success: boolean; error?: string; data?: unknown } | void>;
|
submitEditAction: (id: string, formData: FormData) => Promise<{ success: boolean; error?: string; data?: unknown } | void>;
|
||||||
appwriteConfig: {
|
appwriteConfig: {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
@ -88,7 +88,7 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
|
|||||||
page?: number;
|
page?: number;
|
||||||
}) => {
|
}) => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
// Default page to 1 on filter changes
|
// Default page to 1 on filter changes
|
||||||
params.set('page', (updates.page ?? 1).toString());
|
params.set('page', (updates.page ?? 1).toString());
|
||||||
|
|
||||||
@ -153,7 +153,8 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
|
|||||||
// ── HANDLERS ────────────────────────────────
|
// ── HANDLERS ────────────────────────────────
|
||||||
|
|
||||||
const handleEdit = useCallback((post: Post) => {
|
const handleEdit = useCallback((post: Post) => {
|
||||||
router.push(`/dashboard/${entity}/${post.$id}`);
|
const returnUrl = encodeURIComponent(window.location.pathname + window.location.search);
|
||||||
|
router.push(`/dashboard/${entity}/${post.$id}?returnUrl=${returnUrl}`);
|
||||||
}, [entity, router]);
|
}, [entity, router]);
|
||||||
|
|
||||||
const handleDelete = useCallback((postId: string) => {
|
const handleDelete = useCallback((postId: string) => {
|
||||||
@ -296,15 +297,18 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
|
|||||||
|
|
||||||
{/* New post button */}
|
{/* New post button */}
|
||||||
<PermissionGuard allowedRoles={['editor', 'admin']} userRole={userRole}>
|
<PermissionGuard allowedRoles={['editor', 'admin']} userRole={userRole}>
|
||||||
<Link
|
<button
|
||||||
href={`/dashboard/${entity}/new`}
|
onClick={() => {
|
||||||
|
const returnUrl = encodeURIComponent(window.location.pathname + window.location.search);
|
||||||
|
router.push(`/dashboard/${entity}/new?returnUrl=${returnUrl}`);
|
||||||
|
}}
|
||||||
className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500
|
className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500
|
||||||
text-white font-bold text-sm rounded-xl transition-all
|
text-white font-bold text-sm rounded-xl transition-all
|
||||||
shadow-[0_0_20px_rgba(6,182,212,0.25)] hover:shadow-[0_0_30px_rgba(6,182,212,0.4)] hover:scale-105 active:scale-95 whitespace-nowrap"
|
shadow-[0_0_20px_rgba(6,182,212,0.25)] hover:shadow-[0_0_30px_rgba(6,182,212,0.4)] hover:scale-105 active:scale-95 whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
Nueva publicación
|
Nueva publicación
|
||||||
</Link>
|
</button>
|
||||||
</PermissionGuard>
|
</PermissionGuard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -321,12 +325,12 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
|
|||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||||
{filteredAndSortedPosts.map((post) => {
|
{filteredAndSortedPosts.map((post) => {
|
||||||
const enrichedPost = { ...post };
|
const enrichedPost = { ...post };
|
||||||
|
|
||||||
if (typeof enrichedPost.imagen_destacada_id === 'string' && relationData['imagen_destacada_id']) {
|
if (typeof enrichedPost.imagen_destacada_id === 'string' && relationData['imagen_destacada_id']) {
|
||||||
const mediaDoc = relationData['imagen_destacada_id'].find(m => m.$id === enrichedPost.imagen_destacada_id);
|
const mediaDoc = relationData['imagen_destacada_id'].find(m => m.$id === enrichedPost.imagen_destacada_id);
|
||||||
if (mediaDoc) enrichedPost.imagen_destacada_id = mediaDoc as any;
|
if (mediaDoc) enrichedPost.imagen_destacada_id = mediaDoc as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof enrichedPost.tipo_publicacion_id === 'string' && relationData['tipo_publicacion_id']) {
|
if (typeof enrichedPost.tipo_publicacion_id === 'string' && relationData['tipo_publicacion_id']) {
|
||||||
const typeDoc = relationData['tipo_publicacion_id'].find(t => t.$id === enrichedPost.tipo_publicacion_id);
|
const typeDoc = relationData['tipo_publicacion_id'].find(t => t.$id === enrichedPost.tipo_publicacion_id);
|
||||||
if (typeDoc) enrichedPost.tipo_publicacion_id = typeDoc as any;
|
if (typeDoc) enrichedPost.tipo_publicacion_id = typeDoc as any;
|
||||||
@ -358,14 +362,17 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<PermissionGuard allowedRoles={['editor', 'admin']} userRole={userRole}>
|
<PermissionGuard allowedRoles={['editor', 'admin']} userRole={userRole}>
|
||||||
<Link
|
<button
|
||||||
href={`/dashboard/${entity}/new`}
|
onClick={() => {
|
||||||
|
const returnUrl = encodeURIComponent(window.location.pathname + window.location.search);
|
||||||
|
router.push(`/dashboard/${entity}/new?returnUrl=${returnUrl}`);
|
||||||
|
}}
|
||||||
className="flex items-center gap-2 px-5 py-2.5 bg-slate-800 hover:bg-slate-700
|
className="flex items-center gap-2 px-5 py-2.5 bg-slate-800 hover:bg-slate-700
|
||||||
border border-slate-700 text-white text-sm font-bold rounded-xl transition-all"
|
border border-slate-700 text-white text-sm font-bold rounded-xl transition-all"
|
||||||
>
|
>
|
||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
Crear publicación
|
Crear publicación
|
||||||
</Link>
|
</button>
|
||||||
</PermissionGuard>
|
</PermissionGuard>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Client, Account, Databases } from 'node-appwrite';
|
import { Client, Account, Databases, Storage } from 'node-appwrite';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
// Robust instance-level patch for Appwrite Next.js Server Components
|
// Robust instance-level patch for Appwrite Next.js Server Components
|
||||||
@ -70,7 +70,7 @@ export async function createAdminClient() {
|
|||||||
.setKey((process.env.APPWRITE_API_KEY || '').trim());
|
.setKey((process.env.APPWRITE_API_KEY || '').trim());
|
||||||
|
|
||||||
patchClientInstance(client);
|
patchClientInstance(client);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -79,6 +79,9 @@ export async function createAdminClient() {
|
|||||||
},
|
},
|
||||||
get databases() {
|
get databases() {
|
||||||
return new Databases(client);
|
return new Databases(client);
|
||||||
|
},
|
||||||
|
get storage() {
|
||||||
|
return new Storage(client);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -90,11 +93,11 @@ export async function getLoggedInUser() {
|
|||||||
|
|
||||||
// Verificación de bypass (Evitamos imprimir log repetitivo a menos que sea un error)
|
// Verificación de bypass (Evitamos imprimir log repetitivo a menos que sea un error)
|
||||||
if (sessionCookie?.value === "admin-bypass-session") {
|
if (sessionCookie?.value === "admin-bypass-session") {
|
||||||
return {
|
return {
|
||||||
$id: "admin-emergency",
|
$id: "admin-emergency",
|
||||||
name: "Administrador (Emergencia)",
|
name: "Administrador (Emergencia)",
|
||||||
email: "admin@almeira.com.co",
|
email: "admin@almeira.com.co",
|
||||||
prefs: { role: 'admin' }
|
prefs: { role: 'admin' }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,7 +109,7 @@ export async function getLoggedInUser() {
|
|||||||
if (!sessionClient) {
|
if (!sessionClient) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await sessionClient.account.get();
|
return await sessionClient.account.get();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Next.js uses a specific error to bailout of static generation. We MUST re-throw it.
|
// Next.js uses a specific error to bailout of static generation. We MUST re-throw it.
|
||||||
@ -114,7 +117,7 @@ export async function getLoggedInUser() {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (error?.code !== 401) {
|
if (error?.code !== 401) {
|
||||||
console.error("getLoggedInUser: Error al validar sesión:", error.message || error);
|
console.error("getLoggedInUser: Error al validar sesión:", error.message || error);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,67 +1,40 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || '';
|
const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL;
|
||||||
const EVOLUTION_API_KEY = process.env.EVOLUTION_API_KEY || '';
|
const EVOLUTION_API_KEY = process.env.EVOLUTION_API_KEY;
|
||||||
const EVOLUTION_INSTANCE_NAME = process.env.EVOLUTION_INSTANCE_NAME || '';
|
const EVOLUTION_INSTANCE_NAME = process.env.EVOLUTION_INSTANCE_NAME;
|
||||||
|
|
||||||
/**
|
const headers = {
|
||||||
* Send a simple text message via Evolution API
|
apikey: EVOLUTION_API_KEY || '',
|
||||||
*/
|
'Content-Type': 'application/json'
|
||||||
export async function sendWhatsAppMessage(number: string, text: string) {
|
};
|
||||||
if (!EVOLUTION_API_URL || !EVOLUTION_API_KEY || !EVOLUTION_INSTANCE_NAME) {
|
|
||||||
console.warn('Evolution API no está configurada en las variables de entorno.');
|
|
||||||
return { success: false, error: 'Configuración faltante' };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
export async function sendWhatsAppMessage(numberId: string, text: string) {
|
||||||
const response = await axios.post(
|
const url = `${EVOLUTION_API_URL}/message/sendText/${EVOLUTION_INSTANCE_NAME}`;
|
||||||
`${EVOLUTION_API_URL}/message/sendText/${EVOLUTION_INSTANCE_NAME}`,
|
|
||||||
{
|
const response = await axios.post(url, {
|
||||||
number,
|
number: numberId,
|
||||||
text
|
text: text
|
||||||
},
|
}, { headers });
|
||||||
{
|
|
||||||
headers: {
|
return response.data;
|
||||||
'apikey': EVOLUTION_API_KEY,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return { success: true, data: response.data };
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error al enviar mensaje de WhatsApp:', error?.response?.data || error.message);
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function getBase64FromMediaMessage(messagePayload: any): Promise<{ base64: string, mimetype: string } | null> {
|
||||||
* Send an image with text/caption via Evolution API
|
try {
|
||||||
*/
|
const url = `${EVOLUTION_API_URL}/chat/getBase64FromMediaMessage/${EVOLUTION_INSTANCE_NAME}`;
|
||||||
export async function sendWhatsAppImage(number: string, imageUrl: string, caption: string) {
|
const response = await axios.post(url, {
|
||||||
if (!EVOLUTION_API_URL || !EVOLUTION_API_KEY || !EVOLUTION_INSTANCE_NAME) {
|
message: messagePayload
|
||||||
console.warn('Evolution API no está configurada en las variables de entorno.');
|
}, { headers });
|
||||||
return { success: false, error: 'Configuración faltante' };
|
|
||||||
}
|
if (response.data && response.data.base64) {
|
||||||
|
return {
|
||||||
try {
|
base64: response.data.base64,
|
||||||
const response = await axios.post(
|
mimetype: response.data.mimetype || 'image/jpeg'
|
||||||
`${EVOLUTION_API_URL}/message/sendMedia/${EVOLUTION_INSTANCE_NAME}`,
|
}; // sometimes mimetype might be missing depending on version, fallback to jpeg.
|
||||||
{
|
|
||||||
number,
|
|
||||||
mediatype: 'image',
|
|
||||||
media: imageUrl,
|
|
||||||
caption
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'apikey': EVOLUTION_API_KEY,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return { success: true, data: response.data };
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error al enviar imagen de WhatsApp:', error?.response?.data || error.message);
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error in getBase64FromMediaMessage:', err?.response?.data || err.message);
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
73
src/lib/gemini.ts
Normal file
73
src/lib/gemini.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { GoogleGenAI, Type } from '@google/genai';
|
||||||
|
|
||||||
|
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY || '' });
|
||||||
|
|
||||||
|
export interface ProcessedPublication {
|
||||||
|
titulo: string;
|
||||||
|
tipo: 'noticia' | 'evento' | 'aviso';
|
||||||
|
seo_descripcion: string;
|
||||||
|
contenido: string;
|
||||||
|
fecha_vencimiento: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processWhatsAppMessage(
|
||||||
|
text: string,
|
||||||
|
base64Media?: string,
|
||||||
|
mimeType?: string
|
||||||
|
): Promise<ProcessedPublication> {
|
||||||
|
if (!process.env.GEMINI_API_KEY) {
|
||||||
|
throw new Error('No GEMINI_API_KEY configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemInstruction = `
|
||||||
|
Eres un asistente editorial experto.
|
||||||
|
Analiza un mensaje enviado al grupo de WhatsApp "Publicaciones Almeira".
|
||||||
|
Debes estructurar el mensaje en base a lo siguiente:
|
||||||
|
1. titulo: Asigna un título claro y corto.
|
||||||
|
2. tipo: Determina si es "noticia" (Noticias, actualidad), "evento" (Invitaciones, programaciones), o "aviso" (Clasificados, empleos, ventas, anuncios parroquiales, etc).
|
||||||
|
3. seo_descripcion: Un texto máximo de 150 caracteres describiendo de qué trata.
|
||||||
|
4. contenido: El texto detallado de la publicación en HTML válido formateado (usa <p>, <strong>, listas locales si es útil) pero retén la información original importante.
|
||||||
|
5. fecha_vencimiento: Si el texto indica fecha límite del evento o del aviso (Ej, "hasta el 30 de noviembre", o "el sábado"), devuélvela en formato YYYY-MM-DD. Si no tiene, omite el campo o pasa null.
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Prepara los contenidos
|
||||||
|
const contents: any[] = [
|
||||||
|
{ text: `Mensaje de WhatsApp: \n\n${text}` }
|
||||||
|
];
|
||||||
|
|
||||||
|
if (base64Media && mimeType) {
|
||||||
|
contents.push({
|
||||||
|
inlineData: {
|
||||||
|
data: base64Media,
|
||||||
|
mimeType: mimeType
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await ai.models.generateContent({
|
||||||
|
model: 'gemini-2.5-flash',
|
||||||
|
contents: contents,
|
||||||
|
config: {
|
||||||
|
systemInstruction: systemInstruction,
|
||||||
|
responseMimeType: "application/json",
|
||||||
|
responseSchema: {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
titulo: { type: Type.STRING },
|
||||||
|
tipo: { type: Type.STRING, enum: ["noticia", "evento", "aviso"] },
|
||||||
|
seo_descripcion: { type: Type.STRING, description: "Máximo 150 caracteres" },
|
||||||
|
contenido: { type: Type.STRING, description: "Contenido en HTML" },
|
||||||
|
fecha_vencimiento: { type: Type.STRING, description: "Fecha opcional YYYY-MM-DD", nullable: true }
|
||||||
|
},
|
||||||
|
required: ["titulo", "tipo", "seo_descripcion", "contenido"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.text) {
|
||||||
|
throw new Error('Gemini devolvió respuesta vacía');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = JSON.parse(response.text) as ProcessedPublication;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
40
test-whatsapp-local.js
Normal file
40
test-whatsapp-local.js
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
// Using Node built-in fetch
|
||||||
|
|
||||||
|
// This script simulates a webhook payload entirely on localhost,
|
||||||
|
// causing the endpoint to trigger Gemini and send a real message to the target WhatsApp group.
|
||||||
|
async function testWebhook() {
|
||||||
|
const payload = {
|
||||||
|
event: 'messages.upsert',
|
||||||
|
data: {
|
||||||
|
key: {
|
||||||
|
remoteJid: '573134848203-1408546044@g.us', // The Publications group
|
||||||
|
fromMe: false,
|
||||||
|
id: 'TEST_MESSAGE_ID_12345'
|
||||||
|
},
|
||||||
|
pushName: 'Admin de Prueba (AI)',
|
||||||
|
message: {
|
||||||
|
conversation: 'Gran Bingo Bailable para la recolección de fondos navideños. Se aceptan donaciones hasta el 31 de noviembre. No faltes!'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Enviando petición a la ruta local (asegúrate de tener 'npm run dev' abierto en otra terminal)...");
|
||||||
|
const response = await fetch('http://localhost:3000/api/webhooks/whatsapp', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log("Respuesta del servidor local:", response.status, data);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.log("\n¡Si todo salió bien, debió aparecerte un mensaje de validación en tu grupo de WhatsApp!");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error contactando al servidor local (Quizás no está encendido):", e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testWebhook();
|
||||||
Loading…
Reference in New Issue
Block a user