feat: add filtering and sorting to domain overview (#74)

* feat: add filtering and sorting to domain overview

Add numeric range filters (traffic, volume, CPC, KD/score, rank) and
text include/exclude filters to the domain overview page. Make CPC and
Score columns sortable. Filters persist across searches and auto-apply
to new results.

* fix: match domain filter panel layout to keyword research pattern

Restructure DomainResultsCard to use border-separated sections instead
of card-body gap layout. Filters button now sits in a toolbar row with
keyword count and search, matching the keyword research page's design.

* fix: make filter text inputs full-width so labels stack above

* fix: prettier formatting

* fix: extract domain filtering logic to fix lint errors

Move filterAndSortKeywords into domainFiltering.ts to bring
controllerInternals under 350 lines. Use explicit key array
in useDomainFilters to avoid unsafe type assertion.

* feat: add optional helpText tooltip to SortableHeader

Reuses HeaderHelpLabel from keywords components to show hover
tooltips on CPC and Score column headers.

* save
This commit is contained in:
Ben Senescu 2026-04-04 17:19:34 -04:00 committed by Ben Senescu
parent fc53e9a77c
commit 180657fe33
14 changed files with 538 additions and 142 deletions

View File

@ -114,8 +114,18 @@ export function DomainOverviewPage({
visibleKeywords={state.visibleKeywords} visibleKeywords={state.visibleKeywords}
filteredKeywords={state.filteredKeywords} filteredKeywords={state.filteredKeywords}
filteredPages={state.filteredPages} filteredPages={state.filteredPages}
showFilters={state.showFilters}
setShowFilters={state.setShowFilters}
filtersForm={state.filtersForm}
activeFilterCount={state.activeFilterCount}
resetFilters={state.resetFilters}
onTabChange={(tab) => { onTabChange={(tab) => {
if (tab === "pages" && searchState.sort === "rank") { if (
tab === "pages" &&
(searchState.sort === "rank" ||
searchState.sort === "score" ||
searchState.sort === "cpc")
) {
state.applySort("traffic", getDefaultSortOrder("traffic")); state.applySort("traffic", getDefaultSortOrder("traffic"));
} }
state.setSearchParams({ tab }); state.setSearchParams({ tab });

View File

@ -0,0 +1,167 @@
import { RotateCcw } from "lucide-react";
import type { useDomainFilters } from "@/client/features/domain/hooks/useDomainFilters";
import type { DomainFilterValues } from "@/client/features/domain/types";
type FilterForm = ReturnType<typeof useDomainFilters>["filtersForm"];
type Props = {
filtersForm: FilterForm;
activeFilterCount: number;
resetFilters: () => void;
};
export function DomainFilterPanel({
filtersForm,
activeFilterCount,
resetFilters,
}: Props) {
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="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold">Refine table results</p>
{activeFilterCount > 0 ? (
<span className="badge badge-xs badge-primary border-0 text-primary-content">
{activeFilterCount} active
</span>
) : null}
</div>
<button
className="btn btn-xs btn-ghost gap-1"
onClick={resetFilters}
disabled={activeFilterCount === 0}
>
<RotateCcw className="size-3" />
Clear all
</button>
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<FilterTextInput
form={filtersForm}
name="include"
label="Include Terms"
placeholder="audit, checker, template"
/>
<FilterTextInput
form={filtersForm}
name="exclude"
label="Exclude Terms"
placeholder="jobs, salary, course"
/>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<FilterRangeInputs
form={filtersForm}
title="Traffic"
minName="minTraffic"
maxName="maxTraffic"
/>
<FilterRangeInputs
form={filtersForm}
title="Volume"
minName="minVol"
maxName="maxVol"
/>
<FilterRangeInputs
form={filtersForm}
title="CPC (USD)"
minName="minCpc"
maxName="maxCpc"
step="0.01"
/>
<FilterRangeInputs
form={filtersForm}
title="Score (KD)"
minName="minKd"
maxName="maxKd"
/>
<FilterRangeInputs
form={filtersForm}
title="Rank"
minName="minRank"
maxName="maxRank"
/>
</div>
</div>
);
}
function FilterTextInput({
form,
name,
label,
placeholder,
}: {
form: FilterForm;
name: "include" | "exclude";
label: string;
placeholder: string;
}) {
return (
<label className="form-control gap-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
{label}
</span>
<form.Field name={name}>
{(field) => (
<input
className="input input-bordered input-sm w-full bg-base-100"
placeholder={placeholder}
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
/>
)}
</form.Field>
</label>
);
}
function FilterRangeInputs({
form,
title,
minName,
maxName,
step,
}: {
form: FilterForm;
title: string;
minName: keyof DomainFilterValues;
maxName: keyof DomainFilterValues;
step?: string;
}) {
return (
<div className="rounded-lg border border-base-300 bg-base-100 p-2.5 space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
{title}
</p>
<div className="grid grid-cols-2 gap-2">
<form.Field name={minName}>
{(field) => (
<input
className="input input-bordered input-xs bg-base-100"
placeholder="Min"
type="number"
step={step}
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
/>
)}
</form.Field>
<form.Field name={maxName}>
{(field) => (
<input
className="input input-bordered input-xs bg-base-100"
placeholder="Max"
type="number"
step={step}
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
/>
)}
</form.Field>
</div>
</div>
);
}

View File

@ -1,4 +1,3 @@
import { HeaderHelpLabel } from "@/client/features/keywords/components";
import { DifficultyBadge } from "@/client/features/domain/components/DifficultyBadge"; import { DifficultyBadge } from "@/client/features/domain/components/DifficultyBadge";
import { SortableHeader } from "@/client/features/domain/components/SortableHeader"; import { SortableHeader } from "@/client/features/domain/components/SortableHeader";
import { formatFloat, formatNumber } from "@/client/features/domain/utils"; import { formatFloat, formatNumber } from "@/client/features/domain/utils";
@ -78,13 +77,22 @@ export function DomainKeywordsTable({
/> />
</th> </th>
<th> <th>
<HeaderHelpLabel label="CPC" helpText="Cost per click in USD." /> <SortableHeader
label="CPC"
helpText="Cost per click in USD."
isActive={sortMode === "cpc"}
order={currentSortOrder}
onClick={() => onSortClick("cpc")}
/>
</th> </th>
<th>URL</th> <th>URL</th>
<th> <th>
<HeaderHelpLabel <SortableHeader
label="Score" label="Score"
helpText="Keyword difficulty score." helpText="Keyword difficulty score."
isActive={sortMode === "score"}
order={currentSortOrder}
onClick={() => onSortClick("score")}
/> />
</th> </th>
</tr> </tr>

View File

@ -1,3 +1,4 @@
import { type Dispatch, type SetStateAction } from "react";
import { import {
ChevronDown, ChevronDown,
Copy, Copy,
@ -5,10 +6,13 @@ import {
FileSpreadsheet, FileSpreadsheet,
Save, Save,
Search, Search,
SlidersHorizontal,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DomainFilterPanel } from "@/client/features/domain/components/DomainFilterPanel";
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 { import {
downloadCsv, downloadCsv,
keywordsToCsv, keywordsToCsv,
@ -33,6 +37,11 @@ type Props = {
visibleKeywords: string[]; visibleKeywords: string[];
filteredKeywords: KeywordRow[]; filteredKeywords: KeywordRow[];
filteredPages: PageRow[]; filteredPages: PageRow[];
showFilters: boolean;
setShowFilters: Dispatch<SetStateAction<boolean>>;
filtersForm: ReturnType<typeof useDomainFilters>["filtersForm"];
activeFilterCount: number;
resetFilters: () => void;
onTabChange: (tab: DomainActiveTab) => void; onTabChange: (tab: DomainActiveTab) => void;
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
onSaveKeywords: () => void; onSaveKeywords: () => void;
@ -51,6 +60,11 @@ export function DomainResultsCard({
visibleKeywords, visibleKeywords,
filteredKeywords, filteredKeywords,
filteredPages, filteredPages,
showFilters,
setShowFilters,
filtersForm,
activeFilterCount,
resetFilters,
onTabChange, onTabChange,
onSearchChange, onSearchChange,
onSaveKeywords, onSaveKeywords,
@ -78,18 +92,86 @@ export function DomainResultsCard({
const isKeywordsTab = activeTab === "keywords"; const isKeywordsTab = activeTab === "keywords";
return ( return (
<div className="card bg-base-100 border border-base-300"> <div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden">
<div className="card-body gap-3"> <div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 px-4 py-3 border-b border-base-300">
<DomainResultsCardHeader <div role="tablist" className="tabs tabs-box w-fit">
activeTab={activeTab} <button
selectedKeywordsCount={selectedKeywords.size} role="tab"
onTabChange={onTabChange} className={`tab ${activeTab === "keywords" ? "tab-active" : ""}`}
onSaveKeywords={onSaveKeywords} onClick={() => onTabChange("keywords")}
onCopy={handleCopy} >
onDownload={handleDownload} Top Keywords
/> </button>
<button
role="tab"
className={`tab ${activeTab === "pages" ? "tab-active" : ""}`}
onClick={() => onTabChange("pages")}
>
Top Pages
</button>
</div>
<div className="flex justify-end"> <div className="flex flex-wrap items-center gap-2">
{activeTab === "keywords" ? (
<button
className="btn btn-sm"
onClick={onSaveKeywords}
disabled={selectedKeywords.size === 0}
>
<Save className="size-4" /> Save Keywords
</button>
) : null}
<div className="dropdown dropdown-end">
<div tabIndex={0} role="button" className="btn btn-sm gap-1">
<Download className="size-4" />
Export
<ChevronDown className="size-3 opacity-60" />
</div>
<ul
tabIndex={0}
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-48"
>
<li>
<button onClick={handleCopy}>
<Copy className="size-4" />
Copy data
</button>
</li>
<li>
<button onClick={() => handleDownload("csv")}>
<Download className="size-4" />
Download CSV
</button>
</li>
<li>
<button onClick={() => handleDownload("xls")}>
<FileSpreadsheet className="size-4" />
Download Excel
</button>
</li>
</ul>
</div>
</div>
</div>
<div className="flex items-center gap-2 px-4 py-2 border-b border-base-300">
<button
className={`btn btn-ghost btn-sm gap-1.5 ${showFilters ? "btn-active" : ""}`}
onClick={() => setShowFilters((prev) => !prev)}
title="Toggle filters"
>
<SlidersHorizontal className="size-3.5" />
Filters
{activeFilterCount > 0 ? (
<span className="badge badge-xs badge-primary border-0 text-primary-content">
{activeFilterCount}
</span>
) : null}
</button>
<span className="text-sm text-base-content/60">
{filteredKeywords.length} keywords
</span>
<div className="flex-1" />
<label className="input input-bordered input-sm w-full max-w-xs flex items-center gap-2"> <label className="input input-bordered input-sm w-full max-w-xs flex items-center gap-2">
<Search className="size-4 text-base-content/60" /> <Search className="size-4 text-base-content/60" />
<input <input
@ -100,6 +182,15 @@ export function DomainResultsCard({
</label> </label>
</div> </div>
{showFilters ? (
<DomainFilterPanel
filtersForm={filtersForm}
activeFilterCount={activeFilterCount}
resetFilters={resetFilters}
/>
) : null}
<div className="p-4">
{isKeywordsTab ? ( {isKeywordsTab ? (
<DomainKeywordsTable <DomainKeywordsTable
rows={filteredKeywords} rows={filteredKeywords}
@ -123,82 +214,3 @@ export function DomainResultsCard({
</div> </div>
); );
} }
function DomainResultsCardHeader({
activeTab,
selectedKeywordsCount,
onTabChange,
onSaveKeywords,
onCopy,
onDownload,
}: {
activeTab: DomainActiveTab;
selectedKeywordsCount: number;
onTabChange: (tab: DomainActiveTab) => void;
onSaveKeywords: () => void;
onCopy: () => Promise<void>;
onDownload: (extension: "csv" | "xls") => void;
}) {
return (
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3">
<div role="tablist" className="tabs tabs-box w-fit">
<button
role="tab"
className={`tab ${activeTab === "keywords" ? "tab-active" : ""}`}
onClick={() => onTabChange("keywords")}
>
Top Keywords
</button>
<button
role="tab"
className={`tab ${activeTab === "pages" ? "tab-active" : ""}`}
onClick={() => onTabChange("pages")}
>
Top Pages
</button>
</div>
<div className="flex flex-wrap gap-2">
{activeTab === "keywords" ? (
<button
className="btn btn-sm"
onClick={onSaveKeywords}
disabled={selectedKeywordsCount === 0}
>
<Save className="size-4" /> Save Keywords
</button>
) : null}
<div className="dropdown dropdown-end">
<div tabIndex={0} role="button" className="btn btn-sm gap-1">
<Download className="size-4" />
Export
<ChevronDown className="size-3 opacity-60" />
</div>
<ul
tabIndex={0}
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-48"
>
<li>
<button onClick={onCopy}>
<Copy className="size-4" />
Copy data
</button>
</li>
<li>
<button onClick={() => onDownload("csv")}>
<Download className="size-4" />
Download CSV
</button>
</li>
<li>
<button onClick={() => onDownload("xls")}>
<FileSpreadsheet className="size-4" />
Download Excel
</button>
</li>
</ul>
</div>
</div>
</div>
);
}

View File

@ -62,6 +62,8 @@ export function DomainSearchCard({
<option value="rank">By Rank</option> <option value="rank">By Rank</option>
<option value="traffic">By Traffic</option> <option value="traffic">By Traffic</option>
<option value="volume">By Volume</option> <option value="volume">By Volume</option>
<option value="score">By Score</option>
<option value="cpc">By CPC</option>
</select> </select>
)} )}
</controlsForm.Field> </controlsForm.Field>

View File

@ -1,14 +1,22 @@
import { ArrowDown, ArrowUp } from "lucide-react"; import { ArrowDown, ArrowUp } from "lucide-react";
import { HeaderHelpLabel } from "@/client/features/keywords/components";
import type { SortOrder } from "@/client/features/domain/types"; import type { SortOrder } from "@/client/features/domain/types";
type Props = { type Props = {
label: string; label: string;
helpText?: string;
isActive: boolean; isActive: boolean;
order: SortOrder; order: SortOrder;
onClick: () => void; onClick: () => void;
}; };
export function SortableHeader({ label, isActive, order, onClick }: Props) { export function SortableHeader({
label,
helpText,
isActive,
order,
onClick,
}: Props) {
return ( return (
<button <button
type="button" type="button"
@ -17,7 +25,11 @@ export function SortableHeader({ label, isActive, order, onClick }: Props) {
aria-label={`Sort by ${label}`} aria-label={`Sort by ${label}`}
aria-pressed={isActive} aria-pressed={isActive}
> >
{helpText ? (
<HeaderHelpLabel label={label} helpText={helpText} />
) : (
<span>{label}</span> <span>{label}</span>
)}
{isActive ? ( {isActive ? (
order === "asc" ? ( order === "asc" ? (
<ArrowUp className="size-3" /> <ArrowUp className="size-3" />

View File

@ -0,0 +1,111 @@
import { sortBy } from "remeda";
import { sortableNullableNumber } from "@/client/features/domain/utils";
import type {
DomainFilterValues,
DomainSortMode,
KeywordRow,
SortOrder,
} from "@/client/features/domain/types";
function parseTerms(value: string): string[] {
return value
.toLowerCase()
.split(/[,+]/)
.map((term) => term.trim())
.filter(Boolean);
}
function passesNumericFilter(
value: number | null | undefined,
min: string,
max: string,
): boolean {
const v = value ?? 0;
if (min && v < Number(min)) return false;
if (max && v > Number(max)) return false;
return true;
}
export function filterAndSortKeywords(params: {
keywords: KeywordRow[];
pendingSearch: string;
filters: DomainFilterValues;
sortMode: DomainSortMode;
currentSortOrder: SortOrder;
}): KeywordRow[] {
const { keywords, pendingSearch, filters, sortMode, currentSortOrder } =
params;
const includeTerms = parseTerms(filters.include);
const excludeTerms = parseTerms(filters.exclude);
const filtered = keywords.filter((row) => {
const haystack = `${row.keyword} ${row.relativeUrl ?? ""}`.toLowerCase();
if (
pendingSearch &&
!haystack.includes(pendingSearch.toLowerCase().trim())
) {
return false;
}
if (
includeTerms.length > 0 &&
!includeTerms.every((term) => haystack.includes(term))
) {
return false;
}
if (excludeTerms.some((term) => haystack.includes(term))) {
return false;
}
if (
!passesNumericFilter(row.traffic, filters.minTraffic, filters.maxTraffic)
)
return false;
if (!passesNumericFilter(row.searchVolume, filters.minVol, filters.maxVol))
return false;
if (!passesNumericFilter(row.cpc, filters.minCpc, filters.maxCpc))
return false;
if (
!passesNumericFilter(row.keywordDifficulty, filters.minKd, filters.maxKd)
)
return false;
if (!passesNumericFilter(row.position, filters.minRank, filters.maxRank))
return false;
return true;
});
if (sortMode === "traffic") {
return sortBy(filtered, [
(row) => sortableNullableNumber(row.traffic, currentSortOrder),
currentSortOrder,
]);
}
if (sortMode === "volume") {
return sortBy(filtered, [
(row) => sortableNullableNumber(row.searchVolume, currentSortOrder),
currentSortOrder,
]);
}
if (sortMode === "score") {
return sortBy(filtered, [
(row) => sortableNullableNumber(row.keywordDifficulty, currentSortOrder),
currentSortOrder,
]);
}
if (sortMode === "cpc") {
return sortBy(filtered, [
(row) => sortableNullableNumber(row.cpc, currentSortOrder),
currentSortOrder,
]);
}
return sortBy(filtered, [
(row) => sortableNullableNumber(row.position, currentSortOrder),
currentSortOrder,
]);
}

View File

@ -5,6 +5,7 @@ import { sortBy } from "remeda";
import { toast } from "sonner"; import { toast } from "sonner";
import { getDomainOverview } from "@/serverFunctions/domain"; import { getDomainOverview } from "@/serverFunctions/domain";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { filterAndSortKeywords } from "@/client/features/domain/domainFiltering";
import { import {
getDefaultSortOrder, getDefaultSortOrder,
normalizeDomainTarget, normalizeDomainTarget,
@ -17,6 +18,7 @@ import {
} from "@/client/features/domain/utils"; } from "@/client/features/domain/utils";
import type { import type {
DomainActiveTab, DomainActiveTab,
DomainFilterValues,
DomainOverviewData, DomainOverviewData,
DomainSortMode, DomainSortMode,
SortOrder, SortOrder,
@ -62,45 +64,29 @@ type ControlsFormLike = DomainControlsFormAccess;
export function useOverviewDataState({ export function useOverviewDataState({
overview, overview,
pendingSearch, pendingSearch,
filters,
sortMode, sortMode,
currentSortOrder, currentSortOrder,
setSelectedKeywords, setSelectedKeywords,
}: { }: {
overview: DomainOverviewData | null; overview: DomainOverviewData | null;
pendingSearch: string; pendingSearch: string;
filters: DomainFilterValues;
sortMode: DomainSortMode; sortMode: DomainSortMode;
currentSortOrder: SortOrder; currentSortOrder: SortOrder;
setSelectedKeywords: Dispatch<SetStateAction<Set<string>>>; setSelectedKeywords: Dispatch<SetStateAction<Set<string>>>;
}) { }) {
const filteredKeywords = useMemo(() => { const filteredKeywords = useMemo(
const source = overview?.keywords ?? []; () =>
const filtered = !pendingSearch filterAndSortKeywords({
? source keywords: overview?.keywords ?? [],
: source.filter((row) => { pendingSearch,
const haystack = filters,
`${row.keyword} ${row.relativeUrl ?? ""}`.toLowerCase(); sortMode,
return haystack.includes(pendingSearch.toLowerCase().trim());
});
if (sortMode === "traffic") {
return sortBy(filtered, [
(row) => sortableNullableNumber(row.traffic, currentSortOrder),
currentSortOrder, currentSortOrder,
]); }),
} [currentSortOrder, filters, overview?.keywords, pendingSearch, sortMode],
);
if (sortMode === "volume") {
return sortBy(filtered, [
(row) => sortableNullableNumber(row.searchVolume, currentSortOrder),
currentSortOrder,
]);
}
return sortBy(filtered, [
(row) => sortableNullableNumber(row.position, currentSortOrder),
currentSortOrder,
]);
}, [currentSortOrder, overview?.keywords, pendingSearch, sortMode]);
const filteredPages = useMemo(() => { const filteredPages = useMemo(() => {
const source = overview?.pages ?? []; const source = overview?.pages ?? [];
@ -141,10 +127,16 @@ export function useOverviewDataState({
}); });
}, [setSelectedKeywords, visibleKeywords]); }, [setSelectedKeywords, visibleKeywords]);
const activeFilterCount = useMemo(
() => Object.values(filters).filter((value) => value.trim() !== "").length,
[filters],
);
return { return {
filteredKeywords, filteredKeywords,
filteredPages, filteredPages,
visibleKeywords, visibleKeywords,
activeFilterCount,
toggleKeywordSelection: (keyword: string) => { toggleKeywordSelection: (keyword: string) => {
setSelectedKeywords((prev) => { setSelectedKeywords((prev) => {
const next = new Set(prev); const next = new Set(prev);

View File

@ -0,0 +1,40 @@
import { useCallback } from "react";
import { useForm, useStore } from "@tanstack/react-form";
import {
EMPTY_DOMAIN_FILTERS,
type DomainFilterValues,
} from "@/client/features/domain/types";
export function useDomainFilters() {
const filtersForm = useForm({
defaultValues: EMPTY_DOMAIN_FILTERS,
});
const values = useStore(filtersForm.store, (s) => s.values);
const resetFilters = useCallback(() => {
const keys: Array<keyof DomainFilterValues> = [
"include",
"exclude",
"minTraffic",
"maxTraffic",
"minVol",
"maxVol",
"minCpc",
"maxCpc",
"minKd",
"maxKd",
"minRank",
"maxRank",
];
for (const key of keys) {
filtersForm.setFieldValue(key, "");
}
}, [filtersForm]);
return {
filtersForm,
values,
resetFilters,
};
}

View File

@ -16,10 +16,40 @@ export type PageRow = {
keywords: number | null; keywords: number | null;
}; };
export type DomainFilterValues = {
include: string;
exclude: string;
minTraffic: string;
maxTraffic: string;
minVol: string;
maxVol: string;
minCpc: string;
maxCpc: string;
minKd: string;
maxKd: string;
minRank: string;
maxRank: string;
};
export const EMPTY_DOMAIN_FILTERS: DomainFilterValues = {
include: "",
exclude: "",
minTraffic: "",
maxTraffic: "",
minVol: "",
maxVol: "",
minCpc: "",
maxCpc: "",
minKd: "",
maxKd: "",
minRank: "",
maxRank: "",
};
export type DomainControlsValues = { export type DomainControlsValues = {
domain: string; domain: string;
subdomains: boolean; subdomains: boolean;
sort: "rank" | "traffic" | "volume"; sort: "rank" | "traffic" | "volume" | "score" | "cpc";
}; };
export type DomainSortMode = DomainControlsValues["sort"]; export type DomainSortMode = DomainControlsValues["sort"];

View File

@ -24,6 +24,7 @@ import type {
} from "@/client/features/domain/types"; } from "@/client/features/domain/types";
import { saveSelectedKeywords } from "@/client/features/domain/domainActions"; 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 { import {
useDomainLookupMutation, useDomainLookupMutation,
useOverviewDataState, useOverviewDataState,
@ -109,6 +110,8 @@ export function useDomainOverviewController({
const [selectedKeywords, setSelectedKeywords] = useState<Set<string>>( const [selectedKeywords, setSelectedKeywords] = useState<Set<string>>(
new Set(), new Set(),
); );
const [showFilters, setShowFilters] = useState(false);
const domainFilters = useDomainFilters();
const { history, isLoaded, addSearch, clearHistory, removeHistoryItem } = const { history, isLoaded, addSearch, clearHistory, removeHistoryItem } =
useDomainSearchHistory(projectId); useDomainSearchHistory(projectId);
@ -165,6 +168,7 @@ export function useDomainOverviewController({
const dataState = useOverviewDataState({ const dataState = useOverviewDataState({
overview, overview,
pendingSearch, pendingSearch,
filters: domainFilters.values,
sortMode: searchState.sort, sortMode: searchState.sort,
currentSortOrder, currentSortOrder,
setSelectedKeywords, setSelectedKeywords,
@ -211,6 +215,10 @@ export function useDomainOverviewController({
selectedKeywords, selectedKeywords,
currentSortOrder, currentSortOrder,
setSearchParams, setSearchParams,
showFilters,
setShowFilters,
filtersForm: domainFilters.filtersForm,
resetFilters: domainFilters.resetFilters,
...handlers, ...handlers,
...dataState, ...dataState,
}; };

View File

@ -7,7 +7,13 @@ import type {
import { buildCsv, downloadCsv as downloadCsvFile } from "@/client/lib/csv"; import { buildCsv, downloadCsv as downloadCsvFile } from "@/client/lib/csv";
export function toSortMode(value: string | null): DomainSortMode | undefined { export function toSortMode(value: string | null): DomainSortMode | undefined {
if (value === "rank" || value === "traffic" || value === "volume") { if (
value === "rank" ||
value === "traffic" ||
value === "volume" ||
value === "score" ||
value === "cpc"
) {
return value; return value;
} }
return undefined; return undefined;
@ -50,11 +56,9 @@ export function sortableNullableNumber(
return order === "asc" ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY; return order === "asc" ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY;
} }
export function toPageSortMode( export function toPageSortMode(sortMode: DomainSortMode): "traffic" | "volume" {
sortMode: DomainSortMode, if (sortMode === "volume") return "volume";
): Exclude<DomainSortMode, "rank"> { return "traffic";
if (sortMode === "rank") return "traffic";
return sortMode;
} }
export function normalizeDomainTarget(input: string): string | null { export function normalizeDomainTarget(input: string): string | null {

View File

@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from "react";
import { z } from "zod"; import { z } from "zod";
import { jsonCodec } from "@/shared/json"; import { jsonCodec } from "@/shared/json";
type DomainSortMode = "rank" | "traffic" | "volume"; type DomainSortMode = "rank" | "traffic" | "volume" | "score" | "cpc";
type DomainTab = "keywords" | "pages"; type DomainTab = "keywords" | "pages";
export interface DomainSearchHistoryItem { export interface DomainSearchHistoryItem {

View File

@ -16,7 +16,7 @@ export const domainOverviewSchema = z.object({
/* URL search params schema for /p/$projectId/domain */ /* URL search params schema for /p/$projectId/domain */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
const domainSortModes = ["rank", "traffic", "volume"] as const; const domainSortModes = ["rank", "traffic", "volume", "score", "cpc"] as const;
const domainSortOrders = ["asc", "desc"] as const; const domainSortOrders = ["asc", "desc"] as const;
const domainTabs = ["keywords", "pages"] as const; const domainTabs = ["keywords", "pages"] as const;