diff --git a/SportsTime/Configuration.storekit b/SportsTime/Configuration.storekit new file mode 100644 index 0000000..6f99614 --- /dev/null +++ b/SportsTime/Configuration.storekit @@ -0,0 +1,146 @@ +{ + "identifier" : "D8F3A2B1-4E5C-6D7F-8A9B-0C1D2E3F4A5B", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + + ], + "settings" : { + "_applicationInternalID" : "6741234567", + "_developerTeamID" : "V3PF3M6B6U", + "_failTransactionsEnabled" : false, + "_locale" : "en_US", + "_storefront" : "USA", + "_storeKitErrors" : [ + { + "current" : null, + "enabled" : false, + "name" : "Load Products" + }, + { + "current" : null, + "enabled" : false, + "name" : "Purchase" + }, + { + "current" : null, + "enabled" : false, + "name" : "Verification" + }, + { + "current" : null, + "enabled" : false, + "name" : "App Store Sync" + }, + { + "current" : null, + "enabled" : false, + "name" : "Subscription Status" + }, + { + "current" : null, + "enabled" : false, + "name" : "App Transaction" + }, + { + "current" : null, + "enabled" : false, + "name" : "Manage Subscriptions Sheet" + }, + { + "current" : null, + "enabled" : false, + "name" : "Refund Request Sheet" + }, + { + "current" : null, + "enabled" : false, + "name" : "Offer Code Redeem Sheet" + } + ] + }, + "subscriptionGroups" : [ + { + "id" : "21514523", + "localizations" : [ + + ], + "name" : "SportsTime Pro", + "subscriptions" : [ + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "2.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "6741234568", + "introductoryOffer" : { + "displayPrice" : "0.99", + "internalID" : "6741234569", + "numberOfPeriods" : 1, + "paymentMode" : "payAsYouGo", + "subscriptionPeriod" : "P1M" + }, + "localizations" : [ + { + "description" : "Unlimited trips, PDF export, and progress tracking", + "displayName" : "Monthly", + "locale" : "en_US" + } + ], + "productID" : "com.sportstime.pro.monthly", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "Pro Monthly", + "subscriptionGroupID" : "21514523", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "29.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "6741234570", + "introductoryOffer" : { + "displayPrice" : "19.99", + "internalID" : "6741234571", + "numberOfPeriods" : 1, + "paymentMode" : "payAsYouGo", + "subscriptionPeriod" : "P1Y" + }, + "localizations" : [ + { + "description" : "Unlimited trips, PDF export, and progress tracking - Best Value!", + "displayName" : "Annual", + "locale" : "en_US" + } + ], + "productID" : "com.sportstime.pro.annual", + "recurringSubscriptionPeriod" : "P1Y", + "referenceName" : "Pro Annual", + "subscriptionGroupID" : "21514523", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + } + ] + } + ], + "version" : { + "major" : 4, + "minor" : 0 + } +} diff --git a/SportsTime/Features/Paywall/Views/OnboardingPaywallView.swift b/SportsTime/Features/Paywall/Views/OnboardingPaywallView.swift index 9c9ac36..497ff67 100644 --- a/SportsTime/Features/Paywall/Views/OnboardingPaywallView.swift +++ b/SportsTime/Features/Paywall/Views/OnboardingPaywallView.swift @@ -8,6 +8,501 @@ 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 @@ -19,10 +514,43 @@ struct OnboardingPaywallView: View { private let storeManager = StoreManager.shared - private let pages: [(icon: String, title: String, description: String, color: Color)] = [ - ("suitcase.fill", "Unlimited Trips", "Plan as many road trips as you want. Never lose your itineraries.", Theme.warmOrange), - ("doc.fill", "Export & Share", "Generate beautiful PDF itineraries to share with friends.", Theme.routeGold), - ("trophy.fill", "Track Your Journey", "Log stadium visits, earn badges, complete your bucket list.", .green) + 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 { @@ -68,90 +596,172 @@ struct OnboardingPaywallView: View { private func featurePage(index: Int) -> some View { let page = pages[index] - return VStack(spacing: Theme.Spacing.xl) { - Spacer() + return ZStack { + // Themed background based on page + backgroundForPage(index: index, color: page.color) - ZStack { - Circle() - .fill(page.color.opacity(0.15)) - .frame(width: 120, height: 120) + VStack(spacing: Theme.Spacing.lg) { + Spacer() - Image(systemName: page.icon) - .font(.system(size: 50)) - .foregroundStyle(page.color) + 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() } + } + } - VStack(spacing: Theme.Spacing.md) { - Text(page.title) - .font(.title.bold()) - .foregroundStyle(Theme.textPrimary(colorScheme)) - - Text(page.description) - .font(.body) - .foregroundStyle(Theme.textSecondary(colorScheme)) - .multilineTextAlignment(.center) - .padding(.horizontal, Theme.Spacing.xl) - } - - 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 { - VStack(spacing: Theme.Spacing.lg) { - Spacer() + ZStack { + // Premium background + PricingBackground() - Text("Choose Your Plan") - .font(.title.bold()) - .foregroundStyle(Theme.textPrimary(colorScheme)) + VStack(spacing: Theme.Spacing.lg) { + Spacer() - 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 - } + // 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) } - // Monthly - if let monthly = storeManager.monthlyProduct { - OnboardingPricingRow( - product: monthly, - title: "Monthly", - subtitle: "Flexible billing", - isSelected: selectedProduct?.id == monthly.id, - isRecommended: false - ) { - selectedProduct = monthly - } - } + Text("Choose Your Plan") + .font(.title.bold()) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Text("Unlock the full SportsTime experience") + .font(.subheadline) + .foregroundStyle(Theme.textSecondary(colorScheme)) } - .padding(.horizontal, Theme.Spacing.lg) - } - if let error = errorMessage { - Text(error) - .font(.caption) - .foregroundStyle(.red) - } + 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 + } + } - Spacer() - Spacer() + // 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 { @@ -256,6 +866,43 @@ struct OnboardingPricingRow: View { @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 { @@ -275,16 +922,37 @@ struct OnboardingPricingRow: View { } } - Text(subtitle) - .font(.caption) - .foregroundStyle(Theme.textSecondary(colorScheme)) + 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() - Text(product.displayPrice) - .font(.title3.bold()) - .foregroundStyle(Theme.textPrimary(colorScheme)) + 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)) diff --git a/SportsTime/Features/Settings/Views/SettingsView.swift b/SportsTime/Features/Settings/Views/SettingsView.swift index 462e2e2..1bd8166 100644 --- a/SportsTime/Features/Settings/Views/SettingsView.swift +++ b/SportsTime/Features/Settings/Views/SettingsView.swift @@ -10,6 +10,7 @@ struct SettingsView: View { @State private var viewModel = SettingsViewModel() @State private var showResetConfirmation = false @State private var showPaywall = false + @State private var showOnboardingPaywall = false var body: some View { List { @@ -33,6 +34,11 @@ struct SettingsView: View { // Reset resetSection + + #if DEBUG + // Debug + debugSection + #endif } .scrollContentBackground(.hidden) .themedBackground() @@ -44,6 +50,9 @@ struct SettingsView: View { } message: { Text("This will reset all settings to their default values.") } + .sheet(isPresented: $showOnboardingPaywall) { + OnboardingPaywallView(isPresented: $showOnboardingPaywall) + } } // MARK: - Theme Section @@ -246,6 +255,31 @@ struct SettingsView: View { .listRowBackground(Theme.cardBackground(colorScheme)) } + // MARK: - Debug Section + + #if DEBUG + private var debugSection: some View { + Section { + Button { + showOnboardingPaywall = true + } label: { + Label("Show Onboarding Flow", systemImage: "play.circle") + } + + Button { + UserDefaults.standard.removeObject(forKey: "hasSeenOnboardingPaywall") + } label: { + Label("Reset Onboarding Flag", systemImage: "arrow.counterclockwise") + } + } header: { + Text("Debug") + } footer: { + Text("These options are only visible in debug builds.") + } + .listRowBackground(Theme.cardBackground(colorScheme)) + } + #endif + // MARK: - Subscription Section private var subscriptionSection: some View {