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}
|
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 });
|
||||||
|
|||||||
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 { 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>
|
||||||
|
|||||||
@ -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,28 +92,105 @@ 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
|
||||||
<div className="flex justify-end">
|
role="tab"
|
||||||
<label className="input input-bordered input-sm w-full max-w-xs flex items-center gap-2">
|
className={`tab ${activeTab === "pages" ? "tab-active" : ""}`}
|
||||||
<Search className="size-4 text-base-content/60" />
|
onClick={() => onTabChange("pages")}
|
||||||
<input
|
>
|
||||||
placeholder="Search in results"
|
Top Pages
|
||||||
value={pendingSearch}
|
</button>
|
||||||
onChange={(event) => onSearchChange(event.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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
|
||||||
|
placeholder="Search in results"
|
||||||
|
value={pendingSearch}
|
||||||
|
onChange={(event) => onSearchChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
>
|
>
|
||||||
<span>{label}</span>
|
{helpText ? (
|
||||||
|
<HeaderHelpLabel label={label} helpText={helpText} />
|
||||||
|
) : (
|
||||||
|
<span>{label}</span>
|
||||||
|
)}
|
||||||
{isActive ? (
|
{isActive ? (
|
||||||
order === "asc" ? (
|
order === "asc" ? (
|
||||||
<ArrowUp className="size-3" />
|
<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 { 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);
|
||||||
|
|||||||
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;
|
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"];
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user