Эх сурвалжийг харах

Limit free AI uses and intro trials to users who have never purchased premium.

Refunded or lapsed subscribers forfeit the free tier, while sandbox transaction deletion resets eligibility for testing.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 3 долоо хоног өмнө
parent
commit
7bd45c8f13

+ 3 - 2
App AI for Reddit/Managers/AIConfiguration.swift

@@ -20,7 +20,8 @@ enum AIConfiguration {
 
     private static var cachedAPIKey: String?
 
-    static func usesLiveAI(hasPremiumAccess: Bool) -> Bool {
-        hasPremiumAccess && apiKey != nil
+    static func usesLiveAI(hasPremiumAccess: Bool, canUseFreeTrial: Bool = false) -> Bool {
+        guard apiKey != nil else { return false }
+        return hasPremiumAccess || canUseFreeTrial
     }
 }

+ 50 - 0
App AI for Reddit/Managers/AIFreeUsageManager.swift

@@ -0,0 +1,50 @@
+import Foundation
+
+@MainActor
+@Observable
+final class AIFreeUsageManager {
+    static let shared = AIFreeUsageManager()
+    static let maxFreeUses = 3
+
+    private static let usedCountKey = "AIFreeUsageManager.usedCount"
+
+    private(set) var usedCount: Int
+
+    var remainingUses: Int {
+        max(0, Self.maxFreeUses - usedCount)
+    }
+
+    var hasExhaustedFreeUses: Bool {
+        usedCount >= Self.maxFreeUses
+    }
+
+    func canUseFreeLiveAI(hasEverPurchasedPremium: Bool) -> Bool {
+        !hasEverPurchasedPremium && remainingUses > 0
+    }
+
+    private init() {
+        usedCount = UserDefaults.standard.integer(forKey: Self.usedCountKey)
+    }
+
+    func recordUse(for tool: AIHistoryToolKind) {
+        guard usedCount < Self.maxFreeUses else { return }
+        usedCount += 1
+        persistUsedCount()
+    }
+
+    /// Restores the free AI quota for users who have never purchased premium (e.g. sandbox transaction reset).
+    func resetFreeUses() {
+        usedCount = 0
+        persistUsedCount()
+    }
+
+    /// Permanently removes free AI access after a purchase or refund.
+    func forfeitFreeUses() {
+        usedCount = Self.maxFreeUses
+        persistUsedCount()
+    }
+
+    private func persistUsedCount() {
+        UserDefaults.standard.set(usedCount, forKey: Self.usedCountKey)
+    }
+}

+ 51 - 14
App AI for Reddit/Managers/SubscriptionManager.swift

@@ -61,6 +61,14 @@ final class SubscriptionManager: ObservableObject {
         SubscriptionProductID.all.allSatisfy { productsByID[$0] != nil }
     }
 
+    var isEligibleForFreeAITier: Bool {
+        !hasEverPurchasedPremium
+    }
+
+    var isEligibleForIntroTrial: Bool {
+        !hasEverPurchasedPremium && !hasPremiumAccess
+    }
+
     private var inFlightProductLoad: Task<Void, Never>?
     private var initialEntitlementTask: Task<Void, Never>?
     private var entitlementRefreshTask: Task<Void, Never>?
@@ -352,9 +360,14 @@ final class SubscriptionManager: ObservableObject {
         let finalPlan = mergeTrustedPlan(resolvedPlan, trustedPlan)
         activePremiumPlan = finalPlan
         hasPremiumAccess = finalPlan != nil
+
+        if hasPremiumAccess {
+            markAsHavingPurchasedPremium()
+        }
+
         hasResolvedPremiumStatus = true
         persistPremiumCache()
-        await refreshPurchaseHistory()
+        await syncPurchaseHistory()
         await refreshTrialOffers()
     }
 
@@ -403,6 +416,8 @@ final class SubscriptionManager: ObservableObject {
         activePremiumPlan = nil
         hasPremiumAccess = false
         hasResolvedPremiumStatus = true
+        markAsHavingPurchasedPremium()
+        AIFreeUsageManager.shared.forfeitFreeUses()
         persistPremiumCache()
     }
 
@@ -436,15 +451,48 @@ final class SubscriptionManager: ObservableObject {
         hasEverPurchasedPremium = true
         trialDisplayByPlan = [:]
         UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
+        AIFreeUsageManager.shared.forfeitFreeUses()
+    }
+
+    /// Aligns local purchase flags with StoreKit history.
+    /// - Purchases/refunds still on record → user has bought before; no free tier.
+    /// - No transactions at all (sandbox delete) → treat as a new user with 3 free uses.
+    private func syncPurchaseHistory() async {
+        var hasRecordedPurchase = false
+
+        for await result in Transaction.all {
+            guard case .verified(let transaction) = result else { continue }
+            guard SubscriptionProductID.all.contains(transaction.productID) else { continue }
+            hasRecordedPurchase = true
+            break
+        }
+
+        if hasRecordedPurchase {
+            if !hasEverPurchasedPremium {
+                hasEverPurchasedPremium = true
+                UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
+                AIFreeUsageManager.shared.forfeitFreeUses()
+            }
+        } else if hasEverPurchasedPremium {
+            resetToNeverPurchasedState()
+        }
+    }
+
+    private func resetToNeverPurchasedState() {
+        hasEverPurchasedPremium = false
+        UserDefaults.standard.set(false, forKey: Self.cachedHasEverPurchasedKey)
+        UserDefaults.standard.removeObject(forKey: "SubscriptionManager.hadPremiumAccess")
+        AIFreeUsageManager.shared.resetFreeUses()
+        trialDisplayByPlan = [:]
     }
 
     private func refreshTrialOffers() async {
-        guard let eligiblePlan = PaywallConfigService.shared.config.trialEligiblePlan else {
+        guard isEligibleForIntroTrial else {
             trialDisplayByPlan = [:]
             return
         }
 
-        if hasPremiumAccess {
+        guard let eligiblePlan = PaywallConfigService.shared.config.trialEligiblePlan else {
             trialDisplayByPlan = [:]
             return
         }
@@ -474,17 +522,6 @@ final class SubscriptionManager: ObservableObject {
         return nil
     }
 
-    private func refreshPurchaseHistory() async {
-        if hasEverPurchasedPremium { return }
-
-        for await result in Transaction.all {
-            guard case .verified(let transaction) = result else { continue }
-            guard SubscriptionProductID.all.contains(transaction.productID) else { continue }
-            markAsHavingPurchasedPremium()
-            return
-        }
-    }
-
     private func persistPremiumCache() {
         UserDefaults.standard.set(hasPremiumAccess, forKey: Self.cachedPremiumKey)
         if let plan = activePremiumPlan?.rawValue {

+ 2 - 2
App AI for Reddit/Services/CommentGenerationService.swift

@@ -5,8 +5,8 @@ protocol CommentGenerationServiceProtocol: Sendable {
 }
 
 enum CommentGenerationServiceFactory {
-    static func make(hasPremiumAccess: Bool) -> any CommentGenerationServiceProtocol {
-        if hasPremiumAccess, let apiKey = AIConfiguration.apiKey {
+    static func make(usesLiveAI: Bool) -> any CommentGenerationServiceProtocol {
+        if usesLiveAI, let apiKey = AIConfiguration.apiKey {
             return OpenAICommentGenerationService(apiKey: apiKey)
         }
         return MockCommentGenerationService()

+ 2 - 2
App AI for Reddit/Services/PostGenerationService.swift

@@ -5,8 +5,8 @@ protocol PostGenerationServiceProtocol: Sendable {
 }
 
 enum PostGenerationServiceFactory {
-    static func make(hasPremiumAccess: Bool) -> any PostGenerationServiceProtocol {
-        if hasPremiumAccess, let apiKey = AIConfiguration.apiKey {
+    static func make(usesLiveAI: Bool) -> any PostGenerationServiceProtocol {
+        if usesLiveAI, let apiKey = AIConfiguration.apiKey {
             return OpenAIPostGenerationService(apiKey: apiKey)
         }
         return MockPostGenerationService()

+ 2 - 2
App AI for Reddit/Services/TitleOptimizationService.swift

@@ -5,8 +5,8 @@ protocol TitleOptimizationServiceProtocol: Sendable {
 }
 
 enum TitleOptimizationServiceFactory {
-    static func make(hasPremiumAccess: Bool) -> any TitleOptimizationServiceProtocol {
-        if hasPremiumAccess, let apiKey = AIConfiguration.apiKey {
+    static func make(usesLiveAI: Bool) -> any TitleOptimizationServiceProtocol {
+        if usesLiveAI, let apiKey = AIConfiguration.apiKey {
             return OpenAITitleOptimizationService(apiKey: apiKey)
         }
         return MockTitleOptimizationService()

+ 65 - 5
App AI for Reddit/ViewModels/CommentWriterViewModel.swift

@@ -14,26 +14,46 @@ final class CommentWriterViewModel {
     var showOverwriteConfirmation = false
 
     private var hasPremiumAccess = false
+    private var hasEverPurchasedPremium = false
     private let injectedGenerationService: (any CommentGenerationServiceProtocol)?
     private let historyManager: AIHistoryManager
+    private let freeUsageManager: AIFreeUsageManager
+    var onPaywallRequired: (() -> Void)?
+    private(set) var remainingFreeAIUses: Int
     private let emptyDraft = CommentDraft()
 
     init(
         hasPremiumAccess: Bool = false,
         generationService: (any CommentGenerationServiceProtocol)? = nil,
-        historyManager: AIHistoryManager = .shared
+        historyManager: AIHistoryManager = .shared,
+        freeUsageManager: AIFreeUsageManager = .shared
     ) {
         self.hasPremiumAccess = hasPremiumAccess
         self.injectedGenerationService = generationService
         self.historyManager = historyManager
+        self.freeUsageManager = freeUsageManager
+        self.remainingFreeAIUses = freeUsageManager.remainingUses
     }
 
-    func setPremiumAccess(_ hasPremiumAccess: Bool) {
+    func setSubscriptionState(hasPremiumAccess: Bool, hasEverPurchasedPremium: Bool) {
         self.hasPremiumAccess = hasPremiumAccess
+        self.hasEverPurchasedPremium = hasEverPurchasedPremium
+        refreshFreeUsageState()
+    }
+
+    func setPremiumAccess(_ hasPremiumAccess: Bool) {
+        setSubscriptionState(
+            hasPremiumAccess: hasPremiumAccess,
+            hasEverPurchasedPremium: hasEverPurchasedPremium
+        )
+    }
+
+    func refreshFreeUsageState() {
+        remainingFreeAIUses = freeUsageManager.remainingUses
     }
 
     private var generationService: any CommentGenerationServiceProtocol {
-        injectedGenerationService ?? CommentGenerationServiceFactory.make(hasPremiumAccess: hasPremiumAccess)
+        injectedGenerationService ?? CommentGenerationServiceFactory.make(usesLiveAI: usesLiveAI)
     }
 
     var formattedSubreddit: String {
@@ -43,7 +63,27 @@ final class CommentWriterViewModel {
     }
 
     var usesLiveAI: Bool {
-        AIConfiguration.usesLiveAI(hasPremiumAccess: hasPremiumAccess)
+        AIConfiguration.usesLiveAI(
+            hasPremiumAccess: hasPremiumAccess,
+            canUseFreeTrial: freeUsageManager.canUseFreeLiveAI(
+                hasEverPurchasedPremium: hasEverPurchasedPremium
+            )
+        )
+    }
+
+    var generationEngineSubtitle: String {
+        if usesLiveAI {
+            if hasPremiumAccess {
+                return "Write Reddit-ready comments with AI"
+            }
+            let remaining = remainingFreeAIUses
+            let useLabel = remaining == 1 ? "use" : "uses"
+            return "Write Reddit-ready comments with AI (\(remaining) free \(useLabel) left)"
+        }
+        if hasEverPurchasedPremium {
+            return "Upgrade to Premium for unlimited AI generations"
+        }
+        return "Write Reddit-ready comments with AI templates"
     }
 
     var hasDraftChanges: Bool {
@@ -99,6 +139,11 @@ final class CommentWriterViewModel {
     func generateComment() async {
         guard canGenerate else { return }
 
+        if requiresPaywallForGeneration() {
+            onPaywallRequired?()
+            return
+        }
+
         if needsOverwriteConfirmation {
             showOverwriteConfirmation = true
             return
@@ -165,10 +210,17 @@ final class CommentWriterViewModel {
     }
 
     private func performGenerate(replaceExisting: Bool) async {
+        if requiresPaywallForGeneration() {
+            onPaywallRequired?()
+            return
+        }
+
         isGenerating = true
         errorMessage = nil
         successMessage = nil
 
+        let usedLiveAI = usesLiveAI
+
         do {
             let result = try await generationService.generateComment(from: draft)
             if replaceExisting || draft.body.trimmingCharacters(in: .whitespaces).isEmpty {
@@ -177,12 +229,16 @@ final class CommentWriterViewModel {
             variants = result.variants
             selectedVariantID = result.variants.first?.id
             selectedTab = .preview
+            if usedLiveAI, !hasPremiumAccess, !hasEverPurchasedPremium {
+                freeUsageManager.recordUse(for: .commentWriter)
+                refreshFreeUsageState()
+            }
             historyManager.save(AIHistoryEntry.fromCommentWriter(
                 draft: draft,
                 variants: variants,
                 selectedVariantID: selectedVariantID
             ))
-            let engine = usesLiveAI ? "AI" : "local AI templates"
+            let engine = usedLiveAI ? "AI" : "local AI templates"
             successMessage = "Generated comment with \(result.variants.count) variants using \(engine)."
         } catch {
             errorMessage = UserFacingError.message(for: error)
@@ -190,4 +246,8 @@ final class CommentWriterViewModel {
 
         isGenerating = false
     }
+
+    private func requiresPaywallForGeneration() -> Bool {
+        !hasPremiumAccess && (hasEverPurchasedPremium || remainingFreeAIUses == 0)
+    }
 }

+ 88 - 6
App AI for Reddit/ViewModels/PostGeneratorViewModel.swift

@@ -13,9 +13,13 @@ final class PostGeneratorViewModel {
     var showOverwriteConfirmation = false
 
     private var hasPremiumAccess = false
+    private var hasEverPurchasedPremium = false
     private var generationService: any PostGenerationServiceProtocol
     private let injectedGenerationService: (any PostGenerationServiceProtocol)?
     private let historyManager: AIHistoryManager
+    private let freeUsageManager: AIFreeUsageManager
+    var onPaywallRequired: (() -> Void)?
+    private(set) var remainingFreeAIUses: Int
     private var imageAccessURL: URL?
     private var isAccessingImageResource = false
     private let emptyDraft = PostDraft()
@@ -23,22 +27,60 @@ final class PostGeneratorViewModel {
     init(
         hasPremiumAccess: Bool = false,
         generationService: (any PostGenerationServiceProtocol)? = nil,
-        historyManager: AIHistoryManager = .shared
+        historyManager: AIHistoryManager = .shared,
+        freeUsageManager: AIFreeUsageManager = .shared
     ) {
         self.hasPremiumAccess = hasPremiumAccess
         self.injectedGenerationService = generationService
         self.historyManager = historyManager
+        self.freeUsageManager = freeUsageManager
+        self.remainingFreeAIUses = freeUsageManager.remainingUses
+        self.hasEverPurchasedPremium = false
         self.generationService = generationService
-            ?? PostGenerationServiceFactory.make(hasPremiumAccess: hasPremiumAccess)
+            ?? PostGenerationServiceFactory.make(usesLiveAI: Self.liveAIEnabled(
+                hasPremiumAccess: hasPremiumAccess,
+                hasEverPurchasedPremium: false,
+                freeUsageManager: freeUsageManager
+            ))
     }
 
-    func setPremiumAccess(_ hasPremiumAccess: Bool) {
+    func setSubscriptionState(hasPremiumAccess: Bool, hasEverPurchasedPremium: Bool) {
         self.hasPremiumAccess = hasPremiumAccess
+        self.hasEverPurchasedPremium = hasEverPurchasedPremium
+        refreshFreeUsageState()
+    }
+
+    func setPremiumAccess(_ hasPremiumAccess: Bool) {
+        setSubscriptionState(
+            hasPremiumAccess: hasPremiumAccess,
+            hasEverPurchasedPremium: hasEverPurchasedPremium
+        )
+    }
+
+    func refreshFreeUsageState() {
+        remainingFreeAIUses = freeUsageManager.remainingUses
+        refreshGenerationService()
+    }
+
+    private func refreshGenerationService() {
         if injectedGenerationService == nil {
-            generationService = PostGenerationServiceFactory.make(hasPremiumAccess: hasPremiumAccess)
+            generationService = PostGenerationServiceFactory.make(usesLiveAI: usesLiveAI)
         }
     }
 
+    private static func liveAIEnabled(
+        hasPremiumAccess: Bool,
+        hasEverPurchasedPremium: Bool,
+        freeUsageManager: AIFreeUsageManager
+    ) -> Bool {
+        AIConfiguration.usesLiveAI(
+            hasPremiumAccess: hasPremiumAccess,
+            canUseFreeTrial: freeUsageManager.canUseFreeLiveAI(
+                hasEverPurchasedPremium: hasEverPurchasedPremium
+            )
+        )
+    }
+
     var formattedSubreddit: String {
         let name = PostDraftValidator.normalizedSubreddit(draft.subreddit)
         guard !name.isEmpty else { return "r/subreddit" }
@@ -46,7 +88,27 @@ final class PostGeneratorViewModel {
     }
 
     var usesLiveAI: Bool {
-        AIConfiguration.usesLiveAI(hasPremiumAccess: hasPremiumAccess)
+        AIConfiguration.usesLiveAI(
+            hasPremiumAccess: hasPremiumAccess,
+            canUseFreeTrial: freeUsageManager.canUseFreeLiveAI(
+                hasEverPurchasedPremium: hasEverPurchasedPremium
+            )
+        )
+    }
+
+    var generationEngineSubtitle: String {
+        if usesLiveAI {
+            if hasPremiumAccess {
+                return "Create Reddit-ready posts with AI"
+            }
+            let remaining = remainingFreeAIUses
+            let useLabel = remaining == 1 ? "use" : "uses"
+            return "Create Reddit-ready posts with AI (\(remaining) free \(useLabel) left)"
+        }
+        if hasEverPurchasedPremium {
+            return "Upgrade to Premium for unlimited AI generations"
+        }
+        return "Create Reddit-ready posts with AI templates"
     }
 
     var hasDraftChanges: Bool {
@@ -127,6 +189,11 @@ final class PostGeneratorViewModel {
     func generatePost() async {
         guard canGenerate else { return }
 
+        if requiresPaywallForGeneration() {
+            onPaywallRequired?()
+            return
+        }
+
         if needsOverwriteConfirmation {
             showOverwriteConfirmation = true
             return
@@ -226,16 +293,27 @@ final class PostGeneratorViewModel {
     }
 
     private func performGenerate(replaceExisting: Bool) async {
+        if requiresPaywallForGeneration() {
+            onPaywallRequired?()
+            return
+        }
+
         isGenerating = true
         errorMessage = nil
         successMessage = nil
         await Task.yield()
 
+        let usedLiveAI = usesLiveAI
+
         do {
             let result = try await generationService.generatePost(from: draft)
             applyGeneratedPost(result, replaceExisting: replaceExisting)
             selectedTab = .preview
-            let engine = usesLiveAI ? "AI" : "local AI templates"
+            if usedLiveAI, !hasPremiumAccess, !hasEverPurchasedPremium {
+                freeUsageManager.recordUse(for: .postGenerator)
+                refreshFreeUsageState()
+            }
+            let engine = usedLiveAI ? "AI" : "local AI templates"
             successMessage = "Post generated successfully using \(engine)."
             let entry = AIHistoryEntry.fromPostGenerator(draft: draft)
             Task { @MainActor in
@@ -268,6 +346,10 @@ final class PostGeneratorViewModel {
         }
     }
 
+    private func requiresPaywallForGeneration() -> Bool {
+        !hasPremiumAccess && (hasEverPurchasedPremium || remainingFreeAIUses == 0)
+    }
+
     private func releaseImageAccess() {
         if isAccessingImageResource, let imageAccessURL {
             imageAccessURL.stopAccessingSecurityScopedResource()

+ 83 - 6
App AI for Reddit/ViewModels/TitleOptimizerViewModel.swift

@@ -14,31 +14,93 @@ final class TitleOptimizerViewModel {
     var selectedVariantID: UUID?
 
     private var hasPremiumAccess = false
+    private var hasEverPurchasedPremium = false
     private var optimizationService: any TitleOptimizationServiceProtocol
     private let injectedOptimizationService: (any TitleOptimizationServiceProtocol)?
     private let historyManager: AIHistoryManager
+    private let freeUsageManager: AIFreeUsageManager
+    var onPaywallRequired: (() -> Void)?
+    private(set) var remainingFreeAIUses: Int
 
     init(
         hasPremiumAccess: Bool = false,
         optimizationService: (any TitleOptimizationServiceProtocol)? = nil,
-        historyManager: AIHistoryManager = .shared
+        historyManager: AIHistoryManager = .shared,
+        freeUsageManager: AIFreeUsageManager = .shared
     ) {
         self.hasPremiumAccess = hasPremiumAccess
         self.injectedOptimizationService = optimizationService
         self.historyManager = historyManager
+        self.freeUsageManager = freeUsageManager
+        self.remainingFreeAIUses = freeUsageManager.remainingUses
+        self.hasEverPurchasedPremium = false
         self.optimizationService = optimizationService
-            ?? TitleOptimizationServiceFactory.make(hasPremiumAccess: hasPremiumAccess)
+            ?? TitleOptimizationServiceFactory.make(usesLiveAI: Self.liveAIEnabled(
+                hasPremiumAccess: hasPremiumAccess,
+                hasEverPurchasedPremium: false,
+                freeUsageManager: freeUsageManager
+            ))
     }
 
-    func setPremiumAccess(_ hasPremiumAccess: Bool) {
+    func setSubscriptionState(hasPremiumAccess: Bool, hasEverPurchasedPremium: Bool) {
         self.hasPremiumAccess = hasPremiumAccess
+        self.hasEverPurchasedPremium = hasEverPurchasedPremium
+        refreshFreeUsageState()
+    }
+
+    func setPremiumAccess(_ hasPremiumAccess: Bool) {
+        setSubscriptionState(
+            hasPremiumAccess: hasPremiumAccess,
+            hasEverPurchasedPremium: hasEverPurchasedPremium
+        )
+    }
+
+    func refreshFreeUsageState() {
+        remainingFreeAIUses = freeUsageManager.remainingUses
+        refreshOptimizationService()
+    }
+
+    private func refreshOptimizationService() {
         if injectedOptimizationService == nil {
-            optimizationService = TitleOptimizationServiceFactory.make(hasPremiumAccess: hasPremiumAccess)
+            optimizationService = TitleOptimizationServiceFactory.make(usesLiveAI: usesLiveAI)
         }
     }
 
+    private static func liveAIEnabled(
+        hasPremiumAccess: Bool,
+        hasEverPurchasedPremium: Bool,
+        freeUsageManager: AIFreeUsageManager
+    ) -> Bool {
+        AIConfiguration.usesLiveAI(
+            hasPremiumAccess: hasPremiumAccess,
+            canUseFreeTrial: freeUsageManager.canUseFreeLiveAI(
+                hasEverPurchasedPremium: hasEverPurchasedPremium
+            )
+        )
+    }
+
     var usesLiveAI: Bool {
-        AIConfiguration.usesLiveAI(hasPremiumAccess: hasPremiumAccess)
+        AIConfiguration.usesLiveAI(
+            hasPremiumAccess: hasPremiumAccess,
+            canUseFreeTrial: freeUsageManager.canUseFreeLiveAI(
+                hasEverPurchasedPremium: hasEverPurchasedPremium
+            )
+        )
+    }
+
+    var generationEngineSubtitle: String {
+        if usesLiveAI {
+            if hasPremiumAccess {
+                return "Craft viral Reddit titles with AI"
+            }
+            let remaining = remainingFreeAIUses
+            let useLabel = remaining == 1 ? "use" : "uses"
+            return "Craft viral Reddit titles with AI (\(remaining) free \(useLabel) left)"
+        }
+        if hasEverPurchasedPremium {
+            return "Upgrade to Premium for unlimited AI generations"
+        }
+        return "Craft viral Reddit titles with AI templates"
     }
 
     var formattedSubreddit: String {
@@ -116,18 +178,29 @@ final class TitleOptimizerViewModel {
             return
         }
 
+        if requiresPaywallForGeneration() {
+            onPaywallRequired?()
+            return
+        }
+
         isOptimizing = true
         errorMessage = nil
         successMessage = nil
         await Task.yield()
         defer { isOptimizing = false }
 
+        let usedLiveAI = usesLiveAI
+
         do {
             let result = try await optimizationService.optimizeTitles(from: draft)
             analysis = result.analysis
             variants = result.variants
             selectedVariantID = result.variants.first?.id
-            let engine = usesLiveAI ? "AI" : "local AI templates"
+            if usedLiveAI, !hasPremiumAccess, !hasEverPurchasedPremium {
+                freeUsageManager.recordUse(for: .titleOptimizer)
+                refreshFreeUsageState()
+            }
+            let engine = usedLiveAI ? "AI" : "local AI templates"
             successMessage = "Generated \(result.variants.count) title suggestions using \(engine)."
             let entry = AIHistoryEntry.fromTitleOptimizer(
                 draft: draft,
@@ -207,4 +280,8 @@ final class TitleOptimizerViewModel {
         errorMessage = nil
         successMessage = nil
     }
+
+    private func requiresPaywallForGeneration() -> Bool {
+        !hasPremiumAccess && (hasEverPurchasedPremium || remainingFreeAIUses == 0)
+    }
 }

+ 1 - 1
App AI for Reddit/Views/CommentWriterView.swift

@@ -74,7 +74,7 @@ struct CommentWriterView: View {
                         }
                     }
 
-                    Text(viewModel.usesLiveAI ? "Write Reddit-ready comments with AI" : "Write Reddit-ready comments with AI templates")
+                    Text(viewModel.generationEngineSubtitle)
                         .font(.system(size: 11))
                         .foregroundStyle(AppTheme.textSecondary)
                 }

+ 32 - 7
App AI for Reddit/Views/FrontPageView.swift

@@ -5,6 +5,7 @@ struct FrontPageView: View {
     @EnvironmentObject private var appRating: AppRatingManager
     @Environment(AppearanceManager.self) private var appearanceManager
     @Bindable var session: AppSessionState
+    @Bindable private var freeUsageManager = AIFreeUsageManager.shared
 
     private var viewModel: FrontPageViewModel { session.frontPage }
     private var paywallViewModel: PaywallViewModel { session.paywall }
@@ -56,20 +57,27 @@ struct FrontPageView: View {
         .frame(minWidth: AppWindow.minWidth, minHeight: AppWindow.minHeight)
         .onChange(of: subscriptions.hasPremiumAccess) { _, hasPremium in
             viewModel.dismissPaywall()
-            syncToolPremiumAccess()
+            syncToolSubscriptionState()
             appRating.updatePremiumStatus(hasPremium)
             if subscriptions.hasResolvedPremiumStatus {
                 configureRedditAccessForPremiumStatus()
             }
         }
+        .onChange(of: subscriptions.hasEverPurchasedPremium) { _, _ in
+            syncToolSubscriptionState()
+        }
         .onChange(of: subscriptions.hasResolvedPremiumStatus) { _, resolved in
             if resolved {
-                syncToolPremiumAccess()
+                syncToolSubscriptionState()
                 configureRedditAccessForPremiumStatus()
             }
         }
+        .onChange(of: freeUsageManager.usedCount) { _, _ in
+            syncToolSubscriptionState()
+        }
         .onAppear {
-            syncToolPremiumAccess()
+            syncToolSubscriptionState()
+            configureToolPaywallHandlers()
             appRating.updatePremiumStatus(subscriptions.hasPremiumAccess)
             if subscriptions.hasResolvedPremiumStatus {
                 configureRedditAccessForPremiumStatus()
@@ -160,11 +168,28 @@ struct FrontPageView: View {
         }
     }
 
-    private func syncToolPremiumAccess() {
+    private func syncToolSubscriptionState() {
         let hasPremium = subscriptions.hasPremiumAccess
-        postGeneratorViewModel.setPremiumAccess(hasPremium)
-        titleOptimizerViewModel.setPremiumAccess(hasPremium)
-        commentWriterViewModel.setPremiumAccess(hasPremium)
+        let hasEverPurchased = subscriptions.hasEverPurchasedPremium
+        postGeneratorViewModel.setSubscriptionState(
+            hasPremiumAccess: hasPremium,
+            hasEverPurchasedPremium: hasEverPurchased
+        )
+        titleOptimizerViewModel.setSubscriptionState(
+            hasPremiumAccess: hasPremium,
+            hasEverPurchasedPremium: hasEverPurchased
+        )
+        commentWriterViewModel.setSubscriptionState(
+            hasPremiumAccess: hasPremium,
+            hasEverPurchasedPremium: hasEverPurchased
+        )
+    }
+
+    private func configureToolPaywallHandlers() {
+        let showPaywall = { [viewModel] in viewModel.showPaywall() }
+        postGeneratorViewModel.onPaywallRequired = showPaywall
+        titleOptimizerViewModel.onPaywallRequired = showPaywall
+        commentWriterViewModel.onPaywallRequired = showPaywall
     }
 
     private func configureRedditAccessForPremiumStatus() {

+ 1 - 1
App AI for Reddit/Views/PostGeneratorView.swift

@@ -91,7 +91,7 @@ struct PostGeneratorView: View {
                         }
                     }
 
-                    Text(viewModel.usesLiveAI ? "Create Reddit-ready posts with AI" : "Create Reddit-ready posts with AI templates")
+                    Text(viewModel.generationEngineSubtitle)
                         .font(.system(size: 11))
                         .foregroundStyle(AppTheme.textSecondary)
                 }

+ 1 - 1
App AI for Reddit/Views/TitleOptimizerView.swift

@@ -57,7 +57,7 @@ struct TitleOptimizerView: View {
                     Text("Title Optimizer")
                         .font(.system(size: 18, weight: .semibold))
                         .foregroundStyle(AppTheme.textPrimary)
-                    Text(viewModel.usesLiveAI ? "Craft viral Reddit titles with AI" : "Craft viral Reddit titles with AI templates")
+                    Text(viewModel.generationEngineSubtitle)
                         .font(.system(size: 11))
                         .foregroundStyle(AppTheme.textSecondary)
                 }