Răsfoiți Sursa

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 1 lună în urmă
părinte
comite
e30aeaded2
2 a modificat fișierele cu 147 adăugiri și 31 ștergeri
  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 {
 
     private(set) var products: [Product] = []
     private(set) var isPremium = false
+    var isPro: Bool { isPremium }
     private(set) var isLoadingProducts = false
     private(set) var isPurchasing = false
 
@@ -221,6 +222,11 @@ final class StoreManager {
         }
     }
 
+    func showManageSubscriptions() {
+        guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
+        NSWorkspace.shared.open(url)
+    }
+
     func showPurchaseError(_ error: Error, on window: NSWindow?) {
         if let storeError = error as? StoreError {
             showAlert(title: "Purchase Failed", message: storeError.localizedDescription, on: window)
@@ -244,20 +250,74 @@ final class StoreManager {
         }
     }
 
-    private func refreshPremiumStatus() async {
-        var hasPremium = false
+    func refreshPremiumStatus() async {
+        let hasPremium = await hasActivePremiumAccess()
+        let didChange = hasPremium != isPremium
+        isPremium = hasPremium
+
+        if didChange {
+            NotificationCenter.default.post(name: .premiumStatusDidChange, object: nil)
+        }
+        postStoreStateDidChange()
+    }
 
+    private func hasActivePremiumAccess() async -> Bool {
+        if await hasEntitlementFromCurrentEntitlements() {
+            return true
+        }
+        if await hasEntitlementFromLatestTransactions() {
+            return true
+        }
+        if await hasEntitlementFromSubscriptionStatus() {
+            return true
+        }
+        return false
+    }
+
+    private func hasEntitlementFromCurrentEntitlements() async -> Bool {
         for await result in Transaction.currentEntitlements {
             guard let transaction = try? checkVerified(result) else { continue }
-            if StoreProductID.all.contains(transaction.productID) {
-                hasPremium = true
-                break
+            if isActivePremiumTransaction(transaction) {
+                return true
             }
         }
+        return false
+    }
 
-        guard hasPremium != isPremium else { return }
-        isPremium = hasPremium
-        NotificationCenter.default.post(name: .premiumStatusDidChange, object: nil)
+    private func hasEntitlementFromLatestTransactions() async -> Bool {
+        for productID in StoreProductID.all {
+            guard let result = await Transaction.latest(for: productID),
+                  let transaction = try? checkVerified(result) else { continue }
+            if isActivePremiumTransaction(transaction) {
+                return true
+            }
+        }
+        return false
+    }
+
+    private func hasEntitlementFromSubscriptionStatus() async -> Bool {
+        for product in products where product.subscription != nil {
+            guard StoreProductID.all.contains(product.id) else { continue }
+            guard let statuses = try? await product.subscription?.status else { continue }
+            for status in statuses {
+                switch status.state {
+                case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
+                    return true
+                default:
+                    continue
+                }
+            }
+        }
+        return false
+    }
+
+    private func isActivePremiumTransaction(_ transaction: Transaction) -> Bool {
+        guard StoreProductID.all.contains(transaction.productID) else { return false }
+        guard transaction.revocationDate == nil else { return false }
+        if let expirationDate = transaction.expirationDate {
+            return expirationDate > Date()
+        }
+        return true
     }
 
     private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
@@ -582,8 +642,9 @@ private final class PaywallFooterLink: NSButton, AppearanceRefreshable {
 
     init(title: String) {
         super.init(frame: .zero)
-        self.title = title
+        updateTitle(title)
         isBordered = false
+        bezelStyle = .inline
         font = AppTheme.regularFont(size: 11)
         translatesAutoresizingMaskIntoConstraints = false
         refreshAppearance()
@@ -597,6 +658,12 @@ private final class PaywallFooterLink: NSButton, AppearanceRefreshable {
     @available(*, unavailable)
     required init?(coder: NSCoder) { nil }
 
+    func updateTitle(_ title: String) {
+        self.title = title
+        invalidateIntrinsicContentSize()
+        needsDisplay = true
+    }
+
     func refreshAppearance() {
         contentTintColor = isHovered ? AppTheme.textPrimary : AppTheme.textSecondary
     }
@@ -733,6 +800,9 @@ final class PaywallView: NSView, AppearanceRefreshable {
     private var selectedPlan: PaywallPlan = .yearly
     private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
     private let ctaButton = PaywallCTAButton()
+    private let continueFreePlanLink = PaywallFooterLink(title: "Continue with free plan")
+    private let manageSubscriptionLink = PaywallFooterLink(title: "Manage Subscription")
+    private var primaryFooterLinkCell: NSView?
     private var leftPanelTitle: NSTextField!
     private var rightTitle: NSTextField!
     private var rightSubtitle: NSTextField!
@@ -783,6 +853,19 @@ final class PaywallView: NSView, AppearanceRefreshable {
         let isBusy = store.isPurchasing || store.isLoadingProducts
         ctaButton.isEnabled = !isBusy
         ctaButton.alphaValue = isBusy ? 0.65 : 1
+        refreshPrimaryFooterLink()
+    }
+
+    private func refreshPrimaryFooterLink() {
+        let isPro = StoreManager.shared.isPro
+        continueFreePlanLink.isHidden = isPro
+        manageSubscriptionLink.isHidden = !isPro
+        primaryFooterLinkCell?.needsLayout = true
+        primaryFooterLinkCell?.layoutSubtreeIfNeeded()
+    }
+
+    func refreshStoreState() {
+        refreshProductDisplay()
     }
 
     func refreshAppearance() {
@@ -987,9 +1070,14 @@ final class PaywallView: NSView, AppearanceRefreshable {
         let container = NSView()
         container.translatesAutoresizingMaskIntoConstraints = false
 
-        let continueWithFreePlanLink = PaywallFooterLink(title: "Continue with free plan")
-        continueWithFreePlanLink.target = self
-        continueWithFreePlanLink.action = #selector(continueWithFreePlanTapped)
+        continueFreePlanLink.target = self
+        continueFreePlanLink.action = #selector(continueWithFreePlanTapped)
+        manageSubscriptionLink.target = self
+        manageSubscriptionLink.action = #selector(manageSubscriptionTapped)
+
+        let primaryCell = makePrimaryFooterLinkCell()
+        primaryFooterLinkCell = primaryCell
+        refreshPrimaryFooterLink()
 
         let restoreLink = PaywallFooterLink(title: "Restore Purchase")
         restoreLink.target = self
@@ -999,16 +1087,14 @@ final class PaywallView: NSView, AppearanceRefreshable {
         let termsLink = PaywallFooterLink(title: "Terms of Service")
         let supportLink = PaywallFooterLink(title: "Support")
 
-        let links = [
-            continueWithFreePlanLink,
-            restoreLink,
-            privacyLink,
-            termsLink,
-            supportLink,
+        let linkCells = [
+            primaryCell,
+            makeFooterLinkCell(link: restoreLink),
+            makeFooterLinkCell(link: privacyLink),
+            makeFooterLinkCell(link: termsLink),
+            makeFooterLinkCell(link: supportLink),
         ]
 
-        let linkCells = links.map { makeFooterLinkCell(link: $0) }
-
         let linksStack = NSStackView(views: linkCells)
         linksStack.orientation = .horizontal
         linksStack.spacing = 0
@@ -1027,6 +1113,23 @@ final class PaywallView: NSView, AppearanceRefreshable {
         return container
     }
 
+    private func makePrimaryFooterLinkCell() -> NSView {
+        let cell = NSView()
+        cell.translatesAutoresizingMaskIntoConstraints = false
+
+        for link in [continueFreePlanLink, manageSubscriptionLink] {
+            cell.addSubview(link)
+            NSLayoutConstraint.activate([
+                link.centerXAnchor.constraint(equalTo: cell.centerXAnchor),
+                link.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
+                link.topAnchor.constraint(equalTo: cell.topAnchor),
+                link.bottomAnchor.constraint(equalTo: cell.bottomAnchor),
+            ])
+        }
+
+        return cell
+    }
+
     private func makeFooterLinkCell(link: PaywallFooterLink) -> NSView {
         let cell = NSView()
         cell.translatesAutoresizingMaskIntoConstraints = false
@@ -1055,7 +1158,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
             refreshPurchaseState()
             do {
                 let succeeded = try await StoreManager.shared.purchase(plan: selectedPlan)
-                refreshPurchaseState()
+                refreshStoreState()
                 if succeeded {
                     onPurchaseSucceeded?()
                 }
@@ -1071,7 +1174,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
             refreshPurchaseState()
             do {
                 let restored = try await StoreManager.shared.restorePurchases()
-                refreshPurchaseState()
+                refreshStoreState()
                 if restored {
                     onPurchaseSucceeded?()
                 } else {
@@ -1091,6 +1194,10 @@ final class PaywallView: NSView, AppearanceRefreshable {
     @objc private func continueWithFreePlanTapped() {
         onClose?()
     }
+
+    @objc private func manageSubscriptionTapped() {
+        StoreManager.shared.showManageSubscriptions()
+    }
 }
 
 // MARK: - Overlay Presenter
@@ -1118,6 +1225,10 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
         paywallView.refreshAppearance()
     }
 
+    func refreshStoreState() {
+        paywallView.refreshStoreState()
+    }
+
     @available(*, unavailable)
     required init?(coder: NSCoder) { nil }
 
@@ -1137,7 +1248,7 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
         paywallView.translatesAutoresizingMaskIntoConstraints = false
         paywallView.onClose = { [weak self] in self?.dismiss() }
         paywallView.onPurchaseSucceeded = { [weak self] in
-            self?.dismiss()
+            self?.paywallView.refreshStoreState()
         }
 
         addSubview(blurView)
@@ -1193,10 +1304,9 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
             if StoreManager.shared.products.isEmpty {
                 await StoreManager.shared.loadProducts()
             }
-        }
-        NSAnimationContext.runAnimationGroup { context in
-            context.duration = 0.2
-            animator().alphaValue = 1
+            await StoreManager.shared.refreshPremiumStatus()
+            paywallView.refreshStoreState()
+            alphaValue = 1
         }
     }
 

+ 10 - 4
smart_printer/ViewController.swift

@@ -64,8 +64,8 @@ class ViewController: NSViewController {
     }
 
     @objc private func premiumStatusDidChange() {
-        if StoreManager.shared.isPremium, paywallOverlay != nil {
-            dismissPaywall()
+        Task { @MainActor in
+            paywallOverlay?.refreshStoreState()
         }
     }
 
@@ -165,7 +165,7 @@ class ViewController: NSViewController {
         contentTopBelowHeader.isActive = !isSettings
         contentTopBelowWindow.isActive = isSettings
 
-        if destination == .scanAndHome, !StoreManager.shared.isPremium {
+        if destination == .scanAndHome {
             presentPaywall()
         } else {
             dismissPaywall()
@@ -173,7 +173,13 @@ class ViewController: NSViewController {
     }
 
     private func presentPaywall() {
-        guard paywallOverlay == nil else { return }
+        if let paywallOverlay {
+            Task { @MainActor in
+                await StoreManager.shared.refreshPremiumStatus()
+                paywallOverlay.refreshStoreState()
+            }
+            return
+        }
         setTrafficLightsHidden(true)
         let overlay = PaywallOverlayView()
         overlay.onDismiss = { [weak self] in