polish: configurator visual redesign + copy cleanup
Some checks failed
Build & Push / build-and-push (push) Failing after 29s

- Redesigned progress bar: numbered circles with connectors, checkmarks on completion,
  line only fills up to current step, centered layout
- Redesigned service cards: horizontal 3-column grid, border-based selection state,
  check badges, staggered entrance animations
- Fixed card border clipping (ring → border)
- Fixed height stability (selection hint stays in DOM)
- Removed all em dashes, replaced with regular dashes
- Removed all Côte d'Azur / Riviera / France location references sitewide
- Philosophy quote attributed to "Matt Ciaccio, Founder"
- Footer: "American-founded. Serving clients worldwide."
- Email template: LetsBe Solutions LLC
- AI prompt: removed Côte d'Azur reference

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 18:44:37 +01:00
parent bbe5b6c67e
commit bdb664633d
7 changed files with 194 additions and 218 deletions

View File

@@ -2,6 +2,7 @@
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import { Check } from 'lucide-react';
interface ProgressBarProps {
currentStep: 1 | 2 | 3;
@@ -9,31 +10,68 @@ interface ProgressBarProps {
className?: string;
}
const STEP_LABELS = ['01', '02', '03'];
export default function ProgressBar({ currentStep, className }: ProgressBarProps) {
return (
<div className={cn('flex gap-1.5', className)} role="progressbar" aria-valuenow={currentStep} aria-valuemin={1} aria-valuemax={3}>
{([1, 2, 3] as const).map((step) => {
const isActive = step <= currentStep;
return (
<div
key={step}
className="relative flex-1 h-1 rounded-full bg-outline-variant/40 overflow-hidden"
>
<motion.div
className="absolute inset-0 rounded-full bg-gradient-to-r from-primary-dark to-primary"
initial={{ scaleX: 0 }}
animate={{ scaleX: isActive ? 1 : 0 }}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
delay: isActive ? (step - 1) * 0.05 : 0,
}}
style={{ transformOrigin: 'left' }}
/>
</div>
);
})}
<div
className={cn('flex items-center justify-center', className)}
role="progressbar"
aria-valuenow={currentStep}
aria-valuemin={1}
aria-valuemax={3}
>
<div className="flex items-center gap-0 w-full max-w-xs">
{([1, 2, 3] as const).map((step) => {
const isComplete = step < currentStep;
const isActive = step === currentStep;
const isUpcoming = step > currentStep;
const showFilledLine = step < currentStep;
return (
<div key={step} className="flex items-center flex-1 last:flex-none">
{/* Step circle */}
<motion.div
className={cn(
'relative flex items-center justify-center w-7 h-7 rounded-full text-[11px] font-semibold flex-shrink-0 transition-colors duration-300',
isComplete && 'bg-primary text-white',
isActive && 'bg-primary-dark text-white shadow-[0_0_0_3px_rgba(91,164,217,0.15)]',
isUpcoming && 'bg-surface-low text-outline border border-outline-variant/40',
)}
>
{isComplete ? (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 400, damping: 20 }}
>
<Check size={13} strokeWidth={2.5} />
</motion.div>
) : (
<span>{STEP_LABELS[step - 1]}</span>
)}
</motion.div>
{/* Connector line — only between steps, fills only when step is complete */}
{step < 3 && (
<div className="flex-1 h-px mx-2 bg-outline-variant/25 relative overflow-hidden">
<motion.div
className="absolute inset-y-0 left-0 right-0 bg-primary"
initial={{ scaleX: 0 }}
animate={{ scaleX: showFilledLine ? 1 : 0 }}
transition={{
type: 'spring',
stiffness: 200,
damping: 25,
}}
style={{ transformOrigin: 'left' }}
/>
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -27,7 +27,7 @@ const SERVICES: ServiceOption[] = [
const AI_TYPE_IDS = ['teammate', 'customer-facing', 'data-intelligence', 'notsure'] as const;
// ─── Service Card ────────────────────────────────────────────────────────────
// ─── Service Card (horizontal layout) ────────────────────────────────────────
function ServiceCard({
option,
@@ -35,12 +35,14 @@ function ServiceCard({
onToggle,
title,
description,
index,
}: {
option: ServiceOption;
selected: boolean;
onToggle: () => void;
title: string;
description: string;
index: number;
}) {
const Icon = option.icon;
@@ -48,130 +50,61 @@ function ServiceCard({
<motion.button
type="button"
onClick={onToggle}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.06, duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
whileTap={{ scale: 0.97 }}
className={cn(
'group relative w-full text-left rounded-2xl p-5 transition-all duration-200',
'group relative flex flex-col items-center text-center rounded-xl px-4 py-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/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',
? 'bg-primary/[0.06] border-2 border-primary/40 shadow-[0_4px_16px_rgba(0,100,148,0.1)]'
: 'bg-white border-2 border-outline-variant/20 hover:border-outline-variant/40 hover:shadow-[0_2px_12px_rgba(25,28,29,0.06)]',
)}
>
<div className="flex items-start gap-4">
{/* Icon + check badge */}
<div className="relative mb-2.5">
<div
className={cn(
'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/12',
'w-10 h-10 rounded-lg flex items-center justify-center transition-colors duration-200',
selected ? 'bg-primary/12' : 'bg-surface-low group-hover:bg-primary/6',
)}
>
<Icon size={20} strokeWidth={1.5} />
</div>
<div className="flex-1 min-w-0">
<p
<Icon
size={20}
strokeWidth={1.5}
className={cn(
'text-sm font-semibold leading-tight mb-1 transition-colors duration-200',
selected ? 'text-primary-dark' : 'text-on-surface',
'transition-colors duration-200',
selected ? 'text-primary-dark' : 'text-outline group-hover:text-primary',
)}
>
{title}
</p>
<p className="text-xs text-outline leading-relaxed">{description}</p>
</div>
<div className="flex-shrink-0 mt-0.5">
<motion.div
className={cn(
'w-5 h-5 rounded-full border-2 flex items-center justify-center',
selected ? 'border-primary bg-primary' : 'border-outline-variant bg-transparent',
)}
animate={
selected
? { scale: 1, borderColor: '#5BA4D9', backgroundColor: '#5BA4D9' }
: { scale: 1, borderColor: '#c2c7ce', backgroundColor: 'transparent' }
}
transition={springTransition}
>
<AnimatePresence>
{selected && (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={springTransition}
>
<Check size={11} strokeWidth={3} className="text-white" />
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
</div>
</motion.button>
);
}
// ─── AI Toggle ───────────────────────────────────────────────────────────────
function AIToggle({
enabled,
onToggle,
label,
description,
}: {
enabled: boolean;
onToggle: () => void;
label: string;
description: string;
}) {
return (
<button
type="button"
onClick={onToggle}
className={cn(
'flex items-center gap-3 w-full text-left rounded-xl px-4 py-3',
'transition-all duration-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
enabled
? 'bg-primary/5 shadow-card'
: 'bg-surface-high shadow-subtle hover:shadow-card',
)}
>
<div className="flex-1 min-w-0">
<span
className={cn(
'text-sm font-semibold transition-colors duration-200 flex items-center gap-1.5',
enabled ? 'text-primary-dark' : 'text-on-surface',
)}
>
<Sparkles
size={14}
strokeWidth={1.75}
className={cn(
'flex-shrink-0 transition-colors duration-200',
enabled ? 'text-primary' : 'text-outline',
)}
aria-hidden="true"
/>
{label}
</span>
<p className="text-xs text-outline mt-0.5">{description}</p>
</div>
{/* Check badge — appears on selection */}
<AnimatePresence>
{selected && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
transition={springTransition}
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-primary-dark flex items-center justify-center"
>
<Check size={10} strokeWidth={3} className="text-white" />
</motion.div>
)}
</AnimatePresence>
</div>
<div
<p
className={cn(
'flex-shrink-0 w-10 h-6 rounded-full relative transition-colors duration-300',
enabled ? 'bg-primary' : 'bg-outline-variant',
'text-sm font-semibold leading-tight mb-1 transition-colors duration-200',
selected ? 'text-primary-dark' : 'text-on-surface',
)}
>
<motion.div
className="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow-sm"
animate={{ x: enabled ? 18 : 2 }}
transition={springTransition}
/>
</div>
</button>
{title}
</p>
<p className="text-[11px] text-outline leading-relaxed">{description}</p>
</motion.button>
);
}
@@ -209,19 +142,19 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
const canProceed = formData.services.length > 0;
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-5">
<ProgressBar currentStep={1} totalSteps={3} />
<div>
<div className="text-center">
<h3 className="font-serif text-2xl font-semibold tracking-headline text-on-surface">
{t('step1.title')}
</h3>
<p className="mt-1 text-sm text-outline">{t('step1.subtitle')}</p>
</div>
{/* Service cards */}
<div className="flex flex-col gap-3">
{SERVICES.map((option) => (
{/* Service cards — horizontal grid */}
<div className="grid grid-cols-3 gap-3">
{SERVICES.map((option, index) => (
<ServiceCard
key={option.id}
option={option}
@@ -229,31 +162,49 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
onToggle={() => toggleService(option.id)}
title={t(option.titleKey)}
description={t(option.descriptionKey)}
index={index}
/>
))}
<AnimatePresence>
{formData.services.length === 0 && (
<motion.p
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.2 }}
className="text-xs text-outline/60 text-center pt-1 select-none"
>
{t('selectService')}
</motion.p>
)}
</AnimatePresence>
</div>
{/* AI Toggle + Type Selection */}
<div className="flex flex-col gap-3">
<AIToggle
enabled={formData.aiEnabled}
onToggle={toggleAI}
label={t('aiToggle')}
description={t('aiDescription')}
/>
<p
className={cn(
'text-xs text-center select-none -mt-2 transition-opacity duration-200',
formData.services.length === 0 ? 'text-outline/50' : 'opacity-0',
)}
aria-hidden={formData.services.length > 0}
>
{t('selectService')}
</p>
{/* AI Enhancement */}
<div className="border-t border-dashed border-outline-variant/30 pt-4">
<button
type="button"
onClick={toggleAI}
className={cn(
'flex items-center gap-3 w-full text-left rounded-xl px-4 py-3',
'transition-all duration-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
formData.aiEnabled ? 'bg-primary/[0.04]' : 'hover:bg-surface-low',
)}
>
<div className={cn(
'flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center transition-colors duration-200',
formData.aiEnabled ? 'bg-primary/12' : 'bg-surface-low',
)}>
<Sparkles size={16} strokeWidth={1.75} className={cn('transition-colors', formData.aiEnabled ? 'text-primary-dark' : 'text-outline')} />
</div>
<div className="flex-1 min-w-0">
<span className={cn('text-sm font-semibold transition-colors', formData.aiEnabled ? 'text-primary-dark' : 'text-on-surface')}>
{t('aiToggle')}
</span>
<p className="text-xs text-outline mt-0.5">{t('aiDescription')}</p>
</div>
<div className={cn('flex-shrink-0 w-11 h-6 rounded-full relative transition-colors duration-300', formData.aiEnabled ? 'bg-primary-dark' : 'bg-outline-variant/60')}>
<motion.div className="absolute top-[3px] w-[18px] h-[18px] rounded-full bg-white shadow-sm" animate={{ x: formData.aiEnabled ? 20 : 3 }} transition={springTransition} />
</div>
</button>
<AnimatePresence>
{formData.aiEnabled && (
@@ -264,29 +215,21 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
<div className="pt-1 flex flex-col gap-3">
<div className="pt-3 pb-1 flex flex-col gap-2.5">
<div className="flex flex-wrap gap-2">
{AI_TYPE_IDS.map((aiId, index) => (
<motion.div
key={aiId}
initial={{ opacity: 0, y: 8 }}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{
delay: index * 0.06,
duration: 0.3,
ease: [0.16, 1, 0.3, 1],
}}
transition={{ delay: index * 0.05, duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
>
<Chip
active={formData.aiTypes.includes(aiId)}
onClick={() => toggleAIType(aiId)}
>
<Chip active={formData.aiTypes.includes(aiId)} onClick={() => toggleAIType(aiId)}>
{t(`aiTypes.${aiId}.title`)}
</Chip>
</motion.div>
))}
</div>
<AnimatePresence mode="wait">
{formData.aiTypes.length > 0 && (
<motion.div
@@ -295,7 +238,7 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.2 }}
className="flex flex-col gap-1.5 px-1"
className="flex flex-col gap-1 px-1"
>
{formData.aiTypes.map((aiId) => (
<p key={aiId} className="text-xs text-outline leading-relaxed">
@@ -312,13 +255,8 @@ export default function StepServices({ formData, setFormData, onNext }: StepProp
</AnimatePresence>
</div>
<Button
variant="primary"
arrow
disabled={!canProceed}
onClick={onNext}
className="w-full"
>
{/* CTA */}
<Button variant="primary" arrow disabled={!canProceed} onClick={onNext} className="w-full">
{t('nextStep')}
</Button>
</div>

View File

@@ -131,7 +131,7 @@ export default function Configurator() {
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">
<div className="relative rounded-2xl bg-surface-high shadow-[0_20px_50px_rgba(25,28,29,0.08)] p-6 sm:p-8 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"