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