refactor: P1 - remove unused legacy components

- Delete old homepage components
- Delete unused UI components
This commit is contained in:
张翔
2026-04-30 22:04:07 +08:00
parent fe6e4b1c54
commit f513d9da20
15 changed files with 0 additions and 2140 deletions
-122
View File
@@ -1,122 +0,0 @@
"use client";
import { useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import dynamic from 'next/dynamic';
import { HeroSection } from "@/components/sections/hero-section";
import { SectionSkeleton } from "@/components/ui/loading-skeleton";
import type { ReactNode } from 'react';
declare global {
interface Window {
__isProgrammaticScroll?: boolean;
}
}
const ServicesSection = dynamic(
() => import('@/components/sections/services-section').then(mod => ({ default: mod.ServicesSection })),
{
loading: () => <SectionSkeleton />,
ssr: false
}
);
const HomeSolutionsSection = dynamic(
() => import('@/components/sections/home-solutions-section').then(mod => ({ default: mod.HomeSolutionsSection })),
{
loading: () => <SectionSkeleton />,
ssr: false
}
);
const ProductsSection = dynamic(
() => import('@/components/sections/products-section').then(mod => ({ default: mod.ProductsSection })),
{
loading: () => <SectionSkeleton />,
ssr: false
}
);
const CasesSection = dynamic(
() => import('@/components/sections/cases-section').then(mod => ({ default: mod.CasesSection })),
{
loading: () => <SectionSkeleton />,
ssr: false
}
);
const AboutSection = dynamic(
() => import('@/components/sections/about-section').then(mod => ({ default: mod.AboutSection })),
{
loading: () => <SectionSkeleton />,
ssr: false
}
);
const TeamSection = dynamic(
() => import('@/components/sections/team-section').then(mod => ({ default: mod.TeamSection })),
{
loading: () => <SectionSkeleton />,
ssr: false
}
);
const NewsSection = dynamic(
() => import('@/components/sections/news-section').then(mod => ({ default: mod.NewsSection })),
{
loading: () => <SectionSkeleton />,
ssr: false
}
);
function HomeContent({ heroStats }: { heroStats: ReactNode }) {
const searchParams = useSearchParams();
useEffect(() => {
const section = searchParams.get('section');
if (!section) {return;}
const maxAttempts = 50;
const interval = 100;
let attempts = 0;
const scrollToSection = () => {
const targetElement = document.getElementById(section);
if (targetElement) {
window.__isProgrammaticScroll = true;
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
setTimeout(() => {
window.__isProgrammaticScroll = false;
}, 2000);
return true;
}
return false;
};
if (scrollToSection()) {return;}
const timer = setInterval(() => {
attempts++;
if (scrollToSection() || attempts >= maxAttempts) {
clearInterval(timer);
}
}, interval);
return () => clearInterval(timer);
}, [searchParams]);
return (
<main id="main-content" className="min-h-screen bg-white dark:bg-(--color-bg-primary)">
<HeroSection heroStats={heroStats} />
<ServicesSection />
<HomeSolutionsSection />
<ProductsSection />
<CasesSection />
<AboutSection />
<TeamSection />
<NewsSection />
</main>
);
}
export { HomeContent };
@@ -1,243 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { StaticLink } from '@/components/ui/static-link';
import { RippleButton, SealButton } from '@/components/ui/ripple-button';
import { MagneticButton, BlurReveal, CounterWithEffect } from '@/lib/animations';
import { COMPANY_INFO, STATS } from '@/lib/constants';
import { ArrowRight, Shield, Zap, Award } from 'lucide-react';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { trackButtonClick, trackServiceInterest } from '@/lib/analytics';
interface HeroContentProps {
isVisible: boolean;
}
const features = [
{ icon: Shield, text: '安全可靠' },
{ icon: Zap, text: '高效便捷' },
{ icon: Award, text: '专业服务' },
];
function scrollTo(id: string) {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}
function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>, id: string) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
scrollTo(id);
}
}
export function HeroContent({ isVisible }: HeroContentProps) {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20, scale: 0.95 }}
animate={isVisible ? { opacity: 1, y: 0, scale: 1 } : {}}
transition={shouldReduceMotion ? { duration: 0 } : { duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
className="mb-8"
>
<span className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full border border-[#1C1C1C]/20 bg-[#F5F5F5] text-[#1C1C1C] text-sm font-medium">
</span>
</motion.div>
);
}
export function HeroTitle({ isVisible }: HeroContentProps) {
const shouldReduceMotion = useReducedMotion();
return (
<motion.h1
id="hero-heading"
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={isVisible ? { opacity: 1, y: 0 } : {}}
transition={shouldReduceMotion ? { duration: 0 } : { duration: 0.6, delay: 0.1 }}
className="text-5xl sm:text-6xl lg:text-7xl tracking-tight mb-6 font-brand"
style={{
fontWeight: 'normal',
WebkitFontSmoothing: 'antialiased',
MozOsxFontSmoothing: 'grayscale',
textRendering: 'optimizeLegibility'
}}
>
{COMPANY_INFO.shortName}
</motion.h1>
);
}
export function HeroDescription(_props: HeroContentProps) {
return (
<div className="mb-10">
<BlurReveal delay={0.3}>
<p className="text-xl sm:text-2xl text-[#4A5568] mb-4">
<span className="font-semibold bg-gradient-to-r from-[#C41E3A] via-[#E04A68] to-[#C41E3A] bg-clip-text text-transparent">
</span>
</p>
</BlurReveal>
<BlurReveal delay={0.4}>
<p className="text-lg text-[#718096] max-w-2xl mx-auto leading-relaxed">
</p>
</BlurReveal>
</div>
);
}
export function HeroButtons({ isVisible }: HeroContentProps) {
const shouldReduceMotion = useReducedMotion();
const handleConsultClick = () => {
trackButtonClick('consult_now', 'hero_section');
trackServiceInterest('consultation');
};
const handleLearnMoreClick = () => {
trackButtonClick('learn_more', 'hero_section');
scrollTo('about');
};
return (
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={isVisible ? { opacity: 1, y: 0 } : {}}
transition={shouldReduceMotion ? { duration: 0 } : { duration: 0.6, delay: 0.3 }}
className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-8"
>
<MagneticButton strength={0.4}>
<StaticLink href="/contact" onClick={handleConsultClick}>
<SealButton size="lg" className="min-w-45">
<ArrowRight className="w-4 h-4 ml-2" />
</SealButton>
</StaticLink>
</MagneticButton>
<MagneticButton strength={0.4}>
<RippleButton
size="lg"
variant="outline"
onClick={handleLearnMoreClick}
onKeyDown={(e) => handleKeyDown(e, 'about')}
className="min-w-45"
>
</RippleButton>
</MagneticButton>
</motion.div>
);
}
export function HeroFeatures({ isVisible }: HeroContentProps) {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={isVisible ? { opacity: 1, y: 0 } : {}}
transition={shouldReduceMotion ? { duration: 0 } : { duration: 0.6, delay: 0.35 }}
className="flex flex-wrap gap-4 justify-center mb-16"
>
{features.map((feature, index) => (
<motion.div
key={index}
initial={shouldReduceMotion ? {} : { opacity: 0, scale: 0.9 }}
animate={isVisible ? { opacity: 1, scale: 1 } : {}}
transition={shouldReduceMotion ? { duration: 0 } : { duration: 0.4, delay: 0.4 + index * 0.1 }}
whileHover={shouldReduceMotion ? {} : { scale: 1.05, y: -2 }}
className="flex items-center gap-2 px-4 py-2 rounded-full bg-[#FAFAFA] border border-[#E5E5E5] transition-all duration-300 hover:border-[#1C1C1C] hover:shadow-md cursor-default"
>
<feature.icon className="w-4 h-4 text-[#C41E3A]" />
<span className="text-sm text-[#3D3D3D]">{feature.text}</span>
</motion.div>
))}
</motion.div>
);
}
export function HeroStats() {
const [statsVisible, setStatsVisible] = useState(true);
const shouldReduceMotion = useReducedMotion();
useEffect(() => {
const statsEl = document.getElementById('stats-section');
if (!statsEl) {return;}
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
setStatsVisible(true);
}
},
{ threshold: 0.5 }
);
observer.observe(statsEl);
return () => observer.disconnect();
}, []);
return (
<motion.div
id="stats-section"
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={statsVisible ? { opacity: 1, y: 0 } : {}}
transition={shouldReduceMotion ? { duration: 0 } : { duration: 0.6, delay: 0.4 }}
className="pt-16 border-t border-[#E2E8F0]"
>
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 md:gap-12">
{STATS.map((stat, index) => (
<HeroStatItem
key={stat.label}
stat={stat}
index={index}
shouldAnimate={statsVisible}
shouldReduceMotion={shouldReduceMotion}
/>
))}
</div>
</motion.div>
);
}
function HeroStatItem({ stat, index, shouldAnimate, shouldReduceMotion }: {
stat: { value: string; label: string };
index: number;
shouldAnimate: boolean;
shouldReduceMotion: boolean;
}) {
const numericValue = parseInt(stat.value.replace(/\D/g, ''));
const suffix = stat.value.replace(/[\d]/g, '');
return (
<motion.div
className="group cursor-default text-center"
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20, scale: 0.9 }}
animate={shouldAnimate ? { opacity: 1, y: 0, scale: 1 } : {}}
transition={shouldReduceMotion ? { duration: 0 } : { duration: 0.5, delay: index * 0.1, type: 'spring', stiffness: 100 }}
whileHover={shouldReduceMotion ? {} : { scale: 1.05, y: -5 }}
>
<div className="text-4xl sm:text-5xl font-bold text-[#C41E3A] mb-3">
{shouldAnimate ? (
<CounterWithEffect
end={numericValue}
suffix={suffix}
effect="bounce"
duration={2000}
/>
) : (
<span className="text-[#CBD5E0]">0{suffix}</span>
)}
</div>
<div className="text-sm text-[#718096] group-hover:text-[#4A5568] transition-colors">
{stat.label}
</div>
</motion.div>
);
}
@@ -1,166 +0,0 @@
import { describe, it, expect, jest, beforeAll } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, className, ...props }: { children: React.ReactNode; className?: string }) => (
<div className={className} {...props}>
{children}
</div>
),
section: ({ children, className, ...props }: { children: React.ReactNode; className?: string }) => (
<section className={className} {...props}>
{children}
</section>
),
span: ({ children, className, ...props }: { children: React.ReactNode; className?: string }) => (
<span className={className} {...props}>
{children}
</span>
),
h1: ({ children, className, ...props }: { children: React.ReactNode; className?: string }) => (
<h1 className={className} {...props}>
{children}
</h1>
),
},
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
jest.mock('next/link', () => {
const MockLink = ({ children, href, ...props }: { children: React.ReactNode; href: string }) => (
<a href={href} {...props}>
{children}
</a>
);
MockLink.displayName = 'MockLink';
return MockLink;
});
jest.mock('lucide-react', () => ({
ArrowRight: () => <span data-testid="arrow-right" />,
Shield: () => <span data-testid="shield-icon" />,
Zap: () => <span data-testid="zap-icon" />,
Award: () => <span data-testid="award-icon" />,
}));
jest.mock('next/dynamic', () => ({
__esModule: true,
default: () => {
const MockDynamic = () => null;
MockDynamic.displayName = 'MockDynamic';
return MockDynamic;
},
}));
jest.mock('@/components/ui/ripple-button', () => ({
RippleButton: ({ children, className, ...props }: { children: React.ReactNode; className?: string }) => (
<button className={className} {...props}>
{children}
</button>
),
SealButton: ({ children, className, ...props }: { children: React.ReactNode; className?: string }) => (
<button className={className} {...props}>
{children}
</button>
),
}));
jest.mock('@/lib/animations', () => ({
GradientText: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<span className={className}>{children}</span>
),
MagneticButton: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<button className={className}>{children}</button>
),
BlurReveal: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
CounterWithEffect: ({ end, suffix, className }: { end: number; suffix?: string; className?: string }) => (
<span className={className}>{end}{suffix || ''}</span>
),
}));
jest.mock('@/lib/constants', () => ({
COMPANY_INFO: {
name: '四川睿新致远科技有限公司',
shortName: '睿新致遠',
description: '以智慧连接数字趋势,以伙伴身份陪您成长',
},
STATS: [
{ value: '10+', label: '企业客户' },
{ value: '20+', label: '成功案例' },
{ value: '30+', label: '项目交付' },
{ value: '12+', label: '年团队经验' },
],
}));
jest.mock('./hero-section-atoms', () => ({
HeroContent: () => <div></div>,
HeroTitle: () => <h1></h1>,
HeroDescription: () => <p></p>,
HeroButtons: () => <div><button></button><button></button></div>,
HeroFeatures: () => <div><span></span><span>便</span><span></span></div>,
HeroStats: () => (
<div data-testid="hero-stats">
<span></span>
<span></span>
<span></span>
<span></span>
</div>
),
}));
import { HeroSection } from './hero-section';
import { HeroStats } from './hero-section-atoms';
describe('HeroSection', () => {
beforeAll(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render hero section', () => {
render(<HeroSection heroStats={<HeroStats />} />);
const section = document.querySelector('section#home');
expect(section).toBeInTheDocument();
});
it('should render company name', () => {
render(<HeroSection heroStats={<HeroStats />} />);
expect(screen.getByText('睿新致遠')).toBeInTheDocument();
});
it('should render features', () => {
render(<HeroSection heroStats={<HeroStats />} />);
expect(screen.getByText('安全可靠')).toBeInTheDocument();
expect(screen.getByText('高效便捷')).toBeInTheDocument();
expect(screen.getByText('专业服务')).toBeInTheDocument();
});
});
describe('Statistics', () => {
it('should render statistics section', () => {
render(<HeroSection heroStats={<HeroStats />} />);
expect(screen.getByText('企业客户')).toBeInTheDocument();
expect(screen.getByText('成功案例')).toBeInTheDocument();
expect(screen.getByText('项目交付')).toBeInTheDocument();
expect(screen.getByText('年团队经验')).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have proper ARIA labels', () => {
render(<HeroSection heroStats={<HeroStats />} />);
const section = document.querySelector('section#home');
expect(section).toHaveAttribute('aria-labelledby', 'hero-heading');
});
it('should have accessible buttons', () => {
render(<HeroSection heroStats={<HeroStats />} />);
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThan(0);
});
});
});
-73
View File
@@ -1,73 +0,0 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import dynamic from 'next/dynamic';
import { HeroContent, HeroTitle, HeroDescription, HeroButtons, HeroFeatures } from './hero-section-atoms';
import type { ReactNode } from 'react';
const InkBackground = dynamic(
() => import('@/components/ui/ink-decoration').then(mod => ({ default: mod.InkBackground })),
{ ssr: false }
);
const DataParticleFlow = dynamic(
() => import('@/components/effects/data-particle-flow').then(mod => ({ default: mod.DataParticleFlow })),
{ ssr: false }
);
const SubtleDots = dynamic(
() => import('@/components/effects/subtle-dots').then(mod => ({ default: mod.SubtleDots })),
{ ssr: false }
);
export function HeroSection({ heroStats }: { heroStats: ReactNode }) {
const [isVisible, setIsVisible] = useState(false);
const sectionRef = useRef<HTMLElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
setIsVisible(true);
}
},
{ threshold: 0.1 }
);
if (sectionRef.current) {
observer.observe(sectionRef.current);
}
return () => observer.disconnect();
}, []);
return (
<section
id="home"
ref={sectionRef}
aria-labelledby="hero-heading"
className="relative min-h-screen flex items-center overflow-hidden bg-linear-to-b from-[#FAFAFA] to-white"
>
<InkBackground />
<DataParticleFlow
particleCount={60}
color="#C41E3A"
intensity="subtle"
shape="square"
effect="pulse"
/>
<SubtleDots color="#C41E3A" count={8} />
<div className="container-wide py-24 md:py-32 lg:py-40 relative z-10">
<div className="max-w-4xl mx-auto text-center">
<HeroContent isVisible={isVisible} />
<HeroTitle isVisible={isVisible} />
<HeroDescription isVisible={isVisible} />
<HeroButtons isVisible={isVisible} />
<HeroFeatures isVisible={isVisible} />
{heroStats}
</div>
</div>
</section>
);
}
@@ -1,23 +0,0 @@
import { STATS } from '@/lib/constants';
export function HeroStatsSSR() {
return (
<div className="pt-16 border-t border-[#E2E8F0]">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 md:gap-12">
{STATS.map((stat) => (
<div
key={stat.label}
className="group cursor-default text-center"
>
<div className="text-4xl sm:text-5xl font-bold text-[#C41E3A] mb-3">
{stat.value}
</div>
<div className="text-sm text-[#718096] group-hover:text-[#4A5568] transition-colors">
{stat.label}
</div>
</div>
))}
</div>
</div>
);
}
-185
View File
@@ -1,185 +0,0 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { InkCard, GeometricCard, FlipCard, TiltCard, GlowCard, ExpandCard } from './animated-card';
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, onHoverStart, onHoverEnd, onClick, ...props }: any) => (
<div
{...props}
onMouseEnter={onHoverStart}
onMouseLeave={onHoverEnd}
onClick={onClick}
>
{children}
</div>
),
},
}));
describe('Animated Cards', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('InkCard', () => {
it('should render ink card', () => {
render(<InkCard>Test Content</InkCard>);
expect(screen.getByText('Test Content')).toBeInTheDocument();
});
it('should apply custom className', () => {
const { container } = render(<InkCard className="custom-class">Test</InkCard>);
expect(container.firstChild).toHaveClass('custom-class');
});
it('should handle mouse move', () => {
const { container } = render(<InkCard>Test</InkCard>);
const card = container.firstChild as HTMLElement;
fireEvent.mouseMove(card, {
clientX: 100,
clientY: 100,
});
expect(card).toBeInTheDocument();
});
it('should handle hover events', () => {
const { container } = render(<InkCard>Test</InkCard>);
const card = container.firstChild as HTMLElement;
fireEvent.mouseEnter(card);
fireEvent.mouseLeave(card);
expect(card).toBeInTheDocument();
});
});
describe('GeometricCard', () => {
it('should render geometric card', () => {
render(<GeometricCard>Test Content</GeometricCard>);
expect(screen.getByText('Test Content')).toBeInTheDocument();
});
it('should apply custom className', () => {
const { container } = render(<GeometricCard className="custom-class">Test</GeometricCard>);
expect(container.firstChild).toHaveClass('custom-class');
});
it('should have corner decorations', () => {
const { container } = render(<GeometricCard>Test</GeometricCard>);
const corners = container.querySelectorAll('.absolute');
expect(corners.length).toBeGreaterThan(0);
});
});
describe('FlipCard', () => {
it('should render flip card', () => {
render(
<FlipCard front="Front" back="Back" />
);
expect(screen.getByText('Front')).toBeInTheDocument();
expect(screen.getByText('Back')).toBeInTheDocument();
});
it('should flip on click', () => {
const { container } = render(
<FlipCard front="Front" back="Back" />
);
const card = container.firstChild as HTMLElement;
fireEvent.click(card);
expect(card).toBeInTheDocument();
});
it('should apply custom className', () => {
const { container } = render(
<FlipCard front="Front" back="Back" className="custom-class" />
);
expect(container.firstChild).toHaveClass('custom-class');
});
});
describe('TiltCard', () => {
it('should render tilt card', () => {
render(<TiltCard>Test Content</TiltCard>);
expect(screen.getByText('Test Content')).toBeInTheDocument();
});
it('should apply custom className', () => {
const { container } = render(<TiltCard className="custom-class">Test</TiltCard>);
expect(container.firstChild).toHaveClass('custom-class');
});
it('should handle mouse move', () => {
const { container } = render(<TiltCard>Test</TiltCard>);
const card = container.firstChild as HTMLElement;
fireEvent.mouseMove(card, {
clientX: 100,
clientY: 100,
});
expect(card).toBeInTheDocument();
});
it('should handle mouse leave', () => {
const { container } = render(<TiltCard>Test</TiltCard>);
const card = container.firstChild as HTMLElement;
fireEvent.mouseLeave(card);
expect(card).toBeInTheDocument();
});
});
describe('GlowCard', () => {
it('should render glow card', () => {
render(<GlowCard>Test Content</GlowCard>);
expect(screen.getByText('Test Content')).toBeInTheDocument();
});
it('should apply custom className', () => {
const { container } = render(<GlowCard className="custom-class">Test</GlowCard>);
expect(container.firstChild).toHaveClass('custom-class');
});
it('should handle mouse move', () => {
const { container } = render(<GlowCard>Test</GlowCard>);
const card = container.firstChild as HTMLElement;
fireEvent.mouseMove(card, {
clientX: 100,
clientY: 100,
});
expect(card).toBeInTheDocument();
});
});
describe('ExpandCard', () => {
it('should render expand card', () => {
render(<ExpandCard>Test Content</ExpandCard>);
expect(screen.getByText('Test Content')).toBeInTheDocument();
});
it('should apply custom className', () => {
const { container } = render(<ExpandCard className="custom-class">Test</ExpandCard>);
expect(container.firstChild).toHaveClass('custom-class');
});
it('should expand on click', () => {
const { container } = render(
<ExpandCard expandedContent={<div>Expanded</div>}>Test</ExpandCard>
);
const card = container.firstChild as HTMLElement;
fireEvent.click(card);
expect(screen.getByText('Expanded')).toBeInTheDocument();
});
});
});
-376
View File
@@ -1,376 +0,0 @@
'use client';
import { motion, type HTMLMotionProps, type Variants } from 'framer-motion';
import { useRef, useState, type ReactNode, type MouseEvent } from 'react';
import { cn } from '@/lib/utils';
const cardVariants: Variants = {
hidden: {
opacity: 0,
y: 30,
scale: 0.95,
},
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: {
duration: 0.6,
ease: [0.16, 1, 0.3, 1],
},
},
};
interface InkCardProps extends HTMLMotionProps<'div'> {
children: ReactNode;
className?: string;
hoverScale?: number;
hoverRotate?: number;
inkColor?: string;
showInkOnHover?: boolean;
}
export function InkCard({
children,
className = '',
hoverScale = 1.02,
hoverRotate = 0,
inkColor = 'rgba(196, 30, 58, 0.05)',
showInkOnHover = true,
...props
}: InkCardProps) {
const cardRef = useRef<HTMLDivElement>(null);
const [inkPosition, setInkPosition] = useState({ x: 50, y: 50 });
const [isHovered, setIsHovered] = useState(false);
const handleMouseMove = (e: MouseEvent<HTMLDivElement>) => {
if (!cardRef.current) {return;}
const rect = cardRef.current.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
setInkPosition({ x, y });
};
return (
<motion.div
ref={cardRef}
variants={cardVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.2 }}
whileHover={{
scale: hoverScale,
rotate: hoverRotate,
y: -4,
}}
onHoverStart={() => setIsHovered(true)}
onHoverEnd={() => setIsHovered(false)}
onMouseMove={handleMouseMove}
transition={{
type: 'spring',
stiffness: 300,
damping: 20,
}}
className={cn(
'relative overflow-hidden bg-white border border-[#E5E5E5] rounded-xl transition-shadow duration-300',
'hover:shadow-[0_12px_24px_rgba(28,28,28,0.08)]',
className
)}
{...props}
>
{showInkOnHover && (
<motion.div
className="absolute inset-0 pointer-events-none"
initial={{ opacity: 0 }}
animate={{ opacity: isHovered ? 1 : 0 }}
transition={{ duration: 0.3 }}
>
<div
className="absolute w-[200%] h-[200%] rounded-full"
style={{
background: `radial-gradient(circle, ${inkColor} 0%, transparent 70%)`,
left: `${inkPosition.x - 100}%`,
top: `${inkPosition.y - 100}%`,
}}
/>
</motion.div>
)}
<div className="relative z-10">{children}</div>
</motion.div>
);
}
interface GeometricCardProps extends HTMLMotionProps<'div'> {
children: ReactNode;
className?: string;
cornerColor?: string;
}
export function GeometricCard({
children,
className = '',
cornerColor = '#C41E3A',
...props
}: GeometricCardProps) {
return (
<motion.div
variants={cardVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.2 }}
whileHover={{
y: -4,
transition: { type: 'spring', stiffness: 300, damping: 20 },
}}
className={cn(
'relative bg-white border border-[#E5E5E5] rounded-xl p-6 transition-all duration-300',
'hover:shadow-[0_12px_24px_rgba(28,28,28,0.08)]',
'group',
className
)}
{...props}
>
<div className="absolute top-0 left-0 w-3 h-3 border-t-2 border-l-2 rounded-tl-lg opacity-0 group-hover:opacity-100 transition-opacity duration-300" style={{ borderColor: cornerColor }} />
<div className="absolute top-0 right-0 w-3 h-3 border-t-2 border-r-2 rounded-tr-lg opacity-0 group-hover:opacity-100 transition-opacity duration-300" style={{ borderColor: cornerColor }} />
<div className="absolute bottom-0 left-0 w-3 h-3 border-b-2 border-l-2 rounded-bl-lg opacity-0 group-hover:opacity-100 transition-opacity duration-300" style={{ borderColor: cornerColor }} />
<div className="absolute bottom-0 right-0 w-3 h-3 border-b-2 border-r-2 rounded-br-lg opacity-0 group-hover:opacity-100 transition-opacity duration-300" style={{ borderColor: cornerColor }} />
{children}
</motion.div>
);
}
interface FlipCardProps {
front: ReactNode;
back: ReactNode;
className?: string;
frontClassName?: string;
backClassName?: string;
}
export function FlipCard({
front,
back,
className = '',
frontClassName = '',
backClassName = '',
}: FlipCardProps) {
const [isFlipped, setIsFlipped] = useState(false);
return (
<motion.div
className={cn('relative cursor-pointer perspective-1000', className)}
onClick={() => setIsFlipped(!isFlipped)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsFlipped(!isFlipped);
}
}}
role="button"
tabIndex={0}
aria-pressed={isFlipped}
aria-label={isFlipped ? '点击查看正面' : '点击查看背面'}
style={{ perspective: 1000 }}
>
<motion.div
className="relative w-full h-full"
initial={false}
animate={{ rotateY: isFlipped ? 180 : 0 }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
style={{ transformStyle: 'preserve-3d' }}
>
<div
className={cn(
'absolute inset-0 backface-hidden',
frontClassName
)}
style={{ backfaceVisibility: 'hidden' }}
>
{front}
</div>
<div
className={cn(
'absolute inset-0 backface-hidden',
backClassName
)}
style={{
backfaceVisibility: 'hidden',
transform: 'rotateY(180deg)',
}}
>
{back}
</div>
</motion.div>
</motion.div>
);
}
interface TiltCardProps extends HTMLMotionProps<'div'> {
children: ReactNode;
className?: string;
maxTilt?: number;
scale?: number;
}
export function TiltCard({
children,
className = '',
maxTilt = 10,
scale = 1.02,
...props
}: TiltCardProps) {
const cardRef = useRef<HTMLDivElement>(null);
const [rotateX, setRotateX] = useState(0);
const [rotateY, setRotateY] = useState(0);
const handleMouseMove = (e: MouseEvent<HTMLDivElement>) => {
if (!cardRef.current) {return;}
const rect = cardRef.current.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const mouseX = e.clientX - centerX;
const mouseY = e.clientY - centerY;
const rotateXValue = (mouseY / (rect.height / 2)) * -maxTilt;
const rotateYValue = (mouseX / (rect.width / 2)) * maxTilt;
setRotateX(rotateXValue);
setRotateY(rotateYValue);
};
const handleMouseLeave = () => {
setRotateX(0);
setRotateY(0);
};
return (
<motion.div
ref={cardRef}
className={cn('relative', className)}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
style={{ transformStyle: 'preserve-3d' }}
animate={{
rotateX,
rotateY,
scale: rotateX || rotateY ? scale : 1,
}}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
{...props}
>
{children}
</motion.div>
);
}
interface GlowCardProps extends HTMLMotionProps<'div'> {
children: ReactNode;
className?: string;
glowColor?: string;
}
export function GlowCard({
children,
className = '',
glowColor = 'rgba(196, 30, 58, 0.15)',
...props
}: GlowCardProps) {
const cardRef = useRef<HTMLDivElement>(null);
const [glowPosition, setGlowPosition] = useState({ x: 50, y: 50 });
const handleMouseMove = (e: MouseEvent<HTMLDivElement>) => {
if (!cardRef.current) {return;}
const rect = cardRef.current.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
setGlowPosition({ x, y });
};
return (
<motion.div
ref={cardRef}
variants={cardVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.2 }}
whileHover={{ y: -4 }}
onMouseMove={handleMouseMove}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
className={cn(
'relative overflow-hidden bg-white border border-[#E5E5E5] rounded-xl',
'transition-shadow duration-300',
className
)}
{...props}
>
<motion.div
className="absolute inset-0 pointer-events-none opacity-0 hover:opacity-100 transition-opacity duration-300"
style={{
background: `radial-gradient(circle at ${glowPosition.x}% ${glowPosition.y}%, ${glowColor} 0%, transparent 50%)`,
}}
/>
<div className="relative z-10">{children}</div>
</motion.div>
);
}
interface ExpandCardProps extends HTMLMotionProps<'div'> {
children: ReactNode;
className?: string;
expandedContent?: ReactNode;
}
export function ExpandCard({
children,
className = '',
expandedContent,
...props
}: ExpandCardProps) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<motion.div
variants={cardVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.2 }}
whileHover={{ y: -4 }}
onClick={() => setIsExpanded(!isExpanded)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsExpanded(!isExpanded);
}
}}
role="button"
tabIndex={0}
aria-expanded={isExpanded}
aria-label={isExpanded ? '点击收起详情' : '点击展开详情'}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
className={cn(
'relative overflow-hidden bg-white border border-[#E5E5E5] rounded-xl cursor-pointer',
'transition-shadow duration-300',
'hover:shadow-[0_12px_24px_rgba(28,28,28,0.08)]',
className
)}
{...props}
>
<div className="p-6">{children}</div>
{expandedContent && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{
height: isExpanded ? 'auto' : 0,
opacity: isExpanded ? 1 : 0,
}}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
<div className="px-6 pb-6 border-t border-[#E5E5E5] pt-4">
{expandedContent}
</div>
</motion.div>
)}
</motion.div>
);
}
-74
View File
@@ -1,74 +0,0 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { GlassCard } from './glass-card';
describe('GlassCard', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render glass card', () => {
const { container } = render(<GlassCard>Test Content</GlassCard>);
expect(container.firstChild).toBeInTheDocument();
});
it('should render children', () => {
render(<GlassCard>Test Content</GlassCard>);
expect(screen.getByText('Test Content')).toBeInTheDocument();
});
it('should apply custom className', () => {
const { container } = render(<GlassCard className="custom-class">Test</GlassCard>);
expect(container.firstChild).toHaveClass('custom-class');
});
});
describe('Variants', () => {
it('should render default variant', () => {
const { container } = render(<GlassCard>Test</GlassCard>);
expect(container.firstChild).toBeInTheDocument();
});
it('should render elevated variant', () => {
const { container } = render(<GlassCard variant="elevated">Test</GlassCard>);
expect(container.firstChild).toBeInTheDocument();
});
it('should render outline variant', () => {
const { container } = render(<GlassCard variant="outline">Test</GlassCard>);
expect(container.firstChild).toBeInTheDocument();
});
it('should render glow variant', () => {
const { container } = render(<GlassCard variant="glow">Test</GlassCard>);
expect(container.firstChild).toBeInTheDocument();
});
});
describe('Styling', () => {
it('should have rounded class', () => {
const { container } = render(<GlassCard>Test</GlassCard>);
expect(container.firstChild).toHaveClass('rounded-2xl');
});
it('should have border class', () => {
const { container } = render(<GlassCard>Test</GlassCard>);
expect(container.firstChild).toHaveClass('border');
});
it('should have backdrop-blur class', () => {
const { container } = render(<GlassCard>Test</GlassCard>);
expect(container.firstChild).toHaveClass('backdrop-blur-xl');
});
});
describe('Forward Ref', () => {
it('should forward ref', () => {
const ref = { current: null };
render(<GlassCard ref={ref}>Test</GlassCard>);
expect(ref.current).toBeInstanceOf(HTMLDivElement);
});
});
});
-41
View File
@@ -1,41 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const glassCardVariants = cva(
"rounded-2xl border backdrop-blur-xl transition-all duration-300",
{
variants: {
variant: {
default:
"border-gray-800/50 bg-gray-900/50 hover:border-purple-500/50 hover:bg-gray-800/60 hover:shadow-[0_0_40px_rgba(139,92,246,0.15)] hover:-translate-y-1",
elevated:
"border-gray-800 bg-gray-900/80 shadow-2xl hover:border-purple-500/50 hover:shadow-purple-500/20 hover:-translate-y-2",
outline:
"border-gray-800/50 bg-transparent hover:border-purple-500/30 hover:bg-gray-900/30",
glow:
"border-gray-800/50 bg-gray-900/40 relative overflow-hidden hover:border-purple-500/40",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface GlassCardProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof glassCardVariants> {}
const GlassCard = React.forwardRef<HTMLDivElement, GlassCardProps>(
({ className, variant, ...props }, ref) => (
<div
ref={ref}
className={cn(glassCardVariants({ variant, className }))}
{...props}
/>
)
)
GlassCard.displayName = "GlassCard"
export { GlassCard, glassCardVariants }
-110
View File
@@ -1,110 +0,0 @@
'use client';
import { useEffect, useRef } from 'react';
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
size: number;
opacity: number;
}
interface ParticleBackgroundProps {
particleCount?: number;
className?: string;
}
export function ParticleBackground({
particleCount = 50,
className = ''
}: ParticleBackgroundProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {return;}
const ctx = canvas.getContext('2d');
if (!ctx) {return;}
let animationFrameId: number;
let particles: Particle[] = [];
const resizeCanvas = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
const createParticles = () => {
particles = [];
for (let i = 0; i < particleCount; i++) {
particles.push({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
vx: (Math.random() - 0.5) * 0.5,
vy: (Math.random() - 0.5) * 0.5,
size: Math.random() * 2 + 1,
opacity: Math.random() * 0.5 + 0.2,
});
}
};
const drawParticles = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
particles.forEach((particle, i) => {
particle.x += particle.vx;
particle.y += particle.vy;
if (particle.x < 0 || particle.x > canvas.width) {particle.vx *= -1;}
if (particle.y < 0 || particle.y > canvas.height) {particle.vy *= -1;}
ctx.beginPath();
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
ctx.fillStyle = `rgba(0, 217, 255, ${particle.opacity})`;
ctx.fill();
particles.forEach((otherParticle, j) => {
if (i === j) {return;}
const dx = particle.x - otherParticle.x;
const dy = particle.y - otherParticle.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 150) {
ctx.beginPath();
ctx.moveTo(particle.x, particle.y);
ctx.lineTo(otherParticle.x, otherParticle.y);
ctx.strokeStyle = `rgba(0, 217, 255, ${0.1 * (1 - distance / 150)})`;
ctx.stroke();
}
});
});
animationFrameId = requestAnimationFrame(drawParticles);
};
resizeCanvas();
createParticles();
drawParticles();
window.addEventListener('resize', () => {
resizeCanvas();
createParticles();
});
return () => {
cancelAnimationFrame(animationFrameId);
window.removeEventListener('resize', resizeCanvas);
};
}, [particleCount]);
return (
<canvas
ref={canvasRef}
className={`absolute inset-0 pointer-events-none ${className}`}
style={{ opacity: 0.3 }}
/>
);
}
-92
View File
@@ -1,92 +0,0 @@
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
const MockRippleButton = ({
children,
onClick,
disabled,
variant,
size
}: {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
variant?: string;
size?: string;
}) => {
return (
<button
onClick={onClick}
disabled={disabled}
className={`${variant || 'default'} ${size || 'default'}`}
>
{children}
</button>
);
};
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => (
<div onClick={onClick}>{children}</div>
),
},
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
describe('RippleButton', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render button', () => {
render(<MockRippleButton>Click me</MockRippleButton>);
const button = screen.getByRole('button', { name: /click me/i });
expect(button).toBeInTheDocument();
});
it('should render children', () => {
render(<MockRippleButton>Test Button</MockRippleButton>);
expect(screen.getByText('Test Button')).toBeInTheDocument();
});
it('should apply variant classes', () => {
render(<MockRippleButton variant="secondary">Secondary</MockRippleButton>);
const button = screen.getByRole('button');
expect(button).toHaveClass('secondary');
});
it('should apply size classes', () => {
render(<MockRippleButton size="lg">Large</MockRippleButton>);
const button = screen.getByRole('button');
expect(button).toHaveClass('lg');
});
});
describe('Functionality', () => {
it('should handle click events', () => {
const handleClick = jest.fn();
render(<MockRippleButton onClick={handleClick}>Click me</MockRippleButton>);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(handleClick).toHaveBeenCalled();
});
it('should be disabled when disabled prop is true', () => {
render(<MockRippleButton disabled>Disabled</MockRippleButton>);
const button = screen.getByRole('button');
expect(button).toBeDisabled();
});
});
describe('Accessibility', () => {
it('should be focusable', () => {
render(<MockRippleButton>Focus me</MockRippleButton>);
const button = screen.getByRole('button');
expect(button).not.toHaveAttribute('tabindex', '-1');
});
});
});
-193
View File
@@ -1,193 +0,0 @@
'use client';
import * as React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const rippleButtonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-300 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-[#1C1C1C] focus-visible:ring-offset-2 focus-visible:ring-offset-white relative overflow-hidden',
{
variants: {
variant: {
default:
'bg-[#C41E3A] text-white hover:bg-[#A01830] hover:shadow-[0_8px_20px_rgba(196,30,58,0.35)]',
secondary:
'bg-[#1C1C1C] text-white hover:bg-[#0A0A0A] hover:shadow-[0_8px_20px_rgba(28,28,28,0.35)]',
destructive:
'bg-[#C41E3A] text-white hover:bg-[#A01830] focus-visible:ring-[#C41E3A]',
outline:
'border-2 border-[#1C1C1C] bg-transparent text-[#1C1C1C] hover:bg-[#F5F5F5] hover:shadow-[0_4px_12px_rgba(28,28,28,0.2)]',
ghost:
'text-[#3D3D3D] hover:bg-[#F5F5F5] hover:text-[#1C1C1C]',
link:
'text-[#1C1C1C] underline-offset-4 hover:underline hover:text-[#C41E3A]',
seal:
'bg-[#C41E3A] text-white font-semibold hover:bg-[#A01830] shadow-[0_6px_20px_rgba(196,30,58,0.3)]',
},
size: {
default: 'h-11 px-4 py-2.5',
sm: 'h-9 rounded-md px-3 text-xs',
lg: 'h-12 rounded-lg px-6 text-base',
icon: 'h-11 w-11',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
interface Ripple {
x: number;
y: number;
id: number;
}
export interface RippleButtonProps
extends VariantProps<typeof rippleButtonVariants> {
rippleColor?: string;
rippleDuration?: number;
children?: React.ReactNode;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
onKeyDown?: (e: React.KeyboardEvent<HTMLButtonElement>) => void;
className?: string;
disabled?: boolean;
}
const RippleButton = React.forwardRef<HTMLButtonElement, RippleButtonProps>(
({ className, variant, size, rippleColor, rippleDuration = 800, onClick, children, disabled, ...props }, ref) => {
const [ripples, setRipples] = React.useState<Ripple[]>([]);
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (disabled) {return;}
const button = e.currentTarget;
const rect = button.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const id = Date.now();
setRipples((prev) => [...prev, { x, y, id }]);
setTimeout(() => {
setRipples((prev) => prev.filter((r) => r.id !== id));
}, rippleDuration);
onClick?.(e);
};
const getRippleColor = () => {
if (rippleColor) {return rippleColor;}
if (variant === 'outline' || variant === 'ghost' || variant === 'link') {
return 'rgba(196, 30, 58, 0.2)';
}
return 'rgba(255, 255, 255, 0.4)';
};
return (
<motion.button
ref={ref}
whileHover={disabled ? {} : { scale: 1.03, y: -3 }}
whileTap={disabled ? {} : { scale: 0.97 }}
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
className={cn(rippleButtonVariants({ variant, size, className }))}
onClick={handleClick}
disabled={disabled}
{...props}
>
{children}
<AnimatePresence>
{ripples.map((ripple) => (
<motion.span
key={ripple.id}
initial={{ scale: 0, opacity: 0.8 }}
animate={{ scale: 5, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: rippleDuration / 1000, ease: [0.16, 1, 0.3, 1] }}
className="absolute w-6 h-6 rounded-full pointer-events-none"
style={{
left: ripple.x - 12,
top: ripple.y - 12,
backgroundColor: getRippleColor(),
}}
/>
))}
</AnimatePresence>
</motion.button>
);
}
);
RippleButton.displayName = 'RippleButton';
export interface SealButtonProps extends VariantProps<typeof rippleButtonVariants> {
children?: React.ReactNode;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
onKeyDown?: (e: React.KeyboardEvent<HTMLButtonElement>) => void;
className?: string;
disabled?: boolean;
}
const SealButton = React.forwardRef<HTMLButtonElement, SealButtonProps>(
({ className, variant = 'seal', size, onClick, children, disabled, ...props }, ref) => {
const [isPressed, setIsPressed] = React.useState(false);
const [showInk, setShowInk] = React.useState(false);
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (disabled) {return;}
setIsPressed(true);
setShowInk(true);
setTimeout(() => setIsPressed(false), 600);
setTimeout(() => setShowInk(false), 800);
onClick?.(e);
};
return (
<motion.button
ref={ref}
initial={{ scale: 1, rotate: 0 }}
whileHover={disabled ? {} : { scale: 1.06, y: -2 }}
whileTap={disabled ? {} : { scale: 0.94, rotate: -3 }}
animate={
isPressed
? {
scale: [1, 1.15, 0.95, 1.02, 1],
rotate: [0, -8, 8, -3, 0],
}
: {}
}
transition={{
type: 'spring',
stiffness: 400,
damping: 12,
}}
className={cn(
rippleButtonVariants({ variant, size, className }),
'seal-stamp'
)}
onClick={handleClick}
disabled={disabled}
{...props}
>
<AnimatePresence>
{showInk && (
<motion.div
initial={{ scale: 0, opacity: 0.6 }}
animate={{ scale: 3, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] }}
className="absolute inset-0 flex items-center justify-center pointer-events-none"
>
<div className="w-8 h-8 rounded-full bg-white/20" />
</motion.div>
)}
</AnimatePresence>
<span className="relative z-10 inline-flex items-center gap-1">{children}</span>
</motion.button>
);
}
);
SealButton.displayName = 'SealButton';
export { RippleButton, SealButton, rippleButtonVariants };
-108
View File
@@ -1,108 +0,0 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { TestimonialCard } from './testimonial-card';
jest.mock('next/image', () => ({
__esModule: true,
default: ({ src, alt, width, height, className }: any) => (
<img src={src} alt={alt} width={width} height={height} className={className} />
),
}));
describe('TestimonialCard', () => {
const defaultProps = {
quote: 'Test quote',
author: 'Test Author',
position: 'Manager',
company: 'Test Company',
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render testimonial card', () => {
render(<TestimonialCard {...defaultProps} />);
const blockquote = screen.getByText((content, element) => {
return element?.tagName === 'BLOCKQUOTE' && content.includes('Test quote');
});
expect(blockquote).toBeInTheDocument();
});
it('should render author name', () => {
render(<TestimonialCard {...defaultProps} />);
expect(screen.getByText('Test Author')).toBeInTheDocument();
});
it('should render position and company', () => {
render(<TestimonialCard {...defaultProps} />);
expect(screen.getByText(/Manager · Test Company/)).toBeInTheDocument();
});
it('should render quote with quotes', () => {
render(<TestimonialCard {...defaultProps} />);
const blockquote = screen.getByText((content, element) => {
return element?.tagName === 'BLOCKQUOTE' && content.includes('Test quote');
});
expect(blockquote).toBeInTheDocument();
});
});
describe('Rating', () => {
it('should render 5 stars by default', () => {
render(<TestimonialCard {...defaultProps} />);
const stars = document.querySelectorAll('svg.w-4.h-4');
expect(stars).toHaveLength(5);
});
it('should render custom rating', () => {
render(<TestimonialCard {...defaultProps} rating={3} />);
const stars = document.querySelectorAll('svg.w-4.h-4');
expect(stars).toHaveLength(3);
});
it('should not render stars when rating is 0', () => {
render(<TestimonialCard {...defaultProps} rating={0} />);
const stars = document.querySelectorAll('svg.w-4.h-4');
expect(stars).toHaveLength(0);
});
});
describe('Avatar', () => {
it('should render avatar when avatarUrl is provided', () => {
render(<TestimonialCard {...defaultProps} avatarUrl="/avatar.jpg" />);
const avatar = screen.getByAltText('Test Author');
expect(avatar).toBeInTheDocument();
});
it('should not render avatar when avatarUrl is not provided', () => {
render(<TestimonialCard {...defaultProps} />);
const avatar = screen.queryByAltText('Test Author');
expect(avatar).not.toBeInTheDocument();
});
});
describe('Styling', () => {
it('should have correct card classes', () => {
const { container } = render(<TestimonialCard {...defaultProps} />);
const card = container.firstChild as HTMLElement;
expect(card).toHaveClass('relative');
expect(card).toHaveClass('p-8');
expect(card).toHaveClass('rounded-lg');
});
it('should have border class', () => {
const { container } = render(<TestimonialCard {...defaultProps} />);
const card = container.firstChild as HTMLElement;
expect(card).toHaveClass('border');
});
it('should have background class', () => {
const { container } = render(<TestimonialCard {...defaultProps} />);
const card = container.firstChild as HTMLElement;
expect(card.className).toContain('bg-white');
});
});
});
-65
View File
@@ -1,65 +0,0 @@
'use client';
import Image from 'next/image';
import { Quote } from 'lucide-react';
export interface TestimonialCardProps {
quote: string;
author: string;
position: string;
company: string;
avatarUrl?: string;
rating?: number;
}
export function TestimonialCard({
quote,
author,
position,
company,
avatarUrl,
rating = 5,
}: TestimonialCardProps) {
return (
<div className="relative p-8 rounded-lg border border-[#E5E5E5]/50 bg-white hover:shadow-md transition-shadow">
<Quote className="absolute top-6 right-6 w-8 h-8 text-[#C41E3A]/10" />
{rating > 0 && (
<div className="flex gap-1 mb-4">
{Array.from({ length: rating }).map((_, i) => (
<svg
key={i}
className="w-4 h-4 text-[#C41E3A]"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
)}
<blockquote className="text-base text-[#171717] mb-6 leading-relaxed">
&ldquo;{quote}&rdquo;
</blockquote>
<div className="flex items-center gap-4">
{avatarUrl && (
<Image
src={avatarUrl}
alt={author}
width={48}
height={48}
className="w-12 h-12 rounded-full object-cover"
/>
)}
<div>
<div className="font-semibold text-[#171717]">{author}</div>
<div className="text-sm text-[#737373]">
{position} · {company}
</div>
</div>
</div>
</div>
);
}
-269
View File
@@ -1,269 +0,0 @@
'use client';
import { useState, useCallback, useRef, useEffect, memo } from 'react';
import { cn } from '@/lib/utils';
interface SwipeableProps {
children: React.ReactNode;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
onSwipeUp?: () => void;
onSwipeDown?: () => void;
threshold?: number;
className?: string;
disabled?: boolean;
}
export const Swipeable = memo(function Swipeable({
children,
onSwipeLeft,
onSwipeRight,
onSwipeUp,
onSwipeDown,
threshold = 50,
className,
disabled = false,
}: SwipeableProps) {
const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(null);
const [touchEnd, setTouchEnd] = useState<{ x: number; y: number } | null>(null);
const ref = useRef<HTMLDivElement>(null);
const onTouchStart = useCallback((e: React.TouchEvent) => {
if (disabled) return;
const touch = e.targetTouches[0];
if (!touch) return;
setTouchEnd(null);
setTouchStart({
x: touch.clientX,
y: touch.clientY,
});
}, [disabled]);
const onTouchMove = useCallback((e: React.TouchEvent) => {
if (disabled) return;
const touch = e.targetTouches[0];
if (!touch) return;
setTouchEnd({
x: touch.clientX,
y: touch.clientY,
});
}, [disabled]);
const onTouchEnd = useCallback(() => {
if (!touchStart || !touchEnd || disabled) return;
const distanceX = touchStart.x - touchEnd.x;
const distanceY = touchStart.y - touchEnd.y;
const isHorizontalSwipe = Math.abs(distanceX) > Math.abs(distanceY);
if (isHorizontalSwipe) {
if (Math.abs(distanceX) > threshold) {
if (distanceX > 0) {
onSwipeLeft?.();
} else {
onSwipeRight?.();
}
}
} else {
if (Math.abs(distanceY) > threshold) {
if (distanceY > 0) {
onSwipeUp?.();
} else {
onSwipeDown?.();
}
}
}
}, [touchStart, touchEnd, threshold, onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown, disabled]);
return (
<div
ref={ref}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
className={className}
>
{children}
</div>
);
});
interface PullToRefreshProps {
children: React.ReactNode;
onRefresh: () => Promise<void>;
disabled?: boolean;
className?: string;
}
export const PullToRefresh = memo(function PullToRefresh({
children,
onRefresh,
disabled = false,
className,
}: PullToRefreshProps) {
const [isRefreshing, setIsRefreshing] = useState(false);
const [pullDistance, setPullDistance] = useState(0);
const touchStartY = useRef(0);
const containerRef = useRef<HTMLDivElement>(null);
const handleTouchStart = useCallback((e: React.TouchEvent) => {
if (disabled || isRefreshing) return;
const touch = e.touches[0];
if (touch) {
touchStartY.current = touch.clientY;
}
}, [disabled, isRefreshing]);
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (disabled || isRefreshing) return;
const container = containerRef.current;
if (!container || container.scrollTop > 0) return;
const touch = e.touches[0];
if (!touch) return;
const distance = touch.clientY - touchStartY.current;
if (distance > 0) {
setPullDistance(Math.min(distance, 100));
}
}, [disabled, isRefreshing]);
const handleTouchEnd = useCallback(async () => {
if (disabled || isRefreshing) return;
if (pullDistance > 60) {
setIsRefreshing(true);
try {
await onRefresh();
} finally {
setIsRefreshing(false);
}
}
setPullDistance(0);
}, [disabled, isRefreshing, pullDistance, onRefresh]);
return (
<div
ref={containerRef}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
className={cn('relative overflow-auto', className)}
>
{pullDistance > 0 && (
<div
className="absolute top-0 left-0 right-0 flex items-center justify-center bg-white/80 backdrop-blur-sm z-10"
style={{ height: pullDistance }}
>
<div className={cn(
'w-6 h-6 border-2 border-[#C41E3A] border-t-transparent rounded-full',
isRefreshing && 'animate-spin'
)} />
</div>
)}
{children}
</div>
);
});
interface TouchFeedbackProps {
children: React.ReactNode;
className?: string;
disabled?: boolean;
}
export const TouchFeedback = memo(function TouchFeedback({
children,
className,
disabled = false,
}: TouchFeedbackProps) {
const [isPressed, setIsPressed] = useState(false);
return (
<div
className={cn(
'transition-transform duration-100',
isPressed && !disabled && 'scale-95',
className
)}
onTouchStart={() => !disabled && setIsPressed(true)}
onTouchEnd={() => setIsPressed(false)}
onTouchCancel={() => setIsPressed(false)}
>
{children}
</div>
);
});
interface LongPressProps {
children: React.ReactNode;
onLongPress: () => void;
delay?: number;
className?: string;
disabled?: boolean;
}
export const LongPress = memo(function LongPress({
children,
onLongPress,
delay = 500,
className,
disabled = false,
}: LongPressProps) {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const [isPressed, setIsPressed] = useState(false);
const handleTouchStart = useCallback(() => {
if (disabled) return;
setIsPressed(true);
timeoutRef.current = setTimeout(() => {
onLongPress();
setIsPressed(false);
}, delay);
}, [disabled, delay, onLongPress]);
const handleTouchEnd = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setIsPressed(false);
}, []);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return (
<div
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
className={cn(
'transition-transform duration-100',
isPressed && !disabled && 'scale-95',
className
)}
>
{children}
</div>
);
});
export function useTouchDevice() {
const [isTouchDevice, setIsTouchDevice] = useState(false);
useEffect(() => {
setIsTouchDevice(
'ontouchstart' in window ||
navigator.maxTouchPoints > 0
);
}, []);
return isTouchDevice;
}