Files
novalon-website/src/components/detail/list-page-hero.tsx
T
zhangxiangand阿睿 495f6d4eca refactor(animations): converge P1 motion durations to ≤300ms target
- 151 scripted + 1 manual duration fixes across 46 files
- framer entrance 0.5/0.6s -> 0.3s
- Tailwind hover -> duration-150, entrance -> duration-300
- preserve flip-clock/page-transition per semantic decision
- design-system.ts dead-code deferred
- P1 violations 156 -> 5; P0 remains 0; OK class 249 -> 400
- verified: tsc clean, eslint 0 errors
- verified: 130/130 jest suites, 1573 passed
- verified: visual regression 46/46 zero-pixel diff

Co-Authored-By: 阿睿 <workbuddy@tencent.com>
2026-09-03 21:34:52 +08:00

167 lines
6.4 KiB
TypeScript

'use client';
import { motion } from 'framer-motion';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
interface ListPageHeroProps {
title: string;
subtitle: string;
description?: string;
stats?: Array<{ value: string; label: string }>;
badge?: {
text: string;
variant?: 'brand' | 'blue' | 'green' | 'neutral';
};
}
const BADGE_VARIANTS = {
brand: {
bg: 'var(--color-brand-bg)',
text: 'var(--color-brand)',
border: 'rgba(var(--color-brand-rgb), 0.15)',
dot: 'var(--color-brand)',
},
blue: {
bg: 'rgba(var(--color-accent-blue-rgb), 0.08)',
text: 'var(--color-accent-blue)',
border: 'rgba(var(--color-accent-blue-rgb), 0.15)',
dot: 'var(--color-accent-blue)',
},
green: {
bg: 'rgba(34, 197, 94, 0.08)',
text: '#16a34a',
border: 'rgba(34, 197, 94, 0.15)',
dot: '#22c55e',
},
neutral: {
bg: 'var(--color-bg-section)',
text: 'var(--color-text-secondary)',
border: 'var(--color-border-primary)',
dot: 'var(--color-text-subtle)',
},
};
export function ListPageHero({
title,
subtitle,
description,
stats,
badge,
}: ListPageHeroProps) {
const shouldReduceMotion = useReducedMotion();
const badgeStyle = BADGE_VARIANTS[badge?.variant || 'brand'];
return (
<section className="relative overflow-hidden">
{/* Multi-layer background */}
<div className="absolute inset-0 bg-gradient-to-br from-[var(--color-bg-section)] via-[var(--color-bg-secondary)] to-[var(--color-bg-section)]" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_rgba(var(--color-brand-rgb),_0.05)_0%,_transparent_50%)]" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,_rgba(var(--color-brand-rgb),_0.03)_0%,_transparent_50%)]" />
{/* Subtle grid texture */}
<div
className="absolute inset-0 opacity-[0.03]"
style={{
backgroundImage:
'linear-gradient(rgba(var(--color-border-primary-rgb), 0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(var(--color-border-primary-rgb), 0.5) 1px, transparent 1px)',
backgroundSize: '60px 60px',
}}
aria-hidden="true"
/>
{/* Decorative accent line */}
<div className="absolute top-0 left-0 right-0 h-px" style={{ background: 'linear-gradient(90deg, transparent, var(--color-brand), transparent)' }} aria-hidden="true" />
<div className="container-wide relative z-10 py-20 lg:py-28">
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="max-w-4xl mx-auto text-center"
>
{/* Dynamic Badge */}
{badge && badge.text && (
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, scale: 0.95, y: 8 }}
whileInView={{ opacity: 1, scale: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.1, duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full text-sm font-medium mb-6 backdrop-blur-sm"
style={{ backgroundColor: badgeStyle.bg, color: badgeStyle.text, border: `1px solid ${badgeStyle.border}` }}
>
<span className="w-2 h-2 rounded-full animate-pulse" style={{ backgroundColor: badgeStyle.dot }} />
{badge?.text || ''}
</motion.div>
)}
{/* Title */}
<motion.h1
initial={shouldReduceMotion ? {} : { opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.15, duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight mb-6 text-[var(--color-text-primary)]"
>
{title}
</motion.h1>
{/* Subtitle */}
<motion.p
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.25, duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="text-lg md:text-xl max-w-2xl mx-auto leading-relaxed mb-4 text-[var(--color-text-secondary)]"
>
{subtitle}
</motion.p>
{/* Description */}
{description && (
<motion.p
initial={shouldReduceMotion ? {} : { opacity: 0, y: 18 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.35, duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="text-base max-w-xl mx-auto mb-10 text-[var(--color-text-muted)]"
>
{description}
</motion.p>
)}
{/* Stats Row */}
{stats && stats.length > 0 && (
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.4, duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="flex flex-wrap justify-center gap-8 md:gap-12 mt-12 pt-12 border-t border-[var(--color-border-primary)]"
>
{stats.map((stat, index) => (
<motion.div
key={stat.label}
initial={shouldReduceMotion ? {} : { opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ delay: 0.5 + index * 0.08, duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="text-center min-w-[100px]"
>
<div className="text-3xl md:text-4xl font-bold tracking-tight text-[var(--color-text-primary)]">
{stat.value}
</div>
<div className="text-sm mt-1 text-[var(--color-text-subtle)]">{stat.label}</div>
</motion.div>
))}
</motion.div>
)}
</motion.div>
</div>
{/* Bottom fade */}
<div className="absolute bottom-0 left-0 right-0 h-20 bg-gradient-to-t from-[var(--color-bg-primary)] to-transparent pointer-events-none" aria-hidden="true" />
</section>
);
}