feat: Implement server-side pagination and filtering for domain keywords (#143)
This commit is contained in:
parent
a42fe4d350
commit
def9390cea
@ -12,6 +12,7 @@ import {
|
||||
} from "@/client/features/domain/utils";
|
||||
import type {
|
||||
DomainActiveTab,
|
||||
DomainFilterValues,
|
||||
DomainSortMode,
|
||||
SortOrder,
|
||||
} from "@/client/features/domain/types";
|
||||
@ -26,6 +27,9 @@ type Props = {
|
||||
tab: DomainActiveTab;
|
||||
search: string;
|
||||
locationCode: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
appliedFilters: DomainFilterValues;
|
||||
};
|
||||
navigate: (args: {
|
||||
search: (prev: Record<string, unknown>) => Record<string, unknown>;
|
||||
@ -47,10 +51,6 @@ export function DomainOverviewPage({
|
||||
navigate,
|
||||
searchState,
|
||||
});
|
||||
const handleShowRecentSearches = () => {
|
||||
state.resetView();
|
||||
onShowRecentSearches();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto">
|
||||
@ -92,7 +92,7 @@ export function DomainOverviewPage({
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
|
||||
onClick={handleShowRecentSearches}
|
||||
onClick={onShowRecentSearches}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Recent searches
|
||||
@ -130,22 +130,37 @@ export function DomainOverviewPage({
|
||||
activeTab={searchState.tab}
|
||||
sortMode={searchState.sort}
|
||||
currentSortOrder={state.currentSortOrder}
|
||||
pendingSearch={state.pendingSearch}
|
||||
searchDraft={state.searchDraft}
|
||||
selectedKeywords={state.selectedKeywords}
|
||||
visibleKeywords={state.visibleKeywords}
|
||||
filteredKeywords={state.filteredKeywords}
|
||||
filteredPages={state.filteredPages}
|
||||
pagedPages={state.pagedPages}
|
||||
showFilters={state.showFilters}
|
||||
setShowFilters={state.setShowFilters}
|
||||
filtersForm={state.filtersForm}
|
||||
activeFilterCount={state.activeFilterCount}
|
||||
dirtyFilterCount={state.dirtyFilterCount}
|
||||
conditionCount={state.conditionCount}
|
||||
overLimit={state.overLimit}
|
||||
resetFilters={state.resetFilters}
|
||||
onSearchChange={state.setPendingSearch}
|
||||
applyFilters={state.applyFilters}
|
||||
cancelFilterEdits={state.cancelFilterEdits}
|
||||
onSearchChange={state.setSearchDraft}
|
||||
onSaveKeywords={state.handleSaveKeywords}
|
||||
canSaveKeywords={state.canSaveKeywords}
|
||||
onSortClick={state.handleSortColumnClick}
|
||||
onToggleKeyword={state.toggleKeywordSelection}
|
||||
onToggleAllVisible={state.toggleAllVisibleKeywords}
|
||||
page={state.page}
|
||||
pageSize={state.pageSize}
|
||||
totalKeywordCount={state.totalKeywordCount}
|
||||
totalPagesCount={state.totalPagesCount}
|
||||
hasNextKeywordsPage={state.hasNextKeywordsPage}
|
||||
hasNextPagesPage={state.hasNextPagesPage}
|
||||
isKeywordsLoading={state.keywordsLoading}
|
||||
isPagesLoading={state.pagesLoading}
|
||||
onPageChange={state.goToPage}
|
||||
onPageSizeChange={state.setPageSize}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -1,22 +1,45 @@
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { AlertTriangle, RotateCcw } from "lucide-react";
|
||||
import type { useDomainFilters } from "@/client/features/domain/hooks/useDomainFilters";
|
||||
import type { DomainFilterValues } from "@/client/features/domain/types";
|
||||
import { MAX_DATAFORSEO_FILTER_CONDITIONS } from "@/types/schemas/domain";
|
||||
|
||||
type FilterForm = ReturnType<typeof useDomainFilters>["filtersForm"];
|
||||
|
||||
type Props = {
|
||||
filtersForm: FilterForm;
|
||||
activeFilterCount: number;
|
||||
dirtyFilterCount: number;
|
||||
conditionCount: number;
|
||||
overLimit: boolean;
|
||||
resetFilters: () => void;
|
||||
applyFilters: () => void;
|
||||
cancelFilterEdits: () => void;
|
||||
};
|
||||
|
||||
export function DomainFilterPanel({
|
||||
filtersForm,
|
||||
activeFilterCount,
|
||||
dirtyFilterCount,
|
||||
conditionCount,
|
||||
overLimit,
|
||||
resetFilters,
|
||||
applyFilters,
|
||||
cancelFilterEdits,
|
||||
}: Props) {
|
||||
const isDirty = dirtyFilterCount > 0;
|
||||
const canApply = isDirty && !overLimit;
|
||||
const handleApplyKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Enter" && canApply) {
|
||||
event.preventDefault();
|
||||
applyFilters();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b border-base-300 bg-gradient-to-b from-base-100 to-base-200/30 px-4 py-3 space-y-3">
|
||||
<div
|
||||
className="border-b border-base-300 bg-gradient-to-b from-base-100 to-base-200/30 px-4 py-3 space-y-3"
|
||||
onKeyDown={handleApplyKeyDown}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-semibold">Refine table results</p>
|
||||
@ -25,11 +48,16 @@ export function DomainFilterPanel({
|
||||
{activeFilterCount} active
|
||||
</span>
|
||||
) : null}
|
||||
{isDirty ? (
|
||||
<span className="badge badge-xs badge-warning border-0">
|
||||
{dirtyFilterCount} unapplied
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-xs btn-ghost gap-1"
|
||||
onClick={resetFilters}
|
||||
disabled={activeFilterCount === 0}
|
||||
disabled={activeFilterCount === 0 && !isDirty}
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
Clear all
|
||||
@ -84,6 +112,51 @@ export function DomainFilterPanel({
|
||||
maxName="maxRank"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{overLimit ? (
|
||||
<div className="alert alert-warning py-2 text-xs">
|
||||
<AlertTriangle className="size-4 shrink-0" />
|
||||
<span>
|
||||
Too many filter conditions ({conditionCount} of{" "}
|
||||
{MAX_DATAFORSEO_FILTER_CONDITIONS} max). Remove some terms or ranges
|
||||
before applying.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-between gap-2 pt-1">
|
||||
<span className="text-xs text-base-content/50 tabular-nums">
|
||||
{conditionCount} / {MAX_DATAFORSEO_FILTER_CONDITIONS} conditions
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-ghost"
|
||||
onClick={cancelFilterEdits}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={applyFilters}
|
||||
disabled={!canApply}
|
||||
title={
|
||||
overLimit
|
||||
? `DataForSEO accepts at most ${MAX_DATAFORSEO_FILTER_CONDITIONS} filter conditions per request`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Apply filters
|
||||
{isDirty ? (
|
||||
<span className="badge badge-xs ml-1 border-0 bg-primary-content/20">
|
||||
{dirtyFilterCount}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -112,7 +112,7 @@ export function DomainKeywordsTable({
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.slice(0, 100).map((row) => {
|
||||
rows.map((row) => {
|
||||
const href = resolveDomainPageHref(
|
||||
row.relativeUrl ?? row.url,
|
||||
domain,
|
||||
|
||||
@ -44,7 +44,7 @@ export function DomainPagesTable({
|
||||
<th>
|
||||
<SortableHeader
|
||||
label="Keywords"
|
||||
isActive={toPageSortMode(sortMode) === "volume"}
|
||||
isActive={toPageSortMode(sortMode) === "keywords"}
|
||||
order={currentSortOrder}
|
||||
onClick={() => onSortClick("volume")}
|
||||
/>
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { DomainFilterPanel } from "@/client/features/domain/components/DomainFilterPanel";
|
||||
import { DomainKeywordsPagination } from "@/client/features/domain/components/DomainKeywordsPagination";
|
||||
import { DomainKeywordsTable } from "@/client/features/domain/components/DomainKeywordsTable";
|
||||
import { DomainPagesTable } from "@/client/features/domain/components/DomainPagesTable";
|
||||
import type { useDomainFilters } from "@/client/features/domain/hooks/useDomainFilters";
|
||||
@ -38,22 +39,37 @@ type Props = {
|
||||
activeTab: DomainActiveTab;
|
||||
sortMode: DomainSortMode;
|
||||
currentSortOrder: SortOrder;
|
||||
pendingSearch: string;
|
||||
searchDraft: string;
|
||||
selectedKeywords: Set<string>;
|
||||
visibleKeywords: string[];
|
||||
filteredKeywords: KeywordRow[];
|
||||
filteredPages: PageRow[];
|
||||
pagedPages: PageRow[];
|
||||
showFilters: boolean;
|
||||
setShowFilters: Dispatch<SetStateAction<boolean>>;
|
||||
filtersForm: ReturnType<typeof useDomainFilters>["filtersForm"];
|
||||
activeFilterCount: number;
|
||||
dirtyFilterCount: number;
|
||||
conditionCount: number;
|
||||
overLimit: boolean;
|
||||
resetFilters: () => void;
|
||||
applyFilters: () => void;
|
||||
cancelFilterEdits: () => void;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSaveKeywords: () => void;
|
||||
canSaveKeywords: boolean;
|
||||
onSortClick: (sort: DomainSortMode) => void;
|
||||
onToggleKeyword: (keyword: string) => void;
|
||||
onToggleAllVisible: () => void;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalKeywordCount: number | null;
|
||||
totalPagesCount: number | null;
|
||||
hasNextKeywordsPage: boolean;
|
||||
hasNextPagesPage: boolean;
|
||||
isKeywordsLoading: boolean;
|
||||
isPagesLoading: boolean;
|
||||
onPageChange: (nextPage: number) => void;
|
||||
onPageSizeChange: (nextSize: number) => void;
|
||||
};
|
||||
|
||||
const KEYWORDS_ONLY_SORTS: ReadonlySet<DomainSortMode> = new Set([
|
||||
@ -68,28 +84,43 @@ export function DomainResultsCard({
|
||||
activeTab,
|
||||
sortMode,
|
||||
currentSortOrder,
|
||||
pendingSearch,
|
||||
searchDraft,
|
||||
selectedKeywords,
|
||||
visibleKeywords,
|
||||
filteredKeywords,
|
||||
filteredPages,
|
||||
pagedPages,
|
||||
showFilters,
|
||||
setShowFilters,
|
||||
filtersForm,
|
||||
activeFilterCount,
|
||||
dirtyFilterCount,
|
||||
conditionCount,
|
||||
overLimit,
|
||||
resetFilters,
|
||||
applyFilters,
|
||||
cancelFilterEdits,
|
||||
onSearchChange,
|
||||
onSaveKeywords,
|
||||
canSaveKeywords,
|
||||
onSortClick,
|
||||
onToggleKeyword,
|
||||
onToggleAllVisible,
|
||||
page,
|
||||
pageSize,
|
||||
totalKeywordCount,
|
||||
totalPagesCount,
|
||||
hasNextKeywordsPage,
|
||||
hasNextPagesPage,
|
||||
isKeywordsLoading,
|
||||
isPagesLoading,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
}: Props) {
|
||||
const isKeywordsTab = activeTab === "keywords";
|
||||
const currentRows = isKeywordsTab ? filteredKeywords : filteredPages;
|
||||
const currentRows = isKeywordsTab ? filteredKeywords : pagedPages;
|
||||
const exportTable = isKeywordsTab
|
||||
? keywordsToTable(filteredKeywords)
|
||||
: pagesToTable(filteredPages);
|
||||
: pagesToTable(pagedPages);
|
||||
|
||||
const handleCopy = async () => {
|
||||
const text = JSON.stringify(currentRows, null, 2);
|
||||
@ -127,7 +158,7 @@ export function DomainResultsCard({
|
||||
from="/p/$projectId/domain"
|
||||
to="/p/$projectId/domain"
|
||||
params={{ projectId }}
|
||||
search={(prev) => ({ ...prev, tab: undefined })}
|
||||
search={(prev) => ({ ...prev, tab: undefined, page: undefined })}
|
||||
replace
|
||||
role="tab"
|
||||
className={`tab ${activeTab === "keywords" ? "tab-active" : ""}`}
|
||||
@ -149,6 +180,7 @@ export function DomainResultsCard({
|
||||
tab: "pages" as const,
|
||||
sort: nextSort,
|
||||
order: nextOrder,
|
||||
page: undefined,
|
||||
};
|
||||
}}
|
||||
replace
|
||||
@ -213,69 +245,112 @@ export function DomainResultsCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isKeywordsTab ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-base-300">
|
||||
<button
|
||||
className={`btn btn-ghost btn-sm gap-1.5 ${showFilters ? "btn-active" : ""}`}
|
||||
onClick={() => setShowFilters((prev) => !prev)}
|
||||
title="Toggle filters"
|
||||
>
|
||||
<SlidersHorizontal className="size-3.5" />
|
||||
Filters
|
||||
{activeFilterCount > 0 ? (
|
||||
<span className="badge badge-xs badge-primary border-0 text-primary-content">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
<span className="text-sm text-base-content/60">
|
||||
{filteredKeywords.length} keywords
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<label className="input input-bordered input-sm w-full max-w-xs flex items-center gap-2">
|
||||
<Search className="size-4 text-base-content/60" />
|
||||
<input
|
||||
placeholder="Search in results"
|
||||
value={pendingSearch}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{showFilters ? (
|
||||
<DomainFilterPanel
|
||||
filtersForm={filtersForm}
|
||||
activeFilterCount={activeFilterCount}
|
||||
resetFilters={resetFilters}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-base-300">
|
||||
{isKeywordsTab ? (
|
||||
<button
|
||||
className={`btn btn-ghost btn-sm gap-1.5 ${showFilters ? "btn-active" : ""}`}
|
||||
onClick={() => setShowFilters((prev) => !prev)}
|
||||
title="Toggle filters"
|
||||
>
|
||||
<SlidersHorizontal className="size-3.5" />
|
||||
Filters
|
||||
{activeFilterCount > 0 ? (
|
||||
<span className="badge badge-xs badge-primary border-0 text-primary-content">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
) : null}
|
||||
<span className="text-sm text-base-content/60">
|
||||
{isKeywordsTab
|
||||
? totalKeywordCount != null
|
||||
? `${totalKeywordCount.toLocaleString()} keywords`
|
||||
: `${filteredKeywords.length.toLocaleString()} keywords`
|
||||
: totalPagesCount != null
|
||||
? `${totalPagesCount.toLocaleString()} pages`
|
||||
: `${pagedPages.length.toLocaleString()} pages`}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<form
|
||||
className="w-full max-w-xs"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!overLimit) applyFilters();
|
||||
}}
|
||||
>
|
||||
<label className="input input-bordered input-sm w-full flex items-center gap-2">
|
||||
<Search className="size-4 text-base-content/60" />
|
||||
<input
|
||||
placeholder="Search in results (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}
|
||||
|
||||
<div className="p-4">
|
||||
{isKeywordsTab ? (
|
||||
<DomainKeywordsTable
|
||||
domain={overview.domain}
|
||||
rows={filteredKeywords}
|
||||
selectedKeywords={selectedKeywords}
|
||||
visibleKeywords={visibleKeywords}
|
||||
sortMode={sortMode}
|
||||
currentSortOrder={currentSortOrder}
|
||||
onSortClick={onSortClick}
|
||||
onToggleKeyword={onToggleKeyword}
|
||||
onToggleAllVisible={onToggleAllVisible}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
isKeywordsLoading
|
||||
? "opacity-60 transition-opacity"
|
||||
: "transition-opacity"
|
||||
}
|
||||
>
|
||||
<DomainKeywordsTable
|
||||
domain={overview.domain}
|
||||
rows={filteredKeywords}
|
||||
selectedKeywords={selectedKeywords}
|
||||
visibleKeywords={visibleKeywords}
|
||||
sortMode={sortMode}
|
||||
currentSortOrder={currentSortOrder}
|
||||
onSortClick={onSortClick}
|
||||
onToggleKeyword={onToggleKeyword}
|
||||
onToggleAllVisible={onToggleAllVisible}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<DomainPagesTable
|
||||
domain={overview.domain}
|
||||
rows={filteredPages}
|
||||
sortMode={sortMode}
|
||||
currentSortOrder={currentSortOrder}
|
||||
onSortClick={onSortClick}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
isPagesLoading
|
||||
? "opacity-60 transition-opacity"
|
||||
: "transition-opacity"
|
||||
}
|
||||
>
|
||||
<DomainPagesTable
|
||||
domain={overview.domain}
|
||||
rows={pagedPages}
|
||||
sortMode={sortMode}
|
||||
currentSortOrder={currentSortOrder}
|
||||
onSortClick={onSortClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DomainKeywordsPagination
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalCount={isKeywordsTab ? totalKeywordCount : totalPagesCount}
|
||||
hasNextPage={isKeywordsTab ? hasNextKeywordsPage : hasNextPagesPage}
|
||||
isLoading={isKeywordsTab ? isKeywordsLoading : isPagesLoading}
|
||||
onPageChange={onPageChange}
|
||||
onPageSizeChange={onPageSizeChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { toast } from "sonner";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import type { DomainOverviewData } from "@/client/features/domain/types";
|
||||
import type { KeywordRow } from "@/client/features/domain/types";
|
||||
|
||||
type SaveMutation = (payload: {
|
||||
projectId: string;
|
||||
@ -30,7 +30,7 @@ export function saveSelectedKeywords({
|
||||
languageCode,
|
||||
}: {
|
||||
selectedKeywords: Set<string>;
|
||||
filteredKeywords: DomainOverviewData["keywords"];
|
||||
filteredKeywords: KeywordRow[];
|
||||
save: (payload: Parameters<SaveMutation>[0], opts?: SaveOptions) => void;
|
||||
projectId: string;
|
||||
locationCode: number;
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
}
|
||||
@ -1,35 +1,18 @@
|
||||
import { useEffect, useMemo, type Dispatch, type SetStateAction } from "react";
|
||||
import type { UpdateMetaOptions } from "@tanstack/react-form";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { sortBy } from "remeda";
|
||||
import { toast } from "sonner";
|
||||
import { getDomainOverview } from "@/serverFunctions/domain";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import { filterAndSortKeywords } from "@/client/features/domain/domainFiltering";
|
||||
import {
|
||||
getDefaultSortOrder,
|
||||
normalizeDomainTarget,
|
||||
sortableNullableNumber,
|
||||
toPageSortMode,
|
||||
toSortMode,
|
||||
toSortOrder,
|
||||
toSortOrderSearchParam,
|
||||
toSortSearchParam,
|
||||
} from "@/client/features/domain/utils";
|
||||
import type {
|
||||
DomainActiveTab,
|
||||
DomainFilterValues,
|
||||
DomainOverviewData,
|
||||
DomainSortMode,
|
||||
KeywordRow,
|
||||
SortOrder,
|
||||
} from "@/client/features/domain/types";
|
||||
import type { DomainSearchHistoryItem } from "@/client/hooks/useDomainSearchHistory";
|
||||
import {
|
||||
DEFAULT_LOCATION_CODE,
|
||||
getLanguageCode,
|
||||
isSupportedLocationCode,
|
||||
} from "@/client/features/keywords/locations";
|
||||
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
|
||||
|
||||
export type SearchState = {
|
||||
domain: string;
|
||||
@ -39,6 +22,9 @@ export type SearchState = {
|
||||
tab: DomainActiveTab;
|
||||
search: string;
|
||||
locationCode: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
appliedFilters: DomainFilterValues;
|
||||
};
|
||||
|
||||
type DomainNavigate = (args: {
|
||||
@ -71,57 +57,20 @@ type DomainControlsFormAccess = {
|
||||
type ControlsFormLike = DomainControlsFormAccess;
|
||||
|
||||
export function useOverviewDataState({
|
||||
overview,
|
||||
pendingSearch,
|
||||
filters,
|
||||
sortMode,
|
||||
currentSortOrder,
|
||||
pagedKeywords,
|
||||
setSelectedKeywords,
|
||||
activeFilterCount,
|
||||
}: {
|
||||
overview: DomainOverviewData | null;
|
||||
pendingSearch: string;
|
||||
filters: DomainFilterValues;
|
||||
sortMode: DomainSortMode;
|
||||
currentSortOrder: SortOrder;
|
||||
pagedKeywords: KeywordRow[];
|
||||
setSelectedKeywords: Dispatch<SetStateAction<Set<string>>>;
|
||||
activeFilterCount: number;
|
||||
}) {
|
||||
const filteredKeywords = useMemo(
|
||||
() =>
|
||||
filterAndSortKeywords({
|
||||
keywords: overview?.keywords ?? [],
|
||||
pendingSearch,
|
||||
filters,
|
||||
sortMode,
|
||||
currentSortOrder,
|
||||
}),
|
||||
[currentSortOrder, filters, overview?.keywords, pendingSearch, sortMode],
|
||||
);
|
||||
|
||||
const filteredPages = useMemo(() => {
|
||||
const source = overview?.pages ?? [];
|
||||
const filtered = !pendingSearch
|
||||
? source
|
||||
: source.filter((row) => {
|
||||
const text = `${row.relativePath ?? ""} ${row.page}`.toLowerCase();
|
||||
return text.includes(pendingSearch.toLowerCase().trim());
|
||||
});
|
||||
|
||||
const pageSortMode = toPageSortMode(sortMode);
|
||||
if (pageSortMode === "volume") {
|
||||
return sortBy(filtered, [
|
||||
(row) => sortableNullableNumber(row.keywords, currentSortOrder),
|
||||
currentSortOrder,
|
||||
]);
|
||||
}
|
||||
|
||||
return sortBy(filtered, [
|
||||
(row) => sortableNullableNumber(row.organicTraffic, currentSortOrder),
|
||||
currentSortOrder,
|
||||
]);
|
||||
}, [currentSortOrder, overview?.pages, pendingSearch, sortMode]);
|
||||
// Keywords are now fetched server-side with filters/sort/pagination applied,
|
||||
// so we render whatever the page query returned.
|
||||
const filteredKeywords = pagedKeywords;
|
||||
|
||||
const visibleKeywords = useMemo(
|
||||
() => filteredKeywords.slice(0, 100).map((row) => row.keyword),
|
||||
() => filteredKeywords.map((row) => row.keyword),
|
||||
[filteredKeywords],
|
||||
);
|
||||
|
||||
@ -136,14 +85,8 @@ export function useOverviewDataState({
|
||||
});
|
||||
}, [setSelectedKeywords, visibleKeywords]);
|
||||
|
||||
const activeFilterCount = useMemo(
|
||||
() => Object.values(filters).filter((value) => value.trim() !== "").length,
|
||||
[filters],
|
||||
);
|
||||
|
||||
return {
|
||||
filteredKeywords,
|
||||
filteredPages,
|
||||
visibleKeywords,
|
||||
activeFilterCount,
|
||||
toggleKeywordSelection: (keyword: string) => {
|
||||
@ -172,12 +115,10 @@ export function useOverviewDataState({
|
||||
export function useSyncRouteState({
|
||||
controlsForm,
|
||||
searchState,
|
||||
setPendingSearch,
|
||||
navigate,
|
||||
}: {
|
||||
controlsForm: ControlsFormLike;
|
||||
searchState: SearchState;
|
||||
setPendingSearch: (value: string) => void;
|
||||
navigate: DomainNavigate;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
@ -187,8 +128,7 @@ export function useSyncRouteState({
|
||||
sort: searchState.sort,
|
||||
locationCode: searchState.locationCode,
|
||||
});
|
||||
setPendingSearch(searchState.search);
|
||||
}, [controlsForm, searchState, setPendingSearch]);
|
||||
}, [controlsForm, searchState]);
|
||||
|
||||
useEffect(() => {
|
||||
const raw = new URLSearchParams(window.location.search);
|
||||
@ -232,118 +172,3 @@ export function useSyncRouteState({
|
||||
});
|
||||
}, [navigate]);
|
||||
}
|
||||
|
||||
export function useDomainLookupMutation(projectId: string) {
|
||||
return useMutation({
|
||||
mutationFn: (data: {
|
||||
domain: string;
|
||||
includeSubdomains: boolean;
|
||||
locationCode: number;
|
||||
languageCode: string;
|
||||
}) =>
|
||||
getDomainOverview({
|
||||
data: {
|
||||
...data,
|
||||
projectId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSearchRunner({
|
||||
controlsForm,
|
||||
setPendingSearch,
|
||||
setSearchParams,
|
||||
domainMutation,
|
||||
addSearch,
|
||||
setOverview,
|
||||
setSelectedKeywords,
|
||||
currentState,
|
||||
currentSortOrder,
|
||||
}: {
|
||||
controlsForm: ControlsFormLike;
|
||||
setPendingSearch: (value: string) => void;
|
||||
setSearchParams: (
|
||||
updates: Record<string, string | number | boolean | undefined>,
|
||||
) => void;
|
||||
domainMutation: ReturnType<typeof useDomainLookupMutation>;
|
||||
addSearch: (item: Omit<DomainSearchHistoryItem, "timestamp">) => void;
|
||||
setOverview: (value: DomainOverviewData, locationCode: number) => void;
|
||||
setSelectedKeywords: Dispatch<SetStateAction<Set<string>>>;
|
||||
currentState: SearchState;
|
||||
currentSortOrder: SortOrder;
|
||||
}) {
|
||||
return async (params?: Partial<SearchState>) => {
|
||||
const values = controlsForm.state.values;
|
||||
const rawTarget = params?.domain ?? values.domain;
|
||||
const activeSubdomains = params?.subdomains ?? values.subdomains;
|
||||
const activeSort = params?.sort ?? currentState.sort;
|
||||
const activeOrder = params?.order ?? currentSortOrder;
|
||||
const activeTab = params?.tab ?? currentState.tab;
|
||||
const activeSearch = params?.search ?? currentState.search;
|
||||
const rawLocationCode =
|
||||
params?.locationCode ?? values.locationCode ?? currentState.locationCode;
|
||||
const activeLocationCode = isSupportedLocationCode(rawLocationCode)
|
||||
? rawLocationCode
|
||||
: DEFAULT_LOCATION_CODE;
|
||||
const activeLanguageCode = getLanguageCode(activeLocationCode);
|
||||
const target = normalizeDomainTarget(rawTarget);
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingSearch(activeSearch);
|
||||
controlsForm.setFieldValue("domain", target);
|
||||
controlsForm.setFieldValue("subdomains", activeSubdomains);
|
||||
controlsForm.setFieldValue("sort", activeSort);
|
||||
controlsForm.setFieldValue("locationCode", activeLocationCode);
|
||||
|
||||
setSearchParams({
|
||||
domain: target,
|
||||
subdomains: activeSubdomains ? undefined : activeSubdomains,
|
||||
sort: toSortSearchParam(activeSort),
|
||||
order: toSortOrderSearchParam(activeSort, activeOrder),
|
||||
tab: activeTab === "keywords" ? undefined : activeTab,
|
||||
search: activeSearch.trim() || undefined,
|
||||
loc:
|
||||
activeLocationCode === DEFAULT_LOCATION_CODE
|
||||
? undefined
|
||||
: activeLocationCode,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await domainMutation.mutateAsync({
|
||||
domain: target,
|
||||
includeSubdomains: activeSubdomains,
|
||||
locationCode: activeLocationCode,
|
||||
languageCode: activeLanguageCode,
|
||||
});
|
||||
|
||||
captureClientEvent("domain_overview:search_complete", {
|
||||
sort_mode: activeSort,
|
||||
include_subdomains: activeSubdomains,
|
||||
result_count: response.keywords.length,
|
||||
location_code: activeLocationCode,
|
||||
});
|
||||
|
||||
setOverview(response, activeLocationCode);
|
||||
setSelectedKeywords(new Set());
|
||||
addSearch({
|
||||
domain: target,
|
||||
subdomains: activeSubdomains,
|
||||
sort: activeSort,
|
||||
tab: activeTab,
|
||||
search: activeSearch.trim() || undefined,
|
||||
locationCode: activeLocationCode,
|
||||
});
|
||||
|
||||
if (!response.hasData) {
|
||||
toast.info("Not enough data for this domain");
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
return getStandardErrorMessage(error, "Lookup failed.");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
47
src/client/features/domain/domainSearchValidation.ts
Normal file
47
src/client/features/domain/domainSearchValidation.ts
Normal 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);
|
||||
}
|
||||
@ -1,40 +1,167 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useForm, useStore } from "@tanstack/react-form";
|
||||
import {
|
||||
EMPTY_DOMAIN_FILTERS,
|
||||
type DomainFilterValues,
|
||||
} from "@/client/features/domain/types";
|
||||
import { MAX_DATAFORSEO_FILTER_CONDITIONS } from "@/types/schemas/domain";
|
||||
|
||||
export function useDomainFilters() {
|
||||
const FILTER_KEYS: Array<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({
|
||||
defaultValues: EMPTY_DOMAIN_FILTERS,
|
||||
defaultValues: appliedValues,
|
||||
});
|
||||
|
||||
const values = useStore(filtersForm.store, (s) => s.values);
|
||||
const draftValues = useStore(filtersForm.store, (s) => s.values);
|
||||
const [searchDraft, setSearchDraft] = useState(appliedSearch);
|
||||
|
||||
// Keep the draft in sync when applied values change from outside the panel
|
||||
// (URL navigation, history-select, "back to recent searches"). Without this,
|
||||
// the form keeps the previous draft and diverges from the URL.
|
||||
const appliedKey = useMemo(
|
||||
() => FILTER_KEYS.map((key) => appliedValues[key]).join("|"),
|
||||
[appliedValues],
|
||||
);
|
||||
useEffect(() => {
|
||||
filtersForm.reset({ ...appliedValues });
|
||||
// appliedKey covers content changes; filtersForm is a stable ref.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appliedKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchDraft(appliedSearch);
|
||||
}, [appliedSearch]);
|
||||
|
||||
const applyFilters = useCallback(() => {
|
||||
const trimmedSearch = searchDraft.trim();
|
||||
setSearchParams({
|
||||
...filtersToSearchParams(draftValues),
|
||||
search: trimmedSearch === "" ? undefined : trimmedSearch,
|
||||
page: undefined,
|
||||
});
|
||||
}, [draftValues, searchDraft, setSearchParams]);
|
||||
|
||||
const cancelEdits = useCallback(() => {
|
||||
filtersForm.reset({ ...appliedValues }, { keepDefaultValues: true });
|
||||
setSearchDraft(appliedSearch);
|
||||
}, [appliedValues, appliedSearch, filtersForm]);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
const keys: Array<keyof DomainFilterValues> = [
|
||||
"include",
|
||||
"exclude",
|
||||
"minTraffic",
|
||||
"maxTraffic",
|
||||
"minVol",
|
||||
"maxVol",
|
||||
"minCpc",
|
||||
"maxCpc",
|
||||
"minKd",
|
||||
"maxKd",
|
||||
"minRank",
|
||||
"maxRank",
|
||||
];
|
||||
for (const key of keys) {
|
||||
filtersForm.setFieldValue(key, "");
|
||||
}
|
||||
}, [filtersForm]);
|
||||
filtersForm.reset({ ...EMPTY_DOMAIN_FILTERS }, { keepDefaultValues: true });
|
||||
setSearchDraft("");
|
||||
setSearchParams({
|
||||
...filtersToSearchParams(EMPTY_DOMAIN_FILTERS),
|
||||
search: undefined,
|
||||
page: undefined,
|
||||
});
|
||||
}, [filtersForm, setSearchParams]);
|
||||
|
||||
const activeAppliedCount = useMemo(
|
||||
() =>
|
||||
FILTER_KEYS.filter((key) => appliedValues[key].trim() !== "").length +
|
||||
(appliedSearch.trim() !== "" ? 1 : 0),
|
||||
[appliedValues, appliedSearch],
|
||||
);
|
||||
const dirtyCount = useMemo(() => {
|
||||
const filterDirt = FILTER_KEYS.filter(
|
||||
(key) => draftValues[key].trim() !== appliedValues[key].trim(),
|
||||
).length;
|
||||
const searchDirt = searchDraft.trim() !== appliedSearch.trim() ? 1 : 0;
|
||||
return filterDirt + searchDirt;
|
||||
}, [draftValues, appliedValues, searchDraft, appliedSearch]);
|
||||
|
||||
const conditionCount = countConditions(
|
||||
draftValues,
|
||||
searchDraft.trim() !== "",
|
||||
);
|
||||
const overLimit = conditionCount > MAX_DATAFORSEO_FILTER_CONDITIONS;
|
||||
|
||||
return {
|
||||
filtersForm,
|
||||
values,
|
||||
draftValues,
|
||||
appliedValues,
|
||||
searchDraft,
|
||||
setSearchDraft,
|
||||
activeAppliedCount,
|
||||
dirtyCount,
|
||||
conditionCount,
|
||||
overLimit,
|
||||
applyFilters,
|
||||
cancelEdits,
|
||||
resetFilters,
|
||||
};
|
||||
}
|
||||
|
||||
93
src/client/features/domain/hooks/useDomainKeywordsQuery.ts
Normal file
93
src/client/features/domain/hooks/useDomainKeywordsQuery.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
37
src/client/features/domain/hooks/useDomainOverviewQuery.ts
Normal file
37
src/client/features/domain/hooks/useDomainOverviewQuery.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
57
src/client/features/domain/hooks/useDomainPagesQuery.ts
Normal file
57
src/client/features/domain/hooks/useDomainPagesQuery.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@ -64,8 +64,6 @@ export type DomainOverviewData = {
|
||||
backlinks: number | null;
|
||||
referringDomains: number | null;
|
||||
hasData: boolean;
|
||||
keywords: KeywordRow[];
|
||||
pages: PageRow[];
|
||||
};
|
||||
|
||||
export type DomainHistoryItem = {
|
||||
|
||||
138
src/client/features/domain/useDomainControllerHandlers.ts
Normal file
138
src/client/features/domain/useDomainControllerHandlers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -1,12 +1,9 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { type QueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useDomainSearchHistory } from "@/client/hooks/useDomainSearchHistory";
|
||||
import {
|
||||
useDomainSearchHistory,
|
||||
type DomainSearchHistoryItem,
|
||||
} from "@/client/hooks/useDomainSearchHistory";
|
||||
import {
|
||||
getDefaultSortOrder,
|
||||
normalizeDomainTarget,
|
||||
resolveSortOrder,
|
||||
toSortOrderSearchParam,
|
||||
@ -16,27 +13,29 @@ import {
|
||||
createFormValidationErrors,
|
||||
shouldValidateFieldOnChange,
|
||||
} from "@/client/lib/forms";
|
||||
import type {
|
||||
DomainControlsValues,
|
||||
DomainOverviewData,
|
||||
DomainSortMode,
|
||||
SortOrder,
|
||||
} from "@/client/features/domain/types";
|
||||
import { saveSelectedKeywords } from "@/client/features/domain/domainActions";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import type { KeywordRow, PageRow } from "@/client/features/domain/types";
|
||||
import { useSaveKeywordsMutation } from "@/client/features/domain/mutations";
|
||||
import { useDomainFilters } from "@/client/features/domain/hooks/useDomainFilters";
|
||||
import { useDomainKeywordsQuery } from "@/client/features/domain/hooks/useDomainKeywordsQuery";
|
||||
import { useDomainOverviewQuery } from "@/client/features/domain/hooks/useDomainOverviewQuery";
|
||||
import { useDomainPagesQuery } from "@/client/features/domain/hooks/useDomainPagesQuery";
|
||||
import {
|
||||
getDomainSearchChangeValidationErrors,
|
||||
getDomainSearchValidationErrors,
|
||||
} from "@/client/features/domain/domainSearchValidation";
|
||||
import { useDomainControllerHandlers } from "@/client/features/domain/useDomainControllerHandlers";
|
||||
import {
|
||||
useDomainLookupMutation,
|
||||
useOverviewDataState,
|
||||
useSearchRunner,
|
||||
useSyncRouteState,
|
||||
type SearchState,
|
||||
} from "@/client/features/domain/domainOverviewControllerInternals";
|
||||
import {
|
||||
DEFAULT_LOCATION_CODE,
|
||||
getLanguageCode,
|
||||
isSupportedLocationCode,
|
||||
} from "@/client/features/keywords/locations";
|
||||
import { DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE } from "@/types/schemas/domain";
|
||||
|
||||
type Params = {
|
||||
projectId: string;
|
||||
@ -48,78 +47,17 @@ type Params = {
|
||||
searchState: SearchState;
|
||||
};
|
||||
|
||||
type DomainControlsFormApi = {
|
||||
state: {
|
||||
values: DomainControlsValues;
|
||||
};
|
||||
handleSubmit: () => Promise<unknown>;
|
||||
reset: (values: DomainControlsValues) => void;
|
||||
setFieldValue: (
|
||||
field: keyof DomainControlsValues,
|
||||
value: string | boolean | number,
|
||||
) => void;
|
||||
};
|
||||
|
||||
function getDomainSearchValidationErrors(value: DomainControlsValues) {
|
||||
if (!value.domain.trim()) {
|
||||
return createFormValidationErrors({
|
||||
fields: {
|
||||
domain: "Please enter a domain",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!normalizeDomainTarget(value.domain)) {
|
||||
return createFormValidationErrors({
|
||||
fields: {
|
||||
domain: "Please enter a valid URL or domain (e.g. browserbase.com)",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getDomainSearchChangeValidationErrors(
|
||||
value: DomainControlsValues,
|
||||
shouldValidateUntouchedField: boolean,
|
||||
shouldValidateFormat: boolean,
|
||||
) {
|
||||
if (!value.domain.trim()) {
|
||||
if (!shouldValidateUntouchedField) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createFormValidationErrors({
|
||||
fields: {
|
||||
domain: "Please enter a domain",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!shouldValidateFormat) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getDomainSearchValidationErrors(value);
|
||||
}
|
||||
|
||||
export function useDomainOverviewController({
|
||||
projectId,
|
||||
queryClient,
|
||||
navigate,
|
||||
searchState,
|
||||
}: Params) {
|
||||
const [pendingSearch, setPendingSearch] = useState(searchState.search);
|
||||
const [overview, setOverview] = useState<DomainOverviewData | null>(null);
|
||||
const [overviewLocationCode, setOverviewLocationCode] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [selectedKeywords, setSelectedKeywords] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const domainFilters = useDomainFilters();
|
||||
|
||||
const {
|
||||
history,
|
||||
isLoaded: historyLoaded,
|
||||
@ -141,6 +79,22 @@ export function useDomainOverviewController({
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const domainFilters = useDomainFilters({
|
||||
appliedValues: searchState.appliedFilters,
|
||||
appliedSearch: searchState.search,
|
||||
setSearchParams,
|
||||
});
|
||||
|
||||
const overviewLanguageCode = getLanguageCode(searchState.locationCode);
|
||||
const overviewQuery = useDomainOverviewQuery({
|
||||
projectId,
|
||||
domain: searchState.domain,
|
||||
includeSubdomains: searchState.subdomains,
|
||||
locationCode: searchState.locationCode,
|
||||
languageCode: overviewLanguageCode,
|
||||
});
|
||||
const overview = overviewQuery.data ?? null;
|
||||
|
||||
const controlsForm = useForm({
|
||||
defaultValues: {
|
||||
domain: searchState.domain,
|
||||
@ -157,54 +111,136 @@ export function useDomainOverviewController({
|
||||
),
|
||||
onSubmit: ({ value }) => getDomainSearchValidationErrors(value),
|
||||
},
|
||||
onSubmit: async ({ formApi, value }) => {
|
||||
const submitError = await runSearch({
|
||||
domain: value.domain,
|
||||
subdomains: value.subdomains,
|
||||
sort: value.sort,
|
||||
order: currentSortOrder,
|
||||
tab: searchState.tab,
|
||||
search: searchState.search,
|
||||
locationCode: value.locationCode,
|
||||
});
|
||||
|
||||
formApi.setErrorMap({
|
||||
onSubmit: submitError
|
||||
? createFormValidationErrors({ form: submitError })
|
||||
: undefined,
|
||||
onSubmit: ({ formApi, value }) => {
|
||||
const target = normalizeDomainTarget(value.domain);
|
||||
if (!target) return;
|
||||
formApi.setFieldValue("domain", target);
|
||||
setSearchParams({
|
||||
domain: target,
|
||||
subdomains: value.subdomains ? undefined : false,
|
||||
sort: toSortSearchParam(value.sort),
|
||||
order: toSortOrderSearchParam(value.sort, currentSortOrder),
|
||||
tab: searchState.tab === "keywords" ? undefined : searchState.tab,
|
||||
loc:
|
||||
value.locationCode === DEFAULT_LOCATION_CODE
|
||||
? undefined
|
||||
: value.locationCode,
|
||||
page: undefined,
|
||||
size: undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useSyncRouteState({ controlsForm, searchState, setPendingSearch, navigate });
|
||||
const domainMutation = useDomainLookupMutation(projectId);
|
||||
useSyncRouteState({ controlsForm, searchState, navigate });
|
||||
const saveMutation = useSaveKeywordsMutation({ projectId, queryClient });
|
||||
const dataState = useOverviewDataState({
|
||||
overview,
|
||||
pendingSearch,
|
||||
filters: domainFilters.values,
|
||||
|
||||
// Surface overview-query errors through the form's submit error map so the
|
||||
// existing error UI keeps working without a parallel error channel.
|
||||
useEffect(() => {
|
||||
controlsForm.setErrorMap({
|
||||
onSubmit: overviewQuery.error
|
||||
? createFormValidationErrors({
|
||||
form: getStandardErrorMessage(
|
||||
overviewQuery.error,
|
||||
"Lookup failed.",
|
||||
),
|
||||
})
|
||||
: undefined,
|
||||
});
|
||||
}, [controlsForm, overviewQuery.error]);
|
||||
|
||||
// History + analytics + "no data" toast: fire once per successful overview
|
||||
// fetch, keyed on the inputs that actually trigger a refetch.
|
||||
const lastTrackedKey = useRef<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,
|
||||
currentSortOrder,
|
||||
setSelectedKeywords,
|
||||
sortOrder: currentSortOrder,
|
||||
appliedFilters: searchState.appliedFilters,
|
||||
searchTerm: searchState.search,
|
||||
enabled: overview !== null && overview.hasData && keywordsTabActive,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setSearchParams({ search: pendingSearch.trim() || undefined });
|
||||
}, [pendingSearch, setSearchParams]);
|
||||
const pagesQuery = useDomainPagesQuery({
|
||||
projectId,
|
||||
domain: overview?.domain ?? "",
|
||||
includeSubdomains: searchState.subdomains,
|
||||
locationCode: searchState.locationCode,
|
||||
languageCode: overviewLanguageCode,
|
||||
page: searchState.page,
|
||||
pageSize: searchState.pageSize,
|
||||
sortMode: searchState.sort,
|
||||
sortOrder: currentSortOrder,
|
||||
searchTerm: searchState.search,
|
||||
enabled: overview !== null && overview.hasData && pagesTabActive,
|
||||
});
|
||||
|
||||
const runSearch = useSearchRunner({
|
||||
controlsForm,
|
||||
setPendingSearch,
|
||||
setSearchParams,
|
||||
domainMutation,
|
||||
addSearch,
|
||||
setOverview: (value, locationCode) => {
|
||||
setOverview(value);
|
||||
setOverviewLocationCode(locationCode);
|
||||
},
|
||||
const pagedKeywords: KeywordRow[] = keywordsQuery.data?.keywords ?? [];
|
||||
const totalKeywordCount =
|
||||
keywordsQuery.data?.totalCount ?? overview?.organicKeywords ?? null;
|
||||
|
||||
const pagedPages: PageRow[] = pagesQuery.data?.pages ?? [];
|
||||
const totalPagesCount = pagesQuery.data?.totalCount ?? null;
|
||||
|
||||
const dataState = useOverviewDataState({
|
||||
pagedKeywords,
|
||||
setSelectedKeywords,
|
||||
currentState: searchState,
|
||||
currentSortOrder,
|
||||
activeFilterCount: domainFilters.activeAppliedCount,
|
||||
});
|
||||
|
||||
const handlers = useDomainControllerHandlers({
|
||||
@ -212,159 +248,81 @@ export function useDomainOverviewController({
|
||||
currentSortOrder,
|
||||
currentState: searchState,
|
||||
dataState,
|
||||
overviewLocationCode,
|
||||
projectId,
|
||||
runSearch,
|
||||
saveMutation,
|
||||
selectedKeywords,
|
||||
setSearchParams,
|
||||
});
|
||||
|
||||
const canSaveKeywords =
|
||||
overviewLocationCode !== null &&
|
||||
overviewLocationCode === controlsForm.state.values.locationCode;
|
||||
controlsForm.state.values.locationCode === searchState.locationCode &&
|
||||
overview !== null &&
|
||||
overview.hasData;
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
setOverview(null);
|
||||
setOverviewLocationCode(null);
|
||||
setPendingSearch("");
|
||||
setSelectedKeywords(new Set());
|
||||
setShowFilters(false);
|
||||
domainFilters.resetFilters();
|
||||
}, [domainFilters]);
|
||||
const goToPage = useCallback(
|
||||
(nextPage: number) => {
|
||||
const safe = Math.max(1, Math.floor(nextPage));
|
||||
setSearchParams({ page: safe === 1 ? undefined : safe });
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const setPageSize = useCallback(
|
||||
(nextSize: number) => {
|
||||
setSearchParams({
|
||||
size:
|
||||
nextSize === DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE ? undefined : nextSize,
|
||||
page: undefined,
|
||||
});
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
// Treat the page as loading until the active tab's first fetch resolves —
|
||||
// otherwise the table area would render an empty shell with a spinner while
|
||||
// we wait on DataForSEO. `isLoading` is true only on the very first fetch
|
||||
// (subsequent paginations keep prior data via keepPreviousData).
|
||||
const activeTabFirstFetch =
|
||||
(keywordsTabActive && keywordsQuery.isLoading) ||
|
||||
(pagesTabActive && pagesQuery.isLoading);
|
||||
const isLoading = overviewQuery.isLoading || activeTabFirstFetch;
|
||||
|
||||
return {
|
||||
controlsForm,
|
||||
isLoading: domainMutation.isPending,
|
||||
isLoading,
|
||||
overview,
|
||||
canSaveKeywords,
|
||||
history,
|
||||
historyLoaded,
|
||||
removeHistoryItem,
|
||||
pendingSearch,
|
||||
setPendingSearch,
|
||||
searchDraft: domainFilters.searchDraft,
|
||||
setSearchDraft: domainFilters.setSearchDraft,
|
||||
selectedKeywords,
|
||||
currentSortOrder,
|
||||
setSearchParams,
|
||||
showFilters,
|
||||
setShowFilters,
|
||||
filtersForm: domainFilters.filtersForm,
|
||||
resetView,
|
||||
resetFilters: domainFilters.resetFilters,
|
||||
applyFilters: domainFilters.applyFilters,
|
||||
cancelFilterEdits: domainFilters.cancelEdits,
|
||||
dirtyFilterCount: domainFilters.dirtyCount,
|
||||
conditionCount: domainFilters.conditionCount,
|
||||
overLimit: domainFilters.overLimit,
|
||||
keywordsLoading: keywordsQuery.isFetching,
|
||||
keywordsError: keywordsQuery.error,
|
||||
pagesLoading: pagesQuery.isFetching,
|
||||
pagesError: pagesQuery.error,
|
||||
page: searchState.page,
|
||||
pageSize: searchState.pageSize,
|
||||
totalKeywordCount,
|
||||
totalPagesCount,
|
||||
hasNextKeywordsPage: keywordsQuery.data?.hasMore ?? false,
|
||||
hasNextPagesPage: pagesQuery.data?.hasMore ?? false,
|
||||
pagedPages,
|
||||
goToPage,
|
||||
setPageSize,
|
||||
...handlers,
|
||||
...dataState,
|
||||
};
|
||||
}
|
||||
|
||||
function useDomainControllerHandlers({
|
||||
controlsForm,
|
||||
currentSortOrder,
|
||||
currentState,
|
||||
dataState,
|
||||
overviewLocationCode,
|
||||
projectId,
|
||||
runSearch,
|
||||
saveMutation,
|
||||
selectedKeywords,
|
||||
setSearchParams,
|
||||
}: {
|
||||
controlsForm: DomainControlsFormApi;
|
||||
currentSortOrder: SortOrder;
|
||||
currentState: SearchState;
|
||||
dataState: ReturnType<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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -47,16 +47,10 @@ export function toSortOrderSearchParam(
|
||||
return sortOrder === getDefaultSortOrder(sortMode) ? undefined : sortOrder;
|
||||
}
|
||||
|
||||
export function sortableNullableNumber(
|
||||
value: number | null | undefined,
|
||||
order: SortOrder,
|
||||
): number {
|
||||
if (value != null) return value;
|
||||
return order === "asc" ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
export function toPageSortMode(sortMode: DomainSortMode): "traffic" | "volume" {
|
||||
if (sortMode === "volume") return "volume";
|
||||
export function toPageSortMode(
|
||||
sortMode: DomainSortMode,
|
||||
): "traffic" | "keywords" {
|
||||
if (sortMode === "volume") return "keywords";
|
||||
return "traffic";
|
||||
}
|
||||
|
||||
|
||||
@ -9,25 +9,40 @@ import {
|
||||
DEFAULT_LOCATION_CODE,
|
||||
isSupportedLocationCode,
|
||||
} from "@/client/features/keywords/locations";
|
||||
import { domainSearchSchema } from "@/types/schemas/domain";
|
||||
import {
|
||||
DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE,
|
||||
domainSearchSchema,
|
||||
} from "@/types/schemas/domain";
|
||||
import {
|
||||
EMPTY_DOMAIN_FILTERS,
|
||||
type DomainFilterValues,
|
||||
} from "@/client/features/domain/types";
|
||||
|
||||
export const Route = createFileRoute("/_project/p/$projectId/domain")({
|
||||
validateSearch: domainSearchSchema,
|
||||
component: DomainOverviewRoute,
|
||||
});
|
||||
|
||||
function numberToFilterString(value: number | undefined): string {
|
||||
if (value == null || !Number.isFinite(value)) return "";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function DomainOverviewRoute() {
|
||||
const { projectId } = Route.useParams();
|
||||
const navigate = useNavigate({ from: Route.fullPath });
|
||||
const search = Route.useSearch();
|
||||
const {
|
||||
domain = "",
|
||||
subdomains = true,
|
||||
sort = "rank",
|
||||
order,
|
||||
tab = "keywords",
|
||||
search = "",
|
||||
search: searchTerm = "",
|
||||
loc,
|
||||
} = Route.useSearch();
|
||||
page,
|
||||
size,
|
||||
} = search;
|
||||
|
||||
const normalizedSort = toSortMode(sort) ?? "rank";
|
||||
const normalizedOrder = resolveSortOrder(
|
||||
@ -36,22 +51,30 @@ function DomainOverviewRoute() {
|
||||
);
|
||||
const normalizedLocationCode =
|
||||
loc != null && isSupportedLocationCode(loc) ? loc : DEFAULT_LOCATION_CODE;
|
||||
const normalizedPage = page != null && page > 0 ? page : 1;
|
||||
const normalizedPageSize = size ?? DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE;
|
||||
|
||||
const appliedFilters: DomainFilterValues = {
|
||||
include: search.include ?? EMPTY_DOMAIN_FILTERS.include,
|
||||
exclude: search.exclude ?? EMPTY_DOMAIN_FILTERS.exclude,
|
||||
minTraffic: numberToFilterString(search.minTraffic),
|
||||
maxTraffic: numberToFilterString(search.maxTraffic),
|
||||
minVol: numberToFilterString(search.minVol),
|
||||
maxVol: numberToFilterString(search.maxVol),
|
||||
minCpc: numberToFilterString(search.minCpc),
|
||||
maxCpc: numberToFilterString(search.maxCpc),
|
||||
minKd: numberToFilterString(search.minKd),
|
||||
maxKd: numberToFilterString(search.maxKd),
|
||||
minRank: numberToFilterString(search.minRank),
|
||||
maxRank: numberToFilterString(search.maxRank),
|
||||
};
|
||||
|
||||
return (
|
||||
<DomainOverviewPage
|
||||
projectId={projectId}
|
||||
onShowRecentSearches={() => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
domain: undefined,
|
||||
subdomains: undefined,
|
||||
sort: undefined,
|
||||
order: undefined,
|
||||
tab: undefined,
|
||||
search: undefined,
|
||||
loc: undefined,
|
||||
}),
|
||||
search: () => ({}),
|
||||
replace: true,
|
||||
});
|
||||
}}
|
||||
@ -62,8 +85,11 @@ function DomainOverviewRoute() {
|
||||
sort: normalizedSort,
|
||||
order: normalizedOrder,
|
||||
tab,
|
||||
search,
|
||||
search: searchTerm,
|
||||
locationCode: normalizedLocationCode,
|
||||
page: normalizedPage,
|
||||
pageSize: normalizedPageSize,
|
||||
appliedFilters,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -1,72 +1,27 @@
|
||||
import { type DomainRankedKeywordItem } from "@/server/lib/dataforseo";
|
||||
import { sortBy } from "remeda";
|
||||
import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache";
|
||||
import { z } from "zod";
|
||||
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
||||
import { createDataforseoClient } from "@/server/lib/dataforseoClient";
|
||||
import { normalizeDomainInput, toRelativePath } from "@/server/lib/domainUtils";
|
||||
import { normalizeDomainInput } from "@/server/lib/domainUtils";
|
||||
import { mapKeywordItem } from "@/server/features/domain/services/domainKeywordMapper";
|
||||
import { getKeywordsPage } from "@/server/features/domain/services/domainKeywordsPage";
|
||||
import { getPagesPage } from "@/server/features/domain/services/domainPagesPage";
|
||||
|
||||
/** Domain overview data is refreshed every 12 hours. */
|
||||
const DOMAIN_OVERVIEW_TTL_SECONDS = 12 * 60 * 60;
|
||||
|
||||
type DomainOverviewResult = {
|
||||
domain: string;
|
||||
organicTraffic: number | null;
|
||||
organicKeywords: number | null;
|
||||
backlinks: number | null;
|
||||
referringDomains: number | null;
|
||||
hasData: boolean;
|
||||
keywords: Array<{
|
||||
keyword: string;
|
||||
position: number | null;
|
||||
searchVolume: number | null;
|
||||
traffic: number | null;
|
||||
cpc: number | null;
|
||||
url: string | null;
|
||||
relativeUrl: string | null;
|
||||
keywordDifficulty: number | null;
|
||||
}>;
|
||||
pages: Array<{
|
||||
page: string;
|
||||
relativePath: string | null;
|
||||
organicTraffic: number | null;
|
||||
keywords: number | null;
|
||||
backlinks: number | null;
|
||||
}>;
|
||||
fetchedAt: string;
|
||||
};
|
||||
|
||||
const domainKeywordSchema = z.object({
|
||||
keyword: z.string(),
|
||||
position: z.number().nullable(),
|
||||
searchVolume: z.number().nullable(),
|
||||
traffic: z.number().nullable(),
|
||||
cpc: z.number().nullable(),
|
||||
url: z.string().nullable(),
|
||||
relativeUrl: z.string().nullable(),
|
||||
keywordDifficulty: z.number().nullable(),
|
||||
});
|
||||
|
||||
const domainPageSchema = z.object({
|
||||
page: z.string(),
|
||||
relativePath: z.string().nullable(),
|
||||
organicTraffic: z.number().nullable(),
|
||||
keywords: z.number().nullable(),
|
||||
backlinks: z.number().nullable(),
|
||||
});
|
||||
|
||||
const domainOverviewSchema = z.object({
|
||||
const domainOverviewResultSchema = z.object({
|
||||
domain: z.string(),
|
||||
organicTraffic: z.number().nullable(),
|
||||
organicKeywords: z.number().nullable(),
|
||||
backlinks: z.number().nullable(),
|
||||
referringDomains: z.number().nullable(),
|
||||
hasData: z.boolean(),
|
||||
keywords: z.array(domainKeywordSchema),
|
||||
pages: z.array(domainPageSchema),
|
||||
fetchedAt: z.string(),
|
||||
});
|
||||
|
||||
type DomainOverviewResult = z.infer<typeof domainOverviewResultSchema>;
|
||||
|
||||
async function getOverview(
|
||||
input: {
|
||||
projectId: string;
|
||||
@ -89,41 +44,21 @@ async function getOverview(
|
||||
});
|
||||
|
||||
const cachedRaw = await getCached(cacheKey);
|
||||
const cached = domainOverviewSchema.safeParse(cachedRaw);
|
||||
const cached = domainOverviewResultSchema.safeParse(cachedRaw);
|
||||
if (cached.success && cached.data.hasData) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// --- Fetch fresh from DataForSEO ---
|
||||
const nowIso = new Date().toISOString();
|
||||
const dataforseo = createDataforseoClient(billingCustomer);
|
||||
|
||||
const [metricsResponse, rankedKeywordsResponse] = await Promise.all([
|
||||
dataforseo.domain.rankOverview({
|
||||
target: domain,
|
||||
locationCode: input.locationCode,
|
||||
languageCode: input.languageCode,
|
||||
}),
|
||||
dataforseo.domain.rankedKeywords({
|
||||
target: domain,
|
||||
locationCode: input.locationCode,
|
||||
languageCode: input.languageCode,
|
||||
limit: 200,
|
||||
orderBy: ["keyword_data.keyword_info.search_volume,desc"],
|
||||
}),
|
||||
]);
|
||||
const metricsResponse = await dataforseo.domain.rankOverview({
|
||||
target: domain,
|
||||
locationCode: input.locationCode,
|
||||
languageCode: input.languageCode,
|
||||
});
|
||||
|
||||
const metrics = metricsResponse[0];
|
||||
const rankedItems = rankedKeywordsResponse;
|
||||
|
||||
const keywords = rankedItems
|
||||
.map((item) => mapKeywordItem(item))
|
||||
.filter(
|
||||
(item): item is NonNullable<ReturnType<typeof mapKeywordItem>> =>
|
||||
item != null,
|
||||
);
|
||||
|
||||
const pages = derivePages(keywords);
|
||||
|
||||
const organicTraffic =
|
||||
metrics?.metrics?.organic?.etv != null
|
||||
@ -140,9 +75,7 @@ async function getOverview(
|
||||
organicKeywords,
|
||||
backlinks: null,
|
||||
referringDomains: null,
|
||||
hasData: keywords.length > 0,
|
||||
keywords,
|
||||
pages,
|
||||
hasData: organicKeywords != null && organicKeywords > 0,
|
||||
fetchedAt: nowIso,
|
||||
};
|
||||
|
||||
@ -157,97 +90,6 @@ async function getOverview(
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mapKeywordItem(item: DomainRankedKeywordItem) {
|
||||
const keywordData = item.keyword_data;
|
||||
const keywordInfo = keywordData?.keyword_info;
|
||||
const keywordProperties = keywordData?.keyword_properties;
|
||||
const rankedSerpElement = item.ranked_serp_element;
|
||||
const serpItem = rankedSerpElement?.serp_item;
|
||||
|
||||
const keyword = keywordData?.keyword ?? item.keyword;
|
||||
if (!keyword) return null;
|
||||
|
||||
const url = serpItem?.url ?? rankedSerpElement?.url ?? null;
|
||||
|
||||
const relativeUrl =
|
||||
serpItem?.relative_url ??
|
||||
rankedSerpElement?.relative_url ??
|
||||
(url ? toRelativePath(url) : null);
|
||||
|
||||
const position =
|
||||
serpItem?.rank_absolute ?? rankedSerpElement?.rank_absolute ?? null;
|
||||
|
||||
const traffic = serpItem?.etv ?? rankedSerpElement?.etv ?? null;
|
||||
|
||||
const keywordDifficulty =
|
||||
keywordProperties?.keyword_difficulty ??
|
||||
keywordInfo?.keyword_difficulty ??
|
||||
null;
|
||||
|
||||
return {
|
||||
keyword,
|
||||
position: position != null ? Math.round(position) : null,
|
||||
searchVolume:
|
||||
keywordInfo?.search_volume != null
|
||||
? Math.round(keywordInfo.search_volume)
|
||||
: null,
|
||||
traffic: traffic ?? null,
|
||||
cpc: keywordInfo?.cpc ?? null,
|
||||
url: url ?? null,
|
||||
relativeUrl,
|
||||
keywordDifficulty:
|
||||
keywordDifficulty != null ? Math.round(keywordDifficulty) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function derivePages(
|
||||
keywords: Array<{
|
||||
url: string | null;
|
||||
relativeUrl: string | null;
|
||||
traffic: number | null;
|
||||
}>,
|
||||
) {
|
||||
const grouped = new Map<
|
||||
string,
|
||||
{
|
||||
page: string;
|
||||
relativePath: string | null;
|
||||
traffic: number;
|
||||
keywords: number;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const keyword of keywords) {
|
||||
if (!keyword.url) continue;
|
||||
|
||||
const existing = grouped.get(keyword.url) ?? {
|
||||
page: keyword.url,
|
||||
relativePath: keyword.relativeUrl,
|
||||
traffic: 0,
|
||||
keywords: 0,
|
||||
};
|
||||
|
||||
existing.traffic += keyword.traffic ?? 0;
|
||||
existing.keywords += 1;
|
||||
|
||||
grouped.set(keyword.url, existing);
|
||||
}
|
||||
|
||||
return sortBy(Array.from(grouped.values()), [(page) => page.traffic, "desc"])
|
||||
.slice(0, 100)
|
||||
.map((page) => ({
|
||||
page: page.page,
|
||||
relativePath: page.relativePath,
|
||||
organicTraffic: page.traffic,
|
||||
keywords: page.keywords,
|
||||
backlinks: null,
|
||||
}));
|
||||
}
|
||||
|
||||
async function getSuggestedKeywords(
|
||||
input: {
|
||||
domain: string;
|
||||
@ -296,7 +138,7 @@ async function getSuggestedKeywords(
|
||||
|
||||
const dataforseo = createDataforseoClient(billingCustomer);
|
||||
|
||||
const rankedItems = await dataforseo.domain.rankedKeywords({
|
||||
const rankedKeywordsResponse = await dataforseo.domain.rankedKeywords({
|
||||
target: domain,
|
||||
locationCode: input.locationCode,
|
||||
languageCode: input.languageCode,
|
||||
@ -304,7 +146,7 @@ async function getSuggestedKeywords(
|
||||
orderBy: ["keyword_data.keyword_info.search_volume,desc"],
|
||||
});
|
||||
|
||||
const keywords = rankedItems
|
||||
const keywords = rankedKeywordsResponse.items
|
||||
.map((item) => mapKeywordItem(item))
|
||||
.filter(
|
||||
(item): item is NonNullable<ReturnType<typeof mapKeywordItem>> =>
|
||||
@ -333,4 +175,6 @@ async function getSuggestedKeywords(
|
||||
export const DomainService = {
|
||||
getOverview,
|
||||
getSuggestedKeywords,
|
||||
getKeywordsPage,
|
||||
getPagesPage,
|
||||
} as const;
|
||||
|
||||
122
src/server/features/domain/services/domainKeywordFilters.test.ts
Normal file
122
src/server/features/domain/services/domainKeywordFilters.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
149
src/server/features/domain/services/domainKeywordFilters.ts
Normal file
149
src/server/features/domain/services/domainKeywordFilters.ts
Normal 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}%`],
|
||||
];
|
||||
}
|
||||
45
src/server/features/domain/services/domainKeywordMapper.ts
Normal file
45
src/server/features/domain/services/domainKeywordMapper.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
123
src/server/features/domain/services/domainKeywordsPage.ts
Normal file
123
src/server/features/domain/services/domainKeywordsPage.ts
Normal 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;
|
||||
}
|
||||
142
src/server/features/domain/services/domainPagesPage.ts
Normal file
142
src/server/features/domain/services/domainPagesPage.ts
Normal 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;
|
||||
}
|
||||
@ -6,6 +6,7 @@ import {
|
||||
DataforseoLabsGoogleKeywordIdeasLiveRequestInfo,
|
||||
DataforseoLabsGoogleDomainRankOverviewLiveRequestInfo,
|
||||
DataforseoLabsGoogleRankedKeywordsLiveRequestInfo,
|
||||
DataforseoLabsGoogleRelevantPagesLiveRequestInfo,
|
||||
} from "dataforseo-client";
|
||||
import { env } from "cloudflare:workers";
|
||||
import { z } from "zod";
|
||||
@ -19,6 +20,7 @@ import {
|
||||
labsKeywordDataItemSchema,
|
||||
parseTaskItems,
|
||||
relatedKeywordItemSchema,
|
||||
relevantPagesItemSchema,
|
||||
serpSnapshotItemSchema,
|
||||
type DataforseoTask,
|
||||
type DomainMetricsItem,
|
||||
@ -26,12 +28,14 @@ import {
|
||||
type KeywordOverviewItem,
|
||||
type LabsKeywordDataItem,
|
||||
type RelatedKeywordItem,
|
||||
type RelevantPagesItem,
|
||||
type SerpLiveItem,
|
||||
successfulDataforseoTaskSchema,
|
||||
} from "@/server/lib/dataforseoSchemas";
|
||||
export type {
|
||||
DomainRankedKeywordItem,
|
||||
LabsKeywordDataItem,
|
||||
RelevantPagesItem,
|
||||
SerpLiveItem,
|
||||
} from "@/server/lib/dataforseoSchemas";
|
||||
|
||||
@ -346,29 +350,80 @@ export async function fetchDomainRankOverviewRaw(
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchRankedKeywordsRaw(
|
||||
target: string,
|
||||
locationCode: number,
|
||||
languageCode: string,
|
||||
limit: number,
|
||||
orderBy?: string[],
|
||||
): Promise<DataforseoApiResponse<DomainRankedKeywordItem[]>> {
|
||||
type RankedKeywordsPage = {
|
||||
items: DomainRankedKeywordItem[];
|
||||
totalCount: number | null;
|
||||
};
|
||||
|
||||
export async function fetchRankedKeywordsRaw(input: {
|
||||
target: string;
|
||||
locationCode: number;
|
||||
languageCode: string;
|
||||
limit: number;
|
||||
offset?: number;
|
||||
orderBy?: string[];
|
||||
filters?: unknown[];
|
||||
}): Promise<DataforseoApiResponse<RankedKeywordsPage>> {
|
||||
const api = getLabsApi();
|
||||
const req = new DataforseoLabsGoogleRankedKeywordsLiveRequestInfo({
|
||||
target,
|
||||
location_code: locationCode,
|
||||
language_code: languageCode,
|
||||
limit,
|
||||
order_by: orderBy,
|
||||
target: input.target,
|
||||
location_code: input.locationCode,
|
||||
language_code: input.languageCode,
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
order_by: input.orderBy,
|
||||
filters: input.filters,
|
||||
});
|
||||
|
||||
const endpoint = "google-ranked-keywords-live";
|
||||
const response = await api.googleRankedKeywordsLive([req]);
|
||||
const task = assertOk(response);
|
||||
const data = parseTaskItems(endpoint, task, domainRankedKeywordItemSchema);
|
||||
const items = parseTaskItems(endpoint, task, domainRankedKeywordItemSchema);
|
||||
const rawTotal = (task.result?.[0] as Record<string, unknown> | undefined)
|
||||
?.total_count;
|
||||
const totalCount = typeof rawTotal === "number" ? rawTotal : null;
|
||||
|
||||
return {
|
||||
data,
|
||||
data: { items, totalCount },
|
||||
billing: buildTaskBilling(task),
|
||||
};
|
||||
}
|
||||
|
||||
type RelevantPagesPage = {
|
||||
items: RelevantPagesItem[];
|
||||
totalCount: number | null;
|
||||
};
|
||||
|
||||
export async function fetchRelevantPagesRaw(input: {
|
||||
target: string;
|
||||
locationCode: number;
|
||||
languageCode: string;
|
||||
limit: number;
|
||||
offset?: number;
|
||||
orderBy?: string[];
|
||||
filters?: unknown[];
|
||||
}): Promise<DataforseoApiResponse<RelevantPagesPage>> {
|
||||
const api = getLabsApi();
|
||||
const req = new DataforseoLabsGoogleRelevantPagesLiveRequestInfo({
|
||||
target: input.target,
|
||||
location_code: input.locationCode,
|
||||
language_code: input.languageCode,
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
order_by: input.orderBy,
|
||||
filters: input.filters,
|
||||
});
|
||||
|
||||
const endpoint = "google-relevant-pages-live";
|
||||
const response = await api.googleRelevantPagesLive([req]);
|
||||
const task = assertOk(response);
|
||||
const items = parseTaskItems(endpoint, task, relevantPagesItemSchema);
|
||||
const rawTotal = (task.result?.[0] as Record<string, unknown> | undefined)
|
||||
?.total_count;
|
||||
const totalCount = typeof rawTotal === "number" ? rawTotal : null;
|
||||
|
||||
return {
|
||||
data: { items, totalCount },
|
||||
billing: buildTaskBilling(task),
|
||||
};
|
||||
}
|
||||
|
||||
@ -306,6 +306,15 @@ describe("mapDataforseoPathToCreditFeature", () => {
|
||||
"live",
|
||||
]),
|
||||
).toBe("domain_overview");
|
||||
expect(
|
||||
mapDataforseoPathToCreditFeature([
|
||||
"v3",
|
||||
"dataforseo_labs",
|
||||
"google",
|
||||
"relevant_pages",
|
||||
"live",
|
||||
]),
|
||||
).toBe("domain_overview");
|
||||
});
|
||||
|
||||
it("maps real backlinks paths", () => {
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
fetchRelatedKeywordsRaw,
|
||||
fetchDomainRankOverviewRaw,
|
||||
fetchRankedKeywordsRaw,
|
||||
fetchRelevantPagesRaw,
|
||||
fetchLiveSerpItemsRaw,
|
||||
fetchRankCheckSerpRaw,
|
||||
type LabsKeywordDataItem,
|
||||
@ -80,7 +81,11 @@ export function mapDataforseoPathToCreditFeature(
|
||||
return "ai_search";
|
||||
case "dataforseo_labs": {
|
||||
const endpoint = path[3] ?? "";
|
||||
if (endpoint.startsWith("domain_") || endpoint === "ranked_keywords") {
|
||||
if (
|
||||
endpoint.startsWith("domain_") ||
|
||||
endpoint === "ranked_keywords" ||
|
||||
endpoint === "relevant_pages"
|
||||
) {
|
||||
return "domain_overview";
|
||||
}
|
||||
return "keyword_research";
|
||||
@ -187,16 +192,25 @@ export function createDataforseoClient(customer: BillingCustomerContext) {
|
||||
locationCode: number;
|
||||
languageCode: string;
|
||||
limit: number;
|
||||
offset?: number;
|
||||
orderBy?: string[];
|
||||
filters?: unknown[];
|
||||
}) {
|
||||
return meterDataforseoCall(customer, () =>
|
||||
fetchRankedKeywordsRaw(
|
||||
input.target,
|
||||
input.locationCode,
|
||||
input.languageCode,
|
||||
input.limit,
|
||||
input.orderBy,
|
||||
),
|
||||
fetchRankedKeywordsRaw(input),
|
||||
);
|
||||
},
|
||||
relevantPages(input: {
|
||||
target: string;
|
||||
locationCode: number;
|
||||
languageCode: string;
|
||||
limit: number;
|
||||
offset?: number;
|
||||
orderBy?: string[];
|
||||
filters?: unknown[];
|
||||
}) {
|
||||
return meterDataforseoCall(customer, () =>
|
||||
fetchRelevantPagesRaw(input),
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@ -110,6 +110,16 @@ export const domainMetricsItemSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const relevantPagesItemSchema = z
|
||||
.object({
|
||||
page_address: z.string().nullable().optional(),
|
||||
metrics: z
|
||||
.record(z.string(), domainMetricsValueSchema.nullable().optional())
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const rankedKeywordInfoSchema = z
|
||||
.object({
|
||||
search_volume: z.number().nullable().optional(),
|
||||
@ -205,6 +215,7 @@ export type DomainMetricsItem = z.infer<typeof domainMetricsItemSchema>;
|
||||
export type DomainRankedKeywordItem = z.infer<
|
||||
typeof domainRankedKeywordItemSchema
|
||||
>;
|
||||
export type RelevantPagesItem = z.infer<typeof relevantPagesItemSchema>;
|
||||
export type SerpLiveItem = z.infer<typeof serpSnapshotItemSchema>;
|
||||
|
||||
export function parseTaskItems<T extends z.ZodType>(
|
||||
|
||||
@ -28,6 +28,8 @@ export async function buildCacheKey(
|
||||
|
||||
/**
|
||||
* Get a cached JSON value from R2. Returns null on miss or expiry.
|
||||
* Callers should validate the shape with Zod before trusting it — schema
|
||||
* drift between writes and reads is otherwise silent.
|
||||
*/
|
||||
export async function getCached(key: string): Promise<unknown> {
|
||||
const obj = await env.R2.get(`${CACHE_PREFIX}${key}`);
|
||||
|
||||
@ -3,6 +3,8 @@ import { requireProjectContext } from "@/serverFunctions/middleware";
|
||||
import {
|
||||
domainOverviewSchema,
|
||||
domainKeywordSuggestionsSchema,
|
||||
domainKeywordsPageRequestSchema,
|
||||
domainPagesPageRequestSchema,
|
||||
} from "@/types/schemas/domain";
|
||||
import { DomainService } from "@/server/features/domain/services/DomainService";
|
||||
|
||||
@ -32,3 +34,31 @@ export const getDomainKeywordSuggestions = createServerFn({ method: "POST" })
|
||||
context,
|
||||
),
|
||||
);
|
||||
|
||||
export const getDomainKeywordsPage = createServerFn({ method: "POST" })
|
||||
.middleware(requireProjectContext)
|
||||
.inputValidator((data: unknown) =>
|
||||
domainKeywordsPageRequestSchema.parse(data),
|
||||
)
|
||||
.handler(async ({ data, context }) =>
|
||||
DomainService.getKeywordsPage(
|
||||
{
|
||||
...data,
|
||||
projectId: context.projectId,
|
||||
},
|
||||
context,
|
||||
),
|
||||
);
|
||||
|
||||
export const getDomainPagesPage = createServerFn({ method: "POST" })
|
||||
.middleware(requireProjectContext)
|
||||
.inputValidator((data: unknown) => domainPagesPageRequestSchema.parse(data))
|
||||
.handler(async ({ data, context }) =>
|
||||
DomainService.getPagesPage(
|
||||
{
|
||||
...data,
|
||||
projectId: context.projectId,
|
||||
},
|
||||
context,
|
||||
),
|
||||
);
|
||||
|
||||
@ -58,6 +58,87 @@ export const domainKeywordSuggestionsSchema = z.object({
|
||||
languageCode: z.string().min(2).max(8),
|
||||
});
|
||||
|
||||
export const DOMAIN_KEYWORDS_PAGE_SIZES = [50, 100, 200] as const;
|
||||
export const DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE = 100;
|
||||
export const MAX_DATAFORSEO_FILTER_CONDITIONS = 8;
|
||||
|
||||
const optionalNumber = z
|
||||
.union([
|
||||
z.number(),
|
||||
z.string().transform((value, ctx) => {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") return undefined;
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
ctx.addIssue({ code: "custom", message: "Invalid number" });
|
||||
return z.NEVER;
|
||||
}
|
||||
return parsed;
|
||||
}),
|
||||
])
|
||||
.optional();
|
||||
|
||||
const domainKeywordsFiltersSchema = z.object({
|
||||
include: z.string().optional(),
|
||||
exclude: z.string().optional(),
|
||||
minTraffic: optionalNumber,
|
||||
maxTraffic: optionalNumber,
|
||||
minVol: optionalNumber,
|
||||
maxVol: optionalNumber,
|
||||
minCpc: optionalNumber,
|
||||
maxCpc: optionalNumber,
|
||||
minKd: optionalNumber,
|
||||
maxKd: optionalNumber,
|
||||
minRank: optionalNumber,
|
||||
maxRank: optionalNumber,
|
||||
});
|
||||
|
||||
export type DomainKeywordsFilters = z.infer<typeof domainKeywordsFiltersSchema>;
|
||||
|
||||
export const domainKeywordsPageRequestSchema = z.object({
|
||||
projectId: z.string().uuid(),
|
||||
domain: z.string().min(1).max(255),
|
||||
includeSubdomains: z.boolean().default(true),
|
||||
locationCode: z.number().int().positive().default(2840),
|
||||
languageCode: z.string().min(2).max(8).default("en"),
|
||||
page: z.number().int().positive().default(1),
|
||||
pageSize: z
|
||||
.number()
|
||||
.int()
|
||||
.refine((value) =>
|
||||
(DOMAIN_KEYWORDS_PAGE_SIZES as readonly number[]).includes(value),
|
||||
)
|
||||
.default(DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE),
|
||||
sortMode: z.enum(domainSortModes).default("rank"),
|
||||
sortOrder: z.enum(domainSortOrders).default("asc"),
|
||||
filters: domainKeywordsFiltersSchema.default({}),
|
||||
search: z.string().optional(),
|
||||
});
|
||||
|
||||
const domainPagesSortModes = ["traffic", "keywords"] as const;
|
||||
|
||||
export const domainPagesPageRequestSchema = z.object({
|
||||
projectId: z.string().uuid(),
|
||||
domain: z.string().min(1).max(255),
|
||||
includeSubdomains: z.boolean().default(true),
|
||||
locationCode: z.number().int().positive().default(2840),
|
||||
languageCode: z.string().min(2).max(8).default("en"),
|
||||
page: z.number().int().positive().default(1),
|
||||
pageSize: z
|
||||
.number()
|
||||
.int()
|
||||
.refine((value) =>
|
||||
(DOMAIN_KEYWORDS_PAGE_SIZES as readonly number[]).includes(value),
|
||||
)
|
||||
.default(DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE),
|
||||
sortMode: z.enum(domainPagesSortModes).default("traffic"),
|
||||
sortOrder: z.enum(domainSortOrders).default("desc"),
|
||||
search: z.string().optional(),
|
||||
});
|
||||
|
||||
const filterStringParam = z.string().optional();
|
||||
const filterNumberParam = z.coerce.number().optional();
|
||||
|
||||
export const domainSearchSchema = z.object({
|
||||
domain: z.string().optional(),
|
||||
subdomains: booleanSearchParamSchema.optional(),
|
||||
@ -66,4 +147,24 @@ export const domainSearchSchema = z.object({
|
||||
tab: z.enum(domainTabs).optional(),
|
||||
search: z.string().optional(),
|
||||
loc: z.coerce.number().int().positive().optional(),
|
||||
page: z.coerce.number().int().positive().optional(),
|
||||
size: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.refine((value) =>
|
||||
(DOMAIN_KEYWORDS_PAGE_SIZES as readonly number[]).includes(value),
|
||||
)
|
||||
.optional(),
|
||||
include: filterStringParam,
|
||||
exclude: filterStringParam,
|
||||
minTraffic: filterNumberParam,
|
||||
maxTraffic: filterNumberParam,
|
||||
minVol: filterNumberParam,
|
||||
maxVol: filterNumberParam,
|
||||
minCpc: filterNumberParam,
|
||||
maxCpc: filterNumberParam,
|
||||
minKd: filterNumberParam,
|
||||
maxKd: filterNumberParam,
|
||||
minRank: filterNumberParam,
|
||||
maxRank: filterNumberParam,
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user