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
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
# Novalon Website - 领域术语表
## 核心实体
### 墨韵流光
Novalon 网站的核心视觉系统,包含两个子机制:
- **旋转渐变边框 (ink-glow-border)**:卡片边框使用旋转的 conic-gradient,产生墨韵流动感
- **鼠标跟随光晕 (mouse-follow)**:卡片内跟随鼠标的 radial-gradient 光晕,产生交互反馈
当前状态:.impeccable.md 已定义规范,但首页组件**未实现**。
### 水墨雅致
Novalon 的整体设计风格定位。核心原则:以留白和排版取胜,特效点到为止。参考 Apple 中国官网。
### 朱砂点睛
品牌红 #C41E3A 的使用原则——仅作点缀,不作为主色调。标题中关键词用 font-calligraphy 突出。
### 特效组件 (Effects)
`src/components/effects/` 目录下的 24 个视觉特效组件。当前状态:**大部分未被首页使用**,属于技术债务。
### Design Tokens
`.impeccable.md` 中定义的设计令牌系统,包含颜色、排版、间距、卡片系统、Section 背景交替规则。当前状态:**文档已定义,代码中 globals.css 有对应 CSS 变量,但组件层未完全落地**。
## 关键决策
| 术语 | 含义 | 决策状态 |
|------|------|---------|
| 重构范围 | 保持 5 个 section 不变,聚焦质量提升 | ✅ 已确认 |
| 特效取舍 | 逐个评估 24 个特效组件,决定保留/改造/删除 | ✅ 已确认 |
| Hero 视觉方向 | 先用原型对比再决定(排版驱动 vs 墨韵背景 vs 中间路线) | ✅ 已确认 |
| web-design-engineer 定位 | 仅用于 Hero 原型验证,不用于全站重构 | ✅ 已确认 |
| 运营状态 | 未正式上线,重构风险可控 | ✅ 已确认 |
## 歧义已解决
- **"重构"≠ 推倒重来**:在现有 Next.js 架构内做系统性清理和提升,不更换技术栈
- **"web-design-engineer"≠ 代码生成器**:它是设计验证工具,产出原型 HTML,不是最终代码
- **"克制动效"≠ 零动效**:动效服务于信息传达(hover 反馈、scroll reveal、卡片交互),但不做装饰性粒子/水墨动画
@@ -0,0 +1,33 @@
# ADR 0001: 重构路径选择——混合方案而非全站 web-design-engineer 替换
## 状态
已接受
## 上下文
Novalon 网站面临技术债务问题:24 个特效组件和 50+ UI 组件大部分未被首页使用,.impeccable.md 定义的设计系统未完全落地,globals.css 有 1200+ 行但大量未引用。需要决定重构路径。
## 决策
采用**混合方案**web-design-engineer 仅用于 Hero 区原型验证,主体重构在现有 Next.js 架构内进行。
## 理由
### 为什么不用 web-design-engineer 全站替换?
1. **架构不兼容**web-design-engineer 产出独立 HTML 文件,无法集成到 Next.js App Router + 静态导出架构中
2. **丢失现有资产**:50+ UI 组件(含测试)、SEO 优化、分析追踪、可访问性适配等将全部丢失
3. **维护性倒退**:单文件 HTML 无法支撑后续迭代(产品页、解决方案页等动态路由)
### 为什么用混合方案?
1. **Hero 视觉方向未定**:当前 Hero 是纯白底+文字,与设计文档差距最大,需要原型对比来决策
2. **web-design-engineer 擅长快速视觉探索**:2-3 个 Hero 变体可在单次会话中完成
3. **主体工作在 Next.js 内更高效**:清理死代码、激活设计系统、整合特效——这些都需要在代码库内操作
## 后果
- 正面:保留现有架构和资产,风险可控;Hero 原型可快速验证视觉方向
- 负面:Hero 原型需要"翻译"为 Next.js 组件,存在少量双工
- 风险:如果 Hero 原型方向与现有设计系统冲突,可能需要调整 .impeccable.md
+18 -1728
View File
File diff suppressed because it is too large Load Diff
+1 -6
View File
@@ -38,14 +38,10 @@
"prepare": "husky"
},
"dependencies": {
"@antv/g2": "^5.4.8",
"@playwright/test": "^1.58.2",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@types/three": "^0.183.1",
"@radix-ui/react-slot": "^1.2.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"critters": "^0.0.23",
"date-fns": "^4.1.0",
"framer-motion": "^12.34.3",
"lucide-react": "^0.563.0",
@@ -53,7 +49,6 @@
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind-merge": "^3.4.0",
"three": "^0.183.1",
"zod": "^4.3.6"
},
"devDependencies": {
+140 -174
View File
@@ -1,31 +1,16 @@
'use client';
import { motion } from 'framer-motion';
import { useState, useEffect, useMemo } from 'react';
import { COMPANY_INFO, STATS } from '@/lib/constants';
import { FlipClock } from '@/components/ui/flip-clock';
import { useMemo } from 'react';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { COMPANY_INFO } from '@/lib/constants';
import { PageNav } from '@/components/layout/page-nav';
import { Users, Target, Award, MapPin, Mail } from 'lucide-react';
import { differenceInYears, differenceInMonths, differenceInDays, subYears, subMonths } from 'date-fns';
import { BreadcrumbSchema } from '@/components/seo/structured-data';
import { Users, Target, Award } from 'lucide-react';
export function AboutClient() {
const [operationTime, setOperationTime] = useState({ days: 0, months: 0, years: 0 });
useEffect(() => {
const foundingDate = new Date('2026-01-15');
const calculateTime = () => {
const now = new Date();
const years = differenceInYears(now, foundingDate);
const afterYears = subYears(now, years);
const months = differenceInMonths(afterYears, foundingDate);
const afterMonths = subMonths(afterYears, months);
const days = differenceInDays(afterMonths, foundingDate);
setOperationTime({ days, months, years });
};
calculateTime();
const timer = setInterval(calculateTime, 60000);
return () => clearInterval(timer);
}, []);
const shouldReduceMotion = useReducedMotion();
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
const values = useMemo(() => [
{ icon: Target, title: '务实', description: '不追逐风口,只做真正为客户创造价值的事。' },
@@ -34,193 +19,174 @@ export function AboutClient() {
], []);
const milestones = useMemo(() => [
{ date: '2026年1月', title: '公司成立', description: '四川睿新致远科技有限公司在成都龙泉驿区正式成立' },
{ date: '2026年1月', title: '团队组建', description: '核心团队到位,成员来自多个大型传统IT企业,具备扎实的工程能力和规范化交付经验' },
{ date: '2026年2月', title: '业务启动', description: '推出企业数字化转型咨询与解决方案服务,开始接触首批意向客户' },
{ date: '2026年3月', title: '产品研发', description: '自主研发的ERP、CRM等产品启动研发,逐步构建产品矩阵' },
{ date: '2026年5月', title: '研发推进', description: '多款产品进入核心功能开发阶段,同步开展早期用户体验计划' },
{ date: '2026.01', title: '公司成立', description: '四川睿新致远科技有限公司在成都龙泉驿区正式成立' },
{ date: '2026.01', title: '团队组建', description: '核心团队到位,成员来自多个大型传统IT企业,具备扎实的工程能力和规范化交付经验' },
{ date: '2026.02', title: '业务启动', description: '推出企业数字化转型咨询与解决方案服务,开始接触首批意向客户' },
{ date: '2026.03', title: '产品研发', description: '自主研发的ERP、CRM等产品启动研发,逐步构建产品矩阵' },
{ date: '2026.05', title: '研发推进', description: '多款产品进入核心功能开发阶段,同步开展早期用户体验计划' },
], []);
return (
<div className="min-h-screen bg-white">
<div className="min-h-screen bg-[var(--color-bg-primary)]">
<BreadcrumbSchema items={[{ name: '首页', href: '/' }, { name: '关于我们', href: '/about' }]} />
<section className="pt-32 pb-16">
<div className="container-wide">
<PageNav items={[{ label: '关于我们' }]} />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
{...fadeUp}
animate={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-3xl"
>
<p className="text-sm font-medium text-[#C41E3A] mb-4 tracking-wide uppercase">About</p>
<h1 className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6 tracking-tight">
<p className="text-sm font-medium text-[var(--color-brand-primary)] mb-4 tracking-wide uppercase">About</p>
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-text-primary)] mb-6 tracking-tight">
</h1>
<p className="text-lg text-[#595959] leading-relaxed">
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed">
</p>
</motion.div>
</div>
</section>
<section className="pb-16">
<section className="pb-20">
<div className="container-wide">
<div className="max-w-4xl mx-auto space-y-8">
<div className="max-w-4xl mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
{...fadeUp}
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="p-8 rounded-xl border border-[#E5E5E5]"
className="bg-[var(--color-bg-section)] rounded-2xl p-8 md:p-12"
>
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6"> {COMPANY_INFO.shortName}</h2>
<p className="text-xl font-semibold text-[#1C1C1C] mb-4"></p>
<p className="text-[#595959] mb-6 leading-relaxed"></p>
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-8">
{COMPANY_INFO.shortName}
</h2>
<div className="mb-6">
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-3"></h3>
<p className="text-[#595959] mb-2 leading-relaxed"></p>
<p className="text-[#595959] mb-2 leading-relaxed"></p>
<p className="text-[#595959] leading-relaxed"></p>
</div>
<div>
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-3"></h3>
<p className="text-[#595959] mb-2 leading-relaxed">&ldquo;&rdquo;</p>
<p className="text-[#595959] mb-2 leading-relaxed"></p>
<p className="text-[#595959] mb-2 leading-relaxed"></p>
<p className="text-[#595959] mt-3 leading-relaxed">&ldquo;&rdquo;</p>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.1 }}
className="p-8 rounded-xl border border-[#E5E5E5]"
>
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6"></h2>
<p className="text-[#595959] mb-4 leading-relaxed"></p>
<ul className="space-y-2 mb-6">
<li className="flex items-start gap-3">
<span className="text-[#C41E3A] font-bold"></span>
<span className="text-[#595959]"></span>
</li>
<li className="flex items-start gap-3">
<span className="text-[#C41E3A] font-bold"></span>
<span className="text-[#595959]"></span>
</li>
<li className="flex items-start gap-3">
<span className="text-[#C41E3A] font-bold"></span>
<span className="text-[#595959]">&ldquo;&rdquo;</span>
</li>
</ul>
<p className="text-[#1C1C1C] leading-relaxed font-medium">
</p>
</motion.div>
<FlipClock
years={operationTime.years}
months={operationTime.months}
days={operationTime.days}
/>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="grid grid-cols-2 md:grid-cols-4 gap-4"
>
{STATS.map((stat, idx) => (
<div key={idx} className="p-6 bg-[#F5F5F5] rounded-lg text-center">
<div className="text-3xl font-bold text-[#C41E3A] mb-1">{stat.value}</div>
<div className="text-sm text-[#595959]">{stat.label}</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12">
<div>
<h3 className="text-lg font-semibold text-[var(--color-brand-primary)] mb-3"></h3>
<p className="text-[var(--color-text-muted)] mb-2 leading-relaxed"></p>
<p className="text-[var(--color-text-muted)] mb-2 leading-relaxed"></p>
<p className="text-[var(--color-text-muted)] leading-relaxed"></p>
</div>
<div>
<h3 className="text-lg font-semibold text-[var(--color-brand-primary)] mb-3"></h3>
<p className="text-[var(--color-text-muted)] mb-2 leading-relaxed">&ldquo;&rdquo;</p>
<p className="text-[var(--color-text-muted)] mb-2 leading-relaxed"></p>
<p className="text-[var(--color-text-muted)] leading-relaxed"></p>
</div>
</div>
<div className="mt-8 pt-8 border-t border-[var(--color-border-light)]">
<p className="text-[var(--color-text-muted)] mb-4 leading-relaxed"></p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{[
'不卖您用不上的技术',
'不说不懂业务的术语',
'不做路过就忘的"一锤子买卖"',
].map((promise) => (
<div key={promise} className="flex items-start gap-2">
<span className="text-[var(--color-brand-primary)] font-bold mt-0.5"></span>
<span className="text-[var(--color-text-muted)] text-sm">{promise}</span>
</div>
))}
</div>
<p className="text-[var(--color-text-primary)] leading-relaxed font-medium mt-4">
</p>
</div>
</motion.div>
</div>
</div>
</section>
<section className="py-20 bg-[var(--color-bg-section)]">
<div className="container-wide">
<div className="max-w-4xl mx-auto">
<motion.h2
{...fadeUp}
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="text-2xl font-bold text-[var(--color-text-primary)] mb-10"
>
</motion.h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{values.map((value) => (
<motion.div
key={value.title}
{...fadeUp}
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="flex items-start gap-4 p-6 rounded-xl bg-[var(--color-bg-primary)] border border-[var(--color-border-primary)] hover:border-[rgba(var(--color-brand-primary-rgb),0.3)] transition-colors"
>
<div className="w-10 h-10 rounded-lg bg-[var(--color-brand-primary-bg)] flex items-center justify-center shrink-0">
<value.icon className="w-5 h-5 text-[var(--color-brand-primary)]" />
</div>
<div>
<h3 className="font-semibold text-[var(--color-text-primary)] mb-1">{value.title}</h3>
<p className="text-sm text-[var(--color-text-muted)]">{value.description}</p>
</div>
</motion.div>
))}
</motion.div>
</div>
</div>
</div>
</section>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
<section className="py-20">
<div className="container-wide">
<div className="max-w-4xl mx-auto">
<motion.h2
{...fadeUp}
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="text-2xl font-bold text-[var(--color-text-primary)] mb-10"
>
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6 text-center"></h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{values.map((value) => (
<div
key={value.title}
className="flex items-start gap-4 p-6 rounded-xl border border-[#E5E5E5] hover:border-[#C41E3A]/30 transition-colors"
>
<div className="w-10 h-10 rounded-lg bg-[#FEF2F4] flex items-center justify-center shrink-0">
<value.icon className="w-5 h-5 text-[#C41E3A]" />
</div>
<div>
<h3 className="font-semibold text-[#1C1C1C] mb-1">{value.title}</h3>
<p className="text-sm text-[#595959]">{value.description}</p>
</div>
</div>
))}
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6 text-center"></h2>
<div className="space-y-4">
{milestones.map((milestone) => (
<div
</motion.h2>
<div className="relative">
<div className="absolute left-[4.5rem] top-0 bottom-0 w-px bg-[var(--color-border-primary)] hidden md:block" />
<div className="space-y-8">
{milestones.map((milestone, idx) => {
const isLatest = idx === milestones.length - 1;
return (
<motion.div
key={milestone.title}
className="flex flex-col md:flex-row md:items-start gap-3 p-5 rounded-lg border border-[#E5E5E5]"
{...fadeUp}
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: idx * 0.1 }}
className="flex items-start gap-6"
>
<div className="md:w-28 shrink-0">
<span className="text-sm font-medium text-[#C41E3A]">{milestone.date}</span>
<div className="md:w-20 shrink-0 text-right hidden md:block">
<span className="text-sm font-mono font-medium text-[var(--color-brand-primary)]">{milestone.date}</span>
</div>
<div className="flex-1">
<h3 className="font-semibold text-[#1C1C1C] mb-1 text-sm">{milestone.title}</h3>
<p className="text-sm text-[#595959]">{milestone.description}</p>
<div className="relative shrink-0 hidden md:block">
<div className={`w-3 h-3 rounded-full border-2 border-white shadow-sm mt-1 ${isLatest ? 'bg-[var(--color-brand-primary)] animate-pulse' : 'bg-[var(--color-brand-primary)]'}`} />
</div>
</div>
))}
<div className="flex-1 pb-2">
<div className="md:hidden text-sm font-mono font-medium text-[var(--color-brand-primary)] mb-1">{milestone.date}</div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-[var(--color-text-primary)]">{milestone.title}</h3>
{isLatest && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-[var(--color-brand-primary-bg)] text-[var(--color-brand-primary)] border border-[var(--color-brand-primary)]/10">
</span>
)}
</div>
<p className="text-sm text-[var(--color-text-muted)] leading-relaxed">{milestone.description}</p>
</div>
</motion.div>
);
})}
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="p-8 rounded-xl border border-[#E5E5E5]"
>
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6 text-center"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-[#FEF2F4] rounded-lg flex items-center justify-center">
<MapPin className="w-5 h-5 text-[#C41E3A]" />
</div>
<div>
<p className="text-xs text-[#595959]"></p>
<p className="text-sm font-medium text-[#1C1C1C]">{COMPANY_INFO.address}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-[#FEF2F4] rounded-lg flex items-center justify-center">
<Mail className="w-5 h-5 text-[#C41E3A]" />
</div>
<div>
<p className="text-xs text-[#595959]"></p>
<p className="text-sm font-medium text-[#1C1C1C]">{COMPANY_INFO.email}</p>
</div>
</div>
</div>
</motion.div>
</div>
</div>
</div>
</section>
-11
View File
@@ -72,17 +72,6 @@ jest.mock('@/components/ui/card', () => {
return { Card, CardContent };
});
jest.mock('@/components/ui/page-header', () => {
const PageHeader = ({ title, description }: { title: string; description?: string }) => (
<header>
<h1>{title}</h1>
<p>{description}</p>
</header>
);
PageHeader.displayName = 'PageHeader';
return { PageHeader };
});
jest.mock('@/components/ui/flip-clock', () => {
const FlipClock = ({ years, months, days }: { years: number; months: number; days: number }) => (
<div data-testid="flip-clock">
+48 -49
View File
@@ -1,9 +1,10 @@
'use client';
import { useState, useEffect, Suspense } from 'react';
import { useState, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { z } from 'zod';
import { motion } from 'framer-motion';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
@@ -12,6 +13,7 @@ import { Mail, MapPin, Send, Loader2, Clock, HeadphonesIcon, CheckCircle2 } from
import { COMPANY_INFO } from '@/lib/constants';
import { PageNav } from '@/components/layout/page-nav';
import { trackContactForm, trackConversion } from '@/lib/analytics';
import { BreadcrumbSchema } from '@/components/seo/structured-data';
const contactFormSchema = z.object({
name: z.string().min(2, '姓名至少需要2个字符'),
@@ -34,6 +36,8 @@ interface FormErrors {
function ContactFormContent() {
const searchParams = useSearchParams();
const isSuccessFromRedirect = searchParams.get('success') === 'true';
const shouldReduceMotion = useReducedMotion();
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
const [showToast, setShowToast] = useState(isSuccessFromRedirect);
const [toastMessage, setToastMessage] = useState(
isSuccessFromRedirect ? '表单提交成功!我们会尽快与您联系。' : ''
@@ -52,12 +56,6 @@ function ContactFormContent() {
});
const [errors, setErrors] = useState<FormErrors>({});
useEffect(() => {
if (isSuccessFromRedirect) {
setShowToast(true);
}
}, [isSuccessFromRedirect]);
const validateField = (field: keyof ContactFormData, value: string) => {
try {
contactFormSchema.shape[field].parse(value);
@@ -153,7 +151,8 @@ function ContactFormContent() {
}
return (
<div className="min-h-screen bg-white">
<div className="min-h-screen bg-[var(--color-bg-primary)]">
<BreadcrumbSchema items={[{ name: '首页', href: '/' }, { name: '联系我们', href: '/contact' }]} />
{showToast && (
<Toast
message={toastMessage}
@@ -166,16 +165,16 @@ function ContactFormContent() {
<div className="container-wide">
<PageNav items={[{ label: '联系我们' }]} />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
{...fadeUp}
animate={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-3xl"
>
<p className="text-sm font-medium text-[#C41E3A] mb-4 tracking-wide uppercase">Contact</p>
<h1 className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6 tracking-tight">
<p className="text-sm font-medium text-[var(--color-brand-primary)] mb-4 tracking-wide uppercase">Contact</p>
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-text-primary)] mb-6 tracking-tight">
</h1>
<p className="text-lg text-[#595959] leading-relaxed">
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed">
</p>
</motion.div>
@@ -186,93 +185,93 @@ function ContactFormContent() {
<div className="container-wide">
<div className="grid lg:grid-cols-5 gap-12 lg:gap-16">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
{...fadeUp}
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="lg:col-span-2 space-y-6"
>
<div>
<h2 className="text-lg font-semibold text-[#1C1C1C] mb-6"></h2>
<h2 className="text-lg font-semibold text-[var(--color-text-primary)] mb-6"></h2>
<div className="space-y-4" data-testid="contact-info">
<div className="flex items-start gap-4 group" data-testid="email-info">
<div className="w-10 h-10 bg-[#FEF2F4] rounded-lg flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform duration-200">
<Mail className="w-5 h-5 text-[#C41E3A]" />
<div className="w-10 h-10 bg-[var(--color-brand-primary-bg)] rounded-lg flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform duration-200">
<Mail className="w-5 h-5 text-[var(--color-brand-primary)]" />
</div>
<div>
<p className="text-sm text-[#595959] mb-1"></p>
<a href={`mailto:${COMPANY_INFO.email}`} className="text-[#1C1C1C] hover:text-[#C41E3A] transition-colors duration-200" data-testid="email-link">
<p className="text-sm text-[var(--color-text-muted)] mb-1"></p>
<a href={`mailto:${COMPANY_INFO.email}`} className="text-[var(--color-text-primary)] hover:text-[var(--color-brand-primary)] transition-colors duration-200" data-testid="email-link">
{COMPANY_INFO.email}
</a>
</div>
</div>
<div className="flex items-start gap-4 group" data-testid="address-info">
<div className="w-10 h-10 bg-[#FEF2F4] rounded-lg flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform duration-200">
<MapPin className="w-5 h-5 text-[#C41E3A]" />
<div className="w-10 h-10 bg-[var(--color-brand-primary-bg)] rounded-lg flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform duration-200">
<MapPin className="w-5 h-5 text-[var(--color-brand-primary)]" />
</div>
<div>
<p className="text-sm text-[#595959] mb-1"></p>
<p className="text-[#1C1C1C]" data-testid="address-text">{COMPANY_INFO.address}</p>
<p className="text-sm text-[var(--color-text-muted)] mb-1"></p>
<p className="text-[var(--color-text-primary)]" data-testid="address-text">{COMPANY_INFO.address}</p>
</div>
</div>
</div>
</div>
<div className="p-5 rounded-xl border border-[#E5E5E5]" data-testid="work-hours-card">
<div className="p-5 rounded-xl border border-[var(--color-border)]" data-testid="work-hours-card">
<div className="flex items-center gap-2 mb-3">
<Clock className="w-4 h-4 text-[#C41E3A]" />
<h2 className="text-sm font-medium text-[#1C1C1C]"></h2>
<Clock className="w-4 h-4 text-[var(--color-brand-primary)]" />
<h2 className="text-sm font-medium text-[var(--color-text-primary)]"></h2>
</div>
<div className="flex justify-between text-sm" data-testid="work-hours-row">
<span className="text-[#595959]"></span>
<span className="text-[#C41E3A] font-medium">9:00 - 18:00</span>
<span className="text-[var(--color-text-muted)]"></span>
<span className="text-[var(--color-brand-primary)] font-medium">9:00 - 18:00</span>
</div>
</div>
<div className="p-5 rounded-xl border border-[#E5E5E5]">
<div className="p-5 rounded-xl border border-[var(--color-border)]">
<div className="flex items-center gap-2 mb-3">
<HeadphonesIcon className="w-4 h-4 text-[#C41E3A]" />
<h2 className="text-sm font-medium text-[#1C1C1C]"></h2>
<HeadphonesIcon className="w-4 h-4 text-[var(--color-brand-primary)]" />
<h2 className="text-sm font-medium text-[var(--color-text-primary)]"></h2>
</div>
<div className="space-y-3">
<div className="flex items-start gap-2">
<div className="w-1.5 h-1.5 bg-[#C41E3A] rounded-full mt-2 shrink-0" />
<p className="text-sm text-[#595959]"> 2 </p>
<div className="w-1.5 h-1.5 bg-[var(--color-brand-primary)] rounded-full mt-2 shrink-0" />
<p className="text-sm text-[var(--color-text-muted)]"> 2 </p>
</div>
<div className="flex items-start gap-2">
<div className="w-1.5 h-1.5 bg-[#C41E3A] rounded-full mt-2 shrink-0" />
<p className="text-sm text-[#595959]"></p>
<div className="w-1.5 h-1.5 bg-[var(--color-brand-primary)] rounded-full mt-2 shrink-0" />
<p className="text-sm text-[var(--color-text-muted)]"></p>
</div>
<div className="flex items-start gap-2">
<div className="w-1.5 h-1.5 bg-[#C41E3A] rounded-full mt-2 shrink-0" />
<p className="text-sm text-[#595959]"></p>
<div className="w-1.5 h-1.5 bg-[var(--color-brand-primary)] rounded-full mt-2 shrink-0" />
<p className="text-sm text-[var(--color-text-muted)]"></p>
</div>
</div>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
{...fadeUp}
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.1 }}
className="lg:col-span-3"
>
<div className="p-6 sm:p-8 rounded-xl border border-[#E5E5E5] bg-[#FAFAFA]">
<h2 className="text-lg font-semibold text-[#1C1C1C] mb-6"></h2>
<div className="p-6 sm:p-8 rounded-xl border border-[var(--color-border-primary)] bg-[var(--color-bg-primary)] shadow-md">
<h2 className="text-lg font-semibold text-[var(--color-text-primary)] mb-6"></h2>
{isSubmitted ? (
<div className="text-center py-12">
<div className="w-16 h-16 bg-[#C41E3A] rounded-full flex items-center justify-center mx-auto mb-4">
<div className="w-16 h-16 bg-[var(--color-brand-primary)] rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle2 className="w-8 h-8 text-white" />
</div>
<h4 className="text-xl font-semibold text-[#1C1C1C] mb-2"></h4>
<p className="text-[#595959]"></p>
<h4 className="text-xl font-semibold text-[var(--color-text-primary)] mb-2"></h4>
<p className="text-[var(--color-text-muted)]"></p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-5">
<input type="text" name="website" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" />
<input type="text" name="website" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" aria-hidden="true" />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
name="name"
@@ -342,7 +341,7 @@ function ContactFormContent() {
type="submit"
data-testid="submit-button"
size="lg"
className="w-full bg-[#C41E3A] hover:bg-[#A01830] text-white min-h-13 md:min-h-0"
className="w-full bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] text-white min-h-13 md:min-h-0"
disabled={isSubmitting}
>
{isSubmitting ? (
@@ -371,8 +370,8 @@ function ContactFormContent() {
export default function ContactPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="animate-pulse text-[#595959]">...</div>
<div className="min-h-screen bg-[var(--color-bg-primary)] flex items-center justify-center">
<div className="animate-pulse text-[var(--color-text-muted)]">...</div>
</div>
}>
<ContactFormContent />
+2 -2
View File
@@ -26,11 +26,11 @@ const CTASection = dynamic(
function HomeContentV2() {
return (
<main id="main-content" className="min-h-screen bg-white">
<main id="main-content" className="min-h-screen bg-[var(--color-bg-primary)]">
<HeroSectionV2 />
<SocialProofSection />
<ProductMatrixSection />
<ChallengeSection />
<SocialProofSection />
<CTASection />
</main>
);
+9
View File
@@ -0,0 +1,9 @@
export default function Loading() {
return (
<div className="min-h-[60vh] flex items-center justify-center">
<div className="page-transition-loader">
<div className="page-transition-loader__bar" />
</div>
</div>
);
}
@@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge';
import { PageNav } from '@/components/layout/page-nav';
import { Calendar, ArrowLeft, Newspaper } from 'lucide-react';
import { motion } from 'framer-motion';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { NEWS } from '@/lib/constants';
interface NewsDetailClientProps {
@@ -13,28 +14,30 @@ interface NewsDetailClientProps {
}
export function NewsDetailClient({ news }: NewsDetailClientProps) {
const shouldReduceMotion = useReducedMotion();
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
const relatedNews = NEWS
.filter((n) => n.id !== news.id && n.category === news.category)
.slice(0, 3);
return (
<div className="min-h-screen bg-white">
<div className="min-h-screen bg-[var(--color-bg-primary)]">
<section className="pt-32 pb-12">
<div className="container-wide">
<PageNav items={[{ label: '新闻动态', href: '/news' }, { label: news.title }]} />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
{...fadeUp}
animate={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-4xl mt-6"
>
<Badge className="mb-4 bg-[#C41E3A]/10 text-[#C41E3A] hover:bg-[#C41E3A]/20 border-0">
<Badge className="mb-4 bg-[var(--color-brand-primary-bg)] text-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary)]/20 border-0">
{news.category}
</Badge>
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-bold text-[#1C1C1C] mb-4 tracking-tight">
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4 tracking-tight">
{news.title}
</h1>
<div className="flex items-center gap-4 text-[#595959] text-sm">
<div className="flex items-center gap-4 text-[var(--color-text-muted)] text-sm">
<div className="flex items-center gap-1.5">
<Calendar className="w-4 h-4" />
{news.date}
@@ -47,8 +50,8 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
<section className="pb-16">
<div className="container-wide">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
{...fadeUp}
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="max-w-4xl"
@@ -62,24 +65,24 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
/>
</div>
) : (
<div className="aspect-video bg-gradient-to-br from-[#FEF2F4] to-[#F5F5F5] rounded-xl mb-8 flex items-center justify-center">
<Newspaper className="w-16 h-16 text-[#C41E3A]/30" />
<div className="aspect-video bg-gradient-to-br from-[var(--color-brand-primary-bg)] to-[var(--color-bg-tertiary)] rounded-xl mb-8 flex items-center justify-center">
<Newspaper className="w-16 h-16 text-[var(--color-brand-primary)]/30" />
</div>
)}
<div className="border-l-4 border-[#C41E3A] pl-6 mb-8">
<p className="text-lg text-[#595959] leading-relaxed">
<div className="border-l-4 border-[var(--color-brand-primary)] pl-6 mb-8">
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed">
{news.excerpt}
</p>
</div>
<div className="text-[#1C1C1C] leading-relaxed whitespace-pre-line">
<div className="text-[var(--color-text-primary)] leading-relaxed whitespace-pre-line">
{news.content}
</div>
{relatedNews.length > 0 && (
<div className="mt-16 pt-12 border-t border-[#E5E5E5]">
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-8"></h2>
<div className="mt-16 pt-12 border-t border-[var(--color-border)]">
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-8"></h2>
<div className="grid md:grid-cols-3 gap-6">
{relatedNews.map((related) => (
<StaticLink key={related.id} href={`/news/${related.id}`}>
@@ -92,16 +95,16 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-[#FEF2F4] to-[#F5F5F5] flex items-center justify-center">
<Newspaper className="w-8 h-8 text-[#C41E3A]/30" />
<div className="w-full h-full bg-gradient-to-br from-[var(--color-brand-primary-bg)] to-[var(--color-bg-tertiary)] flex items-center justify-center">
<Newspaper className="w-8 h-8 text-[var(--color-brand-primary)]/30" />
</div>
)}
</div>
<Badge variant="secondary" className="mb-2 text-xs">{related.category}</Badge>
<h3 className="text-base font-semibold text-[#1C1C1C] mb-1 line-clamp-2 group-hover:text-[#C41E3A] transition-colors">
<h3 className="text-base font-semibold text-[var(--color-text-primary)] mb-1 line-clamp-2 group-hover:text-[var(--color-brand-primary)] transition-colors">
{related.title}
</h3>
<p className="text-sm text-[#595959] line-clamp-2">{related.excerpt}</p>
<p className="text-sm text-[var(--color-text-muted)] line-clamp-2">{related.excerpt}</p>
</div>
</StaticLink>
))}
@@ -117,7 +120,7 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
</Button>
</StaticLink>
<StaticLink href="/contact">
<Button size="lg" className="bg-[#C41E3A] hover:bg-[#A01830] text-white">
<Button size="lg" className="bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] text-white">
</Button>
</StaticLink>
+26 -23
View File
@@ -2,6 +2,7 @@
import { useState, useMemo, ChangeEvent } from 'react';
import { motion } from 'framer-motion';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { NEWS, COMPANY_INFO } from '@/lib/constants';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
@@ -14,6 +15,8 @@ const categories = ['全部', '公司新闻', '研发动态'];
const ITEMS_PER_PAGE = 9;
export default function NewsListPage() {
const shouldReduceMotion = useReducedMotion();
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
const [selectedCategory, setSelectedCategory] = useState('全部');
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
@@ -50,21 +53,21 @@ export default function NewsListPage() {
};
return (
<div className="min-h-screen bg-white">
<div className="min-h-screen bg-[var(--color-bg-primary)]">
<section className="pt-32 pb-16">
<div className="container-wide">
<PageNav items={[{ label: '新闻动态' }]} />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
{...fadeUp}
animate={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-3xl"
>
<p className="text-sm font-medium text-[#C41E3A] mb-4 tracking-wide uppercase">News</p>
<h1 className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6 tracking-tight">
<p className="text-sm font-medium text-[var(--color-brand-primary)] mb-4 tracking-wide uppercase">News</p>
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-text-primary)] mb-6 tracking-tight">
</h1>
<p className="text-lg text-[#595959] leading-relaxed">
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed">
{COMPANY_INFO.displayName}
</p>
</motion.div>
@@ -74,8 +77,8 @@ export default function NewsListPage() {
<section className="pb-16">
<div className="container-wide">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
{...fadeUp}
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="mb-8 space-y-4"
@@ -88,7 +91,7 @@ export default function NewsListPage() {
onClick={() => handleCategoryChange(category)}
className={
selectedCategory === category
? 'bg-[#C41E3A] hover:bg-[#A01830] text-white'
? 'bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] text-white'
: ''
}
>
@@ -98,7 +101,7 @@ export default function NewsListPage() {
</div>
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[#595959] w-5 h-5" />
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[var(--color-text-muted)] w-5 h-5" />
<Input
type="text"
placeholder="搜索新闻..."
@@ -112,8 +115,8 @@ export default function NewsListPage() {
{paginatedNews.length === 0 ? (
<div className="text-center py-20">
<Newspaper className="w-12 h-12 text-[#E5E5E5] mx-auto mb-4" />
<p className="text-lg text-[#595959]"></p>
<Newspaper className="w-12 h-12 text-[var(--color-border)] mx-auto mb-4" />
<p className="text-lg text-[var(--color-text-muted)]"></p>
</div>
) : (
<>
@@ -121,15 +124,15 @@ export default function NewsListPage() {
{paginatedNews.map((newsItem, index) => (
<motion.div
key={newsItem.id}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
{...fadeUp}
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
<StaticLink href={`/news/${newsItem.id}`}>
<div className="group h-full bg-white rounded-xl border border-[#E5E5E5] overflow-hidden hover:border-[#C41E3A]/30 hover:shadow-md transition-all duration-300">
<div className="group h-full bg-[var(--color-bg-primary)] rounded-xl border border-[var(--color-border)] overflow-hidden hover:border-[var(--color-brand-primary)]/30 hover:shadow-md transition-all duration-300">
{newsItem.image ? (
<div className="aspect-video bg-[#F5F5F5] overflow-hidden">
<div className="aspect-video bg-[var(--color-bg-tertiary)] overflow-hidden">
<img
src={newsItem.image}
alt={newsItem.title}
@@ -137,25 +140,25 @@ export default function NewsListPage() {
/>
</div>
) : (
<div className="aspect-video bg-gradient-to-br from-[#FEF2F4] to-[#F5F5F5] flex items-center justify-center">
<Newspaper className="w-12 h-12 text-[#C41E3A]/30" />
<div className="aspect-video bg-gradient-to-br from-[var(--color-brand-primary-bg)] to-[var(--color-bg-tertiary)] flex items-center justify-center">
<Newspaper className="w-12 h-12 text-[var(--color-brand-primary)]/30" />
</div>
)}
<div className="p-6">
<div className="flex items-center gap-2 mb-3">
<Badge variant="secondary" className="text-xs">{newsItem.category}</Badge>
<div className="flex items-center gap-1 text-xs text-[#595959]">
<div className="flex items-center gap-1 text-xs text-[var(--color-text-muted)]">
<Calendar className="w-3 h-3" />
{newsItem.date}
</div>
</div>
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-2 line-clamp-2 group-hover:text-[#C41E3A] transition-colors">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-2 line-clamp-2 group-hover:text-[var(--color-brand-primary)] transition-colors">
{newsItem.title}
</h3>
<p className="text-sm text-[#595959] line-clamp-3 mb-4">
<p className="text-sm text-[var(--color-text-muted)] line-clamp-3 mb-4">
{newsItem.excerpt}
</p>
<div className="flex items-center text-[#C41E3A] text-sm font-medium">
<div className="flex items-center text-[var(--color-brand-primary)] text-sm font-medium">
<ArrowRight className="ml-1 w-4 h-4 group-hover:translate-x-1 transition-transform" />
</div>
@@ -184,7 +187,7 @@ export default function NewsListPage() {
onClick={() => handlePageChange(page)}
className={
currentPage === page
? 'bg-[#C41E3A] hover:bg-[#A01830] text-white'
? 'bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] text-white'
: ''
}
>
+38 -38
View File
@@ -34,28 +34,28 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
}
return (
<div className="min-h-screen bg-white">
<div className="min-h-screen bg-[var(--color-bg-primary)]">
<div className="pt-32 pb-16">
<div className="container-wide">
<PageNav items={[{ label: '产品', href: '/products' }, { label: product.title }]} />
<div className="max-w-4xl mt-8">
<div className="flex items-center gap-3 mb-4">
<p className="text-sm font-medium text-[#C41E3A] tracking-wide uppercase">{product.category}</p>
<p className="text-sm font-medium text-[var(--color-brand-primary)] tracking-wide uppercase">{product.category}</p>
<span
className="text-xs font-medium px-3 py-1 rounded-full border"
style={{
backgroundColor: 'rgba(196, 30, 58, 0.08)',
color: '#C41E3A',
borderColor: 'rgba(196, 30, 58, 0.15)',
backgroundColor: 'rgba(var(--color-brand-primary-rgb), 0.08)',
color: 'var(--color-brand-primary)',
borderColor: 'rgba(var(--color-brand-primary-rgb), 0.15)',
}}
>
{product.status}
</span>
</div>
<h1 className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6 tracking-tight">
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-text-primary)] mb-6 tracking-tight">
{product.title}
</h1>
<p className="text-xl text-[#595959] leading-relaxed">
<p className="text-xl text-[var(--color-text-muted)] leading-relaxed">
{product.description}
</p>
</div>
@@ -64,92 +64,92 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
<div className="container-wide pb-20">
<div className="max-w-4xl space-y-16">
<section>
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6"></h2>
<p className="text-lg text-[#595959] leading-relaxed">
<section aria-labelledby="product-overview">
<h2 id="product-overview" className="text-2xl font-bold text-[var(--color-text-primary)] mb-6"></h2>
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed">
{product.overview}
</p>
</section>
<section>
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6 flex items-center gap-3">
<Zap className="w-6 h-6 text-[#C41E3A]" />
<section aria-labelledby="product-features">
<h2 id="product-features" className="text-2xl font-bold text-[var(--color-text-primary)] mb-6 flex items-center gap-3">
<Zap className="w-6 h-6 text-[var(--color-brand-primary)]" />
</h2>
<div className="grid md:grid-cols-2 gap-4">
{product.features.map((feature, index) => (
<div
key={index}
className="flex items-start gap-3 p-4 bg-[#F5F5F5] rounded-lg hover:bg-[#FEF2F4] transition-colors"
className="flex items-start gap-3 p-4 bg-[var(--color-bg-tertiary)] rounded-lg hover:bg-[var(--color-brand-primary-bg)] transition-colors"
>
<CheckCircle2 className="w-5 h-5 text-[#C41E3A] mt-0.5 shrink-0" />
<span className="text-[#1C1C1C] text-sm">{feature}</span>
<CheckCircle2 className="w-5 h-5 text-[var(--color-brand-primary)] mt-0.5 shrink-0" />
<span className="text-[var(--color-text-primary)] text-sm">{feature}</span>
</div>
))}
</div>
</section>
<section>
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6 flex items-center gap-3">
<Target className="w-6 h-6 text-[#C41E3A]" />
<section aria-labelledby="product-benefits">
<h2 id="product-benefits" className="text-2xl font-bold text-[var(--color-text-primary)] mb-6 flex items-center gap-3">
<Target className="w-6 h-6 text-[var(--color-brand-primary)]" />
</h2>
<div className="space-y-3">
{product.benefits.map((benefit, index) => (
<div
key={index}
className="flex items-start gap-3 p-4 rounded-lg border-l-4 border-[#C41E3A] bg-[#FAFAFA]"
className="flex items-start gap-3 p-4 rounded-lg border-l-4 border-[var(--color-brand-primary)] bg-[var(--color-bg-section)]"
>
<span className="text-[#1C1C1C] font-medium text-sm">{benefit}</span>
<span className="text-[var(--color-text-primary)] font-medium text-sm">{benefit}</span>
</div>
))}
</div>
</section>
<section>
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6 flex items-center gap-3">
<Layers className="w-6 h-6 text-[#C41E3A]" />
<section aria-labelledby="product-process">
<h2 id="product-process" className="text-2xl font-bold text-[var(--color-text-primary)] mb-6 flex items-center gap-3">
<Layers className="w-6 h-6 text-[var(--color-brand-primary)]" />
</h2>
<div className="space-y-4">
{product.process.map((step, index) => (
<div key={index} className="flex items-start gap-4">
<div className="w-8 h-8 bg-[#C41E3A] rounded-full flex items-center justify-center shrink-0 text-white text-sm font-bold">
<div className="w-8 h-8 bg-[var(--color-brand-primary)] rounded-full flex items-center justify-center shrink-0 text-white text-sm font-bold">
{index + 1}
</div>
<p className="text-[#1C1C1C] pt-1">{step}</p>
<p className="text-[var(--color-text-primary)] pt-1">{step}</p>
</div>
))}
</div>
</section>
<section>
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6"></h2>
<section aria-labelledby="product-specs">
<h2 id="product-specs" className="text-2xl font-bold text-[var(--color-text-primary)] mb-6"></h2>
<div className="grid md:grid-cols-2 gap-3">
{product.specs.map((spec, index) => (
<div
key={index}
className="flex items-center gap-3 p-3 bg-[#F5F5F5] rounded-lg"
className="flex items-center gap-3 p-3 bg-[var(--color-bg-tertiary)] rounded-lg"
>
<div className="w-1.5 h-1.5 bg-[#C41E3A] rounded-full" />
<span className="text-[#1C1C1C] text-sm">{spec}</span>
<div className="w-1.5 h-1.5 bg-[var(--color-brand-primary)] rounded-full" />
<span className="text-[var(--color-text-primary)] text-sm">{spec}</span>
</div>
))}
</div>
</section>
<section className="bg-[#FEF2F4] rounded-2xl p-8 md:p-10">
<section className="bg-[var(--color-brand-primary-bg)] rounded-2xl p-8 md:p-10" aria-labelledby="product-pricing">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-[#C41E3A] rounded-xl flex items-center justify-center shrink-0">
<div className="w-12 h-12 bg-[var(--color-brand-primary)] rounded-xl flex items-center justify-center shrink-0">
<FlaskConical className="w-6 h-6 text-white" />
</div>
<div>
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-3"></h2>
<p className="text-[#595959] leading-relaxed mb-6">
<h2 id="product-pricing" className="text-2xl font-bold text-[var(--color-text-primary)] mb-3"></h2>
<p className="text-[var(--color-text-muted)] leading-relaxed mb-6">
</p>
<div className="flex flex-wrap gap-3">
<Button size="lg" className="bg-[#C41E3A] hover:bg-[#A01830] text-white" asChild>
<Button size="lg" className="bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] text-white" asChild>
<StaticLink href="/contact">
<ArrowRight className="ml-2 w-4 h-4" />
@@ -163,11 +163,11 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
</div>
</section>
<div className="flex justify-center gap-4 pt-8 border-t border-[#E5E5E5]">
<div className="flex justify-center gap-4 pt-8 border-t border-[var(--color-border-primary)]">
<Button variant="outline" size="lg" asChild>
<StaticLink href="/contact"></StaticLink>
</Button>
<Button size="lg" className="bg-[#C41E3A] hover:bg-[#A01830] text-white" asChild>
<Button size="lg" className="bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] text-white" asChild>
<StaticLink href="/contact">
<ArrowRight className="ml-2 w-4 h-4" />
+21 -30
View File
@@ -2,29 +2,32 @@
import { PRODUCTS } from '@/lib/constants';
import { ProductCard } from '@/components/ui/product-card';
import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button';
import { ArrowRight } from 'lucide-react';
import { motion } from 'framer-motion';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { PageNav } from '@/components/layout/page-nav';
import { CTASection } from '@/components/sections/cta-section';
import { BreadcrumbSchema } from '@/components/seo/structured-data';
export default function ProductsPage() {
const shouldReduceMotion = useReducedMotion();
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
return (
<div className="min-h-screen bg-white">
<div className="min-h-screen bg-[var(--color-bg-primary)]">
<BreadcrumbSchema items={[{ name: '首页', href: '/' }, { name: '产品', href: '/products' }]} />
<section className="pt-32 pb-16">
<div className="container-wide">
<PageNav items={[{ label: '产品' }]} />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
{...fadeUp}
animate={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-3xl"
>
<p className="text-sm font-medium text-[#C41E3A] mb-4 tracking-wide uppercase">Products</p>
<h1 className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6 tracking-tight">
<p className="text-sm font-medium text-[var(--color-brand-primary)] mb-4 tracking-wide uppercase">Products</p>
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-text-primary)] mb-6 tracking-tight">
</h1>
<p className="text-lg text-[#595959] leading-relaxed">
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed">
</p>
</motion.div>
@@ -33,7 +36,7 @@ export default function ProductsPage() {
<section className="pb-20">
<div className="container-wide">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{PRODUCTS.map((product, index) => (
<ProductCard
key={product.id}
@@ -48,26 +51,14 @@ export default function ProductsPage() {
</div>
</section>
<section className="bg-[#F5F5F5] py-20">
<div className="container-wide text-center">
<h2 className="text-3xl font-bold text-[#1C1C1C] mb-6">
</h2>
<p className="text-lg text-[#595959] mb-8 max-w-2xl mx-auto">
</p>
<Button
size="lg"
className="bg-[#C41E3A] hover:bg-[#A01830] text-white"
asChild
>
<StaticLink href="/contact">
<ArrowRight className="ml-2 w-4 h-4" />
</StaticLink>
</Button>
</div>
</section>
<CTASection
title="期待与您共同打磨产品"
description="我们的产品正在研发中,如果您对产品方向有建议或希望参与早期体验,欢迎联系我们"
primaryLabel="预约体验"
primaryHref="/contact"
secondaryLabel="了解方案"
secondaryHref="/solutions"
/>
</div>
);
}
+31 -31
View File
@@ -72,16 +72,16 @@ export function ServiceDetailClient({ service }: ServiceDetailClientProps) {
const serviceOutcomes = outcomes[service.id] ?? [];
return (
<div className="min-h-screen bg-white">
<div className="min-h-screen bg-[var(--color-bg-primary)]">
<div className="pt-32 pb-16">
<div className="container-wide">
<PageNav items={[{ label: '服务', href: '/services' }, { label: service.title }]} />
<div className="max-w-4xl mt-8">
<p className="text-sm font-medium text-[#C41E3A] mb-4 tracking-wide uppercase">Services</p>
<h1 className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6 tracking-tight">
<p className="text-sm font-medium text-[var(--color-brand-primary)] mb-4 tracking-wide uppercase">Services</p>
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-text-primary)] mb-6 tracking-tight">
{service.title}
</h1>
<p className="text-xl text-[#595959] leading-relaxed">
<p className="text-xl text-[var(--color-text-muted)] leading-relaxed">
{service.description}
</p>
</div>
@@ -92,19 +92,19 @@ export function ServiceDetailClient({ service }: ServiceDetailClientProps) {
<div className="max-w-4xl space-y-16">
<section>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-[#FEF2F4] rounded-lg flex items-center justify-center">
<MessageCircle className="w-5 h-5 text-[#C41E3A]" />
<div className="w-10 h-10 bg-[var(--color-brand-primary-bg)] rounded-lg flex items-center justify-center">
<MessageCircle className="w-5 h-5 text-[var(--color-brand-primary)]" />
</div>
<h2 className="text-2xl font-bold text-[#1C1C1C]"></h2>
<h2 className="text-2xl font-bold text-[var(--color-text-primary)]"></h2>
</div>
<div className="grid md:grid-cols-2 gap-4">
{serviceChallenges.map((challenge, index) => (
<div
key={index}
className="p-4 bg-[#F5F5F5] rounded-lg hover:bg-[#FEF2F4] transition-colors"
className="p-4 bg-[var(--color-bg-tertiary)] rounded-lg hover:bg-[var(--color-brand-primary-bg)] transition-colors"
>
<h3 className="font-semibold text-[#1C1C1C] mb-1 text-sm">{challenge.title}</h3>
<p className="text-sm text-[#595959]">{challenge.description}</p>
<h3 className="font-semibold text-[var(--color-text-primary)] mb-1 text-sm">{challenge.title}</h3>
<p className="text-sm text-[var(--color-text-muted)]">{challenge.description}</p>
</div>
))}
</div>
@@ -112,19 +112,19 @@ export function ServiceDetailClient({ service }: ServiceDetailClientProps) {
<section>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-[#FEF2F4] rounded-lg flex items-center justify-center">
<Target className="w-5 h-5 text-[#C41E3A]" />
<div className="w-10 h-10 bg-[var(--color-brand-primary-bg)] rounded-lg flex items-center justify-center">
<Target className="w-5 h-5 text-[var(--color-brand-primary)]" />
</div>
<h2 className="text-2xl font-bold text-[#1C1C1C]"></h2>
<h2 className="text-2xl font-bold text-[var(--color-text-primary)]"></h2>
</div>
<p className="text-lg text-[#595959] leading-relaxed mb-6">
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed mb-6">
{service.overview}
</p>
<div className="space-y-3">
{service.features.map((feature, index) => (
<div key={index} className="flex items-start gap-3">
<CheckCircle2 className="w-5 h-5 text-[#C41E3A] mt-0.5 shrink-0" />
<span className="text-[#1C1C1C] text-sm">{feature}</span>
<CheckCircle2 className="w-5 h-5 text-[var(--color-brand-primary)] mt-0.5 shrink-0" />
<span className="text-[var(--color-text-primary)] text-sm">{feature}</span>
</div>
))}
</div>
@@ -132,18 +132,18 @@ export function ServiceDetailClient({ service }: ServiceDetailClientProps) {
<section>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-[#FEF2F4] rounded-lg flex items-center justify-center">
<Clock className="w-5 h-5 text-[#C41E3A]" />
<div className="w-10 h-10 bg-[var(--color-brand-primary-bg)] rounded-lg flex items-center justify-center">
<Clock className="w-5 h-5 text-[var(--color-brand-primary)]" />
</div>
<h2 className="text-2xl font-bold text-[#1C1C1C]"></h2>
<h2 className="text-2xl font-bold text-[var(--color-text-primary)]"></h2>
</div>
<div className="space-y-4">
{service.process.map((step, index) => (
<div key={index} className="flex items-start gap-4">
<div className="w-8 h-8 bg-[#C41E3A] rounded-full flex items-center justify-center shrink-0 text-white text-sm font-bold">
<div className="w-8 h-8 bg-[var(--color-brand-primary)] rounded-full flex items-center justify-center shrink-0 text-white text-sm font-bold">
{index + 1}
</div>
<p className="text-[#1C1C1C] pt-1">{step}</p>
<p className="text-[var(--color-text-primary)] pt-1">{step}</p>
</div>
))}
</div>
@@ -151,37 +151,37 @@ export function ServiceDetailClient({ service }: ServiceDetailClientProps) {
<section>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-[#FEF2F4] rounded-lg flex items-center justify-center">
<TrendingUp className="w-5 h-5 text-[#C41E3A]" />
<div className="w-10 h-10 bg-[var(--color-brand-primary-bg)] rounded-lg flex items-center justify-center">
<TrendingUp className="w-5 h-5 text-[var(--color-brand-primary)]" />
</div>
<h2 className="text-2xl font-bold text-[#1C1C1C]"></h2>
<h2 className="text-2xl font-bold text-[var(--color-text-primary)]"></h2>
</div>
<div className="grid sm:grid-cols-3 gap-4">
{serviceOutcomes.map((outcome, index) => (
<div
key={index}
className="p-6 bg-[#F5F5F5] rounded-lg text-center hover:bg-[#FEF2F4] transition-colors"
className="p-6 bg-[var(--color-bg-tertiary)] rounded-lg text-center hover:bg-[var(--color-brand-primary-bg)] transition-colors"
>
<div className="text-3xl font-bold text-[#C41E3A] mb-2">
<div className="text-3xl font-bold text-[var(--color-brand-primary)] mb-2">
{outcome.value}
</div>
<div className="text-sm text-[#595959]">{outcome.label}</div>
<div className="text-sm text-[var(--color-text-muted)]">{outcome.label}</div>
</div>
))}
</div>
<div className="mt-4 p-4 bg-[#F5F5F5] rounded-lg">
<p className="text-sm text-[#595959]">
<div className="mt-4 p-4 bg-[var(--color-bg-tertiary)] rounded-lg">
<p className="text-sm text-[var(--color-text-muted)]">
{service.benefits.join('')}
</p>
</div>
</section>
<div className="flex justify-center gap-4 pt-8 border-t border-[#E5E5E5]">
<div className="flex justify-center gap-4 pt-8 border-t border-[var(--color-border-primary)]">
<StaticLink href="/services">
<Button variant="outline" size="lg"></Button>
</StaticLink>
<StaticLink href="/contact">
<Button size="lg" className="bg-[#C41E3A] hover:bg-[#A01830] text-white">
<Button size="lg" className="bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] text-white">
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
+21 -18
View File
@@ -5,25 +5,28 @@ import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button';
import { ArrowRight, ArrowUpRight } from 'lucide-react';
import { motion } from 'framer-motion';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { PageNav } from '@/components/layout/page-nav';
export default function ServicesPage() {
const shouldReduceMotion = useReducedMotion();
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
return (
<div className="min-h-screen bg-white">
<div className="min-h-screen bg-[var(--color-bg-primary)]">
<section className="pt-32 pb-16">
<div className="container-wide">
<PageNav items={[{ label: '服务' }]} />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
{...fadeUp}
animate={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-3xl"
>
<p className="text-sm font-medium text-[#C41E3A] mb-4 tracking-wide uppercase">Services</p>
<h1 className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6 tracking-tight">
<p className="text-sm font-medium text-[var(--color-brand-primary)] mb-4 tracking-wide uppercase">Services</p>
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-text-primary)] mb-6 tracking-tight">
</h1>
<p className="text-lg text-[#595959] leading-relaxed">
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed">
</p>
</motion.div>
@@ -36,32 +39,32 @@ export default function ServicesPage() {
{SERVICES.map((service, index) => (
<motion.div
key={service.id}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
{...fadeUp}
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: index * 0.1, ease: [0.16, 1, 0.3, 1] }}
>
<StaticLink
href={`/services/${service.id}`}
className="group block p-6 rounded-xl border border-[#E5E5E5] bg-white hover:border-[#C41E3A]/40 hover:shadow-lg transition-all duration-300 h-full"
className="group block p-6 rounded-xl border border-[var(--color-border-primary)] bg-[var(--color-bg-primary)] hover:border-[var(--color-brand-primary)]/40 hover:shadow-lg transition-all duration-300 h-full"
>
<div className="flex items-start justify-between mb-4">
<span className="inline-flex items-center justify-center w-10 h-10 rounded-lg bg-[#FEF2F4] text-[#C41E3A] text-sm font-bold">
<span className="inline-flex items-center justify-center w-10 h-10 rounded-lg bg-[var(--color-brand-primary-bg)] text-[var(--color-brand-primary)] text-sm font-bold">
{String(index + 1).padStart(2, '0')}
</span>
<ArrowUpRight className="w-5 h-5 text-[#595959] group-hover:text-[#C41E3A] transition-colors" />
<ArrowUpRight className="w-5 h-5 text-[var(--color-text-muted)] group-hover:text-[var(--color-brand-primary)] transition-colors" />
</div>
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-2 group-hover:text-[#C41E3A] transition-colors">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-2 group-hover:text-[var(--color-brand-primary)] transition-colors">
{service.title}
</h3>
<p className="text-sm text-[#595959] leading-relaxed mb-4">
<p className="text-sm text-[var(--color-text-muted)] leading-relaxed mb-4">
{service.description}
</p>
<div className="flex flex-wrap gap-1.5">
{service.features.slice(0, 3).map((feature, idx) => (
<span
key={idx}
className="inline-flex items-center text-xs px-2 py-1 bg-[#F5F5F5] text-[#595959] rounded"
className="inline-flex items-center text-xs px-2 py-1 bg-[var(--color-bg-tertiary)] text-[var(--color-text-muted)] rounded"
>
{feature.split('')[0]}
</span>
@@ -74,17 +77,17 @@ export default function ServicesPage() {
</div>
</section>
<section className="bg-[#F5F5F5] py-20">
<section className="bg-[var(--color-bg-tertiary)] py-20">
<div className="container-wide text-center">
<h2 className="text-3xl font-bold text-[#1C1C1C] mb-6">
<h2 className="text-3xl font-bold text-[var(--color-text-primary)] mb-6">
</h2>
<p className="text-lg text-[#595959] mb-8 max-w-2xl mx-auto">
<p className="text-lg text-[var(--color-text-muted)] mb-8 max-w-2xl mx-auto">
</p>
<Button
size="lg"
className="bg-[#C41E3A] hover:bg-[#A01830] text-white"
className="bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] text-white"
asChild
>
<StaticLink href="/contact">
+34 -31
View File
@@ -1,6 +1,7 @@
'use client';
import { motion } from 'framer-motion';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button';
import { PageNav } from '@/components/layout/page-nav';
@@ -14,25 +15,27 @@ interface SolutionDetailClientProps {
}
export function SolutionDetailClient({ solution, relatedProducts }: SolutionDetailClientProps) {
const shouldReduceMotion = useReducedMotion();
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
return (
<div className="min-h-screen bg-white">
<div className="min-h-screen bg-[var(--color-bg-primary)]">
<div className="pt-32 pb-16">
<div className="container-wide">
<PageNav items={[{ label: '解决方案', href: '/solutions' }, { label: solution.title }]} />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
{...fadeUp}
animate={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-4xl mt-8"
>
<p className="text-sm font-medium text-[#C41E3A] mb-4 tracking-wide uppercase">
<p className="text-sm font-medium text-[var(--color-brand-primary)] mb-4 tracking-wide uppercase">
{solution.industry}
</p>
<h1 className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-4 tracking-tight">
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-text-primary)] mb-4 tracking-tight">
{solution.title}
</h1>
<p className="text-xl text-[#595959] mb-2">{solution.subtitle}</p>
<p className="text-lg text-[#595959] leading-relaxed">
<p className="text-xl text-[var(--color-text-muted)] mb-2">{solution.subtitle}</p>
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed">
{solution.description}
</p>
</motion.div>
@@ -42,44 +45,44 @@ export function SolutionDetailClient({ solution, relatedProducts }: SolutionDeta
<div className="container-wide pb-20">
<div className="max-w-4xl space-y-16">
<motion.section
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
{...fadeUp}
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6 flex items-center gap-3">
<AlertTriangle className="w-6 h-6 text-[#C41E3A]" />
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6 flex items-center gap-3">
<AlertTriangle className="w-6 h-6 text-[var(--color-brand-primary)]" />
</h2>
<div className="space-y-3">
{solution.challenges.map((challenge, index) => (
<div
key={index}
className="flex items-start gap-3 p-4 rounded-lg border-l-4 border-[#C41E3A] bg-[#FAFAFA]"
className="flex items-start gap-3 p-4 rounded-lg border-l-4 border-[var(--color-brand-primary)] bg-[var(--color-bg-section)]"
>
<span className="text-[#1C1C1C] text-sm">{challenge}</span>
<span className="text-[var(--color-text-primary)] text-sm">{challenge}</span>
</div>
))}
</div>
</motion.section>
<motion.section
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
{...fadeUp}
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.1 }}
>
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6 flex items-center gap-3">
<CheckCircle2 className="w-6 h-6 text-[#C41E3A]" />
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6 flex items-center gap-3">
<CheckCircle2 className="w-6 h-6 text-[var(--color-brand-primary)]" />
</h2>
<div className="space-y-4">
{solution.solutions.map((sol, index) => (
<div key={index} className="flex items-start gap-4">
<div className="w-8 h-8 bg-[#C41E3A] rounded-full flex items-center justify-center shrink-0 text-white text-sm font-bold">
<div className="w-8 h-8 bg-[var(--color-brand-primary)] rounded-full flex items-center justify-center shrink-0 text-white text-sm font-bold">
{index + 1}
</div>
<p className="text-[#1C1C1C] pt-1 leading-relaxed">{sol}</p>
<p className="text-[var(--color-text-primary)] pt-1 leading-relaxed">{sol}</p>
</div>
))}
</div>
@@ -87,13 +90,13 @@ export function SolutionDetailClient({ solution, relatedProducts }: SolutionDeta
{relatedProducts.length > 0 && (
<motion.section
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
{...fadeUp}
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6 flex items-center gap-3">
<Package className="w-6 h-6 text-[#C41E3A]" />
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6 flex items-center gap-3">
<Package className="w-6 h-6 text-[var(--color-brand-primary)]" />
</h2>
<div className="grid md:grid-cols-2 gap-4">
@@ -101,26 +104,26 @@ export function SolutionDetailClient({ solution, relatedProducts }: SolutionDeta
<StaticLink
key={product.id}
href={`/products/${product.id}`}
className="group block p-6 rounded-xl border border-[#E5E5E5] bg-white hover:border-[#C41E3A]/40 hover:shadow-lg transition-all duration-300"
className="group block p-6 rounded-xl border border-[var(--color-border-primary)] bg-[var(--color-bg-primary)] hover:border-[var(--color-brand-primary)]/40 hover:shadow-lg transition-all duration-300"
>
<div className="flex items-center gap-2 mb-2">
<h3 className="text-lg font-semibold text-[#1C1C1C] group-hover:text-[#C41E3A] transition-colors">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] group-hover:text-[var(--color-brand-primary)] transition-colors">
{product.title}
</h3>
{product.status && (
<span
className="text-xs font-medium px-3 py-1 rounded-full border shrink-0"
style={{
backgroundColor: product.status === '研发中' ? 'rgba(196, 30, 58, 0.08)' : product.status === '内测中' ? 'rgba(217, 119, 6, 0.08)' : 'rgba(22, 163, 74, 0.08)',
color: product.status === '研发中' ? '#C41E3A' : product.status === '内测中' ? '#D97706' : '#16A34A',
borderColor: product.status === '研发中' ? 'rgba(196, 30, 58, 0.15)' : product.status === '内测中' ? 'rgba(217, 119, 6, 0.15)' : 'rgba(22, 163, 74, 0.15)',
backgroundColor: product.status === '研发中' ? 'rgba(var(--color-brand-primary-rgb), 0.08)' : product.status === '内测中' ? 'rgba(var(--color-warning-rgb), 0.08)' : 'rgba(var(--color-success-rgb), 0.08)',
color: product.status === '研发中' ? 'var(--color-brand-primary)' : product.status === '内测中' ? 'var(--color-warning)' : 'var(--color-success)',
borderColor: product.status === '研发中' ? 'rgba(var(--color-brand-primary-rgb), 0.15)' : product.status === '内测中' ? 'rgba(var(--color-warning-rgb), 0.15)' : 'rgba(var(--color-success-rgb), 0.15)',
}}
>
{product.status}
</span>
)}
</div>
<p className="text-sm text-[#595959] leading-relaxed">
<p className="text-sm text-[var(--color-text-muted)] leading-relaxed">
{product.description}
</p>
</StaticLink>
@@ -129,11 +132,11 @@ export function SolutionDetailClient({ solution, relatedProducts }: SolutionDeta
</motion.section>
)}
<div className="flex justify-center gap-4 pt-8 border-t border-[#E5E5E5]">
<div className="flex justify-center gap-4 pt-8 border-t border-[var(--color-border-primary)]">
<Button variant="outline" size="lg" asChild>
<StaticLink href="/contact"></StaticLink>
</Button>
<Button size="lg" className="bg-[#C41E3A] hover:bg-[#A01830] text-white" asChild>
<Button size="lg" className="bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] text-white" asChild>
<StaticLink href="/contact">
<ArrowRight className="ml-2 w-4 h-4" />
+60 -54
View File
@@ -1,11 +1,13 @@
'use client';
import { motion } from 'framer-motion';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button';
import { ArrowRight, Lightbulb, Cpu, Users, CheckCircle2 } from 'lucide-react';
import { MethodologySection } from '@/components/sections/methodology-section';
import { CTASection } from '@/components/sections/cta-section';
import { PageNav } from '@/components/layout/page-nav';
import { BreadcrumbSchema } from '@/components/seo/structured-data';
const modules = [
{
@@ -19,8 +21,10 @@ const modules = [
],
values: ['行业趋势洞察报告', '数字化转型成熟度评估', '个性化实施路径规划'],
cta: '预约一次免费诊断',
ctaVariant: 'default' as const,
ctaHref: '/contact',
accentColor: 'var(--color-brand-primary)',
accentBg: 'var(--color-brand-primary-bg)',
accentBorder: 'border-l-[var(--color-brand-primary)]',
},
{
icon: Cpu,
@@ -33,8 +37,10 @@ const modules = [
],
values: ['业务场景深度调研', '技术方案定制开发', '敏捷交付快速迭代'],
cta: '了解技术方案',
ctaVariant: 'outline' as const,
ctaHref: '/products',
accentColor: 'var(--color-accent-blue)',
accentBg: 'rgba(37, 99, 235, 0.08)',
accentBorder: 'border-l-[var(--color-accent-blue)]',
},
{
icon: Users,
@@ -47,28 +53,33 @@ const modules = [
],
values: ['专属客户成功经理', '季度业务复盘会', '7×24小时响应通道'],
cta: '了解陪跑服务',
ctaVariant: 'default' as const,
ctaHref: '/services',
accentColor: 'var(--color-success)',
accentBg: 'rgba(22, 163, 74, 0.08)',
accentBorder: 'border-l-[var(--color-success)]',
},
];
export default function SolutionsPage() {
const shouldReduceMotion = useReducedMotion();
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
return (
<div className="min-h-screen bg-white">
<div className="min-h-screen bg-[var(--color-bg-primary)]">
<BreadcrumbSchema items={[{ name: '首页', href: '/' }, { name: '解决方案', href: '/solutions' }]} />
<section className="pt-32 pb-16">
<div className="container-wide">
<PageNav items={[{ label: '解决方案' }]} />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
{...fadeUp}
animate={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-3xl"
>
<p className="text-sm font-medium text-[#C41E3A] mb-4 tracking-wide uppercase">Solutions</p>
<h1 className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6 tracking-tight">
<p className="text-sm font-medium text-[var(--color-brand-primary)] mb-4 tracking-wide uppercase">Solutions</p>
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-text-primary)] mb-6 tracking-tight">
</h1>
<p className="text-lg text-[#595959] leading-relaxed">
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed">
</p>
</motion.div>
@@ -84,57 +95,65 @@ export default function SolutionsPage() {
<motion.section
key={index}
id={anchorIds[index]}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
{...fadeUp}
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
className="p-8 md:p-12 rounded-xl border border-[#E5E5E5] bg-white hover:border-[#C41E3A]/30 transition-colors"
className={`p-8 md:p-12 rounded-xl border border-[var(--color-border-primary)] border-l-4 ${module.accentBorder} bg-[var(--color-bg-primary)] hover:border-[var(--color-border-primary)] transition-colors`}
>
<div className="flex items-start gap-4 mb-6">
<div className="w-12 h-12 bg-[#C41E3A] rounded-xl flex items-center justify-center shrink-0">
<module.icon className="w-6 h-6 text-white" />
<div className="w-12 h-12 rounded-xl flex items-center justify-center shrink-0" style={{ backgroundColor: module.accentBg }}>
<module.icon className="w-6 h-6" style={{ color: module.accentColor }} />
</div>
<div>
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-1">
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-1">
{['一', '二', '三'][index]}{module.title}
</h2>
<p className="text-[#595959]">{module.subtitle}</p>
<p className="text-[var(--color-text-muted)]">{module.subtitle}</p>
</div>
</div>
<div className="space-y-4 mb-8">
{module.paragraphs.map((p, i) => (
<p key={i} className="text-[#1C1C1C] leading-relaxed">{p}</p>
<p key={i} className="text-[var(--color-text-primary)] leading-relaxed">{p}</p>
))}
</div>
<div className="mb-8">
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-4 flex items-center gap-2">
<CheckCircle2 className="w-5 h-5 text-[#C41E3A]" />
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4 flex items-center gap-2">
<CheckCircle2 className="w-5 h-5" style={{ color: module.accentColor }} />
</h3>
<div className="grid md:grid-cols-3 gap-3">
{module.values.map((value, i) => (
<div key={i} className="flex items-start gap-2 p-3 bg-[#F5F5F5] rounded-lg">
<div className="w-1.5 h-1.5 bg-[#C41E3A] rounded-full mt-2 shrink-0" />
<span className="text-sm text-[#1C1C1C]">{value}</span>
<div key={i} className="flex items-start gap-2 p-3 rounded-lg" style={{ backgroundColor: module.accentBg }}>
<div className="w-1.5 h-1.5 rounded-full mt-2 shrink-0" style={{ backgroundColor: module.accentColor }} />
<span className="text-sm text-[var(--color-text-primary)]">{value}</span>
</div>
))}
</div>
</div>
<div className="flex justify-center">
<Button
size="lg"
variant={module.ctaVariant}
className={module.ctaVariant === 'default' ? 'bg-[#C41E3A] hover:bg-[#A01830] text-white' : 'border-[#C41E3A] text-[#C41E3A] hover:bg-[#C41E3A] hover:text-white'}
asChild
<StaticLink
href={module.ctaHref}
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg border-2 font-medium transition-all duration-200 hover:text-white"
style={{
borderColor: module.accentColor,
color: module.accentColor,
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = module.accentColor;
e.currentTarget.style.color = '#fff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = module.accentColor;
}}
>
<StaticLink href={module.ctaHref}>
{module.cta}
<ArrowRight className="ml-2 w-4 h-4" />
</StaticLink>
</Button>
{module.cta}
<ArrowRight className="w-4 h-4" />
</StaticLink>
</div>
</motion.section>
);
@@ -145,27 +164,14 @@ export default function SolutionsPage() {
<MethodologySection />
<section className="bg-[#F5F5F5] py-20">
<div className="container-wide text-center">
<h2 className="text-3xl font-bold text-[#1C1C1C] mb-6">
</h2>
<p className="text-lg text-[#595959] mb-8 max-w-2xl mx-auto">
</p>
<div className="flex justify-center gap-4">
<Button size="lg" variant="outline" asChild>
<StaticLink href="/contact"></StaticLink>
</Button>
<Button size="lg" className="bg-[#C41E3A] hover:bg-[#A01830] text-white" asChild>
<StaticLink href="/contact">
<ArrowRight className="ml-2 w-4 h-4" />
</StaticLink>
</Button>
</div>
</div>
</section>
<CTASection
title="准备开始您的数字化转型之旅?"
description="无论您处于哪个阶段,我们都能为您提供合适的解决方案"
primaryLabel="立即咨询"
primaryHref="/contact"
secondaryLabel="联系我们"
secondaryHref="/contact"
/>
</div>
);
}
+32 -29
View File
@@ -1,6 +1,7 @@
'use client';
import { motion } from 'framer-motion';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button';
import { Shield, Building2, Users, Code, Target, ArrowRight } from 'lucide-react';
@@ -35,22 +36,24 @@ const TEAM_PILLARS = [
];
export function TeamClient() {
const shouldReduceMotion = useReducedMotion();
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
return (
<div className="min-h-screen bg-white">
<div className="min-h-screen bg-[var(--color-bg-primary)]">
<section className="pt-32 pb-16">
<div className="container-wide">
<PageNav items={[{ label: '核心团队' }]} />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
{...fadeUp}
animate={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-3xl"
>
<p className="text-sm font-medium text-[#C41E3A] mb-4 tracking-wide uppercase">Team</p>
<h1 className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6 tracking-tight">
<p className="text-sm font-medium text-[var(--color-brand-primary)] mb-4 tracking-wide uppercase">Team</p>
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-text-primary)] mb-6 tracking-tight">
</h1>
<p className="text-lg text-[#595959] leading-relaxed">
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed">
12 + IT
</p>
</motion.div>
@@ -61,21 +64,21 @@ export function TeamClient() {
<div className="container-wide">
<div className="max-w-5xl mx-auto space-y-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
{...fadeUp}
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="p-8 rounded-xl border border-[#E5E5E5]"
className="p-8 rounded-xl border border-[var(--color-border-primary)]"
>
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6 text-center"></h2>
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6 text-center"></h2>
<div className="space-y-4 max-w-3xl mx-auto text-center">
<p className="text-[#595959] leading-relaxed">
<span className="text-[#C41E3A] font-medium"></span><span className="text-[#C41E3A] font-medium"></span> 12
<p className="text-[var(--color-text-muted)] leading-relaxed">
<span className="text-[var(--color-brand-primary)] font-medium"></span><span className="text-[var(--color-brand-primary)] font-medium"></span> 12
</p>
<p className="text-[#595959] leading-relaxed">
<span className="text-[#C41E3A] font-medium"> IT </span>
<p className="text-[var(--color-text-muted)] leading-relaxed">
<span className="text-[var(--color-brand-primary)] font-medium"> IT </span>
</p>
<p className="text-[#595959] leading-relaxed">
<p className="text-[var(--color-text-muted)] leading-relaxed">
</p>
@@ -83,31 +86,31 @@ export function TeamClient() {
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
{...fadeUp}
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-8 text-center"></h2>
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-8 text-center"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{TEAM_PILLARS.map((item, idx) => {
const Icon = item.icon;
return (
<motion.div
key={item.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
{...fadeUp}
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: idx * 0.1 }}
className={idx >= 3 ? 'md:col-span-1 lg:col-start-1' : ''}
>
<div className="flex items-start gap-4 p-6 rounded-xl border border-[#E5E5E5] hover:border-[#C41E3A]/30 transition-colors h-full">
<div className="w-10 h-10 rounded-lg bg-[#FEF2F4] flex items-center justify-center shrink-0">
<Icon className="w-5 h-5 text-[#C41E3A]" />
<div className="flex items-start gap-4 p-6 rounded-xl border border-[var(--color-border-primary)] hover:border-[var(--color-brand-primary)]/30 transition-colors h-full">
<div className="w-10 h-10 rounded-lg bg-[var(--color-brand-primary-bg)] flex items-center justify-center shrink-0">
<Icon className="w-5 h-5 text-[var(--color-brand-primary)]" />
</div>
<div>
<h3 className="font-semibold text-[#1C1C1C] mb-1">{item.title}</h3>
<p className="text-sm text-[#595959]">{item.description}</p>
<h3 className="font-semibold text-[var(--color-text-primary)] mb-1">{item.title}</h3>
<p className="text-sm text-[var(--color-text-muted)]">{item.description}</p>
</div>
</div>
</motion.div>
@@ -117,14 +120,14 @@ export function TeamClient() {
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
{...fadeUp}
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="text-center pt-8"
>
<p className="text-lg text-[#595959] mb-6"></p>
<Button size="lg" className="bg-[#C41E3A] hover:bg-[#A01830] text-white" asChild>
<p className="text-lg text-[var(--color-text-muted)] mb-6"></p>
<Button size="lg" className="bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] text-white" asChild>
<StaticLink href="/contact">
<ArrowRight className="ml-2 w-4 h-4" />
+50
View File
@@ -0,0 +1,50 @@
'use client';
import { motion, type Variants } from 'framer-motion';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
const pageVariants: Variants = {
initial: {
opacity: 0,
y: 8,
},
enter: {
opacity: 1,
y: 0,
transition: {
duration: 0.35,
ease: [0.16, 1, 0.3, 1] as [number, number, number, number],
},
},
exit: {
opacity: 0,
y: -4,
transition: {
duration: 0.2,
ease: [0.16, 1, 0.3, 1] as [number, number, number, number],
},
},
};
export default function MarketingTemplate({
children,
}: {
children: React.ReactNode;
}) {
const shouldReduceMotion = useReducedMotion();
if (shouldReduceMotion) {
return <>{children}</>;
}
return (
<motion.div
initial="initial"
animate="enter"
exit="exit"
variants={pageVariants}
>
{children}
</motion.div>
);
}
+24 -24
View File
@@ -17,32 +17,32 @@ export default function Error({
}, [error]);
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="min-h-screen bg-[var(--color-bg-primary)] flex items-center justify-center">
<div className="container-wide px-4 py-20">
<div className="max-w-2xl mx-auto text-center">
<div className="mb-8">
<div className="w-24 h-24 bg-[#C41E3A]/10 rounded-full flex items-center justify-center mx-auto mb-6">
<AlertTriangle className="w-12 h-12 text-[#C41E3A]" />
<div className="w-24 h-24 bg-[var(--color-brand-primary-bg)] rounded-full flex items-center justify-center mx-auto mb-6">
<AlertTriangle className="w-12 h-12 text-[var(--color-brand-primary)]" />
</div>
<div className="w-32 h-1 bg-[#C41E3A] mx-auto" />
<div className="w-32 h-1 bg-[var(--color-brand-primary)] mx-auto" />
</div>
<h1 className="text-3xl font-bold text-[#1C1C1C] mb-4">
<h1 className="text-3xl font-bold text-[var(--color-text-primary)] mb-4">
</h1>
<p className="text-lg text-[#5C5C5C] mb-6 leading-relaxed">
<p className="text-lg text-[var(--color-text-placeholder)] mb-6 leading-relaxed">
</p>
{error.message && (
<div className="bg-[#FAFAFA] border border-[#E5E5E5] rounded-lg p-4 mb-8 text-left">
<p className="text-sm text-[#5C5C5C] font-mono">
<div className="bg-[var(--color-bg-section)] border border-[var(--color-border-primary)] rounded-lg p-4 mb-8 text-left">
<p className="text-sm text-[var(--color-text-placeholder)] font-mono">
: {error.message}
</p>
{error.digest && (
<p className="text-xs text-[#5C5C5C] mt-2 font-mono">
<p className="text-xs text-[var(--color-text-placeholder)] mt-2 font-mono">
ID: {error.digest}
</p>
)}
@@ -53,7 +53,7 @@ export default function Error({
<Button
size="lg"
onClick={reset}
className="bg-[#C41E3A] hover:bg-[#A01830] text-white"
className="bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] text-white"
>
<RefreshCw className="w-5 h-5 mr-2" />
@@ -71,43 +71,43 @@ export default function Error({
</Button>
</div>
<div className="bg-[#FAFAFA] rounded-lg p-8">
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-6">
<div className="bg-[var(--color-bg-section)] rounded-lg p-8">
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-6">
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<StaticLink
href="/contact"
className="flex items-center p-4 bg-white rounded-lg hover:shadow-md transition-shadow group"
className="flex items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
>
<div className="w-10 h-10 bg-[#C41E3A]/10 rounded-lg flex items-center justify-center mr-4 group-hover:bg-[#C41E3A]/20 transition-colors">
<AlertTriangle className="w-5 h-5 text-[#C41E3A]" />
<div className="w-10 h-10 bg-[var(--color-brand-primary-bg)] rounded-lg flex items-center justify-center mr-4 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors">
<AlertTriangle className="w-5 h-5 text-[var(--color-brand-primary)]" />
</div>
<div className="text-left">
<div className="font-semibold text-[#1C1C1C]"></div>
<div className="text-sm text-[#5C5C5C]"></div>
<div className="font-semibold text-[var(--color-text-primary)]"></div>
<div className="text-sm text-[var(--color-text-placeholder)]"></div>
</div>
</StaticLink>
<StaticLink
href="/services"
className="flex items-center p-4 bg-white rounded-lg hover:shadow-md transition-shadow group"
className="flex items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
>
<div className="w-10 h-10 bg-[#C41E3A]/10 rounded-lg flex items-center justify-center mr-4 group-hover:bg-[#C41E3A]/20 transition-colors">
<RefreshCw className="w-5 h-5 text-[#C41E3A]" />
<div className="w-10 h-10 bg-[var(--color-brand-primary-bg)] rounded-lg flex items-center justify-center mr-4 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors">
<RefreshCw className="w-5 h-5 text-[var(--color-brand-primary)]" />
</div>
<div className="text-left">
<div className="font-semibold text-[#1C1C1C]"></div>
<div className="text-sm text-[#5C5C5C]"></div>
<div className="font-semibold text-[var(--color-text-primary)]"></div>
<div className="text-sm text-[var(--color-text-placeholder)]"></div>
</div>
</StaticLink>
</div>
</div>
<div className="mt-8 text-sm text-[#5C5C5C]">
<div className="mt-8 text-sm text-[var(--color-text-placeholder)]">
{' '}
<StaticLink href="/contact" className="text-[#C41E3A] hover:underline">
<StaticLink href="/contact" className="text-[var(--color-brand-primary)] hover:underline">
</StaticLink>
</div>
Binary file not shown.
Binary file not shown.
+142 -855
View File
File diff suppressed because it is too large Load Diff
+16 -19
View File
@@ -1,6 +1,6 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono, Noto_Sans_SC, Ma_Shan_Zheng } from "next/font/google";
import localFont from "next/font/local";
import { Ma_Shan_Zheng, Noto_Sans_SC } from "next/font/google";
import "./globals.css";
import { Suspense } from "react";
import { ThemeProvider } from "@/contexts/theme-context";
@@ -15,24 +15,23 @@ import { ErrorBoundary } from "@/components/ui/error-boundary";
import { ScrollProgress } from "@/components/ui/scroll-progress";
import { BackToTop } from "@/components/ui/back-to-top";
const geistSans = Geist({
const geistSans = localFont({
src: "./fonts/geist-sans.woff2",
variable: "--font-geist-sans",
subsets: ["latin"],
display: "swap",
preload: false,
});
const geistMono = Geist_Mono({
const geistMono = localFont({
src: "./fonts/geist-mono.woff2",
variable: "--font-geist-mono",
subsets: ["latin"],
display: "swap",
preload: false,
});
const notoSansSC = Noto_Sans_SC({
weight: ["400", "500", "700"],
variable: "--font-noto-sans-sc",
subsets: ["latin"],
const aoyagiReisho = localFont({
src: "./fonts/AoyagiReisho-subset.ttf",
variable: "--font-aoyagi-reisho",
display: "swap",
preload: true,
});
@@ -40,17 +39,16 @@ const notoSansSC = Noto_Sans_SC({
const maShanZheng = Ma_Shan_Zheng({
weight: "400",
variable: "--font-ma-shan-zheng",
subsets: ["latin"],
display: "swap",
preload: true,
preload: false,
});
// 青柳隷書 - 仅用于品牌标题"睿新致遠"(子集版本,仅包含4个字符)
const aoyagiReisho = localFont({
src: "./fonts/AoyagiReisho-subset.ttf",
variable: "--font-aoyagi-reisho",
const notoSansSC = Noto_Sans_SC({
weight: ["400", "500", "700"],
variable: "--font-noto-sans-sc",
display: "swap",
preload: true,
subsets: ["latin"],
preload: false,
});
export const metadata: Metadata = {
@@ -134,12 +132,11 @@ export default function RootLayout({
<WebsiteSchema />
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} ${notoSansSC.variable} ${maShanZheng.variable} ${aoyagiReisho.variable} font-sans antialiased`}
style={{ fontFamily: "'Noto Sans SC', 'Geist', -apple-system, BlinkMacSystemFont, sans-serif" }}
className={`${geistSans.variable} ${geistMono.variable} ${aoyagiReisho.variable} ${maShanZheng.variable} ${notoSansSC.variable} font-sans antialiased`}
>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[9999] focus:px-4 focus:py-2 focus:bg-[#C41E3A] focus:text-white focus:rounded-md focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-[#C41E3A]"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[9999] focus:px-4 focus:py-2 focus:bg-[var(--color-brand-primary)] focus:text-white focus:rounded-md focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-[var(--color-brand-primary)]"
>
</a>
+30 -30
View File
@@ -7,21 +7,21 @@ import { COMPANY_INFO } from '@/lib/constants';
export default function NotFound() {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="min-h-screen bg-[var(--color-bg-primary)] flex items-center justify-center">
<div className="container-wide px-4 py-20">
<div className="max-w-2xl mx-auto text-center">
<div className="mb-8">
<h1 className="text-[120px] font-bold text-[#C41E3A] leading-none mb-4">
<h1 className="text-[120px] font-bold text-[var(--color-brand-primary)] leading-none mb-4">
404
</h1>
<div className="w-32 h-1 bg-[#C41E3A] mx-auto mb-6" />
<div className="w-32 h-1 bg-[var(--color-brand-primary)] mx-auto mb-6" />
</div>
<h2 className="text-3xl font-bold text-[#1C1C1C] mb-4">
<h2 className="text-3xl font-bold text-[var(--color-text-primary)] mb-4">
</h2>
<p className="text-lg text-[#5C5C5C] mb-8 leading-relaxed">
<p className="text-lg text-[var(--color-text-placeholder)] mb-8 leading-relaxed">
访
使
</p>
@@ -30,7 +30,7 @@ export default function NotFound() {
<Button
size="lg"
asChild
className="bg-[#C41E3A] hover:bg-[#A01830] text-white"
className="bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] text-white"
>
<StaticLink href="/">
<Home className="w-5 h-5 mr-2" />
@@ -48,69 +48,69 @@ export default function NotFound() {
</Button>
</div>
<div className="bg-[#FAFAFA] rounded-lg p-8">
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-6">
<div className="bg-[var(--color-bg-section)] rounded-lg p-8">
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-6">
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<StaticLink
href="/about"
className="flex items-center p-4 bg-white rounded-lg hover:shadow-md transition-shadow group"
className="flex items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
>
<div className="w-10 h-10 bg-[#C41E3A]/10 rounded-lg flex items-center justify-center mr-4 group-hover:bg-[#C41E3A]/20 transition-colors">
<Search className="w-5 h-5 text-[#C41E3A]" />
<div className="w-10 h-10 bg-[var(--color-brand-primary-bg)] rounded-lg flex items-center justify-center mr-4 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors">
<Search className="w-5 h-5 text-[var(--color-brand-primary)]" />
</div>
<div className="text-left">
<div className="font-semibold text-[#1C1C1C]"></div>
<div className="text-sm text-[#5C5C5C]">{COMPANY_INFO.displayName}</div>
<div className="font-semibold text-[var(--color-text-primary)]"></div>
<div className="text-sm text-[var(--color-text-placeholder)]">{COMPANY_INFO.displayName}</div>
</div>
</StaticLink>
<StaticLink
href="/services"
className="flex items-center p-4 bg-white rounded-lg hover:shadow-md transition-shadow group"
className="flex items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
>
<div className="w-10 h-10 bg-[#C41E3A]/10 rounded-lg flex items-center justify-center mr-4 group-hover:bg-[#C41E3A]/20 transition-colors">
<Search className="w-5 h-5 text-[#C41E3A]" />
<div className="w-10 h-10 bg-[var(--color-brand-primary-bg)] rounded-lg flex items-center justify-center mr-4 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors">
<Search className="w-5 h-5 text-[var(--color-brand-primary)]" />
</div>
<div className="text-left">
<div className="font-semibold text-[#1C1C1C]"></div>
<div className="text-sm text-[#5C5C5C]"></div>
<div className="font-semibold text-[var(--color-text-primary)]"></div>
<div className="text-sm text-[var(--color-text-placeholder)]"></div>
</div>
</StaticLink>
<StaticLink
href="/products"
className="flex items-center p-4 bg-white rounded-lg hover:shadow-md transition-shadow group"
className="flex items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
>
<div className="w-10 h-10 bg-[#C41E3A]/10 rounded-lg flex items-center justify-center mr-4 group-hover:bg-[#C41E3A]/20 transition-colors">
<Search className="w-5 h-5 text-[#C41E3A]" />
<div className="w-10 h-10 bg-[var(--color-brand-primary-bg)] rounded-lg flex items-center justify-center mr-4 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors">
<Search className="w-5 h-5 text-[var(--color-brand-primary)]" />
</div>
<div className="text-left">
<div className="font-semibold text-[#1C1C1C]"></div>
<div className="text-sm text-[#5C5C5C]"></div>
<div className="font-semibold text-[var(--color-text-primary)]"></div>
<div className="text-sm text-[var(--color-text-placeholder)]"></div>
</div>
</StaticLink>
<StaticLink
href="/solutions"
className="flex items-center p-4 bg-white rounded-lg hover:shadow-md transition-shadow group"
className="flex items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
>
<div className="w-10 h-10 bg-[#C41E3A]/10 rounded-lg flex items-center justify-center mr-4 group-hover:bg-[#C41E3A]/20 transition-colors">
<Search className="w-5 h-5 text-[#C41E3A]" />
<div className="w-10 h-10 bg-[var(--color-brand-primary-bg)] rounded-lg flex items-center justify-center mr-4 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors">
<Search className="w-5 h-5 text-[var(--color-brand-primary)]" />
</div>
<div className="text-left">
<div className="font-semibold text-[#1C1C1C]"></div>
<div className="text-sm text-[#5C5C5C]"></div>
<div className="font-semibold text-[var(--color-text-primary)]"></div>
<div className="text-sm text-[var(--color-text-placeholder)]"></div>
</div>
</StaticLink>
</div>
</div>
<div className="mt-8 text-sm text-[#5C5C5C]">
<div className="mt-8 text-sm text-[var(--color-text-placeholder)]">
{' '}
<StaticLink href="/contact" className="text-[#C41E3A] hover:underline">
<StaticLink href="/contact" className="text-[var(--color-brand-primary)] hover:underline">
</StaticLink>
</div>
+68 -68
View File
@@ -8,8 +8,8 @@ export const metadata: Metadata = {
export default function PrivacyPolicyPage() {
return (
<div className="min-h-screen bg-white">
<div className="bg-gradient-to-br from-[#C41E3A] to-[#1C1C1C] py-20">
<div className="min-h-screen bg-[var(--color-bg-primary)]">
<div className="bg-gradient-to-br from-[var(--color-brand-primary)] to-[var(--color-text-primary)] py-20">
<div className="container-wide">
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
@@ -24,31 +24,31 @@ export default function PrivacyPolicyPage() {
<div className="max-w-4xl">
<div className="prose prose-lg max-w-none">
<section className="mb-12">
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4"></h2>
<p className="text-[#5C5C5C] leading-relaxed mb-4">
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4"></h2>
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
&ldquo;&rdquo;&ldquo;&rdquo;
</p>
<p className="text-[#5C5C5C] leading-relaxed">
<p className="text-[var(--color-text-muted)] leading-relaxed">
访使使
</p>
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4">使</h2>
<p className="text-[#5C5C5C] leading-relaxed mb-4">
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4">使</h2>
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
使
</p>
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">1.1 </h3>
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-2 mb-4">
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">1.1 </h3>
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-2 mb-4">
<li></li>
<li></li>
<li>使使访</li>
<li>使</li>
</ul>
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">1.2 使</h3>
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-2">
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">1.2 使</h3>
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-2">
<li></li>
<li></li>
<li>使</li>
@@ -58,33 +58,33 @@ export default function PrivacyPolicyPage() {
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4"></h2>
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4"></h2>
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">2.1 </h3>
<p className="text-[#5C5C5C] leading-relaxed mb-4">
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">2.1 </h3>
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
</p>
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-2 mb-4">
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-2 mb-4">
<li></li>
<li></li>
<li></li>
</ul>
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">2.2 </h3>
<p className="text-[#5C5C5C] leading-relaxed mb-4">
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">2.2 </h3>
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
</p>
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-2 mb-4">
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-2 mb-4">
<li></li>
<li></li>
<li></li>
</ul>
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">2.3 </h3>
<p className="text-[#5C5C5C] leading-relaxed">
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">2.3 </h3>
<p className="text-[var(--color-text-muted)] leading-relaxed">
</p>
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-2">
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-2">
<li></li>
<li></li>
<li></li>
@@ -93,11 +93,11 @@ export default function PrivacyPolicyPage() {
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4"></h2>
<p className="text-[#5C5C5C] leading-relaxed mb-4">
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4"></h2>
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
使访使
</p>
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-2">
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-2">
<li>使</li>
<li>访访</li>
<li></li>
@@ -107,11 +107,11 @@ export default function PrivacyPolicyPage() {
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4"></h2>
<p className="text-[#5C5C5C] leading-relaxed mb-4">
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4"></h2>
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
使
</p>
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-2">
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-2">
<li>访</li>
<li></li>
<li></li>
@@ -119,82 +119,82 @@ export default function PrivacyPolicyPage() {
<li></li>
<li></li>
</ul>
<p className="text-[#5C5C5C] leading-relaxed mt-4">
<p className="text-[var(--color-text-muted)] leading-relaxed mt-4">
使
</p>
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4"></h2>
<p className="text-[#5C5C5C] leading-relaxed">
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4"></h2>
<p className="text-[var(--color-text-muted)] leading-relaxed">
18使
</p>
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4"></h2>
<p className="text-[#5C5C5C] leading-relaxed">
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4"></h2>
<p className="text-[var(--color-text-muted)] leading-relaxed">
使
</p>
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4">Cookie </h2>
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4">Cookie </h2>
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">7.1 Cookie 使</h3>
<p className="text-[#5C5C5C] leading-relaxed mb-4">
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">7.1 Cookie 使</h3>
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
使 Cookie Cookie
</p>
<div className="overflow-x-auto mb-6">
<table className="min-w-full border border-gray-200 rounded-lg">
<thead className="bg-gray-50">
<table className="min-w-full border border-[var(--color-border)] rounded-lg">
<thead className="bg-[var(--color-bg-tertiary)]">
<tr>
<th className="px-4 py-3 text-left text-sm font-semibold text-[#1C1C1C] border-b">Cookie </th>
<th className="px-4 py-3 text-left text-sm font-semibold text-[#1C1C1C] border-b"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-[#1C1C1C] border-b"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-[#1C1C1C] border-b"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--color-text-primary)] border-b border-[var(--color-border)]">Cookie </th>
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--color-text-primary)] border-b border-[var(--color-border)]"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--color-text-primary)] border-b border-[var(--color-border)]"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--color-text-primary)] border-b border-[var(--color-border)]"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
<tbody className="divide-y divide-[var(--color-border)]">
<tr>
<td className="px-4 py-3 text-sm text-[#5C5C5C]"> Cookie</td>
<td className="px-4 py-3 text-sm text-[#5C5C5C]"></td>
<td className="px-4 py-3 text-sm text-[#5C5C5C]"></td>
<td className="px-4 py-3 text-sm text-[#5C5C5C]"></td>
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]"> Cookie</td>
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]"></td>
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]"></td>
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]"></td>
</tr>
<tr>
<td className="px-4 py-3 text-sm text-[#5C5C5C]"> Cookie</td>
<td className="px-4 py-3 text-sm text-[#5C5C5C]">使</td>
<td className="px-4 py-3 text-sm text-[#5C5C5C]">14</td>
<td className="px-4 py-3 text-sm text-[#5C5C5C]"></td>
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]"> Cookie</td>
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]">使</td>
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]">14</td>
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]"></td>
</tr>
<tr>
<td className="px-4 py-3 text-sm text-[#5C5C5C]"> Cookie</td>
<td className="px-4 py-3 text-sm text-[#5C5C5C]">广使</td>
<td className="px-4 py-3 text-sm text-[#5C5C5C]">-</td>
<td className="px-4 py-3 text-sm text-[#5C5C5C]"></td>
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]"> Cookie</td>
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]">广使</td>
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]">-</td>
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]"></td>
</tr>
</tbody>
</table>
</div>
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">7.2 Google Analytics 使</h3>
<p className="text-[#5C5C5C] leading-relaxed mb-4">
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">7.2 Google Analytics 使</h3>
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
使 Google Analytics 4 Google LLC 使访使
</p>
<p className="text-[#5C5C5C] leading-relaxed mb-2">
<p className="text-[var(--color-text-muted)] leading-relaxed mb-2">
<strong></strong>
</p>
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-1 mb-4">
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-1 mb-4">
<li>访</li>
<li></li>
<li>/IP </li>
<li>访访</li>
</ul>
<p className="text-[#5C5C5C] leading-relaxed mb-2">
<p className="text-[var(--color-text-muted)] leading-relaxed mb-2">
<strong></strong>
</p>
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-1 mb-4">
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-1 mb-4">
<li>IP </li>
<li> 14 </li>
<li>广</li>
@@ -202,29 +202,29 @@ export default function PrivacyPolicyPage() {
<li> Google 广</li>
</ul>
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">7.3 </h3>
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-2 mb-4">
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">7.3 </h3>
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-2 mb-4">
<li>访 Cookie</li>
<li>&ldquo;Cookie &rdquo;</li>
<li> Cookie</li>
</ul>
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">7.4 </h3>
<p className="text-[#5C5C5C] leading-relaxed">
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">7.4 </h3>
<p className="text-[var(--color-text-muted)] leading-relaxed">
</p>
<ul className="list-none text-[#5C5C5C] space-y-1 mt-2">
<ul className="list-none text-[var(--color-text-muted)] space-y-1 mt-2">
<li>privacy@novalon.cn</li>
<li>驿12</li>
</ul>
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4"></h2>
<p className="text-[#5C5C5C] leading-relaxed mb-4">
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4"></h2>
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
使
</p>
<ul className="list-none text-[#5C5C5C] space-y-2">
<ul className="list-none text-[var(--color-text-muted)] space-y-2">
<li></li>
<li>contact@novalon.cn</li>
<li>privacy@novalon.cn</li>
+46 -46
View File
@@ -8,8 +8,8 @@ export const metadata: Metadata = {
export default function TermsOfServicePage() {
return (
<div className="min-h-screen bg-white">
<div className="bg-gradient-to-br from-[#C41E3A] to-[#1C1C1C] py-20">
<div className="min-h-screen bg-[var(--color-bg-primary)]">
<div className="bg-gradient-to-br from-[var(--color-brand-primary)] to-[var(--color-text-primary)] py-20">
<div className="container-wide">
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
@@ -24,48 +24,48 @@ export default function TermsOfServicePage() {
<div className="max-w-4xl">
<div className="prose prose-lg max-w-none">
<section className="mb-12">
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4"></h2>
<p className="text-[#5C5C5C] leading-relaxed mb-4">
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4"></h2>
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
使&ldquo;&rdquo;&ldquo;&rdquo;使使
</p>
<p className="text-[#5C5C5C] leading-relaxed">
<p className="text-[var(--color-text-muted)] leading-relaxed">
使
</p>
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4"></h2>
<p className="text-[#5C5C5C] leading-relaxed mb-4">
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4"></h2>
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
ERPCRMBI等
</p>
<p className="text-[#5C5C5C] leading-relaxed">
<p className="text-[var(--color-text-muted)] leading-relaxed">
</p>
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4"></h2>
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4"></h2>
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">2.1 </h3>
<p className="text-[#5C5C5C] leading-relaxed mb-4">
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">2.1 </h3>
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
使
</p>
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">2.2 </h3>
<p className="text-[#5C5C5C] leading-relaxed mb-4">
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">2.2 </h3>
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
使
</p>
<p className="text-[#5C5C5C] leading-relaxed">
<p className="text-[var(--color-text-muted)] leading-relaxed">
</p>
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4"></h2>
<p className="text-[#5C5C5C] leading-relaxed mb-4">
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4"></h2>
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
使
</p>
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-2 mb-4">
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-2 mb-4">
<li></li>
<li></li>
<li></li>
@@ -74,52 +74,52 @@ export default function TermsOfServicePage() {
<li></li>
<li></li>
</ul>
<p className="text-[#5C5C5C] leading-relaxed">
<p className="text-[var(--color-text-muted)] leading-relaxed">
使
</p>
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4"></h2>
<p className="text-[#5C5C5C] leading-relaxed mb-4">
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4"></h2>
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
</p>
<p className="text-[#5C5C5C] leading-relaxed mb-4">
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
使
</p>
<p className="text-[#5C5C5C] leading-relaxed">
<p className="text-[var(--color-text-muted)] leading-relaxed">
使使
</p>
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4"></h2>
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4"></h2>
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">5.1 </h3>
<p className="text-[#5C5C5C] leading-relaxed mb-4">
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">5.1 </h3>
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
</p>
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">5.2 </h3>
<p className="text-[#5C5C5C] leading-relaxed mb-4">
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">5.2 </h3>
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
</p>
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3">5.3 退</h3>
<p className="text-[#5C5C5C] leading-relaxed">
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-3">5.3 退</h3>
<p className="text-[var(--color-text-muted)] leading-relaxed">
退使
</p>
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4"></h2>
<p className="text-[#5C5C5C] leading-relaxed mb-4">
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4"></h2>
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
&ldquo;&rdquo;&ldquo;&rdquo;
</p>
<p className="text-[#5C5C5C] leading-relaxed mb-4">
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
</p>
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-2">
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-2">
<li></li>
<li></li>
<li></li>
@@ -128,11 +128,11 @@ export default function TermsOfServicePage() {
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4"></h2>
<p className="text-[#5C5C5C] leading-relaxed mb-4">
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4"></h2>
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
使
</p>
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-2">
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-2">
<li></li>
<li></li>
<li>使</li>
@@ -141,15 +141,15 @@ export default function TermsOfServicePage() {
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4"></h2>
<p className="text-[#5C5C5C] leading-relaxed mb-4">
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4"></h2>
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
</p>
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4"></h2>
<ul className="list-disc pl-6 text-[#5C5C5C] space-y-2">
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4"></h2>
<ul className="list-disc pl-6 text-[var(--color-text-muted)] space-y-2">
<li>使</li>
<li>使</li>
<li>使使</li>
@@ -158,20 +158,20 @@ export default function TermsOfServicePage() {
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4"></h2>
<p className="text-[#5C5C5C] leading-relaxed mb-4">
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4"></h2>
<p className="text-[var(--color-text-muted)] leading-relaxed mb-4">
</p>
<ul className="list-none text-[#5C5C5C] space-y-2">
<ul className="list-none text-[var(--color-text-muted)] space-y-2">
<li></li>
<li>contact@novalon.cn</li>
<li>驿12</li>
</ul>
</section>
<section className="bg-[#FFFBF5] p-6 rounded-lg border-l-4 border-[#C41E3A]">
<p className="text-[#1C1C1C] font-medium mb-2"></p>
<p className="text-[#5C5C5C]">2026226</p>
<section className="bg-[var(--color-warning-bg)] p-6 rounded-lg border-l-4 border-[var(--color-brand-primary)]">
<p className="text-[var(--color-text-primary)] font-medium mb-2"></p>
<p className="text-[var(--color-text-muted)]">2026226</p>
</section>
</div>
</div>
+24 -24
View File
@@ -124,18 +124,18 @@ export function CookieConsent() {
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="fixed bottom-16 md:bottom-0 left-0 right-0 z-[9998] bg-white border-t border-gray-200 shadow-lg"
className="fixed bottom-16 md:bottom-0 left-0 right-0 z-[9998] bg-[var(--color-bg-primary)] border-t border-[var(--color-border-secondary)] shadow-lg"
>
<div className="max-w-7xl mx-auto px-4 py-4 sm:px-6 lg:px-8">
{!showSettings ? (
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex-1">
<p className="text-sm text-gray-700">
<p className="text-sm text-[var(--color-text-secondary)]">
使 Cookie
使{' '}
<a
href="/privacy"
className="text-[#C41E3A] hover:text-[#A01830] underline font-medium"
className="text-[var(--color-brand-primary)] hover:text-[var(--color-brand-primary-hover)] underline font-medium"
>
</a>
@@ -146,21 +146,21 @@ export function CookieConsent() {
<button
onClick={() => setShowSettings(true)}
disabled={isAnimating}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg hover:bg-[var(--color-primary-lighter)] transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleRejectAll}
disabled={isAnimating}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg hover:bg-[var(--color-primary-lighter)] transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleAcceptAll}
disabled={isAnimating}
className="px-4 py-2 text-sm font-medium text-white bg-[#C41E3A] rounded-lg hover:bg-[#A01830] transition-colors disabled:opacity-50"
className="px-4 py-2 text-sm font-medium text-white bg-[var(--color-brand-primary)] rounded-lg hover:bg-[var(--color-brand-primary-hover)] transition-colors disabled:opacity-50"
>
</button>
@@ -169,10 +169,10 @@ export function CookieConsent() {
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-[#1C1C1C]">Cookie </h3>
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">Cookie </h3>
<button
onClick={() => setShowSettings(false)}
className="text-gray-500 hover:text-gray-700"
className="text-[var(--color-text-placeholder)] hover:text-[var(--color-text-primary)]"
aria-label="关闭设置"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -182,52 +182,52 @@ export function CookieConsent() {
</div>
<div className="space-y-3">
<div className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg">
<div className="flex items-start gap-3 p-3 bg-[var(--color-primary-lighter)] rounded-lg">
<input
type="checkbox"
checked
disabled
className="mt-1 h-4 w-4 rounded border-gray-300 text-[#C41E3A] focus:ring-[#C41E3A] cursor-not-allowed"
className="mt-1 h-4 w-4 rounded border-[var(--color-border-secondary)] text-[var(--color-brand-primary)] focus:ring-[var(--color-brand-primary)] cursor-not-allowed"
aria-label="必要 Cookie"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-[#1C1C1C]"> Cookie</span>
<span className="text-xs px-2 py-0.5 bg-gray-200 text-gray-600 rounded"></span>
<span className="font-medium text-[var(--color-text-primary)]"> Cookie</span>
<span className="text-xs px-2 py-0.5 bg-[var(--color-border-secondary)] text-[var(--color-text-muted)] rounded"></span>
</div>
<p className="text-sm text-[#5C5C5C] mt-1">
<p className="text-sm text-[var(--color-text-muted)] mt-1">
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg">
<div className="flex items-start gap-3 p-3 bg-[var(--color-primary-lighter)] rounded-lg">
<input
type="checkbox"
checked={preferences.analytics}
onChange={() => handleTogglePreference('analytics')}
className="mt-1 h-4 w-4 rounded border-gray-300 text-[#C41E3A] focus:ring-[#C41E3A] cursor-pointer"
className="mt-1 h-4 w-4 rounded border-[var(--color-border-secondary)] text-[var(--color-brand-primary)] focus:ring-[var(--color-brand-primary)] cursor-pointer"
aria-label="分析 Cookie"
/>
<div className="flex-1">
<span className="font-medium text-[#1C1C1C]"> Cookie</span>
<p className="text-sm text-[#5C5C5C] mt-1">
<span className="font-medium text-[var(--color-text-primary)]"> Cookie</span>
<p className="text-sm text-[var(--color-text-muted)] mt-1">
访使
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg opacity-50">
<div className="flex items-start gap-3 p-3 bg-[var(--color-primary-lighter)] rounded-lg opacity-50">
<input
type="checkbox"
checked={preferences.marketing}
onChange={() => handleTogglePreference('marketing')}
className="mt-1 h-4 w-4 rounded border-gray-300 text-[#C41E3A] focus:ring-[#C41E3A] cursor-pointer"
className="mt-1 h-4 w-4 rounded border-[var(--color-border-secondary)] text-[var(--color-brand-primary)] focus:ring-[var(--color-brand-primary)] cursor-pointer"
aria-label="营销 Cookie"
/>
<div className="flex-1">
<span className="font-medium text-[#1C1C1C]"> Cookie</span>
<p className="text-sm text-[#5C5C5C] mt-1">
<span className="font-medium text-[var(--color-text-primary)]"> Cookie</span>
<p className="text-sm text-[var(--color-text-muted)] mt-1">
广使
</p>
</div>
@@ -238,14 +238,14 @@ export function CookieConsent() {
<button
onClick={() => setShowSettings(false)}
disabled={isAnimating}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg hover:bg-[var(--color-primary-lighter)] transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleSaveCustom}
disabled={isAnimating}
className="px-4 py-2 text-sm font-medium text-white bg-[#C41E3A] rounded-lg hover:bg-[#A01830] transition-colors disabled:opacity-50"
className="px-4 py-2 text-sm font-medium text-white bg-[var(--color-brand-primary)] rounded-lg hover:bg-[var(--color-brand-primary-hover)] transition-colors disabled:opacity-50"
>
</button>
@@ -273,7 +273,7 @@ export function CookieSettingsButton() {
const event = new CustomEvent('open-cookie-settings');
window.dispatchEvent(event);
}}
className="fixed bottom-4 right-4 z-[9997] px-3 py-2 text-xs font-medium text-gray-600 bg-white border border-gray-200 rounded-lg shadow-sm hover:bg-gray-50 transition-colors"
className="fixed bottom-4 right-4 z-[9997] px-3 py-2 text-xs font-medium text-[var(--color-text-muted)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg shadow-sm hover:bg-[var(--color-primary-lighter)] transition-colors"
aria-label="Cookie 设置"
>
Cookie
@@ -1,450 +0,0 @@
'use client';
import { motion, useScroll, useTransform } from 'framer-motion';
import { useMemo, useState, useEffect, useRef } from 'react';
import { Cpu, Shield, Zap, Globe, FileText, TrendingUp, BarChart3, Users } from 'lucide-react';
interface FloatingOrbProps {
size?: number;
color?: string;
delay?: number;
x?: number;
y?: number;
duration?: number;
icon?: any;
className?: string;
}
function FloatingOrb({
size = 80,
color = 'rgba(196, 30, 58, 0.08)',
delay = 0,
x = 0,
y = 0,
duration = 8,
icon: Icon,
className = ''
}: FloatingOrbProps) {
return (
<motion.div
className={`absolute rounded-full pointer-events-none ${className}`}
style={{
width: size,
height: size,
backgroundColor: color,
backdropFilter: 'blur(20px)',
boxShadow: '0 0 40px rgba(196, 30, 58, 0.1)',
}}
initial={{ opacity: 0, scale: 0, x, y }}
animate={{
opacity: [0, 1, 1],
scale: [0.5, 1, 1],
y: [y, y - 30, y],
x: [x, x + 15, x],
}}
transition={{
duration: duration,
delay,
repeat: Infinity,
ease: 'easeInOut',
times: [0, 0.5, 1],
}}
>
{Icon && (
<div className="absolute inset-0 flex items-center justify-center">
<Icon className="w-5 h-5 text-[#C41E3A]/30" />
</div>
)}
</motion.div>
);
}
interface FloatingLineProps {
startX?: number;
startY?: number;
endX?: number;
endY?: number;
color?: string;
delay?: number;
duration?: number;
className?: string;
}
function FloatingLine({
startX = 0,
startY = 0,
endX = 200,
endY = 0,
color = 'rgba(28, 28, 28, 0.1)',
delay = 0,
duration = 6,
className = ''
}: FloatingLineProps) {
return (
<motion.svg
className={`absolute pointer-events-none ${className}`}
style={{
left: startX,
top: startY,
width: Math.abs(endX - startX) || 100,
height: Math.abs(endY - startY) || 2,
overflow: 'visible',
}}
initial={{ opacity: 0 }}
animate={{ opacity: [0, 1, 0.5, 1] }}
transition={{
duration,
delay,
repeat: Infinity,
ease: 'easeInOut',
}}
>
<motion.path
d={`M0 0 Q${(endX - startX) / 2} ${-20 + Math.random() * 40} ${endX - startX} 0`}
fill="none"
stroke={color}
strokeWidth="1"
strokeLinecap="round"
initial={{ pathLength: 0 }}
animate={{ pathLength: [0, 1, 0] }}
transition={{
duration: duration * 2,
delay,
repeat: Infinity,
ease: 'easeInOut',
}}
/>
</motion.svg>
);
}
interface FloatingIconProps {
icon?: any;
size?: number;
color?: string;
delay?: number;
x?: number;
y?: number;
rotation?: number;
className?: string;
}
function FloatingIcon({
icon: Icon,
size = 24,
color = '#1C1C1C',
delay = 0,
x = 0,
y = 0,
rotation = 0,
className = ''
}: FloatingIconProps) {
return (
<motion.div
className={`absolute pointer-events-none ${className}`}
style={{
left: x,
top: y,
}}
initial={{ opacity: 0, scale: 0, rotate: rotation - 15, x, y }}
animate={{
opacity: [0, 1, 0.8],
scale: [0.8, 1, 0.9],
rotate: [rotation - 10, rotation + 10, rotation],
y: [y, y - 25, y - 10],
}}
transition={{
duration: 7 + Math.random() * 3,
delay,
repeat: Infinity,
ease: 'easeInOut',
times: [0, 0.5, 1],
}}
>
<div
className="flex items-center justify-center rounded-full"
style={{
width: size + 24,
height: size + 24,
backgroundColor: 'rgba(255, 255, 255, 0.5)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(28, 28, 28, 0.08)',
boxShadow: '0 4px 20px rgba(28, 28, 28, 0.05)',
}}
>
<Icon className="w-5 h-5" style={{ color }} />
</div>
</motion.div>
);
}
interface ParticleRingProps {
size?: number;
color?: string;
delay?: number;
x?: number;
y?: number;
className?: string;
}
function ParticleRing({
size = 120,
color = 'rgba(196, 30, 58, 0.1)',
delay = 0,
x = 0,
y = 0,
className = ''
}: ParticleRingProps) {
return (
<motion.div
className={`absolute pointer-events-none ${className}`}
style={{
left: x,
top: y,
width: size,
height: size,
}}
initial={{ opacity: 0, scale: 0 }}
animate={{
opacity: [0, 1, 0.5],
scale: [0.5, 1.2, 0.8],
rotate: [0, 90, 180],
}}
transition={{
duration: 12,
delay,
repeat: Infinity,
ease: 'linear',
}}
>
<svg width={size} height={size} viewBox="0 0 120 120">
{[0, 60, 120, 180, 240, 300].map((angle, i) => {
const rad = (angle * Math.PI) / 180;
const px = 60 + Math.cos(rad) * 45;
const py = 60 + Math.sin(rad) * 45;
return (
<motion.circle
key={i}
cx={px}
cy={py}
r={3}
fill={color}
initial={{ opacity: 0 }}
animate={{
opacity: [0.3, 1, 0.3],
scale: [0.5, 1.5, 0.5],
}}
transition={{
duration: 4,
delay: delay + i * 0.3,
repeat: Infinity,
ease: 'easeInOut',
}}
/>
);
})}
<circle
cx={60}
cy={60}
r={50}
fill="none"
stroke={color}
strokeWidth="1"
strokeDasharray="5 5"
/>
</svg>
</motion.div>
);
}
interface GlowingDotProps {
size?: number;
color?: string;
delay?: number;
x?: number;
y?: number;
className?: string;
}
function GlowingDot({
size = 8,
color = '#C41E3A',
delay = 0,
x = 0,
y = 0,
className = ''
}: GlowingDotProps) {
return (
<motion.div
className={`absolute rounded-full pointer-events-none ${className}`}
style={{
left: x,
top: y,
width: size,
height: size,
backgroundColor: color,
boxShadow: `0 0 ${size * 2}px ${color}`,
}}
initial={{ opacity: 0, scale: 0 }}
animate={{
opacity: [0, 1, 0.5, 1],
scale: [0.5, 1.5, 0.8, 1.2],
}}
transition={{
duration: 3 + Math.random() * 2,
delay,
repeat: Infinity,
ease: 'easeInOut',
}}
/>
);
}
interface AdvancedFloatingEffectsProps {
variant?: 'minimal' | 'balanced' | 'rich' | 'parallax';
className?: string;
}
export function AdvancedFloatingEffects({
variant = 'balanced',
className = ''
}: AdvancedFloatingEffectsProps) {
const [isMounted, setIsMounted] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const { scrollY } = useScroll();
useEffect(() => {
setIsMounted(true);
}, []);
const config = {
minimal: { orbs: 2, icons: 3, rings: 0, lines: 2, dots: 5 },
balanced: { orbs: 3, icons: 5, rings: 1, lines: 4, dots: 8 },
rich: { orbs: 5, icons: 8, rings: 2, lines: 6, dots: 12 },
parallax: { orbs: 4, icons: 6, rings: 2, lines: 5, dots: 10 },
};
const { orbs, icons, rings, lines, dots } = config[variant];
const iconsList = [Cpu, Shield, Zap, Globe, FileText, TrendingUp, BarChart3, Users];
const elements = useMemo(() => {
if (!isMounted) {return [];}
const items = [];
const width = typeof window !== 'undefined' ? window.innerWidth : 1920;
const height = typeof window !== 'undefined' ? window.innerHeight : 1080;
for (let i = 0; i < orbs; i++) {
items.push({
type: 'orb',
id: `orb-${i}`,
props: {
size: 60 + Math.random() * 60,
color: i % 2 === 0 ? 'rgba(196, 30, 58, 0.08)' : 'rgba(28, 28, 28, 0.05)',
delay: i * 0.5,
x: width * 0.1 + (i * width * 0.35),
y: height * 0.15 + Math.random() * height * 0.5,
duration: 7 + Math.random() * 4,
icon: i % 3 === 0 ? iconsList[i % iconsList.length] : undefined,
},
parallaxDepth: 0.1 + i * 0.1,
});
}
for (let i = 0; i < icons; i++) {
items.push({
type: 'icon',
id: `icon-${i}`,
props: {
icon: iconsList[i % iconsList.length],
size: 20,
color: i % 2 === 0 ? '#C41E3A' : '#1C1C1C',
delay: i * 0.4,
x: width * 0.08 + (i * width * 0.12),
y: height * 0.1 + Math.random() * height * 0.65,
rotation: -15 + Math.random() * 30,
},
parallaxDepth: 0.2 + i * 0.05,
});
}
for (let i = 0; i < rings; i++) {
items.push({
type: 'ring',
id: `ring-${i}`,
props: {
size: 100 + Math.random() * 80,
color: i % 2 === 0 ? 'rgba(196, 30, 58, 0.1)' : 'rgba(28, 28, 28, 0.08)',
delay: i * 0.8,
x: width * 0.2 + (i * width * 0.4),
y: height * 0.2 + Math.random() * height * 0.4,
},
parallaxDepth: 0.05 + i * 0.1,
});
}
for (let i = 0; i < lines; i++) {
items.push({
type: 'line',
id: `line-${i}`,
props: {
startX: width * 0.05 + (i * width * 0.15),
startY: height * 0.1 + Math.random() * height * 0.7,
endX: width * 0.05 + (i * width * 0.15) + 80 + Math.random() * 120,
endY: height * 0.1 + Math.random() * height * 0.7,
color: i % 2 === 0 ? 'rgba(196, 30, 58, 0.15)' : 'rgba(28, 28, 28, 0.1)',
delay: i * 0.6,
duration: 5 + Math.random() * 3,
},
parallaxDepth: 0.15 + i * 0.05,
});
}
for (let i = 0; i < dots; i++) {
items.push({
type: 'dot',
id: `dot-${i}`,
props: {
size: 4 + Math.random() * 6,
color: i % 3 === 0 ? '#C41E3A' : i % 3 === 1 ? '#1C1C1C' : '#D4A574',
delay: i * 0.3,
x: Math.random() * width,
y: Math.random() * height,
},
parallaxDepth: 0.25 + i * 0.02,
});
}
return items;
}, [orbs, icons, rings, lines, dots, isMounted, iconsList]);
const getParallaxStyle = (depth: number) => {
if (variant !== 'parallax') {return {};}
const y = useTransform(scrollY, [0, 500], [0, -depth * 100]);
return { y };
};
return (
<div
ref={containerRef}
className={`absolute inset-0 pointer-events-none overflow-hidden ${className}`}
>
{elements.map((el) => {
const parallaxStyle = getParallaxStyle(el.parallaxDepth);
return (
<motion.div key={el.id} style={parallaxStyle}>
{el.type === 'orb' && <FloatingOrb {...el.props} />}
{el.type === 'icon' && <FloatingIcon {...el.props} />}
{el.type === 'ring' && <ParticleRing {...el.props} />}
{el.type === 'line' && <FloatingLine {...el.props} />}
{el.type === 'dot' && <GlowingDot {...el.props} />}
</motion.div>
);
})}
</div>
);
}
export default AdvancedFloatingEffects;
@@ -1,195 +0,0 @@
'use client';
import { motion, useReducedMotion } from 'framer-motion';
import { useEffect, useState } from 'react';
interface DataParticleFlowProps {
className?: string;
particleCount?: number;
color?: string;
intensity?: 'subtle' | 'normal' | 'prominent';
shape?: 'circle' | 'square' | 'triangle' | 'diamond' | 'star' | 'mixed';
effect?: 'default' | 'pulse' | 'glow' | 'trail';
}
interface Particle {
id: number;
x: number;
y: number;
size: number;
duration: number;
delay: number;
opacity: number;
moveRange: number;
shape: 'circle' | 'square' | 'triangle' | 'diamond' | 'star';
rotation: number;
}
export function DataParticleFlow({
className = '',
particleCount = 50,
color = '#C41E3A',
intensity = 'normal',
shape = 'circle',
effect = 'default',
}: DataParticleFlowProps) {
const prefersReducedMotion = useReducedMotion();
const [particles, setParticles] = useState<Particle[]>([]);
useEffect(() => {
const intensityConfig = {
subtle: { sizeMin: 3, sizeMax: 8, opacityMin: 0.2, opacityMax: 0.4, moveRange: 80 },
normal: { sizeMin: 6, sizeMax: 16, opacityMin: 0.4, opacityMax: 0.7, moveRange: 150 },
prominent: { sizeMin: 10, sizeMax: 24, opacityMin: 0.5, opacityMax: 0.9, moveRange: 200 },
};
const shapes: Particle['shape'][] = ['circle', 'square', 'triangle', 'diamond', 'star'];
const config = intensityConfig[intensity];
const generated: Particle[] = Array.from({ length: particleCount }, (_, i) => ({
id: i,
x: Math.random() * 100,
y: Math.random() * 100,
size: Math.random() * (config.sizeMax - config.sizeMin) + config.sizeMin,
duration: Math.random() * 12 + 8,
delay: Math.random() * 3,
opacity: Math.random() * (config.opacityMax - config.opacityMin) + config.opacityMin,
moveRange: config.moveRange,
shape: shape === 'mixed' ? (shapes[Math.floor(Math.random() * shapes.length)] ?? 'circle') : shape as Particle['shape'],
rotation: Math.random() * 360,
}));
setParticles(generated);
}, [particleCount, intensity, shape]);
const getShapeStyles = (particle: Particle): React.CSSProperties => {
const baseStyles: React.CSSProperties = {
width: particle.size,
height: particle.size,
left: `${particle.x}%`,
top: `${particle.y}%`,
willChange: prefersReducedMotion ? 'auto' : 'transform, opacity',
};
switch (particle.shape) {
case 'circle':
return {
...baseStyles,
borderRadius: '50%',
background: `radial-gradient(circle, ${color} 0%, ${color}80 40%, transparent 70%)`,
boxShadow: effect === 'glow' ? `0 0 ${particle.size * 2}px ${color}60` : 'none',
};
case 'square':
return {
...baseStyles,
borderRadius: '2px',
background: `linear-gradient(135deg, ${color} 0%, ${color}60 100%)`,
boxShadow: effect === 'glow' ? `0 0 ${particle.size}px ${color}40` : 'none',
};
case 'triangle':
return {
...baseStyles,
width: 0,
height: 0,
background: 'transparent',
borderLeft: `${particle.size / 2}px solid transparent`,
borderRight: `${particle.size / 2}px solid transparent`,
borderBottom: `${particle.size}px solid ${color}`,
filter: effect === 'glow' ? `drop-shadow(0 0 ${particle.size / 2}px ${color}60)` : 'none',
};
case 'diamond':
return {
...baseStyles,
transform: `rotate(45deg)`,
background: `linear-gradient(135deg, ${color} 0%, ${color}60 100%)`,
boxShadow: effect === 'glow' ? `0 0 ${particle.size}px ${color}40` : 'none',
};
case 'star':
return {
...baseStyles,
clipPath: 'polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)',
background: `radial-gradient(circle, ${color} 0%, ${color}80 100%)`,
filter: effect === 'glow' ? `drop-shadow(0 0 ${particle.size / 2}px ${color}60)` : 'none',
};
default:
return baseStyles;
}
};
const getAnimationVariants = (particle: Particle) => {
if (prefersReducedMotion) {
return { scale: 1, opacity: particle.opacity };
}
const baseAnimation = {
scale: [0, 2, 1.5, 2.5, 0],
opacity: [0, particle.opacity, particle.opacity * 0.8, particle.opacity, 0],
y: [0, -particle.moveRange * 0.5, -particle.moveRange, -particle.moveRange * 1.5, -particle.moveRange * 2],
x: [0, particle.moveRange * 0.3, -particle.moveRange * 0.2, particle.moveRange * 0.15, 0],
};
switch (effect) {
case 'pulse':
return {
...baseAnimation,
scale: [0, 1.5, 1, 1.8, 0],
};
case 'glow':
return {
...baseAnimation,
opacity: [0, particle.opacity, particle.opacity * 1.2, particle.opacity, 0],
};
case 'trail':
return {
...baseAnimation,
y: [particle.moveRange * 0.5, -particle.moveRange * 0.5, -particle.moveRange * 1.5, -particle.moveRange * 2.5, -particle.moveRange * 3],
};
default:
return baseAnimation;
}
};
return (
<div className={`absolute inset-0 overflow-hidden ${className}`} aria-hidden="true">
{particles.map((particle) => (
<motion.div
key={particle.id}
className="absolute"
style={getShapeStyles(particle)}
initial={{ scale: 0, opacity: 0 }}
animate={getAnimationVariants(particle)}
transition={{
duration: particle.duration,
repeat: Infinity,
ease: 'easeInOut',
delay: particle.delay,
}}
/>
))}
<svg className="absolute inset-0 w-full h-full opacity-15">
<defs>
<pattern id="dataGrid" width="60" height="60" patternUnits="userSpaceOnUse">
<circle cx="30" cy="30" r="1.5" fill={color} opacity="0.4" />
<circle cx="0" cy="0" r="1" fill={color} opacity="0.2" />
<circle cx="60" cy="0" r="1" fill={color} opacity="0.2" />
<circle cx="0" cy="60" r="1" fill={color} opacity="0.2" />
<circle cx="60" cy="60" r="1" fill={color} opacity="0.2" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#dataGrid)" />
</svg>
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-white/20" />
</div>
);
}
export default DataParticleFlow;
@@ -1,237 +0,0 @@
'use client';
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
interface FluidWaveBackgroundProps {
className?: string;
color1?: string;
color2?: string;
speed?: number;
intensity?: number;
noiseScale?: number;
mouseInfluence?: number;
}
export function FluidWaveBackground({
className = '',
color1 = '#C41E3A',
color2 = '#1C1C1C',
speed = 0.5,
intensity = 1.2,
noiseScale = 3.0,
mouseInfluence = 0.8
}: FluidWaveBackgroundProps) {
const containerRef = useRef<HTMLDivElement>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
const sceneRef = useRef<THREE.Scene | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const meshRef = useRef<THREE.Mesh | null>(null);
const animationRef = useRef<number | undefined>(undefined);
const mouseRef = useRef({ x: 0, y: 0, active: false });
const vertexShader = `
varying vec2 vUv;
varying float vElevation;
uniform float uTime;
uniform float uIntensity;
uniform float uNoiseScale;
uniform vec2 uMouse;
uniform float uMouseInfluence;
uniform float uMouseActive;
float random(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
}
float noise(vec2 st) {
vec2 i = floor(st);
vec2 f = fract(st);
float a = random(i);
float b = random(i + vec2(1.0, 0.0));
float c = random(i + vec2(0.0, 1.0));
float d = random(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
}
float fbm(vec2 st) {
float value = 0.0;
float amplitude = 0.5;
for (int i = 0; i < 4; i++) {
value += amplitude * noise(st);
st *= 2.0;
amplitude *= 0.5;
}
return value;
}
void main() {
vUv = uv;
vec2 pos = position.xy * uNoiseScale;
float elevation = fbm(pos + uTime * 0.1);
if (uMouseActive > 0.5) {
float dist = distance(uv, uMouse);
float mouseEffect = smoothstep(0.3, 0.0, dist) * uMouseInfluence;
elevation += mouseEffect * sin(uTime * 2.0 + dist * 10.0);
}
vElevation = elevation * uIntensity;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position.x, position.y, vElevation, 1.0);
}
`;
const fragmentShader = `
varying vec2 vUv;
varying float vElevation;
uniform vec3 uColor1;
uniform vec3 uColor2;
uniform float uTime;
void main() {
float mixFactor = smoothstep(-0.5, 0.5, vElevation);
vec3 color = mix(uColor2, uColor1, mixFactor);
float highlight = smoothstep(0.3, 0.5, vElevation) * 0.3;
color += vec3(highlight);
float alpha = 0.6 + vElevation * 0.2;
gl_FragColor = vec4(color, alpha);
}
`;
useEffect(() => {
if (!containerRef.current) {return;}
const container = containerRef.current;
const width = container.clientWidth;
const height = container.clientHeight;
const scene = new THREE.Scene();
sceneRef.current = scene;
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
camera.position.z = 5;
cameraRef.current = camera;
const renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
powerPreference: 'high-performance'
});
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
container.appendChild(renderer.domElement);
rendererRef.current = renderer;
const geometry = new THREE.PlaneGeometry(10, 10, 128, 128);
const uniforms = {
uTime: { value: 0 },
uColor1: { value: new THREE.Color(color1) },
uColor2: { value: new THREE.Color(color2) },
uIntensity: { value: intensity },
uNoiseScale: { value: noiseScale },
uMouse: { value: new THREE.Vector2(0, 0) },
uMouseInfluence: { value: mouseInfluence },
uMouseActive: { value: 0 }
};
const material = new THREE.ShaderMaterial({
uniforms,
vertexShader,
fragmentShader,
transparent: true,
side: THREE.DoubleSide
});
const mesh = new THREE.Mesh(geometry, material);
mesh.rotation.x = -Math.PI / 4;
scene.add(mesh);
meshRef.current = mesh;
const animate = (time: number) => {
if (meshRef.current && rendererRef.current && sceneRef.current && cameraRef.current) {
const material = meshRef.current.material as THREE.ShaderMaterial;
if (material.uniforms.uTime) {
material.uniforms.uTime.value = time * speed;
}
if (mouseRef.current.active) {
if (material.uniforms.uMouse) {
material.uniforms.uMouse.value.x = mouseRef.current.x;
material.uniforms.uMouse.value.y = mouseRef.current.y;
}
if (material.uniforms.uMouseActive) {
material.uniforms.uMouseActive.value = 1.0;
}
} else {
if (material.uniforms.uMouseActive) {
material.uniforms.uMouseActive.value = 0.0;
}
}
rendererRef.current.render(sceneRef.current, cameraRef.current);
animationRef.current = requestAnimationFrame(animate);
}
};
const handleMouseMove = (event: MouseEvent) => {
if (!containerRef.current) {return;}
const rect = containerRef.current.getBoundingClientRect();
mouseRef.current.x = (event.clientX - rect.left) / rect.width;
mouseRef.current.y = 1.0 - (event.clientY - rect.top) / rect.height;
mouseRef.current.active = true;
};
const handleMouseLeave = () => {
mouseRef.current.active = false;
};
containerRef.current.addEventListener('mousemove', handleMouseMove);
containerRef.current.addEventListener('mouseleave', handleMouseLeave);
animate(0);
const handleResize = () => {
if (!containerRef.current || !cameraRef.current || !rendererRef.current) {return;}
const newWidth = containerRef.current.clientWidth;
const newHeight = containerRef.current.clientHeight;
cameraRef.current.aspect = newWidth / newHeight;
cameraRef.current.updateProjectionMatrix();
rendererRef.current.setSize(newWidth, newHeight);
};
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
containerRef.current?.removeEventListener('mousemove', handleMouseMove);
containerRef.current?.removeEventListener('mouseleave', handleMouseLeave);
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
if (rendererRef.current) {
rendererRef.current.dispose();
container.removeChild(rendererRef.current.domElement);
}
if (meshRef.current) {
meshRef.current.geometry.dispose();
(meshRef.current.material as THREE.ShaderMaterial).dispose();
}
};
}, [color1, color2, speed, intensity, noiseScale, vertexShader, fragmentShader]);
return (
<div
ref={containerRef}
className={`absolute inset-0 pointer-events-none ${className}`}
/>
);
}
export default FluidWaveBackground;
@@ -1,163 +0,0 @@
'use client';
import { motion, useReducedMotion } from 'framer-motion';
import { useEffect, useState } from 'react';
interface GeometricAbstractProps {
className?: string;
variant?: 'minimal' | 'complex' | 'dynamic';
color?: string;
}
interface Shape {
id: number;
type: 'circle' | 'square' | 'triangle';
x: number;
y: number;
size: number;
rotation: number;
opacity: number;
duration: number;
delay: number;
}
export function GeometricAbstract({
className = '',
variant = 'minimal',
color = '#C41E3A',
}: GeometricAbstractProps) {
const prefersReducedMotion = useReducedMotion();
const [shapes, setShapes] = useState<Shape[]>([]);
useEffect(() => {
const count = variant === 'complex' ? 15 : variant === 'dynamic' ? 20 : 8;
const generated: Shape[] = Array.from({ length: count }, (_, i) => ({
id: i,
type: ['circle', 'square', 'triangle'][Math.floor(Math.random() * 3)] as Shape['type'],
x: Math.random() * 100,
y: Math.random() * 100,
size: Math.random() * 100 + 50,
rotation: Math.random() * 360,
opacity: Math.random() * 0.08 + 0.02,
duration: Math.random() * 20 + 15,
delay: Math.random() * 3,
}));
setShapes(generated);
}, [variant]);
const renderShape = (shape: Shape) => {
const baseStyle = {
position: 'absolute' as const,
left: `${shape.x}%`,
top: `${shape.y}%`,
width: shape.size,
height: shape.size,
opacity: shape.opacity,
};
switch (shape.type) {
case 'circle':
return (
<motion.div
key={shape.id}
style={{
...baseStyle,
borderRadius: '50%',
border: `1px solid ${color}`,
background: `radial-gradient(circle, ${color}10 0%, transparent 70%)`,
}}
animate={
prefersReducedMotion
? {}
: {
scale: [1, 1.2, 1],
rotate: [0, 180, 360],
opacity: [shape.opacity, shape.opacity * 1.5, shape.opacity],
}
}
transition={{
duration: shape.duration,
repeat: Infinity,
ease: 'easeInOut',
delay: shape.delay,
}}
/>
);
case 'square':
return (
<motion.div
key={shape.id}
style={{
...baseStyle,
border: `1px solid ${color}`,
background: `linear-gradient(135deg, ${color}08 0%, transparent 100%)`,
}}
animate={
prefersReducedMotion
? {}
: {
rotate: [shape.rotation, shape.rotation + 90, shape.rotation],
scale: [1, 1.1, 1],
opacity: [shape.opacity, shape.opacity * 1.3, shape.opacity],
}
}
transition={{
duration: shape.duration,
repeat: Infinity,
ease: 'easeInOut',
delay: shape.delay,
}}
/>
);
case 'triangle':
return (
<motion.div
key={shape.id}
style={{
...baseStyle,
clipPath: 'polygon(50% 0%, 0% 100%, 100% 100%)',
background: `linear-gradient(135deg, ${color}10 0%, transparent 100%)`,
}}
animate={
prefersReducedMotion
? {}
: {
rotate: [0, 120, 240, 360],
scale: [1, 1.15, 1],
}
}
transition={{
duration: shape.duration,
repeat: Infinity,
ease: 'easeInOut',
delay: shape.delay,
}}
/>
);
default:
return null;
}
};
return (
<div className={`absolute inset-0 overflow-hidden ${className}`} aria-hidden="true">
{shapes.map(renderShape)}
<svg className="absolute inset-0 w-full h-full opacity-5">
<defs>
<pattern id="geoGrid" width="60" height="60" patternUnits="userSpaceOnUse">
<path d="M 60 0 L 0 0 0 60" fill="none" stroke={color} strokeWidth="0.5" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#geoGrid)" />
</svg>
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-white/40" />
</div>
);
}
export default GeometricAbstract;
@@ -1,56 +0,0 @@
'use client';
import { motion } from 'framer-motion';
interface GeometricShapeProps {
className?: string;
color?: string;
}
export function GeometricShapes({
className = '',
color = '#C41E3A'
}: GeometricShapeProps) {
const shapes = [
{ type: 'circle', size: 120, x: 10, y: 15, delay: 0 },
{ type: 'square', size: 80, x: 80, y: 20, delay: 1 },
{ type: 'triangle', size: 60, x: 70, y: 60, delay: 2 },
{ type: 'circle', size: 40, x: 20, y: 70, delay: 3 },
{ type: 'square', size: 50, x: 85, y: 75, delay: 4 }
];
return (
<div className={`absolute inset-0 pointer-events-none ${className}`}>
{shapes.map((shape, index) => (
<motion.div
key={index}
className="absolute border-2"
style={{
borderColor: `${color}20`,
width: shape.size,
height: shape.size,
left: `${shape.x}%`,
top: `${shape.y}%`,
borderRadius: shape.type === 'circle' ? '50%' : '0',
transform: shape.type === 'triangle' ? 'rotate(0deg)' : 'rotate(0deg)'
}}
initial={{ opacity: 0, scale: 0, rotate: 0 }}
animate={{
opacity: [0, 0.15, 0.15, 0],
scale: [0, 1, 1, 0],
rotate: [0, 45, 45, 0]
}}
transition={{
duration: 10,
delay: shape.delay,
repeat: Infinity,
ease: 'easeInOut',
times: [0, 0.2, 0.8, 1]
}}
/>
))}
</div>
);
}
export default GeometricShapes;
-72
View File
@@ -1,72 +0,0 @@
'use client';
import { motion } from 'framer-motion';
import { useState, useEffect } from 'react';
interface GlowEffectProps {
className?: string;
color?: string;
count?: number;
}
export function GlowEffect({
className = '',
color = '#C41E3A',
count = 3
}: GlowEffectProps) {
const [glows, setGlows] = useState<Array<{
id: number;
size: number;
x: number;
y: number;
delay: number;
}>>([]);
useEffect(() => {
const generatedGlows = Array.from({ length: count }, (_, i) => ({
id: i,
size: 150 + Math.random() * 100,
x: Math.random() * 100,
y: Math.random() * 100,
delay: i * 2
}));
setGlows(generatedGlows);
}, [count]);
if (glows.length === 0) {
return <div className={`absolute inset-0 pointer-events-none ${className}`} />;
}
return (
<div className={`absolute inset-0 pointer-events-none ${className}`}>
{glows.map((glow) => (
<motion.div
key={glow.id}
className="absolute rounded-full"
style={{
width: glow.size,
height: glow.size,
background: `radial-gradient(circle, ${color} 0%, transparent 70%)`,
left: `${glow.x}%`,
top: `${glow.y}%`,
transform: 'translate(-50%, -50%)'
}}
initial={{ opacity: 0, scale: 0.5 }}
animate={{
opacity: [0, 0.4, 0.4, 0],
scale: [0.5, 1.2, 1.2, 0.5]
}}
transition={{
duration: 8,
delay: glow.delay,
repeat: Infinity,
ease: 'easeInOut',
times: [0, 0.3, 0.7, 1]
}}
/>
))}
</div>
);
}
export default GlowEffect;
@@ -1,37 +0,0 @@
'use client';
import { motion } from 'framer-motion';
interface GradientAnimationProps {
className?: string;
colors?: string[];
duration?: number;
}
export function GradientAnimation({
className = '',
colors = ['#C41E3A', '#1C1C1C', '#D4A574'],
duration = 8
}: GradientAnimationProps) {
return (
<motion.div
className={`absolute inset-0 ${className}`}
animate={{
background: colors.map((color, i) =>
`${color} ${100 / colors.length * i}% ${100 / colors.length * (i + 1)}%`
).join(', ')
}}
transition={{
duration,
repeat: Infinity,
ease: 'linear'
}}
style={{
backgroundSize: '400% 400%',
backgroundPosition: '0% 50%'
}}
/>
);
}
export default GradientAnimation;
@@ -1,70 +0,0 @@
'use client';
import { motion, useReducedMotion } from 'framer-motion';
interface GradientFlowOptimizedProps {
className?: string;
colors?: string[];
duration?: number;
variant?: 'smooth' | 'dynamic' | 'minimal';
}
export function GradientFlowOptimized({
className = '',
colors = ['#FAFAFA', '#FFE8EC', '#FFF0F3', '#F5F5F5', '#FFD6DD'],
duration = 15,
variant = 'smooth',
}: GradientFlowOptimizedProps) {
const prefersReducedMotion = useReducedMotion();
const gradientStyle = {
background: `linear-gradient(135deg, ${colors.join(', ')})`,
backgroundSize: '400% 400%',
};
const variants = {
smooth: {
backgroundPosition: ['0% 50%', '100% 50%', '0% 50%'],
},
dynamic: {
backgroundPosition: ['0% 0%', '100% 100%', '0% 50%', '100% 0%', '0% 0%'],
},
minimal: {
backgroundPosition: ['0% 50%', '50% 50%', '0% 50%'],
},
};
if (prefersReducedMotion) {
return (
<div
className={`absolute inset-0 ${className}`}
style={{
...gradientStyle,
backgroundPosition: '50% 50%',
}}
aria-hidden="true"
/>
);
}
return (
<div className={`absolute inset-0 overflow-hidden ${className}`} aria-hidden="true">
<motion.div
className="absolute inset-0"
style={{
...gradientStyle,
willChange: 'background-position',
}}
animate={variants[variant]}
transition={{
duration,
repeat: Infinity,
ease: 'linear',
}}
/>
<div className="absolute inset-0 backdrop-blur-[100px] opacity-50" />
</div>
);
}
export default GradientFlowOptimized;
@@ -1,45 +0,0 @@
import { render } from '@testing-library/react';
import '@testing-library/jest-dom';
import { GradientFlow } from './gradient-flow';
jest.mock('framer-motion', () => ({
motion: {
div: ({ className }: { className?: string }) => <div className={className} data-testid="gradient-flow" />,
},
}));
describe('GradientFlow', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render gradient flow component', () => {
const { getByTestId } = render(<GradientFlow />);
const component = getByTestId('gradient-flow');
expect(component).toBeInTheDocument();
});
it('should apply custom className', () => {
const { getByTestId } = render(<GradientFlow className="custom-class" />);
const component = getByTestId('gradient-flow');
expect(component).toHaveClass('custom-class');
});
});
describe('Props', () => {
it('should accept custom colors', () => {
const { getByTestId } = render(
<GradientFlow colors={['#ff0000', '#00ff00', '#0000ff']} />
);
const component = getByTestId('gradient-flow');
expect(component).toBeInTheDocument();
});
it('should accept custom duration', () => {
const { getByTestId } = render(<GradientFlow duration={20} />);
const component = getByTestId('gradient-flow');
expect(component).toBeInTheDocument();
});
});
});
-35
View File
@@ -1,35 +0,0 @@
'use client';
import { motion } from 'framer-motion';
interface GradientFlowProps {
className?: string;
colors?: string[];
duration?: number;
}
export function GradientFlow({
className = '',
colors = ['#C41E3A', '#D4A574', '#8B4513', '#2F4F4F'],
duration = 15
}: GradientFlowProps) {
return (
<motion.div
className={`absolute inset-0 ${className}`}
style={{
background: `linear-gradient(-45deg, ${colors.join(', ')})`,
backgroundSize: '400% 400%'
}}
animate={{
backgroundPosition: ['0% 50%', '100% 50%', '0% 50%']
}}
transition={{
duration,
repeat: Infinity,
ease: 'linear'
}}
/>
);
}
export default GradientFlow;
-57
View File
@@ -1,57 +0,0 @@
'use client';
import { motion } from 'framer-motion';
interface GradientGridProps {
className?: string;
color?: string;
gridSize?: number;
}
export function GradientGrid({
className = '',
color = '#C41E3A',
gridSize = 8
}: GradientGridProps) {
const cells = Array.from({ length: gridSize }, (_, row) =>
Array.from({ length: gridSize }, (_, col) => ({
row,
col,
delay: (row + col) * 0.1
}))
).flat();
return (
<div
className={`absolute inset-0 pointer-events-none ${className}`}
style={{
display: 'grid',
gridTemplateColumns: `repeat(${gridSize}, 1fr)`,
gridTemplateRows: `repeat(${gridSize}, 1fr)`,
gap: '1px'
}}
>
{cells.map((cell, index) => (
<motion.div
key={index}
style={{
background: `linear-gradient(135deg, ${color}05 0%, ${color}10 100%)`
}}
initial={{ opacity: 0 }}
animate={{
opacity: [0, 0.3, 0.3, 0]
}}
transition={{
duration: 6,
delay: cell.delay,
repeat: Infinity,
ease: 'easeInOut',
times: [0, 0.3, 0.7, 1]
}}
/>
))}
</div>
);
}
export default GradientGrid;
-92
View File
@@ -1,92 +0,0 @@
'use client';
import { motion, useReducedMotion } from 'framer-motion';
import { useEffect, useState } from 'react';
interface GradientOrbsProps {
className?: string;
count?: number;
}
interface Orb {
id: number;
size: number;
x: number;
y: number;
color: string;
duration: number;
delay: number;
}
const colorPalette = [
'rgba(196, 30, 58, 0.15)',
'rgba(255, 232, 236, 0.2)',
'rgba(255, 240, 243, 0.18)',
'rgba(245, 245, 245, 0.15)',
'rgba(255, 214, 221, 0.2)',
'rgba(224, 74, 104, 0.12)',
];
export function GradientOrbs({ className = '', count = 5 }: GradientOrbsProps) {
const prefersReducedMotion = useReducedMotion();
const [orbs, setOrbs] = useState<Orb[]>([]);
useEffect(() => {
const generatedOrbs: Orb[] = Array.from({ length: count }, (_, i) => ({
id: i,
size: Math.random() * 400 + 200,
x: Math.random() * 100,
y: Math.random() * 100,
color: colorPalette[i % colorPalette.length] ?? 'rgba(196, 30, 58, 0.15)',
duration: Math.random() * 20 + 15,
delay: Math.random() * 5,
}));
setOrbs(generatedOrbs);
}, [count]);
return (
<div
className={`absolute inset-0 overflow-hidden ${className}`}
aria-hidden="true"
>
{orbs.map((orb) => (
<motion.div
key={orb.id}
className="absolute rounded-full"
style={{
width: orb.size,
height: orb.size,
left: `${orb.x}%`,
top: `${orb.y}%`,
background: `radial-gradient(circle, ${orb.color} 0%, transparent 70%)`,
willChange: prefersReducedMotion ? 'auto' : 'transform',
filter: 'blur(60px)',
}}
initial={{
x: '-50%',
y: '-50%',
scale: 1,
}}
animate={
prefersReducedMotion
? {}
: {
x: ['-50%', '-40%', '-60%', '-50%'],
y: ['-50%', '-60%', '-40%', '-50%'],
scale: [1, 1.2, 0.9, 1],
}
}
transition={{
duration: orb.duration,
repeat: Infinity,
ease: 'easeInOut',
delay: orb.delay,
}}
/>
))}
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-white/20 pointer-events-none" />
</div>
);
}
export default GradientOrbs;
-70
View File
@@ -1,70 +0,0 @@
'use client';
import { motion } from 'framer-motion';
import { useState, useEffect } from 'react';
interface GridLinesProps {
className?: string;
color?: string;
density?: number;
}
export function GridLines({
className = '',
color = '#C41E3A',
density = 6
}: GridLinesProps) {
const [lines, setLines] = useState<Array<{
id: number;
delay: number;
duration: number;
top: number;
width: number;
}>>([]);
useEffect(() => {
const generatedLines = Array.from({ length: density }, (_, i) => ({
id: i,
delay: i * 0.5,
duration: 6 + Math.random() * 4,
top: 20 + Math.random() * 60,
width: 30 + Math.random() * 40
}));
setLines(generatedLines);
}, [density]);
if (lines.length === 0) {
return <div className={`absolute inset-0 pointer-events-none ${className}`} />;
}
return (
<div className={`absolute inset-0 pointer-events-none ${className}`}>
{lines.map((line) => (
<motion.div
key={line.id}
className="absolute h-px"
style={{
backgroundColor: `${color}10`,
left: `${(line.id / density) * 100}%`,
top: `${line.top}%`,
width: `${line.width}%`
}}
initial={{ opacity: 0, scaleX: 0 }}
animate={{
opacity: [0, 0.2, 0.2, 0],
scaleX: [0, 1, 1, 0]
}}
transition={{
duration: line.duration,
delay: line.delay,
repeat: Infinity,
ease: 'easeInOut',
times: [0, 0.3, 0.7, 1]
}}
/>
))}
</div>
);
}
export default GridLines;
-21
View File
@@ -1,21 +0,0 @@
export { DataParticleFlow } from './data-particle-flow';
export { SubtleDots } from './subtle-dots';
export { SubtleParticles } from './subtle-particles';
export { ParticleGalaxy } from './particle-galaxy';
export { MouseInteractiveParticles } from './mouse-interactive-particles';
export { GradientFlow } from './gradient-flow';
export { GradientAnimation } from './gradient-animation';
export { GradientOrbs } from './gradient-orbs';
export { GradientGrid } from './gradient-grid';
export { TechGridFlow } from './tech-grid-flow';
export { MeshGradient } from './mesh-gradient';
export { InkTechFusion } from './ink-tech-fusion';
export { GridLines } from './grid-lines';
export { GlowEffect } from './glow-effect';
export { GeometricShapes } from './geometric-shapes';
export { GeometricAbstract } from './geometric-abstract';
export { FluidWaveBackground } from './fluid-wave-background';
export { AdvancedFloatingEffects } from './advanced-floating-effects';
export { ParallaxEffect } from './parallax-effect';
export { SealAnimationEnhanced } from './seal-animation-enhanced';
export { InkDataMorph } from './ink-data-morph';
@@ -1,198 +0,0 @@
import { render } from '@testing-library/react';
import '@testing-library/jest-dom';
import { InkDataMorph } from './ink-data-morph';
jest.mock('@/hooks/use-reduced-motion', () => ({
useReducedMotion: () => false,
}));
class MockCanvasContext {
fillStyle = '';
strokeStyle = '';
lineWidth = 0;
lineCap = '';
globalAlpha = 1;
globalCompositeOperation = 'source-over';
beginPath = jest.fn();
arc = jest.fn();
ellipse = jest.fn();
fill = jest.fn();
stroke = jest.fn();
moveTo = jest.fn();
lineTo = jest.fn();
quadraticCurveTo = jest.fn();
closePath = jest.fn();
save = jest.fn();
restore = jest.fn();
clearRect = jest.fn();
fillRect = jest.fn();
setTransform = jest.fn();
translate = jest.fn();
rotate = jest.fn();
createRadialGradient = jest.fn(() => ({
addColorStop: jest.fn(),
}));
createLinearGradient = jest.fn(() => ({
addColorStop: jest.fn(),
}));
createPattern = jest.fn(() => ({}));
}
describe('InkDataMorph', () => {
let mockContext: MockCanvasContext;
beforeEach(() => {
jest.clearAllMocks();
mockContext = new MockCanvasContext();
HTMLCanvasElement.prototype.getContext = jest.fn().mockReturnValue(mockContext);
Object.defineProperty(HTMLCanvasElement.prototype, 'width', {
configurable: true,
value: 1920,
writable: true,
});
Object.defineProperty(HTMLCanvasElement.prototype, 'height', {
configurable: true,
value: 1080,
writable: true,
});
});
describe('Rendering', () => {
it('should render a canvas element', () => {
const { container } = render(
<div style={{ width: 960, height: 540 }}>
<InkDataMorph />
</div>
);
const canvas = container.querySelector('canvas');
expect(canvas).toBeInTheDocument();
});
it('should apply custom className', () => {
const { container } = render(
<div style={{ width: 960, height: 540 }}>
<InkDataMorph className="custom-class" />
</div>
);
const canvas = container.querySelector('canvas');
expect(canvas).toHaveClass('custom-class');
});
it('should have aria-hidden attribute', () => {
const { container } = render(
<div style={{ width: 960, height: 540 }}>
<InkDataMorph />
</div>
);
const canvas = container.querySelector('canvas');
expect(canvas).toHaveAttribute('aria-hidden', 'true');
});
it('should have pointer-events-none class', () => {
const { container } = render(
<div style={{ width: 960, height: 540 }}>
<InkDataMorph />
</div>
);
const canvas = container.querySelector('canvas');
expect(canvas).toHaveClass('pointer-events-none');
});
});
describe('Canvas initialization', () => {
it('should render canvas with correct dimensions', () => {
const { container } = render(
<div style={{ width: 960, height: 540 }}>
<InkDataMorph />
</div>
);
const canvas = container.querySelector('canvas');
expect(canvas).toBeInTheDocument();
});
});
describe('Responsive behavior', () => {
it('should render on mobile viewport with reduced effects', () => {
const { container } = render(
<div style={{ width: 375, height: 667 }}>
<InkDataMorph />
</div>
);
const canvas = container.querySelector('canvas');
expect(canvas).toBeInTheDocument();
});
it('should render on tablet viewport', () => {
const { container } = render(
<div style={{ width: 900, height: 600 }}>
<InkDataMorph />
</div>
);
const canvas = container.querySelector('canvas');
expect(canvas).toBeInTheDocument();
});
it('should render on desktop viewport with full effects', () => {
const { container } = render(
<div style={{ width: 1440, height: 900 }}>
<InkDataMorph />
</div>
);
const canvas = container.querySelector('canvas');
expect(canvas).toBeInTheDocument();
});
});
describe('Ink wash enhancement', () => {
it('should render without errors on mobile without feibai', () => {
const { container } = render(
<div style={{ width: 375, height: 667 }}>
<InkDataMorph />
</div>
);
const canvas = container.querySelector('canvas');
expect(canvas).toBeInTheDocument();
});
it('should render without errors on tablet with feibai', () => {
const { container } = render(
<div style={{ width: 900, height: 600 }}>
<InkDataMorph />
</div>
);
const canvas = container.querySelector('canvas');
expect(canvas).toBeInTheDocument();
});
it('should render without errors on desktop with full effects', () => {
const { container } = render(
<div style={{ width: 1440, height: 900 }}>
<InkDataMorph />
</div>
);
const canvas = container.querySelector('canvas');
expect(canvas).toBeInTheDocument();
});
it('should render canvas with correct aria attributes', () => {
const { container } = render(
<div style={{ width: 1440, height: 900 }}>
<InkDataMorph />
</div>
);
const canvas = container.querySelector('canvas');
expect(canvas).toHaveAttribute('aria-hidden', 'true');
});
it('should accept custom className prop', () => {
const { container } = render(
<div style={{ width: 1440, height: 900 }}>
<InkDataMorph className="ink-enhanced" />
</div>
);
const canvas = container.querySelector('canvas');
expect(canvas).toHaveClass('ink-enhanced');
});
});
});
-913
View File
@@ -1,913 +0,0 @@
'use client';
import { useEffect, useRef, useCallback } from 'react';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
interface InkDataMorphProps {
className?: string;
}
interface TrailPoint {
x: number;
y: number;
}
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
radius: number;
initialRadius: number;
dataRadius: number;
opacity: number;
toneIndex: number;
rotation: number;
scaleX: number;
scaleY: number;
isSplash: boolean;
phase: 'spreading' | 'settling' | 'morphing' | 'complete' | 'fading';
spreadTime: number;
maxSpreadTime: number;
settleTime: number;
morphProgress: number;
targetX: number;
targetY: number;
delay: number;
age: number;
trail: TrailPoint[];
seed1: number;
seed2: number;
wobbleFactor: number;
prevVx: number;
prevVy: number;
inkLayerCount: number;
}
interface ResponsiveConfig {
particleCount: number;
centers: { x: number; y: number }[];
centerDistribution: number[];
targetXRange: [number, number];
targetYRange: [number, number];
connectionDistance: number;
glowScale: number;
gradientLayers: number;
maxTrail: number;
splashRatio: number;
bgOrbScale: number;
inkLayers: number;
inkRadiusScale: number;
inkStringThreshold: number;
showFeibai: boolean;
paperTextureSize: number;
wobbleDetail: number;
}
const TONES = [
{ hex: '#1C1C1C', rgb: '28, 28, 28', weight: 0.15 },
{ hex: '#4A4A4A', rgb: '74, 74, 74', weight: 0.25 },
{ hex: '#8C8C8C', rgb: '140, 140, 140', weight: 0.30 },
{ hex: '#C41E3A', rgb: '196, 30, 58', weight: 0.10 },
{ hex: '#E8707A', rgb: '232, 112, 122', weight: 0.20 },
] as const;
const ACCENT_TONES = [3, 4];
function pickToneIndex(isSplash: boolean): number {
if (isSplash) {
return Math.random() < 0.4 ? 0 : 1;
}
const r = Math.random();
let cumulative = 0;
for (let i = 0; i < TONES.length; i++) {
const tone = TONES[i];
if (!tone) {
continue;
}
cumulative += tone.weight;
if (r < cumulative) {
return i;
}
}
return 2;
}
function getResponsiveConfig(W: number): ResponsiveConfig {
if (W < 768) {
return {
particleCount: 70,
centers: [{ x: 0.65, y: 0.3 }],
centerDistribution: [1],
targetXRange: [0.55, 0.9],
targetYRange: [0.1, 0.5],
connectionDistance: 60,
glowScale: 0.5,
gradientLayers: 2,
maxTrail: 2,
splashRatio: 0,
bgOrbScale: 0.45,
inkLayers: 3,
inkRadiusScale: 1.2,
inkStringThreshold: 0.35,
showFeibai: false,
paperTextureSize: 128,
wobbleDetail: 16,
};
}
if (W < 1024) {
return {
particleCount: 120,
centers: [
{ x: 0.68, y: 0.3 },
{ x: 0.3, y: 0.6 },
],
centerDistribution: [0.65, 0.35],
targetXRange: [0.5, 0.9],
targetYRange: [0.12, 0.6],
connectionDistance: 80,
glowScale: 0.75,
gradientLayers: 2,
maxTrail: 4,
splashRatio: 0.05,
bgOrbScale: 0.7,
inkLayers: 3,
inkRadiusScale: 1.1,
inkStringThreshold: 0.4,
showFeibai: true,
paperTextureSize: 128,
wobbleDetail: 20,
};
}
return {
particleCount: 180,
centers: [
{ x: 0.72, y: 0.3 },
{ x: 0.25, y: 0.65 },
],
centerDistribution: [0.67, 0.33],
targetXRange: [0.5, 0.9],
targetYRange: [0.12, 0.65],
connectionDistance: 100,
glowScale: 1.0,
gradientLayers: 3,
maxTrail: 6,
splashRatio: 0.1,
bgOrbScale: 1.0,
inkLayers: 4,
inkRadiusScale: 1.0,
inkStringThreshold: 0.5,
showFeibai: true,
paperTextureSize: 256,
wobbleDetail: 24,
};
}
function generatePaperTexture(size: number): HTMLCanvasElement {
const offscreen = document.createElement('canvas');
offscreen.width = size;
offscreen.height = size;
const octx = offscreen.getContext('2d');
if (!octx) {
return offscreen;
}
octx.fillStyle = '#FAFAF5';
octx.fillRect(0, 0, size, size);
for (let i = 0; i < size * size * 0.15; i++) {
const x = Math.random() * size;
const y = Math.random() * size;
const gray = 200 + Math.floor(Math.random() * 40);
const alpha = 0.02 + Math.random() * 0.04;
octx.fillStyle = `rgba(${gray}, ${gray}, ${gray}, ${alpha})`;
octx.fillRect(x, y, 1, 1);
}
octx.strokeStyle = 'rgba(180, 175, 168, 0.015)';
octx.lineWidth = 0.5;
for (let i = 0; i < size * 0.3; i++) {
const y = Math.random() * size;
const startX = Math.random() * size * 0.3;
const endX = startX + size * (0.1 + Math.random() * 0.4);
octx.beginPath();
octx.moveTo(startX, y);
octx.lineTo(Math.min(endX, size), y + (Math.random() - 0.5) * 2);
octx.stroke();
}
return offscreen;
}
function drawPaperTexture(
ctx: CanvasRenderingContext2D,
W: number,
H: number,
texture: HTMLCanvasElement,
): void {
const pattern = ctx.createPattern(texture, 'repeat');
if (!pattern) {
return;
}
ctx.save();
ctx.fillStyle = pattern;
ctx.globalAlpha = 0.03;
ctx.fillRect(0, 0, W, H);
ctx.restore();
}
function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3);
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
function updateParticle(p: Particle, maxTrail: number): void {
p.age++;
if (p.age < p.delay) {
return;
}
if (p.phase === 'spreading') {
if (maxTrail > 0) {
p.trail.push({ x: p.x, y: p.y });
if (p.trail.length > maxTrail) {
p.trail.shift();
}
}
p.prevVx = p.vx;
p.prevVy = p.vy;
p.x += p.vx;
p.y += p.vy;
p.vx *= 0.98;
p.vy *= 0.98;
p.spreadTime++;
const spreadRatio = p.spreadTime / p.maxSpreadTime;
if (spreadRatio < 0.3) {
p.radius = p.initialRadius * (1 + spreadRatio * 2);
} else {
p.radius = p.initialRadius * lerp(1.6, 1, easeOutCubic((spreadRatio - 0.3) / 0.7));
}
if (p.spreadTime >= p.maxSpreadTime) {
if (p.isSplash) {
p.phase = 'fading';
} else {
p.phase = 'settling';
}
}
} else if (p.phase === 'settling') {
p.settleTime++;
p.opacity = Math.max(0.2, p.opacity - 0.002);
p.x += (Math.random() - 0.5) * 0.15;
p.y += (Math.random() - 0.5) * 0.15;
if (p.settleTime > 60) {
p.phase = 'morphing';
}
} else if (p.phase === 'morphing') {
p.morphProgress = Math.min(1, p.morphProgress + 0.006);
const t = easeInOutCubic(p.morphProgress);
p.x = lerp(p.x, p.targetX, t * 0.025);
p.y = lerp(p.y, p.targetY, t * 0.025);
p.radius = lerp(p.radius, p.dataRadius, t * 0.02);
p.opacity = lerp(p.opacity, 0.35, t * 0.008);
if (p.morphProgress >= 1) {
p.phase = 'complete';
}
} else if (p.phase === 'complete') {
p.opacity = lerp(p.opacity, 0.35, 0.01);
} else if (p.phase === 'fading') {
p.opacity -= 0.01;
p.radius *= 0.98;
if (p.opacity <= 0) {
p.opacity = 0;
p.phase = 'complete';
}
}
}
function drawTrail(
ctx: CanvasRenderingContext2D,
p: Particle,
rgb: string,
): void {
if (p.trail.length < 2) {
return;
}
ctx.save();
ctx.lineCap = 'round';
for (let i = 1; i < p.trail.length; i++) {
const prev = p.trail[i - 1];
const curr = p.trail[i];
if (!prev || !curr) {
continue;
}
const trailAlpha = (i / p.trail.length) * p.opacity * 0.3;
const trailWidth = (i / p.trail.length) * p.radius * 0.8;
ctx.beginPath();
ctx.moveTo(prev.x, prev.y);
ctx.lineTo(curr.x, curr.y);
ctx.strokeStyle = `rgba(${rgb}, ${trailAlpha})`;
ctx.lineWidth = Math.max(0.3, trailWidth);
ctx.stroke();
}
ctx.restore();
}
function drawInkDot(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
r: number,
rotation: number,
scaleX: number,
scaleY: number,
rgb: string,
opacity: number,
): void {
ctx.save();
ctx.translate(x, y);
ctx.rotate(rotation);
ctx.beginPath();
ctx.ellipse(0, 0, Math.max(0.5, r * scaleX), Math.max(0.5, r * scaleY), 0, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${rgb}, ${opacity})`;
ctx.fill();
ctx.restore();
}
function drawInkBlob(
ctx: CanvasRenderingContext2D,
cx: number,
cy: number,
baseR: number,
seed1: number,
seed2: number,
wobble: number,
detail: number,
rgb: string,
opacity: number,
): void {
ctx.beginPath();
for (let i = 0; i <= detail; i++) {
const angle = (i / detail) * Math.PI * 2;
const noise = Math.sin(angle * 3 + seed1) * 0.3 + Math.cos(angle * 5 + seed2) * 0.2;
const r = Math.max(0.5, baseR * (1 + noise * wobble));
const x = cx + Math.cos(angle) * r;
const y = cy + Math.sin(angle) * r;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.closePath();
ctx.fillStyle = `rgba(${rgb}, ${opacity})`;
ctx.fill();
}
function drawInkLayers(
ctx: CanvasRenderingContext2D,
p: Particle,
config: ResponsiveConfig,
): void {
const tone = TONES[p.toneIndex];
if (!tone) {
return;
}
const isAccent = ACCENT_TONES.includes(p.toneIndex);
const deepRgb = isAccent ? tone.rgb : (p.toneIndex <= 1 ? tone.rgb : TONES[0].rgb);
const lightRgb = isAccent ? TONES[1].rgb : (p.toneIndex >= 2 ? tone.rgb : TONES[2].rgb);
const r = Math.max(0.5, p.radius) * config.inkRadiusScale;
const op = p.opacity;
const detail = config.wobbleDetail;
const layers: Array<{ radiusMul: number; opacityMul: number; rgb: string; wobble: number; useRect: boolean }> = [
{ radiusMul: 5, opacityMul: 0.04, rgb: lightRgb, wobble: 0.25, useRect: true },
{ radiusMul: 3, opacityMul: 0.12, rgb: lightRgb, wobble: 0.2, useRect: true },
{ radiusMul: 1.5, opacityMul: 0.4, rgb: deepRgb, wobble: 0.12, useRect: false },
{ radiusMul: 1, opacityMul: 0.9, rgb: deepRgb, wobble: 0.06, useRect: false },
];
const layerCount = p.inkLayerCount;
const startIdx = layers.length - layerCount;
for (let i = startIdx; i < layers.length; i++) {
const layer = layers[i];
if (!layer) {
continue;
}
const layerR = r * layer.radiusMul;
const layerOp = op * layer.opacityMul;
if (layer.useRect) {
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, layerR);
grad.addColorStop(0, `rgba(${layer.rgb}, ${layerOp})`);
grad.addColorStop(0.5, `rgba(${layer.rgb}, ${layerOp * 0.3})`);
grad.addColorStop(1, 'transparent');
ctx.fillStyle = grad;
ctx.fillRect(p.x - layerR, p.y - layerR, layerR * 2, layerR * 2);
} else {
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, layerR);
grad.addColorStop(0, `rgba(${layer.rgb}, ${layerOp})`);
grad.addColorStop(0.6, `rgba(${layer.rgb}, ${layerOp * 0.5})`);
grad.addColorStop(1, 'transparent');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(p.x, p.y, layerR, 0, Math.PI * 2);
ctx.fill();
drawInkBlob(ctx, p.x, p.y, layerR * 0.7, p.seed1, p.seed2, layer.wobble, detail, layer.rgb, layerOp * 0.6);
}
}
}
function drawFeibai(
ctx: CanvasRenderingContext2D,
p: Particle,
rgb: string,
): void {
const speed = Math.sqrt(p.prevVx * p.prevVx + p.prevVy * p.prevVy);
if (speed < 0.1) {
return;
}
const dirX = -p.prevVx / speed;
const dirY = -p.prevVy / speed;
const length = p.radius * (3 + Math.random() * 2);
const endX = p.x + dirX * length;
const endY = p.y + dirY * length;
const perpX = -dirY;
const perpY = dirX;
const curvature = (Math.random() - 0.5) * length * 0.15;
const cpX = (p.x + endX) / 2 + perpX * curvature;
const cpY = (p.y + endY) / 2 + perpY * curvature;
const grad = ctx.createLinearGradient(p.x, p.y, endX, endY);
grad.addColorStop(0, `rgba(${rgb}, ${p.opacity * 0.25})`);
grad.addColorStop(1, 'transparent');
ctx.save();
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.quadraticCurveTo(cpX, cpY, endX, endY);
ctx.strokeStyle = grad;
ctx.lineWidth = Math.max(0.3, p.radius * 0.6);
ctx.stroke();
ctx.restore();
}
function drawParticle(
ctx: CanvasRenderingContext2D,
p: Particle,
time: number,
config: ResponsiveConfig,
): void {
if (p.age < p.delay || p.opacity <= 0) {
return;
}
const tone = TONES[p.toneIndex];
if (!tone) {
return;
}
const rgb = tone.rgb;
const r = Math.max(0.5, p.radius);
const gs = config.glowScale;
if (p.phase === 'spreading') {
drawTrail(ctx, p, rgb);
drawInkLayers(ctx, p, config);
} else if (p.phase === 'settling') {
drawInkLayers(ctx, p, config);
if (config.showFeibai) {
drawFeibai(ctx, p, rgb);
}
} else if (p.phase === 'fading') {
const grad1 = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r * 1.5);
grad1.addColorStop(0, `rgba(${rgb}, ${p.opacity * 0.6})`);
grad1.addColorStop(1, 'transparent');
ctx.fillStyle = grad1;
ctx.beginPath();
ctx.arc(p.x, p.y, r * 1.5, 0, Math.PI * 2);
ctx.fill();
} else {
const pulse = 1 + Math.sin(time * 0.002 + p.targetX * 0.01) * 0.15;
const glowR = r * 3 * pulse * gs;
const grad2 = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowR);
grad2.addColorStop(0, `rgba(${rgb}, ${p.opacity * 0.12})`);
grad2.addColorStop(0.4, `rgba(${rgb}, ${p.opacity * 0.04})`);
grad2.addColorStop(1, 'transparent');
ctx.fillStyle = grad2;
ctx.fillRect(p.x - glowR, p.y - glowR, glowR * 2, glowR * 2);
drawInkDot(ctx, p.x, p.y, r, p.rotation, p.scaleX, p.scaleY, rgb, p.opacity);
}
}
function drawInkStrings(
ctx: CanvasRenderingContext2D,
particles: Particle[],
config: ResponsiveConfig,
): void {
const spreading = particles.filter(
p => p.phase === 'spreading' && p.age >= p.delay
);
if (spreading.length < 2) {
return;
}
const threshold = config.connectionDistance * config.inkStringThreshold;
ctx.save();
ctx.lineCap = 'round';
for (let i = 0; i < spreading.length; i++) {
const a = spreading[i];
if (!a) {
continue;
}
for (let j = i + 1; j < spreading.length; j++) {
const b = spreading[j];
if (!b) {
continue;
}
const dx = a.x - b.x;
const dy = a.y - b.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < threshold && dist > 0) {
const distRatio = 1 - dist / threshold;
const alpha = distRatio * 0.15 * Math.min(a.opacity, b.opacity);
const useAccent = ACCENT_TONES.includes(a.toneIndex) || ACCENT_TONES.includes(b.toneIndex);
const rgb = useAccent ? TONES[3].rgb : TONES[0].rgb;
const mx = (a.x + b.x) / 2;
const my = (a.y + b.y) / 2;
const perpX = -dy / dist;
const perpY = dx / dist;
const curvature = (Math.random() - 0.5) * dist * 0.2;
const cpx = mx + perpX * curvature;
const cpy = my + perpY * curvature;
const grad = ctx.createLinearGradient(a.x, a.y, b.x, b.y);
grad.addColorStop(0, `rgba(${rgb}, ${alpha * 0.3})`);
grad.addColorStop(0.5, `rgba(${rgb}, ${alpha})`);
grad.addColorStop(1, `rgba(${rgb}, ${alpha * 0.3})`);
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.quadraticCurveTo(cpx, cpy, b.x, b.y);
ctx.strokeStyle = grad;
ctx.lineWidth = Math.max(0.2, distRatio * 1.5);
ctx.stroke();
}
}
}
ctx.restore();
}
function drawConnections(
ctx: CanvasRenderingContext2D,
particles: Particle[],
connectionDistance: number,
): void {
const morphed = particles.filter(
p => p.phase === 'morphing' && p.morphProgress > 0.3
);
ctx.save();
ctx.lineCap = 'round';
for (let i = 0; i < morphed.length; i++) {
const a = morphed[i];
if (!a) {
continue;
}
const toneA = TONES[a.toneIndex];
if (!toneA) {
continue;
}
for (let j = i + 1; j < morphed.length; j++) {
const b = morphed[j];
if (!b) {
continue;
}
const toneB = TONES[b.toneIndex];
if (!toneB) {
continue;
}
const dx = a.x - b.x;
const dy = a.y - b.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < connectionDistance && dist > 0) {
const progress = Math.min(a.morphProgress, b.morphProgress);
const distRatio = 1 - dist / connectionDistance;
const alpha = distRatio * 0.12 * progress;
const lineWidth = 0.3 + distRatio * 0.7;
const useAccent = ACCENT_TONES.includes(a.toneIndex) || ACCENT_TONES.includes(b.toneIndex);
const connRgb = useAccent ? TONES[3].rgb : TONES[0].rgb;
const mx = (a.x + b.x) / 2;
const my = (a.y + b.y) / 2;
const offset = dist * 0.1;
const cpx = mx + (dy / dist) * offset;
const cpy = my - (dx / dist) * offset;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.quadraticCurveTo(cpx, cpy, b.x, b.y);
ctx.strokeStyle = `rgba(${connRgb}, ${alpha})`;
ctx.lineWidth = lineWidth;
ctx.stroke();
}
}
}
ctx.restore();
}
function drawBgOrbs(
ctx: CanvasRenderingContext2D,
W: number,
H: number,
orbScale: number,
): void {
const orbs = [
{ x: W * 0.72, y: H * 0.28, r: 350 * orbScale, rgb: TONES[3].rgb, alpha: 0.04 },
{ x: W * 0.28, y: H * 0.68, r: 280 * orbScale, rgb: TONES[0].rgb, alpha: 0.03 },
{ x: W * 0.5, y: H * 0.45, r: 400 * orbScale, rgb: TONES[4].rgb, alpha: 0.015 },
{ x: W * 0.85, y: H * 0.7, r: 200 * orbScale, rgb: TONES[1].rgb, alpha: 0.02 },
];
for (const o of orbs) {
const grad = ctx.createRadialGradient(o.x, o.y, 0, o.x, o.y, o.r);
grad.addColorStop(0, `rgba(${o.rgb}, ${o.alpha})`);
grad.addColorStop(0.6, `rgba(${o.rgb}, ${o.alpha * 0.3})`);
grad.addColorStop(1, 'transparent');
ctx.fillStyle = grad;
ctx.fillRect(o.x - o.r, o.y - o.r, o.r * 2, o.r * 2);
}
}
function createParticle(
cx: number,
cy: number,
W: number,
H: number,
delay: number,
config: ResponsiveConfig,
): Particle {
const angle = Math.random() * Math.PI * 2;
const dist = Math.random() * 8;
const isSplash = Math.random() < config.splashRatio;
const speed = isSplash
? 1.5 + Math.random() * 3
: 0.4 + Math.random() * 1.8;
const toneIndex = pickToneIndex(isSplash);
const isHeavy = toneIndex === 0 || toneIndex === 3;
const initialRadius = isSplash
? 0.5 + Math.random() * 1.5
: 2 + Math.random() * 5;
const [txMin, txMax] = config.targetXRange;
const [tyMin, tyMax] = config.targetYRange;
return {
x: cx + Math.cos(angle) * dist,
y: cy + Math.sin(angle) * dist,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
radius: initialRadius,
initialRadius,
dataRadius: isSplash ? 0 : 1.2 + Math.random() * 2.5,
opacity: isSplash ? 0.6 + Math.random() * 0.3 : 0.5 + Math.random() * 0.4,
toneIndex,
rotation: Math.random() * Math.PI * 2,
scaleX: isHeavy ? 0.8 + Math.random() * 0.2 : 0.5 + Math.random() * 0.4,
scaleY: isHeavy ? 0.8 + Math.random() * 0.2 : 0.5 + Math.random() * 0.4,
isSplash,
phase: 'spreading',
spreadTime: 0,
maxSpreadTime: isSplash ? 30 + Math.random() * 40 : 70 + Math.random() * 90,
settleTime: 0,
morphProgress: 0,
targetX: W * (txMin + Math.random() * (txMax - txMin)),
targetY: H * (tyMin + Math.random() * (tyMax - tyMin)),
delay,
age: 0,
trail: [],
seed1: Math.random() * Math.PI * 2,
seed2: Math.random() * Math.PI * 2,
wobbleFactor: 0.3,
prevVx: 0,
prevVy: 0,
inkLayerCount: config.inkLayers,
};
}
function initParticlesArray(W: number, H: number, config: ResponsiveConfig): Particle[] {
const particles: Particle[] = [];
let remaining = config.particleCount;
for (let c = 0; c < config.centers.length; c++) {
const centerDef = config.centers[c];
if (!centerDef) {
continue;
}
const cx = W * centerDef.x;
const cy = H * centerDef.y;
const dist = config.centerDistribution[c] ?? 0;
const count = c === config.centers.length - 1
? remaining
: Math.floor(config.particleCount * dist);
remaining -= count;
const baseDelay = c * 25;
for (let i = 0; i < count; i++) {
particles.push(
createParticle(cx, cy, W, H, baseDelay + Math.random() * 20, config)
);
}
}
return particles;
}
export function InkDataMorph({
className = '',
}: InkDataMorphProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const particlesRef = useRef<Particle[]>([]);
const animationRef = useRef<number | undefined>(undefined);
const startTimeRef = useRef<number>(0);
const configRef = useRef<ResponsiveConfig | null>(null);
const paperTextureRef = useRef<HTMLCanvasElement | null>(null);
const shouldReduceMotion = useReducedMotion();
const drawStaticFinal = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const ctx = canvas.getContext('2d');
if (!ctx) {
return;
}
const config = configRef.current;
if (!config) {
return;
}
const dpr = canvas.width / (canvas.clientWidth || 1);
const W = canvas.width / dpr;
const H = canvas.height / dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#FAFAF5';
ctx.fillRect(0, 0, W, H);
drawBgOrbs(ctx, W, H, config.bgOrbScale);
const finalParticles = particlesRef.current
.filter(p => !p.isSplash)
.map(p => ({
...p,
x: p.targetX,
y: p.targetY,
radius: p.dataRadius,
opacity: 0.35,
phase: 'complete' as const,
trail: [],
}));
for (const p of finalParticles) {
drawParticle(ctx, p, 0, config);
}
drawConnections(ctx, finalParticles, config.connectionDistance);
drawInkStrings(ctx, finalParticles, config);
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const parent = canvas.parentElement;
if (!parent) {
return;
}
const initCanvas = () => {
if (animationRef.current !== undefined) {
cancelAnimationFrame(animationRef.current);
animationRef.current = undefined;
}
const W = parent.clientWidth;
const H = parent.clientHeight;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = W * dpr;
canvas.height = H * dpr;
const config = getResponsiveConfig(W);
configRef.current = config;
particlesRef.current = initParticlesArray(W, H, config);
paperTextureRef.current = generatePaperTexture(config.paperTextureSize);
if (shouldReduceMotion) {
drawStaticFinal();
return;
}
startTimeRef.current = performance.now();
const animate = () => {
const ctx = canvas.getContext('2d');
if (!ctx) {
return;
}
const time = performance.now() - startTimeRef.current;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#FAFAF5';
ctx.fillRect(0, 0, W, H);
drawBgOrbs(ctx, W, H, config.bgOrbScale);
const particles = particlesRef.current;
let allComplete = true;
for (const p of particles) {
updateParticle(p, config.maxTrail);
drawParticle(ctx, p, time, config);
if (p.phase !== 'complete') {
allComplete = false;
}
}
drawConnections(ctx, particles, config.connectionDistance);
drawInkStrings(ctx, particles, config);
if (paperTextureRef.current) {
drawPaperTexture(ctx, W, H, paperTextureRef.current);
}
if (allComplete) {
animationRef.current = undefined;
return;
}
animationRef.current = requestAnimationFrame(animate);
};
animationRef.current = requestAnimationFrame(animate);
};
initCanvas();
let resizeTimer: ReturnType<typeof setTimeout> | undefined;
const handleResize = () => {
if (resizeTimer !== undefined) {
clearTimeout(resizeTimer);
}
resizeTimer = setTimeout(initCanvas, 300);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
if (resizeTimer !== undefined) {
clearTimeout(resizeTimer);
}
if (animationRef.current !== undefined) {
cancelAnimationFrame(animationRef.current);
}
};
}, [shouldReduceMotion, drawStaticFinal]);
return (
<canvas
ref={canvasRef}
className={`absolute inset-0 w-full h-full pointer-events-none ${className}`}
aria-hidden="true"
/>
);
}
-135
View File
@@ -1,135 +0,0 @@
'use client';
import { motion, useReducedMotion } from 'framer-motion';
import { useEffect, useState } from 'react';
interface InkTechFusionProps {
className?: string;
variant?: 'subtle' | 'prominent' | 'dynamic';
primaryColor?: string;
secondaryColor?: string;
}
interface InkBlob {
id: number;
x: number;
y: number;
size: number;
opacity: number;
duration: number;
delay: number;
color: string;
}
export function InkTechFusion({
className = '',
variant = 'subtle',
primaryColor = '#C41E3A',
secondaryColor = '#1C1C1C',
}: InkTechFusionProps) {
const prefersReducedMotion = useReducedMotion();
const [blobs, setBlobs] = useState<InkBlob[]>([]);
useEffect(() => {
const count = variant === 'prominent' ? 8 : variant === 'dynamic' ? 12 : 5;
const generated: InkBlob[] = Array.from({ length: count }, (_, i) => ({
id: i,
x: Math.random() * 100,
y: Math.random() * 100,
size: Math.random() * 300 + 100,
opacity: Math.random() * 0.06 + 0.02,
duration: Math.random() * 25 + 20,
delay: Math.random() * 5,
color: i % 2 === 0 ? primaryColor : secondaryColor,
}));
setBlobs(generated);
}, [variant, primaryColor, secondaryColor]);
return (
<div className={`absolute inset-0 overflow-hidden ${className}`} aria-hidden="true">
{blobs.map((blob) => (
<motion.div
key={blob.id}
className="absolute"
style={{
left: `${blob.x}%`,
top: `${blob.y}%`,
width: blob.size,
height: blob.size,
background: `radial-gradient(circle, ${blob.color}${Math.round(blob.opacity * 255).toString(16).padStart(2, '0')} 0%, transparent 70%)`,
borderRadius: '50%',
filter: 'blur(40px)',
willChange: prefersReducedMotion ? 'auto' : 'transform',
}}
initial={{ scale: 0.8, opacity: 0 }}
animate={
prefersReducedMotion
? { scale: 1, opacity: blob.opacity }
: {
scale: [0.8, 1.2, 0.9, 1.1, 0.8],
opacity: [0, blob.opacity, blob.opacity * 1.2, blob.opacity, 0],
x: [0, 30, -20, 10, 0],
y: [0, -20, 30, -10, 0],
}
}
transition={{
duration: blob.duration,
repeat: Infinity,
ease: 'easeInOut',
delay: blob.delay,
}}
/>
))}
<svg className="absolute inset-0 w-full h-full opacity-10">
<defs>
<filter id="ink-blur">
<feGaussianBlur in="SourceGraphic" stdDeviation="3" />
</filter>
<pattern id="ink-texture" width="100" height="100" patternUnits="userSpaceOnUse">
<circle cx="50" cy="50" r="1" fill={primaryColor} opacity="0.2" />
<circle cx="25" cy="75" r="0.5" fill={secondaryColor} opacity="0.15" />
<circle cx="75" cy="25" r="0.8" fill={primaryColor} opacity="0.18" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#ink-texture)" filter="url(#ink-blur)" />
</svg>
<svg className="absolute inset-0 w-full h-full opacity-5">
<defs>
<linearGradient id="tech-line-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor={primaryColor} stopOpacity="0" />
<stop offset="50%" stopColor={primaryColor} stopOpacity="0.3" />
<stop offset="100%" stopColor={primaryColor} stopOpacity="0" />
</linearGradient>
</defs>
<motion.line
x1="0%"
y1="30%"
x2="100%"
y2="70%"
stroke="url(#tech-line-gradient)"
strokeWidth="1"
initial={{ pathLength: 0 }}
animate={prefersReducedMotion ? { pathLength: 1 } : { pathLength: [0, 1, 1, 0] }}
transition={{ duration: 15, repeat: Infinity, ease: 'easeInOut' }}
/>
<motion.line
x1="0%"
y1="70%"
x2="100%"
y2="30%"
stroke="url(#tech-line-gradient)"
strokeWidth="1"
initial={{ pathLength: 0 }}
animate={prefersReducedMotion ? { pathLength: 1 } : { pathLength: [0, 1, 1, 0] }}
transition={{ duration: 18, repeat: Infinity, ease: 'easeInOut', delay: 3 }}
/>
</svg>
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-white/50" />
</div>
);
}
export default InkTechFusion;
-89
View File
@@ -1,89 +0,0 @@
'use client';
import { motion, useReducedMotion } from 'framer-motion';
interface MeshGradientProps {
className?: string;
variant?: 'default' | 'warm' | 'cool' | 'elegant';
}
const gradientVariants = {
default: {
colors: [
'radial-gradient(at 40% 20%, hsla(280,80%,90%,0.3) 0px, transparent 50%)',
'radial-gradient(at 80% 0%, hsla(189,100%,56%,0.2) 0px, transparent 50%)',
'radial-gradient(at 0% 50%, hsla(355,100%,93%,0.3) 0px, transparent 50%)',
'radial-gradient(at 80% 50%, hsla(340,100%,76%,0.2) 0px, transparent 50%)',
'radial-gradient(at 0% 100%, hsla(22,100%,77%,0.3) 0px, transparent 50%)',
'radial-gradient(at 80% 100%, hsla(242,100%,70%,0.2) 0px, transparent 50%)',
'radial-gradient(at 0% 0%, hsla(343,100%,76%,0.2) 0px, transparent 50%)',
],
},
warm: {
colors: [
'radial-gradient(at 40% 20%, hsla(15,90%,85%,0.4) 0px, transparent 50%)',
'radial-gradient(at 80% 0%, hsla(30,100%,80%,0.3) 0px, transparent 50%)',
'radial-gradient(at 0% 50%, hsla(0,100%,94%,0.4) 0px, transparent 50%)',
'radial-gradient(at 80% 50%, hsla(20,100%,85%,0.3) 0px, transparent 50%)',
'radial-gradient(at 0% 100%, hsla(10,100%,90%,0.4) 0px, transparent 50%)',
],
},
cool: {
colors: [
'radial-gradient(at 40% 20%, hsla(200,80%,90%,0.3) 0px, transparent 50%)',
'radial-gradient(at 80% 0%, hsla(220,100%,85%,0.2) 0px, transparent 50%)',
'radial-gradient(at 0% 50%, hsla(180,100%,90%,0.3) 0px, transparent 50%)',
'radial-gradient(at 80% 50%, hsla(240,80%,90%,0.2) 0px, transparent 50%)',
],
},
elegant: {
colors: [
'radial-gradient(at 40% 20%, hsla(0,70%,90%,0.25) 0px, transparent 50%)',
'radial-gradient(at 80% 0%, hsla(0,60%,95%,0.2) 0px, transparent 50%)',
'radial-gradient(at 0% 50%, hsla(350,80%,92%,0.25) 0px, transparent 50%)',
'radial-gradient(at 80% 50%, hsla(0,50%,97%,0.2) 0px, transparent 50%)',
],
},
};
export function MeshGradient({ className = '', variant = 'default' }: MeshGradientProps) {
const prefersReducedMotion = useReducedMotion();
const { colors } = gradientVariants[variant];
return (
<div
className={`absolute inset-0 overflow-hidden ${className}`}
aria-hidden="true"
>
{colors.map((gradient, index) => (
<motion.div
key={index}
className="absolute inset-0"
style={{
background: gradient,
willChange: prefersReducedMotion ? 'auto' : 'transform, opacity',
}}
animate={
prefersReducedMotion
? {}
: {
scale: [1, 1.1, 1],
opacity: [0.6, 0.8, 0.6],
x: [0, 10, 0],
y: [0, -10, 0],
}
}
transition={{
duration: 20 + index * 2,
repeat: Infinity,
ease: 'easeInOut',
delay: index * 0.5,
}}
/>
))}
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-white/30" />
</div>
);
}
export default MeshGradient;
@@ -1,194 +0,0 @@
'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
interface InteractiveParticle {
x: number;
y: number;
originX: number;
originY: number;
vx: number;
vy: number;
size: number;
opacity: number;
color: string;
life: number;
}
interface MouseInteractiveParticlesProps {
particleCount?: number;
className?: string;
colorScheme?: 'red' | 'dark' | 'mixed';
interactionRadius?: number;
}
export function MouseInteractiveParticles({
particleCount = 80,
className = '',
colorScheme = 'mixed',
interactionRadius = 150,
}: MouseInteractiveParticlesProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isMounted, setIsMounted] = useState(false);
const mouseRef = useRef({ x: -1000, y: -1000, active: false });
const particlesRef = useRef<InteractiveParticle[]>([]);
const animationRef = useRef<number | null>(null);
const getColors = useCallback(() => {
switch (colorScheme) {
case 'red':
return ['#C41E3A', '#E04A68', '#A01830'];
case 'dark':
return ['#1C1C1C', '#2D2D2D', '#3E3E3E'];
case 'mixed':
default:
return ['#C41E3A', '#1C1C1C', '#D4A574'];
}
}, [colorScheme]);
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
if (!isMounted) {return;}
const canvas = canvasRef.current;
if (!canvas) {return;}
const ctx = canvas.getContext('2d');
if (!ctx) {return;}
let width = window.innerWidth;
let height = window.innerHeight;
const resize = () => {
width = window.innerWidth;
height = window.innerHeight;
canvas.width = width;
canvas.height = height;
initParticles();
};
const initParticles = () => {
const colors = getColors();
particlesRef.current = [];
for (let i = 0; i < particleCount; i++) {
const x = Math.random() * width;
const y = Math.random() * height;
particlesRef.current.push({
x,
y,
originX: x,
originY: y,
vx: (Math.random() - 0.5) * 0.5,
vy: (Math.random() - 0.5) * 0.5,
size: Math.random() * 3 + 1,
opacity: Math.random() * 0.5 + 0.2,
color: colors[Math.floor(Math.random() * colors.length)] ?? '#C41E3A',
life: Math.random() * Math.PI * 2,
});
}
};
const handleMouseMove = (e: MouseEvent) => {
mouseRef.current.x = e.clientX;
mouseRef.current.y = e.clientY;
mouseRef.current.active = true;
};
const handleMouseLeave = () => {
mouseRef.current.x = -1000;
mouseRef.current.y = -1000;
mouseRef.current.active = false;
};
const animate = () => {
ctx.clearRect(0, 0, width, height);
particlesRef.current.forEach((particle, i) => {
particle.life += 0.02;
if (mouseRef.current.active) {
const dx = mouseRef.current.x - particle.x;
const dy = mouseRef.current.y - particle.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < interactionRadius) {
const force = (interactionRadius - distance) / interactionRadius;
const angle = Math.atan2(dy, dx);
particle.vx -= Math.cos(angle) * force * 0.5;
particle.vy -= Math.sin(angle) * force * 0.5;
}
}
const returnForce = 0.01;
particle.vx += (particle.originX - particle.x) * returnForce;
particle.vy += (particle.originY - particle.y) * returnForce;
particle.vx *= 0.98;
particle.vy *= 0.98;
particle.x += particle.vx;
particle.y += particle.vy;
particle.x += Math.sin(particle.life + i) * 0.1;
particle.y += Math.cos(particle.life * 0.8 + i) * 0.1;
ctx.beginPath();
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
ctx.fillStyle = particle.color;
ctx.globalAlpha = particle.opacity;
ctx.fill();
particlesRef.current.forEach((otherParticle, j) => {
if (i === j) {return;}
const dx = particle.x - otherParticle.x;
const dy = particle.y - otherParticle.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 100) {
ctx.beginPath();
ctx.moveTo(particle.x, particle.y);
ctx.lineTo(otherParticle.x, otherParticle.y);
ctx.strokeStyle = particle.color;
ctx.globalAlpha = 0.05 * (1 - distance / 100);
ctx.stroke();
}
});
});
ctx.globalAlpha = 1;
animationRef.current = requestAnimationFrame(animate);
};
resize();
initParticles();
animate();
window.addEventListener('resize', resize);
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseleave', handleMouseLeave);
return () => {
window.removeEventListener('resize', resize);
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseleave', handleMouseLeave);
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [isMounted, particleCount, getColors, interactionRadius]);
if (!isMounted) {return null;}
return (
<canvas
ref={canvasRef}
className={`absolute inset-0 pointer-events-none ${className}`}
/>
);
}
export default MouseInteractiveParticles;
@@ -1,77 +0,0 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { motion } from 'framer-motion';
interface ParallaxEffectProps {
className?: string;
color?: string;
sensitivity?: number;
}
export function ParallaxEffect({
className = '',
color = '#C41E3A',
sensitivity = 0.05
}: ParallaxEffectProps) {
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!containerRef.current) {return;}
const rect = containerRef.current.getBoundingClientRect();
const centerX = rect.width / 2;
const centerY = rect.height / 2;
const x = (e.clientX - rect.left - centerX) * sensitivity;
const y = (e.clientY - rect.top - centerY) * sensitivity;
setMousePosition({ x, y });
};
const container = containerRef.current;
container?.addEventListener('mousemove', handleMouseMove);
return () => {
container?.removeEventListener('mousemove', handleMouseMove);
};
}, [sensitivity]);
const layers = [
{ size: 300, x: 10, y: 15, factor: 1 },
{ size: 200, x: 70, y: 20, factor: 1.5 },
{ size: 150, x: 60, y: 60, factor: 2 },
{ size: 100, x: 15, y: 65, factor: 2.5 }
];
return (
<div ref={containerRef} className={`absolute inset-0 pointer-events-none ${className}`}>
{layers.map((layer, index) => (
<motion.div
key={index}
className="absolute rounded-full"
style={{
width: layer.size,
height: layer.size,
background: `radial-gradient(circle, ${color}15 0%, transparent 70%)`,
left: `${layer.x}%`,
top: `${layer.y}%`
}}
animate={{
x: mousePosition.x * layer.factor,
y: mousePosition.y * layer.factor
}}
transition={{
type: 'spring',
stiffness: 50,
damping: 30
}}
/>
))}
</div>
);
}
export default ParallaxEffect;
-229
View File
@@ -1,229 +0,0 @@
'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
import { motion, useMotionValue } from 'framer-motion';
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
size: number;
opacity: number;
}
interface ParticleGalaxyProps {
particleCount?: number;
connectionDistance?: number;
mouseRadius?: number;
particleColor?: string;
lineColor?: string;
className?: string;
}
export function ParticleGalaxy({
particleCount = 100,
connectionDistance = 150,
mouseRadius = 150,
particleColor = '196, 30, 58',
lineColor = '196, 30, 58',
className = ''
}: ParticleGalaxyProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const particlesRef = useRef<Particle[]>([]);
const animationRef = useRef<number | undefined>(undefined);
const [isVisible, setIsVisible] = useState(false);
const mouseX = useMotionValue(0);
const mouseY = useMotionValue(0);
const mouseInCanvas = useMotionValue(false);
const createParticle = useCallback((width: number, height: number): Particle => {
return {
x: Math.random() * width,
y: Math.random() * height,
vx: (Math.random() - 0.5) * 0.8,
vy: (Math.random() - 0.5) * 0.8,
size: Math.random() * 2 + 1,
opacity: Math.random() * 0.5 + 0.2
};
}, []);
const initParticles = useCallback((width: number, height: number) => {
particlesRef.current = Array.from({ length: particleCount }, () =>
createParticle(width, height)
);
}, [particleCount, createParticle]);
const drawParticles = useCallback((ctx: CanvasRenderingContext2D, width: number, height: number) => {
const mx = mouseX.get();
const my = mouseY.get();
const inCanvas = mouseInCanvas.get();
ctx.clearRect(0, 0, width, height);
const particles = particlesRef.current;
particles.forEach((particle, i) => {
let { x, y, vx, vy, size, opacity } = particle;
if (inCanvas) {
const dx = x - mx;
const dy = y - my;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < mouseRadius) {
const force = (mouseRadius - distance) / mouseRadius;
const angle = Math.atan2(dy, dx);
vx += Math.cos(angle) * force * 0.5;
vy += Math.sin(angle) * force * 0.5;
}
}
x += vx;
y += vy;
if (x < 0 || x > width) {vx *= -1;}
if (y < 0 || y > height) {vy *= -1;}
x = Math.max(0, Math.min(width, x));
y = Math.max(0, Math.min(height, y));
vx *= 0.99;
vy *= 0.99;
const speed = Math.sqrt(vx * vx + vy * vy);
if (speed < 0.1) {
vx += (Math.random() - 0.5) * 0.1;
vy += (Math.random() - 0.5) * 0.1;
}
particlesRef.current[i] = { x, y, vx, vy, size, opacity };
ctx.beginPath();
ctx.arc(x, y, size, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${particleColor}, ${opacity})`;
ctx.fill();
});
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const p1 = particles[i];
const p2 = particles[j];
if (!p1 || !p2) {continue;}
const dx = p1.x - p2.x;
const dy = p1.y - p2.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < connectionDistance) {
const opacity = (1 - distance / connectionDistance) * 0.3;
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.strokeStyle = `rgba(${lineColor}, ${opacity})`;
ctx.lineWidth = 0.5;
ctx.stroke();
}
}
}
}, [mouseX, mouseY, mouseInCanvas, mouseRadius, particleColor, lineColor, connectionDistance]);
const animate = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) {return;}
const ctx = canvas.getContext('2d');
if (!ctx) {return;}
drawParticles(ctx, canvas.width, canvas.height);
animationRef.current = requestAnimationFrame(animate);
}, [drawParticles]);
const handleResize = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) {return;}
const container = canvas.parentElement;
if (!container) {return;}
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
initParticles(canvas.width, canvas.height);
}, [initParticles]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {return;}
const container = canvas.parentElement;
if (!container) {return;}
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
initParticles(canvas.width, canvas.height);
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
setIsVisible(entry.isIntersecting);
});
},
{ threshold: 0.1 }
);
observer.observe(canvas);
if (isVisible) {
animate();
}
const handleResizeWithDebounce = () => {
setTimeout(handleResize, 250);
};
window.addEventListener('resize', handleResizeWithDebounce);
return () => {
observer.disconnect();
window.removeEventListener('resize', handleResizeWithDebounce);
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [isVisible, animate, initParticles, handleResize]);
useEffect(() => {
if (isVisible) {
animate();
} else if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
}, [isVisible, animate]);
return (
<motion.div
className={`absolute inset-0 pointer-events-none ${className}`}
initial={{ opacity: 0 }}
animate={{ opacity: isVisible ? 1 : 0 }}
transition={{ duration: 0.5 }}
>
<canvas
ref={canvasRef}
className="w-full h-full"
onMouseMove={(e) => {
mouseX.set(e.clientX);
mouseY.set(e.clientY);
mouseInCanvas.set(true);
}}
onMouseLeave={() => {
mouseInCanvas.set(false);
}}
/>
</motion.div>
);
}
export default ParticleGalaxy;
@@ -1,178 +0,0 @@
'use client';
import { useEffect, useRef, useCallback, useState } from 'react';
interface Particle {
x: number;
y: number;
targetX: number;
targetY: number;
vx: number;
vy: number;
size: number;
opacity: number;
color: string;
life: number;
maxLife: number;
stage: 'idle' | 'dispersing' | 'reforming';
}
interface SealAnimationEnhancedProps {
width?: number;
height?: number;
particleCount?: number;
colors?: string[];
sealText?: string;
animationStages?: boolean;
onStageChange?: (stage: string) => void;
className?: string;
}
export function SealAnimationEnhanced({
width = 300,
height = 300,
particleCount = 150,
colors = ['#C41E3A', '#D4A574', '#8B4513'],
sealText: _sealText = '睿新',
animationStages = true,
onStageChange,
className = '',
}: SealAnimationEnhancedProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const particlesRef = useRef<Particle[]>([]);
const animationRef = useRef<number | null>(null);
const [_currentStage, setCurrentStage] = useState<'idle' | 'dispersing' | 'reforming'>('idle');
const stageTimerRef = useRef<NodeJS.Timeout | null>(null);
const createSealShape = useCallback((width: number, height: number) => {
const centerX = width / 2;
const centerY = height / 2;
const sealSize = Math.min(width, height) * 0.35;
const particles: { x: number; y: number }[] = [];
for (let i = 0; i < particleCount; i++) {
const angle = (i / particleCount) * Math.PI * 2;
const radius = sealSize * (0.8 + Math.random() * 0.4);
particles.push({
x: centerX + Math.cos(angle) * radius * (Math.random() > 0.5 ? 1 : 0.8),
y: centerY + Math.sin(angle) * radius * (Math.random() > 0.5 ? 1 : 0.8),
});
}
return particles;
}, [particleCount]);
const createParticle = useCallback(
(x: number, y: number, targetX: number, targetY: number): Particle => {
const color = colors[Math.floor(Math.random() * colors.length)] ?? '#C41E3A';
const size = 2 + Math.random() * 3;
const maxLife = 200 + Math.random() * 100;
return {
x,
y,
targetX,
targetY,
vx: (Math.random() - 0.5) * 2,
vy: (Math.random() - 0.5) * 2,
size,
opacity: 0.6 + Math.random() * 0.4,
color,
life: 0,
maxLife,
stage: 'idle',
};
},
[colors]
);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {return;}
const ctx = canvas.getContext('2d');
if (!ctx) {return;}
canvas.width = width;
canvas.height = height;
const sealPositions = createSealShape(width, height);
particlesRef.current = sealPositions.map((pos) =>
createParticle(pos.x, pos.y, pos.x, pos.y)
);
if (animationStages) {
stageTimerRef.current = setTimeout(() => {
setCurrentStage('dispersing');
onStageChange?.('dispersing');
particlesRef.current.forEach(p => {
p.vx = (Math.random() - 0.5) * 4;
p.vy = (Math.random() - 0.5) * 4;
p.stage = 'dispersing';
});
setTimeout(() => {
setCurrentStage('reforming');
onStageChange?.('reforming');
particlesRef.current.forEach(p => {
p.stage = 'reforming';
});
setTimeout(() => {
setCurrentStage('idle');
onStageChange?.('idle');
}, 3000);
}, 2000);
}, 3000);
}
const animate = () => {
ctx.clearRect(0, 0, width, height);
particlesRef.current.forEach((particle) => {
if (particle.stage === 'reforming') {
const dx = particle.targetX - particle.x;
const dy = particle.targetY - particle.y;
particle.vx += dx * 0.02;
particle.vy += dy * 0.02;
particle.vx *= 0.95;
particle.vy *= 0.95;
}
particle.x += particle.vx;
particle.y += particle.vy;
particle.life++;
ctx.beginPath();
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
ctx.fillStyle = particle.color;
ctx.globalAlpha = particle.opacity;
ctx.fill();
ctx.globalAlpha = 1;
});
animationRef.current = requestAnimationFrame(animate);
};
animate();
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
if (stageTimerRef.current) {
clearTimeout(stageTimerRef.current);
}
};
}, [width, height, createSealShape, createParticle, animationStages, onStageChange]);
return (
<canvas
ref={canvasRef}
className={className}
style={{ width, height }}
/>
);
}
-71
View File
@@ -1,71 +0,0 @@
'use client';
import { motion } from 'framer-motion';
import { useState, useEffect } from 'react';
interface SubtleDotsProps {
className?: string;
color?: string;
count?: number;
}
export function SubtleDots({
className = '',
color = '#C41E3A',
count = 12
}: SubtleDotsProps) {
const [dots, setDots] = useState<Array<{
id: number;
x: number;
y: number;
size: number;
delay: number;
}>>([]);
useEffect(() => {
const generatedDots = Array.from({ length: count }, (_, i) => ({
id: i,
x: 10 + Math.random() * 80,
y: 10 + Math.random() * 80,
size: 2 + Math.random() * 3,
delay: i * 0.3
}));
setDots(generatedDots);
}, [count]);
if (dots.length === 0) {
return <div className={`absolute inset-0 pointer-events-none ${className}`} />;
}
return (
<div className={`absolute inset-0 pointer-events-none ${className}`}>
{dots.map((dot) => (
<motion.div
key={dot.id}
className="absolute rounded-full"
style={{
width: dot.size,
height: dot.size,
backgroundColor: color,
left: `${dot.x}%`,
top: `${dot.y}%`
}}
initial={{ opacity: 0, scale: 0 }}
animate={{
opacity: [0, 0.2, 0.2, 0],
scale: [0, 1, 1, 0]
}}
transition={{
duration: 4,
delay: dot.delay,
repeat: Infinity,
ease: 'easeInOut',
times: [0, 0.3, 0.7, 1]
}}
/>
))}
</div>
);
}
export default SubtleDots;
@@ -1,74 +0,0 @@
'use client';
import { motion } from 'framer-motion';
import { useState, useEffect } from 'react';
interface SubtleParticleProps {
count?: number;
size?: number;
color?: string;
className?: string;
}
export function SubtleParticles({
count = 20,
size = 3,
color = '#C41E3A',
className = ''
}: SubtleParticleProps) {
const [particles, setParticles] = useState<Array<{
id: number;
x: number;
y: number;
delay: number;
duration: number;
}>>([]);
useEffect(() => {
const generatedParticles = Array.from({ length: count }, (_, i) => ({
id: i,
x: Math.random() * 100,
y: Math.random() * 100,
delay: Math.random() * 5,
duration: 8 + Math.random() * 4
}));
setParticles(generatedParticles);
}, [count]);
if (particles.length === 0) {
return <div className={`absolute inset-0 pointer-events-none ${className}`} />;
}
return (
<div className={`absolute inset-0 pointer-events-none ${className}`}>
{particles.map((particle) => (
<motion.div
key={particle.id}
className="absolute rounded-full"
style={{
width: size,
height: size,
backgroundColor: color,
left: `${particle.x}%`,
top: `${particle.y}%`
}}
initial={{ opacity: 0, scale: 0 }}
animate={{
opacity: [0, 0.3, 0.3, 0],
scale: [0, 1, 1, 0],
y: [0, -20, -20, 0]
}}
transition={{
duration: particle.duration,
delay: particle.delay,
repeat: Infinity,
ease: 'easeInOut',
times: [0, 0.3, 0.7, 1]
}}
/>
))}
</div>
);
}
export default SubtleParticles;
-106
View File
@@ -1,106 +0,0 @@
'use client';
import { motion, useReducedMotion } from 'framer-motion';
import { useEffect, useState } from 'react';
interface TechGridFlowProps {
className?: string;
variant?: 'default' | 'dense' | 'sparse';
color?: string;
}
interface GridLine {
id: number;
x1: number;
y1: number;
x2: number;
y2: number;
delay: number;
duration: number;
}
export function TechGridFlow({
className = '',
variant = 'default',
color = '#C41E3A',
}: TechGridFlowProps) {
const prefersReducedMotion = useReducedMotion();
const [lines, setLines] = useState<GridLine[]>([]);
useEffect(() => {
const lineCount = variant === 'dense' ? 30 : variant === 'sparse' ? 10 : 20;
const generatedLines: GridLine[] = [];
for (let i = 0; i < lineCount; i++) {
const isHorizontal = Math.random() > 0.5;
generatedLines.push({
id: i,
x1: isHorizontal ? 0 : Math.random() * 100,
y1: isHorizontal ? Math.random() * 100 : 0,
x2: isHorizontal ? 100 : Math.random() * 100,
y2: isHorizontal ? Math.random() * 100 : 100,
delay: Math.random() * 5,
duration: Math.random() * 10 + 10,
});
}
setLines(generatedLines);
}, [variant]);
return (
<div className={`absolute inset-0 overflow-hidden ${className}`} aria-hidden="true">
<svg
className="absolute inset-0 w-full h-full"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="none"
>
<defs>
<linearGradient id="gridGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor={color} stopOpacity="0" />
<stop offset="50%" stopColor={color} stopOpacity="0.15" />
<stop offset="100%" stopColor={color} stopOpacity="0" />
</linearGradient>
<filter id="glow">
<feGaussianBlur stdDeviation="2" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{lines.map((line) => (
<motion.line
key={line.id}
x1={`${line.x1}%`}
y1={`${line.y1}%`}
x2={`${line.x2}%`}
y2={`${line.y2}%`}
stroke="url(#gridGradient)"
strokeWidth="1"
filter="url(#glow)"
initial={{ pathLength: 0, opacity: 0 }}
animate={
prefersReducedMotion
? { pathLength: 1, opacity: 0.3 }
: {
pathLength: [0, 1, 1, 0],
opacity: [0, 0.3, 0.3, 0],
}
}
transition={{
duration: line.duration,
repeat: Infinity,
ease: 'easeInOut',
delay: line.delay,
}}
/>
))}
</svg>
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-white/20" />
</div>
);
}
export default TechGridFlow;
+4 -4
View File
@@ -14,16 +14,16 @@ interface BreadcrumbProps {
export function Breadcrumb({ items }: BreadcrumbProps) {
return (
<nav aria-label="breadcrumb" className="flex items-center space-x-1 text-sm text-[#5C5C5C] py-4">
<StaticLink href="/" className="flex items-center hover:text-[#C41E3A] transition-colors" aria-label="返回首页">
<nav aria-label="breadcrumb" className="flex items-center space-x-1 text-sm text-[var(--color-text-placeholder)] py-4">
<StaticLink href="/" className="flex items-center hover:text-[var(--color-brand-primary)] transition-colors" aria-label="返回首页">
<Home className="w-4 h-4" />
</StaticLink>
{items.map((item, index) => (
<div key={index} className="flex items-center">
<ChevronRight className="w-4 h-4 text-[#E5E5E5]" />
<ChevronRight className="w-4 h-4 text-[var(--color-border-primary)]" />
<StaticLink
href={item.href}
className="ml-1 hover:text-[#C41E3A] transition-colors"
className="ml-1 hover:text-[var(--color-brand-primary)] transition-colors"
>
{item.label}
</StaticLink>
+26 -29
View File
@@ -5,10 +5,10 @@ import { COMPANY_INFO, NAVIGATION_V2, MEGA_DROPDOWN_DATA } from '@/lib/constants
export function Footer() {
return (
<footer className="bg-[#1C1C1C] text-white py-16" data-testid="footer" role="contentinfo">
<footer className="bg-[var(--color-footer-bg)] text-white py-16" data-testid="footer" role="contentinfo">
<div className="container-wide">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-10 lg:gap-12">
<div data-testid="card-brand">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-10 lg:gap-8">
<div data-testid="card-brand" className="lg:col-span-2">
<div className="mb-6">
<Image
src="/logo-light.svg"
@@ -19,12 +19,12 @@ export function Footer() {
priority
/>
</div>
<p className="text-[#A0A0A0] text-sm leading-relaxed mb-6">
<p className="text-[var(--color-footer-text)] text-sm leading-relaxed mb-6">
{COMPANY_INFO.description}
</p>
<div className="pt-6 border-t border-[#333]">
<p className="text-sm text-[#A0A0A0] mb-3"></p>
<div className="inline-block p-2 rounded-lg border border-[#333]">
<div className="pt-6 border-t border-[var(--color-footer-border)]">
<p className="text-sm text-[var(--color-footer-text)] mb-3"></p>
<div className="inline-block p-2 rounded-lg border border-[var(--color-footer-border)]">
<Image
src="/images/qrcode_for_gh_a297181ff548_258.jpg"
alt="微信公众号二维码"
@@ -44,7 +44,7 @@ export function Footer() {
<li key={item.id}>
<StaticLink
href={item.href}
className="text-[#A0A0A0] hover:text-white transition-colors duration-200 text-sm"
className="text-[var(--color-footer-text)] hover:text-white transition-colors duration-200 text-sm"
>
{item.label}
</StaticLink>
@@ -54,26 +54,23 @@ export function Footer() {
</div>
<div data-testid="card-products">
<h3 className="font-semibold text-base mb-5 text-white"></h3>
<h3 className="font-semibold text-base mb-5 text-white"></h3>
<ul className="space-y-3">
{(MEGA_DROPDOWN_DATA.products ?? []).map((item) => (
<li key={item.id}>
<StaticLink
href={item.href}
className="text-[#A0A0A0] hover:text-white transition-colors duration-200 text-sm"
className="text-[var(--color-footer-text)] hover:text-white transition-colors duration-200 text-sm"
>
{item.title}
</StaticLink>
</li>
))}
</ul>
<h3 className="font-semibold text-base mb-5 mt-8 text-white"></h3>
<ul className="space-y-3">
{(MEGA_DROPDOWN_DATA.solutions ?? []).map((item) => (
<li key={item.id}>
<StaticLink
href={item.href}
className="text-[#A0A0A0] hover:text-white transition-colors duration-200 text-sm"
className="text-[var(--color-footer-text)] hover:text-white transition-colors duration-200 text-sm"
>
{item.title}
</StaticLink>
@@ -86,17 +83,17 @@ export function Footer() {
<h3 className="font-semibold text-base mb-5 text-white"></h3>
<ul className="space-y-4">
<li className="flex items-start gap-3">
<MapPin className="w-4 h-4 text-[#C41E3A] mt-0.5 shrink-0" />
<span className="text-[#A0A0A0] text-sm">{COMPANY_INFO.address}</span>
<MapPin className="w-4 h-4 text-[var(--color-brand-primary)] mt-0.5 shrink-0" />
<span className="text-[var(--color-footer-text)] text-sm">{COMPANY_INFO.address}</span>
</li>
<li className="flex items-center gap-3">
<Mail className="w-4 h-4 text-[#C41E3A] shrink-0" />
<span className="text-[#A0A0A0] text-sm">{COMPANY_INFO.email}</span>
<Mail className="w-4 h-4 text-[var(--color-brand-primary)] shrink-0" />
<span className="text-[var(--color-footer-text)] text-sm">{COMPANY_INFO.email}</span>
</li>
</ul>
<div className="mt-6 pt-6 border-t border-[#333]">
<p className="text-sm text-[#A0A0A0] mb-3"></p>
<div className="inline-block p-2 rounded-lg border border-[#333]">
<div className="mt-6 pt-6 border-t border-[var(--color-footer-border)]">
<p className="text-sm text-[var(--color-footer-text)] mb-3"></p>
<div className="inline-block p-2 rounded-lg border border-[var(--color-footer-border)]">
<Image
src="/images/149A1D2F-D9FD-49C7-B139-142C50C5FE8B_1_201_a.jpeg"
alt="企业微信业务咨询二维码"
@@ -110,37 +107,37 @@ export function Footer() {
</div>
</div>
<div className="border-t border-[#333] mt-12 pt-8 pb-24 md:pb-8">
<div className="border-t border-[var(--color-footer-border)] mt-12 pt-8 pb-24 md:pb-8">
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<p className="text-[#666] text-sm">
<p className="text-[var(--color-footer-text-muted)] text-sm">
© {new Date().getFullYear()} {COMPANY_INFO.name}. All rights reserved.
</p>
<div className="flex gap-6">
<StaticLink href="/privacy" className="text-[#666] hover:text-white text-sm transition-colors duration-200">
<StaticLink href="/privacy" className="text-[var(--color-footer-text-muted)] hover:text-white text-sm transition-colors duration-200">
</StaticLink>
<StaticLink href="/terms" className="text-[#666] hover:text-white text-sm transition-colors duration-200">
<StaticLink href="/terms" className="text-[var(--color-footer-text-muted)] hover:text-white text-sm transition-colors duration-200">
</StaticLink>
</div>
</div>
<div className="text-center mt-6 pt-6 border-t border-[#333]">
<div className="text-center mt-6 pt-6 border-t border-[var(--color-footer-border)]">
<div className="flex flex-col sm:flex-row justify-center items-center gap-2 sm:gap-4 text-xs">
<a
href="https://beian.miit.gov.cn/"
target="_blank"
rel="noopener noreferrer"
className="text-[#E0E0E0] hover:text-white transition-colors duration-200"
className="text-[var(--color-footer-text-link)] hover:text-white transition-colors duration-200"
>
{COMPANY_INFO.icp}
</a>
<span className="hidden sm:inline text-[#999]">|</span>
<span className="hidden sm:inline text-[var(--color-footer-text-dim)]">|</span>
<a
href="https://beian.mps.gov.cn/#/query/webSearch?code=51010602003285"
target="_blank"
rel="noreferrer"
className="text-[#E0E0E0] hover:text-white transition-colors duration-200 inline-flex items-center gap-1.5"
className="text-[var(--color-footer-text-link)] hover:text-white transition-colors duration-200 inline-flex items-center gap-1.5"
>
<Image
src="/images/beian-icon.png"
+12 -12
View File
@@ -77,7 +77,7 @@ function HeaderContent() {
fixed top-0 left-0 right-0 z-50
transition-all duration-300 ease-out
${isScrolled
? 'bg-white/95 backdrop-blur-xl border-b border-[#E2E8F0] shadow-sm'
? 'bg-[var(--color-bg-primary)]/95 backdrop-blur-xl border-b border-[var(--color-border-primary)] shadow-sm'
: 'bg-transparent'
}
`}
@@ -123,8 +123,8 @@ function HeaderContent() {
relative px-3 py-1.5 text-sm font-medium
transition-all duration-300
${isActive(item)
? 'text-[#1C1C1C]'
: 'text-[#3D3D3D] hover:text-[#1C1C1C]'
? 'text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
}
`}
aria-current={isActive(item) ? 'page' : undefined}
@@ -132,7 +132,7 @@ function HeaderContent() {
{item.label}
<span
className={`
absolute bottom-0 left-1/2 -translate-x-1/2 w-6 h-0.5 bg-[#C41E3A] rounded-full
absolute bottom-0 left-1/2 -translate-x-1/2 w-6 h-0.5 bg-[var(--color-brand-primary)] rounded-full
transition-all duration-200 ease-out
${isActive(item)
? 'opacity-100 scale-x-100'
@@ -155,7 +155,7 @@ function HeaderContent() {
</div>
<button
className="md:hidden p-3 -mr-3 text-[#3D3D3D] hover:text-[#1C1C1C] hover:bg-[#F5F5F5] rounded-lg transition-all duration-200 active:scale-95"
className="md:hidden p-3 -mr-3 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-primary-lighter)] rounded-lg transition-all duration-200 active:scale-95"
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
aria-expanded={isOpen}
@@ -190,7 +190,7 @@ function HeaderContent() {
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
className="absolute top-16 right-0 bottom-0 left-0 bg-white/98 backdrop-blur-xl shadow-2xl overflow-y-auto"
className="absolute top-16 right-0 bottom-0 left-0 bg-[var(--color-bg-primary)]/98 backdrop-blur-xl shadow-2xl overflow-y-auto"
id="mobile-menu"
role="navigation"
aria-label="移动端导航"
@@ -211,8 +211,8 @@ function HeaderContent() {
block px-4 py-4 text-base font-medium rounded-lg
transition-all duration-200
${isActive(item)
? 'text-[#1C1C1C] bg-[#F5F5F5] border-l-4 border-[#C41E3A]'
: 'text-[#3D3D3D] hover:text-[#1C1C1C] hover:bg-[#F5F5F5]'
? 'text-[var(--color-text-primary)] bg-[var(--color-primary-lighter)] border-l-4 border-[var(--color-brand-primary)]'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-primary-lighter)]'
}
`}
style={{ minHeight: '48px', display: 'flex', alignItems: 'center' }}
@@ -221,7 +221,7 @@ function HeaderContent() {
</StaticLink>
</motion.div>
))}
<div className="mt-6 px-4 pt-6 border-t border-[#E2E8F0]">
<div className="mt-6 px-4 pt-6 border-t border-[var(--color-border-primary)]">
<Button
className="w-full"
asChild
@@ -246,13 +246,13 @@ function HeaderFallback() {
<header className="fixed top-0 left-0 right-0 z-50 bg-transparent">
<div className="container-wide">
<div className="flex items-center justify-between h-16">
<div className="h-8 w-8 bg-gray-200 animate-pulse rounded" />
<div className="h-8 w-8 bg-[var(--color-skeleton-bg)] animate-pulse rounded" />
<nav className="hidden md:flex items-center gap-1">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-6 w-16 bg-gray-200 animate-pulse rounded mx-1" />
<div key={i} className="h-6 w-16 bg-[var(--color-skeleton-bg)] animate-pulse rounded mx-1" />
))}
</nav>
<div className="h-9 w-20 bg-gray-200 animate-pulse rounded" />
<div className="h-9 w-20 bg-[var(--color-skeleton-bg)] animate-pulse rounded" />
</div>
</div>
</header>
+27 -52
View File
@@ -2,69 +2,42 @@
import { useRef, useEffect } from 'react';
import { ChevronDown } from 'lucide-react';
import { AnimatePresence, motion } from 'framer-motion';
import { StaticLink } from '@/components/ui/static-link';
import type { MegaDropdownItem } from '@/lib/constants';
import { AnimatePresence, motion } from 'framer-motion';
interface MegaDropdownItem {
id: string;
title: string;
description: string;
href: string;
}
interface MegaDropdownProps {
label: string;
items: MegaDropdownItem[];
isOpen: boolean;
onToggle: () => void;
onOpen?: () => void;
onClose?: () => void;
onOpen: () => void;
onClose: () => void;
}
const HOVER_DELAY = 150;
export function MegaDropdown({ label, items, isOpen, onToggle, onOpen, onClose }: MegaDropdownProps) {
const dropdownRef = useRef<HTMLDivElement>(null);
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
if (isOpen) { onToggle(); }
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen, onToggle]);
useEffect(() => {
return () => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
};
}, []);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleMouseEnter = () => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
if (!isOpen) {
if (onOpen) {
onOpen();
} else {
onToggle();
}
}
clearTimeout(timeoutRef.current);
onOpen();
};
const handleMouseLeave = () => {
hoverTimeoutRef.current = setTimeout(() => {
if (isOpen) {
if (onClose) {
onClose();
} else {
onToggle();
}
}
}, HOVER_DELAY);
timeoutRef.current = setTimeout(onClose, 150);
};
useEffect(() => {
return () => clearTimeout(timeoutRef.current);
}, []);
return (
<div ref={dropdownRef} className="relative" onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<button
@@ -73,8 +46,8 @@ export function MegaDropdown({ label, items, isOpen, onToggle, onOpen, onClose }
flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md
transition-all duration-200
${isOpen
? 'text-[#C41E3A] bg-[#FEF2F4]'
: 'text-[#3D3D3D] hover:text-[#1C1C1C] hover:bg-[#F5F5F5]'
? 'text-[var(--color-brand-primary)] bg-[var(--color-brand-primary-bg)]'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)]'
}
`}
aria-expanded={isOpen}
@@ -93,19 +66,21 @@ export function MegaDropdown({ label, items, isOpen, onToggle, onOpen, onClose }
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.15 }}
className="absolute top-full left-1/2 -translate-x-1/2 mt-3 w-[520px] bg-white rounded-xl border border-[#E5E5E5] shadow-lg p-5 z-50"
className="absolute top-full left-1/2 -translate-x-1/2 pt-2 w-[520px] z-50"
>
<div className="grid grid-cols-2 gap-3">
<div className="bg-[var(--color-bg-primary)] rounded-xl border border-[var(--color-border-primary)] shadow-lg p-5">
<div className="grid grid-cols-2 gap-3">
{items.map((item) => (
<StaticLink
key={item.id}
href={item.href}
className="block p-4 rounded-lg border-l-[3px] border-l-[#C41E3A] hover:bg-[#FFFBF5] transition-colors duration-200"
className="block p-4 rounded-lg border-l-[3px] border-l-[var(--color-brand-primary)] hover:bg-[var(--color-bg-secondary)] transition-colors duration-200"
>
<div className="text-sm font-semibold text-[#1C1C1C]">{item.title}</div>
<div className="text-xs text-[#5C5C5C] mt-1.5 leading-relaxed">{item.description}</div>
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{item.title}</div>
<div className="text-xs text-[var(--color-text-placeholder)] mt-1.5 leading-relaxed">{item.description}</div>
</StaticLink>
))}
</div>
</div>
</motion.div>
)}
+9 -9
View File
@@ -46,15 +46,15 @@ export function MobileMenu({ className }: MobileMenuProps) {
<button
onClick={() => setIsOpen(!isOpen)}
onKeyDown={(e) => handleKeyDown(e)}
className="p-3 rounded-md hover:bg-[#F5F5F5] transition-colors focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:ring-offset-2 min-w-[48px] min-h-[48px] flex items-center justify-center"
className="p-3 rounded-md hover:bg-[var(--color-primary-lighter)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-primary)] focus:ring-offset-2 min-w-[48px] min-h-[48px] flex items-center justify-center"
aria-label={isOpen ? '关闭菜单' : '打开菜单'}
aria-expanded={isOpen}
aria-controls="mobile-menu-panel"
>
{isOpen ? (
<X className="w-6 h-6 text-[#171717]" />
<X className="w-6 h-6 text-[var(--color-text-primary)]" />
) : (
<Menu className="w-6 h-6 text-[#171717]" />
<Menu className="w-6 h-6 text-[var(--color-text-primary)]" />
)}
</button>
@@ -68,7 +68,7 @@ export function MobileMenu({ className }: MobileMenuProps) {
<nav
id="mobile-menu-panel"
className="fixed top-16 left-0 right-0 bg-white border-b border-[#E5E5E5] z-50 shadow-lg max-h-[calc(100vh-4rem)] overflow-y-auto"
className="fixed top-16 left-0 right-0 bg-[var(--color-bg-primary)] border-b border-[var(--color-border-primary)] z-50 shadow-lg max-h-[calc(100vh-4rem)] overflow-y-auto"
role="navigation"
aria-label="移动端导航"
>
@@ -81,7 +81,7 @@ export function MobileMenu({ className }: MobileMenuProps) {
<button
onClick={() => toggleDropdown(item.dropdownKey!)}
onKeyDown={(e) => handleKeyDown(e, () => toggleDropdown(item.dropdownKey!))}
className="flex items-center justify-between w-full text-left px-4 py-4 text-[#171717] hover:bg-[#FEF2F4] hover:text-[#C41E3A] rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:ring-inset min-h-[48px]"
className="flex items-center justify-between w-full text-left px-4 py-4 text-[var(--color-text-primary)] hover:bg-[var(--color-brand-primary-bg)] hover:text-[var(--color-brand-primary)] rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-primary)] focus:ring-inset min-h-[48px]"
aria-expanded={expandedDropdown === item.dropdownKey}
>
{item.label}
@@ -99,10 +99,10 @@ export function MobileMenu({ className }: MobileMenuProps) {
<StaticLink
href={sub.href}
onClick={() => setIsOpen(false)}
className="block px-4 py-3 text-sm text-[#595959] hover:text-[#C41E3A] hover:bg-[#FEF2F4] rounded-md transition-colors"
className="block px-4 py-3 text-sm text-[var(--color-text-muted)] hover:text-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-bg)] rounded-md transition-colors"
>
<span className="font-medium text-[#1C1C1C]">{sub.title}</span>
<span className="block text-xs text-[#8C8C8C] mt-0.5">{sub.description}</span>
<span className="font-medium text-[var(--color-text-primary)]">{sub.title}</span>
<span className="block text-xs text-[var(--color-text-hint)] mt-0.5">{sub.description}</span>
</StaticLink>
</li>
))}
@@ -113,7 +113,7 @@ export function MobileMenu({ className }: MobileMenuProps) {
<StaticLink
href={item.href}
onClick={() => setIsOpen(false)}
className="block px-4 py-4 text-[#171717] hover:bg-[#FEF2F4] hover:text-[#C41E3A] rounded-md transition-colors min-h-[48px]"
className="block px-4 py-4 text-[var(--color-text-primary)] hover:bg-[var(--color-brand-primary-bg)] hover:text-[var(--color-brand-primary)] rounded-md transition-colors min-h-[48px]"
>
{item.label}
</StaticLink>
@@ -61,7 +61,7 @@ describe('MobileTabBar', () => {
it('should show active indicator', () => {
render(<MobileTabBar />);
const activeIndicator = document.querySelector('.bg-\\[\\#C41E3A\\]');
const activeIndicator = document.querySelector('.bg-\\[var\\(--color-brand-primary\\)\\]');
expect(activeIndicator).toBeInTheDocument();
});
});
+8 -8
View File
@@ -2,14 +2,14 @@
import { StaticLink } from '@/components/ui/static-link';
import { usePathname } from 'next/navigation';
import { Home, Briefcase, Package, FileText, User } from 'lucide-react';
import { Home, Lightbulb, Package, FileText, User } from 'lucide-react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
const tabs = [
{ id: 'home', label: '首页', href: '/', icon: Home },
{ id: 'services', label: '服务', href: '/services', icon: Briefcase },
{ id: 'products', label: '产品', href: '/products', icon: Package },
{ id: 'solutions', label: '方案', href: '/solutions', icon: Lightbulb },
{ id: 'about', label: '关于', href: '/about', icon: FileText },
{ id: 'contact', label: '联系', href: '/contact', icon: User },
];
@@ -27,8 +27,8 @@ export function MobileTabBar() {
if (id === 'products') {
return pathname === '/products' || pathname.startsWith('/products/');
}
if (id === 'services') {
return pathname === '/services' || pathname.startsWith('/services/');
if (id === 'solutions') {
return pathname === '/solutions' || pathname.startsWith('/solutions/');
}
if (id === 'about') {
return pathname === '/about';
@@ -37,7 +37,7 @@ export function MobileTabBar() {
};
return (
<nav className="fixed bottom-0 left-0 right-0 z-50 md:hidden bg-white/95 backdrop-blur-xl border-t border-[#E5E5E5] safe-area-inset-bottom">
<nav className="fixed bottom-0 left-0 right-0 z-50 md:hidden bg-[var(--color-bg-primary)]/95 backdrop-blur-xl border-t border-[var(--color-border-primary)] safe-area-inset-bottom">
<div className="flex items-center justify-around h-16">
{tabs.map((tab) => {
const Icon = tab.icon;
@@ -53,13 +53,13 @@ export function MobileTabBar() {
<Icon
className={cn(
'w-6 h-6 transition-colors',
active ? 'text-[#C41E3A]' : 'text-[#5C5C5C] group-hover:text-[#1C1C1C]'
active ? 'text-[var(--color-brand-primary)]' : 'text-[var(--color-text-placeholder)] group-hover:text-[var(--color-text-primary)]'
)}
/>
<span
className={cn(
'text-xs mt-1 transition-colors',
active ? 'text-[#C41E3A] font-medium' : 'text-[#5C5C5C]'
active ? 'text-[var(--color-brand-primary)] font-medium' : 'text-[var(--color-text-placeholder)]'
)}
>
{tab.label}
@@ -67,7 +67,7 @@ export function MobileTabBar() {
{active && (
<motion.div
layoutId="activeTab"
className="absolute -bottom-1 w-8 h-0.5 bg-[#C41E3A] rounded-full"
className="absolute -bottom-1 w-8 h-0.5 bg-[var(--color-brand-primary)] rounded-full"
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
/>
)}
+5 -5
View File
@@ -14,23 +14,23 @@ interface PageNavProps {
export function PageNav({ items }: PageNavProps) {
return (
<nav aria-label="breadcrumb" className="flex items-center gap-[3px] md:gap-1 text-[11px] md:text-sm text-[#A3A3A3] mb-2 md:mb-8 -ml-0.5">
<StaticLink href="/" className="flex items-center w-fit hover:text-[#C41E3A] transition-colors" aria-label="返回首页">
<nav aria-label="breadcrumb" className="flex items-center gap-[3px] md:gap-1 text-[11px] md:text-sm text-[var(--color-text-subtle)] mb-2 md:mb-8 -ml-0.5">
<StaticLink href="/" className="flex items-center w-fit hover:text-[var(--color-brand-primary)] transition-colors" aria-label="返回首页">
<Home className="w-2.5 h-2.5 md:w-3.5 md:h-3.5" />
</StaticLink>
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<div key={index} className="flex items-center gap-[3px] md:gap-1">
<ChevronRight className="w-2.5 h-2.5 md:w-3.5 md:h-3.5 text-[#D4D4D4]" />
<ChevronRight className="w-2.5 h-2.5 md:w-3.5 md:h-3.5 text-[var(--color-border-secondary)]" />
{isLast || !item.href ? (
<span className={isLast ? 'text-[#1C1C1C] font-medium' : ''}>
<span className={isLast ? 'text-[var(--color-text-primary)] font-medium' : ''}>
{item.label}
</span>
) : (
<StaticLink
href={item.href}
className="hover:text-[#C41E3A] transition-colors"
className="hover:text-[var(--color-brand-primary)] transition-colors"
>
{item.label}
</StaticLink>
@@ -1,109 +0,0 @@
import { describe, it, expect, beforeEach } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { AboutSection } from './about-section';
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
},
useInView: () => true,
}));
jest.mock('next/link', () => {
const MockLink = ({ children, href }: any) => <a href={href}>{children}</a>;
MockLink.displayName = 'MockLink';
return MockLink;
});
describe('AboutSection', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render about section', () => {
render(<AboutSection />);
const section = document.querySelector('section#about');
expect(section).toBeInTheDocument();
});
it('should render section heading', () => {
render(<AboutSection />);
expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument();
});
it('should render company slogan', () => {
render(<AboutSection />);
expect(screen.getByText(/数字化转型不是一场冒险/)).toBeInTheDocument();
});
it('should render company mission', () => {
render(<AboutSection />);
expect(screen.getByText(/务实/)).toBeInTheDocument();
});
});
describe('Statistics', () => {
it('should render statistics cards', () => {
render(<AboutSection />);
const cards = document.querySelectorAll('.text-3xl');
expect(cards.length).toBeGreaterThan(0);
});
it('should display statistics in grid layout', () => {
const { container } = render(<AboutSection />);
const grid = container.querySelector('.grid-cols-1');
expect(grid).toBeInTheDocument();
});
});
describe('Call to Action', () => {
it('should render value cards with titles', () => {
render(<AboutSection />);
expect(screen.getByText('务实')).toBeInTheDocument();
expect(screen.getByText('共情')).toBeInTheDocument();
expect(screen.getByText('敏捷')).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have region role', () => {
render(<AboutSection />);
const section = screen.getByRole('region');
expect(section).toBeInTheDocument();
});
it('should have aria-labelledby attribute', () => {
render(<AboutSection />);
const section = document.querySelector('section#about');
expect(section).toHaveAttribute('aria-labelledby', 'about-heading');
});
it('should have accessible heading', () => {
render(<AboutSection />);
const heading = screen.getByRole('heading', { level: 2 });
expect(heading).toHaveAttribute('id', 'about-heading');
});
});
describe('Styling', () => {
it('should have background color', () => {
render(<AboutSection />);
const section = document.querySelector('section#about');
expect(section).toHaveClass('bg-[#FAFAFA]');
});
it('should have proper padding', () => {
render(<AboutSection />);
const section = document.querySelector('section#about');
expect(section).toHaveClass('py-20');
});
it('should have decorative background pattern', () => {
const { container } = render(<AboutSection />);
const pattern = container.querySelector('.absolute.inset-0');
expect(pattern).toBeInTheDocument();
});
});
});
-83
View File
@@ -1,83 +0,0 @@
'use client';
import { motion } from 'framer-motion';
import { useInView } from 'framer-motion';
import { useRef } from 'react';
import { InkGlowCard } from '@/components/ui/ink-glow-card';
import { Target, Heart, Zap } from 'lucide-react';
const VALUES = [
{
icon: Target,
title: '务实',
description: '不画大饼,只做能落地的方案。每个建议都经过深思熟虑,每个方向都经得起推敲。',
},
{
icon: Heart,
title: '共情',
description: '先理解您的困境,再提供解决方案。我们相信,好的技术伙伴首先是好的倾听者。',
},
{
icon: Zap,
title: '敏捷',
description: '快速响应,持续迭代。在不确定的市场中,速度本身就是竞争力。',
},
];
const valueAccents: { rgb: string; glowStart: string; glowEnd: string }[] = [
{ rgb: '196, 30, 58', glowStart: '#C41E3A', glowEnd: '#D97706' },
{ rgb: '217, 119, 6', glowStart: '#D97706', glowEnd: '#16A34A' },
{ rgb: '37, 99, 235', glowStart: '#2563EB', glowEnd: '#7C3AED' },
];
export function AboutSection() {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: '-100px' });
return (
<section id="about" role="region" aria-labelledby="about-heading" className="py-20 md:py-28 bg-[#FAFAFA]" ref={ref}>
<div className="container-wide">
<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="text-center max-w-3xl mx-auto mb-14"
>
<h2 id="about-heading" className="text-3xl sm:text-4xl font-semibold text-[#1C1C1C] mb-4">
<span className="text-[#C41E3A] font-calligraphy"></span>
</h2>
<p className="text-base text-[#595959]">
</p>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8">
{VALUES.map((item, idx) => {
const Icon = item.icon;
const accent = valueAccents[idx % valueAccents.length]!;
return (
<InkGlowCard
key={item.title}
index={idx}
accentColorRgb={accent.rgb}
glowStart={accent.glowStart}
glowEnd={accent.glowEnd}
>
<div className="p-6 md:p-8">
<div
className="w-11 h-11 rounded-xl flex items-center justify-center mb-5"
style={{ backgroundColor: `rgba(${accent.rgb}, 0.06)` }}
>
<Icon className="w-5 h-5" style={{ color: accent.glowStart }} strokeWidth={1.8} />
</div>
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-2">{item.title}</h3>
<p className="text-sm text-[#595959] leading-relaxed">{item.description}</p>
</div>
</InkGlowCard>
);
})}
</div>
</div>
</section>
);
}
+12 -30
View File
@@ -1,6 +1,5 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { motion } from 'framer-motion';
import { ChallengeCard } from '@/components/ui/challenge-card';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
@@ -30,44 +29,27 @@ const CHALLENGES = [
];
export function ChallengeSection() {
const [isVisible, setIsVisible] = useState(false);
const sectionRef = useRef<HTMLElement>(null);
const shouldReduceMotion = useReducedMotion();
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
setIsVisible(true);
}
},
{ threshold: 0.1 }
);
if (sectionRef.current) {
observer.observe(sectionRef.current);
}
return () => observer.disconnect();
}, []);
return (
<section
id="challenges"
ref={sectionRef}
className="py-20 md:py-28 bg-[#FAFAFA]"
>
<section id="challenges" className="py-20 md:py-28 bg-[var(--color-bg-primary)]">
<div className="container-wide">
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={isVisible ? { opacity: 1, y: 0 } : {}}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-100px' }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
className="text-center max-w-3xl mx-auto mb-14"
className="mb-14"
>
<h2 className="text-3xl sm:text-4xl font-semibold text-[#1C1C1C] mb-4">
<span className="text-[#C41E3A] font-calligraphy">使</span>
<div className="flex items-center gap-4 mb-4">
<div className="h-px flex-1 bg-[var(--color-border-primary)]" />
<span className="text-xs font-mono tracking-widest text-[var(--color-text-subtle)]">CHALLENGES</span>
<div className="h-px flex-1 bg-[var(--color-border-primary)]" />
</div>
<h2 className="text-3xl sm:text-4xl font-semibold text-[var(--color-text-primary)] text-center">
<span className="text-[var(--color-brand-primary)] font-calligraphy">使</span>
</h2>
<p className="text-base text-[#595959]">
<p className="text-base text-[var(--color-text-muted)] text-center mt-4 max-w-xl mx-auto">
</p>
</motion.div>
-343
View File
@@ -1,343 +0,0 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Toast } from '@/components/ui/toast';
import { useFormAutosave } from '@/hooks/use-form-autosave';
import { Mail, MapPin, Send, Loader2, Clock, HeadphonesIcon, CheckCircle2, Save } from 'lucide-react';
import { COMPANY_INFO } from '@/lib/constants';
const contactFormSchema = z.object({
name: z.string().min(2, '姓名至少需要2个字符'),
phone: z.string().regex(/^1[3-9]\d{9}$/, '请输入有效的手机号码'),
email: z.string().email('请输入有效的邮箱地址'),
message: z.string().min(10, '留言内容至少需要10个字符'),
});
type ContactFormData = z.infer<typeof contactFormSchema>;
interface FormErrors {
name?: string;
phone?: string;
email?: string;
message?: string;
}
export function ContactSection() {
const [isVisible, setIsVisible] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [showToast, setShowToast] = useState(false);
const [toastMessage, setToastMessage] = useState('');
const [toastType, setToastType] = useState<'success' | 'error'>('success');
const [errors, setErrors] = useState<FormErrors>({});
const sectionRef = useRef<HTMLElement>(null);
const {
data: formData,
updateData,
lastSaved,
isRestored,
clearSavedData,
} = useFormAutosave<ContactFormData>({
key: 'contact_form',
initialData: {
name: '',
phone: '',
email: '',
message: '',
},
debounceMs: 1000,
});
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
setIsVisible(true);
}
},
{ threshold: 0.1 }
);
if (sectionRef.current) {
observer.observe(sectionRef.current);
}
return () => observer.disconnect();
}, []);
const validateField = (field: keyof ContactFormData, value: string) => {
try {
contactFormSchema.shape[field].parse(value);
setErrors((prev) => ({ ...prev, [field]: undefined }));
} catch (error) {
if (error instanceof z.ZodError) {
const fieldError = error.issues[0];
if (fieldError) {
setErrors((prev) => ({ ...prev, [field]: fieldError.message }));
}
}
}
};
const handleChange = (field: keyof ContactFormData, value: string) => {
updateData({ [field]: value });
if (errors[field]) {
validateField(field, value);
}
};
const handleBlur = (field: keyof ContactFormData, value: string) => {
validateField(field, value);
};
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const result = contactFormSchema.safeParse(formData);
if (!result.success) {
const fieldErrors: FormErrors = {};
result.error.issues.forEach((issue) => {
const field = issue.path[0] as keyof ContactFormData;
fieldErrors[field] = issue.message;
});
setErrors(fieldErrors);
return;
}
setIsSubmitting(true);
try {
const response = await fetch('https://formspree.io/f/' + process.env.NEXT_PUBLIC_FORMSPREE_ID, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(formData),
});
if (response.ok) {
setIsSubmitted(true);
clearSavedData();
setToastMessage('表单提交成功!我们会尽快与您联系。');
setToastType('success');
setShowToast(true);
} else {
setToastMessage('提交失败,请稍后重试。');
setToastType('error');
setShowToast(true);
}
} catch {
setToastMessage('网络错误,请稍后重试。');
setToastType('error');
setShowToast(true);
} finally {
setIsSubmitting(false);
}
}
return (
<section id="contact" role="region" aria-labelledby="contact-heading" className="py-20 md:py-28 bg-white" ref={sectionRef}>
{showToast && (
<Toast
message={toastMessage}
type={toastType}
onClose={() => setShowToast(false)}
data-testid="toast-notification"
/>
)}
<div className="container-wide">
<div
className={`mb-14 opacity-0 translate-y-4 ${isVisible ? 'animate-fade-in-up' : ''}`}
>
<h2 id="contact-heading" className="text-3xl sm:text-4xl font-semibold text-[#1C1C1C] mb-4 text-center">
<span className="text-[#C41E3A] font-calligraphy"></span>
</h2>
<p className="text-base text-[#595959] max-w-2xl mx-auto text-center">
</p>
</div>
<div className="grid lg:grid-cols-5 gap-12 lg:gap-16">
<div
className={`lg:col-span-2 space-y-8 flex flex-col opacity-0 translate-y-4 ${isVisible ? 'animate-fade-in-up stagger-1' : ''}`}
>
<div>
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-6"></h3>
<div className="space-y-4">
<div className="flex items-start gap-4 group">
<div className="w-10 h-10 bg-[#C41E3A] rounded-lg flex items-center justify-center shrink-0 transition-transform duration-200 group-hover:scale-105">
<Mail className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-sm text-[#A3A3A3] mb-1"></p>
<a href={`mailto:${COMPANY_INFO.email}`} className="text-[#1C1C1C] hover:text-[#C41E3A] transition-colors duration-200">
{COMPANY_INFO.email}
</a>
</div>
</div>
<div className="flex items-start gap-4 group">
<div className="w-10 h-10 bg-[#C41E3A] rounded-lg flex items-center justify-center shrink-0 transition-transform duration-200 group-hover:scale-105">
<MapPin className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-sm text-[#A3A3A3] mb-1"></p>
<p className="text-[#1C1C1C]">{COMPANY_INFO.address}</p>
</div>
</div>
</div>
</div>
<div className="bg-[#FAFAFA] p-5 rounded-xl border border-[#F0F0F0]" aria-label="工作时间" data-testid="work-hours-card">
<div className="flex items-center gap-2 mb-3">
<Clock className="w-4 h-4 text-[#C41E3A]" />
<h4 className="text-sm font-medium text-[#1C1C1C]"></h4>
</div>
<div className="space-y-1">
<div className="flex justify-between text-sm" data-testid="work-hours-row">
<span className="text-[#595959]"></span>
<span className="text-[#C41E3A]">9:00 - 18:00</span>
</div>
</div>
</div>
<div className="bg-[#FAFAFA] p-5 rounded-xl border border-[#F0F0F0]">
<div className="flex items-center gap-2 mb-3">
<HeadphonesIcon className="w-4 h-4 text-[#C41E3A]" />
<h4 className="text-sm font-medium text-[#1C1C1C]"></h4>
</div>
<div className="space-y-3">
<div className="flex items-start gap-2">
<div className="w-1.5 h-1.5 bg-[#C41E3A] rounded-full mt-2 shrink-0" />
<p className="text-sm text-[#595959]"> 2 </p>
</div>
<div className="flex items-start gap-2">
<div className="w-1.5 h-1.5 bg-[#C41E3A] rounded-full mt-2 shrink-0" />
<p className="text-sm text-[#595959]"></p>
</div>
<div className="flex items-start gap-2">
<div className="w-1.5 h-1.5 bg-[#C41E3A] rounded-full mt-2 shrink-0" />
<p className="text-sm text-[#595959]"></p>
</div>
</div>
</div>
</div>
<div
className={`lg:col-span-3 flex flex-col opacity-0 translate-y-4 ${isVisible ? 'animate-fade-in-up stagger-2' : ''}`}
>
<div className="bg-[#FAFAFA] p-6 sm:p-8 rounded-xl border border-[#F0F0F0] flex-1 flex flex-col">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-[#1C1C1C]"></h3>
<div className="flex items-center gap-2 text-sm text-[#A3A3A3]">
{lastSaved && (
<>
<Save className="w-4 h-4" />
<span> {lastSaved.toLocaleTimeString()}</span>
</>
)}
</div>
</div>
{isRestored && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-center justify-between">
<span className="text-sm text-blue-700">
</span>
<button
type="button"
onClick={clearSavedData}
className="text-sm text-blue-600 hover:text-blue-800 underline"
>
</button>
</div>
)}
{isSubmitted ? (
<div className="text-center py-12 flex-1 flex items-center justify-center" data-testid="success-message">
<div className="w-16 h-16 bg-[#C41E3A] rounded-full flex items-center justify-center mx-auto mb-4 animate-stamp-in">
<CheckCircle2 className="w-8 h-8 text-white" />
</div>
<h4 className="text-xl font-semibold text-[#1C1C1C] mb-2"></h4>
<p className="text-[#A3A3A3]"></p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-5 flex-1 flex flex-col">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label="姓名"
id="name"
placeholder="请输入您的姓名"
required
data-testid="name-input"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
onBlur={(e) => handleBlur('name', e.target.value)}
error={errors.name}
/>
<Input
label="电话"
id="phone"
type="tel"
placeholder="请输入您的电话"
required
data-testid="phone-input"
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
onBlur={(e) => handleBlur('phone', e.target.value)}
error={errors.phone}
/>
</div>
<Input
label="邮箱"
id="email"
type="email"
placeholder="请输入您的邮箱"
required
data-testid="email-input"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
onBlur={(e) => handleBlur('email', e.target.value)}
error={errors.email}
/>
<Textarea
label="留言内容"
id="message"
placeholder="请输入您想咨询的内容"
rows={5}
required
data-testid="message-input"
value={formData.message}
onChange={(e) => handleChange('message', e.target.value)}
onBlur={(e) => handleBlur('message', e.target.value)}
error={errors.message}
/>
<Button
type="submit"
size="lg"
className="w-full group mt-auto min-h-13 md:min-h-0"
disabled={isSubmitting}
data-testid="submit-button"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Send className="mr-2 h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</>
)}
</Button>
</form>
)}
</div>
</div>
</div>
</div>
</section>
);
}
+15 -6
View File
@@ -5,6 +5,7 @@ import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button';
import { ArrowRight } from 'lucide-react';
import { COMPANY_INFO } from '@/lib/constants';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
interface CTASectionProps {
title?: string;
@@ -23,20 +24,28 @@ export function CTASection({
secondaryLabel = '了解方案',
secondaryHref = '/solutions',
}: CTASectionProps) {
const shouldReduceMotion = useReducedMotion();
return (
<section id="cta" className="py-20 md:py-28 bg-[#1C1C1C]">
<div className="container-wide">
<section id="cta" className="relative py-20 md:py-28 bg-[var(--color-primary)] overflow-hidden">
<div
className="pointer-events-none absolute inset-0"
style={{
background: 'linear-gradient(to top, rgba(var(--color-brand-primary-rgb), 0.04) 0%, transparent 60%)',
}}
/>
<div className="container-wide relative z-10">
<motion.div
initial={{ opacity: 0, y: 20 }}
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
viewport={{ once: true, margin: '-100px' }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
className="text-center max-w-3xl mx-auto"
>
<h2 className="text-3xl sm:text-4xl font-semibold text-white mb-4">
{title}
</h2>
<p className="text-lg text-[#A3A3A3] mb-10">
<p className="text-lg text-white/70 mb-10">
{description}
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
@@ -46,7 +55,7 @@ export function CTASection({
<ArrowRight className="w-4 h-4 ml-2" />
</StaticLink>
</Button>
<Button size="lg" variant="outline" className="border-white/20 text-white hover:bg-white/10" asChild>
<Button size="lg" variant="outline" className="border-white/30 text-white hover:bg-white/10 hover:border-white/50" asChild>
<StaticLink href={secondaryHref}>
{secondaryLabel}
</StaticLink>
+168 -71
View File
@@ -1,101 +1,198 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { motion } from 'framer-motion';
import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button';
import { COMPANY_INFO } from '@/lib/constants';
import { ArrowRight } from 'lucide-react';
import { ArrowRight, MessageSquare, Search, Rocket, Handshake } from 'lucide-react';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
const EASE = [0.16, 1, 0.3, 1] as const;
const CAPABILITIES = [
{ icon: MessageSquare, label: '需求沟通' },
{ icon: Search, label: '方案诊断' },
{ icon: Rocket, label: '敏捷交付' },
{ icon: Handshake, label: '长期陪跑' },
];
const JOURNEY_STEPS = [
{ icon: MessageSquare, label: '需求沟通', desc: '深入理解您的业务痛点' },
{ icon: Search, label: '方案诊断', desc: '量身定制技术路径' },
{ icon: Rocket, label: '敏捷交付', desc: '快速迭代持续验证' },
{ icon: Handshake, label: '长期陪跑', desc: '持续优化保障落地' },
];
export function HeroSectionV2() {
const [isVisible, setIsVisible] = useState(false);
const sectionRef = useRef<HTMLElement>(null);
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
const [isCardHovered, setIsCardHovered] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);
const shouldReduceMotion = useReducedMotion();
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
setIsVisible(true);
}
},
{ threshold: 0.1 }
);
if (sectionRef.current) {
observer.observe(sectionRef.current);
}
return () => observer.disconnect();
const handleCardMouseMove = useCallback((e: React.MouseEvent) => {
if (!cardRef.current) { return; }
const rect = cardRef.current.getBoundingClientRect();
setMousePos({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
}, []);
const fadeUp = {
const fadeUp = (delay: number) => ({
initial: shouldReduceMotion ? {} : { opacity: 0, y: 24 },
animate: isVisible ? { opacity: 1, y: 0 } : {},
};
animate: { opacity: 1, y: 0 },
transition: { duration: 0.5, delay, ease: EASE },
});
return (
<section
id="home"
ref={sectionRef}
aria-labelledby="hero-heading"
className="relative min-h-screen flex flex-col justify-center overflow-hidden bg-white"
className="relative min-h-screen flex flex-col justify-center overflow-hidden bg-[var(--color-bg-primary)]"
>
<div
className="pointer-events-none absolute inset-0"
style={{
background:
'radial-gradient(ellipse at 15% 40%, rgba(var(--color-brand-primary-rgb), 0.05) 0%, transparent 55%), radial-gradient(ellipse at 85% 25%, rgba(var(--color-primary-rgb), 0.04) 0%, transparent 50%), radial-gradient(ellipse at 50% 90%, rgba(var(--color-brand-primary-rgb), 0.03) 0%, transparent 40%)',
}}
/>
<div className="container-wide py-24 md:py-32 lg:py-40 relative z-10 flex-1 flex items-center">
<div className="max-w-3xl">
<motion.div
{...fadeUp}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
className="mb-6"
>
<span className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[#FEF2F4] text-[#C41E3A] text-sm font-medium border border-[#C41E3A]/10">
</span>
</motion.div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20 items-center w-full">
<div>
<motion.div
{...fadeUp(0)}
className="mb-8"
>
<span className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[var(--color-brand-primary-bg)] text-[var(--color-brand-primary)] text-sm font-medium border border-[var(--color-brand-primary)]/10">
{COMPANY_INFO.slogan}
</span>
</motion.div>
<motion.h1
id="hero-heading"
{...fadeUp}
transition={{ duration: 0.5, delay: 0.1, ease: [0.16, 1, 0.3, 1] }}
className="text-5xl sm:text-6xl lg:text-7xl tracking-tight mb-6 font-brand"
style={{ fontWeight: 'normal' }}
>
{COMPANY_INFO.shortName}
</motion.h1>
<motion.h1
id="hero-heading"
{...fadeUp(0.1)}
className="text-6xl sm:text-7xl lg:text-8xl tracking-tight mb-6 font-brand"
style={{ fontWeight: 'normal' }}
>
{COMPANY_INFO.shortName}
</motion.h1>
<motion.p
{...fadeUp}
transition={{ duration: 0.5, delay: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="text-xl sm:text-2xl text-[#1C1C1C] mb-4"
>
<span className="font-semibold text-[#C41E3A]"></span>
</motion.p>
<motion.p
{...fadeUp(0.2)}
className="text-xl sm:text-2xl text-[var(--color-text-primary)] mb-4"
>
<span className="font-semibold text-[var(--color-brand-primary)] font-calligraphy">
</span>
</motion.p>
<motion.p
{...fadeUp}
transition={{ duration: 0.5, delay: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="text-lg text-[#595959] max-w-2xl leading-relaxed mb-10"
>
</motion.p>
<motion.p
{...fadeUp(0.3)}
className="text-lg text-[var(--color-text-muted)] max-w-xl leading-relaxed mb-10"
>
{COMPANY_INFO.description}
</motion.p>
<motion.div
{...fadeUp(0.4)}
className="flex flex-col sm:flex-row items-start gap-4"
>
<Button size="lg" asChild>
<StaticLink href="/contact">
<ArrowRight className="w-4 h-4 ml-2" />
</StaticLink>
</Button>
<Button size="lg" variant="outline" asChild>
<StaticLink href="/products"></StaticLink>
</Button>
</motion.div>
<motion.div
{...fadeUp(0.5)}
className="flex flex-wrap gap-3 mt-10 lg:hidden"
>
{CAPABILITIES.map((cap) => {
const Icon = cap.icon;
return (
<div
key={cap.label}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--color-bg-section)] border border-[var(--color-border-primary)]"
>
<Icon className="w-4 h-4 text-[var(--color-brand-primary)]" strokeWidth={1.8} />
<span className="text-xs font-medium text-[var(--color-text-primary)]">{cap.label}</span>
</div>
);
})}
</motion.div>
</div>
<motion.div
{...fadeUp}
transition={{ duration: 0.5, delay: 0.4, ease: [0.16, 1, 0.3, 1] }}
className="flex flex-col sm:flex-row items-start gap-4"
initial={shouldReduceMotion ? {} : { opacity: 0, y: 32 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.5, ease: EASE }}
className="hidden lg:block"
>
<Button size="lg" asChild>
<StaticLink href="/contact">
<ArrowRight className="w-4 h-4 ml-2" />
</StaticLink>
</Button>
<Button size="lg" variant="outline" asChild>
<StaticLink href="/products">
</StaticLink>
</Button>
<div
ref={cardRef}
className="relative ink-glow-border rounded-2xl"
style={
{
'--glow-start': 'var(--color-brand-primary)',
'--glow-end': 'var(--color-warning)',
} as React.CSSProperties
}
onMouseMove={handleCardMouseMove}
onMouseEnter={() => setIsCardHovered(true)}
onMouseLeave={() => setIsCardHovered(false)}
>
<div
className="absolute inset-0 rounded-2xl pointer-events-none transition-opacity duration-500"
style={{
opacity: isCardHovered ? 1 : 0,
background: `radial-gradient(400px circle at ${mousePos.x}px ${mousePos.y}px, rgba(var(--color-brand-primary-rgb), 0.06), transparent 40%)`,
}}
/>
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-8 lg:p-10">
<div className="w-14 h-14 rounded-xl bg-[var(--color-brand-primary-bg)] flex items-center justify-center mb-6">
<Handshake className="w-6 h-6 text-[var(--color-brand-primary)]" strokeWidth={1.8} />
</div>
<h3 className="text-xl font-semibold text-[var(--color-text-primary)] mb-2">
</h3>
<p className="text-sm text-[var(--color-text-muted)] leading-relaxed mb-8">
</p>
<div className="space-y-4">
{JOURNEY_STEPS.map((step, idx) => {
const Icon = step.icon;
return (
<div
key={step.label}
className="flex items-center gap-4 p-3 rounded-lg bg-[var(--color-bg-section)]/60"
>
<div className="w-9 h-9 rounded-lg bg-[var(--color-brand-primary-bg)] flex items-center justify-center shrink-0">
<Icon className="w-4 h-4 text-[var(--color-brand-primary)]" strokeWidth={1.8} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-[var(--color-brand-primary)]">0{idx + 1}</span>
<span className="text-sm font-medium text-[var(--color-text-primary)]">{step.label}</span>
</div>
<p className="text-xs text-[var(--color-text-muted)] mt-0.5">{step.desc}</p>
</div>
</div>
);
})}
</div>
</div>
</div>
</motion.div>
</div>
</div>
@@ -1,114 +0,0 @@
'use client';
import { motion } from 'framer-motion';
import { useInView } from 'framer-motion';
import { useRef } from 'react';
import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button';
import { ArrowRight, Lightbulb, Cpu, Users } from 'lucide-react';
import { InkGlowCard } from '@/components/ui/ink-glow-card';
const SOLUTIONS_OVERVIEW = [
{
icon: Lightbulb,
title: '参谋伙伴',
subtitle: '数字化转型咨询',
description: '帮您看清前路,迈对第一步。用行业智慧洞察趋势,用理性分析避开陷阱。',
points: ['行业趋势洞察报告', '成熟度评估', '实施路径规划'],
},
{
icon: Cpu,
title: '技术伙伴',
subtitle: '信息技术解决方案',
description: '让技术真正为业务服务。不追逐"最火"的技术,只选择"最对"的技术。',
points: ['业务场景调研', '技术方案定制', '敏捷交付迭代'],
},
{
icon: Users,
title: '同行伙伴',
subtitle: '长期陪跑服务',
description: '从需求理解到产品落地,陪伴才是常态。当产品真正为您所用那天,才是我们成为伙伴的开始。',
points: ['专属客户成功经理', '季度业务复盘', '7×24小时响应'],
},
];
const solutionAccents: { rgb: string; glowStart: string; glowEnd: string }[] = [
{ rgb: '196, 30, 58', glowStart: '#C41E3A', glowEnd: '#D97706' },
{ rgb: '37, 99, 235', glowStart: '#2563EB', glowEnd: '#7C3AED' },
{ rgb: '22, 163, 74', glowStart: '#16A34A', glowEnd: '#0891B2' },
];
export function HomeSolutionsSection() {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: '-100px' });
return (
<section id="solutions" role="region" aria-labelledby="solutions-heading" className="py-20 md:py-28 bg-white" ref={ref}>
<div className="container-wide">
<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="text-center max-w-3xl mx-auto mb-14"
>
<h2 id="solutions-heading" className="text-3xl sm:text-4xl font-semibold text-[#1C1C1C] mb-4">
<span className="text-[#C41E3A] font-calligraphy"></span>
</h2>
<p className="text-base text-[#595959]">
</p>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8">
{SOLUTIONS_OVERVIEW.map((item, idx) => {
const Icon = item.icon;
const accent = solutionAccents[idx % solutionAccents.length]!;
return (
<InkGlowCard
key={item.title}
index={idx}
accentColorRgb={accent.rgb}
glowStart={accent.glowStart}
glowEnd={accent.glowEnd}
>
<div className="p-6 md:p-8">
<div
className="w-11 h-11 rounded-xl flex items-center justify-center mb-5"
style={{ backgroundColor: `rgba(${accent.rgb}, 0.06)` }}
>
<Icon className="w-5 h-5" style={{ color: accent.glowStart }} strokeWidth={1.8} />
</div>
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-1">{item.title}</h3>
<p className="text-xs text-[#C41E3A] font-medium mb-3">{item.subtitle}</p>
<p className="text-sm text-[#595959] leading-relaxed mb-5">{item.description}</p>
<ul className="space-y-2">
{item.points.map((point, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-[#595959]">
<div className="w-1.5 h-1.5 bg-[#C41E3A] rounded-full mt-1.5 shrink-0" />
{point}
</li>
))}
</ul>
</div>
</InkGlowCard>
);
})}
</div>
<motion.div
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="mt-12 text-center"
>
<Button size="lg" asChild>
<StaticLink href="/solutions">
<ArrowRight className="ml-2 w-4 h-4" />
</StaticLink>
</Button>
</motion.div>
</div>
</section>
);
}
+20 -19
View File
@@ -8,10 +8,10 @@ import { CheckCircle2 } from 'lucide-react';
import { InkGlowCard } from '@/components/ui/ink-glow-card';
const phaseAccents: { rgb: string; glowStart: string; glowEnd: string }[] = [
{ rgb: '196, 30, 58', glowStart: '#C41E3A', glowEnd: '#D97706' },
{ rgb: '217, 119, 6', glowStart: '#D97706', glowEnd: '#16A34A' },
{ rgb: '22, 163, 74', glowStart: '#16A34A', glowEnd: '#0891B2' },
{ rgb: '37, 99, 235', glowStart: '#2563EB', glowEnd: '#7C3AED' },
{ rgb: '196, 30, 58', glowStart: 'var(--color-brand-primary)', glowEnd: 'var(--color-warning)' },
{ rgb: '217, 119, 6', glowStart: 'var(--color-warning)', glowEnd: 'var(--color-success)' },
{ rgb: '22, 163, 74', glowStart: 'var(--color-success)', glowEnd: 'var(--color-accent-cyan)' },
{ rgb: '37, 99, 235', glowStart: 'var(--color-accent-blue)', glowEnd: 'var(--color-accent-purple)' },
];
export function MethodologySection() {
@@ -19,7 +19,7 @@ export function MethodologySection() {
const isInView = useInView(ref, { once: true, margin: '-100px' });
return (
<section id="methodology" role="region" aria-labelledby="methodology-heading" className="py-20 md:py-28 bg-[#FAFAFA]" ref={ref}>
<section id="methodology" role="region" aria-labelledby="methodology-heading" className="py-20 md:py-28 bg-[var(--color-bg-section)]" ref={ref}>
<div className="container-wide">
<motion.div
initial={{ opacity: 0, y: 20 }}
@@ -27,16 +27,17 @@ export function MethodologySection() {
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
className="text-center max-w-3xl mx-auto mb-14"
>
<h2 id="methodology-heading" className="text-3xl sm:text-4xl font-semibold text-[#1C1C1C] mb-4">
<span className="text-[#C41E3A] font-calligraphy"></span>
<h2 id="methodology-heading" className="text-3xl sm:text-4xl font-semibold text-[var(--color-text-primary)] mb-4">
<span className="text-[var(--color-brand-primary)]"></span>
</h2>
<p className="text-base text-[#595959]">
<p className="text-base text-[var(--color-text-muted)]">
</p>
</motion.div>
<div className="relative">
<div className="hidden lg:block absolute top-16 left-[12.5%] right-[12.5%] h-px bg-gradient-to-r from-transparent via-[#E5E5E5] to-transparent" />
<div className="hidden lg:block absolute top-16 left-[12.5%] right-[12.5%] h-px bg-gradient-to-r from-transparent via-[var(--color-border-primary)] to-transparent" />
<div className="hidden md:block lg:hidden absolute left-[calc(50%+0.375rem)] top-0 bottom-0 w-px bg-[var(--color-border-primary)]" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8">
{METHODOLOGY.map((phase, idx) => {
@@ -52,21 +53,21 @@ export function MethodologySection() {
<div className="p-6 md:p-8">
<div
className="w-10 h-10 rounded-full flex items-center justify-center mb-5 text-sm font-bold"
style={{ backgroundColor: accent.glowStart, color: '#FFFFFF' }}
style={{ backgroundColor: accent.glowStart, color: 'var(--color-bg-primary)' }}
>
{phase.number}
</div>
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-1">{phase.title}</h3>
<p className="text-xs text-[#C41E3A] font-medium mb-3">{phase.subtitle}</p>
<p className="text-sm text-[#595959] leading-relaxed mb-5">{phase.description}</p>
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-1">{phase.title}</h3>
<p className="text-xs text-[var(--color-brand-primary)] font-medium mb-3">{phase.subtitle}</p>
<p className="text-sm text-[var(--color-text-muted)] leading-relaxed mb-5">{phase.description}</p>
<div className="mb-4">
<p className="text-xs font-semibold text-[#1C1C1C] mb-2 tracking-wide"></p>
<p className="text-xs font-semibold text-[var(--color-text-primary)] mb-2 tracking-wide"></p>
<ul className="space-y-1.5">
{phase.activities.map((activity, i) => (
<li key={i} className="flex items-start gap-2 text-xs text-[#595959]">
<CheckCircle2 className="w-3.5 h-3.5 text-[#C41E3A] mt-0.5 shrink-0" />
<li key={i} className="flex items-start gap-2 text-xs text-[var(--color-text-muted)]">
<CheckCircle2 className="w-3.5 h-3.5 text-[var(--color-brand-primary)] mt-0.5 shrink-0" />
{activity}
</li>
))}
@@ -74,11 +75,11 @@ export function MethodologySection() {
</div>
<div>
<p className="text-xs font-semibold text-[#1C1C1C] mb-2 tracking-wide"></p>
<p className="text-xs font-semibold text-[var(--color-text-primary)] mb-2 tracking-wide"></p>
<ul className="space-y-1.5">
{phase.deliverables.map((deliverable, i) => (
<li key={i} className="flex items-start gap-2 text-xs text-[#A3A3A3]">
<span className="w-1.5 h-1.5 bg-[#C41E3A]/40 rounded-full mt-1.5 shrink-0" />
<li key={i} className="flex items-start gap-2 text-xs text-[var(--color-text-subtle)]">
<span className="w-1.5 h-1.5 bg-[var(--color-brand-primary)]/40 rounded-full mt-1.5 shrink-0" />
{deliverable}
</li>
))}
-65
View File
@@ -1,65 +0,0 @@
'use client';
import { motion } from 'framer-motion';
import { useInView } from 'framer-motion';
import { useRef } from 'react';
import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button';
import { ArrowRight } from 'lucide-react';
import { InsightCard } from '@/components/ui/insight-card';
import { NEWS } from '@/lib/constants';
export function NewsSection() {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: '-100px' });
return (
<section id="news" role="region" aria-labelledby="news-heading" className="py-20 md:py-28 bg-[#FAFAFA]" ref={ref}>
<div className="container-wide">
<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="text-center max-w-3xl mx-auto mb-14"
>
<h2 id="news-heading" className="text-3xl sm:text-4xl font-semibold text-[#1C1C1C] mb-4">
<span className="text-[#C41E3A] font-calligraphy"></span>
</h2>
<p className="text-base text-[#595959]">
</p>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
{NEWS.slice(0, 3).map((item, idx) => (
<InsightCard
key={item.id}
title={item.title}
excerpt={item.excerpt}
category={item.category}
readTime="5 分钟"
publishedAt={item.date}
imageUrl={item.image}
href={`/news/${item.id}`}
featured={idx === 0}
/>
))}
</div>
<motion.div
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="mt-12 text-center"
>
<Button variant="outline" size="lg" className="group" asChild>
<StaticLink href="/news">
<ArrowRight className="ml-2 w-4 h-4 transition-transform group-hover:translate-x-1" />
</StaticLink>
</Button>
</motion.div>
</div>
</section>
);
}
@@ -1,52 +1,34 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { motion } from 'framer-motion';
import { ProductCard } from '@/components/ui/product-card';
import { PRODUCTS } from '@/lib/constants';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
export function ProductMatrixSection() {
const [isVisible, setIsVisible] = useState(false);
const sectionRef = useRef<HTMLElement>(null);
const shouldReduceMotion = useReducedMotion();
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
setIsVisible(true);
}
},
{ threshold: 0.1 }
);
if (sectionRef.current) {
observer.observe(sectionRef.current);
}
return () => observer.disconnect();
}, []);
return (
<section
id="products"
ref={sectionRef}
className="py-20 md:py-28 bg-white"
>
<section id="products" className="py-20 md:py-28 bg-[var(--color-bg-section)]">
<div className="container-wide">
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={isVisible ? { opacity: 1, y: 0 } : {}}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-100px' }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
className="text-center max-w-3xl mx-auto mb-14"
className="flex flex-col md:flex-row md:items-end md:justify-between gap-4 mb-14"
>
<h2 className="text-3xl sm:text-4xl font-semibold text-[#1C1C1C] mb-4">
<span className="text-[#C41E3A] font-calligraphy"></span>
</h2>
<p className="text-base text-[#595959]">
</p>
<div>
<h2 className="text-3xl sm:text-4xl font-semibold text-[var(--color-text-primary)] mb-3">
</h2>
<p className="text-base text-[var(--color-text-muted)] max-w-lg">
</p>
</div>
<span className="text-sm text-[var(--color-text-subtle)] font-mono tracking-wider hidden md:block">
{PRODUCTS.length} PRODUCTS
</span>
</motion.div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
@@ -1,137 +0,0 @@
'use client';
import { motion } from 'framer-motion';
import { useInView } from 'framer-motion';
import { useRef } from 'react';
import { StaticLink } from '@/components/ui/static-link';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ArrowRight, Check, TrendingUp } from 'lucide-react';
import { PRODUCTS } from '@/lib/constants';
export function ProductsSection() {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: '-100px' });
return (
<section id="products" role="region" aria-labelledby="products-heading" className="py-24 bg-[#F5F7FA] relative overflow-hidden" ref={ref}>
<div className="absolute top-1/2 left-0 w-[400px] h-[400px] bg-[rgba(79,70,229,0.03)] rounded-full blur-3xl" />
<div className="absolute top-1/3 right-0 w-[300px] h-[300px] bg-[rgba(196,30,58,0.02)] rounded-full blur-3xl" />
<div className="container-wide relative z-10">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6 }}
className="text-center max-w-3xl mx-auto mb-16"
>
<h2 id="products-heading" className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6">
<span className="text-[#C41E3A] font-calligraphy"></span>
</h2>
<p className="text-lg text-[#5C5C5C]">
</p>
</motion.div>
{PRODUCTS.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{PRODUCTS.map((product, idx) => (
<motion.div
key={product.id}
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, delay: 0.1 + idx * 0.1 }}
>
<StaticLink href={`/products/${product.id}`}>
<Card className="h-full flex flex-col group cursor-pointer border-[#E5E5E5] hover:border-[#1C1C1C] transition-colors">
<CardHeader>
<Badge variant="secondary" className="w-fit mb-3">
{product.category}
</Badge>
<CardTitle>{product.title}</CardTitle>
</CardHeader>
<CardContent className="flex-1 flex flex-col">
<CardDescription className="text-base leading-relaxed mb-4 flex-1">
{product.description}
</CardDescription>
<div className="mb-4">
<p className="text-sm font-medium text-[#1C1C1C] mb-2"></p>
<div className="flex flex-wrap gap-1.5">
{product.features.slice(0, 4).map((feature, idx) => (
<span
key={idx}
className="inline-flex items-center text-xs px-2 py-1 bg-[#FAFAFA] text-[#3D3D3D] rounded border border-[#E5E5E5]"
>
<Check className="w-3 h-3 mr-1 text-[#C41E3A]" />
{feature}
</span>
))}
</div>
</div>
<div className="mb-4">
<p className="text-sm font-medium text-[#1C1C1C] mb-2 flex items-center">
<TrendingUp className="w-4 h-4 mr-1 text-[#C41E3A]" />
</p>
<ul className="space-y-1">
{product.benefits.map((benefit, idx) => (
<li key={idx} className="text-xs text-[#5C5C5C] flex items-start">
<span className="text-[#C41E3A] mr-1.5"></span>
{benefit}
</li>
))}
</ul>
</div>
<Button variant="outline" className="w-full mt-auto group-hover:bg-[#A01830] group-hover:text-white group-hover:border-[#A01830] transition-colors">
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</CardContent>
</Card>
</StaticLink>
</motion.div>
))}
</div>
) : (
<div className="text-center py-12">
<p className="text-lg text-[#5C5C5C]"></p>
</div>
)}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, delay: 0.5 }}
className="mt-20 text-center"
>
<div className="bg-white rounded-2xl p-12 border border-[#E2E8F0] relative overflow-hidden">
<div className="absolute inset-0 pointer-events-none">
<div className="absolute top-0 right-0 w-64 h-64 bg-[rgba(79,70,229,0.03)] rounded-full blur-3xl" />
<div className="absolute bottom-0 left-0 w-48 h-48 bg-[rgba(196,30,58,0.02)] rounded-full blur-3xl" />
</div>
<div className="relative z-10">
<h3 className="text-2xl sm:text-3xl font-bold text-[#1A1A2E] mb-4">
</h3>
<p className="text-[#718096] mb-8 max-w-2xl mx-auto">
</p>
<Button
size="lg"
asChild
>
<StaticLink href="/contact">
<ArrowRight className="ml-2 w-4 h-4" />
</StaticLink>
</Button>
</div>
</div>
</motion.div>
</div>
</section>
);
}
@@ -1,101 +0,0 @@
'use client';
import { motion } from 'framer-motion';
import { useInView } from 'framer-motion';
import { useRef } from 'react';
import { StaticLink } from '@/components/ui/static-link';
import { Code, BarChart3, Lightbulb, Puzzle, ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { InkGlowCard } from '@/components/ui/ink-glow-card';
import { SERVICES } from '@/lib/constants';
const iconMap: Record<string, React.ComponentType<{ className?: string; strokeWidth?: number }>> = {
Code,
BarChart3,
Lightbulb,
Puzzle,
};
const serviceAccents: { rgb: string; glowStart: string; glowEnd: string }[] = [
{ rgb: '196, 30, 58', glowStart: '#C41E3A', glowEnd: '#D97706' },
{ rgb: '217, 119, 6', glowStart: '#D97706', glowEnd: '#16A34A' },
{ rgb: '37, 99, 235', glowStart: '#2563EB', glowEnd: '#7C3AED' },
{ rgb: '22, 163, 74', glowStart: '#16A34A', glowEnd: '#0891B2' },
];
export function ServicesSection() {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: '-100px' });
return (
<section id="services" aria-labelledby="services-heading" className="py-20 md:py-28 bg-white" ref={ref}>
<div className="container-wide">
<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="text-center max-w-3xl mx-auto mb-14"
>
<h2 id="services-heading" className="text-3xl sm:text-4xl font-semibold text-[#1C1C1C] mb-4">
<span className="text-[#C41E3A] font-calligraphy"></span>
</h2>
<p className="text-base text-[#595959] max-w-2xl mx-auto">
</p>
</motion.div>
{SERVICES.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8">
{SERVICES.map((service, index) => {
const Icon = iconMap[service.icon];
const accent = serviceAccents[index % serviceAccents.length]!;
return (
<InkGlowCard
key={service.id}
index={index}
href={`/services/${service.id}`}
accentColorRgb={accent.rgb}
glowStart={accent.glowStart}
glowEnd={accent.glowEnd}
>
<div className="p-6 md:p-8">
<div
className="w-11 h-11 rounded-xl flex items-center justify-center mb-5"
style={{ backgroundColor: `rgba(${accent.rgb}, 0.06)` }}
>
{Icon && <Icon className="w-5 h-5 text-[#C41E3A]" strokeWidth={1.8} />}
</div>
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-2">{service.title}</h3>
<p className="text-sm text-[#595959] leading-relaxed mb-4">{service.description}</p>
<div className="flex items-center text-sm font-medium text-[#C41E3A]">
<ArrowRight className="ml-1 w-4 h-4" />
</div>
</div>
</InkGlowCard>
);
})}
</div>
) : (
<div className="text-center py-12">
<p className="text-base text-[#595959]"></p>
</div>
)}
<motion.div
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-center mt-12"
>
<Button variant="outline" size="lg" className="group" asChild>
<StaticLink href="/services">
<ArrowRight className="ml-2 w-4 h-4 transition-transform group-hover:translate-x-1" />
</StaticLink>
</Button>
</motion.div>
</div>
</section>
);
}
@@ -1,54 +1,70 @@
'use client';
import { motion } from 'framer-motion';
import { useInView } from 'framer-motion';
import { useRef } from 'react';
import { Building2, Users, Award, TrendingUp } from 'lucide-react';
import { Shield, Users, Cpu, Handshake } from 'lucide-react';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
const STATS = [
{ icon: Building2, value: '6', label: '研发产品' },
{ icon: Users, value: '10+', label: '团队成员' },
{ icon: Award, value: '5+', label: '行业覆盖' },
{ icon: TrendingUp, value: '12+', label: '年核心团队经验' },
const TRUST_PILLARS = [
{
icon: Shield,
title: '私有化部署',
description: '数据不出企业,满足安全合规与数据主权要求',
},
{
icon: Users,
title: '资深团队',
description: '核心成员来自大型 IT 企业,具备扎实的工程能力与规范化交付经验',
},
{
icon: Cpu,
title: '全栈自研',
description: '6 款产品自主研发中,覆盖 ERP、CRM、BI 等企业核心场景',
},
{
icon: Handshake,
title: '长期陪跑',
description: '不做完就跑,从需求理解到产品打磨,持续陪伴客户成长',
},
];
export function SocialProofSection() {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: '-100px' });
const shouldReduceMotion = useReducedMotion();
return (
<section id="social-proof" role="region" aria-labelledby="social-proof-heading" className="py-20 md:py-28 bg-[#FAFAFA]" ref={ref}>
<section id="social-proof" role="region" aria-labelledby="social-proof-heading" className="py-20 md:py-28 bg-[var(--color-bg-section)]">
<div className="container-wide">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-100px' }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
className="text-center max-w-3xl mx-auto mb-14"
>
<h2 id="social-proof-heading" className="text-3xl sm:text-4xl font-semibold text-[#1C1C1C] mb-4">
<span className="text-[#C41E3A] font-calligraphy"></span>
<h2 id="social-proof-heading" className="text-3xl sm:text-4xl font-semibold text-[var(--color-text-primary)] mb-4">
</h2>
<p className="text-base text-[#595959]">
<p className="text-base text-[var(--color-text-muted)]">
</p>
</motion.div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 md:gap-8">
{STATS.map((stat, idx) => {
const Icon = stat.icon;
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8">
{TRUST_PILLARS.map((pillar, idx) => {
const Icon = pillar.icon;
return (
<motion.div
key={stat.label}
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
key={pillar.title}
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-50px' }}
transition={{ duration: 0.5, delay: idx * 0.1, ease: [0.16, 1, 0.3, 1] }}
className="text-center p-6 md:p-8"
className="text-center p-6 md:p-8 rounded-xl bg-[var(--color-bg-primary)] border border-[var(--color-border-primary)] hover:border-[rgba(var(--color-brand-primary-rgb),0.3)] transition-colors duration-300"
>
<div className="w-12 h-12 rounded-xl bg-[#C41E3A]/5 flex items-center justify-center mx-auto mb-4">
<Icon className="w-5 h-5 text-[#C41E3A]" strokeWidth={1.8} />
<div className="w-12 h-12 rounded-xl bg-[var(--color-brand-primary)]/5 flex items-center justify-center mx-auto mb-4">
<Icon className="w-5 h-5 text-[var(--color-brand-primary)]" strokeWidth={1.8} />
</div>
<div className="text-3xl sm:text-4xl font-semibold text-[#1C1C1C] mb-1">{stat.value}</div>
<div className="text-sm text-[#A3A3A3]">{stat.label}</div>
<h3 className="text-base font-semibold text-[var(--color-text-primary)] mb-2">{pillar.title}</h3>
<p className="text-sm text-[var(--color-text-muted)] leading-relaxed">{pillar.description}</p>
</motion.div>
);
})}
-55
View File
@@ -1,55 +0,0 @@
'use client';
import { motion } from 'framer-motion';
import { useInView } from 'framer-motion';
import { useRef } from 'react';
import { TEAM_MEMBERS } from '@/lib/constants';
import { InkGlowCard } from '@/components/ui/ink-glow-card';
export function TeamSection() {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: '-100px' });
return (
<section id="team" role="region" aria-labelledby="team-heading" className="py-20 md:py-28 bg-white" ref={ref}>
<div className="container-wide">
<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="text-center max-w-3xl mx-auto mb-14"
>
<h2 id="team-heading" className="text-3xl sm:text-4xl font-semibold text-[#1C1C1C] mb-4">
<span className="text-[#C41E3A] font-calligraphy"></span>
</h2>
<p className="text-base text-[#595959]">
</p>
</motion.div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8">
{TEAM_MEMBERS.map((member, idx) => (
<InkGlowCard
key={member.name}
index={idx}
accentColorRgb="196, 30, 58"
glowStart="#C41E3A"
glowEnd="#D97706"
>
<div className="p-6 md:p-8 text-center">
<div className="w-16 h-16 rounded-full bg-[#FAFAFA] mx-auto mb-4 flex items-center justify-center">
<span className="text-xl font-semibold text-[#C41E3A]">
{member.name.charAt(0)}
</span>
</div>
<h3 className="text-base font-semibold text-[#1C1C1C] mb-1">{member.name}</h3>
<p className="text-xs text-[#C41E3A] font-medium mb-2">{member.title}</p>
<p className="text-sm text-[#595959] leading-relaxed">{member.bio}</p>
</div>
</InkGlowCard>
))}
</div>
</div>
</section>
);
}
@@ -1,85 +0,0 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { motion } from 'framer-motion';
import { TestimonialBlock } from '@/components/ui/testimonial-block';
const TESTIMONIALS = [
{
quote: '我们相信,好的企业管理软件应该像水一样自然——融入业务流程,而不是让业务流程去适应软件。',
author: '产品理念',
title: '产品设计原则',
company: '睿新致远',
},
{
quote: '数据不应被锁在孤岛里。打通信息壁垒,让每一个决策都有数据支撑,这是我们打造BI平台的初心。',
author: '技术愿景',
title: '技术方向',
company: '睿新致远',
},
{
quote: '我们不做"做完就跑"的供应商。从需求理解到产品打磨,我们希望成为企业数字化转型路上真正的同行者。',
author: '服务承诺',
title: '核心价值观',
company: '睿新致远',
},
];
export function TestimonialSection() {
const [isVisible, setIsVisible] = useState(false);
const sectionRef = useRef<HTMLElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
setIsVisible(true);
}
},
{ threshold: 0.1 }
);
if (sectionRef.current) {
observer.observe(sectionRef.current);
}
return () => observer.disconnect();
}, []);
return (
<section
id="testimonials"
ref={sectionRef}
className="py-20 md:py-28 bg-[#FAFAFA]"
>
<div className="container-wide">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isVisible ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
className="text-center max-w-3xl mx-auto mb-14"
>
<h2 className="text-3xl sm:text-4xl font-semibold text-[#1C1C1C] mb-4">
<span className="text-[#C41E3A] font-calligraphy"></span>
</h2>
<p className="text-base text-[#595959]">
</p>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8">
{TESTIMONIALS.map((testimonial, index) => (
<TestimonialBlock
key={testimonial.author}
quote={testimonial.quote}
author={testimonial.author}
title={testimonial.title}
company={testimonial.company}
index={index}
/>
))}
</div>
</div>
</section>
);
}
+25
View File
@@ -74,3 +74,28 @@ export function ServiceSchema() {
/>
);
}
interface BreadcrumbItem {
name: string;
href: string;
}
export function BreadcrumbSchema({ items }: { items: BreadcrumbItem[] }) {
const schema = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": items.map((item, idx) => ({
"@type": "ListItem",
"position": idx + 1,
"name": item.name,
"item": `https://www.novalon.cn${item.href}`,
})),
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
);
}
-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>
)}

Some files were not shown because too many files have changed in this diff Show More