import SwiftUI import StoreKit // MARK: - Chart Preview (decorative, no real data) private struct PremiumChartPreview: View { // Normalized growth data representing a healthy upward portfolio trend private let points: [Double] = [0.52, 0.58, 0.55, 0.65, 0.70, 0.66, 0.78, 0.85, 0.82, 0.91, 0.88, 1.0] var body: some View { ZStack { RoundedRectangle(cornerRadius: 12) .fill(Color.appPrimary.opacity(0.08)) VStack(alignment: .leading, spacing: 0) { HStack(alignment: .firstTextBaseline) { Text("€24,750") .font(.system(size: 18, weight: .bold, design: .rounded)) .foregroundColor(.primary) Spacer() Text("+34.2%") .font(.caption.weight(.semibold)) .foregroundColor(.positiveGreen) .padding(.horizontal, 7) .padding(.vertical, 3) .background(Color.positiveGreen.opacity(0.12)) .clipShape(Capsule()) } .padding(.horizontal, 14) .padding(.top, 12) .padding(.bottom, 6) GeometryReader { geo in chartPaths(in: geo.size) } .padding(.bottom, 8) } } .frame(height: 96) } @ViewBuilder private func chartPaths(in size: CGSize) -> some View { let pts = chartPoints(in: size) // Gradient fill Path { path in guard !pts.isEmpty else { return } path.move(to: CGPoint(x: pts[0].x, y: size.height)) path.addLine(to: pts[0]) for i in 1.. [CGPoint] { guard points.count > 1 else { return [] } let stepX = size.width / CGFloat(points.count - 1) return points.enumerated().map { i, val in CGPoint(x: CGFloat(i) * stepX, y: size.height * (1.0 - val * 0.85)) } } } struct PaywallView: View { @Environment(\.dismiss) private var dismiss @EnvironmentObject var iapService: IAPService @State private var isPurchasing = false @State private var errorMessage: String? @State private var showingError = false /// Which plan the user has highlighted. Defaults to annual (primary offer) when /// the subscription exists in App Store Connect; falls back to lifetime otherwise. enum Plan { case annual, lifetime } @State private var selectedPlan: Plan = .lifetime var body: some View { ZStack(alignment: .topTrailing) { AppBackground() VStack(spacing: 0) { // Header headerSection .padding(.top, 48) .padding(.horizontal, 24) Spacer() // Key benefits benefitsSection .padding(.horizontal, 24) Spacer() // Bottom actions VStack(spacing: 12) { if iapService.annualProduct != nil { planSelector } else { priceLabel } purchaseButton restoreButton legalSection } .padding(.horizontal, 24) .padding(.bottom, 32) } // Close button Button { dismiss() } label: { Image(systemName: "xmark") .font(.system(size: 14, weight: .semibold)) .foregroundColor(.secondary) .padding(10) .background(Color(.secondarySystemBackground)) .clipShape(Circle()) } .padding(.top, 16) .padding(.trailing, 16) } .ignoresSafeArea() .alert("Error", isPresented: $showingError) { Button("OK", role: .cancel) {} } message: { Text(errorMessage ?? "An error occurred") } .onChange(of: iapService.isPremium) { _, isPremium in if isPremium { dismiss() } } } // MARK: - Header Section private var headerSection: some View { VStack(spacing: 12) { PremiumChartPreview() Text("Your full portfolio,\nfully clear") .font(.title.weight(.bold)) .multilineTextAlignment(.center) Text("One payment. Every feature. Forever.") .font(.subheadline) .foregroundColor(.secondary) .multilineTextAlignment(.center) } } // MARK: - Benefits Section private var benefitsSection: some View { VStack(spacing: 10) { ForEach(IAPService.paywallBenefits, id: \.title) { benefit in BenefitRow(icon: benefit.icon, title: benefit.title, subtitle: benefit.subtitle) } } .padding(20) .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } // MARK: - Price Label private var priceLabel: some View { HStack(spacing: 4) { Text(iapService.formattedPrice) .font(.system(size: 28, weight: .bold, design: .rounded)) .foregroundColor(.appPrimary) Text("· one-time · Family Sharing") .font(.footnote) .foregroundColor(.secondary) } } // MARK: - Plan Selector /// Two-option selector: annual subscription (primary, with free trial when /// configured) and lifetime unlock as the "best value" anchor. private var planSelector: some View { VStack(spacing: 8) { if let annualPrice = iapService.formattedAnnualPrice { planRow( plan: .annual, title: String(localized: "paywall_plan_annual"), price: String(format: String(localized: "paywall_plan_annual_per_year"), annualPrice), badge: iapService.annualTrialDescription ) } planRow( plan: .lifetime, title: String(localized: "paywall_plan_lifetime"), price: iapService.formattedPrice, badge: String(localized: "paywall_plan_lifetime_badge") ) } .onAppear { if iapService.annualProduct != nil { selectedPlan = .annual } } } private func planRow(plan: Plan, title: String, price: String, badge: String?) -> some View { Button { selectedPlan = plan } label: { HStack { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text(title) .font(.subheadline.weight(.semibold)) .foregroundColor(.primary) if let badge, !badge.isEmpty { Text(badge) .font(.caption2.weight(.bold)) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.appSecondary.opacity(0.15)) .foregroundColor(.appSecondary) .clipShape(Capsule()) } } Text(price) .font(.footnote) .foregroundColor(.secondary) } Spacer() Image(systemName: selectedPlan == plan ? "checkmark.circle.fill" : "circle") .foregroundColor(selectedPlan == plan ? .appPrimary : .secondary) } .padding(12) .background(Color(.systemBackground)) .overlay( RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius) .stroke(selectedPlan == plan ? Color.appPrimary : Color.gray.opacity(0.25), lineWidth: selectedPlan == plan ? 2 : 1) ) .cornerRadius(AppConstants.UI.smallCornerRadius) } .buttonStyle(.plain) } // MARK: - Purchase Button private var purchaseButton: some View { Button { purchase() } label: { HStack { if isPurchasing { ProgressView() .tint(.white) } else { Text("Get Full Access") .font(.headline) } } .frame(maxWidth: .infinity) .padding() .background(Color.appPrimary) .foregroundColor(.white) .cornerRadius(AppConstants.UI.cornerRadius) } .disabled(isPurchasing) } // MARK: - Restore Button private var restoreButton: some View { Button { restore() } label: { Text("Restore Purchases") .font(.subheadline) .foregroundColor(.appPrimary) } .disabled(isPurchasing) } // MARK: - Legal Section private var legalSection: some View { VStack(spacing: 4) { Text("Payment charged to your Apple ID account.") .font(.caption2) .foregroundColor(.secondary) .multilineTextAlignment(.center) HStack(spacing: 12) { Link("Terms", destination: URL(string: AppConstants.URLs.termsOfService)!) .font(.caption2) .foregroundColor(.secondary) Link("Privacy", destination: URL(string: AppConstants.URLs.privacyPolicy)!) .font(.caption2) .foregroundColor(.secondary) } } } // MARK: - Actions private func purchase() { isPurchasing = true let product = (selectedPlan == .annual ? iapService.annualProduct : iapService.premiumProduct) FirebaseService.shared.logPurchaseAttempt( productId: product?.id ?? IAPService.premiumProductID ) Task { do { if let product { try await iapService.purchase(product) } else { try await iapService.purchase() } } catch { errorMessage = error.localizedDescription showingError = true } isPurchasing = false } } private func restore() { isPurchasing = true Task { await iapService.restorePurchases() if !iapService.isPremium { errorMessage = "No purchases found to restore" showingError = true } isPurchasing = false } } } // MARK: - Benefit Row struct BenefitRow: View { let icon: String let title: String let subtitle: String var body: some View { HStack(spacing: 14) { Image(systemName: icon) .font(.system(size: 20)) .foregroundColor(.appPrimary) .frame(width: 28) VStack(alignment: .leading, spacing: 1) { Text(title) .font(.subheadline.weight(.semibold)) Text(subtitle) .font(.caption) .foregroundColor(.secondary) } Spacer() Image(systemName: "checkmark.circle.fill") .foregroundColor(.positiveGreen) .font(.system(size: 18)) } .padding(.vertical, 2) } } // MARK: - Feature Row (kept for backward compatibility) struct FeatureRow: View { let icon: String let title: String let description: String var body: some View { BenefitRow(icon: icon, title: title, subtitle: description) } } // MARK: - Compact Paywall (for inline use) struct CompactPaywallBanner: View { @EnvironmentObject var iapService: IAPService @Binding var showingPaywall: Bool var body: some View { HStack(spacing: 12) { Image(systemName: "crown.fill") .font(.title2) .foregroundStyle( LinearGradient( colors: [.yellow, .orange], startPoint: .topLeading, endPoint: .bottomTrailing ) ) VStack(alignment: .leading, spacing: 2) { Text("Full access, one payment") .font(.subheadline.weight(.semibold)) Text("Unlimited sources, advanced charts & more") .font(.caption) .foregroundColor(.secondary) } Spacer() Button { showingPaywall = true } label: { Text(iapService.formattedPrice) .font(.caption.weight(.semibold)) .padding(.horizontal, 12) .padding(.vertical, 6) .background(Color.appPrimary) .foregroundColor(.white) .cornerRadius(16) } } .padding() .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } } // MARK: - Premium Lock Overlay struct PremiumLockOverlay: View { let feature: String @Binding var showingPaywall: Bool var body: some View { VStack(spacing: 16) { Image(systemName: "crown.fill") .font(.system(size: 32)) .foregroundStyle( LinearGradient( colors: [.yellow, .orange], startPoint: .topLeading, endPoint: .bottomTrailing ) ) Text(feature) .font(.headline) .multilineTextAlignment(.center) Button { showingPaywall = true } label: { Text("See full access") .font(.subheadline.weight(.semibold)) .padding(.horizontal, 20) .padding(.vertical, 10) .background(Color.appPrimary) .foregroundColor(.white) .cornerRadius(20) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(.systemBackground).opacity(0.95)) } } #Preview { PaywallView() .environmentObject(IAPService()) }