Sfoglia il codice sorgente

Show free trial messaging only for eligible first-time subscribers.

Use StoreKit intro-offer eligibility and dynamic trial duration instead of hardcoded 7-day copy for all users.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 4 settimane fa
parent
commit
61cbf66afd
1 ha cambiato i file con 124 aggiunte e 31 eliminazioni
  1. 124 31
      smart_printer/PaywallView.swift

+ 124 - 31
smart_printer/PaywallView.swift

@@ -1,6 +1,31 @@
 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"
+    }
+}
+
 // MARK: - Plan Model
 
 enum PaywallPlan: CaseIterable {
@@ -27,7 +52,7 @@ enum PaywallPlan: CaseIterable {
     var subtitle: String {
         switch self {
         case .monthly: "$4.99 / month, cancel anytime"
-        case .yearly: "Eligible new subscribers get 7 days free, then $29.99 / year"
+        case .yearly: "$29.99 / year, cancel anytime"
         case .lifetime: "$99.99 once, lifetime access"
         }
     }
@@ -43,7 +68,7 @@ enum PaywallPlan: CaseIterable {
     var ctaTitle: String {
         switch self {
         case .monthly: "Subscribe for $4.99 / Month"
-        case .yearly: "Start 7-Day Free Trial"
+        case .yearly: "Subscribe for $29.99 / Year"
         case .lifetime: "Buy Lifetime Access"
         }
     }
@@ -52,15 +77,16 @@ enum PaywallPlan: CaseIterable {
         product?.displayPrice ?? price
     }
 
-    func localizedSubtitle(from product: Product?) -> String {
+    func localizedSubtitle(from product: Product?, introOffer: Product.SubscriptionOffer? = nil) -> String {
         guard let product else { return subtitle }
 
         switch self {
         case .monthly:
             return "\(product.displayPrice) / month, cancel anytime"
         case .yearly:
-            if product.subscription?.introductoryOffer != nil {
-                return "Eligible new subscribers get 7 days free, then \(product.displayPrice) / year"
+            if let introOffer {
+                let duration = SubscriptionOfferFormatting.trialDurationDescription(from: introOffer).lowercased()
+                return "Get \(duration) free, then \(product.displayPrice) / year"
             }
             return "\(product.displayPrice) / year, cancel anytime"
         case .lifetime:
@@ -68,15 +94,15 @@ enum PaywallPlan: CaseIterable {
         }
     }
 
-    func localizedCTATitle(from product: Product?) -> String {
+    func localizedCTATitle(from product: Product?, introOffer: Product.SubscriptionOffer? = nil) -> String {
         guard let product else { return ctaTitle }
 
         switch self {
         case .monthly:
             return "Subscribe for \(product.displayPrice) / Month"
         case .yearly:
-            if product.subscription?.introductoryOffer != nil {
-                return "Start 7-Day Free Trial"
+            if let introOffer {
+                return SubscriptionOfferFormatting.freeTrialCTATitle(from: introOffer)
             }
             return "Subscribe for \(product.displayPrice) / Year"
         case .lifetime:
@@ -118,6 +144,7 @@ final class StoreManager {
     var isPro: Bool { isPremium }
     private(set) var isLoadingProducts = false
     private(set) var isPurchasing = false
+    private(set) var introOfferEligibleByProductID: [String: Bool] = [:]
 
     private var transactionListener: Task<Void, Never>?
     private var hasStarted = false
@@ -144,6 +171,20 @@ final class StoreManager {
         products.first { $0.id == plan.productID }
     }
 
+    func eligibleIntroOffer(for plan: PaywallPlan) -> Product.SubscriptionOffer? {
+        guard let product = product(for: plan) else { return nil }
+        return eligibleIntroOffer(for: product)
+    }
+
+    func eligibleIntroOffer(for product: Product) -> Product.SubscriptionOffer? {
+        guard introOfferEligibleByProductID[product.id] == true,
+              let offer = product.subscription?.introductoryOffer,
+              offer.paymentMode == .freeTrial else {
+            return nil
+        }
+        return offer
+    }
+
     func loadProducts() async {
         isLoadingProducts = true
         postStoreStateDidChange()
@@ -157,6 +198,7 @@ final class StoreManager {
                 .sorted { lhs, rhs in
                     productSortOrder(for: lhs.id) < productSortOrder(for: rhs.id)
                 }
+            await refreshIntroOfferEligibility()
             NotificationCenter.default.post(name: .storeProductsDidUpdate, object: nil)
         } catch {
             NSLog("Failed to load products: \(error.localizedDescription)")
@@ -251,6 +293,7 @@ final class StoreManager {
     }
 
     func refreshPremiumStatus() async {
+        await refreshIntroOfferEligibility()
         let hasPremium = await hasActivePremiumAccess()
         let didChange = hasPremium != isPremium
         isPremium = hasPremium
@@ -338,6 +381,21 @@ final class StoreManager {
         }
     }
 
+    private func refreshIntroOfferEligibility() async {
+        var eligibility: [String: Bool] = [:]
+
+        for product in products {
+            guard let subscription = product.subscription,
+                  subscription.introductoryOffer != nil else {
+                continue
+            }
+            let groupID = subscription.subscriptionGroupID
+            eligibility[product.id] = await Product.SubscriptionInfo.isEligibleForIntroOffer(for: groupID)
+        }
+
+        introOfferEligibleByProductID = eligibility
+    }
+
     private func postStoreStateDidChange() {
         NotificationCenter.default.post(name: .storeStateDidChange, object: nil)
     }
@@ -421,7 +479,10 @@ private final class PaywallLeftPanelView: NSView, AppearanceRefreshable {
 // MARK: - Badge
 
 private final class PaywallBadgeView: NSView {
+    private let label: NSTextField
+
     init(text: String, iconName: String, background: NSColor, foreground: NSColor) {
+        label = NSTextField(labelWithString: text)
         super.init(frame: .zero)
         translatesAutoresizingMaskIntoConstraints = false
         wantsLayer = true
@@ -437,7 +498,6 @@ private final class PaywallBadgeView: NSView {
         }
         icon.contentTintColor = foreground
 
-        let label = NSTextField(labelWithString: text)
         label.font = AppTheme.semiboldFont(size: 10)
         label.textColor = foreground
         label.translatesAutoresizingMaskIntoConstraints = false
@@ -459,6 +519,10 @@ private final class PaywallBadgeView: NSView {
         ])
     }
 
+    func updateText(_ text: String) {
+        label.stringValue = text
+    }
+
     @available(*, unavailable)
     required init?(coder: NSCoder) { nil }
 }
@@ -531,6 +595,7 @@ private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
     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
 
@@ -579,20 +644,7 @@ private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
             priceLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
         ])
 
-        if plan == .yearly {
-            let badge = PaywallBadgeView(
-                text: "7 Days Free Trial",
-                iconName: "calendar",
-                background: AppTheme.paywallPink,
-                foreground: AppTheme.paywallPinkText
-            )
-            badgeView = badge
-            addSubview(badge)
-            NSLayoutConstraint.activate([
-                badge.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
-                badge.topAnchor.constraint(equalTo: topAnchor, constant: 8),
-            ])
-        } else if plan == .lifetime {
+        if plan == .lifetime {
             let badge = PaywallBadgeView(
                 text: "Best Value",
                 iconName: "star.fill",
@@ -618,9 +670,37 @@ private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
     @available(*, unavailable)
     required init?(coder: NSCoder) { nil }
 
-    func updateDisplay(product: Product?) {
-        subtitleLabel.stringValue = plan.localizedSubtitle(from: product)
+    func updateDisplay(product: Product?, introOffer: Product.SubscriptionOffer? = nil) {
+        subtitleLabel.stringValue = plan.localizedSubtitle(from: product, introOffer: introOffer)
         priceLabel.stringValue = plan.localizedPrice(from: product)
+        updateTrialBadge(introOffer: introOffer)
+    }
+
+    private func updateTrialBadge(introOffer: Product.SubscriptionOffer?) {
+        guard plan == .yearly else { return }
+
+        if let introOffer {
+            let text = SubscriptionOfferFormatting.freeTrialBadgeText(from: introOffer)
+            if let trialBadgeView {
+                trialBadgeView.updateText(text)
+                trialBadgeView.isHidden = false
+            } else {
+                let badge = PaywallBadgeView(
+                    text: text,
+                    iconName: "calendar",
+                    background: AppTheme.paywallPink,
+                    foreground: AppTheme.paywallPinkText
+                )
+                trialBadgeView = badge
+                addSubview(badge)
+                NSLayoutConstraint.activate([
+                    badge.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
+                    badge.topAnchor.constraint(equalTo: topAnchor, constant: 8),
+                ])
+            }
+        } else {
+            trialBadgeView?.isHidden = true
+        }
     }
 
     func refreshAppearance() {
@@ -925,10 +1005,10 @@ final class PaywallView: NSView, AppearanceRefreshable {
                 self?.refreshProductDisplay()
             },
             center.addObserver(forName: .storeStateDidChange, object: nil, queue: .main) { [weak self] _ in
-                self?.refreshPurchaseState()
+                self?.refreshProductDisplay()
             },
             center.addObserver(forName: .premiumStatusDidChange, object: nil, queue: .main) { [weak self] _ in
-                self?.refreshPurchaseState()
+                self?.refreshProductDisplay()
             },
         ]
     }
@@ -936,9 +1016,16 @@ final class PaywallView: NSView, AppearanceRefreshable {
     private func refreshProductDisplay() {
         let store = StoreManager.shared
         for (plan, card) in planCards {
-            card.updateDisplay(product: store.product(for: plan))
+            let product = store.product(for: plan)
+            card.updateDisplay(
+                product: product,
+                introOffer: store.eligibleIntroOffer(for: plan)
+            )
         }
-        ctaButton.title = selectedPlan.localizedCTATitle(from: store.product(for: selectedPlan))
+        ctaButton.title = selectedPlan.localizedCTATitle(
+            from: store.product(for: selectedPlan),
+            introOffer: store.eligibleIntroOffer(for: selectedPlan)
+        )
         refreshPurchaseState()
     }
 
@@ -1086,7 +1173,10 @@ final class PaywallView: NSView, AppearanceRefreshable {
             plansStack.addArrangedSubview(card)
         }
 
-        ctaButton.title = selectedPlan.localizedCTATitle(from: StoreManager.shared.product(for: selectedPlan))
+        ctaButton.title = selectedPlan.localizedCTATitle(
+            from: StoreManager.shared.product(for: selectedPlan),
+            introOffer: StoreManager.shared.eligibleIntroOffer(for: selectedPlan)
+        )
         ctaButton.target = self
         ctaButton.action = #selector(purchaseTapped)
         ctaButton.translatesAutoresizingMaskIntoConstraints = false
@@ -1263,7 +1353,10 @@ final class PaywallView: NSView, AppearanceRefreshable {
         for (key, card) in planCards {
             card.isChosen = key == plan
         }
-        ctaButton.title = plan.localizedCTATitle(from: StoreManager.shared.product(for: plan))
+        ctaButton.title = plan.localizedCTATitle(
+            from: StoreManager.shared.product(for: plan),
+            introOffer: StoreManager.shared.eligibleIntroOffer(for: plan)
+        )
     }
 
     @objc private func purchaseTapped() {