Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions src/client/features/keywords/components/IntentBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
},
Expand Down
107 changes: 107 additions & 0 deletions src/client/features/keywords/hooks/useKeywordFiltering.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
12 changes: 10 additions & 2 deletions src/client/features/keywords/hooks/useKeywordFiltering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@ 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;
sortDir: SortDir;
}): 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();
Expand All @@ -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;
Expand Down
35 changes: 35 additions & 0 deletions src/client/features/keywords/hooks/useLocalKeywordFilters.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
6 changes: 5 additions & 1 deletion src/client/features/keywords/hooks/useLocalKeywordFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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 {
Expand Down Expand Up @@ -63,6 +66,7 @@ export function useLocalKeywordFilters() {
"maxCpc",
"minKd",
"maxKd",
"intents",
];

for (const key of keys) {
Expand Down
49 changes: 49 additions & 0 deletions src/client/features/keywords/keywordResearchTypes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { KeywordIntent } from "@/types/keywords";

export const MAX_KEYWORDS_PER_SUBMIT = 5;

export type ResultLimit = 150 | 300 | 500;
Expand All @@ -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 = {
Expand All @@ -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(",");
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -330,6 +331,8 @@ function DesktopFilters({ controller }: Props) {
maxName="maxKd"
/>
</div>

<FilterIntentSelect form={filtersForm} />
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -322,6 +323,8 @@ function MobileFilters({ controller }: Props) {
placeholder="Max difficulty"
/>
</div>

<FilterIntentSelect form={filtersForm} />
</div>
);
}
Expand Down
Loading
Loading