Просмотр исходного кода

Harden StoreKit entitlements and improve paywall subscription flows.

Use currentEntitlements with subscription status checks for refunds and grace periods, let subscribers upgrade to lifetime from the paywall with a cancel reminder, and tighten premium gating across settings and Words Count.

Co-authored-by: Cursor <cursoragent@cursor.com>
Hussain Afzal 4 недель назад
Родитель
Сommit
54dab857d7

+ 122 - 43
gramora/Managers/SubscriptionManager.swift

@@ -44,18 +44,25 @@ final class SubscriptionManager: ObservableObject {
     @Published private(set) var hasPremiumAccess = false
     @Published private(set) var hasPremiumAccess = false
     @Published private(set) var hasManageableSubscription = false
     @Published private(set) var hasManageableSubscription = false
     @Published private(set) var hasResolvedPremiumStatus = false
     @Published private(set) var hasResolvedPremiumStatus = false
+    @Published private(set) var activePlan: PaywallPlan?
+    @Published private(set) var activeRecurringPlan: PaywallPlan?
     @Published var purchaseError: PurchaseError?
     @Published var purchaseError: PurchaseError?
+    @Published var subscriptionSuccessMessage: String?
+    @Published var showsCancelSubscriptionReminder = false
+    @Published private(set) var recurringPlanPendingCancellation: PaywallPlan?
 
 
     var hasAllProductsLoaded: Bool {
     var hasAllProductsLoaded: Bool {
         SubscriptionProductID.all.allSatisfy { productsByID[$0] != nil }
         SubscriptionProductID.all.allSatisfy { productsByID[$0] != nil }
     }
     }
 
 
     private var inFlightProductLoad: Task<Void, Never>?
     private var inFlightProductLoad: Task<Void, Never>?
+    private var cancellables = Set<AnyCancellable>()
 
 
     init() {
     init() {
         Task { await listenForTransactionUpdates() }
         Task { await listenForTransactionUpdates() }
         Task { await refreshPremiumAccess() }
         Task { await refreshPremiumAccess() }
         Task { await loadProducts(presentLoadingUI: false) }
         Task { await loadProducts(presentLoadingUI: false) }
+        observeAppLifecycle()
     }
     }
 
 
     func loadProducts(presentLoadingUI: Bool? = nil) async {
     func loadProducts(presentLoadingUI: Bool? = nil) async {
@@ -188,6 +195,18 @@ final class SubscriptionManager: ObservableObject {
     func purchase(_ plan: PaywallPlan) async -> Bool {
     func purchase(_ plan: PaywallPlan) async -> Bool {
         purchaseError = nil
         purchaseError = nil
 
 
+        if hasPremiumAccess {
+            if !hasManageableSubscription {
+                return true
+            }
+            if let activePlan, plan == activePlan {
+                return true
+            }
+            if let activeRecurringPlan, plan == activeRecurringPlan {
+                return true
+            }
+        }
+
         var product = productsByID[plan.productID]
         var product = productsByID[plan.productID]
         if product == nil {
         if product == nil {
             await loadProducts(presentLoadingUI: false)
             await loadProducts(presentLoadingUI: false)
@@ -214,6 +233,9 @@ final class SubscriptionManager: ObservableObject {
         purchasingPlan = plan
         purchasingPlan = plan
         defer { purchasingPlan = nil }
         defer { purchasingPlan = nil }
 
 
+        let shouldRemindToCancelSubscription = plan == .lifetime && hasManageableSubscription
+        let recurringPlanBeforePurchase = activeRecurringPlan ?? activePlan
+
         do {
         do {
             let result = try await product.purchase()
             let result = try await product.purchase()
             switch result {
             switch result {
@@ -222,16 +244,25 @@ final class SubscriptionManager: ObservableObject {
                     purchaseError = .purchaseNotVerified
                     purchaseError = .purchaseNotVerified
                     return false
                     return false
                 }
                 }
-                NotificationCenter.default.post(name: .subscriptionPurchased, object: nil)
                 await transaction.finish()
                 await transaction.finish()
-                await refreshPremiumAccess()
-                return hasPremiumAccess
+                await refreshPremiumAccessWithRetry()
+                if hasPremiumAccess {
+                    NotificationCenter.default.post(name: .subscriptionPurchased, object: nil)
+                }
+                if shouldRemindToCancelSubscription {
+                    recurringPlanPendingCancellation = recurringPlanBeforePurchase
+                    showsCancelSubscriptionReminder = true
+                } else if !hasPremiumAccess {
+                    subscriptionSuccessMessage = "Purchase successful. If premium features don't unlock right away, tap Restore Purchases."
+                }
+                return hasPremiumAccess || shouldRemindToCancelSubscription
             case .userCancelled:
             case .userCancelled:
                 return false
                 return false
             case .pending:
             case .pending:
                 purchaseError = .purchasePending
                 purchaseError = .purchasePending
                 return false
                 return false
             @unknown default:
             @unknown default:
+                purchaseError = .generic
                 return false
                 return false
             }
             }
         } catch {
         } catch {
@@ -247,10 +278,16 @@ final class SubscriptionManager: ObservableObject {
 
 
     func restorePurchases() async {
     func restorePurchases() async {
         purchaseError = nil
         purchaseError = nil
+        subscriptionSuccessMessage = nil
+        let hadPremium = hasPremiumAccess
         do {
         do {
             try await AppStore.sync()
             try await AppStore.sync()
             await refreshPremiumAccess()
             await refreshPremiumAccess()
-            if !hasPremiumAccess {
+            if hasPremiumAccess {
+                if !hadPremium {
+                    subscriptionSuccessMessage = "Your purchases have been restored."
+                }
+            } else {
                 purchaseError = .noActiveSubscriptions
                 purchaseError = .noActiveSubscriptions
             }
             }
         } catch {
         } catch {
@@ -258,6 +295,23 @@ final class SubscriptionManager: ObservableObject {
         }
         }
     }
     }
 
 
+    func clearSubscriptionSuccessMessage() {
+        subscriptionSuccessMessage = nil
+    }
+
+    func clearCancelSubscriptionReminder() {
+        showsCancelSubscriptionReminder = false
+        recurringPlanPendingCancellation = nil
+    }
+
+    var cancelSubscriptionReminderMessage: String {
+        if let plan = recurringPlanPendingCancellation {
+            let planName = displayTitle(for: plan)
+            return "Your lifetime access is active. Your \(planName) subscription is still active and will renew automatically. Cancel it in the App Store to avoid future charges."
+        }
+        return "Your lifetime access is active. Your recurring subscription is still active and will renew automatically. Cancel it in the App Store to avoid future charges."
+    }
+
     private func performLoadProducts(presentLoadingUI: Bool?) async {
     private func performLoadProducts(presentLoadingUI: Bool?) async {
         let missingAny = SubscriptionProductID.all.contains { productsByID[$0] == nil }
         let missingAny = SubscriptionProductID.all.contains { productsByID[$0] == nil }
         let shouldShowLoading = presentLoadingUI ?? missingAny
         let shouldShowLoading = presentLoadingUI ?? missingAny
@@ -319,74 +373,99 @@ final class SubscriptionManager: ObservableObject {
     private func refreshPremiumAccess() async {
     private func refreshPremiumAccess() async {
         var hasPremium = false
         var hasPremium = false
         var manageableSubscription = false
         var manageableSubscription = false
+        var resolvedPlan: PaywallPlan?
+        var resolvedRecurringPlan: PaywallPlan?
 
 
         for await result in Transaction.currentEntitlements {
         for await result in Transaction.currentEntitlements {
             guard case .verified(let transaction) = result else { continue }
             guard case .verified(let transaction) = result else { continue }
             guard SubscriptionProductID.all.contains(transaction.productID) else { continue }
             guard SubscriptionProductID.all.contains(transaction.productID) else { continue }
             guard transaction.revocationDate == nil else { continue }
             guard transaction.revocationDate == nil else { continue }
 
 
-            if let expirationDate = transaction.expirationDate, expirationDate < Date() {
-                continue
-            }
+            guard let plan = SubscriptionProductID.plan(for: transaction.productID) else { continue }
 
 
-            hasPremium = true
             if SubscriptionProductID.recurring.contains(transaction.productID) {
             if SubscriptionProductID.recurring.contains(transaction.productID) {
+                guard await isRecurringSubscriptionEntitled(productID: transaction.productID) else {
+                    continue
+                }
                 manageableSubscription = true
                 manageableSubscription = true
+                if resolvedRecurringPlan == nil {
+                    resolvedRecurringPlan = plan
+                }
             }
             }
-        }
 
 
-        if !hasPremium {
-            hasPremium = await hasActiveSubscriptionStatus()
-            if hasPremium {
-                manageableSubscription = true
+            hasPremium = true
+            if resolvedPlan == nil {
+                resolvedPlan = plan
             }
             }
         }
         }
 
 
         hasPremiumAccess = hasPremium
         hasPremiumAccess = hasPremium
         hasManageableSubscription = manageableSubscription
         hasManageableSubscription = manageableSubscription
+        activePlan = resolvedPlan
+        activeRecurringPlan = resolvedRecurringPlan
         hasResolvedPremiumStatus = true
         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
-            }
+    private func refreshPremiumAccessWithRetry() async {
+        await refreshPremiumAccess()
+        guard !hasPremiumAccess else { return }
+
+        for _ in 0..<3 {
+            try? await Task.sleep(nanoseconds: 400_000_000)
+            await refreshPremiumAccess()
+            if hasPremiumAccess { return }
         }
         }
+    }
+
+    private func isRecurringSubscriptionEntitled(productID: String) async -> Bool {
+        let product: Product?
+        if let cached = productsByID[productID] {
+            product = cached
+        } else if let loaded = try? await Product.products(for: [productID]).first {
+            product = loaded
+        } else {
+            return true
+        }
+
+        guard let product, let subscription = product.subscription else { return true }
 
 
-        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
-                    }
+        do {
+            let statuses = try await subscription.status
+            return 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
             }
             }
+        } catch {
+            return true
         }
         }
+    }
 
 
-        return false
+    private func observeAppLifecycle() {
+        NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)
+            .sink { [weak self] _ in
+                Task { await self?.refreshPremiumAccess() }
+            }
+            .store(in: &cancellables)
     }
     }
 
 
     private func listenForTransactionUpdates() async {
     private func listenForTransactionUpdates() async {
         for await update in Transaction.updates {
         for await update in Transaction.updates {
-            guard case .verified(let transaction) = update else { continue }
-            await transaction.finish()
-            await refreshPremiumAccess()
+            switch update {
+            case .verified(let transaction):
+                await transaction.finish()
+                await refreshPremiumAccess()
+            case .unverified(_, let error):
+                #if DEBUG
+                print("[SubscriptionManager] Unverified transaction update: \(error)")
+                #endif
+                purchaseError = .purchaseNotVerified
+            }
         }
         }
     }
     }
 
 

+ 1 - 1
gramora/Utilities/SettingsEnvironment.swift

@@ -5,7 +5,7 @@ private struct SettingsActionKey: EnvironmentKey {
 }
 }
 
 
 private struct RequirePremiumAccessKey: EnvironmentKey {
 private struct RequirePremiumAccessKey: EnvironmentKey {
-    static let defaultValue: () -> Bool = { true }
+    static let defaultValue: () -> Bool = { false }
 }
 }
 
 
 extension EnvironmentValues {
 extension EnvironmentValues {

+ 7 - 1
gramora/Views/Components/PremiumCardView.swift

@@ -2,10 +2,16 @@ import SwiftUI
 
 
 struct PremiumCardView: View {
 struct PremiumCardView: View {
     let isPremium: Bool
     let isPremium: Bool
+    let hasManageableSubscription: Bool
     let onUpgradeTapped: () -> Void
     let onUpgradeTapped: () -> Void
     let onManageSubscriptionTapped: () -> Void
     let onManageSubscriptionTapped: () -> Void
     @State private var isUpgradeHovered = false
     @State private var isUpgradeHovered = false
 
 
+    private var premiumButtonTitle: String {
+        if !isPremium { return "Upgrade Now" }
+        return hasManageableSubscription ? "Manage Subscription" : "View Premium"
+    }
+
     var body: some View {
     var body: some View {
         VStack(alignment: .leading, spacing: 10) {
         VStack(alignment: .leading, spacing: 10) {
             HStack(spacing: 8) {
             HStack(spacing: 8) {
@@ -30,7 +36,7 @@ struct PremiumCardView: View {
 
 
             Button(action: isPremium ? onManageSubscriptionTapped : onUpgradeTapped) {
             Button(action: isPremium ? onManageSubscriptionTapped : onUpgradeTapped) {
                 HStack(spacing: 6) {
                 HStack(spacing: 6) {
-                    Text(isPremium ? "Manage Subscription" : "Upgrade Now")
+                    Text(premiumButtonTitle)
                         .font(.system(size: 12, weight: .semibold))
                         .font(.system(size: 12, weight: .semibold))
 
 
                     if !isPremium {
                     if !isPremium {

+ 5 - 0
gramora/Views/MainView.swift

@@ -17,6 +17,7 @@ struct MainView: View {
                 ),
                 ),
                 isShowingSettings: viewModel.isShowingSettings,
                 isShowingSettings: viewModel.isShowingSettings,
                 isPremium: subscriptions.hasPremiumAccess,
                 isPremium: subscriptions.hasPremiumAccess,
+                hasManageableSubscription: subscriptions.hasManageableSubscription,
                 onUpgradeTapped: viewModel.showPaywall,
                 onUpgradeTapped: viewModel.showPaywall,
                 onManageSubscriptionTapped: viewModel.showPaywall
                 onManageSubscriptionTapped: viewModel.showPaywall
             )
             )
@@ -34,6 +35,7 @@ struct MainView: View {
                         onClose: viewModel.hideSettings,
                         onClose: viewModel.hideSettings,
                         themeManager: themeManager
                         themeManager: themeManager
                     )
                     )
+                    .environmentObject(subscriptions)
                     .transition(.opacity)
                     .transition(.opacity)
                 } else {
                 } else {
                     contentView
                     contentView
@@ -50,6 +52,9 @@ struct MainView: View {
             }
             }
             .environment(\.onSettingsTapped, viewModel.showSettings)
             .environment(\.onSettingsTapped, viewModel.showSettings)
             .environment(\.requirePremiumAccess, {
             .environment(\.requirePremiumAccess, {
+                if !subscriptions.hasResolvedPremiumStatus {
+                    return false
+                }
                 if subscriptions.hasPremiumAccess { return true }
                 if subscriptions.hasPremiumAccess { return true }
                 viewModel.showPaywall()
                 viewModel.showPaywall()
                 return false
                 return false

+ 107 - 6
gramora/Views/PaywallView.swift

@@ -22,6 +22,31 @@ struct PaywallView: View {
         viewModel.selectedPlan == .lifetime
         viewModel.selectedPlan == .lifetime
     }
     }
 
 
+    private var canPurchaseSelectedPlan: Bool {
+        guard isPremium else { return true }
+        guard subscriptions.hasManageableSubscription else { return false }
+        guard let activePlan = subscriptions.activeRecurringPlan ?? subscriptions.activePlan else {
+            return viewModel.selectedPlan == .lifetime
+        }
+        return viewModel.selectedPlan != activePlan
+    }
+
+    private var ctaDescription: String {
+        if isPremium, !subscriptions.hasManageableSubscription {
+            return "You already have lifetime access."
+        }
+        if subscriptions.hasManageableSubscription,
+           let activePlan = subscriptions.activeRecurringPlan ?? subscriptions.activePlan,
+           viewModel.selectedPlan == activePlan {
+            return "You're currently on the \(subscriptions.displayTitle(for: activePlan)) plan."
+        }
+        if subscriptions.hasManageableSubscription,
+           viewModel.selectedPlan == .lifetime {
+            return "Upgrade to lifetime with a one-time payment. Your current subscription won't cancel automatically — cancel it after purchasing to avoid further charges."
+        }
+        return subscriptions.billingDescription(for: viewModel.selectedPlan)
+    }
+
     private var footerPrimaryTitle: String {
     private var footerPrimaryTitle: String {
         if isPremium {
         if isPremium {
             return subscriptions.hasManageableSubscription ? "Manage Subscription" : "Close"
             return subscriptions.hasManageableSubscription ? "Manage Subscription" : "Close"
@@ -49,7 +74,11 @@ struct PaywallView: View {
             ZStack {
             ZStack {
                 VStack(spacing: 0) {
                 VStack(spacing: 0) {
                     VStack(spacing: verticalSpacing) {
                     VStack(spacing: verticalSpacing) {
-                        titleSection
+                        if isPremium {
+                            premiumTitleSection
+                        } else {
+                            titleSection
+                        }
                         featuresSection(featureHeight: featureHeight)
                         featuresSection(featureHeight: featureHeight)
                         plansSection(planHeight: planHeight)
                         plansSection(planHeight: planHeight)
                         ctaSection
                         ctaSection
@@ -78,6 +107,9 @@ struct PaywallView: View {
         .clearHostingBackground()
         .clearHostingBackground()
         .task {
         .task {
             await subscriptions.loadProducts(presentLoadingUI: true)
             await subscriptions.loadProducts(presentLoadingUI: true)
+            if subscriptions.hasManageableSubscription {
+                viewModel.selectPlan(.lifetime)
+            }
         }
         }
         .alert(
         .alert(
             "Subscriptions",
             "Subscriptions",
@@ -92,6 +124,69 @@ struct PaywallView: View {
         } message: {
         } message: {
             Text(subscriptions.purchaseError?.message ?? "")
             Text(subscriptions.purchaseError?.message ?? "")
         }
         }
+        .alert(
+            "Subscriptions",
+            isPresented: Binding(
+                get: { subscriptions.subscriptionSuccessMessage != nil },
+                set: { presented in
+                    if !presented { subscriptions.clearSubscriptionSuccessMessage() }
+                }
+            )
+        ) {
+            Button("OK", role: .cancel) {}
+        } message: {
+            Text(subscriptions.subscriptionSuccessMessage ?? "")
+        }
+        .alert(
+            "Cancel your subscription",
+            isPresented: Binding(
+                get: { subscriptions.showsCancelSubscriptionReminder },
+                set: { presented in
+                    if !presented { subscriptions.clearCancelSubscriptionReminder() }
+                }
+            )
+        ) {
+            Button("Manage Subscription") {
+                subscriptions.openSubscriptionManagement()
+                subscriptions.clearCancelSubscriptionReminder()
+                onClose()
+            }
+            Button("Not Now", role: .cancel) {
+                subscriptions.clearCancelSubscriptionReminder()
+                onClose()
+            }
+        } message: {
+            Text(subscriptions.cancelSubscriptionReminderMessage)
+        }
+    }
+
+    private var premiumTitleSection: some View {
+        VStack(spacing: 4) {
+            HStack(spacing: 10) {
+                Image(systemName: "crown.fill")
+                    .font(.system(size: 28, weight: .bold))
+                    .foregroundStyle(Color(red: 0.95, green: 0.75, blue: 0.20))
+
+                Text("You're a PRO member")
+                    .font(.system(size: 34, weight: .heavy, design: .rounded))
+                    .foregroundStyle(AppTheme.textPrimary)
+            }
+
+            Text(premiumStatusSubtitle)
+                .font(.system(size: 16, weight: .medium))
+                .foregroundStyle(Color(red: 0.47, green: 0.51, blue: 0.57))
+                .multilineTextAlignment(.center)
+        }
+    }
+
+    private var premiumStatusSubtitle: String {
+        if subscriptions.hasManageableSubscription, let plan = subscriptions.activeRecurringPlan ?? subscriptions.activePlan {
+            return "Your \(subscriptions.displayTitle(for: plan)) plan is active. Upgrade to lifetime or switch plans below."
+        }
+        if subscriptions.hasManageableSubscription {
+            return "Your subscription is active. Upgrade to lifetime or switch plans below."
+        }
+        return "You have lifetime access to all premium features."
     }
     }
 
 
     private var closeButton: some View {
     private var closeButton: some View {
@@ -154,15 +249,21 @@ struct PaywallView: View {
 
 
     private var ctaSection: some View {
     private var ctaSection: some View {
         VStack(spacing: 14) {
         VStack(spacing: 14) {
-            Text(subscriptions.billingDescription(for: viewModel.selectedPlan))
+            Text(ctaDescription)
                 .font(.system(size: 13, weight: .medium))
                 .font(.system(size: 13, weight: .medium))
                 .foregroundStyle(Color(red: 0.47, green: 0.51, blue: 0.57))
                 .foregroundStyle(Color(red: 0.47, green: 0.51, blue: 0.57))
+                .multilineTextAlignment(.center)
 
 
             Button {
             Button {
                 Task {
                 Task {
                     let purchased = await subscriptions.purchase(viewModel.selectedPlan)
                     let purchased = await subscriptions.purchase(viewModel.selectedPlan)
                     if purchased {
                     if purchased {
-                        onClose()
+                        if subscriptions.showsCancelSubscriptionReminder {
+                            return
+                        }
+                        if subscriptions.hasPremiumAccess {
+                            onClose()
+                        }
                     }
                     }
                 }
                 }
             } label: {
             } label: {
@@ -193,8 +294,8 @@ struct PaywallView: View {
                     }
                     }
                 }
                 }
             }
             }
-            .disabled(buttonsBusy || subscriptions.isLoadingProducts || !subscriptions.hasAllProductsLoaded)
-            .opacity((buttonsBusy || subscriptions.isLoadingProducts || !subscriptions.hasAllProductsLoaded) ? 0.7 : 1)
+            .disabled(buttonsBusy || subscriptions.isLoadingProducts || !subscriptions.hasAllProductsLoaded || !canPurchaseSelectedPlan)
+            .opacity((buttonsBusy || subscriptions.isLoadingProducts || !subscriptions.hasAllProductsLoaded || !canPurchaseSelectedPlan) ? 0.7 : 1)
             .scaleEffect(isCTAHovered ? 1.01 : 1.0)
             .scaleEffect(isCTAHovered ? 1.01 : 1.0)
             .shadow(
             .shadow(
                 color: isCTAHovered ? AppTheme.primaryShadow.opacity(0.32) : .clear,
                 color: isCTAHovered ? AppTheme.primaryShadow.opacity(0.32) : .clear,
@@ -216,7 +317,7 @@ struct PaywallView: View {
             if !isSelectedPlanLifetime {
             if !isSelectedPlanLifetime {
                 VStack(spacing: 2) {
                 VStack(spacing: 2) {
                     Text("Your subscription will automatically renew unless auto-renew is turned off at least 24-hours before the end of the current subscription period.")
                     Text("Your subscription will automatically renew unless auto-renew is turned off at least 24-hours before the end of the current subscription period.")
-                    Text("Payment will be charged to your iTunes account at confirmation of purchase.")
+                    Text("Payment will be charged to your Apple ID at confirmation of purchase.")
                 }
                 }
                 .padding(.top, -8)
                 .padding(.top, -8)
                 .font(.system(size: 12, weight: .medium))
                 .font(.system(size: 12, weight: .medium))

+ 114 - 0
gramora/Views/SettingsView.swift

@@ -2,6 +2,7 @@ import SwiftUI
 
 
 struct SettingsView: View {
 struct SettingsView: View {
     @StateObject private var viewModel: SettingsViewModel
     @StateObject private var viewModel: SettingsViewModel
+    @EnvironmentObject private var subscriptions: SubscriptionManager
 
 
     let onClose: () -> Void
     let onClose: () -> Void
 
 
@@ -53,6 +54,32 @@ struct SettingsView: View {
         } message: {
         } message: {
             Text("This will remove your recent dictionary lookups.")
             Text("This will remove your recent dictionary lookups.")
         }
         }
+        .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 ?? "")
+        }
+        .alert(
+            "Subscriptions",
+            isPresented: Binding(
+                get: { subscriptions.subscriptionSuccessMessage != nil },
+                set: { presented in
+                    if !presented { subscriptions.clearSubscriptionSuccessMessage() }
+                }
+            )
+        ) {
+            Button("OK", role: .cancel) {}
+        } message: {
+            Text(subscriptions.subscriptionSuccessMessage ?? "")
+        }
     }
     }
 
 
     private var header: some View {
     private var header: some View {
@@ -72,6 +99,7 @@ struct SettingsView: View {
     private var settingsCard: some View {
     private var settingsCard: some View {
         ScrollView(.vertical, showsIndicators: false) {
         ScrollView(.vertical, showsIndicators: false) {
             VStack(alignment: .leading, spacing: 24) {
             VStack(alignment: .leading, spacing: 24) {
+                subscriptionSection
                 appearanceSection
                 appearanceSection
                 dataSection
                 dataSection
                 aboutSection
                 aboutSection
@@ -89,6 +117,67 @@ struct SettingsView: View {
         )
         )
     }
     }
 
 
+    private var subscriptionSection: some View {
+        SettingsSectionCard(title: "Subscription", iconName: "crown.fill") {
+            VStack(spacing: 0) {
+                SettingsInfoRow(
+                    iconName: "checkmark.seal.fill",
+                    title: subscriptionStatusTitle,
+                    subtitle: subscriptionStatusSubtitle
+                )
+
+                SettingsRowDivider()
+
+                SettingsActionRow(
+                    iconName: "arrow.clockwise",
+                    title: "Restore Purchases",
+                    subtitle: "Restore subscriptions on this Apple ID",
+                    actionTitle: "Restore",
+                    isDestructive: false,
+                    isActionEnabled: !subscriptions.isLoadingProducts
+                ) {
+                    Task {
+                        await subscriptions.restorePurchases()
+                    }
+                }
+
+                if subscriptions.hasManageableSubscription {
+                    SettingsRowDivider()
+
+                    SettingsActionRow(
+                        iconName: "creditcard.fill",
+                        title: "Manage Subscription",
+                        subtitle: "Change plan or cancel in the App Store",
+                        actionTitle: "Manage",
+                        isDestructive: false
+                    ) {
+                        subscriptions.openSubscriptionManagement()
+                    }
+                }
+            }
+        }
+    }
+
+    private var subscriptionStatusTitle: String {
+        if subscriptions.hasPremiumAccess {
+            return AppBrand.premiumName
+        }
+        return "Free Plan"
+    }
+
+    private var subscriptionStatusSubtitle: String {
+        if subscriptions.hasPremiumAccess {
+            if subscriptions.hasManageableSubscription, let plan = subscriptions.activeRecurringPlan ?? subscriptions.activePlan {
+                return "\(subscriptions.displayTitle(for: plan)) plan is active"
+            }
+            if subscriptions.hasManageableSubscription {
+                return "Subscription is active"
+            }
+            return "Lifetime access is active"
+        }
+        return "Upgrade to unlock all premium features"
+    }
+
     private var appearanceSection: some View {
     private var appearanceSection: some View {
         SettingsSectionCard(title: "Appearance", iconName: "paintbrush.fill") {
         SettingsSectionCard(title: "Appearance", iconName: "paintbrush.fill") {
             SettingsToggleRow(
             SettingsToggleRow(
@@ -198,6 +287,31 @@ private struct SettingsSectionCard<Content: View>: View {
     }
     }
 }
 }
 
 
+private struct SettingsInfoRow: View {
+    let iconName: String
+    let title: String
+    let subtitle: String
+
+    var body: some View {
+        HStack(spacing: 12) {
+            SettingsRowIcon(systemName: iconName)
+
+            VStack(alignment: .leading, spacing: 2) {
+                Text(title)
+                    .font(.system(size: 14, weight: .semibold))
+                    .foregroundStyle(AppTheme.textPrimary)
+
+                Text(subtitle)
+                    .font(.system(size: 12))
+                    .foregroundStyle(AppTheme.textMuted)
+            }
+
+            Spacer(minLength: 8)
+        }
+        .settingsRowHover(isHovered: false)
+    }
+}
+
 private struct SettingsActionRow: View {
 private struct SettingsActionRow: View {
     let iconName: String
     let iconName: String
     let title: String
     let title: String

+ 2 - 0
gramora/Views/SidebarView.swift

@@ -4,6 +4,7 @@ struct SidebarView: View {
     @Binding var selectedDestination: NavigationDestination
     @Binding var selectedDestination: NavigationDestination
     let isShowingSettings: Bool
     let isShowingSettings: Bool
     let isPremium: Bool
     let isPremium: Bool
+    let hasManageableSubscription: Bool
     let onUpgradeTapped: () -> Void
     let onUpgradeTapped: () -> Void
     let onManageSubscriptionTapped: () -> Void
     let onManageSubscriptionTapped: () -> Void
 
 
@@ -30,6 +31,7 @@ struct SidebarView: View {
 
 
             PremiumCardView(
             PremiumCardView(
                 isPremium: isPremium,
                 isPremium: isPremium,
+                hasManageableSubscription: hasManageableSubscription,
                 onUpgradeTapped: onUpgradeTapped,
                 onUpgradeTapped: onUpgradeTapped,
                 onManageSubscriptionTapped: onManageSubscriptionTapped
                 onManageSubscriptionTapped: onManageSubscriptionTapped
             )
             )

+ 66 - 36
gramora/Views/WordsCountView.swift

@@ -7,6 +7,10 @@ struct WordsCountView: View {
     @EnvironmentObject private var subscriptions: SubscriptionManager
     @EnvironmentObject private var subscriptions: SubscriptionManager
     @State private var didPromptWordCountPaywall = false
     @State private var didPromptWordCountPaywall = false
 
 
+    private var hasPremium: Bool {
+        subscriptions.hasPremiumAccess
+    }
+
     var body: some View {
     var body: some View {
         GeometryReader { proxy in
         GeometryReader { proxy in
             let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
             let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
@@ -33,23 +37,10 @@ struct WordsCountView: View {
             .padding(.bottom, dynamicBottomPadding)
             .padding(.bottom, dynamicBottomPadding)
         }
         }
         .clearHostingBackground()
         .clearHostingBackground()
-        .onChange(of: viewModel.text) { newValue in
-            let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
-            if trimmed.isEmpty {
-                didPromptWordCountPaywall = false
+        .onChange(of: subscriptions.hasPremiumAccess) { isPremium in
+            if isPremium {
                 viewModel.handleTextChange()
                 viewModel.handleTextChange()
-                return
-            }
-
-            guard subscriptions.hasPremiumAccess else {
-                if !didPromptWordCountPaywall {
-                    didPromptWordCountPaywall = true
-                    requirePremiumAccess()
-                }
-                return
             }
             }
-
-            viewModel.handleTextChange()
         }
         }
     }
     }
 
 
@@ -85,31 +76,43 @@ struct WordsCountView: View {
         VStack(alignment: .leading, spacing: AppTheme.contentCardSpacing) {
         VStack(alignment: .leading, spacing: AppTheme.contentCardSpacing) {
             YourContentHeader()
             YourContentHeader()
 
 
-            Text(viewModel.wordCountLabel)
+            Text(hasPremium ? viewModel.wordCountLabel : "Words Count")
                 .font(.system(size: 18, weight: .semibold))
                 .font(.system(size: 18, weight: .semibold))
                 .foregroundStyle(AppTheme.textPrimary)
                 .foregroundStyle(AppTheme.textPrimary)
 
 
             statsRow
             statsRow
 
 
             VStack(alignment: .leading, spacing: 6) {
             VStack(alignment: .leading, spacing: 6) {
-                ThinCaretTextEditor(
-                    text: $viewModel.text,
-                    placeholder: "Type or paste your text here to count words...",
-                    maxWords: WordsCountViewModel.maxWords,
-                    onWordLimitReached: viewModel.notifyWordLimitReached
-                )
-                    .font(.system(size: 14))
-                    .foregroundStyle(AppTheme.textPrimary)
-                    .frame(maxWidth: .infinity, maxHeight: .infinity)
-                    .frame(minHeight: 96)
-                    .background(AppTheme.surface)
-                    .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
-                    .overlay(
-                        RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
-                            .stroke(AppTheme.inputBorder, lineWidth: 1)
+                ZStack(alignment: .topLeading) {
+                    ThinCaretTextEditor(
+                        text: premiumGatedText,
+                        placeholder: "Type or paste your text here to count words...",
+                        maxWords: WordsCountViewModel.maxWords,
+                        onWordLimitReached: viewModel.notifyWordLimitReached
                     )
                     )
+                        .font(.system(size: 14))
+                        .foregroundStyle(AppTheme.textPrimary)
+                        .frame(maxWidth: .infinity, maxHeight: .infinity)
+                        .frame(minHeight: 96)
+                        .background(AppTheme.surface)
+                        .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
+                        .overlay(
+                            RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
+                                .stroke(AppTheme.inputBorder, lineWidth: 1)
+                        )
+
+                    if !hasPremium {
+                        RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
+                            .fill(Color.clear)
+                            .contentShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
+                            .onTapGesture {
+                                didPromptWordCountPaywall = false
+                                _ = requirePremiumAccess()
+                            }
+                    }
+                }
 
 
-                Text(viewModel.wordCountLimitLabel)
+                Text(hasPremium ? viewModel.wordCountLimitLabel : "Upgrade to count words")
                     .font(.system(size: 11))
                     .font(.system(size: 11))
                     .foregroundStyle(AppTheme.textMuted)
                     .foregroundStyle(AppTheme.textMuted)
                     .frame(maxWidth: .infinity, alignment: .trailing)
                     .frame(maxWidth: .infinity, alignment: .trailing)
@@ -131,6 +134,7 @@ struct WordsCountView: View {
 
 
                 ClearButton {
                 ClearButton {
                     viewModel.clearText()
                     viewModel.clearText()
+                    didPromptWordCountPaywall = false
                 }
                 }
             }
             }
         }
         }
@@ -145,12 +149,38 @@ struct WordsCountView: View {
         .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
         .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
     }
     }
 
 
+    private var premiumGatedText: Binding<String> {
+        Binding(
+            get: { viewModel.text },
+            set: { newValue in
+                let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
+                if trimmed.isEmpty {
+                    didPromptWordCountPaywall = false
+                    viewModel.text = newValue
+                    viewModel.handleTextChange()
+                    return
+                }
+
+                guard subscriptions.hasPremiumAccess else {
+                    if !didPromptWordCountPaywall {
+                        didPromptWordCountPaywall = true
+                        requirePremiumAccess()
+                    }
+                    return
+                }
+
+                viewModel.text = newValue
+                viewModel.handleTextChange()
+            }
+        )
+    }
+
     private var statsRow: some View {
     private var statsRow: some View {
         HStack(spacing: 12) {
         HStack(spacing: 12) {
-            StatItem(title: "Words", value: "\(viewModel.wordCount)")
-            StatItem(title: "Sentences", value: "\(viewModel.sentenceCount)")
-            StatItem(title: "Paragraphs", value: "\(viewModel.paragraphCount)")
-            StatItem(title: "Characters", value: "\(viewModel.characterCount)")
+            StatItem(title: "Words", value: hasPremium ? "\(viewModel.wordCount)" : "—")
+            StatItem(title: "Sentences", value: hasPremium ? "\(viewModel.sentenceCount)" : "—")
+            StatItem(title: "Paragraphs", value: hasPremium ? "\(viewModel.paragraphCount)" : "—")
+            StatItem(title: "Characters", value: hasPremium ? "\(viewModel.characterCount)" : "—")
             Spacer(minLength: 0)
             Spacer(minLength: 0)
         }
         }
         .padding(.vertical, 6)
         .padding(.vertical, 6)