From c153b16593e03757628f31e58914946d261f418a Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Tue, 3 Feb 2026 16:11:12 -0600 Subject: [PATCH] using loal swift paywall Signed-off-by: Matt Bruce --- .../UserInterfaceState.xcuserstate | Bin 38146 -> 39365 bytes .../Configuration/Secrets.swift.template | 8 +- .../Paywall/Views/ProPaywallView.swift | 327 ++++++++++++------ SelfieCam/Resources/Localizable.xcstrings | 73 +++- .../Shared/Premium/PaywallPresenter.swift | 136 +------- 5 files changed, 281 insertions(+), 263 deletions(-) diff --git a/SelfieCam.xcodeproj/project.xcworkspace/xcuserdata/mattbruce.xcuserdatad/UserInterfaceState.xcuserstate b/SelfieCam.xcodeproj/project.xcworkspace/xcuserdata/mattbruce.xcuserdatad/UserInterfaceState.xcuserstate index ef0de9f75d0f4a4e7600c1aae61dac8873a6d796..6721b1fac027c5ecc9ab3f9c5bf1e5161f7da3b1 100644 GIT binary patch delta 14529 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 delta 13894 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 diff --git a/SelfieCam/Configuration/Secrets.swift.template b/SelfieCam/Configuration/Secrets.swift.template index 0bd741e..3bd60b9 100644 --- a/SelfieCam/Configuration/Secrets.swift.template +++ b/SelfieCam/Configuration/Secrets.swift.template @@ -10,5 +10,11 @@ enum Secrets { /// RevenueCat API Key /// - Debug: Use your test/sandbox API key (starts with "test_") /// - Release: Use your production API key (starts with "appl_") - static let revenueCatAPIKey = "YOUR_REVENUECAT_API_KEY_HERE" + static let revenueCatAPIKey: String = { + #if DEBUG + return "test_YOUR_TEST_KEY_HERE" + #else + return "appl_YOUR_PRODUCTION_KEY_HERE" + #endif + }() } diff --git a/SelfieCam/Features/Paywall/Views/ProPaywallView.swift b/SelfieCam/Features/Paywall/Views/ProPaywallView.swift index 55e9e5a..260bad8 100644 --- a/SelfieCam/Features/Paywall/Views/ProPaywallView.swift +++ b/SelfieCam/Features/Paywall/Views/ProPaywallView.swift @@ -21,67 +21,19 @@ struct ProPaywallView: View { /// Whether a restore is in progress @State private var isRestoring = false + + /// Currently selected package + @State private var selectedPackage: Package? var body: some View { NavigationStack { ScrollView { VStack(spacing: Design.Spacing.xLarge) { - // Crown icon - Image(systemName: "crown.fill") - .font(.system(size: Design.FontSize.hero)) - .foregroundStyle(.yellow) - - Text(String(localized: "Go Pro")) - .font(.system(size: Design.FontSize.title, weight: .bold)) - - // Benefits list - VStack(alignment: .leading, spacing: Design.Spacing.medium) { - BenefitRow(image: "paintpalette.fill", text: String(localized: "Premium Colors + Custom Color Picker")) - BenefitRow(image: "sparkles", text: String(localized: "Skin Smoothing Beauty Filter")) - BenefitRow(image: "arrow.left.and.right.righttriangle.left.righttriangle.right.fill", text: String(localized: "True Mirror Mode")) - BenefitRow(image: "bolt.fill", text: String(localized: "Flash Sync with Ring Light")) - BenefitRow(image: "camera.filters", text: String(localized: "HDR Mode for Better Photos")) - BenefitRow(image: "person.crop.rectangle.fill", text: String(localized: "Center Stage Auto-Framing")) - BenefitRow(image: "timer", text: String(localized: "Extended Self-Timers (5s, 10s)")) - BenefitRow(image: "star.fill", text: String(localized: "High Quality Photo Export")) - } - .frame(maxWidth: .infinity, alignment: .leading) - - // Product packages - if manager.availablePackages.isEmpty { - ProgressView() - .padding() - } else { - ForEach(manager.availablePackages, id: \.identifier) { package in - ProductPackageButton( - package: package, - isPremiumUnlocked: manager.isPremiumUnlocked, - isLoading: isPurchasing, - onPurchase: { - Task { - await purchasePackage(package) - } - } - ) - } - } - - // Restore purchases - Button { - Task { - await restorePurchases() - } - } label: { - if isRestoring { - ProgressView() - .tint(.secondary) - } else { - Text(String(localized: "Restore Purchases")) - } - } - .font(.footnote) - .foregroundStyle(.secondary) - .disabled(isRestoring || isPurchasing) + header + benefitsCard + packageSelection + purchaseCTA + restoreButton } .padding(Design.Spacing.large) } @@ -96,7 +48,17 @@ struct ProPaywallView: View { } } .font(.system(size: bodyFontSize)) - .task { try? await manager.loadProducts() } + .task { + try? await manager.loadProducts() + if selectedPackage == nil { + selectedPackage = preferredPackage(from: manager.availablePackages) + } + } + .onChange(of: manager.availablePackages) { _, newValue in + if selectedPackage == nil { + selectedPackage = preferredPackage(from: newValue) + } + } .alert( String(localized: "Purchase Error"), isPresented: $showError, @@ -110,6 +72,120 @@ struct ProPaywallView: View { } } + // MARK: - Sections + + private var header: some View { + VStack(spacing: Design.Spacing.medium) { + ZStack { + Circle() + .fill(AppAccent.primary.opacity(Design.Opacity.subtle)) + .frame(width: 72, height: 72) + Image(systemName: "crown.fill") + .font(.system(size: Design.FontSize.large, weight: .bold)) + .foregroundStyle(AppStatus.warning) + } + + Text(String(localized: "Unlock SelfieCam Pro")) + .font(.system(size: Design.FontSize.title, weight: .bold)) + .foregroundStyle(.white) + + Text(String(localized: "Premium tools for better selfies")) + .font(.system(size: Design.FontSize.body)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + } + .frame(maxWidth: .infinity) + } + + private var benefitsCard: some View { + VStack(alignment: .leading, spacing: Design.Spacing.medium) { + BenefitRow(image: "paintpalette.fill", text: String(localized: "Premium Colors + Custom Color Picker")) + BenefitRow(image: "sparkles", text: String(localized: "Skin Smoothing Beauty Filter")) + BenefitRow(image: "arrow.left.and.right.righttriangle.left.righttriangle.right.fill", text: String(localized: "True Mirror Mode")) + BenefitRow(image: "camera.filters", text: String(localized: "HDR Mode for Better Photos")) + BenefitRow(image: "timer", text: String(localized: "Extended Self-Timers (5s, 10s)")) + BenefitRow(image: "star.fill", text: String(localized: "High Quality Photo Export")) + } + .padding(Design.Spacing.large) + .frame(maxWidth: .infinity, alignment: .leading) + .background(AppSurface.card) + .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) + .overlay( + RoundedRectangle(cornerRadius: Design.CornerRadius.large) + .strokeBorder(AppBorder.subtle, lineWidth: Design.LineWidth.thin) + ) + } + + private var packageSelection: some View { + VStack(spacing: Design.Spacing.medium) { + if manager.availablePackages.isEmpty { + ProgressView() + .padding() + } else { + ForEach(manager.availablePackages, id: \.identifier) { package in + PackageOptionRow( + package: package, + isSelected: selectedPackage?.identifier == package.identifier, + isDisabled: isPurchasing, + onSelect: { selectedPackage = package } + ) + } + } + } + } + + private var purchaseCTA: some View { + VStack(spacing: Design.Spacing.small) { + Button { + guard let selectedPackage else { + errorMessage = String(localized: "Please select a plan.") + showError = true + return + } + Task { + await purchasePackage(selectedPackage) + } + } label: { + HStack(spacing: Design.Spacing.small) { + if isPurchasing { + ProgressView() + .tint(.white) + } + Text(String(localized: "Continue")) + .font(.headline) + .foregroundStyle(.white) + } + .frame(maxWidth: .infinity) + .padding(Design.Spacing.large) + .background(AppAccent.primary) + .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) + } + .disabled(isPurchasing || isRestoring || selectedPackage == nil) + + Text(String(localized: "Cancel anytime. Payment will be charged to your Apple ID.")) + .font(.caption) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + .multilineTextAlignment(.center) + } + } + + private var restoreButton: some View { + Button { + Task { + await restorePurchases() + } + } label: { + if isRestoring { + ProgressView() + .tint(.secondary) + } else { + Text(String(localized: "Restore Purchases")) + } + } + .font(.footnote) + .foregroundStyle(.secondary) + .disabled(isRestoring || isPurchasing) + } + // MARK: - Purchase Logic private func purchasePackage(_ package: Package) async { @@ -159,15 +235,27 @@ struct ProPaywallView: View { showError = true } } + + // MARK: - Helpers + + private func preferredPackage(from packages: [Package]) -> Package? { + if let annual = packages.first(where: { $0.packageType == .annual }) { + return annual + } + if let lifetime = packages.first(where: { $0.packageType == .lifetime }) { + return lifetime + } + return packages.first + } } -// MARK: - Product Package Button +// MARK: - Package Option Row -private struct ProductPackageButton: View { +private struct PackageOptionRow: View { let package: Package - let isPremiumUnlocked: Bool - let isLoading: Bool - let onPurchase: () -> Void + let isSelected: Bool + let isDisabled: Bool + let onSelect: () -> Void private var isLifetime: Bool { package.packageType == .lifetime @@ -177,63 +265,78 @@ private struct ProductPackageButton: View { package.packageType == .annual } - /// Background color based on package type - private var backgroundColor: Color { - isLifetime ? AppStatus.warning.opacity(Design.Opacity.medium) : AppAccent.primary.opacity(Design.Opacity.medium) - } - - /// Border color based on package type - private var borderColor: Color { - isLifetime ? AppStatus.warning : AppAccent.primary - } - var body: some View { - Button(action: onPurchase) { - VStack(spacing: Design.Spacing.small) { - // Badge for lifetime - if isLifetime { - Text(String(localized: "ONE TIME")) - .font(.caption2.bold()) - .foregroundStyle(.black) - .padding(.horizontal, Design.Spacing.small) - .padding(.vertical, Design.Spacing.xxSmall) - .background(AppStatus.warning) - .clipShape(Capsule()) + Button(action: onSelect) { + HStack(spacing: Design.Spacing.medium) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.title3) + .foregroundStyle(isSelected ? AppAccent.primary : .white.opacity(Design.Opacity.medium)) + + VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { + HStack(spacing: Design.Spacing.small) { + Text(package.storeProduct.localizedTitle) + .font(.headline) + .foregroundStyle(.white) + + if isAnnual { + Text(String(localized: "Best Value")) + .font(.caption2.bold()) + .foregroundStyle(AppStatus.warning) + .padding(.horizontal, Design.Spacing.xSmall) + .padding(.vertical, Design.Spacing.xxSmall) + .background(AppStatus.warning.opacity(Design.Opacity.subtle)) + .clipShape(Capsule()) + } else if isLifetime { + Text(String(localized: "One Time")) + .font(.caption2.bold()) + .foregroundStyle(AppAccent.primary) + .padding(.horizontal, Design.Spacing.xSmall) + .padding(.vertical, Design.Spacing.xxSmall) + .background(AppAccent.primary.opacity(Design.Opacity.subtle)) + .clipShape(Capsule()) + } + } + + Text(packageSubtitle) + .font(.caption) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) } - Text(package.storeProduct.localizedTitle) - .font(.headline) - .foregroundStyle(.white) + Spacer() Text(package.localizedPriceString) - .font(.title2.bold()) + .font(.title3.bold()) .foregroundStyle(.white) - - // Subtitle based on type - if isLifetime { - Text(String(localized: "Pay once, own forever")) - .font(.caption) - .foregroundStyle(.white.opacity(Design.Opacity.accent)) - } else if isAnnual { - Text(String(localized: "Best Value • Save 33%")) - .font(.caption) - .foregroundStyle(.white.opacity(Design.Opacity.accent)) - } } - .frame(maxWidth: .infinity) .padding(Design.Spacing.large) - .background(backgroundColor) + .frame(maxWidth: .infinity) + .background(AppSurface.card) .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) .overlay( RoundedRectangle(cornerRadius: Design.CornerRadius.large) - .strokeBorder(borderColor, lineWidth: Design.LineWidth.thin) + .strokeBorder(isSelected ? AppAccent.primary : AppBorder.subtle, lineWidth: Design.LineWidth.thin) ) - .opacity(isLoading ? 0.6 : 1.0) + .opacity(isDisabled ? 0.6 : 1.0) } - .disabled(isLoading) - .accessibilityLabel(isLifetime - ? String(localized: "Purchase \(package.storeProduct.localizedTitle) for \(package.localizedPriceString), one-time payment") - : String(localized: "Subscribe to \(package.storeProduct.localizedTitle) for \(package.localizedPriceString)")) + .disabled(isDisabled) + .accessibilityLabel(accessibilityLabel) + } + + private var packageSubtitle: String { + if isLifetime { + return String(localized: "Pay once, own forever") + } + if isAnnual { + return String(localized: "Best value yearly plan") + } + return String(localized: "Billed monthly") + } + + private var accessibilityLabel: String { + if isLifetime { + return String(localized: "Select \(package.storeProduct.localizedTitle) for \(package.localizedPriceString), one-time payment") + } + return String(localized: "Select \(package.storeProduct.localizedTitle) for \(package.localizedPriceString)") } } @@ -245,10 +348,14 @@ struct BenefitRow: View { var body: some View { HStack(spacing: Design.Spacing.medium) { - Image(systemName: image) - .font(.title2) - .foregroundStyle(AppAccent.primary) - .frame(width: Design.IconSize.xLarge) + ZStack { + Circle() + .fill(AppAccent.primary.opacity(Design.Opacity.subtle)) + .frame(width: 32, height: 32) + Image(systemName: image) + .font(.body.bold()) + .foregroundStyle(AppAccent.primary) + } Text(text) .foregroundStyle(.white) diff --git a/SelfieCam/Resources/Localizable.xcstrings b/SelfieCam/Resources/Localizable.xcstrings index 9d72fcb..f39f79c 100644 --- a/SelfieCam/Resources/Localizable.xcstrings +++ b/SelfieCam/Resources/Localizable.xcstrings @@ -498,8 +498,13 @@ "comment" : "Subtitle of a premium feature card that offers skin smoothing.", "isCommentAutoGenerated" : true }, + "Best Value" : { + "comment" : "A label indicating that a subscription is the best value for the user.", + "isCommentAutoGenerated" : true + }, "Best Value • Save 33%" : { "comment" : "A promotional text displayed below an annual subscription package, highlighting its value.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "es-MX" : { @@ -522,9 +527,16 @@ } } }, + "Best value yearly plan" : { + "comment" : "Subtitle for a package option row when the plan is billed annually.", + "isCommentAutoGenerated" : true + }, "Better lighting" : { "comment" : "Subtitle for a premium feature card that allows users to take photos in better lighting.", "isCommentAutoGenerated" : true + }, + "Billed monthly" : { + }, "Boomerang" : { "comment" : "Display name for the \"Boomerang\" capture mode.", @@ -653,6 +665,10 @@ } } }, + "Cancel anytime. Payment will be charged to your Apple ID." : { + "comment" : "A footer text displayed below the \"Continue\" button in the Pro paywall.", + "isCommentAutoGenerated" : true + }, "Captured photo" : { "comment" : "A label describing a captured photo.", "extractionState" : "stale", @@ -751,6 +767,7 @@ }, "Center Stage Auto-Framing" : { "comment" : "Benefit of the \"Go Pro\" premium package: Automatic centering of the subject in the photo.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "es-MX" : { @@ -1293,6 +1310,7 @@ }, "Flash Sync with Ring Light" : { "comment" : "Benefit description for the \"Flash Sync with Ring Light\" feature.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "es-MX" : { @@ -1426,6 +1444,7 @@ }, "Go Pro" : { "comment" : "The title of the \"Go Pro\" button in the Pro paywall.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "es-MX" : { @@ -1696,10 +1715,6 @@ } } }, - "Loading..." : { - "comment" : "A placeholder text displayed while waiting for content to load.", - "isCommentAutoGenerated" : true - }, "Locked. Tap to unlock with Pro." : { "comment" : "A hint that appears when a user taps on a color preset button.", "isCommentAutoGenerated" : true, @@ -1801,8 +1816,8 @@ "comment" : "A message displayed in an alert when the \"Reset Onboarding\" button is tapped.", "isCommentAutoGenerated" : true }, - "ONE TIME" : { - "comment" : "A label for a badge indicating a one-time purchase.", + "One Time" : { + "comment" : "A description of a one-time purchase option.", "isCommentAutoGenerated" : true }, "Open Settings" : { @@ -1951,6 +1966,10 @@ } } }, + "Please select a plan." : { + "comment" : "Error message displayed when the user tries to purchase a package but hasn't selected one.", + "isCommentAutoGenerated" : true + }, "Premium color" : { "comment" : "An accessibility hint for a premium color option in the color preset button.", "isCommentAutoGenerated" : true, @@ -2022,6 +2041,9 @@ } } } + }, + "Premium tools for better selfies" : { + }, "Pro Active" : { "comment" : "A label indicating that the user has an active Pro subscription.", @@ -2035,18 +2057,6 @@ "comment" : "An accessibility label for the section of the settings view that displays that their Pro subscription is active.", "isCommentAutoGenerated" : true }, - "Purchase %@ for %@, one-time payment" : { - "comment" : "An accessibility label for a button that purchases a product package. The label includes the product name and price, with a note that it's a one-time payment if the package is a lifetime subscription.", - "isCommentAutoGenerated" : true, - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Purchase %1$@ for %2$@, one-time payment" - } - } - } - }, "Purchase Error" : { "comment" : "The title of an alert that appears when there is an error during a purchase process.", "isCommentAutoGenerated" : true @@ -2498,6 +2508,28 @@ } } }, + "Select %@ for %@" : { + "comment" : "Accessibility label for a row in the \"Choose a package\" sheet. The first argument is the localized title of the package. The second argument is the localized price string of the package.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Select %1$@ for %2$@" + } + } + } + }, + "Select %@ for %@, one-time payment" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Select %1$@ for %2$@, one-time payment" + } + } + } + }, "Select camera position" : { "comment" : "A label describing the action of selecting a camera position.", "isCommentAutoGenerated" : true, @@ -2938,6 +2970,7 @@ }, "Subscribe to %@ for %@" : { "comment" : "A button that triggers a purchase of a premium content package. The label text is generated based on the package's title and price.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -3300,6 +3333,10 @@ "comment" : "Title of a section in the onboarding soft paywall that describes the benefits of upgrading to a premium account.", "isCommentAutoGenerated" : true }, + "Unlock SelfieCam Pro" : { + "comment" : "The title of the paywall view.", + "isCommentAutoGenerated" : true + }, "Upgrade to Pro" : { "comment" : "A button label that prompts users to upgrade to the premium version of the app.", "isCommentAutoGenerated" : true, diff --git a/SelfieCam/Shared/Premium/PaywallPresenter.swift b/SelfieCam/Shared/Premium/PaywallPresenter.swift index 7686be8..54a3e1b 100644 --- a/SelfieCam/Shared/Premium/PaywallPresenter.swift +++ b/SelfieCam/Shared/Premium/PaywallPresenter.swift @@ -7,11 +7,9 @@ // import SwiftUI -import RevenueCat -import RevenueCatUI import Bedrock -/// Presents RevenueCat Paywall with fallback to custom paywall. +/// Presents the custom SelfieCam paywall. /// /// Usage: /// ```swift @@ -23,21 +21,10 @@ import Bedrock /// } /// ``` /// -/// The presenter will: -/// 1. Attempt to load the RevenueCat paywall from your configured offering -/// 2. If successful, display the native RevenueCat PaywallView -/// 3. If it fails (network error, no paywall configured), fall back to ProPaywallView struct PaywallPresenter: View { /// Callback triggered when a purchase or restore is successful var onPurchaseSuccess: (() -> Void)? - @Environment(\.dismiss) private var dismiss - @State private var offering: Offering? - @State private var isLoading = true - @State private var useFallback = false - @State private var errorMessage: String? - @State private var showError = false - /// Creates a PaywallPresenter with an optional success callback /// - Parameter onPurchaseSuccess: Called when purchase or restore completes successfully init(onPurchaseSuccess: (() -> Void)? = nil) { @@ -45,126 +32,7 @@ struct PaywallPresenter: View { } var body: some View { - Group { - if isLoading { - // Loading state while fetching offerings - loadingView - } else if useFallback { - // Fallback to custom paywall on error - ProPaywallView(onPurchaseSuccess: onPurchaseSuccess) - } else if let offering { - // RevenueCat native paywall - paywallView(for: offering) - } else { - // No offering available, use fallback - ProPaywallView(onPurchaseSuccess: onPurchaseSuccess) - } - } - .task { - await loadOffering() - } - .alert( - String(localized: "Purchase Error"), - isPresented: $showError, - presenting: errorMessage - ) { _ in - Button(String(localized: "OK"), role: .cancel) { - errorMessage = nil - } - } message: { message in - Text(message) - } - } - - // MARK: - Loading View - - private var loadingView: some View { - VStack { - ProgressView() - .scaleEffect(1.5) - .tint(.white) - - Text(String(localized: "Loading...")) - .font(.system(size: Design.FontSize.body)) - .foregroundStyle(.white.opacity(Design.Opacity.medium)) - .padding(.top, Design.Spacing.medium) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(AppSurface.primary) - } - - // MARK: - Paywall View - - private func paywallView(for offering: Offering) -> some View { - PaywallView(offering: offering) - .onPurchaseCompleted { _ in - #if DEBUG - print("✅ [PaywallPresenter] Purchase completed") - #endif - onPurchaseSuccess?() - dismiss() - } - .onPurchaseCancelled { - #if DEBUG - print("ℹ️ [PaywallPresenter] Purchase cancelled by user") - #endif - // User cancelled - paywall stays open, no action needed - } - .onPurchaseFailure { error in - #if DEBUG - print("❌ [PaywallPresenter] Purchase failed: \(error.localizedDescription)") - #endif - errorMessage = error.localizedDescription - showError = true - } - .onRestoreCompleted { customerInfo in - #if DEBUG - print("✅ [PaywallPresenter] Restore completed") - #endif - // Only call success if user actually has entitlements after restore - if !customerInfo.entitlements.active.isEmpty { - onPurchaseSuccess?() - dismiss() - } - } - .onRestoreFailure { error in - #if DEBUG - print("❌ [PaywallPresenter] Restore failed: \(error.localizedDescription)") - #endif - errorMessage = error.localizedDescription - showError = true - } - } - - // MARK: - Load Offering - - private func loadOffering() async { - do { - let offerings = try await Purchases.shared.offerings() - - // Check if current offering has a paywall configured - if let current = offerings.current { - offering = current - isLoading = false - - #if DEBUG - print("✅ [PaywallPresenter] Loaded offering: \(current.identifier)") - #endif - } else { - // No current offering, use fallback - #if DEBUG - print("⚠️ [PaywallPresenter] No current offering available, using fallback") - #endif - useFallback = true - isLoading = false - } - } catch { - #if DEBUG - print("⚠️ [PaywallPresenter] Failed to load offerings: \(error)") - #endif - useFallback = true - isLoading = false - } + ProPaywallView(onPurchaseSuccess: onPurchaseSuccess) } }