diff --git a/src/client/features/domain/DomainOverviewPage.tsx b/src/client/features/domain/DomainOverviewPage.tsx index 431bc3b..50bd9c5 100644 --- a/src/client/features/domain/DomainOverviewPage.tsx +++ b/src/client/features/domain/DomainOverviewPage.tsx @@ -12,6 +12,7 @@ import { } from "@/client/features/domain/utils"; import type { DomainActiveTab, + DomainFilterValues, DomainSortMode, SortOrder, } from "@/client/features/domain/types"; @@ -26,6 +27,9 @@ type Props = { tab: DomainActiveTab; search: string; locationCode: number; + page: number; + pageSize: number; + appliedFilters: DomainFilterValues; }; navigate: (args: { search: (prev: Record) => Record; @@ -47,10 +51,6 @@ export function DomainOverviewPage({ navigate, searchState, }); - const handleShowRecentSearches = () => { - state.resetView(); - onShowRecentSearches(); - }; return (
@@ -92,7 +92,7 @@ export function DomainOverviewPage({
+ + {overLimit ? ( +
+ + + Too many filter conditions ({conditionCount} of{" "} + {MAX_DATAFORSEO_FILTER_CONDITIONS} max). Remove some terms or ranges + before applying. + +
+ ) : null} + +
+ + {conditionCount} / {MAX_DATAFORSEO_FILTER_CONDITIONS} conditions + +
+ + +
+
); } diff --git a/src/client/features/domain/components/DomainKeywordsPagination.tsx b/src/client/features/domain/components/DomainKeywordsPagination.tsx new file mode 100644 index 0000000..9451aaf --- /dev/null +++ b/src/client/features/domain/components/DomainKeywordsPagination.tsx @@ -0,0 +1,96 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { DOMAIN_KEYWORDS_PAGE_SIZES } from "@/types/schemas/domain"; + +type Props = { + page: number; + pageSize: number; + totalCount: number | null; + hasNextPage: boolean; + isLoading: boolean; + onPageChange: (nextPage: number) => void; + onPageSizeChange: (nextPageSize: number) => void; +}; + +function formatRange( + page: number, + pageSize: number, + totalCount: number | null, +) { + const start = (page - 1) * pageSize + 1; + if (totalCount == null) { + return `${start.toLocaleString()}–${(start + pageSize - 1).toLocaleString()}`; + } + if (totalCount === 0) return "0"; + const end = Math.min(totalCount, start + pageSize - 1); + return `${start.toLocaleString()}–${end.toLocaleString()} of ${totalCount.toLocaleString()}`; +} + +export function DomainKeywordsPagination({ + page, + pageSize, + totalCount, + hasNextPage, + isLoading, + onPageChange, + onPageSizeChange, +}: Props) { + const totalPages = + totalCount != null ? Math.max(1, Math.ceil(totalCount / pageSize)) : null; + const canGoPrev = page > 1; + const canGoNext = totalPages != null ? page < totalPages : hasNextPage; + + return ( +
+
+ {formatRange(page, pageSize, totalCount)} + {isLoading ? ( + + ) : null} +
+ +
+ + +
+ + Page {page.toLocaleString()} + {totalPages != null ? ` of ${totalPages.toLocaleString()}` : ""} + +
+ + +
+
+
+
+ ); +} diff --git a/src/client/features/domain/components/DomainKeywordsTable.tsx b/src/client/features/domain/components/DomainKeywordsTable.tsx index 600ecf6..a19ea33 100644 --- a/src/client/features/domain/components/DomainKeywordsTable.tsx +++ b/src/client/features/domain/components/DomainKeywordsTable.tsx @@ -112,7 +112,7 @@ export function DomainKeywordsTable({ ) : ( - rows.slice(0, 100).map((row) => { + rows.map((row) => { const href = resolveDomainPageHref( row.relativeUrl ?? row.url, domain, diff --git a/src/client/features/domain/components/DomainPagesTable.tsx b/src/client/features/domain/components/DomainPagesTable.tsx index bf169f4..e021325 100644 --- a/src/client/features/domain/components/DomainPagesTable.tsx +++ b/src/client/features/domain/components/DomainPagesTable.tsx @@ -44,7 +44,7 @@ export function DomainPagesTable({ onSortClick("volume")} /> diff --git a/src/client/features/domain/components/DomainResultsCard.tsx b/src/client/features/domain/components/DomainResultsCard.tsx index f474aed..6854c98 100644 --- a/src/client/features/domain/components/DomainResultsCard.tsx +++ b/src/client/features/domain/components/DomainResultsCard.tsx @@ -12,6 +12,7 @@ import { } from "lucide-react"; import { toast } from "sonner"; import { DomainFilterPanel } from "@/client/features/domain/components/DomainFilterPanel"; +import { DomainKeywordsPagination } from "@/client/features/domain/components/DomainKeywordsPagination"; import { DomainKeywordsTable } from "@/client/features/domain/components/DomainKeywordsTable"; import { DomainPagesTable } from "@/client/features/domain/components/DomainPagesTable"; import type { useDomainFilters } from "@/client/features/domain/hooks/useDomainFilters"; @@ -38,22 +39,37 @@ type Props = { activeTab: DomainActiveTab; sortMode: DomainSortMode; currentSortOrder: SortOrder; - pendingSearch: string; + searchDraft: string; selectedKeywords: Set; visibleKeywords: string[]; filteredKeywords: KeywordRow[]; - filteredPages: PageRow[]; + pagedPages: PageRow[]; showFilters: boolean; setShowFilters: Dispatch>; filtersForm: ReturnType["filtersForm"]; activeFilterCount: number; + dirtyFilterCount: number; + conditionCount: number; + overLimit: boolean; resetFilters: () => void; + applyFilters: () => void; + cancelFilterEdits: () => void; onSearchChange: (value: string) => void; onSaveKeywords: () => void; canSaveKeywords: boolean; onSortClick: (sort: DomainSortMode) => void; onToggleKeyword: (keyword: string) => void; onToggleAllVisible: () => void; + page: number; + pageSize: number; + totalKeywordCount: number | null; + totalPagesCount: number | null; + hasNextKeywordsPage: boolean; + hasNextPagesPage: boolean; + isKeywordsLoading: boolean; + isPagesLoading: boolean; + onPageChange: (nextPage: number) => void; + onPageSizeChange: (nextSize: number) => void; }; const KEYWORDS_ONLY_SORTS: ReadonlySet = new Set([ @@ -68,28 +84,43 @@ export function DomainResultsCard({ activeTab, sortMode, currentSortOrder, - pendingSearch, + searchDraft, selectedKeywords, visibleKeywords, filteredKeywords, - filteredPages, + pagedPages, showFilters, setShowFilters, filtersForm, activeFilterCount, + dirtyFilterCount, + conditionCount, + overLimit, resetFilters, + applyFilters, + cancelFilterEdits, onSearchChange, onSaveKeywords, canSaveKeywords, onSortClick, onToggleKeyword, onToggleAllVisible, + page, + pageSize, + totalKeywordCount, + totalPagesCount, + hasNextKeywordsPage, + hasNextPagesPage, + isKeywordsLoading, + isPagesLoading, + onPageChange, + onPageSizeChange, }: Props) { const isKeywordsTab = activeTab === "keywords"; - const currentRows = isKeywordsTab ? filteredKeywords : filteredPages; + const currentRows = isKeywordsTab ? filteredKeywords : pagedPages; const exportTable = isKeywordsTab ? keywordsToTable(filteredKeywords) - : pagesToTable(filteredPages); + : pagesToTable(pagedPages); const handleCopy = async () => { const text = JSON.stringify(currentRows, null, 2); @@ -127,7 +158,7 @@ export function DomainResultsCard({ from="/p/$projectId/domain" to="/p/$projectId/domain" params={{ projectId }} - search={(prev) => ({ ...prev, tab: undefined })} + search={(prev) => ({ ...prev, tab: undefined, page: undefined })} replace role="tab" className={`tab ${activeTab === "keywords" ? "tab-active" : ""}`} @@ -149,6 +180,7 @@ export function DomainResultsCard({ tab: "pages" as const, sort: nextSort, order: nextOrder, + page: undefined, }; }} replace @@ -213,69 +245,112 @@ export function DomainResultsCard({ - {isKeywordsTab ? ( - <> -
- - - {filteredKeywords.length} keywords - -
- -
- - {showFilters ? ( - + {isKeywordsTab ? ( + + ) : null} + + {isKeywordsTab + ? totalKeywordCount != null + ? `${totalKeywordCount.toLocaleString()} keywords` + : `${filteredKeywords.length.toLocaleString()} keywords` + : totalPagesCount != null + ? `${totalPagesCount.toLocaleString()} pages` + : `${pagedPages.length.toLocaleString()} pages`} + +
+
{ + event.preventDefault(); + if (!overLimit) applyFilters(); + }} + > + +
+
+ + {isKeywordsTab && showFilters ? ( + ) : null}
{isKeywordsTab ? ( - +
+ +
) : ( - +
+ +
)}
+ +
); } diff --git a/src/client/features/domain/domainActions.ts b/src/client/features/domain/domainActions.ts index 655d9c6..c00f683 100644 --- a/src/client/features/domain/domainActions.ts +++ b/src/client/features/domain/domainActions.ts @@ -1,7 +1,7 @@ import { toast } from "sonner"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { captureClientEvent } from "@/client/lib/posthog"; -import type { DomainOverviewData } from "@/client/features/domain/types"; +import type { KeywordRow } from "@/client/features/domain/types"; type SaveMutation = (payload: { projectId: string; @@ -30,7 +30,7 @@ export function saveSelectedKeywords({ languageCode, }: { selectedKeywords: Set; - filteredKeywords: DomainOverviewData["keywords"]; + filteredKeywords: KeywordRow[]; save: (payload: Parameters[0], opts?: SaveOptions) => void; projectId: string; locationCode: number; diff --git a/src/client/features/domain/domainFiltering.ts b/src/client/features/domain/domainFiltering.ts deleted file mode 100644 index 84a5370..0000000 --- a/src/client/features/domain/domainFiltering.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { sortBy } from "remeda"; -import { sortableNullableNumber } from "@/client/features/domain/utils"; -import type { - DomainFilterValues, - DomainSortMode, - KeywordRow, - SortOrder, -} from "@/client/features/domain/types"; - -function parseTerms(value: string): string[] { - return value - .toLowerCase() - .split(/[,+]/) - .map((term) => term.trim()) - .filter(Boolean); -} - -function passesNumericFilter( - value: number | null | undefined, - min: string, - max: string, -): boolean { - const v = value ?? 0; - if (min && v < Number(min)) return false; - if (max && v > Number(max)) return false; - return true; -} - -export function filterAndSortKeywords(params: { - keywords: KeywordRow[]; - pendingSearch: string; - filters: DomainFilterValues; - sortMode: DomainSortMode; - currentSortOrder: SortOrder; -}): KeywordRow[] { - const { keywords, pendingSearch, filters, sortMode, currentSortOrder } = - params; - const includeTerms = parseTerms(filters.include); - const excludeTerms = parseTerms(filters.exclude); - - const filtered = keywords.filter((row) => { - const haystack = `${row.keyword} ${row.relativeUrl ?? ""}`.toLowerCase(); - - if ( - pendingSearch && - !haystack.includes(pendingSearch.toLowerCase().trim()) - ) { - return false; - } - - if ( - includeTerms.length > 0 && - !includeTerms.every((term) => haystack.includes(term)) - ) { - return false; - } - if (excludeTerms.some((term) => haystack.includes(term))) { - return false; - } - - if ( - !passesNumericFilter(row.traffic, filters.minTraffic, filters.maxTraffic) - ) - return false; - if (!passesNumericFilter(row.searchVolume, filters.minVol, filters.maxVol)) - return false; - if (!passesNumericFilter(row.cpc, filters.minCpc, filters.maxCpc)) - return false; - if ( - !passesNumericFilter(row.keywordDifficulty, filters.minKd, filters.maxKd) - ) - return false; - if (!passesNumericFilter(row.position, filters.minRank, filters.maxRank)) - return false; - - return true; - }); - - if (sortMode === "traffic") { - return sortBy(filtered, [ - (row) => sortableNullableNumber(row.traffic, currentSortOrder), - currentSortOrder, - ]); - } - - if (sortMode === "volume") { - return sortBy(filtered, [ - (row) => sortableNullableNumber(row.searchVolume, currentSortOrder), - currentSortOrder, - ]); - } - - if (sortMode === "score") { - return sortBy(filtered, [ - (row) => sortableNullableNumber(row.keywordDifficulty, currentSortOrder), - currentSortOrder, - ]); - } - - if (sortMode === "cpc") { - return sortBy(filtered, [ - (row) => sortableNullableNumber(row.cpc, currentSortOrder), - currentSortOrder, - ]); - } - - return sortBy(filtered, [ - (row) => sortableNullableNumber(row.position, currentSortOrder), - currentSortOrder, - ]); -} diff --git a/src/client/features/domain/domainOverviewControllerInternals.ts b/src/client/features/domain/domainOverviewControllerInternals.ts index 4a718b6..c075823 100644 --- a/src/client/features/domain/domainOverviewControllerInternals.ts +++ b/src/client/features/domain/domainOverviewControllerInternals.ts @@ -1,35 +1,18 @@ import { useEffect, useMemo, type Dispatch, type SetStateAction } from "react"; import type { UpdateMetaOptions } from "@tanstack/react-form"; -import { useMutation } from "@tanstack/react-query"; -import { sortBy } from "remeda"; -import { toast } from "sonner"; -import { getDomainOverview } from "@/serverFunctions/domain"; -import { getStandardErrorMessage } from "@/client/lib/error-messages"; -import { captureClientEvent } from "@/client/lib/posthog"; -import { filterAndSortKeywords } from "@/client/features/domain/domainFiltering"; import { getDefaultSortOrder, - normalizeDomainTarget, - sortableNullableNumber, - toPageSortMode, toSortMode, toSortOrder, - toSortOrderSearchParam, - toSortSearchParam, } from "@/client/features/domain/utils"; import type { DomainActiveTab, DomainFilterValues, - DomainOverviewData, DomainSortMode, + KeywordRow, SortOrder, } from "@/client/features/domain/types"; -import type { DomainSearchHistoryItem } from "@/client/hooks/useDomainSearchHistory"; -import { - DEFAULT_LOCATION_CODE, - getLanguageCode, - isSupportedLocationCode, -} from "@/client/features/keywords/locations"; +import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations"; export type SearchState = { domain: string; @@ -39,6 +22,9 @@ export type SearchState = { tab: DomainActiveTab; search: string; locationCode: number; + page: number; + pageSize: number; + appliedFilters: DomainFilterValues; }; type DomainNavigate = (args: { @@ -71,57 +57,20 @@ type DomainControlsFormAccess = { type ControlsFormLike = DomainControlsFormAccess; export function useOverviewDataState({ - overview, - pendingSearch, - filters, - sortMode, - currentSortOrder, + pagedKeywords, setSelectedKeywords, + activeFilterCount, }: { - overview: DomainOverviewData | null; - pendingSearch: string; - filters: DomainFilterValues; - sortMode: DomainSortMode; - currentSortOrder: SortOrder; + pagedKeywords: KeywordRow[]; setSelectedKeywords: Dispatch>>; + activeFilterCount: number; }) { - const filteredKeywords = useMemo( - () => - filterAndSortKeywords({ - keywords: overview?.keywords ?? [], - pendingSearch, - filters, - sortMode, - currentSortOrder, - }), - [currentSortOrder, filters, overview?.keywords, pendingSearch, sortMode], - ); - - const filteredPages = useMemo(() => { - const source = overview?.pages ?? []; - const filtered = !pendingSearch - ? source - : source.filter((row) => { - const text = `${row.relativePath ?? ""} ${row.page}`.toLowerCase(); - return text.includes(pendingSearch.toLowerCase().trim()); - }); - - const pageSortMode = toPageSortMode(sortMode); - if (pageSortMode === "volume") { - return sortBy(filtered, [ - (row) => sortableNullableNumber(row.keywords, currentSortOrder), - currentSortOrder, - ]); - } - - return sortBy(filtered, [ - (row) => sortableNullableNumber(row.organicTraffic, currentSortOrder), - currentSortOrder, - ]); - }, [currentSortOrder, overview?.pages, pendingSearch, sortMode]); + // Keywords are now fetched server-side with filters/sort/pagination applied, + // so we render whatever the page query returned. + const filteredKeywords = pagedKeywords; const visibleKeywords = useMemo( - () => filteredKeywords.slice(0, 100).map((row) => row.keyword), + () => filteredKeywords.map((row) => row.keyword), [filteredKeywords], ); @@ -136,14 +85,8 @@ export function useOverviewDataState({ }); }, [setSelectedKeywords, visibleKeywords]); - const activeFilterCount = useMemo( - () => Object.values(filters).filter((value) => value.trim() !== "").length, - [filters], - ); - return { filteredKeywords, - filteredPages, visibleKeywords, activeFilterCount, toggleKeywordSelection: (keyword: string) => { @@ -172,12 +115,10 @@ export function useOverviewDataState({ export function useSyncRouteState({ controlsForm, searchState, - setPendingSearch, navigate, }: { controlsForm: ControlsFormLike; searchState: SearchState; - setPendingSearch: (value: string) => void; navigate: DomainNavigate; }) { useEffect(() => { @@ -187,8 +128,7 @@ export function useSyncRouteState({ sort: searchState.sort, locationCode: searchState.locationCode, }); - setPendingSearch(searchState.search); - }, [controlsForm, searchState, setPendingSearch]); + }, [controlsForm, searchState]); useEffect(() => { const raw = new URLSearchParams(window.location.search); @@ -232,118 +172,3 @@ export function useSyncRouteState({ }); }, [navigate]); } - -export function useDomainLookupMutation(projectId: string) { - return useMutation({ - mutationFn: (data: { - domain: string; - includeSubdomains: boolean; - locationCode: number; - languageCode: string; - }) => - getDomainOverview({ - data: { - ...data, - projectId, - }, - }), - }); -} - -export function useSearchRunner({ - controlsForm, - setPendingSearch, - setSearchParams, - domainMutation, - addSearch, - setOverview, - setSelectedKeywords, - currentState, - currentSortOrder, -}: { - controlsForm: ControlsFormLike; - setPendingSearch: (value: string) => void; - setSearchParams: ( - updates: Record, - ) => void; - domainMutation: ReturnType; - addSearch: (item: Omit) => void; - setOverview: (value: DomainOverviewData, locationCode: number) => void; - setSelectedKeywords: Dispatch>>; - currentState: SearchState; - currentSortOrder: SortOrder; -}) { - return async (params?: Partial) => { - const values = controlsForm.state.values; - const rawTarget = params?.domain ?? values.domain; - const activeSubdomains = params?.subdomains ?? values.subdomains; - const activeSort = params?.sort ?? currentState.sort; - const activeOrder = params?.order ?? currentSortOrder; - const activeTab = params?.tab ?? currentState.tab; - const activeSearch = params?.search ?? currentState.search; - const rawLocationCode = - params?.locationCode ?? values.locationCode ?? currentState.locationCode; - const activeLocationCode = isSupportedLocationCode(rawLocationCode) - ? rawLocationCode - : DEFAULT_LOCATION_CODE; - const activeLanguageCode = getLanguageCode(activeLocationCode); - const target = normalizeDomainTarget(rawTarget); - - if (!target) { - return; - } - - setPendingSearch(activeSearch); - controlsForm.setFieldValue("domain", target); - controlsForm.setFieldValue("subdomains", activeSubdomains); - controlsForm.setFieldValue("sort", activeSort); - controlsForm.setFieldValue("locationCode", activeLocationCode); - - setSearchParams({ - domain: target, - subdomains: activeSubdomains ? undefined : activeSubdomains, - sort: toSortSearchParam(activeSort), - order: toSortOrderSearchParam(activeSort, activeOrder), - tab: activeTab === "keywords" ? undefined : activeTab, - search: activeSearch.trim() || undefined, - loc: - activeLocationCode === DEFAULT_LOCATION_CODE - ? undefined - : activeLocationCode, - }); - - try { - const response = await domainMutation.mutateAsync({ - domain: target, - includeSubdomains: activeSubdomains, - locationCode: activeLocationCode, - languageCode: activeLanguageCode, - }); - - captureClientEvent("domain_overview:search_complete", { - sort_mode: activeSort, - include_subdomains: activeSubdomains, - result_count: response.keywords.length, - location_code: activeLocationCode, - }); - - setOverview(response, activeLocationCode); - setSelectedKeywords(new Set()); - addSearch({ - domain: target, - subdomains: activeSubdomains, - sort: activeSort, - tab: activeTab, - search: activeSearch.trim() || undefined, - locationCode: activeLocationCode, - }); - - if (!response.hasData) { - toast.info("Not enough data for this domain"); - } - return null; - } catch (error) { - return getStandardErrorMessage(error, "Lookup failed."); - } - }; -} diff --git a/src/client/features/domain/domainSearchValidation.ts b/src/client/features/domain/domainSearchValidation.ts new file mode 100644 index 0000000..04257e1 --- /dev/null +++ b/src/client/features/domain/domainSearchValidation.ts @@ -0,0 +1,47 @@ +import { normalizeDomainTarget } from "@/client/features/domain/utils"; +import { createFormValidationErrors } from "@/client/lib/forms"; +import type { DomainControlsValues } from "@/client/features/domain/types"; + +export function getDomainSearchValidationErrors(value: DomainControlsValues) { + if (!value.domain.trim()) { + return createFormValidationErrors({ + fields: { + domain: "Please enter a domain", + }, + }); + } + + if (!normalizeDomainTarget(value.domain)) { + return createFormValidationErrors({ + fields: { + domain: "Please enter a valid URL or domain (e.g. browserbase.com)", + }, + }); + } + + return null; +} + +export function getDomainSearchChangeValidationErrors( + value: DomainControlsValues, + shouldValidateUntouchedField: boolean, + shouldValidateFormat: boolean, +) { + if (!value.domain.trim()) { + if (!shouldValidateUntouchedField) { + return null; + } + + return createFormValidationErrors({ + fields: { + domain: "Please enter a domain", + }, + }); + } + + if (!shouldValidateFormat) { + return null; + } + + return getDomainSearchValidationErrors(value); +} diff --git a/src/client/features/domain/hooks/useDomainFilters.ts b/src/client/features/domain/hooks/useDomainFilters.ts index 3d189f5..b886e22 100644 --- a/src/client/features/domain/hooks/useDomainFilters.ts +++ b/src/client/features/domain/hooks/useDomainFilters.ts @@ -1,40 +1,167 @@ -import { useCallback } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useForm, useStore } from "@tanstack/react-form"; import { EMPTY_DOMAIN_FILTERS, type DomainFilterValues, } from "@/client/features/domain/types"; +import { MAX_DATAFORSEO_FILTER_CONDITIONS } from "@/types/schemas/domain"; -export function useDomainFilters() { +const FILTER_KEYS: Array = [ + "include", + "exclude", + "minTraffic", + "maxTraffic", + "minVol", + "maxVol", + "minCpc", + "maxCpc", + "minKd", + "maxKd", + "minRank", + "maxRank", +]; + +function filtersToSearchParams( + values: DomainFilterValues, +): Record { + const out: Record = {}; + for (const key of FILTER_KEYS) { + const trimmed = values[key].trim(); + if (trimmed === "") { + out[key] = undefined; + continue; + } + if (key === "include" || key === "exclude") { + out[key] = trimmed; + } else { + const parsed = Number(trimmed); + out[key] = Number.isFinite(parsed) ? parsed : undefined; + } + } + return out; +} + +/** + * One include/exclude term per comma. Numeric ranges count as one per bound. + * Mirrors how `buildKeywordFilters` packs the request, plus 2 extra slots + * reserved for the search-box OR-clause when active. + */ +function countConditions( + values: DomainFilterValues, + hasSearch: boolean, +): number { + let n = 0; + for (const term of values.include.split(/[,+]/)) if (term.trim()) n += 1; + for (const term of values.exclude.split(/[,+]/)) if (term.trim()) n += 1; + for (const k of [ + "minTraffic", + "maxTraffic", + "minVol", + "maxVol", + "minCpc", + "maxCpc", + "minKd", + "maxKd", + "minRank", + "maxRank", + ] as const) { + if (values[k].trim() !== "") n += 1; + } + if (hasSearch) n += 2; + return n; +} + +export function useDomainFilters({ + appliedValues, + appliedSearch, + setSearchParams, +}: { + appliedValues: DomainFilterValues; + appliedSearch: string; + setSearchParams: ( + updates: Record, + ) => void; +}) { const filtersForm = useForm({ - defaultValues: EMPTY_DOMAIN_FILTERS, + defaultValues: appliedValues, }); - const values = useStore(filtersForm.store, (s) => s.values); + const draftValues = useStore(filtersForm.store, (s) => s.values); + const [searchDraft, setSearchDraft] = useState(appliedSearch); + + // Keep the draft in sync when applied values change from outside the panel + // (URL navigation, history-select, "back to recent searches"). Without this, + // the form keeps the previous draft and diverges from the URL. + const appliedKey = useMemo( + () => FILTER_KEYS.map((key) => appliedValues[key]).join("|"), + [appliedValues], + ); + useEffect(() => { + filtersForm.reset({ ...appliedValues }); + // appliedKey covers content changes; filtersForm is a stable ref. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [appliedKey]); + + useEffect(() => { + setSearchDraft(appliedSearch); + }, [appliedSearch]); + + const applyFilters = useCallback(() => { + const trimmedSearch = searchDraft.trim(); + setSearchParams({ + ...filtersToSearchParams(draftValues), + search: trimmedSearch === "" ? undefined : trimmedSearch, + page: undefined, + }); + }, [draftValues, searchDraft, setSearchParams]); + + const cancelEdits = useCallback(() => { + filtersForm.reset({ ...appliedValues }, { keepDefaultValues: true }); + setSearchDraft(appliedSearch); + }, [appliedValues, appliedSearch, filtersForm]); const resetFilters = useCallback(() => { - const keys: Array = [ - "include", - "exclude", - "minTraffic", - "maxTraffic", - "minVol", - "maxVol", - "minCpc", - "maxCpc", - "minKd", - "maxKd", - "minRank", - "maxRank", - ]; - for (const key of keys) { - filtersForm.setFieldValue(key, ""); - } - }, [filtersForm]); + filtersForm.reset({ ...EMPTY_DOMAIN_FILTERS }, { keepDefaultValues: true }); + setSearchDraft(""); + setSearchParams({ + ...filtersToSearchParams(EMPTY_DOMAIN_FILTERS), + search: undefined, + page: undefined, + }); + }, [filtersForm, setSearchParams]); + + const activeAppliedCount = useMemo( + () => + FILTER_KEYS.filter((key) => appliedValues[key].trim() !== "").length + + (appliedSearch.trim() !== "" ? 1 : 0), + [appliedValues, appliedSearch], + ); + const dirtyCount = useMemo(() => { + const filterDirt = FILTER_KEYS.filter( + (key) => draftValues[key].trim() !== appliedValues[key].trim(), + ).length; + const searchDirt = searchDraft.trim() !== appliedSearch.trim() ? 1 : 0; + return filterDirt + searchDirt; + }, [draftValues, appliedValues, searchDraft, appliedSearch]); + + const conditionCount = countConditions( + draftValues, + searchDraft.trim() !== "", + ); + const overLimit = conditionCount > MAX_DATAFORSEO_FILTER_CONDITIONS; return { filtersForm, - values, + draftValues, + appliedValues, + searchDraft, + setSearchDraft, + activeAppliedCount, + dirtyCount, + conditionCount, + overLimit, + applyFilters, + cancelEdits, resetFilters, }; } diff --git a/src/client/features/domain/hooks/useDomainKeywordsQuery.ts b/src/client/features/domain/hooks/useDomainKeywordsQuery.ts new file mode 100644 index 0000000..edc7f07 --- /dev/null +++ b/src/client/features/domain/hooks/useDomainKeywordsQuery.ts @@ -0,0 +1,93 @@ +import { useMemo } from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { getDomainKeywordsPage } from "@/serverFunctions/domain"; +import type { + DomainFilterValues, + DomainSortMode, + SortOrder, +} from "@/client/features/domain/types"; + +type DomainKeywordsQueryInput = { + projectId: string; + domain: string; + includeSubdomains: boolean; + locationCode: number; + languageCode: string; + page: number; + pageSize: number; + sortMode: DomainSortMode; + sortOrder: SortOrder; + appliedFilters: DomainFilterValues; + searchTerm: string; + enabled: boolean; +}; + +function toNumberOrUndefined(value: string): number | undefined { + const trimmed = value.trim(); + if (trimmed === "") return undefined; + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function toFiltersPayload( + filters: DomainFilterValues, +): Record { + return { + include: filters.include || undefined, + exclude: filters.exclude || undefined, + minTraffic: toNumberOrUndefined(filters.minTraffic), + maxTraffic: toNumberOrUndefined(filters.maxTraffic), + minVol: toNumberOrUndefined(filters.minVol), + maxVol: toNumberOrUndefined(filters.maxVol), + minCpc: toNumberOrUndefined(filters.minCpc), + maxCpc: toNumberOrUndefined(filters.maxCpc), + minKd: toNumberOrUndefined(filters.minKd), + maxKd: toNumberOrUndefined(filters.maxKd), + minRank: toNumberOrUndefined(filters.minRank), + maxRank: toNumberOrUndefined(filters.maxRank), + }; +} + +export function useDomainKeywordsQuery(input: DomainKeywordsQueryInput) { + const filtersPayload = useMemo( + () => toFiltersPayload(input.appliedFilters), + [input.appliedFilters], + ); + const trimmedSearch = input.searchTerm.trim(); + + return useQuery({ + enabled: input.enabled && Boolean(input.domain), + queryKey: [ + "domain-keywords", + input.projectId, + input.domain, + input.includeSubdomains, + input.locationCode, + input.languageCode, + input.page, + input.pageSize, + input.sortMode, + input.sortOrder, + filtersPayload, + trimmedSearch || undefined, + ], + queryFn: () => + getDomainKeywordsPage({ + data: { + projectId: input.projectId, + domain: input.domain, + includeSubdomains: input.includeSubdomains, + locationCode: input.locationCode, + languageCode: input.languageCode, + page: input.page, + pageSize: input.pageSize, + sortMode: input.sortMode, + sortOrder: input.sortOrder, + filters: filtersPayload, + search: trimmedSearch || undefined, + }, + }), + placeholderData: keepPreviousData, + staleTime: 60_000, + }); +} diff --git a/src/client/features/domain/hooks/useDomainOverviewQuery.ts b/src/client/features/domain/hooks/useDomainOverviewQuery.ts new file mode 100644 index 0000000..28a7abe --- /dev/null +++ b/src/client/features/domain/hooks/useDomainOverviewQuery.ts @@ -0,0 +1,37 @@ +import { useQuery } from "@tanstack/react-query"; +import { getDomainOverview } from "@/serverFunctions/domain"; + +type Input = { + projectId: string; + domain: string; + includeSubdomains: boolean; + locationCode: number; + languageCode: string; +}; + +export function useDomainOverviewQuery(input: Input) { + const trimmedDomain = input.domain.trim(); + + return useQuery({ + enabled: trimmedDomain !== "", + queryKey: [ + "domain-overview", + input.projectId, + trimmedDomain, + input.includeSubdomains, + input.locationCode, + input.languageCode, + ], + queryFn: () => + getDomainOverview({ + data: { + projectId: input.projectId, + domain: trimmedDomain, + includeSubdomains: input.includeSubdomains, + locationCode: input.locationCode, + languageCode: input.languageCode, + }, + }), + staleTime: 5 * 60_000, + }); +} diff --git a/src/client/features/domain/hooks/useDomainPagesQuery.ts b/src/client/features/domain/hooks/useDomainPagesQuery.ts new file mode 100644 index 0000000..8a0ebed --- /dev/null +++ b/src/client/features/domain/hooks/useDomainPagesQuery.ts @@ -0,0 +1,57 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { getDomainPagesPage } from "@/serverFunctions/domain"; +import { toPageSortMode } from "@/client/features/domain/utils"; +import type { DomainSortMode, SortOrder } from "@/client/features/domain/types"; + +type DomainPagesQueryInput = { + projectId: string; + domain: string; + includeSubdomains: boolean; + locationCode: number; + languageCode: string; + page: number; + pageSize: number; + sortMode: DomainSortMode; + sortOrder: SortOrder; + searchTerm: string; + enabled: boolean; +}; + +export function useDomainPagesQuery(input: DomainPagesQueryInput) { + const trimmedSearch = input.searchTerm.trim(); + const pageSortMode = toPageSortMode(input.sortMode); + + return useQuery({ + enabled: input.enabled && Boolean(input.domain), + queryKey: [ + "domain-pages", + input.projectId, + input.domain, + input.includeSubdomains, + input.locationCode, + input.languageCode, + input.page, + input.pageSize, + pageSortMode, + input.sortOrder, + trimmedSearch || undefined, + ], + queryFn: () => + getDomainPagesPage({ + data: { + projectId: input.projectId, + domain: input.domain, + includeSubdomains: input.includeSubdomains, + locationCode: input.locationCode, + languageCode: input.languageCode, + page: input.page, + pageSize: input.pageSize, + sortMode: pageSortMode, + sortOrder: input.sortOrder, + search: trimmedSearch || undefined, + }, + }), + placeholderData: keepPreviousData, + staleTime: 60_000, + }); +} diff --git a/src/client/features/domain/types.ts b/src/client/features/domain/types.ts index 8be1457..47633e5 100644 --- a/src/client/features/domain/types.ts +++ b/src/client/features/domain/types.ts @@ -64,8 +64,6 @@ export type DomainOverviewData = { backlinks: number | null; referringDomains: number | null; hasData: boolean; - keywords: KeywordRow[]; - pages: PageRow[]; }; export type DomainHistoryItem = { diff --git a/src/client/features/domain/useDomainControllerHandlers.ts b/src/client/features/domain/useDomainControllerHandlers.ts new file mode 100644 index 0000000..94a6b56 --- /dev/null +++ b/src/client/features/domain/useDomainControllerHandlers.ts @@ -0,0 +1,138 @@ +import { useCallback, type FormEvent } from "react"; +import { + getDefaultSortOrder, + toSortOrderSearchParam, + toSortSearchParam, +} from "@/client/features/domain/utils"; +import type { + DomainControlsValues, + DomainSortMode, + SortOrder, +} from "@/client/features/domain/types"; +import { saveSelectedKeywords } from "@/client/features/domain/domainActions"; +import type { useSaveKeywordsMutation } from "@/client/features/domain/mutations"; +import type { + SearchState, + useOverviewDataState, +} from "@/client/features/domain/domainOverviewControllerInternals"; +import type { DomainSearchHistoryItem } from "@/client/hooks/useDomainSearchHistory"; +import { + DEFAULT_LOCATION_CODE, + getLanguageCode, + isSupportedLocationCode, +} from "@/client/features/keywords/locations"; + +type DomainControlsFormApi = { + state: { values: DomainControlsValues }; + handleSubmit: () => Promise; + reset: (values: DomainControlsValues) => void; + setFieldValue: ( + field: keyof DomainControlsValues, + value: string | boolean | number, + ) => void; +}; + +export function useDomainControllerHandlers({ + controlsForm, + currentSortOrder, + currentState, + dataState, + projectId, + saveMutation, + selectedKeywords, + setSearchParams, +}: { + controlsForm: DomainControlsFormApi; + currentSortOrder: SortOrder; + currentState: SearchState; + dataState: ReturnType; + projectId: string; + saveMutation: ReturnType; + selectedKeywords: Set; + setSearchParams: ( + updates: Record, + ) => void; +}) { + const applySort = useCallback( + (nextSort: DomainSortMode, nextOrder: SortOrder) => { + controlsForm.setFieldValue("sort", nextSort); + setSearchParams({ + sort: toSortSearchParam(nextSort), + order: toSortOrderSearchParam(nextSort, nextOrder), + page: undefined, + }); + }, + [controlsForm, setSearchParams], + ); + + const applyLocationChange = useCallback( + (nextLocationCode: number) => { + if (!isSupportedLocationCode(nextLocationCode)) return; + controlsForm.setFieldValue("locationCode", nextLocationCode); + setSearchParams({ + loc: + nextLocationCode === DEFAULT_LOCATION_CODE + ? undefined + : nextLocationCode, + }); + }, + [controlsForm, setSearchParams], + ); + + const handleSortColumnClick = useCallback( + (nextSort: DomainSortMode) => { + const nextOrder = + nextSort === currentState.sort + ? currentSortOrder === "asc" + ? "desc" + : "asc" + : getDefaultSortOrder(nextSort); + applySort(nextSort, nextOrder); + }, + [applySort, currentSortOrder, currentState.sort], + ); + + const handleSaveKeywords = () => { + saveSelectedKeywords({ + selectedKeywords, + filteredKeywords: dataState.filteredKeywords, + save: saveMutation.mutate, + projectId, + locationCode: currentState.locationCode, + languageCode: getLanguageCode(currentState.locationCode), + }); + }; + + const handleHistorySelect = (item: DomainSearchHistoryItem) => { + const historyLocation = + item.locationCode != null && isSupportedLocationCode(item.locationCode) + ? item.locationCode + : DEFAULT_LOCATION_CODE; + setSearchParams({ + domain: item.domain, + subdomains: item.subdomains ? undefined : false, + sort: toSortSearchParam(item.sort), + order: undefined, + tab: item.tab === "keywords" ? undefined : item.tab, + search: item.search?.trim() || undefined, + loc: + historyLocation === DEFAULT_LOCATION_CODE ? undefined : historyLocation, + page: undefined, + size: undefined, + }); + }; + + const handleSearchSubmit = (event: FormEvent) => { + event.preventDefault(); + void controlsForm.handleSubmit(); + }; + + return { + applySort, + applyLocationChange, + handleSortColumnClick, + handleSaveKeywords, + handleSearchSubmit, + handleHistorySelect, + }; +} diff --git a/src/client/features/domain/useDomainOverviewController.ts b/src/client/features/domain/useDomainOverviewController.ts index c229179..71eb8c0 100644 --- a/src/client/features/domain/useDomainOverviewController.ts +++ b/src/client/features/domain/useDomainOverviewController.ts @@ -1,12 +1,9 @@ -import { useCallback, useEffect, useState, type FormEvent } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useForm } from "@tanstack/react-form"; import { type QueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useDomainSearchHistory } from "@/client/hooks/useDomainSearchHistory"; import { - useDomainSearchHistory, - type DomainSearchHistoryItem, -} from "@/client/hooks/useDomainSearchHistory"; -import { - getDefaultSortOrder, normalizeDomainTarget, resolveSortOrder, toSortOrderSearchParam, @@ -16,27 +13,29 @@ import { createFormValidationErrors, shouldValidateFieldOnChange, } from "@/client/lib/forms"; -import type { - DomainControlsValues, - DomainOverviewData, - DomainSortMode, - SortOrder, -} from "@/client/features/domain/types"; -import { saveSelectedKeywords } from "@/client/features/domain/domainActions"; +import { captureClientEvent } from "@/client/lib/posthog"; +import { getStandardErrorMessage } from "@/client/lib/error-messages"; +import type { KeywordRow, PageRow } from "@/client/features/domain/types"; import { useSaveKeywordsMutation } from "@/client/features/domain/mutations"; import { useDomainFilters } from "@/client/features/domain/hooks/useDomainFilters"; +import { useDomainKeywordsQuery } from "@/client/features/domain/hooks/useDomainKeywordsQuery"; +import { useDomainOverviewQuery } from "@/client/features/domain/hooks/useDomainOverviewQuery"; +import { useDomainPagesQuery } from "@/client/features/domain/hooks/useDomainPagesQuery"; +import { + getDomainSearchChangeValidationErrors, + getDomainSearchValidationErrors, +} from "@/client/features/domain/domainSearchValidation"; +import { useDomainControllerHandlers } from "@/client/features/domain/useDomainControllerHandlers"; import { - useDomainLookupMutation, useOverviewDataState, - useSearchRunner, useSyncRouteState, type SearchState, } from "@/client/features/domain/domainOverviewControllerInternals"; import { DEFAULT_LOCATION_CODE, getLanguageCode, - isSupportedLocationCode, } from "@/client/features/keywords/locations"; +import { DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE } from "@/types/schemas/domain"; type Params = { projectId: string; @@ -48,78 +47,17 @@ type Params = { searchState: SearchState; }; -type DomainControlsFormApi = { - state: { - values: DomainControlsValues; - }; - handleSubmit: () => Promise; - reset: (values: DomainControlsValues) => void; - setFieldValue: ( - field: keyof DomainControlsValues, - value: string | boolean | number, - ) => void; -}; - -function getDomainSearchValidationErrors(value: DomainControlsValues) { - if (!value.domain.trim()) { - return createFormValidationErrors({ - fields: { - domain: "Please enter a domain", - }, - }); - } - - if (!normalizeDomainTarget(value.domain)) { - return createFormValidationErrors({ - fields: { - domain: "Please enter a valid URL or domain (e.g. browserbase.com)", - }, - }); - } - - return null; -} - -function getDomainSearchChangeValidationErrors( - value: DomainControlsValues, - shouldValidateUntouchedField: boolean, - shouldValidateFormat: boolean, -) { - if (!value.domain.trim()) { - if (!shouldValidateUntouchedField) { - return null; - } - - return createFormValidationErrors({ - fields: { - domain: "Please enter a domain", - }, - }); - } - - if (!shouldValidateFormat) { - return null; - } - - return getDomainSearchValidationErrors(value); -} - export function useDomainOverviewController({ projectId, queryClient, navigate, searchState, }: Params) { - const [pendingSearch, setPendingSearch] = useState(searchState.search); - const [overview, setOverview] = useState(null); - const [overviewLocationCode, setOverviewLocationCode] = useState< - number | null - >(null); const [selectedKeywords, setSelectedKeywords] = useState>( new Set(), ); const [showFilters, setShowFilters] = useState(false); - const domainFilters = useDomainFilters(); + const { history, isLoaded: historyLoaded, @@ -141,6 +79,22 @@ export function useDomainOverviewController({ [navigate], ); + const domainFilters = useDomainFilters({ + appliedValues: searchState.appliedFilters, + appliedSearch: searchState.search, + setSearchParams, + }); + + const overviewLanguageCode = getLanguageCode(searchState.locationCode); + const overviewQuery = useDomainOverviewQuery({ + projectId, + domain: searchState.domain, + includeSubdomains: searchState.subdomains, + locationCode: searchState.locationCode, + languageCode: overviewLanguageCode, + }); + const overview = overviewQuery.data ?? null; + const controlsForm = useForm({ defaultValues: { domain: searchState.domain, @@ -157,54 +111,136 @@ export function useDomainOverviewController({ ), onSubmit: ({ value }) => getDomainSearchValidationErrors(value), }, - onSubmit: async ({ formApi, value }) => { - const submitError = await runSearch({ - domain: value.domain, - subdomains: value.subdomains, - sort: value.sort, - order: currentSortOrder, - tab: searchState.tab, - search: searchState.search, - locationCode: value.locationCode, - }); - - formApi.setErrorMap({ - onSubmit: submitError - ? createFormValidationErrors({ form: submitError }) - : undefined, + onSubmit: ({ formApi, value }) => { + const target = normalizeDomainTarget(value.domain); + if (!target) return; + formApi.setFieldValue("domain", target); + setSearchParams({ + domain: target, + subdomains: value.subdomains ? undefined : false, + sort: toSortSearchParam(value.sort), + order: toSortOrderSearchParam(value.sort, currentSortOrder), + tab: searchState.tab === "keywords" ? undefined : searchState.tab, + loc: + value.locationCode === DEFAULT_LOCATION_CODE + ? undefined + : value.locationCode, + page: undefined, + size: undefined, }); }, }); - useSyncRouteState({ controlsForm, searchState, setPendingSearch, navigate }); - const domainMutation = useDomainLookupMutation(projectId); + useSyncRouteState({ controlsForm, searchState, navigate }); const saveMutation = useSaveKeywordsMutation({ projectId, queryClient }); - const dataState = useOverviewDataState({ - overview, - pendingSearch, - filters: domainFilters.values, + + // Surface overview-query errors through the form's submit error map so the + // existing error UI keeps working without a parallel error channel. + useEffect(() => { + controlsForm.setErrorMap({ + onSubmit: overviewQuery.error + ? createFormValidationErrors({ + form: getStandardErrorMessage( + overviewQuery.error, + "Lookup failed.", + ), + }) + : undefined, + }); + }, [controlsForm, overviewQuery.error]); + + // History + analytics + "no data" toast: fire once per successful overview + // fetch, keyed on the inputs that actually trigger a refetch. + const lastTrackedKey = useRef(""); + useEffect(() => { + if (!overviewQuery.isSuccess || !overviewQuery.data) return; + const key = `${searchState.domain}|${searchState.subdomains}|${searchState.locationCode}`; + if (lastTrackedKey.current === key) return; + lastTrackedKey.current = key; + + const data = overviewQuery.data; + captureClientEvent("domain_overview:search_complete", { + sort_mode: searchState.sort, + include_subdomains: searchState.subdomains, + result_count: data.organicKeywords ?? 0, + location_code: searchState.locationCode, + }); + addSearch({ + domain: searchState.domain, + subdomains: searchState.subdomains, + sort: searchState.sort, + tab: searchState.tab, + search: searchState.search.trim() || undefined, + locationCode: searchState.locationCode, + }); + if (!data.hasData) { + toast.info("Not enough data for this domain"); + } + setSelectedKeywords(new Set()); + }, [ + overviewQuery.isSuccess, + overviewQuery.data, + searchState.domain, + searchState.subdomains, + searchState.locationCode, + searchState.sort, + searchState.tab, + searchState.search, + addSearch, + ]); + + // Reset transient panel state when the user navigates back to recent searches. + useEffect(() => { + if (searchState.domain.trim() === "") { + setShowFilters(false); + setSelectedKeywords(new Set()); + lastTrackedKey.current = ""; + } + }, [searchState.domain]); + + const keywordsTabActive = searchState.tab === "keywords"; + const pagesTabActive = searchState.tab === "pages"; + + const keywordsQuery = useDomainKeywordsQuery({ + projectId, + domain: overview?.domain ?? "", + includeSubdomains: searchState.subdomains, + locationCode: searchState.locationCode, + languageCode: overviewLanguageCode, + page: searchState.page, + pageSize: searchState.pageSize, sortMode: searchState.sort, - currentSortOrder, - setSelectedKeywords, + sortOrder: currentSortOrder, + appliedFilters: searchState.appliedFilters, + searchTerm: searchState.search, + enabled: overview !== null && overview.hasData && keywordsTabActive, }); - useEffect(() => { - setSearchParams({ search: pendingSearch.trim() || undefined }); - }, [pendingSearch, setSearchParams]); + const pagesQuery = useDomainPagesQuery({ + projectId, + domain: overview?.domain ?? "", + includeSubdomains: searchState.subdomains, + locationCode: searchState.locationCode, + languageCode: overviewLanguageCode, + page: searchState.page, + pageSize: searchState.pageSize, + sortMode: searchState.sort, + sortOrder: currentSortOrder, + searchTerm: searchState.search, + enabled: overview !== null && overview.hasData && pagesTabActive, + }); - const runSearch = useSearchRunner({ - controlsForm, - setPendingSearch, - setSearchParams, - domainMutation, - addSearch, - setOverview: (value, locationCode) => { - setOverview(value); - setOverviewLocationCode(locationCode); - }, + const pagedKeywords: KeywordRow[] = keywordsQuery.data?.keywords ?? []; + const totalKeywordCount = + keywordsQuery.data?.totalCount ?? overview?.organicKeywords ?? null; + + const pagedPages: PageRow[] = pagesQuery.data?.pages ?? []; + const totalPagesCount = pagesQuery.data?.totalCount ?? null; + + const dataState = useOverviewDataState({ + pagedKeywords, setSelectedKeywords, - currentState: searchState, - currentSortOrder, + activeFilterCount: domainFilters.activeAppliedCount, }); const handlers = useDomainControllerHandlers({ @@ -212,159 +248,81 @@ export function useDomainOverviewController({ currentSortOrder, currentState: searchState, dataState, - overviewLocationCode, projectId, - runSearch, saveMutation, selectedKeywords, setSearchParams, }); const canSaveKeywords = - overviewLocationCode !== null && - overviewLocationCode === controlsForm.state.values.locationCode; + controlsForm.state.values.locationCode === searchState.locationCode && + overview !== null && + overview.hasData; - const resetView = useCallback(() => { - setOverview(null); - setOverviewLocationCode(null); - setPendingSearch(""); - setSelectedKeywords(new Set()); - setShowFilters(false); - domainFilters.resetFilters(); - }, [domainFilters]); + const goToPage = useCallback( + (nextPage: number) => { + const safe = Math.max(1, Math.floor(nextPage)); + setSearchParams({ page: safe === 1 ? undefined : safe }); + }, + [setSearchParams], + ); + + const setPageSize = useCallback( + (nextSize: number) => { + setSearchParams({ + size: + nextSize === DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE ? undefined : nextSize, + page: undefined, + }); + }, + [setSearchParams], + ); + + // Treat the page as loading until the active tab's first fetch resolves — + // otherwise the table area would render an empty shell with a spinner while + // we wait on DataForSEO. `isLoading` is true only on the very first fetch + // (subsequent paginations keep prior data via keepPreviousData). + const activeTabFirstFetch = + (keywordsTabActive && keywordsQuery.isLoading) || + (pagesTabActive && pagesQuery.isLoading); + const isLoading = overviewQuery.isLoading || activeTabFirstFetch; return { controlsForm, - isLoading: domainMutation.isPending, + isLoading, overview, canSaveKeywords, history, historyLoaded, removeHistoryItem, - pendingSearch, - setPendingSearch, + searchDraft: domainFilters.searchDraft, + setSearchDraft: domainFilters.setSearchDraft, selectedKeywords, currentSortOrder, setSearchParams, showFilters, setShowFilters, filtersForm: domainFilters.filtersForm, - resetView, resetFilters: domainFilters.resetFilters, + applyFilters: domainFilters.applyFilters, + cancelFilterEdits: domainFilters.cancelEdits, + dirtyFilterCount: domainFilters.dirtyCount, + conditionCount: domainFilters.conditionCount, + overLimit: domainFilters.overLimit, + keywordsLoading: keywordsQuery.isFetching, + keywordsError: keywordsQuery.error, + pagesLoading: pagesQuery.isFetching, + pagesError: pagesQuery.error, + page: searchState.page, + pageSize: searchState.pageSize, + totalKeywordCount, + totalPagesCount, + hasNextKeywordsPage: keywordsQuery.data?.hasMore ?? false, + hasNextPagesPage: pagesQuery.data?.hasMore ?? false, + pagedPages, + goToPage, + setPageSize, ...handlers, ...dataState, }; } - -function useDomainControllerHandlers({ - controlsForm, - currentSortOrder, - currentState, - dataState, - overviewLocationCode, - projectId, - runSearch, - saveMutation, - selectedKeywords, - setSearchParams, -}: { - controlsForm: DomainControlsFormApi; - currentSortOrder: SortOrder; - currentState: SearchState; - dataState: ReturnType; - overviewLocationCode: number | null; - projectId: string; - runSearch: ReturnType; - saveMutation: ReturnType; - selectedKeywords: Set; - setSearchParams: ( - updates: Record, - ) => void; -}) { - const applySort = useCallback( - (nextSort: DomainSortMode, nextOrder: SortOrder) => { - controlsForm.setFieldValue("sort", nextSort); - setSearchParams({ - sort: toSortSearchParam(nextSort), - order: toSortOrderSearchParam(nextSort, nextOrder), - }); - }, - [controlsForm, setSearchParams], - ); - - const applyLocationChange = useCallback( - (nextLocationCode: number) => { - if (!isSupportedLocationCode(nextLocationCode)) return; - controlsForm.setFieldValue("locationCode", nextLocationCode); - setSearchParams({ - loc: - nextLocationCode === DEFAULT_LOCATION_CODE - ? undefined - : nextLocationCode, - }); - }, - [controlsForm, setSearchParams], - ); - - const handleSortColumnClick = useCallback( - (nextSort: DomainSortMode) => { - const nextOrder = - nextSort === currentState.sort - ? currentSortOrder === "asc" - ? "desc" - : "asc" - : getDefaultSortOrder(nextSort); - applySort(nextSort, nextOrder); - }, - [applySort, currentSortOrder, currentState.sort], - ); - - const handleSaveKeywords = () => { - if (overviewLocationCode === null) return; - saveSelectedKeywords({ - selectedKeywords, - filteredKeywords: dataState.filteredKeywords, - save: saveMutation.mutate, - projectId, - locationCode: overviewLocationCode, - languageCode: getLanguageCode(overviewLocationCode), - }); - }; - - const handleHistorySelect = (item: DomainSearchHistoryItem) => { - const historyLocation = - item.locationCode != null && isSupportedLocationCode(item.locationCode) - ? item.locationCode - : DEFAULT_LOCATION_CODE; - controlsForm.reset({ - domain: item.domain, - subdomains: item.subdomains, - sort: item.sort, - locationCode: historyLocation, - }); - void runSearch({ - domain: item.domain, - subdomains: item.subdomains, - sort: item.sort, - order: getDefaultSortOrder(item.sort), - tab: item.tab, - search: item.search ?? "", - locationCode: historyLocation, - }); - }; - - const handleSearchSubmit = (event: FormEvent) => { - event.preventDefault(); - void controlsForm.handleSubmit(); - }; - - return { - applySort, - applyLocationChange, - handleSortColumnClick, - handleSaveKeywords, - runSearch, - handleSearchSubmit, - handleHistorySelect, - }; -} diff --git a/src/client/features/domain/utils.ts b/src/client/features/domain/utils.ts index 139ec7d..f22ae86 100644 --- a/src/client/features/domain/utils.ts +++ b/src/client/features/domain/utils.ts @@ -47,16 +47,10 @@ export function toSortOrderSearchParam( return sortOrder === getDefaultSortOrder(sortMode) ? undefined : sortOrder; } -export function sortableNullableNumber( - value: number | null | undefined, - order: SortOrder, -): number { - if (value != null) return value; - return order === "asc" ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY; -} - -export function toPageSortMode(sortMode: DomainSortMode): "traffic" | "volume" { - if (sortMode === "volume") return "volume"; +export function toPageSortMode( + sortMode: DomainSortMode, +): "traffic" | "keywords" { + if (sortMode === "volume") return "keywords"; return "traffic"; } diff --git a/src/routes/_project/p/$projectId/domain.tsx b/src/routes/_project/p/$projectId/domain.tsx index e692d5d..05bac08 100644 --- a/src/routes/_project/p/$projectId/domain.tsx +++ b/src/routes/_project/p/$projectId/domain.tsx @@ -9,25 +9,40 @@ import { DEFAULT_LOCATION_CODE, isSupportedLocationCode, } from "@/client/features/keywords/locations"; -import { domainSearchSchema } from "@/types/schemas/domain"; +import { + DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE, + domainSearchSchema, +} from "@/types/schemas/domain"; +import { + EMPTY_DOMAIN_FILTERS, + type DomainFilterValues, +} from "@/client/features/domain/types"; export const Route = createFileRoute("/_project/p/$projectId/domain")({ validateSearch: domainSearchSchema, component: DomainOverviewRoute, }); +function numberToFilterString(value: number | undefined): string { + if (value == null || !Number.isFinite(value)) return ""; + return String(value); +} + function DomainOverviewRoute() { const { projectId } = Route.useParams(); const navigate = useNavigate({ from: Route.fullPath }); + const search = Route.useSearch(); const { domain = "", subdomains = true, sort = "rank", order, tab = "keywords", - search = "", + search: searchTerm = "", loc, - } = Route.useSearch(); + page, + size, + } = search; const normalizedSort = toSortMode(sort) ?? "rank"; const normalizedOrder = resolveSortOrder( @@ -36,22 +51,30 @@ function DomainOverviewRoute() { ); const normalizedLocationCode = loc != null && isSupportedLocationCode(loc) ? loc : DEFAULT_LOCATION_CODE; + const normalizedPage = page != null && page > 0 ? page : 1; + const normalizedPageSize = size ?? DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE; + + const appliedFilters: DomainFilterValues = { + include: search.include ?? EMPTY_DOMAIN_FILTERS.include, + exclude: search.exclude ?? EMPTY_DOMAIN_FILTERS.exclude, + minTraffic: numberToFilterString(search.minTraffic), + maxTraffic: numberToFilterString(search.maxTraffic), + minVol: numberToFilterString(search.minVol), + maxVol: numberToFilterString(search.maxVol), + minCpc: numberToFilterString(search.minCpc), + maxCpc: numberToFilterString(search.maxCpc), + minKd: numberToFilterString(search.minKd), + maxKd: numberToFilterString(search.maxKd), + minRank: numberToFilterString(search.minRank), + maxRank: numberToFilterString(search.maxRank), + }; return ( { void navigate({ - search: (prev) => ({ - ...prev, - domain: undefined, - subdomains: undefined, - sort: undefined, - order: undefined, - tab: undefined, - search: undefined, - loc: undefined, - }), + search: () => ({}), replace: true, }); }} @@ -62,8 +85,11 @@ function DomainOverviewRoute() { sort: normalizedSort, order: normalizedOrder, tab, - search, + search: searchTerm, locationCode: normalizedLocationCode, + page: normalizedPage, + pageSize: normalizedPageSize, + appliedFilters, }} /> ); diff --git a/src/server/features/domain/services/DomainService.ts b/src/server/features/domain/services/DomainService.ts index f57b85b..757c09a 100644 --- a/src/server/features/domain/services/DomainService.ts +++ b/src/server/features/domain/services/DomainService.ts @@ -1,72 +1,27 @@ -import { type DomainRankedKeywordItem } from "@/server/lib/dataforseo"; -import { sortBy } from "remeda"; import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache"; import { z } from "zod"; import type { BillingCustomerContext } from "@/server/billing/subscription"; import { createDataforseoClient } from "@/server/lib/dataforseoClient"; -import { normalizeDomainInput, toRelativePath } from "@/server/lib/domainUtils"; +import { normalizeDomainInput } from "@/server/lib/domainUtils"; +import { mapKeywordItem } from "@/server/features/domain/services/domainKeywordMapper"; +import { getKeywordsPage } from "@/server/features/domain/services/domainKeywordsPage"; +import { getPagesPage } from "@/server/features/domain/services/domainPagesPage"; /** Domain overview data is refreshed every 12 hours. */ const DOMAIN_OVERVIEW_TTL_SECONDS = 12 * 60 * 60; -type DomainOverviewResult = { - domain: string; - organicTraffic: number | null; - organicKeywords: number | null; - backlinks: number | null; - referringDomains: number | null; - hasData: boolean; - keywords: Array<{ - keyword: string; - position: number | null; - searchVolume: number | null; - traffic: number | null; - cpc: number | null; - url: string | null; - relativeUrl: string | null; - keywordDifficulty: number | null; - }>; - pages: Array<{ - page: string; - relativePath: string | null; - organicTraffic: number | null; - keywords: number | null; - backlinks: number | null; - }>; - fetchedAt: string; -}; - -const domainKeywordSchema = z.object({ - keyword: z.string(), - position: z.number().nullable(), - searchVolume: z.number().nullable(), - traffic: z.number().nullable(), - cpc: z.number().nullable(), - url: z.string().nullable(), - relativeUrl: z.string().nullable(), - keywordDifficulty: z.number().nullable(), -}); - -const domainPageSchema = z.object({ - page: z.string(), - relativePath: z.string().nullable(), - organicTraffic: z.number().nullable(), - keywords: z.number().nullable(), - backlinks: z.number().nullable(), -}); - -const domainOverviewSchema = z.object({ +const domainOverviewResultSchema = z.object({ domain: z.string(), organicTraffic: z.number().nullable(), organicKeywords: z.number().nullable(), backlinks: z.number().nullable(), referringDomains: z.number().nullable(), hasData: z.boolean(), - keywords: z.array(domainKeywordSchema), - pages: z.array(domainPageSchema), fetchedAt: z.string(), }); +type DomainOverviewResult = z.infer; + async function getOverview( input: { projectId: string; @@ -89,41 +44,21 @@ async function getOverview( }); const cachedRaw = await getCached(cacheKey); - const cached = domainOverviewSchema.safeParse(cachedRaw); + const cached = domainOverviewResultSchema.safeParse(cachedRaw); if (cached.success && cached.data.hasData) { return cached.data; } - // --- Fetch fresh from DataForSEO --- const nowIso = new Date().toISOString(); const dataforseo = createDataforseoClient(billingCustomer); - const [metricsResponse, rankedKeywordsResponse] = await Promise.all([ - dataforseo.domain.rankOverview({ - target: domain, - locationCode: input.locationCode, - languageCode: input.languageCode, - }), - dataforseo.domain.rankedKeywords({ - target: domain, - locationCode: input.locationCode, - languageCode: input.languageCode, - limit: 200, - orderBy: ["keyword_data.keyword_info.search_volume,desc"], - }), - ]); + const metricsResponse = await dataforseo.domain.rankOverview({ + target: domain, + locationCode: input.locationCode, + languageCode: input.languageCode, + }); const metrics = metricsResponse[0]; - const rankedItems = rankedKeywordsResponse; - - const keywords = rankedItems - .map((item) => mapKeywordItem(item)) - .filter( - (item): item is NonNullable> => - item != null, - ); - - const pages = derivePages(keywords); const organicTraffic = metrics?.metrics?.organic?.etv != null @@ -140,9 +75,7 @@ async function getOverview( organicKeywords, backlinks: null, referringDomains: null, - hasData: keywords.length > 0, - keywords, - pages, + hasData: organicKeywords != null && organicKeywords > 0, fetchedAt: nowIso, }; @@ -157,97 +90,6 @@ async function getOverview( return result; } -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function mapKeywordItem(item: DomainRankedKeywordItem) { - const keywordData = item.keyword_data; - const keywordInfo = keywordData?.keyword_info; - const keywordProperties = keywordData?.keyword_properties; - const rankedSerpElement = item.ranked_serp_element; - const serpItem = rankedSerpElement?.serp_item; - - const keyword = keywordData?.keyword ?? item.keyword; - if (!keyword) return null; - - const url = serpItem?.url ?? rankedSerpElement?.url ?? null; - - const relativeUrl = - serpItem?.relative_url ?? - rankedSerpElement?.relative_url ?? - (url ? toRelativePath(url) : null); - - const position = - serpItem?.rank_absolute ?? rankedSerpElement?.rank_absolute ?? null; - - const traffic = serpItem?.etv ?? rankedSerpElement?.etv ?? null; - - const keywordDifficulty = - keywordProperties?.keyword_difficulty ?? - keywordInfo?.keyword_difficulty ?? - null; - - return { - keyword, - position: position != null ? Math.round(position) : null, - searchVolume: - keywordInfo?.search_volume != null - ? Math.round(keywordInfo.search_volume) - : null, - traffic: traffic ?? null, - cpc: keywordInfo?.cpc ?? null, - url: url ?? null, - relativeUrl, - keywordDifficulty: - keywordDifficulty != null ? Math.round(keywordDifficulty) : null, - }; -} - -function derivePages( - keywords: Array<{ - url: string | null; - relativeUrl: string | null; - traffic: number | null; - }>, -) { - const grouped = new Map< - string, - { - page: string; - relativePath: string | null; - traffic: number; - keywords: number; - } - >(); - - for (const keyword of keywords) { - if (!keyword.url) continue; - - const existing = grouped.get(keyword.url) ?? { - page: keyword.url, - relativePath: keyword.relativeUrl, - traffic: 0, - keywords: 0, - }; - - existing.traffic += keyword.traffic ?? 0; - existing.keywords += 1; - - grouped.set(keyword.url, existing); - } - - return sortBy(Array.from(grouped.values()), [(page) => page.traffic, "desc"]) - .slice(0, 100) - .map((page) => ({ - page: page.page, - relativePath: page.relativePath, - organicTraffic: page.traffic, - keywords: page.keywords, - backlinks: null, - })); -} - async function getSuggestedKeywords( input: { domain: string; @@ -296,7 +138,7 @@ async function getSuggestedKeywords( const dataforseo = createDataforseoClient(billingCustomer); - const rankedItems = await dataforseo.domain.rankedKeywords({ + const rankedKeywordsResponse = await dataforseo.domain.rankedKeywords({ target: domain, locationCode: input.locationCode, languageCode: input.languageCode, @@ -304,7 +146,7 @@ async function getSuggestedKeywords( orderBy: ["keyword_data.keyword_info.search_volume,desc"], }); - const keywords = rankedItems + const keywords = rankedKeywordsResponse.items .map((item) => mapKeywordItem(item)) .filter( (item): item is NonNullable> => @@ -333,4 +175,6 @@ async function getSuggestedKeywords( export const DomainService = { getOverview, getSuggestedKeywords, + getKeywordsPage, + getPagesPage, } as const; diff --git a/src/server/features/domain/services/domainKeywordFilters.test.ts b/src/server/features/domain/services/domainKeywordFilters.test.ts new file mode 100644 index 0000000..cceedac --- /dev/null +++ b/src/server/features/domain/services/domainKeywordFilters.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { + buildKeywordFilters, + buildOrderBy, +} from "@/server/features/domain/services/domainKeywordFilters"; + +describe("buildOrderBy", () => { + it("maps sort modes to DataForSEO field paths", () => { + expect(buildOrderBy("rank", "asc")).toEqual([ + "ranked_serp_element.serp_item.rank_absolute,asc", + ]); + expect(buildOrderBy("volume", "desc")).toEqual([ + "keyword_data.keyword_info.search_volume,desc", + ]); + expect(buildOrderBy("score", "asc")).toEqual([ + "keyword_data.keyword_properties.keyword_difficulty,asc", + ]); + }); +}); + +describe("buildKeywordFilters", () => { + it("returns an empty array when no filters are set", () => { + expect(buildKeywordFilters({})).toEqual([]); + }); + + it("emits one ilike clause per include term and chains them with 'and'", () => { + expect(buildKeywordFilters({ include: "audit, checker" })).toEqual([ + ["keyword_data.keyword", "ilike", "%audit%"], + "and", + ["keyword_data.keyword", "ilike", "%checker%"], + ]); + }); + + it("emits not_ilike clauses for exclude terms", () => { + expect(buildKeywordFilters({ exclude: "jobs+salary" })).toEqual([ + ["keyword_data.keyword", "not_ilike", "%jobs%"], + "and", + ["keyword_data.keyword", "not_ilike", "%salary%"], + ]); + }); + + it("escapes SQL LIKE wildcards in user-supplied terms", () => { + const result = buildKeywordFilters({ include: "100%" }); + expect(result[0]).toEqual(["keyword_data.keyword", "ilike", "%100\\%%"]); + }); + + it("includes numeric range conditions", () => { + const result = buildKeywordFilters({ + minVol: 100, + maxVol: 5000, + minCpc: 0.5, + }); + expect(result).toEqual([ + ["keyword_data.keyword_info.search_volume", ">=", 100], + "and", + ["keyword_data.keyword_info.search_volume", "<=", 5000], + "and", + ["keyword_data.keyword_info.cpc", ">=", 0.5], + ]); + }); + + it("emits an OR group matching keyword or url for the search term", () => { + const result = buildKeywordFilters({}, "audit"); + expect(result).toEqual([ + [ + ["keyword_data.keyword", "ilike", "%audit%"], + "or", + ["ranked_serp_element.serp_item.url", "ilike", "%audit%"], + ], + ]); + }); + + it("ANDs the search OR-group after structured filters", () => { + const result = buildKeywordFilters({ minVol: 100 }, "audit"); + expect(result).toEqual([ + ["keyword_data.keyword_info.search_volume", ">=", 100], + "and", + [ + ["keyword_data.keyword", "ilike", "%audit%"], + "or", + ["ranked_serp_element.serp_item.url", "ilike", "%audit%"], + ], + ]); + }); + + it("packs exactly 8 conditions without throwing", () => { + const result = buildKeywordFilters({ + include: "a,b,c,d", + exclude: "e,f", + minVol: 1, + maxVol: 2, + }); + const arrayClauses = result.filter((entry) => Array.isArray(entry)); + expect(arrayClauses).toHaveLength(8); + }); + + it("throws when conditions exceed the 8-condition cap", () => { + expect(() => + buildKeywordFilters({ + include: "a,b,c,d", + exclude: "e,f", + minVol: 1, + maxVol: 2, + minTraffic: 3, + maxTraffic: 4, + }), + ).toThrow(/Too many filter conditions/); + }); + + it("counts the search OR-group as 2 toward the cap", () => { + expect(() => + buildKeywordFilters( + { + include: "a,b,c,d", + exclude: "e,f", + minVol: 1, + }, + "audit", + ), + ).toThrow(/Too many filter conditions/); + }); +}); diff --git a/src/server/features/domain/services/domainKeywordFilters.ts b/src/server/features/domain/services/domainKeywordFilters.ts new file mode 100644 index 0000000..778f061 --- /dev/null +++ b/src/server/features/domain/services/domainKeywordFilters.ts @@ -0,0 +1,149 @@ +import { + MAX_DATAFORSEO_FILTER_CONDITIONS, + type DomainKeywordsFilters, +} from "@/types/schemas/domain"; +import { AppError } from "@/server/lib/errors"; + +export type DomainKeywordsSortMode = + | "rank" + | "traffic" + | "volume" + | "score" + | "cpc"; +export type DomainKeywordsSortOrder = "asc" | "desc"; + +const SORT_FIELD_BY_MODE: Record = { + rank: "ranked_serp_element.serp_item.rank_absolute", + traffic: "ranked_serp_element.serp_item.etv", + volume: "keyword_data.keyword_info.search_volume", + score: "keyword_data.keyword_properties.keyword_difficulty", + cpc: "keyword_data.keyword_info.cpc", +}; + +export function buildOrderBy( + sortMode: DomainKeywordsSortMode, + sortOrder: DomainKeywordsSortOrder, +): string[] { + return [`${SORT_FIELD_BY_MODE[sortMode]},${sortOrder}`]; +} + +function escapeLikeTerm(term: string): string { + return term.replace(/[\\%_]/g, (match) => `\\${match}`); +} + +function pushAnd(filters: unknown[], expression: Clause) { + if (filters.length > 0) filters.push("and"); + filters.push(expression); +} + +function parseTerms(value: string | undefined): string[] { + if (!value) return []; + return value + .toLowerCase() + .split(/[,+]/) + .map((term) => term.trim()) + .filter(Boolean); +} + +function collectNumericRange( + out: Clause[], + field: string, + min: number | undefined, + max: number | undefined, +) { + if (typeof min === "number" && Number.isFinite(min)) { + out.push([field, ">=", min]); + } + if (typeof max === "number" && Number.isFinite(max)) { + out.push([field, "<=", max]); + } +} + +/** + * DataForSEO accepts up to 8 filter conditions per request joined by + * "and"/"or" operators. Each include/exclude term is one ilike clause; + * numeric ranges add one per bound; the free-text search term adds one OR- + * group of two (keyword OR url). The client surfaces the same condition + * count and disables Apply when over budget, so reaching the cap here + * indicates a misbehaving client — we throw rather than silently truncate. + */ +type Clause = unknown[]; + +export function buildKeywordFilters( + filters: DomainKeywordsFilters, + searchTerm?: string, +): unknown[] { + const conditions: Clause[] = []; + + for (const term of parseTerms(filters.include)) { + conditions.push([ + "keyword_data.keyword", + "ilike", + `%${escapeLikeTerm(term)}%`, + ]); + } + for (const term of parseTerms(filters.exclude)) { + conditions.push([ + "keyword_data.keyword", + "not_ilike", + `%${escapeLikeTerm(term)}%`, + ]); + } + + collectNumericRange( + conditions, + "keyword_data.keyword_info.search_volume", + filters.minVol, + filters.maxVol, + ); + collectNumericRange( + conditions, + "ranked_serp_element.serp_item.etv", + filters.minTraffic, + filters.maxTraffic, + ); + collectNumericRange( + conditions, + "keyword_data.keyword_info.cpc", + filters.minCpc, + filters.maxCpc, + ); + collectNumericRange( + conditions, + "keyword_data.keyword_properties.keyword_difficulty", + filters.minKd, + filters.maxKd, + ); + collectNumericRange( + conditions, + "ranked_serp_element.serp_item.rank_absolute", + filters.minRank, + filters.maxRank, + ); + + const trimmedSearch = searchTerm?.trim(); + const searchGroup = trimmedSearch ? buildSearchGroup(trimmedSearch) : null; + + // The search OR-group costs 2 slots; everything else is 1. + const totalConditions = conditions.length + (searchGroup ? 2 : 0); + if (totalConditions > MAX_DATAFORSEO_FILTER_CONDITIONS) { + throw new AppError( + "VALIDATION_ERROR", + `Too many filter conditions (${totalConditions} of ${MAX_DATAFORSEO_FILTER_CONDITIONS} max).`, + ); + } + + const expressions: unknown[] = []; + for (const clause of conditions) pushAnd(expressions, clause); + if (searchGroup) pushAnd(expressions, searchGroup); + return expressions; +} + +function buildSearchGroup(term: string): Clause { + const escaped = escapeLikeTerm(term); + return [ + ["keyword_data.keyword", "ilike", `%${escaped}%`], + "or", + ["ranked_serp_element.serp_item.url", "ilike", `%${escaped}%`], + ]; +} diff --git a/src/server/features/domain/services/domainKeywordMapper.ts b/src/server/features/domain/services/domainKeywordMapper.ts new file mode 100644 index 0000000..9ab6137 --- /dev/null +++ b/src/server/features/domain/services/domainKeywordMapper.ts @@ -0,0 +1,45 @@ +import { type DomainRankedKeywordItem } from "@/server/lib/dataforseo"; +import { toRelativePath } from "@/server/lib/domainUtils"; + +export function mapKeywordItem(item: DomainRankedKeywordItem) { + const keywordData = item.keyword_data; + const keywordInfo = keywordData?.keyword_info; + const keywordProperties = keywordData?.keyword_properties; + const rankedSerpElement = item.ranked_serp_element; + const serpItem = rankedSerpElement?.serp_item; + + const keyword = keywordData?.keyword ?? item.keyword; + if (!keyword) return null; + + const url = serpItem?.url ?? rankedSerpElement?.url ?? null; + + const relativeUrl = + serpItem?.relative_url ?? + rankedSerpElement?.relative_url ?? + (url ? toRelativePath(url) : null); + + const position = + serpItem?.rank_absolute ?? rankedSerpElement?.rank_absolute ?? null; + + const traffic = serpItem?.etv ?? rankedSerpElement?.etv ?? null; + + const keywordDifficulty = + keywordProperties?.keyword_difficulty ?? + keywordInfo?.keyword_difficulty ?? + null; + + return { + keyword, + position: position != null ? Math.round(position) : null, + searchVolume: + keywordInfo?.search_volume != null + ? Math.round(keywordInfo.search_volume) + : null, + traffic: traffic ?? null, + cpc: keywordInfo?.cpc ?? null, + url: url ?? null, + relativeUrl, + keywordDifficulty: + keywordDifficulty != null ? Math.round(keywordDifficulty) : null, + }; +} diff --git a/src/server/features/domain/services/domainKeywordsPage.ts b/src/server/features/domain/services/domainKeywordsPage.ts new file mode 100644 index 0000000..78e5397 --- /dev/null +++ b/src/server/features/domain/services/domainKeywordsPage.ts @@ -0,0 +1,123 @@ +import { z } from "zod"; +import type { BillingCustomerContext } from "@/server/billing/subscription"; +import { createDataforseoClient } from "@/server/lib/dataforseoClient"; +import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache"; +import { normalizeDomainInput } from "@/server/lib/domainUtils"; +import { mapKeywordItem } from "@/server/features/domain/services/domainKeywordMapper"; +import { + buildKeywordFilters, + buildOrderBy, + type DomainKeywordsSortMode, + type DomainKeywordsSortOrder, +} from "@/server/features/domain/services/domainKeywordFilters"; +import type { DomainKeywordsFilters } from "@/types/schemas/domain"; + +const DOMAIN_KEYWORDS_PAGE_TTL_SECONDS = 12 * 60 * 60; + +const domainKeywordsPageResultSchema = z.object({ + domain: z.string(), + page: z.number(), + pageSize: z.number(), + totalCount: z.number().nullable(), + hasMore: z.boolean(), + keywords: z.array( + z.object({ + keyword: z.string(), + position: z.number().nullable(), + searchVolume: z.number().nullable(), + traffic: z.number().nullable(), + cpc: z.number().nullable(), + url: z.string().nullable(), + relativeUrl: z.string().nullable(), + keywordDifficulty: z.number().nullable(), + }), + ), + fetchedAt: z.string(), +}); + +type DomainKeywordsPageResult = z.infer; + +export async function getKeywordsPage( + input: { + projectId: string; + domain: string; + includeSubdomains: boolean; + locationCode: number; + languageCode: string; + page: number; + pageSize: number; + sortMode: DomainKeywordsSortMode; + sortOrder: DomainKeywordsSortOrder; + filters: DomainKeywordsFilters; + search?: string; + }, + billingCustomer: BillingCustomerContext, +): Promise { + const domain = normalizeDomainInput(input.domain, input.includeSubdomains); + const offset = (input.page - 1) * input.pageSize; + const orderBy = buildOrderBy(input.sortMode, input.sortOrder); + const filters = buildKeywordFilters(input.filters, input.search); + + const cacheKey = await buildCacheKey("domain:keywords-page", { + organizationId: billingCustomer.organizationId, + projectId: input.projectId, + domain, + includeSubdomains: input.includeSubdomains, + locationCode: input.locationCode, + languageCode: input.languageCode, + page: input.page, + pageSize: input.pageSize, + sortMode: input.sortMode, + sortOrder: input.sortOrder, + filters: input.filters, + search: input.search, + }); + + const cachedRaw = await getCached(cacheKey); + const cached = domainKeywordsPageResultSchema.safeParse(cachedRaw); + if (cached.success) { + return cached.data; + } + + const dataforseo = createDataforseoClient(billingCustomer); + const response = await dataforseo.domain.rankedKeywords({ + target: domain, + locationCode: input.locationCode, + languageCode: input.languageCode, + limit: input.pageSize, + offset, + orderBy, + filters: filters.length > 0 ? filters : undefined, + }); + + const keywords = response.items + .map((item) => mapKeywordItem(item)) + .filter( + (item): item is NonNullable> => + item != null, + ); + + const totalCount = response.totalCount; + const hasMore = + totalCount != null + ? offset + keywords.length < totalCount + : keywords.length === input.pageSize; + + const result: DomainKeywordsPageResult = { + domain, + page: input.page, + pageSize: input.pageSize, + totalCount, + hasMore, + keywords, + fetchedAt: new Date().toISOString(), + }; + + void setCached(cacheKey, result, DOMAIN_KEYWORDS_PAGE_TTL_SECONDS).catch( + (error) => { + console.error("domain.keywords-page.cache-write failed:", error); + }, + ); + + return result; +} diff --git a/src/server/features/domain/services/domainPagesPage.ts b/src/server/features/domain/services/domainPagesPage.ts new file mode 100644 index 0000000..daa43d6 --- /dev/null +++ b/src/server/features/domain/services/domainPagesPage.ts @@ -0,0 +1,142 @@ +import { z } from "zod"; +import type { BillingCustomerContext } from "@/server/billing/subscription"; +import { createDataforseoClient } from "@/server/lib/dataforseoClient"; +import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache"; +import { normalizeDomainInput, toRelativePath } from "@/server/lib/domainUtils"; +import type { RelevantPagesItem } from "@/server/lib/dataforseo"; + +const DOMAIN_PAGES_PAGE_TTL_SECONDS = 12 * 60 * 60; + +type DomainPagesSortMode = "traffic" | "keywords"; +type DomainPagesSortOrder = "asc" | "desc"; + +const SORT_FIELD_BY_MODE: Record = { + traffic: "metrics.organic.etv", + keywords: "metrics.organic.count", +}; + +const domainPagesPageResultSchema = z.object({ + domain: z.string(), + page: z.number(), + pageSize: z.number(), + totalCount: z.number().nullable(), + hasMore: z.boolean(), + pages: z.array( + z.object({ + page: z.string(), + relativePath: z.string().nullable(), + organicTraffic: z.number().nullable(), + keywords: z.number().nullable(), + }), + ), + fetchedAt: z.string(), +}); + +type DomainPagesPageResult = z.infer; + +function escapeLikeTerm(term: string): string { + return term.replace(/[\\%_]/g, (match) => `\\${match}`); +} + +function buildPageFilters(searchTerm?: string): unknown[] { + const trimmed = searchTerm?.trim(); + if (!trimmed) return []; + return [["page_address", "ilike", `%${escapeLikeTerm(trimmed)}%`]]; +} + +function mapPageItem(item: RelevantPagesItem) { + const url = item.page_address ?? null; + if (!url) return null; + const organic = item.metrics?.organic ?? null; + const traffic = organic?.etv ?? null; + const keywords = organic?.count ?? null; + return { + page: url, + relativePath: toRelativePath(url), + organicTraffic: traffic != null ? Math.round(traffic) : null, + keywords: keywords != null ? Math.round(keywords) : null, + }; +} + +export async function getPagesPage( + input: { + projectId: string; + domain: string; + includeSubdomains: boolean; + locationCode: number; + languageCode: string; + page: number; + pageSize: number; + sortMode: DomainPagesSortMode; + sortOrder: DomainPagesSortOrder; + search?: string; + }, + billingCustomer: BillingCustomerContext, +): Promise { + const domain = normalizeDomainInput(input.domain, input.includeSubdomains); + const offset = (input.page - 1) * input.pageSize; + const orderBy = [`${SORT_FIELD_BY_MODE[input.sortMode]},${input.sortOrder}`]; + const filters = buildPageFilters(input.search); + + const cacheKey = await buildCacheKey("domain:pages-page", { + organizationId: billingCustomer.organizationId, + projectId: input.projectId, + domain, + includeSubdomains: input.includeSubdomains, + locationCode: input.locationCode, + languageCode: input.languageCode, + page: input.page, + pageSize: input.pageSize, + sortMode: input.sortMode, + sortOrder: input.sortOrder, + search: input.search, + }); + + const cachedRaw = await getCached(cacheKey); + const cached = domainPagesPageResultSchema.safeParse(cachedRaw); + if (cached.success) { + return cached.data; + } + + const dataforseo = createDataforseoClient(billingCustomer); + const response = await dataforseo.domain.relevantPages({ + target: domain, + locationCode: input.locationCode, + languageCode: input.languageCode, + limit: input.pageSize, + offset, + orderBy, + filters: filters.length > 0 ? filters : undefined, + }); + + const pages = response.items + .map(mapPageItem) + .filter( + (item): item is NonNullable> => + item != null, + ); + + const totalCount = response.totalCount; + const hasMore = + totalCount != null + ? offset + pages.length < totalCount + : pages.length === input.pageSize; + + const result: DomainPagesPageResult = { + domain, + page: input.page, + pageSize: input.pageSize, + totalCount, + hasMore, + pages, + fetchedAt: new Date().toISOString(), + }; + + void setCached(cacheKey, result, DOMAIN_PAGES_PAGE_TTL_SECONDS).catch( + (error) => { + console.error("domain.pages-page.cache-write failed:", error); + }, + ); + + return result; +} diff --git a/src/server/lib/dataforseo.ts b/src/server/lib/dataforseo.ts index 32e8e39..8365023 100644 --- a/src/server/lib/dataforseo.ts +++ b/src/server/lib/dataforseo.ts @@ -6,6 +6,7 @@ import { DataforseoLabsGoogleKeywordIdeasLiveRequestInfo, DataforseoLabsGoogleDomainRankOverviewLiveRequestInfo, DataforseoLabsGoogleRankedKeywordsLiveRequestInfo, + DataforseoLabsGoogleRelevantPagesLiveRequestInfo, } from "dataforseo-client"; import { env } from "cloudflare:workers"; import { z } from "zod"; @@ -19,6 +20,7 @@ import { labsKeywordDataItemSchema, parseTaskItems, relatedKeywordItemSchema, + relevantPagesItemSchema, serpSnapshotItemSchema, type DataforseoTask, type DomainMetricsItem, @@ -26,12 +28,14 @@ import { type KeywordOverviewItem, type LabsKeywordDataItem, type RelatedKeywordItem, + type RelevantPagesItem, type SerpLiveItem, successfulDataforseoTaskSchema, } from "@/server/lib/dataforseoSchemas"; export type { DomainRankedKeywordItem, LabsKeywordDataItem, + RelevantPagesItem, SerpLiveItem, } from "@/server/lib/dataforseoSchemas"; @@ -346,29 +350,80 @@ export async function fetchDomainRankOverviewRaw( }; } -export async function fetchRankedKeywordsRaw( - target: string, - locationCode: number, - languageCode: string, - limit: number, - orderBy?: string[], -): Promise> { +type RankedKeywordsPage = { + items: DomainRankedKeywordItem[]; + totalCount: number | null; +}; + +export async function fetchRankedKeywordsRaw(input: { + target: string; + locationCode: number; + languageCode: string; + limit: number; + offset?: number; + orderBy?: string[]; + filters?: unknown[]; +}): Promise> { const api = getLabsApi(); const req = new DataforseoLabsGoogleRankedKeywordsLiveRequestInfo({ - target, - location_code: locationCode, - language_code: languageCode, - limit, - order_by: orderBy, + target: input.target, + location_code: input.locationCode, + language_code: input.languageCode, + limit: input.limit, + offset: input.offset, + order_by: input.orderBy, + filters: input.filters, }); const endpoint = "google-ranked-keywords-live"; const response = await api.googleRankedKeywordsLive([req]); const task = assertOk(response); - const data = parseTaskItems(endpoint, task, domainRankedKeywordItemSchema); + const items = parseTaskItems(endpoint, task, domainRankedKeywordItemSchema); + const rawTotal = (task.result?.[0] as Record | undefined) + ?.total_count; + const totalCount = typeof rawTotal === "number" ? rawTotal : null; return { - data, + data: { items, totalCount }, + billing: buildTaskBilling(task), + }; +} + +type RelevantPagesPage = { + items: RelevantPagesItem[]; + totalCount: number | null; +}; + +export async function fetchRelevantPagesRaw(input: { + target: string; + locationCode: number; + languageCode: string; + limit: number; + offset?: number; + orderBy?: string[]; + filters?: unknown[]; +}): Promise> { + const api = getLabsApi(); + const req = new DataforseoLabsGoogleRelevantPagesLiveRequestInfo({ + target: input.target, + location_code: input.locationCode, + language_code: input.languageCode, + limit: input.limit, + offset: input.offset, + order_by: input.orderBy, + filters: input.filters, + }); + + const endpoint = "google-relevant-pages-live"; + const response = await api.googleRelevantPagesLive([req]); + const task = assertOk(response); + const items = parseTaskItems(endpoint, task, relevantPagesItemSchema); + const rawTotal = (task.result?.[0] as Record | undefined) + ?.total_count; + const totalCount = typeof rawTotal === "number" ? rawTotal : null; + + return { + data: { items, totalCount }, billing: buildTaskBilling(task), }; } diff --git a/src/server/lib/dataforseoClient.test.ts b/src/server/lib/dataforseoClient.test.ts index 212df2a..da60306 100644 --- a/src/server/lib/dataforseoClient.test.ts +++ b/src/server/lib/dataforseoClient.test.ts @@ -306,6 +306,15 @@ describe("mapDataforseoPathToCreditFeature", () => { "live", ]), ).toBe("domain_overview"); + expect( + mapDataforseoPathToCreditFeature([ + "v3", + "dataforseo_labs", + "google", + "relevant_pages", + "live", + ]), + ).toBe("domain_overview"); }); it("maps real backlinks paths", () => { diff --git a/src/server/lib/dataforseoClient.ts b/src/server/lib/dataforseoClient.ts index b04a123..26cb1bc 100644 --- a/src/server/lib/dataforseoClient.ts +++ b/src/server/lib/dataforseoClient.ts @@ -15,6 +15,7 @@ import { fetchRelatedKeywordsRaw, fetchDomainRankOverviewRaw, fetchRankedKeywordsRaw, + fetchRelevantPagesRaw, fetchLiveSerpItemsRaw, fetchRankCheckSerpRaw, type LabsKeywordDataItem, @@ -80,7 +81,11 @@ export function mapDataforseoPathToCreditFeature( return "ai_search"; case "dataforseo_labs": { const endpoint = path[3] ?? ""; - if (endpoint.startsWith("domain_") || endpoint === "ranked_keywords") { + if ( + endpoint.startsWith("domain_") || + endpoint === "ranked_keywords" || + endpoint === "relevant_pages" + ) { return "domain_overview"; } return "keyword_research"; @@ -187,16 +192,25 @@ export function createDataforseoClient(customer: BillingCustomerContext) { locationCode: number; languageCode: string; limit: number; + offset?: number; orderBy?: string[]; + filters?: unknown[]; }) { return meterDataforseoCall(customer, () => - fetchRankedKeywordsRaw( - input.target, - input.locationCode, - input.languageCode, - input.limit, - input.orderBy, - ), + fetchRankedKeywordsRaw(input), + ); + }, + relevantPages(input: { + target: string; + locationCode: number; + languageCode: string; + limit: number; + offset?: number; + orderBy?: string[]; + filters?: unknown[]; + }) { + return meterDataforseoCall(customer, () => + fetchRelevantPagesRaw(input), ); }, }, diff --git a/src/server/lib/dataforseoSchemas.ts b/src/server/lib/dataforseoSchemas.ts index 83329b2..5182429 100644 --- a/src/server/lib/dataforseoSchemas.ts +++ b/src/server/lib/dataforseoSchemas.ts @@ -110,6 +110,16 @@ export const domainMetricsItemSchema = z }) .passthrough(); +export const relevantPagesItemSchema = z + .object({ + page_address: z.string().nullable().optional(), + metrics: z + .record(z.string(), domainMetricsValueSchema.nullable().optional()) + .nullable() + .optional(), + }) + .passthrough(); + const rankedKeywordInfoSchema = z .object({ search_volume: z.number().nullable().optional(), @@ -205,6 +215,7 @@ export type DomainMetricsItem = z.infer; export type DomainRankedKeywordItem = z.infer< typeof domainRankedKeywordItemSchema >; +export type RelevantPagesItem = z.infer; export type SerpLiveItem = z.infer; export function parseTaskItems( diff --git a/src/server/lib/r2-cache.ts b/src/server/lib/r2-cache.ts index f3a0ef0..57a8ab9 100644 --- a/src/server/lib/r2-cache.ts +++ b/src/server/lib/r2-cache.ts @@ -28,6 +28,8 @@ export async function buildCacheKey( /** * Get a cached JSON value from R2. Returns null on miss or expiry. + * Callers should validate the shape with Zod before trusting it — schema + * drift between writes and reads is otherwise silent. */ export async function getCached(key: string): Promise { const obj = await env.R2.get(`${CACHE_PREFIX}${key}`); diff --git a/src/serverFunctions/domain.ts b/src/serverFunctions/domain.ts index af54bef..48bce58 100644 --- a/src/serverFunctions/domain.ts +++ b/src/serverFunctions/domain.ts @@ -3,6 +3,8 @@ import { requireProjectContext } from "@/serverFunctions/middleware"; import { domainOverviewSchema, domainKeywordSuggestionsSchema, + domainKeywordsPageRequestSchema, + domainPagesPageRequestSchema, } from "@/types/schemas/domain"; import { DomainService } from "@/server/features/domain/services/DomainService"; @@ -32,3 +34,31 @@ export const getDomainKeywordSuggestions = createServerFn({ method: "POST" }) context, ), ); + +export const getDomainKeywordsPage = createServerFn({ method: "POST" }) + .middleware(requireProjectContext) + .inputValidator((data: unknown) => + domainKeywordsPageRequestSchema.parse(data), + ) + .handler(async ({ data, context }) => + DomainService.getKeywordsPage( + { + ...data, + projectId: context.projectId, + }, + context, + ), + ); + +export const getDomainPagesPage = createServerFn({ method: "POST" }) + .middleware(requireProjectContext) + .inputValidator((data: unknown) => domainPagesPageRequestSchema.parse(data)) + .handler(async ({ data, context }) => + DomainService.getPagesPage( + { + ...data, + projectId: context.projectId, + }, + context, + ), + ); diff --git a/src/types/schemas/domain.ts b/src/types/schemas/domain.ts index a2665d2..cde4360 100644 --- a/src/types/schemas/domain.ts +++ b/src/types/schemas/domain.ts @@ -58,6 +58,87 @@ export const domainKeywordSuggestionsSchema = z.object({ languageCode: z.string().min(2).max(8), }); +export const DOMAIN_KEYWORDS_PAGE_SIZES = [50, 100, 200] as const; +export const DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE = 100; +export const MAX_DATAFORSEO_FILTER_CONDITIONS = 8; + +const optionalNumber = z + .union([ + z.number(), + z.string().transform((value, ctx) => { + const trimmed = value.trim(); + if (trimmed === "") return undefined; + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) { + ctx.addIssue({ code: "custom", message: "Invalid number" }); + return z.NEVER; + } + return parsed; + }), + ]) + .optional(); + +const domainKeywordsFiltersSchema = z.object({ + include: z.string().optional(), + exclude: z.string().optional(), + minTraffic: optionalNumber, + maxTraffic: optionalNumber, + minVol: optionalNumber, + maxVol: optionalNumber, + minCpc: optionalNumber, + maxCpc: optionalNumber, + minKd: optionalNumber, + maxKd: optionalNumber, + minRank: optionalNumber, + maxRank: optionalNumber, +}); + +export type DomainKeywordsFilters = z.infer; + +export const domainKeywordsPageRequestSchema = z.object({ + projectId: z.string().uuid(), + domain: z.string().min(1).max(255), + includeSubdomains: z.boolean().default(true), + locationCode: z.number().int().positive().default(2840), + languageCode: z.string().min(2).max(8).default("en"), + page: z.number().int().positive().default(1), + pageSize: z + .number() + .int() + .refine((value) => + (DOMAIN_KEYWORDS_PAGE_SIZES as readonly number[]).includes(value), + ) + .default(DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE), + sortMode: z.enum(domainSortModes).default("rank"), + sortOrder: z.enum(domainSortOrders).default("asc"), + filters: domainKeywordsFiltersSchema.default({}), + search: z.string().optional(), +}); + +const domainPagesSortModes = ["traffic", "keywords"] as const; + +export const domainPagesPageRequestSchema = z.object({ + projectId: z.string().uuid(), + domain: z.string().min(1).max(255), + includeSubdomains: z.boolean().default(true), + locationCode: z.number().int().positive().default(2840), + languageCode: z.string().min(2).max(8).default("en"), + page: z.number().int().positive().default(1), + pageSize: z + .number() + .int() + .refine((value) => + (DOMAIN_KEYWORDS_PAGE_SIZES as readonly number[]).includes(value), + ) + .default(DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE), + sortMode: z.enum(domainPagesSortModes).default("traffic"), + sortOrder: z.enum(domainSortOrders).default("desc"), + search: z.string().optional(), +}); + +const filterStringParam = z.string().optional(); +const filterNumberParam = z.coerce.number().optional(); + export const domainSearchSchema = z.object({ domain: z.string().optional(), subdomains: booleanSearchParamSchema.optional(), @@ -66,4 +147,24 @@ export const domainSearchSchema = z.object({ tab: z.enum(domainTabs).optional(), search: z.string().optional(), loc: z.coerce.number().int().positive().optional(), + page: z.coerce.number().int().positive().optional(), + size: z.coerce + .number() + .int() + .refine((value) => + (DOMAIN_KEYWORDS_PAGE_SIZES as readonly number[]).includes(value), + ) + .optional(), + include: filterStringParam, + exclude: filterStringParam, + minTraffic: filterNumberParam, + maxTraffic: filterNumberParam, + minVol: filterNumberParam, + maxVol: filterNumberParam, + minCpc: filterNumberParam, + maxCpc: filterNumberParam, + minKd: filterNumberParam, + maxKd: filterNumberParam, + minRank: filterNumberParam, + maxRank: filterNumberParam, });