@@ -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`}
>
@@ -114,9 +135,29 @@ export default function SolutionsPage() {
- {module.paragraphs.map((p, i) => (
-
{p}
- ))}
+
+
+ {expandedModules.has(index) && (
+
+ {module.paragraphs.map((p, i) => (
+ {p}
+ ))}
+
+ )}
+
diff --git a/src/app/terms/page.tsx b/src/app/terms/page.tsx
index b815da0..2c2e1a1 100644
--- a/src/app/terms/page.tsx
+++ b/src/app/terms/page.tsx
@@ -169,9 +169,12 @@ export default function TermsOfServicePage() {
-
diff --git a/src/app/test-error-tracking/page.tsx b/src/app/test-error-tracking/page.tsx
index 2ea3b3f..db3e204 100644
--- a/src/app/test-error-tracking/page.tsx
+++ b/src/app/test-error-tracking/page.tsx
@@ -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,
diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx
index 144e4d0..2d1e42c 100644
--- a/src/components/layout/header.tsx
+++ b/src/components/layout/header.tsx
@@ -196,7 +196,7 @@ function HeaderContent() {
className="fixed inset-0 z-40 md:hidden"
>
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)]'
}
`}
diff --git a/src/components/layout/mobile-menu.tsx b/src/components/layout/mobile-menu.tsx
index acf048f..bf1ef9b 100644
--- a/src/components/layout/mobile-menu.tsx
+++ b/src/components/layout/mobile-menu.tsx
@@ -61,7 +61,7 @@ export function MobileMenu({ className }: MobileMenuProps) {
{isOpen && (
<>
setIsOpen(false)}
aria-hidden="true"
/>
diff --git a/src/components/ui/detail-swipe-nav.tsx b/src/components/ui/detail-swipe-nav.tsx
new file mode 100644
index 0000000..1e50abd
--- /dev/null
+++ b/src/components/ui/detail-swipe-nav.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
index f67c338..d2e9080 100644
--- a/src/components/ui/input.tsx
+++ b/src/components/ui/input.tsx
@@ -2,7 +2,7 @@ import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps extends React.InputHTMLAttributes
{
- label?: string
+ label?: string | React.ReactNode
error?: string
'data-testid'?: string
}
diff --git a/src/components/ui/loading-state.tsx b/src/components/ui/loading-state.tsx
new file mode 100644
index 0000000..ee1d648
--- /dev/null
+++ b/src/components/ui/loading-state.tsx
@@ -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 (
+
+ {variant === 'hero' &&
}
+ {variant === 'list' &&
}
+ {variant === 'card' && (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ )}
+ {variant === 'form' &&
}
+ {variant === 'custom' && (customSkeleton ||
)}
+ {variant === 'spinner' && (
+
+
+
+ )}
+
+ );
+ }
+
+ 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 (
+
+ );
+}
+
+interface PageLoaderProps {
+ isLoading: boolean;
+ message?: string;
+}
+
+export function PageLoader({ isLoading, message = '正在加载...' }: PageLoaderProps) {
+ if (!isLoading) return null;
+
+ return (
+
+ );
+}
+
+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,
+ };
+}
diff --git a/src/components/ui/page-transition.tsx b/src/components/ui/page-transition.tsx
index 31029a9..80ebfcf 100644
--- a/src/components/ui/page-transition.tsx
+++ b/src/components/ui/page-transition.tsx
@@ -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 (
-
+
{children}
);
-}
\ No newline at end of file
+}
+
+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 {children}
;
+ }
+
+ return (
+
+ {Array.isArray(children)
+ ? children.map((child, i) => (
+
+ {child}
+
+ ))
+ : children
+ }
+
+ );
+}
diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..978cf1c
--- /dev/null
+++ b/src/components/ui/skeleton.tsx
@@ -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 (
+
+ );
+}
+
+interface SkeletonTextProps {
+ lines?: number;
+ className?: string;
+ lastLineWidth?: string;
+}
+
+export function SkeletonText({
+ lines = 3,
+ className,
+ lastLineWidth = '75%'
+}: SkeletonTextProps) {
+ return (
+
+ {Array.from({ length: lines }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+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 (
+
+ {showImage && (
+
+ )}
+
+ {showTitle && (
+
+ )}
+
+ {showDescription && (
+
+ )}
+
+ {showFooter && (
+
+
+
+
+ )}
+
+ );
+}
+
+interface SkeletonListProps {
+ items?: number;
+ className?: string;
+ variant?: 'card' | 'list' | 'compact';
+}
+
+export function SkeletonList({
+ items = 3,
+ className,
+ variant = 'card',
+}: SkeletonListProps) {
+ return (
+
+ {Array.from({ length: items }).map((_, i) => (
+
+ {variant === 'card' &&
}
+ {variant === 'list' && (
+
+ )}
+ {variant === 'compact' && (
+
+ )}
+
+ ))}
+
+ );
+}
+
+interface SkeletonHeroProps {
+ className?: string;
+}
+
+export function SkeletonHero({ className }: SkeletonHeroProps) {
+ return (
+
+ );
+}
+
+interface SkeletonFormProps {
+ fields?: number;
+ className?: string;
+}
+
+export function SkeletonForm({ fields = 4, className }: SkeletonFormProps) {
+ return (
+
+ {Array.from({ length: fields }).map((_, i) => (
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..52a39d1
--- /dev/null
+++ b/src/components/ui/tooltip.tsx
@@ -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(null);
+ const tooltipRef = useRef(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 (
+
+ {children}
+ {isVisible && (
+
+ )}
+
+ );
+}
diff --git a/src/hooks/use-keyboard-shortcuts.ts b/src/hooks/use-keyboard-shortcuts.ts
new file mode 100644
index 0000000..04acbbe
--- /dev/null
+++ b/src/hooks/use-keyboard-shortcuts.ts
@@ -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]);
+}
diff --git a/src/hooks/use-swipe-gesture.tsx b/src/hooks/use-swipe-gesture.tsx
new file mode 100644
index 0000000..f4a959f
--- /dev/null
+++ b/src/hooks/use-swipe-gesture.tsx
@@ -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({
+ isSwiping: false,
+ startX: 0,
+ currentX: 0,
+ progress: 0,
+ direction: null,
+ });
+
+ const containerRef = useRef(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 (
+ <>
+ } className={cn(className)} />
+
+
+ {swipeHint && (
+
+ {swipeHint === 'prev' ? (
+ <>
+
+ {prevLabel}
+ >
+ ) : (
+ <>
+ {nextLabel}
+
+ >
+ )}
+
+ )}
+
+
+
+ {showHint && (
+
+
+
提示
+
+ 尝试左右滑动屏幕切换页面
+
+
+ {prevRoute && (
+
+ )}
+ {nextRoute && (
+
+ )}
+
+
+
+ )}
+
+
+ {(prevRoute || nextRoute) && (
+
+ )}
+ >
+ );
+}
+
+interface PullToRefreshProps {
+ onRefresh: () => Promise
;
+ 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 (
+
+
+
60 ? 180 : 0 }}
+ transition={{ duration: 0.3 }}
+ >
+
+
+
+
+ {children}
+
+ );
+}