Răsfoiți Sursa

Align printer paywall with Reddit app and fix premium sidebar state.

Port subscription handling, trial config, and paywall UX parity while ensuring the sidebar premium card updates correctly for active subscribers.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 3 săptămâni în urmă
părinte
comite
160a559d94

+ 81 - 2
smart_printer/PaywallConfig.swift

@@ -8,19 +8,31 @@ struct PaywallConfig: Codable, Sendable {
         let subtitleTemplate: String
         let subtitleTemplate: String
         let trialSubtitleTemplate: String?
         let trialSubtitleTemplate: String?
         let ctaTemplate: String
         let ctaTemplate: String
+        let fallbackPrice: String
+        let priceSuffix: String?
+        let fallbackBillingDescription: String
+        let features: [String]
 
 
         init(
         init(
             title: String,
             title: String,
             badge: String? = nil,
             badge: String? = nil,
             subtitleTemplate: String,
             subtitleTemplate: String,
             trialSubtitleTemplate: String? = nil,
             trialSubtitleTemplate: String? = nil,
-            ctaTemplate: String
+            ctaTemplate: String,
+            fallbackPrice: String = "—",
+            priceSuffix: String? = nil,
+            fallbackBillingDescription: String = "",
+            features: [String] = []
         ) {
         ) {
             self.title = title
             self.title = title
             self.badge = badge
             self.badge = badge
             self.subtitleTemplate = subtitleTemplate
             self.subtitleTemplate = subtitleTemplate
             self.trialSubtitleTemplate = trialSubtitleTemplate
             self.trialSubtitleTemplate = trialSubtitleTemplate
             self.ctaTemplate = ctaTemplate
             self.ctaTemplate = ctaTemplate
+            self.fallbackPrice = fallbackPrice
+            self.priceSuffix = priceSuffix
+            self.fallbackBillingDescription = fallbackBillingDescription
+            self.features = features
         }
         }
     }
     }
 
 
@@ -72,9 +84,16 @@ struct PaywallConfig: Codable, Sendable {
         let year: DurationUnit
         let year: DurationUnit
     }
     }
 
 
+    struct TrialFallbackDuration: Codable, Sendable {
+        let count: Int
+        let unit: String
+    }
+
     struct Trial: Codable, Sendable {
     struct Trial: Codable, Sendable {
+        let eligiblePlan: String
         let badgeTemplate: String
         let badgeTemplate: String
         let ctaTemplate: String
         let ctaTemplate: String
+        let fallbackDuration: TrialFallbackDuration?
     }
     }
 
 
     struct Messages: Codable, Sendable {
     struct Messages: Codable, Sendable {
@@ -91,6 +110,12 @@ struct PaywallConfig: Codable, Sendable {
         let retry: String
         let retry: String
         let retrying: String
         let retrying: String
         let periodFallback: String
         let periodFallback: String
+        let productsLoadTimeout: String
+        let subscriptionUnavailable: String
+        let planUnavailable: String
+        let noActiveSubscriptions: String
+        let restoreSyncTimeout: String
+        let purchaseGeneric: String
     }
     }
 
 
     struct Sidebar: Codable, Sendable {
     struct Sidebar: Codable, Sendable {
@@ -122,6 +147,8 @@ struct PaywallConfig: Codable, Sendable {
     let trialDisclosureTemplate: String
     let trialDisclosureTemplate: String
     let lifetimeDisclosure: String
     let lifetimeDisclosure: String
     let loadingDisclosure: String
     let loadingDisclosure: String
+    let securePaymentNote: String
+    let closeButtonHelp: String
 }
 }
 
 
 extension PaywallConfig {
 extension PaywallConfig {
@@ -146,10 +173,35 @@ extension PaywallConfig {
             subscriptionDisclosureTemplate: subscriptionDisclosureTemplate,
             subscriptionDisclosureTemplate: subscriptionDisclosureTemplate,
             trialDisclosureTemplate: trialDisclosureTemplate,
             trialDisclosureTemplate: trialDisclosureTemplate,
             lifetimeDisclosure: lifetimeDisclosure,
             lifetimeDisclosure: lifetimeDisclosure,
-            loadingDisclosure: loadingDisclosure
+            loadingDisclosure: loadingDisclosure,
+            securePaymentNote: securePaymentNote,
+            closeButtonHelp: closeButtonHelp
         )
         )
     }
     }
 
 
+    func message(for error: PurchaseError) -> String {
+        switch error {
+        case .productsLoadTimeout:
+            messages.productsLoadTimeout
+        case .subscriptionUnavailable:
+            messages.subscriptionUnavailable
+        case .planUnavailable:
+            messages.planUnavailable
+        case .purchaseNotVerified:
+            messages.failedVerification
+        case .purchasePending:
+            messages.pendingPurchaseMessage
+        case .noActiveSubscriptions:
+            messages.noActiveSubscriptions
+        case .noActivePurchases:
+            messages.restoreNotFoundMessage
+        case .restoreSyncTimeout:
+            messages.restoreSyncTimeout
+        case .generic:
+            messages.purchaseGeneric
+        }
+    }
+
     static func loadBundled() -> PaywallConfig {
     static func loadBundled() -> PaywallConfig {
         guard let url = Bundle.main.url(forResource: "paywall", withExtension: "json"),
         guard let url = Bundle.main.url(forResource: "paywall", withExtension: "json"),
               let data = try? Data(contentsOf: url),
               let data = try? Data(contentsOf: url),
@@ -206,6 +258,33 @@ extension PaywallConfig {
         let duration = trialDurationDescription(from: offer)
         let duration = trialDurationDescription(from: offer)
         return trial.ctaTemplate.replacingOccurrences(of: "{duration}", with: duration)
         return trial.ctaTemplate.replacingOccurrences(of: "{duration}", with: duration)
     }
     }
+
+    func trialBadgeText(duration: String) -> String {
+        trial.badgeTemplate.replacingOccurrences(of: "{duration}", with: duration)
+    }
+
+    func trialCTATitle(duration: String) -> String {
+        trial.ctaTemplate.replacingOccurrences(of: "{duration}", with: duration)
+    }
+
+    func trialDurationDescription(from fallback: TrialFallbackDuration, lowercase: Bool = false) -> String? {
+        guard let unit = subscriptionPeriodUnit(from: fallback.unit) else { return nil }
+        return formatDuration(count: fallback.count, unit: unit, lowercase: lowercase)
+    }
+
+    var trialEligiblePlan: PaywallPlan? {
+        PaywallPlan(rawValue: trial.eligiblePlan)
+    }
+
+    private func subscriptionPeriodUnit(from value: String) -> Product.SubscriptionPeriod.Unit? {
+        switch value {
+        case "day": .day
+        case "week": .week
+        case "month": .month
+        case "year": .year
+        default: nil
+        }
+    }
 }
 }
 
 
 extension Notification.Name {
 extension Notification.Name {

+ 41 - 0
smart_printer/PaywallModels.swift

@@ -0,0 +1,41 @@
+import Foundation
+import StoreKit
+
+struct PaywallTrialDisplay: Equatable, Sendable {
+    let badgeText: String
+    let durationLowercased: String
+    let ctaText: String
+
+    static func from(offer: Product.SubscriptionOffer, config: PaywallConfig) -> PaywallTrialDisplay {
+        let durationLowercased = config.trialDurationDescription(from: offer, lowercase: true)
+        return PaywallTrialDisplay(
+            badgeText: config.trialBadgeText(from: offer),
+            durationLowercased: durationLowercased,
+            ctaText: config.trialCTATitle(from: offer)
+        )
+    }
+
+    static func from(fallback: PaywallConfig.TrialFallbackDuration, config: PaywallConfig) -> PaywallTrialDisplay? {
+        guard let duration = config.trialDurationDescription(from: fallback),
+              let durationLowercased = config.trialDurationDescription(from: fallback, lowercase: true) else {
+            return nil
+        }
+        return PaywallTrialDisplay(
+            badgeText: config.trialBadgeText(duration: duration),
+            durationLowercased: durationLowercased,
+            ctaText: config.trialCTATitle(duration: duration)
+        )
+    }
+}
+
+enum PurchaseError: Equatable {
+    case productsLoadTimeout
+    case subscriptionUnavailable
+    case planUnavailable
+    case purchaseNotVerified
+    case purchasePending
+    case noActiveSubscriptions
+    case noActivePurchases
+    case restoreSyncTimeout
+    case generic
+}

Fișier diff suprimat deoarece este prea mare
+ 585 - 200
smart_printer/PaywallView.swift


+ 1 - 1
smart_printer/SidebarView.swift

@@ -36,7 +36,7 @@ final class SidebarView: NSView, AppearanceRefreshable {
     }
     }
 
 
     func refreshPremiumState() {
     func refreshPremiumState() {
-        premiumCard?.update(isPremium: StoreManager.shared.isPremium)
+        premiumCard?.refreshFromStore()
     }
     }
 
 
     func refreshAppearance() {
     func refreshAppearance() {

+ 26 - 20
smart_printer/UIComponents.swift

@@ -231,54 +231,60 @@ final class SidebarPremiumCardView: NSView, AppearanceRefreshable {
     private let actionButton = PremiumCardActionButton()
     private let actionButton = PremiumCardActionButton()
 
 
     private var isPremium = false
     private var isPremium = false
-    private var configObserver: NSObjectProtocol?
+    private var activePlan: PaywallPlan?
+    private var observers: [NSObjectProtocol] = []
 
 
     init() {
     init() {
         super.init(frame: .zero)
         super.init(frame: .zero)
         translatesAutoresizingMaskIntoConstraints = false
         translatesAutoresizingMaskIntoConstraints = false
         setup()
         setup()
-        observeConfigUpdates()
-        update(isPremium: StoreManager.shared.isPremium)
+        observeStoreUpdates()
+        refreshFromStore()
     }
     }
 
 
     deinit {
     deinit {
-        if let configObserver {
-            NotificationCenter.default.removeObserver(configObserver)
-        }
+        observers.forEach { NotificationCenter.default.removeObserver($0) }
     }
     }
 
 
     @available(*, unavailable)
     @available(*, unavailable)
     required init?(coder: NSCoder) { nil }
     required init?(coder: NSCoder) { nil }
 
 
-    func update(isPremium: Bool) {
+    func refreshFromStore() {
+        let store = StoreManager.shared
+        update(isPremium: store.isPremium, activePlan: store.activePremiumPlan)
+    }
+
+    func update(isPremium: Bool, activePlan: PaywallPlan? = nil) {
         self.isPremium = isPremium
         self.isPremium = isPremium
+        self.activePlan = activePlan
         let sidebar = PaywallConfigService.shared.config.sidebar
         let sidebar = PaywallConfigService.shared.config.sidebar
         titleLabel.stringValue = isPremium ? sidebar.proTitle : sidebar.unlockTitle
         titleLabel.stringValue = isPremium ? sidebar.proTitle : sidebar.unlockTitle
         descriptionLabel.stringValue = isPremium ? sidebar.proDescription : sidebar.unlockDescription
         descriptionLabel.stringValue = isPremium ? sidebar.proDescription : sidebar.unlockDescription
 
 
         let actionTitle: String
         let actionTitle: String
-        let showsArrow: Bool
         if isPremium {
         if isPremium {
-            actionTitle = sidebar.manageSubscriptionAction
-            showsArrow = true
+            actionTitle = activePlan == .lifetime
+                ? sidebar.lifetimeAction
+                : sidebar.manageSubscriptionAction
         } else {
         } else {
             actionTitle = sidebar.upgradeAction
             actionTitle = sidebar.upgradeAction
-            showsArrow = true
         }
         }
 
 
-        actionButton.configure(title: actionTitle, showsArrow: showsArrow)
+        actionButton.configure(title: actionTitle, showsArrow: true)
         refreshAppearance()
         refreshAppearance()
     }
     }
 
 
-    private func observeConfigUpdates() {
-        configObserver = NotificationCenter.default.addObserver(
-            forName: .paywallConfigDidUpdate,
-            object: nil,
-            queue: .main
-        ) { [weak self] _ in
-            guard let self else { return }
-            self.update(isPremium: self.isPremium)
+    private func observeStoreUpdates() {
+        let center = NotificationCenter.default
+        let refresh: (Notification) -> Void = { [weak self] _ in
+            self?.refreshFromStore()
         }
         }
+        observers = [
+            center.addObserver(forName: .premiumStatusDidChange, object: nil, queue: .main, using: refresh),
+            center.addObserver(forName: .entitlementsDidResolve, object: nil, queue: .main, using: refresh),
+            center.addObserver(forName: .storeStateDidChange, object: nil, queue: .main, using: refresh),
+            center.addObserver(forName: .paywallConfigDidUpdate, object: nil, queue: .main, using: refresh),
+        ]
     }
     }
 
 
     func refreshAppearance() {
     func refreshAppearance() {

+ 14 - 0
smart_printer/ViewController.swift

@@ -63,6 +63,12 @@ class ViewController: NSViewController {
             name: .premiumStatusDidChange,
             name: .premiumStatusDidChange,
             object: nil
             object: nil
         )
         )
+        NotificationCenter.default.addObserver(
+            self,
+            selector: #selector(entitlementsDidResolve),
+            name: .entitlementsDidResolve,
+            object: nil
+        )
     }
     }
 
 
     deinit {
     deinit {
@@ -78,6 +84,14 @@ class ViewController: NSViewController {
     }
     }
 
 
     @objc private func premiumStatusDidChange() {
     @objc private func premiumStatusDidChange() {
+        refreshPremiumUI()
+    }
+
+    @objc private func entitlementsDidResolve() {
+        refreshPremiumUI()
+    }
+
+    private func refreshPremiumUI() {
         Task { @MainActor in
         Task { @MainActor in
             paywallOverlay?.refreshStoreState()
             paywallOverlay?.refreshStoreState()
             sidebar?.refreshPremiumState()
             sidebar?.refreshPremiumState()

+ 44 - 7
smart_printer/paywall.json

@@ -41,19 +41,43 @@
     "monthly": {
     "monthly": {
       "title": "Monthly",
       "title": "Monthly",
       "subtitleTemplate": "{price} / month, cancel anytime",
       "subtitleTemplate": "{price} / month, cancel anytime",
-      "ctaTemplate": "Subscribe for {price} / Month"
+      "ctaTemplate": "Subscribe for {price} / Month",
+      "fallbackPrice": "$4.99",
+      "priceSuffix": "/mo",
+      "fallbackBillingDescription": "Billed at {price} every month",
+      "features": [
+        "Unlimited high-quality scans",
+        "Advanced OCR technology",
+        "Draw & print canvas"
+      ]
     },
     },
     "yearly": {
     "yearly": {
       "title": "Yearly",
       "title": "Yearly",
       "subtitleTemplate": "{price} / year, cancel anytime",
       "subtitleTemplate": "{price} / year, cancel anytime",
-      "trialSubtitleTemplate": "Get {trial} free, then {price} / year",
-      "ctaTemplate": "Subscribe for {price} / Year"
+      "trialSubtitleTemplate": "{trial} free, then {price} / year",
+      "ctaTemplate": "Subscribe for {price} / Year",
+      "fallbackPrice": "$29.99",
+      "priceSuffix": "/yr",
+      "fallbackBillingDescription": "Billed at {price} every year",
+      "features": [
+        "Unlimited high-quality scans",
+        "Advanced OCR technology",
+        "Draw & print canvas"
+      ]
     },
     },
     "lifetime": {
     "lifetime": {
       "title": "Lifetime",
       "title": "Lifetime",
       "badge": "Best Value",
       "badge": "Best Value",
       "subtitleTemplate": "{price} once, lifetime access",
       "subtitleTemplate": "{price} once, lifetime access",
-      "ctaTemplate": "Buy Lifetime Access for {price}"
+      "ctaTemplate": "Buy Lifetime Access for {price}",
+      "fallbackPrice": "$49.99",
+      "priceSuffix": null,
+      "fallbackBillingDescription": "One-time payment of {price}",
+      "features": [
+        "Unlimited high-quality scans",
+        "Advanced OCR technology",
+        "Draw & print canvas"
+      ]
     }
     }
   },
   },
   "footer": {
   "footer": {
@@ -71,8 +95,13 @@
     "manageSubscriptions": "https://apps.apple.com/account/subscriptions"
     "manageSubscriptions": "https://apps.apple.com/account/subscriptions"
   },
   },
   "trial": {
   "trial": {
+    "eligiblePlan": "yearly",
     "badgeTemplate": "{duration} Free Trial",
     "badgeTemplate": "{duration} Free Trial",
-    "ctaTemplate": "Start {duration} Free Trial"
+    "ctaTemplate": "Start {duration} Free Trial",
+    "fallbackDuration": {
+      "count": 3,
+      "unit": "day"
+    }
   },
   },
   "duration": {
   "duration": {
     "units": {
     "units": {
@@ -97,7 +126,13 @@
     "alertOK": "OK",
     "alertOK": "OK",
     "retry": "Retry",
     "retry": "Retry",
     "retrying": "Retrying…",
     "retrying": "Retrying…",
-    "periodFallback": "period"
+    "periodFallback": "period",
+    "productsLoadTimeout": "Couldn't load subscription products right now. Check your connection and try again.",
+    "subscriptionUnavailable": "This subscription is currently unavailable. Please try again in a moment.",
+    "planUnavailable": "This plan isn't available right now. Please try again later.",
+    "noActiveSubscriptions": "No active subscriptions were found for this Apple ID.",
+    "restoreSyncTimeout": "Couldn't reach the App Store in time. Check your connection and tap Restore Purchases again.",
+    "purchaseGeneric": "Something went wrong with your subscription. Please try again."
   },
   },
   "sidebar": {
   "sidebar": {
     "unlockTitle": "Unlock Premium",
     "unlockTitle": "Unlock Premium",
@@ -113,5 +148,7 @@
   "subscriptionDisclosureTemplate": "Payment will be charged to your Apple ID. Subscription automatically renews at {price} per {period} unless canceled at least 24 hours before the end of the current period.",
   "subscriptionDisclosureTemplate": "Payment will be charged to your Apple ID. Subscription automatically renews at {price} per {period} unless canceled at least 24 hours before the end of the current period.",
   "trialDisclosureTemplate": "After your {trial} free trial, payment will be charged to your Apple ID. Subscription automatically renews at {price} per {period} unless canceled at least 24 hours before the end of the current period.",
   "trialDisclosureTemplate": "After your {trial} free trial, payment will be charged to your Apple ID. Subscription automatically renews at {price} per {period} unless canceled at least 24 hours before the end of the current period.",
   "lifetimeDisclosure": "One-time purchase. No subscription or recurring charges.",
   "lifetimeDisclosure": "One-time purchase. No subscription or recurring charges.",
-  "loadingDisclosure": "Payment will be charged to your Apple ID. Subscription automatically renews unless canceled at least 24 hours before the end of the current period."
+  "loadingDisclosure": "Payment will be charged to your Apple ID. Subscription automatically renews unless canceled at least 24 hours before the end of the current period.",
+  "securePaymentNote": "Secure payment. Cancel anytime.",
+  "closeButtonHelp": "Close"
 }
 }

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff