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

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 hete
szülő
commit
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)