diff --git a/src/client/features/keywords/components/IntentBadge.tsx b/src/client/features/keywords/components/IntentBadge.tsx index bf975af8..3858e557 100644 --- a/src/client/features/keywords/components/IntentBadge.tsx +++ b/src/client/features/keywords/components/IntentBadge.tsx @@ -18,32 +18,41 @@ const SHORT_LABELS: Record = { unknown: "?", }; +/** Full intent labels, shared with the keyword filters so both stay in sync. */ +export const INTENT_LABELS: Record = { + informational: "Informational", + commercial: "Commercial", + transactional: "Transactional", + navigational: "Navigational", + unknown: "Unknown", +}; + const DESCRIPTIONS: Record< KeywordIntent, { label: string; description: string } > = { informational: { - label: "Informational", + label: INTENT_LABELS.informational, description: "The searcher wants information or answers. Use this for educational content, guides, and comparison-light explainers.", }, commercial: { - label: "Commercial", + label: INTENT_LABELS.commercial, description: "The searcher is researching options before a purchase. Treat this as buying intent for comparisons, alternatives, and product-led pages.", }, transactional: { - label: "Transactional", + label: INTENT_LABELS.transactional, description: "The searcher is ready to complete an action, often a purchase. Prioritize clear offers, pricing, trials, or conversion paths.", }, navigational: { - label: "Navigational", + label: INTENT_LABELS.navigational, description: "The searcher is looking for a specific site, brand, or page. These queries usually reward matching the expected destination.", }, unknown: { - label: "Unknown", + label: INTENT_LABELS.unknown, description: "Intent was not available for this keyword, so avoid making content strategy decisions from this badge alone.", }, diff --git a/src/client/features/keywords/hooks/useKeywordFiltering.test.ts b/src/client/features/keywords/hooks/useKeywordFiltering.test.ts new file mode 100644 index 00000000..5e1c7780 --- /dev/null +++ b/src/client/features/keywords/hooks/useKeywordFiltering.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import type { KeywordIntent, KeywordResearchRow } from "@/types/keywords"; +import { + EMPTY_FILTERS, + parseIntentFilter, + toggleIntentFilter, + type KeywordFilterValues, +} from "@/client/features/keywords/keywordResearchTypes"; +import { applyKeywordFiltersAndSort } from "./useKeywordFiltering"; + +function makeRow(keyword: string, intent: KeywordIntent): KeywordResearchRow { + return { + keyword, + searchVolume: 100, + trend: [], + keywordDifficulty: 10, + cpc: 1, + competition: 0.5, + intent, + }; +} + +function filter( + rows: KeywordResearchRow[], + overrides: Partial, +): KeywordResearchRow[] { + return applyKeywordFiltersAndSort({ + rows, + filters: { ...EMPTY_FILTERS, ...overrides }, + sortField: "keyword", + sortDir: "asc", + }); +} + +const rows: KeywordResearchRow[] = [ + makeRow("buy running shoes", "transactional"), + makeRow("best running shoes", "commercial"), + makeRow("how to run", "informational"), + makeRow("nike store", "navigational"), + makeRow("mystery term", "unknown"), +]; + +describe("parseIntentFilter", () => { + it("returns an empty list for an empty string", () => { + expect(parseIntentFilter("")).toEqual([]); + }); + + it("parses a comma-separated string in canonical order", () => { + expect(parseIntentFilter("transactional,informational")).toEqual([ + "informational", + "transactional", + ]); + }); + + it("drops unknown tokens and de-duplicates", () => { + expect(parseIntentFilter("commercial,bogus,commercial")).toEqual([ + "commercial", + ]); + }); +}); + +describe("toggleIntentFilter", () => { + it("adds an intent when absent and keeps canonical order", () => { + expect(toggleIntentFilter("transactional", "informational")).toBe( + "informational,transactional", + ); + }); + + it("removes an intent when already present", () => { + expect( + toggleIntentFilter("informational,transactional", "informational"), + ).toBe("transactional"); + }); +}); + +describe("applyKeywordFiltersAndSort — intent filtering", () => { + it("returns every row when no intent is selected", () => { + expect(filter(rows, { intents: "" })).toHaveLength(rows.length); + }); + + it("keeps only rows matching a single selected intent", () => { + const result = filter(rows, { intents: "transactional" }); + expect(result.map((r) => r.keyword)).toEqual(["buy running shoes"]); + }); + + it("keeps rows matching any of multiple selected intents", () => { + const result = filter(rows, { intents: "transactional,commercial" }); + expect(result.map((r) => r.keyword).toSorted()).toEqual([ + "best running shoes", + "buy running shoes", + ]); + }); + + it("combines the intent filter with other filters (AND)", () => { + // "running" narrows to the two shoe rows; intent narrows to the commercial one. + const result = filter(rows, { + include: "running", + intents: "commercial", + }); + expect(result.map((r) => r.keyword)).toEqual(["best running shoes"]); + }); + + it("ignores invalid intent tokens (treated as no intent match constraint)", () => { + const result = filter(rows, { intents: "bogus" }); + expect(result).toHaveLength(rows.length); + }); +}); diff --git a/src/client/features/keywords/hooks/useKeywordFiltering.ts b/src/client/features/keywords/hooks/useKeywordFiltering.ts index 816ef44b..8e69d274 100644 --- a/src/client/features/keywords/hooks/useKeywordFiltering.ts +++ b/src/client/features/keywords/hooks/useKeywordFiltering.ts @@ -2,10 +2,13 @@ import { useMemo } from "react"; import { sortBy } from "remeda"; import { parseTerms } from "@/client/features/keywords/utils"; import type { KeywordResearchRow } from "@/types/keywords"; -import type { KeywordFilterValues } from "@/client/features/keywords/keywordResearchTypes"; +import { + parseIntentFilter, + type KeywordFilterValues, +} from "@/client/features/keywords/keywordResearchTypes"; import type { SortDir, SortField } from "@/client/features/keywords/components"; -function applyKeywordFiltersAndSort(params: { +export function applyKeywordFiltersAndSort(params: { rows: KeywordResearchRow[]; filters: KeywordFilterValues; sortField: SortField; @@ -13,6 +16,7 @@ function applyKeywordFiltersAndSort(params: { }): KeywordResearchRow[] { const includeTerms = parseTerms(params.filters.include); const excludeTerms = parseTerms(params.filters.exclude); + const selectedIntents = parseIntentFilter(params.filters.intents); const filtered = params.rows.filter((row) => { const haystack = row.keyword.toLowerCase(); @@ -26,6 +30,10 @@ function applyKeywordFiltersAndSort(params: { return false; } + if (selectedIntents.length > 0 && !selectedIntents.includes(row.intent)) { + return false; + } + const vol = row.searchVolume ?? 0; const cpc = row.cpc ?? 0; const kd = row.keywordDifficulty ?? 0; diff --git a/src/client/features/keywords/hooks/useLocalKeywordFilters.test.ts b/src/client/features/keywords/hooks/useLocalKeywordFilters.test.ts new file mode 100644 index 00000000..2c54e2bc --- /dev/null +++ b/src/client/features/keywords/hooks/useLocalKeywordFilters.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { EMPTY_FILTERS } from "@/client/features/keywords/keywordResearchTypes"; +import { filterValuesSchema } from "./useLocalKeywordFilters"; + +describe("filterValuesSchema — persistence migration", () => { + it("defaults intents to '' for blobs persisted before the intent filter existed", () => { + // A filter blob saved by an older build has no `intents` key. + const legacyBlob = { + include: "shoes", + exclude: "", + minVol: "100", + maxVol: "", + minCpc: "", + maxCpc: "", + minKd: "", + maxKd: "", + }; + + const parsed = filterValuesSchema.parse(legacyBlob); + + expect(parsed.intents).toBe(""); + // The rest of the user's saved filters survive the migration. + expect(parsed.include).toBe("shoes"); + expect(parsed.minVol).toBe("100"); + }); + + it("preserves a stored intents value", () => { + const parsed = filterValuesSchema.parse({ + ...EMPTY_FILTERS, + intents: "transactional,commercial", + }); + + expect(parsed.intents).toBe("transactional,commercial"); + }); +}); diff --git a/src/client/features/keywords/hooks/useLocalKeywordFilters.ts b/src/client/features/keywords/hooks/useLocalKeywordFilters.ts index e87466a9..566a1467 100644 --- a/src/client/features/keywords/hooks/useLocalKeywordFilters.ts +++ b/src/client/features/keywords/hooks/useLocalKeywordFilters.ts @@ -8,7 +8,7 @@ import { const STORAGE_KEY = "keyword-default-filters"; -const filterValuesSchema = z.object({ +export const filterValuesSchema = z.object({ include: z.string(), exclude: z.string(), minVol: z.string(), @@ -17,6 +17,9 @@ const filterValuesSchema = z.object({ maxCpc: z.string(), minKd: z.string(), maxKd: z.string(), + // Defaulted so filter blobs persisted before the intent filter existed still + // parse (missing key -> "") instead of discarding the user's saved filters. + intents: z.string().default(""), }); function loadFiltersFromStorage(): KeywordFilterValues { @@ -63,6 +66,7 @@ export function useLocalKeywordFilters() { "maxCpc", "minKd", "maxKd", + "intents", ]; for (const key of keys) { diff --git a/src/client/features/keywords/keywordResearchTypes.ts b/src/client/features/keywords/keywordResearchTypes.ts index 6071980b..687c70b3 100644 --- a/src/client/features/keywords/keywordResearchTypes.ts +++ b/src/client/features/keywords/keywordResearchTypes.ts @@ -1,3 +1,5 @@ +import type { KeywordIntent } from "@/types/keywords"; + export const MAX_KEYWORDS_PER_SUBMIT = 5; export type ResultLimit = 150 | 300 | 500; @@ -17,6 +19,14 @@ export type KeywordFilterValues = { maxCpc: string; minKd: string; maxKd: string; + /** + * Selected search intents, stored as a comma-separated string (e.g. + * "transactional,commercial") so it fits the all-strings filter shape used + * for form state, persistence, and active-filter counting. Empty = no intent + * filter. Use {@link parseIntentFilter} / {@link toggleIntentFilter} to read + * and edit it rather than parsing the string ad hoc. + */ + intents: string; }; export const EMPTY_FILTERS: KeywordFilterValues = { @@ -28,4 +38,43 @@ export const EMPTY_FILTERS: KeywordFilterValues = { maxCpc: "", minKd: "", maxKd: "", + intents: "", }; + +/** Canonical intent order for stable serialization and UI display. */ +export const KEYWORD_INTENT_ORDER: KeywordIntent[] = [ + "informational", + "commercial", + "transactional", + "navigational", + "unknown", +]; + +const KEYWORD_INTENT_SET = new Set(KEYWORD_INTENT_ORDER); + +/** Parses the stored intents string into a list of valid, de-duplicated intents. */ +export function parseIntentFilter(value: string): KeywordIntent[] { + if (!value) return []; + const selected = new Set( + value + .split(",") + .map((part) => part.trim()) + .filter((part): part is KeywordIntent => KEYWORD_INTENT_SET.has(part)), + ); + // Emit in canonical order so persistence and comparisons are deterministic. + return KEYWORD_INTENT_ORDER.filter((intent) => selected.has(intent)); +} + +/** Toggles one intent in the stored string, preserving canonical order. */ +export function toggleIntentFilter( + value: string, + intent: KeywordIntent, +): string { + const selected = new Set(parseIntentFilter(value)); + if (selected.has(intent)) { + selected.delete(intent); + } else { + selected.add(intent); + } + return KEYWORD_INTENT_ORDER.filter((item) => selected.has(item)).join(","); +} diff --git a/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx b/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx index 0a697da7..eb0554b3 100644 --- a/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx +++ b/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx @@ -23,9 +23,10 @@ import { import type { KeywordResearchRow } from "@/types/keywords"; import type { KeywordResearchControllerState } from "./types"; import { + FilterIntentSelect, FilterRangeInputs, FilterTextInput, -} from "./keywordResearchDesktopFilters"; +} from "./keywordResearchFilters"; import { KeywordResearchDesktopTable } from "./KeywordResearchDesktopTable"; import { KeywordResearchPagination, @@ -330,6 +331,8 @@ function DesktopFilters({ controller }: Props) { maxName="maxKd" /> + + ); } diff --git a/src/client/features/keywords/page/KeywordResearchDesktopTable.tsx b/src/client/features/keywords/page/KeywordResearchDesktopTable.tsx index 6b2839e2..fd14c3f1 100644 --- a/src/client/features/keywords/page/KeywordResearchDesktopTable.tsx +++ b/src/client/features/keywords/page/KeywordResearchDesktopTable.tsx @@ -19,7 +19,7 @@ import { import { DifficultyBadge } from "@/client/features/domain/components/DifficultyBadge"; import { formatNumber } from "@/client/features/keywords/utils"; import type { KeywordResearchRow } from "@/types/keywords"; -import { EmptyFilterResults } from "./keywordResearchDesktopFilters"; +import { EmptyFilterResults } from "./keywordResearchFilters"; type Props = { activeFilterCount: number; diff --git a/src/client/features/keywords/page/KeywordResearchMobileResults.tsx b/src/client/features/keywords/page/KeywordResearchMobileResults.tsx index befa7942..56ea1b10 100644 --- a/src/client/features/keywords/page/KeywordResearchMobileResults.tsx +++ b/src/client/features/keywords/page/KeywordResearchMobileResults.tsx @@ -15,6 +15,7 @@ import { import { exportTableToSheets } from "@/client/lib/exportToSheets"; import { captureClientEvent } from "@/client/lib/posthog"; import { SerpAnalysisCard } from "@/client/features/keywords/components"; +import { FilterIntentSelect } from "./keywordResearchFilters"; import { KeywordResearchDesktopTable } from "./KeywordResearchDesktopTable"; import { KeywordResearchPagination, @@ -322,6 +323,8 @@ function MobileFilters({ controller }: Props) { placeholder="Max difficulty" /> + + ); } diff --git a/src/client/features/keywords/page/keywordResearchDesktopFilters.tsx b/src/client/features/keywords/page/keywordResearchFilters.tsx similarity index 61% rename from src/client/features/keywords/page/keywordResearchDesktopFilters.tsx rename to src/client/features/keywords/page/keywordResearchFilters.tsx index 85b7aa7e..6d3d5f55 100644 --- a/src/client/features/keywords/page/keywordResearchDesktopFilters.tsx +++ b/src/client/features/keywords/page/keywordResearchFilters.tsx @@ -1,5 +1,63 @@ +import { + KEYWORD_INTENT_ORDER, + parseIntentFilter, + toggleIntentFilter, +} from "@/client/features/keywords/keywordResearchTypes"; +import { INTENT_LABELS } from "@/client/features/keywords/components/IntentBadge"; import type { KeywordResearchControllerState } from "./types"; +export function FilterIntentSelect({ + form, +}: { + form: KeywordResearchControllerState["filtersForm"]; +}) { + return ( +
+

+ Intent +

+ + {(field) => { + const selected = parseIntentFilter(field.state.value); + return ( +
+ {KEYWORD_INTENT_ORDER.map((intent) => { + const isActive = selected.includes(intent); + return ( + + ); + })} +
+ ); + }} +
+
+ ); +} + export function FilterTextInput({ form, name,