diff --git a/src/components/common/ScrollToTop.tsx b/src/components/common/ScrollToTop.tsx new file mode 100644 index 0000000..0e23dee --- /dev/null +++ b/src/components/common/ScrollToTop.tsx @@ -0,0 +1,60 @@ +import React, { useState, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { ChevronUp } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface ScrollToTopProps { + threshold?: number; + className?: string; +} + +const ScrollToTop: React.FC = ({ + threshold = 400, + className, +}) => { + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + const toggleVisibility = () => { + if (window.scrollY > threshold) { + setIsVisible(true); + } else { + setIsVisible(false); + } + }; + + window.addEventListener('scroll', toggleVisibility); + return () => window.removeEventListener('scroll', toggleVisibility); + }, [threshold]); + + const scrollToTop = () => { + window.scrollTo({ + top: 0, + behavior: 'smooth', + }); + }; + + return ( + + {isVisible && ( + + + + )} + + ); +}; + +export default ScrollToTop; diff --git a/src/components/common/SectionErrorBoundary.tsx b/src/components/common/SectionErrorBoundary.tsx new file mode 100644 index 0000000..9749403 --- /dev/null +++ b/src/components/common/SectionErrorBoundary.tsx @@ -0,0 +1,77 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { AlertCircle, RefreshCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +interface Props { + children: ReactNode; + sectionName?: string; + minHeight?: string | number; + className?: string; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +class SectionErrorBoundary extends Component { + public state: State = { + hasError: false, + error: null, + }; + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error(`Uncaught error in section ${this.props.sectionName || 'Unknown'}:`, error, errorInfo); + } + + private handleRetry = () => { + this.setState({ hasError: false, error: null }); + }; + + public render() { + if (this.state.hasError) { + return ( +
+
+ +

+ Something went wrong in this section +

+

+ {this.props.sectionName + ? `We encountered an error while loading the ${this.props.sectionName}.` + : 'We encountered an error while loading this content.'} + Please try again or contact support if the issue persists. +

+
+ +
+ ); + } + + return this.props.children; + } +} + +export default SectionErrorBoundary; diff --git a/src/components/common/__tests__/ScrollToTop.test.tsx b/src/components/common/__tests__/ScrollToTop.test.tsx new file mode 100644 index 0000000..6366ee5 --- /dev/null +++ b/src/components/common/__tests__/ScrollToTop.test.tsx @@ -0,0 +1,60 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitForElementToBeRemoved } from '@testing-library/react'; +import ScrollToTop from '@/components/common/ScrollToTop'; + +describe('ScrollToTop', () => { + beforeEach(() => { + vi.stubGlobal('scrollTo', vi.fn()); + // Set initial scroll position + window.scrollY = 0; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('is initially hidden', () => { + render(); + expect(screen.queryByLabelText('Scroll to top')).not.toBeInTheDocument(); + }); + + it('appears after scrolling past the threshold', () => { + render(); + + // Simulate scroll + window.scrollY = 150; + fireEvent.scroll(window); + + expect(screen.getByLabelText('Scroll to top')).toBeInTheDocument(); + }); + + it('hides when scrolling back above the threshold', async () => { + render(); + + // Scroll down + window.scrollY = 150; + fireEvent.scroll(window); + expect(screen.getByLabelText('Scroll to top')).toBeInTheDocument(); + + // Scroll up + window.scrollY = 50; + fireEvent.scroll(window); + + await waitForElementToBeRemoved(() => screen.queryByLabelText('Scroll to top')); + }); + + it('triggers window.scrollTo when clicked', () => { + render(); + + window.scrollY = 150; + fireEvent.scroll(window); + + const button = screen.getByLabelText('Scroll to top'); + fireEvent.click(button); + + expect(window.scrollTo).toHaveBeenCalledWith({ + top: 0, + behavior: 'smooth', + }); + }); +}); diff --git a/src/components/common/__tests__/SectionErrorBoundary.test.tsx b/src/components/common/__tests__/SectionErrorBoundary.test.tsx new file mode 100644 index 0000000..9e024e3 --- /dev/null +++ b/src/components/common/__tests__/SectionErrorBoundary.test.tsx @@ -0,0 +1,81 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import SectionErrorBoundary from '@/components/common/SectionErrorBoundary'; + +// Mock component that throws an error +const BuggyComponent = ({ shouldThrow = false }: { shouldThrow?: boolean }) => { + if (shouldThrow) { + throw new Error('Test error'); + } + return
Normal Content
; +}; + +describe('SectionErrorBoundary', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders children when no error occurs', () => { + render( + + + + ); + expect(screen.getByText('Normal Content')).toBeInTheDocument(); + }); + + it('renders fallback UI when an error occurs', () => { + // Suppress console.error for the expected error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + render( + + + + ); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText(/something went wrong/i)).toBeInTheDocument(); + expect(screen.getByText(/Test Section/i)).toBeInTheDocument(); + expect(consoleSpy).toHaveBeenCalled(); + }); + + it('resets error state when Retry button is clicked', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { rerender } = render( + + + + ); + + expect(screen.getByText(/something went wrong/i)).toBeInTheDocument(); + + // Update children to not throw anymore + rerender( + + + + ); + + // Click retry + fireEvent.click(screen.getByText(/retry/i)); + + expect(screen.queryByText(/something went wrong/i)).not.toBeInTheDocument(); + expect(screen.getByText('Normal Content')).toBeInTheDocument(); + }); + + it('applies custom minHeight and className', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + + render( + + + + ); + + const alert = screen.getByRole('alert'); + expect(alert).toHaveStyle('min-height: 500px'); + expect(alert).toHaveClass('custom-class'); + }); +}); diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 9a55cb0..af03e97 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -27,6 +27,8 @@ import { useNetworkMismatch } from '@/hooks/useNetworkMismatch'; import showToast from '@/utils/toast.util'; import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils'; import PrecisionModeToggle, { type PrecisionMode } from '@/components/common/PrecisionModeToggle'; +import ScrollToTop from '@/components/common/ScrollToTop'; +import SectionErrorBoundary from '@/components/common/SectionErrorBoundary'; const FEATURED_CREATOR_FACTS = [ { label: 'Membership', value: 'Collectors Circle' }, @@ -442,96 +444,98 @@ function LandingPage() { - - + + + - {isLoading ? ( - - ) : filteredCreators.length > 0 ? ( -
- {showRetryBanner && ( - setFetchRetryAttempt(0)} - /> - )} - {finalFetchError && ( -
- {finalFetchError} + {isLoading ? ( + + ) : filteredCreators.length > 0 ? ( +
+ {showRetryBanner && ( + setFetchRetryAttempt(0)} + /> + )} + {finalFetchError && ( +
+ {finalFetchError} +
+ )} +
+ {pagedCreators.map(creator => ( + + ))}
- )} -
- {pagedCreators.map(creator => ( - - ))} -
-
- - - Page {safePage + 1} of {totalPages} - - +
+ + + Page {safePage + 1} of {totalPages} + + +
+ {safePage >= totalPages - 1 && ( +

+ {`You've reached the end — ${formatNumber(filteredCreators.length)} creator${filteredCreators.length === 1 ? '' : 's'} shown.`} +

+ )}
- {safePage >= totalPages - 1 && ( -

- {`You've reached the end — ${formatNumber(filteredCreators.length)} creator${filteredCreators.length === 1 ? '' : 's'} shown.`} -

- )} -
- ) : ( -
- - {!hasInvalidSearchInput && ( - + - )} -
- )} - + {!hasInvalidSearchInput && ( + + )} +
+ )} + + @@ -541,121 +545,125 @@ function LandingPage() { parentHref="/" currentLabel="Alex Rivers Portfolio" /> - + + +
- -
- - - - Use the same subtitle pattern beneath headings, then drop - repeated creator facts into one responsive grid that stays - tidy on mobile and desktop. - -
-
- - - + + +
+ + + + Use the same subtitle pattern beneath headings, then drop + repeated creator facts into one responsive grid that stays + tidy on mobile and desktop. + +
+
+ + + +
-
-
- -
- - Metrics display - - + +
+ + Metrics display + + +
+ + {isNetworkMismatch && } +
+ + +
- - {isNetworkMismatch && } -
- - -
-
- + +
@@ -715,6 +723,7 @@ function LandingPage() { title="Confirming trade" description="Waiting for Stellar confirmation, then refreshing holdings." /> + ); }