import AppKit import StoreKit import SwiftUI struct PaywallView: View { @EnvironmentObject private var subscriptions: SubscriptionManager @ObservedObject private var paywallConfigService = PaywallConfigService.shared @Bindable var viewModel: PaywallViewModel var onDismiss: () -> Void private var config: PaywallConfig { paywallConfigService.config } private var isPurchasingSelectedPlan: Bool { subscriptions.purchasingPlan == viewModel.selectedPlan } private var buttonsBusy: Bool { subscriptions.purchasingPlan != nil || subscriptions.isRestoringPurchases } private var isPremium: Bool { subscriptions.hasPremiumAccess } private var hasProductLoadFailure: Bool { subscriptions.productLoadError != nil && !subscriptions.hasAllProductsLoaded } private var isProductReady: Bool { subscriptions.product(for: viewModel.selectedPlan) != nil } private var availablePlans: [PaywallPlan] { subscriptions.availablePaywallPlans } var body: some View { GeometryReader { geometry in let metrics = PaywallLayoutMetrics(size: geometry.size) ZStack { AppTheme.background .ignoresSafeArea() VStack(spacing: metrics.sectionSpacing) { header(metrics: metrics) featuresSection(metrics: metrics) if hasProductLoadFailure { productsErrorSection(metrics: metrics) } else { pricingCards(metrics: metrics) } ctaSection(metrics: metrics) Spacer(minLength: 0) footerLinks(metrics: metrics) } .padding(.horizontal, metrics.horizontalInset) .padding(.top, metrics.topInset) .padding(.bottom, metrics.bottomInset) .frame(width: geometry.size.width, height: geometry.size.height, alignment: .top) if subscriptions.isLoadingProducts && !subscriptions.hasAllProductsLoaded && !hasProductLoadFailure { AppTheme.scrim .ignoresSafeArea() .allowsHitTesting(false) ProgressView() .controlSize(.large) } if isPremium { closeButton(metrics: metrics) } } } .frame(maxWidth: .infinity, maxHeight: .infinity) .ignoresSafeArea() .task { await subscriptions.ensureEntitlementsResolved() if !subscriptions.hasAllProductsLoaded, !subscriptions.isLoadingProducts { await subscriptions.loadProducts(presentLoadingUI: true) } await subscriptions.refreshTrialEligibility() await paywallConfigService.refreshFromRemote() viewModel.syncSelectedPlan(with: availablePlans) } .onChange(of: subscriptions.hasEverPurchasedPremium) { _, _ in Task { await subscriptions.refreshTrialEligibility() } viewModel.syncSelectedPlan(with: availablePlans) if let eligiblePlan = config.trialEligiblePlan, subscriptions.trialDisplay(for: eligiblePlan) != nil { viewModel.selectPlan(eligiblePlan) } } .onChange(of: subscriptions.hasPremiumAccess) { _, _ in Task { await subscriptions.refreshTrialEligibility() } } .onChange(of: subscriptions.hasAllProductsLoaded) { _, loaded in if loaded { Task { await subscriptions.refreshTrialEligibility() } } } .onChange(of: subscriptions.trialDisplayByPlan) { _, _ in if let eligiblePlan = config.trialEligiblePlan, subscriptions.trialDisplay(for: eligiblePlan) != nil { viewModel.selectPlan(eligiblePlan) } } .onReceive(NotificationCenter.default.publisher(for: .paywallConfigDidUpdate)) { _ in } .alert( config.messages.purchaseFailedTitle, isPresented: Binding( get: { subscriptions.purchaseError != nil }, set: { presented in if !presented { subscriptions.purchaseError = nil } } ) ) { Button(config.messages.alertOK, role: .cancel) {} } message: { Text(subscriptions.purchaseError.map { config.message(for: $0) } ?? "") } } private func closeButton(metrics: PaywallLayoutMetrics) -> some View { VStack { HStack { Spacer() Button(action: onDismiss) { Image(systemName: "xmark") .font(.system(size: metrics.closeButtonSize, weight: .medium)) .foregroundStyle(AppTheme.textSecondary) .frame(width: metrics.closeButtonHitArea, height: metrics.closeButtonHitArea) .background( Circle() .fill(AppTheme.cardBackground) .overlay( Circle() .stroke(AppTheme.cardBorder, lineWidth: 1) ) ) } .buttonStyle(AppPlainButtonStyle()) .help(config.closeButtonHelp) } Spacer() } .padding(.trailing, metrics.horizontalInset * 0.55) .padding(.top, metrics.topInset * 0.28) } private func header(metrics: PaywallLayoutMetrics) -> some View { VStack(spacing: metrics.headerSpacing) { Text(config.rightTitle) .font(.system(size: metrics.titleFontSize, weight: .bold)) .foregroundStyle(AppTheme.textPrimary) Text(config.rightSubtitle) .font(.system(size: metrics.subtitleFontSize)) .foregroundStyle(AppTheme.textSecondary) .multilineTextAlignment(.center) .frame(maxWidth: metrics.subtitleMaxWidth) } .frame(maxWidth: .infinity) } private func featuresSection(metrics: PaywallLayoutMetrics) -> some View { VStack(alignment: .leading, spacing: metrics.featureSectionSpacing) { Text(config.leftPanelTitle) .font(.system(size: metrics.sectionTitleFontSize, weight: .semibold)) .foregroundStyle(AppTheme.textPrimary) LazyVGrid(columns: metrics.featureColumns, alignment: .leading, spacing: metrics.featureRowSpacing) { ForEach(Array(config.features.enumerated()), id: \.offset) { _, feature in PaywallFeatureRow(title: feature, metrics: metrics) } } } .frame(maxWidth: .infinity, alignment: .leading) } private func productsErrorSection(metrics: PaywallLayoutMetrics) -> some View { VStack(spacing: metrics.ctaSpacing) { Text(subscriptions.productLoadError ?? config.messages.plansLoadFailed) .font(.system(size: metrics.footerNoteFontSize)) .foregroundStyle(AppTheme.textSecondary) .multilineTextAlignment(.center) Button(subscriptions.isLoadingProducts ? config.messages.retrying : config.messages.retry) { Task { await subscriptions.loadProducts(presentLoadingUI: true) } } .buttonStyle(AppTextLinkButtonStyle()) .font(.system(size: metrics.footerLinkFontSize)) .foregroundStyle(AppTheme.textSecondary) .disabled(subscriptions.isLoadingProducts) } .frame(maxWidth: .infinity, minHeight: metrics.cardMinHeight) } private func pricingCards(metrics: PaywallLayoutMetrics) -> some View { HStack(alignment: .top, spacing: metrics.cardSpacing) { ForEach(availablePlans) { plan in PaywallPricingCard( plan: plan, config: config, product: subscriptions.product(for: plan), trial: subscriptions.trialDisplay(for: plan), isSelected: viewModel.selectedPlan == plan, metrics: metrics ) { viewModel.selectPlan(plan) subscriptions.purchaseError = nil } } } .frame(maxWidth: .infinity) } private func ctaSection(metrics: PaywallLayoutMetrics) -> some View { let selectedProduct = subscriptions.product(for: viewModel.selectedPlan) let selectedTrial = subscriptions.trialDisplay(for: viewModel.selectedPlan) let ctaDisabled = buttonsBusy || subscriptions.isLoadingProducts || !isProductReady || hasProductLoadFailure return VStack(spacing: metrics.ctaSpacing) { Text( viewModel.selectedPlan.localizedRenewalDisclosure( from: selectedProduct, config: config, trial: selectedTrial ) ) .font(.system(size: metrics.footerNoteFontSize)) .foregroundStyle(AppTheme.textTertiary) .multilineTextAlignment(.center) Button { Task { let purchased = await subscriptions.purchase(viewModel.selectedPlan) if purchased { onDismiss() } } } label: { HStack(spacing: 8) { if isPurchasingSelectedPlan { ProgressView() .controlSize(.small) .tint(Color(hex: 0x0B0E14)) } Text( viewModel.selectedPlan.localizedCTATitle( from: selectedProduct, config: config, trial: selectedTrial ) ) .font(.system(size: metrics.ctaFontSize, weight: .bold)) .foregroundStyle(Color(hex: 0x0B0E14)) } .frame(maxWidth: .infinity) .padding(.vertical, metrics.ctaVerticalPadding) .background( LinearGradient( colors: [AppTheme.accentPurpleLight, AppTheme.accentTeal], startPoint: .leading, endPoint: .trailing ) ) .clipShape(RoundedRectangle(cornerRadius: metrics.cornerRadius)) } .buttonStyle(AppPrimaryButtonStyle()) .disabled(ctaDisabled) .opacity(ctaDisabled ? 0.7 : 1) HStack(spacing: 6) { Image(systemName: "lock.fill") .font(.system(size: metrics.footerNoteFontSize)) Text(config.securePaymentNote) .font(.system(size: metrics.footerNoteFontSize)) } .foregroundStyle(AppTheme.textTertiary) } } private func footerLinks(metrics: PaywallLayoutMetrics) -> some View { HStack(spacing: 0) { if !isPremium { footerLink(config.footer.continueFree, metrics: metrics) { onDismiss() } Spacer(minLength: metrics.footerLinkSpacing) } else if subscriptions.hasActiveSubscription { footerLink(config.footer.manageSubscription, metrics: metrics) { subscriptions.openSubscriptionManagement() } Spacer(minLength: metrics.footerLinkSpacing) } footerLink( subscriptions.isRestoringPurchases ? config.messages.retrying : config.footer.restorePurchase, metrics: metrics ) { Task { await subscriptions.restorePurchases() if subscriptions.hasPremiumAccess { onDismiss() } } } .disabled(subscriptions.isRestoringPurchases) Spacer(minLength: metrics.footerLinkSpacing) footerLink(config.footer.privacyPolicy, metrics: metrics) { openExternalLink(config.urls.privacy) } Spacer(minLength: metrics.footerLinkSpacing) footerLink(config.footer.support, metrics: metrics) { openExternalLink(config.urls.support) } Spacer(minLength: metrics.footerLinkSpacing) footerLink(config.footer.termsOfService, metrics: metrics) { openExternalLink(config.urls.terms) } } .padding(.horizontal, metrics.footerLinksHorizontalInset) .frame(maxWidth: .infinity) } private func openExternalLink(_ urlString: String) { guard let url = URL(string: urlString) else { return } NSWorkspace.shared.open(url) } private func footerLink(_ title: String, metrics: PaywallLayoutMetrics, action: @escaping () -> Void) -> some View { Button(title, action: action) .buttonStyle(AppTextLinkButtonStyle()) .font(.system(size: metrics.footerLinkFontSize)) .foregroundStyle(AppTheme.textSecondary) .lineLimit(1) .minimumScaleFactor(0.8) } } // MARK: - Layout Metrics private struct PaywallLayoutMetrics { let size: CGSize var horizontalInset: CGFloat { size.width * 0.06 } var topInset: CGFloat { size.height * 0.11 } var bottomInset: CGFloat { size.height * 0.05 } var sectionSpacing: CGFloat { size.height * 0.04 } var headerSpacing: CGFloat { size.height * 0.012 } var featureSectionSpacing: CGFloat { size.height * 0.018 } var featureRowSpacing: CGFloat { size.height * 0.016 } var cardSpacing: CGFloat { size.width * 0.018 } var ctaSpacing: CGFloat { size.height * 0.014 } var footerLinkSpacing: CGFloat { size.width * 0.018 } var footerLinksHorizontalInset: CGFloat { size.width * 0.035 } var titleFontSize: CGFloat { clamp(size.height * 0.043, min: 22, max: 36) } var subtitleFontSize: CGFloat { clamp(size.height * 0.02, min: 12, max: 16) } var sectionTitleFontSize: CGFloat { clamp(size.height * 0.018, min: 11, max: 14) } var featureFontSize: CGFloat { clamp(size.height * 0.017, min: 10, max: 13) } var ctaFontSize: CGFloat { clamp(size.height * 0.022, min: 13, max: 16) } var footerNoteFontSize: CGFloat { clamp(size.height * 0.015, min: 9, max: 12) } var footerLinkFontSize: CGFloat { clamp(size.height * 0.015, min: 9, max: 12) } var subtitleMaxWidth: CGFloat { size.width * 0.55 } var cardMinHeight: CGFloat { size.height * 0.32 } var cardPadding: CGFloat { size.width * 0.014 } var cornerRadius: CGFloat { clamp(size.height * 0.014, min: 8, max: 12) } var ctaVerticalPadding: CGFloat { size.height * 0.022 } var closeButtonSize: CGFloat { clamp(size.height * 0.016, min: 10, max: 14) } var closeButtonHitArea: CGFloat { clamp(size.height * 0.038, min: 28, max: 36) } var featureColumns: [GridItem] { [ GridItem(.flexible(), spacing: featureRowSpacing), GridItem(.flexible(), spacing: featureRowSpacing), GridItem(.flexible(), spacing: featureRowSpacing), ] } private func clamp(_ value: CGFloat, min: CGFloat, max: CGFloat) -> CGFloat { Swift.min(Swift.max(value, min), max) } } // MARK: - Components private struct PaywallFeatureRow: View { let title: String let metrics: PaywallLayoutMetrics var body: some View { HStack(spacing: 8) { Image(systemName: "checkmark") .font(.system(size: metrics.featureFontSize * 0.75, weight: .bold)) .foregroundStyle(AppTheme.accentGreen) .frame(width: metrics.featureFontSize + 4) Text(title) .font(.system(size: metrics.featureFontSize)) .foregroundStyle(AppTheme.textSecondary) .lineLimit(1) .minimumScaleFactor(0.85) } } } private struct PaywallPricingCard: View { let plan: PaywallPlan let config: PaywallConfig let product: Product? let trial: PaywallTrialDisplay? let isSelected: Bool let metrics: PaywallLayoutMetrics let onSelect: () -> Void private var planCopy: PaywallConfig.PlanCopy { plan.planCopy(from: config) } var body: some View { Button(action: onSelect) { VStack(alignment: .leading, spacing: 0) { Color.clear .frame(height: metrics.cardPadding * 1.0) .padding(.bottom, metrics.cardPadding * 0.15) Text(planCopy.title) .font(.system(size: metrics.ctaFontSize, weight: .semibold)) .foregroundStyle(AppTheme.textPrimary) if product != nil { Text(plan.localizedSubtitle(from: product, config: config, trial: trial)) .font(.system(size: metrics.featureFontSize * 0.85)) .foregroundStyle(AppTheme.textTertiary) .fixedSize(horizontal: false, vertical: true) .padding(.top, 2) } HStack(alignment: .firstTextBaseline, spacing: 2) { Text(plan.localizedPrice(from: product, config: config)) .font(.system(size: metrics.titleFontSize * 0.85, weight: .bold)) .foregroundStyle(AppTheme.textPrimary) if product != nil, let priceSuffix = planCopy.priceSuffix { Text(priceSuffix) .font(.system(size: metrics.featureFontSize)) .foregroundStyle(AppTheme.textSecondary) } } .padding(.top, metrics.cardPadding) .padding(.bottom, metrics.cardPadding * 0.9) VStack(alignment: .leading, spacing: metrics.featureRowSpacing * 0.7) { ForEach(planCopy.features, id: \.self) { feature in HStack(alignment: .top, spacing: 8) { Image(systemName: "checkmark") .font(.system(size: metrics.featureFontSize * 0.75, weight: .bold)) .foregroundStyle(AppTheme.accentGreen) .padding(.top, 2) Text(feature) .font(.system(size: metrics.featureFontSize * 0.92)) .foregroundStyle(AppTheme.textSecondary) .fixedSize(horizontal: false, vertical: true) .multilineTextAlignment(.leading) } } } Spacer(minLength: 0) } .padding(metrics.cardPadding) .frame(maxWidth: .infinity, minHeight: metrics.cardMinHeight, alignment: .topLeading) .background( RoundedRectangle(cornerRadius: metrics.cornerRadius) .fill(AppTheme.cardBackground) ) .overlay( RoundedRectangle(cornerRadius: metrics.cornerRadius) .stroke( isSelected ? AppTheme.accentTeal : AppTheme.cardBorder, lineWidth: isSelected ? 2 : 1 ) ) .overlay(alignment: .topTrailing) { if let trial { cardBadge(trial.badgeText) } else if let badge = planCopy.badge, plan == .lifetime { cardBadge(badge) } } } .buttonStyle(AppPlainButtonStyle()) .hoverCard(cornerRadius: metrics.cornerRadius, isSelected: isSelected) } private func cardBadge(_ text: String) -> some View { Text(text) .font(.system(size: metrics.featureFontSize * 0.75, weight: .bold)) .foregroundStyle(Color(hex: 0x0B0E14)) .padding(.horizontal, 10) .padding(.vertical, 4) .background(AppTheme.accentTeal) .clipShape(Capsule()) .padding(.top, metrics.cardPadding * 0.65) .padding(.trailing, metrics.cardPadding * 0.65) } } #Preview { let subscriptions = SubscriptionManager() return PaywallView(viewModel: PaywallViewModel(), onDismiss: {}) .environmentObject(subscriptions) .frame(width: AppWindow.width, height: AppWindow.height) .onAppear { subscriptions.start() PaywallConfigService.shared.start() } }