PaywallConfig.swift 9.8 KB

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