Files
InvestmentTrackerApp/PortfolioJournal/Views/Premium/PaywallView.swift
T
alexandrev-tibco 197bddcd7e Growth y monetización: fixes críticos, paywall multi-plan, teasers y App Intents
CRÍTICO — enlaces del App Store rotos (adquisición viral = 0):
- GoalShareService.appStoreURL apuntaba a id6744983373 (404): el QR de TODAS las
  imágenes compartidas llevaba a una página muerta. Corregido a id6757678318.
- SettingsView enlazaba id6741412965 (una novela romántica). Ahora usa la constante.

Monetización:
- IAPService multi-producto: sub anual (com.portfoliojournal.premium.annual, con
  intro offer/trial) + lifetime como ancla. isPremium acepta cualquiera de los dos.
  Paywall con selector de plan (anual por defecto si existe; degrada a solo lifetime
  mientras el producto no esté creado en App Store Connect).
- paywallBenefits: añadidos Multiple Accounts y Family Sharing (diferenciadores).
- Teaser de historia bloqueada en Charts: "N meses más con Premium" en vez de
  truncar en silencio (FreemiumValidator.hiddenHistoryMonths + banner).
- Trigger de paywall de backups ahora instrumentado (logPaywallShown "backups").

Retención / adquisición:
- Rating velocity: prompt tras 2 check-ins y 30 días (antes 3 + 90 — en una app
  mensual eso era >3 meses sin poder pedir la primera review con ~0 ratings).
- Sample data ON por defecto en onboarding (skip ya no aterriza en dashboard vacío).
- Notificación de protección de streak el día 25 si el check-in del mes está pendiente.
- App Intents + AppShortcutsProvider: "Update my portfolio" / "Check my portfolio"
  vía Siri/Shortcuts/Spotlight.
- Instrumentación: onboarding_step/onboarding_skipped y content_shared (viral loop).
- ScreenshotMode (--screenshots) para capturas de marketing automatizadas.

ASO:
- 6 locales nuevos en metadata (en-GB, en-CA, en-AU, es-MX, fr-CA, pt-PT) reusando
  traducciones — 6 campos de keywords extra; pt-PT adaptado (reforma).
- Categoría secundaria: PRODUCTIVITY.
- 17 strings nuevas localizadas en los 7 idiomas.

Pendiente manual: crear la sub anual en App Store Connect (grupo de suscripción +
intro offer 7 días) con el id exacto com.portfoliojournal.premium.annual.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
2026-07-02 22:12:32 +02:00

509 lines
16 KiB
Swift

import SwiftUI
import StoreKit
// MARK: - Chart Preview (decorative, no real data)
private struct PremiumChartPreview: View {
// Normalized growth data representing a healthy upward portfolio trend
private let points: [Double] = [0.52, 0.58, 0.55, 0.65, 0.70, 0.66, 0.78, 0.85, 0.82, 0.91, 0.88, 1.0]
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(Color.appPrimary.opacity(0.08))
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .firstTextBaseline) {
Text("€24,750")
.font(.system(size: 18, weight: .bold, design: .rounded))
.foregroundColor(.primary)
Spacer()
Text("+34.2%")
.font(.caption.weight(.semibold))
.foregroundColor(.positiveGreen)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(Color.positiveGreen.opacity(0.12))
.clipShape(Capsule())
}
.padding(.horizontal, 14)
.padding(.top, 12)
.padding(.bottom, 6)
GeometryReader { geo in
chartPaths(in: geo.size)
}
.padding(.bottom, 8)
}
}
.frame(height: 96)
}
@ViewBuilder
private func chartPaths(in size: CGSize) -> some View {
let pts = chartPoints(in: size)
// Gradient fill
Path { path in
guard !pts.isEmpty else { return }
path.move(to: CGPoint(x: pts[0].x, y: size.height))
path.addLine(to: pts[0])
for i in 1..<pts.count {
let ctrl = CGPoint(x: (pts[i-1].x + pts[i].x) / 2, y: (pts[i-1].y + pts[i].y) / 2)
path.addQuadCurve(to: pts[i], control: ctrl)
}
path.addLine(to: CGPoint(x: pts.last!.x, y: size.height))
path.closeSubpath()
}
.fill(LinearGradient(
colors: [Color.appPrimary.opacity(0.25), Color.appPrimary.opacity(0.0)],
startPoint: .top, endPoint: .bottom
))
// Line
Path { path in
guard !pts.isEmpty else { return }
path.move(to: pts[0])
for i in 1..<pts.count {
let ctrl = CGPoint(x: (pts[i-1].x + pts[i].x) / 2, y: (pts[i-1].y + pts[i].y) / 2)
path.addQuadCurve(to: pts[i], control: ctrl)
}
}
.stroke(Color.appPrimary, lineWidth: 2)
// Last point dot
if let last = pts.last {
Circle()
.fill(Color.appPrimary)
.frame(width: 7, height: 7)
.position(last)
}
}
private func chartPoints(in size: CGSize) -> [CGPoint] {
guard points.count > 1 else { return [] }
let stepX = size.width / CGFloat(points.count - 1)
return points.enumerated().map { i, val in
CGPoint(x: CGFloat(i) * stepX, y: size.height * (1.0 - val * 0.85))
}
}
}
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
/// Which plan the user has highlighted. Defaults to annual (primary offer) when
/// the subscription exists in App Store Connect; falls back to lifetime otherwise.
enum Plan { case annual, lifetime }
@State private var selectedPlan: Plan = .lifetime
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) {
if iapService.annualProduct != nil {
planSelector
} else {
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) {
PremiumChartPreview()
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: - Plan Selector
/// Two-option selector: annual subscription (primary, with free trial when
/// configured) and lifetime unlock as the "best value" anchor.
private var planSelector: some View {
VStack(spacing: 8) {
if let annualPrice = iapService.formattedAnnualPrice {
planRow(
plan: .annual,
title: String(localized: "paywall_plan_annual"),
price: String(format: String(localized: "paywall_plan_annual_per_year"), annualPrice),
badge: iapService.annualTrialDescription
)
}
planRow(
plan: .lifetime,
title: String(localized: "paywall_plan_lifetime"),
price: iapService.formattedPrice,
badge: String(localized: "paywall_plan_lifetime_badge")
)
}
.onAppear {
if iapService.annualProduct != nil { selectedPlan = .annual }
}
}
private func planRow(plan: Plan, title: String, price: String, badge: String?) -> some View {
Button {
selectedPlan = plan
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundColor(.primary)
if let badge, !badge.isEmpty {
Text(badge)
.font(.caption2.weight(.bold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.appSecondary.opacity(0.15))
.foregroundColor(.appSecondary)
.clipShape(Capsule())
}
}
Text(price)
.font(.footnote)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: selectedPlan == plan ? "checkmark.circle.fill" : "circle")
.foregroundColor(selectedPlan == plan ? .appPrimary : .secondary)
}
.padding(12)
.background(Color(.systemBackground))
.overlay(
RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius)
.stroke(selectedPlan == plan ? Color.appPrimary : Color.gray.opacity(0.25),
lineWidth: selectedPlan == plan ? 2 : 1)
)
.cornerRadius(AppConstants.UI.smallCornerRadius)
}
.buttonStyle(.plain)
}
// 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
let product = (selectedPlan == .annual ? iapService.annualProduct : iapService.premiumProduct)
FirebaseService.shared.logPurchaseAttempt(
productId: product?.id ?? IAPService.premiumProductID
)
Task {
do {
if let product {
try await iapService.purchase(product)
} else {
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())
}