- Landing: extract 513-line monolith into 12 focused section components (Hero, StatsBand, FeatureGrid, HowItWorks, LivePreview, Comparison, UseCases, Testimonials, Pricing, FAQ, FinalCTA, TrustBar) - Auth pages: replace flat orange panel with animated live mock (real persona SVGs, typewriter messages, theme bars); Login label fixed to "Email or username"; Register wires ?plan= badge - Brand: new Logo SVG (C-arc + 3 figures + wordmark/tagline), expanded palette tokens, fluid display type scale, framer-motion shared variants - Header: scroll progress bar, removed non-functional language pill - Footer: fixed all dead links, legal stubs, new logo - Legal: /about /privacy /terms /cookies /gdpr real pages added - Email: FROM_EMAIL default fixed to noreply@ai-impress.com (verified apex domain), HTML template rewritten to match new brand - Tooling: Playwright screenshot script for visual self-check Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
86 lines
2.7 KiB
TypeScript
86 lines
2.7 KiB
TypeScript
import { motion, useReducedMotion, useSpring, useTransform, useInView } from 'framer-motion';
|
||
import { useEffect, useRef } from 'react';
|
||
import { Clock, DollarSign, Globe } from 'lucide-react';
|
||
import { fadeUp, staggerChildren, viewportOnce } from '@/lib/motion';
|
||
|
||
const STATS = [
|
||
{
|
||
icon: Clock,
|
||
prefix: '',
|
||
value: 10,
|
||
suffix: '×',
|
||
label: 'FASTER',
|
||
sub: 'Insights in hours, not weeks',
|
||
},
|
||
{
|
||
icon: DollarSign,
|
||
prefix: '',
|
||
value: 99,
|
||
suffix: '%',
|
||
label: 'CHEAPER',
|
||
sub: 'No recruiter, incentives, or no-shows',
|
||
},
|
||
{
|
||
icon: Globe,
|
||
prefix: '',
|
||
value: 24,
|
||
suffix: '/7',
|
||
label: 'SCALE',
|
||
sub: 'Run 50 sessions in parallel',
|
||
},
|
||
];
|
||
|
||
function AnimatedNumber({ value, shouldAnimate }: { value: number; shouldAnimate: boolean }) {
|
||
const ref = useRef<HTMLSpanElement>(null);
|
||
const isInView = useInView(ref, { once: true, margin: '-40px' });
|
||
const spring = useSpring(0, { stiffness: 60, damping: 20 });
|
||
const display = useTransform(spring, (v) => Math.round(v).toString());
|
||
|
||
useEffect(() => {
|
||
if (isInView && shouldAnimate) {
|
||
spring.set(value);
|
||
} else if (!shouldAnimate) {
|
||
spring.set(value);
|
||
}
|
||
}, [isInView, shouldAnimate, spring, value]);
|
||
|
||
return (
|
||
<motion.span ref={ref}>
|
||
{shouldAnimate ? <motion.span>{display}</motion.span> : <span>{value}</span>}
|
||
</motion.span>
|
||
);
|
||
}
|
||
|
||
export default function StatsBand() {
|
||
const shouldReduce = useReducedMotion();
|
||
const shouldAnimate = !shouldReduce;
|
||
|
||
return (
|
||
<section className="py-20 px-6 bg-[hsl(var(--brand-charcoal))]">
|
||
<div className="max-w-5xl mx-auto">
|
||
<motion.div
|
||
variants={staggerChildren}
|
||
initial="hidden"
|
||
whileInView="visible"
|
||
viewport={viewportOnce}
|
||
className="grid grid-cols-1 md:grid-cols-3 gap-5"
|
||
>
|
||
{STATS.map(({ icon: Icon, prefix, value, suffix, label, sub }) => (
|
||
<motion.div key={label} variants={fadeUp} className="corner-card p-8 group">
|
||
<div className="h-10 w-10 rounded-xl bg-primary/15 flex items-center justify-center mb-4 group-hover:bg-primary/25 transition-colors">
|
||
<Icon className="h-5 w-5 text-primary" />
|
||
</div>
|
||
<div className="font-display font-black text-5xl text-foreground mb-1">
|
||
{prefix}
|
||
<AnimatedNumber value={value} shouldAnimate={shouldAnimate} />
|
||
{suffix}
|
||
</div>
|
||
<div className="text-xs font-bold tracking-widest text-primary uppercase mb-2">{label}</div>
|
||
<p className="text-sm text-muted-foreground">{sub}</p>
|
||
</motion.div>
|
||
))}
|
||
</motion.div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|