From 26d00f65e0214e524b3eaf2978155194440b67c8 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Wed, 26 Aug 2026 14:33:18 +0200 Subject: [PATCH] reviews: ask unico a premium existentes, funnel instrumentado y fix source unknown del paywall - requestForExistingPremiumUser(): prompt nativo una sola vez para quien ya era premium antes del ask post-compra (evento review_prompt_premium_user) - Funnel de semanas completadas emite review_funnel_shown/positive/negative/dismissed - Paywall de Home via sheet(item:) con PaywallPresentation: el source viaja con la presentacion; antes dos @State separados hacian que a veces se registrara paywall_viewed con source=unknown (post_onboarding nunca llegaba) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG --- MealMood/Services/ReviewPromptService.swift | 17 ++++++++ MealMood/Views/Home/HomeView.swift | 43 +++++++++++++-------- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/MealMood/Services/ReviewPromptService.swift b/MealMood/Services/ReviewPromptService.swift index bced2b1..3212dd8 100644 --- a/MealMood/Services/ReviewPromptService.swift +++ b/MealMood/Services/ReviewPromptService.swift @@ -8,6 +8,7 @@ final class ReviewPromptService { private let reviewCompletedKey = "review_funnel_completed" private let lastPromptedCompletedWeeksKey = "review_funnel_last_prompted_completed_weeks" private let postPurchaseAskedKey = "review_post_purchase_asked" + private let premiumUserAskedKey = "review_premium_user_asked" private let minimumCompletedWeeksToStart = 1 private let promptIntervalWeeks = 2 @@ -22,6 +23,7 @@ final class ReviewPromptService { /// user just paid, so sentiment is at its peak. Once per user; the small /// delay lets the "premium active" state render before the system alert. func requestAfterPurchase() { + guard !hasCompletedReviewFlow else { return } guard !UserDefaults.standard.bool(forKey: postPurchaseAskedKey) else { return } UserDefaults.standard.set(true, forKey: postPurchaseAskedKey) AnalyticsService.logEvent("review_prompt_post_purchase") @@ -32,6 +34,21 @@ final class ReviewPromptService { } } + /// One-time ask for users who were already premium before the post-purchase + /// prompt existed — they never went through it, and a paying user is the + /// most likely 5-star reviewer the app has. + func requestForExistingPremiumUser() { + guard !hasCompletedReviewFlow else { return } + guard !UserDefaults.standard.bool(forKey: premiumUserAskedKey) else { return } + UserDefaults.standard.set(true, forKey: premiumUserAskedKey) + AnalyticsService.logEvent("review_prompt_premium_user") + Task { @MainActor in + try? await Task.sleep(for: .seconds(2)) + requestReview() + markReviewCompleted() + } + } + func shouldShowFunnelAfterWeekCompletion(completedWeeks: Int) -> Bool { guard !hasCompletedReviewFlow else { return false } guard completedWeeks >= minimumCompletedWeeksToStart else { return false } diff --git a/MealMood/Views/Home/HomeView.swift b/MealMood/Views/Home/HomeView.swift index 1695ab8..947036b 100644 --- a/MealMood/Views/Home/HomeView.swift +++ b/MealMood/Views/Home/HomeView.swift @@ -12,8 +12,10 @@ struct HomeView: View { @Query private var allSettings: [AppSettings] @StateObject private var viewModel = HomeViewModel() @State private var editingDish: Dish? - @State private var showPremiumFromExport: Bool = false - @State private var paywallSource: String = "unknown" + // sheet(item:) so the source travels with the presentation — with two + // separate @State vars the sheet sometimes rendered before the source was + // set and logged paywall_viewed as "unknown". + @State private var paywallPresentation: PaywallPresentation? @State private var showMonthlyHistory: Bool = false @State private var showStats: Bool = false @State private var showWeekLimitUpsell: Bool = false @@ -187,8 +189,7 @@ struct HomeView: View { showWeekLimitUpsell = true } AnalyticsService.logWeekLimitHit() - paywallSource = "week_limit" - showPremiumFromExport = true + paywallPresentation = PaywallPresentation(source: "week_limit") HapticManager.shared.notification(type: .warning) } } label: { @@ -305,8 +306,7 @@ struct HomeView: View { messageKey: "premium_limit_future_weeks", actionTitleKey: "premium_subscribe" ) { - paywallSource = "week_limit_banner" - showPremiumFromExport = true + paywallPresentation = PaywallPresentation(source: "week_limit_banner") } .padding(.horizontal, 16) .padding(.bottom, 8) @@ -414,8 +414,7 @@ struct HomeView: View { .alert("onboarding_premium_prompt_title", isPresented: $showPostOnboardingPremiumPrompt) { Button("onboarding_premium_prompt_cta") { UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingPremiumPromptKey) - paywallSource = "post_onboarding" - showPremiumFromExport = true + paywallPresentation = PaywallPresentation(source: "post_onboarding") } Button("reset_cancel", role: .cancel) { UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingPremiumPromptKey) @@ -425,13 +424,17 @@ struct HomeView: View { } .alert("review_funnel_title", isPresented: $showReviewSentimentPrompt) { Button("review_funnel_positive") { + AnalyticsService.logEvent("review_funnel_positive") ReviewPromptService.shared.requestReview() ReviewPromptService.shared.markReviewCompleted() } Button("review_funnel_negative", role: .destructive) { + AnalyticsService.logEvent("review_funnel_negative") showReviewSupportPrompt = true } - Button("reset_cancel", role: .cancel) {} + Button("reset_cancel", role: .cancel) { + AnalyticsService.logEvent("review_funnel_dismissed") + } } message: { Text("review_funnel_message") } @@ -560,9 +563,9 @@ struct HomeView: View { .sheet(item: $editingDish) { dish in DishFormView(dish: dish) } - .sheet(isPresented: $showPremiumFromExport) { + .sheet(item: $paywallPresentation) { presentation in NavigationStack { - PremiumView(settings: settings, source: paywallSource) + PremiumView(settings: settings, source: presentation.source) } } .sheet(isPresented: $showExportStylePicker) { @@ -630,6 +633,9 @@ struct HomeView: View { wasWeekComplete = isWeekComplete(plan: plan) evaluatePostOnboardingPromptsIfNeeded(plan: plan, settings: settings) evaluateWidgetPromo() + if settings.isPremium { + ReviewPromptService.shared.requestForExistingPremiumUser() + } updateWidget(settings: settings) } .onOpenURL { url in @@ -647,6 +653,11 @@ struct HomeView: View { } } + private struct PaywallPresentation: Identifiable { + let id = UUID() + let source: String + } + private struct SlotDishPickerSheet: View { let dishes: [Dish] let tags: [Tag] @@ -845,8 +856,7 @@ struct HomeView: View { showWeekLimitUpsell = true } AnalyticsService.logWeekLimitHit() - paywallSource = "week_picker" - showPremiumFromExport = true + paywallPresentation = PaywallPresentation(source: "week_picker") HapticManager.shared.notification(type: .warning) } } @@ -971,8 +981,7 @@ struct HomeView: View { .buttonStyle(.plain) } else { Button { - paywallSource = "share_week" - showPremiumFromExport = true + paywallPresentation = PaywallPresentation(source: "share_week") } label: { Label("share_week_button", systemImage: "star.fill") .font(.mealMoodBodyBold) @@ -1031,8 +1040,7 @@ struct HomeView: View { if settings.isPremium { startShareFlow(plan: plan, settings: settings) } else { - paywallSource = "share_week" - showPremiumFromExport = true + paywallPresentation = PaywallPresentation(source: "share_week") } } @@ -1181,6 +1189,7 @@ struct HomeView: View { let completedWeeks = plans.filter { !$0.slotList.isEmpty && $0.slotList.allSatisfy { $0.dishId != nil } }.count guard ReviewPromptService.shared.shouldShowFunnelAfterWeekCompletion(completedWeeks: completedWeeks) else { return } ReviewPromptService.shared.markFunnelShown(completedWeeks: completedWeeks) + AnalyticsService.logEvent("review_funnel_shown", parameters: ["completed_weeks": completedWeeks]) showReviewSentimentPrompt = true }