refactor: P1 - remove unused legacy components
- Delete old homepage components - Delete unused UI components
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
“{quote}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user