import SwiftUI /// Paywall sheet — shown from `SettingsView`'s "Auf Pro upgraden" button and /// from `ProjectsListView` when creating a folder fails with `pro_required`. /// /// Products and prices are loaded from `PurchaseService.loadProducts()` — /// real, StoreKit-localized products via `StoreKitPurchaseService` by /// default. Nothing here is hardcoded copy; if products fail to load, a /// retry affordance is shown instead of guessing at prices. struct PaywallView: View { @Environment(\.dismiss) private var dismiss @EnvironmentObject private var appState: AppState @State private var products: [PurchaseProduct] = [] @State private var isLoadingProducts = false @State private var loadErrorMessage: String? @State private var purchasingProductID: String? @State private var errorMessage: String? @State private var purchaseCompletedMessage: String? @State private var isRestoring = false private let purchaseService: PurchaseService init(purchaseService: PurchaseService = StoreKitPurchaseService()) { self.purchaseService = purchaseService } var body: some View { NavigationStack { ScrollView { VStack(spacing: ZenithSpacing.md) { Text("Unbegrenzt scannen, Ordner anlegen und exportieren.") .zenithBodyStyle(color: .zenithMuted) .multilineTextAlignment(.center) .padding(.horizontal) content if !products.isEmpty { Button { Task { await restore() } } label: { if isRestoring { ProgressView() } else { Text("Käufe wiederherstellen") } } .buttonStyle(.zenithPlain) .disabled(isRestoring || purchasingProductID != nil) } } .padding(ZenithSpacing.sm) } .background(Color.zenithBg) .navigationTitle("ScanReceipts Pro") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Schließen") { dismiss() } } } .task { await loadProducts() } .alert("Kauf nicht möglich", isPresented: errorAlertBinding) { Button("OK", role: .cancel) {} } message: { Text(errorMessage ?? "") } .alert("Kauf abgeschlossen", isPresented: purchaseCompletedAlertBinding) { Button("OK", role: .cancel) { dismiss() } } message: { Text(purchaseCompletedMessage ?? "") } } } @ViewBuilder private var content: some View { if isLoadingProducts && products.isEmpty { ProgressView() .padding(.vertical, ZenithSpacing.lg) } else if let loadErrorMessage, products.isEmpty { VStack(spacing: ZenithSpacing.xs) { Text(loadErrorMessage) .zenithBodySmStyle() .multilineTextAlignment(.center) Button("Erneut versuchen") { Task { await loadProducts() } } .buttonStyle(.zenithPlain) } .padding(.vertical, ZenithSpacing.lg) } else { ForEach(products) { product in productCard(product) } } } private var errorAlertBinding: Binding { Binding( get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } } ) } private var purchaseCompletedAlertBinding: Binding { Binding( get: { purchaseCompletedMessage != nil }, set: { if !$0 { purchaseCompletedMessage = nil } } ) } private func productCard(_ product: PurchaseProduct) -> some View { VStack(alignment: .leading, spacing: ZenithSpacing.xs) { Text(product.displayName) .zenithHeadlineMdStyle() Text(priceLabel(for: product)) .zenithLabelMdStyle() Button { Task { await purchase(product) } } label: { if purchasingProductID == product.id { ProgressView() .tint(.white) } else { Text("Kaufen") } } .buttonStyle(.zenithPrimary) .disabled(purchasingProductID != nil) .opacity(purchasingProductID != nil && purchasingProductID != product.id ? 0.5 : 1) .padding(.top, ZenithSpacing.xs) } .frame(maxWidth: .infinity, alignment: .leading) .zenithCard() } private func priceLabel(for product: PurchaseProduct) -> String { if let period = product.periodDescription { return "\(product.displayPrice) · \(period)" } return product.displayPrice } private func loadProducts() async { isLoadingProducts = true loadErrorMessage = nil defer { isLoadingProducts = false } do { products = try await purchaseService.loadProducts() } catch { loadErrorMessage = error.localizedDescription } } /// On success (`true`), shows a neutral completion message and dismisses /// once the user acknowledges it. `POST /api/webhooks/apple` now exists /// server-side and (given a resolvable `appAccountToken`, see /// `User.appleAccountToken`) does grant Pro from this purchase — but /// that whole path is genuinely unverified end-to-end (no real Apple /// Developer account/device available to generate a live transaction /// against it), and delivery of Apple's server notification isn't /// synchronous with the purchase sheet closing. So: still no "You're Pro /// now!" claim here — that would assert something this code hasn't /// actually observed happening. `false` means the user cancelled the /// StoreKit sheet, not an error, no message needed. A thrown error is /// surfaced in the existing error alert. private func purchase(_ product: PurchaseProduct) async { purchasingProductID = product.id defer { purchasingProductID = nil } do { let completed = try await purchaseService.purchase( productID: product.id, appAccountToken: appState.currentUser?.appleAccountToken ) if completed { purchaseCompletedMessage = "Kauf abgeschlossen." } } catch { errorMessage = error.localizedDescription } } private func restore() async { isRestoring = true defer { isRestoring = false } do { try await purchaseService.restorePurchases() } catch { errorMessage = error.localizedDescription } } }