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