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

Integrate StoreKit 2 purchases and local testing config.

Wire the paywall to real product loading, purchase, entitlement sync, and restore flows, and add a shared StoreKit scheme/config so local StoreKit testing works reliably in Xcode.

Made-with: Cursor
huzaifahayat12 1 неделя назад
Родитель
Сommit
94c4d7a92d

+ 82 - 0
meetings_app.xcodeproj/xcshareddata/xcschemes/meetings_app.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 = "13AF5DA12F83DE23001BE867"
19
+               BuildableName = "meetings_app.app"
20
+               BlueprintName = "meetings_app"
21
+               ReferencedContainer = "container:meetings_app.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 = "13AF5DA12F83DE23001BE867"
49
+            BuildableName = "meetings_app.app"
50
+            BlueprintName = "meetings_app"
51
+            ReferencedContainer = "container:meetings_app.xcodeproj">
52
+         </BuildableReference>
53
+      </BuildableProductRunnable>
54
+      <StoreKitConfigurationFileReference
55
+         identifier = "../../meetings_app/StoreKit.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 = "13AF5DA12F83DE23001BE867"
69
+            BuildableName = "meetings_app.app"
70
+            BlueprintName = "meetings_app"
71
+            ReferencedContainer = "container:meetings_app.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>

+ 82 - 0
meetings_app/StoreKit.storekit

@@ -0,0 +1,82 @@
1
+{
2
+  "identifier" : "7B5DA685-94A9-4A9B-86EA-F7D90A0D5249",
3
+  "nonRenewingSubscriptions" : [],
4
+  "products" : [
5
+    {
6
+      "displayPrice" : "1100.00",
7
+      "familyShareable" : false,
8
+      "internalID" : "F16C0A1F-5B83-41AB-A9F2-69157A11A11A",
9
+      "localizations" : [
10
+        {
11
+          "description" : "Unlock premium features with weekly access.",
12
+          "displayName" : "Premium Weekly",
13
+          "locale" : "en_US"
14
+        }
15
+      ],
16
+      "productID" : "com.mqldev.meetingsapp.premium.weekly",
17
+      "referenceName" : "Premium Weekly",
18
+      "type" : "NonConsumable"
19
+    },
20
+    {
21
+      "displayPrice" : "2500.00",
22
+      "familyShareable" : false,
23
+      "internalID" : "B2B57D59-AE2B-4953-BF03-5D4AFECAC6C1",
24
+      "localizations" : [
25
+        {
26
+          "description" : "Unlock premium features with monthly access.",
27
+          "displayName" : "Premium Monthly",
28
+          "locale" : "en_US"
29
+        }
30
+      ],
31
+      "productID" : "com.mqldev.meetingsapp.premium.monthly",
32
+      "referenceName" : "Premium Monthly",
33
+      "type" : "NonConsumable"
34
+    },
35
+    {
36
+      "displayPrice" : "9900.00",
37
+      "familyShareable" : false,
38
+      "internalID" : "C5694F51-47D8-4AFD-9D33-95A888527BB5",
39
+      "localizations" : [
40
+        {
41
+          "description" : "Unlock premium features with yearly access.",
42
+          "displayName" : "Premium Yearly",
43
+          "locale" : "en_US"
44
+        }
45
+      ],
46
+      "productID" : "com.mqldev.meetingsapp.premium.yearly",
47
+      "referenceName" : "Premium Yearly",
48
+      "type" : "NonConsumable"
49
+    },
50
+    {
51
+      "displayPrice" : "14900.00",
52
+      "familyShareable" : false,
53
+      "internalID" : "7F9AA412-9F7A-4DF7-BCFB-DF308359DCEF",
54
+      "localizations" : [
55
+        {
56
+          "description" : "One-time premium purchase for lifetime access.",
57
+          "displayName" : "Premium Lifetime",
58
+          "locale" : "en_US"
59
+        }
60
+      ],
61
+      "productID" : "com.mqldev.meetingsapp.premium.lifetime",
62
+      "referenceName" : "Premium Lifetime",
63
+      "type" : "NonConsumable"
64
+    }
65
+  ],
66
+  "settings" : {
67
+    "_applicationInternalID" : "A53B9DA3-4C5B-40F4-8452-AD3B8DDE5B9F",
68
+    "_developerTeamID" : "",
69
+    "_disableDialogs" : false,
70
+    "_failTransactionsEnabled" : false,
71
+    "_lastSynchronizedDate" : 0,
72
+    "_locale" : "en_US",
73
+    "_renewalRate" : 0,
74
+    "_storefront" : "USA",
75
+    "_timeRate" : 0
76
+  },
77
+  "subscriptionGroups" : [],
78
+  "version" : {
79
+    "major" : 3,
80
+    "minor" : 0
81
+  }
82
+}

+ 310 - 2
meetings_app/ViewController.swift

@@ -9,6 +9,7 @@ import Cocoa
9 9
 import QuartzCore
10 10
 import WebKit
11 11
 import AuthenticationServices
12
+import StoreKit
12 13
 
13 14
 private enum SidebarPage: Int {
14 15
     case joinMeetings = 0
@@ -38,6 +39,168 @@ private enum PremiumPlan: Int {
38 39
     case lifetime = 3
39 40
 }
40 41
 
42
+private enum PremiumStoreProduct {
43
+    static let weekly = "com.mqldev.meetingsapp.premium.weekly"
44
+    static let monthly = "com.mqldev.meetingsapp.premium.monthly"
45
+    static let yearly = "com.mqldev.meetingsapp.premium.yearly"
46
+    static let lifetime = "com.mqldev.meetingsapp.premium.lifetime"
47
+
48
+    static let allIDs = [weekly, monthly, yearly, lifetime]
49
+
50
+    static func productID(for plan: PremiumPlan) -> String {
51
+        switch plan {
52
+        case .weekly: return weekly
53
+        case .monthly: return monthly
54
+        case .yearly: return yearly
55
+        case .lifetime: return lifetime
56
+        }
57
+    }
58
+
59
+    static func plan(for productID: String) -> PremiumPlan? {
60
+        switch productID {
61
+        case weekly: return .weekly
62
+        case monthly: return .monthly
63
+        case yearly: return .yearly
64
+        case lifetime: return .lifetime
65
+        default: return nil
66
+        }
67
+    }
68
+}
69
+
70
+@MainActor
71
+private final class StoreKitCoordinator {
72
+    enum PurchaseOutcome {
73
+        case success
74
+        case cancelled
75
+        case pending
76
+        case unavailable
77
+        case alreadyOwned
78
+        case failed(String)
79
+    }
80
+
81
+    private(set) var productsByID: [String: Product] = [:]
82
+    private(set) var activeEntitlementProductIDs: Set<String> = []
83
+    private(set) var lastProductLoadError: String?
84
+
85
+    var hasPremiumAccess: Bool { !activeEntitlementProductIDs.isEmpty }
86
+
87
+    private var transactionUpdatesTask: Task<Void, Never>?
88
+
89
+    deinit {
90
+        transactionUpdatesTask?.cancel()
91
+    }
92
+
93
+    func start() async {
94
+        if transactionUpdatesTask == nil {
95
+            transactionUpdatesTask = Task { [weak self] in
96
+                await self?.observeTransactionUpdates()
97
+            }
98
+        }
99
+        await refreshProducts()
100
+        await refreshEntitlements()
101
+    }
102
+
103
+    func refreshProducts() async {
104
+        do {
105
+            let products = try await Product.products(for: PremiumStoreProduct.allIDs)
106
+            productsByID = Dictionary(uniqueKeysWithValues: products.map { ($0.id, $0) })
107
+            lastProductLoadError = nil
108
+        } catch {
109
+            productsByID = [:]
110
+            lastProductLoadError = error.localizedDescription
111
+        }
112
+    }
113
+
114
+    func refreshEntitlements() async {
115
+        var active = Set<String>()
116
+        for await entitlement in Transaction.currentEntitlements {
117
+            guard case .verified(let transaction) = entitlement else { continue }
118
+            guard PremiumStoreProduct.allIDs.contains(transaction.productID) else { continue }
119
+            if Self.isTransactionActive(transaction) {
120
+                active.insert(transaction.productID)
121
+            }
122
+        }
123
+        activeEntitlementProductIDs = active
124
+    }
125
+
126
+    func purchase(plan: PremiumPlan) async -> PurchaseOutcome {
127
+        let productID = PremiumStoreProduct.productID(for: plan)
128
+
129
+        if activeEntitlementProductIDs.contains(productID) {
130
+            return .alreadyOwned
131
+        }
132
+
133
+        guard let product = productsByID[productID] else {
134
+            await refreshProducts()
135
+            guard let refreshed = productsByID[productID] else {
136
+                if let lastProductLoadError, !lastProductLoadError.isEmpty {
137
+                    return .failed("Unable to load products: \(lastProductLoadError)")
138
+                }
139
+                let loadedIDs = productsByID.keys.sorted().joined(separator: ", ")
140
+                let debugIDs = loadedIDs.isEmpty ? "none" : loadedIDs
141
+                return .failed("Product ID not found in StoreKit response. Requested: \(productID). Loaded IDs: \(debugIDs)")
142
+            }
143
+            return await purchase(product: refreshed)
144
+        }
145
+
146
+        return await purchase(product: product)
147
+    }
148
+
149
+    func restorePurchases() async -> String {
150
+        do {
151
+            try await AppStore.sync()
152
+            await refreshEntitlements()
153
+            if hasPremiumAccess {
154
+                return "Purchases restored successfully."
155
+            }
156
+            return "No previous premium purchase was found for this Apple ID."
157
+        } catch {
158
+            return "Restore failed: \(error.localizedDescription)"
159
+        }
160
+    }
161
+
162
+    private func purchase(product: Product) async -> PurchaseOutcome {
163
+        do {
164
+            let result = try await product.purchase()
165
+            switch result {
166
+            case .success(let verificationResult):
167
+                guard case .verified(let transaction) = verificationResult else {
168
+                    return .failed("Purchase verification failed.")
169
+                }
170
+                await transaction.finish()
171
+                await refreshEntitlements()
172
+                return .success
173
+            case .pending:
174
+                return .pending
175
+            case .userCancelled:
176
+                return .cancelled
177
+            @unknown default:
178
+                return .failed("Unknown purchase state.")
179
+            }
180
+        } catch {
181
+            return .failed(error.localizedDescription)
182
+        }
183
+    }
184
+
185
+    private func observeTransactionUpdates() async {
186
+        for await update in Transaction.updates {
187
+            guard case .verified(let transaction) = update else { continue }
188
+            if PremiumStoreProduct.allIDs.contains(transaction.productID) {
189
+                await refreshEntitlements()
190
+            }
191
+            await transaction.finish()
192
+        }
193
+    }
194
+
195
+    private static func isTransactionActive(_ transaction: Transaction) -> Bool {
196
+        if transaction.revocationDate != nil { return false }
197
+        if let expirationDate = transaction.expirationDate {
198
+            return expirationDate > Date()
199
+        }
200
+        return true
201
+    }
202
+}
203
+
41 204
 final class ViewController: NSViewController {
42 205
     private struct GoogleProfileDisplay {
43 206
         let name: String
@@ -66,11 +229,19 @@ final class ViewController: NSViewController {
66 229
     private var paywallPlanViews: [PremiumPlan: NSView] = [:]
67 230
     private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
68 231
     private weak var paywallOfferLabel: NSTextField?
232
+    private weak var paywallContinueLabel: NSTextField?
233
+    private weak var paywallContinueButton: NSView?
69 234
     private weak var meetLinkField: NSTextField?
70 235
     private weak var browseAddressField: NSTextField?
71 236
     private var inAppBrowserWindowController: InAppBrowserWindowController?
72 237
     private let googleOAuth = GoogleOAuthService.shared
73 238
     private let calendarClient = GoogleCalendarClient()
239
+    private let storeKitCoordinator = StoreKitCoordinator()
240
+    private var storeKitStartupTask: Task<Void, Never>?
241
+    private var paywallPurchaseTask: Task<Void, Never>?
242
+    private var paywallPriceLabels: [PremiumPlan: NSTextField] = [:]
243
+    private var paywallSubtitleLabels: [PremiumPlan: NSTextField] = [:]
244
+    private var paywallContinueEnabled = true
74 245
 
75 246
     private enum ScheduleFilter: Int {
76 247
         case all = 0
@@ -139,6 +310,7 @@ final class ViewController: NSViewController {
139 310
         palette = Palette(isDarkMode: darkModeEnabled)
140 311
         setupRootView()
141 312
         buildMainLayout()
313
+        startStoreKit()
142 314
     }
143 315
 
144 316
     override func viewDidAppear() {
@@ -167,6 +339,11 @@ final class ViewController: NSViewController {
167 339
     override var representedObject: Any? {
168 340
         didSet {}
169 341
     }
342
+
343
+    deinit {
344
+        storeKitStartupTask?.cancel()
345
+        paywallPurchaseTask?.cancel()
346
+    }
170 347
 }
171 348
 
172 349
 private extension ViewController {
@@ -445,6 +622,11 @@ private extension ViewController {
445 622
         settingsActionByView.removeAll()
446 623
         paywallPlanViews.removeAll()
447 624
         premiumPlanByView.removeAll()
625
+        paywallPriceLabels.removeAll()
626
+        paywallSubtitleLabels.removeAll()
627
+        paywallContinueLabel = nil
628
+        paywallContinueButton = nil
629
+        paywallContinueEnabled = true
448 630
 
449 631
         googleAccountPopover?.performClose(nil)
450 632
         googleAccountPopover = nil
@@ -459,7 +641,14 @@ private extension ViewController {
459 641
     private func handleSettingsAction(_ action: SettingsAction) {
460 642
         switch action {
461 643
         case .restore:
462
-            showSimpleAlert(title: "Restore", message: "Restore action tapped.")
644
+            settingsPopover?.performClose(nil)
645
+            settingsPopover = nil
646
+            Task { [weak self] in
647
+                guard let self else { return }
648
+                let message = await self.storeKitCoordinator.restorePurchases()
649
+                self.refreshPaywallStoreUI()
650
+                self.showSimpleAlert(title: "Restore Purchases", message: message)
651
+            }
463 652
         case .rateUs:
464 653
             settingsPopover?.performClose(nil)
465 654
             settingsPopover = nil
@@ -529,6 +718,12 @@ private extension ViewController {
529 718
         panel.makeKeyAndOrderFront(nil)
530 719
         NSApp.activate(ignoringOtherApps: true)
531 720
         paywallWindow = panel
721
+
722
+        Task { [weak self] in
723
+            guard let self else { return }
724
+            await self.storeKitCoordinator.refreshProducts()
725
+            self.refreshPaywallStoreUI()
726
+        }
532 727
     }
533 728
 
534 729
     @objc private func closePaywallClicked(_ sender: Any?) {
@@ -582,6 +777,19 @@ private extension ViewController {
582 777
     }
583 778
 
584 779
     private func paywallOfferText(for plan: PremiumPlan) -> String {
780
+        if storeKitCoordinator.hasPremiumAccess {
781
+            return "Premium is active on this Apple ID."
782
+        }
783
+        let productID = PremiumStoreProduct.productID(for: plan)
784
+        if let product = storeKitCoordinator.productsByID[productID] {
785
+            if product.type == .nonConsumable {
786
+                return "\(product.displayPrice) one-time purchase"
787
+            }
788
+            if let period = product.subscription?.subscriptionPeriod {
789
+                return "\(product.displayPrice)/\(subscriptionUnitText(period.unit))"
790
+            }
791
+            return product.displayPrice
792
+        }
585 793
         switch plan {
586 794
         case .weekly:
587 795
             return "Rs 1,100.00/week"
@@ -594,6 +802,99 @@ private extension ViewController {
594 802
         }
595 803
     }
596 804
 
805
+    private func subscriptionUnitText(_ unit: Product.SubscriptionPeriod.Unit) -> String {
806
+        switch unit {
807
+        case .day: return "day"
808
+        case .week: return "week"
809
+        case .month: return "month"
810
+        case .year: return "year"
811
+        @unknown default: return "period"
812
+        }
813
+    }
814
+
815
+    private func startStoreKit() {
816
+        storeKitStartupTask?.cancel()
817
+        storeKitStartupTask = Task { [weak self] in
818
+            guard let self else { return }
819
+            await self.storeKitCoordinator.start()
820
+            self.refreshPaywallStoreUI()
821
+        }
822
+    }
823
+
824
+    private func refreshPaywallStoreUI() {
825
+        for (plan, label) in paywallPriceLabels {
826
+            let productID = PremiumStoreProduct.productID(for: plan)
827
+            if let product = storeKitCoordinator.productsByID[productID] {
828
+                label.stringValue = product.displayPrice
829
+            }
830
+        }
831
+        for (plan, label) in paywallSubtitleLabels {
832
+            let productID = PremiumStoreProduct.productID(for: plan)
833
+            guard let product = storeKitCoordinator.productsByID[productID],
834
+                  let period = product.subscription?.subscriptionPeriod else { continue }
835
+            label.stringValue = "\(product.displayPrice)/\(subscriptionUnitText(period.unit))"
836
+        }
837
+        updatePaywallPlanSelection()
838
+        updatePaywallContinueState(isLoading: false)
839
+    }
840
+
841
+    @objc private func paywallContinueClicked(_ sender: Any?) {
842
+        startSelectedPlanPurchase()
843
+    }
844
+
845
+    private func startSelectedPlanPurchase() {
846
+        guard paywallContinueEnabled else {
847
+            if storeKitCoordinator.hasPremiumAccess {
848
+                showSimpleAlert(title: "Premium Active", message: "This Apple ID already has premium access.")
849
+            } else {
850
+                showSimpleAlert(title: "Please Wait", message: "A purchase is already being processed.")
851
+            }
852
+            return
853
+        }
854
+        paywallPurchaseTask?.cancel()
855
+        updatePaywallContinueState(isLoading: true)
856
+        let selectedPlan = selectedPremiumPlan
857
+        paywallPurchaseTask = Task { [weak self] in
858
+            guard let self else { return }
859
+            let result = await self.storeKitCoordinator.purchase(plan: selectedPlan)
860
+            self.updatePaywallContinueState(isLoading: false)
861
+            self.refreshPaywallStoreUI()
862
+            switch result {
863
+            case .success:
864
+                self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
865
+                self.paywallWindow?.performClose(nil)
866
+            case .cancelled:
867
+                break
868
+            case .pending:
869
+                self.showSimpleAlert(title: "Purchase Pending", message: "Your purchase is pending approval. You can continue once it completes.")
870
+            case .unavailable:
871
+                self.showSimpleAlert(title: "Product Not Available", message: "Unable to load this product. Check your StoreKit configuration and product IDs.")
872
+            case .alreadyOwned:
873
+                self.showSimpleAlert(title: "Already Purchased", message: "This plan is already active on your Apple ID.")
874
+            case .failed(let message):
875
+                self.showSimpleAlert(title: "Purchase Failed", message: message)
876
+            }
877
+        }
878
+    }
879
+
880
+    private func updatePaywallContinueState(isLoading: Bool) {
881
+        if isLoading {
882
+            paywallContinueEnabled = false
883
+            paywallContinueLabel?.stringValue = "Processing..."
884
+            paywallContinueButton?.alphaValue = 0.75
885
+            return
886
+        }
887
+        if storeKitCoordinator.hasPremiumAccess {
888
+            paywallContinueEnabled = false
889
+            paywallContinueLabel?.stringValue = "Premium Active"
890
+            paywallContinueButton?.alphaValue = 0.75
891
+        } else {
892
+            paywallContinueEnabled = true
893
+            paywallContinueLabel?.stringValue = "Continue"
894
+            paywallContinueButton?.alphaValue = 1.0
895
+        }
896
+    }
897
+
597 898
     private func applyPaywallPlanStyle(_ card: NSView, isSelected: Bool, hovering: Bool = false) {
598 899
         let selectedBorder = NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1)
599 900
         let idleBorder = palette.inputBorder
@@ -1270,8 +1571,10 @@ private extension ViewController {
1270 1571
         contentStack.addArrangedSubview(offerWrap)
1271 1572
         contentStack.setCustomSpacing(18, after: offerWrap)
1272 1573
 
1273
-        let continueButton = HoverTrackingView()
1574
+        let continueButton = HoverButton(title: "", target: self, action: #selector(paywallContinueClicked(_:)))
1274 1575
         continueButton.translatesAutoresizingMaskIntoConstraints = false
1576
+        continueButton.isBordered = false
1577
+        continueButton.bezelStyle = .regularSquare
1275 1578
         continueButton.wantsLayer = true
1276 1579
         continueButton.layer?.cornerRadius = 14
1277 1580
         continueButton.layer?.backgroundColor = palette.primaryBlue.cgColor
@@ -1291,6 +1594,8 @@ private extension ViewController {
1291 1594
             continueButton.layer?.backgroundColor = (hovering ? hoverBlue : baseBlue).cgColor
1292 1595
         }
1293 1596
         continueButton.onHoverChanged?(false)
1597
+        paywallContinueButton = continueButton
1598
+        paywallContinueLabel = continueLabel
1294 1599
         contentStack.addArrangedSubview(continueButton)
1295 1600
         contentStack.setCustomSpacing(16, after: continueButton)
1296 1601
 
@@ -1318,6 +1623,7 @@ private extension ViewController {
1318 1623
             contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -12)
1319 1624
         ])
1320 1625
 
1626
+        refreshPaywallStoreUI()
1321 1627
         return panel
1322 1628
     }
1323 1629
 
@@ -1378,6 +1684,7 @@ private extension ViewController {
1378 1684
         card.addSubview(titleLabel)
1379 1685
         let priceLabel = textLabel(price, font: NSFont.systemFont(ofSize: 12, weight: .bold), color: palette.textPrimary)
1380 1686
         card.addSubview(priceLabel)
1687
+        paywallPriceLabels[plan] = priceLabel
1381 1688
 
1382 1689
         NSLayoutConstraint.activate([
1383 1690
             badgeWrap.centerXAnchor.constraint(equalTo: card.centerXAnchor),
@@ -1393,6 +1700,7 @@ private extension ViewController {
1393 1700
         if let subtitle {
1394 1701
             let sub = textLabel(subtitle, font: NSFont.systemFont(ofSize: 10, weight: .semibold), color: palette.textSecondary)
1395 1702
             card.addSubview(sub)
1703
+            paywallSubtitleLabels[plan] = sub
1396 1704
             NSLayoutConstraint.activate([
1397 1705
                 sub.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
1398 1706
                 sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 0)