Răsfoiți Sursa

Sync paywall plans with StoreKit and remote config.

Use localized StoreKit prices, dynamically add or remove plan cards from available products, and refresh paywall copy from remote config in all builds.

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

+ 0 - 2
smart_printer/PaywallConfigService.swift

@@ -18,9 +18,7 @@ final class PaywallConfigService {
     func start() {
         guard !hasStarted else { return }
         hasStarted = true
-        #if DEBUG
         Task { await refreshFromRemote() }
-        #endif
     }
 
     func refreshFromRemote() async {

+ 87 - 0
smart_printer/PaywallModels.swift

@@ -1,6 +1,93 @@
 import Foundation
 import StoreKit
 
+enum PaywallPlan: String, CaseIterable {
+    case monthly
+    case yearly
+    case lifetime
+
+    static let displayOrder: [PaywallPlan] = [.monthly, .yearly, .lifetime]
+
+    var productID: String {
+        switch self {
+        case .monthly: StoreProductID.monthly
+        case .yearly: StoreProductID.yearly
+        case .lifetime: StoreProductID.lifetime
+        }
+    }
+
+    func planCopy(from config: PaywallConfig) -> PaywallConfig.PlanCopy {
+        switch self {
+        case .monthly: config.plans.monthly
+        case .yearly: config.plans.yearly
+        case .lifetime: config.plans.lifetime
+        }
+    }
+
+    func localizedPrice(from product: Product?, config: PaywallConfig) -> String {
+        product?.displayPrice ?? config.loadingPrice
+    }
+
+    func localizedSubtitle(
+        from product: Product?,
+        config: PaywallConfig,
+        trial: PaywallTrialDisplay? = nil
+    ) -> String {
+        guard let product else { return config.loadingPrice }
+        let copy = planCopy(from: config)
+        let price = product.displayPrice
+
+        if let trial, let trialTemplate = copy.trialSubtitleTemplate {
+            return trialTemplate
+                .replacingOccurrences(of: "{trial}", with: trial.durationLowercased)
+                .replacingOccurrences(of: "{price}", with: price)
+        }
+
+        return copy.subtitleTemplate.replacingOccurrences(of: "{price}", with: price)
+    }
+
+    func localizedCTATitle(
+        from product: Product?,
+        config: PaywallConfig,
+        trial: PaywallTrialDisplay? = nil
+    ) -> String {
+        guard let product else { return config.loadingCTA }
+        if let trial {
+            return trial.ctaText
+        }
+        let copy = planCopy(from: config)
+        return copy.ctaTemplate.replacingOccurrences(of: "{price}", with: product.displayPrice)
+    }
+
+    func localizedRenewalDisclosure(
+        from product: Product?,
+        config: PaywallConfig,
+        trial: PaywallTrialDisplay? = nil
+    ) -> String {
+        switch self {
+        case .lifetime:
+            return config.lifetimeDisclosure
+        case .monthly, .yearly:
+            guard let product else { return config.loadingDisclosure }
+            let price = product.displayPrice
+            let period = product.subscription.map {
+                config.subscriptionPeriodDescription(from: $0.subscriptionPeriod)
+            } ?? config.messages.periodFallback
+
+            if let trial {
+                return config.trialDisclosureTemplate
+                    .replacingOccurrences(of: "{trial}", with: trial.durationLowercased)
+                    .replacingOccurrences(of: "{price}", with: price)
+                    .replacingOccurrences(of: "{period}", with: period)
+            }
+
+            return config.subscriptionDisclosureTemplate
+                .replacingOccurrences(of: "{price}", with: price)
+                .replacingOccurrences(of: "{period}", with: period)
+        }
+    }
+}
+
 struct PaywallTrialDisplay: Equatable, Sendable {
     let badgeText: String
     let durationLowercased: String

+ 85 - 147
smart_printer/PaywallView.swift

@@ -1,93 +1,6 @@
 import Cocoa
 import StoreKit
 
-// MARK: - Plan Model
-
-enum PaywallPlan: String, CaseIterable {
-    case monthly
-    case yearly
-    case lifetime
-
-    var productID: String {
-        switch self {
-        case .monthly: StoreProductID.monthly
-        case .yearly: StoreProductID.yearly
-        case .lifetime: StoreProductID.lifetime
-        }
-    }
-
-    func planCopy(from config: PaywallConfig) -> PaywallConfig.PlanCopy {
-        switch self {
-        case .monthly: config.plans.monthly
-        case .yearly: config.plans.yearly
-        case .lifetime: config.plans.lifetime
-        }
-    }
-
-    func localizedPrice(from product: Product?, config: PaywallConfig) -> String {
-        product?.displayPrice ?? planCopy(from: config).fallbackPrice
-    }
-
-    func localizedSubtitle(
-        from product: Product?,
-        config: PaywallConfig,
-        trial: PaywallTrialDisplay? = nil
-    ) -> String {
-        guard let product else { return config.loadingPrice }
-        let copy = planCopy(from: config)
-        let price = product.displayPrice
-
-        if let trial, let trialTemplate = copy.trialSubtitleTemplate {
-            return trialTemplate
-                .replacingOccurrences(of: "{trial}", with: trial.durationLowercased)
-                .replacingOccurrences(of: "{price}", with: price)
-        }
-
-        return copy.subtitleTemplate.replacingOccurrences(of: "{price}", with: price)
-    }
-
-    func localizedCTATitle(
-        from product: Product?,
-        config: PaywallConfig,
-        trial: PaywallTrialDisplay? = nil
-    ) -> String {
-        guard let product else { return config.loadingCTA }
-        if let trial {
-            return trial.ctaText
-        }
-        let copy = planCopy(from: config)
-        return copy.ctaTemplate.replacingOccurrences(of: "{price}", with: product.displayPrice)
-    }
-
-    func localizedRenewalDisclosure(
-        from product: Product?,
-        config: PaywallConfig,
-        trial: PaywallTrialDisplay? = nil
-    ) -> String {
-        switch self {
-        case .lifetime:
-            return config.lifetimeDisclosure
-        case .monthly, .yearly:
-            guard let product else { return config.loadingDisclosure }
-            let price = product.displayPrice
-            let period = product.subscription.map {
-                config.subscriptionPeriodDescription(from: $0.subscriptionPeriod)
-            } ?? config.messages.periodFallback
-
-            if let trial {
-                return config.trialDisclosureTemplate
-                    .replacingOccurrences(of: "{trial}", with: trial.durationLowercased)
-                    .replacingOccurrences(of: "{price}", with: price)
-                    .replacingOccurrences(of: "{period}", with: period)
-            }
-
-            return config.subscriptionDisclosureTemplate
-                .replacingOccurrences(of: "{price}", with: price)
-                .replacingOccurrences(of: "{period}", with: period)
-        }
-    }
-}
-
 // MARK: - StoreKit
 
 enum StoreProductID {
@@ -160,14 +73,17 @@ final class StoreManager {
     }
 
     var products: [Product] {
-        StoreProductID.all.compactMap { productsByID[$0] }
+        availablePlans.compactMap { productsByID[$0.productID] }
+    }
+
+    var availablePlans: [PaywallPlan] {
+        PaywallPlan.displayOrder.filter { productsByID[$0.productID] != nil }
     }
 
     var isResolvingEntitlements: Bool { !hasResolvedPremiumStatus }
     var hasResolvedPremiumStatus = false
-    var hasAllProductsLoaded: Bool {
-        StoreProductID.all.allSatisfy { productsByID[$0] != nil }
-    }
+    var hasAvailablePlans: Bool { !availablePlans.isEmpty }
+    var hasAllProductsLoaded: Bool { hasAvailablePlans }
 
     private(set) var isLoadingProducts = false
     private(set) var productLoadError: String?
@@ -432,10 +348,10 @@ final class StoreManager {
     }
 
     private func performLoadProducts(presentLoadingUI: Bool?) async {
-        let missingAny = StoreProductID.all.contains { productsByID[$0] == nil }
-        let shouldShowLoading = presentLoadingUI ?? missingAny
+        let shouldReload = presentLoadingUI == true || productsByID.isEmpty
+        let shouldShowLoading = presentLoadingUI ?? productsByID.isEmpty
 
-        if !missingAny, presentLoadingUI != true {
+        if !shouldReload, presentLoadingUI != true {
             purchaseError = nil
             await refreshPremiumAccess()
             await refreshTrialOffers()
@@ -1077,7 +993,6 @@ private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
     private let titleLabel = NSTextField(labelWithString: "")
     private let subtitleLabel = NSTextField(labelWithString: "")
     private let priceLabel = NSTextField(labelWithString: "")
-    private var badgeView: PaywallBadgeView?
     private var trialBadgeView: PaywallBadgeView?
     private var hoverTracker: HoverTracker?
     private var isHovered = false
@@ -1128,21 +1043,6 @@ private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
             priceLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
         ])
 
-        if plan == .lifetime, let badgeText = plan.planCopy(from: config).badge {
-            let badge = PaywallBadgeView(
-                text: badgeText,
-                iconName: "star.fill",
-                background: AppTheme.paywallGold,
-                foreground: AppTheme.paywallGoldText
-            )
-            badgeView = badge
-            addSubview(badge)
-            NSLayoutConstraint.activate([
-                badge.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
-                badge.topAnchor.constraint(equalTo: topAnchor, constant: 8),
-            ])
-        }
-
         applyCardShadow()
         updateAppearance()
 
@@ -1162,22 +1062,9 @@ private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
         titleLabel.stringValue = plan.planCopy(from: config).title
         subtitleLabel.stringValue = plan.localizedSubtitle(from: product, config: config, trial: trial)
         priceLabel.stringValue = plan.localizedPrice(from: product, config: config)
-        updateLifetimeBadge(config: config)
         updateTrialBadge(trial: trial)
     }
 
-    private func updateLifetimeBadge(config: PaywallConfig) {
-        guard plan == .lifetime else { return }
-        if let badgeText = plan.planCopy(from: config).badge {
-            if let badgeView {
-                badgeView.updateText(badgeText)
-                badgeView.isHidden = false
-            }
-        } else {
-            badgeView?.isHidden = true
-        }
-    }
-
     private func updateTrialBadge(trial: PaywallTrialDisplay?) {
         guard plan == PaywallConfigService.shared.config.trialEligiblePlan else { return }
 
@@ -1594,6 +1481,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
     private var productsErrorView: PaywallProductsErrorView!
     private var plansStack: NSStackView!
     private var plansStackHeightConstraint: NSLayoutConstraint!
+    private var plansAreaViewHeightConstraint: NSLayoutConstraint!
     private var productsErrorHeightConstraint: NSLayoutConstraint!
     private var storeObservers: [NSObjectProtocol] = []
 
@@ -1699,6 +1587,9 @@ final class PaywallView: NSView, AppearanceRefreshable {
     private func refreshProductDisplay() {
         let store = StoreManager.shared
         let config = paywallConfig
+        let availablePlans = store.availablePlans
+        syncPlanCards(with: availablePlans)
+
         for (plan, card) in planCards {
             let product = store.product(for: plan)
             card.updateDisplay(
@@ -1707,6 +1598,10 @@ final class PaywallView: NSView, AppearanceRefreshable {
                 trial: store.trialDisplay(for: plan)
             )
         }
+
+        ensureValidSelectedPlan(from: availablePlans)
+        refreshPlansLayout(planCount: availablePlans.count)
+
         ctaButton.title = selectedPlan.localizedCTATitle(
             from: store.product(for: selectedPlan),
             config: config,
@@ -1726,6 +1621,63 @@ final class PaywallView: NSView, AppearanceRefreshable {
         selectTrialEligiblePlanIfAvailable()
     }
 
+    private func syncPlanCards(with plans: [PaywallPlan]) {
+        guard let plansStack else { return }
+
+        let removedPlans = planCards.keys.filter { !plans.contains($0) }
+        for plan in removedPlans {
+            guard let card = planCards.removeValue(forKey: plan) else { continue }
+            plansStack.removeArrangedSubview(card)
+            card.removeFromSuperview()
+        }
+
+        for plan in plans where planCards[plan] == nil {
+            let card = PaywallPlanCard(plan: plan)
+            card.isChosen = plan == selectedPlan
+            card.onSelect = { [weak self] in self?.selectPlan(plan) }
+            planCards[plan] = card
+        }
+
+        for view in plansStack.arrangedSubviews {
+            plansStack.removeArrangedSubview(view)
+        }
+        for plan in plans {
+            if let card = planCards[plan] {
+                plansStack.addArrangedSubview(card)
+            }
+        }
+    }
+
+    private func ensureValidSelectedPlan(from availablePlans: [PaywallPlan]) {
+        guard !availablePlans.isEmpty else { return }
+        guard !availablePlans.contains(selectedPlan) else { return }
+
+        let preferred = paywallConfig.trialEligiblePlan
+            .flatMap { trialPlan in
+                availablePlans.contains(trialPlan) && StoreManager.shared.trialDisplay(for: trialPlan) != nil
+                    ? trialPlan
+                    : nil
+            }
+            ?? availablePlans.first(where: { $0 == .yearly })
+            ?? availablePlans[0]
+
+        selectPlan(preferred)
+    }
+
+    private func refreshPlansLayout(planCount: Int) {
+        let height = plansAreaHeight(for: planCount)
+        plansStackHeightConstraint?.constant = height
+        plansAreaViewHeightConstraint?.constant = height
+    }
+
+    private func plansAreaHeight(for planCount: Int) -> CGFloat {
+        let cardHeight: CGFloat = 86
+        let spacing: CGFloat = 12
+        let count = CGFloat(max(planCount, 0))
+        guard count > 0 else { return 0 }
+        return count * cardHeight + max(0, count - 1) * spacing
+    }
+
     private func refreshSecurePaymentNote() {
         let config = paywallConfig
         securePaymentLabel.stringValue = selectedPlan == .lifetime
@@ -1735,12 +1687,13 @@ final class PaywallView: NSView, AppearanceRefreshable {
 
     private func refreshProductsErrorState() {
         let store = StoreManager.shared
-        let hasLoadFailure = store.productLoadError != nil && !store.hasAllProductsLoaded
+        let hasLoadFailure = store.productLoadError != nil && !store.hasAvailablePlans
         let isRetrying = hasLoadFailure && store.isLoadingProducts
 
         productsErrorView.isHidden = !hasLoadFailure
-        plansStack.isHidden = hasLoadFailure
-        plansStackHeightConstraint.constant = hasLoadFailure ? 0 : plansAreaHeight
+        plansStack.isHidden = hasLoadFailure || store.availablePlans.isEmpty
+        let visiblePlanCount = hasLoadFailure ? 0 : store.availablePlans.count
+        refreshPlansLayout(planCount: visiblePlanCount)
         productsErrorHeightConstraint.constant = hasLoadFailure ? productsErrorAreaHeight : 0
 
         if hasLoadFailure, let error = store.productLoadError {
@@ -1748,18 +1701,11 @@ final class PaywallView: NSView, AppearanceRefreshable {
         }
     }
 
-    private var plansAreaHeight: CGFloat {
-        let cardHeight: CGFloat = 86
-        let spacing: CGFloat = 12
-        let planCount = CGFloat(PaywallPlan.allCases.count)
-        return planCount * cardHeight + max(0, planCount - 1) * spacing
-    }
-
     private var productsErrorAreaHeight: CGFloat { 86 }
 
     private func refreshPurchaseState() {
         let store = StoreManager.shared
-        let hasProductLoadFailure = store.productLoadError != nil && !store.hasAllProductsLoaded
+        let hasProductLoadFailure = store.productLoadError != nil && !store.hasAvailablePlans
         let isProductReady = store.product(for: selectedPlan) != nil
         let isBusy = store.isPurchasing || store.isLoadingProducts
         let isDisabled = isBusy || !isProductReady || hasProductLoadFailure
@@ -1956,14 +1902,6 @@ final class PaywallView: NSView, AppearanceRefreshable {
         }
         self.productsErrorView = productsErrorView
 
-        for plan in PaywallPlan.allCases {
-            let card = PaywallPlanCard(plan: plan)
-            card.isChosen = plan == selectedPlan
-            card.onSelect = { [weak self] in self?.selectPlan(plan) }
-            planCards[plan] = card
-            plansStack.addArrangedSubview(card)
-        }
-
         ctaButton.title = selectedPlan.localizedCTATitle(
             from: StoreManager.shared.product(for: selectedPlan),
             config: config,
@@ -2009,8 +1947,9 @@ final class PaywallView: NSView, AppearanceRefreshable {
         plansAreaView.addSubview(plansStack)
         plansAreaView.addSubview(productsErrorView)
 
-        plansStackHeightConstraint = plansStack.heightAnchor.constraint(equalToConstant: plansAreaHeight)
+        plansStackHeightConstraint = plansStack.heightAnchor.constraint(equalToConstant: 0)
         productsErrorHeightConstraint = productsErrorView.heightAnchor.constraint(equalToConstant: 0)
+        plansAreaViewHeightConstraint = plansAreaView.heightAnchor.constraint(equalToConstant: 0)
 
         panel.addSubview(title)
         panel.addSubview(subtitle)
@@ -2034,7 +1973,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
             plansAreaView.leadingAnchor.constraint(equalTo: title.leadingAnchor),
             plansAreaView.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
             plansAreaView.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 24),
-            plansAreaView.heightAnchor.constraint(equalToConstant: plansAreaHeight),
+            plansAreaViewHeightConstraint,
 
             plansStack.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor),
             plansStack.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor),
@@ -2346,7 +2285,7 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
 
     private func refreshLoadingOverlay() {
         let store = StoreManager.shared
-        let shouldShow = store.isLoadingProducts && !store.hasAllProductsLoaded && store.productLoadError == nil
+        let shouldShow = store.isLoadingProducts && !store.hasAvailablePlans && store.productLoadError == nil
         loadingOverlay.isHidden = !shouldShow
         if shouldShow {
             loadingIndicator.startAnimation(nil)
@@ -2470,9 +2409,8 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
         }
 
         Task { @MainActor in
-            #if DEBUG
             await PaywallConfigService.shared.refreshFromRemote()
-            #endif
+            paywallView.refreshStoreState()
         }
     }
 

+ 5 - 6
smart_printer/paywall.json

@@ -42,7 +42,7 @@
       "title": "Monthly",
       "subtitleTemplate": "{price} / month, cancel anytime",
       "ctaTemplate": "Subscribe for {price} / Month",
-      "fallbackPrice": "$4.99",
+      "fallbackPrice": "",
       "priceSuffix": "/mo",
       "fallbackBillingDescription": "Billed at {price} every month",
       "features": [
@@ -56,7 +56,7 @@
       "subtitleTemplate": "{price} / year, cancel anytime",
       "trialSubtitleTemplate": "{trial} free, then {price} / year",
       "ctaTemplate": "Subscribe for {price} / Year",
-      "fallbackPrice": "$29.99",
+      "fallbackPrice": "",
       "priceSuffix": "/yr",
       "fallbackBillingDescription": "Billed at {price} every year",
       "features": [
@@ -67,10 +67,9 @@
     },
     "lifetime": {
       "title": "Lifetime",
-      "badge": "Best Value",
       "subtitleTemplate": "{price} once, lifetime access",
       "ctaTemplate": "Buy Lifetime Access for {price}",
-      "fallbackPrice": "$99.99",
+      "fallbackPrice": "",
       "priceSuffix": null,
       "fallbackBillingDescription": "One-time payment of {price}",
       "features": [
@@ -99,8 +98,8 @@
     "badgeTemplate": "{duration} Free Trial",
     "ctaTemplate": "Start {duration} Free Trial",
     "fallbackDuration": {
-      "count": 1,
-      "unit": "week"
+      "count": 3,
+      "unit": "day"
     }
   },
   "duration": {