fix: Refactor folder structure & services (#17)

* refactor: simplify keyword source selection flow

* fix: improve stacked keyword page layout

* fix: keep SERP tied to searched keyword

Stop silently falling back SERP lookups to related keywords and show a keyword-specific empty message so missing coverage is explicit to users.

* refactor: extract keyword controller and switch SERP to live

Move keyword research orchestration out of the route, call DataForSEO organic live SERP, and harden response normalization with runtime validation for safer typed handling.

* refactor: split keyword research page state into focused hooks

* fix: aggregate auto keyword fallback and reuse cached results

* scope keyword metrics to projectId

* refactor: modularize SEO routes and harden parsing for CI checks

* refactor: adopt zod json codecs at parse boundaries

* refactor: adopt papaparse, tldts, and zod error enums

* refactor: reorganize client and server feature layout

Group keyword UI/controller files and server repositories/services by feature while keeping serverFunctions as the stable API surface. This improves navigation and maintainability without changing runtime behavior.

* refactor: move PSI business logic into service layer

Keep serverFunctions/psi as thin transport handlers by delegating orchestration, export shaping, and source resolution to PsiAuditService. This aligns PSI with the existing service-first backend pattern without changing endpoint behavior.

* refactor: enforce 350/120 size limits across modules

* move self host

* fix: show dynamic range for search trends

Replace the static 'Past 12 months' subtitle with a computed range from the actual plotted trend points so the UI reflects data lag correctly.

* fix: bind audit workflow writes to workflow instance

* chore: make ci checks pass
This commit is contained in:
Ben Senescu 2026-03-11 23:01:52 -04:00 committed by GitHub
parent 6017fbc4ef
commit 75f9868500
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
113 changed files with 10952 additions and 7861 deletions

View File

@ -5,7 +5,13 @@
"suspicious": "error" "suspicious": "error"
}, },
"plugins": ["typescript", "import", "react", "unicorn", "oxc"], "plugins": ["typescript", "import", "react", "unicorn", "oxc"],
"ignorePatterns": ["node_modules", "dist", ".output", "src/routeTree.gen.ts"], "ignorePatterns": [
"node_modules",
"dist",
".output",
"src/routeTree.gen.ts",
"worker-configuration.d.ts"
],
"rules": { "rules": {
"react/react-in-jsx-scope": "off", "react/react-in-jsx-scope": "off",
"react/jsx-uses-react": "off", "react/jsx-uses-react": "off",
@ -17,6 +23,11 @@
"error", "error",
{ "checkLiteralConstAssertions": false } { "checkLiteralConstAssertions": false }
], ],
"typescript/no-unsafe-member-access": "error",
"typescript/no-unsafe-assignment": "error",
"typescript/no-unsafe-call": "error",
"typescript/no-unsafe-return": "error",
"typescript/no-unsafe-argument": "error",
"eslint/no-constant-binary-expression": "error", "eslint/no-constant-binary-expression": "error",
"eslint/no-self-assign": "error", "eslint/no-self-assign": "error",
"eslint/no-unreachable-loop": "error", "eslint/no-unreachable-loop": "error",
@ -24,11 +35,11 @@
"eslint/complexity": ["error", { "max": 40 }], "eslint/complexity": ["error", { "max": 40 }],
"eslint/max-lines": [ "eslint/max-lines": [
"error", "error",
{ "max": 2000, "skipBlankLines": true, "skipComments": true } { "max": 350, "skipBlankLines": true, "skipComments": true }
], ],
"eslint/max-lines-per-function": [ "eslint/max-lines-per-function": [
"error", "error",
{ "max": 1000, "skipBlankLines": true, "skipComments": true } { "max": 120, "skipBlankLines": true, "skipComments": true }
], ],
"eslint/max-depth": ["error", 4], "eslint/max-depth": ["error", 4],
"eslint/max-params": ["error", 5], "eslint/max-params": ["error", 5],

View File

@ -2,7 +2,7 @@ services:
open-seo: open-seo:
build: build:
context: . context: .
dockerfile: self-host/Dockerfile.selfhost dockerfile: Dockerfile.selfhost
working_dir: /app working_dir: /app
environment: environment:
- PORT=${PORT:-3001} - PORT=${PORT:-3001}
@ -21,7 +21,6 @@ services:
- .:/app - .:/app
- open_seo_node_modules:/app/node_modules - open_seo_node_modules:/app/node_modules
- open_seo_pnpm_store:/pnpm/store - open_seo_pnpm_store:/pnpm/store
volumes: volumes:
open_seo_node_modules: open_seo_node_modules:
open_seo_pnpm_store: open_seo_pnpm_store:

View File

@ -0,0 +1,67 @@
CREATE TABLE `__keyword_metrics_project_guard` (
`ok` integer NOT NULL,
CHECK (`ok` = 1)
);--> statement-breakpoint
INSERT INTO `__keyword_metrics_project_guard` (`ok`)
SELECT
CASE
WHEN (
SELECT COUNT(*) FROM `keyword_metrics`
) = 0 THEN 1
WHEN (
SELECT COUNT(*) FROM `projects`
) = 1 THEN 1
ELSE 0
END;--> statement-breakpoint
DROP TABLE `__keyword_metrics_project_guard`;--> statement-breakpoint
ALTER TABLE `keyword_metrics` RENAME TO `keyword_metrics_legacy`;--> statement-breakpoint
CREATE TABLE `keyword_metrics` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`project_id` text NOT NULL,
`keyword` text NOT NULL,
`location_code` integer NOT NULL,
`language_code` text DEFAULT 'en' NOT NULL,
`search_volume` integer,
`cpc` real,
`competition` real,
`keyword_difficulty` integer,
`intent` text,
`monthly_searches` text,
`fetched_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);--> statement-breakpoint
INSERT INTO `keyword_metrics` (
`project_id`,
`keyword`,
`location_code`,
`language_code`,
`search_volume`,
`cpc`,
`competition`,
`keyword_difficulty`,
`intent`,
`monthly_searches`,
`fetched_at`
)
SELECT
(
SELECT `id`
FROM `projects`
ORDER BY `created_at` ASC, `id` ASC
LIMIT 1
),
`keyword`,
`location_code`,
`language_code`,
`search_volume`,
`cpc`,
`competition`,
`keyword_difficulty`,
`intent`,
`monthly_searches`,
`fetched_at`
FROM `keyword_metrics_legacy`;--> statement-breakpoint
DROP TABLE `keyword_metrics_legacy`;--> statement-breakpoint
CREATE UNIQUE INDEX `keyword_metrics_unique_project_keyword_location_language` ON `keyword_metrics` (`project_id`,`keyword`,`location_code`,`language_code`);--> statement-breakpoint
CREATE INDEX `keyword_metrics_lookup_idx` ON `keyword_metrics` (`project_id`,`keyword`,`location_code`,`language_code`,`fetched_at`);

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,13 @@
"when": 1772127913577, "when": 1772127913577,
"tag": "0001_round_unus", "tag": "0001_round_unus",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1773261363719,
"tag": "0002_fair_toad_men",
"breakpoints": true
} }
] ]
} }

View File

@ -2,6 +2,7 @@
"name": "open-seo", "name": "open-seo",
"private": true, "private": true,
"sideEffects": false, "sideEffects": false,
"version": "0.0.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "AUTH_MODE=local_noauth vite dev", "dev": "AUTH_MODE=local_noauth vite dev",
@ -55,6 +56,7 @@
"fast-xml-parser": "^5.4.1", "fast-xml-parser": "^5.4.1",
"jose": "^6.0.12", "jose": "^6.0.12",
"lucide-react": "^0.542.0", "lucide-react": "^0.542.0",
"papaparse": "^5.5.3",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"recharts": "^3.7.0", "recharts": "^3.7.0",
@ -62,6 +64,7 @@
"robots-parser": "^3.0.1", "robots-parser": "^3.0.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwindcss": "^4.1.16", "tailwindcss": "^4.1.16",
"tldts": "^7.0.25",
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {
@ -72,6 +75,7 @@
"@tanstack/devtools-vite": "^0.5.1", "@tanstack/devtools-vite": "^0.5.1",
"@tanstack/react-devtools": "^0.9.6", "@tanstack/react-devtools": "^0.9.6",
"@types/node": "^22.18.13", "@types/node": "^22.18.13",
"@types/papaparse": "^5.5.2",
"@types/react": "^19.0.8", "@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3", "@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.6.0",

34
pnpm-lock.yaml generated
View File

@ -59,6 +59,9 @@ importers:
lucide-react: lucide-react:
specifier: ^0.542.0 specifier: ^0.542.0
version: 0.542.0(react@19.2.4) version: 0.542.0(react@19.2.4)
papaparse:
specifier: ^5.5.3
version: 5.5.3
react: react:
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.2.4 version: 19.2.4
@ -80,6 +83,9 @@ importers:
tailwindcss: tailwindcss:
specifier: ^4.1.16 specifier: ^4.1.16
version: 4.2.1 version: 4.2.1
tldts:
specifier: ^7.0.25
version: 7.0.25
zod: zod:
specifier: ^4.1.12 specifier: ^4.1.12
version: 4.3.6 version: 4.3.6
@ -105,6 +111,9 @@ importers:
'@types/node': '@types/node':
specifier: ^22.18.13 specifier: ^22.18.13
version: 22.19.11 version: 22.19.11
'@types/papaparse':
specifier: ^5.5.2
version: 5.5.2
'@types/react': '@types/react':
specifier: ^19.0.8 specifier: ^19.0.8
version: 19.2.14 version: 19.2.14
@ -1863,6 +1872,9 @@ packages:
'@types/node@22.19.11': '@types/node@22.19.11':
resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==} resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==}
'@types/papaparse@5.5.2':
resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==}
'@types/react-dom@19.2.3': '@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies: peerDependencies:
@ -2649,6 +2661,9 @@ packages:
oxlint-tsgolint: oxlint-tsgolint:
optional: true optional: true
papaparse@5.5.3:
resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==}
parse5-htmlparser2-tree-adapter@7.1.0: parse5-htmlparser2-tree-adapter@7.1.0:
resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==}
@ -2885,6 +2900,13 @@ packages:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
tldts-core@7.0.25:
resolution: {integrity: sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==}
tldts@7.0.25:
resolution: {integrity: sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==}
hasBin: true
to-regex-range@5.0.1: to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'} engines: {node: '>=8.0'}
@ -4482,6 +4504,10 @@ snapshots:
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
'@types/papaparse@5.5.2':
dependencies:
'@types/node': 22.19.11
'@types/react-dom@19.2.3(@types/react@19.2.14)': '@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies: dependencies:
'@types/react': 19.2.14 '@types/react': 19.2.14
@ -5255,6 +5281,8 @@ snapshots:
'@oxlint/binding-win32-x64-msvc': 1.50.0 '@oxlint/binding-win32-x64-msvc': 1.50.0
oxlint-tsgolint: 0.15.0 oxlint-tsgolint: 0.15.0
papaparse@5.5.3: {}
parse5-htmlparser2-tree-adapter@7.1.0: parse5-htmlparser2-tree-adapter@7.1.0:
dependencies: dependencies:
domhandler: 5.0.3 domhandler: 5.0.3
@ -5499,6 +5527,12 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3 picomatch: 4.0.3
tldts-core@7.0.25: {}
tldts@7.0.25:
dependencies:
tldts-core: 7.0.25
to-regex-range@5.0.1: to-regex-range@5.0.1:
dependencies: dependencies:
is-number: 7.0.0 is-number: 7.0.0

View File

@ -0,0 +1,124 @@
import { MoreHorizontal, ScanSearch, Trash2 } from "lucide-react";
import type { getAuditHistory } from "@/serverFunctions/audit";
import { formatDate, StatusBadge } from "@/client/features/audit/shared";
export function AuditHistorySection({
history,
isLoading,
onView,
onDelete,
}: {
history: Awaited<ReturnType<typeof getAuditHistory>>;
isLoading: boolean;
onView: (auditId: string) => void;
onDelete: (auditId: string) => void;
}) {
if (history.length === 0 && !isLoading) {
return (
<div className="flex items-center justify-center py-16">
<div className="text-center text-base-content/40 space-y-3">
<ScanSearch className="size-12 mx-auto opacity-30" />
<p className="text-lg font-medium">No audits yet</p>
</div>
</div>
);
}
if (history.length === 0) return null;
return (
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-3">
<h2 className="card-title text-base">Previous Audits</h2>
<div className="overflow-x-auto">
<table className="table table-sm">
<thead>
<tr>
<th>Date</th>
<th>URL</th>
<th>Status</th>
<th>Pages</th>
<th>PSI</th>
<th></th>
</tr>
</thead>
<tbody>
{history.map((audit) => (
<tr key={audit.id} className="hover group">
<td className="text-xs text-base-content/70">
{formatDate(audit.startedAt)}
</td>
<td className="max-w-[220px] truncate">{audit.startUrl}</td>
<td>
<StatusBadge status={audit.status} />
</td>
<td>{audit.pagesTotal || audit.pagesCrawled}</td>
<td>
{audit.ranPsi ? (
<span className="badge badge-ghost badge-xs">Yes</span>
) : null}
</td>
<td>
<HistoryActions
auditId={audit.id}
onView={onView}
onDelete={onDelete}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
function HistoryActions({
auditId,
onView,
onDelete,
}: {
auditId: string;
onView: (auditId: string) => void;
onDelete: (auditId: string) => void;
}) {
return (
<div className="flex items-center justify-end gap-2 transition-opacity md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100">
<button
className="btn btn-primary btn-xs"
onClick={() => onView(auditId)}
>
View
</button>
<div className="dropdown dropdown-end">
<div
tabIndex={0}
role="button"
className="btn btn-ghost btn-xs btn-square"
aria-label="Audit actions"
>
<MoreHorizontal className="size-3.5" />
</div>
<ul
tabIndex={0}
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-40"
>
<li>
<button
className="text-error"
onClick={(event) => {
event.stopPropagation();
onDelete(auditId);
}}
>
<Trash2 className="size-3.5" />
Delete audit
</button>
</li>
</ul>
</div>
</div>
);
}

View File

@ -0,0 +1,229 @@
import type { FormEvent } from "react";
import { Loader2, Settings } from "lucide-react";
import {
MAX_PAGES_LIMIT,
MIN_PAGES,
type LaunchFormApi,
type LaunchState,
type SettingsFormApi,
} from "@/client/features/audit/launch/types";
export function LaunchFormCard({
launchForm,
settingsForm,
state,
setState,
isPending,
onSubmit,
onOpenSettings,
onRunPsiToggle,
commitMaxPagesInput,
}: {
launchForm: LaunchFormApi;
settingsForm: SettingsFormApi;
state: LaunchState;
setState: React.Dispatch<React.SetStateAction<LaunchState>>;
isPending: boolean;
onSubmit: (event: FormEvent) => void;
onOpenSettings: () => void;
onRunPsiToggle: (checked: boolean) => void;
commitMaxPagesInput: () => number;
}) {
return (
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-4">
<div className="flex items-center justify-between">
<h2 className="card-title text-base">Start New Audit</h2>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={onOpenSettings}
>
<Settings className="size-4" />
Settings
</button>
</div>
<form
className="grid grid-cols-1 gap-3 lg:grid-cols-12 lg:items-center"
onSubmit={onSubmit}
>
<label
className={`input input-bordered w-full lg:col-span-9 ${state.urlError ? "input-error" : ""}`}
>
<launchForm.Field name="url">
{(field) => (
<input
placeholder="https://example.com"
value={field.state.value}
onChange={(event) => {
field.handleChange(event.target.value);
if (state.urlError)
setState((prev) => ({ ...prev, urlError: null }));
}}
/>
)}
</launchForm.Field>
</label>
<button
type="submit"
className="btn btn-primary btn-sm w-full lg:col-span-3"
disabled={isPending}
>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" /> Starting...
</>
) : (
"Start Audit"
)}
</button>
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 lg:col-span-12 lg:items-start">
<LaunchOptions
launchForm={launchForm}
commitMaxPagesInput={commitMaxPagesInput}
/>
<PsiOptions
launchForm={launchForm}
settingsForm={settingsForm}
onRunPsiToggle={onRunPsiToggle}
/>
</div>
</form>
<LaunchErrors state={state} />
</div>
</div>
);
}
function LaunchOptions({
launchForm,
commitMaxPagesInput,
}: {
launchForm: LaunchFormApi;
commitMaxPagesInput: () => number;
}) {
return (
<div className="rounded-lg border border-base-300 bg-base-200/20 p-3 space-y-2">
<label className="text-xs font-medium uppercase tracking-wide text-base-content/60">
Crawl limit
</label>
<div className="flex items-center gap-2">
<span className="text-sm text-base-content/70">Max pages</span>
<launchForm.Field name="maxPagesInput">
{(field) => (
<input
type="number"
min={MIN_PAGES}
max={MAX_PAGES_LIMIT}
className="input input-bordered input-sm w-28"
value={field.state.value}
onChange={(event) => {
const next = event.target.value;
if (!/^\d*$/.test(next)) return;
field.handleChange(next);
}}
onBlur={commitMaxPagesInput}
/>
)}
</launchForm.Field>
</div>
<p className="text-xs text-base-content/50">
Enter any value from {MIN_PAGES} to {MAX_PAGES_LIMIT}.
</p>
</div>
);
}
function PsiOptions({
launchForm,
settingsForm,
onRunPsiToggle,
}: {
launchForm: LaunchFormApi;
settingsForm: SettingsFormApi;
onRunPsiToggle: (checked: boolean) => void;
}) {
return (
<div className="rounded-lg border border-base-300 bg-base-200/20 p-3 space-y-2">
<label className="label cursor-pointer justify-start gap-2 p-0">
<launchForm.Field name="runPsi">
{(field) => (
<input
type="checkbox"
className="toggle toggle-sm toggle-primary"
checked={Boolean(field.state.value)}
onChange={(event) => onRunPsiToggle(event.target.checked)}
/>
)}
</launchForm.Field>
<span
className="text-sm font-medium text-base-content/80"
title="Run Google PageSpeed Insights checks during this audit"
>
Include PSI checks
</span>
</label>
<launchForm.Subscribe selector={(snapshot) => snapshot.values.runPsi}>
{(runPsi) =>
runPsi ? (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-base-content/60">PSI mode</span>
<launchForm.Field name="psiMode">
{(field) => (
<select
className="select select-bordered select-xs"
value={field.state.value}
onChange={(event) =>
field.handleChange(
event.target.value === "all" ? "all" : "auto",
)
}
>
<option value="auto">Auto sample (recommended)</option>
<option value="all">All crawled pages</option>
</select>
)}
</launchForm.Field>
<settingsForm.Subscribe
selector={(snapshot) => snapshot.values.psiApiKey}
>
{(psiApiKey) => (
<span
className={`text-xs ${psiApiKey.trim() ? "text-success/80" : "text-warning"}`}
>
{psiApiKey.trim() ? "PSI key saved" : "PSI key required"}
</span>
)}
</settingsForm.Subscribe>
</div>
) : null
}
</launchForm.Subscribe>
</div>
);
}
function LaunchErrors({ state }: { state: LaunchState }) {
return (
<div className="space-y-2">
{state.urlError ? (
<p className="text-sm text-error">{state.urlError}</p>
) : null}
{state.psiRequirementError ? (
<div className="alert alert-warning py-2">
<span className="text-sm">{state.psiRequirementError}</span>
</div>
) : null}
{state.startError ? (
<div className="alert alert-error py-2">
<span className="text-sm">{state.startError}</span>
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,51 @@
import { AuditHistorySection } from "@/client/features/audit/launch/AuditHistorySection";
import { LaunchFormCard } from "@/client/features/audit/launch/LaunchFormCard";
import { SettingsModal } from "@/client/features/audit/launch/SettingsModal";
import { useLaunchController } from "@/client/features/audit/launch/useLaunchController";
export function LaunchView({
projectId,
onAuditStarted,
}: {
projectId: string;
onAuditStarted: (auditId: string) => void;
}) {
const controller = useLaunchController({ projectId, onAuditStarted });
return (
<div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto">
<div className="mx-auto max-w-5xl space-y-4">
<h1 className="text-2xl font-semibold">Site Audit</h1>
<LaunchFormCard
launchForm={controller.launchForm}
settingsForm={controller.settingsForm}
state={controller.state}
setState={controller.setState}
isPending={controller.startMutation.isPending}
onSubmit={controller.handleSubmit}
onOpenSettings={controller.openSettings}
onRunPsiToggle={controller.onRunPsiToggle}
commitMaxPagesInput={controller.commitMaxPagesInput}
/>
{controller.state.isSettingsOpen && (
<SettingsModal
settingsForm={controller.settingsForm}
state={controller.state}
setState={controller.setState}
onClear={controller.clearPsiKey}
onSave={controller.saveSettings}
/>
)}
<AuditHistorySection
history={controller.historyQuery.data ?? []}
isLoading={controller.historyQuery.isLoading}
onView={onAuditStarted}
onDelete={controller.deleteAudit}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,128 @@
import type {
LaunchState,
SettingsFormApi,
} from "@/client/features/audit/launch/types";
export function SettingsModal({
settingsForm,
state,
setState,
onClear,
onSave,
}: {
settingsForm: SettingsFormApi;
state: LaunchState;
setState: React.Dispatch<React.SetStateAction<LaunchState>>;
onClear: () => void;
onSave: () => void;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/35 p-4">
<div className="card w-full max-w-lg bg-base-100 border border-base-300 shadow-xl">
<div className="card-body gap-4">
<div className="flex items-center justify-between">
<h3 className="card-title text-base">Audit Settings</h3>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() =>
setState((prev) => ({ ...prev, isSettingsOpen: false }))
}
>
Close
</button>
</div>
<div className="space-y-2">
<label className="text-sm text-base-content/70">
Google PageSpeed Insights API Key
</label>
<div className="flex gap-2">
<settingsForm.Field name="psiApiKey">
{(field) => (
<input
type={state.showPsiKey ? "text" : "password"}
className="input input-bordered flex-1"
placeholder="Google API key"
value={field.state.value}
onChange={(event) => {
field.handleChange(event.target.value);
if (state.settingsError || state.psiRequirementError) {
setState((prev) => ({
...prev,
settingsError: null,
psiRequirementError: null,
}));
}
}}
/>
)}
</settingsForm.Field>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() =>
setState((prev) => ({
...prev,
showPsiKey: !prev.showPsiKey,
}))
}
>
{state.showPsiKey ? "Hide" : "Show"}
</button>
</div>
<p className="text-xs text-base-content/50">
Stored on this project and reused by PSI and Site Audit. Required
to run PSI checks in audits.
</p>
<PsiKeyHelp />
{state.settingsError ? (
<p className="text-sm text-error">{state.settingsError}</p>
) : null}
</div>
<div className="flex items-center justify-between">
<button
type="button"
className="btn btn-ghost btn-sm text-error"
onClick={onClear}
>
Clear key
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={onSave}
>
Save settings
</button>
</div>
</div>
</div>
</div>
);
}
function PsiKeyHelp() {
return (
<div className="rounded-md border border-base-300 bg-base-200/30 p-3 text-xs text-base-content/70 space-y-1.5">
<p className="font-medium text-base-content/80">Need a PSI key?</p>
<ol className="list-decimal list-inside space-y-1">
<li>
Open{" "}
<a
className="link link-primary"
href="https://developers.google.com/speed/docs/insights/v5/get-started"
target="_blank"
rel="noopener noreferrer"
>
PageSpeed Insights getting started
</a>{" "}
and click "Get a key".
</li>
<li>Create any Google Cloud project (for example: Open SEO).</li>
<li>Paste the key here and save.</li>
</ol>
</div>
);
}

View File

@ -0,0 +1,31 @@
import { useForm } from "@tanstack/react-form";
export type LaunchState = {
isSettingsOpen: boolean;
showPsiKey: boolean;
urlError: string | null;
psiRequirementError: string | null;
startError: string | null;
settingsError: string | null;
};
export const MIN_PAGES = 10;
export const MAX_PAGES_LIMIT = 10_000;
export function useLaunchForm() {
return useForm({
defaultValues: {
url: "",
maxPagesInput: "50",
runPsi: false,
psiMode: "auto" as "auto" | "all",
},
});
}
export function useSettingsForm() {
return useForm({ defaultValues: { psiApiKey: "" } });
}
export type LaunchFormApi = ReturnType<typeof useLaunchForm>;
export type SettingsFormApi = ReturnType<typeof useSettingsForm>;

View File

@ -0,0 +1,252 @@
import { useEffect, useState, type FormEvent } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import {
deleteAudit,
getAuditHistory,
startAudit,
} from "@/serverFunctions/audit";
import {
clearProjectPsiApiKey,
getProjectPsiApiKey,
saveProjectPsiApiKey,
} from "@/serverFunctions/psi";
import {
MAX_PAGES_LIMIT,
MIN_PAGES,
useLaunchForm,
useSettingsForm,
type LaunchState,
} from "@/client/features/audit/launch/types";
export function useLaunchController({
projectId,
onAuditStarted,
}: {
projectId: string;
onAuditStarted: (auditId: string) => void;
}) {
const launchForm = useLaunchForm();
const settingsForm = useSettingsForm();
const [state, setState] = useState<LaunchState>({
isSettingsOpen: false,
showPsiKey: false,
urlError: null,
psiRequirementError: null,
startError: null,
settingsError: null,
});
const historyQuery = useQuery({
queryKey: ["audit-history", projectId],
queryFn: () => getAuditHistory({ data: { projectId } }),
});
const keyQuery = useQuery({
queryKey: ["projectPsiApiKey", projectId],
queryFn: () => getProjectPsiApiKey({ data: { projectId } }),
});
const { startMutation, deleteMutation, saveKeyMutation, clearKeyMutation } =
useLaunchMutations({
projectId,
historyRefetch: historyQuery.refetch,
keyRefetch: keyQuery.refetch,
clearPsiApiKeyField: () => settingsForm.setFieldValue("psiApiKey", ""),
});
useSyncPsiKeyField(keyQuery.data?.apiKey, settingsForm);
const applyMaxPages = (value: number) => {
const safeValue = Number.isFinite(value)
? Math.max(MIN_PAGES, Math.min(MAX_PAGES_LIMIT, Math.round(value)))
: MIN_PAGES;
launchForm.setFieldValue("maxPagesInput", String(safeValue));
return safeValue;
};
const commitMaxPagesInput = () => {
const maxPagesInput = launchForm.state.values.maxPagesInput;
if (!maxPagesInput) return applyMaxPages(MIN_PAGES);
return applyMaxPages(Number.parseInt(maxPagesInput, 10));
};
const handleStart = () => {
const launchValues = launchForm.state.values;
const settingsValues = settingsForm.state.values;
const effectiveMaxPages = commitMaxPagesInput();
setState((prev) => ({ ...prev, startError: null }));
if (!launchValues.url.trim())
return setState((prev) => ({ ...prev, urlError: "Please enter a URL." }));
if (launchValues.runPsi && !settingsValues.psiApiKey.trim()) {
return setState((prev) => ({
...prev,
psiRequirementError:
"Set a Google PageSpeed Insights API key before running PSI checks.",
isSettingsOpen: true,
}));
}
if (effectiveMaxPages > 500) {
const confirmed = window.confirm(
`You are about to crawl ${effectiveMaxPages.toLocaleString()} pages. This is okay, but it may take a while. Continue?`,
);
if (!confirmed) return;
}
startMutation.mutate(
{
projectId,
startUrl: launchValues.url,
maxPages: effectiveMaxPages,
psiStrategy: launchValues.runPsi ? launchValues.psiMode : "none",
psiApiKey: launchValues.runPsi
? settingsValues.psiApiKey || undefined
: undefined,
},
{
onSuccess: (result) => {
setState((prev) => ({
...prev,
urlError: null,
psiRequirementError: null,
startError: null,
}));
toast.success("Audit started!");
onAuditStarted(result.auditId);
},
onError: (error) => {
setState((prev) => ({
...prev,
startError:
error instanceof Error ? error.message : "Failed to start audit",
}));
},
},
);
};
return {
launchForm,
settingsForm,
state,
setState,
historyQuery,
startMutation,
commitMaxPagesInput,
handleSubmit: (event: FormEvent) => {
event.preventDefault();
handleStart();
},
openSettings: () => setState((prev) => ({ ...prev, isSettingsOpen: true })),
onRunPsiToggle: (checked: boolean) =>
handleRunPsiToggle(checked, launchForm, settingsForm, setState),
saveSettings: () =>
handleSaveSettings(settingsForm, setState, saveKeyMutation.mutate),
clearPsiKey: () => clearKeyMutation.mutate(),
deleteAudit: (auditId: string) => deleteMutation.mutate(auditId),
};
}
function useSyncPsiKeyField(
apiKey: string | null | undefined,
settingsForm: ReturnType<typeof useSettingsForm>,
) {
useEffect(() => {
if (apiKey) {
settingsForm.setFieldValue("psiApiKey", apiKey);
}
}, [apiKey, settingsForm]);
}
function useLaunchMutations({
projectId,
historyRefetch,
keyRefetch,
clearPsiApiKeyField,
}: {
projectId: string;
historyRefetch: () => Promise<unknown>;
keyRefetch: () => Promise<unknown>;
clearPsiApiKeyField: () => void;
}) {
const startMutation = useMutation({
mutationFn: (data: {
projectId: string;
startUrl: string;
maxPages: number;
psiStrategy: "auto" | "all" | "none";
psiApiKey?: string;
}) => startAudit({ data }),
});
const deleteMutation = useMutation({
mutationFn: (auditId: string) => deleteAudit({ data: { auditId } }),
onSuccess: () => {
void historyRefetch();
toast.success("Audit deleted");
},
});
const saveKeyMutation = useMutation({
mutationFn: (apiKey: string) =>
saveProjectPsiApiKey({ data: { projectId, apiKey } }),
onSuccess: async () => {
toast.success("PSI API key saved for this project");
await keyRefetch();
},
});
const clearKeyMutation = useMutation({
mutationFn: () => clearProjectPsiApiKey({ data: { projectId } }),
onSuccess: async () => {
clearPsiApiKeyField();
toast.success("PSI API key cleared");
await keyRefetch();
},
});
return { startMutation, deleteMutation, saveKeyMutation, clearKeyMutation };
}
function handleRunPsiToggle(
checked: boolean,
launchForm: ReturnType<typeof useLaunchForm>,
settingsForm: ReturnType<typeof useSettingsForm>,
setState: React.Dispatch<React.SetStateAction<LaunchState>>,
) {
if (!checked) {
setState((prev) => ({ ...prev, psiRequirementError: null }));
launchForm.setFieldValue("runPsi", false);
return;
}
if (!settingsForm.state.values.psiApiKey.trim()) {
setState((prev) => ({ ...prev, isSettingsOpen: true }));
return;
}
launchForm.setFieldValue("runPsi", true);
}
function handleSaveSettings(
settingsForm: ReturnType<typeof useSettingsForm>,
setState: React.Dispatch<React.SetStateAction<LaunchState>>,
save: (apiKey: string) => void,
) {
const trimmed = settingsForm.state.values.psiApiKey.trim();
if (!trimmed) {
setState((prev) => ({
...prev,
settingsError: "Please enter an API key.",
}));
return;
}
setState((prev) => ({
...prev,
settingsError: null,
psiRequirementError: null,
showPsiKey: false,
isSettingsOpen: false,
}));
save(trimmed);
}

View File

@ -0,0 +1,201 @@
import { ChevronDown, Download, ExternalLink } from "lucide-react";
import {
extractPathname,
HttpStatusBadge,
PsiScoreBadge,
} from "@/client/features/audit/shared";
import type { AuditResultsData } from "@/client/features/audit/results/types";
export function PagesTable({ pages }: { pages: AuditResultsData["pages"] }) {
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) => (
<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>
);
}
export function PerformanceTable({
projectId,
psi,
pages,
}: {
projectId: string;
psi: AuditResultsData["psi"];
pages: AuditResultsData["pages"];
}) {
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>
{psi.map((result) => (
<PerformanceRow
key={result.id}
projectId={projectId}
result={result}
page={pages.find((candidate) => candidate.id === result.pageId)}
/>
))}
</tbody>
</table>
</div>
);
}
function PerformanceRow({
projectId,
result,
page,
}: {
projectId: string;
result: AuditResultsData["psi"][number];
page: AuditResultsData["pages"][number] | undefined;
}) {
const isFailed = !!result.errorMessage;
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 ? (
<span
className="badge badge-error badge-outline text-xs"
title={result.errorMessage ?? "PSI check failed"}
>
failed
</span>
) : (
<span className="badge badge-success badge-outline text-xs">ok</span>
)}
</td>
<td>
<PsiScoreBadge score={result.performanceScore} />
</td>
<td>
<PsiScoreBadge score={result.accessibilityScore} />
</td>
<td>
<PsiScoreBadge 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 ? (
<a
className="btn btn-primary btn-xs"
href={`/p/${projectId}/audit/issues/${result.id}?source=site&category=performance`}
>
View issues
</a>
) : (
<span className="text-xs text-base-content/40">-</span>
)}
</td>
</tr>
);
}
export function ExportDropdown({
onExport,
}: {
onExport: (format: "csv" | "json") => 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-40"
>
<li>
<button onClick={() => onExport("csv")}>CSV</button>
</li>
<li>
<button onClick={() => onExport("json")}>JSON</button>
</li>
</ul>
</div>
);
}

View File

@ -0,0 +1,215 @@
import { useMemo } from "react";
import { StatCard } from "@/client/features/audit/shared";
import {
exportPages,
exportPerformance,
} from "@/client/features/audit/results/export";
import type { AuditResultsData } from "@/client/features/audit/results/types";
import {
ExportDropdown,
PagesTable,
PerformanceTable,
} from "@/client/features/audit/results/ResultsTables";
type SearchSetter = (updates: Record<string, string | undefined>) => void;
export function ResultsView({
projectId,
data,
tab,
setSearchParams,
}: {
projectId: string;
data: AuditResultsData;
tab: string;
setSearchParams: SearchSetter;
}) {
const { audit, pages, psi } = data;
const hasPerformanceTab = psi.length > 0;
const activeTab = hasPerformanceTab ? tab : "pages";
const stats = useResultStats(pages, psi);
return (
<>
<StatsGrid
pagesCrawled={audit.pagesCrawled}
totalPages={pages.length}
totalPsi={psi.length}
averageResponseMs={stats.averageResponseMs}
psiSummary={stats.psiSummary}
/>
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-3">
<ResultsHeader
pageCount={pages.length}
psiCount={psi.length}
hasPerformanceTab={hasPerformanceTab}
activeTab={activeTab}
setSearchParams={setSearchParams}
onExport={(format) => {
if (activeTab === "performance") {
exportPerformance(psi, pages, format);
return;
}
exportPages(pages, format);
}}
/>
{activeTab === "pages" && <PagesTable pages={pages} />}
{activeTab === "performance" && psi.length > 0 && (
<PerformanceTable projectId={projectId} psi={psi} pages={pages} />
)}
</div>
</div>
</>
);
}
function useResultStats(
pages: AuditResultsData["pages"],
psi: AuditResultsData["psi"],
) {
const averageResponseMs = useMemo(() => {
if (pages.length === 0) return 0;
const total = pages.reduce(
(sum, page) => sum + (page.responseTimeMs ?? 0),
0,
);
return Math.round(total / pages.length);
}, [pages]);
const psiSummary = useMemo(() => {
const failed = psi.filter((row) => !!row.errorMessage).length;
const successful = psi.filter((row) => !row.errorMessage);
const averageScore = (
key: "performanceScore" | "seoScore" | "accessibilityScore",
) => {
const values = successful
.map((row) => row[key])
.filter((value): value is number => value != null);
if (values.length === 0) return null;
const total = values.reduce((sum, value) => sum + value, 0);
return Math.round(total / values.length);
};
return {
failed,
avgPerformance: averageScore("performanceScore"),
avgSeo: averageScore("seoScore"),
avgAccessibility: averageScore("accessibilityScore"),
};
}, [psi]);
return { averageResponseMs, psiSummary };
}
function ResultsHeader({
pageCount,
psiCount,
hasPerformanceTab,
activeTab,
setSearchParams,
onExport,
}: {
pageCount: number;
psiCount: number;
hasPerformanceTab: boolean;
activeTab: string;
setSearchParams: SearchSetter;
onExport: (format: "csv" | "json") => void;
}) {
return (
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3">
{hasPerformanceTab ? (
<div role="tablist" className="tabs tabs-box w-fit">
<button
role="tab"
className={`tab ${activeTab === "pages" ? "tab-active" : ""}`}
onClick={() => setSearchParams({ tab: "pages" })}
>
Pages ({pageCount})
</button>
<button
role="tab"
className={`tab ${activeTab === "performance" ? "tab-active" : ""}`}
onClick={() => setSearchParams({ tab: "performance" })}
>
Performance ({psiCount})
</button>
</div>
) : (
<h3 className="text-base font-medium">Pages ({pageCount})</h3>
)}
<ExportDropdown onExport={onExport} />
</div>
);
}
function StatsGrid({
pagesCrawled,
totalPages,
totalPsi,
averageResponseMs,
psiSummary,
}: {
pagesCrawled: number;
totalPages: number;
totalPsi: number;
averageResponseMs: number;
psiSummary: {
failed: number;
avgPerformance: number | null;
avgSeo: number | null;
avgAccessibility: number | null;
};
}) {
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label="Pages Crawled" value={String(pagesCrawled)} />
<StatCard label="Total URLs" value={String(totalPages)} />
<StatCard label="PSI Tests" value={String(totalPsi)} />
<StatCard label="Avg Response" value={`${averageResponseMs}ms`} />
{totalPsi > 0 && (
<>
<StatCard
label="Avg PSI Perf"
value={
psiSummary.avgPerformance == null
? "-"
: String(psiSummary.avgPerformance)
}
className={scoreClass(psiSummary.avgPerformance)}
/>
<StatCard
label="Avg PSI SEO"
value={psiSummary.avgSeo == null ? "-" : String(psiSummary.avgSeo)}
className={scoreClass(psiSummary.avgSeo)}
/>
<StatCard
label="Avg PSI A11y"
value={
psiSummary.avgAccessibility == null
? "-"
: String(psiSummary.avgAccessibility)
}
className={scoreClass(psiSummary.avgAccessibility)}
/>
<StatCard
label="PSI Failures"
value={String(psiSummary.failed)}
className={psiSummary.failed > 0 ? "text-error" : "text-success"}
/>
</>
)}
</div>
);
}
function scoreClass(score: number | null) {
if (score == null) return "";
if (score >= 90) return "text-success";
if (score >= 50) return "text-warning";
return "text-error";
}

View File

@ -0,0 +1,115 @@
import type { AuditResultsData } from "@/client/features/audit/results/types";
import { buildCsv, downloadCsv } from "@/client/lib/csv";
function downloadFile(content: string, filename: string, mime: string) {
const blob = new Blob([content], { type: `${mime};charset=utf-8;` });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
export function exportPages(
pages: AuditResultsData["pages"],
format: "csv" | "json",
) {
const rows = pages.map((page) => ({
url: page.url,
statusCode: page.statusCode,
title: page.title ?? "",
h1Count: page.h1Count,
wordCount: page.wordCount,
imagesTotal: page.imagesTotal,
imagesMissingAlt: page.imagesMissingAlt,
responseTimeMs: page.responseTimeMs,
}));
if (format === "json") {
downloadFile(
JSON.stringify(rows, null, 2),
"audit-pages.json",
"application/json",
);
return;
}
const headers = [
"URL",
"Status",
"Title",
"H1",
"Words",
"Images",
"Missing Alt",
"Response Time (ms)",
];
const lines = rows.map((row) => [
row.url,
row.statusCode,
row.title,
row.h1Count,
row.wordCount,
row.imagesTotal,
row.imagesMissingAlt,
row.responseTimeMs,
]);
downloadCsv("audit-pages.csv", buildCsv(headers, lines));
}
export function exportPerformance(
psi: AuditResultsData["psi"],
pages: AuditResultsData["pages"],
format: "csv" | "json",
) {
const rows = psi.map((result) => {
const page = pages.find((candidate) => candidate.id === result.pageId);
return {
url: page?.url ?? "",
strategy: result.strategy,
performance: result.performanceScore,
accessibility: result.accessibilityScore,
seo: result.seoScore,
lcpMs: result.lcpMs,
cls: result.cls,
inpMs: result.inpMs,
ttfbMs: result.ttfbMs,
};
});
if (format === "json") {
downloadFile(
JSON.stringify(rows, null, 2),
"audit-performance.json",
"application/json",
);
return;
}
const headers = [
"URL",
"Device",
"Performance",
"Accessibility",
"SEO",
"LCP (ms)",
"CLS",
"INP (ms)",
"TTFB (ms)",
];
const lines = rows.map((row) => [
row.url,
row.strategy,
row.performance,
row.accessibility,
row.seo,
row.lcpMs,
row.cls,
row.inpMs,
row.ttfbMs,
]);
downloadCsv("audit-performance.csv", buildCsv(headers, lines));
}

View File

@ -0,0 +1,3 @@
import type { getAuditResults } from "@/serverFunctions/audit";
export type AuditResultsData = Awaited<ReturnType<typeof getAuditResults>>;

View File

@ -0,0 +1,101 @@
import { AlertCircle, CheckCircle, Loader2 } from "lucide-react";
export const SUPPORT_URL = "https://everyapp.dev/support";
export function extractPathname(url: string): string {
try {
return new URL(url).pathname;
} catch {
return url;
}
}
export function extractHostname(url: string): string {
try {
return new URL(url).hostname;
} catch {
return url;
}
}
export function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
export function formatStartedAt(dateStr: string): string {
return new Date(dateStr).toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
export function StatusBadge({ status }: { status: string }) {
if (status === "running") {
return (
<span className="badge badge-info badge-sm gap-1">
<Loader2 className="size-3 animate-spin" /> Running
</span>
);
}
if (status === "completed") {
return (
<span className="badge badge-outline badge-sm gap-1 text-success/80 border-success/30 bg-success/5">
<CheckCircle className="size-3" /> Done
</span>
);
}
return (
<span className="badge badge-error badge-sm gap-1">
<AlertCircle className="size-3" /> Failed
</span>
);
}
export function HttpStatusBadge({ code }: { code: number | null }) {
if (!code) return <span className="badge badge-ghost badge-sm">-</span>;
if (code >= 200 && code < 300) {
return <span className="badge badge-success badge-sm">{code}</span>;
}
if (code >= 300 && code < 400) {
return <span className="badge badge-warning badge-sm">{code}</span>;
}
return <span className="badge badge-error badge-sm">{code}</span>;
}
export function PsiScoreBadge({ score }: { score: number | null }) {
if (score == null) {
return <span className="text-xs text-base-content/40">-</span>;
}
const color =
score >= 90 ? "text-success" : score >= 50 ? "text-warning" : "text-error";
return <span className={`font-medium text-sm ${color}`}>{score}</span>;
}
export function StatCard({
label,
value,
className = "",
}: {
label: string;
value: string;
className?: string;
}) {
return (
<div className="card bg-base-100 border border-base-300">
<div className="card-body p-4">
<p className="text-xs uppercase tracking-wide text-base-content/60">
{label}
</p>
<p className={`text-2xl font-semibold ${className}`}>{value}</p>
</div>
</div>
);
}

View File

@ -0,0 +1,139 @@
import { useQueryClient } from "@tanstack/react-query";
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 { StatCard } from "@/client/features/domain/components/StatCard";
import { useDomainOverviewController } from "@/client/features/domain/useDomainOverviewController";
import {
formatMetric,
getDefaultSortOrder,
} from "@/client/features/domain/utils";
import type {
DomainActiveTab,
DomainSortMode,
SortOrder,
} from "@/client/features/domain/types";
type Props = {
projectId: string;
searchState: {
domain: string;
subdomains: boolean;
sort: DomainSortMode;
order?: SortOrder;
tab: DomainActiveTab;
search: string;
};
navigate: (args: {
search: (prev: Record<string, unknown>) => Record<string, unknown>;
replace: boolean;
}) => void;
};
export function DomainOverviewPage({
projectId,
searchState,
navigate,
}: Props) {
const queryClient = useQueryClient();
const state = useDomainOverviewController({
projectId,
queryClient,
navigate,
searchState,
});
return (
<div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto">
<div className="mx-auto max-w-7xl space-y-4">
<div>
<h1 className="text-2xl font-semibold">Domain Overview</h1>
<p className="text-sm text-base-content/70">
Analyze any domain&apos;s SEO profile: traffic, keywords, and
backlinks.
</p>
</div>
<DomainSearchCard
controlsForm={state.controlsForm}
domainError={state.domainError}
overviewError={state.overviewError}
isLoading={state.isLoading}
onSubmit={state.handleSearchSubmit}
onSortChange={(sort) =>
state.applySort(sort, getDefaultSortOrder(sort))
}
onDomainInput={() => {
if (state.domainError) state.setDomainError(null);
}}
/>
{state.isLoading ? (
<DomainOverviewLoadingState />
) : state.overview === null ? (
<div className="space-y-4 pt-1">
<DomainHistorySection
historyLoaded={state.historyLoaded}
history={state.history}
onClearHistory={state.clearHistory}
onRemoveHistoryItem={state.removeHistoryItem}
onSelectHistoryItem={state.handleHistorySelect}
/>
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<StatCard
label="Estimated Organic Traffic"
value={formatMetric(
state.overview.organicTraffic,
state.overview.hasData,
)}
/>
<StatCard
label="Organic Keywords"
value={formatMetric(
state.overview.organicKeywords,
state.overview.hasData,
)}
/>
</div>
{!state.overview.hasData ? (
<div className="alert alert-info">
<span>
Not enough data for this domain yet. Try another domain or
include subdomains.
</span>
</div>
) : null}
<DomainResultsCard
overview={state.overview}
activeTab={searchState.tab}
sortMode={searchState.sort}
currentSortOrder={state.currentSortOrder}
pendingSearch={state.pendingSearch}
selectedKeywords={state.selectedKeywords}
visibleKeywords={state.visibleKeywords}
filteredKeywords={state.filteredKeywords}
filteredPages={state.filteredPages}
onTabChange={(tab) => {
if (tab === "pages" && searchState.sort === "rank") {
state.applySort("traffic", getDefaultSortOrder("traffic"));
}
state.setSearchParams({ tab });
}}
onSearchChange={state.setPendingSearch}
onSaveKeywords={state.handleSaveKeywords}
onSortClick={state.handleSortColumnClick}
onToggleKeyword={state.toggleKeywordSelection}
onToggleAllVisible={state.toggleAllVisibleKeywords}
/>
</>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,15 @@
import { scoreTierClass } from "@/client/features/keywords/utils";
export function DifficultyBadge({ value }: { value: number | null }) {
if (value == null) {
return <span className="badge badge-ghost">-</span>;
}
return (
<span
className={`score-badge ${scoreTierClass(value)} inline-flex h-6 min-w-6 items-center justify-center rounded-full px-2 text-xs font-semibold`}
>
{value}
</span>
);
}

View File

@ -0,0 +1,90 @@
import { Clock, History, X } from "lucide-react";
import { Globe } from "lucide-react";
import type { DomainHistoryItem } from "@/client/features/domain/types";
type Props = {
historyLoaded: boolean;
history: DomainHistoryItem[];
onClearHistory: () => void;
onRemoveHistoryItem: (timestamp: number) => void;
onSelectHistoryItem: (item: DomainHistoryItem) => void;
};
export function DomainHistorySection({
historyLoaded,
history,
onClearHistory,
onRemoveHistoryItem,
onSelectHistoryItem,
}: Props) {
if (!historyLoaded || history.length === 0) {
return (
<section className="rounded-2xl border border-dashed border-base-300 bg-base-100/70 p-6 text-center text-base-content/55 space-y-2">
<Globe className="size-9 mx-auto opacity-35" />
<p className="text-base font-medium text-base-content/80">
Enter a domain to get started
</p>
</section>
);
}
return (
<section className="rounded-2xl border border-base-300 bg-base-100 p-5 md:p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<History className="size-4 text-base-content/45" />
<span className="text-sm text-base-content/60">
{history.length} recent search{history.length !== 1 ? "es" : ""}
</span>
</div>
<button
className="btn btn-ghost btn-xs text-error"
onClick={onClearHistory}
>
Clear all
</button>
</div>
<div className="grid gap-2">
{history.map((item) => (
<div
key={item.timestamp}
className="flex items-center justify-between p-3 rounded-lg border border-base-300 bg-base-100 hover:bg-base-200 transition-colors text-left group cursor-pointer"
onClick={() => onSelectHistoryItem(item)}
>
<div className="flex items-center gap-3 min-w-0">
<Clock className="size-4 text-base-content/40 shrink-0" />
<div className="min-w-0">
<p className="font-medium text-base-content truncate">
{item.domain}
</p>
<p className="text-sm text-base-content/60 truncate">
{item.subdomains ? "Include subdomains" : "Root domain only"}
{item.search?.trim() ? ` - ${item.search}` : ""}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-base-content/40">
{new Date(item.timestamp).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})}
</span>
<button
type="button"
className="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100 p-1"
onClick={(event) => {
event.stopPropagation();
onRemoveHistoryItem(item.timestamp);
}}
>
<X className="size-3" />
</button>
</div>
</div>
))}
</div>
</section>
);
}

View File

@ -0,0 +1,132 @@
import { HeaderHelpLabel } from "@/client/features/keywords/components";
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 type {
DomainSortMode,
KeywordRow,
SortOrder,
} from "@/client/features/domain/types";
type Props = {
rows: KeywordRow[];
selectedKeywords: Set<string>;
visibleKeywords: string[];
sortMode: DomainSortMode;
currentSortOrder: SortOrder;
onSortClick: (sort: DomainSortMode) => void;
onToggleKeyword: (keyword: string) => void;
onToggleAllVisible: () => void;
};
export function DomainKeywordsTable({
rows,
selectedKeywords,
visibleKeywords,
sortMode,
currentSortOrder,
onSortClick,
onToggleKeyword,
onToggleAllVisible,
}: Props) {
return (
<div className="overflow-x-auto">
<div className="mb-2 text-xs text-base-content/60">
{selectedKeywords.size > 0
? `${selectedKeywords.size} selected`
: "Select keywords to save"}
</div>
<table className="table table-zebra table-sm">
<thead>
<tr>
<th>
<input
type="checkbox"
className="checkbox checkbox-xs"
checked={
visibleKeywords.length > 0 &&
visibleKeywords.every((keyword) =>
selectedKeywords.has(keyword),
)
}
onChange={onToggleAllVisible}
/>
</th>
<th>Keyword</th>
<th>
<SortableHeader
label="Rank"
isActive={sortMode === "rank"}
order={currentSortOrder}
onClick={() => onSortClick("rank")}
/>
</th>
<th>
<SortableHeader
label="Volume"
isActive={sortMode === "volume"}
order={currentSortOrder}
onClick={() => onSortClick("volume")}
/>
</th>
<th>
<SortableHeader
label="Traffic"
isActive={sortMode === "traffic"}
order={currentSortOrder}
onClick={() => onSortClick("traffic")}
/>
</th>
<th>
<HeaderHelpLabel label="CPC" helpText="Cost per click in USD." />
</th>
<th>URL</th>
<th>
<HeaderHelpLabel
label="Score"
helpText="Keyword difficulty score."
/>
</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={8} className="py-6 text-center text-base-content/60">
No keywords match this search.
</td>
</tr>
) : (
rows.slice(0, 100).map((row) => (
<tr key={`${row.keyword}-${row.url ?? ""}`}>
<td>
<input
type="checkbox"
className="checkbox checkbox-xs"
checked={selectedKeywords.has(row.keyword)}
onChange={() => onToggleKeyword(row.keyword)}
aria-label={`Select ${row.keyword}`}
/>
</td>
<td className="font-medium">{row.keyword}</td>
<td>{row.position ?? "-"}</td>
<td>{formatNumber(row.searchVolume)}</td>
<td>{formatFloat(row.traffic)}</td>
<td>{row.cpc == null ? "-" : `$${row.cpc.toFixed(2)}`}</td>
<td
className="max-w-[260px] truncate"
title={row.url ?? undefined}
>
{row.relativeUrl ?? row.url ?? "-"}
</td>
<td>
<DifficultyBadge value={row.keywordDifficulty} />
</td>
</tr>
))
)}
</tbody>
</table>
</div>
);
}

View File

@ -0,0 +1,41 @@
export function DomainOverviewLoadingState() {
return (
<div className="space-y-4" aria-busy>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="card bg-base-100 border border-base-300">
<div className="card-body p-4 space-y-2">
<div className="skeleton h-3 w-36" />
<div className="skeleton h-8 w-44" />
</div>
</div>
<div className="card bg-base-100 border border-base-300">
<div className="card-body p-4 space-y-2">
<div className="skeleton h-3 w-32" />
<div className="skeleton h-8 w-40" />
</div>
</div>
</div>
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-3">
<div className="flex items-center justify-between">
<div className="skeleton h-8 w-48" />
<div className="skeleton h-8 w-60" />
</div>
<div className="skeleton h-9 w-64" />
<div className="space-y-2">
{Array.from({ length: 8 }).map((_, index) => (
<div key={index} className="grid grid-cols-7 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 col-span-2" />
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,72 @@
import { SortableHeader } from "@/client/features/domain/components/SortableHeader";
import {
formatFloat,
formatNumber,
toPageSortMode,
} from "@/client/features/domain/utils";
import type {
DomainSortMode,
PageRow,
SortOrder,
} from "@/client/features/domain/types";
type Props = {
rows: PageRow[];
sortMode: DomainSortMode;
currentSortOrder: SortOrder;
onSortClick: (sort: DomainSortMode) => void;
};
export function DomainPagesTable({
rows,
sortMode,
currentSortOrder,
onSortClick,
}: Props) {
return (
<div className="overflow-x-auto">
<table className="table table-zebra table-sm">
<thead>
<tr>
<th>Page</th>
<th>
<SortableHeader
label="Organic Traffic"
isActive={toPageSortMode(sortMode) === "traffic"}
order={currentSortOrder}
onClick={() => onSortClick("traffic")}
/>
</th>
<th>
<SortableHeader
label="Keywords"
isActive={toPageSortMode(sortMode) === "volume"}
order={currentSortOrder}
onClick={() => onSortClick("volume")}
/>
</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={3} className="py-6 text-center text-base-content/60">
No pages match this search.
</td>
</tr>
) : (
rows.slice(0, 100).map((row) => (
<tr key={row.page}>
<td className="max-w-[420px] truncate" title={row.page}>
{row.relativePath ?? row.page}
</td>
<td>{formatFloat(row.organicTraffic)}</td>
<td>{formatNumber(row.keywords)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
);
}

View File

@ -0,0 +1,204 @@
import {
ChevronDown,
Copy,
Download,
FileSpreadsheet,
Save,
Search,
} from "lucide-react";
import { toast } from "sonner";
import { DomainKeywordsTable } from "@/client/features/domain/components/DomainKeywordsTable";
import { DomainPagesTable } from "@/client/features/domain/components/DomainPagesTable";
import {
downloadCsv,
keywordsToCsv,
pagesToCsv,
} from "@/client/features/domain/utils";
import type {
DomainActiveTab,
DomainOverviewData,
DomainSortMode,
KeywordRow,
PageRow,
SortOrder,
} from "@/client/features/domain/types";
type Props = {
overview: DomainOverviewData;
activeTab: DomainActiveTab;
sortMode: DomainSortMode;
currentSortOrder: SortOrder;
pendingSearch: string;
selectedKeywords: Set<string>;
visibleKeywords: string[];
filteredKeywords: KeywordRow[];
filteredPages: PageRow[];
onTabChange: (tab: DomainActiveTab) => void;
onSearchChange: (value: string) => void;
onSaveKeywords: () => void;
onSortClick: (sort: DomainSortMode) => void;
onToggleKeyword: (keyword: string) => void;
onToggleAllVisible: () => void;
};
export function DomainResultsCard({
overview,
activeTab,
sortMode,
currentSortOrder,
pendingSearch,
selectedKeywords,
visibleKeywords,
filteredKeywords,
filteredPages,
onTabChange,
onSearchChange,
onSaveKeywords,
onSortClick,
onToggleKeyword,
onToggleAllVisible,
}: Props) {
const currentRows =
activeTab === "keywords" ? filteredKeywords : filteredPages;
const handleCopy = async () => {
const text = JSON.stringify(currentRows, null, 2);
await navigator.clipboard.writeText(text);
toast.success("Copied data");
};
const handleDownload = (extension: "csv" | "xls") => {
const rows =
activeTab === "keywords"
? keywordsToCsv(filteredKeywords)
: pagesToCsv(filteredPages);
downloadCsv(rows, `${overview.domain}-${activeTab}.${extension}`);
};
const isKeywordsTab = activeTab === "keywords";
return (
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-3">
<DomainResultsCardHeader
activeTab={activeTab}
selectedKeywordsCount={selectedKeywords.size}
onTabChange={onTabChange}
onSaveKeywords={onSaveKeywords}
onCopy={handleCopy}
onDownload={handleDownload}
/>
<div className="flex justify-end">
<label className="input input-bordered input-sm w-full max-w-xs flex items-center gap-2">
<Search className="size-4 text-base-content/60" />
<input
placeholder="Search in results"
value={pendingSearch}
onChange={(event) => onSearchChange(event.target.value)}
/>
</label>
</div>
{isKeywordsTab ? (
<DomainKeywordsTable
rows={filteredKeywords}
selectedKeywords={selectedKeywords}
visibleKeywords={visibleKeywords}
sortMode={sortMode}
currentSortOrder={currentSortOrder}
onSortClick={onSortClick}
onToggleKeyword={onToggleKeyword}
onToggleAllVisible={onToggleAllVisible}
/>
) : (
<DomainPagesTable
rows={filteredPages}
sortMode={sortMode}
currentSortOrder={currentSortOrder}
onSortClick={onSortClick}
/>
)}
</div>
</div>
);
}
function DomainResultsCardHeader({
activeTab,
selectedKeywordsCount,
onTabChange,
onSaveKeywords,
onCopy,
onDownload,
}: {
activeTab: DomainActiveTab;
selectedKeywordsCount: number;
onTabChange: (tab: DomainActiveTab) => void;
onSaveKeywords: () => void;
onCopy: () => Promise<void>;
onDownload: (extension: "csv" | "xls") => void;
}) {
return (
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3">
<div role="tablist" className="tabs tabs-box w-fit">
<button
role="tab"
className={`tab ${activeTab === "keywords" ? "tab-active" : ""}`}
onClick={() => onTabChange("keywords")}
>
Top Keywords
</button>
<button
role="tab"
className={`tab ${activeTab === "pages" ? "tab-active" : ""}`}
onClick={() => onTabChange("pages")}
>
Top Pages
</button>
</div>
<div className="flex flex-wrap gap-2">
{activeTab === "keywords" ? (
<button
className="btn btn-sm"
onClick={onSaveKeywords}
disabled={selectedKeywordsCount === 0}
>
<Save className="size-4" /> Save Keywords
</button>
) : null}
<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-48"
>
<li>
<button onClick={onCopy}>
<Copy className="size-4" />
Copy data
</button>
</li>
<li>
<button onClick={() => onDownload("csv")}>
<Download className="size-4" />
Download CSV
</button>
</li>
<li>
<button onClick={() => onDownload("xls")}>
<FileSpreadsheet className="size-4" />
Download Excel
</button>
</li>
</ul>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,176 @@
import type { ComponentType, FormEvent, ReactNode } from "react";
import { AlertCircle, Search } from "lucide-react";
import { toSortMode } from "@/client/features/domain/utils";
import type { DomainSortMode } from "@/client/features/domain/types";
type FieldHost = {
Field: ComponentType<{
name: "domain" | "sort" | "subdomains";
children: (field: unknown) => ReactNode;
}>;
};
type TextField = {
state: { value: string };
handleChange: (value: string) => void;
};
type SortField = {
state: { value: DomainSortMode };
handleChange: (value: DomainSortMode) => void;
};
type ToggleField = {
state: { value: boolean };
handleChange: (value: boolean) => void;
};
function isTextField(field: unknown): field is TextField {
if (!field || typeof field !== "object") return false;
const candidate = field as {
state?: { value?: unknown };
handleChange?: unknown;
};
return (
typeof candidate.handleChange === "function" &&
typeof candidate.state?.value === "string"
);
}
function isSortField(field: unknown): field is SortField {
if (!isTextField(field)) return false;
return (
field.state.value === "rank" ||
field.state.value === "traffic" ||
field.state.value === "volume"
);
}
function isToggleField(field: unknown): field is ToggleField {
if (!field || typeof field !== "object") return false;
const candidate = field as {
state?: { value?: unknown };
handleChange?: unknown;
};
return (
typeof candidate.handleChange === "function" &&
typeof candidate.state?.value === "boolean"
);
}
type Props = {
controlsForm: FieldHost;
domainError: string | null;
overviewError: string | null;
isLoading: boolean;
onSubmit: (event: FormEvent) => void;
onSortChange: (sort: DomainSortMode) => void;
onDomainInput: () => void;
};
export function DomainSearchCard({
controlsForm,
domainError,
overviewError,
isLoading,
onSubmit,
onSortChange,
onDomainInput,
}: Props) {
return (
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-4">
<form
className="grid grid-cols-1 gap-3 lg:grid-cols-12"
onSubmit={onSubmit}
>
<label
className={`input input-bordered lg:col-span-8 flex items-center gap-2 ${domainError ? "input-error" : ""}`}
>
<Search className="size-4 text-base-content/60" />
<controlsForm.Field name="domain">
{(field) => {
if (!isTextField(field)) return null;
return (
<input
placeholder="Enter a domain (e.g. coolify.io or example.com/blog)"
value={field.state.value}
onChange={(e) => {
field.handleChange(e.target.value);
onDomainInput();
}}
aria-invalid={domainError ? true : undefined}
aria-describedby={
domainError ? "domain-input-error" : undefined
}
/>
);
}}
</controlsForm.Field>
</label>
<controlsForm.Field name="sort">
{(field) => {
if (!isSortField(field)) return null;
return (
<select
className="select select-bordered lg:col-span-2"
value={field.state.value}
onChange={(e) => {
const next = toSortMode(e.target.value) ?? "rank";
field.handleChange(next);
onSortChange(next);
}}
>
<option value="rank">By Rank</option>
<option value="traffic">By Traffic</option>
<option value="volume">By Volume</option>
</select>
);
}}
</controlsForm.Field>
<button
type="submit"
className="btn btn-primary lg:col-span-2"
disabled={isLoading}
>
{isLoading ? "Loading..." : "Search"}
</button>
</form>
{domainError ? (
<p id="domain-input-error" className="text-sm text-error">
{domainError}
</p>
) : null}
{overviewError ? (
<div className="rounded-lg border border-error/30 bg-error/10 p-3 text-sm text-error flex items-start gap-2">
<AlertCircle className="size-4 shrink-0 mt-0.5" />
<span>{overviewError}</span>
</div>
) : null}
<div className="flex flex-wrap items-center gap-3">
<label className="label cursor-pointer gap-2 py-0">
<controlsForm.Field name="subdomains">
{(field) => {
if (!isToggleField(field)) return null;
return (
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={field.state.value}
onChange={(e) => field.handleChange(e.target.checked)}
/>
);
}}
</controlsForm.Field>
<span className="label-text">Include subdomains</span>
</label>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,30 @@
import { ArrowDown, ArrowUp } from "lucide-react";
import type { SortOrder } from "@/client/features/domain/types";
type Props = {
label: string;
isActive: boolean;
order: SortOrder;
onClick: () => void;
};
export function SortableHeader({ label, isActive, order, onClick }: Props) {
return (
<button
type="button"
className="inline-flex items-center gap-1 font-medium hover:text-base-content"
onClick={onClick}
aria-label={`Sort by ${label}`}
aria-pressed={isActive}
>
<span>{label}</span>
{isActive ? (
order === "asc" ? (
<ArrowUp className="size-3" />
) : (
<ArrowDown className="size-3" />
)
) : null}
</button>
);
}

View File

@ -0,0 +1,12 @@
export function StatCard({ label, value }: { label: string; value: string }) {
return (
<div className="card bg-base-100 border border-base-300">
<div className="card-body p-4">
<p className="text-xs uppercase tracking-wide text-base-content/60">
{label}
</p>
<p className="text-2xl font-semibold">{value}</p>
</div>
</div>
);
}

View File

@ -0,0 +1,64 @@
import { toast } from "sonner";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import type { DomainOverviewData } from "@/client/features/domain/types";
type SaveMutation = (payload: {
projectId: string;
keywords: string[];
locationCode: number;
languageCode: string;
metrics?: Array<{
keyword: string;
searchVolume?: number | null;
cpc?: number | null;
keywordDifficulty?: number | null;
}>;
}) => void;
type SaveOptions = {
onSuccess?: () => void;
onError?: (error: unknown) => void;
};
export function saveSelectedKeywords({
selectedKeywords,
filteredKeywords,
save,
projectId,
}: {
selectedKeywords: Set<string>;
filteredKeywords: DomainOverviewData["keywords"];
save: (payload: Parameters<SaveMutation>[0], opts?: SaveOptions) => void;
projectId: string;
}) {
if (selectedKeywords.size === 0) {
toast.error("Select at least one keyword first");
return;
}
const selectedRows = filteredKeywords.filter((row) =>
selectedKeywords.has(row.keyword),
);
save(
{
projectId,
keywords: [...selectedKeywords],
locationCode: 2840,
languageCode: "en",
metrics: selectedRows.map((row) => ({
keyword: row.keyword,
searchVolume: row.searchVolume,
cpc: row.cpc,
keywordDifficulty: row.keywordDifficulty,
})),
},
{
onSuccess: () => {
toast.success(`Saved ${selectedKeywords.size} keywords`);
},
onError: (error: unknown) => {
toast.error(getStandardErrorMessage(error, "Save failed."));
},
},
);
}

View File

@ -0,0 +1,334 @@
import { useEffect, useMemo, type Dispatch, type SetStateAction } from "react";
import type { UpdateMetaOptions } from "@tanstack/react-form";
import { useMutation } from "@tanstack/react-query";
import { sortBy } from "remeda";
import { toast } from "sonner";
import { getDomainOverview } from "@/serverFunctions/domain";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import {
getDefaultSortOrder,
normalizeDomainTarget,
sortableNullableNumber,
toPageSortMode,
toSortMode,
toSortOrder,
toSortOrderSearchParam,
toSortSearchParam,
} from "@/client/features/domain/utils";
import type {
DomainActiveTab,
DomainOverviewData,
DomainSortMode,
SortOrder,
} from "@/client/features/domain/types";
import type { DomainSearchHistoryItem } from "@/client/hooks/useDomainSearchHistory";
export type SearchState = {
domain: string;
subdomains: boolean;
sort: DomainSortMode;
order?: SortOrder;
tab: DomainActiveTab;
search: string;
};
type DomainNavigate = (args: {
search: (prev: Record<string, unknown>) => Record<string, unknown>;
replace: boolean;
}) => void;
type DomainControlsFormAccess = {
state: {
values: {
domain: string;
subdomains: boolean;
sort: DomainSortMode;
};
};
setFieldValue: (
field: "domain" | "subdomains" | "sort",
updater: string | boolean,
opts?: UpdateMetaOptions,
) => void;
};
type ControlsFormLike = DomainControlsFormAccess;
export function useOverviewDataState({
overview,
pendingSearch,
sortMode,
currentSortOrder,
setSelectedKeywords,
}: {
overview: DomainOverviewData | null;
pendingSearch: string;
sortMode: DomainSortMode;
currentSortOrder: SortOrder;
setSelectedKeywords: Dispatch<SetStateAction<Set<string>>>;
}) {
const filteredKeywords = useMemo(() => {
const source = overview?.keywords ?? [];
const filtered = !pendingSearch
? source
: source.filter((row) => {
const haystack =
`${row.keyword} ${row.relativeUrl ?? ""}`.toLowerCase();
return haystack.includes(pendingSearch.toLowerCase().trim());
});
if (sortMode === "traffic") {
return sortBy(filtered, [
(row) => sortableNullableNumber(row.traffic, currentSortOrder),
currentSortOrder,
]);
}
if (sortMode === "volume") {
return sortBy(filtered, [
(row) => sortableNullableNumber(row.searchVolume, currentSortOrder),
currentSortOrder,
]);
}
return sortBy(filtered, [
(row) => sortableNullableNumber(row.position, currentSortOrder),
currentSortOrder,
]);
}, [currentSortOrder, overview?.keywords, pendingSearch, sortMode]);
const filteredPages = useMemo(() => {
const source = overview?.pages ?? [];
const filtered = !pendingSearch
? source
: source.filter((row) => {
const text = `${row.relativePath ?? ""} ${row.page}`.toLowerCase();
return text.includes(pendingSearch.toLowerCase().trim());
});
const pageSortMode = toPageSortMode(sortMode);
if (pageSortMode === "volume") {
return sortBy(filtered, [
(row) => sortableNullableNumber(row.keywords, currentSortOrder),
currentSortOrder,
]);
}
return sortBy(filtered, [
(row) => sortableNullableNumber(row.organicTraffic, currentSortOrder),
currentSortOrder,
]);
}, [currentSortOrder, overview?.pages, pendingSearch, sortMode]);
const visibleKeywords = useMemo(
() => filteredKeywords.slice(0, 100).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,
filteredPages,
visibleKeywords,
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((k) => prev.has(k))
) {
return new Set();
}
return new Set(visibleKeywords);
});
},
};
}
export function useSyncRouteState({
controlsForm,
searchState,
setPendingSearch,
navigate,
}: {
controlsForm: ControlsFormLike;
searchState: SearchState;
setPendingSearch: (value: string) => void;
navigate: DomainNavigate;
}) {
useEffect(() => {
controlsForm.setFieldValue("domain", searchState.domain);
controlsForm.setFieldValue("subdomains", searchState.subdomains);
controlsForm.setFieldValue("sort", searchState.sort);
setPendingSearch(searchState.search);
}, [controlsForm, searchState, setPendingSearch]);
useEffect(() => {
const raw = new URLSearchParams(window.location.search);
const rawSort = toSortMode(raw.get("sort"));
const rawOrder = toSortOrder(raw.get("order"));
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";
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,
};
},
replace: true,
});
}, [navigate]);
}
export function useDomainLookupMutation({
setOverview,
setOverviewError,
}: {
setOverview: (value: DomainOverviewData) => void;
setOverviewError: (value: string | null) => void;
}) {
return useMutation({
mutationFn: (data: {
domain: string;
includeSubdomains: boolean;
locationCode: number;
languageCode: string;
}) => getDomainOverview({ data }),
onError: (error) => {
setOverviewError(getStandardErrorMessage(error, "Lookup failed."));
},
onSuccess: (response) => {
setOverview(response);
if (!response.hasData) toast.info("Not enough data for this domain");
},
});
}
export function useSearchRunner({
controlsForm,
setDomainError,
setOverviewError,
setPendingSearch,
setSearchParams,
domainMutation,
addSearch,
setOverview,
setSelectedKeywords,
currentState,
currentSortOrder,
}: {
controlsForm: ControlsFormLike;
setDomainError: (value: string | null) => void;
setOverviewError: (value: string | null) => void;
setPendingSearch: (value: string) => void;
setSearchParams: (
updates: Record<string, string | boolean | undefined>,
) => void;
domainMutation: ReturnType<typeof useDomainLookupMutation>["mutate"];
addSearch: (item: Omit<DomainSearchHistoryItem, "timestamp">) => void;
setOverview: (value: DomainOverviewData) => void;
setSelectedKeywords: (value: Set<string>) => void;
currentState: SearchState;
currentSortOrder: SortOrder;
}) {
return (params?: Partial<SearchState>) => {
const values = controlsForm.state.values;
const rawTarget = params?.domain ?? values.domain;
const activeSubdomains = params?.subdomains ?? values.subdomains;
const activeSort = params?.sort ?? currentState.sort;
const activeOrder = params?.order ?? currentSortOrder;
const activeTab = params?.tab ?? currentState.tab;
const activeSearch = params?.search ?? currentState.search;
if (!rawTarget.trim()) {
setDomainError("Please enter a domain");
return;
}
const target = normalizeDomainTarget(rawTarget);
if (!target) {
setDomainError(
"Please enter a valid URL or domain (e.g. browserbase.com)",
);
return;
}
setDomainError(null);
setOverviewError(null);
setPendingSearch(activeSearch);
controlsForm.setFieldValue("domain", target);
controlsForm.setFieldValue("subdomains", activeSubdomains);
controlsForm.setFieldValue("sort", activeSort);
setSearchParams({
domain: target,
subdomains: activeSubdomains ? undefined : activeSubdomains,
sort: toSortSearchParam(activeSort),
order: toSortOrderSearchParam(activeSort, activeOrder),
tab: activeTab === "keywords" ? undefined : activeTab,
search: activeSearch.trim() || undefined,
});
domainMutation(
{
domain: target,
includeSubdomains: activeSubdomains,
locationCode: 2840,
languageCode: "en",
},
{
onSuccess: (response) => {
setOverview(response);
setSelectedKeywords(new Set());
addSearch({
domain: target,
subdomains: activeSubdomains,
sort: activeSort,
tab: activeTab,
search: activeSearch.trim() || undefined,
});
},
onError: (error) => {
setOverviewError(getStandardErrorMessage(error, "Lookup failed."));
},
},
);
};
}

View File

@ -0,0 +1,30 @@
import { useMutation, type QueryClient } from "@tanstack/react-query";
import { saveKeywords } from "@/serverFunctions/keywords";
export function useSaveKeywordsMutation({
projectId,
queryClient,
}: {
projectId: string;
queryClient: QueryClient;
}) {
return useMutation({
mutationFn: (data: {
projectId: string;
keywords: string[];
locationCode: number;
languageCode: string;
metrics?: Array<{
keyword: string;
searchVolume?: number | null;
cpc?: number | null;
keywordDifficulty?: number | null;
}>;
}) => saveKeywords({ data }),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["savedKeywords", projectId],
});
},
});
}

View File

@ -0,0 +1,47 @@
export type KeywordRow = {
keyword: string;
position: number | null;
searchVolume: number | null;
traffic: number | null;
cpc: number | null;
url: string | null;
relativeUrl: string | null;
keywordDifficulty: number | null;
};
export type PageRow = {
page: string;
relativePath: string | null;
organicTraffic: number | null;
keywords: number | null;
};
export type DomainControlsValues = {
domain: string;
subdomains: boolean;
sort: "rank" | "traffic" | "volume";
};
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;
keywords: KeywordRow[];
pages: PageRow[];
};
export type DomainHistoryItem = {
timestamp: number;
domain: string;
subdomains: boolean;
sort: DomainSortMode;
tab: DomainActiveTab;
search?: string;
};

View File

@ -0,0 +1,241 @@
import { useCallback, useEffect, useState, type FormEvent } from "react";
import { type QueryClient } from "@tanstack/react-query";
import { useForm } from "@tanstack/react-form";
import {
useDomainSearchHistory,
type DomainSearchHistoryItem,
} from "@/client/hooks/useDomainSearchHistory";
import {
getDefaultSortOrder,
resolveSortOrder,
toSortOrderSearchParam,
toSortSearchParam,
} from "@/client/features/domain/utils";
import type {
DomainControlsValues,
DomainOverviewData,
DomainSortMode,
SortOrder,
} from "@/client/features/domain/types";
import { saveSelectedKeywords } from "@/client/features/domain/domainActions";
import { useSaveKeywordsMutation } from "@/client/features/domain/mutations";
import {
useDomainLookupMutation,
useOverviewDataState,
useSearchRunner,
useSyncRouteState,
type SearchState,
} from "@/client/features/domain/domainOverviewControllerInternals";
type Params = {
projectId: string;
queryClient: QueryClient;
navigate: (args: {
search: (prev: Record<string, unknown>) => Record<string, unknown>;
replace: boolean;
}) => void;
searchState: SearchState;
};
function useDomainControlsForm(defaultValues: DomainControlsValues) {
return useForm({ defaultValues });
}
export function useDomainOverviewController({
projectId,
queryClient,
navigate,
searchState,
}: Params) {
const [domainError, setDomainError] = useState<string | null>(null);
const [overviewError, setOverviewError] = useState<string | null>(null);
const [pendingSearch, setPendingSearch] = useState(searchState.search);
const [overview, setOverview] = useState<DomainOverviewData | null>(null);
const [selectedKeywords, setSelectedKeywords] = useState<Set<string>>(
new Set(),
);
const controlsForm = useDomainControlsForm({
domain: searchState.domain,
subdomains: searchState.subdomains,
sort: searchState.sort,
});
const { history, isLoaded, addSearch, clearHistory, 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],
);
useSyncRouteState({ controlsForm, searchState, setPendingSearch, navigate });
const domainMutation = useDomainLookupMutation({
setOverview,
setOverviewError,
});
const saveMutation = useSaveKeywordsMutation({ projectId, queryClient });
const dataState = useOverviewDataState({
overview,
pendingSearch,
sortMode: searchState.sort,
currentSortOrder,
setSelectedKeywords,
});
useEffect(() => {
setSearchParams({ search: pendingSearch.trim() || undefined });
}, [pendingSearch, setSearchParams]);
const handlers = useDomainControllerHandlers({
addSearch,
controlsForm,
currentSortOrder,
currentState: searchState,
dataState,
domainMutation: domainMutation.mutate,
projectId,
saveMutation,
selectedKeywords,
setDomainError,
setOverview,
setOverviewError,
setPendingSearch,
setSearchParams,
setSelectedKeywords,
});
return {
controlsForm,
domainError,
setDomainError,
overviewError,
isLoading: domainMutation.isPending,
overview,
history,
historyLoaded: isLoaded,
clearHistory,
removeHistoryItem,
pendingSearch,
setPendingSearch,
selectedKeywords,
currentSortOrder,
setSearchParams,
...handlers,
...dataState,
};
}
function useDomainControllerHandlers({
addSearch,
controlsForm,
currentSortOrder,
currentState,
dataState,
domainMutation,
projectId,
saveMutation,
selectedKeywords,
setDomainError,
setOverview,
setOverviewError,
setPendingSearch,
setSearchParams,
setSelectedKeywords,
}: {
addSearch: (item: Omit<DomainSearchHistoryItem, "timestamp">) => void;
controlsForm: ReturnType<typeof useDomainControlsForm>;
currentSortOrder: SortOrder;
currentState: SearchState;
dataState: ReturnType<typeof useOverviewDataState>;
domainMutation: ReturnType<typeof useDomainLookupMutation>["mutate"];
projectId: string;
saveMutation: ReturnType<typeof useSaveKeywordsMutation>;
selectedKeywords: Set<string>;
setDomainError: (value: string | null) => void;
setOverview: (value: DomainOverviewData | null) => void;
setOverviewError: (value: string | null) => void;
setPendingSearch: (value: string) => void;
setSearchParams: (
updates: Record<string, string | number | boolean | undefined>,
) => void;
setSelectedKeywords: (value: Set<string>) => void;
}) {
const applySort = useCallback(
(nextSort: DomainSortMode, nextOrder: SortOrder) => {
controlsForm.setFieldValue("sort", nextSort);
setSearchParams({
sort: toSortSearchParam(nextSort),
order: toSortOrderSearchParam(nextSort, nextOrder),
});
},
[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,
});
const runSearch = useSearchRunner({
controlsForm,
setDomainError,
setOverviewError,
setPendingSearch,
setSearchParams,
domainMutation,
addSearch,
setOverview: (value) => setOverview(value),
setSelectedKeywords,
currentState,
currentSortOrder,
});
const handleHistorySelect = (item: DomainSearchHistoryItem) => {
runSearch({
domain: item.domain,
subdomains: item.subdomains,
sort: item.sort,
order: getDefaultSortOrder(item.sort),
tab: item.tab,
search: item.search ?? "",
});
};
const handleSearchSubmit = (event: FormEvent) => {
event.preventDefault();
runSearch();
};
return {
applySort,
handleSortColumnClick,
handleSaveKeywords,
runSearch,
handleSearchSubmit,
handleHistorySelect,
};
}

View File

@ -0,0 +1,134 @@
import type {
DomainSortMode,
KeywordRow,
PageRow,
SortOrder,
} from "@/client/features/domain/types";
import { buildCsv, downloadCsv as downloadCsvFile } from "@/client/lib/csv";
export function toSortMode(value: string | null): DomainSortMode | undefined {
if (value === "rank" || value === "traffic" || value === "volume") {
return value;
}
return undefined;
}
export function toSortOrder(value: string | null): SortOrder | undefined {
if (value === "asc" || value === "desc") return value;
return undefined;
}
export function getDefaultSortOrder(sortMode: DomainSortMode): SortOrder {
return sortMode === "rank" ? "asc" : "desc";
}
export function resolveSortOrder(
sortMode: DomainSortMode,
sortOrder: SortOrder | undefined,
): SortOrder {
return sortOrder ?? getDefaultSortOrder(sortMode);
}
export function toSortSearchParam(
sortMode: DomainSortMode,
): DomainSortMode | undefined {
return sortMode === "rank" ? undefined : sortMode;
}
export function toSortOrderSearchParam(
sortMode: DomainSortMode,
sortOrder: SortOrder,
): SortOrder | undefined {
return sortOrder === getDefaultSortOrder(sortMode) ? undefined : sortOrder;
}
export function sortableNullableNumber(
value: number | null | undefined,
order: SortOrder,
): number {
if (value != null) return value;
return order === "asc" ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY;
}
export function toPageSortMode(
sortMode: DomainSortMode,
): Exclude<DomainSortMode, "rank"> {
if (sortMode === "rank") return "traffic";
return sortMode;
}
export function normalizeDomainTarget(input: string): string | null {
const value = input.trim();
if (!value) return null;
const withProtocol = /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(value)
? value
: `https://${value}`;
try {
const parsed = new URL(withProtocol);
const hostname = parsed.hostname.toLowerCase();
if (!hostname || !hostname.includes(".")) return null;
if (!/^[a-z\d.-]+$/.test(hostname)) return null;
const path = parsed.pathname === "/" ? "" : parsed.pathname;
return `${hostname}${path}`;
} catch {
return null;
}
}
export function formatNumber(value: number | null | undefined) {
if (value == null) return "-";
return new Intl.NumberFormat().format(value);
}
export function formatFloat(value: number | null | undefined) {
if (value == null) return "-";
if (value > 100) return new Intl.NumberFormat().format(Math.round(value));
return value.toFixed(2);
}
export function formatMetric(
value: number | null | undefined,
hasData: boolean | undefined,
) {
if (!hasData) return "Not enough data";
return formatNumber(value);
}
export function keywordsToCsv(rows: KeywordRow[]): string {
const headers = [
"Keyword",
"Rank",
"Volume",
"Traffic",
"CPC",
"URL",
"Score",
];
const lines = rows.map((row) => [
row.keyword,
row.position,
row.searchVolume,
row.traffic,
row.cpc,
row.relativeUrl ?? row.url,
row.keywordDifficulty,
]);
return buildCsv(headers, lines);
}
export function pagesToCsv(rows: PageRow[]): string {
const headers = ["Page", "Organic Traffic", "Keywords"];
const lines = rows.map((row) => [
row.relativePath ?? row.page,
row.organicTraffic,
row.keywords,
]);
return buildCsv(headers, lines);
}
export function downloadCsv(content: string, filename: string) {
downloadCsvFile(filename, content);
}

View File

@ -1,666 +0,0 @@
import {
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
ExternalLink,
Minus,
TrendingDown,
TrendingUp,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { sortBy } from "remeda";
import {
Area,
AreaChart,
CartesianGrid,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type {
KeywordIntent,
KeywordResearchRow,
MonthlySearch,
SerpResultItem,
} from "@/types/keywords";
import { formatNumber, scoreTierClass } from "./utils";
export type SortField =
| "keyword"
| "searchVolume"
| "cpc"
| "competition"
| "keywordDifficulty";
export type SortDir = "asc" | "desc";
export function HeaderHelpLabel({
label,
helpText,
delayMs = 150,
}: {
label: string;
helpText: string;
delayMs?: number;
}) {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const openTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const triggerRef = useRef<HTMLSpanElement | null>(null);
const updatePosition = () => {
const rect = triggerRef.current?.getBoundingClientRect();
if (!rect) return;
setPosition({
top: rect.top - 8,
left: rect.left + rect.width / 2,
});
};
const clearOpenTimeout = () => {
if (openTimeoutRef.current) {
clearTimeout(openTimeoutRef.current);
openTimeoutRef.current = null;
}
};
const scheduleOpen = () => {
clearOpenTimeout();
openTimeoutRef.current = setTimeout(() => {
updatePosition();
setIsOpen(true);
openTimeoutRef.current = null;
}, delayMs);
};
const closeNow = () => {
clearOpenTimeout();
setIsOpen(false);
};
useEffect(() => clearOpenTimeout, []);
useEffect(() => {
if (!isOpen) return;
updatePosition();
const handleReposition = () => updatePosition();
window.addEventListener("resize", handleReposition);
window.addEventListener("scroll", handleReposition, true);
return () => {
window.removeEventListener("resize", handleReposition);
window.removeEventListener("scroll", handleReposition, true);
};
}, [isOpen]);
return (
<span
ref={triggerRef}
className="relative inline-flex items-center"
onMouseEnter={scheduleOpen}
onMouseLeave={closeNow}
onFocus={scheduleOpen}
onBlur={closeNow}
onKeyDown={(e) => {
if (e.key === "Escape") closeNow();
}}
>
<span>{label}</span>
{isOpen && typeof document !== "undefined"
? createPortal(
<span
role="tooltip"
className="pointer-events-none fixed z-[1000] w-max max-w-56 -translate-x-1/2 -translate-y-full rounded-md border border-base-300 bg-base-100 px-2 py-1 text-[11px] font-normal normal-case leading-snug text-base-content shadow-md"
style={{ left: position.left, top: position.top }}
>
{helpText}
</span>,
document.body,
)
: null}
</span>
);
}
export function OverviewStats({ keyword }: { keyword: KeywordResearchRow }) {
return (
<div className="shrink-0 bg-base-100 border border-base-300 rounded-xl px-4 py-2.5 flex items-center gap-4 min-h-[48px]">
<div className="flex items-center gap-2 min-w-0 shrink-0">
<span className="font-bold text-base truncate max-w-[240px] capitalize">
{keyword.keyword}
</span>
<ScoreBadge value={keyword.keywordDifficulty} size="sm" />
</div>
<div className="w-px h-6 bg-base-300 shrink-0" />
<div className="flex items-center gap-4 text-sm flex-wrap min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-base-content/50">Vol</span>
<span className="font-semibold tabular-nums">
{formatNumber(keyword.searchVolume)}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-base-content/50">CPC</span>
<span className="font-semibold tabular-nums">
{keyword.cpc == null ? "-" : `$${keyword.cpc.toFixed(2)}`}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-base-content/50">Comp</span>
<span className="font-semibold tabular-nums">
{keyword.competition == null ? "-" : keyword.competition.toFixed(2)}
</span>
</div>
<IntentBadge intent={keyword.intent} />
</div>
</div>
);
}
export function KeywordRow({
row,
isSelected,
isActive,
onToggle,
onClick,
}: {
row: KeywordResearchRow;
isSelected: boolean;
isActive: boolean;
onToggle: () => void;
onClick: () => void;
}) {
return (
<div
className={`flex items-center gap-3 px-4 py-2 border-b border-base-200 text-sm hover:bg-base-200/50 transition-colors cursor-pointer ${
isActive ? "bg-primary/5 border-l-2 border-l-primary" : ""
}`}
onClick={onClick}
>
<input
type="checkbox"
className="checkbox checkbox-xs shrink-0"
checked={isSelected}
onChange={(e) => {
e.stopPropagation();
onToggle();
}}
onClick={(e) => e.stopPropagation()}
/>
<span
className="flex-1 min-w-0 truncate font-medium capitalize"
title={row.keyword}
>
{row.keyword}
</span>
<span className="w-16 text-right tabular-nums text-base-content/70">
{formatNumber(row.searchVolume)}
</span>
<span className="w-14 text-right tabular-nums text-base-content/70">
{row.cpc == null ? "-" : row.cpc.toFixed(2)}
</span>
<span className="w-12 text-right tabular-nums text-base-content/70">
{row.competition == null ? "-" : row.competition.toFixed(2)}
</span>
<div className="w-10 flex justify-end">
<ScoreBadge value={row.keywordDifficulty} size="sm" />
</div>
</div>
);
}
export function KeywordCard({
row,
isSelected,
isActive,
onToggle,
onClick,
}: {
row: KeywordResearchRow;
isSelected: boolean;
isActive: boolean;
onToggle: () => void;
onClick: () => void;
}) {
return (
<div
className={`bg-base-100 border border-base-300 rounded-lg p-3 space-y-2 cursor-pointer transition-colors ${
isActive ? "border-primary bg-primary/5" : "hover:bg-base-200/50"
}`}
onClick={onClick}
>
<div className="flex items-start gap-2">
<input
type="checkbox"
className="checkbox checkbox-sm shrink-0 mt-0.5"
checked={isSelected}
onChange={(e) => {
e.stopPropagation();
onToggle();
}}
onClick={(e) => e.stopPropagation()}
/>
<span className="flex-1 font-semibold text-sm capitalize leading-tight">
{row.keyword}
</span>
<ScoreBadge value={row.keywordDifficulty} size="sm" />
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="text-center">
<p className="text-base-content/50">Volume</p>
<p className="font-medium tabular-nums">
{formatNumber(row.searchVolume)}
</p>
</div>
<div className="text-center">
<p className="text-base-content/50">CPC</p>
<p className="font-medium tabular-nums">
{row.cpc == null ? "-" : `$${row.cpc.toFixed(2)}`}
</p>
</div>
<div className="text-center">
<p className="text-base-content/50">Comp.</p>
<p className="font-medium tabular-nums">
{row.competition == null ? "-" : row.competition.toFixed(2)}
</p>
</div>
</div>
<div className="flex items-center justify-between pt-1">
<IntentBadge intent={row.intent} />
</div>
</div>
);
}
export function SerpAnalysisCard({
items,
loading,
error,
onRetry,
page,
pageSize,
onPageChange,
}: {
items: SerpResultItem[];
loading: boolean;
error?: string | null;
onRetry?: () => void;
page: number;
pageSize: number;
onPageChange: (p: number) => void;
}) {
const totalPages = Math.ceil(items.length / pageSize);
const pageItems = items.slice(page * pageSize, (page + 1) * pageSize);
return (
<div>
{loading ? (
<div className="space-y-3" aria-busy>
<div className="skeleton h-3 w-40" />
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="grid grid-cols-[24px_minmax(0,1fr)_72px_92px_82px_56px] items-center gap-2"
>
<div className="skeleton h-3 w-4" />
<div className="space-y-1">
<div className="skeleton h-3 w-10/12" />
<div className="skeleton h-2.5 w-7/12" />
</div>
<div className="skeleton h-3 w-12 justify-self-end" />
<div className="skeleton h-3 w-16 justify-self-end" />
<div className="skeleton h-3 w-16 justify-self-end" />
<div className="skeleton h-3 w-10 justify-self-center" />
</div>
))}
</div>
</div>
) : error ? (
<div className="rounded-lg border border-error/30 bg-error/10 p-3 text-sm text-error space-y-2">
<p>{error}</p>
{onRetry ? (
<button className="btn btn-xs" onClick={onRetry}>
Retry
</button>
) : null}
</div>
) : items.length === 0 ? (
<div className="text-center py-6 text-base-content/40 text-sm">
No SERP data available for this keyword
</div>
) : (
<>
<div className="text-xs text-base-content/50 mb-3">
{items.length} organic results
</div>
<div className="overflow-x-auto">
<table className="table table-xs w-full">
<thead>
<tr className="text-xs text-base-content/60">
<th className="w-8">#</th>
<th>Page</th>
<th className="text-right w-20">Traffic</th>
<th className="text-right w-20">Ref. Domains</th>
<th className="text-right w-20">Backlinks</th>
<th className="text-center w-16">Change</th>
</tr>
</thead>
<tbody>
{pageItems.map((item) => (
<tr
key={`${item.rank}-${item.url}`}
className="hover:bg-base-200/50"
>
<td className="font-mono text-base-content/50 text-xs">
{item.rank}
</td>
<td className="max-w-[280px]">
<div className="flex flex-col gap-0.5">
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-primary hover:underline truncate flex items-center gap-1"
title={item.title}
>
{item.title || item.url}
<ExternalLink className="size-3 shrink-0 opacity-40" />
</a>
<span className="text-xs text-base-content/40 truncate">
{item.domain}
</span>
</div>
</td>
<td className="text-right tabular-nums text-base-content/70">
{formatNumber(item.etv)}
</td>
<td className="text-right tabular-nums text-base-content/70">
{formatNumber(item.referringDomains)}
</td>
<td className="text-right tabular-nums text-base-content/70">
{formatNumber(item.backlinks)}
</td>
<td className="text-center">
<RankChangeBadge
change={item.rankChange}
isNew={item.isNew}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between mt-3 pt-3 border-t border-base-200">
<span className="text-xs text-base-content/50">
Page {page + 1} of {totalPages}
</span>
<div className="flex gap-1">
<button
className="btn btn-ghost btn-xs"
disabled={page === 0}
onClick={() => onPageChange(page - 1)}
>
<ChevronLeft className="size-3.5" />
Prev
</button>
<button
className="btn btn-ghost btn-xs"
disabled={page >= totalPages - 1}
onClick={() => onPageChange(page + 1)}
>
Next
<ChevronRight className="size-3.5" />
</button>
</div>
</div>
)}
</>
)}
</div>
);
}
function RankChangeBadge({
change,
isNew,
}: {
change: number | null;
isNew: boolean;
}) {
if (isNew) {
return <span className="badge badge-xs badge-success">NEW</span>;
}
if (change == null || change === 0) {
return <Minus className="size-3 text-base-content/30 mx-auto" />;
}
if (change > 0) {
return (
<span className="inline-flex items-center gap-0.5 text-success text-xs font-medium">
<TrendingUp className="size-3" />+{change}
</span>
);
}
return (
<span className="inline-flex items-center gap-0.5 text-error text-xs font-medium">
<TrendingDown className="size-3" />
{change}
</span>
);
}
function ScoreBadge({
value,
size = "sm",
}: {
value: number | null;
size?: "sm" | "lg";
}) {
if (value == null) return null;
const tierClass = scoreTierClass(value);
const sizeClasses =
size === "lg"
? "size-9 text-sm font-bold"
: "size-6 text-[10px] font-semibold";
return (
<span
className={`score-badge ${tierClass} inline-flex items-center justify-center rounded-full ${sizeClasses}`}
>
{value}
</span>
);
}
export function AreaTrendChart({ trend }: { trend: MonthlySearch[] }) {
const sorted = sortBy(trend, (item) => item.year * 100 + item.month);
const last12 = sorted.slice(-12);
const containerRef = useRef<HTMLDivElement | null>(null);
const [chartWidth, setChartWidth] = useState(0);
if (last12.length === 0) return null;
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const update = () => {
setChartWidth(container.clientWidth);
};
update();
const observer = new ResizeObserver(update);
observer.observe(container);
return () => {
observer.disconnect();
};
}, []);
const monthLabels = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const data = last12.map((m) => ({
month: monthLabels[m.month - 1],
year: m.year,
searchVolume: m.searchVolume,
label: `${monthLabels[m.month - 1]} ${m.year}`,
}));
return (
<div
ref={containerRef}
className="w-full h-[210px] min-w-0"
aria-label="Search trend chart"
>
{chartWidth > 0 ? (
<AreaChart
width={chartWidth}
height={210}
data={data}
margin={{ top: 8, right: 8, left: 0, bottom: 4 }}
accessibilityLayer
>
<defs>
<linearGradient id="trendGrad" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor="var(--color-primary)"
stopOpacity="var(--trend-fill-start-opacity)"
/>
<stop
offset="100%"
stopColor="var(--color-primary)"
stopOpacity="var(--trend-fill-end-opacity)"
/>
</linearGradient>
</defs>
<CartesianGrid
stroke="var(--trend-grid-color)"
strokeDasharray="2 4"
vertical={true}
horizontal={true}
/>
<XAxis
dataKey="month"
tick={{ fill: "var(--trend-axis-color)", fontSize: 11 }}
axisLine={false}
tickLine={false}
/>
<YAxis
tickFormatter={(value: number | string) =>
formatNumber(Number(value))
}
tick={{ fill: "var(--trend-axis-color)", fontSize: 11 }}
width={56}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--trend-tooltip-bg)",
border: "1px solid var(--trend-tooltip-border)",
borderRadius: "10px",
boxShadow: "0 8px 24px var(--trend-tooltip-shadow)",
color: "var(--color-base-content)",
}}
/>
<Area
type="monotone"
dataKey="searchVolume"
name="Search volume"
stroke="var(--color-primary)"
strokeWidth={2}
fill="url(#trendGrad)"
isAnimationActive={false}
dot={{ r: 3, fill: "var(--color-primary)", strokeWidth: 0 }}
activeDot={{ r: 5, fill: "var(--color-primary)" }}
/>
</AreaChart>
) : null}
</div>
);
}
export function SortHeader({
label,
helpText,
field,
current,
dir,
onToggle,
className,
}: {
label: string;
helpText?: string;
field: SortField;
current: SortField;
dir: SortDir;
onToggle: (f: SortField) => void;
className?: string;
}) {
const isActive = field === current;
return (
<button
className={`inline-flex items-center gap-0.5 hover:text-primary transition-colors cursor-pointer select-none ${className ?? ""}`}
onClick={() => onToggle(field)}
>
{helpText ? <HeaderHelpLabel label={label} helpText={helpText} /> : label}
{isActive &&
(dir === "asc" ? (
<ChevronUp className="size-3" />
) : (
<ChevronDown className="size-3" />
))}
</button>
);
}
function IntentBadge({ intent }: { intent: KeywordIntent }) {
const colors: Record<KeywordIntent, string> = {
informational: "badge-info",
commercial: "badge-warning",
transactional: "badge-success",
navigational: "badge-primary",
unknown: "badge-ghost",
};
const shortLabels: Record<KeywordIntent, string> = {
informational: "Info",
commercial: "Comm",
transactional: "Trans",
navigational: "Nav",
unknown: "?",
};
return (
<span className={`badge badge-sm ${colors[intent]}`}>
{shortLabels[intent]}
</span>
);
}

View File

@ -0,0 +1,290 @@
import { ChevronDown, ChevronUp } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { sortBy } from "remeda";
import {
Area,
AreaChart,
CartesianGrid,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { KeywordIntent, MonthlySearch } from "@/types/keywords";
import { formatNumber } from "../utils";
export type SortField =
| "keyword"
| "searchVolume"
| "cpc"
| "competition"
| "keywordDifficulty";
export type SortDir = "asc" | "desc";
export function HeaderHelpLabel({
label,
helpText,
delayMs = 150,
}: {
label: string;
helpText: string;
delayMs?: number;
}) {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const openTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const triggerRef = useRef<HTMLSpanElement | null>(null);
const updatePosition = () => {
const rect = triggerRef.current?.getBoundingClientRect();
if (!rect) return;
setPosition({
top: rect.top - 8,
left: rect.left + rect.width / 2,
});
};
const clearOpenTimeout = () => {
if (openTimeoutRef.current) {
clearTimeout(openTimeoutRef.current);
openTimeoutRef.current = null;
}
};
const scheduleOpen = () => {
clearOpenTimeout();
openTimeoutRef.current = setTimeout(() => {
updatePosition();
setIsOpen(true);
openTimeoutRef.current = null;
}, delayMs);
};
const closeNow = () => {
clearOpenTimeout();
setIsOpen(false);
};
useEffect(() => clearOpenTimeout, []);
useEffect(() => {
if (!isOpen) return;
updatePosition();
const handleReposition = () => updatePosition();
window.addEventListener("resize", handleReposition);
window.addEventListener("scroll", handleReposition, true);
return () => {
window.removeEventListener("resize", handleReposition);
window.removeEventListener("scroll", handleReposition, true);
};
}, [isOpen]);
return (
<span
ref={triggerRef}
className="relative inline-flex items-center"
onMouseEnter={scheduleOpen}
onMouseLeave={closeNow}
onFocus={scheduleOpen}
onBlur={closeNow}
onKeyDown={(e) => {
if (e.key === "Escape") closeNow();
}}
>
<span>{label}</span>
{isOpen && typeof document !== "undefined"
? createPortal(
<span
role="tooltip"
className="pointer-events-none fixed z-[1000] w-max max-w-56 -translate-x-1/2 -translate-y-full rounded-md border border-base-300 bg-base-100 px-2 py-1 text-[11px] font-normal normal-case leading-snug text-base-content shadow-md"
style={{ left: position.left, top: position.top }}
>
{helpText}
</span>,
document.body,
)
: null}
</span>
);
}
export function AreaTrendChart({ trend }: { trend: MonthlySearch[] }) {
const sorted = sortBy(trend, (item) => item.year * 100 + item.month);
const last12 = sorted.slice(-12);
const containerRef = useRef<HTMLDivElement | null>(null);
const [chartWidth, setChartWidth] = useState(0);
if (last12.length === 0) return null;
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const update = () => {
setChartWidth(container.clientWidth);
};
update();
const observer = new ResizeObserver(update);
observer.observe(container);
return () => {
observer.disconnect();
};
}, []);
const monthLabels = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const data = last12.map((m) => ({
month: monthLabels[m.month - 1],
year: m.year,
searchVolume: m.searchVolume,
label: `${monthLabels[m.month - 1]} ${m.year}`,
}));
return (
<div
ref={containerRef}
className="w-full h-[210px] min-w-0"
aria-label="Search trend chart"
>
{chartWidth > 0 ? (
<AreaChart
width={chartWidth}
height={210}
data={data}
margin={{ top: 8, right: 8, left: 0, bottom: 4 }}
accessibilityLayer
>
<defs>
<linearGradient id="trendGrad" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor="var(--color-primary)"
stopOpacity="var(--trend-fill-start-opacity)"
/>
<stop
offset="100%"
stopColor="var(--color-primary)"
stopOpacity="var(--trend-fill-end-opacity)"
/>
</linearGradient>
</defs>
<CartesianGrid
stroke="var(--trend-grid-color)"
strokeDasharray="2 4"
vertical={true}
horizontal={true}
/>
<XAxis
dataKey="month"
tick={{ fill: "var(--trend-axis-color)", fontSize: 11 }}
axisLine={false}
tickLine={false}
/>
<YAxis
tickFormatter={(value: number | string) =>
formatNumber(Number(value))
}
tick={{ fill: "var(--trend-axis-color)", fontSize: 11 }}
width={56}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--trend-tooltip-bg)",
border: "1px solid var(--trend-tooltip-border)",
borderRadius: "10px",
boxShadow: "0 8px 24px var(--trend-tooltip-shadow)",
color: "var(--color-base-content)",
}}
/>
<Area
type="monotone"
dataKey="searchVolume"
name="Search volume"
stroke="var(--color-primary)"
strokeWidth={2}
fill="url(#trendGrad)"
isAnimationActive={false}
dot={{ r: 3, fill: "var(--color-primary)", strokeWidth: 0 }}
activeDot={{ r: 5, fill: "var(--color-primary)" }}
/>
</AreaChart>
) : null}
</div>
);
}
export function SortHeader({
label,
helpText,
field,
current,
dir,
onToggle,
className,
}: {
label: string;
helpText?: string;
field: SortField;
current: SortField;
dir: SortDir;
onToggle: (f: SortField) => void;
className?: string;
}) {
const isActive = field === current;
return (
<button
className={`inline-flex items-center gap-0.5 hover:text-primary transition-colors cursor-pointer select-none ${className ?? ""}`}
onClick={() => onToggle(field)}
>
{helpText ? <HeaderHelpLabel label={label} helpText={helpText} /> : label}
{isActive &&
(dir === "asc" ? (
<ChevronUp className="size-3" />
) : (
<ChevronDown className="size-3" />
))}
</button>
);
}
export function IntentBadge({ intent }: { intent: KeywordIntent }) {
const colors: Record<KeywordIntent, string> = {
informational: "badge-info",
commercial: "badge-warning",
transactional: "badge-success",
navigational: "badge-primary",
unknown: "badge-ghost",
};
const shortLabels: Record<KeywordIntent, string> = {
informational: "Info",
commercial: "Comm",
transactional: "Trans",
navigational: "Nav",
unknown: "?",
};
return (
<span className={`badge badge-sm ${colors[intent]}`}>
{shortLabels[intent]}
</span>
);
}

View File

@ -0,0 +1,192 @@
import type { KeywordResearchRow } from "@/types/keywords";
import { formatNumber, scoreTierClass } from "../utils";
import { IntentBadge } from "./DisplayPrimitives";
export { SerpAnalysisCard } from "./SerpAnalysisCard";
export type { SortDir, SortField } from "./DisplayPrimitives";
export {
AreaTrendChart,
HeaderHelpLabel,
SortHeader,
} from "./DisplayPrimitives";
export function OverviewStats({ keyword }: { keyword: KeywordResearchRow }) {
return (
<div className="shrink-0 bg-base-100 border border-base-300 rounded-xl px-4 py-2.5 flex items-center gap-4 min-h-[48px]">
<div className="flex items-center gap-2 min-w-0 shrink-0">
<span className="font-bold text-base truncate max-w-[240px] capitalize">
{keyword.keyword}
</span>
<ScoreBadge value={keyword.keywordDifficulty} size="sm" />
</div>
<div className="w-px h-6 bg-base-300 shrink-0" />
<div className="flex items-center gap-4 text-sm flex-wrap min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-base-content/50">Vol</span>
<span className="font-semibold tabular-nums">
{formatNumber(keyword.searchVolume)}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-base-content/50">CPC</span>
<span className="font-semibold tabular-nums">
{keyword.cpc == null ? "-" : `$${keyword.cpc.toFixed(2)}`}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-base-content/50">Comp</span>
<span className="font-semibold tabular-nums">
{keyword.competition == null ? "-" : keyword.competition.toFixed(2)}
</span>
</div>
<IntentBadge intent={keyword.intent} />
</div>
</div>
);
}
export function KeywordRow({
row,
isSelected,
isActive,
onToggle,
onClick,
}: {
row: KeywordResearchRow;
isSelected: boolean;
isActive: boolean;
onToggle: () => void;
onClick: () => void;
}) {
return (
<div
className={`flex items-center gap-3 px-4 py-2 border-b border-base-200 text-sm hover:bg-base-200/50 transition-colors cursor-pointer ${
isActive ? "bg-primary/5 border-l-2 border-l-primary" : ""
}`}
onClick={onClick}
>
<input
type="checkbox"
className="checkbox checkbox-xs shrink-0"
checked={isSelected}
onChange={(e) => {
e.stopPropagation();
onToggle();
}}
onClick={(e) => e.stopPropagation()}
/>
<span
className="flex-1 min-w-0 truncate font-medium capitalize"
title={row.keyword}
>
{row.keyword}
</span>
<span className="w-16 text-right tabular-nums text-base-content/70">
{formatNumber(row.searchVolume)}
</span>
<span className="w-14 text-right tabular-nums text-base-content/70">
{row.cpc == null ? "-" : row.cpc.toFixed(2)}
</span>
<span className="w-12 text-right tabular-nums text-base-content/70">
{row.competition == null ? "-" : row.competition.toFixed(2)}
</span>
<div className="w-10 flex justify-end">
<ScoreBadge value={row.keywordDifficulty} size="sm" />
</div>
</div>
);
}
export function KeywordCard({
row,
isSelected,
isActive,
onToggle,
onClick,
}: {
row: KeywordResearchRow;
isSelected: boolean;
isActive: boolean;
onToggle: () => void;
onClick: () => void;
}) {
return (
<div
className={`bg-base-100 border border-base-300 rounded-lg p-3 space-y-2 cursor-pointer transition-colors ${
isActive ? "border-primary bg-primary/5" : "hover:bg-base-200/50"
}`}
onClick={onClick}
>
<div className="flex items-start gap-2">
<input
type="checkbox"
className="checkbox checkbox-sm shrink-0 mt-0.5"
checked={isSelected}
onChange={(e) => {
e.stopPropagation();
onToggle();
}}
onClick={(e) => e.stopPropagation()}
/>
<span className="flex-1 font-semibold text-sm capitalize leading-tight">
{row.keyword}
</span>
<ScoreBadge value={row.keywordDifficulty} size="sm" />
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="text-center">
<p className="text-base-content/50">Volume</p>
<p className="font-medium tabular-nums">
{formatNumber(row.searchVolume)}
</p>
</div>
<div className="text-center">
<p className="text-base-content/50">CPC</p>
<p className="font-medium tabular-nums">
{row.cpc == null ? "-" : `$${row.cpc.toFixed(2)}`}
</p>
</div>
<div className="text-center">
<p className="text-base-content/50">Comp.</p>
<p className="font-medium tabular-nums">
{row.competition == null ? "-" : row.competition.toFixed(2)}
</p>
</div>
</div>
<div className="flex items-center justify-between pt-1">
<IntentBadge intent={row.intent} />
</div>
</div>
);
}
function ScoreBadge({
value,
size = "sm",
}: {
value: number | null;
size?: "sm" | "lg";
}) {
if (value == null) return null;
const tierClass = scoreTierClass(value);
const sizeClasses =
size === "lg"
? "size-9 text-sm font-bold"
: "size-6 text-[10px] font-semibold";
return (
<span
className={`score-badge ${tierClass} inline-flex items-center justify-center rounded-full ${sizeClasses}`}
>
{value}
</span>
);
}

View File

@ -0,0 +1,216 @@
import {
ChevronLeft,
ChevronRight,
ExternalLink,
Minus,
TrendingDown,
TrendingUp,
} from "lucide-react";
import type { SerpResultItem } from "@/types/keywords";
import { formatNumber } from "../utils";
export function SerpAnalysisCard({
items,
keyword,
loading,
error,
onRetry,
page,
pageSize,
onPageChange,
}: {
items: SerpResultItem[];
keyword?: string | null;
loading: boolean;
error?: string | null;
onRetry?: () => void;
page: number;
pageSize: number;
onPageChange: (p: number) => void;
}) {
const totalPages = Math.ceil(items.length / pageSize);
const pageItems = items.slice(page * pageSize, (page + 1) * pageSize);
if (loading) return <SerpAnalysisLoadingState />;
if (error) {
return (
<div className="rounded-lg border border-error/30 bg-error/10 p-3 text-sm text-error space-y-2">
<p>{error}</p>
{onRetry ? (
<button className="btn btn-xs" onClick={onRetry}>
Retry
</button>
) : null}
</div>
);
}
if (items.length === 0) return <SerpAnalysisEmptyState keyword={keyword} />;
return (
<div>
<div className="text-xs text-base-content/50 mb-3">
{items.length} organic results
</div>
<SerpAnalysisTable items={pageItems} />
<SerpAnalysisPagination
page={page}
totalPages={totalPages}
onPageChange={onPageChange}
/>
</div>
);
}
function SerpAnalysisTable({ items }: { items: SerpResultItem[] }) {
return (
<div className="overflow-x-auto">
<table className="table table-xs w-full">
<thead>
<tr className="text-xs text-base-content/60">
<th className="w-8">#</th>
<th>Page</th>
<th className="text-right w-20">Traffic</th>
<th className="text-right w-20">Ref. Domains</th>
<th className="text-right w-20">Backlinks</th>
<th className="text-center w-16">Change</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr
key={`${item.rank}-${item.url}`}
className="hover:bg-base-200/50"
>
<td className="font-mono text-base-content/50 text-xs">
{item.rank}
</td>
<td className="max-w-[280px]">
<div className="flex flex-col gap-0.5">
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-primary hover:underline truncate flex items-center gap-1"
title={item.title}
>
{item.title || item.url}
<ExternalLink className="size-3 shrink-0 opacity-40" />
</a>
<span className="text-xs text-base-content/40 truncate">
{item.domain}
</span>
</div>
</td>
<td className="text-right tabular-nums text-base-content/70">
{formatNumber(item.etv)}
</td>
<td className="text-right tabular-nums text-base-content/70">
{formatNumber(item.referringDomains)}
</td>
<td className="text-right tabular-nums text-base-content/70">
{formatNumber(item.backlinks)}
</td>
<td className="text-center">
<RankChangeBadge change={item.rankChange} isNew={item.isNew} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function SerpAnalysisPagination({
page,
totalPages,
onPageChange,
}: {
page: number;
totalPages: number;
onPageChange: (p: number) => void;
}) {
if (totalPages <= 1) return null;
return (
<div className="flex items-center justify-between mt-3 pt-3 border-t border-base-200">
<span className="text-xs text-base-content/50">
Page {page + 1} of {totalPages}
</span>
<div className="flex gap-1">
<button
className="btn btn-ghost btn-xs"
disabled={page === 0}
onClick={() => onPageChange(page - 1)}
>
<ChevronLeft className="size-3.5" />
Prev
</button>
<button
className="btn btn-ghost btn-xs"
disabled={page >= totalPages - 1}
onClick={() => onPageChange(page + 1)}
>
Next
<ChevronRight className="size-3.5" />
</button>
</div>
</div>
);
}
function SerpAnalysisLoadingState() {
return (
<div className="space-y-2">
{Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className="h-8 rounded bg-base-200 animate-pulse"
style={{ animationDelay: `${index * 50}ms` }}
/>
))}
</div>
);
}
function SerpAnalysisEmptyState({ keyword }: { keyword?: string | null }) {
return (
<div className="text-sm text-base-content/50 text-center py-8">
<p>No SERP details available for this keyword yet.</p>
{keyword ? (
<p className="mt-1">Try clicking another keyword to load data.</p>
) : null}
</div>
);
}
function RankChangeBadge({
change,
isNew,
}: {
change: number | null;
isNew?: boolean;
}) {
if (isNew) {
return <span className="badge badge-success badge-xs">new</span>;
}
if (change == null)
return <Minus className="size-3 text-base-content/40 mx-auto" />;
if (change > 0) {
return (
<span className="inline-flex items-center gap-0.5 text-success text-xs">
<TrendingUp className="size-3" />
{change}
</span>
);
}
if (change < 0) {
return (
<span className="inline-flex items-center gap-0.5 text-error text-xs">
<TrendingDown className="size-3" />
{Math.abs(change)}
</span>
);
}
return <Minus className="size-3 text-base-content/40 mx-auto" />;
}

View File

@ -0,0 +1,2 @@
export * from "./KeywordUi";
export * from "./DisplayPrimitives";

View File

@ -0,0 +1,23 @@
import { useForm } from "@tanstack/react-form";
import type {
KeywordMode,
ResultLimit,
} from "@/client/features/keywords/keywordResearchTypes";
type UseKeywordControlsFormInput = {
keywordInput: string;
locationCode: number;
resultLimit: ResultLimit;
keywordMode: KeywordMode;
};
export function useKeywordControlsForm(input: UseKeywordControlsFormInput) {
return useForm({
defaultValues: {
keyword: input.keywordInput,
locationCode: input.locationCode,
resultLimit: input.resultLimit,
mode: input.keywordMode,
},
});
}

View File

@ -0,0 +1,93 @@
import { useMemo } from "react";
import { sortBy } from "remeda";
import { parseTerms } from "@/client/features/keywords/utils";
import type { KeywordResearchRow } from "@/types/keywords";
import type { KeywordFilterValues } from "@/client/features/keywords/keywordResearchTypes";
import type { SortDir, SortField } from "@/client/features/keywords/components";
function applyKeywordFiltersAndSort(params: {
rows: KeywordResearchRow[];
filters: KeywordFilterValues;
sortField: SortField;
sortDir: SortDir;
}): KeywordResearchRow[] {
const includeTerms = parseTerms(params.filters.include);
const excludeTerms = parseTerms(params.filters.exclude);
const filtered = params.rows.filter((row) => {
const haystack = row.keyword.toLowerCase();
if (
includeTerms.length > 0 &&
!includeTerms.every((term) => haystack.includes(term))
) {
return false;
}
if (excludeTerms.some((term) => haystack.includes(term))) {
return false;
}
const vol = row.searchVolume ?? 0;
const cpc = row.cpc ?? 0;
const kd = row.keywordDifficulty ?? 0;
if (params.filters.minVol && vol < Number(params.filters.minVol))
return false;
if (params.filters.maxVol && vol > Number(params.filters.maxVol))
return false;
if (params.filters.minCpc && cpc < Number(params.filters.minCpc))
return false;
if (params.filters.maxCpc && cpc > Number(params.filters.maxCpc))
return false;
if (params.filters.minKd && kd < Number(params.filters.minKd)) return false;
if (params.filters.maxKd && kd > Number(params.filters.maxKd)) return false;
return true;
});
if (params.sortField === "keyword") {
return sortBy(filtered, [(row) => row.keyword, params.sortDir]);
}
if (params.sortField === "searchVolume") {
return sortBy(filtered, [(row) => row.searchVolume ?? -1, params.sortDir]);
}
if (params.sortField === "cpc") {
return sortBy(filtered, [(row) => row.cpc ?? -1, params.sortDir]);
}
if (params.sortField === "competition") {
return sortBy(filtered, [(row) => row.competition ?? -1, params.sortDir]);
}
return sortBy(filtered, [
(row) => row.keywordDifficulty ?? -1,
params.sortDir,
]);
}
export function useKeywordFiltering(params: {
rows: KeywordResearchRow[];
filters: KeywordFilterValues;
sortField: SortField;
sortDir: SortDir;
}) {
const filteredRows = useMemo(
() =>
applyKeywordFiltersAndSort({
rows: params.rows,
filters: params.filters,
sortField: params.sortField,
sortDir: params.sortDir,
}),
[params.filters, params.rows, params.sortDir, params.sortField],
);
const activeFilterCount = useMemo(
() =>
Object.values(params.filters).filter((value) => value.trim() !== "")
.length,
[params.filters],
);
return {
filteredRows,
activeFilterCount,
};
}

View File

@ -0,0 +1,118 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { LOCATIONS, getLanguageCode } from "@/client/features/keywords/utils";
import { researchKeywords } from "@/serverFunctions/keywords";
import type {
KeywordMode,
KeywordSource,
ResultLimit,
} from "@/client/features/keywords/keywordResearchTypes";
import type { KeywordResearchRow } from "@/types/keywords";
type AddSearchFn = (
keyword: string,
locationCode: number,
locationName: string,
) => void;
export function useKeywordResearchData(addSearch: AddSearchFn) {
const [rows, setRows] = useState<KeywordResearchRow[]>([]);
const [hasSearched, setHasSearched] = useState(false);
const [lastSearchError, setLastSearchError] = useState(false);
const [lastResultSource, setLastResultSource] =
useState<KeywordSource>("related");
const [lastUsedFallback, setLastUsedFallback] = useState(false);
const [lastSearchKeyword, setLastSearchKeyword] = useState("");
const [lastSearchLocationCode, setLastSearchLocationCode] = useState(2840);
const [researchError, setResearchError] = useState<string | null>(null);
const [searchedKeyword, setSearchedKeyword] = useState("");
const researchMutation = useMutation({
mutationFn: (data: {
projectId: string;
keywords: string[];
locationCode: number;
languageCode: string;
resultLimit: ResultLimit;
mode: KeywordMode;
}) => researchKeywords({ data }),
});
const beginSearch = (seedKeyword: string, locationCode: number) => {
setResearchError(null);
setHasSearched(true);
setLastSearchError(false);
setSearchedKeyword(seedKeyword);
setLastSearchKeyword(seedKeyword);
setLastSearchLocationCode(locationCode);
};
const runSearch = (
input: {
projectId: string;
keywords: string[];
locationCode: number;
resultLimit: ResultLimit;
mode: KeywordMode;
},
handlers?: {
onSuccess?: (seedKeyword: string, rows: KeywordResearchRow[]) => void;
onError?: () => void;
},
) => {
const seedKeyword = input.keywords[0] ?? "";
const languageCode = getLanguageCode(input.locationCode);
researchMutation.mutate(
{
keywords: input.keywords,
projectId: input.projectId,
locationCode: input.locationCode,
languageCode,
resultLimit: input.resultLimit,
mode: input.mode,
},
{
onSuccess: (result) => {
setResearchError(null);
setRows(result.rows);
setLastResultSource(result.source);
setLastUsedFallback(result.usedFallback);
if (seedKeyword) {
addSearch(
seedKeyword,
input.locationCode,
LOCATIONS[input.locationCode] || "Unknown",
);
}
handlers?.onSuccess?.(seedKeyword, result.rows);
},
onError: (error) => {
setLastSearchError(true);
setRows([]);
setResearchError(getStandardErrorMessage(error, "Research failed."));
handlers?.onError?.();
},
},
);
};
return {
rows,
hasSearched,
lastSearchError,
lastResultSource,
lastUsedFallback,
lastSearchKeyword,
lastSearchLocationCode,
researchError,
searchedKeyword,
isLoading: researchMutation.isPending,
setRows,
beginSearch,
runSearch,
};
}

View File

@ -0,0 +1,41 @@
import { useCallback, useState } from "react";
function getNextSelectionSet(
current: Set<string>,
allVisibleKeywords: string[],
): Set<string> {
if (current.size === allVisibleKeywords.length) {
return new Set();
}
return new Set(allVisibleKeywords);
}
export function useKeywordSelection() {
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
const clearSelection = useCallback(() => {
setSelectedRows(new Set());
}, []);
const toggleRowSelection = useCallback((keyword: string) => {
setSelectedRows((prev) => {
const next = new Set(prev);
if (next.has(keyword)) next.delete(keyword);
else next.add(keyword);
return next;
});
}, []);
const toggleAllRows = useCallback((allVisibleKeywords: string[]) => {
setSelectedRows((prev) => getNextSelectionSet(prev, allVisibleKeywords));
}, []);
return {
selectedRows,
setSelectedRows,
clearSelection,
toggleRowSelection,
toggleAllRows,
};
}

View File

@ -0,0 +1,45 @@
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { getLanguageCode } from "@/client/features/keywords/utils";
import { getSerpAnalysis } from "@/serverFunctions/keywords";
export function useKeywordSerpAnalysis(locationCode: number) {
const [serpKeyword, setSerpKeyword] = useState<string | null>(null);
const [serpPage, setSerpPage] = useState(0);
const SERP_PAGE_SIZE = 10;
const serpQuery = useQuery({
queryKey: ["serpAnalysis", serpKeyword, locationCode],
queryFn: () =>
getSerpAnalysis({
data: {
keyword: serpKeyword!,
locationCode,
languageCode: getLanguageCode(locationCode),
},
}),
enabled: !!serpKeyword,
});
const serpResults = serpQuery.data?.items ?? [];
const activeSerpKeyword =
serpKeyword ?? serpQuery.data?.requestedKeyword ?? null;
const serpLoading = serpQuery.isLoading;
const serpError = serpQuery.isError
? getStandardErrorMessage(serpQuery.error, "Failed to load SERP data.")
: null;
return {
serpKeyword,
setSerpKeyword,
serpPage,
setSerpPage,
SERP_PAGE_SIZE,
serpQuery,
serpResults,
activeSerpKeyword,
serpLoading,
serpError,
};
}

View File

@ -0,0 +1,36 @@
import { useCallback } from "react";
import { useForm } from "@tanstack/react-form";
import {
EMPTY_FILTERS,
type KeywordFilterValues,
} from "@/client/features/keywords/keywordResearchTypes";
export function useLocalKeywordFilters() {
const filtersForm = useForm({
defaultValues: EMPTY_FILTERS,
});
const values = filtersForm.state.values;
const resetFilters = useCallback(() => {
const keys: Array<keyof KeywordFilterValues> = [
"include",
"exclude",
"minVol",
"maxVol",
"minCpc",
"maxCpc",
"minKd",
"maxKd",
];
for (const key of keys) {
filtersForm.setFieldValue(key, "");
}
}, [filtersForm]);
return {
filtersForm,
values,
resetFilters,
};
}

View File

@ -0,0 +1,27 @@
export type ResultLimit = 150 | 300 | 500;
export const RESULT_LIMITS: ResultLimit[] = [150, 300, 500];
export type KeywordSource = "related" | "suggestions" | "ideas";
export type KeywordMode = "auto" | KeywordSource;
export type KeywordFilterValues = {
include: string;
exclude: string;
minVol: string;
maxVol: string;
minCpc: string;
maxCpc: string;
minKd: string;
maxKd: string;
};
export const EMPTY_FILTERS: KeywordFilterValues = {
include: "",
exclude: "",
minVol: "",
maxVol: "",
minCpc: "",
maxCpc: "",
minKd: "",
maxKd: "",
};

View File

@ -0,0 +1,92 @@
import type { SortDir, SortField } from "@/client/features/keywords/components";
import type {
KeywordMode,
ResultLimit,
} from "@/client/features/keywords/keywordResearchTypes";
type KeywordSearchParams = {
q?: string;
loc?: number;
kLimit?: ResultLimit;
mode?: KeywordMode;
sort?: SortField;
order?: SortDir;
minVol?: string;
maxVol?: string;
minCpc?: string;
maxCpc?: string;
minKd?: string;
maxKd?: string;
include?: string;
exclude?: string;
};
export function normalizeLegacyKeywordSearch(search: KeywordSearchParams): {
normalized: KeywordSearchParams;
changed: boolean;
} {
const normalized: KeywordSearchParams = {
...search,
q: search.q === "" ? undefined : search.q,
loc: search.loc === 2840 ? undefined : search.loc,
kLimit: search.kLimit === 150 ? undefined : search.kLimit,
mode: search.mode === "auto" ? undefined : search.mode,
sort: search.sort === "searchVolume" ? undefined : search.sort,
order: search.order === "desc" ? undefined : search.order,
minVol: undefined,
maxVol: undefined,
minCpc: undefined,
maxCpc: undefined,
minKd: undefined,
maxKd: undefined,
include: undefined,
exclude: undefined,
};
const keys: Array<keyof KeywordSearchParams> = [
"q",
"loc",
"kLimit",
"mode",
"sort",
"order",
"minVol",
"maxVol",
"minCpc",
"maxCpc",
"minKd",
"maxKd",
"include",
"exclude",
];
return {
normalized,
changed: keys.some((key) => search[key] !== normalized[key]),
};
}
export function isResultLimit(value: number): value is ResultLimit {
return value === 150 || value === 300 || value === 500;
}
export function normalizeKeywordMode(value: string): KeywordMode {
if (value === "auto") return "auto";
if (value === "related") return "related";
if (value === "suggestions") return "suggestions";
if (value === "ideas") return "ideas";
return "auto";
}
export function normalizeSortField(value: string): SortField {
if (value === "keyword") return "keyword";
if (value === "searchVolume") return "searchVolume";
if (value === "cpc") return "cpc";
if (value === "competition") return "competition";
if (value === "keywordDifficulty") return "keywordDifficulty";
return "searchVolume";
}
export function normalizeSortDir(value: string): SortDir {
return value === "asc" ? "asc" : "desc";
}

View File

@ -0,0 +1,354 @@
import {
FileDown,
Globe,
RotateCcw,
Save,
SlidersHorizontal,
} from "lucide-react";
import {
AreaTrendChart,
KeywordRow,
OverviewStats,
SerpAnalysisCard,
SortHeader,
} from "@/client/features/keywords/components";
import type { KeywordResearchRow } from "@/types/keywords";
import type { KeywordResearchControllerState } from "./types";
import {
EmptyFilterResults,
FilterRangeInputs,
FilterTextInput,
} from "./keywordResearchDesktopFilters";
const MONTH_SHORT_LABELS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
] as const;
function formatTrendRangeLabel(trend: KeywordResearchRow["trend"]): string {
if (trend.length === 0) return "Last 12 available months";
const sorted = trend.toSorted(
(a, b) => a.year * 100 + a.month - (b.year * 100 + b.month),
);
const last12 = sorted.slice(-12);
const start = last12[0];
const end = last12[last12.length - 1];
const toLabel = (month: number, year: number) => {
const monthLabel = MONTH_SHORT_LABELS[month - 1] ?? `M${month}`;
return `${monthLabel} ${year}`;
};
const startLabel = toLabel(start.month, start.year);
const endLabel = toLabel(end.month, end.year);
return startLabel === endLabel ? startLabel : `${startLabel} - ${endLabel}`;
}
type Props = {
controller: KeywordResearchControllerState;
};
export function KeywordResearchDesktopResults({ controller }: Props) {
return (
<div className="flex-1 hidden md:flex flex-col xl:flex-row overflow-y-auto xl:overflow-hidden gap-4 mt-2">
<DesktopKeywordPanel controller={controller} />
<DesktopSerpPanel controller={controller} />
</div>
);
}
function DesktopKeywordPanel({ controller }: Props) {
const {
lastResultSource,
lastUsedFallback,
searchedKeyword,
showApproximateMatchNotice,
} = controller;
return (
<div className="order-2 xl:order-1 flex flex-col min-w-0 gap-2 xl:flex-1">
{showApproximateMatchNotice ? (
<div
className="rounded-lg border border-warning/40 bg-warning/15 px-3 py-2 text-sm text-base-content"
role="status"
>
No exact match for{" "}
<span className="font-medium">"{searchedKeyword}"</span>. Showing
closest related keywords instead.
{lastUsedFallback ? (
<span className="text-base-content/75">
{" "}
Source: {lastResultSource} fallback.
</span>
) : null}
</div>
) : null}
{controller.overviewKeyword ? (
<OverviewStats keyword={controller.overviewKeyword} />
) : null}
<DesktopTableCard controller={controller} />
</div>
);
}
function DesktopTableCard({ controller }: Props) {
const { activeFilterCount, filteredRows, selectedRows, showFilters } =
controller;
return (
<div className="flex-1 flex flex-col min-w-0 border border-base-300 rounded-xl bg-base-100 overflow-hidden">
<div className="shrink-0 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={() => controller.setShowFilters((current) => !current)}
title="Toggle table 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>
<span className="text-sm text-base-content/60">
{selectedRows.size > 0
? `${selectedRows.size} of ${filteredRows.length} selected`
: `${filteredRows.length} keywords`}
</span>
<div className="flex-1" />
<button
className="btn btn-ghost btn-sm gap-1"
onClick={controller.handleSaveKeywords}
disabled={selectedRows.size === 0}
>
<Save className="size-3.5" />
<span className="hidden lg:inline">Save Keywords</span>
</button>
<button
className="btn btn-ghost btn-sm gap-1"
onClick={controller.exportCsv}
disabled={filteredRows.length === 0}
>
<FileDown className="size-3.5" />
<span className="hidden lg:inline">Export</span>
</button>
</div>
{showFilters ? <DesktopFilters controller={controller} /> : null}
<DesktopTableHeader controller={controller} />
<DesktopTableRows controller={controller} />
</div>
);
}
function DesktopFilters({ controller }: Props) {
const { activeFilterCount, filtersForm } = controller;
return (
<div className="shrink-0 border-b border-base-300 bg-gradient-to-b from-base-100 to-base-200/30 px-4 py-3 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold">Refine table results</p>
{activeFilterCount > 0 ? (
<span className="badge badge-xs badge-primary border-0 text-primary-content">
{activeFilterCount} active
</span>
) : null}
</div>
<button
className="btn btn-xs btn-ghost gap-1"
onClick={controller.resetFilters}
disabled={activeFilterCount === 0}
>
<RotateCcw className="size-3" />
Clear all
</button>
</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"
/>
</div>
<div className="grid grid-cols-1 gap-2 lg:grid-cols-3">
<FilterRangeInputs
form={filtersForm}
title="Search Volume"
minName="minVol"
maxName="maxVol"
/>
<FilterRangeInputs
form={filtersForm}
title="CPC (USD)"
minName="minCpc"
maxName="maxCpc"
step="0.01"
/>
<FilterRangeInputs
form={filtersForm}
title="Difficulty"
minName="minKd"
maxName="maxKd"
/>
</div>
</div>
);
}
function DesktopTableHeader({ controller }: Props) {
const { filteredRows, selectedRows } = controller;
return (
<div className="shrink-0 flex items-center gap-3 px-4 py-2 border-b border-base-300 bg-base-100 text-xs text-base-content/60 font-medium">
<input
type="checkbox"
className="checkbox checkbox-xs shrink-0"
checked={
filteredRows.length > 0 && selectedRows.size === filteredRows.length
}
onChange={controller.toggleAllRows}
/>
<SortHeader
label="Keyword"
field="keyword"
current={controller.sortField}
dir={controller.sortDir}
onToggle={controller.toggleSort}
className="flex-1 min-w-0"
/>
<SortHeader
label="Volume"
field="searchVolume"
current={controller.sortField}
dir={controller.sortDir}
onToggle={controller.toggleSort}
className="w-16 text-right"
/>
<SortHeader
label="CPC"
helpText="Cost per click in USD."
field="cpc"
current={controller.sortField}
dir={controller.sortDir}
onToggle={controller.toggleSort}
className="w-14 text-right"
/>
<SortHeader
label="Comp."
helpText="Advertiser competition."
field="competition"
current={controller.sortField}
dir={controller.sortDir}
onToggle={controller.toggleSort}
className="w-12 text-right"
/>
<SortHeader
label="Score"
helpText="Keyword difficulty score."
field="keywordDifficulty"
current={controller.sortField}
dir={controller.sortDir}
onToggle={controller.toggleSort}
className="w-10 text-right"
/>
</div>
);
}
function DesktopTableRows({ controller }: Props) {
const { activeFilterCount, filteredRows, overviewKeyword, selectedRows } =
controller;
return (
<div className="flex-1 overflow-y-auto">
{filteredRows.length === 0 ? (
<EmptyFilterResults
activeFilterCount={activeFilterCount}
resetFilters={controller.resetFilters}
/>
) : (
filteredRows.map((row) => (
<KeywordRow
key={row.keyword}
row={row}
isSelected={selectedRows.has(row.keyword)}
isActive={overviewKeyword?.keyword === row.keyword}
onToggle={() => controller.toggleRowSelection(row.keyword)}
onClick={() => controller.handleRowClick(row)}
/>
))
)}
</div>
);
}
function DesktopSerpPanel({ controller }: Props) {
const { overviewKeyword } = controller;
const trendRangeLabel = overviewKeyword
? formatTrendRangeLabel(overviewKeyword.trend)
: "Last 12 available months";
return (
<div className="order-1 xl:order-2 flex flex-col min-w-0 gap-2 xl:flex-1">
{overviewKeyword && overviewKeyword.trend.length > 0 ? (
<div className="shrink-0 border border-base-300 rounded-xl bg-base-100 px-4 py-3">
<h4 className="text-sm font-semibold mb-1">
Search Trends{" "}
<span className="font-normal text-base-content/50">
{trendRangeLabel}
</span>
</h4>
<AreaTrendChart trend={overviewKeyword.trend} />
</div>
) : null}
<div className="flex-1 flex flex-col overflow-hidden border border-base-300 rounded-xl bg-base-100">
<div className="shrink-0 px-4 py-3 border-b border-base-300">
<h3 className="text-sm font-semibold flex items-center gap-1.5">
<Globe className="size-3.5" />
SERP Analysis
{controller.activeSerpKeyword ? (
<span className="font-normal text-base-content/50 truncate">
: {controller.activeSerpKeyword}
</span>
) : null}
</h3>
</div>
<div className="flex-1 overflow-y-auto p-4">
<SerpAnalysisCard
items={controller.serpResults}
keyword={controller.activeSerpKeyword}
loading={controller.serpLoading}
error={controller.serpError}
onRetry={() => void controller.serpQuery.refetch()}
page={controller.serpPage}
pageSize={controller.SERP_PAGE_SIZE}
onPageChange={controller.setSerpPage}
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,197 @@
import { Clock, Globe, History, Search, X } from "lucide-react";
import { reverse } from "remeda";
import { LOCATIONS } from "@/client/features/keywords/utils";
import type { KeywordResearchControllerState } from "./types";
type Props = {
controller: KeywordResearchControllerState;
};
export function KeywordResearchEmptyState({ controller }: Props) {
const { hasSearched, isLoading, lastSearchError } = controller;
if (hasSearched && !isLoading && !lastSearchError) {
return <NoResultsState controller={controller} />;
}
return <SearchHistoryState controller={controller} />;
}
function NoResultsState({ controller }: Props) {
const {
controlsForm,
lastResultSource,
lastSearchKeyword,
lastSearchLocationCode,
lastUsedFallback,
onSearch,
} = controller;
return (
<div className="flex-1 flex items-center justify-center px-4 md:px-6 py-6">
<div className="w-full max-w-2xl rounded-2xl border border-base-300 bg-base-100 p-6 md:p-8 text-center space-y-4">
<Globe className="size-10 mx-auto text-base-content/40" />
<div className="space-y-2">
<p className="text-lg font-semibold text-base-content">
Not enough keyword data for this query yet
</p>
<p className="text-sm text-base-content/70">
We could not find keyword opportunities for
<span className="font-medium text-base-content">
{` "${lastSearchKeyword}" `}
</span>
in
<span className="font-medium text-base-content">
{` ${LOCATIONS[lastSearchLocationCode] || "this location"}`}
</span>
.
</p>
</div>
<div className="rounded-xl bg-base-200/70 px-4 py-3 text-left text-sm text-base-content/70 space-y-1">
<p>
Source checked:{" "}
<span className="font-medium">{lastResultSource}</span>
{lastUsedFallback ? (
<span> (with fallback chain: related - suggestions - ideas)</span>
) : null}
</p>
<p>Try a broader phrase, swap word order, or change location.</p>
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
<button
className="btn btn-sm btn-outline"
onClick={() => {
const words = lastSearchKeyword.split(/\s+/).filter(Boolean);
const reversedKeyword = reverse(words).join(" ");
if (!reversedKeyword || reversedKeyword === lastSearchKeyword) {
return;
}
controlsForm.setFieldValue("keyword", reversedKeyword);
onSearch({
keyword: reversedKeyword,
locationCode: lastSearchLocationCode,
});
}}
disabled={lastSearchKeyword.trim().split(/\s+/).length < 2}
>
Try reversed phrase
</button>
<button
className="btn btn-sm btn-ghost"
onClick={() => {
const firstWord = lastSearchKeyword
.split(/\s+/)
.filter(Boolean)[0];
if (!firstWord) return;
controlsForm.setFieldValue("keyword", firstWord);
onSearch({
keyword: firstWord,
locationCode: lastSearchLocationCode,
});
}}
>
Try broader seed
</button>
</div>
</div>
</div>
);
}
function SearchHistoryState({ controller }: Props) {
const {
clearHistory,
controlsForm,
history,
historyLoaded,
onSearch,
removeHistoryItem,
} = controller;
return (
<div className="flex-1 overflow-y-auto px-4 md:px-6 pb-6">
<div className="mx-auto w-full max-w-5xl space-y-6 pt-3 md:pt-5">
{historyLoaded && history.length > 0 ? (
<section className="rounded-2xl border border-base-300 bg-base-100 p-5 md:p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<History className="size-4 text-base-content/45" />
<span className="text-sm text-base-content/60">
{history.length} recent search
{history.length !== 1 ? "es" : ""}
</span>
</div>
<button
className="btn btn-ghost btn-xs text-error"
onClick={clearHistory}
>
Clear all
</button>
</div>
<div className="grid gap-2">
{history.map((item) => (
<div
key={item.timestamp}
className="flex items-center justify-between p-3 rounded-lg border border-base-300 bg-base-100 hover:bg-base-200 transition-colors text-left group cursor-pointer"
onClick={() => {
controlsForm.setFieldValue("keyword", item.keyword);
controlsForm.setFieldValue(
"locationCode",
item.locationCode,
);
onSearch({
keyword: item.keyword,
locationCode: item.locationCode,
});
}}
>
<div className="flex items-center gap-3">
<Clock className="size-4 text-base-content/40" />
<div>
<p className="font-medium text-base-content">
{item.keyword}
</p>
<p className="text-sm text-base-content/60">
{item.locationName}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-base-content/40">
{new Date(item.timestamp).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})}
</span>
<button
className="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100 p-1"
onClick={(event) => {
event.stopPropagation();
removeHistoryItem(item.timestamp);
}}
>
<X className="size-3" />
</button>
</div>
</div>
))}
</div>
</section>
) : (
<section className="rounded-2xl border border-dashed border-base-300 bg-base-100/70 p-6 text-center text-base-content/50 space-y-3">
<Search className="size-10 mx-auto opacity-40" />
<p className="text-lg font-medium text-base-content/80">
Enter a keyword to get started
</p>
<p className="text-sm max-w-md mx-auto">
Search for any keyword to see volume, difficulty, CPC, and related
keyword ideas.
</p>
</section>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,72 @@
export function KeywordResearchLoadingState() {
return (
<div className="flex-1 w-full px-4 md:px-6 pb-4 max-w-8xl mx-auto">
<div className="hidden md:flex h-full gap-4 mt-2">
<div className="flex-1 flex flex-col min-w-0 gap-2">
<div className="rounded-xl border border-base-300 bg-base-100 p-4">
<div className="skeleton h-5 w-56" />
</div>
<div className="flex-1 rounded-xl border border-base-300 bg-base-100 overflow-hidden">
<div className="border-b border-base-300 px-4 py-3 flex items-center gap-3">
<div className="skeleton h-8 w-24" />
<div className="skeleton h-4 w-40" />
</div>
<div className="p-4 space-y-3">
{Array.from({ length: 10 }).map((_, index) => (
<div
key={index}
className="grid grid-cols-[24px_minmax(0,1fr)_64px_56px_48px_40px] items-center gap-3"
>
<div className="skeleton h-3 w-3" />
<div className="skeleton h-4 w-10/12" />
<div className="skeleton h-3 w-12 justify-self-end" />
<div className="skeleton h-3 w-10 justify-self-end" />
<div className="skeleton h-3 w-10 justify-self-end" />
<div className="skeleton h-6 w-6 rounded-full justify-self-end" />
</div>
))}
</div>
</div>
</div>
<div className="flex-1 flex flex-col min-w-0 gap-2">
<div className="rounded-xl border border-base-300 bg-base-100 p-4 space-y-3">
<div className="skeleton h-4 w-36" />
<div className="skeleton h-56 w-full" />
</div>
<div className="flex-1 rounded-xl border border-base-300 bg-base-100 p-4 space-y-3">
<div className="skeleton h-4 w-44" />
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="grid grid-cols-[24px_1fr_72px] gap-2">
<div className="skeleton h-3 w-4" />
<div className="skeleton h-3 w-10/12" />
<div className="skeleton h-3 w-12 justify-self-end" />
</div>
))}
</div>
</div>
</div>
<div className="md:hidden mt-2 space-y-3">
<div className="rounded-xl border border-base-300 bg-base-100 p-4 space-y-3">
<div className="skeleton h-8 w-full" />
<div className="skeleton h-8 w-2/3" />
</div>
<div className="rounded-xl border border-base-300 bg-base-100 p-4 space-y-2">
{Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className="space-y-2 rounded-lg border border-base-300 p-3"
>
<div className="skeleton h-4 w-9/12" />
<div className="grid grid-cols-3 gap-2">
<div className="skeleton h-3 w-full" />
<div className="skeleton h-3 w-full" />
<div className="skeleton h-3 w-full" />
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,256 @@
import { FileDown, RotateCcw, Save, SlidersHorizontal } from "lucide-react";
import {
KeywordCard,
SerpAnalysisCard,
} from "@/client/features/keywords/components";
import type { KeywordResearchControllerState } from "./types";
type Props = {
controller: KeywordResearchControllerState;
};
export function KeywordResearchMobileResults({ controller }: Props) {
const { filteredRows, mobileTab } = controller;
return (
<div className="flex-1 flex flex-col overflow-hidden md:hidden">
<div className="shrink-0 flex border-b border-base-300 bg-base-100">
<button
className={`flex-1 py-2 text-sm font-medium text-center border-b-2 transition-colors ${
mobileTab === "keywords"
? "border-primary text-primary"
: "border-transparent text-base-content/60"
}`}
onClick={() => controller.setMobileTab("keywords")}
>
Keywords ({filteredRows.length})
</button>
<button
className={`flex-1 py-2 text-sm font-medium text-center border-b-2 transition-colors ${
mobileTab === "serp"
? "border-primary text-primary"
: "border-transparent text-base-content/60"
}`}
onClick={() => controller.setMobileTab("serp")}
>
SERP Analysis
</button>
</div>
{mobileTab === "keywords" ? (
<MobileKeywordCards controller={controller} />
) : (
<div className="flex-1 overflow-y-auto p-4">
<SerpAnalysisCard
items={controller.serpResults}
keyword={controller.activeSerpKeyword}
loading={controller.serpLoading}
error={controller.serpError}
onRetry={() => void controller.serpQuery.refetch()}
page={controller.serpPage}
pageSize={controller.SERP_PAGE_SIZE}
onPageChange={controller.setSerpPage}
/>
</div>
)}
</div>
);
}
function MobileKeywordCards({ controller }: Props) {
const { activeFilterCount, filteredRows, selectedRows, showFilters } =
controller;
return (
<div className="flex-1 flex flex-col overflow-hidden">
{controller.showApproximateMatchNotice ? (
<div
className="mx-4 mt-2 rounded-lg border border-warning/40 bg-warning/15 px-3 py-2 text-xs text-base-content"
role="status"
>
No exact match for{" "}
<span className="font-medium">"{controller.searchedKeyword}"</span>.
Showing closest related keywords.
</div>
) : null}
<div className="shrink-0 flex items-center gap-2 px-4 py-2 border-b border-base-300 bg-base-100">
<button
className={`btn btn-ghost btn-xs gap-1 ${showFilters ? "btn-active" : ""}`}
onClick={() => controller.setShowFilters((current) => !current)}
>
<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-xs text-base-content/60">
{selectedRows.size > 0
? `${selectedRows.size} selected`
: `${filteredRows.length} keywords`}
</span>
<div className="flex-1" />
<button
className="btn btn-ghost btn-xs"
onClick={controller.handleSaveKeywords}
disabled={selectedRows.size === 0}
>
<Save className="size-3.5" />
</button>
<button
className="btn btn-ghost btn-xs"
onClick={controller.exportCsv}
disabled={filteredRows.length === 0}
>
<FileDown className="size-3.5" />
</button>
</div>
{showFilters ? <MobileFilters controller={controller} /> : null}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{filteredRows.length === 0 ? (
<div className="h-full min-h-48 flex flex-col items-center justify-center text-center px-4 text-base-content/50 gap-3">
<p className="text-sm font-medium">
No keywords match your current filters.
</p>
{activeFilterCount > 0 ? (
<button
className="btn btn-ghost btn-sm"
onClick={controller.resetFilters}
>
Clear filters
</button>
) : null}
</div>
) : (
filteredRows.map((row) => (
<KeywordCard
key={row.keyword}
row={row}
isSelected={selectedRows.has(row.keyword)}
isActive={controller.overviewKeyword?.keyword === row.keyword}
onToggle={() => controller.toggleRowSelection(row.keyword)}
onClick={() => controller.handleRowClick(row)}
/>
))
)}
</div>
</div>
);
}
function MobileFilters({ controller }: Props) {
const { activeFilterCount, filtersForm } = controller;
return (
<div className="shrink-0 border-b border-base-300 bg-gradient-to-b from-base-100 to-base-200/30 px-4 py-3 space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<p className="text-xs font-semibold">Refine table results</p>
{activeFilterCount > 0 ? (
<span className="badge badge-xs badge-primary border-0 text-primary-content">
{activeFilterCount}
</span>
) : null}
</div>
<button
className="btn btn-xs btn-ghost gap-1"
onClick={controller.resetFilters}
disabled={activeFilterCount === 0}
>
<RotateCcw className="size-3" />
Clear
</button>
</div>
<div className="grid grid-cols-1 gap-2">
<filtersForm.Field name="include">
{(field) => (
<input
className="input input-bordered input-sm bg-base-100"
placeholder="Include terms (audit, checker)"
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
/>
)}
</filtersForm.Field>
<filtersForm.Field name="exclude">
{(field) => (
<input
className="input input-bordered input-sm bg-base-100"
placeholder="Exclude terms (jobs, course)"
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
/>
)}
</filtersForm.Field>
</div>
<div className="grid grid-cols-2 gap-2">
<MobileRangeInput
form={filtersForm}
name="minVol"
placeholder="Min volume"
/>
<MobileRangeInput
form={filtersForm}
name="maxVol"
placeholder="Max volume"
/>
<MobileRangeInput
form={filtersForm}
name="minCpc"
placeholder="Min CPC"
step="0.01"
/>
<MobileRangeInput
form={filtersForm}
name="maxCpc"
placeholder="Max CPC"
step="0.01"
/>
<MobileRangeInput
form={filtersForm}
name="minKd"
placeholder="Min difficulty"
/>
<MobileRangeInput
form={filtersForm}
name="maxKd"
placeholder="Max difficulty"
/>
</div>
</div>
);
}
function MobileRangeInput({
form,
name,
placeholder,
step,
}: {
form: KeywordResearchControllerState["filtersForm"];
name: "minVol" | "maxVol" | "minCpc" | "maxCpc" | "minKd" | "maxKd";
placeholder: string;
step?: string;
}) {
return (
<form.Field name={name}>
{(field) => (
<input
className="input input-bordered input-sm bg-base-100"
placeholder={placeholder}
type="number"
step={step}
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
/>
)}
</form.Field>
);
}

View File

@ -0,0 +1,92 @@
import { AlertCircle } from "lucide-react";
import { useKeywordResearchController } from "@/client/features/keywords/state/useKeywordResearchController";
import type { KeywordResearchControllerInput } from "@/client/features/keywords/state/useKeywordResearchController";
import { KeywordResearchEmptyState } from "./KeywordResearchEmptyState";
import { KeywordResearchLoadingState } from "./KeywordResearchLoadingState";
import { KeywordResearchResults } from "./KeywordResearchResults";
import { KeywordResearchSearchBar } from "./KeywordResearchSearchBar";
import type { KeywordResearchControllerState } from "./types";
type Props = KeywordResearchControllerInput;
export function KeywordResearchPage(props: Props) {
const controller = useKeywordResearchController(props);
return (
<div className="flex flex-col h-full overflow-hidden">
<KeywordResearchSearchBar controller={controller} />
<KeywordResearchContent controller={controller} />
<KeywordSaveDialog controller={controller} />
</div>
);
}
function KeywordResearchContent({
controller,
}: {
controller: KeywordResearchControllerState;
}) {
if (controller.isLoading) {
return <KeywordResearchLoadingState />;
}
if (controller.researchError) {
return (
<div className="flex-1 flex items-center justify-center px-4 md:px-6">
<div className="w-full max-w-xl rounded-xl border border-error/30 bg-error/10 p-5 text-error space-y-3">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<p className="text-sm">{controller.researchError}</p>
</div>
<button className="btn btn-sm" onClick={() => controller.onSearch()}>
Try again
</button>
</div>
</div>
);
}
if (controller.rows.length === 0) {
return <KeywordResearchEmptyState controller={controller} />;
}
return <KeywordResearchResults controller={controller} />;
}
function KeywordSaveDialog({
controller,
}: {
controller: KeywordResearchControllerState;
}) {
if (!controller.showSaveDialog) return null;
return (
<div className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold text-lg">
Save {controller.selectedRows.size} Keywords
</h3>
<div className="py-4">
<p className="text-base-content/70 text-sm">
These keywords will be saved to your current project.
</p>
</div>
<div className="modal-action">
<button
className="btn"
onClick={() => controller.setShowSaveDialog(false)}
>
Cancel
</button>
<button className="btn btn-primary" onClick={controller.confirmSave}>
Save
</button>
</div>
</div>
<div
className="modal-backdrop"
onClick={() => controller.setShowSaveDialog(false)}
/>
</div>
);
}

View File

@ -0,0 +1,16 @@
import { KeywordResearchDesktopResults } from "./KeywordResearchDesktopResults";
import { KeywordResearchMobileResults } from "./KeywordResearchMobileResults";
import type { KeywordResearchControllerState } from "./types";
type Props = {
controller: KeywordResearchControllerState;
};
export function KeywordResearchResults({ controller }: Props) {
return (
<div className="flex-1 flex flex-col overflow-hidden w-full px-4 md:px-6 pb-4 max-w-8xl mx-auto">
<KeywordResearchDesktopResults controller={controller} />
<KeywordResearchMobileResults controller={controller} />
</div>
);
}

View File

@ -0,0 +1,120 @@
import { Search } from "lucide-react";
import {
isResultLimit,
normalizeKeywordMode,
} from "@/client/features/keywords/keywordSearchParams";
import { RESULT_LIMITS } from "@/client/features/keywords/keywordResearchTypes";
import type { KeywordResearchControllerState } from "./types";
type Props = {
controller: KeywordResearchControllerState;
};
const LOCATION_OPTIONS = [
{ code: 2840, label: "United States" },
{ code: 2826, label: "United Kingdom" },
{ code: 2276, label: "Germany" },
{ code: 2250, label: "France" },
{ code: 2036, label: "Australia" },
{ code: 2124, label: "Canada" },
{ code: 2356, label: "India" },
{ code: 2076, label: "Brazil" },
];
export function KeywordResearchSearchBar({ controller }: Props) {
const { controlsForm, handleSearchSubmit, isLoading, searchInputError } =
controller;
return (
<div className="shrink-0 px-4 md:px-6 pt-4 pb-2 max-w-8xl mx-auto w-full">
<form
className="bg-base-100 border border-base-300 rounded-xl px-4 py-3 flex flex-wrap items-center gap-2"
onSubmit={handleSearchSubmit}
>
<label
className={`input input-bordered input-sm flex items-center gap-2 flex-1 min-w-0 max-w-md ${searchInputError ? "input-error" : ""}`}
>
<Search className="size-3.5 shrink-0 text-base-content/50" />
<controlsForm.Field name="keyword">
{(field) => (
<input
className="grow min-w-0"
placeholder="Enter Keyword"
value={field.state.value}
onChange={(event) => {
field.handleChange(event.target.value);
if (searchInputError) controller.setSearchInputError(null);
}}
/>
)}
</controlsForm.Field>
</label>
<controlsForm.Field name="locationCode">
{(field) => (
<select
className="select select-bordered select-sm w-auto"
value={field.state.value}
onChange={(event) =>
field.handleChange(Number(event.target.value))
}
>
{LOCATION_OPTIONS.map((option) => (
<option key={option.code} value={option.code}>
{option.label}
</option>
))}
</select>
)}
</controlsForm.Field>
<controlsForm.Field name="resultLimit">
{(field) => (
<select
className="select select-bordered select-sm w-auto"
value={field.state.value}
onChange={(event) => {
const next = Number(event.target.value);
field.handleChange(isResultLimit(next) ? next : 150);
}}
>
{RESULT_LIMITS.map((limit) => (
<option key={limit} value={limit}>
{limit} results
</option>
))}
</select>
)}
</controlsForm.Field>
<controlsForm.Field name="mode">
{(field) => (
<select
className="select select-bordered select-sm w-auto"
value={field.state.value}
onChange={(event) =>
field.handleChange(normalizeKeywordMode(event.target.value))
}
>
<option value="auto">Auto</option>
<option value="related">Related keywords</option>
<option value="suggestions">Suggestions</option>
<option value="ideas">Ideas</option>
</select>
)}
</controlsForm.Field>
<button
type="submit"
className="btn btn-primary btn-sm px-6 font-semibold"
disabled={isLoading}
>
{isLoading ? "Searching..." : "Search"}
</button>
</form>
{searchInputError ? (
<p className="mt-2 text-sm text-error">{searchInputError}</p>
) : null}
</div>
);
}

View File

@ -0,0 +1,115 @@
import type { KeywordResearchControllerState } from "./types";
export function FilterTextInput({
form,
name,
label,
placeholder,
}: {
form: KeywordResearchControllerState["filtersForm"];
name: "include" | "exclude";
label: string;
placeholder: string;
}) {
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 bg-base-100"
placeholder={placeholder}
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
/>
)}
</form.Field>
</label>
);
}
export function FilterRangeInputs({
form,
title,
minName,
maxName,
step,
}: {
form: KeywordResearchControllerState["filtersForm"];
title: string;
minName: "minVol" | "minCpc" | "minKd";
maxName: "maxVol" | "maxCpc" | "maxKd";
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">
<CompactRangeInput
form={form}
name={minName}
placeholder="Min"
step={step}
/>
<CompactRangeInput
form={form}
name={maxName}
placeholder="Max"
step={step}
/>
</div>
</div>
);
}
function CompactRangeInput({
form,
name,
placeholder,
step,
}: {
form: KeywordResearchControllerState["filtersForm"];
name: "minVol" | "maxVol" | "minCpc" | "maxCpc" | "minKd" | "maxKd";
placeholder: string;
step?: string;
}) {
return (
<form.Field name={name}>
{(field) => (
<input
className="input input-bordered input-xs bg-base-100"
placeholder={placeholder}
type="number"
step={step}
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
/>
)}
</form.Field>
);
}
export function EmptyFilterResults({
activeFilterCount,
resetFilters,
}: {
activeFilterCount: number;
resetFilters: () => void;
}) {
return (
<div className="h-full flex flex-col items-center justify-center text-center px-4 text-base-content/50 gap-3">
<p className="text-sm font-medium">
No keywords match your current filters.
</p>
{activeFilterCount > 0 ? (
<button className="btn btn-ghost btn-sm" onClick={resetFilters}>
Clear filters
</button>
) : null}
</div>
);
}

View File

@ -0,0 +1,5 @@
import type { useKeywordResearchController } from "@/client/features/keywords/state/useKeywordResearchController";
export type KeywordResearchControllerState = ReturnType<
typeof useKeywordResearchController
>;

View File

@ -0,0 +1,235 @@
import { type FormEvent } from "react";
import { toast } from "sonner";
import { buildCsv, downloadCsv } from "@/client/lib/csv";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import type {
KeywordMode,
ResultLimit,
} from "@/client/features/keywords/keywordResearchTypes";
import { getLanguageCode } from "@/client/features/keywords/utils";
import type { KeywordResearchRow } from "@/types/keywords";
import type { SortDir, SortField } from "@/client/features/keywords/components";
import type { KeywordResearchControllerInput } from "./useKeywordResearchController";
type ControlsFormLike = {
state: {
values: {
keyword: string;
locationCode: number;
resultLimit: ResultLimit;
mode: KeywordMode;
};
};
};
type RunSearchLike = (
args: {
projectId: string;
keywords: string[];
locationCode: number;
resultLimit: ResultLimit;
mode: KeywordMode;
},
options: {
onSuccess: (seedKeyword: string, nextRows: KeywordResearchRow[]) => void;
},
) => void;
type SearchActionParams = {
controlsForm: ControlsFormLike;
input: KeywordResearchControllerInput;
beginSearch: (seedKeyword: string, locationCode: number) => void;
runSearch: RunSearchLike;
clearSelection: () => void;
setSelectedKeyword: (keyword: KeywordResearchRow | null) => void;
setSerpKeyword: (keyword: string | null) => void;
setSerpPage: (page: number) => void;
setSearchInputError: (error: string | null) => void;
setSearchParams: (
updates: Record<string, string | number | boolean | undefined>,
) => void;
};
type SaveExportActionParams = {
selectedRows: Set<string>;
filteredRows: KeywordResearchRow[];
input: KeywordResearchControllerInput;
saveKeywordsMutate: (
variables: {
projectId: string;
keywords: string[];
locationCode: number;
languageCode: string;
},
options: {
onSuccess: () => void;
onError: (error: unknown) => void;
},
) => void;
setShowSaveDialog: (show: boolean) => void;
};
function getNextSortParams(
currentField: SortField,
currentDirection: SortDir,
targetField: SortField,
): { sort: SortField; order: SortDir } {
if (currentField !== targetField) {
return { sort: targetField, order: "desc" };
}
return {
sort: currentField,
order: currentDirection === "asc" ? "desc" : "asc",
};
}
export function useSearchActions(params: SearchActionParams) {
const {
controlsForm,
input,
beginSearch,
runSearch,
clearSelection,
setSelectedKeyword,
setSerpKeyword,
setSerpPage,
setSearchInputError,
setSearchParams,
} = params;
const onSearch = (
overrides?: Partial<{
keyword: string;
locationCode: number;
}>,
) => {
const values = controlsForm.state.values;
const inputKeyword = overrides?.keyword ?? values.keyword;
const activeLocation = overrides?.locationCode ?? values.locationCode;
const activeResultLimit = values.resultLimit;
const activeMode = values.mode;
const keywords = inputKeyword
.split(/[\n,]/)
.map((keyword) => keyword.trim())
.filter(Boolean);
if (keywords.length === 0) {
setSearchInputError("Please enter at least one keyword.");
return;
}
setSearchInputError(null);
setSearchParams({
q: inputKeyword,
loc: activeLocation === 2840 ? undefined : activeLocation,
kLimit: activeResultLimit === 150 ? undefined : activeResultLimit,
mode: activeMode === "auto" ? undefined : activeMode,
});
setSelectedKeyword(null);
clearSelection();
setSerpKeyword(null);
beginSearch(keywords[0], activeLocation);
runSearch(
{
projectId: input.projectId,
keywords,
locationCode: activeLocation,
resultLimit: activeResultLimit,
mode: activeMode,
},
{
onSuccess: (seedKeyword, nextRows) => {
if (nextRows.length === 0) {
setSerpKeyword(null);
return;
}
setSerpKeyword(seedKeyword);
setSerpPage(0);
},
},
);
};
const handleSearchSubmit = (event: FormEvent) => {
event.preventDefault();
onSearch();
};
const toggleSort = (field: SortField) => {
setSearchParams(getNextSortParams(input.sortField, input.sortDir, field));
};
return { onSearch, handleSearchSubmit, toggleSort };
}
export function useSaveAndExportActions(params: SaveExportActionParams) {
const {
selectedRows,
filteredRows,
input,
saveKeywordsMutate,
setShowSaveDialog,
} = params;
const handleSaveKeywords = () => {
if (selectedRows.size === 0) {
toast.error("Select at least one keyword first");
return;
}
setShowSaveDialog(true);
};
const confirmSave = () => {
saveKeywordsMutate(
{
projectId: input.projectId,
keywords: [...selectedRows],
locationCode: input.locationCode,
languageCode: getLanguageCode(input.locationCode),
},
{
onSuccess: () => {
toast.success(`Saved ${selectedRows.size} keywords`);
setShowSaveDialog(false);
},
onError: (error: unknown) => {
toast.error(getStandardErrorMessage(error, "Save failed."));
},
},
);
};
const exportCsv = () => {
const source =
selectedRows.size > 0
? filteredRows.filter((row) => selectedRows.has(row.keyword))
: filteredRows;
if (source.length === 0) {
toast.error("No data to export");
return;
}
const headers = [
"Keyword",
"Volume",
"CPC",
"Competition",
"Difficulty",
"Intent",
];
const csvRows = source.map((row) => [
row.keyword,
row.searchVolume ?? "",
row.cpc?.toFixed(2) ?? "",
row.competition?.toFixed(2) ?? "",
row.keywordDifficulty ?? "",
row.intent,
]);
const csv = buildCsv(headers, csvRows);
downloadCsv("keyword-research.csv", csv);
};
return { handleSaveKeywords, confirmSave, exportCsv };
}

View File

@ -0,0 +1,51 @@
import { useMemo } from "react";
import type { KeywordMode } from "@/client/features/keywords/keywordResearchTypes";
import type { KeywordResearchRow } from "@/types/keywords";
export function useKeywordOverviewState({
rows,
searchedKeyword,
selectedKeyword,
hasSearched,
isLoading,
lastSearchError,
keywordMode,
}: {
rows: KeywordResearchRow[];
searchedKeyword: string;
selectedKeyword: KeywordResearchRow | null;
hasSearched: boolean;
isLoading: boolean;
lastSearchError: boolean;
keywordMode: KeywordMode;
}) {
const hasExactMatchInResults = useMemo(() => {
const normalizedSeed = searchedKeyword.trim().toLowerCase();
if (!normalizedSeed || rows.length === 0) return false;
return rows.some(
(row) => row.keyword.trim().toLowerCase() === normalizedSeed,
);
}, [rows, searchedKeyword]);
const showApproximateMatchNotice =
hasSearched &&
!isLoading &&
!lastSearchError &&
rows.length > 0 &&
searchedKeyword.trim() !== "" &&
!hasExactMatchInResults &&
keywordMode !== "auto";
const overviewKeyword: KeywordResearchRow | null = useMemo(() => {
if (selectedKeyword) return selectedKeyword;
if (searchedKeyword && rows.length > 0) {
const seed = rows.find(
(row) => row.keyword.toLowerCase() === searchedKeyword.toLowerCase(),
);
if (seed) return seed;
}
return rows.length > 0 ? rows[0] : null;
}, [selectedKeyword, searchedKeyword, rows]);
return { showApproximateMatchNotice, overviewKeyword };
}

View File

@ -0,0 +1,281 @@
import { useNavigate } from "@tanstack/react-router";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import { useKeywordControlsForm } from "@/client/features/keywords/hooks/useKeywordControlsForm";
import { useKeywordFiltering } from "@/client/features/keywords/hooks/useKeywordFiltering";
import { useLocalKeywordFilters } from "@/client/features/keywords/hooks/useLocalKeywordFilters";
import { useKeywordResearchData } from "@/client/features/keywords/hooks/useKeywordResearchData";
import { useKeywordSelection } from "@/client/features/keywords/hooks/useKeywordSelection";
import { useKeywordSerpAnalysis } from "@/client/features/keywords/hooks/useKeywordSerpAnalysis";
import { useSearchHistory } from "@/client/hooks/useSearchHistory";
import {
type KeywordMode,
type ResultLimit,
} from "@/client/features/keywords/keywordResearchTypes";
import { saveKeywords } from "@/serverFunctions/keywords";
import type { KeywordResearchRow } from "@/types/keywords";
import type { SortDir, SortField } from "@/client/features/keywords/components";
import {
useSaveAndExportActions,
useSearchActions,
} from "./keywordControllerActions";
import { useKeywordOverviewState } from "./useKeywordOverviewState";
export type KeywordResearchControllerInput = {
projectId: string;
keywordInput: string;
locationCode: number;
resultLimit: ResultLimit;
keywordMode: KeywordMode;
sortField: SortField;
sortDir: SortDir;
};
export function useKeywordResearchController(
input: KeywordResearchControllerInput,
) {
const state = useKeywordControllerState(input);
const { onSearch, handleSearchSubmit, toggleSort } = useSearchActions({
controlsForm: state.controlsForm,
input,
beginSearch: state.beginSearch,
runSearch: state.runSearch,
clearSelection: state.clearSelection,
setSelectedKeyword: state.setSelectedKeyword,
setSerpKeyword: state.setSerpKeyword,
setSerpPage: state.setSerpPage,
setSearchInputError: state.setSearchInputError,
setSearchParams: state.setSearchParams,
});
const { handleSaveKeywords, confirmSave, exportCsv } =
useSaveAndExportActions({
selectedRows: state.selectedRows,
filteredRows: state.filteredRows,
input,
saveKeywordsMutate: state.saveMutation.mutate,
setShowSaveDialog: state.setShowSaveDialog,
});
const handleToggleAllRows = () => {
state.toggleAllRows(state.filteredRows.map((row) => row.keyword));
};
const handleRowClick = (row: KeywordResearchRow) => {
state.setSelectedKeyword(row);
state.setSerpKeyword(row.keyword);
state.setSerpPage(0);
};
return buildControllerOutput({
activeFilterCount: state.activeFilterCount,
activeSerpKeyword: state.activeSerpKeyword,
clearHistory: state.clearHistory,
confirmSave,
controlsForm: state.controlsForm,
exportCsv,
filteredRows: state.filteredRows,
filtersForm: state.filtersForm,
handleRowClick,
handleSaveKeywords,
handleSearchSubmit,
hasSearched: state.hasSearched,
history: state.history,
historyLoaded: state.historyLoaded,
isLoading: state.isLoading,
lastResultSource: state.lastResultSource,
lastSearchError: state.lastSearchError,
lastSearchKeyword: state.lastSearchKeyword,
lastSearchLocationCode: state.lastSearchLocationCode,
lastUsedFallback: state.lastUsedFallback,
mobileTab: state.mobileTab,
onSearch,
overviewKeyword: state.overviewKeyword,
removeHistoryItem: state.removeHistoryItem,
researchError: state.researchError,
resetFilters: state.resetFilters,
rows: state.rows,
searchedKeyword: state.searchedKeyword,
searchInputError: state.searchInputError,
selectedRows: state.selectedRows,
serpError: state.serpError,
serpLoading: state.serpLoading,
serpPage: state.serpPage,
serpQuery: state.serpQuery,
serpResults: state.serpResults,
setMobileTab: state.setMobileTab,
setSearchInputError: state.setSearchInputError,
setSerpPage: state.setSerpPage,
setShowFilters: state.setShowFilters,
setShowSaveDialog: state.setShowSaveDialog,
showApproximateMatchNotice: state.showApproximateMatchNotice,
showFilters: state.showFilters,
showSaveDialog: state.showSaveDialog,
sortDir: input.sortDir,
sortField: input.sortField,
toggleAllRows: handleToggleAllRows,
toggleRowSelection: state.toggleRowSelection,
toggleSort,
SERP_PAGE_SIZE: state.SERP_PAGE_SIZE,
});
}
function useKeywordControllerState(input: KeywordResearchControllerInput) {
const [showFilters, setShowFilters] = useState(false);
const [selectedKeyword, setSelectedKeyword] =
useState<KeywordResearchRow | null>(null);
const controlsForm = useKeywordControlsForm(input);
const {
filtersForm,
values: filterValues,
resetFilters,
} = useLocalKeywordFilters();
const { selectedRows, clearSelection, toggleRowSelection, toggleAllRows } =
useKeywordSelection();
const {
setSerpKeyword,
serpPage,
setSerpPage,
SERP_PAGE_SIZE,
serpQuery,
serpResults,
activeSerpKeyword,
serpLoading,
serpError,
} = useKeywordSerpAnalysis(input.locationCode);
const {
history,
isLoaded: historyLoaded,
addSearch,
clearHistory,
removeHistoryItem,
} = useSearchHistory(input.projectId);
const {
rows,
hasSearched,
lastSearchError,
lastResultSource,
lastUsedFallback,
lastSearchKeyword,
lastSearchLocationCode,
researchError,
searchedKeyword,
isLoading,
beginSearch,
runSearch,
} = useKeywordResearchData(addSearch);
const [searchInputError, setSearchInputError] = useState<string | null>(null);
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [mobileTab, setMobileTab] = useState<"keywords" | "serp">("keywords");
const setSearchParams = useKeywordSearchParams();
const saveMutation = useKeywordSaveMutation(input.projectId);
const { filteredRows, activeFilterCount } = useKeywordFiltering({
rows,
filters: filterValues,
sortField: input.sortField,
sortDir: input.sortDir,
});
const { showApproximateMatchNotice, overviewKeyword } =
useKeywordOverviewState({
rows,
searchedKeyword,
selectedKeyword,
hasSearched,
isLoading,
lastSearchError,
keywordMode: input.keywordMode,
});
return {
activeFilterCount,
activeSerpKeyword,
beginSearch,
clearSelection,
clearHistory,
controlsForm,
filteredRows,
filtersForm,
hasSearched,
history,
historyLoaded,
isLoading,
lastResultSource,
lastSearchError,
lastSearchKeyword,
lastSearchLocationCode,
lastUsedFallback,
mobileTab,
overviewKeyword,
removeHistoryItem,
researchError,
runSearch,
resetFilters,
rows,
searchedKeyword,
searchInputError,
selectedKeyword,
selectedRows,
saveMutation,
setSelectedKeyword,
setSearchParams,
setSerpKeyword,
serpError,
serpLoading,
serpPage,
serpQuery,
serpResults,
setMobileTab,
setSearchInputError,
setSerpPage,
setShowFilters,
setShowSaveDialog,
showApproximateMatchNotice,
showFilters,
showSaveDialog,
toggleAllRows,
toggleRowSelection,
SERP_PAGE_SIZE,
};
}
function useKeywordSearchParams() {
const navigate = useNavigate({ from: "/p/$projectId/keywords" });
return useCallback(
(updates: Record<string, string | number | boolean | undefined>) => {
void navigate({
search: (prev) => ({ ...prev, ...updates }),
replace: true,
});
},
[navigate],
);
}
function useKeywordSaveMutation(projectId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: {
projectId: string;
keywords: string[];
locationCode: number;
languageCode: string;
}) => saveKeywords({ data }),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["savedKeywords", projectId],
});
},
});
}
function buildControllerOutput<T extends Record<string, unknown>>(state: T): T {
return state;
}

View File

@ -54,9 +54,3 @@ export function formatNumber(value: number | null | undefined): string {
if (value == null) return "-"; if (value == null) return "-";
return new Intl.NumberFormat().format(value); return new Intl.NumberFormat().format(value);
} }
export function csvEscape(value: string | number | null | undefined): string {
if (value == null) return "";
const text = String(value).replace(/"/g, '""');
return `"${text}"`;
}

View File

@ -0,0 +1,362 @@
import {
ChevronDown,
Copy,
Download,
FileWarning,
Info,
TriangleAlert,
} from "lucide-react";
import type { CategoryTab, ExportPayload, PsiIssue } from "./types";
import {
categoryLabel,
renderInlineMarkdown,
severityBadgeClass,
severityIcon,
} from "./utils";
import { categoryTabs } from "./types";
export function PsiIssuesHeader({
backLabel,
onBack,
scannedAt,
finalUrl,
severityCounts,
}: {
backLabel: string;
onBack: () => void;
scannedAt?: string;
finalUrl?: string;
severityCounts: { critical: number; warning: number; info: number };
}) {
return (
<>
<div className="flex items-center justify-between gap-3">
<button className="btn btn-ghost btn-sm px-2" onClick={onBack}>
&larr; Back to {backLabel}
</button>
<span className="text-xs text-base-content/60">
{scannedAt
? `Scanned ${new Date(scannedAt).toLocaleString()}`
: "Reading latest issues..."}
</span>
</div>
<div className="card bg-base-100 border border-base-300">
<div className="card-body py-5 gap-4">
<div className="space-y-1">
<h1 className="text-2xl font-semibold">PSI Issues</h1>
<p className="text-sm text-base-content/70 break-all">
{finalUrl ?? "Loading URL..."}
</p>
</div>
<div className="flex flex-wrap gap-2 text-xs">
<span className="badge border border-error/30 bg-error/10 text-error/80 gap-1">
<FileWarning className="size-3" />
Critical {severityCounts.critical}
</span>
<span className="badge border border-warning/30 bg-warning/10 text-warning/80 gap-1">
<TriangleAlert className="size-3" />
Warning {severityCounts.warning}
</span>
<span className="badge border border-info/30 bg-info/10 text-info/80 gap-1">
<Info className="size-3" />
Info {severityCounts.info}
</span>
</div>
</div>
</div>
</>
);
}
export function PsiIssuesToolbar({
category,
categoryCounts,
selectedCategoryLabel,
isBusy,
visibleIssues,
allIssues,
onCategoryChange,
onCopy,
onExport,
onExportCsv,
}: {
category: CategoryTab;
categoryCounts: Record<CategoryTab, number>;
selectedCategoryLabel: string;
isBusy: boolean;
visibleIssues: PsiIssue[];
allIssues: PsiIssue[];
onCategoryChange: (next: CategoryTab) => void;
onCopy: (data: ExportPayload, toastMessage: string) => void;
onExport: (data: ExportPayload) => void;
onExportCsv: (issues: PsiIssue[], variant: "all" | "current") => void;
}) {
const exportCurrentCategory: ExportPayload =
category === "all" ? { mode: "issues" } : { mode: "category", category };
const categoryLabelLower = selectedCategoryLabel.toLowerCase();
return (
<div className="sticky top-0 z-[2] -mx-2 px-2 py-2 bg-base-100/95 backdrop-blur-sm border-b border-base-300/60">
<div className="flex flex-wrap items-center justify-between gap-3">
<CategoryTabs
category={category}
categoryCounts={categoryCounts}
onCategoryChange={onCategoryChange}
/>
<ExportMenu
allIssues={allIssues}
categoryLabelLower={categoryLabelLower}
exportCurrentCategory={exportCurrentCategory}
isBusy={isBusy}
onCopy={onCopy}
onExport={onExport}
onExportCsv={onExportCsv}
visibleIssues={visibleIssues}
/>
</div>
</div>
);
}
function CategoryTabs({
category,
categoryCounts,
onCategoryChange,
}: {
category: CategoryTab;
categoryCounts: Record<CategoryTab, number>;
onCategoryChange: (next: CategoryTab) => void;
}) {
return (
<div className="flex flex-wrap items-center gap-4">
{categoryTabs.map((tab) => (
<button
key={tab}
className={`pb-2 border-b-2 text-sm font-medium transition-colors ${
category === tab
? "border-primary text-base-content"
: "border-transparent text-base-content/60 hover:text-base-content"
}`}
onClick={() => onCategoryChange(tab)}
>
<span>{categoryLabel(tab)}</span>
<span className="ml-1 text-xs opacity-70">
({categoryCounts[tab]})
</span>
</button>
))}
</div>
);
}
function ExportMenu({
allIssues,
categoryLabelLower,
exportCurrentCategory,
isBusy,
onCopy,
onExport,
onExportCsv,
visibleIssues,
}: {
allIssues: PsiIssue[];
categoryLabelLower: string;
exportCurrentCategory: ExportPayload;
isBusy: boolean;
onCopy: (data: ExportPayload, toastMessage: string) => void;
onExport: (data: ExportPayload) => void;
onExportCsv: (issues: PsiIssue[], variant: "all" | "current") => void;
visibleIssues: PsiIssue[];
}) {
return (
<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-72"
>
<li className="menu-title">
<span>Copy</span>
</li>
<li>
<button
disabled={isBusy}
onClick={() =>
onCopy(
exportCurrentCategory,
`Copied ${categoryLabelLower} issues`,
)
}
>
<Copy className="size-4" />
Copy {categoryLabelLower} issues
</button>
</li>
<li>
<button
disabled={isBusy}
onClick={() => onCopy({ mode: "issues" }, "Copied all issues")}
>
<Copy className="size-4" />
Copy all issues
</button>
</li>
<li>
<button
disabled={isBusy}
onClick={() =>
onCopy({ mode: "full" }, "Copied full Lighthouse report")
}
>
<Copy className="size-4" />
Copy full Lighthouse report
</button>
</li>
<li className="menu-title">
<span>Download JSON</span>
</li>
<li>
<button
disabled={isBusy}
onClick={() => onExport(exportCurrentCategory)}
>
Download {categoryLabelLower} issues
</button>
</li>
<li>
<button
disabled={isBusy}
onClick={() => onExport({ mode: "issues" })}
>
Download all issues
</button>
</li>
<li>
<button disabled={isBusy} onClick={() => onExport({ mode: "full" })}>
Download full Lighthouse report
</button>
</li>
<li className="menu-title">
<span>Download CSV</span>
</li>
<li>
<button
disabled={!visibleIssues.length}
onClick={() => onExportCsv(visibleIssues, "current")}
>
Download {categoryLabelLower} issues
</button>
</li>
<li>
<button
disabled={!allIssues.length}
onClick={() => onExportCsv(allIssues, "all")}
>
Download all issues
</button>
</li>
</ul>
</div>
);
}
export function PsiIssueList({
issues,
isLoading,
}: {
issues: PsiIssue[];
isLoading: boolean;
}) {
if (isLoading) {
return <p className="text-sm text-base-content/60">Loading issues...</p>;
}
if (!issues.length) {
return (
<p className="text-sm text-base-content/60">
No unresolved issues for this category.
</p>
);
}
return (
<div className="space-y-3">
{issues.map((issue) => (
<PsiIssueCard
key={`${issue.category}-${issue.auditKey}`}
issue={issue}
/>
))}
</div>
);
}
function PsiIssueCard({ issue }: { issue: PsiIssue }) {
return (
<div className="card bg-base-200/30 border border-base-300">
<div className="card-body p-5 gap-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<span className="badge badge-outline">{issue.category}</span>
<span
className={`badge border ${severityBadgeClass(issue.severity)} gap-1`}
>
{severityIcon(issue.severity)}
{issue.severity}
</span>
{issue.score != null ? (
<div
className="tooltip tooltip-top"
data-tip="Lighthouse score from 0-100 for this audit. Lower means larger opportunity for improvement."
>
<span className="badge badge-ghost cursor-help">
Score {issue.score}
</span>
</div>
) : null}
</div>
{issue.impactMs != null || issue.impactBytes != null ? (
<span className="text-xs text-base-content/60">
Impact {issue.impactMs ?? 0}ms / {issue.impactBytes ?? 0} bytes
</span>
) : null}
</div>
<p className="font-semibold leading-tight">{issue.title}</p>
{issue.displayValue ? (
<p className="text-sm text-base-content/70">{issue.displayValue}</p>
) : null}
{issue.description ? (
<div className="text-sm text-base-content/80 leading-relaxed">
{renderInlineMarkdown(issue.description)}
</div>
) : null}
{issue.items.length > 0 ? (
<details className="text-sm bg-base-100 rounded-box border border-base-300/80 px-3 py-2">
<summary className="cursor-pointer font-medium text-base-content/75">
Affected items ({issue.items.length})
</summary>
<div className="mt-2 space-y-2">
{issue.items.map((item) => (
<pre
key={`${issue.auditKey}-${item}`}
className="bg-base-200/60 p-2 rounded-box overflow-x-auto text-xs"
>
{item}
</pre>
))}
</div>
</details>
) : null}
</div>
</div>
);
}

View File

@ -0,0 +1,221 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { exportPsiBySource, getPsiIssuesBySource } from "@/serverFunctions/psi";
import type { CategoryTab, ExportPayload, PsiIssue } from "./types";
import {
categoryLabel,
categorySlug,
downloadTextFile,
issuesToCsv,
} from "./utils";
import {
PsiIssueList,
PsiIssuesHeader,
PsiIssuesToolbar,
} from "./PsiIssuesParts";
import { categoryTabs } from "./types";
type PsiIssuesScreenProps = {
projectId: string;
resultId: string;
source: string;
category: CategoryTab;
backLabel: string;
onBack: () => void;
onCategoryChange: (next: CategoryTab) => void;
};
export function PsiIssuesScreen(props: PsiIssuesScreenProps) {
const {
projectId,
resultId,
source,
category,
backLabel,
onBack,
onCategoryChange,
} = props;
const issuesQuery = useQuery({
queryKey: ["psiIssuesBySource", projectId, source, resultId, category],
queryFn: () =>
getPsiIssuesBySource({
data: {
projectId,
source,
resultId,
category: category === "all" ? undefined : category,
},
}),
});
const summaryQuery = useQuery({
queryKey: ["psiIssuesSummary", projectId, source, resultId],
queryFn: () =>
getPsiIssuesBySource({
data: {
projectId,
source,
resultId,
},
}),
});
const exportMutation = useMutation({
mutationFn: (data: ExportPayload) =>
exportPsiBySource({
data: {
projectId,
source,
resultId,
...data,
},
}),
});
const {
allIssues,
categoryCounts,
runCopy,
runExport,
runExportCsv,
selectedCategoryLabel,
severityCounts,
visibleIssues,
} = usePsiIssuesActions({
category,
exportMutation,
issues: (issuesQuery.data?.issues ?? []) as PsiIssue[],
summaryIssues: summaryQuery.data?.issues,
});
return (
<div className="px-4 py-3 md:px-6 md:py-4 pb-24 md:pb-8 overflow-auto">
<div className="mx-auto max-w-5xl space-y-4">
<PsiIssuesHeader
backLabel={backLabel}
onBack={onBack}
scannedAt={issuesQuery.data?.createdAt}
finalUrl={issuesQuery.data?.finalUrl}
severityCounts={severityCounts}
/>
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-4">
<PsiIssuesToolbar
category={category}
categoryCounts={categoryCounts}
selectedCategoryLabel={selectedCategoryLabel}
isBusy={exportMutation.isPending}
visibleIssues={visibleIssues}
allIssues={allIssues}
onCategoryChange={onCategoryChange}
onCopy={(data, message) => {
void runCopy(data, message);
}}
onExport={(data) => {
void runExport(data);
}}
onExportCsv={runExportCsv}
/>
<PsiIssueList
issues={visibleIssues}
isLoading={issuesQuery.isLoading}
/>
</div>
</div>
</div>
</div>
);
}
function usePsiIssuesActions({
category,
exportMutation,
issues,
summaryIssues,
}: {
category: CategoryTab;
exportMutation: {
mutateAsync: (
data: ExportPayload,
) => Promise<{ filename: string; content: string }>;
};
issues: PsiIssue[];
summaryIssues: PsiIssue[] | undefined;
}) {
const visibleIssues = issues;
const allIssues = summaryIssues ?? visibleIssues;
const selectedCategoryLabel = categoryLabel(category);
const categoryCounts = getCategoryCounts(allIssues);
const severityCounts = getSeverityCounts(visibleIssues);
const runExport = async (data: ExportPayload) => {
try {
const exported = await exportMutation.mutateAsync(data);
downloadTextFile(exported.filename, exported.content, "application/json");
toast.success("Download started");
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to export payload";
toast.error(message);
}
};
const runExportCsv = (rows: PsiIssue[], variant: "all" | "current") => {
const filename = `psi-${variant}-${categorySlug(category)}-issues.csv`;
downloadTextFile(filename, issuesToCsv(rows), "text/csv");
toast.success("CSV download started");
};
const runCopy = async (data: ExportPayload, toastMessage: string) => {
try {
const exported = await exportMutation.mutateAsync(data);
await navigator.clipboard.writeText(exported.content);
toast.success(toastMessage);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to copy payload";
toast.error(message);
}
};
return {
allIssues,
categoryCounts,
runCopy,
runExport,
runExportCsv,
selectedCategoryLabel,
severityCounts,
visibleIssues,
};
}
function getCategoryCounts(allIssues: PsiIssue[]): Record<CategoryTab, number> {
return categoryTabs.reduce<Record<CategoryTab, number>>(
(acc, tab) => {
if (tab === "all") {
acc[tab] = allIssues.length;
return acc;
}
acc[tab] = allIssues.filter((issue) => issue.category === tab).length;
return acc;
},
{
all: allIssues.length,
performance: 0,
accessibility: 0,
"best-practices": 0,
seo: 0,
},
);
}
function getSeverityCounts(issues: PsiIssue[]) {
return {
critical: issues.filter((issue) => issue.severity === "critical").length,
warning: issues.filter((issue) => issue.severity === "warning").length,
info: issues.filter((issue) => issue.severity === "info").length,
};
}

View File

@ -0,0 +1,28 @@
export const categoryTabs = [
"all",
"performance",
"accessibility",
"best-practices",
"seo",
] as const;
export type CategoryTab = (typeof categoryTabs)[number];
export type IssueCategory = Exclude<CategoryTab, "all">;
export type ExportPayload = {
mode: "full" | "issues" | "category";
category?: IssueCategory;
};
export type PsiIssue = {
auditKey: string;
category: IssueCategory;
severity: "critical" | "warning" | "info";
score?: number | null;
title: string;
displayValue?: string | null;
description?: string | null;
impactMs?: number | null;
impactBytes?: number | null;
items: string[];
};

View File

@ -0,0 +1,113 @@
import type { ReactNode } from "react";
import { ExternalLink, FileWarning, Info, TriangleAlert } from "lucide-react";
import { buildCsv } from "@/client/lib/csv";
import type { CategoryTab, PsiIssue } from "./types";
export function categoryLabel(category: CategoryTab) {
if (category === "best-practices") return "Best practices";
if (category === "all") return "All";
return `${category.charAt(0).toUpperCase()}${category.slice(1)}`;
}
export function categorySlug(category: CategoryTab) {
return category === "all" ? "all" : category;
}
export function issuesToCsv(issues: PsiIssue[]) {
const headers = [
"Category",
"Severity",
"Score",
"Title",
"Display Value",
"Description",
"Impact (ms)",
"Impact (bytes)",
"Affected Items",
];
const rows = issues.map((issue) => [
issue.category,
issue.severity,
issue.score ?? "",
issue.title,
issue.displayValue ?? "",
issue.description ?? "",
issue.impactMs ?? "",
issue.impactBytes ?? "",
issue.items.length,
]);
return buildCsv(headers, rows);
}
export function renderInlineMarkdown(markdown: string): ReactNode {
const linkPattern = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g;
const nodes: ReactNode[] = [];
let cursor = 0;
let match = linkPattern.exec(markdown);
while (match) {
const [raw, label, href] = match;
const index = match.index;
if (index > cursor) {
nodes.push(markdown.slice(cursor, index));
}
nodes.push(
<a
key={`${href}-${index}`}
href={href}
target="_blank"
rel="noopener noreferrer"
className="link link-primary inline-flex items-center gap-1"
>
{label}
<ExternalLink className="size-3" />
</a>,
);
cursor = index + raw.length;
match = linkPattern.exec(markdown);
}
if (cursor < markdown.length) {
nodes.push(markdown.slice(cursor));
}
if (!nodes.length) {
return markdown;
}
return nodes;
}
export function downloadTextFile(
filename: string,
content: string,
mimeType: string,
) {
const blob = new Blob([content], { type: mimeType });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
}
export function severityBadgeClass(severity: "critical" | "warning" | "info") {
if (severity === "critical") {
return "border-error/30 bg-error/10 text-error/80";
}
if (severity === "warning") {
return "border-warning/35 bg-warning/10 text-warning/80";
}
return "border-info/30 bg-info/10 text-info/80";
}
export function severityIcon(severity: "critical" | "warning" | "info") {
if (severity === "critical") return <FileWarning className="size-3" />;
if (severity === "warning") return <TriangleAlert className="size-3" />;
return <Info className="size-3" />;
}

View File

@ -1,4 +1,6 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { z } from "zod";
import { jsonCodec } from "@/shared/json";
type DomainSortMode = "rank" | "traffic" | "volume"; type DomainSortMode = "rank" | "traffic" | "volume";
type DomainTab = "keywords" | "pages"; type DomainTab = "keywords" | "pages";
@ -16,34 +18,28 @@ type AddDomainSearchInput = Omit<DomainSearchHistoryItem, "timestamp">;
const MAX_HISTORY = 20; const MAX_HISTORY = 20;
const domainSearchHistoryItemSchema = z.object({
domain: z.string(),
subdomains: z.boolean(),
sort: z.enum(["rank", "traffic", "volume"]),
tab: z.enum(["keywords", "pages"]),
search: z.string().optional(),
timestamp: z.number(),
});
const domainSearchHistorySchema = z.array(domainSearchHistoryItemSchema);
const domainSearchHistoryCodec = jsonCodec(domainSearchHistorySchema);
function storageKey(projectId: string) { function storageKey(projectId: string) {
return `domain-search-history:${projectId}`; return `domain-search-history:${projectId}`;
} }
function loadHistory(projectId: string): DomainSearchHistoryItem[] { function loadHistory(projectId: string): DomainSearchHistoryItem[] {
try { const raw = localStorage.getItem(storageKey(projectId));
const raw = localStorage.getItem(storageKey(projectId)); if (!raw) return [];
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed const parsed = domainSearchHistoryCodec.safeParse(raw);
.filter( return parsed.success ? parsed.data.slice(0, MAX_HISTORY) : [];
(item): item is DomainSearchHistoryItem =>
item &&
typeof item.domain === "string" &&
typeof item.subdomains === "boolean" &&
(item.sort === "rank" ||
item.sort === "traffic" ||
item.sort === "volume") &&
(item.tab === "keywords" || item.tab === "pages") &&
(item.search === undefined || typeof item.search === "string") &&
typeof item.timestamp === "number",
)
.slice(0, MAX_HISTORY);
} catch {
return [];
}
} }
function saveHistory(projectId: string, items: DomainSearchHistoryItem[]) { function saveHistory(projectId: string, items: DomainSearchHistoryItem[]) {

View File

@ -1,4 +1,6 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { z } from "zod";
import { jsonCodec } from "@/shared/json";
export interface SearchHistoryItem { export interface SearchHistoryItem {
keyword: string; keyword: string;
@ -9,30 +11,26 @@ export interface SearchHistoryItem {
const MAX_HISTORY = 20; const MAX_HISTORY = 20;
const searchHistoryItemSchema = z.object({
keyword: z.string(),
locationCode: z.number(),
locationName: z.string(),
timestamp: z.number(),
});
const searchHistorySchema = z.array(searchHistoryItemSchema);
const searchHistoryCodec = jsonCodec(searchHistorySchema);
function storageKey(projectId: string) { function storageKey(projectId: string) {
return `search-history:${projectId}`; return `search-history:${projectId}`;
} }
function loadHistory(projectId: string): SearchHistoryItem[] { function loadHistory(projectId: string): SearchHistoryItem[] {
try { const raw = localStorage.getItem(storageKey(projectId));
const raw = localStorage.getItem(storageKey(projectId)); if (!raw) return [];
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed const parsed = searchHistoryCodec.safeParse(raw);
.filter( return parsed.success ? parsed.data.slice(0, MAX_HISTORY) : [];
(item): item is SearchHistoryItem =>
item &&
typeof item.keyword === "string" &&
typeof item.locationCode === "number" &&
typeof item.locationName === "string" &&
typeof item.timestamp === "number",
)
.slice(0, MAX_HISTORY);
} catch {
return [];
}
} }
function saveHistory(projectId: string, items: SearchHistoryItem[]) { function saveHistory(projectId: string, items: SearchHistoryItem[]) {

View File

@ -0,0 +1,232 @@
import * as React from "react";
import { Link, Outlet } from "@tanstack/react-router";
import {
AlertTriangle,
ChevronsUpDown,
ExternalLink,
Menu,
} from "lucide-react";
import { Sidebar } from "@/client/components/Sidebar";
import { projectNavItems } from "@/client/navigation/items";
export function TopNav({
drawerOpen,
projectId,
pathname,
onOpenDrawer,
}: {
drawerOpen: boolean;
projectId: string | null;
pathname: string;
onOpenDrawer: () => void;
}) {
return (
<div className="navbar bg-base-100 border-b border-base-300 shrink-0 gap-2">
<div className="flex-none flex items-center md:hidden">
<button
type="button"
className="btn btn-square btn-ghost"
aria-label="Toggle sidebar"
aria-expanded={drawerOpen}
onClick={onOpenDrawer}
>
<Menu className="h-6 w-6" />
</button>
<span className="font-semibold text-base-content ml-1">OpenSEO</span>
</div>
<div className="hidden md:flex items-center gap-1">
<span className="text-lg font-semibold text-base-content px-2">
OpenSEO
</span>
{projectId
? projectNavItems.map((item) => {
const Icon = item.icon;
const isActive = pathname.includes(item.matchSegment);
return (
<Link
key={item.to}
to={item.to}
params={{ projectId }}
className={`btn btn-sm gap-2 ${
isActive
? "bg-primary/10 text-primary font-medium border-transparent"
: "btn-ghost text-base-content/60 hover:text-base-content"
}`}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
);
})
: null}
</div>
<div className="flex-1" />
<div className="flex-none hidden md:flex">
<div
className="tooltip tooltip-left before:whitespace-nowrap"
data-tip="Multiple projects coming soon"
>
<button className="btn btn-ghost btn-sm font-medium text-sm gap-1 cursor-default">
<span className="truncate">Default</span>
<ChevronsUpDown className="size-3.5 shrink-0 text-base-content/40" />
</button>
</div>
</div>
</div>
);
}
export function SeoApiStatusBanners({
helpPath,
shouldShowSeoApiWarning,
seoApiKeyStatusError,
}: {
helpPath: string;
shouldShowSeoApiWarning: boolean;
seoApiKeyStatusError: boolean;
}) {
return (
<>
{shouldShowSeoApiWarning ? (
<div className="shrink-0 px-4 py-2.5 md:px-6">
<div className="mx-auto max-w-7xl">
<div className="alert alert-warning">
<AlertTriangle className="size-4 shrink-0" />
<span className="text-sm">
Setup needed: add your DataForSEO API key to use OpenSEO
features. See the quick steps on the{" "}
<Link to={helpPath} className="link link-primary font-medium">
help page
</Link>
.
</span>
</div>
</div>
</div>
) : null}
{seoApiKeyStatusError ? (
<div className="shrink-0 px-4 py-2.5 md:px-6">
<div className="mx-auto max-w-7xl">
<div className="alert alert-info">
<AlertTriangle className="size-4 shrink-0" />
<span className="text-sm">
We could not verify your DataForSEO setup. If features are not
working, check the setup steps on the{" "}
<Link to={helpPath} className="link link-primary font-medium">
help page
</Link>
.
</span>
</div>
</div>
</div>
) : null}
</>
);
}
export function AppContent({
drawerOpen,
pathname,
projectId,
onCloseDrawer,
}: {
drawerOpen: boolean;
pathname: string;
projectId: string | null;
onCloseDrawer: () => void;
}) {
return (
<>
<div className="flex-1 min-h-0 md:hidden">
<div className="h-full overflow-auto">
<Outlet />
</div>
{drawerOpen ? (
<div className="fixed inset-0 z-50">
<button
type="button"
aria-label="Close sidebar"
className="absolute inset-0 bg-black/45"
onClick={onCloseDrawer}
/>
<div className="absolute left-0 top-0 h-full">
<Sidebar
currentPath={pathname}
projectId={projectId}
onNavigate={onCloseDrawer}
onClose={onCloseDrawer}
/>
</div>
</div>
) : null}
</div>
<div className="hidden md:block flex-1 min-h-0 overflow-auto">
<Outlet />
</div>
</>
);
}
export const MissingSeoSetupModal = React.forwardRef<
HTMLDivElement,
{
helpPath: string;
isOpen: boolean;
onClose: () => void;
}
>(({ helpPath, isOpen, onClose }, ref) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div
ref={ref}
role="dialog"
aria-modal="true"
aria-labelledby="dataforseo-setup-title"
aria-describedby="dataforseo-setup-description"
tabIndex={-1}
className="w-full max-w-lg rounded-xl border border-base-300 bg-base-100 p-5 shadow-2xl"
>
<div className="flex items-start gap-3">
<div className="rounded-full bg-warning/20 p-2 text-warning">
<AlertTriangle className="size-5" />
</div>
<div className="space-y-2">
<h2
id="dataforseo-setup-title"
className="text-lg font-semibold text-base-content"
>
One quick setup step
</h2>
<p
id="dataforseo-setup-description"
className="text-sm text-base-content/75"
>
Add your DataForSEO API key to start using OpenSEO.
</p>
</div>
</div>
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button type="button" className="btn btn-ghost" onClick={onClose}>
Dismiss
</button>
<Link to={helpPath} className="btn btn-primary" onClick={onClose}>
Open setup guide
<ExternalLink className="size-4" />
</Link>
</div>
</div>
</div>
);
});
MissingSeoSetupModal.displayName = "MissingSeoSetupModal";

28
src/client/lib/csv.ts Normal file
View File

@ -0,0 +1,28 @@
import Papa from "papaparse";
type CsvValue = string | number | boolean | null | undefined;
export function buildCsv(headers: string[], rows: CsvValue[][]): string {
const normalizedRows = rows.map((row) => row.map((value) => value ?? ""));
return Papa.unparse(
{
fields: headers,
data: normalizedRows,
},
{
quotes: true,
newline: "\n",
},
);
}
export function downloadCsv(filename: string, content: string): void {
const blob = new Blob([content], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}

View File

@ -67,6 +67,9 @@ export const keywordMetrics = sqliteTable(
"keyword_metrics", "keyword_metrics",
{ {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
keyword: text("keyword").notNull(), keyword: text("keyword").notNull(),
locationCode: integer("location_code").notNull(), locationCode: integer("location_code").notNull(),
languageCode: text("language_code").notNull().default("en"), languageCode: text("language_code").notNull().default("en"),
@ -81,12 +84,14 @@ export const keywordMetrics = sqliteTable(
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
}, },
(table) => [ (table) => [
uniqueIndex("keyword_metrics_unique_keyword_location_language").on( uniqueIndex("keyword_metrics_unique_project_keyword_location_language").on(
table.projectId,
table.keyword, table.keyword,
table.locationCode, table.locationCode,
table.languageCode, table.languageCode,
), ),
index("keyword_metrics_lookup_idx").on( index("keyword_metrics_lookup_idx").on(
table.projectId,
table.keyword, table.keyword,
table.locationCode, table.locationCode,
table.languageCode, table.languageCode,

View File

@ -2,10 +2,8 @@
import { import {
ClientOnly, ClientOnly,
HeadContent, HeadContent,
Link,
Scripts, Scripts,
createRootRoute, createRootRoute,
Outlet,
useLocation, useLocation,
} from "@tanstack/react-router"; } from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
@ -13,20 +11,18 @@ import { TanStackDevtools } from "@tanstack/react-devtools";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import * as React from "react"; import * as React from "react";
import { useState } from "react"; import { useState } from "react";
import {
Menu,
ChevronsUpDown,
AlertTriangle,
ExternalLink,
} from "lucide-react";
import { DefaultCatchBoundary } from "@/client/components/DefaultCatchBoundary"; import { DefaultCatchBoundary } from "@/client/components/DefaultCatchBoundary";
import { NotFound } from "@/client/components/NotFound"; import { NotFound } from "@/client/components/NotFound";
import appCss from "@/client/styles/app.css?url"; import appCss from "@/client/styles/app.css?url";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { Sidebar } from "@/client/components/Sidebar";
import { queryClient } from "@/client/tanstack-db"; import { queryClient } from "@/client/tanstack-db";
import { projectNavItems } from "@/client/navigation/items";
import { getSeoApiKeyStatus } from "@/serverFunctions/config"; import { getSeoApiKeyStatus } from "@/serverFunctions/config";
import {
AppContent,
MissingSeoSetupModal,
SeoApiStatusBanners,
TopNav,
} from "@/client/layout/AppShell";
const DATAFORSEO_HELP_PATH = "/help/dataforseo-api-key"; const DATAFORSEO_HELP_PATH = "/help/dataforseo-api-key";
@ -148,191 +144,32 @@ function AppLayout() {
return ( return (
<div className="flex flex-col h-[100dvh] bg-base-200"> <div className="flex flex-col h-[100dvh] bg-base-200">
{/* Top Navbar */} <TopNav
<div className="navbar bg-base-100 border-b border-base-300 shrink-0 gap-2"> drawerOpen={drawerOpen}
{/* Mobile: hamburger + title */} projectId={projectId}
<div className="flex-none flex items-center md:hidden"> pathname={location.pathname}
<button onOpenDrawer={() => setDrawerOpen(true)}
type="button" />
className="btn btn-square btn-ghost"
aria-label="Toggle sidebar"
aria-expanded={drawerOpen}
onClick={() => setDrawerOpen(true)}
>
<Menu className="h-6 w-6" />
</button>
<span className="font-semibold text-base-content ml-1">OpenSEO</span>
</div>
{/* Desktop: app brand + nav links (left) */} <SeoApiStatusBanners
<div className="hidden md:flex items-center gap-1"> helpPath={DATAFORSEO_HELP_PATH}
<span className="text-lg font-semibold text-base-content px-2"> shouldShowSeoApiWarning={shouldShowSeoApiWarning}
OpenSEO seoApiKeyStatusError={seoApiKeyStatusError}
</span> />
{projectId &&
projectNavItems.map((item) => {
const Icon = item.icon;
const isActive = location.pathname.includes(item.matchSegment);
return ( <AppContent
<Link drawerOpen={drawerOpen}
key={item.to} pathname={location.pathname}
to={item.to} projectId={projectId}
params={{ projectId }} onCloseDrawer={() => setDrawerOpen(false)}
className={`btn btn-sm gap-2 ${ />
isActive
? "bg-primary/10 text-primary font-medium border-transparent"
: "btn-ghost text-base-content/60 hover:text-base-content"
}`}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
</div>
{/* Spacer */} <MissingSeoSetupModal
<div className="flex-1" /> ref={setupModalRef}
helpPath={DATAFORSEO_HELP_PATH}
{/* Desktop: project switcher (right-aligned) */} isOpen={shouldShowMissingSeoApiKeyModal}
<div className="flex-none hidden md:flex"> onClose={() => setShowMissingSeoApiKeyModal(false)}
<div />
className="tooltip tooltip-left before:whitespace-nowrap"
data-tip="Multiple projects coming soon"
>
<button className="btn btn-ghost btn-sm font-medium text-sm gap-1 cursor-default">
<span className="truncate">Default</span>
<ChevronsUpDown className="size-3.5 shrink-0 text-base-content/40" />
</button>
</div>
</div>
</div>
{shouldShowSeoApiWarning ? (
<div className="shrink-0 px-4 py-2.5 md:px-6">
<div className="mx-auto max-w-7xl">
<div className="alert alert-warning">
<AlertTriangle className="size-4 shrink-0" />
<span className="text-sm">
Setup needed: add your DataForSEO API key to use OpenSEO
features. See the quick steps on the{" "}
<Link
to={DATAFORSEO_HELP_PATH}
className="link link-primary font-medium"
>
help page
</Link>
.
</span>
</div>
</div>
</div>
) : null}
{seoApiKeyStatusError ? (
<div className="shrink-0 px-4 py-2.5 md:px-6">
<div className="mx-auto max-w-7xl">
<div className="alert alert-info">
<AlertTriangle className="size-4 shrink-0" />
<span className="text-sm">
We could not verify your DataForSEO setup. If features are not
working, check the setup steps on the{" "}
<Link
to={DATAFORSEO_HELP_PATH}
className="link link-primary font-medium"
>
help page
</Link>
.
</span>
</div>
</div>
</div>
) : null}
{/* Mobile: drawer layout */}
<div className="flex-1 min-h-0 md:hidden">
<div className="h-full overflow-auto">
<Outlet />
</div>
{drawerOpen ? (
<div className="fixed inset-0 z-50">
<button
type="button"
aria-label="Close sidebar"
className="absolute inset-0 bg-black/45"
onClick={() => setDrawerOpen(false)}
/>
<div className="absolute left-0 top-0 h-full">
<Sidebar
currentPath={location.pathname}
projectId={projectId}
onNavigate={() => setDrawerOpen(false)}
onClose={() => setDrawerOpen(false)}
/>
</div>
</div>
) : null}
</div>
{/* Desktop: plain content area */}
<div className="hidden md:block flex-1 min-h-0 overflow-auto">
<Outlet />
</div>
{shouldShowMissingSeoApiKeyModal ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div
ref={setupModalRef}
role="dialog"
aria-modal="true"
aria-labelledby="dataforseo-setup-title"
aria-describedby="dataforseo-setup-description"
tabIndex={-1}
className="w-full max-w-lg rounded-xl border border-base-300 bg-base-100 p-5 shadow-2xl"
>
<div className="flex items-start gap-3">
<div className="rounded-full bg-warning/20 p-2 text-warning">
<AlertTriangle className="size-5" />
</div>
<div className="space-y-2">
<h2
id="dataforseo-setup-title"
className="text-lg font-semibold text-base-content"
>
One quick setup step
</h2>
<p
id="dataforseo-setup-description"
className="text-sm text-base-content/75"
>
Add your DataForSEO API key to start using OpenSEO.
</p>
</div>
</div>
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button
type="button"
className="btn btn-ghost"
onClick={() => setShowMissingSeoApiKeyModal(false)}
>
Dismiss
</button>
<Link
to={DATAFORSEO_HELP_PATH}
className="btn btn-primary"
onClick={() => setShowMissingSeoApiKeyModal(false)}
>
Open setup guide
<ExternalLink className="size-4" />
</Link>
</div>
</div>
</div>
) : null}
</div> </div>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,554 +1,36 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useMutation, useQuery } from "@tanstack/react-query"; import { PsiIssuesScreen } from "@/client/features/psi/issues/PsiIssuesScreen";
import type { ReactNode } from "react";
import {
ChevronDown,
Copy,
Download,
ExternalLink,
FileWarning,
Info,
TriangleAlert,
} from "lucide-react";
import { toast } from "sonner";
import { exportPsiBySource, getPsiIssuesBySource } from "@/serverFunctions/psi";
import { psiIssuesSearchSchema } from "@/types/schemas/psi"; import { psiIssuesSearchSchema } from "@/types/schemas/psi";
const categoryTabs = [
"all",
"performance",
"accessibility",
"best-practices",
"seo",
] as const;
type CategoryTab = (typeof categoryTabs)[number];
type IssueCategory = Exclude<CategoryTab, "all">;
type ExportPayload = {
mode: "full" | "issues" | "category";
category?: IssueCategory;
};
type PsiIssue = {
auditKey: string;
category: IssueCategory;
severity: "critical" | "warning" | "info";
score?: number | null;
title: string;
displayValue?: string | null;
description?: string | null;
impactMs?: number | null;
impactBytes?: number | null;
items: string[];
};
export const Route = createFileRoute("/p/$projectId/audit/issues/$resultId")({ export const Route = createFileRoute("/p/$projectId/audit/issues/$resultId")({
validateSearch: psiIssuesSearchSchema, validateSearch: psiIssuesSearchSchema,
component: PsiIssuesPage, component: AuditIssuesPage,
}); });
function PsiIssuesPage() { function AuditIssuesPage() {
const { projectId, resultId } = Route.useParams(); const { projectId, resultId } = Route.useParams();
const { source, category } = Route.useSearch(); const { source, category } = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath }); const navigate = useNavigate({ from: Route.fullPath });
const issuesQuery = useQuery({
queryKey: ["psiIssuesBySource", projectId, source, resultId, category],
queryFn: () =>
getPsiIssuesBySource({
data: {
projectId,
source,
resultId,
category: category === "all" ? undefined : category,
},
}),
});
const summaryQuery = useQuery({
queryKey: ["psiIssuesSummary", projectId, source, resultId],
queryFn: () =>
getPsiIssuesBySource({
data: {
projectId,
source,
resultId,
},
}),
});
const exportMutation = useMutation({
mutationFn: (data: ExportPayload) =>
exportPsiBySource({
data: {
projectId,
source,
resultId,
...data,
},
}),
});
const visibleIssues = (issuesQuery.data?.issues ?? []) as PsiIssue[];
const allIssues = summaryQuery.data?.issues ?? visibleIssues;
const categoryCounts = categoryTabs.reduce<Record<CategoryTab, number>>(
(acc, tab) => {
if (tab === "all") {
acc[tab] = allIssues.length;
return acc;
}
acc[tab] = allIssues.filter((issue) => issue.category === tab).length;
return acc;
},
{
all: allIssues.length,
performance: 0,
accessibility: 0,
"best-practices": 0,
seo: 0,
},
);
const severityCounts = {
critical: visibleIssues.filter((issue) => issue.severity === "critical")
.length,
warning: visibleIssues.filter((issue) => issue.severity === "warning")
.length,
info: visibleIssues.filter((issue) => issue.severity === "info").length,
};
const exportCurrentCategory: ExportPayload =
category === "all"
? { mode: "issues" }
: {
mode: "category",
category,
};
const selectedCategoryLabel = categoryLabel(category);
const runExport = async (data: ExportPayload) => {
try {
const exported = await exportMutation.mutateAsync(data);
downloadTextFile(exported.filename, exported.content, "application/json");
toast.success("Download started");
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to export payload";
toast.error(message);
}
};
const runExportCsv = (issues: PsiIssue[], variant: "all" | "current") => {
const filename = `psi-${variant}-${categorySlug(category)}-issues.csv`;
downloadTextFile(filename, issuesToCsv(issues), "text/csv");
toast.success("CSV download started");
};
const runCopy = async (data: ExportPayload, toastMessage: string) => {
try {
const exported = await exportMutation.mutateAsync(data);
await navigator.clipboard.writeText(exported.content);
toast.success(toastMessage);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to copy payload";
toast.error(message);
}
};
const isBusy = exportMutation.isPending;
return ( return (
<div className="px-4 py-3 md:px-6 md:py-4 pb-24 md:pb-8 overflow-auto"> <PsiIssuesScreen
<div className="mx-auto max-w-5xl space-y-4"> projectId={projectId}
<div className="flex items-center justify-between gap-3"> resultId={resultId}
<button source={source}
className="btn btn-ghost btn-sm px-2" category={category}
onClick={() => backLabel="Site Audit"
navigate({ onBack={() =>
to: "/p/$projectId/audit", void navigate({
params: { projectId }, to: "/p/$projectId/audit",
}) params: { projectId },
} })
> }
&larr; Back to Site Audit onCategoryChange={(next) =>
</button> void navigate({
<span className="text-xs text-base-content/60"> search: (prev) => ({ ...prev, category: next }),
{issuesQuery.data?.createdAt replace: true,
? `Scanned ${new Date(issuesQuery.data.createdAt).toLocaleString()}` })
: "Reading latest issues..."} }
</span> />
</div>
<div className="card bg-base-100 border border-base-300">
<div className="card-body py-5 gap-4">
<div className="space-y-1">
<h1 className="text-2xl font-semibold">PSI Issues</h1>
<p className="text-sm text-base-content/70 break-all">
{issuesQuery.data?.finalUrl ?? "Loading URL..."}
</p>
</div>
<div className="flex flex-wrap gap-2 text-xs">
<span className="badge border border-error/30 bg-error/10 text-error/80 gap-1">
<FileWarning className="size-3" />
Critical {severityCounts.critical}
</span>
<span className="badge border border-warning/30 bg-warning/10 text-warning/80 gap-1">
<TriangleAlert className="size-3" />
Warning {severityCounts.warning}
</span>
<span className="badge border border-info/30 bg-info/10 text-info/80 gap-1">
<Info className="size-3" />
Info {severityCounts.info}
</span>
</div>
</div>
</div>
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-4">
<div className="sticky top-0 z-[2] -mx-2 px-2 py-2 bg-base-100/95 backdrop-blur-sm border-b border-base-300/60">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-4">
{categoryTabs.map((tab) => (
<button
key={tab}
className={`pb-2 border-b-2 text-sm font-medium transition-colors ${
category === tab
? "border-primary text-base-content"
: "border-transparent text-base-content/60 hover:text-base-content"
}`}
onClick={() =>
navigate({
search: (prev) => ({
...prev,
category: tab,
}),
replace: true,
})
}
>
<span>{categoryLabel(tab)}</span>
<span className="ml-1 text-xs opacity-70">
({categoryCounts[tab]})
</span>
</button>
))}
</div>
<div className="flex 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-72"
>
<li className="menu-title">
<span>Copy</span>
</li>
<li>
<button
disabled={isBusy}
onClick={() =>
runCopy(
exportCurrentCategory,
`Copied ${selectedCategoryLabel.toLowerCase()} issues`,
)
}
>
<Copy className="size-4" />
Copy {selectedCategoryLabel.toLowerCase()} issues
</button>
</li>
<li>
<button
disabled={isBusy}
onClick={() =>
runCopy({ mode: "issues" }, "Copied all issues")
}
>
<Copy className="size-4" />
Copy all issues
</button>
</li>
<li>
<button
disabled={isBusy}
onClick={() =>
runCopy(
{ mode: "full" },
"Copied full Lighthouse report",
)
}
>
<Copy className="size-4" />
Copy full Lighthouse report
</button>
</li>
<li className="menu-title">
<span>Download JSON</span>
</li>
<li>
<button
disabled={isBusy}
onClick={() => runExport(exportCurrentCategory)}
>
Download {selectedCategoryLabel.toLowerCase()} issues
</button>
</li>
<li>
<button
disabled={isBusy}
onClick={() => runExport({ mode: "issues" })}
>
Download all issues
</button>
</li>
<li>
<button
disabled={isBusy}
onClick={() => runExport({ mode: "full" })}
>
Download full Lighthouse report
</button>
</li>
<li className="menu-title">
<span>Download CSV</span>
</li>
<li>
<button
disabled={!visibleIssues.length}
onClick={() => runExportCsv(visibleIssues, "current")}
>
Download {selectedCategoryLabel.toLowerCase()} issues
</button>
</li>
<li>
<button
disabled={!allIssues.length}
onClick={() => runExportCsv(allIssues, "all")}
>
Download all issues
</button>
</li>
</ul>
</div>
</div>
</div>
</div>
{issuesQuery.isLoading ? (
<p className="text-sm text-base-content/60">Loading issues...</p>
) : visibleIssues.length ? (
<div className="space-y-3">
{visibleIssues.map((issue) => (
<div
key={`${issue.category}-${issue.auditKey}`}
className="card bg-base-200/30 border border-base-300"
>
<div className="card-body p-5 gap-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<span className="badge badge-outline">
{issue.category}
</span>
<span
className={`badge border ${severityBadgeClass(issue.severity)} gap-1`}
>
{severityIcon(issue.severity)}
{issue.severity}
</span>
{issue.score != null && (
<div
className="tooltip tooltip-top"
data-tip="Lighthouse score from 0-100 for this audit. Lower means larger opportunity for improvement."
>
<span className="badge badge-ghost cursor-help">
Score {issue.score}
</span>
</div>
)}
</div>
{(issue.impactMs != null ||
issue.impactBytes != null) && (
<span className="text-xs text-base-content/60">
Impact {issue.impactMs ?? 0}ms /{" "}
{issue.impactBytes ?? 0} bytes
</span>
)}
</div>
<p className="font-semibold leading-tight">
{issue.title}
</p>
{issue.displayValue && (
<p className="text-sm text-base-content/70">
{issue.displayValue}
</p>
)}
{issue.description && (
<div className="text-sm text-base-content/80 leading-relaxed">
{renderInlineMarkdown(issue.description)}
</div>
)}
{issue.items.length > 0 && (
<details className="text-sm bg-base-100 rounded-box border border-base-300/80 px-3 py-2">
<summary className="cursor-pointer font-medium text-base-content/75">
Affected items ({issue.items.length})
</summary>
<div className="mt-2 space-y-2">
{issue.items.map((item) => (
<pre
key={`${issue.auditKey}-${item}`}
className="bg-base-200/60 p-2 rounded-box overflow-x-auto text-xs"
>
{item}
</pre>
))}
</div>
</details>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-base-content/60">
No unresolved issues for this category.
</p>
)}
</div>
</div>
</div>
</div>
); );
} }
function categoryLabel(category: CategoryTab) {
if (category === "best-practices") return "Best practices";
if (category === "all") return "All";
return `${category.charAt(0).toUpperCase()}${category.slice(1)}`;
}
function categorySlug(category: CategoryTab) {
return category === "all" ? "all" : category;
}
function issuesToCsv(issues: PsiIssue[]) {
const headers = [
"Category",
"Severity",
"Score",
"Title",
"Display Value",
"Description",
"Impact (ms)",
"Impact (bytes)",
"Affected Items",
];
const rows = issues.map((issue) => [
issue.category,
issue.severity,
issue.score ?? "",
issue.title,
issue.displayValue ?? "",
issue.description ?? "",
issue.impactMs ?? "",
issue.impactBytes ?? "",
issue.items.length,
]);
return [
headers.map(csvEscape).join(","),
...rows.map((row) => row.map(csvEscape).join(",")),
].join("\n");
}
function csvEscape(value: string | number) {
const text = String(value);
if (text.includes(",") || text.includes('"') || text.includes("\n")) {
return `"${text.replaceAll('"', '""')}"`;
}
return text;
}
function renderInlineMarkdown(markdown: string): ReactNode {
const linkPattern = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g;
const nodes: ReactNode[] = [];
let cursor = 0;
let match = linkPattern.exec(markdown);
while (match) {
const [raw, label, href] = match;
const index = match.index;
if (index > cursor) {
nodes.push(markdown.slice(cursor, index));
}
nodes.push(
<a
key={`${href}-${index}`}
href={href}
target="_blank"
rel="noopener noreferrer"
className="link link-primary inline-flex items-center gap-1"
>
{label}
<ExternalLink className="size-3" />
</a>,
);
cursor = index + raw.length;
match = linkPattern.exec(markdown);
}
if (cursor < markdown.length) {
nodes.push(markdown.slice(cursor));
}
if (!nodes.length) {
return markdown;
}
return nodes;
}
function downloadTextFile(filename: string, content: string, mimeType: string) {
const blob = new Blob([content], { type: mimeType });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
}
function severityBadgeClass(severity: "critical" | "warning" | "info") {
if (severity === "critical")
return "border-error/30 bg-error/10 text-error/80";
if (severity === "warning")
return "border-warning/35 bg-warning/10 text-warning/80";
return "border-info/30 bg-info/10 text-info/80";
}
function severityIcon(severity: "critical" | "warning" | "info") {
if (severity === "critical") return <FileWarning className="size-3" />;
if (severity === "warning") return <TriangleAlert className="size-3" />;
return <Info className="size-3" />;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,48 +1,7 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useMutation, useQuery } from "@tanstack/react-query"; import { PsiIssuesScreen } from "@/client/features/psi/issues/PsiIssuesScreen";
import type { ReactNode } from "react";
import {
ChevronDown,
Copy,
Download,
ExternalLink,
FileWarning,
Info,
TriangleAlert,
} from "lucide-react";
import { toast } from "sonner";
import { exportPsiBySource, getPsiIssuesBySource } from "@/serverFunctions/psi";
import { psiIssuesSearchSchema } from "@/types/schemas/psi"; import { psiIssuesSearchSchema } from "@/types/schemas/psi";
const categoryTabs = [
"all",
"performance",
"accessibility",
"best-practices",
"seo",
] as const;
type CategoryTab = (typeof categoryTabs)[number];
type IssueCategory = Exclude<CategoryTab, "all">;
type ExportPayload = {
mode: "full" | "issues" | "category";
category?: IssueCategory;
};
type PsiIssue = {
auditKey: string;
category: IssueCategory;
severity: "critical" | "warning" | "info";
score?: number | null;
title: string;
displayValue?: string | null;
description?: string | null;
impactMs?: number | null;
impactBytes?: number | null;
items: string[];
};
export const Route = createFileRoute("/p/$projectId/psi/issues/$resultId")({ export const Route = createFileRoute("/p/$projectId/psi/issues/$resultId")({
validateSearch: psiIssuesSearchSchema, validateSearch: psiIssuesSearchSchema,
component: PsiIssuesPage, component: PsiIssuesPage,
@ -53,502 +12,25 @@ function PsiIssuesPage() {
const { source, category } = Route.useSearch(); const { source, category } = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath }); const navigate = useNavigate({ from: Route.fullPath });
const issuesQuery = useQuery({
queryKey: ["psiIssuesBySource", projectId, source, resultId, category],
queryFn: () =>
getPsiIssuesBySource({
data: {
projectId,
source,
resultId,
category: category === "all" ? undefined : category,
},
}),
});
const summaryQuery = useQuery({
queryKey: ["psiIssuesSummary", projectId, source, resultId],
queryFn: () =>
getPsiIssuesBySource({
data: {
projectId,
source,
resultId,
},
}),
});
const exportMutation = useMutation({
mutationFn: (data: ExportPayload) =>
exportPsiBySource({
data: {
projectId,
source,
resultId,
...data,
},
}),
});
const visibleIssues = (issuesQuery.data?.issues ?? []) as PsiIssue[];
const allIssues = summaryQuery.data?.issues ?? visibleIssues;
const categoryCounts = categoryTabs.reduce<Record<CategoryTab, number>>(
(acc, tab) => {
if (tab === "all") {
acc[tab] = allIssues.length;
return acc;
}
acc[tab] = allIssues.filter((issue) => issue.category === tab).length;
return acc;
},
{
all: allIssues.length,
performance: 0,
accessibility: 0,
"best-practices": 0,
seo: 0,
},
);
const severityCounts = {
critical: visibleIssues.filter((issue) => issue.severity === "critical")
.length,
warning: visibleIssues.filter((issue) => issue.severity === "warning")
.length,
info: visibleIssues.filter((issue) => issue.severity === "info").length,
};
const exportCurrentCategory: ExportPayload =
category === "all"
? { mode: "issues" }
: {
mode: "category",
category,
};
const selectedCategoryLabel = categoryLabel(category);
const runExport = async (data: ExportPayload) => {
try {
const exported = await exportMutation.mutateAsync(data);
downloadTextFile(exported.filename, exported.content, "application/json");
toast.success("Download started");
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to export payload";
toast.error(message);
}
};
const runExportCsv = (issues: PsiIssue[], variant: "all" | "current") => {
const filename = `psi-${variant}-${categorySlug(category)}-issues.csv`;
downloadTextFile(filename, issuesToCsv(issues), "text/csv");
toast.success("CSV download started");
};
const runCopy = async (data: ExportPayload, toastMessage: string) => {
try {
const exported = await exportMutation.mutateAsync(data);
await navigator.clipboard.writeText(exported.content);
toast.success(toastMessage);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to copy payload";
toast.error(message);
}
};
const isBusy = exportMutation.isPending;
return ( return (
<div className="px-4 py-3 md:px-6 md:py-4 pb-24 md:pb-8 overflow-auto"> <PsiIssuesScreen
<div className="mx-auto max-w-5xl space-y-4"> projectId={projectId}
<div className="flex items-center justify-between gap-3"> resultId={resultId}
<button source={source}
className="btn btn-ghost btn-sm px-2" category={category}
onClick={() => backLabel={source === "site" ? "Site Audit" : "PSI"}
navigate({ onBack={() =>
to: source === "site" ? "/p/$projectId/audit" : "..", void navigate({
params: { projectId }, to: source === "site" ? "/p/$projectId/audit" : "..",
}) params: { projectId },
} })
> }
&larr; Back to {source === "site" ? "Site Audit" : "PSI"} onCategoryChange={(next) =>
</button> void navigate({
<span className="text-xs text-base-content/60"> search: (prev) => ({ ...prev, category: next }),
{issuesQuery.data?.createdAt replace: true,
? `Scanned ${new Date(issuesQuery.data.createdAt).toLocaleString()}` })
: "Reading latest issues..."} }
</span> />
</div>
<div className="card bg-base-100 border border-base-300">
<div className="card-body py-5 gap-4">
<div className="space-y-1">
<h1 className="text-2xl font-semibold">PSI Issues</h1>
<p className="text-sm text-base-content/70 break-all">
{issuesQuery.data?.finalUrl ?? "Loading URL..."}
</p>
</div>
<div className="flex flex-wrap gap-2 text-xs">
<span className="badge border border-error/30 bg-error/10 text-error/80 gap-1">
<FileWarning className="size-3" />
Critical {severityCounts.critical}
</span>
<span className="badge border border-warning/30 bg-warning/10 text-warning/80 gap-1">
<TriangleAlert className="size-3" />
Warning {severityCounts.warning}
</span>
<span className="badge border border-info/30 bg-info/10 text-info/80 gap-1">
<Info className="size-3" />
Info {severityCounts.info}
</span>
</div>
</div>
</div>
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-4">
<div className="sticky top-0 z-[2] -mx-2 px-2 py-2 bg-base-100/95 backdrop-blur-sm border-b border-base-300/60">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-4">
{categoryTabs.map((tab) => (
<button
key={tab}
className={`pb-2 border-b-2 text-sm font-medium transition-colors ${
category === tab
? "border-primary text-base-content"
: "border-transparent text-base-content/60 hover:text-base-content"
}`}
onClick={() =>
navigate({
search: (prev) => ({
...prev,
category: tab,
}),
replace: true,
})
}
>
<span>{categoryLabel(tab)}</span>
<span className="ml-1 text-xs opacity-70">
({categoryCounts[tab]})
</span>
</button>
))}
</div>
<div className="flex 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-72"
>
<li className="menu-title">
<span>Copy</span>
</li>
<li>
<button
disabled={isBusy}
onClick={() =>
runCopy(
exportCurrentCategory,
`Copied ${selectedCategoryLabel.toLowerCase()} issues`,
)
}
>
<Copy className="size-4" />
Copy {selectedCategoryLabel.toLowerCase()} issues
</button>
</li>
<li>
<button
disabled={isBusy}
onClick={() =>
runCopy({ mode: "issues" }, "Copied all issues")
}
>
<Copy className="size-4" />
Copy all issues
</button>
</li>
<li>
<button
disabled={isBusy}
onClick={() =>
runCopy(
{ mode: "full" },
"Copied full Lighthouse report",
)
}
>
<Copy className="size-4" />
Copy full Lighthouse report
</button>
</li>
<li className="menu-title">
<span>Download JSON</span>
</li>
<li>
<button
disabled={isBusy}
onClick={() => runExport(exportCurrentCategory)}
>
Download {selectedCategoryLabel.toLowerCase()} issues
</button>
</li>
<li>
<button
disabled={isBusy}
onClick={() => runExport({ mode: "issues" })}
>
Download all issues
</button>
</li>
<li>
<button
disabled={isBusy}
onClick={() => runExport({ mode: "full" })}
>
Download full Lighthouse report
</button>
</li>
<li className="menu-title">
<span>Download CSV</span>
</li>
<li>
<button
disabled={!visibleIssues.length}
onClick={() => runExportCsv(visibleIssues, "current")}
>
Download {selectedCategoryLabel.toLowerCase()} issues
</button>
</li>
<li>
<button
disabled={!allIssues.length}
onClick={() => runExportCsv(allIssues, "all")}
>
Download all issues
</button>
</li>
</ul>
</div>
</div>
</div>
</div>
{issuesQuery.isLoading ? (
<p className="text-sm text-base-content/60">Loading issues...</p>
) : visibleIssues.length ? (
<div className="space-y-3">
{visibleIssues.map((issue) => (
<div
key={`${issue.category}-${issue.auditKey}`}
className="card bg-base-200/30 border border-base-300"
>
<div className="card-body p-5 gap-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<span className="badge badge-outline">
{issue.category}
</span>
<span
className={`badge border ${severityBadgeClass(issue.severity)} gap-1`}
>
{severityIcon(issue.severity)}
{issue.severity}
</span>
{issue.score != null && (
<div
className="tooltip tooltip-top"
data-tip="Lighthouse score from 0-100 for this audit. Lower means larger opportunity for improvement."
>
<span className="badge badge-ghost cursor-help">
Score {issue.score}
</span>
</div>
)}
</div>
{(issue.impactMs != null ||
issue.impactBytes != null) && (
<span className="text-xs text-base-content/60">
Impact {issue.impactMs ?? 0}ms /{" "}
{issue.impactBytes ?? 0} bytes
</span>
)}
</div>
<p className="font-semibold leading-tight">
{issue.title}
</p>
{issue.displayValue && (
<p className="text-sm text-base-content/70">
{issue.displayValue}
</p>
)}
{issue.description && (
<div className="text-sm text-base-content/80 leading-relaxed">
{renderInlineMarkdown(issue.description)}
</div>
)}
{issue.items.length > 0 && (
<details className="text-sm bg-base-100 rounded-box border border-base-300/80 px-3 py-2">
<summary className="cursor-pointer font-medium text-base-content/75">
Affected items ({issue.items.length})
</summary>
<div className="mt-2 space-y-2">
{issue.items.map((item) => (
<pre
key={`${issue.auditKey}-${item}`}
className="bg-base-200/60 p-2 rounded-box overflow-x-auto text-xs"
>
{item}
</pre>
))}
</div>
</details>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-base-content/60">
No unresolved issues for this category.
</p>
)}
</div>
</div>
</div>
</div>
); );
} }
function categoryLabel(category: CategoryTab) {
if (category === "best-practices") return "Best practices";
if (category === "all") return "All";
return `${category.charAt(0).toUpperCase()}${category.slice(1)}`;
}
function categorySlug(category: CategoryTab) {
return category === "all" ? "all" : category;
}
function issuesToCsv(issues: PsiIssue[]) {
const headers = [
"Category",
"Severity",
"Score",
"Title",
"Display Value",
"Description",
"Impact (ms)",
"Impact (bytes)",
"Affected Items",
];
const rows = issues.map((issue) => [
issue.category,
issue.severity,
issue.score ?? "",
issue.title,
issue.displayValue ?? "",
issue.description ?? "",
issue.impactMs ?? "",
issue.impactBytes ?? "",
issue.items.length,
]);
return [
headers.map(csvEscape).join(","),
...rows.map((row) => row.map(csvEscape).join(",")),
].join("\n");
}
function csvEscape(value: string | number) {
const text = String(value);
if (text.includes(",") || text.includes('"') || text.includes("\n")) {
return `"${text.replaceAll('"', '""')}"`;
}
return text;
}
function renderInlineMarkdown(markdown: string): ReactNode {
const linkPattern = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g;
const nodes: ReactNode[] = [];
let cursor = 0;
let match = linkPattern.exec(markdown);
while (match) {
const [raw, label, href] = match;
const index = match.index;
if (index > cursor) {
nodes.push(markdown.slice(cursor, index));
}
nodes.push(
<a
key={`${href}-${index}`}
href={href}
target="_blank"
rel="noopener noreferrer"
className="link link-primary inline-flex items-center gap-1"
>
{label}
<ExternalLink className="size-3" />
</a>,
);
cursor = index + raw.length;
match = linkPattern.exec(markdown);
}
if (cursor < markdown.length) {
nodes.push(markdown.slice(cursor));
}
if (!nodes.length) {
return markdown;
}
return nodes;
}
function downloadTextFile(filename: string, content: string, mimeType: string) {
const blob = new Blob([content], { type: mimeType });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
}
function severityBadgeClass(severity: "critical" | "warning" | "info") {
if (severity === "critical")
return "border-error/30 bg-error/10 text-error/80";
if (severity === "warning")
return "border-warning/35 bg-warning/10 text-warning/80";
return "border-info/30 bg-info/10 text-info/80";
}
function severityIcon(severity: "critical" | "warning" | "info") {
if (severity === "critical") return <FileWarning className="size-3" />;
if (severity === "warning") return <TriangleAlert className="size-3" />;
return <Info className="size-3" />;
}

View File

@ -7,6 +7,7 @@ import {
removeSavedKeyword, removeSavedKeyword,
} from "@/serverFunctions/keywords"; } from "@/serverFunctions/keywords";
import { Trash2, Download, Search, Loader2, AlertCircle } from "lucide-react"; import { Trash2, Download, Search, Loader2, AlertCircle } from "lucide-react";
import { buildCsv, downloadCsv } from "@/client/lib/csv";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
export const Route = createFileRoute("/p/$projectId/saved")({ export const Route = createFileRoute("/p/$projectId/saved")({
@ -66,31 +67,58 @@ function SavedKeywordsPage() {
"Intent", "Intent",
"Fetched At", "Fetched At",
]; ];
const csvRows = savedKeywords.map((kw) => const csvRows = savedKeywords.map((kw) => [
[ kw.keyword,
csvEscape(kw.keyword), kw.searchVolume ?? "",
kw.searchVolume ?? "", kw.cpc?.toFixed(2) ?? "",
kw.cpc?.toFixed(2) ?? "", kw.competition?.toFixed(2) ?? "",
kw.competition?.toFixed(2) ?? "", kw.keywordDifficulty ?? "",
kw.keywordDifficulty ?? "", kw.intent ?? "",
kw.intent ?? "", kw.fetchedAt ?? "",
kw.fetchedAt ?? "", ]);
].join(","), const csv = buildCsv(headers, csvRows);
); downloadCsv("saved-keywords.csv", csv);
const csv = [headers.join(","), ...csvRows].join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "saved-keywords.csv";
link.click();
URL.revokeObjectURL(url);
}; };
return (
<SavedKeywordsContent
isLoading={isLoading}
removeError={removeError}
removingId={removingId}
savedKeywords={savedKeywords}
onExportCsv={exportCsv}
onRemoveKeyword={handleRemoveKeyword}
/>
);
}
function SavedKeywordsContent({
isLoading,
removeError,
removingId,
savedKeywords,
onExportCsv,
onRemoveKeyword,
}: {
isLoading: boolean;
removeError: string | null;
removingId: string | null;
savedKeywords: Array<{
id: string;
keyword: string;
searchVolume: number | null;
cpc: number | null;
competition: number | null;
keywordDifficulty: number | null;
intent: string | null;
fetchedAt: string | null;
}>;
onExportCsv: () => void;
onRemoveKeyword: (savedKeywordId: string) => void;
}) {
return ( return (
<div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto"> <div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto">
<div className="mx-auto max-w-5xl space-y-4"> <div className="mx-auto max-w-5xl space-y-4">
{/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-semibold">Saved Keywords</h1> <h1 className="text-2xl font-semibold">Saved Keywords</h1>
@ -99,13 +127,12 @@ function SavedKeywordsPage() {
</p> </p>
</div> </div>
{savedKeywords.length > 0 && ( {savedKeywords.length > 0 && (
<button className="btn btn-sm" onClick={exportCsv}> <button className="btn btn-sm" onClick={onExportCsv}>
<Download className="size-4" /> Export CSV <Download className="size-4" /> Export CSV
</button> </button>
)} )}
</div> </div>
{/* Keyword list */}
{isLoading ? ( {isLoading ? (
<div className="card bg-base-100 border border-base-300"> <div className="card bg-base-100 border border-base-300">
<div className="card-body gap-3" aria-busy> <div className="card-body gap-3" aria-busy>
@ -149,65 +176,11 @@ function SavedKeywordsPage() {
{savedKeywords.length} saved keyword {savedKeywords.length} saved keyword
{savedKeywords.length !== 1 ? "s" : ""} {savedKeywords.length !== 1 ? "s" : ""}
</p> </p>
<div className="overflow-x-auto"> <SavedKeywordsTable
<table className="table table-zebra table-sm"> rows={savedKeywords}
<thead> removingId={removingId}
<tr> onRemoveKeyword={onRemoveKeyword}
<th>Keyword</th> />
<th>Volume</th>
<th>CPC</th>
<th>Competition</th>
<th>Difficulty</th>
<th>Intent</th>
<th>Last Fetched</th>
<th></th>
</tr>
</thead>
<tbody>
{savedKeywords.map((kw) => (
<tr key={kw.id}>
<td className="font-medium">{kw.keyword}</td>
<td>{formatNumber(kw.searchVolume)}</td>
<td>
{kw.cpc == null ? "-" : `$${kw.cpc.toFixed(2)}`}
</td>
<td>
{kw.competition == null
? "-"
: kw.competition.toFixed(2)}
</td>
<td>
<DifficultyBadge value={kw.keywordDifficulty} />
</td>
<td>
<span className="badge badge-sm badge-ghost">
{kw.intent ?? "?"}
</span>
</td>
<td className="text-xs text-base-content/50">
{kw.fetchedAt
? new Date(kw.fetchedAt).toLocaleDateString()
: "-"}
</td>
<td>
<button
className="btn btn-ghost btn-xs text-error"
onClick={() => handleRemoveKeyword(kw.id)}
disabled={removingId === kw.id}
title="Remove"
>
{removingId === kw.id ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Trash2 className="size-3" />
)}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div> </div>
</div> </div>
)} )}
@ -216,6 +189,83 @@ function SavedKeywordsPage() {
); );
} }
function SavedKeywordsTable({
rows,
removingId,
onRemoveKeyword,
}: {
rows: Array<{
id: string;
keyword: string;
searchVolume: number | null;
cpc: number | null;
competition: number | null;
keywordDifficulty: number | null;
intent: string | null;
fetchedAt: string | null;
}>;
removingId: string | null;
onRemoveKeyword: (savedKeywordId: string) => void;
}) {
return (
<div className="overflow-x-auto">
<table className="table table-zebra table-sm">
<thead>
<tr>
<th>Keyword</th>
<th>Volume</th>
<th>CPC</th>
<th>Competition</th>
<th>Difficulty</th>
<th>Intent</th>
<th>Last Fetched</th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((kw) => (
<tr key={kw.id}>
<td className="font-medium">{kw.keyword}</td>
<td>{formatNumber(kw.searchVolume)}</td>
<td>{kw.cpc == null ? "-" : `$${kw.cpc.toFixed(2)}`}</td>
<td>
{kw.competition == null ? "-" : kw.competition.toFixed(2)}
</td>
<td>
<DifficultyBadge value={kw.keywordDifficulty} />
</td>
<td>
<span className="badge badge-sm badge-ghost">
{kw.intent ?? "?"}
</span>
</td>
<td className="text-xs text-base-content/50">
{kw.fetchedAt
? new Date(kw.fetchedAt).toLocaleDateString()
: "-"}
</td>
<td>
<button
className="btn btn-ghost btn-xs text-error"
onClick={() => onRemoveKeyword(kw.id)}
disabled={removingId === kw.id}
title="Remove"
>
{removingId === kw.id ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Trash2 className="size-3" />
)}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function DifficultyBadge({ value }: { value: number | null }) { function DifficultyBadge({ value }: { value: number | null }) {
if (value == null) if (value == null)
return <span className="badge badge-ghost badge-sm">-</span>; return <span className="badge badge-ghost badge-sm">-</span>;
@ -230,9 +280,3 @@ function formatNumber(value: number | null | undefined) {
if (value == null) return "-"; if (value == null) return "-";
return new Intl.NumberFormat().format(value); return new Intl.NumberFormat().format(value);
} }
function csvEscape(value: string | number | null | undefined): string {
if (value == null) return "";
const text = String(value).replace(/"/g, '""');
return `"${text}"`;
}

View File

@ -32,6 +32,7 @@ async function createAudit(data: {
async function updateAuditProgress( async function updateAuditProgress(
auditId: string, auditId: string,
workflowInstanceId: string,
data: { data: {
pagesCrawled?: number; pagesCrawled?: number;
pagesTotal?: number; pagesTotal?: number;
@ -41,11 +42,20 @@ async function updateAuditProgress(
currentPhase?: string; currentPhase?: string;
}, },
) { ) {
await db.update(audits).set(data).where(eq(audits.id, auditId)); await db
.update(audits)
.set(data)
.where(
and(
eq(audits.id, auditId),
eq(audits.workflowInstanceId, workflowInstanceId),
),
);
} }
async function completeAudit( async function completeAudit(
auditId: string, auditId: string,
workflowInstanceId: string,
data: { data: {
pagesCrawled: number; pagesCrawled: number;
pagesTotal: number; pagesTotal: number;
@ -59,10 +69,15 @@ async function completeAudit(
currentPhase: "completed", currentPhase: "completed",
...data, ...data,
}) })
.where(eq(audits.id, auditId)); .where(
and(
eq(audits.id, auditId),
eq(audits.workflowInstanceId, workflowInstanceId),
),
);
} }
async function failAudit(auditId: string) { async function failAudit(auditId: string, workflowInstanceId: string) {
await db await db
.update(audits) .update(audits)
.set({ .set({
@ -70,7 +85,24 @@ async function failAudit(auditId: string) {
completedAt: new Date().toISOString(), completedAt: new Date().toISOString(),
currentPhase: "failed", currentPhase: "failed",
}) })
.where(eq(audits.id, auditId)); .where(
and(
eq(audits.id, auditId),
eq(audits.workflowInstanceId, workflowInstanceId),
),
);
}
async function getAuditForWorkflow(
auditId: string,
workflowInstanceId: string,
) {
return db.query.audits.findFirst({
where: and(
eq(audits.id, auditId),
eq(audits.workflowInstanceId, workflowInstanceId),
),
});
} }
// ─── Batch write results (finalize step) ───────────────────────────────────── // ─── Batch write results (finalize step) ─────────────────────────────────────
@ -298,6 +330,7 @@ export const AuditRepository = {
updateAuditProgress, updateAuditProgress,
completeAudit, completeAudit,
failAudit, failAudit,
getAuditForWorkflow,
batchWriteResults, batchWriteResults,
isProjectOwnedByUser, isProjectOwnedByUser,
getAuditForUser, getAuditForUser,

View File

@ -3,12 +3,13 @@
* Orchestrates between the workflow trigger, repository, and data formatting. * Orchestrates between the workflow trigger, repository, and data formatting.
*/ */
import { env } from "cloudflare:workers"; import { env } from "cloudflare:workers";
import { AuditRepository } from "@/server/repositories/AuditRepository"; import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
import { AuditProgressKV } from "@/server/lib/audit/progress-kv"; import { AuditProgressKV } from "@/server/lib/audit/progress-kv";
import { normalizeAndValidateStartUrl } from "@/server/lib/audit/url-policy"; import { normalizeAndValidateStartUrl } from "@/server/lib/audit/url-policy";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import type { AuditConfig, PsiStrategy } from "@/server/lib/audit/types"; import type { AuditConfig, PsiStrategy } from "@/server/lib/audit/types";
import { KeywordResearchRepository } from "@/server/repositories/KeywordResearchRepository"; import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository";
import { jsonCodec } from "@/shared/json";
import { z } from "zod"; import { z } from "zod";
const auditConfigSchema = z.object({ const auditConfigSchema = z.object({
@ -17,16 +18,12 @@ const auditConfigSchema = z.object({
psiApiKey: z.string().optional(), psiApiKey: z.string().optional(),
}); });
const auditConfigCodec = jsonCodec(auditConfigSchema);
function parseAuditConfig(configRaw: string | null): AuditConfig | null { function parseAuditConfig(configRaw: string | null): AuditConfig | null {
if (!configRaw) return null; if (!configRaw) return null;
try { const result = auditConfigCodec.safeParse(configRaw);
const parsed = JSON.parse(configRaw); return result.success ? result.data : null;
const result = auditConfigSchema.safeParse(parsed);
if (!result.success) return null;
return result.data;
} catch {
return null;
}
} }
async function startAudit(input: { async function startAudit(input: {

View File

@ -4,6 +4,7 @@ import { keywordMetrics, projects, savedKeywords } from "@/db/schema";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
async function upsertKeywordMetric(params: { async function upsertKeywordMetric(params: {
projectId: string;
keyword: string; keyword: string;
locationCode: number; locationCode: number;
languageCode: string; languageCode: string;
@ -19,6 +20,7 @@ async function upsertKeywordMetric(params: {
await db await db
.insert(keywordMetrics) .insert(keywordMetrics)
.values({ .values({
projectId: params.projectId,
keyword: params.keyword, keyword: params.keyword,
locationCode: params.locationCode, locationCode: params.locationCode,
languageCode: params.languageCode, languageCode: params.languageCode,
@ -32,6 +34,7 @@ async function upsertKeywordMetric(params: {
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [ target: [
keywordMetrics.projectId,
keywordMetrics.keyword, keywordMetrics.keyword,
keywordMetrics.locationCode, keywordMetrics.locationCode,
keywordMetrics.languageCode, keywordMetrics.languageCode,
@ -157,6 +160,7 @@ async function listSavedKeywordsByProject(projectId: string) {
keywordMetrics, keywordMetrics,
and( and(
eq(keywordMetrics.keyword, savedKeywords.keyword), eq(keywordMetrics.keyword, savedKeywords.keyword),
eq(keywordMetrics.projectId, savedKeywords.projectId),
eq(keywordMetrics.locationCode, savedKeywords.locationCode), eq(keywordMetrics.locationCode, savedKeywords.locationCode),
eq(keywordMetrics.languageCode, savedKeywords.languageCode), eq(keywordMetrics.languageCode, savedKeywords.languageCode),
), ),

View File

@ -0,0 +1,25 @@
import {
createProject,
deleteProject,
getOrCreateDefaultProject,
getProject,
getSavedKeywords,
getSerpAnalysis,
listProjects,
removeSavedKeyword,
research,
saveKeywords,
} from "@/server/features/keywords/services/research";
export const KeywordResearchService = {
research,
getSerpAnalysis,
listProjects,
createProject,
deleteProject,
saveKeywords,
getSavedKeywords,
removeSavedKeyword,
getOrCreateDefaultProject,
getProject,
} as const;

View File

@ -0,0 +1,14 @@
export { research } from "./research";
export { getSerpAnalysis } from "./serp";
export {
listProjects,
createProject,
deleteProject,
getOrCreateDefaultProject,
getProject,
} from "./projects";
export {
saveKeywords,
getSavedKeywords,
removeSavedKeyword,
} from "./saved-keywords";

View File

@ -2,7 +2,7 @@ import type {
CreateProjectInput, CreateProjectInput,
DeleteProjectInput, DeleteProjectInput,
} from "@/types/schemas/keywords"; } from "@/types/schemas/keywords";
import { KeywordResearchRepository } from "@/server/repositories/KeywordResearchRepository"; import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository";
export async function listProjects(userId: string) { export async function listProjects(userId: string) {
const rows = await KeywordResearchRepository.listProjects(userId); const rows = await KeywordResearchRepository.listProjects(userId);

View File

@ -0,0 +1,130 @@
import {
fetchKeywordIdeasRaw,
fetchKeywordSuggestionsRaw,
fetchRelatedKeywordsRaw,
type LabsKeywordDataItem,
} from "@/server/lib/dataforseo";
import {
normalizeIntent,
normalizeKeyword,
type EnrichedKeyword,
} from "./helpers";
import type { KeywordSource } from "./selection";
type FetchResearchRowsParams = {
seedKeyword: string;
locationCode: number;
languageCode: string;
resultLimit: number;
source: KeywordSource;
};
function mapKeywordDataItems(items: LabsKeywordDataItem[]): EnrichedKeyword[] {
const rows: EnrichedKeyword[] = [];
const seen = new Set<string>();
for (const item of items) {
const keyword = item.keyword;
if (!keyword) continue;
const normalized = normalizeKeyword(keyword);
if (seen.has(normalized)) continue;
seen.add(normalized);
const keywordInfo = item.keyword_info_normalized_with_clickstream
?.search_volume
? item.keyword_info_normalized_with_clickstream
: item.keyword_info;
rows.push({
keyword: normalized,
searchVolume: keywordInfo?.search_volume ?? null,
trend: (keywordInfo?.monthly_searches ?? []).map((entry) => ({
year: entry.year,
month: entry.month,
searchVolume: entry.search_volume ?? 0,
})),
cpc: item.keyword_info?.cpc ?? null,
competition: item.keyword_info?.competition ?? null,
keywordDifficulty: item.keyword_properties?.keyword_difficulty ?? null,
intent: normalizeIntent(item.search_intent_info?.main_intent),
});
}
return rows;
}
async function fetchRelatedRows(
params: Omit<FetchResearchRowsParams, "source">,
) {
const items = await fetchRelatedKeywordsRaw(
params.seedKeyword,
params.locationCode,
params.languageCode,
params.resultLimit,
3,
);
const rows: EnrichedKeyword[] = [];
const seen = new Set<string>();
for (const item of items) {
const keywordData = item.keyword_data;
const keyword = keywordData.keyword;
if (!keyword) continue;
const normalized = normalizeKeyword(keyword);
if (seen.has(normalized)) continue;
seen.add(normalized);
const keywordInfo = keywordData.keyword_info_normalized_with_clickstream
?.search_volume
? keywordData.keyword_info_normalized_with_clickstream
: keywordData.keyword_info;
rows.push({
keyword: normalized,
searchVolume: keywordInfo?.search_volume ?? null,
trend: (keywordInfo?.monthly_searches ?? []).map((entry) => ({
year: entry.year,
month: entry.month,
searchVolume: entry.search_volume ?? 0,
})),
cpc: keywordData.keyword_info?.cpc ?? null,
competition: keywordData.keyword_info?.competition ?? null,
keywordDifficulty:
keywordData.keyword_properties?.keyword_difficulty ?? null,
intent: normalizeIntent(keywordData.search_intent_info?.main_intent),
});
}
return rows;
}
export async function fetchResearchRowsBySource(
params: FetchResearchRowsParams,
): Promise<EnrichedKeyword[]> {
if (params.source === "related") {
return fetchRelatedRows(params);
}
if (params.source === "suggestions") {
return mapKeywordDataItems(
await fetchKeywordSuggestionsRaw(
params.seedKeyword,
params.locationCode,
params.languageCode,
params.resultLimit,
),
);
}
return mapKeywordDataItems(
await fetchKeywordIdeasRaw(
params.seedKeyword,
params.locationCode,
params.languageCode,
params.resultLimit,
),
);
}

View File

@ -0,0 +1,268 @@
import { AppError } from "@/server/lib/errors";
import {
CACHE_TTL,
buildCacheKey,
getCached,
setCached,
} from "@/server/lib/kv-cache";
import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository";
import type { KeywordResearchRow } from "@/types/keywords";
import type { ResearchKeywordsInput } from "@/types/schemas/keywords";
import { z } from "zod";
import { type EnrichedKeyword, normalizeKeyword } from "./helpers";
import { fetchResearchRowsBySource } from "./research-data";
import {
AUTO_KEYWORD_SOURCES,
MIN_NON_SEED_FOR_AUTO,
countNonSeedKeywords,
hasSufficientCoverage,
type KeywordMode,
type KeywordSource,
} from "./selection";
type SourceAttempt = {
source: KeywordSource;
rowCount: number;
nonSeedCount: number;
};
type ResearchDiagnostics = {
requestedMode: KeywordMode;
threshold: number;
sourceAttempts: SourceAttempt[];
};
type ResearchResult = {
rows: KeywordResearchRow[];
source: KeywordSource;
usedFallback: boolean;
diagnostics: ResearchDiagnostics;
};
type CachedResult = ResearchResult;
const cachedKeywordRowSchema = z.object({
keyword: z.string(),
searchVolume: z.number().nullable(),
trend: z.array(
z.object({
year: z.number(),
month: z.number(),
searchVolume: z.number(),
}),
),
cpc: z.number().nullable(),
competition: z.number().nullable(),
keywordDifficulty: z.number().nullable(),
intent: z.enum([
"informational",
"commercial",
"transactional",
"navigational",
"unknown",
]),
});
const sourceAttemptSchema = z.object({
source: z.enum(["related", "suggestions", "ideas"]),
rowCount: z.number(),
nonSeedCount: z.number(),
});
const cachedResultSchema = z.object({
rows: z.array(cachedKeywordRowSchema),
source: z.enum(["related", "suggestions", "ideas"]),
usedFallback: z.boolean(),
diagnostics: z.object({
requestedMode: z.enum(["auto", "related", "suggestions", "ideas"]),
threshold: z.number(),
sourceAttempts: z.array(sourceAttemptSchema),
}),
});
const CACHE_VERSION = 2;
function getMode(input: ResearchKeywordsInput): KeywordMode {
return input.mode ?? "auto";
}
async function fetchRowsFromSource(
source: KeywordSource,
input: ResearchKeywordsInput,
seedKeyword: string,
): Promise<EnrichedKeyword[]> {
return fetchResearchRowsBySource({
source,
seedKeyword,
locationCode: input.locationCode,
languageCode: input.languageCode,
resultLimit: input.resultLimit,
});
}
async function fetchAutoRows(
input: ResearchKeywordsInput,
seedKeyword: string,
): Promise<ResearchResult> {
const attempts: SourceAttempt[] = [];
let lastSource: KeywordSource = "related";
const accumulatedRows: EnrichedKeyword[] = [];
const seenKeywords = new Set<string>();
for (const source of AUTO_KEYWORD_SOURCES) {
const rows = await fetchRowsFromSource(source, input, seedKeyword);
for (const row of rows) {
if (accumulatedRows.length >= input.resultLimit) break;
if (seenKeywords.has(row.keyword)) continue;
seenKeywords.add(row.keyword);
accumulatedRows.push(row);
}
attempts.push({
source,
rowCount: rows.length,
nonSeedCount: countNonSeedKeywords(rows, seedKeyword),
});
lastSource = source;
if (
hasSufficientCoverage(accumulatedRows, seedKeyword, MIN_NON_SEED_FOR_AUTO)
) {
return {
rows: accumulatedRows,
source,
usedFallback: source !== AUTO_KEYWORD_SOURCES[0],
diagnostics: {
requestedMode: "auto",
threshold: MIN_NON_SEED_FOR_AUTO,
sourceAttempts: attempts,
},
};
}
}
return {
rows: accumulatedRows,
source: lastSource,
usedFallback: true,
diagnostics: {
requestedMode: "auto",
threshold: MIN_NON_SEED_FOR_AUTO,
sourceAttempts: attempts,
},
};
}
async function fetchManualRows(
mode: Exclude<KeywordMode, "auto">,
input: ResearchKeywordsInput,
seedKeyword: string,
): Promise<ResearchResult> {
const rows = await fetchRowsFromSource(mode, input, seedKeyword);
const attempt: SourceAttempt = {
source: mode,
rowCount: rows.length,
nonSeedCount: countNonSeedKeywords(rows, seedKeyword),
};
return {
rows,
source: mode,
usedFallback: false,
diagnostics: {
requestedMode: mode,
threshold: MIN_NON_SEED_FOR_AUTO,
sourceAttempts: [attempt],
},
};
}
function isUsableCachedResult(cached: CachedResult): boolean {
if (cached.rows.length === 0) return false;
return true;
}
function buildResearchCacheKey(
input: ResearchKeywordsInput,
normalizedKeywords: string[],
mode: KeywordMode,
): string {
return buildCacheKey("kw:research", {
cacheVersion: CACHE_VERSION,
projectId: input.projectId,
keywords: normalizedKeywords,
locationCode: input.locationCode,
languageCode: input.languageCode,
resultLimit: input.resultLimit,
mode,
depth: 3,
});
}
function persistRows(input: ResearchKeywordsInput, rows: EnrichedKeyword[]) {
void Promise.all(
rows.map((row) =>
KeywordResearchRepository.upsertKeywordMetric({
projectId: input.projectId,
keyword: row.keyword,
locationCode: input.locationCode,
languageCode: input.languageCode,
searchVolume: row.searchVolume,
cpc: row.cpc,
competition: row.competition,
keywordDifficulty: row.keywordDifficulty,
intent: row.intent,
monthlySearchesJson: JSON.stringify(row.trend),
}),
),
).catch((error) => {
console.error("keywords.research.persist-metrics failed:", error);
});
}
export async function research(
userId: string,
input: ResearchKeywordsInput,
): Promise<ResearchResult> {
const project = await KeywordResearchRepository.getProject(
input.projectId,
userId,
);
if (!project) {
throw new AppError("NOT_FOUND");
}
const uniqueKeywords = [
...new Set(input.keywords.map(normalizeKeyword)),
].filter((keyword) => keyword.length > 0);
if (uniqueKeywords.length === 0) {
throw new AppError("VALIDATION_ERROR");
}
const seedKeyword = uniqueKeywords[0];
const mode = getMode(input);
const cacheKey = buildResearchCacheKey(input, uniqueKeywords, mode);
const cachedRaw = await getCached(cacheKey);
const cachedResult = cachedResultSchema.safeParse(cachedRaw);
const cached: CachedResult | null = cachedResult.success
? cachedResult.data
: null;
if (cached && isUsableCachedResult(cached)) {
return cached;
}
const result =
mode === "auto"
? await fetchAutoRows(input, seedKeyword)
: await fetchManualRows(mode, input, seedKeyword);
await setCached(cacheKey, result, CACHE_TTL.researchResult);
persistRows(input, result.rows);
return result;
}

View File

@ -1,5 +1,6 @@
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { KeywordResearchRepository } from "@/server/repositories/KeywordResearchRepository"; import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository";
import { jsonCodec } from "@/shared/json";
import type { import type {
GetSavedKeywordsInput, GetSavedKeywordsInput,
RemoveSavedKeywordInput, RemoveSavedKeywordInput,
@ -15,16 +16,12 @@ const monthlySearchSchema = z.object({
searchVolume: z.number().int().nonnegative(), searchVolume: z.number().int().nonnegative(),
}); });
const monthlySearchesCodec = jsonCodec(z.array(monthlySearchSchema));
function parseMonthlySearches(payload: string | null): MonthlySearch[] { function parseMonthlySearches(payload: string | null): MonthlySearch[] {
if (!payload) return []; if (!payload) return [];
try { const result = monthlySearchesCodec.safeParse(payload);
const parsed = JSON.parse(payload); return result.success ? result.data : [];
const result = z.array(monthlySearchSchema).safeParse(parsed);
return result.success ? result.data : [];
} catch (error) {
console.error("keywords.saved.parse-monthly-searches failed:", error);
return [];
}
} }
export async function saveKeywords(userId: string, input: SaveKeywordsInput) { export async function saveKeywords(userId: string, input: SaveKeywordsInput) {
@ -42,6 +39,45 @@ export async function saveKeywords(userId: string, input: SaveKeywordsInput) {
), ),
]; ];
const metricByKeyword = new Map(
(input.metrics ?? [])
.map((metric) => {
const keyword = normalizeKeyword(metric.keyword);
if (!keyword || !normalizedKeywords.includes(keyword)) return null;
return [keyword, metric] as const;
})
.filter(
(
entry,
): entry is readonly [
string,
NonNullable<typeof input.metrics>[number],
] => entry != null,
),
);
if (metricByKeyword.size > 0) {
await Promise.all(
normalizedKeywords.map(async (keyword) => {
const metric = metricByKeyword.get(keyword);
if (!metric) return;
await KeywordResearchRepository.upsertKeywordMetric({
projectId: input.projectId,
keyword,
locationCode: input.locationCode,
languageCode: input.languageCode,
searchVolume: metric.searchVolume ?? null,
cpc: metric.cpc ?? null,
competition: metric.competition ?? null,
keywordDifficulty: metric.keywordDifficulty ?? null,
intent: metric.intent ?? null,
monthlySearchesJson: JSON.stringify(metric.monthlySearches ?? []),
});
}),
);
}
await KeywordResearchRepository.saveKeywordsToProject({ await KeywordResearchRepository.saveKeywordsToProject({
projectId: input.projectId, projectId: input.projectId,
keywords: normalizedKeywords, keywords: normalizedKeywords,

View File

@ -0,0 +1,28 @@
import type { EnrichedKeyword } from "./helpers";
export type KeywordSource = "related" | "suggestions" | "ideas";
export type KeywordMode = "auto" | KeywordSource;
export const AUTO_KEYWORD_SOURCES: KeywordSource[] = [
"related",
"suggestions",
"ideas",
];
export const MIN_NON_SEED_FOR_AUTO = 5;
export function countNonSeedKeywords(
rows: EnrichedKeyword[],
seedKeyword: string,
): number {
const normalizedSeed = seedKeyword.trim().toLowerCase();
return rows.filter((row) => row.keyword !== normalizedSeed).length;
}
export function hasSufficientCoverage(
rows: EnrichedKeyword[],
seedKeyword: string,
threshold: number = MIN_NON_SEED_FOR_AUTO,
): boolean {
return countNonSeedKeywords(rows, seedKeyword) >= threshold;
}

View File

@ -1,12 +1,22 @@
import { fetchHistoricalSerpsRaw } from "@/server/lib/dataforseo"; import {
fetchLiveSerpItemsRaw,
type SerpLiveItem,
} from "@/server/lib/dataforseo";
import { buildCacheKey, getCached, setCached } from "@/server/lib/kv-cache"; import { buildCacheKey, getCached, setCached } from "@/server/lib/kv-cache";
import type { SerpResultItem } from "@/types/keywords"; import type { SerpResultItem } from "@/types/keywords";
import { normalizeKeyword } from "./helpers";
import { z } from "zod"; import { z } from "zod";
import { normalizeKeyword } from "./helpers";
const SERP_CACHE_TTL_SECONDS = 12 * 60 * 60; const SERP_CACHE_TTL_SECONDS = 12 * 60 * 60;
type SerpAnalysisReason = "no_organic_results";
type SerpAnalysisResult = {
requestedKeyword: string;
items: SerpResultItem[];
reason?: SerpAnalysisReason;
};
const serpResultItemSchema = z.object({ const serpResultItemSchema = z.object({
rank: z.number().int(), rank: z.number().int(),
title: z.string(), title: z.string(),
@ -22,38 +32,13 @@ const serpResultItemSchema = z.object({
}); });
const serpCacheSchema = z.object({ const serpCacheSchema = z.object({
requestedKeyword: z.string(),
items: z.array(serpResultItemSchema), items: z.array(serpResultItemSchema),
reason: z.enum(["no_organic_results"]).optional(),
}); });
export async function getSerpAnalysis(input: { function mapOrganicSerpItems(items: SerpLiveItem[]): SerpResultItem[] {
keyword: string; return items
locationCode: number;
languageCode: string;
}): Promise<{ items: SerpResultItem[] }> {
const keyword = normalizeKeyword(input.keyword);
const cacheKey = buildCacheKey("serp:analysis", {
keyword,
locationCode: input.locationCode,
languageCode: input.languageCode,
});
const cachedRaw = await getCached(cacheKey);
const cached = serpCacheSchema.safeParse(cachedRaw);
if (cached.success && cached.data.items.length > 0) {
return cached.data;
}
const snapshots = await fetchHistoricalSerpsRaw(
keyword,
input.locationCode,
input.languageCode,
);
const snapshot = snapshots[0];
const rawItems = snapshot?.items ?? [];
const items: SerpResultItem[] = rawItems
.filter((item) => item.type === "organic") .filter((item) => item.type === "organic")
.map((item) => ({ .map((item) => ({
rank: item.rank_absolute ?? item.rank_group ?? 0, rank: item.rank_absolute ?? item.rank_group ?? 0,
@ -65,21 +50,47 @@ export async function getSerpAnalysis(input: {
estimatedPaidTrafficCost: item.estimated_paid_traffic_cost ?? null, estimatedPaidTrafficCost: item.estimated_paid_traffic_cost ?? null,
referringDomains: item.backlinks_info?.referring_domains ?? null, referringDomains: item.backlinks_info?.referring_domains ?? null,
backlinks: item.backlinks_info?.backlinks ?? null, backlinks: item.backlinks_info?.backlinks ?? null,
isNew: item.rank_changes?.is_new ?? false, isNew: false,
rankChange: rankChange: null,
item.rank_changes?.previous_rank_absolute != null &&
item.rank_absolute != null
? item.rank_changes.previous_rank_absolute - item.rank_absolute
: null,
})); }));
}
const result = { items }; async function getSerpLiveAnalysis(input: {
keyword: string;
locationCode: number;
languageCode: string;
}): Promise<SerpAnalysisResult> {
const keyword = normalizeKeyword(input.keyword);
if (items.length > 0) { const cacheKey = buildCacheKey("serp:analysis", {
void setCached(cacheKey, result, SERP_CACHE_TTL_SECONDS).catch((error) => { keyword,
console.error("keywords.serp.cache-write failed:", error); locationCode: input.locationCode,
}); languageCode: input.languageCode,
});
const cachedRaw = await getCached(cacheKey);
const cached = serpCacheSchema.safeParse(cachedRaw);
if (cached.success) {
return cached.data;
} }
const liveItems = await fetchLiveSerpItemsRaw(
keyword,
input.locationCode,
input.languageCode,
);
const items = mapOrganicSerpItems(liveItems);
const result: SerpAnalysisResult = { requestedKeyword: keyword, items };
if (items.length === 0) {
result.reason = "no_organic_results";
}
void setCached(cacheKey, result, SERP_CACHE_TTL_SECONDS).catch((error) => {
console.error("keywords.serp.cache-write failed:", error);
});
return result; return result;
} }
export const getSerpAnalysis = getSerpLiveAnalysis;

View File

@ -0,0 +1,369 @@
import { AppError } from "@/server/lib/errors";
import { getJsonFromR2, putJsonToR2 } from "@/server/lib/r2";
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository";
import { PsiAuditRepository } from "@/server/features/psi/repositories/PsiAuditRepository";
import {
PsiIssuesService,
type PsiIssueCategory,
} from "@/server/features/psi/services/PsiIssuesService";
import { buildPsiExportFile } from "@/server/features/psi/services/psi-export";
import { PsiService } from "@/server/features/psi/services/PsiService";
type PsiStrategy = "mobile" | "desktop";
type PsiSource = "single" | "site";
type ExportMode = "full" | "issues" | "category";
type ResolvedPsiSource = {
id: string;
strategy: PsiStrategy;
finalUrl: string;
createdAt: string;
r2Key: string | null;
};
async function resolvePsiSource(input: {
projectId: string;
userId: string;
source: PsiSource;
resultId: string;
}): Promise<ResolvedPsiSource> {
if (input.source === "single") {
const row = await PsiAuditRepository.getAuditResult({
auditId: input.resultId,
projectId: input.projectId,
userId: input.userId,
});
if (!row) {
throw new AppError("NOT_FOUND");
}
return {
id: row.id,
strategy: row.strategy,
finalUrl: row.finalUrl,
createdAt: row.createdAt,
r2Key: row.r2Key,
};
}
const site = await AuditRepository.getPsiResultById({
psiResultId: input.resultId,
projectId: input.projectId,
userId: input.userId,
});
if (!site) {
throw new AppError("NOT_FOUND");
}
return {
id: site.psi.id,
strategy: site.psi.strategy,
finalUrl: site.page?.url ?? "",
createdAt: site.audit.startedAt,
r2Key: site.psi.r2Key,
};
}
async function runAudit(input: {
projectId: string;
userId: string;
url: string;
strategy: PsiStrategy;
}) {
const apiKey = await KeywordResearchRepository.getProjectPsiApiKey(
input.projectId,
input.userId,
);
if (!apiKey) {
throw new AppError("VALIDATION_ERROR");
}
const auditId = crypto.randomUUID();
try {
const result = await PsiService.runAudit({
url: input.url,
strategy: input.strategy,
apiKey,
});
const datePrefix = new Date().toISOString().slice(0, 10);
const key = `psi/${input.projectId}/${datePrefix}/${auditId}.json`;
const uploaded = await putJsonToR2(key, result.rawPayload);
await PsiAuditRepository.createAuditResult({
id: auditId,
projectId: input.projectId,
requestedUrl: result.requestedUrl,
finalUrl: result.finalUrl,
strategy: result.strategy,
status: "completed",
performanceScore: result.scores.performance,
accessibilityScore: result.scores.accessibility,
bestPracticesScore: result.scores["best-practices"],
seoScore: result.scores.seo,
firstContentfulPaint: result.metrics.firstContentfulPaint.displayValue,
largestContentfulPaint:
result.metrics.largestContentfulPaint.displayValue,
totalBlockingTime: result.metrics.totalBlockingTime.displayValue,
cumulativeLayoutShift: result.metrics.cumulativeLayoutShift.displayValue,
speedIndex: result.metrics.speedIndex.displayValue,
timeToInteractive: result.metrics.timeToInteractive.displayValue,
lighthouseVersion: result.lighthouseVersion,
r2Key: uploaded.key,
payloadSizeBytes: uploaded.sizeBytes,
});
return {
auditId,
requestedUrl: result.requestedUrl,
finalUrl: result.finalUrl,
strategy: result.strategy,
fetchedAt: result.fetchedAt,
lighthouseVersion: result.lighthouseVersion,
scores: result.scores,
metrics: result.metrics,
};
} catch (error) {
const requestedUrl = input.url.trim();
const message =
error instanceof Error ? error.message : "PSI request failed";
await PsiAuditRepository.createAuditResult({
id: auditId,
projectId: input.projectId,
requestedUrl,
finalUrl: requestedUrl,
strategy: input.strategy,
status: "failed",
errorMessage: message,
});
throw error;
}
}
async function getProjectPsiApiKey(input: {
projectId: string;
userId: string;
}) {
const apiKey = await KeywordResearchRepository.getProjectPsiApiKey(
input.projectId,
input.userId,
);
return { apiKey };
}
async function saveProjectPsiApiKey(input: {
projectId: string;
userId: string;
apiKey: string;
}) {
await KeywordResearchRepository.setProjectPsiApiKey(
input.projectId,
input.userId,
input.apiKey.trim(),
);
return { success: true };
}
async function clearProjectPsiApiKey(input: {
projectId: string;
userId: string;
}) {
await KeywordResearchRepository.clearProjectPsiApiKey(
input.projectId,
input.userId,
);
return { success: true };
}
async function listProjectPsiAudits(input: {
projectId: string;
userId: string;
strategy?: PsiStrategy;
limit: number;
}) {
const rows = await PsiAuditRepository.listAuditResults({
projectId: input.projectId,
userId: input.userId,
strategy: input.strategy,
limit: input.limit,
});
return {
rows: rows.map((row) => ({
id: row.id,
requestedUrl: row.requestedUrl,
finalUrl: row.finalUrl,
strategy: row.strategy,
status: row.status,
performanceScore: row.performanceScore,
accessibilityScore: row.accessibilityScore,
bestPracticesScore: row.bestPracticesScore,
seoScore: row.seoScore,
firstContentfulPaint: row.firstContentfulPaint,
largestContentfulPaint: row.largestContentfulPaint,
totalBlockingTime: row.totalBlockingTime,
cumulativeLayoutShift: row.cumulativeLayoutShift,
speedIndex: row.speedIndex,
timeToInteractive: row.timeToInteractive,
lighthouseVersion: row.lighthouseVersion,
errorMessage: row.errorMessage,
payloadSizeBytes: row.payloadSizeBytes,
createdAt: row.createdAt,
})),
};
}
async function getProjectPsiAuditRaw(input: {
projectId: string;
userId: string;
auditId: string;
}) {
const row = await PsiAuditRepository.getAuditResult({
auditId: input.auditId,
projectId: input.projectId,
userId: input.userId,
});
if (!row || !row.r2Key) {
throw new AppError("NOT_FOUND");
}
const payloadJson = await getJsonFromR2(row.r2Key);
return {
id: row.id,
strategy: row.strategy,
finalUrl: row.finalUrl,
createdAt: row.createdAt,
payloadJson,
};
}
async function getProjectPsiAuditIssues(input: {
projectId: string;
userId: string;
auditId: string;
category?: PsiIssueCategory;
}) {
const row = await PsiAuditRepository.getAuditResult({
auditId: input.auditId,
projectId: input.projectId,
userId: input.userId,
});
if (!row || !row.r2Key) {
throw new AppError("NOT_FOUND");
}
const payloadJson = await getJsonFromR2(row.r2Key);
const issues = PsiIssuesService.parseIssues(payloadJson, input.category);
return {
id: row.id,
finalUrl: row.finalUrl,
strategy: row.strategy,
createdAt: row.createdAt,
issues,
};
}
async function exportProjectPsiAudit(input: {
projectId: string;
userId: string;
auditId: string;
mode: ExportMode;
category?: PsiIssueCategory;
}) {
const row = await PsiAuditRepository.getAuditResult({
auditId: input.auditId,
projectId: input.projectId,
userId: input.userId,
});
if (!row || !row.r2Key) {
throw new AppError("NOT_FOUND");
}
const payloadJson = await getJsonFromR2(row.r2Key);
return buildPsiExportFile({
idField: "auditId",
idValue: row.id,
finalUrl: row.finalUrl,
strategy: row.strategy,
createdAt: row.createdAt,
payloadJson,
mode: input.mode,
category: input.mode === "category" ? input.category : undefined,
});
}
async function getPsiIssuesBySource(input: {
projectId: string;
userId: string;
source: PsiSource;
resultId: string;
category?: PsiIssueCategory;
}) {
const target = await resolvePsiSource(input);
if (!target.r2Key) {
throw new AppError("NOT_FOUND");
}
const payloadJson = await getJsonFromR2(target.r2Key);
const issues = PsiIssuesService.parseIssues(payloadJson, input.category);
return {
id: target.id,
finalUrl: target.finalUrl,
strategy: target.strategy,
createdAt: target.createdAt,
issues,
};
}
async function exportPsiBySource(input: {
projectId: string;
userId: string;
source: PsiSource;
resultId: string;
mode: ExportMode;
category?: PsiIssueCategory;
}) {
const target = await resolvePsiSource(input);
if (!target.r2Key) {
throw new AppError("NOT_FOUND");
}
const payloadJson = await getJsonFromR2(target.r2Key);
return buildPsiExportFile({
idField: "resultId",
idValue: target.id,
finalUrl: target.finalUrl,
strategy: target.strategy,
createdAt: target.createdAt,
payloadJson,
mode: input.mode,
category: input.mode === "category" ? input.category : undefined,
});
}
export const PsiAuditService = {
runAudit,
getProjectPsiApiKey,
saveProjectPsiApiKey,
clearProjectPsiApiKey,
listProjectPsiAudits,
getProjectPsiAuditRaw,
getProjectPsiAuditIssues,
exportProjectPsiAudit,
getPsiIssuesBySource,
exportPsiBySource,
} as const;

View File

@ -1,5 +1,6 @@
import { sortBy } from "remeda"; import { sortBy } from "remeda";
import { z } from "zod"; import { z } from "zod";
import { jsonCodec } from "@/shared/json";
const PSI_CATEGORIES = [ const PSI_CATEGORIES = [
"performance", "performance",
@ -10,7 +11,7 @@ const PSI_CATEGORIES = [
export type PsiIssueCategory = (typeof PSI_CATEGORIES)[number]; export type PsiIssueCategory = (typeof PSI_CATEGORIES)[number];
export type PsiIssue = { type PsiIssue = {
category: PsiIssueCategory; category: PsiIssueCategory;
auditKey: string; auditKey: string;
title: string; title: string;
@ -87,6 +88,8 @@ const psiPayloadSchema = z.object({
}), }),
}); });
const psiPayloadCodec = jsonCodec(psiPayloadSchema);
function normalizeScore(score: number | null | undefined): number | null { function normalizeScore(score: number | null | undefined): number | null {
if (score == null || Number.isNaN(score)) return null; if (score == null || Number.isNaN(score)) return null;
return Math.round(score * 100); return Math.round(score * 100);
@ -149,14 +152,7 @@ function parseIssues(
payloadJson: string, payloadJson: string,
categoryFilter?: PsiIssueCategory, categoryFilter?: PsiIssueCategory,
): PsiIssue[] { ): PsiIssue[] {
let payload: unknown; const parsedPayload = psiPayloadCodec.safeParse(payloadJson);
try {
payload = JSON.parse(payloadJson);
} catch {
throw new Error("Invalid Lighthouse payload JSON");
}
const parsedPayload = psiPayloadSchema.safeParse(payload);
if (!parsedPayload.success) { if (!parsedPayload.success) {
throw new Error("Invalid Lighthouse payload JSON"); throw new Error("Invalid Lighthouse payload JSON");
} }

View File

@ -0,0 +1,52 @@
import {
PsiIssuesService,
type PsiIssueCategory,
} from "@/server/features/psi/services/PsiIssuesService";
type PsiStrategy = "mobile" | "desktop";
type ExportMode = "full" | "issues" | "category";
export function buildPsiExportFile(input: {
idField: "auditId" | "resultId";
idValue: string;
finalUrl: string;
strategy: PsiStrategy;
createdAt: string;
payloadJson: string;
mode: ExportMode;
category?: PsiIssueCategory;
}) {
const safeDate = input.createdAt.replace(/[:.]/g, "-");
const baseName = `psi-${input.strategy}-${safeDate}`;
if (input.mode === "full") {
return {
filename: `${baseName}-full.json`,
content: input.payloadJson,
};
}
const issues = PsiIssuesService.parseIssues(
input.payloadJson,
input.category,
);
return {
filename:
input.mode === "category" && input.category
? `${baseName}-${input.category}-issues.json`
: `${baseName}-issues.json`,
content: JSON.stringify(
{
[input.idField]: input.idValue,
finalUrl: input.finalUrl,
strategy: input.strategy,
createdAt: input.createdAt,
category: input.category ?? "all",
issues,
},
null,
2,
),
};
}

View File

@ -82,8 +82,8 @@ function getSitemapLocations(input: unknown): string[] {
const entries = Array.isArray(input) ? input : [input]; const entries = Array.isArray(input) ? input : [input];
return entries return entries
.map((entry) => { .map((entry) => {
if (entry && typeof entry === "object" && "loc" in entry) { if (isRecord(entry)) {
const loc = entry.loc; const loc = entry["loc"];
return typeof loc === "string" ? loc : null; return typeof loc === "string" ? loc : null;
} }
return null; return null;
@ -91,11 +91,38 @@ function getSitemapLocations(input: unknown): string[] {
.filter((loc): loc is string => typeof loc === "string"); .filter((loc): loc is string => typeof loc === "string");
} }
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object";
}
function getParsedSitemapSections(parsed: unknown): {
sitemap: unknown;
url: unknown;
} {
if (!parsed || typeof parsed !== "object") {
return { sitemap: undefined, url: undefined };
}
const root = parsed as {
sitemapindex?: { sitemap?: unknown };
urlset?: { url?: unknown };
};
return {
sitemap: root.sitemapindex?.sitemap,
url: root.urlset?.url,
};
}
function isTimeoutError(error: unknown): boolean { function isTimeoutError(error: unknown): boolean {
if (!error || typeof error !== "object") return false; if (!error || typeof error !== "object") return false;
return "name" in error && error.name === "TimeoutError"; return "name" in error && error.name === "TimeoutError";
} }
function parseXmlDocument(body: string): unknown {
return xmlParser.parse(body) as unknown;
}
async function fetchSitemapDocumentWithRetry(sitemapUrl: string): Promise<{ async function fetchSitemapDocumentWithRetry(sitemapUrl: string): Promise<{
nestedSitemaps: string[]; nestedSitemaps: string[];
pageUrls: string[]; pageUrls: string[];
@ -129,11 +156,12 @@ async function fetchSitemapDocumentWithRetry(sitemapUrl: string): Promise<{
return { nestedSitemaps: [], pageUrls: [], timedOut: false }; return { nestedSitemaps: [], pageUrls: [], timedOut: false };
} }
const parsed = xmlParser.parse(body); const parsed = parseXmlDocument(body);
const nestedSitemaps = getSitemapLocations(parsed.sitemapindex?.sitemap) const sections = getParsedSitemapSections(parsed);
const nestedSitemaps = getSitemapLocations(sections.sitemap)
.map((loc) => normalizeUrl(loc, finalUrl)) .map((loc) => normalizeUrl(loc, finalUrl))
.filter((loc): loc is string => loc !== null); .filter((loc): loc is string => loc !== null);
const pageUrls = getSitemapLocations(parsed.urlset?.url) const pageUrls = getSitemapLocations(sections.url)
.map((loc) => normalizeUrl(loc, finalUrl)) .map((loc) => normalizeUrl(loc, finalUrl))
.filter((loc): loc is string => loc !== null); .filter((loc): loc is string => loc !== null);

View File

@ -8,10 +8,13 @@
* the audit is running. Once finalized, we explicitly delete it. * the audit is running. Once finalized, we explicitly delete it.
*/ */
import { env } from "cloudflare:workers"; import { env } from "cloudflare:workers";
import { z } from "zod";
import { jsonCodec } from "@/shared/json";
const KV_PREFIX = "audit-progress:"; const KV_PREFIX = "audit-progress:";
const TTL_SECONDS = 30 * 60; // 30 minutes const TTL_SECONDS = 30 * 60; // 30 minutes
const MAX_ENTRIES = 300; const MAX_ENTRIES = 300;
const jsonUnknownCodec = jsonCodec(z.unknown());
export interface CrawledUrlEntry { export interface CrawledUrlEntry {
url: string; url: string;
@ -21,6 +24,29 @@ export interface CrawledUrlEntry {
crawledAt: number; crawledAt: number;
} }
function isCrawledUrlEntry(value: unknown): value is CrawledUrlEntry {
if (!value || typeof value !== "object") return false;
const candidate = value as {
url?: unknown;
statusCode?: unknown;
title?: unknown;
crawledAt?: unknown;
};
return (
typeof candidate.url === "string" &&
typeof candidate.statusCode === "number" &&
typeof candidate.title === "string" &&
typeof candidate.crawledAt === "number"
);
}
function parseCrawledEntries(json: string | null): CrawledUrlEntry[] {
if (!json) return [];
const parsed = jsonUnknownCodec.safeParse(json);
if (!parsed.success || !Array.isArray(parsed.data)) return [];
return parsed.data.filter(isCrawledUrlEntry);
}
function key(auditId: string): string { function key(auditId: string): string {
return `${KV_PREFIX}${auditId}`; return `${KV_PREFIX}${auditId}`;
} }
@ -48,7 +74,7 @@ async function pushCrawledUrls(
const k = key(auditId); const k = key(auditId);
const existing = await env.KV.get(k, "text"); const existing = await env.KV.get(k, "text");
const entries: CrawledUrlEntry[] = existing ? JSON.parse(existing) : []; const entries = parseCrawledEntries(existing);
const merged = [...nextEntries, ...entries].slice(0, MAX_ENTRIES); const merged = [...nextEntries, ...entries].slice(0, MAX_ENTRIES);
await env.KV.put(k, JSON.stringify(merged), { await env.KV.put(k, JSON.stringify(merged), {
@ -62,8 +88,7 @@ async function pushCrawledUrls(
*/ */
async function getCrawledUrls(auditId: string): Promise<CrawledUrlEntry[]> { async function getCrawledUrls(auditId: string): Promise<CrawledUrlEntry[]> {
const data = await env.KV.get(key(auditId), "text"); const data = await env.KV.get(key(auditId), "text");
if (!data) return []; return parseCrawledEntries(data);
return JSON.parse(data);
} }
/** /**

View File

@ -5,11 +5,30 @@ import {
DataforseoLabsGoogleKeywordIdeasLiveRequestInfo, DataforseoLabsGoogleKeywordIdeasLiveRequestInfo,
DataforseoLabsGoogleDomainRankOverviewLiveRequestInfo, DataforseoLabsGoogleDomainRankOverviewLiveRequestInfo,
DataforseoLabsGoogleRankedKeywordsLiveRequestInfo, DataforseoLabsGoogleRankedKeywordsLiveRequestInfo,
DataforseoLabsGoogleHistoricalSerpsLiveRequestInfo,
} from "dataforseo-client"; } from "dataforseo-client";
import { env } from "cloudflare:workers"; import { env } from "cloudflare:workers";
import { z } from "zod"; import { getDomain } from "tldts";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import {
dataforseoResponseSchema,
domainMetricsItemSchema,
domainRankedKeywordItemSchema,
labsKeywordDataItemSchema,
parseTaskItems,
relatedKeywordItemSchema,
serpSnapshotItemSchema,
type DataforseoTask,
type DomainMetricsItem,
type DomainRankedKeywordItem,
type LabsKeywordDataItem,
type RelatedKeywordItem,
type SerpLiveItem,
} from "@/server/lib/dataforseoSchemas";
export type {
DomainRankedKeywordItem,
LabsKeywordDataItem,
SerpLiveItem,
} from "@/server/lib/dataforseoSchemas";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// SDK client factories (lazily created per-request using the env secret) // SDK client factories (lazily created per-request using the env secret)
@ -34,6 +53,29 @@ function getLabsApi() {
return new DataforseoLabsApi(API_BASE, { fetch: createAuthenticatedFetch() }); return new DataforseoLabsApi(API_BASE, { fetch: createAuthenticatedFetch() });
} }
async function postDataforseo(
path: string,
payload: unknown,
): Promise<unknown> {
const authenticatedFetch = createAuthenticatedFetch();
const response = await authenticatedFetch(`${API_BASE}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new AppError(
"INTERNAL_ERROR",
`DataForSEO HTTP ${response.status} on ${path}`,
);
}
return await response.json();
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Response helpers // Response helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -74,210 +116,6 @@ function assertOk<T extends { status_code?: number; status_message?: string }>(
return task; return task;
} }
type DataforseoTaskResult = { items?: unknown[] };
type DataforseoTask = {
status_code?: number;
status_message?: string;
result?: DataforseoTaskResult[];
};
function getTaskItems(task: DataforseoTask): unknown[] {
return task.result?.[0]?.items ?? [];
}
const monthlySearchSchema = z
.object({
year: z.number().int(),
month: z.number().int().min(1).max(12),
search_volume: z.number().nullable(),
})
.passthrough();
const keywordInfoSchema = z
.object({
search_volume: z.number().nullable().optional(),
cpc: z.number().nullable().optional(),
competition: z.number().nullable().optional(),
monthly_searches: z.array(monthlySearchSchema).nullable().optional(),
})
.passthrough();
const keywordInfoWithClickstreamSchema = z
.object({
search_volume: z.number().nullable().optional(),
monthly_searches: z.array(monthlySearchSchema).nullable().optional(),
})
.passthrough();
const searchIntentInfoSchema = z
.object({
main_intent: z.string().nullable().optional(),
})
.passthrough();
const keywordPropertiesSchema = z
.object({
keyword_difficulty: z.number().nullable().optional(),
})
.passthrough();
const relatedKeywordItemSchema = z
.object({
keyword_data: z
.object({
keyword: z.string().optional(),
keyword_info: keywordInfoSchema.optional(),
keyword_info_normalized_with_clickstream:
keywordInfoWithClickstreamSchema.optional(),
search_intent_info: searchIntentInfoSchema.nullable().optional(),
keyword_properties: keywordPropertiesSchema.nullable().optional(),
})
.passthrough(),
})
.passthrough();
const labsKeywordDataItemSchema = z
.object({
keyword: z.string(),
keyword_info: keywordInfoSchema.optional(),
keyword_info_normalized_with_clickstream:
keywordInfoWithClickstreamSchema.optional(),
search_intent_info: searchIntentInfoSchema.nullable().optional(),
keyword_properties: keywordPropertiesSchema.nullable().optional(),
})
.passthrough();
const domainMetricsValueSchema = z
.object({
etv: z.number().nullable().optional(),
count: z.number().nullable().optional(),
})
.passthrough();
const domainMetricsItemSchema = z
.object({
metrics: z.record(
z.string(),
domainMetricsValueSchema.nullable().optional(),
),
})
.passthrough();
const rankedKeywordInfoSchema = z
.object({
search_volume: z.number().nullable().optional(),
cpc: z.number().nullable().optional(),
keyword_difficulty: z.number().nullable().optional(),
})
.passthrough();
const rankedKeywordDataSchema = z
.object({
keyword: z.string().nullable().optional(),
keyword_info: rankedKeywordInfoSchema.nullable().optional(),
keyword_properties: keywordPropertiesSchema.nullable().optional(),
})
.passthrough();
const rankedSerpItemSchema = z
.object({
url: z.string().nullable().optional(),
relative_url: z.string().nullable().optional(),
rank_absolute: z.number().nullable().optional(),
etv: z.number().nullable().optional(),
})
.passthrough();
const rankedSerpElementSchema = z
.object({
serp_item: rankedSerpItemSchema.nullable().optional(),
url: z.string().nullable().optional(),
relative_url: z.string().nullable().optional(),
rank_absolute: z.number().nullable().optional(),
etv: z.number().nullable().optional(),
})
.passthrough();
const domainRankedKeywordItemSchema = z
.object({
keyword_data: rankedKeywordDataSchema.nullable().optional(),
ranked_serp_element: rankedSerpElementSchema.nullable().optional(),
keyword: z.string().nullable().optional(),
rank_absolute: z.number().nullable().optional(),
etv: z.number().nullable().optional(),
keyword_difficulty: z.number().nullable().optional(),
})
.passthrough();
const serpSnapshotItemSchema = z
.object({
type: z.string(),
rank_group: z.number().nullable().optional(),
rank_absolute: z.number().nullable().optional(),
domain: z.string().nullable().optional(),
title: z.string().nullable().optional(),
url: z.string().nullable().optional(),
description: z.string().nullable().optional(),
breadcrumb: z.string().nullable().optional(),
etv: z.number().nullable().optional(),
estimated_paid_traffic_cost: z.number().nullable().optional(),
backlinks_info: z
.object({
referring_domains: z.number().nullable().optional(),
backlinks: z.number().nullable().optional(),
})
.passthrough()
.nullable()
.optional(),
rank_changes: z
.object({
previous_rank_absolute: z.number().nullable().optional(),
is_new: z.boolean().nullable().optional(),
is_up: z.boolean().nullable().optional(),
is_down: z.boolean().nullable().optional(),
})
.passthrough()
.nullable()
.optional(),
})
.passthrough();
const serpSnapshotSchema = z
.object({
se_results_count: z.number().nullable().optional(),
items_count: z.number().nullable().optional(),
items: z.array(serpSnapshotItemSchema),
})
.passthrough();
type RelatedKeywordItem = z.infer<typeof relatedKeywordItemSchema>;
export type LabsKeywordDataItem = z.infer<typeof labsKeywordDataItemSchema>;
type DomainMetricsItem = z.infer<typeof domainMetricsItemSchema>;
export type DomainRankedKeywordItem = z.infer<
typeof domainRankedKeywordItemSchema
>;
type SerpSnapshot = z.infer<typeof serpSnapshotSchema>;
function parseTaskItems<T extends z.ZodType>(
endpointName: string,
task: DataforseoTask,
itemSchema: T,
): z.infer<T>[] {
const parsed = z.array(itemSchema).safeParse(getTaskItems(task));
if (!parsed.success) {
console.error(
`dataforseo.${endpointName}.invalid-payload`,
parsed.error.issues.slice(0, 5),
);
throw new AppError(
"INTERNAL_ERROR",
`DataForSEO ${endpointName} returned an invalid response shape`,
);
}
return parsed.data;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// DataForSEO Labs API wrappers // DataForSEO Labs API wrappers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -416,27 +254,33 @@ export async function fetchRankedKeywordsRaw(
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// SERP Analysis API wrapper // SERP Analysis API wrapper (Google Organic Live)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export async function fetchHistoricalSerpsRaw( export async function fetchLiveSerpItemsRaw(
keyword: string, keyword: string,
locationCode: number, locationCode: number,
languageCode: string, languageCode: string,
): Promise<SerpSnapshot[]> { ): Promise<SerpLiveItem[]> {
const api = getLabsApi(); const responseRaw = await postDataforseo(
const req = new DataforseoLabsGoogleHistoricalSerpsLiveRequestInfo({ "/v3/serp/google/organic/live/advanced",
keyword, [
location_code: locationCode, {
language_code: languageCode, keyword,
}); location_code: locationCode,
language_code: languageCode,
const response = await api.googleHistoricalSerpsLive([req]); device: "desktop",
os: "windows",
depth: 100,
},
],
);
const response = dataforseoResponseSchema.parse(responseRaw);
const task = assertOk<DataforseoTask>(response); const task = assertOk<DataforseoTask>(response);
return parseTaskItems( return parseTaskItems(
"google-historical-serps-live", "google-organic-live-advanced",
task, task,
serpSnapshotSchema, serpSnapshotItemSchema,
); );
} }
@ -464,36 +308,24 @@ export function normalizeDomainInput(
throw new AppError("VALIDATION_ERROR", "Domain is required"); throw new AppError("VALIDATION_ERROR", "Domain is required");
} }
const withProtocol = /^https?:\/\//.test(trimmed) const withProtocol = /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(trimmed)
? trimmed ? trimmed
: `https://${trimmed}`; : `https://${trimmed}`;
const host = new URL(withProtocol).hostname.replace(/^www\./, ""); let host: string;
try {
host = new URL(withProtocol).hostname.toLowerCase().replace(/^www\./, "");
} catch {
throw new AppError("VALIDATION_ERROR", "Domain is invalid");
}
if (!host) {
throw new AppError("VALIDATION_ERROR", "Domain is invalid");
}
if (includeSubdomains) { if (includeSubdomains) {
return host; return host;
} }
return toRootDomain(host); return getDomain(host) ?? host;
}
function toRootDomain(host: string): string {
const parts = host.split(".").filter(Boolean);
if (parts.length <= 2) return host;
const knownSecondLevel = new Set([
"co.uk",
"org.uk",
"ac.uk",
"com.au",
"co.jp",
]);
const lastTwo = `${parts[parts.length - 2]}.${parts[parts.length - 1]}`;
const lastThree = `${parts[parts.length - 3]}.${lastTwo}`;
if (knownSecondLevel.has(lastTwo) && parts.length >= 3) {
return lastThree;
}
return lastTwo;
} }

View File

@ -0,0 +1,222 @@
import { z } from "zod";
import { AppError } from "@/server/lib/errors";
type DataforseoTaskResult = { items?: unknown[] };
export type DataforseoTask = {
status_code?: number;
status_message?: string;
result?: DataforseoTaskResult[];
};
const dataforseoTaskSchema = z
.object({
status_code: z.number().optional(),
status_message: z.string().optional(),
result: z
.array(
z
.object({
items: z.array(z.unknown()).optional(),
})
.passthrough(),
)
.optional(),
})
.passthrough();
export const dataforseoResponseSchema = z
.object({
status_code: z.number().optional(),
status_message: z.string().optional(),
tasks: z.array(dataforseoTaskSchema).optional(),
})
.passthrough();
function getTaskItems(task: DataforseoTask): unknown[] {
return task.result?.[0]?.items ?? [];
}
const monthlySearchSchema = z
.object({
year: z.number().int(),
month: z.number().int().min(1).max(12),
search_volume: z.number().nullable(),
})
.passthrough();
const keywordInfoSchema = z
.object({
search_volume: z.number().nullable().optional(),
cpc: z.number().nullable().optional(),
competition: z.number().nullable().optional(),
monthly_searches: z.array(monthlySearchSchema).nullable().optional(),
})
.passthrough();
const keywordInfoWithClickstreamSchema = z
.object({
search_volume: z.number().nullable().optional(),
monthly_searches: z.array(monthlySearchSchema).nullable().optional(),
})
.passthrough();
const searchIntentInfoSchema = z
.object({
main_intent: z.string().nullable().optional(),
})
.passthrough();
const keywordPropertiesSchema = z
.object({
keyword_difficulty: z.number().nullable().optional(),
})
.passthrough();
export const relatedKeywordItemSchema = z
.object({
keyword_data: z
.object({
keyword: z.string().optional(),
keyword_info: keywordInfoSchema.optional(),
keyword_info_normalized_with_clickstream:
keywordInfoWithClickstreamSchema.optional(),
search_intent_info: searchIntentInfoSchema.nullable().optional(),
keyword_properties: keywordPropertiesSchema.nullable().optional(),
})
.passthrough(),
})
.passthrough();
export const labsKeywordDataItemSchema = z
.object({
keyword: z.string(),
keyword_info: keywordInfoSchema.optional(),
keyword_info_normalized_with_clickstream:
keywordInfoWithClickstreamSchema.optional(),
search_intent_info: searchIntentInfoSchema.nullable().optional(),
keyword_properties: keywordPropertiesSchema.nullable().optional(),
})
.passthrough();
const domainMetricsValueSchema = z
.object({
etv: z.number().nullable().optional(),
count: z.number().nullable().optional(),
})
.passthrough();
export const domainMetricsItemSchema = z
.object({
metrics: z.record(
z.string(),
domainMetricsValueSchema.nullable().optional(),
),
})
.passthrough();
const rankedKeywordInfoSchema = z
.object({
search_volume: z.number().nullable().optional(),
cpc: z.number().nullable().optional(),
keyword_difficulty: z.number().nullable().optional(),
})
.passthrough();
const rankedKeywordDataSchema = z
.object({
keyword: z.string().nullable().optional(),
keyword_info: rankedKeywordInfoSchema.nullable().optional(),
keyword_properties: keywordPropertiesSchema.nullable().optional(),
})
.passthrough();
const rankedSerpItemSchema = z
.object({
url: z.string().nullable().optional(),
relative_url: z.string().nullable().optional(),
rank_absolute: z.number().nullable().optional(),
etv: z.number().nullable().optional(),
})
.passthrough();
const rankedSerpElementSchema = z
.object({
serp_item: rankedSerpItemSchema.nullable().optional(),
url: z.string().nullable().optional(),
relative_url: z.string().nullable().optional(),
rank_absolute: z.number().nullable().optional(),
etv: z.number().nullable().optional(),
})
.passthrough();
export const domainRankedKeywordItemSchema = z
.object({
keyword_data: rankedKeywordDataSchema.nullable().optional(),
ranked_serp_element: rankedSerpElementSchema.nullable().optional(),
keyword: z.string().nullable().optional(),
rank_absolute: z.number().nullable().optional(),
etv: z.number().nullable().optional(),
keyword_difficulty: z.number().nullable().optional(),
})
.passthrough();
export const serpSnapshotItemSchema = z
.object({
type: z.string(),
rank_group: z.number().nullable().optional(),
rank_absolute: z.number().nullable().optional(),
domain: z.string().nullable().optional(),
title: z.string().nullable().optional(),
url: z.string().nullable().optional(),
description: z.string().nullable().optional(),
breadcrumb: z.string().nullable().optional(),
etv: z.number().nullable().optional(),
estimated_paid_traffic_cost: z.number().nullable().optional(),
backlinks_info: z
.object({
referring_domains: z.number().nullable().optional(),
backlinks: z.number().nullable().optional(),
})
.passthrough()
.nullable()
.optional(),
rank_changes: z
.object({
previous_rank_absolute: z.number().nullable().optional(),
is_new: z.boolean().nullable().optional(),
is_up: z.boolean().nullable().optional(),
is_down: z.boolean().nullable().optional(),
})
.passthrough()
.nullable()
.optional(),
})
.passthrough();
export type RelatedKeywordItem = z.infer<typeof relatedKeywordItemSchema>;
export type LabsKeywordDataItem = z.infer<typeof labsKeywordDataItemSchema>;
export type DomainMetricsItem = z.infer<typeof domainMetricsItemSchema>;
export type DomainRankedKeywordItem = z.infer<
typeof domainRankedKeywordItemSchema
>;
export type SerpLiveItem = z.infer<typeof serpSnapshotItemSchema>;
export function parseTaskItems<T extends z.ZodType>(
endpointName: string,
task: DataforseoTask,
itemSchema: T,
): z.infer<T>[] {
const parsed = z.array(itemSchema).safeParse(getTaskItems(task));
if (!parsed.success) {
console.error(
`dataforseo.${endpointName}.invalid-payload`,
parsed.error.issues.slice(0, 5),
);
throw new AppError(
"INTERNAL_ERROR",
`DataForSEO ${endpointName} returned an invalid response shape`,
);
}
return parsed.data;
}

View File

@ -1,5 +1,7 @@
import { env } from "cloudflare:workers"; import { env } from "cloudflare:workers";
import { sortBy } from "remeda"; import { sortBy } from "remeda";
import { z } from "zod";
import { jsonCodec } from "@/shared/json";
/** /**
* Cache TTL constants in seconds. * Cache TTL constants in seconds.
@ -9,6 +11,8 @@ export const CACHE_TTL = {
researchResult: 86400, researchResult: 86400,
} as const; } as const;
const jsonUnknownCodec = jsonCodec(z.unknown());
/** /**
* Build a deterministic cache key from an endpoint slug and input params. * Build a deterministic cache key from an endpoint slug and input params.
* Uses FNV-1a hash for compactness. * Uses FNV-1a hash for compactness.
@ -30,12 +34,8 @@ export function buildCacheKey(
export async function getCached(key: string): Promise<unknown> { export async function getCached(key: string): Promise<unknown> {
const value = await env.KV.get(key, "text"); const value = await env.KV.get(key, "text");
if (value === null) return null; if (value === null) return null;
const parsed = jsonUnknownCodec.safeParse(value);
try { return parsed.success ? parsed.data : null;
return JSON.parse(value);
} catch {
return null;
}
} }
/** /**

Some files were not shown because too many files have changed in this diff Show More