PaywallConfig.swift 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  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. init(
  11. title: String,
  12. badge: String? = nil,
  13. subtitleTemplate: String,
  14. trialSubtitleTemplate: String? = nil,
  15. ctaTemplate: String
  16. ) {
  17. self.title = title
  18. self.badge = badge
  19. self.subtitleTemplate = subtitleTemplate
  20. self.trialSubtitleTemplate = trialSubtitleTemplate
  21. self.ctaTemplate = ctaTemplate
  22. }
  23. }
  24. struct TrustItem: Codable, Sendable {
  25. let icon: String
  26. let title: String
  27. let subtitle: String
  28. }
  29. struct Footer: Codable, Sendable {
  30. let continueFree: String
  31. let manageSubscription: String
  32. let restorePurchase: String
  33. let privacyPolicy: String
  34. let termsOfService: String
  35. let support: String
  36. }
  37. struct URLs: Codable, Sendable {
  38. let privacy: String
  39. let terms: String
  40. let support: String
  41. let manageSubscriptions: String
  42. }
  43. struct Plans: Codable, Sendable {
  44. let monthly: PlanCopy
  45. let yearly: PlanCopy
  46. let lifetime: PlanCopy
  47. }
  48. struct DurationUnit: Codable, Sendable {
  49. let one: String
  50. let other: String
  51. let lowerOne: String
  52. let lowerOther: String
  53. }
  54. struct Duration: Codable, Sendable {
  55. let units: DurationUnits
  56. let countUnitTemplate: String
  57. let unknownFallback: String
  58. }
  59. struct DurationUnits: Codable, Sendable {
  60. let day: DurationUnit
  61. let week: DurationUnit
  62. let month: DurationUnit
  63. let year: DurationUnit
  64. }
  65. struct Trial: Codable, Sendable {
  66. let badgeTemplate: String
  67. let ctaTemplate: String
  68. }
  69. struct Messages: Codable, Sendable {
  70. let productNotFound: String
  71. let failedVerification: String
  72. let noPlansAvailable: String
  73. let plansLoadFailed: String
  74. let purchaseFailedTitle: String
  75. let restoreNotFoundTitle: String
  76. let restoreNotFoundMessage: String
  77. let pendingPurchaseTitle: String
  78. let pendingPurchaseMessage: String
  79. let alertOK: String
  80. let retry: String
  81. let retrying: String
  82. let periodFallback: String
  83. }
  84. struct Sidebar: Codable, Sendable {
  85. let unlockTitle: String
  86. let proTitle: String
  87. let unlockDescription: String
  88. let proDescription: String
  89. let upgradeAction: String
  90. let manageSubscriptionAction: String
  91. let lifetimeAction: String
  92. }
  93. let leftPanelTitle: String
  94. let rightTitle: String
  95. let rightSubtitle: String
  96. let features: [String]
  97. let trustItems: [TrustItem]
  98. let lifetimeTrustItem: TrustItem
  99. let plans: Plans
  100. let footer: Footer
  101. let urls: URLs
  102. let trial: Trial
  103. let duration: Duration
  104. let messages: Messages
  105. let sidebar: Sidebar
  106. let loadingPrice: String
  107. let loadingCTA: String
  108. let subscriptionDisclosureTemplate: String
  109. let trialDisclosureTemplate: String
  110. let lifetimeDisclosure: String
  111. let loadingDisclosure: String
  112. }
  113. extension PaywallConfig {
  114. func mergingMarketing(from remote: PaywallConfig) -> PaywallConfig {
  115. PaywallConfig(
  116. leftPanelTitle: remote.leftPanelTitle,
  117. rightTitle: remote.rightTitle,
  118. rightSubtitle: remote.rightSubtitle,
  119. features: remote.features,
  120. trustItems: remote.trustItems,
  121. lifetimeTrustItem: remote.lifetimeTrustItem,
  122. plans: remote.plans,
  123. footer: footer,
  124. urls: urls,
  125. trial: trial,
  126. duration: duration,
  127. messages: messages,
  128. sidebar: remote.sidebar,
  129. loadingPrice: loadingPrice,
  130. loadingCTA: loadingCTA,
  131. subscriptionDisclosureTemplate: subscriptionDisclosureTemplate,
  132. trialDisclosureTemplate: trialDisclosureTemplate,
  133. lifetimeDisclosure: lifetimeDisclosure,
  134. loadingDisclosure: loadingDisclosure
  135. )
  136. }
  137. static func loadBundled() -> PaywallConfig {
  138. guard let url = Bundle.main.url(forResource: "paywall", withExtension: "json"),
  139. let data = try? Data(contentsOf: url),
  140. let config = try? JSONDecoder().decode(PaywallConfig.self, from: data) else {
  141. fatalError("Missing or invalid paywall.json in app bundle.")
  142. }
  143. return config
  144. }
  145. func formatDuration(count: Int, unit: Product.SubscriptionPeriod.Unit, lowercase: Bool) -> String {
  146. guard count > 0 else {
  147. return duration.unknownFallback.replacingOccurrences(of: "{count}", with: "0")
  148. }
  149. let durationUnit: DurationUnit
  150. switch unit {
  151. case .day: durationUnit = duration.units.day
  152. case .week: durationUnit = duration.units.week
  153. case .month: durationUnit = duration.units.month
  154. case .year: durationUnit = duration.units.year
  155. @unknown default:
  156. return duration.unknownFallback.replacingOccurrences(of: "{count}", with: "\(count)")
  157. }
  158. let unitWord: String
  159. if count == 1 {
  160. unitWord = lowercase ? durationUnit.lowerOne : durationUnit.one
  161. } else {
  162. unitWord = lowercase ? durationUnit.lowerOther : durationUnit.other
  163. }
  164. if count == 1, lowercase {
  165. return unitWord
  166. }
  167. return duration.countUnitTemplate
  168. .replacingOccurrences(of: "{count}", with: "\(count)")
  169. .replacingOccurrences(of: "{unit}", with: unitWord)
  170. }
  171. func trialDurationDescription(from offer: Product.SubscriptionOffer, lowercase: Bool = false) -> String {
  172. let count = offer.period.value * offer.periodCount
  173. return formatDuration(count: count, unit: offer.period.unit, lowercase: lowercase)
  174. }
  175. func subscriptionPeriodDescription(from period: Product.SubscriptionPeriod) -> String {
  176. formatDuration(count: period.value, unit: period.unit, lowercase: true)
  177. }
  178. func trialBadgeText(from offer: Product.SubscriptionOffer) -> String {
  179. let duration = trialDurationDescription(from: offer)
  180. return trial.badgeTemplate.replacingOccurrences(of: "{duration}", with: duration)
  181. }
  182. func trialCTATitle(from offer: Product.SubscriptionOffer) -> String {
  183. let duration = trialDurationDescription(from: offer)
  184. return trial.ctaTemplate.replacingOccurrences(of: "{duration}", with: duration)
  185. }
  186. }
  187. extension PaywallConfig: Equatable {
  188. static func == (lhs: PaywallConfig, rhs: PaywallConfig) -> Bool {
  189. guard let lhsData = try? JSONEncoder().encode(lhs),
  190. let rhsData = try? JSONEncoder().encode(rhs) else {
  191. return false
  192. }
  193. return lhsData == rhsData
  194. }
  195. }
  196. extension Notification.Name {
  197. static let paywallConfigDidUpdate = Notification.Name("paywallConfigDidUpdate")
  198. }