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

@@ -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)',