"use client"; import React from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { RefreshCw } from "lucide-react"; import { useAuth } from "@/components/auth/authContext"; import { ApiClientError, getBranches, search } from "@/lib/frontend/apiClient"; import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect"; import { searchPath } from "@/lib/frontend/routes"; import { SEARCH_SCOPE, parseSearchUrlState, serializeSearchUrlState, } from "@/lib/frontend/search/urlState"; import { mapSearchError } from "@/lib/frontend/search/errorMapping"; import ExplorerPageShell from "@/components/explorer/ExplorerPageShell"; import ExplorerSectionCard from "@/components/explorer/ExplorerSectionCard"; import ForbiddenView from "@/components/system/ForbiddenView"; import { Button } from "@/components/ui/button"; import SearchForm from "@/components/search/SearchForm"; import SearchResults from "@/components/search/SearchResults"; const PAGE_LIMIT = 100; const BRANCH_LIST_STATE = Object.freeze({ IDLE: "idle", LOADING: "loading", READY: "ready", ERROR: "error", }); export default function SearchPage({ branch }) { const router = useRouter(); const searchParams = useSearchParams(); const { status, user } = useAuth(); const isAuthenticated = status === "authenticated" && user; const isAdminDev = isAuthenticated && (user.role === "admin" || user.role === "dev"); const isBranchUser = isAuthenticated && user.role === "branch"; const parsedUrlState = React.useMemo(() => { return parseSearchUrlState(searchParams, { routeBranch: branch }); }, [searchParams, branch]); // Enforce "single = this route branch" and keep branch users safe. const urlState = React.useMemo(() => { // AuthGate ensures auth before rendering, but keep this defensive. if (!user) return parsedUrlState; // Branch users: always single, no cross-branch scope params. if (user.role === "branch") { return { ...parsedUrlState, scope: SEARCH_SCOPE.SINGLE, branch, branches: [], }; } // Admin/dev: single scope is always the route branch context. if (parsedUrlState.scope === SEARCH_SCOPE.SINGLE) { return { ...parsedUrlState, branch }; } return parsedUrlState; }, [parsedUrlState, user, branch]); const searchKey = React.useMemo(() => { // This is our "query identity" without cursor. return serializeSearchUrlState(urlState); }, [urlState]); // Keep a ref of the latest key so async "load more" cannot append to a new search. const searchKeyRef = React.useRef(searchKey); React.useEffect(() => { searchKeyRef.current = searchKey; }, [searchKey]); // Input draft (URL remains the single source of truth for executed searches). const [qDraft, setQDraft] = React.useState(urlState.q || ""); React.useEffect(() => { setQDraft(urlState.q || ""); }, [urlState.q]); // Admin/dev: load branch list for multi-select (fail-open). const [branchList, setBranchList] = React.useState({ status: BRANCH_LIST_STATE.IDLE, branches: null, }); React.useEffect(() => { if (!isAdminDev) return; let cancelled = false; setBranchList({ status: BRANCH_LIST_STATE.LOADING, branches: null }); (async () => { try { const res = await getBranches(); if (cancelled) return; const branches = Array.isArray(res?.branches) ? res.branches : []; setBranchList({ status: BRANCH_LIST_STATE.READY, branches }); } catch (err) { if (cancelled) return; console.error("[SearchPage] getBranches failed:", err); setBranchList({ status: BRANCH_LIST_STATE.ERROR, branches: null }); } })(); return () => { cancelled = true; }; }, [isAdminDev, user?.userId]); // Search results state. const [statusState, setStatusState] = React.useState("idle"); // idle|loading|success|error const [items, setItems] = React.useState([]); const [nextCursor, setNextCursor] = React.useState(null); const [error, setError] = React.useState(null); const [isLoadingMore, setIsLoadingMore] = React.useState(false); const [loadMoreError, setLoadMoreError] = React.useState(null); const searchRequestIdRef = React.useRef(0); const loadMoreRequestIdRef = React.useRef(0); const mappedError = React.useMemo(() => mapSearchError(error), [error]); const mappedLoadMoreError = React.useMemo( () => mapSearchError(loadMoreError), [loadMoreError] ); // Redirect on unauthenticated search calls (session expired mid-session). React.useEffect(() => { if (mappedError?.kind !== "unauthenticated") return; const next = typeof window !== "undefined" ? `${window.location.pathname}${window.location.search}` : searchPath(branch); window.location.replace( buildLoginUrl({ reason: LOGIN_REASONS.EXPIRED, next }) ); }, [mappedError?.kind, branch]); function buildHref(nextState, { push } = {}) { const base = searchPath(branch); const qs = serializeSearchUrlState(nextState); const href = qs ? `${base}?${qs}` : base; if (push) router.push(href); else router.replace(href); } function handleSubmit() { const nextState = { q: qDraft, // Branch users always single; admin/dev use selected scope. scope: isAdminDev ? urlState.scope : SEARCH_SCOPE.SINGLE, branch, // explicit for shareability (even though route already contains it) branches: urlState.branches, from: urlState.from, to: urlState.to, }; buildHref(nextState, { push: true }); } function handleScopeChange(nextScope) { if (!isAdminDev) return; const nextState = { // Scope changes rerun the currently executed query (URL q), not the draft. q: urlState.q, scope: nextScope, branch, branches: nextScope === SEARCH_SCOPE.MULTI ? urlState.branches : [], from: urlState.from, to: urlState.to, }; buildHref(nextState, { push: false }); } function toggleMultiBranch(branchId) { if (!isAdminDev) return; const current = Array.isArray(urlState.branches) ? urlState.branches : []; const set = new Set(current); if (set.has(branchId)) set.delete(branchId); else set.add(branchId); const nextState = { q: urlState.q, scope: SEARCH_SCOPE.MULTI, branches: Array.from(set), from: urlState.from, to: urlState.to, }; buildHref(nextState, { push: false }); } function buildSearchRequest({ cursor = null } = {}) { const q = urlState.q; if (!q) return null; const base = { q, limit: PAGE_LIMIT, }; if (urlState.from) base.from = urlState.from; if (urlState.to) base.to = urlState.to; if (cursor) base.cursor = cursor; // Branch role: never send scope/branches from URL (avoid forbidden / keep safe). if (isBranchUser) { base.branch = branch; return base; } // Admin/dev scopes: if (urlState.scope === SEARCH_SCOPE.ALL) { base.scope = "all"; return base; } if (urlState.scope === SEARCH_SCOPE.MULTI) { base.scope = "multi"; base.branches = urlState.branches; return base; } // Single (explicit branch param for shareability) base.branch = branch; return base; } async function runFirstPage() { // Reset "load more" UI whenever we start a new first-page search. setIsLoadingMore(false); setLoadMoreError(null); const q = urlState.q; // No search yet. if (!q) { setStatusState("idle"); setItems([]); setNextCursor(null); setError(null); return; } // Local validation: multi scope requires at least one branch. if (isAdminDev && urlState.scope === SEARCH_SCOPE.MULTI) { const branches = Array.isArray(urlState.branches) ? urlState.branches : []; if (branches.length === 0) { setStatusState("error"); setItems([]); setNextCursor(null); setError( new ApiClientError({ status: 400, code: "VALIDATION_SEARCH_BRANCHES", message: "Invalid branches", }) ); return; } } const req = buildSearchRequest({ cursor: null }); if (!req) return; const id = ++searchRequestIdRef.current; setStatusState("loading"); setItems([]); setNextCursor(null); setError(null); try { const res = await search(req); if (id !== searchRequestIdRef.current) return; const nextItems = Array.isArray(res?.items) ? res.items : []; const next = typeof res?.nextCursor === "string" ? res.nextCursor : null; setItems(nextItems); setNextCursor(next); setStatusState("success"); } catch (err) { if (id !== searchRequestIdRef.current) return; setItems([]); setNextCursor(null); setError(err); setStatusState("error"); } } async function loadMore() { if (!nextCursor) return; if (isLoadingMore) return; const baseKey = searchKeyRef.current; const req = buildSearchRequest({ cursor: nextCursor }); if (!req) return; const id = ++loadMoreRequestIdRef.current; setIsLoadingMore(true); setLoadMoreError(null); try { const res = await search(req); // If a newer "load more" started, ignore this result. if (id !== loadMoreRequestIdRef.current) return; // If the base search changed, do not append. if (searchKeyRef.current !== baseKey) return; const moreItems = Array.isArray(res?.items) ? res.items : []; const next = typeof res?.nextCursor === "string" ? res.nextCursor : null; setItems((prev) => [...prev, ...moreItems]); setNextCursor(next); } catch (err) { if (id !== loadMoreRequestIdRef.current) return; setLoadMoreError(err); } finally { if (id === loadMoreRequestIdRef.current) { setIsLoadingMore(false); } } } // Run first-page search whenever the URL-driven search identity changes. React.useEffect(() => { runFirstPage(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchKey, isAdminDev, isBranchUser, branch]); // Forbidden (keep consistent with Explorer UX). if (mappedError?.kind === "forbidden") { return ; } const actions = ( ); const resultsHeaderRight = urlState.q ? ( {urlState.q} ) : null; const resultsDescription = urlState.q ? `Niederlassung ${branch}` : "Geben Sie einen Suchbegriff ein, um zu starten."; return ( ); }