Compare commits

...

3 Commits

Author SHA1 Message Date
bbe5b6c67e feat: multi-select AI types in configurator
Some checks failed
Build & Push / build-and-push (push) Failing after 34s
- Changed aiType (string|null) to aiTypes (string[]) across all components
- StepServices: chips now toggle on/off independently, descriptions show for all selected
- StepContact: summary tags show all selected AI types
- API route: handles array of AI types for both AI and fallback brief generation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 18:06:44 +01:00
acefb70b68 feat: configurator overhaul — full i18n, AI brief generation, redesigned UI
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) <noreply@anthropic.com>
2026-03-26 17:52:09 +01:00
4aa357a999 polish: nav scroll threshold, footer cleanup, hero gradient
- Nav: trigger solid background at 10px scroll (was 100px) to prevent logo/text overlap
- Footer: copyright uses legal name "LetsBe Solutions LLC"
- Footer: location changed to "American-founded. Serving the Côte d'Azur and beyond."
- Footer: remove GitHub social link (no public GitHub)
- Hero: smooth gradient fade into trust bar section (no hard color gap)
- Services: "SEO & Digital Marketing" replaces "SEO & Performance"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:40:15 +01:00
15 changed files with 563 additions and 484 deletions

View File

@@ -6,6 +6,8 @@ services:
POSTGRES_DB: letsbe POSTGRES_DB: letsbe
POSTGRES_USER: ${POSTGRES_USER:-letsbe} POSTGRES_USER: ${POSTGRES_USER:-letsbe}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
ports:
- "5432:5432"
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
healthcheck: healthcheck:

19
package-lock.json generated
View File

@@ -3537,14 +3537,6 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT" "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": { "node_modules/@types/unist": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "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": { "node_modules/next/node_modules/postcss": {
"version": "8.4.31", "version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",

View File

@@ -6,7 +6,7 @@ import { sendBriefToClient, sendLeadNotification } from '@/lib/email';
interface ConfigureRequestBody { interface ConfigureRequestBody {
services: string[]; services: string[];
aiEnabled: boolean; aiEnabled: boolean;
aiType: string | null; aiTypes: string[];
industry: string | null; industry: string | null;
scope: string; scope: string;
timeline: string | null; timeline: string | null;
@@ -15,134 +15,180 @@ interface ConfigureRequestBody {
email: string; email: string;
} }
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Formatting helpers ──────────────────────────────────────────────────────
function formatServicesList(services: string[]): string { const SERVICE_NAMES: Record<string, string> = {
if (services.length === 0) return 'digital services'; web: 'Web Design & Development',
if (services.length === 1) return services[0]; systems: 'Custom Software',
if (services.length === 2) return `${services[0]} and ${services[1]}`; infrastructure: 'Private Infrastructure',
const last = services[services.length - 1]; };
const rest = services.slice(0, -1);
return `${rest.join(', ')}, and ${last}`; const INDUSTRY_NAMES: Record<string, string> = {
maritime: 'Maritime & Yachting',
hospitality: 'Hospitality',
technology: 'Technology',
realestate: 'Real Estate',
finance: 'Finance',
ngo: 'NGO & Nonprofit',
other: 'Other',
};
const TIMELINE_NAMES: Record<string, string> = {
asap: 'As soon as possible',
'1-3months': '13 months',
'3-6months': '36 months',
exploring: 'Just exploring',
};
const AI_TYPE_NAMES: Record<string, string> = {
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 aiTypeNames = body.aiEnabled && body.aiTypes.length > 0
? body.aiTypes.map((t) => AI_TYPE_NAMES[t] ?? t).join(', ')
: null;
let context = `Client Name: ${body.name}
Company: ${company}
Services Requested: ${services}
Industry: ${industry}
Timeline: ${timeline}`;
if (body.aiEnabled) {
context += `\nAI Integration: Yes — ${aiTypeNames ?? 'type to be determined'}`;
}
if (body.scope.trim()) {
context += `\nClient's Goals: "${body.scope.trim()}"`;
}
return context;
} }
function formatTimeline(timeline: string | null): string { // ─── AI Brief Generation ─────────────────────────────────────────────────────
switch (timeline) {
case 'asap': async function generateBriefWithAI(body: ConfigureRequestBody): Promise<string> {
return 'as soon as possible'; const apiKey = process.env.OPENROUTER_API_KEY;
case '1-3months': if (!apiKey) {
return 'within the next 13 months'; return generateFallbackBrief(body);
case '3-6months': }
return 'over a 36 month horizon';
case 'exploring': const context = buildContext(body);
return 'at a pace that suits your strategic planning'; const displayName = body.name.split(' ')[0] || body.name;
default:
return 'within a timeline to be agreed upon'; 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 { // ─── Fallback Brief (no API key or API failure) ──────────────────────────────
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';
}
}
function formatAIType(aiType: string | null): string { function generateFallbackBrief(body: ConfigureRequestBody): string {
switch (aiType) { const { services, aiEnabled, aiTypes, industry, scope, timeline, name, company } = body;
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 generateMockBrief(body: ConfigureRequestBody): string { const serviceNames = services.map((s) => SERVICE_NAMES[s] ?? s);
const { const servicesList = serviceNames.length <= 2
services, ? serviceNames.join(' and ')
aiEnabled, : `${serviceNames.slice(0, -1).join(', ')}, and ${serviceNames[serviceNames.length - 1]}`;
aiType, const industryLabel = industry ? INDUSTRY_NAMES[industry] ?? industry : 'your industry';
industry, const displayCompany = company.trim() || 'your organization';
scope,
timeline,
name,
company,
} = body;
const servicesList = formatServicesList(services);
const industryLabel = formatIndustry(industry);
const timelineStr = formatTimeline(timeline);
const displayCompany = company.trim() || 'your organisation';
const displayName = name.split(' ')[0] || 'there'; const displayName = name.split(' ')[0] || 'there';
const hasWeb = services.some((s) => const timelineStr = timeline
s.toLowerCase().includes('web') || s.toLowerCase().includes('design'), ? TIMELINE_NAMES[timeline]?.toLowerCase() ?? 'a timeline to be agreed upon'
); : 'a timeline to be agreed upon';
const hasSystems = services.some((s) =>
s.toLowerCase().includes('system') || s.toLowerCase().includes('cog') || s.toLowerCase().includes('custom'), const hasWeb = services.includes('web');
); const hasSystems = services.includes('systems');
const hasInfra = services.some((s) => const hasInfra = services.includes('infrastructure');
s.toLowerCase().includes('infra') || s.toLowerCase().includes('server'),
); let sections = '';
let webSection = '';
if (hasWeb) { if (hasWeb) {
webSection = ` 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`;
**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.
`;
} }
let systemsSection = '';
if (hasSystems) { if (hasSystems) {
systemsSection = ` 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`;
**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.
`;
} }
let infraSection = '';
if (hasInfra) { if (hasInfra) {
infraSection = ` 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`;
**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.
`;
} }
if (aiEnabled && aiTypes.length > 0) {
let aiSection = ''; const aiLabels = aiTypes.map((t) => AI_TYPE_NAMES[t] ?? t).join(', ');
if (aiEnabled && aiType) { 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`;
aiSection = ` } else if (aiEnabled) {
**AI Integration** 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`;
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.
`;
} }
if (scope?.trim()) {
let scopeSection = ''; sections += `\n**Your Goals**\nYou shared: "${scope.trim()}" — we'll frame our discovery sessions around these priorities.\n`;
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.
`;
} }
return `**Project Brief for ${displayCompany}** return `**Project Brief for ${displayCompany}**
@@ -153,27 +199,27 @@ Date: ${new Date().toLocaleDateString('en-GB', { year: 'numeric', month: 'long',
**Overview** **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. We'll approach this as a unified project — every component working together, fully owned and controlled by you.
${webSection}${systemsSection}${infraSection}${aiSection}${scopeSection} ${sections}
**Recommended Approach** **Our Approach**
We propose a phased engagement beginning with a structured Discovery sprint (23 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 (23 sessions) to understand your requirements before writing any code. This ensures we build exactly what you need.
**Timeline** **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** **Next Steps**
1. Book a 30-minute introductory call with our team 1. Book a 30-minute introductory call
2. We'll share a detailed scope document within 48 hours of that call 2. We'll share a detailed scope document within 48 hours
3. Discovery sprint begins — at no obligation 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 ──────────────────────────────────────────────────────────── // ─── Route Handler ────────────────────────────────────────────────────────────
@@ -204,8 +250,8 @@ export async function POST(request: NextRequest) {
); );
} }
// Generate the brief // Generate the brief (AI if available, fallback otherwise)
const brief = generateMockBrief(body); const brief = await generateBriefWithAI(body);
// Send emails (non-blocking — don't fail the response if email fails) // Send emails (non-blocking — don't fail the response if email fails)
if (process.env.SMTP_HOST && process.env.SMTP_PASS) { if (process.env.SMTP_HOST && process.env.SMTP_PASS) {
@@ -217,7 +263,7 @@ export async function POST(request: NextRequest) {
brief, brief,
}), }),
sendLeadNotification({ sendLeadNotification({
to: body.email, to: process.env.ADMIN_EMAIL || 'hello@letsbe.biz',
name: body.name, name: body.name,
company: body.company, company: body.company,
brief, brief,
@@ -225,7 +271,6 @@ export async function POST(request: NextRequest) {
email: body.email, email: body.email,
}), }),
]).catch(() => { ]).catch(() => {
// Silently log — don't break the user flow
console.error('Email sending failed'); console.error('Email sending failed');
}); });
} }

View File

@@ -2,28 +2,25 @@
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { motion } from 'framer-motion'; 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 AnimatedCheckmark from '@/components/icons/AnimatedCheckmark';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import CalButton from '@/components/ui/CalButton'; import CalButton from '@/components/ui/CalButton';
import type { WizardFormData } from './WizardContainer'; import type { WizardFormData } from './WizardContainer';
// ─── Brief Renderer ────────────────────────────────────────────────────────── // ─── Brief Renderer ──────────────────────────────────────────────────────────
function renderBrief(brief: string) { function renderBrief(brief: string) {
// Split on double newlines for paragraph blocks
const blocks = brief.split('\n\n').filter(Boolean); const blocks = brief.split('\n\n').filter(Boolean);
return blocks.map((block, blockIdx) => { return blocks.map((block, blockIdx) => {
const lines = block.split('\n').filter(Boolean); const lines = block.split('\n').filter(Boolean);
// Detect a heading block (starts with **)
const isSectionHeading = const isSectionHeading =
lines.length === 1 && lines[0].startsWith('**') && lines[0].endsWith('**'); lines.length === 1 && lines[0].startsWith('**') && lines[0].endsWith('**');
if (isSectionHeading) { if (isSectionHeading) {
const text = lines[0].replace(/\*\*/g, ''); const text = lines[0].replace(/\*\*/g, '');
// Top heading is larger
if (blockIdx === 0) { if (blockIdx === 0) {
return ( return (
<p key={blockIdx} className="font-semibold text-sm text-on-surface mb-0.5"> <p key={blockIdx} className="font-semibold text-sm text-on-surface mb-0.5">
@@ -38,16 +35,13 @@ function renderBrief(brief: string) {
); );
} }
// Separator line
if (lines.length === 1 && lines[0] === '---') { if (lines.length === 1 && lines[0] === '---') {
return <hr key={blockIdx} className="border-outline-variant/30 my-3" />; return <hr key={blockIdx} className="border-outline-variant/30 my-3" />;
} }
// Body paragraph — inline bold rendering
return ( return (
<div key={blockIdx} className="text-xs text-outline leading-relaxed"> <div key={blockIdx} className="text-xs text-outline leading-relaxed">
{lines.map((line, lineIdx) => { {lines.map((line, lineIdx) => {
// Render **bold** inline
const parts = line.split(/(\*\*[^*]+\*\*)/g); const parts = line.split(/(\*\*[^*]+\*\*)/g);
return ( return (
<p key={lineIdx} className={lineIdx > 0 ? 'mt-1' : ''}> <p key={lineIdx} className={lineIdx > 0 ? 'mt-1' : ''}>
@@ -68,37 +62,15 @@ function renderBrief(brief: string) {
}); });
} }
// ─── Cal.com Embed / Booking ────────────────────────────────────────────────── // ─── Main Component ──────────────────────────────────────────────────────────
function BookingSection() {
return (
<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>
<p className="text-sm font-semibold text-on-surface mb-1">Book a Consultation</p>
<p className="text-xs text-outline mb-4">30 minutes to discuss your brief with our team</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"
style={{ background: 'linear-gradient(135deg, #006494, #5BA4D9)' }}
>
<Calendar size={16} />
Book a Call
</CalButton>
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
interface StepCompleteProps { interface StepCompleteProps {
formData: WizardFormData; formData: WizardFormData;
brief: string; brief: string;
onReset?: () => void;
} }
export default function StepComplete({ formData, brief }: StepCompleteProps) { export default function StepComplete({ formData, brief, onReset }: StepCompleteProps) {
const t = useTranslations('configurator'); const t = useTranslations('configurator');
const displayEmail = formData.email || 'your inbox'; const displayEmail = formData.email || 'your inbox';
@@ -106,9 +78,7 @@ export default function StepComplete({ formData, brief }: StepCompleteProps) {
const containerVariants = { const containerVariants = {
hidden: {}, hidden: {},
visible: { visible: {
transition: { transition: { staggerChildren: 0.12 },
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" className="rounded-xl bg-surface-high border border-outline-variant/40 px-5 py-5 shadow-card"
> >
<p className="text-xs font-semibold uppercase tracking-label text-outline mb-3"> <p className="text-xs font-semibold uppercase tracking-label text-outline mb-3">
Your project brief {t('complete.briefPreview')}
</p> </p>
<div className="space-y-1 max-h-72 overflow-y-auto pr-1 scrollbar-thin"> <div className="space-y-1 max-h-72 overflow-y-auto pr-1 scrollbar-thin">
{renderBrief(brief)} {renderBrief(brief)}
@@ -161,16 +131,32 @@ export default function StepComplete({ formData, brief }: StepCompleteProps) {
{/* Booking */} {/* Booking */}
<motion.div variants={itemVariants}> <motion.div variants={itemVariants}>
<p className="text-xs font-semibold uppercase tracking-label text-outline mb-3"> <div className="rounded-xl bg-surface-low px-5 py-5 text-center">
{t('complete.bookTitle')} <div className="flex justify-center mb-3">
</p> <span className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
<BookingSection /> <Calendar size={18} strokeWidth={1.5} className="text-primary-dark" />
</span>
</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"
style={{ background: 'linear-gradient(135deg, #006494, #5BA4D9)' }}
>
<Calendar size={16} />
{t('complete.bookCall')}
</CalButton>
</div>
</motion.div> </motion.div>
{/* Fallback contact */} {/* Fallback contact + reset */}
<motion.div variants={itemVariants}> <motion.div variants={itemVariants} className="flex flex-col gap-3">
<p className="text-center text-xs text-outline"> <p className="text-center text-xs text-outline">
Or reach us directly at{' '} {t('complete.reachDirectly')}{' '}
<a <a
href="mailto:hello@letsbe.biz" href="mailto:hello@letsbe.biz"
className="text-primary-dark underline underline-offset-2 hover:text-primary transition-colors" className="text-primary-dark underline underline-offset-2 hover:text-primary transition-colors"
@@ -178,6 +164,17 @@ export default function StepComplete({ formData, brief }: StepCompleteProps) {
hello@letsbe.biz hello@letsbe.biz
</a> </a>
</p> </p>
{onReset && (
<button
type="button"
onClick={onReset}
className="flex items-center justify-center gap-1.5 text-xs text-outline hover:text-on-surface transition-colors mx-auto"
>
<RotateCcw size={12} />
{t('startOver')}
</button>
)}
</motion.div> </motion.div>
</motion.div> </motion.div>
); );

View File

@@ -7,51 +7,8 @@ import Button from '@/components/ui/Button';
import ProgressBar from './ProgressBar'; import ProgressBar from './ProgressBar';
import type { StepProps } from './WizardContainer'; import type { StepProps } from './WizardContainer';
// ─── Data helpers ─────────────────────────────────────────────────────────────
const SERVICE_LABELS: Record<string, string> = {
web: 'Web Design & Dev',
systems: 'Custom Systems',
infrastructure: 'Digital Infrastructure',
};
const AI_TYPE_LABELS: Record<string, string> = {
teammate: 'AI Teammate',
'customer-facing': 'Customer-Facing AI',
'data-intelligence': 'Data Intelligence',
notsure: 'AI (TBD)',
};
const TIMELINE_LABELS: Record<string, string> = {
asap: 'ASAP',
'1-3months': '13 months',
'3-6months': '36 months',
exploring: 'Just exploring',
};
const INDUSTRY_LABELS: Record<string, string> = {
maritime: 'Maritime / Yachting',
hospitality: 'Hospitality',
technology: 'Technology',
realestate: 'Real Estate',
finance: 'Finance',
ngo: 'NGO / Nonprofit',
other: 'Other',
};
// ─── Sub-components ─────────────────────────────────────────────────────────── // ─── Sub-components ───────────────────────────────────────────────────────────
interface InputFieldProps {
id: string;
label: string;
type?: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
required?: boolean;
autoComplete?: string;
}
function InputField({ function InputField({
id, id,
label, label,
@@ -61,7 +18,16 @@ function InputField({
placeholder, placeholder,
required, required,
autoComplete, autoComplete,
}: InputFieldProps) { }: {
id: string;
label: string;
type?: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
required?: boolean;
autoComplete?: string;
}) {
return ( return (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label <label
@@ -90,12 +56,7 @@ function InputField({
); );
} }
interface SummaryTagProps { function SummaryTag({ label, variant = 'neutral' }: { label: string; variant?: 'primary' | 'neutral' }) {
label: string;
variant?: 'primary' | 'neutral';
}
function SummaryTag({ label, variant = 'neutral' }: SummaryTagProps) {
return ( return (
<span <span
className={cn( className={cn(
@@ -110,8 +71,6 @@ function SummaryTag({ label, variant = 'neutral' }: SummaryTagProps) {
); );
} }
// ─── Loading Dots ─────────────────────────────────────────────────────────────
function LoadingDots() { function LoadingDots() {
return ( return (
<span className="inline-flex items-center gap-1" aria-label="Generating brief"> <span className="inline-flex items-center gap-1" aria-label="Generating brief">
@@ -132,14 +91,14 @@ function LoadingDots() {
); );
} }
// ─── Extended StepContact Props ─────────────────────────────────────────────── // ─── Extended Props ───────────────────────────────────────────────────────────
interface StepContactProps extends StepProps { interface StepContactProps extends StepProps {
isSubmitting: boolean; isSubmitting: boolean;
submitError: string | null; submitError: string | null;
} }
// ─── Main Component ────────────────────────────────────────────────────────── // ─── Main Component ──────────────────────────────────────────────────────────
export default function StepContact({ export default function StepContact({
formData, formData,
@@ -157,30 +116,40 @@ export default function StepContact({
isEmailValid && isEmailValid &&
!isSubmitting; !isSubmitting;
// Build summary tags // Build summary tags using translation keys
const serviceTags = formData.services.map((id) => SERVICE_LABELS[id] ?? id); const serviceTags = formData.services.map((id) => ({
const aiTag = label: t(`services.${id}.title`),
formData.aiEnabled && formData.aiType variant: 'primary' as const,
? AI_TYPE_LABELS[formData.aiType] ?? formData.aiType }));
: formData.aiEnabled
? 'AI Enhancement' const aiTags = formData.aiEnabled
: null; ? formData.aiTypes.length > 0
const industryTag = formData.industry ? INDUSTRY_LABELS[formData.industry] ?? formData.industry : null; ? formData.aiTypes.map((id) => ({
const timelineTag = formData.timeline ? TIMELINE_LABELS[formData.timeline] ?? formData.timeline : null; label: t(`aiTypes.${id}.title`),
variant: 'primary' as const,
}))
: [{ label: t('summary.aiEnhancement'), variant: 'primary' as const }]
: [];
const industryTag = formData.industry
? { label: t(`industries.${formData.industry}`), variant: 'neutral' as const }
: null;
const timelineTag = formData.timeline
? { label: t(`timelines.${formData.timeline}`), variant: 'neutral' as const }
: null;
const allTags = [ const allTags = [
...serviceTags.map((s) => ({ label: s, variant: 'primary' as const })), ...serviceTags,
...(aiTag ? [{ label: aiTag, variant: 'primary' as const }] : []), ...aiTags,
...(industryTag ? [{ label: industryTag, variant: 'neutral' as const }] : []), ...(industryTag ? [industryTag] : []),
...(timelineTag ? [{ label: timelineTag, variant: 'neutral' as const }] : []), ...(timelineTag ? [timelineTag] : []),
]; ];
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Progress */}
<ProgressBar currentStep={3} /> <ProgressBar currentStep={3} />
{/* Heading */}
<div> <div>
<h3 className="font-serif text-2xl font-semibold tracking-headline text-on-surface"> <h3 className="font-serif text-2xl font-semibold tracking-headline text-on-surface">
{t('step3.title')} {t('step3.title')}
@@ -192,7 +161,7 @@ export default function StepContact({
{allTags.length > 0 && ( {allTags.length > 0 && (
<div className="rounded-xl border border-outline-variant/40 bg-surface-low px-4 py-3"> <div className="rounded-xl border border-outline-variant/40 bg-surface-low px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-label text-outline mb-2.5"> <p className="text-xs font-semibold uppercase tracking-label text-outline mb-2.5">
Your selections {t('summary.heading')}
</p> </p>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{allTags.map((tag, i) => ( {allTags.map((tag, i) => (
@@ -213,28 +182,25 @@ export default function StepContact({
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<InputField <InputField
id="contact-name" id="contact-name"
label="Your name" label={t('fields.name')}
value={formData.name} value={formData.name}
onChange={(v) => setFormData((prev) => ({ ...prev, name: v }))} onChange={(v) => setFormData((prev) => ({ ...prev, name: v }))}
placeholder="Sophie Laurent"
required required
autoComplete="name" autoComplete="name"
/> />
<InputField <InputField
id="contact-company" id="contact-company"
label="Company" label={t('fields.company')}
value={formData.company} value={formData.company}
onChange={(v) => setFormData((prev) => ({ ...prev, company: v }))} onChange={(v) => setFormData((prev) => ({ ...prev, company: v }))}
placeholder="Maison Laurent Group"
autoComplete="organization" autoComplete="organization"
/> />
<InputField <InputField
id="contact-email" id="contact-email"
label="Email address" label={t('fields.email')}
type="email" type="email"
value={formData.email} value={formData.email}
onChange={(v) => setFormData((prev) => ({ ...prev, email: v }))} onChange={(v) => setFormData((prev) => ({ ...prev, email: v }))}
placeholder="sophie@example.com"
required required
autoComplete="email" autoComplete="email"
/> />
@@ -273,7 +239,7 @@ export default function StepContact({
> >
{isSubmitting ? ( {isSubmitting ? (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
Generating {t('generating')}
<LoadingDots /> <LoadingDots />
</span> </span>
) : ( ) : (
@@ -283,7 +249,7 @@ export default function StepContact({
</div> </div>
<p className="text-center text-xs text-outline"> <p className="text-center text-xs text-outline">
Your information is private and will never be shared. {t('privacy')}
</p> </p>
</div> </div>
); );

View File

@@ -10,32 +10,8 @@ import type { StepProps } from './WizardContainer';
// ─── Data ───────────────────────────────────────────────────────────────────── // ─── Data ─────────────────────────────────────────────────────────────────────
interface IndustryOption { const INDUSTRY_IDS = ['maritime', 'hospitality', 'technology', 'realestate', 'finance', 'ngo', 'other'] as const;
id: string; const TIMELINE_IDS = ['asap', '1-3months', '3-6months', 'exploring'] as const;
label: string;
}
const INDUSTRIES: IndustryOption[] = [
{ id: 'maritime', label: 'Maritime / Yachting' },
{ id: 'hospitality', label: 'Hospitality' },
{ id: 'technology', label: 'Technology' },
{ id: 'realestate', label: 'Real Estate' },
{ id: 'finance', label: 'Finance' },
{ id: 'ngo', label: 'NGO / Nonprofit' },
{ id: 'other', label: 'Other' },
];
interface TimelineOption {
id: string;
label: string;
}
const TIMELINES: TimelineOption[] = [
{ id: 'asap', label: 'ASAP' },
{ id: '1-3months', label: '13 months' },
{ id: '3-6months', label: '36 months' },
{ id: 'exploring', label: 'Just exploring' },
];
// ─── Component ──────────────────────────────────────────────────────────────── // ─── Component ────────────────────────────────────────────────────────────────
@@ -56,14 +32,10 @@ export default function StepDetails({ formData, setFormData, onNext, onBack }: S
})); }));
}; };
const canProceed = true; // Step 2 fields are optional
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Progress */}
<ProgressBar currentStep={2} /> <ProgressBar currentStep={2} />
{/* Heading */}
<div> <div>
<h3 className="font-serif text-2xl font-semibold tracking-headline text-on-surface"> <h3 className="font-serif text-2xl font-semibold tracking-headline text-on-surface">
{t('step2.title')} {t('step2.title')}
@@ -74,12 +46,12 @@ export default function StepDetails({ formData, setFormData, onNext, onBack }: S
{/* Industry */} {/* Industry */}
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
<label className="text-xs font-semibold uppercase tracking-label text-outline"> <label className="text-xs font-semibold uppercase tracking-label text-outline">
Your industry {t('fields.industry')}
</label> </label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{INDUSTRIES.map((option, index) => ( {INDUSTRY_IDS.map((id, index) => (
<motion.div <motion.div
key={option.id} key={id}
initial={{ opacity: 0, y: 6 }} initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ transition={{
@@ -89,10 +61,10 @@ export default function StepDetails({ formData, setFormData, onNext, onBack }: S
}} }}
> >
<Chip <Chip
active={formData.industry === option.id} active={formData.industry === id}
onClick={() => selectIndustry(option.id)} onClick={() => selectIndustry(id)}
> >
{option.label} {t(`industries.${id}`)}
</Chip> </Chip>
</motion.div> </motion.div>
))} ))}
@@ -105,8 +77,10 @@ export default function StepDetails({ formData, setFormData, onNext, onBack }: S
htmlFor="scope-textarea" htmlFor="scope-textarea"
className="text-xs font-semibold uppercase tracking-label text-outline" className="text-xs font-semibold uppercase tracking-label text-outline"
> >
What are you looking to achieve? {t('fields.scope')}
<span className="ml-1.5 normal-case font-normal text-outline/70">(optional)</span> <span className="ml-1.5 normal-case font-normal text-outline/70">
{t('fields.scopeOptional')}
</span>
</label> </label>
<textarea <textarea
id="scope-textarea" id="scope-textarea"
@@ -114,7 +88,7 @@ export default function StepDetails({ formData, setFormData, onNext, onBack }: S
onChange={(e) => onChange={(e) =>
setFormData((prev) => ({ ...prev, scope: e.target.value })) setFormData((prev) => ({ ...prev, scope: e.target.value }))
} }
placeholder="e.g. We need to replace our current booking system and improve the client-facing experience…" placeholder={t('fields.scopePlaceholder')}
rows={4} rows={4}
className={cn( className={cn(
'w-full resize-none rounded-xl border border-outline-variant/60 bg-surface-high', 'w-full resize-none rounded-xl border border-outline-variant/60 bg-surface-high',
@@ -129,12 +103,12 @@ export default function StepDetails({ formData, setFormData, onNext, onBack }: S
{/* Timeline */} {/* Timeline */}
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
<label className="text-xs font-semibold uppercase tracking-label text-outline"> <label className="text-xs font-semibold uppercase tracking-label text-outline">
Timeline {t('fields.timeline')}
</label> </label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{TIMELINES.map((option, index) => ( {TIMELINE_IDS.map((id, index) => (
<motion.div <motion.div
key={option.id} key={id}
initial={{ opacity: 0, y: 6 }} initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ transition={{
@@ -144,10 +118,10 @@ export default function StepDetails({ formData, setFormData, onNext, onBack }: S
}} }}
> >
<Chip <Chip
active={formData.timeline === option.id} active={formData.timeline === id}
onClick={() => selectTimeline(option.id)} onClick={() => selectTimeline(id)}
> >
{option.label} {t(`timelines.${id}`)}
</Chip> </Chip>
</motion.div> </motion.div>
))} ))}
@@ -162,7 +136,6 @@ export default function StepDetails({ formData, setFormData, onNext, onBack }: S
<Button <Button
variant="primary" variant="primary"
arrow arrow
disabled={!canProceed}
onClick={onNext} onClick={onNext}
className="flex-1" className="flex-1"
> >

View File

@@ -20,95 +20,55 @@ interface ServiceOption {
} }
const SERVICES: ServiceOption[] = [ const SERVICES: ServiceOption[] = [
{ { id: 'web', icon: Globe, titleKey: 'services.web.title', descriptionKey: 'services.web.description' },
id: 'web', { id: 'systems', icon: Cog, titleKey: 'services.systems.title', descriptionKey: 'services.systems.description' },
icon: Globe, { id: 'infrastructure', icon: Server, titleKey: 'services.infrastructure.title', descriptionKey: 'services.infrastructure.description' },
titleKey: 'services.web.title',
descriptionKey: 'services.web.description',
},
{
id: 'systems',
icon: Cog,
titleKey: 'services.systems.title',
descriptionKey: 'services.systems.description',
},
{
id: 'infrastructure',
icon: Server,
titleKey: 'services.infrastructure.title',
descriptionKey: 'services.infrastructure.description',
},
]; ];
interface AITypeOption { const AI_TYPE_IDS = ['teammate', 'customer-facing', 'data-intelligence', 'notsure'] as const;
id: string;
label: string;
description: string;
}
const AI_TYPES: AITypeOption[] = [ // ─── Service Card ────────────────────────────────────────────────────────────
{
id: 'teammate',
label: 'AI Teammate',
description: 'An intelligent assistant embedded in your internal workflows — drafting, summarising, routing tasks.',
},
{
id: 'customer-facing',
label: 'Customer-Facing AI',
description: 'A smart interface layer for your clients — personalised responses, 24/7 availability, on-brand tone.',
},
{
id: 'data-intelligence',
label: 'Data Intelligence',
description: 'Automated reporting, anomaly detection, and decision-support drawn from your own business data.',
},
{
id: 'notsure',
label: 'Not Sure Yet',
description: 'We\'ll identify the right AI application for your context during our discovery sessions.',
},
];
// ─── Sub-components ─────────────────────────────────────────────────────────── function ServiceCard({
option,
interface ServiceCardProps { selected,
onToggle,
title,
description,
}: {
option: ServiceOption; option: ServiceOption;
selected: boolean; selected: boolean;
onToggle: () => void; onToggle: () => void;
title: string; title: string;
description: string; description: string;
} }) {
function ServiceCard({ option, selected, onToggle, title, description }: ServiceCardProps) {
const Icon = option.icon; const Icon = option.icon;
return ( return (
<motion.button <motion.button
type="button" type="button"
onClick={onToggle} onClick={onToggle}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.97 }}
className={cn( className={cn(
'group relative w-full text-left rounded-2xl p-5 transition-all duration-200', 'group relative w-full text-left rounded-2xl p-5 transition-all duration-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
selected selected
? 'bg-primary/5 shadow-card' ? 'bg-primary/6 border-2 border-primary/30 shadow-card'
: 'bg-surface-high shadow-subtle hover:shadow-card hover:bg-surface-high', : 'bg-surface-high border-2 border-transparent shadow-subtle hover:shadow-card hover:border-outline-variant/30',
)} )}
> >
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
{/* Icon */}
<div <div
className={cn( className={cn(
'flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center transition-colors duration-200', 'flex-shrink-0 w-11 h-11 rounded-xl flex items-center justify-center transition-colors duration-200',
selected selected
? 'bg-primary/15 text-primary-dark' ? 'bg-primary/15 text-primary-dark'
: 'bg-primary/8 text-primary group-hover:bg-primary/15 group-hover:text-primary-dark', : 'bg-primary/8 text-primary group-hover:bg-primary/12',
)} )}
> >
<Icon size={20} strokeWidth={1.5} /> <Icon size={20} strokeWidth={1.5} />
</div> </div>
{/* Text */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p <p
className={cn( className={cn(
@@ -121,7 +81,6 @@ function ServiceCard({ option, selected, onToggle, title, description }: Service
<p className="text-xs text-outline leading-relaxed">{description}</p> <p className="text-xs text-outline leading-relaxed">{description}</p>
</div> </div>
{/* Checkbox */}
<div className="flex-shrink-0 mt-0.5"> <div className="flex-shrink-0 mt-0.5">
<motion.div <motion.div
className={cn( className={cn(
@@ -154,16 +113,19 @@ function ServiceCard({ option, selected, onToggle, title, description }: Service
); );
} }
// ─── AI Toggle ───────────────────────────────────────────────────────────── // ─── AI Toggle ───────────────────────────────────────────────────────────────
interface AIToggleProps { function AIToggle({
enabled,
onToggle,
label,
description,
}: {
enabled: boolean; enabled: boolean;
onToggle: () => void; onToggle: () => void;
label: string; label: string;
description: string; description: string;
} }) {
function AIToggle({ enabled, onToggle, label, description }: AIToggleProps) {
return ( return (
<button <button
type="button" type="button"
@@ -195,11 +157,8 @@ function AIToggle({ enabled, onToggle, label, description }: AIToggleProps) {
/> />
{label} {label}
</span> </span>
<p className="text-xs text-outline mt-0.5"> <p className="text-xs text-outline mt-0.5">{description}</p>
{description}
</p>
</div> </div>
{/* Switch */}
<div <div
className={cn( className={cn(
'flex-shrink-0 w-10 h-6 rounded-full relative transition-colors duration-300', 'flex-shrink-0 w-10 h-6 rounded-full relative transition-colors duration-300',
@@ -216,7 +175,7 @@ function AIToggle({ enabled, onToggle, label, description }: AIToggleProps) {
); );
} }
// ─── Main Component ────────────────────────────────────────────────────────── // ─── Main Component ──────────────────────────────────────────────────────────
export default function StepServices({ formData, setFormData, onNext }: StepProps) { export default function StepServices({ formData, setFormData, onNext }: StepProps) {
const t = useTranslations('configurator'); const t = useTranslations('configurator');
@@ -234,27 +193,25 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
aiEnabled: !prev.aiEnabled, aiEnabled: !prev.aiEnabled,
aiType: prev.aiEnabled ? null : prev.aiType, aiTypes: prev.aiEnabled ? [] : prev.aiTypes,
})); }));
}; };
const selectAIType = (id: string) => { const toggleAIType = (id: string) => {
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
aiType: prev.aiType === id ? null : id, aiTypes: prev.aiTypes.includes(id)
? prev.aiTypes.filter((t) => t !== id)
: [...prev.aiTypes, id],
})); }));
}; };
const canProceed = formData.services.length > 0; const canProceed = formData.services.length > 0;
const selectedAIType = AI_TYPES.find((a) => a.id === formData.aiType);
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Progress */}
<ProgressBar currentStep={1} totalSteps={3} /> <ProgressBar currentStep={1} totalSteps={3} />
{/* Heading */}
<div> <div>
<h3 className="font-serif text-2xl font-semibold tracking-headline text-on-surface"> <h3 className="font-serif text-2xl font-semibold tracking-headline text-on-surface">
{t('step1.title')} {t('step1.title')}
@@ -263,7 +220,7 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
</div> </div>
{/* Service cards */} {/* Service cards */}
<div className="flex flex-col gap-0 divide-y divide-outline-variant/10"> <div className="flex flex-col gap-3">
{SERVICES.map((option) => ( {SERVICES.map((option) => (
<ServiceCard <ServiceCard
key={option.id} key={option.id}
@@ -274,7 +231,6 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
description={t(option.descriptionKey)} description={t(option.descriptionKey)}
/> />
))} ))}
{/* Empty-state hint */}
<AnimatePresence> <AnimatePresence>
{formData.services.length === 0 && ( {formData.services.length === 0 && (
<motion.p <motion.p
@@ -282,7 +238,7 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }} exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
className="text-xs text-outline/60 text-center pt-1 pb-0.5 select-none" className="text-xs text-outline/60 text-center pt-1 select-none"
> >
{t('selectService')} {t('selectService')}
</motion.p> </motion.p>
@@ -290,7 +246,7 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
</AnimatePresence> </AnimatePresence>
</div> </div>
{/* AI Toggle */} {/* AI Toggle + Type Selection */}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<AIToggle <AIToggle
enabled={formData.aiEnabled} enabled={formData.aiEnabled}
@@ -299,7 +255,6 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
description={t('aiDescription')} description={t('aiDescription')}
/> />
{/* AI type chips — stagger in */}
<AnimatePresence> <AnimatePresence>
{formData.aiEnabled && ( {formData.aiEnabled && (
<motion.div <motion.div
@@ -310,11 +265,10 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
className="overflow-hidden" className="overflow-hidden"
> >
<div className="pt-1 flex flex-col gap-3"> <div className="pt-1 flex flex-col gap-3">
{/* Chips row */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{AI_TYPES.map((aiOption, index) => ( {AI_TYPE_IDS.map((aiId, index) => (
<motion.div <motion.div
key={aiOption.id} key={aiId}
initial={{ opacity: 0, y: 8 }} initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ transition={{
@@ -324,28 +278,32 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
}} }}
> >
<Chip <Chip
active={formData.aiType === aiOption.id} active={formData.aiTypes.includes(aiId)}
onClick={() => selectAIType(aiOption.id)} onClick={() => toggleAIType(aiId)}
> >
{aiOption.label} {t(`aiTypes.${aiId}.title`)}
</Chip> </Chip>
</motion.div> </motion.div>
))} ))}
</div> </div>
{/* AI type description */}
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{selectedAIType && ( {formData.aiTypes.length > 0 && (
<motion.p <motion.div
key={selectedAIType.id} key={formData.aiTypes.join(',')}
initial={{ opacity: 0, y: 4 }} initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }} exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
className="text-xs text-outline leading-relaxed px-1" className="flex flex-col gap-1.5 px-1"
> >
{selectedAIType.description} {formData.aiTypes.map((aiId) => (
</motion.p> <p key={aiId} className="text-xs text-outline leading-relaxed">
<strong className="text-on-surface font-medium">{t(`aiTypes.${aiId}.title`)}:</strong>{' '}
{t(`aiTypes.${aiId}.description`)}
</p>
))}
</motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
</div> </div>
@@ -354,7 +312,6 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
</AnimatePresence> </AnimatePresence>
</div> </div>
{/* CTA */}
<Button <Button
variant="primary" variant="primary"
arrow arrow

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import StepServices from './StepServices'; import StepServices from './StepServices';
import StepDetails from './StepDetails'; import StepDetails from './StepDetails';
@@ -12,7 +13,7 @@ import StepComplete from './StepComplete';
export interface WizardFormData { export interface WizardFormData {
services: string[]; services: string[];
aiEnabled: boolean; aiEnabled: boolean;
aiType: string | null; aiTypes: string[];
industry: string | null; industry: string | null;
scope: string; scope: string;
timeline: string | null; timeline: string | null;
@@ -58,7 +59,7 @@ const makeVariants = (direction: 1 | -1) => ({
const DEFAULT_FORM_DATA: WizardFormData = { const DEFAULT_FORM_DATA: WizardFormData = {
services: [], services: [],
aiEnabled: false, aiEnabled: false,
aiType: null, aiTypes: [],
industry: null, industry: null,
scope: '', scope: '',
timeline: null, timeline: null,
@@ -68,6 +69,7 @@ const DEFAULT_FORM_DATA: WizardFormData = {
}; };
export default function WizardContainer() { export default function WizardContainer() {
const t = useTranslations('configurator');
const [currentStep, setCurrentStep] = useState<1 | 2 | 3 | 4>(1); const [currentStep, setCurrentStep] = useState<1 | 2 | 3 | 4>(1);
const [direction, setDirection] = useState<1 | -1>(1); const [direction, setDirection] = useState<1 | -1>(1);
const [formData, setFormData] = useState<WizardFormData>(DEFAULT_FORM_DATA); const [formData, setFormData] = useState<WizardFormData>(DEFAULT_FORM_DATA);
@@ -85,6 +87,14 @@ export default function WizardContainer() {
setCurrentStep((prev) => Math.max(prev - 1, 1) as 1 | 2 | 3 | 4); setCurrentStep((prev) => Math.max(prev - 1, 1) as 1 | 2 | 3 | 4);
}; };
const handleReset = () => {
setDirection(-1);
setFormData(DEFAULT_FORM_DATA);
setBrief('');
setSubmitError(null);
setCurrentStep(1);
};
const handleSubmit = async () => { const handleSubmit = async () => {
setIsSubmitting(true); setIsSubmitting(true);
setSubmitError(null); setSubmitError(null);
@@ -98,7 +108,7 @@ export default function WizardContainer() {
const data = (await response.json()) as { success: boolean; brief?: string; error?: string }; const data = (await response.json()) as { success: boolean; brief?: string; error?: string };
if (!response.ok || !data.success) { if (!response.ok || !data.success) {
setSubmitError(data.error ?? 'Something went wrong. Please try again.'); setSubmitError(data.error ?? t('errors.general'));
setIsSubmitting(false); setIsSubmitting(false);
return; return;
} }
@@ -107,7 +117,7 @@ export default function WizardContainer() {
setDirection(1); setDirection(1);
setCurrentStep(4); setCurrentStep(4);
} catch { } catch {
setSubmitError('Network error. Please check your connection and try again.'); setSubmitError(t('errors.network'));
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -174,7 +184,11 @@ export default function WizardContainer() {
animate="animate" animate="animate"
exit="exit" exit="exit"
> >
<StepComplete formData={formData} brief={brief} /> <StepComplete
formData={formData}
brief={brief}
onReset={handleReset}
/>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>

View File

@@ -51,11 +51,6 @@ const SOCIAL_LINKS = [
href: 'https://www.linkedin.com/company/letsbe-digital', href: 'https://www.linkedin.com/company/letsbe-digital',
Icon: LinkedInIcon, Icon: LinkedInIcon,
}, },
{
label: 'GitHub',
href: 'https://github.com/letsbe-digital',
Icon: GitHubIcon,
},
{ {
label: 'X', label: 'X',
href: 'https://x.com/letsbe_digital', href: 'https://x.com/letsbe_digital',
@@ -208,7 +203,7 @@ export default function Footer() {
<div className="mx-auto max-w-7xl px-6 lg:px-8 py-6"> <div className="mx-auto max-w-7xl px-6 lg:px-8 py-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3"> <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<p className="text-xs text-on-surface/35"> <p className="text-xs text-on-surface/35">
© {currentYear} LetsBe. Digital Studio © {currentYear} LetsBe Solutions LLC
</p> </p>
<ul className="flex items-center gap-5" role="list"> <ul className="flex items-center gap-5" role="list">
<li> <li>

View File

@@ -99,7 +99,7 @@ export default function Nav() {
const t = useTranslations('nav') const t = useTranslations('nav')
const pathname = usePathname() const pathname = usePathname()
const router = useRouter() const router = useRouter()
const scrolled = useScrolled(100) const scrolled = useScrolled(10)
const [mobileOpen, setMobileOpen] = useState(false) const [mobileOpen, setMobileOpen] = useState(false)
// Derive current locale from pathname (next-intl with localePrefix: 'as-needed') // Derive current locale from pathname (next-intl with localePrefix: 'as-needed')

View File

@@ -6,20 +6,20 @@ import { ShieldCheck } from 'lucide-react';
import { revealVariants, staggerContainer, viewportOnce } from '@/lib/animations'; import { revealVariants, staggerContainer, viewportOnce } from '@/lib/animations';
import WizardContainer from '@/components/configurator/WizardContainer'; import WizardContainer from '@/components/configurator/WizardContainer';
// ─── Step indicator dot ─────────────────────────────────────────────────────── // ─── Step indicator ──────────────────────────────────────────────────────────
interface StepDotProps { function StepIndicator({ index, label, isLast }: { index: number; label: string; isLast: boolean }) {
index: number;
label: string;
}
function StepDot({ index, label }: StepDotProps) {
return ( return (
<motion.div variants={revealVariants} className="flex items-center gap-3"> <motion.div variants={revealVariants} className="flex items-start gap-4">
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0"> <div className="flex flex-col items-center">
<span className="text-xs font-semibold text-primary-dark leading-none">{index}</span> <div className="w-7 h-7 rounded-full bg-primary/10 border border-primary/20 flex items-center justify-center flex-shrink-0">
<span className="text-xs font-semibold text-primary-dark leading-none">{index}</span>
</div>
{!isLast && (
<div className="w-px h-5 bg-primary/15 mt-1" aria-hidden="true" />
)}
</div> </div>
<span className="text-sm text-outline">{label}</span> <span className="text-sm text-outline pt-1">{label}</span>
</motion.div> </motion.div>
); );
} }
@@ -36,11 +36,20 @@ export default function Configurator() {
]; ];
return ( return (
<section id="configure" className="bg-surface-low py-20"> <section id="configure" className="relative bg-surface py-24 overflow-hidden">
<div className="container mx-auto px-6"> {/* Subtle diagonal accent line */}
<div className="grid grid-cols-1 gap-12 lg:grid-cols-12"> <div
className="absolute top-0 left-0 right-0 h-px pointer-events-none"
style={{
background: 'linear-gradient(90deg, transparent 10%, rgba(91,164,217,0.15) 50%, transparent 90%)',
}}
aria-hidden="true"
/>
{/* ── Left: Sticky context panel ─────────────────────────────── */} <div className="relative z-10 container mx-auto px-6">
<div className="grid grid-cols-1 gap-12 lg:grid-cols-12 lg:gap-16 items-start">
{/* ── Left: Context panel ──────────────────────────────────────── */}
<div className="lg:col-span-5"> <div className="lg:col-span-5">
<div className="lg:sticky lg:top-24"> <div className="lg:sticky lg:top-24">
<motion.div <motion.div
@@ -50,7 +59,6 @@ export default function Configurator() {
viewport={viewportOnce} viewport={viewportOnce}
className="flex flex-col gap-6" className="flex flex-col gap-6"
> >
{/* Eyebrow */}
<motion.span <motion.span
variants={revealVariants} variants={revealVariants}
className="label-md text-primary" className="label-md text-primary"
@@ -58,7 +66,6 @@ export default function Configurator() {
{t('eyebrow')} {t('eyebrow')}
</motion.span> </motion.span>
{/* H2 */}
<motion.h2 <motion.h2
variants={revealVariants} variants={revealVariants}
className="font-serif text-4xl font-semibold tracking-headline text-on-surface leading-tight md:text-5xl" className="font-serif text-4xl font-semibold tracking-headline text-on-surface leading-tight md:text-5xl"
@@ -66,7 +73,6 @@ export default function Configurator() {
{t('title')} {t('title')}
</motion.h2> </motion.h2>
{/* Description */}
<motion.p <motion.p
variants={revealVariants} variants={revealVariants}
className="text-base text-outline leading-relaxed max-w-sm" className="text-base text-outline leading-relaxed max-w-sm"
@@ -74,50 +80,79 @@ export default function Configurator() {
{t('description')} {t('description')}
</motion.p> </motion.p>
{/* Divider */}
<motion.div
variants={revealVariants}
className="w-12 h-px bg-outline-variant/40"
aria-hidden="true"
/>
{/* Step indicators */} {/* Step indicators */}
<motion.div <motion.div
variants={revealVariants} variants={revealVariants}
className="flex flex-col gap-3 pt-2" className="flex flex-col gap-1"
> >
<p className="text-xs font-semibold uppercase tracking-label text-outline/70"> <p className="text-xs font-semibold uppercase tracking-label text-outline/60 mb-3">
{t('howItWorks')} {t('howItWorks')}
</p> </p>
{/* Vertical accent line + steps */} {steps.map((step, i) => (
<div className="flex gap-4"> <StepIndicator
<div className="flex-shrink-0 w-px bg-primary/20 ml-3 rounded-full" aria-hidden="true" /> key={i}
<div className="flex flex-col gap-2.5 flex-1"> index={i + 1}
{steps.map((step, i) => ( label={step}
<StepDot key={i} index={i + 1} label={step} /> isLast={i === steps.length - 1}
))} />
</div> ))}
</div>
</motion.div> </motion.div>
{/* Trust signal */} {/* Trust signal */}
<motion.div <motion.div
variants={revealVariants} variants={revealVariants}
className="pt-1 flex items-center gap-2" className="flex items-center gap-2 pt-2"
> >
<ShieldCheck size={14} strokeWidth={1.75} className="text-primary flex-shrink-0" aria-hidden="true" /> <ShieldCheck
size={14}
strokeWidth={1.75}
className="text-primary flex-shrink-0"
aria-hidden="true"
/>
<p className="text-xs text-outline">{t('noCommitment')}</p> <p className="text-xs text-outline">{t('noCommitment')}</p>
</motion.div> </motion.div>
</motion.div> </motion.div>
</div> </div>
</div> </div>
{/* ── Right: Wizard ───────────────────────────────────────────── */} {/* ── Right: Wizard card ────────────────────────────────────────── */}
<div className="lg:col-span-7"> <div className="lg:col-span-7">
<div className="relative rounded-2xl bg-surface-high shadow-subtle p-6 sm:p-8 overflow-hidden"> <motion.div
{/* Radial gradient glow — top-left warmth */} initial={{ opacity: 0, y: 24 }}
<div whileInView={{ opacity: 1, y: 0 }}
className="pointer-events-none absolute -top-16 -left-16 w-72 h-72 rounded-full" viewport={viewportOnce}
style={{ transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1], delay: 0.1 }}
background: 'radial-gradient(circle, rgba(91,164,217,0.07) 0%, transparent 70%)', className="relative"
}} >
aria-hidden="true" <div className="relative rounded-2xl bg-surface-high shadow-[0_20px_50px_rgba(25,28,29,0.08)] p-6 sm:p-8 overflow-hidden border border-outline-variant/20">
/> {/* Top-edge accent line */}
<WizardContainer /> <div
</div> className="absolute top-0 left-6 right-6 h-[2px] rounded-full pointer-events-none"
style={{
background: 'linear-gradient(90deg, #006494, #5BA4D9, transparent)',
}}
aria-hidden="true"
/>
{/* Soft radial glow */}
<div
className="pointer-events-none absolute -top-20 -left-20 w-80 h-80 rounded-full"
style={{
background: 'radial-gradient(circle, rgba(91,164,217,0.05) 0%, transparent 70%)',
}}
aria-hidden="true"
/>
<WizardContainer />
</div>
</motion.div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -127,7 +127,7 @@ export default function Hero() {
<div className="relative z-10 w-full max-w-screen-xl mx-auto px-6 lg:px-12 xl:px-16 flex flex-col lg:flex-row lg:items-center min-h-screen"> <div className="relative z-10 w-full max-w-screen-xl mx-auto px-6 lg:px-12 xl:px-16 flex flex-col lg:flex-row lg:items-center min-h-screen">
{/* ── LEFT COLUMN — text content (55% on desktop) ─────────────── */} {/* ── LEFT COLUMN — text content (55% on desktop) ─────────────── */}
<div className="flex-1 lg:max-w-[58%] flex flex-col justify-center pt-24 pb-16 lg:pt-28 lg:pb-0"> <div className="flex-1 lg:max-w-[58%] flex flex-col justify-center pt-24 pb-16 lg:pt-20 lg:pb-16">
{/* Headline — word-by-word stagger */} {/* Headline — word-by-word stagger */}
<motion.h1 <motion.h1
@@ -160,7 +160,7 @@ export default function Hero() {
{/* Subtitle */} {/* Subtitle */}
<motion.p <motion.p
className="text-lg text-outline leading-relaxed max-w-lg mb-8" className="text-lg text-outline leading-relaxed max-w-lg mb-6"
variants={subtitleVariant} variants={subtitleVariant}
initial="hidden" initial="hidden"
animate="visible" animate="visible"
@@ -170,7 +170,7 @@ export default function Hero() {
{/* Gradient separator — expands from left */} {/* Gradient separator — expands from left */}
<motion.span <motion.span
className="block w-20 h-px mb-10 origin-left" className="block w-20 h-px mb-6 origin-left"
style={{ style={{
background: background:
'linear-gradient(to right, rgba(0,100,148,0.5), rgba(91,164,217,0.2), transparent)', 'linear-gradient(to right, rgba(0,100,148,0.5), rgba(91,164,217,0.2), transparent)',
@@ -341,10 +341,10 @@ export default function Hero() {
{/* ─── Bottom fade into next section ────────────────────────────── */} {/* ─── Bottom fade into next section ────────────────────────────── */}
<div <div
className="absolute bottom-0 left-0 right-0 h-28 pointer-events-none z-10" className="absolute bottom-0 left-0 right-0 h-32 pointer-events-none z-10"
style={{ style={{
background: background:
'linear-gradient(to bottom, transparent, rgba(248,249,250,0.65))', 'linear-gradient(to bottom, transparent, #f3f4f5)',
}} }}
aria-hidden="true" aria-hidden="true"
/> />

View File

@@ -113,11 +113,11 @@ export default function TrustBar() {
aria-label="Trust indicators" aria-label="Trust indicators"
className="relative bg-surface-low py-12" className="relative bg-surface-low py-12"
> >
{/* Gradient bridge — blends hero surface-high into this section */} {/* Gradient bridge — blends hero into this section */}
<div <div
className="absolute top-0 left-0 right-0 h-16 pointer-events-none" className="absolute top-0 left-0 right-0 h-24 pointer-events-none"
style={{ style={{
background: 'linear-gradient(to bottom, #ffffff, transparent)', background: 'linear-gradient(to bottom, #f8f9fa, transparent)',
}} }}
aria-hidden="true" aria-hidden="true"
/> />

View File

@@ -39,7 +39,7 @@
"title": "Design. Build. Run.", "title": "Design. Build. Run.",
"web": { "web": {
"title": "Websites & Web Apps", "title": "Websites & Web Apps",
"features": ["Custom Web Design", "Responsive Development", "SEO & Performance", "Content Management"] "features": ["Custom Web Design", "Responsive Development", "SEO & Digital Marketing", "Content Management"]
}, },
"systems": { "systems": {
"title": "Custom Software", "title": "Custom Software",
@@ -64,14 +64,17 @@
"subtitle": "A few details help us prepare the right approach." "subtitle": "A few details help us prepare the right approach."
}, },
"step3": { "step3": {
"title": "Your details", "title": "Almost there",
"subtitle": "We'll send you a personalized project brief." "subtitle": "Review your selections and tell us how to reach you."
}, },
"complete": { "complete": {
"title": "Your project brief is ready", "title": "Your project brief is ready",
"subtitle": "Check your inbox — we've sent a detailed brief to {email}", "subtitle": "Check your inbox — we've sent a detailed brief to {email}",
"bookTitle": "Book a Consultation", "bookTitle": "Book a Consultation",
"bookSubtitle": "30 minutes to discuss your brief with our team" "bookSubtitle": "30 minutes to discuss your brief with our team",
"bookCall": "Book a Call",
"briefPreview": "Your project brief",
"reachDirectly": "Or reach us directly at"
}, },
"howItWorks": "How it works", "howItWorks": "How it works",
"noCommitment": "No commitment required", "noCommitment": "No commitment required",
@@ -92,9 +95,63 @@
}, },
"aiToggle": "Add AI Integration", "aiToggle": "Add AI Integration",
"aiDescription": "Intelligent features and automation built directly into your systems.", "aiDescription": "Intelligent features and automation built directly into your systems.",
"aiTypes": {
"teammate": {
"title": "AI Teammate",
"description": "An internal AI assistant that helps your team work faster — automates tasks, answers questions, connects your tools."
},
"customer-facing": {
"title": "Customer-Facing AI",
"description": "AI features your clients interact with — smart search, personalized recommendations, conversational interfaces."
},
"data-intelligence": {
"title": "Data Intelligence",
"description": "AI that analyzes your business data to surface insights, trends, and actionable recommendations."
},
"notsure": {
"title": "Not Sure Yet",
"description": "No problem — we'll explore the best AI approach together during discovery."
}
},
"industries": {
"maritime": "Maritime / Yachting",
"hospitality": "Hospitality",
"technology": "Technology",
"realestate": "Real Estate",
"finance": "Finance",
"ngo": "NGO / Nonprofit",
"other": "Other"
},
"timelines": {
"asap": "ASAP",
"1-3months": "13 months",
"3-6months": "36 months",
"exploring": "Just exploring"
},
"fields": {
"industry": "Your industry",
"scope": "What are you looking to achieve?",
"scopeOptional": "(optional)",
"scopePlaceholder": "e.g. We need to replace our current booking system and improve the client-facing experience…",
"timeline": "Timeline",
"name": "Your name",
"company": "Company",
"email": "Email address"
},
"summary": {
"heading": "Your selections",
"aiEnhancement": "AI Enhancement"
},
"generating": "Generating",
"privacy": "Your information is private and will never be shared.",
"generateBrief": "Generate My Brief", "generateBrief": "Generate My Brief",
"nextStep": "Next Step", "nextStep": "Next Step",
"back": "Back" "back": "Back",
"startOver": "Start Over",
"errors": {
"general": "Something went wrong. Please try again.",
"network": "Network error. Please check your connection and try again."
}
}, },
"process": { "process": {
"eyebrow": "How We Work", "eyebrow": "How We Work",
@@ -181,7 +238,7 @@
}, },
"footer": { "footer": {
"tagline": "Custom websites, software, and infrastructure for businesses that want to own their digital future.", "tagline": "Custom websites, software, and infrastructure for businesses that want to own their digital future.",
"location": "Côte d'Azur, France", "location": "American-founded. Serving the Côte d'Azur and beyond.",
"services": "Services", "services": "Services",
"studio": "Studio", "studio": "Studio",
"connect": "Connect", "connect": "Connect",

View File

@@ -39,7 +39,7 @@
"title": "Concevoir. Développer. Héberger.", "title": "Concevoir. Développer. Héberger.",
"web": { "web": {
"title": "Sites Web & Applications", "title": "Sites Web & Applications",
"features": ["Design Web Sur Mesure", "Développement Responsive", "SEO & Performance", "Gestion de Contenu"] "features": ["Design Web Sur Mesure", "Développement Responsive", "SEO & Marketing Digital", "Gestion de Contenu"]
}, },
"systems": { "systems": {
"title": "Logiciels Sur Mesure", "title": "Logiciels Sur Mesure",
@@ -64,14 +64,17 @@
"subtitle": "Quelques détails nous aident à préparer la bonne approche." "subtitle": "Quelques détails nous aident à préparer la bonne approche."
}, },
"step3": { "step3": {
"title": "Vos coordonnées", "title": "Presque terminé",
"subtitle": "Nous vous enverrons un brief projet personnalisé." "subtitle": "Vérifiez vos sélections et indiquez-nous comment vous joindre."
}, },
"complete": { "complete": {
"title": "Votre brief projet est prêt", "title": "Votre brief projet est prêt",
"subtitle": "Vérifiez votre boîte mail — nous avons envoyé un brief détaillé à {email}", "subtitle": "Vérifiez votre boîte mail — nous avons envoyé un brief détaillé à {email}",
"bookTitle": "Réservez une Consultation", "bookTitle": "Réservez une Consultation",
"bookSubtitle": "30 minutes pour discuter de votre brief avec notre équipe" "bookSubtitle": "30 minutes pour discuter de votre brief avec notre équipe",
"bookCall": "Réserver un Appel",
"briefPreview": "Votre brief projet",
"reachDirectly": "Ou contactez-nous directement à"
}, },
"howItWorks": "Comment ça marche", "howItWorks": "Comment ça marche",
"noCommitment": "Sans engagement", "noCommitment": "Sans engagement",
@@ -92,9 +95,63 @@
}, },
"aiToggle": "Ajouter l'Intégration IA", "aiToggle": "Ajouter l'Intégration IA",
"aiDescription": "Fonctionnalités intelligentes et automatisation intégrées directement dans vos systèmes.", "aiDescription": "Fonctionnalités intelligentes et automatisation intégrées directement dans vos systèmes.",
"aiTypes": {
"teammate": {
"title": "IA Coéquipier",
"description": "Un assistant IA interne qui aide votre équipe à travailler plus vite — automatise les tâches, répond aux questions, connecte vos outils."
},
"customer-facing": {
"title": "IA Client",
"description": "Des fonctionnalités IA avec lesquelles vos clients interagissent — recherche intelligente, recommandations personnalisées, interfaces conversationnelles."
},
"data-intelligence": {
"title": "Intelligence Données",
"description": "L'IA analyse vos données métier pour faire émerger des insights, tendances et recommandations actionnables."
},
"notsure": {
"title": "Pas Encore Sûr",
"description": "Pas de problème — nous explorerons ensemble la meilleure approche IA lors de la phase de découverte."
}
},
"industries": {
"maritime": "Maritime / Yachting",
"hospitality": "Hôtellerie",
"technology": "Technologie",
"realestate": "Immobilier",
"finance": "Finance",
"ngo": "ONG / Associatif",
"other": "Autre"
},
"timelines": {
"asap": "Dès que possible",
"1-3months": "13 mois",
"3-6months": "36 mois",
"exploring": "Je me renseigne"
},
"fields": {
"industry": "Votre secteur",
"scope": "Que cherchez-vous à accomplir ?",
"scopeOptional": "(facultatif)",
"scopePlaceholder": "ex. Nous devons remplacer notre système de réservation actuel et améliorer l'expérience client…",
"timeline": "Calendrier",
"name": "Votre nom",
"company": "Entreprise",
"email": "Adresse email"
},
"summary": {
"heading": "Vos sélections",
"aiEnhancement": "Enrichissement IA"
},
"generating": "Génération",
"privacy": "Vos informations sont privées et ne seront jamais partagées.",
"generateBrief": "Générer Mon Brief", "generateBrief": "Générer Mon Brief",
"nextStep": "Étape Suivante", "nextStep": "Étape Suivante",
"back": "Retour" "back": "Retour",
"startOver": "Recommencer",
"errors": {
"general": "Une erreur est survenue. Veuillez réessayer.",
"network": "Erreur réseau. Veuillez vérifier votre connexion et réessayer."
}
}, },
"process": { "process": {
"eyebrow": "Notre Méthode", "eyebrow": "Notre Méthode",
@@ -181,7 +238,7 @@
}, },
"footer": { "footer": {
"tagline": "Sites web, logiciels et infrastructure sur mesure pour les entreprises qui veulent maîtriser leur avenir digital.", "tagline": "Sites web, logiciels et infrastructure sur mesure pour les entreprises qui veulent maîtriser leur avenir digital.",
"location": "Côte d'Azur, France", "location": "Fondé aux États-Unis. Au service de la Côte d'Azur et au-delà.",
"services": "Services", "services": "Services",
"studio": "Studio", "studio": "Studio",
"connect": "Contact", "connect": "Contact",