using loal swift paywall

Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
Matt Bruce 2026-02-03 16:11:12 -06:00
parent d62cc6e88e
commit c153b16593
5 changed files with 281 additions and 263 deletions

View File

@ -10,5 +10,11 @@ enum Secrets {
/// RevenueCat API Key /// RevenueCat API Key
/// - Debug: Use your test/sandbox API key (starts with "test_") /// - Debug: Use your test/sandbox API key (starts with "test_")
/// - Release: Use your production API key (starts with "appl_") /// - 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
}()
} }

View File

@ -22,66 +22,18 @@ struct ProPaywallView: View {
/// Whether a restore is in progress /// Whether a restore is in progress
@State private var isRestoring = false @State private var isRestoring = false
/// Currently selected package
@State private var selectedPackage: Package?
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ScrollView { ScrollView {
VStack(spacing: Design.Spacing.xLarge) { VStack(spacing: Design.Spacing.xLarge) {
// Crown icon header
Image(systemName: "crown.fill") benefitsCard
.font(.system(size: Design.FontSize.hero)) packageSelection
.foregroundStyle(.yellow) purchaseCTA
restoreButton
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)
} }
.padding(Design.Spacing.large) .padding(Design.Spacing.large)
} }
@ -96,7 +48,17 @@ struct ProPaywallView: View {
} }
} }
.font(.system(size: bodyFontSize)) .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( .alert(
String(localized: "Purchase Error"), String(localized: "Purchase Error"),
isPresented: $showError, 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 // MARK: - Purchase Logic
private func purchasePackage(_ package: Package) async { private func purchasePackage(_ package: Package) async {
@ -159,15 +235,27 @@ struct ProPaywallView: View {
showError = true 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 package: Package
let isPremiumUnlocked: Bool let isSelected: Bool
let isLoading: Bool let isDisabled: Bool
let onPurchase: () -> Void let onSelect: () -> Void
private var isLifetime: Bool { private var isLifetime: Bool {
package.packageType == .lifetime package.packageType == .lifetime
@ -177,63 +265,78 @@ private struct ProductPackageButton: View {
package.packageType == .annual 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 { var body: some View {
Button(action: onPurchase) { Button(action: onSelect) {
VStack(spacing: Design.Spacing.small) { HStack(spacing: Design.Spacing.medium) {
// Badge for lifetime Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
if isLifetime { .font(.title3)
Text(String(localized: "ONE TIME")) .foregroundStyle(isSelected ? AppAccent.primary : .white.opacity(Design.Opacity.medium))
.font(.caption2.bold())
.foregroundStyle(.black) VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
.padding(.horizontal, Design.Spacing.small) HStack(spacing: Design.Spacing.small) {
.padding(.vertical, Design.Spacing.xxSmall) Text(package.storeProduct.localizedTitle)
.background(AppStatus.warning) .font(.headline)
.clipShape(Capsule()) .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) Spacer()
.font(.headline)
.foregroundStyle(.white)
Text(package.localizedPriceString) Text(package.localizedPriceString)
.font(.title2.bold()) .font(.title3.bold())
.foregroundStyle(.white) .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) .padding(Design.Spacing.large)
.background(backgroundColor) .frame(maxWidth: .infinity)
.background(AppSurface.card)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large)) .clipShape(.rect(cornerRadius: Design.CornerRadius.large))
.overlay( .overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.large) 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) .disabled(isDisabled)
.accessibilityLabel(isLifetime .accessibilityLabel(accessibilityLabel)
? String(localized: "Purchase \(package.storeProduct.localizedTitle) for \(package.localizedPriceString), one-time payment") }
: String(localized: "Subscribe to \(package.storeProduct.localizedTitle) for \(package.localizedPriceString)"))
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 { var body: some View {
HStack(spacing: Design.Spacing.medium) { HStack(spacing: Design.Spacing.medium) {
Image(systemName: image) ZStack {
.font(.title2) Circle()
.foregroundStyle(AppAccent.primary) .fill(AppAccent.primary.opacity(Design.Opacity.subtle))
.frame(width: Design.IconSize.xLarge) .frame(width: 32, height: 32)
Image(systemName: image)
.font(.body.bold())
.foregroundStyle(AppAccent.primary)
}
Text(text) Text(text)
.foregroundStyle(.white) .foregroundStyle(.white)

View File

@ -498,8 +498,13 @@
"comment" : "Subtitle of a premium feature card that offers skin smoothing.", "comment" : "Subtitle of a premium feature card that offers skin smoothing.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Best Value" : {
"comment" : "A label indicating that a subscription is the best value for the user.",
"isCommentAutoGenerated" : true
},
"Best Value • Save 33%" : { "Best Value • Save 33%" : {
"comment" : "A promotional text displayed below an annual subscription package, highlighting its value.", "comment" : "A promotional text displayed below an annual subscription package, highlighting its value.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"es-MX" : { "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" : { "Better lighting" : {
"comment" : "Subtitle for a premium feature card that allows users to take photos in better lighting.", "comment" : "Subtitle for a premium feature card that allows users to take photos in better lighting.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Billed monthly" : {
}, },
"Boomerang" : { "Boomerang" : {
"comment" : "Display name for the \"Boomerang\" capture mode.", "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" : { "Captured photo" : {
"comment" : "A label describing a captured photo.", "comment" : "A label describing a captured photo.",
"extractionState" : "stale", "extractionState" : "stale",
@ -751,6 +767,7 @@
}, },
"Center Stage Auto-Framing" : { "Center Stage Auto-Framing" : {
"comment" : "Benefit of the \"Go Pro\" premium package: Automatic centering of the subject in the photo.", "comment" : "Benefit of the \"Go Pro\" premium package: Automatic centering of the subject in the photo.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"es-MX" : { "es-MX" : {
@ -1293,6 +1310,7 @@
}, },
"Flash Sync with Ring Light" : { "Flash Sync with Ring Light" : {
"comment" : "Benefit description for the \"Flash Sync with Ring Light\" feature.", "comment" : "Benefit description for the \"Flash Sync with Ring Light\" feature.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"es-MX" : { "es-MX" : {
@ -1426,6 +1444,7 @@
}, },
"Go Pro" : { "Go Pro" : {
"comment" : "The title of the \"Go Pro\" button in the Pro paywall.", "comment" : "The title of the \"Go Pro\" button in the Pro paywall.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"es-MX" : { "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." : { "Locked. Tap to unlock with Pro." : {
"comment" : "A hint that appears when a user taps on a color preset button.", "comment" : "A hint that appears when a user taps on a color preset button.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@ -1801,8 +1816,8 @@
"comment" : "A message displayed in an alert when the \"Reset Onboarding\" button is tapped.", "comment" : "A message displayed in an alert when the \"Reset Onboarding\" button is tapped.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"ONE TIME" : { "One Time" : {
"comment" : "A label for a badge indicating a one-time purchase.", "comment" : "A description of a one-time purchase option.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Open Settings" : { "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" : { "Premium color" : {
"comment" : "An accessibility hint for a premium color option in the color preset button.", "comment" : "An accessibility hint for a premium color option in the color preset button.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@ -2022,6 +2041,9 @@
} }
} }
} }
},
"Premium tools for better selfies" : {
}, },
"Pro Active" : { "Pro Active" : {
"comment" : "A label indicating that the user has an active Pro subscription.", "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.", "comment" : "An accessibility label for the section of the settings view that displays that their Pro subscription is active.",
"isCommentAutoGenerated" : true "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" : { "Purchase Error" : {
"comment" : "The title of an alert that appears when there is an error during a purchase process.", "comment" : "The title of an alert that appears when there is an error during a purchase process.",
"isCommentAutoGenerated" : true "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" : { "Select camera position" : {
"comment" : "A label describing the action of selecting a camera position.", "comment" : "A label describing the action of selecting a camera position.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@ -2938,6 +2970,7 @@
}, },
"Subscribe to %@ for %@" : { "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.", "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, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"en" : { "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.", "comment" : "Title of a section in the onboarding soft paywall that describes the benefits of upgrading to a premium account.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Unlock SelfieCam Pro" : {
"comment" : "The title of the paywall view.",
"isCommentAutoGenerated" : true
},
"Upgrade to Pro" : { "Upgrade to Pro" : {
"comment" : "A button label that prompts users to upgrade to the premium version of the app.", "comment" : "A button label that prompts users to upgrade to the premium version of the app.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,

View File

@ -7,11 +7,9 @@
// //
import SwiftUI import SwiftUI
import RevenueCat
import RevenueCatUI
import Bedrock import Bedrock
/// Presents RevenueCat Paywall with fallback to custom paywall. /// Presents the custom SelfieCam paywall.
/// ///
/// Usage: /// Usage:
/// ```swift /// ```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 { struct PaywallPresenter: View {
/// Callback triggered when a purchase or restore is successful /// Callback triggered when a purchase or restore is successful
var onPurchaseSuccess: (() -> Void)? 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 /// Creates a PaywallPresenter with an optional success callback
/// - Parameter onPurchaseSuccess: Called when purchase or restore completes successfully /// - Parameter onPurchaseSuccess: Called when purchase or restore completes successfully
init(onPurchaseSuccess: (() -> Void)? = nil) { init(onPurchaseSuccess: (() -> Void)? = nil) {
@ -45,126 +32,7 @@ struct PaywallPresenter: View {
} }
var body: some View { var body: some View {
Group { ProPaywallView(onPurchaseSuccess: onPurchaseSuccess)
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
}
} }
} }