feat(ui): error boundary with premium fallback + scroll-to-top button
ErrorBoundary: - Class-based React error boundary with animated destructive icon - Collapsible technical details, Try again + Go home actions - Supports custom fallback and onReset callback - Replaces old ErrorBoundary with premium version ScrollToTop: - Floating button appears after 400px scroll - framer-motion entry/exit animation - Responsive positioning (above mobile nav on small screens) Layout: - Auto scroll-to-top on route change - ScrollToTop button integrated Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
74434239d0
commit
0391fa4817
4 changed files with 126 additions and 2 deletions
|
|
@ -6,7 +6,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
|||
import { useAuthStore } from '@/features/auth/store/authStore';
|
||||
|
||||
import { useUIStore } from '@/stores/ui';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
import { ToastProvider } from '@/components/feedback/ToastProvider';
|
||||
import { AstralBackground } from '@/components/ui/AstralBackground';
|
||||
import { OfflineIndicator } from '@/components/OfflineIndicator';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { ReactNode } from 'react';
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Header } from './Header';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { MobileBottomNav } from './MobileBottomNav';
|
||||
|
|
@ -8,6 +9,7 @@ import { useUIStore } from '@/stores/ui';
|
|||
import { cn } from '@/lib/utils';
|
||||
import { AstralBackground } from '../ui/AstralBackground';
|
||||
import { NavigationProgress } from '../ui/NavigationProgress';
|
||||
import { ScrollToTop } from '../ui/ScrollToTop';
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode;
|
||||
|
|
@ -15,6 +17,12 @@ interface LayoutProps {
|
|||
|
||||
export function Layout({ children }: LayoutProps) {
|
||||
const { sidebarOpen } = useUIStore();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
// Scroll to top on route change
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground relative overflow-x-hidden">
|
||||
|
|
@ -43,6 +51,7 @@ export function Layout({ children }: LayoutProps) {
|
|||
</div>
|
||||
|
||||
<MobileBottomNav />
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
77
apps/web/src/components/ui/ErrorBoundary.tsx
Normal file
77
apps/web/src/components/ui/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import React from 'react';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
this.props.onReset?.();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) return this.props.fallback;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-layout-page p-6 text-center animate-fade-in">
|
||||
{/* Animated icon */}
|
||||
<div className="relative mb-6">
|
||||
<div className="absolute inset-0 bg-destructive/20 rounded-full blur-2xl animate-pulse" />
|
||||
<div className="relative bg-destructive/10 rounded-full p-6">
|
||||
<AlertTriangle className="h-12 w-12 text-destructive" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-heading-2 mb-2">Something went wrong</h2>
|
||||
<p className="text-muted-foreground max-w-md mb-6">
|
||||
An unexpected error occurred. This has been logged and we'll look into it.
|
||||
</p>
|
||||
|
||||
{/* Error details (collapsible) */}
|
||||
{this.state.error && (
|
||||
<details className="mb-6 w-full max-w-md text-left">
|
||||
<summary className="text-caption cursor-pointer hover:text-foreground transition-colors">
|
||||
Technical details
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 rounded-lg bg-muted text-xs text-muted-foreground overflow-auto max-h-32">
|
||||
{this.state.error.message}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={this.handleReset} className="gap-2">
|
||||
<RefreshCw className="h-4 w-4" /> Try again
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => window.location.href = '/'} className="gap-2">
|
||||
<Home className="h-4 w-4" /> Go home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
38
apps/web/src/components/ui/ScrollToTop.tsx
Normal file
38
apps/web/src/components/ui/ScrollToTop.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ArrowUp } from 'lucide-react';
|
||||
|
||||
export function ScrollToTop() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setVisible(window.scrollY > 400);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, scale: 0.8, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.8, y: 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={scrollToTop}
|
||||
className="fixed bottom-24 right-6 z-40 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:shadow-xl hover:scale-105 active:scale-95 transition-all lg:bottom-8"
|
||||
aria-label="Scroll to top"
|
||||
>
|
||||
<ArrowUp className="h-5 w-5" />
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue