瀏覽代碼

Improve Pro subscription detection and paywall actions

- SubscriptionStore: add optional deep refresh via Product.subscription
  status when entitlements are empty; post status notification only when
  isProActive changes; fold redundant posts into refreshEntitlements.
- AppDelegate: refresh entitlements on launch and when the app becomes
  active (debounced) using deep refresh; document why AppStore.sync is
  not called at launch.
- Dashboard: keep the upgrade card visible for all users, switch copy
  and CTA between Try Pro and Manage Subscription, refresh entitlements
  before opening the paywall or App Store subscriptions.
- Premium plans: footer uses Try Pro vs Manage Subscription; Manage
  opens Apple subscription settings even if StoreKit lags; Try Pro no
  longer dismisses the sheet; refresh footer after loading products.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 2 月之前
父節點
當前提交
e6a274a0b1

+ 23 - 1
App for Indeed/AppDelegate.swift

@@ -11,6 +11,8 @@ import Cocoa
 class AppDelegate: NSObject, NSApplicationDelegate {
 
     private let minimumWindowSize = NSSize(width: 1120, height: 700)
+    /// Avoids hammering StoreKit when `didBecomeActive` fires in quick succession (e.g. after system sheets).
+    private var lastSubscriptionRefreshAt: Date?
 
     func applicationWillFinishLaunching(_ notification: Notification) {
         // Dashboard is light-themed; without this, a Dark Mode Mac paints a dark title bar.
@@ -18,8 +20,28 @@ class AppDelegate: NSObject, NSApplicationDelegate {
     }
 
     func applicationDidFinishLaunching(_ aNotification: Notification) {
+        NotificationCenter.default.addObserver(
+            forName: NSApplication.didBecomeActiveNotification,
+            object: nil,
+            queue: .main
+        ) { [weak self] _ in
+            Task { @MainActor in
+                guard let self else { return }
+                let now = Date()
+                if let last = self.lastSubscriptionRefreshAt, now.timeIntervalSince(last) < 2.5 {
+                    return
+                }
+                self.lastSubscriptionRefreshAt = now
+                await SubscriptionStore.shared.refreshEntitlements(deep: true)
+            }
+        }
+
         Task { @MainActor in
-            await SubscriptionStore.shared.refreshEntitlements()
+            // Do not call `AppStore.sync()` here — it prompts "Sign in with Apple Account" in Xcode / StoreKit
+            // testing and can repeat when the app re-activates after dismissing the sheet. Sync only from
+            // explicit "Restore purchases" in `SubscriptionStore.restorePurchases()`.
+            lastSubscriptionRefreshAt = Date()
+            await SubscriptionStore.shared.refreshEntitlements(deep: true)
             NotificationCenter.default.post(name: .subscriptionStatusDidChange, object: nil)
         }
         NSApp.activate(ignoringOtherApps: true)

+ 25 - 8
App for Indeed/Controllers/PremiumPlansWindowController.swift

@@ -219,7 +219,9 @@ private final class PremiumPlansViewController: NSViewController {
                 self?.updatePremiumCloseButtonVisibility()
             }
         }
-        Task { await loadStoreProducts() }
+        Task { @MainActor in
+            await loadStoreProducts()
+        }
     }
 
     override func viewDidLayout() {
@@ -574,8 +576,13 @@ private final class PremiumPlansViewController: NSViewController {
         return (container, button)
     }
 
+    private enum PrimaryFooterSubscriptionTitle {
+        static let manage = "Manage Subscription"
+        static let tryPro = "Try Pro"
+    }
+
     private func subscriptionPrimaryFooterTitle() -> String {
-        subscriptionStore.isProActive ? "Manage Subscription" : "Continue with free plan"
+        subscriptionStore.isProActive ? PrimaryFooterSubscriptionTitle.manage : PrimaryFooterSubscriptionTitle.tryPro
     }
 
     private func updateSubscriptionPrimaryFooter() {
@@ -626,12 +633,19 @@ private final class PremiumPlansViewController: NSViewController {
         Task { await purchasePlan(planKey: planKey) }
     }
 
-    @objc private func didTapPrimaryFooterSubscriptionAction() {
-        if subscriptionStore.isProActive {
-            guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
-            NSWorkspace.shared.open(url)
-        } else {
-            didTapClose()
+    @objc private func didTapPrimaryFooterSubscriptionAction(_ sender: NSButton) {
+        let userTappedManage = (sender.title == PrimaryFooterSubscriptionTitle.manage)
+        Task { @MainActor [weak self] in
+            guard let self else { return }
+            await subscriptionStore.refreshEntitlements(deep: true)
+            updateSubscriptionPrimaryFooter()
+            let active = subscriptionStore.isProActive
+            if active || userTappedManage {
+                guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
+                NSWorkspace.shared.open(url)
+                return
+            }
+            // "Try Pro" while this paywall is visible: stay on the paywall (do not dismiss the sheet).
         }
     }
 
@@ -665,8 +679,11 @@ private final class PremiumPlansViewController: NSViewController {
     }
 
     private func loadStoreProducts() async {
+        await subscriptionStore.refreshEntitlements(deep: true)
         await subscriptionStore.loadProducts()
         applyStorePricing()
+        updateSubscriptionPrimaryFooter()
+        updatePremiumCloseButtonVisibility()
     }
 
     private func applyStorePricing() {

+ 38 - 10
App for Indeed/Subscription/SubscriptionStore.swift

@@ -27,9 +27,17 @@ final class SubscriptionStore {
         transactionListenerTask?.cancel()
     }
 
-    /// Syncs `isProActive` with `Transaction.currentEntitlements`. Call on launch and after StoreKit events.
-    func refreshEntitlements() async {
+    /// Syncs `isProActive` with StoreKit. Set `deep` to also consult subscription status via `Product.products` when entitlements are empty (after sync, restore, or opening the paywall).
+    /// Posts `.subscriptionStatusDidChange` when the value changes.
+    func refreshEntitlements(deep: Bool = false) async {
+        let before = isProActive
         isProActive = await computeProEntitlementFromStore()
+        if !isProActive, deep {
+            isProActive = await computeProFromLoadedSubscriptionProducts()
+        }
+        if before != isProActive {
+            NotificationCenter.default.post(name: .subscriptionStatusDidChange, object: nil)
+        }
     }
 
     /// Loads subscription products from the App Store (or StoreKit Testing in Xcode).
@@ -60,8 +68,7 @@ final class SubscriptionStore {
         case .success(let verification):
             let transaction = try checkVerified(verification)
             await transaction.finish()
-            await refreshEntitlements()
-            NotificationCenter.default.post(name: .subscriptionStatusDidChange, object: nil)
+            await refreshEntitlements(deep: true)
             return true
         case .userCancelled:
             return false
@@ -75,13 +82,12 @@ final class SubscriptionStore {
     /// 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)
+        await refreshEntitlements(deep: true)
     }
 
     /// Whether the user has an active subscription for one of this app’s Pro product IDs.
     func hasActiveSubscription() async -> Bool {
-        await refreshEntitlements()
+        await refreshEntitlements(deep: true)
         return isProActive
     }
 
@@ -89,7 +95,6 @@ final class SubscriptionStore {
         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
             }
@@ -97,12 +102,35 @@ final class SubscriptionStore {
         return false
     }
 
+    /// Fallback when `currentEntitlements` is empty or lagging (common right after install, account changes, or macOS StoreKit edge cases).
+    private func computeProFromLoadedSubscriptionProducts() async -> Bool {
+        do {
+            let products = try await Product.products(for: SubscriptionProductIDs.all)
+            for product in products {
+                guard let subscription = product.subscription else { continue }
+                let statuses = try await subscription.status
+                for status in statuses {
+                    switch status.state {
+                    case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
+                        return true
+                    case .expired, .revoked:
+                        break
+                    default:
+                        break
+                    }
+                }
+            }
+        } catch {
+            return false
+        }
+        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)
+            await refreshEntitlements(deep: true)
         }
     }
 

+ 36 - 4
App for Indeed/Views/DashboardView.swift

@@ -127,6 +127,9 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     private let jobSearchService = OpenAIJobSearchService()
     private var premiumPlansWindowController: PremiumPlansWindowController?
     private weak var sidebarUpgradeCard: NSView?
+    private weak var sidebarUpgradeHeadline: NSTextField?
+    private weak var sidebarUpgradeDescription: NSTextField?
+    private weak var sidebarUpgradeButton: HoverableButton?
     private var subscriptionObserver: NSObjectProtocol?
 
     /// Upper bound sent to the model per request (was fixed at 8 in the prompt). Clamped when calling the API.
@@ -158,7 +161,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         super.viewDidMoveToWindow()
         guard window != nil else { return }
         Task { @MainActor in
-            await SubscriptionStore.shared.refreshEntitlements()
+            await SubscriptionStore.shared.refreshEntitlements(deep: true)
             self.applyProSubscriptionToSidebar()
         }
     }
@@ -363,7 +366,24 @@ final class DashboardView: NSView, NSTextFieldDelegate {
 
     private func applyProSubscriptionToSidebar() {
         let active = SubscriptionStore.shared.isProActive
-        sidebarUpgradeCard?.isHidden = active
+        sidebarUpgradeCard?.isHidden = false
+
+        guard let headline = sidebarUpgradeHeadline,
+              let upgradeDescription = sidebarUpgradeDescription,
+              let upgradeButton = sidebarUpgradeButton else { return }
+
+        let descriptionWidth: CGFloat = 158
+        if active {
+            headline.stringValue = "You're on Pro"
+            upgradeDescription.stringValue = "Manage billing, renewals, and plans in the App Store."
+            upgradeDescription.preferredMaxLayoutWidth = descriptionWidth
+            upgradeButton.title = "Manage Subscription"
+        } else {
+            headline.stringValue = "Upgrade to Pro"
+            upgradeDescription.stringValue = "Unlimited AI matches, smart alerts, and interview prep—all in one place."
+            upgradeDescription.preferredMaxLayoutWidth = descriptionWidth
+            upgradeButton.title = "Try Pro"
+        }
     }
 
     private func presentPremiumPlansSheet() {
@@ -2073,7 +2093,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         let innerContentWidth = cardWidth - 28
         upgradeDescription.preferredMaxLayoutWidth = innerContentWidth
 
-        let upgradeButton = HoverableButton(title: "Upgrade to Pro", target: self, action: #selector(didTapUpgradeToPro))
+        let upgradeButton = HoverableButton(title: "Try Pro", target: self, action: #selector(didTapUpgradeToPro))
         upgradeButton.isBordered = false
         upgradeButton.bezelStyle = .rounded
         upgradeButton.font = .systemFont(ofSize: 13, weight: .bold)
@@ -2115,11 +2135,23 @@ final class DashboardView: NSView, NSTextFieldDelegate {
 
         sidebar.addArrangedSubview(upgradeCard)
         sidebarUpgradeCard = upgradeCard
+        sidebarUpgradeHeadline = headline
+        sidebarUpgradeDescription = upgradeDescription
+        sidebarUpgradeButton = upgradeButton
         applyProSubscriptionToSidebar()
     }
 
     @objc private func didTapUpgradeToPro() {
-        presentPremiumPlansSheet()
+        Task { @MainActor in
+            await SubscriptionStore.shared.refreshEntitlements(deep: true)
+            applyProSubscriptionToSidebar()
+            guard SubscriptionStore.shared.isProActive else {
+                presentPremiumPlansSheet()
+                return
+            }
+            guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
+            NSWorkspace.shared.open(url)
+        }
     }
 
     @objc private func didChangeThemeSelection(_ sender: NSSegmentedControl) {