import Foundation import StoreKit struct PaywallConfig: Codable, Sendable { struct PlanCopy: Codable, Sendable { let title: String let badge: String? let subtitleTemplate: String let trialSubtitleTemplate: String? let ctaTemplate: String init( title: String, badge: String? = nil, subtitleTemplate: String, trialSubtitleTemplate: String? = nil, ctaTemplate: String ) { self.title = title self.badge = badge self.subtitleTemplate = subtitleTemplate self.trialSubtitleTemplate = trialSubtitleTemplate self.ctaTemplate = ctaTemplate } } struct TrustItem: Codable, Sendable { let icon: String let title: String let subtitle: String } struct Footer: Codable, Sendable { let continueFree: String let manageSubscription: String let restorePurchase: String let privacyPolicy: String let termsOfService: String let support: String } struct URLs: Codable, Sendable { let privacy: String let terms: String let support: String let manageSubscriptions: String } struct Plans: Codable, Sendable { let monthly: PlanCopy let yearly: PlanCopy let lifetime: PlanCopy } struct DurationUnit: Codable, Sendable { let one: String let other: String let lowerOne: String let lowerOther: String } struct Duration: Codable, Sendable { let units: DurationUnits let countUnitTemplate: String let unknownFallback: String } struct DurationUnits: Codable, Sendable { let day: DurationUnit let week: DurationUnit let month: DurationUnit let year: DurationUnit } struct Trial: Codable, Sendable { let badgeTemplate: String let ctaTemplate: String } struct Messages: Codable, Sendable { let productNotFound: String let failedVerification: String let noPlansAvailable: String let plansLoadFailed: String let purchaseFailedTitle: String let restoreNotFoundTitle: String let restoreNotFoundMessage: String let pendingPurchaseTitle: String let pendingPurchaseMessage: String let alertOK: String let retry: String let retrying: String let periodFallback: String } struct Sidebar: Codable, Sendable { let unlockTitle: String let proTitle: String let unlockDescription: String let proDescription: String let upgradeAction: String let manageSubscriptionAction: String let lifetimeAction: String } let leftPanelTitle: String let rightTitle: String let rightSubtitle: String let features: [String] let trustItems: [TrustItem] let lifetimeTrustItem: TrustItem let plans: Plans let footer: Footer let urls: URLs let trial: Trial let duration: Duration let messages: Messages let sidebar: Sidebar let loadingPrice: String let loadingCTA: String let subscriptionDisclosureTemplate: String let trialDisclosureTemplate: String let lifetimeDisclosure: String let loadingDisclosure: String } extension PaywallConfig { func mergingMarketing(from remote: PaywallConfig) -> PaywallConfig { PaywallConfig( leftPanelTitle: remote.leftPanelTitle, rightTitle: remote.rightTitle, rightSubtitle: remote.rightSubtitle, features: remote.features, trustItems: remote.trustItems, lifetimeTrustItem: remote.lifetimeTrustItem, plans: remote.plans, footer: footer, urls: urls, trial: trial, duration: duration, messages: messages, sidebar: remote.sidebar, loadingPrice: loadingPrice, loadingCTA: loadingCTA, subscriptionDisclosureTemplate: subscriptionDisclosureTemplate, trialDisclosureTemplate: trialDisclosureTemplate, lifetimeDisclosure: lifetimeDisclosure, loadingDisclosure: loadingDisclosure ) } static func loadBundled() -> PaywallConfig { guard let url = Bundle.main.url(forResource: "paywall", withExtension: "json"), let data = try? Data(contentsOf: url), let config = try? JSONDecoder().decode(PaywallConfig.self, from: data) else { fatalError("Missing or invalid paywall.json in app bundle.") } return config } func formatDuration(count: Int, unit: Product.SubscriptionPeriod.Unit, lowercase: Bool) -> String { guard count > 0 else { return duration.unknownFallback.replacingOccurrences(of: "{count}", with: "0") } let durationUnit: DurationUnit switch unit { case .day: durationUnit = duration.units.day case .week: durationUnit = duration.units.week case .month: durationUnit = duration.units.month case .year: durationUnit = duration.units.year @unknown default: return duration.unknownFallback.replacingOccurrences(of: "{count}", with: "\(count)") } let unitWord: String if count == 1 { unitWord = lowercase ? durationUnit.lowerOne : durationUnit.one } else { unitWord = lowercase ? durationUnit.lowerOther : durationUnit.other } if count == 1, lowercase { return unitWord } return duration.countUnitTemplate .replacingOccurrences(of: "{count}", with: "\(count)") .replacingOccurrences(of: "{unit}", with: unitWord) } func trialDurationDescription(from offer: Product.SubscriptionOffer, lowercase: Bool = false) -> String { let count = offer.period.value * offer.periodCount return formatDuration(count: count, unit: offer.period.unit, lowercase: lowercase) } func subscriptionPeriodDescription(from period: Product.SubscriptionPeriod) -> String { formatDuration(count: period.value, unit: period.unit, lowercase: true) } func trialBadgeText(from offer: Product.SubscriptionOffer) -> String { let duration = trialDurationDescription(from: offer) return trial.badgeTemplate.replacingOccurrences(of: "{duration}", with: duration) } func trialCTATitle(from offer: Product.SubscriptionOffer) -> String { let duration = trialDurationDescription(from: offer) return trial.ctaTemplate.replacingOccurrences(of: "{duration}", with: duration) } } extension PaywallConfig: Equatable { static func == (lhs: PaywallConfig, rhs: PaywallConfig) -> Bool { guard let lhsData = try? JSONEncoder().encode(lhs), let rhsData = try? JSONEncoder().encode(rhs) else { return false } return lhsData == rhsData } } extension Notification.Name { static let paywallConfigDidUpdate = Notification.Name("paywallConfigDidUpdate") }