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>
243 lines
7.9 KiB
Swift
243 lines
7.9 KiB
Swift
import StoreKit
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
final class StoreManager: ObservableObject {
|
|
enum ProductLoadState {
|
|
case idle
|
|
case loading
|
|
case loaded
|
|
case timedOut
|
|
case notFound
|
|
case failed
|
|
}
|
|
|
|
enum PurchaseResult {
|
|
case success
|
|
case cancelled
|
|
case pending
|
|
case failed
|
|
}
|
|
|
|
@Published var products: [Product] = []
|
|
@Published var isPremium: Bool = false
|
|
@Published var isLoading: Bool = false
|
|
@Published var isLoadingProducts: Bool = false
|
|
@Published var productLoadState: ProductLoadState = .idle
|
|
@Published var debugLoadedProductIds: [String] = []
|
|
@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.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")
|
|
}
|
|
|
|
var seen = Set<String>()
|
|
return ids.filter { seen.insert($0).inserted }
|
|
}
|
|
|
|
init() {
|
|
Task {
|
|
await loadProducts()
|
|
await checkPremiumStatus()
|
|
}
|
|
}
|
|
|
|
func loadProducts() async {
|
|
isLoadingProducts = true
|
|
productLoadState = .loading
|
|
defer { isLoadingProducts = false }
|
|
do {
|
|
let fetchedProducts = try await loadProductsWithRetries()
|
|
products = fetchedProducts
|
|
debugLoadedProductIds = fetchedProducts.map(\.id)
|
|
debugLoadedProducts = fetchedProducts.map { "\($0.id) [\($0.type)]" }
|
|
productLoadState = fetchedProducts.isEmpty ? .notFound : .loaded
|
|
} catch {
|
|
if error is TimeoutError {
|
|
productLoadState = .timedOut
|
|
} else {
|
|
productLoadState = .failed
|
|
}
|
|
print("Failed to load products: \(error)")
|
|
}
|
|
}
|
|
|
|
func purchase(_ product: Product) async -> PurchaseResult {
|
|
isLoading = true
|
|
defer { isLoading = false }
|
|
|
|
do {
|
|
let result = try await product.purchase()
|
|
switch result {
|
|
case .success(let verification):
|
|
if case .verified(let transaction) = verification {
|
|
await transaction.finish()
|
|
isPremium = true
|
|
AnalyticsService.logPremiumPurchased()
|
|
return .success
|
|
}
|
|
return .failed
|
|
case .userCancelled:
|
|
return .cancelled
|
|
case .pending:
|
|
return .pending
|
|
@unknown default:
|
|
return .failed
|
|
}
|
|
} catch {
|
|
print("Purchase failed: \(error)")
|
|
return .failed
|
|
}
|
|
}
|
|
|
|
func restorePurchases() async {
|
|
isLoading = true
|
|
defer { isLoading = false }
|
|
|
|
do {
|
|
try await AppStore.sync()
|
|
let wasNotPremium = !isPremium
|
|
await checkPremiumStatus()
|
|
if wasNotPremium && isPremium {
|
|
AnalyticsService.logPremiumRestored()
|
|
}
|
|
} catch {
|
|
print("Restore failed: \(error)")
|
|
}
|
|
}
|
|
|
|
private func checkPremiumStatus() async {
|
|
isPremium = await Self.hasActiveSubscription()
|
|
}
|
|
|
|
var monthlyProduct: Product? {
|
|
if let match = products.first(where: { $0.id == Self.monthlyProductId && $0.type == .autoRenewable }) {
|
|
return match
|
|
}
|
|
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,
|
|
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"]
|
|
|
|
for await result in Transaction.currentEntitlements {
|
|
guard case .verified(let transaction) = result,
|
|
transaction.revocationDate == nil else { continue }
|
|
|
|
if legacyIds.contains(transaction.productID) {
|
|
return true
|
|
}
|
|
|
|
if subscriptionIds.contains(transaction.productID) {
|
|
if let expirationDate = transaction.expirationDate, expirationDate < Date() {
|
|
continue
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
private struct TimeoutError: Error {}
|
|
|
|
private func loadProductsWithRetries(maxAttempts: Int = 3) async throws -> [Product] {
|
|
var lastProducts: [Product] = []
|
|
|
|
for attempt in 1...maxAttempts {
|
|
let products = try await loadProductsWithTimeout(seconds: 12)
|
|
if !products.isEmpty {
|
|
return products
|
|
}
|
|
lastProducts = products
|
|
|
|
if attempt < maxAttempts {
|
|
try await Task.sleep(nanoseconds: 800_000_000)
|
|
}
|
|
}
|
|
|
|
return lastProducts
|
|
}
|
|
|
|
private func loadProductsWithTimeout(seconds: UInt64) async throws -> [Product] {
|
|
let ids = productIds
|
|
return try await withThrowingTaskGroup(of: [Product].self) { group in
|
|
group.addTask {
|
|
try await self.loadProductsBySingleID(ids: ids)
|
|
}
|
|
group.addTask {
|
|
try await Task.sleep(nanoseconds: seconds * 1_000_000_000)
|
|
throw TimeoutError()
|
|
}
|
|
|
|
let firstResult = try await group.next() ?? []
|
|
group.cancelAll()
|
|
return firstResult
|
|
}
|
|
}
|
|
|
|
private func loadProductsBySingleID(ids: [String]) async throws -> [Product] {
|
|
var merged: [String: Product] = [:]
|
|
|
|
// First, attempt a single batch request with all IDs.
|
|
let batchItems = try await Product.products(for: ids)
|
|
for item in batchItems {
|
|
merged[item.id] = item
|
|
}
|
|
if !merged.isEmpty {
|
|
return Array(merged.values)
|
|
}
|
|
|
|
// Fallback to one-by-one requests for better resilience/debugging.
|
|
for id in ids {
|
|
let items = try await Product.products(for: [id])
|
|
for item in items {
|
|
merged[item.id] = item
|
|
}
|
|
}
|
|
return Array(merged.values)
|
|
}
|
|
}
|