diff --git a/next.config.ts b/next.config.ts index 3fae878..b6c8d96 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,6 +5,8 @@ const nextConfig: NextConfig = { distDir: 'dist', images: { unoptimized: true, + deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], + imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], }, }; diff --git a/src/components/layout/mobile-menu.tsx b/src/components/layout/mobile-menu.tsx new file mode 100644 index 0000000..1435b10 --- /dev/null +++ b/src/components/layout/mobile-menu.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Menu, X } from 'lucide-react'; +import { NAVIGATION } from '@/lib/constants'; +import { cn } from '@/lib/utils'; + +interface MobileMenuProps { + className?: string; +} + +export function MobileMenu({ className }: MobileMenuProps) { + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = 'unset'; + } + + return () => { + document.body.style.overflow = 'unset'; + }; + }, [isOpen]); + + const handleNavClick = (href: string) => { + setIsOpen(false); + const element = document.querySelector(href); + if (element) { + element.scrollIntoView({ behavior: 'smooth' }); + } + }; + + return ( +
+ + + {isOpen && ( + <> +
setIsOpen(false)} + /> + + + + )} +
+ ); +} diff --git a/src/components/ui/touch-button.tsx b/src/components/ui/touch-button.tsx new file mode 100644 index 0000000..29663b5 --- /dev/null +++ b/src/components/ui/touch-button.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useState, type ReactNode, type ButtonHTMLAttributes } from 'react'; +import { cn } from '@/lib/utils'; + +interface TouchButtonProps extends ButtonHTMLAttributes { + children: ReactNode; + variant?: 'primary' | 'secondary' | 'ghost'; + size?: 'sm' | 'md' | 'lg'; + fullWidth?: boolean; +} + +export function TouchButton({ + children, + variant = 'primary', + size = 'md', + fullWidth = false, + className, + disabled, + ...props +}: TouchButtonProps) { + const [isPressed, setIsPressed] = useState(false); + + const baseStyles = ` + inline-flex items-center justify-center font-medium + transition-all duration-150 ease-out + active:scale-95 + focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 + disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100 + touch-manipulation + `; + + const variants = { + primary: ` + bg-[#C41E3A] text-white + hover:bg-[#A01830] + focus-visible:ring-[#C41E3A] + ${isPressed ? 'bg-[#8B1429]' : ''} + `, + secondary: ` + bg-[#F5F5F5] text-[#171717] + hover:bg-[#E5E5E5] + border border-[#E5E5E5] + focus-visible:ring-[#C41E3A] + ${isPressed ? 'bg-[#D4D4D4]' : ''} + `, + ghost: ` + bg-transparent text-[#525252] + hover:bg-[#FEF2F4] hover:text-[#C41E3A] + focus-visible:ring-[#C41E3A] + ${isPressed ? 'bg-[#FCE8EC] text-[#C41E3A]' : ''} + `, + }; + + const sizes = { + sm: 'text-sm px-4 py-2 rounded-md gap-1.5 min-h-[36px]', + md: 'text-base px-5 py-2.5 rounded-lg gap-2 min-h-[44px]', + lg: 'text-lg px-6 py-3 rounded-lg gap-2 min-h-[52px]', + }; + + return ( + + ); +} diff --git a/src/components/ui/touch-swipe.tsx b/src/components/ui/touch-swipe.tsx new file mode 100644 index 0000000..402983d --- /dev/null +++ b/src/components/ui/touch-swipe.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useState, useRef, useEffect, type ReactNode } from 'react'; + +interface TouchSwipeProps { + children: ReactNode; + onSwipeLeft?: () => void; + onSwipeRight?: () => void; + threshold?: number; + className?: string; +} + +export function TouchSwipe({ + children, + onSwipeLeft, + onSwipeRight, + threshold = 50, + className = '', +}: TouchSwipeProps) { + const [touchStart, setTouchStart] = useState(null); + const [touchEnd, setTouchEnd] = useState(null); + const containerRef = useRef(null); + + const handleTouchStart = (e: React.TouchEvent) => { + setTouchEnd(null); + setTouchStart(e.targetTouches[0].clientX); + }; + + const handleTouchMove = (e: React.TouchEvent) => { + setTouchEnd(e.targetTouches[0].clientX); + }; + + const handleTouchEnd = () => { + if (!touchStart || !touchEnd) return; + + const distance = touchStart - touchEnd; + const isLeftSwipe = distance > threshold; + const isRightSwipe = distance < -threshold; + + if (isLeftSwipe && onSwipeLeft) { + onSwipeLeft(); + } + + if (isRightSwipe && onSwipeRight) { + onSwipeRight(); + } + }; + + return ( +
+ {children} +
+ ); +} diff --git a/src/hooks/use-intersection-observer.ts b/src/hooks/use-intersection-observer.ts new file mode 100644 index 0000000..e4b451c --- /dev/null +++ b/src/hooks/use-intersection-observer.ts @@ -0,0 +1,41 @@ +'use client'; + +import { useState, useEffect, useRef, type RefObject } from 'react'; + +interface UseIntersectionObserverOptions { + threshold?: number | number[]; + root?: Element | null; + rootMargin?: string; + freezeOnceVisible?: boolean; +} + +export function useIntersectionObserver( + options: UseIntersectionObserverOptions = {} +): [RefObject, boolean] { + const { threshold = 0, root = null, rootMargin = '0px', freezeOnceVisible = false } = options; + + const elementRef = useRef(null); + const [isIntersecting, setIsIntersecting] = useState(false); + + useEffect(() => { + const element = elementRef.current; + if (!element) return; + + if (freezeOnceVisible && isIntersecting) return; + + const observer = new IntersectionObserver( + ([entry]) => { + setIsIntersecting(entry.isIntersecting); + }, + { threshold, root, rootMargin } + ); + + observer.observe(element); + + return () => { + observer.disconnect(); + }; + }, [threshold, root, rootMargin, freezeOnceVisible, isIntersecting]); + + return [elementRef, isIntersecting]; +} diff --git a/src/hooks/use-media-query.ts b/src/hooks/use-media-query.ts new file mode 100644 index 0000000..39a98e3 --- /dev/null +++ b/src/hooks/use-media-query.ts @@ -0,0 +1,39 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +export function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(false); + + useEffect(() => { + const media = window.matchMedia(query); + + if (media.matches !== matches) { + setMatches(media.matches); + } + + const listener = (event: MediaQueryListEvent) => { + setMatches(event.matches); + }; + + media.addEventListener('change', listener); + + return () => { + media.removeEventListener('change', listener); + }; + }, [matches, query]); + + return matches; +} + +export function useIsMobile(): boolean { + return useMediaQuery('(max-width: 767px)'); +} + +export function useIsTablet(): boolean { + return useMediaQuery('(min-width: 768px) and (max-width: 1023px)'); +} + +export function useIsDesktop(): boolean { + return useMediaQuery('(min-width: 1024px)'); +} diff --git a/src/hooks/use-scroll-reveal.ts b/src/hooks/use-scroll-reveal.ts new file mode 100644 index 0000000..63a6246 --- /dev/null +++ b/src/hooks/use-scroll-reveal.ts @@ -0,0 +1,99 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +interface UseScrollRevealOptions { + threshold?: number; + rootMargin?: string; + triggerOnce?: boolean; +} + +export function useScrollReveal(options: UseScrollRevealOptions = {}) { + const { threshold = 0.1, rootMargin = '0px', triggerOnce = true } = options; + const ref = useRef(null); + const [isRevealed, setIsRevealed] = useState(false); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsRevealed(true); + if (triggerOnce) { + observer.unobserve(element); + } + } else if (!triggerOnce) { + setIsRevealed(false); + } + }, + { threshold, rootMargin } + ); + + observer.observe(element); + + return () => observer.disconnect(); + }, [threshold, rootMargin, triggerOnce]); + + return { ref, isRevealed }; +} + +export function useCountUp(end: number, duration: number = 2000, start: number = 0) { + const [count, setCount] = useState(start); + const [isAnimating, setIsAnimating] = useState(false); + + const startAnimation = () => { + if (isAnimating) return; + setIsAnimating(true); + + const startTime = Date.now(); + const diff = end - start; + + const animate = () => { + const elapsed = Date.now() - startTime; + const progress = Math.min(elapsed / duration, 1); + + const easeOutQuart = 1 - Math.pow(1 - progress, 4); + const current = Math.floor(start + diff * easeOutQuart); + + setCount(current); + + if (progress < 1) { + requestAnimationFrame(animate); + } else { + setIsAnimating(false); + } + }; + + requestAnimationFrame(animate); + }; + + return { count, startAnimation, isAnimating }; +} + +export function useStampAnimation() { + const ref = useRef(null); + const [hasAnimated, setHasAnimated] = useState(false); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting && !hasAnimated) { + setHasAnimated(true); + observer.unobserve(element); + } + }, + { threshold: 0.5 } + ); + + observer.observe(element); + + return () => observer.disconnect(); + }, [hasAnimated]); + + return { ref, hasAnimated }; +}