feat: 更新营销页面组件与交互优化

- 删除 .impeccable.md
- 新增 detail-swipe-nav, loading-state, skeleton, tooltip 组件
- 新增 use-keyboard-shortcuts, use-swipe-gesture hooks
- 更新 contact, news, products, services, solutions 等页面
- 优化 header, mobile-menu, input, page-transition 组件
- 添加 terms 与错误追踪测试页面
This commit is contained in:
张翔
2026-05-14 18:21:54 +08:00
parent b8ab1fd0e3
commit 7f6128a6ff
23 changed files with 1190 additions and 111 deletions
View File
+29 -9
View File
@@ -9,11 +9,12 @@ 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 { Mail, MapPin, Send, Loader2, Clock, HeadphonesIcon, CheckCircle2 } from 'lucide-react';
import { Mail, MapPin, Send, Loader2, Clock, HeadphonesIcon, CheckCircle2, HelpCircle } from 'lucide-react';
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';
import { Tooltip } from '@/components/ui/tooltip';
const contactFormSchema = z.object({
name: z.string().min(2, '姓名至少需要2个字符'),
@@ -273,10 +274,18 @@ function ContactFormContent() {
<form onSubmit={handleSubmit} className="space-y-5">
<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">
<div className="relative">
<Input
name="name"
data-testid="name-input"
label="姓名"
label={
<span className="flex items-center gap-1.5">
<Tooltip content="用于正式沟通和方案报价">
<HelpCircle className="w-3.5 h-3.5 text-[var(--color-text-subtle)] hover:text-[var(--color-brand-primary)] cursor-help" />
</Tooltip>
</span>
}
id="name"
placeholder="请输入您的姓名"
required
@@ -285,13 +294,22 @@ function ContactFormContent() {
onBlur={(e) => handleBlur('name', e.target.value)}
error={errors.name}
/>
</div>
<div className="relative">
<Input
name="phone"
data-testid="phone-input"
label="电话"
label={
<span className="flex items-center gap-1.5">
<Tooltip content="接收项目进度通知和验证码,仅用于业务联系">
<HelpCircle className="w-3.5 h-3.5 text-[var(--color-text-subtle)] hover:text-[var(--color-brand-primary)] cursor-help" />
</Tooltip>
</span>
}
id="phone"
type="tel"
placeholder="请输入您的电话"
placeholder="请输入11位手机号"
required
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
@@ -299,6 +317,7 @@ function ContactFormContent() {
error={errors.phone}
/>
</div>
</div>
<Input
name="email"
data-testid="email-input"
@@ -341,14 +360,15 @@ function ContactFormContent() {
type="submit"
data-testid="submit-button"
size="lg"
className="w-full bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] 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 relative overflow-hidden disabled:opacity-90"
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
<span className="flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>...</span>
<span className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent animate-pulse" />
</span>
) : (
<>
<Send className="mr-2 h-4 w-4" />
+12 -2
View File
@@ -3,6 +3,7 @@
import { ErrorBoundary } from '@/components/ui/error-boundary';
import { Header } from '@/components/layout/header';
import { Footer } from '@/components/layout/footer';
import { PageTransition } from '@/components/ui/page-transition';
export default function MarketingLayout({
children,
@@ -11,10 +12,19 @@ export default function MarketingLayout({
}) {
return (
<div className="min-h-screen flex flex-col">
<a
href="#main-content"
data-skip-to-content
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-6 focus:py-3 focus:bg-[var(--color-brand-primary)] focus:text-white focus:rounded-lg focus:shadow-lg focus:outline-none"
>
</a>
<Header />
<ErrorBoundary>
<main id="main-content" className="flex-1 pt-16">
{children}
<main id="main-content" tabIndex={-1} className="flex-1 pt-16" role="main" aria-label="页面主体内容">
<PageTransition>
{children}
</PageTransition>
</main>
</ErrorBoundary>
<Footer />
@@ -70,7 +70,7 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
</div>
)}
<div className="border-l-4 border-[var(--color-brand-primary)] pl-6 mb-8">
<div className="bg-[var(--color-brand-primary-bg)] rounded-xl p-6 mb-8">
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed">
{news.excerpt}
</p>
+12 -3
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useMemo, ChangeEvent } from 'react';
import { useState, useMemo, ChangeEvent, useEffect } from 'react';
import { motion } from 'framer-motion';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { NEWS, COMPANY_INFO } from '@/lib/constants';
@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { StaticLink } from '@/components/ui/static-link';
import { LoadingState } from '@/components/ui/loading-state';
import { Search, Calendar, ChevronLeft, ChevronRight, ArrowRight, Newspaper } from 'lucide-react';
import { PageNav } from '@/components/layout/page-nav';
@@ -20,6 +21,12 @@ export default function NewsListPage() {
const [selectedCategory, setSelectedCategory] = useState('全部');
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => setIsLoading(false), 600);
return () => clearTimeout(timer);
}, []);
const filteredNews = useMemo(() => {
return NEWS.filter((newsItem) => {
@@ -119,8 +126,9 @@ export default function NewsListPage() {
<p className="text-lg text-[var(--color-text-muted)]"></p>
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<LoadingState isLoading={isLoading} variant="list">
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{paginatedNews.map((newsItem, index) => (
<motion.div
key={newsItem.id}
@@ -205,6 +213,7 @@ export default function NewsListPage() {
</div>
)}
</>
</LoadingState>
)}
</div>
</section>
+4 -1
View File
@@ -3,6 +3,7 @@ import { StaticLink } from '@/components/ui/static-link';
import { PRODUCTS, COMPANY_INFO } from '@/lib/constants';
import { Button } from '@/components/ui/button';
import { PageNav } from '@/components/layout/page-nav';
import { DetailSwipeNav } from '@/components/ui/detail-swipe-nav';
import { CheckCircle2, Zap, Target, Layers, ArrowRight, FlaskConical } from 'lucide-react';
export async function generateStaticParams() {
@@ -35,6 +36,7 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
return (
<div className="min-h-screen bg-[var(--color-bg-primary)]">
<DetailSwipeNav type="product" currentId={product.id} />
<div className="pt-32 pb-16">
<div className="container-wide">
<PageNav items={[{ label: '产品', href: '/products' }, { label: product.title }]} />
@@ -98,8 +100,9 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
{product.benefits.map((benefit, index) => (
<div
key={index}
className="flex items-start gap-3 p-4 rounded-lg border-l-4 border-[var(--color-brand-primary)] bg-[var(--color-bg-section)]"
className="flex items-start gap-3 p-4 rounded-lg bg-[var(--color-brand-primary-bg)]"
>
<div className="w-1.5 h-1.5 bg-[var(--color-brand-primary)] rounded-full mt-2 shrink-0" />
<span className="text-[var(--color-text-primary)] font-medium text-sm">{benefit}</span>
</div>
))}
+22 -12
View File
@@ -1,5 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { PRODUCTS } from '@/lib/constants';
import { ProductCard } from '@/components/ui/product-card';
import { motion } from 'framer-motion';
@@ -7,10 +8,17 @@ 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';
import { LoadingState } from '@/components/ui/loading-state';
export default function ProductsPage() {
const shouldReduceMotion = useReducedMotion();
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => setIsLoading(false), 500);
return () => clearTimeout(timer);
}, []);
return (
<div className="min-h-screen bg-[var(--color-bg-primary)]">
<BreadcrumbSchema items={[{ name: '首页', href: '/' }, { name: '产品', href: '/products' }]} />
@@ -36,18 +44,20 @@ export default function ProductsPage() {
<section className="pb-20">
<div className="container-wide">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{PRODUCTS.map((product, index) => (
<ProductCard
key={product.id}
title={product.title}
description={product.description}
href={`/products/${product.id}`}
index={index}
status={product.status}
/>
))}
</div>
<LoadingState isLoading={isLoading} variant="card">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{PRODUCTS.map((product, index) => (
<ProductCard
key={product.id}
title={product.title}
description={product.description}
href={`/products/${product.id}`}
index={index}
status={product.status}
/>
))}
</div>
</LoadingState>
</div>
</section>
@@ -3,6 +3,7 @@
import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button';
import { PageNav } from '@/components/layout/page-nav';
import { DetailSwipeNav } from '@/components/ui/detail-swipe-nav';
import {
CheckCircle2,
TrendingUp,
@@ -73,6 +74,7 @@ export function ServiceDetailClient({ service }: ServiceDetailClientProps) {
return (
<div className="min-h-screen bg-[var(--color-bg-primary)]">
<DetailSwipeNav type="service" currentId={service.id} />
<div className="pt-32 pb-16">
<div className="container-wide">
<PageNav items={[{ label: '服务', href: '/services' }, { label: service.title }]} />
+47 -37
View File
@@ -1,5 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { SERVICES } from '@/lib/constants';
import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button';
@@ -7,10 +8,17 @@ 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';
import { LoadingState } from '@/components/ui/loading-state';
export default function ServicesPage() {
const shouldReduceMotion = useReducedMotion();
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => setIsLoading(false), 500);
return () => clearTimeout(timer);
}, []);
return (
<div className="min-h-screen bg-[var(--color-bg-primary)]">
<section className="pt-32 pb-16">
@@ -35,45 +43,47 @@ export default function ServicesPage() {
<section className="pb-20">
<div className="container-wide">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{SERVICES.map((service, index) => (
<motion.div
key={service.id}
{...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-[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"
<LoadingState isLoading={isLoading} variant="card">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{SERVICES.map((service, index) => (
<motion.div
key={service.id}
{...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] }}
>
<div className="flex items-start justify-between mb-4">
<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-[var(--color-text-muted)] group-hover:text-[var(--color-brand-primary)] transition-colors" />
</div>
<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-[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-[var(--color-bg-tertiary)] text-[var(--color-text-muted)] rounded"
>
{feature.split('')[0]}
<StaticLink
href={`/services/${service.id}`}
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-[var(--color-brand-primary-bg)] text-[var(--color-brand-primary)] text-sm font-bold">
{String(index + 1).padStart(2, '0')}
</span>
))}
</div>
</StaticLink>
</motion.div>
))}
</div>
<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-[var(--color-text-primary)] mb-2 group-hover:text-[var(--color-brand-primary)] transition-colors">
{service.title}
</h3>
<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-[var(--color-bg-tertiary)] text-[var(--color-text-muted)] rounded"
>
{feature.split('')[0]}
</span>
))}
</div>
</StaticLink>
</motion.div>
))}
</div>
</LoadingState>
</div>
</section>
@@ -58,8 +58,9 @@ export function SolutionDetailClient({ solution, relatedProducts }: SolutionDeta
{solution.challenges.map((challenge, index) => (
<div
key={index}
className="flex items-start gap-3 p-4 rounded-lg border-l-4 border-[var(--color-brand-primary)] bg-[var(--color-bg-section)]"
className="flex items-start gap-3 p-4 rounded-lg bg-[var(--color-brand-primary-bg)]"
>
<AlertTriangle className="w-4 h-4 text-[var(--color-brand-primary)] mt-0.5 shrink-0" />
<span className="text-[var(--color-text-primary)] text-sm">{challenge}</span>
</div>
))}
+53 -12
View File
@@ -1,19 +1,21 @@
'use client';
import { motion } from 'framer-motion';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { StaticLink } from '@/components/ui/static-link';
import { ArrowRight, Lightbulb, Cpu, Users, CheckCircle2 } from 'lucide-react';
import { ArrowRight, Lightbulb, Cpu, Users, CheckCircle2, ChevronDown } 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';
import { SwipeNavigation } from '@/hooks/use-swipe-gesture';
const modules = [
{
icon: Lightbulb,
title: '数字化转型咨询 · 参谋伙伴',
subtitle: '帮您看清前路,迈对第一步',
title: '数字化转型咨询',
subtitle: '参谋伙伴 — 帮您看清前路,迈对第一步',
paragraphs: [
'数字化转型最大的成本,是走错方向的成本。',
'我们用行业智慧帮您洞察趋势,用理性分析帮您避开陷阱。',
@@ -28,8 +30,8 @@ const modules = [
},
{
icon: Cpu,
title: '信息技术解决方案 · 技术伙伴',
subtitle: '让技术真正为业务服务',
title: '信息技术解决方案',
subtitle: '技术伙伴 — 让技术真正为业务服务',
paragraphs: [
'我们不追逐"最火"的技术,只选择"最对"的技术。',
'将前沿技术深度融入您的业务场景,让每一行代码都产生业务价值。',
@@ -44,8 +46,8 @@ const modules = [
},
{
icon: Users,
title: '长期陪跑服务 · 同行伙伴',
subtitle: '从需求理解到产品落地,陪伴才是常态',
title: '长期陪跑服务',
subtitle: '同行伙伴 — 从需求到落地,持续陪伴',
paragraphs: [
'当产品真正为您所用那天,才是我们成为伙伴的开始。',
'我们建立长效服务机制,定期回访、持续优化、随时响应。',
@@ -63,9 +65,28 @@ const modules = [
export default function SolutionsPage() {
const shouldReduceMotion = useReducedMotion();
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
const [expandedModules, setExpandedModules] = useState<Set<number>>(new Set([0]));
const toggleModule = (index: number) => {
setExpandedModules(prev => {
const next = new Set(prev);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
return next;
});
};
return (
<div className="min-h-screen bg-[var(--color-bg-primary)]">
<BreadcrumbSchema items={[{ name: '首页', href: '/' }, { name: '解决方案', href: '/solutions' }]} />
<SwipeNavigation
prevRoute="/products"
nextRoute="/services"
prevLabel="产品方案"
nextLabel="服务支持"
/>
<section className="pt-32 pb-16">
<div className="container-wide">
<PageNav items={[{ label: '解决方案' }]} />
@@ -99,7 +120,7 @@ export default function SolutionsPage() {
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-[var(--color-border-primary)] border-l-4 ${module.accentBorder} bg-[var(--color-bg-primary)] hover:border-[var(--color-border-primary)] transition-colors`}
className={`p-8 md:p-12 rounded-xl border border-[var(--color-border-primary)] 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 rounded-xl flex items-center justify-center shrink-0" style={{ backgroundColor: module.accentBg }}>
@@ -114,9 +135,29 @@ export default function SolutionsPage() {
</div>
<div className="space-y-4 mb-8">
{module.paragraphs.map((p, i) => (
<p key={i} className="text-[var(--color-text-primary)] leading-relaxed">{p}</p>
))}
<button
onClick={() => toggleModule(index)}
className="flex items-center gap-2 text-sm font-medium text-[var(--color-brand-primary)] hover:text-[var(--color-brand-primary-hover)] transition-colors group"
aria-expanded={expandedModules.has(index)}
>
<ChevronDown className={`w-4 h-4 transition-transform duration-200 ${expandedModules.has(index) ? 'rotate-180' : ''}`} />
{expandedModules.has(index) ? '收起详情' : '展开了解详情'}
</button>
<AnimatePresence>
{expandedModules.has(index) && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
{module.paragraphs.map((p, i) => (
<p key={i} className="text-[var(--color-text-primary)] leading-relaxed mb-3 last:mb-0">{p}</p>
))}
</motion.div>
)}
</AnimatePresence>
</div>
<div className="mb-8">
+6 -3
View File
@@ -169,9 +169,12 @@ export default function TermsOfServicePage() {
</ul>
</section>
<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 className="bg-[var(--color-warning-bg)] p-6 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 bg-[var(--color-brand-primary)] rounded-full" />
<p className="text-[var(--color-text-primary)] font-medium"></p>
</div>
<p className="text-[var(--color-text-muted)] pl-4">2026226</p>
</section>
</div>
</div>
+2 -1
View File
@@ -15,7 +15,8 @@ export default function TestErrorTrackingPage() {
addResult('🧪 测试 1: 触发 JavaScript 运行时错误...');
throw new Error('测试错误:这是一个故意的 JavaScript 错误');
} catch (error) {
trackError('javascript_error', error.message, false, {
const errorMessage = error instanceof Error ? error.message : String(error);
trackError('javascript_error', errorMessage, false, {
test_id: 'test_js_error',
filename: 'test-error-page.tsx',
lineno: 20,
+2 -2
View File
@@ -196,7 +196,7 @@ function HeaderContent() {
className="fixed inset-0 z-40 md:hidden"
>
<div
className="absolute inset-0 bg-black/30 backdrop-blur-sm"
className="absolute inset-0 bg-[var(--color-primary)]/30 backdrop-blur-sm"
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
@@ -226,7 +226,7 @@ function HeaderContent() {
block px-4 py-4 text-base font-medium rounded-lg
transition-all duration-200
${isActive(item)
? 'text-[var(--color-text-primary)] bg-[var(--color-primary-lighter)] border-l-4 border-[var(--color-brand-primary)]'
? 'text-[var(--color-text-primary)] bg-[var(--color-brand-primary-bg)]'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-primary-lighter)]'
}
`}
+1 -1
View File
@@ -61,7 +61,7 @@ export function MobileMenu({ className }: MobileMenuProps) {
{isOpen && (
<>
<div
className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40"
className="fixed inset-0 bg-[var(--color-primary)]/20 backdrop-blur-sm z-40"
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
+41
View File
@@ -0,0 +1,41 @@
'use client';
import { PRODUCTS, SERVICES } from '@/lib/constants';
import { SwipeNavigation } from '@/hooks/use-swipe-gesture';
interface DetailSwipeNavProps {
type: 'product' | 'service' | 'solution';
currentId: string;
className?: string;
}
export function DetailSwipeNav({ type, currentId, className }: DetailSwipeNavProps) {
let items: { id: string; title: string }[] = [];
if (type === 'product') {
items = PRODUCTS.map(p => ({ id: p.id, title: p.title }));
} else if (type === 'service') {
items = SERVICES.map(s => ({ id: s.id, title: s.title }));
} else {
return null;
}
const currentIndex = items.findIndex(item => item.id === currentId);
if (currentIndex === -1) return null;
const prevItem = currentIndex > 0 ? items[currentIndex - 1] : null;
const nextItem = currentIndex < items.length - 1 ? items[currentIndex + 1] : null;
const basePath = type === 'product' ? '/products' : type === 'service' ? '/services' : `/solutions`;
return (
<SwipeNavigation
prevRoute={prevItem ? `${basePath}/${prevItem.id}` : undefined}
nextRoute={nextItem ? `${basePath}/${nextItem.id}` : undefined}
prevLabel={prevItem?.title || ''}
nextLabel={nextItem?.title || ''}
className={className}
/>
);
}
+1 -1
View File
@@ -2,7 +2,7 @@ import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
label?: string | React.ReactNode
error?: string
'data-testid'?: string
}
+176
View File
@@ -0,0 +1,176 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { cn } from '@/lib/utils';
import {
Skeleton,
SkeletonHero,
SkeletonList,
SkeletonCard,
SkeletonForm,
} from './skeleton';
type LoadingVariant = 'hero' | 'list' | 'card' | 'form' | 'custom' | 'spinner';
interface LoadingStateProps {
isLoading: boolean;
children: React.ReactNode;
variant?: LoadingVariant;
listItems?: number;
formFields?: number;
className?: string;
delayMs?: number;
customSkeleton?: React.ReactNode;
}
export function LoadingState({
isLoading,
children,
variant = 'custom',
listItems = 3,
formFields = 4,
className,
delayMs = 150,
customSkeleton,
}: LoadingStateProps) {
const [showSkeleton, setShowSkeleton] = useState(false);
const prevIsLoading = useRef(isLoading);
useEffect(() => {
if (isLoading !== prevIsLoading.current) {
prevIsLoading.current = isLoading;
if (!isLoading) {
setShowSkeleton(false);
return;
}
const timer = setTimeout(() => setShowSkeleton(true), delayMs);
return () => clearTimeout(timer);
}
return undefined;
}, [isLoading, delayMs]);
if (isLoading && showSkeleton) {
return (
<div
className={cn('animate-in fade-in duration-300', className)}
role="status"
aria-label="内容加载中"
aria-busy="true"
>
{variant === 'hero' && <SkeletonHero />}
{variant === 'list' && <SkeletonList items={listItems} />}
{variant === 'card' && (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<SkeletonCard key={i} />
))}
</div>
)}
{variant === 'form' && <SkeletonForm fields={formFields} />}
{variant === 'custom' && (customSkeleton || <Skeleton className="h-40 w-full rounded-lg" />)}
{variant === 'spinner' && (
<div className="flex items-center justify-center py-12">
<Spinner size="lg" />
</div>
)}
</div>
);
}
return <>{children}</>;
}
interface SpinnerProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function Spinner({ size = 'md', className }: SpinnerProps) {
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-8 h-8',
lg: 'w-12 h-12',
};
return (
<svg
className={cn(
'animate-spin text-[var(--color-brand-primary)]',
sizeClasses[size],
className
)}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
role="status"
aria-label="加载中"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
);
}
interface PageLoaderProps {
isLoading: boolean;
message?: string;
}
export function PageLoader({ isLoading, message = '正在加载...' }: PageLoaderProps) {
if (!isLoading) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--color-bg-primary)]/80 backdrop-blur-sm"
role="alertdialog"
aria-modal="true"
aria-label={message}
>
<div className="text-center space-y-4">
<Spinner size="lg" className="mx-auto" />
<p className="text-sm text-[var(--color-text-muted)] animate-pulse">
{message}
</p>
</div>
</div>
);
}
export function useLoadingState(initialState = false, delayMs = 150) {
const [isLoading, setIsLoading] = useState(initialState);
const [shouldShow, setShouldShow] = useState(false);
useEffect(() => {
if (!isLoading) {
setShouldShow(false);
return;
}
const timer = setTimeout(() => setShouldShow(true), delayMs);
return () => clearTimeout(timer);
}, [isLoading, delayMs]);
const startLoading = useCallback(() => setIsLoading(true), []);
const stopLoading = useCallback(() => setIsLoading(false), []);
return {
isLoading,
shouldShow,
startLoading,
stopLoading,
setLoading: setIsLoading,
};
}
+90 -25
View File
@@ -2,47 +2,112 @@
import { motion, AnimatePresence } from 'framer-motion';
import { usePathname } from 'next/navigation';
import { ReactNode, useSyncExternalStore } from 'react';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
interface PageTransitionProps {
children: ReactNode;
}
function useIsMounted() {
return useSyncExternalStore(
(callback) => {
window.addEventListener('resize', callback);
return () => window.removeEventListener('resize', callback);
const pageVariants = {
initial: {
opacity: 0,
y: 8,
scale: 0.98,
},
animate: {
opacity: 1,
y: 0,
scale: 1,
transition: {
duration: 0.4,
ease: [0.25, 1, 0.5, 1] as const,
staggerChildren: 0.05,
},
() => true,
() => false
);
}
},
exit: {
opacity: 0,
y: -8,
scale: 0.98,
transition: {
duration: 0.2,
ease: [0.22, 1, 0.36, 1] as const,
},
},
};
export function PageTransition({ children }: PageTransitionProps) {
export function PageTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const shouldReduceMotion = useReducedMotion();
const mounted = useIsMounted();
if (shouldReduceMotion || !mounted) {
if (shouldReduceMotion) {
return <>{children}</>;
}
return (
<AnimatePresence mode="wait">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={pathname}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{
duration: 0.3,
ease: [0.16, 1, 0.3, 1],
variants={pageVariants}
initial="initial"
animate="animate"
exit="exit"
style={{
willChange: 'transform, opacity',
backfaceVisibility: 'hidden',
}}
>
{children}
</motion.div>
</AnimatePresence>
);
}
}
const sectionVariants = {
hidden: { opacity: 0, y: 20 },
visible: (i: number) => ({
opacity: 1,
y: 0,
transition: {
delay: i * 0.08,
duration: 0.5,
ease: [0.25, 1, 0.5, 1] as const,
},
}),
};
export function StaggerChildren({
children,
className = '',
staggerCount = 4
}: {
children: React.ReactNode;
className?: string;
staggerCount?: number;
}) {
const shouldReduceMotion = useReducedMotion();
if (shouldReduceMotion) {
return <div className={className}>{children}</div>;
}
return (
<motion.div
className={className}
initial="hidden"
animate="visible"
variants={{
visible: {
transition: {
staggerChildren: 0.06,
delayChildren: 0.1,
},
},
}}
>
{Array.isArray(children)
? children.map((child, i) => (
<motion.div key={i} custom={Math.min(i, staggerCount)} variants={sectionVariants}>
{child}
</motion.div>
))
: children
}
</motion.div>
);
}
+180
View File
@@ -0,0 +1,180 @@
import { cn } from '@/lib/utils';
interface SkeletonProps {
className?: string;
variant?: 'default' | 'circular' | 'rounded';
}
export function Skeleton({ className, variant = 'default' }: SkeletonProps) {
return (
<div
className={cn(
'animate-pulse bg-[var(--color-bg-section)]',
variant === 'circular' && 'rounded-full',
variant === 'rounded' && 'rounded-lg',
variant === 'default' && 'rounded',
className
)}
aria-hidden="true"
role="presentation"
/>
);
}
interface SkeletonTextProps {
lines?: number;
className?: string;
lastLineWidth?: string;
}
export function SkeletonText({
lines = 3,
className,
lastLineWidth = '75%'
}: SkeletonTextProps) {
return (
<div className={cn('space-y-3', className)} aria-hidden="true" role="presentation">
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
key={i}
className={cn(
'h-4',
i === lines - 1 ? `w-[${lastLineWidth}]` : 'w-full'
)}
/>
))}
</div>
);
}
interface SkeletonCardProps {
className?: string;
showImage?: boolean;
showTitle?: boolean;
showDescription?: boolean;
showFooter?: boolean;
}
export function SkeletonCard({
className,
showImage = true,
showTitle = true,
showDescription = true,
showFooter = false,
}: SkeletonCardProps) {
return (
<div
className={cn(
'p-6 rounded-xl border border-[var(--color-border-primary)] bg-[var(--color-bg-primary)]',
className
)}
role="status"
aria-label="加载中"
aria-busy="true"
>
{showImage && (
<Skeleton className="h-40 w-full rounded-lg mb-4" />
)}
{showTitle && (
<Skeleton className="h-6 w-3/4 mb-3" />
)}
{showDescription && (
<SkeletonText lines={2} lastLineWidth="90%" />
)}
{showFooter && (
<div className="flex gap-2 mt-4">
<Skeleton className="h-10 w-24 rounded-lg" />
<Skeleton className="h-10 w-20 rounded-lg" />
</div>
)}
</div>
);
}
interface SkeletonListProps {
items?: number;
className?: string;
variant?: 'card' | 'list' | 'compact';
}
export function SkeletonList({
items = 3,
className,
variant = 'card',
}: SkeletonListProps) {
return (
<div className={cn('space-y-4', className)} role="status" aria-label="内容列表加载中">
{Array.from({ length: items }).map((_, i) => (
<div key={i}>
{variant === 'card' && <SkeletonCard />}
{variant === 'list' && (
<div className="flex items-center gap-4 p-4">
<Skeleton className="w-12 h-12 rounded-lg shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-1/2" />
<Skeleton className="h-4 w-full" />
</div>
<Skeleton className="w-16 h-8 rounded-lg shrink-0" />
</div>
)}
{variant === 'compact' && (
<div className="flex items-center justify-between p-3">
<div className="flex items-center gap-3 flex-1">
<Skeleton className="w-8 h-8 rounded shrink-0" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-2 w-12 rounded-full" />
</div>
)}
</div>
))}
</div>
);
}
interface SkeletonHeroProps {
className?: string;
}
export function SkeletonHero({ className }: SkeletonHeroProps) {
return (
<div
className={cn('pt-32 pb-16', className)}
role="status"
aria-label="页面标题加载中"
aria-busy="true"
>
<div className="container-wide max-w-3xl space-y-6">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-12 md:h-14 w-full max-w-2xl" />
<Skeleton className="h-6 w-full max-w-xl" />
<div className="flex gap-3 pt-4">
<Skeleton className="h-12 w-36 rounded-lg" />
<Skeleton className="h-12 w-28 rounded-lg" />
</div>
</div>
</div>
);
}
interface SkeletonFormProps {
fields?: number;
className?: string;
}
export function SkeletonForm({ fields = 4, className }: SkeletonFormProps) {
return (
<div className={cn('space-y-6 max-w-2xl', className)} role="status" aria-label="表单加载中">
{Array.from({ length: fields }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-14 w-full rounded-lg" />
</div>
))}
<Skeleton className="h-14 w-full rounded-lg mt-8" />
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { cn } from '@/lib/utils';
interface TooltipProps {
content: string;
children: React.ReactNode;
side?: 'top' | 'right' | 'bottom' | 'left';
className?: string;
}
export function Tooltip({ content, children, side = 'top', className }: TooltipProps) {
const [isVisible, setIsVisible] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const triggerRef = useRef<HTMLDivElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const showTooltip = () => {
if (!triggerRef.current || !tooltipRef.current) return;
const triggerRect = triggerRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
let top = 0;
let left = 0;
switch (side) {
case 'top':
top = triggerRect.top - tooltipRect.height - 8;
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
break;
case 'bottom':
top = triggerRect.bottom + 8;
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
break;
case 'left':
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
left = triggerRect.left - tooltipRect.width - 8;
break;
case 'right':
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
left = triggerRect.right + 8;
break;
}
setPosition({ top, left });
setIsVisible(true);
};
const hideTooltip = () => {
setIsVisible(false);
};
useEffect(() => {
if (!isVisible) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') hideTooltip();
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isVisible]);
return (
<div
ref={triggerRef}
className="relative inline-flex"
onMouseEnter={showTooltip}
onMouseLeave={hideTooltip}
onFocus={showTooltip}
onBlur={hideTooltip}
>
{children}
{isVisible && (
<div
ref={tooltipRef}
role="tooltip"
className={cn(
'fixed z-50 px-3 py-2 text-sm rounded-lg bg-[var(--color-primary)] text-white shadow-lg',
'pointer-events-none animate-in fade-in-0 zoom-in-95 duration-200',
'max-w-xs',
className
)}
style={{
top: `${position.top}px`,
left: `${position.left}px`,
}}
>
{content}
<div
className={cn(
'absolute w-2 h-2 bg-[var(--color-primary)] rotate-45',
side === 'top' && 'bottom-[-4px] left-1/2 -translate-x-1/2',
side === 'bottom' && 'top-[-4px] left-1/2 -translate-x-1/2',
side === 'left' && 'right-[-4px] top-1/2 -translate-y-1/2',
side === 'right' && 'left-[-4px] top-1/2 -translate-y-1/2'
)}
/>
</div>
)}
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
'use client';
import { useEffect, useCallback } from 'react';
interface KeyboardShortcutsProps {
onSearch?: () => void;
onNavigateHome?: () => void;
onSkipToContent?: () => void;
}
export function useKeyboardShortcuts({
onSearch,
onNavigateHome,
onSkipToContent,
}: KeyboardShortcutsProps = {}) {
const handleKeyDown = useCallback((event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement ||
event.target instanceof HTMLSelectElement) {
return;
}
const isModKey = event.metaKey || event.ctrlKey;
switch (true) {
case isModKey && event.key === 'k':
event.preventDefault();
onSearch?.();
break;
case event.altKey && event.key === 'h':
event.preventDefault();
onNavigateHome?.();
break;
case event.key === 'Tab' && !event.shiftKey && (document.activeElement?.getAttribute('data-skip-to-content') === 'true' || document.activeElement?.getAttribute('data-skip-to-content')):
event.preventDefault();
onSkipToContent?.();
break;
}
}, [onSearch, onNavigateHome, onSkipToContent]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
}
+357
View File
@@ -0,0 +1,357 @@
'use client';
import { useRef, useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils';
interface SwipeGestureOptions {
threshold?: number;
edgeSize?: number;
enabled?: boolean;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
}
interface SwipeState {
isSwiping: boolean;
startX: number;
currentX: number;
progress: number;
direction: 'left' | 'right' | null;
}
export function useSwipeGesture(options: SwipeGestureOptions = {}) {
const {
edgeSize = 30,
enabled = true,
onSwipeLeft,
onSwipeRight,
} = options;
const swipeState = useRef<SwipeState>({
isSwiping: false,
startX: 0,
currentX: 0,
progress: 0,
direction: null,
});
const containerRef = useRef<HTMLDivElement>(null);
const handleTouchStart = useCallback((e: Event) => {
if (!enabled) return;
const touchEvent = e as TouchEvent;
const touch = touchEvent.touches?.[0];
if (!touch) return;
const x = touch.clientX;
if (x > window.innerWidth - edgeSize || x < edgeSize) {
swipeState.current = {
isSwiping: true,
startX: x,
currentX: x,
progress: 0,
direction: null,
};
}
}, [enabled, edgeSize]);
const handleTouchMove = useCallback((e: Event) => {
if (!swipeState.current.isSwiping) return;
e.preventDefault();
const touchEvent = e as TouchEvent;
const touch = touchEvent.touches?.[0];
if (!touch) return;
swipeState.current.currentX = touch.clientX;
const deltaX = swipeState.current.currentX - swipeState.current.startX;
const absDeltaX = Math.abs(deltaX);
if (absDeltaX > 10) {
swipeState.current.direction = deltaX > 0 ? 'right' : 'left';
swipeState.current.progress = Math.min(absDeltaX / (window.innerWidth * 0.35), 1);
}
}, []);
const handleTouchEnd = useCallback(() => {
if (!swipeState.current.isSwiping) return;
const { progress, direction } = swipeState.current;
if (progress > 0.3 && direction) {
if (direction === 'left') {
triggerHaptic('light');
onSwipeLeft?.();
} else {
triggerHaptic('light');
onSwipeRight?.();
}
}
swipeState.current = {
isSwiping: false,
startX: 0,
currentX: 0,
progress: 0,
direction: null,
};
}, [onSwipeLeft, onSwipeRight]);
useEffect(() => {
const element = containerRef.current || document;
element.addEventListener('touchstart', handleTouchStart, { passive: true });
element.addEventListener('touchmove', handleTouchMove, { passive: false });
element.addEventListener('touchend', handleTouchEnd, { passive: true });
return () => {
element.removeEventListener('touchstart', handleTouchStart);
element.removeEventListener('touchmove', handleTouchMove);
element.removeEventListener('touchend', handleTouchEnd);
};
}, [handleTouchStart, handleTouchMove, handleTouchEnd]);
return { containerRef, swipeState };
}
function triggerHaptic(style: 'light' | 'medium' | 'heavy' = 'light') {
if ('vibrate' in navigator) {
switch (style) {
case 'light':
navigator.vibrate(10);
break;
case 'medium':
navigator.vibrate(25);
break;
case 'heavy':
navigator.vibrate(50);
break;
}
}
}
interface SwipeNavigationProps {
prevRoute?: string;
nextRoute?: string;
prevLabel?: string;
nextLabel?: string;
className?: string;
}
export function SwipeNavigation({
prevRoute,
nextRoute,
prevLabel = '上一页',
nextLabel = '下一页',
className,
}: SwipeNavigationProps) {
const router = useRouter();
const [swipeHint, setSwipeHint] = useState<'prev' | 'next' | null>(null);
const [showHint, setShowHint] = useState(false);
const { containerRef } = useSwipeGesture({
onSwipeLeft: () => {
if (nextRoute) {
setSwipeHint('next');
setTimeout(() => router.push(nextRoute), 200);
}
},
onSwipeRight: () => {
if (prevRoute) {
setSwipeHint('prev');
setTimeout(() => router.push(prevRoute), 200);
}
},
});
useEffect(() => {
const hasVisited = sessionStorage.getItem('swipe-hint-shown');
if (!hasVisited && (prevRoute || nextRoute)) {
const timer = setTimeout(() => setShowHint(true), 1500);
sessionStorage.setItem('swipe-hint-shown', 'true');
return () => clearTimeout(timer);
}
return undefined;
}, [prevRoute, nextRoute]);
useEffect(() => {
if (showHint) {
const timer = setTimeout(() => setShowHint(false), 3000);
return () => clearTimeout(timer);
}
return undefined;
}, [showHint]);
return (
<>
<div ref={containerRef as React.RefObject<HTMLDivElement>} className={cn(className)} />
<AnimatePresence>
{swipeHint && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className={cn(
'fixed bottom-24 left-1/2 -translate-x-1/2 z-50',
'px-4 py-2 rounded-full shadow-lg backdrop-blur-md',
'bg-[var(--color-primary)] text-white text-sm font-medium',
'flex items-center gap-2 pointer-events-none'
)}
>
{swipeHint === 'prev' ? (
<>
<ArrowLeft className="w-4 h-4" />
<span>{prevLabel}</span>
</>
) : (
<>
<span>{nextLabel}</span>
<ArrowRight className="w-4 h-4" />
</>
)}
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{showHint && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="fixed bottom-32 left-1/2 -translate-x-1/2 z-40"
>
<div className="bg-[var(--color-bg-primary)] border border-[var(--color-border-primary)] rounded-xl px-4 py-3 shadow-lg">
<p className="text-xs text-[var(--color-text-muted)] mb-1"></p>
<p className="text-sm text-[var(--color-text-primary)]">
</p>
<div className="flex items-center justify-center gap-4 mt-2">
{prevRoute && (
<div className="flex items-center gap-1 text-[var(--color-brand-primary)]">
<ArrowLeft className="w-3 h-3" />
<span className="text-xs">{prevLabel}</span>
</div>
)}
{nextRoute && (
<div className="flex items-center gap-1 text-[var(--color-brand-primary)]">
<span className="text-xs">{nextLabel}</span>
<ArrowRight className="w-3 h-3" />
</div>
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{(prevRoute || nextRoute) && (
<div className="fixed bottom-6 right-6 md:hidden z-30">
<div className="flex flex-col items-center gap-1 opacity-50">
<div className="w-8 h-0.5 bg-[var(--color-text-subtle)] rounded-full" />
<div className="w-6 h-0.5 bg-[var(--color-text-subtle)] rounded-full" />
<div className="w-4 h-0.5 bg-[var(--color-text-subtle)] rounded-full" />
</div>
</div>
)}
</>
);
}
interface PullToRefreshProps {
onRefresh: () => Promise<void>;
children: React.ReactNode;
className?: string;
}
export function PullToRefresh({ onRefresh, children, className }: PullToRefreshProps) {
const [pullDistance, setPullDistance] = useState(0);
const [isRefreshing, setIsRefreshing] = useState(false);
const startY = useRef(0);
const isPulling = useRef(false);
const handleTouchStart = (e: React.TouchEvent) => {
if (window.scrollY === 0 && e.touches?.[0]) {
startY.current = e.touches[0].clientY;
isPulling.current = true;
}
};
const handleTouchMove = (e: React.TouchEvent) => {
if (!isPulling.current || isRefreshing) return;
const touch = e.touches?.[0];
if (!touch) return;
const currentY = touch.clientY;
const distance = Math.max(0, (currentY - startY.current) * 0.5);
if (distance > 0 && window.scrollY === 0) {
e.preventDefault();
setPullDistance(Math.min(distance, 100));
}
};
const handleTouchEnd = async () => {
if (!isPulling.current) return;
isPulling.current = false;
if (pullDistance > 60 && !isRefreshing) {
setIsRefreshing(true);
triggerHaptic('medium');
try {
await onRefresh();
} finally {
setIsRefreshing(false);
}
}
setPullDistance(0);
};
return (
<div
className={cn('relative', className)}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div
className="absolute top-0 left-0 right-0 flex items-center justify-center overflow-hidden transition-transform"
style={{
height: `${Math.max(0, pullDistance)}px`,
transform: `translateY(${-pullDistance + (isRefreshing ? 50 : 0)}px)`,
}}
>
<motion.div
animate={{ rotate: isRefreshing ? 360 : pullDistance > 60 ? 180 : 0 }}
transition={{ duration: 0.3 }}
>
<svg
className={cn(
'w-6 h-6 transition-colors',
pullDistance > 60
? 'text-[var(--color-brand-primary)]'
: 'text-[var(--color-text-subtle)]'
)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.036 0A8.938 8.938 0 0112 21a8.938 8.938 0 01-7.97-4.94M20 9H19.418m0 0A8.938 8.938 0 0012 3a8.938 8.938 0 00-7.97 4.94" />
</svg>
</motion.div>
</div>
{children}
</div>
);
}