polish: configurator visual redesign + copy cleanup
Some checks failed
Build & Push / build-and-push (push) Failing after 29s
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user