metatron-open-seo/src/client/features/keywords/hooks/useKeywordSerpAnalysis.ts

142 lines
4.9 KiB
TypeScript

import { useQuery } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { getSerpAnalysis } from "@/serverFunctions/keywords";
const SERP_PAGE_SIZE = 10;
/** Depth every SERP panel opens at — two pages of results, ~5 credits. */
const SERP_INITIAL_DEPTH = 20;
/**
* Depth we buy when a user pages past what's loaded. Google has no offset, so
* this re-crawls the top of the SERP: the deeper snapshot replaces the shallow
* one instead of extending it, and costs ~20 more credits.
*/
const SERP_DEEP_DEPTH = 100;
export function useKeywordSerpAnalysis(
projectId: string,
locationCode: number | undefined,
) {
const [serpKeyword, setSerpKeywordState] = useState<string | null>(null);
const [serpPage, setSerpPageState] = useState(0);
const [requestedDepth, setRequestedDepth] = useState<20 | 100>(
SERP_INITIAL_DEPTH,
);
// Everything but the depth identifies the snapshot; the depth decides how
// deep it goes.
const snapshotKey = ["serpAnalysis", projectId, serpKeyword, locationCode];
const serpQuery = useQuery({
queryKey: [...snapshotKey, requestedDepth],
queryFn: () =>
getSerpAnalysis({
data: {
projectId,
keyword: serpKeyword!,
locationCode,
depth: requestedDepth,
},
}),
// Keep the shallow snapshot on screen while the deeper refetch runs — but
// only when nothing except the depth changed, so a new keyword or market
// shows the loading state instead of the previous SERP.
placeholderData: (previous, previousQuery) => {
const previousKey = previousQuery?.queryKey;
const sameSnapshot = snapshotKey.every(
(part, index) => previousKey?.[index] === part,
);
return sameSnapshot ? previous : undefined;
},
// Every fetch here is billed, and a deep crawl can outrun DataForSEO's
// request deadline — a timed-out call may already be billed upstream while
// metering nothing. Never replay or re-issue one without the user asking;
// the card offers an explicit retry.
retry: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
enabled: !!serpKeyword,
// Every attempt is a fresh billed DataForSEO task, so a failure must not be
// retried automatically — the user re-runs the search instead.
retry: false,
});
const { refetch: refetchSerp } = serpQuery;
const serpResults = serpQuery.data?.items ?? [];
const activeSerpKeyword =
serpKeyword ?? serpQuery.data?.requestedKeyword ?? null;
const serpLoading = !!serpKeyword && serpQuery.isLoading;
const serpError = serpQuery.isError
? getStandardErrorMessage(serpQuery.error, "Failed to load SERP data.")
: null;
// A deeper fetch is available until the loaded snapshot is the deep one.
const canLoadMoreSerp =
(serpQuery.data?.depth ?? SERP_DEEP_DEPTH) < SERP_DEEP_DEPTH;
const serpLoadingMore = serpQuery.isPlaceholderData;
// A failed deep fetch leaves nothing on screen, and retrying it re-buys the
// expensive call. The card offers the shallow snapshot back instead.
const deepFetchFailed =
serpQuery.isError && requestedDepth === SERP_DEEP_DEPTH;
// Retrying a failed deep crawl re-buys the expensive call that just timed
// out. Drop back to the shallow snapshot instead: it's still cached, so the
// user gets their results back for free and can ask for 100 again.
const retrySerp = useCallback(() => {
if (requestedDepth === SERP_DEEP_DEPTH) {
setRequestedDepth(SERP_INITIAL_DEPTH);
return;
}
void refetchSerp();
}, [refetchSerp, requestedDepth]);
const setSerpKeyword = useCallback((keyword: string | null) => {
setSerpKeywordState(keyword);
setRequestedDepth(SERP_INITIAL_DEPTH);
}, []);
const loadedPages = Math.max(
1,
Math.ceil(serpResults.length / SERP_PAGE_SIZE),
);
// While the deeper snapshot loads the user stays on the page they asked for;
// once it lands, a SERP too small to fill that page falls back to the last.
const visiblePage = serpLoadingMore
? serpPage
: Math.min(serpPage, loadedPages - 1);
const setSerpPage = useCallback(
(nextPage: number) => {
// Paging forward past the loaded results buys the full 100-deep snapshot;
// the user lands on the requested page once it arrives.
if (
nextPage > visiblePage &&
nextPage >= loadedPages &&
canLoadMoreSerp
) {
setRequestedDepth(SERP_DEEP_DEPTH);
}
setSerpPageState(nextPage);
},
[canLoadMoreSerp, loadedPages, visiblePage],
);
return {
serpKeyword,
setSerpKeyword,
retrySerp,
serpPage: visiblePage,
setSerpPage,
SERP_PAGE_SIZE,
serpQuery,
serpResults,
activeSerpKeyword,
serpLoading,
serpLoadingMore,
canLoadMoreSerp,
deepFetchFailed,
serpError,
};
}