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