diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 290d9e7..36252be 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -7,7 +7,10 @@ import { ErrorBoundary } from "@/components/shared/error-boundary"; import { ToastProvider } from "@/components/shared/toast-provider"; import { AuthProvider } from "@/components/auth/auth-provider"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { ThemeProvider, themeInitScript } from "@/components/theme/theme-provider"; +import { + ThemeProvider, + themeInitScript, +} from "@/components/theme/theme-provider"; import { PageTransition } from "@/components/shared/page-transition"; import "./globals.css"; @@ -68,7 +71,13 @@ export default function RootLayout({ > Skip to content - + { + console.error("Application error:", error); + console.error("Component stack:", errorInfo.componentStack); + }} + > diff --git a/src/components/course/course-card.tsx b/src/components/course/course-card.tsx index 8df998d..ebf35b8 100644 --- a/src/components/course/course-card.tsx +++ b/src/components/course/course-card.tsx @@ -5,8 +5,18 @@ import Link from "next/link"; import { cn } from "@/lib/utils/cn"; import { Card, CardContent, CardFooter } from "@/components/ui/card"; import { Badge, difficultyVariant } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { ProgressBar } from "@/components/course/progress-bar"; -import { Clock, Users, Star, MoreVertical, Share, Bookmark } from "lucide-react"; +import { + Clock, + Users, + Star, + MoreVertical, + Share, + Bookmark, + PlayCircle, + Plus, +} from "lucide-react"; import { DropdownMenu, DropdownMenuContent, @@ -20,32 +30,83 @@ interface CourseCardProps { course: Course; enrolled?: boolean; progress?: number; + onEnroll?: (courseId: string) => void; + onContinue?: (courseId: string) => void; className?: string; } - export const CourseCard = memo(function CourseCard({ course, enrolled, progress, + onEnroll, + onContinue, className, }: CourseCardProps) { + const handleEnroll = (e: React.MouseEvent) => { + e.preventDefault(); + onEnroll?.(course.id); + }; + + const handleContinue = (e: React.MouseEvent) => { + e.preventDefault(); + onContinue?.(course.id); + }; + return ( - {/* Image placeholder */} -
+ {/* Image placeholder with enrolled badge */} +
- -
e.preventDefault()} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') e.preventDefault(); }}> + + {/* Enrolled badge */} + {enrolled && ( +
+ + ✓ Enrolled + +
+ )} + + {/* Action buttons on hover */} +
+ {enrolled ? ( + + ) : ( + + )} +
+ +
e.preventDefault()} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") e.preventDefault(); + }} + > - @@ -70,10 +131,12 @@ export const CourseCard = memo(function CourseCard({ {capitalize(course.difficulty)} - {capitalize(course.category)} + + {capitalize(course.category)} +
-

+

{course.title}

@@ -81,7 +144,7 @@ export const CourseCard = memo(function CourseCard({

- +
@@ -98,8 +161,17 @@ export const CourseCard = memo(function CourseCard({
+ {/* Progress bar with percentage label */} {enrolled && progress !== undefined && ( -
+
+
+ + Progress + + + {Math.round(progress)}% + +
)} diff --git a/src/components/shared/error-boundary.tsx b/src/components/shared/error-boundary.tsx index fbc0cc5..10e9f65 100644 --- a/src/components/shared/error-boundary.tsx +++ b/src/components/shared/error-boundary.tsx @@ -3,61 +3,267 @@ import React, { useEffect, useState } from "react"; import { usePathname } from "next/navigation"; import { Button } from "@/components/ui/button"; -import { AlertTriangle, RefreshCw } from "lucide-react"; +import { AlertTriangle, RefreshCw, ChevronDown } from "lucide-react"; import { useErrorStore } from "@/store/error-store"; +/** + * Custom error reporting service + * Can be replaced with external service (Sentry, LogRocket, etc.) + */ +interface ErrorReportService { + report: (error: Error, context: ErrorContext) => Promise; +} + +interface ErrorContext { + componentStack?: string | null; + timestamp: string; + isDevelopment: boolean; + url: string; +} + +const createDefaultErrorReporter = (): ErrorReportService => { + return { + report: async (error: Error, context: ErrorContext) => { + const payload = { + message: error.message, + stack: error.stack, + ...context, + }; + + // Log to console in development + if (context.isDevelopment) { + console.error("[Error Reporter]", payload); + } + + // Send to external service (placeholder) + try { + // await fetch('/api/errors', { method: 'POST', body: JSON.stringify(payload) }); + } catch (err) { + console.error("Failed to report error:", err); + } + }, + }; +}; + interface ErrorBoundaryProps { children: React.ReactNode; - fallback?: React.ReactNode; + /** Custom fallback UI to display on error */ + fallback?: React.ReactNode | ((error: Error) => React.ReactNode); + /** Custom error reporter */ + errorReporter?: ErrorReportService; + /** Enable developer error details toggle */ + showErrorDetails?: boolean; + /** Callback when error is caught */ + onError?: (error: Error, errorInfo: React.ErrorInfo) => void; } interface ErrorBoundaryState { hasError: boolean; error: Error | null; + errorInfo: React.ErrorInfo | null; + retryCount: number; + isRetrying: boolean; + showDetails: boolean; } class ErrorBoundaryInner extends React.Component< ErrorBoundaryProps, ErrorBoundaryState > { + private retryDelays: number[] = []; + private errorReporter: ErrorReportService; + private isDevelopment: boolean; + constructor(props: ErrorBoundaryProps) { super(props); - this.state = { hasError: false, error: null }; + this.state = { + hasError: false, + error: null, + errorInfo: null, + retryCount: 0, + isRetrying: false, + showDetails: false, + }; + this.errorReporter = props.errorReporter || createDefaultErrorReporter(); + this.isDevelopment = process.env.NODE_ENV === "development"; } - static getDerivedStateFromError(error: Error): ErrorBoundaryState { + static getDerivedStateFromError(error: Error): Partial { return { hasError: true, error }; } componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + // Update state with error details + this.setState({ errorInfo }); + + // Report error to service + this.reportError(error, errorInfo); + + // Call custom error callback + if (this.props.onError) { + this.props.onError(error, errorInfo); + } + + // Log to console console.error("ErrorBoundary caught:", error, errorInfo); } + private reportError = async (error: Error, errorInfo: React.ErrorInfo) => { + try { + await this.errorReporter.report(error, { + componentStack: errorInfo.componentStack, + timestamp: new Date().toISOString(), + isDevelopment: this.isDevelopment, + url: typeof window !== "undefined" ? window.location.href : "", + }); + } catch (err) { + console.error("Error reporting failed:", err); + } + }; + + private getExponentialBackoffDelay = (retryCount: number): number => { + // Exponential backoff: 1s, 2s, 4s, 8s, 16s (max 30s) + const baseDelay = 1000; + const maxDelay = 30000; + const delay = Math.min(baseDelay * Math.pow(2, retryCount), maxDelay); + // Add jitter (±10%) + const jitter = delay * 0.1 * (Math.random() * 2 - 1); + return delay + jitter; + }; + + private handleRetry = async () => { + const { retryCount } = this.state; + const delay = this.getExponentialBackoffDelay(retryCount); + + this.setState({ isRetrying: true }); + + try { + // Wait before retrying + await new Promise((resolve) => setTimeout(resolve, delay)); + + // Reset error state to retry rendering children + this.setState({ + hasError: false, + error: null, + errorInfo: null, + retryCount: retryCount + 1, + isRetrying: false, + }); + } catch (err) { + console.error("Retry failed:", err); + this.setState({ isRetrying: false }); + } + }; + + private toggleDetails = () => { + this.setState((state) => ({ showDetails: !state.showDetails })); + }; + + private renderErrorDetails = () => { + const { error, errorInfo, showDetails } = this.state; + + if (!showDetails || !this.props.showErrorDetails) return null; + + return ( +
+ + + Error Details (Developer) + +
+ {error && ( +
+

Message:

+

{error.message}

+
+ )} + {error?.stack && ( +
+

Stack:

+

+ {error.stack} +

+
+ )} + {errorInfo?.componentStack && ( +
+

Component Stack:

+

+ {errorInfo.componentStack} +

+
+ )} +
+
+ ); + }; + render() { - if (this.state.hasError) { + const { hasError, error, retryCount, isRetrying, showDetails } = this.state; + + if (hasError) { + // Use custom fallback if provided if (this.props.fallback) { + if (typeof this.props.fallback === "function") { + return (this.props.fallback as (error: Error) => React.ReactNode)( + error!, + ); + } return this.props.fallback; } + // Default error UI return ( -
+

Something went wrong

-

- {this.state.error?.message || "An unexpected error occurred."} +

+ {error?.message || "An unexpected error occurred."}

- + {retryCount > 0 && ( +

+ Retry attempt {retryCount} +

+ )} + + {/* Error Details Toggle */} + {this.props.showErrorDetails && this.isDevelopment && ( + + )} + + {/* Error Details Section */} + {this.renderErrorDetails()} + + {/* Actions */} +
+ + +
); } @@ -69,6 +275,7 @@ class ErrorBoundaryInner extends React.Component< function ApiErrorDisplay() { const { error, isTransient, clearError, retry } = useErrorStore(); const [isRetrying, setIsRetrying] = useState(false); + const [retryCount, setRetryCount] = useState(0); if (!error) return null; @@ -78,8 +285,10 @@ function ApiErrorDisplay() { try { await retry(); clearError(); + setRetryCount(0); } catch (err) { console.error("Retry failed:", err); + setRetryCount((prev) => prev + 1); } finally { setIsRetrying(false); } @@ -87,18 +296,28 @@ function ApiErrorDisplay() { return (
-
-
+
+
-

+

{error.status === 401 ? "Authentication Error" : "Request Failed"}

-

+

{error.message || "An unexpected error occurred."}

+ {retryCount > 0 && ( +

+ Retry attempt {retryCount} +

+ )}
- {isTransient && retry && ( @@ -107,7 +326,9 @@ function ApiErrorDisplay() { disabled={isRetrying} className="flex-1 gap-2" > - + {isRetrying ? "Retrying..." : "Retry"} )} diff --git a/src/lib/hooks/use-local-storage.ts b/src/lib/hooks/use-local-storage.ts new file mode 100644 index 0000000..56d7755 --- /dev/null +++ b/src/lib/hooks/use-local-storage.ts @@ -0,0 +1,78 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; + +/** + * Custom hook for syncing state with localStorage. + * + * - Handles SSR: returns initial value on server, syncs on client mount + * - JSON serialization: automatically serializes/deserializes values + * - Cross-tab sync: listens to storage events from other tabs + * - Cleanup: removes listener on unmount + * + * @param key - localStorage key + * @param initialValue - default value if key doesn't exist + * @returns [value, setValue] similar to useState + */ +export function useLocalStorage( + key: string, + initialValue: T +): [T, (value: T | ((val: T) => T)) => void] { + // State to store value + const [storedValue, setStoredValue] = useState(initialValue); + const [isMounted, setIsMounted] = useState(false); + + // Return wrapped setter to handle both direct values and functions + const setValue = useCallback( + (value: T | ((val: T) => T)) => { + try { + const valueToStore = value instanceof Function ? value(storedValue) : value; + setStoredValue(valueToStore); + + // Save to localStorage + if (typeof window !== "undefined") { + window.localStorage.setItem(key, JSON.stringify(valueToStore)); + } + } catch (error) { + console.error(`useLocalStorage error for key "${key}":`, error); + } + }, + [key, storedValue] + ); + + // Initialize from localStorage on mount + useEffect(() => { + try { + if (typeof window === "undefined") return; + + const item = window.localStorage.getItem(key); + if (item) { + setStoredValue(JSON.parse(item)); + } + } catch (error) { + console.error(`useLocalStorage hydration error for key "${key}":`, error); + } + + setIsMounted(true); + }, [key]); + + // Listen for storage changes from other tabs + useEffect(() => { + if (typeof window === "undefined" || !isMounted) return; + + const handleStorageChange = (e: StorageEvent) => { + if (e.key === key && e.newValue) { + try { + setStoredValue(JSON.parse(e.newValue)); + } catch (error) { + console.error(`useLocalStorage sync error for key "${key}":`, error); + } + } + }; + + window.addEventListener("storage", handleStorageChange); + return () => window.removeEventListener("storage", handleStorageChange); + }, [key, isMounted]); + + return [storedValue, setValue]; +}