Pārlūkot izejas kodu

Externalize paywall copy and improve purchase flow handling.

Move trial formatting, error messages, and sidebar strings into paywall.json, handle pending purchases explicitly, and fix premium gating while entitlements resolve.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 3 nedēļas atpakaļ
vecāks
revīzija
121fee5ebc

+ 105 - 0
smart_printer/PaywallConfig.swift

@@ -1,4 +1,5 @@
 import Foundation
+import StoreKit
 
 struct PaywallConfig: Codable, Sendable {
     struct PlanCopy: Codable, Sendable {
@@ -51,14 +52,70 @@ struct PaywallConfig: Codable, Sendable {
         let lifetime: PlanCopy
     }
 
+    struct DurationUnit: Codable, Sendable {
+        let one: String
+        let other: String
+        let lowerOne: String
+        let lowerOther: String
+    }
+
+    struct Duration: Codable, Sendable {
+        let units: DurationUnits
+        let countUnitTemplate: String
+        let unknownFallback: String
+    }
+
+    struct DurationUnits: Codable, Sendable {
+        let day: DurationUnit
+        let week: DurationUnit
+        let month: DurationUnit
+        let year: DurationUnit
+    }
+
+    struct Trial: Codable, Sendable {
+        let badgeTemplate: String
+        let ctaTemplate: String
+    }
+
+    struct Messages: Codable, Sendable {
+        let productNotFound: String
+        let failedVerification: String
+        let noPlansAvailable: String
+        let plansLoadFailed: String
+        let purchaseFailedTitle: String
+        let restoreNotFoundTitle: String
+        let restoreNotFoundMessage: String
+        let pendingPurchaseTitle: String
+        let pendingPurchaseMessage: String
+        let alertOK: String
+        let retry: String
+        let retrying: String
+        let periodFallback: String
+    }
+
+    struct Sidebar: Codable, Sendable {
+        let unlockTitle: String
+        let proTitle: String
+        let unlockDescription: String
+        let proDescription: String
+        let upgradeAction: String
+        let manageSubscriptionAction: String
+        let lifetimeAction: String
+    }
+
     let leftPanelTitle: String
     let rightTitle: String
     let rightSubtitle: String
     let features: [String]
     let trustItems: [TrustItem]
+    let lifetimeTrustItem: TrustItem
     let plans: Plans
     let footer: Footer
     let urls: URLs
+    let trial: Trial
+    let duration: Duration
+    let messages: Messages
+    let sidebar: Sidebar
     let loadingPrice: String
     let loadingCTA: String
     let subscriptionDisclosureTemplate: String
@@ -76,6 +133,54 @@ extension PaywallConfig {
         }
         return config
     }
+
+    func formatDuration(count: Int, unit: Product.SubscriptionPeriod.Unit, lowercase: Bool) -> String {
+        guard count > 0 else { return duration.unknownFallback.replacingOccurrences(of: "{count}", with: "0") }
+
+        let durationUnit: DurationUnit
+        switch unit {
+        case .day: durationUnit = duration.units.day
+        case .week: durationUnit = duration.units.week
+        case .month: durationUnit = duration.units.month
+        case .year: durationUnit = duration.units.year
+        @unknown default:
+            return duration.unknownFallback.replacingOccurrences(of: "{count}", with: "\(count)")
+        }
+
+        let unitWord: String
+        if count == 1 {
+            unitWord = lowercase ? durationUnit.lowerOne : durationUnit.one
+        } else {
+            unitWord = lowercase ? durationUnit.lowerOther : durationUnit.other
+        }
+
+        if count == 1, lowercase {
+            return unitWord
+        }
+
+        return duration.countUnitTemplate
+            .replacingOccurrences(of: "{count}", with: "\(count)")
+            .replacingOccurrences(of: "{unit}", with: unitWord)
+    }
+
+    func trialDurationDescription(from offer: Product.SubscriptionOffer, lowercase: Bool = false) -> String {
+        let count = offer.period.value * offer.periodCount
+        return formatDuration(count: count, unit: offer.period.unit, lowercase: lowercase)
+    }
+
+    func subscriptionPeriodDescription(from period: Product.SubscriptionPeriod) -> String {
+        formatDuration(count: period.value, unit: period.unit, lowercase: true)
+    }
+
+    func trialBadgeText(from offer: Product.SubscriptionOffer) -> String {
+        let duration = trialDurationDescription(from: offer)
+        return trial.badgeTemplate.replacingOccurrences(of: "{duration}", with: duration)
+    }
+
+    func trialCTATitle(from offer: Product.SubscriptionOffer) -> String {
+        let duration = trialDurationDescription(from: offer)
+        return trial.ctaTemplate.replacingOccurrences(of: "{duration}", with: duration)
+    }
 }
 
 extension Notification.Name {

+ 66 - 87
smart_printer/PaywallView.swift

@@ -1,42 +1,6 @@
 import Cocoa
 import StoreKit
 
-// MARK: - Intro Offer Formatting
-
-private enum SubscriptionOfferFormatting {
-    static func trialDurationDescription(from offer: Product.SubscriptionOffer) -> String {
-        let count = offer.period.value * offer.periodCount
-        let unit: String
-        switch offer.period.unit {
-        case .day: unit = count == 1 ? "Day" : "Days"
-        case .week: unit = count == 1 ? "Week" : "Weeks"
-        case .month: unit = count == 1 ? "Month" : "Months"
-        case .year: unit = count == 1 ? "Year" : "Years"
-        @unknown default: return "\(count) Days"
-        }
-        return "\(count) \(unit)"
-    }
-
-    static func freeTrialBadgeText(from offer: Product.SubscriptionOffer) -> String {
-        "\(trialDurationDescription(from: offer)) Free Trial"
-    }
-
-    static func freeTrialCTATitle(from offer: Product.SubscriptionOffer) -> String {
-        "Start \(trialDurationDescription(from: offer)) Free Trial"
-    }
-
-    static func subscriptionPeriodDescription(from period: Product.SubscriptionPeriod) -> String {
-        let count = period.value
-        switch period.unit {
-        case .day: return count == 1 ? "day" : "\(count) days"
-        case .week: return count == 1 ? "week" : "\(count) weeks"
-        case .month: return count == 1 ? "month" : "\(count) months"
-        case .year: return count == 1 ? "year" : "\(count) years"
-        @unknown default: return "\(count) days"
-        }
-    }
-}
-
 // MARK: - Plan Model
 
 enum PaywallPlan: CaseIterable {
@@ -74,7 +38,7 @@ enum PaywallPlan: CaseIterable {
         let price = product.displayPrice
 
         if self == .yearly, let introOffer, let trialTemplate = copy.trialSubtitleTemplate {
-            let duration = SubscriptionOfferFormatting.trialDurationDescription(from: introOffer).lowercased()
+            let duration = config.trialDurationDescription(from: introOffer, lowercase: true)
             return trialTemplate
                 .replacingOccurrences(of: "{trial}", with: duration)
                 .replacingOccurrences(of: "{price}", with: price)
@@ -93,7 +57,7 @@ enum PaywallPlan: CaseIterable {
         let price = product.displayPrice
 
         if self == .yearly, let introOffer {
-            return SubscriptionOfferFormatting.freeTrialCTATitle(from: introOffer)
+            return config.trialCTATitle(from: introOffer)
         }
 
         return copy.ctaTemplate.replacingOccurrences(of: "{price}", with: price)
@@ -111,11 +75,11 @@ enum PaywallPlan: CaseIterable {
             guard let product else { return config.loadingDisclosure }
             let price = product.displayPrice
             let period = product.subscription.map {
-                SubscriptionOfferFormatting.subscriptionPeriodDescription(from: $0.subscriptionPeriod)
-            } ?? "period"
+                config.subscriptionPeriodDescription(from: $0.subscriptionPeriod)
+            } ?? config.messages.periodFallback
 
             if self == .yearly, let introOffer {
-                let trial = SubscriptionOfferFormatting.trialDurationDescription(from: introOffer).lowercased()
+                let trial = config.trialDurationDescription(from: introOffer, lowercase: true)
                 return config.trialDisclosureTemplate
                     .replacingOccurrences(of: "{trial}", with: trial)
                     .replacingOccurrences(of: "{price}", with: price)
@@ -145,18 +109,15 @@ enum PremiumAccessKind: String {
     case lifetime
 }
 
-enum StoreError: LocalizedError {
+enum StoreError: Error {
     case productNotFound
     case failedVerification
+}
 
-    var errorDescription: String? {
-        switch self {
-        case .productNotFound:
-            "The selected plan is not available right now. Please try again later."
-        case .failedVerification:
-            "We couldn't verify your purchase. Please contact support."
-        }
-    }
+enum PurchaseOutcome: Sendable {
+    case purchased
+    case cancelled
+    case pending
 }
 
 @MainActor
@@ -245,7 +206,7 @@ final class StoreManager {
         do {
             let loaded = try await Product.products(for: StoreProductID.all)
             guard !loaded.isEmpty else {
-                productLoadError = "No subscription plans are available right now."
+                productLoadError = PaywallConfigService.shared.config.messages.noPlansAvailable
                 products = []
                 return
             }
@@ -256,13 +217,13 @@ final class StoreManager {
             await refreshIntroOfferEligibility()
             NotificationCenter.default.post(name: .storeProductsDidUpdate, object: nil)
         } catch {
-            productLoadError = "Couldn't load subscription plans. Check your connection and try again."
+            productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
             NSLog("Failed to load products: \(error.localizedDescription)")
         }
     }
 
     @discardableResult
-    func purchase(plan: PaywallPlan) async throws -> Bool {
+    func purchase(plan: PaywallPlan) async throws -> PurchaseOutcome {
         if products.isEmpty {
             await loadProducts()
         }
@@ -285,11 +246,13 @@ final class StoreManager {
             let transaction = try checkVerified(verification)
             await transaction.finish()
             await refreshPremiumStatus()
-            return isPremium
-        case .userCancelled, .pending:
-            return false
+            return isPremium ? .purchased : .cancelled
+        case .userCancelled:
+            return .cancelled
+        case .pending:
+            return .pending
         @unknown default:
-            return false
+            return .cancelled
         }
     }
 
@@ -312,7 +275,7 @@ final class StoreManager {
         alert.messageText = title
         alert.informativeText = message
         alert.alertStyle = .informational
-        alert.addButton(withTitle: "OK")
+        alert.addButton(withTitle: PaywallConfigService.shared.config.messages.alertOK)
         if let window {
             alert.beginSheetModal(for: window)
         } else {
@@ -327,16 +290,18 @@ final class StoreManager {
     }
 
     func showPurchaseError(_ error: Error, on window: NSWindow?) {
-        if let storeError = error as? StoreError {
-            showAlert(title: "Purchase Failed", message: storeError.localizedDescription, on: window)
-            return
-        }
-
-        if let storeKitError = error as? StoreKitError, case .userCancelled = storeKitError {
-            return
+        let messages = PaywallConfigService.shared.config.messages
+        switch error {
+        case StoreError.productNotFound:
+            showAlert(title: messages.purchaseFailedTitle, message: messages.productNotFound, on: window)
+        case StoreError.failedVerification:
+            showAlert(title: messages.purchaseFailedTitle, message: messages.failedVerification, on: window)
+        default:
+            if let storeKitError = error as? StoreKitError, case .userCancelled = storeKitError {
+                return
+            }
+            showAlert(title: messages.purchaseFailedTitle, message: error.localizedDescription, on: window)
         }
-
-        showAlert(title: "Purchase Failed", message: error.localizedDescription, on: window)
     }
 
     private func handleTransactionUpdate(_ update: VerificationResult<Transaction>) async {
@@ -478,7 +443,9 @@ extension Notification.Name {
 enum PremiumAccess {
     @MainActor
     static var isGranted: Bool {
-        StoreManager.shared.isPremium
+        let store = StoreManager.shared
+        guard !store.isResolvingEntitlements else { return false }
+        return store.isPremium
     }
 
     @MainActor
@@ -780,7 +747,7 @@ private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
         guard plan == .yearly else { return }
 
         if let introOffer {
-            let text = SubscriptionOfferFormatting.freeTrialBadgeText(from: introOffer)
+            let text = PaywallConfigService.shared.config.trialBadgeText(from: introOffer)
             if let trialBadgeView {
                 trialBadgeView.updateText(text)
                 trialBadgeView.isHidden = false
@@ -984,7 +951,7 @@ private final class PaywallProductsErrorView: NSView, AppearanceRefreshable {
     var onRetry: (() -> Void)?
 
     private let messageLabel = NSTextField(wrappingLabelWithString: "")
-    private let retryButton = PaywallFooterLink(title: "Retry")
+    private let retryButton = PaywallFooterLink(title: "")
 
     init() {
         super.init(frame: .zero)
@@ -1020,10 +987,11 @@ private final class PaywallProductsErrorView: NSView, AppearanceRefreshable {
     required init?(coder: NSCoder) { nil }
 
     func update(message: String, isRetrying: Bool) {
+        let messages = PaywallConfigService.shared.config.messages
         messageLabel.stringValue = message
         retryButton.isHidden = false
         retryButton.isEnabled = !isRetrying
-        retryButton.updateTitle(isRetrying ? "Retrying…" : "Retry")
+        retryButton.updateTitle(isRetrying ? messages.retrying : messages.retry)
     }
 
     func refreshAppearance() {
@@ -1285,17 +1253,16 @@ final class PaywallView: NSView, AppearanceRefreshable {
 
     private func refreshProductsErrorState() {
         let store = StoreManager.shared
-        let shouldShowError = store.productLoadError != nil
-            && store.products.isEmpty
-            && !store.isLoadingProducts
+        let hasLoadFailure = store.productLoadError != nil && store.products.isEmpty
+        let isRetrying = hasLoadFailure && store.isLoadingProducts
 
-        productsErrorView.isHidden = !shouldShowError
-        plansStack.isHidden = shouldShowError
-        plansStackHeightConstraint.constant = shouldShowError ? 0 : plansAreaHeight
-        productsErrorHeightConstraint.constant = shouldShowError ? productsErrorAreaHeight : 0
+        productsErrorView.isHidden = !hasLoadFailure
+        plansStack.isHidden = hasLoadFailure
+        plansStackHeightConstraint.constant = hasLoadFailure ? 0 : plansAreaHeight
+        productsErrorHeightConstraint.constant = hasLoadFailure ? productsErrorAreaHeight : 0
 
-        if shouldShowError, let error = store.productLoadError {
-            productsErrorView.update(message: error, isRetrying: false)
+        if hasLoadFailure, let error = store.productLoadError {
+            productsErrorView.update(message: error, isRetrying: isRetrying)
         }
     }
 
@@ -1337,10 +1304,11 @@ final class PaywallView: NSView, AppearanceRefreshable {
 
         let cancelItem = paywallConfig.trustItems[1]
         if selectedPlan == .lifetime {
+            let item = paywallConfig.lifetimeTrustItem
             trustItemViews[1].update(
-                iconName: "checkmark.seal.fill",
-                title: "One-Time Purchase",
-                subtitle: "Pay once, no recurring charges."
+                iconName: item.icon,
+                title: item.title,
+                subtitle: item.subtitle
             )
         } else {
             trustItemViews[1].update(
@@ -1780,10 +1748,20 @@ final class PaywallView: NSView, AppearanceRefreshable {
         Task { @MainActor in
             refreshPurchaseState()
             do {
-                let succeeded = try await StoreManager.shared.purchase(plan: selectedPlan)
+                let outcome = try await StoreManager.shared.purchase(plan: selectedPlan)
                 refreshStoreState()
-                if succeeded {
+                switch outcome {
+                case .purchased:
                     AppRatingManager.shared.promptAfterFirstPurchaseIfNeeded(on: window)
+                case .pending:
+                    let messages = paywallConfig.messages
+                    StoreManager.shared.showAlert(
+                        title: messages.pendingPurchaseTitle,
+                        message: messages.pendingPurchaseMessage,
+                        on: window
+                    )
+                case .cancelled:
+                    break
                 }
             } catch {
                 refreshPurchaseState()
@@ -1799,9 +1777,10 @@ final class PaywallView: NSView, AppearanceRefreshable {
                 let restored = try await StoreManager.shared.restorePurchases()
                 refreshStoreState()
                 if !restored {
+                    let messages = paywallConfig.messages
                     StoreManager.shared.showAlert(
-                        title: "No Purchases Found",
-                        message: "We couldn't find any previous purchases for this Apple ID.",
+                        title: messages.restoreNotFoundTitle,
+                        message: messages.restoreNotFoundMessage,
                         on: window
                     )
                 }

+ 42 - 10
smart_printer/UIComponents.swift

@@ -222,39 +222,70 @@ final class SidebarPremiumCardView: NSView, AppearanceRefreshable {
 
     private let cardContainer = NSView()
     private let crownIcon = NSImageView()
-    private let titleLabel = NSTextField.labelWithTheme("Unlock Premium", style: .primary, font: AppTheme.semiboldFont(size: 12))
+    private let titleLabel = NSTextField.labelWithTheme("", style: .primary, font: AppTheme.semiboldFont(size: 12))
     private let descriptionLabel = NSTextField.wrappingThemeLabel(
-        "Get unlimited scans, printing, OCR and all premium features.",
+        "",
         style: .secondary,
         font: AppTheme.regularFont(size: 10)
     )
     private let actionButton = PremiumCardActionButton()
 
     private var isPremium = false
+    private var configObserver: NSObjectProtocol?
 
     init() {
         super.init(frame: .zero)
         translatesAutoresizingMaskIntoConstraints = false
         setup()
+        observeConfigUpdates()
         update(isPremium: StoreManager.shared.isPremium)
     }
 
+    deinit {
+        if let configObserver {
+            NotificationCenter.default.removeObserver(configObserver)
+        }
+    }
+
     @available(*, unavailable)
     required init?(coder: NSCoder) { nil }
 
     func update(isPremium: Bool) {
         self.isPremium = isPremium
-        titleLabel.stringValue = isPremium ? "Smart Printer Pro" : "Unlock Premium"
-        descriptionLabel.stringValue = isPremium
-            ? "You have unlimited scans, printing, OCR and all premium features."
-            : "Get unlimited scans, printing, OCR and all premium features."
-        actionButton.configure(
-            title: isPremium ? "Manage Subscription" : "Upgrade Now",
-            showsArrow: !isPremium
-        )
+        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 {
+            if StoreManager.shared.hasLifetimeAccess {
+                actionTitle = sidebar.lifetimeAction
+                showsArrow = false
+            } else {
+                actionTitle = sidebar.manageSubscriptionAction
+                showsArrow = true
+            }
+        } else {
+            actionTitle = sidebar.upgradeAction
+            showsArrow = true
+        }
+
+        actionButton.configure(title: actionTitle, showsArrow: showsArrow)
         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)
+        }
+    }
+
     func refreshAppearance() {
         cardContainer.layer?.backgroundColor = AppTheme.premiumBackground.cgColor
         cardContainer.layer?.borderColor = AppTheme.premiumCardBorder.cgColor
@@ -283,6 +314,7 @@ final class SidebarPremiumCardView: NSView, AppearanceRefreshable {
         actionButton.onClick = { [weak self] in
             guard let self else { return }
             if self.isPremium {
+                guard StoreManager.shared.hasActiveSubscription else { return }
                 self.onManageSubscriptionTapped?()
             } else {
                 self.onUpgradeTapped?()

+ 11 - 9
smart_printer/ViewController.swift

@@ -503,18 +503,20 @@ class ViewController: NSViewController {
     }
 
     private func guardPremiumFeature() -> Bool {
-        guard StoreManager.shared.isPremium else {
-            if StoreManager.shared.isResolvingEntitlements {
-                Task { @MainActor in
-                    await StoreManager.shared.ensureEntitlementsResolved()
-                    guard !StoreManager.shared.isPremium else { return }
-                    self.presentPaywall(returnToHomeOnDismiss: false)
-                }
-            } else {
-                presentPaywall(returnToHomeOnDismiss: false)
+        let store = StoreManager.shared
+        if store.isResolvingEntitlements {
+            Task { @MainActor in
+                await store.ensureEntitlementsResolved()
+                guard !store.isPremium else { return }
+                self.presentPaywall(returnToHomeOnDismiss: false)
             }
             return false
         }
+
+        guard store.isPremium else {
+            presentPaywall(returnToHomeOnDismiss: false)
+            return false
+        }
         return true
     }
 

+ 43 - 0
smart_printer/paywall.json

@@ -32,6 +32,11 @@
       "subtitle": "Your data is safe with us."
     }
   ],
+  "lifetimeTrustItem": {
+    "icon": "checkmark.seal.fill",
+    "title": "One-Time Purchase",
+    "subtitle": "Pay once, no recurring charges."
+  },
   "plans": {
     "monthly": {
       "title": "Monthly",
@@ -65,6 +70,44 @@
     "support": "https://sites.google.com/view/smartprinterappmacos/get-support",
     "manageSubscriptions": "https://apps.apple.com/account/subscriptions"
   },
+  "trial": {
+    "badgeTemplate": "{duration} Free Trial",
+    "ctaTemplate": "Start {duration} Free Trial"
+  },
+  "duration": {
+    "units": {
+      "day": { "one": "Day", "other": "Days", "lowerOne": "day", "lowerOther": "days" },
+      "week": { "one": "Week", "other": "Weeks", "lowerOne": "week", "lowerOther": "weeks" },
+      "month": { "one": "Month", "other": "Months", "lowerOne": "month", "lowerOther": "months" },
+      "year": { "one": "Year", "other": "Years", "lowerOne": "year", "lowerOther": "years" }
+    },
+    "countUnitTemplate": "{count} {unit}",
+    "unknownFallback": "{count} days"
+  },
+  "messages": {
+    "productNotFound": "The selected plan is not available right now. Please try again later.",
+    "failedVerification": "We couldn't verify your purchase. Please contact support.",
+    "noPlansAvailable": "No subscription plans are available right now.",
+    "plansLoadFailed": "Couldn't load subscription plans. Check your connection and try again.",
+    "purchaseFailedTitle": "Purchase Failed",
+    "restoreNotFoundTitle": "No Purchases Found",
+    "restoreNotFoundMessage": "We couldn't find any previous purchases for this Apple ID.",
+    "pendingPurchaseTitle": "Purchase Pending",
+    "pendingPurchaseMessage": "Your purchase is waiting for approval. You'll get access once it's approved.",
+    "alertOK": "OK",
+    "retry": "Retry",
+    "retrying": "Retrying…",
+    "periodFallback": "period"
+  },
+  "sidebar": {
+    "unlockTitle": "Unlock Premium",
+    "proTitle": "Smart Printer Pro",
+    "unlockDescription": "Get unlimited scans, printing, OCR and all premium features.",
+    "proDescription": "You have unlimited scans, printing, OCR and all premium features.",
+    "upgradeAction": "Upgrade Now",
+    "manageSubscriptionAction": "Manage Subscription",
+    "lifetimeAction": "Premium Active"
+  },
   "loadingPrice": "—",
   "loadingCTA": "Loading plans…",
   "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.",