소스 검색

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 시간 전
부모
커밋
e30aeaded2
2개의 변경된 파일147개의 추가작업 그리고 31개의 파일을 삭제
  1. 137 27
      smart_printer/PaywallView.swift
  2. 10 4
      smart_printer/ViewController.swift

+ 137 - 27
smart_printer/PaywallView.swift

@@ -115,6 +115,7 @@ final class StoreManager {
115 115
 
116 116
     private(set) var products: [Product] = []
117 117
     private(set) var isPremium = false
118
+    var isPro: Bool { isPremium }
118 119
     private(set) var isLoadingProducts = false
119 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 230
     func showPurchaseError(_ error: Error, on window: NSWindow?) {
225 231
         if let storeError = error as? StoreError {
226 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 278
         for await result in Transaction.currentEntitlements {
251 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 323
     private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
@@ -582,8 +642,9 @@ private final class PaywallFooterLink: NSButton, AppearanceRefreshable {
582 642
 
583 643
     init(title: String) {
584 644
         super.init(frame: .zero)
585
-        self.title = title
645
+        updateTitle(title)
586 646
         isBordered = false
647
+        bezelStyle = .inline
587 648
         font = AppTheme.regularFont(size: 11)
588 649
         translatesAutoresizingMaskIntoConstraints = false
589 650
         refreshAppearance()
@@ -597,6 +658,12 @@ private final class PaywallFooterLink: NSButton, AppearanceRefreshable {
597 658
     @available(*, unavailable)
598 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 667
     func refreshAppearance() {
601 668
         contentTintColor = isHovered ? AppTheme.textPrimary : AppTheme.textSecondary
602 669
     }
@@ -733,6 +800,9 @@ final class PaywallView: NSView, AppearanceRefreshable {
733 800
     private var selectedPlan: PaywallPlan = .yearly
734 801
     private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
735 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 806
     private var leftPanelTitle: NSTextField!
737 807
     private var rightTitle: NSTextField!
738 808
     private var rightSubtitle: NSTextField!
@@ -783,6 +853,19 @@ final class PaywallView: NSView, AppearanceRefreshable {
783 853
         let isBusy = store.isPurchasing || store.isLoadingProducts
784 854
         ctaButton.isEnabled = !isBusy
785 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 871
     func refreshAppearance() {
@@ -987,9 +1070,14 @@ final class PaywallView: NSView, AppearanceRefreshable {
987 1070
         let container = NSView()
988 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 1082
         let restoreLink = PaywallFooterLink(title: "Restore Purchase")
995 1083
         restoreLink.target = self
@@ -999,16 +1087,14 @@ final class PaywallView: NSView, AppearanceRefreshable {
999 1087
         let termsLink = PaywallFooterLink(title: "Terms of Service")
1000 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 1098
         let linksStack = NSStackView(views: linkCells)
1013 1099
         linksStack.orientation = .horizontal
1014 1100
         linksStack.spacing = 0
@@ -1027,6 +1113,23 @@ final class PaywallView: NSView, AppearanceRefreshable {
1027 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 1133
     private func makeFooterLinkCell(link: PaywallFooterLink) -> NSView {
1031 1134
         let cell = NSView()
1032 1135
         cell.translatesAutoresizingMaskIntoConstraints = false
@@ -1055,7 +1158,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
1055 1158
             refreshPurchaseState()
1056 1159
             do {
1057 1160
                 let succeeded = try await StoreManager.shared.purchase(plan: selectedPlan)
1058
-                refreshPurchaseState()
1161
+                refreshStoreState()
1059 1162
                 if succeeded {
1060 1163
                     onPurchaseSucceeded?()
1061 1164
                 }
@@ -1071,7 +1174,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
1071 1174
             refreshPurchaseState()
1072 1175
             do {
1073 1176
                 let restored = try await StoreManager.shared.restorePurchases()
1074
-                refreshPurchaseState()
1177
+                refreshStoreState()
1075 1178
                 if restored {
1076 1179
                     onPurchaseSucceeded?()
1077 1180
                 } else {
@@ -1091,6 +1194,10 @@ final class PaywallView: NSView, AppearanceRefreshable {
1091 1194
     @objc private func continueWithFreePlanTapped() {
1092 1195
         onClose?()
1093 1196
     }
1197
+
1198
+    @objc private func manageSubscriptionTapped() {
1199
+        StoreManager.shared.showManageSubscriptions()
1200
+    }
1094 1201
 }
1095 1202
 
1096 1203
 // MARK: - Overlay Presenter
@@ -1118,6 +1225,10 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
1118 1225
         paywallView.refreshAppearance()
1119 1226
     }
1120 1227
 
1228
+    func refreshStoreState() {
1229
+        paywallView.refreshStoreState()
1230
+    }
1231
+
1121 1232
     @available(*, unavailable)
1122 1233
     required init?(coder: NSCoder) { nil }
1123 1234
 
@@ -1137,7 +1248,7 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
1137 1248
         paywallView.translatesAutoresizingMaskIntoConstraints = false
1138 1249
         paywallView.onClose = { [weak self] in self?.dismiss() }
1139 1250
         paywallView.onPurchaseSucceeded = { [weak self] in
1140
-            self?.dismiss()
1251
+            self?.paywallView.refreshStoreState()
1141 1252
         }
1142 1253
 
1143 1254
         addSubview(blurView)
@@ -1193,10 +1304,9 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
1193 1304
             if StoreManager.shared.products.isEmpty {
1194 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 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 165
         contentTopBelowHeader.isActive = !isSettings
166 166
         contentTopBelowWindow.isActive = isSettings
167 167
 
168
-        if destination == .scanAndHome, !StoreManager.shared.isPremium {
168
+        if destination == .scanAndHome {
169 169
             presentPaywall()
170 170
         } else {
171 171
             dismissPaywall()
@@ -173,7 +173,13 @@ class ViewController: NSViewController {
173 173
     }
174 174
 
175 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 183
         setTrafficLightsHidden(true)
178 184
         let overlay = PaywallOverlayView()
179 185
         overlay.onDismiss = { [weak self] in