Quellcode durchsuchen

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 vor 3 Monaten
Ursprung
Commit
94c4d7a92d

+ 82 - 0
meetings_app.xcodeproj/xcshareddata/xcschemes/meetings_app.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 = "13AF5DA12F83DE23001BE867"
+               BuildableName = "meetings_app.app"
+               BlueprintName = "meetings_app"
+               ReferencedContainer = "container:meetings_app.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 = "13AF5DA12F83DE23001BE867"
+            BuildableName = "meetings_app.app"
+            BlueprintName = "meetings_app"
+            ReferencedContainer = "container:meetings_app.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+      <StoreKitConfigurationFileReference
+         identifier = "../../meetings_app/StoreKit.storekit">
+      </StoreKitConfigurationFileReference>
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Release"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "13AF5DA12F83DE23001BE867"
+            BuildableName = "meetings_app.app"
+            BlueprintName = "meetings_app"
+            ReferencedContainer = "container:meetings_app.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

+ 82 - 0
meetings_app/StoreKit.storekit

@@ -0,0 +1,82 @@
+{
+  "identifier" : "7B5DA685-94A9-4A9B-86EA-F7D90A0D5249",
+  "nonRenewingSubscriptions" : [],
+  "products" : [
+    {
+      "displayPrice" : "1100.00",
+      "familyShareable" : false,
+      "internalID" : "F16C0A1F-5B83-41AB-A9F2-69157A11A11A",
+      "localizations" : [
+        {
+          "description" : "Unlock premium features with weekly access.",
+          "displayName" : "Premium Weekly",
+          "locale" : "en_US"
+        }
+      ],
+      "productID" : "com.mqldev.meetingsapp.premium.weekly",
+      "referenceName" : "Premium Weekly",
+      "type" : "NonConsumable"
+    },
+    {
+      "displayPrice" : "2500.00",
+      "familyShareable" : false,
+      "internalID" : "B2B57D59-AE2B-4953-BF03-5D4AFECAC6C1",
+      "localizations" : [
+        {
+          "description" : "Unlock premium features with monthly access.",
+          "displayName" : "Premium Monthly",
+          "locale" : "en_US"
+        }
+      ],
+      "productID" : "com.mqldev.meetingsapp.premium.monthly",
+      "referenceName" : "Premium Monthly",
+      "type" : "NonConsumable"
+    },
+    {
+      "displayPrice" : "9900.00",
+      "familyShareable" : false,
+      "internalID" : "C5694F51-47D8-4AFD-9D33-95A888527BB5",
+      "localizations" : [
+        {
+          "description" : "Unlock premium features with yearly access.",
+          "displayName" : "Premium Yearly",
+          "locale" : "en_US"
+        }
+      ],
+      "productID" : "com.mqldev.meetingsapp.premium.yearly",
+      "referenceName" : "Premium Yearly",
+      "type" : "NonConsumable"
+    },
+    {
+      "displayPrice" : "14900.00",
+      "familyShareable" : false,
+      "internalID" : "7F9AA412-9F7A-4DF7-BCFB-DF308359DCEF",
+      "localizations" : [
+        {
+          "description" : "One-time premium purchase for lifetime access.",
+          "displayName" : "Premium Lifetime",
+          "locale" : "en_US"
+        }
+      ],
+      "productID" : "com.mqldev.meetingsapp.premium.lifetime",
+      "referenceName" : "Premium Lifetime",
+      "type" : "NonConsumable"
+    }
+  ],
+  "settings" : {
+    "_applicationInternalID" : "A53B9DA3-4C5B-40F4-8452-AD3B8DDE5B9F",
+    "_developerTeamID" : "",
+    "_disableDialogs" : false,
+    "_failTransactionsEnabled" : false,
+    "_lastSynchronizedDate" : 0,
+    "_locale" : "en_US",
+    "_renewalRate" : 0,
+    "_storefront" : "USA",
+    "_timeRate" : 0
+  },
+  "subscriptionGroups" : [],
+  "version" : {
+    "major" : 3,
+    "minor" : 0
+  }
+}

+ 310 - 2
meetings_app/ViewController.swift

@@ -9,6 +9,7 @@ import Cocoa
 import QuartzCore
 import QuartzCore
 import WebKit
 import WebKit
 import AuthenticationServices
 import AuthenticationServices
+import StoreKit
 
 
 private enum SidebarPage: Int {
 private enum SidebarPage: Int {
     case joinMeetings = 0
     case joinMeetings = 0
@@ -38,6 +39,168 @@ private enum PremiumPlan: Int {
     case lifetime = 3
     case lifetime = 3
 }
 }
 
 
+private enum PremiumStoreProduct {
+    static let weekly = "com.mqldev.meetingsapp.premium.weekly"
+    static let monthly = "com.mqldev.meetingsapp.premium.monthly"
+    static let yearly = "com.mqldev.meetingsapp.premium.yearly"
+    static let lifetime = "com.mqldev.meetingsapp.premium.lifetime"
+
+    static let allIDs = [weekly, monthly, yearly, lifetime]
+
+    static func productID(for plan: PremiumPlan) -> String {
+        switch plan {
+        case .weekly: return weekly
+        case .monthly: return monthly
+        case .yearly: return yearly
+        case .lifetime: return lifetime
+        }
+    }
+
+    static func plan(for productID: String) -> PremiumPlan? {
+        switch productID {
+        case weekly: return .weekly
+        case monthly: return .monthly
+        case yearly: return .yearly
+        case lifetime: return .lifetime
+        default: return nil
+        }
+    }
+}
+
+@MainActor
+private final class StoreKitCoordinator {
+    enum PurchaseOutcome {
+        case success
+        case cancelled
+        case pending
+        case unavailable
+        case alreadyOwned
+        case failed(String)
+    }
+
+    private(set) var productsByID: [String: Product] = [:]
+    private(set) var activeEntitlementProductIDs: Set<String> = []
+    private(set) var lastProductLoadError: String?
+
+    var hasPremiumAccess: Bool { !activeEntitlementProductIDs.isEmpty }
+
+    private var transactionUpdatesTask: Task<Void, Never>?
+
+    deinit {
+        transactionUpdatesTask?.cancel()
+    }
+
+    func start() async {
+        if transactionUpdatesTask == nil {
+            transactionUpdatesTask = Task { [weak self] in
+                await self?.observeTransactionUpdates()
+            }
+        }
+        await refreshProducts()
+        await refreshEntitlements()
+    }
+
+    func refreshProducts() async {
+        do {
+            let products = try await Product.products(for: PremiumStoreProduct.allIDs)
+            productsByID = Dictionary(uniqueKeysWithValues: products.map { ($0.id, $0) })
+            lastProductLoadError = nil
+        } catch {
+            productsByID = [:]
+            lastProductLoadError = error.localizedDescription
+        }
+    }
+
+    func refreshEntitlements() async {
+        var active = Set<String>()
+        for await entitlement in Transaction.currentEntitlements {
+            guard case .verified(let transaction) = entitlement else { continue }
+            guard PremiumStoreProduct.allIDs.contains(transaction.productID) else { continue }
+            if Self.isTransactionActive(transaction) {
+                active.insert(transaction.productID)
+            }
+        }
+        activeEntitlementProductIDs = active
+    }
+
+    func purchase(plan: PremiumPlan) async -> PurchaseOutcome {
+        let productID = PremiumStoreProduct.productID(for: plan)
+
+        if activeEntitlementProductIDs.contains(productID) {
+            return .alreadyOwned
+        }
+
+        guard let product = productsByID[productID] else {
+            await refreshProducts()
+            guard let refreshed = productsByID[productID] else {
+                if let lastProductLoadError, !lastProductLoadError.isEmpty {
+                    return .failed("Unable to load products: \(lastProductLoadError)")
+                }
+                let loadedIDs = productsByID.keys.sorted().joined(separator: ", ")
+                let debugIDs = loadedIDs.isEmpty ? "none" : loadedIDs
+                return .failed("Product ID not found in StoreKit response. Requested: \(productID). Loaded IDs: \(debugIDs)")
+            }
+            return await purchase(product: refreshed)
+        }
+
+        return await purchase(product: product)
+    }
+
+    func restorePurchases() async -> String {
+        do {
+            try await AppStore.sync()
+            await refreshEntitlements()
+            if hasPremiumAccess {
+                return "Purchases restored successfully."
+            }
+            return "No previous premium purchase was found for this Apple ID."
+        } catch {
+            return "Restore failed: \(error.localizedDescription)"
+        }
+    }
+
+    private func purchase(product: Product) async -> PurchaseOutcome {
+        do {
+            let result = try await product.purchase()
+            switch result {
+            case .success(let verificationResult):
+                guard case .verified(let transaction) = verificationResult else {
+                    return .failed("Purchase verification failed.")
+                }
+                await transaction.finish()
+                await refreshEntitlements()
+                return .success
+            case .pending:
+                return .pending
+            case .userCancelled:
+                return .cancelled
+            @unknown default:
+                return .failed("Unknown purchase state.")
+            }
+        } catch {
+            return .failed(error.localizedDescription)
+        }
+    }
+
+    private func observeTransactionUpdates() async {
+        for await update in Transaction.updates {
+            guard case .verified(let transaction) = update else { continue }
+            if PremiumStoreProduct.allIDs.contains(transaction.productID) {
+                await refreshEntitlements()
+            }
+            await transaction.finish()
+        }
+    }
+
+    private static func isTransactionActive(_ transaction: Transaction) -> Bool {
+        if transaction.revocationDate != nil { return false }
+        if let expirationDate = transaction.expirationDate {
+            return expirationDate > Date()
+        }
+        return true
+    }
+}
+
 final class ViewController: NSViewController {
 final class ViewController: NSViewController {
     private struct GoogleProfileDisplay {
     private struct GoogleProfileDisplay {
         let name: String
         let name: String
@@ -66,11 +229,19 @@ final class ViewController: NSViewController {
     private var paywallPlanViews: [PremiumPlan: NSView] = [:]
     private var paywallPlanViews: [PremiumPlan: NSView] = [:]
     private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
     private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
     private weak var paywallOfferLabel: NSTextField?
     private weak var paywallOfferLabel: NSTextField?
+    private weak var paywallContinueLabel: NSTextField?
+    private weak var paywallContinueButton: NSView?
     private weak var meetLinkField: NSTextField?
     private weak var meetLinkField: NSTextField?
     private weak var browseAddressField: NSTextField?
     private weak var browseAddressField: NSTextField?
     private var inAppBrowserWindowController: InAppBrowserWindowController?
     private var inAppBrowserWindowController: InAppBrowserWindowController?
     private let googleOAuth = GoogleOAuthService.shared
     private let googleOAuth = GoogleOAuthService.shared
     private let calendarClient = GoogleCalendarClient()
     private let calendarClient = GoogleCalendarClient()
+    private let storeKitCoordinator = StoreKitCoordinator()
+    private var storeKitStartupTask: Task<Void, Never>?
+    private var paywallPurchaseTask: Task<Void, Never>?
+    private var paywallPriceLabels: [PremiumPlan: NSTextField] = [:]
+    private var paywallSubtitleLabels: [PremiumPlan: NSTextField] = [:]
+    private var paywallContinueEnabled = true
 
 
     private enum ScheduleFilter: Int {
     private enum ScheduleFilter: Int {
         case all = 0
         case all = 0
@@ -139,6 +310,7 @@ final class ViewController: NSViewController {
         palette = Palette(isDarkMode: darkModeEnabled)
         palette = Palette(isDarkMode: darkModeEnabled)
         setupRootView()
         setupRootView()
         buildMainLayout()
         buildMainLayout()
+        startStoreKit()
     }
     }
 
 
     override func viewDidAppear() {
     override func viewDidAppear() {
@@ -167,6 +339,11 @@ final class ViewController: NSViewController {
     override var representedObject: Any? {
     override var representedObject: Any? {
         didSet {}
         didSet {}
     }
     }
+
+    deinit {
+        storeKitStartupTask?.cancel()
+        paywallPurchaseTask?.cancel()
+    }
 }
 }
 
 
 private extension ViewController {
 private extension ViewController {
@@ -445,6 +622,11 @@ private extension ViewController {
         settingsActionByView.removeAll()
         settingsActionByView.removeAll()
         paywallPlanViews.removeAll()
         paywallPlanViews.removeAll()
         premiumPlanByView.removeAll()
         premiumPlanByView.removeAll()
+        paywallPriceLabels.removeAll()
+        paywallSubtitleLabels.removeAll()
+        paywallContinueLabel = nil
+        paywallContinueButton = nil
+        paywallContinueEnabled = true
 
 
         googleAccountPopover?.performClose(nil)
         googleAccountPopover?.performClose(nil)
         googleAccountPopover = nil
         googleAccountPopover = nil
@@ -459,7 +641,14 @@ private extension ViewController {
     private func handleSettingsAction(_ action: SettingsAction) {
     private func handleSettingsAction(_ action: SettingsAction) {
         switch action {
         switch action {
         case .restore:
         case .restore:
-            showSimpleAlert(title: "Restore", message: "Restore action tapped.")
+            settingsPopover?.performClose(nil)
+            settingsPopover = nil
+            Task { [weak self] in
+                guard let self else { return }
+                let message = await self.storeKitCoordinator.restorePurchases()
+                self.refreshPaywallStoreUI()
+                self.showSimpleAlert(title: "Restore Purchases", message: message)
+            }
         case .rateUs:
         case .rateUs:
             settingsPopover?.performClose(nil)
             settingsPopover?.performClose(nil)
             settingsPopover = nil
             settingsPopover = nil
@@ -529,6 +718,12 @@ private extension ViewController {
         panel.makeKeyAndOrderFront(nil)
         panel.makeKeyAndOrderFront(nil)
         NSApp.activate(ignoringOtherApps: true)
         NSApp.activate(ignoringOtherApps: true)
         paywallWindow = panel
         paywallWindow = panel
+
+        Task { [weak self] in
+            guard let self else { return }
+            await self.storeKitCoordinator.refreshProducts()
+            self.refreshPaywallStoreUI()
+        }
     }
     }
 
 
     @objc private func closePaywallClicked(_ sender: Any?) {
     @objc private func closePaywallClicked(_ sender: Any?) {
@@ -582,6 +777,19 @@ private extension ViewController {
     }
     }
 
 
     private func paywallOfferText(for plan: PremiumPlan) -> String {
     private func paywallOfferText(for plan: PremiumPlan) -> String {
+        if storeKitCoordinator.hasPremiumAccess {
+            return "Premium is active on this Apple ID."
+        }
+        let productID = PremiumStoreProduct.productID(for: plan)
+        if let product = storeKitCoordinator.productsByID[productID] {
+            if product.type == .nonConsumable {
+                return "\(product.displayPrice) one-time purchase"
+            }
+            if let period = product.subscription?.subscriptionPeriod {
+                return "\(product.displayPrice)/\(subscriptionUnitText(period.unit))"
+            }
+            return product.displayPrice
+        }
         switch plan {
         switch plan {
         case .weekly:
         case .weekly:
             return "Rs 1,100.00/week"
             return "Rs 1,100.00/week"
@@ -594,6 +802,99 @@ private extension ViewController {
         }
         }
     }
     }
 
 
+    private func subscriptionUnitText(_ unit: Product.SubscriptionPeriod.Unit) -> String {
+        switch unit {
+        case .day: return "day"
+        case .week: return "week"
+        case .month: return "month"
+        case .year: return "year"
+        @unknown default: return "period"
+        }
+    }
+
+    private func startStoreKit() {
+        storeKitStartupTask?.cancel()
+        storeKitStartupTask = Task { [weak self] in
+            guard let self else { return }
+            await self.storeKitCoordinator.start()
+            self.refreshPaywallStoreUI()
+        }
+    }
+
+    private func refreshPaywallStoreUI() {
+        for (plan, label) in paywallPriceLabels {
+            let productID = PremiumStoreProduct.productID(for: plan)
+            if let product = storeKitCoordinator.productsByID[productID] {
+                label.stringValue = product.displayPrice
+            }
+        }
+        for (plan, label) in paywallSubtitleLabels {
+            let productID = PremiumStoreProduct.productID(for: plan)
+            guard let product = storeKitCoordinator.productsByID[productID],
+                  let period = product.subscription?.subscriptionPeriod else { continue }
+            label.stringValue = "\(product.displayPrice)/\(subscriptionUnitText(period.unit))"
+        }
+        updatePaywallPlanSelection()
+        updatePaywallContinueState(isLoading: false)
+    }
+
+    @objc private func paywallContinueClicked(_ sender: Any?) {
+        startSelectedPlanPurchase()
+    }
+
+    private func startSelectedPlanPurchase() {
+        guard paywallContinueEnabled else {
+            if storeKitCoordinator.hasPremiumAccess {
+                showSimpleAlert(title: "Premium Active", message: "This Apple ID already has premium access.")
+            } else {
+                showSimpleAlert(title: "Please Wait", message: "A purchase is already being processed.")
+            }
+            return
+        }
+        paywallPurchaseTask?.cancel()
+        updatePaywallContinueState(isLoading: true)
+        let selectedPlan = selectedPremiumPlan
+        paywallPurchaseTask = Task { [weak self] in
+            guard let self else { return }
+            let result = await self.storeKitCoordinator.purchase(plan: selectedPlan)
+            self.updatePaywallContinueState(isLoading: false)
+            self.refreshPaywallStoreUI()
+            switch result {
+            case .success:
+                self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
+                self.paywallWindow?.performClose(nil)
+            case .cancelled:
+                break
+            case .pending:
+                self.showSimpleAlert(title: "Purchase Pending", message: "Your purchase is pending approval. You can continue once it completes.")
+            case .unavailable:
+                self.showSimpleAlert(title: "Product Not Available", message: "Unable to load this product. Check your StoreKit configuration and product IDs.")
+            case .alreadyOwned:
+                self.showSimpleAlert(title: "Already Purchased", message: "This plan is already active on your Apple ID.")
+            case .failed(let message):
+                self.showSimpleAlert(title: "Purchase Failed", message: message)
+            }
+        }
+    }
+
+    private func updatePaywallContinueState(isLoading: Bool) {
+        if isLoading {
+            paywallContinueEnabled = false
+            paywallContinueLabel?.stringValue = "Processing..."
+            paywallContinueButton?.alphaValue = 0.75
+            return
+        }
+        if storeKitCoordinator.hasPremiumAccess {
+            paywallContinueEnabled = false
+            paywallContinueLabel?.stringValue = "Premium Active"
+            paywallContinueButton?.alphaValue = 0.75
+        } else {
+            paywallContinueEnabled = true
+            paywallContinueLabel?.stringValue = "Continue"
+            paywallContinueButton?.alphaValue = 1.0
+        }
+    }
+
     private func applyPaywallPlanStyle(_ card: NSView, isSelected: Bool, hovering: Bool = false) {
     private func applyPaywallPlanStyle(_ card: NSView, isSelected: Bool, hovering: Bool = false) {
         let selectedBorder = NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1)
         let selectedBorder = NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1)
         let idleBorder = palette.inputBorder
         let idleBorder = palette.inputBorder
@@ -1270,8 +1571,10 @@ private extension ViewController {
         contentStack.addArrangedSubview(offerWrap)
         contentStack.addArrangedSubview(offerWrap)
         contentStack.setCustomSpacing(18, after: offerWrap)
         contentStack.setCustomSpacing(18, after: offerWrap)
 
 
-        let continueButton = HoverTrackingView()
+        let continueButton = HoverButton(title: "", target: self, action: #selector(paywallContinueClicked(_:)))
         continueButton.translatesAutoresizingMaskIntoConstraints = false
         continueButton.translatesAutoresizingMaskIntoConstraints = false
+        continueButton.isBordered = false
+        continueButton.bezelStyle = .regularSquare
         continueButton.wantsLayer = true
         continueButton.wantsLayer = true
         continueButton.layer?.cornerRadius = 14
         continueButton.layer?.cornerRadius = 14
         continueButton.layer?.backgroundColor = palette.primaryBlue.cgColor
         continueButton.layer?.backgroundColor = palette.primaryBlue.cgColor
@@ -1291,6 +1594,8 @@ private extension ViewController {
             continueButton.layer?.backgroundColor = (hovering ? hoverBlue : baseBlue).cgColor
             continueButton.layer?.backgroundColor = (hovering ? hoverBlue : baseBlue).cgColor
         }
         }
         continueButton.onHoverChanged?(false)
         continueButton.onHoverChanged?(false)
+        paywallContinueButton = continueButton
+        paywallContinueLabel = continueLabel
         contentStack.addArrangedSubview(continueButton)
         contentStack.addArrangedSubview(continueButton)
         contentStack.setCustomSpacing(16, after: continueButton)
         contentStack.setCustomSpacing(16, after: continueButton)
 
 
@@ -1318,6 +1623,7 @@ private extension ViewController {
             contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -12)
             contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -12)
         ])
         ])
 
 
+        refreshPaywallStoreUI()
         return panel
         return panel
     }
     }
 
 
@@ -1378,6 +1684,7 @@ private extension ViewController {
         card.addSubview(titleLabel)
         card.addSubview(titleLabel)
         let priceLabel = textLabel(price, font: NSFont.systemFont(ofSize: 12, weight: .bold), color: palette.textPrimary)
         let priceLabel = textLabel(price, font: NSFont.systemFont(ofSize: 12, weight: .bold), color: palette.textPrimary)
         card.addSubview(priceLabel)
         card.addSubview(priceLabel)
+        paywallPriceLabels[plan] = priceLabel
 
 
         NSLayoutConstraint.activate([
         NSLayoutConstraint.activate([
             badgeWrap.centerXAnchor.constraint(equalTo: card.centerXAnchor),
             badgeWrap.centerXAnchor.constraint(equalTo: card.centerXAnchor),
@@ -1393,6 +1700,7 @@ private extension ViewController {
         if let subtitle {
         if let subtitle {
             let sub = textLabel(subtitle, font: NSFont.systemFont(ofSize: 10, weight: .semibold), color: palette.textSecondary)
             let sub = textLabel(subtitle, font: NSFont.systemFont(ofSize: 10, weight: .semibold), color: palette.textSecondary)
             card.addSubview(sub)
             card.addSubview(sub)
+            paywallSubtitleLabels[plan] = sub
             NSLayoutConstraint.activate([
             NSLayoutConstraint.activate([
                 sub.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
                 sub.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
                 sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 0)
                 sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 0)