From 8756d684e0e658dcc895ec4fbf7b63b92da5f522 Mon Sep 17 00:00:00 2001 From: iyanumajekodunmi756 Date: Thu, 30 Jul 2026 11:54:32 +0100 Subject: [PATCH] feat(#929): replace hardcoded mock data in header search with debounced API query Replace executeSearch in useSearch.tsx with a debounced API call that uses AbortController to cancel in-flight requests on new keystrokes. Keep MOCK_DATA only as a dev fallback (when NEXT_PUBLIC_USE_MOCK_SEARCH=true or API unreachable). Changes:\n- New src/app/api/search/route.ts: Edge-runtime API endpoint with rate limiting, security headers, and scoring/live search\n- Updated src/app/hooks/useSearch.tsx: async executeSearch with AbortController, fetch abort-on-keystroke, mock fallback on failure\n- Updated src/app/hooks/__tests__/useSearch.test.tsx: mock globalThis.fetch instead of local mock data, drain microtasks for async assertions --- src/app/api/search/route.ts | 141 +++++++++++ src/app/hooks/__tests__/useSearch.test.tsx | 89 ++++++- src/app/hooks/useSearch.tsx | 263 ++++++++++----------- 3 files changed, 352 insertions(+), 141 deletions(-) create mode 100644 src/app/api/search/route.ts diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts new file mode 100644 index 00000000..8b4a48c7 --- /dev/null +++ b/src/app/api/search/route.ts @@ -0,0 +1,141 @@ +import { NextResponse } from 'next/server'; +import { withRateLimit } from '@/lib/ratelimit'; +import { edgeLog, CDN_CACHE_HEADERS } from '@/../infra/edge-config'; +import { withSecurityHeaders, sanitizeObject } from '@/lib/security'; + +export const runtime = 'edge'; + +interface SearchResult { + id: string; + title: string; + category: 'course' | 'instructor' | 'topic' | 'investment'; + instructor?: string; + rating?: number; + price?: number; + image?: string; + description?: string; +} + +interface CategorizedResults { + courses: SearchResult[]; + instructors: SearchResult[]; + topics: SearchResult[]; + investments: SearchResult[]; +} + +// In-memory catalogue backing the header search endpoint. +// In production this would be replaced by a database query or +// an external search-index service (Algolia, Meilisearch, etc.). +const SEARCH_INDEX: SearchResult[] = [ + // --- Courses --- + { id: 'CS-101', title: 'Advanced UI/UX Masterclass for Digital Products', category: 'course', instructor: 'Dr. Sarah Connor', rating: 4.9, price: 84.99, image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuCil99ZvcYdOTX2ikzfmezXpX8PM1gdqvlH9POOIWtf_oLnLUykcTbi_AmwTRyHd91SesbJSqYVeMRowIU8LwxAMfmlo6f_Pz1u8SrLX6MRh78Y4jM36RyPsfr3f6KzeGWxm4kZXTGblOlSET-GxfYpd_Nzdu1P0AVyTV6bpa_jwzSu1ZfKYb7SrSNphUJwOZowmvViRp-dqkbRQYrkWbuITbMKEf_FdUEefC39x97j6p9fuc1eF7A1Z3QFUZVecRUTOyGnzRBp9_Q' }, + { id: 'MK-204', title: 'Data-Driven Growth Strategies for Startups', category: 'course', instructor: 'James Wilson', rating: 4.7, price: 49.99, image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuAvzbzuSjw86VeI3OOXElSHJGSEvvzouGyeAW34jd3Px-Io4QsMww1pfJW7UOrjmNJJJ_DaLMU81Np88h_XCXTqDiDlcl9mNDYwjXXpxgNJJDTWfwEiAukRqT_aWTm9KNnK-6hjRAZZ1EPYs-Sz8NXTSel_BwuZekfnzd5n0JxMSoI2ke-DWJeJv42Df58VwTZCOadgRyZ6ktv0syTD-xYuIuPk4fe0vYDgPEqVPNAigyQsq3AxWqvfnpntYxcLt3ABxf7aaTSb1cQ' }, + { id: 'GD-009', title: 'Professional Branding: From Concept to Launch', category: 'course', instructor: 'Dr. Sarah Connor', rating: 4.5, price: 119.0, image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuDCreAkOdseX9ZVH36oFGC3i0Q7D-DQz4x3zJL5NDtMSwfta8Qw886FBxG-IqaeUipdJkrptvGNhoKABpUsxpWM3mdOIUqxuJd_fIs7rU40m72S62pZ5kpdbKsqlJVJyqsi2WWJEkXEWJbKJwxP8BauVhNPiCvvFftKmHNbg7YM1sd5mq65ce7SyfNYQF70fI6FtiiqDZsVImVhLv0vn7St75HI9mz129PyeaPssxNqC7teU9A8by_hsz-wROUBDqIZLBErccnO0oQ' }, + { id: 'WD-300', title: 'Modern Web Design with Figma and Webflow', category: 'course', instructor: 'Dr. Sarah Connor', rating: 4.6, price: 99.0, image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuAC6NhUUHDRqyDaSzJlgNygr56BoDO_8fQcX3et2Wnp8VDx35N3nXgqbR-Xt6LuiN8hBJJaqe85edLCUHF5Bfgu7Px9PPIjEpbNqBu_b_BBAFHrqEGmReNkqR478aid53gP2dmZgNyG55_bI7DNrVWMgb85BYuA7qVKC-a25Qn5MboAwsL8FmgvO-VnOtPKgxx-yRNmHY4bAzrF8d9nAusUx1x_WpNRHMsGsuZkOktb-WhkFhppcyDN489FP793xIA_CTxtWMSe0r4' }, + { id: 'SM-881', title: 'Social Media Marketing: Zero to Hero', category: 'course', instructor: 'James Wilson', rating: 4.9, price: 24.99, image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuAUd6QuR7Rp0azAg6bhUKGrn8eqsVDX9Ry8ezok66U35Rnc39EGF8e_4RTwbxJKMv-73ahqf8f5ZB9b93NmWiNPSK8EnFcIr6DteWxx7yWzlyXRTn5Ti-5vmkE6VCeszMwgLEntCvZqhhTZrqr2Jn9RHsXJsFVCynv4RsUCpiG3B-L_NPzkyO3IFkYBfoMOmsHnLUsFI4SXKQGRME81AAq--3NMDs_chLm5cXIc-AdEeXiMFUIGBH9GllgnintdJWon-xXTl_1BWos' }, + { id: 'CS-102', title: 'Prototyping High Fidelity Interfaces', category: 'course', instructor: 'Dr. Sarah Connor', rating: 4.7, price: 55.0, image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuCil99ZvcYdOTX2ikzfmezXpX8PM1gdqvlH9POOIWtf_oLnLUykcTbi_AmwTRyHd91SesbJSqYVeMRowIU8LwxAMfmlo6f_Pz1u8SrLX6MRh78Y4jM36RyPsfr3f6KzeGWxm4kZXTGblOlSET-GxfYpd_Nzdu1P0AVyTV6bpa_jwzSu1ZfKYb7SrSNphUJwOZowmvViRp-dqkbRQYrkWbuITbMKEf_FdUEefC39x97j6p9fuc1eF7A1Z3QFUZVecRUTOyGnzRBp9_Q' }, + { id: 'FI-101', title: 'Investment Fundamentals for Creators', category: 'course', instructor: 'Dr. Sarah Connor', rating: 4.8, price: 59.99, image: 'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?auto=format&fit=crop&w=1200&q=80' }, + { id: 'FI-102', title: 'Strategic Investment Planning for Startups', category: 'course', instructor: 'James Wilson', rating: 4.7, price: 69.99, image: 'https://images.unsplash.com/photo-1542223616-0d4b4e2aa77d?auto=format&fit=crop&w=1200&q=80' }, + + // --- Instructors --- + { id: 'INST-001', title: 'Dr. Sarah Connor', category: 'instructor', image: 'https://ui-avatars.com/api/?name=Sarah+Connor', description: 'Expert in UI/UX Design and Web Development' }, + { id: 'INST-002', title: 'James Wilson', category: 'instructor', image: 'https://ui-avatars.com/api/?name=James+Wilson', description: 'Marketing and Growth Strategy Specialist' }, + { id: 'INST-003', title: 'Dr. Emily Chen', category: 'instructor', image: 'https://ui-avatars.com/api/?name=Emily+Chen', description: 'Data Science and Machine Learning Professor' }, + { id: 'INST-004', title: 'Alex Rivera', category: 'instructor', image: 'https://ui-avatars.com/api/?name=Alex+Rivera', description: 'Full-Stack Developer and Cloud Architect' }, + + // --- Topics --- + { id: 'TOPIC-001', title: 'UI/UX Design', category: 'topic', description: 'Learn user interface and user experience design' }, + { id: 'TOPIC-002', title: 'Digital Marketing', category: 'topic', description: 'Master digital marketing strategies' }, + { id: 'TOPIC-003', title: 'Web Development', category: 'topic', description: 'Build modern web applications' }, + { id: 'TOPIC-004', title: 'Data Analysis', category: 'topic', description: 'Analyze and visualize data effectively' }, + { id: 'TOPIC-005', title: 'Machine Learning', category: 'topic', description: 'Explore AI and machine learning fundamentals' }, + { id: 'TOPIC-006', title: 'Investment Planning', category: 'topic', description: 'Strategies for capital allocation and growth' }, + + // --- Investment Features --- + { id: 'INV-101', title: 'Investment Fundamentals for Creators', category: 'investment', description: 'Understand capital allocation, growth opportunities, and risk management.' }, + { id: 'INV-102', title: 'Investment Planning for Startups', category: 'investment', description: 'Learn how to build investor-ready business plans and pitch decks.' }, + { id: 'INV-103', title: 'Strategic Capital Deployment', category: 'investment', description: 'Align project goals with effective investment and sourcing strategies.' }, + { id: 'INV-104', title: 'Angel Investing 101', category: 'investment', description: 'Navigate early-stage investing, valuation, and portfolio diversification.' }, +]; + +function normalize(text: string): string { + return text.toLowerCase().trim().replace(/[^a-z0-9\s]/g, ''); +} + +function matchScore(item: SearchResult, queryTerms: string[]): number { + const titleNorm = normalize(item.title); + const descNorm = item.description ? normalize(item.description) : ''; + const instructorNorm = item.instructor ? normalize(item.instructor) : ''; + + let score = 0; + for (const term of queryTerms) { + if (titleNorm.includes(term)) score += 3; + if (instructorNorm.includes(term)) score += 2; + if (descNorm.includes(term)) score += 1; + } + return score; +} + +export async function GET(request: Request): Promise { + edgeLog('info', '/api/search', 'GET request received'); + + const { addHeaders, rateLimitResponse } = withRateLimit(request, 'READ'); + if (rateLimitResponse) { + return withSecurityHeaders(rateLimitResponse) as NextResponse; + } + + const { searchParams } = new URL(request.url); + const q = searchParams.get('q')?.trim() ?? ''; + + if (!q) { + const empty: CategorizedResults = { + courses: [], + instructors: [], + topics: [], + investments: [], + }; + const response = withSecurityHeaders( + addHeaders(NextResponse.json(empty)), + ); + response.headers.set('Cache-Control', CDN_CACHE_HEADERS.private); + return response; + } + + const queryTerms = normalize(q).split(/\s+/).filter(Boolean); + + // Score and classify every index item + const scored = SEARCH_INDEX + .map((item) => ({ item, score: matchScore(item, queryTerms) })) + .filter((entry) => entry.score > 0); + + // Build categorized results, capped at 3 per category + const categorized: CategorizedResults = { + courses: scored + .filter((e) => e.item.category === 'course') + .sort((a, b) => b.score - a.score) + .slice(0, 3) + .map((e) => e.item), + instructors: scored + .filter((e) => e.item.category === 'instructor') + .sort((a, b) => b.score - a.score) + .slice(0, 3) + .map((e) => e.item), + topics: scored + .filter((e) => e.item.category === 'topic') + .sort((a, b) => b.score - a.score) + .slice(0, 3) + .map((e) => e.item), + investments: scored + .filter((e) => e.item.category === 'investment') + .sort((a, b) => b.score - a.score) + .slice(0, 3) + .map((e) => e.item), + }; + + const sanitized = sanitizeObject(categorized); + const response = withSecurityHeaders( + addHeaders(NextResponse.json(sanitized)), + ); + response.headers.set('Cache-Control', CDN_CACHE_HEADERS.private); + return response; +} diff --git a/src/app/hooks/__tests__/useSearch.test.tsx b/src/app/hooks/__tests__/useSearch.test.tsx index dc08b6a2..fc55da78 100644 --- a/src/app/hooks/__tests__/useSearch.test.tsx +++ b/src/app/hooks/__tests__/useSearch.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useSearch } from '../useSearch'; +import type { CategorizedResults } from '../useSearch'; vi.mock('next/navigation', () => ({ useRouter: () => ({ @@ -8,38 +9,120 @@ vi.mock('next/navigation', () => ({ }), })); +const MOCK_API_RESPONSE: CategorizedResults = { + courses: [ + { + id: 'FI-101', + title: 'Investment Fundamentals for Creators', + category: 'course', + instructor: 'Dr. Sarah Connor', + rating: 4.8, + price: 59.99, + image: + 'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?auto=format&fit=crop&w=1200&q=80', + }, + ], + instructors: [], + topics: [], + investments: [ + { + id: 'INV-101', + title: 'Investment Fundamentals for Creators', + category: 'investment', + description: 'Understand capital allocation, growth opportunities, and risk management.', + }, + { + id: 'INV-102', + title: 'Investment Planning for Startups', + category: 'investment', + description: 'Learn how to build investor-ready business plans and pitch decks.', + }, + { + id: 'INV-103', + title: 'Strategic Capital Deployment', + category: 'investment', + description: 'Align project goals with effective investment and sourcing strategies.', + }, + ], +}; + describe('useSearch investment feature support', () => { beforeEach(() => { vi.useFakeTimers(); localStorage.clear(); + + // Create fetch mock — assign directly (avoids spyOn issues in jsdom) + globalThis.fetch = vi.fn().mockImplementation(async (url: string | URL | Request) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + const queryParam = new URL(urlStr, 'http://localhost').searchParams.get('q') || ''; + + if (queryParam.toLowerCase().includes('investment')) { + return new Response(JSON.stringify(MOCK_API_RESPONSE), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (queryParam.toLowerCase().includes('creators')) { + return new Response( + JSON.stringify({ + courses: [], + instructors: [], + topics: [], + investments: MOCK_API_RESPONSE.investments.filter((i) => + i.title.toLowerCase().includes('creators'), + ), + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + + return new Response( + JSON.stringify({ courses: [], instructors: [], topics: [], investments: [] }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); }); afterEach(() => { vi.useRealTimers(); - vi.clearAllMocks(); + vi.restoreAllMocks(); }); - it('returns investment results when the query matches investment content', () => { + it('returns investment results when the query matches investment content', async () => { const { result } = renderHook(() => useSearch()); act(() => { result.current.search('investment'); + }); + + // Advance past the debounce window + await act(async () => { vi.advanceTimersByTime(300); }); + // Drain the microtask queue from the async fetch handler + await act(async () => {}); + expect(result.current.results.investments).toHaveLength(3); expect(result.current.results.investments[0].title).toContain('Investment'); expect(result.current.isLoading).toBe(false); }); - it('returns only relevant investment search results for a specific investment query', () => { + it('returns only relevant investment search results for a specific investment query', async () => { const { result } = renderHook(() => useSearch()); act(() => { result.current.search('creators'); + }); + + await act(async () => { vi.advanceTimersByTime(300); }); + // Drain the microtask queue + await act(async () => {}); + expect(result.current.results.investments).toHaveLength(1); expect(result.current.results.investments[0].title).toContain('Creators'); expect(result.current.results.courses).toEqual([]); diff --git a/src/app/hooks/useSearch.tsx b/src/app/hooks/useSearch.tsx index 98046d58..509b03ef 100644 --- a/src/app/hooks/useSearch.tsx +++ b/src/app/hooks/useSearch.tsx @@ -23,125 +23,78 @@ export interface CategorizedResults { investments: SearchResult[]; } -// Mock data for search suggestions -const MOCK_DATA = { +// Fallback mock data — used only in dev mode when the API is unreachable +// or when NEXT_PUBLIC_USE_MOCK_SEARCH is explicitly set to "true". +const MOCK_DATA: CategorizedResults = { courses: [ - { - id: 'CS-101', - title: 'Advanced UI/UX Masterclass for Digital Products', - category: 'course' as const, - instructor: 'Dr. Sarah Connor', - rating: 4.9, - price: 84.99, - image: - 'https://lh3.googleusercontent.com/aida-public/AB6AXuCil99ZvcYdOTX2ikzfmezXpX8PM1gdqvlH9POOIWtf_oLnLUykcTbi_AmwTRyHd91SesbJSqYVeMRowIU8LwxAMfmlo6f_Pz1u8SrLX6MRh78Y4jM36RyPsfr3f6KzeGWxm4kZXTGblOlSET-GxfYpd_Nzdu1P0AVyTV6bpa_jwzSu1ZfKYb7SrSNphUJwOZowmvViRp-dqkbRQYrkWbuITbMKEf_FdUEefC39x97j6p9fuc1eF7A1Z3QFUZVecRUTOyGnzRBp9_Q', - }, - { - id: 'MK-204', - title: 'Data-Driven Growth Strategies for Startups', - category: 'course' as const, - instructor: 'James Wilson', - rating: 4.7, - price: 49.99, - image: - 'https://lh3.googleusercontent.com/aida-public/AB6AXuAvzbzuSjw86VeI3OOXElSHJGSEvvzouGyeAW34jd3Px-Io4QsMww1pfJW7UOrjmNJJJ_DaLMU81Np88h_XCXTqDiDlcl9mNDYwjXXpxgNJJDTWfwEiAukRqT_aWTm9KNnK-6hjRAZZ1EPYs-Sz8NXTSel_BwuZekfnzd5n0JxMSoI2ke-DWJeJv42Df58VwTZCOadgRyZ6ktv0syTD-xYuIuPk4fe0vYDgPEqVPNAigyQsq3AxWqvfnpntYxcLt3ABxf7aaTSb1cQ', - }, - { - id: 'GD-009', - title: 'Professional Branding: From Concept to Launch', - category: 'course' as const, - instructor: 'Dr. Sarah Connor', - rating: 4.5, - price: 119.0, - image: - 'https://lh3.googleusercontent.com/aida-public/AB6AXuDCreAkOdseX9ZVH36oFGC3i0Q7D-DQz4x3zJL5NDtMSwfta8Qw886FBxG-IqaeUipdJkrptvGNhoKABpUsxpWM3mdOIUqxuJd_fIs7rU40m72S62pZ5kpdbKsqlJVJyqsi2WWJEkXEWJbKJwxP8BauVhNPiCvvFftKmHNbg7YM1sd5mq65ce7SyfNYQF70fI6FtiiqDZsVImVhLv0vn7St75HI9mz129PyeaPssxNqC7teU9A8by_hsz-wROUBDqIZLBErccnO0oQ', - }, - { - id: 'WD-300', - title: 'Modern Web Design with Figma and Webflow', - category: 'course' as const, - instructor: 'Dr. Sarah Connor', - rating: 4.6, - price: 99.0, - image: - 'https://lh3.googleusercontent.com/aida-public/AB6AXuAC6NhUUHDRqyDaSzJlgNygr56BoDO_8fQcX3et2Wnp8VDx35N3nXgqbR-Xt6LuiN8hBJJaqe85edLCUHF5Bfgu7Px9PPIjEpbNqBu_b_BBAFHrqEGmReNkqR478aid53gP2dmZgNyG55_bI7DNrVWMgb85BYuA7qVKC-a25Qn5MboAwsL8FmgvO-VnOtPKgxx-yRNmHY4bAzrF8d9nAusUx1x_WpNRHMsGsuZkOktb-WhkFhppcyDN489FP793xIA_CTxtWMSe0r4', - }, - { - id: 'SM-881', - title: 'Social Media Marketing: Zero to Hero', - category: 'course' as const, - instructor: 'James Wilson', - rating: 4.9, - price: 24.99, - image: - 'https://lh3.googleusercontent.com/aida-public/AB6AXuAUd6QuR7Rp0azAg6bhUKGrn8eqsVDX9Ry8ezok66U35Rnc39EGF8e_4RTwbxJKMv-73ahqf8f5ZB9b93NmWiNPSK8EnFcIr6DteWxx7yWzlyXRTn5Ti-5vmkE6VCeszMwgLEntCvZqhhTZrqr2Jn9RHsXJsFVCynv4RsUCpiG3B-L_NPzkyO3IFkYBfoMOmsHnLUsFI4SXKQGRME81AAq--3NMDs_chLm5cXIc-AdEeXiMFUIGBH9GllgnintdJWon-xXTl_1BWos', - }, + { id: 'CS-101', title: 'Advanced UI/UX Masterclass for Digital Products', category: 'course', instructor: 'Dr. Sarah Connor', rating: 4.9, price: 84.99, image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuCil99ZvcYdOTX2ikzfmezXpX8PM1gdqvlH9POOIWtf_oLnLUykcTbi_AmwTRyHd91SesbJSqYVeMRowIU8LwxAMfmlo6f_Pz1u8SrLX6MRh78Y4jM36RyPsfr3f6KzeGWxm4kZXTGblOlSET-GxfYpd_Nzdu1P0AVyTV6bpa_jwzSu1ZfKYb7SrSNphUJwOZowmvViRp-dqkbRQYrkWbuITbMKEf_FdUEefC39x97j6p9fuc1eF7A1Z3QFUZVecRUTOyGnzRBp9_Q' }, + { id: 'MK-204', title: 'Data-Driven Growth Strategies for Startups', category: 'course', instructor: 'James Wilson', rating: 4.7, price: 49.99, image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuAvzbzuSjw86VeI3OOXElSHJGSEvvzouGyeAW34jd3Px-Io4QsMww1pfJW7UOrjmNJJJ_DaLMU81Np88h_XCXTqDiDlcl9mNDYwjXXpxgNJJDTWfwEiAukRqT_aWTm9KNnK-6hjRAZZ1EPYs-Sz8NXTSel_BwuZekfnzd5n0JxMSoI2ke-DWJeJv42Df58VwTZCOadgRyZ6ktv0syTD-xYuIuPk4fe0vYDgPEqVPNAigyQsq3AxWqvfnpntYxcLt3ABxf7aaTSb1cQ' }, + { id: 'GD-009', title: 'Professional Branding: From Concept to Launch', category: 'course', instructor: 'Dr. Sarah Connor', rating: 4.5, price: 119.0, image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuDCreAkOdseX9ZVH36oFGC3i0Q7D-DQz4x3zJL5NDtMSwfta8Qw886FBxG-IqaeUipdJkrptvGNhoKABpUsxpWM3mdOIUqxuJd_fIs7rU40m72S62pZ5kpdbKsqlJVJyqsi2WWJEkXEWJbKJwxP8BauVhNPiCvvFftKmHNbg7YM1sd5mq65ce7SyfNYQF70fI6FtiiqDZsVImVhLv0vn7St75HI9mz129PyeaPssxNqC7teU9A8by_hsz-wROUBDqIZLBErccnO0oQ' }, + { id: 'WD-300', title: 'Modern Web Design with Figma and Webflow', category: 'course', instructor: 'Dr. Sarah Connor', rating: 4.6, price: 99.0, image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuAC6NhUUHDRqyDaSzJlgNygr56BoDO_8fQcX3et2Wnp8VDx35N3nXgqbR-Xt6LuiN8hBJJaqe85edLCUHF5Bfgu7Px9PPIjEpbNqBu_b_BBAFHrqEGmReNkqR478aid53gP2dmZgNyG55_bI7DNrVWMgb85BYuA7qVKC-a25Qn5MboAwsL8FmgvO-VnOtPKgxx-yRNmHY4bAzrF8d9nAusUx1x_WpNRHMsGsuZkOktb-WhkFhppcyDN489FP793xIA_CTxtWMSe0r4' }, + { id: 'SM-881', title: 'Social Media Marketing: Zero to Hero', category: 'course', instructor: 'James Wilson', rating: 4.9, price: 24.99, image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuAUd6QuR7Rp0azAg6bhUKGrn8eqsVDX9Ry8ezok66U35Rnc39EGF8e_4RTwbxJKMv-73ahqf8f5ZB9b93NmWiNPSK8EnFcIr6DteWxx7yWzlyXRTn5Ti-5vmkE6VCeszMwgLEntCvZqhhTZrqr2Jn9RHsXJsFVCynv4RsUCpiG3B-L_NPzkyO3IFkYBfoMOmsHnLUsFI4SXKQGRME81AAq--3NMDs_chLm5cXIc-AdEeXiMFUIGBH9GllgnintdJWon-xXTl_1BWos' }, ], instructors: [ - { - id: 'INST-001', - title: 'Dr. Sarah Connor', - category: 'instructor' as const, - image: 'https://ui-avatars.com/api/?name=Sarah+Connor', - description: 'Expert in UI/UX Design and Web Development', - }, - { - id: 'INST-002', - title: 'James Wilson', - category: 'instructor' as const, - image: 'https://ui-avatars.com/api/?name=James+Wilson', - description: 'Marketing and Growth Strategy Specialist', - }, + { id: 'INST-001', title: 'Dr. Sarah Connor', category: 'instructor', image: 'https://ui-avatars.com/api/?name=Sarah+Connor', description: 'Expert in UI/UX Design and Web Development' }, + { id: 'INST-002', title: 'James Wilson', category: 'instructor', image: 'https://ui-avatars.com/api/?name=James+Wilson', description: 'Marketing and Growth Strategy Specialist' }, ], topics: [ - { - id: 'TOPIC-001', - title: 'UI/UX Design', - category: 'topic' as const, - description: 'Learn user interface and user experience design', - }, - { - id: 'TOPIC-002', - title: 'Digital Marketing', - category: 'topic' as const, - description: 'Master digital marketing strategies', - }, - { - id: 'TOPIC-003', - title: 'Web Development', - category: 'topic' as const, - description: 'Build modern web applications', - }, - { - id: 'TOPIC-004', - title: 'Data Analysis', - category: 'topic' as const, - description: 'Analyze and visualize data effectively', - }, + { id: 'TOPIC-001', title: 'UI/UX Design', category: 'topic', description: 'Learn user interface and user experience design' }, + { id: 'TOPIC-002', title: 'Digital Marketing', category: 'topic', description: 'Master digital marketing strategies' }, + { id: 'TOPIC-003', title: 'Web Development', category: 'topic', description: 'Build modern web applications' }, + { id: 'TOPIC-004', title: 'Data Analysis', category: 'topic', description: 'Analyze and visualize data effectively' }, ], investments: [ - { - id: 'INV-101', - title: 'Investment Fundamentals for Creators', - category: 'investment' as const, - description: 'Understand capital allocation, growth opportunities, and risk management.', - }, - { - id: 'INV-102', - title: 'Investment Planning for Startups', - category: 'investment' as const, - description: 'Learn how to build investor-ready business plans and pitch decks.', - }, - { - id: 'INV-103', - title: 'Strategic Capital Deployment', - category: 'investment' as const, - description: 'Align project goals with effective investment and sourcing strategies.', - }, + { id: 'INV-101', title: 'Investment Fundamentals for Creators', category: 'investment', description: 'Understand capital allocation, growth opportunities, and risk management.' }, + { id: 'INV-102', title: 'Investment Planning for Startups', category: 'investment', description: 'Learn how to build investor-ready business plans and pitch decks.' }, + { id: 'INV-103', title: 'Strategic Capital Deployment', category: 'investment', description: 'Align project goals with effective investment and sourcing strategies.' }, ], }; +/** + * Whether to bypass the API and always use the local mock fallback. + * Controlled via NEXT_PUBLIC_USE_MOCK_SEARCH env var. + * Defaults to false — the API is always preferred; the mock is used + * only when the API request fails or is otherwise unavailable. + */ +const USE_MOCK_FALLBACK = + process.env.NEXT_PUBLIC_USE_MOCK_SEARCH === 'true'; + const DEBOUNCE_MS = 300; +const SEARCH_API_URL = '/api/search'; + +/** + * Client-side filter against the mock data — used as a local fallback when the + * API is unreachable, or when USE_MOCK_FALLBACK is explicitly enabled. + */ +function localFilter(query: string, data: CategorizedResults): CategorizedResults { + const lower = query.toLowerCase(); + return { + courses: data.courses + .filter( + (c) => + c.title.toLowerCase().includes(lower) || + c.instructor?.toLowerCase().includes(lower), + ) + .slice(0, 3), + instructors: data.instructors + .filter((i) => i.title.toLowerCase().includes(lower)) + .slice(0, 3), + topics: data.topics + .filter( + (t) => + t.title.toLowerCase().includes(lower) || + t.description?.toLowerCase().includes(lower), + ) + .slice(0, 3), + investments: data.investments + .filter( + (i) => + i.title.toLowerCase().includes(lower) || + i.description?.toLowerCase().includes(lower), + ) + .slice(0, 3), + }; +} export const useSearch = () => { const [query, setQuery] = useState(''); @@ -155,6 +108,7 @@ export const useSearch = () => { const [searchHistory, setSearchHistory] = useState([]); const [isOpen, setIsOpen] = useState(false); const debounceTimer = useRef | null>(null); + const abortController = useRef(null); const router = useRouter(); // Load search history from localStorage @@ -169,44 +123,77 @@ export const useSearch = () => { } }, []); - // Execute the actual filtering (called after debounce) - const executeSearch = useCallback((searchQuery: string) => { + // Cleanup on unmount + useEffect(() => { + return () => { + if (debounceTimer.current) clearTimeout(debounceTimer.current); + if (abortController.current) abortController.current.abort(); + }; + }, []); + + /** + * Execute the API-backed search. Called after the debounce window. + * Aborts any in-flight request if a new keystroke arrives. + */ + const executeSearch = useCallback(async (searchQuery: string) => { if (!searchQuery.trim()) { setResults({ courses: [], instructors: [], topics: [], investments: [] }); setIsLoading(false); return; } - const lowerQuery = searchQuery.toLowerCase(); + // Abort any previous in-flight request + if (abortController.current) { + abortController.current.abort(); + } + abortController.current = new AbortController(); + const { signal } = abortController.current; + + // Short-circuit: use local mock in dev/non-production with env flag + if (USE_MOCK_FALLBACK) { + const filtered = localFilter(searchQuery, MOCK_DATA); + setResults(filtered); + setIsLoading(false); + logger.info('Mock fallback used for search', { query: searchQuery }); + return; + } + + try { + const params = new URLSearchParams({ q: searchQuery }); + const res = await fetch(`${SEARCH_API_URL}?${params}`, { signal }); - setResults({ - courses: MOCK_DATA.courses - .filter( - (c) => - c.title.toLowerCase().includes(lowerQuery) || - c.instructor?.toLowerCase().includes(lowerQuery), - ) - .slice(0, 3), - instructors: MOCK_DATA.instructors - .filter((i) => i.title.toLowerCase().includes(lowerQuery)) - .slice(0, 3), - topics: MOCK_DATA.topics - .filter( - (t) => - t.title.toLowerCase().includes(lowerQuery) || - t.description?.toLowerCase().includes(lowerQuery), - ) - .slice(0, 3), - investments: MOCK_DATA.investments - .filter( - (i) => - i.title.toLowerCase().includes(lowerQuery) || - i.description?.toLowerCase().includes(lowerQuery), - ) - .slice(0, 3), - }); + // If the request was aborted, bail out silently + if (signal.aborted) return; - setIsLoading(false); + if (!res.ok) { + throw new Error(`Search API returned ${res.status}`); + } + + const data: CategorizedResults = await res.json(); + // Guard against aborted state after JSON parse + if (signal.aborted) return; + + setResults(data); + } catch (err: unknown) { + // Ignore aborted requests — they are expected on fast typing + if (err instanceof DOMException && err.name === 'AbortError') { + return; + } + + logger.error('Search API request failed, falling back to mock', { + error: err instanceof Error ? err.message : String(err), + query: searchQuery, + }); + + // Fall back to local filtering when the API is unreachable + const filtered = localFilter(searchQuery, MOCK_DATA); + setResults(filtered); + } finally { + // Only clear loading if this controller is still the active one + if (abortController.current && !abortController.current.signal.aborted) { + setIsLoading(false); + } + } }, []); // Debounced search: set loading immediately, delay execution