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:
parent
fc53e9a77c
commit
180657fe33
@ -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 });
|
||||
|
||||
167
src/client/features/domain/components/DomainFilterPanel.tsx
Normal file
167
src/client/features/domain/components/DomainFilterPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" />
|
||||
|
||||
111
src/client/features/domain/domainFiltering.ts
Normal file
111
src/client/features/domain/domainFiltering.ts
Normal 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,
|
||||
]);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
40
src/client/features/domain/hooks/useDomainFilters.ts
Normal file
40
src/client/features/domain/hooks/useDomainFilters.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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"];
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user