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
11 changes: 10 additions & 1 deletion src/app/search/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Metadata } from 'next';
import { Suspense } from 'react';
import { AdvancedSearchInterface } from '@/components/search/AdvancedSearchInterface';

export const metadata: Metadata = {
Expand All @@ -21,7 +22,15 @@ export const metadata: Metadata = {
export default function SearchPage() {
return (
<main className="min-h-screen bg-slate-50/50">
<AdvancedSearchInterface />
<Suspense
fallback={
<div className="max-w-6xl mx-auto px-4 py-12 text-center text-slate-400">
Loading search...
</div>
}
>
<AdvancedSearchInterface />
</Suspense>
</main>
);
}
14 changes: 10 additions & 4 deletions src/components/search/SearchResultsVisualizer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,19 @@ export const SearchResultsVisualizer = React.memo<SearchResultsVisualizerProps>(

if (results.length === 0) {
return (
<div className="text-center py-20 bg-slate-50 rounded-3xl border border-slate-100">
<div
role="status"
aria-live="polite"
data-testid="search-empty-state"
className="text-center py-20 bg-slate-50 rounded-3xl border border-slate-100"
>
<div className="w-20 h-20 bg-white shadow-xl rounded-full flex items-center justify-center mx-auto mb-6 text-slate-300">
<Eye className="w-10 h-10" />
<Eye className="w-10 h-10" aria-hidden="true" />
</div>
<h3 className="text-xl font-bold font-sans text-slate-700 mb-2">No results found</h3>
<p className="text-slate-400 max-w-xs mx-auto text-sm">
Try expanding your search parameters or checking for typos.
<p className="text-slate-500 max-w-sm mx-auto text-sm">
We couldn&apos;t find any matches for your search. Try adjusting your query keywords or
filters to find what you&apos;re looking for.
</p>
</div>
);
Expand Down
100 changes: 100 additions & 0 deletions src/components/search/__tests__/SearchResultsVisualizer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { SearchResultsVisualizer } from '../SearchResultsVisualizer';
import type { SearchResult } from '../../utils/searchUtils';

const mockResults: SearchResult[] = [
{
id: 'res-1',
type: 'course',
title: 'Starknet Cairo Essentials',
description: 'Learn Cairo and Starknet development from scratch.',
createdAt: '2024-05-10T12:00:00.000Z',
relevanceScore: 0.95,
author: 'Alice',
rating: 4.8,
price: 0,
topic: 'Cairo',
difficulty: 'beginner',
reputation: 99,
},
];

describe('SearchResultsVisualizer', () => {
it('renders a distinct, accessible empty state when there are zero results and not searching', () => {
const onSortChange = vi.fn();
render(
<SearchResultsVisualizer
results={[]}
isSearching={false}
sortBy="relevance"
onSortChange={onSortChange}
/>,
);

const emptyState = screen.getByTestId('search-empty-state');
expect(emptyState).toBeInTheDocument();
expect(emptyState).toHaveAttribute('role', 'status');
expect(emptyState).toHaveAttribute('aria-live', 'polite');

expect(screen.getByRole('heading', { level: 3, name: /no results found/i })).toBeInTheDocument();
expect(
screen.getByText(/we couldn't find any matches for your search/i),
).toBeInTheDocument();
expect(
screen.getByText(/try adjusting your query keywords or filters/i),
).toBeInTheDocument();
});

it('renders loading skeleton when isSearching is true', () => {
const onSortChange = vi.fn();
const { container } = render(
<SearchResultsVisualizer
results={[]}
isSearching={true}
sortBy="relevance"
onSortChange={onSortChange}
/>,
);

expect(screen.queryByTestId('search-empty-state')).not.toBeInTheDocument();
expect(container.querySelectorAll('.animate-pulse').length).toBe(3);
});

it('renders search results when results are provided', () => {
const onSortChange = vi.fn();
render(
<SearchResultsVisualizer
results={mockResults}
isSearching={false}
sortBy="relevance"
onSortChange={onSortChange}
/>,
);

expect(screen.queryByTestId('search-empty-state')).not.toBeInTheDocument();
expect(screen.getByText('1 results')).toBeInTheDocument();
expect(screen.getByText('Starknet Cairo Essentials')).toBeInTheDocument();
expect(screen.getByText('Learn Cairo and Starknet development from scratch.')).toBeInTheDocument();
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('FREE')).toBeInTheDocument();
});

it('calls onSortChange when user changes the sort option', () => {
const onSortChange = vi.fn();
render(
<SearchResultsVisualizer
results={mockResults}
isSearching={false}
sortBy="relevance"
onSortChange={onSortChange}
/>,
);

const sortSelect = screen.getByRole('combobox');
fireEvent.change(sortSelect, { target: { value: 'newest' } });

expect(onSortChange).toHaveBeenCalledWith('newest');
});
});
9 changes: 7 additions & 2 deletions src/hooks/useSearchFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import { debounce } from '../utils/formUtils';

export interface FilterState {
difficulty: string[];
Expand Down Expand Up @@ -76,7 +77,7 @@ export const useSearchFilters = () => {
}, [router, pathname, searchParams]);

useEffect(() => {
const timer = setTimeout(() => {
const debouncedSync = debounce(() => {
const params = new URLSearchParams();

if (filters.difficulty && filters.difficulty.length > 0) {
Expand Down Expand Up @@ -123,7 +124,11 @@ export const useSearchFilters = () => {
pRouter.replace(newUrl, { scroll: false });
}, 300);

return () => clearTimeout(timer);
debouncedSync();

return () => {
debouncedSync.cancel();
};
}, [filters]);

const setFilters = useCallback((newFilters: Partial<FilterState>) => {
Expand Down
10 changes: 9 additions & 1 deletion src/utils/accessibilityUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,15 @@ function getLuminance(r: number, g: number, b: number): number {
* Parse hex color to RGB
*/
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
const cleanHex = hex.trim().replace(/^#/, '');
if (cleanHex.length === 3) {
const r = parseInt(cleanHex[0] + cleanHex[0], 16);
const g = parseInt(cleanHex[1] + cleanHex[1], 16);
const b = parseInt(cleanHex[2] + cleanHex[2], 16);
if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
return { r, g, b };
}
const result = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(cleanHex);
return result
? {
r: parseInt(result[1], 16),
Expand Down
18 changes: 16 additions & 2 deletions src/utils/formUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,16 +350,21 @@ export function formDataToObject(formData: FormData): Record<string, any> {
return obj;
}

export interface DebouncedFunction<T extends (...args: any[]) => any> {
(...args: Parameters<T>): void;
cancel: () => void;
}

/**
* Debounce function for form operations
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number,
): (...args: Parameters<T>) => void {
): DebouncedFunction<T> {
let timeout: ReturnType<typeof setTimeout> | null = null;

return function executedFunction(...args: Parameters<T>) {
const executedFunction = function (...args: Parameters<T>) {
const later = () => {
timeout = null;
func(...args);
Expand All @@ -370,6 +375,15 @@ export function debounce<T extends (...args: any[]) => any>(
}
timeout = setTimeout(later, wait);
};

executedFunction.cancel = () => {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
};

return executedFunction;
}

/**
Expand Down
47 changes: 36 additions & 11 deletions src/utils/notificationUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,14 @@ export function generateNotificationId(): string {
*/
export function formatNotificationTime(timestamp: string): string {
const date = new Date(timestamp);
if (isNaN(date.getTime())) {
return '';
}
const now = new Date();
const diffMs = now.getTime() - date.getTime();
if (diffMs < 0) {
return 'Just now';
}
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
Expand All @@ -99,13 +105,25 @@ export function isWithinQuietHours(quietHours: {
end: string;
timezone: string;
}): boolean {
if (!quietHours?.start || !quietHours?.end || !quietHours?.timezone) {
return false;
}
const now = new Date();
const currentTime = now.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
timeZone: quietHours.timezone,
});
let currentTime: string;
try {
currentTime = now.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
timeZone: quietHours.timezone,
});
} catch {
currentTime = now.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
});
}

const start = quietHours.start;
const end = quietHours.end;
Expand All @@ -126,6 +144,10 @@ export function shouldSendNotification(
channel: NotificationChannel,
preferences: UserNotificationPreferences,
): boolean {
if (!preferences?.channels || !preferences?.categories) {
return false;
}

// Check if channel is enabled globally
if (!preferences.channels[channel === 'in-app' ? 'inApp' : channel]) {
return false;
Expand All @@ -138,19 +160,19 @@ export function shouldSendNotification(
}

// Check if channel is enabled for this category
if (!categoryPrefs.channels.includes(channel)) {
if (!categoryPrefs.channels?.includes(channel)) {
return false;
}

// Check quiet hours
if (preferences.quietHours.enabled && channel !== 'in-app') {
if (preferences.quietHours?.enabled && channel !== 'in-app') {
if (isWithinQuietHours(preferences.quietHours)) {
return false;
}
}

// Check category-specific quiet hours
if (categoryPrefs.quietHours) {
if (categoryPrefs.quietHours?.start && categoryPrefs.quietHours?.end) {
const now = new Date();
const currentTime = now.toLocaleTimeString('en-US', {
hour12: false,
Expand All @@ -159,8 +181,11 @@ export function shouldSendNotification(
});

if (
currentTime >= categoryPrefs.quietHours.start &&
currentTime <= categoryPrefs.quietHours.end
categoryPrefs.quietHours.start > categoryPrefs.quietHours.end
? currentTime >= categoryPrefs.quietHours.start ||
currentTime <= categoryPrefs.quietHours.end
: currentTime >= categoryPrefs.quietHours.start &&
currentTime <= categoryPrefs.quietHours.end
) {
return false;
}
Expand Down
Loading
Loading