Refactor pages to improve performance and consistency (#205)
This commit is contained in:
parent
74ba0c4fc8
commit
2484f93ce6
266
e2e/domain-overview-filters.perf.spec.ts
Normal file
266
e2e/domain-overview-filters.perf.spec.ts
Normal file
@ -0,0 +1,266 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
attachDomainPerfMetrics,
|
||||
attachJsonArtifact,
|
||||
applyFilters,
|
||||
closeFilters,
|
||||
ensureFiltersOpen,
|
||||
expectPageResponsive,
|
||||
getDomainPerfMetrics,
|
||||
installDomainPerfProbe,
|
||||
openDomainOverview,
|
||||
openFilters,
|
||||
resetDomainPerfMetrics,
|
||||
switchDomainTab,
|
||||
type DomainPerfMetrics,
|
||||
typeIntoDraftInput,
|
||||
waitForDomainRows,
|
||||
} from "./domain-overview-test-utils";
|
||||
|
||||
const CPU_THROTTLE_RATE = Number(process.env.DOMAIN_FILTER_CPU_THROTTLE ?? 6);
|
||||
const PERF_BUDGETS = {
|
||||
actionMs: Number(process.env.DOMAIN_FILTER_ACTION_MS ?? 2_500),
|
||||
maxLongTaskMs: Number(process.env.DOMAIN_FILTER_MAX_LONG_TASK_MS ?? 1_000),
|
||||
maxRafGapMs: Number(process.env.DOMAIN_FILTER_MAX_RAF_GAP_MS ?? 1_500),
|
||||
maxInputMs: Number(process.env.DOMAIN_FILTER_MAX_INPUT_MS ?? 4_000),
|
||||
totalLongTaskMs: Number(
|
||||
process.env.DOMAIN_FILTER_TOTAL_LONG_TASK_MS ?? 8_000,
|
||||
),
|
||||
};
|
||||
|
||||
type PerfCheckpoint =
|
||||
| {
|
||||
label: string;
|
||||
metrics: DomainPerfMetrics;
|
||||
}
|
||||
| {
|
||||
label: string;
|
||||
error: string;
|
||||
};
|
||||
|
||||
type DomainDebugEntry = {
|
||||
type: string;
|
||||
text: string;
|
||||
parsed: unknown;
|
||||
};
|
||||
|
||||
test.describe("Domain Overview filter performance", () => {
|
||||
test("captures main-thread stalls in the applied-filter edit flow", async ({
|
||||
page,
|
||||
browserName,
|
||||
}, testInfo) => {
|
||||
test.skip(browserName !== "chromium", "CPU throttling requires CDP");
|
||||
test.setTimeout(120_000);
|
||||
|
||||
await installDomainPerfProbe(page);
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("debug:domain-overview", "1");
|
||||
});
|
||||
const domainDebugLog: DomainDebugEntry[] = [];
|
||||
page.on("console", (message) => {
|
||||
const text = message.text();
|
||||
if (!text.startsWith("[domain-debug]")) return;
|
||||
const raw = text.slice("[domain-debug]".length).trim();
|
||||
domainDebugLog.push({
|
||||
type: message.type(),
|
||||
text,
|
||||
parsed: parseDomainDebugMessage(raw),
|
||||
});
|
||||
});
|
||||
const client = await page.context().newCDPSession(page);
|
||||
await client.send("Emulation.setCPUThrottlingRate", {
|
||||
rate: CPU_THROTTLE_RATE,
|
||||
});
|
||||
|
||||
const checkpoints: PerfCheckpoint[] = [];
|
||||
let finalMetrics: DomainPerfMetrics | null = null;
|
||||
let flowError: unknown;
|
||||
const captureCheckpoint = async (label: string) => {
|
||||
checkpoints.push({
|
||||
label,
|
||||
metrics: await withDeadline(
|
||||
getDomainPerfMetrics(page),
|
||||
2_000,
|
||||
`Timed out reading perf metrics for ${label}`,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await openDomainOverview(page, "pages");
|
||||
await waitForDomainRows(page, "Top Pages");
|
||||
await resetDomainPerfMetrics(page);
|
||||
await captureCheckpoint("ready on Top Pages");
|
||||
|
||||
await openFilters(page);
|
||||
await closeFilters(page);
|
||||
await openFilters(page);
|
||||
await closeFilters(page);
|
||||
await openFilters(page);
|
||||
await captureCheckpoint("toggled Top Pages filters");
|
||||
|
||||
await typeIntoDraftInput(
|
||||
page,
|
||||
page.getByPlaceholder("Min").nth(0),
|
||||
"10",
|
||||
"Pages Traffic min",
|
||||
{
|
||||
actionTimeoutMs: PERF_BUDGETS.actionMs,
|
||||
cdpSession: client,
|
||||
inputLatencyBudgetMs: PERF_BUDGETS.maxInputMs,
|
||||
recordPerf: true,
|
||||
},
|
||||
);
|
||||
await applyFilters(page, "pMinTraffic", "10");
|
||||
await captureCheckpoint("applied Pages Traffic min");
|
||||
|
||||
await ensureFiltersOpen(page, "Include Page Terms");
|
||||
await typeIntoDraftInput(
|
||||
page,
|
||||
page.getByPlaceholder("Max").nth(1),
|
||||
"50",
|
||||
"Pages Keywords max",
|
||||
{
|
||||
actionTimeoutMs: PERF_BUDGETS.actionMs,
|
||||
cdpSession: client,
|
||||
inputLatencyBudgetMs: PERF_BUDGETS.maxInputMs,
|
||||
recordPerf: true,
|
||||
},
|
||||
);
|
||||
await expect(page.getByText("unapplied")).toBeVisible();
|
||||
await expectPageResponsive(page, "after editing Pages Keywords max");
|
||||
await captureCheckpoint("edited Pages Keywords max");
|
||||
|
||||
await switchDomainTab(page, "keywords");
|
||||
await waitForDomainRows(page, "Top Keywords");
|
||||
await openFilters(page);
|
||||
await typeIntoDraftInput(
|
||||
page,
|
||||
page.getByPlaceholder("Max").nth(1),
|
||||
"5000",
|
||||
"Keywords Volume max",
|
||||
{
|
||||
actionTimeoutMs: PERF_BUDGETS.actionMs,
|
||||
cdpSession: client,
|
||||
inputLatencyBudgetMs: PERF_BUDGETS.maxInputMs,
|
||||
recordPerf: true,
|
||||
},
|
||||
);
|
||||
await expect(page.getByText("unapplied")).toBeVisible();
|
||||
await expectPageResponsive(page, "after editing Keywords Volume max");
|
||||
} catch (error) {
|
||||
flowError = error;
|
||||
checkpoints.push({
|
||||
label: "flow error",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
if (flowError) {
|
||||
checkpoints.push({
|
||||
label: "final metrics",
|
||||
error:
|
||||
"Skipped after renderer stall to preserve the original failure",
|
||||
});
|
||||
} else {
|
||||
finalMetrics = await withDeadline(
|
||||
getDomainPerfMetrics(page),
|
||||
2_000,
|
||||
"Timed out reading final perf metrics",
|
||||
).catch((error: unknown) => {
|
||||
checkpoints.push({
|
||||
label: "final metrics",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
await attachJsonArtifact(
|
||||
testInfo,
|
||||
"domain-filter-perf-checkpoints.json",
|
||||
checkpoints,
|
||||
);
|
||||
await attachJsonArtifact(
|
||||
testInfo,
|
||||
"domain-filter-debug-log.json",
|
||||
domainDebugLog,
|
||||
);
|
||||
if (finalMetrics) {
|
||||
await attachDomainPerfMetrics(testInfo, finalMetrics);
|
||||
}
|
||||
await withDeadline(
|
||||
client.send("Emulation.setCPUThrottlingRate", { rate: 1 }),
|
||||
2_000,
|
||||
"Timed out resetting CPU throttling",
|
||||
).catch(() => undefined);
|
||||
}
|
||||
|
||||
if (flowError) throw flowError;
|
||||
if (!finalMetrics) throw new Error("Perf metrics were not collected");
|
||||
|
||||
console.info(
|
||||
"[domain-filter-perf]",
|
||||
JSON.stringify(summarizeMetrics(finalMetrics)),
|
||||
);
|
||||
|
||||
expect(finalMetrics.errors, "browser console/runtime errors").toEqual([]);
|
||||
expect(
|
||||
finalMetrics.maxInputDuration,
|
||||
"slowest measured input",
|
||||
).toBeLessThan(PERF_BUDGETS.maxInputMs);
|
||||
expect(finalMetrics.maxLongTaskDuration, "slowest long task").toBeLessThan(
|
||||
PERF_BUDGETS.maxLongTaskMs,
|
||||
);
|
||||
expect(
|
||||
finalMetrics.totalLongTaskDuration,
|
||||
"total long-task time",
|
||||
).toBeLessThan(PERF_BUDGETS.totalLongTaskMs);
|
||||
expect(finalMetrics.maxRafGap, "largest animation-frame gap").toBeLessThan(
|
||||
PERF_BUDGETS.maxRafGapMs,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function summarizeMetrics(metrics: DomainPerfMetrics) {
|
||||
return {
|
||||
maxInputDuration: Math.round(metrics.maxInputDuration),
|
||||
maxLongTaskDuration: Math.round(metrics.maxLongTaskDuration),
|
||||
totalLongTaskDuration: Math.round(metrics.totalLongTaskDuration),
|
||||
maxRafGap: Math.round(metrics.maxRafGap),
|
||||
longTaskCount: metrics.longTasks.length,
|
||||
rafGapCount: metrics.rafGaps.length,
|
||||
inputEvents: metrics.inputEvents.map((event) => ({
|
||||
label: event.label,
|
||||
duration: Math.round(event.duration),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function parseDomainDebugMessage(raw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
async function withDeadline<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
message: string,
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
231
e2e/domain-overview-filters.spec.ts
Normal file
231
e2e/domain-overview-filters.spec.ts
Normal file
@ -0,0 +1,231 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
applyFilters,
|
||||
ensureFiltersOpen,
|
||||
expectPageResponsive,
|
||||
openDomainOverview,
|
||||
PRIMARY_TEST_DOMAIN,
|
||||
SECONDARY_TEST_DOMAIN,
|
||||
openFilters,
|
||||
switchDomainTab,
|
||||
typeIntoDraftInput,
|
||||
} from "./domain-overview-test-utils";
|
||||
|
||||
test.describe("Domain Overview filters", () => {
|
||||
test("closing an inactive search tab does not select it", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openDomainOverview(page, "keywords");
|
||||
const firstUrl = new URL(page.url());
|
||||
|
||||
const secondUrl = new URL(page.url());
|
||||
secondUrl.searchParams.set("domain", SECONDARY_TEST_DOMAIN);
|
||||
await page.goto(secondUrl.toString());
|
||||
await expect(
|
||||
page.getByRole("tab", { name: SECONDARY_TEST_DOMAIN }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
const inactiveCloseButton = page.getByRole("button", {
|
||||
name: `Close ${PRIMARY_TEST_DOMAIN} tab`,
|
||||
});
|
||||
await inactiveCloseButton.click();
|
||||
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("domain"))
|
||||
.toBe(SECONDARY_TEST_DOMAIN);
|
||||
await expect(
|
||||
page.getByRole("tab", { name: SECONDARY_TEST_DOMAIN }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await expect(
|
||||
page.getByRole("tab", { name: PRIMARY_TEST_DOMAIN }),
|
||||
).toHaveCount(0);
|
||||
expect(firstUrl.searchParams.get("domain")).toBe(PRIMARY_TEST_DOMAIN);
|
||||
});
|
||||
|
||||
test("closing the active search tab removes it and selects the neighbor", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openDomainOverview(page, "keywords");
|
||||
|
||||
const secondUrl = new URL(page.url());
|
||||
secondUrl.searchParams.set("domain", SECONDARY_TEST_DOMAIN);
|
||||
await page.goto(secondUrl.toString());
|
||||
await expect(
|
||||
page.getByRole("tab", { name: SECONDARY_TEST_DOMAIN }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
const activeCloseButton = page.getByRole("button", {
|
||||
name: `Close ${SECONDARY_TEST_DOMAIN} tab`,
|
||||
});
|
||||
const closedTabId =
|
||||
await activeCloseButton.getAttribute("data-search-tab-id");
|
||||
expect(closedTabId).toBeTruthy();
|
||||
|
||||
await activeCloseButton.click();
|
||||
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("domain"))
|
||||
.toBe(PRIMARY_TEST_DOMAIN);
|
||||
await expect(
|
||||
page.getByRole("tab", { name: PRIMARY_TEST_DOMAIN }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await expect(
|
||||
page.locator(`[data-search-tab-id="${closedTabId}"]`),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("keyword filters stay responsive after applying Traffic min and editing Volume max", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openDomainOverview(page, "keywords");
|
||||
|
||||
await openFilters(page);
|
||||
await typeIntoDraftInput(
|
||||
page,
|
||||
page.getByPlaceholder("Min").nth(0),
|
||||
"10",
|
||||
"Traffic min",
|
||||
);
|
||||
await applyFilters(page);
|
||||
|
||||
await ensureFiltersOpen(page, "Include Terms");
|
||||
await typeIntoDraftInput(
|
||||
page,
|
||||
page.getByPlaceholder("Max").nth(1),
|
||||
"5000",
|
||||
"Volume max",
|
||||
);
|
||||
|
||||
await expect(page.getByText("unapplied")).toBeVisible();
|
||||
await expectPageResponsive(page, "after editing Volume max");
|
||||
});
|
||||
|
||||
test("page filters stay responsive after applying Traffic min and editing Keywords max", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openDomainOverview(page, "pages");
|
||||
|
||||
await openFilters(page);
|
||||
await typeIntoDraftInput(
|
||||
page,
|
||||
page.getByPlaceholder("Min").nth(0),
|
||||
"10",
|
||||
"Traffic min",
|
||||
);
|
||||
await applyFilters(page, "pMinTraffic", "10");
|
||||
|
||||
await ensureFiltersOpen(page, "Include Page Terms");
|
||||
await typeIntoDraftInput(
|
||||
page,
|
||||
page.getByPlaceholder("Max").nth(1),
|
||||
"50",
|
||||
"Keywords max",
|
||||
);
|
||||
|
||||
await expect(page.getByText("unapplied")).toBeVisible();
|
||||
await expectPageResponsive(page, "after editing Keywords max");
|
||||
});
|
||||
|
||||
test("clearing page filters does not clear keyword filters", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openDomainOverview(page, "keywords");
|
||||
|
||||
await openFilters(page);
|
||||
await typeIntoDraftInput(
|
||||
page,
|
||||
page.getByPlaceholder("Min").nth(0),
|
||||
"10",
|
||||
"Keyword traffic min",
|
||||
);
|
||||
await applyFilters(page, "minTraffic", "10");
|
||||
|
||||
await switchDomainTab(page, "pages");
|
||||
await openFilters(page);
|
||||
await typeIntoDraftInput(
|
||||
page,
|
||||
page.getByPlaceholder("Min").nth(0),
|
||||
"20",
|
||||
"Page traffic min",
|
||||
);
|
||||
await applyFilters(page, "pMinTraffic", "20");
|
||||
|
||||
await ensureFiltersOpen(page, "Include Page Terms");
|
||||
await page.getByRole("button", { name: "Clear all" }).click();
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("pMinTraffic"))
|
||||
.toBe(null);
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("minTraffic"))
|
||||
.toBe("10");
|
||||
await expectPageResponsive(page, "after clearing page filters");
|
||||
|
||||
const keywordUrl = new URL(page.url());
|
||||
keywordUrl.searchParams.delete("tab");
|
||||
await page.goto(keywordUrl.toString());
|
||||
await ensureFiltersOpen(page, "Include Terms");
|
||||
await expect(page.getByPlaceholder("Min").nth(0)).toHaveValue("10");
|
||||
});
|
||||
|
||||
test("submitting a new domain clears the previous domain filters", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openDomainOverview(page, "pages");
|
||||
|
||||
await openFilters(page);
|
||||
await typeIntoDraftInput(
|
||||
page,
|
||||
page.getByPlaceholder("Min").nth(0),
|
||||
"20",
|
||||
"Page traffic min",
|
||||
);
|
||||
await applyFilters(page, "pMinTraffic", "20");
|
||||
|
||||
const domainInput = page.getByPlaceholder("Enter a domain").nth(1);
|
||||
await domainInput.click();
|
||||
await domainInput.press(
|
||||
process.platform === "darwin" ? "Meta+A" : "Control+A",
|
||||
);
|
||||
await domainInput.press("Backspace");
|
||||
await domainInput.pressSequentially(SECONDARY_TEST_DOMAIN);
|
||||
await page.getByRole("button", { name: "Search", exact: true }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("domain"))
|
||||
.toBe(SECONDARY_TEST_DOMAIN);
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("pMinTraffic"))
|
||||
.toBe(null);
|
||||
await ensureFiltersOpen(page, "Include Page Terms");
|
||||
await expect(page.getByPlaceholder("Min").nth(0)).toHaveValue("");
|
||||
await expectPageResponsive(page, "after submitting a new domain");
|
||||
});
|
||||
|
||||
test("saved filter defaults apply only when the URL has no tab filters", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openDomainOverview(page, "pages");
|
||||
|
||||
await openFilters(page);
|
||||
await typeIntoDraftInput(
|
||||
page,
|
||||
page.getByPlaceholder("Min").nth(0),
|
||||
"20",
|
||||
"Page traffic min",
|
||||
);
|
||||
await applyFilters(page, "pMinTraffic", "20");
|
||||
|
||||
const urlWithoutPageFilters = new URL(page.url());
|
||||
urlWithoutPageFilters.searchParams.delete("pMinTraffic");
|
||||
await page.goto(urlWithoutPageFilters.toString());
|
||||
await ensureFiltersOpen(page, "Include Page Terms");
|
||||
await expect(page.getByPlaceholder("Min").nth(0)).toHaveValue("20");
|
||||
expect(new URL(page.url()).searchParams.get("pMinTraffic")).toBe(null);
|
||||
|
||||
const urlWithExplicitPageFilters = new URL(page.url());
|
||||
urlWithExplicitPageFilters.searchParams.set("pMinTraffic", "30");
|
||||
await page.goto(urlWithExplicitPageFilters.toString());
|
||||
await ensureFiltersOpen(page, "Include Page Terms");
|
||||
await expect(page.getByPlaceholder("Min").nth(0)).toHaveValue("30");
|
||||
});
|
||||
});
|
||||
372
e2e/domain-overview-test-utils.ts
Normal file
372
e2e/domain-overview-test-utils.ts
Normal file
@ -0,0 +1,372 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import {
|
||||
expect,
|
||||
type CDPSession,
|
||||
type Locator,
|
||||
type Page,
|
||||
type TestInfo,
|
||||
} from "@playwright/test";
|
||||
|
||||
export const PRIMARY_TEST_DOMAIN = "primary.example";
|
||||
export const SECONDARY_TEST_DOMAIN = "secondary.example";
|
||||
const RESPONSIVE_TIMEOUT_MS = 1_500;
|
||||
const INPUT_LATENCY_BUDGET_MS = 8_000;
|
||||
|
||||
type DomainTab = "keywords" | "pages";
|
||||
|
||||
type DomainLongTask = {
|
||||
name: string;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
type DomainRafGap = {
|
||||
startTime: number;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
type DomainInputEvent = {
|
||||
label: string;
|
||||
duration: number;
|
||||
time: number;
|
||||
};
|
||||
|
||||
type DomainPerfState = {
|
||||
startedAt: number;
|
||||
longTasks: DomainLongTask[];
|
||||
rafGaps: DomainRafGap[];
|
||||
maxRafGap: number;
|
||||
inputEvents: DomainInputEvent[];
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
type WindowWithDomainPerf = Window &
|
||||
typeof globalThis & {
|
||||
__domainPerf?: DomainPerfState;
|
||||
};
|
||||
|
||||
export type DomainPerfMetrics = DomainPerfState & {
|
||||
url: string;
|
||||
userAgent: string;
|
||||
maxLongTaskDuration: number;
|
||||
totalLongTaskDuration: number;
|
||||
maxInputDuration: number;
|
||||
};
|
||||
|
||||
export async function installDomainPerfProbe(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
const state: DomainPerfState = {
|
||||
startedAt: performance.now(),
|
||||
longTasks: [],
|
||||
rafGaps: [],
|
||||
maxRafGap: 0,
|
||||
inputEvents: [],
|
||||
errors: [],
|
||||
};
|
||||
const win = window as WindowWithDomainPerf;
|
||||
win.__domainPerf = state;
|
||||
|
||||
try {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
state.longTasks.push({
|
||||
name: entry.name,
|
||||
startTime: entry.startTime,
|
||||
duration: entry.duration,
|
||||
});
|
||||
}
|
||||
});
|
||||
observer.observe({ type: "longtask", buffered: true });
|
||||
} catch {
|
||||
// Long Tasks are Chromium-only. The rest of the probe still catches stalls.
|
||||
}
|
||||
|
||||
let lastFrame = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const gap = now - lastFrame;
|
||||
state.maxRafGap = Math.max(state.maxRafGap, gap);
|
||||
if (gap > 100) {
|
||||
state.rafGaps.push({ startTime: lastFrame, duration: gap });
|
||||
}
|
||||
lastFrame = now;
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
state.errors.push(event.message);
|
||||
});
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
state.errors.push(String(event.reason));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetDomainPerfMetrics(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const state = (window as WindowWithDomainPerf).__domainPerf;
|
||||
if (!state) return;
|
||||
state.startedAt = performance.now();
|
||||
state.longTasks = [];
|
||||
state.rafGaps = [];
|
||||
state.maxRafGap = 0;
|
||||
state.inputEvents = [];
|
||||
state.errors = [];
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDomainPerfMetrics(
|
||||
page: Page,
|
||||
): Promise<DomainPerfMetrics> {
|
||||
return page.evaluate(() => {
|
||||
const state = (window as WindowWithDomainPerf).__domainPerf;
|
||||
if (!state) {
|
||||
throw new Error("Domain perf probe was not installed");
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
url: window.location.href,
|
||||
userAgent: navigator.userAgent,
|
||||
maxLongTaskDuration: Math.max(
|
||||
0,
|
||||
...state.longTasks.map((entry) => entry.duration),
|
||||
),
|
||||
totalLongTaskDuration: state.longTasks.reduce(
|
||||
(total, entry) => total + entry.duration,
|
||||
0,
|
||||
),
|
||||
maxInputDuration: Math.max(
|
||||
0,
|
||||
...state.inputEvents.map((entry) => entry.duration),
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function attachDomainPerfMetrics(
|
||||
testInfo: TestInfo,
|
||||
metrics: DomainPerfMetrics,
|
||||
) {
|
||||
await attachJsonArtifact(testInfo, "domain-filter-perf.json", metrics);
|
||||
}
|
||||
|
||||
export async function attachJsonArtifact(
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
value: unknown,
|
||||
) {
|
||||
const outputPath = testInfo.outputPath(name);
|
||||
await writeFile(outputPath, JSON.stringify(value, null, 2));
|
||||
await testInfo.attach(name, {
|
||||
path: outputPath,
|
||||
contentType: "application/json",
|
||||
});
|
||||
}
|
||||
|
||||
export async function openDomainOverview(page: Page, tab: DomainTab) {
|
||||
await page.addInitScript(() => {
|
||||
if (window.sessionStorage.getItem("domain-overview-e2e-cleared") === "1") {
|
||||
return;
|
||||
}
|
||||
for (let index = window.localStorage.length - 1; index >= 0; index -= 1) {
|
||||
const key = window.localStorage.key(index);
|
||||
if (key?.startsWith("domain-overview-filter-defaults:")) {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
window.sessionStorage.setItem("domain-overview-e2e-cleared", "1");
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.waitForURL(/\/p\/([^/]+)\/keywords(?:\?.*)?$/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const match = page.url().match(/\/p\/([^/]+)\/keywords/);
|
||||
if (!match) throw new Error(`Could not read project id from ${page.url()}`);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
domain: PRIMARY_TEST_DOMAIN,
|
||||
subdomains: "true",
|
||||
sort: "traffic",
|
||||
order: "desc",
|
||||
});
|
||||
if (tab === "pages") params.set("tab", "pages");
|
||||
|
||||
await page.goto(`/p/${match[1]}/domain?${params.toString()}`);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Domain Overview" }),
|
||||
).toBeVisible();
|
||||
await dismissSetupModal(page);
|
||||
await expect(page.getByRole("button", { name: /Filters/ })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectPageResponsive(page, "after opening Domain Overview");
|
||||
}
|
||||
|
||||
async function dismissSetupModal(page: Page) {
|
||||
const dismissButton = page.getByRole("button", { name: "Dismiss" });
|
||||
if (await dismissButton.isVisible()) {
|
||||
await dismissButton.click();
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForDomainRows(page: Page, label: string) {
|
||||
const marker = label.includes("Pages")
|
||||
? page.getByRole("link", { name: /\/section-a\/page-001/ }).first()
|
||||
: page
|
||||
.getByRole("row", {
|
||||
name: /primary sample query.*\/section-a\/page-001/,
|
||||
})
|
||||
.first();
|
||||
await expect(marker, label).toBeVisible({ timeout: 30_000 });
|
||||
await expectPageResponsive(page, `after table rows loaded for ${label}`);
|
||||
}
|
||||
|
||||
export async function switchDomainTab(page: Page, tab: DomainTab) {
|
||||
const label = tab === "keywords" ? "Top Keywords" : "Top Pages";
|
||||
await page.getByRole("tab", { name: label }).click();
|
||||
await expect(page.getByRole("tab", { name: label })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expectPageResponsive(page, `after switching to ${label}`);
|
||||
}
|
||||
|
||||
export async function openFilters(page: Page) {
|
||||
await page.getByRole("button", { name: /Filters/ }).click();
|
||||
await expect(page.getByText("Refine table results")).toBeVisible();
|
||||
await expectPageResponsive(page, "after opening filters");
|
||||
}
|
||||
|
||||
export async function closeFilters(page: Page) {
|
||||
await page.getByRole("button", { name: /Filters/ }).click();
|
||||
await expect(page.getByText("Refine table results")).toBeHidden();
|
||||
await expectPageResponsive(page, "after closing filters");
|
||||
}
|
||||
|
||||
export async function ensureFiltersOpen(page: Page, expectedLabel: string) {
|
||||
if (!(await page.getByText(expectedLabel).isVisible())) {
|
||||
await openFilters(page);
|
||||
}
|
||||
await expect(page.getByText(expectedLabel)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function applyFilters(
|
||||
page: Page,
|
||||
expectedParam = "minTraffic",
|
||||
expectedValue = "10",
|
||||
) {
|
||||
await page.getByRole("button", { name: /Apply filters/ }).click();
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get(expectedParam))
|
||||
.toBe(expectedValue);
|
||||
await expect(page.getByRole("button", { name: /Filters/ })).toContainText(
|
||||
"1",
|
||||
);
|
||||
await expectPageResponsive(page, "after applying filters");
|
||||
}
|
||||
|
||||
export async function typeIntoDraftInput(
|
||||
page: Page,
|
||||
input: Locator,
|
||||
value: string,
|
||||
label: string,
|
||||
options?: {
|
||||
actionTimeoutMs?: number;
|
||||
cdpSession?: CDPSession;
|
||||
inputLatencyBudgetMs?: number;
|
||||
recordPerf?: boolean;
|
||||
},
|
||||
) {
|
||||
const actionTimeout = options?.actionTimeoutMs ?? 5_000;
|
||||
const budget = options?.inputLatencyBudgetMs ?? INPUT_LATENCY_BUDGET_MS;
|
||||
const started = Date.now();
|
||||
await input.click({ timeout: actionTimeout });
|
||||
if (options?.cdpSession) {
|
||||
await withTimeout(
|
||||
input.evaluate((element) => {
|
||||
if (!(element instanceof HTMLInputElement)) {
|
||||
throw new Error("Expected a text input");
|
||||
}
|
||||
element.value = "";
|
||||
element.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}),
|
||||
actionTimeout,
|
||||
`Input clear did not settle for ${label}`,
|
||||
);
|
||||
} else {
|
||||
await input.press(process.platform === "darwin" ? "Meta+A" : "Control+A", {
|
||||
timeout: actionTimeout,
|
||||
});
|
||||
await input.press("Backspace", { timeout: actionTimeout });
|
||||
}
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const char = value[index] ?? "";
|
||||
const keyStarted = Date.now();
|
||||
if (options?.cdpSession) {
|
||||
await withTimeout(
|
||||
options.cdpSession.send("Input.dispatchKeyEvent", {
|
||||
type: "char",
|
||||
text: char,
|
||||
}),
|
||||
actionTimeout,
|
||||
`CDP input did not settle for ${label} char ${index + 1}`,
|
||||
);
|
||||
} else {
|
||||
await input.press(char, { timeout: actionTimeout });
|
||||
}
|
||||
const keyDuration = Date.now() - keyStarted;
|
||||
if (options?.recordPerf) {
|
||||
await recordInputLatency(page, `${label} char ${index + 1}`, keyDuration);
|
||||
}
|
||||
await expectPageResponsive(page, `after typing ${label}`);
|
||||
}
|
||||
|
||||
const duration = Date.now() - started;
|
||||
if (options?.recordPerf) {
|
||||
await recordInputLatency(page, label, duration);
|
||||
}
|
||||
expect(duration, `${label} input latency`).toBeLessThan(budget);
|
||||
}
|
||||
|
||||
export async function expectPageResponsive(page: Page, label: string) {
|
||||
await withTimeout(
|
||||
page.evaluate(() => document.body.textContent?.includes("Domain Overview")),
|
||||
RESPONSIVE_TIMEOUT_MS,
|
||||
`Page did not respond ${label}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function recordInputLatency(page: Page, label: string, duration: number) {
|
||||
await page.evaluate(
|
||||
({ label: eventLabel, duration: eventDuration }) => {
|
||||
const state = (window as WindowWithDomainPerf).__domainPerf;
|
||||
state?.inputEvents.push({
|
||||
label: eventLabel,
|
||||
duration: eventDuration,
|
||||
time: performance.now(),
|
||||
});
|
||||
},
|
||||
{ label, duration },
|
||||
);
|
||||
}
|
||||
|
||||
async function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
message: string,
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
85
e2e/fixtures/domain-overview-fixtures.ts
Normal file
85
e2e/fixtures/domain-overview-fixtures.ts
Normal file
@ -0,0 +1,85 @@
|
||||
export function getFixtureOverview(domain: string) {
|
||||
return {
|
||||
domain,
|
||||
organicTraffic: 373,
|
||||
organicKeywords: 307,
|
||||
backlinks: null,
|
||||
referringDomains: null,
|
||||
hasData: true,
|
||||
fetchedAt: "2026-05-19T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
export function getFixturePagesPage(data: {
|
||||
domain: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}) {
|
||||
const rows = [
|
||||
["/section-a/page-001", 179, 34],
|
||||
["/section-a/page-002", 63, 9],
|
||||
["/section-b/page-003", 23, 25],
|
||||
["/section-b/page-004", 19, 16],
|
||||
["/section-b/page-005", 17, 12],
|
||||
["/section-c/page-006", 8, 7],
|
||||
["/section-c/page-007", 8, 11],
|
||||
["/guides/topic-008", 3, 10],
|
||||
["/guides/topic-009", 3, 11],
|
||||
["/guides/topic-010", 3, 7],
|
||||
["/guides/topic-011", 3, 6],
|
||||
["/guides/topic-012", 2, 5],
|
||||
];
|
||||
const pages = rows.map(([path, traffic, keywords]) => ({
|
||||
page: `https://${data.domain}${path}`,
|
||||
relativePath: String(path),
|
||||
organicTraffic: Number(traffic),
|
||||
keywords: Number(keywords),
|
||||
}));
|
||||
|
||||
return {
|
||||
domain: data.domain,
|
||||
page: data.page,
|
||||
pageSize: data.pageSize,
|
||||
totalCount: 66,
|
||||
hasMore: data.page * data.pageSize < 66,
|
||||
pages,
|
||||
fetchedAt: "2026-05-19T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
export function getFixtureKeywordsPage(data: {
|
||||
domain: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}) {
|
||||
const rows = [
|
||||
["primary sample query", 1, 4_400, 86, 0.61, 18],
|
||||
["secondary sample query", 2, 3_600, 74, 0.82, 24],
|
||||
["tertiary sample query", 1, 1_900, 51, 0.44, 21],
|
||||
["sample comparison term", 3, 1_600, 39, 0.52, 28],
|
||||
["sample evaluation term", 4, 1_300, 31, 0.49, 25],
|
||||
["sample reference term", 5, 1_100, 24, 0.37, 19],
|
||||
];
|
||||
const keywords = rows.map(
|
||||
([keyword, position, searchVolume, traffic, cpc, keywordDifficulty]) => ({
|
||||
keyword: String(keyword),
|
||||
position: Number(position),
|
||||
searchVolume: Number(searchVolume),
|
||||
traffic: Number(traffic),
|
||||
cpc: Number(cpc),
|
||||
url: `https://${data.domain}/section-a/page-001`,
|
||||
relativeUrl: "/section-a/page-001",
|
||||
keywordDifficulty: Number(keywordDifficulty),
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
domain: data.domain,
|
||||
page: data.page,
|
||||
pageSize: data.pageSize,
|
||||
totalCount: 307,
|
||||
hasMore: data.page * data.pageSize < 307,
|
||||
keywords,
|
||||
fetchedAt: "2026-05-19T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
73
e2e/fixtures/keyword-research-fixtures.ts
Normal file
73
e2e/fixtures/keyword-research-fixtures.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import type { KeywordResearchRow } from "@/types/keywords";
|
||||
import type { ResearchKeywordsInput } from "@/types/schemas/keywords";
|
||||
|
||||
const MONTHLY_SEARCHES = [
|
||||
{ year: 2025, month: 4, searchVolume: 1200 },
|
||||
{ year: 2025, month: 5, searchVolume: 1600 },
|
||||
{ year: 2025, month: 6, searchVolume: 2400 },
|
||||
{ year: 2025, month: 7, searchVolume: 3200 },
|
||||
{ year: 2025, month: 8, searchVolume: 4200 },
|
||||
{ year: 2025, month: 9, searchVolume: 3600 },
|
||||
{ year: 2025, month: 10, searchVolume: 3000 },
|
||||
{ year: 2025, month: 11, searchVolume: 2600 },
|
||||
{ year: 2025, month: 12, searchVolume: 2200 },
|
||||
{ year: 2026, month: 1, searchVolume: 2100 },
|
||||
{ year: 2026, month: 2, searchVolume: 2300 },
|
||||
{ year: 2026, month: 3, searchVolume: 2800 },
|
||||
];
|
||||
|
||||
function makeRow(
|
||||
keyword: string,
|
||||
index: number,
|
||||
overrides: Partial<KeywordResearchRow> = {},
|
||||
): KeywordResearchRow {
|
||||
return {
|
||||
keyword,
|
||||
searchVolume: 20_000 - index * 750,
|
||||
trend: MONTHLY_SEARCHES,
|
||||
keywordDifficulty: 40 + (index % 40),
|
||||
cpc: Number((1.25 + index * 0.15).toFixed(2)),
|
||||
competition: Number((0.05 + (index % 10) * 0.04).toFixed(2)),
|
||||
intent: index % 3 === 0 ? "commercial" : "informational",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function getKeywordResearchFixture(data: ResearchKeywordsInput) {
|
||||
const seedKeyword = data.keywords[0] ?? "keyword research";
|
||||
const rows = [
|
||||
makeRow(seedKeyword, 0, {
|
||||
searchVolume: 288_431,
|
||||
keywordDifficulty: 78,
|
||||
cpc: 11.93,
|
||||
competition: 0.07,
|
||||
intent: "informational",
|
||||
}),
|
||||
makeRow(`${seedKeyword} tools`, 1),
|
||||
makeRow(`${seedKeyword} software`, 2),
|
||||
makeRow(`${seedKeyword} checklist`, 3),
|
||||
makeRow(`${seedKeyword} template`, 4),
|
||||
makeRow(`${seedKeyword} examples`, 5),
|
||||
makeRow(`${seedKeyword} guide`, 6),
|
||||
makeRow(`${seedKeyword} strategy`, 7),
|
||||
makeRow(`${seedKeyword} platform`, 8),
|
||||
makeRow(`${seedKeyword} generator`, 9),
|
||||
];
|
||||
|
||||
return {
|
||||
rows,
|
||||
source: "related" as const,
|
||||
usedFallback: false,
|
||||
diagnostics: {
|
||||
requestedMode: data.mode,
|
||||
threshold: 3,
|
||||
sourceAttempts: [
|
||||
{
|
||||
source: "related" as const,
|
||||
rowCount: rows.length,
|
||||
nonSeedCount: rows.length - 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
120
e2e/keyword-research-navigation.spec.ts
Normal file
120
e2e/keyword-research-navigation.spec.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function getProjectId(page: Page) {
|
||||
await page.goto("/");
|
||||
await page.waitForURL(/\/p\/([^/]+)\/keywords(?:\?.*)?$/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const match = page.url().match(/\/p\/([^/]+)\/keywords/);
|
||||
if (!match) throw new Error(`Could not read project id from ${page.url()}`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
test.describe("Keyword Research navigation", () => {
|
||||
test("Back to Recent searches clears the active keyword tab query", async ({
|
||||
page,
|
||||
}) => {
|
||||
const projectId = await getProjectId(page);
|
||||
|
||||
await page.goto(
|
||||
`/p/${projectId}/keywords?q=keyword%20research&loc=2840&kLimit=150&mode=auto`,
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Keyword Research", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("row", { name: /keyword research/i }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
const recentSearchesButton = page.locator(
|
||||
'[data-testid="keyword-research-recent-searches"]:visible',
|
||||
);
|
||||
await expect(recentSearchesButton).toBeVisible();
|
||||
|
||||
await recentSearchesButton.click();
|
||||
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("q"))
|
||||
.toBe(null);
|
||||
await expect(
|
||||
page.getByRole("link", { name: "keyword research US" }),
|
||||
).toBeVisible();
|
||||
await expect(recentSearchesButton).toBeHidden();
|
||||
});
|
||||
|
||||
test("clicking keyword tabs keeps the clicked tab and URL in sync", async ({
|
||||
page,
|
||||
}) => {
|
||||
const projectId = await getProjectId(page);
|
||||
|
||||
await page.goto(
|
||||
`/p/${projectId}/keywords?q=keyword%20research&loc=2840&kLimit=150&mode=auto`,
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("row", { name: /keyword research/i }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(
|
||||
`/p/${projectId}/keywords?q=backlinks&loc=2840&kLimit=150&mode=auto`,
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("row", { name: /backlinks/i }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByRole("tab", { name: /keyword research/i }).click();
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("q"))
|
||||
.toBe("keyword research");
|
||||
await expect(
|
||||
page.getByRole("row", { name: /keyword research/i }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole("tab", { name: /^backlinks/i }).click();
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("q"))
|
||||
.toBe("backlinks");
|
||||
await expect(
|
||||
page.getByRole("row", { name: /backlinks/i }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("closing the active middle keyword tab removes it and selects the next tab", async ({
|
||||
page,
|
||||
}) => {
|
||||
const projectId = await getProjectId(page);
|
||||
|
||||
for (const keyword of ["ai seo", "backlinks", "open seo"]) {
|
||||
await page.goto(
|
||||
`/p/${projectId}/keywords?q=${encodeURIComponent(keyword)}&loc=2840&kLimit=150&mode=auto`,
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("row", { name: new RegExp(keyword, "i") }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
await page.getByRole("tab", { name: /^backlinks/i }).click();
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("q"))
|
||||
.toBe("backlinks");
|
||||
|
||||
const closeButton = page.getByRole("button", {
|
||||
name: "Close backlinks tab",
|
||||
});
|
||||
const closedTabId = await closeButton.getAttribute("data-search-tab-id");
|
||||
expect(closedTabId).toBeTruthy();
|
||||
|
||||
await closeButton.click();
|
||||
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("q"))
|
||||
.toBe("open seo");
|
||||
await expect(page.getByRole("tab", { name: /^open seo/i })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(
|
||||
page.locator(`[data-search-tab-id="${closedTabId}"]`),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByRole("tab")).toHaveCount(2);
|
||||
});
|
||||
});
|
||||
@ -28,6 +28,10 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ci": "vitest run --reporter=dot",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:domain": "playwright test e2e/domain-overview-filters.spec.ts",
|
||||
"test:e2e:domain:perf": "playwright test e2e/domain-overview-filters.perf.spec.ts",
|
||||
"test:e2e:keywords": "playwright test e2e/keyword-research-navigation.spec.ts",
|
||||
"billing:backlinks": "tsx scripts/backlinks-cost-profile.ts",
|
||||
"billing:brand-lookup": "tsx scripts/brand-lookup-cost-profile.ts",
|
||||
"cleanup:default-projects:d1": "tsx scripts/d1-default-project-cleanup.ts",
|
||||
@ -94,6 +98,7 @@
|
||||
"@cloudflare/vite-plugin": "^1.13.18",
|
||||
"@cloudflare/workers-types": "^4.20251014.0",
|
||||
"@libsql/client": "^0.15.15",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/devtools-vite": "^0.6.0",
|
||||
"@tanstack/react-devtools": "^0.10.1",
|
||||
|
||||
28
playwright.config.ts
Normal file
28
playwright.config.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
timeout: 45_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL: "http://localhost:3101",
|
||||
actionTimeout: 5_000,
|
||||
navigationTimeout: 30_000,
|
||||
channel: process.env.PLAYWRIGHT_CHANNEL ?? "chrome",
|
||||
screenshot: "only-on-failure",
|
||||
trace: "retain-on-failure",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
webServer: {
|
||||
command:
|
||||
"NODE_OPTIONS= AUTH_MODE=local_noauth VITE_E2E_DOMAIN_FIXTURES=1 VITE_E2E_KEYWORD_FIXTURES=1 PORT=3101 pnpm exec vite dev --host 127.0.0.1 --strictPort",
|
||||
url: "http://localhost:3101",
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
38
pnpm-lock.yaml
generated
38
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -127,3 +127,42 @@ export function TableBulkExportMenu({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableExportMenu({
|
||||
actions,
|
||||
buttonClassName = "btn btn-sm gap-1",
|
||||
menuClassName = "dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-56",
|
||||
}: {
|
||||
actions: Array<{
|
||||
label: ReactNode;
|
||||
icon?: ReactNode;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}>;
|
||||
buttonClassName?: string;
|
||||
menuClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="dropdown dropdown-end">
|
||||
<div tabIndex={0} role="button" className={buttonClassName}>
|
||||
<Download className="size-4" />
|
||||
Export
|
||||
<ChevronDown className="size-3 opacity-60" />
|
||||
</div>
|
||||
<ul tabIndex={0} className={menuClassName}>
|
||||
{actions.map((action, index) => (
|
||||
<li key={index}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
275
src/client/features/audit/results/AuditResultsTableFilters.tsx
Normal file
275
src/client/features/audit/results/AuditResultsTableFilters.tsx
Normal file
@ -0,0 +1,275 @@
|
||||
import {
|
||||
EMPTY_PAGES_FILTERS,
|
||||
EMPTY_PERFORMANCE_FILTERS,
|
||||
type PagesFilters,
|
||||
type PerformanceFilters,
|
||||
} from "@/client/features/audit/results/AuditResultsTableFilterLogic";
|
||||
|
||||
export function PagesFilterBar({
|
||||
filters,
|
||||
onChange,
|
||||
resultCount,
|
||||
totalCount,
|
||||
}: {
|
||||
filters: PagesFilters;
|
||||
onChange: (filters: PagesFilters) => void;
|
||||
resultCount: number;
|
||||
totalCount: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-base-300 bg-base-200/30 p-3">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<TextFilter
|
||||
label="Search"
|
||||
value={filters.query}
|
||||
placeholder="URL, title, meta"
|
||||
onChange={(query) => onChange({ ...filters, query })}
|
||||
/>
|
||||
<SelectFilter
|
||||
label="Status"
|
||||
value={filters.status}
|
||||
onChange={(status) => onChange({ ...filters, status })}
|
||||
options={[
|
||||
["all", "All"],
|
||||
["ok", "2xx"],
|
||||
["redirect", "3xx"],
|
||||
["error", "4xx/5xx"],
|
||||
["missing", "Missing"],
|
||||
]}
|
||||
/>
|
||||
<RangeFilter
|
||||
label="Words"
|
||||
min={filters.minWords}
|
||||
max={filters.maxWords}
|
||||
onMinChange={(minWords) => onChange({ ...filters, minWords })}
|
||||
onMaxChange={(maxWords) => onChange({ ...filters, maxWords })}
|
||||
/>
|
||||
<RangeFilter
|
||||
label="Speed ms"
|
||||
min={filters.minResponseMs}
|
||||
max={filters.maxResponseMs}
|
||||
onMinChange={(minResponseMs) =>
|
||||
onChange({ ...filters, minResponseMs })
|
||||
}
|
||||
onMaxChange={(maxResponseMs) =>
|
||||
onChange({ ...filters, maxResponseMs })
|
||||
}
|
||||
/>
|
||||
<SelectFilter
|
||||
label="Alt text"
|
||||
value={filters.missingAlt}
|
||||
onChange={(missingAlt) => onChange({ ...filters, missingAlt })}
|
||||
options={[
|
||||
["all", "All"],
|
||||
["yes", "Missing alt"],
|
||||
["no", "No missing alt"],
|
||||
]}
|
||||
/>
|
||||
<FilterSummary
|
||||
resultCount={resultCount}
|
||||
totalCount={totalCount}
|
||||
onReset={() => onChange(EMPTY_PAGES_FILTERS)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PerformanceFilterBar({
|
||||
filters,
|
||||
onChange,
|
||||
resultCount,
|
||||
totalCount,
|
||||
}: {
|
||||
filters: PerformanceFilters;
|
||||
onChange: (filters: PerformanceFilters) => void;
|
||||
resultCount: number;
|
||||
totalCount: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-base-300 bg-base-200/30 p-3">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<TextFilter
|
||||
label="Search"
|
||||
value={filters.query}
|
||||
placeholder="URL"
|
||||
onChange={(query) => onChange({ ...filters, query })}
|
||||
/>
|
||||
<SelectFilter
|
||||
label="Device"
|
||||
value={filters.device}
|
||||
onChange={(device) => onChange({ ...filters, device })}
|
||||
options={[
|
||||
["all", "All"],
|
||||
["desktop", "Desktop"],
|
||||
["mobile", "Mobile"],
|
||||
]}
|
||||
/>
|
||||
<SelectFilter
|
||||
label="Status"
|
||||
value={filters.status}
|
||||
onChange={(status) => onChange({ ...filters, status })}
|
||||
options={[
|
||||
["all", "All"],
|
||||
["ok", "OK"],
|
||||
["failed", "Failed"],
|
||||
]}
|
||||
/>
|
||||
<RangeFilter
|
||||
label="Perf"
|
||||
min={filters.minPerf}
|
||||
max={filters.maxPerf}
|
||||
onMinChange={(minPerf) => onChange({ ...filters, minPerf })}
|
||||
onMaxChange={(maxPerf) => onChange({ ...filters, maxPerf })}
|
||||
/>
|
||||
<RangeFilter
|
||||
label="SEO"
|
||||
min={filters.minSeo}
|
||||
max={filters.maxSeo}
|
||||
onMinChange={(minSeo) => onChange({ ...filters, minSeo })}
|
||||
onMaxChange={(maxSeo) => onChange({ ...filters, maxSeo })}
|
||||
/>
|
||||
<TextFilter
|
||||
label="Max LCP s"
|
||||
value={filters.maxLcpSeconds}
|
||||
placeholder="2.5"
|
||||
type="number"
|
||||
onChange={(maxLcpSeconds) => onChange({ ...filters, maxLcpSeconds })}
|
||||
/>
|
||||
<FilterSummary
|
||||
resultCount={resultCount}
|
||||
totalCount={totalCount}
|
||||
onReset={() => onChange(EMPTY_PERFORMANCE_FILTERS)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyTableMessage({ label }: { label: string }) {
|
||||
return <div className="py-6 text-center text-base-content/60">{label}</div>;
|
||||
}
|
||||
|
||||
function TextFilter({
|
||||
label,
|
||||
value,
|
||||
placeholder,
|
||||
type = "text",
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
placeholder: string;
|
||||
type?: "text" | "number";
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="form-control gap-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
|
||||
{label}
|
||||
</span>
|
||||
<input
|
||||
className="input input-bordered input-sm w-40 bg-base-100"
|
||||
type={type}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function RangeFilter({
|
||||
label,
|
||||
min,
|
||||
max,
|
||||
onMinChange,
|
||||
onMaxChange,
|
||||
}: {
|
||||
label: string;
|
||||
min: string;
|
||||
max: string;
|
||||
onMinChange: (value: string) => void;
|
||||
onMaxChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="form-control gap-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
className="input input-bordered input-sm w-20 bg-base-100"
|
||||
type="number"
|
||||
value={min}
|
||||
placeholder="Min"
|
||||
onChange={(event) => onMinChange(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input input-bordered input-sm w-20 bg-base-100"
|
||||
type="number"
|
||||
value={max}
|
||||
placeholder="Max"
|
||||
onChange={(event) => onMaxChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectFilter<T extends string>({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: T;
|
||||
options: Array<[T, string]>;
|
||||
onChange: (value: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="form-control gap-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
|
||||
{label}
|
||||
</span>
|
||||
<select
|
||||
className="select select-bordered select-sm w-32 bg-base-100"
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
const selected = options.find(
|
||||
([optionValue]) => optionValue === event.target.value,
|
||||
)?.[0];
|
||||
if (selected != null) onChange(selected);
|
||||
}}
|
||||
>
|
||||
{options.map(([optionValue, optionLabel]) => (
|
||||
<option key={optionValue} value={optionValue}>
|
||||
{optionLabel}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterSummary({
|
||||
resultCount,
|
||||
totalCount,
|
||||
onReset,
|
||||
}: {
|
||||
resultCount: number;
|
||||
totalCount: number;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="ml-auto flex items-center gap-2 pb-0.5 text-xs text-base-content/60">
|
||||
<span className="tabular-nums">
|
||||
{resultCount.toLocaleString()} of {totalCount.toLocaleString()}
|
||||
</span>
|
||||
<button className="btn btn-ghost btn-xs" onClick={onReset}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,91 +1,154 @@
|
||||
import { ChevronDown, Download, ExternalLink } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
createColumnHelper,
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
} from "@tanstack/react-table";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import {
|
||||
AppDataTable,
|
||||
useAppTable,
|
||||
} from "@/client/components/table/AppDataTable";
|
||||
import { TableExportMenu } from "@/client/components/table/TableBulkActionBar";
|
||||
import { SortableHeader } from "@/client/components/table/SortableHeader";
|
||||
import {
|
||||
extractPathname,
|
||||
HttpStatusBadge,
|
||||
LighthouseScoreBadge,
|
||||
} from "@/client/features/audit/shared";
|
||||
import type { AuditResultsData } from "@/client/features/audit/results/types";
|
||||
|
||||
type LighthouseFailureFields = {
|
||||
errorMessage: string | null;
|
||||
performanceScore: number | null;
|
||||
accessibilityScore: number | null;
|
||||
bestPracticesScore: number | null;
|
||||
seoScore: number | null;
|
||||
};
|
||||
|
||||
function hasMissingLighthouseScores(row: LighthouseFailureFields) {
|
||||
return (
|
||||
row.performanceScore == null &&
|
||||
row.accessibilityScore == null &&
|
||||
row.bestPracticesScore == null &&
|
||||
row.seoScore == null
|
||||
);
|
||||
}
|
||||
import {
|
||||
EmptyTableMessage,
|
||||
PagesFilterBar,
|
||||
PerformanceFilterBar,
|
||||
} from "@/client/features/audit/results/AuditResultsTableFilters";
|
||||
import {
|
||||
EMPTY_PAGES_FILTERS,
|
||||
EMPTY_PERFORMANCE_FILTERS,
|
||||
filterPages,
|
||||
filterPerformanceRows,
|
||||
isLighthouseFailure as getIsLighthouseFailure,
|
||||
type LighthouseFailureFields,
|
||||
type PageRow,
|
||||
type PagesFilters,
|
||||
type PerformanceFilters,
|
||||
type PerformanceRowData,
|
||||
} from "@/client/features/audit/results/AuditResultsTableFilterLogic";
|
||||
|
||||
export function isLighthouseFailure(row: LighthouseFailureFields) {
|
||||
return !!row.errorMessage || hasMissingLighthouseScores(row);
|
||||
return getIsLighthouseFailure(row);
|
||||
}
|
||||
|
||||
function getLighthouseFailureMessage(row: LighthouseFailureFields) {
|
||||
return row.errorMessage ?? "Lighthouse returned no category scores";
|
||||
}
|
||||
|
||||
const pageColumnHelper = createColumnHelper<PageRow>();
|
||||
const performanceColumnHelper = createColumnHelper<PerformanceRowData>();
|
||||
|
||||
const pagesColumns: ColumnDef<PageRow>[] = [
|
||||
pageColumnHelper.accessor("url", {
|
||||
header: ({ column }) => <SortableHeader column={column} label="URL" />,
|
||||
cell: ({ getValue }) => {
|
||||
const url = getValue();
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="link link-primary inline-flex items-center gap-1 text-xs"
|
||||
>
|
||||
<span className="truncate">{extractPathname(url)}</span>
|
||||
<ExternalLink className="size-3 shrink-0" />
|
||||
</a>
|
||||
);
|
||||
},
|
||||
meta: { cellClassName: "max-w-[240px] truncate" },
|
||||
}),
|
||||
pageColumnHelper.accessor("statusCode", {
|
||||
header: ({ column }) => <SortableHeader column={column} label="Status" />,
|
||||
cell: ({ getValue }) => <HttpStatusBadge code={getValue()} />,
|
||||
sortingFn: nullableNumberSort,
|
||||
}),
|
||||
pageColumnHelper.accessor("title", {
|
||||
header: ({ column }) => <SortableHeader column={column} label="Title" />,
|
||||
cell: ({ getValue }) => {
|
||||
const title = getValue();
|
||||
return title ? (
|
||||
<span title={title}>{title}</span>
|
||||
) : (
|
||||
<span className="text-error text-xs">missing</span>
|
||||
);
|
||||
},
|
||||
sortingFn: nullableStringSort,
|
||||
meta: { cellClassName: "max-w-[220px] truncate" },
|
||||
}),
|
||||
pageColumnHelper.accessor("h1Count", {
|
||||
header: ({ column }) => <SortableHeader column={column} label="H1" />,
|
||||
}),
|
||||
pageColumnHelper.accessor("wordCount", {
|
||||
header: ({ column }) => <SortableHeader column={column} label="Words" />,
|
||||
}),
|
||||
pageColumnHelper.display({
|
||||
id: "images",
|
||||
header: ({ column }) => <SortableHeader column={column} label="Images" />,
|
||||
cell: ({ row }) =>
|
||||
row.original.imagesMissingAlt > 0 ? (
|
||||
<span className="text-warning">
|
||||
{row.original.imagesMissingAlt}/{row.original.imagesTotal}
|
||||
</span>
|
||||
) : (
|
||||
row.original.imagesTotal
|
||||
),
|
||||
enableSorting: true,
|
||||
sortingFn: (left, right) =>
|
||||
left.original.imagesMissingAlt - right.original.imagesMissingAlt ||
|
||||
left.original.imagesTotal - right.original.imagesTotal,
|
||||
}),
|
||||
pageColumnHelper.accessor("responseTimeMs", {
|
||||
header: ({ column }) => <SortableHeader column={column} label="Speed" />,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
return value ? (
|
||||
<span className="text-xs">{value}ms</span>
|
||||
) : (
|
||||
<span className="text-xs text-base-content/40">-</span>
|
||||
);
|
||||
},
|
||||
sortingFn: nullableNumberSort,
|
||||
}),
|
||||
];
|
||||
|
||||
export function PagesTable({ pages }: { pages: AuditResultsData["pages"] }) {
|
||||
const [filters, setFilters] = useState<PagesFilters>(EMPTY_PAGES_FILTERS);
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "statusCode", desc: true },
|
||||
]);
|
||||
const filteredPages = useMemo(
|
||||
() => filterPages(pages, filters),
|
||||
[filters, pages],
|
||||
);
|
||||
const table = useAppTable({
|
||||
data: filteredPages,
|
||||
columns: pagesColumns,
|
||||
state: { sorting },
|
||||
onSortingChange: setSorting,
|
||||
withSorting: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>URL</th>
|
||||
<th>Status</th>
|
||||
<th>Title</th>
|
||||
<th>H1</th>
|
||||
<th>Words</th>
|
||||
<th>Images</th>
|
||||
<th>Speed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pages.map((page: AuditResultsData["pages"][number]) => (
|
||||
<tr key={page.id}>
|
||||
<td className="max-w-[200px] truncate">
|
||||
<a
|
||||
href={page.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="link link-primary text-xs inline-flex items-center gap-1"
|
||||
>
|
||||
{extractPathname(page.url)}
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<HttpStatusBadge code={page.statusCode} />
|
||||
</td>
|
||||
<td className="max-w-[180px] truncate" title={page.title ?? ""}>
|
||||
{page.title || (
|
||||
<span className="text-error text-xs">missing</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{page.h1Count}</td>
|
||||
<td>{page.wordCount}</td>
|
||||
<td>
|
||||
{page.imagesMissingAlt > 0 ? (
|
||||
<span className="text-warning">
|
||||
{page.imagesMissingAlt}/{page.imagesTotal}
|
||||
</span>
|
||||
) : (
|
||||
page.imagesTotal
|
||||
)}
|
||||
</td>
|
||||
<td className="text-xs">
|
||||
{page.responseTimeMs ? `${page.responseTimeMs}ms` : "-"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="space-y-3">
|
||||
<PagesFilterBar
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
resultCount={filteredPages.length}
|
||||
totalCount={pages.length}
|
||||
/>
|
||||
<AppDataTable
|
||||
table={table}
|
||||
className="table table-sm"
|
||||
empty={<EmptyTableMessage label="No pages match these filters." />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -101,65 +164,89 @@ export function PerformanceTable({
|
||||
lighthouse: AuditResultsData["lighthouse"];
|
||||
pages: AuditResultsData["pages"];
|
||||
}) {
|
||||
const [filters, setFilters] = useState<PerformanceFilters>(
|
||||
EMPTY_PERFORMANCE_FILTERS,
|
||||
);
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "performanceScore", desc: false },
|
||||
]);
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
lighthouse.map((result) => {
|
||||
const page = pages.find((candidate) => candidate.id === result.pageId);
|
||||
const pageUrl = page?.url ?? null;
|
||||
return {
|
||||
...result,
|
||||
pageUrl,
|
||||
pagePath: pageUrl ? extractPathname(pageUrl) : null,
|
||||
};
|
||||
}),
|
||||
[lighthouse, pages],
|
||||
);
|
||||
const filteredRows = useMemo(
|
||||
() => filterPerformanceRows(rows, filters),
|
||||
[filters, rows],
|
||||
);
|
||||
const columns = useMemo(
|
||||
() => buildPerformanceColumns({ auditId, projectId }),
|
||||
[auditId, projectId],
|
||||
);
|
||||
const table = useAppTable({
|
||||
data: filteredRows,
|
||||
columns,
|
||||
state: { sorting },
|
||||
onSortingChange: setSorting,
|
||||
withSorting: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>URL</th>
|
||||
<th>Device</th>
|
||||
<th>Status</th>
|
||||
<th>Perf</th>
|
||||
<th>A11y</th>
|
||||
<th>SEO</th>
|
||||
<th>LCP</th>
|
||||
<th>CLS</th>
|
||||
<th>INP</th>
|
||||
<th>TTFB</th>
|
||||
<th>Issues</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lighthouse.map((result: AuditResultsData["lighthouse"][number]) => (
|
||||
<PerformanceRow
|
||||
key={result.id}
|
||||
auditId={auditId}
|
||||
projectId={projectId}
|
||||
result={result}
|
||||
page={pages.find(
|
||||
(candidate: AuditResultsData["pages"][number]) =>
|
||||
candidate.id === result.pageId,
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="space-y-3">
|
||||
<PerformanceFilterBar
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
resultCount={filteredRows.length}
|
||||
totalCount={rows.length}
|
||||
/>
|
||||
<AppDataTable
|
||||
table={table}
|
||||
className="table table-sm"
|
||||
empty={
|
||||
<EmptyTableMessage label="No performance results match these filters." />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PerformanceRow({
|
||||
function buildPerformanceColumns({
|
||||
auditId,
|
||||
projectId,
|
||||
result,
|
||||
page,
|
||||
}: {
|
||||
auditId: string;
|
||||
projectId: string;
|
||||
result: AuditResultsData["lighthouse"][number];
|
||||
page: AuditResultsData["pages"][number] | undefined;
|
||||
}) {
|
||||
const isFailed = isLighthouseFailure(result);
|
||||
const failureMessage = getLighthouseFailureMessage(result);
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td className="max-w-[160px] truncate text-xs">
|
||||
{page ? extractPathname(page.url) : "-"}
|
||||
</td>
|
||||
<td className="capitalize text-xs">{result.strategy}</td>
|
||||
<td>
|
||||
{isFailed ? (
|
||||
}): ColumnDef<PerformanceRowData>[] {
|
||||
return [
|
||||
performanceColumnHelper.accessor("pagePath", {
|
||||
header: ({ column }) => <SortableHeader column={column} label="URL" />,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-xs">{getValue() ?? "-"}</span>
|
||||
),
|
||||
sortingFn: nullableStringSort,
|
||||
meta: { cellClassName: "max-w-[180px] truncate" },
|
||||
}),
|
||||
performanceColumnHelper.accessor("strategy", {
|
||||
header: ({ column }) => <SortableHeader column={column} label="Device" />,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="capitalize text-xs">{getValue()}</span>
|
||||
),
|
||||
}),
|
||||
performanceColumnHelper.display({
|
||||
id: "status",
|
||||
header: ({ column }) => <SortableHeader column={column} label="Status" />,
|
||||
cell: ({ row }) => {
|
||||
const isFailed = isLighthouseFailure(row.original);
|
||||
const failureMessage = getLighthouseFailureMessage(row.original);
|
||||
return isFailed ? (
|
||||
<span
|
||||
className="badge badge-error badge-outline text-xs"
|
||||
title={failureMessage}
|
||||
@ -168,43 +255,92 @@ function PerformanceRow({
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge badge-success badge-outline text-xs">ok</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<LighthouseScoreBadge score={result.performanceScore} />
|
||||
</td>
|
||||
<td>
|
||||
<LighthouseScoreBadge score={result.accessibilityScore} />
|
||||
</td>
|
||||
<td>
|
||||
<LighthouseScoreBadge score={result.seoScore} />
|
||||
</td>
|
||||
<td className="text-xs">
|
||||
{result.lcpMs ? `${(result.lcpMs / 1000).toFixed(1)}s` : "-"}
|
||||
</td>
|
||||
<td className="text-xs">
|
||||
{result.cls != null ? result.cls.toFixed(3) : "-"}
|
||||
</td>
|
||||
<td className="text-xs">
|
||||
{result.inpMs ? `${Math.round(result.inpMs)}ms` : "-"}
|
||||
</td>
|
||||
<td className="text-xs">
|
||||
{result.ttfbMs ? `${Math.round(result.ttfbMs)}ms` : "-"}
|
||||
</td>
|
||||
<td>
|
||||
{result.r2Key && !isFailed ? (
|
||||
);
|
||||
},
|
||||
enableSorting: true,
|
||||
sortingFn: (left, right) =>
|
||||
Number(isLighthouseFailure(left.original)) -
|
||||
Number(isLighthouseFailure(right.original)),
|
||||
}),
|
||||
performanceColumnHelper.accessor("performanceScore", {
|
||||
header: ({ column }) => <SortableHeader column={column} label="Perf" />,
|
||||
cell: ({ getValue }) => <LighthouseScoreBadge score={getValue()} />,
|
||||
sortingFn: nullableNumberSort,
|
||||
}),
|
||||
performanceColumnHelper.accessor("accessibilityScore", {
|
||||
header: ({ column }) => <SortableHeader column={column} label="A11y" />,
|
||||
cell: ({ getValue }) => <LighthouseScoreBadge score={getValue()} />,
|
||||
sortingFn: nullableNumberSort,
|
||||
}),
|
||||
performanceColumnHelper.accessor("seoScore", {
|
||||
header: ({ column }) => <SortableHeader column={column} label="SEO" />,
|
||||
cell: ({ getValue }) => <LighthouseScoreBadge score={getValue()} />,
|
||||
sortingFn: nullableNumberSort,
|
||||
}),
|
||||
performanceColumnHelper.accessor("lcpMs", {
|
||||
header: ({ column }) => <SortableHeader column={column} label="LCP" />,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
return value ? (
|
||||
<span className="text-xs">{(value / 1000).toFixed(1)}s</span>
|
||||
) : (
|
||||
<span className="text-xs text-base-content/40">-</span>
|
||||
);
|
||||
},
|
||||
sortingFn: nullableNumberSort,
|
||||
}),
|
||||
performanceColumnHelper.accessor("cls", {
|
||||
header: ({ column }) => <SortableHeader column={column} label="CLS" />,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
return value != null ? (
|
||||
<span className="text-xs">{value.toFixed(3)}</span>
|
||||
) : (
|
||||
<span className="text-xs text-base-content/40">-</span>
|
||||
);
|
||||
},
|
||||
sortingFn: nullableNumberSort,
|
||||
}),
|
||||
performanceColumnHelper.accessor("inpMs", {
|
||||
header: ({ column }) => <SortableHeader column={column} label="INP" />,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
return value ? (
|
||||
<span className="text-xs">{Math.round(value)}ms</span>
|
||||
) : (
|
||||
<span className="text-xs text-base-content/40">-</span>
|
||||
);
|
||||
},
|
||||
sortingFn: nullableNumberSort,
|
||||
}),
|
||||
performanceColumnHelper.accessor("ttfbMs", {
|
||||
header: ({ column }) => <SortableHeader column={column} label="TTFB" />,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
return value ? (
|
||||
<span className="text-xs">{Math.round(value)}ms</span>
|
||||
) : (
|
||||
<span className="text-xs text-base-content/40">-</span>
|
||||
);
|
||||
},
|
||||
sortingFn: nullableNumberSort,
|
||||
}),
|
||||
performanceColumnHelper.display({
|
||||
id: "issues",
|
||||
header: () => "Issues",
|
||||
cell: ({ row }) =>
|
||||
row.original.r2Key && !isLighthouseFailure(row.original) ? (
|
||||
<a
|
||||
className="btn btn-primary btn-xs"
|
||||
href={`/p/${projectId}/audit/issues/${result.id}?auditId=${auditId}&category=performance`}
|
||||
href={`/p/${projectId}/audit/issues/${row.original.id}?auditId=${auditId}&category=performance`}
|
||||
>
|
||||
View issues
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-xs text-base-content/40">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export function ExportDropdown({
|
||||
@ -213,26 +349,40 @@ export function ExportDropdown({
|
||||
onExport: (format: "csv" | "json" | "sheets") => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="dropdown dropdown-end">
|
||||
<div tabIndex={0} role="button" className="btn btn-sm btn-ghost gap-1">
|
||||
<Download className="size-4" />
|
||||
Export
|
||||
<ChevronDown className="size-3 opacity-60" />
|
||||
</div>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-52"
|
||||
>
|
||||
<li>
|
||||
<button onClick={() => onExport("sheets")}>Export to Sheets</button>
|
||||
</li>
|
||||
<li>
|
||||
<button onClick={() => onExport("csv")}>CSV</button>
|
||||
</li>
|
||||
<li>
|
||||
<button onClick={() => onExport("json")}>JSON</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<TableExportMenu
|
||||
buttonClassName="btn btn-sm btn-ghost gap-1"
|
||||
menuClassName="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-52"
|
||||
actions={[
|
||||
{ label: "Export to Sheets", onClick: () => onExport("sheets") },
|
||||
{ label: "CSV", onClick: () => onExport("csv") },
|
||||
{ label: "JSON", onClick: () => onExport("json") },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function nullableNumberSort(
|
||||
left: { getValue: (columnId: string) => number | null },
|
||||
right: { getValue: (columnId: string) => number | null },
|
||||
columnId: string,
|
||||
) {
|
||||
const a = left.getValue(columnId);
|
||||
const b = right.getValue(columnId);
|
||||
if (a == null && b == null) return 0;
|
||||
if (a == null) return 1;
|
||||
if (b == null) return -1;
|
||||
return a - b;
|
||||
}
|
||||
|
||||
function nullableStringSort(
|
||||
left: { getValue: (columnId: string) => string | null },
|
||||
right: { getValue: (columnId: string) => string | null },
|
||||
columnId: string,
|
||||
) {
|
||||
const a = left.getValue(columnId);
|
||||
const b = right.getValue(columnId);
|
||||
if (!a && !b) return 0;
|
||||
if (!a) return 1;
|
||||
if (!b) return -1;
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
type SortingFn,
|
||||
type SortingState,
|
||||
} from "@tanstack/react-table";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
AppDataTable,
|
||||
@ -55,7 +56,20 @@ const columns = [
|
||||
helpText="The referring site linking to your target."
|
||||
/>
|
||||
),
|
||||
cell: ({ getValue }) => getValue() ?? "-",
|
||||
cell: ({ getValue }) => {
|
||||
const domain = getValue();
|
||||
if (!domain) return "-";
|
||||
return (
|
||||
<Link
|
||||
from="/p/$projectId/backlinks"
|
||||
to="/p/$projectId/backlinks"
|
||||
search={{ target: domain, scope: "domain", tab: undefined }}
|
||||
className="link link-primary link-hover break-all"
|
||||
>
|
||||
{domain}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
sortingFn: stringNullsLast,
|
||||
}),
|
||||
columnHelper.accessor("backlinks", {
|
||||
|
||||
@ -1,44 +1,58 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useMemo } from "react";
|
||||
/* eslint-disable max-lines, max-lines-per-function -- Domain Overview keeps page-only orchestration colocated to avoid fake indirection. */
|
||||
import { useCallback, useEffect, useMemo, useRef, type FormEvent } from "react";
|
||||
import { useForm, useStore } from "@tanstack/react-form";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE,
|
||||
type DomainSearchParams,
|
||||
} from "@/types/schemas/domain";
|
||||
import {
|
||||
DEFAULT_LOCATION_CODE,
|
||||
LOCATIONS,
|
||||
getLanguageCode,
|
||||
isSupportedLocationCode,
|
||||
} from "@/client/features/keywords/locations";
|
||||
import { useDomainSearchHistory } from "@/client/hooks/useDomainSearchHistory";
|
||||
import type { DomainSearchHistoryItem } from "@/client/hooks/useDomainSearchHistory";
|
||||
import {
|
||||
getDomainSearchChangeValidationErrors,
|
||||
getDomainSearchValidationErrors,
|
||||
} from "@/client/features/domain/domainSearchValidation";
|
||||
import { useDomainOverviewQuery } from "@/client/features/domain/hooks/useDomainOverviewQuery";
|
||||
import { DomainOverviewLoadingState } from "@/client/features/domain/components/DomainOverviewLoadingState";
|
||||
import { DomainHistorySection } from "@/client/features/domain/components/DomainHistorySection";
|
||||
import { DomainResultsCard } from "@/client/features/domain/components/DomainResultsCard";
|
||||
import { DomainSearchCard } from "@/client/features/domain/components/DomainSearchCard";
|
||||
import { KeywordsTab } from "@/client/features/domain/components/KeywordsTab";
|
||||
import { PagesTab } from "@/client/features/domain/components/PagesTab";
|
||||
import { StatCard } from "@/client/features/domain/components/StatCard";
|
||||
import { SearchTabStrip } from "@/client/features/search-tabs/SearchTabStrip";
|
||||
import type { SearchTabInput } from "@/client/features/search-tabs/types";
|
||||
import { useSearchTabNavigation } from "@/client/features/search-tabs/useSearchTabNavigation";
|
||||
import { useDomainOverviewController } from "@/client/features/domain/useDomainOverviewController";
|
||||
import {
|
||||
normalizeDomainTarget,
|
||||
formatMetric,
|
||||
getDefaultSortOrder,
|
||||
normalizeDomainTarget,
|
||||
toSortOrderSearchParam,
|
||||
toSortSearchParam,
|
||||
} from "@/client/features/domain/utils";
|
||||
import { createFormValidationErrors } from "@/client/lib/forms";
|
||||
import {
|
||||
createFormValidationErrors,
|
||||
shouldValidateFieldOnChange,
|
||||
} from "@/client/lib/forms";
|
||||
import { buildDomainFiltersClearSearchUpdate } from "@/client/features/domain/domainFilterUtils";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import type { DomainOverviewRouteState } from "@/client/features/domain/domainRouteState";
|
||||
import type {
|
||||
DomainActiveTab,
|
||||
DomainFilterValues,
|
||||
DomainSortMode,
|
||||
SortOrder,
|
||||
} from "@/client/features/domain/types";
|
||||
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
searchState: {
|
||||
domain: string;
|
||||
subdomains: boolean;
|
||||
sort: DomainSortMode;
|
||||
order?: SortOrder;
|
||||
tab: DomainActiveTab;
|
||||
search: string;
|
||||
locationCode: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
appliedFilters: DomainFilterValues;
|
||||
};
|
||||
routeState: DomainOverviewRouteState;
|
||||
navigate: (args: {
|
||||
search: (prev: Record<string, unknown>) => Record<string, unknown>;
|
||||
replace: boolean;
|
||||
@ -46,38 +60,374 @@ type Props = {
|
||||
onShowRecentSearches: () => void;
|
||||
};
|
||||
|
||||
type DomainNavigate = Props["navigate"];
|
||||
type DomainSearchUpdate = Partial<DomainSearchParams>;
|
||||
|
||||
const KEYWORDS_ONLY_SORTS: ReadonlySet<DomainSortMode> = new Set([
|
||||
"rank",
|
||||
"score",
|
||||
"cpc",
|
||||
]);
|
||||
|
||||
function getSortSearchUpdate(
|
||||
nextSort: DomainSortMode,
|
||||
nextOrder: SortOrder,
|
||||
): DomainSearchUpdate {
|
||||
return {
|
||||
sort: toSortSearchParam(nextSort),
|
||||
order: toSortOrderSearchParam(nextSort, nextOrder),
|
||||
page: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function getLocationSearchUpdate(
|
||||
nextLocationCode: number,
|
||||
): DomainSearchUpdate | null {
|
||||
if (!isSupportedLocationCode(nextLocationCode)) return null;
|
||||
return {
|
||||
loc:
|
||||
nextLocationCode === DEFAULT_LOCATION_CODE ? undefined : nextLocationCode,
|
||||
page: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function getPageSearchUpdate(nextPage: number): DomainSearchUpdate {
|
||||
const safe = Math.max(1, Math.floor(nextPage));
|
||||
return { page: safe === 1 ? undefined : safe };
|
||||
}
|
||||
|
||||
function getPageSizeSearchUpdate(nextSize: number): DomainSearchUpdate {
|
||||
return {
|
||||
size: nextSize === DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE ? undefined : nextSize,
|
||||
page: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function getTabSearchUpdate(
|
||||
nextTab: DomainActiveTab,
|
||||
currentSort: DomainSortMode,
|
||||
): DomainSearchUpdate {
|
||||
if (nextTab === "keywords") {
|
||||
return { tab: undefined, page: undefined };
|
||||
}
|
||||
|
||||
const fallbackSortNeeded = KEYWORDS_ONLY_SORTS.has(currentSort);
|
||||
const update: DomainSearchUpdate = {
|
||||
tab: "pages",
|
||||
page: undefined,
|
||||
};
|
||||
if (fallbackSortNeeded) {
|
||||
update.sort = "traffic";
|
||||
update.order = getDefaultSortOrder("traffic");
|
||||
}
|
||||
return update;
|
||||
}
|
||||
|
||||
function getHistorySearchUpdate(
|
||||
item: DomainSearchHistoryItem,
|
||||
): DomainSearchUpdate {
|
||||
const historyLocation =
|
||||
item.locationCode != null && isSupportedLocationCode(item.locationCode)
|
||||
? item.locationCode
|
||||
: DEFAULT_LOCATION_CODE;
|
||||
|
||||
return {
|
||||
...buildDomainFiltersClearSearchUpdate(),
|
||||
domain: item.domain,
|
||||
subdomains: item.subdomains ? undefined : false,
|
||||
sort: toSortSearchParam(item.sort),
|
||||
order: undefined,
|
||||
tab: item.tab === "keywords" ? undefined : item.tab,
|
||||
loc:
|
||||
historyLocation === DEFAULT_LOCATION_CODE ? undefined : historyLocation,
|
||||
size: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function getSearchSubmitUpdate({
|
||||
domain,
|
||||
subdomains,
|
||||
sort,
|
||||
locationCode,
|
||||
currentOrder,
|
||||
activeTab,
|
||||
}: {
|
||||
domain: string;
|
||||
subdomains: boolean;
|
||||
sort: DomainSortMode;
|
||||
locationCode: number;
|
||||
currentOrder: SortOrder;
|
||||
activeTab: DomainActiveTab;
|
||||
}): DomainSearchUpdate {
|
||||
return {
|
||||
...buildDomainFiltersClearSearchUpdate(),
|
||||
domain,
|
||||
subdomains: subdomains ? undefined : false,
|
||||
sort: toSortSearchParam(sort),
|
||||
order: toSortOrderSearchParam(sort, currentOrder),
|
||||
tab: activeTab === "keywords" ? undefined : activeTab,
|
||||
loc: locationCode === DEFAULT_LOCATION_CODE ? undefined : locationCode,
|
||||
size: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function useDomainOverviewState({
|
||||
navigate,
|
||||
routeState,
|
||||
projectId,
|
||||
}: {
|
||||
navigate: DomainNavigate;
|
||||
routeState: DomainOverviewRouteState;
|
||||
projectId: string;
|
||||
}) {
|
||||
const lastTrackedKey = useRef<string>("");
|
||||
|
||||
const {
|
||||
history,
|
||||
isLoaded: historyLoaded,
|
||||
addSearch,
|
||||
removeHistoryItem,
|
||||
} = useDomainSearchHistory(projectId);
|
||||
|
||||
const setSearchParams = useCallback(
|
||||
(updates: DomainSearchUpdate) => {
|
||||
navigate({
|
||||
search: (prev) => ({ ...prev, ...updates }),
|
||||
replace: true,
|
||||
});
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const applySort = useCallback(
|
||||
(nextSort: DomainSortMode, nextOrder: SortOrder) => {
|
||||
setSearchParams(getSortSearchUpdate(nextSort, nextOrder));
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const applyLocationChange = useCallback(
|
||||
(nextLocationCode: number) => {
|
||||
const update = getLocationSearchUpdate(nextLocationCode);
|
||||
if (update) setSearchParams(update);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const handleSortColumnClick = useCallback(
|
||||
(nextSort: DomainSortMode) => {
|
||||
const nextOrder =
|
||||
nextSort === routeState.sort
|
||||
? routeState.order === "asc"
|
||||
? "desc"
|
||||
: "asc"
|
||||
: getDefaultSortOrder(nextSort);
|
||||
applySort(nextSort, nextOrder);
|
||||
},
|
||||
[applySort, routeState.order, routeState.sort],
|
||||
);
|
||||
|
||||
const goToPage = useCallback(
|
||||
(nextPage: number) => {
|
||||
setSearchParams(getPageSearchUpdate(nextPage));
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const setPageSize = useCallback(
|
||||
(nextSize: number) => {
|
||||
setSearchParams(getPageSizeSearchUpdate(nextSize));
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(nextTab: DomainActiveTab) => {
|
||||
setSearchParams(getTabSearchUpdate(nextTab, routeState.sort));
|
||||
},
|
||||
[routeState.sort, setSearchParams],
|
||||
);
|
||||
|
||||
const handleHistorySelect = useCallback(
|
||||
(item: DomainSearchHistoryItem) => {
|
||||
setSearchParams(getHistorySearchUpdate(item));
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const languageCode = getLanguageCode(routeState.locationCode);
|
||||
const overviewQuery = useDomainOverviewQuery({
|
||||
projectId,
|
||||
domain: routeState.domain,
|
||||
includeSubdomains: routeState.subdomains,
|
||||
locationCode: routeState.locationCode,
|
||||
languageCode,
|
||||
});
|
||||
const overview = overviewQuery.data ?? null;
|
||||
const isLoading = overviewQuery.isLoading;
|
||||
|
||||
const controlsForm = useForm({
|
||||
defaultValues: {
|
||||
domain: routeState.domain,
|
||||
subdomains: routeState.subdomains,
|
||||
sort: routeState.sort,
|
||||
locationCode: routeState.locationCode,
|
||||
},
|
||||
validators: {
|
||||
onChange: ({ formApi, value }) =>
|
||||
getDomainSearchChangeValidationErrors(
|
||||
value,
|
||||
shouldValidateFieldOnChange(formApi, "domain"),
|
||||
formApi.state.submissionAttempts > 0,
|
||||
),
|
||||
onSubmit: ({ value }) => getDomainSearchValidationErrors(value),
|
||||
},
|
||||
onSubmit: ({ formApi, value }) => {
|
||||
const target = normalizeDomainTarget(value.domain);
|
||||
if (!target) return;
|
||||
formApi.setFieldValue("domain", target);
|
||||
setSearchParams(
|
||||
getSearchSubmitUpdate({
|
||||
domain: target,
|
||||
subdomains: value.subdomains,
|
||||
sort: value.sort,
|
||||
locationCode: value.locationCode,
|
||||
currentOrder: routeState.order,
|
||||
activeTab: routeState.tab,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
controlsForm.reset({
|
||||
domain: routeState.domain,
|
||||
subdomains: routeState.subdomains,
|
||||
sort: routeState.sort,
|
||||
locationCode: routeState.locationCode,
|
||||
});
|
||||
}, [
|
||||
controlsForm,
|
||||
routeState.domain,
|
||||
routeState.locationCode,
|
||||
routeState.sort,
|
||||
routeState.subdomains,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
controlsForm.setErrorMap({
|
||||
onSubmit: overviewQuery.error
|
||||
? createFormValidationErrors({
|
||||
form: getStandardErrorMessage(
|
||||
overviewQuery.error,
|
||||
"Lookup failed.",
|
||||
),
|
||||
})
|
||||
: undefined,
|
||||
});
|
||||
}, [controlsForm, overviewQuery.error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!overviewQuery.isSuccess || !overview) return;
|
||||
const key = `${routeState.domain}|${routeState.subdomains}|${routeState.locationCode}`;
|
||||
if (lastTrackedKey.current === key) return;
|
||||
lastTrackedKey.current = key;
|
||||
|
||||
captureClientEvent("domain_overview:search_complete", {
|
||||
sort_mode: routeState.sort,
|
||||
include_subdomains: routeState.subdomains,
|
||||
result_count: overview.organicKeywords ?? 0,
|
||||
location_code: routeState.locationCode,
|
||||
});
|
||||
addSearch({
|
||||
domain: routeState.domain,
|
||||
subdomains: routeState.subdomains,
|
||||
sort: routeState.sort,
|
||||
tab: routeState.tab,
|
||||
locationCode: routeState.locationCode,
|
||||
});
|
||||
if (!overview.hasData) {
|
||||
toast.info("Not enough data for this domain");
|
||||
}
|
||||
}, [
|
||||
addSearch,
|
||||
overview,
|
||||
overviewQuery.isSuccess,
|
||||
routeState.domain,
|
||||
routeState.locationCode,
|
||||
routeState.sort,
|
||||
routeState.subdomains,
|
||||
routeState.tab,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (routeState.domain.trim() !== "") return;
|
||||
lastTrackedKey.current = "";
|
||||
}, [routeState.domain]);
|
||||
|
||||
const controlsLocationCode = useStore(
|
||||
controlsForm.store,
|
||||
(s) => s.values.locationCode,
|
||||
);
|
||||
const canSaveKeywords = useMemo(
|
||||
() =>
|
||||
controlsLocationCode === routeState.locationCode &&
|
||||
overview !== null &&
|
||||
overview.hasData,
|
||||
[controlsLocationCode, overview, routeState.locationCode],
|
||||
);
|
||||
|
||||
const handleSearchSubmit = useCallback(
|
||||
(event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
void controlsForm.handleSubmit();
|
||||
},
|
||||
[controlsForm],
|
||||
);
|
||||
|
||||
return {
|
||||
controlsForm,
|
||||
isLoading,
|
||||
overview,
|
||||
canSaveKeywords,
|
||||
history,
|
||||
historyLoaded,
|
||||
removeHistoryItem,
|
||||
languageCode,
|
||||
setSearchParams,
|
||||
applySort,
|
||||
applyLocationChange,
|
||||
handleTabChange,
|
||||
handleSortColumnClick,
|
||||
handleHistorySelect,
|
||||
handleSearchSubmit,
|
||||
goToPage,
|
||||
setPageSize,
|
||||
};
|
||||
}
|
||||
|
||||
export type DomainOverviewControlsForm = ReturnType<
|
||||
typeof useDomainOverviewState
|
||||
>["controlsForm"];
|
||||
|
||||
export function DomainOverviewPage({
|
||||
projectId,
|
||||
searchState,
|
||||
routeState,
|
||||
navigate,
|
||||
onShowRecentSearches,
|
||||
}: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const state = useDomainOverviewController({
|
||||
projectId,
|
||||
queryClient,
|
||||
navigate,
|
||||
searchState,
|
||||
});
|
||||
const state = useDomainOverviewState({ navigate, routeState, projectId });
|
||||
const urlTabInput = useMemo<SearchTabInput | null>(() => {
|
||||
if (searchState.domain.trim() === "") return null;
|
||||
if (routeState.domain.trim() === "") return null;
|
||||
return {
|
||||
type: "domain",
|
||||
domain: searchState.domain,
|
||||
subdomains: searchState.subdomains,
|
||||
sort: searchState.sort,
|
||||
order: searchState.order ?? getDefaultSortOrder(searchState.sort),
|
||||
locationCode: searchState.locationCode,
|
||||
domain: routeState.domain,
|
||||
subdomains: routeState.subdomains,
|
||||
locationCode: routeState.locationCode,
|
||||
};
|
||||
}, [
|
||||
searchState.domain,
|
||||
searchState.locationCode,
|
||||
searchState.order,
|
||||
searchState.sort,
|
||||
searchState.subdomains,
|
||||
]);
|
||||
}, [routeState.domain, routeState.locationCode, routeState.subdomains]);
|
||||
|
||||
const navigateToTab = useCallback(
|
||||
const navigateToSearchTab = useCallback(
|
||||
(input: SearchTabInput | null) => {
|
||||
if (input?.type !== "domain") {
|
||||
navigate({
|
||||
@ -86,18 +436,21 @@ export function DomainOverviewPage({
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
...buildDomainFiltersClearSearchUpdate(),
|
||||
domain: input.domain,
|
||||
subdomains: input.subdomains ? undefined : false,
|
||||
sort: input.sort === "rank" ? undefined : input.sort,
|
||||
order: toSortOrderSearchParam(input.sort, input.order),
|
||||
sort: undefined,
|
||||
order: undefined,
|
||||
tab: undefined,
|
||||
page: undefined,
|
||||
loc:
|
||||
input.locationCode === DEFAULT_LOCATION_CODE
|
||||
? undefined
|
||||
: input.locationCode,
|
||||
page: undefined,
|
||||
size: undefined,
|
||||
}),
|
||||
replace: true,
|
||||
@ -105,17 +458,23 @@ export function DomainOverviewPage({
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const searchTabs = useSearchTabNavigation({
|
||||
storageKey: `domain:${projectId}`,
|
||||
urlInput: urlTabInput,
|
||||
getLabel: useCallback(
|
||||
(input) => (input.type === "domain" ? input.domain : ""),
|
||||
[],
|
||||
),
|
||||
navigateToInput: navigateToTab,
|
||||
getLabel: useCallback((input) => {
|
||||
if (input.type !== "domain") return "";
|
||||
const locationSuffix =
|
||||
input.locationCode === DEFAULT_LOCATION_CODE
|
||||
? ""
|
||||
: ` ${LOCATIONS[input.locationCode] ?? input.locationCode}`;
|
||||
return `${input.domain}${locationSuffix}`;
|
||||
}, []),
|
||||
navigateToInput: navigateToSearchTab,
|
||||
});
|
||||
|
||||
const handleSearchSubmit = useCallback(
|
||||
(event: React.FormEvent) => {
|
||||
(event: FormEvent) => {
|
||||
const values = state.controlsForm.state.values;
|
||||
const target = normalizeDomainTarget(values.domain);
|
||||
if (!target) {
|
||||
@ -127,8 +486,6 @@ export function DomainOverviewPage({
|
||||
type: "domain",
|
||||
domain: target,
|
||||
subdomains: values.subdomains,
|
||||
sort: values.sort,
|
||||
order: searchState.order ?? getDefaultSortOrder(values.sort),
|
||||
locationCode: values.locationCode,
|
||||
};
|
||||
|
||||
@ -146,9 +503,10 @@ export function DomainOverviewPage({
|
||||
|
||||
state.handleSearchSubmit(event);
|
||||
},
|
||||
[searchState.order, searchTabs, state],
|
||||
[searchTabs, state],
|
||||
);
|
||||
const tabControls = searchState.domain ? (
|
||||
|
||||
const tabControls = routeState.domain ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<button
|
||||
@ -238,44 +596,57 @@ export function DomainOverviewPage({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DomainResultsCard
|
||||
projectId={projectId}
|
||||
overview={state.overview}
|
||||
activeTab={searchState.tab}
|
||||
sortMode={searchState.sort}
|
||||
currentSortOrder={state.currentSortOrder}
|
||||
searchDraft={state.searchDraft}
|
||||
selectedKeywords={state.selectedKeywords}
|
||||
setSelectedKeywords={state.setSelectedKeywords}
|
||||
visibleKeywords={state.visibleKeywords}
|
||||
filteredKeywords={state.filteredKeywords}
|
||||
pagedPages={state.pagedPages}
|
||||
showFilters={state.showFilters}
|
||||
setShowFilters={state.setShowFilters}
|
||||
filtersForm={state.filtersForm}
|
||||
activeFilterCount={state.activeFilterCount}
|
||||
dirtyFilterCount={state.dirtyFilterCount}
|
||||
conditionCount={state.conditionCount}
|
||||
overLimit={state.overLimit}
|
||||
resetFilters={state.resetFilters}
|
||||
applyFilters={state.applyFilters}
|
||||
cancelFilterEdits={state.cancelFilterEdits}
|
||||
onSearchChange={state.setSearchDraft}
|
||||
onSaveKeywords={state.handleSaveKeywords}
|
||||
canSaveKeywords={state.canSaveKeywords}
|
||||
onSortClick={state.handleSortColumnClick}
|
||||
onToggleKeyword={state.toggleKeywordSelection}
|
||||
page={state.page}
|
||||
pageSize={state.pageSize}
|
||||
totalKeywordCount={state.totalKeywordCount}
|
||||
totalPagesCount={state.totalPagesCount}
|
||||
hasNextKeywordsPage={state.hasNextKeywordsPage}
|
||||
hasNextPagesPage={state.hasNextPagesPage}
|
||||
isKeywordsLoading={state.keywordsLoading}
|
||||
isPagesLoading={state.pagesLoading}
|
||||
onPageChange={state.goToPage}
|
||||
onPageSizeChange={state.setPageSize}
|
||||
/>
|
||||
<div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 px-4 py-3 border-b border-base-300">
|
||||
<div role="tablist" className="tabs tabs-box w-fit">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={routeState.tab === "keywords"}
|
||||
className={`tab ${routeState.tab === "keywords" ? "tab-active" : ""}`}
|
||||
onClick={() => state.handleTabChange("keywords")}
|
||||
>
|
||||
Top Keywords
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={routeState.tab === "pages"}
|
||||
className={`tab ${routeState.tab === "pages" ? "tab-active" : ""}`}
|
||||
onClick={() => state.handleTabChange("pages")}
|
||||
>
|
||||
Top Pages
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{routeState.tab === "keywords" ? (
|
||||
<KeywordsTab
|
||||
key="keywords"
|
||||
projectId={projectId}
|
||||
domain={state.overview.domain}
|
||||
languageCode={state.languageCode}
|
||||
routeState={routeState}
|
||||
canSaveKeywords={state.canSaveKeywords}
|
||||
setSearchParams={state.setSearchParams}
|
||||
onSortClick={state.handleSortColumnClick}
|
||||
onPageChange={state.goToPage}
|
||||
onPageSizeChange={state.setPageSize}
|
||||
/>
|
||||
) : (
|
||||
<PagesTab
|
||||
key="pages"
|
||||
projectId={projectId}
|
||||
domain={state.overview.domain}
|
||||
languageCode={state.languageCode}
|
||||
routeState={routeState}
|
||||
setSearchParams={state.setSearchParams}
|
||||
onSortClick={state.handleSortColumnClick}
|
||||
onPageChange={state.goToPage}
|
||||
onPageSizeChange={state.setPageSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
72
src/client/features/domain/components/DomainFilterFields.tsx
Normal file
72
src/client/features/domain/components/DomainFilterFields.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
function FilterFieldLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterTextInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="form-control gap-1.5">
|
||||
<FilterFieldLabel>{label}</FilterFieldLabel>
|
||||
<input
|
||||
className="input input-bordered input-sm w-full bg-base-100"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterNumberInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
step,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
step?: string;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
className="input input-bordered input-xs bg-base-100"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
step={step}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterRangeGroup({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-base-300 bg-base-100 p-2.5 space-y-2">
|
||||
<FilterFieldLabel>{title}</FilterFieldLabel>
|
||||
<div className="grid grid-cols-2 gap-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,44 +1,128 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AlertTriangle, RotateCcw } from "lucide-react";
|
||||
import type { useDomainFilters } from "@/client/features/domain/hooks/useDomainFilters";
|
||||
import type { DomainFilterValues } from "@/client/features/domain/types";
|
||||
import {
|
||||
FilterNumberInput,
|
||||
FilterRangeGroup,
|
||||
FilterTextInput,
|
||||
} from "@/client/features/domain/components/DomainFilterFields";
|
||||
import {
|
||||
debugDomain,
|
||||
useDomainRenderDebug,
|
||||
} from "@/client/features/domain/domainDebug";
|
||||
import { MAX_DATAFORSEO_FILTER_CONDITIONS } from "@/types/schemas/domain";
|
||||
|
||||
type FilterForm = ReturnType<typeof useDomainFilters>["filtersForm"];
|
||||
type FilterValues = Record<string, string>;
|
||||
|
||||
type Props = {
|
||||
filtersForm: FilterForm;
|
||||
activeFilterCount: number;
|
||||
dirtyFilterCount: number;
|
||||
conditionCount: number;
|
||||
overLimit: boolean;
|
||||
resetFilters: () => void;
|
||||
applyFilters: () => void;
|
||||
cancelFilterEdits: () => void;
|
||||
type FilterTextField<TValues extends FilterValues> = {
|
||||
key: keyof TValues;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
};
|
||||
|
||||
export function DomainFilterPanel({
|
||||
filtersForm,
|
||||
type FilterRangeField<TValues extends FilterValues> = {
|
||||
title: string;
|
||||
minKey: keyof TValues;
|
||||
maxKey: keyof TValues;
|
||||
step?: string;
|
||||
};
|
||||
|
||||
type Props<TValues extends FilterValues> = {
|
||||
debugName: string;
|
||||
activeFilterCount: number;
|
||||
appliedFilters: TValues;
|
||||
fields: ReadonlyArray<keyof TValues>;
|
||||
textFields: ReadonlyArray<FilterTextField<TValues>>;
|
||||
rangeFields: ReadonlyArray<FilterRangeField<TValues>>;
|
||||
countConditions: (values: TValues) => number;
|
||||
onApply: (values: TValues) => void;
|
||||
onClear: () => void;
|
||||
};
|
||||
|
||||
export function DomainFilterPanel<TValues extends FilterValues>({
|
||||
debugName,
|
||||
activeFilterCount,
|
||||
dirtyFilterCount,
|
||||
conditionCount,
|
||||
overLimit,
|
||||
resetFilters,
|
||||
applyFilters,
|
||||
cancelFilterEdits,
|
||||
}: Props) {
|
||||
const isDirty = dirtyFilterCount > 0;
|
||||
const canApply = isDirty && !overLimit;
|
||||
const handleApplyKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Enter" && canApply) {
|
||||
event.preventDefault();
|
||||
applyFilters();
|
||||
}
|
||||
appliedFilters,
|
||||
fields,
|
||||
textFields,
|
||||
rangeFields,
|
||||
countConditions,
|
||||
onApply,
|
||||
onClear,
|
||||
}: Props<TValues>) {
|
||||
const appliedKey = useMemo(
|
||||
() => fields.map((key) => appliedFilters[key]).join("|"),
|
||||
[appliedFilters, fields],
|
||||
);
|
||||
const [draftFilters, setDraftFilters] = useState(appliedFilters);
|
||||
useEffect(() => {
|
||||
setDraftFilters(appliedFilters);
|
||||
// appliedKey covers content changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appliedKey]);
|
||||
|
||||
const onValueChange = useCallback((key: keyof TValues, value: string) => {
|
||||
setDraftFilters((current) => ({ ...current, [key]: value }));
|
||||
}, []);
|
||||
const meta = useMemo(
|
||||
() =>
|
||||
getFilterMeta({
|
||||
values: draftFilters,
|
||||
appliedFilters,
|
||||
fields,
|
||||
countConditions,
|
||||
}),
|
||||
[appliedFilters, countConditions, draftFilters, fields],
|
||||
);
|
||||
useDomainRenderDebug(debugName, {
|
||||
activeFilterCount,
|
||||
conditionCount: meta.conditionCount,
|
||||
dirtyCount: meta.dirtyCount,
|
||||
});
|
||||
const applyFilters = useCallback(() => {
|
||||
if (meta.overLimit) return;
|
||||
debugDomain(`${debugName}:apply`, {
|
||||
conditionCount: meta.conditionCount,
|
||||
dirtyCount: meta.dirtyCount,
|
||||
draftFilters,
|
||||
});
|
||||
onApply(draftFilters);
|
||||
}, [
|
||||
debugName,
|
||||
draftFilters,
|
||||
meta.conditionCount,
|
||||
meta.dirtyCount,
|
||||
meta.overLimit,
|
||||
onApply,
|
||||
]);
|
||||
const cancelFilterEdits = useCallback(() => {
|
||||
debugDomain(`${debugName}:cancel`);
|
||||
setDraftFilters(appliedFilters);
|
||||
}, [appliedFilters, debugName]);
|
||||
const resetFilters = useCallback(() => {
|
||||
debugDomain(`${debugName}:clear`);
|
||||
onClear();
|
||||
}, [debugName, onClear]);
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key !== "Enter") return;
|
||||
if (meta.overLimit) return;
|
||||
event.preventDefault();
|
||||
applyFilters();
|
||||
};
|
||||
const handleValueChange = useCallback(
|
||||
(key: keyof TValues, value: string) => {
|
||||
debugDomain(`${debugName}:draft-change`, {
|
||||
field: String(key),
|
||||
valueLength: value.length,
|
||||
});
|
||||
onValueChange(key, value);
|
||||
},
|
||||
[debugName, onValueChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border-b border-base-300 bg-gradient-to-b from-base-100 to-base-200/30 px-4 py-3 space-y-3"
|
||||
onKeyDown={handleApplyKeyDown}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@ -48,16 +132,17 @@ export function DomainFilterPanel({
|
||||
{activeFilterCount} active
|
||||
</span>
|
||||
) : null}
|
||||
{isDirty ? (
|
||||
{meta.dirtyCount > 0 ? (
|
||||
<span className="badge badge-xs badge-warning border-0">
|
||||
{dirtyFilterCount} unapplied
|
||||
{meta.dirtyCount} unapplied
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-xs btn-ghost gap-1"
|
||||
onClick={resetFilters}
|
||||
disabled={activeFilterCount === 0 && !isDirty}
|
||||
disabled={activeFilterCount === 0 && !meta.isDirty}
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
Clear all
|
||||
@ -65,75 +150,56 @@ export function DomainFilterPanel({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
<FilterTextInput
|
||||
form={filtersForm}
|
||||
name="include"
|
||||
label="Include Terms"
|
||||
placeholder="audit, checker, template"
|
||||
/>
|
||||
<FilterTextInput
|
||||
form={filtersForm}
|
||||
name="exclude"
|
||||
label="Exclude Terms"
|
||||
placeholder="jobs, salary, course"
|
||||
/>
|
||||
{textFields.map((field) => (
|
||||
<FilterTextInput
|
||||
key={String(field.key)}
|
||||
label={field.label}
|
||||
placeholder={field.placeholder}
|
||||
value={draftFilters[field.key]}
|
||||
onChange={(value) => handleValueChange(field.key, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||
<FilterRangeInputs
|
||||
form={filtersForm}
|
||||
title="Traffic"
|
||||
minName="minTraffic"
|
||||
maxName="maxTraffic"
|
||||
/>
|
||||
<FilterRangeInputs
|
||||
form={filtersForm}
|
||||
title="Volume"
|
||||
minName="minVol"
|
||||
maxName="maxVol"
|
||||
/>
|
||||
<FilterRangeInputs
|
||||
form={filtersForm}
|
||||
title="CPC (USD)"
|
||||
minName="minCpc"
|
||||
maxName="maxCpc"
|
||||
step="0.01"
|
||||
/>
|
||||
<FilterRangeInputs
|
||||
form={filtersForm}
|
||||
title="Score (KD)"
|
||||
minName="minKd"
|
||||
maxName="maxKd"
|
||||
/>
|
||||
<FilterRangeInputs
|
||||
form={filtersForm}
|
||||
title="Rank"
|
||||
minName="minRank"
|
||||
maxName="maxRank"
|
||||
/>
|
||||
{rangeFields.map((field) => (
|
||||
<FilterRangeGroup key={String(field.minKey)} title={field.title}>
|
||||
<FilterNumberInput
|
||||
value={draftFilters[field.minKey]}
|
||||
onChange={(value) => handleValueChange(field.minKey, value)}
|
||||
placeholder="Min"
|
||||
step={field.step}
|
||||
/>
|
||||
<FilterNumberInput
|
||||
value={draftFilters[field.maxKey]}
|
||||
onChange={(value) => handleValueChange(field.maxKey, value)}
|
||||
placeholder="Max"
|
||||
step={field.step}
|
||||
/>
|
||||
</FilterRangeGroup>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{overLimit ? (
|
||||
{meta.overLimit ? (
|
||||
<div className="alert alert-warning py-2 text-xs">
|
||||
<AlertTriangle className="size-4 shrink-0" />
|
||||
<span>
|
||||
Too many filter conditions ({conditionCount} of{" "}
|
||||
Too many filter conditions ({meta.conditionCount} of{" "}
|
||||
{MAX_DATAFORSEO_FILTER_CONDITIONS} max). Remove some terms or ranges
|
||||
before applying.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-between gap-2 pt-1">
|
||||
<span className="text-xs text-base-content/50 tabular-nums">
|
||||
{conditionCount} / {MAX_DATAFORSEO_FILTER_CONDITIONS} conditions
|
||||
{meta.conditionCount} / {MAX_DATAFORSEO_FILTER_CONDITIONS} conditions
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-ghost"
|
||||
onClick={cancelFilterEdits}
|
||||
disabled={!isDirty}
|
||||
disabled={!meta.isDirty}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@ -141,17 +207,17 @@ export function DomainFilterPanel({
|
||||
type="button"
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={applyFilters}
|
||||
disabled={!canApply}
|
||||
disabled={!meta.isDirty || meta.overLimit}
|
||||
title={
|
||||
overLimit
|
||||
meta.overLimit
|
||||
? `DataForSEO accepts at most ${MAX_DATAFORSEO_FILTER_CONDITIONS} filter conditions per request`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Apply filters
|
||||
{isDirty ? (
|
||||
{meta.isDirty ? (
|
||||
<span className="badge badge-xs ml-1 border-0 bg-primary-content/20">
|
||||
{dirtyFilterCount}
|
||||
{meta.dirtyCount}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
@ -161,80 +227,27 @@ export function DomainFilterPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function FilterTextInput({
|
||||
form,
|
||||
name,
|
||||
label,
|
||||
placeholder,
|
||||
function getFilterMeta<TValues extends FilterValues>({
|
||||
values,
|
||||
appliedFilters,
|
||||
fields,
|
||||
countConditions,
|
||||
}: {
|
||||
form: FilterForm;
|
||||
name: "include" | "exclude";
|
||||
label: string;
|
||||
placeholder: string;
|
||||
values: TValues;
|
||||
appliedFilters: TValues;
|
||||
fields: ReadonlyArray<keyof TValues>;
|
||||
countConditions: (values: TValues) => number;
|
||||
}) {
|
||||
return (
|
||||
<label className="form-control gap-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
|
||||
{label}
|
||||
</span>
|
||||
<form.Field name={name}>
|
||||
{(field) => (
|
||||
<input
|
||||
className="input input-bordered input-sm w-full bg-base-100"
|
||||
placeholder={placeholder}
|
||||
value={field.state.value}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterRangeInputs({
|
||||
form,
|
||||
title,
|
||||
minName,
|
||||
maxName,
|
||||
step,
|
||||
}: {
|
||||
form: FilterForm;
|
||||
title: string;
|
||||
minName: keyof DomainFilterValues;
|
||||
maxName: keyof DomainFilterValues;
|
||||
step?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-base-300 bg-base-100 p-2.5 space-y-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
|
||||
{title}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<form.Field name={minName}>
|
||||
{(field) => (
|
||||
<input
|
||||
className="input input-bordered input-xs bg-base-100"
|
||||
placeholder="Min"
|
||||
type="number"
|
||||
step={step}
|
||||
value={field.state.value}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
<form.Field name={maxName}>
|
||||
{(field) => (
|
||||
<input
|
||||
className="input input-bordered input-xs bg-base-100"
|
||||
placeholder="Max"
|
||||
type="number"
|
||||
step={step}
|
||||
value={field.state.value}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
</div>
|
||||
const conditionCount = countConditions(values);
|
||||
const dirtyCount = fields.reduce(
|
||||
(acc, key) =>
|
||||
acc + (values[key].trim() !== appliedFilters[key].trim() ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
return {
|
||||
conditionCount,
|
||||
dirtyCount,
|
||||
isDirty: dirtyCount > 0,
|
||||
overLimit: conditionCount > MAX_DATAFORSEO_FILTER_CONDITIONS,
|
||||
};
|
||||
}
|
||||
|
||||
@ -59,7 +59,6 @@ export function DomainHistorySection({
|
||||
</p>
|
||||
<p className="text-sm text-base-content/60 truncate">
|
||||
{item.subdomains ? "Include subdomains" : "Root domain only"}
|
||||
{item.search?.trim() ? ` - ${item.search}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import type { ReactNode } from "react";
|
||||
import { DOMAIN_KEYWORDS_PAGE_SIZES } from "@/types/schemas/domain";
|
||||
|
||||
type Props = {
|
||||
@ -70,27 +72,72 @@ export function DomainKeywordsPagination({
|
||||
{totalPages != null ? ` of ${totalPages.toLocaleString()}` : ""}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm btn-square"
|
||||
<PageLink
|
||||
page={page - 1}
|
||||
disabled={!canGoPrev || isLoading}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
aria-label="Previous page"
|
||||
onPageChange={onPageChange}
|
||||
label="Previous page"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm btn-square"
|
||||
</PageLink>
|
||||
<PageLink
|
||||
page={page + 1}
|
||||
disabled={!canGoNext || isLoading}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
aria-label="Next page"
|
||||
onPageChange={onPageChange}
|
||||
label="Next page"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</PageLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageLink({
|
||||
page,
|
||||
disabled,
|
||||
label,
|
||||
children,
|
||||
onPageChange,
|
||||
}: {
|
||||
page: number;
|
||||
disabled: boolean;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
onPageChange: (nextPage: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
from="/p/$projectId/domain"
|
||||
to="/p/$projectId/domain"
|
||||
search={(prev) => ({
|
||||
...prev,
|
||||
page: page === 1 ? undefined : page,
|
||||
})}
|
||||
aria-label={label}
|
||||
aria-disabled={disabled}
|
||||
className={`btn btn-ghost btn-sm btn-square ${disabled ? "btn-disabled" : ""}`}
|
||||
onClick={(event) => {
|
||||
if (disabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey ||
|
||||
event.button !== 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
onPageChange(page);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { memo, useMemo } from "react";
|
||||
import {
|
||||
createColumnHelper,
|
||||
type ColumnDef,
|
||||
@ -13,7 +13,8 @@ import {
|
||||
import { ExternalUrlCell } from "@/client/components/table/url";
|
||||
import { DifficultyBadge } from "@/client/features/domain/components/DifficultyBadge";
|
||||
import { SortableHeader } from "@/client/features/domain/components/SortableHeader";
|
||||
import { formatFloat, formatNumber } from "@/client/features/domain/utils";
|
||||
import { useDomainRenderDebug } from "@/client/features/domain/domainDebug";
|
||||
import { formatNumber, formatRounded } from "@/client/features/domain/utils";
|
||||
import type {
|
||||
DomainSortMode,
|
||||
KeywordRow,
|
||||
@ -33,7 +34,7 @@ type Props = {
|
||||
|
||||
const keywordColumnHelper = createColumnHelper<KeywordRow>();
|
||||
|
||||
export function DomainKeywordsTable({
|
||||
function DomainKeywordsTableComponent({
|
||||
domain,
|
||||
rows,
|
||||
selectedKeywords,
|
||||
@ -43,6 +44,7 @@ export function DomainKeywordsTable({
|
||||
onSortClick,
|
||||
onToggleKeyword,
|
||||
}: Props) {
|
||||
const renderStarted = performance.now();
|
||||
const selectAnchorRef = useSelectionAnchor();
|
||||
const rowSelection = useMemo<RowSelectionState>(
|
||||
() =>
|
||||
@ -91,7 +93,7 @@ export function DomainKeywordsTable({
|
||||
onClick={() => onSortClick("traffic")}
|
||||
/>
|
||||
),
|
||||
cell: ({ getValue }) => formatFloat(getValue()),
|
||||
cell: ({ getValue }) => formatRounded(getValue()),
|
||||
}),
|
||||
keywordColumnHelper.accessor("cpc", {
|
||||
header: () => (
|
||||
@ -157,6 +159,13 @@ export function DomainKeywordsTable({
|
||||
getRowId: (row) => row.keyword,
|
||||
enableRowSelection: true,
|
||||
});
|
||||
useDomainRenderDebug("DomainKeywordsTable", {
|
||||
rows: rows.length,
|
||||
selectedCount: selectedKeywords.size,
|
||||
durationMs: Math.round(performance.now() - renderStarted),
|
||||
sortMode,
|
||||
currentSortOrder,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
@ -178,3 +187,5 @@ export function DomainKeywordsTable({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const DomainKeywordsTable = memo(DomainKeywordsTableComponent);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { memo, useMemo } from "react";
|
||||
import { createColumnHelper, type ColumnDef } from "@tanstack/react-table";
|
||||
import {
|
||||
AppDataTable,
|
||||
@ -6,9 +6,10 @@ import {
|
||||
} from "@/client/components/table/AppDataTable";
|
||||
import { ExternalUrlCell } from "@/client/components/table/url";
|
||||
import { SortableHeader } from "@/client/features/domain/components/SortableHeader";
|
||||
import { useDomainRenderDebug } from "@/client/features/domain/domainDebug";
|
||||
import {
|
||||
formatFloat,
|
||||
formatNumber,
|
||||
formatRounded,
|
||||
toPageSortMode,
|
||||
} from "@/client/features/domain/utils";
|
||||
import type {
|
||||
@ -27,13 +28,14 @@ type Props = {
|
||||
|
||||
const pageColumnHelper = createColumnHelper<PageRow>();
|
||||
|
||||
export function DomainPagesTable({
|
||||
function DomainPagesTableComponent({
|
||||
domain,
|
||||
rows,
|
||||
sortMode,
|
||||
currentSortOrder,
|
||||
onSortClick,
|
||||
}: Props) {
|
||||
const renderStarted = performance.now();
|
||||
const columns = useMemo<ColumnDef<PageRow>[]>(
|
||||
() => [
|
||||
pageColumnHelper.display({
|
||||
@ -60,7 +62,7 @@ export function DomainPagesTable({
|
||||
onClick={() => onSortClick("traffic")}
|
||||
/>
|
||||
),
|
||||
cell: ({ getValue }) => formatFloat(getValue()),
|
||||
cell: ({ getValue }) => formatRounded(getValue()),
|
||||
}),
|
||||
pageColumnHelper.accessor("keywords", {
|
||||
header: () => (
|
||||
@ -80,6 +82,12 @@ export function DomainPagesTable({
|
||||
data: rows.slice(0, 100),
|
||||
columns,
|
||||
});
|
||||
useDomainRenderDebug("DomainPagesTable", {
|
||||
rows: rows.length,
|
||||
durationMs: Math.round(performance.now() - renderStarted),
|
||||
sortMode,
|
||||
currentSortOrder,
|
||||
});
|
||||
|
||||
return (
|
||||
<AppDataTable
|
||||
@ -93,3 +101,5 @@ export function DomainPagesTable({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const DomainPagesTable = memo(DomainPagesTableComponent);
|
||||
|
||||
@ -1,404 +0,0 @@
|
||||
import { type Dispatch, type SetStateAction } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import {
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Download,
|
||||
FileSpreadsheet,
|
||||
Save,
|
||||
Search,
|
||||
Sheet,
|
||||
SlidersHorizontal,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { DomainFilterPanel } from "@/client/features/domain/components/DomainFilterPanel";
|
||||
import { DomainKeywordsPagination } from "@/client/features/domain/components/DomainKeywordsPagination";
|
||||
import { DomainKeywordsTable } from "@/client/features/domain/components/DomainKeywordsTable";
|
||||
import { DomainPagesTable } from "@/client/features/domain/components/DomainPagesTable";
|
||||
import type { useDomainFilters } from "@/client/features/domain/hooks/useDomainFilters";
|
||||
import {
|
||||
getDefaultSortOrder,
|
||||
keywordsToTable,
|
||||
pagesToTable,
|
||||
} from "@/client/features/domain/utils";
|
||||
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
||||
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import {
|
||||
TableBulkActionBar,
|
||||
TableBulkActionButton,
|
||||
TableBulkExportMenu,
|
||||
} from "@/client/components/table/TableBulkActionBar";
|
||||
import type {
|
||||
DomainActiveTab,
|
||||
DomainOverviewData,
|
||||
DomainSortMode,
|
||||
KeywordRow,
|
||||
PageRow,
|
||||
SortOrder,
|
||||
} from "@/client/features/domain/types";
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
overview: DomainOverviewData;
|
||||
activeTab: DomainActiveTab;
|
||||
sortMode: DomainSortMode;
|
||||
currentSortOrder: SortOrder;
|
||||
searchDraft: string;
|
||||
selectedKeywords: Set<string>;
|
||||
setSelectedKeywords: Dispatch<SetStateAction<Set<string>>>;
|
||||
visibleKeywords: string[];
|
||||
filteredKeywords: KeywordRow[];
|
||||
pagedPages: PageRow[];
|
||||
showFilters: boolean;
|
||||
setShowFilters: Dispatch<SetStateAction<boolean>>;
|
||||
filtersForm: ReturnType<typeof useDomainFilters>["filtersForm"];
|
||||
activeFilterCount: number;
|
||||
dirtyFilterCount: number;
|
||||
conditionCount: number;
|
||||
overLimit: boolean;
|
||||
resetFilters: () => void;
|
||||
applyFilters: () => void;
|
||||
cancelFilterEdits: () => void;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSaveKeywords: () => void;
|
||||
canSaveKeywords: boolean;
|
||||
onSortClick: (sort: DomainSortMode) => void;
|
||||
onToggleKeyword: (keyword: string) => void;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalKeywordCount: number | null;
|
||||
totalPagesCount: number | null;
|
||||
hasNextKeywordsPage: boolean;
|
||||
hasNextPagesPage: boolean;
|
||||
isKeywordsLoading: boolean;
|
||||
isPagesLoading: boolean;
|
||||
onPageChange: (nextPage: number) => void;
|
||||
onPageSizeChange: (nextSize: number) => void;
|
||||
};
|
||||
|
||||
const KEYWORDS_ONLY_SORTS: ReadonlySet<DomainSortMode> = new Set([
|
||||
"rank",
|
||||
"score",
|
||||
"cpc",
|
||||
]);
|
||||
|
||||
export function DomainResultsCard({
|
||||
projectId,
|
||||
overview,
|
||||
activeTab,
|
||||
sortMode,
|
||||
currentSortOrder,
|
||||
searchDraft,
|
||||
selectedKeywords,
|
||||
setSelectedKeywords,
|
||||
visibleKeywords,
|
||||
filteredKeywords,
|
||||
pagedPages,
|
||||
showFilters,
|
||||
setShowFilters,
|
||||
filtersForm,
|
||||
activeFilterCount,
|
||||
dirtyFilterCount,
|
||||
conditionCount,
|
||||
overLimit,
|
||||
resetFilters,
|
||||
applyFilters,
|
||||
cancelFilterEdits,
|
||||
onSearchChange,
|
||||
onSaveKeywords,
|
||||
canSaveKeywords,
|
||||
onSortClick,
|
||||
onToggleKeyword,
|
||||
page,
|
||||
pageSize,
|
||||
totalKeywordCount,
|
||||
totalPagesCount,
|
||||
hasNextKeywordsPage,
|
||||
hasNextPagesPage,
|
||||
isKeywordsLoading,
|
||||
isPagesLoading,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
}: Props) {
|
||||
const isKeywordsTab = activeTab === "keywords";
|
||||
const currentRows = isKeywordsTab ? filteredKeywords : pagedPages;
|
||||
const exportTable = isKeywordsTab
|
||||
? keywordsToTable(filteredKeywords)
|
||||
: pagesToTable(pagedPages);
|
||||
const selectedKeywordRows = filteredKeywords.filter((row) =>
|
||||
selectedKeywords.has(row.keyword),
|
||||
);
|
||||
const selectedKeywordExportTable = keywordsToTable(selectedKeywordRows);
|
||||
|
||||
const handleCopy = async () => {
|
||||
const text = JSON.stringify(currentRows, null, 2);
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success("Copied data");
|
||||
};
|
||||
|
||||
const handleExportToSheets = () => {
|
||||
void exportTableToSheets({
|
||||
headers: exportTable.headers,
|
||||
rows: exportTable.rows,
|
||||
feature: "domain_overview",
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownload = (extension: "csv" | "xls") => {
|
||||
downloadCsv(
|
||||
`${overview.domain}-${activeTab}.${extension}`,
|
||||
buildCsv(exportTable.headers, exportTable.rows),
|
||||
);
|
||||
|
||||
if (extension === "csv") {
|
||||
captureClientEvent("data:export", {
|
||||
source_feature: "domain_overview",
|
||||
result_count: currentRows.length,
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleExportSelectionToSheets = () => {
|
||||
void exportTableToSheets({
|
||||
headers: selectedKeywordExportTable.headers,
|
||||
rows: selectedKeywordExportTable.rows,
|
||||
feature: "domain_overview",
|
||||
});
|
||||
};
|
||||
const handleDownloadSelectionCsv = () => {
|
||||
downloadCsv(
|
||||
`${overview.domain}-selected-keywords.csv`,
|
||||
buildCsv(
|
||||
selectedKeywordExportTable.headers,
|
||||
selectedKeywordExportTable.rows,
|
||||
),
|
||||
);
|
||||
|
||||
captureClientEvent("data:export", {
|
||||
source_feature: "domain_overview",
|
||||
result_count: selectedKeywordRows.length,
|
||||
scope: "selection",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 px-4 py-3 border-b border-base-300">
|
||||
<div role="tablist" className="tabs tabs-box w-fit">
|
||||
<Link
|
||||
from="/p/$projectId/domain"
|
||||
to="/p/$projectId/domain"
|
||||
params={{ projectId }}
|
||||
search={(prev) => ({ ...prev, tab: undefined, page: undefined })}
|
||||
replace
|
||||
role="tab"
|
||||
className={`tab ${activeTab === "keywords" ? "tab-active" : ""}`}
|
||||
>
|
||||
Top Keywords
|
||||
</Link>
|
||||
<Link
|
||||
from="/p/$projectId/domain"
|
||||
to="/p/$projectId/domain"
|
||||
params={{ projectId }}
|
||||
search={(prev) => {
|
||||
const fallbackSortNeeded = KEYWORDS_ONLY_SORTS.has(sortMode);
|
||||
const nextSort = fallbackSortNeeded ? "traffic" : prev.sort;
|
||||
const nextOrder = fallbackSortNeeded
|
||||
? getDefaultSortOrder("traffic")
|
||||
: prev.order;
|
||||
return {
|
||||
...prev,
|
||||
tab: "pages" as const,
|
||||
sort: nextSort,
|
||||
order: nextOrder,
|
||||
page: undefined,
|
||||
};
|
||||
}}
|
||||
replace
|
||||
role="tab"
|
||||
className={`tab ${activeTab === "pages" ? "tab-active" : ""}`}
|
||||
>
|
||||
Top Pages
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="dropdown dropdown-end">
|
||||
<div tabIndex={0} role="button" className="btn btn-sm gap-1">
|
||||
<Download className="size-4" />
|
||||
Export
|
||||
<ChevronDown className="size-3 opacity-60" />
|
||||
</div>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-56"
|
||||
>
|
||||
<li>
|
||||
<button onClick={handleExportToSheets}>
|
||||
<Sheet className="size-4" />
|
||||
Export to Sheets
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button onClick={handleCopy}>
|
||||
<Copy className="size-4" />
|
||||
Copy data (JSON)
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button onClick={() => handleDownload("csv")}>
|
||||
<Download className="size-4" />
|
||||
Download CSV
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button onClick={() => handleDownload("xls")}>
|
||||
<FileSpreadsheet className="size-4" />
|
||||
Download Excel
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === "keywords" ? (
|
||||
<TableBulkActionBar
|
||||
selectedCount={selectedKeywords.size}
|
||||
onClear={() => setSelectedKeywords(new Set())}
|
||||
actions={
|
||||
<div className="flex items-center px-1.5">
|
||||
<TableBulkActionButton
|
||||
icon={<Save className="size-3.5" />}
|
||||
onClick={onSaveKeywords}
|
||||
disabled={!canSaveKeywords}
|
||||
>
|
||||
Save Keywords
|
||||
</TableBulkActionButton>
|
||||
<TableBulkExportMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Export to Sheets",
|
||||
icon: <Sheet className="size-4" />,
|
||||
onClick: handleExportSelectionToSheets,
|
||||
},
|
||||
{
|
||||
label: "Download CSV",
|
||||
icon: <Download className="size-4" />,
|
||||
onClick: handleDownloadSelectionCsv,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-base-300">
|
||||
{isKeywordsTab ? (
|
||||
<button
|
||||
className={`btn btn-ghost btn-sm gap-1.5 ${showFilters ? "btn-active" : ""}`}
|
||||
onClick={() => setShowFilters((prev) => !prev)}
|
||||
title="Toggle filters"
|
||||
>
|
||||
<SlidersHorizontal className="size-3.5" />
|
||||
Filters
|
||||
{activeFilterCount > 0 ? (
|
||||
<span className="badge badge-xs badge-primary border-0 text-primary-content">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
) : null}
|
||||
<span className="text-sm text-base-content/60">
|
||||
{isKeywordsTab
|
||||
? totalKeywordCount != null
|
||||
? `${totalKeywordCount.toLocaleString()} keywords`
|
||||
: `${filteredKeywords.length.toLocaleString()} keywords`
|
||||
: totalPagesCount != null
|
||||
? `${totalPagesCount.toLocaleString()} pages`
|
||||
: `${pagedPages.length.toLocaleString()} pages`}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<form
|
||||
className="w-full max-w-xs"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!overLimit) applyFilters();
|
||||
}}
|
||||
>
|
||||
<label className="input input-bordered input-sm w-full flex items-center gap-2">
|
||||
<Search className="size-4 text-base-content/60" />
|
||||
<input
|
||||
placeholder="Search in results (press Enter)"
|
||||
value={searchDraft}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{isKeywordsTab && showFilters ? (
|
||||
<DomainFilterPanel
|
||||
filtersForm={filtersForm}
|
||||
activeFilterCount={activeFilterCount}
|
||||
dirtyFilterCount={dirtyFilterCount}
|
||||
conditionCount={conditionCount}
|
||||
overLimit={overLimit}
|
||||
resetFilters={resetFilters}
|
||||
applyFilters={applyFilters}
|
||||
cancelFilterEdits={cancelFilterEdits}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="p-4">
|
||||
{isKeywordsTab ? (
|
||||
<div
|
||||
className={
|
||||
isKeywordsLoading
|
||||
? "opacity-60 transition-opacity"
|
||||
: "transition-opacity"
|
||||
}
|
||||
>
|
||||
<DomainKeywordsTable
|
||||
domain={overview.domain}
|
||||
rows={filteredKeywords}
|
||||
selectedKeywords={selectedKeywords}
|
||||
visibleKeywords={visibleKeywords}
|
||||
sortMode={sortMode}
|
||||
currentSortOrder={currentSortOrder}
|
||||
onSortClick={onSortClick}
|
||||
onToggleKeyword={onToggleKeyword}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
isPagesLoading
|
||||
? "opacity-60 transition-opacity"
|
||||
: "transition-opacity"
|
||||
}
|
||||
>
|
||||
<DomainPagesTable
|
||||
domain={overview.domain}
|
||||
rows={pagedPages}
|
||||
sortMode={sortMode}
|
||||
currentSortOrder={currentSortOrder}
|
||||
onSortClick={onSortClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DomainKeywordsPagination
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalCount={isKeywordsTab ? totalKeywordCount : totalPagesCount}
|
||||
hasNextPage={isKeywordsTab ? hasNextKeywordsPage : hasNextPagesPage}
|
||||
isLoading={isKeywordsTab ? isKeywordsLoading : isPagesLoading}
|
||||
onPageChange={onPageChange}
|
||||
onPageSizeChange={onPageSizeChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,13 +1,13 @@
|
||||
import type { FormEvent } from "react";
|
||||
import { AlertCircle, Search } from "lucide-react";
|
||||
import { getFieldError, getFormError } from "@/client/lib/forms";
|
||||
import type { useDomainOverviewController } from "@/client/features/domain/useDomainOverviewController";
|
||||
import type { DomainOverviewControlsForm } from "@/client/features/domain/DomainOverviewPage";
|
||||
import { toSortMode } from "@/client/features/domain/utils";
|
||||
import type { DomainSortMode } from "@/client/features/domain/types";
|
||||
import { LOCATION_OPTIONS } from "@/client/features/keywords/locations";
|
||||
|
||||
type Props = {
|
||||
controlsForm: ReturnType<typeof useDomainOverviewController>["controlsForm"];
|
||||
controlsForm: DomainOverviewControlsForm;
|
||||
isLoading: boolean;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
onSortChange: (sort: DomainSortMode) => void;
|
||||
|
||||
@ -0,0 +1,82 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import { TableExportMenu } from "@/client/components/table/TableBulkActionBar";
|
||||
import { TableLoadingRows } from "@/client/features/domain/components/TableLoadingRows";
|
||||
|
||||
type DomainTableExportAction = {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
showFilters: boolean;
|
||||
onToggleFilters: () => void;
|
||||
activeFilterCount: number;
|
||||
countLabel: string;
|
||||
totalCount: number | null;
|
||||
fallbackCount: number;
|
||||
exportActions: DomainTableExportAction[];
|
||||
filterPanel?: ReactNode;
|
||||
isLoading: boolean;
|
||||
showTableLoading: boolean;
|
||||
children: ReactNode;
|
||||
pagination: ReactNode;
|
||||
};
|
||||
|
||||
export function DomainTableTabSurface({
|
||||
showFilters,
|
||||
onToggleFilters,
|
||||
activeFilterCount,
|
||||
countLabel,
|
||||
totalCount,
|
||||
fallbackCount,
|
||||
exportActions,
|
||||
filterPanel,
|
||||
isLoading,
|
||||
showTableLoading,
|
||||
children,
|
||||
pagination,
|
||||
}: Props) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-base-300">
|
||||
<button
|
||||
className={`btn btn-ghost btn-sm gap-1.5 ${showFilters ? "btn-active" : ""}`}
|
||||
onClick={onToggleFilters}
|
||||
title="Toggle filters"
|
||||
type="button"
|
||||
>
|
||||
<SlidersHorizontal className="size-3.5" />
|
||||
Filters
|
||||
{activeFilterCount > 0 ? (
|
||||
<span className="badge badge-xs badge-primary border-0 text-primary-content">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
<span className="text-sm text-base-content/60">
|
||||
{(totalCount ?? fallbackCount).toLocaleString()} {countLabel}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<TableExportMenu actions={exportActions} />
|
||||
</div>
|
||||
|
||||
{filterPanel}
|
||||
|
||||
<div className="p-4">
|
||||
<div
|
||||
className={
|
||||
isLoading && !showTableLoading
|
||||
? "opacity-60 transition-opacity"
|
||||
: "transition-opacity"
|
||||
}
|
||||
>
|
||||
{showTableLoading ? <TableLoadingRows /> : children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pagination}
|
||||
</>
|
||||
);
|
||||
}
|
||||
354
src/client/features/domain/components/KeywordsTab.tsx
Normal file
354
src/client/features/domain/components/KeywordsTab.tsx
Normal file
@ -0,0 +1,354 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Copy, Download, FileSpreadsheet, Save, Sheet } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
TableBulkActionBar,
|
||||
TableBulkActionButton,
|
||||
TableBulkExportMenu,
|
||||
} from "@/client/components/table/TableBulkActionBar";
|
||||
import { DomainKeywordsPagination } from "@/client/features/domain/components/DomainKeywordsPagination";
|
||||
import { DomainKeywordsTable } from "@/client/features/domain/components/DomainKeywordsTable";
|
||||
import { DomainFilterPanel } from "@/client/features/domain/components/DomainFilterPanel";
|
||||
import { DomainTableTabSurface } from "@/client/features/domain/components/DomainTableTabSurface";
|
||||
import { saveSelectedKeywords } from "@/client/features/domain/domainActions";
|
||||
import {
|
||||
KEYWORD_FILTER_FIELDS,
|
||||
buildKeywordsSearchUpdate,
|
||||
countKeywordFilterConditions,
|
||||
} from "@/client/features/domain/domainFilterUtils";
|
||||
import {
|
||||
debugDomain,
|
||||
useDomainRenderDebug,
|
||||
} from "@/client/features/domain/domainDebug";
|
||||
import { useDomainKeywordsQuery } from "@/client/features/domain/hooks/useDomainKeywordsQuery";
|
||||
import { useSaveKeywordsMutation } from "@/client/features/domain/mutations";
|
||||
import { useDomainKeywordFilterPreferences } from "@/client/features/domain/useDomainFilterPreferences";
|
||||
import {
|
||||
type DomainSortMode,
|
||||
type KeywordRow,
|
||||
type KeywordsFilterValues,
|
||||
} from "@/client/features/domain/types";
|
||||
import { keywordsToTable } from "@/client/features/domain/utils";
|
||||
import type { DomainOverviewRouteState } from "@/client/features/domain/domainRouteState";
|
||||
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
||||
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import {
|
||||
MAX_DATAFORSEO_FILTER_CONDITIONS,
|
||||
type DomainSearchParams,
|
||||
} from "@/types/schemas/domain";
|
||||
|
||||
type SearchUpdate = Partial<DomainSearchParams>;
|
||||
|
||||
const EMPTY_KEYWORDS: KeywordRow[] = [];
|
||||
const KEYWORD_TEXT_FILTERS = [
|
||||
{
|
||||
key: "include",
|
||||
label: "Include Terms",
|
||||
placeholder: "audit, checker, template",
|
||||
},
|
||||
{
|
||||
key: "exclude",
|
||||
label: "Exclude Terms",
|
||||
placeholder: "jobs, salary, course",
|
||||
},
|
||||
] as const;
|
||||
const KEYWORD_RANGE_FILTERS = [
|
||||
{ title: "Traffic", minKey: "minTraffic", maxKey: "maxTraffic" },
|
||||
{ title: "Volume", minKey: "minVol", maxKey: "maxVol" },
|
||||
{ title: "CPC (USD)", minKey: "minCpc", maxKey: "maxCpc", step: "0.01" },
|
||||
{ title: "Score (KD)", minKey: "minKd", maxKey: "maxKd" },
|
||||
{ title: "Rank", minKey: "minRank", maxKey: "maxRank" },
|
||||
] as const;
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
domain: string;
|
||||
languageCode: string;
|
||||
routeState: DomainOverviewRouteState;
|
||||
canSaveKeywords: boolean;
|
||||
setSearchParams: (updates: SearchUpdate) => void;
|
||||
onSortClick: (sort: DomainSortMode) => void;
|
||||
onPageChange: (nextPage: number) => void;
|
||||
onPageSizeChange: (nextSize: number) => void;
|
||||
};
|
||||
|
||||
export function KeywordsTab({
|
||||
projectId,
|
||||
domain,
|
||||
languageCode,
|
||||
routeState,
|
||||
canSaveKeywords,
|
||||
setSearchParams,
|
||||
onSortClick,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
}: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedKeywords, setSelectedKeywords] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const filterPreferences = useDomainKeywordFilterPreferences(
|
||||
`${projectId}:${domain}`,
|
||||
);
|
||||
const {
|
||||
filters: preferredFilters,
|
||||
save: savePreferredFilters,
|
||||
clear: clearPreferredFilters,
|
||||
} = filterPreferences;
|
||||
const appliedFilters = routeState.hasAppliedKeywordFilters
|
||||
? routeState.appliedFilters
|
||||
: preferredFilters;
|
||||
|
||||
const query = useDomainKeywordsQuery({
|
||||
projectId,
|
||||
domain,
|
||||
includeSubdomains: routeState.subdomains,
|
||||
locationCode: routeState.locationCode,
|
||||
languageCode,
|
||||
page: routeState.page,
|
||||
pageSize: routeState.pageSize,
|
||||
sortMode: routeState.sort,
|
||||
sortOrder: routeState.order,
|
||||
appliedFilters,
|
||||
enabled: Boolean(domain),
|
||||
});
|
||||
|
||||
const rows = query.data?.keywords ?? EMPTY_KEYWORDS;
|
||||
const totalCount = query.data?.totalCount ?? null;
|
||||
const hasNextPage = query.data?.hasMore ?? false;
|
||||
const isLoading = query.isFetching;
|
||||
const showTableLoading = isLoading && (showFilters || rows.length === 0);
|
||||
useDomainRenderDebug("KeywordsTab", {
|
||||
showFilters,
|
||||
isLoading,
|
||||
isPending: query.isPending,
|
||||
rows: rows.length,
|
||||
totalCount,
|
||||
selectedCount: selectedKeywords.size,
|
||||
activeTab: routeState.tab,
|
||||
page: routeState.page,
|
||||
sort: routeState.sort,
|
||||
order: routeState.order,
|
||||
});
|
||||
|
||||
const visibleKeywords = useMemo(() => rows.map((r) => r.keyword), [rows]);
|
||||
useEffect(() => {
|
||||
const visibleSet = new Set(visibleKeywords);
|
||||
setSelectedKeywords((prev) => {
|
||||
const next = new Set([...prev].filter((k) => visibleSet.has(k)));
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
}, [visibleKeywords]);
|
||||
|
||||
const toggleKeywordSelection = useCallback((keyword: string) => {
|
||||
setSelectedKeywords((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(keyword)) next.delete(keyword);
|
||||
else next.add(keyword);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const saveMutation = useSaveKeywordsMutation({ projectId, queryClient });
|
||||
const handleSaveKeywords = useCallback(() => {
|
||||
saveSelectedKeywords({
|
||||
selectedKeywords,
|
||||
filteredKeywords: rows,
|
||||
save: saveMutation.mutate,
|
||||
projectId,
|
||||
locationCode: routeState.locationCode,
|
||||
languageCode,
|
||||
});
|
||||
}, [
|
||||
languageCode,
|
||||
projectId,
|
||||
routeState.locationCode,
|
||||
rows,
|
||||
saveMutation.mutate,
|
||||
selectedKeywords,
|
||||
]);
|
||||
|
||||
const applyFilters = useCallback(
|
||||
(values: KeywordsFilterValues) => {
|
||||
if (
|
||||
countKeywordFilterConditions(values) > MAX_DATAFORSEO_FILTER_CONDITIONS
|
||||
)
|
||||
return;
|
||||
const update = buildKeywordsSearchUpdate(values);
|
||||
debugDomain("KeywordsTab:apply-filters", { values, update });
|
||||
savePreferredFilters(values);
|
||||
setSearchParams(update);
|
||||
},
|
||||
[savePreferredFilters, setSearchParams],
|
||||
);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
const update: SearchUpdate = { page: undefined };
|
||||
for (const key of KEYWORD_FILTER_FIELDS) update[key] = undefined;
|
||||
debugDomain("KeywordsTab:reset-filters", { update });
|
||||
clearPreferredFilters();
|
||||
setSearchParams(update);
|
||||
}, [clearPreferredFilters, setSearchParams]);
|
||||
|
||||
const activeFilterCount = useMemo(
|
||||
() =>
|
||||
KEYWORD_FILTER_FIELDS.filter((k) => appliedFilters[k].trim() !== "")
|
||||
.length,
|
||||
[appliedFilters],
|
||||
);
|
||||
|
||||
const exportTable = useMemo(() => keywordsToTable(rows), [rows]);
|
||||
const selectedExportTable = useMemo(
|
||||
() => keywordsToTable(rows.filter((r) => selectedKeywords.has(r.keyword))),
|
||||
[rows, selectedKeywords],
|
||||
);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(JSON.stringify(rows, null, 2));
|
||||
toast.success("Copied data");
|
||||
};
|
||||
const handleExportToSheets = () => {
|
||||
void exportTableToSheets({
|
||||
headers: exportTable.headers,
|
||||
rows: exportTable.rows,
|
||||
feature: "domain_overview",
|
||||
});
|
||||
};
|
||||
const handleDownload = (extension: "csv" | "xls") => {
|
||||
downloadCsv(
|
||||
`${domain}-keywords.${extension}`,
|
||||
buildCsv(exportTable.headers, exportTable.rows),
|
||||
);
|
||||
if (extension === "csv") {
|
||||
captureClientEvent("data:export", {
|
||||
source_feature: "domain_overview",
|
||||
result_count: rows.length,
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleExportSelectionToSheets = () => {
|
||||
void exportTableToSheets({
|
||||
headers: selectedExportTable.headers,
|
||||
rows: selectedExportTable.rows,
|
||||
feature: "domain_overview",
|
||||
});
|
||||
};
|
||||
const handleDownloadSelectionCsv = () => {
|
||||
downloadCsv(
|
||||
`${domain}-selected-keywords.csv`,
|
||||
buildCsv(selectedExportTable.headers, selectedExportTable.rows),
|
||||
);
|
||||
captureClientEvent("data:export", {
|
||||
source_feature: "domain_overview",
|
||||
result_count: selectedKeywords.size,
|
||||
scope: "selection",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableBulkActionBar
|
||||
selectedCount={selectedKeywords.size}
|
||||
onClear={() => setSelectedKeywords(new Set())}
|
||||
actions={
|
||||
<div className="flex items-center px-1.5">
|
||||
<TableBulkActionButton
|
||||
icon={<Save className="size-3.5" />}
|
||||
onClick={handleSaveKeywords}
|
||||
disabled={!canSaveKeywords}
|
||||
>
|
||||
Save Keywords
|
||||
</TableBulkActionButton>
|
||||
<TableBulkExportMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Export to Sheets",
|
||||
icon: <Sheet className="size-4" />,
|
||||
onClick: handleExportSelectionToSheets,
|
||||
},
|
||||
{
|
||||
label: "Download CSV",
|
||||
icon: <Download className="size-4" />,
|
||||
onClick: handleDownloadSelectionCsv,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<DomainTableTabSurface
|
||||
showFilters={showFilters}
|
||||
onToggleFilters={() => setShowFilters((prev) => !prev)}
|
||||
activeFilterCount={activeFilterCount}
|
||||
countLabel="keywords"
|
||||
totalCount={totalCount}
|
||||
fallbackCount={rows.length}
|
||||
isLoading={isLoading}
|
||||
showTableLoading={showTableLoading}
|
||||
exportActions={[
|
||||
{
|
||||
label: "Export to Sheets",
|
||||
icon: <Sheet className="size-4" />,
|
||||
onClick: handleExportToSheets,
|
||||
},
|
||||
{
|
||||
label: "Copy data (JSON)",
|
||||
icon: <Copy className="size-4" />,
|
||||
onClick: handleCopy,
|
||||
},
|
||||
{
|
||||
label: "Download CSV",
|
||||
icon: <Download className="size-4" />,
|
||||
onClick: () => handleDownload("csv"),
|
||||
},
|
||||
{
|
||||
label: "Download Excel",
|
||||
icon: <FileSpreadsheet className="size-4" />,
|
||||
onClick: () => handleDownload("xls"),
|
||||
},
|
||||
]}
|
||||
filterPanel={
|
||||
showFilters ? (
|
||||
<DomainFilterPanel
|
||||
debugName="KeywordsFilterPanel"
|
||||
activeFilterCount={activeFilterCount}
|
||||
appliedFilters={appliedFilters}
|
||||
fields={KEYWORD_FILTER_FIELDS}
|
||||
textFields={KEYWORD_TEXT_FILTERS}
|
||||
rangeFields={KEYWORD_RANGE_FILTERS}
|
||||
countConditions={countKeywordFilterConditions}
|
||||
onApply={applyFilters}
|
||||
onClear={resetFilters}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
pagination={
|
||||
<DomainKeywordsPagination
|
||||
page={routeState.page}
|
||||
pageSize={routeState.pageSize}
|
||||
totalCount={totalCount}
|
||||
hasNextPage={hasNextPage}
|
||||
isLoading={isLoading}
|
||||
onPageChange={onPageChange}
|
||||
onPageSizeChange={onPageSizeChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DomainKeywordsTable
|
||||
domain={domain}
|
||||
rows={rows}
|
||||
selectedKeywords={selectedKeywords}
|
||||
visibleKeywords={visibleKeywords}
|
||||
sortMode={routeState.sort}
|
||||
currentSortOrder={routeState.order}
|
||||
onSortClick={onSortClick}
|
||||
onToggleKeyword={toggleKeywordSelection}
|
||||
/>
|
||||
</DomainTableTabSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
250
src/client/features/domain/components/PagesTab.tsx
Normal file
250
src/client/features/domain/components/PagesTab.tsx
Normal file
@ -0,0 +1,250 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Copy, Download, FileSpreadsheet, Sheet } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { DomainKeywordsPagination } from "@/client/features/domain/components/DomainKeywordsPagination";
|
||||
import { DomainFilterPanel } from "@/client/features/domain/components/DomainFilterPanel";
|
||||
import { DomainPagesTable } from "@/client/features/domain/components/DomainPagesTable";
|
||||
import { DomainTableTabSurface } from "@/client/features/domain/components/DomainTableTabSurface";
|
||||
import {
|
||||
PAGE_FILTER_FIELDS,
|
||||
buildPagesClearSearchUpdate,
|
||||
buildPagesSearchUpdate,
|
||||
countPageFilterConditions,
|
||||
} from "@/client/features/domain/domainFilterUtils";
|
||||
import {
|
||||
debugDomain,
|
||||
useDomainRenderDebug,
|
||||
} from "@/client/features/domain/domainDebug";
|
||||
import { useDomainPagesQuery } from "@/client/features/domain/hooks/useDomainPagesQuery";
|
||||
import { useDomainPageFilterPreferences } from "@/client/features/domain/useDomainFilterPreferences";
|
||||
import {
|
||||
type DomainSortMode,
|
||||
type PageRow,
|
||||
type PagesFilterValues,
|
||||
} from "@/client/features/domain/types";
|
||||
import { pagesToTable } from "@/client/features/domain/utils";
|
||||
import type { DomainOverviewRouteState } from "@/client/features/domain/domainRouteState";
|
||||
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
||||
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import {
|
||||
MAX_DATAFORSEO_FILTER_CONDITIONS,
|
||||
type DomainSearchParams,
|
||||
} from "@/types/schemas/domain";
|
||||
|
||||
type SearchUpdate = Partial<DomainSearchParams>;
|
||||
|
||||
const EMPTY_PAGES_ROWS: PageRow[] = [];
|
||||
const PAGE_TEXT_FILTERS = [
|
||||
{
|
||||
key: "include",
|
||||
label: "Include Page Terms",
|
||||
placeholder: "pricing, tools, guides",
|
||||
},
|
||||
{
|
||||
key: "exclude",
|
||||
label: "Exclude Page Terms",
|
||||
placeholder: "blog, tag, archive",
|
||||
},
|
||||
] as const;
|
||||
const PAGE_RANGE_FILTERS = [
|
||||
{ title: "Traffic", minKey: "minTraffic", maxKey: "maxTraffic" },
|
||||
{ title: "Keywords", minKey: "minVol", maxKey: "maxVol" },
|
||||
] as const;
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
domain: string;
|
||||
languageCode: string;
|
||||
routeState: DomainOverviewRouteState;
|
||||
setSearchParams: (updates: SearchUpdate) => void;
|
||||
onSortClick: (sort: DomainSortMode) => void;
|
||||
onPageChange: (nextPage: number) => void;
|
||||
onPageSizeChange: (nextSize: number) => void;
|
||||
};
|
||||
|
||||
export function PagesTab({
|
||||
projectId,
|
||||
domain,
|
||||
languageCode,
|
||||
routeState,
|
||||
setSearchParams,
|
||||
onSortClick,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
}: Props) {
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const filterPreferences = useDomainPageFilterPreferences(
|
||||
`${projectId}:${domain}`,
|
||||
);
|
||||
const {
|
||||
filters: preferredFilters,
|
||||
save: savePreferredFilters,
|
||||
clear: clearPreferredFilters,
|
||||
} = filterPreferences;
|
||||
const appliedPagesFilters = useMemo(
|
||||
() =>
|
||||
routeState.hasAppliedPageFilters
|
||||
? routeState.appliedPageFilters
|
||||
: preferredFilters,
|
||||
[
|
||||
preferredFilters,
|
||||
routeState.appliedPageFilters,
|
||||
routeState.hasAppliedPageFilters,
|
||||
],
|
||||
);
|
||||
|
||||
const query = useDomainPagesQuery({
|
||||
projectId,
|
||||
domain,
|
||||
includeSubdomains: routeState.subdomains,
|
||||
locationCode: routeState.locationCode,
|
||||
languageCode,
|
||||
page: routeState.page,
|
||||
pageSize: routeState.pageSize,
|
||||
sortMode: routeState.sort,
|
||||
sortOrder: routeState.order,
|
||||
appliedFilters: appliedPagesFilters,
|
||||
enabled: Boolean(domain),
|
||||
});
|
||||
|
||||
const rows = query.data?.pages ?? EMPTY_PAGES_ROWS;
|
||||
const totalCount = query.data?.totalCount ?? null;
|
||||
const hasNextPage = query.data?.hasMore ?? false;
|
||||
const isLoading = query.isFetching;
|
||||
const showTableLoading = isLoading && (showFilters || rows.length === 0);
|
||||
useDomainRenderDebug("PagesTab", {
|
||||
showFilters,
|
||||
isLoading,
|
||||
isPending: query.isPending,
|
||||
rows: rows.length,
|
||||
totalCount,
|
||||
activeTab: routeState.tab,
|
||||
page: routeState.page,
|
||||
sort: routeState.sort,
|
||||
order: routeState.order,
|
||||
});
|
||||
|
||||
const applyFilters = useCallback(
|
||||
(values: PagesFilterValues) => {
|
||||
if (countPageFilterConditions(values) > MAX_DATAFORSEO_FILTER_CONDITIONS)
|
||||
return;
|
||||
const update = buildPagesSearchUpdate(values);
|
||||
debugDomain("PagesTab:apply-filters", { values, update });
|
||||
savePreferredFilters(values);
|
||||
setSearchParams(update);
|
||||
},
|
||||
[savePreferredFilters, setSearchParams],
|
||||
);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
const update = buildPagesClearSearchUpdate();
|
||||
debugDomain("PagesTab:reset-filters", { update });
|
||||
clearPreferredFilters();
|
||||
setSearchParams(update);
|
||||
}, [clearPreferredFilters, setSearchParams]);
|
||||
|
||||
const activeFilterCount = useMemo(
|
||||
() =>
|
||||
PAGE_FILTER_FIELDS.filter((k) => appliedPagesFilters[k].trim() !== "")
|
||||
.length,
|
||||
[appliedPagesFilters],
|
||||
);
|
||||
|
||||
const exportTable = useMemo(() => pagesToTable(rows), [rows]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(JSON.stringify(rows, null, 2));
|
||||
toast.success("Copied data");
|
||||
};
|
||||
const handleExportToSheets = () => {
|
||||
void exportTableToSheets({
|
||||
headers: exportTable.headers,
|
||||
rows: exportTable.rows,
|
||||
feature: "domain_overview",
|
||||
});
|
||||
};
|
||||
const handleDownload = (extension: "csv" | "xls") => {
|
||||
downloadCsv(
|
||||
`${domain}-pages.${extension}`,
|
||||
buildCsv(exportTable.headers, exportTable.rows),
|
||||
);
|
||||
if (extension === "csv") {
|
||||
captureClientEvent("data:export", {
|
||||
source_feature: "domain_overview",
|
||||
result_count: rows.length,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DomainTableTabSurface
|
||||
showFilters={showFilters}
|
||||
onToggleFilters={() => setShowFilters((prev) => !prev)}
|
||||
activeFilterCount={activeFilterCount}
|
||||
countLabel="pages"
|
||||
totalCount={totalCount}
|
||||
fallbackCount={rows.length}
|
||||
isLoading={isLoading}
|
||||
showTableLoading={showTableLoading}
|
||||
exportActions={[
|
||||
{
|
||||
label: "Export to Sheets",
|
||||
icon: <Sheet className="size-4" />,
|
||||
onClick: handleExportToSheets,
|
||||
},
|
||||
{
|
||||
label: "Copy data (JSON)",
|
||||
icon: <Copy className="size-4" />,
|
||||
onClick: handleCopy,
|
||||
},
|
||||
{
|
||||
label: "Download CSV",
|
||||
icon: <Download className="size-4" />,
|
||||
onClick: () => handleDownload("csv"),
|
||||
},
|
||||
{
|
||||
label: "Download Excel",
|
||||
icon: <FileSpreadsheet className="size-4" />,
|
||||
onClick: () => handleDownload("xls"),
|
||||
},
|
||||
]}
|
||||
filterPanel={
|
||||
showFilters ? (
|
||||
<DomainFilterPanel
|
||||
debugName="PagesFilterPanel"
|
||||
activeFilterCount={activeFilterCount}
|
||||
appliedFilters={appliedPagesFilters}
|
||||
fields={PAGE_FILTER_FIELDS}
|
||||
textFields={PAGE_TEXT_FILTERS}
|
||||
rangeFields={PAGE_RANGE_FILTERS}
|
||||
countConditions={countPageFilterConditions}
|
||||
onApply={applyFilters}
|
||||
onClear={resetFilters}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
pagination={
|
||||
<DomainKeywordsPagination
|
||||
page={routeState.page}
|
||||
pageSize={routeState.pageSize}
|
||||
totalCount={totalCount}
|
||||
hasNextPage={hasNextPage}
|
||||
isLoading={isLoading}
|
||||
onPageChange={onPageChange}
|
||||
onPageSizeChange={onPageSizeChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DomainPagesTable
|
||||
domain={domain}
|
||||
rows={rows}
|
||||
sortMode={routeState.sort}
|
||||
currentSortOrder={routeState.order}
|
||||
onSortClick={onSortClick}
|
||||
/>
|
||||
</DomainTableTabSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
15
src/client/features/domain/components/TableLoadingRows.tsx
Normal file
15
src/client/features/domain/components/TableLoadingRows.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
export function TableLoadingRows() {
|
||||
return (
|
||||
<div className="space-y-3 py-4" aria-busy>
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<div key={index} className="grid grid-cols-6 gap-3">
|
||||
<div className="skeleton h-4 col-span-2" />
|
||||
<div className="skeleton h-4" />
|
||||
<div className="skeleton h-4" />
|
||||
<div className="skeleton h-4" />
|
||||
<div className="skeleton h-4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
src/client/features/domain/domainDebug.ts
Normal file
33
src/client/features/domain/domainDebug.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type DebugPayload = Record<string, unknown>;
|
||||
|
||||
function isDomainDebugEnabled() {
|
||||
if (typeof window === "undefined") return false;
|
||||
return (
|
||||
window.localStorage.getItem("debug:domain-overview") === "1" ||
|
||||
new URLSearchParams(window.location.search).get("debugDomain") === "1"
|
||||
);
|
||||
}
|
||||
|
||||
export function debugDomain(event: string, payload?: DebugPayload) {
|
||||
if (!isDomainDebugEnabled()) return;
|
||||
const entry = {
|
||||
event,
|
||||
t: Math.round(performance.now()),
|
||||
...payload,
|
||||
};
|
||||
console.info("[domain-debug]", JSON.stringify(entry));
|
||||
}
|
||||
|
||||
export function useDomainRenderDebug(name: string, payload?: DebugPayload) {
|
||||
const countRef = useRef(0);
|
||||
countRef.current += 1;
|
||||
|
||||
useEffect(() => {
|
||||
debugDomain(`${name}:render`, {
|
||||
count: countRef.current,
|
||||
...payload,
|
||||
});
|
||||
});
|
||||
}
|
||||
156
src/client/features/domain/domainFilterUtils.ts
Normal file
156
src/client/features/domain/domainFilterUtils.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import type {
|
||||
KeywordsFilterValues,
|
||||
PageFilterKey,
|
||||
PagesFilterValues,
|
||||
} from "@/client/features/domain/types";
|
||||
import type { DomainSearchParams } from "@/types/schemas/domain";
|
||||
|
||||
export const KEYWORD_FILTER_FIELDS = [
|
||||
"include",
|
||||
"exclude",
|
||||
"minTraffic",
|
||||
"maxTraffic",
|
||||
"minVol",
|
||||
"maxVol",
|
||||
"minCpc",
|
||||
"maxCpc",
|
||||
"minKd",
|
||||
"maxKd",
|
||||
"minRank",
|
||||
"maxRank",
|
||||
] as const satisfies ReadonlyArray<keyof KeywordsFilterValues>;
|
||||
|
||||
export const PAGE_FILTER_FIELDS = [
|
||||
"include",
|
||||
"exclude",
|
||||
"minTraffic",
|
||||
"maxTraffic",
|
||||
"minVol",
|
||||
"maxVol",
|
||||
] as const satisfies ReadonlyArray<keyof PagesFilterValues>;
|
||||
|
||||
const PAGE_SEARCH_PARAM_BY_FIELD = {
|
||||
include: "pInclude",
|
||||
exclude: "pExclude",
|
||||
minTraffic: "pMinTraffic",
|
||||
maxTraffic: "pMaxTraffic",
|
||||
minVol: "pMinVol",
|
||||
maxVol: "pMaxVol",
|
||||
} as const satisfies Record<PageFilterKey, keyof DomainSearchParams>;
|
||||
|
||||
type SearchUpdate = Partial<DomainSearchParams>;
|
||||
type FilterValues = Record<string, string>;
|
||||
type FilterKey<TValues extends FilterValues> = Extract<keyof TValues, string>;
|
||||
|
||||
export function getPageFilterSearchParam(
|
||||
key: PageFilterKey,
|
||||
): (typeof PAGE_SEARCH_PARAM_BY_FIELD)[PageFilterKey] {
|
||||
return PAGE_SEARCH_PARAM_BY_FIELD[key];
|
||||
}
|
||||
|
||||
export function countKeywordFilterConditions(
|
||||
values: KeywordsFilterValues,
|
||||
): number {
|
||||
let n = 0;
|
||||
for (const term of values.include.split(/[,+]/)) if (term.trim()) n += 1;
|
||||
for (const term of values.exclude.split(/[,+]/)) if (term.trim()) n += 1;
|
||||
for (const key of KEYWORD_FILTER_FIELDS) {
|
||||
if (key === "include" || key === "exclude") continue;
|
||||
if (values[key].trim() !== "") n += 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export function countPageFilterConditions(values: PagesFilterValues): number {
|
||||
return countFilterConditions(values, PAGE_FILTER_FIELDS);
|
||||
}
|
||||
|
||||
export function buildKeywordsSearchUpdate(
|
||||
values: KeywordsFilterValues,
|
||||
): SearchUpdate {
|
||||
return buildFilterSearchUpdate<KeywordsFilterValues>(
|
||||
values,
|
||||
KEYWORD_FILTER_FIELDS,
|
||||
(key) => key,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPagesSearchUpdate(
|
||||
values: PagesFilterValues,
|
||||
): SearchUpdate {
|
||||
return buildFilterSearchUpdate<PagesFilterValues>(
|
||||
values,
|
||||
PAGE_FILTER_FIELDS,
|
||||
(key) => getPageFilterSearchParam(key),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPagesClearSearchUpdate(): SearchUpdate {
|
||||
return buildFilterClearSearchUpdate<PagesFilterValues>(
|
||||
PAGE_FILTER_FIELDS,
|
||||
(key) => getPageFilterSearchParam(key),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildDomainFiltersClearSearchUpdate(): SearchUpdate {
|
||||
const update = buildFilterClearSearchUpdate<KeywordsFilterValues>(
|
||||
KEYWORD_FILTER_FIELDS,
|
||||
(key) => key,
|
||||
);
|
||||
Object.assign(
|
||||
update,
|
||||
buildFilterClearSearchUpdate<PagesFilterValues>(PAGE_FILTER_FIELDS, (key) =>
|
||||
getPageFilterSearchParam(key),
|
||||
),
|
||||
);
|
||||
return update;
|
||||
}
|
||||
|
||||
function countFilterConditions<TValues extends Record<string, string>>(
|
||||
values: TValues,
|
||||
fields: ReadonlyArray<FilterKey<TValues>>,
|
||||
): number {
|
||||
let n = 0;
|
||||
for (const term of values.include.split(/[,+]/)) if (term.trim()) n += 1;
|
||||
for (const term of values.exclude.split(/[,+]/)) if (term.trim()) n += 1;
|
||||
for (const key of fields) {
|
||||
if (key === "include" || key === "exclude") continue;
|
||||
if (values[key].trim() !== "") n += 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function buildFilterSearchUpdate<TValues extends FilterValues>(
|
||||
values: TValues,
|
||||
fields: ReadonlyArray<FilterKey<TValues>>,
|
||||
getParam: (key: FilterKey<TValues>) => keyof DomainSearchParams,
|
||||
): SearchUpdate {
|
||||
const update: SearchUpdate = { page: undefined };
|
||||
for (const key of fields) {
|
||||
const param = getParam(key);
|
||||
const raw = values[key].trim();
|
||||
if (raw === "") {
|
||||
Object.assign(update, { [param]: undefined });
|
||||
continue;
|
||||
}
|
||||
if (key === "include" || key === "exclude") {
|
||||
Object.assign(update, { [param]: raw });
|
||||
continue;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
Object.assign(update, {
|
||||
[param]: Number.isFinite(parsed) ? parsed : undefined,
|
||||
});
|
||||
}
|
||||
return update;
|
||||
}
|
||||
|
||||
function buildFilterClearSearchUpdate<TValues extends FilterValues>(
|
||||
fields: ReadonlyArray<FilterKey<TValues>>,
|
||||
getParam: (key: FilterKey<TValues>) => keyof DomainSearchParams,
|
||||
): SearchUpdate {
|
||||
const update: SearchUpdate = { page: undefined };
|
||||
for (const key of fields)
|
||||
Object.assign(update, { [getParam(key)]: undefined });
|
||||
return update;
|
||||
}
|
||||
@ -1,174 +0,0 @@
|
||||
import { useEffect, useMemo, type Dispatch, type SetStateAction } from "react";
|
||||
import type { UpdateMetaOptions } from "@tanstack/react-form";
|
||||
import {
|
||||
getDefaultSortOrder,
|
||||
toSortMode,
|
||||
toSortOrder,
|
||||
} from "@/client/features/domain/utils";
|
||||
import type {
|
||||
DomainActiveTab,
|
||||
DomainFilterValues,
|
||||
DomainSortMode,
|
||||
KeywordRow,
|
||||
SortOrder,
|
||||
} from "@/client/features/domain/types";
|
||||
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
|
||||
|
||||
export type SearchState = {
|
||||
domain: string;
|
||||
subdomains: boolean;
|
||||
sort: DomainSortMode;
|
||||
order?: SortOrder;
|
||||
tab: DomainActiveTab;
|
||||
search: string;
|
||||
locationCode: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
appliedFilters: DomainFilterValues;
|
||||
};
|
||||
|
||||
type DomainNavigate = (args: {
|
||||
search: (prev: Record<string, unknown>) => Record<string, unknown>;
|
||||
replace: boolean;
|
||||
}) => void;
|
||||
|
||||
type DomainControlsFormAccess = {
|
||||
state: {
|
||||
values: {
|
||||
domain: string;
|
||||
subdomains: boolean;
|
||||
sort: DomainSortMode;
|
||||
locationCode: number;
|
||||
};
|
||||
};
|
||||
reset: (values: {
|
||||
domain: string;
|
||||
subdomains: boolean;
|
||||
sort: DomainSortMode;
|
||||
locationCode: number;
|
||||
}) => void;
|
||||
setFieldValue: (
|
||||
field: "domain" | "subdomains" | "sort" | "locationCode",
|
||||
updater: string | boolean | number,
|
||||
opts?: UpdateMetaOptions,
|
||||
) => void;
|
||||
};
|
||||
|
||||
type ControlsFormLike = DomainControlsFormAccess;
|
||||
|
||||
export function useOverviewDataState({
|
||||
pagedKeywords,
|
||||
setSelectedKeywords,
|
||||
activeFilterCount,
|
||||
}: {
|
||||
pagedKeywords: KeywordRow[];
|
||||
setSelectedKeywords: Dispatch<SetStateAction<Set<string>>>;
|
||||
activeFilterCount: number;
|
||||
}) {
|
||||
// Keywords are now fetched server-side with filters/sort/pagination applied,
|
||||
// so we render whatever the page query returned.
|
||||
const filteredKeywords = pagedKeywords;
|
||||
|
||||
const visibleKeywords = useMemo(
|
||||
() => filteredKeywords.map((row) => row.keyword),
|
||||
[filteredKeywords],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const visibleSet = new Set(visibleKeywords);
|
||||
setSelectedKeywords((prev) => {
|
||||
const next = new Set(
|
||||
[...prev].filter((keyword) => visibleSet.has(keyword)),
|
||||
);
|
||||
if (next.size === prev.size) return prev;
|
||||
return next;
|
||||
});
|
||||
}, [setSelectedKeywords, visibleKeywords]);
|
||||
|
||||
return {
|
||||
filteredKeywords,
|
||||
visibleKeywords,
|
||||
activeFilterCount,
|
||||
toggleKeywordSelection: (keyword: string) => {
|
||||
setSelectedKeywords((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(keyword)) next.delete(keyword);
|
||||
else next.add(keyword);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
toggleAllVisibleKeywords: () => {
|
||||
setSelectedKeywords((prev) => {
|
||||
if (
|
||||
visibleKeywords.length > 0 &&
|
||||
visibleKeywords.every((keyword) => prev.has(keyword))
|
||||
) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return new Set(visibleKeywords);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useSyncRouteState({
|
||||
controlsForm,
|
||||
searchState,
|
||||
navigate,
|
||||
}: {
|
||||
controlsForm: ControlsFormLike;
|
||||
searchState: SearchState;
|
||||
navigate: DomainNavigate;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
controlsForm.reset({
|
||||
domain: searchState.domain,
|
||||
subdomains: searchState.subdomains,
|
||||
sort: searchState.sort,
|
||||
locationCode: searchState.locationCode,
|
||||
});
|
||||
}, [controlsForm, searchState]);
|
||||
|
||||
useEffect(() => {
|
||||
const raw = new URLSearchParams(window.location.search);
|
||||
const rawSort = toSortMode(raw.get("sort"));
|
||||
const rawOrder = toSortOrder(raw.get("order"));
|
||||
const rawLoc = raw.get("loc");
|
||||
const shouldNormalize =
|
||||
raw.get("domain") === "" ||
|
||||
raw.get("search") === "" ||
|
||||
raw.get("subdomains") === "true" ||
|
||||
raw.get("sort") === "rank" ||
|
||||
(rawOrder != null &&
|
||||
rawOrder === getDefaultSortOrder(rawSort ?? "rank")) ||
|
||||
raw.get("tab") === "keywords" ||
|
||||
rawLoc === String(DEFAULT_LOCATION_CODE);
|
||||
if (!shouldNormalize) return;
|
||||
|
||||
navigate({
|
||||
search: (prev) => {
|
||||
const prevSort =
|
||||
typeof prev.sort === "string" ? toSortMode(prev.sort) : undefined;
|
||||
return {
|
||||
...prev,
|
||||
domain: prev.domain === "" ? undefined : prev.domain,
|
||||
search: prev.search === "" ? undefined : prev.search,
|
||||
subdomains: prev.subdomains === true ? undefined : prev.subdomains,
|
||||
sort: prev.sort === "rank" ? undefined : prev.sort,
|
||||
order:
|
||||
prev.order != null &&
|
||||
prev.order === getDefaultSortOrder(prevSort ?? "rank")
|
||||
? undefined
|
||||
: prev.order,
|
||||
tab: prev.tab === "keywords" ? undefined : prev.tab,
|
||||
loc:
|
||||
prev.loc != null && Number(prev.loc) === DEFAULT_LOCATION_CODE
|
||||
? undefined
|
||||
: prev.loc,
|
||||
};
|
||||
},
|
||||
replace: true,
|
||||
});
|
||||
}, [navigate]);
|
||||
}
|
||||
100
src/client/features/domain/domainRouteState.ts
Normal file
100
src/client/features/domain/domainRouteState.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import {
|
||||
DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE,
|
||||
type DomainSearchParams,
|
||||
} from "@/types/schemas/domain";
|
||||
import {
|
||||
DEFAULT_LOCATION_CODE,
|
||||
isSupportedLocationCode,
|
||||
} from "@/client/features/keywords/locations";
|
||||
import {
|
||||
EMPTY_DOMAIN_FILTERS,
|
||||
type DomainActiveTab,
|
||||
type DomainFilterValues,
|
||||
type DomainSortMode,
|
||||
type KeywordsFilterValues,
|
||||
type PagesFilterValues,
|
||||
type SortOrder,
|
||||
} from "@/client/features/domain/types";
|
||||
import {
|
||||
KEYWORD_FILTER_FIELDS,
|
||||
PAGE_FILTER_FIELDS,
|
||||
getPageFilterSearchParam,
|
||||
} from "@/client/features/domain/domainFilterUtils";
|
||||
import { resolveSortOrder, toSortMode, toSortOrder } from "./utils";
|
||||
|
||||
export type DomainOverviewRouteState = {
|
||||
domain: string;
|
||||
subdomains: boolean;
|
||||
sort: DomainSortMode;
|
||||
order: SortOrder;
|
||||
tab: DomainActiveTab;
|
||||
locationCode: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
appliedFilters: DomainFilterValues;
|
||||
appliedPageFilters: PagesFilterValues;
|
||||
hasAppliedKeywordFilters: boolean;
|
||||
hasAppliedPageFilters: boolean;
|
||||
};
|
||||
|
||||
function numberToFilterString(value: number | undefined): string {
|
||||
if (value == null || !Number.isFinite(value)) return "";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function getDomainRouteState(
|
||||
search: DomainSearchParams,
|
||||
): DomainOverviewRouteState {
|
||||
const normalizedSort = toSortMode(search.sort ?? null) ?? "rank";
|
||||
const normalizedLocationCode =
|
||||
search.loc != null && isSupportedLocationCode(search.loc)
|
||||
? search.loc
|
||||
: DEFAULT_LOCATION_CODE;
|
||||
|
||||
return {
|
||||
domain: search.domain ?? "",
|
||||
subdomains: search.subdomains ?? true,
|
||||
sort: normalizedSort,
|
||||
order: resolveSortOrder(normalizedSort, toSortOrder(search.order ?? null)),
|
||||
tab: search.tab ?? "keywords",
|
||||
locationCode: normalizedLocationCode,
|
||||
page: search.page != null && search.page > 0 ? search.page : 1,
|
||||
pageSize: search.size ?? DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE,
|
||||
appliedFilters: {
|
||||
include: search.include ?? EMPTY_DOMAIN_FILTERS.include,
|
||||
exclude: search.exclude ?? EMPTY_DOMAIN_FILTERS.exclude,
|
||||
minTraffic: numberToFilterString(search.minTraffic),
|
||||
maxTraffic: numberToFilterString(search.maxTraffic),
|
||||
minVol: numberToFilterString(search.minVol),
|
||||
maxVol: numberToFilterString(search.maxVol),
|
||||
minCpc: numberToFilterString(search.minCpc),
|
||||
maxCpc: numberToFilterString(search.maxCpc),
|
||||
minKd: numberToFilterString(search.minKd),
|
||||
maxKd: numberToFilterString(search.maxKd),
|
||||
minRank: numberToFilterString(search.minRank),
|
||||
maxRank: numberToFilterString(search.maxRank),
|
||||
},
|
||||
appliedPageFilters: {
|
||||
include: search.pInclude ?? EMPTY_DOMAIN_FILTERS.include,
|
||||
exclude: search.pExclude ?? EMPTY_DOMAIN_FILTERS.exclude,
|
||||
minTraffic: numberToFilterString(search.pMinTraffic),
|
||||
maxTraffic: numberToFilterString(search.pMaxTraffic),
|
||||
minVol: numberToFilterString(search.pMinVol),
|
||||
maxVol: numberToFilterString(search.pMaxVol),
|
||||
},
|
||||
hasAppliedKeywordFilters: hasKeywordSearchFilters(search),
|
||||
hasAppliedPageFilters: hasPageSearchFilters(search),
|
||||
};
|
||||
}
|
||||
|
||||
function hasKeywordSearchFilters(search: DomainSearchParams): boolean {
|
||||
return KEYWORD_FILTER_FIELDS.some(
|
||||
(key: keyof KeywordsFilterValues) => search[key] != null,
|
||||
);
|
||||
}
|
||||
|
||||
function hasPageSearchFilters(search: DomainSearchParams): boolean {
|
||||
return PAGE_FILTER_FIELDS.some(
|
||||
(key) => search[getPageFilterSearchParam(key)] != null,
|
||||
);
|
||||
}
|
||||
@ -1,167 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useForm, useStore } from "@tanstack/react-form";
|
||||
import {
|
||||
EMPTY_DOMAIN_FILTERS,
|
||||
type DomainFilterValues,
|
||||
} from "@/client/features/domain/types";
|
||||
import { MAX_DATAFORSEO_FILTER_CONDITIONS } from "@/types/schemas/domain";
|
||||
|
||||
const FILTER_KEYS: Array<keyof DomainFilterValues> = [
|
||||
"include",
|
||||
"exclude",
|
||||
"minTraffic",
|
||||
"maxTraffic",
|
||||
"minVol",
|
||||
"maxVol",
|
||||
"minCpc",
|
||||
"maxCpc",
|
||||
"minKd",
|
||||
"maxKd",
|
||||
"minRank",
|
||||
"maxRank",
|
||||
];
|
||||
|
||||
function filtersToSearchParams(
|
||||
values: DomainFilterValues,
|
||||
): Record<string, string | number | undefined> {
|
||||
const out: Record<string, string | number | undefined> = {};
|
||||
for (const key of FILTER_KEYS) {
|
||||
const trimmed = values[key].trim();
|
||||
if (trimmed === "") {
|
||||
out[key] = undefined;
|
||||
continue;
|
||||
}
|
||||
if (key === "include" || key === "exclude") {
|
||||
out[key] = trimmed;
|
||||
} else {
|
||||
const parsed = Number(trimmed);
|
||||
out[key] = Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* One include/exclude term per comma. Numeric ranges count as one per bound.
|
||||
* Mirrors how `buildKeywordFilters` packs the request, plus 2 extra slots
|
||||
* reserved for the search-box OR-clause when active.
|
||||
*/
|
||||
function countConditions(
|
||||
values: DomainFilterValues,
|
||||
hasSearch: boolean,
|
||||
): number {
|
||||
let n = 0;
|
||||
for (const term of values.include.split(/[,+]/)) if (term.trim()) n += 1;
|
||||
for (const term of values.exclude.split(/[,+]/)) if (term.trim()) n += 1;
|
||||
for (const k of [
|
||||
"minTraffic",
|
||||
"maxTraffic",
|
||||
"minVol",
|
||||
"maxVol",
|
||||
"minCpc",
|
||||
"maxCpc",
|
||||
"minKd",
|
||||
"maxKd",
|
||||
"minRank",
|
||||
"maxRank",
|
||||
] as const) {
|
||||
if (values[k].trim() !== "") n += 1;
|
||||
}
|
||||
if (hasSearch) n += 2;
|
||||
return n;
|
||||
}
|
||||
|
||||
export function useDomainFilters({
|
||||
appliedValues,
|
||||
appliedSearch,
|
||||
setSearchParams,
|
||||
}: {
|
||||
appliedValues: DomainFilterValues;
|
||||
appliedSearch: string;
|
||||
setSearchParams: (
|
||||
updates: Record<string, string | number | boolean | undefined>,
|
||||
) => void;
|
||||
}) {
|
||||
const filtersForm = useForm({
|
||||
defaultValues: appliedValues,
|
||||
});
|
||||
|
||||
const draftValues = useStore(filtersForm.store, (s) => s.values);
|
||||
const [searchDraft, setSearchDraft] = useState(appliedSearch);
|
||||
|
||||
// Keep the draft in sync when applied values change from outside the panel
|
||||
// (URL navigation, history-select, "back to recent searches"). Without this,
|
||||
// the form keeps the previous draft and diverges from the URL.
|
||||
const appliedKey = useMemo(
|
||||
() => FILTER_KEYS.map((key) => appliedValues[key]).join("|"),
|
||||
[appliedValues],
|
||||
);
|
||||
useEffect(() => {
|
||||
filtersForm.reset({ ...appliedValues });
|
||||
// appliedKey covers content changes; filtersForm is a stable ref.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appliedKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchDraft(appliedSearch);
|
||||
}, [appliedSearch]);
|
||||
|
||||
const applyFilters = useCallback(() => {
|
||||
const trimmedSearch = searchDraft.trim();
|
||||
setSearchParams({
|
||||
...filtersToSearchParams(draftValues),
|
||||
search: trimmedSearch === "" ? undefined : trimmedSearch,
|
||||
page: undefined,
|
||||
});
|
||||
}, [draftValues, searchDraft, setSearchParams]);
|
||||
|
||||
const cancelEdits = useCallback(() => {
|
||||
filtersForm.reset({ ...appliedValues }, { keepDefaultValues: true });
|
||||
setSearchDraft(appliedSearch);
|
||||
}, [appliedValues, appliedSearch, filtersForm]);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
filtersForm.reset({ ...EMPTY_DOMAIN_FILTERS }, { keepDefaultValues: true });
|
||||
setSearchDraft("");
|
||||
setSearchParams({
|
||||
...filtersToSearchParams(EMPTY_DOMAIN_FILTERS),
|
||||
search: undefined,
|
||||
page: undefined,
|
||||
});
|
||||
}, [filtersForm, setSearchParams]);
|
||||
|
||||
const activeAppliedCount = useMemo(
|
||||
() =>
|
||||
FILTER_KEYS.filter((key) => appliedValues[key].trim() !== "").length +
|
||||
(appliedSearch.trim() !== "" ? 1 : 0),
|
||||
[appliedValues, appliedSearch],
|
||||
);
|
||||
const dirtyCount = useMemo(() => {
|
||||
const filterDirt = FILTER_KEYS.filter(
|
||||
(key) => draftValues[key].trim() !== appliedValues[key].trim(),
|
||||
).length;
|
||||
const searchDirt = searchDraft.trim() !== appliedSearch.trim() ? 1 : 0;
|
||||
return filterDirt + searchDirt;
|
||||
}, [draftValues, appliedValues, searchDraft, appliedSearch]);
|
||||
|
||||
const conditionCount = countConditions(
|
||||
draftValues,
|
||||
searchDraft.trim() !== "",
|
||||
);
|
||||
const overLimit = conditionCount > MAX_DATAFORSEO_FILTER_CONDITIONS;
|
||||
|
||||
return {
|
||||
filtersForm,
|
||||
draftValues,
|
||||
appliedValues,
|
||||
searchDraft,
|
||||
setSearchDraft,
|
||||
activeAppliedCount,
|
||||
dirtyCount,
|
||||
conditionCount,
|
||||
overLimit,
|
||||
applyFilters,
|
||||
cancelEdits,
|
||||
resetFilters,
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1,138 +0,0 @@
|
||||
import { useCallback, type FormEvent } from "react";
|
||||
import {
|
||||
getDefaultSortOrder,
|
||||
toSortOrderSearchParam,
|
||||
toSortSearchParam,
|
||||
} from "@/client/features/domain/utils";
|
||||
import type {
|
||||
DomainControlsValues,
|
||||
DomainSortMode,
|
||||
SortOrder,
|
||||
} from "@/client/features/domain/types";
|
||||
import { saveSelectedKeywords } from "@/client/features/domain/domainActions";
|
||||
import type { useSaveKeywordsMutation } from "@/client/features/domain/mutations";
|
||||
import type {
|
||||
SearchState,
|
||||
useOverviewDataState,
|
||||
} from "@/client/features/domain/domainOverviewControllerInternals";
|
||||
import type { DomainSearchHistoryItem } from "@/client/hooks/useDomainSearchHistory";
|
||||
import {
|
||||
DEFAULT_LOCATION_CODE,
|
||||
getLanguageCode,
|
||||
isSupportedLocationCode,
|
||||
} from "@/client/features/keywords/locations";
|
||||
|
||||
type DomainControlsFormApi = {
|
||||
state: { values: DomainControlsValues };
|
||||
handleSubmit: () => Promise<unknown>;
|
||||
reset: (values: DomainControlsValues) => void;
|
||||
setFieldValue: (
|
||||
field: keyof DomainControlsValues,
|
||||
value: string | boolean | number,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function useDomainControllerHandlers({
|
||||
controlsForm,
|
||||
currentSortOrder,
|
||||
currentState,
|
||||
dataState,
|
||||
projectId,
|
||||
saveMutation,
|
||||
selectedKeywords,
|
||||
setSearchParams,
|
||||
}: {
|
||||
controlsForm: DomainControlsFormApi;
|
||||
currentSortOrder: SortOrder;
|
||||
currentState: SearchState;
|
||||
dataState: ReturnType<typeof useOverviewDataState>;
|
||||
projectId: string;
|
||||
saveMutation: ReturnType<typeof useSaveKeywordsMutation>;
|
||||
selectedKeywords: Set<string>;
|
||||
setSearchParams: (
|
||||
updates: Record<string, string | number | boolean | undefined>,
|
||||
) => void;
|
||||
}) {
|
||||
const applySort = useCallback(
|
||||
(nextSort: DomainSortMode, nextOrder: SortOrder) => {
|
||||
controlsForm.setFieldValue("sort", nextSort);
|
||||
setSearchParams({
|
||||
sort: toSortSearchParam(nextSort),
|
||||
order: toSortOrderSearchParam(nextSort, nextOrder),
|
||||
page: undefined,
|
||||
});
|
||||
},
|
||||
[controlsForm, setSearchParams],
|
||||
);
|
||||
|
||||
const applyLocationChange = useCallback(
|
||||
(nextLocationCode: number) => {
|
||||
if (!isSupportedLocationCode(nextLocationCode)) return;
|
||||
controlsForm.setFieldValue("locationCode", nextLocationCode);
|
||||
setSearchParams({
|
||||
loc:
|
||||
nextLocationCode === DEFAULT_LOCATION_CODE
|
||||
? undefined
|
||||
: nextLocationCode,
|
||||
});
|
||||
},
|
||||
[controlsForm, setSearchParams],
|
||||
);
|
||||
|
||||
const handleSortColumnClick = useCallback(
|
||||
(nextSort: DomainSortMode) => {
|
||||
const nextOrder =
|
||||
nextSort === currentState.sort
|
||||
? currentSortOrder === "asc"
|
||||
? "desc"
|
||||
: "asc"
|
||||
: getDefaultSortOrder(nextSort);
|
||||
applySort(nextSort, nextOrder);
|
||||
},
|
||||
[applySort, currentSortOrder, currentState.sort],
|
||||
);
|
||||
|
||||
const handleSaveKeywords = () => {
|
||||
saveSelectedKeywords({
|
||||
selectedKeywords,
|
||||
filteredKeywords: dataState.filteredKeywords,
|
||||
save: saveMutation.mutate,
|
||||
projectId,
|
||||
locationCode: currentState.locationCode,
|
||||
languageCode: getLanguageCode(currentState.locationCode),
|
||||
});
|
||||
};
|
||||
|
||||
const handleHistorySelect = (item: DomainSearchHistoryItem) => {
|
||||
const historyLocation =
|
||||
item.locationCode != null && isSupportedLocationCode(item.locationCode)
|
||||
? item.locationCode
|
||||
: DEFAULT_LOCATION_CODE;
|
||||
setSearchParams({
|
||||
domain: item.domain,
|
||||
subdomains: item.subdomains ? undefined : false,
|
||||
sort: toSortSearchParam(item.sort),
|
||||
order: undefined,
|
||||
tab: item.tab === "keywords" ? undefined : item.tab,
|
||||
search: item.search?.trim() || undefined,
|
||||
loc:
|
||||
historyLocation === DEFAULT_LOCATION_CODE ? undefined : historyLocation,
|
||||
page: undefined,
|
||||
size: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearchSubmit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
void controlsForm.handleSubmit();
|
||||
};
|
||||
|
||||
return {
|
||||
applySort,
|
||||
applyLocationChange,
|
||||
handleSortColumnClick,
|
||||
handleSaveKeywords,
|
||||
handleSearchSubmit,
|
||||
handleHistorySelect,
|
||||
};
|
||||
}
|
||||
157
src/client/features/domain/useDomainFilterPreferences.ts
Normal file
157
src/client/features/domain/useDomainFilterPreferences.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
EMPTY_DOMAIN_FILTERS,
|
||||
type KeywordsFilterValues,
|
||||
type PagesFilterValues,
|
||||
} from "@/client/features/domain/types";
|
||||
import {
|
||||
KEYWORD_FILTER_FIELDS,
|
||||
PAGE_FILTER_FIELDS,
|
||||
} from "@/client/features/domain/domainFilterUtils";
|
||||
|
||||
const STORAGE_KEY_PREFIX = "domain-overview-filter-defaults:";
|
||||
|
||||
type DomainFilterPreferenceTab = "keywords" | "pages";
|
||||
type FilterValues = Record<string, string>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function hasAnyFilter(values: FilterValues): boolean {
|
||||
return Object.values(values).some((value) => value.trim() !== "");
|
||||
}
|
||||
|
||||
function getStorageKey(tab: DomainFilterPreferenceTab, scope: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}${scope}:${tab}`;
|
||||
}
|
||||
|
||||
function loadFromStorage<T extends FilterValues>(
|
||||
tab: DomainFilterPreferenceTab,
|
||||
scope: string,
|
||||
fallback: T,
|
||||
): T {
|
||||
const result = { ...fallback };
|
||||
if (typeof window === "undefined") return result;
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(getStorageKey(tab, scope));
|
||||
if (!raw) return result;
|
||||
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!isRecord(parsed)) return result;
|
||||
|
||||
for (const key in fallback) {
|
||||
const value = parsed[key];
|
||||
if (typeof value === "string") {
|
||||
Object.assign(result, { [key]: value });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// localStorage can be unavailable in private browsing or strict modes.
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function saveToStorage(
|
||||
tab: DomainFilterPreferenceTab,
|
||||
scope: string,
|
||||
values: FilterValues,
|
||||
) {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
const key = getStorageKey(tab, scope);
|
||||
if (hasAnyFilter(values)) {
|
||||
window.localStorage.setItem(key, JSON.stringify(values));
|
||||
} else {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
} catch {
|
||||
// storage full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function clearStorage(tab: DomainFilterPreferenceTab, scope: string) {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
window.localStorage.removeItem(getStorageKey(tab, scope));
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function emptyPageFilters(): PagesFilterValues {
|
||||
return {
|
||||
include: EMPTY_DOMAIN_FILTERS.include,
|
||||
exclude: EMPTY_DOMAIN_FILTERS.exclude,
|
||||
minTraffic: EMPTY_DOMAIN_FILTERS.minTraffic,
|
||||
maxTraffic: EMPTY_DOMAIN_FILTERS.maxTraffic,
|
||||
minVol: EMPTY_DOMAIN_FILTERS.minVol,
|
||||
maxVol: EMPTY_DOMAIN_FILTERS.maxVol,
|
||||
};
|
||||
}
|
||||
|
||||
function loadKeywordFilters(scope: string): KeywordsFilterValues {
|
||||
return loadFromStorage("keywords", scope, { ...EMPTY_DOMAIN_FILTERS });
|
||||
}
|
||||
|
||||
function loadPageFilters(scope: string): PagesFilterValues {
|
||||
return loadFromStorage("pages", scope, emptyPageFilters());
|
||||
}
|
||||
|
||||
export function useDomainKeywordFilterPreferences(scope: string) {
|
||||
const [filters, setFilters] = useState<KeywordsFilterValues>(() =>
|
||||
loadKeywordFilters(scope),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFilters(loadKeywordFilters(scope));
|
||||
}, [scope]);
|
||||
|
||||
const save = useCallback(
|
||||
(values: KeywordsFilterValues) => {
|
||||
const next = { ...EMPTY_DOMAIN_FILTERS };
|
||||
for (const key of KEYWORD_FILTER_FIELDS) next[key] = values[key];
|
||||
saveToStorage("keywords", scope, next);
|
||||
setFilters(next);
|
||||
},
|
||||
[scope],
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
clearStorage("keywords", scope);
|
||||
setFilters({ ...EMPTY_DOMAIN_FILTERS });
|
||||
}, [scope]);
|
||||
|
||||
return { filters, save, clear };
|
||||
}
|
||||
|
||||
export function useDomainPageFilterPreferences(scope: string) {
|
||||
const [filters, setFilters] = useState<PagesFilterValues>(() =>
|
||||
loadPageFilters(scope),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFilters(loadPageFilters(scope));
|
||||
}, [scope]);
|
||||
|
||||
const save = useCallback(
|
||||
(values: PagesFilterValues) => {
|
||||
const next = emptyPageFilters();
|
||||
for (const key of PAGE_FILTER_FIELDS) next[key] = values[key];
|
||||
saveToStorage("pages", scope, next);
|
||||
setFilters(next);
|
||||
},
|
||||
[scope],
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
clearStorage("pages", scope);
|
||||
setFilters(emptyPageFilters());
|
||||
}, [scope]);
|
||||
|
||||
return { filters, save, clear };
|
||||
}
|
||||
@ -1,329 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { type QueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useDomainSearchHistory } from "@/client/hooks/useDomainSearchHistory";
|
||||
import {
|
||||
normalizeDomainTarget,
|
||||
resolveSortOrder,
|
||||
toSortOrderSearchParam,
|
||||
toSortSearchParam,
|
||||
} from "@/client/features/domain/utils";
|
||||
import {
|
||||
createFormValidationErrors,
|
||||
shouldValidateFieldOnChange,
|
||||
} from "@/client/lib/forms";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import type { KeywordRow, PageRow } from "@/client/features/domain/types";
|
||||
import { useSaveKeywordsMutation } from "@/client/features/domain/mutations";
|
||||
import { useDomainFilters } from "@/client/features/domain/hooks/useDomainFilters";
|
||||
import { useDomainKeywordsQuery } from "@/client/features/domain/hooks/useDomainKeywordsQuery";
|
||||
import { useDomainOverviewQuery } from "@/client/features/domain/hooks/useDomainOverviewQuery";
|
||||
import { useDomainPagesQuery } from "@/client/features/domain/hooks/useDomainPagesQuery";
|
||||
import {
|
||||
getDomainSearchChangeValidationErrors,
|
||||
getDomainSearchValidationErrors,
|
||||
} from "@/client/features/domain/domainSearchValidation";
|
||||
import { useDomainControllerHandlers } from "@/client/features/domain/useDomainControllerHandlers";
|
||||
import {
|
||||
useOverviewDataState,
|
||||
useSyncRouteState,
|
||||
type SearchState,
|
||||
} from "@/client/features/domain/domainOverviewControllerInternals";
|
||||
import {
|
||||
DEFAULT_LOCATION_CODE,
|
||||
getLanguageCode,
|
||||
} from "@/client/features/keywords/locations";
|
||||
import { DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE } from "@/types/schemas/domain";
|
||||
|
||||
type Params = {
|
||||
projectId: string;
|
||||
queryClient: QueryClient;
|
||||
navigate: (args: {
|
||||
search: (prev: Record<string, unknown>) => Record<string, unknown>;
|
||||
replace: boolean;
|
||||
}) => void;
|
||||
searchState: SearchState;
|
||||
};
|
||||
|
||||
export function useDomainOverviewController({
|
||||
projectId,
|
||||
queryClient,
|
||||
navigate,
|
||||
searchState,
|
||||
}: Params) {
|
||||
const [selectedKeywords, setSelectedKeywords] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
const {
|
||||
history,
|
||||
isLoaded: historyLoaded,
|
||||
addSearch,
|
||||
removeHistoryItem,
|
||||
} = useDomainSearchHistory(projectId);
|
||||
|
||||
const currentSortOrder = resolveSortOrder(
|
||||
searchState.sort,
|
||||
searchState.order,
|
||||
);
|
||||
const setSearchParams = useCallback(
|
||||
(updates: Record<string, string | number | boolean | undefined>) => {
|
||||
navigate({
|
||||
search: (prev) => ({ ...prev, ...updates }),
|
||||
replace: true,
|
||||
});
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const domainFilters = useDomainFilters({
|
||||
appliedValues: searchState.appliedFilters,
|
||||
appliedSearch: searchState.search,
|
||||
setSearchParams,
|
||||
});
|
||||
|
||||
const overviewLanguageCode = getLanguageCode(searchState.locationCode);
|
||||
const overviewQuery = useDomainOverviewQuery({
|
||||
projectId,
|
||||
domain: searchState.domain,
|
||||
includeSubdomains: searchState.subdomains,
|
||||
locationCode: searchState.locationCode,
|
||||
languageCode: overviewLanguageCode,
|
||||
});
|
||||
const overview = overviewQuery.data ?? null;
|
||||
|
||||
const controlsForm = useForm({
|
||||
defaultValues: {
|
||||
domain: searchState.domain,
|
||||
subdomains: searchState.subdomains,
|
||||
sort: searchState.sort,
|
||||
locationCode: searchState.locationCode,
|
||||
},
|
||||
validators: {
|
||||
onChange: ({ formApi, value }) =>
|
||||
getDomainSearchChangeValidationErrors(
|
||||
value,
|
||||
shouldValidateFieldOnChange(formApi, "domain"),
|
||||
formApi.state.submissionAttempts > 0,
|
||||
),
|
||||
onSubmit: ({ value }) => getDomainSearchValidationErrors(value),
|
||||
},
|
||||
onSubmit: ({ formApi, value }) => {
|
||||
const target = normalizeDomainTarget(value.domain);
|
||||
if (!target) return;
|
||||
formApi.setFieldValue("domain", target);
|
||||
setSearchParams({
|
||||
domain: target,
|
||||
subdomains: value.subdomains ? undefined : false,
|
||||
sort: toSortSearchParam(value.sort),
|
||||
order: toSortOrderSearchParam(value.sort, currentSortOrder),
|
||||
tab: searchState.tab === "keywords" ? undefined : searchState.tab,
|
||||
loc:
|
||||
value.locationCode === DEFAULT_LOCATION_CODE
|
||||
? undefined
|
||||
: value.locationCode,
|
||||
page: undefined,
|
||||
size: undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useSyncRouteState({ controlsForm, searchState, navigate });
|
||||
const saveMutation = useSaveKeywordsMutation({ projectId, queryClient });
|
||||
|
||||
// Surface overview-query errors through the form's submit error map so the
|
||||
// existing error UI keeps working without a parallel error channel.
|
||||
useEffect(() => {
|
||||
controlsForm.setErrorMap({
|
||||
onSubmit: overviewQuery.error
|
||||
? createFormValidationErrors({
|
||||
form: getStandardErrorMessage(
|
||||
overviewQuery.error,
|
||||
"Lookup failed.",
|
||||
),
|
||||
})
|
||||
: undefined,
|
||||
});
|
||||
}, [controlsForm, overviewQuery.error]);
|
||||
|
||||
// History + analytics + "no data" toast: fire once per successful overview
|
||||
// fetch, keyed on the inputs that actually trigger a refetch.
|
||||
const lastTrackedKey = useRef<string>("");
|
||||
useEffect(() => {
|
||||
if (!overviewQuery.isSuccess || !overviewQuery.data) return;
|
||||
const key = `${searchState.domain}|${searchState.subdomains}|${searchState.locationCode}`;
|
||||
if (lastTrackedKey.current === key) return;
|
||||
lastTrackedKey.current = key;
|
||||
|
||||
const data = overviewQuery.data;
|
||||
captureClientEvent("domain_overview:search_complete", {
|
||||
sort_mode: searchState.sort,
|
||||
include_subdomains: searchState.subdomains,
|
||||
result_count: data.organicKeywords ?? 0,
|
||||
location_code: searchState.locationCode,
|
||||
});
|
||||
addSearch({
|
||||
domain: searchState.domain,
|
||||
subdomains: searchState.subdomains,
|
||||
sort: searchState.sort,
|
||||
tab: searchState.tab,
|
||||
search: searchState.search.trim() || undefined,
|
||||
locationCode: searchState.locationCode,
|
||||
});
|
||||
if (!data.hasData) {
|
||||
toast.info("Not enough data for this domain");
|
||||
}
|
||||
setSelectedKeywords(new Set());
|
||||
}, [
|
||||
overviewQuery.isSuccess,
|
||||
overviewQuery.data,
|
||||
searchState.domain,
|
||||
searchState.subdomains,
|
||||
searchState.locationCode,
|
||||
searchState.sort,
|
||||
searchState.tab,
|
||||
searchState.search,
|
||||
addSearch,
|
||||
]);
|
||||
|
||||
// Reset transient panel state when the user navigates back to recent searches.
|
||||
useEffect(() => {
|
||||
if (searchState.domain.trim() === "") {
|
||||
setShowFilters(false);
|
||||
setSelectedKeywords(new Set());
|
||||
lastTrackedKey.current = "";
|
||||
}
|
||||
}, [searchState.domain]);
|
||||
|
||||
const keywordsTabActive = searchState.tab === "keywords";
|
||||
const pagesTabActive = searchState.tab === "pages";
|
||||
|
||||
const keywordsQuery = useDomainKeywordsQuery({
|
||||
projectId,
|
||||
domain: overview?.domain ?? "",
|
||||
includeSubdomains: searchState.subdomains,
|
||||
locationCode: searchState.locationCode,
|
||||
languageCode: overviewLanguageCode,
|
||||
page: searchState.page,
|
||||
pageSize: searchState.pageSize,
|
||||
sortMode: searchState.sort,
|
||||
sortOrder: currentSortOrder,
|
||||
appliedFilters: searchState.appliedFilters,
|
||||
searchTerm: searchState.search,
|
||||
enabled: overview !== null && overview.hasData && keywordsTabActive,
|
||||
});
|
||||
|
||||
const pagesQuery = useDomainPagesQuery({
|
||||
projectId,
|
||||
domain: overview?.domain ?? "",
|
||||
includeSubdomains: searchState.subdomains,
|
||||
locationCode: searchState.locationCode,
|
||||
languageCode: overviewLanguageCode,
|
||||
page: searchState.page,
|
||||
pageSize: searchState.pageSize,
|
||||
sortMode: searchState.sort,
|
||||
sortOrder: currentSortOrder,
|
||||
searchTerm: searchState.search,
|
||||
enabled: overview !== null && overview.hasData && pagesTabActive,
|
||||
});
|
||||
|
||||
const pagedKeywords: KeywordRow[] = keywordsQuery.data?.keywords ?? [];
|
||||
const totalKeywordCount =
|
||||
keywordsQuery.data?.totalCount ?? overview?.organicKeywords ?? null;
|
||||
|
||||
const pagedPages: PageRow[] = pagesQuery.data?.pages ?? [];
|
||||
const totalPagesCount = pagesQuery.data?.totalCount ?? null;
|
||||
|
||||
const dataState = useOverviewDataState({
|
||||
pagedKeywords,
|
||||
setSelectedKeywords,
|
||||
activeFilterCount: domainFilters.activeAppliedCount,
|
||||
});
|
||||
|
||||
const handlers = useDomainControllerHandlers({
|
||||
controlsForm,
|
||||
currentSortOrder,
|
||||
currentState: searchState,
|
||||
dataState,
|
||||
projectId,
|
||||
saveMutation,
|
||||
selectedKeywords,
|
||||
setSearchParams,
|
||||
});
|
||||
|
||||
const canSaveKeywords =
|
||||
controlsForm.state.values.locationCode === searchState.locationCode &&
|
||||
overview !== null &&
|
||||
overview.hasData;
|
||||
|
||||
const goToPage = useCallback(
|
||||
(nextPage: number) => {
|
||||
const safe = Math.max(1, Math.floor(nextPage));
|
||||
setSearchParams({ page: safe === 1 ? undefined : safe });
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const setPageSize = useCallback(
|
||||
(nextSize: number) => {
|
||||
setSearchParams({
|
||||
size:
|
||||
nextSize === DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE ? undefined : nextSize,
|
||||
page: undefined,
|
||||
});
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
// Treat the page as loading until the active tab's first fetch resolves —
|
||||
// otherwise the table area would render an empty shell with a spinner while
|
||||
// we wait on DataForSEO. `isLoading` is true only on the very first fetch
|
||||
// (subsequent paginations keep prior data via keepPreviousData).
|
||||
const activeTabFirstFetch =
|
||||
(keywordsTabActive && keywordsQuery.isLoading) ||
|
||||
(pagesTabActive && pagesQuery.isLoading);
|
||||
const isLoading = overviewQuery.isLoading || activeTabFirstFetch;
|
||||
|
||||
return {
|
||||
controlsForm,
|
||||
isLoading,
|
||||
overview,
|
||||
canSaveKeywords,
|
||||
history,
|
||||
historyLoaded,
|
||||
removeHistoryItem,
|
||||
searchDraft: domainFilters.searchDraft,
|
||||
setSearchDraft: domainFilters.setSearchDraft,
|
||||
selectedKeywords,
|
||||
setSelectedKeywords,
|
||||
currentSortOrder,
|
||||
setSearchParams,
|
||||
showFilters,
|
||||
setShowFilters,
|
||||
filtersForm: domainFilters.filtersForm,
|
||||
resetFilters: domainFilters.resetFilters,
|
||||
applyFilters: domainFilters.applyFilters,
|
||||
cancelFilterEdits: domainFilters.cancelEdits,
|
||||
dirtyFilterCount: domainFilters.dirtyCount,
|
||||
conditionCount: domainFilters.conditionCount,
|
||||
overLimit: domainFilters.overLimit,
|
||||
keywordsLoading: keywordsQuery.isFetching,
|
||||
keywordsError: keywordsQuery.error,
|
||||
pagesLoading: pagesQuery.isFetching,
|
||||
pagesError: pagesQuery.error,
|
||||
page: searchState.page,
|
||||
pageSize: searchState.pageSize,
|
||||
totalKeywordCount,
|
||||
totalPagesCount,
|
||||
hasNextKeywordsPage: keywordsQuery.data?.hasMore ?? false,
|
||||
hasNextPagesPage: pagesQuery.data?.hasMore ?? false,
|
||||
pagedPages,
|
||||
goToPage,
|
||||
setPageSize,
|
||||
...handlers,
|
||||
...dataState,
|
||||
};
|
||||
}
|
||||
@ -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)[][] };
|
||||
|
||||
@ -13,17 +13,14 @@ import {
|
||||
import { useKeywordResearchController } from "@/client/features/keywords/state/useKeywordResearchController";
|
||||
import type { KeywordResearchControllerInput } from "@/client/features/keywords/state/useKeywordResearchController";
|
||||
import type { KeywordControlsValues } from "@/client/features/keywords/hooks/useKeywordControlsForm";
|
||||
import {
|
||||
parseKeywordInput,
|
||||
buildKeywordSearchKey,
|
||||
} from "@/client/features/keywords/state/keywordControllerActions";
|
||||
import { parseKeywordInput } from "@/client/features/keywords/state/keywordControllerActions";
|
||||
import { useKeywordSearchParams } from "@/client/features/keywords/state/keywordControllerInternals";
|
||||
import {
|
||||
getKeywordTabsSnapshot,
|
||||
useKeywordTabs,
|
||||
type OpenTabInput,
|
||||
} from "@/client/features/keywords/state/useKeywordTabs";
|
||||
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
|
||||
import type {
|
||||
KeywordSearchTabInput,
|
||||
SearchTab,
|
||||
} from "@/client/features/search-tabs/types";
|
||||
import { useSearchTabNavigation } from "@/client/features/search-tabs/useSearchTabNavigation";
|
||||
import { KeywordResearchEmptyState } from "./KeywordResearchEmptyState";
|
||||
import { KeywordResearchLoadingState } from "./KeywordResearchLoadingState";
|
||||
import { KeywordResearchResults } from "./KeywordResearchResults";
|
||||
@ -32,16 +29,19 @@ import { KeywordResearchTabStrip } from "./KeywordResearchTabStrip";
|
||||
import type { KeywordResearchControllerState } from "./types";
|
||||
|
||||
type Props = Omit<KeywordResearchControllerInput, "onFormSubmit">;
|
||||
type KeywordSearchTab = SearchTab & { input: KeywordSearchTabInput };
|
||||
|
||||
function isKeywordSearchTab(tab: SearchTab): tab is KeywordSearchTab {
|
||||
return tab.input.type === "keyword";
|
||||
}
|
||||
|
||||
export function KeywordResearchPage(input: Props) {
|
||||
const tabs = useKeywordTabs(input.projectId);
|
||||
const { openTabs, setActiveTab, findMatchingTab } = tabs;
|
||||
const setSearchParams = useKeywordSearchParams();
|
||||
const projectId = input.projectId;
|
||||
|
||||
const setSearchParamsForTab = useCallback(
|
||||
(tab: OpenTabInput | null) => {
|
||||
if (!tab) {
|
||||
const navigateToKeywordInput = useCallback(
|
||||
(tabInput: KeywordSearchTabInput | null) => {
|
||||
if (!tabInput) {
|
||||
setSearchParams({
|
||||
q: undefined,
|
||||
loc: undefined,
|
||||
@ -52,23 +52,24 @@ export function KeywordResearchPage(input: Props) {
|
||||
}
|
||||
|
||||
setSearchParams({
|
||||
q: tab.keyword,
|
||||
q: tabInput.keyword,
|
||||
loc:
|
||||
tab.locationCode === DEFAULT_LOCATION_CODE
|
||||
tabInput.locationCode === DEFAULT_LOCATION_CODE
|
||||
? undefined
|
||||
: tab.locationCode,
|
||||
kLimit: tab.resultLimit === 150 ? undefined : tab.resultLimit,
|
||||
mode: tab.mode === "auto" ? undefined : tab.mode,
|
||||
: tabInput.locationCode,
|
||||
kLimit: tabInput.resultLimit === 150 ? undefined : tabInput.resultLimit,
|
||||
mode: tabInput.mode === "auto" ? undefined : tabInput.mode,
|
||||
});
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const urlInput = useMemo<OpenTabInput | null>(() => {
|
||||
const urlInput = useMemo<KeywordSearchTabInput | null>(() => {
|
||||
const keywords = parseKeywordInput(input.keywordInput);
|
||||
const keyword = keywords[0];
|
||||
if (!keyword) return null;
|
||||
return {
|
||||
type: "keyword",
|
||||
keyword,
|
||||
locationCode: input.locationCode,
|
||||
resultLimit: input.resultLimit,
|
||||
@ -80,107 +81,72 @@ export function KeywordResearchPage(input: Props) {
|
||||
input.locationCode,
|
||||
input.resultLimit,
|
||||
]);
|
||||
const currentUrlKey = useMemo(
|
||||
() =>
|
||||
buildKeywordSearchKey({
|
||||
keyword: input.keywordInput,
|
||||
locationCode: input.locationCode,
|
||||
resultLimit: input.resultLimit,
|
||||
mode: input.keywordMode,
|
||||
}),
|
||||
[
|
||||
input.keywordInput,
|
||||
input.keywordMode,
|
||||
input.locationCode,
|
||||
input.resultLimit,
|
||||
],
|
||||
);
|
||||
const searchTabs = useSearchTabNavigation({
|
||||
storageKey: `keyword:${projectId}`,
|
||||
urlInput,
|
||||
getLabel: useCallback(
|
||||
(tabInput) => (tabInput.type === "keyword" ? tabInput.keyword : ""),
|
||||
[],
|
||||
),
|
||||
navigateToInput: useCallback(
|
||||
(tabInput) => {
|
||||
navigateToKeywordInput(tabInput?.type === "keyword" ? tabInput : null);
|
||||
},
|
||||
[navigateToKeywordInput],
|
||||
),
|
||||
});
|
||||
|
||||
// Effect: URL → activeTab. When the URL params resolve to a tab we already
|
||||
// have, focus it. Otherwise create one matching the URL (handles deep links
|
||||
// and back/forward navigation).
|
||||
useEffect(() => {
|
||||
if (!urlInput) {
|
||||
if (getKeywordTabsSnapshot(projectId).activeTabId !== null) {
|
||||
setActiveTab(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = findMatchingTab(urlInput);
|
||||
if (existing) {
|
||||
if (getKeywordTabsSnapshot(projectId).activeTabId !== existing.id) {
|
||||
setActiveTab(existing.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
openTabs([urlInput]);
|
||||
}, [urlInput, projectId, openTabs, setActiveTab, findMatchingTab]);
|
||||
|
||||
// Effect: activeTab → URL. After user actions (click tab, close tab, open
|
||||
// tabs from a multi-keyword submit) the active tab can diverge from the URL.
|
||||
// Re-align the URL so the controller below keeps reading the right query.
|
||||
const activeTab = tabs.activeTab;
|
||||
const activeTabUrlKey = useMemo(
|
||||
() =>
|
||||
activeTab
|
||||
? buildKeywordSearchKey({
|
||||
keyword: activeTab.keyword,
|
||||
locationCode: activeTab.locationCode,
|
||||
resultLimit: activeTab.resultLimit,
|
||||
mode: activeTab.mode,
|
||||
})
|
||||
: null,
|
||||
[activeTab],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!activeTab) return;
|
||||
|
||||
if (currentUrlKey === activeTabUrlKey) return;
|
||||
|
||||
setSearchParamsForTab(activeTab);
|
||||
}, [
|
||||
activeTab,
|
||||
activeTab?.id,
|
||||
activeTab?.keyword,
|
||||
activeTab?.locationCode,
|
||||
activeTab?.resultLimit,
|
||||
activeTab?.mode,
|
||||
activeTabUrlKey,
|
||||
currentUrlKey,
|
||||
setSearchParamsForTab,
|
||||
]);
|
||||
const activeTab = useMemo<KeywordSearchTab | null>(() => {
|
||||
if (!urlInput) return null;
|
||||
const tab = searchTabs.tabs.find(
|
||||
(candidate) => candidate.id === searchTabs.activeTabId,
|
||||
);
|
||||
return tab && isKeywordSearchTab(tab) ? tab : null;
|
||||
}, [searchTabs.activeTabId, searchTabs.tabs, urlInput]);
|
||||
|
||||
const onFormSubmit = useCallback(
|
||||
(value: KeywordControlsValues) => {
|
||||
const keywords = parseKeywordInput(value.keyword);
|
||||
if (keywords.length === 0) return;
|
||||
|
||||
const inputs: OpenTabInput[] = keywords.map((keyword) => ({
|
||||
const inputs: KeywordSearchTabInput[] = keywords.map((keyword) => ({
|
||||
type: "keyword",
|
||||
keyword,
|
||||
locationCode: value.locationCode,
|
||||
resultLimit: value.resultLimit,
|
||||
mode: value.mode,
|
||||
}));
|
||||
|
||||
const result = openTabs(inputs);
|
||||
if (result.activeTab) setSearchParamsForTab(result.activeTab);
|
||||
},
|
||||
[openTabs, setSearchParamsForTab],
|
||||
);
|
||||
const closeTab = useCallback(
|
||||
(tabId: string) => {
|
||||
const result = tabs.closeTab(tabId);
|
||||
if (result.closedActive) {
|
||||
setSearchParamsForTab(result.nextActiveTab);
|
||||
let activeInput: KeywordSearchTabInput | null = null;
|
||||
for (const tabInput of inputs) {
|
||||
const result = searchTabs.openTab(tabInput);
|
||||
if (result.tab?.input.type === "keyword") {
|
||||
activeInput = result.tab.input;
|
||||
}
|
||||
}
|
||||
if (activeInput) navigateToKeywordInput(activeInput);
|
||||
},
|
||||
[setSearchParamsForTab, tabs],
|
||||
[navigateToKeywordInput, searchTabs],
|
||||
);
|
||||
const showRecentSearches = useCallback(() => {
|
||||
searchTabs.setActiveTab(null);
|
||||
navigateToKeywordInput(null);
|
||||
}, [navigateToKeywordInput, searchTabs]);
|
||||
const getOpenKeywordTabs = useCallback(
|
||||
() => getKeywordTabsSnapshot(projectId).tabs,
|
||||
[projectId],
|
||||
() =>
|
||||
searchTabs.tabs.flatMap((tab) =>
|
||||
tab.input.type === "keyword"
|
||||
? [
|
||||
{
|
||||
keyword: tab.input.keyword,
|
||||
locationCode: tab.input.locationCode,
|
||||
resultLimit: tab.input.resultLimit,
|
||||
mode: tab.input.mode,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
[searchTabs.tabs],
|
||||
);
|
||||
|
||||
const controllerInput = useMemo<Props>(
|
||||
@ -188,20 +154,20 @@ export function KeywordResearchPage(input: Props) {
|
||||
activeTab
|
||||
? {
|
||||
...input,
|
||||
keywordInput: activeTab.keyword,
|
||||
locationCode: activeTab.locationCode,
|
||||
keywordInput: activeTab.input.keyword,
|
||||
locationCode: activeTab.input.locationCode,
|
||||
hasExplicitLocationCode: true,
|
||||
resultLimit: activeTab.resultLimit,
|
||||
keywordMode: activeTab.mode,
|
||||
resultLimit: activeTab.input.resultLimit,
|
||||
keywordMode: activeTab.input.mode,
|
||||
getOpenKeywordTabs,
|
||||
keywordTabsLimit: tabs.limit,
|
||||
keywordTabsLimit: searchTabs.limit,
|
||||
}
|
||||
: {
|
||||
...input,
|
||||
getOpenKeywordTabs,
|
||||
keywordTabsLimit: tabs.limit,
|
||||
keywordTabsLimit: searchTabs.limit,
|
||||
},
|
||||
[activeTab, getOpenKeywordTabs, input, tabs.limit],
|
||||
[activeTab, getOpenKeywordTabs, input, searchTabs.limit],
|
||||
);
|
||||
const controller = useKeywordResearchController({
|
||||
...controllerInput,
|
||||
@ -220,7 +186,7 @@ export function KeywordResearchPage(input: Props) {
|
||||
onSubmit: undefined,
|
||||
},
|
||||
}));
|
||||
}, [controller.controlsForm, tabs.tabs]);
|
||||
}, [controller.controlsForm, searchTabs.tabs]);
|
||||
|
||||
// Mark the active tab as viewed once its data lands. Reads cache state via
|
||||
// the same query key the controller uses, so this catches both fresh fetches
|
||||
@ -230,10 +196,10 @@ export function KeywordResearchPage(input: Props) {
|
||||
activeTab
|
||||
? buildKeywordResearchRequest({
|
||||
projectId,
|
||||
keywordInput: activeTab.keyword,
|
||||
locationCode: activeTab.locationCode,
|
||||
resultLimit: activeTab.resultLimit,
|
||||
mode: activeTab.mode,
|
||||
keywordInput: activeTab.input.keyword,
|
||||
locationCode: activeTab.input.locationCode,
|
||||
resultLimit: activeTab.input.resultLimit,
|
||||
mode: activeTab.input.mode,
|
||||
})
|
||||
: null,
|
||||
[activeTab, projectId],
|
||||
@ -249,7 +215,7 @@ export function KeywordResearchPage(input: Props) {
|
||||
gcTime: KEYWORD_RESEARCH_STALE_TIME_MS,
|
||||
});
|
||||
|
||||
const markTabViewed = tabs.markTabViewed;
|
||||
const markTabViewed = searchTabs.markTabViewed;
|
||||
useEffect(() => {
|
||||
if (!activeTab) return;
|
||||
if (!activeTabQuery.isSuccess) return;
|
||||
@ -279,25 +245,21 @@ export function KeywordResearchPage(input: Props) {
|
||||
<KeywordResearchSearchBar controller={controller} />
|
||||
{controller.hasSearched ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link
|
||||
from="/p/$projectId/keywords"
|
||||
to="/p/$projectId/keywords"
|
||||
params={{ projectId }}
|
||||
search={{}}
|
||||
replace
|
||||
<button
|
||||
type="button"
|
||||
data-testid="keyword-research-recent-searches"
|
||||
className="btn btn-ghost btn-sm w-fit gap-2 px-0 text-base-content/70 hover:bg-transparent"
|
||||
onClick={() => {
|
||||
setActiveTab(null);
|
||||
setSearchParamsForTab(null);
|
||||
}}
|
||||
onClick={showRecentSearches}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Recent searches
|
||||
</Link>
|
||||
</button>
|
||||
<KeywordResearchTabStrip
|
||||
projectId={projectId}
|
||||
tabs={tabs}
|
||||
closeTab={closeTab}
|
||||
tabs={searchTabs.tabs}
|
||||
activeTabId={searchTabs.activeTabId}
|
||||
onSelect={searchTabs.selectTab}
|
||||
onClose={searchTabs.closeTab}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@ -8,23 +8,30 @@ import {
|
||||
buildKeywordResearchRequest,
|
||||
keywordResearchQueryFn,
|
||||
} from "@/client/features/keywords/hooks/useKeywordResearchData";
|
||||
import type { UseKeywordTabsReturn } from "@/client/features/keywords/state/useKeywordTabs";
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
tabs: UseKeywordTabsReturn;
|
||||
closeTab: (tabId: string) => void;
|
||||
tabs: SearchTab[];
|
||||
activeTabId: string | null;
|
||||
onSelect: (tab: SearchTab) => void;
|
||||
onClose: (tabId: string) => void;
|
||||
};
|
||||
|
||||
export function KeywordResearchTabStrip({ projectId, tabs, closeTab }: Props) {
|
||||
if (tabs.tabs.length === 0) return null;
|
||||
export function KeywordResearchTabStrip({
|
||||
projectId,
|
||||
tabs,
|
||||
activeTabId,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: Props) {
|
||||
if (tabs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SearchTabStrip
|
||||
activeTabId={tabs.activeTabId}
|
||||
tabs={tabs.tabs}
|
||||
onSelect={(tab) => tabs.setActiveTab(tab.id)}
|
||||
onClose={closeTab}
|
||||
activeTabId={activeTabId}
|
||||
tabs={tabs}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
renderLeading={(tab, active) => (
|
||||
<KeywordTabStatus tab={tab} projectId={projectId} active={active} />
|
||||
)}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,158 +0,0 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type {
|
||||
KeywordSearchTabInput,
|
||||
SearchTab,
|
||||
} from "@/client/features/search-tabs/types";
|
||||
import {
|
||||
getSearchTabsSnapshot,
|
||||
useSearchTabs,
|
||||
} from "@/client/features/search-tabs/useSearchTabs";
|
||||
|
||||
export type OpenTabInput = Omit<KeywordSearchTabInput, "type">;
|
||||
|
||||
type KeywordTab = SearchTab & {
|
||||
input: KeywordSearchTabInput;
|
||||
keyword: string;
|
||||
locationCode: KeywordSearchTabInput["locationCode"];
|
||||
resultLimit: KeywordSearchTabInput["resultLimit"];
|
||||
mode: KeywordSearchTabInput["mode"];
|
||||
};
|
||||
|
||||
type ProjectTabsState = {
|
||||
tabs: KeywordTab[];
|
||||
activeTabId: string | null;
|
||||
};
|
||||
|
||||
type OpenTabsResult = {
|
||||
opened: KeywordTab[];
|
||||
focused: KeywordTab[];
|
||||
activeTab: KeywordTab | null;
|
||||
dropped: OpenTabInput[];
|
||||
};
|
||||
|
||||
const KEYWORD_TABS_KEY_PREFIX = "keyword";
|
||||
|
||||
function keywordTabsKey(projectId: string) {
|
||||
return `${KEYWORD_TABS_KEY_PREFIX}:${projectId}`;
|
||||
}
|
||||
|
||||
function toSearchTabInput(input: OpenTabInput): KeywordSearchTabInput {
|
||||
return {
|
||||
type: "keyword",
|
||||
keyword: input.keyword,
|
||||
locationCode: input.locationCode,
|
||||
resultLimit: input.resultLimit,
|
||||
mode: input.mode,
|
||||
};
|
||||
}
|
||||
|
||||
function toKeywordTab(tab: SearchTab): KeywordTab | null {
|
||||
if (tab.input.type !== "keyword") return null;
|
||||
return {
|
||||
...tab,
|
||||
input: tab.input,
|
||||
keyword: tab.input.keyword,
|
||||
locationCode: tab.input.locationCode,
|
||||
resultLimit: tab.input.resultLimit,
|
||||
mode: tab.input.mode,
|
||||
};
|
||||
}
|
||||
|
||||
function toKeywordTabs(tabs: readonly SearchTab[]): KeywordTab[] {
|
||||
return tabs.flatMap((tab) => {
|
||||
const keywordTab = toKeywordTab(tab);
|
||||
return keywordTab ? [keywordTab] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function getKeywordTabsSnapshot(projectId: string): ProjectTabsState {
|
||||
const snapshot = getSearchTabsSnapshot(keywordTabsKey(projectId));
|
||||
return {
|
||||
tabs: toKeywordTabs(snapshot.tabs),
|
||||
activeTabId: snapshot.activeTabId,
|
||||
};
|
||||
}
|
||||
|
||||
export function useKeywordTabs(projectId: string) {
|
||||
const tabs = useSearchTabs(keywordTabsKey(projectId));
|
||||
const keywordTabs = useMemo(() => toKeywordTabs(tabs.tabs), [tabs.tabs]);
|
||||
const activeTab = useMemo(
|
||||
() => keywordTabs.find((tab) => tab.id === tabs.activeTabId) ?? null,
|
||||
[keywordTabs, tabs.activeTabId],
|
||||
);
|
||||
|
||||
const openTabs = useCallback(
|
||||
(inputs: OpenTabInput[]): OpenTabsResult => {
|
||||
const opened: KeywordTab[] = [];
|
||||
const focused: KeywordTab[] = [];
|
||||
const dropped: OpenTabInput[] = [];
|
||||
let resultActiveTab: KeywordTab | null = null;
|
||||
let simulatedTabs = keywordTabs;
|
||||
|
||||
for (const input of inputs) {
|
||||
const result = tabs.openTab({
|
||||
label: input.keyword,
|
||||
input: toSearchTabInput(input),
|
||||
});
|
||||
|
||||
if (result.dropped) {
|
||||
dropped.push(input);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!result.tab) continue;
|
||||
const keywordTab = toKeywordTab(result.tab);
|
||||
if (!keywordTab) continue;
|
||||
resultActiveTab = keywordTab;
|
||||
|
||||
const wasAlreadyOpen = simulatedTabs.some(
|
||||
(tab) => tab.id === keywordTab.id,
|
||||
);
|
||||
if (wasAlreadyOpen) focused.push(keywordTab);
|
||||
else {
|
||||
opened.push(keywordTab);
|
||||
simulatedTabs = [...simulatedTabs, keywordTab];
|
||||
}
|
||||
}
|
||||
|
||||
return { opened, focused, activeTab: resultActiveTab, dropped };
|
||||
},
|
||||
[keywordTabs, tabs],
|
||||
);
|
||||
|
||||
const findMatchingTab = useCallback(
|
||||
(input: OpenTabInput) => {
|
||||
const match = tabs.findMatchingTab(toSearchTabInput(input));
|
||||
return match ? toKeywordTab(match) : null;
|
||||
},
|
||||
[tabs],
|
||||
);
|
||||
|
||||
const closeTab = useCallback(
|
||||
(tabId: string) => {
|
||||
const result = tabs.closeTab(tabId);
|
||||
return {
|
||||
closedActive: result.closedActive,
|
||||
nextActiveTab: result.nextActiveTab
|
||||
? toKeywordTab(result.nextActiveTab)
|
||||
: null,
|
||||
};
|
||||
},
|
||||
[tabs],
|
||||
);
|
||||
|
||||
return {
|
||||
tabs: keywordTabs,
|
||||
activeTabId: tabs.activeTabId,
|
||||
activeTab,
|
||||
isAtCap: keywordTabs.length >= tabs.limit,
|
||||
limit: tabs.limit,
|
||||
openTabs,
|
||||
closeTab,
|
||||
setActiveTab: tabs.setActiveTab,
|
||||
markTabViewed: tabs.markTabViewed,
|
||||
findMatchingTab,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseKeywordTabsReturn = ReturnType<typeof useKeywordTabs>;
|
||||
@ -31,36 +31,34 @@ export function SearchTabStrip({
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(tab)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onSelect(tab);
|
||||
}
|
||||
}}
|
||||
className={`group flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm transition ${
|
||||
data-search-tab-id={tab.id}
|
||||
className={`group flex shrink-0 items-stretch overflow-hidden rounded-md text-sm transition ${
|
||||
active
|
||||
? "bg-base-300 text-base-content shadow-sm"
|
||||
: "text-base-content/80 hover:bg-base-200"
|
||||
}`}
|
||||
>
|
||||
{renderLeading ? renderLeading(tab, active) : null}
|
||||
<span
|
||||
className="max-w-[10rem] truncate font-medium"
|
||||
title={tab.label}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-0.5 text-base-content/50 opacity-60 transition hover:bg-base-content/10 hover:text-base-content hover:opacity-100 group-hover:opacity-100"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClose(tab.id);
|
||||
}}
|
||||
role="tab"
|
||||
data-search-tab-id={tab.id}
|
||||
aria-selected={active}
|
||||
className="flex min-w-0 items-center gap-1.5 px-2.5 py-1.5 text-left"
|
||||
onClick={() => onSelect(tab)}
|
||||
>
|
||||
{renderLeading ? renderLeading(tab, active) : null}
|
||||
<span
|
||||
className="max-w-[10rem] truncate font-medium"
|
||||
title={tab.label}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-search-tab-id={tab.id}
|
||||
className="flex items-center px-1.5 text-base-content/50 opacity-60 transition hover:bg-base-content/10 hover:text-base-content hover:opacity-100 group-hover:opacity-100"
|
||||
onClick={() => onClose(tab.id)}
|
||||
aria-label={`Close ${tab.label} tab`}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import type { DomainSortMode, SortOrder } from "@/client/features/domain/types";
|
||||
import type {
|
||||
KeywordMode,
|
||||
ResultLimit,
|
||||
@ -15,8 +14,6 @@ export type DomainSearchTabInput = {
|
||||
type: "domain";
|
||||
domain: string;
|
||||
subdomains: boolean;
|
||||
sort: DomainSortMode;
|
||||
order: SortOrder;
|
||||
locationCode: number;
|
||||
};
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { SearchTab, SearchTabInput } from "./types";
|
||||
import { useSearchTabs } from "./useSearchTabs";
|
||||
|
||||
@ -9,17 +9,34 @@ type UseSearchTabNavigationArgs = {
|
||||
navigateToInput: (input: SearchTabInput | null) => void;
|
||||
};
|
||||
|
||||
function tabInputKey(input: SearchTabInput | null) {
|
||||
return input ? JSON.stringify(input) : "";
|
||||
}
|
||||
|
||||
export function useSearchTabNavigation({
|
||||
storageKey,
|
||||
urlInput,
|
||||
getLabel,
|
||||
navigateToInput,
|
||||
}: UseSearchTabNavigationArgs) {
|
||||
const closedInputKeysRef = useRef<Set<string>>(new Set());
|
||||
const tabs = useSearchTabs(storageKey);
|
||||
const { activeTabId, closeTab, findMatchingTab, openTab, setActiveTab } =
|
||||
tabs;
|
||||
const {
|
||||
activeTabId,
|
||||
closeTab,
|
||||
findMatchingTab,
|
||||
markTabViewed,
|
||||
openTab,
|
||||
setActiveTab,
|
||||
} = tabs;
|
||||
|
||||
useEffect(() => {
|
||||
const urlKey = tabInputKey(urlInput);
|
||||
if (closedInputKeysRef.current.has(urlKey)) {
|
||||
return;
|
||||
}
|
||||
closedInputKeysRef.current.clear();
|
||||
|
||||
if (!urlInput) {
|
||||
setActiveTab(null);
|
||||
return;
|
||||
@ -44,6 +61,7 @@ export function useSearchTabNavigation({
|
||||
|
||||
const selectTab = useCallback(
|
||||
(tab: SearchTab) => {
|
||||
closedInputKeysRef.current.delete(tabInputKey(tab.input));
|
||||
setActiveTab(tab.id);
|
||||
navigateToInput(tab.input);
|
||||
},
|
||||
@ -52,29 +70,44 @@ export function useSearchTabNavigation({
|
||||
|
||||
const closeSearchTab = useCallback(
|
||||
(tabId: string) => {
|
||||
const closingTab = tabs.tabs.find((tab) => tab.id === tabId) ?? null;
|
||||
if (closingTab) {
|
||||
closedInputKeysRef.current.add(tabInputKey(closingTab.input));
|
||||
}
|
||||
const result = closeTab(tabId);
|
||||
if (result.closedActive) {
|
||||
navigateToInput(result.nextActiveTab?.input ?? null);
|
||||
}
|
||||
},
|
||||
[closeTab, navigateToInput],
|
||||
[closeTab, navigateToInput, tabs.tabs],
|
||||
);
|
||||
|
||||
const openSearchTab = useCallback(
|
||||
(input: SearchTabInput) =>
|
||||
openTab({
|
||||
(input: SearchTabInput) => {
|
||||
closedInputKeysRef.current.delete(tabInputKey(input));
|
||||
return openTab({
|
||||
label: getLabel(input),
|
||||
input,
|
||||
}),
|
||||
});
|
||||
},
|
||||
[getLabel, openTab],
|
||||
);
|
||||
|
||||
const visibleTabs = useMemo(
|
||||
() =>
|
||||
tabs.tabs.filter(
|
||||
(tab) => !closedInputKeysRef.current.has(tabInputKey(tab.input)),
|
||||
),
|
||||
[tabs.tabs],
|
||||
);
|
||||
|
||||
return {
|
||||
activeTabId: tabs.activeTabId,
|
||||
tabs: tabs.tabs,
|
||||
tabs: visibleTabs,
|
||||
canOpenTab: tabs.canOpenTab,
|
||||
closeTab: closeSearchTab,
|
||||
limit: tabs.limit,
|
||||
markTabViewed,
|
||||
openTab: openSearchTab,
|
||||
selectTab,
|
||||
setActiveTab,
|
||||
|
||||
@ -44,23 +44,11 @@ function parseTabInput(value: unknown): SearchTabInput | null {
|
||||
if (value.type === "domain") {
|
||||
if (typeof value.domain !== "string" || value.domain === "") return null;
|
||||
if (typeof value.subdomains !== "boolean") return null;
|
||||
if (
|
||||
value.sort !== "rank" &&
|
||||
value.sort !== "traffic" &&
|
||||
value.sort !== "volume" &&
|
||||
value.sort !== "score" &&
|
||||
value.sort !== "cpc"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (value.order !== "asc" && value.order !== "desc") return null;
|
||||
if (typeof value.locationCode !== "number") return null;
|
||||
return {
|
||||
type: "domain",
|
||||
domain: value.domain,
|
||||
subdomains: value.subdomains,
|
||||
sort: value.sort,
|
||||
order: value.order,
|
||||
locationCode: value.locationCode,
|
||||
};
|
||||
}
|
||||
@ -149,7 +137,7 @@ function loadState(key: string): TabsState {
|
||||
}
|
||||
}
|
||||
|
||||
export function getSearchTabsSnapshot(key: string): TabsState {
|
||||
function getSearchTabsSnapshot(key: string): TabsState {
|
||||
let state = stateCache.get(key);
|
||||
if (!state) {
|
||||
state = loadState(key);
|
||||
@ -265,7 +253,7 @@ export function useSearchTabs(key: string) {
|
||||
let activeTabId = current.activeTabId;
|
||||
if (current.activeTabId === tabId) {
|
||||
closedActive = true;
|
||||
const neighbor = tabs[index] ?? tabs[index - 1] ?? null;
|
||||
const neighbor = tabs[index - 1] ?? tabs[index] ?? null;
|
||||
activeTabId = neighbor?.id ?? null;
|
||||
nextActiveTab = neighbor;
|
||||
}
|
||||
|
||||
@ -10,7 +10,6 @@ export interface DomainSearchHistoryItem {
|
||||
subdomains: boolean;
|
||||
sort: DomainSortMode;
|
||||
tab: DomainTab;
|
||||
search?: string;
|
||||
locationCode?: number;
|
||||
timestamp: number;
|
||||
}
|
||||
@ -24,7 +23,6 @@ const domainSearchHistoryItemSchema = z.object({
|
||||
subdomains: z.boolean(),
|
||||
sort: z.enum(["rank", "traffic", "volume", "score", "cpc"]),
|
||||
tab: z.enum(["keywords", "pages"]),
|
||||
search: z.string().optional(),
|
||||
locationCode: z.number().int().positive().optional(),
|
||||
timestamp: z.number(),
|
||||
});
|
||||
@ -32,10 +30,6 @@ const domainSearchHistoryItemSchema = z.object({
|
||||
const domainSearchHistorySchema = z.array(domainSearchHistoryItemSchema);
|
||||
const domainSearchHistoryCodec = jsonCodec(domainSearchHistorySchema);
|
||||
|
||||
function normalizeSearchText(value: string | undefined): string {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
function isSameSearch(
|
||||
a: DomainSearchHistoryItem,
|
||||
b: AddDomainSearchInput,
|
||||
@ -45,8 +39,7 @@ function isSameSearch(
|
||||
a.subdomains === b.subdomains &&
|
||||
a.sort === b.sort &&
|
||||
a.tab === b.tab &&
|
||||
a.locationCode === b.locationCode &&
|
||||
normalizeSearchText(a.search) === normalizeSearchText(b.search)
|
||||
a.locationCode === b.locationCode
|
||||
);
|
||||
}
|
||||
|
||||
@ -62,7 +55,6 @@ export function useDomainSearchHistory(projectId: string) {
|
||||
isSameItem: isSameSearch,
|
||||
createItem: (item) => ({
|
||||
...item,
|
||||
search: normalizeSearchText(item.search) || undefined,
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
getItemKey: (item) => item.timestamp,
|
||||
|
||||
2
src/env.d.ts
vendored
2
src/env.d.ts
vendored
@ -28,6 +28,8 @@ interface ImportMetaEnv {
|
||||
readonly AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted";
|
||||
readonly POSTHOG_PUBLIC_KEY?: string;
|
||||
readonly POSTHOG_HOST?: string;
|
||||
readonly VITE_E2E_DOMAIN_FIXTURES?: string;
|
||||
readonly VITE_E2E_KEYWORD_FIXTURES?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@ -23,10 +23,14 @@ function extractProjectId(data: unknown) {
|
||||
: null;
|
||||
}
|
||||
|
||||
function getRuntimeAuthMode() {
|
||||
return getAuthMode(import.meta.env.AUTH_MODE ?? env.AUTH_MODE);
|
||||
}
|
||||
|
||||
export const ensureUserMiddleware = createMiddleware({
|
||||
type: "function",
|
||||
}).server(async ({ next, data }) => {
|
||||
const authMode = getAuthMode(env.AUTH_MODE);
|
||||
const authMode = getRuntimeAuthMode();
|
||||
const headers = getRequest().headers;
|
||||
let context: EnsuredUserContext;
|
||||
|
||||
|
||||
@ -1,73 +1,58 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
createFileRoute,
|
||||
stripSearchParams,
|
||||
useNavigate,
|
||||
} from "@tanstack/react-router";
|
||||
import { DomainOverviewPage } from "@/client/features/domain/DomainOverviewPage";
|
||||
import {
|
||||
resolveSortOrder,
|
||||
toSortMode,
|
||||
toSortOrder,
|
||||
} from "@/client/features/domain/utils";
|
||||
import {
|
||||
DEFAULT_LOCATION_CODE,
|
||||
isSupportedLocationCode,
|
||||
} from "@/client/features/keywords/locations";
|
||||
import {
|
||||
DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE,
|
||||
domainSearchSchema,
|
||||
} from "@/types/schemas/domain";
|
||||
import {
|
||||
EMPTY_DOMAIN_FILTERS,
|
||||
type DomainFilterValues,
|
||||
} from "@/client/features/domain/types";
|
||||
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
|
||||
import { getDomainRouteState } from "@/client/features/domain/domainRouteState";
|
||||
|
||||
const DEFAULT_DOMAIN_SEARCH = {
|
||||
domain: "",
|
||||
subdomains: true,
|
||||
sort: "rank",
|
||||
order: undefined,
|
||||
tab: "keywords",
|
||||
loc: DEFAULT_LOCATION_CODE,
|
||||
page: 1,
|
||||
size: DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE,
|
||||
include: "",
|
||||
exclude: "",
|
||||
minTraffic: undefined,
|
||||
maxTraffic: undefined,
|
||||
minVol: undefined,
|
||||
maxVol: undefined,
|
||||
minCpc: undefined,
|
||||
maxCpc: undefined,
|
||||
minKd: undefined,
|
||||
maxKd: undefined,
|
||||
minRank: undefined,
|
||||
maxRank: undefined,
|
||||
pInclude: "",
|
||||
pExclude: "",
|
||||
pMinTraffic: undefined,
|
||||
pMaxTraffic: undefined,
|
||||
pMinVol: undefined,
|
||||
pMaxVol: undefined,
|
||||
} as const;
|
||||
|
||||
export const Route = createFileRoute("/_project/p/$projectId/domain")({
|
||||
validateSearch: domainSearchSchema,
|
||||
search: {
|
||||
middlewares: [stripSearchParams(DEFAULT_DOMAIN_SEARCH)],
|
||||
},
|
||||
component: DomainOverviewRoute,
|
||||
});
|
||||
|
||||
function numberToFilterString(value: number | undefined): string {
|
||||
if (value == null || !Number.isFinite(value)) return "";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function DomainOverviewRoute() {
|
||||
const { projectId } = Route.useParams();
|
||||
const navigate = useNavigate({ from: Route.fullPath });
|
||||
const search = Route.useSearch();
|
||||
const {
|
||||
domain = "",
|
||||
subdomains = true,
|
||||
sort = "rank",
|
||||
order,
|
||||
tab = "keywords",
|
||||
search: searchTerm = "",
|
||||
loc,
|
||||
page,
|
||||
size,
|
||||
} = search;
|
||||
|
||||
const normalizedSort = toSortMode(sort) ?? "rank";
|
||||
const normalizedOrder = resolveSortOrder(
|
||||
normalizedSort,
|
||||
toSortOrder(order ?? null),
|
||||
);
|
||||
const normalizedLocationCode =
|
||||
loc != null && isSupportedLocationCode(loc) ? loc : DEFAULT_LOCATION_CODE;
|
||||
const normalizedPage = page != null && page > 0 ? page : 1;
|
||||
const normalizedPageSize = size ?? DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE;
|
||||
|
||||
const appliedFilters: DomainFilterValues = {
|
||||
include: search.include ?? EMPTY_DOMAIN_FILTERS.include,
|
||||
exclude: search.exclude ?? EMPTY_DOMAIN_FILTERS.exclude,
|
||||
minTraffic: numberToFilterString(search.minTraffic),
|
||||
maxTraffic: numberToFilterString(search.maxTraffic),
|
||||
minVol: numberToFilterString(search.minVol),
|
||||
maxVol: numberToFilterString(search.maxVol),
|
||||
minCpc: numberToFilterString(search.minCpc),
|
||||
maxCpc: numberToFilterString(search.maxCpc),
|
||||
minKd: numberToFilterString(search.minKd),
|
||||
maxKd: numberToFilterString(search.maxKd),
|
||||
minRank: numberToFilterString(search.minRank),
|
||||
maxRank: numberToFilterString(search.maxRank),
|
||||
};
|
||||
const routeState = getDomainRouteState(search);
|
||||
|
||||
return (
|
||||
<DomainOverviewPage
|
||||
@ -79,18 +64,7 @@ function DomainOverviewRoute() {
|
||||
});
|
||||
}}
|
||||
navigate={navigate}
|
||||
searchState={{
|
||||
domain,
|
||||
subdomains,
|
||||
sort: normalizedSort,
|
||||
order: normalizedOrder,
|
||||
tab,
|
||||
search: searchTerm,
|
||||
locationCode: normalizedLocationCode,
|
||||
page: normalizedPage,
|
||||
pageSize: normalizedPageSize,
|
||||
appliedFilters,
|
||||
}}
|
||||
routeState={routeState}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import { createDataforseoClient } from "@/server/lib/dataforseoClient";
|
||||
import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache";
|
||||
import { normalizeDomainInput, toRelativePath } from "@/server/lib/domainUtils";
|
||||
import type { RelevantPagesItem } from "@/server/lib/dataforseo";
|
||||
import type { DomainKeywordsFilters } from "@/types/schemas/domain";
|
||||
|
||||
const DOMAIN_PAGES_PAGE_TTL_SECONDS = 12 * 60 * 60;
|
||||
|
||||
@ -38,10 +39,68 @@ function escapeLikeTerm(term: string): string {
|
||||
return term.replace(/[\\%_]/g, (match) => `\\${match}`);
|
||||
}
|
||||
|
||||
function buildPageFilters(searchTerm?: string): unknown[] {
|
||||
function pushAnd(filters: unknown[], expression: unknown[]) {
|
||||
if (filters.length > 0) filters.push("and");
|
||||
filters.push(expression);
|
||||
}
|
||||
|
||||
function collectNumericRange(
|
||||
out: unknown[][],
|
||||
field: string,
|
||||
min: number | undefined,
|
||||
max: number | undefined,
|
||||
) {
|
||||
if (typeof min === "number" && Number.isFinite(min)) {
|
||||
out.push([field, ">=", min]);
|
||||
}
|
||||
if (typeof max === "number" && Number.isFinite(max)) {
|
||||
out.push([field, "<=", max]);
|
||||
}
|
||||
}
|
||||
|
||||
function parseTerms(value: string | undefined): string[] {
|
||||
if (!value) return [];
|
||||
return value
|
||||
.toLowerCase()
|
||||
.split(/[,+]/)
|
||||
.map((term) => term.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function buildPageFilters(
|
||||
filters: DomainKeywordsFilters,
|
||||
searchTerm?: string,
|
||||
): unknown[] {
|
||||
const conditions: unknown[][] = [];
|
||||
|
||||
for (const term of parseTerms(filters.include)) {
|
||||
conditions.push(["page_address", "ilike", `%${escapeLikeTerm(term)}%`]);
|
||||
}
|
||||
for (const term of parseTerms(filters.exclude)) {
|
||||
conditions.push(["page_address", "not_ilike", `%${escapeLikeTerm(term)}%`]);
|
||||
}
|
||||
|
||||
collectNumericRange(
|
||||
conditions,
|
||||
"metrics.organic.etv",
|
||||
filters.minTraffic,
|
||||
filters.maxTraffic,
|
||||
);
|
||||
collectNumericRange(
|
||||
conditions,
|
||||
"metrics.organic.count",
|
||||
filters.minVol,
|
||||
filters.maxVol,
|
||||
);
|
||||
|
||||
const trimmed = searchTerm?.trim();
|
||||
if (!trimmed) return [];
|
||||
return [["page_address", "ilike", `%${escapeLikeTerm(trimmed)}%`]];
|
||||
if (trimmed) {
|
||||
conditions.push(["page_address", "ilike", `%${escapeLikeTerm(trimmed)}%`]);
|
||||
}
|
||||
|
||||
const expressions: unknown[] = [];
|
||||
for (const condition of conditions) pushAnd(expressions, condition);
|
||||
return expressions;
|
||||
}
|
||||
|
||||
function mapPageItem(item: RelevantPagesItem) {
|
||||
@ -69,6 +128,7 @@ export async function getPagesPage(
|
||||
pageSize: number;
|
||||
sortMode: DomainPagesSortMode;
|
||||
sortOrder: DomainPagesSortOrder;
|
||||
filters: DomainKeywordsFilters;
|
||||
search?: string;
|
||||
},
|
||||
billingCustomer: BillingCustomerContext,
|
||||
@ -76,7 +136,7 @@ export async function getPagesPage(
|
||||
const domain = normalizeDomainInput(input.domain, input.includeSubdomains);
|
||||
const offset = (input.page - 1) * input.pageSize;
|
||||
const orderBy = [`${SORT_FIELD_BY_MODE[input.sortMode]},${input.sortOrder}`];
|
||||
const filters = buildPageFilters(input.search);
|
||||
const filters = buildPageFilters(input.filters, input.search);
|
||||
|
||||
const cacheKey = await buildCacheKey("domain:pages-page", {
|
||||
organizationId: billingCustomer.organizationId,
|
||||
@ -89,6 +149,7 @@ export async function getPagesPage(
|
||||
pageSize: input.pageSize,
|
||||
sortMode: input.sortMode,
|
||||
sortOrder: input.sortOrder,
|
||||
filters: input.filters,
|
||||
search: input.search,
|
||||
});
|
||||
|
||||
|
||||
@ -8,18 +8,31 @@ import {
|
||||
} from "@/types/schemas/domain";
|
||||
import { DomainService } from "@/server/features/domain/services/DomainService";
|
||||
|
||||
function shouldUseDomainE2eFixtures() {
|
||||
return import.meta.env.VITE_E2E_DOMAIN_FIXTURES === "1";
|
||||
}
|
||||
|
||||
async function getDomainE2eFixtures() {
|
||||
return import("../../e2e/fixtures/domain-overview-fixtures");
|
||||
}
|
||||
|
||||
export const getDomainOverview = createServerFn({ method: "POST" })
|
||||
.middleware(requireProjectContext)
|
||||
.inputValidator((data: unknown) => domainOverviewSchema.parse(data))
|
||||
.handler(async ({ data, context }) =>
|
||||
DomainService.getOverview(
|
||||
.handler(async ({ data, context }) => {
|
||||
if (shouldUseDomainE2eFixtures()) {
|
||||
const fixtures = await getDomainE2eFixtures();
|
||||
return fixtures.getFixtureOverview(data.domain);
|
||||
}
|
||||
|
||||
return DomainService.getOverview(
|
||||
{
|
||||
...data,
|
||||
projectId: context.projectId,
|
||||
},
|
||||
context,
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
|
||||
export const getDomainKeywordSuggestions = createServerFn({ method: "POST" })
|
||||
.middleware(requireProjectContext)
|
||||
@ -40,25 +53,35 @@ export const getDomainKeywordsPage = createServerFn({ method: "POST" })
|
||||
.inputValidator((data: unknown) =>
|
||||
domainKeywordsPageRequestSchema.parse(data),
|
||||
)
|
||||
.handler(async ({ data, context }) =>
|
||||
DomainService.getKeywordsPage(
|
||||
.handler(async ({ data, context }) => {
|
||||
if (shouldUseDomainE2eFixtures()) {
|
||||
const fixtures = await getDomainE2eFixtures();
|
||||
return fixtures.getFixtureKeywordsPage(data);
|
||||
}
|
||||
|
||||
return DomainService.getKeywordsPage(
|
||||
{
|
||||
...data,
|
||||
projectId: context.projectId,
|
||||
},
|
||||
context,
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
|
||||
export const getDomainPagesPage = createServerFn({ method: "POST" })
|
||||
.middleware(requireProjectContext)
|
||||
.inputValidator((data: unknown) => domainPagesPageRequestSchema.parse(data))
|
||||
.handler(async ({ data, context }) =>
|
||||
DomainService.getPagesPage(
|
||||
.handler(async ({ data, context }) => {
|
||||
if (shouldUseDomainE2eFixtures()) {
|
||||
const fixtures = await getDomainE2eFixtures();
|
||||
return fixtures.getFixturePagesPage(data);
|
||||
}
|
||||
|
||||
return DomainService.getPagesPage(
|
||||
{
|
||||
...data,
|
||||
projectId: context.projectId,
|
||||
},
|
||||
context,
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
|
||||
@ -13,10 +13,23 @@ import {
|
||||
import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService";
|
||||
import { requireProjectContext } from "@/serverFunctions/middleware";
|
||||
|
||||
function shouldUseKeywordE2eFixtures() {
|
||||
return import.meta.env.VITE_E2E_KEYWORD_FIXTURES === "1";
|
||||
}
|
||||
|
||||
async function getKeywordE2eFixtures() {
|
||||
return import("../../e2e/fixtures/keyword-research-fixtures");
|
||||
}
|
||||
|
||||
export const researchKeywords = createServerFn({ method: "POST" })
|
||||
.middleware(requireProjectContext)
|
||||
.inputValidator((data: unknown) => researchKeywordsSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
if (shouldUseKeywordE2eFixtures()) {
|
||||
const fixtures = await getKeywordE2eFixtures();
|
||||
return fixtures.getKeywordResearchFixture(data);
|
||||
}
|
||||
|
||||
return KeywordResearchService.research(
|
||||
{
|
||||
...data,
|
||||
|
||||
@ -133,6 +133,7 @@ export const domainPagesPageRequestSchema = z.object({
|
||||
.default(DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE),
|
||||
sortMode: z.enum(domainPagesSortModes).default("traffic"),
|
||||
sortOrder: z.enum(domainSortOrders).default("desc"),
|
||||
filters: domainKeywordsFiltersSchema.default({}),
|
||||
search: z.string().optional(),
|
||||
});
|
||||
|
||||
@ -152,7 +153,6 @@ export const domainSearchSchema = z.object({
|
||||
sort: z.enum(domainSortModes).optional(),
|
||||
order: z.enum(domainSortOrders).optional(),
|
||||
tab: z.enum(domainTabs).optional(),
|
||||
search: z.string().optional(),
|
||||
loc: optionalSearchPositiveIntParam,
|
||||
page: optionalSearchPositiveIntParam,
|
||||
size: z.coerce
|
||||
@ -175,4 +175,12 @@ export const domainSearchSchema = z.object({
|
||||
maxKd: filterNumberParam,
|
||||
minRank: filterNumberParam,
|
||||
maxRank: filterNumberParam,
|
||||
pInclude: filterStringParam,
|
||||
pExclude: filterStringParam,
|
||||
pMinTraffic: filterNumberParam,
|
||||
pMaxTraffic: filterNumberParam,
|
||||
pMinVol: filterNumberParam,
|
||||
pMaxVol: filterNumberParam,
|
||||
});
|
||||
|
||||
export type DomainSearchParams = z.infer<typeof domainSearchSchema>;
|
||||
|
||||
@ -8,7 +8,11 @@ import { devtools } from "@tanstack/devtools-vite";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
const port = env.PORT ? Number(env.PORT) : 3001;
|
||||
const port = process.env.PORT
|
||||
? Number(process.env.PORT)
|
||||
: env.PORT
|
||||
? Number(env.PORT)
|
||||
: 3001;
|
||||
const showDevtools = env.VITE_SHOW_DEVTOOLS !== "false";
|
||||
const allowedHosts = [
|
||||
env.ALLOWED_HOST,
|
||||
@ -39,7 +43,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
})
|
||||
: null,
|
||||
cloudflare({ viteEnvironment: { name: "ssr" } }),
|
||||
cloudflare({ inspectorPort: false, viteEnvironment: { name: "ssr" } }),
|
||||
tsConfigPaths(),
|
||||
tanstackStart(),
|
||||
viteReact(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user