From 85eccdc33b8d91e28327960de9010db732f9a8ab Mon Sep 17 00:00:00 2001 From: Eleazar Shekoaga Musa <63799805+anonfedora@users.noreply.github.com> Date: Thu, 30 Apr 2026 19:47:52 +0100 Subject: [PATCH] Revert "feat(mobile): Implement Mobile Optimization (#258)" This reverts commit 42369272999e45769a6f40a63fd5616af150af4d. --- frontend/next.config.js | 67 ---- frontend/src/app/globals.css | 180 ---------- .../components/game/MobileGameControls.tsx | 336 ------------------ frontend/src/components/ui/BottomNav.tsx | 185 ---------- frontend/src/components/ui/BottomSheet.tsx | 132 ------- .../src/components/ui/MobileErrorBoundary.tsx | 184 ---------- frontend/src/components/ui/TouchButton.tsx | 89 ----- frontend/src/hooks/useMobile.ts | 197 ---------- frontend/src/hooks/useMobileAnalytics.ts | 278 --------------- frontend/src/hooks/usePushNotifications.ts | 231 ------------ frontend/src/hooks/useSwipeGesture.ts | 137 ------- frontend/tailwind.config.ts | 72 +--- 12 files changed, 6 insertions(+), 2082 deletions(-) delete mode 100644 frontend/src/components/game/MobileGameControls.tsx delete mode 100644 frontend/src/components/ui/BottomNav.tsx delete mode 100644 frontend/src/components/ui/BottomSheet.tsx delete mode 100644 frontend/src/components/ui/MobileErrorBoundary.tsx delete mode 100644 frontend/src/components/ui/TouchButton.tsx delete mode 100644 frontend/src/hooks/useMobile.ts delete mode 100644 frontend/src/hooks/useMobileAnalytics.ts delete mode 100644 frontend/src/hooks/usePushNotifications.ts delete mode 100644 frontend/src/hooks/useSwipeGesture.ts diff --git a/frontend/next.config.js b/frontend/next.config.js index 4aea2fb..7d2c843 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -1,35 +1,5 @@ /** @type {import('next').NextConfig} */ const nextConfig = { - // PWA support - pwa: { - dest: "public", - register: true, - skipWaiting: true, - disable: process.env.NODE_ENV === "development", - }, - - // Image optimization for mobile - images: { - formats: ["image/avif", "image/webp"], - deviceSizes: [320, 420, 768, 1024, 1200, 1920, 2048], - imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], - minimumCacheTTL: 60 * 60 * 24 * 30, // 30 days - dangerouslyAllowSVG: true, - contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;", - }, - - // Enable compression - compress: true, - - // Reduce bundle size by splitting code - swcMinify: true, - - // Experimental features for better mobile performance - experimental: { - optimizeCss: true, - optimizePackageImports: ["lucide-react", "framer-motion"], - }, - async rewrites() { const apiUrl = process.env.NEXT_PUBLIC_API_URL; if (apiUrl) { @@ -42,43 +12,6 @@ const nextConfig = { } return []; }, - - // Headers for mobile optimization - async headers() { - return [ - { - source: "/:path*", - headers: [ - { - key: "Cache-Control", - value: "public, max-age=3600, stale-while-revalidate=86400", - }, - { - key: "X-Device-Type", - value: "desktop", // Updated by middleware based on User-Agent - }, - ], - }, - { - source: "/icons/:path*", - headers: [ - { - key: "Cache-Control", - value: "public, max-age=2592000, immutable", - }, - ], - }, - { - source: "/_next/static/:path*", - headers: [ - { - key: "Cache-Control", - value: "public, max-age=31536000, immutable", - }, - ], - }, - ]; - }, }; module.exports = nextConfig; diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 1584c81..8abdb15 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -74,183 +74,3 @@ @apply bg-background text-foreground; } } - -/* Mobile-first responsive utilities */ -@layer utilities { - /* Safe area insets for notched devices */ - .safe-area-t { - padding-top: env(safe-area-inset-top); - } - .safe-area-b { - padding-bottom: env(safe-area-inset-bottom); - } - .safe-area-l { - padding-left: env(safe-area-inset-left); - } - .safe-area-r { - padding-right: env(safe-area-inset-right); - } - .safe-area-p { - padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); - } - - /* Touch-friendly tap targets */ - .tap-target { - min-height: 44px; - min-width: 44px; - } - .tap-target-lg { - min-height: 48px; - min-width: 48px; - } - .tap-target-xl { - min-height: 56px; - min-width: 56px; - } - - /* Hide scrollbar but keep functionality */ - .scrollbar-hide { - -ms-overflow-style: none; - scrollbar-width: none; - } - .scrollbar-hide::-webkit-scrollbar { - display: none; - } - - /* Smooth scrolling */ - .smooth-scroll { - -webkit-overflow-scrolling: touch; - scroll-behavior: smooth; - } - - /* Prevent text selection on UI elements */ - .no-select { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - } - - /* Touch action modifiers */ - .touch-none { - touch-action: none; - } - .touch-manipulation { - touch-action: manipulation; - } - - /* Responsive text sizes */ - .text-xs-mobile { - @apply text-xs; - } - .text-sm-mobile { - @apply text-sm; - } - .text-base-mobile { - @apply text-base; - } - .text-lg-mobile { - @apply text-lg; - } - .text-xl-mobile { - @apply text-xl; - } - - /* Mobile-specific visibility */ - @media (max-width: 767px) { - .hide-mobile { - display: none !important; - } - } - - @media (min-width: 768px) { - .show-mobile-only { - display: none !important; - } - } - - /* Responsive spacing */ - .p-mobile { - @apply p-4; - } - .px-mobile { - @apply px-4; - } - .py-mobile { - @apply py-4; - } - - /* Mobile-optimized forms */ - .mobile-input { - @apply text-base; - font-size: 16px; /* Prevents iOS zoom on focus */ - } - - /* Hardware acceleration hints */ - .gpu-accelerated { - transform: translateZ(0); - will-change: transform; - backface-visibility: hidden; - } - - /* Prevent pull-to-refresh on mobile */ - .no-pull-refresh { - overscroll-behavior-y: contain; - } -} - -/* Mobile-specific base styles */ -@layer base { - /* iOS-specific fixes */ - @supports (-webkit-touch-callout: none) { - body { - min-height: -webkit-fill-available; - } - - input, textarea, select { - font-size: 16px; /* Prevents iOS zoom */ - } - - /* Smooth scrolling on iOS */ - html { - -webkit-overflow-scrolling: touch; - } - } - - /* Android-specific fixes */ - @supports not (-webkit-touch-callout: none) { - /* Android specific */ - } - - /* Responsive typography */ - html { - font-size: 16px; - } - - @media (min-width: 768px) { - html { - font-size: 16px; - } - } - - @media (min-width: 1024px) { - html { - font-size: 16px; - } - } - - /* Focus styles for touch */ - :focus-visible { - @apply outline-2 outline-ring outline-offset-2; - } - - /* Remove tap highlight on mobile */ - * { - -webkit-tap-highlight-color: transparent; - } - - /* Better touch handling */ - button, a, input, select, textarea { - touch-action: manipulation; - } -} diff --git a/frontend/src/components/game/MobileGameControls.tsx b/frontend/src/components/game/MobileGameControls.tsx deleted file mode 100644 index 60c792b..0000000 --- a/frontend/src/components/game/MobileGameControls.tsx +++ /dev/null @@ -1,336 +0,0 @@ -// filepath: frontend/src/components/game/MobileGameControls.tsx -"use client"; - -import { useState, useCallback, useRef, useEffect } from "react"; -import { cn } from "@/lib/utils"; -import { useDevice, useVibration } from "@/hooks/useMobile"; -import { useSwipeGesture } from "@/hooks/useSwipeGesture"; - -interface GameControlConfig { - size?: "small" | "medium" | "large"; - showLabels?: boolean; - hapticFeedback?: boolean; -} - -interface Position { - x: number; - y: number; -} - -// Virtual joystick for movement -interface VirtualJoystickProps { - onMove: (x: number, y: number) => void; - onRelease?: () => void; - disabled?: boolean; - className?: string; -} - -function VirtualJoystick({ - onMove, - onRelease, - disabled = false, - className, -}: VirtualJoystickProps) { - const [isActive, setIsActive] = useState(false); - const [position, setPosition] = useState({ x: 0, y: 0 }); - const joystickRef = useRef(null); - const centerRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); - const maxDistance = 40; - const { vibrate } = useVibration(); - - const handleTouchStart = useCallback( - (e: React.TouchEvent) => { - if (disabled) return; - e.preventDefault(); - e.stopPropagation(); - - const rect = joystickRef.current?.getBoundingClientRect(); - if (!rect) return; - - setIsActive(true); - centerRef.current = { - x: rect.left + rect.width / 2, - y: rect.top + rect.height / 2, - }; - - if (vibrate) vibrate(10); - }, - [disabled, vibrate] - ); - - const handleTouchMove = useCallback( - (e: React.TouchEvent) => { - if (!isActive || disabled) return; - e.preventDefault(); - - const touch = e.touches[0]; - const deltaX = touch.clientX - centerRef.current.x; - const deltaY = touch.clientY - centerRef.current.y; - const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); - - // Clamp to max distance - const clampedDistance = Math.min(distance, maxDistance); - const angle = Math.atan2(deltaY, deltaX); - - const clampedX = Math.cos(angle) * clampedDistance; - const clampedY = Math.sin(angle) * clampedDistance; - - setPosition({ x: clampedX, y: clampedY }); - - // Normalize to -1 to 1 range - const normalizedX = clampedX / maxDistance; - const normalizedY = clampedY / maxDistance; - onMove(normalizedX, normalizedY); - }, - [isActive, disabled, onMove] - ); - - const handleTouchEnd = useCallback(() => { - setIsActive(false); - setPosition({ x: 0, y: 0 }); - onMove(0, 0); - onRelease?.(); - }, [onMove, onRelease]); - - return ( -
- {/* Outer ring */} -
- - {/* Joystick knob */} -
-
- ); -} - -// Action buttons for game -interface ActionButtonProps { - label: string; - icon?: React.ReactNode; - onPress: () => void; - onRelease?: () => void; - variant?: "primary" | "secondary" | "danger"; - size?: "small" | "medium" | "large"; - disabled?: boolean; -} - -function ActionButton({ - label, - icon, - onPress, - onRelease, - variant = "primary", - size = "medium", - disabled = false, -}: ActionButtonProps) { - const [isPressed, setIsPressed] = useState(false); - const { vibrate } = useVibration(); - - const handleTouchStart = useCallback( - (e: React.TouchEvent) => { - if (disabled) return; - e.preventDefault(); - setIsPressed(true); - vibrate?.(15); - onPress(); - }, - [disabled, onPress, vibrate] - ); - - const handleTouchEnd = useCallback(() => { - setIsPressed(false); - onRelease?.(); - }, [onRelease]); - - const sizeClasses = { - small: "w-14 h-14 text-sm", - medium: "w-16 h-16 text-base", - large: "w-20 h-20 text-lg", - }; - - const variantClasses = { - primary: "bg-primary text-primary-foreground", - secondary: "bg-secondary text-secondary-foreground", - danger: "bg-destructive text-destructive-foreground", - }; - - return ( - - ); -} - -// Main mobile game controls component -interface MobileGameControlsProps { - config?: GameControlConfig; - onMove?: (x: number, y: number) => void; - onJump?: () => void; - onAttack?: () => void; - onSpecial?: () => void; - onPause?: () => void; - disabled?: boolean; -} - -export function MobileGameControls({ - config = {}, - onMove, - onJump, - onAttack, - onSpecial, - onPause, - disabled = false, -}: MobileGameControlsProps) { - const { isMobile, isLandscape } = useDevice(); - const [moveVector, setMoveVector] = useState({ x: 0, y: 0 }); - - // Handle joystick movement - const handleMove = useCallback( - (x: number, y: number) => { - setMoveVector({ x, y }); - onMove?.(x, y); - }, - [onMove] - ); - - // Handle joystick release - const handleRelease = useCallback(() => { - setMoveVector({ x: 0, y: 0 }); - onMove?.(0, 0); - }, [onMove]); - - // Track gesture for quick actions - const { handleTouchStart: handleSwipeStart, handleTouchEnd: handleSwipeEnd } = - useSwipeGesture({ - threshold: 30, - onSwipeLeft: () => onSpecial?.(), - onSwipeRight: () => onAttack?.(), - onSwipeUp: () => onJump?.(), - enabled: !disabled, - }); - - // Show controls only on mobile - if (!isMobile) { - return null; - } - - return ( -
- {/* Top bar - pause button */} -
- -
- - {/* Bottom controls */} -
- {/* Left side - Movement joystick */} -
- -
- - {/* Right side - Action buttons */} -
- {})} - variant="primary" - size={config.size === "large" ? "large" : "medium"} - disabled={disabled} - /> - {})} - variant="secondary" - size={config.size === "large" ? "large" : "medium"} - disabled={disabled} - /> - {})} - variant="primary" - size={config.size === "large" ? "large" : "medium"} - disabled={disabled} - /> -
-
-
- ); -} - -export { VirtualJoystick, ActionButton }; -export default MobileGameControls; \ No newline at end of file diff --git a/frontend/src/components/ui/BottomNav.tsx b/frontend/src/components/ui/BottomNav.tsx deleted file mode 100644 index b526202..0000000 --- a/frontend/src/components/ui/BottomNav.tsx +++ /dev/null @@ -1,185 +0,0 @@ -// filepath: frontend/src/components/ui/BottomNav.tsx -"use client"; - -import { useState, useEffect } from "react"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { motion, AnimatePresence } from "framer-motion"; -import { cn } from "@/lib/utils"; -import { useDevice } from "@/hooks/useMobile"; - -// Navigation items with icons -const NAV_ITEMS = [ - { - label: "Play", - href: "/play", - icon: (active: boolean) => ( - - - - ), - }, - { - label: "Tournaments", - href: "/tournaments", - icon: (active: boolean) => ( - - - - ), - }, - { - label: "Matches", - href: "/matches", - icon: (active: boolean) => ( - - - - ), - }, - { - label: "Leaderboard", - href: "/leaderboard", - icon: (active: boolean) => ( - - - - ), - }, - { - label: "Profile", - href: "/profile", - icon: (active: boolean) => ( - - - - ), - }, -]; - -interface BottomNavProps { - className?: string; -} - -export function BottomNav({ className }: BottomNavProps) { - const pathname = usePathname(); - const { isMobile, safeAreaInsets } = useDevice(); - const [isVisible, setIsVisible] = useState(true); - const [lastScrollY, setLastScrollY] = useState(0); - - // Hide on scroll down, show on scroll up - useEffect(() => { - const handleScroll = () => { - const currentScrollY = window.scrollY; - - if (currentScrollY > lastScrollY && currentScrollY > 100) { - // Scrolling down - hide - setIsVisible(false); - } else { - // Scrolling up - show - setIsVisible(true); - } - - setLastScrollY(currentScrollY); - }; - - window.addEventListener("scroll", handleScroll, { passive: true }); - return () => window.removeEventListener("scroll", handleScroll); - }, [lastScrollY]); - - // Don't render on desktop - if (!isMobile) { - return null; - } - - return ( - - {isVisible && ( - -
- {NAV_ITEMS.map((item) => { - const isActive = pathname === item.href || - (item.href !== "/" && pathname.startsWith(item.href)); - - return ( - -
- {item.icon(isActive)} - {isActive && ( - - )} -
- - {item.label} - - - ); - })} -
-
- )} -
- ); -} - -export default BottomNav; \ No newline at end of file diff --git a/frontend/src/components/ui/BottomSheet.tsx b/frontend/src/components/ui/BottomSheet.tsx deleted file mode 100644 index 1f93cb8..0000000 --- a/frontend/src/components/ui/BottomSheet.tsx +++ /dev/null @@ -1,132 +0,0 @@ -// filepath: frontend/src/components/ui/BottomSheet.tsx -"use client"; - -import { useEffect, useRef, useCallback, ReactNode } from "react"; -import { X } from "lucide-react"; -import { motion, AnimatePresence } from "framer-motion"; -import { cn } from "@/lib/utils"; - -interface BottomSheetProps { - isOpen: boolean; - onClose: () => void; - title?: string; - children: ReactNode; - className?: string; - snapPoints?: number[]; -} - -export function BottomSheet({ - isOpen, - onClose, - title, - children, - className, - snapPoints = [0.25, 0.5, 0.75, 1], -}: BottomSheetProps) { - const sheetRef = useRef(null); - const startY = useRef(0); - const currentSnap = useRef(0); - - // Handle touch gestures for snapping - const handleTouchStart = useCallback((e: React.TouchEvent) => { - startY.current = e.touches[0].clientY; - }, []); - - const handleTouchMove = useCallback((e: React.TouchEvent) => { - if (!sheetRef.current) return; - - const deltaY = e.touches[0].clientY - startY.current; - const currentHeight = sheetRef.current.offsetHeight; - - // Calculate snap point based on drag - if (deltaY > 0) { - // Dragging down - could close or snap down - const progress = deltaY / currentHeight; - if (progress > 0.3) { - onClose(); - } - } - }, [onClose]); - - // Prevent body scroll when sheet is open - useEffect(() => { - if (isOpen) { - document.body.style.overflow = "hidden"; - } else { - document.body.style.overflow = ""; - } - return () => { - document.body.style.overflow = ""; - }; - }, [isOpen]); - - // Handle ESC key - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape" && isOpen) { - onClose(); - } - }; - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [isOpen, onClose]); - - return ( - - {isOpen && ( - <> - {/* Backdrop */} - - - {/* Bottom Sheet */} - - {/* Handle indicator */} -
-
-
- - {/* Header */} - {title && ( -
-

{title}

- -
- )} - - {/* Content */} -
- {children} -
- - - )} - - ); -} \ No newline at end of file diff --git a/frontend/src/components/ui/MobileErrorBoundary.tsx b/frontend/src/components/ui/MobileErrorBoundary.tsx deleted file mode 100644 index f36347c..0000000 --- a/frontend/src/components/ui/MobileErrorBoundary.tsx +++ /dev/null @@ -1,184 +0,0 @@ -// filepath: frontend/src/components/ui/MobileErrorBoundary.tsx -"use client"; - -import { Component, ReactNode } from "react"; -import { Button } from "@/components/ui/Button"; -import { AlertTriangle, RefreshCw, Home, Mail } from "lucide-react"; - -interface ErrorBoundaryProps { - children: ReactNode; - fallback?: ReactNode; - onError?: (error: Error, errorInfo: React.ErrorInfo) => void; -} - -interface ErrorBoundaryState { - hasError: boolean; - error: Error | null; -} - -// Mobile-specific error messages -const MOBILE_ERROR_MESSAGES = { - network: { - title: "Connection Lost", - message: "Please check your internet connection and try again.", - action: "Retry", - }, - timeout: { - title: "Request Timeout", - message: "The server took too long to respond. Please try again.", - action: "Try Again", - }, - generic: { - title: "Something Went Wrong", - message: "An unexpected error occurred. Please try again.", - action: "Refresh", - }, - offline: { - title: "You're Offline", - message: "Please connect to the internet to continue.", - action: "Go Back", - }, -}; - -export class MobileErrorBoundary extends Component< - ErrorBoundaryProps, - ErrorBoundaryState -> { - constructor(props: ErrorBoundaryProps) { - super(props); - this.state = { hasError: false, error: null }; - } - - static getDerivedStateFromError(error: Error): ErrorBoundaryState { - return { hasError: true, error }; - } - - componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { - console.error("Mobile Error Boundary caught:", error, errorInfo); - this.props.onError?.(error, errorInfo); - } - - handleRetry = () => { - this.setState({ hasError: false, error: null }); - }; - - handleGoHome = () => { - window.location.href = "/"; - }; - - handleReportIssue = () => { - window.location.href = "/contact?error=true"; - }; - - getErrorType = (): keyof typeof MOBILE_ERROR_MESSAGES => { - const { error } = this.state; - if (!error) return "generic"; - - const message = error.message.toLowerCase(); - if (message.includes("network") || message.includes("fetch")) { - return "network"; - } - if (message.includes("timeout") || message.includes("timed out")) { - return "timeout"; - } - if (message.includes("offline")) { - return "offline"; - } - return "generic"; - }; - - render() { - if (this.state.hasError) { - if (this.props.fallback) { - return this.props.fallback; - } - - const errorType = this.getErrorType(); - const errorInfo = MOBILE_ERROR_MESSAGES[errorType]; - - return ( -
-
- {/* Error Icon */} -
- -
- - {/* Error Title */} -

- {errorInfo.title} -

- - {/* Error Message */} -

- {errorInfo.message} -

- - {/* Error Code (for debugging) */} - {this.state.error && ( -
- - Technical Details - -
-                  {this.state.error.message}
-                
-
- )} - - {/* Action Buttons */} -
- - -
- - - -
-
-
-
- ); - } - - return this.props.children; - } -} - -// Hook for mobile-specific error handling -export function useMobileErrorHandler() { - const handleError = (error: Error) => { - console.error("[Mobile Error]:", error); - - // Track error for analytics - if (typeof window !== "undefined" && (window as any).gtag) { - (window as any).gtag("event", "exception", { - description: error.message, - fatal: false, - }); - } - }; - - return { handleError }; -} \ No newline at end of file diff --git a/frontend/src/components/ui/TouchButton.tsx b/frontend/src/components/ui/TouchButton.tsx deleted file mode 100644 index 92f1e2e..0000000 --- a/frontend/src/components/ui/TouchButton.tsx +++ /dev/null @@ -1,89 +0,0 @@ -// filepath: frontend/src/components/ui/TouchButton.tsx -"use client"; - -import { ButtonHTMLAttributes, forwardRef } from "react"; -import { cn } from "@/lib/utils"; -import { cva, type VariantProps } from "class-variance-authority"; - -const touchButtonVariants = cva( - "inline-flex items-center justify-center rounded-lg font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background", - { - variants: { - variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", - destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", - outline: "border border-input hover:bg-accent hover:text-accent-foreground", - secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: "hover:bg-accent hover:text-accent-foreground", - link: "underline-offset-4 hover:underline text-primary", - }, - size: { - default: "h-10 py-2 px-4 min-h-[44px] min-w-[44px]", // Touch-friendly size - sm: "h-9 px-3 rounded-md min-h-[36px] min-w-[36px]", - lg: "h-12 px-8 rounded-lg min-h-[48px] min-w-[48px]", - xl: "h-14 px-10 rounded-xl min-h-[56px] min-w-[56px]", - icon: "h-10 w-10 min-h-[44px] min-w-[44px]", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -); - -export interface TouchButtonProps - extends ButtonHTMLAttributes, - VariantProps { - loading?: boolean; - fullWidth?: boolean; -} - -const TouchButton = forwardRef( - ({ className, variant, size, loading, fullWidth, children, disabled, ...props }, ref) => { - return ( - - ); - } -); - -TouchButton.displayName = "TouchButton"; - -export { TouchButton, touchButtonVariants }; \ No newline at end of file diff --git a/frontend/src/hooks/useMobile.ts b/frontend/src/hooks/useMobile.ts deleted file mode 100644 index b23c8e7..0000000 --- a/frontend/src/hooks/useMobile.ts +++ /dev/null @@ -1,197 +0,0 @@ -// filepath: frontend/src/hooks/useMobile.ts -"use client"; - -import { useState, useEffect, useCallback } from "react"; - -export type DeviceType = "mobile" | "tablet" | "desktop"; - -interface ViewportSize { - width: number; - height: number; -} - -interface UseDeviceReturn { - isMobile: boolean; - isTablet: boolean; - isDesktop: boolean; - deviceType: DeviceType; - viewport: ViewportSize; - isPortrait: boolean; - isLandscape: boolean; - isTouchDevice: boolean; - hasNotch: boolean; - safeAreaInsets: { - top: number; - right: number; - bottom: number; - left: number; - }; -} - -// Breakpoints matching Tailwind config -const BREAKPOINTS = { - sm: 640, - md: 768, - lg: 1024, - xl: 1280, - "2xl": 1536, -}; - -export function useDevice(): UseDeviceReturn { - const [viewport, setViewport] = useState({ - width: typeof window !== "undefined" ? window.innerWidth : 0, - height: typeof window !== "undefined" ? window.innerHeight : 0, - }); - - const [deviceType, setDeviceType] = useState("desktop"); - const [isTouchDevice, setIsTouchDevice] = useState(false); - const [hasNotch, setHasNotch] = useState(false); - - const updateViewport = useCallback(() => { - if (typeof window === "undefined") return; - - const width = window.innerWidth; - const height = window.innerHeight; - - setViewport({ width, height }); - - // Determine device type - if (width < BREAKPOINTS.md) { - setDeviceType("mobile"); - } else if (width < BREAKPOINTS.lg) { - setDeviceType("tablet"); - } else { - setDeviceType("desktop"); - } - - // Check for touch device - setIsTouchDevice( - "ontouchstart" in window || - navigator.maxTouchPoints > 0 || - window.matchMedia("(pointer: coarse)").matches - ); - - // Check for notch (iPhone X and newer) - setHasNotch( - // iPhone X, XS, XR, 11, 12, 13, 14, 15 series - (window.screen.height === 844 && window.screen.width === 390) || - (window.screen.height === 896 && window.screen.width === 414) || - (window.screen.height === 852 && window.screen.width === 393) || - (window.screen.height === 844 && window.screen.width === 391) || - (window.screen.height === 932 && window.screen.width === 430) || - (window.screen.height === 852 && window.screen.width === 393) || - // Plus other notch sizes - (window.innerHeight === 844 && window.innerWidth === 390) || - (window.innerHeight === 896 && window.innerWidth === 414) - ); - }, []); - - useEffect(() => { - updateViewport(); - - window.addEventListener("resize", updateViewport); - window.addEventListener("orientationchange", updateViewport); - - return () => { - window.removeEventListener("resize", updateViewport); - window.removeEventListener("orientationchange", updateViewport); - }; - }, [updateViewport]); - - // Calculate safe area insets using CSS - const safeAreaInsets = { - top: typeof window !== "undefined" - ? parseInt(getComputedStyle(document.documentElement).getPropertyValue("--sat") || "0") - : 0, - right: typeof window !== "undefined" - ? parseInt(getComputedStyle(document.documentElement).getPropertyValue("--sar") || "0") - : 0, - bottom: typeof window !== "undefined" - ? parseInt(getComputedStyle(document.documentElement).getPropertyValue("--sab") || "0") - : 0, - left: typeof window !== "undefined" - ? parseInt(getComputedStyle(document.documentElement).getPropertyValue("--sal") || "0") - : 0, - }; - - return { - isMobile: deviceType === "mobile", - isTablet: deviceType === "tablet", - isDesktop: deviceType === "desktop", - deviceType, - viewport, - isPortrait: viewport.height > viewport.width, - isLandscape: viewport.width > viewport.height, - isTouchDevice, - hasNotch, - safeAreaInsets, - }; -} - -// Hook for online/offline status -export function useOnlineStatus() { - const [isOnline, setIsOnline] = useState(true); - - useEffect(() => { - if (typeof window === "undefined") return; - - setIsOnline(navigator.onLine); - - const handleOnline = () => setIsOnline(true); - const handleOffline = () => setIsOnline(false); - - window.addEventListener("online", handleOnline); - window.addEventListener("offline", handleOffline); - - return () => { - window.removeEventListener("online", handleOnline); - window.removeEventListener("offline", handleOffline); - }; - }, []); - - return isOnline; -} - -// Hook for connection quality (approximation) -export function useConnectionSpeed() { - const [speed, setSpeed] = useState<"slow" | "fast">("fast"); - - useEffect(() => { - if (typeof window === "undefined") return; - - // Check if connection is save-data mode or slow - const connection = (navigator as any).connection; - - if (connection) { - const checkConnection = () => { - const effectiveType = connection.effectiveType; - if (effectiveType === "slow-2g" || effectiveType === "2g") { - setSpeed("slow"); - } else { - setSpeed("fast"); - } - }; - - checkConnection(); - connection.addEventListener("change", checkConnection); - - return () => connection.removeEventListener("change", checkConnection); - } - - // Default to fast if API not available - setSpeed("fast"); - }, []); - - return speed; -} - -// Hook for vibration feedback -export function useVibration() { - const vibrate = useCallback((pattern: number | number[] = 10) => { - if (typeof navigator !== "undefined" && navigator.vibrate) { - navigator.vibrate(pattern); - } - }, []); - - return { vibrate }; -} \ No newline at end of file diff --git a/frontend/src/hooks/useMobileAnalytics.ts b/frontend/src/hooks/useMobileAnalytics.ts deleted file mode 100644 index 89ee100..0000000 --- a/frontend/src/hooks/useMobileAnalytics.ts +++ /dev/null @@ -1,278 +0,0 @@ -// filepath: frontend/src/hooks/useMobileAnalytics.ts -"use client"; - -import { useEffect, useCallback, useRef } from "react"; -import { useDevice, useOnlineStatus, useConnectionSpeed } from "./useMobile"; - -interface AnalyticsEvent { - category: string; - action: string; - label?: string; - value?: number; -} - -interface ScreenView { - screen_name: string; - screen_path: string; -} - -interface UserTiming { - category: string; - variable: string; - value: number; - label?: string; -} - -interface Exception { - description: string; - fatal: boolean; -} - -// Analytics configuration -const ANALYTICS_CONFIG = { - // Sample rate for mobile (can be lower to reduce data) - sampleRate: 100, // 100% on mobile - // Minimum session duration before sending events - minSessionDuration: 10000, - // Maximum queue size before forcing flush - maxQueueSize: 50, - // Flush interval in milliseconds - flushInterval: 30000, -}; - -class MobileAnalytics { - private queue: AnalyticsEvent[] = []; - private screenHistory: ScreenView[] = []; - private sessionStart: number = 0; - private isInitialized: boolean = false; - private flushTimer: NodeJS.Timeout | null = null; - - // Initialize analytics - init() { - if (this.isInitialized) return; - - this.sessionStart = Date.now(); - this.isInitialized = true; - - // Start flush timer - this.flushTimer = setInterval(() => { - this.flush(); - }, ANALYTICS_CONFIG.flushInterval); - - console.log("[MobileAnalytics] Initialized"); - } - - // Track screen view - trackScreenView(screenName: string, screenPath: string) { - const screenView: ScreenView = { - screen_name: screenName, - screen_path: screenPath, - }; - - this.screenHistory.push(screenView); - - // In production, send to analytics service - this.sendEvent({ - category: "screen_view", - action: screenName, - label: screenPath, - }); - - console.log("[MobileAnalytics] Screen view:", screenName); - } - - // Track event - trackEvent(event: AnalyticsEvent) { - this.queue.push(event); - - // Flush if queue is full - if (this.queue.length >= ANALYTICS_CONFIG.maxQueueSize) { - this.flush(); - } - } - - // Track user timing - trackUserTiming(timing: UserTiming) { - this.sendEvent({ - category: "timing", - action: timing.variable, - label: timing.label, - value: timing.value, - }); - } - - // Track exception - trackException(exception: Exception) { - this.sendEvent({ - category: "exception", - action: exception.description, - label: exception.fatal ? "fatal" : "non-fatal", - }); - } - - // Track mobile-specific events - trackMobileEvent(action: string, label?: string, value?: number) { - this.trackEvent({ - category: "mobile", - action, - label, - value, - }); - } - - // Send event to analytics service - private sendEvent(event: AnalyticsEvent) { - // In production, replace with actual analytics service call - // Example: gtag, Mixpanel, Amplitude, etc. - console.log("[MobileAnalytics] Event:", event); - - // Example implementation: - // if (typeof window !== 'undefined' && (window as any).gtag) { - // (window as any).gtag('event', event.action, { - // event_category: event.category, - // event_label: event.label, - // value: event.value, - // }); - // } - } - - // Flush queue - private flush() { - if (this.queue.length === 0) return; - - // Send all events - this.queue.forEach((event) => this.sendEvent(event)); - this.queue = []; - - console.log("[MobileAnalytics] Flushed events"); - } - - // End session - endSession() { - const sessionDuration = Date.now() - this.sessionStart; - - if (sessionDuration >= ANALYTICS_CONFIG.minSessionDuration) { - this.trackEvent({ - category: "session", - action: "end", - value: sessionDuration, - }); - } - - this.flush(); - - if (this.flushTimer) { - clearInterval(this.flushTimer); - } - } - - // Get screen history - getScreenHistory() { - return [...this.screenHistory]; - } -} - -// Create singleton instance -const analytics = new MobileAnalytics(); - -// React hook for using analytics -export function useMobileAnalytics() { - const device = useDevice(); - const isOnline = useOnlineStatus(); - const connectionSpeed = useConnectionSpeed(); - const isInitialized = useRef(false); - - // Initialize on mount - useEffect(() => { - if (!isInitialized.current) { - analytics.init(); - isInitialized.current = true; - } - - // Track device info - analytics.trackMobileEvent("device_info", `${device.deviceType}`, undefined); - - // Track connection speed - analytics.trackMobileEvent("connection", connectionSpeed, undefined); - - // Track online status - if (!isOnline) { - analytics.trackMobileEvent("offline", "connection_lost"); - } - - return () => { - analytics.endSession(); - }; - }, []); - - // Track screen view - const trackScreen = useCallback((screenName: string, screenPath: string) => { - analytics.trackScreenView(screenName, screenPath); - }, []); - - // Track custom event - const trackEvent = useCallback( - (action: string, label?: string, value?: number) => { - if (!isOnline) { - console.log("[MobileAnalytics] Offline - event queued"); - } - analytics.trackMobileEvent(action, label, value); - }, - [isOnline] - ); - - // Track timing - const trackTiming = useCallback( - (category: string, variable: string, value: number, label?: string) => { - analytics.trackUserTiming({ category, variable, value, label }); - }, - [] - ); - - // Track error - const trackError = useCallback( - (description: string, fatal = false) => { - analytics.trackException({ description, fatal }); - }, - [] - ); - - // Track touch interaction - const trackTouch = useCallback( - (element: string, action: string) => { - analytics.trackMobileEvent("touch", `${element}_${action}`); - }, - [] - ); - - // Track gesture - const trackGesture = useCallback( - (gesture: string, direction?: string) => { - analytics.trackMobileEvent("gesture", direction ? `${gesture}_${direction}` : gesture); - }, - [] - ); - - // Track performance - const trackPerformance = useCallback( - (metric: string, value: number) => { - analytics.trackTiming("performance", metric, value); - }, - [] - ); - - return { - trackScreen, - trackEvent, - trackTiming, - trackError, - trackTouch, - trackGesture, - trackPerformance, - deviceType: device.deviceType, - isOnline, - connectionSpeed, - }; -} - -export default useMobileAnalytics; \ No newline at end of file diff --git a/frontend/src/hooks/usePushNotifications.ts b/frontend/src/hooks/usePushNotifications.ts deleted file mode 100644 index 65d0b99..0000000 --- a/frontend/src/hooks/usePushNotifications.ts +++ /dev/null @@ -1,231 +0,0 @@ -// filepath: frontend/src/hooks/usePushNotifications.ts -"use client"; - -import { useState, useEffect, useCallback } from "react"; - -interface PushNotificationPayload { - title: string; - body: string; - icon?: string; - badge?: string; - tag?: string; - data?: Record; - actions?: Array<{ - action: string; - title: string; - icon?: string; - }>; -} - -interface PushNotificationPermission { - status: NotificationPermission; - token?: string; -} - -interface UsePushNotificationsReturn { - permission: NotificationPermission; - token: string | null; - isSupported: boolean; - isEnabled: boolean; - subscribe: () => Promise; - unsubscribe: () => Promise; - showNotification: (payload: PushNotificationPayload) => void; -} - -// Check if push notifications are supported -const isPushSupported = (): boolean => { - if (typeof window === "undefined") return false; - - return ( - "serviceWorker" in navigator && - "PushManager" in window && - Notification.permission !== "denied" - ); -}; - -// Get current permission status -const getPermissionStatus = async (): Promise => { - if (typeof window === "undefined") return "default"; - - // Check if already granted - if (Notification.permission === "granted") { - return "granted"; - } - - // Check if denied - if (Notification.permission === "denied") { - return "denied"; - } - - return "default"; -}; - -export function usePushNotifications(): UsePushNotificationsReturn { - const [permission, setPermission] = useState("default"); - const [token, setToken] = useState(null); - const [isEnabled, setIsEnabled] = useState(false); - - // Check support and permission on mount - useEffect(() => { - const checkPermission = async () => { - if (!isPushSupported()) { - console.log("[PushNotifications] Not supported"); - return; - } - - const status = await getPermissionStatus(); - setPermission(status); - - if (status === "granted") { - setIsEnabled(true); - // Get token if available - try { - const registration = await navigator.serviceWorker.ready; - const subscription = await registration.pushManager.getSubscription(); - if (subscription) { - setToken(subscription.endpoint); - } - } catch (error) { - console.error("[PushNotifications] Error getting subscription:", error); - } - } - }; - - checkPermission(); - }, []); - - // Subscribe to push notifications - const subscribe = useCallback(async () => { - if (!isPushSupported()) { - console.error("[PushNotifications] Push not supported"); - throw new Error("Push notifications not supported"); - } - - try { - // Request permission - const permissionResult = await Notification.requestPermission(); - setPermission(permissionResult); - - if (permissionResult !== "granted") { - throw new Error("Permission not granted"); - } - - // Get service worker registration - const registration = await navigator.serviceWorker.ready; - - // Subscribe to push - const subscription = await registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array( - // In production, use your VAPID public key - process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || "" - ), - }); - - // Send subscription to server - const subscriptionJson = subscription.toJSON(); - - // In production, send to your backend - // await fetch('/api/notifications/subscribe', { - // method: 'POST', - // headers: { 'Content-Type': 'application/json' }, - // body: JSON.stringify(subscriptionJson), - // }); - - setToken(subscription.endpoint); - setIsEnabled(true); - - console.log("[PushNotifications] Subscribed successfully"); - } catch (error) { - console.error("[PushNotifications] Subscription error:", error); - throw error; - } - }, []); - - // Unsubscribe from push notifications - const unsubscribe = useCallback(async () => { - try { - const registration = await navigator.serviceWorker.ready; - const subscription = await registration.pushManager.getSubscription(); - - if (subscription) { - await subscription.unsubscribe(); - - // In production, notify backend - // await fetch('/api/notifications/unsubscribe', { - // method: 'POST', - // headers: { 'Content-Type': 'application/json' }, - // body: JSON.stringify({ endpoint: subscription.endpoint }), - // }); - } - - setToken(null); - setIsEnabled(false); - - console.log("[PushNotifications] Unsubscribed successfully"); - } catch (error) { - console.error("[PushNotifications] Unsubscribe error:", error); - throw error; - } - }, []); - - // Show local notification (for testing) - const showNotification = useCallback( - (payload: PushNotificationPayload) => { - if (permission !== "granted") { - console.warn("[PushNotifications] Permission not granted"); - return; - } - - const options: NotificationOptions = { - body: payload.body, - icon: payload.icon || "/icons/icon-192x192.png", - badge: payload.badge || "/icons/icon-72x72.png", - tag: payload.tag, - data: payload.data, - vibrate: [100, 50, 100], - actions: payload.actions, - }; - - // Try to use service worker notification first - if ("serviceWorker" in navigator) { - navigator.serviceWorker.ready.then((registration) => { - registration.showNotification(payload.title, options); - }); - } else { - // Fallback to standard notification - new Notification(payload.title, options); - } - }, - [permission] - ); - - return { - permission, - token, - isSupported: isPushSupported(), - isEnabled, - subscribe, - unsubscribe, - showNotification, - }; -} - -// Helper function to convert VAPID key -function urlBase64ToUint8Array(base64String: string): Uint8Array { - if (!base64String) return new Uint8Array(); - - const padding = "=".repeat((4 - (base64String.length % 4)) % 4); - const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); - - const rawData = window.atob(base64); - const outputArray = new Uint8Array(rawData.length); - - for (let i = 0; i < rawData.length; ++i) { - outputArray[i] = rawData.charCodeAt(i); - } - - return outputArray; -} - -export default usePushNotifications; \ No newline at end of file diff --git a/frontend/src/hooks/useSwipeGesture.ts b/frontend/src/hooks/useSwipeGesture.ts deleted file mode 100644 index c0b63da..0000000 --- a/frontend/src/hooks/useSwipeGesture.ts +++ /dev/null @@ -1,137 +0,0 @@ -// filepath: frontend/src/hooks/useSwipeGesture.ts -"use client"; - -import { useCallback, useRef, useState } from "react"; - -type SwipeDirection = "left" | "right" | "up" | "down" | null; - -interface SwipeOptions { - threshold?: number; - onSwipeLeft?: () => void; - onSwipeRight?: () => void; - onSwipeUp?: () => void; - onSwipeDown?: () => void; - enabled?: boolean; -} - -export function useSwipeGesture({ - threshold = 50, - onSwipeLeft, - onSwipeRight, - onSwipeUp, - onSwipeDown, - enabled = true, -}: SwipeOptions) { - const touchStart = useRef<{ x: number; y: number } | null>(null); - const touchEnd = useRef<{ x: number; y: number } | null>(null); - const [direction, setDirection] = useState(null); - - const handleTouchStart = useCallback( - (e: React.TouchEvent | TouchEvent) => { - if (!enabled) return; - const touch = e instanceof TouchEvent ? e.touches[0] : e.touches[0]; - touchStart.current = { x: touch.clientX, y: touch.clientY }; - touchEnd.current = { x: touch.clientX, y: touch.clientY }; - }, - [enabled] - ); - - const handleTouchMove = useCallback( - (e: React.TouchEvent | TouchEvent) => { - if (!enabled || !touchStart.current) return; - const touch = e instanceof TouchEvent ? e.touches[0] : e.touches[0]; - touchEnd.current = { x: touch.clientX, y: touch.clientY }; - }, - [enabled] - ); - - const handleTouchEnd = useCallback(() => { - if (!enabled || !touchStart.current || !touchEnd.current) return; - - const deltaX = touchEnd.current.x - touchStart.current.x; - const deltaY = touchEnd.current.y - touchStart.current.y; - const absX = Math.abs(deltaX); - const absY = Math.abs(deltaY); - - // Determine if horizontal or vertical swipe - if (absX > absY) { - // Horizontal swipe - if (absX > threshold) { - if (deltaX > 0) { - setDirection("right"); - onSwipeRight?.(); - } else { - setDirection("left"); - onSwipeLeft?.(); - } - } - } else { - // Vertical swipe - if (absY > threshold) { - if (deltaY > 0) { - setDirection("down"); - onSwipeDown?.(); - } else { - setDirection("up"); - onSwipeUp?.(); - } - } - } - - // Reset after a short delay - setTimeout(() => setDirection(null), 300); - touchStart.current = null; - touchEnd.current = null; - }, [enabled, threshold, onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown]); - - return { - handleTouchStart, - handleTouchMove, - handleTouchEnd, - direction, - }; -} - -// Hook for detecting long press -export function useLongPress(callback: () => void, duration = 500) { - const timeoutRef = useRef(null); - const [isLongPress, setIsLongPress] = useState(false); - - const startLongPress = useCallback(() => { - timeoutRef.current = setTimeout(() => { - setIsLongPress(true); - callback(); - }, duration); - }, [callback, duration]); - - const cancelLongPress = useCallback(() => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - setIsLongPress(false); - }, []); - - return { - onTouchStart: startLongPress, - onTouchEnd: cancelLongPress, - onTouchMove: cancelLongPress, - isLongPress, - }; -} - -// Hook for detecting double tap -export function useDoubleTap(callback: () => void, delay = 300) { - const lastTapRef = useRef(0); - - const handleTap = useCallback(() => { - const now = Date.now(); - if (now - lastTapRef.current < delay) { - callback(); - lastTapRef.current = 0; - } else { - lastTapRef.current = now; - } - }, [callback, delay]); - - return handleTap; -} \ No newline at end of file diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index e3ea9ee..41668a3 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -12,49 +12,12 @@ const config = { theme: { container: { center: true, - padding: { - DEFAULT: "1rem", - sm: "1.5rem", - lg: "2rem", - xl: "3rem", - "2xl": "4rem", - }, + padding: "2rem", screens: { "2xl": "1400px", - // Mobile-first breakpoints - xs: "375px", - sm: "640px", - md: "768px", - lg: "1024px", - xl: "1280px", }, }, extend: { - // Mobile-first spacing - spacing: { - "safe-area-inset": "env(safe-area-inset-top)", - }, - // Mobile-specific border radius - borderRadius: { - lg: "var(--radius)", - md: "calc(var(--radius) - 2px)", - sm: "calc(var(--radius) - 4px)", - xl: "1rem", - "2xl": "1.5rem", - "3xl": "2rem", - }, - // Touch-friendly sizes - minHeight: { - touch: "44px", - "touch-lg": "48px", - "touch-xl": "56px", - }, - minWidth: { - touch: "44px", - "touch-lg": "48px", - "touch-xl": "56px", - }, - // Mobile-specific colors colors: { border: "hsl(var(--border))", input: "hsl(var(--input))", @@ -90,7 +53,11 @@ const config = { foreground: "hsl(var(--card-foreground))", }, }, - // Animations for mobile + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, keyframes: { "accordion-down": { from: { height: "0" }, @@ -100,37 +67,10 @@ const config = { from: { height: "var(--radix-accordion-content-height)" }, to: { height: "0" }, }, - "slide-in-up": { - from: { transform: "translateY(100%)" }, - to: { transform: "translateY(0)" }, - }, - "slide-in-down": { - from: { transform: "translateY(-100%)" }, - to: { transform: "translateY(0)" }, - }, - "fade-in": { - from: { opacity: "0" }, - to: { opacity: "1" }, - }, - "scale-in": { - from: { transform: "scale(0.95)", opacity: "0" }, - to: { transform: "scale(1)", opacity: "1" }, - }, - "bounce-in": { - "0%": { transform: "scale(0.3)", opacity: "0" }, - "50%": { transform: "scale(1.05)" }, - "70%": { transform: "scale(0.9)" }, - "100%": { transform: "scale(1)", opacity: "1" }, - }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", - "slide-in-up": "slide-in-up 0.3s ease-out", - "slide-in-down": "slide-in-down 0.3s ease-out", - "fade-in": "fade-in 0.2s ease-out", - "scale-in": "scale-in 0.2s ease-out", - "bounce-in": "bounce-in 0.5s ease-out", }, }, },