feat(ui): optimize CTA buttons with contextual copy and fix static export build issues
CTA Optimization: - Implement scenario-based CTA text across 6 key locations - Add responsive Header CTA with icon+compact design - Enhance CTA Section with vision-driven copy Bug Fixes: - Fix useSearchParams() build failure with dynamic import wrapper - Remove useSearchParams() from PageTransition component - Fix React 19 useEffect lint errors via useSyncExternalStore UI Enhancements: - Add ripple effects and gradient animations to Button - Enhance loading skeleton with branded pulse animation
This commit is contained in:
BIN
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
@@ -91,7 +91,7 @@ export default function ServicesPage() {
|
||||
asChild
|
||||
>
|
||||
<StaticLink href="/contact">
|
||||
立即咨询
|
||||
获取服务报价
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</StaticLink>
|
||||
</Button>
|
||||
|
||||
@@ -138,7 +138,7 @@ export function SolutionDetailClient({ solution, relatedProducts }: SolutionDeta
|
||||
</Button>
|
||||
<Button size="lg" className="bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] text-white" asChild>
|
||||
<StaticLink href="/contact">
|
||||
立即咨询
|
||||
预约方案演示
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</StaticLink>
|
||||
</Button>
|
||||
|
||||
+40
-10
@@ -65,12 +65,12 @@
|
||||
--color-accent-cyan-rgb: 8, 145, 178;
|
||||
|
||||
--color-footer-bg: #1C1C1C;
|
||||
--color-cta-bg: #FAFAFA;
|
||||
--color-cta-bg: #1a0a0f;
|
||||
--color-hero-dark-end: #1C1C1C;
|
||||
--color-footer-text: #A0A0A0;
|
||||
--color-footer-text-muted: #666666;
|
||||
--color-footer-text-dim: #999999;
|
||||
--color-footer-text-link: #E0E0E0;
|
||||
--color-footer-text: #B0B0B0;
|
||||
--color-footer-text-muted: #8C8C8C;
|
||||
--color-footer-text-dim: #A0A0A0;
|
||||
--color-footer-text-link: #E5E5E5;
|
||||
--color-footer-border: #333333;
|
||||
|
||||
--color-challenge-isolation: #FEF2F4;
|
||||
@@ -149,8 +149,8 @@
|
||||
--color-primary-lighter: #262626;
|
||||
--color-primary-rgb: 229, 229, 229;
|
||||
|
||||
--color-brand-primary: #E04A68;
|
||||
--color-brand-primary-hover: #F06880;
|
||||
--color-brand-primary: #D43650;
|
||||
--color-brand-primary-hover: #E04A68;
|
||||
--color-brand-primary-light: #C41E3A;
|
||||
--color-brand-primary-bg: rgba(196, 30, 58, 0.15);
|
||||
|
||||
@@ -185,14 +185,14 @@
|
||||
--color-warning-bg: rgba(217, 119, 6, 0.15);
|
||||
--color-info: #8C8C8C;
|
||||
--color-info-bg: #1A1A1A;
|
||||
--color-error: #E04A68;
|
||||
--color-error: #D43650;
|
||||
--color-error-bg: rgba(196, 30, 58, 0.15);
|
||||
|
||||
--color-accent-blue: #3B82F6;
|
||||
--color-accent-purple: #8B5CF6;
|
||||
--color-accent-cyan: #06B6D4;
|
||||
|
||||
--color-brand-primary-rgb: 224, 74, 104;
|
||||
--color-brand-primary-rgb: 212, 54, 80;
|
||||
--color-warning-rgb: 245, 158, 11;
|
||||
--color-success-rgb: 34, 197, 94;
|
||||
--color-accent-blue-rgb: 59, 130, 246;
|
||||
@@ -355,7 +355,7 @@
|
||||
@layer utilities {
|
||||
.container-wide {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
max-width: 1280px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding-left: var(--spacing-lg);
|
||||
@@ -629,3 +629,33 @@ body {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ripple {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
transform: scale(4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-ripple {
|
||||
animation: ripple 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes skeletonPulse {
|
||||
0%, 100% {
|
||||
background-color: var(--color-skeleton-bg);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
background-color: color-mix(in srgb, var(--color-brand-primary) 8%, var(--color-skeleton-bg));
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-brand {
|
||||
animation: skeletonPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
+10
-4
@@ -4,16 +4,17 @@ import { Ma_Shan_Zheng, Noto_Sans_SC } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Suspense } from "react";
|
||||
import { ThemeProvider } from "@/contexts/theme-context";
|
||||
import { GoogleAnalytics } from "@/components/analytics/GoogleAnalytics";
|
||||
import { GoogleAnalyticsWrapper } from "@/components/analytics/GoogleAnalyticsWrapper";
|
||||
import { CookieConsent } from "@/components/analytics/CookieConsent";
|
||||
import { PerformanceTracker } from "@/components/analytics/PerformanceTracker";
|
||||
import { OutboundLinkTracker } from "@/components/analytics/OutboundLinkTracker";
|
||||
import { ScrollDepthTracker } from "@/components/analytics/ScrollDepthTracker";
|
||||
import { OrganizationSchema, WebsiteSchema } from "@/components/seo/structured-data";
|
||||
import { OrganizationSchema, WebsiteSchema, LocalBusinessSchema } from "@/components/seo/structured-data";
|
||||
import { MobileTabBar } from "@/components/layout/mobile-tab-bar";
|
||||
import { ErrorBoundary } from "@/components/ui/error-boundary";
|
||||
import { ScrollProgress } from "@/components/ui/scroll-progress";
|
||||
import { BackToTop } from "@/components/ui/back-to-top";
|
||||
import { ClientLayout } from "@/components/layout/client-layout";
|
||||
|
||||
const geistSans = localFont({
|
||||
src: "./fonts/geist-sans.woff2",
|
||||
@@ -133,8 +134,11 @@ export default function RootLayout({
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="theme-color" content="#C41E3A" />
|
||||
<link rel="dns-prefetch" href="//formsubmit.co" />
|
||||
<link rel="preconnect" href="//formsubmit.co" crossOrigin="anonymous" />
|
||||
<OrganizationSchema />
|
||||
<WebsiteSchema />
|
||||
<LocalBusinessSchema />
|
||||
</head>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${aoyagiReisho.variable} ${maShanZheng.variable} ${notoSansSC.variable} font-sans antialiased`}
|
||||
@@ -146,13 +150,15 @@ export default function RootLayout({
|
||||
跳转到主内容
|
||||
</a>
|
||||
<ScrollProgress />
|
||||
<GoogleAnalytics />
|
||||
<GoogleAnalyticsWrapper />
|
||||
<PerformanceTracker />
|
||||
<OutboundLinkTracker />
|
||||
<ScrollDepthTracker />
|
||||
<ThemeProvider>
|
||||
<ErrorBoundary>
|
||||
{children}
|
||||
<ClientLayout>
|
||||
{children}
|
||||
</ClientLayout>
|
||||
</ErrorBoundary>
|
||||
</ThemeProvider>
|
||||
<Suspense fallback={null}>
|
||||
|
||||
@@ -2,16 +2,28 @@
|
||||
|
||||
import Script from 'next/script';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, Suspense } from 'react';
|
||||
import { useEffect, Suspense, useSyncExternalStore } from 'react';
|
||||
|
||||
const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || '';
|
||||
|
||||
function useIsMounted() {
|
||||
return useSyncExternalStore(
|
||||
(callback) => {
|
||||
window.addEventListener('resize', callback);
|
||||
return () => window.removeEventListener('resize', callback);
|
||||
},
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleAnalyticsContent() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const mounted = useIsMounted();
|
||||
|
||||
useEffect(() => {
|
||||
if (!GA_MEASUREMENT_ID || typeof window === 'undefined') {return;}
|
||||
if (!GA_MEASUREMENT_ID || !mounted || typeof window === 'undefined') {return;}
|
||||
|
||||
const url = pathname + (searchParams.toString() ? `?${searchParams.toString()}` : '');
|
||||
|
||||
@@ -22,9 +34,9 @@ function GoogleAnalyticsContent() {
|
||||
page_location: window.location.origin + url,
|
||||
});
|
||||
}
|
||||
}, [pathname, searchParams]);
|
||||
}, [pathname, searchParams, mounted]);
|
||||
|
||||
if (!GA_MEASUREMENT_ID) {return null;}
|
||||
if (!GA_MEASUREMENT_ID || !mounted) {return null;}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -61,11 +73,13 @@ function GoogleAnalyticsContent() {
|
||||
}
|
||||
|
||||
export function GoogleAnalytics() {
|
||||
if (!GA_MEASUREMENT_ID) {return null;}
|
||||
const mounted = useIsMounted();
|
||||
|
||||
if (!GA_MEASUREMENT_ID || !mounted) {return null;}
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<GoogleAnalyticsContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const GoogleAnalytics = dynamic(
|
||||
() => import('./GoogleAnalytics').then((mod) => mod.GoogleAnalytics),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
export function GoogleAnalyticsWrapper() {
|
||||
return <GoogleAnalytics />;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { PageTransition } from '@/components/ui/page-transition';
|
||||
|
||||
interface ClientLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ClientLayout({ children }: ClientLayoutProps) {
|
||||
return (
|
||||
<PageTransition>
|
||||
{children}
|
||||
</PageTransition>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Suspense, useState, useEffect, useCallback } from 'react';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import Image from 'next/image';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import { Menu, X, MessageCircle } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||
@@ -75,25 +75,25 @@ function HeaderContent() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
<header
|
||||
className={`
|
||||
fixed top-0 left-0 right-0 z-50
|
||||
transition-all duration-300 ease-out
|
||||
${isScrolled
|
||||
? 'bg-[var(--color-bg-primary)]/95 backdrop-blur-xl border-b border-[var(--color-border-primary)] shadow-sm'
|
||||
${isScrolled
|
||||
? 'bg-[var(--color-bg-primary)]/95 backdrop-blur-xl border-b border-[var(--color-border-primary)] shadow-sm'
|
||||
: 'bg-transparent'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="container-wide">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<StaticLink
|
||||
<StaticLink
|
||||
href="/"
|
||||
className="flex items-center group"
|
||||
aria-label="返回首页"
|
||||
>
|
||||
<Image
|
||||
src={resolvedTheme === 'dark' ? '/logo-white.svg' : '/logo.svg'}
|
||||
<Image
|
||||
src={resolvedTheme === 'dark' ? '/logo-light.svg' : '/logo.svg'}
|
||||
alt={COMPANY_INFO.name}
|
||||
width={120}
|
||||
height={30}
|
||||
@@ -133,12 +133,12 @@ function HeaderContent() {
|
||||
aria-current={isActive(item) ? 'page' : undefined}
|
||||
>
|
||||
{item.label}
|
||||
<span
|
||||
<span
|
||||
className={`
|
||||
absolute bottom-0 left-1/2 -translate-x-1/2 w-6 h-0.5 bg-[var(--color-brand-primary)] rounded-full
|
||||
transition-all duration-200 ease-out
|
||||
${isActive(item)
|
||||
? 'opacity-100 scale-x-100'
|
||||
${isActive(item)
|
||||
? 'opacity-100 scale-x-100'
|
||||
: 'opacity-0 scale-x-0'
|
||||
}
|
||||
`}
|
||||
@@ -150,9 +150,20 @@ function HeaderContent() {
|
||||
|
||||
<div className="hidden md:flex items-center gap-3">
|
||||
<ThemeToggle />
|
||||
<Button
|
||||
<Button
|
||||
size="sm"
|
||||
asChild
|
||||
className="hidden lg:flex"
|
||||
>
|
||||
<StaticLink href="/contact" data-testid="consult-button">
|
||||
<MessageCircle className="w-4 h-4 mr-1.5" />
|
||||
咨询
|
||||
</StaticLink>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
asChild
|
||||
className="lg:hidden"
|
||||
>
|
||||
<StaticLink href="/contact" data-testid="consult-button">立即咨询</StaticLink>
|
||||
</Button>
|
||||
@@ -176,7 +187,7 @@ function HeaderContent() {
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
<motion.div
|
||||
ref={focusTrapRef}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -184,12 +195,12 @@ function HeaderContent() {
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-40 md:hidden"
|
||||
>
|
||||
<div
|
||||
<div
|
||||
className="absolute inset-0 bg-black/30 backdrop-blur-sm"
|
||||
onClick={() => setIsOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<motion.div
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
@@ -236,7 +247,8 @@ function HeaderContent() {
|
||||
size="lg"
|
||||
>
|
||||
<StaticLink href="/contact" onClick={() => setIsOpen(false)}>
|
||||
立即咨询
|
||||
<MessageCircle className="w-4 h-4 mr-2" />
|
||||
咨询专家
|
||||
</StaticLink>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ export function ChallengeSection() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<section id="challenges" className="py-20 md:py-28 bg-[var(--color-bg-primary)]">
|
||||
<section id="challenges" className="relative py-20 md:py-28 bg-[var(--color-bg-section)]">
|
||||
<div className="container-wide">
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
@@ -56,14 +56,21 @@ export function ChallengeSection() {
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8">
|
||||
{CHALLENGES.map((challenge, index) => (
|
||||
<ChallengeCard
|
||||
<motion.div
|
||||
key={challenge.id}
|
||||
title={challenge.title}
|
||||
description={challenge.description}
|
||||
scenario={challenge.scenario}
|
||||
href={challenge.href}
|
||||
index={index}
|
||||
/>
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, x: index === 0 ? -32 : index === 1 ? 0 : 32 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, margin: '-80px' }}
|
||||
transition={{ duration: 0.55, delay: index * 0.12, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<ChallengeCard
|
||||
title={challenge.title}
|
||||
description={challenge.description}
|
||||
scenario={challenge.scenario}
|
||||
href={challenge.href}
|
||||
index={index}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useRef, useState } from 'react';
|
||||
import { motion, useMotionValue, useSpring, useTransform } from 'framer-motion';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { ArrowRight, Sparkles } from 'lucide-react';
|
||||
import { COMPANY_INFO } from '@/lib/constants';
|
||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||
|
||||
@@ -19,46 +20,158 @@ interface CTASectionProps {
|
||||
export function CTASection({
|
||||
title = '开启您的数字化转型之旅',
|
||||
description = `与${COMPANY_INFO.shortName}一起,让技术成为您业务增长的核心引擎`,
|
||||
primaryLabel = '立即咨询',
|
||||
primaryLabel = '开启数字化转型之旅',
|
||||
primaryHref = '/contact',
|
||||
secondaryLabel = '了解方案',
|
||||
secondaryHref = '/solutions',
|
||||
}: CTASectionProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mouseX = useMotionValue(0);
|
||||
const mouseY = useMotionValue(0);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const springX = useSpring(mouseX, { stiffness: 80, damping: 20 });
|
||||
const springY = useSpring(mouseY, { stiffness: 80, damping: 20 });
|
||||
|
||||
const glowX = useTransform(springX, [0, 1], ['-10%', '110%']);
|
||||
const glowY = useTransform(springY, [0, 1], ['-10%', '110%']);
|
||||
|
||||
function handleMouseMove(e: React.MouseEvent<HTMLDivElement>) {
|
||||
if (!containerRef.current || shouldReduceMotion) {return;}
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
mouseX.set((e.clientX - rect.left) / rect.width);
|
||||
mouseY.set((e.clientY - rect.top) / rect.height);
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="cta" className="relative py-20 md:py-28 bg-[var(--color-cta-bg)] overflow-hidden">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background: 'linear-gradient(to top, rgba(var(--color-brand-primary-rgb), 0.04) 0%, transparent 60%)',
|
||||
}}
|
||||
/>
|
||||
<section
|
||||
id="cta"
|
||||
ref={containerRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className="relative py-24 md:py-32 overflow-hidden"
|
||||
style={{ backgroundColor: 'var(--color-cta-bg)' }}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<svg className="absolute inset-0 w-full h-full" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<filter id="cta-grain">
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency="0.75"
|
||||
numOctaves="4"
|
||||
stitchTiles="stitch"
|
||||
result="noise"
|
||||
/>
|
||||
<feColorMatrix type="saturate" values="0" in="noise" result="desaturated" />
|
||||
<feComponentTransfer in="desaturated" result="faded">
|
||||
<feFuncA type="linear" slope="0.04" />
|
||||
</feComponentTransfer>
|
||||
</filter>
|
||||
<rect width="100%" height="100%" filter="url(#cta-grain)" />
|
||||
</svg>
|
||||
|
||||
{!shouldReduceMotion && (
|
||||
<>
|
||||
<motion.div
|
||||
className="absolute inset-[-20%]"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(circle at center, rgba(196, 30, 58, 0.08) 0%, transparent 50%)',
|
||||
x: glowX,
|
||||
y: glowY,
|
||||
opacity: isHovered ? 1 : 0.5,
|
||||
}}
|
||||
transition={{ opacity: { duration: 0.6 } }}
|
||||
/>
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.15, 1],
|
||||
opacity: [0.06, 0.12, 0.06],
|
||||
}}
|
||||
transition={{
|
||||
duration: 8,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
className="absolute left-[10%] top-[20%] w-64 h-64 rounded-full"
|
||||
style={{ background: 'radial-gradient(circle, rgba(196,30,58,0.2), transparent 70%)' }}
|
||||
/>
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.2, 1],
|
||||
opacity: [0.05, 0.1, 0.05],
|
||||
}}
|
||||
transition={{
|
||||
duration: 10,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
delay: 2,
|
||||
}}
|
||||
className="absolute right-[5%] bottom-[25%] w-80 h-80 rounded-full"
|
||||
style={{ background: 'radial-gradient(circle, rgba(196,30,58,0.15), transparent 70%)' }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to top, rgba(196, 30, 58, 0.06) 0%, transparent 40%), linear-gradient(135deg, transparent 40%, rgba(196, 30, 58, 0.03) 100%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23C41E3A' fill-opacity='0.02'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="container-wide relative z-10">
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-100px' }}
|
||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, scale: 0.96, y: 16 }}
|
||||
whileInView={{ opacity: 1, scale: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-80px' }}
|
||||
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="text-center max-w-3xl mx-auto"
|
||||
>
|
||||
<h2 className="text-3xl sm:text-4xl font-semibold text-white mb-4">
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: -8 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: 0.15 }}
|
||||
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-white/5 border border-white/10 mb-8"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-[var(--color-brand-primary)]" />
|
||||
<span className="text-sm font-medium text-white/80">开始合作</span>
|
||||
</motion.div>
|
||||
|
||||
<h2 className="text-4xl sm:text-5xl lg:text-6xl font-semibold text-white mb-6 tracking-tight leading-tight">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-lg text-white/70 mb-10">
|
||||
<p className="text-lg md:text-xl text-white/65 leading-relaxed mb-12 max-w-2xl mx-auto">
|
||||
{description}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Button size="lg" asChild>
|
||||
<Button size="lg" asChild className="group relative overflow-hidden">
|
||||
<StaticLink href={primaryHref}>
|
||||
{primaryLabel}
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
<span className="relative z-10 flex items-center">
|
||||
{primaryLabel}
|
||||
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||
</span>
|
||||
</StaticLink>
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" className="border-white/30 text-white hover:bg-white/10 hover:border-white/50" asChild>
|
||||
<StaticLink href={secondaryHref}>
|
||||
{secondaryLabel}
|
||||
</StaticLink>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="border-white/20 text-white hover:bg-white/10 hover:border-white/35 backdrop-blur-sm"
|
||||
asChild
|
||||
>
|
||||
<StaticLink href={secondaryHref}>{secondaryLabel}</StaticLink>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useRef, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { HeroInkBackground } from '@/components/ui/hero-ink-background';
|
||||
import { COMPANY_INFO } from '@/lib/constants';
|
||||
import { ArrowRight, MessageSquare, Search, Rocket, Handshake } from 'lucide-react';
|
||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||
@@ -51,13 +52,7 @@ export function HeroSectionV2() {
|
||||
aria-labelledby="hero-heading"
|
||||
className="relative min-h-screen flex flex-col justify-center overflow-hidden bg-[var(--color-bg-primary)]"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(ellipse at 15% 40%, rgba(var(--color-brand-primary-rgb), 0.05) 0%, transparent 55%), radial-gradient(ellipse at 85% 25%, rgba(var(--color-primary-rgb), 0.04) 0%, transparent 50%), radial-gradient(ellipse at 50% 90%, rgba(var(--color-brand-primary-rgb), 0.03) 0%, transparent 40%)',
|
||||
}}
|
||||
/>
|
||||
<HeroInkBackground />
|
||||
|
||||
<div className="container-wide py-24 md:py-32 lg:py-40 relative z-10 flex-1 flex items-center">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20 items-center w-full">
|
||||
@@ -102,7 +97,7 @@ export function HeroSectionV2() {
|
||||
>
|
||||
<Button size="lg" asChild>
|
||||
<StaticLink href="/contact">
|
||||
立即咨询
|
||||
免费获取定制方案
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</StaticLink>
|
||||
</Button>
|
||||
|
||||
@@ -9,7 +9,7 @@ export function ProductMatrixSection() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<section id="products" className="py-20 md:py-28 bg-[var(--color-bg-section)]">
|
||||
<section id="products" className="relative py-16 md:py-20 bg-[var(--color-bg-primary)]">
|
||||
<div className="container-wide">
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
@@ -33,14 +33,21 @@ export function ProductMatrixSection() {
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
||||
{PRODUCTS.map((product, index) => (
|
||||
<ProductCard
|
||||
<motion.div
|
||||
key={product.id}
|
||||
title={product.title}
|
||||
description={product.description}
|
||||
href={`/products/${product.id}`}
|
||||
index={index}
|
||||
status={product.status}
|
||||
/>
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 28 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-60px' }}
|
||||
transition={{ duration: 0.5, delay: index * 0.08, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<ProductCard
|
||||
title={product.title}
|
||||
description={product.description}
|
||||
href={`/products/${product.id}`}
|
||||
index={index}
|
||||
status={product.status}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function SocialProofSection() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<section id="social-proof" role="region" aria-labelledby="social-proof-heading" className="py-20 md:py-28 bg-[var(--color-bg-section)]">
|
||||
<section id="social-proof" role="region" aria-labelledby="social-proof-heading" className="py-16 md:py-20 bg-[var(--color-bg-section)]">
|
||||
<div className="container-wide">
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
@@ -54,10 +54,10 @@ export function SocialProofSection() {
|
||||
return (
|
||||
<motion.div
|
||||
key={pillar.title}
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-50px' }}
|
||||
transition={{ duration: 0.5, delay: idx * 0.1, ease: [0.16, 1, 0.3, 1] }}
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 24, scale: 0.94 }}
|
||||
whileInView={{ opacity: 1, y: 0, scale: 1 }}
|
||||
viewport={{ once: true, margin: '-60px' }}
|
||||
transition={{ duration: 0.5, delay: idx * 0.1 + 0.05, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="text-center p-6 md:p-8 rounded-xl bg-[var(--color-bg-primary)] border border-[var(--color-border-primary)] hover:border-[rgba(var(--color-brand-primary-rgb),0.3)] transition-colors duration-300"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-xl bg-[var(--color-brand-primary)]/5 flex items-center justify-center mx-auto mb-4">
|
||||
|
||||
@@ -99,3 +99,66 @@ export function BreadcrumbSchema({ items }: { items: BreadcrumbItem[] }) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LocalBusinessSchema() {
|
||||
const schema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "LocalBusiness",
|
||||
"@id": "https://www.novalon.cn/#business",
|
||||
"name": COMPANY_INFO.name,
|
||||
"image": "https://www.novalon.cn/og-image.jpg",
|
||||
"url": "https://www.novalon.cn",
|
||||
"email": COMPANY_INFO.email,
|
||||
"description": "专注于企业数字化转型服务,提供软件开发、云计算、数据分析、信息安全等一站式解决方案",
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"streetAddress": "成都市高新区",
|
||||
"addressLocality": "成都市",
|
||||
"addressRegion": "四川省",
|
||||
"postalCode": "610000",
|
||||
"addressCountry": "CN"
|
||||
},
|
||||
"geo": {
|
||||
"@type": "GeoCoordinates",
|
||||
"latitude": 30.5728,
|
||||
"longitude": 104.0668
|
||||
},
|
||||
"openingHoursSpecification": [
|
||||
{
|
||||
"@type": "OpeningHoursSpecification",
|
||||
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
|
||||
"opens": "09:00",
|
||||
"closes": "18:00"
|
||||
}
|
||||
],
|
||||
"priceRange": "$$",
|
||||
"currenciesAccepted": "CNY",
|
||||
"paymentAccepted": "Cash, Credit Card, Bank Transfer",
|
||||
"areaServed": [
|
||||
{ "@type": "City", "name": "成都" },
|
||||
{ "@type": "State", "name": "四川" },
|
||||
{ "@type": "Country", "name": "中国" }
|
||||
],
|
||||
"hasOfferCatalog": {
|
||||
"@type": "OfferCatalog",
|
||||
"name": "数字化转型服务",
|
||||
"itemListElement": [
|
||||
{ "@type": "Offer", "itemOffered": { "@type": "Service", "name": "软件开发服务" } },
|
||||
{ "@type": "Offer", "itemOffered": { "@type": "Service", "name": "云计算解决方案" } },
|
||||
{ "@type": "Offer", "itemOffered": { "@type": "Service", "name": "数据分析与BI" } },
|
||||
{ "@type": "Offer", "itemOffered": { "@type": "Service", "name": "信息安全咨询" } },
|
||||
{ "@type": "Offer", "itemOffered": { "@type": "Service", "name": "企业IT架构设计" } },
|
||||
]
|
||||
},
|
||||
"sameAs": [
|
||||
"https://www.novalon.cn"
|
||||
]
|
||||
};
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,26 +3,25 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-200 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-white min-h-[44px] min-w-[44px] touch-manipulation",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-200 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-white min-h-[44px] min-w-[44px] touch-manipulation relative overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-[var(--color-brand-primary)] text-white hover:bg-[var(--color-brand-primary-hover)] hover:shadow-[0_4px_12px_rgba(var(--color-brand-primary-rgb),0.25)] hover:-translate-y-0.5 active:scale-[0.98]",
|
||||
"bg-[var(--color-brand-primary)] text-white hover:bg-[var(--color-brand-primary-hover)] hover:shadow-[0_4px_16px_rgba(var(--color-brand-primary-rgb),0.3)] hover:-translate-y-0.5 active:scale-[0.97] group/btn before:absolute before:inset-0 before:bg-gradient-to-r before:from-transparent before:via-white/10 before:to-transparent before:-translate-x-full hover:before:translate-x-full before:transition-transform before:duration-700 before:ease-out",
|
||||
secondary:
|
||||
"bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary-hover)] hover:shadow-[0_4px_12px_rgba(var(--color-primary-rgb),0.25)] hover:-translate-y-0.5 active:scale-[0.98]",
|
||||
"bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary-hover)] hover:shadow-[0_4px_16px_rgba(var(--color-primary-rgb),0.25)] hover:-translate-y-0.5 active:scale-[0.97]",
|
||||
destructive:
|
||||
"bg-[var(--color-brand-primary)] text-white hover:bg-[var(--color-brand-primary-hover)] focus-visible:ring-[var(--color-brand-primary)]",
|
||||
"bg-[var(--color-brand-primary)] text-white hover:bg-[var(--color-brand-primary-hover)] focus-visible:ring-[var(--color-brand-primary)] active:scale-[0.97]",
|
||||
outline:
|
||||
"border-2 border-[var(--color-primary)] bg-transparent text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-white hover:-translate-y-0.5 active:scale-[0.98]",
|
||||
"border-2 border-[var(--color-primary)] bg-transparent text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-white hover:-translate-y-0.5 hover:border-[var(--color-brand-primary)] active:scale-[0.97] hover:shadow-[0_4px_12px_rgba(var(--color-brand-primary-rgb),0.15)]",
|
||||
ghost:
|
||||
"text-[var(--color-primary-light)] hover:bg-[var(--color-primary-lighter)] hover:text-[var(--color-primary)]",
|
||||
"text-[var(--color-primary-light)] hover:bg-[var(--color-primary-lighter)] hover:text-[var(--color-brand-primary)]",
|
||||
link:
|
||||
"text-[var(--color-primary)] underline-offset-4 hover:underline hover:text-[var(--color-brand-primary)]",
|
||||
"text-[var(--color-primary)] underline-offset-4 hover:underline hover:text-[var(--color-brand-primary)] after:absolute after:bottom-0 after:left-0 after:h-[1.5px] after:w-0 hover:after:w-full after:bg-[var(--color-brand-primary)] after:transition-all after:duration-300",
|
||||
},
|
||||
size: {
|
||||
default: "h-11 px-4 py-2",
|
||||
@@ -45,14 +44,45 @@ export interface ButtonProps
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
({ className, variant, size, asChild = false, onClick, children, ...props }, ref) => {
|
||||
const [ripples, setRipples] = React.useState<Array<{ id: number; x: number; y: number }>>([])
|
||||
|
||||
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
const id = Date.now()
|
||||
setRipples(prev => [...prev, { id, x, y }])
|
||||
setTimeout(() => {
|
||||
setRipples(prev => prev.filter(r => r.id !== id))
|
||||
}, 600)
|
||||
onClick?.(e)
|
||||
}
|
||||
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
{ripples.map(ripple => (
|
||||
<span
|
||||
key={ripple.id}
|
||||
className="absolute rounded-full bg-white/30 pointer-events-none animate-ripple"
|
||||
style={{
|
||||
left: ripple.x,
|
||||
top: ripple.y,
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
marginLeft: '-10px',
|
||||
marginTop: '-10px',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Comp>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useMemo, useSyncExternalStore } from 'react';
|
||||
import { motion, useScroll, useTransform } from 'framer-motion';
|
||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||
|
||||
const PARTICLE_COUNT = 24;
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
duration: number;
|
||||
delay: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
function generateParticles(): Particle[] {
|
||||
const particles: Particle[] = [];
|
||||
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
||||
particles.push({
|
||||
x: Math.random() * 100,
|
||||
y: Math.random() * 100,
|
||||
size: Math.max(1.5, Math.random() * 3.5 + 0.5),
|
||||
duration: 14 + Math.random() * 20,
|
||||
delay: Math.random() * -20,
|
||||
opacity: 0.15 + Math.random() * 0.35,
|
||||
});
|
||||
}
|
||||
return particles;
|
||||
}
|
||||
|
||||
function ParallaxLayer({ children }: { children: React.ReactNode }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: containerRef,
|
||||
offset: ['start start', 'end start'],
|
||||
});
|
||||
|
||||
const y = useTransform(scrollYProgress, [0, 1], [0, 120]);
|
||||
const opacity = useTransform(scrollYProgress, [0, 0.6, 1], [1, 0.6, 0]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
style={{ y, opacity }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function InkCanvas({ shouldReduceMotion }: { shouldReduceMotion: boolean }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldReduceMotion || !canvasRef.current) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const rawCtx = canvas.getContext('2d');
|
||||
if (!rawCtx) {
|
||||
return undefined;
|
||||
}
|
||||
const ctx = rawCtx;
|
||||
|
||||
let animFrameId: number;
|
||||
let time = 0;
|
||||
|
||||
function resize() {
|
||||
if (!canvasRef.current) {
|
||||
return;
|
||||
}
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
}
|
||||
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
const inkBlobs = [
|
||||
{ cx: 0.18, cy: 0.35, r: 0.28, speed: 0.0004, phase: 0 },
|
||||
{ cx: 0.82, cy: 0.22, r: 0.22, speed: 0.0003, phase: 2 },
|
||||
{ cx: 0.55, cy: 0.78, r: 0.32, speed: 0.00035, phase: 4 },
|
||||
];
|
||||
|
||||
function draw() {
|
||||
const w = canvas.getBoundingClientRect().width;
|
||||
const h = canvas.getBoundingClientRect().height;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
inkBlobs.forEach((blob) => {
|
||||
const pulse = Math.sin(time * blob.speed + blob.phase) * 0.08 + 1;
|
||||
const rx = blob.r * w * pulse;
|
||||
const ry = blob.r * h * pulse;
|
||||
const gx = blob.cx * w + Math.sin(time * 0.0006 + blob.phase) * w * 0.03;
|
||||
const gy = blob.cy * h + Math.cos(time * 0.0005 + blob.phase) * h * 0.03;
|
||||
|
||||
const grad = ctx.createRadialGradient(gx, gy, 0, gx, gy, Math.max(rx, ry));
|
||||
grad.addColorStop(0, 'rgba(196, 30, 58, 0.045)');
|
||||
grad.addColorStop(0.5, 'rgba(196, 30, 58, 0.02)');
|
||||
grad.addColorStop(1, 'rgba(196, 30, 58, 0)');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
});
|
||||
|
||||
const particleGrad = ctx.createRadialGradient(w * 0.5, h * 0.4, 0, w * 0.5, h * 0.4, w * 0.6);
|
||||
particleGrad.addColorStop(0, 'rgba(28, 28, 28, 0.025)');
|
||||
particleGrad.addColorStop(1, 'rgba(28, 28, 28, 0)');
|
||||
ctx.fillStyle = particleGrad;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
time += 16;
|
||||
animFrameId = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
animFrameId = requestAnimationFrame(draw);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animFrameId);
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, [shouldReduceMotion]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
style={{ opacity: 0.8 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InkParticles({ particles }: { particles: Particle[] }) {
|
||||
return (
|
||||
<svg className="absolute inset-0 w-full h-full" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="ink-glow">
|
||||
<feGaussianBlur stdDeviation="40" result="blur" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1.2 0"
|
||||
in="blur"
|
||||
result="glow"
|
||||
/>
|
||||
<feMerge>
|
||||
<feMergeNode in="glow" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<circle
|
||||
cx="15%"
|
||||
cy="40%"
|
||||
r="18%"
|
||||
fill="rgba(196, 30, 58, 0.03)"
|
||||
filter="url(#ink-glow)"
|
||||
style={{
|
||||
transformOrigin: '15% 40%',
|
||||
animation: 'inkBreatheA 10s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
<circle
|
||||
cx="85%"
|
||||
cy="25%"
|
||||
r="14%"
|
||||
fill="rgba(28, 28, 28, 0.025)"
|
||||
filter="url(#ink-glow)"
|
||||
style={{
|
||||
transformOrigin: '85% 25%',
|
||||
animation: 'inkBreatheB 13s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
<circle
|
||||
cx="50%"
|
||||
cy="85%"
|
||||
r="22%"
|
||||
fill="rgba(196, 30, 58, 0.025)"
|
||||
filter="url(#ink-glow)"
|
||||
style={{
|
||||
transformOrigin: '50% 85%',
|
||||
animation: 'inkBreatheC 11s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
{particles.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={`${p.x}%`}
|
||||
cy={`${p.y}%`}
|
||||
r={p.size}
|
||||
fill={`rgba(196, 30, 58, ${p.opacity})`}
|
||||
style={{
|
||||
transformOrigin: `${p.x}% ${p.y}%`,
|
||||
animation: `particleFloat ${p.duration}s ease-in-out ${p.delay}s infinite`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<style>{`
|
||||
@keyframes inkBreatheA {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.15); opacity: 0.7; }
|
||||
}
|
||||
@keyframes inkBreatheB {
|
||||
0%, 100% { transform: scale(1); opacity: 0.9; }
|
||||
50% { transform: scale(1.2); opacity: 0.5; }
|
||||
}
|
||||
@keyframes inkBreatheC {
|
||||
0%, 100% { transform: scale(1); opacity: 0.8; }
|
||||
50% { transform: scale(1.12); opacity: 0.5; }
|
||||
}
|
||||
@keyframes particleFloat {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
opacity: var(--base-opacity, 0.3);
|
||||
}
|
||||
25% {
|
||||
transform: translate(12px, -20px) scale(1.1);
|
||||
opacity: calc(var(--base-opacity, 0.3) * 1.5);
|
||||
}
|
||||
50% {
|
||||
transform: translate(-8px, -35px) scale(0.9);
|
||||
opacity: var(--base-opacity, 0.3);
|
||||
}
|
||||
75% {
|
||||
transform: translate(15px, -15px) scale(1.05);
|
||||
opacity: calc(var(--base-opacity, 0.3) * 1.2);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function useIsMounted() {
|
||||
return useSyncExternalStore(
|
||||
(callback) => {
|
||||
window.addEventListener('resize', callback);
|
||||
return () => window.removeEventListener('resize', callback);
|
||||
},
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
}
|
||||
|
||||
export function HeroInkBackground() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const mounted = useIsMounted();
|
||||
|
||||
const particles = useMemo(() => {
|
||||
if (!mounted) {return [];}
|
||||
return generateParticles();
|
||||
}, [mounted]);
|
||||
|
||||
if (shouldReduceMotion) {
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(ellipse at 18% 35%, rgba(196, 30, 58, 0.05) 0%, transparent 50%), radial-gradient(ellipse at 82% 22%, rgba(28, 28, 28, 0.04) 0%, transparent 45%), radial-gradient(ellipse at 55% 78%, rgba(196, 30, 58, 0.03) 0%, transparent 40%)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 relative"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(ellipse at 18% 35%, rgba(196, 30, 58, 0.05) 0%, transparent 50%), radial-gradient(ellipse at 82% 22%, rgba(28, 28, 28, 0.04) 0%, transparent 45%)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 relative" aria-hidden="true">
|
||||
<ParallaxLayer>
|
||||
<InkCanvas shouldReduceMotion={shouldReduceMotion} />
|
||||
<InkParticles particles={particles} />
|
||||
</ParallaxLayer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export function Skeleton({ className }: SkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-pulse rounded-md bg-[var(--color-bg-hover)]',
|
||||
'skeleton-brand rounded-md',
|
||||
className
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ReactNode, useSyncExternalStore } from 'react';
|
||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||
|
||||
interface PageTransitionProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function useIsMounted() {
|
||||
return useSyncExternalStore(
|
||||
(callback) => {
|
||||
window.addEventListener('resize', callback);
|
||||
return () => window.removeEventListener('resize', callback);
|
||||
},
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
}
|
||||
|
||||
export function PageTransition({ children }: PageTransitionProps) {
|
||||
const pathname = usePathname();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const mounted = useIsMounted();
|
||||
|
||||
if (shouldReduceMotion || !mounted) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={pathname}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,7 @@ export function ScrollProgress() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const { scrollYProgress } = useScroll();
|
||||
|
||||
// 使用弹簧动画使进度条移动更平滑
|
||||
|
||||
const scaleX = useSpring(scrollYProgress, {
|
||||
stiffness: 100,
|
||||
damping: 30,
|
||||
@@ -17,9 +16,8 @@ export function ScrollProgress() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// 当滚动超过 100px 时显示进度条
|
||||
const handleScroll = () => {
|
||||
setIsVisible(window.scrollY > 100);
|
||||
setIsVisible(window.scrollY > 80);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
@@ -30,22 +28,58 @@ export function ScrollProgress() {
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={shouldReduceMotion ? {} : { opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed top-0 left-0 right-0 h-1 z-[100] bg-transparent"
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={shouldReduceMotion ? {} : { opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="fixed top-0 left-0 right-0 h-[3px] z-[100] bg-transparent"
|
||||
role="progressbar"
|
||||
aria-label="页面滚动进度"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-[var(--color-brand-primary)] to-[var(--color-brand-primary-light)] origin-left"
|
||||
style={{
|
||||
className="h-full origin-left relative"
|
||||
style={{
|
||||
scaleX,
|
||||
boxShadow: '0 0 10px rgba(var(--color-brand-primary-rgb), 0.3)',
|
||||
background:
|
||||
'linear-gradient(90deg, #C41E3A 0%, #E04A68 50%, #C41E3A 100%)',
|
||||
backgroundSize: '200% 100%',
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-y-0 right-0 w-6 -mr-[2px]"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, transparent, rgba(196,30,58,0.6))',
|
||||
filter: 'blur(4px)',
|
||||
opacity: shouldReduceMotion ? 0 : 1,
|
||||
}}
|
||||
/>
|
||||
{!shouldReduceMotion && (
|
||||
<>
|
||||
<motion.div
|
||||
className="absolute top-1/2 -translate-y-1/2 right-0 w-2 h-2 rounded-full"
|
||||
style={{ background: '#fff', boxShadow: '0 0 6px rgba(196,30,58,0.8)' }}
|
||||
animate={{ scale: [1, 1.5, 1], opacity: [0.8, 1, 0.8] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, ease: 'easeInOut' }}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute bottom-0 left-0 right-0 h-[1px]"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.15), transparent)',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, transparent, rgba(196,30,58,0.08), transparent)',
|
||||
filter: 'blur(8px)',
|
||||
transform: `scaleX(${typeof scaleX === 'object' ? 'var(--progress, 0)' : 1})`,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user