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 { useForm, useStore } from "@tanstack/react-form";
import { z } from "zod";
import { import {
EMPTY_FILTERS, EMPTY_FILTERS,
type KeywordFilterValues, type KeywordFilterValues,
} from "@/client/features/keywords/keywordResearchTypes"; } 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() { export function useLocalKeywordFilters() {
const filtersForm = useForm({ const filtersForm = useForm({
defaultValues: EMPTY_FILTERS, defaultValues: loadFiltersFromStorage(),
}); });
const values = useStore(filtersForm.store, (s) => s.values); const values = useStore(filtersForm.store, (s) => s.values);
useEffect(() => {
saveFiltersToStorage(values);
}, [values]);
const resetFilters = useCallback(() => { const resetFilters = useCallback(() => {
const keys: Array<keyof KeywordFilterValues> = [ const keys: Array<keyof KeywordFilterValues> = [
"include", "include",

View File

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

View File

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

View File

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

View File

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