import SwiftUI struct PaywallView: View { @Bindable var viewModel: PaywallViewModel var onDismiss: () -> Void var body: some View { GeometryReader { geometry in let metrics = PaywallLayoutMetrics(size: geometry.size) ZStack(alignment: .topTrailing) { AppTheme.background .ignoresSafeArea() VStack(spacing: metrics.sectionSpacing) { header(metrics: metrics) featuresSection(metrics: metrics) pricingCards(metrics: metrics) ctaSection(metrics: metrics) 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) closeButton .padding(metrics.closeButtonPadding) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .ignoresSafeArea() } private var closeButton: some View { Button(action: onDismiss) { Image(systemName: "xmark") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(AppTheme.textSecondary) .frame(width: 32, height: 32) .background(AppTheme.cardBackground) .clipShape(Circle()) .overlay( Circle() .stroke(AppTheme.cardBorder, lineWidth: 1) ) } .buttonStyle(.plain) } private func header(metrics: PaywallLayoutMetrics) -> some View { VStack(spacing: metrics.headerSpacing) { Text(PaywallContent.title) .font(.system(size: metrics.titleFontSize, weight: .bold)) .foregroundStyle(AppTheme.textPrimary) Text(PaywallContent.subtitle) .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(PaywallContent.includedSectionTitle) .font(.system(size: metrics.sectionTitleFontSize, weight: .semibold)) .foregroundStyle(AppTheme.textPrimary) LazyVGrid(columns: metrics.featureColumns, alignment: .leading, spacing: metrics.featureRowSpacing) { ForEach(PaywallContent.features) { feature in PaywallFeatureRow(feature: feature, metrics: metrics) } } } .frame(maxWidth: .infinity, alignment: .leading) } private func pricingCards(metrics: PaywallLayoutMetrics) -> some View { HStack(alignment: .top, spacing: metrics.cardSpacing) { ForEach(PaywallPlan.allCases) { plan in PaywallPricingCard( plan: plan, isSelected: viewModel.selectedPlan == plan, metrics: metrics ) { viewModel.selectPlan(plan) } } } .frame(maxWidth: .infinity) } private func ctaSection(metrics: PaywallLayoutMetrics) -> some View { VStack(spacing: metrics.ctaSpacing) { Button { viewModel.purchase() } label: { Text("Get Premium") .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(.plain) HStack(spacing: 6) { Image(systemName: "lock.fill") .font(.system(size: metrics.footerNoteFontSize)) Text("Secure payment. Cancel anytime.") .font(.system(size: metrics.footerNoteFontSize)) } .foregroundStyle(AppTheme.textTertiary) } } private func footerLinks(metrics: PaywallLayoutMetrics) -> some View { HStack { Button("Continue with free plan") { onDismiss() } .buttonStyle(.plain) .font(.system(size: metrics.footerLinkFontSize)) .foregroundStyle(AppTheme.textSecondary) Spacer() HStack(spacing: metrics.footerLinkSpacing) { footerLink("Restore Purchases", metrics: metrics) { viewModel.restorePurchases() } footerLink("Privacy Policy", metrics: metrics) {} footerLink("Support", metrics: metrics) {} footerLink("Terms of Services", metrics: metrics) {} } } } private func footerLink(_ title: String, metrics: PaywallLayoutMetrics, action: @escaping () -> Void) -> some View { Button(title, action: action) .buttonStyle(.plain) .font(.system(size: metrics.footerLinkFontSize)) .foregroundStyle(AppTheme.textTertiary) } } // MARK: - Layout Metrics private struct PaywallLayoutMetrics { let size: CGSize var horizontalInset: CGFloat { size.width * 0.06 } var topInset: CGFloat { size.height * 0.07 } var bottomInset: CGFloat { size.height * 0.05 } var closeButtonPadding: CGFloat { max(16, size.width * 0.02) } 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.014 } var titleFontSize: CGFloat { clamp(size.height * 0.048, min: 24, max: 40) } var subtitleFontSize: CGFloat { clamp(size.height * 0.022, min: 13, max: 18) } var sectionTitleFontSize: CGFloat { clamp(size.height * 0.02, min: 12, max: 16) } var featureFontSize: CGFloat { clamp(size.height * 0.019, min: 11, max: 15) } var ctaFontSize: CGFloat { clamp(size.height * 0.024, min: 14, max: 18) } var footerNoteFontSize: CGFloat { clamp(size.height * 0.017, min: 10, max: 13) } var footerLinkFontSize: CGFloat { clamp(size.height * 0.017, min: 10, max: 13) } 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 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 feature: PaywallFeature let metrics: PaywallLayoutMetrics var body: some View { HStack(spacing: 8) { Image(systemName: feature.systemImage) .font(.system(size: metrics.featureFontSize * 0.9, weight: .semibold)) .foregroundStyle(AppTheme.accentTeal) .frame(width: metrics.featureFontSize + 4) Text(feature.title) .font(.system(size: metrics.featureFontSize)) .foregroundStyle(AppTheme.textSecondary) .lineLimit(1) .minimumScaleFactor(0.85) } } } private struct PaywallPricingCard: View { let plan: PaywallPlan let isSelected: Bool let metrics: PaywallLayoutMetrics let onSelect: () -> Void var body: some View { Button(action: onSelect) { VStack(alignment: .leading, spacing: 0) { if plan.isBestValue { Text("BEST VALUE") .font(.system(size: metrics.featureFontSize * 0.75, weight: .bold)) .foregroundStyle(Color(hex: 0x0B0E14)) .padding(.horizontal, 10) .padding(.vertical, 4) .background(AppTheme.accentTeal) .clipShape(Capsule()) .frame(maxWidth: .infinity) .padding(.bottom, metrics.cardPadding) } else { Color.clear .frame(height: metrics.cardPadding * 1.6) .padding(.bottom, metrics.cardPadding * 0.4) } Text(plan.title) .font(.system(size: metrics.ctaFontSize, weight: .semibold)) .foregroundStyle(AppTheme.textPrimary) Text(plan.subtitle) .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.price) .font(.system(size: metrics.titleFontSize * 0.85, weight: .bold)) .foregroundStyle(AppTheme.textPrimary) if let suffix = plan.priceSuffix { Text(suffix) .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(plan.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 ) ) } .buttonStyle(.plain) } } #Preview { PaywallView(viewModel: PaywallViewModel(), onDismiss: {}) .frame(width: AppWindow.width, height: AppWindow.height) }