feat: website analysis pipeline, voice agent, configurator improvements
All checks were successful
Build & Push / build-and-push (push) Successful in 6m2s
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:
@@ -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': '1–3 mois',
|
||||
'3-6months': '3–6 mois',
|
||||
exploring: 'en phase d\'exploration',
|
||||
};
|
||||
|
||||
const svcNames = isFr ? SERVICE_NAMES_FR : SERVICE_NAMES;
|
||||
const indNames = isFr ? INDUSTRY_NAMES_FR : INDUSTRY_NAMES;
|
||||
const tlNames = isFr ? TIMELINE_NAMES_FR : TIMELINE_NAMES;
|
||||
|
||||
const serviceNames = services.map((s) => svcNames[s] ?? s);
|
||||
const joiner = isFr ? ' et ' : ' and ';
|
||||
const servicesList = serviceNames.length <= 2
|
||||
? serviceNames.join(' and ')
|
||||
: `${serviceNames.slice(0, -1).join(', ')}, and ${serviceNames[serviceNames.length - 1]}`;
|
||||
const industryLabel = industry ? INDUSTRY_NAMES[industry] ?? industry : 'your industry';
|
||||
const displayCompany = company.trim() || 'your organization';
|
||||
const displayName = name.split(' ')[0] || 'there';
|
||||
? serviceNames.join(joiner)
|
||||
: `${serviceNames.slice(0, -1).join(', ')}${isFr ? ' et ' : ', and '}${serviceNames[serviceNames.length - 1]}`;
|
||||
const industryLabel = industry ? indNames[industry] ?? industry : (isFr ? 'votre secteur' : 'your industry');
|
||||
const displayCompany = company.trim() || (isFr ? 'votre organisation' : 'your organization');
|
||||
const displayName = name.split(' ')[0] || (isFr ? 'bonjour' : 'there');
|
||||
|
||||
const timelineStr = timeline
|
||||
? TIMELINE_NAMES[timeline]?.toLowerCase() ?? 'a timeline to be agreed upon'
|
||||
: 'a timeline to be agreed upon';
|
||||
? tlNames[timeline]?.toLowerCase() ?? (isFr ? 'un calendrier à convenir' : 'a timeline to be agreed upon')
|
||||
: (isFr ? 'un calendrier à convenir' : 'a timeline to be agreed upon');
|
||||
|
||||
const hasWeb = services.includes('web');
|
||||
const hasSystems = services.includes('systems');
|
||||
@@ -178,23 +269,76 @@ function generateFallbackBrief(body: ConfigureRequestBody): string {
|
||||
|
||||
let sections = '';
|
||||
|
||||
if (hasWeb) {
|
||||
sections += `\n**Web Design & Development**\nWe'll design and build a custom website for ${displayCompany} from scratch — no templates, no page builders. Modern, responsive, fast, and optimized for search engines from day one.\n`;
|
||||
if (isFr) {
|
||||
if (hasWeb) {
|
||||
sections += `\n**Design & Développement Web**\nNous concevrons et développerons un site web sur mesure pour ${displayCompany} — sans templates, sans constructeurs de pages. Moderne, responsive, rapide et optimisé pour le référencement dès le premier jour.\n`;
|
||||
}
|
||||
if (hasSystems) {
|
||||
sections += `\n**Logiciels Sur Mesure**\nNous développerons un système conçu pour correspondre exactement au fonctionnement de ${displayCompany} — modèle de données personnalisé, accès par rôles et intégrations avec vos outils existants.\n`;
|
||||
}
|
||||
if (hasInfra) {
|
||||
sections += `\n**Infrastructure Privée**\nNous mettrons en place un environnement serveur dédié pour ${displayCompany} avec email, stockage cloud et outils métier que vous possédez et contrôlez entièrement.\n`;
|
||||
}
|
||||
if (aiEnabled && aiTypes.length > 0) {
|
||||
const aiLabels = aiTypes.map((t) => AI_TYPE_NAMES[t] ?? t).join(', ');
|
||||
sections += `\n**Intégration IA**\nNous intégrerons ${aiLabels.toLowerCase()} dans vos systèmes — en profondeur, pas en surface. L'approche exacte sera définie lors de la phase de découverte.\n`;
|
||||
} else if (aiEnabled) {
|
||||
sections += `\n**Intégration IA**\nNous intégrerons l'IA dans vos systèmes — en profondeur, pas en surface. L'approche exacte sera définie lors de la phase de découverte.\n`;
|
||||
}
|
||||
if (scope?.trim()) {
|
||||
sections += `\n**Vos Objectifs**\nVous avez partagé : "${scope.trim()}" — nous orienterons nos sessions de découverte autour de ces priorités.\n`;
|
||||
}
|
||||
} else {
|
||||
if (hasWeb) {
|
||||
sections += `\n**Web Design & Development**\nWe'll design and build a custom website for ${displayCompany} from scratch — no templates, no page builders. Modern, responsive, fast, and optimized for search engines from day one.\n`;
|
||||
}
|
||||
if (hasSystems) {
|
||||
sections += `\n**Custom Software**\nWe'll build a purpose-made system tailored to how ${displayCompany} actually operates — custom data model, role-based access, and integrations with your existing tools.\n`;
|
||||
}
|
||||
if (hasInfra) {
|
||||
sections += `\n**Private Infrastructure**\nWe'll set up a dedicated server environment for ${displayCompany} with email, cloud storage, and business tools that you fully own and control.\n`;
|
||||
}
|
||||
if (aiEnabled && aiTypes.length > 0) {
|
||||
const aiLabels = aiTypes.map((t) => AI_TYPE_NAMES[t] ?? t).join(', ');
|
||||
sections += `\n**AI Integration**\nWe'll layer ${aiLabels.toLowerCase()} into your systems — deeply integrated, not bolted on. The exact approach will be scoped during discovery.\n`;
|
||||
} else if (aiEnabled) {
|
||||
sections += `\n**AI Integration**\nWe'll layer AI integration into your systems — deeply integrated, not bolted on. The exact approach will be scoped during discovery.\n`;
|
||||
}
|
||||
if (scope?.trim()) {
|
||||
sections += `\n**Your Goals**\nYou shared: "${scope.trim()}" — we'll frame our discovery sessions around these priorities.\n`;
|
||||
}
|
||||
}
|
||||
if (hasSystems) {
|
||||
sections += `\n**Custom Software**\nWe'll build a purpose-made system tailored to how ${displayCompany} actually operates — custom data model, role-based access, and integrations with your existing tools.\n`;
|
||||
}
|
||||
if (hasInfra) {
|
||||
sections += `\n**Private Infrastructure**\nWe'll set up a dedicated server environment for ${displayCompany} with email, cloud storage, and business tools that you fully own and control.\n`;
|
||||
}
|
||||
if (aiEnabled && aiTypes.length > 0) {
|
||||
const aiLabels = aiTypes.map((t) => AI_TYPE_NAMES[t] ?? t).join(', ');
|
||||
sections += `\n**AI Integration**\nWe'll layer ${aiLabels.toLowerCase()} into your systems — deeply integrated, not bolted on. The exact approach will be scoped during discovery.\n`;
|
||||
} else if (aiEnabled) {
|
||||
sections += `\n**AI Integration**\nWe'll layer AI integration into your systems — deeply integrated, not bolted on. The exact approach will be scoped during discovery.\n`;
|
||||
}
|
||||
if (scope?.trim()) {
|
||||
sections += `\n**Your Goals**\nYou shared: "${scope.trim()}" — we'll frame our discovery sessions around these priorities.\n`;
|
||||
|
||||
if (isFr) {
|
||||
return `**Brief Projet pour ${displayCompany}**
|
||||
Préparé pour : ${name}
|
||||
Date : ${new Date().toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
|
||||
---
|
||||
|
||||
**Aperçu**
|
||||
|
||||
Bonjour ${displayName}, suite à votre intérêt pour ${servicesList} dans le secteur ${industryLabel}, voici un brief préliminaire pour guider notre première conversation.
|
||||
|
||||
Nous aborderons ceci comme un projet unifié — chaque composant fonctionnant ensemble, entièrement détenu et contrôlé par vous.
|
||||
${sections}
|
||||
**Notre Approche**
|
||||
|
||||
Nous commençons par une phase de Découverte (2–3 sessions) pour comprendre vos besoins avant d'écrire la moindre ligne de code.
|
||||
|
||||
**Calendrier**
|
||||
|
||||
Livraison cible : ${timelineStr}. Une feuille de route détaillée suivra la phase de Découverte.
|
||||
|
||||
**Prochaines Étapes**
|
||||
|
||||
1. Réservez un appel de présentation de 30 minutes
|
||||
2. Nous vous enverrons un document de cadrage détaillé sous 48 heures
|
||||
3. La Découverte commence — sans engagement
|
||||
|
||||
Au plaisir de construire quelque chose de formidable ensemble.
|
||||
|
||||
— L'équipe LetsBe`;
|
||||
}
|
||||
|
||||
return `**Project Brief for ${displayCompany}**
|
||||
@@ -256,8 +400,20 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Analyze current website if URL provided
|
||||
let siteAnalysis: SiteAnalysis | null = null;
|
||||
if (body.currentSiteUrl?.trim()) {
|
||||
console.log(`[configure] Analyzing site: ${body.currentSiteUrl.trim()}...`);
|
||||
siteAnalysis = await analyzeSite(body.currentSiteUrl.trim());
|
||||
console.log(`[configure] Site analysis complete (fetchError: ${siteAnalysis.fetchError ?? 'none'})`);
|
||||
console.log(`[configure] Tech stack:`, JSON.stringify(siteAnalysis.techStack));
|
||||
console.log(`[configure] Performance:`, JSON.stringify(siteAnalysis.performance));
|
||||
console.log(`[configure] Colors:`, siteAnalysis.primaryColors);
|
||||
console.log(`[configure] Title:`, siteAnalysis.title);
|
||||
}
|
||||
|
||||
// Generate the brief (AI if available, fallback otherwise)
|
||||
const brief = await generateBriefWithAI(body);
|
||||
const brief = await generateBriefWithAI(body, siteAnalysis);
|
||||
|
||||
// Send emails (non-blocking — don't fail the response if email fails)
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
@@ -280,6 +436,8 @@ export async function POST(request: NextRequest) {
|
||||
brief,
|
||||
services: body.services,
|
||||
email: body.email,
|
||||
phone: body.phone || undefined,
|
||||
contactPreference: body.contactPreference || undefined,
|
||||
}),
|
||||
]).then((results) => {
|
||||
results.forEach((result, i) => {
|
||||
|
||||
Reference in New Issue
Block a user