PaywallConfig.swift 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  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 Trial: Codable, Sendable {
  78. let badgeTemplate: String
  79. let ctaTemplate: String
  80. }
  81. struct Messages: Codable, Sendable {
  82. let productNotFound: String
  83. let failedVerification: String
  84. let noPlansAvailable: String
  85. let plansLoadFailed: String
  86. let purchaseFailedTitle: String
  87. let restoreNotFoundTitle: String
  88. let restoreNotFoundMessage: String
  89. let pendingPurchaseTitle: String
  90. let pendingPurchaseMessage: String
  91. let alertOK: String
  92. let retry: String
  93. let retrying: String
  94. let periodFallback: String
  95. let productsLoadTimeout: String
  96. let subscriptionUnavailable: String
  97. let planUnavailable: String
  98. let noActiveSubscriptions: String
  99. let restoreSyncTimeout: String
  100. let purchaseGeneric: String
  101. }
  102. struct Sidebar: Codable, Sendable {
  103. let unlockTitle: String
  104. let proTitle: String
  105. let unlockDescription: String
  106. let proDescription: String
  107. let upgradeAction: String
  108. let manageSubscriptionAction: String
  109. let lifetimeAction: String
  110. }
  111. let leftPanelTitle: String
  112. let rightTitle: String
  113. let rightSubtitle: String
  114. let features: [String]
  115. let trustItems: [TrustItem]
  116. let lifetimeTrustItem: TrustItem
  117. let plans: Plans
  118. let footer: Footer
  119. let urls: URLs
  120. let trial: Trial
  121. let duration: Duration
  122. let messages: Messages
  123. let sidebar: Sidebar
  124. let loadingPrice: String
  125. let loadingCTA: String
  126. let subscriptionDisclosureTemplate: String
  127. let trialDisclosureTemplate: String
  128. let lifetimeDisclosure: String
  129. let loadingDisclosure: String
  130. let securePaymentNote: String
  131. let closeButtonHelp: String
  132. }
  133. extension PaywallConfig {
  134. func mergingMarketing(from remote: PaywallConfig) -> PaywallConfig {
  135. PaywallConfig(
  136. leftPanelTitle: remote.leftPanelTitle,
  137. rightTitle: remote.rightTitle,
  138. rightSubtitle: remote.rightSubtitle,
  139. features: remote.features,
  140. trustItems: remote.trustItems,
  141. lifetimeTrustItem: remote.lifetimeTrustItem,
  142. plans: remote.plans,
  143. footer: footer,
  144. urls: urls,
  145. trial: trial,
  146. duration: duration,
  147. messages: messages,
  148. sidebar: remote.sidebar,
  149. loadingPrice: loadingPrice,
  150. loadingCTA: loadingCTA,
  151. subscriptionDisclosureTemplate: subscriptionDisclosureTemplate,
  152. trialDisclosureTemplate: trialDisclosureTemplate,
  153. lifetimeDisclosure: lifetimeDisclosure,
  154. loadingDisclosure: loadingDisclosure,
  155. securePaymentNote: securePaymentNote,
  156. closeButtonHelp: closeButtonHelp
  157. )
  158. }
  159. func message(for error: PurchaseError) -> String {
  160. switch error {
  161. case .productsLoadTimeout:
  162. messages.productsLoadTimeout
  163. case .subscriptionUnavailable:
  164. messages.subscriptionUnavailable
  165. case .planUnavailable:
  166. messages.planUnavailable
  167. case .purchaseNotVerified:
  168. messages.failedVerification
  169. case .purchasePending:
  170. messages.pendingPurchaseMessage
  171. case .noActiveSubscriptions:
  172. messages.noActiveSubscriptions
  173. case .noActivePurchases:
  174. messages.restoreNotFoundMessage
  175. case .restoreSyncTimeout:
  176. messages.restoreSyncTimeout
  177. case .generic:
  178. messages.purchaseGeneric
  179. }
  180. }
  181. static func loadBundled() -> PaywallConfig {
  182. guard let url = Bundle.main.url(forResource: "paywall", withExtension: "json"),
  183. let data = try? Data(contentsOf: url),
  184. let config = try? JSONDecoder().decode(PaywallConfig.self, from: data) else {
  185. fatalError("Missing or invalid paywall.json in app bundle.")
  186. }
  187. return config
  188. }
  189. func formatDuration(count: Int, unit: Product.SubscriptionPeriod.Unit, lowercase: Bool) -> String {
  190. guard count > 0 else {
  191. return duration.unknownFallback.replacingOccurrences(of: "{count}", with: "0")
  192. }
  193. let durationUnit: DurationUnit
  194. switch unit {
  195. case .day: durationUnit = duration.units.day
  196. case .week: durationUnit = duration.units.week
  197. case .month: durationUnit = duration.units.month
  198. case .year: durationUnit = duration.units.year
  199. @unknown default:
  200. return duration.unknownFallback.replacingOccurrences(of: "{count}", with: "\(count)")
  201. }
  202. let unitWord: String
  203. if count == 1 {
  204. unitWord = lowercase ? durationUnit.lowerOne : durationUnit.one
  205. } else {
  206. unitWord = lowercase ? durationUnit.lowerOther : durationUnit.other
  207. }
  208. if count == 1, lowercase {
  209. return unitWord
  210. }
  211. return duration.countUnitTemplate
  212. .replacingOccurrences(of: "{count}", with: "\(count)")
  213. .replacingOccurrences(of: "{unit}", with: unitWord)
  214. }
  215. func trialDurationDescription(from offer: Product.SubscriptionOffer, lowercase: Bool = false) -> String {
  216. let count = offer.period.value * offer.periodCount
  217. return formatDuration(count: count, unit: offer.period.unit, lowercase: lowercase)
  218. }
  219. func subscriptionPeriodDescription(from period: Product.SubscriptionPeriod) -> String {
  220. formatDuration(count: period.value, unit: period.unit, lowercase: true)
  221. }
  222. func trialBadgeText(from offer: Product.SubscriptionOffer) -> String {
  223. let duration = trialDurationDescription(from: offer)
  224. return trial.badgeTemplate.replacingOccurrences(of: "{duration}", with: duration)
  225. }
  226. func trialCTATitle(from offer: Product.SubscriptionOffer) -> String {
  227. let duration = trialDurationDescription(from: offer)
  228. return trial.ctaTemplate.replacingOccurrences(of: "{duration}", with: duration)
  229. }
  230. }
  231. extension PaywallConfig: Equatable {
  232. static func == (lhs: PaywallConfig, rhs: PaywallConfig) -> Bool {
  233. guard let lhsData = try? JSONEncoder().encode(lhs),
  234. let rhsData = try? JSONEncoder().encode(rhs) else {
  235. return false
  236. }
  237. return lhsData == rhsData
  238. }
  239. }
  240. extension Notification.Name {
  241. static let paywallConfigDidUpdate = Notification.Name("paywallConfigDidUpdate")
  242. }