feat: website analysis pipeline, voice agent, configurator improvements
All checks were successful
Build & Push / build-and-push (push) Successful in 6m2s

- Site analysis: cheerio HTML parsing, inline tech stack detection (~20 CMS/framework/analytics signatures), Google PageSpeed API integration
- Gemini Live voice agent: WebSocket-based real-time voice mode with live transcript, selection chips, and mid-conversation website analysis
- Type/Talk mode toggle with silent capability detection
- Stepped progress animation during brief generation (4 animated steps)
- URL + thoughts fields in Step 2, phone + contact preference in Step 3
- AI prompt improvements: dedicated website analysis section, 30-min call, concrete benefits, industry depth
- Email redesign: branded templates with logo, proper markdown rendering for both client and admin
- French locale support for AI-generated briefs
- Smaller checkmark, compact booking CTA, expanded brief area

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-28 13:41:35 +01:00
parent 16cd2a74ee
commit bab45b981e
19 changed files with 2923 additions and 119 deletions

View File

@@ -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

700
package-lock.json generated
View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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 });
}
}

View File

@@ -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<string, string> = {
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<string> {
async function generateBriefWithAI(body: ConfigureRequestBody, siteAnalysis: SiteAnalysis | null = null): Promise<string> {
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<string>
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<string, string> = {
web: 'Design & Développement Web',
systems: 'Logiciels Sur Mesure',
infrastructure: 'Infrastructure Privée',
};
const INDUSTRY_NAMES_FR: Record<string, string> = {
maritime: 'Maritime & Yachting',
hospitality: 'Hôtellerie',
technology: 'Technologie',
realestate: 'Immobilier',
finance: 'Finance',
ngo: 'ONG & Associatif',
other: 'Autre',
};
const TIMELINE_NAMES_FR: Record<string, string> = {
asap: 'dès que possible',
'1-3months': '13 mois',
'3-6months': '36 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 (23 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) => {

View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import { generateEphemeralToken } from '@/lib/gemini-live';
// ─── Rate Limiting ────────────────────────────────────────────────────────────
const rateLimitMap = new Map<string, number>();
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 });
}
}

View File

@@ -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 (
<div className="flex items-center gap-1 rounded-xl bg-surface-low p-1 border border-outline-variant/30">
<button
type="button"
onClick={() => onChange('type')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200',
mode === 'type'
? 'bg-white text-on-surface shadow-card'
: 'text-outline hover:text-on-surface',
)}
>
<Keyboard size={13} />
{t('mode.type')}
</button>
<button
type="button"
onClick={() => onChange('talk')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200',
mode === 'talk'
? 'bg-white text-on-surface shadow-card'
: 'text-outline hover:text-on-surface',
)}
>
<Mic size={13} />
{t('mode.talk')}
</button>
</div>
);
}

View File

@@ -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 */}
<motion.div variants={itemVariants} className="flex flex-col items-center text-center pt-2 pb-1">
<AnimatedCheckmark size={64} color="#006494" />
<motion.div variants={itemVariants} className="flex flex-col items-center text-center pt-1 pb-0">
<AnimatedCheckmark size={40} color="#006494" />
<h3 className="font-serif text-2xl font-semibold tracking-headline text-on-surface mt-4">
<h3 className="font-serif text-xl font-semibold tracking-headline text-on-surface mt-2.5">
{t('complete.title')}
</h3>
<div className="flex items-center gap-2 mt-2">
<Mail size={14} strokeWidth={1.5} className="text-primary flex-shrink-0" />
<p className="text-sm text-outline">
{t('complete.subtitle', { email: displayEmail })}
</p>
</div>
<p className="text-sm text-outline mt-2">
{t('complete.subtitle', { email: displayEmail })}
</p>
</motion.div>
{/* Brief preview */}
@@ -123,31 +120,28 @@ export default function StepComplete({ formData, brief, onReset }: StepCompleteP
<p className="text-xs font-semibold uppercase tracking-label text-outline mb-3">
{t('complete.briefPreview')}
</p>
<div className="space-y-1 max-h-72 overflow-y-auto pr-1 scrollbar-thin">
<div className="space-y-1 max-h-[28rem] overflow-y-auto pr-1 scrollbar-thin">
{renderBrief(brief)}
</div>
</motion.div>
)}
{/* Booking */}
{/* Next step: book a call */}
<motion.div variants={itemVariants}>
<div className="rounded-xl bg-surface-low px-5 py-5 text-center">
<div className="flex justify-center mb-3">
<span className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
<Calendar size={18} strokeWidth={1.5} className="text-primary-dark" />
</span>
<div className="flex items-center justify-between gap-4 rounded-lg border border-primary/20 bg-primary/5 px-4 py-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-on-surface">
{t('complete.nextStep')}
</p>
<p className="text-xs text-outline mt-0.5">
{t('complete.bookSubtitle')}
</p>
</div>
<p className="text-sm font-semibold text-on-surface mb-1">
{t('complete.bookTitle')}
</p>
<p className="text-xs text-outline mb-4">
{t('complete.bookSubtitle')}
</p>
<CalButton
className="inline-flex items-center gap-2 px-6 py-2.5 rounded-lg text-sm font-medium text-white transition-all hover:-translate-y-px active:translate-y-0"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white whitespace-nowrap transition-all hover:-translate-y-px active:translate-y-0 flex-shrink-0"
style={{ background: 'linear-gradient(135deg, #006494, #5BA4D9)' }}
>
<Calendar size={16} />
<Calendar size={14} />
{t('complete.bookCall')}
</CalButton>
</div>

View File

@@ -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 */}
<div className="flex flex-col gap-1.5">
<label
htmlFor="contact-phone"
className="text-xs font-semibold uppercase tracking-label text-outline"
>
{t('fields.phone')}{' '}
<span className="normal-case font-normal">{t('fields.phoneOptional')}</span>
</label>
<PhoneInput
inputProps={{ id: 'contact-phone', autoComplete: 'tel' }}
defaultCountry="fr"
preferredCountries={['fr', 'us', 'gb', 'mc', 'ch']}
forceDialCode={true}
value={formData.phone}
onChange={(phone) => 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"
/>
</div>
{/* Contact preference selector */}
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold uppercase tracking-label text-outline">
{t('fields.contactPreference')}
</span>
<div className="flex gap-2">
{(['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 (
<button
key={method}
type="button"
onClick={() => setFormData((prev) => ({ ...prev, contactPreference: method }))}
className={cn(
'px-3 py-1.5 rounded-lg border text-xs transition-colors duration-150',
isActive
? 'bg-primary/10 text-primary-dark border-primary/30 font-medium'
: 'bg-white text-outline border-outline-variant/20 hover:border-outline-variant/40',
)}
>
{t(labelKey)}
</button>
);
})}
</div>
</div>
</div>
{/* Error state */}

View File

@@ -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
/>
</div>
{/* Current Website URL */}
<div className="flex flex-col gap-2">
<label
htmlFor="current-site-url"
className="text-xs font-semibold uppercase tracking-label text-outline"
>
{t('fields.currentSiteUrl')}
<span className="ml-1.5 normal-case font-normal text-outline/70">
{t('fields.currentSiteUrlOptional')}
</span>
</label>
<input
id="current-site-url"
type="url"
value={formData.currentSiteUrl}
onChange={(e) =>
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',
)}
/>
</div>
{/* Thoughts on current site (conditional) */}
<AnimatePresence>
{formData.currentSiteUrl.trim().length > 0 && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
<div className="flex flex-col gap-2 pt-1">
<label
htmlFor="current-site-thoughts"
className="text-xs font-semibold uppercase tracking-label text-outline"
>
{t('fields.currentSiteThoughts')}
</label>
<textarea
id="current-site-thoughts"
value={formData.currentSiteThoughts}
onChange={(e) =>
setFormData((prev) => ({ ...prev, currentSiteThoughts: e.target.value }))
}
placeholder={t('fields.currentSiteThoughtsPlaceholder')}
rows={3}
className={cn(
'w-full resize-none 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',
'leading-relaxed',
)}
/>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Timeline */}
<div className="flex flex-col gap-2.5">
<label className="text-xs font-semibold uppercase tracking-label text-outline">

View File

@@ -0,0 +1,120 @@
'use client';
import { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { motion, AnimatePresence } from 'framer-motion';
import { Check, Loader2 } from 'lucide-react';
// ─── Types ───────────────────────────────────────────────────────────────────
interface GeneratingStep {
id: string;
labelKey: string;
durationMs: number;
}
interface StepGeneratingProps {
hasUrl: boolean;
}
// ─── Component ───────────────────────────────────────────────────────────────
export default function StepGenerating({ hasUrl }: StepGeneratingProps) {
const t = useTranslations('configurator');
const steps: GeneratingStep[] = [
{ id: 'preparing', labelKey: 'generatingSteps.preparingBrief', durationMs: 1000 },
...(hasUrl
? [
{ id: 'analyzing', labelKey: 'generatingSteps.analyzingSite', durationMs: 3000 },
{ id: 'performance', labelKey: 'generatingSteps.runningAudit', durationMs: 5000 },
]
: []),
{ id: 'generating', labelKey: 'generatingSteps.generatingBrief', durationMs: Infinity },
];
const [completedCount, setCompletedCount] = useState(0);
useEffect(() => {
const step = steps[completedCount];
if (!step || step.durationMs === Infinity) return;
const timer = setTimeout(() => {
setCompletedCount((prev) => Math.min(prev + 1, steps.length - 1));
}, step.durationMs);
return () => clearTimeout(timer);
}, [completedCount, steps]);
return (
<div className="flex flex-col items-center gap-8 py-12">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
className="w-16 h-16 rounded-2xl bg-gradient-to-br from-primary to-primary-dark/80 flex items-center justify-center shadow-lg"
>
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 2, repeat: Infinity, ease: 'linear' }}
>
<Loader2 size={28} strokeWidth={1.5} className="text-white" />
</motion.div>
</motion.div>
<div className="flex flex-col gap-3 w-full max-w-xs">
{steps.map((step, index) => {
const isCompleted = index < completedCount;
const isActive = index === completedCount;
return (
<motion.div
key={step.id}
initial={{ opacity: 0, x: -12 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1, duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
className="flex items-center gap-3"
>
<div className="flex-shrink-0 w-6 h-6 flex items-center justify-center">
<AnimatePresence mode="wait">
{isCompleted ? (
<motion.div
key="check"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="w-5 h-5 rounded-full bg-primary flex items-center justify-center"
>
<Check size={12} strokeWidth={3} className="text-white" />
</motion.div>
) : isActive ? (
<motion.div
key="spinner"
animate={{ rotate: 360 }}
transition={{ duration: 1.2, repeat: Infinity, ease: 'linear' }}
className="w-5 h-5 rounded-full border-2 border-primary/30 border-t-primary"
/>
) : (
<div
key="pending"
className="w-3 h-3 rounded-full bg-outline-variant/30"
/>
)}
</AnimatePresence>
</div>
<span
className={
isCompleted
? 'text-sm font-medium text-on-surface'
: isActive
? 'text-sm font-semibold text-primary-dark'
: 'text-sm text-outline/40'
}
>
{t(step.labelKey)}
</span>
</motion.div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,275 @@
'use client';
import { useEffect, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { motion, AnimatePresence, useMotionValue, useTransform } from 'framer-motion';
import { Mic, MicOff, PhoneOff, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import Chip from '@/components/ui/Chip';
import { useVoiceAgent, type TranscriptEntry } from './VoiceAgentProvider';
import type { WizardFormData } from './WizardContainer';
// ─── Types ───────────────────────────────────────────────────────────────────
interface VoiceAgentProps {
locale: string;
onComplete: (brief: string, formData: WizardFormData) => void;
}
// ─── Transcript Bubble ───────────────────────────────────────────────────────
function TranscriptBubble({ entry }: { entry: TranscriptEntry }) {
return (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
className={cn(
'flex',
entry.role === 'agent' ? 'justify-start' : 'justify-end',
)}
>
<div
className={cn(
'max-w-[85%] rounded-xl px-3 py-2 text-xs leading-relaxed',
entry.role === 'agent'
? 'bg-surface-low text-on-surface'
: 'bg-primary/10 text-primary-dark',
)}
>
{entry.text}
</div>
</motion.div>
);
}
// ─── Main Component ──────────────────────────────────────────────────────────
export default function VoiceAgent({ locale, onComplete }: VoiceAgentProps) {
const t = useTranslations('configurator');
const {
status,
errorMessage,
isMicActive,
toggleMic,
transcript,
selections,
isAnalyzingSite,
agentAmplitude,
startConversation,
endConversation,
completedBrief,
completedFormData,
} = useVoiceAgent();
const transcriptEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll transcript
useEffect(() => {
transcriptEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [transcript]);
// Handle completion
useEffect(() => {
if (completedBrief && completedFormData) {
const timer = setTimeout(() => {
onComplete(completedBrief, completedFormData);
}, 1500);
return () => clearTimeout(timer);
}
}, [completedBrief, completedFormData, onComplete]);
// Orb animation driven by agent amplitude
const amplitudeValue = useMotionValue(0);
useEffect(() => {
amplitudeValue.set(agentAmplitude);
}, [agentAmplitude, amplitudeValue]);
const orbScale = useTransform(amplitudeValue, [0, 0.5], [1, 1.18]);
const orbGlow = useTransform(
amplitudeValue,
[0, 0.5],
['0px 0px 0px rgba(0,100,148,0)', '0px 0px 30px rgba(0,100,148,0.3)'],
);
// Build selection chips
const chipLabels: string[] = [];
if (selections.services) {
for (const svc of selections.services) {
try { chipLabels.push(t(`services.${svc}.title`)); } catch { chipLabels.push(svc); }
}
}
if (selections.aiEnabled && selections.aiTypes) {
for (const ai of selections.aiTypes) {
try { chipLabels.push(t(`aiTypes.${ai}.title`)); } catch { chipLabels.push(ai); }
}
}
if (selections.industry) {
try { chipLabels.push(t(`industries.${selections.industry}`)); } catch { chipLabels.push(selections.industry); }
}
if (selections.timeline) {
try { chipLabels.push(t(`timelines.${selections.timeline}`)); } catch { chipLabels.push(selections.timeline); }
}
return (
<div className="flex flex-col gap-5">
{/* Agent card header */}
<div className="flex items-center gap-3 px-1">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary-dark/80 flex items-center justify-center">
<span className="text-white font-serif text-xs font-bold">L</span>
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-on-surface">{t('voice.agentName')}</p>
<div className="flex items-center gap-1.5">
<span
className={cn(
'w-1.5 h-1.5 rounded-full',
status === 'active' ? 'bg-green-500' : status === 'connecting' ? 'bg-amber-400 animate-pulse' : 'bg-outline-variant/50',
)}
/>
<span className="text-[10px] text-outline">
{status === 'active' ? 'Connected' : status === 'connecting' ? t('voice.connecting') : 'Ready'}
</span>
</div>
</div>
</div>
{/* Waveform orb */}
<div className="flex flex-col items-center gap-3 py-4">
<motion.div
style={{ scale: status === 'active' ? orbScale : 1, boxShadow: status === 'active' ? orbGlow : 'none' }}
className={cn(
'w-20 h-20 rounded-full flex items-center justify-center transition-colors duration-300',
status === 'active'
? 'bg-gradient-to-br from-primary to-primary-dark'
: status === 'connecting'
? 'bg-primary/20'
: 'bg-surface-low border-2 border-outline-variant/30',
)}
>
{status === 'idle' && (
<Mic size={28} strokeWidth={1.5} className="text-outline" />
)}
{status === 'connecting' && (
<motion.div animate={{ rotate: 360 }} transition={{ duration: 1.5, repeat: Infinity, ease: 'linear' }}>
<Loader2 size={28} strokeWidth={1.5} className="text-primary" />
</motion.div>
)}
{status === 'active' && (
<motion.div
animate={{ scale: [1, 1.1, 1] }}
transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut' }}
>
<Mic size={28} strokeWidth={1.5} className="text-white" />
</motion.div>
)}
</motion.div>
{/* Analyzing site badge */}
<AnimatePresence>
{isAnalyzingSite && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary/10 text-primary-dark text-xs font-medium"
>
<motion.div animate={{ rotate: 360 }} transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}>
<Loader2 size={11} />
</motion.div>
{t('voice.analyzingSite')}
</motion.div>
)}
</AnimatePresence>
{/* Error message */}
{errorMessage && (
<p className="text-xs text-red-600 text-center max-w-xs">{errorMessage}</p>
)}
</div>
{/* Live transcript */}
{transcript.length > 0 && (
<div className="rounded-xl border border-outline-variant/30 bg-surface-high p-3 max-h-40 overflow-y-auto scrollbar-thin">
<div className="flex flex-col gap-2">
{transcript.map((entry, i) => (
<TranscriptBubble key={`${entry.timestamp}-${i}`} entry={entry} />
))}
<div ref={transcriptEndRef} />
</div>
</div>
)}
{/* Selection chips */}
<AnimatePresence>
{chipLabels.length > 0 && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden"
>
<p className="text-xs font-semibold uppercase tracking-label text-outline mb-2">
{t('voice.capturedSoFar')}
</p>
<div className="flex flex-wrap gap-1.5">
{chipLabels.map((label, i) => (
<motion.div
key={label}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: i * 0.05, duration: 0.2 }}
>
<Chip active>{label}</Chip>
</motion.div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Controls */}
<div className="flex items-center justify-center gap-3 pt-2">
{status === 'idle' && (
<button
type="button"
onClick={startConversation}
className="flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-medium text-white transition-all hover:-translate-y-px active:translate-y-0"
style={{ background: 'linear-gradient(135deg, #006494, #5BA4D9)' }}
>
<Mic size={16} />
{locale === 'fr' ? 'Démarrer la conversation' : 'Start Conversation'}
</button>
)}
{status === 'active' && (
<>
<button
type="button"
onClick={toggleMic}
className={cn(
'w-11 h-11 rounded-full flex items-center justify-center transition-all',
isMicActive
? 'bg-surface-low text-on-surface hover:bg-outline-variant/30'
: 'bg-red-100 text-red-600',
)}
>
{isMicActive ? <Mic size={18} /> : <MicOff size={18} />}
</button>
<button
type="button"
onClick={endConversation}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-red-50 text-red-700 text-xs font-medium hover:bg-red-100 transition-colors"
>
<PhoneOff size={14} />
{t('voice.endConversation')}
</button>
</>
)}
{status === 'connecting' && (
<p className="text-sm text-outline animate-pulse">{t('voice.connecting')}</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,432 @@
'use client';
import { createContext, useContext, useState, useRef, useCallback, type ReactNode } from 'react';
import type { WizardFormData } from './WizardContainer';
// ─── Types ───────────────────────────────────────────────────────────────────
export interface TranscriptEntry {
role: 'user' | 'agent';
text: string;
timestamp: number;
}
type ConnectionStatus = 'idle' | 'connecting' | 'active' | 'ending' | 'error';
interface VoiceAgentContextValue {
status: ConnectionStatus;
errorMessage: string | null;
isMicActive: boolean;
toggleMic: () => void;
transcript: TranscriptEntry[];
selections: Partial<WizardFormData>;
isAnalyzingSite: boolean;
userAmplitude: number;
agentAmplitude: number;
startConversation: () => Promise<void>;
endConversation: () => void;
completedBrief: string | null;
completedFormData: WizardFormData | null;
}
// ─── Context ─────────────────────────────────────────────────────────────────
const VoiceAgentContext = createContext<VoiceAgentContextValue | null>(null);
export function useVoiceAgent() {
const ctx = useContext(VoiceAgentContext);
if (!ctx) throw new Error('useVoiceAgent must be used within VoiceAgentProvider');
return ctx;
}
// ─── Audio Helpers ───────────────────────────────────────────────────────────
function int16ToFloat32(int16: Int16Array): Float32Array {
const float32 = new Float32Array(int16.length);
for (let i = 0; i < int16.length; i++) {
float32[i] = int16[i] / 32768;
}
return float32;
}
function base64ToInt16(base64: string): Int16Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new Int16Array(bytes.buffer);
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
// ─── Audio Worklet Processor Code ────────────────────────────────────────────
const WORKLET_CODE = `
class AudioRecordingWorklet extends AudioWorkletProcessor {
buffer = new Int16Array(2048);
bufferWriteIndex = 0;
process(inputs) {
if (inputs[0].length) {
const channel0 = inputs[0][0];
for (let i = 0; i < channel0.length; i++) {
const sample = Math.max(-1, Math.min(1, channel0[i]));
this.buffer[this.bufferWriteIndex++] = sample * 32767;
if (this.bufferWriteIndex >= this.buffer.length) {
this.port.postMessage({
event: 'chunk',
data: { int16arrayBuffer: this.buffer.slice(0, this.bufferWriteIndex).buffer },
});
this.bufferWriteIndex = 0;
}
}
}
return true;
}
}
registerProcessor('audio-recorder-worklet', AudioRecordingWorklet);
`;
// ─── Default Form Data (mirror WizardContainer) ─────────────────────────────
const DEFAULT_FORM_DATA: WizardFormData = {
services: [],
aiEnabled: false,
aiTypes: [],
industry: null,
scope: '',
timeline: null,
name: '',
company: '',
email: '',
phone: '',
contactPreference: 'email',
currentSiteUrl: '',
currentSiteThoughts: '',
};
// ─── Provider Component ──────────────────────────────────────────────────────
interface VoiceAgentProviderProps {
locale: string;
children: ReactNode;
}
export default function VoiceAgentProvider({ locale, children }: VoiceAgentProviderProps) {
const [status, setStatus] = useState<ConnectionStatus>('idle');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isMicActive, setIsMicActive] = useState(true);
const [transcript, setTranscript] = useState<TranscriptEntry[]>([]);
const [selections, setSelections] = useState<Partial<WizardFormData>>({});
const [isAnalyzingSite, setIsAnalyzingSite] = useState(false);
const [userAmplitude, setUserAmplitude] = useState(0);
const [agentAmplitude, setAgentAmplitude] = useState(0);
const [completedBrief, setCompletedBrief] = useState<string | null>(null);
const [completedFormData, setCompletedFormData] = useState<WizardFormData | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const mediaStreamRef = useRef<MediaStream | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const playbackContextRef = useRef<AudioContext | null>(null);
const nextStartTimeRef = useRef(0);
const analyserRef = useRef<AnalyserNode | null>(null);
const animFrameRef = useRef<number>(0);
const addTranscript = useCallback((role: 'user' | 'agent', text: string) => {
setTranscript((prev) => [...prev, { role, text, timestamp: Date.now() }]);
}, []);
const trackAmplitude = useCallback(() => {
if (!analyserRef.current) return;
const data = new Uint8Array(analyserRef.current.fftSize);
analyserRef.current.getByteTimeDomainData(data);
let sum = 0;
for (let i = 0; i < data.length; i++) {
const v = (data[i] - 128) / 128;
sum += v * v;
}
setUserAmplitude(Math.sqrt(sum / data.length));
animFrameRef.current = requestAnimationFrame(trackAmplitude);
}, []);
const handleToolCall = useCallback(
async (name: string, args: Record<string, unknown>, callId: string) => {
if (name === 'update_selections') {
setSelections((prev) => ({ ...prev, ...(args as Partial<WizardFormData>) }));
return JSON.stringify({ success: true });
}
if (name === 'analyze_website') {
setIsAnalyzingSite(true);
try {
const res = await fetch('/api/analyze-site', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: args.url }),
});
const data = await res.json();
setIsAnalyzingSite(false);
return JSON.stringify(data);
} catch {
setIsAnalyzingSite(false);
return JSON.stringify({ success: false, summary: "I wasn't able to analyze that site." });
}
}
if (name === 'complete_brief') {
try {
const formData = { ...DEFAULT_FORM_DATA, ...(args as Partial<WizardFormData>), locale };
const res = await fetch('/api/configure', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
const data = (await res.json()) as { success: boolean; brief?: string };
if (data.success && data.brief) {
setCompletedBrief(data.brief);
setCompletedFormData(formData as WizardFormData);
}
return JSON.stringify(data);
} catch {
return JSON.stringify({ success: false, error: 'Brief generation failed' });
}
}
return JSON.stringify({ error: `Unknown tool: ${name}` });
},
[locale],
);
const playAudioChunk = useCallback((base64Audio: string) => {
if (!playbackContextRef.current) {
playbackContextRef.current = new AudioContext({ sampleRate: 24000 });
nextStartTimeRef.current = playbackContextRef.current.currentTime;
}
const ctx = playbackContextRef.current;
const int16 = base64ToInt16(base64Audio);
const float32 = int16ToFloat32(int16);
const buffer = ctx.createBuffer(1, float32.length, 24000);
buffer.copyToChannel(new Float32Array(float32), 0);
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(ctx.destination);
if (nextStartTimeRef.current < ctx.currentTime) {
nextStartTimeRef.current = ctx.currentTime;
}
source.start(nextStartTimeRef.current);
nextStartTimeRef.current += buffer.duration;
const amplitude = Math.sqrt(float32.reduce((sum, v) => sum + v * v, 0) / float32.length);
setAgentAmplitude(amplitude);
}, []);
const startConversation = useCallback(async () => {
setStatus('connecting');
setErrorMessage(null);
setTranscript([]);
setSelections({});
setCompletedBrief(null);
setCompletedFormData(null);
try {
const tokenRes = await fetch('/api/gemini-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locale }),
});
const tokenData = await tokenRes.json();
if (!tokenData.success) throw new Error('Token generation failed');
const { apiKey, model, config } = tokenData;
const stream = await navigator.mediaDevices.getUserMedia({
audio: { sampleRate: 16000, channelCount: 1, echoCancellation: true, noiseSuppression: true },
});
mediaStreamRef.current = stream;
// Create AudioContext for mic capture (must be in user gesture handler)
const audioCtx = new AudioContext({ sampleRate: 16000 });
audioContextRef.current = audioCtx;
const source = audioCtx.createMediaStreamSource(stream);
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
analyserRef.current = analyser;
// Register AudioWorklet
const workletBlob = new Blob([WORKLET_CODE], { type: 'application/javascript' });
const workletUrl = URL.createObjectURL(workletBlob);
await audioCtx.audioWorklet.addModule(workletUrl);
URL.revokeObjectURL(workletUrl);
const workletNode = new AudioWorkletNode(audioCtx, 'audio-recorder-worklet');
source.connect(workletNode);
workletNode.connect(audioCtx.destination);
// Open WebSocket to Gemini Live API
const wsUrl = `wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key=${apiKey}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
// Send setup message with config
ws.send(JSON.stringify({
setup: {
model: `models/${model}`,
generationConfig: {
responseModalities: config.responseModalities,
speechConfig: config.speechConfig,
},
systemInstruction: {
parts: [{ text: config.systemInstruction }],
},
tools: config.tools,
},
}));
};
// Send audio chunks from worklet
workletNode.port.onmessage = (event) => {
if (event.data.event === 'chunk' && ws.readyState === WebSocket.OPEN) {
const base64 = arrayBufferToBase64(event.data.data.int16arrayBuffer);
ws.send(JSON.stringify({
realtimeInput: {
mediaChunks: [{ mimeType: 'audio/pcm;rate=16000', data: base64 }],
},
}));
}
};
ws.onmessage = async (event) => {
const msg = JSON.parse(event.data as string);
// Setup complete
if (msg.setupComplete) {
setStatus('active');
trackAmplitude();
return;
}
// Server content (audio + text)
if (msg.serverContent) {
const parts = msg.serverContent.modelTurn?.parts;
if (parts) {
for (const part of parts) {
if (part.inlineData?.mimeType?.startsWith('audio/')) {
playAudioChunk(part.inlineData.data);
}
if (part.text) {
addTranscript('agent', part.text);
}
}
}
// Input transcription
if (msg.serverContent.inputTranscription?.text) {
addTranscript('user', msg.serverContent.inputTranscription.text);
}
// Output transcription
if (msg.serverContent.outputTranscription?.text) {
addTranscript('agent', msg.serverContent.outputTranscription.text);
}
}
// Tool call
if (msg.toolCall) {
const calls = msg.toolCall.functionCalls;
if (calls) {
const responses = [];
for (const call of calls) {
const result = await handleToolCall(call.name, call.args ?? {}, call.id);
responses.push({ id: call.id, name: call.name, response: { result } });
}
ws.send(JSON.stringify({ toolResponse: { functionResponses: responses } }));
}
}
};
ws.onerror = () => {
setStatus('error');
setErrorMessage('Connection error. Please try again.');
};
ws.onclose = () => {
if (status === 'active') {
setStatus('idle');
}
};
} catch (error) {
setStatus('error');
if (error instanceof DOMException && error.name === 'NotAllowedError') {
setErrorMessage('Microphone access was denied.');
} else {
setErrorMessage('Failed to start conversation. Please try again.');
}
}
}, [locale, trackAmplitude, handleToolCall, playAudioChunk, addTranscript, status]);
const endConversation = useCallback(() => {
setStatus('ending');
cancelAnimationFrame(animFrameRef.current);
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
if (mediaStreamRef.current) {
mediaStreamRef.current.getTracks().forEach((track) => track.stop());
mediaStreamRef.current = null;
}
if (audioContextRef.current) {
void audioContextRef.current.close();
audioContextRef.current = null;
}
if (playbackContextRef.current) {
void playbackContextRef.current.close();
playbackContextRef.current = null;
}
setUserAmplitude(0);
setAgentAmplitude(0);
setStatus('idle');
}, []);
const toggleMic = useCallback(() => {
if (!mediaStreamRef.current) return;
const track = mediaStreamRef.current.getAudioTracks()[0];
if (track) {
track.enabled = !track.enabled;
setIsMicActive(track.enabled);
}
}, []);
const value: VoiceAgentContextValue = {
status,
errorMessage,
isMicActive,
toggleMic,
transcript,
selections,
isAnalyzingSite,
userAmplitude,
agentAmplitude,
startConversation,
endConversation,
completedBrief,
completedFormData,
};
return (
<VoiceAgentContext.Provider value={value}>
{children}
</VoiceAgentContext.Provider>
);
}

View File

@@ -1,12 +1,16 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import { AnimatePresence, motion } from 'framer-motion';
import StepServices from './StepServices';
import StepDetails from './StepDetails';
import StepContact from './StepContact';
import StepGenerating from './StepGenerating';
import StepComplete from './StepComplete';
import ModeToggle from './ModeToggle';
import VoiceAgent from './VoiceAgent';
import VoiceAgentProvider from './VoiceAgentProvider';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -20,6 +24,10 @@ export interface WizardFormData {
name: string;
company: string;
email: string;
phone: string;
contactPreference: string;
currentSiteUrl: string;
currentSiteThoughts: string;
}
export interface StepProps {
@@ -66,16 +74,23 @@ const DEFAULT_FORM_DATA: WizardFormData = {
name: '',
company: '',
email: '',
phone: '',
contactPreference: 'email',
currentSiteUrl: '',
currentSiteThoughts: '',
};
export default function WizardContainer() {
const t = useTranslations('configurator');
const locale = useLocale();
const [currentStep, setCurrentStep] = useState<1 | 2 | 3 | 4>(1);
const [direction, setDirection] = useState<1 | -1>(1);
const [formData, setFormData] = useState<WizardFormData>(DEFAULT_FORM_DATA);
const [brief, setBrief] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [mode, setMode] = useState<'type' | 'talk'>('type');
const goNext = () => {
setDirection(1);
@@ -92,37 +107,50 @@ export default function WizardContainer() {
setFormData(DEFAULT_FORM_DATA);
setBrief('');
setSubmitError(null);
setIsGenerating(false);
setMode('type');
setCurrentStep(1);
};
const handleSubmit = async () => {
setIsSubmitting(true);
setIsGenerating(true);
setSubmitError(null);
try {
const response = await fetch('/api/configure', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
body: JSON.stringify({ ...formData, locale }),
});
const data = (await response.json()) as { success: boolean; brief?: string; error?: string };
if (!response.ok || !data.success) {
setSubmitError(data.error ?? t('errors.general'));
setIsGenerating(false);
setIsSubmitting(false);
return;
}
setBrief(data.brief ?? '');
setDirection(1);
setIsGenerating(false);
setCurrentStep(4);
} catch {
setSubmitError(t('errors.network'));
setIsGenerating(false);
} finally {
setIsSubmitting(false);
}
};
const handleVoiceComplete = (voiceBrief: string, voiceFormData: WizardFormData) => {
setFormData(voiceFormData);
setBrief(voiceBrief);
setDirection(1);
setCurrentStep(4);
};
const stepVariants = makeVariants(direction);
const sharedProps: StepProps = {
@@ -134,8 +162,28 @@ export default function WizardContainer() {
return (
<div className="relative overflow-hidden">
{!isGenerating && currentStep !== 4 && (
<div className="flex justify-center mb-4">
<ModeToggle mode={mode} onChange={setMode} />
</div>
)}
<AnimatePresence mode="wait" initial={false}>
{currentStep === 1 && (
{mode === 'talk' && !isGenerating && currentStep !== 4 && (
<motion.div
key="voice-mode"
variants={stepVariants}
initial="initial"
animate="animate"
exit="exit"
>
<VoiceAgentProvider locale={locale}>
<VoiceAgent locale={locale} onComplete={handleVoiceComplete} />
</VoiceAgentProvider>
</motion.div>
)}
{mode === 'type' && !isGenerating && currentStep === 1 && (
<motion.div
key="step-1"
variants={stepVariants}
@@ -147,7 +195,7 @@ export default function WizardContainer() {
</motion.div>
)}
{currentStep === 2 && (
{mode === 'type' && !isGenerating && currentStep === 2 && (
<motion.div
key="step-2"
variants={stepVariants}
@@ -159,7 +207,7 @@ export default function WizardContainer() {
</motion.div>
)}
{currentStep === 3 && (
{mode === 'type' && !isGenerating && currentStep === 3 && (
<motion.div
key="step-3"
variants={stepVariants}
@@ -176,7 +224,19 @@ export default function WizardContainer() {
</motion.div>
)}
{currentStep === 4 && (
{isGenerating && (
<motion.div
key="step-generating"
variants={stepVariants}
initial="initial"
animate="animate"
exit="exit"
>
<StepGenerating hasUrl={!!formData.currentSiteUrl.trim()} />
</motion.div>
)}
{!isGenerating && currentStep === 4 && (
<motion.div
key="step-4"
variants={stepVariants}

View File

@@ -70,8 +70,8 @@
"complete": {
"title": "Your project brief is ready",
"subtitle": "Check your inbox - we've sent a detailed brief to {email}",
"bookTitle": "Book a Consultation",
"bookSubtitle": "30 minutes to discuss your brief with our team",
"nextStep": "Next step: let's talk through your brief",
"bookSubtitle": "Book a free 30-minute call to discuss your project and next steps.",
"bookCall": "Book a Call",
"briefPreview": "Your project brief",
"reachDirectly": "Or reach us directly at"
@@ -136,13 +136,43 @@
"timeline": "Timeline",
"name": "Your name",
"company": "Company",
"email": "Email address"
"email": "Email address",
"phone": "Phone",
"phoneOptional": "(optional)",
"contactPreference": "Preferred contact method",
"contactEmail": "Email",
"contactPhone": "Phone",
"contactWhatsapp": "WhatsApp",
"currentSiteUrl": "Current website",
"currentSiteUrlOptional": "(optional)",
"currentSiteUrlPlaceholder": "https://yourwebsite.com",
"currentSiteThoughts": "Thoughts on your current site",
"currentSiteThoughtsPlaceholder": "What's working, what isn't, what you'd like to change..."
},
"summary": {
"heading": "Your selections",
"aiEnhancement": "AI Enhancement"
},
"generating": "Generating",
"generatingSteps": {
"preparingBrief": "Preparing your brief",
"analyzingSite": "Analyzing your current website",
"runningAudit": "Running performance audit",
"generatingBrief": "Generating your personalized brief"
},
"mode": {
"type": "Type",
"talk": "Talk"
},
"voice": {
"agentName": "LetsBe project assistant",
"capturedSoFar": "Captured so far",
"endConversation": "End Conversation",
"analyzingSite": "Analyzing your site...",
"connecting": "Connecting...",
"mute": "Mute",
"unmute": "Unmute"
},
"privacy": "Your information is private and will never be shared.",
"generateBrief": "Generate My Brief",
"nextStep": "Next Step",

View File

@@ -70,8 +70,8 @@
"complete": {
"title": "Votre brief projet est prêt",
"subtitle": "Vérifiez votre boîte mail - nous avons envoyé un brief détaillé à {email}",
"bookTitle": "Réservez une Consultation",
"bookSubtitle": "30 minutes pour discuter de votre brief avec notre équipe",
"nextStep": "Prochaine étape : discutons de votre brief",
"bookSubtitle": "Réservez un appel gratuit de 30 minutes pour discuter de votre projet.",
"bookCall": "Réserver un Appel",
"briefPreview": "Votre brief projet",
"reachDirectly": "Ou contactez-nous directement à"
@@ -136,13 +136,43 @@
"timeline": "Calendrier",
"name": "Votre nom",
"company": "Entreprise",
"email": "Adresse email"
"email": "Adresse email",
"phone": "Téléphone",
"phoneOptional": "(facultatif)",
"contactPreference": "Mode de contact préféré",
"contactEmail": "Email",
"contactPhone": "Téléphone",
"contactWhatsapp": "WhatsApp",
"currentSiteUrl": "Site web actuel",
"currentSiteUrlOptional": "(facultatif)",
"currentSiteUrlPlaceholder": "https://votresite.com",
"currentSiteThoughts": "Vos impressions sur votre site actuel",
"currentSiteThoughtsPlaceholder": "Ce qui fonctionne, ce qui ne fonctionne pas, ce que vous aimeriez changer..."
},
"summary": {
"heading": "Vos sélections",
"aiEnhancement": "Enrichissement IA"
},
"generating": "Génération",
"generatingSteps": {
"preparingBrief": "Préparation de votre brief",
"analyzingSite": "Analyse de votre site actuel",
"runningAudit": "Audit de performance en cours",
"generatingBrief": "Génération de votre brief personnalisé"
},
"mode": {
"type": "Écrire",
"talk": "Parler"
},
"voice": {
"agentName": "Assistant projet LetsBe",
"capturedSoFar": "Informations recueillies",
"endConversation": "Terminer la conversation",
"analyzingSite": "Analyse de votre site...",
"connecting": "Connexion en cours...",
"mute": "Couper le micro",
"unmute": "Activer le micro"
},
"privacy": "Vos informations sont privées et ne seront jamais partagées.",
"generateBrief": "Générer Mon Brief",
"nextStep": "Étape Suivante",

View File

@@ -17,68 +17,286 @@ interface SendBriefEmailOptions {
brief: string
}
interface SendLeadNotificationOptions {
to: string
name: string
company: string
brief: string
email: string
services: string[]
phone?: string
contactPreference?: string
}
/**
* Converts a markdown-style brief string into styled HTML.
* Handles: **bold**, ---, numbered lists, section headings, paragraphs.
*/
function convertBriefToHtml(brief: string): string {
const lines = brief.split('\n')
const outputLines: string[] = []
for (let i = 0; i < lines.length; i++) {
let line = lines[i]
// Horizontal rule
if (line.trim() === '---') {
outputLines.push('<hr style="border:none;border-top:1px solid #d1e8f5;margin:20px 0">')
continue
}
// Empty line — paragraph break spacer
if (line.trim() === '') {
outputLines.push('<div style="height:8px"></div>')
continue
}
// Bold inline
line = line.replace(/\*\*(.*?)\*\*/g, '<strong style="color:#191c1d;font-weight:600">$1</strong>')
// Numbered list items: "1. text" or "2. text"
const numberedMatch = line.match(/^(\d+)\.\s+(.+)$/)
if (numberedMatch) {
outputLines.push(
`<div style="display:flex;gap:10px;margin:6px 0;line-height:1.65;font-size:14px;color:#374151">` +
`<span style="font-weight:700;color:#006494;min-width:20px">${numberedMatch[1]}.</span>` +
`<span>${numberedMatch[2]}</span>` +
`</div>`
)
continue
}
// Section heading detection: lines that are entirely bold (e.g. **Heading**)
const headingMatch = line.match(/^<strong[^>]*>(.*?)<\/strong>$/)
if (headingMatch) {
outputLines.push(
`<p style="margin:16px 0 4px;font-size:15px;font-weight:700;color:#006494;letter-spacing:0.01em">${headingMatch[1]}</p>`
)
continue
}
// Regular paragraph line
outputLines.push(
`<p style="margin:0 0 8px;font-size:14px;line-height:1.7;color:#374151">${line}</p>`
)
}
return outputLines.join('\n')
}
export async function sendBriefToClient({ to, name, brief }: SendBriefEmailOptions) {
const firstName = name.split(' ')[0] || 'there'
// Convert markdown-style **bold** to HTML <strong>
const htmlBrief = brief
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/---/g, '<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0">')
.replace(/\n\n/g, '</p><p style="margin:0 0 12px;line-height:1.6">')
.replace(/\n/g, '<br>')
const htmlBrief = convertBriefToHtml(brief)
await transporter.sendMail({
from: `"LetsBe." <${process.env.SMTP_FROM || 'hello@letsbe.biz'}>`,
to,
subject: 'Your Project Brief from LetsBe.',
html: `
<div style="font-family:'Inter',Helvetica,Arial,sans-serif;max-width:600px;margin:0 auto;color:#191c1d">
<div style="padding:32px 0;border-bottom:2px solid #006494">
<h1 style="font-family:Georgia,serif;font-size:24px;margin:0;color:#006494">LetsBe.</h1>
</div>
<div style="padding:32px 0">
<p style="margin:0 0 16px;font-size:16px;line-height:1.6">Hi ${firstName},</p>
<p style="margin:0 0 24px;font-size:15px;line-height:1.6;color:#555">
Thank you for configuring your project with us. Here's your personalized brief:
</p>
<div style="background:#f8f9fa;border-radius:12px;padding:24px;font-size:14px;line-height:1.7;color:#333">
<p style="margin:0 0 12px;line-height:1.6">${htmlBrief}</p>
</div>
<div style="margin-top:32px;text-align:center">
<a href="https://scheduling.letsbe.solutions/matt-ciaccio/letsbe"
style="display:inline-block;padding:14px 32px;background:linear-gradient(135deg,#006494,#5BA4D9);color:#fff;text-decoration:none;border-radius:8px;font-size:14px;font-weight:500">
Book a Consultation
</a>
</div>
<p style="margin:32px 0 0;font-size:13px;color:#999;text-align:center">
Or reply to this email — we'll get back to you within 24 hours.
</p>
</div>
<div style="border-top:1px solid #e5e7eb;padding:24px 0;font-size:12px;color:#999;text-align:center">
LetsBe Solutions LLC
</div>
</div>
`,
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Your Project Brief from LetsBe.</title>
</head>
<body style="margin:0;padding:0;background-color:#f0f4f8;font-family:'Inter',-apple-system,'Segoe UI',Helvetica,Arial,sans-serif">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f0f4f8;padding:32px 16px">
<tr>
<td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px">
<!-- HEADER -->
<tr>
<td style="background:#ffffff;border-bottom:2px solid #e8eef3;border-radius:16px 16px 0 0;padding:28px 32px;text-align:center">
<img src="${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.letsbe.biz'}/images/letsbe-logo-short.png" alt="LetsBe." width="52" style="display:block;margin:0 auto" />
</td>
</tr>
<!-- GREETING -->
<tr>
<td style="background:#ffffff;padding:36px 40px 28px">
<p style="margin:0 0 8px;font-size:22px;font-weight:600;color:#191c1d;line-height:1.3">Hi ${firstName},</p>
<p style="margin:0;font-size:15px;line-height:1.7;color:#6b7280">
Thank you for configuring your project with us. Here's your personalized brief — a summary of everything we discussed, ready for your review.
</p>
</td>
</tr>
<!-- BRIEF CARD -->
<tr>
<td style="background:#ffffff;padding:0 40px 36px">
<div style="background:#f0f7fb;border-left:3px solid #006494;border-radius:12px;padding:28px">
${htmlBrief}
</div>
</td>
</tr>
<!-- DIVIDER -->
<tr>
<td style="background:#ffffff;padding:0 40px">
<hr style="border:none;border-top:1px solid #e5e7eb;margin:0">
</td>
</tr>
<!-- CTA -->
<tr>
<td style="background:#ffffff;padding:36px 40px;text-align:center">
<p style="margin:0 0 6px;font-family:Georgia,'Times New Roman',serif;font-size:20px;font-weight:400;color:#191c1d">Ready to discuss your brief?</p>
<p style="margin:0 0 28px;font-size:14px;color:#6b7280;line-height:1.6">We'll walk through your goals, answer questions, and outline next steps — no pressure.</p>
<a href="https://scheduling.letsbe.solutions/matt-ciaccio/letsbe"
style="display:inline-block;padding:16px 40px;background:linear-gradient(135deg,#006494 0%,#5BA4D9 100%);color:#ffffff;text-decoration:none;border-radius:10px;font-size:15px;font-weight:600;letter-spacing:0.01em;box-shadow:0 4px 14px rgba(0,100,148,0.35)">
Book a 30-Minute Call
</a>
<p style="margin:20px 0 0;font-size:13px;color:#9ca3af;line-height:1.6">
Or reply to this email — we'll get back to you within 24 hours.
</p>
</td>
</tr>
<!-- FOOTER -->
<tr>
<td style="background:#f8fafb;border-top:1px solid #e5e7eb;border-radius:0 0 16px 16px;padding:24px 40px;text-align:center">
<p style="margin:0 0 4px;font-size:13px;font-weight:600;color:#374151">LetsBe Solutions LLC</p>
<p style="margin:0;font-size:12px;color:#9ca3af;line-height:1.6">Custom websites, software, and infrastructure — designed and built around you.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`.trim(),
})
}
export async function sendLeadNotification({ to, name, company, brief }: SendBriefEmailOptions & { services: string[]; email: string }) {
export async function sendLeadNotification({
to,
name,
company,
brief,
email,
phone,
contactPreference,
}: SendLeadNotificationOptions) {
const adminEmail = process.env.ADMIN_EMAIL || 'hello@letsbe.biz'
const htmlBrief = convertBriefToHtml(brief)
// Build contact rows with alternating backgrounds
const contactRows: string[] = []
const rowData: Array<{ label: string; value: string; isLink?: string }> = [
{ label: 'Name', value: name },
{ label: 'Company', value: company || '—' },
{ label: 'Email', value: email, isLink: `mailto:${email}` },
]
if (phone) {
rowData.push({ label: 'Phone', value: phone, isLink: `tel:${phone}` })
}
if (contactPreference) {
rowData.push({ label: 'Preferred Contact', value: contactPreference })
}
rowData.forEach((row, idx) => {
const bg = idx % 2 === 0 ? '#f8fafb' : '#ffffff'
const valueHtml = row.isLink
? `<a href="${row.isLink}" style="color:#006494;text-decoration:none;font-weight:500">${row.value}</a>`
: `<span style="color:#191c1d">${row.value}</span>`
contactRows.push(`
<tr style="background-color:${bg}">
<td style="padding:10px 16px;font-size:13px;font-weight:600;color:#6b7280;white-space:nowrap;width:140px">${row.label}</td>
<td style="padding:10px 16px;font-size:14px">${valueHtml}</td>
</tr>
`)
})
await transporter.sendMail({
from: `"LetsBe. Configurator" <${process.env.SMTP_FROM || 'hello@letsbe.biz'}>`,
to: adminEmail,
subject: `New Lead: ${name}${company ? `${company}` : ''}`,
html: `
<div style="font-family:'Inter',Helvetica,Arial,sans-serif;color:#191c1d">
<h2 style="font-family:Georgia,serif;color:#006494;margin:0 0 16px">New Configurator Submission</h2>
<table style="font-size:14px;line-height:1.6;border-collapse:collapse">
<tr><td style="padding:4px 16px 4px 0;font-weight:600">Name:</td><td>${name}</td></tr>
<tr><td style="padding:4px 16px 4px 0;font-weight:600">Company:</td><td>${company || '—'}</td></tr>
<tr><td style="padding:4px 16px 4px 0;font-weight:600">Email:</td><td><a href="mailto:${to}">${to}</a></td></tr>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>New Configurator Lead</title>
</head>
<body style="margin:0;padding:0;background-color:#f0f4f8;font-family:'Inter',-apple-system,'Segoe UI',Helvetica,Arial,sans-serif">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f0f4f8;padding:32px 16px">
<tr>
<td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px">
<!-- HEADER -->
<tr>
<td style="background:#ffffff;border-bottom:2px solid #e8eef3;border-radius:16px 16px 0 0;padding:24px 32px">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td>
<img src="${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.letsbe.biz'}/images/letsbe-logo-short.png" alt="LetsBe." width="44" style="display:block;max-width:44px;height:auto" />
</td>
<td align="right" valign="middle">
<span style="display:inline-block;background:#006494;color:#ffffff;font-size:11px;font-weight:700;letter-spacing:0.06em;text-transform:uppercase;padding:5px 14px;border-radius:20px">New Lead</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- CONTACT INFO CARD -->
<tr>
<td style="background:#ffffff;padding:32px 40px 24px">
<p style="margin:0 0 16px;font-size:11px;font-weight:700;color:#9ca3af;letter-spacing:0.08em;text-transform:uppercase">Contact Details</p>
<div style="border:1px solid #e5e7eb;border-radius:10px;overflow:hidden">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse">
${contactRows.join('')}
</table>
</div>
</td>
</tr>
<!-- BRIEF SECTION -->
<tr>
<td style="background:#ffffff;padding:8px 40px 36px">
<p style="margin:0 0 16px;font-size:11px;font-weight:700;color:#9ca3af;letter-spacing:0.08em;text-transform:uppercase">Project Brief</p>
<div style="background:#f0f7fb;border-left:3px solid #006494;border-radius:12px;padding:28px">
${htmlBrief}
</div>
</td>
</tr>
<!-- DIVIDER -->
<tr>
<td style="background:#ffffff;padding:0 40px">
<hr style="border:none;border-top:1px solid #e5e7eb;margin:0">
</td>
</tr>
<!-- QUICK ACTION -->
<tr>
<td style="background:#ffffff;padding:28px 40px;text-align:center">
<a href="mailto:${email}?subject=Re: Your LetsBe. Project Brief"
style="display:inline-block;padding:14px 36px;background:linear-gradient(135deg,#006494 0%,#5BA4D9 100%);color:#ffffff;text-decoration:none;border-radius:10px;font-size:14px;font-weight:600;letter-spacing:0.01em">
Reply to Lead
</a>
</td>
</tr>
<!-- FOOTER -->
<tr>
<td style="background:#f8fafb;border-top:1px solid #e5e7eb;border-radius:0 0 16px 16px;padding:20px 40px;text-align:center">
<p style="margin:0;font-size:12px;color:#9ca3af">LetsBe Solutions LLC — Internal Notification</p>
</td>
</tr>
</table>
<div style="margin-top:24px;background:#f8f9fa;border-radius:8px;padding:16px;font-size:13px;white-space:pre-wrap">${brief}</div>
</div>
`,
</td>
</tr>
</table>
</body>
</html>
`.trim(),
})
}

164
src/lib/gemini-live.ts Normal file
View File

@@ -0,0 +1,164 @@
import { GoogleGenAI, Type } from '@google/genai';
// ─── Constants ────────────────────────────────────────────────────────────────
export const GEMINI_LIVE_MODEL = 'gemini-3.1-flash-live-preview';
// ─── Agent Tools ─────────────────────────────────────────────────────────────
export const AGENT_TOOLS = [
{
name: 'update_selections',
description:
'Emit structured project data as it is confirmed during conversation. Call incrementally as each detail is captured.',
parameters: {
type: Type.OBJECT,
properties: {
services: {
type: Type.ARRAY,
items: { type: Type.STRING },
description: 'Selected services: web, systems, infrastructure',
},
aiEnabled: {
type: Type.BOOLEAN,
description: 'Whether AI integration is requested',
},
aiTypes: {
type: Type.ARRAY,
items: { type: Type.STRING },
description: 'AI types: teammate, customer-facing, data-intelligence, notsure',
},
industry: { type: Type.STRING, description: 'Industry sector' },
timeline: { type: Type.STRING, description: 'Timeline preference' },
currentSiteUrl: { type: Type.STRING, description: 'Current website URL' },
scope: { type: Type.STRING, description: 'Project goals/scope summary' },
},
},
},
{
name: 'analyze_website',
description:
'Analyze a website URL to understand its current technology, performance, and structure. Call when the user provides their current website URL.',
parameters: {
type: Type.OBJECT,
properties: {
url: { type: Type.STRING, description: 'The website URL to analyze' },
},
required: ['url'],
},
},
{
name: 'complete_brief',
description:
'Generate and send the project brief. Call once all information is collected and the user has confirmed their name and email.',
parameters: {
type: Type.OBJECT,
properties: {
name: { type: Type.STRING },
email: { type: Type.STRING },
company: { type: Type.STRING },
phone: { type: Type.STRING },
contactPreference: { type: Type.STRING },
services: { type: Type.ARRAY, items: { type: Type.STRING } },
aiEnabled: { type: Type.BOOLEAN },
aiTypes: { type: Type.ARRAY, items: { type: Type.STRING } },
industry: { type: Type.STRING },
timeline: { type: Type.STRING },
currentSiteUrl: { type: Type.STRING },
currentSiteThoughts: { type: Type.STRING },
scope: { type: Type.STRING },
},
required: ['name', 'email', 'services'],
},
},
];
// ─── System Prompt ────────────────────────────────────────────────────────────
export function buildSystemPrompt(locale: string): string {
const isFr = locale === 'fr';
if (isFr) {
return `Tu es l'assistant de projets LetsBe, un consultant amical et compétent pour LetsBe Solutions. Tu mènes toute cette conversation en français.
Présente-toi ainsi : "Bonjour, je suis l'assistant de projets LetsBe. Parlez-moi de votre projet et je préparerai un brief personnalisé pour vous."
Ton rôle est de guider naturellement la conversation à travers les sujets suivants :
1. Quels services ils recherchent (web, logiciels sur mesure, infrastructure privée)
2. S'ils souhaitent une intégration IA — et si oui, quel type (assistant interne, IA pour les clients, intelligence de données, ou pas encore sûr)
3. Leur secteur d'activité
4. Leur calendrier préféré
5. S'ils ont un site web actuel (propose de l'analyser si c'est le cas)
6. Leurs objectifs et la portée du projet
7. Enfin, leur prénom, nom et adresse e-mail pour envoyer le brief
Instructions :
- Appelle update_selections chaque fois qu'un point est confirmé dans la conversation.
- Appelle analyze_website dès que l'utilisateur fournit une URL — puis intègre naturellement les résultats dans la discussion.
- Appelle complete_brief une fois que le nom et l'e-mail sont confirmés.
- Garde tes réponses concises : 2 à 3 phrases maximum par tour.
- Sois chaleureux, direct et professionnel — jamais générique.
Faits clés sur LetsBe à mentionner si pertinent :
- Tout est développé sur mesure — aucun template, aucun constructeur de pages
- Infrastructure privée : le client possède et contrôle entièrement ses données et serveurs
- Petite équipe expérimentée avec des décennies d'expérience combinée
- Intégration IA profonde dans tous types de systèmes
- Souveraineté numérique et protection des données comme priorité`;
}
return `You are the LetsBe project assistant, a friendly and knowledgeable project consultant for LetsBe Solutions.
Introduce yourself: "Hi, I'm the LetsBe project assistant. Tell me about your project and I'll put together a personalized brief for you."
Your role is to walk through the following topics naturally in conversation:
1. What services they need (web, custom software, private infrastructure)
2. Whether they want AI integration — and if so, what kind (internal teammate, customer-facing, data intelligence, or not sure yet)
3. Their industry
4. Their timeline preference
5. Whether they have a current website (offer to analyze it if they do)
6. Their goals and project scope
7. Finally, their name and email to send the brief
Instructions:
- Call update_selections each time a data point is confirmed during the conversation.
- Call analyze_website as soon as the user provides a URL — then discuss the findings naturally.
- Call complete_brief once name and email are confirmed.
- Keep responses concise: 23 sentences maximum per turn.
- Be warm, direct, and professional — never generic.
Key facts about LetsBe to reference when relevant:
- Everything is custom-built from scratch — no templates, no page builders
- Private infrastructure: the client fully owns and controls their data and servers
- Small, experienced team with decades of combined expertise in design and engineering
- Deep AI integration into any type of system they build
- Data sovereignty and digital privacy as a core focus`;
}
// ─── Live Config ──────────────────────────────────────────────────────────────
export function buildLiveConfig(locale: string) {
return {
responseModalities: ['AUDIO'],
systemInstruction: buildSystemPrompt(locale),
tools: [{ functionDeclarations: AGENT_TOOLS }],
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: { voiceName: 'Aoede' },
},
},
};
}
// ─── Ephemeral Token ──────────────────────────────────────────────────────────
export async function generateEphemeralToken(locale: string) {
// GoogleGenAI is instantiated here to validate the API key at request time.
// The SDK does not yet expose an ephemeral token API; in production, replace
// this with ai.auth.tokens.create() or equivalent when available to avoid
// exposing the API key to the client.
new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });
const config = buildLiveConfig(locale);
return { config, model: GEMINI_LIVE_MODEL };
}

316
src/lib/site-analysis.ts Normal file
View File

@@ -0,0 +1,316 @@
import * as cheerio from 'cheerio'
// ─── Types ───────────────────────────────────────────────────────────────────
export interface TechStack {
cms: string | null
framework: string | null
ecommerce: string | null
analytics: string[]
hosting: string | null
}
export interface PerformanceMetrics {
score: number
fcp: number
lcp: number
cls: number
tbt: number
speedIndex: number
}
export interface SiteAnalysis {
url: string
fetchedAt: string
title: string | null
description: string | null
themeColor: string | null
primaryColors: string[]
headingStructure: { h1: string[]; h2: string[] }
navLinks: string[]
hasForms: boolean
techStack: TechStack | null
performance: PerformanceMetrics | null
fetchError: string | null
}
// ─── Internal result types ────────────────────────────────────────────────────
interface ParsedHtml {
title: string | null
description: string | null
themeColor: string | null
primaryColors: string[]
headingStructure: { h1: string[]; h2: string[] }
navLinks: string[]
hasForms: boolean
}
// ─── HTML parser ─────────────────────────────────────────────────────────────
function parseHtml(html: string): ParsedHtml {
const $ = cheerio.load(html)
const title = $('title').first().text().trim() || null
const description =
$('meta[name="description"]').attr('content')?.trim() ?? null
const themeColor =
$('meta[name="theme-color"]').attr('content')?.trim() ?? null
const colorPattern = /(#[0-9a-fA-F]{3,8})|rgb[a]?\(\s*\d[\d\s,./%]*\)/g
const colorSet = new Set<string>()
$('style').each((_, el) => {
const text = $(el).text()
const matches = text.match(colorPattern)
if (matches) matches.forEach(c => colorSet.add(c))
})
$('[style]').each((_, el) => {
const style = $(el).attr('style') ?? ''
const matches = style.match(colorPattern)
if (matches) matches.forEach(c => colorSet.add(c))
})
const primaryColors = [...colorSet].slice(0, 8)
const h1: string[] = []
$('h1').each((_, el) => {
if (h1.length < 3) h1.push($(el).text().trim())
})
const h2: string[] = []
$('h2').each((_, el) => {
if (h2.length < 3) h2.push($(el).text().trim())
})
const navLinks: string[] = []
$('nav').first().find('a').each((_, el) => {
const text = $(el).text().trim()
if (text && navLinks.length < 10) navLinks.push(text)
})
const hasForms = $('form').length > 0
return { title, description, themeColor, primaryColors, headingStructure: { h1, h2 }, navLinks, hasForms }
}
// ─── Tech stack detector ──────────────────────────────────────────────────────
function detectStack(html: string, headers: Record<string, string>): TechStack {
const h = html.toLowerCase()
const headerLower: Record<string, string> = {}
for (const [k, v] of Object.entries(headers)) {
headerLower[k.toLowerCase()] = v.toLowerCase()
}
// CMS
let cms: string | null = null
if (
h.includes('wp-content/') ||
(headerLower['x-powered-by']?.includes('php') && h.includes('wp-json'))
) {
cms = 'WordPress'
} else if (h.includes('cdn.shopify.com') || h.includes('shopify.theme')) {
cms = 'Shopify'
} else if (
h.includes('wixsite.com') ||
Object.keys(headerLower).some(k => k.includes('x-wix'))
) {
cms = 'Wix'
} else if (h.includes('static1.squarespace.com') || h.includes('squarespace-cdn')) {
cms = 'Squarespace'
} else if (h.includes('webflow.io') || h.includes('data-wf-site')) {
cms = 'Webflow'
} else if (h.includes('/media/jui/') || h.includes('joomla')) {
cms = 'Joomla'
} else if (
h.includes('/sites/default/files/') ||
headerLower['x-generator']?.includes('drupal')
) {
cms = 'Drupal'
} else if (h.includes('ghost.io') || h.includes('content="ghost')) {
cms = 'Ghost'
}
// Framework
let framework: string | null = null
if (h.includes('__next_data__') || h.includes('_next/static')) {
framework = 'Next.js'
} else if (h.includes('__nuxt__') || h.includes('_nuxt/')) {
framework = 'Nuxt'
} else if (!framework && (h.includes('data-reactroot') || h.includes('react-root'))) {
framework = 'React'
} else if (!framework && h.includes('data-v-')) {
framework = 'Vue'
} else if (h.includes('ng-version')) {
framework = 'Angular'
}
// Ecommerce
let ecommerce: string | null = null
if (h.includes('woocommerce')) {
ecommerce = 'WooCommerce'
} else if (h.includes('prestashop')) {
ecommerce = 'PrestaShop'
} else if (h.includes('mage.cookies') || h.includes('skin/frontend')) {
ecommerce = 'Magento'
}
// Analytics (collect all)
const analytics: string[] = []
if (h.includes('gtag') && /\/g-[a-z0-9]+\//i.test(html)) {
analytics.push('Google Analytics 4')
}
if (h.includes('googletagmanager.com')) {
analytics.push('Google Tag Manager')
}
if (h.includes('hotjar.com')) {
analytics.push('Hotjar')
}
if (h.includes('matomo.js') || h.includes('piwik.js')) {
analytics.push('Matomo')
}
if (h.includes('fbq(')) {
analytics.push('Facebook Pixel')
}
// Hosting
let hosting: string | null = null
if ('cf-ray' in headerLower) {
hosting = 'Cloudflare'
} else if (Object.keys(headerLower).some(k => k.startsWith('x-vercel'))) {
hosting = 'Vercel'
} else if ('x-nf-request-id' in headerLower) {
hosting = 'Netlify'
} else if (
'wpe-backend' in headerLower ||
headerLower['server']?.includes('wpe')
) {
hosting = 'WP Engine'
}
return { cms, framework, ecommerce, analytics, hosting }
}
// ─── PageSpeed fetcher ────────────────────────────────────────────────────────
async function fetchPageSpeed(url: string): Promise<PerformanceMetrics | null> {
try {
const apiUrl = `https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent(url)}&strategy=mobile`
const res = await fetch(apiUrl)
const json = await res.json() as Record<string, unknown>
const lr = json['lighthouseResult'] as Record<string, unknown>
const categories = lr['categories'] as Record<string, Record<string, unknown>>
const audits = lr['audits'] as Record<string, Record<string, unknown>>
const score = Math.round((categories['performance']['score'] as number) * 100)
const fcp = audits['first-contentful-paint']['numericValue'] as number
const lcp = audits['largest-contentful-paint']['numericValue'] as number
const cls = audits['cumulative-layout-shift']['numericValue'] as number
const tbt = audits['total-blocking-time']['numericValue'] as number
const speedIndex = audits['speed-index']['numericValue'] as number
return { score, fcp, lcp, cls, tbt, speedIndex }
} catch {
return null
}
}
// ─── URL validation ───────────────────────────────────────────────────────────
function normalizeUrl(input: string): string {
const trimmed = input.trim()
if (!/^https?:\/\//i.test(trimmed)) {
return `https://${trimmed}`
}
return trimmed
}
function isHttpUrl(input: string): boolean {
return /^https?:\/\//i.test(input)
}
// ─── Main export ──────────────────────────────────────────────────────────────
export async function analyzeSite(url: string): Promise<SiteAnalysis> {
const normalizedUrl = normalizeUrl(url)
const fetchedAt = new Date().toISOString()
const base: SiteAnalysis = {
url: normalizedUrl,
fetchedAt,
title: null,
description: null,
themeColor: null,
primaryColors: [],
headingStructure: { h1: [], h2: [] },
navLinks: [],
hasForms: false,
techStack: null,
performance: null,
fetchError: null,
}
if (!isHttpUrl(normalizedUrl)) {
return { ...base, fetchError: 'Invalid URL: only http and https schemes are supported.' }
}
let html: string
let headers: Record<string, string>
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
const response = await fetch(normalizedUrl, {
signal: controller.signal,
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; SiteAnalyzer/1.0)' },
})
clearTimeout(timeout)
html = await response.text()
headers = {}
response.headers.forEach((value, key) => {
headers[key.toLowerCase()] = value
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return { ...base, fetchError: message }
}
const [htmlResult, stackResult, perfResult] = await Promise.allSettled([
Promise.resolve(parseHtml(html)),
Promise.resolve(detectStack(html, headers)),
fetchPageSpeed(normalizedUrl),
])
const parsed = htmlResult.status === 'fulfilled' ? htmlResult.value : null
const stack = stackResult.status === 'fulfilled' ? stackResult.value : null
const perf = perfResult.status === 'fulfilled' ? perfResult.value : null
return {
url: normalizedUrl,
fetchedAt,
title: parsed?.title ?? null,
description: parsed?.description ?? null,
themeColor: parsed?.themeColor ?? null,
primaryColors: parsed?.primaryColors ?? [],
headingStructure: parsed?.headingStructure ?? { h1: [], h2: [] },
navLinks: parsed?.navLinks ?? [],
hasForms: parsed?.hasForms ?? false,
techStack: stack,
performance: perf,
fetchError: null,
}
}