1.1.5: conversion funnel overhaul

Paywall:
- Redesigned PremiumView with yearly/monthly/lifetime side by side
- 7-day free trial badge with savings % vs monthly
- Social proof line, BEST VALUE highlight on yearly
- StoreManager: new yearlyProduct, lifetimeProduct, yearlySavingsPercent, hasTrialAvailable

Funnel:
- Paywall step added to onboarding (PaywallStepView)
- Contextual modal at dish limit (replaces silent block)
- Visible "X/15 dishes" counter in DishListView
- BottomPromoBanner rotates AdMob with internal "Remove ads" CTA (1 in 4)
- Free dish limit lowered 20 → 15
- Review prompt earlier: 2 weeks → 1 week

Analytics:
- paywall_viewed/dismissed with source + seconds_on_screen
- dish_limit_hit, week_limit_hit, free_trial_started events
- Every PremiumView call site now passes its source

StoreKit:
- MealMood.storekit: added yearly $19.99 + 7d trial, monthly trial, lifetime $39.99
- AppStoreConnect-1.1.5-setup.md with full manual setup instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
alexandrev-tibco
2026-06-11 22:21:36 +02:00
parent 72770801d3
commit b45cb2a530
31 changed files with 1067 additions and 104 deletions
+58 -14
View File
@@ -9,7 +9,7 @@ struct DishFormView: View {
@Query private var allSettings: [AppSettings]
@StateObject private var viewModel = DishViewModel()
@State private var showPremium = false
@State private var showDishLimitUpsell = false
@State private var showDishLimitModal = false
@State private var showSavedFeedback = false
var dish: Dish?
@@ -154,14 +154,6 @@ struct DishFormView: View {
.background(Color.mealMoodSurface)
.cornerRadius(14)
if showDishLimitUpsell && reachedFreeDishLimit {
PremiumUpsellBanner(
messageKey: "premium_limit_dishes",
actionTitleKey: "premium_subscribe"
) {
showPremium = true
}
}
// Delete button (edit mode only)
if viewModel.isEditing {
@@ -195,10 +187,8 @@ struct DishFormView: View {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
guard !reachedFreeDishLimit else {
withAnimation(.easeInOut(duration: 0.2)) {
showDishLimitUpsell = true
}
showPremium = true
AnalyticsService.logDishLimitHit()
showDishLimitModal = true
HapticManager.shared.notification(type: .warning)
return
}
@@ -226,9 +216,22 @@ struct DishFormView: View {
}
.sheet(isPresented: $showPremium) {
if let settings = allSettings.first {
NavigationStack { PremiumView(settings: settings) }
NavigationStack { PremiumView(settings: settings, source: "dish_limit") }
}
}
.sheet(isPresented: $showDishLimitModal) {
DishLimitModal(
onSeePremium: {
showDishLimitModal = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
showPremium = true
}
},
onDismiss: { showDishLimitModal = false }
)
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
}
.alert("dish_delete_title", isPresented: $viewModel.showDeleteAlert) {
Button("dish_cancel", role: .cancel) {}
Button("dish_delete", role: .destructive) {
@@ -255,6 +258,47 @@ struct DishFormView: View {
}
// Simple flow layout for tags
private struct DishLimitModal: View {
let onSeePremium: () -> Void
let onDismiss: () -> Void
var body: some View {
VStack(spacing: 20) {
Image(systemName: "sparkles")
.font(.system(size: 44))
.foregroundColor(.mealMoodCoral)
.padding(.top, 24)
VStack(spacing: 8) {
Text("dish_limit_modal_title")
.font(.mealMoodH2)
.foregroundColor(.mealMoodTextPrimary)
.multilineTextAlignment(.center)
Text("dish_limit_modal_message")
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextSecondary)
.multilineTextAlignment(.center)
}
.padding(.horizontal, 24)
Spacer()
VStack(spacing: 10) {
PrimaryButton(title: String(localized: "dish_limit_modal_cta"), action: onSeePremium)
Button(action: onDismiss) {
Text("dish_limit_modal_dismiss")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodTextSecondary)
}
}
.padding(.horizontal, 24)
.padding(.bottom, 24)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.mealMoodBackground.ignoresSafeArea())
}
}
struct FlowLayout: Layout {
var spacing: CGFloat = 8