Refactor pages to improve performance and consistency (#205)

This commit is contained in:
Ben Senescu 2026-05-20 17:23:09 -04:00 committed by GitHub
parent 74ba0c4fc8
commit 2484f93ce6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
56 changed files with 4440 additions and 2136 deletions

View File

@ -0,0 +1,266 @@
import { expect, test } from "@playwright/test";
import {
attachDomainPerfMetrics,
attachJsonArtifact,
applyFilters,
closeFilters,
ensureFiltersOpen,
expectPageResponsive,
getDomainPerfMetrics,
installDomainPerfProbe,
openDomainOverview,
openFilters,
resetDomainPerfMetrics,
switchDomainTab,
type DomainPerfMetrics,
typeIntoDraftInput,
waitForDomainRows,
} from "./domain-overview-test-utils";
const CPU_THROTTLE_RATE = Number(process.env.DOMAIN_FILTER_CPU_THROTTLE ?? 6);
const PERF_BUDGETS = {
actionMs: Number(process.env.DOMAIN_FILTER_ACTION_MS ?? 2_500),
maxLongTaskMs: Number(process.env.DOMAIN_FILTER_MAX_LONG_TASK_MS ?? 1_000),
maxRafGapMs: Number(process.env.DOMAIN_FILTER_MAX_RAF_GAP_MS ?? 1_500),
maxInputMs: Number(process.env.DOMAIN_FILTER_MAX_INPUT_MS ?? 4_000),
totalLongTaskMs: Number(
process.env.DOMAIN_FILTER_TOTAL_LONG_TASK_MS ?? 8_000,
),
};
type PerfCheckpoint =
| {
label: string;
metrics: DomainPerfMetrics;
}
| {
label: string;
error: string;
};
type DomainDebugEntry = {
type: string;
text: string;
parsed: unknown;
};
test.describe("Domain Overview filter performance", () => {
test("captures main-thread stalls in the applied-filter edit flow", async ({
page,
browserName,
}, testInfo) => {
test.skip(browserName !== "chromium", "CPU throttling requires CDP");
test.setTimeout(120_000);
await installDomainPerfProbe(page);
await page.addInitScript(() => {
window.localStorage.setItem("debug:domain-overview", "1");
});
const domainDebugLog: DomainDebugEntry[] = [];
page.on("console", (message) => {
const text = message.text();
if (!text.startsWith("[domain-debug]")) return;
const raw = text.slice("[domain-debug]".length).trim();
domainDebugLog.push({
type: message.type(),
text,
parsed: parseDomainDebugMessage(raw),
});
});
const client = await page.context().newCDPSession(page);
await client.send("Emulation.setCPUThrottlingRate", {
rate: CPU_THROTTLE_RATE,
});
const checkpoints: PerfCheckpoint[] = [];
let finalMetrics: DomainPerfMetrics | null = null;
let flowError: unknown;
const captureCheckpoint = async (label: string) => {
checkpoints.push({
label,
metrics: await withDeadline(
getDomainPerfMetrics(page),
2_000,
`Timed out reading perf metrics for ${label}`,
),
});
};
try {
await openDomainOverview(page, "pages");
await waitForDomainRows(page, "Top Pages");
await resetDomainPerfMetrics(page);
await captureCheckpoint("ready on Top Pages");
await openFilters(page);
await closeFilters(page);
await openFilters(page);
await closeFilters(page);
await openFilters(page);
await captureCheckpoint("toggled Top Pages filters");
await typeIntoDraftInput(
page,
page.getByPlaceholder("Min").nth(0),
"10",
"Pages Traffic min",
{
actionTimeoutMs: PERF_BUDGETS.actionMs,
cdpSession: client,
inputLatencyBudgetMs: PERF_BUDGETS.maxInputMs,
recordPerf: true,
},
);
await applyFilters(page, "pMinTraffic", "10");
await captureCheckpoint("applied Pages Traffic min");
await ensureFiltersOpen(page, "Include Page Terms");
await typeIntoDraftInput(
page,
page.getByPlaceholder("Max").nth(1),
"50",
"Pages Keywords max",
{
actionTimeoutMs: PERF_BUDGETS.actionMs,
cdpSession: client,
inputLatencyBudgetMs: PERF_BUDGETS.maxInputMs,
recordPerf: true,
},
);
await expect(page.getByText("unapplied")).toBeVisible();
await expectPageResponsive(page, "after editing Pages Keywords max");
await captureCheckpoint("edited Pages Keywords max");
await switchDomainTab(page, "keywords");
await waitForDomainRows(page, "Top Keywords");
await openFilters(page);
await typeIntoDraftInput(
page,
page.getByPlaceholder("Max").nth(1),
"5000",
"Keywords Volume max",
{
actionTimeoutMs: PERF_BUDGETS.actionMs,
cdpSession: client,
inputLatencyBudgetMs: PERF_BUDGETS.maxInputMs,
recordPerf: true,
},
);
await expect(page.getByText("unapplied")).toBeVisible();
await expectPageResponsive(page, "after editing Keywords Volume max");
} catch (error) {
flowError = error;
checkpoints.push({
label: "flow error",
error: getErrorMessage(error),
});
} finally {
if (flowError) {
checkpoints.push({
label: "final metrics",
error:
"Skipped after renderer stall to preserve the original failure",
});
} else {
finalMetrics = await withDeadline(
getDomainPerfMetrics(page),
2_000,
"Timed out reading final perf metrics",
).catch((error: unknown) => {
checkpoints.push({
label: "final metrics",
error: getErrorMessage(error),
});
return null;
});
}
await attachJsonArtifact(
testInfo,
"domain-filter-perf-checkpoints.json",
checkpoints,
);
await attachJsonArtifact(
testInfo,
"domain-filter-debug-log.json",
domainDebugLog,
);
if (finalMetrics) {
await attachDomainPerfMetrics(testInfo, finalMetrics);
}
await withDeadline(
client.send("Emulation.setCPUThrottlingRate", { rate: 1 }),
2_000,
"Timed out resetting CPU throttling",
).catch(() => undefined);
}
if (flowError) throw flowError;
if (!finalMetrics) throw new Error("Perf metrics were not collected");
console.info(
"[domain-filter-perf]",
JSON.stringify(summarizeMetrics(finalMetrics)),
);
expect(finalMetrics.errors, "browser console/runtime errors").toEqual([]);
expect(
finalMetrics.maxInputDuration,
"slowest measured input",
).toBeLessThan(PERF_BUDGETS.maxInputMs);
expect(finalMetrics.maxLongTaskDuration, "slowest long task").toBeLessThan(
PERF_BUDGETS.maxLongTaskMs,
);
expect(
finalMetrics.totalLongTaskDuration,
"total long-task time",
).toBeLessThan(PERF_BUDGETS.totalLongTaskMs);
expect(finalMetrics.maxRafGap, "largest animation-frame gap").toBeLessThan(
PERF_BUDGETS.maxRafGapMs,
);
});
});
function summarizeMetrics(metrics: DomainPerfMetrics) {
return {
maxInputDuration: Math.round(metrics.maxInputDuration),
maxLongTaskDuration: Math.round(metrics.maxLongTaskDuration),
totalLongTaskDuration: Math.round(metrics.totalLongTaskDuration),
maxRafGap: Math.round(metrics.maxRafGap),
longTaskCount: metrics.longTasks.length,
rafGapCount: metrics.rafGaps.length,
inputEvents: metrics.inputEvents.map((event) => ({
label: event.label,
duration: Math.round(event.duration),
})),
};
}
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
function parseDomainDebugMessage(raw: string): unknown {
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
async function withDeadline<T>(
promise: Promise<T>,
timeoutMs: number,
message: string,
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}

View File

@ -0,0 +1,231 @@
import { expect, test } from "@playwright/test";
import {
applyFilters,
ensureFiltersOpen,
expectPageResponsive,
openDomainOverview,
PRIMARY_TEST_DOMAIN,
SECONDARY_TEST_DOMAIN,
openFilters,
switchDomainTab,
typeIntoDraftInput,
} from "./domain-overview-test-utils";
test.describe("Domain Overview filters", () => {
test("closing an inactive search tab does not select it", async ({
page,
}) => {
await openDomainOverview(page, "keywords");
const firstUrl = new URL(page.url());
const secondUrl = new URL(page.url());
secondUrl.searchParams.set("domain", SECONDARY_TEST_DOMAIN);
await page.goto(secondUrl.toString());
await expect(
page.getByRole("tab", { name: SECONDARY_TEST_DOMAIN }),
).toHaveAttribute("aria-selected", "true");
const inactiveCloseButton = page.getByRole("button", {
name: `Close ${PRIMARY_TEST_DOMAIN} tab`,
});
await inactiveCloseButton.click();
await expect
.poll(() => new URL(page.url()).searchParams.get("domain"))
.toBe(SECONDARY_TEST_DOMAIN);
await expect(
page.getByRole("tab", { name: SECONDARY_TEST_DOMAIN }),
).toHaveAttribute("aria-selected", "true");
await expect(
page.getByRole("tab", { name: PRIMARY_TEST_DOMAIN }),
).toHaveCount(0);
expect(firstUrl.searchParams.get("domain")).toBe(PRIMARY_TEST_DOMAIN);
});
test("closing the active search tab removes it and selects the neighbor", async ({
page,
}) => {
await openDomainOverview(page, "keywords");
const secondUrl = new URL(page.url());
secondUrl.searchParams.set("domain", SECONDARY_TEST_DOMAIN);
await page.goto(secondUrl.toString());
await expect(
page.getByRole("tab", { name: SECONDARY_TEST_DOMAIN }),
).toHaveAttribute("aria-selected", "true");
const activeCloseButton = page.getByRole("button", {
name: `Close ${SECONDARY_TEST_DOMAIN} tab`,
});
const closedTabId =
await activeCloseButton.getAttribute("data-search-tab-id");
expect(closedTabId).toBeTruthy();
await activeCloseButton.click();
await expect
.poll(() => new URL(page.url()).searchParams.get("domain"))
.toBe(PRIMARY_TEST_DOMAIN);
await expect(
page.getByRole("tab", { name: PRIMARY_TEST_DOMAIN }),
).toHaveAttribute("aria-selected", "true");
await expect(
page.locator(`[data-search-tab-id="${closedTabId}"]`),
).toHaveCount(0);
});
test("keyword filters stay responsive after applying Traffic min and editing Volume max", async ({
page,
}) => {
await openDomainOverview(page, "keywords");
await openFilters(page);
await typeIntoDraftInput(
page,
page.getByPlaceholder("Min").nth(0),
"10",
"Traffic min",
);
await applyFilters(page);
await ensureFiltersOpen(page, "Include Terms");
await typeIntoDraftInput(
page,
page.getByPlaceholder("Max").nth(1),
"5000",
"Volume max",
);
await expect(page.getByText("unapplied")).toBeVisible();
await expectPageResponsive(page, "after editing Volume max");
});
test("page filters stay responsive after applying Traffic min and editing Keywords max", async ({
page,
}) => {
await openDomainOverview(page, "pages");
await openFilters(page);
await typeIntoDraftInput(
page,
page.getByPlaceholder("Min").nth(0),
"10",
"Traffic min",
);
await applyFilters(page, "pMinTraffic", "10");
await ensureFiltersOpen(page, "Include Page Terms");
await typeIntoDraftInput(
page,
page.getByPlaceholder("Max").nth(1),
"50",
"Keywords max",
);
await expect(page.getByText("unapplied")).toBeVisible();
await expectPageResponsive(page, "after editing Keywords max");
});
test("clearing page filters does not clear keyword filters", async ({
page,
}) => {
await openDomainOverview(page, "keywords");
await openFilters(page);
await typeIntoDraftInput(
page,
page.getByPlaceholder("Min").nth(0),
"10",
"Keyword traffic min",
);
await applyFilters(page, "minTraffic", "10");
await switchDomainTab(page, "pages");
await openFilters(page);
await typeIntoDraftInput(
page,
page.getByPlaceholder("Min").nth(0),
"20",
"Page traffic min",
);
await applyFilters(page, "pMinTraffic", "20");
await ensureFiltersOpen(page, "Include Page Terms");
await page.getByRole("button", { name: "Clear all" }).click();
await expect
.poll(() => new URL(page.url()).searchParams.get("pMinTraffic"))
.toBe(null);
await expect
.poll(() => new URL(page.url()).searchParams.get("minTraffic"))
.toBe("10");
await expectPageResponsive(page, "after clearing page filters");
const keywordUrl = new URL(page.url());
keywordUrl.searchParams.delete("tab");
await page.goto(keywordUrl.toString());
await ensureFiltersOpen(page, "Include Terms");
await expect(page.getByPlaceholder("Min").nth(0)).toHaveValue("10");
});
test("submitting a new domain clears the previous domain filters", async ({
page,
}) => {
await openDomainOverview(page, "pages");
await openFilters(page);
await typeIntoDraftInput(
page,
page.getByPlaceholder("Min").nth(0),
"20",
"Page traffic min",
);
await applyFilters(page, "pMinTraffic", "20");
const domainInput = page.getByPlaceholder("Enter a domain").nth(1);
await domainInput.click();
await domainInput.press(
process.platform === "darwin" ? "Meta+A" : "Control+A",
);
await domainInput.press("Backspace");
await domainInput.pressSequentially(SECONDARY_TEST_DOMAIN);
await page.getByRole("button", { name: "Search", exact: true }).click();
await expect
.poll(() => new URL(page.url()).searchParams.get("domain"))
.toBe(SECONDARY_TEST_DOMAIN);
await expect
.poll(() => new URL(page.url()).searchParams.get("pMinTraffic"))
.toBe(null);
await ensureFiltersOpen(page, "Include Page Terms");
await expect(page.getByPlaceholder("Min").nth(0)).toHaveValue("");
await expectPageResponsive(page, "after submitting a new domain");
});
test("saved filter defaults apply only when the URL has no tab filters", async ({
page,
}) => {
await openDomainOverview(page, "pages");
await openFilters(page);
await typeIntoDraftInput(
page,
page.getByPlaceholder("Min").nth(0),
"20",
"Page traffic min",
);
await applyFilters(page, "pMinTraffic", "20");
const urlWithoutPageFilters = new URL(page.url());
urlWithoutPageFilters.searchParams.delete("pMinTraffic");
await page.goto(urlWithoutPageFilters.toString());
await ensureFiltersOpen(page, "Include Page Terms");
await expect(page.getByPlaceholder("Min").nth(0)).toHaveValue("20");
expect(new URL(page.url()).searchParams.get("pMinTraffic")).toBe(null);
const urlWithExplicitPageFilters = new URL(page.url());
urlWithExplicitPageFilters.searchParams.set("pMinTraffic", "30");
await page.goto(urlWithExplicitPageFilters.toString());
await ensureFiltersOpen(page, "Include Page Terms");
await expect(page.getByPlaceholder("Min").nth(0)).toHaveValue("30");
});
});

View File

@ -0,0 +1,372 @@
import { writeFile } from "node:fs/promises";
import {
expect,
type CDPSession,
type Locator,
type Page,
type TestInfo,
} from "@playwright/test";
export const PRIMARY_TEST_DOMAIN = "primary.example";
export const SECONDARY_TEST_DOMAIN = "secondary.example";
const RESPONSIVE_TIMEOUT_MS = 1_500;
const INPUT_LATENCY_BUDGET_MS = 8_000;
type DomainTab = "keywords" | "pages";
type DomainLongTask = {
name: string;
startTime: number;
duration: number;
};
type DomainRafGap = {
startTime: number;
duration: number;
};
type DomainInputEvent = {
label: string;
duration: number;
time: number;
};
type DomainPerfState = {
startedAt: number;
longTasks: DomainLongTask[];
rafGaps: DomainRafGap[];
maxRafGap: number;
inputEvents: DomainInputEvent[];
errors: string[];
};
type WindowWithDomainPerf = Window &
typeof globalThis & {
__domainPerf?: DomainPerfState;
};
export type DomainPerfMetrics = DomainPerfState & {
url: string;
userAgent: string;
maxLongTaskDuration: number;
totalLongTaskDuration: number;
maxInputDuration: number;
};
export async function installDomainPerfProbe(page: Page) {
await page.addInitScript(() => {
const state: DomainPerfState = {
startedAt: performance.now(),
longTasks: [],
rafGaps: [],
maxRafGap: 0,
inputEvents: [],
errors: [],
};
const win = window as WindowWithDomainPerf;
win.__domainPerf = state;
try {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
state.longTasks.push({
name: entry.name,
startTime: entry.startTime,
duration: entry.duration,
});
}
});
observer.observe({ type: "longtask", buffered: true });
} catch {
// Long Tasks are Chromium-only. The rest of the probe still catches stalls.
}
let lastFrame = performance.now();
const tick = (now: number) => {
const gap = now - lastFrame;
state.maxRafGap = Math.max(state.maxRafGap, gap);
if (gap > 100) {
state.rafGaps.push({ startTime: lastFrame, duration: gap });
}
lastFrame = now;
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
window.addEventListener("error", (event) => {
state.errors.push(event.message);
});
window.addEventListener("unhandledrejection", (event) => {
state.errors.push(String(event.reason));
});
});
}
export async function resetDomainPerfMetrics(page: Page) {
await page.evaluate(() => {
const state = (window as WindowWithDomainPerf).__domainPerf;
if (!state) return;
state.startedAt = performance.now();
state.longTasks = [];
state.rafGaps = [];
state.maxRafGap = 0;
state.inputEvents = [];
state.errors = [];
});
}
export async function getDomainPerfMetrics(
page: Page,
): Promise<DomainPerfMetrics> {
return page.evaluate(() => {
const state = (window as WindowWithDomainPerf).__domainPerf;
if (!state) {
throw new Error("Domain perf probe was not installed");
}
return {
...state,
url: window.location.href,
userAgent: navigator.userAgent,
maxLongTaskDuration: Math.max(
0,
...state.longTasks.map((entry) => entry.duration),
),
totalLongTaskDuration: state.longTasks.reduce(
(total, entry) => total + entry.duration,
0,
),
maxInputDuration: Math.max(
0,
...state.inputEvents.map((entry) => entry.duration),
),
};
});
}
export async function attachDomainPerfMetrics(
testInfo: TestInfo,
metrics: DomainPerfMetrics,
) {
await attachJsonArtifact(testInfo, "domain-filter-perf.json", metrics);
}
export async function attachJsonArtifact(
testInfo: TestInfo,
name: string,
value: unknown,
) {
const outputPath = testInfo.outputPath(name);
await writeFile(outputPath, JSON.stringify(value, null, 2));
await testInfo.attach(name, {
path: outputPath,
contentType: "application/json",
});
}
export async function openDomainOverview(page: Page, tab: DomainTab) {
await page.addInitScript(() => {
if (window.sessionStorage.getItem("domain-overview-e2e-cleared") === "1") {
return;
}
for (let index = window.localStorage.length - 1; index >= 0; index -= 1) {
const key = window.localStorage.key(index);
if (key?.startsWith("domain-overview-filter-defaults:")) {
window.localStorage.removeItem(key);
}
}
window.sessionStorage.setItem("domain-overview-e2e-cleared", "1");
});
await page.goto("/");
await page.waitForURL(/\/p\/([^/]+)\/keywords(?:\?.*)?$/, {
timeout: 30_000,
});
const match = page.url().match(/\/p\/([^/]+)\/keywords/);
if (!match) throw new Error(`Could not read project id from ${page.url()}`);
const params = new URLSearchParams({
domain: PRIMARY_TEST_DOMAIN,
subdomains: "true",
sort: "traffic",
order: "desc",
});
if (tab === "pages") params.set("tab", "pages");
await page.goto(`/p/${match[1]}/domain?${params.toString()}`);
await expect(
page.getByRole("heading", { name: "Domain Overview" }),
).toBeVisible();
await dismissSetupModal(page);
await expect(page.getByRole("button", { name: /Filters/ })).toBeVisible({
timeout: 30_000,
});
await expectPageResponsive(page, "after opening Domain Overview");
}
async function dismissSetupModal(page: Page) {
const dismissButton = page.getByRole("button", { name: "Dismiss" });
if (await dismissButton.isVisible()) {
await dismissButton.click();
}
}
export async function waitForDomainRows(page: Page, label: string) {
const marker = label.includes("Pages")
? page.getByRole("link", { name: /\/section-a\/page-001/ }).first()
: page
.getByRole("row", {
name: /primary sample query.*\/section-a\/page-001/,
})
.first();
await expect(marker, label).toBeVisible({ timeout: 30_000 });
await expectPageResponsive(page, `after table rows loaded for ${label}`);
}
export async function switchDomainTab(page: Page, tab: DomainTab) {
const label = tab === "keywords" ? "Top Keywords" : "Top Pages";
await page.getByRole("tab", { name: label }).click();
await expect(page.getByRole("tab", { name: label })).toHaveAttribute(
"aria-selected",
"true",
);
await expectPageResponsive(page, `after switching to ${label}`);
}
export async function openFilters(page: Page) {
await page.getByRole("button", { name: /Filters/ }).click();
await expect(page.getByText("Refine table results")).toBeVisible();
await expectPageResponsive(page, "after opening filters");
}
export async function closeFilters(page: Page) {
await page.getByRole("button", { name: /Filters/ }).click();
await expect(page.getByText("Refine table results")).toBeHidden();
await expectPageResponsive(page, "after closing filters");
}
export async function ensureFiltersOpen(page: Page, expectedLabel: string) {
if (!(await page.getByText(expectedLabel).isVisible())) {
await openFilters(page);
}
await expect(page.getByText(expectedLabel)).toBeVisible();
}
export async function applyFilters(
page: Page,
expectedParam = "minTraffic",
expectedValue = "10",
) {
await page.getByRole("button", { name: /Apply filters/ }).click();
await expect
.poll(() => new URL(page.url()).searchParams.get(expectedParam))
.toBe(expectedValue);
await expect(page.getByRole("button", { name: /Filters/ })).toContainText(
"1",
);
await expectPageResponsive(page, "after applying filters");
}
export async function typeIntoDraftInput(
page: Page,
input: Locator,
value: string,
label: string,
options?: {
actionTimeoutMs?: number;
cdpSession?: CDPSession;
inputLatencyBudgetMs?: number;
recordPerf?: boolean;
},
) {
const actionTimeout = options?.actionTimeoutMs ?? 5_000;
const budget = options?.inputLatencyBudgetMs ?? INPUT_LATENCY_BUDGET_MS;
const started = Date.now();
await input.click({ timeout: actionTimeout });
if (options?.cdpSession) {
await withTimeout(
input.evaluate((element) => {
if (!(element instanceof HTMLInputElement)) {
throw new Error("Expected a text input");
}
element.value = "";
element.dispatchEvent(new Event("input", { bubbles: true }));
}),
actionTimeout,
`Input clear did not settle for ${label}`,
);
} else {
await input.press(process.platform === "darwin" ? "Meta+A" : "Control+A", {
timeout: actionTimeout,
});
await input.press("Backspace", { timeout: actionTimeout });
}
for (let index = 0; index < value.length; index += 1) {
const char = value[index] ?? "";
const keyStarted = Date.now();
if (options?.cdpSession) {
await withTimeout(
options.cdpSession.send("Input.dispatchKeyEvent", {
type: "char",
text: char,
}),
actionTimeout,
`CDP input did not settle for ${label} char ${index + 1}`,
);
} else {
await input.press(char, { timeout: actionTimeout });
}
const keyDuration = Date.now() - keyStarted;
if (options?.recordPerf) {
await recordInputLatency(page, `${label} char ${index + 1}`, keyDuration);
}
await expectPageResponsive(page, `after typing ${label}`);
}
const duration = Date.now() - started;
if (options?.recordPerf) {
await recordInputLatency(page, label, duration);
}
expect(duration, `${label} input latency`).toBeLessThan(budget);
}
export async function expectPageResponsive(page: Page, label: string) {
await withTimeout(
page.evaluate(() => document.body.textContent?.includes("Domain Overview")),
RESPONSIVE_TIMEOUT_MS,
`Page did not respond ${label}`,
);
}
async function recordInputLatency(page: Page, label: string, duration: number) {
await page.evaluate(
({ label: eventLabel, duration: eventDuration }) => {
const state = (window as WindowWithDomainPerf).__domainPerf;
state?.inputEvents.push({
label: eventLabel,
duration: eventDuration,
time: performance.now(),
});
},
{ label, duration },
);
}
async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
message: string,
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}

View File

@ -0,0 +1,85 @@
export function getFixtureOverview(domain: string) {
return {
domain,
organicTraffic: 373,
organicKeywords: 307,
backlinks: null,
referringDomains: null,
hasData: true,
fetchedAt: "2026-05-19T00:00:00.000Z",
};
}
export function getFixturePagesPage(data: {
domain: string;
page: number;
pageSize: number;
}) {
const rows = [
["/section-a/page-001", 179, 34],
["/section-a/page-002", 63, 9],
["/section-b/page-003", 23, 25],
["/section-b/page-004", 19, 16],
["/section-b/page-005", 17, 12],
["/section-c/page-006", 8, 7],
["/section-c/page-007", 8, 11],
["/guides/topic-008", 3, 10],
["/guides/topic-009", 3, 11],
["/guides/topic-010", 3, 7],
["/guides/topic-011", 3, 6],
["/guides/topic-012", 2, 5],
];
const pages = rows.map(([path, traffic, keywords]) => ({
page: `https://${data.domain}${path}`,
relativePath: String(path),
organicTraffic: Number(traffic),
keywords: Number(keywords),
}));
return {
domain: data.domain,
page: data.page,
pageSize: data.pageSize,
totalCount: 66,
hasMore: data.page * data.pageSize < 66,
pages,
fetchedAt: "2026-05-19T00:00:00.000Z",
};
}
export function getFixtureKeywordsPage(data: {
domain: string;
page: number;
pageSize: number;
}) {
const rows = [
["primary sample query", 1, 4_400, 86, 0.61, 18],
["secondary sample query", 2, 3_600, 74, 0.82, 24],
["tertiary sample query", 1, 1_900, 51, 0.44, 21],
["sample comparison term", 3, 1_600, 39, 0.52, 28],
["sample evaluation term", 4, 1_300, 31, 0.49, 25],
["sample reference term", 5, 1_100, 24, 0.37, 19],
];
const keywords = rows.map(
([keyword, position, searchVolume, traffic, cpc, keywordDifficulty]) => ({
keyword: String(keyword),
position: Number(position),
searchVolume: Number(searchVolume),
traffic: Number(traffic),
cpc: Number(cpc),
url: `https://${data.domain}/section-a/page-001`,
relativeUrl: "/section-a/page-001",
keywordDifficulty: Number(keywordDifficulty),
}),
);
return {
domain: data.domain,
page: data.page,
pageSize: data.pageSize,
totalCount: 307,
hasMore: data.page * data.pageSize < 307,
keywords,
fetchedAt: "2026-05-19T00:00:00.000Z",
};
}

View File

@ -0,0 +1,73 @@
import type { KeywordResearchRow } from "@/types/keywords";
import type { ResearchKeywordsInput } from "@/types/schemas/keywords";
const MONTHLY_SEARCHES = [
{ year: 2025, month: 4, searchVolume: 1200 },
{ year: 2025, month: 5, searchVolume: 1600 },
{ year: 2025, month: 6, searchVolume: 2400 },
{ year: 2025, month: 7, searchVolume: 3200 },
{ year: 2025, month: 8, searchVolume: 4200 },
{ year: 2025, month: 9, searchVolume: 3600 },
{ year: 2025, month: 10, searchVolume: 3000 },
{ year: 2025, month: 11, searchVolume: 2600 },
{ year: 2025, month: 12, searchVolume: 2200 },
{ year: 2026, month: 1, searchVolume: 2100 },
{ year: 2026, month: 2, searchVolume: 2300 },
{ year: 2026, month: 3, searchVolume: 2800 },
];
function makeRow(
keyword: string,
index: number,
overrides: Partial<KeywordResearchRow> = {},
): KeywordResearchRow {
return {
keyword,
searchVolume: 20_000 - index * 750,
trend: MONTHLY_SEARCHES,
keywordDifficulty: 40 + (index % 40),
cpc: Number((1.25 + index * 0.15).toFixed(2)),
competition: Number((0.05 + (index % 10) * 0.04).toFixed(2)),
intent: index % 3 === 0 ? "commercial" : "informational",
...overrides,
};
}
export function getKeywordResearchFixture(data: ResearchKeywordsInput) {
const seedKeyword = data.keywords[0] ?? "keyword research";
const rows = [
makeRow(seedKeyword, 0, {
searchVolume: 288_431,
keywordDifficulty: 78,
cpc: 11.93,
competition: 0.07,
intent: "informational",
}),
makeRow(`${seedKeyword} tools`, 1),
makeRow(`${seedKeyword} software`, 2),
makeRow(`${seedKeyword} checklist`, 3),
makeRow(`${seedKeyword} template`, 4),
makeRow(`${seedKeyword} examples`, 5),
makeRow(`${seedKeyword} guide`, 6),
makeRow(`${seedKeyword} strategy`, 7),
makeRow(`${seedKeyword} platform`, 8),
makeRow(`${seedKeyword} generator`, 9),
];
return {
rows,
source: "related" as const,
usedFallback: false,
diagnostics: {
requestedMode: data.mode,
threshold: 3,
sourceAttempts: [
{
source: "related" as const,
rowCount: rows.length,
nonSeedCount: rows.length - 1,
},
],
},
};
}

View File

@ -0,0 +1,120 @@
import { expect, test, type Page } from "@playwright/test";
async function getProjectId(page: Page) {
await page.goto("/");
await page.waitForURL(/\/p\/([^/]+)\/keywords(?:\?.*)?$/, {
timeout: 30_000,
});
const match = page.url().match(/\/p\/([^/]+)\/keywords/);
if (!match) throw new Error(`Could not read project id from ${page.url()}`);
return match[1];
}
test.describe("Keyword Research navigation", () => {
test("Back to Recent searches clears the active keyword tab query", async ({
page,
}) => {
const projectId = await getProjectId(page);
await page.goto(
`/p/${projectId}/keywords?q=keyword%20research&loc=2840&kLimit=150&mode=auto`,
);
await expect(
page.getByRole("heading", { name: "Keyword Research", exact: true }),
).toBeVisible();
await expect(
page.getByRole("row", { name: /keyword research/i }).first(),
).toBeVisible({ timeout: 30_000 });
const recentSearchesButton = page.locator(
'[data-testid="keyword-research-recent-searches"]:visible',
);
await expect(recentSearchesButton).toBeVisible();
await recentSearchesButton.click();
await expect
.poll(() => new URL(page.url()).searchParams.get("q"))
.toBe(null);
await expect(
page.getByRole("link", { name: "keyword research US" }),
).toBeVisible();
await expect(recentSearchesButton).toBeHidden();
});
test("clicking keyword tabs keeps the clicked tab and URL in sync", async ({
page,
}) => {
const projectId = await getProjectId(page);
await page.goto(
`/p/${projectId}/keywords?q=keyword%20research&loc=2840&kLimit=150&mode=auto`,
);
await expect(
page.getByRole("row", { name: /keyword research/i }).first(),
).toBeVisible({ timeout: 30_000 });
await page.goto(
`/p/${projectId}/keywords?q=backlinks&loc=2840&kLimit=150&mode=auto`,
);
await expect(
page.getByRole("row", { name: /backlinks/i }).first(),
).toBeVisible({ timeout: 30_000 });
await page.getByRole("tab", { name: /keyword research/i }).click();
await expect
.poll(() => new URL(page.url()).searchParams.get("q"))
.toBe("keyword research");
await expect(
page.getByRole("row", { name: /keyword research/i }).first(),
).toBeVisible();
await page.getByRole("tab", { name: /^backlinks/i }).click();
await expect
.poll(() => new URL(page.url()).searchParams.get("q"))
.toBe("backlinks");
await expect(
page.getByRole("row", { name: /backlinks/i }).first(),
).toBeVisible();
});
test("closing the active middle keyword tab removes it and selects the next tab", async ({
page,
}) => {
const projectId = await getProjectId(page);
for (const keyword of ["ai seo", "backlinks", "open seo"]) {
await page.goto(
`/p/${projectId}/keywords?q=${encodeURIComponent(keyword)}&loc=2840&kLimit=150&mode=auto`,
);
await expect(
page.getByRole("row", { name: new RegExp(keyword, "i") }).first(),
).toBeVisible({ timeout: 30_000 });
}
await page.getByRole("tab", { name: /^backlinks/i }).click();
await expect
.poll(() => new URL(page.url()).searchParams.get("q"))
.toBe("backlinks");
const closeButton = page.getByRole("button", {
name: "Close backlinks tab",
});
const closedTabId = await closeButton.getAttribute("data-search-tab-id");
expect(closedTabId).toBeTruthy();
await closeButton.click();
await expect
.poll(() => new URL(page.url()).searchParams.get("q"))
.toBe("open seo");
await expect(page.getByRole("tab", { name: /^open seo/i })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(
page.locator(`[data-search-tab-id="${closedTabId}"]`),
).toHaveCount(0);
await expect(page.getByRole("tab")).toHaveCount(2);
});
});

View File

@ -28,6 +28,10 @@
"test": "vitest run",
"test:watch": "vitest",
"test:ci": "vitest run --reporter=dot",
"test:e2e": "playwright test",
"test:e2e:domain": "playwright test e2e/domain-overview-filters.spec.ts",
"test:e2e:domain:perf": "playwright test e2e/domain-overview-filters.perf.spec.ts",
"test:e2e:keywords": "playwright test e2e/keyword-research-navigation.spec.ts",
"billing:backlinks": "tsx scripts/backlinks-cost-profile.ts",
"billing:brand-lookup": "tsx scripts/brand-lookup-cost-profile.ts",
"cleanup:default-projects:d1": "tsx scripts/d1-default-project-cleanup.ts",
@ -94,6 +98,7 @@
"@cloudflare/vite-plugin": "^1.13.18",
"@cloudflare/workers-types": "^4.20251014.0",
"@libsql/client": "^0.15.15",
"@playwright/test": "^1.59.1",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/devtools-vite": "^0.6.0",
"@tanstack/react-devtools": "^0.10.1",

28
playwright.config.ts Normal file
View File

@ -0,0 +1,28 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
timeout: 45_000,
expect: {
timeout: 10_000,
},
fullyParallel: false,
workers: 1,
reporter: [["list"]],
use: {
baseURL: "http://localhost:3101",
actionTimeout: 5_000,
navigationTimeout: 30_000,
channel: process.env.PLAYWRIGHT_CHANNEL ?? "chrome",
screenshot: "only-on-failure",
trace: "retain-on-failure",
video: "retain-on-failure",
},
webServer: {
command:
"NODE_OPTIONS= AUTH_MODE=local_noauth VITE_E2E_DOMAIN_FIXTURES=1 VITE_E2E_KEYWORD_FIXTURES=1 PORT=3101 pnpm exec vite dev --host 127.0.0.1 --strictPort",
url: "http://localhost:3101",
reuseExistingServer: false,
timeout: 120_000,
},
});

38
pnpm-lock.yaml generated
View File

@ -123,6 +123,9 @@ importers:
'@libsql/client':
specifier: ^0.15.15
version: 0.15.15
'@playwright/test':
specifier: ^1.59.1
version: 1.59.1
'@tailwindcss/vite':
specifier: ^4.1.11
version: 4.2.1(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
@ -1604,6 +1607,11 @@ packages:
cpu: [x64]
os: [win32]
'@playwright/test@1.59.1':
resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==}
engines: {node: '>=18'}
hasBin: true
'@poppinss/colors@4.1.6':
resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==}
@ -3224,6 +3232,11 @@ packages:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -3919,6 +3932,16 @@ packages:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'}
playwright-core@1.59.1:
resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.59.1:
resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==}
engines: {node: '>=18'}
hasBin: true
portless@0.5.2:
resolution: {integrity: sha512-LnJvnFUduG4QSIDqc4og9WCLRA6L0+btA96nn6icY6cxyEAR44cnWaIxVzmw7y74KQ+R7zJM1HpenankphympA==}
engines: {node: '>=20'}
@ -5726,6 +5749,10 @@ snapshots:
'@oxlint/binding-win32-x64-msvc@1.50.0':
optional: true
'@playwright/test@1.59.1':
dependencies:
playwright: 1.59.1
'@poppinss/colors@4.1.6':
dependencies:
kleur: 4.1.5
@ -7246,6 +7273,9 @@ snapshots:
fresh@2.0.0: {}
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@ -8104,6 +8134,14 @@ snapshots:
pkce-challenge@5.0.1: {}
playwright-core@1.59.1: {}
playwright@1.59.1:
dependencies:
playwright-core: 1.59.1
optionalDependencies:
fsevents: 2.3.2
portless@0.5.2:
dependencies:
chalk: 5.6.2

View File

@ -127,3 +127,42 @@ export function TableBulkExportMenu({
</div>
);
}
export function TableExportMenu({
actions,
buttonClassName = "btn btn-sm gap-1",
menuClassName = "dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-56",
}: {
actions: Array<{
label: ReactNode;
icon?: ReactNode;
onClick: () => void;
disabled?: boolean;
}>;
buttonClassName?: string;
menuClassName?: string;
}) {
return (
<div className="dropdown dropdown-end">
<div tabIndex={0} role="button" className={buttonClassName}>
<Download className="size-4" />
Export
<ChevronDown className="size-3 opacity-60" />
</div>
<ul tabIndex={0} className={menuClassName}>
{actions.map((action, index) => (
<li key={index}>
<button
type="button"
onClick={action.onClick}
disabled={action.disabled}
>
{action.icon}
{action.label}
</button>
</li>
))}
</ul>
</div>
);
}

View File

@ -0,0 +1,165 @@
import type { AuditResultsData } from "@/client/features/audit/results/types";
export type PageRow = AuditResultsData["pages"][number];
type PerformanceResultRow = AuditResultsData["lighthouse"][number];
export type PerformanceRowData = PerformanceResultRow & {
pageUrl: string | null;
pagePath: string | null;
};
export type LighthouseFailureFields = {
errorMessage: string | null;
performanceScore: number | null;
accessibilityScore: number | null;
bestPracticesScore: number | null;
seoScore: number | null;
};
export type PagesFilters = {
query: string;
status: "all" | "ok" | "redirect" | "error" | "missing";
minWords: string;
maxWords: string;
minResponseMs: string;
maxResponseMs: string;
missingAlt: "all" | "yes" | "no";
};
export type PerformanceFilters = {
query: string;
device: "all" | "desktop" | "mobile";
status: "all" | "ok" | "failed";
minPerf: string;
maxPerf: string;
minSeo: string;
maxSeo: string;
maxLcpSeconds: string;
};
export const EMPTY_PAGES_FILTERS: PagesFilters = {
query: "",
status: "all",
minWords: "",
maxWords: "",
minResponseMs: "",
maxResponseMs: "",
missingAlt: "all",
};
export const EMPTY_PERFORMANCE_FILTERS: PerformanceFilters = {
query: "",
device: "all",
status: "all",
minPerf: "",
maxPerf: "",
minSeo: "",
maxSeo: "",
maxLcpSeconds: "",
};
function hasMissingLighthouseScores(row: LighthouseFailureFields) {
return (
row.performanceScore == null &&
row.accessibilityScore == null &&
row.bestPracticesScore == null &&
row.seoScore == null
);
}
export function isLighthouseFailure(row: LighthouseFailureFields) {
return !!row.errorMessage || hasMissingLighthouseScores(row);
}
export function filterPages(rows: PageRow[], filters: PagesFilters) {
const query = filters.query.trim().toLowerCase();
return rows.filter((row) => {
if (query) {
const haystack = [row.url, row.title, row.metaDescription]
.filter(Boolean)
.join(" ")
.toLowerCase();
if (!haystack.includes(query)) return false;
}
if (!matchesStatus(row.statusCode, filters.status)) return false;
if (!matchesRange(row.wordCount, filters.minWords, filters.maxWords)) {
return false;
}
if (
!matchesRange(
row.responseTimeMs,
filters.minResponseMs,
filters.maxResponseMs,
)
) {
return false;
}
if (filters.missingAlt === "yes" && row.imagesMissingAlt <= 0) {
return false;
}
if (filters.missingAlt === "no" && row.imagesMissingAlt > 0) {
return false;
}
return true;
});
}
export function filterPerformanceRows(
rows: PerformanceRowData[],
filters: PerformanceFilters,
) {
const query = filters.query.trim().toLowerCase();
return rows.filter((row) => {
if (query) {
const haystack = [row.pageUrl, row.pagePath].filter(Boolean).join(" ");
if (!haystack.toLowerCase().includes(query)) return false;
}
if (filters.device !== "all" && row.strategy !== filters.device) {
return false;
}
if (filters.status === "ok" && isLighthouseFailure(row)) return false;
if (filters.status === "failed" && !isLighthouseFailure(row)) return false;
if (!matchesRange(row.performanceScore, filters.minPerf, filters.maxPerf)) {
return false;
}
if (!matchesRange(row.seoScore, filters.minSeo, filters.maxSeo)) {
return false;
}
const maxLcpSeconds = parseFilterNumber(filters.maxLcpSeconds);
if (
maxLcpSeconds != null &&
(row.lcpMs == null || row.lcpMs / 1000 > maxLcpSeconds)
) {
return false;
}
return true;
});
}
function matchesStatus(
statusCode: number | null,
status: PagesFilters["status"],
) {
if (status === "all") return true;
if (status === "missing") return statusCode == null;
if (statusCode == null) return false;
if (status === "ok") return statusCode >= 200 && statusCode < 300;
if (status === "redirect") return statusCode >= 300 && statusCode < 400;
return statusCode >= 400;
}
function matchesRange(value: number | null, minRaw: string, maxRaw: string) {
const min = parseFilterNumber(minRaw);
const max = parseFilterNumber(maxRaw);
if (min == null && max == null) return true;
if (value == null) return false;
if (min != null && value < min) return false;
if (max != null && value > max) return false;
return true;
}
function parseFilterNumber(value: string) {
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}

View File

@ -0,0 +1,275 @@
import {
EMPTY_PAGES_FILTERS,
EMPTY_PERFORMANCE_FILTERS,
type PagesFilters,
type PerformanceFilters,
} from "@/client/features/audit/results/AuditResultsTableFilterLogic";
export function PagesFilterBar({
filters,
onChange,
resultCount,
totalCount,
}: {
filters: PagesFilters;
onChange: (filters: PagesFilters) => void;
resultCount: number;
totalCount: number;
}) {
return (
<div className="rounded-lg border border-base-300 bg-base-200/30 p-3">
<div className="flex flex-wrap items-end gap-2">
<TextFilter
label="Search"
value={filters.query}
placeholder="URL, title, meta"
onChange={(query) => onChange({ ...filters, query })}
/>
<SelectFilter
label="Status"
value={filters.status}
onChange={(status) => onChange({ ...filters, status })}
options={[
["all", "All"],
["ok", "2xx"],
["redirect", "3xx"],
["error", "4xx/5xx"],
["missing", "Missing"],
]}
/>
<RangeFilter
label="Words"
min={filters.minWords}
max={filters.maxWords}
onMinChange={(minWords) => onChange({ ...filters, minWords })}
onMaxChange={(maxWords) => onChange({ ...filters, maxWords })}
/>
<RangeFilter
label="Speed ms"
min={filters.minResponseMs}
max={filters.maxResponseMs}
onMinChange={(minResponseMs) =>
onChange({ ...filters, minResponseMs })
}
onMaxChange={(maxResponseMs) =>
onChange({ ...filters, maxResponseMs })
}
/>
<SelectFilter
label="Alt text"
value={filters.missingAlt}
onChange={(missingAlt) => onChange({ ...filters, missingAlt })}
options={[
["all", "All"],
["yes", "Missing alt"],
["no", "No missing alt"],
]}
/>
<FilterSummary
resultCount={resultCount}
totalCount={totalCount}
onReset={() => onChange(EMPTY_PAGES_FILTERS)}
/>
</div>
</div>
);
}
export function PerformanceFilterBar({
filters,
onChange,
resultCount,
totalCount,
}: {
filters: PerformanceFilters;
onChange: (filters: PerformanceFilters) => void;
resultCount: number;
totalCount: number;
}) {
return (
<div className="rounded-lg border border-base-300 bg-base-200/30 p-3">
<div className="flex flex-wrap items-end gap-2">
<TextFilter
label="Search"
value={filters.query}
placeholder="URL"
onChange={(query) => onChange({ ...filters, query })}
/>
<SelectFilter
label="Device"
value={filters.device}
onChange={(device) => onChange({ ...filters, device })}
options={[
["all", "All"],
["desktop", "Desktop"],
["mobile", "Mobile"],
]}
/>
<SelectFilter
label="Status"
value={filters.status}
onChange={(status) => onChange({ ...filters, status })}
options={[
["all", "All"],
["ok", "OK"],
["failed", "Failed"],
]}
/>
<RangeFilter
label="Perf"
min={filters.minPerf}
max={filters.maxPerf}
onMinChange={(minPerf) => onChange({ ...filters, minPerf })}
onMaxChange={(maxPerf) => onChange({ ...filters, maxPerf })}
/>
<RangeFilter
label="SEO"
min={filters.minSeo}
max={filters.maxSeo}
onMinChange={(minSeo) => onChange({ ...filters, minSeo })}
onMaxChange={(maxSeo) => onChange({ ...filters, maxSeo })}
/>
<TextFilter
label="Max LCP s"
value={filters.maxLcpSeconds}
placeholder="2.5"
type="number"
onChange={(maxLcpSeconds) => onChange({ ...filters, maxLcpSeconds })}
/>
<FilterSummary
resultCount={resultCount}
totalCount={totalCount}
onReset={() => onChange(EMPTY_PERFORMANCE_FILTERS)}
/>
</div>
</div>
);
}
export function EmptyTableMessage({ label }: { label: string }) {
return <div className="py-6 text-center text-base-content/60">{label}</div>;
}
function TextFilter({
label,
value,
placeholder,
type = "text",
onChange,
}: {
label: string;
value: string;
placeholder: string;
type?: "text" | "number";
onChange: (value: string) => void;
}) {
return (
<label className="form-control gap-1">
<span className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
{label}
</span>
<input
className="input input-bordered input-sm w-40 bg-base-100"
type={type}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
/>
</label>
);
}
function RangeFilter({
label,
min,
max,
onMinChange,
onMaxChange,
}: {
label: string;
min: string;
max: string;
onMinChange: (value: string) => void;
onMaxChange: (value: string) => void;
}) {
return (
<div className="form-control gap-1">
<span className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
{label}
</span>
<div className="flex gap-1">
<input
className="input input-bordered input-sm w-20 bg-base-100"
type="number"
value={min}
placeholder="Min"
onChange={(event) => onMinChange(event.target.value)}
/>
<input
className="input input-bordered input-sm w-20 bg-base-100"
type="number"
value={max}
placeholder="Max"
onChange={(event) => onMaxChange(event.target.value)}
/>
</div>
</div>
);
}
function SelectFilter<T extends string>({
label,
value,
options,
onChange,
}: {
label: string;
value: T;
options: Array<[T, string]>;
onChange: (value: T) => void;
}) {
return (
<label className="form-control gap-1">
<span className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
{label}
</span>
<select
className="select select-bordered select-sm w-32 bg-base-100"
value={value}
onChange={(event) => {
const selected = options.find(
([optionValue]) => optionValue === event.target.value,
)?.[0];
if (selected != null) onChange(selected);
}}
>
{options.map(([optionValue, optionLabel]) => (
<option key={optionValue} value={optionValue}>
{optionLabel}
</option>
))}
</select>
</label>
);
}
function FilterSummary({
resultCount,
totalCount,
onReset,
}: {
resultCount: number;
totalCount: number;
onReset: () => void;
}) {
return (
<div className="ml-auto flex items-center gap-2 pb-0.5 text-xs text-base-content/60">
<span className="tabular-nums">
{resultCount.toLocaleString()} of {totalCount.toLocaleString()}
</span>
<button className="btn btn-ghost btn-xs" onClick={onReset}>
Clear
</button>
</div>
);
}

View File

@ -1,91 +1,154 @@
import { ChevronDown, Download, ExternalLink } from "lucide-react";
import { useMemo, useState } from "react";
import {
createColumnHelper,
type ColumnDef,
type SortingState,
} from "@tanstack/react-table";
import { ExternalLink } from "lucide-react";
import {
AppDataTable,
useAppTable,
} from "@/client/components/table/AppDataTable";
import { TableExportMenu } from "@/client/components/table/TableBulkActionBar";
import { SortableHeader } from "@/client/components/table/SortableHeader";
import {
extractPathname,
HttpStatusBadge,
LighthouseScoreBadge,
} from "@/client/features/audit/shared";
import type { AuditResultsData } from "@/client/features/audit/results/types";
type LighthouseFailureFields = {
errorMessage: string | null;
performanceScore: number | null;
accessibilityScore: number | null;
bestPracticesScore: number | null;
seoScore: number | null;
};
function hasMissingLighthouseScores(row: LighthouseFailureFields) {
return (
row.performanceScore == null &&
row.accessibilityScore == null &&
row.bestPracticesScore == null &&
row.seoScore == null
);
}
import {
EmptyTableMessage,
PagesFilterBar,
PerformanceFilterBar,
} from "@/client/features/audit/results/AuditResultsTableFilters";
import {
EMPTY_PAGES_FILTERS,
EMPTY_PERFORMANCE_FILTERS,
filterPages,
filterPerformanceRows,
isLighthouseFailure as getIsLighthouseFailure,
type LighthouseFailureFields,
type PageRow,
type PagesFilters,
type PerformanceFilters,
type PerformanceRowData,
} from "@/client/features/audit/results/AuditResultsTableFilterLogic";
export function isLighthouseFailure(row: LighthouseFailureFields) {
return !!row.errorMessage || hasMissingLighthouseScores(row);
return getIsLighthouseFailure(row);
}
function getLighthouseFailureMessage(row: LighthouseFailureFields) {
return row.errorMessage ?? "Lighthouse returned no category scores";
}
const pageColumnHelper = createColumnHelper<PageRow>();
const performanceColumnHelper = createColumnHelper<PerformanceRowData>();
const pagesColumns: ColumnDef<PageRow>[] = [
pageColumnHelper.accessor("url", {
header: ({ column }) => <SortableHeader column={column} label="URL" />,
cell: ({ getValue }) => {
const url = getValue();
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="link link-primary inline-flex items-center gap-1 text-xs"
>
<span className="truncate">{extractPathname(url)}</span>
<ExternalLink className="size-3 shrink-0" />
</a>
);
},
meta: { cellClassName: "max-w-[240px] truncate" },
}),
pageColumnHelper.accessor("statusCode", {
header: ({ column }) => <SortableHeader column={column} label="Status" />,
cell: ({ getValue }) => <HttpStatusBadge code={getValue()} />,
sortingFn: nullableNumberSort,
}),
pageColumnHelper.accessor("title", {
header: ({ column }) => <SortableHeader column={column} label="Title" />,
cell: ({ getValue }) => {
const title = getValue();
return title ? (
<span title={title}>{title}</span>
) : (
<span className="text-error text-xs">missing</span>
);
},
sortingFn: nullableStringSort,
meta: { cellClassName: "max-w-[220px] truncate" },
}),
pageColumnHelper.accessor("h1Count", {
header: ({ column }) => <SortableHeader column={column} label="H1" />,
}),
pageColumnHelper.accessor("wordCount", {
header: ({ column }) => <SortableHeader column={column} label="Words" />,
}),
pageColumnHelper.display({
id: "images",
header: ({ column }) => <SortableHeader column={column} label="Images" />,
cell: ({ row }) =>
row.original.imagesMissingAlt > 0 ? (
<span className="text-warning">
{row.original.imagesMissingAlt}/{row.original.imagesTotal}
</span>
) : (
row.original.imagesTotal
),
enableSorting: true,
sortingFn: (left, right) =>
left.original.imagesMissingAlt - right.original.imagesMissingAlt ||
left.original.imagesTotal - right.original.imagesTotal,
}),
pageColumnHelper.accessor("responseTimeMs", {
header: ({ column }) => <SortableHeader column={column} label="Speed" />,
cell: ({ getValue }) => {
const value = getValue();
return value ? (
<span className="text-xs">{value}ms</span>
) : (
<span className="text-xs text-base-content/40">-</span>
);
},
sortingFn: nullableNumberSort,
}),
];
export function PagesTable({ pages }: { pages: AuditResultsData["pages"] }) {
const [filters, setFilters] = useState<PagesFilters>(EMPTY_PAGES_FILTERS);
const [sorting, setSorting] = useState<SortingState>([
{ id: "statusCode", desc: true },
]);
const filteredPages = useMemo(
() => filterPages(pages, filters),
[filters, pages],
);
const table = useAppTable({
data: filteredPages,
columns: pagesColumns,
state: { sorting },
onSortingChange: setSorting,
withSorting: true,
});
return (
<div className="overflow-x-auto">
<table className="table table-sm">
<thead>
<tr>
<th>URL</th>
<th>Status</th>
<th>Title</th>
<th>H1</th>
<th>Words</th>
<th>Images</th>
<th>Speed</th>
</tr>
</thead>
<tbody>
{pages.map((page: AuditResultsData["pages"][number]) => (
<tr key={page.id}>
<td className="max-w-[200px] truncate">
<a
href={page.url}
target="_blank"
rel="noopener noreferrer"
className="link link-primary text-xs inline-flex items-center gap-1"
>
{extractPathname(page.url)}
<ExternalLink className="size-3" />
</a>
</td>
<td>
<HttpStatusBadge code={page.statusCode} />
</td>
<td className="max-w-[180px] truncate" title={page.title ?? ""}>
{page.title || (
<span className="text-error text-xs">missing</span>
)}
</td>
<td>{page.h1Count}</td>
<td>{page.wordCount}</td>
<td>
{page.imagesMissingAlt > 0 ? (
<span className="text-warning">
{page.imagesMissingAlt}/{page.imagesTotal}
</span>
) : (
page.imagesTotal
)}
</td>
<td className="text-xs">
{page.responseTimeMs ? `${page.responseTimeMs}ms` : "-"}
</td>
</tr>
))}
</tbody>
</table>
<div className="space-y-3">
<PagesFilterBar
filters={filters}
onChange={setFilters}
resultCount={filteredPages.length}
totalCount={pages.length}
/>
<AppDataTable
table={table}
className="table table-sm"
empty={<EmptyTableMessage label="No pages match these filters." />}
/>
</div>
);
}
@ -101,65 +164,89 @@ export function PerformanceTable({
lighthouse: AuditResultsData["lighthouse"];
pages: AuditResultsData["pages"];
}) {
const [filters, setFilters] = useState<PerformanceFilters>(
EMPTY_PERFORMANCE_FILTERS,
);
const [sorting, setSorting] = useState<SortingState>([
{ id: "performanceScore", desc: false },
]);
const rows = useMemo(
() =>
lighthouse.map((result) => {
const page = pages.find((candidate) => candidate.id === result.pageId);
const pageUrl = page?.url ?? null;
return {
...result,
pageUrl,
pagePath: pageUrl ? extractPathname(pageUrl) : null,
};
}),
[lighthouse, pages],
);
const filteredRows = useMemo(
() => filterPerformanceRows(rows, filters),
[filters, rows],
);
const columns = useMemo(
() => buildPerformanceColumns({ auditId, projectId }),
[auditId, projectId],
);
const table = useAppTable({
data: filteredRows,
columns,
state: { sorting },
onSortingChange: setSorting,
withSorting: true,
});
return (
<div className="overflow-x-auto">
<table className="table table-sm">
<thead>
<tr>
<th>URL</th>
<th>Device</th>
<th>Status</th>
<th>Perf</th>
<th>A11y</th>
<th>SEO</th>
<th>LCP</th>
<th>CLS</th>
<th>INP</th>
<th>TTFB</th>
<th>Issues</th>
</tr>
</thead>
<tbody>
{lighthouse.map((result: AuditResultsData["lighthouse"][number]) => (
<PerformanceRow
key={result.id}
auditId={auditId}
projectId={projectId}
result={result}
page={pages.find(
(candidate: AuditResultsData["pages"][number]) =>
candidate.id === result.pageId,
)}
/>
))}
</tbody>
</table>
<div className="space-y-3">
<PerformanceFilterBar
filters={filters}
onChange={setFilters}
resultCount={filteredRows.length}
totalCount={rows.length}
/>
<AppDataTable
table={table}
className="table table-sm"
empty={
<EmptyTableMessage label="No performance results match these filters." />
}
/>
</div>
);
}
function PerformanceRow({
function buildPerformanceColumns({
auditId,
projectId,
result,
page,
}: {
auditId: string;
projectId: string;
result: AuditResultsData["lighthouse"][number];
page: AuditResultsData["pages"][number] | undefined;
}) {
const isFailed = isLighthouseFailure(result);
const failureMessage = getLighthouseFailureMessage(result);
return (
<tr>
<td className="max-w-[160px] truncate text-xs">
{page ? extractPathname(page.url) : "-"}
</td>
<td className="capitalize text-xs">{result.strategy}</td>
<td>
{isFailed ? (
}): ColumnDef<PerformanceRowData>[] {
return [
performanceColumnHelper.accessor("pagePath", {
header: ({ column }) => <SortableHeader column={column} label="URL" />,
cell: ({ getValue }) => (
<span className="text-xs">{getValue() ?? "-"}</span>
),
sortingFn: nullableStringSort,
meta: { cellClassName: "max-w-[180px] truncate" },
}),
performanceColumnHelper.accessor("strategy", {
header: ({ column }) => <SortableHeader column={column} label="Device" />,
cell: ({ getValue }) => (
<span className="capitalize text-xs">{getValue()}</span>
),
}),
performanceColumnHelper.display({
id: "status",
header: ({ column }) => <SortableHeader column={column} label="Status" />,
cell: ({ row }) => {
const isFailed = isLighthouseFailure(row.original);
const failureMessage = getLighthouseFailureMessage(row.original);
return isFailed ? (
<span
className="badge badge-error badge-outline text-xs"
title={failureMessage}
@ -168,43 +255,92 @@ function PerformanceRow({
</span>
) : (
<span className="badge badge-success badge-outline text-xs">ok</span>
)}
</td>
<td>
<LighthouseScoreBadge score={result.performanceScore} />
</td>
<td>
<LighthouseScoreBadge score={result.accessibilityScore} />
</td>
<td>
<LighthouseScoreBadge score={result.seoScore} />
</td>
<td className="text-xs">
{result.lcpMs ? `${(result.lcpMs / 1000).toFixed(1)}s` : "-"}
</td>
<td className="text-xs">
{result.cls != null ? result.cls.toFixed(3) : "-"}
</td>
<td className="text-xs">
{result.inpMs ? `${Math.round(result.inpMs)}ms` : "-"}
</td>
<td className="text-xs">
{result.ttfbMs ? `${Math.round(result.ttfbMs)}ms` : "-"}
</td>
<td>
{result.r2Key && !isFailed ? (
);
},
enableSorting: true,
sortingFn: (left, right) =>
Number(isLighthouseFailure(left.original)) -
Number(isLighthouseFailure(right.original)),
}),
performanceColumnHelper.accessor("performanceScore", {
header: ({ column }) => <SortableHeader column={column} label="Perf" />,
cell: ({ getValue }) => <LighthouseScoreBadge score={getValue()} />,
sortingFn: nullableNumberSort,
}),
performanceColumnHelper.accessor("accessibilityScore", {
header: ({ column }) => <SortableHeader column={column} label="A11y" />,
cell: ({ getValue }) => <LighthouseScoreBadge score={getValue()} />,
sortingFn: nullableNumberSort,
}),
performanceColumnHelper.accessor("seoScore", {
header: ({ column }) => <SortableHeader column={column} label="SEO" />,
cell: ({ getValue }) => <LighthouseScoreBadge score={getValue()} />,
sortingFn: nullableNumberSort,
}),
performanceColumnHelper.accessor("lcpMs", {
header: ({ column }) => <SortableHeader column={column} label="LCP" />,
cell: ({ getValue }) => {
const value = getValue();
return value ? (
<span className="text-xs">{(value / 1000).toFixed(1)}s</span>
) : (
<span className="text-xs text-base-content/40">-</span>
);
},
sortingFn: nullableNumberSort,
}),
performanceColumnHelper.accessor("cls", {
header: ({ column }) => <SortableHeader column={column} label="CLS" />,
cell: ({ getValue }) => {
const value = getValue();
return value != null ? (
<span className="text-xs">{value.toFixed(3)}</span>
) : (
<span className="text-xs text-base-content/40">-</span>
);
},
sortingFn: nullableNumberSort,
}),
performanceColumnHelper.accessor("inpMs", {
header: ({ column }) => <SortableHeader column={column} label="INP" />,
cell: ({ getValue }) => {
const value = getValue();
return value ? (
<span className="text-xs">{Math.round(value)}ms</span>
) : (
<span className="text-xs text-base-content/40">-</span>
);
},
sortingFn: nullableNumberSort,
}),
performanceColumnHelper.accessor("ttfbMs", {
header: ({ column }) => <SortableHeader column={column} label="TTFB" />,
cell: ({ getValue }) => {
const value = getValue();
return value ? (
<span className="text-xs">{Math.round(value)}ms</span>
) : (
<span className="text-xs text-base-content/40">-</span>
);
},
sortingFn: nullableNumberSort,
}),
performanceColumnHelper.display({
id: "issues",
header: () => "Issues",
cell: ({ row }) =>
row.original.r2Key && !isLighthouseFailure(row.original) ? (
<a
className="btn btn-primary btn-xs"
href={`/p/${projectId}/audit/issues/${result.id}?auditId=${auditId}&category=performance`}
href={`/p/${projectId}/audit/issues/${row.original.id}?auditId=${auditId}&category=performance`}
>
View issues
</a>
) : (
<span className="text-xs text-base-content/40">-</span>
)}
</td>
</tr>
);
),
}),
];
}
export function ExportDropdown({
@ -213,26 +349,40 @@ export function ExportDropdown({
onExport: (format: "csv" | "json" | "sheets") => void;
}) {
return (
<div className="dropdown dropdown-end">
<div tabIndex={0} role="button" className="btn btn-sm btn-ghost gap-1">
<Download className="size-4" />
Export
<ChevronDown className="size-3 opacity-60" />
</div>
<ul
tabIndex={0}
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-52"
>
<li>
<button onClick={() => onExport("sheets")}>Export to Sheets</button>
</li>
<li>
<button onClick={() => onExport("csv")}>CSV</button>
</li>
<li>
<button onClick={() => onExport("json")}>JSON</button>
</li>
</ul>
</div>
<TableExportMenu
buttonClassName="btn btn-sm btn-ghost gap-1"
menuClassName="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-52"
actions={[
{ label: "Export to Sheets", onClick: () => onExport("sheets") },
{ label: "CSV", onClick: () => onExport("csv") },
{ label: "JSON", onClick: () => onExport("json") },
]}
/>
);
}
function nullableNumberSort(
left: { getValue: (columnId: string) => number | null },
right: { getValue: (columnId: string) => number | null },
columnId: string,
) {
const a = left.getValue(columnId);
const b = right.getValue(columnId);
if (a == null && b == null) return 0;
if (a == null) return 1;
if (b == null) return -1;
return a - b;
}
function nullableStringSort(
left: { getValue: (columnId: string) => string | null },
right: { getValue: (columnId: string) => string | null },
columnId: string,
) {
const a = left.getValue(columnId);
const b = right.getValue(columnId);
if (!a && !b) return 0;
if (!a) return 1;
if (!b) return -1;
return a.localeCompare(b);
}

View File

@ -3,6 +3,7 @@ import {
type SortingFn,
type SortingState,
} from "@tanstack/react-table";
import { Link } from "@tanstack/react-router";
import { useState } from "react";
import {
AppDataTable,
@ -55,7 +56,20 @@ const columns = [
helpText="The referring site linking to your target."
/>
),
cell: ({ getValue }) => getValue() ?? "-",
cell: ({ getValue }) => {
const domain = getValue();
if (!domain) return "-";
return (
<Link
from="/p/$projectId/backlinks"
to="/p/$projectId/backlinks"
search={{ target: domain, scope: "domain", tab: undefined }}
className="link link-primary link-hover break-all"
>
{domain}
</Link>
);
},
sortingFn: stringNullsLast,
}),
columnHelper.accessor("backlinks", {

View File

@ -1,44 +1,58 @@
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
/* eslint-disable max-lines, max-lines-per-function -- Domain Overview keeps page-only orchestration colocated to avoid fake indirection. */
import { useCallback, useEffect, useMemo, useRef, type FormEvent } from "react";
import { useForm, useStore } from "@tanstack/react-form";
import { ArrowLeft } from "lucide-react";
import { toast } from "sonner";
import {
DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE,
type DomainSearchParams,
} from "@/types/schemas/domain";
import {
DEFAULT_LOCATION_CODE,
LOCATIONS,
getLanguageCode,
isSupportedLocationCode,
} from "@/client/features/keywords/locations";
import { useDomainSearchHistory } from "@/client/hooks/useDomainSearchHistory";
import type { DomainSearchHistoryItem } from "@/client/hooks/useDomainSearchHistory";
import {
getDomainSearchChangeValidationErrors,
getDomainSearchValidationErrors,
} from "@/client/features/domain/domainSearchValidation";
import { useDomainOverviewQuery } from "@/client/features/domain/hooks/useDomainOverviewQuery";
import { DomainOverviewLoadingState } from "@/client/features/domain/components/DomainOverviewLoadingState";
import { DomainHistorySection } from "@/client/features/domain/components/DomainHistorySection";
import { DomainResultsCard } from "@/client/features/domain/components/DomainResultsCard";
import { DomainSearchCard } from "@/client/features/domain/components/DomainSearchCard";
import { KeywordsTab } from "@/client/features/domain/components/KeywordsTab";
import { PagesTab } from "@/client/features/domain/components/PagesTab";
import { StatCard } from "@/client/features/domain/components/StatCard";
import { SearchTabStrip } from "@/client/features/search-tabs/SearchTabStrip";
import type { SearchTabInput } from "@/client/features/search-tabs/types";
import { useSearchTabNavigation } from "@/client/features/search-tabs/useSearchTabNavigation";
import { useDomainOverviewController } from "@/client/features/domain/useDomainOverviewController";
import {
normalizeDomainTarget,
formatMetric,
getDefaultSortOrder,
normalizeDomainTarget,
toSortOrderSearchParam,
toSortSearchParam,
} from "@/client/features/domain/utils";
import { createFormValidationErrors } from "@/client/lib/forms";
import {
createFormValidationErrors,
shouldValidateFieldOnChange,
} from "@/client/lib/forms";
import { buildDomainFiltersClearSearchUpdate } from "@/client/features/domain/domainFilterUtils";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog";
import type { DomainOverviewRouteState } from "@/client/features/domain/domainRouteState";
import type {
DomainActiveTab,
DomainFilterValues,
DomainSortMode,
SortOrder,
} from "@/client/features/domain/types";
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
type Props = {
projectId: string;
searchState: {
domain: string;
subdomains: boolean;
sort: DomainSortMode;
order?: SortOrder;
tab: DomainActiveTab;
search: string;
locationCode: number;
page: number;
pageSize: number;
appliedFilters: DomainFilterValues;
};
routeState: DomainOverviewRouteState;
navigate: (args: {
search: (prev: Record<string, unknown>) => Record<string, unknown>;
replace: boolean;
@ -46,38 +60,374 @@ type Props = {
onShowRecentSearches: () => void;
};
type DomainNavigate = Props["navigate"];
type DomainSearchUpdate = Partial<DomainSearchParams>;
const KEYWORDS_ONLY_SORTS: ReadonlySet<DomainSortMode> = new Set([
"rank",
"score",
"cpc",
]);
function getSortSearchUpdate(
nextSort: DomainSortMode,
nextOrder: SortOrder,
): DomainSearchUpdate {
return {
sort: toSortSearchParam(nextSort),
order: toSortOrderSearchParam(nextSort, nextOrder),
page: undefined,
};
}
function getLocationSearchUpdate(
nextLocationCode: number,
): DomainSearchUpdate | null {
if (!isSupportedLocationCode(nextLocationCode)) return null;
return {
loc:
nextLocationCode === DEFAULT_LOCATION_CODE ? undefined : nextLocationCode,
page: undefined,
};
}
function getPageSearchUpdate(nextPage: number): DomainSearchUpdate {
const safe = Math.max(1, Math.floor(nextPage));
return { page: safe === 1 ? undefined : safe };
}
function getPageSizeSearchUpdate(nextSize: number): DomainSearchUpdate {
return {
size: nextSize === DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE ? undefined : nextSize,
page: undefined,
};
}
function getTabSearchUpdate(
nextTab: DomainActiveTab,
currentSort: DomainSortMode,
): DomainSearchUpdate {
if (nextTab === "keywords") {
return { tab: undefined, page: undefined };
}
const fallbackSortNeeded = KEYWORDS_ONLY_SORTS.has(currentSort);
const update: DomainSearchUpdate = {
tab: "pages",
page: undefined,
};
if (fallbackSortNeeded) {
update.sort = "traffic";
update.order = getDefaultSortOrder("traffic");
}
return update;
}
function getHistorySearchUpdate(
item: DomainSearchHistoryItem,
): DomainSearchUpdate {
const historyLocation =
item.locationCode != null && isSupportedLocationCode(item.locationCode)
? item.locationCode
: DEFAULT_LOCATION_CODE;
return {
...buildDomainFiltersClearSearchUpdate(),
domain: item.domain,
subdomains: item.subdomains ? undefined : false,
sort: toSortSearchParam(item.sort),
order: undefined,
tab: item.tab === "keywords" ? undefined : item.tab,
loc:
historyLocation === DEFAULT_LOCATION_CODE ? undefined : historyLocation,
size: undefined,
};
}
function getSearchSubmitUpdate({
domain,
subdomains,
sort,
locationCode,
currentOrder,
activeTab,
}: {
domain: string;
subdomains: boolean;
sort: DomainSortMode;
locationCode: number;
currentOrder: SortOrder;
activeTab: DomainActiveTab;
}): DomainSearchUpdate {
return {
...buildDomainFiltersClearSearchUpdate(),
domain,
subdomains: subdomains ? undefined : false,
sort: toSortSearchParam(sort),
order: toSortOrderSearchParam(sort, currentOrder),
tab: activeTab === "keywords" ? undefined : activeTab,
loc: locationCode === DEFAULT_LOCATION_CODE ? undefined : locationCode,
size: undefined,
};
}
function useDomainOverviewState({
navigate,
routeState,
projectId,
}: {
navigate: DomainNavigate;
routeState: DomainOverviewRouteState;
projectId: string;
}) {
const lastTrackedKey = useRef<string>("");
const {
history,
isLoaded: historyLoaded,
addSearch,
removeHistoryItem,
} = useDomainSearchHistory(projectId);
const setSearchParams = useCallback(
(updates: DomainSearchUpdate) => {
navigate({
search: (prev) => ({ ...prev, ...updates }),
replace: true,
});
},
[navigate],
);
const applySort = useCallback(
(nextSort: DomainSortMode, nextOrder: SortOrder) => {
setSearchParams(getSortSearchUpdate(nextSort, nextOrder));
},
[setSearchParams],
);
const applyLocationChange = useCallback(
(nextLocationCode: number) => {
const update = getLocationSearchUpdate(nextLocationCode);
if (update) setSearchParams(update);
},
[setSearchParams],
);
const handleSortColumnClick = useCallback(
(nextSort: DomainSortMode) => {
const nextOrder =
nextSort === routeState.sort
? routeState.order === "asc"
? "desc"
: "asc"
: getDefaultSortOrder(nextSort);
applySort(nextSort, nextOrder);
},
[applySort, routeState.order, routeState.sort],
);
const goToPage = useCallback(
(nextPage: number) => {
setSearchParams(getPageSearchUpdate(nextPage));
},
[setSearchParams],
);
const setPageSize = useCallback(
(nextSize: number) => {
setSearchParams(getPageSizeSearchUpdate(nextSize));
},
[setSearchParams],
);
const handleTabChange = useCallback(
(nextTab: DomainActiveTab) => {
setSearchParams(getTabSearchUpdate(nextTab, routeState.sort));
},
[routeState.sort, setSearchParams],
);
const handleHistorySelect = useCallback(
(item: DomainSearchHistoryItem) => {
setSearchParams(getHistorySearchUpdate(item));
},
[setSearchParams],
);
const languageCode = getLanguageCode(routeState.locationCode);
const overviewQuery = useDomainOverviewQuery({
projectId,
domain: routeState.domain,
includeSubdomains: routeState.subdomains,
locationCode: routeState.locationCode,
languageCode,
});
const overview = overviewQuery.data ?? null;
const isLoading = overviewQuery.isLoading;
const controlsForm = useForm({
defaultValues: {
domain: routeState.domain,
subdomains: routeState.subdomains,
sort: routeState.sort,
locationCode: routeState.locationCode,
},
validators: {
onChange: ({ formApi, value }) =>
getDomainSearchChangeValidationErrors(
value,
shouldValidateFieldOnChange(formApi, "domain"),
formApi.state.submissionAttempts > 0,
),
onSubmit: ({ value }) => getDomainSearchValidationErrors(value),
},
onSubmit: ({ formApi, value }) => {
const target = normalizeDomainTarget(value.domain);
if (!target) return;
formApi.setFieldValue("domain", target);
setSearchParams(
getSearchSubmitUpdate({
domain: target,
subdomains: value.subdomains,
sort: value.sort,
locationCode: value.locationCode,
currentOrder: routeState.order,
activeTab: routeState.tab,
}),
);
},
});
useEffect(() => {
controlsForm.reset({
domain: routeState.domain,
subdomains: routeState.subdomains,
sort: routeState.sort,
locationCode: routeState.locationCode,
});
}, [
controlsForm,
routeState.domain,
routeState.locationCode,
routeState.sort,
routeState.subdomains,
]);
useEffect(() => {
controlsForm.setErrorMap({
onSubmit: overviewQuery.error
? createFormValidationErrors({
form: getStandardErrorMessage(
overviewQuery.error,
"Lookup failed.",
),
})
: undefined,
});
}, [controlsForm, overviewQuery.error]);
useEffect(() => {
if (!overviewQuery.isSuccess || !overview) return;
const key = `${routeState.domain}|${routeState.subdomains}|${routeState.locationCode}`;
if (lastTrackedKey.current === key) return;
lastTrackedKey.current = key;
captureClientEvent("domain_overview:search_complete", {
sort_mode: routeState.sort,
include_subdomains: routeState.subdomains,
result_count: overview.organicKeywords ?? 0,
location_code: routeState.locationCode,
});
addSearch({
domain: routeState.domain,
subdomains: routeState.subdomains,
sort: routeState.sort,
tab: routeState.tab,
locationCode: routeState.locationCode,
});
if (!overview.hasData) {
toast.info("Not enough data for this domain");
}
}, [
addSearch,
overview,
overviewQuery.isSuccess,
routeState.domain,
routeState.locationCode,
routeState.sort,
routeState.subdomains,
routeState.tab,
]);
useEffect(() => {
if (routeState.domain.trim() !== "") return;
lastTrackedKey.current = "";
}, [routeState.domain]);
const controlsLocationCode = useStore(
controlsForm.store,
(s) => s.values.locationCode,
);
const canSaveKeywords = useMemo(
() =>
controlsLocationCode === routeState.locationCode &&
overview !== null &&
overview.hasData,
[controlsLocationCode, overview, routeState.locationCode],
);
const handleSearchSubmit = useCallback(
(event: FormEvent) => {
event.preventDefault();
void controlsForm.handleSubmit();
},
[controlsForm],
);
return {
controlsForm,
isLoading,
overview,
canSaveKeywords,
history,
historyLoaded,
removeHistoryItem,
languageCode,
setSearchParams,
applySort,
applyLocationChange,
handleTabChange,
handleSortColumnClick,
handleHistorySelect,
handleSearchSubmit,
goToPage,
setPageSize,
};
}
export type DomainOverviewControlsForm = ReturnType<
typeof useDomainOverviewState
>["controlsForm"];
export function DomainOverviewPage({
projectId,
searchState,
routeState,
navigate,
onShowRecentSearches,
}: Props) {
const queryClient = useQueryClient();
const state = useDomainOverviewController({
projectId,
queryClient,
navigate,
searchState,
});
const state = useDomainOverviewState({ navigate, routeState, projectId });
const urlTabInput = useMemo<SearchTabInput | null>(() => {
if (searchState.domain.trim() === "") return null;
if (routeState.domain.trim() === "") return null;
return {
type: "domain",
domain: searchState.domain,
subdomains: searchState.subdomains,
sort: searchState.sort,
order: searchState.order ?? getDefaultSortOrder(searchState.sort),
locationCode: searchState.locationCode,
domain: routeState.domain,
subdomains: routeState.subdomains,
locationCode: routeState.locationCode,
};
}, [
searchState.domain,
searchState.locationCode,
searchState.order,
searchState.sort,
searchState.subdomains,
]);
}, [routeState.domain, routeState.locationCode, routeState.subdomains]);
const navigateToTab = useCallback(
const navigateToSearchTab = useCallback(
(input: SearchTabInput | null) => {
if (input?.type !== "domain") {
navigate({
@ -86,18 +436,21 @@ export function DomainOverviewPage({
});
return;
}
navigate({
search: (prev) => ({
...prev,
...buildDomainFiltersClearSearchUpdate(),
domain: input.domain,
subdomains: input.subdomains ? undefined : false,
sort: input.sort === "rank" ? undefined : input.sort,
order: toSortOrderSearchParam(input.sort, input.order),
sort: undefined,
order: undefined,
tab: undefined,
page: undefined,
loc:
input.locationCode === DEFAULT_LOCATION_CODE
? undefined
: input.locationCode,
page: undefined,
size: undefined,
}),
replace: true,
@ -105,17 +458,23 @@ export function DomainOverviewPage({
},
[navigate],
);
const searchTabs = useSearchTabNavigation({
storageKey: `domain:${projectId}`,
urlInput: urlTabInput,
getLabel: useCallback(
(input) => (input.type === "domain" ? input.domain : ""),
[],
),
navigateToInput: navigateToTab,
getLabel: useCallback((input) => {
if (input.type !== "domain") return "";
const locationSuffix =
input.locationCode === DEFAULT_LOCATION_CODE
? ""
: ` ${LOCATIONS[input.locationCode] ?? input.locationCode}`;
return `${input.domain}${locationSuffix}`;
}, []),
navigateToInput: navigateToSearchTab,
});
const handleSearchSubmit = useCallback(
(event: React.FormEvent) => {
(event: FormEvent) => {
const values = state.controlsForm.state.values;
const target = normalizeDomainTarget(values.domain);
if (!target) {
@ -127,8 +486,6 @@ export function DomainOverviewPage({
type: "domain",
domain: target,
subdomains: values.subdomains,
sort: values.sort,
order: searchState.order ?? getDefaultSortOrder(values.sort),
locationCode: values.locationCode,
};
@ -146,9 +503,10 @@ export function DomainOverviewPage({
state.handleSearchSubmit(event);
},
[searchState.order, searchTabs, state],
[searchTabs, state],
);
const tabControls = searchState.domain ? (
const tabControls = routeState.domain ? (
<div className="flex flex-col gap-2">
<div>
<button
@ -238,44 +596,57 @@ export function DomainOverviewPage({
</div>
) : null}
<DomainResultsCard
projectId={projectId}
overview={state.overview}
activeTab={searchState.tab}
sortMode={searchState.sort}
currentSortOrder={state.currentSortOrder}
searchDraft={state.searchDraft}
selectedKeywords={state.selectedKeywords}
setSelectedKeywords={state.setSelectedKeywords}
visibleKeywords={state.visibleKeywords}
filteredKeywords={state.filteredKeywords}
pagedPages={state.pagedPages}
showFilters={state.showFilters}
setShowFilters={state.setShowFilters}
filtersForm={state.filtersForm}
activeFilterCount={state.activeFilterCount}
dirtyFilterCount={state.dirtyFilterCount}
conditionCount={state.conditionCount}
overLimit={state.overLimit}
resetFilters={state.resetFilters}
applyFilters={state.applyFilters}
cancelFilterEdits={state.cancelFilterEdits}
onSearchChange={state.setSearchDraft}
onSaveKeywords={state.handleSaveKeywords}
canSaveKeywords={state.canSaveKeywords}
onSortClick={state.handleSortColumnClick}
onToggleKeyword={state.toggleKeywordSelection}
page={state.page}
pageSize={state.pageSize}
totalKeywordCount={state.totalKeywordCount}
totalPagesCount={state.totalPagesCount}
hasNextKeywordsPage={state.hasNextKeywordsPage}
hasNextPagesPage={state.hasNextPagesPage}
isKeywordsLoading={state.keywordsLoading}
isPagesLoading={state.pagesLoading}
onPageChange={state.goToPage}
onPageSizeChange={state.setPageSize}
/>
<div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 px-4 py-3 border-b border-base-300">
<div role="tablist" className="tabs tabs-box w-fit">
<button
type="button"
role="tab"
aria-selected={routeState.tab === "keywords"}
className={`tab ${routeState.tab === "keywords" ? "tab-active" : ""}`}
onClick={() => state.handleTabChange("keywords")}
>
Top Keywords
</button>
<button
type="button"
role="tab"
aria-selected={routeState.tab === "pages"}
className={`tab ${routeState.tab === "pages" ? "tab-active" : ""}`}
onClick={() => state.handleTabChange("pages")}
>
Top Pages
</button>
</div>
</div>
{routeState.tab === "keywords" ? (
<KeywordsTab
key="keywords"
projectId={projectId}
domain={state.overview.domain}
languageCode={state.languageCode}
routeState={routeState}
canSaveKeywords={state.canSaveKeywords}
setSearchParams={state.setSearchParams}
onSortClick={state.handleSortColumnClick}
onPageChange={state.goToPage}
onPageSizeChange={state.setPageSize}
/>
) : (
<PagesTab
key="pages"
projectId={projectId}
domain={state.overview.domain}
languageCode={state.languageCode}
routeState={routeState}
setSearchParams={state.setSearchParams}
onSortClick={state.handleSortColumnClick}
onPageChange={state.goToPage}
onPageSizeChange={state.setPageSize}
/>
)}
</div>
</>
)}
</div>

View File

@ -0,0 +1,72 @@
import type { ReactNode } from "react";
function FilterFieldLabel({ children }: { children: ReactNode }) {
return (
<span className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
{children}
</span>
);
}
export function FilterTextInput({
label,
value,
onChange,
placeholder,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
}) {
return (
<label className="form-control gap-1.5">
<FilterFieldLabel>{label}</FilterFieldLabel>
<input
className="input input-bordered input-sm w-full bg-base-100"
placeholder={placeholder}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
</label>
);
}
export function FilterNumberInput({
value,
onChange,
placeholder,
step,
}: {
value: string;
onChange: (value: string) => void;
placeholder: string;
step?: string;
}) {
return (
<input
className="input input-bordered input-xs bg-base-100"
type="text"
inputMode="decimal"
step={step}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
/>
);
}
export function FilterRangeGroup({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<div className="rounded-lg border border-base-300 bg-base-100 p-2.5 space-y-2">
<FilterFieldLabel>{title}</FilterFieldLabel>
<div className="grid grid-cols-2 gap-2">{children}</div>
</div>
);
}

View File

@ -1,44 +1,128 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { AlertTriangle, RotateCcw } from "lucide-react";
import type { useDomainFilters } from "@/client/features/domain/hooks/useDomainFilters";
import type { DomainFilterValues } from "@/client/features/domain/types";
import {
FilterNumberInput,
FilterRangeGroup,
FilterTextInput,
} from "@/client/features/domain/components/DomainFilterFields";
import {
debugDomain,
useDomainRenderDebug,
} from "@/client/features/domain/domainDebug";
import { MAX_DATAFORSEO_FILTER_CONDITIONS } from "@/types/schemas/domain";
type FilterForm = ReturnType<typeof useDomainFilters>["filtersForm"];
type FilterValues = Record<string, string>;
type Props = {
filtersForm: FilterForm;
activeFilterCount: number;
dirtyFilterCount: number;
conditionCount: number;
overLimit: boolean;
resetFilters: () => void;
applyFilters: () => void;
cancelFilterEdits: () => void;
type FilterTextField<TValues extends FilterValues> = {
key: keyof TValues;
label: string;
placeholder: string;
};
export function DomainFilterPanel({
filtersForm,
type FilterRangeField<TValues extends FilterValues> = {
title: string;
minKey: keyof TValues;
maxKey: keyof TValues;
step?: string;
};
type Props<TValues extends FilterValues> = {
debugName: string;
activeFilterCount: number;
appliedFilters: TValues;
fields: ReadonlyArray<keyof TValues>;
textFields: ReadonlyArray<FilterTextField<TValues>>;
rangeFields: ReadonlyArray<FilterRangeField<TValues>>;
countConditions: (values: TValues) => number;
onApply: (values: TValues) => void;
onClear: () => void;
};
export function DomainFilterPanel<TValues extends FilterValues>({
debugName,
activeFilterCount,
dirtyFilterCount,
conditionCount,
overLimit,
resetFilters,
applyFilters,
cancelFilterEdits,
}: Props) {
const isDirty = dirtyFilterCount > 0;
const canApply = isDirty && !overLimit;
const handleApplyKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Enter" && canApply) {
event.preventDefault();
applyFilters();
}
appliedFilters,
fields,
textFields,
rangeFields,
countConditions,
onApply,
onClear,
}: Props<TValues>) {
const appliedKey = useMemo(
() => fields.map((key) => appliedFilters[key]).join("|"),
[appliedFilters, fields],
);
const [draftFilters, setDraftFilters] = useState(appliedFilters);
useEffect(() => {
setDraftFilters(appliedFilters);
// appliedKey covers content changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appliedKey]);
const onValueChange = useCallback((key: keyof TValues, value: string) => {
setDraftFilters((current) => ({ ...current, [key]: value }));
}, []);
const meta = useMemo(
() =>
getFilterMeta({
values: draftFilters,
appliedFilters,
fields,
countConditions,
}),
[appliedFilters, countConditions, draftFilters, fields],
);
useDomainRenderDebug(debugName, {
activeFilterCount,
conditionCount: meta.conditionCount,
dirtyCount: meta.dirtyCount,
});
const applyFilters = useCallback(() => {
if (meta.overLimit) return;
debugDomain(`${debugName}:apply`, {
conditionCount: meta.conditionCount,
dirtyCount: meta.dirtyCount,
draftFilters,
});
onApply(draftFilters);
}, [
debugName,
draftFilters,
meta.conditionCount,
meta.dirtyCount,
meta.overLimit,
onApply,
]);
const cancelFilterEdits = useCallback(() => {
debugDomain(`${debugName}:cancel`);
setDraftFilters(appliedFilters);
}, [appliedFilters, debugName]);
const resetFilters = useCallback(() => {
debugDomain(`${debugName}:clear`);
onClear();
}, [debugName, onClear]);
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key !== "Enter") return;
if (meta.overLimit) return;
event.preventDefault();
applyFilters();
};
const handleValueChange = useCallback(
(key: keyof TValues, value: string) => {
debugDomain(`${debugName}:draft-change`, {
field: String(key),
valueLength: value.length,
});
onValueChange(key, value);
},
[debugName, onValueChange],
);
return (
<div
className="border-b border-base-300 bg-gradient-to-b from-base-100 to-base-200/30 px-4 py-3 space-y-3"
onKeyDown={handleApplyKeyDown}
onKeyDown={handleKeyDown}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
@ -48,16 +132,17 @@ export function DomainFilterPanel({
{activeFilterCount} active
</span>
) : null}
{isDirty ? (
{meta.dirtyCount > 0 ? (
<span className="badge badge-xs badge-warning border-0">
{dirtyFilterCount} unapplied
{meta.dirtyCount} unapplied
</span>
) : null}
</div>
<button
type="button"
className="btn btn-xs btn-ghost gap-1"
onClick={resetFilters}
disabled={activeFilterCount === 0 && !isDirty}
disabled={activeFilterCount === 0 && !meta.isDirty}
>
<RotateCcw className="size-3" />
Clear all
@ -65,75 +150,56 @@ export function DomainFilterPanel({
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<FilterTextInput
form={filtersForm}
name="include"
label="Include Terms"
placeholder="audit, checker, template"
/>
<FilterTextInput
form={filtersForm}
name="exclude"
label="Exclude Terms"
placeholder="jobs, salary, course"
/>
{textFields.map((field) => (
<FilterTextInput
key={String(field.key)}
label={field.label}
placeholder={field.placeholder}
value={draftFilters[field.key]}
onChange={(value) => handleValueChange(field.key, value)}
/>
))}
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<FilterRangeInputs
form={filtersForm}
title="Traffic"
minName="minTraffic"
maxName="maxTraffic"
/>
<FilterRangeInputs
form={filtersForm}
title="Volume"
minName="minVol"
maxName="maxVol"
/>
<FilterRangeInputs
form={filtersForm}
title="CPC (USD)"
minName="minCpc"
maxName="maxCpc"
step="0.01"
/>
<FilterRangeInputs
form={filtersForm}
title="Score (KD)"
minName="minKd"
maxName="maxKd"
/>
<FilterRangeInputs
form={filtersForm}
title="Rank"
minName="minRank"
maxName="maxRank"
/>
{rangeFields.map((field) => (
<FilterRangeGroup key={String(field.minKey)} title={field.title}>
<FilterNumberInput
value={draftFilters[field.minKey]}
onChange={(value) => handleValueChange(field.minKey, value)}
placeholder="Min"
step={field.step}
/>
<FilterNumberInput
value={draftFilters[field.maxKey]}
onChange={(value) => handleValueChange(field.maxKey, value)}
placeholder="Max"
step={field.step}
/>
</FilterRangeGroup>
))}
</div>
{overLimit ? (
{meta.overLimit ? (
<div className="alert alert-warning py-2 text-xs">
<AlertTriangle className="size-4 shrink-0" />
<span>
Too many filter conditions ({conditionCount} of{" "}
Too many filter conditions ({meta.conditionCount} of{" "}
{MAX_DATAFORSEO_FILTER_CONDITIONS} max). Remove some terms or ranges
before applying.
</span>
</div>
) : null}
<div className="flex items-center justify-between gap-2 pt-1">
<span className="text-xs text-base-content/50 tabular-nums">
{conditionCount} / {MAX_DATAFORSEO_FILTER_CONDITIONS} conditions
{meta.conditionCount} / {MAX_DATAFORSEO_FILTER_CONDITIONS} conditions
</span>
<div className="flex items-center gap-2">
<button
type="button"
className="btn btn-sm btn-ghost"
onClick={cancelFilterEdits}
disabled={!isDirty}
disabled={!meta.isDirty}
>
Cancel
</button>
@ -141,17 +207,17 @@ export function DomainFilterPanel({
type="button"
className="btn btn-sm btn-primary"
onClick={applyFilters}
disabled={!canApply}
disabled={!meta.isDirty || meta.overLimit}
title={
overLimit
meta.overLimit
? `DataForSEO accepts at most ${MAX_DATAFORSEO_FILTER_CONDITIONS} filter conditions per request`
: undefined
}
>
Apply filters
{isDirty ? (
{meta.isDirty ? (
<span className="badge badge-xs ml-1 border-0 bg-primary-content/20">
{dirtyFilterCount}
{meta.dirtyCount}
</span>
) : null}
</button>
@ -161,80 +227,27 @@ export function DomainFilterPanel({
);
}
function FilterTextInput({
form,
name,
label,
placeholder,
function getFilterMeta<TValues extends FilterValues>({
values,
appliedFilters,
fields,
countConditions,
}: {
form: FilterForm;
name: "include" | "exclude";
label: string;
placeholder: string;
values: TValues;
appliedFilters: TValues;
fields: ReadonlyArray<keyof TValues>;
countConditions: (values: TValues) => number;
}) {
return (
<label className="form-control gap-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
{label}
</span>
<form.Field name={name}>
{(field) => (
<input
className="input input-bordered input-sm w-full bg-base-100"
placeholder={placeholder}
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
/>
)}
</form.Field>
</label>
);
}
function FilterRangeInputs({
form,
title,
minName,
maxName,
step,
}: {
form: FilterForm;
title: string;
minName: keyof DomainFilterValues;
maxName: keyof DomainFilterValues;
step?: string;
}) {
return (
<div className="rounded-lg border border-base-300 bg-base-100 p-2.5 space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
{title}
</p>
<div className="grid grid-cols-2 gap-2">
<form.Field name={minName}>
{(field) => (
<input
className="input input-bordered input-xs bg-base-100"
placeholder="Min"
type="number"
step={step}
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
/>
)}
</form.Field>
<form.Field name={maxName}>
{(field) => (
<input
className="input input-bordered input-xs bg-base-100"
placeholder="Max"
type="number"
step={step}
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
/>
)}
</form.Field>
</div>
</div>
const conditionCount = countConditions(values);
const dirtyCount = fields.reduce(
(acc, key) =>
acc + (values[key].trim() !== appliedFilters[key].trim() ? 1 : 0),
0,
);
return {
conditionCount,
dirtyCount,
isDirty: dirtyCount > 0,
overLimit: conditionCount > MAX_DATAFORSEO_FILTER_CONDITIONS,
};
}

View File

@ -59,7 +59,6 @@ export function DomainHistorySection({
</p>
<p className="text-sm text-base-content/60 truncate">
{item.subdomains ? "Include subdomains" : "Root domain only"}
{item.search?.trim() ? ` - ${item.search}` : ""}
</p>
</div>
</button>

View File

@ -1,4 +1,6 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Link } from "@tanstack/react-router";
import type { ReactNode } from "react";
import { DOMAIN_KEYWORDS_PAGE_SIZES } from "@/types/schemas/domain";
type Props = {
@ -70,27 +72,72 @@ export function DomainKeywordsPagination({
{totalPages != null ? ` of ${totalPages.toLocaleString()}` : ""}
</span>
<div className="flex items-center gap-1">
<button
type="button"
className="btn btn-ghost btn-sm btn-square"
<PageLink
page={page - 1}
disabled={!canGoPrev || isLoading}
onClick={() => onPageChange(page - 1)}
aria-label="Previous page"
onPageChange={onPageChange}
label="Previous page"
>
<ChevronLeft className="size-4" />
</button>
<button
type="button"
className="btn btn-ghost btn-sm btn-square"
</PageLink>
<PageLink
page={page + 1}
disabled={!canGoNext || isLoading}
onClick={() => onPageChange(page + 1)}
aria-label="Next page"
onPageChange={onPageChange}
label="Next page"
>
<ChevronRight className="size-4" />
</button>
</PageLink>
</div>
</div>
</div>
</div>
);
}
function PageLink({
page,
disabled,
label,
children,
onPageChange,
}: {
page: number;
disabled: boolean;
label: string;
children: ReactNode;
onPageChange: (nextPage: number) => void;
}) {
return (
<Link
from="/p/$projectId/domain"
to="/p/$projectId/domain"
search={(prev) => ({
...prev,
page: page === 1 ? undefined : page,
})}
aria-label={label}
aria-disabled={disabled}
className={`btn btn-ghost btn-sm btn-square ${disabled ? "btn-disabled" : ""}`}
onClick={(event) => {
if (disabled) {
event.preventDefault();
return;
}
if (
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey ||
event.button !== 0
) {
return;
}
event.preventDefault();
onPageChange(page);
}}
>
{children}
</Link>
);
}

View File

@ -1,4 +1,4 @@
import { useMemo } from "react";
import { memo, useMemo } from "react";
import {
createColumnHelper,
type ColumnDef,
@ -13,7 +13,8 @@ import {
import { ExternalUrlCell } from "@/client/components/table/url";
import { DifficultyBadge } from "@/client/features/domain/components/DifficultyBadge";
import { SortableHeader } from "@/client/features/domain/components/SortableHeader";
import { formatFloat, formatNumber } from "@/client/features/domain/utils";
import { useDomainRenderDebug } from "@/client/features/domain/domainDebug";
import { formatNumber, formatRounded } from "@/client/features/domain/utils";
import type {
DomainSortMode,
KeywordRow,
@ -33,7 +34,7 @@ type Props = {
const keywordColumnHelper = createColumnHelper<KeywordRow>();
export function DomainKeywordsTable({
function DomainKeywordsTableComponent({
domain,
rows,
selectedKeywords,
@ -43,6 +44,7 @@ export function DomainKeywordsTable({
onSortClick,
onToggleKeyword,
}: Props) {
const renderStarted = performance.now();
const selectAnchorRef = useSelectionAnchor();
const rowSelection = useMemo<RowSelectionState>(
() =>
@ -91,7 +93,7 @@ export function DomainKeywordsTable({
onClick={() => onSortClick("traffic")}
/>
),
cell: ({ getValue }) => formatFloat(getValue()),
cell: ({ getValue }) => formatRounded(getValue()),
}),
keywordColumnHelper.accessor("cpc", {
header: () => (
@ -157,6 +159,13 @@ export function DomainKeywordsTable({
getRowId: (row) => row.keyword,
enableRowSelection: true,
});
useDomainRenderDebug("DomainKeywordsTable", {
rows: rows.length,
selectedCount: selectedKeywords.size,
durationMs: Math.round(performance.now() - renderStarted),
sortMode,
currentSortOrder,
});
return (
<div className="overflow-x-auto">
@ -178,3 +187,5 @@ export function DomainKeywordsTable({
</div>
);
}
export const DomainKeywordsTable = memo(DomainKeywordsTableComponent);

View File

@ -1,4 +1,4 @@
import { useMemo } from "react";
import { memo, useMemo } from "react";
import { createColumnHelper, type ColumnDef } from "@tanstack/react-table";
import {
AppDataTable,
@ -6,9 +6,10 @@ import {
} from "@/client/components/table/AppDataTable";
import { ExternalUrlCell } from "@/client/components/table/url";
import { SortableHeader } from "@/client/features/domain/components/SortableHeader";
import { useDomainRenderDebug } from "@/client/features/domain/domainDebug";
import {
formatFloat,
formatNumber,
formatRounded,
toPageSortMode,
} from "@/client/features/domain/utils";
import type {
@ -27,13 +28,14 @@ type Props = {
const pageColumnHelper = createColumnHelper<PageRow>();
export function DomainPagesTable({
function DomainPagesTableComponent({
domain,
rows,
sortMode,
currentSortOrder,
onSortClick,
}: Props) {
const renderStarted = performance.now();
const columns = useMemo<ColumnDef<PageRow>[]>(
() => [
pageColumnHelper.display({
@ -60,7 +62,7 @@ export function DomainPagesTable({
onClick={() => onSortClick("traffic")}
/>
),
cell: ({ getValue }) => formatFloat(getValue()),
cell: ({ getValue }) => formatRounded(getValue()),
}),
pageColumnHelper.accessor("keywords", {
header: () => (
@ -80,6 +82,12 @@ export function DomainPagesTable({
data: rows.slice(0, 100),
columns,
});
useDomainRenderDebug("DomainPagesTable", {
rows: rows.length,
durationMs: Math.round(performance.now() - renderStarted),
sortMode,
currentSortOrder,
});
return (
<AppDataTable
@ -93,3 +101,5 @@ export function DomainPagesTable({
/>
);
}
export const DomainPagesTable = memo(DomainPagesTableComponent);

View File

@ -1,404 +0,0 @@
import { type Dispatch, type SetStateAction } from "react";
import { Link } from "@tanstack/react-router";
import {
ChevronDown,
Copy,
Download,
FileSpreadsheet,
Save,
Search,
Sheet,
SlidersHorizontal,
} from "lucide-react";
import { toast } from "sonner";
import { DomainFilterPanel } from "@/client/features/domain/components/DomainFilterPanel";
import { DomainKeywordsPagination } from "@/client/features/domain/components/DomainKeywordsPagination";
import { DomainKeywordsTable } from "@/client/features/domain/components/DomainKeywordsTable";
import { DomainPagesTable } from "@/client/features/domain/components/DomainPagesTable";
import type { useDomainFilters } from "@/client/features/domain/hooks/useDomainFilters";
import {
getDefaultSortOrder,
keywordsToTable,
pagesToTable,
} from "@/client/features/domain/utils";
import { buildCsv, downloadCsv } from "@/client/lib/csv";
import { exportTableToSheets } from "@/client/lib/exportToSheets";
import { captureClientEvent } from "@/client/lib/posthog";
import {
TableBulkActionBar,
TableBulkActionButton,
TableBulkExportMenu,
} from "@/client/components/table/TableBulkActionBar";
import type {
DomainActiveTab,
DomainOverviewData,
DomainSortMode,
KeywordRow,
PageRow,
SortOrder,
} from "@/client/features/domain/types";
type Props = {
projectId: string;
overview: DomainOverviewData;
activeTab: DomainActiveTab;
sortMode: DomainSortMode;
currentSortOrder: SortOrder;
searchDraft: string;
selectedKeywords: Set<string>;
setSelectedKeywords: Dispatch<SetStateAction<Set<string>>>;
visibleKeywords: string[];
filteredKeywords: KeywordRow[];
pagedPages: PageRow[];
showFilters: boolean;
setShowFilters: Dispatch<SetStateAction<boolean>>;
filtersForm: ReturnType<typeof useDomainFilters>["filtersForm"];
activeFilterCount: number;
dirtyFilterCount: number;
conditionCount: number;
overLimit: boolean;
resetFilters: () => void;
applyFilters: () => void;
cancelFilterEdits: () => void;
onSearchChange: (value: string) => void;
onSaveKeywords: () => void;
canSaveKeywords: boolean;
onSortClick: (sort: DomainSortMode) => void;
onToggleKeyword: (keyword: string) => void;
page: number;
pageSize: number;
totalKeywordCount: number | null;
totalPagesCount: number | null;
hasNextKeywordsPage: boolean;
hasNextPagesPage: boolean;
isKeywordsLoading: boolean;
isPagesLoading: boolean;
onPageChange: (nextPage: number) => void;
onPageSizeChange: (nextSize: number) => void;
};
const KEYWORDS_ONLY_SORTS: ReadonlySet<DomainSortMode> = new Set([
"rank",
"score",
"cpc",
]);
export function DomainResultsCard({
projectId,
overview,
activeTab,
sortMode,
currentSortOrder,
searchDraft,
selectedKeywords,
setSelectedKeywords,
visibleKeywords,
filteredKeywords,
pagedPages,
showFilters,
setShowFilters,
filtersForm,
activeFilterCount,
dirtyFilterCount,
conditionCount,
overLimit,
resetFilters,
applyFilters,
cancelFilterEdits,
onSearchChange,
onSaveKeywords,
canSaveKeywords,
onSortClick,
onToggleKeyword,
page,
pageSize,
totalKeywordCount,
totalPagesCount,
hasNextKeywordsPage,
hasNextPagesPage,
isKeywordsLoading,
isPagesLoading,
onPageChange,
onPageSizeChange,
}: Props) {
const isKeywordsTab = activeTab === "keywords";
const currentRows = isKeywordsTab ? filteredKeywords : pagedPages;
const exportTable = isKeywordsTab
? keywordsToTable(filteredKeywords)
: pagesToTable(pagedPages);
const selectedKeywordRows = filteredKeywords.filter((row) =>
selectedKeywords.has(row.keyword),
);
const selectedKeywordExportTable = keywordsToTable(selectedKeywordRows);
const handleCopy = async () => {
const text = JSON.stringify(currentRows, null, 2);
await navigator.clipboard.writeText(text);
toast.success("Copied data");
};
const handleExportToSheets = () => {
void exportTableToSheets({
headers: exportTable.headers,
rows: exportTable.rows,
feature: "domain_overview",
});
};
const handleDownload = (extension: "csv" | "xls") => {
downloadCsv(
`${overview.domain}-${activeTab}.${extension}`,
buildCsv(exportTable.headers, exportTable.rows),
);
if (extension === "csv") {
captureClientEvent("data:export", {
source_feature: "domain_overview",
result_count: currentRows.length,
});
}
};
const handleExportSelectionToSheets = () => {
void exportTableToSheets({
headers: selectedKeywordExportTable.headers,
rows: selectedKeywordExportTable.rows,
feature: "domain_overview",
});
};
const handleDownloadSelectionCsv = () => {
downloadCsv(
`${overview.domain}-selected-keywords.csv`,
buildCsv(
selectedKeywordExportTable.headers,
selectedKeywordExportTable.rows,
),
);
captureClientEvent("data:export", {
source_feature: "domain_overview",
result_count: selectedKeywordRows.length,
scope: "selection",
});
};
return (
<div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 px-4 py-3 border-b border-base-300">
<div role="tablist" className="tabs tabs-box w-fit">
<Link
from="/p/$projectId/domain"
to="/p/$projectId/domain"
params={{ projectId }}
search={(prev) => ({ ...prev, tab: undefined, page: undefined })}
replace
role="tab"
className={`tab ${activeTab === "keywords" ? "tab-active" : ""}`}
>
Top Keywords
</Link>
<Link
from="/p/$projectId/domain"
to="/p/$projectId/domain"
params={{ projectId }}
search={(prev) => {
const fallbackSortNeeded = KEYWORDS_ONLY_SORTS.has(sortMode);
const nextSort = fallbackSortNeeded ? "traffic" : prev.sort;
const nextOrder = fallbackSortNeeded
? getDefaultSortOrder("traffic")
: prev.order;
return {
...prev,
tab: "pages" as const,
sort: nextSort,
order: nextOrder,
page: undefined,
};
}}
replace
role="tab"
className={`tab ${activeTab === "pages" ? "tab-active" : ""}`}
>
Top Pages
</Link>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="dropdown dropdown-end">
<div tabIndex={0} role="button" className="btn btn-sm gap-1">
<Download className="size-4" />
Export
<ChevronDown className="size-3 opacity-60" />
</div>
<ul
tabIndex={0}
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-56"
>
<li>
<button onClick={handleExportToSheets}>
<Sheet className="size-4" />
Export to Sheets
</button>
</li>
<li>
<button onClick={handleCopy}>
<Copy className="size-4" />
Copy data (JSON)
</button>
</li>
<li>
<button onClick={() => handleDownload("csv")}>
<Download className="size-4" />
Download CSV
</button>
</li>
<li>
<button onClick={() => handleDownload("xls")}>
<FileSpreadsheet className="size-4" />
Download Excel
</button>
</li>
</ul>
</div>
</div>
</div>
{activeTab === "keywords" ? (
<TableBulkActionBar
selectedCount={selectedKeywords.size}
onClear={() => setSelectedKeywords(new Set())}
actions={
<div className="flex items-center px-1.5">
<TableBulkActionButton
icon={<Save className="size-3.5" />}
onClick={onSaveKeywords}
disabled={!canSaveKeywords}
>
Save Keywords
</TableBulkActionButton>
<TableBulkExportMenu
actions={[
{
label: "Export to Sheets",
icon: <Sheet className="size-4" />,
onClick: handleExportSelectionToSheets,
},
{
label: "Download CSV",
icon: <Download className="size-4" />,
onClick: handleDownloadSelectionCsv,
},
]}
/>
</div>
}
/>
) : null}
<div className="flex items-center gap-2 px-4 py-2 border-b border-base-300">
{isKeywordsTab ? (
<button
className={`btn btn-ghost btn-sm gap-1.5 ${showFilters ? "btn-active" : ""}`}
onClick={() => setShowFilters((prev) => !prev)}
title="Toggle filters"
>
<SlidersHorizontal className="size-3.5" />
Filters
{activeFilterCount > 0 ? (
<span className="badge badge-xs badge-primary border-0 text-primary-content">
{activeFilterCount}
</span>
) : null}
</button>
) : null}
<span className="text-sm text-base-content/60">
{isKeywordsTab
? totalKeywordCount != null
? `${totalKeywordCount.toLocaleString()} keywords`
: `${filteredKeywords.length.toLocaleString()} keywords`
: totalPagesCount != null
? `${totalPagesCount.toLocaleString()} pages`
: `${pagedPages.length.toLocaleString()} pages`}
</span>
<div className="flex-1" />
<form
className="w-full max-w-xs"
onSubmit={(event) => {
event.preventDefault();
if (!overLimit) applyFilters();
}}
>
<label className="input input-bordered input-sm w-full flex items-center gap-2">
<Search className="size-4 text-base-content/60" />
<input
placeholder="Search in results (press Enter)"
value={searchDraft}
onChange={(event) => onSearchChange(event.target.value)}
/>
</label>
</form>
</div>
{isKeywordsTab && showFilters ? (
<DomainFilterPanel
filtersForm={filtersForm}
activeFilterCount={activeFilterCount}
dirtyFilterCount={dirtyFilterCount}
conditionCount={conditionCount}
overLimit={overLimit}
resetFilters={resetFilters}
applyFilters={applyFilters}
cancelFilterEdits={cancelFilterEdits}
/>
) : null}
<div className="p-4">
{isKeywordsTab ? (
<div
className={
isKeywordsLoading
? "opacity-60 transition-opacity"
: "transition-opacity"
}
>
<DomainKeywordsTable
domain={overview.domain}
rows={filteredKeywords}
selectedKeywords={selectedKeywords}
visibleKeywords={visibleKeywords}
sortMode={sortMode}
currentSortOrder={currentSortOrder}
onSortClick={onSortClick}
onToggleKeyword={onToggleKeyword}
/>
</div>
) : (
<div
className={
isPagesLoading
? "opacity-60 transition-opacity"
: "transition-opacity"
}
>
<DomainPagesTable
domain={overview.domain}
rows={pagedPages}
sortMode={sortMode}
currentSortOrder={currentSortOrder}
onSortClick={onSortClick}
/>
</div>
)}
</div>
<DomainKeywordsPagination
page={page}
pageSize={pageSize}
totalCount={isKeywordsTab ? totalKeywordCount : totalPagesCount}
hasNextPage={isKeywordsTab ? hasNextKeywordsPage : hasNextPagesPage}
isLoading={isKeywordsTab ? isKeywordsLoading : isPagesLoading}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
/>
</div>
);
}

View File

@ -1,13 +1,13 @@
import type { FormEvent } from "react";
import { AlertCircle, Search } from "lucide-react";
import { getFieldError, getFormError } from "@/client/lib/forms";
import type { useDomainOverviewController } from "@/client/features/domain/useDomainOverviewController";
import type { DomainOverviewControlsForm } from "@/client/features/domain/DomainOverviewPage";
import { toSortMode } from "@/client/features/domain/utils";
import type { DomainSortMode } from "@/client/features/domain/types";
import { LOCATION_OPTIONS } from "@/client/features/keywords/locations";
type Props = {
controlsForm: ReturnType<typeof useDomainOverviewController>["controlsForm"];
controlsForm: DomainOverviewControlsForm;
isLoading: boolean;
onSubmit: (event: FormEvent) => void;
onSortChange: (sort: DomainSortMode) => void;

View File

@ -0,0 +1,82 @@
import type { ReactNode } from "react";
import { SlidersHorizontal } from "lucide-react";
import { TableExportMenu } from "@/client/components/table/TableBulkActionBar";
import { TableLoadingRows } from "@/client/features/domain/components/TableLoadingRows";
type DomainTableExportAction = {
label: string;
icon: ReactNode;
onClick: () => void;
};
type Props = {
showFilters: boolean;
onToggleFilters: () => void;
activeFilterCount: number;
countLabel: string;
totalCount: number | null;
fallbackCount: number;
exportActions: DomainTableExportAction[];
filterPanel?: ReactNode;
isLoading: boolean;
showTableLoading: boolean;
children: ReactNode;
pagination: ReactNode;
};
export function DomainTableTabSurface({
showFilters,
onToggleFilters,
activeFilterCount,
countLabel,
totalCount,
fallbackCount,
exportActions,
filterPanel,
isLoading,
showTableLoading,
children,
pagination,
}: Props) {
return (
<>
<div className="flex items-center gap-2 px-4 py-2 border-b border-base-300">
<button
className={`btn btn-ghost btn-sm gap-1.5 ${showFilters ? "btn-active" : ""}`}
onClick={onToggleFilters}
title="Toggle filters"
type="button"
>
<SlidersHorizontal className="size-3.5" />
Filters
{activeFilterCount > 0 ? (
<span className="badge badge-xs badge-primary border-0 text-primary-content">
{activeFilterCount}
</span>
) : null}
</button>
<span className="text-sm text-base-content/60">
{(totalCount ?? fallbackCount).toLocaleString()} {countLabel}
</span>
<div className="flex-1" />
<TableExportMenu actions={exportActions} />
</div>
{filterPanel}
<div className="p-4">
<div
className={
isLoading && !showTableLoading
? "opacity-60 transition-opacity"
: "transition-opacity"
}
>
{showTableLoading ? <TableLoadingRows /> : children}
</div>
</div>
{pagination}
</>
);
}

View File

@ -0,0 +1,354 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Copy, Download, FileSpreadsheet, Save, Sheet } from "lucide-react";
import { toast } from "sonner";
import {
TableBulkActionBar,
TableBulkActionButton,
TableBulkExportMenu,
} from "@/client/components/table/TableBulkActionBar";
import { DomainKeywordsPagination } from "@/client/features/domain/components/DomainKeywordsPagination";
import { DomainKeywordsTable } from "@/client/features/domain/components/DomainKeywordsTable";
import { DomainFilterPanel } from "@/client/features/domain/components/DomainFilterPanel";
import { DomainTableTabSurface } from "@/client/features/domain/components/DomainTableTabSurface";
import { saveSelectedKeywords } from "@/client/features/domain/domainActions";
import {
KEYWORD_FILTER_FIELDS,
buildKeywordsSearchUpdate,
countKeywordFilterConditions,
} from "@/client/features/domain/domainFilterUtils";
import {
debugDomain,
useDomainRenderDebug,
} from "@/client/features/domain/domainDebug";
import { useDomainKeywordsQuery } from "@/client/features/domain/hooks/useDomainKeywordsQuery";
import { useSaveKeywordsMutation } from "@/client/features/domain/mutations";
import { useDomainKeywordFilterPreferences } from "@/client/features/domain/useDomainFilterPreferences";
import {
type DomainSortMode,
type KeywordRow,
type KeywordsFilterValues,
} from "@/client/features/domain/types";
import { keywordsToTable } from "@/client/features/domain/utils";
import type { DomainOverviewRouteState } from "@/client/features/domain/domainRouteState";
import { buildCsv, downloadCsv } from "@/client/lib/csv";
import { exportTableToSheets } from "@/client/lib/exportToSheets";
import { captureClientEvent } from "@/client/lib/posthog";
import {
MAX_DATAFORSEO_FILTER_CONDITIONS,
type DomainSearchParams,
} from "@/types/schemas/domain";
type SearchUpdate = Partial<DomainSearchParams>;
const EMPTY_KEYWORDS: KeywordRow[] = [];
const KEYWORD_TEXT_FILTERS = [
{
key: "include",
label: "Include Terms",
placeholder: "audit, checker, template",
},
{
key: "exclude",
label: "Exclude Terms",
placeholder: "jobs, salary, course",
},
] as const;
const KEYWORD_RANGE_FILTERS = [
{ title: "Traffic", minKey: "minTraffic", maxKey: "maxTraffic" },
{ title: "Volume", minKey: "minVol", maxKey: "maxVol" },
{ title: "CPC (USD)", minKey: "minCpc", maxKey: "maxCpc", step: "0.01" },
{ title: "Score (KD)", minKey: "minKd", maxKey: "maxKd" },
{ title: "Rank", minKey: "minRank", maxKey: "maxRank" },
] as const;
type Props = {
projectId: string;
domain: string;
languageCode: string;
routeState: DomainOverviewRouteState;
canSaveKeywords: boolean;
setSearchParams: (updates: SearchUpdate) => void;
onSortClick: (sort: DomainSortMode) => void;
onPageChange: (nextPage: number) => void;
onPageSizeChange: (nextSize: number) => void;
};
export function KeywordsTab({
projectId,
domain,
languageCode,
routeState,
canSaveKeywords,
setSearchParams,
onSortClick,
onPageChange,
onPageSizeChange,
}: Props) {
const queryClient = useQueryClient();
const [selectedKeywords, setSelectedKeywords] = useState<Set<string>>(
new Set(),
);
const [showFilters, setShowFilters] = useState(false);
const filterPreferences = useDomainKeywordFilterPreferences(
`${projectId}:${domain}`,
);
const {
filters: preferredFilters,
save: savePreferredFilters,
clear: clearPreferredFilters,
} = filterPreferences;
const appliedFilters = routeState.hasAppliedKeywordFilters
? routeState.appliedFilters
: preferredFilters;
const query = useDomainKeywordsQuery({
projectId,
domain,
includeSubdomains: routeState.subdomains,
locationCode: routeState.locationCode,
languageCode,
page: routeState.page,
pageSize: routeState.pageSize,
sortMode: routeState.sort,
sortOrder: routeState.order,
appliedFilters,
enabled: Boolean(domain),
});
const rows = query.data?.keywords ?? EMPTY_KEYWORDS;
const totalCount = query.data?.totalCount ?? null;
const hasNextPage = query.data?.hasMore ?? false;
const isLoading = query.isFetching;
const showTableLoading = isLoading && (showFilters || rows.length === 0);
useDomainRenderDebug("KeywordsTab", {
showFilters,
isLoading,
isPending: query.isPending,
rows: rows.length,
totalCount,
selectedCount: selectedKeywords.size,
activeTab: routeState.tab,
page: routeState.page,
sort: routeState.sort,
order: routeState.order,
});
const visibleKeywords = useMemo(() => rows.map((r) => r.keyword), [rows]);
useEffect(() => {
const visibleSet = new Set(visibleKeywords);
setSelectedKeywords((prev) => {
const next = new Set([...prev].filter((k) => visibleSet.has(k)));
return next.size === prev.size ? prev : next;
});
}, [visibleKeywords]);
const toggleKeywordSelection = useCallback((keyword: string) => {
setSelectedKeywords((prev) => {
const next = new Set(prev);
if (next.has(keyword)) next.delete(keyword);
else next.add(keyword);
return next;
});
}, []);
const saveMutation = useSaveKeywordsMutation({ projectId, queryClient });
const handleSaveKeywords = useCallback(() => {
saveSelectedKeywords({
selectedKeywords,
filteredKeywords: rows,
save: saveMutation.mutate,
projectId,
locationCode: routeState.locationCode,
languageCode,
});
}, [
languageCode,
projectId,
routeState.locationCode,
rows,
saveMutation.mutate,
selectedKeywords,
]);
const applyFilters = useCallback(
(values: KeywordsFilterValues) => {
if (
countKeywordFilterConditions(values) > MAX_DATAFORSEO_FILTER_CONDITIONS
)
return;
const update = buildKeywordsSearchUpdate(values);
debugDomain("KeywordsTab:apply-filters", { values, update });
savePreferredFilters(values);
setSearchParams(update);
},
[savePreferredFilters, setSearchParams],
);
const resetFilters = useCallback(() => {
const update: SearchUpdate = { page: undefined };
for (const key of KEYWORD_FILTER_FIELDS) update[key] = undefined;
debugDomain("KeywordsTab:reset-filters", { update });
clearPreferredFilters();
setSearchParams(update);
}, [clearPreferredFilters, setSearchParams]);
const activeFilterCount = useMemo(
() =>
KEYWORD_FILTER_FIELDS.filter((k) => appliedFilters[k].trim() !== "")
.length,
[appliedFilters],
);
const exportTable = useMemo(() => keywordsToTable(rows), [rows]);
const selectedExportTable = useMemo(
() => keywordsToTable(rows.filter((r) => selectedKeywords.has(r.keyword))),
[rows, selectedKeywords],
);
const handleCopy = async () => {
await navigator.clipboard.writeText(JSON.stringify(rows, null, 2));
toast.success("Copied data");
};
const handleExportToSheets = () => {
void exportTableToSheets({
headers: exportTable.headers,
rows: exportTable.rows,
feature: "domain_overview",
});
};
const handleDownload = (extension: "csv" | "xls") => {
downloadCsv(
`${domain}-keywords.${extension}`,
buildCsv(exportTable.headers, exportTable.rows),
);
if (extension === "csv") {
captureClientEvent("data:export", {
source_feature: "domain_overview",
result_count: rows.length,
});
}
};
const handleExportSelectionToSheets = () => {
void exportTableToSheets({
headers: selectedExportTable.headers,
rows: selectedExportTable.rows,
feature: "domain_overview",
});
};
const handleDownloadSelectionCsv = () => {
downloadCsv(
`${domain}-selected-keywords.csv`,
buildCsv(selectedExportTable.headers, selectedExportTable.rows),
);
captureClientEvent("data:export", {
source_feature: "domain_overview",
result_count: selectedKeywords.size,
scope: "selection",
});
};
return (
<>
<TableBulkActionBar
selectedCount={selectedKeywords.size}
onClear={() => setSelectedKeywords(new Set())}
actions={
<div className="flex items-center px-1.5">
<TableBulkActionButton
icon={<Save className="size-3.5" />}
onClick={handleSaveKeywords}
disabled={!canSaveKeywords}
>
Save Keywords
</TableBulkActionButton>
<TableBulkExportMenu
actions={[
{
label: "Export to Sheets",
icon: <Sheet className="size-4" />,
onClick: handleExportSelectionToSheets,
},
{
label: "Download CSV",
icon: <Download className="size-4" />,
onClick: handleDownloadSelectionCsv,
},
]}
/>
</div>
}
/>
<DomainTableTabSurface
showFilters={showFilters}
onToggleFilters={() => setShowFilters((prev) => !prev)}
activeFilterCount={activeFilterCount}
countLabel="keywords"
totalCount={totalCount}
fallbackCount={rows.length}
isLoading={isLoading}
showTableLoading={showTableLoading}
exportActions={[
{
label: "Export to Sheets",
icon: <Sheet className="size-4" />,
onClick: handleExportToSheets,
},
{
label: "Copy data (JSON)",
icon: <Copy className="size-4" />,
onClick: handleCopy,
},
{
label: "Download CSV",
icon: <Download className="size-4" />,
onClick: () => handleDownload("csv"),
},
{
label: "Download Excel",
icon: <FileSpreadsheet className="size-4" />,
onClick: () => handleDownload("xls"),
},
]}
filterPanel={
showFilters ? (
<DomainFilterPanel
debugName="KeywordsFilterPanel"
activeFilterCount={activeFilterCount}
appliedFilters={appliedFilters}
fields={KEYWORD_FILTER_FIELDS}
textFields={KEYWORD_TEXT_FILTERS}
rangeFields={KEYWORD_RANGE_FILTERS}
countConditions={countKeywordFilterConditions}
onApply={applyFilters}
onClear={resetFilters}
/>
) : null
}
pagination={
<DomainKeywordsPagination
page={routeState.page}
pageSize={routeState.pageSize}
totalCount={totalCount}
hasNextPage={hasNextPage}
isLoading={isLoading}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
/>
}
>
<DomainKeywordsTable
domain={domain}
rows={rows}
selectedKeywords={selectedKeywords}
visibleKeywords={visibleKeywords}
sortMode={routeState.sort}
currentSortOrder={routeState.order}
onSortClick={onSortClick}
onToggleKeyword={toggleKeywordSelection}
/>
</DomainTableTabSurface>
</>
);
}

View File

@ -0,0 +1,250 @@
import { useCallback, useMemo, useState } from "react";
import { Copy, Download, FileSpreadsheet, Sheet } from "lucide-react";
import { toast } from "sonner";
import { DomainKeywordsPagination } from "@/client/features/domain/components/DomainKeywordsPagination";
import { DomainFilterPanel } from "@/client/features/domain/components/DomainFilterPanel";
import { DomainPagesTable } from "@/client/features/domain/components/DomainPagesTable";
import { DomainTableTabSurface } from "@/client/features/domain/components/DomainTableTabSurface";
import {
PAGE_FILTER_FIELDS,
buildPagesClearSearchUpdate,
buildPagesSearchUpdate,
countPageFilterConditions,
} from "@/client/features/domain/domainFilterUtils";
import {
debugDomain,
useDomainRenderDebug,
} from "@/client/features/domain/domainDebug";
import { useDomainPagesQuery } from "@/client/features/domain/hooks/useDomainPagesQuery";
import { useDomainPageFilterPreferences } from "@/client/features/domain/useDomainFilterPreferences";
import {
type DomainSortMode,
type PageRow,
type PagesFilterValues,
} from "@/client/features/domain/types";
import { pagesToTable } from "@/client/features/domain/utils";
import type { DomainOverviewRouteState } from "@/client/features/domain/domainRouteState";
import { buildCsv, downloadCsv } from "@/client/lib/csv";
import { exportTableToSheets } from "@/client/lib/exportToSheets";
import { captureClientEvent } from "@/client/lib/posthog";
import {
MAX_DATAFORSEO_FILTER_CONDITIONS,
type DomainSearchParams,
} from "@/types/schemas/domain";
type SearchUpdate = Partial<DomainSearchParams>;
const EMPTY_PAGES_ROWS: PageRow[] = [];
const PAGE_TEXT_FILTERS = [
{
key: "include",
label: "Include Page Terms",
placeholder: "pricing, tools, guides",
},
{
key: "exclude",
label: "Exclude Page Terms",
placeholder: "blog, tag, archive",
},
] as const;
const PAGE_RANGE_FILTERS = [
{ title: "Traffic", minKey: "minTraffic", maxKey: "maxTraffic" },
{ title: "Keywords", minKey: "minVol", maxKey: "maxVol" },
] as const;
type Props = {
projectId: string;
domain: string;
languageCode: string;
routeState: DomainOverviewRouteState;
setSearchParams: (updates: SearchUpdate) => void;
onSortClick: (sort: DomainSortMode) => void;
onPageChange: (nextPage: number) => void;
onPageSizeChange: (nextSize: number) => void;
};
export function PagesTab({
projectId,
domain,
languageCode,
routeState,
setSearchParams,
onSortClick,
onPageChange,
onPageSizeChange,
}: Props) {
const [showFilters, setShowFilters] = useState(false);
const filterPreferences = useDomainPageFilterPreferences(
`${projectId}:${domain}`,
);
const {
filters: preferredFilters,
save: savePreferredFilters,
clear: clearPreferredFilters,
} = filterPreferences;
const appliedPagesFilters = useMemo(
() =>
routeState.hasAppliedPageFilters
? routeState.appliedPageFilters
: preferredFilters,
[
preferredFilters,
routeState.appliedPageFilters,
routeState.hasAppliedPageFilters,
],
);
const query = useDomainPagesQuery({
projectId,
domain,
includeSubdomains: routeState.subdomains,
locationCode: routeState.locationCode,
languageCode,
page: routeState.page,
pageSize: routeState.pageSize,
sortMode: routeState.sort,
sortOrder: routeState.order,
appliedFilters: appliedPagesFilters,
enabled: Boolean(domain),
});
const rows = query.data?.pages ?? EMPTY_PAGES_ROWS;
const totalCount = query.data?.totalCount ?? null;
const hasNextPage = query.data?.hasMore ?? false;
const isLoading = query.isFetching;
const showTableLoading = isLoading && (showFilters || rows.length === 0);
useDomainRenderDebug("PagesTab", {
showFilters,
isLoading,
isPending: query.isPending,
rows: rows.length,
totalCount,
activeTab: routeState.tab,
page: routeState.page,
sort: routeState.sort,
order: routeState.order,
});
const applyFilters = useCallback(
(values: PagesFilterValues) => {
if (countPageFilterConditions(values) > MAX_DATAFORSEO_FILTER_CONDITIONS)
return;
const update = buildPagesSearchUpdate(values);
debugDomain("PagesTab:apply-filters", { values, update });
savePreferredFilters(values);
setSearchParams(update);
},
[savePreferredFilters, setSearchParams],
);
const resetFilters = useCallback(() => {
const update = buildPagesClearSearchUpdate();
debugDomain("PagesTab:reset-filters", { update });
clearPreferredFilters();
setSearchParams(update);
}, [clearPreferredFilters, setSearchParams]);
const activeFilterCount = useMemo(
() =>
PAGE_FILTER_FIELDS.filter((k) => appliedPagesFilters[k].trim() !== "")
.length,
[appliedPagesFilters],
);
const exportTable = useMemo(() => pagesToTable(rows), [rows]);
const handleCopy = async () => {
await navigator.clipboard.writeText(JSON.stringify(rows, null, 2));
toast.success("Copied data");
};
const handleExportToSheets = () => {
void exportTableToSheets({
headers: exportTable.headers,
rows: exportTable.rows,
feature: "domain_overview",
});
};
const handleDownload = (extension: "csv" | "xls") => {
downloadCsv(
`${domain}-pages.${extension}`,
buildCsv(exportTable.headers, exportTable.rows),
);
if (extension === "csv") {
captureClientEvent("data:export", {
source_feature: "domain_overview",
result_count: rows.length,
});
}
};
return (
<>
<DomainTableTabSurface
showFilters={showFilters}
onToggleFilters={() => setShowFilters((prev) => !prev)}
activeFilterCount={activeFilterCount}
countLabel="pages"
totalCount={totalCount}
fallbackCount={rows.length}
isLoading={isLoading}
showTableLoading={showTableLoading}
exportActions={[
{
label: "Export to Sheets",
icon: <Sheet className="size-4" />,
onClick: handleExportToSheets,
},
{
label: "Copy data (JSON)",
icon: <Copy className="size-4" />,
onClick: handleCopy,
},
{
label: "Download CSV",
icon: <Download className="size-4" />,
onClick: () => handleDownload("csv"),
},
{
label: "Download Excel",
icon: <FileSpreadsheet className="size-4" />,
onClick: () => handleDownload("xls"),
},
]}
filterPanel={
showFilters ? (
<DomainFilterPanel
debugName="PagesFilterPanel"
activeFilterCount={activeFilterCount}
appliedFilters={appliedPagesFilters}
fields={PAGE_FILTER_FIELDS}
textFields={PAGE_TEXT_FILTERS}
rangeFields={PAGE_RANGE_FILTERS}
countConditions={countPageFilterConditions}
onApply={applyFilters}
onClear={resetFilters}
/>
) : null
}
pagination={
<DomainKeywordsPagination
page={routeState.page}
pageSize={routeState.pageSize}
totalCount={totalCount}
hasNextPage={hasNextPage}
isLoading={isLoading}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
/>
}
>
<DomainPagesTable
domain={domain}
rows={rows}
sortMode={routeState.sort}
currentSortOrder={routeState.order}
onSortClick={onSortClick}
/>
</DomainTableTabSurface>
</>
);
}

View File

@ -0,0 +1,15 @@
export function TableLoadingRows() {
return (
<div className="space-y-3 py-4" aria-busy>
{Array.from({ length: 8 }).map((_, index) => (
<div key={index} className="grid grid-cols-6 gap-3">
<div className="skeleton h-4 col-span-2" />
<div className="skeleton h-4" />
<div className="skeleton h-4" />
<div className="skeleton h-4" />
<div className="skeleton h-4" />
</div>
))}
</div>
);
}

View File

@ -0,0 +1,33 @@
import { useEffect, useRef } from "react";
type DebugPayload = Record<string, unknown>;
function isDomainDebugEnabled() {
if (typeof window === "undefined") return false;
return (
window.localStorage.getItem("debug:domain-overview") === "1" ||
new URLSearchParams(window.location.search).get("debugDomain") === "1"
);
}
export function debugDomain(event: string, payload?: DebugPayload) {
if (!isDomainDebugEnabled()) return;
const entry = {
event,
t: Math.round(performance.now()),
...payload,
};
console.info("[domain-debug]", JSON.stringify(entry));
}
export function useDomainRenderDebug(name: string, payload?: DebugPayload) {
const countRef = useRef(0);
countRef.current += 1;
useEffect(() => {
debugDomain(`${name}:render`, {
count: countRef.current,
...payload,
});
});
}

View File

@ -0,0 +1,156 @@
import type {
KeywordsFilterValues,
PageFilterKey,
PagesFilterValues,
} from "@/client/features/domain/types";
import type { DomainSearchParams } from "@/types/schemas/domain";
export const KEYWORD_FILTER_FIELDS = [
"include",
"exclude",
"minTraffic",
"maxTraffic",
"minVol",
"maxVol",
"minCpc",
"maxCpc",
"minKd",
"maxKd",
"minRank",
"maxRank",
] as const satisfies ReadonlyArray<keyof KeywordsFilterValues>;
export const PAGE_FILTER_FIELDS = [
"include",
"exclude",
"minTraffic",
"maxTraffic",
"minVol",
"maxVol",
] as const satisfies ReadonlyArray<keyof PagesFilterValues>;
const PAGE_SEARCH_PARAM_BY_FIELD = {
include: "pInclude",
exclude: "pExclude",
minTraffic: "pMinTraffic",
maxTraffic: "pMaxTraffic",
minVol: "pMinVol",
maxVol: "pMaxVol",
} as const satisfies Record<PageFilterKey, keyof DomainSearchParams>;
type SearchUpdate = Partial<DomainSearchParams>;
type FilterValues = Record<string, string>;
type FilterKey<TValues extends FilterValues> = Extract<keyof TValues, string>;
export function getPageFilterSearchParam(
key: PageFilterKey,
): (typeof PAGE_SEARCH_PARAM_BY_FIELD)[PageFilterKey] {
return PAGE_SEARCH_PARAM_BY_FIELD[key];
}
export function countKeywordFilterConditions(
values: KeywordsFilterValues,
): number {
let n = 0;
for (const term of values.include.split(/[,+]/)) if (term.trim()) n += 1;
for (const term of values.exclude.split(/[,+]/)) if (term.trim()) n += 1;
for (const key of KEYWORD_FILTER_FIELDS) {
if (key === "include" || key === "exclude") continue;
if (values[key].trim() !== "") n += 1;
}
return n;
}
export function countPageFilterConditions(values: PagesFilterValues): number {
return countFilterConditions(values, PAGE_FILTER_FIELDS);
}
export function buildKeywordsSearchUpdate(
values: KeywordsFilterValues,
): SearchUpdate {
return buildFilterSearchUpdate<KeywordsFilterValues>(
values,
KEYWORD_FILTER_FIELDS,
(key) => key,
);
}
export function buildPagesSearchUpdate(
values: PagesFilterValues,
): SearchUpdate {
return buildFilterSearchUpdate<PagesFilterValues>(
values,
PAGE_FILTER_FIELDS,
(key) => getPageFilterSearchParam(key),
);
}
export function buildPagesClearSearchUpdate(): SearchUpdate {
return buildFilterClearSearchUpdate<PagesFilterValues>(
PAGE_FILTER_FIELDS,
(key) => getPageFilterSearchParam(key),
);
}
export function buildDomainFiltersClearSearchUpdate(): SearchUpdate {
const update = buildFilterClearSearchUpdate<KeywordsFilterValues>(
KEYWORD_FILTER_FIELDS,
(key) => key,
);
Object.assign(
update,
buildFilterClearSearchUpdate<PagesFilterValues>(PAGE_FILTER_FIELDS, (key) =>
getPageFilterSearchParam(key),
),
);
return update;
}
function countFilterConditions<TValues extends Record<string, string>>(
values: TValues,
fields: ReadonlyArray<FilterKey<TValues>>,
): number {
let n = 0;
for (const term of values.include.split(/[,+]/)) if (term.trim()) n += 1;
for (const term of values.exclude.split(/[,+]/)) if (term.trim()) n += 1;
for (const key of fields) {
if (key === "include" || key === "exclude") continue;
if (values[key].trim() !== "") n += 1;
}
return n;
}
function buildFilterSearchUpdate<TValues extends FilterValues>(
values: TValues,
fields: ReadonlyArray<FilterKey<TValues>>,
getParam: (key: FilterKey<TValues>) => keyof DomainSearchParams,
): SearchUpdate {
const update: SearchUpdate = { page: undefined };
for (const key of fields) {
const param = getParam(key);
const raw = values[key].trim();
if (raw === "") {
Object.assign(update, { [param]: undefined });
continue;
}
if (key === "include" || key === "exclude") {
Object.assign(update, { [param]: raw });
continue;
}
const parsed = Number(raw);
Object.assign(update, {
[param]: Number.isFinite(parsed) ? parsed : undefined,
});
}
return update;
}
function buildFilterClearSearchUpdate<TValues extends FilterValues>(
fields: ReadonlyArray<FilterKey<TValues>>,
getParam: (key: FilterKey<TValues>) => keyof DomainSearchParams,
): SearchUpdate {
const update: SearchUpdate = { page: undefined };
for (const key of fields)
Object.assign(update, { [getParam(key)]: undefined });
return update;
}

View File

@ -1,174 +0,0 @@
import { useEffect, useMemo, type Dispatch, type SetStateAction } from "react";
import type { UpdateMetaOptions } from "@tanstack/react-form";
import {
getDefaultSortOrder,
toSortMode,
toSortOrder,
} from "@/client/features/domain/utils";
import type {
DomainActiveTab,
DomainFilterValues,
DomainSortMode,
KeywordRow,
SortOrder,
} from "@/client/features/domain/types";
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
export type SearchState = {
domain: string;
subdomains: boolean;
sort: DomainSortMode;
order?: SortOrder;
tab: DomainActiveTab;
search: string;
locationCode: number;
page: number;
pageSize: number;
appliedFilters: DomainFilterValues;
};
type DomainNavigate = (args: {
search: (prev: Record<string, unknown>) => Record<string, unknown>;
replace: boolean;
}) => void;
type DomainControlsFormAccess = {
state: {
values: {
domain: string;
subdomains: boolean;
sort: DomainSortMode;
locationCode: number;
};
};
reset: (values: {
domain: string;
subdomains: boolean;
sort: DomainSortMode;
locationCode: number;
}) => void;
setFieldValue: (
field: "domain" | "subdomains" | "sort" | "locationCode",
updater: string | boolean | number,
opts?: UpdateMetaOptions,
) => void;
};
type ControlsFormLike = DomainControlsFormAccess;
export function useOverviewDataState({
pagedKeywords,
setSelectedKeywords,
activeFilterCount,
}: {
pagedKeywords: KeywordRow[];
setSelectedKeywords: Dispatch<SetStateAction<Set<string>>>;
activeFilterCount: number;
}) {
// Keywords are now fetched server-side with filters/sort/pagination applied,
// so we render whatever the page query returned.
const filteredKeywords = pagedKeywords;
const visibleKeywords = useMemo(
() => filteredKeywords.map((row) => row.keyword),
[filteredKeywords],
);
useEffect(() => {
const visibleSet = new Set(visibleKeywords);
setSelectedKeywords((prev) => {
const next = new Set(
[...prev].filter((keyword) => visibleSet.has(keyword)),
);
if (next.size === prev.size) return prev;
return next;
});
}, [setSelectedKeywords, visibleKeywords]);
return {
filteredKeywords,
visibleKeywords,
activeFilterCount,
toggleKeywordSelection: (keyword: string) => {
setSelectedKeywords((prev) => {
const next = new Set(prev);
if (next.has(keyword)) next.delete(keyword);
else next.add(keyword);
return next;
});
},
toggleAllVisibleKeywords: () => {
setSelectedKeywords((prev) => {
if (
visibleKeywords.length > 0 &&
visibleKeywords.every((keyword) => prev.has(keyword))
) {
return new Set();
}
return new Set(visibleKeywords);
});
},
};
}
export function useSyncRouteState({
controlsForm,
searchState,
navigate,
}: {
controlsForm: ControlsFormLike;
searchState: SearchState;
navigate: DomainNavigate;
}) {
useEffect(() => {
controlsForm.reset({
domain: searchState.domain,
subdomains: searchState.subdomains,
sort: searchState.sort,
locationCode: searchState.locationCode,
});
}, [controlsForm, searchState]);
useEffect(() => {
const raw = new URLSearchParams(window.location.search);
const rawSort = toSortMode(raw.get("sort"));
const rawOrder = toSortOrder(raw.get("order"));
const rawLoc = raw.get("loc");
const shouldNormalize =
raw.get("domain") === "" ||
raw.get("search") === "" ||
raw.get("subdomains") === "true" ||
raw.get("sort") === "rank" ||
(rawOrder != null &&
rawOrder === getDefaultSortOrder(rawSort ?? "rank")) ||
raw.get("tab") === "keywords" ||
rawLoc === String(DEFAULT_LOCATION_CODE);
if (!shouldNormalize) return;
navigate({
search: (prev) => {
const prevSort =
typeof prev.sort === "string" ? toSortMode(prev.sort) : undefined;
return {
...prev,
domain: prev.domain === "" ? undefined : prev.domain,
search: prev.search === "" ? undefined : prev.search,
subdomains: prev.subdomains === true ? undefined : prev.subdomains,
sort: prev.sort === "rank" ? undefined : prev.sort,
order:
prev.order != null &&
prev.order === getDefaultSortOrder(prevSort ?? "rank")
? undefined
: prev.order,
tab: prev.tab === "keywords" ? undefined : prev.tab,
loc:
prev.loc != null && Number(prev.loc) === DEFAULT_LOCATION_CODE
? undefined
: prev.loc,
};
},
replace: true,
});
}, [navigate]);
}

View File

@ -0,0 +1,100 @@
import {
DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE,
type DomainSearchParams,
} from "@/types/schemas/domain";
import {
DEFAULT_LOCATION_CODE,
isSupportedLocationCode,
} from "@/client/features/keywords/locations";
import {
EMPTY_DOMAIN_FILTERS,
type DomainActiveTab,
type DomainFilterValues,
type DomainSortMode,
type KeywordsFilterValues,
type PagesFilterValues,
type SortOrder,
} from "@/client/features/domain/types";
import {
KEYWORD_FILTER_FIELDS,
PAGE_FILTER_FIELDS,
getPageFilterSearchParam,
} from "@/client/features/domain/domainFilterUtils";
import { resolveSortOrder, toSortMode, toSortOrder } from "./utils";
export type DomainOverviewRouteState = {
domain: string;
subdomains: boolean;
sort: DomainSortMode;
order: SortOrder;
tab: DomainActiveTab;
locationCode: number;
page: number;
pageSize: number;
appliedFilters: DomainFilterValues;
appliedPageFilters: PagesFilterValues;
hasAppliedKeywordFilters: boolean;
hasAppliedPageFilters: boolean;
};
function numberToFilterString(value: number | undefined): string {
if (value == null || !Number.isFinite(value)) return "";
return String(value);
}
export function getDomainRouteState(
search: DomainSearchParams,
): DomainOverviewRouteState {
const normalizedSort = toSortMode(search.sort ?? null) ?? "rank";
const normalizedLocationCode =
search.loc != null && isSupportedLocationCode(search.loc)
? search.loc
: DEFAULT_LOCATION_CODE;
return {
domain: search.domain ?? "",
subdomains: search.subdomains ?? true,
sort: normalizedSort,
order: resolveSortOrder(normalizedSort, toSortOrder(search.order ?? null)),
tab: search.tab ?? "keywords",
locationCode: normalizedLocationCode,
page: search.page != null && search.page > 0 ? search.page : 1,
pageSize: search.size ?? DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE,
appliedFilters: {
include: search.include ?? EMPTY_DOMAIN_FILTERS.include,
exclude: search.exclude ?? EMPTY_DOMAIN_FILTERS.exclude,
minTraffic: numberToFilterString(search.minTraffic),
maxTraffic: numberToFilterString(search.maxTraffic),
minVol: numberToFilterString(search.minVol),
maxVol: numberToFilterString(search.maxVol),
minCpc: numberToFilterString(search.minCpc),
maxCpc: numberToFilterString(search.maxCpc),
minKd: numberToFilterString(search.minKd),
maxKd: numberToFilterString(search.maxKd),
minRank: numberToFilterString(search.minRank),
maxRank: numberToFilterString(search.maxRank),
},
appliedPageFilters: {
include: search.pInclude ?? EMPTY_DOMAIN_FILTERS.include,
exclude: search.pExclude ?? EMPTY_DOMAIN_FILTERS.exclude,
minTraffic: numberToFilterString(search.pMinTraffic),
maxTraffic: numberToFilterString(search.pMaxTraffic),
minVol: numberToFilterString(search.pMinVol),
maxVol: numberToFilterString(search.pMaxVol),
},
hasAppliedKeywordFilters: hasKeywordSearchFilters(search),
hasAppliedPageFilters: hasPageSearchFilters(search),
};
}
function hasKeywordSearchFilters(search: DomainSearchParams): boolean {
return KEYWORD_FILTER_FIELDS.some(
(key: keyof KeywordsFilterValues) => search[key] != null,
);
}
function hasPageSearchFilters(search: DomainSearchParams): boolean {
return PAGE_FILTER_FIELDS.some(
(key) => search[getPageFilterSearchParam(key)] != null,
);
}

View File

@ -1,167 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useForm, useStore } from "@tanstack/react-form";
import {
EMPTY_DOMAIN_FILTERS,
type DomainFilterValues,
} from "@/client/features/domain/types";
import { MAX_DATAFORSEO_FILTER_CONDITIONS } from "@/types/schemas/domain";
const FILTER_KEYS: Array<keyof DomainFilterValues> = [
"include",
"exclude",
"minTraffic",
"maxTraffic",
"minVol",
"maxVol",
"minCpc",
"maxCpc",
"minKd",
"maxKd",
"minRank",
"maxRank",
];
function filtersToSearchParams(
values: DomainFilterValues,
): Record<string, string | number | undefined> {
const out: Record<string, string | number | undefined> = {};
for (const key of FILTER_KEYS) {
const trimmed = values[key].trim();
if (trimmed === "") {
out[key] = undefined;
continue;
}
if (key === "include" || key === "exclude") {
out[key] = trimmed;
} else {
const parsed = Number(trimmed);
out[key] = Number.isFinite(parsed) ? parsed : undefined;
}
}
return out;
}
/**
* One include/exclude term per comma. Numeric ranges count as one per bound.
* Mirrors how `buildKeywordFilters` packs the request, plus 2 extra slots
* reserved for the search-box OR-clause when active.
*/
function countConditions(
values: DomainFilterValues,
hasSearch: boolean,
): number {
let n = 0;
for (const term of values.include.split(/[,+]/)) if (term.trim()) n += 1;
for (const term of values.exclude.split(/[,+]/)) if (term.trim()) n += 1;
for (const k of [
"minTraffic",
"maxTraffic",
"minVol",
"maxVol",
"minCpc",
"maxCpc",
"minKd",
"maxKd",
"minRank",
"maxRank",
] as const) {
if (values[k].trim() !== "") n += 1;
}
if (hasSearch) n += 2;
return n;
}
export function useDomainFilters({
appliedValues,
appliedSearch,
setSearchParams,
}: {
appliedValues: DomainFilterValues;
appliedSearch: string;
setSearchParams: (
updates: Record<string, string | number | boolean | undefined>,
) => void;
}) {
const filtersForm = useForm({
defaultValues: appliedValues,
});
const draftValues = useStore(filtersForm.store, (s) => s.values);
const [searchDraft, setSearchDraft] = useState(appliedSearch);
// Keep the draft in sync when applied values change from outside the panel
// (URL navigation, history-select, "back to recent searches"). Without this,
// the form keeps the previous draft and diverges from the URL.
const appliedKey = useMemo(
() => FILTER_KEYS.map((key) => appliedValues[key]).join("|"),
[appliedValues],
);
useEffect(() => {
filtersForm.reset({ ...appliedValues });
// appliedKey covers content changes; filtersForm is a stable ref.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appliedKey]);
useEffect(() => {
setSearchDraft(appliedSearch);
}, [appliedSearch]);
const applyFilters = useCallback(() => {
const trimmedSearch = searchDraft.trim();
setSearchParams({
...filtersToSearchParams(draftValues),
search: trimmedSearch === "" ? undefined : trimmedSearch,
page: undefined,
});
}, [draftValues, searchDraft, setSearchParams]);
const cancelEdits = useCallback(() => {
filtersForm.reset({ ...appliedValues }, { keepDefaultValues: true });
setSearchDraft(appliedSearch);
}, [appliedValues, appliedSearch, filtersForm]);
const resetFilters = useCallback(() => {
filtersForm.reset({ ...EMPTY_DOMAIN_FILTERS }, { keepDefaultValues: true });
setSearchDraft("");
setSearchParams({
...filtersToSearchParams(EMPTY_DOMAIN_FILTERS),
search: undefined,
page: undefined,
});
}, [filtersForm, setSearchParams]);
const activeAppliedCount = useMemo(
() =>
FILTER_KEYS.filter((key) => appliedValues[key].trim() !== "").length +
(appliedSearch.trim() !== "" ? 1 : 0),
[appliedValues, appliedSearch],
);
const dirtyCount = useMemo(() => {
const filterDirt = FILTER_KEYS.filter(
(key) => draftValues[key].trim() !== appliedValues[key].trim(),
).length;
const searchDirt = searchDraft.trim() !== appliedSearch.trim() ? 1 : 0;
return filterDirt + searchDirt;
}, [draftValues, appliedValues, searchDraft, appliedSearch]);
const conditionCount = countConditions(
draftValues,
searchDraft.trim() !== "",
);
const overLimit = conditionCount > MAX_DATAFORSEO_FILTER_CONDITIONS;
return {
filtersForm,
draftValues,
appliedValues,
searchDraft,
setSearchDraft,
activeAppliedCount,
dirtyCount,
conditionCount,
overLimit,
applyFilters,
cancelEdits,
resetFilters,
};
}

View File

@ -1,6 +1,7 @@
import { useMemo } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { getDomainKeywordsPage } from "@/serverFunctions/domain";
import { debugDomain } from "@/client/features/domain/domainDebug";
import type {
DomainFilterValues,
DomainSortMode,
@ -18,7 +19,6 @@ type DomainKeywordsQueryInput = {
sortMode: DomainSortMode;
sortOrder: SortOrder;
appliedFilters: DomainFilterValues;
searchTerm: string;
enabled: boolean;
};
@ -53,11 +53,8 @@ export function useDomainKeywordsQuery(input: DomainKeywordsQueryInput) {
() => toFiltersPayload(input.appliedFilters),
[input.appliedFilters],
);
const trimmedSearch = input.searchTerm.trim();
return useQuery({
enabled: input.enabled && Boolean(input.domain),
queryKey: [
const queryKey = useMemo(
() => [
"domain-keywords",
input.projectId,
input.domain,
@ -69,8 +66,31 @@ export function useDomainKeywordsQuery(input: DomainKeywordsQueryInput) {
input.sortMode,
input.sortOrder,
filtersPayload,
trimmedSearch || undefined,
],
[
filtersPayload,
input.domain,
input.includeSubdomains,
input.languageCode,
input.locationCode,
input.page,
input.pageSize,
input.projectId,
input.sortMode,
input.sortOrder,
],
);
useEffect(() => {
debugDomain("useDomainKeywordsQuery:key", {
queryKey,
enabled: input.enabled && Boolean(input.domain),
});
}, [input.domain, input.enabled, queryKey]);
const query = useQuery({
enabled: input.enabled && Boolean(input.domain),
queryKey,
queryFn: () =>
getDomainKeywordsPage({
data: {
@ -84,10 +104,22 @@ export function useDomainKeywordsQuery(input: DomainKeywordsQueryInput) {
sortMode: input.sortMode,
sortOrder: input.sortOrder,
filters: filtersPayload,
search: trimmedSearch || undefined,
},
}),
placeholderData: keepPreviousData,
staleTime: 60_000,
});
useEffect(() => {
debugDomain("useDomainKeywordsQuery:state", {
status: query.status,
fetchStatus: query.fetchStatus,
isFetching: query.isFetching,
rows: query.data?.keywords.length ?? 0,
});
}, [
query.data?.keywords.length,
query.fetchStatus,
query.isFetching,
query.status,
]);
return query;
}

View File

@ -1,7 +1,13 @@
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { getDomainPagesPage } from "@/serverFunctions/domain";
import { debugDomain } from "@/client/features/domain/domainDebug";
import { toPageSortMode } from "@/client/features/domain/utils";
import type { DomainSortMode, SortOrder } from "@/client/features/domain/types";
import type {
DomainSortMode,
PagesFilterValues,
SortOrder,
} from "@/client/features/domain/types";
type DomainPagesQueryInput = {
projectId: string;
@ -13,17 +19,14 @@ type DomainPagesQueryInput = {
pageSize: number;
sortMode: DomainSortMode;
sortOrder: SortOrder;
searchTerm: string;
appliedFilters: PagesFilterValues;
enabled: boolean;
};
export function useDomainPagesQuery(input: DomainPagesQueryInput) {
const trimmedSearch = input.searchTerm.trim();
const pageSortMode = toPageSortMode(input.sortMode);
return useQuery({
enabled: input.enabled && Boolean(input.domain),
queryKey: [
const queryKey = useMemo(
() => [
"domain-pages",
input.projectId,
input.domain,
@ -34,8 +37,32 @@ export function useDomainPagesQuery(input: DomainPagesQueryInput) {
input.pageSize,
pageSortMode,
input.sortOrder,
trimmedSearch || undefined,
input.appliedFilters,
],
[
input.appliedFilters,
input.domain,
input.includeSubdomains,
input.languageCode,
input.locationCode,
input.page,
input.pageSize,
input.projectId,
input.sortOrder,
pageSortMode,
],
);
useEffect(() => {
debugDomain("useDomainPagesQuery:key", {
queryKey,
enabled: input.enabled && Boolean(input.domain),
});
}, [input.domain, input.enabled, queryKey]);
const query = useQuery({
enabled: input.enabled && Boolean(input.domain),
queryKey,
queryFn: () =>
getDomainPagesPage({
data: {
@ -48,10 +75,23 @@ export function useDomainPagesQuery(input: DomainPagesQueryInput) {
pageSize: input.pageSize,
sortMode: pageSortMode,
sortOrder: input.sortOrder,
search: trimmedSearch || undefined,
filters: input.appliedFilters,
},
}),
placeholderData: keepPreviousData,
staleTime: 60_000,
});
useEffect(() => {
debugDomain("useDomainPagesQuery:state", {
status: query.status,
fetchStatus: query.fetchStatus,
isFetching: query.isFetching,
rows: query.data?.pages.length ?? 0,
});
}, [
query.data?.pages.length,
query.fetchStatus,
query.isFetching,
query.status,
]);
return query;
}

View File

@ -46,6 +46,15 @@ export const EMPTY_DOMAIN_FILTERS: DomainFilterValues = {
maxRank: "",
};
export type KeywordsFilterValues = DomainFilterValues;
export type PagesFilterValues = Pick<
DomainFilterValues,
"include" | "exclude" | "minTraffic" | "maxTraffic" | "minVol" | "maxVol"
>;
export type PageFilterKey = keyof PagesFilterValues;
export type DomainControlsValues = {
domain: string;
subdomains: boolean;
@ -57,15 +66,6 @@ export type DomainSortMode = DomainControlsValues["sort"];
export type SortOrder = "asc" | "desc";
export type DomainActiveTab = "keywords" | "pages";
export type DomainOverviewData = {
domain: string;
organicTraffic: number | null;
organicKeywords: number | null;
backlinks: number | null;
referringDomains: number | null;
hasData: boolean;
};
export type DomainHistoryItem = {
timestamp: number;
domain: string;

View File

@ -1,138 +0,0 @@
import { useCallback, type FormEvent } from "react";
import {
getDefaultSortOrder,
toSortOrderSearchParam,
toSortSearchParam,
} from "@/client/features/domain/utils";
import type {
DomainControlsValues,
DomainSortMode,
SortOrder,
} from "@/client/features/domain/types";
import { saveSelectedKeywords } from "@/client/features/domain/domainActions";
import type { useSaveKeywordsMutation } from "@/client/features/domain/mutations";
import type {
SearchState,
useOverviewDataState,
} from "@/client/features/domain/domainOverviewControllerInternals";
import type { DomainSearchHistoryItem } from "@/client/hooks/useDomainSearchHistory";
import {
DEFAULT_LOCATION_CODE,
getLanguageCode,
isSupportedLocationCode,
} from "@/client/features/keywords/locations";
type DomainControlsFormApi = {
state: { values: DomainControlsValues };
handleSubmit: () => Promise<unknown>;
reset: (values: DomainControlsValues) => void;
setFieldValue: (
field: keyof DomainControlsValues,
value: string | boolean | number,
) => void;
};
export function useDomainControllerHandlers({
controlsForm,
currentSortOrder,
currentState,
dataState,
projectId,
saveMutation,
selectedKeywords,
setSearchParams,
}: {
controlsForm: DomainControlsFormApi;
currentSortOrder: SortOrder;
currentState: SearchState;
dataState: ReturnType<typeof useOverviewDataState>;
projectId: string;
saveMutation: ReturnType<typeof useSaveKeywordsMutation>;
selectedKeywords: Set<string>;
setSearchParams: (
updates: Record<string, string | number | boolean | undefined>,
) => void;
}) {
const applySort = useCallback(
(nextSort: DomainSortMode, nextOrder: SortOrder) => {
controlsForm.setFieldValue("sort", nextSort);
setSearchParams({
sort: toSortSearchParam(nextSort),
order: toSortOrderSearchParam(nextSort, nextOrder),
page: undefined,
});
},
[controlsForm, setSearchParams],
);
const applyLocationChange = useCallback(
(nextLocationCode: number) => {
if (!isSupportedLocationCode(nextLocationCode)) return;
controlsForm.setFieldValue("locationCode", nextLocationCode);
setSearchParams({
loc:
nextLocationCode === DEFAULT_LOCATION_CODE
? undefined
: nextLocationCode,
});
},
[controlsForm, setSearchParams],
);
const handleSortColumnClick = useCallback(
(nextSort: DomainSortMode) => {
const nextOrder =
nextSort === currentState.sort
? currentSortOrder === "asc"
? "desc"
: "asc"
: getDefaultSortOrder(nextSort);
applySort(nextSort, nextOrder);
},
[applySort, currentSortOrder, currentState.sort],
);
const handleSaveKeywords = () => {
saveSelectedKeywords({
selectedKeywords,
filteredKeywords: dataState.filteredKeywords,
save: saveMutation.mutate,
projectId,
locationCode: currentState.locationCode,
languageCode: getLanguageCode(currentState.locationCode),
});
};
const handleHistorySelect = (item: DomainSearchHistoryItem) => {
const historyLocation =
item.locationCode != null && isSupportedLocationCode(item.locationCode)
? item.locationCode
: DEFAULT_LOCATION_CODE;
setSearchParams({
domain: item.domain,
subdomains: item.subdomains ? undefined : false,
sort: toSortSearchParam(item.sort),
order: undefined,
tab: item.tab === "keywords" ? undefined : item.tab,
search: item.search?.trim() || undefined,
loc:
historyLocation === DEFAULT_LOCATION_CODE ? undefined : historyLocation,
page: undefined,
size: undefined,
});
};
const handleSearchSubmit = (event: FormEvent) => {
event.preventDefault();
void controlsForm.handleSubmit();
};
return {
applySort,
applyLocationChange,
handleSortColumnClick,
handleSaveKeywords,
handleSearchSubmit,
handleHistorySelect,
};
}

View File

@ -0,0 +1,157 @@
import { useCallback, useEffect, useState } from "react";
import {
EMPTY_DOMAIN_FILTERS,
type KeywordsFilterValues,
type PagesFilterValues,
} from "@/client/features/domain/types";
import {
KEYWORD_FILTER_FIELDS,
PAGE_FILTER_FIELDS,
} from "@/client/features/domain/domainFilterUtils";
const STORAGE_KEY_PREFIX = "domain-overview-filter-defaults:";
type DomainFilterPreferenceTab = "keywords" | "pages";
type FilterValues = Record<string, string>;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function hasAnyFilter(values: FilterValues): boolean {
return Object.values(values).some((value) => value.trim() !== "");
}
function getStorageKey(tab: DomainFilterPreferenceTab, scope: string): string {
return `${STORAGE_KEY_PREFIX}${scope}:${tab}`;
}
function loadFromStorage<T extends FilterValues>(
tab: DomainFilterPreferenceTab,
scope: string,
fallback: T,
): T {
const result = { ...fallback };
if (typeof window === "undefined") return result;
try {
const raw = window.localStorage.getItem(getStorageKey(tab, scope));
if (!raw) return result;
const parsed: unknown = JSON.parse(raw);
if (!isRecord(parsed)) return result;
for (const key in fallback) {
const value = parsed[key];
if (typeof value === "string") {
Object.assign(result, { [key]: value });
}
}
} catch {
// localStorage can be unavailable in private browsing or strict modes.
}
return result;
}
function saveToStorage(
tab: DomainFilterPreferenceTab,
scope: string,
values: FilterValues,
) {
if (typeof window === "undefined") return;
try {
const key = getStorageKey(tab, scope);
if (hasAnyFilter(values)) {
window.localStorage.setItem(key, JSON.stringify(values));
} else {
window.localStorage.removeItem(key);
}
} catch {
// storage full or unavailable
}
}
function clearStorage(tab: DomainFilterPreferenceTab, scope: string) {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(getStorageKey(tab, scope));
} catch {
// storage unavailable
}
}
function emptyPageFilters(): PagesFilterValues {
return {
include: EMPTY_DOMAIN_FILTERS.include,
exclude: EMPTY_DOMAIN_FILTERS.exclude,
minTraffic: EMPTY_DOMAIN_FILTERS.minTraffic,
maxTraffic: EMPTY_DOMAIN_FILTERS.maxTraffic,
minVol: EMPTY_DOMAIN_FILTERS.minVol,
maxVol: EMPTY_DOMAIN_FILTERS.maxVol,
};
}
function loadKeywordFilters(scope: string): KeywordsFilterValues {
return loadFromStorage("keywords", scope, { ...EMPTY_DOMAIN_FILTERS });
}
function loadPageFilters(scope: string): PagesFilterValues {
return loadFromStorage("pages", scope, emptyPageFilters());
}
export function useDomainKeywordFilterPreferences(scope: string) {
const [filters, setFilters] = useState<KeywordsFilterValues>(() =>
loadKeywordFilters(scope),
);
useEffect(() => {
setFilters(loadKeywordFilters(scope));
}, [scope]);
const save = useCallback(
(values: KeywordsFilterValues) => {
const next = { ...EMPTY_DOMAIN_FILTERS };
for (const key of KEYWORD_FILTER_FIELDS) next[key] = values[key];
saveToStorage("keywords", scope, next);
setFilters(next);
},
[scope],
);
const clear = useCallback(() => {
clearStorage("keywords", scope);
setFilters({ ...EMPTY_DOMAIN_FILTERS });
}, [scope]);
return { filters, save, clear };
}
export function useDomainPageFilterPreferences(scope: string) {
const [filters, setFilters] = useState<PagesFilterValues>(() =>
loadPageFilters(scope),
);
useEffect(() => {
setFilters(loadPageFilters(scope));
}, [scope]);
const save = useCallback(
(values: PagesFilterValues) => {
const next = emptyPageFilters();
for (const key of PAGE_FILTER_FIELDS) next[key] = values[key];
saveToStorage("pages", scope, next);
setFilters(next);
},
[scope],
);
const clear = useCallback(() => {
clearStorage("pages", scope);
setFilters(emptyPageFilters());
}, [scope]);
return { filters, save, clear };
}

View File

@ -1,329 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useForm } from "@tanstack/react-form";
import { type QueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useDomainSearchHistory } from "@/client/hooks/useDomainSearchHistory";
import {
normalizeDomainTarget,
resolveSortOrder,
toSortOrderSearchParam,
toSortSearchParam,
} from "@/client/features/domain/utils";
import {
createFormValidationErrors,
shouldValidateFieldOnChange,
} from "@/client/lib/forms";
import { captureClientEvent } from "@/client/lib/posthog";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import type { KeywordRow, PageRow } from "@/client/features/domain/types";
import { useSaveKeywordsMutation } from "@/client/features/domain/mutations";
import { useDomainFilters } from "@/client/features/domain/hooks/useDomainFilters";
import { useDomainKeywordsQuery } from "@/client/features/domain/hooks/useDomainKeywordsQuery";
import { useDomainOverviewQuery } from "@/client/features/domain/hooks/useDomainOverviewQuery";
import { useDomainPagesQuery } from "@/client/features/domain/hooks/useDomainPagesQuery";
import {
getDomainSearchChangeValidationErrors,
getDomainSearchValidationErrors,
} from "@/client/features/domain/domainSearchValidation";
import { useDomainControllerHandlers } from "@/client/features/domain/useDomainControllerHandlers";
import {
useOverviewDataState,
useSyncRouteState,
type SearchState,
} from "@/client/features/domain/domainOverviewControllerInternals";
import {
DEFAULT_LOCATION_CODE,
getLanguageCode,
} from "@/client/features/keywords/locations";
import { DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE } from "@/types/schemas/domain";
type Params = {
projectId: string;
queryClient: QueryClient;
navigate: (args: {
search: (prev: Record<string, unknown>) => Record<string, unknown>;
replace: boolean;
}) => void;
searchState: SearchState;
};
export function useDomainOverviewController({
projectId,
queryClient,
navigate,
searchState,
}: Params) {
const [selectedKeywords, setSelectedKeywords] = useState<Set<string>>(
new Set(),
);
const [showFilters, setShowFilters] = useState(false);
const {
history,
isLoaded: historyLoaded,
addSearch,
removeHistoryItem,
} = useDomainSearchHistory(projectId);
const currentSortOrder = resolveSortOrder(
searchState.sort,
searchState.order,
);
const setSearchParams = useCallback(
(updates: Record<string, string | number | boolean | undefined>) => {
navigate({
search: (prev) => ({ ...prev, ...updates }),
replace: true,
});
},
[navigate],
);
const domainFilters = useDomainFilters({
appliedValues: searchState.appliedFilters,
appliedSearch: searchState.search,
setSearchParams,
});
const overviewLanguageCode = getLanguageCode(searchState.locationCode);
const overviewQuery = useDomainOverviewQuery({
projectId,
domain: searchState.domain,
includeSubdomains: searchState.subdomains,
locationCode: searchState.locationCode,
languageCode: overviewLanguageCode,
});
const overview = overviewQuery.data ?? null;
const controlsForm = useForm({
defaultValues: {
domain: searchState.domain,
subdomains: searchState.subdomains,
sort: searchState.sort,
locationCode: searchState.locationCode,
},
validators: {
onChange: ({ formApi, value }) =>
getDomainSearchChangeValidationErrors(
value,
shouldValidateFieldOnChange(formApi, "domain"),
formApi.state.submissionAttempts > 0,
),
onSubmit: ({ value }) => getDomainSearchValidationErrors(value),
},
onSubmit: ({ formApi, value }) => {
const target = normalizeDomainTarget(value.domain);
if (!target) return;
formApi.setFieldValue("domain", target);
setSearchParams({
domain: target,
subdomains: value.subdomains ? undefined : false,
sort: toSortSearchParam(value.sort),
order: toSortOrderSearchParam(value.sort, currentSortOrder),
tab: searchState.tab === "keywords" ? undefined : searchState.tab,
loc:
value.locationCode === DEFAULT_LOCATION_CODE
? undefined
: value.locationCode,
page: undefined,
size: undefined,
});
},
});
useSyncRouteState({ controlsForm, searchState, navigate });
const saveMutation = useSaveKeywordsMutation({ projectId, queryClient });
// Surface overview-query errors through the form's submit error map so the
// existing error UI keeps working without a parallel error channel.
useEffect(() => {
controlsForm.setErrorMap({
onSubmit: overviewQuery.error
? createFormValidationErrors({
form: getStandardErrorMessage(
overviewQuery.error,
"Lookup failed.",
),
})
: undefined,
});
}, [controlsForm, overviewQuery.error]);
// History + analytics + "no data" toast: fire once per successful overview
// fetch, keyed on the inputs that actually trigger a refetch.
const lastTrackedKey = useRef<string>("");
useEffect(() => {
if (!overviewQuery.isSuccess || !overviewQuery.data) return;
const key = `${searchState.domain}|${searchState.subdomains}|${searchState.locationCode}`;
if (lastTrackedKey.current === key) return;
lastTrackedKey.current = key;
const data = overviewQuery.data;
captureClientEvent("domain_overview:search_complete", {
sort_mode: searchState.sort,
include_subdomains: searchState.subdomains,
result_count: data.organicKeywords ?? 0,
location_code: searchState.locationCode,
});
addSearch({
domain: searchState.domain,
subdomains: searchState.subdomains,
sort: searchState.sort,
tab: searchState.tab,
search: searchState.search.trim() || undefined,
locationCode: searchState.locationCode,
});
if (!data.hasData) {
toast.info("Not enough data for this domain");
}
setSelectedKeywords(new Set());
}, [
overviewQuery.isSuccess,
overviewQuery.data,
searchState.domain,
searchState.subdomains,
searchState.locationCode,
searchState.sort,
searchState.tab,
searchState.search,
addSearch,
]);
// Reset transient panel state when the user navigates back to recent searches.
useEffect(() => {
if (searchState.domain.trim() === "") {
setShowFilters(false);
setSelectedKeywords(new Set());
lastTrackedKey.current = "";
}
}, [searchState.domain]);
const keywordsTabActive = searchState.tab === "keywords";
const pagesTabActive = searchState.tab === "pages";
const keywordsQuery = useDomainKeywordsQuery({
projectId,
domain: overview?.domain ?? "",
includeSubdomains: searchState.subdomains,
locationCode: searchState.locationCode,
languageCode: overviewLanguageCode,
page: searchState.page,
pageSize: searchState.pageSize,
sortMode: searchState.sort,
sortOrder: currentSortOrder,
appliedFilters: searchState.appliedFilters,
searchTerm: searchState.search,
enabled: overview !== null && overview.hasData && keywordsTabActive,
});
const pagesQuery = useDomainPagesQuery({
projectId,
domain: overview?.domain ?? "",
includeSubdomains: searchState.subdomains,
locationCode: searchState.locationCode,
languageCode: overviewLanguageCode,
page: searchState.page,
pageSize: searchState.pageSize,
sortMode: searchState.sort,
sortOrder: currentSortOrder,
searchTerm: searchState.search,
enabled: overview !== null && overview.hasData && pagesTabActive,
});
const pagedKeywords: KeywordRow[] = keywordsQuery.data?.keywords ?? [];
const totalKeywordCount =
keywordsQuery.data?.totalCount ?? overview?.organicKeywords ?? null;
const pagedPages: PageRow[] = pagesQuery.data?.pages ?? [];
const totalPagesCount = pagesQuery.data?.totalCount ?? null;
const dataState = useOverviewDataState({
pagedKeywords,
setSelectedKeywords,
activeFilterCount: domainFilters.activeAppliedCount,
});
const handlers = useDomainControllerHandlers({
controlsForm,
currentSortOrder,
currentState: searchState,
dataState,
projectId,
saveMutation,
selectedKeywords,
setSearchParams,
});
const canSaveKeywords =
controlsForm.state.values.locationCode === searchState.locationCode &&
overview !== null &&
overview.hasData;
const goToPage = useCallback(
(nextPage: number) => {
const safe = Math.max(1, Math.floor(nextPage));
setSearchParams({ page: safe === 1 ? undefined : safe });
},
[setSearchParams],
);
const setPageSize = useCallback(
(nextSize: number) => {
setSearchParams({
size:
nextSize === DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE ? undefined : nextSize,
page: undefined,
});
},
[setSearchParams],
);
// Treat the page as loading until the active tab's first fetch resolves —
// otherwise the table area would render an empty shell with a spinner while
// we wait on DataForSEO. `isLoading` is true only on the very first fetch
// (subsequent paginations keep prior data via keepPreviousData).
const activeTabFirstFetch =
(keywordsTabActive && keywordsQuery.isLoading) ||
(pagesTabActive && pagesQuery.isLoading);
const isLoading = overviewQuery.isLoading || activeTabFirstFetch;
return {
controlsForm,
isLoading,
overview,
canSaveKeywords,
history,
historyLoaded,
removeHistoryItem,
searchDraft: domainFilters.searchDraft,
setSearchDraft: domainFilters.setSearchDraft,
selectedKeywords,
setSelectedKeywords,
currentSortOrder,
setSearchParams,
showFilters,
setShowFilters,
filtersForm: domainFilters.filtersForm,
resetFilters: domainFilters.resetFilters,
applyFilters: domainFilters.applyFilters,
cancelFilterEdits: domainFilters.cancelEdits,
dirtyFilterCount: domainFilters.dirtyCount,
conditionCount: domainFilters.conditionCount,
overLimit: domainFilters.overLimit,
keywordsLoading: keywordsQuery.isFetching,
keywordsError: keywordsQuery.error,
pagesLoading: pagesQuery.isFetching,
pagesError: pagesQuery.error,
page: searchState.page,
pageSize: searchState.pageSize,
totalKeywordCount,
totalPagesCount,
hasNextKeywordsPage: keywordsQuery.data?.hasMore ?? false,
hasNextPagesPage: pagesQuery.data?.hasMore ?? false,
pagedPages,
goToPage,
setPageSize,
...handlers,
...dataState,
};
}

View File

@ -80,10 +80,9 @@ export function formatNumber(value: number | null | undefined) {
return new Intl.NumberFormat().format(value);
}
export function formatFloat(value: number | null | undefined) {
export function formatRounded(value: number | null | undefined) {
if (value == null) return "-";
if (value > 100) return new Intl.NumberFormat().format(Math.round(value));
return value.toFixed(2);
return new Intl.NumberFormat().format(Math.round(value));
}
export function formatMetric(
@ -91,7 +90,7 @@ export function formatMetric(
hasData: boolean | undefined,
) {
if (!hasData) return "Not enough data";
return formatNumber(value);
return formatRounded(value);
}
type ExportTable = { headers: string[]; rows: (string | number | null)[][] };

View File

@ -13,17 +13,14 @@ import {
import { useKeywordResearchController } from "@/client/features/keywords/state/useKeywordResearchController";
import type { KeywordResearchControllerInput } from "@/client/features/keywords/state/useKeywordResearchController";
import type { KeywordControlsValues } from "@/client/features/keywords/hooks/useKeywordControlsForm";
import {
parseKeywordInput,
buildKeywordSearchKey,
} from "@/client/features/keywords/state/keywordControllerActions";
import { parseKeywordInput } from "@/client/features/keywords/state/keywordControllerActions";
import { useKeywordSearchParams } from "@/client/features/keywords/state/keywordControllerInternals";
import {
getKeywordTabsSnapshot,
useKeywordTabs,
type OpenTabInput,
} from "@/client/features/keywords/state/useKeywordTabs";
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
import type {
KeywordSearchTabInput,
SearchTab,
} from "@/client/features/search-tabs/types";
import { useSearchTabNavigation } from "@/client/features/search-tabs/useSearchTabNavigation";
import { KeywordResearchEmptyState } from "./KeywordResearchEmptyState";
import { KeywordResearchLoadingState } from "./KeywordResearchLoadingState";
import { KeywordResearchResults } from "./KeywordResearchResults";
@ -32,16 +29,19 @@ import { KeywordResearchTabStrip } from "./KeywordResearchTabStrip";
import type { KeywordResearchControllerState } from "./types";
type Props = Omit<KeywordResearchControllerInput, "onFormSubmit">;
type KeywordSearchTab = SearchTab & { input: KeywordSearchTabInput };
function isKeywordSearchTab(tab: SearchTab): tab is KeywordSearchTab {
return tab.input.type === "keyword";
}
export function KeywordResearchPage(input: Props) {
const tabs = useKeywordTabs(input.projectId);
const { openTabs, setActiveTab, findMatchingTab } = tabs;
const setSearchParams = useKeywordSearchParams();
const projectId = input.projectId;
const setSearchParamsForTab = useCallback(
(tab: OpenTabInput | null) => {
if (!tab) {
const navigateToKeywordInput = useCallback(
(tabInput: KeywordSearchTabInput | null) => {
if (!tabInput) {
setSearchParams({
q: undefined,
loc: undefined,
@ -52,23 +52,24 @@ export function KeywordResearchPage(input: Props) {
}
setSearchParams({
q: tab.keyword,
q: tabInput.keyword,
loc:
tab.locationCode === DEFAULT_LOCATION_CODE
tabInput.locationCode === DEFAULT_LOCATION_CODE
? undefined
: tab.locationCode,
kLimit: tab.resultLimit === 150 ? undefined : tab.resultLimit,
mode: tab.mode === "auto" ? undefined : tab.mode,
: tabInput.locationCode,
kLimit: tabInput.resultLimit === 150 ? undefined : tabInput.resultLimit,
mode: tabInput.mode === "auto" ? undefined : tabInput.mode,
});
},
[setSearchParams],
);
const urlInput = useMemo<OpenTabInput | null>(() => {
const urlInput = useMemo<KeywordSearchTabInput | null>(() => {
const keywords = parseKeywordInput(input.keywordInput);
const keyword = keywords[0];
if (!keyword) return null;
return {
type: "keyword",
keyword,
locationCode: input.locationCode,
resultLimit: input.resultLimit,
@ -80,107 +81,72 @@ export function KeywordResearchPage(input: Props) {
input.locationCode,
input.resultLimit,
]);
const currentUrlKey = useMemo(
() =>
buildKeywordSearchKey({
keyword: input.keywordInput,
locationCode: input.locationCode,
resultLimit: input.resultLimit,
mode: input.keywordMode,
}),
[
input.keywordInput,
input.keywordMode,
input.locationCode,
input.resultLimit,
],
);
const searchTabs = useSearchTabNavigation({
storageKey: `keyword:${projectId}`,
urlInput,
getLabel: useCallback(
(tabInput) => (tabInput.type === "keyword" ? tabInput.keyword : ""),
[],
),
navigateToInput: useCallback(
(tabInput) => {
navigateToKeywordInput(tabInput?.type === "keyword" ? tabInput : null);
},
[navigateToKeywordInput],
),
});
// Effect: URL → activeTab. When the URL params resolve to a tab we already
// have, focus it. Otherwise create one matching the URL (handles deep links
// and back/forward navigation).
useEffect(() => {
if (!urlInput) {
if (getKeywordTabsSnapshot(projectId).activeTabId !== null) {
setActiveTab(null);
}
return;
}
const existing = findMatchingTab(urlInput);
if (existing) {
if (getKeywordTabsSnapshot(projectId).activeTabId !== existing.id) {
setActiveTab(existing.id);
}
return;
}
openTabs([urlInput]);
}, [urlInput, projectId, openTabs, setActiveTab, findMatchingTab]);
// Effect: activeTab → URL. After user actions (click tab, close tab, open
// tabs from a multi-keyword submit) the active tab can diverge from the URL.
// Re-align the URL so the controller below keeps reading the right query.
const activeTab = tabs.activeTab;
const activeTabUrlKey = useMemo(
() =>
activeTab
? buildKeywordSearchKey({
keyword: activeTab.keyword,
locationCode: activeTab.locationCode,
resultLimit: activeTab.resultLimit,
mode: activeTab.mode,
})
: null,
[activeTab],
);
useEffect(() => {
if (!activeTab) return;
if (currentUrlKey === activeTabUrlKey) return;
setSearchParamsForTab(activeTab);
}, [
activeTab,
activeTab?.id,
activeTab?.keyword,
activeTab?.locationCode,
activeTab?.resultLimit,
activeTab?.mode,
activeTabUrlKey,
currentUrlKey,
setSearchParamsForTab,
]);
const activeTab = useMemo<KeywordSearchTab | null>(() => {
if (!urlInput) return null;
const tab = searchTabs.tabs.find(
(candidate) => candidate.id === searchTabs.activeTabId,
);
return tab && isKeywordSearchTab(tab) ? tab : null;
}, [searchTabs.activeTabId, searchTabs.tabs, urlInput]);
const onFormSubmit = useCallback(
(value: KeywordControlsValues) => {
const keywords = parseKeywordInput(value.keyword);
if (keywords.length === 0) return;
const inputs: OpenTabInput[] = keywords.map((keyword) => ({
const inputs: KeywordSearchTabInput[] = keywords.map((keyword) => ({
type: "keyword",
keyword,
locationCode: value.locationCode,
resultLimit: value.resultLimit,
mode: value.mode,
}));
const result = openTabs(inputs);
if (result.activeTab) setSearchParamsForTab(result.activeTab);
},
[openTabs, setSearchParamsForTab],
);
const closeTab = useCallback(
(tabId: string) => {
const result = tabs.closeTab(tabId);
if (result.closedActive) {
setSearchParamsForTab(result.nextActiveTab);
let activeInput: KeywordSearchTabInput | null = null;
for (const tabInput of inputs) {
const result = searchTabs.openTab(tabInput);
if (result.tab?.input.type === "keyword") {
activeInput = result.tab.input;
}
}
if (activeInput) navigateToKeywordInput(activeInput);
},
[setSearchParamsForTab, tabs],
[navigateToKeywordInput, searchTabs],
);
const showRecentSearches = useCallback(() => {
searchTabs.setActiveTab(null);
navigateToKeywordInput(null);
}, [navigateToKeywordInput, searchTabs]);
const getOpenKeywordTabs = useCallback(
() => getKeywordTabsSnapshot(projectId).tabs,
[projectId],
() =>
searchTabs.tabs.flatMap((tab) =>
tab.input.type === "keyword"
? [
{
keyword: tab.input.keyword,
locationCode: tab.input.locationCode,
resultLimit: tab.input.resultLimit,
mode: tab.input.mode,
},
]
: [],
),
[searchTabs.tabs],
);
const controllerInput = useMemo<Props>(
@ -188,20 +154,20 @@ export function KeywordResearchPage(input: Props) {
activeTab
? {
...input,
keywordInput: activeTab.keyword,
locationCode: activeTab.locationCode,
keywordInput: activeTab.input.keyword,
locationCode: activeTab.input.locationCode,
hasExplicitLocationCode: true,
resultLimit: activeTab.resultLimit,
keywordMode: activeTab.mode,
resultLimit: activeTab.input.resultLimit,
keywordMode: activeTab.input.mode,
getOpenKeywordTabs,
keywordTabsLimit: tabs.limit,
keywordTabsLimit: searchTabs.limit,
}
: {
...input,
getOpenKeywordTabs,
keywordTabsLimit: tabs.limit,
keywordTabsLimit: searchTabs.limit,
},
[activeTab, getOpenKeywordTabs, input, tabs.limit],
[activeTab, getOpenKeywordTabs, input, searchTabs.limit],
);
const controller = useKeywordResearchController({
...controllerInput,
@ -220,7 +186,7 @@ export function KeywordResearchPage(input: Props) {
onSubmit: undefined,
},
}));
}, [controller.controlsForm, tabs.tabs]);
}, [controller.controlsForm, searchTabs.tabs]);
// Mark the active tab as viewed once its data lands. Reads cache state via
// the same query key the controller uses, so this catches both fresh fetches
@ -230,10 +196,10 @@ export function KeywordResearchPage(input: Props) {
activeTab
? buildKeywordResearchRequest({
projectId,
keywordInput: activeTab.keyword,
locationCode: activeTab.locationCode,
resultLimit: activeTab.resultLimit,
mode: activeTab.mode,
keywordInput: activeTab.input.keyword,
locationCode: activeTab.input.locationCode,
resultLimit: activeTab.input.resultLimit,
mode: activeTab.input.mode,
})
: null,
[activeTab, projectId],
@ -249,7 +215,7 @@ export function KeywordResearchPage(input: Props) {
gcTime: KEYWORD_RESEARCH_STALE_TIME_MS,
});
const markTabViewed = tabs.markTabViewed;
const markTabViewed = searchTabs.markTabViewed;
useEffect(() => {
if (!activeTab) return;
if (!activeTabQuery.isSuccess) return;
@ -279,25 +245,21 @@ export function KeywordResearchPage(input: Props) {
<KeywordResearchSearchBar controller={controller} />
{controller.hasSearched ? (
<div className="flex flex-col gap-2">
<Link
from="/p/$projectId/keywords"
to="/p/$projectId/keywords"
params={{ projectId }}
search={{}}
replace
<button
type="button"
data-testid="keyword-research-recent-searches"
className="btn btn-ghost btn-sm w-fit gap-2 px-0 text-base-content/70 hover:bg-transparent"
onClick={() => {
setActiveTab(null);
setSearchParamsForTab(null);
}}
onClick={showRecentSearches}
>
<ArrowLeft className="size-4" />
Recent searches
</Link>
</button>
<KeywordResearchTabStrip
projectId={projectId}
tabs={tabs}
closeTab={closeTab}
tabs={searchTabs.tabs}
activeTabId={searchTabs.activeTabId}
onSelect={searchTabs.selectTab}
onClose={searchTabs.closeTab}
/>
</div>
) : null}

View File

@ -8,23 +8,30 @@ import {
buildKeywordResearchRequest,
keywordResearchQueryFn,
} from "@/client/features/keywords/hooks/useKeywordResearchData";
import type { UseKeywordTabsReturn } from "@/client/features/keywords/state/useKeywordTabs";
type Props = {
projectId: string;
tabs: UseKeywordTabsReturn;
closeTab: (tabId: string) => void;
tabs: SearchTab[];
activeTabId: string | null;
onSelect: (tab: SearchTab) => void;
onClose: (tabId: string) => void;
};
export function KeywordResearchTabStrip({ projectId, tabs, closeTab }: Props) {
if (tabs.tabs.length === 0) return null;
export function KeywordResearchTabStrip({
projectId,
tabs,
activeTabId,
onSelect,
onClose,
}: Props) {
if (tabs.length === 0) return null;
return (
<SearchTabStrip
activeTabId={tabs.activeTabId}
tabs={tabs.tabs}
onSelect={(tab) => tabs.setActiveTab(tab.id)}
onClose={closeTab}
activeTabId={activeTabId}
tabs={tabs}
onSelect={onSelect}
onClose={onClose}
renderLeading={(tab, active) => (
<KeywordTabStatus tab={tab} projectId={projectId} active={active} />
)}

View File

@ -14,7 +14,6 @@ import {
type KeywordMode,
type ResultLimit,
} from "@/client/features/keywords/keywordResearchTypes";
import type { OpenTabInput } from "@/client/features/keywords/state/useKeywordTabs";
import type { KeywordResearchRow } from "@/types/keywords";
import type { SortDir, SortField } from "@/client/features/keywords/components";
import {
@ -30,6 +29,13 @@ import {
} from "./keywordControllerInternals";
import { useKeywordOverviewState } from "./useKeywordOverviewState";
type OpenKeywordTabInput = {
keyword: string;
locationCode: number;
resultLimit: ResultLimit;
mode: KeywordMode;
};
export type KeywordResearchControllerInput = {
projectId: string;
keywordInput: string;
@ -39,7 +45,7 @@ export type KeywordResearchControllerInput = {
keywordMode: KeywordMode;
sortField: SortField;
sortDir: SortDir;
getOpenKeywordTabs?: () => readonly OpenTabInput[];
getOpenKeywordTabs?: () => readonly OpenKeywordTabInput[];
keywordTabsLimit?: number;
/**
* Called when the user submits the search form. Lets the caller decide

View File

@ -1,158 +0,0 @@
import { useCallback, useMemo } from "react";
import type {
KeywordSearchTabInput,
SearchTab,
} from "@/client/features/search-tabs/types";
import {
getSearchTabsSnapshot,
useSearchTabs,
} from "@/client/features/search-tabs/useSearchTabs";
export type OpenTabInput = Omit<KeywordSearchTabInput, "type">;
type KeywordTab = SearchTab & {
input: KeywordSearchTabInput;
keyword: string;
locationCode: KeywordSearchTabInput["locationCode"];
resultLimit: KeywordSearchTabInput["resultLimit"];
mode: KeywordSearchTabInput["mode"];
};
type ProjectTabsState = {
tabs: KeywordTab[];
activeTabId: string | null;
};
type OpenTabsResult = {
opened: KeywordTab[];
focused: KeywordTab[];
activeTab: KeywordTab | null;
dropped: OpenTabInput[];
};
const KEYWORD_TABS_KEY_PREFIX = "keyword";
function keywordTabsKey(projectId: string) {
return `${KEYWORD_TABS_KEY_PREFIX}:${projectId}`;
}
function toSearchTabInput(input: OpenTabInput): KeywordSearchTabInput {
return {
type: "keyword",
keyword: input.keyword,
locationCode: input.locationCode,
resultLimit: input.resultLimit,
mode: input.mode,
};
}
function toKeywordTab(tab: SearchTab): KeywordTab | null {
if (tab.input.type !== "keyword") return null;
return {
...tab,
input: tab.input,
keyword: tab.input.keyword,
locationCode: tab.input.locationCode,
resultLimit: tab.input.resultLimit,
mode: tab.input.mode,
};
}
function toKeywordTabs(tabs: readonly SearchTab[]): KeywordTab[] {
return tabs.flatMap((tab) => {
const keywordTab = toKeywordTab(tab);
return keywordTab ? [keywordTab] : [];
});
}
export function getKeywordTabsSnapshot(projectId: string): ProjectTabsState {
const snapshot = getSearchTabsSnapshot(keywordTabsKey(projectId));
return {
tabs: toKeywordTabs(snapshot.tabs),
activeTabId: snapshot.activeTabId,
};
}
export function useKeywordTabs(projectId: string) {
const tabs = useSearchTabs(keywordTabsKey(projectId));
const keywordTabs = useMemo(() => toKeywordTabs(tabs.tabs), [tabs.tabs]);
const activeTab = useMemo(
() => keywordTabs.find((tab) => tab.id === tabs.activeTabId) ?? null,
[keywordTabs, tabs.activeTabId],
);
const openTabs = useCallback(
(inputs: OpenTabInput[]): OpenTabsResult => {
const opened: KeywordTab[] = [];
const focused: KeywordTab[] = [];
const dropped: OpenTabInput[] = [];
let resultActiveTab: KeywordTab | null = null;
let simulatedTabs = keywordTabs;
for (const input of inputs) {
const result = tabs.openTab({
label: input.keyword,
input: toSearchTabInput(input),
});
if (result.dropped) {
dropped.push(input);
continue;
}
if (!result.tab) continue;
const keywordTab = toKeywordTab(result.tab);
if (!keywordTab) continue;
resultActiveTab = keywordTab;
const wasAlreadyOpen = simulatedTabs.some(
(tab) => tab.id === keywordTab.id,
);
if (wasAlreadyOpen) focused.push(keywordTab);
else {
opened.push(keywordTab);
simulatedTabs = [...simulatedTabs, keywordTab];
}
}
return { opened, focused, activeTab: resultActiveTab, dropped };
},
[keywordTabs, tabs],
);
const findMatchingTab = useCallback(
(input: OpenTabInput) => {
const match = tabs.findMatchingTab(toSearchTabInput(input));
return match ? toKeywordTab(match) : null;
},
[tabs],
);
const closeTab = useCallback(
(tabId: string) => {
const result = tabs.closeTab(tabId);
return {
closedActive: result.closedActive,
nextActiveTab: result.nextActiveTab
? toKeywordTab(result.nextActiveTab)
: null,
};
},
[tabs],
);
return {
tabs: keywordTabs,
activeTabId: tabs.activeTabId,
activeTab,
isAtCap: keywordTabs.length >= tabs.limit,
limit: tabs.limit,
openTabs,
closeTab,
setActiveTab: tabs.setActiveTab,
markTabViewed: tabs.markTabViewed,
findMatchingTab,
};
}
export type UseKeywordTabsReturn = ReturnType<typeof useKeywordTabs>;

View File

@ -31,36 +31,34 @@ export function SearchTabStrip({
return (
<div
key={tab.id}
role="tab"
aria-selected={active}
tabIndex={0}
onClick={() => onSelect(tab)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelect(tab);
}
}}
className={`group flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm transition ${
data-search-tab-id={tab.id}
className={`group flex shrink-0 items-stretch overflow-hidden rounded-md text-sm transition ${
active
? "bg-base-300 text-base-content shadow-sm"
: "text-base-content/80 hover:bg-base-200"
}`}
>
{renderLeading ? renderLeading(tab, active) : null}
<span
className="max-w-[10rem] truncate font-medium"
title={tab.label}
>
{tab.label}
</span>
<button
type="button"
className="rounded p-0.5 text-base-content/50 opacity-60 transition hover:bg-base-content/10 hover:text-base-content hover:opacity-100 group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
onClose(tab.id);
}}
role="tab"
data-search-tab-id={tab.id}
aria-selected={active}
className="flex min-w-0 items-center gap-1.5 px-2.5 py-1.5 text-left"
onClick={() => onSelect(tab)}
>
{renderLeading ? renderLeading(tab, active) : null}
<span
className="max-w-[10rem] truncate font-medium"
title={tab.label}
>
{tab.label}
</span>
</button>
<button
type="button"
data-search-tab-id={tab.id}
className="flex items-center px-1.5 text-base-content/50 opacity-60 transition hover:bg-base-content/10 hover:text-base-content hover:opacity-100 group-hover:opacity-100"
onClick={() => onClose(tab.id)}
aria-label={`Close ${tab.label} tab`}
>
<X className="size-3.5" />

View File

@ -1,4 +1,3 @@
import type { DomainSortMode, SortOrder } from "@/client/features/domain/types";
import type {
KeywordMode,
ResultLimit,
@ -15,8 +14,6 @@ export type DomainSearchTabInput = {
type: "domain";
domain: string;
subdomains: boolean;
sort: DomainSortMode;
order: SortOrder;
locationCode: number;
};

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { SearchTab, SearchTabInput } from "./types";
import { useSearchTabs } from "./useSearchTabs";
@ -9,17 +9,34 @@ type UseSearchTabNavigationArgs = {
navigateToInput: (input: SearchTabInput | null) => void;
};
function tabInputKey(input: SearchTabInput | null) {
return input ? JSON.stringify(input) : "";
}
export function useSearchTabNavigation({
storageKey,
urlInput,
getLabel,
navigateToInput,
}: UseSearchTabNavigationArgs) {
const closedInputKeysRef = useRef<Set<string>>(new Set());
const tabs = useSearchTabs(storageKey);
const { activeTabId, closeTab, findMatchingTab, openTab, setActiveTab } =
tabs;
const {
activeTabId,
closeTab,
findMatchingTab,
markTabViewed,
openTab,
setActiveTab,
} = tabs;
useEffect(() => {
const urlKey = tabInputKey(urlInput);
if (closedInputKeysRef.current.has(urlKey)) {
return;
}
closedInputKeysRef.current.clear();
if (!urlInput) {
setActiveTab(null);
return;
@ -44,6 +61,7 @@ export function useSearchTabNavigation({
const selectTab = useCallback(
(tab: SearchTab) => {
closedInputKeysRef.current.delete(tabInputKey(tab.input));
setActiveTab(tab.id);
navigateToInput(tab.input);
},
@ -52,29 +70,44 @@ export function useSearchTabNavigation({
const closeSearchTab = useCallback(
(tabId: string) => {
const closingTab = tabs.tabs.find((tab) => tab.id === tabId) ?? null;
if (closingTab) {
closedInputKeysRef.current.add(tabInputKey(closingTab.input));
}
const result = closeTab(tabId);
if (result.closedActive) {
navigateToInput(result.nextActiveTab?.input ?? null);
}
},
[closeTab, navigateToInput],
[closeTab, navigateToInput, tabs.tabs],
);
const openSearchTab = useCallback(
(input: SearchTabInput) =>
openTab({
(input: SearchTabInput) => {
closedInputKeysRef.current.delete(tabInputKey(input));
return openTab({
label: getLabel(input),
input,
}),
});
},
[getLabel, openTab],
);
const visibleTabs = useMemo(
() =>
tabs.tabs.filter(
(tab) => !closedInputKeysRef.current.has(tabInputKey(tab.input)),
),
[tabs.tabs],
);
return {
activeTabId: tabs.activeTabId,
tabs: tabs.tabs,
tabs: visibleTabs,
canOpenTab: tabs.canOpenTab,
closeTab: closeSearchTab,
limit: tabs.limit,
markTabViewed,
openTab: openSearchTab,
selectTab,
setActiveTab,

View File

@ -44,23 +44,11 @@ function parseTabInput(value: unknown): SearchTabInput | null {
if (value.type === "domain") {
if (typeof value.domain !== "string" || value.domain === "") return null;
if (typeof value.subdomains !== "boolean") return null;
if (
value.sort !== "rank" &&
value.sort !== "traffic" &&
value.sort !== "volume" &&
value.sort !== "score" &&
value.sort !== "cpc"
) {
return null;
}
if (value.order !== "asc" && value.order !== "desc") return null;
if (typeof value.locationCode !== "number") return null;
return {
type: "domain",
domain: value.domain,
subdomains: value.subdomains,
sort: value.sort,
order: value.order,
locationCode: value.locationCode,
};
}
@ -149,7 +137,7 @@ function loadState(key: string): TabsState {
}
}
export function getSearchTabsSnapshot(key: string): TabsState {
function getSearchTabsSnapshot(key: string): TabsState {
let state = stateCache.get(key);
if (!state) {
state = loadState(key);
@ -265,7 +253,7 @@ export function useSearchTabs(key: string) {
let activeTabId = current.activeTabId;
if (current.activeTabId === tabId) {
closedActive = true;
const neighbor = tabs[index] ?? tabs[index - 1] ?? null;
const neighbor = tabs[index - 1] ?? tabs[index] ?? null;
activeTabId = neighbor?.id ?? null;
nextActiveTab = neighbor;
}

View File

@ -10,7 +10,6 @@ export interface DomainSearchHistoryItem {
subdomains: boolean;
sort: DomainSortMode;
tab: DomainTab;
search?: string;
locationCode?: number;
timestamp: number;
}
@ -24,7 +23,6 @@ const domainSearchHistoryItemSchema = z.object({
subdomains: z.boolean(),
sort: z.enum(["rank", "traffic", "volume", "score", "cpc"]),
tab: z.enum(["keywords", "pages"]),
search: z.string().optional(),
locationCode: z.number().int().positive().optional(),
timestamp: z.number(),
});
@ -32,10 +30,6 @@ const domainSearchHistoryItemSchema = z.object({
const domainSearchHistorySchema = z.array(domainSearchHistoryItemSchema);
const domainSearchHistoryCodec = jsonCodec(domainSearchHistorySchema);
function normalizeSearchText(value: string | undefined): string {
return value?.trim() ?? "";
}
function isSameSearch(
a: DomainSearchHistoryItem,
b: AddDomainSearchInput,
@ -45,8 +39,7 @@ function isSameSearch(
a.subdomains === b.subdomains &&
a.sort === b.sort &&
a.tab === b.tab &&
a.locationCode === b.locationCode &&
normalizeSearchText(a.search) === normalizeSearchText(b.search)
a.locationCode === b.locationCode
);
}
@ -62,7 +55,6 @@ export function useDomainSearchHistory(projectId: string) {
isSameItem: isSameSearch,
createItem: (item) => ({
...item,
search: normalizeSearchText(item.search) || undefined,
timestamp: Date.now(),
}),
getItemKey: (item) => item.timestamp,

2
src/env.d.ts vendored
View File

@ -28,6 +28,8 @@ interface ImportMetaEnv {
readonly AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted";
readonly POSTHOG_PUBLIC_KEY?: string;
readonly POSTHOG_HOST?: string;
readonly VITE_E2E_DOMAIN_FIXTURES?: string;
readonly VITE_E2E_KEYWORD_FIXTURES?: string;
}
interface ImportMeta {

View File

@ -23,10 +23,14 @@ function extractProjectId(data: unknown) {
: null;
}
function getRuntimeAuthMode() {
return getAuthMode(import.meta.env.AUTH_MODE ?? env.AUTH_MODE);
}
export const ensureUserMiddleware = createMiddleware({
type: "function",
}).server(async ({ next, data }) => {
const authMode = getAuthMode(env.AUTH_MODE);
const authMode = getRuntimeAuthMode();
const headers = getRequest().headers;
let context: EnsuredUserContext;

View File

@ -1,73 +1,58 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import {
createFileRoute,
stripSearchParams,
useNavigate,
} from "@tanstack/react-router";
import { DomainOverviewPage } from "@/client/features/domain/DomainOverviewPage";
import {
resolveSortOrder,
toSortMode,
toSortOrder,
} from "@/client/features/domain/utils";
import {
DEFAULT_LOCATION_CODE,
isSupportedLocationCode,
} from "@/client/features/keywords/locations";
import {
DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE,
domainSearchSchema,
} from "@/types/schemas/domain";
import {
EMPTY_DOMAIN_FILTERS,
type DomainFilterValues,
} from "@/client/features/domain/types";
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
import { getDomainRouteState } from "@/client/features/domain/domainRouteState";
const DEFAULT_DOMAIN_SEARCH = {
domain: "",
subdomains: true,
sort: "rank",
order: undefined,
tab: "keywords",
loc: DEFAULT_LOCATION_CODE,
page: 1,
size: DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE,
include: "",
exclude: "",
minTraffic: undefined,
maxTraffic: undefined,
minVol: undefined,
maxVol: undefined,
minCpc: undefined,
maxCpc: undefined,
minKd: undefined,
maxKd: undefined,
minRank: undefined,
maxRank: undefined,
pInclude: "",
pExclude: "",
pMinTraffic: undefined,
pMaxTraffic: undefined,
pMinVol: undefined,
pMaxVol: undefined,
} as const;
export const Route = createFileRoute("/_project/p/$projectId/domain")({
validateSearch: domainSearchSchema,
search: {
middlewares: [stripSearchParams(DEFAULT_DOMAIN_SEARCH)],
},
component: DomainOverviewRoute,
});
function numberToFilterString(value: number | undefined): string {
if (value == null || !Number.isFinite(value)) return "";
return String(value);
}
function DomainOverviewRoute() {
const { projectId } = Route.useParams();
const navigate = useNavigate({ from: Route.fullPath });
const search = Route.useSearch();
const {
domain = "",
subdomains = true,
sort = "rank",
order,
tab = "keywords",
search: searchTerm = "",
loc,
page,
size,
} = search;
const normalizedSort = toSortMode(sort) ?? "rank";
const normalizedOrder = resolveSortOrder(
normalizedSort,
toSortOrder(order ?? null),
);
const normalizedLocationCode =
loc != null && isSupportedLocationCode(loc) ? loc : DEFAULT_LOCATION_CODE;
const normalizedPage = page != null && page > 0 ? page : 1;
const normalizedPageSize = size ?? DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE;
const appliedFilters: DomainFilterValues = {
include: search.include ?? EMPTY_DOMAIN_FILTERS.include,
exclude: search.exclude ?? EMPTY_DOMAIN_FILTERS.exclude,
minTraffic: numberToFilterString(search.minTraffic),
maxTraffic: numberToFilterString(search.maxTraffic),
minVol: numberToFilterString(search.minVol),
maxVol: numberToFilterString(search.maxVol),
minCpc: numberToFilterString(search.minCpc),
maxCpc: numberToFilterString(search.maxCpc),
minKd: numberToFilterString(search.minKd),
maxKd: numberToFilterString(search.maxKd),
minRank: numberToFilterString(search.minRank),
maxRank: numberToFilterString(search.maxRank),
};
const routeState = getDomainRouteState(search);
return (
<DomainOverviewPage
@ -79,18 +64,7 @@ function DomainOverviewRoute() {
});
}}
navigate={navigate}
searchState={{
domain,
subdomains,
sort: normalizedSort,
order: normalizedOrder,
tab,
search: searchTerm,
locationCode: normalizedLocationCode,
page: normalizedPage,
pageSize: normalizedPageSize,
appliedFilters,
}}
routeState={routeState}
/>
);
}

View File

@ -4,6 +4,7 @@ import { createDataforseoClient } from "@/server/lib/dataforseoClient";
import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache";
import { normalizeDomainInput, toRelativePath } from "@/server/lib/domainUtils";
import type { RelevantPagesItem } from "@/server/lib/dataforseo";
import type { DomainKeywordsFilters } from "@/types/schemas/domain";
const DOMAIN_PAGES_PAGE_TTL_SECONDS = 12 * 60 * 60;
@ -38,10 +39,68 @@ function escapeLikeTerm(term: string): string {
return term.replace(/[\\%_]/g, (match) => `\\${match}`);
}
function buildPageFilters(searchTerm?: string): unknown[] {
function pushAnd(filters: unknown[], expression: unknown[]) {
if (filters.length > 0) filters.push("and");
filters.push(expression);
}
function collectNumericRange(
out: unknown[][],
field: string,
min: number | undefined,
max: number | undefined,
) {
if (typeof min === "number" && Number.isFinite(min)) {
out.push([field, ">=", min]);
}
if (typeof max === "number" && Number.isFinite(max)) {
out.push([field, "<=", max]);
}
}
function parseTerms(value: string | undefined): string[] {
if (!value) return [];
return value
.toLowerCase()
.split(/[,+]/)
.map((term) => term.trim())
.filter(Boolean);
}
function buildPageFilters(
filters: DomainKeywordsFilters,
searchTerm?: string,
): unknown[] {
const conditions: unknown[][] = [];
for (const term of parseTerms(filters.include)) {
conditions.push(["page_address", "ilike", `%${escapeLikeTerm(term)}%`]);
}
for (const term of parseTerms(filters.exclude)) {
conditions.push(["page_address", "not_ilike", `%${escapeLikeTerm(term)}%`]);
}
collectNumericRange(
conditions,
"metrics.organic.etv",
filters.minTraffic,
filters.maxTraffic,
);
collectNumericRange(
conditions,
"metrics.organic.count",
filters.minVol,
filters.maxVol,
);
const trimmed = searchTerm?.trim();
if (!trimmed) return [];
return [["page_address", "ilike", `%${escapeLikeTerm(trimmed)}%`]];
if (trimmed) {
conditions.push(["page_address", "ilike", `%${escapeLikeTerm(trimmed)}%`]);
}
const expressions: unknown[] = [];
for (const condition of conditions) pushAnd(expressions, condition);
return expressions;
}
function mapPageItem(item: RelevantPagesItem) {
@ -69,6 +128,7 @@ export async function getPagesPage(
pageSize: number;
sortMode: DomainPagesSortMode;
sortOrder: DomainPagesSortOrder;
filters: DomainKeywordsFilters;
search?: string;
},
billingCustomer: BillingCustomerContext,
@ -76,7 +136,7 @@ export async function getPagesPage(
const domain = normalizeDomainInput(input.domain, input.includeSubdomains);
const offset = (input.page - 1) * input.pageSize;
const orderBy = [`${SORT_FIELD_BY_MODE[input.sortMode]},${input.sortOrder}`];
const filters = buildPageFilters(input.search);
const filters = buildPageFilters(input.filters, input.search);
const cacheKey = await buildCacheKey("domain:pages-page", {
organizationId: billingCustomer.organizationId,
@ -89,6 +149,7 @@ export async function getPagesPage(
pageSize: input.pageSize,
sortMode: input.sortMode,
sortOrder: input.sortOrder,
filters: input.filters,
search: input.search,
});

View File

@ -8,18 +8,31 @@ import {
} from "@/types/schemas/domain";
import { DomainService } from "@/server/features/domain/services/DomainService";
function shouldUseDomainE2eFixtures() {
return import.meta.env.VITE_E2E_DOMAIN_FIXTURES === "1";
}
async function getDomainE2eFixtures() {
return import("../../e2e/fixtures/domain-overview-fixtures");
}
export const getDomainOverview = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => domainOverviewSchema.parse(data))
.handler(async ({ data, context }) =>
DomainService.getOverview(
.handler(async ({ data, context }) => {
if (shouldUseDomainE2eFixtures()) {
const fixtures = await getDomainE2eFixtures();
return fixtures.getFixtureOverview(data.domain);
}
return DomainService.getOverview(
{
...data,
projectId: context.projectId,
},
context,
),
);
);
});
export const getDomainKeywordSuggestions = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
@ -40,25 +53,35 @@ export const getDomainKeywordsPage = createServerFn({ method: "POST" })
.inputValidator((data: unknown) =>
domainKeywordsPageRequestSchema.parse(data),
)
.handler(async ({ data, context }) =>
DomainService.getKeywordsPage(
.handler(async ({ data, context }) => {
if (shouldUseDomainE2eFixtures()) {
const fixtures = await getDomainE2eFixtures();
return fixtures.getFixtureKeywordsPage(data);
}
return DomainService.getKeywordsPage(
{
...data,
projectId: context.projectId,
},
context,
),
);
);
});
export const getDomainPagesPage = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => domainPagesPageRequestSchema.parse(data))
.handler(async ({ data, context }) =>
DomainService.getPagesPage(
.handler(async ({ data, context }) => {
if (shouldUseDomainE2eFixtures()) {
const fixtures = await getDomainE2eFixtures();
return fixtures.getFixturePagesPage(data);
}
return DomainService.getPagesPage(
{
...data,
projectId: context.projectId,
},
context,
),
);
);
});

View File

@ -13,10 +13,23 @@ import {
import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService";
import { requireProjectContext } from "@/serverFunctions/middleware";
function shouldUseKeywordE2eFixtures() {
return import.meta.env.VITE_E2E_KEYWORD_FIXTURES === "1";
}
async function getKeywordE2eFixtures() {
return import("../../e2e/fixtures/keyword-research-fixtures");
}
export const researchKeywords = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => researchKeywordsSchema.parse(data))
.handler(async ({ data, context }) => {
if (shouldUseKeywordE2eFixtures()) {
const fixtures = await getKeywordE2eFixtures();
return fixtures.getKeywordResearchFixture(data);
}
return KeywordResearchService.research(
{
...data,

View File

@ -133,6 +133,7 @@ export const domainPagesPageRequestSchema = z.object({
.default(DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE),
sortMode: z.enum(domainPagesSortModes).default("traffic"),
sortOrder: z.enum(domainSortOrders).default("desc"),
filters: domainKeywordsFiltersSchema.default({}),
search: z.string().optional(),
});
@ -152,7 +153,6 @@ export const domainSearchSchema = z.object({
sort: z.enum(domainSortModes).optional(),
order: z.enum(domainSortOrders).optional(),
tab: z.enum(domainTabs).optional(),
search: z.string().optional(),
loc: optionalSearchPositiveIntParam,
page: optionalSearchPositiveIntParam,
size: z.coerce
@ -175,4 +175,12 @@ export const domainSearchSchema = z.object({
maxKd: filterNumberParam,
minRank: filterNumberParam,
maxRank: filterNumberParam,
pInclude: filterStringParam,
pExclude: filterStringParam,
pMinTraffic: filterNumberParam,
pMaxTraffic: filterNumberParam,
pMinVol: filterNumberParam,
pMaxVol: filterNumberParam,
});
export type DomainSearchParams = z.infer<typeof domainSearchSchema>;

View File

@ -8,7 +8,11 @@ import { devtools } from "@tanstack/devtools-vite";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const port = env.PORT ? Number(env.PORT) : 3001;
const port = process.env.PORT
? Number(process.env.PORT)
: env.PORT
? Number(env.PORT)
: 3001;
const showDevtools = env.VITE_SHOW_DEVTOOLS !== "false";
const allowedHosts = [
env.ALLOWED_HOST,
@ -39,7 +43,7 @@ export default defineConfig(({ mode }) => {
},
})
: null,
cloudflare({ viteEnvironment: { name: "ssr" } }),
cloudflare({ inspectorPort: false, viteEnvironment: { name: "ssr" } }),
tsConfigPaths(),
tanstackStart(),
viteReact(),