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:
@@ -5,16 +5,24 @@ struct PremiumView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@StateObject private var storeManager = StoreManager()
|
||||
@State private var purchaseStatusMessageKey: String?
|
||||
@State private var appearedAt: Date = Date()
|
||||
@State private var didConvert: Bool = false
|
||||
@State private var selectedPlan: PlanChoice = .yearly
|
||||
|
||||
let settings: AppSettings
|
||||
var source: String = "unknown"
|
||||
|
||||
enum PlanChoice {
|
||||
case monthly, yearly, lifetime
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.mealMoodBackground.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
VStack(spacing: 16) {
|
||||
VStack(spacing: 22) {
|
||||
VStack(spacing: 12) {
|
||||
AppIconPlaceholder(size: 84)
|
||||
|
||||
Text("premium_title")
|
||||
@@ -25,10 +33,28 @@ struct PremiumView: View {
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("premium_social_proof")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
.padding(.top, 28)
|
||||
.padding(.top, 24)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
if settings.isPremium {
|
||||
Label("premium_active", systemImage: "checkmark.seal.fill")
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodSuccess)
|
||||
.padding(.horizontal, 24)
|
||||
} else {
|
||||
planSelector
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
ctaButton
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
BenefitItem(text: String(localized: "premium_no_ads"))
|
||||
BenefitItem(text: String(localized: "premium_unlimited_dishes"))
|
||||
@@ -37,39 +63,14 @@ struct PremiumView: View {
|
||||
BenefitItem(text: String(localized: "premium_share_week"))
|
||||
BenefitItem(text: String(localized: "premium_family_sharing"))
|
||||
}
|
||||
.padding(20)
|
||||
.padding(16)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(16)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
if settings.isPremium {
|
||||
Label("premium_active", systemImage: "checkmark.seal.fill")
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodSuccess)
|
||||
if !settings.isPremium && storeManager.monthlyProduct == nil && storeManager.yearlyProduct == nil && storeManager.lifetimeProduct == nil {
|
||||
productLoadFallback
|
||||
.padding(.horizontal, 24)
|
||||
} else if let monthly = storeManager.monthlyProduct {
|
||||
PlanCard(
|
||||
title: String(localized: "premium_monthly"),
|
||||
price: monthly.displayPrice + " / " + String(localized: "premium_month"),
|
||||
caption: String(localized: "premium_price_note"),
|
||||
isLoading: storeManager.isLoading
|
||||
) {
|
||||
Task { await purchase(monthly) }
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
} else {
|
||||
let fallbackText = fallbackContent(for: storeManager.productLoadState)
|
||||
|
||||
PlanCard(
|
||||
title: String(localized: "premium_monthly"),
|
||||
price: fallbackText.price,
|
||||
caption: fallbackText.hint,
|
||||
buttonTitle: String(localized: "premium_retry_products"),
|
||||
isLoading: storeManager.isLoadingProducts
|
||||
) {
|
||||
Task { await storeManager.loadProducts() }
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
#if DEBUG
|
||||
let hasStoreKitFileInBundle = Bundle.main.url(forResource: "MealMood", withExtension: "storekit") != nil
|
||||
@@ -128,6 +129,14 @@ struct PremiumView: View {
|
||||
.task {
|
||||
await storeManager.loadProducts()
|
||||
}
|
||||
.onAppear {
|
||||
appearedAt = Date()
|
||||
AnalyticsService.logPaywallViewed(source: source)
|
||||
}
|
||||
.onDisappear {
|
||||
let seconds = Int(Date().timeIntervalSince(appearedAt))
|
||||
AnalyticsService.logPaywallDismissed(source: source, secondsOnScreen: seconds, didConvert: didConvert)
|
||||
}
|
||||
.onChange(of: storeManager.isPremium) { _, isPremium in
|
||||
if settings.isPremium != isPremium {
|
||||
settings.isPremium = isPremium
|
||||
@@ -146,12 +155,187 @@ struct PremiumView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var planSelector: some View {
|
||||
VStack(spacing: 10) {
|
||||
HStack(spacing: 10) {
|
||||
planChip(.yearly)
|
||||
planChip(.monthly)
|
||||
}
|
||||
if storeManager.lifetimeProduct != nil {
|
||||
planChip(.lifetime, fullWidth: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func planChip(_ plan: PlanChoice, fullWidth: Bool = false) -> some View {
|
||||
let isSelected = selectedPlan == plan
|
||||
let product: Product? = {
|
||||
switch plan {
|
||||
case .monthly: return storeManager.monthlyProduct
|
||||
case .yearly: return storeManager.yearlyProduct
|
||||
case .lifetime: return storeManager.lifetimeProduct
|
||||
}
|
||||
}()
|
||||
let titleKey: LocalizedStringKey = {
|
||||
switch plan {
|
||||
case .monthly: return "premium_monthly"
|
||||
case .yearly: return "premium_yearly"
|
||||
case .lifetime: return "premium_lifetime"
|
||||
}
|
||||
}()
|
||||
let priceSuffix: String = {
|
||||
switch plan {
|
||||
case .monthly: return " / " + String(localized: "premium_month")
|
||||
case .yearly: return " / " + String(localized: "premium_year")
|
||||
case .lifetime: return " · " + String(localized: "premium_one_time")
|
||||
}
|
||||
}()
|
||||
|
||||
Button {
|
||||
selectedPlan = plan
|
||||
HapticManager.shared.impact(style: .light)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(titleKey)
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Spacer()
|
||||
if plan == .yearly, let savings = storeManager.yearlySavingsPercent {
|
||||
Text(String(format: String(localized: "premium_save_badge"), "\(savings)%"))
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.mealMoodCoral)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
if plan == .lifetime {
|
||||
Text("premium_lifetime_badge")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.mealMoodTextPrimary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
if let product = product {
|
||||
Text(product.displayPrice + priceSuffix)
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
} else {
|
||||
Text("premium_loading_products")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
if plan == .yearly {
|
||||
Text("premium_best_value")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(14)
|
||||
.background(isSelected ? Color.mealMoodCoral.opacity(0.08) : Color.mealMoodSurface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(isSelected ? Color.mealMoodCoral : Color(hex: "#E0E0E0"), lineWidth: isSelected ? 2 : 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var ctaButton: some View {
|
||||
let selectedProduct = currentSelectedProduct
|
||||
VStack(spacing: 8) {
|
||||
if selectedHasTrial {
|
||||
Text("premium_trial_badge")
|
||||
.font(.mealMoodCaption.weight(.semibold))
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.mealMoodCoral.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
PrimaryButton(
|
||||
title: ctaTitle,
|
||||
action: { if let p = selectedProduct { Task { await purchase(p) } } },
|
||||
isEnabled: !storeManager.isLoading && selectedProduct != nil
|
||||
)
|
||||
if let product = selectedProduct, selectedHasTrial {
|
||||
Text(String(format: String(localized: "premium_trial_note"),
|
||||
product.displayPrice + " / " + String(localized: selectedPlan == .yearly ? "premium_year" : "premium_month")))
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var currentSelectedProduct: Product? {
|
||||
switch selectedPlan {
|
||||
case .monthly: return storeManager.monthlyProduct
|
||||
case .yearly: return storeManager.yearlyProduct
|
||||
case .lifetime: return storeManager.lifetimeProduct
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedHasTrial: Bool {
|
||||
guard selectedPlan != .lifetime else { return false }
|
||||
return storeManager.hasTrialAvailable
|
||||
}
|
||||
|
||||
private var ctaTitle: String {
|
||||
if storeManager.isLoading {
|
||||
return String(localized: "premium_processing")
|
||||
}
|
||||
if selectedPlan == .lifetime {
|
||||
return String(localized: "premium_buy_lifetime")
|
||||
}
|
||||
if selectedHasTrial {
|
||||
return String(localized: "onboarding_paywall_cta_trial")
|
||||
}
|
||||
return String(localized: "premium_subscribe")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var productLoadFallback: some View {
|
||||
let text = fallbackContent(for: storeManager.productLoadState)
|
||||
VStack(spacing: 10) {
|
||||
Text(text.price)
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
Text(text.hint)
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
PrimaryButton(
|
||||
title: String(localized: "premium_retry_products"),
|
||||
action: { Task { await storeManager.loadProducts() } },
|
||||
isEnabled: !storeManager.isLoadingProducts
|
||||
)
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.mealMoodSurface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
|
||||
private func purchase(_ product: Product) async {
|
||||
AnalyticsService.logPremiumUpgradeTapped(source: "premium_view")
|
||||
AnalyticsService.logPremiumUpgradeTapped(source: source)
|
||||
let result = await storeManager.purchase(product)
|
||||
switch result {
|
||||
case .success:
|
||||
settings.isPremium = true
|
||||
didConvert = true
|
||||
if storeManager.hasTrialAvailable {
|
||||
AnalyticsService.logFreeTrialStarted(plan: product.id)
|
||||
}
|
||||
try? context.save()
|
||||
case .pending:
|
||||
purchaseStatusMessageKey = "premium_purchase_pending"
|
||||
|
||||
Reference in New Issue
Block a user