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

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 hasManageableSubscription = 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 subscriptionSuccessMessage: String?
+    @Published var showsCancelSubscriptionReminder = false
+    @Published private(set) var recurringPlanPendingCancellation: PaywallPlan?
 
     var hasAllProductsLoaded: Bool {
         SubscriptionProductID.all.allSatisfy { productsByID[$0] != nil }
     }
 
     private var inFlightProductLoad: Task<Void, Never>?
+    private var cancellables = Set<AnyCancellable>()
 
     init() {
         Task { await listenForTransactionUpdates() }
         Task { await refreshPremiumAccess() }
         Task { await loadProducts(presentLoadingUI: false) }
+        observeAppLifecycle()
     }
 
     func loadProducts(presentLoadingUI: Bool? = nil) async {
@@ -188,6 +195,18 @@ final class SubscriptionManager: ObservableObject {
     func purchase(_ plan: PaywallPlan) async -> Bool {
         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]
         if product == nil {
             await loadProducts(presentLoadingUI: false)
@@ -214,6 +233,9 @@ final class SubscriptionManager: ObservableObject {
         purchasingPlan = plan
         defer { purchasingPlan = nil }
 
+        let shouldRemindToCancelSubscription = plan == .lifetime && hasManageableSubscription
+        let recurringPlanBeforePurchase = activeRecurringPlan ?? activePlan
+
         do {
             let result = try await product.purchase()
             switch result {
@@ -222,16 +244,25 @@ final class SubscriptionManager: ObservableObject {
                     purchaseError = .purchaseNotVerified
                     return false
                 }
-                NotificationCenter.default.post(name: .subscriptionPurchased, object: nil)
                 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:
                 return false
             case .pending:
                 purchaseError = .purchasePending
                 return false
             @unknown default:
+                purchaseError = .generic
                 return false
             }
         } catch {
@@ -247,10 +278,16 @@ final class SubscriptionManager: ObservableObject {
 
     func restorePurchases() async {
         purchaseError = nil
+        subscriptionSuccessMessage = nil
+        let hadPremium = hasPremiumAccess
         do {
             try await AppStore.sync()
             await refreshPremiumAccess()
-            if !hasPremiumAccess {
+            if hasPremiumAccess {
+                if !hadPremium {
+                    subscriptionSuccessMessage = "Your purchases have been restored."
+                }
+            } else {
                 purchaseError = .noActiveSubscriptions
             }
         } 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 {
         let missingAny = SubscriptionProductID.all.contains { productsByID[$0] == nil }
         let shouldShowLoading = presentLoadingUI ?? missingAny
@@ -319,74 +373,99 @@ final class SubscriptionManager: ObservableObject {
     private func refreshPremiumAccess() async {
         var hasPremium = false
         var manageableSubscription = false
+        var resolvedPlan: PaywallPlan?
+        var resolvedRecurringPlan: PaywallPlan?
 
         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
-            }
+            guard let plan = SubscriptionProductID.plan(for: transaction.productID) else { continue }
 
-            hasPremium = true
             if SubscriptionProductID.recurring.contains(transaction.productID) {
+                guard await isRecurringSubscriptionEntitled(productID: transaction.productID) else {
+                    continue
+                }
                 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
         hasManageableSubscription = manageableSubscription
+        activePlan = resolvedPlan
+        activeRecurringPlan = resolvedRecurringPlan
         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 {
         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 {
-    static let defaultValue: () -> Bool = { true }
+    static let defaultValue: () -> Bool = { false }
 }
 
 extension EnvironmentValues {

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

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

+ 5 - 0
gramora/Views/MainView.swift

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

+ 107 - 6
gramora/Views/PaywallView.swift

@@ -22,6 +22,31 @@ struct PaywallView: View {
         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 {
         if isPremium {
             return subscriptions.hasManageableSubscription ? "Manage Subscription" : "Close"
@@ -49,7 +74,11 @@ struct PaywallView: View {
             ZStack {
                 VStack(spacing: 0) {
                     VStack(spacing: verticalSpacing) {
-                        titleSection
+                        if isPremium {
+                            premiumTitleSection
+                        } else {
+                            titleSection
+                        }
                         featuresSection(featureHeight: featureHeight)
                         plansSection(planHeight: planHeight)
                         ctaSection
@@ -78,6 +107,9 @@ struct PaywallView: View {
         .clearHostingBackground()
         .task {
             await subscriptions.loadProducts(presentLoadingUI: true)
+            if subscriptions.hasManageableSubscription {
+                viewModel.selectPlan(.lifetime)
+            }
         }
         .alert(
             "Subscriptions",
@@ -92,6 +124,69 @@ struct PaywallView: View {
         } 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 {
@@ -154,15 +249,21 @@ struct PaywallView: View {
 
     private var ctaSection: some View {
         VStack(spacing: 14) {
-            Text(subscriptions.billingDescription(for: viewModel.selectedPlan))
+            Text(ctaDescription)
                 .font(.system(size: 13, weight: .medium))
                 .foregroundStyle(Color(red: 0.47, green: 0.51, blue: 0.57))
+                .multilineTextAlignment(.center)
 
             Button {
                 Task {
                     let purchased = await subscriptions.purchase(viewModel.selectedPlan)
                     if purchased {
-                        onClose()
+                        if subscriptions.showsCancelSubscriptionReminder {
+                            return
+                        }
+                        if subscriptions.hasPremiumAccess {
+                            onClose()
+                        }
                     }
                 }
             } 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)
             .shadow(
                 color: isCTAHovered ? AppTheme.primaryShadow.opacity(0.32) : .clear,
@@ -216,7 +317,7 @@ struct PaywallView: View {
             if !isSelectedPlanLifetime {
                 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("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)
                 .font(.system(size: 12, weight: .medium))

+ 114 - 0
gramora/Views/SettingsView.swift

@@ -2,6 +2,7 @@ import SwiftUI
 
 struct SettingsView: View {
     @StateObject private var viewModel: SettingsViewModel
+    @EnvironmentObject private var subscriptions: SubscriptionManager
 
     let onClose: () -> Void
 
@@ -53,6 +54,32 @@ struct SettingsView: View {
         } message: {
             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 {
@@ -72,6 +99,7 @@ struct SettingsView: View {
     private var settingsCard: some View {
         ScrollView(.vertical, showsIndicators: false) {
             VStack(alignment: .leading, spacing: 24) {
+                subscriptionSection
                 appearanceSection
                 dataSection
                 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 {
         SettingsSectionCard(title: "Appearance", iconName: "paintbrush.fill") {
             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 {
     let iconName: String
     let title: String

+ 2 - 0
gramora/Views/SidebarView.swift

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

+ 66 - 36
gramora/Views/WordsCountView.swift

@@ -7,6 +7,10 @@ struct WordsCountView: View {
     @EnvironmentObject private var subscriptions: SubscriptionManager
     @State private var didPromptWordCountPaywall = false
 
+    private var hasPremium: Bool {
+        subscriptions.hasPremiumAccess
+    }
+
     var body: some View {
         GeometryReader { proxy in
             let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
@@ -33,23 +37,10 @@ struct WordsCountView: View {
             .padding(.bottom, dynamicBottomPadding)
         }
         .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()
-                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) {
             YourContentHeader()
 
-            Text(viewModel.wordCountLabel)
+            Text(hasPremium ? viewModel.wordCountLabel : "Words Count")
                 .font(.system(size: 18, weight: .semibold))
                 .foregroundStyle(AppTheme.textPrimary)
 
             statsRow
 
             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))
                     .foregroundStyle(AppTheme.textMuted)
                     .frame(maxWidth: .infinity, alignment: .trailing)
@@ -131,6 +134,7 @@ struct WordsCountView: View {
 
                 ClearButton {
                     viewModel.clearText()
+                    didPromptWordCountPaywall = false
                 }
             }
         }
@@ -145,12 +149,38 @@ struct WordsCountView: View {
         .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 {
         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)
         }
         .padding(.vertical, 6)