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:
@@ -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