PaywallConfig.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. import Foundation
  2. import StoreKit
  3. struct PaywallConfig: Codable, Sendable {
  4. struct PlanCopy: Codable, Sendable {
  5. let productID: String
  6. let title: String
  7. let badge: String?
  8. let subtitleTemplate: String
  9. let trialSubtitleTemplate: String?
  10. let ctaTemplate: String
  11. let fallbackPrice: String
  12. let priceSuffix: String?
  13. let fallbackBillingDescription: String
  14. let features: [String]
  15. init(
  16. productID: String,
  17. title: String,
  18. badge: String? = nil,
  19. subtitleTemplate: String,
  20. trialSubtitleTemplate: String? = nil,
  21. ctaTemplate: String,
  22. fallbackPrice: String = "—",
  23. priceSuffix: String? = nil,
  24. fallbackBillingDescription: String = "",
  25. features: [String] = []
  26. ) {
  27. self.productID = productID
  28. self.title = title
  29. self.badge = badge
  30. self.subtitleTemplate = subtitleTemplate
  31. self.trialSubtitleTemplate = trialSubtitleTemplate
  32. self.ctaTemplate = ctaTemplate
  33. self.fallbackPrice = fallbackPrice
  34. self.priceSuffix = priceSuffix
  35. self.fallbackBillingDescription = fallbackBillingDescription
  36. self.features = features
  37. }
  38. }
  39. struct TrustItem: Codable, Sendable {
  40. let icon: String
  41. let title: String
  42. let subtitle: String
  43. }
  44. struct Footer: Codable, Sendable {
  45. let continueFree: String
  46. let manageSubscription: String
  47. let restorePurchase: String
  48. let privacyPolicy: String
  49. let termsOfService: String
  50. let support: String
  51. }
  52. struct URLs: Codable, Sendable {
  53. let privacy: String
  54. let terms: String
  55. let support: String
  56. let manageSubscriptions: String
  57. }
  58. struct Plans: Codable, Sendable {
  59. let monthly: PlanCopy
  60. let yearly: PlanCopy
  61. let lifetime: PlanCopy
  62. }
  63. struct DurationUnit: Codable, Sendable {
  64. let one: String
  65. let other: String
  66. let lowerOne: String
  67. let lowerOther: String
  68. }
  69. struct Duration: Codable, Sendable {
  70. let units: DurationUnits
  71. let countUnitTemplate: String
  72. let unknownFallback: String
  73. }
  74. struct DurationUnits: Codable, Sendable {
  75. let day: DurationUnit
  76. let week: DurationUnit
  77. let month: DurationUnit
  78. let year: DurationUnit
  79. }
  80. struct TrialFallbackDuration: Codable, Sendable {
  81. let count: Int
  82. let unit: String
  83. }
  84. struct Trial: Codable, Sendable {
  85. let eligiblePlan: String
  86. let badgeTemplate: String
  87. let ctaTemplate: String
  88. let fallbackDuration: TrialFallbackDuration?
  89. }
  90. struct Messages: Codable, Sendable {
  91. let productNotFound: String
  92. let failedVerification: String
  93. let noPlansAvailable: String
  94. let plansLoadFailed: String
  95. let purchaseFailedTitle: String
  96. let restoreNotFoundTitle: String
  97. let restoreNotFoundMessage: String
  98. let restoreSuccessTitle: String
  99. let restoreSuccessMessage: String
  100. let pendingPurchaseTitle: String
  101. let pendingPurchaseMessage: String
  102. let alertOK: String
  103. let retry: String
  104. let retrying: String
  105. let periodFallback: String
  106. let productsLoadTimeout: String
  107. let subscriptionUnavailable: String
  108. let planUnavailable: String
  109. let noActiveSubscriptions: String
  110. let restoreSyncTimeout: String
  111. let purchaseGeneric: String
  112. }
  113. struct Sidebar: Codable, Sendable {
  114. let unlockTitle: String
  115. let proTitle: String
  116. let unlockDescription: String
  117. let proDescription: String
  118. let upgradeAction: String
  119. let manageSubscriptionAction: String
  120. let lifetimeAction: String
  121. }
  122. let leftPanelTitle: String
  123. let rightTitle: String
  124. let rightSubtitle: String
  125. let features: [String]
  126. let trustItems: [TrustItem]
  127. let lifetimeTrustItem: TrustItem
  128. let plans: Plans
  129. let footer: Footer
  130. let urls: URLs
  131. let trial: Trial
  132. let duration: Duration
  133. let messages: Messages
  134. let sidebar: Sidebar
  135. let loadingPrice: String
  136. let loadingCTA: String
  137. let subscriptionDisclosureTemplate: String
  138. let trialDisclosureTemplate: String
  139. let lifetimeDisclosure: String
  140. let loadingDisclosure: String
  141. let securePaymentNote: String
  142. let lifetimeSecurePaymentNote: String
  143. let closeButtonHelp: String
  144. }
  145. extension PaywallConfig {
  146. /// Applies remote marketing copy only. Product IDs, legal URLs, and disclosures stay bundled.
  147. func mergingMarketing(from remote: PaywallConfig) -> PaywallConfig {
  148. PaywallConfig(
  149. leftPanelTitle: remote.leftPanelTitle,
  150. rightTitle: remote.rightTitle,
  151. rightSubtitle: remote.rightSubtitle,
  152. features: remote.features,
  153. trustItems: remote.trustItems,
  154. lifetimeTrustItem: remote.lifetimeTrustItem,
  155. plans: plans.mergingMarketing(from: remote.plans),
  156. footer: footer,
  157. urls: urls,
  158. trial: trial,
  159. duration: duration,
  160. messages: messages,
  161. sidebar: remote.sidebar,
  162. loadingPrice: loadingPrice,
  163. loadingCTA: loadingCTA,
  164. subscriptionDisclosureTemplate: subscriptionDisclosureTemplate,
  165. trialDisclosureTemplate: trialDisclosureTemplate,
  166. lifetimeDisclosure: lifetimeDisclosure,
  167. loadingDisclosure: loadingDisclosure,
  168. securePaymentNote: securePaymentNote,
  169. lifetimeSecurePaymentNote: lifetimeSecurePaymentNote,
  170. closeButtonHelp: closeButtonHelp
  171. )
  172. }
  173. func message(for error: PurchaseError) -> String {
  174. switch error {
  175. case .productsLoadTimeout:
  176. messages.productsLoadTimeout
  177. case .subscriptionUnavailable:
  178. messages.subscriptionUnavailable
  179. case .planUnavailable:
  180. messages.planUnavailable
  181. case .purchaseNotVerified:
  182. messages.failedVerification
  183. case .purchasePending:
  184. messages.pendingPurchaseMessage
  185. case .noActiveSubscriptions:
  186. messages.noActiveSubscriptions
  187. case .noActivePurchases:
  188. messages.restoreNotFoundMessage
  189. case .restoreSyncTimeout:
  190. messages.restoreSyncTimeout
  191. case .generic:
  192. messages.purchaseGeneric
  193. }
  194. }
  195. static func loadBundled() -> PaywallConfig {
  196. guard let url = Bundle.main.url(forResource: "paywall", withExtension: "json"),
  197. let data = try? Data(contentsOf: url),
  198. let config = try? JSONDecoder().decode(PaywallConfig.self, from: data) else {
  199. fatalError("Missing or invalid paywall.json in app bundle.")
  200. }
  201. return config
  202. }
  203. func formatDuration(count: Int, unit: Product.SubscriptionPeriod.Unit, lowercase: Bool) -> String {
  204. guard count > 0 else { return duration.unknownFallback.replacingOccurrences(of: "{count}", with: "0") }
  205. let durationUnit: DurationUnit
  206. switch unit {
  207. case .day: durationUnit = duration.units.day
  208. case .week: durationUnit = duration.units.week
  209. case .month: durationUnit = duration.units.month
  210. case .year: durationUnit = duration.units.year
  211. @unknown default:
  212. return duration.unknownFallback.replacingOccurrences(of: "{count}", with: "\(count)")
  213. }
  214. let unitWord: String
  215. if count == 1 {
  216. unitWord = lowercase ? durationUnit.lowerOne : durationUnit.one
  217. } else {
  218. unitWord = lowercase ? durationUnit.lowerOther : durationUnit.other
  219. }
  220. if count == 1, lowercase {
  221. return unitWord
  222. }
  223. return duration.countUnitTemplate
  224. .replacingOccurrences(of: "{count}", with: "\(count)")
  225. .replacingOccurrences(of: "{unit}", with: unitWord)
  226. }
  227. func trialDurationDescription(from offer: Product.SubscriptionOffer, lowercase: Bool = false) -> String {
  228. let count = offer.period.value * offer.periodCount
  229. return formatDuration(count: count, unit: offer.period.unit, lowercase: lowercase)
  230. }
  231. func subscriptionPeriodDescription(from period: Product.SubscriptionPeriod) -> String {
  232. formatDuration(count: period.value, unit: period.unit, lowercase: true)
  233. }
  234. func trialBadgeText(from offer: Product.SubscriptionOffer) -> String {
  235. let duration = trialDurationDescription(from: offer)
  236. return trial.badgeTemplate.replacingOccurrences(of: "{duration}", with: duration)
  237. }
  238. func trialCTATitle(from offer: Product.SubscriptionOffer) -> String {
  239. let duration = trialDurationDescription(from: offer)
  240. return trial.ctaTemplate.replacingOccurrences(of: "{duration}", with: duration)
  241. }
  242. func trialBadgeText(duration: String) -> String {
  243. trial.badgeTemplate.replacingOccurrences(of: "{duration}", with: duration)
  244. }
  245. func trialCTATitle(duration: String) -> String {
  246. trial.ctaTemplate.replacingOccurrences(of: "{duration}", with: duration)
  247. }
  248. func trialDurationDescription(from fallback: TrialFallbackDuration, lowercase: Bool = false) -> String? {
  249. guard let unit = subscriptionPeriodUnit(from: fallback.unit) else { return nil }
  250. return formatDuration(count: fallback.count, unit: unit, lowercase: lowercase)
  251. }
  252. var trialEligiblePlan: PaywallPlan? {
  253. PaywallPlan(rawValue: trial.eligiblePlan)
  254. }
  255. private func subscriptionPeriodUnit(from value: String) -> Product.SubscriptionPeriod.Unit? {
  256. switch value {
  257. case "day": .day
  258. case "week": .week
  259. case "month": .month
  260. case "year": .year
  261. default: nil
  262. }
  263. }
  264. var allProductIDs: Set<String> {
  265. Set(PaywallPlan.displayOrder.map(\.productID))
  266. }
  267. func plan(for productID: String) -> PaywallPlan? {
  268. PaywallPlan.displayOrder.first { $0.productID == productID }
  269. }
  270. }
  271. extension PaywallConfig.Plans {
  272. func mergingMarketing(from remote: PaywallConfig.Plans) -> PaywallConfig.Plans {
  273. PaywallConfig.Plans(
  274. monthly: monthly.mergingMarketing(from: remote.monthly),
  275. yearly: yearly.mergingMarketing(from: remote.yearly),
  276. lifetime: lifetime.mergingMarketing(from: remote.lifetime)
  277. )
  278. }
  279. }
  280. extension PaywallConfig.PlanCopy {
  281. func mergingMarketing(from remote: PaywallConfig.PlanCopy) -> PaywallConfig.PlanCopy {
  282. PaywallConfig.PlanCopy(
  283. productID: productID,
  284. title: remote.title,
  285. badge: remote.badge,
  286. subtitleTemplate: remote.subtitleTemplate,
  287. trialSubtitleTemplate: remote.trialSubtitleTemplate,
  288. ctaTemplate: remote.ctaTemplate,
  289. fallbackPrice: remote.fallbackPrice,
  290. priceSuffix: remote.priceSuffix,
  291. fallbackBillingDescription: remote.fallbackBillingDescription,
  292. features: remote.features
  293. )
  294. }
  295. }
  296. extension Notification.Name {
  297. static let paywallConfigDidUpdate = Notification.Name("paywallConfigDidUpdate")
  298. }