feat(keywords): add search intent filter to Keyword Research (#76)
Add a multi-select intent filter (Informational, Commercial, Transactional, Navigational, Unknown) to the Keyword Research table on both the desktop and mobile layouts (closes #67). Selected intents are stored as a comma-separated string in a new `intents` filter field, so they fit the existing all-strings filter shape and get active-filter counting, persistence, and clear-all for free. Sorting, pagination, save, and export already derive from the filtered row set, so they respect the intent filter automatically. The persistence schema defaults `intents` to "" so filter blobs saved before this change still load. Intent classification and URL params are untouched. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
65f7baf8a1
commit
927e931e51
@ -18,32 +18,41 @@ const SHORT_LABELS: Record<KeywordIntent, string> = {
|
||||
unknown: "?",
|
||||
};
|
||||
|
||||
/** Full intent labels, shared with the keyword filters so both stay in sync. */
|
||||
export const INTENT_LABELS: Record<KeywordIntent, string> = {
|
||||
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.",
|
||||
},
|
||||
|
||||
107
src/client/features/keywords/hooks/useKeywordFiltering.test.ts
Normal file
107
src/client/features/keywords/hooks/useKeywordFiltering.test.ts
Normal file
@ -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<KeywordFilterValues>,
|
||||
): 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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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) {
|
||||
|
||||
@ -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<string>(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(",");
|
||||
}
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FilterIntentSelect form={filtersForm} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FilterIntentSelect form={filtersForm} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby="keyword-intent-filter-label"
|
||||
className="rounded-lg border border-base-300 bg-base-100 p-2.5 space-y-2"
|
||||
>
|
||||
<p
|
||||
id="keyword-intent-filter-label"
|
||||
className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60"
|
||||
>
|
||||
Intent
|
||||
</p>
|
||||
<form.Field name="intents">
|
||||
{(field) => {
|
||||
const selected = parseIntentFilter(field.state.value);
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{KEYWORD_INTENT_ORDER.map((intent) => {
|
||||
const isActive = selected.includes(intent);
|
||||
return (
|
||||
<button
|
||||
key={intent}
|
||||
type="button"
|
||||
aria-pressed={isActive}
|
||||
className={`btn btn-xs ${
|
||||
isActive
|
||||
? "btn-primary"
|
||||
: "btn-ghost border border-base-300"
|
||||
}`}
|
||||
onClick={() =>
|
||||
field.handleChange(
|
||||
toggleIntentFilter(field.state.value, intent),
|
||||
)
|
||||
}
|
||||
>
|
||||
{INTENT_LABELS[intent]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</form.Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterTextInput({
|
||||
form,
|
||||
name,
|
||||
Loading…
x
Reference in New Issue
Block a user