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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
This commit is contained in:
alexandrev-tibco
2026-08-26 14:33:18 +02:00
parent d41f72a84f
commit 26d00f65e0
2 changed files with 43 additions and 17 deletions
@@ -8,6 +8,7 @@ final class ReviewPromptService {
private let reviewCompletedKey = "review_funnel_completed" private let reviewCompletedKey = "review_funnel_completed"
private let lastPromptedCompletedWeeksKey = "review_funnel_last_prompted_completed_weeks" private let lastPromptedCompletedWeeksKey = "review_funnel_last_prompted_completed_weeks"
private let postPurchaseAskedKey = "review_post_purchase_asked" private let postPurchaseAskedKey = "review_post_purchase_asked"
private let premiumUserAskedKey = "review_premium_user_asked"
private let minimumCompletedWeeksToStart = 1 private let minimumCompletedWeeksToStart = 1
private let promptIntervalWeeks = 2 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 /// 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. /// delay lets the "premium active" state render before the system alert.
func requestAfterPurchase() { func requestAfterPurchase() {
guard !hasCompletedReviewFlow else { return }
guard !UserDefaults.standard.bool(forKey: postPurchaseAskedKey) else { return } guard !UserDefaults.standard.bool(forKey: postPurchaseAskedKey) else { return }
UserDefaults.standard.set(true, forKey: postPurchaseAskedKey) UserDefaults.standard.set(true, forKey: postPurchaseAskedKey)
AnalyticsService.logEvent("review_prompt_post_purchase") 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 { func shouldShowFunnelAfterWeekCompletion(completedWeeks: Int) -> Bool {
guard !hasCompletedReviewFlow else { return false } guard !hasCompletedReviewFlow else { return false }
guard completedWeeks >= minimumCompletedWeeksToStart else { return false } guard completedWeeks >= minimumCompletedWeeksToStart else { return false }
+26 -17
View File
@@ -12,8 +12,10 @@ struct HomeView: View {
@Query private var allSettings: [AppSettings] @Query private var allSettings: [AppSettings]
@StateObject private var viewModel = HomeViewModel() @StateObject private var viewModel = HomeViewModel()
@State private var editingDish: Dish? @State private var editingDish: Dish?
@State private var showPremiumFromExport: Bool = false // sheet(item:) so the source travels with the presentation with two
@State private var paywallSource: String = "unknown" // 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 showMonthlyHistory: Bool = false
@State private var showStats: Bool = false @State private var showStats: Bool = false
@State private var showWeekLimitUpsell: Bool = false @State private var showWeekLimitUpsell: Bool = false
@@ -187,8 +189,7 @@ struct HomeView: View {
showWeekLimitUpsell = true showWeekLimitUpsell = true
} }
AnalyticsService.logWeekLimitHit() AnalyticsService.logWeekLimitHit()
paywallSource = "week_limit" paywallPresentation = PaywallPresentation(source: "week_limit")
showPremiumFromExport = true
HapticManager.shared.notification(type: .warning) HapticManager.shared.notification(type: .warning)
} }
} label: { } label: {
@@ -305,8 +306,7 @@ struct HomeView: View {
messageKey: "premium_limit_future_weeks", messageKey: "premium_limit_future_weeks",
actionTitleKey: "premium_subscribe" actionTitleKey: "premium_subscribe"
) { ) {
paywallSource = "week_limit_banner" paywallPresentation = PaywallPresentation(source: "week_limit_banner")
showPremiumFromExport = true
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.bottom, 8) .padding(.bottom, 8)
@@ -414,8 +414,7 @@ struct HomeView: View {
.alert("onboarding_premium_prompt_title", isPresented: $showPostOnboardingPremiumPrompt) { .alert("onboarding_premium_prompt_title", isPresented: $showPostOnboardingPremiumPrompt) {
Button("onboarding_premium_prompt_cta") { Button("onboarding_premium_prompt_cta") {
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingPremiumPromptKey) UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingPremiumPromptKey)
paywallSource = "post_onboarding" paywallPresentation = PaywallPresentation(source: "post_onboarding")
showPremiumFromExport = true
} }
Button("reset_cancel", role: .cancel) { Button("reset_cancel", role: .cancel) {
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingPremiumPromptKey) UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingPremiumPromptKey)
@@ -425,13 +424,17 @@ struct HomeView: View {
} }
.alert("review_funnel_title", isPresented: $showReviewSentimentPrompt) { .alert("review_funnel_title", isPresented: $showReviewSentimentPrompt) {
Button("review_funnel_positive") { Button("review_funnel_positive") {
AnalyticsService.logEvent("review_funnel_positive")
ReviewPromptService.shared.requestReview() ReviewPromptService.shared.requestReview()
ReviewPromptService.shared.markReviewCompleted() ReviewPromptService.shared.markReviewCompleted()
} }
Button("review_funnel_negative", role: .destructive) { Button("review_funnel_negative", role: .destructive) {
AnalyticsService.logEvent("review_funnel_negative")
showReviewSupportPrompt = true showReviewSupportPrompt = true
} }
Button("reset_cancel", role: .cancel) {} Button("reset_cancel", role: .cancel) {
AnalyticsService.logEvent("review_funnel_dismissed")
}
} message: { } message: {
Text("review_funnel_message") Text("review_funnel_message")
} }
@@ -560,9 +563,9 @@ struct HomeView: View {
.sheet(item: $editingDish) { dish in .sheet(item: $editingDish) { dish in
DishFormView(dish: dish) DishFormView(dish: dish)
} }
.sheet(isPresented: $showPremiumFromExport) { .sheet(item: $paywallPresentation) { presentation in
NavigationStack { NavigationStack {
PremiumView(settings: settings, source: paywallSource) PremiumView(settings: settings, source: presentation.source)
} }
} }
.sheet(isPresented: $showExportStylePicker) { .sheet(isPresented: $showExportStylePicker) {
@@ -630,6 +633,9 @@ struct HomeView: View {
wasWeekComplete = isWeekComplete(plan: plan) wasWeekComplete = isWeekComplete(plan: plan)
evaluatePostOnboardingPromptsIfNeeded(plan: plan, settings: settings) evaluatePostOnboardingPromptsIfNeeded(plan: plan, settings: settings)
evaluateWidgetPromo() evaluateWidgetPromo()
if settings.isPremium {
ReviewPromptService.shared.requestForExistingPremiumUser()
}
updateWidget(settings: settings) updateWidget(settings: settings)
} }
.onOpenURL { url in .onOpenURL { url in
@@ -647,6 +653,11 @@ struct HomeView: View {
} }
} }
private struct PaywallPresentation: Identifiable {
let id = UUID()
let source: String
}
private struct SlotDishPickerSheet: View { private struct SlotDishPickerSheet: View {
let dishes: [Dish] let dishes: [Dish]
let tags: [Tag] let tags: [Tag]
@@ -845,8 +856,7 @@ struct HomeView: View {
showWeekLimitUpsell = true showWeekLimitUpsell = true
} }
AnalyticsService.logWeekLimitHit() AnalyticsService.logWeekLimitHit()
paywallSource = "week_picker" paywallPresentation = PaywallPresentation(source: "week_picker")
showPremiumFromExport = true
HapticManager.shared.notification(type: .warning) HapticManager.shared.notification(type: .warning)
} }
} }
@@ -971,8 +981,7 @@ struct HomeView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
} else { } else {
Button { Button {
paywallSource = "share_week" paywallPresentation = PaywallPresentation(source: "share_week")
showPremiumFromExport = true
} label: { } label: {
Label("share_week_button", systemImage: "star.fill") Label("share_week_button", systemImage: "star.fill")
.font(.mealMoodBodyBold) .font(.mealMoodBodyBold)
@@ -1031,8 +1040,7 @@ struct HomeView: View {
if settings.isPremium { if settings.isPremium {
startShareFlow(plan: plan, settings: settings) startShareFlow(plan: plan, settings: settings)
} else { } else {
paywallSource = "share_week" paywallPresentation = PaywallPresentation(source: "share_week")
showPremiumFromExport = true
} }
} }
@@ -1181,6 +1189,7 @@ struct HomeView: View {
let completedWeeks = plans.filter { !$0.slotList.isEmpty && $0.slotList.allSatisfy { $0.dishId != nil } }.count let completedWeeks = plans.filter { !$0.slotList.isEmpty && $0.slotList.allSatisfy { $0.dishId != nil } }.count
guard ReviewPromptService.shared.shouldShowFunnelAfterWeekCompletion(completedWeeks: completedWeeks) else { return } guard ReviewPromptService.shared.shouldShowFunnelAfterWeekCompletion(completedWeeks: completedWeeks) else { return }
ReviewPromptService.shared.markFunnelShown(completedWeeks: completedWeeks) ReviewPromptService.shared.markFunnelShown(completedWeeks: completedWeeks)
AnalyticsService.logEvent("review_funnel_shown", parameters: ["completed_weeks": completedWeeks])
showReviewSentimentPrompt = true showReviewSentimentPrompt = true
} }