diff --git a/src/client/features/backlinks/BacklinksPage.tsx b/src/client/features/backlinks/BacklinksPage.tsx index bad595c..1a23517 100644 --- a/src/client/features/backlinks/BacklinksPage.tsx +++ b/src/client/features/backlinks/BacklinksPage.tsx @@ -84,7 +84,6 @@ export function BacklinksPage({ }), [], ); - return (
@@ -102,11 +101,6 @@ export function BacklinksPage({ searchTabs.canOpenTab(toBacklinksTabInput(values)) } @@ -147,6 +141,7 @@ export function BacklinksPage({ tabs: searchTabs.tabs, onSelect: searchTabs.selectTab, onClose: searchTabs.closeTab, + onViewed: searchTabs.markTabViewed, } : null } diff --git a/src/client/features/backlinks/BacklinksPageContent.tsx b/src/client/features/backlinks/BacklinksPageContent.tsx index f37eb64..35c2fcc 100644 --- a/src/client/features/backlinks/BacklinksPageContent.tsx +++ b/src/client/features/backlinks/BacklinksPageContent.tsx @@ -52,6 +52,7 @@ type BacklinksBodyProps = { tabs: SearchTab[]; onSelect: (tab: SearchTab) => void; onClose: (tabId: string) => void; + onViewed: (tabId: string, when?: number) => void; } | null; }; @@ -105,10 +106,12 @@ export function BacklinksBody({ ); const tabStrip = searchTabs ? ( ) : null; diff --git a/src/client/features/backlinks/BacklinksSearchCard.tsx b/src/client/features/backlinks/BacklinksSearchCard.tsx index b025361..9f42205 100644 --- a/src/client/features/backlinks/BacklinksSearchCard.tsx +++ b/src/client/features/backlinks/BacklinksSearchCard.tsx @@ -50,14 +50,12 @@ export function BacklinksSearchCard({ canOpenSearch, errorMessage, initialValues, - isFetching, onSubmit, tabLimit, }: { canOpenSearch?: (values: SearchDraft) => boolean; errorMessage: string | null; initialValues: SearchDraft; - isFetching: boolean; onSubmit: (values: SearchDraft) => void; tabLimit?: number; }) { @@ -145,9 +143,9 @@ export function BacklinksSearchCard({ )} diff --git a/src/client/features/domain/DomainOverviewPage.tsx b/src/client/features/domain/DomainOverviewPage.tsx index c44dbba..0e7172a 100644 --- a/src/client/features/domain/DomainOverviewPage.tsx +++ b/src/client/features/domain/DomainOverviewPage.tsx @@ -522,10 +522,12 @@ export function DomainOverviewPage({
) : null; diff --git a/src/client/features/keywords/page/KeywordResearchPage.tsx b/src/client/features/keywords/page/KeywordResearchPage.tsx index 0176630..5f838c8 100644 --- a/src/client/features/keywords/page/KeywordResearchPage.tsx +++ b/src/client/features/keywords/page/KeywordResearchPage.tsx @@ -1,15 +1,8 @@ import { Link } from "@tanstack/react-router"; -import { useQuery } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo } from "react"; import { AlertCircle, ArrowLeft } from "lucide-react"; import { getErrorCode } from "@/client/lib/error-messages"; import { BILLING_ROUTE } from "@/shared/billing"; -import { - KEYWORD_RESEARCH_STALE_TIME_MS, - buildKeywordResearchQueryKey, - buildKeywordResearchRequest, - keywordResearchQueryFn, -} from "@/client/features/keywords/hooks/useKeywordResearchData"; import { useKeywordResearchController } from "@/client/features/keywords/state/useKeywordResearchController"; import type { KeywordResearchControllerInput } from "@/client/features/keywords/state/useKeywordResearchController"; import type { KeywordControlsValues } from "@/client/features/keywords/hooks/useKeywordControlsForm"; @@ -20,12 +13,12 @@ import type { KeywordSearchTabInput, SearchTab, } from "@/client/features/search-tabs/types"; +import { SearchTabStrip } from "@/client/features/search-tabs/SearchTabStrip"; import { useSearchTabNavigation } from "@/client/features/search-tabs/useSearchTabNavigation"; import { KeywordResearchEmptyState } from "./KeywordResearchEmptyState"; import { KeywordResearchLoadingState } from "./KeywordResearchLoadingState"; import { KeywordResearchResults } from "./KeywordResearchResults"; import { KeywordResearchSearchBar } from "./KeywordResearchSearchBar"; -import { KeywordResearchTabStrip } from "./KeywordResearchTabStrip"; import type { KeywordResearchControllerState } from "./types"; type Props = Omit; @@ -188,50 +181,6 @@ export function KeywordResearchPage(input: Props) { })); }, [controller.controlsForm, searchTabs.tabs]); - // Mark the active tab as viewed once its data lands. Reads cache state via - // the same query key the controller uses, so this catches both fresh fetches - // and warm-cache loads on tab switch. - const activeRequest = useMemo( - () => - activeTab - ? buildKeywordResearchRequest({ - projectId, - keywordInput: activeTab.input.keyword, - locationCode: activeTab.input.locationCode, - resultLimit: activeTab.input.resultLimit, - mode: activeTab.input.mode, - }) - : null, - [activeTab, projectId], - ); - const activeTabQuery = useQuery({ - queryKey: buildKeywordResearchQueryKey(activeRequest), - queryFn: () => { - if (!activeRequest) throw new Error("Active tab missing request"); - return keywordResearchQueryFn(activeRequest); - }, - enabled: false, - staleTime: KEYWORD_RESEARCH_STALE_TIME_MS, - gcTime: KEYWORD_RESEARCH_STALE_TIME_MS, - }); - - const markTabViewed = searchTabs.markTabViewed; - useEffect(() => { - if (!activeTab) return; - if (!activeTabQuery.isSuccess) return; - const dataUpdatedAt = activeTabQuery.dataUpdatedAt; - if (dataUpdatedAt <= 0) return; - if (activeTab.viewedAt !== null && activeTab.viewedAt >= dataUpdatedAt) { - return; - } - markTabViewed(activeTab.id, dataUpdatedAt); - }, [ - activeTab, - activeTabQuery.dataUpdatedAt, - activeTabQuery.isSuccess, - markTabViewed, - ]); - return (
@@ -254,12 +203,13 @@ export function KeywordResearchPage(input: Props) { Recent searches -
) : null} diff --git a/src/client/features/keywords/page/KeywordResearchTabStrip.tsx b/src/client/features/keywords/page/KeywordResearchTabStrip.tsx deleted file mode 100644 index 0adb5b5..0000000 --- a/src/client/features/keywords/page/KeywordResearchTabStrip.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { memo } from "react"; -import { SearchTabStrip } from "@/client/features/search-tabs/SearchTabStrip"; -import type { SearchTab } from "@/client/features/search-tabs/types"; -import { - KEYWORD_RESEARCH_STALE_TIME_MS, - buildKeywordResearchQueryKey, - buildKeywordResearchRequest, - keywordResearchQueryFn, -} from "@/client/features/keywords/hooks/useKeywordResearchData"; - -type Props = { - projectId: string; - tabs: SearchTab[]; - activeTabId: string | null; - onSelect: (tab: SearchTab) => void; - onClose: (tabId: string) => void; -}; - -export function KeywordResearchTabStrip({ - projectId, - tabs, - activeTabId, - onSelect, - onClose, -}: Props) { - if (tabs.length === 0) return null; - - return ( - ( - - )} - /> - ); -} - -const KeywordTabStatus = memo(function KeywordTabStatus({ - tab, - projectId, - active, -}: { - tab: SearchTab; - projectId: string; - active: boolean; -}) { - if (tab.input.type !== "keyword") return null; - - const request = buildKeywordResearchRequest({ - projectId, - keywordInput: tab.input.keyword, - locationCode: tab.input.locationCode, - resultLimit: tab.input.resultLimit, - mode: tab.input.mode, - }); - const queryKey = buildKeywordResearchQueryKey(request); - - // enabled: false — observer only. The active tab's controller owns fetching. - const query = useQuery({ - queryKey, - queryFn: () => { - if (!request) throw new Error("Tab is missing a research request"); - return keywordResearchQueryFn(request); - }, - enabled: false, - select: () => null, - notifyOnChangeProps: ["dataUpdatedAt", "errorUpdatedAt"], - staleTime: KEYWORD_RESEARCH_STALE_TIME_MS, - gcTime: KEYWORD_RESEARCH_STALE_TIME_MS, - }); - - const hasResult = query.dataUpdatedAt > 0; - const unviewed = - !active && - hasResult && - (tab.viewedAt === null || tab.viewedAt < query.dataUpdatedAt); - const isError = query.isError; - - return ( - - {isError ? ( - - ) : unviewed ? ( - - ) : null} - - ); -}); diff --git a/src/client/features/search-tabs/SearchTabStrip.tsx b/src/client/features/search-tabs/SearchTabStrip.tsx index 08a98a4..16e1695 100644 --- a/src/client/features/search-tabs/SearchTabStrip.tsx +++ b/src/client/features/search-tabs/SearchTabStrip.tsx @@ -1,22 +1,42 @@ -import { X } from "lucide-react"; -import type { ReactNode } from "react"; +import { useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { QueryKey } from "@tanstack/react-query"; +import { Loader2, X } from "lucide-react"; import type { SearchTab } from "./types"; +import { + KEYWORD_RESEARCH_STALE_TIME_MS, + buildKeywordResearchQueryKey, + buildKeywordResearchRequest, + keywordResearchQueryFn, +} from "@/client/features/keywords/hooks/useKeywordResearchData"; +import { getLanguageCode } from "@/client/features/keywords/locations"; +import { getBacklinksOverview } from "@/serverFunctions/backlinks"; +import { getDomainOverview } from "@/serverFunctions/domain"; export type { SearchTab } from "./types"; type Props = { activeTabId: string | null; + projectId: string; tabs: SearchTab[]; onSelect: (tab: SearchTab) => void; onClose: (tabId: string) => void; - renderLeading?: (tab: SearchTab, active: boolean) => ReactNode; + onViewed: (tabId: string, when?: number) => void; +}; + +type SearchTabQueryConfig = { + queryKey: QueryKey; + queryFn: () => Promise; + staleTime?: number; + gcTime?: number; }; export function SearchTabStrip({ activeTabId, + projectId, tabs, onSelect, onClose, - renderLeading, + onViewed, }: Props) { if (tabs.length === 0) return null; @@ -46,7 +66,12 @@ export function SearchTabStrip({ className="flex min-w-0 items-center gap-1.5 px-2.5 py-1.5 text-left" onClick={() => onSelect(tab)} > - {renderLeading ? renderLeading(tab, active) : null} + ); } + +function SearchTabStatus({ + tab, + projectId, + active, + onViewed, +}: { + tab: SearchTab; + projectId: string; + active: boolean; + onViewed: (tabId: string, when?: number) => void; +}) { + const config = getSearchTabQueryConfig(projectId, tab); + const query = useQuery({ + queryKey: config.queryKey, + queryFn: config.queryFn, + enabled: false, + select: () => null, + notifyOnChangeProps: ["dataUpdatedAt", "fetchStatus", "status"], + staleTime: config.staleTime, + gcTime: config.gcTime, + }); + + const isLoading = query.fetchStatus === "fetching"; + const hasResult = query.dataUpdatedAt > 0; + const hasError = query.status === "error"; + const unviewed = + !active && + hasResult && + (tab.viewedAt === null || tab.viewedAt < query.dataUpdatedAt); + + useEffect(() => { + if (!active) return; + if (!hasResult) return; + if (tab.viewedAt !== null && tab.viewedAt >= query.dataUpdatedAt) return; + onViewed(tab.id, query.dataUpdatedAt); + }, [active, hasResult, onViewed, query.dataUpdatedAt, tab.id, tab.viewedAt]); + + const status = isLoading + ? "loading" + : hasError + ? "error" + : unviewed + ? "unviewed" + : "idle"; + + return ; +} + +function SearchTabStatusIndicator({ + status, +}: { + status: "idle" | "loading" | "unviewed" | "error"; +}) { + return ( + + {status === "loading" ? ( + + ) : status === "error" ? ( + + ) : status === "unviewed" ? ( + + ) : null} + + ); +} + +function getSearchTabQueryConfig( + projectId: string, + tab: SearchTab, +): SearchTabQueryConfig { + if (tab.input.type === "backlinks") { + const input = tab.input; + return { + queryKey: ["backlinksOverview", projectId, input.scope, input.target], + queryFn: () => + getBacklinksOverview({ + data: { + projectId, + target: input.target, + scope: input.scope, + hideSpam: false, + }, + }), + }; + } + + if (tab.input.type === "domain") { + const input = tab.input; + const trimmedDomain = input.domain.trim(); + const languageCode = getLanguageCode(input.locationCode); + + return { + queryKey: [ + "domain-overview", + projectId, + trimmedDomain, + input.subdomains, + input.locationCode, + languageCode, + ], + queryFn: () => + getDomainOverview({ + data: { + projectId, + domain: trimmedDomain, + includeSubdomains: input.subdomains, + locationCode: input.locationCode, + languageCode, + }, + }), + staleTime: 5 * 60_000, + }; + } + + const input = tab.input; + const request = buildKeywordResearchRequest({ + projectId, + keywordInput: input.keyword, + locationCode: input.locationCode, + resultLimit: input.resultLimit, + mode: input.mode, + }); + + return { + queryKey: buildKeywordResearchQueryKey(request), + queryFn: () => { + if (!request) throw new Error("Tab is missing a research request"); + return keywordResearchQueryFn(request); + }, + staleTime: KEYWORD_RESEARCH_STALE_TIME_MS, + gcTime: KEYWORD_RESEARCH_STALE_TIME_MS, + }; +}