Files
company-site/components/sections/hero-section.tsx
JanYork 6a68a96287 feat: 添加连锁门店系统首页及核心UI组件
新增首页布局、导航栏、页脚及多个核心UI组件(按钮、卡片、表格等)
添加图片资源、工具函数和样式配置
实现响应式设计和主题支持
包含行业解决方案展示区块
2026-03-18 10:50:42 +08:00

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>
)
}