refactor: P1 - remove unused legacy components
- Delete old homepage components - Delete unused UI components
This commit is contained in:
@@ -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