Kaynağa Gözat

Show Manage Subscription in the paywall footer for pro users.

Improve premium detection and keep the paywall visible so the footer switches between Continue with free plan and Manage Subscription based on active entitlements.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 2 saat önce
ebeveyn
işleme
e30aeaded2

+ 137 - 27
smart_printer/PaywallView.swift

@@ -115,6 +115,7 @@ final class StoreManager {
115
 
115
 
116
     private(set) var products: [Product] = []
116
     private(set) var products: [Product] = []
117
     private(set) var isPremium = false
117
     private(set) var isPremium = false
118
+    var isPro: Bool { isPremium }
118
     private(set) var isLoadingProducts = false
119
     private(set) var isLoadingProducts = false
119
     private(set) var isPurchasing = false
120
     private(set) var isPurchasing = false
120
 
121
 
@@ -221,6 +222,11 @@ final class StoreManager {
221
         }
222
         }
222
     }
223
     }
223
 
224
 
225
+    func showManageSubscriptions() {
226
+        guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
227
+        NSWorkspace.shared.open(url)
228
+    }
229
+
224
     func showPurchaseError(_ error: Error, on window: NSWindow?) {
230
     func showPurchaseError(_ error: Error, on window: NSWindow?) {
225
         if let storeError = error as? StoreError {
231
         if let storeError = error as? StoreError {
226
             showAlert(title: "Purchase Failed", message: storeError.localizedDescription, on: window)
232
             showAlert(title: "Purchase Failed", message: storeError.localizedDescription, on: window)
@@ -244,20 +250,74 @@ final class StoreManager {
244
         }
250
         }
245
     }
251
     }
246
 
252
 
247
-    private func refreshPremiumStatus() async {
248
-        var hasPremium = false
253
+    func refreshPremiumStatus() async {
254
+        let hasPremium = await hasActivePremiumAccess()
255
+        let didChange = hasPremium != isPremium
256
+        isPremium = hasPremium
257
+
258
+        if didChange {
259
+            NotificationCenter.default.post(name: .premiumStatusDidChange, object: nil)
260
+        }
261
+        postStoreStateDidChange()
262
+    }
249
 
263
 
264
+    private func hasActivePremiumAccess() async -> Bool {
265
+        if await hasEntitlementFromCurrentEntitlements() {
266
+            return true
267
+        }
268
+        if await hasEntitlementFromLatestTransactions() {
269
+            return true
270
+        }
271
+        if await hasEntitlementFromSubscriptionStatus() {
272
+            return true
273
+        }
274
+        return false
275
+    }
276
+
277
+    private func hasEntitlementFromCurrentEntitlements() async -> Bool {
250
         for await result in Transaction.currentEntitlements {
278
         for await result in Transaction.currentEntitlements {
251
             guard let transaction = try? checkVerified(result) else { continue }
279
             guard let transaction = try? checkVerified(result) else { continue }
252
-            if StoreProductID.all.contains(transaction.productID) {
253
-                hasPremium = true
254
-                break
280
+            if isActivePremiumTransaction(transaction) {
281
+                return true
255
             }
282
             }
256
         }
283
         }
284
+        return false
285
+    }
257
 
286
 
258
-        guard hasPremium != isPremium else { return }
259
-        isPremium = hasPremium
260
-        NotificationCenter.default.post(name: .premiumStatusDidChange, object: nil)
287
+    private func hasEntitlementFromLatestTransactions() async -> Bool {
288
+        for productID in StoreProductID.all {
289
+            guard let result = await Transaction.latest(for: productID),
290
+                  let transaction = try? checkVerified(result) else { continue }
291
+            if isActivePremiumTransaction(transaction) {
292
+                return true
293
+            }
294
+        }
295
+        return false
296
+    }
297
+
298
+    private func hasEntitlementFromSubscriptionStatus() async -> Bool {
299
+        for product in products where product.subscription != nil {
300
+            guard StoreProductID.all.contains(product.id) else { continue }
301
+            guard let statuses = try? await product.subscription?.status else { continue }
302
+            for status in statuses {
303
+                switch status.state {
304
+                case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
305
+                    return true
306
+                default:
307
+                    continue
308
+                }
309
+            }
310
+        }
311
+        return false
312
+    }
313
+
314
+    private func isActivePremiumTransaction(_ transaction: Transaction) -> Bool {
315
+        guard StoreProductID.all.contains(transaction.productID) else { return false }
316
+        guard transaction.revocationDate == nil else { return false }
317
+        if let expirationDate = transaction.expirationDate {
318
+            return expirationDate > Date()
319
+        }
320
+        return true
261
     }
321
     }
262
 
322
 
263
     private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
323
     private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
@@ -582,8 +642,9 @@ private final class PaywallFooterLink: NSButton, AppearanceRefreshable {
582
 
642
 
583
     init(title: String) {
643
     init(title: String) {
584
         super.init(frame: .zero)
644
         super.init(frame: .zero)
585
-        self.title = title
645
+        updateTitle(title)
586
         isBordered = false
646
         isBordered = false
647
+        bezelStyle = .inline
587
         font = AppTheme.regularFont(size: 11)
648
         font = AppTheme.regularFont(size: 11)
588
         translatesAutoresizingMaskIntoConstraints = false
649
         translatesAutoresizingMaskIntoConstraints = false
589
         refreshAppearance()
650
         refreshAppearance()
@@ -597,6 +658,12 @@ private final class PaywallFooterLink: NSButton, AppearanceRefreshable {
597
     @available(*, unavailable)
658
     @available(*, unavailable)
598
     required init?(coder: NSCoder) { nil }
659
     required init?(coder: NSCoder) { nil }
599
 
660
 
661
+    func updateTitle(_ title: String) {
662
+        self.title = title
663
+        invalidateIntrinsicContentSize()
664
+        needsDisplay = true
665
+    }
666
+
600
     func refreshAppearance() {
667
     func refreshAppearance() {
601
         contentTintColor = isHovered ? AppTheme.textPrimary : AppTheme.textSecondary
668
         contentTintColor = isHovered ? AppTheme.textPrimary : AppTheme.textSecondary
602
     }
669
     }
@@ -733,6 +800,9 @@ final class PaywallView: NSView, AppearanceRefreshable {
733
     private var selectedPlan: PaywallPlan = .yearly
800
     private var selectedPlan: PaywallPlan = .yearly
734
     private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
801
     private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
735
     private let ctaButton = PaywallCTAButton()
802
     private let ctaButton = PaywallCTAButton()
803
+    private let continueFreePlanLink = PaywallFooterLink(title: "Continue with free plan")
804
+    private let manageSubscriptionLink = PaywallFooterLink(title: "Manage Subscription")
805
+    private var primaryFooterLinkCell: NSView?
736
     private var leftPanelTitle: NSTextField!
806
     private var leftPanelTitle: NSTextField!
737
     private var rightTitle: NSTextField!
807
     private var rightTitle: NSTextField!
738
     private var rightSubtitle: NSTextField!
808
     private var rightSubtitle: NSTextField!
@@ -783,6 +853,19 @@ final class PaywallView: NSView, AppearanceRefreshable {
783
         let isBusy = store.isPurchasing || store.isLoadingProducts
853
         let isBusy = store.isPurchasing || store.isLoadingProducts
784
         ctaButton.isEnabled = !isBusy
854
         ctaButton.isEnabled = !isBusy
785
         ctaButton.alphaValue = isBusy ? 0.65 : 1
855
         ctaButton.alphaValue = isBusy ? 0.65 : 1
856
+        refreshPrimaryFooterLink()
857
+    }
858
+
859
+    private func refreshPrimaryFooterLink() {
860
+        let isPro = StoreManager.shared.isPro
861
+        continueFreePlanLink.isHidden = isPro
862
+        manageSubscriptionLink.isHidden = !isPro
863
+        primaryFooterLinkCell?.needsLayout = true
864
+        primaryFooterLinkCell?.layoutSubtreeIfNeeded()
865
+    }
866
+
867
+    func refreshStoreState() {
868
+        refreshProductDisplay()
786
     }
869
     }
787
 
870
 
788
     func refreshAppearance() {
871
     func refreshAppearance() {
@@ -987,9 +1070,14 @@ final class PaywallView: NSView, AppearanceRefreshable {
987
         let container = NSView()
1070
         let container = NSView()
988
         container.translatesAutoresizingMaskIntoConstraints = false
1071
         container.translatesAutoresizingMaskIntoConstraints = false
989
 
1072
 
990
-        let continueWithFreePlanLink = PaywallFooterLink(title: "Continue with free plan")
991
-        continueWithFreePlanLink.target = self
992
-        continueWithFreePlanLink.action = #selector(continueWithFreePlanTapped)
1073
+        continueFreePlanLink.target = self
1074
+        continueFreePlanLink.action = #selector(continueWithFreePlanTapped)
1075
+        manageSubscriptionLink.target = self
1076
+        manageSubscriptionLink.action = #selector(manageSubscriptionTapped)
1077
+
1078
+        let primaryCell = makePrimaryFooterLinkCell()
1079
+        primaryFooterLinkCell = primaryCell
1080
+        refreshPrimaryFooterLink()
993
 
1081
 
994
         let restoreLink = PaywallFooterLink(title: "Restore Purchase")
1082
         let restoreLink = PaywallFooterLink(title: "Restore Purchase")
995
         restoreLink.target = self
1083
         restoreLink.target = self
@@ -999,16 +1087,14 @@ final class PaywallView: NSView, AppearanceRefreshable {
999
         let termsLink = PaywallFooterLink(title: "Terms of Service")
1087
         let termsLink = PaywallFooterLink(title: "Terms of Service")
1000
         let supportLink = PaywallFooterLink(title: "Support")
1088
         let supportLink = PaywallFooterLink(title: "Support")
1001
 
1089
 
1002
-        let links = [
1003
-            continueWithFreePlanLink,
1004
-            restoreLink,
1005
-            privacyLink,
1006
-            termsLink,
1007
-            supportLink,
1090
+        let linkCells = [
1091
+            primaryCell,
1092
+            makeFooterLinkCell(link: restoreLink),
1093
+            makeFooterLinkCell(link: privacyLink),
1094
+            makeFooterLinkCell(link: termsLink),
1095
+            makeFooterLinkCell(link: supportLink),
1008
         ]
1096
         ]
1009
 
1097
 
1010
-        let linkCells = links.map { makeFooterLinkCell(link: $0) }
1011
-
1012
         let linksStack = NSStackView(views: linkCells)
1098
         let linksStack = NSStackView(views: linkCells)
1013
         linksStack.orientation = .horizontal
1099
         linksStack.orientation = .horizontal
1014
         linksStack.spacing = 0
1100
         linksStack.spacing = 0
@@ -1027,6 +1113,23 @@ final class PaywallView: NSView, AppearanceRefreshable {
1027
         return container
1113
         return container
1028
     }
1114
     }
1029
 
1115
 
1116
+    private func makePrimaryFooterLinkCell() -> NSView {
1117
+        let cell = NSView()
1118
+        cell.translatesAutoresizingMaskIntoConstraints = false
1119
+
1120
+        for link in [continueFreePlanLink, manageSubscriptionLink] {
1121
+            cell.addSubview(link)
1122
+            NSLayoutConstraint.activate([
1123
+                link.centerXAnchor.constraint(equalTo: cell.centerXAnchor),
1124
+                link.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
1125
+                link.topAnchor.constraint(equalTo: cell.topAnchor),
1126
+                link.bottomAnchor.constraint(equalTo: cell.bottomAnchor),
1127
+            ])
1128
+        }
1129
+
1130
+        return cell
1131
+    }
1132
+
1030
     private func makeFooterLinkCell(link: PaywallFooterLink) -> NSView {
1133
     private func makeFooterLinkCell(link: PaywallFooterLink) -> NSView {
1031
         let cell = NSView()
1134
         let cell = NSView()
1032
         cell.translatesAutoresizingMaskIntoConstraints = false
1135
         cell.translatesAutoresizingMaskIntoConstraints = false
@@ -1055,7 +1158,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
1055
             refreshPurchaseState()
1158
             refreshPurchaseState()
1056
             do {
1159
             do {
1057
                 let succeeded = try await StoreManager.shared.purchase(plan: selectedPlan)
1160
                 let succeeded = try await StoreManager.shared.purchase(plan: selectedPlan)
1058
-                refreshPurchaseState()
1161
+                refreshStoreState()
1059
                 if succeeded {
1162
                 if succeeded {
1060
                     onPurchaseSucceeded?()
1163
                     onPurchaseSucceeded?()
1061
                 }
1164
                 }
@@ -1071,7 +1174,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
1071
             refreshPurchaseState()
1174
             refreshPurchaseState()
1072
             do {
1175
             do {
1073
                 let restored = try await StoreManager.shared.restorePurchases()
1176
                 let restored = try await StoreManager.shared.restorePurchases()
1074
-                refreshPurchaseState()
1177
+                refreshStoreState()
1075
                 if restored {
1178
                 if restored {
1076
                     onPurchaseSucceeded?()
1179
                     onPurchaseSucceeded?()
1077
                 } else {
1180
                 } else {
@@ -1091,6 +1194,10 @@ final class PaywallView: NSView, AppearanceRefreshable {
1091
     @objc private func continueWithFreePlanTapped() {
1194
     @objc private func continueWithFreePlanTapped() {
1092
         onClose?()
1195
         onClose?()
1093
     }
1196
     }
1197
+
1198
+    @objc private func manageSubscriptionTapped() {
1199
+        StoreManager.shared.showManageSubscriptions()
1200
+    }
1094
 }
1201
 }
1095
 
1202
 
1096
 // MARK: - Overlay Presenter
1203
 // MARK: - Overlay Presenter
@@ -1118,6 +1225,10 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
1118
         paywallView.refreshAppearance()
1225
         paywallView.refreshAppearance()
1119
     }
1226
     }
1120
 
1227
 
1228
+    func refreshStoreState() {
1229
+        paywallView.refreshStoreState()
1230
+    }
1231
+
1121
     @available(*, unavailable)
1232
     @available(*, unavailable)
1122
     required init?(coder: NSCoder) { nil }
1233
     required init?(coder: NSCoder) { nil }
1123
 
1234
 
@@ -1137,7 +1248,7 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
1137
         paywallView.translatesAutoresizingMaskIntoConstraints = false
1248
         paywallView.translatesAutoresizingMaskIntoConstraints = false
1138
         paywallView.onClose = { [weak self] in self?.dismiss() }
1249
         paywallView.onClose = { [weak self] in self?.dismiss() }
1139
         paywallView.onPurchaseSucceeded = { [weak self] in
1250
         paywallView.onPurchaseSucceeded = { [weak self] in
1140
-            self?.dismiss()
1251
+            self?.paywallView.refreshStoreState()
1141
         }
1252
         }
1142
 
1253
 
1143
         addSubview(blurView)
1254
         addSubview(blurView)
@@ -1193,10 +1304,9 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
1193
             if StoreManager.shared.products.isEmpty {
1304
             if StoreManager.shared.products.isEmpty {
1194
                 await StoreManager.shared.loadProducts()
1305
                 await StoreManager.shared.loadProducts()
1195
             }
1306
             }
1196
-        }
1197
-        NSAnimationContext.runAnimationGroup { context in
1198
-            context.duration = 0.2
1199
-            animator().alphaValue = 1
1307
+            await StoreManager.shared.refreshPremiumStatus()
1308
+            paywallView.refreshStoreState()
1309
+            alphaValue = 1
1200
         }
1310
         }
1201
     }
1311
     }
1202
 
1312
 

+ 10 - 4
smart_printer/ViewController.swift

@@ -64,8 +64,8 @@ class ViewController: NSViewController {
64
     }
64
     }
65
 
65
 
66
     @objc private func premiumStatusDidChange() {
66
     @objc private func premiumStatusDidChange() {
67
-        if StoreManager.shared.isPremium, paywallOverlay != nil {
68
-            dismissPaywall()
67
+        Task { @MainActor in
68
+            paywallOverlay?.refreshStoreState()
69
         }
69
         }
70
     }
70
     }
71
 
71
 
@@ -165,7 +165,7 @@ class ViewController: NSViewController {
165
         contentTopBelowHeader.isActive = !isSettings
165
         contentTopBelowHeader.isActive = !isSettings
166
         contentTopBelowWindow.isActive = isSettings
166
         contentTopBelowWindow.isActive = isSettings
167
 
167
 
168
-        if destination == .scanAndHome, !StoreManager.shared.isPremium {
168
+        if destination == .scanAndHome {
169
             presentPaywall()
169
             presentPaywall()
170
         } else {
170
         } else {
171
             dismissPaywall()
171
             dismissPaywall()
@@ -173,7 +173,13 @@ class ViewController: NSViewController {
173
     }
173
     }
174
 
174
 
175
     private func presentPaywall() {
175
     private func presentPaywall() {
176
-        guard paywallOverlay == nil else { return }
176
+        if let paywallOverlay {
177
+            Task { @MainActor in
178
+                await StoreManager.shared.refreshPremiumStatus()
179
+                paywallOverlay.refreshStoreState()
180
+            }
181
+            return
182
+        }
177
         setTrafficLightsHidden(true)
183
         setTrafficLightsHidden(true)
178
         let overlay = PaywallOverlayView()
184
         let overlay = PaywallOverlayView()
179
         overlay.onDismiss = { [weak self] in
185
         overlay.onDismiss = { [weak self] in