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
+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"))