Эх сурвалжийг харах

Add StoreKit 2 subscriptions, Pro gating, and local StoreKit config

- Introduce SubscriptionStore, product IDs, and transaction handling
- Gate AI job search behind active subscription; refresh entitlements on launch
- Wire premium paywall: purchase, restore, manage links, sheet dismiss on success
- Add ProSubscriptions.storekit and link it in the shared run scheme
- Improve purchase error messages with App Store Connect and scheme guidance

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 2 сар өмнө
parent
commit
17d196b896

+ 82 - 0
App for Indeed.xcodeproj/xcshareddata/xcschemes/App for Indeed.xcscheme

@@ -0,0 +1,82 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+   LastUpgradeVersion = "2640"
+   version = "1.7">
+   <BuildAction
+      parallelizeBuildables = "YES"
+      buildImplicitDependencies = "YES"
+      buildArchitectures = "Automatic">
+      <BuildActionEntries>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "27D8527F2FB1D367008DF557"
+               BuildableName = "App for Indeed.app"
+               BlueprintName = "App for Indeed"
+               ReferencedContainer = "container:App for Indeed.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+      </BuildActionEntries>
+   </BuildAction>
+   <TestAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      shouldAutocreateTestPlan = "YES">
+   </TestAction>
+   <LaunchAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      launchStyle = "0"
+      useCustomWorkingDirectory = "NO"
+      ignoresPersistentStateOnLaunch = "NO"
+      debugDocumentVersioning = "YES"
+      debugServiceExtension = "internal"
+      allowLocationSimulation = "YES"
+      queueDebuggingEnableBacktraceRecording = "Yes">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "27D8527F2FB1D367008DF557"
+            BuildableName = "App for Indeed.app"
+            BlueprintName = "App for Indeed"
+            ReferencedContainer = "container:App for Indeed.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+      <StoreKitConfigurationFileReference
+         identifier = "../../App for Indeed/ProSubscriptions.storekit">
+      </StoreKitConfigurationFileReference>
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Release"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "27D8527F2FB1D367008DF557"
+            BuildableName = "App for Indeed.app"
+            BlueprintName = "App for Indeed"
+            ReferencedContainer = "container:App for Indeed.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

+ 4 - 0
App for Indeed/AppDelegate.swift

@@ -18,6 +18,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
     }
 
     func applicationDidFinishLaunching(_ aNotification: Notification) {
+        Task { @MainActor in
+            await SubscriptionStore.shared.refreshEntitlements()
+            NotificationCenter.default.post(name: .subscriptionStatusDidChange, object: nil)
+        }
         NSApp.activate(ignoringOtherApps: true)
         DispatchQueue.main.async { [weak self] in
             guard

+ 198 - 13
App for Indeed/Controllers/PremiumPlansWindowController.swift

@@ -1,4 +1,5 @@
 import Cocoa
+import StoreKit
 
 final class PremiumPlansWindowController: NSWindowController {
     init() {
@@ -129,6 +130,11 @@ private final class PremiumPlansViewController: NSViewController {
         static let edgeInsets = NSEdgeInsets(top: 21, left: 37, bottom: 21, right: 0)
     }
 
+    private let subscriptionStore = SubscriptionStore.shared
+    private var planPriceFields: [String: (price: NSTextField, period: NSTextField)] = [:]
+    private var planPurchaseButtons: [String: NSButton] = [:]
+    private var subscriptionStatusObservation: NSObjectProtocol?
+
     private let plans: [Plan] = [
         Plan(
             id: "weekly",
@@ -190,6 +196,28 @@ private final class PremiumPlansViewController: NSViewController {
     ]
 
     private let pageGradient = CAGradientLayer()
+
+    deinit {
+        if let subscriptionStatusObservation {
+            NotificationCenter.default.removeObserver(subscriptionStatusObservation)
+        }
+    }
+
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        subscriptionStatusObservation = NotificationCenter.default.addObserver(
+            forName: .subscriptionStatusDidChange,
+            object: nil,
+            queue: .main
+        ) { [weak self] _ in
+            Task { @MainActor in
+                await self?.subscriptionStore.loadProducts()
+                self?.applyStorePricing()
+            }
+        }
+        Task { await loadStoreProducts() }
+    }
+
     override func viewDidLayout() {
         super.viewDidLayout()
         pageGradient.frame = view.bounds
@@ -346,6 +374,8 @@ private final class PremiumPlansViewController: NSViewController {
 
         let selectButton = NSButton(title: "Get \(plan.title)", target: self, action: #selector(didTapSelectPlan))
         selectButton.identifier = NSUserInterfaceItemIdentifier(plan.id)
+        planPurchaseButtons[plan.id] = selectButton
+        planPriceFields[plan.id] = (priceLabel, periodLabel)
         selectButton.isBordered = false
         selectButton.bezelStyle = .rounded
         selectButton.font = .systemFont(ofSize: 14, weight: .semibold)
@@ -476,16 +506,19 @@ private final class PremiumPlansViewController: NSViewController {
     }
 
     private func makeFooterRow() -> NSView {
-        let items = [
-            "Manage Subscription",
-            "Restore Purchase",
-            "Privacy Policy",
-            "Terms of Services",
-            "Support"
+        let entries: [(text: String, action: Selector?)] = [
+            ("Manage Subscription", #selector(didTapManageSubscription)),
+            ("Restore Purchase", #selector(didTapRestorePurchases)),
+            ("Privacy Policy", nil),
+            ("Terms of Services", nil),
+            ("Support", nil)
         ]
 
-        let cells = items.enumerated().map { index, text in
-            footerCell(text: text, showsTrailingDivider: index < items.count - 1)
+        let cells = entries.enumerated().map { index, entry in
+            if let action = entry.action {
+                return footerActionCell(title: entry.text, action: action, showsTrailingDivider: index < entries.count - 1)
+            }
+            return footerCell(text: entry.text, showsTrailingDivider: index < entries.count - 1)
         }
 
         let links = NSStackView(views: cells)
@@ -497,6 +530,37 @@ private final class PremiumPlansViewController: NSViewController {
         return links
     }
 
+    private func footerActionCell(title: String, action: Selector, showsTrailingDivider: Bool) -> NSView {
+        let container = NSView()
+        container.translatesAutoresizingMaskIntoConstraints = false
+
+        let button = NSButton(title: title, target: self, action: action)
+        button.isBordered = false
+        button.bezelStyle = .rounded
+        button.font = .systemFont(ofSize: 12, weight: .medium)
+        button.contentTintColor = Theme.secondaryText
+        button.focusRingType = .none
+        button.translatesAutoresizingMaskIntoConstraints = false
+        container.addSubview(button)
+
+        var constraints: [NSLayoutConstraint] = [
+            button.centerXAnchor.constraint(equalTo: container.centerXAnchor),
+            button.centerYAnchor.constraint(equalTo: container.centerYAnchor)
+        ]
+
+        if showsTrailingDivider {
+            let divider = footerDivider()
+            container.addSubview(divider)
+            constraints.append(contentsOf: [
+                divider.trailingAnchor.constraint(equalTo: container.trailingAnchor),
+                divider.centerYAnchor.constraint(equalTo: container.centerYAnchor)
+            ])
+        }
+
+        NSLayoutConstraint.activate(constraints)
+        return container
+    }
+
     private func footerCell(text: String, showsTrailingDivider: Bool) -> NSView {
         let container = NSView()
         container.translatesAutoresizingMaskIntoConstraints = false
@@ -571,13 +635,129 @@ private final class PremiumPlansViewController: NSViewController {
     }
 
     @objc private func didTapSelectPlan(_ sender: NSButton) {
-        sender.layer?.backgroundColor = Theme.accentHover.cgColor
-        let selectedPlan = sender.identifier?.rawValue ?? sender.title
+        guard let planKey = sender.identifier?.rawValue else { return }
+        Task { await purchasePlan(planKey: planKey) }
+    }
+
+    @objc private func didTapManageSubscription() {
+        guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
+        NSWorkspace.shared.open(url)
+    }
+
+    @objc private func didTapRestorePurchases() {
+        Task { await restorePurchases() }
+    }
+
+    private func loadStoreProducts() async {
+        await subscriptionStore.loadProducts()
+        applyStorePricing()
+    }
+
+    private func applyStorePricing() {
+        for plan in plans {
+            guard let fields = planPriceFields[plan.id],
+                  let product = subscriptionStore.product(forPlanKey: plan.id) else { continue }
+            fields.price.stringValue = product.displayPrice
+            if let period = product.subscription?.subscriptionPeriod {
+                fields.period.stringValue = periodSuffix(for: period)
+            }
+        }
+    }
+
+    private func periodSuffix(for period: Product.SubscriptionPeriod) -> String {
+        let value = period.value
+        switch period.unit {
+        case .day: return value == 1 ? "/ day" : "/ \(value) days"
+        case .week: return value == 1 ? "/ week" : "/ \(value) weeks"
+        case .month: return value == 1 ? "/ month" : "/ \(value) months"
+        case .year: return value == 1 ? "/ year" : "/ \(value) years"
+        @unknown default: return ""
+        }
+    }
+
+    private func setPurchasing(_ isPurchasing: Bool) {
+        for button in planPurchaseButtons.values {
+            button.isEnabled = !isPurchasing
+        }
+    }
+
+    private func purchasePlan(planKey: String) async {
+        setPurchasing(true)
+        defer { setPurchasing(false) }
+        do {
+            let completed = try await subscriptionStore.purchase(planKey: planKey)
+            guard completed else { return }
+            let alert = NSAlert()
+            alert.messageText = "You're subscribed"
+            alert.informativeText = "Thank you — Pro features are now available."
+            alert.alertStyle = .informational
+            alert.addButton(withTitle: "OK")
+            if let window = view.window {
+                alert.beginSheetModal(for: window) { [weak self] _ in
+                    self?.dismissPremiumSheetFromParentIfNeeded()
+                }
+            } else {
+                alert.runModal()
+                dismissPremiumSheetFromParentIfNeeded()
+            }
+        } catch {
+            await MainActor.run {
+                self.presentPurchaseError(error)
+            }
+        }
+    }
 
+    private func restorePurchases() async {
+        setPurchasing(true)
+        defer { setPurchasing(false) }
+        do {
+            try await subscriptionStore.restorePurchases()
+            let active = subscriptionStore.isProActive
+            let alert = NSAlert()
+            if active {
+                alert.messageText = "Purchases restored"
+                alert.informativeText = "Your subscription is active."
+            } else {
+                alert.messageText = "No subscription found"
+                alert.informativeText = "There was nothing to restore for this Apple ID."
+            }
+            alert.alertStyle = .informational
+            alert.addButton(withTitle: "OK")
+            if let window = view.window {
+                alert.beginSheetModal(for: window) { [weak self] _ in
+                    if active {
+                        self?.dismissPremiumSheetFromParentIfNeeded()
+                    }
+                }
+            } else {
+                alert.runModal()
+                if active {
+                    dismissPremiumSheetFromParentIfNeeded()
+                }
+            }
+        } catch {
+            await MainActor.run {
+                self.presentPurchaseError(error)
+            }
+        }
+    }
+
+    private func presentPurchaseError(_ error: Error) {
         let alert = NSAlert()
-        alert.messageText = "Premium checkout coming soon"
-        alert.informativeText = "Plan selected: \(selectedPlan.capitalized). Payment flow can be connected next."
-        alert.alertStyle = .informational
+        alert.messageText = "Something went wrong"
+        if let localized = error as? LocalizedError {
+            var parts: [String] = []
+            if let description = localized.errorDescription {
+                parts.append(description)
+            }
+            if let recovery = localized.recoverySuggestion {
+                parts.append(recovery)
+            }
+            alert.informativeText = parts.isEmpty ? error.localizedDescription : parts.joined(separator: "\n\n")
+        } else {
+            alert.informativeText = error.localizedDescription
+        }
+        alert.alertStyle = .warning
         alert.addButton(withTitle: "OK")
         if let window = view.window {
             alert.beginSheetModal(for: window)
@@ -586,6 +766,11 @@ private final class PremiumPlansViewController: NSViewController {
         }
     }
 
+    private func dismissPremiumSheetFromParentIfNeeded() {
+        guard let sheet = view.window, let parent = sheet.sheetParent else { return }
+        parent.endSheet(sheet)
+    }
+
     @objc private func didTapClose() {
         guard let window = view.window else { return }
         if let parent = window.sheetParent {

+ 123 - 0
App for Indeed/ProSubscriptions.storekit

@@ -0,0 +1,123 @@
+{
+  "appPolicies" : {
+    "eula" : "",
+    "policies" : [
+      {
+        "locale" : "en_US",
+        "policyText" : "",
+        "policyURL" : ""
+      }
+    ]
+  },
+  "identifier" : "ProSubscriptions",
+  "nonRenewingSubscriptions" : [
+
+  ],
+  "products" : [
+
+  ],
+  "settings" : {
+    "_failTransactionsEnabled" : false,
+    "_locale" : "en_US",
+    "_storefront" : "USA",
+    "_storeKitErrors" : [
+
+    ],
+    "_timeRate" : 0
+  },
+  "subscriptionGroups" : [
+    {
+      "id" : "21829751",
+      "localizations" : [
+
+      ],
+      "name" : "Indeed AI Pro",
+      "subscriptions" : [
+        {
+          "adHocOffers" : [
+
+          ],
+          "codeOffers" : [
+
+          ],
+          "displayPrice" : "9.99",
+          "familyShareable" : false,
+          "groupNumber" : 1,
+          "internalID" : "A1000001",
+          "introductoryOffer" : null,
+          "localizations" : [
+            {
+              "description" : "All premium features. Cancel anytime.",
+              "displayName" : "Weekly Pro",
+              "locale" : "en_US"
+            }
+          ],
+          "productID" : "com.mqldev.appforindeed.pro.weekly",
+          "recurringSubscriptionPeriod" : "P1W",
+          "referenceName" : "Weekly Pro",
+          "subscriptionGroupID" : "21829751",
+          "type" : "RecurringSubscription"
+        },
+        {
+          "adHocOffers" : [
+
+          ],
+          "codeOffers" : [
+
+          ],
+          "displayPrice" : "19.99",
+          "familyShareable" : false,
+          "groupNumber" : 2,
+          "internalID" : "A1000002",
+          "introductoryOffer" : null,
+          "localizations" : [
+            {
+              "description" : "All premium features and priority support.",
+              "displayName" : "Monthly Pro",
+              "locale" : "en_US"
+            }
+          ],
+          "productID" : "com.mqldev.appforindeed.pro.monthly",
+          "recurringSubscriptionPeriod" : "P1M",
+          "referenceName" : "Monthly Pro",
+          "subscriptionGroupID" : "21829751",
+          "type" : "RecurringSubscription"
+        },
+        {
+          "adHocOffers" : [
+
+          ],
+          "codeOffers" : [
+
+          ],
+          "displayPrice" : "39.99",
+          "familyShareable" : false,
+          "groupNumber" : 3,
+          "internalID" : "A1000003",
+          "introductoryOffer" : {
+            "internalID" : "A1000004",
+            "numberOfPeriods" : 1,
+            "paymentMode" : "free",
+            "subscriptionPeriod" : "P3D"
+          },
+          "localizations" : [
+            {
+              "description" : "Best value — includes a 3-day free trial.",
+              "displayName" : "Yearly Pro",
+              "locale" : "en_US"
+            }
+          ],
+          "productID" : "com.mqldev.appforindeed.pro.yearly",
+          "recurringSubscriptionPeriod" : "P1Y",
+          "referenceName" : "Yearly Pro",
+          "subscriptionGroupID" : "21829751",
+          "type" : "RecurringSubscription"
+        }
+      ]
+    }
+  ],
+  "version" : {
+    "major" : 3,
+    "minor" : 0
+  }
+}

+ 26 - 0
App for Indeed/Subscription/SubscriptionProductIDs.swift

@@ -0,0 +1,26 @@
+//
+//  SubscriptionProductIDs.swift
+//  App for Indeed
+//
+
+import Foundation
+
+/// Identifiers for auto-renewable subscriptions in App Store Connect.
+/// Local Xcode runs use `App for Indeed/ProSubscriptions.storekit` (selected in the Run scheme → Options → StoreKit Configuration).
+/// Create three subscriptions with these exact IDs and attach them to the same subscription group.
+enum SubscriptionProductIDs {
+    static let weekly = "com.mqldev.appforindeed.pro.weekly"
+    static let monthly = "com.mqldev.appforindeed.pro.monthly"
+    static let yearly = "com.mqldev.appforindeed.pro.yearly"
+
+    static let all: [String] = [weekly, monthly, yearly]
+
+    static func productID(planKey: String) -> String? {
+        switch planKey {
+        case "weekly": return weekly
+        case "monthly": return monthly
+        case "yearly": return yearly
+        default: return nil
+        }
+    }
+}

+ 140 - 0
App for Indeed/Subscription/SubscriptionStore.swift

@@ -0,0 +1,140 @@
+//
+//  SubscriptionStore.swift
+//  App for Indeed
+//
+
+import Foundation
+import StoreKit
+
+extension Notification.Name {
+    static let subscriptionStatusDidChange = Notification.Name("subscriptionStatusDidChange")
+}
+
+@MainActor
+final class SubscriptionStore {
+    static let shared = SubscriptionStore()
+
+    private(set) var productsByID: [String: Product] = [:]
+    /// Mirrors StoreKit entitlements; safe to read on the main thread after `refreshEntitlements()` or a `.subscriptionStatusDidChange` notification.
+    private(set) var isProActive: Bool = false
+    private var transactionListenerTask: Task<Void, Never>?
+
+    private init() {
+        transactionListenerTask = Task { await listenForTransactions() }
+    }
+
+    deinit {
+        transactionListenerTask?.cancel()
+    }
+
+    /// Syncs `isProActive` with `Transaction.currentEntitlements`. Call on launch and after StoreKit events.
+    func refreshEntitlements() async {
+        isProActive = await computeProEntitlementFromStore()
+    }
+
+    /// Loads subscription products from the App Store (or StoreKit Testing in Xcode).
+    func loadProducts() async {
+        do {
+            let products = try await Product.products(for: SubscriptionProductIDs.all)
+            var map: [String: Product] = [:]
+            for product in products {
+                map[product.id] = product
+            }
+            productsByID = map
+        } catch {
+            productsByID = [:]
+        }
+    }
+
+    func product(forPlanKey planKey: String) -> Product? {
+        guard let id = SubscriptionProductIDs.productID(planKey: planKey) else { return nil }
+        return productsByID[id]
+    }
+
+    func purchase(planKey: String) async throws -> Bool {
+        guard let product = product(forPlanKey: planKey) else {
+            throw SubscriptionStoreError.productUnavailable
+        }
+        let result = try await product.purchase()
+        switch result {
+        case .success(let verification):
+            let transaction = try checkVerified(verification)
+            await transaction.finish()
+            await refreshEntitlements()
+            NotificationCenter.default.post(name: .subscriptionStatusDidChange, object: nil)
+            return true
+        case .userCancelled:
+            return false
+        case .pending:
+            return false
+        @unknown default:
+            return false
+        }
+    }
+
+    /// Restores purchases by syncing with the App Store (required on macOS instead of `restoreCompletedTransactions`).
+    func restorePurchases() async throws {
+        try await AppStore.sync()
+        await refreshEntitlements()
+        NotificationCenter.default.post(name: .subscriptionStatusDidChange, object: nil)
+    }
+
+    /// Whether the user has an active subscription for one of this app’s Pro product IDs.
+    func hasActiveSubscription() async -> Bool {
+        await refreshEntitlements()
+        return isProActive
+    }
+
+    private func computeProEntitlementFromStore() async -> Bool {
+        for await result in Transaction.currentEntitlements {
+            guard case .verified(let transaction) = result else { continue }
+            if transaction.revocationDate != nil { continue }
+            guard transaction.productType == .autoRenewable else { continue }
+            if SubscriptionProductIDs.all.contains(transaction.productID) {
+                return true
+            }
+        }
+        return false
+    }
+
+    private func listenForTransactions() async {
+        for await result in Transaction.updates {
+            guard case .verified(let transaction) = result else { continue }
+            await transaction.finish()
+            await refreshEntitlements()
+            NotificationCenter.default.post(name: .subscriptionStatusDidChange, object: nil)
+        }
+    }
+
+    private nonisolated func checkVerified(_ result: VerificationResult<Transaction>) throws -> Transaction {
+        switch result {
+        case .unverified(_, let error):
+            throw error
+        case .verified(let transaction):
+            return transaction
+        }
+    }
+}
+
+enum SubscriptionStoreError: LocalizedError {
+    /// No `Product` was returned for this ID (wrong IDs, products not approved in ASC, or StoreKit Configuration not selected in the scheme).
+    case productUnavailable
+
+    var errorDescription: String? {
+        switch self {
+        case .productUnavailable:
+            return "That subscription isn’t available from the App Store right now."
+        }
+    }
+
+    var recoverySuggestion: String? {
+        switch self {
+        case .productUnavailable:
+            return """
+            For local testing in Xcode: Product → Scheme → Edit Scheme → Run → Options → StoreKit Configuration → choose ProSubscriptions.storekit.
+
+            For TestFlight / App Store: In App Store Connect, create auto-renewable subscriptions whose Product IDs exactly match SubscriptionProductIDs.swift (same spelling as com.mqldev.appforindeed.pro.*), then submit them with the app version.
+            """
+        }
+    }
+}

+ 68 - 20
App for Indeed/Views/DashboardView.swift

@@ -126,6 +126,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     private var chatThinkingRowHost: NSView?
     private let jobSearchService = OpenAIJobSearchService()
     private var premiumPlansWindowController: PremiumPlansWindowController?
+    private weak var sidebarUpgradeCard: NSView?
+    private var subscriptionObserver: NSObjectProtocol?
 
     /// Upper bound sent to the model per request (was fixed at 8 in the prompt). Clamped when calling the API.
     private static let jobsPerSearchDefault = 15
@@ -146,6 +148,21 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         setupLayout()
     }
 
+    deinit {
+        if let subscriptionObserver {
+            NotificationCenter.default.removeObserver(subscriptionObserver)
+        }
+    }
+
+    override func viewDidMoveToWindow() {
+        super.viewDidMoveToWindow()
+        guard window != nil else { return }
+        Task { @MainActor in
+            await SubscriptionStore.shared.refreshEntitlements()
+            self.applyProSubscriptionToSidebar()
+        }
+    }
+
     override func layout() {
         super.layout()
         updateSearchBarShadowPath()
@@ -330,6 +347,46 @@ final class DashboardView: NSView, NSTextFieldDelegate {
 
             welcomeHeroContent.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, constant: -32)
         ])
+        registerSubscriptionObserverOnce()
+    }
+
+    private func registerSubscriptionObserverOnce() {
+        guard subscriptionObserver == nil else { return }
+        subscriptionObserver = NotificationCenter.default.addObserver(
+            forName: .subscriptionStatusDidChange,
+            object: nil,
+            queue: .main
+        ) { [weak self] _ in
+            self?.applyProSubscriptionToSidebar()
+        }
+    }
+
+    private func applyProSubscriptionToSidebar() {
+        let active = SubscriptionStore.shared.isProActive
+        sidebarUpgradeCard?.isHidden = active
+    }
+
+    private func presentPremiumPlansSheet() {
+        guard let hostWindow = window else { return }
+
+        if premiumPlansWindowController == nil {
+            premiumPlansWindowController = PremiumPlansWindowController()
+        }
+        guard let paywallWindow = premiumPlansWindowController?.window else { return }
+
+        if hostWindow.attachedSheet === paywallWindow {
+            return
+        }
+
+        let hostContentSize = hostWindow.contentView?.bounds.size ?? hostWindow.frame.size
+        paywallWindow.setContentSize(hostContentSize)
+        paywallWindow.minSize = hostContentSize
+        paywallWindow.maxSize = hostContentSize
+        paywallWindow.styleMask.insert(.fullSizeContentView)
+        paywallWindow.titlebarAppearsTransparent = true
+        paywallWindow.titleVisibility = .hidden
+
+        hostWindow.beginSheet(paywallWindow)
     }
 
     private func configureFeatureShortcutCards() {
@@ -1365,6 +1422,10 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     }
 
     @objc private func didSubmitSearch() {
+        guard SubscriptionStore.shared.isProActive else {
+            presentPremiumPlansSheet()
+            return
+        }
         let prompt = jobKeywordsField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
         guard !prompt.isEmpty, !isAwaitingResponse else { return }
         let isContinuation = isContinuationPrompt(prompt)
@@ -1422,6 +1483,10 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     }
 
     @objc private func didTapLoadMoreJobs() {
+        guard SubscriptionStore.shared.isProActive else {
+            presentPremiumPlansSheet()
+            return
+        }
         let prompt = "Show more jobs"
         guard !isAwaitingResponse, isContinuationPrompt(prompt) else { return }
         if anchorUserJobQuery(excludingLatestUserMessage: prompt) == nil { return }
@@ -2049,29 +2114,12 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         ])
 
         sidebar.addArrangedSubview(upgradeCard)
+        sidebarUpgradeCard = upgradeCard
+        applyProSubscriptionToSidebar()
     }
 
     @objc private func didTapUpgradeToPro() {
-        guard let hostWindow = window else { return }
-
-        if premiumPlansWindowController == nil {
-            premiumPlansWindowController = PremiumPlansWindowController()
-        }
-        guard let paywallWindow = premiumPlansWindowController?.window else { return }
-
-        if hostWindow.attachedSheet === paywallWindow {
-            return
-        }
-
-        let hostContentSize = hostWindow.contentView?.bounds.size ?? hostWindow.frame.size
-        paywallWindow.setContentSize(hostContentSize)
-        paywallWindow.minSize = hostContentSize
-        paywallWindow.maxSize = hostContentSize
-        paywallWindow.styleMask.insert(.fullSizeContentView)
-        paywallWindow.titlebarAppearsTransparent = true
-        paywallWindow.titleVisibility = .hidden
-
-        hostWindow.beginSheet(paywallWindow)
+        presentPremiumPlansSheet()
     }
 
     @objc private func didChangeThemeSelection(_ sender: NSSegmentedControl) {