// // PaywallView.swift // SportsTime // // Full-screen paywall for Pro subscription. // import SwiftUI import StoreKit struct PaywallView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.dismiss) private var dismiss @State private var selectedProduct: Product? @State private var isPurchasing = false @State private var errorMessage: String? private let storeManager = StoreManager.shared var body: some View { NavigationStack { ScrollView { VStack(spacing: Theme.Spacing.xl) { // Hero heroSection // Features featuresSection // Pricing pricingSection // Error message if let error = errorMessage { Text(error) .font(.subheadline) .foregroundStyle(.red) .multilineTextAlignment(.center) .padding(.horizontal) } // Subscribe button subscribeButton // Restore purchases restoreButton // Legal legalSection } .padding(Theme.Spacing.lg) } .themedBackground() .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button { dismiss() } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(Theme.textMuted(colorScheme)) } } } .task { await storeManager.loadProducts() // Default to annual selectedProduct = storeManager.annualProduct ?? storeManager.monthlyProduct } } } // MARK: - Hero Section private var heroSection: some View { VStack(spacing: Theme.Spacing.md) { Image(systemName: "star.circle.fill") .font(.system(size: 60)) .foregroundStyle(Theme.warmOrange) Text("Upgrade to Pro") .font(.largeTitle.bold()) .foregroundStyle(Theme.textPrimary(colorScheme)) Text("Unlock the full SportsTime experience") .font(.body) .foregroundStyle(Theme.textSecondary(colorScheme)) .multilineTextAlignment(.center) } } // MARK: - Features Section private var featuresSection: some View { VStack(spacing: Theme.Spacing.md) { ForEach(ProFeature.allCases) { feature in HStack(spacing: Theme.Spacing.md) { ZStack { Circle() .fill(Theme.warmOrange.opacity(0.15)) .frame(width: 44, height: 44) Image(systemName: feature.icon) .foregroundStyle(Theme.warmOrange) } VStack(alignment: .leading, spacing: 4) { Text(feature.displayName) .font(.body) .foregroundStyle(Theme.textPrimary(colorScheme)) Text(feature.description) .font(.subheadline) .foregroundStyle(Theme.textSecondary(colorScheme)) } Spacer() Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) } .padding(Theme.Spacing.md) .background(Theme.cardBackground(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) } } } // MARK: - Pricing Section private var pricingSection: some View { VStack(spacing: Theme.Spacing.md) { if storeManager.isLoading { ProgressView() .padding() } else { HStack(spacing: Theme.Spacing.md) { // Monthly option if let monthly = storeManager.monthlyProduct { PricingOptionCard( product: monthly, title: "Monthly", isSelected: selectedProduct?.id == monthly.id, savingsText: nil ) { selectedProduct = monthly } } // Annual option if let annual = storeManager.annualProduct { PricingOptionCard( product: annual, title: "Annual", isSelected: selectedProduct?.id == annual.id, savingsText: "Save 17%" ) { selectedProduct = annual } } } } } } // MARK: - Subscribe Button private var subscribeButton: some View { Button { Task { await purchase() } } label: { HStack { if isPurchasing { ProgressView() .tint(.white) } else { Text("Subscribe Now") .fontWeight(.semibold) } } .frame(maxWidth: .infinity) .padding(Theme.Spacing.md) .background(Theme.warmOrange) .foregroundStyle(.white) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) } .disabled(selectedProduct == nil || isPurchasing) .opacity(selectedProduct == nil ? 0.5 : 1) } // MARK: - Restore Button private var restoreButton: some View { Button { Task { await storeManager.restorePurchases() if storeManager.isPro { dismiss() } else { errorMessage = "No active subscription found." } } } label: { Text("Restore Purchases") .font(.subheadline) .foregroundStyle(Theme.warmOrange) } } // MARK: - Legal Section private var legalSection: some View { VStack(spacing: Theme.Spacing.xs) { Text("Subscriptions automatically renew unless cancelled at least 24 hours before the end of the current period.") .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) .multilineTextAlignment(.center) HStack(spacing: Theme.Spacing.md) { Link("Terms", destination: URL(string: "https://88oakapps.com/terms")!) Link("Privacy", destination: URL(string: "https://88oakapps.com/privacy")!) } .font(.caption) .foregroundStyle(Theme.warmOrange) } .padding(.top, Theme.Spacing.md) } // MARK: - Actions private func purchase() async { guard let product = selectedProduct else { return } isPurchasing = true errorMessage = nil do { try await storeManager.purchase(product) dismiss() } catch StoreError.userCancelled { // User cancelled, no error message } catch { errorMessage = error.localizedDescription } isPurchasing = false } } // MARK: - Pricing Option Card struct PricingOptionCard: View { let product: Product let title: String let isSelected: Bool let savingsText: String? let onSelect: () -> Void @Environment(\.colorScheme) private var colorScheme var body: some View { Button(action: onSelect) { VStack(spacing: Theme.Spacing.sm) { if let savings = savingsText { Text(savings) .font(.caption.bold()) .foregroundStyle(.white) .padding(.horizontal, 8) .padding(.vertical, 4) .background(Theme.warmOrange, in: Capsule()) } Text(title) .font(.subheadline) .foregroundStyle(Theme.textSecondary(colorScheme)) Text(product.displayPrice) .font(.title2.bold()) .foregroundStyle(Theme.textPrimary(colorScheme)) Text(pricePerMonth) .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) } .frame(maxWidth: .infinity) .padding(Theme.Spacing.lg) .background(Theme.cardBackground(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) .overlay { RoundedRectangle(cornerRadius: Theme.CornerRadius.large) .stroke(isSelected ? Theme.warmOrange : Theme.surfaceGlow(colorScheme), lineWidth: isSelected ? 2 : 1) } } .buttonStyle(.plain) } private var pricePerMonth: String { if product.id.contains("annual") { let monthly = product.price / 12 return "\(monthly.formatted(.currency(code: product.priceFormatStyle.currencyCode)))/mo" } return "per month" } } #Preview { PaywallView() }