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(),