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>
This commit is contained in:
2026-03-26 17:52:09 +01:00
parent 4aa357a999
commit acefb70b68
12 changed files with 532 additions and 459 deletions

View File

@@ -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<string, string> = {
web: 'Web Design & Development',
systems: 'Custom Software',
infrastructure: 'Private Infrastructure',
};
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 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 13 months';
case '3-6months':
return 'over a 36 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<string> {
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 (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**
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');
});
}

View File

@@ -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 (
<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] === '---') {
return <hr key={blockIdx} className="border-outline-variant/30 my-3" />;
}
// Body paragraph — inline bold rendering
return (
<div key={blockIdx} className="text-xs text-outline leading-relaxed">
{lines.map((line, lineIdx) => {
// Render **bold** inline
const parts = line.split(/(\*\*[^*]+\*\*)/g);
return (
<p key={lineIdx} className={lineIdx > 0 ? 'mt-1' : ''}>
@@ -68,37 +62,15 @@ function renderBrief(brief: string) {
});
}
// ─── Cal.com Embed / Booking ──────────────────────────────────────────────────
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 ───────────────────────────────────────────────────────────
// ─── 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"
>
<p className="text-xs font-semibold uppercase tracking-label text-outline mb-3">
Your project brief
{t('complete.briefPreview')}
</p>
<div className="space-y-1 max-h-72 overflow-y-auto pr-1 scrollbar-thin">
{renderBrief(brief)}
@@ -161,16 +131,32 @@ export default function StepComplete({ formData, brief }: StepCompleteProps) {
{/* Booking */}
<motion.div variants={itemVariants}>
<p className="text-xs font-semibold uppercase tracking-label text-outline mb-3">
{t('complete.bookTitle')}
</p>
<BookingSection />
<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">
{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>
{/* Fallback contact */}
<motion.div variants={itemVariants}>
{/* Fallback contact + reset */}
<motion.div variants={itemVariants} className="flex flex-col gap-3">
<p className="text-center text-xs text-outline">
Or reach us directly at{' '}
{t('complete.reachDirectly')}{' '}
<a
href="mailto:hello@letsbe.biz"
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
</a>
</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>
);

View File

@@ -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<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 ───────────────────────────────────────────────────────────
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 (
<div className="flex flex-col gap-1.5">
<label
@@ -90,12 +56,7 @@ function InputField({
);
}
interface SummaryTagProps {
label: string;
variant?: 'primary' | 'neutral';
}
function SummaryTag({ label, variant = 'neutral' }: SummaryTagProps) {
function SummaryTag({ label, variant = 'neutral' }: { label: string; variant?: 'primary' | 'neutral' }) {
return (
<span
className={cn(
@@ -110,8 +71,6 @@ function SummaryTag({ label, variant = 'neutral' }: SummaryTagProps) {
);
}
// ─── Loading Dots ─────────────────────────────────────────────────────────────
function LoadingDots() {
return (
<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 {
isSubmitting: boolean;
submitError: string | null;
}
// ─── Main Component ──────────────────────────────────────────────────────────
// ─── Main Component ──────────────────────────────────────────────────────────
export default function StepContact({
formData,
@@ -157,30 +116,40 @@ export default function StepContact({
isEmailValid &&
!isSubmitting;
// Build summary tags
const serviceTags = formData.services.map((id) => SERVICE_LABELS[id] ?? id);
const aiTag =
formData.aiEnabled && formData.aiType
? AI_TYPE_LABELS[formData.aiType] ?? formData.aiType
: formData.aiEnabled
? 'AI Enhancement'
: null;
const industryTag = formData.industry ? INDUSTRY_LABELS[formData.industry] ?? formData.industry : null;
const timelineTag = formData.timeline ? TIMELINE_LABELS[formData.timeline] ?? formData.timeline : null;
// Build summary tags using translation keys
const serviceTags = formData.services.map((id) => ({
label: t(`services.${id}.title`),
variant: 'primary' as const,
}));
const aiTag = formData.aiEnabled
? {
label: formData.aiType
? t(`aiTypes.${formData.aiType}.title`)
: t('summary.aiEnhancement'),
variant: 'primary' as const,
}
: null;
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 = [
...serviceTags.map((s) => ({ label: s, variant: 'primary' as const })),
...(aiTag ? [{ label: aiTag, variant: 'primary' as const }] : []),
...(industryTag ? [{ label: industryTag, variant: 'neutral' as const }] : []),
...(timelineTag ? [{ label: timelineTag, variant: 'neutral' as const }] : []),
...serviceTags,
...(aiTag ? [aiTag] : []),
...(industryTag ? [industryTag] : []),
...(timelineTag ? [timelineTag] : []),
];
return (
<div className="flex flex-col gap-6">
{/* Progress */}
<ProgressBar currentStep={3} />
{/* Heading */}
<div>
<h3 className="font-serif text-2xl font-semibold tracking-headline text-on-surface">
{t('step3.title')}
@@ -192,7 +161,7 @@ export default function StepContact({
{allTags.length > 0 && (
<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">
Your selections
{t('summary.heading')}
</p>
<div className="flex flex-wrap gap-1.5">
{allTags.map((tag, i) => (
@@ -213,28 +182,25 @@ export default function StepContact({
<div className="flex flex-col gap-4">
<InputField
id="contact-name"
label="Your name"
label={t('fields.name')}
value={formData.name}
onChange={(v) => setFormData((prev) => ({ ...prev, name: v }))}
placeholder="Sophie Laurent"
required
autoComplete="name"
/>
<InputField
id="contact-company"
label="Company"
label={t('fields.company')}
value={formData.company}
onChange={(v) => setFormData((prev) => ({ ...prev, company: v }))}
placeholder="Maison Laurent Group"
autoComplete="organization"
/>
<InputField
id="contact-email"
label="Email address"
label={t('fields.email')}
type="email"
value={formData.email}
onChange={(v) => setFormData((prev) => ({ ...prev, email: v }))}
placeholder="sophie@example.com"
required
autoComplete="email"
/>
@@ -273,7 +239,7 @@ export default function StepContact({
>
{isSubmitting ? (
<span className="flex items-center gap-2">
Generating
{t('generating')}
<LoadingDots />
</span>
) : (
@@ -283,7 +249,7 @@ export default function StepContact({
</div>
<p className="text-center text-xs text-outline">
Your information is private and will never be shared.
{t('privacy')}
</p>
</div>
);

View File

@@ -10,32 +10,8 @@ import type { StepProps } from './WizardContainer';
// ─── Data ─────────────────────────────────────────────────────────────────────
interface IndustryOption {
id: string;
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' },
];
const INDUSTRY_IDS = ['maritime', 'hospitality', 'technology', 'realestate', 'finance', 'ngo', 'other'] as const;
const TIMELINE_IDS = ['asap', '1-3months', '3-6months', 'exploring'] as const;
// ─── Component ────────────────────────────────────────────────────────────────
@@ -56,14 +32,10 @@ export default function StepDetails({ formData, setFormData, onNext, onBack }: S
}));
};
const canProceed = true; // Step 2 fields are optional
return (
<div className="flex flex-col gap-6">
{/* Progress */}
<ProgressBar currentStep={2} />
{/* Heading */}
<div>
<h3 className="font-serif text-2xl font-semibold tracking-headline text-on-surface">
{t('step2.title')}
@@ -74,12 +46,12 @@ export default function StepDetails({ formData, setFormData, onNext, onBack }: S
{/* Industry */}
<div className="flex flex-col gap-2.5">
<label className="text-xs font-semibold uppercase tracking-label text-outline">
Your industry
{t('fields.industry')}
</label>
<div className="flex flex-wrap gap-2">
{INDUSTRIES.map((option, index) => (
{INDUSTRY_IDS.map((id, index) => (
<motion.div
key={option.id}
key={id}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{
@@ -89,10 +61,10 @@ export default function StepDetails({ formData, setFormData, onNext, onBack }: S
}}
>
<Chip
active={formData.industry === option.id}
onClick={() => selectIndustry(option.id)}
active={formData.industry === id}
onClick={() => selectIndustry(id)}
>
{option.label}
{t(`industries.${id}`)}
</Chip>
</motion.div>
))}
@@ -105,8 +77,10 @@ export default function StepDetails({ formData, setFormData, onNext, onBack }: S
htmlFor="scope-textarea"
className="text-xs font-semibold uppercase tracking-label text-outline"
>
What are you looking to achieve?
<span className="ml-1.5 normal-case font-normal text-outline/70">(optional)</span>
{t('fields.scope')}
<span className="ml-1.5 normal-case font-normal text-outline/70">
{t('fields.scopeOptional')}
</span>
</label>
<textarea
id="scope-textarea"
@@ -114,7 +88,7 @@ export default function StepDetails({ formData, setFormData, onNext, onBack }: S
onChange={(e) =>
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}
className={cn(
'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 */}
<div className="flex flex-col gap-2.5">
<label className="text-xs font-semibold uppercase tracking-label text-outline">
Timeline
{t('fields.timeline')}
</label>
<div className="flex flex-wrap gap-2">
{TIMELINES.map((option, index) => (
{TIMELINE_IDS.map((id, index) => (
<motion.div
key={option.id}
key={id}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{
@@ -144,10 +118,10 @@ export default function StepDetails({ formData, setFormData, onNext, onBack }: S
}}
>
<Chip
active={formData.timeline === option.id}
onClick={() => selectTimeline(option.id)}
active={formData.timeline === id}
onClick={() => selectTimeline(id)}
>
{option.label}
{t(`timelines.${id}`)}
</Chip>
</motion.div>
))}
@@ -162,7 +136,6 @@ export default function StepDetails({ formData, setFormData, onNext, onBack }: S
<Button
variant="primary"
arrow
disabled={!canProceed}
onClick={onNext}
className="flex-1"
>

View File

@@ -20,95 +20,55 @@ interface ServiceOption {
}
const SERVICES: ServiceOption[] = [
{
id: 'web',
icon: Globe,
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',
},
{ id: 'web', icon: Globe, 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 {
id: string;
label: string;
description: string;
}
const AI_TYPE_IDS = ['teammate', 'customer-facing', 'data-intelligence', 'notsure'] as const;
const AI_TYPES: AITypeOption[] = [
{
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.',
},
];
// ─── Service Card ────────────────────────────────────────────────────────────
// ─── Sub-components ───────────────────────────────────────────────────────────
interface ServiceCardProps {
function ServiceCard({
option,
selected,
onToggle,
title,
description,
}: {
option: ServiceOption;
selected: boolean;
onToggle: () => void;
title: string;
description: string;
}
function ServiceCard({ option, selected, onToggle, title, description }: ServiceCardProps) {
}) {
const Icon = option.icon;
return (
<motion.button
type="button"
onClick={onToggle}
whileTap={{ scale: 0.98 }}
whileTap={{ scale: 0.97 }}
className={cn(
'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',
selected
? 'bg-primary/5 shadow-card'
: 'bg-surface-high shadow-subtle hover:shadow-card hover:bg-surface-high',
? 'bg-primary/6 border-2 border-primary/30 shadow-card'
: 'bg-surface-high border-2 border-transparent shadow-subtle hover:shadow-card hover:border-outline-variant/30',
)}
>
<div className="flex items-start gap-4">
{/* Icon */}
<div
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
? '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} />
</div>
{/* Text */}
<div className="flex-1 min-w-0">
<p
className={cn(
@@ -121,7 +81,6 @@ function ServiceCard({ option, selected, onToggle, title, description }: Service
<p className="text-xs text-outline leading-relaxed">{description}</p>
</div>
{/* Checkbox */}
<div className="flex-shrink-0 mt-0.5">
<motion.div
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;
onToggle: () => void;
label: string;
description: string;
}
function AIToggle({ enabled, onToggle, label, description }: AIToggleProps) {
}) {
return (
<button
type="button"
@@ -195,11 +157,8 @@ function AIToggle({ enabled, onToggle, label, description }: AIToggleProps) {
/>
{label}
</span>
<p className="text-xs text-outline mt-0.5">
{description}
</p>
<p className="text-xs text-outline mt-0.5">{description}</p>
</div>
{/* Switch */}
<div
className={cn(
'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) {
const t = useTranslations('configurator');
@@ -247,14 +206,10 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
const canProceed = formData.services.length > 0;
const selectedAIType = AI_TYPES.find((a) => a.id === formData.aiType);
return (
<div className="flex flex-col gap-6">
{/* Progress */}
<ProgressBar currentStep={1} totalSteps={3} />
{/* Heading */}
<div>
<h3 className="font-serif text-2xl font-semibold tracking-headline text-on-surface">
{t('step1.title')}
@@ -263,7 +218,7 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
</div>
{/* 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) => (
<ServiceCard
key={option.id}
@@ -274,7 +229,6 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
description={t(option.descriptionKey)}
/>
))}
{/* Empty-state hint */}
<AnimatePresence>
{formData.services.length === 0 && (
<motion.p
@@ -282,7 +236,7 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
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')}
</motion.p>
@@ -290,7 +244,7 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
</AnimatePresence>
</div>
{/* AI Toggle */}
{/* AI Toggle + Type Selection */}
<div className="flex flex-col gap-3">
<AIToggle
enabled={formData.aiEnabled}
@@ -299,7 +253,6 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
description={t('aiDescription')}
/>
{/* AI type chips — stagger in */}
<AnimatePresence>
{formData.aiEnabled && (
<motion.div
@@ -310,11 +263,10 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
className="overflow-hidden"
>
<div className="pt-1 flex flex-col gap-3">
{/* Chips row */}
<div className="flex flex-wrap gap-2">
{AI_TYPES.map((aiOption, index) => (
{AI_TYPE_IDS.map((aiId, index) => (
<motion.div
key={aiOption.id}
key={aiId}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{
@@ -324,27 +276,26 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
}}
>
<Chip
active={formData.aiType === aiOption.id}
onClick={() => selectAIType(aiOption.id)}
active={formData.aiType === aiId}
onClick={() => selectAIType(aiId)}
>
{aiOption.label}
{t(`aiTypes.${aiId}.title`)}
</Chip>
</motion.div>
))}
</div>
{/* AI type description */}
<AnimatePresence mode="wait">
{selectedAIType && (
{formData.aiType && (
<motion.p
key={selectedAIType.id}
key={formData.aiType}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.2 }}
className="text-xs text-outline leading-relaxed px-1"
>
{selectedAIType.description}
{t(`aiTypes.${formData.aiType}.description`)}
</motion.p>
)}
</AnimatePresence>
@@ -354,7 +305,6 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
</AnimatePresence>
</div>
{/* CTA */}
<Button
variant="primary"
arrow

View File

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

View File

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

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">
{/* ── 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 */}
<motion.h1
@@ -160,7 +160,7 @@ export default function Hero() {
{/* Subtitle */}
<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}
initial="hidden"
animate="visible"
@@ -170,7 +170,7 @@ export default function Hero() {
{/* Gradient separator — expands from left */}
<motion.span
className="block w-20 h-px mb-10 origin-left"
className="block w-20 h-px mb-6 origin-left"
style={{
background:
'linear-gradient(to right, rgba(0,100,148,0.5), rgba(91,164,217,0.2), transparent)',
@@ -341,7 +341,7 @@ export default function Hero() {
{/* ─── Bottom fade into next section ────────────────────────────── */}
<div
className="absolute bottom-0 left-0 right-0 h-40 pointer-events-none z-10"
className="absolute bottom-0 left-0 right-0 h-32 pointer-events-none z-10"
style={{
background:
'linear-gradient(to bottom, transparent, #f3f4f5)',

View File

@@ -64,14 +64,17 @@
"subtitle": "A few details help us prepare the right approach."
},
"step3": {
"title": "Your details",
"subtitle": "We'll send you a personalized project brief."
"title": "Almost there",
"subtitle": "Review your selections and tell us how to reach you."
},
"complete": {
"title": "Your project brief is ready",
"subtitle": "Check your inbox — we've sent a detailed brief to {email}",
"bookTitle": "Book a Consultation",
"bookSubtitle": "30 minutes to discuss your brief with our team"
"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",
"noCommitment": "No commitment required",
@@ -92,9 +95,63 @@
},
"aiToggle": "Add AI Integration",
"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",
"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": {
"eyebrow": "How We Work",

View File

@@ -64,14 +64,17 @@
"subtitle": "Quelques détails nous aident à préparer la bonne approche."
},
"step3": {
"title": "Vos coordonnées",
"subtitle": "Nous vous enverrons un brief projet personnalisé."
"title": "Presque terminé",
"subtitle": "Vérifiez vos sélections et indiquez-nous comment vous joindre."
},
"complete": {
"title": "Votre brief projet est prêt",
"subtitle": "Vérifiez votre boîte mail — nous avons envoyé un brief détaillé à {email}",
"bookTitle": "Réservez une Consultation",
"bookSubtitle": "30 minutes pour discuter de votre brief avec notre équipe"
"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",
"noCommitment": "Sans engagement",
@@ -92,9 +95,63 @@
},
"aiToggle": "Ajouter l'Intégration IA",
"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",
"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": {
"eyebrow": "Notre Méthode",