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)
|
||||
}
|
||||
}
|
||||
@@ -15,18 +15,18 @@
|
||||
],
|
||||
"products" : [
|
||||
{
|
||||
"displayPrice" : "2.99",
|
||||
"familyShareable" : false,
|
||||
"displayPrice" : "39.99",
|
||||
"familyShareable" : true,
|
||||
"internalID" : "B2F4D1A0-3E7C-4F8B-9D2E-1A5C6B0E3F7D",
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Lifetime access to all premium features.",
|
||||
"displayName" : "MealMood Premium (Legacy)",
|
||||
"displayName" : "MealMood Premium Lifetime",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "com.mealmood.premium.monthly",
|
||||
"referenceName" : "MealMood Premium Legacy",
|
||||
"referenceName" : "MealMood Premium Lifetime",
|
||||
"type" : "NonConsumable"
|
||||
}
|
||||
],
|
||||
@@ -66,7 +66,11 @@
|
||||
"familyShareable" : true,
|
||||
"groupNumber" : 1,
|
||||
"internalID" : "1A32C57F-C4E9-49F8-8F72-28A2B8A1CC4A",
|
||||
"introductoryOffer" : null,
|
||||
"introductoryOffer" : {
|
||||
"internalID" : "F47EAB12-9D4F-4BD2-AF52-5C90E6D2C5A1",
|
||||
"paymentMode" : "free",
|
||||
"subscriptionPeriod" : "P1W"
|
||||
},
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Monthly subscription to unlock all premium features.",
|
||||
@@ -74,10 +78,34 @@
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "com.mealmood.premium.monthly.sub",
|
||||
"productID" : "com.alexandrevazquez.mealmood.premium.monthly.sub",
|
||||
"recurringSubscriptionPeriod" : "P1M",
|
||||
"referenceName" : "MealMood Premium Monthly",
|
||||
"subscriptionPricePointID" : "0"
|
||||
},
|
||||
{
|
||||
"adHocOffers" : [],
|
||||
"codeOffers" : [],
|
||||
"displayPrice" : "19.99",
|
||||
"familyShareable" : true,
|
||||
"groupNumber" : 2,
|
||||
"internalID" : "C18B4F7E-3A2D-44C9-B47F-6E1D5E9C2B83",
|
||||
"introductoryOffer" : {
|
||||
"internalID" : "A2E5B1F3-7C84-4E59-B0D2-1F8A4D3E7C92",
|
||||
"paymentMode" : "free",
|
||||
"subscriptionPeriod" : "P1W"
|
||||
},
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Yearly subscription to unlock all premium features — best value.",
|
||||
"displayName" : "MealMood Premium Yearly",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "com.alexandrevazquez.mealmood.premium.yearly.sub",
|
||||
"recurringSubscriptionPeriod" : "P1Y",
|
||||
"referenceName" : "MealMood Premium Yearly",
|
||||
"subscriptionPricePointID" : "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.1.4</string>
|
||||
<string>1.1.5</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>50</string>
|
||||
<string>51</string>
|
||||
<key>GADApplicationIdentifier</key>
|
||||
<string>ca-app-pub-1549720748100858~9985112590</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
|
||||
@@ -199,7 +199,31 @@
|
||||
"premium_purchase_cancelled" = "Der Kauf wurde abgebrochen.";
|
||||
"premium_purchase_failed" = "Der Kauf ist fehlgeschlagen. Bitte versuche es erneut.";
|
||||
"tag_selector_empty" = "Noch keine Tags verfügbar";
|
||||
"premium_limit_dishes" = "Limit des Gratisplans erreicht: 20 Gerichte";
|
||||
"premium_limit_dishes" = "Limit des Gratisplans erreicht: 15 Gerichte";
|
||||
"dish_counter_label" = "Gerichte";
|
||||
"dish_counter_upgrade_hint" = "Tippe für unbegrenzte Gerichte mit Premium";
|
||||
"dish_limit_modal_title" = "Du bist auf einer Welle! 🔥";
|
||||
"dish_limit_modal_message" = "Du hast deinen Gratisplan voll. Hol dir unbegrenzte Gerichte, erweiterte Regeln und mehr mit Premium.";
|
||||
"dish_limit_modal_cta" = "Premium ansehen";
|
||||
"dish_limit_modal_dismiss" = "Vielleicht später";
|
||||
"onboarding_paywall_title" = "MealMood Premium kostenlos testen";
|
||||
"onboarding_paywall_subtitle" = "Alles für 7 Tage freigeschaltet — unverbindlich.";
|
||||
"onboarding_paywall_cta_trial" = "Gratis-Test starten";
|
||||
"onboarding_paywall_cta_skip" = "Gratis weiter";
|
||||
"remove_ads_promo_title" = "Müde von Werbung?";
|
||||
"remove_ads_promo_subtitle" = "Hol dir Premium — nie wieder Werbung.";
|
||||
"remove_ads_promo_cta" = "Werbung entfernen";
|
||||
"premium_yearly" = "Jährlich";
|
||||
"premium_year" = "Jahr";
|
||||
"premium_lifetime" = "Lebenslang";
|
||||
"premium_one_time" = "einmalig";
|
||||
"premium_lifetime_badge" = "EINMALIG";
|
||||
"premium_buy_lifetime" = "Lebenslang kaufen";
|
||||
"premium_save_badge" = "SPARE %@";
|
||||
"premium_best_value" = "BESTE WAHL";
|
||||
"premium_trial_badge" = "7 Tage gratis";
|
||||
"premium_trial_note" = "7 Tage gratis, dann %@. Jederzeit kündbar.";
|
||||
"premium_social_proof" = "Geliebt von zufriedenen Familien ★★★★★";
|
||||
"premium_limit_rules" = "Erweiterte Tag-Regeln sind Premium";
|
||||
"premium_limit_future_weeks" = "Der Gratisplan erlaubt Planung bis zur nächsten Woche";
|
||||
|
||||
|
||||
@@ -199,7 +199,31 @@
|
||||
"premium_purchase_cancelled" = "Purchase was cancelled.";
|
||||
"premium_purchase_failed" = "Purchase failed. Please try again.";
|
||||
"tag_selector_empty" = "No tags available yet";
|
||||
"premium_limit_dishes" = "Free plan limit reached: 20 dishes";
|
||||
"premium_limit_dishes" = "Free plan limit reached: 15 dishes";
|
||||
"dish_counter_label" = "dishes";
|
||||
"dish_counter_upgrade_hint" = "Tap to get unlimited with Premium";
|
||||
"dish_limit_modal_title" = "You're cooking up a storm! 🔥";
|
||||
"dish_limit_modal_message" = "You've filled your free plan. Unlock unlimited dishes, advanced rules, and more with Premium.";
|
||||
"dish_limit_modal_cta" = "See Premium";
|
||||
"dish_limit_modal_dismiss" = "Maybe later";
|
||||
"onboarding_paywall_title" = "Try MealMood Premium free";
|
||||
"onboarding_paywall_subtitle" = "Unlock everything for 7 days — no commitment.";
|
||||
"onboarding_paywall_cta_trial" = "Start free trial";
|
||||
"onboarding_paywall_cta_skip" = "Continue free";
|
||||
"remove_ads_promo_title" = "Tired of ads?";
|
||||
"remove_ads_promo_subtitle" = "Go Premium and never see one again.";
|
||||
"remove_ads_promo_cta" = "Remove ads";
|
||||
"premium_yearly" = "Yearly";
|
||||
"premium_year" = "year";
|
||||
"premium_lifetime" = "Lifetime";
|
||||
"premium_one_time" = "one-time";
|
||||
"premium_lifetime_badge" = "PAY ONCE";
|
||||
"premium_buy_lifetime" = "Buy lifetime";
|
||||
"premium_save_badge" = "SAVE %@";
|
||||
"premium_best_value" = "BEST VALUE";
|
||||
"premium_trial_badge" = "7-day free trial";
|
||||
"premium_trial_note" = "Free for 7 days, then %@. Cancel anytime.";
|
||||
"premium_social_proof" = "Trusted by happy families ★★★★★";
|
||||
"premium_limit_rules" = "Advanced tag rules are Premium";
|
||||
"premium_limit_future_weeks" = "Free plan allows planning up to next week";
|
||||
|
||||
|
||||
@@ -199,7 +199,31 @@
|
||||
"premium_purchase_cancelled" = "La compra se ha cancelado.";
|
||||
"premium_purchase_failed" = "La compra ha fallado. Inténtalo de nuevo.";
|
||||
"tag_selector_empty" = "Todavía no hay etiquetas";
|
||||
"premium_limit_dishes" = "Límite del plan gratuito: 20 platos";
|
||||
"premium_limit_dishes" = "Límite del plan gratuito: 15 platos";
|
||||
"dish_counter_label" = "platos";
|
||||
"dish_counter_upgrade_hint" = "Toca para tener ilimitados con Premium";
|
||||
"dish_limit_modal_title" = "¡Estás imparable! 🔥";
|
||||
"dish_limit_modal_message" = "Has llenado tu plan gratuito. Desbloquea platos ilimitados, reglas avanzadas y más con Premium.";
|
||||
"dish_limit_modal_cta" = "Ver Premium";
|
||||
"dish_limit_modal_dismiss" = "Quizá más tarde";
|
||||
"onboarding_paywall_title" = "Prueba MealMood Premium gratis";
|
||||
"onboarding_paywall_subtitle" = "Desbloquea todo durante 7 días — sin compromiso.";
|
||||
"onboarding_paywall_cta_trial" = "Empezar prueba gratis";
|
||||
"onboarding_paywall_cta_skip" = "Continuar gratis";
|
||||
"remove_ads_promo_title" = "¿Cansado de anuncios?";
|
||||
"remove_ads_promo_subtitle" = "Hazte Premium y no volverás a verlos.";
|
||||
"remove_ads_promo_cta" = "Quitar anuncios";
|
||||
"premium_yearly" = "Anual";
|
||||
"premium_year" = "año";
|
||||
"premium_lifetime" = "De por vida";
|
||||
"premium_one_time" = "pago único";
|
||||
"premium_lifetime_badge" = "PAGO ÚNICO";
|
||||
"premium_buy_lifetime" = "Comprar de por vida";
|
||||
"premium_save_badge" = "AHORRA %@";
|
||||
"premium_best_value" = "MEJOR OPCIÓN";
|
||||
"premium_trial_badge" = "7 días gratis";
|
||||
"premium_trial_note" = "Gratis 7 días, después %@. Cancela cuando quieras.";
|
||||
"premium_social_proof" = "Familias felices nos puntúan ★★★★★";
|
||||
"premium_limit_rules" = "Las reglas avanzadas son Premium";
|
||||
"premium_limit_future_weeks" = "El plan gratuito permite planificar hasta la semana siguiente";
|
||||
|
||||
|
||||
@@ -199,7 +199,31 @@
|
||||
"premium_purchase_cancelled" = "L'achat a été annulé.";
|
||||
"premium_purchase_failed" = "L'achat a échoué. Veuillez réessayer.";
|
||||
"tag_selector_empty" = "Aucun tag disponible pour le moment";
|
||||
"premium_limit_dishes" = "Limite du plan gratuit atteinte : 20 plats";
|
||||
"premium_limit_dishes" = "Limite du plan gratuit atteinte : 15 plats";
|
||||
"dish_counter_label" = "plats";
|
||||
"dish_counter_upgrade_hint" = "Touchez pour des plats illimités avec Premium";
|
||||
"dish_limit_modal_title" = "Vous êtes en feu ! 🔥";
|
||||
"dish_limit_modal_message" = "Vous avez rempli votre plan gratuit. Débloquez les plats illimités, règles avancées et plus avec Premium.";
|
||||
"dish_limit_modal_cta" = "Voir Premium";
|
||||
"dish_limit_modal_dismiss" = "Plus tard";
|
||||
"onboarding_paywall_title" = "Essayez MealMood Premium gratuitement";
|
||||
"onboarding_paywall_subtitle" = "Tout débloqué pendant 7 jours — sans engagement.";
|
||||
"onboarding_paywall_cta_trial" = "Démarrer l'essai gratuit";
|
||||
"onboarding_paywall_cta_skip" = "Continuer en gratuit";
|
||||
"remove_ads_promo_title" = "Fatigué des pubs ?";
|
||||
"remove_ads_promo_subtitle" = "Passez Premium et plus jamais de publicité.";
|
||||
"remove_ads_promo_cta" = "Supprimer les pubs";
|
||||
"premium_yearly" = "Annuel";
|
||||
"premium_year" = "an";
|
||||
"premium_lifetime" = "À vie";
|
||||
"premium_one_time" = "paiement unique";
|
||||
"premium_lifetime_badge" = "PAIEMENT UNIQUE";
|
||||
"premium_buy_lifetime" = "Acheter à vie";
|
||||
"premium_save_badge" = "ÉCONOMISEZ %@";
|
||||
"premium_best_value" = "MEILLEUR CHOIX";
|
||||
"premium_trial_badge" = "7 jours gratuits";
|
||||
"premium_trial_note" = "Gratuit 7 jours, puis %@. Annulez à tout moment.";
|
||||
"premium_social_proof" = "Adoré par les familles ★★★★★";
|
||||
"premium_limit_rules" = "Les règles de tags avancées sont réservées au Premium";
|
||||
"premium_limit_future_weeks" = "Le plan gratuit permet de planifier jusqu'à la semaine prochaine";
|
||||
|
||||
|
||||
@@ -199,7 +199,31 @@
|
||||
"premium_purchase_cancelled" = "L'acquisto è stato annullato.";
|
||||
"premium_purchase_failed" = "L'acquisto non è riuscito. Riprova.";
|
||||
"tag_selector_empty" = "Nessun tag disponibile al momento";
|
||||
"premium_limit_dishes" = "Limite del piano gratuito raggiunto: 20 piatti";
|
||||
"premium_limit_dishes" = "Limite del piano gratuito raggiunto: 15 piatti";
|
||||
"dish_counter_label" = "piatti";
|
||||
"dish_counter_upgrade_hint" = "Tocca per piatti illimitati con Premium";
|
||||
"dish_limit_modal_title" = "Sei in fiamme! 🔥";
|
||||
"dish_limit_modal_message" = "Hai riempito il piano gratuito. Sblocca piatti illimitati, regole avanzate e altro con Premium.";
|
||||
"dish_limit_modal_cta" = "Vedi Premium";
|
||||
"dish_limit_modal_dismiss" = "Forse più tardi";
|
||||
"onboarding_paywall_title" = "Prova MealMood Premium gratis";
|
||||
"onboarding_paywall_subtitle" = "Tutto sbloccato per 7 giorni — senza impegno.";
|
||||
"onboarding_paywall_cta_trial" = "Inizia prova gratuita";
|
||||
"onboarding_paywall_cta_skip" = "Continua gratis";
|
||||
"remove_ads_promo_title" = "Stanco delle pubblicità?";
|
||||
"remove_ads_promo_subtitle" = "Passa a Premium e non vedrai mai più una pubblicità.";
|
||||
"remove_ads_promo_cta" = "Rimuovi pubblicità";
|
||||
"premium_yearly" = "Annuale";
|
||||
"premium_year" = "anno";
|
||||
"premium_lifetime" = "A vita";
|
||||
"premium_one_time" = "pagamento unico";
|
||||
"premium_lifetime_badge" = "UNA TANTUM";
|
||||
"premium_buy_lifetime" = "Acquista a vita";
|
||||
"premium_save_badge" = "RISPARMI %@";
|
||||
"premium_best_value" = "MIGLIOR SCELTA";
|
||||
"premium_trial_badge" = "7 giorni gratis";
|
||||
"premium_trial_note" = "Gratis 7 giorni, poi %@. Annulla quando vuoi.";
|
||||
"premium_social_proof" = "Amato da famiglie felici ★★★★★";
|
||||
"premium_limit_rules" = "Le regole avanzate dei tag sono Premium";
|
||||
"premium_limit_future_weeks" = "Il piano gratuito consente di pianificare fino alla prossima settimana";
|
||||
|
||||
|
||||
@@ -199,7 +199,31 @@
|
||||
"premium_purchase_cancelled" = "A compra foi cancelada.";
|
||||
"premium_purchase_failed" = "A compra falhou. Tente novamente.";
|
||||
"tag_selector_empty" = "Ainda não há tags disponíveis";
|
||||
"premium_limit_dishes" = "Limite do plano grátis atingido: 20 pratos";
|
||||
"premium_limit_dishes" = "Limite do plano grátis atingido: 15 pratos";
|
||||
"dish_counter_label" = "pratos";
|
||||
"dish_counter_upgrade_hint" = "Toque para pratos ilimitados com Premium";
|
||||
"dish_limit_modal_title" = "Você está arrasando! 🔥";
|
||||
"dish_limit_modal_message" = "Você encheu seu plano grátis. Desbloqueie pratos ilimitados, regras avançadas e mais com Premium.";
|
||||
"dish_limit_modal_cta" = "Ver Premium";
|
||||
"dish_limit_modal_dismiss" = "Talvez depois";
|
||||
"onboarding_paywall_title" = "Experimente MealMood Premium grátis";
|
||||
"onboarding_paywall_subtitle" = "Tudo liberado por 7 dias — sem compromisso.";
|
||||
"onboarding_paywall_cta_trial" = "Iniciar teste grátis";
|
||||
"onboarding_paywall_cta_skip" = "Continuar grátis";
|
||||
"remove_ads_promo_title" = "Cansado de anúncios?";
|
||||
"remove_ads_promo_subtitle" = "Vire Premium e nunca mais veja um.";
|
||||
"remove_ads_promo_cta" = "Remover anúncios";
|
||||
"premium_yearly" = "Anual";
|
||||
"premium_year" = "ano";
|
||||
"premium_lifetime" = "Vitalício";
|
||||
"premium_one_time" = "pagamento único";
|
||||
"premium_lifetime_badge" = "PAGAMENTO ÚNICO";
|
||||
"premium_buy_lifetime" = "Comprar vitalício";
|
||||
"premium_save_badge" = "ECONOMIZE %@";
|
||||
"premium_best_value" = "MELHOR OPÇÃO";
|
||||
"premium_trial_badge" = "7 dias grátis";
|
||||
"premium_trial_note" = "Grátis por 7 dias, depois %@. Cancele quando quiser.";
|
||||
"premium_social_proof" = "Aprovado por famílias felizes ★★★★★";
|
||||
"premium_limit_rules" = "Regras avançadas de tags são Premium";
|
||||
"premium_limit_future_weeks" = "O plano grátis permite planejar até a próxima semana";
|
||||
|
||||
|
||||
@@ -35,6 +35,13 @@ enum AnalyticsEvent {
|
||||
|
||||
// Feedback loop
|
||||
static let weekRated = "week_rated"
|
||||
|
||||
// Conversion funnel (1.1.5)
|
||||
static let paywallViewed = "paywall_viewed"
|
||||
static let paywallDismissed = "paywall_dismissed"
|
||||
static let dishLimitHit = "dish_limit_hit"
|
||||
static let weekLimitHit = "week_limit_hit"
|
||||
static let freeTrialStarted = "free_trial_started"
|
||||
}
|
||||
|
||||
enum AnalyticsService {
|
||||
@@ -118,4 +125,28 @@ enum AnalyticsService {
|
||||
let value = rating == 1 ? "liked" : rating == -1 ? "disliked" : "cleared"
|
||||
logEvent(AnalyticsEvent.weekRated, parameters: ["rating": value])
|
||||
}
|
||||
|
||||
static func logPaywallViewed(source: String) {
|
||||
logEvent(AnalyticsEvent.paywallViewed, parameters: ["source": source])
|
||||
}
|
||||
|
||||
static func logPaywallDismissed(source: String, secondsOnScreen: Int, didConvert: Bool) {
|
||||
logEvent(AnalyticsEvent.paywallDismissed, parameters: [
|
||||
"source": source,
|
||||
"seconds_on_screen": secondsOnScreen,
|
||||
"did_convert": didConvert
|
||||
])
|
||||
}
|
||||
|
||||
static func logDishLimitHit() {
|
||||
logEvent(AnalyticsEvent.dishLimitHit)
|
||||
}
|
||||
|
||||
static func logWeekLimitHit() {
|
||||
logEvent(AnalyticsEvent.weekLimitHit)
|
||||
}
|
||||
|
||||
static func logFreeTrialStarted(plan: String) {
|
||||
logEvent(AnalyticsEvent.freeTrialStarted, parameters: ["plan": plan])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
enum PremiumAccess {
|
||||
static let freeDishLimit = 20
|
||||
static let freeDishLimit = 15
|
||||
static let freeFutureWeeks = 1
|
||||
|
||||
static func hasReachedFreeDishLimit(dishCount: Int, isPremium: Bool) -> Bool {
|
||||
|
||||
@@ -7,7 +7,7 @@ final class ReviewPromptService {
|
||||
|
||||
private let reviewCompletedKey = "review_funnel_completed"
|
||||
private let lastPromptedCompletedWeeksKey = "review_funnel_last_prompted_completed_weeks"
|
||||
private let minimumCompletedWeeksToStart = 2
|
||||
private let minimumCompletedWeeksToStart = 1
|
||||
private let promptIntervalWeeks = 2
|
||||
|
||||
private init() {}
|
||||
|
||||
@@ -28,12 +28,14 @@ final class StoreManager: ObservableObject {
|
||||
@Published var debugLoadedProducts: [String] = []
|
||||
|
||||
static let monthlyProductId = "com.alexandrevazquez.mealmood.premium.monthly.sub"
|
||||
static let yearlyProductId = "com.alexandrevazquez.mealmood.premium.yearly.sub"
|
||||
static let legacyOneTimeProductId = "com.mealmood.premium.monthly"
|
||||
private var productIds: [String] {
|
||||
var ids = [Self.monthlyProductId, Self.legacyOneTimeProductId]
|
||||
var ids = [Self.monthlyProductId, Self.yearlyProductId, Self.legacyOneTimeProductId]
|
||||
|
||||
if let bundleId = Bundle.main.bundleIdentifier {
|
||||
ids.append("\(bundleId).premium.monthly.sub")
|
||||
ids.append("\(bundleId).premium.yearly.sub")
|
||||
ids.append("\(bundleId).premium.monthly")
|
||||
}
|
||||
|
||||
@@ -120,14 +122,44 @@ final class StoreManager: ObservableObject {
|
||||
if let match = products.first(where: { $0.id == Self.monthlyProductId && $0.type == .autoRenewable }) {
|
||||
return match
|
||||
}
|
||||
return products.first(where: { $0.type == .autoRenewable })
|
||||
return products.first(where: { $0.type == .autoRenewable && $0.subscription?.subscriptionPeriod.unit == .month })
|
||||
}
|
||||
|
||||
var yearlyProduct: Product? {
|
||||
if let match = products.first(where: { $0.id == Self.yearlyProductId && $0.type == .autoRenewable }) {
|
||||
return match
|
||||
}
|
||||
return products.first(where: { $0.type == .autoRenewable && $0.subscription?.subscriptionPeriod.unit == .year })
|
||||
}
|
||||
|
||||
var lifetimeProduct: Product? {
|
||||
products.first(where: { $0.type == .nonConsumable })
|
||||
}
|
||||
|
||||
var yearlySavingsPercent: Int? {
|
||||
guard let monthly = monthlyProduct, let yearly = yearlyProduct else { return nil }
|
||||
let monthlyAnnualised = monthly.price * 12
|
||||
guard monthlyAnnualised > 0 else { return nil }
|
||||
let savings = (monthlyAnnualised - yearly.price) / monthlyAnnualised
|
||||
let percent = Int((NSDecimalNumber(decimal: savings).doubleValue * 100).rounded())
|
||||
return percent > 0 ? percent : nil
|
||||
}
|
||||
|
||||
var hasTrialAvailable: Bool {
|
||||
guard let yearly = yearlyProduct, let intro = yearly.subscription?.introductoryOffer else { return false }
|
||||
return intro.paymentMode == .freeTrial
|
||||
}
|
||||
|
||||
var debugProductIds: [String] { productIds }
|
||||
|
||||
static func hasActiveSubscription() async -> Bool {
|
||||
let bundleId = Bundle.main.bundleIdentifier ?? ""
|
||||
let subscriptionIds: Set<String> = [monthlyProductId, "\(bundleId).premium.monthly.sub"]
|
||||
let subscriptionIds: Set<String> = [
|
||||
monthlyProductId,
|
||||
yearlyProductId,
|
||||
"\(bundleId).premium.monthly.sub",
|
||||
"\(bundleId).premium.yearly.sub"
|
||||
]
|
||||
// Legacy one-time purchases grant lifetime access — no expiration check needed.
|
||||
let legacyIds: Set<String> = [legacyOneTimeProductId, "\(bundleId).premium.monthly"]
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ final class OnboardingViewModel: ObservableObject {
|
||||
@Published var addedDishes: [(name: String, tagIds: [UUID])] = []
|
||||
let maxOnboardingDishes = 10
|
||||
|
||||
let totalSteps = 5
|
||||
let totalSteps = 6
|
||||
|
||||
var canContinue: Bool {
|
||||
switch currentStep {
|
||||
@@ -36,6 +36,7 @@ final class OnboardingViewModel: ObservableObject {
|
||||
case 2: return true // Weekends (toggle)
|
||||
case 3: return true // Calendar (optional)
|
||||
case 4: return true // Dishes are optional
|
||||
case 5: return true // Paywall (always skippable)
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ struct DishListView: View {
|
||||
@State private var showDishForm = false
|
||||
@State private var editingDish: Dish?
|
||||
@State private var showDeleteBlockedAlert = false
|
||||
@State private var showPremium = false
|
||||
|
||||
private var language: AppLanguage {
|
||||
(allSettings.first?.languageEnum ?? .system).resolved()
|
||||
@@ -31,7 +32,11 @@ struct DishListView: View {
|
||||
.padding(.horizontal, 60)
|
||||
}
|
||||
} else {
|
||||
List {
|
||||
VStack(spacing: 0) {
|
||||
if let settings = allSettings.first, !settings.isPremium {
|
||||
dishCounterBanner
|
||||
}
|
||||
List {
|
||||
ForEach(dishes, id: \.id) { dish in
|
||||
let dishTags = tags.filter { dish.tagIds.contains($0.id) }
|
||||
Button {
|
||||
@@ -65,6 +70,7 @@ struct DishListView: View {
|
||||
.onDelete(perform: deleteDishes)
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("home_my_dishes")
|
||||
@@ -78,6 +84,11 @@ struct DishListView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showPremium) {
|
||||
if let settings = allSettings.first {
|
||||
NavigationStack { PremiumView(settings: settings, source: "dish_counter") }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showDishForm) {
|
||||
DishFormView()
|
||||
}
|
||||
@@ -91,6 +102,47 @@ struct DishListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var dishCounterBanner: some View {
|
||||
let count = dishes.count
|
||||
let limit = PremiumAccess.freeDishLimit
|
||||
let remaining = max(0, limit - count)
|
||||
let isNearLimit = remaining <= 2
|
||||
let accent: Color = isNearLimit ? .mealMoodCoral : .mealMoodTextSecondary
|
||||
|
||||
return Button {
|
||||
showPremium = true
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: isNearLimit ? "exclamationmark.circle.fill" : "star.circle")
|
||||
.foregroundColor(accent)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(count) / \(limit) \(String(localized: "dish_counter_label"))")
|
||||
.font(.mealMoodSmall.weight(.semibold))
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Text("dish_counter_upgrade_hint")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.mealMoodSurface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(accent.opacity(isNearLimit ? 0.5 : 0.15), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
private func deleteDishes(at offsets: IndexSet) {
|
||||
let currentWeekStart = Date().startOfWeek()
|
||||
let currentWeekPlan = weekPlans.first { $0.weekStartDate == currentWeekStart }
|
||||
|
||||
@@ -13,6 +13,7 @@ struct HomeView: View {
|
||||
@StateObject private var viewModel = HomeViewModel()
|
||||
@State private var editingDish: Dish?
|
||||
@State private var showPremiumFromExport: Bool = false
|
||||
@State private var paywallSource: String = "unknown"
|
||||
@State private var showMonthlyHistory: Bool = false
|
||||
@State private var showStats: Bool = false
|
||||
@State private var showWeekLimitUpsell: Bool = false
|
||||
@@ -111,6 +112,8 @@ struct HomeView: View {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showWeekLimitUpsell = true
|
||||
}
|
||||
AnalyticsService.logWeekLimitHit()
|
||||
paywallSource = "week_limit"
|
||||
showPremiumFromExport = true
|
||||
HapticManager.shared.notification(type: .warning)
|
||||
}
|
||||
@@ -218,6 +221,7 @@ struct HomeView: View {
|
||||
messageKey: "premium_limit_future_weeks",
|
||||
actionTitleKey: "premium_subscribe"
|
||||
) {
|
||||
paywallSource = "week_limit_banner"
|
||||
showPremiumFromExport = true
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
@@ -267,7 +271,7 @@ struct HomeView: View {
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
if !settings.isPremium && !isRunningOnMac {
|
||||
AdBannerView()
|
||||
BottomPromoBanner(settings: settings)
|
||||
}
|
||||
}
|
||||
.alert("reset_title", isPresented: $viewModel.showResetAlert) {
|
||||
@@ -312,6 +316,7 @@ struct HomeView: View {
|
||||
.alert("onboarding_premium_prompt_title", isPresented: $showPostOnboardingPremiumPrompt) {
|
||||
Button("onboarding_premium_prompt_cta") {
|
||||
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingPremiumPromptKey)
|
||||
paywallSource = "post_onboarding"
|
||||
showPremiumFromExport = true
|
||||
}
|
||||
Button("reset_cancel", role: .cancel) {
|
||||
@@ -459,7 +464,7 @@ struct HomeView: View {
|
||||
}
|
||||
.sheet(isPresented: $showPremiumFromExport) {
|
||||
NavigationStack {
|
||||
PremiumView(settings: settings)
|
||||
PremiumView(settings: settings, source: paywallSource)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showExportStylePicker) {
|
||||
@@ -727,6 +732,8 @@ struct HomeView: View {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showWeekLimitUpsell = true
|
||||
}
|
||||
AnalyticsService.logWeekLimitHit()
|
||||
paywallSource = "week_picker"
|
||||
showPremiumFromExport = true
|
||||
HapticManager.shared.notification(type: .warning)
|
||||
}
|
||||
@@ -819,6 +826,7 @@ struct HomeView: View {
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
Button {
|
||||
paywallSource = "share_week"
|
||||
showPremiumFromExport = true
|
||||
} label: {
|
||||
Label("share_week_button", systemImage: "star.fill")
|
||||
|
||||
@@ -63,13 +63,19 @@ struct OnboardingView: View {
|
||||
viewModel: viewModel,
|
||||
tags: tags,
|
||||
onFinish: {
|
||||
if viewModel.completeOnboarding(context: context) {
|
||||
onComplete()
|
||||
}
|
||||
_ = viewModel.completeOnboarding(context: context)
|
||||
viewModel.nextStep()
|
||||
}
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.tag(4)
|
||||
|
||||
PaywallStepView(
|
||||
onSkip: { onComplete() },
|
||||
onConverted: { onComplete() }
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.tag(5)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.animation(.easeInOut(duration: 0.3), value: viewModel.currentStep)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -181,7 +181,7 @@ struct SettingsView: View {
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
|
||||
NavigationLink(destination: PremiumView(settings: settings)) {
|
||||
NavigationLink(destination: PremiumView(settings: settings, source: "settings")) {
|
||||
Text("settings_remove_ads")
|
||||
}
|
||||
} header: {
|
||||
|
||||
@@ -203,7 +203,7 @@ struct TagRulesEditView: View {
|
||||
}
|
||||
.sheet(isPresented: $showPremium) {
|
||||
if let settings = allSettings.first {
|
||||
NavigationStack { PremiumView(settings: settings) }
|
||||
NavigationStack { PremiumView(settings: settings, source: "tag_rules") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user