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