"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 (
);
}