diff --git a/e2e/domain-overview-filters.perf.spec.ts b/e2e/domain-overview-filters.perf.spec.ts new file mode 100644 index 0000000..a8e24ac --- /dev/null +++ b/e2e/domain-overview-filters.perf.spec.ts @@ -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( + promise: Promise, + timeoutMs: number, + message: string, +): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeoutMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} diff --git a/e2e/domain-overview-filters.spec.ts b/e2e/domain-overview-filters.spec.ts new file mode 100644 index 0000000..c8e9b47 --- /dev/null +++ b/e2e/domain-overview-filters.spec.ts @@ -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"); + }); +}); diff --git a/e2e/domain-overview-test-utils.ts b/e2e/domain-overview-test-utils.ts new file mode 100644 index 0000000..3cac31b --- /dev/null +++ b/e2e/domain-overview-test-utils.ts @@ -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 { + 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( + promise: Promise, + timeoutMs: number, + message: string, +): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeoutMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} diff --git a/e2e/fixtures/domain-overview-fixtures.ts b/e2e/fixtures/domain-overview-fixtures.ts new file mode 100644 index 0000000..1e5896f --- /dev/null +++ b/e2e/fixtures/domain-overview-fixtures.ts @@ -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", + }; +} diff --git a/e2e/fixtures/keyword-research-fixtures.ts b/e2e/fixtures/keyword-research-fixtures.ts new file mode 100644 index 0000000..aa53d9b --- /dev/null +++ b/e2e/fixtures/keyword-research-fixtures.ts @@ -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 { + 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, + }, + ], + }, + }; +} diff --git a/e2e/keyword-research-navigation.spec.ts b/e2e/keyword-research-navigation.spec.ts new file mode 100644 index 0000000..910051a --- /dev/null +++ b/e2e/keyword-research-navigation.spec.ts @@ -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); + }); +}); diff --git a/package.json b/package.json index 6218039..aa369d7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..5b0c3e1 --- /dev/null +++ b/playwright.config.ts @@ -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, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b97ab0..c0f2cd6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/client/components/table/TableBulkActionBar.tsx b/src/client/components/table/TableBulkActionBar.tsx index 2890d0e..f6e1e10 100644 --- a/src/client/components/table/TableBulkActionBar.tsx +++ b/src/client/components/table/TableBulkActionBar.tsx @@ -127,3 +127,42 @@ export function TableBulkExportMenu({ ); } + +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 ( +
+
+ + Export + +
+
    + {actions.map((action, index) => ( +
  • + +
  • + ))} +
+
+ ); +} diff --git a/src/client/features/audit/results/AuditResultsTableFilterLogic.ts b/src/client/features/audit/results/AuditResultsTableFilterLogic.ts new file mode 100644 index 0000000..e58676b --- /dev/null +++ b/src/client/features/audit/results/AuditResultsTableFilterLogic.ts @@ -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; +} diff --git a/src/client/features/audit/results/AuditResultsTableFilters.tsx b/src/client/features/audit/results/AuditResultsTableFilters.tsx new file mode 100644 index 0000000..039d84f --- /dev/null +++ b/src/client/features/audit/results/AuditResultsTableFilters.tsx @@ -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 ( +
+
+ onChange({ ...filters, query })} + /> + onChange({ ...filters, status })} + options={[ + ["all", "All"], + ["ok", "2xx"], + ["redirect", "3xx"], + ["error", "4xx/5xx"], + ["missing", "Missing"], + ]} + /> + onChange({ ...filters, minWords })} + onMaxChange={(maxWords) => onChange({ ...filters, maxWords })} + /> + + onChange({ ...filters, minResponseMs }) + } + onMaxChange={(maxResponseMs) => + onChange({ ...filters, maxResponseMs }) + } + /> + onChange({ ...filters, missingAlt })} + options={[ + ["all", "All"], + ["yes", "Missing alt"], + ["no", "No missing alt"], + ]} + /> + onChange(EMPTY_PAGES_FILTERS)} + /> +
+
+ ); +} + +export function PerformanceFilterBar({ + filters, + onChange, + resultCount, + totalCount, +}: { + filters: PerformanceFilters; + onChange: (filters: PerformanceFilters) => void; + resultCount: number; + totalCount: number; +}) { + return ( +
+
+ onChange({ ...filters, query })} + /> + onChange({ ...filters, device })} + options={[ + ["all", "All"], + ["desktop", "Desktop"], + ["mobile", "Mobile"], + ]} + /> + onChange({ ...filters, status })} + options={[ + ["all", "All"], + ["ok", "OK"], + ["failed", "Failed"], + ]} + /> + onChange({ ...filters, minPerf })} + onMaxChange={(maxPerf) => onChange({ ...filters, maxPerf })} + /> + onChange({ ...filters, minSeo })} + onMaxChange={(maxSeo) => onChange({ ...filters, maxSeo })} + /> + onChange({ ...filters, maxLcpSeconds })} + /> + onChange(EMPTY_PERFORMANCE_FILTERS)} + /> +
+
+ ); +} + +export function EmptyTableMessage({ label }: { label: string }) { + return
{label}
; +} + +function TextFilter({ + label, + value, + placeholder, + type = "text", + onChange, +}: { + label: string; + value: string; + placeholder: string; + type?: "text" | "number"; + onChange: (value: string) => void; +}) { + return ( + + ); +} + +function RangeFilter({ + label, + min, + max, + onMinChange, + onMaxChange, +}: { + label: string; + min: string; + max: string; + onMinChange: (value: string) => void; + onMaxChange: (value: string) => void; +}) { + return ( +
+ + {label} + +
+ onMinChange(event.target.value)} + /> + onMaxChange(event.target.value)} + /> +
+
+ ); +} + +function SelectFilter({ + label, + value, + options, + onChange, +}: { + label: string; + value: T; + options: Array<[T, string]>; + onChange: (value: T) => void; +}) { + return ( + + ); +} + +function FilterSummary({ + resultCount, + totalCount, + onReset, +}: { + resultCount: number; + totalCount: number; + onReset: () => void; +}) { + return ( +
+ + {resultCount.toLocaleString()} of {totalCount.toLocaleString()} + + +
+ ); +} diff --git a/src/client/features/audit/results/ResultsTables.tsx b/src/client/features/audit/results/ResultsTables.tsx index a55bee8..7995607 100644 --- a/src/client/features/audit/results/ResultsTables.tsx +++ b/src/client/features/audit/results/ResultsTables.tsx @@ -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(); +const performanceColumnHelper = createColumnHelper(); + +const pagesColumns: ColumnDef[] = [ + pageColumnHelper.accessor("url", { + header: ({ column }) => , + cell: ({ getValue }) => { + const url = getValue(); + return ( + + {extractPathname(url)} + + + ); + }, + meta: { cellClassName: "max-w-[240px] truncate" }, + }), + pageColumnHelper.accessor("statusCode", { + header: ({ column }) => , + cell: ({ getValue }) => , + sortingFn: nullableNumberSort, + }), + pageColumnHelper.accessor("title", { + header: ({ column }) => , + cell: ({ getValue }) => { + const title = getValue(); + return title ? ( + {title} + ) : ( + missing + ); + }, + sortingFn: nullableStringSort, + meta: { cellClassName: "max-w-[220px] truncate" }, + }), + pageColumnHelper.accessor("h1Count", { + header: ({ column }) => , + }), + pageColumnHelper.accessor("wordCount", { + header: ({ column }) => , + }), + pageColumnHelper.display({ + id: "images", + header: ({ column }) => , + cell: ({ row }) => + row.original.imagesMissingAlt > 0 ? ( + + {row.original.imagesMissingAlt}/{row.original.imagesTotal} + + ) : ( + 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 }) => , + cell: ({ getValue }) => { + const value = getValue(); + return value ? ( + {value}ms + ) : ( + - + ); + }, + sortingFn: nullableNumberSort, + }), +]; + export function PagesTable({ pages }: { pages: AuditResultsData["pages"] }) { + const [filters, setFilters] = useState(EMPTY_PAGES_FILTERS); + const [sorting, setSorting] = useState([ + { 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 ( -
- - - - - - - - - - - - - - {pages.map((page: AuditResultsData["pages"][number]) => ( - - - - - - - - - - ))} - -
URLStatusTitleH1WordsImagesSpeed
- - {extractPathname(page.url)} - - - - - - {page.title || ( - missing - )} - {page.h1Count}{page.wordCount} - {page.imagesMissingAlt > 0 ? ( - - {page.imagesMissingAlt}/{page.imagesTotal} - - ) : ( - page.imagesTotal - )} - - {page.responseTimeMs ? `${page.responseTimeMs}ms` : "-"} -
+
+ + } + />
); } @@ -101,65 +164,89 @@ export function PerformanceTable({ lighthouse: AuditResultsData["lighthouse"]; pages: AuditResultsData["pages"]; }) { + const [filters, setFilters] = useState( + EMPTY_PERFORMANCE_FILTERS, + ); + const [sorting, setSorting] = useState([ + { 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 ( -
- - - - - - - - - - - - - - - - - - {lighthouse.map((result: AuditResultsData["lighthouse"][number]) => ( - - candidate.id === result.pageId, - )} - /> - ))} - -
URLDeviceStatusPerfA11ySEOLCPCLSINPTTFBIssues
+
+ + + } + />
); } -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 ( - - - {page ? extractPathname(page.url) : "-"} - - {result.strategy} - - {isFailed ? ( +}): ColumnDef[] { + return [ + performanceColumnHelper.accessor("pagePath", { + header: ({ column }) => , + cell: ({ getValue }) => ( + {getValue() ?? "-"} + ), + sortingFn: nullableStringSort, + meta: { cellClassName: "max-w-[180px] truncate" }, + }), + performanceColumnHelper.accessor("strategy", { + header: ({ column }) => , + cell: ({ getValue }) => ( + {getValue()} + ), + }), + performanceColumnHelper.display({ + id: "status", + header: ({ column }) => , + cell: ({ row }) => { + const isFailed = isLighthouseFailure(row.original); + const failureMessage = getLighthouseFailureMessage(row.original); + return isFailed ? ( ) : ( ok - )} - - - - - - - - - - - - {result.lcpMs ? `${(result.lcpMs / 1000).toFixed(1)}s` : "-"} - - - {result.cls != null ? result.cls.toFixed(3) : "-"} - - - {result.inpMs ? `${Math.round(result.inpMs)}ms` : "-"} - - - {result.ttfbMs ? `${Math.round(result.ttfbMs)}ms` : "-"} - - - {result.r2Key && !isFailed ? ( + ); + }, + enableSorting: true, + sortingFn: (left, right) => + Number(isLighthouseFailure(left.original)) - + Number(isLighthouseFailure(right.original)), + }), + performanceColumnHelper.accessor("performanceScore", { + header: ({ column }) => , + cell: ({ getValue }) => , + sortingFn: nullableNumberSort, + }), + performanceColumnHelper.accessor("accessibilityScore", { + header: ({ column }) => , + cell: ({ getValue }) => , + sortingFn: nullableNumberSort, + }), + performanceColumnHelper.accessor("seoScore", { + header: ({ column }) => , + cell: ({ getValue }) => , + sortingFn: nullableNumberSort, + }), + performanceColumnHelper.accessor("lcpMs", { + header: ({ column }) => , + cell: ({ getValue }) => { + const value = getValue(); + return value ? ( + {(value / 1000).toFixed(1)}s + ) : ( + - + ); + }, + sortingFn: nullableNumberSort, + }), + performanceColumnHelper.accessor("cls", { + header: ({ column }) => , + cell: ({ getValue }) => { + const value = getValue(); + return value != null ? ( + {value.toFixed(3)} + ) : ( + - + ); + }, + sortingFn: nullableNumberSort, + }), + performanceColumnHelper.accessor("inpMs", { + header: ({ column }) => , + cell: ({ getValue }) => { + const value = getValue(); + return value ? ( + {Math.round(value)}ms + ) : ( + - + ); + }, + sortingFn: nullableNumberSort, + }), + performanceColumnHelper.accessor("ttfbMs", { + header: ({ column }) => , + cell: ({ getValue }) => { + const value = getValue(); + return value ? ( + {Math.round(value)}ms + ) : ( + - + ); + }, + sortingFn: nullableNumberSort, + }), + performanceColumnHelper.display({ + id: "issues", + header: () => "Issues", + cell: ({ row }) => + row.original.r2Key && !isLighthouseFailure(row.original) ? ( View issues ) : ( - - )} - - - ); + ), + }), + ]; } export function ExportDropdown({ @@ -213,26 +349,40 @@ export function ExportDropdown({ onExport: (format: "csv" | "json" | "sheets") => void; }) { return ( -
-
- - Export - -
-
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
-
+ 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); +} diff --git a/src/client/features/backlinks/ReferringDomainsTable.tsx b/src/client/features/backlinks/ReferringDomainsTable.tsx index 6e9b62b..14cc1bb 100644 --- a/src/client/features/backlinks/ReferringDomainsTable.tsx +++ b/src/client/features/backlinks/ReferringDomainsTable.tsx @@ -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 ( + + {domain} + + ); + }, sortingFn: stringNullsLast, }), columnHelper.accessor("backlinks", { diff --git a/src/client/features/domain/DomainOverviewPage.tsx b/src/client/features/domain/DomainOverviewPage.tsx index 41e2e1d..c44dbba 100644 --- a/src/client/features/domain/DomainOverviewPage.tsx +++ b/src/client/features/domain/DomainOverviewPage.tsx @@ -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) => Record; replace: boolean; @@ -46,38 +60,374 @@ type Props = { onShowRecentSearches: () => void; }; +type DomainNavigate = Props["navigate"]; +type DomainSearchUpdate = Partial; + +const KEYWORDS_ONLY_SORTS: ReadonlySet = 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(""); + + 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(() => { - 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 ? (
+ +
+
+ + {routeState.tab === "keywords" ? ( + + ) : ( + + )} +
)}
diff --git a/src/client/features/domain/components/DomainFilterFields.tsx b/src/client/features/domain/components/DomainFilterFields.tsx new file mode 100644 index 0000000..4ed9589 --- /dev/null +++ b/src/client/features/domain/components/DomainFilterFields.tsx @@ -0,0 +1,72 @@ +import type { ReactNode } from "react"; + +function FilterFieldLabel({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +export function FilterTextInput({ + label, + value, + onChange, + placeholder, +}: { + label: string; + value: string; + onChange: (value: string) => void; + placeholder: string; +}) { + return ( + + ); +} + +export function FilterNumberInput({ + value, + onChange, + placeholder, + step, +}: { + value: string; + onChange: (value: string) => void; + placeholder: string; + step?: string; +}) { + return ( + onChange(event.target.value)} + /> + ); +} + +export function FilterRangeGroup({ + title, + children, +}: { + title: string; + children: ReactNode; +}) { + return ( +
+ {title} +
{children}
+
+ ); +} diff --git a/src/client/features/domain/components/DomainFilterPanel.tsx b/src/client/features/domain/components/DomainFilterPanel.tsx index ddc9e78..f297e4d 100644 --- a/src/client/features/domain/components/DomainFilterPanel.tsx +++ b/src/client/features/domain/components/DomainFilterPanel.tsx @@ -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["filtersForm"]; +type FilterValues = Record; -type Props = { - filtersForm: FilterForm; - activeFilterCount: number; - dirtyFilterCount: number; - conditionCount: number; - overLimit: boolean; - resetFilters: () => void; - applyFilters: () => void; - cancelFilterEdits: () => void; +type FilterTextField = { + key: keyof TValues; + label: string; + placeholder: string; }; -export function DomainFilterPanel({ - filtersForm, +type FilterRangeField = { + title: string; + minKey: keyof TValues; + maxKey: keyof TValues; + step?: string; +}; + +type Props = { + debugName: string; + activeFilterCount: number; + appliedFilters: TValues; + fields: ReadonlyArray; + textFields: ReadonlyArray>; + rangeFields: ReadonlyArray>; + countConditions: (values: TValues) => number; + onApply: (values: TValues) => void; + onClear: () => void; +}; + +export function DomainFilterPanel({ + 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) { + 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 (
@@ -48,16 +132,17 @@ export function DomainFilterPanel({ {activeFilterCount} active ) : null} - {isDirty ? ( + {meta.dirtyCount > 0 ? ( - {dirtyFilterCount} unapplied + {meta.dirtyCount} unapplied ) : null}
- - + {textFields.map((field) => ( + handleValueChange(field.key, value)} + /> + ))}
- - - - - + {rangeFields.map((field) => ( + + handleValueChange(field.minKey, value)} + placeholder="Min" + step={field.step} + /> + handleValueChange(field.maxKey, value)} + placeholder="Max" + step={field.step} + /> + + ))}
- {overLimit ? ( + {meta.overLimit ? (
- 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.
) : null} -
- {conditionCount} / {MAX_DATAFORSEO_FILTER_CONDITIONS} conditions + {meta.conditionCount} / {MAX_DATAFORSEO_FILTER_CONDITIONS} conditions
@@ -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 ? ( - {dirtyFilterCount} + {meta.dirtyCount} ) : null} @@ -161,80 +227,27 @@ export function DomainFilterPanel({ ); } -function FilterTextInput({ - form, - name, - label, - placeholder, +function getFilterMeta({ + values, + appliedFilters, + fields, + countConditions, }: { - form: FilterForm; - name: "include" | "exclude"; - label: string; - placeholder: string; + values: TValues; + appliedFilters: TValues; + fields: ReadonlyArray; + countConditions: (values: TValues) => number; }) { - return ( - - ); -} - -function FilterRangeInputs({ - form, - title, - minName, - maxName, - step, -}: { - form: FilterForm; - title: string; - minName: keyof DomainFilterValues; - maxName: keyof DomainFilterValues; - step?: string; -}) { - return ( -
-

- {title} -

-
- - {(field) => ( - field.handleChange(event.target.value)} - /> - )} - - - {(field) => ( - field.handleChange(event.target.value)} - /> - )} - -
-
+ 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, + }; } diff --git a/src/client/features/domain/components/DomainHistorySection.tsx b/src/client/features/domain/components/DomainHistorySection.tsx index 3d0bc19..8cc083a 100644 --- a/src/client/features/domain/components/DomainHistorySection.tsx +++ b/src/client/features/domain/components/DomainHistorySection.tsx @@ -59,7 +59,6 @@ export function DomainHistorySection({

{item.subdomains ? "Include subdomains" : "Root domain only"} - {item.search?.trim() ? ` - ${item.search}` : ""}

diff --git a/src/client/features/domain/components/DomainKeywordsPagination.tsx b/src/client/features/domain/components/DomainKeywordsPagination.tsx index 9451aaf..3a72ff2 100644 --- a/src/client/features/domain/components/DomainKeywordsPagination.tsx +++ b/src/client/features/domain/components/DomainKeywordsPagination.tsx @@ -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()}` : ""}
- - +
); } + +function PageLink({ + page, + disabled, + label, + children, + onPageChange, +}: { + page: number; + disabled: boolean; + label: string; + children: ReactNode; + onPageChange: (nextPage: number) => void; +}) { + return ( + ({ + ...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} + + ); +} diff --git a/src/client/features/domain/components/DomainKeywordsTable.tsx b/src/client/features/domain/components/DomainKeywordsTable.tsx index 68ccdfb..c0aab87 100644 --- a/src/client/features/domain/components/DomainKeywordsTable.tsx +++ b/src/client/features/domain/components/DomainKeywordsTable.tsx @@ -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(); -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( () => @@ -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 (
@@ -178,3 +187,5 @@ export function DomainKeywordsTable({
); } + +export const DomainKeywordsTable = memo(DomainKeywordsTableComponent); diff --git a/src/client/features/domain/components/DomainPagesTable.tsx b/src/client/features/domain/components/DomainPagesTable.tsx index c3004da..f87cbc1 100644 --- a/src/client/features/domain/components/DomainPagesTable.tsx +++ b/src/client/features/domain/components/DomainPagesTable.tsx @@ -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(); -export function DomainPagesTable({ +function DomainPagesTableComponent({ domain, rows, sortMode, currentSortOrder, onSortClick, }: Props) { + const renderStarted = performance.now(); const columns = useMemo[]>( () => [ 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 ( ); } + +export const DomainPagesTable = memo(DomainPagesTableComponent); diff --git a/src/client/features/domain/components/DomainResultsCard.tsx b/src/client/features/domain/components/DomainResultsCard.tsx deleted file mode 100644 index aa28ef0..0000000 --- a/src/client/features/domain/components/DomainResultsCard.tsx +++ /dev/null @@ -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; - setSelectedKeywords: Dispatch>>; - visibleKeywords: string[]; - filteredKeywords: KeywordRow[]; - pagedPages: PageRow[]; - showFilters: boolean; - setShowFilters: Dispatch>; - filtersForm: ReturnType["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 = 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 ( -
-
-
- ({ ...prev, tab: undefined, page: undefined })} - replace - role="tab" - className={`tab ${activeTab === "keywords" ? "tab-active" : ""}`} - > - Top Keywords - - { - 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 - -
- -
-
-
- - Export - -
-
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
-
-
-
- - {activeTab === "keywords" ? ( - setSelectedKeywords(new Set())} - actions={ -
- } - onClick={onSaveKeywords} - disabled={!canSaveKeywords} - > - Save Keywords - - , - onClick: handleExportSelectionToSheets, - }, - { - label: "Download CSV", - icon: , - onClick: handleDownloadSelectionCsv, - }, - ]} - /> -
- } - /> - ) : null} - -
- {isKeywordsTab ? ( - - ) : null} - - {isKeywordsTab - ? totalKeywordCount != null - ? `${totalKeywordCount.toLocaleString()} keywords` - : `${filteredKeywords.length.toLocaleString()} keywords` - : totalPagesCount != null - ? `${totalPagesCount.toLocaleString()} pages` - : `${pagedPages.length.toLocaleString()} pages`} - -
-
{ - event.preventDefault(); - if (!overLimit) applyFilters(); - }} - > - -
-
- - {isKeywordsTab && showFilters ? ( - - ) : null} - -
- {isKeywordsTab ? ( -
- -
- ) : ( -
- -
- )} -
- - -
- ); -} diff --git a/src/client/features/domain/components/DomainSearchCard.tsx b/src/client/features/domain/components/DomainSearchCard.tsx index e97492b..99567c7 100644 --- a/src/client/features/domain/components/DomainSearchCard.tsx +++ b/src/client/features/domain/components/DomainSearchCard.tsx @@ -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["controlsForm"]; + controlsForm: DomainOverviewControlsForm; isLoading: boolean; onSubmit: (event: FormEvent) => void; onSortChange: (sort: DomainSortMode) => void; diff --git a/src/client/features/domain/components/DomainTableTabSurface.tsx b/src/client/features/domain/components/DomainTableTabSurface.tsx new file mode 100644 index 0000000..c8fc4f2 --- /dev/null +++ b/src/client/features/domain/components/DomainTableTabSurface.tsx @@ -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 ( + <> +
+ + + {(totalCount ?? fallbackCount).toLocaleString()} {countLabel} + +
+ +
+ + {filterPanel} + +
+
+ {showTableLoading ? : children} +
+
+ + {pagination} + + ); +} diff --git a/src/client/features/domain/components/KeywordsTab.tsx b/src/client/features/domain/components/KeywordsTab.tsx new file mode 100644 index 0000000..36702ba --- /dev/null +++ b/src/client/features/domain/components/KeywordsTab.tsx @@ -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; + +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>( + 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 ( + <> + setSelectedKeywords(new Set())} + actions={ +
+ } + onClick={handleSaveKeywords} + disabled={!canSaveKeywords} + > + Save Keywords + + , + onClick: handleExportSelectionToSheets, + }, + { + label: "Download CSV", + icon: , + onClick: handleDownloadSelectionCsv, + }, + ]} + /> +
+ } + /> + + setShowFilters((prev) => !prev)} + activeFilterCount={activeFilterCount} + countLabel="keywords" + totalCount={totalCount} + fallbackCount={rows.length} + isLoading={isLoading} + showTableLoading={showTableLoading} + exportActions={[ + { + label: "Export to Sheets", + icon: , + onClick: handleExportToSheets, + }, + { + label: "Copy data (JSON)", + icon: , + onClick: handleCopy, + }, + { + label: "Download CSV", + icon: , + onClick: () => handleDownload("csv"), + }, + { + label: "Download Excel", + icon: , + onClick: () => handleDownload("xls"), + }, + ]} + filterPanel={ + showFilters ? ( + + ) : null + } + pagination={ + + } + > + + + + ); +} diff --git a/src/client/features/domain/components/PagesTab.tsx b/src/client/features/domain/components/PagesTab.tsx new file mode 100644 index 0000000..88dc522 --- /dev/null +++ b/src/client/features/domain/components/PagesTab.tsx @@ -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; + +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 ( + <> + setShowFilters((prev) => !prev)} + activeFilterCount={activeFilterCount} + countLabel="pages" + totalCount={totalCount} + fallbackCount={rows.length} + isLoading={isLoading} + showTableLoading={showTableLoading} + exportActions={[ + { + label: "Export to Sheets", + icon: , + onClick: handleExportToSheets, + }, + { + label: "Copy data (JSON)", + icon: , + onClick: handleCopy, + }, + { + label: "Download CSV", + icon: , + onClick: () => handleDownload("csv"), + }, + { + label: "Download Excel", + icon: , + onClick: () => handleDownload("xls"), + }, + ]} + filterPanel={ + showFilters ? ( + + ) : null + } + pagination={ + + } + > + + + + ); +} diff --git a/src/client/features/domain/components/TableLoadingRows.tsx b/src/client/features/domain/components/TableLoadingRows.tsx new file mode 100644 index 0000000..32b269f --- /dev/null +++ b/src/client/features/domain/components/TableLoadingRows.tsx @@ -0,0 +1,15 @@ +export function TableLoadingRows() { + return ( +
+ {Array.from({ length: 8 }).map((_, index) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} diff --git a/src/client/features/domain/domainDebug.ts b/src/client/features/domain/domainDebug.ts new file mode 100644 index 0000000..8161062 --- /dev/null +++ b/src/client/features/domain/domainDebug.ts @@ -0,0 +1,33 @@ +import { useEffect, useRef } from "react"; + +type DebugPayload = Record; + +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, + }); + }); +} diff --git a/src/client/features/domain/domainFilterUtils.ts b/src/client/features/domain/domainFilterUtils.ts new file mode 100644 index 0000000..6b78db8 --- /dev/null +++ b/src/client/features/domain/domainFilterUtils.ts @@ -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; + +export const PAGE_FILTER_FIELDS = [ + "include", + "exclude", + "minTraffic", + "maxTraffic", + "minVol", + "maxVol", +] as const satisfies ReadonlyArray; + +const PAGE_SEARCH_PARAM_BY_FIELD = { + include: "pInclude", + exclude: "pExclude", + minTraffic: "pMinTraffic", + maxTraffic: "pMaxTraffic", + minVol: "pMinVol", + maxVol: "pMaxVol", +} as const satisfies Record; + +type SearchUpdate = Partial; +type FilterValues = Record; +type FilterKey = Extract; + +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( + values, + KEYWORD_FILTER_FIELDS, + (key) => key, + ); +} + +export function buildPagesSearchUpdate( + values: PagesFilterValues, +): SearchUpdate { + return buildFilterSearchUpdate( + values, + PAGE_FILTER_FIELDS, + (key) => getPageFilterSearchParam(key), + ); +} + +export function buildPagesClearSearchUpdate(): SearchUpdate { + return buildFilterClearSearchUpdate( + PAGE_FILTER_FIELDS, + (key) => getPageFilterSearchParam(key), + ); +} + +export function buildDomainFiltersClearSearchUpdate(): SearchUpdate { + const update = buildFilterClearSearchUpdate( + KEYWORD_FILTER_FIELDS, + (key) => key, + ); + Object.assign( + update, + buildFilterClearSearchUpdate(PAGE_FILTER_FIELDS, (key) => + getPageFilterSearchParam(key), + ), + ); + return update; +} + +function countFilterConditions>( + values: TValues, + fields: ReadonlyArray>, +): 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( + values: TValues, + fields: ReadonlyArray>, + getParam: (key: FilterKey) => 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( + fields: ReadonlyArray>, + getParam: (key: FilterKey) => keyof DomainSearchParams, +): SearchUpdate { + const update: SearchUpdate = { page: undefined }; + for (const key of fields) + Object.assign(update, { [getParam(key)]: undefined }); + return update; +} diff --git a/src/client/features/domain/domainOverviewControllerInternals.ts b/src/client/features/domain/domainOverviewControllerInternals.ts deleted file mode 100644 index c075823..0000000 --- a/src/client/features/domain/domainOverviewControllerInternals.ts +++ /dev/null @@ -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) => Record; - 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>>; - 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]); -} diff --git a/src/client/features/domain/domainRouteState.ts b/src/client/features/domain/domainRouteState.ts new file mode 100644 index 0000000..04d779d --- /dev/null +++ b/src/client/features/domain/domainRouteState.ts @@ -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, + ); +} diff --git a/src/client/features/domain/hooks/useDomainFilters.ts b/src/client/features/domain/hooks/useDomainFilters.ts deleted file mode 100644 index b886e22..0000000 --- a/src/client/features/domain/hooks/useDomainFilters.ts +++ /dev/null @@ -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 = [ - "include", - "exclude", - "minTraffic", - "maxTraffic", - "minVol", - "maxVol", - "minCpc", - "maxCpc", - "minKd", - "maxKd", - "minRank", - "maxRank", -]; - -function filtersToSearchParams( - values: DomainFilterValues, -): Record { - const out: Record = {}; - 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, - ) => 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, - }; -} diff --git a/src/client/features/domain/hooks/useDomainKeywordsQuery.ts b/src/client/features/domain/hooks/useDomainKeywordsQuery.ts index edc7f07..3c2699c 100644 --- a/src/client/features/domain/hooks/useDomainKeywordsQuery.ts +++ b/src/client/features/domain/hooks/useDomainKeywordsQuery.ts @@ -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; } diff --git a/src/client/features/domain/hooks/useDomainPagesQuery.ts b/src/client/features/domain/hooks/useDomainPagesQuery.ts index 8a0ebed..fe11694 100644 --- a/src/client/features/domain/hooks/useDomainPagesQuery.ts +++ b/src/client/features/domain/hooks/useDomainPagesQuery.ts @@ -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; } diff --git a/src/client/features/domain/types.ts b/src/client/features/domain/types.ts index 47633e5..6e77d30 100644 --- a/src/client/features/domain/types.ts +++ b/src/client/features/domain/types.ts @@ -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; diff --git a/src/client/features/domain/useDomainControllerHandlers.ts b/src/client/features/domain/useDomainControllerHandlers.ts deleted file mode 100644 index 94a6b56..0000000 --- a/src/client/features/domain/useDomainControllerHandlers.ts +++ /dev/null @@ -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; - 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; - projectId: string; - saveMutation: ReturnType; - selectedKeywords: Set; - setSearchParams: ( - updates: Record, - ) => 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, - }; -} diff --git a/src/client/features/domain/useDomainFilterPreferences.ts b/src/client/features/domain/useDomainFilterPreferences.ts new file mode 100644 index 0000000..4d25b7f --- /dev/null +++ b/src/client/features/domain/useDomainFilterPreferences.ts @@ -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; + +function isRecord(value: unknown): value is Record { + 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( + 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(() => + 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(() => + 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 }; +} diff --git a/src/client/features/domain/useDomainOverviewController.ts b/src/client/features/domain/useDomainOverviewController.ts deleted file mode 100644 index ac4dba7..0000000 --- a/src/client/features/domain/useDomainOverviewController.ts +++ /dev/null @@ -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) => Record; - replace: boolean; - }) => void; - searchState: SearchState; -}; - -export function useDomainOverviewController({ - projectId, - queryClient, - navigate, - searchState, -}: Params) { - const [selectedKeywords, setSelectedKeywords] = useState>( - 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) => { - 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(""); - 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, - }; -} diff --git a/src/client/features/domain/utils.ts b/src/client/features/domain/utils.ts index 588acea..40bd963 100644 --- a/src/client/features/domain/utils.ts +++ b/src/client/features/domain/utils.ts @@ -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)[][] }; diff --git a/src/client/features/keywords/page/KeywordResearchPage.tsx b/src/client/features/keywords/page/KeywordResearchPage.tsx index 1c0e5f4..0176630 100644 --- a/src/client/features/keywords/page/KeywordResearchPage.tsx +++ b/src/client/features/keywords/page/KeywordResearchPage.tsx @@ -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; +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(() => { + const urlInput = useMemo(() => { 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(() => { + 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( @@ -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) { {controller.hasSearched ? (
- { - setActiveTab(null); - setSearchParamsForTab(null); - }} + onClick={showRecentSearches} > Recent searches - +
) : null} diff --git a/src/client/features/keywords/page/KeywordResearchTabStrip.tsx b/src/client/features/keywords/page/KeywordResearchTabStrip.tsx index 8822600..0adb5b5 100644 --- a/src/client/features/keywords/page/KeywordResearchTabStrip.tsx +++ b/src/client/features/keywords/page/KeywordResearchTabStrip.tsx @@ -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 ( tabs.setActiveTab(tab.id)} - onClose={closeTab} + activeTabId={activeTabId} + tabs={tabs} + onSelect={onSelect} + onClose={onClose} renderLeading={(tab, active) => ( )} diff --git a/src/client/features/keywords/state/useKeywordResearchController.ts b/src/client/features/keywords/state/useKeywordResearchController.ts index 9453248..f3da608 100644 --- a/src/client/features/keywords/state/useKeywordResearchController.ts +++ b/src/client/features/keywords/state/useKeywordResearchController.ts @@ -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 diff --git a/src/client/features/keywords/state/useKeywordTabs.ts b/src/client/features/keywords/state/useKeywordTabs.ts deleted file mode 100644 index 64d5e4f..0000000 --- a/src/client/features/keywords/state/useKeywordTabs.ts +++ /dev/null @@ -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; - -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; diff --git a/src/client/features/search-tabs/SearchTabStrip.tsx b/src/client/features/search-tabs/SearchTabStrip.tsx index 7577cfa..08a98a4 100644 --- a/src/client/features/search-tabs/SearchTabStrip.tsx +++ b/src/client/features/search-tabs/SearchTabStrip.tsx @@ -31,36 +31,34 @@ export function SearchTabStrip({ return (
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} - - {tab.label} - +