feat(website): 三轮视觉改造与页面过渡动画

改造概要(30项):
- 第一轮:Hero重构/Section差异化/SocialProof强化/CTA对比度/About架构
- 第二轮:字体优化/背景交替/Solutions差异化/Footer五列/MegaDropdown修复
- 第三轮:卡片交互/表单层级/CTA统一/时间线标记/连接线/三列布局/移动导航/Button微交互/SEO Schema
- P3-2:template.tsx+Framer Motion页面过渡/loading.tsx加载状态
- 清理:删除未用组件/hooks,修复重复移动导航,清理冗余CSS
This commit is contained in:
张翔
2026-05-10 08:20:27 +08:00
parent 747405dc96
commit 37296b5717
133 changed files with 2583 additions and 13487 deletions
-133
View File
@@ -1,133 +0,0 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { AnimatedNumber, StatCard } from './animated-number';
jest.mock('framer-motion', () => ({
motion: {
span: ({ children, ...props }: any) => <span {...props}>{children}</span>,
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
},
useInView: () => true,
}));
describe('AnimatedNumber', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render number', () => {
render(<AnimatedNumber value={100} />);
expect(screen.getByText('0')).toBeInTheDocument();
});
it('should render with prefix', () => {
render(<AnimatedNumber value={100} prefix="$" />);
expect(screen.getByText(/\$0/)).toBeInTheDocument();
});
it('should render with suffix', () => {
render(<AnimatedNumber value={100} suffix="+" />);
expect(screen.getByText(/0\+/)).toBeInTheDocument();
});
it('should render with prefix and suffix', () => {
render(<AnimatedNumber value={100} prefix="$" suffix="+" />);
expect(screen.getByText(/\$0\+/)).toBeInTheDocument();
});
it('should render with custom className', () => {
const { container } = render(<AnimatedNumber value={100} className="custom-class" />);
const element = container.querySelector('.custom-class');
expect(element).toBeInTheDocument();
});
});
describe('Animation', () => {
it('should accept duration prop', () => {
render(<AnimatedNumber value={100} duration={3000} />);
const element = screen.getByText('0');
expect(element).toBeInTheDocument();
});
it('should accept delay prop', () => {
render(<AnimatedNumber value={100} delay={500} />);
const element = screen.getByText('0');
expect(element).toBeInTheDocument();
});
it('should start from 0', () => {
render(<AnimatedNumber value={100} />);
const element = screen.getByText('0');
expect(element).toBeInTheDocument();
});
});
describe('Edge Cases', () => {
it('should handle zero value', () => {
render(<AnimatedNumber value={0} />);
const element = screen.getByText('0');
expect(element).toBeInTheDocument();
});
it('should handle large numbers', () => {
render(<AnimatedNumber value={1000000} />);
const element = screen.getByText('0');
expect(element).toBeInTheDocument();
});
it('should handle decimal numbers', () => {
render(<AnimatedNumber value={99} />);
const element = screen.getByText('0');
expect(element).toBeInTheDocument();
});
});
});
describe('StatCard', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render stat card', () => {
render(<StatCard value={100} label="Users" />);
expect(screen.getByText('Users')).toBeInTheDocument();
});
it('should render with prefix', () => {
render(<StatCard value={100} label="Revenue" prefix="$" />);
expect(screen.getByText('Revenue')).toBeInTheDocument();
});
it('should render with suffix', () => {
render(<StatCard value={100} label="Growth" suffix="%" />);
expect(screen.getByText('Growth')).toBeInTheDocument();
});
it('should render with prefix and suffix', () => {
render(<StatCard value={100} label="Score" prefix="+" suffix="pts" />);
expect(screen.getByText('Score')).toBeInTheDocument();
});
it('should render with index', () => {
render(<StatCard value={100} label="Users" index={2} />);
expect(screen.getByText('Users')).toBeInTheDocument();
});
});
describe('Styling', () => {
it('should have text-center class', () => {
const { container } = render(<StatCard value={100} label="Users" />);
const card = container.querySelector('.text-center');
expect(card).toBeInTheDocument();
});
it('should have group class', () => {
const { container } = render(<StatCard value={100} label="Users" />);
const card = container.querySelector('.group');
expect(card).toBeInTheDocument();
});
});
});
-103
View File
@@ -1,103 +0,0 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { motion, useInView } from 'framer-motion';
interface AnimatedNumberProps {
value: number;
duration?: number;
delay?: number;
suffix?: string;
prefix?: string;
className?: string;
}
export function AnimatedNumber({
value,
duration = 2000,
delay = 0,
suffix = '',
prefix = '',
className = '',
}: AnimatedNumberProps) {
const [count, setCount] = useState(0);
const ref = useRef<HTMLSpanElement>(null);
const isInView = useInView(ref, { once: true, margin: '-100px' });
const hasAnimated = useRef(false);
useEffect(() => {
if (!isInView || hasAnimated.current) {return;}
hasAnimated.current = true;
const startTime = Date.now() + delay;
const endTime = startTime + duration;
const animate = () => {
const now = Date.now();
if (now < startTime) {
requestAnimationFrame(animate);
return;
}
if (now >= endTime) {
setCount(value);
return;
}
const progress = (now - startTime) / duration;
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
const currentValue = Math.floor(easeOutQuart * value);
setCount(currentValue);
requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
}, [isInView, value, duration, delay]);
return (
<motion.span
ref={ref}
initial={{ opacity: 0, scale: 0.5 }}
animate={isInView ? { opacity: 1, scale: 1 } : {}}
transition={{ duration: 0.5, delay: delay / 1000 }}
className={className}
>
{prefix}{count}{suffix}
</motion.span>
);
}
interface StatCardProps {
value: number;
label: string;
suffix?: string;
prefix?: string;
index?: number;
}
export function StatCard({ value, label, suffix = '', prefix = '', index = 0 }: StatCardProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
className="text-center group cursor-default"
>
<div className="text-3xl sm:text-4xl font-bold tech-gradient-text mb-2">
<AnimatedNumber
value={value}
suffix={suffix}
prefix={prefix}
delay={index * 100}
/>
</div>
<div className="text-sm text-[var(--color-text-muted)] group-hover:text-[var(--color-text-tertiary)] transition-colors">
{label}
</div>
</motion.div>
);
}
-67
View File
@@ -1,67 +0,0 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { BackButton } from './back-button';
describe('BackButton', () => {
const mockBack = jest.fn();
beforeEach(() => {
mockBack.mockClear();
// Mock window.history.back
Object.defineProperty(window, 'history', {
value: {
back: mockBack,
forward: jest.fn(),
go: jest.fn(),
length: 1,
pushState: jest.fn(),
replaceState: jest.fn(),
scrollRestoration: 'auto',
state: null,
},
writable: true,
});
});
describe('Rendering', () => {
it('should render back button', () => {
render(<BackButton />);
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('should render button text', () => {
render(<BackButton />);
expect(screen.getByText('返回')).toBeInTheDocument();
});
it('should render arrow icon', () => {
const { container } = render(<BackButton />);
const svg = container.querySelector('svg');
expect(svg).toBeInTheDocument();
});
});
describe('Interaction', () => {
it('should call window.history.back() when clicked', () => {
render(<BackButton />);
fireEvent.click(screen.getByRole('button'));
expect(mockBack).toHaveBeenCalledTimes(1);
});
});
describe('Styling', () => {
it('should have ghost variant', () => {
const { container } = render(<BackButton />);
const button = container.querySelector('button');
expect(button).toBeInTheDocument();
});
it('should have small size', () => {
const { container } = render(<BackButton />);
const button = container.querySelector('button');
expect(button).toBeInTheDocument();
});
});
});
-24
View File
@@ -1,24 +0,0 @@
'use client';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
/**
* BackButton - 统一的返回按钮组件
*
* 在纯静态导出模式下使用 window.history.back() 替代 Next.js 的 router.back()
* 确保在无服务端路由的环境下正常工作。
*/
export function BackButton() {
return (
<Button
variant="ghost"
size="sm"
className="text-[#5C5C5C] hover:text-[#C41E3A] hover:bg-transparent h-auto py-2 px-3"
onClick={() => window.history.back()}
>
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
);
}
+2 -2
View File
@@ -35,11 +35,11 @@ export function BackToTop() {
exit={shouldReduceMotion ? {} : { opacity: 0, y: 20, scale: 0.8 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
onClick={scrollToTop}
className="fixed right-4 bottom-20 md:bottom-8 md:right-8 z-40 p-3 bg-[#C41E3A] text-white rounded-full shadow-lg hover:bg-[#A01830] hover:shadow-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:ring-offset-2"
className="fixed right-4 bottom-20 md:bottom-8 md:right-8 z-40 p-3 bg-[var(--color-brand-primary)] text-white rounded-full shadow-lg hover:bg-[var(--color-brand-primary-hover)] hover:shadow-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-primary)] focus:ring-offset-2"
aria-label="返回顶部"
title="返回顶部"
style={{
boxShadow: '0 4px 14px rgba(196, 30, 58, 0.4)',
boxShadow: '0 4px 14px rgba(var(--color-brand-primary-rgb), 0.4)',
}}
whileHover={shouldReduceMotion ? {} : { scale: 1.1 }}
whileTap={shouldReduceMotion ? {} : { scale: 0.95 }}
+9 -9
View File
@@ -7,21 +7,21 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-3 py-1 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-[#1C1C1C] focus-visible:ring-2 focus-visible:ring-[#1C1C1C]/50 transition-all duration-300 overflow-hidden",
"inline-flex items-center justify-center rounded-full border px-3 py-1 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-[var(--color-primary)] focus-visible:ring-2 focus-visible:ring-[var(--color-primary)]/50 transition-all duration-300 overflow-hidden",
{
variants: {
variant: {
default: "bg-[#C41E3A] text-white border-transparent shadow-sm",
default: "bg-[var(--color-brand-primary)] text-white border-transparent shadow-sm",
secondary:
"bg-[#1C1C1C] text-white border-transparent shadow-sm",
"bg-[var(--color-primary)] text-white border-transparent shadow-sm",
destructive:
"bg-[#C41E3A] text-white border-transparent hover:bg-[#A01830]",
"bg-[var(--color-brand-primary)] text-white border-transparent hover:bg-[var(--color-brand-primary-hover)]",
outline:
"border-[#1C1C1C] text-[#1C1C1C] bg-transparent hover:bg-[#F5F5F5]",
ghost: "text-[#5C5C5C] hover:text-[#1C1C1C] hover:bg-[#F5F5F5]",
success: "bg-[#16A34A] text-white border-transparent hover:bg-[#15803D]",
warning: "bg-[#D97706] text-white border-transparent hover:bg-[#B45309]",
info: "bg-[#5C5C5C] text-white border-transparent hover:bg-[#3D3D3D]",
"border-[var(--color-primary)] text-[var(--color-primary)] bg-transparent hover:bg-[var(--color-primary-lighter)]",
ghost: "text-[var(--color-text-placeholder)] hover:text-[var(--color-primary)] hover:bg-[var(--color-primary-lighter)]",
success: "bg-[var(--color-success)] text-white border-transparent hover:bg-[var(--color-success-hover)]",
warning: "bg-[var(--color-warning)] text-white border-transparent hover:bg-[var(--color-warning-hover)]",
info: "bg-[var(--color-info)] text-white border-transparent hover:bg-[var(--color-primary-light)]",
},
},
defaultVariants: {
+5 -5
View File
@@ -19,7 +19,7 @@ describe('Button Component', () => {
it('should apply default variant styles', () => {
render(<Button>Default</Button>);
const button = screen.getByRole('button');
expect(button).toHaveClass('bg-[#C41E3A]');
expect(button).toHaveClass('bg-[var(--color-brand-primary)]');
});
});
@@ -27,20 +27,20 @@ describe('Button Component', () => {
it('should apply secondary variant styles', () => {
render(<Button variant="secondary">Secondary</Button>);
const button = screen.getByRole('button');
expect(button).toHaveClass('bg-[#1C1C1C]');
expect(button).toHaveClass('bg-[var(--color-primary)]');
});
it('should apply outline variant styles', () => {
render(<Button variant="outline">Outline</Button>);
const button = screen.getByRole('button');
expect(button).toHaveClass('border-2');
expect(button).toHaveClass('border-[#1C1C1C]');
expect(button).toHaveClass('border-[var(--color-primary)]');
});
it('should apply ghost variant styles', () => {
render(<Button variant="ghost">Ghost</Button>);
const button = screen.getByRole('button');
expect(button).toHaveClass('text-[#3D3D3D]');
expect(button).toHaveClass('text-[var(--color-text-secondary)]');
});
it('should apply link variant styles', () => {
@@ -52,7 +52,7 @@ describe('Button Component', () => {
it('should apply destructive variant styles', () => {
render(<Button variant="destructive">Destructive</Button>);
const button = screen.getByRole('button');
expect(button).toHaveClass('bg-[#C41E3A]');
expect(button).toHaveClass('bg-[var(--color-brand-primary)]');
});
});
+7 -7
View File
@@ -7,22 +7,22 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-200 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-[#1C1C1C] focus-visible:ring-offset-2 focus-visible:ring-offset-white min-h-[44px] min-w-[44px] touch-manipulation",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-200 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-white min-h-[44px] min-w-[44px] touch-manipulation",
{
variants: {
variant: {
default:
"bg-[#C41E3A] text-white hover:bg-[#A01830] hover:shadow-[0_4px_12px_rgba(196,30,58,0.25)] hover:-translate-y-0.5 active:scale-[0.98]",
"bg-[var(--color-brand-primary)] text-white hover:bg-[var(--color-brand-primary-hover)] hover:shadow-[0_4px_12px_rgba(var(--color-brand-primary-rgb),0.25)] hover:-translate-y-0.5 active:scale-[0.98]",
secondary:
"bg-[#1C1C1C] text-white hover:bg-[#0A0A0A] hover:shadow-[0_4px_12px_rgba(28,28,28,0.25)] hover:-translate-y-0.5 active:scale-[0.98]",
"bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary-hover)] hover:shadow-[0_4px_12px_rgba(var(--color-primary-rgb),0.25)] hover:-translate-y-0.5 active:scale-[0.98]",
destructive:
"bg-[#C41E3A] text-white hover:bg-[#A01830] focus-visible:ring-[#C41E3A]",
"bg-[var(--color-brand-primary)] text-white hover:bg-[var(--color-brand-primary-hover)] focus-visible:ring-[var(--color-brand-primary)]",
outline:
"border-2 border-[#1C1C1C] bg-transparent text-[#1C1C1C]",
"border-2 border-[var(--color-primary)] bg-transparent text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-white hover:-translate-y-0.5 active:scale-[0.98]",
ghost:
"text-[#3D3D3D] hover:bg-[#F5F5F5] hover:text-[#1C1C1C]",
"text-[var(--color-primary-light)] hover:bg-[var(--color-primary-lighter)] hover:text-[var(--color-primary)]",
link:
"text-[#1C1C1C] underline-offset-4 hover:underline hover:text-[#C41E3A]",
"text-[var(--color-primary)] underline-offset-4 hover:underline hover:text-[var(--color-brand-primary)]",
},
size: {
default: "h-11 px-4 py-2",
+4 -4
View File
@@ -31,7 +31,7 @@ describe('Card Components', () => {
it('should apply default styles', () => {
render(<Card data-testid="card">Test</Card>);
const card = screen.getByTestId('card');
expect(card).toHaveClass('bg-[#FAFAFA]');
expect(card).toHaveClass('bg-[var(--color-bg-secondary)]');
expect(card).toHaveClass('rounded-xl');
});
@@ -44,7 +44,7 @@ describe('Card Components', () => {
it('should have hover effects', () => {
render(<Card data-testid="card">Test</Card>);
const card = screen.getByTestId('card');
expect(card).toHaveClass('hover:border-[#1C1C1C]');
expect(card).toHaveClass('hover:border-[var(--color-text-primary)]');
expect(card).toHaveClass('hover:shadow-[0_8px_24px_rgba(0,0,0,0.06)]');
});
});
@@ -88,7 +88,7 @@ describe('Card Components', () => {
render(<CardTitle data-testid="title">Test</CardTitle>);
const title = screen.getByTestId('title');
expect(title).toHaveClass('font-semibold');
expect(title).toHaveClass('text-[#1C1C1C]');
expect(title).toHaveClass('text-[var(--color-text-primary)]');
});
});
@@ -107,7 +107,7 @@ describe('Card Components', () => {
it('should apply default styles', () => {
render(<CardDescription data-testid="desc">Test</CardDescription>);
const desc = screen.getByTestId('desc');
expect(desc).toHaveClass('text-[#5C5C5C]');
expect(desc).toHaveClass('text-[var(--color-text-muted)]');
expect(desc).toHaveClass('text-sm');
});
});
+4 -4
View File
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card"
className={cn(
"bg-[#FAFAFA] text-[#1C1C1C] flex flex-col gap-6 rounded-xl border border-[#E5E5E5] py-6 shadow-[0_2px_8px_rgba(0,0,0,0.04)] transition-all duration-300 hover:border-[#1C1C1C] hover:shadow-[0_8px_24px_rgba(0,0,0,0.06)] hover:-translate-y-1",
"bg-[var(--color-bg-section)] text-[var(--color-text-primary)] flex flex-col gap-6 rounded-xl border border-[var(--color-border-primary)] py-6 shadow-[0_2px_8px_rgba(0,0,0,0.04)] transition-all duration-300 hover:border-[var(--color-primary)] hover:shadow-[0_8px_24px_rgba(0,0,0,0.06)] hover:-translate-y-1",
className
)}
{...props}
@@ -32,7 +32,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold text-[#1C1C1C]", className)}
className={cn("leading-none font-semibold text-[var(--color-text-primary)]", className)}
{...props}
/>
)
@@ -42,7 +42,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-[#5C5C5C] text-sm", className)}
className={cn("text-[var(--color-text-placeholder)] text-sm", className)}
{...props}
/>
)
@@ -75,7 +75,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6 border-t border-[#E5E5E5]", className)}
className={cn("flex items-center px-6 [.border-t]:pt-6 border-t border-[var(--color-border-primary)]", className)}
{...props}
/>
)
+13 -13
View File
@@ -23,24 +23,24 @@ interface ScenarioConfig {
const scenarioConfig: Record<string, ScenarioConfig> = {
isolation: {
icon: Lock,
accentColor: '#C41E3A',
accentColor: 'var(--color-brand-primary)',
accentColorRgb: '196, 30, 58',
glowStart: '#C41E3A',
glowEnd: '#7C3AED',
glowStart: 'var(--color-brand-primary)',
glowEnd: 'var(--color-accent-purple)',
},
growth: {
icon: TrendingUp,
accentColor: '#D97706',
accentColor: 'var(--color-warning)',
accentColorRgb: '217, 119, 6',
glowStart: '#D97706',
glowEnd: '#16A34A',
glowStart: 'var(--color-warning)',
glowEnd: 'var(--color-success)',
},
compliance: {
icon: Shield,
accentColor: '#16A34A',
accentColor: 'var(--color-success)',
accentColorRgb: '22, 163, 74',
glowStart: '#16A34A',
glowEnd: '#0891B2',
glowStart: 'var(--color-success)',
glowEnd: 'var(--color-accent-cyan)',
},
};
@@ -68,20 +68,20 @@ export function ChallengeCard({ title, description, scenario, href, index }: Cha
strokeWidth={1.8}
/>
</div>
<span className="text-xs font-mono tracking-widest text-[#A3A3A3]">
<span className="text-xs font-mono tracking-widest text-[var(--color-text-subtle)]">
{String(index + 1).padStart(2, '0')}
</span>
</div>
<h3 className="text-xl font-semibold mb-3 leading-tight tracking-tight text-[#1C1C1C]">
<h3 className="text-xl font-semibold mb-3 leading-tight tracking-tight text-[var(--color-text-primary)]">
{title}
</h3>
<p className="text-sm text-[#595959] leading-relaxed mb-6 min-h-[3.5rem]">
<p className="text-sm text-[var(--color-text-muted)] leading-relaxed mb-6 min-h-[3.5rem]">
{description}
</p>
<div className="flex items-center gap-2 text-sm font-medium text-[#C41E3A]">
<div className="flex items-center gap-2 text-sm font-medium text-[var(--color-brand-primary)]">
<span></span>
<ArrowRight className="w-4 h-4" />
</div>
-172
View File
@@ -1,172 +0,0 @@
import { describe, it, expect } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from './dialog';
jest.mock('@radix-ui/react-dialog', () => ({
Root: ({ children, open }: any) => <div data-open={open}>{children}</div>,
Trigger: ({ children, ...props }: any) => <button {...props}>{children}</button>,
Portal: ({ children }: any) => <div>{children}</div>,
Close: ({ children, ...props }: any) => <button {...props}>{children}</button>,
Overlay: ({ children, ...props }: any) => <div {...props}>{children}</div>,
Content: ({ children, ...props }: any) => <div {...props}>{children}</div>,
Title: ({ children, ...props }: any) => <h2 {...props}>{children}</h2>,
Description: ({ children, ...props }: any) => <p {...props}>{children}</p>,
}));
describe('Dialog Components', () => {
describe('Dialog', () => {
it('should render dialog root', () => {
render(
<Dialog>
<div>Dialog Content</div>
</Dialog>
);
expect(screen.getByText('Dialog Content')).toBeInTheDocument();
});
});
describe('DialogTrigger', () => {
it('should render trigger button', () => {
render(
<Dialog>
<DialogTrigger>Open Dialog</DialogTrigger>
</Dialog>
);
expect(screen.getByRole('button', { name: 'Open Dialog' })).toBeInTheDocument();
});
});
describe('DialogContent', () => {
it('should render content with children', () => {
render(
<Dialog open>
<DialogContent>
<p>Dialog Body</p>
</DialogContent>
</Dialog>
);
expect(screen.getByText('Dialog Body')).toBeInTheDocument();
});
it('should apply custom className', () => {
render(
<Dialog open>
<DialogContent className="custom-class">
<p>Test</p>
</DialogContent>
</Dialog>
);
const content = screen.getByText('Test').parentElement;
expect(content).toHaveClass('custom-class');
});
});
describe('DialogHeader', () => {
it('should render header with children', () => {
render(<DialogHeader>Header Content</DialogHeader>);
expect(screen.getByText('Header Content')).toBeInTheDocument();
});
it('should apply custom className', () => {
render(<DialogHeader className="custom-header">Test</DialogHeader>);
const header = screen.getByText('Test');
expect(header).toHaveClass('custom-header');
});
});
describe('DialogFooter', () => {
it('should render footer with children', () => {
render(<DialogFooter>Footer Content</DialogFooter>);
expect(screen.getByText('Footer Content')).toBeInTheDocument();
});
it('should apply custom className', () => {
render(<DialogFooter className="custom-footer">Test</DialogFooter>);
const footer = screen.getByText('Test');
expect(footer).toHaveClass('custom-footer');
});
});
describe('DialogTitle', () => {
it('should render title text', () => {
render(<DialogTitle>Dialog Title</DialogTitle>);
expect(screen.getByText('Dialog Title')).toBeInTheDocument();
});
it('should render as h2 element', () => {
render(<DialogTitle>Test Title</DialogTitle>);
const title = screen.getByRole('heading', { level: 2 });
expect(title).toBeInTheDocument();
});
it('should apply custom className', () => {
render(<DialogTitle className="custom-title">Test</DialogTitle>);
const title = screen.getByText('Test');
expect(title).toHaveClass('custom-title');
});
});
describe('DialogDescription', () => {
it('should render description text', () => {
render(<DialogDescription>Dialog Description</DialogDescription>);
expect(screen.getByText('Dialog Description')).toBeInTheDocument();
});
it('should apply custom className', () => {
render(<DialogDescription className="custom-desc">Test</DialogDescription>);
const desc = screen.getByText('Test');
expect(desc).toHaveClass('custom-desc');
});
});
describe('Dialog Composition', () => {
it('should render complete dialog structure', () => {
render(
<Dialog open>
<DialogContent>
<DialogHeader>
<DialogTitle>Test Dialog</DialogTitle>
<DialogDescription>This is a test dialog</DialogDescription>
</DialogHeader>
<div>Dialog Body</div>
<DialogFooter>
<button>Close Dialog</button>
</DialogFooter>
</DialogContent>
</Dialog>
);
expect(screen.getByText('Test Dialog')).toBeInTheDocument();
expect(screen.getByText('This is a test dialog')).toBeInTheDocument();
expect(screen.getByText('Dialog Body')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Close Dialog' })).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have accessible title', () => {
render(<DialogTitle>Accessible Title</DialogTitle>);
const title = screen.getByRole('heading', { name: 'Accessible Title' });
expect(title).toBeInTheDocument();
});
it('should support custom ARIA attributes', () => {
render(
<DialogContent aria-label="Test Dialog">
<p>Content</p>
</DialogContent>
);
const content = screen.getByText('Content').parentElement;
expect(content).toHaveAttribute('aria-label', 'Test Dialog');
});
});
});
-122
View File
@@ -1,122 +0,0 @@
'use client';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight text-[#1C1C1C]',
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-[#5C5C5C]', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
-164
View File
@@ -1,164 +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 {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
} from './dropdown-menu';
describe('DropdownMenu', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render dropdown menu trigger', () => {
render(
<DropdownMenu>
<DropdownMenuTrigger>Open Menu</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Item 1</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
expect(screen.getByText('Open Menu')).toBeInTheDocument();
});
it('should render menu items', () => {
render(
<DropdownMenu defaultOpen>
<DropdownMenuTrigger>Open Menu</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Item 1</DropdownMenuItem>
<DropdownMenuItem>Item 2</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
expect(screen.getByText('Item 1')).toBeInTheDocument();
expect(screen.getByText('Item 2')).toBeInTheDocument();
});
it('should render menu label', () => {
render(
<DropdownMenu defaultOpen>
<DropdownMenuTrigger>Open Menu</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Menu Label</DropdownMenuLabel>
</DropdownMenuContent>
</DropdownMenu>
);
expect(screen.getByText('Menu Label')).toBeInTheDocument();
});
it('should render separator', () => {
render(
<DropdownMenu defaultOpen>
<DropdownMenuTrigger>Open Menu</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Item 1</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Item 2</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
const separator = document.querySelector('[role="separator"]');
expect(separator).toBeInTheDocument();
});
});
describe('Interaction', () => {
it('should open menu on trigger click', () => {
render(
<DropdownMenu>
<DropdownMenuTrigger>Open Menu</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Item 1</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
const trigger = screen.getByText('Open Menu');
fireEvent.click(trigger);
expect(trigger).toBeInTheDocument();
});
it('should close menu on item click', () => {
const onSelect = jest.fn();
render(
<DropdownMenu defaultOpen>
<DropdownMenuTrigger>Open Menu</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onSelect={onSelect}>Item 1</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
const item = screen.getByText('Item 1');
fireEvent.click(item);
expect(onSelect).toHaveBeenCalled();
});
});
describe('DropdownMenuShortcut', () => {
it('should render shortcut text', () => {
render(
<DropdownMenu defaultOpen>
<DropdownMenuTrigger>Open Menu</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
Save
<DropdownMenuShortcut>S</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
expect(screen.getByText('Save')).toBeInTheDocument();
expect(screen.getByText('⌘S')).toBeInTheDocument();
});
});
describe('Styling', () => {
it('should apply custom className to trigger', () => {
render(
<DropdownMenu>
<DropdownMenuTrigger className="custom-trigger">Open Menu</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Item 1</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
expect(screen.getByText('Open Menu')).toBeInTheDocument();
});
it('should apply custom className to content', () => {
render(
<DropdownMenu defaultOpen>
<DropdownMenuTrigger>Open Menu</DropdownMenuTrigger>
<DropdownMenuContent className="custom-content">
<DropdownMenuItem>Item 1</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
it('should apply custom className to item', () => {
render(
<DropdownMenu defaultOpen>
<DropdownMenuTrigger>Open Menu</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem className="custom-item">Item 1</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
});
});
-224
View File
@@ -1,224 +0,0 @@
'use client';
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-[#F0F0F0] dark:focus:bg-[#333333] data-[state=open]:bg-[#F0F0F0] dark:data-[state=open]:bg-[#333333]',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<svg
className="ml-auto h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m9 18 6-6-6-6" />
</svg>
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-lg border border-[#E5E5E5] dark:border-[#333333] bg-white dark:bg-[#171717] p-1 text-[#171717] dark:text-[#FAFAFA] shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-lg border border-[#E5E5E5] dark:border-[#333333] bg-white dark:bg-[#171717] p-1 text-[#171717] dark:text-[#FAFAFA] shadow-lg',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-[#F0F0F0] dark:focus:bg-[#333333] focus:text-[#171717] dark:focus:text-[#FAFAFA] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-[#F0F0F0] dark:focus:bg-[#333333] focus:text-[#171717] dark:focus:text-[#FAFAFA] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<svg
className="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-[#F0F0F0] dark:focus:bg-[#333333] focus:text-[#171717] dark:focus:text-[#FAFAFA] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<svg
className="h-2 w-2 fill-current"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" />
</svg>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-[#E5E5E5] dark:bg-[#333333]', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
+1 -1
View File
@@ -151,7 +151,7 @@ describe('ErrorBoundary', () => {
);
const button = screen.getByRole('button', { name: '重试' });
expect(button).toHaveClass('bg-[#C41E3A]');
expect(button).toHaveClass('bg-[var(--color-brand-primary)]');
expect(button).toHaveClass('text-white');
});
});
+3 -3
View File
@@ -48,13 +48,13 @@ export class ErrorBoundary extends Component<Props, State> {
/>
</svg>
</div>
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4"></h2>
<p className="text-[#5C5C5C] mb-6">
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4"></h2>
<p className="text-[var(--color-text-placeholder)] mb-6">
</p>
<button
onClick={() => this.setState({ hasError: false, error: undefined })}
className="px-6 py-2.5 bg-[#C41E3A] text-white rounded-lg hover:bg-[#A01830] transition-colors focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:ring-offset-2"
className="px-6 py-2.5 bg-[var(--color-brand-primary)] text-white rounded-lg hover:bg-[var(--color-brand-primary-hover)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-primary)] focus:ring-offset-2"
aria-label="重试"
>
+20 -23
View File
@@ -18,16 +18,14 @@ function FlipDigit({ digit, prevDigit }: FlipDigitProps) {
return (
<div className="relative w-14 h-20 sm:w-16 sm:h-24 md:w-20 md:h-28 perspective-1000">
{/* 背景卡片 - 静态显示当前数字 */}
<div className="absolute inset-0 bg-gradient-to-b from-white via-white to-gray-50 rounded-lg shadow-xl overflow-hidden border border-gray-200">
{/* 上半部分 */}
<div className="absolute top-0 left-0 right-0 h-1/2 bg-gradient-to-b from-white to-gray-50 border-b border-gray-300 overflow-hidden">
<div className="absolute inset-0 flex items-center justify-center text-4xl sm:text-5xl md:text-6xl font-bold text-[#C41E3A]">
<div className="absolute inset-0 bg-gradient-to-b from-[var(--color-bg-primary)] via-[var(--color-bg-primary)] to-[var(--color-flip-card-bg)] rounded-lg shadow-xl overflow-hidden border border-[var(--color-flip-card-border)]">
<div className="absolute top-0 left-0 right-0 h-1/2 bg-gradient-to-b from-[var(--color-bg-primary)] to-[var(--color-flip-card-bg)] border-b border-[var(--color-flip-card-border)] overflow-hidden">
<div className="absolute inset-0 flex items-center justify-center text-4xl sm:text-5xl md:text-6xl font-bold text-[var(--color-brand-primary)]">
{digit}
</div>
</div>
{/* 下半部分 */}
<div className="absolute bottom-0 left-0 right-0 h-1/2 bg-gradient-to-b from-white to-gray-50 overflow-hidden">
<div className="absolute inset-0 flex items-center justify-center text-4xl sm:text-5xl md:text-6xl font-bold text-[#C41E3A]">
<div className="absolute bottom-0 left-0 right-0 h-1/2 bg-gradient-to-b from-[var(--color-bg-primary)] to-[var(--color-flip-card-bg)] overflow-hidden">
<div className="absolute inset-0 flex items-center justify-center text-4xl sm:text-5xl md:text-6xl font-bold text-[var(--color-brand-primary)]">
{digit}
</div>
</div>
@@ -42,13 +40,13 @@ function FlipDigit({ digit, prevDigit }: FlipDigitProps) {
animate={{ rotateX: -180 }}
exit={{ rotateX: -180 }}
transition={{ duration: 0.6, ease: [0.4, 0, 0.2, 1] }}
className="absolute inset-0 bg-gradient-to-b from-white to-gray-50 rounded-t-lg overflow-hidden border-t border-l border-r border-gray-200"
className="absolute inset-0 bg-gradient-to-b from-[var(--color-bg-primary)] to-[var(--color-flip-card-bg)] rounded-t-lg overflow-hidden border-t border-l border-r border-[var(--color-flip-card-border)]"
style={{
transformOrigin: 'bottom',
backfaceVisibility: 'hidden',
}}
>
<div className="absolute inset-0 flex items-center justify-center text-4xl sm:text-5xl md:text-6xl font-bold text-[#C41E3A]">
<div className="absolute inset-0 flex items-center justify-center text-4xl sm:text-5xl md:text-6xl font-bold text-[var(--color-brand-primary)]">
{prevDigit}
</div>
</motion.div>
@@ -60,22 +58,21 @@ function FlipDigit({ digit, prevDigit }: FlipDigitProps) {
animate={{ rotateX: 0 }}
exit={{ rotateX: 0 }}
transition={{ duration: 0.6, ease: [0.4, 0, 0.2, 1] }}
className="absolute inset-0 bg-gradient-to-b from-white to-gray-50 rounded-b-lg overflow-hidden border-b border-l border-r border-gray-200"
className="absolute inset-0 bg-gradient-to-b from-[var(--color-bg-primary)] to-[var(--color-flip-card-bg)] rounded-b-lg overflow-hidden border-b border-l border-r border-[var(--color-flip-card-border)]"
style={{
transformOrigin: 'top',
backfaceVisibility: 'hidden',
}}
>
<div className="absolute inset-0 flex items-center justify-center text-4xl sm:text-5xl md:text-6xl font-bold text-[#C41E3A]">
<div className="absolute inset-0 flex items-center justify-center text-4xl sm:text-5xl md:text-6xl font-bold text-[var(--color-brand-primary)]">
{digit}
</div>
</motion.div>
</AnimatePresence>
{/* 中间分割线和装饰 */}
<div className="absolute top-1/2 left-0 right-0 h-px bg-gray-400 transform -translate-y-1/2" />
<div className="absolute top-0 left-0 right-0 h-px bg-gray-300/50" />
<div className="absolute bottom-0 left-0 right-0 h-px bg-gray-300/50" />
<div className="absolute top-1/2 left-0 right-0 h-px bg-[var(--color-flip-card-divider)] transform -translate-y-1/2" />
<div className="absolute top-0 left-0 right-0 h-px bg-[var(--color-flip-card-divider-subtle)]/50" />
<div className="absolute bottom-0 left-0 right-0 h-px bg-[var(--color-flip-card-divider-subtle)]/50" />
{/* 侧面阴影增强立体感 */}
<div className="absolute inset-0 rounded-lg shadow-[inset_0_2px_4px_rgba(0,0,0,0.1)] pointer-events-none" />
@@ -116,7 +113,7 @@ function FlipCard({ value, label, maxDigits = 2 }: FlipCardProps) {
/>
))}
</div>
<div className="text-sm sm:text-base text-[#5C5C5C] font-medium">{label}</div>
<div className="text-sm sm:text-base text-[var(--color-text-placeholder)] font-medium">{label}</div>
</div>
);
}
@@ -129,21 +126,21 @@ interface FlipClockProps {
export function FlipClock({ years, months, days }: FlipClockProps) {
return (
<div className="bg-[#FFFBF5] rounded-2xl p-8 border border-[#E5E5E5]">
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6"></h2>
<p className="text-[#5C5C5C] mb-6 leading-relaxed">
<span className="font-semibold text-[#1C1C1C]">2026 1 15 </span>
<div className="bg-[var(--color-bg-secondary)] rounded-2xl p-8 border border-[var(--color-border-primary)]">
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6"></h2>
<p className="text-[var(--color-text-placeholder)] mb-6 leading-relaxed">
<span className="font-semibold text-[var(--color-text-primary)]">2026 1 15 </span>
</p>
<div className="flex flex-wrap justify-center items-center gap-4 sm:gap-6 mb-6">
<FlipCard value={years} label="年" maxDigits={2} />
<div className="text-2xl sm:text-3xl font-bold text-[#C41E3A]">:</div>
<div className="text-2xl sm:text-3xl font-bold text-[var(--color-brand-primary)]">:</div>
<FlipCard value={months} label="个月" maxDigits={2} />
<div className="text-2xl sm:text-3xl font-bold text-[#C41E3A]">:</div>
<div className="text-2xl sm:text-3xl font-bold text-[var(--color-brand-primary)]">:</div>
<FlipCard value={days} label="天" maxDigits={3} />
</div>
<p className="text-[#5C5C5C] leading-relaxed font-medium">
<p className="text-[var(--color-text-placeholder)] leading-relaxed font-medium">
</p>
</div>
-583
View File
@@ -1,583 +0,0 @@
'use client';
import { motion } from 'framer-motion';
import { useMemo, useState, useEffect } from 'react';
interface InkDropProps {
size?: number;
opacity?: number;
color?: string;
blur?: number;
delay?: number;
duration?: number;
className?: string;
}
export function InkDrop({
size = 20,
opacity = 0.15,
color = '#1C1C1C',
blur = 0,
delay = 0,
duration: _duration = 8,
className = ''
}: InkDropProps) {
return (
<motion.div
className={`absolute rounded-full ${className}`}
style={{
width: size,
height: size,
backgroundColor: color,
opacity,
filter: blur > 0 ? `blur(${blur}px)` : 'none',
}}
initial={{ scale: 0, opacity: 0 }}
animate={{
scale: [0.8, 1.2, 1],
opacity: [0, opacity, opacity],
}}
transition={{
duration: 1.5,
delay,
ease: [0.16, 1, 0.3, 1],
}}
/>
);
}
interface InkSplashProps {
size?: number;
color?: string;
opacity?: number;
delay?: number;
className?: string;
}
export function InkSplash({
size = 60,
color = '#C41E3A',
opacity = 0.2,
delay = 0,
className = ''
}: InkSplashProps) {
return (
<motion.svg
viewBox="0 0 100 100"
width={size}
height={size}
className={`absolute ${className}`}
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity, scale: 1 }}
transition={{ duration: 1.2, delay, ease: [0.16, 1, 0.3, 1] }}
>
<motion.path
d="M50 10 Q30 25 35 50 Q30 75 50 90 Q70 75 65 50 Q70 25 50 10"
fill={color}
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ duration: 2, delay: delay + 0.3, ease: [0.16, 1, 0.3, 1] }}
/>
</motion.svg>
);
}
interface SealStampProps {
size?: number;
color?: string;
opacity?: number;
delay?: number;
className?: string;
}
export function SealStamp({
size = 40,
color = '#C41E3A',
opacity = 0.15,
delay = 0,
className = ''
}: SealStampProps) {
return (
<motion.div
className={`absolute ${className}`}
style={{
width: size,
height: size,
border: `2px solid ${color}`,
borderRadius: '4px',
opacity: 0,
transform: 'rotate(-8deg)',
}}
initial={{ opacity: 0, scale: 1.5, rotate: -20 }}
animate={{ opacity, scale: 1, rotate: -8 }}
transition={{
type: 'spring',
stiffness: 200,
damping: 15,
delay,
}}
>
<motion.div
className="absolute inset-1 border border-current"
style={{ borderColor: color, opacity: 0.5 }}
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
transition={{ duration: 0.5, delay: delay + 0.3 }}
/>
</motion.div>
);
}
interface InkStainProps {
size?: number;
color?: string;
opacity?: number;
blur?: number;
delay?: number;
className?: string;
}
export function InkStain({
size = 100,
color = '#1C1C1C',
opacity = 0.05,
blur = 20,
delay = 0,
className = ''
}: InkStainProps) {
return (
<motion.div
className={`absolute ${className}`}
style={{
width: size,
height: size * 0.8,
backgroundColor: color,
borderRadius: '50% 40% 60% 45%',
opacity: 0,
filter: `blur(${blur}px)`,
}}
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity, scale: 1 }}
transition={{ duration: 2, delay, ease: [0.16, 1, 0.3, 1] }}
/>
);
}
interface InkLineProps {
width?: number;
height?: number;
color?: string;
opacity?: number;
delay?: number;
className?: string;
}
export function InkLine({
width = 200,
height = 2,
color = '#1C1C1C',
opacity = 0.1,
delay = 0,
className = ''
}: InkLineProps) {
return (
<motion.div
className={`absolute ${className}`}
style={{
width,
height,
backgroundColor: color,
opacity: 0,
borderRadius: height / 2,
}}
initial={{ opacity: 0, scaleX: 0 }}
animate={{ opacity, scaleX: 1 }}
transition={{ duration: 1.5, delay, ease: [0.16, 1, 0.3, 1] }}
/>
);
}
interface BrushStrokeProps {
width?: number;
height?: number;
color?: string;
opacity?: number;
delay?: number;
className?: string;
}
export function BrushStroke({
width = 150,
height = 30,
color = '#C41E3A',
opacity = 0.12,
delay = 0,
className = ''
}: BrushStrokeProps) {
return (
<motion.svg
viewBox="0 0 150 30"
width={width}
height={height}
className={`absolute ${className}`}
initial={{ opacity: 0 }}
animate={{ opacity }}
transition={{ duration: 1, delay }}
>
<motion.path
d="M0 15 Q20 5 40 15 Q60 25 80 15 Q100 5 120 15 Q135 20 150 15"
fill="none"
stroke={color}
strokeWidth="3"
strokeLinecap="round"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 2, delay: delay + 0.5, ease: [0.16, 1, 0.3, 1] }}
/>
</motion.svg>
);
}
interface FloatingInkProps {
count?: number;
className?: string;
}
export function FloatingInk({ count = 15, className = '' }: FloatingInkProps) {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
const elements = useMemo(() => {
if (!isMounted) {return [];}
const items = [];
for (let i = 0; i < count; i++) {
const type = i % 5;
const baseDelay = i * 0.15;
items.push({
id: i,
type,
delay: baseDelay,
props: {
left: `${10 + Math.random() * 80}%`,
top: `${10 + Math.random() * 80}%`,
},
animX: Math.random() * 10 - 5,
animDuration: 6 + Math.random() * 4,
size: type === 0 ? 8 + Math.random() * 16 :
type === 1 ? 4 + Math.random() * 8 :
type === 2 ? 20 + Math.random() * 30 :
type === 3 ? 60 + Math.random() * 80 : 80 + Math.random() * 100,
opacity: type === 0 ? 0.08 + Math.random() * 0.1 :
type === 1 ? 0.1 + Math.random() * 0.15 :
type === 2 ? 0.08 + Math.random() * 0.08 :
type === 3 ? 0.03 + Math.random() * 0.04 : 0.06 + Math.random() * 0.08,
blur: type === 0 ? Math.random() * 2 : 0,
height: type === 4 ? 15 + Math.random() * 20 : undefined,
});
}
return items;
}, [count, isMounted]);
return (
<div className={`absolute inset-0 pointer-events-none overflow-hidden ${className}`}>
{elements.map((el) => (
<motion.div
key={el.id}
className="absolute"
style={el.props}
animate={{
y: [0, -20, 0],
x: [0, el.animX, 0],
}}
transition={{
duration: el.animDuration,
delay: el.delay,
repeat: Infinity,
ease: 'easeInOut',
}}
>
{el.type === 0 && (
<InkDrop
size={el.size}
opacity={el.opacity}
blur={el.blur}
delay={el.delay}
/>
)}
{el.type === 1 && (
<InkDrop
size={el.size}
opacity={el.opacity}
color="#C41E3A"
delay={el.delay}
/>
)}
{el.type === 2 && (
<SealStamp
size={el.size}
opacity={el.opacity}
delay={el.delay}
/>
)}
{el.type === 3 && (
<InkStain
size={el.size}
opacity={el.opacity}
delay={el.delay}
/>
)}
{el.type === 4 && (
<BrushStroke
width={el.size}
height={el.height}
opacity={el.opacity}
delay={el.delay}
/>
)}
</motion.div>
))}
</div>
);
}
interface InkDecorationProps {
variant?: 'minimal' | 'balanced' | 'rich';
className?: string;
}
interface DropPosition {
left: string;
top: string;
size: number;
opacity: number;
blur: number;
isRed: boolean;
duration: number;
}
interface SplashPosition {
left: string;
top: string;
size: number;
duration: number;
}
interface SealPosition {
left: string;
top: string;
size: number;
duration: number;
}
interface StainPosition {
left: string;
top: string;
size: number;
duration: number;
}
interface StrokePosition {
left: string;
top: string;
width: number;
duration: number;
}
export function InkDecoration({ variant = 'balanced', className = '' }: InkDecorationProps) {
const [dropPositions, setDropPositions] = useState<DropPosition[]>([]);
const [splashPositions, setSplashPositions] = useState<SplashPosition[]>([]);
const [sealPositions, setSealPositions] = useState<SealPosition[]>([]);
const [stainPositions, setStainPositions] = useState<StainPosition[]>([]);
const [strokePositions, setStrokePositions] = useState<StrokePosition[]>([]);
const config = {
minimal: { drops: 3, splashes: 1, seals: 1, stains: 1, strokes: 1 },
balanced: { drops: 5, splashes: 2, seals: 2, stains: 2, strokes: 2 },
rich: { drops: 8, splashes: 3, seals: 3, stains: 3, strokes: 3 },
};
const { drops, splashes, seals, stains, strokes } = config[variant];
useEffect(() => {
setDropPositions(Array.from({ length: drops }, (_, i) => ({
left: `${15 + (i * 70 / drops)}%`,
top: `${20 + Math.random() * 60}%`,
size: 6 + Math.random() * 14,
opacity: 0.06 + Math.random() * 0.1,
blur: Math.random() * 3,
isRed: i % 3 === 0,
duration: 5 + Math.random() * 3,
})));
setSplashPositions(Array.from({ length: splashes }, (_, i) => ({
left: `${20 + (i * 60 / splashes)}%`,
top: `${15 + Math.random() * 70}%`,
size: 40 + Math.random() * 40,
duration: 7 + Math.random() * 3,
})));
setSealPositions(Array.from({ length: seals }, (_, i) => ({
left: `${25 + (i * 50 / seals)}%`,
top: `${25 + Math.random() * 50}%`,
size: 25 + Math.random() * 25,
duration: 6 + Math.random() * 2,
})));
setStainPositions(Array.from({ length: stains }, (_, i) => ({
left: `${10 + (i * 80 / stains)}%`,
top: `${30 + Math.random() * 40}%`,
size: 80 + Math.random() * 60,
duration: 8 + Math.random() * 4,
})));
setStrokePositions(Array.from({ length: strokes }, (_, i) => ({
left: `${15 + (i * 70 / strokes)}%`,
top: `${40 + Math.random() * 30}%`,
width: 100 + Math.random() * 100,
duration: 6 + Math.random() * 3,
})));
}, [drops, splashes, seals, stains, strokes]);
return (
<div className={`absolute inset-0 pointer-events-none overflow-hidden ${className}`}>
{dropPositions.map((pos, i) => (
<motion.div
key={`drop-${i}`}
className="absolute"
style={{ left: pos.left, top: pos.top }}
animate={{
y: [0, -15, 0],
scale: [1, 1.1, 1],
}}
transition={{
duration: pos.duration,
delay: i * 0.2,
repeat: Infinity,
ease: 'easeInOut',
}}
>
<InkDrop
size={pos.size}
opacity={pos.opacity}
blur={pos.blur}
color={pos.isRed ? '#C41E3A' : '#1C1C1C'}
delay={i * 0.1}
/>
</motion.div>
))}
{splashPositions.map((pos, i) => (
<motion.div
key={`splash-${i}`}
className="absolute"
style={{ left: pos.left, top: pos.top }}
animate={{
y: [0, -10, 0],
rotate: [0, 5, -5, 0],
}}
transition={{
duration: pos.duration,
delay: i * 0.3,
repeat: Infinity,
ease: 'easeInOut',
}}
>
<InkSplash size={pos.size} opacity={0.12} delay={i * 0.15} />
</motion.div>
))}
{sealPositions.map((pos, i) => (
<motion.div
key={`seal-${i}`}
className="absolute"
style={{ left: pos.left, top: pos.top }}
animate={{
y: [0, -8, 0],
rotate: [-8, -5, -10, -8],
}}
transition={{
duration: pos.duration,
delay: i * 0.25,
repeat: Infinity,
ease: 'easeInOut',
}}
>
<SealStamp size={pos.size} opacity={0.1} delay={i * 0.2} />
</motion.div>
))}
{stainPositions.map((pos, i) => (
<motion.div
key={`stain-${i}`}
className="absolute"
style={{ left: pos.left, top: pos.top }}
animate={{
scale: [1, 1.05, 1],
opacity: [0.04, 0.06, 0.04],
}}
transition={{
duration: pos.duration,
delay: i * 0.35,
repeat: Infinity,
ease: 'easeInOut',
}}
>
<InkStain size={pos.size} opacity={0.05} delay={i * 0.1} />
</motion.div>
))}
{strokePositions.map((pos, i) => (
<motion.div
key={`stroke-${i}`}
className="absolute"
style={{ left: pos.left, top: pos.top }}
animate={{
x: [0, 10, 0],
opacity: [0.08, 0.12, 0.08],
}}
transition={{
duration: pos.duration,
delay: i * 0.3,
repeat: Infinity,
ease: 'easeInOut',
}}
>
<BrushStroke width={pos.width} opacity={0.1} delay={i * 0.15} />
</motion.div>
))}
</div>
);
}
export function InkBackground() {
return (
<div className="absolute inset-0 pointer-events-none overflow-hidden">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 2 }}
className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-[radial-gradient(ellipse_at_center,rgba(28,28,28,0.02)_0%,transparent_70%)]"
/>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 2, delay: 0.3 }}
className="absolute bottom-0 right-1/4 w-[400px] h-[400px] bg-[radial-gradient(ellipse_at_center,rgba(196,30,58,0.03)_0%,transparent_60%)]"
/>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 2, delay: 0.6 }}
className="absolute top-1/3 right-0 w-[300px] h-[300px] bg-[radial-gradient(ellipse_at_center,rgba(28,28,28,0.015)_0%,transparent_50%)]"
/>
</div>
);
}
+8 -8
View File
@@ -16,8 +16,8 @@ interface InkGlowCardProps {
}
const DEFAULT_ACCENT = '196, 30, 58';
const DEFAULT_GLOW_START = '#C41E3A';
const DEFAULT_GLOW_END = '#D97706';
const DEFAULT_GLOW_START = 'var(--color-brand-primary)';
const DEFAULT_GLOW_END = 'var(--color-warning)';
export function InkGlowCard({
children,
@@ -75,12 +75,12 @@ export function InkGlowCard({
{href ? (
<StaticLink
href={href}
className="relative block rounded-2xl bg-white overflow-hidden transition-all duration-500"
className="relative block rounded-2xl bg-[var(--color-bg-primary)] overflow-hidden transition-all duration-500"
style={{
boxShadow: isHovered
? `0 16px 32px rgba(0,0,0,0.08), 0 0 0 1px rgba(${accentColorRgb}, 0.08)`
? `0 20px 40px rgba(0,0,0,0.1), 0 0 0 1px rgba(${accentColorRgb}, 0.12)`
: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
transform: isHovered ? 'translateY(-4px)' : 'translateY(0)',
transform: isHovered ? 'translateY(-6px)' : 'translateY(0)',
}}
onMouseMove={handleMouseMove}
onMouseEnter={() => setIsHovered(true)}
@@ -90,12 +90,12 @@ export function InkGlowCard({
</StaticLink>
) : (
<div
className="relative rounded-2xl bg-white overflow-hidden transition-all duration-500"
className="relative rounded-2xl bg-[var(--color-bg-primary)] overflow-hidden transition-all duration-500"
style={{
boxShadow: isHovered
? `0 16px 32px rgba(0,0,0,0.08), 0 0 0 1px rgba(${accentColorRgb}, 0.08)`
? `0 20px 40px rgba(0,0,0,0.1), 0 0 0 1px rgba(${accentColorRgb}, 0.12)`
: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
transform: isHovered ? 'translateY(-4px)' : 'translateY(0)',
transform: isHovered ? 'translateY(-6px)' : 'translateY(0)',
}}
onMouseMove={handleMouseMove}
onMouseEnter={() => setIsHovered(true)}
+2 -2
View File
@@ -6,7 +6,7 @@ import '@testing-library/jest-dom';
import { Input } from './input';
jest.mock('@/lib/utils', () => ({
cn: (...args: any[]) => args.filter(Boolean).join(' '),
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
}));
describe('Input', () => {
@@ -166,7 +166,7 @@ describe('Input', () => {
it('should have error styling when error exists', () => {
render(<Input error="错误信息" />);
const input = screen.getByRole('textbox');
expect(input.className).toMatch(/border-\[#C41E3A\]/);
expect(input.className).toMatch(/border-\[var\(--color-brand-primary\)\]/);
});
});
+7 -7
View File
@@ -18,10 +18,10 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-[#3D3D3D] mb-2"
className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2"
>
{label}
{props.required && <span className="text-[#C41E3A] ml-1">*</span>}
{props.required && <span className="text-[var(--color-brand-primary)] ml-1">*</span>}
</label>
)}
<input
@@ -30,10 +30,10 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
data-slot="input"
data-testid={props['data-testid']}
className={cn(
"file:text-foreground placeholder:text-[#8C8C8C] selection:bg-[#1C1C1C] selection:text-white h-14 min-h-[56px] w-full min-w-0 rounded-lg border border-[#E5E5E5] bg-[#FAFAFA] px-4 py-3 text-base text-[#1C1C1C] shadow-sm transition-all duration-300 outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 touch-manipulation md:h-12 md:min-h-[44px] md:py-2",
"focus-visible:border-[#1C1C1C] focus-visible:ring-2 focus-visible:ring-[#1C1C1C]/50 focus-visible:shadow-lg focus-visible:shadow-[#1C1C1C]/20",
"hover:border-[#3D3D3D]",
error && "border-[#C41E3A] focus-visible:border-[#C41E3A] focus-visible:ring-[#C41E3A]/50",
"file:text-foreground placeholder:text-[var(--color-text-hint)] selection:bg-[var(--color-primary)] selection:text-white h-14 min-h-[56px] w-full min-w-0 rounded-lg border border-[var(--color-border-primary)] bg-[var(--color-bg-section)] px-4 py-3 text-base text-[var(--color-text-primary)] shadow-sm transition-all duration-300 outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 touch-manipulation md:h-12 md:min-h-[44px] md:py-2",
"focus-visible:border-[var(--color-primary)] focus-visible:ring-2 focus-visible:ring-[var(--color-primary)]/50 focus-visible:shadow-lg focus-visible:shadow-[var(--color-primary)]/20",
"hover:border-[var(--color-text-secondary)]",
error && "border-[var(--color-brand-primary)] focus-visible:border-[var(--color-brand-primary)] focus-visible:ring-[var(--color-brand-primary)]/50",
className
)}
ref={ref}
@@ -43,7 +43,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
{...props}
/>
{error && (
<p id={errorId} className="mt-1 text-sm text-[#C41E3A]" role="alert" data-testid="error-message">
<p id={errorId} className="mt-1 text-sm text-[var(--color-brand-primary)]" role="alert" data-testid="error-message">
{error}
</p>
)}
-133
View File
@@ -1,133 +0,0 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { InsightCard } from './insight-card';
jest.mock('next/image', () => ({
__esModule: true,
default: ({ src, alt, fill, className }: any) => (
<img src={src} alt={alt} className={className} data-fill={fill} />
),
}));
jest.mock('@/components/ui/ink-glow-card', () => ({
InkGlowCard: ({ children, className, href }: { children: React.ReactNode; className?: string; href?: string }) => (
<article className={className} data-href={href}>
{children}
</article>
),
}));
jest.mock('@/components/ui/badge', () => ({
Badge: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<span className={className}>{children}</span>
),
}));
jest.mock('lucide-react', () => ({
Calendar: () => <span data-testid="calendar-icon" />,
Clock: () => <span data-testid="clock-icon" />,
ArrowRight: () => <span data-testid="arrow-right-icon" />,
}));
jest.mock('next/link', () => {
const MockLink = ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
<a href={href} {...props}>{children}</a>
);
MockLink.displayName = 'MockLink';
return MockLink;
});
describe('InsightCard', () => {
const defaultProps = {
title: 'Test Title',
excerpt: 'Test excerpt',
category: 'Technology',
readTime: '5 min',
publishedAt: '2026-01-01',
href: '/test-article',
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render insight card', () => {
render(<InsightCard {...defaultProps} />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
it('should render title', () => {
render(<InsightCard {...defaultProps} />);
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument();
});
it('should render excerpt', () => {
render(<InsightCard {...defaultProps} />);
expect(screen.getByText('Test excerpt')).toBeInTheDocument();
});
it('should render category badge', () => {
render(<InsightCard {...defaultProps} />);
expect(screen.getByText('Technology')).toBeInTheDocument();
});
it('should render read time', () => {
render(<InsightCard {...defaultProps} />);
expect(screen.getByText('5 min')).toBeInTheDocument();
});
it('should render published date', () => {
render(<InsightCard {...defaultProps} />);
expect(screen.getByText('2026-01-01')).toBeInTheDocument();
});
it('should render read more link', () => {
render(<InsightCard {...defaultProps} />);
expect(screen.getByText('阅读更多')).toBeInTheDocument();
});
});
describe('Image', () => {
it('should render image when imageUrl is provided', () => {
render(<InsightCard {...defaultProps} imageUrl="/test.jpg" />);
const image = screen.getByAltText('Test Title');
expect(image).toBeInTheDocument();
});
it('should not render image when imageUrl is not provided', () => {
render(<InsightCard {...defaultProps} />);
const image = screen.queryByRole('img');
expect(image).not.toBeInTheDocument();
});
});
describe('Featured', () => {
it('should not be featured by default', () => {
const { container } = render(<InsightCard {...defaultProps} />);
const article = container.querySelector('article');
expect(article).not.toHaveClass('md:col-span-2');
});
it('should be featured when featured prop is true', () => {
const { container } = render(<InsightCard {...defaultProps} featured />);
const article = container.querySelector('article');
expect(article).toHaveClass('md:col-span-2');
});
});
describe('Styling', () => {
it('should have article element', () => {
const { container } = render(<InsightCard {...defaultProps} />);
const article = container.querySelector('article');
expect(article).toBeInTheDocument();
});
it('should pass featured class to InkGlowCard', () => {
const { container } = render(<InsightCard {...defaultProps} featured />);
const article = container.querySelector('article');
expect(article).toHaveClass('md:col-span-2');
});
});
});
-82
View File
@@ -1,82 +0,0 @@
'use client';
import Image from 'next/image';
import { Calendar, Clock, ArrowRight } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { InkGlowCard } from '@/components/ui/ink-glow-card';
export interface InsightCardProps {
title: string;
excerpt: string;
category: string;
readTime: string;
publishedAt: string;
imageUrl?: string;
href: string;
featured?: boolean;
}
export function InsightCard({
title,
excerpt,
category,
readTime,
publishedAt,
imageUrl,
href,
featured = false,
}: InsightCardProps) {
return (
<InkGlowCard
href={href}
accentColorRgb="196, 30, 58"
glowStart="#C41E3A"
glowEnd="#D97706"
className={featured ? 'md:col-span-2' : ''}
>
{imageUrl && (
<div className="relative h-48 overflow-hidden">
<Image
src={imageUrl}
alt={title}
fill
className="object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent" />
</div>
)}
<div className="p-6 md:p-8">
<div className="flex items-center gap-3 mb-4">
<Badge variant="secondary" className="text-xs">
{category}
</Badge>
<div className="flex items-center gap-1 text-xs text-[#A3A3A3]">
<Clock className="w-3 h-3" />
<span>{readTime}</span>
</div>
</div>
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-2 line-clamp-2">
{title}
</h3>
<p className="text-sm text-[#595959] mb-5 line-clamp-2">
{excerpt}
</p>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-xs text-[#A3A3A3]">
<Calendar className="w-3 h-3" />
<span>{publishedAt}</span>
</div>
<span className="inline-flex items-center gap-1 text-sm font-medium text-[#C41E3A]">
<ArrowRight className="w-4 h-4" />
</span>
</div>
</div>
</InkGlowCard>
);
}
+1 -1
View File
@@ -22,7 +22,7 @@ describe('Loading Skeleton Components', () => {
const { container } = render(<Skeleton />);
const skeleton = container.firstChild as HTMLElement;
expect(skeleton).toHaveClass('animate-pulse');
expect(skeleton).toHaveClass('bg-[#F5F5F5]');
expect(skeleton).toHaveClass('bg-[var(--color-primary-lighter)]');
});
it('should apply custom className', () => {
+6 -6
View File
@@ -10,7 +10,7 @@ export function Skeleton({ className }: SkeletonProps) {
return (
<div
className={cn(
'animate-pulse rounded-md bg-[#F5F5F5]',
'animate-pulse rounded-md bg-[var(--color-bg-hover)]',
className
)}
/>
@@ -19,7 +19,7 @@ export function Skeleton({ className }: SkeletonProps) {
export function CardSkeleton() {
return (
<div className="bg-[#FAFAFA] rounded-xl border border-[#E5E5E5] p-6">
<div className="bg-[var(--color-bg-section)] rounded-xl border border-[var(--color-border-primary)] p-6">
<Skeleton className="h-12 w-12 rounded-xl mb-4" />
<Skeleton className="h-6 w-3/4 mb-3" />
<Skeleton className="h-4 w-full mb-2" />
@@ -30,7 +30,7 @@ export function CardSkeleton() {
export function ServiceCardSkeleton() {
return (
<div className="bg-white rounded-xl border border-[#E5E5E5] p-6 h-full">
<div className="bg-[var(--color-bg-primary)] rounded-xl border border-[var(--color-border-primary)] p-6 h-full">
<Skeleton className="h-14 w-14 rounded-xl mb-4" />
<Skeleton className="h-6 w-3/4 mb-3" />
<Skeleton className="h-4 w-full mb-2" />
@@ -41,7 +41,7 @@ export function ServiceCardSkeleton() {
export function CaseCardSkeleton() {
return (
<div className="bg-white rounded-xl border border-[#E5E5E5] overflow-hidden">
<div className="bg-[var(--color-bg-primary)] rounded-xl border border-[var(--color-border-primary)] overflow-hidden">
<Skeleton className="h-48 w-full" />
<div className="p-6">
<Skeleton className="h-5 w-1/2 mb-3" />
@@ -55,7 +55,7 @@ export function CaseCardSkeleton() {
export function ProductCardSkeleton() {
return (
<div className="bg-[#FAFAFA] rounded-xl border border-[#E5E5E5] p-6 h-full flex flex-col">
<div className="bg-[var(--color-bg-section)] rounded-xl border border-[var(--color-border-primary)] p-6 h-full flex flex-col">
<Skeleton className="h-6 w-1/3 mb-3" />
<Skeleton className="h-7 w-3/4 mb-4" />
<Skeleton className="h-4 w-full mb-2" />
@@ -67,7 +67,7 @@ export function ProductCardSkeleton() {
export function NewsCardSkeleton() {
return (
<div className="bg-white rounded-xl border border-[#E5E5E5] overflow-hidden">
<div className="bg-[var(--color-bg-primary)] rounded-xl border border-[var(--color-border-primary)] overflow-hidden">
<Skeleton className="h-48 w-full" />
<div className="p-6">
<Skeleton className="h-5 w-1/4 mb-3" />
@@ -1,99 +0,0 @@
import { render, screen, waitFor } from '@testing-library/react';
import { OptimizedImage } from './optimized-image';
// Mock next/image
jest.mock('next/image', () => ({
__esModule: true,
default: ({ onLoad, onError, className, ...props }: React.ComponentProps<'img'>) => (
<img
{...props}
className={className}
data-testid="optimized-image"
onLoad={onLoad}
onError={onError}
/>
),
}));
describe('OptimizedImage', () => {
it('should render with loading state initially', () => {
render(
<OptimizedImage
src="/test-image.jpg"
alt="Test image"
width={400}
height={300}
/>
);
// 应该显示加载动画
expect(screen.getByTestId('optimized-image')).toHaveClass('opacity-0');
});
it('should show error state when image fails to load', async () => {
render(
<OptimizedImage
src="/invalid-image.jpg"
alt="Invalid image"
width={400}
height={300}
/>
);
const img = screen.getByTestId('optimized-image');
// 触发错误事件
img.dispatchEvent(new Event('error'));
await waitFor(() => {
expect(screen.getByText('图片加载失败')).toBeInTheDocument();
});
});
it('should show image when loaded', async () => {
render(
<OptimizedImage
src="/test-image.jpg"
alt="Test image"
width={400}
height={300}
/>
);
const img = screen.getByTestId('optimized-image');
// 触发加载完成事件
img.dispatchEvent(new Event('load'));
await waitFor(() => {
expect(img).toHaveClass('opacity-100');
});
});
it('should render with correct alt text', () => {
render(
<OptimizedImage
src="/test-image.jpg"
alt="Descriptive alt text"
width={400}
height={300}
/>
);
expect(screen.getByAltText('Descriptive alt text')).toBeInTheDocument();
});
it('should use fill mode when specified', () => {
render(
<OptimizedImage
src="/test-image.jpg"
alt="Fill mode image"
fill
className="object-cover"
/>
);
const container = screen.getByTestId('optimized-image').parentElement;
expect(container).toHaveClass('relative', 'overflow-hidden', 'w-full', 'h-full');
});
});
-112
View File
@@ -1,112 +0,0 @@
'use client';
import { useState, useCallback } from 'react';
import Image from 'next/image';
import { cn } from '@/lib/utils';
interface OptimizedImageProps {
src: string;
alt: string;
width?: number;
height?: number;
fill?: boolean;
className?: string;
containerClassName?: string;
priority?: boolean;
sizes?: string;
quality?: number;
placeholder?: 'blur' | 'empty';
blurDataURL?: string;
}
export function OptimizedImage({
src,
alt,
width,
height,
fill = false,
className,
containerClassName,
priority = false,
sizes = '(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw',
quality = 85,
placeholder = 'blur',
blurDataURL,
}: OptimizedImageProps) {
const [isLoaded, setIsLoaded] = useState(false);
const [error, setError] = useState(false);
// 生成默认的模糊占位符
const defaultBlurDataURL = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iNDAwIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjFmNWY5Ii8+PC9zdmc+';
// 使用 callback 来处理加载状态
const handleLoad = useCallback(() => {
setIsLoaded(true);
}, []);
const handleError = useCallback(() => {
setError(true);
}, []);
if (error) {
return (
<div
className={cn(
'bg-gray-100 flex items-center justify-center',
containerClassName
)}
style={!fill ? { width, height } : undefined}
>
<span className="text-gray-400 text-sm"></span>
</div>
);
}
return (
<div
className={cn(
'relative overflow-hidden',
fill ? 'w-full h-full' : '',
containerClassName
)}
style={!fill ? { width, height } : undefined}
>
{/* 模糊占位符背景 */}
{!isLoaded && placeholder === 'blur' && (
<div
className="absolute inset-0 bg-cover bg-center blur-sm scale-110 transition-opacity duration-500"
style={{
backgroundImage: `url(${blurDataURL || defaultBlurDataURL})`,
}}
/>
)}
{/* 加载动画 */}
{!isLoaded && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-gray-200 border-t-[#C41E3A] rounded-full animate-spin" />
</div>
)}
<Image
src={src}
alt={alt}
width={fill ? undefined : width}
height={fill ? undefined : height}
fill={fill}
priority={priority}
sizes={sizes}
quality={quality}
placeholder={placeholder}
blurDataURL={blurDataURL || defaultBlurDataURL}
className={cn(
'transition-opacity duration-500',
isLoaded ? 'opacity-100' : 'opacity-0',
className
)}
onLoad={handleLoad}
onError={handleError}
/>
</div>
);
}
-85
View File
@@ -1,85 +0,0 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { PageHeader } from './page-header';
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
h1: ({ children, ...props }: any) => <h1 {...props}>{children}</h1>,
p: ({ children, ...props }: any) => <p {...props}>{children}</p>,
},
useInView: () => true,
}));
jest.mock('@/components/ui/badge', () => ({
Badge: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<span className={className}>{children}</span>
),
}));
describe('PageHeader', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render page header', () => {
render(<PageHeader title="Test Title" />);
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
});
it('should render title', () => {
render(<PageHeader title="Test Title" />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
it('should render description when provided', () => {
render(<PageHeader title="Test" description="Test description" />);
expect(screen.getByText('Test description')).toBeInTheDocument();
});
it('should not render description when not provided', () => {
render(<PageHeader title="Test" />);
expect(screen.queryByText('Test description')).not.toBeInTheDocument();
});
it('should render badge when provided', () => {
render(<PageHeader title="Test" badge="Test Badge" />);
expect(screen.getByText('Test Badge')).toBeInTheDocument();
});
it('should not render badge when not provided', () => {
render(<PageHeader title="Test" />);
expect(screen.queryByText('Test Badge')).not.toBeInTheDocument();
});
});
describe('Effects', () => {
it('should have background styling', () => {
const { container } = render(<PageHeader title="Test" />);
const bgDiv = container.querySelector('.bg-\\[\\#FAFAFA\\]');
expect(bgDiv).toBeInTheDocument();
});
it('should have overflow hidden', () => {
const { container } = render(<PageHeader title="Test" />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toHaveClass('overflow-hidden');
});
});
describe('Styling', () => {
it('should have container class', () => {
const { container } = render(<PageHeader title="Test" />);
const containerDiv = container.querySelector('.container-wide');
expect(containerDiv).toBeInTheDocument();
});
it('should apply custom className', () => {
const { container } = render(<PageHeader title="Test" className="custom-class" />);
const customDiv = container.querySelector('.custom-class');
expect(customDiv).toBeInTheDocument();
});
});
});
-62
View File
@@ -1,62 +0,0 @@
'use client';
import { motion } from 'framer-motion';
import { useInView } from 'framer-motion';
import { useRef } from 'react';
import { Badge } from '@/components/ui/badge';
interface PageHeaderProps {
badge?: string;
title: string;
description?: string;
className?: string;
}
export function PageHeader({ badge, title, description, className = '' }: PageHeaderProps) {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: '-100px' });
return (
<div className="relative overflow-hidden bg-[#FAFAFA]">
<div className="container-wide relative z-10 pt-32 pb-20" ref={ref}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
className={`max-w-4xl mx-auto text-center ${className}`}
>
{badge && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, delay: 0.1, ease: [0.16, 1, 0.3, 1] }}
className="mb-6"
>
<Badge variant="outline">{badge}</Badge>
</motion.div>
)}
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, delay: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="text-3xl sm:text-4xl font-semibold text-[#1C1C1C] mb-4"
>
{title}
</motion.h1>
{description && (
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, delay: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="text-base text-[#595959] max-w-2xl mx-auto leading-relaxed"
>
{description}
</motion.p>
)}
</motion.div>
</div>
</div>
);
}
-421
View File
@@ -1,421 +0,0 @@
'use client';
import { motion, AnimatePresence, type Variants, type Transition } from 'framer-motion';
import { usePathname } from 'next/navigation';
import { type ReactNode } from 'react';
const defaultTransition: Transition = {
duration: 0.4,
ease: [0.16, 1, 0.3, 1],
};
export const fadeInVariants: Variants = {
initial: {
opacity: 0,
},
enter: {
opacity: 1,
transition: defaultTransition,
},
exit: {
opacity: 0,
transition: { ...defaultTransition, duration: 0.2 },
},
};
export const slideUpVariants: Variants = {
initial: {
opacity: 0,
y: 20,
},
enter: {
opacity: 1,
y: 0,
transition: defaultTransition,
},
exit: {
opacity: 0,
y: -20,
transition: { ...defaultTransition, duration: 0.2 },
},
};
export const slideRightVariants: Variants = {
initial: {
opacity: 0,
x: -20,
},
enter: {
opacity: 1,
x: 0,
transition: defaultTransition,
},
exit: {
opacity: 0,
x: 20,
transition: { ...defaultTransition, duration: 0.2 },
},
};
export const scaleVariants: Variants = {
initial: {
opacity: 0,
scale: 0.98,
},
enter: {
opacity: 1,
scale: 1,
transition: defaultTransition,
},
exit: {
opacity: 0,
scale: 1.02,
transition: { ...defaultTransition, duration: 0.2 },
},
};
export const inkRevealPageVariants: Variants = {
initial: {
opacity: 0,
clipPath: 'circle(0% at 50% 50%)',
},
enter: {
opacity: 1,
clipPath: 'circle(100% at 50% 50%)',
transition: {
duration: 0.8,
ease: [0.16, 1, 0.3, 1],
},
},
exit: {
opacity: 0,
clipPath: 'circle(0% at 50% 50%)',
transition: {
duration: 0.4,
ease: [0.16, 1, 0.3, 1],
},
},
};
export const curtainVariants: Variants = {
initial: {
opacity: 0,
y: '100%',
},
enter: {
opacity: 1,
y: 0,
transition: {
duration: 0.6,
ease: [0.16, 1, 0.3, 1],
},
},
exit: {
opacity: 0,
y: '-100%',
transition: {
duration: 0.4,
ease: [0.16, 1, 0.3, 1],
},
},
};
interface PageTransitionProps {
children: ReactNode;
variants?: Variants;
className?: string;
}
export function PageTransition({
children,
variants = fadeInVariants,
className = '',
}: PageTransitionProps) {
const pathname = usePathname();
return (
<AnimatePresence mode="wait">
<motion.div
key={pathname}
initial="initial"
animate="enter"
exit="exit"
variants={variants}
className={className}
>
{children}
</motion.div>
</AnimatePresence>
);
}
interface FadeTransitionProps {
children: ReactNode;
className?: string;
}
export function FadeTransition({ children, className = '' }: FadeTransitionProps) {
return (
<PageTransition variants={fadeInVariants} className={className}>
{children}
</PageTransition>
);
}
interface SlideUpTransitionProps {
children: ReactNode;
className?: string;
}
export function SlideUpTransition({ children, className = '' }: SlideUpTransitionProps) {
return (
<PageTransition variants={slideUpVariants} className={className}>
{children}
</PageTransition>
);
}
interface InkRevealTransitionProps {
children: ReactNode;
className?: string;
}
export function InkRevealTransition({ children, className = '' }: InkRevealTransitionProps) {
return (
<PageTransition variants={inkRevealPageVariants} className={className}>
{children}
</PageTransition>
);
}
interface SectionTransitionProps {
children: ReactNode;
className?: string;
delay?: number;
}
export function SectionTransition({ children, className = '', delay = 0 }: SectionTransitionProps) {
return (
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2 }}
transition={{
duration: 0.6,
delay,
ease: [0.16, 1, 0.3, 1],
}}
className={className}
>
{children}
</motion.div>
);
}
interface StaggerChildrenProps {
children: ReactNode;
className?: string;
staggerDelay?: number;
}
export function StaggerChildren({ children, className = '', staggerDelay = 0.1 }: StaggerChildrenProps) {
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: staggerDelay,
delayChildren: 0.1,
},
},
};
return (
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.2 }}
variants={containerVariants}
className={className}
>
{children}
</motion.div>
);
}
interface AnimatedSectionProps {
children: ReactNode;
className?: string;
animation?: 'fade' | 'slide' | 'scale' | 'ink';
delay?: number;
}
export function AnimatedSection({
children,
className = '',
animation = 'fade',
delay = 0,
}: AnimatedSectionProps) {
const getVariants = (): Variants => {
switch (animation) {
case 'slide':
return {
hidden: { opacity: 0, y: 40 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.6,
delay,
ease: [0.16, 1, 0.3, 1],
},
},
};
case 'scale':
return {
hidden: { opacity: 0, scale: 0.95 },
visible: {
opacity: 1,
scale: 1,
transition: {
duration: 0.6,
delay,
ease: [0.16, 1, 0.3, 1],
},
},
};
case 'ink':
return {
hidden: {
opacity: 0,
scale: 0.9,
filter: 'blur(10px)',
},
visible: {
opacity: 1,
scale: 1,
filter: 'blur(0px)',
transition: {
duration: 0.8,
delay,
ease: [0.16, 1, 0.3, 1],
},
},
};
default:
return {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
duration: 0.6,
delay,
ease: [0.16, 1, 0.3, 1],
},
},
};
}
};
return (
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.2 }}
variants={getVariants()}
className={className}
>
{children}
</motion.div>
);
}
interface LoadingTransitionProps {
isLoading: boolean;
children: ReactNode;
loadingComponent?: ReactNode;
className?: string;
}
export function LoadingTransition({
isLoading,
children,
loadingComponent,
className = '',
}: LoadingTransitionProps) {
return (
<AnimatePresence mode="wait">
{isLoading ? (
<motion.div
key="loading"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className={className}
>
{loadingComponent || (
<div className="flex items-center justify-center py-12">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
className="w-8 h-8 border-2 border-[#C41E3A] border-t-transparent rounded-full"
/>
</div>
)}
</motion.div>
) : (
<motion.div
key="content"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
className={className}
>
{children}
</motion.div>
)}
</AnimatePresence>
);
}
interface SkeletonLoaderProps {
className?: string;
animate?: boolean;
}
export function SkeletonLoader({ className = '', animate = true }: SkeletonLoaderProps) {
return (
<div
className={`
bg-[#F5F5F5] rounded-lg
${animate ? 'animate-pulse' : ''}
${className}
`}
/>
);
}
interface CardSkeletonProps {
count?: number;
className?: string;
}
export function CardSkeleton({ count = 1, className = '' }: CardSkeletonProps) {
return (
<div className={className}>
{Array.from({ length: count }).map((_, index) => (
<div key={index} className="bg-white border border-[#E5E5E5] rounded-xl p-6 space-y-4">
<SkeletonLoader className="h-4 w-3/4" />
<SkeletonLoader className="h-4 w-1/2" />
<SkeletonLoader className="h-20 w-full" />
<div className="flex gap-2">
<SkeletonLoader className="h-8 w-20" />
<SkeletonLoader className="h-8 w-20" />
</div>
</div>
))}
</div>
);
}
+14 -14
View File
@@ -21,22 +21,22 @@ interface ProductStyleConfig {
}
const productConfig: ProductStyleConfig[] = [
{ icon: Database, accentColor: '#C41E3A', accentColorRgb: '196, 30, 58', glowStart: '#C41E3A', glowEnd: '#D97706' },
{ icon: Users, accentColor: '#D97706', accentColorRgb: '217, 119, 6', glowStart: '#D97706', glowEnd: '#16A34A' },
{ icon: FileText, accentColor: '#2563EB', accentColorRgb: '37, 99, 235', glowStart: '#2563EB', glowEnd: '#7C3AED' },
{ icon: BarChart3, accentColor: '#16A34A', accentColorRgb: '22, 163, 74', glowStart: '#16A34A', glowEnd: '#0891B2' },
{ icon: Truck, accentColor: '#7C3AED', accentColorRgb: '124, 58, 237', glowStart: '#7C3AED', glowEnd: '#C41E3A' },
{ icon: Building2, accentColor: '#0891B2', accentColorRgb: '8, 145, 178', glowStart: '#0891B2', glowEnd: '#2563EB' },
{ icon: Database, accentColor: 'var(--color-brand-primary)', accentColorRgb: '196, 30, 58', glowStart: 'var(--color-brand-primary)', glowEnd: 'var(--color-warning)' },
{ icon: Users, accentColor: 'var(--color-warning)', accentColorRgb: '217, 119, 6', glowStart: 'var(--color-warning)', glowEnd: 'var(--color-success)' },
{ icon: FileText, accentColor: 'var(--color-accent-blue)', accentColorRgb: '37, 99, 235', glowStart: 'var(--color-accent-blue)', glowEnd: 'var(--color-accent-purple)' },
{ icon: BarChart3, accentColor: 'var(--color-success)', accentColorRgb: '22, 163, 74', glowStart: 'var(--color-success)', glowEnd: 'var(--color-accent-cyan)' },
{ icon: Truck, accentColor: 'var(--color-accent-purple)', accentColorRgb: '124, 58, 237', glowStart: 'var(--color-accent-purple)', glowEnd: 'var(--color-brand-primary)' },
{ icon: Building2, accentColor: 'var(--color-accent-cyan)', accentColorRgb: '8, 145, 178', glowStart: 'var(--color-accent-cyan)', glowEnd: 'var(--color-accent-blue)' },
];
const defaultConfig: ProductStyleConfig = {
icon: Database, accentColor: '#C41E3A', accentColorRgb: '196, 30, 58', glowStart: '#C41E3A', glowEnd: '#D97706',
icon: Database, accentColor: 'var(--color-brand-primary)', accentColorRgb: '196, 30, 58', glowStart: 'var(--color-brand-primary)', glowEnd: 'var(--color-warning)',
};
const statusConfig = {
'研发中': { bg: 'rgba(196, 30, 58, 0.08)', text: '#C41E3A', border: 'rgba(196, 30, 58, 0.15)' },
'内测中': { bg: 'rgba(217, 119, 6, 0.08)', text: '#D97706', border: 'rgba(217, 119, 6, 0.15)' },
'已发布': { bg: 'rgba(22, 163, 74, 0.08)', text: '#16A34A', border: 'rgba(22, 163, 74, 0.15)' },
'研发中': { bg: 'rgba(var(--color-brand-primary-rgb), 0.08)', text: 'var(--color-brand-primary)', border: 'rgba(var(--color-brand-primary-rgb), 0.15)' },
'内测中': { bg: 'rgba(var(--color-warning-rgb), 0.08)', text: 'var(--color-warning)', border: 'rgba(var(--color-warning-rgb), 0.15)' },
'已发布': { bg: 'rgba(var(--color-success-rgb), 0.08)', text: 'var(--color-success)', border: 'rgba(var(--color-success-rgb), 0.15)' },
} as const;
export function ProductCard({ title, description, href, index, status }: ProductCardProps) {
@@ -77,21 +77,21 @@ export function ProductCard({ title, description, href, index, status }: Product
{status}
</span>
)}
<span className="text-xs font-mono tracking-widest text-[#A3A3A3]">
<span className="text-xs font-mono tracking-widest text-[var(--color-text-subtle)]">
{String(index + 1).padStart(2, '0')}
</span>
</div>
</div>
<h3 className="text-lg font-semibold mb-2 leading-snug tracking-tight text-[#1C1C1C]">
<h3 className="text-lg font-semibold mb-2 leading-snug tracking-tight text-[var(--color-text-primary)]">
{title}
</h3>
<p className="text-sm text-[#595959] leading-relaxed line-clamp-3 mb-5">
<p className="text-sm text-[var(--color-text-muted)] leading-relaxed line-clamp-3 mb-5">
{description}
</p>
<div className="flex items-center gap-1.5 text-sm font-medium text-[#A3A3A3] group-hover:text-[#C41E3A] transition-colors">
<div className="flex items-center gap-1.5 text-sm font-medium text-[var(--color-text-subtle)] group-hover:text-[var(--color-brand-primary)] transition-colors">
<span></span>
<ArrowUpRight className="w-4 h-4" strokeWidth={2} />
</div>
-443
View File
@@ -1,443 +0,0 @@
'use client';
import { motion, useScroll, useTransform, useSpring, type Variants, type HTMLMotionProps } from 'framer-motion';
import { useRef, type ReactNode } from 'react';
export const scrollRevealVariants: Variants = {
hidden: {
opacity: 0,
y: 50,
scale: 0.95,
},
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: {
duration: 0.8,
ease: [0.16, 1, 0.3, 1],
},
},
};
export const inkRevealVariants: Variants = {
hidden: {
opacity: 0,
scale: 0.8,
filter: 'blur(10px)',
},
visible: {
opacity: 1,
scale: 1,
filter: 'blur(0px)',
transition: {
duration: 1,
ease: [0.16, 1, 0.3, 1],
},
},
};
export const slideInLeftVariants: Variants = {
hidden: {
opacity: 0,
x: -50,
},
visible: {
opacity: 1,
x: 0,
transition: {
duration: 0.6,
ease: [0.16, 1, 0.3, 1],
},
},
};
export const slideInRightVariants: Variants = {
hidden: {
opacity: 0,
x: 50,
},
visible: {
opacity: 1,
x: 0,
transition: {
duration: 0.6,
ease: [0.16, 1, 0.3, 1],
},
},
};
interface ScrollRevealProps extends HTMLMotionProps<'div'> {
children: ReactNode;
className?: string;
delay?: number;
variants?: Variants;
once?: boolean;
threshold?: number;
}
export function ScrollReveal({
children,
className = '',
delay = 0,
variants: _variants = scrollRevealVariants,
once = true,
threshold = 0.1,
...props
}: ScrollRevealProps) {
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ['start end', 'start center'],
});
const opacity = useTransform(scrollYProgress, [0, threshold], [0, 1]);
const y = useTransform(scrollYProgress, [0, threshold], [50, 0]);
const scale = useTransform(scrollYProgress, [0, threshold], [0.95, 1]);
const smoothOpacity = useSpring(opacity, { stiffness: 100, damping: 30 });
const smoothY = useSpring(y, { stiffness: 100, damping: 30 });
const smoothScale = useSpring(scale, { stiffness: 100, damping: 30 });
return (
<motion.div
ref={ref}
style={{
opacity: smoothOpacity,
y: smoothY,
scale: smoothScale,
}}
initial={{ opacity: 0, y: 50, scale: 0.95 }}
whileInView={{ opacity: 1, y: 0, scale: 1 }}
viewport={{ once, amount: threshold }}
transition={{
duration: 0.8,
delay,
ease: [0.16, 1, 0.3, 1],
}}
className={className}
{...props}
>
{children}
</motion.div>
);
}
interface ParallaxSectionProps extends HTMLMotionProps<'section'> {
children: ReactNode;
className?: string;
speed?: number;
}
export function ParallaxSection({
children,
className = '',
speed = 0.5,
...props
}: ParallaxSectionProps) {
const ref = useRef<HTMLElement>(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ['start end', 'end start'],
});
const y = useTransform(scrollYProgress, [0, 1], ['0%', `${speed * 100}%`]);
const smoothY = useSpring(y, { stiffness: 100, damping: 30 });
return (
<motion.section ref={ref} style={{ y: smoothY }} className={className} {...props}>
{children}
</motion.section>
);
}
interface ParallaxImageProps {
src: string;
alt: string;
className?: string;
speed?: number;
}
export function ParallaxImage({ src, alt, className = '', speed = 0.3 }: ParallaxImageProps) {
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ['start end', 'end start'],
});
const y = useTransform(scrollYProgress, [0, 1], [`${-speed * 50}%`, `${speed * 50}%`]);
const scale = useTransform(scrollYProgress, [0, 0.5, 1], [1.1, 1, 1.1]);
return (
<div ref={ref} className={`overflow-hidden ${className}`}>
<motion.img
src={src}
alt={alt}
style={{ y, scale }}
className="w-full h-full object-cover"
/>
</div>
);
}
interface ScaleOnScrollProps extends HTMLMotionProps<'div'> {
children: ReactNode;
className?: string;
minScale?: number;
maxScale?: number;
}
export function ScaleOnScroll({
children,
className = '',
minScale = 0.8,
maxScale = 1,
...props
}: ScaleOnScrollProps) {
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ['start end', 'center center'],
});
const scale = useTransform(scrollYProgress, [0, 1], [minScale, maxScale]);
const smoothScale = useSpring(scale, { stiffness: 100, damping: 30 });
return (
<motion.div ref={ref} style={{ scale: smoothScale }} className={className} {...props}>
{children}
</motion.div>
);
}
interface FadeOnScrollProps extends HTMLMotionProps<'div'> {
children: ReactNode;
className?: string;
direction?: 'up' | 'down' | 'left' | 'right';
distance?: number;
}
export function FadeOnScroll({
children,
className = '',
direction = 'up',
distance = 50,
...props
}: FadeOnScrollProps) {
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ['start end', 'center center'],
});
const transformUp = useTransform(scrollYProgress, [0, 1], [distance, 0]);
const transformDown = useTransform(scrollYProgress, [0, 1], [-distance, 0]);
const transformLeft = useTransform(scrollYProgress, [0, 1], [distance, 0]);
const transformRight = useTransform(scrollYProgress, [0, 1], [-distance, 0]);
const getTransform = () => {
switch (direction) {
case 'up':
return transformUp;
case 'down':
return transformDown;
case 'left':
return transformLeft;
case 'right':
return transformRight;
default:
return transformUp;
}
};
const transform = getTransform();
const opacity = useTransform(scrollYProgress, [0, 0.5], [0, 1]);
const smoothTransform = useSpring(transform, { stiffness: 100, damping: 30 });
const smoothOpacity = useSpring(opacity, { stiffness: 100, damping: 30 });
const style =
direction === 'left' || direction === 'right' ? { x: smoothTransform, opacity: smoothOpacity } : { y: smoothTransform, opacity: smoothOpacity };
return (
<motion.div ref={ref} style={style} className={className} {...props}>
{children}
</motion.div>
);
}
interface StaggerRevealProps extends HTMLMotionProps<'div'> {
children: ReactNode;
className?: string;
staggerDelay?: number;
containerDelay?: number;
}
export function StaggerReveal({
children,
className = '',
staggerDelay = 0.1,
containerDelay = 0,
...props
}: StaggerRevealProps) {
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: staggerDelay,
delayChildren: containerDelay,
},
},
};
return (
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.2 }}
variants={containerVariants}
className={className}
{...props}
>
{children}
</motion.div>
);
}
interface StaggerItemProps extends HTMLMotionProps<'div'> {
children: ReactNode;
className?: string;
}
export function StaggerItem({ children, className = '', ...props }: StaggerItemProps) {
const itemVariants: 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],
},
},
};
return (
<motion.div variants={itemVariants} className={className} {...props}>
{children}
</motion.div>
);
}
interface TextRevealProps extends HTMLMotionProps<'div'> {
text: string;
className?: string;
delay?: number;
}
export function TextReveal({ text, className = '', delay = 0, ...props }: TextRevealProps) {
const words = text.split(' ');
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.05,
delayChildren: delay,
},
},
};
const wordVariants: Variants = {
hidden: {
opacity: 0,
y: 20,
},
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.4,
ease: [0.16, 1, 0.3, 1],
},
},
};
return (
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
variants={containerVariants}
className={className}
{...props}
>
{words.map((word, index) => (
<motion.span
key={index}
variants={wordVariants}
className="inline-block mr-2"
>
{word}
</motion.span>
))}
</motion.div>
);
}
interface ProgressIndicatorProps {
className?: string;
color?: string;
}
export function ProgressIndicator({ className = '', color = '#C41E3A' }: ProgressIndicatorProps) {
const { scrollYProgress } = useScroll();
const scaleX = useSpring(scrollYProgress, { stiffness: 100, damping: 30 });
return (
<motion.div
className={`fixed top-0 left-0 right-0 h-1 origin-left z-50 ${className}`}
style={{ scaleX, backgroundColor: color }}
/>
);
}
interface ScrollTriggeredCounterProps {
end: number;
duration?: number;
prefix?: string;
suffix?: string;
className?: string;
}
export function ScrollTriggeredCounter({
end,
duration: _duration = 2,
prefix = '',
suffix = '',
className = '',
}: ScrollTriggeredCounterProps) {
const ref = useRef<HTMLSpanElement>(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ['start end', 'center center'],
});
const displayNumber = useTransform(scrollYProgress, [0, 1], [0, end]);
const rounded = useTransform(displayNumber, (latest) => Math.round(latest));
return (
<motion.span ref={ref} className={className}>
{prefix}
<motion.span>{rounded}</motion.span>
{suffix}
</motion.span>
);
}
+2 -2
View File
@@ -41,10 +41,10 @@ export function ScrollProgress() {
aria-valuemax={100}
>
<motion.div
className="h-full bg-gradient-to-r from-[#C41E3A] to-[#E04A68] origin-left"
className="h-full bg-gradient-to-r from-[var(--color-brand-primary)] to-[var(--color-brand-primary-light)] origin-left"
style={{
scaleX,
boxShadow: '0 0 10px rgba(196, 30, 58, 0.3)',
boxShadow: '0 0 10px rgba(var(--color-brand-primary-rgb), 0.3)',
}}
/>
</motion.div>
-261
View File
@@ -1,261 +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 {
Sheet,
SheetTrigger,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
} from './sheet';
describe('Sheet', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render sheet trigger', () => {
render(
<Sheet>
<SheetTrigger>Open Sheet</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Sheet Title</SheetTitle>
</SheetHeader>
</SheetContent>
</Sheet>
);
expect(screen.getByText('Open Sheet')).toBeInTheDocument();
});
it('should render sheet content when open', () => {
render(
<Sheet defaultOpen>
<SheetTrigger>Open Sheet</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Sheet Title</SheetTitle>
</SheetHeader>
</SheetContent>
</Sheet>
);
expect(screen.getByText('Sheet Title')).toBeInTheDocument();
});
it('should render sheet title', () => {
render(
<Sheet defaultOpen>
<SheetTrigger>Open Sheet</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Sheet Title</SheetTitle>
</SheetHeader>
</SheetContent>
</Sheet>
);
expect(screen.getByRole('heading', { name: 'Sheet Title' })).toBeInTheDocument();
});
it('should render sheet description', () => {
render(
<Sheet defaultOpen>
<SheetTrigger>Open Sheet</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Sheet Title</SheetTitle>
<SheetDescription>Sheet Description</SheetDescription>
</SheetHeader>
</SheetContent>
</Sheet>
);
expect(screen.getByText('Sheet Description')).toBeInTheDocument();
});
it('should render sheet footer', () => {
render(
<Sheet defaultOpen>
<SheetTrigger>Open Sheet</SheetTrigger>
<SheetContent>
<SheetFooter>
<button>Footer Button</button>
</SheetFooter>
</SheetContent>
</Sheet>
);
expect(screen.getByText('Footer Button')).toBeInTheDocument();
});
});
describe('Interaction', () => {
it('should open sheet on trigger click', () => {
render(
<Sheet>
<SheetTrigger>Open Sheet</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Sheet Title</SheetTitle>
</SheetHeader>
</SheetContent>
</Sheet>
);
const trigger = screen.getByText('Open Sheet');
fireEvent.click(trigger);
expect(screen.getByText('Sheet Title')).toBeInTheDocument();
});
it('should close sheet on close button click', () => {
render(
<Sheet defaultOpen>
<SheetTrigger>Open Sheet</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Sheet Title</SheetTitle>
</SheetHeader>
</SheetContent>
</Sheet>
);
const closeButton = screen.getByRole('button', { name: /close/i });
fireEvent.click(closeButton);
expect(screen.queryByText('Sheet Title')).not.toBeInTheDocument();
});
});
describe('Sides', () => {
it('should render right side by default', () => {
const { container } = render(
<Sheet defaultOpen>
<SheetTrigger>Open Sheet</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Sheet Title</SheetTitle>
</SheetHeader>
</SheetContent>
</Sheet>
);
expect(container.firstChild).toBeInTheDocument();
});
it('should render left side', () => {
const { container } = render(
<Sheet defaultOpen>
<SheetTrigger>Open Sheet</SheetTrigger>
<SheetContent side="left">
<SheetHeader>
<SheetTitle>Sheet Title</SheetTitle>
</SheetHeader>
</SheetContent>
</Sheet>
);
expect(container.firstChild).toBeInTheDocument();
});
it('should render top side', () => {
const { container } = render(
<Sheet defaultOpen>
<SheetTrigger>Open Sheet</SheetTrigger>
<SheetContent side="top">
<SheetHeader>
<SheetTitle>Sheet Title</SheetTitle>
</SheetHeader>
</SheetContent>
</Sheet>
);
expect(container.firstChild).toBeInTheDocument();
});
it('should render bottom side', () => {
const { container } = render(
<Sheet defaultOpen>
<SheetTrigger>Open Sheet</SheetTrigger>
<SheetContent side="bottom">
<SheetHeader>
<SheetTitle>Sheet Title</SheetTitle>
</SheetHeader>
</SheetContent>
</Sheet>
);
expect(container.firstChild).toBeInTheDocument();
});
});
describe('Styling', () => {
it('should apply custom className to content', () => {
render(
<Sheet defaultOpen>
<SheetTrigger>Open Sheet</SheetTrigger>
<SheetContent className="custom-content">
<SheetHeader>
<SheetTitle>Sheet Title</SheetTitle>
</SheetHeader>
</SheetContent>
</Sheet>
);
expect(screen.getByText('Sheet Title')).toBeInTheDocument();
});
it('should apply custom className to header', () => {
render(
<Sheet defaultOpen>
<SheetTrigger>Open Sheet</SheetTrigger>
<SheetContent>
<SheetHeader className="custom-header">
<SheetTitle>Sheet Title</SheetTitle>
</SheetHeader>
</SheetContent>
</Sheet>
);
expect(screen.getByText('Sheet Title')).toBeInTheDocument();
});
it('should apply custom className to footer', () => {
render(
<Sheet defaultOpen>
<SheetTrigger>Open Sheet</SheetTrigger>
<SheetContent>
<SheetFooter className="custom-footer">
<button>Footer</button>
</SheetFooter>
</SheetContent>
</Sheet>
);
expect(screen.getByText('Footer')).toBeInTheDocument();
});
});
describe('Close Button', () => {
it('should show close button by default', () => {
render(
<Sheet defaultOpen>
<SheetTrigger>Open Sheet</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Sheet Title</SheetTitle>
</SheetHeader>
</SheetContent>
</Sheet>
);
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
});
it('should hide close button when showCloseButton is false', () => {
render(
<Sheet defaultOpen>
<SheetTrigger>Open Sheet</SheetTrigger>
<SheetContent showCloseButton={false}>
<SheetHeader>
<SheetTitle>Sheet Title</SheetTitle>
</SheetHeader>
</SheetContent>
</Sheet>
);
expect(screen.queryByRole('button', { name: /close/i })).not.toBeInTheDocument();
});
});
});
-143
View File
@@ -1,143 +0,0 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
-22
View File
@@ -1,22 +0,0 @@
import { STATS } from '@/lib/constants';
export function StatsBar() {
return (
<div className="border-t border-[#E5E5E5] bg-white">
<div className="container-wide py-8">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 md:gap-12">
{STATS.map((stat) => (
<div key={stat.label} className="text-center">
<div className="text-3xl sm:text-4xl font-bold text-[#C41E3A] mb-1">
{stat.value}
</div>
<div className="text-sm text-[#595959]">
{stat.label}
</div>
</div>
))}
</div>
</div>
</div>
);
}
-39
View File
@@ -1,39 +0,0 @@
'use client';
import { Quote } from 'lucide-react';
import { InkGlowCard } from '@/components/ui/ink-glow-card';
interface TestimonialBlockProps {
quote: string;
author: string;
title: string;
company: string;
index: number;
}
export function TestimonialBlock({ quote, author, title, company, index }: TestimonialBlockProps) {
return (
<InkGlowCard
index={index}
accentColorRgb="196, 30, 58"
glowStart="#C41E3A"
glowEnd="#D97706"
>
<div className="p-6 md:p-8">
<Quote className="w-7 h-7 text-[#C41E3A]/15 mb-5" />
<blockquote className="text-[#1C1C1C] leading-relaxed mb-6 text-base">
&ldquo;{quote}&rdquo;
</blockquote>
<div className="flex items-center gap-3 pt-4 border-t border-[#F0F0F0]">
<div className="w-9 h-9 rounded-full bg-[#FAFAFA] flex items-center justify-center text-[#C41E3A] font-semibold text-sm">
{author.charAt(0)}
</div>
<div>
<div className="text-sm font-semibold text-[#1C1C1C]">{author}</div>
<div className="text-xs text-[#A3A3A3]">{title}{company}</div>
</div>
</div>
</div>
</InkGlowCard>
);
}
+2 -2
View File
@@ -6,7 +6,7 @@ import '@testing-library/jest-dom';
import { Textarea } from './textarea';
jest.mock('@/lib/utils', () => ({
cn: (...args: any[]) => args.filter(Boolean).join(' '),
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
}));
describe('Textarea', () => {
@@ -154,7 +154,7 @@ describe('Textarea', () => {
it('should have error styling when error exists', () => {
render(<Textarea error="错误信息" />);
const textarea = screen.getByRole('textbox');
expect(textarea.className).toMatch(/border-\[#C41E3A\]/);
expect(textarea.className).toMatch(/border-\[var\(--color-brand-primary\)\]/);
});
});
+7 -7
View File
@@ -18,10 +18,10 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
{label && (
<label
htmlFor={textareaId}
className="block text-sm font-medium text-[#3D3D3D] mb-2"
className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2"
>
{label}
{props.required && <span className="text-[#C41E3A] ml-1">*</span>}
{props.required && <span className="text-[var(--color-brand-primary)] ml-1">*</span>}
</label>
)}
<textarea
@@ -29,10 +29,10 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
data-slot="textarea"
data-testid={props['data-testid']}
className={cn(
"placeholder:text-[#8C8C8C] selection:bg-[#1C1C1C] selection:text-white min-h-[140px] w-full rounded-lg border border-[#E5E5E5] bg-[#FAFAFA] px-4 py-3 text-base text-[#1C1C1C] shadow-sm transition-all duration-300 outline-none disabled:cursor-not-allowed disabled:opacity-50 md:min-h-[80px] md:text-sm md:py-2 resize-none",
"focus-visible:border-[#1C1C1C] focus-visible:ring-2 focus-visible:ring-[#1C1C1C]/50 focus-visible:shadow-lg focus-visible:shadow-[#1C1C1C]/20",
"hover:border-[#3D3D3D]",
error && "border-[#C41E3A] focus-visible:border-[#C41E3A] focus-visible:ring-[#C41E3A]/50",
"placeholder:text-[var(--color-text-hint)] selection:bg-[var(--color-primary)] selection:text-white min-h-[140px] w-full rounded-lg border border-[var(--color-border-primary)] bg-[var(--color-bg-section)] px-4 py-3 text-base text-[var(--color-text-primary)] shadow-sm transition-all duration-300 outline-none disabled:cursor-not-allowed disabled:opacity-50 md:min-h-[80px] md:text-sm md:py-2 resize-none",
"focus-visible:border-[var(--color-primary)] focus-visible:ring-2 focus-visible:ring-[var(--color-primary)]/50 focus-visible:shadow-lg focus-visible:shadow-[var(--color-primary)]/20",
"hover:border-[var(--color-text-secondary)]",
error && "border-[var(--color-brand-primary)] focus-visible:border-[var(--color-brand-primary)] focus-visible:ring-[var(--color-brand-primary)]/50",
className
)}
ref={ref}
@@ -42,7 +42,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
{...props}
/>
{error && (
<p id={errorId} className="mt-1 text-sm text-[#C41E3A]" role="alert">
<p id={errorId} className="mt-1 text-sm text-[var(--color-brand-primary)]" role="alert">
{error}
</p>
)}
+6 -6
View File
@@ -36,29 +36,29 @@ export function Toast({
};
const bgColors = {
success: 'bg-green-50 border-green-200',
error: 'bg-red-50 border-red-200',
info: 'bg-blue-50 border-blue-200'
success: 'bg-[var(--color-toast-success-bg)] border-[var(--color-toast-success-border)]',
error: 'bg-[var(--color-toast-error-bg)] border-[var(--color-toast-error-border)]',
info: 'bg-[var(--color-toast-info-bg)] border-[var(--color-toast-info-border)]'
};
return (
<div
data-testid={dataTestId}
data-type={type}
className={`fixed bottom-4 right-4 z-50 flex items-center gap-3 px-4 py-3 bg-white rounded-lg shadow-lg border transition-all duration-300 ${
className={`fixed bottom-4 right-4 z-50 flex items-center gap-3 px-4 py-3 bg-[var(--color-bg-primary)] rounded-lg shadow-lg border transition-all duration-300 ${
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2'
} ${bgColors[type]}`}
role="alert"
aria-live="polite"
>
{icons[type]}
<p className="text-sm font-medium text-[#1C1C1C]">{message}</p>
<p className="text-sm font-medium text-[var(--color-text-primary)]">{message}</p>
<button
onClick={() => {
setIsVisible(false);
setTimeout(onClose, 300);
}}
className="ml-2 text-gray-400 hover:text-gray-600 transition-colors"
className="ml-2 text-[var(--color-toast-close)] hover:text-[var(--color-toast-close-hover)] transition-colors"
aria-label="关闭提示"
>
<X className="h-4 w-4" />
-125
View File
@@ -1,125 +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 { TouchButton } from './touch-button';
describe('TouchButton', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render button with text', () => {
render(<TouchButton>Click Me</TouchButton>);
expect(screen.getByText('Click Me')).toBeInTheDocument();
});
it('should render button element', () => {
render(<TouchButton>Button</TouchButton>);
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('should apply custom className', () => {
const { container } = render(
<TouchButton className="custom-class">Button</TouchButton>
);
expect(container.firstChild).toHaveClass('custom-class');
});
});
describe('Variants', () => {
it('should render primary variant by default', () => {
const { container } = render(<TouchButton>Primary</TouchButton>);
expect(container.firstChild).toBeInTheDocument();
});
it('should render secondary variant', () => {
const { container } = render(<TouchButton variant="secondary">Secondary</TouchButton>);
expect(container.firstChild).toBeInTheDocument();
});
it('should render ghost variant', () => {
const { container } = render(<TouchButton variant="ghost">Ghost</TouchButton>);
expect(container.firstChild).toBeInTheDocument();
});
});
describe('Sizes', () => {
it('should render small size', () => {
const { container } = render(<TouchButton size="sm">Small</TouchButton>);
expect(container.firstChild).toBeInTheDocument();
});
it('should render medium size by default', () => {
const { container } = render(<TouchButton>Medium</TouchButton>);
expect(container.firstChild).toBeInTheDocument();
});
it('should render large size', () => {
const { container } = render(<TouchButton size="lg">Large</TouchButton>);
expect(container.firstChild).toBeInTheDocument();
});
});
describe('Full Width', () => {
it('should not be full width by default', () => {
const { container } = render(<TouchButton>Button</TouchButton>);
expect(container.firstChild).not.toHaveClass('w-full');
});
it('should be full width when fullWidth is true', () => {
const { container } = render(<TouchButton fullWidth>Button</TouchButton>);
expect(container.firstChild).toHaveClass('w-full');
});
});
describe('Disabled State', () => {
it('should not be disabled by default', () => {
render(<TouchButton>Button</TouchButton>);
expect(screen.getByRole('button')).not.toBeDisabled();
});
it('should be disabled when disabled prop is true', () => {
render(<TouchButton disabled>Button</TouchButton>);
expect(screen.getByRole('button')).toBeDisabled();
});
});
describe('Touch Events', () => {
it('should handle touch start', () => {
const { container } = render(<TouchButton>Button</TouchButton>);
const button = container.firstChild as HTMLElement;
fireEvent.touchStart(button);
expect(button).toBeInTheDocument();
});
it('should handle touch end', () => {
const { container } = render(<TouchButton>Button</TouchButton>);
const button = container.firstChild as HTMLElement;
fireEvent.touchStart(button);
fireEvent.touchEnd(button);
expect(button).toBeInTheDocument();
});
it('should handle touch cancel', () => {
const { container } = render(<TouchButton>Button</TouchButton>);
const button = container.firstChild as HTMLElement;
fireEvent.touchStart(button);
fireEvent.touchCancel(button);
expect(button).toBeInTheDocument();
});
});
describe('Click Events', () => {
it('should handle click events', () => {
const onClick = jest.fn();
render(<TouchButton onClick={onClick}>Button</TouchButton>);
fireEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalled();
});
});
});
-79
View File
@@ -1,79 +0,0 @@
'use client';
import { useState, type ReactNode, type ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
interface TouchButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
fullWidth?: boolean;
}
export function TouchButton({
children,
variant = 'primary',
size = 'md',
fullWidth = false,
className,
disabled,
...props
}: TouchButtonProps) {
const [isPressed, setIsPressed] = useState(false);
const baseStyles = `
inline-flex items-center justify-center font-medium
transition-all duration-150 ease-out
active:scale-95
focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100
touch-manipulation
`;
const variants = {
primary: `
bg-[#C41E3A] text-white
hover:bg-[#A01830]
focus-visible:ring-[#C41E3A]
${isPressed ? 'bg-[#8B1429]' : ''}
`,
secondary: `
bg-[#F5F5F5] text-[#171717]
hover:bg-[#E5E5E5]
border border-[#E5E5E5]
focus-visible:ring-[#C41E3A]
${isPressed ? 'bg-[#D4D4D4]' : ''}
`,
ghost: `
bg-transparent text-[#525252]
hover:bg-[#FEF2F4] hover:text-[#C41E3A]
focus-visible:ring-[#C41E3A]
${isPressed ? 'bg-[#FCE8EC] text-[#C41E3A]' : ''}
`,
};
const sizes = {
sm: 'text-sm px-4 py-2 rounded-md gap-1.5 min-h-[36px]',
md: 'text-base px-5 py-2.5 rounded-lg gap-2 min-h-[44px]',
lg: 'text-lg px-6 py-3 rounded-lg gap-2 min-h-[52px]',
};
return (
<button
className={cn(
baseStyles,
variants[variant],
sizes[size],
fullWidth && 'w-full',
className
)}
disabled={disabled}
onTouchStart={() => setIsPressed(true)}
onTouchEnd={() => setIsPressed(false)}
onTouchCancel={() => setIsPressed(false)}
{...props}
>
{children}
</button>
);
}
-168
View File
@@ -1,168 +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 { TouchSwipe } from './touch-swipe';
describe('TouchSwipe', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render children', () => {
render(
<TouchSwipe>
<div>Test Content</div>
</TouchSwipe>
);
expect(screen.getByText('Test Content')).toBeInTheDocument();
});
it('should apply custom className', () => {
const { container } = render(
<TouchSwipe className="custom-class">
<div>Test</div>
</TouchSwipe>
);
expect(container.firstChild).toHaveClass('custom-class');
});
});
describe('Touch Events', () => {
it('should handle touch start', () => {
const { container } = render(
<TouchSwipe>
<div>Test</div>
</TouchSwipe>
);
const div = container.firstChild as HTMLElement;
fireEvent.touchStart(div, {
touches: [{ clientX: 100, clientY: 100 }],
});
expect(div).toBeInTheDocument();
});
it('should handle touch end', () => {
const { container } = render(
<TouchSwipe>
<div>Test</div>
</TouchSwipe>
);
const div = container.firstChild as HTMLElement;
fireEvent.touchStart(div, {
touches: [{ clientX: 100, clientY: 100 }],
});
fireEvent.touchEnd(div, {
changedTouches: [{ clientX: 150, clientY: 100 }],
});
expect(div).toBeInTheDocument();
});
it('should call onSwipeLeft when swiping left', () => {
const onSwipeLeft = jest.fn();
const { container } = render(
<TouchSwipe onSwipeLeft={onSwipeLeft}>
<div>Test</div>
</TouchSwipe>
);
const div = container.firstChild as HTMLElement;
fireEvent.touchStart(div, {
touches: [{ clientX: 150, clientY: 100 }],
});
fireEvent.touchEnd(div, {
changedTouches: [{ clientX: 50, clientY: 100 }],
});
expect(onSwipeLeft).toHaveBeenCalled();
});
it('should call onSwipeRight when swiping right', () => {
const onSwipeRight = jest.fn();
const { container } = render(
<TouchSwipe onSwipeRight={onSwipeRight}>
<div>Test</div>
</TouchSwipe>
);
const div = container.firstChild as HTMLElement;
fireEvent.touchStart(div, {
touches: [{ clientX: 50, clientY: 100 }],
});
fireEvent.touchEnd(div, {
changedTouches: [{ clientX: 150, clientY: 100 }],
});
expect(onSwipeRight).toHaveBeenCalled();
});
it('should not trigger swipe when below threshold', () => {
const onSwipeLeft = jest.fn();
const { container } = render(
<TouchSwipe onSwipeLeft={onSwipeLeft} threshold={100}>
<div>Test</div>
</TouchSwipe>
);
const div = container.firstChild as HTMLElement;
fireEvent.touchStart(div, {
touches: [{ clientX: 100, clientY: 100 }],
});
fireEvent.touchEnd(div, {
changedTouches: [{ clientX: 80, clientY: 100 }],
});
expect(onSwipeLeft).not.toHaveBeenCalled();
});
});
describe('Threshold', () => {
it('should use default threshold of 50', () => {
const onSwipeLeft = jest.fn();
const { container } = render(
<TouchSwipe onSwipeLeft={onSwipeLeft}>
<div>Test</div>
</TouchSwipe>
);
const div = container.firstChild as HTMLElement;
fireEvent.touchStart(div, {
touches: [{ clientX: 100, clientY: 100 }],
});
fireEvent.touchEnd(div, {
changedTouches: [{ clientX: 40, clientY: 100 }],
});
expect(onSwipeLeft).toHaveBeenCalled();
});
it('should accept custom threshold', () => {
const onSwipeLeft = jest.fn();
const { container } = render(
<TouchSwipe onSwipeLeft={onSwipeLeft} threshold={200}>
<div>Test</div>
</TouchSwipe>
);
const div = container.firstChild as HTMLElement;
fireEvent.touchStart(div, {
touches: [{ clientX: 300, clientY: 100 }],
});
fireEvent.touchEnd(div, {
changedTouches: [{ clientX: 50, clientY: 100 }],
});
expect(onSwipeLeft).toHaveBeenCalled();
});
});
});
-54
View File
@@ -1,54 +0,0 @@
'use client';
import { useRef, ReactNode } from 'react';
interface TouchSwipeProps {
children: ReactNode;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
threshold?: number;
className?: string;
}
export function TouchSwipe({
children,
onSwipeLeft,
onSwipeRight,
threshold = 50,
className = '',
}: TouchSwipeProps) {
const touchStartX = useRef(0);
const containerRef = useRef<HTMLDivElement>(null);
const handleTouchStart = (e: React.TouchEvent) => {
if (e.touches[0]) {
touchStartX.current = e.touches[0].clientX;
}
};
const handleTouchEnd = (e: React.TouchEvent) => {
if (e.changedTouches[0]) {
const touchEndX = e.changedTouches[0].clientX;
const diff = touchStartX.current - touchEndX;
if (Math.abs(diff) > threshold) {
if (diff > 0 && onSwipeLeft) {
onSwipeLeft();
} else if (diff < 0 && onSwipeRight) {
onSwipeRight();
}
}
}
};
return (
<div
ref={containerRef}
className={className}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{children}
</div>
);
}