Kaynağa Gözat

Add StoreKit subscription paywall and gate AI generation behind premium access.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 ay önce
ebeveyn
işleme
10e6f357c4

+ 82 - 0
Reddit App.xcodeproj/xcshareddata/xcschemes/Reddit App.xcscheme

@@ -0,0 +1,82 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+   LastUpgradeVersion = "2650"
+   version = "1.7">
+   <BuildAction
+      parallelizeBuildables = "YES"
+      buildImplicitDependencies = "YES"
+      buildArchitectures = "Automatic">
+      <BuildActionEntries>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "279679442FE5487000907C3B"
+               BuildableName = "Reddit App.app"
+               BlueprintName = "Reddit App"
+               ReferencedContainer = "container:Reddit App.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+      </BuildActionEntries>
+   </BuildAction>
+   <TestAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      shouldAutocreateTestPlan = "YES">
+   </TestAction>
+   <LaunchAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      launchStyle = "0"
+      useCustomWorkingDirectory = "NO"
+      ignoresPersistentStateOnLaunch = "NO"
+      debugDocumentVersioning = "YES"
+      debugServiceExtension = "internal"
+      allowLocationSimulation = "YES"
+      queueDebuggingEnableBacktraceRecording = "Yes">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "279679442FE5487000907C3B"
+            BuildableName = "Reddit App.app"
+            BlueprintName = "Reddit App"
+            ReferencedContainer = "container:Reddit App.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+      <StoreKitConfigurationFileReference
+         identifier = "../../Reddit App/Paywall.storekit">
+      </StoreKitConfigurationFileReference>
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Release"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "279679442FE5487000907C3B"
+            BuildableName = "Reddit App.app"
+            BlueprintName = "Reddit App"
+            ReferencedContainer = "container:Reddit App.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

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

@@ -1,10 +1,14 @@
+import Combine
 import SwiftUI
 
 @main
 struct RedditAppApp: App {
+    @StateObject private var subscriptions = SubscriptionManager()
+
     var body: some Scene {
         WindowGroup {
             FrontPageView()
+                .environmentObject(subscriptions)
         }
         .defaultSize(width: AppWindow.width, height: AppWindow.height)
         .windowStyle(.hiddenTitleBar)

+ 332 - 0
Reddit App/Managers/SubscriptionManager.swift

@@ -0,0 +1,332 @@
+import AppKit
+import Combine
+import Foundation
+import StoreKit
+
+enum PurchaseError: Equatable {
+    case productsLoadTimeout
+    case subscriptionUnavailable
+    case planUnavailable
+    case purchaseNotVerified
+    case purchasePending
+    case noActiveSubscriptions
+    case generic
+
+    var message: String {
+        switch self {
+        case .productsLoadTimeout:
+            "Couldn't load subscription products right now. Check your StoreKit configuration / internet and try again."
+        case .subscriptionUnavailable:
+            "This subscription is currently unavailable. Please try again in a moment."
+        case .planUnavailable:
+            "This plan isn't available right now. Please try again later."
+        case .purchaseNotVerified:
+            "Apple couldn't verify this purchase."
+        case .purchasePending:
+            "This purchase is waiting for approval (for example, Ask to Buy)."
+        case .noActiveSubscriptions:
+            "No active subscriptions were found for this Apple ID."
+        case .generic:
+            "Something went wrong with your subscription. Please try again."
+        }
+    }
+}
+
+@MainActor
+final class SubscriptionManager: ObservableObject {
+    private enum ProductLoadError: Error {
+        case timeout
+    }
+
+    @Published private(set) var productsByID: [String: Product] = [:]
+    @Published private(set) var isLoadingProducts = false
+    @Published private(set) var purchasingPlan: PaywallPlan?
+    @Published private(set) var hasPremiumAccess = false
+    @Published private(set) var hasResolvedPremiumStatus = false
+    @Published var purchaseError: PurchaseError?
+
+    var hasAllProductsLoaded: Bool {
+        SubscriptionProductID.all.allSatisfy { productsByID[$0] != nil }
+    }
+
+    private var inFlightProductLoad: Task<Void, Never>?
+
+    init() {
+        Task { await listenForTransactionUpdates() }
+        Task { await refreshPremiumAccess() }
+        Task { await loadProducts(presentLoadingUI: false) }
+    }
+
+    func loadProducts(presentLoadingUI: Bool? = nil) async {
+        if let existing = inFlightProductLoad {
+            await existing.value
+            return
+        }
+
+        let uiFlag = presentLoadingUI
+        let task = Task { @MainActor in
+            await performLoadProducts(presentLoadingUI: uiFlag)
+        }
+        inFlightProductLoad = task
+        await task.value
+        inFlightProductLoad = nil
+    }
+
+    func product(for plan: PaywallPlan) -> Product? {
+        productsByID[plan.productID]
+    }
+
+    func mainPrice(for plan: PaywallPlan) -> String {
+        product(for: plan)?.displayPrice ?? plan.fallbackMainPrice
+    }
+
+    func priceSuffix(for plan: PaywallPlan) -> String? {
+        switch plan {
+        case .monthly: "/mo"
+        case .yearly: "/yr"
+        case .lifetime: nil
+        }
+    }
+
+    func billingDescription(for plan: PaywallPlan) -> String {
+        guard let product = product(for: plan) else {
+            return plan.fallbackBillingDescription
+        }
+
+        if let subscription = product.subscription,
+           let intro = subscription.introductoryOffer,
+           intro.paymentMode == .freeTrial {
+            let trialPeriod = formattedPeriod(intro.period)
+            let billingPeriod = formattedSubscriptionPeriod(subscription.subscriptionPeriod)
+            return "\(trialPeriod) Free Trial, then \(product.displayPrice) per \(billingPeriod)"
+        }
+
+        switch plan {
+        case .monthly:
+            return "Billed at \(product.displayPrice) every month"
+        case .yearly:
+            return "Billed at \(product.displayPrice) every year"
+        case .lifetime:
+            return "One-time payment of \(product.displayPrice)"
+        }
+    }
+
+    func purchase(_ plan: PaywallPlan) async -> Bool {
+        purchaseError = nil
+
+        var product = productsByID[plan.productID]
+        if product == nil {
+            await loadProducts(presentLoadingUI: false)
+            product = productsByID[plan.productID]
+        }
+
+        guard let product else {
+            let missingProductIDs = SubscriptionProductID.all.filter { productsByID[$0] == nil }
+            if missingProductIDs.isEmpty {
+                purchaseError = .subscriptionUnavailable
+            } else {
+                purchaseError = .planUnavailable
+                #if DEBUG
+                print(
+                    """
+                    [SubscriptionManager] Missing StoreKit products: \(missingProductIDs.joined(separator: ", "))
+                    To test locally, attach `Paywall.storekit` in Xcode:
+                    Product > Scheme > Edit Scheme > Run > Options > StoreKit Configuration.
+                    """
+                )
+                #endif
+            }
+            return false
+        }
+
+        purchasingPlan = plan
+        defer { purchasingPlan = nil }
+
+        do {
+            let result = try await product.purchase()
+            switch result {
+            case .success(let verification):
+                guard case .verified(let transaction) = verification else {
+                    purchaseError = .purchaseNotVerified
+                    return false
+                }
+                await transaction.finish()
+                await refreshPremiumAccess()
+                return hasPremiumAccess
+            case .userCancelled:
+                return false
+            case .pending:
+                purchaseError = .purchasePending
+                return false
+            @unknown default:
+                return false
+            }
+        } catch {
+            purchaseError = .generic
+            return false
+        }
+    }
+
+    func openSubscriptionManagement() {
+        guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
+        NSWorkspace.shared.open(url)
+    }
+
+    func restorePurchases() async {
+        purchaseError = nil
+        do {
+            try await AppStore.sync()
+            await refreshPremiumAccess()
+            if !hasPremiumAccess {
+                purchaseError = .noActiveSubscriptions
+            }
+        } catch {
+            purchaseError = .generic
+        }
+    }
+
+    private func performLoadProducts(presentLoadingUI: Bool?) async {
+        let missingAny = SubscriptionProductID.all.contains { productsByID[$0] == nil }
+        let shouldShowLoading = presentLoadingUI ?? missingAny
+
+        if !missingAny, presentLoadingUI != true {
+            purchaseError = nil
+            await refreshPremiumAccess()
+            return
+        }
+
+        if shouldShowLoading {
+            isLoadingProducts = true
+        }
+        defer {
+            if shouldShowLoading {
+                isLoadingProducts = false
+            }
+        }
+
+        purchaseError = nil
+        do {
+            let loaded = try await loadProductsWithTimeout(seconds: 12)
+            var map: [String: Product] = [:]
+            for product in loaded {
+                map[product.id] = product
+            }
+            productsByID = map
+            await refreshPremiumAccess()
+        } catch is ProductLoadError {
+            purchaseError = .productsLoadTimeout
+        } catch {
+            purchaseError = .generic
+        }
+    }
+
+    private func loadProductsWithTimeout(seconds: TimeInterval) async throws -> [Product] {
+        try await withThrowingTaskGroup(of: [Product].self) { group in
+            group.addTask {
+                try await Product.products(for: SubscriptionProductID.all)
+            }
+            group.addTask {
+                let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
+                try await Task.sleep(nanoseconds: timeoutNanoseconds)
+                throw ProductLoadError.timeout
+            }
+
+            guard let first = try await group.next() else {
+                throw ProductLoadError.timeout
+            }
+            group.cancelAll()
+            return first
+        }
+    }
+
+    private func refreshPremiumAccess() async {
+        var hasPremium = false
+
+        for await result in Transaction.currentEntitlements {
+            guard case .verified(let transaction) = result else { continue }
+            guard SubscriptionProductID.all.contains(transaction.productID) else { continue }
+            guard transaction.revocationDate == nil else { continue }
+
+            if let expirationDate = transaction.expirationDate, expirationDate < Date() {
+                continue
+            }
+
+            hasPremium = true
+            break
+        }
+
+        if !hasPremium {
+            hasPremium = await hasActiveSubscriptionStatus()
+        }
+
+        hasPremiumAccess = hasPremium
+        hasResolvedPremiumStatus = true
+    }
+
+    private func hasActiveSubscriptionStatus() async -> Bool {
+        if productsByID.isEmpty {
+            do {
+                let loaded = try await Product.products(for: SubscriptionProductID.all)
+                var map: [String: Product] = [:]
+                for product in loaded { map[product.id] = product }
+                productsByID = map
+            } catch {
+                return false
+            }
+        }
+
+        for product in productsByID.values {
+            guard let subscription = product.subscription else { continue }
+            do {
+                let statuses = try await subscription.status
+                let isActive = statuses.contains { status in
+                    switch status.state {
+                    case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
+                        return true
+                    case .expired, .revoked:
+                        return false
+                    default:
+                        return false
+                    }
+                }
+                if isActive { return true }
+            } catch {
+                continue
+            }
+        }
+
+        return false
+    }
+
+    private func listenForTransactionUpdates() async {
+        for await update in Transaction.updates {
+            guard case .verified(let transaction) = update else { continue }
+            await transaction.finish()
+            await refreshPremiumAccess()
+        }
+    }
+
+    private func formattedPeriod(_ period: Product.SubscriptionPeriod) -> String {
+        switch period.unit {
+        case .day where period.value == 1: "1 Day"
+        case .day: "\(period.value) Days"
+        case .week where period.value == 1: "1 Week"
+        case .week: "\(period.value) Weeks"
+        case .month where period.value == 1: "1 Month"
+        case .month: "\(period.value) Months"
+        case .year where period.value == 1: "1 Year"
+        case .year: "\(period.value) Years"
+        @unknown default: "\(period.value) Days"
+        }
+    }
+
+    private func formattedSubscriptionPeriod(_ period: Product.SubscriptionPeriod) -> String {
+        switch period.unit {
+        case .day: period.value == 1 ? "day" : "\(period.value) days"
+        case .week: period.value == 1 ? "week" : "\(period.value) weeks"
+        case .month: period.value == 1 ? "month" : "\(period.value) months"
+        case .year: period.value == 1 ? "year" : "\(period.value) years"
+        @unknown default: "billing period"
+        }
+    }
+}

+ 30 - 1
Reddit App/Models/PaywallModels.swift

@@ -23,7 +23,15 @@ enum PaywallPlan: String, CaseIterable, Identifiable {
         }
     }
 
-    var price: String {
+    var productID: String {
+        switch self {
+        case .monthly: SubscriptionProductID.monthly
+        case .yearly: SubscriptionProductID.yearly
+        case .lifetime: SubscriptionProductID.lifetime
+        }
+    }
+
+    var fallbackMainPrice: String {
         switch self {
         case .monthly: "$7.99"
         case .yearly: "$49.99"
@@ -31,6 +39,8 @@ enum PaywallPlan: String, CaseIterable, Identifiable {
         }
     }
 
+    var price: String { fallbackMainPrice }
+
     var priceSuffix: String? {
         switch self {
         case .monthly: "/mo"
@@ -39,6 +49,25 @@ enum PaywallPlan: String, CaseIterable, Identifiable {
         }
     }
 
+    var ctaTitle: String {
+        switch self {
+        case .monthly: "Get Premium"
+        case .yearly: "Get Premium"
+        case .lifetime: "Unlock Lifetime"
+        }
+    }
+
+    var fallbackBillingDescription: String {
+        switch self {
+        case .monthly:
+            return "Billed at \(fallbackMainPrice) every month"
+        case .yearly:
+            return "Billed at \(fallbackMainPrice) every year"
+        case .lifetime:
+            return "One-time payment of \(fallbackMainPrice)"
+        }
+    }
+
     var isBestValue: Bool {
         self == .yearly
     }

+ 146 - 0
Reddit App/Paywall.storekit

@@ -0,0 +1,146 @@
+{
+  "identifier" : "Paywall",
+  "nonRenewingSubscriptions" : [
+
+  ],
+  "products" : [
+    {
+      "displayPrice" : "79.99",
+      "familyShareable" : false,
+      "internalID" : "6748291001",
+      "localizations" : [
+        {
+          "description" : "Unlock Reddora Pro forever with a single payment.",
+          "displayName" : "Lifetime",
+          "locale" : "en_US"
+        }
+      ],
+      "productID" : "com.Reddora.pro.lifetime",
+      "referenceName" : "Lifetime",
+      "type" : "NonConsumable"
+    }
+  ],
+  "settings" : {
+    "_failTransactionsEnabled" : false,
+    "_locale" : "en_US",
+    "_storefront" : "USA",
+    "_storeKitErrors" : [
+      {
+        "current" : null,
+        "enabled" : false,
+        "name" : "Load Products"
+      },
+      {
+        "current" : null,
+        "enabled" : false,
+        "name" : "Purchase"
+      },
+      {
+        "current" : null,
+        "enabled" : false,
+        "name" : "Verification"
+      },
+      {
+        "current" : null,
+        "enabled" : false,
+        "name" : "App Store Sync"
+      },
+      {
+        "current" : null,
+        "enabled" : false,
+        "name" : "Subscription Status"
+      },
+      {
+        "current" : null,
+        "enabled" : false,
+        "name" : "App Transaction"
+      },
+      {
+        "current" : null,
+        "enabled" : false,
+        "name" : "Manage Subscriptions Sheet"
+      },
+      {
+        "current" : null,
+        "enabled" : false,
+        "name" : "Refund Request Sheet"
+      },
+      {
+        "current" : null,
+        "enabled" : false,
+        "name" : "Offer Code Redeem Sheet"
+      }
+    ]
+  },
+  "subscriptionGroups" : [
+    {
+      "id" : "21643201",
+      "localizations" : [
+
+      ],
+      "name" : "Reddora Pro",
+      "subscriptions" : [
+        {
+          "adHocOffers" : [
+
+          ],
+          "codeOffers" : [
+
+          ],
+          "displayPrice" : "7.99",
+          "familyShareable" : false,
+          "groupNumber" : 1,
+          "internalID" : "6748291002",
+          "introductoryOffer" : null,
+          "localizations" : [
+            {
+              "description" : "Monthly access to all Reddora Pro features.",
+              "displayName" : "Monthly",
+              "locale" : "en_US"
+            }
+          ],
+          "productID" : "com.Reddora.pro.monthly",
+          "recurringSubscriptionPeriod" : "P1M",
+          "referenceName" : "Monthly",
+          "subscriptionGroupID" : "21643201",
+          "type" : "RecurringSubscription",
+          "winbackOffers" : [
+
+          ]
+        },
+        {
+          "adHocOffers" : [
+
+          ],
+          "codeOffers" : [
+
+          ],
+          "displayPrice" : "49.99",
+          "familyShareable" : false,
+          "groupNumber" : 2,
+          "internalID" : "6748291003",
+          "introductoryOffer" : null,
+          "localizations" : [
+            {
+              "description" : "Yearly access to all Reddora Pro features.",
+              "displayName" : "Yearly",
+              "locale" : "en_US"
+            }
+          ],
+          "productID" : "com.Reddora.pro.yearly",
+          "recurringSubscriptionPeriod" : "P1Y",
+          "referenceName" : "Yearly",
+          "subscriptionGroupID" : "21643201",
+          "type" : "RecurringSubscription",
+          "winbackOffers" : [
+
+          ]
+        }
+      ]
+    }
+  ],
+  "version" : {
+    "major" : 4,
+    "minor" : 0
+  }
+}

+ 13 - 0
Reddit App/Utilities/PremiumEnvironment.swift

@@ -0,0 +1,13 @@
+import SwiftUI
+
+private struct RequirePremiumAccessKey: EnvironmentKey {
+    static let defaultValue: () -> Bool = { true }
+}
+
+extension EnvironmentValues {
+    /// Returns `true` when the user has premium access. Otherwise presents the paywall and returns `false`.
+    var requirePremiumAccess: () -> Bool {
+        get { self[RequirePremiumAccessKey.self] }
+        set { self[RequirePremiumAccessKey.self] = newValue }
+    }
+}

+ 18 - 0
Reddit App/Utilities/SubscriptionProductID.swift

@@ -0,0 +1,18 @@
+import Foundation
+
+enum SubscriptionProductID {
+    static let monthly = "com.Reddora.pro.monthly"
+    static let yearly = "com.Reddora.pro.yearly"
+    static let lifetime = "com.Reddora.pro.lifetime"
+
+    static let all: Set<String> = [monthly, yearly, lifetime]
+
+    static func plan(for productID: String) -> PaywallPlan? {
+        switch productID {
+        case monthly: .monthly
+        case yearly: .yearly
+        case lifetime: .lifetime
+        default: nil
+        }
+    }
+}

+ 0 - 8
Reddit App/ViewModels/PaywallViewModel.swift

@@ -8,12 +8,4 @@ final class PaywallViewModel {
     func selectPlan(_ plan: PaywallPlan) {
         selectedPlan = plan
     }
-
-    func purchase() {
-        // StoreKit integration placeholder
-    }
-
-    func restorePurchases() {
-        // StoreKit integration placeholder
-    }
 }

+ 3 - 0
Reddit App/Views/CommentWriterView.swift

@@ -1,6 +1,7 @@
 import SwiftUI
 
 struct CommentWriterView: View {
+    @Environment(\.requirePremiumAccess) private var requirePremiumAccess
     @Bindable var viewModel: CommentWriterViewModel
     let onOpenInReddit: (URL) -> Void
 
@@ -32,6 +33,7 @@ struct CommentWriterView: View {
                 viewModel.cancelOverwriteConfirmation()
             }
             Button("Replace", role: .destructive) {
+                guard requirePremiumAccess() else { return }
                 Task { await viewModel.confirmOverwriteAndGenerate() }
             }
         } message: {
@@ -124,6 +126,7 @@ struct CommentWriterView: View {
             .disabled(!viewModel.canExport)
 
             Button {
+                guard requirePremiumAccess() else { return }
                 Task { await viewModel.generateComment() }
             } label: {
                 HStack(spacing: 6) {

+ 16 - 1
Reddit App/Views/FrontPageView.swift

@@ -1,6 +1,7 @@
 import SwiftUI
 
 struct FrontPageView: View {
+    @EnvironmentObject private var subscriptions: SubscriptionManager
     @State private var viewModel = FrontPageViewModel()
     @State private var paywallViewModel = PaywallViewModel()
     @State private var postGeneratorViewModel = PostGeneratorViewModel()
@@ -10,10 +11,18 @@ struct FrontPageView: View {
     var body: some View {
         ZStack {
             HStack(spacing: 0) {
-                SidebarView(viewModel: viewModel)
+                SidebarView(
+                    viewModel: viewModel,
+                    isPremium: subscriptions.hasPremiumAccess
+                )
 
                 mainContent
                     .frame(maxWidth: .infinity, maxHeight: .infinity)
+                    .environment(\.requirePremiumAccess, {
+                        if subscriptions.hasPremiumAccess { return true }
+                        viewModel.showPaywall()
+                        return false
+                    })
             }
 
             if viewModel.isPaywallPresented {
@@ -27,6 +36,11 @@ struct FrontPageView: View {
             }
         }
         .animation(.easeInOut(duration: 0.2), value: viewModel.isPaywallPresented)
+        .onChange(of: subscriptions.hasPremiumAccess) { hasPremium in
+            if hasPremium {
+                viewModel.dismissPaywall()
+            }
+        }
         .frame(minWidth: AppWindow.minWidth, minHeight: AppWindow.minHeight)
         .background(AppTheme.background)
     }
@@ -93,5 +107,6 @@ private struct ToolPlaceholderView: View {
 
 #Preview {
     FrontPageView()
+        .environmentObject(SubscriptionManager())
         .frame(width: AppWindow.width, height: AppWindow.height)
 }

+ 89 - 18
Reddit App/Views/PaywallView.swift

@@ -1,9 +1,22 @@
 import SwiftUI
 
 struct PaywallView: View {
+    @EnvironmentObject private var subscriptions: SubscriptionManager
     @Bindable var viewModel: PaywallViewModel
     var onDismiss: () -> Void
 
+    private var isPurchasingSelectedPlan: Bool {
+        subscriptions.purchasingPlan == viewModel.selectedPlan
+    }
+
+    private var buttonsBusy: Bool {
+        subscriptions.purchasingPlan != nil
+    }
+
+    private var isPremium: Bool {
+        subscriptions.hasPremiumAccess
+    }
+
     var body: some View {
         GeometryReader { geometry in
             let metrics = PaywallLayoutMetrics(size: geometry.size)
@@ -24,10 +37,34 @@ struct PaywallView: View {
                 .padding(.top, metrics.topInset)
                 .padding(.bottom, metrics.bottomInset)
                 .frame(width: geometry.size.width, height: geometry.size.height, alignment: .top)
+
+                if subscriptions.isLoadingProducts && !subscriptions.hasAllProductsLoaded {
+                    Color.black.opacity(0.35)
+                        .ignoresSafeArea()
+                        .allowsHitTesting(false)
+                    ProgressView()
+                        .controlSize(.large)
+                }
             }
         }
         .frame(maxWidth: .infinity, maxHeight: .infinity)
         .ignoresSafeArea()
+        .task {
+            await subscriptions.loadProducts()
+        }
+        .alert(
+            "Subscriptions",
+            isPresented: Binding(
+                get: { subscriptions.purchaseError != nil },
+                set: { presented in
+                    if !presented { subscriptions.purchaseError = nil }
+                }
+            )
+        ) {
+            Button("OK", role: .cancel) {}
+        } message: {
+            Text(subscriptions.purchaseError?.message ?? "")
+        }
     }
 
     private func header(metrics: PaywallLayoutMetrics) -> some View {
@@ -65,10 +102,13 @@ struct PaywallView: View {
             ForEach(PaywallPlan.allCases) { plan in
                 PaywallPricingCard(
                     plan: plan,
+                    price: subscriptions.mainPrice(for: plan),
+                    priceSuffix: subscriptions.priceSuffix(for: plan),
                     isSelected: viewModel.selectedPlan == plan,
                     metrics: metrics
                 ) {
                     viewModel.selectPlan(plan)
+                    subscriptions.purchaseError = nil
                 }
             }
         }
@@ -77,24 +117,43 @@ 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)
+
             Button {
-                viewModel.purchase()
+                Task {
+                    let purchased = await subscriptions.purchase(viewModel.selectedPlan)
+                    if purchased {
+                        onDismiss()
+                    }
+                }
             } label: {
-                Text("Get Premium")
-                    .font(.system(size: metrics.ctaFontSize, weight: .bold))
-                    .foregroundStyle(Color(hex: 0x0B0E14))
-                    .frame(maxWidth: .infinity)
-                    .padding(.vertical, metrics.ctaVerticalPadding)
-                    .background(
-                        LinearGradient(
-                            colors: [AppTheme.accentPurpleLight, AppTheme.accentTeal],
-                            startPoint: .leading,
-                            endPoint: .trailing
-                        )
+                HStack(spacing: 8) {
+                    if isPurchasingSelectedPlan {
+                        ProgressView()
+                            .controlSize(.small)
+                            .tint(Color(hex: 0x0B0E14))
+                    }
+                    Text(viewModel.selectedPlan.ctaTitle)
+                        .font(.system(size: metrics.ctaFontSize, weight: .bold))
+                        .foregroundStyle(Color(hex: 0x0B0E14))
+                }
+                .frame(maxWidth: .infinity)
+                .padding(.vertical, metrics.ctaVerticalPadding)
+                .background(
+                    LinearGradient(
+                        colors: [AppTheme.accentPurpleLight, AppTheme.accentTeal],
+                        startPoint: .leading,
+                        endPoint: .trailing
                     )
-                    .clipShape(RoundedRectangle(cornerRadius: metrics.cornerRadius))
+                )
+                .clipShape(RoundedRectangle(cornerRadius: metrics.cornerRadius))
             }
             .buttonStyle(.plain)
+            .disabled(buttonsBusy || subscriptions.isLoadingProducts)
+            .opacity((buttonsBusy || subscriptions.isLoadingProducts) ? 0.7 : 1)
 
             HStack(spacing: 6) {
                 Image(systemName: "lock.fill")
@@ -108,14 +167,23 @@ struct PaywallView: View {
 
     private func footerLinks(metrics: PaywallLayoutMetrics) -> some View {
         HStack(spacing: 0) {
-            footerLink(PaywallContent.continueWithFreePlan, metrics: metrics) {
-                onDismiss()
+            footerLink(
+                isPremium ? "Manage Subscription" : PaywallContent.continueWithFreePlan,
+                metrics: metrics
+            ) {
+                if isPremium {
+                    subscriptions.openSubscriptionManagement()
+                } else {
+                    onDismiss()
+                }
             }
 
             Spacer(minLength: metrics.footerLinkSpacing)
 
             footerLink(PaywallContent.restorePurchases, metrics: metrics) {
-                viewModel.restorePurchases()
+                Task {
+                    await subscriptions.restorePurchases()
+                }
             }
 
             Spacer(minLength: metrics.footerLinkSpacing)
@@ -213,6 +281,8 @@ private struct PaywallFeatureRow: View {
 
 private struct PaywallPricingCard: View {
     let plan: PaywallPlan
+    let price: String
+    let priceSuffix: String?
     let isSelected: Bool
     let metrics: PaywallLayoutMetrics
     let onSelect: () -> Void
@@ -247,11 +317,11 @@ private struct PaywallPricingCard: View {
                     .padding(.top, 2)
 
                 HStack(alignment: .firstTextBaseline, spacing: 2) {
-                    Text(plan.price)
+                    Text(price)
                         .font(.system(size: metrics.titleFontSize * 0.85, weight: .bold))
                         .foregroundStyle(AppTheme.textPrimary)
 
-                    if let suffix = plan.priceSuffix {
+                    if let suffix = priceSuffix {
                         Text(suffix)
                             .font(.system(size: metrics.featureFontSize))
                             .foregroundStyle(AppTheme.textSecondary)
@@ -299,5 +369,6 @@ private struct PaywallPricingCard: View {
 
 #Preview {
     PaywallView(viewModel: PaywallViewModel(), onDismiss: {})
+        .environmentObject(SubscriptionManager())
         .frame(width: AppWindow.width, height: AppWindow.height)
 }

+ 3 - 0
Reddit App/Views/PostGeneratorView.swift

@@ -3,6 +3,7 @@ import SwiftUI
 import UniformTypeIdentifiers
 
 struct PostGeneratorView: View {
+    @Environment(\.requirePremiumAccess) private var requirePremiumAccess
     @Bindable var viewModel: PostGeneratorViewModel
     let onOpenInReddit: (URL) -> Void
 
@@ -46,6 +47,7 @@ struct PostGeneratorView: View {
                 viewModel.cancelOverwriteConfirmation()
             }
             Button("Replace", role: .destructive) {
+                guard requirePremiumAccess() else { return }
                 Task { await viewModel.confirmOverwriteAndGenerate() }
             }
         } message: {
@@ -140,6 +142,7 @@ struct PostGeneratorView: View {
             .disabled(!viewModel.canExport)
 
             Button {
+                guard requirePremiumAccess() else { return }
                 Task { await viewModel.generatePost() }
             } label: {
                 HStack(spacing: 6) {

+ 8 - 3
Reddit App/Views/SidebarView.swift

@@ -2,6 +2,7 @@ import SwiftUI
 
 struct SidebarView: View {
     @Bindable var viewModel: FrontPageViewModel
+    var isPremium: Bool
 
     var body: some View {
         VStack(spacing: 0) {
@@ -100,19 +101,23 @@ struct SidebarView: View {
                 Image(systemName: "crown.fill")
                     .font(.system(size: 12))
                     .foregroundStyle(AppTheme.accentYellow)
-                Text("Upgrade to Pro")
+                Text(isPremium ? "Reddora Pro" : "Upgrade to Pro")
                     .font(.system(size: 12, weight: .semibold))
                     .foregroundStyle(AppTheme.textPrimary)
             }
 
-            Text("Unlock unlimited AI generations and advanced analytics")
+            Text(
+                isPremium
+                    ? "You have unlimited AI generations and all premium features."
+                    : "Unlock unlimited AI generations and advanced analytics"
+            )
                 .font(.system(size: 10))
                 .foregroundStyle(AppTheme.textSecondary)
                 .multilineTextAlignment(.center)
                 .fixedSize(horizontal: false, vertical: true)
                 .lineSpacing(2)
 
-            Button("Upgrade Now") {
+            Button(isPremium ? "Manage Subscription" : "Upgrade Now") {
                 viewModel.showPaywall()
             }
                 .font(.system(size: 11, weight: .semibold))

+ 2 - 0
Reddit App/Views/TitleOptimizerView.swift

@@ -1,6 +1,7 @@
 import SwiftUI
 
 struct TitleOptimizerView: View {
+    @Environment(\.requirePremiumAccess) private var requirePremiumAccess
     @Bindable var viewModel: TitleOptimizerViewModel
 
     var body: some View {
@@ -75,6 +76,7 @@ struct TitleOptimizerView: View {
             .disabled(viewModel.activeTitle.trimmingCharacters(in: .whitespaces).isEmpty)
 
             Button {
+                guard requirePremiumAccess() else { return }
                 Task { await viewModel.optimizeTitles() }
             } label: {
                 HStack(spacing: 6) {