From b3d543d206adc0c1630d612e6b36a26e3724779d Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:10:18 -0400 Subject: [PATCH] feat: add top-level persistent filters for keyword research (#93) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../keywords/hooks/useLocalKeywordFilters.ts | 45 ++++++++++++++++++- .../page/KeywordResearchDesktopResults.tsx | 13 ++++-- .../page/KeywordResearchMobileResults.tsx | 13 ++++-- .../state/keywordControllerInternals.ts | 4 +- .../state/useKeywordResearchController.ts | 4 +- 5 files changed, 66 insertions(+), 13 deletions(-) diff --git a/src/client/features/keywords/hooks/useLocalKeywordFilters.ts b/src/client/features/keywords/hooks/useLocalKeywordFilters.ts index ea5bd44..e87466a 100644 --- a/src/client/features/keywords/hooks/useLocalKeywordFilters.ts +++ b/src/client/features/keywords/hooks/useLocalKeywordFilters.ts @@ -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 = [ "include", diff --git a/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx b/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx index 2bbe8c4..f60c575 100644 --- a/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx +++ b/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx @@ -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 (
@@ -123,9 +130,7 @@ function DesktopTableCard({ controller }: Props) { ) : null} - {selectedRows.size > 0 - ? `${selectedRows.size} of ${filteredRows.length} selected` - : `${filteredRows.length} keywords`} + {keywordCountLabel}
- {selectedRows.size > 0 - ? `${selectedRows.size} selected` - : `${filteredRows.length} keywords`} + {keywordCountLabel}