Просмотр исходного кода

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 недель назад: 3
Родитель
Сommit
17d196b896

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

@@ -0,0 +1,82 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<Scheme
3
+   LastUpgradeVersion = "2640"
4
+   version = "1.7">
5
+   <BuildAction
6
+      parallelizeBuildables = "YES"
7
+      buildImplicitDependencies = "YES"
8
+      buildArchitectures = "Automatic">
9
+      <BuildActionEntries>
10
+         <BuildActionEntry
11
+            buildForTesting = "YES"
12
+            buildForRunning = "YES"
13
+            buildForProfiling = "YES"
14
+            buildForArchiving = "YES"
15
+            buildForAnalyzing = "YES">
16
+            <BuildableReference
17
+               BuildableIdentifier = "primary"
18
+               BlueprintIdentifier = "27D8527F2FB1D367008DF557"
19
+               BuildableName = "App for Indeed.app"
20
+               BlueprintName = "App for Indeed"
21
+               ReferencedContainer = "container:App for Indeed.xcodeproj">
22
+            </BuildableReference>
23
+         </BuildActionEntry>
24
+      </BuildActionEntries>
25
+   </BuildAction>
26
+   <TestAction
27
+      buildConfiguration = "Debug"
28
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
29
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
30
+      shouldUseLaunchSchemeArgsEnv = "YES"
31
+      shouldAutocreateTestPlan = "YES">
32
+   </TestAction>
33
+   <LaunchAction
34
+      buildConfiguration = "Debug"
35
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
36
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
37
+      launchStyle = "0"
38
+      useCustomWorkingDirectory = "NO"
39
+      ignoresPersistentStateOnLaunch = "NO"
40
+      debugDocumentVersioning = "YES"
41
+      debugServiceExtension = "internal"
42
+      allowLocationSimulation = "YES"
43
+      queueDebuggingEnableBacktraceRecording = "Yes">
44
+      <BuildableProductRunnable
45
+         runnableDebuggingMode = "0">
46
+         <BuildableReference
47
+            BuildableIdentifier = "primary"
48
+            BlueprintIdentifier = "27D8527F2FB1D367008DF557"
49
+            BuildableName = "App for Indeed.app"
50
+            BlueprintName = "App for Indeed"
51
+            ReferencedContainer = "container:App for Indeed.xcodeproj">
52
+         </BuildableReference>
53
+      </BuildableProductRunnable>
54
+      <StoreKitConfigurationFileReference
55
+         identifier = "../../App for Indeed/ProSubscriptions.storekit">
56
+      </StoreKitConfigurationFileReference>
57
+   </LaunchAction>
58
+   <ProfileAction
59
+      buildConfiguration = "Release"
60
+      shouldUseLaunchSchemeArgsEnv = "YES"
61
+      savedToolIdentifier = ""
62
+      useCustomWorkingDirectory = "NO"
63
+      debugDocumentVersioning = "YES">
64
+      <BuildableProductRunnable
65
+         runnableDebuggingMode = "0">
66
+         <BuildableReference
67
+            BuildableIdentifier = "primary"
68
+            BlueprintIdentifier = "27D8527F2FB1D367008DF557"
69
+            BuildableName = "App for Indeed.app"
70
+            BlueprintName = "App for Indeed"
71
+            ReferencedContainer = "container:App for Indeed.xcodeproj">
72
+         </BuildableReference>
73
+      </BuildableProductRunnable>
74
+   </ProfileAction>
75
+   <AnalyzeAction
76
+      buildConfiguration = "Debug">
77
+   </AnalyzeAction>
78
+   <ArchiveAction
79
+      buildConfiguration = "Release"
80
+      revealArchiveInOrganizer = "YES">
81
+   </ArchiveAction>
82
+</Scheme>

+ 4 - 0
App for Indeed/AppDelegate.swift

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

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

@@ -1,4 +1,5 @@
1 1
 import Cocoa
2
+import StoreKit
2 3
 
3 4
 final class PremiumPlansWindowController: NSWindowController {
4 5
     init() {
@@ -129,6 +130,11 @@ private final class PremiumPlansViewController: NSViewController {
129 130
         static let edgeInsets = NSEdgeInsets(top: 21, left: 37, bottom: 21, right: 0)
130 131
     }
131 132
 
133
+    private let subscriptionStore = SubscriptionStore.shared
134
+    private var planPriceFields: [String: (price: NSTextField, period: NSTextField)] = [:]
135
+    private var planPurchaseButtons: [String: NSButton] = [:]
136
+    private var subscriptionStatusObservation: NSObjectProtocol?
137
+
132 138
     private let plans: [Plan] = [
133 139
         Plan(
134 140
             id: "weekly",
@@ -190,6 +196,28 @@ private final class PremiumPlansViewController: NSViewController {
190 196
     ]
191 197
 
192 198
     private let pageGradient = CAGradientLayer()
199
+
200
+    deinit {
201
+        if let subscriptionStatusObservation {
202
+            NotificationCenter.default.removeObserver(subscriptionStatusObservation)
203
+        }
204
+    }
205
+
206
+    override func viewDidLoad() {
207
+        super.viewDidLoad()
208
+        subscriptionStatusObservation = NotificationCenter.default.addObserver(
209
+            forName: .subscriptionStatusDidChange,
210
+            object: nil,
211
+            queue: .main
212
+        ) { [weak self] _ in
213
+            Task { @MainActor in
214
+                await self?.subscriptionStore.loadProducts()
215
+                self?.applyStorePricing()
216
+            }
217
+        }
218
+        Task { await loadStoreProducts() }
219
+    }
220
+
193 221
     override func viewDidLayout() {
194 222
         super.viewDidLayout()
195 223
         pageGradient.frame = view.bounds
@@ -346,6 +374,8 @@ private final class PremiumPlansViewController: NSViewController {
346 374
 
347 375
         let selectButton = NSButton(title: "Get \(plan.title)", target: self, action: #selector(didTapSelectPlan))
348 376
         selectButton.identifier = NSUserInterfaceItemIdentifier(plan.id)
377
+        planPurchaseButtons[plan.id] = selectButton
378
+        planPriceFields[plan.id] = (priceLabel, periodLabel)
349 379
         selectButton.isBordered = false
350 380
         selectButton.bezelStyle = .rounded
351 381
         selectButton.font = .systemFont(ofSize: 14, weight: .semibold)
@@ -476,16 +506,19 @@ private final class PremiumPlansViewController: NSViewController {
476 506
     }
477 507
 
478 508
     private func makeFooterRow() -> NSView {
479
-        let items = [
480
-            "Manage Subscription",
481
-            "Restore Purchase",
482
-            "Privacy Policy",
483
-            "Terms of Services",
484
-            "Support"
509
+        let entries: [(text: String, action: Selector?)] = [
510
+            ("Manage Subscription", #selector(didTapManageSubscription)),
511
+            ("Restore Purchase", #selector(didTapRestorePurchases)),
512
+            ("Privacy Policy", nil),
513
+            ("Terms of Services", nil),
514
+            ("Support", nil)
485 515
         ]
486 516
 
487
-        let cells = items.enumerated().map { index, text in
488
-            footerCell(text: text, showsTrailingDivider: index < items.count - 1)
517
+        let cells = entries.enumerated().map { index, entry in
518
+            if let action = entry.action {
519
+                return footerActionCell(title: entry.text, action: action, showsTrailingDivider: index < entries.count - 1)
520
+            }
521
+            return footerCell(text: entry.text, showsTrailingDivider: index < entries.count - 1)
489 522
         }
490 523
 
491 524
         let links = NSStackView(views: cells)
@@ -497,6 +530,37 @@ private final class PremiumPlansViewController: NSViewController {
497 530
         return links
498 531
     }
499 532
 
533
+    private func footerActionCell(title: String, action: Selector, showsTrailingDivider: Bool) -> NSView {
534
+        let container = NSView()
535
+        container.translatesAutoresizingMaskIntoConstraints = false
536
+
537
+        let button = NSButton(title: title, target: self, action: action)
538
+        button.isBordered = false
539
+        button.bezelStyle = .rounded
540
+        button.font = .systemFont(ofSize: 12, weight: .medium)
541
+        button.contentTintColor = Theme.secondaryText
542
+        button.focusRingType = .none
543
+        button.translatesAutoresizingMaskIntoConstraints = false
544
+        container.addSubview(button)
545
+
546
+        var constraints: [NSLayoutConstraint] = [
547
+            button.centerXAnchor.constraint(equalTo: container.centerXAnchor),
548
+            button.centerYAnchor.constraint(equalTo: container.centerYAnchor)
549
+        ]
550
+
551
+        if showsTrailingDivider {
552
+            let divider = footerDivider()
553
+            container.addSubview(divider)
554
+            constraints.append(contentsOf: [
555
+                divider.trailingAnchor.constraint(equalTo: container.trailingAnchor),
556
+                divider.centerYAnchor.constraint(equalTo: container.centerYAnchor)
557
+            ])
558
+        }
559
+
560
+        NSLayoutConstraint.activate(constraints)
561
+        return container
562
+    }
563
+
500 564
     private func footerCell(text: String, showsTrailingDivider: Bool) -> NSView {
501 565
         let container = NSView()
502 566
         container.translatesAutoresizingMaskIntoConstraints = false
@@ -571,13 +635,129 @@ private final class PremiumPlansViewController: NSViewController {
571 635
     }
572 636
 
573 637
     @objc private func didTapSelectPlan(_ sender: NSButton) {
574
-        sender.layer?.backgroundColor = Theme.accentHover.cgColor
575
-        let selectedPlan = sender.identifier?.rawValue ?? sender.title
638
+        guard let planKey = sender.identifier?.rawValue else { return }
639
+        Task { await purchasePlan(planKey: planKey) }
640
+    }
641
+
642
+    @objc private func didTapManageSubscription() {
643
+        guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
644
+        NSWorkspace.shared.open(url)
645
+    }
646
+
647
+    @objc private func didTapRestorePurchases() {
648
+        Task { await restorePurchases() }
649
+    }
650
+
651
+    private func loadStoreProducts() async {
652
+        await subscriptionStore.loadProducts()
653
+        applyStorePricing()
654
+    }
655
+
656
+    private func applyStorePricing() {
657
+        for plan in plans {
658
+            guard let fields = planPriceFields[plan.id],
659
+                  let product = subscriptionStore.product(forPlanKey: plan.id) else { continue }
660
+            fields.price.stringValue = product.displayPrice
661
+            if let period = product.subscription?.subscriptionPeriod {
662
+                fields.period.stringValue = periodSuffix(for: period)
663
+            }
664
+        }
665
+    }
666
+
667
+    private func periodSuffix(for period: Product.SubscriptionPeriod) -> String {
668
+        let value = period.value
669
+        switch period.unit {
670
+        case .day: return value == 1 ? "/ day" : "/ \(value) days"
671
+        case .week: return value == 1 ? "/ week" : "/ \(value) weeks"
672
+        case .month: return value == 1 ? "/ month" : "/ \(value) months"
673
+        case .year: return value == 1 ? "/ year" : "/ \(value) years"
674
+        @unknown default: return ""
675
+        }
676
+    }
677
+
678
+    private func setPurchasing(_ isPurchasing: Bool) {
679
+        for button in planPurchaseButtons.values {
680
+            button.isEnabled = !isPurchasing
681
+        }
682
+    }
683
+
684
+    private func purchasePlan(planKey: String) async {
685
+        setPurchasing(true)
686
+        defer { setPurchasing(false) }
687
+        do {
688
+            let completed = try await subscriptionStore.purchase(planKey: planKey)
689
+            guard completed else { return }
690
+            let alert = NSAlert()
691
+            alert.messageText = "You're subscribed"
692
+            alert.informativeText = "Thank you — Pro features are now available."
693
+            alert.alertStyle = .informational
694
+            alert.addButton(withTitle: "OK")
695
+            if let window = view.window {
696
+                alert.beginSheetModal(for: window) { [weak self] _ in
697
+                    self?.dismissPremiumSheetFromParentIfNeeded()
698
+                }
699
+            } else {
700
+                alert.runModal()
701
+                dismissPremiumSheetFromParentIfNeeded()
702
+            }
703
+        } catch {
704
+            await MainActor.run {
705
+                self.presentPurchaseError(error)
706
+            }
707
+        }
708
+    }
576 709
 
710
+    private func restorePurchases() async {
711
+        setPurchasing(true)
712
+        defer { setPurchasing(false) }
713
+        do {
714
+            try await subscriptionStore.restorePurchases()
715
+            let active = subscriptionStore.isProActive
716
+            let alert = NSAlert()
717
+            if active {
718
+                alert.messageText = "Purchases restored"
719
+                alert.informativeText = "Your subscription is active."
720
+            } else {
721
+                alert.messageText = "No subscription found"
722
+                alert.informativeText = "There was nothing to restore for this Apple ID."
723
+            }
724
+            alert.alertStyle = .informational
725
+            alert.addButton(withTitle: "OK")
726
+            if let window = view.window {
727
+                alert.beginSheetModal(for: window) { [weak self] _ in
728
+                    if active {
729
+                        self?.dismissPremiumSheetFromParentIfNeeded()
730
+                    }
731
+                }
732
+            } else {
733
+                alert.runModal()
734
+                if active {
735
+                    dismissPremiumSheetFromParentIfNeeded()
736
+                }
737
+            }
738
+        } catch {
739
+            await MainActor.run {
740
+                self.presentPurchaseError(error)
741
+            }
742
+        }
743
+    }
744
+
745
+    private func presentPurchaseError(_ error: Error) {
577 746
         let alert = NSAlert()
578
-        alert.messageText = "Premium checkout coming soon"
579
-        alert.informativeText = "Plan selected: \(selectedPlan.capitalized). Payment flow can be connected next."
580
-        alert.alertStyle = .informational
747
+        alert.messageText = "Something went wrong"
748
+        if let localized = error as? LocalizedError {
749
+            var parts: [String] = []
750
+            if let description = localized.errorDescription {
751
+                parts.append(description)
752
+            }
753
+            if let recovery = localized.recoverySuggestion {
754
+                parts.append(recovery)
755
+            }
756
+            alert.informativeText = parts.isEmpty ? error.localizedDescription : parts.joined(separator: "\n\n")
757
+        } else {
758
+            alert.informativeText = error.localizedDescription
759
+        }
760
+        alert.alertStyle = .warning
581 761
         alert.addButton(withTitle: "OK")
582 762
         if let window = view.window {
583 763
             alert.beginSheetModal(for: window)
@@ -586,6 +766,11 @@ private final class PremiumPlansViewController: NSViewController {
586 766
         }
587 767
     }
588 768
 
769
+    private func dismissPremiumSheetFromParentIfNeeded() {
770
+        guard let sheet = view.window, let parent = sheet.sheetParent else { return }
771
+        parent.endSheet(sheet)
772
+    }
773
+
589 774
     @objc private func didTapClose() {
590 775
         guard let window = view.window else { return }
591 776
         if let parent = window.sheetParent {

+ 123 - 0
App for Indeed/ProSubscriptions.storekit

@@ -0,0 +1,123 @@
1
+{
2
+  "appPolicies" : {
3
+    "eula" : "",
4
+    "policies" : [
5
+      {
6
+        "locale" : "en_US",
7
+        "policyText" : "",
8
+        "policyURL" : ""
9
+      }
10
+    ]
11
+  },
12
+  "identifier" : "ProSubscriptions",
13
+  "nonRenewingSubscriptions" : [
14
+
15
+  ],
16
+  "products" : [
17
+
18
+  ],
19
+  "settings" : {
20
+    "_failTransactionsEnabled" : false,
21
+    "_locale" : "en_US",
22
+    "_storefront" : "USA",
23
+    "_storeKitErrors" : [
24
+
25
+    ],
26
+    "_timeRate" : 0
27
+  },
28
+  "subscriptionGroups" : [
29
+    {
30
+      "id" : "21829751",
31
+      "localizations" : [
32
+
33
+      ],
34
+      "name" : "Indeed AI Pro",
35
+      "subscriptions" : [
36
+        {
37
+          "adHocOffers" : [
38
+
39
+          ],
40
+          "codeOffers" : [
41
+
42
+          ],
43
+          "displayPrice" : "9.99",
44
+          "familyShareable" : false,
45
+          "groupNumber" : 1,
46
+          "internalID" : "A1000001",
47
+          "introductoryOffer" : null,
48
+          "localizations" : [
49
+            {
50
+              "description" : "All premium features. Cancel anytime.",
51
+              "displayName" : "Weekly Pro",
52
+              "locale" : "en_US"
53
+            }
54
+          ],
55
+          "productID" : "com.mqldev.appforindeed.pro.weekly",
56
+          "recurringSubscriptionPeriod" : "P1W",
57
+          "referenceName" : "Weekly Pro",
58
+          "subscriptionGroupID" : "21829751",
59
+          "type" : "RecurringSubscription"
60
+        },
61
+        {
62
+          "adHocOffers" : [
63
+
64
+          ],
65
+          "codeOffers" : [
66
+
67
+          ],
68
+          "displayPrice" : "19.99",
69
+          "familyShareable" : false,
70
+          "groupNumber" : 2,
71
+          "internalID" : "A1000002",
72
+          "introductoryOffer" : null,
73
+          "localizations" : [
74
+            {
75
+              "description" : "All premium features and priority support.",
76
+              "displayName" : "Monthly Pro",
77
+              "locale" : "en_US"
78
+            }
79
+          ],
80
+          "productID" : "com.mqldev.appforindeed.pro.monthly",
81
+          "recurringSubscriptionPeriod" : "P1M",
82
+          "referenceName" : "Monthly Pro",
83
+          "subscriptionGroupID" : "21829751",
84
+          "type" : "RecurringSubscription"
85
+        },
86
+        {
87
+          "adHocOffers" : [
88
+
89
+          ],
90
+          "codeOffers" : [
91
+
92
+          ],
93
+          "displayPrice" : "39.99",
94
+          "familyShareable" : false,
95
+          "groupNumber" : 3,
96
+          "internalID" : "A1000003",
97
+          "introductoryOffer" : {
98
+            "internalID" : "A1000004",
99
+            "numberOfPeriods" : 1,
100
+            "paymentMode" : "free",
101
+            "subscriptionPeriod" : "P3D"
102
+          },
103
+          "localizations" : [
104
+            {
105
+              "description" : "Best value — includes a 3-day free trial.",
106
+              "displayName" : "Yearly Pro",
107
+              "locale" : "en_US"
108
+            }
109
+          ],
110
+          "productID" : "com.mqldev.appforindeed.pro.yearly",
111
+          "recurringSubscriptionPeriod" : "P1Y",
112
+          "referenceName" : "Yearly Pro",
113
+          "subscriptionGroupID" : "21829751",
114
+          "type" : "RecurringSubscription"
115
+        }
116
+      ]
117
+    }
118
+  ],
119
+  "version" : {
120
+    "major" : 3,
121
+    "minor" : 0
122
+  }
123
+}

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

@@ -0,0 +1,26 @@
1
+//
2
+//  SubscriptionProductIDs.swift
3
+//  App for Indeed
4
+//
5
+
6
+import Foundation
7
+
8
+/// Identifiers for auto-renewable subscriptions in App Store Connect.
9
+/// Local Xcode runs use `App for Indeed/ProSubscriptions.storekit` (selected in the Run scheme → Options → StoreKit Configuration).
10
+/// Create three subscriptions with these exact IDs and attach them to the same subscription group.
11
+enum SubscriptionProductIDs {
12
+    static let weekly = "com.mqldev.appforindeed.pro.weekly"
13
+    static let monthly = "com.mqldev.appforindeed.pro.monthly"
14
+    static let yearly = "com.mqldev.appforindeed.pro.yearly"
15
+
16
+    static let all: [String] = [weekly, monthly, yearly]
17
+
18
+    static func productID(planKey: String) -> String? {
19
+        switch planKey {
20
+        case "weekly": return weekly
21
+        case "monthly": return monthly
22
+        case "yearly": return yearly
23
+        default: return nil
24
+        }
25
+    }
26
+}

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

@@ -0,0 +1,140 @@
1
+//
2
+//  SubscriptionStore.swift
3
+//  App for Indeed
4
+//
5
+
6
+import Foundation
7
+import StoreKit
8
+
9
+extension Notification.Name {
10
+    static let subscriptionStatusDidChange = Notification.Name("subscriptionStatusDidChange")
11
+}
12
+
13
+@MainActor
14
+final class SubscriptionStore {
15
+    static let shared = SubscriptionStore()
16
+
17
+    private(set) var productsByID: [String: Product] = [:]
18
+    /// Mirrors StoreKit entitlements; safe to read on the main thread after `refreshEntitlements()` or a `.subscriptionStatusDidChange` notification.
19
+    private(set) var isProActive: Bool = false
20
+    private var transactionListenerTask: Task<Void, Never>?
21
+
22
+    private init() {
23
+        transactionListenerTask = Task { await listenForTransactions() }
24
+    }
25
+
26
+    deinit {
27
+        transactionListenerTask?.cancel()
28
+    }
29
+
30
+    /// Syncs `isProActive` with `Transaction.currentEntitlements`. Call on launch and after StoreKit events.
31
+    func refreshEntitlements() async {
32
+        isProActive = await computeProEntitlementFromStore()
33
+    }
34
+
35
+    /// Loads subscription products from the App Store (or StoreKit Testing in Xcode).
36
+    func loadProducts() async {
37
+        do {
38
+            let products = try await Product.products(for: SubscriptionProductIDs.all)
39
+            var map: [String: Product] = [:]
40
+            for product in products {
41
+                map[product.id] = product
42
+            }
43
+            productsByID = map
44
+        } catch {
45
+            productsByID = [:]
46
+        }
47
+    }
48
+
49
+    func product(forPlanKey planKey: String) -> Product? {
50
+        guard let id = SubscriptionProductIDs.productID(planKey: planKey) else { return nil }
51
+        return productsByID[id]
52
+    }
53
+
54
+    func purchase(planKey: String) async throws -> Bool {
55
+        guard let product = product(forPlanKey: planKey) else {
56
+            throw SubscriptionStoreError.productUnavailable
57
+        }
58
+        let result = try await product.purchase()
59
+        switch result {
60
+        case .success(let verification):
61
+            let transaction = try checkVerified(verification)
62
+            await transaction.finish()
63
+            await refreshEntitlements()
64
+            NotificationCenter.default.post(name: .subscriptionStatusDidChange, object: nil)
65
+            return true
66
+        case .userCancelled:
67
+            return false
68
+        case .pending:
69
+            return false
70
+        @unknown default:
71
+            return false
72
+        }
73
+    }
74
+
75
+    /// Restores purchases by syncing with the App Store (required on macOS instead of `restoreCompletedTransactions`).
76
+    func restorePurchases() async throws {
77
+        try await AppStore.sync()
78
+        await refreshEntitlements()
79
+        NotificationCenter.default.post(name: .subscriptionStatusDidChange, object: nil)
80
+    }
81
+
82
+    /// Whether the user has an active subscription for one of this app’s Pro product IDs.
83
+    func hasActiveSubscription() async -> Bool {
84
+        await refreshEntitlements()
85
+        return isProActive
86
+    }
87
+
88
+    private func computeProEntitlementFromStore() async -> Bool {
89
+        for await result in Transaction.currentEntitlements {
90
+            guard case .verified(let transaction) = result else { continue }
91
+            if transaction.revocationDate != nil { continue }
92
+            guard transaction.productType == .autoRenewable else { continue }
93
+            if SubscriptionProductIDs.all.contains(transaction.productID) {
94
+                return true
95
+            }
96
+        }
97
+        return false
98
+    }
99
+
100
+    private func listenForTransactions() async {
101
+        for await result in Transaction.updates {
102
+            guard case .verified(let transaction) = result else { continue }
103
+            await transaction.finish()
104
+            await refreshEntitlements()
105
+            NotificationCenter.default.post(name: .subscriptionStatusDidChange, object: nil)
106
+        }
107
+    }
108
+
109
+    private nonisolated func checkVerified(_ result: VerificationResult<Transaction>) throws -> Transaction {
110
+        switch result {
111
+        case .unverified(_, let error):
112
+            throw error
113
+        case .verified(let transaction):
114
+            return transaction
115
+        }
116
+    }
117
+}
118
+
119
+enum SubscriptionStoreError: LocalizedError {
120
+    /// No `Product` was returned for this ID (wrong IDs, products not approved in ASC, or StoreKit Configuration not selected in the scheme).
121
+    case productUnavailable
122
+
123
+    var errorDescription: String? {
124
+        switch self {
125
+        case .productUnavailable:
126
+            return "That subscription isn’t available from the App Store right now."
127
+        }
128
+    }
129
+
130
+    var recoverySuggestion: String? {
131
+        switch self {
132
+        case .productUnavailable:
133
+            return """
134
+            For local testing in Xcode: Product → Scheme → Edit Scheme → Run → Options → StoreKit Configuration → choose ProSubscriptions.storekit.
135
+
136
+            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.
137
+            """
138
+        }
139
+    }
140
+}

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

@@ -126,6 +126,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
126 126
     private var chatThinkingRowHost: NSView?
127 127
     private let jobSearchService = OpenAIJobSearchService()
128 128
     private var premiumPlansWindowController: PremiumPlansWindowController?
129
+    private weak var sidebarUpgradeCard: NSView?
130
+    private var subscriptionObserver: NSObjectProtocol?
129 131
 
130 132
     /// Upper bound sent to the model per request (was fixed at 8 in the prompt). Clamped when calling the API.
131 133
     private static let jobsPerSearchDefault = 15
@@ -146,6 +148,21 @@ final class DashboardView: NSView, NSTextFieldDelegate {
146 148
         setupLayout()
147 149
     }
148 150
 
151
+    deinit {
152
+        if let subscriptionObserver {
153
+            NotificationCenter.default.removeObserver(subscriptionObserver)
154
+        }
155
+    }
156
+
157
+    override func viewDidMoveToWindow() {
158
+        super.viewDidMoveToWindow()
159
+        guard window != nil else { return }
160
+        Task { @MainActor in
161
+            await SubscriptionStore.shared.refreshEntitlements()
162
+            self.applyProSubscriptionToSidebar()
163
+        }
164
+    }
165
+
149 166
     override func layout() {
150 167
         super.layout()
151 168
         updateSearchBarShadowPath()
@@ -330,6 +347,46 @@ final class DashboardView: NSView, NSTextFieldDelegate {
330 347
 
331 348
             welcomeHeroContent.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, constant: -32)
332 349
         ])
350
+        registerSubscriptionObserverOnce()
351
+    }
352
+
353
+    private func registerSubscriptionObserverOnce() {
354
+        guard subscriptionObserver == nil else { return }
355
+        subscriptionObserver = NotificationCenter.default.addObserver(
356
+            forName: .subscriptionStatusDidChange,
357
+            object: nil,
358
+            queue: .main
359
+        ) { [weak self] _ in
360
+            self?.applyProSubscriptionToSidebar()
361
+        }
362
+    }
363
+
364
+    private func applyProSubscriptionToSidebar() {
365
+        let active = SubscriptionStore.shared.isProActive
366
+        sidebarUpgradeCard?.isHidden = active
367
+    }
368
+
369
+    private func presentPremiumPlansSheet() {
370
+        guard let hostWindow = window else { return }
371
+
372
+        if premiumPlansWindowController == nil {
373
+            premiumPlansWindowController = PremiumPlansWindowController()
374
+        }
375
+        guard let paywallWindow = premiumPlansWindowController?.window else { return }
376
+
377
+        if hostWindow.attachedSheet === paywallWindow {
378
+            return
379
+        }
380
+
381
+        let hostContentSize = hostWindow.contentView?.bounds.size ?? hostWindow.frame.size
382
+        paywallWindow.setContentSize(hostContentSize)
383
+        paywallWindow.minSize = hostContentSize
384
+        paywallWindow.maxSize = hostContentSize
385
+        paywallWindow.styleMask.insert(.fullSizeContentView)
386
+        paywallWindow.titlebarAppearsTransparent = true
387
+        paywallWindow.titleVisibility = .hidden
388
+
389
+        hostWindow.beginSheet(paywallWindow)
333 390
     }
334 391
 
335 392
     private func configureFeatureShortcutCards() {
@@ -1365,6 +1422,10 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1365 1422
     }
1366 1423
 
1367 1424
     @objc private func didSubmitSearch() {
1425
+        guard SubscriptionStore.shared.isProActive else {
1426
+            presentPremiumPlansSheet()
1427
+            return
1428
+        }
1368 1429
         let prompt = jobKeywordsField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
1369 1430
         guard !prompt.isEmpty, !isAwaitingResponse else { return }
1370 1431
         let isContinuation = isContinuationPrompt(prompt)
@@ -1422,6 +1483,10 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1422 1483
     }
1423 1484
 
1424 1485
     @objc private func didTapLoadMoreJobs() {
1486
+        guard SubscriptionStore.shared.isProActive else {
1487
+            presentPremiumPlansSheet()
1488
+            return
1489
+        }
1425 1490
         let prompt = "Show more jobs"
1426 1491
         guard !isAwaitingResponse, isContinuationPrompt(prompt) else { return }
1427 1492
         if anchorUserJobQuery(excludingLatestUserMessage: prompt) == nil { return }
@@ -2049,29 +2114,12 @@ final class DashboardView: NSView, NSTextFieldDelegate {
2049 2114
         ])
2050 2115
 
2051 2116
         sidebar.addArrangedSubview(upgradeCard)
2117
+        sidebarUpgradeCard = upgradeCard
2118
+        applyProSubscriptionToSidebar()
2052 2119
     }
2053 2120
 
2054 2121
     @objc private func didTapUpgradeToPro() {
2055
-        guard let hostWindow = window else { return }
2056
-
2057
-        if premiumPlansWindowController == nil {
2058
-            premiumPlansWindowController = PremiumPlansWindowController()
2059
-        }
2060
-        guard let paywallWindow = premiumPlansWindowController?.window else { return }
2061
-
2062
-        if hostWindow.attachedSheet === paywallWindow {
2063
-            return
2064
-        }
2065
-
2066
-        let hostContentSize = hostWindow.contentView?.bounds.size ?? hostWindow.frame.size
2067
-        paywallWindow.setContentSize(hostContentSize)
2068
-        paywallWindow.minSize = hostContentSize
2069
-        paywallWindow.maxSize = hostContentSize
2070
-        paywallWindow.styleMask.insert(.fullSizeContentView)
2071
-        paywallWindow.titlebarAppearsTransparent = true
2072
-        paywallWindow.titleVisibility = .hidden
2073
-
2074
-        hostWindow.beginSheet(paywallWindow)
2122
+        presentPremiumPlansSheet()
2075 2123
     }
2076 2124
 
2077 2125
     @objc private func didChangeThemeSelection(_ sender: NSSegmentedControl) {