Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/components/common/ScrollToTop.tsx
Original file line number Diff line number Diff line change
@@ -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<ScrollToTopProps> = ({
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 (
<AnimatePresence>
{isVisible && (
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={scrollToTop}
className={cn(
'fixed bottom-8 right-8 z-50 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-colors hover:bg-primary/90 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring md:h-12 md:w-12',
className
)}
aria-label="Scroll to top"
>
<ChevronUp className="h-6 w-6" />
</motion.button>
)}
</AnimatePresence>
);
};

export default ScrollToTop;
77 changes: 77 additions & 0 deletions src/components/common/SectionErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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<Props, State> {
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 (
<div
className={cn(
'flex w-full flex-col items-center justify-center gap-4 rounded-xl border border-dashed border-border p-8 text-center',
this.props.className
)}
style={{ minHeight: this.props.minHeight }}
role="alert"
aria-live="assertive"
>
<div className="flex flex-col items-center gap-2">
<AlertCircle className="h-10 w-10 text-destructive" />
<h3 className="text-lg font-semibold">
Something went wrong in this section
</h3>
<p className="max-w-md text-sm text-muted-foreground">
{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.
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={this.handleRetry}
className="gap-2"
>
<RefreshCw className="h-4 w-4" />
Retry
</Button>
</div>
);
}

return this.props.children;
}
}

export default SectionErrorBoundary;
60 changes: 60 additions & 0 deletions src/components/common/__tests__/ScrollToTop.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ScrollToTop threshold={100} />);
expect(screen.queryByLabelText('Scroll to top')).not.toBeInTheDocument();
});

it('appears after scrolling past the threshold', () => {
render(<ScrollToTop threshold={100} />);

// 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(<ScrollToTop threshold={100} />);

// 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(<ScrollToTop threshold={100} />);

window.scrollY = 150;
fireEvent.scroll(window);

const button = screen.getByLabelText('Scroll to top');
fireEvent.click(button);

expect(window.scrollTo).toHaveBeenCalledWith({
top: 0,
behavior: 'smooth',
});
});
});
81 changes: 81 additions & 0 deletions src/components/common/__tests__/SectionErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <div>Normal Content</div>;
};

describe('SectionErrorBoundary', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('renders children when no error occurs', () => {
render(
<SectionErrorBoundary>
<BuggyComponent />
</SectionErrorBoundary>
);
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(
<SectionErrorBoundary sectionName="Test Section">
<BuggyComponent shouldThrow={true} />
</SectionErrorBoundary>
);

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(
<SectionErrorBoundary>
<BuggyComponent shouldThrow={true} />
</SectionErrorBoundary>
);

expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();

// Update children to not throw anymore
rerender(
<SectionErrorBoundary>
<BuggyComponent shouldThrow={false} />
</SectionErrorBoundary>
);

// 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(
<SectionErrorBoundary minHeight={500} className="custom-class">
<BuggyComponent shouldThrow={true} />
</SectionErrorBoundary>
);

const alert = screen.getByRole('alert');
expect(alert).toHaveStyle('min-height: 500px');
expect(alert).toHaveClass('custom-class');
});
});
Loading
Loading