Browse Source

Integrate StoreKit 2 into the paywall for purchases and premium access.

Wire SubscriptionManager through the app environment, load live product pricing, and add Paywall.storekit plus scheme configuration for local StoreKit testing.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 month ago
parent
commit
321b40985f

+ 89 - 0
gramora.xcodeproj/xcshareddata/xcschemes/gramora.xcscheme

@@ -0,0 +1,89 @@
+<?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 = "A10000062F00000000000001"
+               BuildableName = "Gramora.app"
+               BlueprintName = "gramora"
+               ReferencedContainer = "container:gramora.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 = "A10000062F00000000000001"
+            BuildableName = "Gramora.app"
+            BlueprintName = "gramora"
+            ReferencedContainer = "container:gramora.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+      <AdditionalOptions>
+         <AdditionalOption
+            key = "APP_DISTRIBUTOR_ID_OVERRIDE"
+            value = "com.apple.AppStore"
+            isEnabled = "YES">
+         </AdditionalOption>
+      </AdditionalOptions>
+      <StoreKitConfigurationFileReference
+         identifier = "../../gramora/Paywall.storekit">
+      </StoreKitConfigurationFileReference>
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Release"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "A10000062F00000000000001"
+            BuildableName = "Gramora.app"
+            BlueprintName = "gramora"
+            ReferencedContainer = "container:gramora.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

+ 3 - 0
gramora/App/GramoraApp.swift

@@ -2,9 +2,12 @@ import SwiftUI
 
 @main
 struct GramoraApp: App {
+    @StateObject private var subscriptions = SubscriptionManager()
+
     var body: some Scene {
         WindowGroup {
             MainView()
+                .environmentObject(subscriptions)
                 .frame(minWidth: AppTheme.windowMinWidth, minHeight: AppTheme.windowMinHeight)
                 .initialWindowSize(width: AppTheme.windowWidth, height: AppTheme.windowHeight)
                 .configureWindowChrome()

+ 341 - 0
gramora/Managers/SubscriptionManager.swift

@@ -0,0 +1,341 @@
+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 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 secondaryPrice(for plan: PaywallPlan) -> String {
+        guard let product = product(for: plan) else {
+            return plan.fallbackSecondaryPrice
+        }
+
+        let formatStyle = product.priceFormatStyle
+
+        switch plan {
+        case .weekly:
+            let doubled = product.price * 2
+            return "\(doubled.formatted(formatStyle)) / week"
+        case .monthly:
+            let weekly = product.price / Decimal(4.33)
+            return "\(weekly.formatted(formatStyle)) / week"
+        case .yearly:
+            let weekly = product.price / Decimal(52)
+            return "\(weekly.formatted(formatStyle)) / week"
+        case .lifetime:
+            let doubled = product.price * 2
+            return doubled.formatted(formatStyle)
+        }
+    }
+
+    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 .weekly:
+            return "Billed at \(product.displayPrice) every week"
+        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 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
+    }
+
+    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"
+        }
+    }
+}

+ 16 - 7
gramora/Models/PaywallModels.swift

@@ -25,7 +25,16 @@ enum PaywallPlan: String, CaseIterable, Identifiable {
         }
     }
 
-    var mainPrice: String {
+    var productID: String {
+        switch self {
+        case .weekly: SubscriptionProductID.weekly
+        case .monthly: SubscriptionProductID.monthly
+        case .yearly: SubscriptionProductID.yearly
+        case .lifetime: SubscriptionProductID.lifetime
+        }
+    }
+
+    var fallbackMainPrice: String {
         switch self {
         case .weekly: "$19.00"
         case .monthly: "$39.00"
@@ -34,7 +43,7 @@ enum PaywallPlan: String, CaseIterable, Identifiable {
         }
     }
 
-    var secondaryPrice: String {
+    var fallbackSecondaryPrice: String {
         switch self {
         case .weekly: "$38.00 / week"
         case .monthly: "$9.75 / week"
@@ -61,16 +70,16 @@ enum PaywallPlan: String, CaseIterable, Identifiable {
         }
     }
 
-    var billingDescription: String {
+    var fallbackBillingDescription: String {
         switch self {
         case .weekly:
-            return "Billed at \(mainPrice) every week"
+            return "Billed at \(fallbackMainPrice) every week"
         case .monthly:
-            return "3 Days Free Trial, then \(mainPrice) per month"
+            return "3 Days Free Trial, then \(fallbackMainPrice) per month"
         case .yearly:
-            return "Billed at \(mainPrice) every year"
+            return "Billed at \(fallbackMainPrice) every year"
         case .lifetime:
-            return "One-time payment of \(mainPrice)"
+            return "One-time payment of \(fallbackMainPrice)"
         }
     }
 }

+ 178 - 0
gramora/Paywall.storekit

@@ -0,0 +1,178 @@
+{
+  "identifier" : "Paywall",
+  "nonRenewingSubscriptions" : [
+
+  ],
+  "products" : [
+    {
+      "displayPrice" : "249.0",
+      "familyShareable" : false,
+      "internalID" : "6738291001",
+      "localizations" : [
+        {
+          "description" : "Unlock Gramora Pro forever with a single payment.",
+          "displayName" : "Lifetime",
+          "locale" : "en_US"
+        }
+      ],
+      "productID" : "MQL-DEV.gramora.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" : "21543201",
+      "localizations" : [
+
+      ],
+      "name" : "Gramora Pro",
+      "subscriptions" : [
+        {
+          "adHocOffers" : [
+
+          ],
+          "codeOffers" : [
+
+          ],
+          "displayPrice" : "19.0",
+          "familyShareable" : false,
+          "groupNumber" : 1,
+          "internalID" : "6738291002",
+          "introductoryOffer" : null,
+          "localizations" : [
+            {
+              "description" : "Weekly access to all Gramora Pro features.",
+              "displayName" : "Weekly",
+              "locale" : "en_US"
+            }
+          ],
+          "productID" : "MQL-DEV.gramora.pro.weekly",
+          "recurringSubscriptionPeriod" : "P1W",
+          "referenceName" : "Weekly",
+          "subscriptionGroupID" : "21543201",
+          "type" : "RecurringSubscription",
+          "winbackOffers" : [
+
+          ]
+        },
+        {
+          "adHocOffers" : [
+
+          ],
+          "codeOffers" : [
+
+          ],
+          "displayPrice" : "39.0",
+          "familyShareable" : false,
+          "groupNumber" : 2,
+          "internalID" : "6738291003",
+          "introductoryOffer" : {
+            "internalID" : "6738291004",
+            "paymentMode" : "free",
+            "subscriptionPeriod" : "P3D"
+          },
+          "localizations" : [
+            {
+              "description" : "Monthly access to all Gramora Pro features.",
+              "displayName" : "Monthly",
+              "locale" : "en_US"
+            }
+          ],
+          "productID" : "MQL-DEV.gramora.pro.monthly",
+          "recurringSubscriptionPeriod" : "P1M",
+          "referenceName" : "Monthly",
+          "subscriptionGroupID" : "21543201",
+          "type" : "RecurringSubscription",
+          "winbackOffers" : [
+
+          ]
+        },
+        {
+          "adHocOffers" : [
+
+          ],
+          "codeOffers" : [
+
+          ],
+          "displayPrice" : "79.0",
+          "familyShareable" : false,
+          "groupNumber" : 3,
+          "internalID" : "6738291005",
+          "introductoryOffer" : null,
+          "localizations" : [
+            {
+              "description" : "Yearly access to all Gramora Pro features.",
+              "displayName" : "Yearly",
+              "locale" : "en_US"
+            }
+          ],
+          "productID" : "MQL-DEV.gramora.pro.yearly",
+          "recurringSubscriptionPeriod" : "P1Y",
+          "referenceName" : "Yearly",
+          "subscriptionGroupID" : "21543201",
+          "type" : "RecurringSubscription",
+          "winbackOffers" : [
+
+          ]
+        }
+      ]
+    }
+  ],
+  "version" : {
+    "major" : 4,
+    "minor" : 0
+  }
+}

+ 20 - 0
gramora/Utilities/SubscriptionProductID.swift

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

+ 7 - 0
gramora/Views/MainView.swift

@@ -2,6 +2,7 @@ import SwiftUI
 
 struct MainView: View {
     @StateObject private var viewModel = MainViewModel()
+    @EnvironmentObject private var subscriptions: SubscriptionManager
 
     var body: some View {
         Group {
@@ -12,6 +13,7 @@ struct MainView: View {
                 HStack(spacing: 0) {
                     SidebarView(
                         selectedDestination: $viewModel.selectedDestination,
+                        isPremium: subscriptions.hasPremiumAccess,
                         onUpgradeTapped: viewModel.showPaywall
                     )
                     .frame(maxHeight: .infinity)
@@ -37,6 +39,11 @@ struct MainView: View {
             }
         }
         .animation(.easeInOut(duration: 0.2), value: viewModel.isShowingPaywall)
+        .onChange(of: subscriptions.hasPremiumAccess) { hasPremium in
+            if hasPremium {
+                viewModel.hidePaywall()
+            }
+        }
     }
 
     @ViewBuilder

+ 95 - 28
gramora/Views/PaywallView.swift

@@ -1,10 +1,19 @@
 import SwiftUI
 
 struct PaywallView: View {
-    let onClose: () -> Void
+    @EnvironmentObject private var subscriptions: SubscriptionManager
     @StateObject private var viewModel = PaywallViewModel()
+    let onClose: () -> Void
     @State private var isCTAHovered = false
 
+    private var isPurchasingSelectedPlan: Bool {
+        subscriptions.purchasingPlan == viewModel.selectedPlan
+    }
+
+    private var buttonsBusy: Bool {
+        subscriptions.purchasingPlan != nil
+    }
+
     var body: some View {
         GeometryReader { proxy in
             let horizontalInset = min(max(proxy.size.width * 0.03, 12), 34)
@@ -22,20 +31,46 @@ struct PaywallView: View {
             let featureHeight = max(74, min(92, 84 * layoutScale))
             let planHeight = max(160, min(174, 166 * layoutScale))
 
-            VStack(spacing: 0) {
-                VStack(spacing: verticalSpacing) {
-                    titleSection
-                    featuresSection(featureHeight: featureHeight)
-                    plansSection(planHeight: planHeight)
-                    ctaSection
-                    legalSection
+            ZStack {
+                VStack(spacing: 0) {
+                    VStack(spacing: verticalSpacing) {
+                        titleSection
+                        featuresSection(featureHeight: featureHeight)
+                        plansSection(planHeight: planHeight)
+                        ctaSection
+                        legalSection
+                    }
+                    .frame(width: contentWidth, alignment: .top)
+                    .padding(.top, topPadding)
+                    .padding(.bottom, topPadding)
+                }
+                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
+                .background(Color.white.ignoresSafeArea())
+
+                if subscriptions.isLoadingProducts && !subscriptions.hasAllProductsLoaded {
+                    Color.white.opacity(0.65)
+                        .ignoresSafeArea()
+                        .allowsHitTesting(false)
+                    ProgressView()
+                        .controlSize(.large)
                 }
-                .frame(width: contentWidth, alignment: .top)
-                .padding(.top, topPadding)
-                .padding(.bottom, topPadding)
             }
-            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
-            .background(Color.white.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 ?? "")
         }
     }
 
@@ -75,8 +110,13 @@ struct PaywallView: View {
                 PaywallPlanCardView(
                     plan: plan,
                     isSelected: viewModel.selectedPlan == plan,
+                    mainPrice: subscriptions.mainPrice(for: plan),
+                    secondaryPrice: subscriptions.secondaryPrice(for: plan),
                     minHeight: planHeight,
-                    onSelect: { viewModel.selectPlan(plan) }
+                    onSelect: {
+                        viewModel.selectPlan(plan)
+                        subscriptions.purchaseError = nil
+                    }
                 )
             }
         }
@@ -85,30 +125,47 @@ struct PaywallView: View {
 
     private var ctaSection: some View {
         VStack(spacing: 14) {
-            Text(viewModel.selectedPlan.billingDescription)
+            Text(subscriptions.billingDescription(for: viewModel.selectedPlan))
                 .font(.system(size: 13, weight: .medium))
                 .foregroundStyle(Color(red: 0.47, green: 0.51, blue: 0.57))
 
-            Button(action: {}) {
+            Button {
+                Task {
+                    let purchased = await subscriptions.purchase(viewModel.selectedPlan)
+                    if purchased {
+                        onClose()
+                    }
+                }
+            } label: {
                 HStack {
                     Spacer()
-                    Text(viewModel.selectedPlan.ctaTitle)
-                        .font(.system(size: 20, weight: .heavy, design: .rounded))
-                        .foregroundStyle(.white)
-                        .lineLimit(1)
-                        .minimumScaleFactor(0.85)
+                    if isPurchasingSelectedPlan {
+                        ProgressView()
+                            .controlSize(.small)
+                            .tint(.white)
+                    } else {
+                        Text(viewModel.selectedPlan.ctaTitle)
+                            .font(.system(size: 20, weight: .heavy, design: .rounded))
+                            .foregroundStyle(.white)
+                            .lineLimit(1)
+                            .minimumScaleFactor(0.85)
+                    }
                     Spacer()
                 }
                 .padding(.vertical, 14)
                 .background(isCTAHovered ? Color(red: 0.02, green: 0.56, blue: 0.43) : Color(red: 0.03, green: 0.62, blue: 0.48))
                 .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
                 .overlay(alignment: .trailing) {
-                    Image(systemName: "chevron.right.2")
-                        .font(.system(size: 18, weight: .heavy))
-                        .foregroundStyle(Color.white.opacity(0.75))
-                        .padding(.trailing, 24)
+                    if !isPurchasingSelectedPlan {
+                        Image(systemName: "chevron.right.2")
+                            .font(.system(size: 18, weight: .heavy))
+                            .foregroundStyle(Color.white.opacity(0.75))
+                            .padding(.trailing, 24)
+                    }
                 }
             }
+            .disabled(buttonsBusy || subscriptions.isLoadingProducts)
+            .opacity((buttonsBusy || subscriptions.isLoadingProducts) ? 0.7 : 1)
             .scaleEffect(isCTAHovered ? 1.01 : 1.0)
             .shadow(
                 color: isCTAHovered ? AppTheme.primaryShadow.opacity(0.32) : .clear,
@@ -137,8 +194,16 @@ struct PaywallView: View {
 
             HStack(spacing: 0) {
                 PaywallFooterLinkView(title: "Continue with free plan", action: onClose)
+                PaywallFooterLinkView(title: "Restore Purchases") {
+                    Task {
+                        await subscriptions.restorePurchases()
+                        if subscriptions.hasPremiumAccess {
+                            onClose()
+                        }
+                    }
+                }
+                .disabled(buttonsBusy)
                 PaywallFooterLinkView(title: "Privacy Policy")
-                PaywallFooterLinkView(title: "Support")
                 PaywallFooterLinkView(title: "Terms of Services")
             }
             .frame(height: 44)
@@ -199,6 +264,8 @@ private struct PaywallFeatureBox: View {
 private struct PaywallPlanCardView: View {
     let plan: PaywallPlan
     let isSelected: Bool
+    let mainPrice: String
+    let secondaryPrice: String
     let minHeight: CGFloat
     let onSelect: () -> Void
     @State private var isHovered = false
@@ -239,7 +306,7 @@ private struct PaywallPlanCardView: View {
                     .padding(.horizontal, 14)
                     .padding(.top, 14)
 
-                Text(plan.mainPrice)
+                Text(mainPrice)
                     .font(.system(size: 21, weight: .heavy, design: .rounded))
                     .foregroundStyle(Color(red: 0.03, green: 0.62, blue: 0.48))
                     .lineLimit(1)
@@ -250,7 +317,7 @@ private struct PaywallPlanCardView: View {
 
                 VStack(alignment: .leading, spacing: 0) {
                     HStack {
-                        Text(plan.secondaryPrice)
+                        Text(secondaryPrice)
                             .font(.system(size: 14, weight: .semibold))
                             .foregroundStyle(Color(red: 0.45, green: 0.48, blue: 0.54))
                             .overlay(alignment: .center) {

+ 6 - 3
gramora/Views/SidebarView.swift

@@ -2,6 +2,7 @@ import SwiftUI
 
 struct SidebarView: View {
     @Binding var selectedDestination: NavigationDestination
+    let isPremium: Bool
     let onUpgradeTapped: () -> Void
 
     var body: some View {
@@ -25,9 +26,11 @@ struct SidebarView: View {
 
             Spacer(minLength: 12)
 
-            PremiumCardView(onUpgradeTapped: onUpgradeTapped)
-                .padding(.horizontal, 16)
-                .padding(.bottom, 20)
+            if !isPremium {
+                PremiumCardView(onUpgradeTapped: onUpgradeTapped)
+                    .padding(.horizontal, 16)
+                    .padding(.bottom, 20)
+            }
         }
         .frame(width: AppTheme.sidebarWidth, alignment: .top)
         .frame(maxHeight: .infinity)

+ 2 - 0
gramora/gramora.entitlements

@@ -6,5 +6,7 @@
 	<true/>
 	<key>com.apple.security.files.user-selected.read-only</key>
 	<true/>
+	<key>com.apple.security.network.client</key>
+	<true/>
 </dict>
 </plist>