Ver Fonte

Polish search bar and Indeed opens for a cleaner job browsing UX.

Use the official Indeed mark, always open listing URLs instead of search fallbacks, simplify saved-job labels, and fix Swift 6 isolation warnings.

Co-authored-by: Cursor <cursoragent@cursor.com>
faiz ali há 18 horas atrás
pai
commit
2660db9753

+ 16 - 0
indeed_ai_job_search/Assets.xcassets/IndeedLogo.imageset/Contents.json

@@ -0,0 +1,16 @@
+{
+  "images": [
+    {
+      "filename": "IndeedLogo.pdf",
+      "idiom": "universal"
+    }
+  ],
+  "info": {
+    "author": "xcode",
+    "version": 1
+  },
+  "properties": {
+    "preserves-vector-representation": true,
+    "template-rendering-intent": "original"
+  }
+}

BIN
indeed_ai_job_search/Assets.xcassets/IndeedLogo.imageset/IndeedLogo.pdf


+ 24 - 33
indeed_ai_job_search/DesignSystem/Components/JobBadges.swift

@@ -117,31 +117,25 @@ struct CompanyLogoView: View {
     let listing: JobListing
 
     var body: some View {
-        ZStack {
-            RoundedRectangle(cornerRadius: 10, style: .continuous)
-                .fill(logoBackground)
-            Image(systemName: listing.logoSymbol)
-                .font(.system(size: 18, weight: .semibold))
-                .foregroundStyle(logoForeground)
-        }
-        .frame(width: 44, height: 44)
-        .accessibilityHidden(true)
+        IndeedMarkView(size: 44, cornerRadius: 10)
+            .accessibilityHidden(true)
+            .opacity(listing.availability == .unavailable ? 0.45 : 1)
     }
+}
 
-    private var logoBackground: Color {
-        switch listing.company.lowercased() {
-        case "apple": return Color.black.opacity(0.08)
-        case "microsoft": return Color.blue.opacity(0.12)
-        default: return AppColors.brandSoft
-        }
-    }
+/// Official Indeed mark used on job rows (results + saved).
+struct IndeedMarkView: View {
+    var size: CGFloat = 28
+    /// Kept for call-site compatibility; the asset is already circular.
+    var cornerRadius: CGFloat = 8
 
-    private var logoForeground: Color {
-        switch listing.company.lowercased() {
-        case "apple": return AppColors.textPrimary
-        case "microsoft": return Color.blue
-        default: return AppColors.brand
-        }
+    var body: some View {
+        Image("IndeedLogo")
+            .resizable()
+            .interpolation(.high)
+            .scaledToFit()
+            .frame(width: size, height: size)
+            .accessibilityHidden(true)
     }
 }
 
@@ -166,22 +160,19 @@ struct SaveJobButton: View {
 struct OpenIndeedLinkButton: View {
     let listing: JobListing
 
-    private var title: String {
-        IndeedBrowseURLBuilder.isLikelyRealJobKey(listing.indeedJobKey)
-            ? "Open on Indeed"
-            : "Search on Indeed"
-    }
-
     var body: some View {
         Button {
             JobListingOpener.open(listing)
         } label: {
-            Label(title, systemImage: "arrow.up.right.square")
-                .font(AppTypography.caption)
-                .foregroundStyle(AppColors.brand)
+            HStack(spacing: 6) {
+                IndeedMarkView(size: 14)
+                Text("Open on Indeed")
+                    .font(AppTypography.caption)
+                    .foregroundStyle(AppColors.brand)
+            }
         }
         .buttonStyle(.plain)
-        .help("\(title): \(listing.title) at \(listing.company)")
-        .accessibilityLabel(title)
+        .help("Open on Indeed: \(listing.title) at \(listing.company)")
+        .accessibilityLabel("Open on Indeed")
     }
 }

+ 18 - 28
indeed_ai_job_search/DesignSystem/Components/JobRowView.swift

@@ -12,6 +12,10 @@ struct JobRowView: View {
     var onOpen: (() -> Void)? = nil
     var isCompact: Bool = false
     var showsCardChrome: Bool = true
+    /// When false, the inline “Open on Indeed” caption is omitted (e.g. saved jobs footer).
+    var showsOpenLink: Bool = true
+    /// When false, hides the relative posted date (“Just now”, “1 day ago”, …).
+    var showsPostedDate: Bool = true
 
     private var isUnavailable: Bool {
         listing.availability == .unavailable
@@ -28,7 +32,6 @@ struct JobRowView: View {
     var body: some View {
         HStack(alignment: .top, spacing: AppSpacing.md) {
             CompanyLogoView(listing: listing)
-                .opacity(isUnavailable ? 0.45 : 1)
 
             VStack(alignment: .leading, spacing: AppSpacing.xs) {
                 HStack(alignment: .top) {
@@ -62,15 +65,19 @@ struct JobRowView: View {
 
                     Spacer(minLength: 0)
 
-                    RelativeDateLabel(date: listing.postedAt)
+                    if showsPostedDate {
+                        RelativeDateLabel(date: listing.postedAt)
+                    }
                 }
                 .padding(.top, 2)
 
-                Text(openLinkTitle)
-                    .font(AppTypography.caption)
-                    .foregroundStyle(AppColors.brand)
-                    .padding(.top, 4)
-                    .accessibilityHidden(true)
+                if showsOpenLink {
+                    Text(openLinkTitle)
+                        .font(AppTypography.caption)
+                        .foregroundStyle(AppColors.brand)
+                        .padding(.top, 4)
+                        .accessibilityHidden(true)
+                }
             }
         }
         .padding(AppSpacing.md)
@@ -98,18 +105,9 @@ struct JobRowView: View {
         .accessibilityAction(named: openLinkTitle, openListing)
     }
 
-    /// Direct viewjob when we have a real Indeed `jk`; otherwise company-scoped search.
-    private var openLinkTitle: String {
-        IndeedBrowseURLBuilder.isLikelyRealJobKey(listing.indeedJobKey)
-            ? "Open on Indeed"
-            : "Search on Indeed"
-    }
+    private var openLinkTitle: String { "Open on Indeed" }
 
-    private var openAccessibilityHint: String {
-        IndeedBrowseURLBuilder.isLikelyRealJobKey(listing.indeedJobKey)
-            ? "Opens this job on Indeed"
-            : "Searches Indeed for this title at \(listing.company)"
-    }
+    private var openAccessibilityHint: String { "Opens this job on Indeed" }
 
     private var metaRow: some View {
         HStack(spacing: AppSpacing.sm) {
@@ -210,15 +208,7 @@ struct JobGridCardView: View {
         .accessibilityAction(named: openLinkTitle, openListing)
     }
 
-    private var openLinkTitle: String {
-        IndeedBrowseURLBuilder.isLikelyRealJobKey(listing.indeedJobKey)
-            ? "Open on Indeed"
-            : "Search on Indeed"
-    }
+    private var openLinkTitle: String { "Open on Indeed" }
 
-    private var openAccessibilityHint: String {
-        IndeedBrowseURLBuilder.isLikelyRealJobKey(listing.indeedJobKey)
-            ? "Opens this job on Indeed"
-            : "Searches Indeed for this title at \(listing.company)"
-    }
+    private var openAccessibilityHint: String { "Opens this job on Indeed" }
 }

+ 30 - 10
indeed_ai_job_search/DesignSystem/Components/SearchComposerView.swift

@@ -94,20 +94,21 @@ struct SearchComposerView: View {
     }
 
     private var compactBar: some View {
-        HStack(spacing: AppSpacing.sm) {
-            HStack(spacing: AppSpacing.xs) {
+        let barHeight: CGFloat = 42
+
+        return HStack(alignment: .center, spacing: AppSpacing.xs) {
+            HStack(spacing: 8) {
                 Image(systemName: "magnifyingglass")
+                    .font(.system(size: 14, weight: .medium))
                     .foregroundStyle(AppColors.textTertiary)
 
                 TextField(
                     "",
                     text: $query,
                     prompt: Text("Describe a job, e.g. iOS developer or Swift engineer in Lahore")
-                        .foregroundStyle(AppColors.textTertiary),
-                    axis: .vertical
+                        .foregroundStyle(AppColors.textTertiary)
                 )
                 .appPlainTextField()
-                .lineLimit(mode == .hero ? 2...4 : 1...2)
                 .focused($isFocused)
                 .onSubmit(onSubmit)
 
@@ -116,18 +117,19 @@ struct SearchComposerView: View {
                         query = ""
                     } label: {
                         Image(systemName: "xmark.circle.fill")
+                            .font(.system(size: 13))
                             .foregroundStyle(AppColors.textTertiary)
                     }
                     .buttonStyle(.plain)
                     .accessibilityLabel("Clear search")
                 }
             }
-            .padding(.horizontal, AppSpacing.md)
-            .padding(.vertical, 12)
+            .padding(.horizontal, 12)
+            .frame(maxHeight: .infinity)
             .background(AppColors.surface)
-            .clipShape(RoundedRectangle(cornerRadius: AppSpacing.inputRadius, style: .continuous))
+            .clipShape(RoundedRectangle(cornerRadius: AppSpacing.buttonRadius, style: .continuous))
             .overlay(
-                RoundedRectangle(cornerRadius: AppSpacing.inputRadius, style: .continuous)
+                RoundedRectangle(cornerRadius: AppSpacing.buttonRadius, style: .continuous)
                     .stroke(isFocused ? AppColors.brand : AppColors.borderStrong, lineWidth: isFocused ? 1.5 : 1)
             )
             .environment(\.colorScheme, .light)
@@ -145,11 +147,29 @@ struct SearchComposerView: View {
                     }
                 }
                 .frame(minWidth: 72)
+                .frame(maxHeight: .infinity)
             }
-            .buttonStyle(PrimaryButtonStyle())
+            .buttonStyle(CompactSearchButtonStyle())
             .disabled(query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSearching)
             .keyboardShortcut(.return, modifiers: .command)
         }
+        .frame(height: barHeight)
+    }
+}
+
+/// Matches the single-line search field height so the button stays aligned.
+private struct CompactSearchButtonStyle: ButtonStyle {
+    @Environment(\.isEnabled) private var isEnabled
+
+    func makeBody(configuration: Configuration) -> some View {
+        configuration.label
+            .font(AppTypography.button)
+            .foregroundStyle(AppColors.textOnBrand)
+            .padding(.horizontal, AppSpacing.md)
+            .frame(maxHeight: .infinity)
+            .background(isEnabled ? AppColors.brand : AppColors.brand.opacity(0.45))
+            .clipShape(RoundedRectangle(cornerRadius: AppSpacing.buttonRadius, style: .continuous))
+            .opacity(configuration.isPressed ? 0.88 : 1)
     }
 }
 

+ 2 - 2
indeed_ai_job_search/Domain/Enums/JobEnums.swift

@@ -79,13 +79,13 @@ nonisolated enum ExperienceLevel: String, CaseIterable, Identifiable, Codable, H
     }
 }
 
-enum JobAvailability: String, CaseIterable, Codable, Hashable {
+nonisolated enum JobAvailability: String, CaseIterable, Codable, Hashable, Sendable {
     case open
     case unknown
     case unavailable
 }
 
-enum SalaryPeriod: String, Codable, Hashable {
+nonisolated enum SalaryPeriod: String, Codable, Hashable, Sendable {
     case year
     case month
     case hour

+ 2 - 2
indeed_ai_job_search/Domain/Models/JobListing.swift

@@ -5,7 +5,7 @@
 
 import Foundation
 
-struct SalaryRange: Hashable, Codable {
+nonisolated struct SalaryRange: Hashable, Codable, Sendable {
     var min: Double?
     var max: Double?
     var currency: String
@@ -36,7 +36,7 @@ struct SalaryRange: Hashable, Codable {
     }
 }
 
-struct JobListing: Identifiable, Hashable, Codable {
+nonisolated struct JobListing: Identifiable, Hashable, Codable, Sendable {
     let id: UUID
     let indeedJobKey: String
     var title: String

+ 4 - 6
indeed_ai_job_search/Features/SavedJobs/Views/SavedJobsView.swift

@@ -268,11 +268,13 @@ struct SavedJobsView: View {
                 listing: saved.listing,
                 isSaved: true,
                 onToggleSave: { requestUnsave(saved.listing) },
-                showsCardChrome: false
+                showsCardChrome: false,
+                showsOpenLink: false,
+                showsPostedDate: false
             )
 
             HStack(spacing: AppSpacing.md) {
-                Label("Saved \(RelativeDateLabel.relativeString(from: saved.savedAt))", systemImage: "bookmark.fill")
+                Label("Saved", systemImage: "bookmark.fill")
                     .font(AppTypography.caption)
                     .foregroundStyle(AppColors.brand)
 
@@ -280,10 +282,6 @@ struct SavedJobsView: View {
                     Label("Unavailable", systemImage: "exclamationmark.triangle.fill")
                         .font(AppTypography.caption)
                         .foregroundStyle(AppColors.unavailable)
-                } else if let verified = saved.lastVerifiedAt {
-                    Text("Checked \(RelativeDateLabel.relativeString(from: verified))")
-                        .font(AppTypography.caption)
-                        .foregroundStyle(AppColors.textTertiary)
                 }
 
                 Spacer(minLength: 0)

+ 1 - 1
indeed_ai_job_search/Persistence/SavedJobsRevalidator.swift

@@ -10,7 +10,7 @@ import Foundation
 @MainActor
 final class SavedJobsRevalidator {
     /// Matches Phase 4 cache policy reserved for saved verification.
-    static let defaultStaleInterval: TimeInterval = 86_400
+    nonisolated static let defaultStaleInterval: TimeInterval = 86_400
 
     private let store: any SavedJobsStore
     private let validationService: JobValidationService

+ 29 - 29
indeed_ai_job_search/PreviewSupport/MockData/MockJobListings.swift

@@ -10,10 +10,10 @@ enum MockJobListings {
         let now = Date()
         let day: TimeInterval = 86_400
 
-        /// Interim demo URLs — real `jk` keys come from a licensed feed later.
-        /// Browse URLs include company so Open on Indeed does not land on a different employer.
-        func url(title: String, company: String, location: String) -> URL {
-            IndeedBrowseURLBuilder.searchURL(query: title, location: location, company: company)
+        /// Interim demo viewjob URLs — real `jk` keys come from live Indeed results.
+        func url(key: String, location: String) -> URL {
+            let host = IndeedBrowseURLBuilder.regionalHost(for: location, preferredHost: nil)
+            return URL(string: "https://\(host)/viewjob?jk=\(key)")!
         }
 
         return [
@@ -28,7 +28,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 160_000, max: 220_000, currency: "USD", period: .year),
                 postedAt: now.addingTimeInterval(-day),
                 snippet: "Build delightful iOS experiences with SwiftUI and UIKit for millions of users.",
-                indeedURL: url(title: "Senior iOS Developer", company: "Apple", location: "Cupertino, CA"),
+                indeedURL: url(key: "ios001", location: "Cupertino, CA"),
                 isEasyApply: true,
                 isFeatured: true,
                 logoSymbol: "apple.logo"
@@ -44,7 +44,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 120_000, max: 160_000, currency: "USD", period: .year),
                 postedAt: now.addingTimeInterval(-2 * day),
                 snippet: "Join a distributed team shipping mobile productivity apps on iOS.",
-                indeedURL: url(title: "iOS Developer", company: "Microsoft", location: "United States"),
+                indeedURL: url(key: "ios002", location: "United States"),
                 isEasyApply: true,
                 isUrgentlyHiring: true,
                 logoSymbol: "square.grid.2x2.fill"
@@ -60,7 +60,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 1_200_000, max: 2_000_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-3 * day),
                 snippet: "Develop and maintain customer-facing iOS applications.",
-                indeedURL: url(title: "Mobile Software Engineer (iOS)", company: "Alorica", location: "Karachi, Pakistan"),
+                indeedURL: url(key: "ios003", location: "Karachi, Pakistan"),
                 isEasyApply: true,
                 logoSymbol: "phone.fill"
             ),
@@ -75,7 +75,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 2_400_000, max: 3_800_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-4 * day),
                 snippet: "Own features end-to-end for the Careem rider and captain apps.",
-                indeedURL: url(title: "iOS Engineer", company: "Careem", location: "Lahore, Pakistan"),
+                indeedURL: url(key: "ios004", location: "Lahore, Pakistan"),
                 isFeatured: true,
                 logoSymbol: "car.fill"
             ),
@@ -90,7 +90,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 3_200_000, max: 5_000_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-5 * day),
                 snippet: "Lead iOS architecture and mentor a growing mobile team.",
-                indeedURL: url(title: "Senior Mobile Developer", company: "Bykea", location: "Karachi, Pakistan"),
+                indeedURL: url(key: "ios005", location: "Karachi, Pakistan"),
                 isEasyApply: true,
                 isUrgentlyHiring: true,
                 logoSymbol: "bicycle"
@@ -106,7 +106,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 2_800_000, max: 4_200_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-6 * day),
                 snippet: "Ship modern SwiftUI interfaces for enterprise AI products.",
-                indeedURL: url(title: "SwiftUI Developer", company: "Afiniti", location: "Islamabad, Pakistan"),
+                indeedURL: url(key: "ios006", location: "Islamabad, Pakistan"),
                 isEasyApply: true,
                 logoSymbol: "sparkles"
             ),
@@ -121,7 +121,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 900_000, max: 1_500_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-7 * day),
                 snippet: "Learn production iOS development with senior mentors.",
-                indeedURL: url(title: "Junior iOS Developer", company: "Systems Limited", location: "Lahore, Pakistan"),
+                indeedURL: url(key: "ios007", location: "Lahore, Pakistan"),
                 isEasyApply: true,
                 logoSymbol: "desktopcomputer"
             ),
@@ -137,7 +137,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 35, max: 55, currency: "USD", period: .hour),
                 postedAt: now.addingTimeInterval(-8 * day),
                 snippet: "6-month contract building a consumer fintech iOS app.",
-                indeedURL: url(title: "iOS Contractor", company: "Remote Team Co", location: "Pakistan"),
+                indeedURL: url(key: "ios008", location: "Pakistan"),
                 isFeatured: true,
                 logoSymbol: "globe"
             ),
@@ -152,7 +152,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 4_000_000, max: 6_000_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-9 * day),
                 snippet: "Drive mobile platform strategy for Pakistan’s top marketplace.",
-                indeedURL: url(title: "Lead iOS Engineer", company: "Daraz", location: "Karachi, Pakistan"),
+                indeedURL: url(key: "ios009", location: "Karachi, Pakistan"),
                 isEasyApply: true,
                 isFeatured: true,
                 logoSymbol: "cart.fill"
@@ -168,7 +168,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 600_000, max: 1_000_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-10 * day),
                 snippet: "Part-time role improving an education app for students.",
-                indeedURL: url(title: "iOS Developer (Part-time)", company: "EduTech PK", location: "Pakistan"),
+                indeedURL: url(key: "ios010", location: "Pakistan"),
                 logoSymbol: "book.fill"
             ),
             JobListing(
@@ -182,7 +182,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 90_000, max: 130_000, currency: "USD", period: .year),
                 postedAt: now.addingTimeInterval(-1.5 * day),
                 snippet: "Build native Apple platform tools with Swift concurrency.",
-                indeedURL: url(title: "macOS / iOS Engineer", company: "Cursor Labs", location: "United States"),
+                indeedURL: url(key: "ios011", location: "United States"),
                 isEasyApply: true,
                 isFeatured: true,
                 logoSymbol: "laptopcomputer"
@@ -198,7 +198,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 2_200_000, max: 3_400_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-11 * day),
                 snippet: "Secure mobile payments experience for millions of users.",
-                indeedURL: url(title: "iOS Developer", company: "JazzCash", location: "Islamabad, Pakistan"),
+                indeedURL: url(key: "ios012", location: "Islamabad, Pakistan"),
                 isUrgentlyHiring: true,
                 logoSymbol: "creditcard.fill"
             ),
@@ -214,7 +214,7 @@ enum MockJobListings {
                 salary: nil,
                 postedAt: now.addingTimeInterval(-12 * day),
                 snippet: "Client projects spanning healthcare and fintech iOS apps.",
-                indeedURL: url(title: "Swift Developer", company: "10Pearls", location: "Karachi, Pakistan"),
+                indeedURL: url(key: "ios013", location: "Karachi, Pakistan"),
                 isEasyApply: true,
                 logoSymbol: "hammer.fill"
             ),
@@ -229,7 +229,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 40_000, max: 70_000, currency: "PKR", period: .month),
                 postedAt: now.addingTimeInterval(-2 * day),
                 snippet: "Paid internship focused on Swift and SwiftUI fundamentals.",
-                indeedURL: url(title: "iOS Intern", company: "Arbisoft", location: "Lahore, Pakistan"),
+                indeedURL: url(key: "ios014", location: "Lahore, Pakistan"),
                 logoSymbol: "graduationcap.fill"
             ),
             JobListing(
@@ -243,7 +243,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 3_500_000, max: 5_500_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-0.5 * day),
                 snippet: "Remote-first team building consumer apps for global clients.",
-                indeedURL: url(title: "Senior iOS Developer", company: "Emumba", location: "Islamabad, Pakistan"),
+                indeedURL: url(key: "ios015", location: "Islamabad, Pakistan"),
                 isEasyApply: true,
                 isFeatured: true,
                 logoSymbol: "antenna.radiowaves.left.and.right"
@@ -259,7 +259,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 2_000_000, max: 3_200_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-14 * day),
                 snippet: "Improve ordering flows and performance on iOS.",
-                indeedURL: url(title: "Mobile Engineer", company: "Foodpanda", location: "Lahore, Pakistan"),
+                indeedURL: url(key: "ios016", location: "Lahore, Pakistan"),
                 logoSymbol: "fork.knife"
             ),
             JobListing(
@@ -273,7 +273,7 @@ enum MockJobListings {
                 salary: nil,
                 postedAt: now.addingTimeInterval(-30 * day),
                 snippet: "This listing is no longer accepting applications.",
-                indeedURL: url(title: "iOS Developer (Unavailable)", company: "Closed Co", location: "Pakistan"),
+                indeedURL: url(key: "ios017", location: "Pakistan"),
                 availability: .unavailable,
                 logoSymbol: "xmark.circle.fill"
             ),
@@ -288,7 +288,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 25, max: 35, currency: "USD", period: .hour),
                 postedAt: now.addingTimeInterval(-3.5 * day),
                 snippet: "Short-term engagement supporting crash triage and hotfixes.",
-                indeedURL: url(title: "Temporary iOS Support Engineer", company: "Helpdesk Inc", location: "Pakistan"),
+                indeedURL: url(key: "ios018", location: "Pakistan"),
                 isEasyApply: true,
                 logoSymbol: "wrench.and.screwdriver.fill"
             ),
@@ -303,7 +303,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 5_500_000, max: 8_000_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-15 * day),
                 snippet: "Define multi-app mobile architecture and CI/CD standards.",
-                indeedURL: url(title: "Principal iOS Architect", company: "Netsol", location: "Lahore, Pakistan"),
+                indeedURL: url(key: "ios019", location: "Lahore, Pakistan"),
                 isFeatured: true,
                 logoSymbol: "building.columns.fill"
             ),
@@ -318,7 +318,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 1_800_000, max: 2_800_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-4.5 * day),
                 snippet: "Collaborate with product and design on high-quality apps.",
-                indeedURL: url(title: "iOS Developer", company: "VentureDive", location: "Karachi, Pakistan"),
+                indeedURL: url(key: "ios020", location: "Karachi, Pakistan"),
                 isEasyApply: true,
                 isUrgentlyHiring: true,
                 logoSymbol: "person.3.fill"
@@ -334,7 +334,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 2_200_000, max: 3_600_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-6.5 * day),
                 snippet: "Hybrid role with native iOS modules and React Native.",
-                indeedURL: url(title: "React Native / iOS Engineer", company: "Confiz", location: "Lahore, Pakistan"),
+                indeedURL: url(key: "ios021", location: "Lahore, Pakistan"),
                 logoSymbol: "arrow.triangle.2.circlepath"
             ),
             JobListing(
@@ -348,7 +348,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 1_600_000, max: 2_500_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-7.5 * day),
                 snippet: "Automate XCUITest suites for mobile release quality.",
-                indeedURL: url(title: "iOS QA Automation Engineer", company: "Folio3", location: "Karachi, Pakistan"),
+                indeedURL: url(key: "ios022", location: "Karachi, Pakistan"),
                 isEasyApply: true,
                 logoSymbol: "checkmark.seal.fill"
             ),
@@ -363,7 +363,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 4_500_000, max: 7_000_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-0.8 * day),
                 snippet: "Own fleet-management mobile experiences at scale.",
-                indeedURL: url(title: "Staff iOS Engineer", company: "KeepTruckin", location: "Islamabad, Pakistan"),
+                indeedURL: url(key: "ios023", location: "Islamabad, Pakistan"),
                 isEasyApply: true,
                 isFeatured: true,
                 logoSymbol: "truck.box.fill"
@@ -379,7 +379,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 1_000_000, max: 1_800_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-9.5 * day),
                 snippet: "Work on game-adjacent and consumer product iOS apps.",
-                indeedURL: url(title: "iOS Developer", company: "Tintash", location: "Lahore, Pakistan"),
+                indeedURL: url(key: "ios024", location: "Lahore, Pakistan"),
                 logoSymbol: "gamecontroller.fill"
             ),
             JobListing(
@@ -393,7 +393,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 2_500_000, max: 4_000_000, currency: "PKR", period: .year),
                 postedAt: now.addingTimeInterval(-1.2 * day),
                 snippet: "Fully remote role building banking apps with Swift.",
-                indeedURL: url(title: "Remote iOS Developer", company: "Global Soft", location: "Pakistan"),
+                indeedURL: url(key: "ios025", location: "Pakistan"),
                 isEasyApply: true,
                 isFeatured: true,
                 isUrgentlyHiring: true,

+ 17 - 29
indeed_ai_job_search/Services/Indeed/IndeedBrowseURLBuilder.swift

@@ -2,9 +2,8 @@
 //  IndeedBrowseURLBuilder.swift
 //  indeed_ai_job_search
 //
-//  Builds openable Indeed URLs. Fake / fixture `jk=` values always 404 on Indeed;
-//  until a licensed feed supplies real keys, open a live Indeed search for the
-//  role + company (not a title-only search that lands on unrelated employers).
+//  Builds openable Indeed listing URLs. Always opens a job page (viewjob / stored
+//  Indeed URL) — never rewrites the open action into an Indeed search.
 //
 
 import Foundation
@@ -17,25 +16,11 @@ enum IndeedBrowseURLBuilder {
         return trimmed.allSatisfy { $0.isHexDigit }
     }
 
-    /// URL that should open a useful Indeed page for this listing.
+    /// URL that opens this listing on Indeed (never a `/jobs` search rewrite).
     static func openableURL(for listing: JobListing) -> (url: URL, usedBrowseFallback: Bool, reason: String?) {
         let key = listing.indeedJobKey
         let stored = listing.indeedURL
 
-        if listing.availability == .unavailable {
-            let browse = searchURL(
-                query: listing.title,
-                location: listing.location,
-                company: listing.company,
-                preferredHost: stored.host
-            )
-            return (
-                browse,
-                true,
-                "Listing marked unavailable (key=\(key)); opening Indeed search for title+company instead of expired viewjob."
-            )
-        }
-
         if isLikelyRealJobKey(key) {
             if stored.path.lowercased().contains("viewjob") {
                 return (stored, false, nil)
@@ -50,18 +35,21 @@ enum IndeedBrowseURLBuilder {
             }
         }
 
-        // Fixture / mock keys (`ios001`, `fixture-…`) are not real Indeed jobs.
-        // Always rebuild with company so the browser does not show a different employer.
-        let browse = searchURL(
-            query: listing.title,
-            location: listing.location,
-            company: listing.company,
-            preferredHost: stored.host
-        )
+        // Prefer the stored Indeed URL as-is (viewjob or source URL). Do not invent a search.
+        if IndeedURLCanonicalizer.isIndeedHost(stored.host ?? "") {
+            return (stored, false, nil)
+        }
+
+        var components = URLComponents()
+        components.scheme = "https"
+        components.host = regionalHost(for: listing.location, preferredHost: stored.host)
+        components.path = "/viewjob"
+        components.queryItems = [URLQueryItem(name: "jk", value: key)]
+        let fallback = components.url ?? URL(string: "https://www.indeed.com")!
         return (
-            browse,
-            true,
-            "Job key “\(key)” is not a real Indeed jk (expected 16-char hex). Opening live Indeed search for “\(listing.title)” at “\(listing.company)” (not a direct viewjob)."
+            fallback,
+            false,
+            "Stored URL was not an Indeed host; opening viewjob for key=\(key)."
         )
     }
 

+ 2 - 7
indeed_ai_job_search/Services/Indeed/IndeedListingMapper.swift

@@ -60,7 +60,7 @@ enum IndeedListingMapper {
             throw IndeedDataError.invalidResponse("Missing title or company for job key \(key).")
         }
 
-        // Real 16-char hex jk → always open that viewjob. Fake/fixture keys → company search.
+        // Prefer a direct viewjob URL when we have a real jk; otherwise keep the source URL.
         let openURL: URL
         if IndeedBrowseURLBuilder.isLikelyRealJobKey(key) {
             if canonical.path.lowercased().contains("viewjob") {
@@ -74,12 +74,7 @@ enum IndeedListingMapper {
                 openURL = components.url ?? canonical
             }
         } else {
-            openURL = IndeedBrowseURLBuilder.searchURL(
-                query: title,
-                location: location,
-                company: company,
-                preferredHost: canonical.host
-            )
+            openURL = canonical
         }
 
         return JobListing(

+ 2 - 3
indeed_ai_job_search/Services/Indeed/JobListingOpener.swift

@@ -13,14 +13,14 @@ enum JobListingOpener {
         category: "JobOpen"
     )
 
-    /// Opens the listing on Indeed (real viewjob when possible; otherwise company-scoped live search).
+    /// Opens the listing on Indeed (always the job URL — never an Indeed search rewrite).
     @discardableResult
     static func open(_ listing: JobListing) -> URL {
         let resolved = IndeedBrowseURLBuilder.openableURL(for: listing)
         let salarySummary = listing.salary?.formatted ?? "(none)"
         let fidelity = IndeedBrowseURLBuilder.isLikelyRealJobKey(listing.indeedJobKey)
             ? "real-jk"
-            : "browse-fallback"
+            : "stored-url"
 
         log.info(
             """
@@ -36,7 +36,6 @@ enum JobListingOpener {
               availability=\(listing.availability.rawValue, privacy: .public)
               storedURL=\(listing.indeedURL.absoluteString, privacy: .public)
               openURL=\(resolved.url.absoluteString, privacy: .public)
-              usedBrowseFallback=\(resolved.usedBrowseFallback, privacy: .public)
             """
         )
 

+ 1 - 7
indeed_ai_job_searchTests/IndeedListingMapperTests.swift

@@ -85,15 +85,9 @@ final class IndeedListingMapperTests: XCTestCase {
     func testMapsBrowseURLWhenProviderKeyMissing() throws {
         let page = try IndeedListingMapper.decodeFixture(from: Data(searchPage0JSON.utf8))
         let android = try XCTUnwrap(page.listings.first { $0.title.contains("Android") })
-        // No provider key + browse URL → stable hash key, openable /jobs URL.
+        // No provider key → stable hash key; keep the source Indeed URL (no search rewrite).
         XCTAssertTrue(android.indeedJobKey.hasPrefix("url-"))
-        XCTAssertTrue(android.indeedURL.path.contains("/jobs"))
         XCTAssertTrue(IndeedURLCanonicalizer.isIndeedHost(android.indeedURL.host ?? ""))
-        let q = URLComponents(url: android.indeedURL, resolvingAgainstBaseURL: false)?
-            .queryItems?
-            .first { $0.name == "q" }?
-            .value ?? ""
-        XCTAssertTrue(q.contains("company:\"Karachi Labs\"") || q.contains("company:Karachi"), q)
         XCTAssertNil(android.salary, "Salary omitted when currency/amounts are not provided")
     }
 

+ 4 - 11
indeed_ai_job_searchTests/JobSearchPipelineTests.swift

@@ -117,7 +117,7 @@ final class SearchCriteriaMergeTests: XCTestCase {
 }
 
 final class IndeedBrowseURLBuilderTests: XCTestCase {
-    func testFakeJobKeyUsesBrowseSearchURL() {
+    func testFakeJobKeyOpensStoredIndeedURL() {
         let listing = JobListing(
             indeedJobKey: "fixture-ios-001",
             title: "iOS Developer",
@@ -130,17 +130,10 @@ final class IndeedBrowseURLBuilderTests: XCTestCase {
             availability: .open
         )
         let resolved = IndeedBrowseURLBuilder.openableURL(for: listing)
-        XCTAssertTrue(resolved.usedBrowseFallback)
-        XCTAssertTrue(resolved.url.path.contains("/jobs"))
+        XCTAssertFalse(resolved.usedBrowseFallback)
         XCTAssertEqual(resolved.url.host, "pk.indeed.com")
-        XCTAssertFalse(resolved.url.absoluteString.contains("viewjob"))
-        // Company must be in `q` so Indeed does not show an unrelated employer.
-        let q = URLComponents(url: resolved.url, resolvingAgainstBaseURL: false)?
-            .queryItems?
-            .first { $0.name == "q" }?
-            .value ?? ""
-        XCTAssertTrue(q.contains("company:Acme"), "expected company filter in q, got \(q)")
-        XCTAssertTrue(q.contains("iOS Developer"))
+        XCTAssertTrue(resolved.url.absoluteString.contains("viewjob"))
+        XCTAssertFalse(resolved.url.path.contains("/jobs"))
     }
 
     func testSearchQueryQuotesMultiWordCompany() {