From 180657fe33386c72b840c64cac2e1a646b006ab2 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Sat, 4 Apr 2026 17:19:34 -0400 Subject: [PATCH] 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 --- .../features/domain/DomainOverviewPage.tsx | 12 +- .../domain/components/DomainFilterPanel.tsx | 167 ++++++++++++++ .../domain/components/DomainKeywordsTable.tsx | 14 +- .../domain/components/DomainResultsCard.tsx | 210 +++++++++--------- .../domain/components/DomainSearchCard.tsx | 2 + .../domain/components/SortableHeader.tsx | 16 +- src/client/features/domain/domainFiltering.ts | 111 +++++++++ .../domainOverviewControllerInternals.ts | 48 ++-- .../features/domain/hooks/useDomainFilters.ts | 40 ++++ src/client/features/domain/types.ts | 32 ++- .../domain/useDomainOverviewController.ts | 8 + src/client/features/domain/utils.ts | 16 +- src/client/hooks/useDomainSearchHistory.ts | 2 +- src/types/schemas/domain.ts | 2 +- 14 files changed, 538 insertions(+), 142 deletions(-) create mode 100644 src/client/features/domain/components/DomainFilterPanel.tsx create mode 100644 src/client/features/domain/domainFiltering.ts create mode 100644 src/client/features/domain/hooks/useDomainFilters.ts diff --git a/src/client/features/domain/DomainOverviewPage.tsx b/src/client/features/domain/DomainOverviewPage.tsx index e27bd2d..a4e632a 100644 --- a/src/client/features/domain/DomainOverviewPage.tsx +++ b/src/client/features/domain/DomainOverviewPage.tsx @@ -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 }); diff --git a/src/client/features/domain/components/DomainFilterPanel.tsx b/src/client/features/domain/components/DomainFilterPanel.tsx new file mode 100644 index 0000000..7efebc1 --- /dev/null +++ b/src/client/features/domain/components/DomainFilterPanel.tsx @@ -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["filtersForm"]; + +type Props = { + filtersForm: FilterForm; + activeFilterCount: number; + resetFilters: () => void; +}; + +export function DomainFilterPanel({ + filtersForm, + activeFilterCount, + resetFilters, +}: Props) { + return ( +
+
+
+

Refine table results

+ {activeFilterCount > 0 ? ( + + {activeFilterCount} active + + ) : null} +
+ +
+ +
+ + +
+ +
+ + + + + +
+
+ ); +} + +function FilterTextInput({ + form, + name, + label, + placeholder, +}: { + form: FilterForm; + name: "include" | "exclude"; + label: string; + placeholder: string; +}) { + return ( + + ); +} + +function FilterRangeInputs({ + form, + title, + minName, + maxName, + step, +}: { + form: FilterForm; + title: string; + minName: keyof DomainFilterValues; + maxName: keyof DomainFilterValues; + step?: string; +}) { + return ( +
+

+ {title} +

+
+ + {(field) => ( + field.handleChange(event.target.value)} + /> + )} + + + {(field) => ( + field.handleChange(event.target.value)} + /> + )} + +
+
+ ); +} diff --git a/src/client/features/domain/components/DomainKeywordsTable.tsx b/src/client/features/domain/components/DomainKeywordsTable.tsx index 6319674..b2478c3 100644 --- a/src/client/features/domain/components/DomainKeywordsTable.tsx +++ b/src/client/features/domain/components/DomainKeywordsTable.tsx @@ -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({ /> - + onSortClick("cpc")} + /> URL - onSortClick("score")} /> diff --git a/src/client/features/domain/components/DomainResultsCard.tsx b/src/client/features/domain/components/DomainResultsCard.tsx index 0cf63a8..ce89b36 100644 --- a/src/client/features/domain/components/DomainResultsCard.tsx +++ b/src/client/features/domain/components/DomainResultsCard.tsx @@ -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>; + filtersForm: ReturnType["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,28 +92,105 @@ export function DomainResultsCard({ const isKeywordsTab = activeTab === "keywords"; return ( -
-
- - -
- +
+
+
+ +
+
+ {activeTab === "keywords" ? ( + + ) : null} +
+
+ + Export + +
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+ +
+ + + {filteredKeywords.length} keywords + +
+ +
+ + {showFilters ? ( + + ) : null} + +
{isKeywordsTab ? ( ); } - -function DomainResultsCardHeader({ - activeTab, - selectedKeywordsCount, - onTabChange, - onSaveKeywords, - onCopy, - onDownload, -}: { - activeTab: DomainActiveTab; - selectedKeywordsCount: number; - onTabChange: (tab: DomainActiveTab) => void; - onSaveKeywords: () => void; - onCopy: () => Promise; - onDownload: (extension: "csv" | "xls") => void; -}) { - return ( -
-
- - -
- -
- {activeTab === "keywords" ? ( - - ) : null} -
-
- - Export - -
-
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
-
-
-
- ); -} diff --git a/src/client/features/domain/components/DomainSearchCard.tsx b/src/client/features/domain/components/DomainSearchCard.tsx index f816af7..1f7214a 100644 --- a/src/client/features/domain/components/DomainSearchCard.tsx +++ b/src/client/features/domain/components/DomainSearchCard.tsx @@ -62,6 +62,8 @@ export function DomainSearchCard({ + + )} diff --git a/src/client/features/domain/components/SortableHeader.tsx b/src/client/features/domain/components/SortableHeader.tsx index 3230c90..9add4e2 100644 --- a/src/client/features/domain/components/SortableHeader.tsx +++ b/src/client/features/domain/components/SortableHeader.tsx @@ -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 (