feat: add PostHog analytics with full event tracking across app
Integrate self-hosted PostHog (SPM) with AnalyticsManager singleton wrapping all SDK calls. Adds ~40 type-safe events covering trip planning, schedule, progress, IAP, settings, polls, export, and share flows. Includes session replay, autocapture, network telemetry, privacy opt-out toggle in Settings, and super properties (app version, device, pro status, selected sports). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
258
SportsTime/Core/Analytics/AnalyticsEvent.swift
Normal file
258
SportsTime/Core/Analytics/AnalyticsEvent.swift
Normal file
@@ -0,0 +1,258 @@
|
||||
//
|
||||
// AnalyticsEvent.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Type-safe analytics event definitions.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum AnalyticsEvent {
|
||||
|
||||
// MARK: - Navigation
|
||||
|
||||
case tabSwitched(tab: String, previousTab: String?)
|
||||
case screenViewed(screen: String)
|
||||
|
||||
// MARK: - Trip Planning
|
||||
|
||||
case tripWizardStarted(mode: String)
|
||||
case tripWizardStepCompleted(step: String, mode: String)
|
||||
case tripPlanned(sportCount: Int, stopCount: Int, dayCount: Int, mode: String)
|
||||
case tripPlanFailed(mode: String, error: String)
|
||||
case tripSaved(tripId: String, stopCount: Int, gameCount: Int)
|
||||
case tripDeleted(tripId: String)
|
||||
case tripViewed(tripId: String, source: String)
|
||||
case suggestedTripTapped(region: String, stopCount: Int)
|
||||
|
||||
// MARK: - Schedule
|
||||
|
||||
case scheduleViewed(sports: [String])
|
||||
case scheduleFiltered(sport: String, dateRange: String)
|
||||
case gameTapped(gameId: String, sport: String, homeTeam: String, awayTeam: String)
|
||||
|
||||
// MARK: - Progress
|
||||
|
||||
case stadiumVisitAdded(stadiumId: String, sport: String)
|
||||
case stadiumVisitDeleted(stadiumId: String, sport: String)
|
||||
case progressCardShared(sport: String)
|
||||
case sportSwitched(sport: String)
|
||||
|
||||
// MARK: - Export
|
||||
|
||||
case pdfExportStarted(tripId: String, stopCount: Int)
|
||||
case pdfExportCompleted(tripId: String)
|
||||
case pdfExportFailed(tripId: String, error: String)
|
||||
case tripShared(tripId: String)
|
||||
|
||||
// MARK: - IAP
|
||||
|
||||
case paywallViewed(source: String)
|
||||
case purchaseStarted(productId: String)
|
||||
case purchaseCompleted(productId: String)
|
||||
case purchaseFailed(productId: String, error: String)
|
||||
case purchaseRestored
|
||||
case subscriptionStatusChanged(isPro: Bool, plan: String?)
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
case themeChanged(from: String, to: String)
|
||||
case appearanceChanged(mode: String)
|
||||
case sportToggled(sport: String, enabled: Bool)
|
||||
case animationsToggled(enabled: Bool)
|
||||
case drivingHoursChanged(hours: Int)
|
||||
case analyticsToggled(enabled: Bool)
|
||||
case settingsReset
|
||||
|
||||
// MARK: - Polls
|
||||
|
||||
case pollCreated(optionCount: Int)
|
||||
case pollVoted(pollId: String)
|
||||
case pollShared(pollId: String)
|
||||
|
||||
// MARK: - Onboarding
|
||||
|
||||
case onboardingPaywallViewed
|
||||
case onboardingPaywallDismissed
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
case errorOccurred(domain: String, message: String, screen: String?)
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .tabSwitched: return "tab_switched"
|
||||
case .screenViewed: return "screen_viewed"
|
||||
case .tripWizardStarted: return "trip_wizard_started"
|
||||
case .tripWizardStepCompleted: return "trip_wizard_step_completed"
|
||||
case .tripPlanned: return "trip_planned"
|
||||
case .tripPlanFailed: return "trip_plan_failed"
|
||||
case .tripSaved: return "trip_saved"
|
||||
case .tripDeleted: return "trip_deleted"
|
||||
case .tripViewed: return "trip_viewed"
|
||||
case .suggestedTripTapped: return "suggested_trip_tapped"
|
||||
case .scheduleViewed: return "schedule_viewed"
|
||||
case .scheduleFiltered: return "schedule_filtered"
|
||||
case .gameTapped: return "game_tapped"
|
||||
case .stadiumVisitAdded: return "stadium_visit_added"
|
||||
case .stadiumVisitDeleted: return "stadium_visit_deleted"
|
||||
case .progressCardShared: return "progress_card_shared"
|
||||
case .sportSwitched: return "sport_switched"
|
||||
case .pdfExportStarted: return "pdf_export_started"
|
||||
case .pdfExportCompleted: return "pdf_export_completed"
|
||||
case .pdfExportFailed: return "pdf_export_failed"
|
||||
case .tripShared: return "trip_shared"
|
||||
case .paywallViewed: return "paywall_viewed"
|
||||
case .purchaseStarted: return "purchase_started"
|
||||
case .purchaseCompleted: return "purchase_completed"
|
||||
case .purchaseFailed: return "purchase_failed"
|
||||
case .purchaseRestored: return "purchase_restored"
|
||||
case .subscriptionStatusChanged: return "subscription_status_changed"
|
||||
case .themeChanged: return "theme_changed"
|
||||
case .appearanceChanged: return "appearance_changed"
|
||||
case .sportToggled: return "sport_toggled"
|
||||
case .animationsToggled: return "animations_toggled"
|
||||
case .drivingHoursChanged: return "driving_hours_changed"
|
||||
case .analyticsToggled: return "analytics_toggled"
|
||||
case .settingsReset: return "settings_reset"
|
||||
case .pollCreated: return "poll_created"
|
||||
case .pollVoted: return "poll_voted"
|
||||
case .pollShared: return "poll_shared"
|
||||
case .onboardingPaywallViewed: return "onboarding_paywall_viewed"
|
||||
case .onboardingPaywallDismissed: return "onboarding_paywall_dismissed"
|
||||
case .errorOccurred: return "error_occurred"
|
||||
}
|
||||
}
|
||||
|
||||
var properties: [String: Any] {
|
||||
switch self {
|
||||
case .tabSwitched(let tab, let previousTab):
|
||||
var props: [String: Any] = ["tab_name": tab]
|
||||
if let prev = previousTab { props["previous_tab"] = prev }
|
||||
return props
|
||||
|
||||
case .screenViewed(let screen):
|
||||
return ["screen_name": screen]
|
||||
|
||||
case .tripWizardStarted(let mode):
|
||||
return ["mode": mode]
|
||||
|
||||
case .tripWizardStepCompleted(let step, let mode):
|
||||
return ["step_name": step, "mode": mode]
|
||||
|
||||
case .tripPlanned(let sportCount, let stopCount, let dayCount, let mode):
|
||||
return ["sport_count": sportCount, "stop_count": stopCount, "day_count": dayCount, "mode": mode]
|
||||
|
||||
case .tripPlanFailed(let mode, let error):
|
||||
return ["mode": mode, "error": error]
|
||||
|
||||
case .tripSaved(let tripId, let stopCount, let gameCount):
|
||||
return ["trip_id": tripId, "stop_count": stopCount, "game_count": gameCount]
|
||||
|
||||
case .tripDeleted(let tripId):
|
||||
return ["trip_id": tripId]
|
||||
|
||||
case .tripViewed(let tripId, let source):
|
||||
return ["trip_id": tripId, "source": source]
|
||||
|
||||
case .suggestedTripTapped(let region, let stopCount):
|
||||
return ["region": region, "stop_count": stopCount]
|
||||
|
||||
case .scheduleViewed(let sports):
|
||||
return ["sports": sports]
|
||||
|
||||
case .scheduleFiltered(let sport, let dateRange):
|
||||
return ["sport": sport, "date_range": dateRange]
|
||||
|
||||
case .gameTapped(let gameId, let sport, let homeTeam, let awayTeam):
|
||||
return ["game_id": gameId, "sport": sport, "home_team": homeTeam, "away_team": awayTeam]
|
||||
|
||||
case .stadiumVisitAdded(let stadiumId, let sport):
|
||||
return ["stadium_id": stadiumId, "sport": sport]
|
||||
|
||||
case .stadiumVisitDeleted(let stadiumId, let sport):
|
||||
return ["stadium_id": stadiumId, "sport": sport]
|
||||
|
||||
case .progressCardShared(let sport):
|
||||
return ["sport": sport]
|
||||
|
||||
case .sportSwitched(let sport):
|
||||
return ["sport": sport]
|
||||
|
||||
case .pdfExportStarted(let tripId, let stopCount):
|
||||
return ["trip_id": tripId, "stop_count": stopCount]
|
||||
|
||||
case .pdfExportCompleted(let tripId):
|
||||
return ["trip_id": tripId]
|
||||
|
||||
case .pdfExportFailed(let tripId, let error):
|
||||
return ["trip_id": tripId, "error": error]
|
||||
|
||||
case .tripShared(let tripId):
|
||||
return ["trip_id": tripId]
|
||||
|
||||
case .paywallViewed(let source):
|
||||
return ["source": source]
|
||||
|
||||
case .purchaseStarted(let productId):
|
||||
return ["product_id": productId]
|
||||
|
||||
case .purchaseCompleted(let productId):
|
||||
return ["product_id": productId]
|
||||
|
||||
case .purchaseFailed(let productId, let error):
|
||||
return ["product_id": productId, "error": error]
|
||||
|
||||
case .purchaseRestored:
|
||||
return [:]
|
||||
|
||||
case .subscriptionStatusChanged(let isPro, let plan):
|
||||
var props: [String: Any] = ["is_pro": isPro]
|
||||
if let plan { props["plan"] = plan }
|
||||
return props
|
||||
|
||||
case .themeChanged(let from, let to):
|
||||
return ["from": from, "to": to]
|
||||
|
||||
case .appearanceChanged(let mode):
|
||||
return ["mode": mode]
|
||||
|
||||
case .sportToggled(let sport, let enabled):
|
||||
return ["sport": sport, "enabled": enabled]
|
||||
|
||||
case .animationsToggled(let enabled):
|
||||
return ["enabled": enabled]
|
||||
|
||||
case .drivingHoursChanged(let hours):
|
||||
return ["hours": hours]
|
||||
|
||||
case .analyticsToggled(let enabled):
|
||||
return ["enabled": enabled]
|
||||
|
||||
case .settingsReset:
|
||||
return [:]
|
||||
|
||||
case .pollCreated(let optionCount):
|
||||
return ["option_count": optionCount]
|
||||
|
||||
case .pollVoted(let pollId):
|
||||
return ["poll_id": pollId]
|
||||
|
||||
case .pollShared(let pollId):
|
||||
return ["poll_id": pollId]
|
||||
|
||||
case .onboardingPaywallViewed:
|
||||
return [:]
|
||||
|
||||
case .onboardingPaywallDismissed:
|
||||
return [:]
|
||||
|
||||
case .errorOccurred(let domain, let message, let screen):
|
||||
var props: [String: Any] = ["domain": domain, "message": message]
|
||||
if let screen { props["screen"] = screen }
|
||||
return props
|
||||
}
|
||||
}
|
||||
}
|
||||
300
SportsTime/Core/Analytics/AnalyticsManager.swift
Normal file
300
SportsTime/Core/Analytics/AnalyticsManager.swift
Normal file
@@ -0,0 +1,300 @@
|
||||
//
|
||||
// AnalyticsManager.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Singleton analytics manager wrapping PostHog SDK.
|
||||
// All analytics events flow through this single manager.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import PostHog
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class AnalyticsManager {
|
||||
|
||||
// MARK: - Singleton
|
||||
|
||||
static let shared = AnalyticsManager()
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
private static let apiKey = "phc_SportsTime_production"
|
||||
private static let host = "https://analytics.88oakapps.com"
|
||||
private static let optOutKey = "analyticsOptedOut"
|
||||
private static let sessionReplayKey = "analytics_session_replay_enabled"
|
||||
private static let iso8601Formatter = ISO8601DateFormatter()
|
||||
|
||||
// MARK: - State
|
||||
|
||||
var isOptedOut: Bool {
|
||||
UserDefaults.standard.bool(forKey: Self.optOutKey)
|
||||
}
|
||||
|
||||
var sessionReplayEnabled: Bool {
|
||||
get {
|
||||
if UserDefaults.standard.object(forKey: Self.sessionReplayKey) == nil {
|
||||
return true
|
||||
}
|
||||
return UserDefaults.standard.bool(forKey: Self.sessionReplayKey)
|
||||
}
|
||||
set {
|
||||
UserDefaults.standard.set(newValue, forKey: Self.sessionReplayKey)
|
||||
if newValue {
|
||||
PostHogSDK.shared.startSessionRecording()
|
||||
} else {
|
||||
PostHogSDK.shared.stopSessionRecording()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var isConfigured = false
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
func configure() {
|
||||
guard !isConfigured else { return }
|
||||
|
||||
let config = PostHogConfig(apiKey: Self.apiKey, host: Self.host)
|
||||
|
||||
// Auto-capture
|
||||
config.captureElementInteractions = true
|
||||
config.captureApplicationLifecycleEvents = true
|
||||
config.captureScreenViews = true
|
||||
|
||||
// Session replay
|
||||
config.sessionReplay = sessionReplayEnabled
|
||||
config.sessionReplayConfig.maskAllTextInputs = true
|
||||
config.sessionReplayConfig.maskAllImages = false
|
||||
config.sessionReplayConfig.captureNetworkTelemetry = true
|
||||
config.sessionReplayConfig.screenshotMode = true // Required for SwiftUI
|
||||
|
||||
// Respect user opt-out preference
|
||||
if isOptedOut {
|
||||
config.optOut = true
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
config.debug = true
|
||||
config.flushAt = 1
|
||||
#endif
|
||||
|
||||
PostHogSDK.shared.setup(config)
|
||||
isConfigured = true
|
||||
|
||||
// Register super properties
|
||||
registerSuperProperties()
|
||||
}
|
||||
|
||||
// MARK: - Super Properties
|
||||
|
||||
func registerSuperProperties() {
|
||||
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
|
||||
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown"
|
||||
let device = UIDevice.current.model
|
||||
let osVersion = UIDevice.current.systemVersion
|
||||
let isPro = StoreManager.shared.isPro
|
||||
let animationsEnabled = DesignStyleManager.shared.animationsEnabled
|
||||
|
||||
// Load selected sports from UserDefaults
|
||||
let selectedSports = UserDefaults.standard.stringArray(forKey: "selectedSports") ?? Sport.supported.map(\.rawValue)
|
||||
|
||||
// Keep super-property keys aligned with Feels so dashboards can compare apps 1:1.
|
||||
PostHogSDK.shared.register([
|
||||
"app_version": version,
|
||||
"build_number": build,
|
||||
"device_model": device,
|
||||
"os_version": osVersion,
|
||||
"is_pro": isPro,
|
||||
"animations_enabled": animationsEnabled,
|
||||
"selected_sports": selectedSports,
|
||||
"theme": "n/a",
|
||||
"icon_pack": "n/a",
|
||||
"voting_layout": "n/a",
|
||||
"day_view_style": "n/a",
|
||||
"mood_shape": "n/a",
|
||||
"personality_pack": "n/a",
|
||||
"privacy_lock_enabled": false,
|
||||
"healthkit_enabled": false,
|
||||
"days_filter_count": 0,
|
||||
"days_filter_all": false,
|
||||
])
|
||||
}
|
||||
|
||||
func updateSuperProperties() {
|
||||
registerSuperProperties()
|
||||
}
|
||||
|
||||
// MARK: - Event Tracking
|
||||
|
||||
func track(_ event: AnalyticsEvent) {
|
||||
guard isConfigured else { return }
|
||||
PostHogSDK.shared.capture(event.name, properties: event.properties)
|
||||
}
|
||||
|
||||
// MARK: - Screen Tracking (manual supplement to auto-capture)
|
||||
|
||||
func trackScreen(_ screenName: String, properties: [String: Any]? = nil) {
|
||||
guard isConfigured else { return }
|
||||
var props: [String: Any] = ["screen_name": screenName]
|
||||
if let properties { props.merge(properties) { _, new in new } }
|
||||
|
||||
#if DEBUG
|
||||
print("[Analytics] screen_viewed: \(screenName)")
|
||||
#endif
|
||||
PostHogSDK.shared.capture("screen_viewed", properties: props)
|
||||
}
|
||||
|
||||
// MARK: - Subscription Funnel
|
||||
|
||||
func trackPaywallViewed(source: String) {
|
||||
guard isConfigured else { return }
|
||||
PostHogSDK.shared.capture("paywall_viewed", properties: [
|
||||
"source": source
|
||||
])
|
||||
}
|
||||
|
||||
func trackPurchaseStarted(productId: String, source: String) {
|
||||
guard isConfigured else { return }
|
||||
PostHogSDK.shared.capture("purchase_started", properties: [
|
||||
"product_id": productId,
|
||||
"source": source
|
||||
])
|
||||
}
|
||||
|
||||
func trackPurchaseCompleted(productId: String, source: String) {
|
||||
guard isConfigured else { return }
|
||||
PostHogSDK.shared.capture("purchase_completed", properties: [
|
||||
"product_id": productId,
|
||||
"source": source
|
||||
])
|
||||
}
|
||||
|
||||
func trackPurchaseFailed(productId: String?, source: String, error: String) {
|
||||
guard isConfigured else { return }
|
||||
var props: [String: Any] = [
|
||||
"source": source,
|
||||
"error": error
|
||||
]
|
||||
if let productId {
|
||||
props["product_id"] = productId
|
||||
}
|
||||
PostHogSDK.shared.capture("purchase_failed", properties: props)
|
||||
}
|
||||
|
||||
func trackPurchaseRestored(source: String) {
|
||||
guard isConfigured else { return }
|
||||
PostHogSDK.shared.capture("purchase_restored", properties: [
|
||||
"source": source
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Opt In / Opt Out
|
||||
|
||||
func optIn() {
|
||||
UserDefaults.standard.set(false, forKey: Self.optOutKey)
|
||||
if isConfigured {
|
||||
PostHogSDK.shared.optIn()
|
||||
PostHogSDK.shared.capture("analytics_toggled", properties: ["enabled": true])
|
||||
}
|
||||
}
|
||||
|
||||
func optOut() {
|
||||
if isConfigured {
|
||||
PostHogSDK.shared.capture("analytics_toggled", properties: ["enabled": false])
|
||||
}
|
||||
UserDefaults.standard.set(true, forKey: Self.optOutKey)
|
||||
if isConfigured {
|
||||
PostHogSDK.shared.optOut()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Person Properties (subscription segmentation)
|
||||
|
||||
func updateSubscriptionStatus(_ status: String, type: String) {
|
||||
guard isConfigured else { return }
|
||||
PostHogSDK.shared.capture("$set", properties: [
|
||||
"$set": [
|
||||
"subscription_status": status,
|
||||
"subscription_type": type
|
||||
]
|
||||
])
|
||||
}
|
||||
|
||||
func trackSubscriptionStatusObserved(
|
||||
status: String,
|
||||
type: String,
|
||||
source: String,
|
||||
isSubscribed: Bool,
|
||||
hasFullAccess: Bool,
|
||||
productId: String?,
|
||||
willAutoRenew: Bool?,
|
||||
isInGracePeriod: Bool?,
|
||||
trialDaysRemaining: Int?,
|
||||
expirationDate: Date?
|
||||
) {
|
||||
guard isConfigured else { return }
|
||||
|
||||
var props: [String: Any] = [
|
||||
"status": status,
|
||||
"type": type,
|
||||
"source": source,
|
||||
"is_subscribed": isSubscribed,
|
||||
"has_full_access": hasFullAccess
|
||||
]
|
||||
|
||||
if let productId {
|
||||
props["product_id"] = productId
|
||||
}
|
||||
if let willAutoRenew {
|
||||
props["will_auto_renew"] = willAutoRenew
|
||||
}
|
||||
if let isInGracePeriod {
|
||||
props["is_in_grace_period"] = isInGracePeriod
|
||||
}
|
||||
if let trialDaysRemaining {
|
||||
props["trial_days_remaining"] = trialDaysRemaining
|
||||
}
|
||||
if let expirationDate {
|
||||
props["expiration_date"] = Self.iso8601Formatter.string(from: expirationDate)
|
||||
}
|
||||
|
||||
PostHogSDK.shared.capture("subscription_status_changed", properties: props)
|
||||
updateSubscriptionStatus(status, type: type)
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
func flush() {
|
||||
guard isConfigured else { return }
|
||||
PostHogSDK.shared.flush()
|
||||
}
|
||||
|
||||
func reset() {
|
||||
guard isConfigured else { return }
|
||||
PostHogSDK.shared.reset()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SwiftUI Screen Tracking Modifier
|
||||
|
||||
struct ScreenTrackingModifier: ViewModifier {
|
||||
let screenName: String
|
||||
let properties: [String: Any]?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.onAppear {
|
||||
AnalyticsManager.shared.trackScreen(screenName, properties: properties)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func trackScreen(_ screenName: String, properties: [String: Any]? = nil) -> some View {
|
||||
modifier(ScreenTrackingModifier(screenName: screenName, properties: properties))
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,9 @@ final class StoreManager {
|
||||
private(set) var isLoading = false
|
||||
private(set) var error: StoreError?
|
||||
|
||||
/// Current subscription status details (nil if no subscription)
|
||||
private(set) var subscriptionStatus: SubscriptionStatusInfo?
|
||||
|
||||
// MARK: - Debug Override (DEBUG builds only)
|
||||
|
||||
#if DEBUG
|
||||
@@ -55,6 +58,10 @@ final class StoreManager {
|
||||
#if DEBUG
|
||||
if debugProOverride { return true }
|
||||
#endif
|
||||
// Grant access if subscribed OR in grace period (billing retry)
|
||||
if let status = subscriptionStatus, status.isInGracePeriod {
|
||||
return true
|
||||
}
|
||||
return !purchasedProductIDs.intersection(Self.proProductIDs).isEmpty
|
||||
}
|
||||
|
||||
@@ -76,13 +83,37 @@ final class StoreManager {
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
print("[StoreManager] Loading products for IDs: \(Self.proProductIDs)")
|
||||
|
||||
do {
|
||||
products = try await Product.products(for: Self.proProductIDs)
|
||||
isLoading = false
|
||||
let fetchedProducts = try await Product.products(for: Self.proProductIDs)
|
||||
products = fetchedProducts
|
||||
|
||||
print("[StoreManager] Loaded \(fetchedProducts.count) products:")
|
||||
for product in fetchedProducts {
|
||||
print("[StoreManager] - \(product.id): \(product.displayPrice) (\(product.displayName))")
|
||||
if let sub = product.subscription {
|
||||
print("[StoreManager] Subscription period: \(sub.subscriptionPeriod)")
|
||||
if let intro = sub.introductoryOffer {
|
||||
print("[StoreManager] Intro offer: \(intro.paymentMode) for \(intro.period), price: \(intro.displayPrice)")
|
||||
} else {
|
||||
print("[StoreManager] No intro offer")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Treat an empty fetch as a configuration issue so UI can show a useful fallback.
|
||||
if fetchedProducts.isEmpty {
|
||||
print("[StoreManager] WARNING: No products returned — check Configuration.storekit")
|
||||
error = .productNotFound
|
||||
}
|
||||
} catch {
|
||||
products = []
|
||||
self.error = .productNotFound
|
||||
isLoading = false
|
||||
print("[StoreManager] ERROR loading products: \(error)")
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
// MARK: - Entitlement Management
|
||||
@@ -97,11 +128,73 @@ final class StoreManager {
|
||||
}
|
||||
|
||||
purchasedProductIDs = purchased
|
||||
|
||||
// Update subscription status details
|
||||
await updateSubscriptionStatus()
|
||||
trackSubscriptionAnalytics(source: "entitlements_refresh")
|
||||
}
|
||||
|
||||
// MARK: - Subscription Status
|
||||
|
||||
private func updateSubscriptionStatus() async {
|
||||
// Find a Pro product to check subscription status
|
||||
guard let product = products.first(where: { Self.proProductIDs.contains($0.id) }),
|
||||
let subscription = product.subscription else {
|
||||
subscriptionStatus = nil
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let statuses = try await subscription.status
|
||||
guard let status = statuses.first(where: { $0.state != .revoked && $0.state != .expired }) ?? statuses.first else {
|
||||
subscriptionStatus = nil
|
||||
return
|
||||
}
|
||||
|
||||
let renewalInfo = try? status.renewalInfo.payloadValue
|
||||
let transaction = try? status.transaction.payloadValue
|
||||
|
||||
let planName: String
|
||||
if let productID = transaction?.productID {
|
||||
planName = productID.contains("annual") ? "Annual" : "Monthly"
|
||||
} else {
|
||||
planName = "Pro"
|
||||
}
|
||||
|
||||
let state: SubscriptionState
|
||||
switch status.state {
|
||||
case .subscribed:
|
||||
state = .active
|
||||
case .inBillingRetryPeriod:
|
||||
state = .billingRetry
|
||||
case .inGracePeriod:
|
||||
state = .gracePeriod
|
||||
case .expired:
|
||||
state = .expired
|
||||
case .revoked:
|
||||
state = .revoked
|
||||
default:
|
||||
state = .active
|
||||
}
|
||||
|
||||
subscriptionStatus = SubscriptionStatusInfo(
|
||||
state: state,
|
||||
planName: planName,
|
||||
productID: transaction?.productID,
|
||||
expirationDate: transaction?.expirationDate,
|
||||
gracePeriodExpirationDate: renewalInfo?.gracePeriodExpirationDate,
|
||||
willAutoRenew: renewalInfo?.willAutoRenew ?? false,
|
||||
isInGracePeriod: status.state == .inBillingRetryPeriod || status.state == .inGracePeriod
|
||||
)
|
||||
} catch {
|
||||
subscriptionStatus = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Purchase
|
||||
|
||||
func purchase(_ product: Product) async throws {
|
||||
func purchase(_ product: Product, source: String = "store_manager") async throws {
|
||||
AnalyticsManager.shared.trackPurchaseStarted(productId: product.id, source: source)
|
||||
let result = try await product.purchase()
|
||||
|
||||
switch result {
|
||||
@@ -109,28 +202,91 @@ final class StoreManager {
|
||||
let transaction = try checkVerified(verification)
|
||||
await transaction.finish()
|
||||
await updateEntitlements()
|
||||
AnalyticsManager.shared.trackPurchaseCompleted(productId: product.id, source: source)
|
||||
trackSubscriptionAnalytics(source: "purchase_success")
|
||||
|
||||
case .userCancelled:
|
||||
AnalyticsManager.shared.trackPurchaseFailed(productId: product.id, source: source, error: "user_cancelled")
|
||||
throw StoreError.userCancelled
|
||||
|
||||
case .pending:
|
||||
// Ask to Buy or SCA - transaction will appear in updates when approved
|
||||
break
|
||||
AnalyticsManager.shared.trackPurchaseFailed(productId: product.id, source: source, error: "pending")
|
||||
|
||||
@unknown default:
|
||||
AnalyticsManager.shared.trackPurchaseFailed(productId: product.id, source: source, error: "unknown")
|
||||
throw StoreError.purchaseFailed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Restore
|
||||
|
||||
func restorePurchases() async {
|
||||
func restorePurchases(source: String = "settings") async {
|
||||
do {
|
||||
try await AppStore.sync()
|
||||
} catch {
|
||||
// Sync failed, but we can still check current entitlements
|
||||
AnalyticsManager.shared.trackPurchaseFailed(productId: nil, source: source, error: error.localizedDescription)
|
||||
}
|
||||
await updateEntitlements()
|
||||
AnalyticsManager.shared.trackPurchaseRestored(source: source)
|
||||
trackSubscriptionAnalytics(source: "restore")
|
||||
}
|
||||
|
||||
// MARK: - Analytics
|
||||
|
||||
func trackSubscriptionAnalytics(source: String) {
|
||||
let status: String
|
||||
let isSubscribed: Bool
|
||||
|
||||
if let subscriptionStatus {
|
||||
switch subscriptionStatus.state {
|
||||
case .active:
|
||||
status = "subscribed"
|
||||
isSubscribed = true
|
||||
case .billingRetry:
|
||||
status = "billing_retry"
|
||||
isSubscribed = true
|
||||
case .gracePeriod:
|
||||
status = "grace_period"
|
||||
isSubscribed = true
|
||||
case .expired:
|
||||
status = "expired"
|
||||
isSubscribed = false
|
||||
case .revoked:
|
||||
status = "revoked"
|
||||
isSubscribed = false
|
||||
}
|
||||
} else {
|
||||
status = isPro ? "subscribed" : "free"
|
||||
isSubscribed = isPro
|
||||
}
|
||||
|
||||
let type = subscriptionType(for: subscriptionStatus?.productID)
|
||||
AnalyticsManager.shared.trackSubscriptionStatusObserved(
|
||||
status: status,
|
||||
type: type,
|
||||
source: source,
|
||||
isSubscribed: isSubscribed,
|
||||
hasFullAccess: isPro,
|
||||
productId: subscriptionStatus?.productID,
|
||||
willAutoRenew: subscriptionStatus?.willAutoRenew,
|
||||
isInGracePeriod: subscriptionStatus?.isInGracePeriod,
|
||||
trialDaysRemaining: nil,
|
||||
expirationDate: subscriptionStatus?.expirationDate
|
||||
)
|
||||
}
|
||||
|
||||
private func subscriptionType(for productID: String?) -> String {
|
||||
guard let productID else { return "none" }
|
||||
let id = productID.lowercased()
|
||||
if id.contains("annual") || id.contains("year") {
|
||||
return "yearly"
|
||||
}
|
||||
if id.contains("month") {
|
||||
return "monthly"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// MARK: - Transaction Listener
|
||||
@@ -161,3 +317,57 @@ final class StoreManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subscription Status Info
|
||||
|
||||
enum SubscriptionState: String {
|
||||
case active
|
||||
case billingRetry
|
||||
case gracePeriod
|
||||
case expired
|
||||
case revoked
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .active: return "Active"
|
||||
case .billingRetry: return "Payment Issue"
|
||||
case .gracePeriod: return "Grace Period"
|
||||
case .expired: return "Expired"
|
||||
case .revoked: return "Revoked"
|
||||
}
|
||||
}
|
||||
|
||||
var isActive: Bool {
|
||||
self == .active || self == .billingRetry || self == .gracePeriod
|
||||
}
|
||||
}
|
||||
|
||||
struct SubscriptionStatusInfo {
|
||||
let state: SubscriptionState
|
||||
let planName: String
|
||||
let productID: String?
|
||||
let expirationDate: Date?
|
||||
let gracePeriodExpirationDate: Date?
|
||||
let willAutoRenew: Bool
|
||||
let isInGracePeriod: Bool
|
||||
|
||||
var renewalDescription: String {
|
||||
guard let date = expirationDate else { return "" }
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
|
||||
if state == .expired || state == .revoked {
|
||||
return "Expired \(formatter.string(from: date))"
|
||||
}
|
||||
|
||||
if isInGracePeriod, let graceDate = gracePeriodExpirationDate {
|
||||
return "Payment due by \(formatter.string(from: graceDate))"
|
||||
}
|
||||
|
||||
if willAutoRenew {
|
||||
return "Renews \(formatter.string(from: date))"
|
||||
} else {
|
||||
return "Expires \(formatter.string(from: date))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user