Files
novalon-website/src/components/sections/case-detail-page.tsx
T

544 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { motion, useScroll, useTransform } from 'framer-motion';
import { useRef, useState, useEffect } from 'react';
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { StaticLink } from '@/components/ui/static-link';
import { SectionHeader } from '@/components/sections/section-header';
import { ServiceCard } from '@/components/sections/service-card';
import { ScrollProgress } from '@/components/ui/scroll-progress';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { ArrowRight, Quote, Building2, Target, Lightbulb, TrendingUp } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { AnimatedCounter } from '@/components/ui/animated-counter';
const EASE = [0.22, 1, 0.36, 1] as const;
export interface CaseDetailData {
id: string;
client: string;
industry: string;
title: string;
subtitle: string;
challenge: string;
solution: string;
result: string;
metrics: { value: string; label: string; highlight?: boolean }[];
timeline: { phase: string; duration: string; description: string }[];
services: { id: string; title: string; description: string }[];
testimonial?: { quote: string; author: string; role: string };
projectDuration?: string;
departments?: string[];
dataScale?: string;
businessProblem?: string;
verified?: boolean;
}
interface CaseDetailHeroProps {
data: CaseDetailData;
}
function CaseDetailHero({ data }: CaseDetailHeroProps) {
const shouldReduceMotion = useReducedMotion();
const heroRef = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: heroRef,
offset: ['start start', 'end start'],
});
const opacity = useTransform(scrollYProgress, [0, 0.6], [1, 0]);
const contentY = useTransform(scrollYProgress, [0, 1], [0, 50]);
return (
<section
ref={heroRef}
className="relative min-h-[85svh] flex items-center overflow-hidden pt-20"
style={{ background: 'var(--color-bg-primary)' }}
>
<motion.div
className="relative z-10 w-full max-w-[1280px] mx-auto px-5 sm:px-8 lg:px-16 py-20 md:py-28"
style={{ opacity, y: shouldReduceMotion ? 0 : contentY }}
>
<motion.div
className="flex flex-wrap items-center gap-3 mb-8"
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: EASE }}
>
<span
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full text-xs font-medium"
style={{
backgroundColor: 'var(--color-brand-soft)',
color: 'var(--color-brand)',
}}
>
<Building2 className="w-3.5 h-3.5" />
{data.industry}
</span>
<span className="text-[12px]" style={{ color: 'var(--color-text-muted)' }}>
{data.client}
</span>
</motion.div>
{/* Bain-style: big result number first */}
<motion.div
className="mb-8"
initial={shouldReduceMotion ? {} : { opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.1, ease: EASE }}
>
<div
className="font-sans text-[clamp(56px,12vw,120px)] font-black leading-none tracking-[-2px] mb-2"
style={{ color: 'var(--color-brand)' }}
>
{data.metrics[0]?.value || '40%'}
</div>
<div
className="text-lg md:text-xl font-medium"
style={{ color: 'var(--color-text-secondary)' }}
>
{data.metrics[0]?.label || '核心成果提升'}
</div>
</motion.div>
<motion.h1
className="font-sans text-[clamp(28px,5vw,48px)] font-bold leading-[1.15] tracking-[-0.5px] text-[var(--color-text-primary)] mb-6 max-w-3xl"
initial={shouldReduceMotion ? {} : { opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, delay: 0.25, ease: EASE }}
>
{data.title}
</motion.h1>
<motion.p
className="text-base md:text-lg max-w-2xl leading-relaxed mb-10 font-light"
style={{ color: 'var(--color-text-secondary)' }}
initial={shouldReduceMotion ? {} : { opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.4, ease: EASE }}
>
{data.subtitle}
</motion.p>
{/* Quick stats row */}
<motion.div
className="grid grid-cols-3 gap-6 sm:gap-10 max-w-lg mb-10"
initial={shouldReduceMotion ? {} : { opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.55, ease: EASE }}
>
{data.metrics.slice(1, 4).map((m, i) => (
<div key={i}>
<div
className="font-sans tabular-nums text-[28px] sm:text-[32px] font-bold leading-none mb-1.5"
style={{ color: m.highlight ? 'var(--color-brand)' : 'var(--color-text-primary)' }}
>
{m.value}
</div>
<div className="text-[11px] sm:text-[12px]" style={{ color: 'var(--color-text-muted)' }}>
{m.label}
</div>
</div>
))}
</motion.div>
{/* Verifiable dimensions */}
<motion.div
className="flex flex-wrap gap-x-6 gap-y-3 text-sm mb-10 border-y border-border-primary py-4"
initial={shouldReduceMotion ? {} : { opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.65, ease: EASE }}
>
{data.projectDuration && (
<div style={{ color: 'var(--color-text-secondary)' }}>
<span style={{ color: 'var(--color-text-muted)' }}>项目周期</span> {data.projectDuration}
</div>
)}
{data.departments && data.departments.length > 0 && (
<div style={{ color: 'var(--color-text-secondary)' }}>
<span style={{ color: 'var(--color-text-muted)' }}>涉及部门</span> {data.departments.join('、')}
</div>
)}
{data.dataScale && (
<div style={{ color: 'var(--color-text-secondary)' }}>
<span style={{ color: 'var(--color-text-muted)' }}>数据规模</span> {data.dataScale}
</div>
)}
{data.verified && (
<div style={{ color: 'var(--color-brand)' }}> 客户授权公开</div>
)}
</motion.div>
<motion.div
className="flex flex-wrap gap-4"
initial={shouldReduceMotion ? {} : { opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.8, ease: EASE }}
>
<Button size="lg" asChild>
<StaticLink href="/contact">
咨询类似方案
<ArrowRight className="w-4 h-4 ml-2" />
</StaticLink>
</Button>
<Button size="lg" variant="outline" asChild>
<a href="#solution">查看方案</a>
</Button>
</motion.div>
</motion.div>
</section>
);
}
function ChallengeSection({ data }: { data: CaseDetailData }) {
return (
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44" style={{ background: 'var(--color-bg-primary)' }}>
<div className="max-w-[1280px] mx-auto px-5 sm:px-8 lg:px-16">
<div className="grid lg:grid-cols-12 gap-12 lg:gap-16 items-start">
<ScrollReveal className="lg:col-span-5">
<SectionHeader
label="Challenge"
title="业务"
highlight="挑战"
desc="每个项目,都从一个真实的业务痛点出发"
/>
</ScrollReveal>
<ScrollReveal delay={0.1} className="lg:col-span-7">
{data.businessProblem && (
<div
className="mb-6 pl-4 border-l-2"
style={{ borderColor: 'var(--color-brand)' }}
>
<div
className="text-xs font-medium mb-2"
style={{ color: 'var(--color-text-muted)' }}
>
具体业务问题
</div>
<p
className="text-base md:text-lg leading-relaxed"
style={{ color: 'var(--color-text-primary)' }}
>
{data.businessProblem}
</p>
</div>
)}
<div
className="p-8 sm:p-10 lg:p-12"
style={{
backgroundColor: 'var(--color-bg-secondary)',
}}
>
<div className="flex items-center gap-3 mb-6">
<Target className="w-6 h-6" style={{ color: 'var(--color-brand)' }} />
<span
className="text-sm font-semibold"
style={{ color: 'var(--color-brand)' }}
>
面临的挑战
</span>
</div>
<p
className="text-lg md:text-xl leading-relaxed mb-6"
style={{ color: 'var(--color-text-primary)' }}
>
{data.challenge}
</p>
</div>
</ScrollReveal>
</div>
</div>
</section>
);
}
function SolutionSection({ data }: { data: CaseDetailData }) {
return (
<section
id="solution"
className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden"
style={{ background: 'var(--color-bg-secondary)' }}
>
<div className="max-w-[1280px] mx-auto px-5 sm:px-8 lg:px-16">
<ScrollReveal className="mb-16 sm:mb-20 max-w-2xl">
<SectionHeader
label="Solution"
title="我们的"
highlight="方案"
desc="针对性的解决方案,每一步都有明确的目标"
/>
</ScrollReveal>
<div className="grid lg:grid-cols-2 gap-0" style={{ border: '1px solid var(--color-border-primary)' }}>
<div style={{ backgroundColor: 'var(--color-bg-primary)' }}>
<div className="p-8 sm:p-10 lg:p-12">
<div className="flex items-center gap-3 mb-6">
<Lightbulb className="w-6 h-6" style={{ color: 'var(--color-brand)' }} />
<span
className="text-sm font-semibold"
style={{ color: 'var(--color-brand)' }}
>
核心思路
</span>
</div>
<p className="text-base md:text-lg leading-relaxed" style={{ color: 'var(--color-text-secondary)' }}>
{data.solution}
</p>
</div>
</div>
<div className="p-8 sm:p-10 lg:p-12" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
<div className="flex items-center gap-3 mb-6">
<TrendingUp className="w-6 h-6" style={{ color: 'var(--color-brand)' }} />
<span
className="text-sm font-semibold"
style={{ color: 'var(--color-brand)' }}
>
实施路径
</span>
</div>
<div className="space-y-5">
{data.timeline.map((t, i) => (
<div key={i} className="flex gap-4">
<div
className="shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold"
style={{
backgroundColor: 'var(--color-brand-soft)',
color: 'var(--color-brand)',
}}
>
{i + 1}
</div>
<div>
<div className="flex items-center gap-3 mb-1">
<h4 className="text-[var(--color-text-primary)] font-semibold">{t.phase}</h4>
<span className="text-[11px] px-2 py-0.5 rounded" style={{ backgroundColor: 'var(--color-bg-tertiary)', color: 'var(--color-text-muted)' }}>
{t.duration}
</span>
</div>
<p className="text-sm leading-relaxed" style={{ color: 'var(--color-text-secondary)' }}>
{t.description}
</p>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</section>
);
}
function ResultsSection({ data }: { data: CaseDetailData }) {
const resultsRef = useRef<HTMLDivElement>(null);
const [inView, setInView] = useState(false);
const shouldReduceMotion = useReducedMotion();
useEffect(() => {
if (!resultsRef.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
setInView(true);
observer.disconnect();
}
},
{ threshold: 0.2 }
);
observer.observe(resultsRef.current);
return () => observer.disconnect();
}, []);
const parseValue = (val: string): { num: number; suffix: string; isNumeric: boolean } => {
const match = val.match(/^([\d.]+)(.*)$/);
if (match && match[1] && !isNaN(parseFloat(match[1]))) {
return { num: parseFloat(match[1]), suffix: match[2] || '', isNumeric: true };
}
return { num: 0, suffix: val, isNumeric: false };
};
return (
<section
ref={resultsRef}
className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden"
style={{ background: 'var(--color-brand)' }}
>
{/* Background decoration */}
<div className="absolute inset-0 pointer-events-none">
<div
className="absolute w-[600px] h-[600px] rounded-full opacity-[0.15] blur-[120px]"
style={{
background: 'radial-gradient(circle, rgba(255,255,255,0.8), transparent 70%)',
top: '-30%',
right: '-10%',
}}
/>
<div
className="absolute w-[400px] h-[400px] rounded-full opacity-[0.1] blur-[100px]"
style={{
background: 'radial-gradient(circle, rgba(255,255,255,0.6), transparent 70%)',
bottom: '-20%',
left: '10%',
}}
/>
</div>
<div className="max-w-[1280px] mx-auto px-5 sm:px-8 lg:px-16 relative z-10">
<ScrollReveal className="mb-16 sm:mb-20 text-center max-w-3xl mx-auto">
<div
className="text-xs font-medium mb-4"
style={{ color: 'rgba(255,255,255,0.6)' }}
>
项目成果
</div>
<h2 className="font-sans text-3xl sm:text-4xl md:text-5xl font-bold text-white tracking-tight leading-tight mb-6">
可量化的业务成果
</h2>
<p className="text-lg text-white/60 leading-relaxed max-w-2xl mx-auto">
{data.result}
</p>
</ScrollReveal>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-px mb-16" style={{ backgroundColor: 'rgba(255,255,255,0.15)' }}>
{data.metrics.slice(0, 4).map((m, i) => {
const { num, suffix, isNumeric } = parseValue(m.value);
const decimals = String(num).includes('.') ? String(num).split('.')[1]?.length ?? 0 : 0;
return (
<div
key={i}
className="p-10 sm:p-12 text-center transition-all duration-500 hover:bg-white/[0.05] group relative overflow-hidden"
style={{ backgroundColor: 'var(--color-brand)' }}
>
{m.highlight && (
<div className="absolute top-4 right-4 px-2 py-1 text-[10px] font-bold text-brand bg-white rounded-full">
核心指标
</div>
)}
<div className="font-sans tabular-nums text-[clamp(40px,8vw,64px)] font-black leading-none mb-3 text-white transition-transform duration-500 group-hover:scale-110">
{isNumeric && inView && !shouldReduceMotion ? (
<AnimatedCounter
value={num}
decimals={decimals}
suffix={suffix}
startOnView={false}
duration={2000}
/>
) : (
<>
{isNumeric ? num : suffix}
{isNumeric && suffix && (
<span className="text-[0.5em] font-bold text-white/80 ml-0.5">{suffix}</span>
)}
</>
)}
</div>
<div className="text-sm md:text-base text-white/60 font-medium">
{m.label}
</div>
</div>
);
})}
</div>
{data.testimonial && (
<ScrollReveal delay={0.3}>
<div className="max-w-4xl mx-auto">
<div className="relative p-8 sm:p-10 lg:p-14 border border-white/20 bg-white/[0.03] backdrop-blur-sm">
<Quote className="w-12 h-12 absolute -top-6 left-8 text-white/20" />
<blockquote className="text-xl sm:text-2xl md:text-3xl font-medium leading-relaxed text-white mb-8">
&ldquo;{data.testimonial.quote}&rdquo;
</blockquote>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-white/10 flex items-center justify-center">
<Building2 className="w-5 h-5 text-white/60" />
</div>
<div>
<div className="font-semibold text-white text-lg">{data.testimonial.author}</div>
<div className="text-sm text-white/50">{data.testimonial.role}</div>
</div>
</div>
</div>
</div>
</ScrollReveal>
)}
</div>
</section>
);
}
function RelatedServicesSection({ services }: { services: CaseDetailData['services'] }) {
return (
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44" style={{ background: 'var(--color-bg-primary)' }}>
<div className="max-w-[1280px] mx-auto px-5 sm:px-8 lg:px-16">
<ScrollReveal className="mb-16 sm:mb-20 max-w-2xl">
<SectionHeader
label="Related"
title="相关"
highlight="服务"
desc="类似的项目,我们还提供这些专业服务"
/>
</ScrollReveal>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
<StaggerReveal staggerDelay={0.08}>
{services.slice(0, 3).map((s) => (
<ServiceCard
key={s.id}
title={s.title}
description={s.description}
href={`/services/${s.id}`}
/>
))}
</StaggerReveal>
</div>
</div>
</section>
);
}
function CTASection() {
return (
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden" style={{ background: 'var(--color-bg-secondary)' }}>
<div className="max-w-[800px] mx-auto px-5 sm:px-8 lg:px-16 text-center relative z-10">
<ScrollReveal>
<h2 className="font-sans text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-[var(--color-text-primary)] leading-tight tracking-tight mb-6">
您也想实现类似的
<span style={{ color: 'var(--color-brand)' }}> 业务突破</span>
</h2>
<p className="text-lg md:text-xl mb-10 font-light" style={{ color: 'var(--color-text-secondary)' }}>
免费咨询,48小时内给出初步方案建议
</p>
<div className="flex flex-col sm:flex-row justify-center gap-4">
<Button size="lg" asChild>
<StaticLink href="/contact">
免费咨询
<ArrowRight className="w-4 h-4 ml-2" />
</StaticLink>
</Button>
<Button size="lg" variant="outline" asChild>
<StaticLink href="/cases">查看更多案例</StaticLink>
</Button>
</div>
</ScrollReveal>
</div>
</section>
);
}
export default function CaseDetailPage({ data }: { data: CaseDetailData }) {
return (
<main className="min-h-screen">
<ScrollProgress />
<CaseDetailHero data={data} />
<ChallengeSection data={data} />
<SolutionSection data={data} />
<ResultsSection data={data} />
<RelatedServicesSection services={data.services} />
<CTASection />
</main>
);
}