merged with Axel frontend updates

This commit is contained in:
belviskhoremk
2026-04-16 21:30:51 +00:00
parent 56ce9717aa
commit 2e9eb3a76a
15 changed files with 2303 additions and 1191 deletions

484
package-lock.json generated
View File

@@ -1,14 +1,15 @@
{ {
"name": "contexta-fe", "name": "contexta_fe",
"version": "0.0.0", "version": "0.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "contexta-fe", "name": "contexta_fe",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"@tsparticles/react": "^3.0.0",
"axios": "^1.13.5", "axios": "^1.13.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.575.0", "lucide-react": "^0.575.0",
@@ -17,6 +18,7 @@
"react-dropzone": "^15.0.0", "react-dropzone": "^15.0.0",
"react-router-dom": "^7.13.0", "react-router-dom": "^7.13.0",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tsparticles-slim": "^2.12.0",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
@@ -1455,6 +1457,38 @@
"react": "^18 || ^19" "react": "^18 || ^19"
} }
}, },
"node_modules/@tsparticles/engine": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/engine/-/engine-3.9.1.tgz",
"integrity": "sha512-DpdgAhWMZ3Eh2gyxik8FXS6BKZ8vyea+Eu5BC4epsahqTGY9V3JGGJcXC6lRJx6cPMAx1A0FaQAojPF3v6rkmQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/matteobruni"
},
{
"type": "github",
"url": "https://github.com/sponsors/tsparticles"
},
{
"type": "buymeacoffee",
"url": "https://www.buymeacoffee.com/matteobruni"
}
],
"hasInstallScript": true,
"license": "MIT",
"peer": true
},
"node_modules/@tsparticles/react": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@tsparticles/react/-/react-3.0.0.tgz",
"integrity": "sha512-hjGEtTT1cwv6BcjL+GcVgH++KYs52bIuQGW3PWv7z3tMa8g0bd6RI/vWSLj7p//NZ3uTjEIeilYIUPBh7Jfq/Q==",
"peerDependencies": {
"@tsparticles/engine": "^3.0.2",
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -4369,6 +4403,452 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tsparticles-basic": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-basic/-/tsparticles-basic-2.12.0.tgz",
"integrity": "sha512-pN6FBpL0UsIUXjYbiui5+IVsbIItbQGOlwyGV55g6IYJBgdTNXgFX0HRYZGE9ZZ9psEXqzqwLM37zvWnb5AG9g==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/matteobruni"
},
{
"type": "github",
"url": "https://github.com/sponsors/tsparticles"
},
{
"type": "buymeacoffee",
"url": "https://www.buymeacoffee.com/matteobruni"
}
],
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0",
"tsparticles-move-base": "^2.12.0",
"tsparticles-shape-circle": "^2.12.0",
"tsparticles-updater-color": "^2.12.0",
"tsparticles-updater-opacity": "^2.12.0",
"tsparticles-updater-out-modes": "^2.12.0",
"tsparticles-updater-size": "^2.12.0"
}
},
"node_modules/tsparticles-engine": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-engine/-/tsparticles-engine-2.12.0.tgz",
"integrity": "sha512-ZjDIYex6jBJ4iMc9+z0uPe7SgBnmb6l+EJm83MPIsOny9lPpetMsnw/8YJ3xdxn8hV+S3myTpTN1CkOVmFv0QQ==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/matteobruni"
},
{
"type": "github",
"url": "https://github.com/sponsors/tsparticles"
},
{
"type": "buymeacoffee",
"url": "https://www.buymeacoffee.com/matteobruni"
}
],
"hasInstallScript": true,
"license": "MIT"
},
"node_modules/tsparticles-interaction-external-attract": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-interaction-external-attract/-/tsparticles-interaction-external-attract-2.12.0.tgz",
"integrity": "sha512-0roC6D1QkFqMVomcMlTaBrNVjVOpyNzxIUsjMfshk2wUZDAvTNTuWQdUpmsLS4EeSTDN3rzlGNnIuuUQqyBU5w==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-interaction-external-bounce": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-interaction-external-bounce/-/tsparticles-interaction-external-bounce-2.12.0.tgz",
"integrity": "sha512-MMcqKLnQMJ30hubORtdq+4QMldQ3+gJu0bBYsQr9BsThsh8/V0xHc1iokZobqHYVP5tV77mbFBD8Z7iSCf0TMQ==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-interaction-external-bubble": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-interaction-external-bubble/-/tsparticles-interaction-external-bubble-2.12.0.tgz",
"integrity": "sha512-5kImCSCZlLNccXOHPIi2Yn+rQWTX3sEa/xCHwXW19uHxtILVJlnAweayc8+Zgmb7mo0DscBtWVFXHPxrVPFDUA==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-interaction-external-connect": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-interaction-external-connect/-/tsparticles-interaction-external-connect-2.12.0.tgz",
"integrity": "sha512-ymzmFPXz6AaA1LAOL5Ihuy7YSQEW8MzuSJzbd0ES13U8XjiU3HlFqlH6WGT1KvXNw6WYoqrZt0T3fKxBW3/C3A==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-interaction-external-grab": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-interaction-external-grab/-/tsparticles-interaction-external-grab-2.12.0.tgz",
"integrity": "sha512-iQF/A947hSfDNqAjr49PRjyQaeRkYgTYpfNmAf+EfME8RsbapeP/BSyF6mTy0UAFC0hK2A2Hwgw72eT78yhXeQ==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-interaction-external-pause": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-interaction-external-pause/-/tsparticles-interaction-external-pause-2.12.0.tgz",
"integrity": "sha512-4SUikNpsFROHnRqniL+uX2E388YTtfRWqqqZxRhY0BrijH4z04Aii3YqaGhJxfrwDKkTQlIoM2GbFT552QZWjw==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-interaction-external-push": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-interaction-external-push/-/tsparticles-interaction-external-push-2.12.0.tgz",
"integrity": "sha512-kqs3V0dgDKgMoeqbdg+cKH2F+DTrvfCMrPF1MCCUpBCqBiH+TRQpJNNC86EZYHfNUeeLuIM3ttWwIkk2hllR/Q==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-interaction-external-remove": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-interaction-external-remove/-/tsparticles-interaction-external-remove-2.12.0.tgz",
"integrity": "sha512-2eNIrv4m1WB2VfSVj46V2L/J9hNEZnMgFc+A+qmy66C8KzDN1G8aJUAf1inW8JVc0lmo5+WKhzex4X0ZSMghBg==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-interaction-external-repulse": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-interaction-external-repulse/-/tsparticles-interaction-external-repulse-2.12.0.tgz",
"integrity": "sha512-rSzdnmgljeBCj5FPp4AtGxOG9TmTsK3AjQW0vlyd1aG2O5kSqFjR+FuT7rfdSk9LEJGH5SjPFE6cwbuy51uEWA==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-interaction-external-slow": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-interaction-external-slow/-/tsparticles-interaction-external-slow-2.12.0.tgz",
"integrity": "sha512-2IKdMC3om7DttqyroMtO//xNdF0NvJL/Lx7LDo08VpfTgJJozxU+JAUT8XVT7urxhaDzbxSSIROc79epESROtA==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-interaction-particles-attract": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-interaction-particles-attract/-/tsparticles-interaction-particles-attract-2.12.0.tgz",
"integrity": "sha512-Hl8qwuwF9aLq3FOkAW+Zomu7Gb8IKs6Y3tFQUQScDmrrSCaeRt2EGklAiwgxwgntmqzL7hbMWNx06CHHcUQKdQ==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-interaction-particles-collisions": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-interaction-particles-collisions/-/tsparticles-interaction-particles-collisions-2.12.0.tgz",
"integrity": "sha512-Se9nPWlyPxdsnHgR6ap4YUImAu3W5MeGKJaQMiQpm1vW8lSMOUejI1n1ioIaQth9weKGKnD9rvcNn76sFlzGBA==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-interaction-particles-links": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-interaction-particles-links/-/tsparticles-interaction-particles-links-2.12.0.tgz",
"integrity": "sha512-e7I8gRs4rmKfcsHONXMkJnymRWpxHmeaJIo4g2NaDRjIgeb2AcJSWKWZvrsoLnm7zvaf/cMQlbN6vQwCixYq3A==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-move-base": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-move-base/-/tsparticles-move-base-2.12.0.tgz",
"integrity": "sha512-oSogCDougIImq+iRtIFJD0YFArlorSi8IW3HD2gO3USkH+aNn3ZqZNTqp321uB08K34HpS263DTbhLHa/D6BWw==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-move-parallax": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-move-parallax/-/tsparticles-move-parallax-2.12.0.tgz",
"integrity": "sha512-58CYXaX8Ih5rNtYhpnH0YwU4Ks7gVZMREGUJtmjhuYN+OFr9FVdF3oDIJ9N6gY5a5AnAKz8f5j5qpucoPRcYrQ==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-particles.js": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-particles.js/-/tsparticles-particles.js-2.12.0.tgz",
"integrity": "sha512-LyOuvYdhbUScmA4iDgV3LxA0HzY1DnOwQUy3NrPYO393S2YwdDjdwMod6Btq7EBUjg9FVIh+sZRizgV5elV2dg==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/matteobruni"
},
{
"type": "github",
"url": "https://github.com/sponsors/tsparticles"
},
{
"type": "buymeacoffee",
"url": "https://www.buymeacoffee.com/matteobruni"
}
],
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-plugin-easing-quad": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-plugin-easing-quad/-/tsparticles-plugin-easing-quad-2.12.0.tgz",
"integrity": "sha512-2mNqez5pydDewMIUWaUhY5cNQ80IUOYiujwG6qx9spTq1D6EEPLbRNAEL8/ecPdn2j1Um3iWSx6lo340rPkv4Q==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/matteobruni"
},
{
"type": "github",
"url": "https://github.com/sponsors/tsparticles"
},
{
"type": "buymeacoffee",
"url": "https://www.buymeacoffee.com/matteobruni"
}
],
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-shape-circle": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-shape-circle/-/tsparticles-shape-circle-2.12.0.tgz",
"integrity": "sha512-L6OngbAlbadG7b783x16ns3+SZ7i0SSB66M8xGa5/k+YcY7zm8zG0uPt1Hd+xQDR2aNA3RngVM10O23/Lwk65Q==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-shape-image": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-shape-image/-/tsparticles-shape-image-2.12.0.tgz",
"integrity": "sha512-iCkSdUVa40DxhkkYjYuYHr9MJGVw+QnQuN5UC+e/yBgJQY+1tQL8UH0+YU/h0GHTzh5Sm+y+g51gOFxHt1dj7Q==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-shape-line": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-shape-line/-/tsparticles-shape-line-2.12.0.tgz",
"integrity": "sha512-RcpKmmpKlk+R8mM5wA2v64Lv1jvXtU4SrBDv3vbdRodKbKaWGGzymzav1Q0hYyDyUZgplEK/a5ZwrfrOwmgYGA==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-shape-polygon": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-shape-polygon/-/tsparticles-shape-polygon-2.12.0.tgz",
"integrity": "sha512-5YEy7HVMt1Obxd/jnlsjajchAlYMr9eRZWN+lSjcFSH6Ibra7h59YuJVnwxOxAobpijGxsNiBX0PuGQnB47pmA==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-shape-square": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-shape-square/-/tsparticles-shape-square-2.12.0.tgz",
"integrity": "sha512-33vfajHqmlODKaUzyPI/aVhnAOT09V7nfEPNl8DD0cfiNikEuPkbFqgJezJuE55ebtVo7BZPDA9o7GYbWxQNuw==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-shape-star": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-shape-star/-/tsparticles-shape-star-2.12.0.tgz",
"integrity": "sha512-4sfG/BBqm2qBnPLASl2L5aBfCx86cmZLXeh49Un+TIR1F5Qh4XUFsahgVOG0vkZQa+rOsZPEH04xY5feWmj90g==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-shape-text": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-shape-text/-/tsparticles-shape-text-2.12.0.tgz",
"integrity": "sha512-v2/FCA+hyTbDqp2ymFOe97h/NFb2eezECMrdirHWew3E3qlvj9S/xBibjbpZva2gnXcasBwxn0+LxKbgGdP0rA==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-slim": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-slim/-/tsparticles-slim-2.12.0.tgz",
"integrity": "sha512-27w9aGAAAPKHvP4LHzWFpyqu7wKyulayyaZ/L6Tuuejy4KP4BBEB4rY5GG91yvAPsLtr6rwWAn3yS+uxnBDpkA==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/matteobruni"
},
{
"type": "github",
"url": "https://github.com/sponsors/tsparticles"
},
{
"type": "buymeacoffee",
"url": "https://www.buymeacoffee.com/matteobruni"
}
],
"license": "MIT",
"dependencies": {
"tsparticles-basic": "^2.12.0",
"tsparticles-engine": "^2.12.0",
"tsparticles-interaction-external-attract": "^2.12.0",
"tsparticles-interaction-external-bounce": "^2.12.0",
"tsparticles-interaction-external-bubble": "^2.12.0",
"tsparticles-interaction-external-connect": "^2.12.0",
"tsparticles-interaction-external-grab": "^2.12.0",
"tsparticles-interaction-external-pause": "^2.12.0",
"tsparticles-interaction-external-push": "^2.12.0",
"tsparticles-interaction-external-remove": "^2.12.0",
"tsparticles-interaction-external-repulse": "^2.12.0",
"tsparticles-interaction-external-slow": "^2.12.0",
"tsparticles-interaction-particles-attract": "^2.12.0",
"tsparticles-interaction-particles-collisions": "^2.12.0",
"tsparticles-interaction-particles-links": "^2.12.0",
"tsparticles-move-base": "^2.12.0",
"tsparticles-move-parallax": "^2.12.0",
"tsparticles-particles.js": "^2.12.0",
"tsparticles-plugin-easing-quad": "^2.12.0",
"tsparticles-shape-circle": "^2.12.0",
"tsparticles-shape-image": "^2.12.0",
"tsparticles-shape-line": "^2.12.0",
"tsparticles-shape-polygon": "^2.12.0",
"tsparticles-shape-square": "^2.12.0",
"tsparticles-shape-star": "^2.12.0",
"tsparticles-shape-text": "^2.12.0",
"tsparticles-updater-color": "^2.12.0",
"tsparticles-updater-life": "^2.12.0",
"tsparticles-updater-opacity": "^2.12.0",
"tsparticles-updater-out-modes": "^2.12.0",
"tsparticles-updater-rotate": "^2.12.0",
"tsparticles-updater-size": "^2.12.0",
"tsparticles-updater-stroke-color": "^2.12.0"
}
},
"node_modules/tsparticles-updater-color": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-updater-color/-/tsparticles-updater-color-2.12.0.tgz",
"integrity": "sha512-KcG3a8zd0f8CTiOrylXGChBrjhKcchvDJjx9sp5qpwQK61JlNojNCU35xoaSk2eEHeOvFjh0o3CXWUmYPUcBTQ==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-updater-life": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-updater-life/-/tsparticles-updater-life-2.12.0.tgz",
"integrity": "sha512-J7RWGHAZkowBHpcLpmjKsxwnZZJ94oGEL2w+wvW1/+ZLmAiFFF6UgU0rHMC5CbHJT4IPx9cbkYMEHsBkcRJ0Bw==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-updater-opacity": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-updater-opacity/-/tsparticles-updater-opacity-2.12.0.tgz",
"integrity": "sha512-YUjMsgHdaYi4HN89LLogboYcCi1o9VGo21upoqxq19yRy0hRCtx2NhH22iHF/i5WrX6jqshN0iuiiNefC53CsA==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-updater-out-modes": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-updater-out-modes/-/tsparticles-updater-out-modes-2.12.0.tgz",
"integrity": "sha512-owBp4Gk0JNlSrmp12XVEeBroDhLZU+Uq3szbWlHGSfcR88W4c/0bt0FiH5bHUqORIkw+m8O56hCjbqwj69kpOQ==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-updater-rotate": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-updater-rotate/-/tsparticles-updater-rotate-2.12.0.tgz",
"integrity": "sha512-waOFlGFmEZOzsQg4C4VSejNVXGf4dMf3fsnQrEROASGf1FCd8B6WcZau7JtXSTFw0OUGuk8UGz36ETWN72DkCw==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-updater-size": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-updater-size/-/tsparticles-updater-size-2.12.0.tgz",
"integrity": "sha512-B0yRdEDd/qZXCGDL/ussHfx5YJ9UhTqNvmS5X2rR2hiZhBAE2fmsXLeWkdtF2QusjPeEqFDxrkGiLOsh6poqRA==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/tsparticles-updater-stroke-color": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/tsparticles-updater-stroke-color/-/tsparticles-updater-stroke-color-2.12.0.tgz",
"integrity": "sha512-MPou1ZDxsuVq6SN1fbX+aI5yrs6FyP2iPCqqttpNbWyL+R6fik1rL0ab/x02B57liDXqGKYomIbBQVP3zUTW1A==",
"deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name",
"license": "MIT",
"dependencies": {
"tsparticles-engine": "^2.12.0"
}
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"@tsparticles/react": "^3.0.0",
"axios": "^1.13.5", "axios": "^1.13.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.575.0", "lucide-react": "^0.575.0",
@@ -19,6 +20,7 @@
"react-dropzone": "^15.0.0", "react-dropzone": "^15.0.0",
"react-router-dom": "^7.13.0", "react-router-dom": "^7.13.0",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tsparticles-slim": "^2.12.0",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
@@ -32,9 +34,9 @@
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0", "globals": "^16.5.0",
"typescript": "~5.9.3",
"postcss": "^8.5.5", "postcss": "^8.5.5",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0", "typescript-eslint": "^8.48.0",
"vite": "^7.3.1" "vite": "^7.3.1"
} }

View File

@@ -1,8 +1,8 @@
#root { #root {
max-width: 1280px; /* max-width: 1280px; */
margin: 0 auto; margin: 0 auto;
padding: 2rem; padding-inline: 1rem;
text-align: center; text-align: start;
} }
.logo { .logo {

View File

@@ -9,6 +9,7 @@ import {
LogOut, Menu, Sparkles, BarChart3, Mail, Users, LogOut, Menu, Sparkles, BarChart3, Mail, Users,
Shield, X, CalendarDays, Megaphone, Shield, X, CalendarDays, Megaphone,
} from 'lucide-react' } from 'lucide-react'
import { Divider } from './ui'
const NAV_ITEMS = [ const NAV_ITEMS = [
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, { label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
@@ -36,7 +37,7 @@ export const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children })
const initial = user?.email?.charAt(0).toUpperCase() || '?' const initial = user?.email?.charAt(0).toUpperCase() || '?'
return ( return (
<div className="flex h-screen bg-gray-50 overflow-hidden"> <div className="flex h-screen bg-gray-50 overflow-hidden shadow-2xl">
{/* Mobile backdrop */} {/* Mobile backdrop */}
{sidebarOpen && ( {sidebarOpen && (
<div <div
@@ -69,7 +70,7 @@ export const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children })
</div> </div>
{/* Nav */} {/* Nav */}
<nav className="flex-1 px-3 py-3 space-y-0.5 overflow-y-auto"> <nav className="flex-1 px-3 py-3 space-y-4 overflow-y-auto">
{NAV_ITEMS.map(({ label, href, icon: Icon }) => { {NAV_ITEMS.map(({ label, href, icon: Icon }) => {
const active = location.pathname.startsWith(href) const active = location.pathname.startsWith(href)
return ( return (
@@ -78,7 +79,7 @@ export const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children })
to={href} to={href}
onClick={() => setSidebarOpen(false)} onClick={() => setSidebarOpen(false)}
className={cn( className={cn(
'group flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium', 'group flex items-center px-3 py-2.5 gap-4 rounded-xl text-sm font-medium',
'transition-all duration-150', 'transition-all duration-150',
active active
? 'bg-primary-50 text-primary-700' ? 'bg-primary-50 text-primary-700'
@@ -86,7 +87,7 @@ export const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children })
)} )}
> >
<Icon className={cn( <Icon className={cn(
'w-4 h-4 shrink-0 transition-transform duration-150', 'w-5 h-5 shrink-0 transition-transform duration-150',
active ? 'text-primary-600' : 'text-gray-400 group-hover:text-gray-600', active ? 'text-primary-600' : 'text-gray-400 group-hover:text-gray-600',
)} /> )} />
<span className="flex-1">{label}</span> <span className="flex-1">{label}</span>

View File

@@ -441,7 +441,7 @@ export const AnalyticsPage: React.FC = () => {
if (isLoading) { if (isLoading) {
return ( return (
<div className="p-4 sm:p-6 max-w-5xl mx-auto space-y-6"> <div className="p-4 sm:p-6 max-w-8xl mx-auto space-y-6">
{/* Header skeleton */} {/* Header skeleton */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-2"> <div className="space-y-2">
@@ -476,7 +476,7 @@ export const AnalyticsPage: React.FC = () => {
if (!data) { if (!data) {
return ( return (
<div className="p-6 max-w-4xl mx-auto"> <div className="p-6 max-w-8xl mx-auto">
<Card className="p-10 text-center"> <Card className="p-10 text-center">
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mx-auto mb-4"> <div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mx-auto mb-4">
<BarChart3 className="w-6 h-6 text-gray-400" /> <BarChart3 className="w-6 h-6 text-gray-400" />

View File

@@ -191,7 +191,7 @@ export const AppointmentsPage: React.FC = () => {
const bookingEnabledChatbots = chatbots.filter(c => c.booking_enabled) const bookingEnabledChatbots = chatbots.filter(c => c.booking_enabled)
return ( return (
<div className="p-4 sm:p-6 max-w-5xl mx-auto space-y-6"> <div className="p-4 sm:p-6 max-w-8xl mx-auto space-y-6 text-start">
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">

View File

@@ -93,7 +93,7 @@ const IconInput: React.FC<{
rightElement?: React.ReactNode rightElement?: React.ReactNode
}> = ({ label, icon, rightElement, ...inputProps }) => ( }> = ({ label, icon, rightElement, ...inputProps }) => (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">{label}</label> <label className="text-sm text-left font-medium text-gray-700">{label}</label>
<div className="relative"> <div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"> <span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
{icon} {icon}

View File

@@ -173,7 +173,7 @@ export const CampaignsPage: React.FC = () => {
const sentTotal = campaigns.filter(c => c.status === 'sent').reduce((sum, c) => sum + c.sent_count, 0) const sentTotal = campaigns.filter(c => c.status === 'sent').reduce((sum, c) => sum + c.sent_count, 0)
return ( return (
<div className="p-4 sm:p-6 max-w-3xl mx-auto space-y-6"> <div className="p-4 sm:p-6 max-w-8xl mx-auto space-y-6 text-start">
{/* Confirm send modal */} {/* Confirm send modal */}
{confirmSendId && (() => { {confirmSendId && (() => {

File diff suppressed because it is too large Load Diff

View File

@@ -57,11 +57,11 @@ export const DashboardPage: React.FC = () => {
} }
return ( return (
<div className="p-4 sm:p-6 max-w-7xl"> <div className="text-start p-4 sm:p-10 max-w-8xl">
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-6 gap-3"> <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-6 gap-3">
<div> <div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight">Dashboard</h1> <h1 className="text-xl sm:text-4xl font-bold text-gray-900 tracking-tight">Dashboard</h1>
<p className="text-sm text-gray-500 mt-0.5"> <p className="text-sm text-gray-500 mt-0.5">
{chatbots.length > 0 {chatbots.length > 0
? `${chatbots.length} chatbot${chatbots.length !== 1 ? 's' : ''}` ? `${chatbots.length} chatbot${chatbots.length !== 1 ? 's' : ''}`

View File

@@ -68,7 +68,7 @@ export const ForgotPasswordPage: React.FC = () => {
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{/* Email input with icon */} {/* Email input with icon */}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Email address</label> <label className="text-sm text-left font-medium text-gray-700">Email address</label>
<div className="relative"> <div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"> <span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
<Mail className="w-4 h-4" /> <Mail className="w-4 h-4" />

View File

@@ -137,8 +137,7 @@ export const LeadsPage: React.FC = () => {
} }
return ( return (
<div className="p-4 sm:p-6 max-w-5xl mx-auto space-y-6"> <div className="p-4 sm:p-6 max-w-8xl mx-auto space-y-6 text-start">
{notesLead && ( {notesLead && (
<NotesModal <NotesModal
lead={notesLead} lead={notesLead}

View File

@@ -1,51 +1,67 @@
import React, { useState } from 'react' import React, { useState } from "react";
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from "react-router-dom";
import { useQuery } from '@tanstack/react-query' import { useQuery } from "@tanstack/react-query";
import { marketplaceAPI } from '@/services/api' import { marketplaceAPI } from "@/services/api";
import { Spinner, EmptyState, Button } from '@/components/ui' import { Spinner, EmptyState, Button, Card } from "@/components/ui";
import { SkeletonCard } from '@/components/Skeletons' import { SkeletonCard } from "@/components/Skeletons";
import { ChatInterface } from '@/components/ChatInterface' import { ChatInterface } from "@/components/ChatInterface";
import { CATEGORIES, INDUSTRIES } from '@/lib/utils' import { CATEGORIES, INDUSTRIES } from "@/lib/utils";
import { Search, Bot, Star, MessageSquare, ArrowLeft, SlidersHorizontal, ChevronLeft, ChevronRight } from 'lucide-react' import {
import type { ChatbotPublic } from '@/types' Search,
Bot,
Star,
MessageSquare,
ArrowLeft,
SlidersHorizontal,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import type { ChatbotPublic } from "@/types";
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
// MARKETPLACE LISTING PAGE // MARKETPLACE LISTING PAGE
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
export const MarketplacePage: React.FC = () => { export const MarketplacePage: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate();
const [search, setSearch] = useState('') const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState('') const [debouncedSearch, setDebouncedSearch] = useState("");
const [category, setCategory] = useState('') const [category, setCategory] = useState("");
const [industry, setIndustry] = useState('') const [industry, setIndustry] = useState("");
const [page, setPage] = useState(1) const [page, setPage] = useState(1);
const [showFilters, setShowFilters] = useState(false) const [showFilters, setShowFilters] = useState(false);
// Debounce search // Debounce search
const searchTimeout = React.useRef<ReturnType<typeof setTimeout>>() const searchTimeout = React.useRef<ReturnType<typeof setTimeout>>();
const handleSearch = (value: string) => { const handleSearch = (value: string) => {
setSearch(value) setSearch(value);
clearTimeout(searchTimeout.current) clearTimeout(searchTimeout.current);
searchTimeout.current = setTimeout(() => { searchTimeout.current = setTimeout(() => {
setDebouncedSearch(value) setDebouncedSearch(value);
setPage(1) setPage(1);
}, 300) }, 300);
} };
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['marketplace', debouncedSearch, category, industry, page], queryKey: ["marketplace", debouncedSearch, category, industry, page],
queryFn: () => marketplaceAPI.list({ search: debouncedSearch, category, industry, page, limit: 20 }), queryFn: () =>
}) marketplaceAPI.list({
search: debouncedSearch,
category,
industry,
page,
limit: 20,
}),
});
const totalPages = data ? Math.ceil(data.total / 20) : 0 const totalPages = data ? Math.ceil(data.total / 20) : 0;
const hasActiveFilters = category !== '' || industry !== '' const hasActiveFilters = category !== "" || industry !== "";
return ( return (
<div className="min-h-full bg-gray-50/50 "> <div className="min-h-full bg-gray-50/50 ">
{/* Page Header */} {/* Page Header */}
<div className="bg-white border-b border-gray-200 "> <div className="bg-white border-b border-gray-200 ">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8 sm:py-10"> <div className="max-w-8xl mx-auto px-4 sm:px-6 py-8 sm:py-10">
<div className="animate-fade-in"> <div className="animate-fade-in">
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center shadow-sm"> <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center shadow-sm">
@@ -56,13 +72,15 @@ export const MarketplacePage: React.FC = () => {
</h1> </h1>
</div> </div>
<p className="text-gray-500 text-sm sm:text-base max-w-xl"> <p className="text-gray-500 text-sm sm:text-base max-w-xl">
Discover and interact with AI-powered chatbots built by businesses ready to answer your questions instantly. Discover and interact with AI-powered chatbots built by businesses
ready to answer your questions instantly.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6"> {/* Content */}
<div className="max-w-8xl mx-auto px-4 sm:px-6 py-6">
{/* Search & Filter Bar */} {/* Search & Filter Bar */}
<div className="mb-6 animate-fade-in-down space-y-3"> <div className="mb-6 animate-fade-in-down space-y-3">
<div className="flex gap-2"> <div className="flex gap-2">
@@ -71,17 +89,27 @@ export const MarketplacePage: React.FC = () => {
<input <input
type="text" type="text"
value={search} value={search}
onChange={e => handleSearch(e.target.value)} onChange={(e) => handleSearch(e.target.value)}
placeholder="Search chatbots by name or description..." placeholder="Search chatbots by name or description..."
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-400 bg-white shadow-sm transition-all placeholder-gray-400" className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-400 bg-white shadow-sm transition-all placeholder-gray-400"
/> />
{search && ( {search && (
<button <button
onClick={() => handleSearch('')} onClick={() => handleSearch("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors" className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
</button> </button>
)} )}
@@ -90,8 +118,8 @@ export const MarketplacePage: React.FC = () => {
onClick={() => setShowFilters(!showFilters)} onClick={() => setShowFilters(!showFilters)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border text-sm font-medium transition-all shadow-sm ${ className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border text-sm font-medium transition-all shadow-sm ${
showFilters || hasActiveFilters showFilters || hasActiveFilters
? 'bg-primary-50 border-primary-300 text-primary-700' ? "bg-primary-50 border-primary-300 text-primary-700"
: 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50' : "bg-white border-gray-200 text-gray-600 hover:bg-gray-50"
}`} }`}
> >
<SlidersHorizontal className="w-4 h-4" /> <SlidersHorizontal className="w-4 h-4" />
@@ -109,26 +137,34 @@ export const MarketplacePage: React.FC = () => {
<div className="bg-white border border-gray-200 rounded-xl p-4 shadow-sm space-y-4 animate-fade-in-down"> <div className="bg-white border border-gray-200 rounded-xl p-4 shadow-sm space-y-4 animate-fade-in-down">
{/* Category filter — pill buttons since list is manageable */} {/* Category filter — pill buttons since list is manageable */}
<div> <div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2.5">Category</p> <p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2.5">
Category
</p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button <button
onClick={() => { setCategory(''); setPage(1) }} onClick={() => {
setCategory("");
setPage(1);
}}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-all border ${ className={`px-3 py-1.5 rounded-full text-xs font-medium transition-all border ${
category === '' category === ""
? 'bg-primary-600 text-white border-primary-600 shadow-sm' ? "bg-primary-600 text-white border-primary-600 shadow-sm"
: 'bg-white text-gray-600 border-gray-200 hover:border-primary-300 hover:text-primary-600' : "bg-white text-gray-600 border-gray-200 hover:border-primary-300 hover:text-primary-600"
}`} }`}
> >
All All
</button> </button>
{CATEGORIES.map(c => ( {CATEGORIES.map((c) => (
<button <button
key={c} key={c}
onClick={() => { setCategory(category === c ? '' : c); setPage(1) }} onClick={() => {
setCategory(category === c ? "" : c);
setPage(1);
}}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-all border ${ className={`px-3 py-1.5 rounded-full text-xs font-medium transition-all border ${
category === c category === c
? 'bg-primary-600 text-white border-primary-600 shadow-sm' ? "bg-primary-600 text-white border-primary-600 shadow-sm"
: 'bg-white text-gray-600 border-gray-200 hover:border-primary-300 hover:text-primary-600' : "bg-white text-gray-600 border-gray-200 hover:border-primary-300 hover:text-primary-600"
}`} }`}
> >
{c} {c}
@@ -139,20 +175,33 @@ export const MarketplacePage: React.FC = () => {
{/* Industry filter — select dropdown since list is long */} {/* Industry filter — select dropdown since list is long */}
<div> <div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2.5">Industry</p> <p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2.5">
Industry
</p>
<select <select
value={industry} value={industry}
onChange={e => { setIndustry(e.target.value); setPage(1) }} onChange={(e) => {
setIndustry(e.target.value);
setPage(1);
}}
className="w-full sm:w-72 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white transition-all text-gray-700" className="w-full sm:w-72 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white transition-all text-gray-700"
> >
<option value="">All Industries</option> <option value="">All Industries</option>
{INDUSTRIES.map(i => <option key={i} value={i}>{i}</option>)} {INDUSTRIES.map((i) => (
<option key={i} value={i}>
{i}
</option>
))}
</select> </select>
</div> </div>
{hasActiveFilters && ( {hasActiveFilters && (
<button <button
onClick={() => { setCategory(''); setIndustry(''); setPage(1) }} onClick={() => {
setCategory("");
setIndustry("");
setPage(1);
}}
className="text-xs text-red-500 hover:text-red-700 transition-colors font-medium" className="text-xs text-red-500 hover:text-red-700 transition-colors font-medium"
> >
Clear all filters Clear all filters
@@ -180,11 +229,20 @@ export const MarketplacePage: React.FC = () => {
} }
action={ action={
hasActiveFilters || debouncedSearch ? ( hasActiveFilters || debouncedSearch ? (
<Button variant="outline" onClick={() => { setCategory(''); setIndustry(''); handleSearch('') }}> <Button
variant="outline"
onClick={() => {
setCategory("");
setIndustry("");
handleSearch("");
}}
>
Clear filters Clear filters
</Button> </Button>
) : ( ) : (
<Button onClick={() => navigate('/chatbots/new')}>Create Chatbot</Button> <Button onClick={() => navigate("/chatbots/new")}>
Create Chatbot
</Button>
) )
} }
/> />
@@ -192,11 +250,15 @@ export const MarketplacePage: React.FC = () => {
<> <>
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<p className="text-xs text-gray-400 font-medium uppercase tracking-wide"> <p className="text-xs text-gray-400 font-medium uppercase tracking-wide">
{data.total} chatbot{data.total !== 1 ? 's' : ''} available {data.total} chatbot{data.total !== 1 ? "s" : ""} available
</p> </p>
{hasActiveFilters && ( {hasActiveFilters && (
<button <button
onClick={() => { setCategory(''); setIndustry(''); setPage(1) }} onClick={() => {
setCategory("");
setIndustry("");
setPage(1);
}}
className="text-xs text-primary-600 hover:text-primary-800 transition-colors" className="text-xs text-primary-600 hover:text-primary-800 transition-colors"
> >
Clear filters Clear filters
@@ -219,7 +281,7 @@ export const MarketplacePage: React.FC = () => {
{data.total > 20 && ( {data.total > 20 && (
<div className="flex justify-center items-center gap-2"> <div className="flex justify-center items-center gap-2">
<button <button
onClick={() => setPage(p => Math.max(1, p - 1))} onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1} disabled={page === 1}
className="w-9 h-9 flex items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:border-gray-300 disabled:opacity-40 disabled:cursor-not-allowed transition-all shadow-sm" className="w-9 h-9 flex items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:border-gray-300 disabled:opacity-40 disabled:cursor-not-allowed transition-all shadow-sm"
> >
@@ -227,15 +289,22 @@ export const MarketplacePage: React.FC = () => {
</button> </button>
{Array.from({ length: totalPages }, (_, i) => i + 1) {Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(p => p === 1 || p === totalPages || Math.abs(p - page) <= 1) .filter(
.reduce<(number | 'ellipsis')[]>((acc, p, idx, arr) => { (p) =>
if (idx > 0 && p - (arr[idx - 1] as number) > 1) acc.push('ellipsis') p === 1 || p === totalPages || Math.abs(p - page) <= 1,
acc.push(p) )
return acc .reduce<(number | "ellipsis")[]>((acc, p, idx, arr) => {
if (idx > 0 && p - (arr[idx - 1] as number) > 1)
acc.push("ellipsis");
acc.push(p);
return acc;
}, []) }, [])
.map((p, idx) => .map((p, idx) =>
p === 'ellipsis' ? ( p === "ellipsis" ? (
<span key={`ellipsis-${idx}`} className="w-9 h-9 flex items-center justify-center text-gray-400 text-sm"> <span
key={`ellipsis-${idx}`}
className="w-9 h-9 flex items-center justify-center text-gray-400 text-sm"
>
</span> </span>
) : ( ) : (
@@ -244,17 +313,17 @@ export const MarketplacePage: React.FC = () => {
onClick={() => setPage(p as number)} onClick={() => setPage(p as number)}
className={`w-9 h-9 flex items-center justify-center rounded-lg text-sm font-medium transition-all ${ className={`w-9 h-9 flex items-center justify-center rounded-lg text-sm font-medium transition-all ${
page === p page === p
? 'bg-primary-600 text-white shadow-sm shadow-primary-200' ? "bg-primary-600 text-white shadow-sm shadow-primary-200"
: 'border border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:border-gray-300' : "border border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:border-gray-300"
}`} }`}
> >
{p} {p}
</button> </button>
) ),
)} )}
<button <button
onClick={() => setPage(p => p + 1)} onClick={() => setPage((p) => p + 1)}
disabled={!data.has_more} disabled={!data.has_more}
className="w-9 h-9 flex items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:border-gray-300 disabled:opacity-40 disabled:cursor-not-allowed transition-all shadow-sm" className="w-9 h-9 flex items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:border-gray-300 disabled:opacity-40 disabled:cursor-not-allowed transition-all shadow-sm"
> >
@@ -266,24 +335,29 @@ export const MarketplacePage: React.FC = () => {
)} )}
</div> </div>
</div> </div>
) );
} };
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
// MARKETPLACE CARD — shows logo when available // MARKETPLACE CARD — shows logo when available
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
const ChatbotMarketplaceCard: React.FC<{ chatbot: ChatbotPublic; onClick: () => void; index: number }> = ({ chatbot, onClick, index }) => ( const ChatbotMarketplaceCard: React.FC<{
chatbot: ChatbotPublic;
onClick: () => void;
index: number;
}> = ({ chatbot, onClick, index }) => (
<div <div
className="group relative bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-lg hover:border-gray-300 hover:-translate-y-1 transition-all duration-200 cursor-pointer overflow-hidden" className="group relative bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-lg hover:border-gray-300 hover:-translate-y-1 transition-all duration-200 cursor-pointer overflow-hidden"
style={{ animationDelay: `${index * 60}ms`, animationFillMode: 'both' }} style={{ animationDelay: `${index * 60}ms`, animationFillMode: "both" }}
onClick={onClick} onClick={onClick}
> >
{/* Colored accent top bar — thicker and with gradient */} {/* Colored accent top bar — thicker and with gradient */}
<div <div
className="h-1.5 w-full" className="h-1.5 w-full"
style={{ background: `linear-gradient(90deg, ${chatbot.primary_color}, ${chatbot.primary_color}aa)` }} style={{
background: `linear-gradient(90deg, ${chatbot.primary_color}, ${chatbot.primary_color}aa)`,
}}
/> />
<div className="p-5"> <div className="p-5">
@@ -308,14 +382,18 @@ const ChatbotMarketplaceCard: React.FC<{ chatbot: ChatbotPublic; onClick: () =>
{chatbot.name} {chatbot.name}
</h3> </h3>
{chatbot.company_name && ( {chatbot.company_name && (
<p className="text-xs text-gray-400 truncate mt-0.5">by {chatbot.company_name}</p> <p className="text-xs text-gray-400 truncate mt-0.5">
by {chatbot.company_name}
</p>
)} )}
</div> </div>
</div> </div>
{/* Description */} {/* Description */}
{chatbot.description ? ( {chatbot.description ? (
<p className="text-xs text-gray-500 mb-4 line-clamp-2 leading-relaxed">{chatbot.description}</p> <p className="text-xs text-gray-500 mb-4 line-clamp-2 leading-relaxed">
{chatbot.description}
</p>
) : ( ) : (
<div className="mb-4" /> <div className="mb-4" />
)} )}
@@ -341,7 +419,7 @@ const ChatbotMarketplaceCard: React.FC<{ chatbot: ChatbotPublic; onClick: () =>
</div> </div>
{/* Hover overlay: "Chat now" CTA */} {/* Hover overlay: "Chat now" CTA */}
<div className="absolute inset-0 flex items-end justify-center pb-5 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none"> <div className="absolute inset-0 flex items-end justify-end mr-5 pb-5 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none">
<div <div
className="px-5 py-2 rounded-full text-white text-xs font-semibold shadow-lg pointer-events-none" className="px-5 py-2 rounded-full text-white text-xs font-semibold shadow-lg pointer-events-none"
style={{ background: chatbot.primary_color }} style={{ background: chatbot.primary_color }}
@@ -350,29 +428,32 @@ const ChatbotMarketplaceCard: React.FC<{ chatbot: ChatbotPublic; onClick: () =>
</div> </div>
</div> </div>
</div> </div>
) );
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
// CHATBOT DETAIL PAGE — shows logo in header + passes to ChatInterface // CHATBOT DETAIL PAGE — shows logo in header + passes to ChatInterface
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
export const ChatbotDetailPage: React.FC = () => { export const ChatbotDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>();
const navigate = useNavigate() const navigate = useNavigate();
const { data: chatbot, isLoading, error } = useQuery({ const {
queryKey: ['marketplace-chatbot', id], data: chatbot,
isLoading,
error,
} = useQuery({
queryKey: ["marketplace-chatbot", id],
queryFn: () => marketplaceAPI.get(id!), queryFn: () => marketplaceAPI.get(id!),
enabled: !!id, enabled: !!id,
}) });
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex justify-center py-20"> <div className="flex justify-center py-20">
<Spinner className="text-primary-600" /> <Spinner className="text-primary-600" />
</div> </div>
) );
} }
if (error || !chatbot) { if (error || !chatbot) {
@@ -383,21 +464,21 @@ export const ChatbotDetailPage: React.FC = () => {
title="Chatbot not found" title="Chatbot not found"
description="This chatbot may have been unpublished or removed." description="This chatbot may have been unpublished or removed."
action={ action={
<Button onClick={() => navigate('/marketplace')} variant="outline"> <Button onClick={() => navigate("/marketplace")} variant="outline">
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
Back to Marketplace Back to Marketplace
</Button> </Button>
} }
/> />
</div> </div>
) );
} }
return ( return (
<div className="p-4 sm:p-6 max-w-4xl mx-auto animate-fade-in"> <Card className="p-4 sm:p-6 max-w-5xl mx-auto animate-fade-in">
{/* Back link */} {/* Back link */}
<button <button
onClick={() => navigate('/marketplace')} onClick={() => navigate("/marketplace")}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-primary-600 mb-6 transition-colors group" className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-primary-600 mb-6 transition-colors group"
> >
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" /> <ArrowLeft className="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" />
@@ -409,7 +490,9 @@ export const ChatbotDetailPage: React.FC = () => {
{/* Accent bar */} {/* Accent bar */}
<div <div
className="h-1.5 w-full" className="h-1.5 w-full"
style={{ background: `linear-gradient(90deg, ${chatbot.primary_color}, ${chatbot.primary_color}88)` }} style={{
background: `linear-gradient(90deg, ${chatbot.primary_color}, ${chatbot.primary_color}88)`,
}}
/> />
<div className="p-5 sm:p-6"> <div className="p-5 sm:p-6">
<div className="flex items-center gap-4 mb-3"> <div className="flex items-center gap-4 mb-3">
@@ -428,9 +511,13 @@ export const ChatbotDetailPage: React.FC = () => {
</div> </div>
)} )}
<div> <div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900">{chatbot.name}</h1> <h1 className="text-xl sm:text-2xl font-bold text-gray-900">
{chatbot.name}
</h1>
{chatbot.company_name && ( {chatbot.company_name && (
<p className="text-sm text-gray-500">by {chatbot.company_name}</p> <p className="text-sm text-gray-500">
by {chatbot.company_name}
</p>
)} )}
<div className="flex items-center gap-2 mt-1.5 flex-wrap"> <div className="flex items-center gap-2 mt-1.5 flex-wrap">
{chatbot.average_rating && chatbot.average_rating > 0 && ( {chatbot.average_rating && chatbot.average_rating > 0 && (
@@ -453,7 +540,9 @@ export const ChatbotDetailPage: React.FC = () => {
</div> </div>
{chatbot.description && ( {chatbot.description && (
<p className="text-gray-500 text-sm leading-relaxed">{chatbot.description}</p> <p className="text-gray-500 text-sm leading-relaxed">
{chatbot.description}
</p>
)} )}
</div> </div>
</div> </div>
@@ -468,6 +557,6 @@ export const ChatbotDetailPage: React.FC = () => {
logoUrl={chatbot.logo_url} logoUrl={chatbot.logo_url}
/> />
</div> </div>
</div> </Card>
) );
} };

View File

@@ -1,56 +1,67 @@
import React, { useState, useCallback } from 'react' import React, { useState, useCallback } from "react";
import { useQuery } from '@tanstack/react-query' import { useQuery } from "@tanstack/react-query";
import { useNavigate, useLocation, Link } from 'react-router-dom' import { useNavigate, useLocation, Link } from "react-router-dom";
import { billingAPI, authAPI } from '@/services/api' import { billingAPI, authAPI } from "@/services/api";
import { useAuthStore } from '@/store/authStore' import { useAuthStore } from "@/store/authStore";
import { Button, Card, Input } from '@/components/ui' import { Button, Card, Input } from "@/components/ui";
import { useToast } from '@/contexts/ToastContext' import { useToast } from "@/contexts/ToastContext";
import { useThemeStore } from '@/store/themeStore' import { useThemeStore } from "@/store/themeStore";
import { getPlanColor, formatDate } from '@/lib/utils' import { getPlanColor, formatDate } from "@/lib/utils";
import { CreditCard, User, ExternalLink, AlertTriangle, Moon, Sun } from 'lucide-react' import {
CreditCard,
User,
ExternalLink,
AlertTriangle,
Moon,
Sun,
} from "lucide-react";
export const SettingsPage: React.FC = () => { export const SettingsPage: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate();
const location = useLocation() const location = useLocation();
const { success: showToast, error: showError } = useToast() const { success: showToast, error: showError } = useToast();
const { isDark, toggle: toggleTheme } = useThemeStore() const { isDark, toggle: toggleTheme } = useThemeStore();
const tab: 'profile' | 'billing' = location.pathname === '/settings/billing' ? 'billing' : 'profile' const tab: "profile" | "billing" =
location.pathname === "/settings/billing" ? "billing" : "profile";
const handleTabChange = useCallback((newTab: 'profile' | 'billing') => { const handleTabChange = useCallback(
const newPath = newTab === 'billing' ? '/settings/billing' : '/settings' (newTab: "profile" | "billing") => {
const newPath = newTab === "billing" ? "/settings/billing" : "/settings";
if (location.pathname !== newPath) { if (location.pathname !== newPath) {
navigate(newPath, { replace: true }) navigate(newPath, { replace: true });
} }
}, [navigate, location.pathname]) },
[navigate, location.pathname],
);
return ( return (
<div className="p-6 max-w-3xl mx-auto"> <div className="p-6 max-w-8xl mx-auto">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Settings</h1> <h1 className="text-4xl font-bold text-gray-900">Settings</h1>
<button <button
onClick={toggleTheme} onClick={toggleTheme}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-200 hover:bg-gray-100 text-sm text-gray-600 transition-colors" className="flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-200 hover:bg-gray-100 text-sm text-gray-600 transition-colors"
aria-label="Toggle dark mode" aria-label="Toggle dark mode"
> >
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />} {isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
{isDark ? 'Light mode' : 'Dark mode'} {isDark ? "Light mode" : "Dark mode"}
</button> </button>
</div> </div>
{/* Tabs */} {/* Tabs */}
<div className="flex gap-1 mb-6 bg-gray-100 p-1 rounded-xl w-fit"> <div className="flex gap-1 mb-6 bg-gray-100 p-1 rounded-xl w-fit">
{[ {[
{ id: 'profile' as const, label: 'Profile', icon: User }, { id: "profile" as const, label: "Profile", icon: User },
{ id: 'billing' as const, label: 'Billing', icon: CreditCard }, { id: "billing" as const, label: "Billing", icon: CreditCard },
].map(({ id, label, icon: Icon }) => ( ].map(({ id, label, icon: Icon }) => (
<button <button
key={id} key={id}
onClick={() => handleTabChange(id)} onClick={() => handleTabChange(id)}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${ className={`flex lg:w-52 items-center justify-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
tab === id tab === id
? 'bg-white shadow-sm text-gray-900' ? "bg-white shadow-sm text-gray-900"
: 'text-gray-500 hover:text-gray-700' : "text-gray-500 hover:text-gray-700"
}`} }`}
> >
<Icon className="w-3.5 h-3.5" /> <Icon className="w-3.5 h-3.5" />
@@ -59,81 +70,105 @@ export const SettingsPage: React.FC = () => {
))} ))}
</div> </div>
{tab === 'profile' && <ProfileSettings onToast={showToast} onError={showError} />} {tab === "profile" && (
{tab === 'billing' && <BillingSettings onToast={showToast} onError={showError} />} <ProfileSettings onToast={showToast} onError={showError} />
)}
{tab === "billing" && (
<BillingSettings onToast={showToast} onError={showError} />
)}
</div> </div>
) );
} };
const ProfileSettings: React.FC<{ onToast: (msg: string) => void; onError: (msg: string) => void }> = ({ onToast, onError }) => { const ProfileSettings: React.FC<{
const { user, setAuth, token, logout } = useAuthStore() onToast: (msg: string) => void;
const navigate = useNavigate() onError: (msg: string) => void;
const [companyName, setCompanyName] = useState(user?.company_name || '') }> = ({ onToast, onError }) => {
const [currentPassword, setCurrentPassword] = useState('') const { user, setAuth, token, logout } = useAuthStore();
const [newPassword, setNewPassword] = useState('') const navigate = useNavigate();
const [saving, setSaving] = useState(false) const [companyName, setCompanyName] = useState(user?.company_name || "");
const [showDeleteModal, setShowDeleteModal] = useState(false) const [currentPassword, setCurrentPassword] = useState("");
const [deleteConfirm, setDeleteConfirm] = useState('') const [newPassword, setNewPassword] = useState("");
const [deleting, setDeleting] = useState(false) const [saving, setSaving] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState("");
const [deleting, setDeleting] = useState(false);
const handleSave = async () => { const handleSave = async () => {
setSaving(true) setSaving(true);
try { try {
const payload: { company_name?: string; current_password?: string; new_password?: string } = {} const payload: {
if (companyName !== user?.company_name) payload.company_name = companyName company_name?: string;
current_password?: string;
new_password?: string;
} = {};
if (companyName !== user?.company_name)
payload.company_name = companyName;
if (newPassword) { if (newPassword) {
payload.current_password = currentPassword payload.current_password = currentPassword;
payload.new_password = newPassword payload.new_password = newPassword;
} }
if (Object.keys(payload).length === 0) { if (Object.keys(payload).length === 0) {
onToast('No changes to save') onToast("No changes to save");
return return;
} }
const updated = await authAPI.updateProfile(payload) const updated = await authAPI.updateProfile(payload);
setAuth(updated, token || '') setAuth(updated, token || "");
setCurrentPassword('') setCurrentPassword("");
setNewPassword('') setNewPassword("");
onToast('Profile updated successfully') onToast("Profile updated successfully");
} catch (err) { } catch (err) {
const e = err as { response?: { data?: { detail?: string } } } const e = err as { response?: { data?: { detail?: string } } };
onError(e.response?.data?.detail || 'Failed to update profile') onError(e.response?.data?.detail || "Failed to update profile");
} finally { } finally {
setSaving(false) setSaving(false);
}
} }
};
const handleDeleteAccount = async () => { const handleDeleteAccount = async () => {
if (deleteConfirm !== 'DELETE') return if (deleteConfirm !== "DELETE") return;
setDeleting(true) setDeleting(true);
try { try {
await authAPI.deleteAccount() await authAPI.deleteAccount();
logout() logout();
navigate('/') navigate("/");
} catch (err) { } catch (err) {
const e = err as { response?: { data?: { detail?: string } } } const e = err as { response?: { data?: { detail?: string } } };
onError(e.response?.data?.detail || 'Failed to delete account') onError(e.response?.data?.detail || "Failed to delete account");
setDeleting(false) setDeleting(false);
}
} }
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Card className="p-6 space-y-4"> <Card className="p-6 space-y-4">
<h2 className="font-semibold text-gray-900">Profile Information</h2> <h2 className="font-semibold text-gray-900">Profile Information</h2>
<Input label="Email" value={user?.email || ''} disabled hint="Email cannot be changed" /> <Input
label="Email"
value={user?.email || ""}
disabled
hint="Email cannot be changed"
/>
<Input <Input
label="Company Name" label="Company Name"
value={companyName} value={companyName}
onChange={e => setCompanyName(e.target.value)} onChange={(e) => setCompanyName(e.target.value)}
placeholder="Your company name" placeholder="Your company name"
/> />
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Plan</label> <label className="block text-sm font-medium text-gray-700 mb-1.5">
Plan
</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`px-3 py-1 text-sm font-medium rounded-full capitalize ${getPlanColor(user?.plan || 'free')}`}> <span
{user?.plan || 'free'} className={`px-3 py-1 text-sm font-medium rounded-full capitalize ${getPlanColor(user?.plan || "free")}`}
>
{user?.plan || "free"}
</span> </span>
<Link to="/pricing" className="text-sm text-primary-600 hover:underline"> <Link
to="/pricing"
className="text-sm text-primary-600 hover:underline"
>
Manage plan Manage plan
</Link> </Link>
</div> </div>
@@ -146,33 +181,44 @@ const ProfileSettings: React.FC<{ onToast: (msg: string) => void; onError: (msg:
label="Current Password" label="Current Password"
type="password" type="password"
value={currentPassword} value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)} onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Enter current password" placeholder="Enter current password"
/> />
<Input <Input
label="New Password" label="New Password"
type="password" type="password"
value={newPassword} value={newPassword}
onChange={e => setNewPassword(e.target.value)} onChange={(e) => setNewPassword(e.target.value)}
placeholder="Min 8 characters" placeholder="Min 8 characters"
hint="Leave blank to keep current password" hint="Leave blank to keep current password"
/> />
</Card> </Card>
<Button onClick={handleSave} loading={saving}> <div className="w-full flex items-center justify-center">
<Button
onClick={handleSave}
loading={saving}
className="w-1/2 h-11 my-5"
>
Save Changes Save Changes
</Button> </Button>
</div>
{/* Danger Zone */} {/* Danger Zone */}
<Card className="p-6 border-red-200 bg-red-50/30"> <Card className="p-6 border-red-200 bg-red-50/30 text-center">
<h2 className="font-semibold text-red-800 mb-2 flex items-center gap-1.5"> <h2 className="font-semibold text-lg text-red-800 mb-2 flex items-center justify-center gap-1.5">
<AlertTriangle className="w-4 h-4" /> <AlertTriangle className="w-4 h-4" />
Danger Zone Danger Zone
</h2> </h2>
<p className="text-sm text-red-700 mb-4"> <p className="text-sm text-red-700 mb-4">
Permanently delete your account, all chatbots, documents, and data. This cannot be undone. Permanently delete your account, all chatbots, documents, and data.
This cannot be undone.
</p> </p>
<Button variant="outline" className="border-red-300 text-red-700 hover:bg-red-50" onClick={() => setShowDeleteModal(true)}> <Button
variant="outline"
className="border-red-300 text-red-700 hover:bg-red-50"
onClick={() => setShowDeleteModal(true)}
>
Delete Account Delete Account
</Button> </Button>
</Card> </Card>
@@ -181,24 +227,39 @@ const ProfileSettings: React.FC<{ onToast: (msg: string) => void; onError: (msg:
{showDeleteModal && ( {showDeleteModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 w-full max-w-md shadow-xl"> <div className="bg-white rounded-2xl p-6 w-full max-w-md shadow-xl">
<h3 className="text-lg font-bold text-gray-900 mb-2">Delete Account</h3> <h3 className="text-lg font-bold text-gray-900 mb-2">
Delete Account
</h3>
<p className="text-sm text-gray-500 mb-4"> <p className="text-sm text-gray-500 mb-4">
This will permanently delete your account and all associated data including chatbots, documents, conversations, and leads. This will permanently delete your account and all associated data
<strong className="text-red-600"> This action cannot be undone.</strong> including chatbots, documents, conversations, and leads.
<strong className="text-red-600">
{" "}
This action cannot be undone.
</strong>
</p>
<p className="text-sm text-gray-700 mb-2">
Type <strong>DELETE</strong> to confirm:
</p> </p>
<p className="text-sm text-gray-700 mb-2">Type <strong>DELETE</strong> to confirm:</p>
<Input <Input
value={deleteConfirm} value={deleteConfirm}
onChange={e => setDeleteConfirm(e.target.value)} onChange={(e) => setDeleteConfirm(e.target.value)}
placeholder="DELETE" placeholder="DELETE"
/> />
<div className="flex gap-3 mt-4"> <div className="flex gap-3 mt-4">
<Button variant="outline" className="flex-1" onClick={() => { setShowDeleteModal(false); setDeleteConfirm('') }}> <Button
variant="outline"
className="flex-1"
onClick={() => {
setShowDeleteModal(false);
setDeleteConfirm("");
}}
>
Cancel Cancel
</Button> </Button>
<Button <Button
className="flex-1 bg-red-600 hover:bg-red-700" className="flex-1 bg-red-600 hover:bg-red-700"
disabled={deleteConfirm !== 'DELETE'} disabled={deleteConfirm !== "DELETE"}
loading={deleting} loading={deleting}
onClick={handleDeleteAccount} onClick={handleDeleteAccount}
> >
@@ -209,97 +270,239 @@ const ProfileSettings: React.FC<{ onToast: (msg: string) => void; onError: (msg:
</div> </div>
)} )}
</div> </div>
) );
} };
const BillingSettings: React.FC<{ onToast: (msg: string) => void; onError: (msg: string) => void }> = ({ onError }) => { const BillingSettings: React.FC<{
const navigate = useNavigate() onToast: (msg: string) => void;
const [loading, setLoading] = useState(false) onError: (msg: string) => void;
}> = ({ onError }) => {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const { data: subscription } = useQuery({ const { data: subscription } = useQuery({
queryKey: ['subscription'], queryKey: ["subscription"],
queryFn: billingAPI.getSubscription, queryFn: billingAPI.getSubscription,
}) });
const handlePortal = async () => { const handlePortal = async () => {
setLoading(true) setLoading(true);
try { try {
const { url } = await billingAPI.createPortal(window.location.href) const { url } = await billingAPI.createPortal(window.location.href);
window.location.href = url window.location.href = url;
} catch (err) { } catch (err) {
const e = err as { response?: { data?: { detail?: string } } } const e = err as { response?: { data?: { detail?: string } } };
onError(e.response?.data?.detail || 'Failed to open billing portal') onError(e.response?.data?.detail || "Failed to open billing portal");
} finally { } finally {
setLoading(false) setLoading(false);
}
} }
};
const plan = subscription?.plan || 'free' const plan = subscription?.plan || "free";
const isPaid = plan !== 'free' const isPaid = plan !== "free";
const planFeatures: Record<string, { published: string; conversations: string; codeExport: string }> = { const planFeatures: Record<
free: { published: '1', conversations: '100/month', codeExport: '❌ Agency+ only' }, string,
starter: { published: '1', conversations: '1,500/month', codeExport: '❌ Agency+ only' }, { published: string; conversations: string; codeExport: string }
business: { published: '3', conversations: '5,000/month', codeExport: '❌ Agency+ only' }, > = {
agency: { published: 'Unlimited', conversations: '20,000/month', codeExport: '✅ Included' }, free: {
enterprise: { published: 'Unlimited', conversations: 'Unlimited', codeExport: '✅ Included' }, published: "1",
} conversations: "100/month",
const features = planFeatures[plan] || planFeatures.free codeExport: "❌ Agency+ only",
},
starter: {
published: "1",
conversations: "1,500/month",
codeExport: "❌ Agency+ only",
},
business: {
published: "3",
conversations: "5,000/month",
codeExport: "❌ Agency+ only",
},
agency: {
published: "Unlimited",
conversations: "20,000/month",
codeExport: "✅ Included",
},
enterprise: {
published: "Unlimited",
conversations: "Unlimited",
codeExport: "✅ Included",
},
};
const features = planFeatures[plan] || planFeatures.free;
return ( return (
<div className="space-y-4"> <div className="space-y-6">
<Card className="p-6"> {/* Current Plan Card - Version with hover effect */}
<h2 className="font-semibold text-gray-900 mb-4">Current Plan</h2> <Card className="group relative overflow-hidden border-0 shadow-lg transition-all duration-300 hover:shadow-2xl">
<div className="flex items-center justify-between mb-4"> <div className="absolute inset-0 bg-gradient-to-r from-blue-600/5 to-purple-600/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
<div> <div className="p-6 relative">
<span className={`px-3 py-1 text-sm font-semibold rounded-full capitalize ${getPlanColor(plan)}`}> <div className="flex items-start justify-between mb-6">
<div className="space-y-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-gradient-to-r from-blue-500 to-purple-500 flex items-center justify-center">
<svg
className="w-4 h-4 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M10 2a8 8 0 100 16 8 8 0 000-16zm1 14h-2v-2h2v2zm0-4h-2V7h2v5z" />
</svg>
</div>
<h2 className="text-xl font-bold text-gray-900">
Current Plan
</h2>
</div>
<div className="flex items-center gap-3 flex-wrap">
<span
className={`inline-flex items-center gap-2 px-3 py-1.5 text-sm font-semibold rounded-full capitalize shadow-sm ${getPlanColor(plan)}`}
>
<span className="w-2 h-2 rounded-full bg-current"></span>
{plan} {plan}
</span> </span>
<p className="text-xs text-gray-500 mt-1"> <div className="flex items-center gap-2">
Status:{' '} <span className="text-xs text-gray-500">Status:</span>
<span className={subscription?.status === 'active' ? 'text-green-600' : 'text-red-600'}> <span
{subscription?.status || 'active'} className={`inline-flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded-full ${
subscription?.status === "active"
? "bg-green-100 text-green-700"
: "bg-red-100 text-red-700"
}`}
>
<span
className={`w-1.5 h-1.5 rounded-full ${subscription?.status === "active" ? "bg-green-500" : "bg-red-500"}`}
></span>
{subscription?.status === "active"
? "Active"
: subscription?.status || "Active"}
</span> </span>
</p>
</div> </div>
</div>
</div>
{isPaid && subscription?.current_period_end && ( {isPaid && subscription?.current_period_end && (
<div className="text-right"> <div className="text-right bg-gray-50 rounded-lg px-4 py-2">
<p className="text-xs text-gray-500">Renews on</p> <p className="text-xs text-gray-500 uppercase tracking-wide">
<p className="text-sm font-medium">{formatDate(subscription.current_period_end)}</p> Renewal Date
</p>
<p className="text-sm font-bold text-gray-900">
{formatDate(subscription.current_period_end)}
</p>
</div> </div>
)} )}
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3 mt-6">
{!isPaid ? ( {!isPaid ? (
<Button onClick={() => navigate('/pricing')} className="flex-1"> <Button
Upgrade Plan onClick={() => navigate("/pricing")}
className="w-full bg-gradient-to-r from-primary-600 to-indigo-600 hover:from-primary-700 hover:to-indigo-700 text-white shadow-md hover:shadow-lg transition-all duration-300 rounded-lg py-5 text-base font-semibold group"
>
<span className="flex items-center justify-center gap-2">
Upgrade Plan
<svg
className="w-4 h-4 transform group-hover:translate-x-1 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</span>
</Button> </Button>
) : ( ) : (
<Button variant="outline" onClick={handlePortal} loading={loading} className="flex-1"> <Button
<ExternalLink className="w-3.5 h-3.5" /> variant="outline"
onClick={handlePortal}
loading={loading}
className="flex-1 border-gray-300 hover:border-gray-400 hover:bg-gray-50 rounded-lg py-5 text-base font-semibold transition-all duration-300"
>
<ExternalLink className="w-4 h-4 mr-2" />
Manage Billing Manage Billing
</Button> </Button>
)} )}
</div> </div>
</div>
</Card> </Card>
{/* Plan features */} {/* Plan Features Card - Enhanced Version */}
<Card className="p-6"> <Card className="border-0 shadow-lg overflow-hidden">
<h3 className="font-semibold text-gray-900 mb-4">Plan Features</h3> <div className="bg-gradient-to-r from-gray-50 to-white p-6">
<div className="space-y-3"> <div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center shadow-md">
<svg
className="w-5 h-5 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"
/>
</svg>
</div>
<h3 className="text-lg font-bold text-gray-900">Plan Features</h3>
</div>
<div className="space-y-2">
{[ {[
{ label: 'Chatbots published', value: features.published }, {
{ label: 'Conversations/month', value: features.conversations }, label: "Chatbots published",
{ label: 'Code export', value: features.codeExport }, value: features.published,
].map(({ label, value }) => ( suffix: "chatbot(s)",
<div key={label} className="flex justify-between py-2 border-b border-gray-100 last:border-0"> },
<span className="text-sm text-gray-600">{label}</span> {
<span className="text-sm font-medium text-gray-900">{value}</span> label: "Conversations / month",
value: features.conversations,
suffix: "conversations",
},
{
label: "Code export",
value: features.codeExport,
highlight: features.codeExport,
},
].map(({ label, value, suffix, highlight }) => (
<div
key={label}
className="flex items-center justify-between py-3 px-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-200 group"
>
<span className="text-sm text-gray-600 group-hover:text-gray-800 transition-colors">
{label}
</span>
<span
className={`text-sm font-semibold ${highlight === false ? "text-gray-400" : "text-gray-900"}`}
>
{value}{" "}
{suffix && (
<span className="text-xs text-gray-400 font-normal">
{suffix}
</span>
)}
</span>
</div> </div>
))} ))}
</div> </div>
<div className="mt-6 pt-4 border-t border-gray-200 text-center">
<p className="text-xs text-gray-500">
{isPaid
? "💳 Simplified subscription management"
: "🚀 Unlock more features by upgrading your plan"}
</p>
</div>
</div>
</Card> </Card>
</div> </div>
) );
} };

View File

@@ -12,7 +12,7 @@ export default defineConfig({
}, },
server: { server: {
port: 5173, port: 5173,
allowedHosts: ['gripple-delena-triserial.ngrok-free.dev', '127.0.0.1', 'localhost'], allowedHosts: ['gripple-delena-triserial.ngrok-free.dev', '127.0.0.1', 'localhost', 'contexta-production-672d.up.railway.app'],
host: true host: true
} }
}) })