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:
parent
f7ccdfee38
commit
b3d543d206
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user