diff --git a/.env.example b/.env.example index beceb0c..f83207b 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,9 @@ SMTP_PASS=your-smtp-password SMTP_FROM=hello@letsbe.biz ADMIN_EMAIL=hello@letsbe.biz +# ── Gemini Live API (voice agent) ── +GEMINI_API_KEY=your-gemini-api-key + # ── Cal.com ── NEXT_PUBLIC_CALCOM_URL=https://cal.letsbe.biz diff --git a/package-lock.json b/package-lock.json index 705ce65..82f2400 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@calcom/embed-react": "^1.5.3", + "@google/genai": "^1.46.0", "@payloadcms/db-postgres": "^3.80.0", "@payloadcms/next": "^3.80.0", "@payloadcms/richtext-lexical": "^3.80.0", @@ -16,6 +17,7 @@ "@types/node": "^25.5.0", "@types/nodemailer": "^7.0.11", "@types/react": "^19.2.14", + "cheerio": "^1.2.0", "clsx": "^2.1.1", "framer-motion": "^12.38.0", "graphql": "^16.13.2", @@ -26,6 +28,7 @@ "payload": "^3.80.0", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-international-phone": "^4.8.0", "sharp": "^0.34.5", "typescript": "^5.9.3" }, @@ -1431,6 +1434,29 @@ "@formatjs/fast-memoize": "3.1.1" } }, + "node_modules/@google/genai": { + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.46.0.tgz", + "integrity": "sha512-ewPMN5JkKfgU5/kdco9ZhXBHDPhVqZpMQqIFQhwsHLf8kyZfx1cNpw1pHo1eV6PGEW7EhIBFi3aYZraFndAXqg==", + "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/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -2901,6 +2927,70 @@ "url": "https://opencollective.com/preact" } }, + "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.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "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/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "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.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, "node_modules/@schummar/icu-type-parser": { "version": "1.21.5", "resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", @@ -3537,6 +3627,12 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "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": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -3584,6 +3680,15 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -3692,6 +3797,26 @@ "npm": ">=6" } }, + "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": { "version": "2.10.11", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.11.tgz", @@ -3704,6 +3829,15 @@ "node": ">=6.0.0" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3722,6 +3856,12 @@ "integrity": "sha512-a7tP5+0Mw3YlUJcGAKUqIBkYYGlYxk2fnCasq/FUph1hadxlTRjF+gAcZksxANnaMnALjxEddmSi/H3OR8ugcQ==", "license": "MIT" }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -3774,6 +3914,12 @@ "integrity": "sha512-vgnKAUzcDoa+AeyYwXCoHyF2q6u/8H46dxu5JN+4/TZeq/Dlinn0K6GvxsCLb3LHUJl0m/TLiEK31kUwtgocMQ==", "license": "Apache-2.0" }, + "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/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3879,6 +4025,57 @@ "node": "*" } }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cheerio/node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -3994,6 +4191,34 @@ "node": "*" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/cssfilter": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz", @@ -4006,6 +4231,15 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "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/dataloader": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.3.tgz", @@ -4111,6 +4345,59 @@ "csstype": "^3.0.2" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, "node_modules/dompurify": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", @@ -4121,6 +4408,20 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/drizzle-kit": { "version": "0.31.7", "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.7.tgz", @@ -4261,6 +4562,15 @@ } } }, + "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": { "version": "1.5.325", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz", @@ -4268,6 +4578,19 @@ "dev": true, "license": "ISC" }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -4417,6 +4740,12 @@ "url": "https://opencollective.com/unified" } }, + "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-copy": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz", @@ -4468,6 +4797,29 @@ } } }, + "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-type": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/file-type/-/file-type-19.3.0.tgz", @@ -4512,6 +4864,18 @@ "tabbable": "^6.2.0" } }, + "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": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -4576,6 +4940,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "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/get-tsconfig": { "version": "4.13.7", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", @@ -4600,6 +4992,32 @@ "node": ">= 6" } }, + "node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "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/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -4699,6 +5117,25 @@ "react-is": "^16.7.0" } }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, "node_modules/http-status": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/http-status/-/http-status-2.1.0.tgz", @@ -4708,6 +5145,31 @@ "node": ">= 0.4.0" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/icu-minify": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.8.3.tgz", @@ -4979,6 +5441,15 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -5023,6 +5494,27 @@ "jsox": "lib/cli.js" } }, + "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/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -5333,6 +5825,12 @@ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, + "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/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -6261,6 +6759,44 @@ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "license": "MIT" }, + "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-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": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", @@ -6286,6 +6822,18 @@ "node": ">=0.10.0" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6325,6 +6873,19 @@ "wrappy": "1" } }, + "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/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6380,6 +6941,55 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -6874,6 +7484,30 @@ "react-is": "^16.13.1" } }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -6984,6 +7618,15 @@ "react": ">=16.13.1" } }, + "node_modules/react-international-phone": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/react-international-phone/-/react-international-phone-4.8.0.tgz", + "integrity": "sha512-PoyXx8t0OZNZXLupZN5UtmLb8nO6PQ6f6jQvYCAtg7VzxonuBcDs/4YA4+flqZZj5QOVqN4DLY1p39mEtJAwzw==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -7107,6 +7750,35 @@ "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/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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/safe-stable-stringify": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", @@ -7116,6 +7788,12 @@ "node": ">=10" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/sanitize-filename": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", @@ -8223,6 +8901,28 @@ "url": "https://opencollective.com/unified" } }, + "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/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", diff --git a/package.json b/package.json index 67f5554..b7cb792 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@calcom/embed-react": "^1.5.3", + "@google/genai": "^1.46.0", "@payloadcms/db-postgres": "^3.80.0", "@payloadcms/next": "^3.80.0", "@payloadcms/richtext-lexical": "^3.80.0", @@ -18,6 +19,7 @@ "@types/node": "^25.5.0", "@types/nodemailer": "^7.0.11", "@types/react": "^19.2.14", + "cheerio": "^1.2.0", "clsx": "^2.1.1", "framer-motion": "^12.38.0", "graphql": "^16.13.2", @@ -28,6 +30,7 @@ "payload": "^3.80.0", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-international-phone": "^4.8.0", "sharp": "^0.34.5", "typescript": "^5.9.3" }, diff --git a/src/app/(frontend)/api/analyze-site/route.ts b/src/app/(frontend)/api/analyze-site/route.ts new file mode 100644 index 0000000..ce2ed6d --- /dev/null +++ b/src/app/(frontend)/api/analyze-site/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { analyzeSite, type SiteAnalysis } from '@/lib/site-analysis'; + +// ─── Summary Builder ────────────────────────────────────────────────────────── + +function buildAnalysisSummary(a: SiteAnalysis): string { + if (a.fetchError) return "I wasn't able to reach that site to analyze it."; + + const parts: string[] = []; + + if (a.techStack?.cms) parts.push(`it's built on ${a.techStack.cms}`); + if (a.techStack?.framework) parts.push(`using ${a.techStack.framework}`); + if (a.techStack?.ecommerce) parts.push(`with ${a.techStack.ecommerce} for e-commerce`); + + if (a.performance) { + const score = a.performance.score; + const quality = score >= 90 ? 'excellent' : score >= 50 ? 'moderate' : 'low'; + parts.push(`the mobile performance score is ${score} out of 100, which is ${quality}`); + } + + if (a.techStack?.hosting) parts.push(`hosted on ${a.techStack.hosting}`); + if (a.hasForms) parts.push('it has contact forms'); + + if (a.techStack?.analytics && a.techStack.analytics.length > 0) { + parts.push(`using ${a.techStack.analytics.join(' and ')} for analytics`); + } + + if (parts.length === 0) { + return "I was able to fetch the site but couldn't determine much about its technology stack."; + } + + return `Here's what I found: ${parts.join(', ')}.`; +} + +// ─── Route Handler ──────────────────────────────────────────────────────────── + +export async function POST(request: NextRequest) { + try { + const { url } = (await request.json()) as { url?: string }; + + if (!url?.trim()) { + return NextResponse.json({ success: false, error: 'URL required' }, { status: 400 }); + } + + const analysis = await analyzeSite(url.trim()); + const summary = buildAnalysisSummary(analysis); + + return NextResponse.json({ success: true, summary, analysis }); + } catch (error) { + console.error('[analyze-site] Failed:', error); + return NextResponse.json({ success: false, error: 'Analysis failed' }, { status: 500 }); + } +} diff --git a/src/app/(frontend)/api/configure/route.ts b/src/app/(frontend)/api/configure/route.ts index 4c50dc5..a89a664 100644 --- a/src/app/(frontend)/api/configure/route.ts +++ b/src/app/(frontend)/api/configure/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { sendBriefToClient, sendLeadNotification } from '@/lib/email'; +import { analyzeSite, type SiteAnalysis } from '@/lib/site-analysis'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -13,6 +14,11 @@ interface ConfigureRequestBody { name: string; company: string; email: string; + phone: string; + contactPreference: string; + currentSiteUrl?: string; + currentSiteThoughts?: string; + locale?: string; } // ─── Formatting helpers ────────────────────────────────────────────────────── @@ -47,7 +53,7 @@ const AI_TYPE_NAMES: Record = { notsure: 'AI Integration (approach TBD)', }; -function buildContext(body: ConfigureRequestBody): string { +function buildContext(body: ConfigureRequestBody, siteAnalysis: SiteAnalysis | null = null): string { const services = body.services.map((s) => SERVICE_NAMES[s] ?? s).join(', '); const industry = body.industry ? INDUSTRY_NAMES[body.industry] ?? body.industry : 'Not specified'; const timeline = body.timeline ? TIMELINE_NAMES[body.timeline] ?? body.timeline : 'Not specified'; @@ -66,16 +72,54 @@ Timeline: ${timeline}`; context += `\nAI Integration: Yes — ${aiTypeNames ?? 'type to be determined'}`; } + if (body.phone?.trim()) { + context += `\nPhone: ${body.phone.trim()}`; + } + + if (body.contactPreference?.trim()) { + context += `\nPreferred Contact Method: ${body.contactPreference.trim()}`; + } + if (body.scope.trim()) { context += `\nClient's Goals: "${body.scope.trim()}"`; } + if (body.currentSiteUrl?.trim()) { + context += `\nCurrent Website: ${body.currentSiteUrl.trim()}`; + } + if (body.currentSiteThoughts?.trim()) { + context += `\nClient's Thoughts on Current Site: "${body.currentSiteThoughts.trim()}"`; + } + + if (siteAnalysis && !siteAnalysis.fetchError) { + context += '\n\n--- Current Website Analysis ---'; + if (siteAnalysis.techStack) { + const { cms, framework, ecommerce, analytics, hosting } = siteAnalysis.techStack; + if (cms) context += `\nCMS: ${cms}`; + if (framework) context += `\nFront-End Framework: ${framework}`; + if (ecommerce) context += `\nE-Commerce: ${ecommerce}`; + if (analytics.length > 0) context += `\nAnalytics: ${analytics.join(', ')}`; + if (hosting) context += `\nHosting: ${hosting}`; + } + if (siteAnalysis.performance) { + const p = siteAnalysis.performance; + context += `\nPerformance Score (mobile): ${p.score}/100`; + context += `\nCore Web Vitals — FCP: ${Math.round(p.fcp)}ms, LCP: ${Math.round(p.lcp)}ms, CLS: ${p.cls.toFixed(2)}, TBT: ${Math.round(p.tbt)}ms`; + } + if (siteAnalysis.title) context += `\nSite Title: ${siteAnalysis.title}`; + if (siteAnalysis.description) context += `\nMeta Description: ${siteAnalysis.description}`; + if (siteAnalysis.primaryColors.length > 0) context += `\nBrand Colors: ${siteAnalysis.primaryColors.join(', ')}`; + if (siteAnalysis.hasForms) context += '\nHas Contact/Lead Forms: Yes'; + } else if (siteAnalysis?.fetchError) { + context += `\nNote: Attempted to analyze ${body.currentSiteUrl} but it was unreachable.`; + } + return context; } // ─── AI Brief Generation ───────────────────────────────────────────────────── -async function generateBriefWithAI(body: ConfigureRequestBody): Promise { +async function generateBriefWithAI(body: ConfigureRequestBody, siteAnalysis: SiteAnalysis | null = null): Promise { const apiKey = process.env.OPENROUTER_API_KEY; if (!apiKey) { console.log('[configure] OPENROUTER_API_KEY not set, using fallback brief template'); @@ -84,7 +128,8 @@ async function generateBriefWithAI(body: ConfigureRequestBody): Promise console.log('[configure] Generating AI brief via OpenRouter (deepseek/deepseek-v3.2)...'); - const context = buildContext(body); + const context = buildContext(body, siteAnalysis); + console.log('[configure] AI context:\n', context); const displayName = body.name.split(' ')[0] || body.name; const systemPrompt = `You are writing a project brief on behalf of LetsBe Solutions, a digital studio that builds custom websites, custom software, and private digital infrastructure. The company is American-founded and serves clients internationally. @@ -97,18 +142,35 @@ Key facts about LetsBe: - Small, experienced team with decades of combined experience in design and engineering - They emphasize data ownership, privacy, and digital sovereignty -Write in a professional but warm tone. Be specific and practical — no empty buzzwords. The brief should feel like it was written by someone who understood the client's needs, not a generic template.`; +Write in a professional but warm tone. Be specific and practical — no empty buzzwords. The brief should feel like it was written by someone who understood the client's needs, not a generic template. + +Structure the brief for easy scanning — use short paragraphs, bullet points where appropriate, and clear section headings. Avoid walls of text. + +Always reference a 30-minute introductory call (not 60 minutes or 1 hour) when mentioning next steps. + +When site analysis data is provided in the context, you MUST include a dedicated **Current Website Analysis** section near the top of the brief (after the introduction, before the proposed solution). This section should: +- State what technology the site currently runs on (CMS, framework, hosting) +- If performance data is available, cite the exact score and what it means practically +- Note any strengths or weaknesses observable from the data (e.g., has forms, missing meta description, no analytics) +- If the client shared thoughts about their current site, acknowledge those specifically +- Explain how the proposed solution addresses each issue found +This section demonstrates that LetsBe has already begun analyzing the client's situation before the first call. Never invent data not present in the context — only reference what the analysis actually returned.`; + + const langInstruction = body.locale === 'fr' + ? '\n\nIMPORTANT: Write the entire brief in French. All headings, body text, and next steps must be in French.' + : ''; const userPrompt = `Generate a personalized project brief for the following prospect. The brief should: 1. Address the client by first name (${displayName}) 2. Acknowledge their specific industry and goals -3. For each service they selected, describe concretely what LetsBe would build and why it matters for their business -4. If AI integration is requested, explain practically what that would look like -5. Propose a clear engagement approach (discovery → strategy → build → launch) -6. Include a timeline note based on their preference -7. End with clear next steps +3. For each service they selected, describe concretely what LetsBe would build. Include 2-3 specific, practical benefits the client would gain (e.g., reduced costs, time saved, better guest experience, competitive advantage). +4. Weave in deep industry context — demonstrate understanding of the client's sector, its challenges, and how the proposed solution addresses real pain points in that industry. +5. If AI integration is requested, explain practically what that would look like +6. Propose a clear engagement approach (discovery → strategy → build → launch). Keep each phase to 1-2 sentences maximum. +7. Include a timeline note based on their preference +8. End with a clear next step: book a free 30-minute introductory call to discuss the brief. -Format the brief using **bold** for section headings and --- for separators. Keep it concise but substantive — around 400-600 words. +Format the brief using **bold** for section headings and --- for separators. Keep it concise but substantive — around 350-500 words. Client details: ${context}`; @@ -125,7 +187,7 @@ ${context}`; body: JSON.stringify({ model: 'deepseek/deepseek-v3.2', messages: [ - { role: 'system', content: systemPrompt }, + { role: 'system', content: systemPrompt + langInstruction }, { role: 'user', content: userPrompt }, ], max_tokens: 1500, @@ -159,18 +221,47 @@ ${context}`; function generateFallbackBrief(body: ConfigureRequestBody): string { const { services, aiEnabled, aiTypes, industry, scope, timeline, name, company } = body; + const isFr = body.locale === 'fr'; - const serviceNames = services.map((s) => SERVICE_NAMES[s] ?? s); + const SERVICE_NAMES_FR: Record = { + web: 'Design & Développement Web', + systems: 'Logiciels Sur Mesure', + infrastructure: 'Infrastructure Privée', + }; + + const INDUSTRY_NAMES_FR: Record = { + maritime: 'Maritime & Yachting', + hospitality: 'Hôtellerie', + technology: 'Technologie', + realestate: 'Immobilier', + finance: 'Finance', + ngo: 'ONG & Associatif', + other: 'Autre', + }; + + const TIMELINE_NAMES_FR: Record = { + asap: 'dès que possible', + '1-3months': '1–3 mois', + '3-6months': '3–6 mois', + exploring: 'en phase d\'exploration', + }; + + const svcNames = isFr ? SERVICE_NAMES_FR : SERVICE_NAMES; + const indNames = isFr ? INDUSTRY_NAMES_FR : INDUSTRY_NAMES; + const tlNames = isFr ? TIMELINE_NAMES_FR : TIMELINE_NAMES; + + const serviceNames = services.map((s) => svcNames[s] ?? s); + const joiner = isFr ? ' et ' : ' and '; const servicesList = serviceNames.length <= 2 - ? serviceNames.join(' and ') - : `${serviceNames.slice(0, -1).join(', ')}, and ${serviceNames[serviceNames.length - 1]}`; - const industryLabel = industry ? INDUSTRY_NAMES[industry] ?? industry : 'your industry'; - const displayCompany = company.trim() || 'your organization'; - const displayName = name.split(' ')[0] || 'there'; + ? serviceNames.join(joiner) + : `${serviceNames.slice(0, -1).join(', ')}${isFr ? ' et ' : ', and '}${serviceNames[serviceNames.length - 1]}`; + const industryLabel = industry ? indNames[industry] ?? industry : (isFr ? 'votre secteur' : 'your industry'); + const displayCompany = company.trim() || (isFr ? 'votre organisation' : 'your organization'); + const displayName = name.split(' ')[0] || (isFr ? 'bonjour' : 'there'); const timelineStr = timeline - ? TIMELINE_NAMES[timeline]?.toLowerCase() ?? 'a timeline to be agreed upon' - : 'a timeline to be agreed upon'; + ? tlNames[timeline]?.toLowerCase() ?? (isFr ? 'un calendrier à convenir' : 'a timeline to be agreed upon') + : (isFr ? 'un calendrier à convenir' : 'a timeline to be agreed upon'); const hasWeb = services.includes('web'); const hasSystems = services.includes('systems'); @@ -178,23 +269,76 @@ function generateFallbackBrief(body: ConfigureRequestBody): string { let sections = ''; - if (hasWeb) { - sections += `\n**Web Design & Development**\nWe'll design and build a custom website for ${displayCompany} from scratch — no templates, no page builders. Modern, responsive, fast, and optimized for search engines from day one.\n`; + if (isFr) { + if (hasWeb) { + sections += `\n**Design & Développement Web**\nNous concevrons et développerons un site web sur mesure pour ${displayCompany} — sans templates, sans constructeurs de pages. Moderne, responsive, rapide et optimisé pour le référencement dès le premier jour.\n`; + } + if (hasSystems) { + sections += `\n**Logiciels Sur Mesure**\nNous développerons un système conçu pour correspondre exactement au fonctionnement de ${displayCompany} — modèle de données personnalisé, accès par rôles et intégrations avec vos outils existants.\n`; + } + if (hasInfra) { + sections += `\n**Infrastructure Privée**\nNous mettrons en place un environnement serveur dédié pour ${displayCompany} avec email, stockage cloud et outils métier que vous possédez et contrôlez entièrement.\n`; + } + if (aiEnabled && aiTypes.length > 0) { + const aiLabels = aiTypes.map((t) => AI_TYPE_NAMES[t] ?? t).join(', '); + sections += `\n**Intégration IA**\nNous intégrerons ${aiLabels.toLowerCase()} dans vos systèmes — en profondeur, pas en surface. L'approche exacte sera définie lors de la phase de découverte.\n`; + } else if (aiEnabled) { + sections += `\n**Intégration IA**\nNous intégrerons l'IA dans vos systèmes — en profondeur, pas en surface. L'approche exacte sera définie lors de la phase de découverte.\n`; + } + if (scope?.trim()) { + sections += `\n**Vos Objectifs**\nVous avez partagé : "${scope.trim()}" — nous orienterons nos sessions de découverte autour de ces priorités.\n`; + } + } else { + if (hasWeb) { + sections += `\n**Web Design & Development**\nWe'll design and build a custom website for ${displayCompany} from scratch — no templates, no page builders. Modern, responsive, fast, and optimized for search engines from day one.\n`; + } + if (hasSystems) { + sections += `\n**Custom Software**\nWe'll build a purpose-made system tailored to how ${displayCompany} actually operates — custom data model, role-based access, and integrations with your existing tools.\n`; + } + if (hasInfra) { + sections += `\n**Private Infrastructure**\nWe'll set up a dedicated server environment for ${displayCompany} with email, cloud storage, and business tools that you fully own and control.\n`; + } + if (aiEnabled && aiTypes.length > 0) { + const aiLabels = aiTypes.map((t) => AI_TYPE_NAMES[t] ?? t).join(', '); + sections += `\n**AI Integration**\nWe'll layer ${aiLabels.toLowerCase()} into your systems — deeply integrated, not bolted on. The exact approach will be scoped during discovery.\n`; + } else if (aiEnabled) { + sections += `\n**AI Integration**\nWe'll layer AI integration into your systems — deeply integrated, not bolted on. The exact approach will be scoped during discovery.\n`; + } + if (scope?.trim()) { + sections += `\n**Your Goals**\nYou shared: "${scope.trim()}" — we'll frame our discovery sessions around these priorities.\n`; + } } - if (hasSystems) { - sections += `\n**Custom Software**\nWe'll build a purpose-made system tailored to how ${displayCompany} actually operates — custom data model, role-based access, and integrations with your existing tools.\n`; - } - if (hasInfra) { - sections += `\n**Private Infrastructure**\nWe'll set up a dedicated server environment for ${displayCompany} with email, cloud storage, and business tools that you fully own and control.\n`; - } - if (aiEnabled && aiTypes.length > 0) { - const aiLabels = aiTypes.map((t) => AI_TYPE_NAMES[t] ?? t).join(', '); - sections += `\n**AI Integration**\nWe'll layer ${aiLabels.toLowerCase()} into your systems — deeply integrated, not bolted on. The exact approach will be scoped during discovery.\n`; - } else if (aiEnabled) { - sections += `\n**AI Integration**\nWe'll layer AI integration into your systems — deeply integrated, not bolted on. The exact approach will be scoped during discovery.\n`; - } - if (scope?.trim()) { - sections += `\n**Your Goals**\nYou shared: "${scope.trim()}" — we'll frame our discovery sessions around these priorities.\n`; + + if (isFr) { + return `**Brief Projet pour ${displayCompany}** +Préparé pour : ${name} +Date : ${new Date().toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', day: 'numeric' })} + +--- + +**Aperçu** + +Bonjour ${displayName}, suite à votre intérêt pour ${servicesList} dans le secteur ${industryLabel}, voici un brief préliminaire pour guider notre première conversation. + +Nous aborderons ceci comme un projet unifié — chaque composant fonctionnant ensemble, entièrement détenu et contrôlé par vous. +${sections} +**Notre Approche** + +Nous commençons par une phase de Découverte (2–3 sessions) pour comprendre vos besoins avant d'écrire la moindre ligne de code. + +**Calendrier** + +Livraison cible : ${timelineStr}. Une feuille de route détaillée suivra la phase de Découverte. + +**Prochaines Étapes** + +1. Réservez un appel de présentation de 30 minutes +2. Nous vous enverrons un document de cadrage détaillé sous 48 heures +3. La Découverte commence — sans engagement + +Au plaisir de construire quelque chose de formidable ensemble. + +— L'équipe LetsBe`; } return `**Project Brief for ${displayCompany}** @@ -256,8 +400,20 @@ export async function POST(request: NextRequest) { ); } + // Analyze current website if URL provided + let siteAnalysis: SiteAnalysis | null = null; + if (body.currentSiteUrl?.trim()) { + console.log(`[configure] Analyzing site: ${body.currentSiteUrl.trim()}...`); + siteAnalysis = await analyzeSite(body.currentSiteUrl.trim()); + console.log(`[configure] Site analysis complete (fetchError: ${siteAnalysis.fetchError ?? 'none'})`); + console.log(`[configure] Tech stack:`, JSON.stringify(siteAnalysis.techStack)); + console.log(`[configure] Performance:`, JSON.stringify(siteAnalysis.performance)); + console.log(`[configure] Colors:`, siteAnalysis.primaryColors); + console.log(`[configure] Title:`, siteAnalysis.title); + } + // Generate the brief (AI if available, fallback otherwise) - const brief = await generateBriefWithAI(body); + const brief = await generateBriefWithAI(body, siteAnalysis); // Send emails (non-blocking — don't fail the response if email fails) const smtpHost = process.env.SMTP_HOST; @@ -280,6 +436,8 @@ export async function POST(request: NextRequest) { brief, services: body.services, email: body.email, + phone: body.phone || undefined, + contactPreference: body.contactPreference || undefined, }), ]).then((results) => { results.forEach((result, i) => { diff --git a/src/app/(frontend)/api/gemini-token/route.ts b/src/app/(frontend)/api/gemini-token/route.ts new file mode 100644 index 0000000..12fd6da --- /dev/null +++ b/src/app/(frontend)/api/gemini-token/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { generateEphemeralToken } from '@/lib/gemini-live'; + +// ─── Rate Limiting ──────────────────────────────────────────────────────────── + +const rateLimitMap = new Map(); +const RATE_LIMIT_MS = 60_000; // 1 token per minute per IP + +// ─── Route Handler ──────────────────────────────────────────────────────────── + +export async function POST(request: NextRequest) { + try { + if (!process.env.GEMINI_API_KEY) { + return NextResponse.json({ success: false }, { status: 503 }); + } + + const ip = + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + request.headers.get('x-real-ip') ?? + 'unknown'; + + const lastRequest = rateLimitMap.get(ip) ?? 0; + if (Date.now() - lastRequest < RATE_LIMIT_MS) { + return NextResponse.json({ success: false, error: 'Rate limited' }, { status: 429 }); + } + rateLimitMap.set(ip, Date.now()); + + const { locale } = (await request.json()) as { locale?: string }; + const result = await generateEphemeralToken(locale === 'fr' ? 'fr' : 'en'); + + return NextResponse.json({ + success: true, + // In production, replace apiKey with an ephemeral token from ai.auth.tokens.create() + // to avoid exposing the long-lived API key to the client. + apiKey: process.env.GEMINI_API_KEY, + model: result.model, + config: result.config, + }); + } catch (error) { + console.error('[gemini-token] Failed:', error); + return NextResponse.json({ success: false }, { status: 500 }); + } +} diff --git a/src/components/configurator/ModeToggle.tsx b/src/components/configurator/ModeToggle.tsx new file mode 100644 index 0000000..9008d11 --- /dev/null +++ b/src/components/configurator/ModeToggle.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useTranslations } from 'next-intl'; +import { Keyboard, Mic } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +interface ModeToggleProps { + mode: 'type' | 'talk'; + onChange: (mode: 'type' | 'talk') => void; +} + +// ─── Component ─────────────────────────────────────────────────────────────── + +export default function ModeToggle({ mode, onChange }: ModeToggleProps) { + const t = useTranslations('configurator'); + const [voiceSupported, setVoiceSupported] = useState(false); + + useEffect(() => { + async function check() { + if (typeof WebSocket === 'undefined') return; + if (!navigator.mediaDevices?.getUserMedia) return; + + try { + const res = await fetch('/api/gemini-token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ locale: 'en' }), + }); + const data = (await res.json()) as { success: boolean }; + if (data.success) setVoiceSupported(true); + } catch { + // silent — toggle stays hidden + } + } + void check(); + }, []); + + if (!voiceSupported) return null; + + return ( +
+ + +
+ ); +} diff --git a/src/components/configurator/StepComplete.tsx b/src/components/configurator/StepComplete.tsx index 66e3097..9accf39 100644 --- a/src/components/configurator/StepComplete.tsx +++ b/src/components/configurator/StepComplete.tsx @@ -2,7 +2,7 @@ import { useTranslations } from 'next-intl'; import { motion } from 'framer-motion'; -import { Calendar, Mail, RotateCcw } from 'lucide-react'; +import { Calendar, RotateCcw } from 'lucide-react'; import AnimatedCheckmark from '@/components/icons/AnimatedCheckmark'; import Button from '@/components/ui/Button'; import CalButton from '@/components/ui/CalButton'; @@ -96,22 +96,19 @@ export default function StepComplete({ formData, brief, onReset }: StepCompleteP variants={containerVariants} initial="hidden" animate="visible" - className="flex flex-col gap-6" + className="flex flex-col gap-4" > {/* Checkmark + heading */} - - + + -

+

{t('complete.title')}

-
- -

- {t('complete.subtitle', { email: displayEmail })} -

-
+

+ {t('complete.subtitle', { email: displayEmail })} +

{/* Brief preview */} @@ -123,31 +120,28 @@ export default function StepComplete({ formData, brief, onReset }: StepCompleteP

{t('complete.briefPreview')}

-
+
{renderBrief(brief)}
)} - {/* Booking */} + {/* Next step: book a call */} -
-
- - - +
+
+

+ {t('complete.nextStep')} +

+

+ {t('complete.bookSubtitle')} +

-

- {t('complete.bookTitle')} -

-

- {t('complete.bookSubtitle')} -

- + {t('complete.bookCall')}
diff --git a/src/components/configurator/StepContact.tsx b/src/components/configurator/StepContact.tsx index 5696908..5f8c3d2 100644 --- a/src/components/configurator/StepContact.tsx +++ b/src/components/configurator/StepContact.tsx @@ -1,7 +1,10 @@ 'use client'; +import React from 'react'; import { useTranslations } from 'next-intl'; import { motion, AnimatePresence } from 'framer-motion'; +import { PhoneInput } from 'react-international-phone'; +import 'react-international-phone/style.css'; import { cn } from '@/lib/utils'; import Button from '@/components/ui/Button'; import ProgressBar from './ProgressBar'; @@ -204,6 +207,68 @@ export default function StepContact({ required autoComplete="email" /> + + {/* Phone field — optional */} +
+ + setFormData((prev) => ({ ...prev, phone }))} + style={ + { + '--react-international-phone-height': '44px', + '--react-international-phone-border-radius': '12px', + '--react-international-phone-border-color': 'rgb(var(--color-outline-variant) / 0.6)', + '--react-international-phone-background-color': 'rgb(var(--color-surface-high))', + '--react-international-phone-text-color': 'rgb(var(--color-on-surface))', + '--react-international-phone-font-size': '14px', + '--react-international-phone-selected-dropdown-item-background-color': 'rgb(var(--color-surface-low))', + } as React.CSSProperties + } + className="w-full [&_.react-international-phone-input]:flex-1 [&_.react-international-phone-input]:rounded-r-xl [&_.react-international-phone-input]:border-outline-variant/60 [&_.react-international-phone-input]:bg-surface-high [&_.react-international-phone-input]:text-on-surface [&_.react-international-phone-input]:placeholder:text-outline/50 [&_.react-international-phone-input]:focus:ring-2 [&_.react-international-phone-input]:focus:ring-primary [&_.react-international-phone-input]:focus:border-primary [&_.react-international-phone-country-selector-button]:rounded-l-xl [&_.react-international-phone-country-selector-button]:border-outline-variant/60 [&_.react-international-phone-country-selector-button]:bg-surface-high" + /> +
+ + {/* Contact preference selector */} +
+ + {t('fields.contactPreference')} + +
+ {(['email', 'phone', 'whatsapp'] as const).map((method) => { + const labelKey = `fields.contact${method.charAt(0).toUpperCase() + method.slice(1)}` as + | 'fields.contactEmail' + | 'fields.contactPhone' + | 'fields.contactWhatsapp'; + const isActive = formData.contactPreference === method; + return ( + + ); + })} +
+
{/* Error state */} diff --git a/src/components/configurator/StepDetails.tsx b/src/components/configurator/StepDetails.tsx index 6ead37c..4b386c3 100644 --- a/src/components/configurator/StepDetails.tsx +++ b/src/components/configurator/StepDetails.tsx @@ -1,7 +1,7 @@ 'use client'; import { useTranslations } from 'next-intl'; -import { motion } from 'framer-motion'; +import { motion, AnimatePresence } from 'framer-motion'; import { cn } from '@/lib/utils'; import Button from '@/components/ui/Button'; import Chip from '@/components/ui/Chip'; @@ -100,6 +100,73 @@ export default function StepDetails({ formData, setFormData, onNext, onBack }: S />
+ {/* Current Website URL */} +
+ + + setFormData((prev) => ({ ...prev, currentSiteUrl: e.target.value })) + } + placeholder={t('fields.currentSiteUrlPlaceholder')} + autoComplete="url" + className={cn( + 'w-full rounded-xl border border-outline-variant/60 bg-surface-high', + 'px-4 py-3 text-sm text-on-surface placeholder:text-outline/50', + 'focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary', + 'transition-colors duration-200', + )} + /> +
+ + {/* Thoughts on current site (conditional) */} + + {formData.currentSiteUrl.trim().length > 0 && ( + +
+ +