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

Add premium paywall, subscription flow, and secure API key storage.

Introduces the paywall UI with StoreKit-backed plans, gates premium models behind subscription, and stores the Anthropic API key in Keychain via a gitignored Secrets.xcconfig bootstrap.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 3 hete
szülő
commit
f6650cec48

+ 1 - 0
.gitignore

@@ -1,3 +1,4 @@
 DerivedData/
 .DS_Store
 .cursor/
+Secrets.xcconfig

+ 2 - 0
Secrets.example.xcconfig

@@ -0,0 +1,2 @@
+// Copy to Secrets.xcconfig and add your keys. Secrets.xcconfig is gitignored.
+ANTHROPIC_API_KEY = your-anthropic-api-key-here

+ 6 - 0
clone _of_clarus_ai_chat_bot.xcodeproj/project.pbxproj

@@ -8,6 +8,7 @@
 
 /* Begin PBXFileReference section */
 		27DB15C52FF62D7300DCD7EF /* clone _of_clarus_ai_chat_bot.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "clone _of_clarus_ai_chat_bot.app"; sourceTree = BUILT_PRODUCTS_DIR; };
+		27DB15E02FF62D7500DCD7EF /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
 /* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -33,6 +34,7 @@
 			isa = PBXGroup;
 			children = (
 				27DB15C72FF62D7300DCD7EF /* clone _of_clarus_ai_chat_bot */,
+				27DB15E02FF62D7500DCD7EF /* Secrets.xcconfig */,
 				27DB15C62FF62D7300DCD7EF /* Products */,
 			);
 			sourceTree = "<group>";
@@ -127,6 +129,7 @@
 /* Begin XCBuildConfiguration section */
 		27DB15D12FF62D7400DCD7EF /* Debug */ = {
 			isa = XCBuildConfiguration;
+			baseConfigurationReference = 27DB15E02FF62D7500DCD7EF /* Secrets.xcconfig */;
 			buildSettings = {
 				ALWAYS_SEARCH_USER_PATHS = NO;
 				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
@@ -190,6 +193,7 @@
 		};
 		27DB15D22FF62D7400DCD7EF /* Release */ = {
 			isa = XCBuildConfiguration;
+			baseConfigurationReference = 27DB15E02FF62D7500DCD7EF /* Secrets.xcconfig */;
 			buildSettings = {
 				ALWAYS_SEARCH_USER_PATHS = NO;
 				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
@@ -254,6 +258,7 @@
 				ENABLE_APP_SANDBOX = YES;
 				ENABLE_USER_SELECTED_FILES = readonly;
 				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_KEY_ANTHROPIC_API_KEY = "$(ANTHROPIC_API_KEY)";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
@@ -285,6 +290,7 @@
 				ENABLE_APP_SANDBOX = YES;
 				ENABLE_USER_SELECTED_FILES = readonly;
 				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_KEY_ANTHROPIC_API_KEY = "$(ANTHROPIC_API_KEY)";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;

+ 2 - 0
clone _of_clarus_ai_chat_bot/AppDelegate.swift

@@ -10,6 +10,8 @@ import Cocoa
 @main
 class AppDelegate: NSObject, NSApplicationDelegate {
     func applicationDidFinishLaunching(_ notification: Notification) {
+        APIKeyStore.shared.bootstrapFromBuildConfigurationIfNeeded()
+
         DispatchQueue.main.async {
             guard let window = NSApplication.shared.windows.first else { return }
             WindowConfiguration.applyLaunchConfiguration(to: window)

+ 59 - 0
clone _of_clarus_ai_chat_bot/Managers/APIKeyStore.swift

@@ -0,0 +1,59 @@
+import Foundation
+import os
+
+protocol APIKeyStoring: Sendable {
+    func anthropicAPIKey() throws -> String?
+    func setAnthropicAPIKey(_ key: String) throws
+    func bootstrapFromBuildConfigurationIfNeeded()
+}
+
+final class APIKeyStore: APIKeyStoring {
+    static let shared = APIKeyStore()
+
+    private let keychain: KeychainManager
+    private let logger = Logger(
+        subsystem: Bundle.main.bundleIdentifier ?? "AIChatBot",
+        category: "APIKeyStore"
+    )
+
+    private enum Keys {
+        static let anthropicAccount = "anthropic_api_key"
+        static let buildConfigurationKey = "ANTHROPIC_API_KEY"
+    }
+
+    init(keychain: KeychainManager = .shared) {
+        self.keychain = keychain
+    }
+
+    func anthropicAPIKey() throws -> String? {
+        try keychain.read(for: Keys.anthropicAccount)
+    }
+
+    func setAnthropicAPIKey(_ key: String) throws {
+        let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else { return }
+        try keychain.save(trimmed, for: Keys.anthropicAccount)
+    }
+
+    func bootstrapFromBuildConfigurationIfNeeded() {
+        do {
+            if let existing = try anthropicAPIKey(), !existing.isEmpty {
+                return
+            }
+
+            guard let buildKey = Bundle.main.object(forInfoDictionaryKey: Keys.buildConfigurationKey) as? String else {
+                return
+            }
+
+            let trimmed = buildKey.trimmingCharacters(in: .whitespacesAndNewlines)
+            guard !trimmed.isEmpty, !trimmed.hasPrefix("$(") else {
+                return
+            }
+
+            try setAnthropicAPIKey(trimmed)
+            logger.info("Anthropic API key stored in Keychain.")
+        } catch {
+            logger.error("Failed to store API key: \(error.localizedDescription)")
+        }
+    }
+}

+ 89 - 0
clone _of_clarus_ai_chat_bot/Managers/KeychainManager.swift

@@ -0,0 +1,89 @@
+import Foundation
+import Security
+
+enum KeychainError: LocalizedError {
+    case unexpectedStatus(OSStatus)
+    case dataConversionFailed
+
+    var errorDescription: String? {
+        switch self {
+        case .unexpectedStatus(let status):
+            "Keychain operation failed with status \(status)."
+        case .dataConversionFailed:
+            "Keychain value could not be converted to text."
+        }
+    }
+}
+
+final class KeychainManager: Sendable {
+    static let shared = KeychainManager()
+
+    private let service: String
+
+    init(service: String = Bundle.main.bundleIdentifier ?? "com.aichatbot") {
+        self.service = service
+    }
+
+    func save(_ value: String, for key: String) throws {
+        guard let data = value.data(using: .utf8) else {
+            throw KeychainError.dataConversionFailed
+        }
+
+        let query: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: service,
+            kSecAttrAccount as String: key,
+        ]
+
+        SecItemDelete(query as CFDictionary)
+
+        var attributes = query
+        attributes[kSecValueData as String] = data
+        attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
+
+        let status = SecItemAdd(attributes as CFDictionary, nil)
+        guard status == errSecSuccess else {
+            throw KeychainError.unexpectedStatus(status)
+        }
+    }
+
+    func read(for key: String) throws -> String? {
+        let query: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: service,
+            kSecAttrAccount as String: key,
+            kSecReturnData as String: true,
+            kSecMatchLimit as String: kSecMatchLimitOne,
+        ]
+
+        var item: CFTypeRef?
+        let status = SecItemCopyMatching(query as CFDictionary, &item)
+
+        if status == errSecItemNotFound {
+            return nil
+        }
+
+        guard status == errSecSuccess else {
+            throw KeychainError.unexpectedStatus(status)
+        }
+
+        guard let data = item as? Data, let value = String(data: data, encoding: .utf8) else {
+            throw KeychainError.dataConversionFailed
+        }
+
+        return value
+    }
+
+    func delete(for key: String) throws {
+        let query: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: service,
+            kSecAttrAccount as String: key,
+        ]
+
+        let status = SecItemDelete(query as CFDictionary)
+        guard status == errSecSuccess || status == errSecItemNotFound else {
+            throw KeychainError.unexpectedStatus(status)
+        }
+    }
+}

+ 159 - 0
clone _of_clarus_ai_chat_bot/Managers/SubscriptionManager.swift

@@ -0,0 +1,159 @@
+import Combine
+import Foundation
+import os
+import StoreKit
+
+protocol SubscriptionManaging: AnyObject {
+    var isPremium: Bool { get }
+    func loadProducts() async
+    func price(for plan: SubscriptionPlan) -> String
+    func purchase(plan: SubscriptionPlan) async throws
+    func restorePurchases() async throws
+}
+
+enum SubscriptionError: LocalizedError {
+    case productUnavailable
+    case purchasePending
+    case userCancelled
+    case verificationFailed
+    case noPurchasesToRestore
+
+    var errorDescription: String? {
+        switch self {
+        case .productUnavailable:
+            "This subscription is not available right now. Please try again later."
+        case .purchasePending:
+            "Your purchase is pending approval."
+        case .userCancelled:
+            nil
+        case .verificationFailed:
+            "We could not verify your purchase. Please contact support."
+        case .noPurchasesToRestore:
+            "No previous purchases were found for this Apple ID."
+        }
+    }
+}
+
+@MainActor
+final class SubscriptionManager: ObservableObject, SubscriptionManaging {
+    @Published private(set) var isPremium = false
+    @Published private(set) var productsByPlan: [SubscriptionPlan: Product] = [:]
+    @Published private(set) var isLoadingProducts = false
+
+    private let logger = Logger(
+        subsystem: Bundle.main.bundleIdentifier ?? "AIChatBot",
+        category: "SubscriptionManager"
+    )
+
+    private var updatesTask: Task<Void, Never>?
+
+    init() {
+        updatesTask = Task { [weak self] in
+            await self?.listenForTransactionUpdates()
+        }
+        Task {
+            await refreshEntitlements()
+            await loadProducts()
+        }
+    }
+
+    deinit {
+        updatesTask?.cancel()
+    }
+
+    func loadProducts() async {
+        isLoadingProducts = true
+        defer { isLoadingProducts = false }
+
+        let productIDs = Set(SubscriptionPlan.allCases.map(\.productID))
+
+        do {
+            let products = try await Product.products(for: productIDs)
+            var mapped: [SubscriptionPlan: Product] = [:]
+
+            for plan in SubscriptionPlan.allCases {
+                if let product = products.first(where: { $0.id == plan.productID }) {
+                    mapped[plan] = product
+                }
+            }
+
+            productsByPlan = mapped
+            logger.info("Loaded \(products.count) subscription products")
+        } catch {
+            logger.error("Failed to load products: \(error.localizedDescription)")
+        }
+    }
+
+    func price(for plan: SubscriptionPlan) -> String {
+        if let product = productsByPlan[plan] {
+            return product.displayPrice
+        }
+        return plan.fallbackPrice
+    }
+
+    func purchase(plan: SubscriptionPlan) async throws {
+        guard let product = productsByPlan[plan] else {
+            throw SubscriptionError.productUnavailable
+        }
+
+        let result = try await product.purchase()
+
+        switch result {
+        case .success(let verification):
+            let transaction = try checkVerified(verification)
+            await transaction.finish()
+            await refreshEntitlements()
+        case .userCancelled:
+            throw SubscriptionError.userCancelled
+        case .pending:
+            throw SubscriptionError.purchasePending
+        @unknown default:
+            throw SubscriptionError.verificationFailed
+        }
+    }
+
+    func restorePurchases() async throws {
+        try await AppStore.sync()
+        await refreshEntitlements()
+
+        guard isPremium else {
+            throw SubscriptionError.noPurchasesToRestore
+        }
+    }
+
+    private func listenForTransactionUpdates() async {
+        for await result in Transaction.updates {
+            do {
+                let transaction = try checkVerified(result)
+                await transaction.finish()
+                await refreshEntitlements()
+            } catch {
+                logger.error("Transaction update failed: \(error.localizedDescription)")
+            }
+        }
+    }
+
+    private func refreshEntitlements() async {
+        var hasPremium = false
+
+        for await result in Transaction.currentEntitlements {
+            guard let transaction = try? checkVerified(result) else { continue }
+
+            if SubscriptionPlan.allCases.map(\.productID).contains(transaction.productID) {
+                hasPremium = true
+                break
+            }
+        }
+
+        isPremium = hasPremium
+    }
+
+    private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
+        switch result {
+        case .unverified:
+            throw SubscriptionError.verificationFailed
+        case .verified(let safe):
+            return safe
+        }
+    }
+}

+ 79 - 0
clone _of_clarus_ai_chat_bot/Models/SubscriptionPlan.swift

@@ -0,0 +1,79 @@
+import Foundation
+
+enum SubscriptionPlan: String, CaseIterable, Identifiable, Sendable {
+    case monthly
+    case biYearly
+    case yearly
+
+    var id: String { rawValue }
+
+    var productID: String {
+        switch self {
+        case .monthly:
+            "com.aichatbot.premium.monthly"
+        case .biYearly:
+            "com.aichatbot.premium.biyearly"
+        case .yearly:
+            "com.aichatbot.premium.yearly"
+        }
+    }
+
+    var title: String {
+        switch self {
+        case .monthly:
+            "Monthly"
+        case .biYearly:
+            "Bi-Yearly"
+        case .yearly:
+            "Yearly"
+        }
+    }
+
+    var fallbackPrice: String {
+        switch self {
+        case .monthly:
+            "$18.99"
+        case .biYearly:
+            "$79.99"
+        case .yearly:
+            "$118.99"
+        }
+    }
+
+    var subtitle: String {
+        switch self {
+        case .monthly:
+            "Flexible plan"
+        case .biYearly:
+            "Billed every 6 months"
+        case .yearly:
+            "Best long-term value"
+        }
+    }
+
+    var billingDetail: String? {
+        switch self {
+        case .monthly:
+            nil
+        case .biYearly:
+            nil
+        case .yearly:
+            "$118.99/year"
+        }
+    }
+
+    var savingsBadge: String? {
+        switch self {
+        case .monthly:
+            nil
+        case .biYearly:
+            "Save 30%"
+        case .yearly:
+            "Save 48%"
+        }
+    }
+
+    var isRecommended: Bool {
+        self == .yearly
+    }
+}

+ 8 - 0
clone _of_clarus_ai_chat_bot/Utilities/AppLinks.swift

@@ -0,0 +1,8 @@
+import Foundation
+
+enum AppLinks {
+    static let privacyPolicy = URL(string: "https://example.com/privacy")!
+    static let termsOfUse = URL(string: "https://example.com/terms")!
+    static let support = URL(string: "https://example.com/support")!
+    static let manageSubscriptions = URL(string: "https://apps.apple.com/account/subscriptions")!
+}

+ 7 - 0
clone _of_clarus_ai_chat_bot/Utilities/AppTheme.swift

@@ -45,4 +45,11 @@ enum AppTheme {
     static let quickActionForeground = textPrimary
     static let quickActionBorder = chatInputBorder
     static let quickActionSelectedForeground = Color.white
+
+    static let paywallHeroBackground = Color(red: 0.98, green: 0.97, blue: 0.96)
+    static let paywallCardBorder = Color(red: 0.86, green: 0.68, blue: 0.62)
+    static let paywallSavingsBackground = Color(red: 0.88, green: 0.96, blue: 0.90)
+    static let paywallSavingsText = Color(red: 0.18, green: 0.55, blue: 0.32)
+    static let paywallSuccess = Color(red: 0.18, green: 0.55, blue: 0.32)
+    static let paywallError = Color(red: 0.82, green: 0.22, blue: 0.22)
 }

+ 43 - 0
clone _of_clarus_ai_chat_bot/ViewModels/HomeViewModel.swift

@@ -6,6 +6,10 @@ final class HomeViewModel: ObservableObject {
     @Published var promptText = ""
     @Published var selectedModel = AIModel(name: "Claude-Haiku")
     @Published var selectedQuickActionTitle = "Auto"
+    @Published var isPaywallPresented = false
+    @Published private(set) var paywallViewModel: PaywallViewModel?
+
+    let subscriptionManager: SubscriptionManager
 
     let navigationItems: [SidebarItem] = [
         SidebarItem(title: "History", symbol: "clock"),
@@ -31,4 +35,43 @@ final class HomeViewModel: ObservableObject {
         AIModel(name: "Haiku 3", isPremium: true),
         AIModel(name: "Claude-Haiku")
     ]
+
+    init(subscriptionManager: SubscriptionManager) {
+        self.subscriptionManager = subscriptionManager
+    }
+
+    convenience init() {
+        self.init(subscriptionManager: SubscriptionManager())
+    }
+
+    var isPremium: Bool {
+        subscriptionManager.isPremium
+    }
+
+    func presentPaywall() {
+        paywallViewModel = PaywallViewModel(
+            subscriptionManager: subscriptionManager,
+            onDismiss: { [weak self] in
+                self?.dismissPaywall()
+            },
+            onPurchaseSuccess: { [weak self] in
+                guard let self, self.isPremium else { return }
+                self.dismissPaywall()
+            }
+        )
+        isPaywallPresented = true
+    }
+
+    func dismissPaywall() {
+        isPaywallPresented = false
+        paywallViewModel = nil
+    }
+
+    func selectModel(_ model: AIModel) {
+        if model.isPremium && !isPremium {
+            presentPaywall()
+            return
+        }
+        selectedModel = model
+    }
 }

+ 87 - 0
clone _of_clarus_ai_chat_bot/ViewModels/PaywallViewModel.swift

@@ -0,0 +1,87 @@
+import Combine
+import Foundation
+
+@MainActor
+final class PaywallViewModel: ObservableObject {
+    @Published var selectedPlan: SubscriptionPlan = .yearly
+    @Published var isProcessing = false
+    @Published var statusMessage: String?
+    @Published var errorMessage: String?
+
+    private let subscriptionManager: SubscriptionManager
+    private let onDismiss: () -> Void
+    private let onPurchaseSuccess: () -> Void
+
+    init(
+        subscriptionManager: SubscriptionManager,
+        onDismiss: @escaping () -> Void,
+        onPurchaseSuccess: @escaping () -> Void
+    ) {
+        self.subscriptionManager = subscriptionManager
+        self.onDismiss = onDismiss
+        self.onPurchaseSuccess = onPurchaseSuccess
+    }
+
+    var isPremium: Bool {
+        subscriptionManager.isPremium
+    }
+
+    func price(for plan: SubscriptionPlan) -> String {
+        subscriptionManager.price(for: plan)
+    }
+
+    func selectPlan(_ plan: SubscriptionPlan) {
+        selectedPlan = plan
+        errorMessage = nil
+    }
+
+    func dismiss() {
+        onDismiss()
+    }
+
+    func startPremium() {
+        guard !isProcessing else { return }
+
+        isProcessing = true
+        errorMessage = nil
+        statusMessage = nil
+
+        Task {
+            defer { isProcessing = false }
+
+            do {
+                try await subscriptionManager.purchase(plan: selectedPlan)
+                statusMessage = "Welcome to Premium!"
+                onPurchaseSuccess()
+            } catch let error as SubscriptionError {
+                if case .userCancelled = error { return }
+                errorMessage = error.localizedDescription
+            } catch {
+                errorMessage = error.localizedDescription
+            }
+        }
+    }
+
+    func restorePurchases() {
+        guard !isProcessing else { return }
+
+        isProcessing = true
+        errorMessage = nil
+        statusMessage = nil
+
+        Task {
+            defer { isProcessing = false }
+
+            do {
+                try await subscriptionManager.restorePurchases()
+                statusMessage = "Your purchase has been restored."
+                onPurchaseSuccess()
+            } catch let error as SubscriptionError {
+                if case .userCancelled = error { return }
+                errorMessage = error.localizedDescription
+            } catch {
+                errorMessage = error.localizedDescription
+            }
+        }
+    }
+}

+ 12 - 3
clone _of_clarus_ai_chat_bot/Views/HomeView.swift

@@ -4,10 +4,19 @@ struct HomeView: View {
     @ObservedObject var viewModel: HomeViewModel
 
     var body: some View {
-        HStack(spacing: 0) {
-            SidebarView(viewModel: viewModel)
-            MainContentView(viewModel: viewModel)
+        ZStack {
+            HStack(spacing: 0) {
+                SidebarView(viewModel: viewModel)
+                MainContentView(viewModel: viewModel)
+            }
+
+            if viewModel.isPaywallPresented, let paywallViewModel = viewModel.paywallViewModel {
+                PaywallView(viewModel: paywallViewModel)
+                .transition(.opacity.combined(with: .move(edge: .bottom)))
+                .zIndex(1)
+            }
         }
+        .animation(.easeInOut(duration: 0.25), value: viewModel.isPaywallPresented)
         .frame(minWidth: AppTheme.windowMinWidth, minHeight: AppTheme.windowMinHeight)
         .frame(maxWidth: .infinity, maxHeight: .infinity)
         .background(AppTheme.pageBackground.ignoresSafeArea())

+ 1 - 1
clone _of_clarus_ai_chat_bot/Views/ModelSelectorMenu.swift

@@ -50,7 +50,7 @@ struct ModelSelectorMenu: View {
         VStack(alignment: .leading, spacing: 2) {
             ForEach(viewModel.availableModels) { model in
                 Button {
-                    viewModel.selectedModel = model
+                    viewModel.selectModel(model)
                     isPresented = false
                 } label: {
                     HStack(spacing: 8) {

+ 406 - 0
clone _of_clarus_ai_chat_bot/Views/PaywallView.swift

@@ -0,0 +1,406 @@
+import SwiftUI
+
+struct PaywallView: View {
+    @ObservedObject var viewModel: PaywallViewModel
+    @Environment(\.openURL) private var openURL
+
+    var body: some View {
+        ZStack {
+            AppTheme.pageBackground
+                .ignoresSafeArea()
+
+            VStack(spacing: 0) {
+                header
+                    .padding(.bottom, 24)
+
+                featureHighlights
+                    .padding(.bottom, 16)
+
+                trustHighlights
+                    .padding(.bottom, 20)
+
+                pricingSection
+                    .padding(.bottom, 20)
+
+                ctaSection
+                    .padding(.bottom, 16)
+
+                legalLinks
+
+                Spacer(minLength: 0)
+            }
+            .padding(.horizontal, 48)
+            .padding(.vertical, 28)
+            .frame(maxWidth: 920)
+            .frame(maxWidth: .infinity, maxHeight: .infinity)
+        }
+        .accessibilityElement(children: .contain)
+        .accessibilityLabel("Premium subscription")
+    }
+
+    // MARK: - Header
+
+    private var header: some View {
+        HStack(spacing: 10) {
+            RoundedRectangle(cornerRadius: 10, style: .continuous)
+                .fill(AppTheme.accent)
+                .frame(width: 32, height: 32)
+                .overlay {
+                    Image(systemName: "sparkle")
+                        .font(.system(size: 14, weight: .bold))
+                        .foregroundStyle(.white)
+                }
+
+            Text("AI ChatBot Assistant")
+                .font(.system(size: 17, weight: .bold))
+                .foregroundStyle(AppTheme.textPrimary)
+        }
+        .frame(maxWidth: .infinity, alignment: .center)
+    }
+
+    // MARK: - Feature Highlights
+
+    private var featureHighlights: some View {
+        HStack(spacing: 10) {
+            PaywallFeatureCard(
+                icon: "bubble.left.and.bubble.right.fill",
+                title: "Smarter responses"
+            )
+            PaywallFeatureCard(
+                icon: "bolt.fill",
+                title: "Faster workflow"
+            )
+            PaywallFeatureCard(
+                icon: "sparkles",
+                title: "Upgraded models"
+            )
+        }
+    }
+
+    // MARK: - Pricing
+
+    private var pricingSection: some View {
+        HStack(alignment: .top, spacing: 14) {
+            ForEach(SubscriptionPlan.allCases) { plan in
+                PaywallPlanCard(
+                    plan: plan,
+                    price: viewModel.price(for: plan),
+                    isSelected: viewModel.selectedPlan == plan
+                ) {
+                    viewModel.selectPlan(plan)
+                }
+            }
+        }
+    }
+
+    // MARK: - Trust Highlights
+
+    private var trustHighlights: some View {
+        HStack(spacing: 10) {
+            PaywallTrustCard(
+                icon: "headphones",
+                title: "Priority support",
+                subtitle: "Fast assistance"
+            )
+            PaywallTrustCard(
+                icon: "key.fill",
+                title: "Instant access",
+                subtitle: "Unlock all tools"
+            )
+            PaywallTrustCard(
+                icon: "lock.shield.fill",
+                title: "Secure billing",
+                subtitle: "Handled by Apple"
+            )
+        }
+    }
+
+    // MARK: - CTA
+
+    private var ctaSection: some View {
+        VStack(spacing: 10) {
+            Button(action: viewModel.startPremium) {
+                HStack(spacing: 8) {
+                    if viewModel.isProcessing {
+                        ProgressView()
+                            .controlSize(.small)
+                            .tint(.white)
+                    }
+
+                    Text(viewModel.isProcessing ? "Processing…" : "Start Premium")
+                        .font(.system(size: 16, weight: .bold))
+                }
+                .foregroundStyle(.white)
+                .frame(maxWidth: .infinity)
+                .padding(.vertical, 15)
+                .background(
+                    RoundedRectangle(cornerRadius: AppTheme.cornerRadiusMedium, style: .continuous)
+                        .fill(AppTheme.accent)
+                )
+            }
+            .buttonStyle(.plain)
+            .disabled(viewModel.isProcessing)
+            .accessibilityLabel("Start Premium subscription")
+
+            if let statusMessage = viewModel.statusMessage {
+                Text(statusMessage)
+                    .font(.system(size: 13, weight: .medium))
+                    .foregroundStyle(AppTheme.paywallSuccess)
+            }
+
+            if let errorMessage = viewModel.errorMessage {
+                Text(errorMessage)
+                    .font(.system(size: 13))
+                    .foregroundStyle(AppTheme.paywallError)
+                    .multilineTextAlignment(.center)
+            }
+
+            Text("Secured by Apple. Cancel anytime.")
+                .font(.system(size: 12))
+                .foregroundStyle(AppTheme.textSecondary)
+
+            Text(autoRenewalDisclosure)
+                .font(.system(size: 11))
+                .foregroundStyle(AppTheme.textSecondary.opacity(0.85))
+                .multilineTextAlignment(.center)
+                .lineSpacing(2)
+                .padding(.top, 2)
+        }
+    }
+
+    private var autoRenewalDisclosure: String {
+        let price = viewModel.price(for: viewModel.selectedPlan)
+        let period: String
+        switch viewModel.selectedPlan {
+        case .monthly:
+            period = "month"
+        case .biYearly:
+            period = "6 months"
+        case .yearly:
+            period = "year"
+        }
+        return "Payment will be charged to your Apple ID at confirmation of purchase. Subscription automatically renews unless canceled at least 24 hours before the end of the current period. Your account will be charged \(price) for renewal within 24 hours prior to the end of the current \(period)."
+    }
+
+    // MARK: - Legal Links
+
+    private var legalLinks: some View {
+        HStack(spacing: 16) {
+            PaywallLinkButton(title: "Continue with Free Plan") {
+                viewModel.dismiss()
+            }
+            PaywallLinkButton(title: "Restore Purchase") {
+                viewModel.restorePurchases()
+            }
+            PaywallLinkButton(title: "Privacy Policy") {
+                openURL(AppLinks.privacyPolicy)
+            }
+            PaywallLinkButton(title: "Support") {
+                openURL(AppLinks.support)
+            }
+            PaywallLinkButton(title: "Terms") {
+                openURL(AppLinks.termsOfUse)
+            }
+        }
+        .frame(maxWidth: .infinity)
+    }
+}
+
+// MARK: - Subviews
+
+private struct PaywallFeatureCard: View {
+    let icon: String
+    let title: String
+
+    var body: some View {
+        VStack(spacing: 6) {
+            Image(systemName: icon)
+                .font(.system(size: 15, weight: .semibold))
+                .foregroundStyle(AppTheme.accent)
+
+            Text(title)
+                .font(.system(size: 12, weight: .semibold))
+                .foregroundStyle(AppTheme.textPrimary)
+                .multilineTextAlignment(.center)
+        }
+        .frame(maxWidth: .infinity)
+        .padding(.vertical, 10)
+        .padding(.horizontal, 8)
+        .background(
+            RoundedRectangle(cornerRadius: AppTheme.cornerRadiusMedium, style: .continuous)
+                .fill(AppTheme.paywallHeroBackground)
+        )
+        .overlay(
+            RoundedRectangle(cornerRadius: AppTheme.cornerRadiusMedium, style: .continuous)
+                .stroke(AppTheme.paywallCardBorder, lineWidth: 1)
+        )
+    }
+}
+
+private struct PaywallTrustCard: View {
+    let icon: String
+    let title: String
+    let subtitle: String
+
+    var body: some View {
+        VStack(spacing: 4) {
+            Image(systemName: icon)
+                .font(.system(size: 15, weight: .semibold))
+                .foregroundStyle(AppTheme.accent)
+
+            Text(title)
+                .font(.system(size: 12, weight: .semibold))
+                .foregroundStyle(AppTheme.textPrimary)
+                .multilineTextAlignment(.center)
+
+            Text(subtitle)
+                .font(.system(size: 10))
+                .foregroundStyle(AppTheme.textSecondary)
+                .multilineTextAlignment(.center)
+        }
+        .frame(maxWidth: .infinity)
+        .padding(.vertical, 10)
+        .padding(.horizontal, 8)
+        .background(
+            RoundedRectangle(cornerRadius: AppTheme.cornerRadiusMedium, style: .continuous)
+                .fill(AppTheme.paywallHeroBackground)
+        )
+        .overlay(
+            RoundedRectangle(cornerRadius: AppTheme.cornerRadiusMedium, style: .continuous)
+                .stroke(AppTheme.paywallCardBorder, lineWidth: 1)
+        )
+    }
+}
+
+private struct PaywallPlanCard: View {
+    let plan: SubscriptionPlan
+    let price: String
+    let isSelected: Bool
+    let onSelect: () -> Void
+
+    var body: some View {
+        Button(action: onSelect) {
+            VStack(spacing: 0) {
+                badgeRow
+                    .frame(height: 28)
+                    .padding(.bottom, 8)
+
+                Text(plan.title)
+                    .font(.system(size: 14, weight: .semibold))
+                    .foregroundStyle(AppTheme.textPrimary)
+                    .padding(.bottom, 6)
+
+                Text(price)
+                    .font(.system(size: 28, weight: .bold))
+                    .foregroundStyle(AppTheme.textPrimary)
+                    .padding(.bottom, 4)
+
+                Text(plan.subtitle)
+                    .font(.system(size: 11))
+                    .foregroundStyle(AppTheme.textSecondary)
+                    .multilineTextAlignment(.center)
+                    .padding(.bottom, 8)
+
+                Text(plan.billingDetail ?? "\u{00A0}")
+                    .font(.system(size: 10))
+                    .foregroundStyle(AppTheme.textSecondary.opacity(plan.billingDetail == nil ? 0 : 0.8))
+            }
+            .frame(maxWidth: .infinity)
+            .padding(.horizontal, 12)
+            .padding(.vertical, 16)
+            .background(
+                RoundedRectangle(cornerRadius: AppTheme.cornerRadiusMedium, style: .continuous)
+                    .fill(Color.white)
+            )
+            .overlay(
+                RoundedRectangle(cornerRadius: AppTheme.cornerRadiusMedium, style: .continuous)
+                    .stroke(
+                        isSelected ? AppTheme.accent : AppTheme.paywallCardBorder,
+                        lineWidth: isSelected ? 2 : 1
+                    )
+            )
+        }
+        .buttonStyle(.plain)
+        .accessibilityLabel("\(plan.title), \(price)")
+        .accessibilityAddTraits(isSelected ? .isSelected : [])
+    }
+
+    @ViewBuilder
+    private var badgeRow: some View {
+        HStack(spacing: 6) {
+            if plan.isRecommended {
+                PaywallBadge(text: "Best Value", style: .accent)
+            }
+            if let savings = plan.savingsBadge {
+                PaywallBadge(text: savings, style: .savings)
+            }
+        }
+    }
+}
+
+private struct PaywallBadge: View {
+    enum Style {
+        case accent
+        case savings
+    }
+
+    let text: String
+    let style: Style
+
+    var body: some View {
+        Text(text)
+            .font(.system(size: 9, weight: .bold))
+            .foregroundStyle(foregroundColor)
+            .padding(.horizontal, 8)
+            .padding(.vertical, 4)
+            .background(
+                Capsule()
+                    .fill(backgroundColor)
+            )
+    }
+
+    private var foregroundColor: Color {
+        switch style {
+        case .accent:
+            .white
+        case .savings:
+            AppTheme.paywallSavingsText
+        }
+    }
+
+    private var backgroundColor: Color {
+        switch style {
+        case .accent:
+            AppTheme.accent
+        case .savings:
+            AppTheme.paywallSavingsBackground
+        }
+    }
+}
+
+private struct PaywallLinkButton: View {
+    let title: String
+    let action: () -> Void
+
+    var body: some View {
+        Button(action: action) {
+            Text(title)
+                .font(.system(size: 11))
+                .foregroundStyle(AppTheme.textSecondary)
+                .underline(false)
+        }
+        .buttonStyle(.plain)
+        .accessibilityLabel(title)
+    }
+}
+
+#Preview {
+    PaywallView(
+        viewModel: PaywallViewModel(
+            subscriptionManager: SubscriptionManager(),
+            onDismiss: {},
+            onPurchaseSuccess: {}
+        )
+    )
+    .frame(width: 1080, height: 780)
+}

+ 1 - 1
clone _of_clarus_ai_chat_bot/Views/SidebarView.swift

@@ -136,7 +136,7 @@ struct SidebarView: View {
             .padding(.top, 12)
             .padding(.bottom, 10)
 
-            Button(action: {}) {
+            Button(action: viewModel.presentPaywall) {
                 HStack(spacing: 4) {
                     Text("Upgrade Now")
                         .font(.system(size: 12, weight: .bold))