"use client"; import React from "react"; import { search as apiSearch } from "@/lib/frontend/apiClient"; import { buildSearchApiInput } from "@/lib/frontend/search/searchApiInput"; /** * useSearchQuery * * Purpose: * - Encapsulate the Search UI data lifecycle in one place: * - "first page" load (URL-driven, cursor reset) * - "load more" pagination (cursor-based, append) * - race protection (ignore stale responses) * * Design rules: * - The URL (searchKey) is the identity of the first page. * - Cursor is intentionally NOT part of searchKey (not shareable). * - When searchKey changes, we reset and load the first page again. * * Returned state is UI-friendly and stable: * - status: "idle" | "loading" | "success" | "error" * - items / nextCursor / total / error * - loadMore() / isLoadingMore / loadMoreError * - retry() triggers a re-fetch of the first page for the current searchKey * * NOTE: * - This hook does not do any redirects or UI mapping. * - Mapping to German user-facing messages stays in SearchPage/SearchResults via mapSearchError(). * * @param {{ * searchKey: string, * urlState: { * q: string|null, * scope: "single"|"multi"|"all", * branch: string|null, * branches: string[], * limit: number, * from: string|null, * to: string|null * }, * routeBranch: string, * user: { role: string, branchId: string|null }|null, * limit?: number * }} args */ export function useSearchQuery({ searchKey, urlState, routeBranch, user, limit = 100, }) { const [status, setStatus] = React.useState("idle"); // idle|loading|success|error const [items, setItems] = React.useState([]); const [nextCursor, setNextCursor] = React.useState(null); const [total, setTotal] = React.useState(null); // number|null const [error, setError] = React.useState(null); const [isLoadingMore, setIsLoadingMore] = React.useState(false); const [loadMoreError, setLoadMoreError] = React.useState(null); // Retry tick triggers a first-page reload without changing searchKey. const [retryTick, setRetryTick] = React.useState(0); // Track the latest searchKey so "load more" cannot append into a newer search. const searchKeyRef = React.useRef(searchKey); React.useEffect(() => { searchKeyRef.current = searchKey; }, [searchKey]); // Race protection: // - firstPageRequestId increments for each first-page fetch // - loadMoreRequestId increments for each loadMore fetch const firstPageRequestIdRef = React.useRef(0); const loadMoreRequestIdRef = React.useRef(0); const retry = React.useCallback(() => { setRetryTick((n) => n + 1); }, []); const runFirstPage = React.useCallback(async () => { // Always reset "load more" state when starting a new first-page request. setIsLoadingMore(false); setLoadMoreError(null); const { input, error: localError } = buildSearchApiInput({ urlState, routeBranch, user, cursor: null, limit, }); // No query => we are in "idle" mode (no results, no error). if (!input && !localError) { setStatus("idle"); setItems([]); setNextCursor(null); setTotal(null); setError(null); return; } // Local validation failed (e.g. multi without branches) => show error without calling API. if (localError) { setStatus("error"); setItems([]); setNextCursor(null); setTotal(null); setError(localError); return; } const requestId = ++firstPageRequestIdRef.current; setStatus("loading"); setItems([]); setNextCursor(null); setTotal(null); setError(null); try { const res = await apiSearch(input); // Ignore stale responses. if (requestId !== firstPageRequestIdRef.current) return; const nextItems = Array.isArray(res?.items) ? res.items : []; const next = typeof res?.nextCursor === "string" && res.nextCursor.trim() ? res.nextCursor : null; const t = typeof res?.total === "number" && Number.isFinite(res.total) ? res.total : null; setItems(nextItems); setNextCursor(next); setTotal(t); setStatus("success"); } catch (err) { if (requestId !== firstPageRequestIdRef.current) return; setItems([]); setNextCursor(null); setTotal(null); setError(err); setStatus("error"); } }, [urlState, routeBranch, user, limit]); React.useEffect(() => { runFirstPage(); }, [runFirstPage, searchKey, retryTick]); const loadMore = React.useCallback(async () => { if (!nextCursor) return; if (isLoadingMore) return; const baseKey = searchKeyRef.current; const { input, error: localError } = buildSearchApiInput({ urlState, routeBranch, user, cursor: nextCursor, limit, }); if (localError) { setLoadMoreError(localError); return; } if (!input) return; const requestId = ++loadMoreRequestIdRef.current; setIsLoadingMore(true); setLoadMoreError(null); try { const res = await apiSearch(input); // Ignore stale responses. if (requestId !== loadMoreRequestIdRef.current) return; // If the base search changed since the click, do not append. if (searchKeyRef.current !== baseKey) return; const moreItems = Array.isArray(res?.items) ? res.items : []; const next = typeof res?.nextCursor === "string" && res.nextCursor.trim() ? res.nextCursor : null; const t = typeof res?.total === "number" && Number.isFinite(res.total) ? res.total : null; setItems((prev) => [...prev, ...moreItems]); setNextCursor(next); // Keep total stable but refresh when the backend provides it. if (t !== null) setTotal(t); } catch (err) { if (requestId !== loadMoreRequestIdRef.current) return; setLoadMoreError(err); } finally { // Only the latest loadMore call can clear the loading flag. if (requestId === loadMoreRequestIdRef.current) { setIsLoadingMore(false); } } }, [nextCursor, isLoadingMore, urlState, routeBranch, user, limit]); return { status, items, nextCursor, total, error, retry, loadMore, isLoadingMore, loadMoreError, }; }