PaywallConfig.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  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. private let byKey: [String: PlanCopy]
  57. init(byKey: [String: PlanCopy]) {
  58. self.byKey = byKey
  59. }
  60. init(from decoder: Decoder) throws {
  61. byKey = try decoder.singleValueContainer().decode([String: PlanCopy].self)
  62. }
  63. func encode(to encoder: Encoder) throws {
  64. var container = encoder.singleValueContainer()
  65. try container.encode(byKey)
  66. }
  67. func planCopy(for key: String) -> PlanCopy? {
  68. byKey[key]
  69. }
  70. }
  71. struct DurationUnit: Codable, Sendable {
  72. let one: String
  73. let other: String
  74. let lowerOne: String
  75. let lowerOther: String
  76. }
  77. struct Duration: Codable, Sendable {
  78. let units: DurationUnits
  79. let countUnitTemplate: String
  80. let unknownFallback: String
  81. }
  82. struct DurationUnits: Codable, Sendable {
  83. let day: DurationUnit
  84. let week: DurationUnit
  85. let month: DurationUnit
  86. let year: DurationUnit
  87. }
  88. struct TrialFallbackDuration: Codable, Sendable {
  89. let count: Int
  90. let unit: String
  91. }
  92. struct Trial: Codable, Sendable {
  93. let eligiblePlan: String
  94. let badgeTemplate: String
  95. let ctaTemplate: String
  96. let fallbackDuration: TrialFallbackDuration?
  97. }
  98. struct Messages: Codable, Sendable {
  99. let productNotFound: String
  100. let failedVerification: String
  101. let noPlansAvailable: String
  102. let plansLoadFailed: String
  103. let purchaseFailedTitle: String
  104. let restoreNotFoundTitle: String
  105. let restoreNotFoundMessage: String
  106. let pendingPurchaseTitle: String
  107. let pendingPurchaseMessage: String
  108. let alertOK: String
  109. let retry: String
  110. let retrying: String
  111. let periodFallback: String
  112. let productsLoadTimeout: String
  113. let subscriptionUnavailable: String
  114. let planUnavailable: String
  115. let noActiveSubscriptions: String
  116. let restoreSyncTimeout: String
  117. let purchaseGeneric: String
  118. }
  119. struct Sidebar: Codable, Sendable {
  120. let unlockTitle: String
  121. let proTitle: String
  122. let unlockDescription: String
  123. let proDescription: String
  124. let upgradeAction: String
  125. let manageSubscriptionAction: String
  126. let lifetimeAction: String
  127. }
  128. let productIDs: [String]?
  129. let leftPanelTitle: String
  130. let rightTitle: String
  131. let rightSubtitle: String
  132. let features: [String]
  133. let trustItems: [TrustItem]
  134. let lifetimeTrustItem: TrustItem
  135. let plans: Plans
  136. let footer: Footer
  137. let urls: URLs
  138. let trial: Trial
  139. let duration: Duration
  140. let messages: Messages
  141. let sidebar: Sidebar
  142. let loadingPrice: String
  143. let loadingCTA: String
  144. let subscriptionDisclosureTemplate: String
  145. let trialDisclosureTemplate: String
  146. let lifetimeDisclosure: String
  147. let loadingDisclosure: String
  148. let securePaymentNote: String
  149. let closeButtonHelp: String
  150. }
  151. extension PaywallConfig {
  152. func mergingMarketing(from remote: PaywallConfig) -> PaywallConfig {
  153. PaywallConfig(
  154. productIDs: productIDs ?? remote.productIDs,
  155. leftPanelTitle: remote.leftPanelTitle,
  156. rightTitle: remote.rightTitle,
  157. rightSubtitle: remote.rightSubtitle,
  158. features: remote.features,
  159. trustItems: remote.trustItems,
  160. lifetimeTrustItem: remote.lifetimeTrustItem,
  161. plans: remote.plans,
  162. footer: footer,
  163. urls: urls,
  164. trial: trial,
  165. duration: duration,
  166. messages: messages,
  167. sidebar: remote.sidebar,
  168. loadingPrice: loadingPrice,
  169. loadingCTA: loadingCTA,
  170. subscriptionDisclosureTemplate: subscriptionDisclosureTemplate,
  171. trialDisclosureTemplate: trialDisclosureTemplate,
  172. lifetimeDisclosure: lifetimeDisclosure,
  173. loadingDisclosure: loadingDisclosure,
  174. securePaymentNote: securePaymentNote,
  175. closeButtonHelp: closeButtonHelp
  176. )
  177. }
  178. func message(for error: PurchaseError) -> String {
  179. switch error {
  180. case .productsLoadTimeout:
  181. messages.productsLoadTimeout
  182. case .subscriptionUnavailable:
  183. messages.subscriptionUnavailable
  184. case .planUnavailable:
  185. messages.planUnavailable
  186. case .purchaseNotVerified:
  187. messages.failedVerification
  188. case .purchasePending:
  189. messages.pendingPurchaseMessage
  190. case .noActiveSubscriptions:
  191. messages.noActiveSubscriptions
  192. case .noActivePurchases:
  193. messages.restoreNotFoundMessage
  194. case .restoreSyncTimeout:
  195. messages.restoreSyncTimeout
  196. case .generic:
  197. messages.purchaseGeneric
  198. }
  199. }
  200. static func loadBundled() -> PaywallConfig {
  201. guard let url = Bundle.main.url(forResource: "paywall", withExtension: "json"),
  202. let data = try? Data(contentsOf: url),
  203. let config = try? JSONDecoder().decode(PaywallConfig.self, from: data) else {
  204. fatalError("Missing or invalid paywall.json in app bundle.")
  205. }
  206. return config
  207. }
  208. func formatDuration(count: Int, unit: Product.SubscriptionPeriod.Unit, lowercase: Bool) -> String {
  209. guard count > 0 else {
  210. return duration.unknownFallback.replacingOccurrences(of: "{count}", with: "0")
  211. }
  212. let durationUnit: DurationUnit
  213. switch unit {
  214. case .day: durationUnit = duration.units.day
  215. case .week: durationUnit = duration.units.week
  216. case .month: durationUnit = duration.units.month
  217. case .year: durationUnit = duration.units.year
  218. @unknown default:
  219. return duration.unknownFallback.replacingOccurrences(of: "{count}", with: "\(count)")
  220. }
  221. let unitWord: String
  222. if count == 1 {
  223. unitWord = lowercase ? durationUnit.lowerOne : durationUnit.one
  224. } else {
  225. unitWord = lowercase ? durationUnit.lowerOther : durationUnit.other
  226. }
  227. if count == 1, lowercase {
  228. return unitWord
  229. }
  230. return duration.countUnitTemplate
  231. .replacingOccurrences(of: "{count}", with: "\(count)")
  232. .replacingOccurrences(of: "{unit}", with: unitWord)
  233. }
  234. func trialDurationDescription(from offer: Product.SubscriptionOffer, lowercase: Bool = false) -> String {
  235. let count = offer.period.value * offer.periodCount
  236. return formatDuration(count: count, unit: offer.period.unit, lowercase: lowercase)
  237. }
  238. func subscriptionPeriodDescription(from period: Product.SubscriptionPeriod) -> String {
  239. formatDuration(count: period.value, unit: period.unit, lowercase: true)
  240. }
  241. func trialBadgeText(from offer: Product.SubscriptionOffer) -> String {
  242. let duration = trialDurationDescription(from: offer)
  243. return trial.badgeTemplate.replacingOccurrences(of: "{duration}", with: duration)
  244. }
  245. func trialCTATitle(from offer: Product.SubscriptionOffer) -> String {
  246. let duration = trialDurationDescription(from: offer)
  247. return trial.ctaTemplate.replacingOccurrences(of: "{duration}", with: duration)
  248. }
  249. func trialBadgeText(duration: String) -> String {
  250. trial.badgeTemplate.replacingOccurrences(of: "{duration}", with: duration)
  251. }
  252. func trialCTATitle(duration: String) -> String {
  253. trial.ctaTemplate.replacingOccurrences(of: "{duration}", with: duration)
  254. }
  255. func trialDurationDescription(from fallback: TrialFallbackDuration, lowercase: Bool = false) -> String? {
  256. guard let unit = subscriptionPeriodUnit(from: fallback.unit) else { return nil }
  257. return formatDuration(count: fallback.count, unit: unit, lowercase: lowercase)
  258. }
  259. var trialEligiblePlan: PaywallPlan? {
  260. PaywallPlan.from(rawValue: trial.eligiblePlan)
  261. }
  262. var fallbackPlanCopy: PlanCopy {
  263. PlanCopy(
  264. title: "Premium",
  265. subtitleTemplate: "{price}",
  266. ctaTemplate: "Get Premium for {price}",
  267. fallbackPrice: loadingPrice,
  268. fallbackBillingDescription: "Billed at {price}",
  269. features: Array(features.prefix(3))
  270. )
  271. }
  272. func generatedPlanCopy(for product: Product) -> PlanCopy {
  273. let title = product.displayName
  274. let cardFeatures = Array(features.prefix(3))
  275. if product.type == .nonConsumable {
  276. return PlanCopy(
  277. title: title,
  278. badge: plans.planCopy(for: "lifetime")?.badge,
  279. subtitleTemplate: "{price} once, lifetime access",
  280. ctaTemplate: "Unlock Lifetime for {price}",
  281. fallbackPrice: product.displayPrice,
  282. fallbackBillingDescription: "One-time payment of {price}",
  283. features: cardFeatures
  284. )
  285. }
  286. if let period = product.subscription?.subscriptionPeriod {
  287. let periodLabel = subscriptionPeriodDescription(from: period)
  288. let suffix = shortPeriodSuffix(from: period)
  289. return PlanCopy(
  290. title: title,
  291. subtitleTemplate: "{price} / \(periodLabel), cancel anytime",
  292. ctaTemplate: "Get Premium for {price}",
  293. fallbackPrice: product.displayPrice,
  294. priceSuffix: suffix,
  295. fallbackBillingDescription: "Billed at {price} every \(periodLabel)",
  296. features: cardFeatures
  297. )
  298. }
  299. return PlanCopy(
  300. title: title,
  301. subtitleTemplate: "{price}",
  302. ctaTemplate: "Get Premium for {price}",
  303. fallbackPrice: product.displayPrice,
  304. fallbackBillingDescription: "Billed at {price}",
  305. features: cardFeatures
  306. )
  307. }
  308. private func shortPeriodSuffix(from period: Product.SubscriptionPeriod) -> String {
  309. switch period.unit {
  310. case .day: "/day"
  311. case .week: "/wk"
  312. case .month: "/mo"
  313. case .year: "/yr"
  314. @unknown default: ""
  315. }
  316. }
  317. private func subscriptionPeriodUnit(from value: String) -> Product.SubscriptionPeriod.Unit? {
  318. switch value {
  319. case "day": .day
  320. case "week": .week
  321. case "month": .month
  322. case "year": .year
  323. default: nil
  324. }
  325. }
  326. }
  327. extension PaywallConfig: Equatable {
  328. static func == (lhs: PaywallConfig, rhs: PaywallConfig) -> Bool {
  329. guard let lhsData = try? JSONEncoder().encode(lhs),
  330. let rhsData = try? JSONEncoder().encode(rhs) else {
  331. return false
  332. }
  333. return lhsData == rhsData
  334. }
  335. }
  336. extension Notification.Name {
  337. static let paywallConfigDidUpdate = Notification.Name("paywallConfigDidUpdate")
  338. }