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,126 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct PaywallStepView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@Query private var allSettings: [AppSettings]
|
||||
@StateObject private var storeManager = StoreManager()
|
||||
|
||||
var onSkip: () -> Void
|
||||
var onConverted: () -> Void
|
||||
|
||||
@State private var purchaseStatusMessageKey: String?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.mealMoodBackground.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 24) {
|
||||
AppIconPlaceholder(size: 84)
|
||||
.padding(.top, 20)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("onboarding_paywall_title")
|
||||
.font(.mealMoodH1)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("onboarding_paywall_subtitle")
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
benefitRow(text: String(localized: "premium_unlimited_dishes"))
|
||||
benefitRow(text: String(localized: "premium_advanced_rules"))
|
||||
benefitRow(text: String(localized: "premium_future_weeks"))
|
||||
benefitRow(text: String(localized: "premium_no_ads"))
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.mealMoodSurface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Text("premium_social_proof")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
PrimaryButton(
|
||||
title: trialButtonTitle,
|
||||
action: { Task { await startTrial() } },
|
||||
isEnabled: !storeManager.isLoading
|
||||
)
|
||||
Button(action: skipPaywall) {
|
||||
Text("onboarding_paywall_cta_skip")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
}
|
||||
.task { await storeManager.loadProducts() }
|
||||
.onAppear { AnalyticsService.logPaywallViewed(source: "onboarding") }
|
||||
.alert("premium_title", isPresented: Binding(
|
||||
get: { purchaseStatusMessageKey != nil },
|
||||
set: { if !$0 { purchaseStatusMessageKey = nil } }
|
||||
)) {
|
||||
Button("dish_delete_blocked_ok", role: .cancel) {}
|
||||
} message: {
|
||||
Text(LocalizedStringKey(purchaseStatusMessageKey ?? ""))
|
||||
}
|
||||
}
|
||||
|
||||
private var trialButtonTitle: String {
|
||||
if let yearly = storeManager.yearlyProduct {
|
||||
let format = String(localized: "premium_trial_note")
|
||||
let priceLabel = yearly.displayPrice + " / " + String(localized: "premium_year")
|
||||
return String(localized: "onboarding_paywall_cta_trial") + " — " + String(format: format, priceLabel)
|
||||
}
|
||||
return String(localized: "onboarding_paywall_cta_trial")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func benefitRow(text: String) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.mealMoodSuccess)
|
||||
Text(text)
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private func startTrial() async {
|
||||
let candidate = storeManager.yearlyProduct ?? storeManager.monthlyProduct
|
||||
guard let product = candidate else {
|
||||
purchaseStatusMessageKey = "premium_products_not_found"
|
||||
return
|
||||
}
|
||||
AnalyticsService.logPremiumUpgradeTapped(source: "onboarding")
|
||||
let result = await storeManager.purchase(product)
|
||||
switch result {
|
||||
case .success:
|
||||
if let settings = allSettings.first {
|
||||
settings.isPremium = true
|
||||
try? context.save()
|
||||
}
|
||||
AnalyticsService.logFreeTrialStarted(plan: product.id)
|
||||
onConverted()
|
||||
case .pending: purchaseStatusMessageKey = "premium_purchase_pending"
|
||||
case .cancelled: purchaseStatusMessageKey = "premium_purchase_cancelled"
|
||||
case .failed: purchaseStatusMessageKey = "premium_purchase_failed"
|
||||
}
|
||||
}
|
||||
|
||||
private func skipPaywall() {
|
||||
AnalyticsService.logPaywallDismissed(source: "onboarding", secondsOnScreen: 0, didConvert: false)
|
||||
onSkip()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user