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"; } from "@/client/features/domain/utils";
import type { import type {
DomainActiveTab, DomainActiveTab,
DomainFilterValues,
DomainSortMode, DomainSortMode,
SortOrder, SortOrder,
} from "@/client/features/domain/types"; } from "@/client/features/domain/types";
@ -26,6 +27,9 @@ type Props = {
tab: DomainActiveTab; tab: DomainActiveTab;
search: string; search: string;
locationCode: number; locationCode: number;
page: number;
pageSize: number;
appliedFilters: DomainFilterValues;
}; };
navigate: (args: { navigate: (args: {
search: (prev: Record<string, unknown>) => Record<string, unknown>; search: (prev: Record<string, unknown>) => Record<string, unknown>;
@ -47,10 +51,6 @@ export function DomainOverviewPage({
navigate, navigate,
searchState, searchState,
}); });
const handleShowRecentSearches = () => {
state.resetView();
onShowRecentSearches();
};
return ( return (
<div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto"> <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 <button
type="button" type="button"
className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent" 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" /> <ArrowLeft className="size-4" />
Recent searches Recent searches
@ -130,22 +130,37 @@ export function DomainOverviewPage({
activeTab={searchState.tab} activeTab={searchState.tab}
sortMode={searchState.sort} sortMode={searchState.sort}
currentSortOrder={state.currentSortOrder} currentSortOrder={state.currentSortOrder}
pendingSearch={state.pendingSearch} searchDraft={state.searchDraft}
selectedKeywords={state.selectedKeywords} selectedKeywords={state.selectedKeywords}
visibleKeywords={state.visibleKeywords} visibleKeywords={state.visibleKeywords}
filteredKeywords={state.filteredKeywords} filteredKeywords={state.filteredKeywords}
filteredPages={state.filteredPages} pagedPages={state.pagedPages}
showFilters={state.showFilters} showFilters={state.showFilters}
setShowFilters={state.setShowFilters} setShowFilters={state.setShowFilters}
filtersForm={state.filtersForm} filtersForm={state.filtersForm}
activeFilterCount={state.activeFilterCount} activeFilterCount={state.activeFilterCount}
dirtyFilterCount={state.dirtyFilterCount}
conditionCount={state.conditionCount}
overLimit={state.overLimit}
resetFilters={state.resetFilters} resetFilters={state.resetFilters}
onSearchChange={state.setPendingSearch} applyFilters={state.applyFilters}
cancelFilterEdits={state.cancelFilterEdits}
onSearchChange={state.setSearchDraft}
onSaveKeywords={state.handleSaveKeywords} onSaveKeywords={state.handleSaveKeywords}
canSaveKeywords={state.canSaveKeywords} canSaveKeywords={state.canSaveKeywords}
onSortClick={state.handleSortColumnClick} onSortClick={state.handleSortColumnClick}
onToggleKeyword={state.toggleKeywordSelection} onToggleKeyword={state.toggleKeywordSelection}
onToggleAllVisible={state.toggleAllVisibleKeywords} 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 { useDomainFilters } from "@/client/features/domain/hooks/useDomainFilters";
import type { DomainFilterValues } from "@/client/features/domain/types"; import type { DomainFilterValues } from "@/client/features/domain/types";
import { MAX_DATAFORSEO_FILTER_CONDITIONS } from "@/types/schemas/domain";
type FilterForm = ReturnType<typeof useDomainFilters>["filtersForm"]; type FilterForm = ReturnType<typeof useDomainFilters>["filtersForm"];
type Props = { type Props = {
filtersForm: FilterForm; filtersForm: FilterForm;
activeFilterCount: number; activeFilterCount: number;
dirtyFilterCount: number;
conditionCount: number;
overLimit: boolean;
resetFilters: () => void; resetFilters: () => void;
applyFilters: () => void;
cancelFilterEdits: () => void;
}; };
export function DomainFilterPanel({ export function DomainFilterPanel({
filtersForm, filtersForm,
activeFilterCount, activeFilterCount,
dirtyFilterCount,
conditionCount,
overLimit,
resetFilters, resetFilters,
applyFilters,
cancelFilterEdits,
}: Props) { }: Props) {
const isDirty = dirtyFilterCount > 0;
const canApply = isDirty && !overLimit;
const handleApplyKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Enter" && canApply) {
event.preventDefault();
applyFilters();
}
};
return ( 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 flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="text-sm font-semibold">Refine table results</p> <p className="text-sm font-semibold">Refine table results</p>
@ -25,11 +48,16 @@ export function DomainFilterPanel({
{activeFilterCount} active {activeFilterCount} active
</span> </span>
) : null} ) : null}
{isDirty ? (
<span className="badge badge-xs badge-warning border-0">
{dirtyFilterCount} unapplied
</span>
) : null}
</div> </div>
<button <button
className="btn btn-xs btn-ghost gap-1" className="btn btn-xs btn-ghost gap-1"
onClick={resetFilters} onClick={resetFilters}
disabled={activeFilterCount === 0} disabled={activeFilterCount === 0 && !isDirty}
> >
<RotateCcw className="size-3" /> <RotateCcw className="size-3" />
Clear all Clear all
@ -84,6 +112,51 @@ export function DomainFilterPanel({
maxName="maxRank" maxName="maxRank"
/> />
</div> </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> </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> </td>
</tr> </tr>
) : ( ) : (
rows.slice(0, 100).map((row) => { rows.map((row) => {
const href = resolveDomainPageHref( const href = resolveDomainPageHref(
row.relativeUrl ?? row.url, row.relativeUrl ?? row.url,
domain, domain,

View File

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

View File

@ -12,6 +12,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DomainFilterPanel } from "@/client/features/domain/components/DomainFilterPanel"; 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 { DomainKeywordsTable } from "@/client/features/domain/components/DomainKeywordsTable";
import { DomainPagesTable } from "@/client/features/domain/components/DomainPagesTable"; import { DomainPagesTable } from "@/client/features/domain/components/DomainPagesTable";
import type { useDomainFilters } from "@/client/features/domain/hooks/useDomainFilters"; import type { useDomainFilters } from "@/client/features/domain/hooks/useDomainFilters";
@ -38,22 +39,37 @@ type Props = {
activeTab: DomainActiveTab; activeTab: DomainActiveTab;
sortMode: DomainSortMode; sortMode: DomainSortMode;
currentSortOrder: SortOrder; currentSortOrder: SortOrder;
pendingSearch: string; searchDraft: string;
selectedKeywords: Set<string>; selectedKeywords: Set<string>;
visibleKeywords: string[]; visibleKeywords: string[];
filteredKeywords: KeywordRow[]; filteredKeywords: KeywordRow[];
filteredPages: PageRow[]; pagedPages: PageRow[];
showFilters: boolean; showFilters: boolean;
setShowFilters: Dispatch<SetStateAction<boolean>>; setShowFilters: Dispatch<SetStateAction<boolean>>;
filtersForm: ReturnType<typeof useDomainFilters>["filtersForm"]; filtersForm: ReturnType<typeof useDomainFilters>["filtersForm"];
activeFilterCount: number; activeFilterCount: number;
dirtyFilterCount: number;
conditionCount: number;
overLimit: boolean;
resetFilters: () => void; resetFilters: () => void;
applyFilters: () => void;
cancelFilterEdits: () => void;
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
onSaveKeywords: () => void; onSaveKeywords: () => void;
canSaveKeywords: boolean; canSaveKeywords: boolean;
onSortClick: (sort: DomainSortMode) => void; onSortClick: (sort: DomainSortMode) => void;
onToggleKeyword: (keyword: string) => void; onToggleKeyword: (keyword: string) => void;
onToggleAllVisible: () => 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([ const KEYWORDS_ONLY_SORTS: ReadonlySet<DomainSortMode> = new Set([
@ -68,28 +84,43 @@ export function DomainResultsCard({
activeTab, activeTab,
sortMode, sortMode,
currentSortOrder, currentSortOrder,
pendingSearch, searchDraft,
selectedKeywords, selectedKeywords,
visibleKeywords, visibleKeywords,
filteredKeywords, filteredKeywords,
filteredPages, pagedPages,
showFilters, showFilters,
setShowFilters, setShowFilters,
filtersForm, filtersForm,
activeFilterCount, activeFilterCount,
dirtyFilterCount,
conditionCount,
overLimit,
resetFilters, resetFilters,
applyFilters,
cancelFilterEdits,
onSearchChange, onSearchChange,
onSaveKeywords, onSaveKeywords,
canSaveKeywords, canSaveKeywords,
onSortClick, onSortClick,
onToggleKeyword, onToggleKeyword,
onToggleAllVisible, onToggleAllVisible,
page,
pageSize,
totalKeywordCount,
totalPagesCount,
hasNextKeywordsPage,
hasNextPagesPage,
isKeywordsLoading,
isPagesLoading,
onPageChange,
onPageSizeChange,
}: Props) { }: Props) {
const isKeywordsTab = activeTab === "keywords"; const isKeywordsTab = activeTab === "keywords";
const currentRows = isKeywordsTab ? filteredKeywords : filteredPages; const currentRows = isKeywordsTab ? filteredKeywords : pagedPages;
const exportTable = isKeywordsTab const exportTable = isKeywordsTab
? keywordsToTable(filteredKeywords) ? keywordsToTable(filteredKeywords)
: pagesToTable(filteredPages); : pagesToTable(pagedPages);
const handleCopy = async () => { const handleCopy = async () => {
const text = JSON.stringify(currentRows, null, 2); const text = JSON.stringify(currentRows, null, 2);
@ -127,7 +158,7 @@ export function DomainResultsCard({
from="/p/$projectId/domain" from="/p/$projectId/domain"
to="/p/$projectId/domain" to="/p/$projectId/domain"
params={{ projectId }} params={{ projectId }}
search={(prev) => ({ ...prev, tab: undefined })} search={(prev) => ({ ...prev, tab: undefined, page: undefined })}
replace replace
role="tab" role="tab"
className={`tab ${activeTab === "keywords" ? "tab-active" : ""}`} className={`tab ${activeTab === "keywords" ? "tab-active" : ""}`}
@ -149,6 +180,7 @@ export function DomainResultsCard({
tab: "pages" as const, tab: "pages" as const,
sort: nextSort, sort: nextSort,
order: nextOrder, order: nextOrder,
page: undefined,
}; };
}} }}
replace replace
@ -213,69 +245,112 @@ export function DomainResultsCard({
</div> </div>
</div> </div>
{isKeywordsTab ? ( <div className="flex items-center gap-2 px-4 py-2 border-b border-base-300">
<> {isKeywordsTab ? (
<div className="flex items-center gap-2 px-4 py-2 border-b border-base-300"> <button
<button className={`btn btn-ghost btn-sm gap-1.5 ${showFilters ? "btn-active" : ""}`}
className={`btn btn-ghost btn-sm gap-1.5 ${showFilters ? "btn-active" : ""}`} onClick={() => setShowFilters((prev) => !prev)}
onClick={() => setShowFilters((prev) => !prev)} title="Toggle filters"
title="Toggle filters" >
> <SlidersHorizontal className="size-3.5" />
<SlidersHorizontal className="size-3.5" /> Filters
Filters {activeFilterCount > 0 ? (
{activeFilterCount > 0 ? ( <span className="badge badge-xs badge-primary border-0 text-primary-content">
<span className="badge badge-xs badge-primary border-0 text-primary-content"> {activeFilterCount}
{activeFilterCount} </span>
</span> ) : null}
) : null} </button>
</button> ) : null}
<span className="text-sm text-base-content/60"> <span className="text-sm text-base-content/60">
{filteredKeywords.length} keywords {isKeywordsTab
</span> ? totalKeywordCount != null
<div className="flex-1" /> ? `${totalKeywordCount.toLocaleString()} keywords`
<label className="input input-bordered input-sm w-full max-w-xs flex items-center gap-2"> : `${filteredKeywords.length.toLocaleString()} keywords`
<Search className="size-4 text-base-content/60" /> : totalPagesCount != null
<input ? `${totalPagesCount.toLocaleString()} pages`
placeholder="Search in results" : `${pagedPages.length.toLocaleString()} pages`}
value={pendingSearch} </span>
onChange={(event) => onSearchChange(event.target.value)} <div className="flex-1" />
/> <form
</label> className="w-full max-w-xs"
</div> onSubmit={(event) => {
event.preventDefault();
{showFilters ? ( if (!overLimit) applyFilters();
<DomainFilterPanel }}
filtersForm={filtersForm} >
activeFilterCount={activeFilterCount} <label className="input input-bordered input-sm w-full flex items-center gap-2">
resetFilters={resetFilters} <Search className="size-4 text-base-content/60" />
<input
placeholder="Search in results (press Enter)"
value={searchDraft}
onChange={(event) => onSearchChange(event.target.value)}
/> />
) : null} </label>
</> </form>
</div>
{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"> <div className="p-4">
{isKeywordsTab ? ( {isKeywordsTab ? (
<DomainKeywordsTable <div
domain={overview.domain} className={
rows={filteredKeywords} isKeywordsLoading
selectedKeywords={selectedKeywords} ? "opacity-60 transition-opacity"
visibleKeywords={visibleKeywords} : "transition-opacity"
sortMode={sortMode} }
currentSortOrder={currentSortOrder} >
onSortClick={onSortClick} <DomainKeywordsTable
onToggleKeyword={onToggleKeyword} domain={overview.domain}
onToggleAllVisible={onToggleAllVisible} rows={filteredKeywords}
/> selectedKeywords={selectedKeywords}
visibleKeywords={visibleKeywords}
sortMode={sortMode}
currentSortOrder={currentSortOrder}
onSortClick={onSortClick}
onToggleKeyword={onToggleKeyword}
onToggleAllVisible={onToggleAllVisible}
/>
</div>
) : ( ) : (
<DomainPagesTable <div
domain={overview.domain} className={
rows={filteredPages} isPagesLoading
sortMode={sortMode} ? "opacity-60 transition-opacity"
currentSortOrder={currentSortOrder} : "transition-opacity"
onSortClick={onSortClick} }
/> >
<DomainPagesTable
domain={overview.domain}
rows={pagedPages}
sortMode={sortMode}
currentSortOrder={currentSortOrder}
onSortClick={onSortClick}
/>
</div>
)} )}
</div> </div>
<DomainKeywordsPagination
page={page}
pageSize={pageSize}
totalCount={isKeywordsTab ? totalKeywordCount : totalPagesCount}
hasNextPage={isKeywordsTab ? hasNextKeywordsPage : hasNextPagesPage}
isLoading={isKeywordsTab ? isKeywordsLoading : isPagesLoading}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
/>
</div> </div>
); );
} }

View File

@ -1,7 +1,7 @@
import { toast } from "sonner"; import { toast } from "sonner";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog"; 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: { type SaveMutation = (payload: {
projectId: string; projectId: string;
@ -30,7 +30,7 @@ export function saveSelectedKeywords({
languageCode, languageCode,
}: { }: {
selectedKeywords: Set<string>; selectedKeywords: Set<string>;
filteredKeywords: DomainOverviewData["keywords"]; filteredKeywords: KeywordRow[];
save: (payload: Parameters<SaveMutation>[0], opts?: SaveOptions) => void; save: (payload: Parameters<SaveMutation>[0], opts?: SaveOptions) => void;
projectId: string; projectId: string;
locationCode: number; 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 { useEffect, useMemo, type Dispatch, type SetStateAction } from "react";
import type { UpdateMetaOptions } from "@tanstack/react-form"; 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 { import {
getDefaultSortOrder, getDefaultSortOrder,
normalizeDomainTarget,
sortableNullableNumber,
toPageSortMode,
toSortMode, toSortMode,
toSortOrder, toSortOrder,
toSortOrderSearchParam,
toSortSearchParam,
} from "@/client/features/domain/utils"; } from "@/client/features/domain/utils";
import type { import type {
DomainActiveTab, DomainActiveTab,
DomainFilterValues, DomainFilterValues,
DomainOverviewData,
DomainSortMode, DomainSortMode,
KeywordRow,
SortOrder, SortOrder,
} from "@/client/features/domain/types"; } from "@/client/features/domain/types";
import type { DomainSearchHistoryItem } from "@/client/hooks/useDomainSearchHistory"; import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
import {
DEFAULT_LOCATION_CODE,
getLanguageCode,
isSupportedLocationCode,
} from "@/client/features/keywords/locations";
export type SearchState = { export type SearchState = {
domain: string; domain: string;
@ -39,6 +22,9 @@ export type SearchState = {
tab: DomainActiveTab; tab: DomainActiveTab;
search: string; search: string;
locationCode: number; locationCode: number;
page: number;
pageSize: number;
appliedFilters: DomainFilterValues;
}; };
type DomainNavigate = (args: { type DomainNavigate = (args: {
@ -71,57 +57,20 @@ type DomainControlsFormAccess = {
type ControlsFormLike = DomainControlsFormAccess; type ControlsFormLike = DomainControlsFormAccess;
export function useOverviewDataState({ export function useOverviewDataState({
overview, pagedKeywords,
pendingSearch,
filters,
sortMode,
currentSortOrder,
setSelectedKeywords, setSelectedKeywords,
activeFilterCount,
}: { }: {
overview: DomainOverviewData | null; pagedKeywords: KeywordRow[];
pendingSearch: string;
filters: DomainFilterValues;
sortMode: DomainSortMode;
currentSortOrder: SortOrder;
setSelectedKeywords: Dispatch<SetStateAction<Set<string>>>; setSelectedKeywords: Dispatch<SetStateAction<Set<string>>>;
activeFilterCount: number;
}) { }) {
const filteredKeywords = useMemo( // Keywords are now fetched server-side with filters/sort/pagination applied,
() => // so we render whatever the page query returned.
filterAndSortKeywords({ const filteredKeywords = pagedKeywords;
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]);
const visibleKeywords = useMemo( const visibleKeywords = useMemo(
() => filteredKeywords.slice(0, 100).map((row) => row.keyword), () => filteredKeywords.map((row) => row.keyword),
[filteredKeywords], [filteredKeywords],
); );
@ -136,14 +85,8 @@ export function useOverviewDataState({
}); });
}, [setSelectedKeywords, visibleKeywords]); }, [setSelectedKeywords, visibleKeywords]);
const activeFilterCount = useMemo(
() => Object.values(filters).filter((value) => value.trim() !== "").length,
[filters],
);
return { return {
filteredKeywords, filteredKeywords,
filteredPages,
visibleKeywords, visibleKeywords,
activeFilterCount, activeFilterCount,
toggleKeywordSelection: (keyword: string) => { toggleKeywordSelection: (keyword: string) => {
@ -172,12 +115,10 @@ export function useOverviewDataState({
export function useSyncRouteState({ export function useSyncRouteState({
controlsForm, controlsForm,
searchState, searchState,
setPendingSearch,
navigate, navigate,
}: { }: {
controlsForm: ControlsFormLike; controlsForm: ControlsFormLike;
searchState: SearchState; searchState: SearchState;
setPendingSearch: (value: string) => void;
navigate: DomainNavigate; navigate: DomainNavigate;
}) { }) {
useEffect(() => { useEffect(() => {
@ -187,8 +128,7 @@ export function useSyncRouteState({
sort: searchState.sort, sort: searchState.sort,
locationCode: searchState.locationCode, locationCode: searchState.locationCode,
}); });
setPendingSearch(searchState.search); }, [controlsForm, searchState]);
}, [controlsForm, searchState, setPendingSearch]);
useEffect(() => { useEffect(() => {
const raw = new URLSearchParams(window.location.search); const raw = new URLSearchParams(window.location.search);
@ -232,118 +172,3 @@ export function useSyncRouteState({
}); });
}, [navigate]); }, [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,40 +1,167 @@
import { useCallback } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useForm, useStore } from "@tanstack/react-form"; import { useForm, useStore } from "@tanstack/react-form";
import { import {
EMPTY_DOMAIN_FILTERS, EMPTY_DOMAIN_FILTERS,
type DomainFilterValues, type DomainFilterValues,
} from "@/client/features/domain/types"; } from "@/client/features/domain/types";
import { MAX_DATAFORSEO_FILTER_CONDITIONS } from "@/types/schemas/domain";
export function useDomainFilters() { const FILTER_KEYS: Array<keyof DomainFilterValues> = [
"include",
"exclude",
"minTraffic",
"maxTraffic",
"minVol",
"maxVol",
"minCpc",
"maxCpc",
"minKd",
"maxKd",
"minRank",
"maxRank",
];
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;
}
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({ 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 resetFilters = useCallback(() => {
const keys: Array<keyof DomainFilterValues> = [ filtersForm.reset({ ...EMPTY_DOMAIN_FILTERS }, { keepDefaultValues: true });
"include", setSearchDraft("");
"exclude", setSearchParams({
"minTraffic", ...filtersToSearchParams(EMPTY_DOMAIN_FILTERS),
"maxTraffic", search: undefined,
"minVol", page: undefined,
"maxVol", });
"minCpc", }, [filtersForm, setSearchParams]);
"maxCpc",
"minKd", const activeAppliedCount = useMemo(
"maxKd", () =>
"minRank", FILTER_KEYS.filter((key) => appliedValues[key].trim() !== "").length +
"maxRank", (appliedSearch.trim() !== "" ? 1 : 0),
]; [appliedValues, appliedSearch],
for (const key of keys) { );
filtersForm.setFieldValue(key, ""); const dirtyCount = useMemo(() => {
} const filterDirt = FILTER_KEYS.filter(
}, [filtersForm]); (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 { return {
filtersForm, filtersForm,
values, draftValues,
appliedValues,
searchDraft,
setSearchDraft,
activeAppliedCount,
dirtyCount,
conditionCount,
overLimit,
applyFilters,
cancelEdits,
resetFilters, 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; backlinks: number | null;
referringDomains: number | null; referringDomains: number | null;
hasData: boolean; hasData: boolean;
keywords: KeywordRow[];
pages: PageRow[];
}; };
export type DomainHistoryItem = { 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 { useForm } from "@tanstack/react-form";
import { type QueryClient } from "@tanstack/react-query"; import { type QueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useDomainSearchHistory } from "@/client/hooks/useDomainSearchHistory";
import { import {
useDomainSearchHistory,
type DomainSearchHistoryItem,
} from "@/client/hooks/useDomainSearchHistory";
import {
getDefaultSortOrder,
normalizeDomainTarget, normalizeDomainTarget,
resolveSortOrder, resolveSortOrder,
toSortOrderSearchParam, toSortOrderSearchParam,
@ -16,27 +13,29 @@ import {
createFormValidationErrors, createFormValidationErrors,
shouldValidateFieldOnChange, shouldValidateFieldOnChange,
} from "@/client/lib/forms"; } from "@/client/lib/forms";
import type { import { captureClientEvent } from "@/client/lib/posthog";
DomainControlsValues, import { getStandardErrorMessage } from "@/client/lib/error-messages";
DomainOverviewData, import type { KeywordRow, PageRow } from "@/client/features/domain/types";
DomainSortMode,
SortOrder,
} from "@/client/features/domain/types";
import { saveSelectedKeywords } from "@/client/features/domain/domainActions";
import { useSaveKeywordsMutation } from "@/client/features/domain/mutations"; import { useSaveKeywordsMutation } from "@/client/features/domain/mutations";
import { useDomainFilters } from "@/client/features/domain/hooks/useDomainFilters"; 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 { import {
useDomainLookupMutation,
useOverviewDataState, useOverviewDataState,
useSearchRunner,
useSyncRouteState, useSyncRouteState,
type SearchState, type SearchState,
} from "@/client/features/domain/domainOverviewControllerInternals"; } from "@/client/features/domain/domainOverviewControllerInternals";
import { import {
DEFAULT_LOCATION_CODE, DEFAULT_LOCATION_CODE,
getLanguageCode, getLanguageCode,
isSupportedLocationCode,
} from "@/client/features/keywords/locations"; } from "@/client/features/keywords/locations";
import { DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE } from "@/types/schemas/domain";
type Params = { type Params = {
projectId: string; projectId: string;
@ -48,78 +47,17 @@ type Params = {
searchState: SearchState; 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({ export function useDomainOverviewController({
projectId, projectId,
queryClient, queryClient,
navigate, navigate,
searchState, searchState,
}: Params) { }: 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>>( const [selectedKeywords, setSelectedKeywords] = useState<Set<string>>(
new Set(), new Set(),
); );
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
const domainFilters = useDomainFilters();
const { const {
history, history,
isLoaded: historyLoaded, isLoaded: historyLoaded,
@ -141,6 +79,22 @@ export function useDomainOverviewController({
[navigate], [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({ const controlsForm = useForm({
defaultValues: { defaultValues: {
domain: searchState.domain, domain: searchState.domain,
@ -157,54 +111,136 @@ export function useDomainOverviewController({
), ),
onSubmit: ({ value }) => getDomainSearchValidationErrors(value), onSubmit: ({ value }) => getDomainSearchValidationErrors(value),
}, },
onSubmit: async ({ formApi, value }) => { onSubmit: ({ formApi, value }) => {
const submitError = await runSearch({ const target = normalizeDomainTarget(value.domain);
domain: value.domain, if (!target) return;
subdomains: value.subdomains, formApi.setFieldValue("domain", target);
sort: value.sort, setSearchParams({
order: currentSortOrder, domain: target,
tab: searchState.tab, subdomains: value.subdomains ? undefined : false,
search: searchState.search, sort: toSortSearchParam(value.sort),
locationCode: value.locationCode, order: toSortOrderSearchParam(value.sort, currentSortOrder),
}); tab: searchState.tab === "keywords" ? undefined : searchState.tab,
loc:
formApi.setErrorMap({ value.locationCode === DEFAULT_LOCATION_CODE
onSubmit: submitError ? undefined
? createFormValidationErrors({ form: submitError }) : value.locationCode,
: undefined, page: undefined,
size: undefined,
}); });
}, },
}); });
useSyncRouteState({ controlsForm, searchState, setPendingSearch, navigate }); useSyncRouteState({ controlsForm, searchState, navigate });
const domainMutation = useDomainLookupMutation(projectId);
const saveMutation = useSaveKeywordsMutation({ projectId, queryClient }); const saveMutation = useSaveKeywordsMutation({ projectId, queryClient });
const dataState = useOverviewDataState({
overview, // Surface overview-query errors through the form's submit error map so the
pendingSearch, // existing error UI keeps working without a parallel error channel.
filters: domainFilters.values, 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<string>("");
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, sortMode: searchState.sort,
currentSortOrder, sortOrder: currentSortOrder,
setSelectedKeywords, appliedFilters: searchState.appliedFilters,
searchTerm: searchState.search,
enabled: overview !== null && overview.hasData && keywordsTabActive,
}); });
useEffect(() => { const pagesQuery = useDomainPagesQuery({
setSearchParams({ search: pendingSearch.trim() || undefined }); projectId,
}, [pendingSearch, setSearchParams]); 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({ const pagedKeywords: KeywordRow[] = keywordsQuery.data?.keywords ?? [];
controlsForm, const totalKeywordCount =
setPendingSearch, keywordsQuery.data?.totalCount ?? overview?.organicKeywords ?? null;
setSearchParams,
domainMutation, const pagedPages: PageRow[] = pagesQuery.data?.pages ?? [];
addSearch, const totalPagesCount = pagesQuery.data?.totalCount ?? null;
setOverview: (value, locationCode) => {
setOverview(value); const dataState = useOverviewDataState({
setOverviewLocationCode(locationCode); pagedKeywords,
},
setSelectedKeywords, setSelectedKeywords,
currentState: searchState, activeFilterCount: domainFilters.activeAppliedCount,
currentSortOrder,
}); });
const handlers = useDomainControllerHandlers({ const handlers = useDomainControllerHandlers({
@ -212,159 +248,81 @@ export function useDomainOverviewController({
currentSortOrder, currentSortOrder,
currentState: searchState, currentState: searchState,
dataState, dataState,
overviewLocationCode,
projectId, projectId,
runSearch,
saveMutation, saveMutation,
selectedKeywords, selectedKeywords,
setSearchParams, setSearchParams,
}); });
const canSaveKeywords = const canSaveKeywords =
overviewLocationCode !== null && controlsForm.state.values.locationCode === searchState.locationCode &&
overviewLocationCode === controlsForm.state.values.locationCode; overview !== null &&
overview.hasData;
const resetView = useCallback(() => { const goToPage = useCallback(
setOverview(null); (nextPage: number) => {
setOverviewLocationCode(null); const safe = Math.max(1, Math.floor(nextPage));
setPendingSearch(""); setSearchParams({ page: safe === 1 ? undefined : safe });
setSelectedKeywords(new Set()); },
setShowFilters(false); [setSearchParams],
domainFilters.resetFilters(); );
}, [domainFilters]);
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 { return {
controlsForm, controlsForm,
isLoading: domainMutation.isPending, isLoading,
overview, overview,
canSaveKeywords, canSaveKeywords,
history, history,
historyLoaded, historyLoaded,
removeHistoryItem, removeHistoryItem,
pendingSearch, searchDraft: domainFilters.searchDraft,
setPendingSearch, setSearchDraft: domainFilters.setSearchDraft,
selectedKeywords, selectedKeywords,
currentSortOrder, currentSortOrder,
setSearchParams, setSearchParams,
showFilters, showFilters,
setShowFilters, setShowFilters,
filtersForm: domainFilters.filtersForm, filtersForm: domainFilters.filtersForm,
resetView,
resetFilters: domainFilters.resetFilters, 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, ...handlers,
...dataState, ...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; return sortOrder === getDefaultSortOrder(sortMode) ? undefined : sortOrder;
} }
export function sortableNullableNumber( export function toPageSortMode(
value: number | null | undefined, sortMode: DomainSortMode,
order: SortOrder, ): "traffic" | "keywords" {
): number { if (sortMode === "volume") return "keywords";
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";
return "traffic"; return "traffic";
} }

View File

@ -9,25 +9,40 @@ import {
DEFAULT_LOCATION_CODE, DEFAULT_LOCATION_CODE,
isSupportedLocationCode, isSupportedLocationCode,
} from "@/client/features/keywords/locations"; } 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")({ export const Route = createFileRoute("/_project/p/$projectId/domain")({
validateSearch: domainSearchSchema, validateSearch: domainSearchSchema,
component: DomainOverviewRoute, component: DomainOverviewRoute,
}); });
function numberToFilterString(value: number | undefined): string {
if (value == null || !Number.isFinite(value)) return "";
return String(value);
}
function DomainOverviewRoute() { function DomainOverviewRoute() {
const { projectId } = Route.useParams(); const { projectId } = Route.useParams();
const navigate = useNavigate({ from: Route.fullPath }); const navigate = useNavigate({ from: Route.fullPath });
const search = Route.useSearch();
const { const {
domain = "", domain = "",
subdomains = true, subdomains = true,
sort = "rank", sort = "rank",
order, order,
tab = "keywords", tab = "keywords",
search = "", search: searchTerm = "",
loc, loc,
} = Route.useSearch(); page,
size,
} = search;
const normalizedSort = toSortMode(sort) ?? "rank"; const normalizedSort = toSortMode(sort) ?? "rank";
const normalizedOrder = resolveSortOrder( const normalizedOrder = resolveSortOrder(
@ -36,22 +51,30 @@ function DomainOverviewRoute() {
); );
const normalizedLocationCode = const normalizedLocationCode =
loc != null && isSupportedLocationCode(loc) ? loc : DEFAULT_LOCATION_CODE; 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 ( return (
<DomainOverviewPage <DomainOverviewPage
projectId={projectId} projectId={projectId}
onShowRecentSearches={() => { onShowRecentSearches={() => {
void navigate({ void navigate({
search: (prev) => ({ search: () => ({}),
...prev,
domain: undefined,
subdomains: undefined,
sort: undefined,
order: undefined,
tab: undefined,
search: undefined,
loc: undefined,
}),
replace: true, replace: true,
}); });
}} }}
@ -62,8 +85,11 @@ function DomainOverviewRoute() {
sort: normalizedSort, sort: normalizedSort,
order: normalizedOrder, order: normalizedOrder,
tab, tab,
search, search: searchTerm,
locationCode: normalizedLocationCode, 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 { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache";
import { z } from "zod"; import { z } from "zod";
import type { BillingCustomerContext } from "@/server/billing/subscription"; import type { BillingCustomerContext } from "@/server/billing/subscription";
import { createDataforseoClient } from "@/server/lib/dataforseoClient"; 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. */ /** Domain overview data is refreshed every 12 hours. */
const DOMAIN_OVERVIEW_TTL_SECONDS = 12 * 60 * 60; const DOMAIN_OVERVIEW_TTL_SECONDS = 12 * 60 * 60;
type DomainOverviewResult = { const domainOverviewResultSchema = z.object({
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({
domain: z.string(), domain: z.string(),
organicTraffic: z.number().nullable(), organicTraffic: z.number().nullable(),
organicKeywords: z.number().nullable(), organicKeywords: z.number().nullable(),
backlinks: z.number().nullable(), backlinks: z.number().nullable(),
referringDomains: z.number().nullable(), referringDomains: z.number().nullable(),
hasData: z.boolean(), hasData: z.boolean(),
keywords: z.array(domainKeywordSchema),
pages: z.array(domainPageSchema),
fetchedAt: z.string(), fetchedAt: z.string(),
}); });
type DomainOverviewResult = z.infer<typeof domainOverviewResultSchema>;
async function getOverview( async function getOverview(
input: { input: {
projectId: string; projectId: string;
@ -89,41 +44,21 @@ async function getOverview(
}); });
const cachedRaw = await getCached(cacheKey); const cachedRaw = await getCached(cacheKey);
const cached = domainOverviewSchema.safeParse(cachedRaw); const cached = domainOverviewResultSchema.safeParse(cachedRaw);
if (cached.success && cached.data.hasData) { if (cached.success && cached.data.hasData) {
return cached.data; return cached.data;
} }
// --- Fetch fresh from DataForSEO ---
const nowIso = new Date().toISOString(); const nowIso = new Date().toISOString();
const dataforseo = createDataforseoClient(billingCustomer); const dataforseo = createDataforseoClient(billingCustomer);
const [metricsResponse, rankedKeywordsResponse] = await Promise.all([ const metricsResponse = await dataforseo.domain.rankOverview({
dataforseo.domain.rankOverview({ target: domain,
target: domain, locationCode: input.locationCode,
locationCode: input.locationCode, languageCode: input.languageCode,
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 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 = const organicTraffic =
metrics?.metrics?.organic?.etv != null metrics?.metrics?.organic?.etv != null
@ -140,9 +75,7 @@ async function getOverview(
organicKeywords, organicKeywords,
backlinks: null, backlinks: null,
referringDomains: null, referringDomains: null,
hasData: keywords.length > 0, hasData: organicKeywords != null && organicKeywords > 0,
keywords,
pages,
fetchedAt: nowIso, fetchedAt: nowIso,
}; };
@ -157,97 +90,6 @@ async function getOverview(
return result; 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( async function getSuggestedKeywords(
input: { input: {
domain: string; domain: string;
@ -296,7 +138,7 @@ async function getSuggestedKeywords(
const dataforseo = createDataforseoClient(billingCustomer); const dataforseo = createDataforseoClient(billingCustomer);
const rankedItems = await dataforseo.domain.rankedKeywords({ const rankedKeywordsResponse = await dataforseo.domain.rankedKeywords({
target: domain, target: domain,
locationCode: input.locationCode, locationCode: input.locationCode,
languageCode: input.languageCode, languageCode: input.languageCode,
@ -304,7 +146,7 @@ async function getSuggestedKeywords(
orderBy: ["keyword_data.keyword_info.search_volume,desc"], orderBy: ["keyword_data.keyword_info.search_volume,desc"],
}); });
const keywords = rankedItems const keywords = rankedKeywordsResponse.items
.map((item) => mapKeywordItem(item)) .map((item) => mapKeywordItem(item))
.filter( .filter(
(item): item is NonNullable<ReturnType<typeof mapKeywordItem>> => (item): item is NonNullable<ReturnType<typeof mapKeywordItem>> =>
@ -333,4 +175,6 @@ async function getSuggestedKeywords(
export const DomainService = { export const DomainService = {
getOverview, getOverview,
getSuggestedKeywords, getSuggestedKeywords,
getKeywordsPage,
getPagesPage,
} as const; } 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, DataforseoLabsGoogleKeywordIdeasLiveRequestInfo,
DataforseoLabsGoogleDomainRankOverviewLiveRequestInfo, DataforseoLabsGoogleDomainRankOverviewLiveRequestInfo,
DataforseoLabsGoogleRankedKeywordsLiveRequestInfo, DataforseoLabsGoogleRankedKeywordsLiveRequestInfo,
DataforseoLabsGoogleRelevantPagesLiveRequestInfo,
} from "dataforseo-client"; } from "dataforseo-client";
import { env } from "cloudflare:workers"; import { env } from "cloudflare:workers";
import { z } from "zod"; import { z } from "zod";
@ -19,6 +20,7 @@ import {
labsKeywordDataItemSchema, labsKeywordDataItemSchema,
parseTaskItems, parseTaskItems,
relatedKeywordItemSchema, relatedKeywordItemSchema,
relevantPagesItemSchema,
serpSnapshotItemSchema, serpSnapshotItemSchema,
type DataforseoTask, type DataforseoTask,
type DomainMetricsItem, type DomainMetricsItem,
@ -26,12 +28,14 @@ import {
type KeywordOverviewItem, type KeywordOverviewItem,
type LabsKeywordDataItem, type LabsKeywordDataItem,
type RelatedKeywordItem, type RelatedKeywordItem,
type RelevantPagesItem,
type SerpLiveItem, type SerpLiveItem,
successfulDataforseoTaskSchema, successfulDataforseoTaskSchema,
} from "@/server/lib/dataforseoSchemas"; } from "@/server/lib/dataforseoSchemas";
export type { export type {
DomainRankedKeywordItem, DomainRankedKeywordItem,
LabsKeywordDataItem, LabsKeywordDataItem,
RelevantPagesItem,
SerpLiveItem, SerpLiveItem,
} from "@/server/lib/dataforseoSchemas"; } from "@/server/lib/dataforseoSchemas";
@ -346,29 +350,80 @@ export async function fetchDomainRankOverviewRaw(
}; };
} }
export async function fetchRankedKeywordsRaw( type RankedKeywordsPage = {
target: string, items: DomainRankedKeywordItem[];
locationCode: number, totalCount: number | null;
languageCode: string, };
limit: number,
orderBy?: string[], export async function fetchRankedKeywordsRaw(input: {
): Promise<DataforseoApiResponse<DomainRankedKeywordItem[]>> { target: string;
locationCode: number;
languageCode: string;
limit: number;
offset?: number;
orderBy?: string[];
filters?: unknown[];
}): Promise<DataforseoApiResponse<RankedKeywordsPage>> {
const api = getLabsApi(); const api = getLabsApi();
const req = new DataforseoLabsGoogleRankedKeywordsLiveRequestInfo({ const req = new DataforseoLabsGoogleRankedKeywordsLiveRequestInfo({
target, target: input.target,
location_code: locationCode, location_code: input.locationCode,
language_code: languageCode, language_code: input.languageCode,
limit, limit: input.limit,
order_by: orderBy, offset: input.offset,
order_by: input.orderBy,
filters: input.filters,
}); });
const endpoint = "google-ranked-keywords-live"; const endpoint = "google-ranked-keywords-live";
const response = await api.googleRankedKeywordsLive([req]); const response = await api.googleRankedKeywordsLive([req]);
const task = assertOk(response); 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 { 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), billing: buildTaskBilling(task),
}; };
} }

View File

@ -306,6 +306,15 @@ describe("mapDataforseoPathToCreditFeature", () => {
"live", "live",
]), ]),
).toBe("domain_overview"); ).toBe("domain_overview");
expect(
mapDataforseoPathToCreditFeature([
"v3",
"dataforseo_labs",
"google",
"relevant_pages",
"live",
]),
).toBe("domain_overview");
}); });
it("maps real backlinks paths", () => { it("maps real backlinks paths", () => {

View File

@ -15,6 +15,7 @@ import {
fetchRelatedKeywordsRaw, fetchRelatedKeywordsRaw,
fetchDomainRankOverviewRaw, fetchDomainRankOverviewRaw,
fetchRankedKeywordsRaw, fetchRankedKeywordsRaw,
fetchRelevantPagesRaw,
fetchLiveSerpItemsRaw, fetchLiveSerpItemsRaw,
fetchRankCheckSerpRaw, fetchRankCheckSerpRaw,
type LabsKeywordDataItem, type LabsKeywordDataItem,
@ -80,7 +81,11 @@ export function mapDataforseoPathToCreditFeature(
return "ai_search"; return "ai_search";
case "dataforseo_labs": { case "dataforseo_labs": {
const endpoint = path[3] ?? ""; 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 "domain_overview";
} }
return "keyword_research"; return "keyword_research";
@ -187,16 +192,25 @@ export function createDataforseoClient(customer: BillingCustomerContext) {
locationCode: number; locationCode: number;
languageCode: string; languageCode: string;
limit: number; limit: number;
offset?: number;
orderBy?: string[]; orderBy?: string[];
filters?: unknown[];
}) { }) {
return meterDataforseoCall(customer, () => return meterDataforseoCall(customer, () =>
fetchRankedKeywordsRaw( fetchRankedKeywordsRaw(input),
input.target, );
input.locationCode, },
input.languageCode, relevantPages(input: {
input.limit, target: string;
input.orderBy, 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(); .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 const rankedKeywordInfoSchema = z
.object({ .object({
search_volume: z.number().nullable().optional(), search_volume: z.number().nullable().optional(),
@ -205,6 +215,7 @@ export type DomainMetricsItem = z.infer<typeof domainMetricsItemSchema>;
export type DomainRankedKeywordItem = z.infer< export type DomainRankedKeywordItem = z.infer<
typeof domainRankedKeywordItemSchema typeof domainRankedKeywordItemSchema
>; >;
export type RelevantPagesItem = z.infer<typeof relevantPagesItemSchema>;
export type SerpLiveItem = z.infer<typeof serpSnapshotItemSchema>; export type SerpLiveItem = z.infer<typeof serpSnapshotItemSchema>;
export function parseTaskItems<T extends z.ZodType>( 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. * 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> { export async function getCached(key: string): Promise<unknown> {
const obj = await env.R2.get(`${CACHE_PREFIX}${key}`); const obj = await env.R2.get(`${CACHE_PREFIX}${key}`);

View File

@ -3,6 +3,8 @@ import { requireProjectContext } from "@/serverFunctions/middleware";
import { import {
domainOverviewSchema, domainOverviewSchema,
domainKeywordSuggestionsSchema, domainKeywordSuggestionsSchema,
domainKeywordsPageRequestSchema,
domainPagesPageRequestSchema,
} from "@/types/schemas/domain"; } from "@/types/schemas/domain";
import { DomainService } from "@/server/features/domain/services/DomainService"; import { DomainService } from "@/server/features/domain/services/DomainService";
@ -32,3 +34,31 @@ export const getDomainKeywordSuggestions = createServerFn({ method: "POST" })
context, 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), 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({ export const domainSearchSchema = z.object({
domain: z.string().optional(), domain: z.string().optional(),
subdomains: booleanSearchParamSchema.optional(), subdomains: booleanSearchParamSchema.optional(),
@ -66,4 +147,24 @@ export const domainSearchSchema = z.object({
tab: z.enum(domainTabs).optional(), tab: z.enum(domainTabs).optional(),
search: z.string().optional(), search: z.string().optional(),
loc: z.coerce.number().int().positive().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,
}); });