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:
alexandrev-tibco
2026-07-02 22:12:32 +02:00
parent 19e80c912b
commit 197bddcd7e
81 changed files with 944 additions and 14 deletions
@@ -0,0 +1,57 @@
import AppIntents
import Foundation
// MARK: - App Intents (Siri / Shortcuts / Spotlight)
//
// Surfaces the core monthly habit outside the app: "Update my portfolio" opens
// Quick Update directly, "Check my portfolio" opens the dashboard. Registering an
// AppShortcutsProvider also lists these actions in Spotlight and the Shortcuts app
// with zero user setup a discovery/retention surface the app didn't have.
struct QuickUpdateIntent: AppIntent {
static let title: LocalizedStringResource = "Update Portfolio"
static let description = IntentDescription("Open Quick Update to record your latest portfolio values.")
static let openAppWhenRun = true
@MainActor
func perform() async throws -> some IntentResult {
// Same signal the widget deep link uses; ContentView routes it to the sheet.
NotificationCenter.default.post(name: .openQuickUpdate, object: nil)
return .result()
}
}
struct OpenDashboardIntent: AppIntent {
static let title: LocalizedStringResource = "Check Portfolio"
static let description = IntentDescription("Open the dashboard to see your net worth and charts.")
static let openAppWhenRun = true
@MainActor
func perform() async throws -> some IntentResult {
NotificationCenter.default.post(name: .openDashboard, object: nil)
return .result()
}
}
struct PortfolioJournalShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: QuickUpdateIntent(),
phrases: [
"Update my portfolio in \(.applicationName)",
"Add a snapshot in \(.applicationName)"
],
shortTitle: "Update Portfolio",
systemImageName: "plus.circle.fill"
)
AppShortcut(
intent: OpenDashboardIntent(),
phrases: [
"Check my portfolio in \(.applicationName)",
"Show my net worth in \(.applicationName)"
],
shortTitle: "Check Portfolio",
systemImageName: "chart.line.uptrend.xyaxis"
)
}
}
@@ -163,6 +163,31 @@ class FirebaseService {
])
}
/// Per-step onboarding funnel event lets us see exactly where users drop off
/// inside onboarding (only the final `onboarding_completed` existed before).
func logOnboardingStep(step: Int) {
guard isConfigured else { return }
Analytics.logEvent("onboarding_step", parameters: [
"step": step
])
}
func logOnboardingSkipped(atStep step: Int) {
guard isConfigured else { return }
Analytics.logEvent("onboarding_skipped", parameters: [
"step": step
])
}
/// Viral loop measurement: fired whenever the user opens a share sheet with
/// app content (portfolio image, check-in, goal). `type` identifies the surface.
func logShare(type: String) {
guard isConfigured else { return }
Analytics.logEvent("content_shared", parameters: [
"share_type": type
])
}
func logWidgetUsed(widgetType: String) {
guard isConfigured else { return }
Analytics.logEvent("widget_used", parameters: [
@@ -7,7 +7,9 @@ class GoalShareService {
static let shared = GoalShareService()
/// App Store URL - auto-redirects to user's country
static let appStoreURL = URL(string: "https://apps.apple.com/app/portfolio-journal/id6744983373")!
/// NOTE: 6757678318 is the real ADAM id (verified via App Store Connect API).
/// The previous id (6744983373) returned a 404, breaking the share QR loop.
static let appStoreURL = URL(string: "https://apps.apple.com/app/portfolio-journal-tracker/id6757678318")!
/// Website URL - has Open Graph tags for social media previews
static let websiteURL = URL(string: "https://portfoliojournal.app")!
@@ -58,6 +60,7 @@ class GoalShareService {
estimatedCompletionDate: Date? = nil,
privacyMode: Bool = false
) {
FirebaseService.shared.logShare(type: "goal")
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let viewController = windowScene.windows.first?.rootViewController else {
return
+52 -5
View File
@@ -16,7 +16,13 @@ class IAPService: ObservableObject {
// MARK: - Constants
/// Lifetime unlock (non-consumable). Existing product keep the id stable.
static let premiumProductID = "com.portfoliojournal.premium"
/// Annual auto-renewable subscription with intro free trial. Acts as the primary
/// offer; lifetime becomes the "best value" anchor. Must be created in App Store
/// Connect with this exact id until then the paywall gracefully shows lifetime only.
static let annualProductID = "com.portfoliojournal.premium.annual"
static let allProductIDs: Set<String> = [premiumProductID, annualProductID]
static let premiumPrice = "€4.69"
// MARK: - Private Properties
@@ -64,7 +70,7 @@ class IAPService: ObservableObject {
func loadProducts() async {
do {
products = try await Product.products(for: [Self.premiumProductID])
products = try await Product.products(for: Self.allProductIDs)
print("Loaded \(products.count) products")
} catch {
print("Failed to load products: \(error)")
@@ -73,10 +79,16 @@ class IAPService: ObservableObject {
// MARK: - Purchase
/// Purchases the lifetime unlock (backwards-compatible entry point).
func purchase() async throws {
guard let product = products.first else {
guard let product = premiumProduct ?? products.first else {
throw IAPError.productNotFound
}
try await purchase(product)
}
/// Purchases a specific product (annual subscription or lifetime unlock).
func purchase(_ product: Product) async throws {
purchaseState = .purchasing
@@ -169,10 +181,13 @@ class IAPService: ObservableObject {
return
}
var entitledProductID = Self.premiumProductID
for await result in StoreKit.Transaction.currentEntitlements {
if case .verified(let transaction) = result {
if transaction.productID == Self.premiumProductID {
// Lifetime unlock OR an active annual subscription both grant premium.
if Self.allProductIDs.contains(transaction.productID) {
isEntitled = true
entitledProductID = transaction.productID
familyShared = transaction.ownershipType == .familyShared
break
}
@@ -187,7 +202,7 @@ class IAPService: ObservableObject {
let context = CoreDataStack.shared.viewContext
PremiumStatus.updateStatus(
isPremium: isEntitled,
productIdentifier: Self.premiumProductID,
productIdentifier: entitledProductID,
transactionId: nil,
isFamilyShared: familyShared,
in: context
@@ -238,9 +253,34 @@ class IAPService: ObservableObject {
products.first { $0.id == Self.premiumProductID }
}
/// Annual subscription, nil until the product exists in App Store Connect.
var annualProduct: Product? {
products.first { $0.id == Self.annualProductID }
}
var formattedPrice: String {
premiumProduct?.displayPrice ?? Self.premiumPrice
}
var formattedAnnualPrice: String? {
annualProduct?.displayPrice
}
/// Localized description of the annual intro offer ("7 days free"), if configured.
var annualTrialDescription: String? {
guard let intro = annualProduct?.subscription?.introductoryOffer,
intro.paymentMode == .freeTrial else { return nil }
let period = intro.period
let unit: String
switch period.unit {
case .day: unit = String(localized: "paywall_trial_days")
case .week: unit = String(localized: "paywall_trial_weeks")
case .month: unit = String(localized: "paywall_trial_months")
case .year: unit = String(localized: "paywall_trial_years")
@unknown default: unit = ""
}
return String(format: String(localized: "paywall_trial_format"), period.value, unit)
}
}
// MARK: - IAP Error
@@ -276,7 +316,8 @@ extension IAPService {
("person.2", "Family Sharing", "Share with up to 5 family members")
]
/// Condensed benefits shown on the paywall (4 max, outcome-focused)
/// Condensed benefits shown on the paywall (outcome-focused). Multiple Accounts and
/// Family Sharing are key differentiators vs competitors keep them visible here.
static var paywallBenefits: [(icon: String, title: String, subtitle: String)] {[
("clock.arrow.circlepath",
String(localized: "paywall_benefit_history_title"),
@@ -287,6 +328,12 @@ extension IAPService {
("wand.and.stars",
String(localized: "paywall_benefit_forecasts_title"),
String(localized: "paywall_benefit_forecasts_subtitle")),
("person.2",
String(localized: "paywall_benefit_accounts_title"),
String(localized: "paywall_benefit_accounts_subtitle")),
("person.3",
String(localized: "paywall_benefit_family_title"),
String(localized: "paywall_benefit_family_subtitle")),
("xmark.circle",
String(localized: "paywall_benefit_noads_title"),
String(localized: "paywall_benefit_noads_subtitle"))
@@ -260,6 +260,44 @@ extension NotificationService {
}
}
/// Streak protection: reminds the user on the 25th of the CURRENT month if this
/// month's check-in is still pending loss aversion beats the day-1 nudge alone.
/// Safe to call on every app activation (no-op if already done or date passed).
func scheduleStreakProtectionReminder() {
guard isAuthorized else { return }
let identifier = "streak_protection"
center.removePendingNotificationRequests(withIdentifiers: [identifier])
let calendar = Calendar.current
let now = Date()
guard let thisMonthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: now)) else { return }
// Already checked in this month nothing to protect
guard MonthlyCheckInStore.completionDate(for: thisMonthStart.adding(days: 1)) == nil else { return }
var components = calendar.dateComponents([.year, .month], from: thisMonthStart)
components.day = 25
components.hour = 18
components.minute = 0
guard let fireDate = calendar.date(from: components), fireDate > now else { return }
let content = UNMutableNotificationContent()
content.title = String(localized: "streak_protection_notification_title")
content.body = String(localized: "streak_protection_notification_body")
content.sound = .default
content.userInfo = ["action": "batchUpdate"]
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
center.add(request) { error in
if let error = error {
print("Streak protection notification error: \(error)")
}
}
}
/// Schedules a monthly portfolio summary notification on the 5th of each month at 9am.
func scheduleMonthlyPerformanceSummary() {
guard isAuthorized else { return }
@@ -8,8 +8,11 @@ final class ReviewPromptService {
private let checkInCountKey = "reviewPromptCheckinCount"
private let hasCompletedStoreReviewKey = "reviewPromptHasCompletedStoreReview"
private let promptedAchievementKeysKey = "reviewPromptedAchievementKeys"
private let minCheckInsBetweenPrompts = 3
private let minDaysBetweenPrompts = 90
// Rating velocity: with a monthly-cadence app, 3 check-ins + 90 days meant 3+ months
// before the first review could even be requested. 2 check-ins + 30 days keeps the
// prompt tied to a good moment (a completed check-in) while building ratings sooner.
private let minCheckInsBetweenPrompts = 2
private let minDaysBetweenPrompts = 30
private let userDefaults: UserDefaults
private let dateProvider: () -> Date
private let reviewRequestHandler: () -> Void
@@ -48,6 +48,7 @@ class ShareService {
@MainActor
func shareMonthlyCheckIn(summary: MonthlySummary, appName: String) {
FirebaseService.shared.logShare(type: "monthly_checkin")
let text = Self.buildMonthlyCheckInShareText(summary: summary, appName: appName)
shareCard(
cardTitle: "\(summary.periodLabel) Check-in",
@@ -69,6 +70,7 @@ class ShareService {
yearChange: String?,
sinceInceptionChange: String?
) {
FirebaseService.shared.logShare(type: "portfolio_value")
let appName = Self.appDisplayName
let text = Self.buildPortfolioValueShareText(
totalValue: totalValue,