feat: add top-level persistent filters for keyword research (#93)

* feat: add top-level persistent filters for keyword research

Add a collapsible filter bar below the search bar so users can set
default filters (volume, CPC, difficulty, include/exclude terms) that
persist across searches and sessions via localStorage.

* simplify: remove top-level filter UI, keep only localStorage persistence

Revert the search bar to its original state. The existing in-table
filters already provide good UX — all we needed was persistence.
Auto-expand the filter panel when persisted filters are active on load.

* feat: show filtered keyword count as "Showing X of Y keywords"

When filters are active, display "Showing 14 of 150 keywords" instead
of just "14 keywords". When no filters are active, show "Showing X
keywords". Applied to both desktop and mobile views.

* style: fix prettier formatting
This commit is contained in:
Ben Senescu 2026-04-07 23:10:18 -04:00 committed by Ben Senescu
parent f7ccdfee38
commit b3d543d206
5 changed files with 66 additions and 13 deletions

View File

@ -1,17 +1,58 @@
import { useCallback } from "react";
import { useCallback, useEffect } from "react";
import { useForm, useStore } from "@tanstack/react-form";
import { z } from "zod";
import {
EMPTY_FILTERS,
type KeywordFilterValues,
} from "@/client/features/keywords/keywordResearchTypes";
const STORAGE_KEY = "keyword-default-filters";
const filterValuesSchema = z.object({
include: z.string(),
exclude: z.string(),
minVol: z.string(),
maxVol: z.string(),
minCpc: z.string(),
maxCpc: z.string(),
minKd: z.string(),
maxKd: z.string(),
});
function loadFiltersFromStorage(): KeywordFilterValues {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return EMPTY_FILTERS;
return filterValuesSchema.parse(JSON.parse(raw));
} catch {
return EMPTY_FILTERS;
}
}
function saveFiltersToStorage(filters: KeywordFilterValues) {
try {
const hasAnyFilter = Object.values(filters).some((v) => v.trim() !== "");
if (hasAnyFilter) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters));
} else {
localStorage.removeItem(STORAGE_KEY);
}
} catch {
// storage full or unavailable
}
}
export function useLocalKeywordFilters() {
const filtersForm = useForm({
defaultValues: EMPTY_FILTERS,
defaultValues: loadFiltersFromStorage(),
});
const values = useStore(filtersForm.store, (s) => s.values);
useEffect(() => {
saveFiltersToStorage(values);
}, [values]);
const resetFilters = useCallback(() => {
const keys: Array<keyof KeywordFilterValues> = [
"include",

View File

@ -103,9 +103,16 @@ function DesktopKeywordPanel({ controller }: Props) {
}
function DesktopTableCard({ controller }: Props) {
const { activeFilterCount, filteredRows, selectedRows, showFilters } =
const { activeFilterCount, filteredRows, rows, selectedRows, showFilters } =
controller;
const keywordCountLabel =
selectedRows.size > 0
? `${selectedRows.size} of ${filteredRows.length} selected`
: activeFilterCount > 0
? `Showing ${filteredRows.length} of ${rows.length} keywords`
: `Showing ${filteredRows.length} keywords`;
return (
<div className="flex-1 flex flex-col min-w-0 border border-base-300 rounded-xl bg-base-100 overflow-hidden">
<div className="shrink-0 flex items-center gap-2 px-4 py-2 border-b border-base-300">
@ -123,9 +130,7 @@ function DesktopTableCard({ controller }: Props) {
) : null}
</button>
<span className="text-sm text-base-content/60">
{selectedRows.size > 0
? `${selectedRows.size} of ${filteredRows.length} selected`
: `${filteredRows.length} keywords`}
{keywordCountLabel}
</span>
<div className="flex-1" />
<button

View File

@ -58,9 +58,16 @@ export function KeywordResearchMobileResults({ controller }: Props) {
}
function MobileKeywordCards({ controller }: Props) {
const { activeFilterCount, filteredRows, selectedRows, showFilters } =
const { activeFilterCount, filteredRows, rows, selectedRows, showFilters } =
controller;
const keywordCountLabel =
selectedRows.size > 0
? `${selectedRows.size} selected`
: activeFilterCount > 0
? `Showing ${filteredRows.length} of ${rows.length}`
: `Showing ${filteredRows.length} keywords`;
return (
<div className="flex-1 flex flex-col overflow-hidden">
{controller.showApproximateMatchNotice ? (
@ -88,9 +95,7 @@ function MobileKeywordCards({ controller }: Props) {
) : null}
</button>
<span className="text-xs text-base-content/60">
{selectedRows.size > 0
? `${selectedRows.size} selected`
: `${filteredRows.length} keywords`}
{keywordCountLabel}
</span>
<div className="flex-1" />
<button

View File

@ -20,8 +20,8 @@ export function useResolvedKeywordLocation(
return { locationCode, setPreferredLocationCode };
}
export function useKeywordUiState() {
const [showFilters, setShowFilters] = useState(false);
export function useKeywordUiState(initialShowFilters: boolean) {
const [showFilters, setShowFilters] = useState(initialShowFilters);
const [selectedKeyword, setSelectedKeyword] =
useState<KeywordResearchRow | null>(null);
const [showSaveDialog, setShowSaveDialog] = useState(false);

View File

@ -160,7 +160,6 @@ export function useKeywordResearchController(
}
function useKeywordControllerState(input: KeywordResearchControllerInput) {
const uiState = useKeywordUiState();
const { locationCode, setPreferredLocationCode } =
useResolvedKeywordLocation(input);
const {
@ -168,6 +167,9 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
values: filterValues,
resetFilters,
} = useLocalKeywordFilters();
const uiState = useKeywordUiState(
Object.values(filterValues).some((v) => v.trim() !== ""),
);
const { selectedRows, clearSelection, toggleRowSelection, toggleAllRows } =
useKeywordSelection();
const {