b45cb2a530
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>
71 lines
2.5 KiB
Swift
71 lines
2.5 KiB
Swift
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)
|
|
}
|
|
}
|