Răsfoiți Sursa

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 săptămâni în urmă
părinte
comite
121fee5ebc

+ 105 - 0
smart_printer/PaywallConfig.swift

@@ -1,4 +1,5 @@
 import Foundation
 import Foundation
+import StoreKit
 
 
 struct PaywallConfig: Codable, Sendable {
 struct PaywallConfig: Codable, Sendable {
     struct PlanCopy: Codable, Sendable {
     struct PlanCopy: Codable, Sendable {
@@ -51,14 +52,70 @@ struct PaywallConfig: Codable, Sendable {
         let lifetime: PlanCopy
         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 leftPanelTitle: String
     let rightTitle: String
     let rightTitle: String
     let rightSubtitle: String
     let rightSubtitle: String
     let features: [String]
     let features: [String]
     let trustItems: [TrustItem]
     let trustItems: [TrustItem]
+    let lifetimeTrustItem: TrustItem
     let plans: Plans
     let plans: Plans
     let footer: Footer
     let footer: Footer
     let urls: URLs
     let urls: URLs
+    let trial: Trial
+    let duration: Duration
+    let messages: Messages
+    let sidebar: Sidebar
     let loadingPrice: String
     let loadingPrice: String
     let loadingCTA: String
     let loadingCTA: String
     let subscriptionDisclosureTemplate: String
     let subscriptionDisclosureTemplate: String
@@ -76,6 +133,54 @@ extension PaywallConfig {
         }
         }
         return config
         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 {
 extension Notification.Name {

+ 66 - 87
smart_printer/PaywallView.swift

@@ -1,42 +1,6 @@
 import Cocoa
 import Cocoa
 import StoreKit
 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
 // MARK: - Plan Model
 
 
 enum PaywallPlan: CaseIterable {
 enum PaywallPlan: CaseIterable {
@@ -74,7 +38,7 @@ enum PaywallPlan: CaseIterable {
         let price = product.displayPrice
         let price = product.displayPrice
 
 
         if self == .yearly, let introOffer, let trialTemplate = copy.trialSubtitleTemplate {
         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
             return trialTemplate
                 .replacingOccurrences(of: "{trial}", with: duration)
                 .replacingOccurrences(of: "{trial}", with: duration)
                 .replacingOccurrences(of: "{price}", with: price)
                 .replacingOccurrences(of: "{price}", with: price)
@@ -93,7 +57,7 @@ enum PaywallPlan: CaseIterable {
         let price = product.displayPrice
         let price = product.displayPrice
 
 
         if self == .yearly, let introOffer {
         if self == .yearly, let introOffer {
-            return SubscriptionOfferFormatting.freeTrialCTATitle(from: introOffer)
+            return config.trialCTATitle(from: introOffer)
         }
         }
 
 
         return copy.ctaTemplate.replacingOccurrences(of: "{price}", with: price)
         return copy.ctaTemplate.replacingOccurrences(of: "{price}", with: price)
@@ -111,11 +75,11 @@ enum PaywallPlan: CaseIterable {
             guard let product else { return config.loadingDisclosure }
             guard let product else { return config.loadingDisclosure }
             let price = product.displayPrice
             let price = product.displayPrice
             let period = product.subscription.map {
             let period = product.subscription.map {
-                SubscriptionOfferFormatting.subscriptionPeriodDescription(from: $0.subscriptionPeriod)
-            } ?? "period"
+                config.subscriptionPeriodDescription(from: $0.subscriptionPeriod)
+            } ?? config.messages.periodFallback
 
 
             if self == .yearly, let introOffer {
             if self == .yearly, let introOffer {
-                let trial = SubscriptionOfferFormatting.trialDurationDescription(from: introOffer).lowercased()
+                let trial = config.trialDurationDescription(from: introOffer, lowercase: true)
                 return config.trialDisclosureTemplate
                 return config.trialDisclosureTemplate
                     .replacingOccurrences(of: "{trial}", with: trial)
                     .replacingOccurrences(of: "{trial}", with: trial)
                     .replacingOccurrences(of: "{price}", with: price)
                     .replacingOccurrences(of: "{price}", with: price)
@@ -145,18 +109,15 @@ enum PremiumAccessKind: String {
     case lifetime
     case lifetime
 }
 }
 
 
-enum StoreError: LocalizedError {
+enum StoreError: Error {
     case productNotFound
     case productNotFound
     case failedVerification
     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
 @MainActor
@@ -245,7 +206,7 @@ final class StoreManager {
         do {
         do {
             let loaded = try await Product.products(for: StoreProductID.all)
             let loaded = try await Product.products(for: StoreProductID.all)
             guard !loaded.isEmpty else {
             guard !loaded.isEmpty else {
-                productLoadError = "No subscription plans are available right now."
+                productLoadError = PaywallConfigService.shared.config.messages.noPlansAvailable
                 products = []
                 products = []
                 return
                 return
             }
             }
@@ -256,13 +217,13 @@ final class StoreManager {
             await refreshIntroOfferEligibility()
             await refreshIntroOfferEligibility()
             NotificationCenter.default.post(name: .storeProductsDidUpdate, object: nil)
             NotificationCenter.default.post(name: .storeProductsDidUpdate, object: nil)
         } catch {
         } 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)")
             NSLog("Failed to load products: \(error.localizedDescription)")
         }
         }
     }
     }
 
 
     @discardableResult
     @discardableResult
-    func purchase(plan: PaywallPlan) async throws -> Bool {
+    func purchase(plan: PaywallPlan) async throws -> PurchaseOutcome {
         if products.isEmpty {
         if products.isEmpty {
             await loadProducts()
             await loadProducts()
         }
         }
@@ -285,11 +246,13 @@ final class StoreManager {
             let transaction = try checkVerified(verification)
             let transaction = try checkVerified(verification)
             await transaction.finish()
             await transaction.finish()
             await refreshPremiumStatus()
             await refreshPremiumStatus()
-            return isPremium
-        case .userCancelled, .pending:
-            return false
+            return isPremium ? .purchased : .cancelled
+        case .userCancelled:
+            return .cancelled
+        case .pending:
+            return .pending
         @unknown default:
         @unknown default:
-            return false
+            return .cancelled
         }
         }
     }
     }
 
 
@@ -312,7 +275,7 @@ final class StoreManager {
         alert.messageText = title
         alert.messageText = title
         alert.informativeText = message
         alert.informativeText = message
         alert.alertStyle = .informational
         alert.alertStyle = .informational
-        alert.addButton(withTitle: "OK")
+        alert.addButton(withTitle: PaywallConfigService.shared.config.messages.alertOK)
         if let window {
         if let window {
             alert.beginSheetModal(for: window)
             alert.beginSheetModal(for: window)
         } else {
         } else {
@@ -327,16 +290,18 @@ final class StoreManager {
     }
     }
 
 
     func showPurchaseError(_ error: Error, on window: NSWindow?) {
     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 {
     private func handleTransactionUpdate(_ update: VerificationResult<Transaction>) async {
@@ -478,7 +443,9 @@ extension Notification.Name {
 enum PremiumAccess {
 enum PremiumAccess {
     @MainActor
     @MainActor
     static var isGranted: Bool {
     static var isGranted: Bool {
-        StoreManager.shared.isPremium
+        let store = StoreManager.shared
+        guard !store.isResolvingEntitlements else { return false }
+        return store.isPremium
     }
     }
 
 
     @MainActor
     @MainActor
@@ -780,7 +747,7 @@ private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
         guard plan == .yearly else { return }
         guard plan == .yearly else { return }
 
 
         if let introOffer {
         if let introOffer {
-            let text = SubscriptionOfferFormatting.freeTrialBadgeText(from: introOffer)
+            let text = PaywallConfigService.shared.config.trialBadgeText(from: introOffer)
             if let trialBadgeView {
             if let trialBadgeView {
                 trialBadgeView.updateText(text)
                 trialBadgeView.updateText(text)
                 trialBadgeView.isHidden = false
                 trialBadgeView.isHidden = false
@@ -984,7 +951,7 @@ private final class PaywallProductsErrorView: NSView, AppearanceRefreshable {
     var onRetry: (() -> Void)?
     var onRetry: (() -> Void)?
 
 
     private let messageLabel = NSTextField(wrappingLabelWithString: "")
     private let messageLabel = NSTextField(wrappingLabelWithString: "")
-    private let retryButton = PaywallFooterLink(title: "Retry")
+    private let retryButton = PaywallFooterLink(title: "")
 
 
     init() {
     init() {
         super.init(frame: .zero)
         super.init(frame: .zero)
@@ -1020,10 +987,11 @@ private final class PaywallProductsErrorView: NSView, AppearanceRefreshable {
     required init?(coder: NSCoder) { nil }
     required init?(coder: NSCoder) { nil }
 
 
     func update(message: String, isRetrying: Bool) {
     func update(message: String, isRetrying: Bool) {
+        let messages = PaywallConfigService.shared.config.messages
         messageLabel.stringValue = message
         messageLabel.stringValue = message
         retryButton.isHidden = false
         retryButton.isHidden = false
         retryButton.isEnabled = !isRetrying
         retryButton.isEnabled = !isRetrying
-        retryButton.updateTitle(isRetrying ? "Retrying…" : "Retry")
+        retryButton.updateTitle(isRetrying ? messages.retrying : messages.retry)
     }
     }
 
 
     func refreshAppearance() {
     func refreshAppearance() {
@@ -1285,17 +1253,16 @@ final class PaywallView: NSView, AppearanceRefreshable {
 
 
     private func refreshProductsErrorState() {
     private func refreshProductsErrorState() {
         let store = StoreManager.shared
         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]
         let cancelItem = paywallConfig.trustItems[1]
         if selectedPlan == .lifetime {
         if selectedPlan == .lifetime {
+            let item = paywallConfig.lifetimeTrustItem
             trustItemViews[1].update(
             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 {
         } else {
             trustItemViews[1].update(
             trustItemViews[1].update(
@@ -1780,10 +1748,20 @@ final class PaywallView: NSView, AppearanceRefreshable {
         Task { @MainActor in
         Task { @MainActor in
             refreshPurchaseState()
             refreshPurchaseState()
             do {
             do {
-                let succeeded = try await StoreManager.shared.purchase(plan: selectedPlan)
+                let outcome = try await StoreManager.shared.purchase(plan: selectedPlan)
                 refreshStoreState()
                 refreshStoreState()
-                if succeeded {
+                switch outcome {
+                case .purchased:
                     AppRatingManager.shared.promptAfterFirstPurchaseIfNeeded(on: window)
                     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 {
             } catch {
                 refreshPurchaseState()
                 refreshPurchaseState()
@@ -1799,9 +1777,10 @@ final class PaywallView: NSView, AppearanceRefreshable {
                 let restored = try await StoreManager.shared.restorePurchases()
                 let restored = try await StoreManager.shared.restorePurchases()
                 refreshStoreState()
                 refreshStoreState()
                 if !restored {
                 if !restored {
+                    let messages = paywallConfig.messages
                     StoreManager.shared.showAlert(
                     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
                         on: window
                     )
                     )
                 }
                 }

+ 42 - 10
smart_printer/UIComponents.swift

@@ -222,39 +222,70 @@ final class SidebarPremiumCardView: NSView, AppearanceRefreshable {
 
 
     private let cardContainer = NSView()
     private let cardContainer = NSView()
     private let crownIcon = NSImageView()
     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(
     private let descriptionLabel = NSTextField.wrappingThemeLabel(
-        "Get unlimited scans, printing, OCR and all premium features.",
+        "",
         style: .secondary,
         style: .secondary,
         font: AppTheme.regularFont(size: 10)
         font: AppTheme.regularFont(size: 10)
     )
     )
     private let actionButton = PremiumCardActionButton()
     private let actionButton = PremiumCardActionButton()
 
 
     private var isPremium = false
     private var isPremium = false
+    private var configObserver: NSObjectProtocol?
 
 
     init() {
     init() {
         super.init(frame: .zero)
         super.init(frame: .zero)
         translatesAutoresizingMaskIntoConstraints = false
         translatesAutoresizingMaskIntoConstraints = false
         setup()
         setup()
+        observeConfigUpdates()
         update(isPremium: StoreManager.shared.isPremium)
         update(isPremium: StoreManager.shared.isPremium)
     }
     }
 
 
+    deinit {
+        if let configObserver {
+            NotificationCenter.default.removeObserver(configObserver)
+        }
+    }
+
     @available(*, unavailable)
     @available(*, unavailable)
     required init?(coder: NSCoder) { nil }
     required init?(coder: NSCoder) { nil }
 
 
     func update(isPremium: Bool) {
     func update(isPremium: Bool) {
         self.isPremium = isPremium
         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()
         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() {
     func refreshAppearance() {
         cardContainer.layer?.backgroundColor = AppTheme.premiumBackground.cgColor
         cardContainer.layer?.backgroundColor = AppTheme.premiumBackground.cgColor
         cardContainer.layer?.borderColor = AppTheme.premiumCardBorder.cgColor
         cardContainer.layer?.borderColor = AppTheme.premiumCardBorder.cgColor
@@ -283,6 +314,7 @@ final class SidebarPremiumCardView: NSView, AppearanceRefreshable {
         actionButton.onClick = { [weak self] in
         actionButton.onClick = { [weak self] in
             guard let self else { return }
             guard let self else { return }
             if self.isPremium {
             if self.isPremium {
+                guard StoreManager.shared.hasActiveSubscription else { return }
                 self.onManageSubscriptionTapped?()
                 self.onManageSubscriptionTapped?()
             } else {
             } else {
                 self.onUpgradeTapped?()
                 self.onUpgradeTapped?()

+ 11 - 9
smart_printer/ViewController.swift

@@ -503,18 +503,20 @@ class ViewController: NSViewController {
     }
     }
 
 
     private func guardPremiumFeature() -> Bool {
     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
             return false
         }
         }
+
+        guard store.isPremium else {
+            presentPaywall(returnToHomeOnDismiss: false)
+            return false
+        }
         return true
         return true
     }
     }
 
 

+ 43 - 0
smart_printer/paywall.json

@@ -32,6 +32,11 @@
       "subtitle": "Your data is safe with us."
       "subtitle": "Your data is safe with us."
     }
     }
   ],
   ],
+  "lifetimeTrustItem": {
+    "icon": "checkmark.seal.fill",
+    "title": "One-Time Purchase",
+    "subtitle": "Pay once, no recurring charges."
+  },
   "plans": {
   "plans": {
     "monthly": {
     "monthly": {
       "title": "Monthly",
       "title": "Monthly",
@@ -65,6 +70,44 @@
     "support": "https://sites.google.com/view/smartprinterappmacos/get-support",
     "support": "https://sites.google.com/view/smartprinterappmacos/get-support",
     "manageSubscriptions": "https://apps.apple.com/account/subscriptions"
     "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": "—",
   "loadingPrice": "—",
   "loadingCTA": "Loading plans…",
   "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.",
   "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.",