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:
张翔
2026-05-11 19:03:37 +08:00
parent c474394237
commit f08874f5c4
20 changed files with 794 additions and 112 deletions
+40 -10
View File
@@ -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>
)
}
)
+295
View File
@@ -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>
);
}
+1 -1
View File
@@ -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
)}
/>
+48
View File
@@ -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>
);
}
+46 -12
View File
@@ -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>
);