소스 검색

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 주 전
부모
커밋
121fee5ebc
5개의 변경된 파일267개의 추가작업 그리고 106개의 파일을 삭제
  1. 105 0
      smart_printer/PaywallConfig.swift
  2. 66 87
      smart_printer/PaywallView.swift
  3. 42 10
      smart_printer/UIComponents.swift
  4. 11 9
      smart_printer/ViewController.swift
  5. 43 0
      smart_printer/paywall.json

+ 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.",