SearchConsoleView.swift
1 // 2 // SearchConsoleView.swift 3 // RacerTracer 4 // 5 // Created by Alexander Kunau on 29.09.25. 6 // 7 8 import SwiftUI 9 import Charts 10 11 struct SearchConsoleView: View { 12 @StateObject private var searchConsoleService = SearchConsoleService() 13 @State private var websiteProperty = "" 14 @State private var searchConsoleData: SearchConsoleData? 15 @State private var isLoading = false 16 @State private var selectedTimeRange: TimeRange = .last30Days 17 @State private var selectedMetric: SearchMetric = .clicks 18 @State private var errorMessage: String? 19 20 var body: some View { 21 VStack(spacing: 24) { 22 // Header with Input 23 SearchConsoleInputSection( 24 websiteProperty: $websiteProperty, 25 selectedTimeRange: $selectedTimeRange, 26 onAnalyze: fetchSearchConsoleData, 27 isLoading: isLoading 28 ) 29 30 if isLoading { 31 SearchConsoleProgressView() 32 } else if let data = searchConsoleData { 33 SearchConsoleResultsView( 34 data: data, 35 selectedMetric: $selectedMetric 36 ) 37 } else if let error = errorMessage { 38 SearchConsoleErrorView(error: error) 39 } else { 40 SearchConsoleStartView() 41 } 42 } 43 .padding(24) 44 .navigationTitle("Search Console") 45 } 46 47 private func fetchSearchConsoleData() { 48 guard !websiteProperty.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } 49 guard searchConsoleService.isCredentialsConfigured() else { 50 errorMessage = "Please configure your Search Console API credentials in Settings" 51 return 52 } 53 54 isLoading = true 55 errorMessage = nil 56 57 Task { 58 do { 59 let data = try await searchConsoleService.fetchSearchData( 60 property: websiteProperty, 61 timeRange: selectedTimeRange 62 ) 63 64 await MainActor.run { 65 searchConsoleData = data 66 isLoading = false 67 } 68 } catch { 69 await MainActor.run { 70 errorMessage = error.localizedDescription 71 isLoading = false 72 } 73 } 74 } 75 } 76 } 77 78 // MARK: - Input Section 79 80 struct SearchConsoleInputSection: View { 81 @Binding var websiteProperty: String 82 @Binding var selectedTimeRange: TimeRange 83 let onAnalyze: () -> Void 84 let isLoading: Bool 85 86 var body: some View { 87 VStack(alignment: .leading, spacing: 16) { 88 Text("Google Search Console Analysis") 89 .font(.title2) 90 .fontWeight(.semibold) 91 92 Text("Analyze search performance data from Google Search Console API including clicks, impressions, CTR, and keyword rankings") 93 .font(.subheadline) 94 .foregroundColor(.secondary) 95 96 VStack(spacing: 12) { 97 HStack { 98 TextField("Website property (e.g., https://example.com/)", text: $websiteProperty) 99 .textFieldStyle(RoundedBorderTextFieldStyle()) 100 .disabled(isLoading) 101 102 Picker("Time Range", selection: $selectedTimeRange) { 103 ForEach(TimeRange.allCases, id: \.self) { range in 104 Text(range.rawValue).tag(range) 105 } 106 } 107 .pickerStyle(MenuPickerStyle()) 108 .frame(width: 150) 109 .disabled(isLoading) 110 } 111 112 Button(action: onAnalyze) { 113 HStack { 114 if isLoading { 115 ProgressView() 116 .scaleEffect(0.8) 117 } else { 118 Image(systemName: "magnifyingglass") 119 } 120 Text(isLoading ? "Fetching Data..." : "Fetch Search Data") 121 } 122 } 123 .buttonStyle(.borderedProminent) 124 .disabled(websiteProperty.isEmpty || isLoading) 125 } 126 } 127 .padding(20) 128 .background(Color(NSColor.controlBackgroundColor)) 129 .cornerRadius(12) 130 } 131 } 132 133 // MARK: - Progress View 134 135 struct SearchConsoleProgressView: View { 136 var body: some View { 137 VStack(spacing: 20) { 138 ProgressView() 139 .scaleEffect(1.5) 140 141 Text("Fetching Search Console Data...") 142 .font(.headline) 143 .foregroundColor(.secondary) 144 145 VStack(spacing: 8) { 146 Text("• Authenticating with Google API") 147 Text("• Retrieving search performance data") 148 Text("• Processing clicks and impressions") 149 Text("• Analyzing keyword rankings") 150 } 151 .font(.subheadline) 152 .foregroundColor(.secondary) 153 } 154 .frame(maxWidth: .infinity) 155 .frame(height: 250) 156 .background(Color(NSColor.controlBackgroundColor)) 157 .cornerRadius(12) 158 } 159 } 160 161 // MARK: - Start View 162 163 struct SearchConsoleStartView: View { 164 var body: some View { 165 VStack(spacing: 20) { 166 Image(systemName: "magnifyingglass") 167 .font(.system(size: 48)) 168 .foregroundColor(.accentColor) 169 170 Text("Search Console Analytics") 171 .font(.title2) 172 .fontWeight(.semibold) 173 174 Text("Connect to Google Search Console to analyze your website's search performance, keyword rankings, and click-through rates.") 175 .font(.subheadline) 176 .foregroundColor(.secondary) 177 .multilineTextAlignment(.center) 178 } 179 .frame(maxWidth: .infinity, maxHeight: .infinity) 180 .background(Color(NSColor.controlBackgroundColor)) 181 .cornerRadius(12) 182 } 183 } 184 185 // MARK: - Error View 186 187 struct SearchConsoleErrorView: View { 188 let error: String 189 190 var body: some View { 191 VStack(spacing: 20) { 192 Image(systemName: "exclamationmark.triangle") 193 .font(.system(size: 48)) 194 .foregroundColor(.orange) 195 196 Text("API Error") 197 .font(.title2) 198 .fontWeight(.semibold) 199 200 Text(error) 201 .font(.subheadline) 202 .foregroundColor(.secondary) 203 .multilineTextAlignment(.center) 204 } 205 .frame(maxWidth: .infinity, maxHeight: .infinity) 206 .background(Color(NSColor.controlBackgroundColor)) 207 .cornerRadius(12) 208 } 209 } 210 211 // MARK: - Results View 212 213 struct SearchConsoleResultsView: View { 214 let data: SearchConsoleData 215 @Binding var selectedMetric: SearchMetric 216 217 var body: some View { 218 ScrollView { 219 VStack(spacing: 24) { 220 // Overview Cards 221 SearchPerformanceOverview(data: data) 222 223 // Metric Selector 224 Picker("Metric", selection: $selectedMetric) { 225 ForEach(SearchMetric.allCases, id: \.self) { metric in 226 Text(metric.rawValue).tag(metric) 227 } 228 } 229 .pickerStyle(SegmentedPickerStyle()) 230 231 // Performance Chart 232 SearchPerformanceChart( 233 timeSeriesData: data.timeSeriesData, 234 selectedMetric: selectedMetric 235 ) 236 237 // Top Queries 238 TopQueriesSection(queries: data.topQueries) 239 240 // Top Pages 241 TopPagesSection(pages: data.topPages) 242 243 // Device Breakdown 244 DevicePerformanceSection(deviceData: data.deviceBreakdown) 245 246 // Country Performance 247 CountryPerformanceSection(countryData: data.countryBreakdown) 248 } 249 .padding(20) 250 } 251 .background(Color(NSColor.controlBackgroundColor)) 252 .cornerRadius(12) 253 } 254 } 255 256 // MARK: - Overview Cards 257 258 struct SearchPerformanceOverview: View { 259 let data: SearchConsoleData 260 261 var body: some View { 262 VStack(alignment: .leading, spacing: 16) { 263 Text("Search Performance Overview") 264 .font(.headline) 265 .fontWeight(.semibold) 266 267 HStack(spacing: 16) { 268 SearchOverviewCard( 269 title: "Total Clicks", 270 value: formatNumber(data.totalClicks), 271 change: data.clicksChange, 272 color: .blue 273 ) 274 275 SearchOverviewCard( 276 title: "Total Impressions", 277 value: formatNumber(data.totalImpressions), 278 change: data.impressionsChange, 279 color: .green 280 ) 281 282 SearchOverviewCard( 283 title: "Average CTR", 284 value: String(format: "%.2f%%", data.averageCTR * 100), 285 change: data.ctrChange, 286 color: .orange 287 ) 288 289 SearchOverviewCard( 290 title: "Average Position", 291 value: String(format: "%.1f", data.averagePosition), 292 change: data.positionChange, 293 color: .purple 294 ) 295 } 296 } 297 } 298 299 private func formatNumber(_ number: Int) -> String { 300 if number >= 1000000 { 301 return String(format: "%.1fM", Double(number) / 1000000) 302 } else if number >= 1000 { 303 return String(format: "%.1fK", Double(number) / 1000) 304 } else { 305 return String(number) 306 } 307 } 308 } 309 310 struct SearchOverviewCard: View { 311 let title: String 312 let value: String 313 let change: Double 314 let color: Color 315 316 var body: some View { 317 VStack(spacing: 8) { 318 Text(title) 319 .font(.caption) 320 .foregroundColor(.secondary) 321 322 Text(value) 323 .font(.title2) 324 .fontWeight(.bold) 325 .foregroundColor(color) 326 327 HStack(spacing: 4) { 328 Image(systemName: change >= 0 ? "arrow.up" : "arrow.down") 329 .foregroundColor(change >= 0 ? .green : .red) 330 .font(.caption) 331 332 Text(String(format: "%.1f%%", abs(change))) 333 .font(.caption) 334 .foregroundColor(change >= 0 ? .green : .red) 335 } 336 } 337 .frame(maxWidth: .infinity) 338 .padding(16) 339 .background(Color(NSColor.textBackgroundColor)) 340 .cornerRadius(8) 341 } 342 } 343 344 // MARK: - Performance Chart 345 346 struct SearchPerformanceChart: View { 347 let timeSeriesData: [SearchTimeSeriesData] 348 let selectedMetric: SearchMetric 349 350 var body: some View { 351 VStack(alignment: .leading, spacing: 16) { 352 Text("\(selectedMetric.rawValue) Over Time") 353 .font(.headline) 354 .fontWeight(.semibold) 355 356 Chart(timeSeriesData, id: \.date) { data in 357 LineMark( 358 x: .value("Date", data.date), 359 y: .value(selectedMetric.rawValue, metricValue(for: data)) 360 ) 361 .foregroundStyle(selectedMetric.color.gradient) 362 .lineStyle(StrokeStyle(lineWidth: 2)) 363 364 AreaMark( 365 x: .value("Date", data.date), 366 y: .value(selectedMetric.rawValue, metricValue(for: data)) 367 ) 368 .foregroundStyle(selectedMetric.color.opacity(0.1)) 369 } 370 .frame(height: 200) 371 } 372 } 373 374 private func metricValue(for data: SearchTimeSeriesData) -> Double { 375 switch selectedMetric { 376 case .clicks: 377 return Double(data.clicks) 378 case .impressions: 379 return Double(data.impressions) 380 case .ctr: 381 return data.ctr * 100 382 case .position: 383 return data.position 384 } 385 } 386 } 387 388 // MARK: - Top Queries 389 390 struct TopQueriesSection: View { 391 let queries: [SearchQuery] 392 393 var body: some View { 394 VStack(alignment: .leading, spacing: 16) { 395 Text("Top Search Queries") 396 .font(.headline) 397 .fontWeight(.semibold) 398 399 LazyVStack(spacing: 8) { 400 ForEach(queries.prefix(10), id: \.id) { query in 401 SearchQueryRow(query: query) 402 } 403 } 404 } 405 } 406 } 407 408 struct SearchQueryRow: View { 409 let query: SearchQuery 410 411 var body: some View { 412 HStack { 413 VStack(alignment: .leading, spacing: 4) { 414 Text(query.query) 415 .font(.subheadline) 416 .fontWeight(.medium) 417 418 HStack(spacing: 16) { 419 Text("\(query.clicks) clicks") 420 .font(.caption) 421 .foregroundColor(.blue) 422 423 Text("\(query.impressions) impressions") 424 .font(.caption) 425 .foregroundColor(.green) 426 427 Text("CTR: \(String(format: "%.2f%%", query.ctr * 100))") 428 .font(.caption) 429 .foregroundColor(.orange) 430 } 431 } 432 433 Spacer() 434 435 VStack(alignment: .trailing, spacing: 4) { 436 Text("Pos: \(String(format: "%.1f", query.position))") 437 .font(.caption) 438 .fontWeight(.medium) 439 .foregroundColor(.purple) 440 } 441 } 442 .padding(12) 443 .background(Color(NSColor.textBackgroundColor)) 444 .cornerRadius(8) 445 } 446 } 447 448 // MARK: - Top Pages 449 450 struct TopPagesSection: View { 451 let pages: [SearchPage] 452 453 var body: some View { 454 VStack(alignment: .leading, spacing: 16) { 455 Text("Top Landing Pages") 456 .font(.headline) 457 .fontWeight(.semibold) 458 459 LazyVStack(spacing: 8) { 460 ForEach(pages.prefix(10), id: \.id) { page in 461 SearchPageRow(page: page) 462 } 463 } 464 } 465 } 466 } 467 468 struct SearchPageRow: View { 469 let page: SearchPage 470 471 var body: some View { 472 HStack { 473 VStack(alignment: .leading, spacing: 4) { 474 Text(URL(string: page.url)?.path ?? page.url) 475 .font(.subheadline) 476 .fontWeight(.medium) 477 .lineLimit(1) 478 479 HStack(spacing: 16) { 480 Text("\(page.clicks) clicks") 481 .font(.caption) 482 .foregroundColor(.blue) 483 484 Text("\(page.impressions) impressions") 485 .font(.caption) 486 .foregroundColor(.green) 487 488 Text("CTR: \(String(format: "%.2f%%", page.ctr * 100))") 489 .font(.caption) 490 .foregroundColor(.orange) 491 } 492 } 493 494 Spacer() 495 496 VStack(alignment: .trailing, spacing: 4) { 497 Text("Pos: \(String(format: "%.1f", page.position))") 498 .font(.caption) 499 .fontWeight(.medium) 500 .foregroundColor(.purple) 501 } 502 } 503 .padding(12) 504 .background(Color(NSColor.textBackgroundColor)) 505 .cornerRadius(8) 506 } 507 } 508 509 // MARK: - Device Performance 510 511 struct DevicePerformanceSection: View { 512 let deviceData: [DevicePerformance] 513 514 var body: some View { 515 VStack(alignment: .leading, spacing: 16) { 516 Text("Performance by Device") 517 .font(.headline) 518 .fontWeight(.semibold) 519 520 Chart(deviceData, id: \.device) { data in 521 BarMark( 522 x: .value("Device", data.device), 523 y: .value("Clicks", data.clicks) 524 ) 525 .foregroundStyle(Color.blue.gradient) 526 } 527 .frame(height: 150) 528 } 529 } 530 } 531 532 // MARK: - Country Performance 533 534 struct CountryPerformanceSection: View { 535 let countryData: [CountryPerformance] 536 537 var body: some View { 538 VStack(alignment: .leading, spacing: 16) { 539 Text("Performance by Country") 540 .font(.headline) 541 .fontWeight(.semibold) 542 543 LazyVStack(spacing: 8) { 544 ForEach(countryData.prefix(5), id: \.id) { country in 545 CountryPerformanceRow(country: country) 546 } 547 } 548 } 549 } 550 } 551 552 struct CountryPerformanceRow: View { 553 let country: CountryPerformance 554 555 var body: some View { 556 HStack { 557 Text(country.country) 558 .font(.subheadline) 559 .fontWeight(.medium) 560 561 Spacer() 562 563 HStack(spacing: 16) { 564 Text("\(country.clicks) clicks") 565 .font(.caption) 566 .foregroundColor(.blue) 567 568 Text("\(country.impressions) impr.") 569 .font(.caption) 570 .foregroundColor(.green) 571 572 Text("\(String(format: "%.2f%%", country.ctr * 100)) CTR") 573 .font(.caption) 574 .foregroundColor(.orange) 575 } 576 } 577 .padding(12) 578 .background(Color(NSColor.textBackgroundColor)) 579 .cornerRadius(8) 580 } 581 } 582 583 // MARK: - Data Models 584 585 enum SearchMetric: String, CaseIterable { 586 case clicks = "Clicks" 587 case impressions = "Impressions" 588 case ctr = "CTR" 589 case position = "Position" 590 591 var color: Color { 592 switch self { 593 case .clicks: return .blue 594 case .impressions: return .green 595 case .ctr: return .orange 596 case .position: return .purple 597 } 598 } 599 } 600 601 struct SearchConsoleData { 602 let property: String 603 let timeRange: TimeRange 604 let totalClicks: Int 605 let totalImpressions: Int 606 let averageCTR: Double 607 let averagePosition: Double 608 let clicksChange: Double 609 let impressionsChange: Double 610 let ctrChange: Double 611 let positionChange: Double 612 let timeSeriesData: [SearchTimeSeriesData] 613 let topQueries: [SearchQuery] 614 let topPages: [SearchPage] 615 let deviceBreakdown: [DevicePerformance] 616 let countryBreakdown: [CountryPerformance] 617 } 618 619 struct SearchTimeSeriesData { 620 let date: Date 621 let clicks: Int 622 let impressions: Int 623 let ctr: Double 624 let position: Double 625 } 626 627 struct SearchQuery: Identifiable { 628 let id = UUID() 629 let query: String 630 let clicks: Int 631 let impressions: Int 632 let ctr: Double 633 let position: Double 634 } 635 636 struct SearchPage: Identifiable { 637 let id = UUID() 638 let url: String 639 let clicks: Int 640 let impressions: Int 641 let ctr: Double 642 let position: Double 643 } 644 645 struct DevicePerformance { 646 let device: String 647 let clicks: Int 648 let impressions: Int 649 let ctr: Double 650 let position: Double 651 } 652 653 struct CountryPerformance: Identifiable { 654 let id = UUID() 655 let country: String 656 let clicks: Int 657 let impressions: Int 658 let ctr: Double 659 let position: Double 660 } 661 662 // MARK: - Search Console Service 663 664 @MainActor 665 class SearchConsoleService: ObservableObject { 666 private var credentials: String { 667 return UserDefaults.standard.string(forKey: "SearchConsoleCredentials") ?? "" 668 } 669 670 func isCredentialsConfigured() -> Bool { 671 return !credentials.isEmpty 672 } 673 674 func fetchSearchData(property: String, timeRange: TimeRange) async throws -> SearchConsoleData { 675 // In a real implementation, this would make actual API calls to Google Search Console 676 // For now, we'll return mock data 677 678 try await Task.sleep(nanoseconds: 2_000_000_000) // Simulate API delay 679 680 return generateMockSearchConsoleData(property: property, timeRange: timeRange) 681 } 682 683 private func generateMockSearchConsoleData(property: String, timeRange: TimeRange) -> SearchConsoleData { 684 let calendar = Calendar.current 685 let endDate = Date() 686 let startDate = calendar.date(byAdding: .day, value: -28, to: endDate) ?? endDate 687 688 // Generate time series data 689 var timeSeriesData: [SearchTimeSeriesData] = [] 690 var currentDate = startDate 691 692 while currentDate <= endDate { 693 timeSeriesData.append(SearchTimeSeriesData( 694 date: currentDate, 695 clicks: Int.random(in: 50...500), 696 impressions: Int.random(in: 1000...5000), 697 ctr: Double.random(in: 0.02...0.15), 698 position: Double.random(in: 5...25) 699 )) 700 currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate) ?? currentDate 701 } 702 703 // Generate top queries 704 let sampleQueries = [ 705 "seo optimization", "website analysis", "keyword research", "google rankings", 706 "technical seo", "page speed", "search console", "site audit", "meta tags", 707 "backlink analysis", "content optimization", "local seo", "mobile seo" 708 ] 709 710 let topQueries = sampleQueries.shuffled().prefix(10).map { query in 711 SearchQuery( 712 query: query, 713 clicks: Int.random(in: 10...200), 714 impressions: Int.random(in: 500...2000), 715 ctr: Double.random(in: 0.02...0.12), 716 position: Double.random(in: 3...20) 717 ) 718 } 719 720 // Generate top pages 721 let samplePages = [ 722 "/", "/blog/seo-guide", "/services", "/about", "/contact", 723 "/blog/keyword-research", "/tools", "/pricing", "/blog/technical-seo" 724 ] 725 726 let topPages = samplePages.map { path in 727 let fullURL = property.hasSuffix("/") ? property + path.dropFirst() : property + path 728 return SearchPage( 729 url: fullURL, 730 clicks: Int.random(in: 20...300), 731 impressions: Int.random(in: 800...3000), 732 ctr: Double.random(in: 0.025...0.1), 733 position: Double.random(in: 4...18) 734 ) 735 } 736 737 // Generate device breakdown 738 let deviceBreakdown = [ 739 DevicePerformance(device: "Mobile", clicks: 1250, impressions: 12500, ctr: 0.1, position: 8.5), 740 DevicePerformance(device: "Desktop", clicks: 800, impressions: 6400, ctr: 0.125, position: 7.2), 741 DevicePerformance(device: "Tablet", clicks: 150, impressions: 1200, ctr: 0.125, position: 9.1) 742 ] 743 744 // Generate country breakdown 745 let countryBreakdown = [ 746 CountryPerformance(country: "United States", clicks: 1200, impressions: 11000, ctr: 0.109, position: 7.8), 747 CountryPerformance(country: "Germany", clicks: 400, impressions: 4200, ctr: 0.095, position: 8.2), 748 CountryPerformance(country: "United Kingdom", clicks: 350, impressions: 3800, ctr: 0.092, position: 8.5), 749 CountryPerformance(country: "Canada", clicks: 250, impressions: 2500, ctr: 0.1, position: 8.0) 750 ] 751 752 let totalClicks = timeSeriesData.reduce(0) { $0 + $1.clicks } 753 let totalImpressions = timeSeriesData.reduce(0) { $0 + $1.impressions } 754 let averageCTR = timeSeriesData.map(\.ctr).reduce(0, +) / Double(timeSeriesData.count) 755 let averagePosition = timeSeriesData.map(\.position).reduce(0, +) / Double(timeSeriesData.count) 756 757 return SearchConsoleData( 758 property: property, 759 timeRange: timeRange, 760 totalClicks: totalClicks, 761 totalImpressions: totalImpressions, 762 averageCTR: averageCTR, 763 averagePosition: averagePosition, 764 clicksChange: Double.random(in: -15...25), 765 impressionsChange: Double.random(in: -10...30), 766 ctrChange: Double.random(in: -5...15), 767 positionChange: Double.random(in: -2...3), 768 timeSeriesData: timeSeriesData, 769 topQueries: Array(topQueries), 770 topPages: topPages, 771 deviceBreakdown: deviceBreakdown, 772 countryBreakdown: countryBreakdown 773 ) 774 } 775 } 776 777 #Preview { 778 SearchConsoleView() 779 }