From acefb70b6801b811b68bc69c7ac4ce1b9891d7fb Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 26 Mar 2026 17:52:09 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20configurator=20overhaul=20=E2=80=94=20f?= =?UTF-8?q?ull=20i18n,=20AI=20brief=20generation,=20redesigned=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Configurator: - Full i18n: industries, timelines, AI types, field labels, error messages, complete screen — all translated to French - Real AI brief generation via OpenRouter (DeepSeek V3.2) with fallback template - Fixed email notification bug (was sending to client instead of admin) - Added wizard reset capability ("Start Over" button on success screen) - Redesigned section shell with refined card, accent line, step indicators - All step components use translation keys instead of hardcoded strings Hero: - Tighter spacing to keep CTAs above the fold - Reduced bottom gradient height Footer: - Removed GitHub link - Legal name in copyright - "American-founded" location text Co-Authored-By: Claude Opus 4.6 (1M context) --- docker-compose.yml | 2 + package-lock.json | 19 -- src/app/(frontend)/api/configure/route.ts | 289 ++++++++++-------- src/components/configurator/StepComplete.tsx | 85 +++--- src/components/configurator/StepContact.tsx | 124 +++----- src/components/configurator/StepDetails.tsx | 65 ++-- src/components/configurator/StepServices.tsx | 128 +++----- .../configurator/WizardContainer.tsx | 20 +- src/components/sections/Configurator.tsx | 121 +++++--- src/components/sections/Hero.tsx | 8 +- src/i18n/messages/en.json | 65 +++- src/i18n/messages/fr.json | 65 +++- 12 files changed, 532 insertions(+), 459 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 02b2a78..c0b4940 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,8 @@ services: POSTGRES_DB: letsbe POSTGRES_USER: ${POSTGRES_USER:-letsbe} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} + ports: + - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data healthcheck: diff --git a/package-lock.json b/package-lock.json index 3585c76..f644690 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3537,14 +3537,6 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "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", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -6216,17 +6208,6 @@ } } }, - "node_modules/next-intl/node_modules/@swc/helpers": { - "version": "0.5.19", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz", - "integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.8.0" - } - }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", diff --git a/src/app/(frontend)/api/configure/route.ts b/src/app/(frontend)/api/configure/route.ts index 1b66c44..1b1cd76 100644 --- a/src/app/(frontend)/api/configure/route.ts +++ b/src/app/(frontend)/api/configure/route.ts @@ -15,134 +15,176 @@ interface ConfigureRequestBody { email: string; } -// ─── Helpers ───────────────────────────────────────────────────────────────── +// ─── Formatting helpers ────────────────────────────────────────────────────── -function formatServicesList(services: string[]): string { - if (services.length === 0) return 'digital services'; - if (services.length === 1) return services[0]; - if (services.length === 2) return `${services[0]} and ${services[1]}`; - const last = services[services.length - 1]; - const rest = services.slice(0, -1); - return `${rest.join(', ')}, and ${last}`; +const SERVICE_NAMES: Record = { + web: 'Web Design & Development', + systems: 'Custom Software', + infrastructure: 'Private Infrastructure', +}; + +const INDUSTRY_NAMES: Record = { + maritime: 'Maritime & Yachting', + hospitality: 'Hospitality', + technology: 'Technology', + realestate: 'Real Estate', + finance: 'Finance', + ngo: 'NGO & Nonprofit', + other: 'Other', +}; + +const TIMELINE_NAMES: Record = { + asap: 'As soon as possible', + '1-3months': '1–3 months', + '3-6months': '3–6 months', + exploring: 'Just exploring', +}; + +const AI_TYPE_NAMES: Record = { + teammate: 'Internal AI Teammate', + 'customer-facing': 'Customer-Facing AI', + 'data-intelligence': 'Data Intelligence', + notsure: 'AI Integration (approach TBD)', +}; + +function buildContext(body: ConfigureRequestBody): 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'; + const company = body.company.trim() || 'Not specified'; + const aiType = body.aiEnabled && body.aiType ? AI_TYPE_NAMES[body.aiType] ?? body.aiType : null; + + let context = `Client Name: ${body.name} +Company: ${company} +Services Requested: ${services} +Industry: ${industry} +Timeline: ${timeline}`; + + if (body.aiEnabled) { + context += `\nAI Integration: Yes — ${aiType ?? 'type to be determined'}`; + } + + if (body.scope.trim()) { + context += `\nClient's Goals: "${body.scope.trim()}"`; + } + + return context; } -function formatTimeline(timeline: string | null): string { - switch (timeline) { - case 'asap': - return 'as soon as possible'; - case '1-3months': - return 'within the next 1–3 months'; - case '3-6months': - return 'over a 3–6 month horizon'; - case 'exploring': - return 'at a pace that suits your strategic planning'; - default: - return 'within a timeline to be agreed upon'; +// ─── AI Brief Generation ───────────────────────────────────────────────────── + +async function generateBriefWithAI(body: ConfigureRequestBody): Promise { + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) { + return generateFallbackBrief(body); + } + + const context = buildContext(body); + 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 businesses on the Côte d'Azur and internationally. + +Key facts about LetsBe: +- Every project is designed and coded from scratch — no templates, no page builders +- They build custom software (CRMs, management platforms, association systems, etc.) +- They deploy private infrastructure on dedicated servers that the client fully owns and controls +- They can layer AI integration into any system they build +- 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.`; + + 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 + +Format the brief using **bold** for section headings and --- for separators. Keep it concise but substantive — around 400-600 words. + +Client details: +${context}`; + + try { + const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + 'HTTP-Referer': process.env.NEXT_PUBLIC_SITE_URL || 'https://letsbe.biz', + 'X-Title': 'LetsBe Project Configurator', + }, + body: JSON.stringify({ + model: 'deepseek/deepseek-v3.2', + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + max_tokens: 1500, + temperature: 0.7, + }), + }); + + if (!response.ok) { + console.error('OpenRouter API error:', response.status, response.statusText); + return generateFallbackBrief(body); + } + + const data = await response.json(); + const content = data.choices?.[0]?.message?.content; + + if (!content) { + return generateFallbackBrief(body); + } + + return content; + } catch (error) { + console.error('AI brief generation failed:', error); + return generateFallbackBrief(body); } } -function formatIndustry(industry: string | null): string { - switch (industry) { - case 'maritime': - return 'Maritime & Yachting'; - case 'hospitality': - return 'Hospitality'; - case 'technology': - return 'Technology'; - case 'realestate': - return 'Real Estate'; - case 'finance': - return 'Finance'; - case 'ngo': - return 'NGO & Nonprofit'; - case 'other': - return 'your sector'; - default: - return 'your industry'; - } -} +// ─── Fallback Brief (no API key or API failure) ────────────────────────────── -function formatAIType(aiType: string | null): string { - switch (aiType) { - case 'teammate': - return 'an internal AI teammate that augments your team\'s workflow'; - case 'customer-facing': - return 'a customer-facing AI layer to enhance client interactions'; - case 'data-intelligence': - return 'a data intelligence system that surfaces actionable insights from your data'; - case 'notsure': - return 'an AI integration strategy tailored to your specific use case (to be defined during discovery)'; - default: - return 'intelligent automation'; - } -} +function generateFallbackBrief(body: ConfigureRequestBody): string { + const { services, aiEnabled, aiType, industry, scope, timeline, name, company } = body; -function generateMockBrief(body: ConfigureRequestBody): string { - const { - services, - aiEnabled, - aiType, - industry, - scope, - timeline, - name, - company, - } = body; - - const servicesList = formatServicesList(services); - const industryLabel = formatIndustry(industry); - const timelineStr = formatTimeline(timeline); - const displayCompany = company.trim() || 'your organisation'; + const serviceNames = services.map((s) => SERVICE_NAMES[s] ?? s); + 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'; - const hasWeb = services.some((s) => - s.toLowerCase().includes('web') || s.toLowerCase().includes('design'), - ); - const hasSystems = services.some((s) => - s.toLowerCase().includes('system') || s.toLowerCase().includes('cog') || s.toLowerCase().includes('custom'), - ); - const hasInfra = services.some((s) => - s.toLowerCase().includes('infra') || s.toLowerCase().includes('server'), - ); + const timelineStr = timeline + ? TIMELINE_NAMES[timeline]?.toLowerCase() ?? 'a timeline to be agreed upon' + : 'a timeline to be agreed upon'; + + const hasWeb = services.includes('web'); + const hasSystems = services.includes('systems'); + const hasInfra = services.includes('infrastructure'); + + let sections = ''; - let webSection = ''; if (hasWeb) { - webSection = ` -**Design & Development** -We will design and develop a bespoke digital presence for ${displayCompany} — starting from a clean slate, not a template. Expect a modern, responsive interface built on a headless architecture with exceptional performance scores (Lighthouse 95+). The design will reflect your brand positioning within the ${industryLabel} sector, with full accessibility compliance and SEO-optimised markup from day one. -`; + 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`; } - - let systemsSection = ''; if (hasSystems) { - systemsSection = ` -**Custom Systems** -We will architect and build a purpose-made internal system tailored to ${displayCompany}'s operational workflows. This includes a custom data model, role-based access control, and integrations with your existing toolchain. No generic SaaS — every logic rule and every interface is written to match how your team actually works. -`; + 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`; } - - let infraSection = ''; if (hasInfra) { - infraSection = ` -**Digital Infrastructure** -We will provision a dedicated, private cloud environment for ${displayCompany} — giving your team full data sovereignty. This includes containerised deployments, automated backups, uptime monitoring, and a managed CI/CD pipeline. Your data remains yours, stored in compliant European infrastructure. -`; + 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`; } - - let aiSection = ''; if (aiEnabled && aiType) { - aiSection = ` -**AI Integration** -Beyond the core build, we will layer in ${formatAIType(aiType)}. This is not a bolted-on chatbot — it is a deeply integrated capability that evolves alongside your digital ecosystem. The AI layer will be scoped precisely during our discovery sessions to ensure maximum return on investment. -`; + const aiLabel = AI_TYPE_NAMES[aiType] ?? 'AI integration'; + sections += `\n**AI Integration**\nWe'll layer ${aiLabel.toLowerCase()} into your systems — deeply integrated, not bolted on. The exact approach will be scoped during discovery.\n`; } - - let scopeSection = ''; - if (scope && scope.trim().length > 0) { - scopeSection = ` -**Your Goals** -You've shared the following context: "${scope.trim()}" — we've taken note of this and will frame our initial discovery session around these priorities. -`; + if (scope?.trim()) { + sections += `\n**Your Goals**\nYou shared: "${scope.trim()}" — we'll frame our discovery sessions around these priorities.\n`; } return `**Project Brief for ${displayCompany}** @@ -153,27 +195,27 @@ Date: ${new Date().toLocaleDateString('en-GB', { year: 'numeric', month: 'long', **Overview** -Hi ${displayName}, based on your requirements for ${servicesList} within the ${industryLabel} sector, we have prepared this preliminary brief to guide our first conversation. +Hi ${displayName}, based on your interest in ${servicesList} for the ${industryLabel} sector, here's a preliminary brief to guide our first conversation. -LetsBe. will approach ${displayCompany}'s project as a complete digital ecosystem — not a collection of disconnected deliverables. Every component we build is designed to work in concert, giving you a unified platform that you own and control entirely. -${webSection}${systemsSection}${infraSection}${aiSection}${scopeSection} -**Recommended Approach** +We'll approach this as a unified project — every component working together, fully owned and controlled by you. +${sections} +**Our Approach** -We propose a phased engagement beginning with a structured Discovery sprint (2–3 sessions) to map your requirements, data flows, and technical constraints before any code is written. This protects your investment and ensures we build exactly what you need — nothing more, nothing less. +We start with a Discovery phase (2–3 sessions) to understand your requirements before writing any code. This ensures we build exactly what you need. **Timeline** -Based on your preference, we will plan to deliver this project ${timelineStr}. A detailed project roadmap with milestones will be shared following the Discovery phase. +Target delivery: ${timelineStr}. A detailed roadmap will follow the Discovery phase. **Next Steps** -1. Book a 30-minute introductory call with our team -2. We'll share a detailed scope document within 48 hours of that call -3. Discovery sprint begins — at no obligation +1. Book a 30-minute introductory call +2. We'll share a detailed scope document within 48 hours +3. Discovery begins — no obligation -We look forward to building something exceptional together. +Looking forward to building something great together. -— The LetsBe. Team`; +— The LetsBe Team`; } // ─── Route Handler ──────────────────────────────────────────────────────────── @@ -204,8 +246,8 @@ export async function POST(request: NextRequest) { ); } - // Generate the brief - const brief = generateMockBrief(body); + // Generate the brief (AI if available, fallback otherwise) + const brief = await generateBriefWithAI(body); // Send emails (non-blocking — don't fail the response if email fails) if (process.env.SMTP_HOST && process.env.SMTP_PASS) { @@ -217,7 +259,7 @@ export async function POST(request: NextRequest) { brief, }), sendLeadNotification({ - to: body.email, + to: process.env.ADMIN_EMAIL || 'hello@letsbe.biz', name: body.name, company: body.company, brief, @@ -225,7 +267,6 @@ export async function POST(request: NextRequest) { email: body.email, }), ]).catch(() => { - // Silently log — don't break the user flow console.error('Email sending failed'); }); } diff --git a/src/components/configurator/StepComplete.tsx b/src/components/configurator/StepComplete.tsx index 4b74df5..66e3097 100644 --- a/src/components/configurator/StepComplete.tsx +++ b/src/components/configurator/StepComplete.tsx @@ -2,28 +2,25 @@ import { useTranslations } from 'next-intl'; import { motion } from 'framer-motion'; -import { Calendar, Mail } from 'lucide-react'; +import { Calendar, Mail, RotateCcw } from 'lucide-react'; import AnimatedCheckmark from '@/components/icons/AnimatedCheckmark'; import Button from '@/components/ui/Button'; import CalButton from '@/components/ui/CalButton'; import type { WizardFormData } from './WizardContainer'; -// ─── Brief Renderer ─────────────────────────────────────────────────────────── +// ─── Brief Renderer ────────────────────────────────────────────────────────── function renderBrief(brief: string) { - // Split on double newlines for paragraph blocks const blocks = brief.split('\n\n').filter(Boolean); return blocks.map((block, blockIdx) => { const lines = block.split('\n').filter(Boolean); - // Detect a heading block (starts with **) const isSectionHeading = lines.length === 1 && lines[0].startsWith('**') && lines[0].endsWith('**'); if (isSectionHeading) { const text = lines[0].replace(/\*\*/g, ''); - // Top heading is larger if (blockIdx === 0) { return (

@@ -38,16 +35,13 @@ function renderBrief(brief: string) { ); } - // Separator line if (lines.length === 1 && lines[0] === '---') { return


; } - // Body paragraph — inline bold rendering return (
{lines.map((line, lineIdx) => { - // Render **bold** inline const parts = line.split(/(\*\*[^*]+\*\*)/g); return (

0 ? 'mt-1' : ''}> @@ -68,37 +62,15 @@ function renderBrief(brief: string) { }); } -// ─── Cal.com Embed / Booking ────────────────────────────────────────────────── - -function BookingSection() { - return ( -

-
- - - -
-

Book a Consultation

-

30 minutes to discuss your brief with our team

- - - Book a Call - -
- ); -} - -// ─── Main Component ─────────────────────────────────────────────────────────── +// ─── Main Component ────────────────────────────────────────────────────────── interface StepCompleteProps { formData: WizardFormData; brief: string; + onReset?: () => void; } -export default function StepComplete({ formData, brief }: StepCompleteProps) { +export default function StepComplete({ formData, brief, onReset }: StepCompleteProps) { const t = useTranslations('configurator'); const displayEmail = formData.email || 'your inbox'; @@ -106,9 +78,7 @@ export default function StepComplete({ formData, brief }: StepCompleteProps) { const containerVariants = { hidden: {}, visible: { - transition: { - staggerChildren: 0.12, - }, + transition: { staggerChildren: 0.12 }, }, }; @@ -151,7 +121,7 @@ export default function StepComplete({ formData, brief }: StepCompleteProps) { className="rounded-xl bg-surface-high border border-outline-variant/40 px-5 py-5 shadow-card" >

- Your project brief + {t('complete.briefPreview')}

{renderBrief(brief)} @@ -161,16 +131,32 @@ export default function StepComplete({ formData, brief }: StepCompleteProps) { {/* Booking */} -

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

- +
+
+ + + +
+

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

+

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

+ + + {t('complete.bookCall')} + +
- {/* Fallback contact */} - + {/* Fallback contact + reset */} +

- Or reach us directly at{' '} + {t('complete.reachDirectly')}{' '}

+ + {onReset && ( + + )}
); diff --git a/src/components/configurator/StepContact.tsx b/src/components/configurator/StepContact.tsx index 8057e74..4a6cfc7 100644 --- a/src/components/configurator/StepContact.tsx +++ b/src/components/configurator/StepContact.tsx @@ -7,51 +7,8 @@ import Button from '@/components/ui/Button'; import ProgressBar from './ProgressBar'; import type { StepProps } from './WizardContainer'; -// ─── Data helpers ───────────────────────────────────────────────────────────── - -const SERVICE_LABELS: Record = { - web: 'Web Design & Dev', - systems: 'Custom Systems', - infrastructure: 'Digital Infrastructure', -}; - -const AI_TYPE_LABELS: Record = { - teammate: 'AI Teammate', - 'customer-facing': 'Customer-Facing AI', - 'data-intelligence': 'Data Intelligence', - notsure: 'AI (TBD)', -}; - -const TIMELINE_LABELS: Record = { - asap: 'ASAP', - '1-3months': '1–3 months', - '3-6months': '3–6 months', - exploring: 'Just exploring', -}; - -const INDUSTRY_LABELS: Record = { - maritime: 'Maritime / Yachting', - hospitality: 'Hospitality', - technology: 'Technology', - realestate: 'Real Estate', - finance: 'Finance', - ngo: 'NGO / Nonprofit', - other: 'Other', -}; - // ─── Sub-components ─────────────────────────────────────────────────────────── -interface InputFieldProps { - id: string; - label: string; - type?: string; - value: string; - onChange: (value: string) => void; - placeholder?: string; - required?: boolean; - autoComplete?: string; -} - function InputField({ id, label, @@ -61,7 +18,16 @@ function InputField({ placeholder, required, autoComplete, -}: InputFieldProps) { +}: { + id: string; + label: string; + type?: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + required?: boolean; + autoComplete?: string; +}) { return (