feat: Implement server-side pagination and filtering for domain keywords (#143)

This commit is contained in:
Ben Senescu 2026-05-04 15:26:36 -04:00 committed by GitHub
parent a42fe4d350
commit def9390cea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1962 additions and 867 deletions

View File

@ -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<string, unknown>) => Record<string, unknown>;
@ -47,10 +51,6 @@ export function DomainOverviewPage({
navigate,
searchState,
});
const handleShowRecentSearches = () => {
state.resetView();
onShowRecentSearches();
};
return (
<div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto">
@ -92,7 +92,7 @@ export function DomainOverviewPage({
<button
type="button"
className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
onClick={handleShowRecentSearches}
onClick={onShowRecentSearches}
>
<ArrowLeft className="size-4" />
Recent searches
@ -130,22 +130,37 @@ export function DomainOverviewPage({
activeTab={searchState.tab}
sortMode={searchState.sort}
currentSortOrder={state.currentSortOrder}
pendingSearch={state.pendingSearch}
searchDraft={state.searchDraft}
selectedKeywords={state.selectedKeywords}
visibleKeywords={state.visibleKeywords}
filteredKeywords={state.filteredKeywords}
filteredPages={state.filteredPages}
pagedPages={state.pagedPages}
showFilters={state.showFilters}
setShowFilters={state.setShowFilters}
filtersForm={state.filtersForm}
activeFilterCount={state.activeFilterCount}
dirtyFilterCount={state.dirtyFilterCount}
conditionCount={state.conditionCount}
overLimit={state.overLimit}
resetFilters={state.resetFilters}
onSearchChange={state.setPendingSearch}
applyFilters={state.applyFilters}
cancelFilterEdits={state.cancelFilterEdits}
onSearchChange={state.setSearchDraft}
onSaveKeywords={state.handleSaveKeywords}
canSaveKeywords={state.canSaveKeywords}
onSortClick={state.handleSortColumnClick}
onToggleKeyword={state.toggleKeywordSelection}
onToggleAllVisible={state.toggleAllVisibleKeywords}
page={state.page}
pageSize={state.pageSize}
totalKeywordCount={state.totalKeywordCount}
totalPagesCount={state.totalPagesCount}
hasNextKeywordsPage={state.hasNextKeywordsPage}
hasNextPagesPage={state.hasNextPagesPage}
isKeywordsLoading={state.keywordsLoading}
isPagesLoading={state.pagesLoading}
onPageChange={state.goToPage}
onPageSizeChange={state.setPageSize}
/>
</>
)}

View File

@ -1,22 +1,45 @@
import { RotateCcw } from "lucide-react";
import { AlertTriangle, RotateCcw } from "lucide-react";
import type { useDomainFilters } from "@/client/features/domain/hooks/useDomainFilters";
import type { DomainFilterValues } from "@/client/features/domain/types";
import { MAX_DATAFORSEO_FILTER_CONDITIONS } from "@/types/schemas/domain";
type FilterForm = ReturnType<typeof useDomainFilters>["filtersForm"];
type Props = {
filtersForm: FilterForm;
activeFilterCount: number;
dirtyFilterCount: number;
conditionCount: number;
overLimit: boolean;
resetFilters: () => void;
applyFilters: () => void;
cancelFilterEdits: () => void;
};
export function DomainFilterPanel({
filtersForm,
activeFilterCount,
dirtyFilterCount,
conditionCount,
overLimit,
resetFilters,
applyFilters,
cancelFilterEdits,
}: Props) {
const isDirty = dirtyFilterCount > 0;
const canApply = isDirty && !overLimit;
const handleApplyKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Enter" && canApply) {
event.preventDefault();
applyFilters();
}
};
return (
<div className="border-b border-base-300 bg-gradient-to-b from-base-100 to-base-200/30 px-4 py-3 space-y-3">
<div
className="border-b border-base-300 bg-gradient-to-b from-base-100 to-base-200/30 px-4 py-3 space-y-3"
onKeyDown={handleApplyKeyDown}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold">Refine table results</p>
@ -25,11 +48,16 @@ export function DomainFilterPanel({
{activeFilterCount} active
</span>
) : null}
{isDirty ? (
<span className="badge badge-xs badge-warning border-0">
{dirtyFilterCount} unapplied
</span>
) : null}
</div>
<button
className="btn btn-xs btn-ghost gap-1"
onClick={resetFilters}
disabled={activeFilterCount === 0}
disabled={activeFilterCount === 0 && !isDirty}
>
<RotateCcw className="size-3" />
Clear all
@ -84,6 +112,51 @@ export function DomainFilterPanel({
maxName="maxRank"
/>
</div>
{overLimit ? (
<div className="alert alert-warning py-2 text-xs">
<AlertTriangle className="size-4 shrink-0" />
<span>
Too many filter conditions ({conditionCount} of{" "}
{MAX_DATAFORSEO_FILTER_CONDITIONS} max). Remove some terms or ranges
before applying.
</span>
</div>
) : null}
<div className="flex items-center justify-between gap-2 pt-1">
<span className="text-xs text-base-content/50 tabular-nums">
{conditionCount} / {MAX_DATAFORSEO_FILTER_CONDITIONS} conditions
</span>
<div className="flex items-center gap-2">
<button
type="button"
className="btn btn-sm btn-ghost"
onClick={cancelFilterEdits}
disabled={!isDirty}
>
Cancel
</button>
<button
type="button"
className="btn btn-sm btn-primary"
onClick={applyFilters}
disabled={!canApply}
title={
overLimit
? `DataForSEO accepts at most ${MAX_DATAFORSEO_FILTER_CONDITIONS} filter conditions per request`
: undefined
}
>
Apply filters
{isDirty ? (
<span className="badge badge-xs ml-1 border-0 bg-primary-content/20">
{dirtyFilterCount}
</span>
) : null}
</button>
</div>
</div>
</div>
);
}

View File

@ -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 (
<div className="flex flex-col gap-3 border-t border-base-300 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 text-sm text-base-content/70 tabular-nums">
<span>{formatRange(page, pageSize, totalCount)}</span>
{isLoading ? (
<span className="loading loading-spinner loading-xs" />
) : null}
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 text-sm text-base-content/70">
<span className="whitespace-nowrap">Rows per page</span>
<select
className="select select-bordered select-sm w-20"
value={pageSize}
onChange={(event) => onPageSizeChange(Number(event.target.value))}
>
{DOMAIN_KEYWORDS_PAGE_SIZES.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</label>
<div className="flex items-center gap-2">
<span className="whitespace-nowrap text-sm tabular-nums text-base-content/70">
Page {page.toLocaleString()}
{totalPages != null ? ` of ${totalPages.toLocaleString()}` : ""}
</span>
<div className="flex items-center gap-1">
<button
type="button"
className="btn btn-ghost btn-sm btn-square"
disabled={!canGoPrev || isLoading}
onClick={() => onPageChange(page - 1)}
aria-label="Previous page"
>
<ChevronLeft className="size-4" />
</button>
<button
type="button"
className="btn btn-ghost btn-sm btn-square"
disabled={!canGoNext || isLoading}
onClick={() => onPageChange(page + 1)}
aria-label="Next page"
>
<ChevronRight className="size-4" />
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -112,7 +112,7 @@ export function DomainKeywordsTable({
</td>
</tr>
) : (
rows.slice(0, 100).map((row) => {
rows.map((row) => {
const href = resolveDomainPageHref(
row.relativeUrl ?? row.url,
domain,

View File

@ -44,7 +44,7 @@ export function DomainPagesTable({
<th>
<SortableHeader
label="Keywords"
isActive={toPageSortMode(sortMode) === "volume"}
isActive={toPageSortMode(sortMode) === "keywords"}
order={currentSortOrder}
onClick={() => onSortClick("volume")}
/>

View File

@ -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<string>;
visibleKeywords: string[];
filteredKeywords: KeywordRow[];
filteredPages: PageRow[];
pagedPages: PageRow[];
showFilters: boolean;
setShowFilters: Dispatch<SetStateAction<boolean>>;
filtersForm: ReturnType<typeof useDomainFilters>["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<DomainSortMode> = 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,9 +245,8 @@ export function DomainResultsCard({
</div>
</div>
{isKeywordsTab ? (
<>
<div className="flex items-center gap-2 px-4 py-2 border-b border-base-300">
{isKeywordsTab ? (
<button
className={`btn btn-ghost btn-sm gap-1.5 ${showFilters ? "btn-active" : ""}`}
onClick={() => setShowFilters((prev) => !prev)}
@ -229,32 +260,57 @@ export function DomainResultsCard({
</span>
) : null}
</button>
) : null}
<span className="text-sm text-base-content/60">
{filteredKeywords.length} keywords
{isKeywordsTab
? totalKeywordCount != null
? `${totalKeywordCount.toLocaleString()} keywords`
: `${filteredKeywords.length.toLocaleString()} keywords`
: totalPagesCount != null
? `${totalPagesCount.toLocaleString()} pages`
: `${pagedPages.length.toLocaleString()} pages`}
</span>
<div className="flex-1" />
<label className="input input-bordered input-sm w-full max-w-xs flex items-center gap-2">
<form
className="w-full max-w-xs"
onSubmit={(event) => {
event.preventDefault();
if (!overLimit) applyFilters();
}}
>
<label className="input input-bordered input-sm w-full flex items-center gap-2">
<Search className="size-4 text-base-content/60" />
<input
placeholder="Search in results"
value={pendingSearch}
placeholder="Search in results (press Enter)"
value={searchDraft}
onChange={(event) => onSearchChange(event.target.value)}
/>
</label>
</form>
</div>
{showFilters ? (
{isKeywordsTab && showFilters ? (
<DomainFilterPanel
filtersForm={filtersForm}
activeFilterCount={activeFilterCount}
dirtyFilterCount={dirtyFilterCount}
conditionCount={conditionCount}
overLimit={overLimit}
resetFilters={resetFilters}
applyFilters={applyFilters}
cancelFilterEdits={cancelFilterEdits}
/>
) : null}
</>
) : null}
<div className="p-4">
{isKeywordsTab ? (
<div
className={
isKeywordsLoading
? "opacity-60 transition-opacity"
: "transition-opacity"
}
>
<DomainKeywordsTable
domain={overview.domain}
rows={filteredKeywords}
@ -266,16 +322,35 @@ export function DomainResultsCard({
onToggleKeyword={onToggleKeyword}
onToggleAllVisible={onToggleAllVisible}
/>
</div>
) : (
<div
className={
isPagesLoading
? "opacity-60 transition-opacity"
: "transition-opacity"
}
>
<DomainPagesTable
domain={overview.domain}
rows={filteredPages}
rows={pagedPages}
sortMode={sortMode}
currentSortOrder={currentSortOrder}
onSortClick={onSortClick}
/>
</div>
)}
</div>
<DomainKeywordsPagination
page={page}
pageSize={pageSize}
totalCount={isKeywordsTab ? totalKeywordCount : totalPagesCount}
hasNextPage={isKeywordsTab ? hasNextKeywordsPage : hasNextPagesPage}
isLoading={isKeywordsTab ? isKeywordsLoading : isPagesLoading}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
/>
</div>
);
}

View File

@ -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<string>;
filteredKeywords: DomainOverviewData["keywords"];
filteredKeywords: KeywordRow[];
save: (payload: Parameters<SaveMutation>[0], opts?: SaveOptions) => void;
projectId: string;
locationCode: number;

View File

@ -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,
]);
}

View File

@ -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<SetStateAction<Set<string>>>;
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<string, string | number | boolean | undefined>,
) => void;
domainMutation: ReturnType<typeof useDomainLookupMutation>;
addSearch: (item: Omit<DomainSearchHistoryItem, "timestamp">) => void;
setOverview: (value: DomainOverviewData, locationCode: number) => void;
setSelectedKeywords: Dispatch<SetStateAction<Set<string>>>;
currentState: SearchState;
currentSortOrder: SortOrder;
}) {
return async (params?: Partial<SearchState>) => {
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.");
}
};
}

View File

@ -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);
}

View File

@ -1,19 +1,12 @@
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 filtersForm = useForm({
defaultValues: EMPTY_DOMAIN_FILTERS,
});
const values = useStore(filtersForm.store, (s) => s.values);
const resetFilters = useCallback(() => {
const keys: Array<keyof DomainFilterValues> = [
const FILTER_KEYS: Array<keyof DomainFilterValues> = [
"include",
"exclude",
"minTraffic",
@ -26,15 +19,149 @@ export function useDomainFilters() {
"maxKd",
"minRank",
"maxRank",
];
for (const key of keys) {
filtersForm.setFieldValue(key, "");
];
function filtersToSearchParams(
values: DomainFilterValues,
): Record<string, string | number | undefined> {
const out: Record<string, string | number | undefined> = {};
for (const key of FILTER_KEYS) {
const trimmed = values[key].trim();
if (trimmed === "") {
out[key] = undefined;
continue;
}
}, [filtersForm]);
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<string, string | number | boolean | undefined>,
) => void;
}) {
const filtersForm = useForm({
defaultValues: appliedValues,
});
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(() => {
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,
};
}

View File

@ -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<string, unknown> {
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,
});
}

View File

@ -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,
});
}

View File

@ -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,
});
}

View File

@ -64,8 +64,6 @@ export type DomainOverviewData = {
backlinks: number | null;
referringDomains: number | null;
hasData: boolean;
keywords: KeywordRow[];
pages: PageRow[];
};
export type DomainHistoryItem = {

View File

@ -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<unknown>;
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<typeof useOverviewDataState>;
projectId: string;
saveMutation: ReturnType<typeof useSaveKeywordsMutation>;
selectedKeywords: Set<string>;
setSearchParams: (
updates: Record<string, string | number | boolean | undefined>,
) => 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,
};
}

View File

@ -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<unknown>;
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<DomainOverviewData | null>(null);
const [overviewLocationCode, setOverviewLocationCode] = useState<
number | null
>(null);
const [selectedKeywords, setSelectedKeywords] = useState<Set<string>>(
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,
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,
});
},
});
formApi.setErrorMap({
onSubmit: submitError
? createFormValidationErrors({ form: submitError })
useSyncRouteState({ controlsForm, searchState, navigate });
const saveMutation = useSaveKeywordsMutation({ projectId, queryClient });
// 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,
});
},
});
useSyncRouteState({ controlsForm, searchState, setPendingSearch, navigate });
const domainMutation = useDomainLookupMutation(projectId);
const saveMutation = useSaveKeywordsMutation({ projectId, queryClient });
const dataState = useOverviewDataState({
overview,
pendingSearch,
filters: domainFilters.values,
sortMode: searchState.sort,
currentSortOrder,
setSelectedKeywords,
});
}, [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<string>("");
useEffect(() => {
setSearchParams({ search: pendingSearch.trim() || undefined });
}, [pendingSearch, setSearchParams]);
if (!overviewQuery.isSuccess || !overviewQuery.data) return;
const key = `${searchState.domain}|${searchState.subdomains}|${searchState.locationCode}`;
if (lastTrackedKey.current === key) return;
lastTrackedKey.current = key;
const runSearch = useSearchRunner({
controlsForm,
setPendingSearch,
setSearchParams,
domainMutation,
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,
setOverview: (value, locationCode) => {
setOverview(value);
setOverviewLocationCode(locationCode);
},
]);
// 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,
sortOrder: currentSortOrder,
appliedFilters: searchState.appliedFilters,
searchTerm: searchState.search,
enabled: overview !== null && overview.hasData && keywordsTabActive,
});
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 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<typeof useOverviewDataState>;
overviewLocationCode: number | null;
projectId: string;
runSearch: ReturnType<typeof useSearchRunner>;
saveMutation: ReturnType<typeof useSaveKeywordsMutation>;
selectedKeywords: Set<string>;
setSearchParams: (
updates: Record<string, string | number | boolean | undefined>,
) => 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,
};
}

View File

@ -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";
}

View File

@ -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 (
<DomainOverviewPage
projectId={projectId}
onShowRecentSearches={() => {
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,
}}
/>
);

View File

@ -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<typeof domainOverviewResultSchema>;
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({
const metricsResponse = await 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 metrics = metricsResponse[0];
const rankedItems = rankedKeywordsResponse;
const keywords = rankedItems
.map((item) => mapKeywordItem(item))
.filter(
(item): item is NonNullable<ReturnType<typeof mapKeywordItem>> =>
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<ReturnType<typeof mapKeywordItem>> =>
@ -333,4 +175,6 @@ async function getSuggestedKeywords(
export const DomainService = {
getOverview,
getSuggestedKeywords,
getKeywordsPage,
getPagesPage,
} as const;

View File

@ -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/);
});
});

View File

@ -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<DomainKeywordsSortMode, string> = {
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}%`],
];
}

View File

@ -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,
};
}

View File

@ -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<typeof domainKeywordsPageResultSchema>;
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<DomainKeywordsPageResult> {
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<ReturnType<typeof mapKeywordItem>> =>
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;
}

View File

@ -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<DomainPagesSortMode, string> = {
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<typeof domainPagesPageResultSchema>;
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<DomainPagesPageResult> {
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<ReturnType<typeof mapPageItem>> =>
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;
}

View File

@ -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<DataforseoApiResponse<DomainRankedKeywordItem[]>> {
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<DataforseoApiResponse<RankedKeywordsPage>> {
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<string, unknown> | 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<DataforseoApiResponse<RelevantPagesPage>> {
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<string, unknown> | undefined)
?.total_count;
const totalCount = typeof rawTotal === "number" ? rawTotal : null;
return {
data: { items, totalCount },
billing: buildTaskBilling(task),
};
}

View File

@ -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", () => {

View File

@ -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),
);
},
},

View File

@ -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<typeof domainMetricsItemSchema>;
export type DomainRankedKeywordItem = z.infer<
typeof domainRankedKeywordItemSchema
>;
export type RelevantPagesItem = z.infer<typeof relevantPagesItemSchema>;
export type SerpLiveItem = z.infer<typeof serpSnapshotItemSchema>;
export function parseTaskItems<T extends z.ZodType>(

View File

@ -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<unknown> {
const obj = await env.R2.get(`${CACHE_PREFIX}${key}`);

View File

@ -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,
),
);

View File

@ -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<typeof domainKeywordsFiltersSchema>;
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,
});