Prechádzať zdrojové kódy

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 týždňov pred
rodič
commit
160a559d94

+ 81 - 2
smart_printer/PaywallConfig.swift

@@ -8,19 +8,31 @@ struct PaywallConfig: Codable, Sendable {
         let subtitleTemplate: String
         let trialSubtitleTemplate: String?
         let ctaTemplate: String
+        let fallbackPrice: String
+        let priceSuffix: String?
+        let fallbackBillingDescription: String
+        let features: [String]
 
         init(
             title: String,
             badge: String? = nil,
             subtitleTemplate: String,
             trialSubtitleTemplate: String? = nil,
-            ctaTemplate: String
+            ctaTemplate: String,
+            fallbackPrice: String = "—",
+            priceSuffix: String? = nil,
+            fallbackBillingDescription: String = "",
+            features: [String] = []
         ) {
             self.title = title
             self.badge = badge
             self.subtitleTemplate = subtitleTemplate
             self.trialSubtitleTemplate = trialSubtitleTemplate
             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
     }
 
+    struct TrialFallbackDuration: Codable, Sendable {
+        let count: Int
+        let unit: String
+    }
+
     struct Trial: Codable, Sendable {
+        let eligiblePlan: String
         let badgeTemplate: String
         let ctaTemplate: String
+        let fallbackDuration: TrialFallbackDuration?
     }
 
     struct Messages: Codable, Sendable {
@@ -91,6 +110,12 @@ struct PaywallConfig: Codable, Sendable {
         let retry: String
         let retrying: 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 {
@@ -122,6 +147,8 @@ struct PaywallConfig: Codable, Sendable {
     let trialDisclosureTemplate: String
     let lifetimeDisclosure: String
     let loadingDisclosure: String
+    let securePaymentNote: String
+    let closeButtonHelp: String
 }
 
 extension PaywallConfig {
@@ -146,10 +173,35 @@ extension PaywallConfig {
             subscriptionDisclosureTemplate: subscriptionDisclosureTemplate,
             trialDisclosureTemplate: trialDisclosureTemplate,
             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 {
         guard let url = Bundle.main.url(forResource: "paywall", withExtension: "json"),
               let data = try? Data(contentsOf: url),
@@ -206,6 +258,33 @@ extension PaywallConfig {
         let duration = trialDurationDescription(from: offer)
         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 {

+ 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
+}

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 585 - 200
smart_printer/PaywallView.swift


+ 1 - 1
smart_printer/SidebarView.swift

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

+ 26 - 20
smart_printer/UIComponents.swift

@@ -231,54 +231,60 @@ final class SidebarPremiumCardView: NSView, AppearanceRefreshable {
     private let actionButton = PremiumCardActionButton()
 
     private var isPremium = false
-    private var configObserver: NSObjectProtocol?
+    private var activePlan: PaywallPlan?
+    private var observers: [NSObjectProtocol] = []
 
     init() {
         super.init(frame: .zero)
         translatesAutoresizingMaskIntoConstraints = false
         setup()
-        observeConfigUpdates()
-        update(isPremium: StoreManager.shared.isPremium)
+        observeStoreUpdates()
+        refreshFromStore()
     }
 
     deinit {
-        if let configObserver {
-            NotificationCenter.default.removeObserver(configObserver)
-        }
+        observers.forEach { NotificationCenter.default.removeObserver($0) }
     }
 
     @available(*, unavailable)
     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.activePlan = activePlan
         let sidebar = PaywallConfigService.shared.config.sidebar
         titleLabel.stringValue = isPremium ? sidebar.proTitle : sidebar.unlockTitle
         descriptionLabel.stringValue = isPremium ? sidebar.proDescription : sidebar.unlockDescription
 
         let actionTitle: String
-        let showsArrow: Bool
         if isPremium {
-            actionTitle = sidebar.manageSubscriptionAction
-            showsArrow = true
+            actionTitle = activePlan == .lifetime
+                ? sidebar.lifetimeAction
+                : sidebar.manageSubscriptionAction
         } else {
             actionTitle = sidebar.upgradeAction
-            showsArrow = true
         }
 
-        actionButton.configure(title: actionTitle, showsArrow: showsArrow)
+        actionButton.configure(title: actionTitle, showsArrow: true)
         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() {

+ 14 - 0
smart_printer/ViewController.swift

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

+ 44 - 7
smart_printer/paywall.json

@@ -41,19 +41,43 @@
     "monthly": {
       "title": "Monthly",
       "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": {
       "title": "Yearly",
       "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": {
       "title": "Lifetime",
       "badge": "Best Value",
       "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": {
@@ -71,8 +95,13 @@
     "manageSubscriptions": "https://apps.apple.com/account/subscriptions"
   },
   "trial": {
+    "eligiblePlan": "yearly",
     "badgeTemplate": "{duration} Free Trial",
-    "ctaTemplate": "Start {duration} Free Trial"
+    "ctaTemplate": "Start {duration} Free Trial",
+    "fallbackDuration": {
+      "count": 3,
+      "unit": "day"
+    }
   },
   "duration": {
     "units": {
@@ -97,7 +126,13 @@
     "alertOK": "OK",
     "retry": "Retry",
     "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": {
     "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.",
   "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.",
-  "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"
 }

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov