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:
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user