Jelajahi Sumber

Add configurable yearly free trial to the paywall for eligible first-time subscribers.

Surface trial copy, CTA, and a top-right badge on the yearly card using StoreKit eligibility with paywall.json fallback when intro offer metadata is missing.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 3 minggu lalu
induk
melakukan
57804fb5ed

+ 49 - 0
App AI for Reddit/Managers/SubscriptionManager.swift

@@ -38,6 +38,7 @@ final class SubscriptionManager: ObservableObject {
     @Published private(set) var activePremiumPlan: PaywallPlan?
     @Published private(set) var productLoadError: String?
     @Published private(set) var hasEverPurchasedPremium = false
+    @Published private(set) var trialDisplayByPlan: [PaywallPlan: PaywallTrialDisplay] = [:]
     @Published var purchaseError: PurchaseError?
 
     var availablePaywallPlans: [PaywallPlan] {
@@ -135,6 +136,14 @@ final class SubscriptionManager: ObservableObject {
         productsByID[plan.productID]
     }
 
+    func trialDisplay(for plan: PaywallPlan) -> PaywallTrialDisplay? {
+        trialDisplayByPlan[plan]
+    }
+
+    func refreshTrialEligibility() async {
+        await refreshTrialOffers()
+    }
+
     func mainPrice(for plan: PaywallPlan) -> String {
         let config = PaywallConfigService.shared.config
         return product(for: plan)?.displayPrice ?? plan.planCopy(from: config).fallbackPrice
@@ -261,6 +270,7 @@ final class SubscriptionManager: ObservableObject {
         if !missingAny, presentLoadingUI != true {
             purchaseError = nil
             await refreshPremiumAccess()
+            await refreshTrialOffers()
             return
         }
 
@@ -289,6 +299,7 @@ final class SubscriptionManager: ObservableObject {
             productsByID = map
             productLoadError = nil
             await refreshPremiumAccess()
+            await refreshTrialOffers()
         } catch is ProductLoadError {
             productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
             purchaseError = .productsLoadTimeout
@@ -344,6 +355,7 @@ final class SubscriptionManager: ObservableObject {
         hasResolvedPremiumStatus = true
         persistPremiumCache()
         await refreshPurchaseHistory()
+        await refreshTrialOffers()
     }
 
     /// Re-reads entitlements from StoreKit, then syncs with the App Store when a
@@ -422,9 +434,46 @@ final class SubscriptionManager: ObservableObject {
     private func markAsHavingPurchasedPremium() {
         guard !hasEverPurchasedPremium else { return }
         hasEverPurchasedPremium = true
+        trialDisplayByPlan = [:]
         UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
     }
 
+    private func refreshTrialOffers() async {
+        guard let eligiblePlan = PaywallConfigService.shared.config.trialEligiblePlan else {
+            trialDisplayByPlan = [:]
+            return
+        }
+
+        if hasPremiumAccess {
+            trialDisplayByPlan = [:]
+            return
+        }
+
+        if let display = await resolveTrialDisplay(for: eligiblePlan) {
+            trialDisplayByPlan = [eligiblePlan: display]
+        } else {
+            trialDisplayByPlan = [:]
+        }
+    }
+
+    private func resolveTrialDisplay(for plan: PaywallPlan) async -> PaywallTrialDisplay? {
+        guard plan == PaywallConfigService.shared.config.trialEligiblePlan else { return nil }
+        guard let product = product(for: plan),
+              let subscription = product.subscription else {
+            return nil
+        }
+        guard await subscription.isEligibleForIntroOffer else { return nil }
+
+        let config = PaywallConfigService.shared.config
+        if let offer = subscription.introductoryOffer {
+            return PaywallTrialDisplay.from(offer: offer, config: config)
+        }
+        if let fallback = config.trial.fallbackDuration {
+            return PaywallTrialDisplay.from(fallback: fallback, config: config)
+        }
+        return nil
+    }
+
     private func refreshPurchaseHistory() async {
         if hasEverPurchasedPremium { return }
 

+ 34 - 0
App AI for Reddit/Models/PaywallConfig.swift

@@ -84,9 +84,16 @@ struct PaywallConfig: Codable, Sendable {
         let year: DurationUnit
     }
 
+    struct TrialFallbackDuration: Codable, Sendable {
+        let count: Int
+        let unit: String
+    }
+
     struct Trial: Codable, Sendable {
+        let eligiblePlan: String
         let badgeTemplate: String
         let ctaTemplate: String
+        let fallbackDuration: TrialFallbackDuration?
     }
 
     struct Messages: Codable, Sendable {
@@ -252,6 +259,33 @@ extension PaywallConfig {
         let duration = trialDurationDescription(from: offer)
         return trial.ctaTemplate.replacingOccurrences(of: "{duration}", with: duration)
     }
+
+    func trialBadgeText(duration: String) -> String {
+        trial.badgeTemplate.replacingOccurrences(of: "{duration}", with: duration)
+    }
+
+    func trialCTATitle(duration: String) -> String {
+        trial.ctaTemplate.replacingOccurrences(of: "{duration}", with: duration)
+    }
+
+    func trialDurationDescription(from fallback: TrialFallbackDuration, lowercase: Bool = false) -> String? {
+        guard let unit = subscriptionPeriodUnit(from: fallback.unit) else { return nil }
+        return formatDuration(count: fallback.count, unit: unit, lowercase: lowercase)
+    }
+
+    var trialEligiblePlan: PaywallPlan? {
+        PaywallPlan(rawValue: trial.eligiblePlan)
+    }
+
+    private func subscriptionPeriodUnit(from value: String) -> Product.SubscriptionPeriod.Unit? {
+        switch value {
+        case "day": .day
+        case "week": .week
+        case "month": .month
+        case "year": .year
+        default: nil
+        }
+    }
 }
 
 extension PaywallConfig: Equatable {

+ 57 - 3
App AI for Reddit/Models/PaywallModels.swift

@@ -1,6 +1,34 @@
 import Foundation
 import StoreKit
 
+struct PaywallTrialDisplay: Equatable, Sendable {
+    let badgeText: String
+    let durationLowercased: String
+    let ctaText: String
+
+    static func from(offer: Product.SubscriptionOffer, config: PaywallConfig) -> PaywallTrialDisplay {
+        let duration = config.trialDurationDescription(from: offer)
+        let durationLowercased = config.trialDurationDescription(from: offer, lowercase: true)
+        return PaywallTrialDisplay(
+            badgeText: config.trialBadgeText(from: offer),
+            durationLowercased: durationLowercased,
+            ctaText: config.trialCTATitle(from: offer)
+        )
+    }
+
+    static func from(fallback: PaywallConfig.TrialFallbackDuration, config: PaywallConfig) -> PaywallTrialDisplay? {
+        guard let duration = config.trialDurationDescription(from: fallback),
+              let durationLowercased = config.trialDurationDescription(from: fallback, lowercase: true) else {
+            return nil
+        }
+        return PaywallTrialDisplay(
+            badgeText: config.trialBadgeText(duration: duration),
+            durationLowercased: durationLowercased,
+            ctaText: config.trialCTATitle(duration: duration)
+        )
+    }
+}
+
 enum PaywallPlan: String, CaseIterable, Identifiable {
     case monthly
     case yearly
@@ -28,19 +56,39 @@ enum PaywallPlan: String, CaseIterable, Identifiable {
         product?.displayPrice ?? planCopy(from: config).fallbackPrice
     }
 
-    func localizedSubtitle(from product: Product?, config: PaywallConfig) -> String {
+    func localizedSubtitle(
+        from product: Product?,
+        config: PaywallConfig,
+        trial: PaywallTrialDisplay? = nil
+    ) -> String {
         guard let product else { return config.loadingPrice }
         let copy = planCopy(from: config)
+        if let trial, let template = copy.trialSubtitleTemplate {
+            return template
+                .replacingOccurrences(of: "{price}", with: product.displayPrice)
+                .replacingOccurrences(of: "{trial}", with: trial.durationLowercased)
+        }
         return copy.subtitleTemplate.replacingOccurrences(of: "{price}", with: product.displayPrice)
     }
 
-    func localizedCTATitle(from product: Product?, config: PaywallConfig) -> String {
+    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) -> String {
+    func localizedRenewalDisclosure(
+        from product: Product?,
+        config: PaywallConfig,
+        trial: PaywallTrialDisplay? = nil
+    ) -> String {
         switch self {
         case .lifetime:
             return config.lifetimeDisclosure
@@ -50,6 +98,12 @@ enum PaywallPlan: String, CaseIterable, Identifiable {
             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)

+ 7 - 1
App AI for Reddit/Paywall.storekit

@@ -119,7 +119,13 @@
           "familyShareable" : false,
           "groupNumber" : 2,
           "internalID" : "6748291003",
-          "introductoryOffer" : null,
+          "introductoryOffer" : {
+            "displayPrice" : "0.00",
+            "internalID" : "6748291004",
+            "numberOfPeriods" : 1,
+            "paymentMode" : "free",
+            "subscriptionPeriod" : "P3D"
+          },
           "localizations" : [
             {
               "description" : "Yearly access to all App AI for Reddit Pro features.",

+ 35 - 13
App AI for Reddit/Views/PaywallView.swift

@@ -81,11 +81,22 @@ struct PaywallView: View {
             if !subscriptions.hasAllProductsLoaded, !subscriptions.isLoadingProducts {
                 await subscriptions.loadProducts(presentLoadingUI: true)
             }
+            await subscriptions.refreshTrialEligibility()
             await paywallConfigService.refreshFromRemote()
             viewModel.syncSelectedPlan(with: availablePlans)
         }
         .onChange(of: subscriptions.hasEverPurchasedPremium) { _, _ in
             viewModel.syncSelectedPlan(with: availablePlans)
+            if let eligiblePlan = config.trialEligiblePlan,
+               subscriptions.trialDisplay(for: eligiblePlan) != nil {
+                viewModel.selectPlan(eligiblePlan)
+            }
+        }
+        .onChange(of: subscriptions.trialDisplayByPlan) { _, _ in
+            if let eligiblePlan = config.trialEligiblePlan,
+               subscriptions.trialDisplay(for: eligiblePlan) != nil {
+                viewModel.selectPlan(eligiblePlan)
+            }
         }
         .onReceive(NotificationCenter.default.publisher(for: .paywallConfigDidUpdate)) { _ in }
         .alert(
@@ -185,6 +196,7 @@ struct PaywallView: View {
                     plan: plan,
                     config: config,
                     product: subscriptions.product(for: plan),
+                    trial: subscriptions.trialDisplay(for: plan),
                     isSelected: viewModel.selectedPlan == plan,
                     metrics: metrics
                 ) {
@@ -198,13 +210,15 @@ struct PaywallView: View {
 
     private func ctaSection(metrics: PaywallLayoutMetrics) -> some View {
         let selectedProduct = subscriptions.product(for: viewModel.selectedPlan)
+        let selectedTrial = subscriptions.trialDisplay(for: viewModel.selectedPlan)
         let ctaDisabled = buttonsBusy || subscriptions.isLoadingProducts || !isProductReady || hasProductLoadFailure
 
         return VStack(spacing: metrics.ctaSpacing) {
             Text(
                 viewModel.selectedPlan.localizedRenewalDisclosure(
                     from: selectedProduct,
-                    config: config
+                    config: config,
+                    trial: selectedTrial
                 )
             )
             .font(.system(size: metrics.footerNoteFontSize))
@@ -228,7 +242,8 @@ struct PaywallView: View {
                     Text(
                         viewModel.selectedPlan.localizedCTATitle(
                             from: selectedProduct,
-                            config: config
+                            config: config,
+                            trial: selectedTrial
                         )
                     )
                     .font(.system(size: metrics.ctaFontSize, weight: .bold))
@@ -398,6 +413,7 @@ private struct PaywallPricingCard: View {
     let plan: PaywallPlan
     let config: PaywallConfig
     let product: Product?
+    let trial: PaywallTrialDisplay?
     let isSelected: Bool
     let metrics: PaywallLayoutMetrics
     let onSelect: () -> Void
@@ -417,7 +433,7 @@ private struct PaywallPricingCard: View {
                     .font(.system(size: metrics.ctaFontSize, weight: .semibold))
                     .foregroundStyle(AppTheme.textPrimary)
 
-                Text(plan.localizedSubtitle(from: product, config: config))
+                Text(plan.localizedSubtitle(from: product, config: config, trial: trial))
                     .font(.system(size: metrics.featureFontSize * 0.85))
                     .foregroundStyle(AppTheme.textTertiary)
                     .fixedSize(horizontal: false, vertical: true)
@@ -470,22 +486,28 @@ private struct PaywallPricingCard: View {
                     )
             )
             .overlay(alignment: .topTrailing) {
-                if let badge = planCopy.badge, plan == .lifetime {
-                    Text(badge)
-                        .font(.system(size: metrics.featureFontSize * 0.75, weight: .bold))
-                        .foregroundStyle(Color(hex: 0x0B0E14))
-                        .padding(.horizontal, 10)
-                        .padding(.vertical, 4)
-                        .background(AppTheme.accentTeal)
-                        .clipShape(Capsule())
-                        .padding(.top, metrics.cardPadding * 0.65)
-                        .padding(.trailing, metrics.cardPadding * 0.65)
+                if let trial {
+                    cardBadge(trial.badgeText)
+                } else if let badge = planCopy.badge, plan == .lifetime {
+                    cardBadge(badge)
                 }
             }
         }
         .buttonStyle(AppPlainButtonStyle())
         .hoverCard(cornerRadius: metrics.cornerRadius, isSelected: isSelected)
     }
+
+    private func cardBadge(_ text: String) -> some View {
+        Text(text)
+            .font(.system(size: metrics.featureFontSize * 0.75, weight: .bold))
+            .foregroundStyle(Color(hex: 0x0B0E14))
+            .padding(.horizontal, 10)
+            .padding(.vertical, 4)
+            .background(AppTheme.accentTeal)
+            .clipShape(Capsule())
+            .padding(.top, metrics.cardPadding * 0.65)
+            .padding(.trailing, metrics.cardPadding * 0.65)
+    }
 }
 
 #Preview {

+ 7 - 1
App AI for Reddit/paywall.json

@@ -54,6 +54,7 @@
     "yearly": {
       "title": "Yearly",
       "subtitleTemplate": "{price} / year, cancel anytime",
+      "trialSubtitleTemplate": "{trial} free, then {price} / year",
       "ctaTemplate": "Get Premium for {price} / Year",
       "fallbackPrice": "$49.99",
       "priceSuffix": "/yr",
@@ -94,8 +95,13 @@
     "manageSubscriptions": "https://apps.apple.com/account/subscriptions"
   },
   "trial": {
+    "eligiblePlan": "yearly",
     "badgeTemplate": "{duration} Free Trial",
-    "ctaTemplate": "Start {duration} Free Trial"
+    "ctaTemplate": "Start {duration} Free Trial",
+    "fallbackDuration": {
+      "count": 3,
+      "unit": "day"
+    }
   },
   "duration": {
     "units": {