diff --git a/frontend/components/SearchErrorModal.tsx b/frontend/components/SearchErrorModal.tsx
new file mode 100644
index 00000000..43a74629
--- /dev/null
+++ b/frontend/components/SearchErrorModal.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+interface SearchErrorModalProps {
+ isOpen: boolean;
+ error: string | null;
+ onRetry?: () => void;
+ onSkip?: () => void;
+ canRetry?: boolean;
+}
+
+export function SearchErrorModal({ isOpen, error, onRetry, onSkip, canRetry = true }: SearchErrorModalProps) {
+ if (!isOpen) return null;
+
+ return (
+
+
+
Search failed
+
{error || 'Unable to retrieve specs from the web'}
+
+
+ {canRetry && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/components/SearchLoadingModal.tsx b/frontend/components/SearchLoadingModal.tsx
new file mode 100644
index 00000000..693d1bb0
--- /dev/null
+++ b/frontend/components/SearchLoadingModal.tsx
@@ -0,0 +1,57 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+interface SearchLoadingModalProps {
+ isOpen: boolean;
+ onTimeout?: () => void;
+ maxSeconds?: number;
+}
+
+export function SearchLoadingModal({ isOpen, onTimeout, maxSeconds = 30 }: SearchLoadingModalProps) {
+ const [seconds, setSeconds] = useState(maxSeconds);
+
+ useEffect(() => {
+ if (!isOpen) {
+ setSeconds(maxSeconds);
+ return;
+ }
+
+ const interval = setInterval(() => {
+ setSeconds(prev => {
+ if (prev <= 1) {
+ clearInterval(interval);
+ onTimeout?.();
+ return maxSeconds;
+ }
+ return prev - 1;
+ });
+ }, 1000);
+
+ return () => clearInterval(interval);
+ }, [isOpen, maxSeconds, onTimeout]);
+
+ if (!isOpen) return null;
+
+ return (
+
+
+
Searching for specs...
+
This may take up to {maxSeconds} seconds
+
+
+
+
+ {seconds} seconds remaining
+
+
+
+ );
+}
diff --git a/frontend/hooks/useItemSearch.ts b/frontend/hooks/useItemSearch.ts
new file mode 100644
index 00000000..9e72ecf9
--- /dev/null
+++ b/frontend/hooks/useItemSearch.ts
@@ -0,0 +1,89 @@
+'use client';
+
+import { useState, useCallback } from 'react';
+
+export interface SearchResult {
+ category?: string;
+ type?: string;
+ description?: string;
+ notes?: string;
+}
+
+export interface SearchState {
+ isSearching: boolean;
+ searchError: string | null;
+ searchResults: SearchResult | null;
+ searchStatus: 'idle' | 'searching' | 'success' | 'timeout' | 'error' | 'no_results' | 'skipped';
+ retryCount: number;
+}
+
+export function useItemSearch(options: { maxRetries?: number; timeout?: number } = {}) {
+ const { maxRetries = 2, timeout = 30000 } = options;
+
+ const [state, setState] = useState({
+ isSearching: false,
+ searchError: null,
+ searchResults: null,
+ searchStatus: 'idle',
+ retryCount: 0,
+ });
+
+ const performSearch = useCallback(
+ async (partNumber: string, category: string): Promise => {
+ if (!partNumber || !category) {
+ setState(prev => ({ ...prev, searchStatus: 'skipped', searchError: null }));
+ return null;
+ }
+
+ setState(prev => ({ ...prev, isSearching: true, searchStatus: 'searching', searchError: null, retryCount: 0 }));
+
+ try {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
+
+ const response = await fetch('/api/onboarding/search', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ partNumber, category }),
+ signal: controller.signal,
+ });
+
+ clearTimeout(timeoutId);
+
+ if (!response.ok) throw new Error(`Search failed: ${response.status}`);
+
+ const results = await response.json();
+ setState(prev => ({ ...prev, isSearching: false, searchStatus: 'success', searchResults: results }));
+ return results;
+ } catch (error) {
+ const errorMsg = error instanceof Error ? error.message : 'Search failed';
+ setState(prev => ({
+ ...prev,
+ isSearching: false,
+ searchStatus: errorMsg.includes('abort') ? 'timeout' : 'error',
+ searchError: errorMsg,
+ }));
+ return null;
+ }
+ },
+ [timeout]
+ );
+
+ const retrySearch = useCallback(
+ async (partNumber: string, category: string) => {
+ if (state.retryCount >= maxRetries) {
+ setState(prev => ({ ...prev, searchStatus: 'error' }));
+ return null;
+ }
+ setState(prev => ({ ...prev, retryCount: prev.retryCount + 1 }));
+ return performSearch(partNumber, category);
+ },
+ [state.retryCount, maxRetries, performSearch]
+ );
+
+ const skipSearch = useCallback(() => {
+ setState(prev => ({ ...prev, searchStatus: 'skipped', isSearching: false }));
+ }, []);
+
+ return { state, performSearch, retrySearch, skipSearch };
+}
diff --git a/frontend/tests/SearchErrorModal.test.tsx b/frontend/tests/SearchErrorModal.test.tsx
new file mode 100644
index 00000000..0ef22f37
--- /dev/null
+++ b/frontend/tests/SearchErrorModal.test.tsx
@@ -0,0 +1,51 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { SearchErrorModal } from '../components/SearchErrorModal';
+
+describe('SearchErrorModal', () => {
+ it('renders when open', () => {
+ render();
+ expect(screen.getByText(/Search failed/i)).toBeInTheDocument();
+ });
+
+ it('does not render when closed', () => {
+ const { container } = render();
+ expect(container.firstChild?.childNodes.length).toBe(0);
+ });
+
+ it('displays error message', () => {
+ const errorMsg = 'Unable to connect to server';
+ render();
+ expect(screen.getByText(errorMsg)).toBeInTheDocument();
+ });
+
+ it('calls onRetry when Retry clicked', async () => {
+ const onRetry = vi.fn();
+ const user = userEvent.setup();
+
+ render();
+
+ const retryBtn = screen.getByRole('button', { name: /Retry/i });
+ await user.click(retryBtn);
+
+ expect(onRetry).toHaveBeenCalled();
+ });
+
+ it('calls onSkip when Skip clicked', async () => {
+ const onSkip = vi.fn();
+ const user = userEvent.setup();
+
+ render();
+
+ const skipBtn = screen.getByRole('button', { name: /Skip/i });
+ await user.click(skipBtn);
+
+ expect(onSkip).toHaveBeenCalled();
+ });
+
+ it('hides Retry button when canRetry is false', () => {
+ render();
+ expect(screen.queryByRole('button', { name: /Retry/i })).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/tests/SearchLoadingModal.test.tsx b/frontend/tests/SearchLoadingModal.test.tsx
new file mode 100644
index 00000000..c9992ff8
--- /dev/null
+++ b/frontend/tests/SearchLoadingModal.test.tsx
@@ -0,0 +1,35 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import { SearchLoadingModal } from '../components/SearchLoadingModal';
+
+describe('SearchLoadingModal', () => {
+ it('renders when open', () => {
+ render();
+ expect(screen.getByText(/Searching for specs/i)).toBeInTheDocument();
+ });
+
+ it('does not render when closed', () => {
+ const { container } = render();
+ expect(container.firstChild?.childNodes.length).toBe(0);
+ });
+
+ it('displays countdown timer', () => {
+ render();
+ expect(screen.getByText(/30 seconds remaining/i)).toBeInTheDocument();
+ });
+
+ it('calls onTimeout when timer expires', async () => {
+ const onTimeout = vi.fn();
+ render();
+
+ await waitFor(() => {
+ expect(onTimeout).toHaveBeenCalled();
+ }, { timeout: 2000 });
+ });
+
+ it('shows progress bar', () => {
+ const { container } = render();
+ const progressBar = container.querySelector('div[class*="bg-primary"]');
+ expect(progressBar).toBeInTheDocument();
+ });
+});
diff --git a/frontend/tests/useItemSearch.test.tsx b/frontend/tests/useItemSearch.test.tsx
new file mode 100644
index 00000000..7afc3f93
--- /dev/null
+++ b/frontend/tests/useItemSearch.test.tsx
@@ -0,0 +1,103 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { renderHook, act, waitFor } from '@testing-library/react';
+import { useItemSearch } from '../hooks/useItemSearch';
+
+describe('useItemSearch', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ global.fetch = vi.fn();
+ });
+
+ it('initializes with idle state', () => {
+ const { result } = renderHook(() => useItemSearch());
+ expect(result.current.state.searchStatus).toBe('idle');
+ expect(result.current.state.isSearching).toBe(false);
+ });
+
+ it('performs search with part number and category', async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ type: 'DDR4', description: 'Kingston RAM' }),
+ });
+
+ const { result } = renderHook(() => useItemSearch());
+
+ await act(async () => {
+ await result.current.performSearch('KF466C40RS-16', 'DDR4');
+ });
+
+ expect(result.current.state.searchStatus).toBe('success');
+ expect(result.current.state.searchResults?.type).toBe('DDR4');
+ });
+
+ it('handles search timeout', async () => {
+ global.fetch = vi.fn().mockImplementation(
+ () =>
+ new Promise((_, reject) => {
+ setTimeout(() => reject(new DOMException('Aborted', 'AbortError')), 100);
+ })
+ );
+
+ const { result } = renderHook(() => useItemSearch({ timeout: 50 }));
+
+ await act(async () => {
+ await result.current.performSearch('KF466C40RS-16', 'DDR4');
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.searchStatus).toBe('timeout');
+ });
+ });
+
+ it('skips search when part number is missing', async () => {
+ const { result } = renderHook(() => useItemSearch());
+
+ await act(async () => {
+ await result.current.performSearch('', 'DDR4');
+ });
+
+ expect(result.current.state.searchStatus).toBe('skipped');
+ });
+
+ it('handles search error', async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ });
+
+ const { result } = renderHook(() => useItemSearch());
+
+ await act(async () => {
+ await result.current.performSearch('KF466C40RS-16', 'DDR4');
+ });
+
+ expect(result.current.state.searchStatus).toBe('error');
+ expect(result.current.state.searchError).toBeTruthy();
+ });
+
+ it('retries search', async () => {
+ const { result } = renderHook(() => useItemSearch({ maxRetries: 2 }));
+
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ type: 'DDR4' }),
+ });
+
+ await act(async () => {
+ await result.current.retrySearch('KF466C40RS-16', 'DDR4');
+ });
+
+ expect(result.current.state.retryCount).toBe(1);
+ });
+
+ it('skips search', async () => {
+ const { result } = renderHook(() => useItemSearch());
+
+ act(() => {
+ result.current.skipSearch();
+ });
+
+ expect(result.current.state.searchStatus).toBe('skipped');
+ expect(result.current.state.isSearching).toBe(false);
+ });
+});