CompetitorAnalysisView.swift
1 // 2 // CompetitorSEOAnalysisView.swift 3 // RacerTracer 4 // 5 // Created by Alexander Kunau on 29.09.25. 6 // 7 8 import SwiftUI 9 import Charts 10 11 struct CompetitorAnalysisView: View { 12 @EnvironmentObject var seoService: SEOAnalysisService 13 @State private var competitorURL = "" 14 @State private var targetKeywords = "" 15 @State private var competitorAnalyses: [CompetitorSEOAnalysis] = [] 16 @State private var isAnalyzing = false 17 @State private var selectedComparison: ComparisonType = .overallScore 18 19 var body: some View { 20 VStack(spacing: 24) { 21 // Input Section 22 CompetitorInputSection( 23 competitorURL: $competitorURL, 24 targetKeywords: $targetKeywords, 25 onAnalyze: performCompetitorSEOAnalysis, 26 isAnalyzing: isAnalyzing 27 ) 28 29 if isAnalyzing { 30 CompetitorSEOAnalysisProgressView() 31 } else if !competitorAnalyses.isEmpty { 32 CompetitorResultsView( 33 analyses: competitorAnalyses, 34 currentSite: seoService.currentAnalysis, 35 selectedComparison: $selectedComparison 36 ) 37 } else { 38 CompetitorStartView() 39 } 40 } 41 .padding(24) 42 .navigationTitle("Competitor Analysis") 43 } 44 45 private func performCompetitorSEOAnalysis() { 46 guard !competitorURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } 47 48 isAnalyzing = true 49 50 // Simuliere Competitor-Analyse 51 DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { 52 let newAnalysis = generateCompetitorSEOAnalysis( 53 url: competitorURL, 54 keywords: targetKeywords.components(separatedBy: ",") 55 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } 56 .filter { !$0.isEmpty } 57 ) 58 59 competitorAnalyses.append(newAnalysis) 60 isAnalyzing = false 61 } 62 } 63 } 64 65 // MARK: - Input Section 66 67 struct CompetitorInputSection: View { 68 @Binding var competitorURL: String 69 @Binding var targetKeywords: String 70 let onAnalyze: () -> Void 71 let isAnalyzing: Bool 72 73 var body: some View { 74 VStack(alignment: .leading, spacing: 16) { 75 Text("Competitor Analysis") 76 .font(.title2) 77 .fontWeight(.semibold) 78 79 Text("Analyze competitor websites to identify opportunities and gaps in your SEO strategy") 80 .font(.subheadline) 81 .foregroundColor(.secondary) 82 83 VStack(spacing: 12) { 84 TextField("Competitor website URL", text: $competitorURL) 85 .textFieldStyle(RoundedBorderTextFieldStyle()) 86 .disabled(isAnalyzing) 87 88 TextField("Target keywords (comma separated)", text: $targetKeywords) 89 .textFieldStyle(RoundedBorderTextFieldStyle()) 90 .disabled(isAnalyzing) 91 92 Button(action: onAnalyze) { 93 HStack { 94 if isAnalyzing { 95 ProgressView() 96 .scaleEffect(0.8) 97 } else { 98 Image(systemName: "chart.line.uptrend.xyaxis") 99 } 100 Text(isAnalyzing ? "Analyzing..." : "Analyze Competitor") 101 } 102 } 103 .buttonStyle(.borderedProminent) 104 .disabled(competitorURL.isEmpty || isAnalyzing) 105 } 106 } 107 .padding(20) 108 .background(Color(NSColor.controlBackgroundColor)) 109 .cornerRadius(12) 110 } 111 } 112 113 // MARK: - Progress View 114 115 struct CompetitorSEOAnalysisProgressView: View { 116 var body: some View { 117 VStack(spacing: 20) { 118 ProgressView() 119 .scaleEffect(1.5) 120 121 Text("Analyzing Competitor...") 122 .font(.headline) 123 .foregroundColor(.secondary) 124 125 VStack(spacing: 8) { 126 Text("• Crawling competitor website") 127 Text("• Analyzing keyword usage") 128 Text("• Comparing SEO metrics") 129 Text("• Identifying opportunities") 130 } 131 .font(.subheadline) 132 .foregroundColor(.secondary) 133 } 134 .frame(maxWidth: .infinity) 135 .frame(height: 200) 136 .background(Color(NSColor.controlBackgroundColor)) 137 .cornerRadius(12) 138 } 139 } 140 141 // MARK: - Start View 142 143 struct CompetitorStartView: View { 144 var body: some View { 145 VStack(spacing: 20) { 146 Image(systemName: "chart.line.uptrend.xyaxis") 147 .font(.system(size: 48)) 148 .foregroundColor(.accentColor) 149 150 Text("Competitor Analysis") 151 .font(.title2) 152 .fontWeight(.semibold) 153 154 Text("Enter a competitor's URL to analyze their SEO strategy, keyword usage, and identify opportunities for improvement.") 155 .font(.subheadline) 156 .foregroundColor(.secondary) 157 .multilineTextAlignment(.center) 158 } 159 .frame(maxWidth: .infinity, maxHeight: .infinity) 160 .background(Color(NSColor.controlBackgroundColor)) 161 .cornerRadius(12) 162 } 163 } 164 165 // MARK: - Results View 166 167 enum ComparisonType: String, CaseIterable { 168 case overallScore = "Overall Score" 169 case keywordUsage = "Keyword Usage" 170 case technicalSEO = "Technical SEO" 171 case contentQuality = "Content Quality" 172 } 173 174 struct CompetitorResultsView: View { 175 let analyses: [CompetitorSEOAnalysis] 176 let currentSite: SEOAnalysis? 177 @Binding var selectedComparison: ComparisonType 178 179 var body: some View { 180 ScrollView { 181 VStack(spacing: 24) { 182 // Comparison Overview 183 CompetitorOverviewSection( 184 analyses: analyses, 185 currentSite: currentSite 186 ) 187 188 // Comparison Chart 189 CompetitorComparisonChart( 190 analyses: analyses, 191 currentSite: currentSite, 192 comparisonType: selectedComparison 193 ) 194 195 // Comparison Type Selector 196 Picker("Comparison", selection: $selectedComparison) { 197 ForEach(ComparisonType.allCases, id: \.self) { type in 198 Text(type.rawValue).tag(type) 199 } 200 } 201 .pickerStyle(SegmentedPickerStyle()) 202 203 // Detailed Analysis 204 ForEach(analyses, id: \.id) { analysis in 205 CompetitorDetailCard(analysis: analysis) 206 } 207 208 // Opportunities Section 209 OpportunitiesSection(analyses: analyses, currentSite: currentSite) 210 } 211 .padding(20) 212 } 213 .background(Color(NSColor.controlBackgroundColor)) 214 .cornerRadius(12) 215 } 216 } 217 218 // MARK: - Overview Section 219 220 struct CompetitorOverviewSection: View { 221 let analyses: [CompetitorSEOAnalysis] 222 let currentSite: SEOAnalysis? 223 224 var body: some View { 225 VStack(alignment: .leading, spacing: 16) { 226 Text("Competitor Overview") 227 .font(.headline) 228 .fontWeight(.semibold) 229 230 HStack(spacing: 16) { 231 if let current = currentSite { 232 CompetitorOverviewCard( 233 title: "Your Site", 234 url: current.url, 235 score: current.overallScore, 236 isYourSite: true 237 ) 238 } 239 240 ForEach(analyses, id: \.id) { analysis in 241 CompetitorOverviewCard( 242 title: "Competitor", 243 url: analysis.url, 244 score: analysis.overallScore, 245 isYourSite: false 246 ) 247 } 248 } 249 } 250 } 251 } 252 253 struct CompetitorOverviewCard: View { 254 let title: String 255 let url: String 256 let score: Double 257 let isYourSite: Bool 258 259 var body: some View { 260 VStack(spacing: 8) { 261 Text(title) 262 .font(.caption) 263 .foregroundColor(.secondary) 264 265 Text(URL(string: url)?.host ?? url) 266 .font(.subheadline) 267 .fontWeight(.medium) 268 .lineLimit(1) 269 270 Text("\(Int(score))") 271 .font(.title) 272 .fontWeight(.bold) 273 .foregroundColor(isYourSite ? .accentColor : scoreColor(score)) 274 275 Text("SEO Score") 276 .font(.caption2) 277 .foregroundColor(.secondary) 278 } 279 .frame(maxWidth: .infinity) 280 .padding(16) 281 .background(Color(NSColor.textBackgroundColor)) 282 .cornerRadius(8) 283 .overlay( 284 RoundedRectangle(cornerRadius: 8) 285 .strokeBorder(isYourSite ? Color.accentColor : Color.clear, lineWidth: 2) 286 ) 287 } 288 289 private func scoreColor(_ score: Double) -> Color { 290 switch score { 291 case 80...: return .green 292 case 60..<80: return .orange 293 default: return .red 294 } 295 } 296 } 297 298 // MARK: - Comparison Chart 299 300 struct CompetitorComparisonChart: View { 301 let analyses: [CompetitorSEOAnalysis] 302 let currentSite: SEOAnalysis? 303 let comparisonType: ComparisonType 304 305 var body: some View { 306 VStack(alignment: .leading, spacing: 16) { 307 Text("\(comparisonType.rawValue) Comparison") 308 .font(.headline) 309 .fontWeight(.semibold) 310 311 Chart(chartData, id: \.site) { data in 312 BarMark( 313 x: .value("Score", data.score), 314 y: .value("Site", data.site) 315 ) 316 .foregroundStyle(data.isYourSite ? Color.accentColor.gradient : Color.secondary.gradient) 317 } 318 .frame(height: max(120, CGFloat(chartData.count * 40))) 319 .chartXScale(domain: 0...100) 320 } 321 } 322 323 private var chartData: [ChartDataPoint] { 324 var data: [ChartDataPoint] = [] 325 326 if let current = currentSite { 327 data.append(ChartDataPoint( 328 site: "Your Site", 329 score: getScoreForComparison(current), 330 isYourSite: true 331 )) 332 } 333 334 for analysis in analyses { 335 data.append(ChartDataPoint( 336 site: URL(string: analysis.url)?.host ?? analysis.url, 337 score: getScoreForComparison(analysis), 338 isYourSite: false 339 )) 340 } 341 342 return data 343 } 344 345 private func getScoreForComparison(_ analysis: SEOAnalysis) -> Double { 346 switch comparisonType { 347 case .overallScore: 348 return analysis.overallScore 349 case .keywordUsage: 350 return analysis.metrics.keywords.score 351 case .technicalSEO: 352 return analysis.metrics.technicalSEO.score 353 case .contentQuality: 354 return (analysis.metrics.titleTag.score + analysis.metrics.metaDescription.score + analysis.metrics.headings.score) / 3 355 } 356 } 357 358 private func getScoreForComparison(_ analysis: CompetitorSEOAnalysis) -> Double { 359 switch comparisonType { 360 case .overallScore: 361 return analysis.overallScore 362 case .keywordUsage: 363 return analysis.keywordScore 364 case .technicalSEO: 365 return analysis.technicalScore 366 case .contentQuality: 367 return analysis.contentScore 368 } 369 } 370 } 371 372 struct ChartDataPoint { 373 let site: String 374 let score: Double 375 let isYourSite: Bool 376 } 377 378 // MARK: - Competitor Detail Card 379 380 struct CompetitorDetailCard: View { 381 let analysis: CompetitorSEOAnalysis 382 383 var body: some View { 384 VStack(alignment: .leading, spacing: 16) { 385 HStack { 386 VStack(alignment: .leading, spacing: 4) { 387 Text(URL(string: analysis.url)?.host ?? analysis.url) 388 .font(.headline) 389 .fontWeight(.semibold) 390 391 Text(analysis.url) 392 .font(.caption) 393 .foregroundColor(.secondary) 394 .lineLimit(1) 395 } 396 397 Spacer() 398 399 VStack(alignment: .trailing, spacing: 4) { 400 Text("\(Int(analysis.overallScore))") 401 .font(.title2) 402 .fontWeight(.bold) 403 .foregroundColor(scoreColor(analysis.overallScore)) 404 405 Text("Overall Score") 406 .font(.caption) 407 .foregroundColor(.secondary) 408 } 409 } 410 411 // Score Breakdown 412 HStack(spacing: 20) { 413 ScoreBreakdownItem( 414 label: "Keywords", 415 score: analysis.keywordScore, 416 color: .blue 417 ) 418 419 ScoreBreakdownItem( 420 label: "Technical", 421 score: analysis.technicalScore, 422 color: .green 423 ) 424 425 ScoreBreakdownItem( 426 label: "Content", 427 score: analysis.contentScore, 428 color: .orange 429 ) 430 } 431 432 // Keywords Found 433 if !analysis.topKeywords.isEmpty { 434 VStack(alignment: .leading, spacing: 8) { 435 Text("Top Keywords") 436 .font(.subheadline) 437 .fontWeight(.medium) 438 439 LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 3), spacing: 8) { 440 ForEach(Array(analysis.topKeywords.prefix(6)), id: \.self) { keyword in 441 Text(keyword) 442 .font(.caption) 443 .padding(.horizontal, 8) 444 .padding(.vertical, 4) 445 .background(Color.accentColor.opacity(0.1)) 446 .foregroundColor(.accentColor) 447 .cornerRadius(4) 448 } 449 } 450 } 451 } 452 453 // Opportunities 454 if !analysis.opportunities.isEmpty { 455 VStack(alignment: .leading, spacing: 8) { 456 Text("Opportunities") 457 .font(.subheadline) 458 .fontWeight(.medium) 459 460 ForEach(Array(analysis.opportunities.prefix(3)), id: \.self) { opportunity in 461 HStack { 462 Image(systemName: "lightbulb.fill") 463 .foregroundColor(.yellow) 464 .font(.caption) 465 466 Text(opportunity) 467 .font(.caption) 468 .foregroundColor(.secondary) 469 470 Spacer() 471 } 472 } 473 } 474 } 475 } 476 .padding(16) 477 .background(Color(NSColor.textBackgroundColor)) 478 .cornerRadius(8) 479 } 480 481 private func scoreColor(_ score: Double) -> Color { 482 switch score { 483 case 80...: return .green 484 case 60..<80: return .orange 485 default: return .red 486 } 487 } 488 } 489 490 struct ScoreBreakdownItem: View { 491 let label: String 492 let score: Double 493 let color: Color 494 495 var body: some View { 496 VStack(spacing: 4) { 497 Text("\(Int(score))") 498 .font(.subheadline) 499 .fontWeight(.semibold) 500 .foregroundColor(color) 501 502 Text(label) 503 .font(.caption2) 504 .foregroundColor(.secondary) 505 } 506 } 507 } 508 509 // MARK: - Opportunities Section 510 511 struct OpportunitiesSection: View { 512 let analyses: [CompetitorSEOAnalysis] 513 let currentSite: SEOAnalysis? 514 515 var body: some View { 516 VStack(alignment: .leading, spacing: 16) { 517 Text("Strategic Opportunities") 518 .font(.headline) 519 .fontWeight(.semibold) 520 521 LazyVStack(spacing: 12) { 522 ForEach(strategicOpportunities, id: \.title) { opportunity in 523 OpportunityCard(opportunity: opportunity) 524 } 525 } 526 } 527 } 528 529 private var strategicOpportunities: [StrategicOpportunity] { 530 var opportunities: [StrategicOpportunity] = [] 531 532 // Analyze gaps and opportunities 533 let competitorKeywords = Set(analyses.flatMap { $0.topKeywords }) 534 let yourKeywords = Set(currentSite?.metrics.keywords.primaryKeywords.map(\.keyword) ?? []) 535 536 let missingKeywords = competitorKeywords.subtracting(yourKeywords) 537 if !missingKeywords.isEmpty { 538 opportunities.append(StrategicOpportunity( 539 title: "Keyword Gaps", 540 description: "Competitors rank for \(missingKeywords.count) keywords you don't target", 541 impact: "High", 542 effort: "Medium", 543 type: .keyword 544 )) 545 } 546 547 // Technical opportunities 548 let avgCompetitorTechnicalScore = analyses.map(\.technicalScore).averageValue 549 let yourTechnicalScore = currentSite?.metrics.technicalSEO.score ?? 0 550 551 if yourTechnicalScore < avgCompetitorTechnicalScore { 552 opportunities.append(StrategicOpportunity( 553 title: "Technical SEO Improvement", 554 description: "Your technical SEO score is below competitor average", 555 impact: "Medium", 556 effort: "Low", 557 type: .technical 558 )) 559 } 560 561 // Content opportunities 562 let avgCompetitorContentScore = analyses.map(\.contentScore).averageValue 563 let yourContentScore = currentSite?.metrics.headings.score ?? 0 564 565 if yourContentScore < avgCompetitorContentScore { 566 opportunities.append(StrategicOpportunity( 567 title: "Content Optimization", 568 description: "Improve content structure and keyword optimization", 569 impact: "High", 570 effort: "Medium", 571 type: .content 572 )) 573 } 574 575 return opportunities 576 } 577 } 578 579 struct OpportunityCard: View { 580 let opportunity: StrategicOpportunity 581 582 var body: some View { 583 HStack { 584 Image(systemName: opportunity.type.icon) 585 .foregroundColor(opportunity.type.color) 586 .font(.title3) 587 588 VStack(alignment: .leading, spacing: 4) { 589 Text(opportunity.title) 590 .font(.subheadline) 591 .fontWeight(.medium) 592 593 Text(opportunity.description) 594 .font(.caption) 595 .foregroundColor(.secondary) 596 } 597 598 Spacer() 599 600 VStack(alignment: .trailing, spacing: 4) { 601 Text("Impact: \(opportunity.impact)") 602 .font(.caption) 603 .foregroundColor(.green) 604 605 Text("Effort: \(opportunity.effort)") 606 .font(.caption) 607 .foregroundColor(.orange) 608 } 609 } 610 .padding(12) 611 .background(Color(NSColor.textBackgroundColor)) 612 .cornerRadius(8) 613 } 614 } 615 616 // MARK: - Data Models 617 618 struct CompetitorSEOAnalysis: Identifiable { 619 let id = UUID() 620 let url: String 621 let overallScore: Double 622 let keywordScore: Double 623 let technicalScore: Double 624 let contentScore: Double 625 let topKeywords: [String] 626 let opportunities: [String] 627 } 628 629 struct StrategicOpportunity { 630 let title: String 631 let description: String 632 let impact: String 633 let effort: String 634 let type: OpportunityType 635 } 636 637 enum OpportunityType { 638 case keyword 639 case technical 640 case content 641 642 var icon: String { 643 switch self { 644 case .keyword: return "key.fill" 645 case .technical: return "gearshape.fill" 646 case .content: return "doc.text.fill" 647 } 648 } 649 650 var color: Color { 651 switch self { 652 case .keyword: return .blue 653 case .technical: return .green 654 case .content: return .orange 655 } 656 } 657 } 658 659 // MARK: - Helper Functions 660 661 private func generateCompetitorSEOAnalysis(url: String, keywords: [String]) -> CompetitorSEOAnalysis { 662 let overallScore = Double.random(in: 45...95) 663 664 // Generate related keywords based on input 665 var topKeywords = keywords 666 topKeywords.append(contentsOf: [ 667 "digital marketing", 668 "online presence", 669 "search optimization", 670 "web traffic", 671 "ranking factors" 672 ].shuffled().dropLast(3)) 673 674 let opportunities = [ 675 "Improve meta descriptions", 676 "Add more internal links", 677 "Optimize image alt texts", 678 "Implement structured data", 679 "Enhance page loading speed", 680 "Create more long-form content" 681 ].shuffled().dropLast(2) 682 683 return CompetitorSEOAnalysis( 684 url: url, 685 overallScore: overallScore, 686 keywordScore: Double.random(in: 40...90), 687 technicalScore: Double.random(in: 50...95), 688 contentScore: Double.random(in: 45...85), 689 topKeywords: Array(topKeywords.shuffled().prefix(8)), 690 opportunities: Array(opportunities.dropLast(2)) 691 ) 692 } 693 694 extension Array where Element == Double { 695 var averageValue: Double { 696 guard !isEmpty else { return 0 } 697 return reduce(0, +) / Double(count) 698 } 699 } 700 701 #Preview { 702 CompetitorAnalysisView() 703 .environmentObject(SEOAnalysisService()) 704 }