Forráskód Böngészése

Align paywall and StoreKit with Smart Printer patterns.

Add JSON-driven paywall config with remote refresh, cached entitlements, intro-offer support, and improved product-load retry and subscription UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 3 hete
szülő
commit
4423f898f1

+ 4 - 0
Reddit App/App/RedditAppApp.swift

@@ -20,6 +20,10 @@ struct RedditAppApp: App {
                 .background {
                     MainWindowLifecycleConfigurator()
                 }
+                .task {
+                    subscriptions.start()
+                    PaywallConfigService.shared.start()
+                }
         }
         .defaultSize(width: AppWindow.width, height: AppWindow.height)
         .defaultPosition(.center)

+ 84 - 3
Reddit App/Managers/SubscriptionManager.swift

@@ -48,6 +48,9 @@ final class SubscriptionManager: ObservableObject {
         case timeout
     }
 
+    private static let cachedPremiumKey = "SubscriptionManager.cachedIsPremium"
+    private static let cachedPremiumPlanKey = "SubscriptionManager.cachedPremiumPlan"
+
     @Published private(set) var productsByID: [String: Product] = [:]
     @Published private(set) var isLoadingProducts = false
     @Published private(set) var purchasingPlan: PaywallPlan?
@@ -55,22 +58,66 @@ final class SubscriptionManager: ObservableObject {
     @Published private(set) var hasPremiumAccess = false
     @Published private(set) var hasResolvedPremiumStatus = false
     @Published private(set) var activePremiumPlan: PaywallPlan?
+    @Published private(set) var productLoadError: String?
+    @Published private(set) var introOfferEligibleByProductID: [String: Bool] = [:]
     @Published var purchaseError: PurchaseError?
 
+    var isResolvingEntitlements: Bool {
+        !hasResolvedPremiumStatus
+    }
+
     var hasLifetimeAccess: Bool {
         activePremiumPlan == .lifetime
     }
 
+    var hasActiveSubscription: Bool {
+        activePremiumPlan == .monthly || activePremiumPlan == .yearly
+    }
+
     var hasAllProductsLoaded: Bool {
         SubscriptionProductID.all.allSatisfy { productsByID[$0] != nil }
     }
 
     private var inFlightProductLoad: Task<Void, Never>?
+    private var initialEntitlementTask: Task<Void, Never>?
+    private var hasStarted = false
 
     init() {
+        hasPremiumAccess = UserDefaults.standard.bool(forKey: Self.cachedPremiumKey)
+        if let raw = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey),
+           let plan = PaywallPlan(rawValue: raw) {
+            activePremiumPlan = plan
+        }
+    }
+
+    func start() {
+        guard !hasStarted else { return }
+        hasStarted = true
+
         Task { await listenForTransactionUpdates() }
-        Task { await refreshPremiumAccess() }
-        Task { await loadProducts(presentLoadingUI: false) }
+
+        initialEntitlementTask = Task {
+            await refreshPremiumAccess()
+            await loadProducts(presentLoadingUI: false)
+        }
+    }
+
+    func ensureEntitlementsResolved() async {
+        await initialEntitlementTask?.value
+    }
+
+    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(presentLoadingUI: Bool? = nil) async {
@@ -190,7 +237,8 @@ final class SubscriptionManager: ObservableObject {
     }
 
     func openSubscriptionManagement() {
-        guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
+        let urlString = PaywallConfigService.shared.config.urls.manageSubscriptions
+        guard let url = URL(string: urlString) else { return }
         NSWorkspace.shared.open(url)
     }
 
@@ -235,17 +283,27 @@ final class SubscriptionManager: ObservableObject {
         }
 
         purchaseError = nil
+        productLoadError = nil
         do {
             let loaded = try await loadProductsWithTimeout(seconds: 12)
+            guard !loaded.isEmpty else {
+                productLoadError = PaywallConfigService.shared.config.messages.noPlansAvailable
+                productsByID = [:]
+                return
+            }
             var map: [String: Product] = [:]
             for product in loaded {
                 map[product.id] = product
             }
             productsByID = map
+            productLoadError = nil
+            await refreshIntroOfferEligibility()
             await refreshPremiumAccess()
         } catch is ProductLoadError {
+            productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
             purchaseError = .productsLoadTimeout
         } catch {
+            productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
             purchaseError = .generic
         }
     }
@@ -294,6 +352,7 @@ final class SubscriptionManager: ObservableObject {
         activePremiumPlan = finalPlan
         hasPremiumAccess = finalPlan != nil
         hasResolvedPremiumStatus = true
+        persistPremiumCache()
     }
 
     /// Re-reads entitlements from StoreKit, then syncs with the App Store when needed.
@@ -337,6 +396,7 @@ final class SubscriptionManager: ObservableObject {
         activePremiumPlan = preferredPlan(activePremiumPlan, plan)
         hasPremiumAccess = true
         hasResolvedPremiumStatus = true
+        persistPremiumCache()
     }
 
     private func syncAppStoreWithTimeout(seconds: TimeInterval) async throws {
@@ -360,6 +420,27 @@ final class SubscriptionManager: ObservableObject {
         activePremiumPlan = preferredPlan(activePremiumPlan, plan)
         hasPremiumAccess = true
         hasResolvedPremiumStatus = true
+        persistPremiumCache()
+    }
+
+    private func persistPremiumCache() {
+        UserDefaults.standard.set(hasPremiumAccess, forKey: Self.cachedPremiumKey)
+        UserDefaults.standard.set(activePremiumPlan?.rawValue, forKey: Self.cachedPremiumPlanKey)
+    }
+
+    private func refreshIntroOfferEligibility() async {
+        var eligibility: [String: Bool] = [:]
+
+        for product in productsByID.values {
+            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 planGrantingPremium(from transaction: StoreKit.Transaction) -> PaywallPlan? {

+ 224 - 0
Reddit App/Models/PaywallConfig.swift

@@ -0,0 +1,224 @@
+import Foundation
+import StoreKit
+
+struct PaywallConfig: Codable, Sendable {
+    struct PlanCopy: Codable, Sendable {
+        let title: String
+        let badge: String?
+        let subtitleTemplate: String
+        let trialSubtitleTemplate: String?
+        let ctaTemplate: String
+
+        init(
+            title: String,
+            badge: String? = nil,
+            subtitleTemplate: String,
+            trialSubtitleTemplate: String? = nil,
+            ctaTemplate: String
+        ) {
+            self.title = title
+            self.badge = badge
+            self.subtitleTemplate = subtitleTemplate
+            self.trialSubtitleTemplate = trialSubtitleTemplate
+            self.ctaTemplate = ctaTemplate
+        }
+    }
+
+    struct TrustItem: Codable, Sendable {
+        let icon: String
+        let title: String
+        let subtitle: String
+    }
+
+    struct Footer: Codable, Sendable {
+        let continueFree: String
+        let manageSubscription: String
+        let restorePurchase: String
+        let privacyPolicy: String
+        let termsOfService: String
+        let support: String
+    }
+
+    struct URLs: Codable, Sendable {
+        let privacy: String
+        let terms: String
+        let support: String
+        let manageSubscriptions: String
+    }
+
+    struct Plans: Codable, Sendable {
+        let monthly: PlanCopy
+        let yearly: 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 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
+    let trialDisclosureTemplate: String
+    let lifetimeDisclosure: String
+    let loadingDisclosure: String
+}
+
+extension PaywallConfig {
+    func mergingMarketing(from remote: PaywallConfig) -> PaywallConfig {
+        PaywallConfig(
+            leftPanelTitle: remote.leftPanelTitle,
+            rightTitle: remote.rightTitle,
+            rightSubtitle: remote.rightSubtitle,
+            features: remote.features,
+            trustItems: remote.trustItems,
+            lifetimeTrustItem: remote.lifetimeTrustItem,
+            plans: remote.plans,
+            footer: footer,
+            urls: urls,
+            trial: trial,
+            duration: duration,
+            messages: messages,
+            sidebar: remote.sidebar,
+            loadingPrice: loadingPrice,
+            loadingCTA: loadingCTA,
+            subscriptionDisclosureTemplate: subscriptionDisclosureTemplate,
+            trialDisclosureTemplate: trialDisclosureTemplate,
+            lifetimeDisclosure: lifetimeDisclosure,
+            loadingDisclosure: loadingDisclosure
+        )
+    }
+
+    static func loadBundled() -> PaywallConfig {
+        guard let url = Bundle.main.url(forResource: "paywall", withExtension: "json"),
+              let data = try? Data(contentsOf: url),
+              let config = try? JSONDecoder().decode(PaywallConfig.self, from: data) else {
+            fatalError("Missing or invalid paywall.json in app bundle.")
+        }
+        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 PaywallConfig: Equatable {
+    static func == (lhs: PaywallConfig, rhs: PaywallConfig) -> Bool {
+        guard let lhsData = try? JSONEncoder().encode(lhs),
+              let rhsData = try? JSONEncoder().encode(rhs) else {
+            return false
+        }
+        return lhsData == rhsData
+    }
+}
+
+extension Notification.Name {
+    static let paywallConfigDidUpdate = Notification.Name("paywallConfigDidUpdate")
+}

+ 77 - 0
Reddit App/Models/PaywallModels.swift

@@ -1,4 +1,5 @@
 import Foundation
+import StoreKit
 
 enum PaywallPlan: String, CaseIterable, Identifiable {
     case monthly
@@ -79,6 +80,82 @@ enum PaywallPlan: String, CaseIterable, Identifiable {
             "Comment Writer with smart replies",
         ]
     }
+
+    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 ?? fallbackMainPrice
+    }
+
+    func localizedSubtitle(
+        from product: Product?,
+        config: PaywallConfig,
+        introOffer: Product.SubscriptionOffer? = nil
+    ) -> String {
+        guard let product else { return config.loadingPrice }
+        let copy = planCopy(from: config)
+        let price = product.displayPrice
+
+        if self == .yearly, let introOffer, let trialTemplate = copy.trialSubtitleTemplate {
+            let duration = config.trialDurationDescription(from: introOffer, lowercase: true)
+            return trialTemplate
+                .replacingOccurrences(of: "{trial}", with: duration)
+                .replacingOccurrences(of: "{price}", with: price)
+        }
+
+        return copy.subtitleTemplate.replacingOccurrences(of: "{price}", with: price)
+    }
+
+    func localizedCTATitle(
+        from product: Product?,
+        config: PaywallConfig,
+        introOffer: Product.SubscriptionOffer? = nil
+    ) -> String {
+        guard let product else { return config.loadingCTA }
+        let copy = planCopy(from: config)
+        let price = product.displayPrice
+
+        if self == .yearly, let introOffer {
+            return config.trialCTATitle(from: introOffer)
+        }
+
+        return copy.ctaTemplate.replacingOccurrences(of: "{price}", with: price)
+    }
+
+    func localizedRenewalDisclosure(
+        from product: Product?,
+        config: PaywallConfig,
+        introOffer: Product.SubscriptionOffer? = 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 self == .yearly, let introOffer {
+                let trial = config.trialDurationDescription(from: introOffer, lowercase: true)
+                return config.trialDisclosureTemplate
+                    .replacingOccurrences(of: "{trial}", with: trial)
+                    .replacingOccurrences(of: "{price}", with: price)
+                    .replacingOccurrences(of: "{period}", with: period)
+            }
+
+            return config.subscriptionDisclosureTemplate
+                .replacingOccurrences(of: "{price}", with: price)
+                .replacingOccurrences(of: "{period}", with: period)
+        }
+    }
 }
 
 struct PaywallFeature: Identifiable {

+ 6 - 1
Reddit App/Paywall.storekit

@@ -119,7 +119,12 @@
           "familyShareable" : false,
           "groupNumber" : 2,
           "internalID" : "6748291003",
-          "introductoryOffer" : null,
+          "introductoryOffer" : {
+            "internalID" : "6748291004",
+            "numberOfPeriods" : 1,
+            "paymentMode" : "free",
+            "subscriptionPeriod" : "P1W"
+          },
           "localizations" : [
             {
               "description" : "Yearly access to all Reddora Pro features.",

+ 51 - 0
Reddit App/Services/PaywallConfigService.swift

@@ -0,0 +1,51 @@
+import Combine
+import Foundation
+
+@MainActor
+final class PaywallConfigService: ObservableObject {
+    static let shared = PaywallConfigService()
+
+    private static let remoteConfigURL = URL(
+        string: "https://git.mqldevelopment.com/MQL-Development/app_for_reddit/-/raw/main/Reddit%20App/paywall.json"
+    )!
+
+    @Published private(set) var config: PaywallConfig
+    private var hasStarted = false
+
+    private init() {
+        config = PaywallConfig.loadBundled()
+    }
+
+    func start() {
+        guard !hasStarted else { return }
+        hasStarted = true
+        Task { await refreshFromRemote() }
+    }
+
+    func refreshFromRemote() async {
+        var request = URLRequest(url: Self.remoteConfigURL)
+        request.cachePolicy = .reloadIgnoringLocalCacheData
+        request.timeoutInterval = 15
+
+        do {
+            let (data, response) = try await URLSession.shared.data(for: request)
+            guard let http = response as? HTTPURLResponse, (200 ... 299).contains(http.statusCode) else {
+                return
+            }
+            let remoteConfig = try JSONDecoder().decode(PaywallConfig.self, from: data)
+            apply(remoteConfig)
+        } catch {
+            #if DEBUG
+            print("Failed to refresh paywall config: \(error.localizedDescription)")
+            #endif
+        }
+    }
+
+    private func apply(_ remoteConfig: PaywallConfig) {
+        let bundledConfig = PaywallConfig.loadBundled()
+        let mergedConfig = bundledConfig.mergingMarketing(from: remoteConfig)
+        guard mergedConfig != config else { return }
+        config = mergedConfig
+        NotificationCenter.default.post(name: .paywallConfigDidUpdate, object: nil)
+    }
+}

+ 5 - 0
Reddit App/Views/FrontPageView.swift

@@ -33,6 +33,10 @@ struct FrontPageView: View {
             }
             .fullScreenLayoutWhenNeeded()
             .environment(\.requirePremiumAccess, {
+                if subscriptions.isResolvingEntitlements {
+                    if subscriptions.hasPremiumAccess { return true }
+                    return false
+                }
                 if subscriptions.hasPremiumAccess { return true }
                 viewModel.showPaywall()
                 return false
@@ -184,6 +188,7 @@ struct FrontPageView: View {
     }
 
     private func configureRedditAccessForPremiumStatus() {
+        guard subscriptions.hasResolvedPremiumStatus else { return }
         if !subscriptions.hasPremiumAccess, viewModel.isRedditActive {
             viewModel.isRedditActive = false
             viewModel.selectNavItem(FrontPageViewModel.postGeneratorItem)

+ 138 - 54
Reddit App/Views/PaywallView.swift

@@ -1,12 +1,16 @@
 import AppKit
+import StoreKit
 import SwiftUI
 
 struct PaywallView: View {
     @EnvironmentObject private var subscriptions: SubscriptionManager
     @EnvironmentObject private var appRating: AppRatingManager
+    @ObservedObject private var paywallConfigService = PaywallConfigService.shared
     @Bindable var viewModel: PaywallViewModel
     var onDismiss: () -> Void
 
+    private var config: PaywallConfig { paywallConfigService.config }
+
     private var isPurchasingSelectedPlan: Bool {
         subscriptions.purchasingPlan == viewModel.selectedPlan
     }
@@ -19,6 +23,14 @@ struct PaywallView: View {
         subscriptions.hasPremiumAccess
     }
 
+    private var hasProductLoadFailure: Bool {
+        subscriptions.productLoadError != nil && !subscriptions.hasAllProductsLoaded
+    }
+
+    private var isProductReady: Bool {
+        subscriptions.product(for: viewModel.selectedPlan) != nil
+    }
+
     var body: some View {
         GeometryReader { geometry in
             let metrics = PaywallLayoutMetrics(size: geometry.size)
@@ -30,7 +42,13 @@ struct PaywallView: View {
                 VStack(spacing: metrics.sectionSpacing) {
                     header(metrics: metrics)
                     featuresSection(metrics: metrics)
-                    pricingCards(metrics: metrics)
+
+                    if hasProductLoadFailure {
+                        productsErrorSection(metrics: metrics)
+                    } else {
+                        pricingCards(metrics: metrics)
+                    }
+
                     ctaSection(metrics: metrics)
                     Spacer(minLength: 0)
                     footerLinks(metrics: metrics)
@@ -40,7 +58,7 @@ struct PaywallView: View {
                 .padding(.bottom, metrics.bottomInset)
                 .frame(width: geometry.size.width, height: geometry.size.height, alignment: .top)
 
-                if subscriptions.isLoadingProducts && !subscriptions.hasAllProductsLoaded {
+                if subscriptions.isLoadingProducts && !subscriptions.hasAllProductsLoaded && !hasProductLoadFailure {
                     AppTheme.scrim
                         .ignoresSafeArea()
                         .allowsHitTesting(false)
@@ -56,10 +74,15 @@ struct PaywallView: View {
         .frame(maxWidth: .infinity, maxHeight: .infinity)
         .ignoresSafeArea()
         .task {
-            await subscriptions.loadProducts()
+            await subscriptions.ensureEntitlementsResolved()
+            if subscriptions.productsByID.isEmpty, !subscriptions.isLoadingProducts {
+                await subscriptions.loadProducts()
+            }
+            await paywallConfigService.refreshFromRemote()
         }
+        .onReceive(NotificationCenter.default.publisher(for: .paywallConfigDidUpdate)) { _ in }
         .alert(
-            "Subscriptions",
+            config.messages.purchaseFailedTitle,
             isPresented: Binding(
                 get: { subscriptions.purchaseError != nil },
                 set: { presented in
@@ -67,7 +90,7 @@ struct PaywallView: View {
                 }
             )
         ) {
-            Button("OK", role: .cancel) {}
+            Button(config.messages.alertOK, role: .cancel) {}
         } message: {
             Text(subscriptions.purchaseError?.message ?? "")
         }
@@ -102,11 +125,11 @@ struct PaywallView: View {
 
     private func header(metrics: PaywallLayoutMetrics) -> some View {
         VStack(spacing: metrics.headerSpacing) {
-            Text(PaywallContent.title)
+            Text(config.rightTitle)
                 .font(.system(size: metrics.titleFontSize, weight: .bold))
                 .foregroundStyle(AppTheme.textPrimary)
 
-            Text(PaywallContent.subtitle)
+            Text(config.rightSubtitle)
                 .font(.system(size: metrics.subtitleFontSize))
                 .foregroundStyle(AppTheme.textSecondary)
                 .multilineTextAlignment(.center)
@@ -117,26 +140,45 @@ struct PaywallView: View {
 
     private func featuresSection(metrics: PaywallLayoutMetrics) -> some View {
         VStack(alignment: .leading, spacing: metrics.featureSectionSpacing) {
-            Text(PaywallContent.includedSectionTitle)
+            Text(config.leftPanelTitle)
                 .font(.system(size: metrics.sectionTitleFontSize, weight: .semibold))
                 .foregroundStyle(AppTheme.textPrimary)
 
             LazyVGrid(columns: metrics.featureColumns, alignment: .leading, spacing: metrics.featureRowSpacing) {
-                ForEach(PaywallContent.features) { feature in
-                    PaywallFeatureRow(feature: feature, metrics: metrics)
+                ForEach(Array(config.features.enumerated()), id: \.offset) { _, feature in
+                    PaywallFeatureRow(title: feature, metrics: metrics)
                 }
             }
         }
         .frame(maxWidth: .infinity, alignment: .leading)
     }
 
+    private func productsErrorSection(metrics: PaywallLayoutMetrics) -> some View {
+        VStack(spacing: metrics.ctaSpacing) {
+            Text(subscriptions.productLoadError ?? config.messages.plansLoadFailed)
+                .font(.system(size: metrics.footerNoteFontSize))
+                .foregroundStyle(AppTheme.textSecondary)
+                .multilineTextAlignment(.center)
+
+            Button(subscriptions.isLoadingProducts ? config.messages.retrying : config.messages.retry) {
+                Task { await subscriptions.loadProducts(presentLoadingUI: true) }
+            }
+            .buttonStyle(AppTextLinkButtonStyle())
+            .font(.system(size: metrics.footerLinkFontSize))
+            .foregroundStyle(AppTheme.textSecondary)
+            .disabled(subscriptions.isLoadingProducts)
+        }
+        .frame(maxWidth: .infinity, minHeight: metrics.cardMinHeight)
+    }
+
     private func pricingCards(metrics: PaywallLayoutMetrics) -> some View {
         HStack(alignment: .top, spacing: metrics.cardSpacing) {
             ForEach(PaywallPlan.allCases) { plan in
                 PaywallPricingCard(
                     plan: plan,
-                    price: subscriptions.mainPrice(for: plan),
-                    priceSuffix: subscriptions.priceSuffix(for: plan),
+                    config: config,
+                    product: subscriptions.product(for: plan),
+                    introOffer: subscriptions.eligibleIntroOffer(for: plan),
                     isSelected: viewModel.selectedPlan == plan,
                     metrics: metrics
                 ) {
@@ -149,11 +191,21 @@ struct PaywallView: View {
     }
 
     private func ctaSection(metrics: PaywallLayoutMetrics) -> some View {
-        VStack(spacing: metrics.ctaSpacing) {
-            Text(subscriptions.billingDescription(for: viewModel.selectedPlan))
-                .font(.system(size: metrics.footerNoteFontSize))
-                .foregroundStyle(AppTheme.textTertiary)
-                .multilineTextAlignment(.center)
+        let selectedProduct = subscriptions.product(for: viewModel.selectedPlan)
+        let introOffer = subscriptions.eligibleIntroOffer(for: viewModel.selectedPlan)
+        let ctaDisabled = buttonsBusy || subscriptions.isLoadingProducts || !isProductReady || hasProductLoadFailure
+
+        return VStack(spacing: metrics.ctaSpacing) {
+            Text(
+                viewModel.selectedPlan.localizedRenewalDisclosure(
+                    from: selectedProduct,
+                    config: config,
+                    introOffer: introOffer
+                )
+            )
+            .font(.system(size: metrics.footerNoteFontSize))
+            .foregroundStyle(AppTheme.textTertiary)
+            .multilineTextAlignment(.center)
 
             Button {
                 Task {
@@ -170,9 +222,15 @@ struct PaywallView: View {
                             .controlSize(.small)
                             .tint(Color(hex: 0x0B0E14))
                     }
-                    Text(viewModel.selectedPlan.ctaTitle)
-                        .font(.system(size: metrics.ctaFontSize, weight: .bold))
-                        .foregroundStyle(Color(hex: 0x0B0E14))
+                    Text(
+                        viewModel.selectedPlan.localizedCTATitle(
+                            from: selectedProduct,
+                            config: config,
+                            introOffer: introOffer
+                        )
+                    )
+                    .font(.system(size: metrics.ctaFontSize, weight: .bold))
+                    .foregroundStyle(Color(hex: 0x0B0E14))
                 }
                 .frame(maxWidth: .infinity)
                 .padding(.vertical, metrics.ctaVerticalPadding)
@@ -186,8 +244,8 @@ struct PaywallView: View {
                 .clipShape(RoundedRectangle(cornerRadius: metrics.cornerRadius))
             }
             .buttonStyle(AppPrimaryButtonStyle())
-            .disabled(buttonsBusy || subscriptions.isLoadingProducts)
-            .opacity((buttonsBusy || subscriptions.isLoadingProducts) ? 0.7 : 1)
+            .disabled(ctaDisabled)
+            .opacity(ctaDisabled ? 0.7 : 1)
 
             HStack(spacing: 6) {
                 Image(systemName: "lock.fill")
@@ -201,21 +259,22 @@ struct PaywallView: View {
 
     private func footerLinks(metrics: PaywallLayoutMetrics) -> some View {
         HStack(spacing: 0) {
-            footerLink(
-                isPremium ? "Manage Subscription" : PaywallContent.continueWithFreePlan,
-                metrics: metrics
-            ) {
-                if isPremium {
-                    subscriptions.openSubscriptionManagement()
-                } else {
+            if !isPremium {
+                footerLink(config.footer.continueFree, metrics: metrics) {
                     onDismiss()
                 }
-            }
 
-            Spacer(minLength: metrics.footerLinkSpacing)
+                Spacer(minLength: metrics.footerLinkSpacing)
+            } else if subscriptions.hasActiveSubscription {
+                footerLink(config.footer.manageSubscription, metrics: metrics) {
+                    subscriptions.openSubscriptionManagement()
+                }
+
+                Spacer(minLength: metrics.footerLinkSpacing)
+            }
 
             footerLink(
-                subscriptions.isRestoringPurchases ? "Restoring..." : PaywallContent.restorePurchases,
+                subscriptions.isRestoringPurchases ? config.messages.retrying : config.footer.restorePurchase,
                 metrics: metrics
             ) {
                 Task {
@@ -229,26 +288,31 @@ struct PaywallView: View {
 
             Spacer(minLength: metrics.footerLinkSpacing)
 
-            footerLink(PaywallContent.privacyPolicy, metrics: metrics) {
-                NSWorkspace.shared.open(AppLinks.privacy)
+            footerLink(config.footer.privacyPolicy, metrics: metrics) {
+                openExternalLink(config.urls.privacy)
             }
 
             Spacer(minLength: metrics.footerLinkSpacing)
 
-            footerLink(PaywallContent.support, metrics: metrics) {
-                NSWorkspace.shared.open(AppLinks.support)
+            footerLink(config.footer.support, metrics: metrics) {
+                openExternalLink(config.urls.support)
             }
 
             Spacer(minLength: metrics.footerLinkSpacing)
 
-            footerLink(PaywallContent.termsOfService, metrics: metrics) {
-                NSWorkspace.shared.open(AppLinks.terms)
+            footerLink(config.footer.termsOfService, metrics: metrics) {
+                openExternalLink(config.urls.terms)
             }
         }
         .padding(.horizontal, metrics.footerLinksHorizontalInset)
         .frame(maxWidth: .infinity)
     }
 
+    private func openExternalLink(_ urlString: String) {
+        guard let url = URL(string: urlString) else { return }
+        NSWorkspace.shared.open(url)
+    }
+
     private func footerLink(_ title: String, metrics: PaywallLayoutMetrics, action: @escaping () -> Void) -> some View {
         Button(title, action: action)
             .buttonStyle(AppTextLinkButtonStyle())
@@ -309,17 +373,17 @@ private struct PaywallLayoutMetrics {
 // MARK: - Components
 
 private struct PaywallFeatureRow: View {
-    let feature: PaywallFeature
+    let title: String
     let metrics: PaywallLayoutMetrics
 
     var body: some View {
         HStack(spacing: 8) {
-            Image(systemName: feature.systemImage)
-                .font(.system(size: metrics.featureFontSize * 0.9, weight: .semibold))
-                .foregroundStyle(AppTheme.accentTeal)
+            Image(systemName: "checkmark")
+                .font(.system(size: metrics.featureFontSize * 0.75, weight: .bold))
+                .foregroundStyle(AppTheme.accentGreen)
                 .frame(width: metrics.featureFontSize + 4)
 
-            Text(feature.title)
+            Text(title)
                 .font(.system(size: metrics.featureFontSize))
                 .foregroundStyle(AppTheme.textSecondary)
                 .lineLimit(1)
@@ -330,12 +394,17 @@ private struct PaywallFeatureRow: View {
 
 private struct PaywallPricingCard: View {
     let plan: PaywallPlan
-    let price: String
-    let priceSuffix: String?
+    let config: PaywallConfig
+    let product: Product?
+    let introOffer: Product.SubscriptionOffer?
     let isSelected: Bool
     let metrics: PaywallLayoutMetrics
     let onSelect: () -> Void
 
+    private var planCopy: PaywallConfig.PlanCopy {
+        plan.planCopy(from: config)
+    }
+
     var body: some View {
         Button(action: onSelect) {
             VStack(alignment: .leading, spacing: 0) {
@@ -343,23 +412,23 @@ private struct PaywallPricingCard: View {
                     .frame(height: metrics.cardPadding * 1.0)
                     .padding(.bottom, metrics.cardPadding * 0.15)
 
-                Text(plan.title)
+                Text(planCopy.title)
                     .font(.system(size: metrics.ctaFontSize, weight: .semibold))
                     .foregroundStyle(AppTheme.textPrimary)
 
-                Text(plan.subtitle)
+                Text(plan.localizedSubtitle(from: product, config: config, introOffer: introOffer))
                     .font(.system(size: metrics.featureFontSize * 0.85))
                     .foregroundStyle(AppTheme.textTertiary)
                     .fixedSize(horizontal: false, vertical: true)
                     .padding(.top, 2)
 
                 HStack(alignment: .firstTextBaseline, spacing: 2) {
-                    Text(price)
+                    Text(plan.localizedPrice(from: product, config: config))
                         .font(.system(size: metrics.titleFontSize * 0.85, weight: .bold))
                         .foregroundStyle(AppTheme.textPrimary)
 
-                    if let suffix = priceSuffix {
-                        Text(suffix)
+                    if plan.priceSuffix != nil {
+                        Text(plan.priceSuffix ?? "")
                             .font(.system(size: metrics.featureFontSize))
                             .foregroundStyle(AppTheme.textSecondary)
                     }
@@ -400,8 +469,8 @@ private struct PaywallPricingCard: View {
                     )
             )
             .overlay(alignment: .topTrailing) {
-                if plan.isBestValue {
-                    Text("BEST VALUE")
+                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)
@@ -410,6 +479,16 @@ private struct PaywallPricingCard: View {
                         .clipShape(Capsule())
                         .padding(.top, metrics.cardPadding * 0.65)
                         .padding(.trailing, metrics.cardPadding * 0.65)
+                } else if plan == .yearly, let introOffer {
+                    Text(config.trialBadgeText(from: introOffer))
+                        .font(.system(size: metrics.featureFontSize * 0.75, weight: .bold))
+                        .foregroundStyle(Color(hex: 0x0B0E14))
+                        .padding(.horizontal, 10)
+                        .padding(.vertical, 4)
+                        .background(AppTheme.accentPurpleLight)
+                        .clipShape(Capsule())
+                        .padding(.top, metrics.cardPadding * 0.65)
+                        .padding(.trailing, metrics.cardPadding * 0.65)
                 }
             }
         }
@@ -419,8 +498,13 @@ private struct PaywallPricingCard: View {
 }
 
 #Preview {
-    PaywallView(viewModel: PaywallViewModel(), onDismiss: {})
-        .environmentObject(SubscriptionManager())
+    let subscriptions = SubscriptionManager()
+    return PaywallView(viewModel: PaywallViewModel(), onDismiss: {})
+        .environmentObject(subscriptions)
         .environmentObject(AppRatingManager())
         .frame(width: AppWindow.width, height: AppWindow.height)
+        .onAppear {
+            subscriptions.start()
+            PaywallConfigService.shared.start()
+        }
 }

+ 23 - 9
Reddit App/Views/SidebarView.swift

@@ -3,9 +3,14 @@ import SwiftUI
 struct SidebarView: View {
     @Environment(\.requirePremiumAccess) private var requirePremiumAccess
     @EnvironmentObject private var subscriptions: SubscriptionManager
+    @ObservedObject private var paywallConfigService = PaywallConfigService.shared
     @Bindable var viewModel: FrontPageViewModel
     var isPremium: Bool
 
+    private var sidebarConfig: PaywallConfig.Sidebar {
+        paywallConfigService.config.sidebar
+    }
+
     var body: some View {
         VStack(spacing: 0) {
             header
@@ -107,17 +112,13 @@ struct SidebarView: View {
                     Image(systemName: "crown.fill")
                         .font(.system(size: 14))
                         .foregroundStyle(AppTheme.accentYellow)
-                    Text(isPremium ? "Reddora Premium" : "Upgrade to Premium")
+                    Text(isPremium ? sidebarConfig.proTitle : sidebarConfig.unlockTitle)
                         .font(.system(size: 12, weight: .semibold))
                         .foregroundStyle(AppTheme.textPrimary)
                 }
                 .frame(maxWidth: .infinity, alignment: .center)
 
-                Text(
-                    isPremium
-                        ? "You have unlimited AI generations and all premium features."
-                        : "Unlock unlimited AI generations and advanced analytics"
-                )
+                Text(isPremium ? sidebarConfig.proDescription : sidebarConfig.unlockDescription)
                     .font(.system(size: 10))
                     .foregroundStyle(AppTheme.textSecondary)
                     .multilineTextAlignment(.center)
@@ -125,9 +126,13 @@ struct SidebarView: View {
                     .fixedSize(horizontal: false, vertical: true)
                     .lineSpacing(2)
 
-                Button(isPremium ? "Manage Subscription" : "Upgrade Now") {
-                    if isPremium, subscriptions.hasLifetimeAccess {
-                        subscriptions.openSubscriptionManagement()
+                Button(premiumActionTitle) {
+                    if isPremium {
+                        if subscriptions.hasActiveSubscription {
+                            subscriptions.openSubscriptionManagement()
+                        } else {
+                            viewModel.showPaywall()
+                        }
                     } else {
                         viewModel.showPaywall()
                     }
@@ -156,4 +161,13 @@ struct SidebarView: View {
             .padding(.bottom, 20)
         }
     }
+
+    private var premiumActionTitle: String {
+        if isPremium {
+            return subscriptions.hasLifetimeAccess
+                ? sidebarConfig.lifetimeAction
+                : sidebarConfig.manageSubscriptionAction
+        }
+        return sidebarConfig.upgradeAction
+    }
 }

+ 117 - 0
Reddit App/paywall.json

@@ -0,0 +1,117 @@
+{
+  "leftPanelTitle": "Unlock Full Pro Power",
+  "rightTitle": "Go Premium",
+  "rightSubtitle": "Generate unlimited posts, optimize titles, and craft smarter replies with advanced AI.",
+  "features": [
+    "Unlimited AI Generations",
+    "Post Generator & Title Optimizer",
+    "Comment Writer with smart replies",
+    "Custom AI Prompts",
+    "Post Analytics",
+    "Priority support"
+  ],
+  "trustItems": [
+    {
+      "icon": "shield.fill",
+      "title": "Secure Payments",
+      "subtitle": "Your payment is 100% secure"
+    },
+    {
+      "icon": "arrow.counterclockwise",
+      "title": "Cancel Anytime",
+      "subtitle": "No commitment, cancel anytime."
+    },
+    {
+      "icon": "headphones",
+      "title": "24/7 Support",
+      "subtitle": "We're here to help you anytime."
+    },
+    {
+      "icon": "lock.fill",
+      "title": "Privacy First",
+      "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",
+      "subtitleTemplate": "{price} / month, cancel anytime",
+      "ctaTemplate": "Get Premium for {price} / Month"
+    },
+    "yearly": {
+      "title": "Yearly",
+      "subtitleTemplate": "{price} / year, cancel anytime",
+      "trialSubtitleTemplate": "Get {trial} free, then {price} / year",
+      "ctaTemplate": "Get Premium for {price} / Year"
+    },
+    "lifetime": {
+      "title": "Lifetime",
+      "badge": "BEST VALUE",
+      "subtitleTemplate": "{price} once, lifetime access",
+      "ctaTemplate": "Unlock Lifetime for {price}"
+    }
+  },
+  "footer": {
+    "continueFree": "Continue with free plan",
+    "manageSubscription": "Manage Subscription",
+    "restorePurchase": "Restore Purchases",
+    "privacyPolicy": "Privacy Policy",
+    "termsOfService": "Terms of Services",
+    "support": "Support"
+  },
+  "urls": {
+    "privacy": "https://sites.google.com/view/appforreddit/privacy-policy",
+    "terms": "https://sites.google.com/view/appforreddit/terms-and-condition",
+    "support": "https://sites.google.com/view/appforreddit/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": "Upgrade to Premium",
+    "proTitle": "Reddora Premium",
+    "unlockDescription": "Unlock unlimited AI generations and advanced analytics",
+    "proDescription": "You have unlimited AI generations 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.",
+  "trialDisclosureTemplate": "After your {trial} free trial, 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.",
+  "lifetimeDisclosure": "One-time purchase. No subscription or recurring charges.",
+  "loadingDisclosure": "Payment will be charged to your Apple ID. Subscription automatically renews unless canceled at least 24 hours before the end of the current period."
+}