|
|
@@ -0,0 +1,231 @@
|
|
|
+"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,
|
|
|
+ };
|
|
|
+}
|