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
This commit is contained in:
@@ -141,6 +141,9 @@ struct ChartsContainerView: View {
|
||||
}
|
||||
}
|
||||
chartContent
|
||||
if viewModel.hiddenHistoryMonths > 0 {
|
||||
lockedHistoryTeaser
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
@@ -150,6 +153,30 @@ struct ChartsContainerView: View {
|
||||
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
|
||||
}
|
||||
|
||||
/// Concrete loss-framing upsell: the user has real data older than the free
|
||||
/// 12-month window; tell them exactly how much is locked instead of hiding it.
|
||||
private var lockedHistoryTeaser: some View {
|
||||
Button {
|
||||
FirebaseService.shared.logPaywallShown(trigger: "history_teaser")
|
||||
viewModel.showingPaywall = true
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "lock.fill")
|
||||
Text(String(format: String(localized: "chart_history_locked"), viewModel.hiddenHistoryMonths))
|
||||
.multilineTextAlignment(.leading)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
}
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundColor(.appPrimary)
|
||||
.padding(12)
|
||||
.background(Color.appPrimary.opacity(0.08))
|
||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - iPhone Layout
|
||||
|
||||
private var iPhoneChartsLayout: some View {
|
||||
@@ -166,6 +193,9 @@ struct ChartsContainerView: View {
|
||||
}
|
||||
filtersSection
|
||||
chartContent
|
||||
if viewModel.hiddenHistoryMonths > 0 {
|
||||
lockedHistoryTeaser
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ struct OnboardingView: View {
|
||||
|
||||
@State private var currentPage = 0
|
||||
@State private var selectedCurrency = Locale.current.currency?.identifier ?? "EUR"
|
||||
@State private var useSampleData = false
|
||||
// Default ON: a user who skips onboarding lands on a populated dashboard (instant
|
||||
// aha moment) instead of an empty screen. Sample data is clearly labeled and removable.
|
||||
@State private var useSampleData = true
|
||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
||||
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
|
||||
@State private var showingImportSheet = false
|
||||
@@ -90,6 +92,7 @@ struct OnboardingView: View {
|
||||
}
|
||||
|
||||
Button {
|
||||
FirebaseService.shared.logOnboardingSkipped(atStep: currentPage)
|
||||
completeOnboarding()
|
||||
} label: {
|
||||
Text("Skip")
|
||||
@@ -117,6 +120,9 @@ struct OnboardingView: View {
|
||||
.onAppear {
|
||||
selectedCurrency = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
|
||||
}
|
||||
.onChange(of: currentPage) { _, newPage in
|
||||
FirebaseService.shared.logOnboardingStep(step: newPage)
|
||||
}
|
||||
.sheet(isPresented: $showingImportSheet) {
|
||||
ImportDataView(importContext: .onboarding)
|
||||
}
|
||||
@@ -220,6 +226,12 @@ struct OnboardingQuickStartView: View {
|
||||
let onImport: () -> Void
|
||||
let onAddSource: () -> Void
|
||||
|
||||
/// URL to the app's page in the system Settings app. Exposed as a static helper
|
||||
/// for testability (see OnboardingViewTests).
|
||||
static func appSettingsURL() -> URL? {
|
||||
URL(string: UIApplication.openSettingsURLString)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 28) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import StoreKit
|
||||
|
||||
// MARK: - Chart Preview (decorative, no real data)
|
||||
|
||||
@@ -96,6 +97,11 @@ struct PaywallView: View {
|
||||
@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()
|
||||
@@ -116,7 +122,11 @@ struct PaywallView: View {
|
||||
|
||||
// Bottom actions
|
||||
VStack(spacing: 12) {
|
||||
priceLabel
|
||||
if iapService.annualProduct != nil {
|
||||
planSelector
|
||||
} else {
|
||||
priceLabel
|
||||
}
|
||||
purchaseButton
|
||||
restoreButton
|
||||
legalSection
|
||||
@@ -197,6 +207,72 @@ struct PaywallView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -259,11 +335,18 @@ struct PaywallView: View {
|
||||
|
||||
private func purchase() {
|
||||
isPurchasing = true
|
||||
FirebaseService.shared.logPurchaseAttempt(productId: IAPService.premiumProductID)
|
||||
let product = (selectedPlan == .annual ? iapService.annualProduct : iapService.premiumProduct)
|
||||
FirebaseService.shared.logPurchaseAttempt(
|
||||
productId: product?.id ?? IAPService.premiumProductID
|
||||
)
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await iapService.purchase()
|
||||
if let product {
|
||||
try await iapService.purchase(product)
|
||||
} else {
|
||||
try await iapService.purchase()
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showingError = true
|
||||
|
||||
@@ -194,7 +194,7 @@ struct SettingsView: View {
|
||||
|
||||
private var updateAvailableSection: some View {
|
||||
Section {
|
||||
Link(destination: URL(string: "https://apps.apple.com/app/id6741412965")!) {
|
||||
Link(destination: GoalShareService.appStoreURL) {
|
||||
HStack {
|
||||
Image(systemName: "arrow.down.circle.fill")
|
||||
.foregroundColor(.appPrimary)
|
||||
|
||||
Reference in New Issue
Block a user