SearchPage.jsx 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. "use client";
  2. import React from "react";
  3. import { useRouter, useSearchParams } from "next/navigation";
  4. import { RefreshCw } from "lucide-react";
  5. import { useAuth } from "@/components/auth/authContext";
  6. import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
  7. import { searchPath } from "@/lib/frontend/routes";
  8. import {
  9. parseSearchUrlState,
  10. serializeSearchUrlState,
  11. SEARCH_SCOPE,
  12. } from "@/lib/frontend/search/urlState";
  13. import { normalizeSearchUrlStateForUser } from "@/lib/frontend/search/normalizeState";
  14. import { mapSearchError } from "@/lib/frontend/search/errorMapping";
  15. import { useSearchQuery } from "@/lib/frontend/search/useSearchQuery";
  16. import {
  17. useSearchBranches,
  18. BRANCH_LIST_STATE,
  19. } from "@/lib/frontend/search/useSearchBranches";
  20. import ExplorerPageShell from "@/components/explorer/ExplorerPageShell";
  21. import ExplorerSectionCard from "@/components/explorer/ExplorerSectionCard";
  22. import ForbiddenView from "@/components/system/ForbiddenView";
  23. import { Button } from "@/components/ui/button";
  24. import SearchForm from "@/components/search/SearchForm";
  25. import SearchResults from "@/components/search/SearchResults";
  26. function buildSearchHref({ routeBranch, state }) {
  27. const base = searchPath(routeBranch);
  28. const qs = serializeSearchUrlState(state);
  29. return qs ? `${base}?${qs}` : base;
  30. }
  31. export default function SearchPage({ branch: routeBranch }) {
  32. const router = useRouter();
  33. const searchParams = useSearchParams();
  34. const { status: authStatus, user } = useAuth();
  35. const isAuthenticated = authStatus === "authenticated" && user;
  36. const isAdminDev =
  37. isAuthenticated && (user.role === "admin" || user.role === "dev");
  38. // 1) URL -> parsed state (pure helper)
  39. const parsedUrlState = React.useMemo(() => {
  40. return parseSearchUrlState(searchParams, { routeBranch });
  41. }, [searchParams, routeBranch]);
  42. // 2) Normalize for user role + route context (pure helper)
  43. const urlState = React.useMemo(() => {
  44. return normalizeSearchUrlStateForUser(parsedUrlState, {
  45. routeBranch,
  46. user,
  47. });
  48. }, [parsedUrlState, routeBranch, user]);
  49. // 3) The identity of a first-page search (cursor intentionally excluded).
  50. const searchKey = React.useMemo(() => {
  51. return serializeSearchUrlState(urlState);
  52. }, [urlState]);
  53. // 4) Draft input (URL remains SoT for executed searches).
  54. const [qDraft, setQDraft] = React.useState(urlState.q || "");
  55. React.useEffect(() => {
  56. setQDraft(urlState.q || "");
  57. }, [urlState.q]);
  58. // 5) Admin/dev only: branches list for multi select (fail-open)
  59. const branchesQuery = useSearchBranches({ enabled: isAdminDev });
  60. // 6) Data lifecycle (first page + load more)
  61. const query = useSearchQuery({
  62. searchKey,
  63. urlState,
  64. routeBranch,
  65. user,
  66. limit: urlState.limit,
  67. });
  68. // 7) Map errors to German UX copy
  69. const mappedError = React.useMemo(
  70. () => mapSearchError(query.error),
  71. [query.error]
  72. );
  73. const mappedLoadMoreError = React.useMemo(
  74. () => mapSearchError(query.loadMoreError),
  75. [query.loadMoreError]
  76. );
  77. // 8) Redirect when unauthenticated mid-request
  78. React.useEffect(() => {
  79. if (mappedError?.kind !== "unauthenticated") return;
  80. const next =
  81. typeof window !== "undefined"
  82. ? `${window.location.pathname}${window.location.search}`
  83. : searchPath(routeBranch);
  84. window.location.replace(
  85. buildLoginUrl({ reason: LOGIN_REASONS.EXPIRED, next })
  86. );
  87. }, [mappedError?.kind, routeBranch]);
  88. // 9) URL write helpers (search is URL-driven)
  89. const pushStateToUrl = React.useCallback(
  90. (nextState) => {
  91. router.push(buildSearchHref({ routeBranch, state: nextState }));
  92. },
  93. [router, routeBranch]
  94. );
  95. const replaceStateToUrl = React.useCallback(
  96. (nextState) => {
  97. router.replace(buildSearchHref({ routeBranch, state: nextState }));
  98. },
  99. [router, routeBranch]
  100. );
  101. // 10) Handlers
  102. const handleSubmit = React.useCallback(() => {
  103. const nextState = {
  104. ...urlState,
  105. q: qDraft,
  106. branch: routeBranch,
  107. };
  108. pushStateToUrl(nextState);
  109. }, [urlState, qDraft, routeBranch, pushStateToUrl]);
  110. const handleScopeChange = React.useCallback(
  111. (nextScope) => {
  112. if (!isAdminDev) return;
  113. const nextState = {
  114. ...urlState,
  115. scope: nextScope,
  116. branch: routeBranch,
  117. branches: nextScope === SEARCH_SCOPE.MULTI ? urlState.branches : [],
  118. };
  119. replaceStateToUrl(nextState);
  120. },
  121. [isAdminDev, urlState, routeBranch, replaceStateToUrl]
  122. );
  123. const handleToggleBranch = React.useCallback(
  124. (branchId) => {
  125. if (!isAdminDev) return;
  126. const current = Array.isArray(urlState.branches) ? urlState.branches : [];
  127. const set = new Set(current);
  128. if (set.has(branchId)) set.delete(branchId);
  129. else set.add(branchId);
  130. const nextState = {
  131. ...urlState,
  132. scope: SEARCH_SCOPE.MULTI,
  133. branches: Array.from(set),
  134. };
  135. replaceStateToUrl(nextState);
  136. },
  137. [isAdminDev, urlState, replaceStateToUrl]
  138. );
  139. const handleLimitChange = React.useCallback(
  140. (nextLimit) => {
  141. const nextState = {
  142. ...urlState,
  143. limit: nextLimit,
  144. branch: routeBranch,
  145. };
  146. // Like scope changes: rerun based on URL state (executed query), not on draft.
  147. replaceStateToUrl(nextState);
  148. },
  149. [urlState, routeBranch, replaceStateToUrl]
  150. );
  151. // Forbidden stays consistent with Explorer UX.
  152. if (mappedError?.kind === "forbidden") {
  153. return <ForbiddenView attemptedBranch={routeBranch} />;
  154. }
  155. const actions = (
  156. <Button
  157. variant="outline"
  158. size="sm"
  159. onClick={query.retry}
  160. disabled={!urlState.q || query.status === "loading"}
  161. title="Aktualisieren"
  162. >
  163. <RefreshCw className="h-4 w-4" />
  164. Aktualisieren
  165. </Button>
  166. );
  167. const resultsHeaderRight = urlState.q ? (
  168. <span className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
  169. {urlState.q}
  170. </span>
  171. ) : null;
  172. const resultsDescription = urlState.q
  173. ? `Niederlassung ${routeBranch}`
  174. : "Geben Sie einen Suchbegriff ein, um zu starten.";
  175. return (
  176. <ExplorerPageShell
  177. title="Suche"
  178. description={`Lieferscheine durchsuchen • Niederlassung ${routeBranch}`}
  179. actions={actions}
  180. >
  181. <ExplorerSectionCard
  182. title="Suche"
  183. description="Suchbegriff und Suchbereich auswählen."
  184. >
  185. <SearchForm
  186. branch={routeBranch}
  187. qDraft={qDraft}
  188. onQDraftChange={setQDraft}
  189. onSubmit={handleSubmit}
  190. currentQuery={urlState.q}
  191. isSubmitting={query.status === "loading"}
  192. isAdminDev={isAdminDev}
  193. scope={urlState.scope}
  194. onScopeChange={handleScopeChange}
  195. availableBranches={
  196. branchesQuery.status === BRANCH_LIST_STATE.READY &&
  197. Array.isArray(branchesQuery.branches)
  198. ? branchesQuery.branches
  199. : []
  200. }
  201. branchesStatus={branchesQuery.status}
  202. selectedBranches={urlState.branches}
  203. onToggleBranch={handleToggleBranch}
  204. limit={urlState.limit}
  205. onLimitChange={handleLimitChange}
  206. />
  207. </ExplorerSectionCard>
  208. <ExplorerSectionCard
  209. title="Ergebnisse"
  210. description={resultsDescription}
  211. headerRight={resultsHeaderRight}
  212. >
  213. <SearchResults
  214. branch={routeBranch}
  215. scope={urlState.scope}
  216. status={query.status}
  217. items={query.items}
  218. total={query.total}
  219. error={mappedError}
  220. onRetry={query.retry}
  221. nextCursor={query.nextCursor}
  222. onLoadMore={query.loadMore}
  223. isLoadingMore={query.isLoadingMore}
  224. loadMoreError={mappedLoadMoreError}
  225. />
  226. </ExplorerSectionCard>
  227. </ExplorerPageShell>
  228. );
  229. }