Ver Fonte

Differentiate lifetime and subscription UX in the paywall.

Track premium access kind so lifetime buyers no longer see Manage Subscription, and update trust copy when the lifetime plan is selected.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 há 4 semanas atrás
pai
commit
2c0096a7a8
1 ficheiros alterados com 69 adições e 27 exclusões
  1. 69 27
      smart_printer/PaywallView.swift

+ 69 - 27
smart_printer/PaywallView.swift

@@ -99,6 +99,12 @@ enum StoreProductID {
     static let all = AppStoreConfig.ProductID.all
 }
 
+enum PremiumAccessKind: String {
+    case none
+    case subscription
+    case lifetime
+}
+
 enum StoreError: LocalizedError {
     case productNotFound
     case failedVerification
@@ -118,10 +124,21 @@ final class StoreManager {
     static let shared = StoreManager()
 
     private static let cachedPremiumKey = "StoreManager.cachedIsPremium"
+    private static let cachedPremiumKindKey = "StoreManager.cachedPremiumAccessKind"
 
     private(set) var products: [Product] = []
     private(set) var isPremium = UserDefaults.standard.bool(forKey: cachedPremiumKey)
+    private(set) var premiumAccessKind: PremiumAccessKind = {
+        guard let raw = UserDefaults.standard.string(forKey: cachedPremiumKindKey),
+              let kind = PremiumAccessKind(rawValue: raw) else {
+            return .none
+        }
+        return kind
+    }()
+
     var isPro: Bool { isPremium }
+    var hasActiveSubscription: Bool { premiumAccessKind == .subscription }
+    var hasLifetimeAccess: Bool { premiumAccessKind == .lifetime }
     private(set) var isLoadingProducts = false
     private(set) var isPurchasing = false
     private(set) var isResolvingEntitlements = true
@@ -284,18 +301,21 @@ final class StoreManager {
     }
 
     func refreshEntitlementsOnly() async {
-        applyPremiumStatus(await hasActivePremiumAccess())
+        applyPremiumStatus(await resolvePremiumAccessKind())
     }
 
     func refreshPremiumStatus() async {
         await refreshIntroOfferEligibility()
-        applyPremiumStatus(await hasActivePremiumAccess())
+        applyPremiumStatus(await resolvePremiumAccessKind())
     }
 
-    private func applyPremiumStatus(_ hasPremium: Bool) {
-        let didChange = hasPremium != isPremium
+    private func applyPremiumStatus(_ kind: PremiumAccessKind) {
+        let hasPremium = kind != .none
+        let didChange = hasPremium != isPremium || kind != premiumAccessKind
         isPremium = hasPremium
+        premiumAccessKind = hasPremium ? kind : .none
         UserDefaults.standard.set(hasPremium, forKey: Self.cachedPremiumKey)
+        UserDefaults.standard.set(premiumAccessKind.rawValue, forKey: Self.cachedPremiumKindKey)
 
         if didChange {
             NotificationCenter.default.post(name: .premiumStatusDidChange, object: nil)
@@ -303,38 +323,35 @@ final class StoreManager {
         postStoreStateDidChange()
     }
 
-    private func hasActivePremiumAccess() async -> Bool {
-        if await hasEntitlementFromCurrentEntitlements() {
-            return true
+    private func resolvePremiumAccessKind() async -> PremiumAccessKind {
+        if await hasActiveEntitlement(for: StoreProductID.lifetime) {
+            return .lifetime
+        }
+        if await hasActiveEntitlement(for: StoreProductID.monthly) {
+            return .subscription
         }
-        if await hasEntitlementFromLatestTransactions() {
-            return true
+        if await hasActiveEntitlement(for: StoreProductID.yearly) {
+            return .subscription
         }
         if await hasEntitlementFromSubscriptionStatus() {
-            return true
+            return .subscription
         }
-        return false
+        return .none
     }
 
-    private func hasEntitlementFromCurrentEntitlements() async -> Bool {
+    private func hasActiveEntitlement(for productID: String) async -> Bool {
         for await result in Transaction.currentEntitlements {
             guard let transaction = try? checkVerified(result) else { continue }
-            if isActivePremiumTransaction(transaction) {
+            if transaction.productID == productID, isActivePremiumTransaction(transaction) {
                 return true
             }
         }
-        return false
-    }
 
-    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
-            }
+        guard let result = await Transaction.latest(for: productID),
+              let transaction = try? checkVerified(result) else {
+            return false
         }
-        return false
+        return isActivePremiumTransaction(transaction)
     }
 
     private func hasEntitlementFromSubscriptionStatus() async -> Bool {
@@ -1136,6 +1153,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
             config: config,
             introOffer: store.eligibleIntroOffer(for: selectedPlan)
         )
+        refreshTrustItemsForSelectedPlan()
         refreshPurchaseState()
     }
 
@@ -1150,14 +1168,37 @@ final class PaywallView: NSView, AppearanceRefreshable {
     }
 
     private func refreshPrimaryFooterLink() {
-        let isPro = StoreManager.shared.isPro
-        continueFreePlanLink.isHidden = isPro
-        manageSubscriptionLink.isHidden = !isPro
-        premiumCloseButton.isHidden = !isPro
+        let store = StoreManager.shared
+        let showContinue = !store.isPro
+        let showManage = store.hasActiveSubscription
+
+        continueFreePlanLink.isHidden = !showContinue
+        manageSubscriptionLink.isHidden = !showManage
+        premiumCloseButton.isHidden = !store.isPro
+        primaryFooterLinkCell?.isHidden = !showContinue && !showManage
         primaryFooterLinkCell?.needsLayout = true
         primaryFooterLinkCell?.layoutSubtreeIfNeeded()
     }
 
+    private func refreshTrustItemsForSelectedPlan() {
+        guard trustItemViews.count > 1, paywallConfig.trustItems.count > 1 else { return }
+
+        let cancelItem = paywallConfig.trustItems[1]
+        if selectedPlan == .lifetime {
+            trustItemViews[1].update(
+                iconName: "checkmark.seal.fill",
+                title: "One-Time Purchase",
+                subtitle: "Pay once, no recurring charges."
+            )
+        } else {
+            trustItemViews[1].update(
+                iconName: cancelItem.icon,
+                title: cancelItem.title,
+                subtitle: cancelItem.subtitle
+            )
+        }
+    }
+
     func refreshStoreState() {
         refreshProductDisplay()
     }
@@ -1464,6 +1505,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
             config: paywallConfig,
             introOffer: StoreManager.shared.eligibleIntroOffer(for: plan)
         )
+        refreshTrustItemsForSelectedPlan()
         refreshPurchaseState()
     }