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:
@@ -0,0 +1,70 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BottomPromoBanner: View {
|
||||
let settings: AppSettings
|
||||
@State private var showRemoveAdsPromo: Bool = false
|
||||
@State private var showPremium = false
|
||||
|
||||
private static let counterKey = "com.mealmood.bottomBannerRotation"
|
||||
private static let rotationInterval = 4
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if showRemoveAdsPromo {
|
||||
removeAdsPromo
|
||||
} else {
|
||||
AdBannerView()
|
||||
}
|
||||
}
|
||||
.onAppear(perform: decideRotation)
|
||||
.sheet(isPresented: $showPremium) {
|
||||
NavigationStack { PremiumView(settings: settings, source: "ad_replacement") }
|
||||
}
|
||||
}
|
||||
|
||||
private var removeAdsPromo: some View {
|
||||
VStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(Color.mealMoodTextSecondary.opacity(0.22))
|
||||
.frame(height: 1)
|
||||
|
||||
Button {
|
||||
showPremium = true
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "star.fill")
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("remove_ads_promo_title")
|
||||
.font(.mealMoodSmall.weight(.semibold))
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Text("remove_ads_promo_subtitle")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
Spacer()
|
||||
Text("remove_ads_promo_cta")
|
||||
.font(.mealMoodCaption.weight(.semibold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.mealMoodCoral)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(maxWidth: .infinity, minHeight: 56)
|
||||
.background(Color.mealMoodSurface)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.mealMoodSurface)
|
||||
}
|
||||
|
||||
private func decideRotation() {
|
||||
let current = UserDefaults.standard.integer(forKey: Self.counterKey)
|
||||
let next = current + 1
|
||||
UserDefaults.standard.set(next, forKey: Self.counterKey)
|
||||
showRemoveAdsPromo = (next % Self.rotationInterval == 0)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user