Files
InvestmentTrackerApp/PortfolioJournal/Views/Premium/PaywallView.swift
T
alexandrev-tibco edb04efc86 Redesign onboarding and paywall for 1.2.0
- Onboarding: replace feature-focused slides with outcome-focused copy
  ("Know exactly where you stand", "5 minutes a month is enough", etc.)
- Paywall: non-scrollable layout, reduce from 8 to 4 key benefits,
  remove transactional language (Unlock/Upgrade/Premium),
  new CTA "Get Full Access", floating close button
- Add IAPService.paywallBenefits for the condensed 4-item paywall list
- Update CompactPaywallBanner and PremiumLockOverlay copy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 12:51:13 +01:00

358 lines
10 KiB
Swift

import SwiftUI
struct PaywallView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject var iapService: IAPService
@State private var isPurchasing = false
@State private var errorMessage: String?
@State private var showingError = false
var body: some View {
ZStack(alignment: .topTrailing) {
AppBackground()
VStack(spacing: 0) {
// Header
headerSection
.padding(.top, 48)
.padding(.horizontal, 24)
Spacer()
// Key benefits
benefitsSection
.padding(.horizontal, 24)
Spacer()
// Bottom actions
VStack(spacing: 12) {
priceLabel
purchaseButton
restoreButton
legalSection
}
.padding(.horizontal, 24)
.padding(.bottom, 32)
}
// Close button
Button {
dismiss()
} label: {
Image(systemName: "xmark")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.secondary)
.padding(10)
.background(Color(.secondarySystemBackground))
.clipShape(Circle())
}
.padding(.top, 16)
.padding(.trailing, 16)
}
.ignoresSafeArea()
.alert("Error", isPresented: $showingError) {
Button("OK", role: .cancel) {}
} message: {
Text(errorMessage ?? "An error occurred")
}
.onChange(of: iapService.isPremium) { _, isPremium in
if isPremium {
dismiss()
}
}
}
// MARK: - Header Section
private var headerSection: some View {
VStack(spacing: 12) {
ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color.yellow.opacity(0.25), Color.orange.opacity(0.25)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 72, height: 72)
Image(systemName: "crown.fill")
.font(.system(size: 30))
.foregroundStyle(
LinearGradient(
colors: [.yellow, .orange],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
Text("Your full portfolio,\nfully clear")
.font(.title.weight(.bold))
.multilineTextAlignment(.center)
Text("One payment. Every feature. Forever.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
}
// MARK: - Benefits Section
private var benefitsSection: some View {
VStack(spacing: 10) {
ForEach(IAPService.paywallBenefits, id: \.title) { benefit in
BenefitRow(icon: benefit.icon, title: benefit.title, subtitle: benefit.subtitle)
}
}
.padding(20)
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
// MARK: - Price Label
private var priceLabel: some View {
HStack(spacing: 4) {
Text(iapService.formattedPrice)
.font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundColor(.appPrimary)
Text("· one-time · Family Sharing")
.font(.footnote)
.foregroundColor(.secondary)
}
}
// MARK: - Purchase Button
private var purchaseButton: some View {
Button {
purchase()
} label: {
HStack {
if isPurchasing {
ProgressView()
.tint(.white)
} else {
Text("Get Full Access")
.font(.headline)
}
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(AppConstants.UI.cornerRadius)
}
.disabled(isPurchasing)
}
// MARK: - Restore Button
private var restoreButton: some View {
Button {
restore()
} label: {
Text("Restore Purchases")
.font(.subheadline)
.foregroundColor(.appPrimary)
}
.disabled(isPurchasing)
}
// MARK: - Legal Section
private var legalSection: some View {
VStack(spacing: 4) {
Text("Payment charged to your Apple ID account.")
.font(.caption2)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
HStack(spacing: 12) {
Link("Terms", destination: URL(string: AppConstants.URLs.termsOfService)!)
.font(.caption2)
.foregroundColor(.secondary)
Link("Privacy", destination: URL(string: AppConstants.URLs.privacyPolicy)!)
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
// MARK: - Actions
private func purchase() {
isPurchasing = true
FirebaseService.shared.logPurchaseAttempt(productId: IAPService.premiumProductID)
Task {
do {
try await iapService.purchase()
} catch {
errorMessage = error.localizedDescription
showingError = true
}
isPurchasing = false
}
}
private func restore() {
isPurchasing = true
Task {
await iapService.restorePurchases()
if !iapService.isPremium {
errorMessage = "No purchases found to restore"
showingError = true
}
isPurchasing = false
}
}
}
// MARK: - Benefit Row
struct BenefitRow: View {
let icon: String
let title: String
let subtitle: String
var body: some View {
HStack(spacing: 14) {
Image(systemName: icon)
.font(.system(size: 20))
.foregroundColor(.appPrimary)
.frame(width: 28)
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(.subheadline.weight(.semibold))
Text(subtitle)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.positiveGreen)
.font(.system(size: 18))
}
.padding(.vertical, 2)
}
}
// MARK: - Feature Row (kept for backward compatibility)
struct FeatureRow: View {
let icon: String
let title: String
let description: String
var body: some View {
BenefitRow(icon: icon, title: title, subtitle: description)
}
}
// MARK: - Compact Paywall (for inline use)
struct CompactPaywallBanner: View {
@EnvironmentObject var iapService: IAPService
@Binding var showingPaywall: Bool
var body: some View {
HStack(spacing: 12) {
Image(systemName: "crown.fill")
.font(.title2)
.foregroundStyle(
LinearGradient(
colors: [.yellow, .orange],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
VStack(alignment: .leading, spacing: 2) {
Text("Full access, one payment")
.font(.subheadline.weight(.semibold))
Text("Unlimited sources, advanced charts & more")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Button {
showingPaywall = true
} label: {
Text(iapService.formattedPrice)
.font(.caption.weight(.semibold))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(16)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
// MARK: - Premium Lock Overlay
struct PremiumLockOverlay: View {
let feature: String
@Binding var showingPaywall: Bool
var body: some View {
VStack(spacing: 16) {
Image(systemName: "crown.fill")
.font(.system(size: 32))
.foregroundStyle(
LinearGradient(
colors: [.yellow, .orange],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
Text(feature)
.font(.headline)
.multilineTextAlignment(.center)
Button {
showingPaywall = true
} label: {
Text("See full access")
.font(.subheadline.weight(.semibold))
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(20)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemBackground).opacity(0.95))
}
}
#Preview {
PaywallView()
.environmentObject(IAPService())
}