From 75f9868500f59fae203bcf02fc4b8695a4925015 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Wed, 11 Mar 2026 23:01:52 -0400 Subject: [PATCH] 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 --- .oxlintrc.json | 17 +- ...Dockerfile.selfhost => Dockerfile.selfhost | 0 compose.yaml | 3 +- drizzle/0002_fair_toad_men.sql | 67 + drizzle/meta/0002_snapshot.json | 1099 ++++++++++++ drizzle/meta/_journal.json | 7 + package.json | 4 + pnpm-lock.yaml | 34 + .../audit/launch/AuditHistorySection.tsx | 124 ++ .../features/audit/launch/LaunchFormCard.tsx | 229 +++ .../features/audit/launch/LaunchView.tsx | 51 + .../features/audit/launch/SettingsModal.tsx | 128 ++ src/client/features/audit/launch/types.ts | 31 + .../audit/launch/useLaunchController.ts | 252 +++ .../features/audit/results/ResultsTables.tsx | 201 +++ .../features/audit/results/ResultsView.tsx | 215 +++ src/client/features/audit/results/export.ts | 115 ++ src/client/features/audit/results/types.ts | 3 + src/client/features/audit/shared.tsx | 101 ++ .../features/domain/DomainOverviewPage.tsx | 139 ++ .../domain/components/DifficultyBadge.tsx | 15 + .../components/DomainHistorySection.tsx | 90 + .../domain/components/DomainKeywordsTable.tsx | 132 ++ .../components/DomainOverviewLoadingState.tsx | 41 + .../domain/components/DomainPagesTable.tsx | 72 + .../domain/components/DomainResultsCard.tsx | 204 +++ .../domain/components/DomainSearchCard.tsx | 176 ++ .../domain/components/SortableHeader.tsx | 30 + .../features/domain/components/StatCard.tsx | 12 + src/client/features/domain/domainActions.ts | 64 + .../domainOverviewControllerInternals.ts | 334 ++++ src/client/features/domain/mutations.ts | 30 + src/client/features/domain/types.ts | 47 + .../domain/useDomainOverviewController.ts | 241 +++ src/client/features/domain/utils.ts | 134 ++ src/client/features/keywords/components.tsx | 666 ------- .../keywords/components/DisplayPrimitives.tsx | 290 +++ .../keywords/components/KeywordUi.tsx | 192 ++ .../keywords/components/SerpAnalysisCard.tsx | 216 +++ .../features/keywords/components/index.ts | 2 + .../keywords/hooks/useKeywordControlsForm.ts | 23 + .../keywords/hooks/useKeywordFiltering.ts | 93 + .../keywords/hooks/useKeywordResearchData.ts | 118 ++ .../keywords/hooks/useKeywordSelection.ts | 41 + .../keywords/hooks/useKeywordSerpAnalysis.ts | 45 + .../keywords/hooks/useLocalKeywordFilters.ts | 36 + .../features/keywords/keywordResearchTypes.ts | 27 + .../features/keywords/keywordSearchParams.ts | 92 + .../page/KeywordResearchDesktopResults.tsx | 354 ++++ .../page/KeywordResearchEmptyState.tsx | 197 ++ .../page/KeywordResearchLoadingState.tsx | 72 + .../page/KeywordResearchMobileResults.tsx | 256 +++ .../keywords/page/KeywordResearchPage.tsx | 92 + .../keywords/page/KeywordResearchResults.tsx | 16 + .../page/KeywordResearchSearchBar.tsx | 120 ++ .../page/keywordResearchDesktopFilters.tsx | 115 ++ src/client/features/keywords/page/types.ts | 5 + .../state/keywordControllerActions.ts | 235 +++ .../keywords/state/useKeywordOverviewState.ts | 51 + .../state/useKeywordResearchController.ts | 281 +++ src/client/features/keywords/utils.ts | 6 - .../features/psi/issues/PsiIssuesParts.tsx | 362 ++++ .../features/psi/issues/PsiIssuesScreen.tsx | 221 +++ src/client/features/psi/issues/types.ts | 28 + src/client/features/psi/issues/utils.tsx | 113 ++ src/client/hooks/useDomainSearchHistory.ts | 40 +- src/client/hooks/useSearchHistory.ts | 34 +- src/client/layout/AppShell.tsx | 232 +++ src/client/lib/csv.ts | 28 + src/db/schema.ts | 7 +- src/routes/__root.tsx | 221 +-- src/routes/p/$projectId/audit/index.tsx | 1262 +------------ .../p/$projectId/audit/issues/$resultId.tsx | 562 +----- src/routes/p/$projectId/domain.tsx | 1215 +------------ src/routes/p/$projectId/keywords.tsx | 1578 +---------------- .../p/$projectId/psi/issues/$resultId.tsx | 558 +----- src/routes/p/$projectId/saved.tsx | 218 ++- .../audit}/repositories/AuditRepository.ts | 41 +- .../audit}/services/AuditService.ts | 17 +- .../domain}/services/DomainService.ts | 0 .../repositories/KeywordResearchRepository.ts | 4 + .../services/KeywordResearchService.ts | 25 + .../keywords/services/research}/helpers.ts | 0 .../keywords/services/research/index.ts | 14 + .../keywords/services/research}/projects.ts | 2 +- .../services/research/research-data.ts | 130 ++ .../keywords/services/research/research.ts | 268 +++ .../services/research}/saved-keywords.ts | 54 +- .../keywords/services/research/selection.ts | 28 + .../keywords/services/research}/serp.ts | 97 +- .../psi}/repositories/PsiAuditRepository.ts | 0 .../features/psi/services/PsiAuditService.ts | 369 ++++ .../psi}/services/PsiIssuesService.ts | 14 +- .../{ => features/psi}/services/PsiService.ts | 0 .../features/psi/services/psi-export.ts | 52 + src/server/lib/audit/discovery.ts | 38 +- src/server/lib/audit/progress-kv.ts | 31 +- src/server/lib/dataforseo.ts | 318 +--- src/server/lib/dataforseoSchemas.ts | 222 +++ src/server/lib/kv-cache.ts | 12 +- src/server/services/KeywordResearchService.ts | 612 ------- .../keyword-research/research-data.ts | 60 - src/server/workflows/SiteAuditWorkflow.ts | 532 +----- .../workflows/site-audit-workflow-helpers.ts | 183 ++ .../workflows/siteAuditWorkflowCrawl.ts | 247 +++ .../workflows/siteAuditWorkflowPhases.ts | 232 +++ src/serverFunctions/audit.ts | 2 +- src/serverFunctions/domain.ts | 2 +- src/serverFunctions/keywords.ts | 2 +- src/serverFunctions/psi.ts | 393 +--- src/shared/error-codes.ts | 10 +- src/shared/json.ts | 32 + src/types/schemas/keywords.ts | 8 +- 113 files changed, 10952 insertions(+), 7861 deletions(-) rename self-host/Dockerfile.selfhost => Dockerfile.selfhost (100%) create mode 100644 drizzle/0002_fair_toad_men.sql create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 src/client/features/audit/launch/AuditHistorySection.tsx create mode 100644 src/client/features/audit/launch/LaunchFormCard.tsx create mode 100644 src/client/features/audit/launch/LaunchView.tsx create mode 100644 src/client/features/audit/launch/SettingsModal.tsx create mode 100644 src/client/features/audit/launch/types.ts create mode 100644 src/client/features/audit/launch/useLaunchController.ts create mode 100644 src/client/features/audit/results/ResultsTables.tsx create mode 100644 src/client/features/audit/results/ResultsView.tsx create mode 100644 src/client/features/audit/results/export.ts create mode 100644 src/client/features/audit/results/types.ts create mode 100644 src/client/features/audit/shared.tsx create mode 100644 src/client/features/domain/DomainOverviewPage.tsx create mode 100644 src/client/features/domain/components/DifficultyBadge.tsx create mode 100644 src/client/features/domain/components/DomainHistorySection.tsx create mode 100644 src/client/features/domain/components/DomainKeywordsTable.tsx create mode 100644 src/client/features/domain/components/DomainOverviewLoadingState.tsx create mode 100644 src/client/features/domain/components/DomainPagesTable.tsx create mode 100644 src/client/features/domain/components/DomainResultsCard.tsx create mode 100644 src/client/features/domain/components/DomainSearchCard.tsx create mode 100644 src/client/features/domain/components/SortableHeader.tsx create mode 100644 src/client/features/domain/components/StatCard.tsx create mode 100644 src/client/features/domain/domainActions.ts create mode 100644 src/client/features/domain/domainOverviewControllerInternals.ts create mode 100644 src/client/features/domain/mutations.ts create mode 100644 src/client/features/domain/types.ts create mode 100644 src/client/features/domain/useDomainOverviewController.ts create mode 100644 src/client/features/domain/utils.ts delete mode 100644 src/client/features/keywords/components.tsx create mode 100644 src/client/features/keywords/components/DisplayPrimitives.tsx create mode 100644 src/client/features/keywords/components/KeywordUi.tsx create mode 100644 src/client/features/keywords/components/SerpAnalysisCard.tsx create mode 100644 src/client/features/keywords/components/index.ts create mode 100644 src/client/features/keywords/hooks/useKeywordControlsForm.ts create mode 100644 src/client/features/keywords/hooks/useKeywordFiltering.ts create mode 100644 src/client/features/keywords/hooks/useKeywordResearchData.ts create mode 100644 src/client/features/keywords/hooks/useKeywordSelection.ts create mode 100644 src/client/features/keywords/hooks/useKeywordSerpAnalysis.ts create mode 100644 src/client/features/keywords/hooks/useLocalKeywordFilters.ts create mode 100644 src/client/features/keywords/keywordResearchTypes.ts create mode 100644 src/client/features/keywords/keywordSearchParams.ts create mode 100644 src/client/features/keywords/page/KeywordResearchDesktopResults.tsx create mode 100644 src/client/features/keywords/page/KeywordResearchEmptyState.tsx create mode 100644 src/client/features/keywords/page/KeywordResearchLoadingState.tsx create mode 100644 src/client/features/keywords/page/KeywordResearchMobileResults.tsx create mode 100644 src/client/features/keywords/page/KeywordResearchPage.tsx create mode 100644 src/client/features/keywords/page/KeywordResearchResults.tsx create mode 100644 src/client/features/keywords/page/KeywordResearchSearchBar.tsx create mode 100644 src/client/features/keywords/page/keywordResearchDesktopFilters.tsx create mode 100644 src/client/features/keywords/page/types.ts create mode 100644 src/client/features/keywords/state/keywordControllerActions.ts create mode 100644 src/client/features/keywords/state/useKeywordOverviewState.ts create mode 100644 src/client/features/keywords/state/useKeywordResearchController.ts create mode 100644 src/client/features/psi/issues/PsiIssuesParts.tsx create mode 100644 src/client/features/psi/issues/PsiIssuesScreen.tsx create mode 100644 src/client/features/psi/issues/types.ts create mode 100644 src/client/features/psi/issues/utils.tsx create mode 100644 src/client/layout/AppShell.tsx create mode 100644 src/client/lib/csv.ts rename src/server/{ => features/audit}/repositories/AuditRepository.ts (91%) rename src/server/{ => features/audit}/services/AuditService.ts (92%) rename src/server/{ => features/domain}/services/DomainService.ts (100%) rename src/server/{ => features/keywords}/repositories/KeywordResearchRepository.ts (97%) create mode 100644 src/server/features/keywords/services/KeywordResearchService.ts rename src/server/{services/keyword-research => features/keywords/services/research}/helpers.ts (100%) create mode 100644 src/server/features/keywords/services/research/index.ts rename src/server/{services/keyword-research => features/keywords/services/research}/projects.ts (93%) create mode 100644 src/server/features/keywords/services/research/research-data.ts create mode 100644 src/server/features/keywords/services/research/research.ts rename src/server/{services/keyword-research => features/keywords/services/research}/saved-keywords.ts (64%) create mode 100644 src/server/features/keywords/services/research/selection.ts rename src/server/{services/keyword-research => features/keywords/services/research}/serp.ts (62%) rename src/server/{ => features/psi}/repositories/PsiAuditRepository.ts (100%) create mode 100644 src/server/features/psi/services/PsiAuditService.ts rename src/server/{ => features/psi}/services/PsiIssuesService.ts (95%) rename src/server/{ => features/psi}/services/PsiService.ts (100%) create mode 100644 src/server/features/psi/services/psi-export.ts create mode 100644 src/server/lib/dataforseoSchemas.ts delete mode 100644 src/server/services/KeywordResearchService.ts delete mode 100644 src/server/services/keyword-research/research-data.ts create mode 100644 src/server/workflows/site-audit-workflow-helpers.ts create mode 100644 src/server/workflows/siteAuditWorkflowCrawl.ts create mode 100644 src/server/workflows/siteAuditWorkflowPhases.ts create mode 100644 src/shared/json.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 09b1df5..797436a 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -5,7 +5,13 @@ "suspicious": "error" }, "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": { "react/react-in-jsx-scope": "off", "react/jsx-uses-react": "off", @@ -17,6 +23,11 @@ "error", { "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-self-assign": "error", "eslint/no-unreachable-loop": "error", @@ -24,11 +35,11 @@ "eslint/complexity": ["error", { "max": 40 }], "eslint/max-lines": [ "error", - { "max": 2000, "skipBlankLines": true, "skipComments": true } + { "max": 350, "skipBlankLines": true, "skipComments": true } ], "eslint/max-lines-per-function": [ "error", - { "max": 1000, "skipBlankLines": true, "skipComments": true } + { "max": 120, "skipBlankLines": true, "skipComments": true } ], "eslint/max-depth": ["error", 4], "eslint/max-params": ["error", 5], diff --git a/self-host/Dockerfile.selfhost b/Dockerfile.selfhost similarity index 100% rename from self-host/Dockerfile.selfhost rename to Dockerfile.selfhost diff --git a/compose.yaml b/compose.yaml index ee5031a..5403d9c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,7 +2,7 @@ services: open-seo: build: context: . - dockerfile: self-host/Dockerfile.selfhost + dockerfile: Dockerfile.selfhost working_dir: /app environment: - PORT=${PORT:-3001} @@ -21,7 +21,6 @@ services: - .:/app - open_seo_node_modules:/app/node_modules - open_seo_pnpm_store:/pnpm/store - volumes: open_seo_node_modules: open_seo_pnpm_store: diff --git a/drizzle/0002_fair_toad_men.sql b/drizzle/0002_fair_toad_men.sql new file mode 100644 index 0000000..c540aae --- /dev/null +++ b/drizzle/0002_fair_toad_men.sql @@ -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`); diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..625bfc5 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1099 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "fa6ce21f-5b93-4b40-9323-7a568c7370ce", + "prevId": "49763879-b240-4770-8bbd-27b6acfb18a2", + "tables": { + "audit_pages": { + "name": "audit_pages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta_description": { + "name": "meta_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "canonical_url": { + "name": "canonical_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "robots_meta": { + "name": "robots_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "og_title": { + "name": "og_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "og_description": { + "name": "og_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "og_image": { + "name": "og_image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "h1_count": { + "name": "h1_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h2_count": { + "name": "h2_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h3_count": { + "name": "h3_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h4_count": { + "name": "h4_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h5_count": { + "name": "h5_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h6_count": { + "name": "h6_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "heading_order_json": { + "name": "heading_order_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "word_count": { + "name": "word_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "images_total": { + "name": "images_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "images_missing_alt": { + "name": "images_missing_alt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "images_json": { + "name": "images_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "internal_link_count": { + "name": "internal_link_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "external_link_count": { + "name": "external_link_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "has_structured_data": { + "name": "has_structured_data", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "hreflang_tags_json": { + "name": "hreflang_tags_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_indexable": { + "name": "is_indexable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audit_pages_audit_id_idx": { + "name": "audit_pages_audit_id_idx", + "columns": [ + "audit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_pages_audit_id_audits_id_fk": { + "name": "audit_pages_audit_id_audits_id_fk", + "tableFrom": "audit_pages", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_psi_results": { + "name": "audit_psi_results", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "strategy": { + "name": "strategy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "performance_score": { + "name": "performance_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessibility_score": { + "name": "accessibility_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "best_practices_score": { + "name": "best_practices_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seo_score": { + "name": "seo_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lcp_ms": { + "name": "lcp_ms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cls": { + "name": "cls", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inp_ms": { + "name": "inp_ms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_size_bytes": { + "name": "payload_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audit_psi_results_audit_id_idx": { + "name": "audit_psi_results_audit_id_idx", + "columns": [ + "audit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_psi_results_audit_id_audits_id_fk": { + "name": "audit_psi_results_audit_id_audits_id_fk", + "tableFrom": "audit_psi_results", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_psi_results_page_id_audit_pages_id_fk": { + "name": "audit_psi_results_page_id_audit_pages_id_fk", + "tableFrom": "audit_psi_results", + "tableTo": "audit_pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audits": { + "name": "audits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_url": { + "name": "start_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "workflow_instance_id": { + "name": "workflow_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "pages_crawled": { + "name": "pages_crawled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "pages_total": { + "name": "pages_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "psi_total": { + "name": "psi_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "psi_completed": { + "name": "psi_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "psi_failed": { + "name": "psi_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "current_phase": { + "name": "current_phase", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'discovery'" + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audits_project_id_idx": { + "name": "audits_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "audits_user_id_idx": { + "name": "audits_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audits_project_id_projects_id_fk": { + "name": "audits_project_id_projects_id_fk", + "tableFrom": "audits", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audits_user_id_users_id_fk": { + "name": "audits_user_id_users_id_fk", + "tableFrom": "audits", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "keyword_metrics": { + "name": "keyword_metrics", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "competition": { + "name": "competition", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "intent": { + "name": "intent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_searches": { + "name": "monthly_searches", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "keyword_metrics_unique_project_keyword_location_language": { + "name": "keyword_metrics_unique_project_keyword_location_language", + "columns": [ + "project_id", + "keyword", + "location_code", + "language_code" + ], + "isUnique": true + }, + "keyword_metrics_lookup_idx": { + "name": "keyword_metrics_lookup_idx", + "columns": [ + "project_id", + "keyword", + "location_code", + "language_code", + "fetched_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "keyword_metrics_project_id_projects_id_fk": { + "name": "keyword_metrics_project_id_projects_id_fk", + "tableFrom": "keyword_metrics", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pagespeed_api_key": { + "name": "pagespeed_api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_user_id_users_id_fk": { + "name": "projects_user_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "psi_audit_results": { + "name": "psi_audit_results", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_url": { + "name": "requested_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "final_url": { + "name": "final_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "strategy": { + "name": "strategy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'completed'" + }, + "performance_score": { + "name": "performance_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessibility_score": { + "name": "accessibility_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "best_practices_score": { + "name": "best_practices_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seo_score": { + "name": "seo_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_contentful_paint": { + "name": "first_contentful_paint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "largest_contentful_paint": { + "name": "largest_contentful_paint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_blocking_time": { + "name": "total_blocking_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cumulative_layout_shift": { + "name": "cumulative_layout_shift", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "speed_index": { + "name": "speed_index", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_to_interactive": { + "name": "time_to_interactive", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lighthouse_version": { + "name": "lighthouse_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_size_bytes": { + "name": "payload_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "psi_audit_results_project_created_idx": { + "name": "psi_audit_results_project_created_idx", + "columns": [ + "project_id", + "created_at" + ], + "isUnique": false + }, + "psi_audit_results_project_strategy_idx": { + "name": "psi_audit_results_project_strategy_idx", + "columns": [ + "project_id", + "strategy" + ], + "isUnique": false + } + }, + "foreignKeys": { + "psi_audit_results_project_id_projects_id_fk": { + "name": "psi_audit_results_project_id_projects_id_fk", + "tableFrom": "psi_audit_results", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "saved_keywords": { + "name": "saved_keywords", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2840 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "saved_keywords_unique_project_keyword_location_language": { + "name": "saved_keywords_unique_project_keyword_location_language", + "columns": [ + "project_id", + "keyword", + "location_code", + "language_code" + ], + "isUnique": true + }, + "saved_keywords_project_created_idx": { + "name": "saved_keywords_project_created_idx", + "columns": [ + "project_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "saved_keywords_project_id_projects_id_fk": { + "name": "saved_keywords_project_id_projects_id_fk", + "tableFrom": "saved_keywords", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 14d8080..bb5f668 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1772127913577, "tag": "0001_round_unus", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1773261363719, + "tag": "0002_fair_toad_men", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 386c506..68f2ef3 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "open-seo", "private": true, "sideEffects": false, + "version": "0.0.1", "type": "module", "scripts": { "dev": "AUTH_MODE=local_noauth vite dev", @@ -55,6 +56,7 @@ "fast-xml-parser": "^5.4.1", "jose": "^6.0.12", "lucide-react": "^0.542.0", + "papaparse": "^5.5.3", "react": "^19.0.0", "react-dom": "^19.0.0", "recharts": "^3.7.0", @@ -62,6 +64,7 @@ "robots-parser": "^3.0.1", "sonner": "^2.0.7", "tailwindcss": "^4.1.16", + "tldts": "^7.0.25", "zod": "^4.1.12" }, "devDependencies": { @@ -72,6 +75,7 @@ "@tanstack/devtools-vite": "^0.5.1", "@tanstack/react-devtools": "^0.9.6", "@types/node": "^22.18.13", + "@types/papaparse": "^5.5.2", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", "@vitejs/plugin-react": "^4.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ab2058..e7e2884 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: lucide-react: specifier: ^0.542.0 version: 0.542.0(react@19.2.4) + papaparse: + specifier: ^5.5.3 + version: 5.5.3 react: specifier: ^19.0.0 version: 19.2.4 @@ -80,6 +83,9 @@ importers: tailwindcss: specifier: ^4.1.16 version: 4.2.1 + tldts: + specifier: ^7.0.25 + version: 7.0.25 zod: specifier: ^4.1.12 version: 4.3.6 @@ -105,6 +111,9 @@ importers: '@types/node': specifier: ^22.18.13 version: 22.19.11 + '@types/papaparse': + specifier: ^5.5.2 + version: 5.5.2 '@types/react': specifier: ^19.0.8 version: 19.2.14 @@ -1863,6 +1872,9 @@ packages: '@types/node@22.19.11': resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==} + '@types/papaparse@5.5.2': + resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -2649,6 +2661,9 @@ packages: oxlint-tsgolint: optional: true + papaparse@5.5.3: + resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} + parse5-htmlparser2-tree-adapter@7.1.0: resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} @@ -2885,6 +2900,13 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} 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: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -4482,6 +4504,10 @@ snapshots: dependencies: 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)': dependencies: '@types/react': 19.2.14 @@ -5255,6 +5281,8 @@ snapshots: '@oxlint/binding-win32-x64-msvc': 1.50.0 oxlint-tsgolint: 0.15.0 + papaparse@5.5.3: {} + parse5-htmlparser2-tree-adapter@7.1.0: dependencies: domhandler: 5.0.3 @@ -5499,6 +5527,12 @@ snapshots: fdir: 6.5.0(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: dependencies: is-number: 7.0.0 diff --git a/src/client/features/audit/launch/AuditHistorySection.tsx b/src/client/features/audit/launch/AuditHistorySection.tsx new file mode 100644 index 0000000..9d882cc --- /dev/null +++ b/src/client/features/audit/launch/AuditHistorySection.tsx @@ -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>; + isLoading: boolean; + onView: (auditId: string) => void; + onDelete: (auditId: string) => void; +}) { + if (history.length === 0 && !isLoading) { + return ( +
+
+ +

No audits yet

+
+
+ ); + } + + if (history.length === 0) return null; + + return ( +
+
+

Previous Audits

+
+ + + + + + + + + + + + + {history.map((audit) => ( + + + + + + + + + ))} + +
DateURLStatusPagesPSI
+ {formatDate(audit.startedAt)} + {audit.startUrl} + + {audit.pagesTotal || audit.pagesCrawled} + {audit.ranPsi ? ( + Yes + ) : null} + + +
+
+
+
+ ); +} + +function HistoryActions({ + auditId, + onView, + onDelete, +}: { + auditId: string; + onView: (auditId: string) => void; + onDelete: (auditId: string) => void; +}) { + return ( +
+ +
+
+ +
+
    +
  • + +
  • +
+
+
+ ); +} diff --git a/src/client/features/audit/launch/LaunchFormCard.tsx b/src/client/features/audit/launch/LaunchFormCard.tsx new file mode 100644 index 0000000..e674939 --- /dev/null +++ b/src/client/features/audit/launch/LaunchFormCard.tsx @@ -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>; + isPending: boolean; + onSubmit: (event: FormEvent) => void; + onOpenSettings: () => void; + onRunPsiToggle: (checked: boolean) => void; + commitMaxPagesInput: () => number; +}) { + return ( +
+
+
+

Start New Audit

+ +
+ +
+ + + + +
+ + +
+
+ + +
+
+ ); +} + +function LaunchOptions({ + launchForm, + commitMaxPagesInput, +}: { + launchForm: LaunchFormApi; + commitMaxPagesInput: () => number; +}) { + return ( +
+ +
+ Max pages + + {(field) => ( + { + const next = event.target.value; + if (!/^\d*$/.test(next)) return; + field.handleChange(next); + }} + onBlur={commitMaxPagesInput} + /> + )} + +
+

+ Enter any value from {MIN_PAGES} to {MAX_PAGES_LIMIT}. +

+
+ ); +} + +function PsiOptions({ + launchForm, + settingsForm, + onRunPsiToggle, +}: { + launchForm: LaunchFormApi; + settingsForm: SettingsFormApi; + onRunPsiToggle: (checked: boolean) => void; +}) { + return ( +
+ + + snapshot.values.runPsi}> + {(runPsi) => + runPsi ? ( +
+ PSI mode + + {(field) => ( + + )} + + snapshot.values.psiApiKey} + > + {(psiApiKey) => ( + + {psiApiKey.trim() ? "PSI key saved" : "PSI key required"} + + )} + +
+ ) : null + } +
+
+ ); +} + +function LaunchErrors({ state }: { state: LaunchState }) { + return ( +
+ {state.urlError ? ( +

{state.urlError}

+ ) : null} + {state.psiRequirementError ? ( +
+ {state.psiRequirementError} +
+ ) : null} + {state.startError ? ( +
+ {state.startError} +
+ ) : null} +
+ ); +} diff --git a/src/client/features/audit/launch/LaunchView.tsx b/src/client/features/audit/launch/LaunchView.tsx new file mode 100644 index 0000000..652eec4 --- /dev/null +++ b/src/client/features/audit/launch/LaunchView.tsx @@ -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 ( +
+
+

Site Audit

+ + + + {controller.state.isSettingsOpen && ( + + )} + + +
+
+ ); +} diff --git a/src/client/features/audit/launch/SettingsModal.tsx b/src/client/features/audit/launch/SettingsModal.tsx new file mode 100644 index 0000000..c76ffeb --- /dev/null +++ b/src/client/features/audit/launch/SettingsModal.tsx @@ -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>; + onClear: () => void; + onSave: () => void; +}) { + return ( +
+
+
+
+

Audit Settings

+ +
+ +
+ +
+ + {(field) => ( + { + field.handleChange(event.target.value); + if (state.settingsError || state.psiRequirementError) { + setState((prev) => ({ + ...prev, + settingsError: null, + psiRequirementError: null, + })); + } + }} + /> + )} + + +
+

+ Stored on this project and reused by PSI and Site Audit. Required + to run PSI checks in audits. +

+ + {state.settingsError ? ( +

{state.settingsError}

+ ) : null} +
+ +
+ + +
+
+
+
+ ); +} + +function PsiKeyHelp() { + return ( +
+

Need a PSI key?

+
    +
  1. + Open{" "} + + PageSpeed Insights getting started + {" "} + and click "Get a key". +
  2. +
  3. Create any Google Cloud project (for example: Open SEO).
  4. +
  5. Paste the key here and save.
  6. +
+
+ ); +} diff --git a/src/client/features/audit/launch/types.ts b/src/client/features/audit/launch/types.ts new file mode 100644 index 0000000..1c198d3 --- /dev/null +++ b/src/client/features/audit/launch/types.ts @@ -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; +export type SettingsFormApi = ReturnType; diff --git a/src/client/features/audit/launch/useLaunchController.ts b/src/client/features/audit/launch/useLaunchController.ts new file mode 100644 index 0000000..46f709f --- /dev/null +++ b/src/client/features/audit/launch/useLaunchController.ts @@ -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({ + 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, +) { + useEffect(() => { + if (apiKey) { + settingsForm.setFieldValue("psiApiKey", apiKey); + } + }, [apiKey, settingsForm]); +} + +function useLaunchMutations({ + projectId, + historyRefetch, + keyRefetch, + clearPsiApiKeyField, +}: { + projectId: string; + historyRefetch: () => Promise; + keyRefetch: () => Promise; + 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, + settingsForm: ReturnType, + setState: React.Dispatch>, +) { + 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, + setState: React.Dispatch>, + 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); +} diff --git a/src/client/features/audit/results/ResultsTables.tsx b/src/client/features/audit/results/ResultsTables.tsx new file mode 100644 index 0000000..36a8d65 --- /dev/null +++ b/src/client/features/audit/results/ResultsTables.tsx @@ -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 ( +
+ + + + + + + + + + + + + + {pages.map((page) => ( + + + + + + + + + + ))} + +
URLStatusTitleH1WordsImagesSpeed
+ + {extractPathname(page.url)} + + + + + + {page.title || ( + missing + )} + {page.h1Count}{page.wordCount} + {page.imagesMissingAlt > 0 ? ( + + {page.imagesMissingAlt}/{page.imagesTotal} + + ) : ( + page.imagesTotal + )} + + {page.responseTimeMs ? `${page.responseTimeMs}ms` : "-"} +
+
+ ); +} + +export function PerformanceTable({ + projectId, + psi, + pages, +}: { + projectId: string; + psi: AuditResultsData["psi"]; + pages: AuditResultsData["pages"]; +}) { + return ( +
+ + + + + + + + + + + + + + + + + + {psi.map((result) => ( + candidate.id === result.pageId)} + /> + ))} + +
URLDeviceStatusPerfA11ySEOLCPCLSINPTTFBIssues
+
+ ); +} + +function PerformanceRow({ + projectId, + result, + page, +}: { + projectId: string; + result: AuditResultsData["psi"][number]; + page: AuditResultsData["pages"][number] | undefined; +}) { + const isFailed = !!result.errorMessage; + + return ( + + + {page ? extractPathname(page.url) : "-"} + + {result.strategy} + + {isFailed ? ( + + failed + + ) : ( + ok + )} + + + + + + + + + + + + {result.lcpMs ? `${(result.lcpMs / 1000).toFixed(1)}s` : "-"} + + + {result.cls != null ? result.cls.toFixed(3) : "-"} + + + {result.inpMs ? `${Math.round(result.inpMs)}ms` : "-"} + + + {result.ttfbMs ? `${Math.round(result.ttfbMs)}ms` : "-"} + + + {result.r2Key ? ( + + View issues + + ) : ( + - + )} + + + ); +} + +export function ExportDropdown({ + onExport, +}: { + onExport: (format: "csv" | "json") => void; +}) { + return ( +
+
+ + Export + +
+
    +
  • + +
  • +
  • + +
  • +
+
+ ); +} diff --git a/src/client/features/audit/results/ResultsView.tsx b/src/client/features/audit/results/ResultsView.tsx new file mode 100644 index 0000000..71b5822 --- /dev/null +++ b/src/client/features/audit/results/ResultsView.tsx @@ -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) => 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 ( + <> + + +
+
+ { + if (activeTab === "performance") { + exportPerformance(psi, pages, format); + return; + } + exportPages(pages, format); + }} + /> + + {activeTab === "pages" && } + {activeTab === "performance" && psi.length > 0 && ( + + )} +
+
+ + ); +} + +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 ( +
+ {hasPerformanceTab ? ( +
+ + +
+ ) : ( +

Pages ({pageCount})

+ )} + + +
+ ); +} + +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 ( +
+ + + + + {totalPsi > 0 && ( + <> + + + + 0 ? "text-error" : "text-success"} + /> + + )} +
+ ); +} + +function scoreClass(score: number | null) { + if (score == null) return ""; + if (score >= 90) return "text-success"; + if (score >= 50) return "text-warning"; + return "text-error"; +} diff --git a/src/client/features/audit/results/export.ts b/src/client/features/audit/results/export.ts new file mode 100644 index 0000000..c69ca1f --- /dev/null +++ b/src/client/features/audit/results/export.ts @@ -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)); +} diff --git a/src/client/features/audit/results/types.ts b/src/client/features/audit/results/types.ts new file mode 100644 index 0000000..db4a885 --- /dev/null +++ b/src/client/features/audit/results/types.ts @@ -0,0 +1,3 @@ +import type { getAuditResults } from "@/serverFunctions/audit"; + +export type AuditResultsData = Awaited>; diff --git a/src/client/features/audit/shared.tsx b/src/client/features/audit/shared.tsx new file mode 100644 index 0000000..ec2fe00 --- /dev/null +++ b/src/client/features/audit/shared.tsx @@ -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 ( + + Running + + ); + } + + if (status === "completed") { + return ( + + Done + + ); + } + + return ( + + Failed + + ); +} + +export function HttpStatusBadge({ code }: { code: number | null }) { + if (!code) return -; + if (code >= 200 && code < 300) { + return {code}; + } + if (code >= 300 && code < 400) { + return {code}; + } + return {code}; +} + +export function PsiScoreBadge({ score }: { score: number | null }) { + if (score == null) { + return -; + } + const color = + score >= 90 ? "text-success" : score >= 50 ? "text-warning" : "text-error"; + return {score}; +} + +export function StatCard({ + label, + value, + className = "", +}: { + label: string; + value: string; + className?: string; +}) { + return ( +
+
+

+ {label} +

+

{value}

+
+
+ ); +} diff --git a/src/client/features/domain/DomainOverviewPage.tsx b/src/client/features/domain/DomainOverviewPage.tsx new file mode 100644 index 0000000..99b8a1b --- /dev/null +++ b/src/client/features/domain/DomainOverviewPage.tsx @@ -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) => Record; + replace: boolean; + }) => void; +}; + +export function DomainOverviewPage({ + projectId, + searchState, + navigate, +}: Props) { + const queryClient = useQueryClient(); + const state = useDomainOverviewController({ + projectId, + queryClient, + navigate, + searchState, + }); + + return ( +
+
+
+

Domain Overview

+

+ Analyze any domain's SEO profile: traffic, keywords, and + backlinks. +

+
+ + + state.applySort(sort, getDefaultSortOrder(sort)) + } + onDomainInput={() => { + if (state.domainError) state.setDomainError(null); + }} + /> + + {state.isLoading ? ( + + ) : state.overview === null ? ( +
+ +
+ ) : ( + <> +
+ + +
+ + {!state.overview.hasData ? ( +
+ + Not enough data for this domain yet. Try another domain or + include subdomains. + +
+ ) : null} + + { + 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} + /> + + )} +
+
+ ); +} diff --git a/src/client/features/domain/components/DifficultyBadge.tsx b/src/client/features/domain/components/DifficultyBadge.tsx new file mode 100644 index 0000000..f3ee7c2 --- /dev/null +++ b/src/client/features/domain/components/DifficultyBadge.tsx @@ -0,0 +1,15 @@ +import { scoreTierClass } from "@/client/features/keywords/utils"; + +export function DifficultyBadge({ value }: { value: number | null }) { + if (value == null) { + return -; + } + + return ( + + {value} + + ); +} diff --git a/src/client/features/domain/components/DomainHistorySection.tsx b/src/client/features/domain/components/DomainHistorySection.tsx new file mode 100644 index 0000000..de41a66 --- /dev/null +++ b/src/client/features/domain/components/DomainHistorySection.tsx @@ -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 ( +
+ +

+ Enter a domain to get started +

+
+ ); + } + + return ( +
+
+
+ + + {history.length} recent search{history.length !== 1 ? "es" : ""} + +
+ +
+ +
+ {history.map((item) => ( +
onSelectHistoryItem(item)} + > +
+ +
+

+ {item.domain} +

+

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

+
+
+
+ + {new Date(item.timestamp).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + })} + + +
+
+ ))} +
+
+ ); +} diff --git a/src/client/features/domain/components/DomainKeywordsTable.tsx b/src/client/features/domain/components/DomainKeywordsTable.tsx new file mode 100644 index 0000000..6319674 --- /dev/null +++ b/src/client/features/domain/components/DomainKeywordsTable.tsx @@ -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; + 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 ( +
+
+ {selectedKeywords.size > 0 + ? `${selectedKeywords.size} selected` + : "Select keywords to save"} +
+ + + + + + + + + + + + + + + {rows.length === 0 ? ( + + + + ) : ( + rows.slice(0, 100).map((row) => ( + + + + + + + + + + + )) + )} + +
+ 0 && + visibleKeywords.every((keyword) => + selectedKeywords.has(keyword), + ) + } + onChange={onToggleAllVisible} + /> + Keyword + onSortClick("rank")} + /> + + onSortClick("volume")} + /> + + onSortClick("traffic")} + /> + + + URL + +
+ No keywords match this search. +
+ onToggleKeyword(row.keyword)} + aria-label={`Select ${row.keyword}`} + /> + {row.keyword}{row.position ?? "-"}{formatNumber(row.searchVolume)}{formatFloat(row.traffic)}{row.cpc == null ? "-" : `$${row.cpc.toFixed(2)}`} + {row.relativeUrl ?? row.url ?? "-"} + + +
+
+ ); +} diff --git a/src/client/features/domain/components/DomainOverviewLoadingState.tsx b/src/client/features/domain/components/DomainOverviewLoadingState.tsx new file mode 100644 index 0000000..30006fc --- /dev/null +++ b/src/client/features/domain/components/DomainOverviewLoadingState.tsx @@ -0,0 +1,41 @@ +export function DomainOverviewLoadingState() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ {Array.from({ length: 8 }).map((_, index) => ( +
+
+
+
+
+
+
+ ))} +
+
+
+
+ ); +} diff --git a/src/client/features/domain/components/DomainPagesTable.tsx b/src/client/features/domain/components/DomainPagesTable.tsx new file mode 100644 index 0000000..4ee4707 --- /dev/null +++ b/src/client/features/domain/components/DomainPagesTable.tsx @@ -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 ( +
+ + + + + + + + + + {rows.length === 0 ? ( + + + + ) : ( + rows.slice(0, 100).map((row) => ( + + + + + + )) + )} + +
Page + onSortClick("traffic")} + /> + + onSortClick("volume")} + /> +
+ No pages match this search. +
+ {row.relativePath ?? row.page} + {formatFloat(row.organicTraffic)}{formatNumber(row.keywords)}
+
+ ); +} diff --git a/src/client/features/domain/components/DomainResultsCard.tsx b/src/client/features/domain/components/DomainResultsCard.tsx new file mode 100644 index 0000000..0cf63a8 --- /dev/null +++ b/src/client/features/domain/components/DomainResultsCard.tsx @@ -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; + 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 ( +
+
+ + +
+ +
+ + {isKeywordsTab ? ( + + ) : ( + + )} +
+
+ ); +} + +function DomainResultsCardHeader({ + activeTab, + selectedKeywordsCount, + onTabChange, + onSaveKeywords, + onCopy, + onDownload, +}: { + activeTab: DomainActiveTab; + selectedKeywordsCount: number; + onTabChange: (tab: DomainActiveTab) => void; + onSaveKeywords: () => void; + onCopy: () => Promise; + onDownload: (extension: "csv" | "xls") => void; +}) { + return ( +
+
+ + +
+ +
+ {activeTab === "keywords" ? ( + + ) : null} +
+
+ + Export + +
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+ ); +} diff --git a/src/client/features/domain/components/DomainSearchCard.tsx b/src/client/features/domain/components/DomainSearchCard.tsx new file mode 100644 index 0000000..1a1c4e0 --- /dev/null +++ b/src/client/features/domain/components/DomainSearchCard.tsx @@ -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 ( +
+
+
+ + + + {(field) => { + if (!isSortField(field)) return null; + return ( + + ); + }} + + + +
+ + {domainError ? ( +

+ {domainError} +

+ ) : null} + + {overviewError ? ( +
+ + {overviewError} +
+ ) : null} + +
+ +
+
+
+ ); +} diff --git a/src/client/features/domain/components/SortableHeader.tsx b/src/client/features/domain/components/SortableHeader.tsx new file mode 100644 index 0000000..3230c90 --- /dev/null +++ b/src/client/features/domain/components/SortableHeader.tsx @@ -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 ( + + ); +} diff --git a/src/client/features/domain/components/StatCard.tsx b/src/client/features/domain/components/StatCard.tsx new file mode 100644 index 0000000..9890165 --- /dev/null +++ b/src/client/features/domain/components/StatCard.tsx @@ -0,0 +1,12 @@ +export function StatCard({ label, value }: { label: string; value: string }) { + return ( +
+
+

+ {label} +

+

{value}

+
+
+ ); +} diff --git a/src/client/features/domain/domainActions.ts b/src/client/features/domain/domainActions.ts new file mode 100644 index 0000000..e035480 --- /dev/null +++ b/src/client/features/domain/domainActions.ts @@ -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; + filteredKeywords: DomainOverviewData["keywords"]; + save: (payload: Parameters[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.")); + }, + }, + ); +} diff --git a/src/client/features/domain/domainOverviewControllerInternals.ts b/src/client/features/domain/domainOverviewControllerInternals.ts new file mode 100644 index 0000000..5d18723 --- /dev/null +++ b/src/client/features/domain/domainOverviewControllerInternals.ts @@ -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) => Record; + 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>>; +}) { + 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, + ) => void; + domainMutation: ReturnType["mutate"]; + addSearch: (item: Omit) => void; + setOverview: (value: DomainOverviewData) => void; + setSelectedKeywords: (value: Set) => void; + currentState: SearchState; + currentSortOrder: SortOrder; +}) { + return (params?: Partial) => { + 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.")); + }, + }, + ); + }; +} diff --git a/src/client/features/domain/mutations.ts b/src/client/features/domain/mutations.ts new file mode 100644 index 0000000..3ad740e --- /dev/null +++ b/src/client/features/domain/mutations.ts @@ -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], + }); + }, + }); +} diff --git a/src/client/features/domain/types.ts b/src/client/features/domain/types.ts new file mode 100644 index 0000000..0192579 --- /dev/null +++ b/src/client/features/domain/types.ts @@ -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; +}; diff --git a/src/client/features/domain/useDomainOverviewController.ts b/src/client/features/domain/useDomainOverviewController.ts new file mode 100644 index 0000000..05c1a3f --- /dev/null +++ b/src/client/features/domain/useDomainOverviewController.ts @@ -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) => Record; + replace: boolean; + }) => void; + searchState: SearchState; +}; + +function useDomainControlsForm(defaultValues: DomainControlsValues) { + return useForm({ defaultValues }); +} + +export function useDomainOverviewController({ + projectId, + queryClient, + navigate, + searchState, +}: Params) { + const [domainError, setDomainError] = useState(null); + const [overviewError, setOverviewError] = useState(null); + const [pendingSearch, setPendingSearch] = useState(searchState.search); + const [overview, setOverview] = useState(null); + const [selectedKeywords, setSelectedKeywords] = useState>( + 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) => { + 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) => void; + controlsForm: ReturnType; + currentSortOrder: SortOrder; + currentState: SearchState; + dataState: ReturnType; + domainMutation: ReturnType["mutate"]; + projectId: string; + saveMutation: ReturnType; + selectedKeywords: Set; + setDomainError: (value: string | null) => void; + setOverview: (value: DomainOverviewData | null) => void; + setOverviewError: (value: string | null) => void; + setPendingSearch: (value: string) => void; + setSearchParams: ( + updates: Record, + ) => void; + setSelectedKeywords: (value: Set) => 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, + }; +} diff --git a/src/client/features/domain/utils.ts b/src/client/features/domain/utils.ts new file mode 100644 index 0000000..2847846 --- /dev/null +++ b/src/client/features/domain/utils.ts @@ -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 { + 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); +} diff --git a/src/client/features/keywords/components.tsx b/src/client/features/keywords/components.tsx deleted file mode 100644 index fe82fe1..0000000 --- a/src/client/features/keywords/components.tsx +++ /dev/null @@ -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 | null>(null); - const triggerRef = useRef(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 ( - { - if (e.key === "Escape") closeNow(); - }} - > - {label} - {isOpen && typeof document !== "undefined" - ? createPortal( - - {helpText} - , - document.body, - ) - : null} - - ); -} - -export function OverviewStats({ keyword }: { keyword: KeywordResearchRow }) { - return ( -
-
- - {keyword.keyword} - - -
- -
- -
-
- Vol - - {formatNumber(keyword.searchVolume)} - -
-
- CPC - - {keyword.cpc == null ? "-" : `$${keyword.cpc.toFixed(2)}`} - -
-
- Comp - - {keyword.competition == null ? "-" : keyword.competition.toFixed(2)} - -
- -
-
- ); -} - -export function KeywordRow({ - row, - isSelected, - isActive, - onToggle, - onClick, -}: { - row: KeywordResearchRow; - isSelected: boolean; - isActive: boolean; - onToggle: () => void; - onClick: () => void; -}) { - return ( -
- { - e.stopPropagation(); - onToggle(); - }} - onClick={(e) => e.stopPropagation()} - /> - - - {row.keyword} - - - - {formatNumber(row.searchVolume)} - - - {row.cpc == null ? "-" : row.cpc.toFixed(2)} - - - {row.competition == null ? "-" : row.competition.toFixed(2)} - - -
- -
-
- ); -} - -export function KeywordCard({ - row, - isSelected, - isActive, - onToggle, - onClick, -}: { - row: KeywordResearchRow; - isSelected: boolean; - isActive: boolean; - onToggle: () => void; - onClick: () => void; -}) { - return ( -
-
- { - e.stopPropagation(); - onToggle(); - }} - onClick={(e) => e.stopPropagation()} - /> - - {row.keyword} - - -
- -
-
-

Volume

-

- {formatNumber(row.searchVolume)} -

-
-
-

CPC

-

- {row.cpc == null ? "-" : `$${row.cpc.toFixed(2)}`} -

-
-
-

Comp.

-

- {row.competition == null ? "-" : row.competition.toFixed(2)} -

-
-
- -
- -
-
- ); -} - -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 ( -
- {loading ? ( -
-
-
- {Array.from({ length: 6 }).map((_, index) => ( -
-
-
-
-
-
-
-
-
-
-
- ))} -
-
- ) : error ? ( -
-

{error}

- {onRetry ? ( - - ) : null} -
- ) : items.length === 0 ? ( -
- No SERP data available for this keyword -
- ) : ( - <> -
- {items.length} organic results -
- -
- - - - - - - - - - - - - {pageItems.map((item) => ( - - - - - - - - - ))} - -
#PageTrafficRef. DomainsBacklinksChange
- {item.rank} - -
- - {item.title || item.url} - - - - {item.domain} - -
-
- {formatNumber(item.etv)} - - {formatNumber(item.referringDomains)} - - {formatNumber(item.backlinks)} - - -
-
- - {totalPages > 1 && ( -
- - Page {page + 1} of {totalPages} - -
- - -
-
- )} - - )} -
- ); -} - -function RankChangeBadge({ - change, - isNew, -}: { - change: number | null; - isNew: boolean; -}) { - if (isNew) { - return NEW; - } - if (change == null || change === 0) { - return ; - } - if (change > 0) { - return ( - - +{change} - - ); - } - return ( - - - {change} - - ); -} - -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 ( - - {value} - - ); -} - -export function AreaTrendChart({ trend }: { trend: MonthlySearch[] }) { - const sorted = sortBy(trend, (item) => item.year * 100 + item.month); - const last12 = sorted.slice(-12); - const containerRef = useRef(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 ( -
- {chartWidth > 0 ? ( - - - - - - - - - - - formatNumber(Number(value)) - } - tick={{ fill: "var(--trend-axis-color)", fontSize: 11 }} - width={56} - axisLine={false} - tickLine={false} - /> - - - - ) : null} -
- ); -} - -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 ( - - ); -} - -function IntentBadge({ intent }: { intent: KeywordIntent }) { - const colors: Record = { - informational: "badge-info", - commercial: "badge-warning", - transactional: "badge-success", - navigational: "badge-primary", - unknown: "badge-ghost", - }; - const shortLabels: Record = { - informational: "Info", - commercial: "Comm", - transactional: "Trans", - navigational: "Nav", - unknown: "?", - }; - return ( - - {shortLabels[intent]} - - ); -} diff --git a/src/client/features/keywords/components/DisplayPrimitives.tsx b/src/client/features/keywords/components/DisplayPrimitives.tsx new file mode 100644 index 0000000..e225f4d --- /dev/null +++ b/src/client/features/keywords/components/DisplayPrimitives.tsx @@ -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 | null>(null); + const triggerRef = useRef(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 ( + { + if (e.key === "Escape") closeNow(); + }} + > + {label} + {isOpen && typeof document !== "undefined" + ? createPortal( + + {helpText} + , + document.body, + ) + : null} + + ); +} + +export function AreaTrendChart({ trend }: { trend: MonthlySearch[] }) { + const sorted = sortBy(trend, (item) => item.year * 100 + item.month); + const last12 = sorted.slice(-12); + const containerRef = useRef(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 ( +
+ {chartWidth > 0 ? ( + + + + + + + + + + + formatNumber(Number(value)) + } + tick={{ fill: "var(--trend-axis-color)", fontSize: 11 }} + width={56} + axisLine={false} + tickLine={false} + /> + + + + ) : null} +
+ ); +} + +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 ( + + ); +} + +export function IntentBadge({ intent }: { intent: KeywordIntent }) { + const colors: Record = { + informational: "badge-info", + commercial: "badge-warning", + transactional: "badge-success", + navigational: "badge-primary", + unknown: "badge-ghost", + }; + const shortLabels: Record = { + informational: "Info", + commercial: "Comm", + transactional: "Trans", + navigational: "Nav", + unknown: "?", + }; + return ( + + {shortLabels[intent]} + + ); +} diff --git a/src/client/features/keywords/components/KeywordUi.tsx b/src/client/features/keywords/components/KeywordUi.tsx new file mode 100644 index 0000000..cad8561 --- /dev/null +++ b/src/client/features/keywords/components/KeywordUi.tsx @@ -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 ( +
+
+ + {keyword.keyword} + + +
+ +
+ +
+
+ Vol + + {formatNumber(keyword.searchVolume)} + +
+
+ CPC + + {keyword.cpc == null ? "-" : `$${keyword.cpc.toFixed(2)}`} + +
+
+ Comp + + {keyword.competition == null ? "-" : keyword.competition.toFixed(2)} + +
+ +
+
+ ); +} + +export function KeywordRow({ + row, + isSelected, + isActive, + onToggle, + onClick, +}: { + row: KeywordResearchRow; + isSelected: boolean; + isActive: boolean; + onToggle: () => void; + onClick: () => void; +}) { + return ( +
+ { + e.stopPropagation(); + onToggle(); + }} + onClick={(e) => e.stopPropagation()} + /> + + + {row.keyword} + + + + {formatNumber(row.searchVolume)} + + + {row.cpc == null ? "-" : row.cpc.toFixed(2)} + + + {row.competition == null ? "-" : row.competition.toFixed(2)} + + +
+ +
+
+ ); +} + +export function KeywordCard({ + row, + isSelected, + isActive, + onToggle, + onClick, +}: { + row: KeywordResearchRow; + isSelected: boolean; + isActive: boolean; + onToggle: () => void; + onClick: () => void; +}) { + return ( +
+
+ { + e.stopPropagation(); + onToggle(); + }} + onClick={(e) => e.stopPropagation()} + /> + + {row.keyword} + + +
+ +
+
+

Volume

+

+ {formatNumber(row.searchVolume)} +

+
+
+

CPC

+

+ {row.cpc == null ? "-" : `$${row.cpc.toFixed(2)}`} +

+
+
+

Comp.

+

+ {row.competition == null ? "-" : row.competition.toFixed(2)} +

+
+
+ +
+ +
+
+ ); +} + +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 ( + + {value} + + ); +} diff --git a/src/client/features/keywords/components/SerpAnalysisCard.tsx b/src/client/features/keywords/components/SerpAnalysisCard.tsx new file mode 100644 index 0000000..2b02159 --- /dev/null +++ b/src/client/features/keywords/components/SerpAnalysisCard.tsx @@ -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 ; + if (error) { + return ( +
+

{error}

+ {onRetry ? ( + + ) : null} +
+ ); + } + if (items.length === 0) return ; + + return ( +
+
+ {items.length} organic results +
+ + +
+ ); +} + +function SerpAnalysisTable({ items }: { items: SerpResultItem[] }) { + return ( +
+ + + + + + + + + + + + + {items.map((item) => ( + + + + + + + + + ))} + +
#PageTrafficRef. DomainsBacklinksChange
+ {item.rank} + +
+ + {item.title || item.url} + + + + {item.domain} + +
+
+ {formatNumber(item.etv)} + + {formatNumber(item.referringDomains)} + + {formatNumber(item.backlinks)} + + +
+
+ ); +} + +function SerpAnalysisPagination({ + page, + totalPages, + onPageChange, +}: { + page: number; + totalPages: number; + onPageChange: (p: number) => void; +}) { + if (totalPages <= 1) return null; + + return ( +
+ + Page {page + 1} of {totalPages} + +
+ + +
+
+ ); +} + +function SerpAnalysisLoadingState() { + return ( +
+ {Array.from({ length: 8 }).map((_, index) => ( +
+ ))} +
+ ); +} + +function SerpAnalysisEmptyState({ keyword }: { keyword?: string | null }) { + return ( +
+

No SERP details available for this keyword yet.

+ {keyword ? ( +

Try clicking another keyword to load data.

+ ) : null} +
+ ); +} + +function RankChangeBadge({ + change, + isNew, +}: { + change: number | null; + isNew?: boolean; +}) { + if (isNew) { + return new; + } + if (change == null) + return ; + if (change > 0) { + return ( + + + {change} + + ); + } + if (change < 0) { + return ( + + + {Math.abs(change)} + + ); + } + return ; +} diff --git a/src/client/features/keywords/components/index.ts b/src/client/features/keywords/components/index.ts new file mode 100644 index 0000000..5683719 --- /dev/null +++ b/src/client/features/keywords/components/index.ts @@ -0,0 +1,2 @@ +export * from "./KeywordUi"; +export * from "./DisplayPrimitives"; diff --git a/src/client/features/keywords/hooks/useKeywordControlsForm.ts b/src/client/features/keywords/hooks/useKeywordControlsForm.ts new file mode 100644 index 0000000..2f371ff --- /dev/null +++ b/src/client/features/keywords/hooks/useKeywordControlsForm.ts @@ -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, + }, + }); +} diff --git a/src/client/features/keywords/hooks/useKeywordFiltering.ts b/src/client/features/keywords/hooks/useKeywordFiltering.ts new file mode 100644 index 0000000..816ef44 --- /dev/null +++ b/src/client/features/keywords/hooks/useKeywordFiltering.ts @@ -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, + }; +} diff --git a/src/client/features/keywords/hooks/useKeywordResearchData.ts b/src/client/features/keywords/hooks/useKeywordResearchData.ts new file mode 100644 index 0000000..9ed3849 --- /dev/null +++ b/src/client/features/keywords/hooks/useKeywordResearchData.ts @@ -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([]); + const [hasSearched, setHasSearched] = useState(false); + const [lastSearchError, setLastSearchError] = useState(false); + const [lastResultSource, setLastResultSource] = + useState("related"); + const [lastUsedFallback, setLastUsedFallback] = useState(false); + const [lastSearchKeyword, setLastSearchKeyword] = useState(""); + const [lastSearchLocationCode, setLastSearchLocationCode] = useState(2840); + const [researchError, setResearchError] = useState(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, + }; +} diff --git a/src/client/features/keywords/hooks/useKeywordSelection.ts b/src/client/features/keywords/hooks/useKeywordSelection.ts new file mode 100644 index 0000000..e8ace7c --- /dev/null +++ b/src/client/features/keywords/hooks/useKeywordSelection.ts @@ -0,0 +1,41 @@ +import { useCallback, useState } from "react"; + +function getNextSelectionSet( + current: Set, + allVisibleKeywords: string[], +): Set { + if (current.size === allVisibleKeywords.length) { + return new Set(); + } + + return new Set(allVisibleKeywords); +} + +export function useKeywordSelection() { + const [selectedRows, setSelectedRows] = useState>(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, + }; +} diff --git a/src/client/features/keywords/hooks/useKeywordSerpAnalysis.ts b/src/client/features/keywords/hooks/useKeywordSerpAnalysis.ts new file mode 100644 index 0000000..3bde2f5 --- /dev/null +++ b/src/client/features/keywords/hooks/useKeywordSerpAnalysis.ts @@ -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(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, + }; +} diff --git a/src/client/features/keywords/hooks/useLocalKeywordFilters.ts b/src/client/features/keywords/hooks/useLocalKeywordFilters.ts new file mode 100644 index 0000000..53b9cb9 --- /dev/null +++ b/src/client/features/keywords/hooks/useLocalKeywordFilters.ts @@ -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 = [ + "include", + "exclude", + "minVol", + "maxVol", + "minCpc", + "maxCpc", + "minKd", + "maxKd", + ]; + + for (const key of keys) { + filtersForm.setFieldValue(key, ""); + } + }, [filtersForm]); + + return { + filtersForm, + values, + resetFilters, + }; +} diff --git a/src/client/features/keywords/keywordResearchTypes.ts b/src/client/features/keywords/keywordResearchTypes.ts new file mode 100644 index 0000000..72853d4 --- /dev/null +++ b/src/client/features/keywords/keywordResearchTypes.ts @@ -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: "", +}; diff --git a/src/client/features/keywords/keywordSearchParams.ts b/src/client/features/keywords/keywordSearchParams.ts new file mode 100644 index 0000000..37257b3 --- /dev/null +++ b/src/client/features/keywords/keywordSearchParams.ts @@ -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 = [ + "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"; +} diff --git a/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx b/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx new file mode 100644 index 0000000..2bbe8c4 --- /dev/null +++ b/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx @@ -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 ( +
+ + +
+ ); +} + +function DesktopKeywordPanel({ controller }: Props) { + const { + lastResultSource, + lastUsedFallback, + searchedKeyword, + showApproximateMatchNotice, + } = controller; + + return ( +
+ {showApproximateMatchNotice ? ( +
+ No exact match for{" "} + "{searchedKeyword}". Showing + closest related keywords instead. + {lastUsedFallback ? ( + + {" "} + Source: {lastResultSource} fallback. + + ) : null} +
+ ) : null} + {controller.overviewKeyword ? ( + + ) : null} + +
+ ); +} + +function DesktopTableCard({ controller }: Props) { + const { activeFilterCount, filteredRows, selectedRows, showFilters } = + controller; + + return ( +
+
+ + + {selectedRows.size > 0 + ? `${selectedRows.size} of ${filteredRows.length} selected` + : `${filteredRows.length} keywords`} + +
+ + +
+ + {showFilters ? : null} + + +
+ ); +} + +function DesktopFilters({ controller }: Props) { + const { activeFilterCount, filtersForm } = controller; + + return ( +
+
+
+

Refine table results

+ {activeFilterCount > 0 ? ( + + {activeFilterCount} active + + ) : null} +
+ +
+ +
+ + +
+ +
+ + + +
+
+ ); +} + +function DesktopTableHeader({ controller }: Props) { + const { filteredRows, selectedRows } = controller; + + return ( +
+ 0 && selectedRows.size === filteredRows.length + } + onChange={controller.toggleAllRows} + /> + + + + + +
+ ); +} + +function DesktopTableRows({ controller }: Props) { + const { activeFilterCount, filteredRows, overviewKeyword, selectedRows } = + controller; + + return ( +
+ {filteredRows.length === 0 ? ( + + ) : ( + filteredRows.map((row) => ( + controller.toggleRowSelection(row.keyword)} + onClick={() => controller.handleRowClick(row)} + /> + )) + )} +
+ ); +} + +function DesktopSerpPanel({ controller }: Props) { + const { overviewKeyword } = controller; + const trendRangeLabel = overviewKeyword + ? formatTrendRangeLabel(overviewKeyword.trend) + : "Last 12 available months"; + + return ( +
+ {overviewKeyword && overviewKeyword.trend.length > 0 ? ( +
+

+ Search Trends{" "} + + {trendRangeLabel} + +

+ +
+ ) : null} + +
+
+

+ + SERP Analysis + {controller.activeSerpKeyword ? ( + + : {controller.activeSerpKeyword} + + ) : null} +

+
+
+ void controller.serpQuery.refetch()} + page={controller.serpPage} + pageSize={controller.SERP_PAGE_SIZE} + onPageChange={controller.setSerpPage} + /> +
+
+
+ ); +} diff --git a/src/client/features/keywords/page/KeywordResearchEmptyState.tsx b/src/client/features/keywords/page/KeywordResearchEmptyState.tsx new file mode 100644 index 0000000..e65cd60 --- /dev/null +++ b/src/client/features/keywords/page/KeywordResearchEmptyState.tsx @@ -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 ; + } + + return ; +} + +function NoResultsState({ controller }: Props) { + const { + controlsForm, + lastResultSource, + lastSearchKeyword, + lastSearchLocationCode, + lastUsedFallback, + onSearch, + } = controller; + + return ( +
+
+ +
+

+ Not enough keyword data for this query yet +

+

+ We could not find keyword opportunities for + + {` "${lastSearchKeyword}" `} + + in + + {` ${LOCATIONS[lastSearchLocationCode] || "this location"}`} + + . +

+
+ +
+

+ Source checked:{" "} + {lastResultSource} + {lastUsedFallback ? ( + (with fallback chain: related - suggestions - ideas) + ) : null} +

+

Try a broader phrase, swap word order, or change location.

+
+ +
+ + +
+
+
+ ); +} + +function SearchHistoryState({ controller }: Props) { + const { + clearHistory, + controlsForm, + history, + historyLoaded, + onSearch, + removeHistoryItem, + } = controller; + + return ( +
+
+ {historyLoaded && history.length > 0 ? ( +
+
+
+ + + {history.length} recent search + {history.length !== 1 ? "es" : ""} + +
+ +
+
+ {history.map((item) => ( +
{ + controlsForm.setFieldValue("keyword", item.keyword); + controlsForm.setFieldValue( + "locationCode", + item.locationCode, + ); + onSearch({ + keyword: item.keyword, + locationCode: item.locationCode, + }); + }} + > +
+ +
+

+ {item.keyword} +

+

+ {item.locationName} +

+
+
+
+ + {new Date(item.timestamp).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + })} + + +
+
+ ))} +
+
+ ) : ( +
+ +

+ Enter a keyword to get started +

+

+ Search for any keyword to see volume, difficulty, CPC, and related + keyword ideas. +

+
+ )} +
+
+ ); +} diff --git a/src/client/features/keywords/page/KeywordResearchLoadingState.tsx b/src/client/features/keywords/page/KeywordResearchLoadingState.tsx new file mode 100644 index 0000000..3510618 --- /dev/null +++ b/src/client/features/keywords/page/KeywordResearchLoadingState.tsx @@ -0,0 +1,72 @@ +export function KeywordResearchLoadingState() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 10 }).map((_, index) => ( +
+
+
+
+
+
+
+
+ ))} +
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 6 }).map((_, index) => ( +
+
+
+
+
+ ))} +
+
+
+ +
+
+
+
+
+
+ {Array.from({ length: 8 }).map((_, index) => ( +
+
+
+
+
+
+
+
+ ))} +
+
+
+ ); +} diff --git a/src/client/features/keywords/page/KeywordResearchMobileResults.tsx b/src/client/features/keywords/page/KeywordResearchMobileResults.tsx new file mode 100644 index 0000000..0b32a22 --- /dev/null +++ b/src/client/features/keywords/page/KeywordResearchMobileResults.tsx @@ -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 ( +
+
+ + +
+ + {mobileTab === "keywords" ? ( + + ) : ( +
+ void controller.serpQuery.refetch()} + page={controller.serpPage} + pageSize={controller.SERP_PAGE_SIZE} + onPageChange={controller.setSerpPage} + /> +
+ )} +
+ ); +} + +function MobileKeywordCards({ controller }: Props) { + const { activeFilterCount, filteredRows, selectedRows, showFilters } = + controller; + + return ( +
+ {controller.showApproximateMatchNotice ? ( +
+ No exact match for{" "} + "{controller.searchedKeyword}". + Showing closest related keywords. +
+ ) : null} + +
+ + + {selectedRows.size > 0 + ? `${selectedRows.size} selected` + : `${filteredRows.length} keywords`} + +
+ + +
+ + {showFilters ? : null} + +
+ {filteredRows.length === 0 ? ( +
+

+ No keywords match your current filters. +

+ {activeFilterCount > 0 ? ( + + ) : null} +
+ ) : ( + filteredRows.map((row) => ( + controller.toggleRowSelection(row.keyword)} + onClick={() => controller.handleRowClick(row)} + /> + )) + )} +
+
+ ); +} + +function MobileFilters({ controller }: Props) { + const { activeFilterCount, filtersForm } = controller; + + return ( +
+
+
+

Refine table results

+ {activeFilterCount > 0 ? ( + + {activeFilterCount} + + ) : null} +
+ +
+ +
+ + {(field) => ( + field.handleChange(event.target.value)} + /> + )} + + + {(field) => ( + field.handleChange(event.target.value)} + /> + )} + +
+ +
+ + + + + + +
+
+ ); +} + +function MobileRangeInput({ + form, + name, + placeholder, + step, +}: { + form: KeywordResearchControllerState["filtersForm"]; + name: "minVol" | "maxVol" | "minCpc" | "maxCpc" | "minKd" | "maxKd"; + placeholder: string; + step?: string; +}) { + return ( + + {(field) => ( + field.handleChange(event.target.value)} + /> + )} + + ); +} diff --git a/src/client/features/keywords/page/KeywordResearchPage.tsx b/src/client/features/keywords/page/KeywordResearchPage.tsx new file mode 100644 index 0000000..3c1ee3a --- /dev/null +++ b/src/client/features/keywords/page/KeywordResearchPage.tsx @@ -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 ( +
+ + + +
+ ); +} + +function KeywordResearchContent({ + controller, +}: { + controller: KeywordResearchControllerState; +}) { + if (controller.isLoading) { + return ; + } + + if (controller.researchError) { + return ( +
+
+
+ +

{controller.researchError}

+
+ +
+
+ ); + } + + if (controller.rows.length === 0) { + return ; + } + + return ; +} + +function KeywordSaveDialog({ + controller, +}: { + controller: KeywordResearchControllerState; +}) { + if (!controller.showSaveDialog) return null; + + return ( +
+
+

+ Save {controller.selectedRows.size} Keywords +

+
+

+ These keywords will be saved to your current project. +

+
+
+ + +
+
+
controller.setShowSaveDialog(false)} + /> +
+ ); +} diff --git a/src/client/features/keywords/page/KeywordResearchResults.tsx b/src/client/features/keywords/page/KeywordResearchResults.tsx new file mode 100644 index 0000000..da0f733 --- /dev/null +++ b/src/client/features/keywords/page/KeywordResearchResults.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/src/client/features/keywords/page/KeywordResearchSearchBar.tsx b/src/client/features/keywords/page/KeywordResearchSearchBar.tsx new file mode 100644 index 0000000..c690d23 --- /dev/null +++ b/src/client/features/keywords/page/KeywordResearchSearchBar.tsx @@ -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 ( +
+
+ + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + +
+ {searchInputError ? ( +

{searchInputError}

+ ) : null} +
+ ); +} diff --git a/src/client/features/keywords/page/keywordResearchDesktopFilters.tsx b/src/client/features/keywords/page/keywordResearchDesktopFilters.tsx new file mode 100644 index 0000000..85b7aa7 --- /dev/null +++ b/src/client/features/keywords/page/keywordResearchDesktopFilters.tsx @@ -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 ( + + ); +} + +export function FilterRangeInputs({ + form, + title, + minName, + maxName, + step, +}: { + form: KeywordResearchControllerState["filtersForm"]; + title: string; + minName: "minVol" | "minCpc" | "minKd"; + maxName: "maxVol" | "maxCpc" | "maxKd"; + step?: string; +}) { + return ( +
+

+ {title} +

+
+ + +
+
+ ); +} + +function CompactRangeInput({ + form, + name, + placeholder, + step, +}: { + form: KeywordResearchControllerState["filtersForm"]; + name: "minVol" | "maxVol" | "minCpc" | "maxCpc" | "minKd" | "maxKd"; + placeholder: string; + step?: string; +}) { + return ( + + {(field) => ( + field.handleChange(event.target.value)} + /> + )} + + ); +} + +export function EmptyFilterResults({ + activeFilterCount, + resetFilters, +}: { + activeFilterCount: number; + resetFilters: () => void; +}) { + return ( +
+

+ No keywords match your current filters. +

+ {activeFilterCount > 0 ? ( + + ) : null} +
+ ); +} diff --git a/src/client/features/keywords/page/types.ts b/src/client/features/keywords/page/types.ts new file mode 100644 index 0000000..ec7c69e --- /dev/null +++ b/src/client/features/keywords/page/types.ts @@ -0,0 +1,5 @@ +import type { useKeywordResearchController } from "@/client/features/keywords/state/useKeywordResearchController"; + +export type KeywordResearchControllerState = ReturnType< + typeof useKeywordResearchController +>; diff --git a/src/client/features/keywords/state/keywordControllerActions.ts b/src/client/features/keywords/state/keywordControllerActions.ts new file mode 100644 index 0000000..583787d --- /dev/null +++ b/src/client/features/keywords/state/keywordControllerActions.ts @@ -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, + ) => void; +}; + +type SaveExportActionParams = { + selectedRows: Set; + 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 }; +} diff --git a/src/client/features/keywords/state/useKeywordOverviewState.ts b/src/client/features/keywords/state/useKeywordOverviewState.ts new file mode 100644 index 0000000..1871e16 --- /dev/null +++ b/src/client/features/keywords/state/useKeywordOverviewState.ts @@ -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 }; +} diff --git a/src/client/features/keywords/state/useKeywordResearchController.ts b/src/client/features/keywords/state/useKeywordResearchController.ts new file mode 100644 index 0000000..e8417ca --- /dev/null +++ b/src/client/features/keywords/state/useKeywordResearchController.ts @@ -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(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(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) => { + 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>(state: T): T { + return state; +} diff --git a/src/client/features/keywords/utils.ts b/src/client/features/keywords/utils.ts index c183840..8495fcf 100644 --- a/src/client/features/keywords/utils.ts +++ b/src/client/features/keywords/utils.ts @@ -54,9 +54,3 @@ export function formatNumber(value: number | null | undefined): string { if (value == null) return "-"; 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}"`; -} diff --git a/src/client/features/psi/issues/PsiIssuesParts.tsx b/src/client/features/psi/issues/PsiIssuesParts.tsx new file mode 100644 index 0000000..75b39fd --- /dev/null +++ b/src/client/features/psi/issues/PsiIssuesParts.tsx @@ -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 ( + <> +
+ + + {scannedAt + ? `Scanned ${new Date(scannedAt).toLocaleString()}` + : "Reading latest issues..."} + +
+ +
+
+
+

PSI Issues

+

+ {finalUrl ?? "Loading URL..."} +

+
+
+ + + Critical {severityCounts.critical} + + + + Warning {severityCounts.warning} + + + + Info {severityCounts.info} + +
+
+
+ + ); +} + +export function PsiIssuesToolbar({ + category, + categoryCounts, + selectedCategoryLabel, + isBusy, + visibleIssues, + allIssues, + onCategoryChange, + onCopy, + onExport, + onExportCsv, +}: { + category: CategoryTab; + categoryCounts: Record; + 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 ( +
+
+ + +
+
+ ); +} + +function CategoryTabs({ + category, + categoryCounts, + onCategoryChange, +}: { + category: CategoryTab; + categoryCounts: Record; + onCategoryChange: (next: CategoryTab) => void; +}) { + return ( +
+ {categoryTabs.map((tab) => ( + + ))} +
+ ); +} + +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 ( +
+
+ + Export + +
+
    +
  • + Copy +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + Download JSON +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + Download CSV +
  • +
  • + +
  • +
  • + +
  • +
+
+ ); +} + +export function PsiIssueList({ + issues, + isLoading, +}: { + issues: PsiIssue[]; + isLoading: boolean; +}) { + if (isLoading) { + return

Loading issues...

; + } + if (!issues.length) { + return ( +

+ No unresolved issues for this category. +

+ ); + } + return ( +
+ {issues.map((issue) => ( + + ))} +
+ ); +} + +function PsiIssueCard({ issue }: { issue: PsiIssue }) { + return ( +
+
+
+
+ {issue.category} + + {severityIcon(issue.severity)} + {issue.severity} + + {issue.score != null ? ( +
+ + Score {issue.score} + +
+ ) : null} +
+ + {issue.impactMs != null || issue.impactBytes != null ? ( + + Impact {issue.impactMs ?? 0}ms / {issue.impactBytes ?? 0} bytes + + ) : null} +
+ +

{issue.title}

+ + {issue.displayValue ? ( +

{issue.displayValue}

+ ) : null} + + {issue.description ? ( +
+ {renderInlineMarkdown(issue.description)} +
+ ) : null} + + {issue.items.length > 0 ? ( +
+ + Affected items ({issue.items.length}) + +
+ {issue.items.map((item) => ( +
+                  {item}
+                
+ ))} +
+
+ ) : null} +
+
+ ); +} diff --git a/src/client/features/psi/issues/PsiIssuesScreen.tsx b/src/client/features/psi/issues/PsiIssuesScreen.tsx new file mode 100644 index 0000000..95265c9 --- /dev/null +++ b/src/client/features/psi/issues/PsiIssuesScreen.tsx @@ -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 ( +
+
+ + +
+
+ { + void runCopy(data, message); + }} + onExport={(data) => { + void runExport(data); + }} + onExportCsv={runExportCsv} + /> + +
+
+
+
+ ); +} + +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 { + return categoryTabs.reduce>( + (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, + }; +} diff --git a/src/client/features/psi/issues/types.ts b/src/client/features/psi/issues/types.ts new file mode 100644 index 0000000..7489032 --- /dev/null +++ b/src/client/features/psi/issues/types.ts @@ -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; + +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[]; +}; diff --git a/src/client/features/psi/issues/utils.tsx b/src/client/features/psi/issues/utils.tsx new file mode 100644 index 0000000..457e02f --- /dev/null +++ b/src/client/features/psi/issues/utils.tsx @@ -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( + + {label} + + , + ); + + 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 ; + if (severity === "warning") return ; + return ; +} diff --git a/src/client/hooks/useDomainSearchHistory.ts b/src/client/hooks/useDomainSearchHistory.ts index e449e5b..ce42fb8 100644 --- a/src/client/hooks/useDomainSearchHistory.ts +++ b/src/client/hooks/useDomainSearchHistory.ts @@ -1,4 +1,6 @@ import { useState, useEffect, useCallback } from "react"; +import { z } from "zod"; +import { jsonCodec } from "@/shared/json"; type DomainSortMode = "rank" | "traffic" | "volume"; type DomainTab = "keywords" | "pages"; @@ -16,34 +18,28 @@ type AddDomainSearchInput = Omit; 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) { return `domain-search-history:${projectId}`; } function loadHistory(projectId: string): DomainSearchHistoryItem[] { - try { - const raw = localStorage.getItem(storageKey(projectId)); - if (!raw) return []; - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) return []; + const raw = localStorage.getItem(storageKey(projectId)); + if (!raw) return []; - return parsed - .filter( - (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 []; - } + const parsed = domainSearchHistoryCodec.safeParse(raw); + return parsed.success ? parsed.data.slice(0, MAX_HISTORY) : []; } function saveHistory(projectId: string, items: DomainSearchHistoryItem[]) { diff --git a/src/client/hooks/useSearchHistory.ts b/src/client/hooks/useSearchHistory.ts index d29c93e..30ffe89 100644 --- a/src/client/hooks/useSearchHistory.ts +++ b/src/client/hooks/useSearchHistory.ts @@ -1,4 +1,6 @@ import { useState, useEffect, useCallback } from "react"; +import { z } from "zod"; +import { jsonCodec } from "@/shared/json"; export interface SearchHistoryItem { keyword: string; @@ -9,30 +11,26 @@ export interface SearchHistoryItem { 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) { return `search-history:${projectId}`; } function loadHistory(projectId: string): SearchHistoryItem[] { - try { - const raw = localStorage.getItem(storageKey(projectId)); - if (!raw) return []; - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) return []; + const raw = localStorage.getItem(storageKey(projectId)); + if (!raw) return []; - return parsed - .filter( - (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 []; - } + const parsed = searchHistoryCodec.safeParse(raw); + return parsed.success ? parsed.data.slice(0, MAX_HISTORY) : []; } function saveHistory(projectId: string, items: SearchHistoryItem[]) { diff --git a/src/client/layout/AppShell.tsx b/src/client/layout/AppShell.tsx new file mode 100644 index 0000000..6ed5c95 --- /dev/null +++ b/src/client/layout/AppShell.tsx @@ -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 ( +
+
+ + OpenSEO +
+ +
+ + OpenSEO + + {projectId + ? projectNavItems.map((item) => { + const Icon = item.icon; + const isActive = pathname.includes(item.matchSegment); + return ( + + + {item.label} + + ); + }) + : null} +
+ +
+ +
+
+ +
+
+
+ ); +} + +export function SeoApiStatusBanners({ + helpPath, + shouldShowSeoApiWarning, + seoApiKeyStatusError, +}: { + helpPath: string; + shouldShowSeoApiWarning: boolean; + seoApiKeyStatusError: boolean; +}) { + return ( + <> + {shouldShowSeoApiWarning ? ( +
+
+
+ + + Setup needed: add your DataForSEO API key to use OpenSEO + features. See the quick steps on the{" "} + + help page + + . + +
+
+
+ ) : null} + + {seoApiKeyStatusError ? ( +
+
+
+ + + We could not verify your DataForSEO setup. If features are not + working, check the setup steps on the{" "} + + help page + + . + +
+
+
+ ) : null} + + ); +} + +export function AppContent({ + drawerOpen, + pathname, + projectId, + onCloseDrawer, +}: { + drawerOpen: boolean; + pathname: string; + projectId: string | null; + onCloseDrawer: () => void; +}) { + return ( + <> +
+
+ +
+ + {drawerOpen ? ( +
+
+ ) : null} +
+ +
+ +
+ + ); +} + +export const MissingSeoSetupModal = React.forwardRef< + HTMLDivElement, + { + helpPath: string; + isOpen: boolean; + onClose: () => void; + } +>(({ helpPath, isOpen, onClose }, ref) => { + if (!isOpen) return null; + + return ( +
+
+
+
+ +
+
+

+ One quick setup step +

+

+ Add your DataForSEO API key to start using OpenSEO. +

+
+
+ +
+ + + Open setup guide + + +
+
+
+ ); +}); + +MissingSeoSetupModal.displayName = "MissingSeoSetupModal"; diff --git a/src/client/lib/csv.ts b/src/client/lib/csv.ts new file mode 100644 index 0000000..d800639 --- /dev/null +++ b/src/client/lib/csv.ts @@ -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); +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 0aff4f6..934c72c 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -67,6 +67,9 @@ export const keywordMetrics = sqliteTable( "keyword_metrics", { id: integer("id").primaryKey({ autoIncrement: true }), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), keyword: text("keyword").notNull(), locationCode: integer("location_code").notNull(), languageCode: text("language_code").notNull().default("en"), @@ -81,12 +84,14 @@ export const keywordMetrics = sqliteTable( .default(sql`(current_timestamp)`), }, (table) => [ - uniqueIndex("keyword_metrics_unique_keyword_location_language").on( + uniqueIndex("keyword_metrics_unique_project_keyword_location_language").on( + table.projectId, table.keyword, table.locationCode, table.languageCode, ), index("keyword_metrics_lookup_idx").on( + table.projectId, table.keyword, table.locationCode, table.languageCode, diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 56c06af..1aef004 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -2,10 +2,8 @@ import { ClientOnly, HeadContent, - Link, Scripts, createRootRoute, - Outlet, useLocation, } from "@tanstack/react-router"; import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; @@ -13,20 +11,18 @@ import { TanStackDevtools } from "@tanstack/react-devtools"; import { QueryClientProvider } from "@tanstack/react-query"; import * as React from "react"; import { useState } from "react"; -import { - Menu, - ChevronsUpDown, - AlertTriangle, - ExternalLink, -} from "lucide-react"; import { DefaultCatchBoundary } from "@/client/components/DefaultCatchBoundary"; import { NotFound } from "@/client/components/NotFound"; import appCss from "@/client/styles/app.css?url"; import { Toaster } from "sonner"; -import { Sidebar } from "@/client/components/Sidebar"; import { queryClient } from "@/client/tanstack-db"; -import { projectNavItems } from "@/client/navigation/items"; import { getSeoApiKeyStatus } from "@/serverFunctions/config"; +import { + AppContent, + MissingSeoSetupModal, + SeoApiStatusBanners, + TopNav, +} from "@/client/layout/AppShell"; const DATAFORSEO_HELP_PATH = "/help/dataforseo-api-key"; @@ -148,191 +144,32 @@ function AppLayout() { return (
- {/* Top Navbar */} -
- {/* Mobile: hamburger + title */} -
- - OpenSEO -
+ setDrawerOpen(true)} + /> - {/* Desktop: app brand + nav links (left) */} -
- - OpenSEO - - {projectId && - projectNavItems.map((item) => { - const Icon = item.icon; - const isActive = location.pathname.includes(item.matchSegment); + - return ( - - - {item.label} - - ); - })} -
+ setDrawerOpen(false)} + /> - {/* Spacer */} -
- - {/* Desktop: project switcher (right-aligned) */} -
-
- -
-
-
- - {shouldShowSeoApiWarning ? ( -
-
-
- - - Setup needed: add your DataForSEO API key to use OpenSEO - features. See the quick steps on the{" "} - - help page - - . - -
-
-
- ) : null} - - {seoApiKeyStatusError ? ( -
-
-
- - - We could not verify your DataForSEO setup. If features are not - working, check the setup steps on the{" "} - - help page - - . - -
-
-
- ) : null} - - {/* Mobile: drawer layout */} -
-
- -
- - {drawerOpen ? ( -
-
- ) : null} -
- - {/* Desktop: plain content area */} -
- -
- - {shouldShowMissingSeoApiKeyModal ? ( -
-
-
-
- -
-
-

- One quick setup step -

-

- Add your DataForSEO API key to start using OpenSEO. -

-
-
- -
- - setShowMissingSeoApiKeyModal(false)} - > - Open setup guide - - -
-
-
- ) : null} + setShowMissingSeoApiKeyModal(false)} + />
); } diff --git a/src/routes/p/$projectId/audit/index.tsx b/src/routes/p/$projectId/audit/index.tsx index d254657..2e0a3bf 100644 --- a/src/routes/p/$projectId/audit/index.tsx +++ b/src/routes/p/$projectId/audit/index.tsx @@ -1,42 +1,23 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useCallback } from "react"; +import { AlertCircle, Loader2 } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; import { - useCallback, - useEffect, - useMemo, - useState, - type FormEvent, -} from "react"; -import { toast } from "sonner"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { useForm } from "@tanstack/react-form"; -import { - startAudit, - getAuditStatus, getAuditResults, - getAuditHistory, + getAuditStatus, getCrawlProgress, - deleteAudit, } from "@/serverFunctions/audit"; -import { - clearProjectPsiApiKey, - getProjectPsiApiKey, - saveProjectPsiApiKey, -} from "@/serverFunctions/psi"; import { auditSearchSchema } from "@/types/schemas/audit"; +import { LaunchView } from "@/client/features/audit/launch/LaunchView"; +import { ResultsView } from "@/client/features/audit/results/ResultsView"; import { - ScanSearch, - AlertCircle, - CheckCircle, - Trash2, - MoreHorizontal, - ExternalLink, - Loader2, - Download, - ChevronDown, - Settings, -} from "lucide-react"; - -const SUPPORT_URL = "https://everyapp.dev/support"; + extractHostname, + extractPathname, + formatStartedAt, + HttpStatusBadge, + StatusBadge, + SUPPORT_URL, +} from "@/client/features/audit/shared"; export const Route = createFileRoute<"/p/$projectId/audit/">( "/p/$projectId/audit/", @@ -45,39 +26,6 @@ export const Route = createFileRoute<"/p/$projectId/audit/">( component: SiteAuditPage, }); -function extractPathname(url: string): string { - try { - return new URL(url).pathname; - } catch { - return url; - } -} - -function extractHostname(url: string): string { - try { - return new URL(url).hostname; - } catch { - return url; - } -} - -function formatDate(dateStr: string): string { - return new Date(dateStr).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); -} - -function formatStartedAt(dateStr: string): string { - return new Date(dateStr).toLocaleString("en-US", { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }); -} - function SiteAuditPage() { const { projectId } = Route.useParams(); const { auditId, tab } = Route.useSearch(); @@ -93,601 +41,26 @@ function SiteAuditPage() { [navigate], ); - if (auditId) { + if (!auditId) { return ( - setSearchParams({ auditId: undefined })} + onAuditStarted={(id) => setSearchParams({ auditId: id })} /> ); } return ( - setSearchParams({ auditId: id })} + auditId={auditId} + tab={tab} + setSearchParams={setSearchParams} + onBack={() => setSearchParams({ auditId: undefined })} /> ); } -function LaunchView({ - projectId, - onAuditStarted, -}: { - projectId: string; - onAuditStarted: (auditId: string) => void; -}) { - type LaunchFormValues = { - url: string; - maxPagesInput: string; - runPsi: boolean; - psiMode: "auto" | "all"; - }; - - const defaultLaunchValues: LaunchFormValues = { - url: "", - maxPagesInput: "50", - runPsi: false, - psiMode: "auto", - }; - - const minPages = 10; - const maxPagesLimit = 10_000; - const launchForm = useForm({ - defaultValues: defaultLaunchValues, - }); - const settingsForm = useForm({ - defaultValues: { - psiApiKey: "", - }, - }); - const [isSettingsOpen, setIsSettingsOpen] = useState(false); - const [showPsiKey, setShowPsiKey] = useState(false); - const [urlError, setUrlError] = useState(null); - const [psiRequirementError, setPsiRequirementError] = useState( - null, - ); - const [startError, setStartError] = useState(null); - const [settingsError, setSettingsError] = useState(null); - - const startMutation = useMutation({ - mutationFn: (data: { - projectId: string; - startUrl: string; - maxPages: number; - psiStrategy: "auto" | "all" | "none"; - psiApiKey?: string; - }) => startAudit({ data }), - }); - - const historyQuery = useQuery({ - queryKey: ["audit-history", projectId], - queryFn: () => getAuditHistory({ data: { projectId } }), - }); - - const deleteMutation = useMutation({ - mutationFn: (auditId: string) => deleteAudit({ data: { auditId } }), - onSuccess: () => { - void historyQuery.refetch(); - toast.success("Audit deleted"); - }, - }); - - const keyQuery = useQuery({ - queryKey: ["projectPsiApiKey", projectId], - // PSI key is non-billing and used to prevent API abuse; this read-back is - // intentional to keep setup simple for self-host users. - queryFn: () => getProjectPsiApiKey({ data: { projectId } }), - }); - - useEffect(() => { - if (keyQuery.data?.apiKey) { - settingsForm.setFieldValue("psiApiKey", keyQuery.data.apiKey); - } - }, [keyQuery.data?.apiKey, settingsForm]); - - const saveKeyMutation = useMutation({ - mutationFn: (apiKey: string) => - saveProjectPsiApiKey({ data: { projectId, apiKey } }), - onSuccess: async () => { - toast.success("PSI API key saved for this project"); - await keyQuery.refetch(); - }, - }); - - const clearKeyMutation = useMutation({ - mutationFn: () => clearProjectPsiApiKey({ data: { projectId } }), - onSuccess: async () => { - settingsForm.setFieldValue("psiApiKey", ""); - toast.success("PSI API key cleared"); - await keyQuery.refetch(); - }, - }); - - const applyMaxPages = (value: number) => { - const safeValue = Number.isFinite(value) - ? Math.max(minPages, Math.min(maxPagesLimit, Math.round(value))) - : minPages; - launchForm.setFieldValue("maxPagesInput", String(safeValue)); - return safeValue; - }; - - const commitMaxPagesInput = () => { - const maxPagesInput = launchForm.state.values.maxPagesInput; - if (!maxPagesInput) { - return applyMaxPages(minPages); - } - - const parsed = Number.parseInt(maxPagesInput, 10); - return applyMaxPages(parsed); - }; - - const handleStart = () => { - const launchValues = launchForm.state.values; - const settingsValues = settingsForm.state.values; - const effectiveMaxPages = commitMaxPagesInput(); - setStartError(null); - - if (!launchValues.url.trim()) { - setUrlError("Please enter a URL."); - return; - } - setUrlError(null); - - if (launchValues.runPsi && !settingsValues.psiApiKey.trim()) { - setPsiRequirementError( - "Set a Google PageSpeed Insights API key before running PSI checks.", - ); - setIsSettingsOpen(true); - return; - } - setPsiRequirementError(null); - - 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) => { - setStartError(null); - toast.success("Audit started!"); - onAuditStarted(result.auditId); - }, - onError: (error) => { - setStartError( - error instanceof Error ? error.message : "Failed to start audit", - ); - }, - }, - ); - }; - - const handleStartSubmit = (event: FormEvent) => { - event.preventDefault(); - handleStart(); - }; - - const history = historyQuery.data ?? []; - - const handleRunPsiToggle = (checked: boolean) => { - const psiApiKey = settingsForm.state.values.psiApiKey; - if (!checked) { - setPsiRequirementError(null); - launchForm.setFieldValue("runPsi", false); - return; - } - - if (!psiApiKey.trim()) { - setIsSettingsOpen(true); - return; - } - - launchForm.setFieldValue("runPsi", true); - }; - - return ( -
-
-
-

Site Audit

-
- -
-
-
-

Start New Audit

- -
- -
- - - - -
-
- -
- - Max pages - - - {(field) => ( - { - const next = e.target.value; - if (!/^\d*$/.test(next)) return; - field.handleChange(next); - }} - onBlur={commitMaxPagesInput} - /> - )} - -
-

- Enter any value from {minPages} to {maxPagesLimit}. -

-
- -
- - - state.values.runPsi} - > - {(runPsi) => - runPsi ? ( -
-
- - PSI mode - - - {(field) => ( - - )} - - state.values.psiApiKey} - > - {(psiApiKey) => ( - - {psiApiKey.trim() - ? "PSI key saved" - : "PSI key required"} - - )} - -
-
- ) : null - } -
-
- -
- {urlError ? ( -

{urlError}

- ) : null} - {psiRequirementError ? ( -
- {psiRequirementError} -
- ) : null} - {startError ? ( -
- {startError} -
- ) : null} -
-
-
-
-
- - {isSettingsOpen && ( -
-
-
-
-

Audit Settings

- -
- -
- -
- - {(field) => ( - { - field.handleChange(e.target.value); - if (settingsError) setSettingsError(null); - if (psiRequirementError) - setPsiRequirementError(null); - }} - /> - )} - - -
-

- Stored on this project and reused by PSI and Site Audit. - Required to run PSI checks in audits. -

-
-

- Need a PSI key? -

-
    -
  1. - Open{" "} - - PageSpeed Insights getting started - {" "} - and click "Get a key". -
  2. -
  3. - Create any Google Cloud project (for example: Open SEO). -
  4. -
  5. Paste the key here and save.
  6. -
-
- {settingsError ? ( -

{settingsError}

- ) : null} -
- -
- - -
-
-
-
- )} - - {history.length > 0 && ( -
-
-

Previous Audits

-
- - - - - - - - - - - - - {history.map((audit) => ( - - - - - - - - - ))} - -
DateURLStatusPagesPSI
- {formatDate(audit.startedAt)} - - {audit.startUrl} - - - {audit.pagesTotal || audit.pagesCrawled} - {audit.ranPsi ? ( - - Yes - - ) : null} - -
- -
-
- -
-
    -
  • - -
  • -
-
-
-
-
-
-
- )} - - {history.length === 0 && !historyQuery.isLoading && ( -
-
- -

No audits yet

-
-
- )} -
-
- ); -} - function AuditDetail({ projectId, auditId, @@ -882,39 +255,13 @@ function ProgressCard({ Updated {new Date(crawledUrls[0].crawledAt).toLocaleTimeString()}

- {crawledUrls.map((entry, i) => { - const pathname = extractPathname(entry.url); - return ( -
-
- - - {pathname} - -
-
- {entry.title && ( - - {entry.title} - - )} -
-
- ); - })} + {crawledUrls.map((entry, i) => ( + + ))}
@@ -923,533 +270,44 @@ function ProgressCard({ ); } -type AuditResultsData = Awaited>; - -function ResultsView({ - projectId, - data, - tab, - setSearchParams, +function ProgressRow({ + entry, + index, }: { - projectId: string; - data: AuditResultsData; - tab: string; - setSearchParams: (updates: Record) => void; + entry: { + url: string; + statusCode: number | null; + title: string | null; + crawledAt: number; + }; + index: number; }) { - const { audit, pages, psi } = data; - const hasPerformanceTab = psi.length > 0; - const activeTab = hasPerformanceTab ? tab : "pages"; - - 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 = ( - rows: typeof successful, - key: "performanceScore" | "seoScore" | "accessibilityScore", - ) => { - const values = rows - .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(successful, "performanceScore"), - avgSeo: averageScore(successful, "seoScore"), - avgAccessibility: averageScore(successful, "accessibilityScore"), - }; - }, [psi]); + const pathname = extractPathname(entry.url); return ( - <> -
- - - - - {psi.length > 0 && ( - <> - = 90 - ? "text-success" - : psiSummary.avgPerformance >= 50 - ? "text-warning" - : "text-error" - } - /> - = 90 - ? "text-success" - : psiSummary.avgSeo >= 50 - ? "text-warning" - : "text-error" - } - /> - = 90 - ? "text-success" - : psiSummary.avgAccessibility >= 50 - ? "text-warning" - : "text-error" - } - /> - 0 ? "text-error" : "text-success"} - /> - +
+
+ + + {pathname} + +
+
+ {entry.title && ( + + {entry.title} + )}
- -
-
-
- {hasPerformanceTab ? ( -
- - -
- ) : ( -

Pages ({pages.length})

- )} - - { - if (activeTab === "performance") { - exportPerformance(psi, pages, format); - } else { - exportPages(pages, format); - } - }} - /> -
- - {activeTab === "pages" && ( -
- - - - - - - - - - - - - - {pages.map((page) => ( - - - - - - - - - - ))} - -
URLStatusTitleH1WordsImagesSpeed
- - {extractPathname(page.url)} - - - - - - {page.title || ( - missing - )} - {page.h1Count}{page.wordCount} - {page.imagesMissingAlt > 0 ? ( - - {page.imagesMissingAlt}/{page.imagesTotal} - - ) : ( - page.imagesTotal - )} - - {page.responseTimeMs ? `${page.responseTimeMs}ms` : "-"} -
-
- )} - - {activeTab === "performance" && psi.length > 0 && ( -
- - - - - - - - - - - - - - - - - - {psi.map((result) => { - const page = pages.find((p) => p.id === result.pageId); - const isFailed = !!result.errorMessage; - return ( - - - - - - - - - - - - - - ); - })} - -
URLDeviceStatusPerfA11ySEOLCPCLSINPTTFBIssues
- {page ? extractPathname(page.url) : "-"} - - {result.strategy} - - {isFailed ? ( - - failed - - ) : ( - - ok - - )} - - - - - - - - {result.lcpMs - ? `${(result.lcpMs / 1000).toFixed(1)}s` - : "-"} - - {result.cls != null ? result.cls.toFixed(3) : "-"} - - {result.inpMs ? `${Math.round(result.inpMs)}ms` : "-"} - - {result.ttfbMs - ? `${Math.round(result.ttfbMs)}ms` - : "-"} - - {result.r2Key ? ( - - View issues - - ) : ( - - - - - )} -
-
- )} -
-
- - ); -} - -function StatusBadge({ status }: { status: string }) { - if (status === "running") { - return ( - - Running - - ); - } - if (status === "completed") { - return ( - - Done - - ); - } - return ( - - Failed - - ); -} - -function HttpStatusBadge({ code }: { code: number | null }) { - if (!code) return -; - if (code >= 200 && code < 300) - return {code}; - if (code >= 300 && code < 400) - return {code}; - return {code}; -} - -function PsiScoreBadge({ score }: { score: number | null }) { - if (score == null) { - return -; - } - const color = - score >= 90 ? "text-success" : score >= 50 ? "text-warning" : "text-error"; - return {score}; -} - -function StatCard({ - label, - value, - className = "", -}: { - label: string; - value: string; - className?: string; -}) { - return ( -
-
-

- {label} -

-

{value}

-
-
- ); -} - -function csvEscape( - value: string | number | boolean | null | undefined, -): string { - if (value == null) return ""; - const text = String(value).replace(/"/g, '""'); - return `"${text}"`; -} - -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); -} - -function exportPages(pages: AuditResultsData["pages"], format: "csv" | "json") { - const rows = pages.map((p) => ({ - url: p.url, - statusCode: p.statusCode, - title: p.title ?? "", - h1Count: p.h1Count, - wordCount: p.wordCount, - imagesTotal: p.imagesTotal, - imagesMissingAlt: p.imagesMissingAlt, - responseTimeMs: p.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((r) => - [ - r.url, - r.statusCode, - r.title, - r.h1Count, - r.wordCount, - r.imagesTotal, - r.imagesMissingAlt, - r.responseTimeMs, - ] - .map(csvEscape) - .join(","), - ); - - downloadFile( - [headers.map(csvEscape).join(","), ...lines].join("\n"), - "audit-pages.csv", - "text/csv", - ); -} - -function exportPerformance( - psi: AuditResultsData["psi"], - pages: AuditResultsData["pages"], - format: "csv" | "json", -) { - const rows = psi.map((r) => { - const page = pages.find((p) => p.id === r.pageId); - return { - url: page?.url ?? "", - strategy: r.strategy, - performance: r.performanceScore, - accessibility: r.accessibilityScore, - seo: r.seoScore, - lcpMs: r.lcpMs, - cls: r.cls, - inpMs: r.inpMs, - ttfbMs: r.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((r) => - [ - r.url, - r.strategy, - r.performance, - r.accessibility, - r.seo, - r.lcpMs, - r.cls, - r.inpMs, - r.ttfbMs, - ] - .map(csvEscape) - .join(","), - ); - - downloadFile( - [headers.map(csvEscape).join(","), ...lines].join("\n"), - "audit-performance.csv", - "text/csv", - ); -} - -function ExportDropdown({ - onExport, -}: { - onExport: (format: "csv" | "json") => void; -}) { - return ( -
-
- - Export - -
-
    -
  • - -
  • -
  • - -
  • -
); } diff --git a/src/routes/p/$projectId/audit/issues/$resultId.tsx b/src/routes/p/$projectId/audit/issues/$resultId.tsx index ee90275..c621d94 100644 --- a/src/routes/p/$projectId/audit/issues/$resultId.tsx +++ b/src/routes/p/$projectId/audit/issues/$resultId.tsx @@ -1,554 +1,36 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useMutation, useQuery } from "@tanstack/react-query"; -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 { PsiIssuesScreen } from "@/client/features/psi/issues/PsiIssuesScreen"; import { psiIssuesSearchSchema } from "@/types/schemas/psi"; -const categoryTabs = [ - "all", - "performance", - "accessibility", - "best-practices", - "seo", -] as const; - -type CategoryTab = (typeof categoryTabs)[number]; -type IssueCategory = Exclude; - -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")({ validateSearch: psiIssuesSearchSchema, - component: PsiIssuesPage, + component: AuditIssuesPage, }); -function PsiIssuesPage() { +function AuditIssuesPage() { const { projectId, resultId } = Route.useParams(); const { source, category } = Route.useSearch(); 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>( - (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 ( -
-
-
- - - {issuesQuery.data?.createdAt - ? `Scanned ${new Date(issuesQuery.data.createdAt).toLocaleString()}` - : "Reading latest issues..."} - -
- -
-
-
-

PSI Issues

-

- {issuesQuery.data?.finalUrl ?? "Loading URL..."} -

-
-
- - - Critical {severityCounts.critical} - - - - Warning {severityCounts.warning} - - - - Info {severityCounts.info} - -
-
-
- -
-
-
-
-
- {categoryTabs.map((tab) => ( - - ))} -
- -
-
-
- - Export - -
-
    -
  • - Copy -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - Download JSON -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - Download CSV -
  • -
  • - -
  • -
  • - -
  • -
-
-
-
-
- - {issuesQuery.isLoading ? ( -

Loading issues...

- ) : visibleIssues.length ? ( -
- {visibleIssues.map((issue) => ( -
-
-
-
- - {issue.category} - - - {severityIcon(issue.severity)} - {issue.severity} - - {issue.score != null && ( -
- - Score {issue.score} - -
- )} -
- {(issue.impactMs != null || - issue.impactBytes != null) && ( - - Impact {issue.impactMs ?? 0}ms /{" "} - {issue.impactBytes ?? 0} bytes - - )} -
- -

- {issue.title} -

- - {issue.displayValue && ( -

- {issue.displayValue} -

- )} - - {issue.description && ( -
- {renderInlineMarkdown(issue.description)} -
- )} - - {issue.items.length > 0 && ( -
- - Affected items ({issue.items.length}) - -
- {issue.items.map((item) => ( -
-                                {item}
-                              
- ))} -
-
- )} -
-
- ))} -
- ) : ( -

- No unresolved issues for this category. -

- )} -
-
-
-
+ + void navigate({ + to: "/p/$projectId/audit", + params: { projectId }, + }) + } + onCategoryChange={(next) => + void navigate({ + search: (prev) => ({ ...prev, category: next }), + replace: true, + }) + } + /> ); } - -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( - - {label} - - , - ); - - 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 ; - if (severity === "warning") return ; - return ; -} diff --git a/src/routes/p/$projectId/domain.tsx b/src/routes/p/$projectId/domain.tsx index c1d5d3f..54db3db 100644 --- a/src/routes/p/$projectId/domain.tsx +++ b/src/routes/p/$projectId/domain.tsx @@ -1,1202 +1,47 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { DomainOverviewPage } from "@/client/features/domain/DomainOverviewPage"; import { - useCallback, - useMemo, - useState, - useEffect, - type FormEvent, -} from "react"; -import { toast } from "sonner"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useForm } from "@tanstack/react-form"; -import { sortBy } from "remeda"; -import { getDomainOverview } from "@/serverFunctions/domain"; -import { saveKeywords } from "@/serverFunctions/keywords"; + resolveSortOrder, + toSortMode, + toSortOrder, +} from "@/client/features/domain/utils"; import { domainSearchSchema } from "@/types/schemas/domain"; -import { - ChevronDown, - Download, - FileSpreadsheet, - Globe, - Save, - Search, - Copy, - AlertCircle, - History, - Clock, - X, - ArrowDown, - ArrowUp, -} from "lucide-react"; -import { getStandardErrorMessage } from "@/client/lib/error-messages"; -import { useDomainSearchHistory } from "@/client/hooks/useDomainSearchHistory"; -import { HeaderHelpLabel } from "@/client/features/keywords/components"; -import { scoreTierClass } from "@/client/features/keywords/utils"; export const Route = createFileRoute("/p/$projectId/domain")({ validateSearch: domainSearchSchema, - component: DomainOverviewPage, + component: DomainOverviewRoute, }); -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; -}; - -type PageRow = { - page: string; - relativePath: string | null; - organicTraffic: number | null; - keywords: number | null; -}; - -type DomainControlsValues = { - domain: string; - subdomains: boolean; - sort: "rank" | "traffic" | "volume"; -}; - -type DomainSortMode = DomainControlsValues["sort"]; -type SortOrder = "asc" | "desc"; - -function DomainOverviewPage() { +function DomainOverviewRoute() { const { projectId } = Route.useParams(); - const queryClient = useQueryClient(); - - // --- URL search params (persisted in query string) --- - const { - domain: domainInput = "", - subdomains: includeSubdomains = true, - sort: sortMode = "rank", - order: sortOrder, - tab: activeTab = "keywords", - search: searchText = "", - } = Route.useSearch(); - const currentSortOrder = resolveSortOrder(sortMode, sortOrder); const navigate = useNavigate({ from: Route.fullPath }); - - // --- Pending state (not in URL until Search clicked) --- - const [domainError, setDomainError] = useState(null); - const [overviewError, setOverviewError] = useState(null); - const [pendingSearch, setPendingSearch] = useState(searchText); - - const defaultControlValues: DomainControlsValues = { - domain: domainInput, - subdomains: includeSubdomains, - sort: sortMode, - }; - - const controlsForm = useForm({ - defaultValues: defaultControlValues, - }); - const { - history, - isLoaded: historyLoaded, - addSearch, - clearHistory, - removeHistoryItem, - } = useDomainSearchHistory(projectId); + domain = "", + subdomains = true, + sort = "rank", + order, + tab = "keywords", + search = "", + } = Route.useSearch(); - // Sync URL params to local pending state when they change - useEffect(() => { - controlsForm.setFieldValue("domain", domainInput); - controlsForm.setFieldValue("subdomains", includeSubdomains); - controlsForm.setFieldValue("sort", sortMode); - setPendingSearch(searchText); - }, [controlsForm, domainInput, includeSubdomains, searchText, sortMode]); - - // One-time URL normalization for old links with empty/default params. - 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; - - void navigate({ - search: (prev) => ({ - ...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(prev.sort ?? "rank") - ? undefined - : prev.order, - tab: prev.tab === "keywords" ? undefined : prev.tab, - }), - replace: true, - }); - }, [navigate]); - - const setSearchParams = useCallback( - (updates: Record) => { - void navigate({ - search: (prev) => ({ ...prev, ...updates }), - replace: true, - }); - }, - [navigate], + const normalizedSort = toSortMode(sort) ?? "rank"; + const normalizedOrder = resolveSortOrder( + normalizedSort, + toSortOrder(order ?? null), ); - // --- Local-only state (API response data) --- - const [overview, setOverview] = useState<{ - domain: string; - organicTraffic: number | null; - organicKeywords: number | null; - backlinks: number | null; - referringDomains: number | null; - hasData: boolean; - keywords: KeywordRow[]; - pages: PageRow[]; - } | null>(null); - const [selectedKeywords, setSelectedKeywords] = useState>( - new Set(), - ); - - const domainMutation = useMutation({ - mutationFn: (data: { - domain: string; - includeSubdomains: boolean; - locationCode: number; - languageCode: string; - }) => getDomainOverview({ data }), - }); - const isLoading = domainMutation.isPending; - - const saveMutation = 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], - }); - }, - }); - - 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]); - useEffect(() => { - setSearchParams({ - search: pendingSearch.trim() || undefined, - }); - }, [pendingSearch, setSearchParams]); - - 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; - }); - }, [visibleKeywords]); - - const toggleKeywordSelection = (keyword: string) => { - setSelectedKeywords((prev) => { - const next = new Set(prev); - if (next.has(keyword)) { - next.delete(keyword); - } else { - next.add(keyword); - } - return next; - }); - }; - - const toggleAllVisibleKeywords = () => { - setSelectedKeywords((prev) => { - if ( - visibleKeywords.length > 0 && - visibleKeywords.every((keyword) => prev.has(keyword)) - ) { - return new Set(); - } - return new Set(visibleKeywords); - }); - }; - - 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 === sortMode - ? currentSortOrder === "asc" - ? "desc" - : "asc" - : getDefaultSortOrder(nextSort); - - applySort(nextSort, nextOrder); - }, - [applySort, currentSortOrder, sortMode], - ); - - const handleSaveKeywords = () => { - if (selectedKeywords.size === 0) { - toast.error("Select at least one keyword first"); - return; - } - - const selectedRows = filteredKeywords.filter((row) => - selectedKeywords.has(row.keyword), - ); - - saveMutation.mutate( - { - 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) => { - toast.error(getStandardErrorMessage(error, "Save failed.")); - }, - }, - ); - }; - - const onSearch = ( - params?: Partial<{ - domain: string; - subdomains: boolean; - sort: DomainSortMode; - order: SortOrder; - tab: "keywords" | "pages"; - search: string; - }>, - ) => { - const values = controlsForm.state.values; - const rawTarget = params?.domain ?? values.domain; - const activeSubdomains = params?.subdomains ?? values.subdomains; - const activeSort = params?.sort ?? sortMode; - const activeOrder = params?.order ?? currentSortOrder; - const activeTabValue = params?.tab ?? activeTab; - const activeSearch = params?.search ?? pendingSearch; - 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); - - // Update URL with pending values before searching (filter out empty values) - const searchUpdates: Record = { - domain: target, - subdomains: activeSubdomains ? undefined : activeSubdomains, - sort: toSortSearchParam(activeSort), - order: toSortOrderSearchParam(activeSort, activeOrder), - tab: activeTabValue === "keywords" ? undefined : activeTabValue, - }; - searchUpdates.search = activeSearch.trim() || undefined; - - void navigate({ - search: (prev) => ({ ...prev, ...searchUpdates }), - replace: true, - }); - - domainMutation.mutate( - { - domain: target, - includeSubdomains: activeSubdomains, - locationCode: 2840, - languageCode: "en", - }, - { - onSuccess: (response) => { - setOverview(response); - setSelectedKeywords(new Set()); - - addSearch({ - domain: target, - subdomains: activeSubdomains, - sort: activeSort, - tab: activeTabValue, - search: activeSearch.trim() || undefined, - }); - - if (!response.hasData) { - toast.info("Not enough data for this domain"); - } - }, - onError: (error) => { - setOverviewError(getStandardErrorMessage(error, "Lookup failed.")); - }, - }, - ); - }; - - const handleSearchSubmit = (event: FormEvent) => { - event.preventDefault(); - onSearch(); - }; - return ( -
-
-
-

Domain Overview

-

- Analyze any domain's SEO profile: traffic, keywords, and - backlinks. -

-
- -
-
-
- - - - {(field) => ( - - )} - - - -
- - {domainError ? ( -

- {domainError} -

- ) : null} - - {overviewError ? ( -
- - {overviewError} -
- ) : null} - -
- -
-
-
- - {isLoading ? ( - - ) : overview === null ? ( -
- {historyLoaded && history.length > 0 ? ( -
-
-
- - - {history.length} recent search - {history.length !== 1 ? "es" : ""} - -
- -
- -
- {history.map((item) => ( -
{ - const updates = { - domain: item.domain, - subdomains: item.subdomains ? undefined : false, - sort: toSortSearchParam(item.sort), - order: undefined, - tab: item.tab === "keywords" ? undefined : item.tab, - search: item.search?.trim() ? item.search : undefined, - }; - - controlsForm.setFieldValue("domain", item.domain); - controlsForm.setFieldValue( - "subdomains", - item.subdomains, - ); - controlsForm.setFieldValue("sort", item.sort); - setPendingSearch(item.search ?? ""); - setSearchParams(updates); - - onSearch({ - domain: item.domain, - subdomains: item.subdomains, - sort: item.sort, - order: getDefaultSortOrder(item.sort), - tab: item.tab, - search: item.search ?? "", - }); - }} - > -
- -
-

- {item.domain} -

-

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

-
-
-
- - {new Date(item.timestamp).toLocaleDateString( - undefined, - { month: "short", day: "numeric" }, - )} - - -
-
- ))} -
-
- ) : ( -
- -

- Enter a domain to get started -

-
- )} -
- ) : ( - <> -
- - -
- - {!overview.hasData && ( -
- - Not enough data for this domain yet. Try another domain or - include subdomains. - -
- )} - -
-
-
-
- - -
- -
- {activeTab === "keywords" && ( - - )} -
-
- - Export - -
-
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
-
-
-
- -
- -
- - {activeTab === "keywords" ? ( -
-
- {selectedKeywords.size > 0 - ? `${selectedKeywords.size} selected` - : "Select keywords to save"} -
- - - - - - - - - - - - - - - {filteredKeywords.length === 0 ? ( - - - - ) : ( - filteredKeywords.slice(0, 100).map((row) => ( - - - - - - - - - - - )) - )} - -
- 0 && - visibleKeywords.every((keyword) => - selectedKeywords.has(keyword), - ) - } - onChange={toggleAllVisibleKeywords} - /> - Keyword - handleSortColumnClick("rank")} - /> - - handleSortColumnClick("volume")} - /> - - handleSortColumnClick("traffic")} - /> - - - URL - -
- No keywords match this search. -
- - toggleKeywordSelection(row.keyword) - } - aria-label={`Select ${row.keyword}`} - /> - {row.keyword}{row.position ?? "-"}{formatNumber(row.searchVolume)}{formatFloat(row.traffic)} - {row.cpc == null - ? "-" - : `$${row.cpc.toFixed(2)}`} - - {row.relativeUrl ?? row.url ?? "-"} - - -
-
- ) : ( -
- - - - - - - - - - {filteredPages.length === 0 ? ( - - - - ) : ( - filteredPages.slice(0, 100).map((row) => ( - - - - - - )) - )} - -
Page - handleSortColumnClick("traffic")} - /> - - handleSortColumnClick("volume")} - /> -
- No pages match this search. -
- {row.relativePath ?? row.page} - {formatFloat(row.organicTraffic)}{formatNumber(row.keywords)}
-
- )} -
-
- - )} -
-
+ ); } - -function DomainOverviewLoadingState() { - return ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
- {Array.from({ length: 8 }).map((_, index) => ( -
-
-
-
-
-
-
- ))} -
-
-
-
- ); -} - -function toSortMode(value: string | null): DomainSortMode | undefined { - if (value === "rank" || value === "traffic" || value === "volume") { - return value; - } - return undefined; -} - -function toSortOrder(value: string | null): SortOrder | undefined { - if (value === "asc" || value === "desc") return value; - return undefined; -} - -function getDefaultSortOrder(sortMode: DomainSortMode): SortOrder { - return sortMode === "rank" ? "asc" : "desc"; -} - -function resolveSortOrder( - sortMode: DomainSortMode, - sortOrder: SortOrder | undefined, -): SortOrder { - return sortOrder ?? getDefaultSortOrder(sortMode); -} - -function toSortSearchParam( - sortMode: DomainSortMode, -): DomainSortMode | undefined { - return sortMode === "rank" ? undefined : sortMode; -} - -function toSortOrderSearchParam( - sortMode: DomainSortMode, - sortOrder: SortOrder, -): SortOrder | undefined { - return sortOrder === getDefaultSortOrder(sortMode) ? undefined : sortOrder; -} - -function sortableNullableNumber( - value: number | null | undefined, - order: SortOrder, -): number { - if (value != null) return value; - return order === "asc" ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY; -} - -function toPageSortMode( - sortMode: DomainSortMode, -): Exclude { - if (sortMode === "rank") return "traffic"; - return sortMode; -} - -function SortableHeader({ - label, - isActive, - order, - onClick, -}: { - label: string; - isActive: boolean; - order: SortOrder; - onClick: () => void; -}) { - return ( - - ); -} - -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; - } -} - -function StatCard({ label, value }: { label: string; value: string }) { - return ( -
-
-

- {label} -

-

{value}

-
-
- ); -} - -function DifficultyBadge({ value }: { value: number | null }) { - if (value == null) { - return -; - } - - return ( - - {value} - - ); -} - -function formatNumber(value: number | null | undefined) { - if (value == null) return "-"; - return new Intl.NumberFormat().format(value); -} - -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); -} - -function formatMetric( - value: number | null | undefined, - hasData: boolean | undefined, -) { - if (!hasData) return "Not enough data"; - return formatNumber(value); -} - -function csvEscape(value: string | number | null | undefined): string { - if (value == null) return ""; - const text = String(value).replace(/"/g, '""'); - return `"${text}"`; -} - -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, - ] - .map(csvEscape) - .join(","), - ); - return [headers.map(csvEscape).join(","), ...lines].join("\n"); -} - -function pagesToCsv(rows: PageRow[]): string { - const headers = ["Page", "Organic Traffic", "Keywords"]; - const lines = rows.map((row) => - [row.relativePath ?? row.page, row.organicTraffic, row.keywords] - .map(csvEscape) - .join(","), - ); - return [headers.map(csvEscape).join(","), ...lines].join("\n"); -} - -function downloadCsv(content: string, filename: string) { - 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); -} diff --git a/src/routes/p/$projectId/keywords.tsx b/src/routes/p/$projectId/keywords.tsx index 64ee736..4167d1e 100644 --- a/src/routes/p/$projectId/keywords.tsx +++ b/src/routes/p/$projectId/keywords.tsx @@ -1,1566 +1,50 @@ -import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { KeywordResearchPage } from "@/client/features/keywords/page/KeywordResearchPage"; import { - useMemo, - useState, - useCallback, - useEffect, - type FormEvent, -} from "react"; -import { toast } from "sonner"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useForm } from "@tanstack/react-form"; -import { reverse, sortBy } from "remeda"; -import { - researchKeywords, - saveKeywords, - getSerpAnalysis, -} from "@/serverFunctions/keywords"; -import { useSearchHistory } from "@/client/hooks/useSearchHistory"; + isResultLimit, + normalizeKeywordMode, + normalizeLegacyKeywordSearch, + normalizeSortDir, + normalizeSortField, +} from "@/client/features/keywords/keywordSearchParams"; import { keywordsSearchSchema } from "@/types/schemas/keywords"; -import { - Search, - SlidersHorizontal, - Save, - FileDown, - Globe, - History, - X, - Clock, - RotateCcw, - AlertCircle, -} from "lucide-react"; -import type { KeywordResearchRow } from "@/types/keywords"; -import { getStandardErrorMessage } from "@/client/lib/error-messages"; -import { - LOCATIONS, - csvEscape, - getLanguageCode, - parseTerms, -} from "@/client/features/keywords/utils"; -import { - AreaTrendChart, - KeywordCard, - KeywordRow, - OverviewStats, - SerpAnalysisCard, - SortHeader, - type SortDir, - type SortField, -} from "@/client/features/keywords/components"; export const Route = createFileRoute("/p/$projectId/keywords")({ validateSearch: keywordsSearchSchema, - component: KeywordResearchPage, + beforeLoad: ({ params, search }) => { + const { normalized, changed } = normalizeLegacyKeywordSearch(search); + if (!changed) return; + + throw redirect({ + to: "/p/$projectId/keywords", + params: { projectId: params.projectId }, + search: normalized, + replace: true, + }); + }, + component: KeywordResearchPageRoute, }); -function shouldNormalizeLegacySearch(raw: URLSearchParams): boolean { - const defaultValues: Array<[string, string]> = [ - ["q", ""], - ["loc", "2840"], - ["kLimit", "150"], - ["sort", "searchVolume"], - ["order", "desc"], - ["minVol", ""], - ["maxVol", ""], - ["minCpc", ""], - ["maxCpc", ""], - ["minKd", ""], - ["maxKd", ""], - ["include", ""], - ["exclude", ""], - ]; - - return defaultValues.some(([key, value]) => raw.get(key) === value); -} - -const RESULT_LIMITS = [150, 300, 500] as const; -type ResultLimit = (typeof RESULT_LIMITS)[number]; -type KeywordSource = "related" | "suggestions" | "ideas"; - -function isResultLimit(value: number): value is ResultLimit { - return value === 150 || value === 300 || value === 500; -} - -type KeywordControlsValues = { - keyword: string; - locationCode: number; - resultLimit: ResultLimit; -}; - -function applyKeywordFiltersAndSort(params: { - rows: KeywordResearchRow[]; - include: string; - exclude: string; - minVol: string; - maxVol: string; - minCpc: string; - maxCpc: string; - minKd: string; - maxKd: string; - sortField: SortField; - sortDir: SortDir; -}): KeywordResearchRow[] { - const includeTerms = parseTerms(params.include); - const excludeTerms = parseTerms(params.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.minVol && vol < Number(params.minVol)) return false; - if (params.maxVol && vol > Number(params.maxVol)) return false; - if (params.minCpc && cpc < Number(params.minCpc)) return false; - if (params.maxCpc && cpc > Number(params.maxCpc)) return false; - if (params.minKd && kd < Number(params.minKd)) return false; - if (params.maxKd && kd > Number(params.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, - ]); -} - -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", - }; -} - -function getNextSelectionSet( - current: Set, - allVisibleKeywords: string[], -): Set { - if (current.size === allVisibleKeywords.length) { - return new Set(); - } - - return new Set(allVisibleKeywords); -} - -/* ------------------------------------------------------------------ */ -/* Main page component */ -/* ------------------------------------------------------------------ */ -// eslint-disable-next-line complexity, max-lines-per-function -function KeywordResearchPage() { +function KeywordResearchPageRoute() { const { projectId } = Route.useParams(); - const queryClient = useQueryClient(); - - // --- URL search params (persisted in query string) --- const { q: keywordInput = "", loc: locationCode = 2840, kLimit: resultLimit = 150, + mode: keywordMode = "auto", sort: sortField = "searchVolume", order: sortDir = "desc", - minVol: minVolume = "", - maxVol: maxVolume = "", - minCpc = "", - maxCpc = "", - minKd: minDifficulty = "", - maxKd: maxDifficulty = "", - include: includeText = "", - exclude: excludeText = "", } = Route.useSearch(); - const navigate = useNavigate({ from: Route.fullPath }); - // --- Local-only UI state --- - const [showFilters, setShowFilters] = useState(false); - const [selectedKeyword, setSelectedKeyword] = - useState(null); - - const defaultControlValues: KeywordControlsValues = { - keyword: keywordInput, - locationCode, - resultLimit, - }; - - const controlsForm = useForm({ - defaultValues: defaultControlValues, - }); - const [pendingInclude, setPendingInclude] = useState(includeText); - const [pendingExclude, setPendingExclude] = useState(excludeText); - const [pendingMinVol, setPendingMinVol] = useState(minVolume); - const [pendingMaxVol, setPendingMaxVol] = useState(maxVolume); - const [pendingMinCpc, setPendingMinCpc] = useState(minCpc); - const [pendingMaxCpc, setPendingMaxCpc] = useState(maxCpc); - const [pendingMinKd, setPendingMinKd] = useState(minDifficulty); - const [pendingMaxKd, setPendingMaxKd] = useState(maxDifficulty); - - // Sync URL params to local pending state when they change (e.g., back/forward nav, history click) - useEffect(() => { - controlsForm.setFieldValue("keyword", keywordInput); - controlsForm.setFieldValue("locationCode", locationCode); - controlsForm.setFieldValue("resultLimit", resultLimit); - setPendingInclude(includeText); - setPendingExclude(excludeText); - setPendingMinVol(minVolume); - setPendingMaxVol(maxVolume); - setPendingMinCpc(minCpc); - setPendingMaxCpc(maxCpc); - setPendingMinKd(minDifficulty); - setPendingMaxKd(maxDifficulty); - }, [ - controlsForm, - keywordInput, - locationCode, - resultLimit, - includeText, - excludeText, - minVolume, - maxVolume, - minCpc, - maxCpc, - minDifficulty, - maxDifficulty, - ]); - - // One-time URL normalization for old links with empty/default params. - useEffect(() => { - const raw = new URLSearchParams(window.location.search); - const shouldNormalize = shouldNormalizeLegacySearch(raw); - - if (!shouldNormalize) return; - - void navigate({ - search: (prev) => ({ - ...prev, - q: prev.q === "" ? undefined : prev.q, - loc: prev.loc === 2840 ? undefined : prev.loc, - kLimit: prev.kLimit === 150 ? undefined : prev.kLimit, - sort: prev.sort === "searchVolume" ? undefined : prev.sort, - order: prev.order === "desc" ? undefined : prev.order, - minVol: prev.minVol === "" ? undefined : prev.minVol, - maxVol: prev.maxVol === "" ? undefined : prev.maxVol, - minCpc: prev.minCpc === "" ? undefined : prev.minCpc, - maxCpc: prev.maxCpc === "" ? undefined : prev.maxCpc, - minKd: prev.minKd === "" ? undefined : prev.minKd, - maxKd: prev.maxKd === "" ? undefined : prev.maxKd, - include: prev.include === "" ? undefined : prev.include, - exclude: prev.exclude === "" ? undefined : prev.exclude, - }), - replace: true, - }); - }, [navigate]); - - // Search history hook - const { - history, - isLoaded: historyLoaded, - addSearch, - clearHistory, - removeHistoryItem, - } = useSearchHistory(projectId); - - // Results - const [rows, setRows] = useState([]); - const [hasSearched, setHasSearched] = useState(false); - const [lastSearchError, setLastSearchError] = useState(false); - const [lastResultSource, setLastResultSource] = - useState("related"); - const [lastUsedFallback, setLastUsedFallback] = useState(false); - const [lastSearchKeyword, setLastSearchKeyword] = useState(""); - const [lastSearchLocationCode, setLastSearchLocationCode] = useState(2840); - const [searchInputError, setSearchInputError] = useState(null); - const [researchError, setResearchError] = useState(null); - - // The seed keyword shown in overview - const [searchedKeyword, setSearchedKeyword] = useState(""); - - // Selection - const [selectedRows, setSelectedRows] = useState>(new Set()); - - // SERP analysis — the keyword currently being viewed for SERP - const [serpKeyword, setSerpKeyword] = useState(null); - const [serpPage, setSerpPage] = useState(0); - const SERP_PAGE_SIZE = 10; - - // Save dialog - const [showSaveDialog, setShowSaveDialog] = useState(false); - - // Mobile tab state - const [mobileTab, setMobileTab] = useState<"keywords" | "serp">("keywords"); - - // --- React Query hooks --- - - // Keyword research (user-triggered) - const researchMutation = useMutation({ - mutationFn: (data: { - keywords: string[]; - locationCode: number; - languageCode: string; - resultLimit: ResultLimit; - }) => researchKeywords({ data }), - }); - const isLoading = researchMutation.isPending; - - // SERP analysis (reactive query keyed by selected keyword) - const serpQuery = useQuery({ - queryKey: ["serpAnalysis", serpKeyword, locationCode] as const, - queryFn: () => - getSerpAnalysis({ - data: { - keyword: serpKeyword!, - locationCode, - languageCode: getLanguageCode(locationCode), - }, - }), - enabled: !!serpKeyword, - }); - const serpResults = serpQuery.data?.items ?? []; - const serpLoading = serpQuery.isLoading; - const serpError = serpQuery.isError - ? getStandardErrorMessage(serpQuery.error, "Failed to load SERP data.") - : null; - - // Save keywords mutation - const saveMutation = useMutation({ - mutationFn: (data: { - projectId: string; - keywords: string[]; - locationCode: number; - languageCode: string; - }) => saveKeywords({ data }), - onSuccess: () => { - void queryClient.invalidateQueries({ - queryKey: ["savedKeywords", projectId], - }); - }, - }); - - /* ---- derived ---- */ - const filteredRows = useMemo(() => { - return applyKeywordFiltersAndSort({ - rows, - include: pendingInclude, - exclude: pendingExclude, - minVol: pendingMinVol, - maxVol: pendingMaxVol, - minCpc: pendingMinCpc, - maxCpc: pendingMaxCpc, - minKd: pendingMinKd, - maxKd: pendingMaxKd, - sortField, - sortDir, - }); - }, [ - pendingExclude, - pendingInclude, - pendingMaxCpc, - pendingMaxKd, - pendingMaxVol, - pendingMinCpc, - pendingMinKd, - pendingMinVol, - rows, - sortField, - sortDir, - ]); - - const activeFilterCount = useMemo( - () => - [ - pendingInclude, - pendingExclude, - pendingMinVol, - pendingMaxVol, - pendingMinCpc, - pendingMaxCpc, - pendingMinKd, - pendingMaxKd, - ].filter((value) => value.trim() !== "").length, - [ - pendingExclude, - pendingInclude, - pendingMaxCpc, - pendingMaxKd, - pendingMaxVol, - pendingMinCpc, - pendingMinKd, - pendingMinVol, - ], - ); - - 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; - - // The keyword to show in the overview strip (selected or first seed result) - const overviewKeyword: KeywordResearchRow | null = useMemo(() => { - if (selectedKeyword) return selectedKeyword; - // find the seed keyword in results - if (searchedKeyword && rows.length > 0) { - const seed = rows.find( - (r) => r.keyword.toLowerCase() === searchedKeyword.toLowerCase(), - ); - if (seed) return seed; - } - return rows.length > 0 ? rows[0] : null; - }, [selectedKeyword, searchedKeyword, rows]); - - // Helper to update search params - const setSearchParams = useCallback( - (updates: Record) => { - void navigate({ - search: (prev) => ({ ...prev, ...updates }), - replace: true, - }); - }, - [navigate], - ); - - /* ---- handlers ---- */ - const onSearch = ( - overrides?: Partial<{ - keyword: string; - locationCode: number; - }>, - ) => { - const values = controlsForm.state.values; - const input = overrides?.keyword ?? values.keyword; - const activeLocation = overrides?.locationCode ?? values.locationCode; - const activeResultLimit = values.resultLimit; - const languageCode = getLanguageCode(activeLocation); - const keywords = input - .split(/[\n,]/) - .map((k) => k.trim()) - .filter(Boolean); - - if (keywords.length === 0) { - setSearchInputError("Please enter at least one keyword."); - return; - } - - setSearchInputError(null); - setResearchError(null); - - // Update URL with all pending values before searching (filter out empty values) - const searchUpdates: Record = - { - q: input, - loc: activeLocation === 2840 ? undefined : activeLocation, - kLimit: activeResultLimit === 150 ? undefined : activeResultLimit, - }; - void navigate({ - search: (prev) => ({ ...prev, ...searchUpdates }), - replace: true, - }); - - setSelectedKeyword(null); - setSelectedRows(new Set()); - setSearchedKeyword(keywords[0]); - setSerpKeyword(null); - setHasSearched(true); - setLastSearchError(false); - setLastSearchKeyword(keywords[0]); - setLastSearchLocationCode(activeLocation); - - researchMutation.mutate( - { - keywords, - locationCode: activeLocation, - languageCode, - resultLimit: activeResultLimit, - }, - { - onSuccess: (result) => { - setResearchError(null); - setRows(result.rows); - setLastResultSource(result.source); - setLastUsedFallback(result.usedFallback); - - // Add to search history - if (keywords.length > 0) { - addSearch( - keywords[0], - activeLocation, - LOCATIONS[activeLocation] || "Unknown", - ); - } - - if (result.rows.length === 0) { - setSerpKeyword(null); - } else { - // Kick off SERP fetch for the seed keyword - setSerpKeyword(keywords[0]); - setSerpPage(0); - } - }, - onError: (error) => { - setLastSearchError(true); - setRows([]); - setResearchError(getStandardErrorMessage(error, "Research failed.")); - }, - }, - ); - }; - - const handleSearchSubmit = (event: FormEvent) => { - event.preventDefault(); - onSearch(); - }; - - const toggleSort = (field: SortField) => { - setSearchParams(getNextSortParams(sortField, sortDir, field)); - }; - - const toggleRowSelection = (keyword: string) => { - setSelectedRows((prev) => { - const next = new Set(prev); - if (next.has(keyword)) next.delete(keyword); - else next.add(keyword); - return next; - }); - }; - - const toggleAllRows = () => { - setSelectedRows( - getNextSelectionSet( - selectedRows, - filteredRows.map((row) => row.keyword), - ), - ); - }; - - const resetFilters = () => { - setPendingInclude(""); - setPendingExclude(""); - setPendingMinVol(""); - setPendingMaxVol(""); - setPendingMinCpc(""); - setPendingMaxCpc(""); - setPendingMinKd(""); - setPendingMaxKd(""); - // Clear URL params by setting to undefined (will be removed from URL) - setSearchParams({ - minVol: undefined, - maxVol: undefined, - minCpc: undefined, - maxCpc: undefined, - minKd: undefined, - maxKd: undefined, - include: undefined, - exclude: undefined, - }); - }; - - const handleSaveKeywords = () => { - if (selectedRows.size === 0) { - toast.error("Select at least one keyword first"); - return; - } - setShowSaveDialog(true); - }; - - const confirmSave = () => { - saveMutation.mutate( - { - projectId, - keywords: [...selectedRows], - locationCode, - languageCode: getLanguageCode(locationCode), - }, - { - onSuccess: () => { - toast.success(`Saved ${selectedRows.size} keywords`); - setShowSaveDialog(false); - }, - onError: (error) => { - toast.error(getStandardErrorMessage(error, "Save failed.")); - }, - }, - ); - }; - - const exportCsv = () => { - const source = - selectedRows.size > 0 - ? filteredRows.filter((r) => selectedRows.has(r.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((r) => - [ - csvEscape(r.keyword), - r.searchVolume ?? "", - r.cpc?.toFixed(2) ?? "", - r.competition?.toFixed(2) ?? "", - r.keywordDifficulty ?? "", - r.intent, - ].join(","), - ); - 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 = "keyword-research.csv"; - link.click(); - URL.revokeObjectURL(url); - }; - - const handleRowClick = (row: KeywordResearchRow) => { - setSelectedKeyword(row); - setSerpKeyword(row.keyword); - setSerpPage(0); - }; - - /* ================================================================ */ - /* RENDER */ - /* ================================================================ */ return ( -
- {/* 1. Search bar */} -
-
- {/* Keyword input */} - - - {/* Location */} - - {(field) => ( - - )} - - - - {(field) => ( - - )} - - - {/* Search button */} - -
- {searchInputError ? ( -

{searchInputError}

- ) : null} -
- - {/* 2. Content area */} - {isLoading ? ( - - ) : researchError ? ( -
-
-
- -

{researchError}

-
- -
-
- ) : rows.length === 0 ? ( - /* Empty state: no results or history */ - hasSearched && !isLoading && !lastSearchError ? ( -
-
- -
-

- Not enough keyword data for this query yet -

-

- We could not find keyword opportunities for - - {` "${lastSearchKeyword}" `} - - in - - {` ${LOCATIONS[lastSearchLocationCode] || "this location"}`} - - . -

-
- -
-

- Source checked:{" "} - {lastResultSource} - {lastUsedFallback ? ( - - {" "} - (with fallback chain: related → suggestions → ideas) - - ) : null} -

-

- Try a broader phrase, swap word order, or change location. -

-
- -
- - -
-
-
- ) : ( -
-
- {historyLoaded && history.length > 0 ? ( -
-
-
- - - {history.length} recent search - {history.length !== 1 ? "es" : ""} - -
- -
-
- {history.map((item) => ( -
{ - controlsForm.setFieldValue("keyword", item.keyword); - controlsForm.setFieldValue( - "locationCode", - item.locationCode, - ); - setSearchParams({ - q: item.keyword, - loc: item.locationCode, - }); - onSearch({ - keyword: item.keyword, - locationCode: item.locationCode, - }); - }} - > -
- -
-

- {item.keyword} -

-

- {item.locationName} -

-
-
-
- - {new Date(item.timestamp).toLocaleDateString( - undefined, - { month: "short", day: "numeric" }, - )} - - -
-
- ))} -
-
- ) : ( -
- -

- Enter a keyword to get started -

-

- Search for any keyword to see volume, difficulty, CPC, and - related keyword ideas. -

-
- )} -
-
- ) - ) : ( -
- {/* 4. Two-panel layout: table (left) + SERP (right) */} - {/* Desktop */} -
- {/* Left column: overview stats + keyword table */} -
- {showApproximateMatchNotice && ( -
- No exact match for{" "} - "{searchedKeyword}". - Showing closest related keywords instead. - {lastUsedFallback ? ( - - {" "} - Source: {lastResultSource} fallback. - - ) : null} -
- )} - - {/* Overview stats strip */} - {overviewKeyword && } - - {/* Keyword table card */} -
- {/* Table toolbar */} -
- - - {selectedRows.size > 0 - ? `${selectedRows.size} of ${filteredRows.length} selected` - : `${filteredRows.length} keywords`} - -
- - -
- - {showFilters && ( -
-
-
-

- Refine table results -

- {activeFilterCount > 0 && ( - - {activeFilterCount} active - - )} -
- -
- -
- - -
- -
-
-

- Search Volume -

-
- setPendingMinVol(e.target.value)} - /> - setPendingMaxVol(e.target.value)} - /> -
-
-
-

- CPC (USD) -

-
- setPendingMinCpc(e.target.value)} - /> - setPendingMaxCpc(e.target.value)} - /> -
-
-
-

- Difficulty -

-
- setPendingMinKd(e.target.value)} - /> - setPendingMaxKd(e.target.value)} - /> -
-
-
-
- )} - - {/* Table header */} -
- 0 && - selectedRows.size === filteredRows.length - } - onChange={toggleAllRows} - /> - - - - - -
- - {/* Scrollable keyword rows */} -
- {filteredRows.length === 0 ? ( -
-

- No keywords match your current filters. -

- {activeFilterCount > 0 ? ( - - ) : null} -
- ) : ( - filteredRows.map((row) => ( - toggleRowSelection(row.keyword)} - onClick={() => handleRowClick(row)} - /> - )) - )} -
-
-
- - {/* Right column: trend chart + SERP panel */} -
- {/* Trend chart */} - {overviewKeyword && overviewKeyword.trend.length > 0 && ( -
-

- Search Trends{" "} - - Past 12 months - -

- -
- )} - - {/* SERP panel */} -
-
-

- - SERP Analysis - {serpKeyword && ( - - : {serpKeyword} - - )} -

-
-
- { - void serpQuery.refetch(); - }} - page={serpPage} - pageSize={SERP_PAGE_SIZE} - onPageChange={setSerpPage} - /> -
-
-
-
- - {/* Mobile: stacked layout with tabs */} -
- {/* Tab bar */} -
- - -
- - {mobileTab === "keywords" ? ( -
- {showApproximateMatchNotice && ( -
- No exact match for{" "} - "{searchedKeyword}". - Showing closest related keywords. -
- )} - - {/* Mobile table toolbar */} -
- - - {selectedRows.size > 0 - ? `${selectedRows.size} selected` - : `${filteredRows.length} keywords`} - -
- - -
- - {showFilters && ( -
-
-
-

- Refine table results -

- {activeFilterCount > 0 && ( - - {activeFilterCount} - - )} -
- -
- -
- setPendingInclude(e.target.value)} - /> - setPendingExclude(e.target.value)} - /> -
- -
- setPendingMinVol(e.target.value)} - /> - setPendingMaxVol(e.target.value)} - /> - setPendingMinCpc(e.target.value)} - /> - setPendingMaxCpc(e.target.value)} - /> - setPendingMinKd(e.target.value)} - /> - setPendingMaxKd(e.target.value)} - /> -
-
- )} - - {/* Mobile keyword cards */} -
- {filteredRows.length === 0 ? ( -
-

- No keywords match your current filters. -

- {activeFilterCount > 0 ? ( - - ) : null} -
- ) : ( - filteredRows.map((row) => ( - toggleRowSelection(row.keyword)} - onClick={() => handleRowClick(row)} - /> - )) - )} -
-
- ) : ( -
- { - void serpQuery.refetch(); - }} - page={serpPage} - pageSize={SERP_PAGE_SIZE} - onPageChange={setSerpPage} - /> -
- )} -
-
- )} - - {/* Save dialog */} - {showSaveDialog && ( -
-
-

- Save {selectedRows.size} Keywords -

-
-

- These keywords will be saved to your current project. -

-
-
- - -
-
-
setShowSaveDialog(false)} - /> -
- )} -
- ); -} - -function KeywordResearchLoadingState() { - return ( -
-
-
-
-
-
-
-
-
-
-
-
- {Array.from({ length: 10 }).map((_, index) => ( -
-
-
-
-
-
-
-
- ))} -
-
-
-
-
-
-
-
-
-
- {Array.from({ length: 6 }).map((_, index) => ( -
-
-
-
-
- ))} -
-
-
- -
-
-
-
-
-
- {Array.from({ length: 8 }).map((_, index) => ( -
-
-
-
-
-
-
-
- ))} -
-
-
+ ); } diff --git a/src/routes/p/$projectId/psi/issues/$resultId.tsx b/src/routes/p/$projectId/psi/issues/$resultId.tsx index d32d930..ab73227 100644 --- a/src/routes/p/$projectId/psi/issues/$resultId.tsx +++ b/src/routes/p/$projectId/psi/issues/$resultId.tsx @@ -1,48 +1,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useMutation, useQuery } from "@tanstack/react-query"; -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 { PsiIssuesScreen } from "@/client/features/psi/issues/PsiIssuesScreen"; import { psiIssuesSearchSchema } from "@/types/schemas/psi"; -const categoryTabs = [ - "all", - "performance", - "accessibility", - "best-practices", - "seo", -] as const; - -type CategoryTab = (typeof categoryTabs)[number]; -type IssueCategory = Exclude; - -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")({ validateSearch: psiIssuesSearchSchema, component: PsiIssuesPage, @@ -53,502 +12,25 @@ function PsiIssuesPage() { const { source, category } = Route.useSearch(); 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>( - (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 ( -
-
-
- - - {issuesQuery.data?.createdAt - ? `Scanned ${new Date(issuesQuery.data.createdAt).toLocaleString()}` - : "Reading latest issues..."} - -
- -
-
-
-

PSI Issues

-

- {issuesQuery.data?.finalUrl ?? "Loading URL..."} -

-
-
- - - Critical {severityCounts.critical} - - - - Warning {severityCounts.warning} - - - - Info {severityCounts.info} - -
-
-
- -
-
-
-
-
- {categoryTabs.map((tab) => ( - - ))} -
- -
-
-
- - Export - -
-
    -
  • - Copy -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - Download JSON -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - Download CSV -
  • -
  • - -
  • -
  • - -
  • -
-
-
-
-
- - {issuesQuery.isLoading ? ( -

Loading issues...

- ) : visibleIssues.length ? ( -
- {visibleIssues.map((issue) => ( -
-
-
-
- - {issue.category} - - - {severityIcon(issue.severity)} - {issue.severity} - - {issue.score != null && ( -
- - Score {issue.score} - -
- )} -
- {(issue.impactMs != null || - issue.impactBytes != null) && ( - - Impact {issue.impactMs ?? 0}ms /{" "} - {issue.impactBytes ?? 0} bytes - - )} -
- -

- {issue.title} -

- - {issue.displayValue && ( -

- {issue.displayValue} -

- )} - - {issue.description && ( -
- {renderInlineMarkdown(issue.description)} -
- )} - - {issue.items.length > 0 && ( -
- - Affected items ({issue.items.length}) - -
- {issue.items.map((item) => ( -
-                                {item}
-                              
- ))} -
-
- )} -
-
- ))} -
- ) : ( -

- No unresolved issues for this category. -

- )} -
-
-
-
+ + void navigate({ + to: source === "site" ? "/p/$projectId/audit" : "..", + params: { projectId }, + }) + } + onCategoryChange={(next) => + void navigate({ + search: (prev) => ({ ...prev, category: next }), + replace: true, + }) + } + /> ); } - -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( - - {label} - - , - ); - - 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 ; - if (severity === "warning") return ; - return ; -} diff --git a/src/routes/p/$projectId/saved.tsx b/src/routes/p/$projectId/saved.tsx index b6b3e45..4418577 100644 --- a/src/routes/p/$projectId/saved.tsx +++ b/src/routes/p/$projectId/saved.tsx @@ -7,6 +7,7 @@ import { removeSavedKeyword, } from "@/serverFunctions/keywords"; import { Trash2, Download, Search, Loader2, AlertCircle } from "lucide-react"; +import { buildCsv, downloadCsv } from "@/client/lib/csv"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; export const Route = createFileRoute("/p/$projectId/saved")({ @@ -66,31 +67,58 @@ function SavedKeywordsPage() { "Intent", "Fetched At", ]; - const csvRows = savedKeywords.map((kw) => - [ - csvEscape(kw.keyword), - kw.searchVolume ?? "", - kw.cpc?.toFixed(2) ?? "", - kw.competition?.toFixed(2) ?? "", - kw.keywordDifficulty ?? "", - kw.intent ?? "", - kw.fetchedAt ?? "", - ].join(","), - ); - 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); + const csvRows = savedKeywords.map((kw) => [ + kw.keyword, + kw.searchVolume ?? "", + kw.cpc?.toFixed(2) ?? "", + kw.competition?.toFixed(2) ?? "", + kw.keywordDifficulty ?? "", + kw.intent ?? "", + kw.fetchedAt ?? "", + ]); + const csv = buildCsv(headers, csvRows); + downloadCsv("saved-keywords.csv", csv); }; + return ( + + ); +} + +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 (
- {/* Header */}

Saved Keywords

@@ -99,13 +127,12 @@ function SavedKeywordsPage() {

{savedKeywords.length > 0 && ( - )}
- {/* Keyword list */} {isLoading ? (
@@ -149,65 +176,11 @@ function SavedKeywordsPage() { {savedKeywords.length} saved keyword {savedKeywords.length !== 1 ? "s" : ""}

-
- - - - - - - - - - - - - - - {savedKeywords.map((kw) => ( - - - - - - - - - - - ))} - -
KeywordVolumeCPCCompetitionDifficultyIntentLast Fetched
{kw.keyword}{formatNumber(kw.searchVolume)} - {kw.cpc == null ? "-" : `$${kw.cpc.toFixed(2)}`} - - {kw.competition == null - ? "-" - : kw.competition.toFixed(2)} - - - - - {kw.intent ?? "?"} - - - {kw.fetchedAt - ? new Date(kw.fetchedAt).toLocaleDateString() - : "-"} - - -
-
+
)} @@ -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 ( +
+ + + + + + + + + + + + + + + {rows.map((kw) => ( + + + + + + + + + + + ))} + +
KeywordVolumeCPCCompetitionDifficultyIntentLast Fetched
{kw.keyword}{formatNumber(kw.searchVolume)}{kw.cpc == null ? "-" : `$${kw.cpc.toFixed(2)}`} + {kw.competition == null ? "-" : kw.competition.toFixed(2)} + + + + + {kw.intent ?? "?"} + + + {kw.fetchedAt + ? new Date(kw.fetchedAt).toLocaleDateString() + : "-"} + + +
+
+ ); +} + function DifficultyBadge({ value }: { value: number | null }) { if (value == null) return -; @@ -230,9 +280,3 @@ function formatNumber(value: number | null | undefined) { if (value == null) return "-"; 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}"`; -} diff --git a/src/server/repositories/AuditRepository.ts b/src/server/features/audit/repositories/AuditRepository.ts similarity index 91% rename from src/server/repositories/AuditRepository.ts rename to src/server/features/audit/repositories/AuditRepository.ts index 602c161..a272ef4 100644 --- a/src/server/repositories/AuditRepository.ts +++ b/src/server/features/audit/repositories/AuditRepository.ts @@ -32,6 +32,7 @@ async function createAudit(data: { async function updateAuditProgress( auditId: string, + workflowInstanceId: string, data: { pagesCrawled?: number; pagesTotal?: number; @@ -41,11 +42,20 @@ async function updateAuditProgress( 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( auditId: string, + workflowInstanceId: string, data: { pagesCrawled: number; pagesTotal: number; @@ -59,10 +69,15 @@ async function completeAudit( currentPhase: "completed", ...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 .update(audits) .set({ @@ -70,7 +85,24 @@ async function failAudit(auditId: string) { completedAt: new Date().toISOString(), 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) ───────────────────────────────────── @@ -298,6 +330,7 @@ export const AuditRepository = { updateAuditProgress, completeAudit, failAudit, + getAuditForWorkflow, batchWriteResults, isProjectOwnedByUser, getAuditForUser, diff --git a/src/server/services/AuditService.ts b/src/server/features/audit/services/AuditService.ts similarity index 92% rename from src/server/services/AuditService.ts rename to src/server/features/audit/services/AuditService.ts index 8d4f80f..ae4062f 100644 --- a/src/server/services/AuditService.ts +++ b/src/server/features/audit/services/AuditService.ts @@ -3,12 +3,13 @@ * Orchestrates between the workflow trigger, repository, and data formatting. */ 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 { normalizeAndValidateStartUrl } from "@/server/lib/audit/url-policy"; import { AppError } from "@/server/lib/errors"; 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"; const auditConfigSchema = z.object({ @@ -17,16 +18,12 @@ const auditConfigSchema = z.object({ psiApiKey: z.string().optional(), }); +const auditConfigCodec = jsonCodec(auditConfigSchema); + function parseAuditConfig(configRaw: string | null): AuditConfig | null { if (!configRaw) return null; - try { - const parsed = JSON.parse(configRaw); - const result = auditConfigSchema.safeParse(parsed); - if (!result.success) return null; - return result.data; - } catch { - return null; - } + const result = auditConfigCodec.safeParse(configRaw); + return result.success ? result.data : null; } async function startAudit(input: { diff --git a/src/server/services/DomainService.ts b/src/server/features/domain/services/DomainService.ts similarity index 100% rename from src/server/services/DomainService.ts rename to src/server/features/domain/services/DomainService.ts diff --git a/src/server/repositories/KeywordResearchRepository.ts b/src/server/features/keywords/repositories/KeywordResearchRepository.ts similarity index 97% rename from src/server/repositories/KeywordResearchRepository.ts rename to src/server/features/keywords/repositories/KeywordResearchRepository.ts index 5a98ec0..17926f6 100644 --- a/src/server/repositories/KeywordResearchRepository.ts +++ b/src/server/features/keywords/repositories/KeywordResearchRepository.ts @@ -4,6 +4,7 @@ import { keywordMetrics, projects, savedKeywords } from "@/db/schema"; import { AppError } from "@/server/lib/errors"; async function upsertKeywordMetric(params: { + projectId: string; keyword: string; locationCode: number; languageCode: string; @@ -19,6 +20,7 @@ async function upsertKeywordMetric(params: { await db .insert(keywordMetrics) .values({ + projectId: params.projectId, keyword: params.keyword, locationCode: params.locationCode, languageCode: params.languageCode, @@ -32,6 +34,7 @@ async function upsertKeywordMetric(params: { }) .onConflictDoUpdate({ target: [ + keywordMetrics.projectId, keywordMetrics.keyword, keywordMetrics.locationCode, keywordMetrics.languageCode, @@ -157,6 +160,7 @@ async function listSavedKeywordsByProject(projectId: string) { keywordMetrics, and( eq(keywordMetrics.keyword, savedKeywords.keyword), + eq(keywordMetrics.projectId, savedKeywords.projectId), eq(keywordMetrics.locationCode, savedKeywords.locationCode), eq(keywordMetrics.languageCode, savedKeywords.languageCode), ), diff --git a/src/server/features/keywords/services/KeywordResearchService.ts b/src/server/features/keywords/services/KeywordResearchService.ts new file mode 100644 index 0000000..c05c8fb --- /dev/null +++ b/src/server/features/keywords/services/KeywordResearchService.ts @@ -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; diff --git a/src/server/services/keyword-research/helpers.ts b/src/server/features/keywords/services/research/helpers.ts similarity index 100% rename from src/server/services/keyword-research/helpers.ts rename to src/server/features/keywords/services/research/helpers.ts diff --git a/src/server/features/keywords/services/research/index.ts b/src/server/features/keywords/services/research/index.ts new file mode 100644 index 0000000..43a7ab9 --- /dev/null +++ b/src/server/features/keywords/services/research/index.ts @@ -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"; diff --git a/src/server/services/keyword-research/projects.ts b/src/server/features/keywords/services/research/projects.ts similarity index 93% rename from src/server/services/keyword-research/projects.ts rename to src/server/features/keywords/services/research/projects.ts index 866ad51..774dd22 100644 --- a/src/server/services/keyword-research/projects.ts +++ b/src/server/features/keywords/services/research/projects.ts @@ -2,7 +2,7 @@ import type { CreateProjectInput, DeleteProjectInput, } from "@/types/schemas/keywords"; -import { KeywordResearchRepository } from "@/server/repositories/KeywordResearchRepository"; +import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository"; export async function listProjects(userId: string) { const rows = await KeywordResearchRepository.listProjects(userId); diff --git a/src/server/features/keywords/services/research/research-data.ts b/src/server/features/keywords/services/research/research-data.ts new file mode 100644 index 0000000..8168e9f --- /dev/null +++ b/src/server/features/keywords/services/research/research-data.ts @@ -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(); + + 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, +) { + const items = await fetchRelatedKeywordsRaw( + params.seedKeyword, + params.locationCode, + params.languageCode, + params.resultLimit, + 3, + ); + + const rows: EnrichedKeyword[] = []; + const seen = new Set(); + + 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 { + 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, + ), + ); +} diff --git a/src/server/features/keywords/services/research/research.ts b/src/server/features/keywords/services/research/research.ts new file mode 100644 index 0000000..afe2593 --- /dev/null +++ b/src/server/features/keywords/services/research/research.ts @@ -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 { + return fetchResearchRowsBySource({ + source, + seedKeyword, + locationCode: input.locationCode, + languageCode: input.languageCode, + resultLimit: input.resultLimit, + }); +} + +async function fetchAutoRows( + input: ResearchKeywordsInput, + seedKeyword: string, +): Promise { + const attempts: SourceAttempt[] = []; + let lastSource: KeywordSource = "related"; + const accumulatedRows: EnrichedKeyword[] = []; + const seenKeywords = new Set(); + + 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, + input: ResearchKeywordsInput, + seedKeyword: string, +): Promise { + 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 { + 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; +} diff --git a/src/server/services/keyword-research/saved-keywords.ts b/src/server/features/keywords/services/research/saved-keywords.ts similarity index 64% rename from src/server/services/keyword-research/saved-keywords.ts rename to src/server/features/keywords/services/research/saved-keywords.ts index 26dec66..6ef8299 100644 --- a/src/server/services/keyword-research/saved-keywords.ts +++ b/src/server/features/keywords/services/research/saved-keywords.ts @@ -1,5 +1,6 @@ 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 { GetSavedKeywordsInput, RemoveSavedKeywordInput, @@ -15,16 +16,12 @@ const monthlySearchSchema = z.object({ searchVolume: z.number().int().nonnegative(), }); +const monthlySearchesCodec = jsonCodec(z.array(monthlySearchSchema)); + function parseMonthlySearches(payload: string | null): MonthlySearch[] { if (!payload) return []; - try { - const parsed = JSON.parse(payload); - const result = z.array(monthlySearchSchema).safeParse(parsed); - return result.success ? result.data : []; - } catch (error) { - console.error("keywords.saved.parse-monthly-searches failed:", error); - return []; - } + const result = monthlySearchesCodec.safeParse(payload); + return result.success ? result.data : []; } 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[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({ projectId: input.projectId, keywords: normalizedKeywords, diff --git a/src/server/features/keywords/services/research/selection.ts b/src/server/features/keywords/services/research/selection.ts new file mode 100644 index 0000000..33886ba --- /dev/null +++ b/src/server/features/keywords/services/research/selection.ts @@ -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; +} diff --git a/src/server/services/keyword-research/serp.ts b/src/server/features/keywords/services/research/serp.ts similarity index 62% rename from src/server/services/keyword-research/serp.ts rename to src/server/features/keywords/services/research/serp.ts index 846494a..4df950f 100644 --- a/src/server/services/keyword-research/serp.ts +++ b/src/server/features/keywords/services/research/serp.ts @@ -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 type { SerpResultItem } from "@/types/keywords"; -import { normalizeKeyword } from "./helpers"; import { z } from "zod"; +import { normalizeKeyword } from "./helpers"; 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({ rank: z.number().int(), title: z.string(), @@ -22,38 +32,13 @@ const serpResultItemSchema = z.object({ }); const serpCacheSchema = z.object({ + requestedKeyword: z.string(), items: z.array(serpResultItemSchema), + reason: z.enum(["no_organic_results"]).optional(), }); -export async function getSerpAnalysis(input: { - keyword: string; - 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 +function mapOrganicSerpItems(items: SerpLiveItem[]): SerpResultItem[] { + return items .filter((item) => item.type === "organic") .map((item) => ({ rank: item.rank_absolute ?? item.rank_group ?? 0, @@ -65,21 +50,47 @@ export async function getSerpAnalysis(input: { estimatedPaidTrafficCost: item.estimated_paid_traffic_cost ?? null, referringDomains: item.backlinks_info?.referring_domains ?? null, backlinks: item.backlinks_info?.backlinks ?? null, - isNew: item.rank_changes?.is_new ?? false, - rankChange: - item.rank_changes?.previous_rank_absolute != null && - item.rank_absolute != null - ? item.rank_changes.previous_rank_absolute - item.rank_absolute - : null, + isNew: false, + rankChange: null, })); +} - const result = { items }; +async function getSerpLiveAnalysis(input: { + keyword: string; + locationCode: number; + languageCode: string; +}): Promise { + const keyword = normalizeKeyword(input.keyword); - if (items.length > 0) { - void setCached(cacheKey, result, SERP_CACHE_TTL_SECONDS).catch((error) => { - console.error("keywords.serp.cache-write failed:", error); - }); + 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) { + 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; } + +export const getSerpAnalysis = getSerpLiveAnalysis; diff --git a/src/server/repositories/PsiAuditRepository.ts b/src/server/features/psi/repositories/PsiAuditRepository.ts similarity index 100% rename from src/server/repositories/PsiAuditRepository.ts rename to src/server/features/psi/repositories/PsiAuditRepository.ts diff --git a/src/server/features/psi/services/PsiAuditService.ts b/src/server/features/psi/services/PsiAuditService.ts new file mode 100644 index 0000000..0b51b47 --- /dev/null +++ b/src/server/features/psi/services/PsiAuditService.ts @@ -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 { + 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; diff --git a/src/server/services/PsiIssuesService.ts b/src/server/features/psi/services/PsiIssuesService.ts similarity index 95% rename from src/server/services/PsiIssuesService.ts rename to src/server/features/psi/services/PsiIssuesService.ts index 4213c01..c706ca6 100644 --- a/src/server/services/PsiIssuesService.ts +++ b/src/server/features/psi/services/PsiIssuesService.ts @@ -1,5 +1,6 @@ import { sortBy } from "remeda"; import { z } from "zod"; +import { jsonCodec } from "@/shared/json"; const PSI_CATEGORIES = [ "performance", @@ -10,7 +11,7 @@ const PSI_CATEGORIES = [ export type PsiIssueCategory = (typeof PSI_CATEGORIES)[number]; -export type PsiIssue = { +type PsiIssue = { category: PsiIssueCategory; auditKey: string; title: string; @@ -87,6 +88,8 @@ const psiPayloadSchema = z.object({ }), }); +const psiPayloadCodec = jsonCodec(psiPayloadSchema); + function normalizeScore(score: number | null | undefined): number | null { if (score == null || Number.isNaN(score)) return null; return Math.round(score * 100); @@ -149,14 +152,7 @@ function parseIssues( payloadJson: string, categoryFilter?: PsiIssueCategory, ): PsiIssue[] { - let payload: unknown; - try { - payload = JSON.parse(payloadJson); - } catch { - throw new Error("Invalid Lighthouse payload JSON"); - } - - const parsedPayload = psiPayloadSchema.safeParse(payload); + const parsedPayload = psiPayloadCodec.safeParse(payloadJson); if (!parsedPayload.success) { throw new Error("Invalid Lighthouse payload JSON"); } diff --git a/src/server/services/PsiService.ts b/src/server/features/psi/services/PsiService.ts similarity index 100% rename from src/server/services/PsiService.ts rename to src/server/features/psi/services/PsiService.ts diff --git a/src/server/features/psi/services/psi-export.ts b/src/server/features/psi/services/psi-export.ts new file mode 100644 index 0000000..e19297e --- /dev/null +++ b/src/server/features/psi/services/psi-export.ts @@ -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, + ), + }; +} diff --git a/src/server/lib/audit/discovery.ts b/src/server/lib/audit/discovery.ts index 4aec3cd..c0cfc73 100644 --- a/src/server/lib/audit/discovery.ts +++ b/src/server/lib/audit/discovery.ts @@ -82,8 +82,8 @@ function getSitemapLocations(input: unknown): string[] { const entries = Array.isArray(input) ? input : [input]; return entries .map((entry) => { - if (entry && typeof entry === "object" && "loc" in entry) { - const loc = entry.loc; + if (isRecord(entry)) { + const loc = entry["loc"]; return typeof loc === "string" ? loc : null; } return null; @@ -91,11 +91,38 @@ function getSitemapLocations(input: unknown): string[] { .filter((loc): loc is string => typeof loc === "string"); } +function isRecord(value: unknown): value is Record { + 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 { if (!error || typeof error !== "object") return false; return "name" in error && error.name === "TimeoutError"; } +function parseXmlDocument(body: string): unknown { + return xmlParser.parse(body) as unknown; +} + async function fetchSitemapDocumentWithRetry(sitemapUrl: string): Promise<{ nestedSitemaps: string[]; pageUrls: string[]; @@ -129,11 +156,12 @@ async function fetchSitemapDocumentWithRetry(sitemapUrl: string): Promise<{ return { nestedSitemaps: [], pageUrls: [], timedOut: false }; } - const parsed = xmlParser.parse(body); - const nestedSitemaps = getSitemapLocations(parsed.sitemapindex?.sitemap) + const parsed = parseXmlDocument(body); + const sections = getParsedSitemapSections(parsed); + const nestedSitemaps = getSitemapLocations(sections.sitemap) .map((loc) => normalizeUrl(loc, finalUrl)) .filter((loc): loc is string => loc !== null); - const pageUrls = getSitemapLocations(parsed.urlset?.url) + const pageUrls = getSitemapLocations(sections.url) .map((loc) => normalizeUrl(loc, finalUrl)) .filter((loc): loc is string => loc !== null); diff --git a/src/server/lib/audit/progress-kv.ts b/src/server/lib/audit/progress-kv.ts index cd9a383..5edf19a 100644 --- a/src/server/lib/audit/progress-kv.ts +++ b/src/server/lib/audit/progress-kv.ts @@ -8,10 +8,13 @@ * the audit is running. Once finalized, we explicitly delete it. */ import { env } from "cloudflare:workers"; +import { z } from "zod"; +import { jsonCodec } from "@/shared/json"; const KV_PREFIX = "audit-progress:"; const TTL_SECONDS = 30 * 60; // 30 minutes const MAX_ENTRIES = 300; +const jsonUnknownCodec = jsonCodec(z.unknown()); export interface CrawledUrlEntry { url: string; @@ -21,6 +24,29 @@ export interface CrawledUrlEntry { 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 { return `${KV_PREFIX}${auditId}`; } @@ -48,7 +74,7 @@ async function pushCrawledUrls( const k = key(auditId); 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); await env.KV.put(k, JSON.stringify(merged), { @@ -62,8 +88,7 @@ async function pushCrawledUrls( */ async function getCrawledUrls(auditId: string): Promise { const data = await env.KV.get(key(auditId), "text"); - if (!data) return []; - return JSON.parse(data); + return parseCrawledEntries(data); } /** diff --git a/src/server/lib/dataforseo.ts b/src/server/lib/dataforseo.ts index e98231d..0c3e38b 100644 --- a/src/server/lib/dataforseo.ts +++ b/src/server/lib/dataforseo.ts @@ -5,11 +5,30 @@ import { DataforseoLabsGoogleKeywordIdeasLiveRequestInfo, DataforseoLabsGoogleDomainRankOverviewLiveRequestInfo, DataforseoLabsGoogleRankedKeywordsLiveRequestInfo, - DataforseoLabsGoogleHistoricalSerpsLiveRequestInfo, } from "dataforseo-client"; import { env } from "cloudflare:workers"; -import { z } from "zod"; +import { getDomain } from "tldts"; 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) @@ -34,6 +53,29 @@ function getLabsApi() { return new DataforseoLabsApi(API_BASE, { fetch: createAuthenticatedFetch() }); } +async function postDataforseo( + path: string, + payload: unknown, +): Promise { + 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 // --------------------------------------------------------------------------- @@ -74,210 +116,6 @@ function assertOk( 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; -export type LabsKeywordDataItem = z.infer; -type DomainMetricsItem = z.infer; -export type DomainRankedKeywordItem = z.infer< - typeof domainRankedKeywordItemSchema ->; -type SerpSnapshot = z.infer; - -function parseTaskItems( - endpointName: string, - task: DataforseoTask, - itemSchema: T, -): z.infer[] { - 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 // --------------------------------------------------------------------------- @@ -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, locationCode: number, languageCode: string, -): Promise { - const api = getLabsApi(); - const req = new DataforseoLabsGoogleHistoricalSerpsLiveRequestInfo({ - keyword, - location_code: locationCode, - language_code: languageCode, - }); - - const response = await api.googleHistoricalSerpsLive([req]); +): Promise { + const responseRaw = await postDataforseo( + "/v3/serp/google/organic/live/advanced", + [ + { + keyword, + location_code: locationCode, + language_code: languageCode, + device: "desktop", + os: "windows", + depth: 100, + }, + ], + ); + const response = dataforseoResponseSchema.parse(responseRaw); const task = assertOk(response); return parseTaskItems( - "google-historical-serps-live", + "google-organic-live-advanced", task, - serpSnapshotSchema, + serpSnapshotItemSchema, ); } @@ -464,36 +308,24 @@ export function normalizeDomainInput( 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 : `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) { return host; } - return toRootDomain(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; + return getDomain(host) ?? host; } diff --git a/src/server/lib/dataforseoSchemas.ts b/src/server/lib/dataforseoSchemas.ts new file mode 100644 index 0000000..70941fa --- /dev/null +++ b/src/server/lib/dataforseoSchemas.ts @@ -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; +export type LabsKeywordDataItem = z.infer; +export type DomainMetricsItem = z.infer; +export type DomainRankedKeywordItem = z.infer< + typeof domainRankedKeywordItemSchema +>; +export type SerpLiveItem = z.infer; + +export function parseTaskItems( + endpointName: string, + task: DataforseoTask, + itemSchema: T, +): z.infer[] { + 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; +} diff --git a/src/server/lib/kv-cache.ts b/src/server/lib/kv-cache.ts index 87ca4e4..e05447c 100644 --- a/src/server/lib/kv-cache.ts +++ b/src/server/lib/kv-cache.ts @@ -1,5 +1,7 @@ import { env } from "cloudflare:workers"; import { sortBy } from "remeda"; +import { z } from "zod"; +import { jsonCodec } from "@/shared/json"; /** * Cache TTL constants in seconds. @@ -9,6 +11,8 @@ export const CACHE_TTL = { researchResult: 86400, } as const; +const jsonUnknownCodec = jsonCodec(z.unknown()); + /** * Build a deterministic cache key from an endpoint slug and input params. * Uses FNV-1a hash for compactness. @@ -30,12 +34,8 @@ export function buildCacheKey( export async function getCached(key: string): Promise { const value = await env.KV.get(key, "text"); if (value === null) return null; - - try { - return JSON.parse(value); - } catch { - return null; - } + const parsed = jsonUnknownCodec.safeParse(value); + return parsed.success ? parsed.data : null; } /** diff --git a/src/server/services/KeywordResearchService.ts b/src/server/services/KeywordResearchService.ts deleted file mode 100644 index d3536a4..0000000 --- a/src/server/services/KeywordResearchService.ts +++ /dev/null @@ -1,612 +0,0 @@ -import type { - KeywordIntent, - KeywordResearchRow, - MonthlySearch, - SavedKeywordRow, - SerpResultItem, -} from "@/types/keywords"; -import type { - CreateProjectInput, - DeleteProjectInput, - GetSavedKeywordsInput, - RemoveSavedKeywordInput, - ResearchKeywordsInput, - SaveKeywordsInput, -} from "@/types/schemas/keywords"; -import { - fetchRelatedKeywordsRaw, - fetchKeywordSuggestionsRaw, - fetchKeywordIdeasRaw, - type LabsKeywordDataItem, - fetchHistoricalSerpsRaw, -} from "@/server/lib/dataforseo"; -import { - buildCacheKey, - getCached, - setCached, - CACHE_TTL, -} from "@/server/lib/kv-cache"; -import { KeywordResearchRepository } from "@/server/repositories/KeywordResearchRepository"; -import { AppError } from "@/server/lib/errors"; -import { z } from "zod"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function normalizeKeyword(input: string): string { - return input.trim().toLowerCase(); -} - -function normalizeIntent(raw: unknown): KeywordIntent { - if (typeof raw !== "string") return "unknown"; - const value = raw.toLowerCase(); - if (value.includes("inform")) return "informational"; - if (value.includes("commerc")) return "commercial"; - if (value.includes("transact")) return "transactional"; - if (value.includes("navig")) return "navigational"; - return "unknown"; -} - -// --------------------------------------------------------------------------- -// DataForSEO fetch helpers -// --------------------------------------------------------------------------- - -type EnrichedKeyword = { - keyword: string; - searchVolume: number | null; - trend: MonthlySearch[]; - cpc: number | null; - competition: number | null; - keywordDifficulty: number | null; - intent: KeywordIntent; -}; - -type KeywordSource = "related" | "suggestions" | "ideas"; - -const monthlySearchSchema = z.object({ - year: z.number().int().positive(), - month: z.number().int().min(1).max(12), - searchVolume: z.number().int().nonnegative(), -}); - -const cachedKeywordRowSchema = z.object({ - keyword: z.string(), - searchVolume: z.number().nullable(), - trend: z.array(monthlySearchSchema), - cpc: z.number().nullable(), - competition: z.number().nullable(), - keywordDifficulty: z.number().nullable(), - intent: z.enum([ - "informational", - "commercial", - "transactional", - "navigational", - "unknown", - ]), -}); - -const cachedResultSchema = z.object({ - rows: z.array(cachedKeywordRowSchema), - source: z.enum(["related", "suggestions", "ideas"]).optional(), - usedFallback: z.boolean().optional(), -}); - -const serpResultItemSchema = z.object({ - rank: z.number().int(), - title: z.string(), - url: z.string(), - domain: z.string(), - description: z.string(), - etv: z.number().nullable(), - estimatedPaidTrafficCost: z.number().nullable(), - referringDomains: z.number().nullable(), - backlinks: z.number().nullable(), - isNew: z.boolean(), - rankChange: z.number().nullable(), -}); - -const serpCacheSchema = z.object({ - items: z.array(serpResultItemSchema), -}); - -function parseMonthlySearches(payload: string | null): MonthlySearch[] { - if (!payload) return []; - try { - const parsed = JSON.parse(payload); - const result = z.array(monthlySearchSchema).safeParse(parsed); - return result.success ? result.data : []; - } catch (error) { - console.error("keywords.saved.parse-monthly-searches failed:", error); - return []; - } -} - -async function fetchRelatedKeywordsWithData( - seedKeyword: string, - locationCode: number, - languageCode: string, - limit: number, -): Promise { - // Fetch from API - data is embedded in the response - const items = await fetchRelatedKeywordsRaw( - seedKeyword, - locationCode, - languageCode, - limit, - 3, // depth=3 for ~584 keywords - ); - - // Map embedded data directly from the response - const rows: EnrichedKeyword[] = []; - const seen = new Set(); - - for (const item of items) { - const keywordData = item.keyword_data; - const kw = keywordData.keyword; - if (!kw) continue; - - const normalizedKw = normalizeKeyword(kw); - if (seen.has(normalizedKw)) continue; - seen.add(normalizedKw); - - // Use clickstream-normalized volume if available, otherwise fall back to regular - const keywordInfo = keywordData.keyword_info_normalized_with_clickstream - ?.search_volume - ? keywordData.keyword_info_normalized_with_clickstream - : keywordData.keyword_info; - - rows.push({ - keyword: normalizedKw, - searchVolume: keywordInfo?.search_volume ?? null, - trend: (keywordInfo?.monthly_searches ?? []).map((m) => ({ - year: m.year, - month: m.month, - searchVolume: m.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; -} - -async function fetchKeywordDataRows( - items: LabsKeywordDataItem[], -): Promise { - const rows: EnrichedKeyword[] = []; - const seen = new Set(); - - for (const item of items) { - const kw = item.keyword; - if (!kw) continue; - - const normalizedKw = normalizeKeyword(kw); - if (seen.has(normalizedKw)) continue; - seen.add(normalizedKw); - - const keywordInfo = item.keyword_info_normalized_with_clickstream - ?.search_volume - ? item.keyword_info_normalized_with_clickstream - : item.keyword_info; - - rows.push({ - keyword: normalizedKw, - searchVolume: keywordInfo?.search_volume ?? null, - trend: (keywordInfo?.monthly_searches ?? []).map((m) => ({ - year: m.year, - month: m.month, - searchVolume: m.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 fetchKeywordRowsWithFallback( - seedKeyword: string, - locationCode: number, - languageCode: string, - limit: number, -): Promise<{ - rows: EnrichedKeyword[]; - source: KeywordSource; - usedFallback: boolean; -}> { - const relatedRows = await fetchRelatedKeywordsWithData( - seedKeyword, - locationCode, - languageCode, - limit, - ); - - if (relatedRows.length > 0) { - return { - rows: relatedRows, - source: "related", - usedFallback: false, - }; - } - - const suggestionRows = await fetchKeywordDataRows( - await fetchKeywordSuggestionsRaw( - seedKeyword, - locationCode, - languageCode, - limit, - ), - ); - - if (suggestionRows.length > 0) { - return { - rows: suggestionRows, - source: "suggestions", - usedFallback: true, - }; - } - - const ideaRows = await fetchKeywordDataRows( - await fetchKeywordIdeasRaw(seedKeyword, locationCode, languageCode, limit), - ); - - return { - rows: ideaRows, - source: "ideas", - usedFallback: true, - }; -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -async function research( - _userId: string, - input: ResearchKeywordsInput, -): Promise<{ - rows: KeywordResearchRow[]; - source: KeywordSource; - usedFallback: boolean; -}> { - const uniqueKeywords = [ - ...new Set(input.keywords.map(normalizeKeyword)), - ].filter((kw) => kw.length > 0); - - if (uniqueKeywords.length === 0) { - throw new AppError("VALIDATION_ERROR"); - } - - // Check KV cache - const cacheKey = buildCacheKey("kw:related", { - keywords: uniqueKeywords, - locationCode: input.locationCode, - languageCode: input.languageCode, - resultLimit: input.resultLimit, - depth: 3, // bump when depth changes to bust stale cache - }); - - const cachedRaw = await getCached(cacheKey); - const cachedResult = cachedResultSchema.safeParse(cachedRaw); - const cached = cachedResult.success ? cachedResult.data : null; - - // Only serve cached results that actually have metric data. Previous - // failed fetches may have cached rows with all-zero volume/cpc/competition. - const cacheHasMetrics = cached?.rows?.some( - (r) => (r.searchVolume ?? 0) > 0 || (r.cpc ?? 0) > 0, - ); - - if (cached && cacheHasMetrics) { - return { - rows: cached.rows, - source: cached.source ?? "related", - usedFallback: cached.usedFallback ?? false, - }; - } - - // Fetch keyword data from primary endpoint with fallback chain - const { rows, source, usedFallback } = await fetchKeywordRowsWithFallback( - uniqueKeywords[0], - input.locationCode, - input.languageCode, - input.resultLimit, - ); - - // Cache the result - await setCached( - cacheKey, - { rows, source, usedFallback }, - CACHE_TTL.researchResult, - ); - - // Persist metrics to DB (fire-and-forget, don't block the response) - void Promise.all( - rows.map((row) => - KeywordResearchRepository.upsertKeywordMetric({ - 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); - }); - - return { rows, source, usedFallback }; -} - -async function listProjects(userId: string) { - const rows = await KeywordResearchRepository.listProjects(userId); - return rows.map((row) => ({ - id: row.id, - name: row.name, - domain: row.domain, - createdAt: row.createdAt, - })); -} - -async function createProject(userId: string, input: CreateProjectInput) { - const id = await KeywordResearchRepository.createProject( - userId, - input.name, - input.domain, - ); - return { id }; -} - -async function deleteProject(userId: string, input: DeleteProjectInput) { - await KeywordResearchRepository.deleteProject(input.projectId, userId); - return { success: true }; -} - -async function saveKeywords(userId: string, input: SaveKeywordsInput) { - const project = await KeywordResearchRepository.getProject( - input.projectId, - userId, - ); - if (!project) { - throw new AppError("NOT_FOUND"); - } - - const normalizedKeywords = [ - ...new Set( - input.keywords.map(normalizeKeyword).filter((kw) => kw.length > 0), - ), - ]; - - 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[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({ - 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({ - projectId: input.projectId, - keywords: normalizedKeywords, - locationCode: input.locationCode, - languageCode: input.languageCode, - }); - - return { success: true }; -} - -async function getSavedKeywords( - userId: string, - input: GetSavedKeywordsInput, -): Promise<{ rows: SavedKeywordRow[] }> { - const project = await KeywordResearchRepository.getProject( - input.projectId, - userId, - ); - if (!project) { - throw new AppError("NOT_FOUND"); - } - - const rows = await KeywordResearchRepository.listSavedKeywordsByProject( - input.projectId, - ); - - return { - rows: rows.map(({ row, metric }) => ({ - id: row.id, - projectId: row.projectId, - keyword: row.keyword, - locationCode: row.locationCode, - languageCode: row.languageCode, - createdAt: row.createdAt, - searchVolume: metric?.searchVolume ?? null, - cpc: metric?.cpc ?? null, - competition: metric?.competition ?? null, - keywordDifficulty: metric?.keywordDifficulty ?? null, - intent: metric?.intent ?? null, - monthlySearches: parseMonthlySearches(metric?.monthlySearches ?? null), - fetchedAt: metric?.fetchedAt ?? null, - })), - }; -} - -async function removeSavedKeyword( - userId: string, - input: RemoveSavedKeywordInput, -) { - // Verify the keyword belongs to a project owned by this user - const savedKw = await KeywordResearchRepository.getSavedKeywordById( - input.savedKeywordId, - ); - if (!savedKw) { - throw new AppError("NOT_FOUND"); - } - - const project = await KeywordResearchRepository.getProject( - savedKw.projectId, - userId, - ); - if (!project) { - throw new AppError("FORBIDDEN"); - } - - await KeywordResearchRepository.removeSavedKeyword(input.savedKeywordId); - return { success: true }; -} - -async function getOrCreateDefaultProject(userId: string) { - const existing = await KeywordResearchRepository.listProjects(userId); - if (existing.length > 0) { - const first = existing[0]; - return { - id: first.id, - name: first.name, - domain: first.domain, - createdAt: first.createdAt, - }; - } - - const id = await KeywordResearchRepository.createProject( - userId, - "Default", - undefined, - ); - return { - id, - name: "Default", - domain: null, - createdAt: new Date().toISOString(), - }; -} - -async function getProject(userId: string, projectId: string) { - const project = await KeywordResearchRepository.getProject(projectId, userId); - if (!project) return null; - return { - id: project.id, - name: project.name, - domain: project.domain, - createdAt: project.createdAt, - }; -} - -// --------------------------------------------------------------------------- -// SERP Analysis -// --------------------------------------------------------------------------- - -const SERP_CACHE_TTL_SECONDS = 12 * 60 * 60; // 12 hours - -async function getSerpAnalysis(input: { - keyword: string; - 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 cachedResult = serpCacheSchema.safeParse(cachedRaw); - if (cachedResult.success && cachedResult.data.items.length > 0) { - return cachedResult.data; - } - - const snapshots = await fetchHistoricalSerpsRaw( - keyword, - input.locationCode, - input.languageCode, - ); - - // Take the most recent snapshot (first item) - const snapshot = snapshots[0]; - const rawItems = snapshot?.items ?? []; - - // Filter to organic results only and map to our shape - const items: SerpResultItem[] = rawItems - .filter((item) => item.type === "organic") - .map((item) => ({ - rank: item.rank_absolute ?? item.rank_group ?? 0, - title: item.title ?? "", - url: item.url ?? "", - domain: item.domain ?? "", - description: item.description ?? "", - etv: item.etv ?? null, - estimatedPaidTrafficCost: item.estimated_paid_traffic_cost ?? null, - referringDomains: item.backlinks_info?.referring_domains ?? null, - backlinks: item.backlinks_info?.backlinks ?? null, - isNew: item.rank_changes?.is_new ?? false, - rankChange: - item.rank_changes?.previous_rank_absolute != null && - item.rank_absolute != null - ? item.rank_changes.previous_rank_absolute - item.rank_absolute - : null, - })); - - const result = { items }; - - if (items.length > 0) { - void setCached(cacheKey, result, SERP_CACHE_TTL_SECONDS).catch((err) => { - console.error("Failed to cache SERP analysis in KV:", err); - }); - } - - return result; -} - -export const KeywordResearchService = { - research, - getSerpAnalysis, - listProjects, - createProject, - deleteProject, - saveKeywords, - getSavedKeywords, - removeSavedKeyword, - getOrCreateDefaultProject, - getProject, -} as const; diff --git a/src/server/services/keyword-research/research-data.ts b/src/server/services/keyword-research/research-data.ts deleted file mode 100644 index 3ffd3e3..0000000 --- a/src/server/services/keyword-research/research-data.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { fetchRelatedKeywordsRaw } from "@/server/lib/dataforseo"; -import type { ResearchKeywordsInput } from "@/types/schemas/keywords"; -import { - normalizeIntent, - normalizeKeyword, - type EnrichedKeyword, -} from "./helpers"; - -export async function fetchResearchRows( - input: ResearchKeywordsInput, - uniqueKeywords: string[], -): Promise { - const seedKeyword = uniqueKeywords[0]; - if (!seedKeyword) { - return []; - } - - const items = await fetchRelatedKeywordsRaw( - seedKeyword, - input.locationCode, - input.languageCode, - input.resultLimit, - 3, - ); - - const rows: EnrichedKeyword[] = []; - const seen = new Set(); - - for (const item of items) { - const keywordData = item.keyword_data; - const keyword = keywordData.keyword; - if (!keyword) continue; - - const normalizedKeyword = normalizeKeyword(keyword); - if (seen.has(normalizedKeyword)) continue; - seen.add(normalizedKeyword); - - const keywordInfo = keywordData.keyword_info_normalized_with_clickstream - ?.search_volume - ? keywordData.keyword_info_normalized_with_clickstream - : keywordData.keyword_info; - - rows.push({ - keyword: normalizedKeyword, - 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; -} diff --git a/src/server/workflows/SiteAuditWorkflow.ts b/src/server/workflows/SiteAuditWorkflow.ts index d4cf103..5490347 100644 --- a/src/server/workflows/SiteAuditWorkflow.ts +++ b/src/server/workflows/SiteAuditWorkflow.ts @@ -1,37 +1,17 @@ /** * Cloudflare Workflow for site audit crawling. * - * Each step is durable — if a step fails, it retries without redoing + * Each step is durable - if a step fails, it retries without redoing * completed steps. - * - * Flow: - * Step 1: Discovery (robots.txt + sitemaps) - * Step 2-N: Crawl page batches (parallel fetch+analyze per step) - * Step N+1: Select PSI sample - * Step N+2-M: PSI batches (parallel URLs, mobile+desktop per URL) - * Step M+1: Finalize (batch write to D1) */ import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep, } from "cloudflare:workers"; -import { - discoverUrls, - fetchRobotsTxt, - type RobotsResult, -} from "@/server/lib/audit/discovery"; -import { analyzeHtml } from "@/server/lib/audit/page-analyzer"; -import { fetchPsiResult, selectPsiSample } from "@/server/lib/audit/psi"; -import { - normalizeUrl, - isSameOrigin, - getOrigin, -} from "@/server/lib/audit/url-utils"; -import { putTextToR2 } from "@/server/lib/r2"; -import { AuditRepository } from "@/server/repositories/AuditRepository"; -import { AuditProgressKV } from "@/server/lib/audit/progress-kv"; -import type { AuditConfig, PsiResult } from "@/server/lib/audit/types"; +import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository"; +import type { AuditConfig } from "@/server/lib/audit/types"; +import { runAuditPhases } from "@/server/workflows/siteAuditWorkflowPhases"; interface AuditParams { auditId: string; @@ -40,501 +20,37 @@ interface AuditParams { config: AuditConfig; } -const CRAWL_CONCURRENCY = 25; -const PSI_URL_CONCURRENCY = 6; - -/** Serializable page data passed between workflow steps. */ -interface StepPageResult { - id: string; - url: string; - statusCode: number; - redirectUrl: string | null; - // Metadata - title: string; - metaDescription: string; - canonicalUrl: string | null; - robotsMeta: string | null; - // Open Graph - ogTitle: string | null; - ogDescription: string | null; - ogImage: string | null; - // Headings - h1Count: number; - h2Count: number; - h3Count: number; - h4Count: number; - h5Count: number; - h6Count: number; - headingOrder: number[]; - // Content - wordCount: number; - // Images - imagesTotal: number; - imagesMissingAlt: number; - images: Array<{ src: string | null; alt: string | null }>; - // Links - internalLinks: string[]; - externalLinks: string[]; - // Structured data - hasStructuredData: boolean; - // Hreflang - hreflangTags: string[]; - // Indexability - isIndexable: boolean; - // Performance - responseTimeMs: number; -} - -type PsiUploadContext = { - projectId: string; - auditId: string; -}; - -function shouldQueueCrawlLink( - link: string, - origin: string, - robots: RobotsResult, - visited: Set, - queued: Set, -): boolean { - return ( - isSameOrigin(link, origin) && - robots.isAllowed(link) && - !visited.has(link) && - !queued.has(link) - ); -} - -function countPsiBatchResults(results: PsiResult[]): { - completed: number; - failed: number; -} { - let completed = 0; - let failed = 0; - for (const result of results) { - if (result.errorMessage) { - failed += 1; - continue; - } - completed += 1; - } - return { completed, failed }; -} - export class SiteAuditWorkflow extends WorkflowEntrypoint { async run(event: WorkflowEvent, step: WorkflowStep) { const { auditId, projectId, startUrl, config } = event.payload; - const origin = getOrigin(startUrl); - const maxPages = config.maxPages; + + const audit = await AuditRepository.getAuditForWorkflow( + auditId, + event.instanceId, + ); + + if (!audit) { + throw new Error("Audit workflow context mismatch"); + } + + if (audit.projectId !== projectId) { + throw new Error("Audit workflow project mismatch"); + } try { - // ─── Step 1: Discovery ─────────────────────────────────────── - const discovery = await step.do("discover-urls", async () => { - const result = await discoverUrls(origin, maxPages); - // Update audit with discovery info - await AuditRepository.updateAuditProgress(auditId, { - pagesTotal: Math.min(result.urls.length + 1, maxPages), - currentPhase: "crawling", - }); - return { - sitemapUrls: result.urls, - // We can't serialize the robots function, so we store the raw result - // and re-fetch robots in crawl steps if needed - }; - }); - - const robots = await fetchRobotsTxt(origin); - // ─── Step 2-N: Crawl pages ────────────────────────────────── - const visited = new Set(); - const queue: string[] = []; - const queued = new Set(); - const allPages: StepPageResult[] = []; - - // Seed the queue - const normalizedStart = normalizeUrl(startUrl) ?? startUrl; - if ( - robots.isAllowed(normalizedStart) && - isSameOrigin(normalizedStart, origin) - ) { - queue.push(normalizedStart); - queued.add(normalizedStart); - } - - // Add sitemap URLs to queue - for (const sitemapUrl of discovery.sitemapUrls) { - const normalized = normalizeUrl(sitemapUrl); - if ( - normalized && - isSameOrigin(normalized, origin) && - robots.isAllowed(normalized) - ) { - if (!visited.has(normalized) && !queued.has(normalized)) { - queue.push(normalized); - queued.add(normalized); - } - } - } - - let crawlBatchIndex = 0; - - while (queue.length > 0 && allPages.length < maxPages) { - const remaining = maxPages - allPages.length; - const batchSize = Math.min(CRAWL_CONCURRENCY, remaining); - const urlsToCrawl: string[] = []; - - while (queue.length > 0 && urlsToCrawl.length < batchSize) { - const url = queue.shift()!; - queued.delete(url); - - if (visited.has(url)) continue; - if (!robots.isAllowed(url)) continue; - visited.add(url); - urlsToCrawl.push(url); - } - - if (urlsToCrawl.length === 0) continue; - - crawlBatchIndex++; - - const crawledBatch = await step.do( - `crawl-batch-${crawlBatchIndex}`, - async () => { - const settled = await Promise.allSettled( - urlsToCrawl.map((url) => crawlPage(url, origin)), - ); - - return settled.flatMap((result) => { - if (result.status === "fulfilled" && result.value) { - return [result.value]; - } - return []; - }); - }, - ); - - allPages.push(...crawledBatch); - - // Add discovered internal links to queue - for (const pageResult of crawledBatch) { - for (const link of pageResult.internalLinks.filter((candidate) => - shouldQueueCrawlLink(candidate, origin, robots, visited, queued), - )) { - queue.push(link); - queued.add(link); - } - } - - // Push crawled URLs to KV for live progress (batched) - await step.do(`kv-progress-batch-${crawlBatchIndex}`, async () => { - await AuditProgressKV.pushCrawledUrls( - auditId, - crawledBatch.map((pageResult) => ({ - url: pageResult.url, - statusCode: pageResult.statusCode, - title: pageResult.title, - crawledAt: Date.now(), - })), - ); - }); - - // Update D1 progress each batch - await step.do(`progress-batch-${crawlBatchIndex}`, async () => { - await AuditRepository.updateAuditProgress(auditId, { - pagesCrawled: allPages.length, - pagesTotal: Math.min(visited.size + queue.length, maxPages), - }); - }); - } - - // ─── PSI Phase ────────────────────────────────────────────── - const psiResults: PsiResult[] = []; - - if (config.psiStrategy !== "none" && config.psiApiKey) { - const psiSample = await step.do("select-psi-sample", async () => { - const pagesForSample = allPages.map((p) => ({ - id: p.id, - url: p.url, - statusCode: p.statusCode, - })); - const sample = selectPsiSample( - pagesForSample, - startUrl, - config.psiStrategy, - ); - - await AuditRepository.updateAuditProgress(auditId, { - currentPhase: "psi", - psiTotal: sample.length * 2, - psiCompleted: 0, - psiFailed: 0, - }); - - return sample; - }); - - let psiCompleted = 0; - let psiFailed = 0; - - const updatePsiProgress = async (stepName: string) => { - await step.do(stepName, async () => { - await AuditRepository.updateAuditProgress(auditId, { - psiCompleted, - psiFailed, - }); - }); - }; - - const psiWork = psiSample.flatMap((psiUrl) => { - const page = allPages.find((p) => p.url === psiUrl); - if (!page) return []; - return [{ url: psiUrl, pageId: page.id }]; - }); - - let psiBatchIndex = 0; - for (let i = 0; i < psiWork.length; i += PSI_URL_CONCURRENCY) { - const batch = psiWork.slice(i, i + PSI_URL_CONCURRENCY); - psiBatchIndex += 1; - - const psiBatchResults = await step.do( - `psi-batch-${psiBatchIndex}`, - async () => { - const perUrlResults = await Promise.all( - batch.map(async ({ url, pageId }) => { - const [mobileResult, desktopResult] = await Promise.all([ - fetchPsiAndUploadToR2( - url, - pageId, - "mobile", - config.psiApiKey!, - { projectId, auditId }, - ), - fetchPsiAndUploadToR2( - url, - pageId, - "desktop", - config.psiApiKey!, - { projectId, auditId }, - ), - ]); - - return [mobileResult, desktopResult]; - }), - ); - - return perUrlResults.flat(); - }, - ); - - psiResults.push(...psiBatchResults); - - const counts = countPsiBatchResults(psiBatchResults); - psiFailed += counts.failed; - psiCompleted += counts.completed; - - await updatePsiProgress(`psi-progress-batch-${psiBatchIndex}`); - } - } - - // ─── Finalize ──────────────────────────────────────────────── - await step.do("finalize", async () => { - await AuditRepository.updateAuditProgress(auditId, { - currentPhase: "finalizing", - }); - - // Batch write all results to D1 - await AuditRepository.batchWriteResults(auditId, allPages, psiResults); - - // Mark audit as completed - await AuditRepository.completeAudit(auditId, { - pagesCrawled: allPages.length, - pagesTotal: allPages.length, - }); - - // Clean up KV progress data (no longer needed once results are in D1) - await AuditProgressKV.clear(auditId); + await runAuditPhases(step, { + auditId, + workflowInstanceId: event.instanceId, + projectId, + startUrl, + config, }); } catch (error) { console.error(`Audit ${auditId} failed:`, error); await step.do("mark-failed", async () => { - await AuditRepository.failAudit(auditId); + await AuditRepository.failAudit(auditId, event.instanceId); }); throw error; } } } - -async function fetchPsiAndUploadToR2( - url: string, - pageId: string, - strategy: "mobile" | "desktop", - apiKey: string, - context: PsiUploadContext, -): Promise { - const result = await fetchPsiResult(url, pageId, strategy, apiKey); - - if (result.rawPayloadJson) { - const key = `site-audit/${context.projectId}/${context.auditId}/${pageId}-${strategy}.json`; - const uploaded = await putTextToR2(key, result.rawPayloadJson); - result.r2Key = uploaded.key; - result.payloadSizeBytes = uploaded.sizeBytes; - result.rawPayloadJson = null; - } - - return result; -} - -/** - * Fetch and analyze a single page. Returns null if the page can't be fetched. - */ -async function crawlPage( - url: string, - crawlOrigin: string, -): Promise { - const startTime = Date.now(); - - try { - const response = await fetch(url, { - headers: { - "User-Agent": "OpenSEO-Audit/1.0", - Accept: "text/html,application/xhtml+xml", - }, - redirect: "follow", - signal: AbortSignal.timeout(15_000), - }); - - const responseTimeMs = Date.now() - startTime; - const statusCode = response.status; - const finalUrl = normalizeUrl(response.url) ?? response.url; - - if (!isSameOrigin(finalUrl, crawlOrigin)) { - return null; - } - - // Detect redirects - const redirectUrl = - response.redirected && response.url !== url ? response.url : null; - - // Only parse HTML responses - const contentType = response.headers.get("content-type") ?? ""; - if (!contentType.includes("text/html")) { - return { - id: crypto.randomUUID(), - url: finalUrl, - statusCode, - redirectUrl, - title: "", - metaDescription: "", - canonicalUrl: null, - robotsMeta: null, - ogTitle: null, - ogDescription: null, - ogImage: null, - h1Count: 0, - h2Count: 0, - h3Count: 0, - h4Count: 0, - h5Count: 0, - h6Count: 0, - headingOrder: [], - wordCount: 0, - imagesTotal: 0, - imagesMissingAlt: 0, - images: [], - internalLinks: [], - externalLinks: [], - hasStructuredData: false, - hreflangTags: [], - isIndexable: false, - responseTimeMs, - }; - } - - const html = await response.text(); - const analysis = analyzeHtml( - html, - finalUrl, - statusCode, - responseTimeMs, - redirectUrl, - ); - - // Determine indexability - const isIndexable = !( - analysis.robotsMeta?.toLowerCase().includes("noindex") ?? false - ); - - // Count headings by level - const h2Count = analysis.headingOrder.filter((h) => h === 2).length; - const h3Count = analysis.headingOrder.filter((h) => h === 3).length; - const h4Count = analysis.headingOrder.filter((h) => h === 4).length; - const h5Count = analysis.headingOrder.filter((h) => h === 5).length; - const h6Count = analysis.headingOrder.filter((h) => h === 6).length; - - return { - id: crypto.randomUUID(), - url: finalUrl, - statusCode, - redirectUrl, - title: analysis.title, - metaDescription: analysis.metaDescription, - canonicalUrl: analysis.canonical, - robotsMeta: analysis.robotsMeta, - ogTitle: analysis.ogTitle, - ogDescription: analysis.ogDescription, - ogImage: analysis.ogImage, - h1Count: analysis.h1s.length, - h2Count, - h3Count, - h4Count, - h5Count, - h6Count, - headingOrder: analysis.headingOrder, - wordCount: analysis.wordCount, - imagesTotal: analysis.images.length, - imagesMissingAlt: analysis.images.filter( - (img) => !img.alt || img.alt === "", - ).length, - images: analysis.images, - internalLinks: analysis.internalLinks, - externalLinks: analysis.externalLinks, - hasStructuredData: analysis.hasStructuredData, - hreflangTags: analysis.hreflangTags, - isIndexable, - responseTimeMs, - }; - } catch (error) { - const responseTimeMs = Date.now() - startTime; - console.warn(`Failed to crawl ${url}:`, error); - - return { - id: crypto.randomUUID(), - url, - statusCode: 0, - redirectUrl: null, - title: "", - metaDescription: "", - canonicalUrl: null, - robotsMeta: null, - ogTitle: null, - ogDescription: null, - ogImage: null, - h1Count: 0, - h2Count: 0, - h3Count: 0, - h4Count: 0, - h5Count: 0, - h6Count: 0, - headingOrder: [], - wordCount: 0, - imagesTotal: 0, - imagesMissingAlt: 0, - images: [], - internalLinks: [], - externalLinks: [], - hasStructuredData: false, - hreflangTags: [], - isIndexable: false, - responseTimeMs, - }; - } -} diff --git a/src/server/workflows/site-audit-workflow-helpers.ts b/src/server/workflows/site-audit-workflow-helpers.ts new file mode 100644 index 0000000..27f26bd --- /dev/null +++ b/src/server/workflows/site-audit-workflow-helpers.ts @@ -0,0 +1,183 @@ +import { analyzeHtml } from "@/server/lib/audit/page-analyzer"; +import { fetchPsiResult } from "@/server/lib/audit/psi"; +import { isSameOrigin, normalizeUrl } from "@/server/lib/audit/url-utils"; +import type { PsiResult } from "@/server/lib/audit/types"; +import { putTextToR2 } from "@/server/lib/r2"; + +export interface StepPageResult { + id: string; + url: string; + statusCode: number; + redirectUrl: string | null; + title: string; + metaDescription: string; + canonicalUrl: string | null; + robotsMeta: string | null; + ogTitle: string | null; + ogDescription: string | null; + ogImage: string | null; + h1Count: number; + h2Count: number; + h3Count: number; + h4Count: number; + h5Count: number; + h6Count: number; + headingOrder: number[]; + wordCount: number; + imagesTotal: number; + imagesMissingAlt: number; + images: Array<{ src: string | null; alt: string | null }>; + internalLinks: string[]; + externalLinks: string[]; + hasStructuredData: boolean; + hreflangTags: string[]; + isIndexable: boolean; + responseTimeMs: number; +} + +type PsiUploadContext = { + projectId: string; + auditId: string; +}; + +export async function fetchPsiAndUploadToR2( + url: string, + pageId: string, + strategy: "mobile" | "desktop", + apiKey: string, + context: PsiUploadContext, +): Promise { + const result = await fetchPsiResult(url, pageId, strategy, apiKey); + + if (result.rawPayloadJson) { + const key = `site-audit/${context.projectId}/${context.auditId}/${pageId}-${strategy}.json`; + const uploaded = await putTextToR2(key, result.rawPayloadJson); + result.r2Key = uploaded.key; + result.payloadSizeBytes = uploaded.sizeBytes; + result.rawPayloadJson = null; + } + + return result; +} + +export async function crawlPage( + url: string, + crawlOrigin: string, +): Promise { + const startTime = Date.now(); + + try { + const response = await fetch(url, { + headers: { + "User-Agent": "OpenSEO-Audit/1.0", + Accept: "text/html,application/xhtml+xml", + }, + redirect: "follow", + signal: AbortSignal.timeout(15_000), + }); + + const responseTimeMs = Date.now() - startTime; + const statusCode = response.status; + const finalUrl = normalizeUrl(response.url) ?? response.url; + if (!isSameOrigin(finalUrl, crawlOrigin)) return null; + + const redirectUrl = + response.redirected && response.url !== url ? response.url : null; + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.includes("text/html")) { + return emptyPageResult(finalUrl, statusCode, redirectUrl, responseTimeMs); + } + + const html = await response.text(); + const analysis = analyzeHtml( + html, + finalUrl, + statusCode, + responseTimeMs, + redirectUrl, + ); + const isIndexable = !( + analysis.robotsMeta?.toLowerCase().includes("noindex") ?? false + ); + const h2Count = analysis.headingOrder.filter((h) => h === 2).length; + const h3Count = analysis.headingOrder.filter((h) => h === 3).length; + const h4Count = analysis.headingOrder.filter((h) => h === 4).length; + const h5Count = analysis.headingOrder.filter((h) => h === 5).length; + const h6Count = analysis.headingOrder.filter((h) => h === 6).length; + + return { + id: crypto.randomUUID(), + url: finalUrl, + statusCode, + redirectUrl, + title: analysis.title, + metaDescription: analysis.metaDescription, + canonicalUrl: analysis.canonical, + robotsMeta: analysis.robotsMeta, + ogTitle: analysis.ogTitle, + ogDescription: analysis.ogDescription, + ogImage: analysis.ogImage, + h1Count: analysis.h1s.length, + h2Count, + h3Count, + h4Count, + h5Count, + h6Count, + headingOrder: analysis.headingOrder, + wordCount: analysis.wordCount, + imagesTotal: analysis.images.length, + imagesMissingAlt: analysis.images.filter( + (img) => !img.alt || img.alt === "", + ).length, + images: analysis.images, + internalLinks: analysis.internalLinks, + externalLinks: analysis.externalLinks, + hasStructuredData: analysis.hasStructuredData, + hreflangTags: analysis.hreflangTags, + isIndexable, + responseTimeMs, + }; + } catch (error) { + const responseTimeMs = Date.now() - startTime; + console.warn(`Failed to crawl ${url}:`, error); + return emptyPageResult(url, 0, null, responseTimeMs); + } +} + +function emptyPageResult( + url: string, + statusCode: number, + redirectUrl: string | null, + responseTimeMs: number, +): StepPageResult { + return { + id: crypto.randomUUID(), + url, + statusCode, + redirectUrl, + title: "", + metaDescription: "", + canonicalUrl: null, + robotsMeta: null, + ogTitle: null, + ogDescription: null, + ogImage: null, + h1Count: 0, + h2Count: 0, + h3Count: 0, + h4Count: 0, + h5Count: 0, + h6Count: 0, + headingOrder: [], + wordCount: 0, + imagesTotal: 0, + imagesMissingAlt: 0, + images: [], + internalLinks: [], + externalLinks: [], + hasStructuredData: false, + hreflangTags: [], + isIndexable: false, + responseTimeMs, + }; +} diff --git a/src/server/workflows/siteAuditWorkflowCrawl.ts b/src/server/workflows/siteAuditWorkflowCrawl.ts new file mode 100644 index 0000000..b9907f9 --- /dev/null +++ b/src/server/workflows/siteAuditWorkflowCrawl.ts @@ -0,0 +1,247 @@ +import type { WorkflowStep } from "cloudflare:workers"; +import type { RobotsResult } from "@/server/lib/audit/discovery"; +import { isSameOrigin, normalizeUrl } from "@/server/lib/audit/url-utils"; +import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository"; +import { AuditProgressKV } from "@/server/lib/audit/progress-kv"; +import { + crawlPage, + type StepPageResult, +} from "@/server/workflows/site-audit-workflow-helpers"; + +const CRAWL_CONCURRENCY = 25; + +function shouldQueueCrawlLink( + link: string, + origin: string, + robots: RobotsResult, + visited: Set, + queued: Set, +): boolean { + return ( + isSameOrigin(link, origin) && + robots.isAllowed(link) && + !visited.has(link) && + !queued.has(link) + ); +} + +type CrawlPhaseParams = { + auditId: string; + workflowInstanceId: string; + origin: string; + startUrl: string; + maxPages: number; + robots: RobotsResult; + sitemapUrls: string[]; +}; + +export async function runCrawlPhase( + step: WorkflowStep, + params: CrawlPhaseParams, +): Promise { + const { + auditId, + workflowInstanceId, + origin, + startUrl, + maxPages, + robots, + sitemapUrls, + } = params; + const visited = new Set(); + const queue: string[] = []; + const queued = new Set(); + const allPages: StepPageResult[] = []; + + seedCrawlQueue({ + startUrl, + origin, + robots, + sitemapUrls, + visited, + queued, + queue, + }); + + let crawlBatchIndex = 0; + while (queue.length > 0 && allPages.length < maxPages) { + const urlsToCrawl = selectNextCrawlBatch( + queue, + queued, + visited, + robots, + maxPages - allPages.length, + ); + if (urlsToCrawl.length === 0) continue; + + crawlBatchIndex += 1; + const crawledBatch = await runCrawlBatch( + step, + crawlBatchIndex, + urlsToCrawl, + origin, + ); + allPages.push(...crawledBatch); + + enqueueDiscoveredLinks({ + crawledBatch, + queue, + queued, + visited, + origin, + robots, + }); + await persistCrawlProgress({ + step, + crawlBatchIndex, + auditId, + workflowInstanceId, + crawledBatch, + pagesCrawled: allPages.length, + visitedCount: visited.size, + queueLength: queue.length, + maxPages, + }); + } + + return allPages; +} + +function seedCrawlQueue({ + startUrl, + origin, + robots, + sitemapUrls, + visited, + queued, + queue, +}: { + startUrl: string; + origin: string; + robots: RobotsResult; + sitemapUrls: string[]; + visited: Set; + queued: Set; + queue: string[]; +}) { + const normalizedStart = normalizeUrl(startUrl) ?? startUrl; + if ( + robots.isAllowed(normalizedStart) && + isSameOrigin(normalizedStart, origin) + ) { + queue.push(normalizedStart); + queued.add(normalizedStart); + } + + for (const sitemapUrl of sitemapUrls) { + const normalized = normalizeUrl(sitemapUrl); + if (!normalized) continue; + if (!shouldQueueCrawlLink(normalized, origin, robots, visited, queued)) { + continue; + } + queue.push(normalized); + queued.add(normalized); + } +} + +function selectNextCrawlBatch( + queue: string[], + queued: Set, + visited: Set, + robots: RobotsResult, + remaining: number, +) { + const batchSize = Math.min(CRAWL_CONCURRENCY, remaining); + const urlsToCrawl: string[] = []; + + while (queue.length > 0 && urlsToCrawl.length < batchSize) { + const url = queue.shift()!; + queued.delete(url); + if (visited.has(url)) continue; + if (!robots.isAllowed(url)) continue; + visited.add(url); + urlsToCrawl.push(url); + } + + return urlsToCrawl; +} + +async function runCrawlBatch( + step: WorkflowStep, + crawlBatchIndex: number, + urlsToCrawl: string[], + origin: string, +): Promise { + return step.do(`crawl-batch-${crawlBatchIndex}`, async () => { + const settled = await Promise.allSettled( + urlsToCrawl.map((url) => crawlPage(url, origin)), + ); + return settled.flatMap((result) => { + if (result.status === "fulfilled" && result.value) { + return [result.value]; + } + return []; + }); + }); +} + +function enqueueDiscoveredLinks(params: { + crawledBatch: StepPageResult[]; + queue: string[]; + queued: Set; + visited: Set; + origin: string; + robots: RobotsResult; +}) { + const { crawledBatch, queue, queued, visited, origin, robots } = params; + for (const pageResult of crawledBatch) { + for (const link of pageResult.internalLinks.filter((candidate) => + shouldQueueCrawlLink(candidate, origin, robots, visited, queued), + )) { + queue.push(link); + queued.add(link); + } + } +} + +async function persistCrawlProgress(params: { + step: WorkflowStep; + crawlBatchIndex: number; + auditId: string; + workflowInstanceId: string; + crawledBatch: StepPageResult[]; + pagesCrawled: number; + visitedCount: number; + queueLength: number; + maxPages: number; +}) { + const { + step, + crawlBatchIndex, + auditId, + workflowInstanceId, + crawledBatch, + pagesCrawled, + visitedCount, + queueLength, + maxPages, + } = params; + await step.do(`kv-progress-batch-${crawlBatchIndex}`, async () => { + await AuditProgressKV.pushCrawledUrls( + auditId, + crawledBatch.map((pageResult) => ({ + url: pageResult.url, + statusCode: pageResult.statusCode, + title: pageResult.title, + crawledAt: Date.now(), + })), + ); + }); + + await step.do(`progress-batch-${crawlBatchIndex}`, async () => { + await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, { + pagesCrawled, + pagesTotal: Math.min(visitedCount + queueLength, maxPages), + }); + }); +} diff --git a/src/server/workflows/siteAuditWorkflowPhases.ts b/src/server/workflows/siteAuditWorkflowPhases.ts new file mode 100644 index 0000000..f1dd2e7 --- /dev/null +++ b/src/server/workflows/siteAuditWorkflowPhases.ts @@ -0,0 +1,232 @@ +import type { WorkflowStep } from "cloudflare:workers"; +import { discoverUrls, fetchRobotsTxt } from "@/server/lib/audit/discovery"; +import { selectPsiSample } from "@/server/lib/audit/psi"; +import { getOrigin } from "@/server/lib/audit/url-utils"; +import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository"; +import { AuditProgressKV } from "@/server/lib/audit/progress-kv"; +import type { AuditConfig, PsiResult } from "@/server/lib/audit/types"; +import { + fetchPsiAndUploadToR2, + type StepPageResult, +} from "@/server/workflows/site-audit-workflow-helpers"; +import { runCrawlPhase } from "@/server/workflows/siteAuditWorkflowCrawl"; + +const PSI_URL_CONCURRENCY = 6; + +function countPsiBatchResults(results: PsiResult[]): { + completed: number; + failed: number; +} { + let completed = 0; + let failed = 0; + for (const result of results) { + if (result.errorMessage) { + failed += 1; + continue; + } + completed += 1; + } + return { completed, failed }; +} + +type AuditPhasesParams = { + auditId: string; + workflowInstanceId: string; + projectId: string; + startUrl: string; + config: AuditConfig; +}; + +export async function runAuditPhases( + step: WorkflowStep, + params: AuditPhasesParams, +) { + const { auditId, workflowInstanceId, projectId, startUrl, config } = params; + const origin = getOrigin(startUrl); + const maxPages = config.maxPages; + + const discovery = await runDiscoveryPhase( + step, + auditId, + workflowInstanceId, + origin, + maxPages, + ); + const robots = await fetchRobotsTxt(origin); + const allPages = await runCrawlPhase(step, { + auditId, + workflowInstanceId, + origin, + startUrl, + maxPages, + robots, + sitemapUrls: discovery.sitemapUrls, + }); + const psiResults = await runPsiPhase(step, { + auditId, + workflowInstanceId, + projectId, + startUrl, + config, + allPages, + }); + await finalizeAudit(step, auditId, workflowInstanceId, allPages, psiResults); +} + +async function runDiscoveryPhase( + step: WorkflowStep, + auditId: string, + workflowInstanceId: string, + origin: string, + maxPages: number, +) { + return step.do("discover-urls", async () => { + const result = await discoverUrls(origin, maxPages); + await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, { + pagesTotal: Math.min(result.urls.length + 1, maxPages), + currentPhase: "crawling", + }); + return { sitemapUrls: result.urls }; + }); +} + +type PsiPhaseParams = { + auditId: string; + workflowInstanceId: string; + projectId: string; + startUrl: string; + config: AuditConfig; + allPages: StepPageResult[]; +}; + +async function runPsiPhase( + step: WorkflowStep, + params: PsiPhaseParams, +): Promise { + const { auditId, workflowInstanceId, projectId, startUrl, config, allPages } = + params; + if (config.psiStrategy === "none" || !config.psiApiKey) return []; + + const psiSample = await selectPsiUrls({ + step, + auditId, + workflowInstanceId, + allPages, + startUrl, + strategy: config.psiStrategy, + }); + const psiWork = psiSample.flatMap((psiUrl) => { + const page = allPages.find((candidate) => candidate.url === psiUrl); + if (!page) return []; + return [{ url: psiUrl, pageId: page.id }]; + }); + + const psiResults: PsiResult[] = []; + let psiCompleted = 0; + let psiFailed = 0; + let psiBatchIndex = 0; + + for (let i = 0; i < psiWork.length; i += PSI_URL_CONCURRENCY) { + const batch = psiWork.slice(i, i + PSI_URL_CONCURRENCY); + psiBatchIndex += 1; + const psiBatchResults = await runPsiBatch({ + step, + psiBatchIndex, + batch, + psiApiKey: config.psiApiKey, + projectId, + auditId, + }); + + psiResults.push(...psiBatchResults); + const counts = countPsiBatchResults(psiBatchResults); + psiFailed += counts.failed; + psiCompleted += counts.completed; + await step.do(`psi-progress-batch-${psiBatchIndex}`, async () => { + await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, { + psiCompleted, + psiFailed, + }); + }); + } + + return psiResults; +} + +async function selectPsiUrls(params: { + step: WorkflowStep; + auditId: string; + workflowInstanceId: string; + allPages: StepPageResult[]; + startUrl: string; + strategy: AuditConfig["psiStrategy"]; +}) { + const { step, auditId, workflowInstanceId, allPages, startUrl, strategy } = + params; + return step.do("select-psi-sample", async () => { + const pagesForSample = allPages.map((page) => ({ + id: page.id, + url: page.url, + statusCode: page.statusCode, + })); + const sample = selectPsiSample(pagesForSample, startUrl, strategy); + + await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, { + currentPhase: "psi", + psiTotal: sample.length * 2, + psiCompleted: 0, + psiFailed: 0, + }); + return sample; + }); +} + +async function runPsiBatch(params: { + step: WorkflowStep; + psiBatchIndex: number; + batch: Array<{ url: string; pageId: string }>; + psiApiKey: string; + projectId: string; + auditId: string; +}) { + const { step, psiBatchIndex, batch, psiApiKey, projectId, auditId } = params; + return step.do(`psi-batch-${psiBatchIndex}`, async () => { + const perUrlResults = await Promise.all( + batch.map(async ({ url, pageId }) => { + const [mobileResult, desktopResult] = await Promise.all([ + fetchPsiAndUploadToR2(url, pageId, "mobile", psiApiKey, { + projectId, + auditId, + }), + fetchPsiAndUploadToR2(url, pageId, "desktop", psiApiKey, { + projectId, + auditId, + }), + ]); + return [mobileResult, desktopResult]; + }), + ); + + return perUrlResults.flat(); + }); +} + +async function finalizeAudit( + step: WorkflowStep, + auditId: string, + workflowInstanceId: string, + allPages: StepPageResult[], + psiResults: PsiResult[], +) { + await step.do("finalize", async () => { + await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, { + currentPhase: "finalizing", + }); + await AuditRepository.batchWriteResults(auditId, allPages, psiResults); + await AuditRepository.completeAudit(auditId, workflowInstanceId, { + pagesCrawled: allPages.length, + pagesTotal: allPages.length, + }); + await AuditProgressKV.clear(auditId); + }); +} diff --git a/src/serverFunctions/audit.ts b/src/serverFunctions/audit.ts index d6d2706..716455f 100644 --- a/src/serverFunctions/audit.ts +++ b/src/serverFunctions/audit.ts @@ -8,7 +8,7 @@ import { deleteAuditSchema, getCrawlProgressSchema, } from "@/types/schemas/audit"; -import { AuditService } from "@/server/services/AuditService"; +import { AuditService } from "@/server/features/audit/services/AuditService"; export const startAudit = createServerFn({ method: "POST" }) .middleware(authenticatedServerFunctionMiddleware) diff --git a/src/serverFunctions/domain.ts b/src/serverFunctions/domain.ts index 02231cb..171ed0f 100644 --- a/src/serverFunctions/domain.ts +++ b/src/serverFunctions/domain.ts @@ -1,7 +1,7 @@ import { createServerFn } from "@tanstack/react-start"; import { authenticatedServerFunctionMiddleware } from "@/serverFunctions/middleware"; import { domainOverviewSchema } from "@/types/schemas/domain"; -import { DomainService } from "@/server/services/DomainService"; +import { DomainService } from "@/server/features/domain/services/DomainService"; export const getDomainOverview = createServerFn({ method: "POST" }) .middleware(authenticatedServerFunctionMiddleware) diff --git a/src/serverFunctions/keywords.ts b/src/serverFunctions/keywords.ts index acc1103..b90bc36 100644 --- a/src/serverFunctions/keywords.ts +++ b/src/serverFunctions/keywords.ts @@ -10,7 +10,7 @@ import { removeSavedKeywordSchema, serpAnalysisSchema, } from "@/types/schemas/keywords"; -import { KeywordResearchService } from "@/server/services/KeywordResearchService"; +import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService"; export const researchKeywords = createServerFn({ method: "POST" }) .middleware(authenticatedServerFunctionMiddleware) diff --git a/src/serverFunctions/psi.ts b/src/serverFunctions/psi.ts index f0bafa6..a15f8c0 100644 --- a/src/serverFunctions/psi.ts +++ b/src/serverFunctions/psi.ts @@ -1,6 +1,6 @@ import { createServerFn } from "@tanstack/react-start"; +import { PsiAuditService } from "@/server/features/psi/services/PsiAuditService"; import { authenticatedServerFunctionMiddleware } from "@/serverFunctions/middleware"; -import { AppError } from "@/server/lib/errors"; import { psiAuditSchema, psiAuditListSchema, @@ -12,392 +12,121 @@ import { psiProjectKeySchema, psiProjectSchema, } from "@/types/schemas/psi"; -import { PsiService } from "@/server/services/PsiService"; -import { KeywordResearchRepository } from "@/server/repositories/KeywordResearchRepository"; -import { PsiAuditRepository } from "@/server/repositories/PsiAuditRepository"; -import { AuditRepository } from "@/server/repositories/AuditRepository"; -import { getJsonFromR2, putJsonToR2 } from "@/server/lib/r2"; -import { PsiIssuesService } from "@/server/services/PsiIssuesService"; - -async function resolvePsiSource(input: { - projectId: string; - userId: string; - source: "single" | "site"; - resultId: string; -}) { - 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, - }; -} export const runPsiAudit = createServerFn({ method: "POST" }) .middleware(authenticatedServerFunctionMiddleware) .inputValidator((data: unknown) => psiAuditSchema.parse(data)) - .handler(async ({ data, context }) => { - const apiKey = await KeywordResearchRepository.getProjectPsiApiKey( - data.projectId, - context.userId, - ); - - if (!apiKey) { - throw new AppError("VALIDATION_ERROR"); - } - - const auditId = crypto.randomUUID(); - try { - const result = await PsiService.runAudit({ - url: data.url, - strategy: data.strategy, - apiKey, - }); - - const now = new Date(); - const datePrefix = now.toISOString().slice(0, 10); - const key = `psi/${data.projectId}/${datePrefix}/${auditId}.json`; - const uploaded = await putJsonToR2(key, result.rawPayload); - - await PsiAuditRepository.createAuditResult({ - id: auditId, - projectId: data.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 = data.url.trim(); - const message = - error instanceof Error ? error.message : "PSI request failed"; - - await PsiAuditRepository.createAuditResult({ - id: auditId, - projectId: data.projectId, - requestedUrl, - finalUrl: requestedUrl, - strategy: data.strategy, - status: "failed", - errorMessage: message, - }); - - throw error; - } - }); + .handler(async ({ data, context }) => + PsiAuditService.runAudit({ + projectId: data.projectId, + userId: context.userId, + url: data.url, + strategy: data.strategy, + }), + ); export const getProjectPsiApiKey = createServerFn({ method: "POST" }) .middleware(authenticatedServerFunctionMiddleware) .inputValidator((data: unknown) => psiProjectSchema.parse(data)) - .handler(async ({ data, context }) => { - // This PSI key is intentionally treated as low-sensitivity operational config - // (Google abuse-control), not a direct billing secret. - const apiKey = await KeywordResearchRepository.getProjectPsiApiKey( - data.projectId, - context.userId, - ); - return { apiKey }; - }); + .handler(async ({ data, context }) => + PsiAuditService.getProjectPsiApiKey({ + projectId: data.projectId, + userId: context.userId, + }), + ); export const saveProjectPsiApiKey = createServerFn({ method: "POST" }) .middleware(authenticatedServerFunctionMiddleware) .inputValidator((data: unknown) => psiProjectKeySchema.parse(data)) - .handler(async ({ data, context }) => { - // Same tradeoff: persisted for convenience across PSI + Site Audit flows. - await KeywordResearchRepository.setProjectPsiApiKey( - data.projectId, - context.userId, - data.apiKey.trim(), - ); - return { success: true }; - }); + .handler(async ({ data, context }) => + PsiAuditService.saveProjectPsiApiKey({ + projectId: data.projectId, + userId: context.userId, + apiKey: data.apiKey, + }), + ); export const clearProjectPsiApiKey = createServerFn({ method: "POST" }) .middleware(authenticatedServerFunctionMiddleware) .inputValidator((data: unknown) => psiProjectSchema.parse(data)) - .handler(async ({ data, context }) => { - await KeywordResearchRepository.clearProjectPsiApiKey( - data.projectId, - context.userId, - ); - return { success: true }; - }); + .handler(async ({ data, context }) => + PsiAuditService.clearProjectPsiApiKey({ + projectId: data.projectId, + userId: context.userId, + }), + ); export const listProjectPsiAudits = createServerFn({ method: "POST" }) .middleware(authenticatedServerFunctionMiddleware) .inputValidator((data: unknown) => psiAuditListSchema.parse(data)) - .handler(async ({ data, context }) => { - const rows = await PsiAuditRepository.listAuditResults({ + .handler(async ({ data, context }) => + PsiAuditService.listProjectPsiAudits({ projectId: data.projectId, userId: context.userId, strategy: data.strategy, limit: data.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, - })), - }; - }); + }), + ); export const getProjectPsiAuditRaw = createServerFn({ method: "POST" }) .middleware(authenticatedServerFunctionMiddleware) .inputValidator((data: unknown) => psiAuditDetailsSchema.parse(data)) - .handler(async ({ data, context }) => { - const row = await PsiAuditRepository.getAuditResult({ - auditId: data.auditId, + .handler(async ({ data, context }) => + PsiAuditService.getProjectPsiAuditRaw({ projectId: data.projectId, userId: context.userId, - }); - - if (!row) { - throw new AppError("NOT_FOUND"); - } - - if (!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, - }; - }); + auditId: data.auditId, + }), + ); export const getProjectPsiAuditIssues = createServerFn({ method: "POST" }) .middleware(authenticatedServerFunctionMiddleware) .inputValidator((data: unknown) => psiIssueFilterSchema.parse(data)) - .handler(async ({ data, context }) => { - const row = await PsiAuditRepository.getAuditResult({ - auditId: data.auditId, + .handler(async ({ data, context }) => + PsiAuditService.getProjectPsiAuditIssues({ projectId: data.projectId, userId: context.userId, - }); - - if (!row) { - throw new AppError("NOT_FOUND"); - } - - if (!row.r2Key) { - throw new AppError("NOT_FOUND"); - } - - const payloadJson = await getJsonFromR2(row.r2Key); - const issues = PsiIssuesService.parseIssues(payloadJson, data.category); - - return { - id: row.id, - finalUrl: row.finalUrl, - strategy: row.strategy, - createdAt: row.createdAt, - issues, - }; - }); + auditId: data.auditId, + category: data.category, + }), + ); export const exportProjectPsiAudit = createServerFn({ method: "POST" }) .middleware(authenticatedServerFunctionMiddleware) .inputValidator((data: unknown) => psiExportSchema.parse(data)) - .handler(async ({ data, context }) => { - const row = await PsiAuditRepository.getAuditResult({ - auditId: data.auditId, + .handler(async ({ data, context }) => + PsiAuditService.exportProjectPsiAudit({ projectId: data.projectId, userId: context.userId, - }); - - if (!row) { - throw new AppError("NOT_FOUND"); - } - - if (!row.r2Key) { - throw new AppError("NOT_FOUND"); - } - - const payloadJson = await getJsonFromR2(row.r2Key); - const safeDate = row.createdAt.replace(/[:.]/g, "-"); - const baseName = `psi-${row.strategy}-${safeDate}`; - - if (data.mode === "full") { - return { - filename: `${baseName}-full.json`, - content: payloadJson, - }; - } - - const category = data.mode === "category" ? data.category : undefined; - const issues = PsiIssuesService.parseIssues(payloadJson, category); - - return { - filename: - data.mode === "category" && category - ? `${baseName}-${category}-issues.json` - : `${baseName}-issues.json`, - content: JSON.stringify( - { - auditId: row.id, - finalUrl: row.finalUrl, - strategy: row.strategy, - createdAt: row.createdAt, - category: category ?? "all", - issues, - }, - null, - 2, - ), - }; - }); + auditId: data.auditId, + mode: data.mode, + category: data.category, + }), + ); export const getPsiIssuesBySource = createServerFn({ method: "POST" }) .middleware(authenticatedServerFunctionMiddleware) .inputValidator((data: unknown) => psiUnifiedIssueSchema.parse(data)) - .handler(async ({ data, context }) => { - const target = await resolvePsiSource({ + .handler(async ({ data, context }) => + PsiAuditService.getPsiIssuesBySource({ projectId: data.projectId, userId: context.userId, source: data.source, resultId: data.resultId, - }); - - if (!target.r2Key) { - throw new AppError("NOT_FOUND"); - } - - const payloadJson = await getJsonFromR2(target.r2Key); - const issues = PsiIssuesService.parseIssues(payloadJson, data.category); - - return { - id: target.id, - finalUrl: target.finalUrl, - strategy: target.strategy, - createdAt: target.createdAt, - issues, - }; - }); + category: data.category, + }), + ); export const exportPsiBySource = createServerFn({ method: "POST" }) .middleware(authenticatedServerFunctionMiddleware) .inputValidator((data: unknown) => psiUnifiedExportSchema.parse(data)) - .handler(async ({ data, context }) => { - const target = await resolvePsiSource({ + .handler(async ({ data, context }) => + PsiAuditService.exportPsiBySource({ projectId: data.projectId, userId: context.userId, source: data.source, resultId: data.resultId, - }); - - if (!target.r2Key) { - throw new AppError("NOT_FOUND"); - } - - const payloadJson = await getJsonFromR2(target.r2Key); - const safeDate = target.createdAt.replace(/[:.]/g, "-"); - const baseName = `psi-${target.strategy}-${safeDate}`; - - if (data.mode === "full") { - return { - filename: `${baseName}-full.json`, - content: payloadJson, - }; - } - - const category = data.mode === "category" ? data.category : undefined; - const issues = PsiIssuesService.parseIssues(payloadJson, category); - - return { - filename: - data.mode === "category" && category - ? `${baseName}-${category}-issues.json` - : `${baseName}-issues.json`, - content: JSON.stringify( - { - resultId: target.id, - finalUrl: target.finalUrl, - strategy: target.strategy, - createdAt: target.createdAt, - category: category ?? "all", - issues, - }, - null, - 2, - ), - }; - }); + mode: data.mode, + category: data.category, + }), + ); diff --git a/src/shared/error-codes.ts b/src/shared/error-codes.ts index b4584d9..923f9e8 100644 --- a/src/shared/error-codes.ts +++ b/src/shared/error-codes.ts @@ -1,4 +1,6 @@ -export const ERROR_CODES = [ +import { z } from "zod"; + +const ERROR_CODES = [ "UNAUTHENTICATED", "AUTH_CONFIG_MISSING", "FORBIDDEN", @@ -10,8 +12,10 @@ export const ERROR_CODES = [ "INTERNAL_ERROR", ] as const; -export type ErrorCode = (typeof ERROR_CODES)[number]; +export const errorCodeSchema = z.enum(ERROR_CODES); + +export type ErrorCode = z.infer; export function isErrorCode(value: string): value is ErrorCode { - return (ERROR_CODES as readonly string[]).includes(value); + return errorCodeSchema.safeParse(value).success; } diff --git a/src/shared/json.ts b/src/shared/json.ts new file mode 100644 index 0000000..18be100 --- /dev/null +++ b/src/shared/json.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +export function jsonCodec(schema: z.ZodType) { + return z.codec(z.string(), schema, { + decode: (jsonString, context) => { + let parsed: unknown; + try { + parsed = JSON.parse(jsonString) as unknown; + } catch { + context.issues.push({ + code: "custom", + message: "Invalid JSON", + input: jsonString, + }); + return z.NEVER; + } + + const validated = schema.safeParse(parsed); + if (!validated.success) { + context.issues.push({ + code: "custom", + message: "JSON does not match schema", + input: jsonString, + }); + return z.NEVER; + } + + return validated.data; + }, + encode: (value) => JSON.stringify(value), + }); +} diff --git a/src/types/schemas/keywords.ts b/src/types/schemas/keywords.ts index c872467..0cbe6af 100644 --- a/src/types/schemas/keywords.ts +++ b/src/types/schemas/keywords.ts @@ -1,13 +1,17 @@ import { z } from "zod"; export const researchKeywordsSchema = z.object({ + projectId: z.string().min(1), keywords: z.array(z.string().min(1)).min(1).max(200), locationCode: z.number().int().positive().default(2840), languageCode: z.string().min(2).max(8).default("en"), resultLimit: z .union([z.literal(150), z.literal(300), z.literal(500)]) .default(150), - mode: z.literal("related").optional().default("related"), + mode: z + .enum(["auto", "related", "suggestions", "ideas"]) + .optional() + .default("auto"), }); export const createProjectSchema = z.object({ @@ -97,11 +101,13 @@ const keywordSortFields = [ ] as const; const sortDirs = ["asc", "desc"] as const; +const keywordModes = ["auto", "related", "suggestions", "ideas"] as const; export const keywordsSearchSchema = z.object({ q: z.string().optional(), loc: z.coerce.number().int().positive().optional(), kLimit: z.union([z.literal(150), z.literal(300), z.literal(500)]).optional(), + mode: z.enum(keywordModes).optional(), sort: z.enum(keywordSortFields).optional(), order: z.enum(sortDirs).optional(), minVol: z.string().optional(),