import Cocoa import StoreKit // MARK: - StoreKit enum StoreProductID { static let monthly = AppStoreConfig.ProductID.monthly static let yearly = AppStoreConfig.ProductID.yearly static let lifetime = AppStoreConfig.ProductID.lifetime static let all = AppStoreConfig.ProductID.all static func plan(for productID: String) -> PaywallPlan? { switch productID { case monthly: .monthly case yearly: .yearly case lifetime: .lifetime default: nil } } } enum PremiumAccessKind: String { case none case subscription case lifetime } enum StoreError: Error { case productNotFound case failedVerification } enum PurchaseOutcome: Sendable { case purchased case cancelled case pending } @MainActor final class StoreManager { static let shared = StoreManager() private enum ProductLoadError: Error { case timeout } private enum StoreSyncError: Error { case timeout } private static let cachedPremiumKey = "StoreManager.cachedIsPremium" private static let cachedPremiumPlanKey = "StoreManager.cachedPremiumPlan" private static let cachedHasEverPurchasedKey = "StoreManager.cachedHasEverPurchased" private static let backgroundRefreshInterval: TimeInterval = 120 private(set) var productsByID: [String: Product] = [:] private(set) var isPremium = UserDefaults.standard.bool(forKey: cachedPremiumKey) private(set) var activePremiumPlan: PaywallPlan? private(set) var hasEverPurchasedPremium = UserDefaults.standard.bool(forKey: cachedHasEverPurchasedKey) private(set) var trialDisplayByPlan: [PaywallPlan: PaywallTrialDisplay] = [:] private(set) var purchaseError: PurchaseError? var isPro: Bool { isPremium } var hasActiveSubscription: Bool { activePremiumPlan == .monthly || activePremiumPlan == .yearly } var hasLifetimeAccess: Bool { activePremiumPlan == .lifetime } var premiumAccessKind: PremiumAccessKind { switch activePremiumPlan { case .lifetime: .lifetime case .monthly, .yearly: .subscription case nil: .none } } var products: [Product] { availablePlans.compactMap { productsByID[$0.productID] } } var availablePlans: [PaywallPlan] { PaywallPlan.displayOrder.filter { productsByID[$0.productID] != nil } } var isResolvingEntitlements: Bool { !hasResolvedPremiumStatus } var hasResolvedPremiumStatus = false var hasAvailablePlans: Bool { !availablePlans.isEmpty } var hasAllProductsLoaded: Bool { hasAvailablePlans } private(set) var isLoadingProducts = false private(set) var productLoadError: String? private(set) var purchasingPlan: PaywallPlan? private(set) var isRestoringPurchases = false var isPurchasing: Bool { purchasingPlan != nil || isRestoringPurchases } private var inFlightProductLoad: Task? private var transactionListener: Task? private var initialEntitlementTask: Task? private var entitlementRefreshTask: Task? private var hasStarted = false private var lastBackgroundEntitlementRefresh: Date? private var appActiveObserver: NSObjectProtocol? private init() { migrateLegacyPremiumCacheIfNeeded() hasEverPurchasedPremium = UserDefaults.standard.bool(forKey: Self.cachedHasEverPurchasedKey) if UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) { isPremium = true activePremiumPlan = restoredCachedPremiumPlan() } } private func migrateLegacyPremiumCacheIfNeeded() { guard UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) else { return } guard UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey) == nil, let legacyKind = UserDefaults.standard.string(forKey: "StoreManager.cachedPremiumAccessKind") else { return } let migratedPlan: String? switch legacyKind { case PremiumAccessKind.lifetime.rawValue: migratedPlan = PaywallPlan.lifetime.rawValue case PremiumAccessKind.subscription.rawValue: migratedPlan = PaywallPlan.yearly.rawValue default: migratedPlan = nil } if let migratedPlan { UserDefaults.standard.set(migratedPlan, forKey: Self.cachedPremiumPlanKey) } } private func restoredCachedPremiumPlan() -> PaywallPlan? { guard UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) else { return nil } if let raw = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey), let plan = PaywallPlan(rawValue: raw) { return plan } if let legacyKind = UserDefaults.standard.string(forKey: "StoreManager.cachedPremiumAccessKind") { switch legacyKind { case PremiumAccessKind.lifetime.rawValue: return .lifetime case PremiumAccessKind.subscription.rawValue: return .yearly default: break } } return .yearly } func start() { guard !hasStarted else { return } hasStarted = true observeAppLifecycle() transactionListener = Task { [weak self] in await self?.listenForTransactionUpdates() } initialEntitlementTask = Task { [weak self] in guard let self else { return } await self.refreshPremiumAccess() await self.loadProducts(presentLoadingUI: false) } } private func observeAppLifecycle() { appActiveObserver = NotificationCenter.default.addObserver( forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main ) { [weak self] _ in Task { @MainActor in self?.refreshEntitlementsOnAppActive() } } } private func refreshEntitlementsOnAppActive() { guard hasStarted else { return } if let lastRefresh = lastBackgroundEntitlementRefresh, Date().timeIntervalSince(lastRefresh) < Self.backgroundRefreshInterval { return } entitlementRefreshTask?.cancel() entitlementRefreshTask = Task { await refreshPremiumAccess() lastBackgroundEntitlementRefresh = Date() } } func ensureEntitlementsResolved() async { await initialEntitlementTask?.value } func ensurePaywallStoreDataLoaded(presentLoadingUI: Bool = true) async { await ensureEntitlementsResolved() await loadProducts(presentLoadingUI: presentLoadingUI && !hasAllProductsLoaded) await refreshTrialOffers() } func product(for plan: PaywallPlan) -> Product? { productsByID[plan.productID] } func trialDisplay(for plan: PaywallPlan) -> PaywallTrialDisplay? { trialDisplayByPlan[plan] } func clearPurchaseError() { purchaseError = nil } var isEligibleForIntroTrial: Bool { !hasEverPurchasedPremium && !isPremium } func loadProducts(presentLoadingUI: Bool? = nil) async { if let existing = inFlightProductLoad { await existing.value return } let uiFlag = presentLoadingUI let task = Task { @MainActor in await performLoadProducts(presentLoadingUI: uiFlag) } inFlightProductLoad = task await task.value inFlightProductLoad = nil } @discardableResult func purchase(plan: PaywallPlan) async -> Bool { purchaseError = nil var product = productsByID[plan.productID] if product == nil { await loadProducts(presentLoadingUI: false) product = productsByID[plan.productID] } guard let product else { purchaseError = products.isEmpty ? .subscriptionUnavailable : .planUnavailable return false } purchasingPlan = plan postStoreStateDidChange() defer { purchasingPlan = nil postStoreStateDidChange() } do { let result = try await product.purchase() switch result { case .success(let verification): guard case .verified(let transaction) = verification else { purchaseError = .purchaseNotVerified return false } let trustedPlan = planGrantingPremium(from: transaction) if let trustedPlan { applyPremiumAccess(from: transaction) } await transaction.finish() await reconcilePremiumAccess(trustedPlan: trustedPlan) return isPremium case .userCancelled: return false case .pending: purchaseError = .purchasePending return false @unknown default: return false } } catch { purchaseError = .generic return false } } @discardableResult func restorePurchases() async -> Bool { purchaseError = nil isRestoringPurchases = true postStoreStateDidChange() defer { isRestoringPurchases = false postStoreStateDidChange() } do { try await syncAppStoreWithTimeout(seconds: 30) } catch is StoreSyncError { purchaseError = .restoreSyncTimeout return false } catch { purchaseError = .generic return false } await reconcilePremiumAccess(trustedPlan: nil) await syncPurchaseHistory(allowReset: true) if !isPremium { purchaseError = .noActivePurchases } return isPremium } func showAlert(title: String, message: String, on window: NSWindow?) { let alert = NSAlert() alert.messageText = title alert.informativeText = message alert.alertStyle = .informational alert.addButton(withTitle: PaywallConfigService.shared.config.messages.alertOK) if let window { alert.beginSheetModal(for: window) } else { alert.runModal() } } func showManageSubscriptions() { let urlString = PaywallConfigService.shared.config.urls.manageSubscriptions guard let url = URL(string: urlString) else { return } NSWorkspace.shared.open(url) } func showPurchaseError(_ error: PurchaseError, on window: NSWindow?) { let config = PaywallConfigService.shared.config showAlert( title: config.messages.purchaseFailedTitle, message: config.message(for: error), on: window ) } private func performLoadProducts(presentLoadingUI: Bool?) async { let shouldReload = presentLoadingUI == true || productsByID.isEmpty let shouldShowLoading = presentLoadingUI ?? productsByID.isEmpty if !shouldReload, presentLoadingUI != true { purchaseError = nil await refreshPremiumAccess() await refreshTrialOffers() postStoreStateDidChange() return } if shouldShowLoading { isLoadingProducts = true postStoreStateDidChange() } defer { if shouldShowLoading { isLoadingProducts = false postStoreStateDidChange() } } purchaseError = nil productLoadError = nil do { let loaded = try await loadProductsWithTimeout(seconds: 12) guard !loaded.isEmpty else { productLoadError = PaywallConfigService.shared.config.messages.noPlansAvailable productsByID = [:] await refreshTrialOffers() return } var map: [String: Product] = [:] for product in loaded { map[product.id] = product } productsByID = map productLoadError = nil NotificationCenter.default.post(name: .storeProductsDidUpdate, object: nil) await refreshPremiumAccess() await refreshTrialOffers() } catch is ProductLoadError { productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed purchaseError = .productsLoadTimeout await refreshTrialOffers() } catch { productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed purchaseError = .generic await refreshTrialOffers() } } private func loadProductsWithTimeout(seconds: TimeInterval) async throws -> [Product] { try await withThrowingTaskGroup(of: [Product].self) { group in group.addTask { try await Product.products(for: StoreProductID.all) } group.addTask { let timeoutNanoseconds = UInt64(seconds * 1_000_000_000) try await Task.sleep(nanoseconds: timeoutNanoseconds) throw ProductLoadError.timeout } guard let first = try await group.next() else { throw ProductLoadError.timeout } group.cancelAll() return first } } private func refreshPremiumAccess(trustedPlan: PaywallPlan? = nil) async { var resolvedPlan: PaywallPlan? if let lifetimePlan = await activeLifetimePlan() { resolvedPlan = lifetimePlan } if resolvedPlan == nil { for await result in Transaction.currentEntitlements { guard case .verified(let transaction) = result else { continue } guard let plan = planGrantingPremium(from: transaction) else { continue } resolvedPlan = preferredPlan(resolvedPlan, plan) } } if resolvedPlan == nil || resolvedPlan != .lifetime { if let subscriptionPlan = await activeSubscriptionPlan() { resolvedPlan = preferredPlan(resolvedPlan, subscriptionPlan) } } if resolvedPlan == nil { if let statusPlan = await activePlanFromSubscriptionStatus() { resolvedPlan = statusPlan } } let finalPlan = mergeTrustedPlan(resolvedPlan, trustedPlan) applyPremiumStatus(plan: finalPlan) hasResolvedPremiumStatus = true NotificationCenter.default.post(name: .entitlementsDidResolve, object: nil) await syncPurchaseHistory(allowReset: false) await refreshTrialOffers() } private func reconcilePremiumAccess(trustedPlan: PaywallPlan?) async { await refreshPremiumAccess(trustedPlan: trustedPlan) guard let trustedPlan, !isPremium else { return } do { try await syncAppStoreWithTimeout(seconds: 15) } catch { applyTrustedPlan(trustedPlan) return } await refreshPremiumAccess(trustedPlan: trustedPlan) if !isPremium { applyTrustedPlan(trustedPlan) } } private func mergeTrustedPlan(_ resolvedPlan: PaywallPlan?, _ trustedPlan: PaywallPlan?) -> PaywallPlan? { switch (resolvedPlan, trustedPlan) { case (nil, nil): nil case (let resolved?, nil): resolved case (nil, let trusted?): trusted case (let resolved?, let trusted?): preferredPlan(resolved, trusted) } } private func applyTrustedPlan(_ plan: PaywallPlan) { activePremiumPlan = preferredPlan(activePremiumPlan, plan) applyPremiumStatus(plan: activePremiumPlan) hasResolvedPremiumStatus = true markAsHavingPurchasedPremium() } private func revokePremiumAccess() { applyPremiumStatus(plan: nil) hasResolvedPremiumStatus = true trialDisplayByPlan = [:] } private func syncAppStoreWithTimeout(seconds: TimeInterval) async throws { try await withThrowingTaskGroup(of: Void.self) { group in group.addTask { try await AppStore.sync() } group.addTask { let timeoutNanoseconds = UInt64(seconds * 1_000_000_000) try await Task.sleep(nanoseconds: timeoutNanoseconds) throw StoreSyncError.timeout } try await group.next() group.cancelAll() } } private func applyPremiumAccess(from transaction: StoreKit.Transaction) { guard let plan = planGrantingPremium(from: transaction) else { return } activePremiumPlan = preferredPlan(activePremiumPlan, plan) applyPremiumStatus(plan: activePremiumPlan) hasResolvedPremiumStatus = true markAsHavingPurchasedPremium() } private func markAsHavingPurchasedPremium() { guard !hasEverPurchasedPremium else { return } hasEverPurchasedPremium = true trialDisplayByPlan = [:] UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey) } private func syncPurchaseHistory(allowReset: Bool) async { var hasRecordedPurchase = false for await result in Transaction.all { guard case .verified(let transaction) = result else { continue } guard StoreProductID.all.contains(transaction.productID) else { continue } hasRecordedPurchase = true break } if hasRecordedPurchase { if !hasEverPurchasedPremium { hasEverPurchasedPremium = true UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey) } } else if allowReset, hasEverPurchasedPremium { resetToNeverPurchasedState() } } private func resetToNeverPurchasedState() { hasEverPurchasedPremium = false UserDefaults.standard.set(false, forKey: Self.cachedHasEverPurchasedKey) trialDisplayByPlan = [:] } private func refreshTrialOffers() async { guard !isPremium else { trialDisplayByPlan = [:] postStoreStateDidChange() return } guard let eligiblePlan = PaywallConfigService.shared.config.trialEligiblePlan else { trialDisplayByPlan = [:] postStoreStateDidChange() return } if let display = await resolveTrialDisplay(for: eligiblePlan) { trialDisplayByPlan = [eligiblePlan: display] } else { trialDisplayByPlan = [:] } postStoreStateDidChange() } private func resolveTrialDisplay(for plan: PaywallPlan) async -> PaywallTrialDisplay? { guard plan == PaywallConfigService.shared.config.trialEligiblePlan else { return nil } guard !isPremium else { return nil } let config = PaywallConfigService.shared.config guard let product = product(for: plan), let subscription = product.subscription else { return fallbackTrialDisplay(config: config) } guard await subscription.isEligibleForIntroOffer else { return nil } if let offer = subscription.introductoryOffer { return PaywallTrialDisplay.from(offer: offer, config: config) } return fallbackTrialDisplay(config: config) } private func fallbackTrialDisplay(config: PaywallConfig) -> PaywallTrialDisplay? { guard !isPremium else { return nil } guard let fallback = config.trial.fallbackDuration else { return nil } return PaywallTrialDisplay.from(fallback: fallback, config: config) } private func applyPremiumStatus(plan: PaywallPlan?) { let hasPremium = plan != nil let kind: PremiumAccessKind switch plan { case .lifetime: kind = .lifetime case .monthly, .yearly: kind = .subscription case nil: kind = .none } let previousKind = premiumAccessKind let previousPlan = activePremiumPlan let didChange = hasPremium != isPremium || kind != previousKind || plan != previousPlan isPremium = hasPremium activePremiumPlan = plan UserDefaults.standard.set(hasPremium, forKey: Self.cachedPremiumKey) if let plan { UserDefaults.standard.set(plan.rawValue, forKey: Self.cachedPremiumPlanKey) } else { UserDefaults.standard.removeObject(forKey: Self.cachedPremiumPlanKey) } if didChange { NotificationCenter.default.post(name: .premiumStatusDidChange, object: nil) } postStoreStateDidChange() } private func planGrantingPremium(from transaction: StoreKit.Transaction) -> PaywallPlan? { guard transactionGrantsPremium(transaction) else { return nil } return StoreProductID.plan(for: transaction.productID) } private func transactionGrantsPremium(_ transaction: StoreKit.Transaction) -> Bool { guard StoreProductID.all.contains(transaction.productID) else { return false } guard transaction.revocationDate == nil else { return false } if let expirationDate = transaction.expirationDate, expirationDate < Date() { return false } return true } private func preferredPlan(_ current: PaywallPlan?, _ candidate: PaywallPlan) -> PaywallPlan { if current == .lifetime || candidate == .lifetime { return .lifetime } if current == .yearly || candidate == .yearly { return .yearly } if current == .monthly || candidate == .monthly { return .monthly } return candidate } private func activeLifetimePlan() async -> PaywallPlan? { guard let result = await Transaction.latest(for: StoreProductID.lifetime) else { return nil } guard case .verified(let transaction) = result else { return nil } return planGrantingPremium(from: transaction) } private func activeSubscriptionPlan() async -> PaywallPlan? { var resolvedPlan: PaywallPlan? for await result in Transaction.currentEntitlements { guard case .verified(let transaction) = result else { continue } guard let plan = planGrantingPremium(from: transaction), plan != .lifetime else { continue } resolvedPlan = preferredPlan(resolvedPlan, plan) } return resolvedPlan } private func activePlanFromSubscriptionStatus() async -> PaywallPlan? { let productIDs = [StoreProductID.monthly, StoreProductID.yearly] for productID in productIDs { let product: Product? if let cached = productsByID[productID] { product = cached } else { product = try? await Product.products(for: [productID]).first } guard let product, let subscription = product.subscription, let statuses = try? await subscription.status else { continue } for status in statuses { switch status.state { case .subscribed, .inGracePeriod, .inBillingRetryPeriod: return StoreProductID.plan(for: productID) default: continue } } } return nil } private func listenForTransactionUpdates() async { for await update in Transaction.updates { guard case .verified(let transaction) = update else { continue } if isRevokedAppTransaction(transaction) { revokePremiumAccess() await transaction.finish() await refreshPremiumAccess() continue } let trustedPlan = planGrantingPremium(from: transaction) if let trustedPlan { applyPremiumAccess(from: transaction) } await transaction.finish() if trustedPlan != nil { await reconcilePremiumAccess(trustedPlan: trustedPlan) } else { await refreshPremiumAccess() } } } private func isRevokedAppTransaction(_ transaction: StoreKit.Transaction) -> Bool { StoreProductID.all.contains(transaction.productID) && transaction.revocationDate != nil } private func postStoreStateDidChange() { NotificationCenter.default.post(name: .storeStateDidChange, object: nil) } } extension Notification.Name { static let premiumStatusDidChange = Notification.Name("premiumStatusDidChange") static let entitlementsDidResolve = Notification.Name("entitlementsDidResolve") static let storeProductsDidUpdate = Notification.Name("storeProductsDidUpdate") static let storeStateDidChange = Notification.Name("storeStateDidChange") } // MARK: - Premium Access enum PremiumAccess { @MainActor static func canAccess(_ feature: AppFeature) -> Bool { let store = StoreManager.shared return FreeTierManager.canAccess(feature, isPremium: store.isPremium) } @MainActor @discardableResult static func require( feature: AppFeature, from window: NSWindow?, returnToHomeOnDismiss: Bool = false ) -> Bool { guard canAccess(feature) else { presentPaywallOrLimitAlert(for: feature, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss) return false } return true } @MainActor @discardableResult static func requireForAction( feature: AppFeature, from window: NSWindow?, returnToHomeOnDismiss: Bool = false, recordFreeUse: Bool = true ) -> Bool { guard require(feature: feature, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss) else { return false } if recordFreeUse { recordFreeUseIfNeeded() } return true } @MainActor static func recordFreeUseIfNeeded() { guard !StoreManager.shared.isPremium else { return } FreeTierManager.recordUseIfNeeded(isPremium: false) } @MainActor @discardableResult static func require(from window: NSWindow?, returnToHomeOnDismiss: Bool = false) -> Bool { require(feature: .documentImport, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss) } @MainActor private static func presentPaywallOrLimitAlert( for feature: AppFeature, from window: NSWindow?, returnToHomeOnDismiss: Bool ) { if StoreManager.shared.isResolvingEntitlements { Task { @MainActor in await StoreManager.shared.ensureEntitlementsResolved() guard !canAccess(feature) else { return } presentPaywallOrLimitAlert(for: feature, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss) } return } guard let viewController = mainViewController(from: window) else { return } viewController.presentPaywall(returnToHomeOnDismiss: returnToHomeOnDismiss) } @MainActor static func mainViewController(from window: NSWindow?) -> ViewController? { if let viewController = window?.contentViewController as? ViewController { return viewController } if let viewController = NSApp.keyWindow?.contentViewController as? ViewController { return viewController } for appWindow in NSApp.windows where appWindow.canBecomeMain { if let viewController = appWindow.contentViewController as? ViewController { return viewController } } return nil } } // MARK: - Left Panel private final class PaywallLeftPanelView: NSView, AppearanceRefreshable { private let gradientLayer = CAGradientLayer() override init(frame frameRect: NSRect) { super.init(frame: frameRect) wantsLayer = true gradientLayer.startPoint = CGPoint(x: 0.5, y: 1) gradientLayer.endPoint = CGPoint(x: 0.5, y: 0) layer?.insertSublayer(gradientLayer, at: 0) refreshAppearance() } func refreshAppearance() { gradientLayer.colors = AppTheme.paywallLeftGradientColors.map(\.cgColor) } @available(*, unavailable) required init?(coder: NSCoder) { nil } override func layout() { super.layout() gradientLayer.frame = bounds let mask = CAShapeLayer() mask.path = CGPath( roundedRect: bounds, cornerWidth: 20, cornerHeight: 20, transform: nil ) layer?.mask = mask } } // MARK: - Badge private final class PaywallBadgeView: NSView { private let label: NSTextField init(text: String, iconName: String, background: NSColor, foreground: NSColor) { label = NSTextField(labelWithString: text) super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false wantsLayer = true layer?.backgroundColor = background.cgColor layer?.cornerRadius = 10 layer?.masksToBounds = true let icon = NSImageView() icon.translatesAutoresizingMaskIntoConstraints = false if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) { let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .semibold) icon.image = image.withSymbolConfiguration(config) } icon.contentTintColor = foreground label.font = AppTheme.semiboldFont(size: 10) label.textColor = foreground label.translatesAutoresizingMaskIntoConstraints = false addSubview(icon) addSubview(label) NSLayoutConstraint.activate([ heightAnchor.constraint(equalToConstant: 20), icon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), icon.centerYAnchor.constraint(equalTo: centerYAnchor), icon.widthAnchor.constraint(equalToConstant: 12), icon.heightAnchor.constraint(equalToConstant: 12), label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 4), label.centerYAnchor.constraint(equalTo: centerYAnchor), label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), ]) } func updateText(_ text: String) { label.stringValue = text } @available(*, unavailable) required init?(coder: NSCoder) { nil } } // MARK: - Feature Row private final class PaywallFeatureRow: NSView, AppearanceRefreshable { private let label: NSTextField init(text: String) { label = NSTextField.themeLabel(text, style: .primary, font: AppTheme.regularFont(size: 14)) super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false let checkContainer = NSView() checkContainer.translatesAutoresizingMaskIntoConstraints = false checkContainer.wantsLayer = true checkContainer.layer?.backgroundColor = AppTheme.green.cgColor checkContainer.layer?.cornerRadius = 10 checkContainer.layer?.masksToBounds = true let checkIcon = NSImageView() checkIcon.translatesAutoresizingMaskIntoConstraints = false if let image = NSImage(systemSymbolName: "checkmark", accessibilityDescription: nil) { let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .bold) checkIcon.image = image.withSymbolConfiguration(config) } checkIcon.contentTintColor = .white label.translatesAutoresizingMaskIntoConstraints = false addSubview(checkContainer) checkContainer.addSubview(checkIcon) addSubview(label) NSLayoutConstraint.activate([ heightAnchor.constraint(equalToConstant: 28), checkContainer.leadingAnchor.constraint(equalTo: leadingAnchor), checkContainer.centerYAnchor.constraint(equalTo: centerYAnchor), checkContainer.widthAnchor.constraint(equalToConstant: 20), checkContainer.heightAnchor.constraint(equalToConstant: 20), checkIcon.centerXAnchor.constraint(equalTo: checkContainer.centerXAnchor), checkIcon.centerYAnchor.constraint(equalTo: checkContainer.centerYAnchor), checkIcon.widthAnchor.constraint(equalToConstant: 12), checkIcon.heightAnchor.constraint(equalToConstant: 12), label.leadingAnchor.constraint(equalTo: checkContainer.trailingAnchor, constant: 12), label.centerYAnchor.constraint(equalTo: centerYAnchor), label.trailingAnchor.constraint(equalTo: trailingAnchor), ]) } func updateText(_ text: String) { label.stringValue = text } @available(*, unavailable) required init?(coder: NSCoder) { nil } func refreshAppearance() { label.refreshThemeLabelColor() } } // MARK: - Plan Card private final class PaywallPlanCard: NSControl, AppearanceRefreshable { var onSelect: (() -> Void)? private let plan: PaywallPlan private let titleLabel = NSTextField(labelWithString: "") private let subtitleLabel = NSTextField(labelWithString: "") private let priceLabel = NSTextField(labelWithString: "") private var trialBadgeView: PaywallBadgeView? private var hoverTracker: HoverTracker? private var isHovered = false var isChosen: Bool = false { didSet { updateAppearance() } } init(plan: PaywallPlan) { self.plan = plan super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false wantsLayer = true layer?.cornerRadius = 12 layer?.masksToBounds = false let config = PaywallConfigService.shared.config titleLabel.stringValue = plan.planCopy(from: config).title titleLabel.font = AppTheme.semiboldFont(size: 15) titleLabel.translatesAutoresizingMaskIntoConstraints = false subtitleLabel.stringValue = config.loadingPrice subtitleLabel.font = AppTheme.regularFont(size: 11) subtitleLabel.themeLabelStyle = .secondary subtitleLabel.textColor = AppTheme.textSecondary subtitleLabel.translatesAutoresizingMaskIntoConstraints = false priceLabel.stringValue = config.loadingPrice priceLabel.font = AppTheme.semiboldFont(size: 15) priceLabel.alignment = .right priceLabel.translatesAutoresizingMaskIntoConstraints = false addSubview(titleLabel) addSubview(subtitleLabel) addSubview(priceLabel) NSLayoutConstraint.activate([ heightAnchor.constraint(equalToConstant: 86), titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 24), subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 3), subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: priceLabel.leadingAnchor, constant: -12), priceLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), priceLabel.centerYAnchor.constraint(equalTo: centerYAnchor), ]) applyCardShadow() updateAppearance() hoverTracker = HoverTracker(view: self) { [weak self] hovering in self?.setHovered(hovering) } } @available(*, unavailable) required init?(coder: NSCoder) { nil } func updateDisplay( product: Product?, config: PaywallConfig, trial: PaywallTrialDisplay? = nil ) { titleLabel.stringValue = plan.planCopy(from: config).title subtitleLabel.stringValue = plan.localizedSubtitle(from: product, config: config, trial: trial) priceLabel.stringValue = plan.localizedPrice(from: product, config: config) updateTrialBadge(trial: trial) } private func updateTrialBadge(trial: PaywallTrialDisplay?) { guard plan == PaywallConfigService.shared.config.trialEligiblePlan else { return } if let trial { let text = trial.badgeText if let trialBadgeView { trialBadgeView.updateText(text) trialBadgeView.isHidden = false } else { let badge = PaywallBadgeView( text: text, iconName: "calendar", background: AppTheme.paywallPink, foreground: AppTheme.paywallPinkText ) trialBadgeView = badge addSubview(badge, positioned: .above, relativeTo: nil) NSLayoutConstraint.activate([ badge.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12), badge.topAnchor.constraint(equalTo: topAnchor, constant: 8), ]) } trialBadgeView?.layer?.zPosition = 10 } else { trialBadgeView?.isHidden = true } } func refreshAppearance() { updateAppearance() subtitleLabel.refreshThemeLabelColor() if isHovered { applyHoverLift(true) } } private func setHovered(_ hovering: Bool) { isHovered = hovering applyHoverLift(hovering) updateAppearance() } private func updateAppearance() { let trialPlan = PaywallConfigService.shared.config.trialEligiblePlan let titleColor = isChosen && plan == trialPlan ? AppTheme.green : AppTheme.paywallAccent titleLabel.textColor = titleColor priceLabel.textColor = titleColor layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor if isChosen { layer?.borderWidth = 2 layer?.borderColor = AppTheme.green.cgColor } else if isHovered { layer?.borderWidth = 2 let hoverBorder = AppTheme.paywallBorder.blended(withFraction: 0.35, of: AppTheme.paywallAccent) ?? AppTheme.paywallBorder layer?.borderColor = hoverBorder.cgColor } else { layer?.borderWidth = 1.5 layer?.borderColor = AppTheme.paywallBorder.cgColor } } override func mouseUp(with event: NSEvent) { guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return } onSelect?() } override func resetCursorRects() { addCursorRect(bounds, cursor: .pointingHand) } } // MARK: - Footer Link private final class PaywallFooterLink: NSButton, AppearanceRefreshable { private var hoverTracker: HoverTracker? private var isHovered = false init(title: String) { super.init(frame: .zero) updateTitle(title) isBordered = false bezelStyle = .inline font = AppTheme.regularFont(size: 11) translatesAutoresizingMaskIntoConstraints = false refreshAppearance() hoverTracker = HoverTracker(view: self) { [weak self] hovering in self?.isHovered = hovering self?.refreshAppearance() } } @available(*, unavailable) required init?(coder: NSCoder) { nil } func updateTitle(_ title: String) { self.title = title invalidateIntrinsicContentSize() needsDisplay = true } func refreshAppearance() { contentTintColor = isHovered ? AppTheme.textPrimary : AppTheme.textSecondary } override func resetCursorRects() { addCursorRect(bounds, cursor: .pointingHand) } } // MARK: - Footer Trust Item private final class PaywallTrustItemView: NSView, AppearanceRefreshable { private let iconContainer = NSView() private let icon = NSImageView() private let titleLabel: NSTextField private let subtitleLabel: NSTextField init(iconName: String, title: String, subtitle: String) { titleLabel = NSTextField.themeLabel(title, style: .primary, font: AppTheme.semiboldFont(size: 11)) subtitleLabel = NSTextField.themeLabel(subtitle, style: .secondary, font: AppTheme.regularFont(size: 9)) super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false iconContainer.translatesAutoresizingMaskIntoConstraints = false iconContainer.wantsLayer = true iconContainer.layer?.cornerRadius = 10 iconContainer.layer?.masksToBounds = true icon.translatesAutoresizingMaskIntoConstraints = false if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) { let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold) icon.image = image.withSymbolConfiguration(config) } titleLabel.lineBreakMode = .byTruncatingTail titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) titleLabel.translatesAutoresizingMaskIntoConstraints = false subtitleLabel.lineBreakMode = .byTruncatingTail subtitleLabel.maximumNumberOfLines = 2 subtitleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) subtitleLabel.translatesAutoresizingMaskIntoConstraints = false addSubview(iconContainer) iconContainer.addSubview(icon) addSubview(titleLabel) addSubview(subtitleLabel) NSLayoutConstraint.activate([ heightAnchor.constraint(equalToConstant: Self.preferredHeight), iconContainer.leadingAnchor.constraint(equalTo: leadingAnchor), iconContainer.topAnchor.constraint(equalTo: topAnchor), iconContainer.widthAnchor.constraint(equalToConstant: 20), iconContainer.heightAnchor.constraint(equalToConstant: 20), icon.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor), icon.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor), icon.widthAnchor.constraint(equalToConstant: 12), icon.heightAnchor.constraint(equalToConstant: 12), titleLabel.leadingAnchor.constraint(equalTo: iconContainer.trailingAnchor, constant: 8), titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 1), titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor), subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 2), subtitleLabel.trailingAnchor.constraint(equalTo: trailingAnchor), subtitleLabel.bottomAnchor.constraint(equalTo: bottomAnchor), ]) setContentCompressionResistancePriority(.defaultLow, for: .horizontal) setContentHuggingPriority(.defaultLow, for: .horizontal) setContentHuggingPriority(.required, for: .vertical) setContentCompressionResistancePriority(.required, for: .vertical) refreshAppearance() } fileprivate static let preferredHeight: CGFloat = 34 func update(iconName: String, title: String, subtitle: String) { titleLabel.stringValue = title subtitleLabel.stringValue = subtitle if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) { let symbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold) icon.image = image.withSymbolConfiguration(symbolConfig) } } @available(*, unavailable) required init?(coder: NSCoder) { nil } func refreshAppearance() { iconContainer.layer?.backgroundColor = AppTheme.paywallTrustIconBackground.cgColor icon.contentTintColor = AppTheme.paywallIconAccent titleLabel.refreshThemeLabelColor() subtitleLabel.refreshThemeLabelColor() } } // MARK: - Products Error private final class PaywallProductsErrorView: NSView, AppearanceRefreshable { var onRetry: (() -> Void)? private let messageLabel = NSTextField(wrappingLabelWithString: "") private let retryButton = PaywallFooterLink(title: "") init() { super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false messageLabel.font = AppTheme.regularFont(size: 12) messageLabel.textColor = AppTheme.textSecondary messageLabel.alignment = .center messageLabel.maximumNumberOfLines = 3 messageLabel.lineBreakMode = .byWordWrapping messageLabel.cell?.wraps = true messageLabel.cell?.isScrollable = false messageLabel.translatesAutoresizingMaskIntoConstraints = false retryButton.target = self retryButton.action = #selector(retryTapped) addSubview(messageLabel) addSubview(retryButton) NSLayoutConstraint.activate([ messageLabel.leadingAnchor.constraint(equalTo: leadingAnchor), messageLabel.trailingAnchor.constraint(equalTo: trailingAnchor), messageLabel.topAnchor.constraint(equalTo: topAnchor), retryButton.centerXAnchor.constraint(equalTo: centerXAnchor), retryButton.topAnchor.constraint(equalTo: messageLabel.bottomAnchor, constant: 8), retryButton.bottomAnchor.constraint(equalTo: bottomAnchor), ]) } @available(*, unavailable) required init?(coder: NSCoder) { nil } func update(message: String, isRetrying: Bool) { let messages = PaywallConfigService.shared.config.messages messageLabel.stringValue = message retryButton.isHidden = false retryButton.isEnabled = !isRetrying retryButton.updateTitle(isRetrying ? messages.retrying : messages.retry) } func refreshAppearance() { messageLabel.textColor = AppTheme.textSecondary retryButton.refreshAppearance() } @objc private func retryTapped() { onRetry?() } } // MARK: - Close Button private final class PaywallCloseButton: NSButton, AppearanceRefreshable { private var hoverTracker: HoverTracker? private var isHovered = false init() { super.init(frame: .zero) isBordered = false bezelStyle = .inline translatesAutoresizingMaskIntoConstraints = false wantsLayer = true layer?.cornerRadius = 15 layer?.borderWidth = 1.5 if let image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close") { let config = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold) self.image = image.withSymbolConfiguration(config) } refreshAppearance() hoverTracker = HoverTracker(view: self) { [weak self] hovering in self?.setHovered(hovering) } } @available(*, unavailable) required init?(coder: NSCoder) { nil } func refreshAppearance() { updateAppearance() } private func setHovered(_ hovering: Bool) { isHovered = hovering animateHover { updateAppearance() layer?.transform = hovering ? CATransform3DMakeScale(1.06, 1.06, 1) : CATransform3DIdentity } } private func updateAppearance() { if isHovered { layer?.backgroundColor = AppTheme.elevatedBackground.cgColor layer?.borderColor = AppTheme.paywallAccent.withAlphaComponent(0.45).cgColor contentTintColor = AppTheme.textPrimary } else { layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor layer?.borderColor = AppTheme.paywallBorder.cgColor contentTintColor = AppTheme.textSecondary } } override func resetCursorRects() { addCursorRect(bounds, cursor: .pointingHand) } } // MARK: - CTA Button private final class PaywallCTAButton: NSButton, AppearanceRefreshable { private var hoverTracker: HoverTracker? private let progressIndicator = NSProgressIndicator() init() { super.init(frame: .zero) isBordered = false wantsLayer = true layer?.cornerRadius = 12 font = AppTheme.semiboldFont(size: 15) translatesAutoresizingMaskIntoConstraints = false progressIndicator.style = .spinning progressIndicator.controlSize = .small progressIndicator.isDisplayedWhenStopped = false progressIndicator.translatesAutoresizingMaskIntoConstraints = false progressIndicator.isHidden = true addSubview(progressIndicator) NSLayoutConstraint.activate([ progressIndicator.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), progressIndicator.centerYAnchor.constraint(equalTo: centerYAnchor), ]) refreshAppearance() hoverTracker = HoverTracker(view: self) { [weak self] hovering in self?.setHovered(hovering) } } @available(*, unavailable) required init?(coder: NSCoder) { nil } func setShowsProgress(_ showsProgress: Bool) { progressIndicator.isHidden = !showsProgress if showsProgress { progressIndicator.startAnimation(nil) } else { progressIndicator.stopAnimation(nil) } } func refreshAppearance() { layer?.backgroundColor = AppTheme.paywallCTABackground.cgColor contentTintColor = AppTheme.paywallCTAForeground } private func setHovered(_ hovering: Bool) { let base = AppTheme.paywallCTABackground let color = hovering ? base.blended(withFraction: 0.12, of: .black) ?? base : base animateHover { layer?.backgroundColor = color.cgColor layer?.transform = hovering ? CATransform3DMakeScale(1.02, 1.02, 1) : CATransform3DIdentity } } override func resetCursorRects() { addCursorRect(bounds, cursor: .pointingHand) } } // MARK: - Main Paywall Card final class PaywallView: NSView, AppearanceRefreshable { var onClose: (() -> Void)? private var selectedPlan: PaywallPlan = .yearly private var planCards: [PaywallPlan: PaywallPlanCard] = [:] private let ctaButton = PaywallCTAButton() private let renewalDisclosureLabel = NSTextField(wrappingLabelWithString: "") private let securePaymentLabel = NSTextField(labelWithString: "") private let continueFreePlanLink = PaywallFooterLink(title: "") private let manageSubscriptionLink = PaywallFooterLink(title: "") private let restoreLink = PaywallFooterLink(title: "") private let privacyLink = PaywallFooterLink(title: "") private let termsLink = PaywallFooterLink(title: "") private let supportLink = PaywallFooterLink(title: "") private let premiumCloseButton = PaywallCloseButton() private var primaryFooterLinkCell: NSView? private var leftPanelTitle: NSTextField! private var rightTitle: NSTextField! private var rightSubtitle: NSTextField! private var featuresStack: NSStackView! private var featureRows: [PaywallFeatureRow] = [] private var trustItemViews: [PaywallTrustItemView] = [] private var trustStack: NSStackView! private var productsErrorView: PaywallProductsErrorView! private var plansStack: NSStackView! private var plansStackHeightConstraint: NSLayoutConstraint! private var plansAreaViewHeightConstraint: NSLayoutConstraint! private var productsErrorHeightConstraint: NSLayoutConstraint! private var storeObservers: [NSObjectProtocol] = [] init() { super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false wantsLayer = true layer?.cornerRadius = 0 setup() observeStoreUpdates() refreshConfigDisplay() refreshProductDisplay() refreshAppearance() } deinit { storeObservers.forEach { NotificationCenter.default.removeObserver($0) } } private func observeStoreUpdates() { let center = NotificationCenter.default storeObservers = [ center.addObserver(forName: .storeProductsDidUpdate, object: nil, queue: .main) { [weak self] _ in self?.refreshProductDisplay() }, center.addObserver(forName: .storeStateDidChange, object: nil, queue: .main) { [weak self] _ in self?.refreshProductDisplay() self?.selectTrialEligiblePlanIfAvailable() }, center.addObserver(forName: .premiumStatusDidChange, object: nil, queue: .main) { [weak self] _ in self?.refreshProductDisplay() }, center.addObserver(forName: .paywallConfigDidUpdate, object: nil, queue: .main) { [weak self] _ in self?.refreshConfigDisplay() self?.refreshProductDisplay() }, ] } private func selectTrialEligiblePlanIfAvailable() { let store = StoreManager.shared let config = paywallConfig guard let eligiblePlan = config.trialEligiblePlan, store.trialDisplay(for: eligiblePlan) != nil else { return } selectPlan(eligiblePlan) } private var paywallConfig: PaywallConfig { PaywallConfigService.shared.config } private func refreshConfigDisplay() { let config = paywallConfig leftPanelTitle?.stringValue = config.leftPanelTitle rightTitle?.stringValue = config.rightTitle rightSubtitle?.stringValue = config.rightSubtitle syncFeatureRows(with: config.features) syncTrustItems(with: config.trustItems) continueFreePlanLink.updateTitle(config.footer.continueFree) manageSubscriptionLink.updateTitle(config.footer.manageSubscription) restoreLink.updateTitle(config.footer.restorePurchase) privacyLink.updateTitle(config.footer.privacyPolicy) termsLink.updateTitle(config.footer.termsOfService) supportLink.updateTitle(config.footer.support) refreshSecurePaymentNote() } private func syncFeatureRows(with features: [String]) { guard let featuresStack else { return } while featureRows.count < features.count { let row = PaywallFeatureRow(text: "") featureRows.append(row) featuresStack.addArrangedSubview(row) } while featureRows.count > features.count { let row = featureRows.removeLast() featuresStack.removeArrangedSubview(row) row.removeFromSuperview() } for (index, text) in features.enumerated() { featureRows[index].updateText(text) } } private func syncTrustItems(with items: [PaywallConfig.TrustItem]) { for (index, item) in items.enumerated() where index < trustItemViews.count { trustItemViews[index].update( iconName: item.icon, title: item.title, subtitle: item.subtitle ) } } private func refreshProductDisplay() { let store = StoreManager.shared let config = paywallConfig let availablePlans = store.availablePlans syncPlanCards(with: availablePlans) for (plan, card) in planCards { let product = store.product(for: plan) card.updateDisplay( product: product, config: config, trial: store.trialDisplay(for: plan) ) } ensureValidSelectedPlan(from: availablePlans) refreshPlansLayout(planCount: availablePlans.count) ctaButton.title = selectedPlan.localizedCTATitle( from: store.product(for: selectedPlan), config: config, trial: store.trialDisplay(for: selectedPlan) ) updateRenewalDisclosure( selectedPlan.localizedRenewalDisclosure( from: store.product(for: selectedPlan), config: config, trial: store.trialDisplay(for: selectedPlan) ) ) refreshTrustItemsForSelectedPlan() refreshProductsErrorState() refreshPurchaseState() refreshSecurePaymentNote() selectTrialEligiblePlanIfAvailable() } private func syncPlanCards(with plans: [PaywallPlan]) { guard let plansStack else { return } let removedPlans = planCards.keys.filter { !plans.contains($0) } for plan in removedPlans { guard let card = planCards.removeValue(forKey: plan) else { continue } plansStack.removeArrangedSubview(card) card.removeFromSuperview() } for plan in plans where planCards[plan] == nil { let card = PaywallPlanCard(plan: plan) card.isChosen = plan == selectedPlan card.onSelect = { [weak self] in self?.selectPlan(plan) } planCards[plan] = card } for view in plansStack.arrangedSubviews { plansStack.removeArrangedSubview(view) } for plan in plans { if let card = planCards[plan] { plansStack.addArrangedSubview(card) } } } private func ensureValidSelectedPlan(from availablePlans: [PaywallPlan]) { guard !availablePlans.isEmpty else { return } guard !availablePlans.contains(selectedPlan) else { return } let preferred = paywallConfig.trialEligiblePlan .flatMap { trialPlan in availablePlans.contains(trialPlan) && StoreManager.shared.trialDisplay(for: trialPlan) != nil ? trialPlan : nil } ?? availablePlans.first(where: { $0 == .yearly }) ?? availablePlans[0] selectPlan(preferred) } private func refreshPlansLayout(planCount: Int) { let height = plansAreaHeight(for: planCount) plansStackHeightConstraint?.constant = height plansAreaViewHeightConstraint?.constant = height } private func plansAreaHeight(for planCount: Int) -> CGFloat { let cardHeight: CGFloat = 86 let spacing: CGFloat = 12 let count = CGFloat(max(planCount, 0)) guard count > 0 else { return 0 } return count * cardHeight + max(0, count - 1) * spacing } private func refreshSecurePaymentNote() { let config = paywallConfig securePaymentLabel.stringValue = selectedPlan == .lifetime ? config.lifetimeSecurePaymentNote : config.securePaymentNote } private func refreshProductsErrorState() { let store = StoreManager.shared let hasLoadFailure = store.productLoadError != nil && !store.hasAvailablePlans let isRetrying = hasLoadFailure && store.isLoadingProducts productsErrorView.isHidden = !hasLoadFailure plansStack.isHidden = hasLoadFailure || store.availablePlans.isEmpty let visiblePlanCount = hasLoadFailure ? 0 : store.availablePlans.count refreshPlansLayout(planCount: visiblePlanCount) productsErrorHeightConstraint.constant = hasLoadFailure ? productsErrorAreaHeight : 0 if hasLoadFailure, let error = store.productLoadError { productsErrorView.update(message: error, isRetrying: isRetrying) } } private var productsErrorAreaHeight: CGFloat { 86 } private func refreshPurchaseState() { let store = StoreManager.shared let hasProductLoadFailure = store.productLoadError != nil && !store.hasAvailablePlans let isProductReady = store.product(for: selectedPlan) != nil let isBusy = store.isPurchasing || store.isLoadingProducts let isDisabled = isBusy || !isProductReady || hasProductLoadFailure ctaButton.isEnabled = !isDisabled ctaButton.alphaValue = isDisabled ? 0.65 : 1 ctaButton.setShowsProgress(store.purchasingPlan == selectedPlan) restoreLink.isEnabled = !store.isRestoringPurchases restoreLink.updateTitle( store.isRestoringPurchases ? paywallConfig.messages.retrying : paywallConfig.footer.restorePurchase ) refreshPrimaryFooterLink() } private func refreshPrimaryFooterLink() { let store = StoreManager.shared let showContinue = !store.isPro let showManage = store.hasActiveSubscription continueFreePlanLink.isHidden = !showContinue manageSubscriptionLink.isHidden = !showManage premiumCloseButton.isHidden = !store.isPro primaryFooterLinkCell?.isHidden = !showContinue && !showManage primaryFooterLinkCell?.needsLayout = true primaryFooterLinkCell?.layoutSubtreeIfNeeded() } private func refreshTrustItemsForSelectedPlan() { guard trustItemViews.count > 1, paywallConfig.trustItems.count > 1 else { return } let cancelItem = paywallConfig.trustItems[1] if selectedPlan == .lifetime { let item = paywallConfig.lifetimeTrustItem trustItemViews[1].update( iconName: item.icon, title: item.title, subtitle: item.subtitle ) } else { trustItemViews[1].update( iconName: cancelItem.icon, title: cancelItem.title, subtitle: cancelItem.subtitle ) } } func refreshStoreState() { refreshProductDisplay() } override func layout() { super.layout() let width = renewalDisclosureLabel.bounds.width guard width > 0, renewalDisclosureLabel.preferredMaxLayoutWidth != width else { return } renewalDisclosureLabel.preferredMaxLayoutWidth = width renewalDisclosureLabel.invalidateIntrinsicContentSize() } private func updateRenewalDisclosure(_ text: String) { renewalDisclosureLabel.stringValue = text renewalDisclosureLabel.invalidateIntrinsicContentSize() needsLayout = true } func refreshAppearance() { layer?.backgroundColor = AppTheme.paywallBackground.cgColor leftPanelTitle?.refreshThemeLabelColor() rightTitle?.refreshThemeLabelColor() rightSubtitle?.refreshThemeLabelColor() renewalDisclosureLabel.textColor = AppTheme.textSecondary securePaymentLabel.textColor = AppTheme.textSecondary trustStack?.layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor trustStack?.layer?.borderColor = AppTheme.paywallBorder.cgColor ctaButton.refreshAppearance() subviews.forEach { $0.refreshAppearanceRecursively() } } @available(*, unavailable) required init?(coder: NSCoder) { nil } private func setup() { let leftPanel = makeLeftPanel() let rightPanel = makeRightPanel() premiumCloseButton.target = self premiumCloseButton.action = #selector(premiumCloseTapped) premiumCloseButton.toolTip = paywallConfig.closeButtonHelp premiumCloseButton.isHidden = true addSubview(leftPanel) addSubview(rightPanel) addSubview(premiumCloseButton) NSLayoutConstraint.activate([ leftPanel.leadingAnchor.constraint(equalTo: leadingAnchor), leftPanel.topAnchor.constraint(equalTo: topAnchor), leftPanel.bottomAnchor.constraint(equalTo: bottomAnchor), leftPanel.widthAnchor.constraint(equalToConstant: 320), rightPanel.leadingAnchor.constraint(equalTo: leftPanel.trailingAnchor), rightPanel.trailingAnchor.constraint(equalTo: trailingAnchor), rightPanel.topAnchor.constraint(equalTo: topAnchor), rightPanel.bottomAnchor.constraint(equalTo: bottomAnchor), premiumCloseButton.topAnchor.constraint(equalTo: topAnchor, constant: 16), premiumCloseButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), premiumCloseButton.widthAnchor.constraint(equalToConstant: 30), premiumCloseButton.heightAnchor.constraint(equalToConstant: 30), ]) } private func makeLeftPanel() -> NSView { let panel = PaywallLeftPanelView() panel.translatesAutoresizingMaskIntoConstraints = false let config = paywallConfig let title = NSTextField.themeLabel( config.leftPanelTitle, style: .primary, font: AppTheme.semiboldFont(size: 22) ) title.maximumNumberOfLines = 2 title.translatesAutoresizingMaskIntoConstraints = false leftPanelTitle = title let featuresStack = NSStackView() featuresStack.orientation = .vertical featuresStack.spacing = 6 featuresStack.alignment = .leading featuresStack.translatesAutoresizingMaskIntoConstraints = false self.featuresStack = featuresStack for feature in config.features { let row = PaywallFeatureRow(text: feature) featureRows.append(row) featuresStack.addArrangedSubview(row) } panel.addSubview(title) panel.addSubview(featuresStack) NSLayoutConstraint.activate([ title.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28), title.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -20), title.topAnchor.constraint(equalTo: panel.topAnchor, constant: 48), featuresStack.leadingAnchor.constraint(equalTo: title.leadingAnchor), featuresStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -20), featuresStack.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 28), ]) return panel } private func makeRightPanel() -> NSView { let panel = NSView() panel.translatesAutoresizingMaskIntoConstraints = false let config = paywallConfig let title = NSTextField.themeLabel( config.rightTitle, style: .primary, font: AppTheme.semiboldFont(size: 26) ) title.alignment = .center title.translatesAutoresizingMaskIntoConstraints = false rightTitle = title let subtitle = NSTextField.themeLabel( config.rightSubtitle, style: .secondary, font: AppTheme.regularFont(size: 13) ) subtitle.alignment = .center subtitle.maximumNumberOfLines = 2 subtitle.translatesAutoresizingMaskIntoConstraints = false rightSubtitle = subtitle let plansStack = NSStackView() plansStack.orientation = .vertical plansStack.spacing = 12 plansStack.translatesAutoresizingMaskIntoConstraints = false self.plansStack = plansStack let plansAreaView = NSView() plansAreaView.translatesAutoresizingMaskIntoConstraints = false let productsErrorView = PaywallProductsErrorView() productsErrorView.isHidden = true productsErrorView.onRetry = { [weak self] in self?.retryProductLoad() } self.productsErrorView = productsErrorView ctaButton.title = selectedPlan.localizedCTATitle( from: StoreManager.shared.product(for: selectedPlan), config: config, trial: StoreManager.shared.trialDisplay(for: selectedPlan) ) ctaButton.target = self ctaButton.action = #selector(purchaseTapped) ctaButton.translatesAutoresizingMaskIntoConstraints = false renewalDisclosureLabel.font = AppTheme.regularFont(size: 10) renewalDisclosureLabel.textColor = AppTheme.textSecondary renewalDisclosureLabel.alignment = .center renewalDisclosureLabel.maximumNumberOfLines = 3 renewalDisclosureLabel.lineBreakMode = .byWordWrapping renewalDisclosureLabel.cell?.wraps = true renewalDisclosureLabel.cell?.isScrollable = false renewalDisclosureLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) renewalDisclosureLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) renewalDisclosureLabel.setContentHuggingPriority(.required, for: .vertical) renewalDisclosureLabel.translatesAutoresizingMaskIntoConstraints = false updateRenewalDisclosure( selectedPlan.localizedRenewalDisclosure( from: StoreManager.shared.product(for: selectedPlan), config: config, trial: StoreManager.shared.trialDisplay(for: selectedPlan) ) ) securePaymentLabel.font = AppTheme.regularFont(size: 10) securePaymentLabel.textColor = AppTheme.textSecondary securePaymentLabel.alignment = .center securePaymentLabel.translatesAutoresizingMaskIntoConstraints = false refreshSecurePaymentNote() let trustRow = makeTrustRow() let footerLinks = makeFooterLinks() let footerSpacer = NSView() footerSpacer.translatesAutoresizingMaskIntoConstraints = false footerSpacer.setContentHuggingPriority(.defaultLow, for: .vertical) footerSpacer.setContentCompressionResistancePriority(.defaultLow, for: .vertical) plansAreaView.addSubview(plansStack) plansAreaView.addSubview(productsErrorView) plansStackHeightConstraint = plansStack.heightAnchor.constraint(equalToConstant: 0) productsErrorHeightConstraint = productsErrorView.heightAnchor.constraint(equalToConstant: 0) plansAreaViewHeightConstraint = plansAreaView.heightAnchor.constraint(equalToConstant: 0) panel.addSubview(title) panel.addSubview(subtitle) panel.addSubview(plansAreaView) panel.addSubview(trustRow) panel.addSubview(ctaButton) panel.addSubview(renewalDisclosureLabel) panel.addSubview(securePaymentLabel) panel.addSubview(footerSpacer) panel.addSubview(footerLinks) NSLayoutConstraint.activate([ title.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28), title.topAnchor.constraint(equalTo: panel.topAnchor, constant: 40), title.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28), subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor), subtitle.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28), subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6), plansAreaView.leadingAnchor.constraint(equalTo: title.leadingAnchor), plansAreaView.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28), plansAreaView.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 24), plansAreaViewHeightConstraint, plansStack.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor), plansStack.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor), plansStack.topAnchor.constraint(equalTo: plansAreaView.topAnchor), plansStackHeightConstraint, productsErrorView.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor), productsErrorView.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor), productsErrorView.topAnchor.constraint(equalTo: plansAreaView.topAnchor), productsErrorHeightConstraint, trustRow.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor), trustRow.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor), trustRow.topAnchor.constraint(equalTo: plansAreaView.bottomAnchor, constant: 18), ctaButton.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor), ctaButton.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor), ctaButton.topAnchor.constraint(equalTo: trustRow.bottomAnchor, constant: 16), ctaButton.heightAnchor.constraint(equalToConstant: 48), renewalDisclosureLabel.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor), renewalDisclosureLabel.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor), renewalDisclosureLabel.topAnchor.constraint(equalTo: ctaButton.bottomAnchor, constant: 10), securePaymentLabel.centerXAnchor.constraint(equalTo: plansAreaView.centerXAnchor), securePaymentLabel.topAnchor.constraint(equalTo: renewalDisclosureLabel.bottomAnchor, constant: 8), footerSpacer.leadingAnchor.constraint(equalTo: panel.leadingAnchor), footerSpacer.trailingAnchor.constraint(equalTo: panel.trailingAnchor), footerSpacer.topAnchor.constraint(equalTo: securePaymentLabel.bottomAnchor, constant: 16), footerSpacer.bottomAnchor.constraint(equalTo: footerLinks.topAnchor), footerLinks.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28), footerLinks.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28), footerLinks.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -16), ]) return panel } private func makeTrustRow() -> NSView { let config = paywallConfig trustItemViews = config.trustItems.map { item in PaywallTrustItemView( iconName: item.icon, title: item.title, subtitle: item.subtitle ) } let trustStack = NSStackView(views: trustItemViews) trustStack.orientation = .horizontal trustStack.distribution = .fillEqually trustStack.spacing = 16 trustStack.alignment = .top trustStack.translatesAutoresizingMaskIntoConstraints = false trustStack.wantsLayer = true trustStack.layer?.cornerRadius = 12 trustStack.layer?.borderWidth = 1.5 trustStack.layer?.masksToBounds = true trustStack.edgeInsets = NSEdgeInsets(top: 12, left: 14, bottom: 12, right: 14) trustStack.translatesAutoresizingMaskIntoConstraints = false trustStack.setContentHuggingPriority(.required, for: .vertical) trustStack.setContentCompressionResistancePriority(.required, for: .vertical) trustStack.heightAnchor.constraint( equalToConstant: PaywallTrustItemView.preferredHeight + trustStack.edgeInsets.top + trustStack.edgeInsets.bottom ).isActive = true self.trustStack = trustStack return trustStack } private func makeFooterLinks() -> NSView { let container = NSView() container.translatesAutoresizingMaskIntoConstraints = false container.setContentHuggingPriority(.required, for: .vertical) container.setContentCompressionResistancePriority(.required, for: .vertical) let config = paywallConfig let separator = NSView() separator.translatesAutoresizingMaskIntoConstraints = false separator.wantsLayer = true separator.layer?.backgroundColor = AppTheme.paywallBorder.cgColor continueFreePlanLink.updateTitle(config.footer.continueFree) continueFreePlanLink.target = self continueFreePlanLink.action = #selector(continueWithFreePlanTapped) manageSubscriptionLink.updateTitle(config.footer.manageSubscription) manageSubscriptionLink.target = self manageSubscriptionLink.action = #selector(manageSubscriptionTapped) let primaryCell = makePrimaryFooterLinkCell() primaryFooterLinkCell = primaryCell refreshPrimaryFooterLink() restoreLink.updateTitle(config.footer.restorePurchase) restoreLink.target = self restoreLink.action = #selector(restoreTapped) privacyLink.updateTitle(config.footer.privacyPolicy) privacyLink.target = self privacyLink.action = #selector(privacyPolicyTapped) termsLink.updateTitle(config.footer.termsOfService) termsLink.target = self termsLink.action = #selector(termsOfServiceTapped) supportLink.updateTitle(config.footer.support) supportLink.target = self supportLink.action = #selector(supportTapped) let linkCells = [ primaryCell, makeFooterLinkCell(link: restoreLink), makeFooterLinkCell(link: privacyLink), makeFooterLinkCell(link: termsLink), makeFooterLinkCell(link: supportLink), ] let linksStack = NSStackView(views: linkCells) linksStack.orientation = .horizontal linksStack.spacing = 0 linksStack.alignment = .centerY linksStack.distribution = .fillEqually linksStack.translatesAutoresizingMaskIntoConstraints = false container.addSubview(separator) container.addSubview(linksStack) NSLayoutConstraint.activate([ separator.leadingAnchor.constraint(equalTo: container.leadingAnchor), separator.trailingAnchor.constraint(equalTo: container.trailingAnchor), separator.topAnchor.constraint(equalTo: container.topAnchor), separator.heightAnchor.constraint(equalToConstant: 1), linksStack.leadingAnchor.constraint(equalTo: container.leadingAnchor), linksStack.trailingAnchor.constraint(equalTo: container.trailingAnchor), linksStack.topAnchor.constraint(equalTo: separator.bottomAnchor, constant: 10), linksStack.bottomAnchor.constraint(equalTo: container.bottomAnchor), ]) return container } private func makePrimaryFooterLinkCell() -> NSView { let cell = NSView() cell.translatesAutoresizingMaskIntoConstraints = false cell.setContentHuggingPriority(.required, for: .vertical) cell.setContentCompressionResistancePriority(.required, for: .vertical) for link in [continueFreePlanLink, manageSubscriptionLink] { cell.addSubview(link) NSLayoutConstraint.activate([ link.centerXAnchor.constraint(equalTo: cell.centerXAnchor), link.centerYAnchor.constraint(equalTo: cell.centerYAnchor), link.topAnchor.constraint(equalTo: cell.topAnchor), link.bottomAnchor.constraint(equalTo: cell.bottomAnchor), ]) } return cell } private func makeFooterLinkCell(link: PaywallFooterLink) -> NSView { let cell = NSView() cell.translatesAutoresizingMaskIntoConstraints = false cell.setContentHuggingPriority(.required, for: .vertical) cell.setContentCompressionResistancePriority(.required, for: .vertical) cell.addSubview(link) NSLayoutConstraint.activate([ link.centerXAnchor.constraint(equalTo: cell.centerXAnchor), link.centerYAnchor.constraint(equalTo: cell.centerYAnchor), link.topAnchor.constraint(equalTo: cell.topAnchor), link.bottomAnchor.constraint(equalTo: cell.bottomAnchor), ]) return cell } private func selectPlan(_ plan: PaywallPlan) { selectedPlan = plan for (key, card) in planCards { card.isChosen = key == plan } ctaButton.title = plan.localizedCTATitle( from: StoreManager.shared.product(for: plan), config: paywallConfig, trial: StoreManager.shared.trialDisplay(for: plan) ) updateRenewalDisclosure( plan.localizedRenewalDisclosure( from: StoreManager.shared.product(for: plan), config: paywallConfig, trial: StoreManager.shared.trialDisplay(for: plan) ) ) refreshTrustItemsForSelectedPlan() refreshPurchaseState() refreshSecurePaymentNote() } private func retryProductLoad() { Task { @MainActor in refreshProductsErrorState() refreshPurchaseState() await StoreManager.shared.loadProducts(presentLoadingUI: true) refreshStoreState() } } @objc private func purchaseTapped() { Task { @MainActor in StoreManager.shared.clearPurchaseError() refreshPurchaseState() let purchased = await StoreManager.shared.purchase(plan: selectedPlan) refreshStoreState() if purchased { onClose?() } else if let error = StoreManager.shared.purchaseError { StoreManager.shared.showPurchaseError(error, on: window) } } } @objc private func restoreTapped() { Task { @MainActor in StoreManager.shared.clearPurchaseError() refreshPurchaseState() let restored = await StoreManager.shared.restorePurchases() refreshStoreState() if restored { onClose?() } else if let error = StoreManager.shared.purchaseError { StoreManager.shared.showPurchaseError(error, on: window) } } } @objc private func continueWithFreePlanTapped() { onClose?() } @objc private func premiumCloseTapped() { onClose?() } @objc private func manageSubscriptionTapped() { StoreManager.shared.showManageSubscriptions() } @objc private func privacyPolicyTapped() { openExternalLink(paywallConfig.urls.privacy) } @objc private func termsOfServiceTapped() { openExternalLink(paywallConfig.urls.terms) } @objc private func supportTapped() { openExternalLink(paywallConfig.urls.support) } private func openExternalLink(_ urlString: String) { guard let url = URL(string: urlString) else { return } NSWorkspace.shared.open(url) } } // MARK: - Overlay Presenter final class PaywallOverlayView: NSView, AppearanceRefreshable { var onDismiss: (() -> Void)? private let paywallView: PaywallView private let blurView = NSVisualEffectView() private let backdrop = NSView() private let pattern = WavePatternView() private let loadingOverlay = NSView() private let loadingIndicator = NSProgressIndicator() private var acceptsHitTesting = false private var storeObservers: [NSObjectProtocol] = [] init() { paywallView = PaywallView() super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false setup() observeStoreUpdates() refreshAppearance() refreshLoadingOverlay() } deinit { storeObservers.forEach { NotificationCenter.default.removeObserver($0) } } private func observeStoreUpdates() { let center = NotificationCenter.default storeObservers = [ center.addObserver(forName: .storeStateDidChange, object: nil, queue: .main) { [weak self] _ in self?.refreshLoadingOverlay() }, center.addObserver(forName: .storeProductsDidUpdate, object: nil, queue: .main) { [weak self] _ in self?.refreshLoadingOverlay() }, ] } private func refreshLoadingOverlay() { let store = StoreManager.shared let shouldShow = store.isLoadingProducts && !store.hasAvailablePlans && store.productLoadError == nil loadingOverlay.isHidden = !shouldShow if shouldShow { loadingIndicator.startAnimation(nil) } else { loadingIndicator.stopAnimation(nil) } } func refreshAppearance() { backdrop.layer?.backgroundColor = AppTheme.paywallOverlayBackdrop.cgColor blurView.material = AppSettings.darkModeEnabled ? .hudWindow : .underWindowBackground pattern.refreshAppearance() paywallView.refreshAppearance() } func refreshStoreState() { paywallView.refreshStoreState() refreshLoadingOverlay() } @available(*, unavailable) required init?(coder: NSCoder) { nil } override func hitTest(_ point: NSPoint) -> NSView? { guard acceptsHitTesting else { return nil } return super.hitTest(point) } private func setup() { blurView.translatesAutoresizingMaskIntoConstraints = false blurView.material = .underWindowBackground blurView.blendingMode = .withinWindow blurView.state = .active backdrop.translatesAutoresizingMaskIntoConstraints = false backdrop.wantsLayer = true backdrop.layer?.backgroundColor = NSColor(calibratedWhite: 0.15, alpha: 0.22).cgColor pattern.translatesAutoresizingMaskIntoConstraints = false pattern.alphaValue = 0.35 paywallView.translatesAutoresizingMaskIntoConstraints = false paywallView.onClose = { [weak self] in self?.dismiss() } loadingOverlay.translatesAutoresizingMaskIntoConstraints = false loadingOverlay.wantsLayer = true loadingOverlay.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.08).cgColor loadingOverlay.isHidden = true loadingIndicator.style = .spinning loadingIndicator.controlSize = .large loadingIndicator.translatesAutoresizingMaskIntoConstraints = false loadingOverlay.addSubview(loadingIndicator) addSubview(blurView) addSubview(backdrop) backdrop.addSubview(pattern) addSubview(paywallView) addSubview(loadingOverlay) NSLayoutConstraint.activate([ blurView.leadingAnchor.constraint(equalTo: leadingAnchor), blurView.trailingAnchor.constraint(equalTo: trailingAnchor), blurView.topAnchor.constraint(equalTo: topAnchor), blurView.bottomAnchor.constraint(equalTo: bottomAnchor), backdrop.leadingAnchor.constraint(equalTo: leadingAnchor), backdrop.trailingAnchor.constraint(equalTo: trailingAnchor), backdrop.topAnchor.constraint(equalTo: topAnchor), backdrop.bottomAnchor.constraint(equalTo: bottomAnchor), pattern.leadingAnchor.constraint(equalTo: backdrop.leadingAnchor), pattern.trailingAnchor.constraint(equalTo: backdrop.trailingAnchor), pattern.topAnchor.constraint(equalTo: backdrop.topAnchor), pattern.bottomAnchor.constraint(equalTo: backdrop.bottomAnchor), paywallView.leadingAnchor.constraint(equalTo: leadingAnchor), paywallView.trailingAnchor.constraint(equalTo: trailingAnchor), paywallView.topAnchor.constraint(equalTo: topAnchor), paywallView.bottomAnchor.constraint(equalTo: bottomAnchor), loadingOverlay.leadingAnchor.constraint(equalTo: leadingAnchor), loadingOverlay.trailingAnchor.constraint(equalTo: trailingAnchor), loadingOverlay.topAnchor.constraint(equalTo: topAnchor), loadingOverlay.bottomAnchor.constraint(equalTo: bottomAnchor), loadingIndicator.centerXAnchor.constraint(equalTo: loadingOverlay.centerXAnchor), loadingIndicator.centerYAnchor.constraint(equalTo: loadingOverlay.centerYAnchor), ]) } func present(in parent: NSView) { guard superview == nil else { return } if let window = parent.window, let windowFrameView = window.contentView?.superview { windowFrameView.addSubview(self) NSLayoutConstraint.activate([ leadingAnchor.constraint(equalTo: windowFrameView.leadingAnchor), trailingAnchor.constraint(equalTo: windowFrameView.trailingAnchor), topAnchor.constraint(equalTo: windowFrameView.topAnchor), bottomAnchor.constraint(equalTo: windowFrameView.bottomAnchor), ]) } else { parent.addSubview(self) NSLayoutConstraint.activate([ leadingAnchor.constraint(equalTo: parent.leadingAnchor), trailingAnchor.constraint(equalTo: parent.trailingAnchor), topAnchor.constraint(equalTo: parent.topAnchor), bottomAnchor.constraint(equalTo: parent.bottomAnchor), ]) } alphaValue = 1 acceptsHitTesting = true paywallView.refreshStoreState() Task { @MainActor in await StoreManager.shared.ensurePaywallStoreDataLoaded() paywallView.refreshStoreState() refreshLoadingOverlay() } Task { @MainActor in await PaywallConfigService.shared.refreshFromRemote() paywallView.refreshStoreState() } } func dismiss() { acceptsHitTesting = false NSAnimationContext.runAnimationGroup({ context in context.duration = 0.15 animator().alphaValue = 0 }, completionHandler: { [weak self] in self?.removeFromSuperview() self?.onDismiss?() }) } }