| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396 |
- import Foundation
- import StoreKit
- struct PaywallConfig: Codable, Sendable {
- struct PlanCopy: Codable, Sendable {
- let productID: String
- let title: String
- let badge: String?
- let subtitleTemplate: String
- let trialSubtitleTemplate: String?
- let ctaTemplate: String
- let fallbackPrice: String
- let priceSuffix: String?
- let fallbackBillingDescription: String
- let features: [String]
- init(
- productID: String,
- title: String,
- badge: String? = nil,
- subtitleTemplate: String,
- trialSubtitleTemplate: String? = nil,
- ctaTemplate: String,
- fallbackPrice: String = "—",
- priceSuffix: String? = nil,
- fallbackBillingDescription: String = "",
- features: [String] = []
- ) {
- self.productID = productID
- self.title = title
- self.badge = badge
- self.subtitleTemplate = subtitleTemplate
- self.trialSubtitleTemplate = trialSubtitleTemplate
- self.ctaTemplate = ctaTemplate
- self.fallbackPrice = fallbackPrice
- self.priceSuffix = priceSuffix
- self.fallbackBillingDescription = fallbackBillingDescription
- self.features = features
- }
- }
- 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 TrialFallbackDuration: Codable, Sendable {
- let count: Int
- let unit: String
- }
- struct Trial: Codable, Sendable {
- let eligiblePlan: String
- let badgeTemplate: String
- let ctaTemplate: String
- let fallbackDuration: TrialFallbackDuration?
- }
- 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 restoreSuccessTitle: String
- let restoreSuccessMessage: String
- let pendingPurchaseTitle: String
- let pendingPurchaseMessage: String
- let alertOK: String
- let retry: String
- let retrying: String
- let periodFallback: String
- let productsLoadTimeout: String
- let subscriptionUnavailable: String
- let planUnavailable: String
- let noActiveSubscriptions: String
- let restoreSyncTimeout: String
- let purchaseGeneric: 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
- let securePaymentNote: String
- let lifetimeSecurePaymentNote: String
- let closeButtonHelp: String
- }
- extension PaywallConfig {
- /// Applies remote marketing copy only. Product IDs, legal URLs, and disclosures stay bundled.
- 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: plans.mergingMarketing(from: 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,
- securePaymentNote: securePaymentNote,
- lifetimeSecurePaymentNote: lifetimeSecurePaymentNote,
- closeButtonHelp: closeButtonHelp
- )
- }
- func message(for error: PurchaseError) -> String {
- switch error {
- case .productsLoadTimeout:
- messages.productsLoadTimeout
- case .subscriptionUnavailable:
- messages.subscriptionUnavailable
- case .planUnavailable:
- messages.planUnavailable
- case .purchaseNotVerified:
- messages.failedVerification
- case .purchasePending:
- messages.pendingPurchaseMessage
- case .noActiveSubscriptions:
- messages.noActiveSubscriptions
- case .noActivePurchases:
- messages.restoreNotFoundMessage
- case .restoreSyncTimeout:
- messages.restoreSyncTimeout
- case .generic:
- messages.purchaseGeneric
- }
- }
- 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)
- }
- func trialBadgeText(duration: String) -> String {
- trial.badgeTemplate.replacingOccurrences(of: "{duration}", with: duration)
- }
- func trialCTATitle(duration: String) -> String {
- trial.ctaTemplate.replacingOccurrences(of: "{duration}", with: duration)
- }
- func trialDurationDescription(from fallback: TrialFallbackDuration, lowercase: Bool = false) -> String? {
- guard let unit = subscriptionPeriodUnit(from: fallback.unit) else { return nil }
- return formatDuration(count: fallback.count, unit: unit, lowercase: lowercase)
- }
- private func subscriptionPeriodUnit(from value: String) -> Product.SubscriptionPeriod.Unit? {
- switch value {
- case "day": .day
- case "week": .week
- case "month": .month
- case "year": .year
- default: nil
- }
- }
- var trialEligiblePlan: PaywallPlan? {
- StoreKitCatalog.shared.plan(matching: trial.eligiblePlan)
- }
- var allProductIDs: Set<String> {
- StoreKitCatalog.allProductIDs
- }
- func plan(for productID: String) -> PaywallPlan? {
- guard StoreKitCatalog.shared.contains(productID: productID) else { return nil }
- return PaywallPlan(productID: productID)
- }
- func planCopy(for productID: String) -> PlanCopy? {
- if productID == plans.monthly.productID { return plans.monthly }
- if productID == plans.yearly.productID { return plans.yearly }
- if productID == plans.lifetime.productID { return plans.lifetime }
- return nil
- }
- func synthesizedPlanCopy(for product: Product) -> PlanCopy {
- let title = product.displayName
- if product.type == .nonConsumable {
- return PlanCopy(
- productID: product.id,
- title: title,
- subtitleTemplate: plans.lifetime.subtitleTemplate,
- ctaTemplate: plans.lifetime.ctaTemplate,
- fallbackPrice: loadingPrice,
- priceSuffix: plans.lifetime.priceSuffix,
- fallbackBillingDescription: plans.lifetime.fallbackBillingDescription,
- features: plans.lifetime.features
- )
- }
- guard let subscription = product.subscription else {
- return placeholderPlanCopy(for: product.id)
- }
- let periodLower = subscriptionPeriodDescription(from: subscription.subscriptionPeriod)
- let periodTitle = formatDuration(
- count: subscription.subscriptionPeriod.value,
- unit: subscription.subscriptionPeriod.unit,
- lowercase: false
- )
- return PlanCopy(
- productID: product.id,
- title: title,
- subtitleTemplate: "{price} / \(periodLower), cancel anytime",
- trialSubtitleTemplate: "{trial} free, then {price} / \(periodLower)",
- ctaTemplate: "Subscribe for {price} / \(periodTitle)",
- fallbackPrice: loadingPrice,
- priceSuffix: "/\(periodLower)",
- fallbackBillingDescription: "Billed at {price} every \(periodLower)",
- features: plans.monthly.features
- )
- }
- func placeholderPlanCopy(for productID: String) -> PlanCopy {
- let title = StoreKitCatalog.shared.entry(for: productID)?.displayName ?? "Premium"
- return PlanCopy(
- productID: productID,
- title: title,
- subtitleTemplate: "{price}, cancel anytime",
- ctaTemplate: "Subscribe for {price}",
- fallbackPrice: loadingPrice,
- priceSuffix: nil,
- fallbackBillingDescription: "Billed at {price}",
- features: plans.monthly.features
- )
- }
- }
- extension PaywallConfig.Plans {
- func mergingMarketing(from remote: PaywallConfig.Plans) -> PaywallConfig.Plans {
- PaywallConfig.Plans(
- monthly: monthly.mergingMarketing(from: remote.monthly),
- yearly: yearly.mergingMarketing(from: remote.yearly),
- lifetime: lifetime.mergingMarketing(from: remote.lifetime)
- )
- }
- }
- extension PaywallConfig.PlanCopy {
- func mergingMarketing(from remote: PaywallConfig.PlanCopy) -> PaywallConfig.PlanCopy {
- PaywallConfig.PlanCopy(
- productID: productID,
- title: remote.title,
- badge: remote.badge,
- subtitleTemplate: remote.subtitleTemplate,
- trialSubtitleTemplate: remote.trialSubtitleTemplate,
- ctaTemplate: remote.ctaTemplate,
- fallbackPrice: remote.fallbackPrice,
- priceSuffix: remote.priceSuffix,
- fallbackBillingDescription: remote.fallbackBillingDescription,
- features: remote.features
- )
- }
- }
- extension Notification.Name {
- static let paywallConfigDidUpdate = Notification.Name("paywallConfigDidUpdate")
- }
|