// // OnboardingPaywallView.swift // SportsTime // // First-launch upsell with feature pages. // import SwiftUI import StoreKit // MARK: - Background Components /// Route lines background for Unlimited Trips page struct TripRoutesBackground: View { let color: Color @State private var animate = false @State private var pulsePhase: CGFloat = 0 var body: some View { ZStack { // Main route canvas Canvas { context, size in // City points scattered across the view let points: [CGPoint] = [ CGPoint(x: size.width * 0.12, y: size.height * 0.18), CGPoint(x: size.width * 0.35, y: size.height * 0.32), CGPoint(x: size.width * 0.58, y: size.height * 0.12), CGPoint(x: size.width * 0.78, y: size.height * 0.38), CGPoint(x: size.width * 0.22, y: size.height * 0.52), CGPoint(x: size.width * 0.62, y: size.height * 0.62), CGPoint(x: size.width * 0.88, y: size.height * 0.22), CGPoint(x: size.width * 0.42, y: size.height * 0.78), CGPoint(x: size.width * 0.82, y: size.height * 0.68), CGPoint(x: size.width * 0.08, y: size.height * 0.72), CGPoint(x: size.width * 0.5, y: size.height * 0.42), CGPoint(x: size.width * 0.92, y: size.height * 0.52), ] // Draw dotted route lines connecting points let routePairs: [(Int, Int)] = [ (0, 1), (1, 3), (3, 6), (2, 6), (1, 4), (4, 5), (5, 8), (4, 9), (5, 7), (7, 9), (2, 3), (1, 10), (10, 5), (3, 11), (8, 11), (0, 9) ] for (start, end) in routePairs { var path = Path() path.move(to: points[start]) path.addLine(to: points[end]) context.stroke( path, with: .color(color.opacity(0.22)), style: StrokeStyle(lineWidth: 2.5, dash: [8, 5]) ) } // Draw city dots with varying sizes for (index, point) in points.enumerated() { let isMainCity = index % 3 == 0 let dotSize: CGFloat = isMainCity ? 10 : 6 let ringSize: CGFloat = isMainCity ? 18 : 12 // Glow effect for main cities if isMainCity { let glowPath = Path(ellipseIn: CGRect( x: point.x - 14, y: point.y - 14, width: 28, height: 28 )) context.fill(glowPath, with: .color(color.opacity(0.08))) } // Outer ring let ringPath = Path(ellipseIn: CGRect( x: point.x - ringSize / 2, y: point.y - ringSize / 2, width: ringSize, height: ringSize )) context.stroke(ringPath, with: .color(color.opacity(0.18)), lineWidth: 1.5) // Inner dot let dotPath = Path(ellipseIn: CGRect( x: point.x - dotSize / 2, y: point.y - dotSize / 2, width: dotSize, height: dotSize )) context.fill(dotPath, with: .color(color.opacity(isMainCity ? 0.35 : 0.25))) } } // Animated car/plane icon traveling along a route TravelingIcon(color: color, animate: animate) } .onAppear { withAnimation(.easeInOut(duration: 2).repeatForever(autoreverses: true)) { animate = true } } } } /// Small animated icon that travels across the background private struct TravelingIcon: View { let color: Color let animate: Bool @State private var position: CGFloat = 0 var body: some View { GeometryReader { geo in Image(systemName: "car.fill") .font(.system(size: 14)) .foregroundStyle(color.opacity(0.3)) .position( x: geo.size.width * (0.15 + position * 0.7), y: geo.size.height * (0.2 + sin(position * .pi * 2) * 0.15) ) } .onAppear { withAnimation(.linear(duration: 8).repeatForever(autoreverses: false)) { position = 1 } } } } /// Floating documents background for Export & Share page struct DocumentsBackground: View { let color: Color @State private var animate = false var body: some View { ZStack { // Connecting share lines in background Canvas { context, size in let centerX = size.width * 0.5 let centerY = size.height * 0.5 // Draw radiating lines from center (like sharing to multiple destinations) let endpoints: [CGPoint] = [ CGPoint(x: size.width * 0.1, y: size.height * 0.2), CGPoint(x: size.width * 0.9, y: size.height * 0.15), CGPoint(x: size.width * 0.05, y: size.height * 0.6), CGPoint(x: size.width * 0.95, y: size.height * 0.55), CGPoint(x: size.width * 0.15, y: size.height * 0.85), CGPoint(x: size.width * 0.85, y: size.height * 0.9), ] for endpoint in endpoints { var path = Path() path.move(to: CGPoint(x: centerX, y: centerY)) path.addLine(to: endpoint) context.stroke( path, with: .color(color.opacity(0.08)), style: StrokeStyle(lineWidth: 1.5, dash: [4, 6]) ) // Small circle at endpoint let dotPath = Path(ellipseIn: CGRect( x: endpoint.x - 4, y: endpoint.y - 4, width: 8, height: 8 )) context.fill(dotPath, with: .color(color.opacity(0.12))) } } // Scattered document icons with enhanced visibility ForEach(0..<12, id: \.self) { index in documentIcon(index: index) } // Central PDF badge GeometryReader { geo in ZStack { RoundedRectangle(cornerRadius: 6) .fill(color.opacity(0.08)) .frame(width: 50, height: 60) VStack(spacing: 2) { Image(systemName: "doc.text.fill") .font(.system(size: 24)) Text("PDF") .font(.system(size: 10, weight: .bold)) } .foregroundStyle(color.opacity(0.2)) } .position(x: geo.size.width * 0.5, y: geo.size.height * 0.45) .scaleEffect(animate ? 1.05 : 0.95) } } .onAppear { withAnimation(.easeInOut(duration: 2.5).repeatForever(autoreverses: true)) { animate = true } } } @ViewBuilder private func documentIcon(index: Int) -> some View { let positions: [(x: CGFloat, y: CGFloat, rotation: Double, scale: CGFloat)] = [ (0.08, 0.12, -18, 0.85), (0.88, 0.18, 14, 0.75), (0.18, 0.38, 10, 0.65), (0.92, 0.45, -12, 0.9), (0.12, 0.72, 22, 0.75), (0.82, 0.78, -10, 0.85), (0.48, 0.08, 6, 0.7), (0.58, 0.88, -14, 0.8), (0.05, 0.55, 8, 0.6), (0.95, 0.32, -6, 0.7), (0.35, 0.92, 16, 0.65), (0.72, 0.05, -20, 0.75), ] let icons = ["doc.fill", "doc.text.fill", "square.and.arrow.up", "doc.richtext.fill", "doc.fill", "square.and.arrow.up.fill", "doc.text.fill", "doc.fill", "square.and.arrow.up", "doc.richtext.fill", "doc.fill", "square.and.arrow.up.fill"] let pos = positions[index] GeometryReader { geo in Image(systemName: icons[index]) .font(.system(size: 32 * pos.scale)) .foregroundStyle(color.opacity(0.18)) .rotationEffect(.degrees(pos.rotation)) .position(x: geo.size.width * pos.x, y: geo.size.height * pos.y) .offset(y: animate ? -8 : 8) .animation( .easeInOut(duration: 2.2 + Double(index) * 0.25) .repeatForever(autoreverses: true) .delay(Double(index) * 0.15), value: animate ) } } } /// Stadium map background for Track Your Journey page struct StadiumMapBackground: View { let color: Color @State private var animate = false @State private var checkmarkScale: CGFloat = 0 var body: some View { ZStack { // Map grid canvas Canvas { context, size in // Draw subtle grid lines like a map let gridSpacing: CGFloat = 35 // Horizontal lines var y: CGFloat = 0 while y < size.height { var path = Path() path.move(to: CGPoint(x: 0, y: y)) path.addLine(to: CGPoint(x: size.width, y: y)) context.stroke(path, with: .color(color.opacity(0.06)), lineWidth: 0.5) y += gridSpacing } // Vertical lines var x: CGFloat = 0 while x < size.width { var path = Path() path.move(to: CGPoint(x: x, y: 0)) path.addLine(to: CGPoint(x: x, y: size.height)) context.stroke(path, with: .color(color.opacity(0.06)), lineWidth: 0.5) x += gridSpacing } // Stadium pin markers - more of them now let pinPositions: [(CGPoint, Bool)] = [ (CGPoint(x: size.width * 0.15, y: size.height * 0.18), true), (CGPoint(x: size.width * 0.72, y: size.height * 0.15), true), (CGPoint(x: size.width * 0.38, y: size.height * 0.35), false), (CGPoint(x: size.width * 0.88, y: size.height * 0.32), true), (CGPoint(x: size.width * 0.12, y: size.height * 0.55), false), (CGPoint(x: size.width * 0.55, y: size.height * 0.48), true), (CGPoint(x: size.width * 0.82, y: size.height * 0.58), false), (CGPoint(x: size.width * 0.28, y: size.height * 0.72), true), (CGPoint(x: size.width * 0.65, y: size.height * 0.75), false), (CGPoint(x: size.width * 0.08, y: size.height * 0.85), false), (CGPoint(x: size.width * 0.92, y: size.height * 0.78), true), (CGPoint(x: size.width * 0.48, y: size.height * 0.88), false), ] for (pos, isVisited) in pinPositions { // Pin drop shadow let shadowPath = Path(ellipseIn: CGRect(x: pos.x - 7, y: pos.y + 12, width: 14, height: 5)) context.fill(shadowPath, with: .color(.black.opacity(0.1))) // Pin body (teardrop shape) var pinPath = Path() pinPath.move(to: CGPoint(x: pos.x, y: pos.y + 14)) pinPath.addQuadCurve( to: CGPoint(x: pos.x - 10, y: pos.y - 5), control: CGPoint(x: pos.x - 12, y: pos.y + 7) ) pinPath.addArc( center: CGPoint(x: pos.x, y: pos.y - 5), radius: 10, startAngle: .degrees(180), endAngle: .degrees(0), clockwise: false ) pinPath.addQuadCurve( to: CGPoint(x: pos.x, y: pos.y + 14), control: CGPoint(x: pos.x + 12, y: pos.y + 7) ) // Visited pins are filled, unvisited are outlined if isVisited { context.fill(pinPath, with: .color(color.opacity(0.28))) // Checkmark inside visited pins let checkCenter = CGPoint(x: pos.x, y: pos.y - 5) var checkPath = Path() checkPath.move(to: CGPoint(x: checkCenter.x - 4, y: checkCenter.y)) checkPath.addLine(to: CGPoint(x: checkCenter.x - 1, y: checkCenter.y + 3)) checkPath.addLine(to: CGPoint(x: checkCenter.x + 4, y: checkCenter.y - 3)) context.stroke(checkPath, with: .color(.white.opacity(0.6)), lineWidth: 2) } else { context.stroke(pinPath, with: .color(color.opacity(0.2)), lineWidth: 2) // Empty dot for unvisited let dotPath = Path(ellipseIn: CGRect(x: pos.x - 4, y: pos.y - 9, width: 8, height: 8)) context.stroke(dotPath, with: .color(color.opacity(0.15)), lineWidth: 1.5) } } } // Floating achievement badges ForEach(0..<3, id: \.self) { index in achievementBadge(index: index) } // Progress counter badge GeometryReader { geo in HStack(spacing: 4) { Text("6") .font(.system(size: 18, weight: .bold, design: .rounded)) Text("/") .font(.system(size: 14)) Text("12") .font(.system(size: 14, weight: .medium, design: .rounded)) } .foregroundStyle(color.opacity(0.25)) .padding(.horizontal, 12) .padding(.vertical, 6) .background(color.opacity(0.06), in: Capsule()) .position(x: geo.size.width * 0.5, y: geo.size.height * 0.92) } } .onAppear { withAnimation(.easeInOut(duration: 2.5).repeatForever(autoreverses: true)) { animate = true } withAnimation(.spring(response: 0.6, dampingFraction: 0.6).delay(0.3)) { checkmarkScale = 1 } } } @ViewBuilder private func achievementBadge(index: Int) -> some View { let badges: [(x: CGFloat, y: CGFloat, icon: String, rotation: Double)] = [ (0.12, 0.08, "trophy.fill", -12), (0.88, 0.1, "star.fill", 8), (0.9, 0.88, "medal.fill", -6), ] let badge = badges[index] GeometryReader { geo in ZStack { Circle() .fill(color.opacity(0.1)) .frame(width: 36, height: 36) Image(systemName: badge.icon) .font(.system(size: 16)) .foregroundStyle(color.opacity(0.25)) } .rotationEffect(.degrees(badge.rotation)) .position(x: geo.size.width * badge.x, y: geo.size.height * badge.y) .scaleEffect(animate ? 1.08 : 0.95) .animation( .easeInOut(duration: 2 + Double(index) * 0.3) .repeatForever(autoreverses: true) .delay(Double(index) * 0.2), value: animate ) } } } /// Premium pricing background with sports icons struct PricingBackground: View { @State private var animate = false var body: some View { ZStack { // Floating sports icons with glow effects ForEach(0..<12, id: \.self) { index in SportsIconWithGlow(index: index, animate: animate) } } .onAppear { withAnimation(.easeInOut(duration: 2).repeatForever(autoreverses: true)) { animate = true } } } } /// Individual sports icon with random glow/flash animation private struct SportsIconWithGlow: View { let index: Int let animate: Bool @State private var isGlowing = false @State private var glowOpacity: Double = 0 private let configs: [(x: CGFloat, y: CGFloat, icon: String, rotation: Double, scale: CGFloat)] = [ (0.08, 0.1, "football.fill", -20, 0.95), (0.92, 0.12, "basketball.fill", 15, 0.9), (0.05, 0.35, "baseball.fill", 10, 0.85), (0.95, 0.38, "hockey.puck.fill", -12, 0.8), (0.1, 0.55, "soccerball", 8, 0.9), (0.9, 0.52, "figure.run", -8, 0.95), (0.06, 0.75, "sportscourt.fill", 5, 0.85), (0.94, 0.78, "trophy.fill", -15, 0.9), (0.12, 0.92, "ticket.fill", 12, 0.8), (0.88, 0.95, "mappin.circle.fill", -10, 0.85), (0.5, 0.05, "car.fill", 0, 0.8), (0.5, 0.98, "map.fill", 5, 0.85), ] var body: some View { let config = configs[index] GeometryReader { geo in ZStack { // Glow circle behind icon when active Circle() .fill(Theme.warmOrange) .frame(width: 40 * config.scale, height: 40 * config.scale) .blur(radius: 12) .opacity(glowOpacity * 0.4) Image(systemName: config.icon) .font(.system(size: 26 * config.scale)) .foregroundStyle(Theme.warmOrange.opacity(0.15 + glowOpacity * 0.25)) .rotationEffect(.degrees(config.rotation)) } .position(x: geo.size.width * config.x, y: geo.size.height * config.y) .scaleEffect(animate ? 1.08 : 0.95) .scaleEffect(1 + glowOpacity * 0.15) .animation( .easeInOut(duration: 2.5 + Double(index) * 0.1) .repeatForever(autoreverses: true) .delay(Double(index) * 0.1), value: animate ) } .onAppear { startRandomGlow() } } private func startRandomGlow() { // Random delay before first glow (stagger the icons) let initialDelay = Double.random(in: 0.5...3.0) DispatchQueue.main.asyncAfter(deadline: .now() + initialDelay) { triggerGlow() } } private func triggerGlow() { // Glow on withAnimation(.easeIn(duration: 0.3)) { glowOpacity = 1 } // Glow off after a moment DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { withAnimation(.easeOut(duration: 0.6)) { glowOpacity = 0 } } // Schedule next glow with random interval let nextGlow = Double.random(in: 2.5...6.0) DispatchQueue.main.asyncAfter(deadline: .now() + nextGlow) { triggerGlow() } } } struct OnboardingPaywallView: View { @Environment(\.colorScheme) private var colorScheme @Binding var isPresented: Bool @State private var currentPage = 0 @State private var selectedProduct: Product? @State private var isPurchasing = false @State private var errorMessage: String? private let storeManager = StoreManager.shared private let pages: [(icon: String, title: String, subtitle: String, bullets: [String], color: Color)] = [ ( "suitcase.fill", "Unlimited Trips", "Your ultimate sports road trip companion", [ "Plan unlimited multi-city adventures", "Smart routing finds the best game combinations", "Save and revisit your favorite itineraries", "Never miss a rivalry game or playoff matchup" ], Theme.warmOrange ), ( "doc.fill", "Export & Share", "Beautiful itineraries at your fingertips", [ "Generate stunning PDF trip guides", "Share plans with friends and family", "Includes maps, schedules, and travel times", "Perfect for group trips and coordination" ], Theme.routeGold ), ( "trophy.fill", "Track Your Journey", "Build your sports legacy", [ "Log every stadium you visit", "Earn badges for milestones and achievements", "Complete your bucket list across all leagues", "Share your progress with fellow fans" ], .green ) ] var body: some View { VStack(spacing: 0) { // Page content TabView(selection: $currentPage) { ForEach(0.. some View { let page = pages[index] return ZStack { // Themed background based on page backgroundForPage(index: index, color: page.color) VStack(spacing: Theme.Spacing.lg) { Spacer() ZStack { Circle() .fill(page.color.opacity(0.15)) .frame(width: 100, height: 100) Image(systemName: page.icon) .font(.system(size: 44)) .foregroundStyle(page.color) } VStack(spacing: Theme.Spacing.sm) { Text(page.title) .font(.title.bold()) .foregroundStyle(Theme.textPrimary(colorScheme)) Text(page.subtitle) .font(.subheadline) .foregroundStyle(Theme.textSecondary(colorScheme)) } // Feature bullets VStack(alignment: .leading, spacing: Theme.Spacing.sm) { ForEach(page.bullets, id: \.self) { bullet in HStack(alignment: .top, spacing: Theme.Spacing.sm) { Image(systemName: "checkmark.circle.fill") .foregroundStyle(page.color) .font(.body) Text(bullet) .font(.body) .foregroundStyle(Theme.textPrimary(colorScheme)) Spacer() } } } .padding(.horizontal, Theme.Spacing.xl) .padding(.top, Theme.Spacing.md) Spacer() Spacer() } } } @ViewBuilder private func backgroundForPage(index: Int, color: Color) -> some View { switch index { case 0: // Unlimited Trips - route map with dotted paths TripRoutesBackground(color: color) case 1: // Export & Share - floating documents DocumentsBackground(color: color) case 2: // Track Your Journey - stadium map with pins StadiumMapBackground(color: color) default: EmptyView() } } // MARK: - Pricing Page private var pricingPage: some View { ZStack { // Premium background PricingBackground() VStack(spacing: Theme.Spacing.lg) { Spacer() // Header with crown icon VStack(spacing: Theme.Spacing.sm) { ZStack { Circle() .fill(Theme.warmOrange.opacity(0.15)) .frame(width: 70, height: 70) Image(systemName: "crown.fill") .font(.system(size: 30)) .foregroundStyle(Theme.warmOrange) } Text("Choose Your Plan") .font(.title.bold()) .foregroundStyle(Theme.textPrimary(colorScheme)) Text("Unlock the full SportsTime experience") .font(.subheadline) .foregroundStyle(Theme.textSecondary(colorScheme)) } if storeManager.isLoading { ProgressView() } else { VStack(spacing: Theme.Spacing.md) { // Annual (recommended) if let annual = storeManager.annualProduct { OnboardingPricingRow( product: annual, title: "Annual", subtitle: "Best Value - Save 17%", isSelected: selectedProduct?.id == annual.id, isRecommended: true ) { selectedProduct = annual } } // Monthly if let monthly = storeManager.monthlyProduct { OnboardingPricingRow( product: monthly, title: "Monthly", subtitle: "Flexible billing", isSelected: selectedProduct?.id == monthly.id, isRecommended: false ) { selectedProduct = monthly } } } .padding(.horizontal, Theme.Spacing.lg) } if let error = errorMessage { Text(error) .font(.caption) .foregroundStyle(.red) } // Features summary HStack(spacing: Theme.Spacing.lg) { featurePill(icon: "infinity", text: "Unlimited") featurePill(icon: "doc.fill", text: "PDF Export") featurePill(icon: "trophy.fill", text: "Progress") } .padding(.top, Theme.Spacing.md) Spacer() Spacer() } } } private func featurePill(icon: String, text: String) -> some View { HStack(spacing: 4) { Image(systemName: icon) .font(.system(size: 11)) Text(text) .font(.caption2) } .foregroundStyle(Theme.textMuted(colorScheme)) .padding(.horizontal, 10) .padding(.vertical, 6) .background(Theme.warmOrange.opacity(0.08), in: Capsule()) } // MARK: - Bottom Buttons private var bottomButtons: some View { VStack(spacing: Theme.Spacing.md) { if currentPage < pages.count { // Next button Button { withAnimation { currentPage += 1 } } label: { Text("Next") .fontWeight(.semibold) .frame(maxWidth: .infinity) .padding(Theme.Spacing.md) .background(Theme.warmOrange) .foregroundStyle(.white) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) } // Skip Button { markOnboardingSeen() isPresented = false } label: { Text("Continue with Free") .font(.subheadline) .foregroundStyle(Theme.textMuted(colorScheme)) } } else { // Subscribe button Button { Task { await purchase() } } label: { HStack { if isPurchasing { ProgressView() .tint(.white) } else { Text("Subscribe") .fontWeight(.semibold) } } .frame(maxWidth: .infinity) .padding(Theme.Spacing.md) .background(Theme.warmOrange) .foregroundStyle(.white) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) } .disabled(selectedProduct == nil || isPurchasing) // Continue free Button { markOnboardingSeen() isPresented = false } label: { Text("Continue with Free") .font(.subheadline) .foregroundStyle(Theme.textMuted(colorScheme)) } } } } // MARK: - Actions private func purchase() async { guard let product = selectedProduct else { return } isPurchasing = true errorMessage = nil do { try await storeManager.purchase(product) markOnboardingSeen() isPresented = false } catch StoreError.userCancelled { // User cancelled } catch { errorMessage = error.localizedDescription } isPurchasing = false } private func markOnboardingSeen() { UserDefaults.standard.set(true, forKey: "hasSeenOnboardingPaywall") } } // MARK: - Pricing Row struct OnboardingPricingRow: View { let product: Product let title: String let subtitle: String let isSelected: Bool let isRecommended: Bool let onSelect: () -> Void @Environment(\.colorScheme) private var colorScheme /// The introductory offer for this product, if available private var introOffer: Product.SubscriptionOffer? { product.subscription?.introductoryOffer } /// Whether this product has an intro offer to display private var hasIntroOffer: Bool { introOffer != nil } /// Format the intro offer period (e.g., "1 month", "1 year") private var introPeriodText: String? { guard let offer = introOffer else { return nil } let unit = offer.period.unit let value = offer.period.value switch unit { case .day: return value == 1 ? "day" : "\(value) days" case .week: return value == 1 ? "week" : "\(value) weeks" case .month: return value == 1 ? "month" : "\(value) months" case .year: return value == 1 ? "year" : "\(value) years" @unknown default: return nil } } /// The regular period text (e.g., "/month", "/year") private var regularPeriodText: String { guard let period = product.subscription?.subscriptionPeriod else { return "" } switch period.unit { case .month: return "/month" case .year: return "/year" case .week: return "/week" case .day: return "/day" @unknown default: return "" } } var body: some View { Button(action: onSelect) { HStack { VStack(alignment: .leading, spacing: 4) { HStack(spacing: Theme.Spacing.xs) { Text(title) .font(.body.bold()) .foregroundStyle(Theme.textPrimary(colorScheme)) if isRecommended { Text("BEST") .font(.caption2.bold()) .foregroundStyle(.white) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Theme.warmOrange, in: Capsule()) } } if hasIntroOffer, let periodText = introPeriodText { Text("First \(periodText) at intro price") .font(.caption) .foregroundStyle(.green) } else { Text(subtitle) .font(.caption) .foregroundStyle(Theme.textSecondary(colorScheme)) } } Spacer() VStack(alignment: .trailing, spacing: 2) { if let offer = introOffer { // Show intro price prominently Text(offer.displayPrice) .font(.title3.bold()) .foregroundStyle(Theme.textPrimary(colorScheme)) // Show regular price as "then $X.XX/period" Text("then \(product.displayPrice)\(regularPeriodText)") .font(.caption2) .foregroundStyle(Theme.textMuted(colorScheme)) } else { // No intro offer - show regular price Text(product.displayPrice) .font(.title3.bold()) .foregroundStyle(Theme.textPrimary(colorScheme)) } } Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") .foregroundStyle(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme)) } .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) } } #Preview { OnboardingPaywallView(isPresented: .constant(true)) }