336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react"
|
|
import Link from "next/link"
|
|
import { Button } from "@/components/ui/button"
|
|
import { ArrowRight, ChevronDown, Sparkles } from "lucide-react"
|
|
import { useParallax, useScrollY } from "@/hooks/use-parallax"
|
|
import homeData from "@/data/home.json"
|
|
|
|
function parseStatValue(raw: string) {
|
|
const match = raw.match(/-?\d[\d,.]*/)
|
|
|
|
if (!match || match.index === undefined) {
|
|
return null
|
|
}
|
|
|
|
const numericRaw = match[0].replace(/,/g, "")
|
|
const value = Number(numericRaw)
|
|
|
|
if (Number.isNaN(value)) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
prefix: raw.slice(0, match.index),
|
|
suffix: raw.slice(match.index + match[0].length),
|
|
value,
|
|
decimals: (numericRaw.split(".")[1] ?? "").length,
|
|
}
|
|
}
|
|
|
|
function formatStatValue(parsed: NonNullable<ReturnType<typeof parseStatValue>>, progress: number) {
|
|
const currentValue = parsed.value * progress
|
|
|
|
if (parsed.decimals > 0) {
|
|
return `${parsed.prefix}${currentValue.toFixed(parsed.decimals)}${parsed.suffix}`
|
|
}
|
|
|
|
return `${parsed.prefix}${Math.round(currentValue).toLocaleString()}${parsed.suffix}`
|
|
}
|
|
|
|
function AnimatedStatValue({ value, start }: { value: string; start: boolean }) {
|
|
const parsed = useMemo(() => parseStatValue(value), [value])
|
|
const [displayValue, setDisplayValue] = useState(() =>
|
|
parsed ? formatStatValue(parsed, 0) : value
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!parsed) {
|
|
setDisplayValue(value)
|
|
return
|
|
}
|
|
|
|
if (!start) {
|
|
setDisplayValue(formatStatValue(parsed, 0))
|
|
return
|
|
}
|
|
|
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
setDisplayValue(value)
|
|
return
|
|
}
|
|
|
|
const duration = 1400
|
|
const startAt = performance.now()
|
|
let frame = 0
|
|
|
|
const animate = (now: number) => {
|
|
const progress = Math.min((now - startAt) / duration, 1)
|
|
const eased = 1 - Math.pow(1 - progress, 3)
|
|
|
|
setDisplayValue(formatStatValue(parsed, eased))
|
|
|
|
if (progress < 1) {
|
|
frame = window.requestAnimationFrame(animate)
|
|
}
|
|
}
|
|
|
|
frame = window.requestAnimationFrame(animate)
|
|
|
|
return () => window.cancelAnimationFrame(frame)
|
|
}, [parsed, start, value])
|
|
|
|
return (
|
|
<span
|
|
className="inline-block tabular-nums [font-variant-numeric:tabular-nums]"
|
|
style={{ minWidth: `${Math.max(value.length + 1, 7)}ch` }}
|
|
>
|
|
{displayValue}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
export function HeroSection() {
|
|
const { hero } = homeData
|
|
const heroAny = hero as any
|
|
const title = heroAny.title ?? heroAny.headline1 ?? ""
|
|
const titleHighlight = heroAny.titleHighlight ?? heroAny.headline2 ?? ""
|
|
const primaryCta = heroAny.primaryCta ?? {
|
|
label: heroAny.ctaPrimary ?? "免费预约演示",
|
|
href: "#contact",
|
|
}
|
|
const secondaryCta = heroAny.secondaryCta ?? {
|
|
label: heroAny.ctaSecondary ?? "查看产品方案",
|
|
href: "#products",
|
|
}
|
|
const stats = Array.isArray(heroAny.stats) ? heroAny.stats : []
|
|
const tags = Array.isArray(heroAny.tags) ? heroAny.tags : []
|
|
const { ref: bgRef, translateY: bgY } = useParallax(0.45)
|
|
const { ref: floatRef, translateY: floatY } = useParallax(-0.15)
|
|
const scrollY = useScrollY()
|
|
const contentOpacity = Math.max(0, 1 - scrollY / 500)
|
|
const statsRef = useRef<HTMLDivElement | null>(null)
|
|
const [statsVisible, setStatsVisible] = useState(false)
|
|
|
|
useEffect(() => {
|
|
const node = statsRef.current
|
|
|
|
if (!node || statsVisible) {
|
|
return
|
|
}
|
|
|
|
const observer = new IntersectionObserver(
|
|
([entry]) => {
|
|
if (entry.isIntersecting) {
|
|
setStatsVisible(true)
|
|
observer.disconnect()
|
|
}
|
|
},
|
|
{ threshold: 0.35 }
|
|
)
|
|
|
|
observer.observe(node)
|
|
|
|
return () => observer.disconnect()
|
|
}, [statsVisible])
|
|
|
|
return (
|
|
<section
|
|
id="home"
|
|
className="relative min-h-[100svh] snap-start snap-always flex flex-col justify-center overflow-hidden"
|
|
aria-label="首屏"
|
|
>
|
|
{/* ── 深色渐变背景(视差层) ── */}
|
|
<div
|
|
ref={bgRef}
|
|
className="absolute inset-[-20%] will-change-transform"
|
|
style={{ transform: `translateY(${bgY})` }}
|
|
aria-hidden="true"
|
|
>
|
|
{/* 主背景 */}
|
|
<div className="absolute inset-0 bg-surface-dark" />
|
|
{/* 顶部蓝色光晕 */}
|
|
<div
|
|
className="absolute -top-32 -left-32 w-[800px] h-[800px] rounded-full opacity-30"
|
|
style={{
|
|
background: "radial-gradient(circle, oklch(0.46 0.22 264) 0%, transparent 70%)",
|
|
}}
|
|
/>
|
|
<div
|
|
className="absolute -bottom-32 -right-16 w-[600px] h-[600px] rounded-full opacity-20"
|
|
style={{
|
|
background: "radial-gradient(circle, oklch(0.55 0.18 280) 0%, transparent 70%)",
|
|
}}
|
|
/>
|
|
{/* 网格线 */}
|
|
<div
|
|
className="absolute inset-0 opacity-[0.06]"
|
|
style={{
|
|
backgroundImage: `
|
|
linear-gradient(oklch(1 0 0 / 1) 1px, transparent 1px),
|
|
linear-gradient(90deg, oklch(1 0 0 / 1) 1px, transparent 1px)
|
|
`,
|
|
backgroundSize: "80px 80px",
|
|
}}
|
|
/>
|
|
{/* 斜线装饰 */}
|
|
<svg
|
|
className="absolute top-0 right-0 w-1/2 h-full opacity-[0.04]"
|
|
viewBox="0 0 600 900"
|
|
fill="none"
|
|
aria-hidden="true"
|
|
>
|
|
{Array.from({ length: 12 }).map((_, i) => (
|
|
<line
|
|
key={i}
|
|
x1={-200 + i * 80}
|
|
y1="0"
|
|
x2={400 + i * 80}
|
|
y2="900"
|
|
stroke="white"
|
|
strokeWidth="1"
|
|
/>
|
|
))}
|
|
</svg>
|
|
</div>
|
|
|
|
{/* ── 浮动几何装饰(反向视差) ── */}
|
|
<div
|
|
ref={floatRef}
|
|
className="absolute inset-0 will-change-transform pointer-events-none"
|
|
style={{ transform: `translateY(${floatY})` }}
|
|
aria-hidden="true"
|
|
>
|
|
{/* 右上角大圆环 */}
|
|
<div
|
|
className="absolute -top-16 -right-16 w-[520px] h-[520px] rounded-full border opacity-10"
|
|
style={{ borderColor: "oklch(0.46 0.22 264)" }}
|
|
/>
|
|
<div
|
|
className="absolute top-20 right-20 w-[360px] h-[360px] rounded-full border opacity-8"
|
|
style={{ borderColor: "oklch(0.65 0.18 264)" }}
|
|
/>
|
|
{/* 小光点 */}
|
|
<div className="absolute top-1/3 right-1/4 w-2 h-2 rounded-full bg-primary opacity-60 animate-pulse" />
|
|
<div className="absolute top-1/2 right-1/3 w-1.5 h-1.5 rounded-full bg-blue-300 opacity-40 animate-pulse [animation-delay:0.8s]" />
|
|
<div className="absolute top-2/3 right-1/5 w-1 h-1 rounded-full bg-white opacity-30 animate-pulse [animation-delay:1.6s]" />
|
|
|
|
{/* 右侧产品亮点浮卡 */}
|
|
<div className="absolute top-1/2 right-8 lg:right-16 xl:right-24 -translate-y-1/2 hidden xl:flex flex-col gap-3">
|
|
{[
|
|
{ label: "智慧收银", sub: "多端聚合支付" },
|
|
{ label: "智能分账", sub: "实时自动结算" },
|
|
{ label: "智能监管", sub: "AI 异常预警" },
|
|
].map((card) => (
|
|
<div
|
|
key={card.label}
|
|
className="flex items-center gap-3 bg-white/8 backdrop-blur-sm border border-white/15 rounded-xl px-4 py-3 min-w-[180px]"
|
|
>
|
|
<div className="w-2 h-2 rounded-full bg-primary shrink-0" />
|
|
<div>
|
|
<p className="text-white text-sm font-semibold leading-none mb-0.5">{card.label}</p>
|
|
<p className="text-white/50 text-xs">{card.sub}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── 主体内容 ── */}
|
|
<div
|
|
className="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-28 pb-20"
|
|
style={{ opacity: contentOpacity }}
|
|
>
|
|
<div className="max-w-2xl xl:max-w-3xl">
|
|
{/* Badge */}
|
|
<div className="inline-flex items-center gap-2 bg-primary/20 border border-primary/40 rounded-full px-4 py-1.5 mb-7">
|
|
<Sparkles className="w-3.5 h-3.5 text-primary" />
|
|
<span className="text-white/90 text-sm font-medium">{hero.badge}</span>
|
|
</div>
|
|
|
|
{/* Heading */}
|
|
<h1 className="text-5xl sm:text-6xl lg:text-7xl font-extrabold text-white leading-[1.08] text-balance mb-6 tracking-tight">
|
|
{title}
|
|
<br />
|
|
<span
|
|
className="relative"
|
|
style={{
|
|
background: "linear-gradient(135deg, oklch(0.75 0.16 220), oklch(0.65 0.2 264), oklch(0.55 0.22 280))",
|
|
WebkitBackgroundClip: "text",
|
|
WebkitTextFillColor: "transparent",
|
|
backgroundClip: "text",
|
|
}}
|
|
>
|
|
{titleHighlight}
|
|
</span>
|
|
</h1>
|
|
|
|
{/* Subtitle */}
|
|
<p className="text-lg text-white/65 leading-relaxed max-w-xl text-pretty mb-10">
|
|
{hero.subtitle}
|
|
</p>
|
|
|
|
{tags.length > 0 && (
|
|
<div className="flex flex-wrap gap-3 mb-10">
|
|
{tags.map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className="inline-flex items-center rounded-full border border-white/12 bg-white/8 px-3.5 py-1.5 text-sm text-white/75 backdrop-blur-sm"
|
|
>
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* CTAs */}
|
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 mb-16">
|
|
<Button
|
|
asChild
|
|
size="lg"
|
|
className="bg-primary hover:bg-primary-dark text-white shadow-xl shadow-primary/30 px-8 h-12 text-base font-semibold group"
|
|
>
|
|
<Link href={primaryCta.href ?? "#contact"} className="flex items-center gap-2">
|
|
{primaryCta.label}
|
|
<ArrowRight className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" />
|
|
</Link>
|
|
</Button>
|
|
<Button
|
|
asChild
|
|
variant="outline"
|
|
size="lg"
|
|
className="border-white/25 bg-white/8 backdrop-blur-sm text-white hover:bg-white/15 hover:border-white/40 px-8 h-12 text-base"
|
|
>
|
|
<Link href={secondaryCta.href ?? "#products"}>
|
|
{secondaryCta.label}
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div
|
|
ref={statsRef}
|
|
className="grid grid-cols-2 sm:grid-cols-4 gap-6 pt-10 border-t border-white/10"
|
|
>
|
|
{stats.map((stat) => (
|
|
<div key={stat.label} className="flex flex-col gap-1.5">
|
|
<span className="text-3xl lg:text-4xl font-black text-white tracking-tight">
|
|
<AnimatedStatValue value={stat.value} start={statsVisible} />
|
|
</span>
|
|
<span className="text-xs text-white/45 leading-tight">{stat.label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Scroll cue */}
|
|
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-10 flex flex-col items-center gap-1.5 animate-bounce">
|
|
<span className="text-white/30 text-xs tracking-widest">SCROLL</span>
|
|
<ChevronDown className="w-4 h-4 text-white/30" />
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|