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}
filteredKeywords={state.filteredKeywords}
filteredPages={state.filteredPages}
showFilters={state.showFilters}
setShowFilters={state.setShowFilters}
filtersForm={state.filtersForm}
activeFilterCount={state.activeFilterCount}
resetFilters={state.resetFilters}
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.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 { SortableHeader } from "@/client/features/domain/components/SortableHeader";
import { formatFloat, formatNumber } from "@/client/features/domain/utils";
@ -78,13 +77,22 @@ export function DomainKeywordsTable({
/>
</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>URL</th>
<th>
<HeaderHelpLabel
<SortableHeader
label="Score"
helpText="Keyword difficulty score."
isActive={sortMode === "score"}
order={currentSortOrder}
onClick={() => onSortClick("score")}
/>
</th>
</tr>

View File

@ -1,3 +1,4 @@
import { type Dispatch, type SetStateAction } from "react";
import {
ChevronDown,
Copy,
@ -5,10 +6,13 @@ import {
FileSpreadsheet,
Save,
Search,
SlidersHorizontal,
} from "lucide-react";
import { toast } from "sonner";
import { DomainFilterPanel } from "@/client/features/domain/components/DomainFilterPanel";
import { DomainKeywordsTable } from "@/client/features/domain/components/DomainKeywordsTable";
import { DomainPagesTable } from "@/client/features/domain/components/DomainPagesTable";
import type { useDomainFilters } from "@/client/features/domain/hooks/useDomainFilters";
import {
downloadCsv,
keywordsToCsv,
@ -33,6 +37,11 @@ type Props = {
visibleKeywords: string[];
filteredKeywords: KeywordRow[];
filteredPages: PageRow[];
showFilters: boolean;
setShowFilters: Dispatch<SetStateAction<boolean>>;
filtersForm: ReturnType<typeof useDomainFilters>["filtersForm"];
activeFilterCount: number;
resetFilters: () => void;
onTabChange: (tab: DomainActiveTab) => void;
onSearchChange: (value: string) => void;
onSaveKeywords: () => void;
@ -51,6 +60,11 @@ export function DomainResultsCard({
visibleKeywords,
filteredKeywords,
filteredPages,
showFilters,
setShowFilters,
filtersForm,
activeFilterCount,
resetFilters,
onTabChange,
onSearchChange,
onSaveKeywords,
@ -78,18 +92,86 @@ export function DomainResultsCard({
const isKeywordsTab = activeTab === "keywords";
return (
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-3">
<DomainResultsCardHeader
activeTab={activeTab}
selectedKeywordsCount={selectedKeywords.size}
onTabChange={onTabChange}
onSaveKeywords={onSaveKeywords}
onCopy={handleCopy}
onDownload={handleDownload}
/>
<div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 px-4 py-3 border-b border-base-300">
<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 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">
<Search className="size-4 text-base-content/60" />
<input
@ -100,6 +182,15 @@ export function DomainResultsCard({
</label>
</div>
{showFilters ? (
<DomainFilterPanel
filtersForm={filtersForm}
activeFilterCount={activeFilterCount}
resetFilters={resetFilters}
/>
) : null}
<div className="p-4">
{isKeywordsTab ? (
<DomainKeywordsTable
rows={filteredKeywords}
@ -123,82 +214,3 @@ export function DomainResultsCard({
</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="traffic">By Traffic</option>
<option value="volume">By Volume</option>
<option value="score">By Score</option>
<option value="cpc">By CPC</option>
</select>
)}
</controlsForm.Field>

View File

@ -1,14 +1,22 @@
import { ArrowDown, ArrowUp } from "lucide-react";
import { HeaderHelpLabel } from "@/client/features/keywords/components";
import type { SortOrder } from "@/client/features/domain/types";
type Props = {
label: string;
helpText?: string;
isActive: boolean;
order: SortOrder;
onClick: () => void;
};
export function SortableHeader({ label, isActive, order, onClick }: Props) {
export function SortableHeader({
label,
helpText,
isActive,
order,
onClick,
}: Props) {
return (
<button
type="button"
@ -17,7 +25,11 @@ export function SortableHeader({ label, isActive, order, onClick }: Props) {
aria-label={`Sort by ${label}`}
aria-pressed={isActive}
>
{helpText ? (
<HeaderHelpLabel label={label} helpText={helpText} />
) : (
<span>{label}</span>
)}
{isActive ? (
order === "asc" ? (
<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 { getDomainOverview } from "@/serverFunctions/domain";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { filterAndSortKeywords } from "@/client/features/domain/domainFiltering";
import {
getDefaultSortOrder,
normalizeDomainTarget,
@ -17,6 +18,7 @@ import {
} from "@/client/features/domain/utils";
import type {
DomainActiveTab,
DomainFilterValues,
DomainOverviewData,
DomainSortMode,
SortOrder,
@ -62,45 +64,29 @@ type ControlsFormLike = DomainControlsFormAccess;
export function useOverviewDataState({
overview,
pendingSearch,
filters,
sortMode,
currentSortOrder,
setSelectedKeywords,
}: {
overview: DomainOverviewData | null;
pendingSearch: string;
filters: DomainFilterValues;
sortMode: DomainSortMode;
currentSortOrder: SortOrder;
setSelectedKeywords: Dispatch<SetStateAction<Set<string>>>;
}) {
const filteredKeywords = useMemo(() => {
const source = overview?.keywords ?? [];
const filtered = !pendingSearch
? source
: source.filter((row) => {
const haystack =
`${row.keyword} ${row.relativeUrl ?? ""}`.toLowerCase();
return haystack.includes(pendingSearch.toLowerCase().trim());
});
if (sortMode === "traffic") {
return sortBy(filtered, [
(row) => sortableNullableNumber(row.traffic, currentSortOrder),
const filteredKeywords = useMemo(
() =>
filterAndSortKeywords({
keywords: overview?.keywords ?? [],
pendingSearch,
filters,
sortMode,
currentSortOrder,
]);
}
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]);
}),
[currentSortOrder, filters, overview?.keywords, pendingSearch, sortMode],
);
const filteredPages = useMemo(() => {
const source = overview?.pages ?? [];
@ -141,10 +127,16 @@ export function useOverviewDataState({
});
}, [setSelectedKeywords, visibleKeywords]);
const activeFilterCount = useMemo(
() => Object.values(filters).filter((value) => value.trim() !== "").length,
[filters],
);
return {
filteredKeywords,
filteredPages,
visibleKeywords,
activeFilterCount,
toggleKeywordSelection: (keyword: string) => {
setSelectedKeywords((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;
};
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 = {
domain: string;
subdomains: boolean;
sort: "rank" | "traffic" | "volume";
sort: "rank" | "traffic" | "volume" | "score" | "cpc";
};
export type DomainSortMode = DomainControlsValues["sort"];

View File

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

View File

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

View File

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

View File

@ -16,7 +16,7 @@ export const domainOverviewSchema = z.object({
/* 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 domainTabs = ["keywords", "pages"] as const;