Files
novalon-website/src/app/(marketing)/about/about-content-v4.tsx
T
张翔 b5245f9aa2 feat(cms): 添加 CMS 内容管理系统与 Admin 管理后台
- 新增 Prisma + SQLite 数据库模型 (Category, Content, Media, User 等)
- 新增 Admin 管理后台 (认证、内容管理、媒体管理)
- 新增 CMS API 路由 (CRUD, 草稿/发布, 重新验证)
- 新增 CMS 内容版本的历史归档页面
- 新增 components/cms 内容渲染组件
- 新增 components/admin 管理后台 UI 组件
- 更新 Contact API 路由
2026-07-07 06:53:58 +08:00

421 lines
18 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 } from 'framer-motion';
import {
Trophy,
Shield, Award, Zap,
ArrowUpRight,
type LucideIcon,
} from 'lucide-react';
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
/** Icon name-to-component mapping for CMS string-based icons */
const ICON_MAP: Record<string, LucideIcon> = {
Shield,
Award,
Zap,
Trophy,
};
const DEFAULT_CERT_ICON_NAMES = ['Shield', 'Award', 'Zap', 'Trophy'] as const;
interface AboutData {
heroSubtitle?: string;
heroTitle?: string;
heroDescription?: string;
heroPrimaryCtaLabel?: string;
heroPrimaryCtaHref?: string;
heroSecondaryCtaLabel?: string;
heroSecondaryCtaHref?: string;
whoWeAreTitle?: string;
whoWeAreDescription?: string;
promiseText?: string;
coreValues?: { number: string; title: string; description: string }[];
milestones?: { year: string; title: string; description: string; highlight: string }[];
keyMetrics?: { value: string; label: string; sub: string }[];
certifications?: { name: string; description: string }[];
partners?: string[];
ctaTitle?: string;
ctaDescription?: string;
ctaPrimaryLabel?: string;
ctaPrimaryHref?: string;
ctaSecondaryLabel?: string;
ctaSecondaryHref?: string;
}
function HeroSection({ data }: { data: AboutData }) {
const titleLines = (data.heroTitle || '').split('\n');
return (
<section className="relative min-h-[85vh] flex items-center overflow-hidden bg-white">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-28 sm:py-36 md:py-44 lg:py-56">
<div className="max-w-4xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.1, ease: EASE_OUT }}
className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-8"
>
{data.heroSubtitle || ''}
</motion.div>
<motion.h1
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, delay: 0.2, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl font-black text-ink leading-[0.88] tracking-tightest mb-10 sm:mb-12 md:mb-16"
>
{titleLines.map((line, i) => (
<div key={i} className={`block ${i > 0 ? 'mt-4 sm:mt-6' : ''}`}>
{i === 1 ? (
<span className="relative inline-block">
<span className="relative text-brand font-black">
{line}
<motion.span
className="absolute -bottom-2 left-0 w-full h-[3px] bg-brand"
style={{ transformOrigin: 'left' }}
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 0.8, delay: 1.2, ease: EASE_OUT }}
/>
</span>
</span>
) : (
line
)}
</div>
))}
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.5, ease: EASE_OUT }}
className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-12 sm:mb-16"
>
{data.heroDescription || ''}
</motion.p>
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.7, ease: EASE_OUT }}
className="flex flex-col sm:flex-row gap-4 sm:gap-6"
>
<a
href={data.heroPrimaryCtaHref || '#'}
className="group inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base bg-brand text-white transition-all duration-300 hover:bg-brand/90 min-h-[52px] touch-manipulation"
>
{data.heroPrimaryCtaLabel || ''}
<ArrowUpRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</a>
<a
href={data.heroSecondaryCtaHref || '#'}
className="inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base text-ink border border-border-primary hover:border-border-secondary hover:bg-bg-secondary transition-all duration-300 min-h-[52px] touch-manipulation"
>
{data.heroSecondaryCtaLabel || ''}
</a>
</motion.div>
</div>
</div>
</section>
);
}
function MetricsStrip({ data }: { data: AboutData }) {
const metrics = data.keyMetrics ?? [];
if (metrics.length === 0) return null;
return (
<section className="relative py-16 sm:py-20 md:py-24 overflow-hidden bg-bg-secondary">
<div className="absolute top-0 left-0 right-0 h-px bg-border-primary" />
<div className="absolute bottom-0 left-0 right-0 h-px bg-border-primary" />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8 md:gap-12">
{metrics.map((metric, i) => (
<motion.div
key={metric.label}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.6, delay: i * 0.08, ease: EASE_OUT }}
className="text-center lg:text-left"
>
<div className={`text-3xl sm:text-4xl md:text-5xl font-black tracking-tight mb-2 ${i === 0 ? 'text-brand' : 'text-ink'}`}>
{metric.value}
</div>
<div className="text-sm font-medium text-text-secondary mb-1">{metric.label}</div>
<div className="text-xs text-text-muted">{metric.sub}</div>
</motion.div>
))}
</div>
</div>
</section>
);
}
function WhoWeAreSection({ data }: { data: AboutData }) {
const values = data.coreValues ?? [];
return (
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-white">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<div className="grid lg:grid-cols-12 gap-12 sm:gap-16 md:gap-20 items-start">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.8, ease: EASE_OUT }}
className="lg:col-span-5"
>
<div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-6">
Who We Are
</div>
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95] mb-8">
{data.whoWeAreTitle || ''}
</h2>
<p className="text-text-secondary leading-relaxed text-lg mb-8">
{data.whoWeAreDescription || ''}
</p>
<div className="bg-brand/[0.04] border-t-2 border-brand p-6">
<p className="text-ink font-medium leading-relaxed">
{data.promiseText || ''}
</p>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.8, delay: 0.15, ease: EASE_OUT }}
className="lg:col-span-7"
>
<div className="space-y-6 md:space-y-8">
{values.map((value) => (
<div
key={value.title}
className="group relative border-b border-border-primary pb-6 md:pb-8 last:border-0 last:pb-0"
>
<div className="flex items-start gap-6 md:gap-8">
<div className="text-4xl md:text-5xl font-black text-text-muted/30 font-mono shrink-0 leading-none pt-1">
{value.number}
</div>
<div className="flex-1">
<h3 className="text-xl md:text-2xl font-bold text-ink mb-3 group-hover:text-brand transition-colors duration-300">
{value.title}
</h3>
<p className="text-text-secondary leading-relaxed">{value.description}</p>
</div>
</div>
</div>
))}
</div>
</motion.div>
</div>
</div>
</section>
);
}
function MilestonesSection({ data }: { data: AboutData }) {
const milestones = data.milestones ?? [];
return (
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-bg-secondary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.8, ease: EASE_OUT }}
className="max-w-3xl mb-16 sm:mb-20 md:mb-24"
>
<div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-6">
Our Journey
</div>
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95]">
<br className="sm:hidden" />
</h2>
</motion.div>
<div className="max-w-4xl mx-auto space-y-0">
{milestones.map((milestone, idx) => (
<motion.div
key={milestone.year}
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.6, delay: idx * 0.08, ease: EASE_OUT }}
className="relative pl-8 sm:pl-12 md:pl-16 pb-10 sm:pb-12 md:pb-14 last:pb-0"
>
<div className="absolute left-0 top-2 bottom-0 w-px bg-border-primary last:hidden" />
<div className={`absolute left-0 top-2 w-3 h-3 rounded-full -translate-x-1/2 ${idx === 0 ? 'bg-brand' : 'bg-border-primary'}`} />
<div className="grid sm:grid-cols-12 gap-4 sm:gap-8">
<div className="sm:col-span-3">
<div className={`text-2xl sm:text-3xl md:text-4xl font-black tracking-tight ${idx === 0 ? 'text-brand' : 'text-ink'}`}>
{milestone.year}
</div>
</div>
<div className="sm:col-span-9">
<h3 className="text-xl md:text-2xl font-bold text-ink mb-2">{milestone.title}</h3>
<p className="text-text-secondary leading-relaxed mb-3">{milestone.description}</p>
<div className="text-sm font-medium text-ink">{milestone.highlight}</div>
</div>
</div>
</motion.div>
))}
</div>
</div>
</section>
);
}
function CertificationsSection({ data }: { data: AboutData }) {
const certifications = data.certifications ?? [];
const partners = data.partners ?? [];
if (certifications.length === 0 && partners.length === 0) return null;
return (
<section className="relative py-20 sm:py-28 md:py-32 overflow-hidden bg-white">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<div className="grid lg:grid-cols-2 gap-12 md:gap-16 lg:gap-20">
{certifications.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.8, ease: EASE_OUT }}
>
<div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-6">
Certifications
</div>
<h2 className="text-3xl sm:text-4xl md:text-5xl font-extrabold text-ink tracking-tight leading-[0.95] mb-10">
</h2>
<div className="grid grid-cols-2 gap-4 md:gap-6">
{certifications.map((cert, i) => {
const iconName = DEFAULT_CERT_ICON_NAMES[i % DEFAULT_CERT_ICON_NAMES.length] ?? 'Shield';
const Icon = ICON_MAP[iconName] ?? Shield;
return (
<div
key={cert.name}
className="p-5 sm:p-6 border border-border-primary bg-white hover:bg-bg-secondary hover:border-border-secondary transition-all duration-300"
>
<div className="w-10 h-10 flex items-center justify-center mb-4 text-text-secondary">
<Icon className="w-5 h-5" />
</div>
<div className="font-semibold text-ink mb-1">{cert.name}</div>
<div className="text-sm text-text-muted">{cert.description}</div>
</div>
);
})}
</div>
</motion.div>
)}
{partners.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.8, delay: 0.15, ease: EASE_OUT }}
>
<div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-6">
Partners
</div>
<h2 className="text-3xl sm:text-4xl md:text-5xl font-extrabold text-ink tracking-tight leading-[0.95] mb-10">
</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 md:gap-4">
{partners.map((partner) => (
<div
key={partner}
className="aspect-[3/2] flex items-center justify-center border border-border-primary bg-white hover:bg-bg-secondary hover:border-border-secondary transition-all duration-300 group"
>
<span className="text-sm font-bold text-text-muted group-hover:text-text-secondary transition-colors duration-300">
{partner}
</span>
</div>
))}
</div>
<p className="mt-6 text-sm text-text-muted leading-relaxed">
</p>
</motion.div>
)}
</div>
</div>
</section>
);
}
function CTASection({ data }: { data: AboutData }) {
return (
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-bg-secondary">
<div className="absolute top-0 left-0 right-0 h-px bg-border-primary" />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.8, ease: EASE_OUT }}
className="max-w-3xl"
>
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95] mb-8">
{data.ctaTitle || ''}
</h2>
<p className="text-lg text-text-secondary leading-relaxed mb-10">
{data.ctaDescription || ''}
</p>
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
<a
href={data.ctaPrimaryHref || '#'}
className="group inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base bg-brand text-white transition-all duration-300 hover:bg-brand/90 min-h-[52px] touch-manipulation"
>
{data.ctaPrimaryLabel || ''}
<ArrowUpRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</a>
<a
href={data.ctaSecondaryHref || '#'}
className="inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base text-ink border border-border-primary hover:border-border-secondary hover:bg-bg-secondary transition-all duration-300 min-h-[52px] touch-manipulation"
>
{data.ctaSecondaryLabel || ''}
</a>
</div>
</motion.div>
</div>
</section>
);
}
export default function AboutContentV4({ data }: { data: Record<string, unknown> }) {
const aboutData = data as AboutData;
// Empty state when no CMS data is available
if (!data || Object.keys(data).length === 0) {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-text-muted text-lg"></div>
</div>
);
}
return (
<div className="min-h-screen bg-white text-ink overflow-x-hidden">
<HeroSection data={aboutData} />
<MetricsStrip data={aboutData} />
<WhoWeAreSection data={aboutData} />
<MilestonesSection data={aboutData} />
<CertificationsSection data={aboutData} />
<CTASection data={aboutData} />
</div>
);
}