diff --git a/SportsTime/Configuration.storekit b/SportsTime/Configuration.storekit deleted file mode 100644 index adfa6cf..0000000 --- a/SportsTime/Configuration.storekit +++ /dev/null @@ -1,146 +0,0 @@ -{ - "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.88oakapps.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.88oakapps.SportsTime.pro.annual2", - "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 497ff67..17822cd 100644 --- a/SportsTime/Features/Paywall/Views/OnboardingPaywallView.swift +++ b/SportsTime/Features/Paywall/Views/OnboardingPaywallView.swift @@ -400,119 +400,11 @@ struct StadiumMapBackground: View { } } -/// 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)] = [ ( @@ -585,10 +477,6 @@ struct OnboardingPaywallView: View { .padding(.bottom, Theme.Spacing.xl) } .background(Theme.backgroundGradient(colorScheme)) - .task { - await storeManager.loadProducts() - selectedProduct = storeManager.annualProduct ?? storeManager.monthlyProduct - } } // MARK: - Feature Page @@ -668,98 +556,8 @@ struct OnboardingPaywallView: View { // 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()) + PaywallView() + .storeButton(.hidden, for: .cancellation) } // MARK: - Bottom Buttons @@ -781,193 +579,25 @@ struct OnboardingPaywallView: View { .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)) - } + // Continue free (always visible) + 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)) diff --git a/SportsTime/Features/Paywall/Views/PaywallView.swift b/SportsTime/Features/Paywall/Views/PaywallView.swift index bfcb2fa..c3d097a 100644 --- a/SportsTime/Features/Paywall/Views/PaywallView.swift +++ b/SportsTime/Features/Paywall/Views/PaywallView.swift @@ -2,307 +2,66 @@ // PaywallView.swift // SportsTime // -// Full-screen paywall for Pro subscription. +// Full-screen paywall for Pro subscription using SubscriptionStoreView. // 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? + @Environment(\.colorScheme) private var colorScheme private let storeManager = StoreManager.shared var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: Theme.Spacing.xl) { - // Hero - heroSection + SubscriptionStoreView(subscriptions: storeManager.products.filter { $0.subscription != nil }) { + VStack(spacing: Theme.Spacing.md) { + Image(systemName: "star.circle.fill") + .font(.system(size: 60)) + .foregroundStyle(Theme.warmOrange) - // 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()) + Text("Upgrade to Pro") + .font(.largeTitle.bold()) .foregroundStyle(Theme.textPrimary(colorScheme)) - Text(pricePerMonth) - .font(.caption) - .foregroundStyle(Theme.textMuted(colorScheme)) + Text("Unlock the full SportsTime experience") + .font(.body) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + HStack(spacing: Theme.Spacing.lg) { + featurePill(icon: "infinity", text: "Unlimited Trips") + featurePill(icon: "doc.fill", text: "PDF Export") + featurePill(icon: "trophy.fill", text: "Progress") + } + .padding(.top, Theme.Spacing.sm) } - .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) + } + .storeButton(.visible, for: .restorePurchases) + .subscriptionStoreControlStyle(.prominentPicker) + .subscriptionStoreButtonLabel(.displayName.multiline) + .onInAppPurchaseCompletion { _, result in + if case .success(.success) = result { + dismiss() } } - .buttonStyle(.plain) + .task { + await storeManager.loadProducts() + } } - private var pricePerMonth: String { - if product.id.contains("annual") { - let monthly = product.price / 12 - return "\(monthly.formatted(.currency(code: product.priceFormatStyle.currencyCode)))/mo" + private func featurePill(icon: String, text: String) -> some View { + HStack(spacing: 4) { + Image(systemName: icon) + .font(.system(size: 11)) + Text(text) + .font(.caption2) } - return "per month" + .foregroundStyle(Theme.textMuted(colorScheme)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Theme.warmOrange.opacity(0.08), in: Capsule()) } }