using loal swift paywall
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
d62cc6e88e
commit
c153b16593
Binary file not shown.
@ -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
|
||||
}()
|
||||
}
|
||||
|
||||
@ -22,66 +22,18 @@ 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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user