PaywallModels.swift 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. import Foundation
  2. import StoreKit
  3. struct PaywallTrialDisplay: Equatable, Sendable {
  4. let badgeText: String
  5. let durationLowercased: String
  6. let ctaText: String
  7. static func from(offer: Product.SubscriptionOffer, config: PaywallConfig) -> PaywallTrialDisplay {
  8. let duration = config.trialDurationDescription(from: offer)
  9. let durationLowercased = config.trialDurationDescription(from: offer, lowercase: true)
  10. return PaywallTrialDisplay(
  11. badgeText: config.trialBadgeText(from: offer),
  12. durationLowercased: durationLowercased,
  13. ctaText: config.trialCTATitle(from: offer)
  14. )
  15. }
  16. static func from(fallback: PaywallConfig.TrialFallbackDuration, config: PaywallConfig) -> PaywallTrialDisplay? {
  17. guard let duration = config.trialDurationDescription(from: fallback),
  18. let durationLowercased = config.trialDurationDescription(from: fallback, lowercase: true) else {
  19. return nil
  20. }
  21. return PaywallTrialDisplay(
  22. badgeText: config.trialBadgeText(duration: duration),
  23. durationLowercased: durationLowercased,
  24. ctaText: config.trialCTATitle(duration: duration)
  25. )
  26. }
  27. }
  28. struct PaywallPlan: Identifiable, Hashable, Sendable {
  29. let productID: String
  30. var id: String { productID }
  31. var planKey: String {
  32. SubscriptionProductID.planKey(for: productID)
  33. }
  34. init(productID: String) {
  35. self.productID = productID
  36. }
  37. static func from(rawValue: String) -> PaywallPlan? {
  38. if rawValue.contains(".") {
  39. return PaywallPlan(productID: rawValue)
  40. }
  41. switch rawValue {
  42. case "weekly": return PaywallPlan(productID: SubscriptionProductID.weekly)
  43. case "monthly": return PaywallPlan(productID: SubscriptionProductID.monthly)
  44. case "yearly": return PaywallPlan(productID: SubscriptionProductID.yearly)
  45. case "lifetime": return PaywallPlan(productID: SubscriptionProductID.lifetime)
  46. default: return nil
  47. }
  48. }
  49. func planCopy(from config: PaywallConfig, product: Product? = nil) -> PaywallConfig.PlanCopy {
  50. if let configured = config.plans.planCopy(for: planKey) {
  51. return configured
  52. }
  53. if let product {
  54. return config.generatedPlanCopy(for: product)
  55. }
  56. return config.fallbackPlanCopy
  57. }
  58. func localizedPrice(from product: Product?, config: PaywallConfig) -> String {
  59. product?.displayPrice ?? planCopy(from: config, product: product).fallbackPrice
  60. }
  61. func localizedSubtitle(
  62. from product: Product?,
  63. config: PaywallConfig,
  64. trial: PaywallTrialDisplay? = nil
  65. ) -> String {
  66. guard let product else { return "" }
  67. let copy = planCopy(from: config, product: product)
  68. if let trial, let template = copy.trialSubtitleTemplate {
  69. return template
  70. .replacingOccurrences(of: "{price}", with: product.displayPrice)
  71. .replacingOccurrences(of: "{trial}", with: trial.durationLowercased)
  72. }
  73. return copy.subtitleTemplate.replacingOccurrences(of: "{price}", with: product.displayPrice)
  74. }
  75. func localizedCTATitle(
  76. from product: Product?,
  77. config: PaywallConfig,
  78. trial: PaywallTrialDisplay? = nil
  79. ) -> String {
  80. guard let product else { return config.loadingCTA }
  81. if let trial {
  82. return trial.ctaText
  83. }
  84. let copy = planCopy(from: config, product: product)
  85. return copy.ctaTemplate.replacingOccurrences(of: "{price}", with: product.displayPrice)
  86. }
  87. func localizedRenewalDisclosure(
  88. from product: Product?,
  89. config: PaywallConfig,
  90. trial: PaywallTrialDisplay? = nil
  91. ) -> String {
  92. if product?.type == .nonConsumable {
  93. return config.lifetimeDisclosure
  94. }
  95. guard let product else { return config.loadingDisclosure }
  96. let price = product.displayPrice
  97. let period = product.subscription.map {
  98. config.subscriptionPeriodDescription(from: $0.subscriptionPeriod)
  99. } ?? config.messages.periodFallback
  100. if let trial {
  101. return config.trialDisclosureTemplate
  102. .replacingOccurrences(of: "{trial}", with: trial.durationLowercased)
  103. .replacingOccurrences(of: "{price}", with: price)
  104. .replacingOccurrences(of: "{period}", with: period)
  105. }
  106. return config.subscriptionDisclosureTemplate
  107. .replacingOccurrences(of: "{price}", with: price)
  108. .replacingOccurrences(of: "{period}", with: period)
  109. }
  110. func fallbackBillingDescription(config: PaywallConfig, product: Product? = nil) -> String {
  111. let copy = planCopy(from: config, product: product)
  112. return copy.fallbackBillingDescription
  113. .replacingOccurrences(of: "{price}", with: copy.fallbackPrice)
  114. }
  115. var isLifetime: Bool {
  116. productID == SubscriptionProductID.lifetime
  117. }
  118. }
  119. extension PaywallPlan {
  120. static func sortOrder(_ lhs: PaywallPlan, _ rhs: PaywallPlan) -> Bool {
  121. let order = [
  122. SubscriptionProductID.weekly,
  123. SubscriptionProductID.monthly,
  124. SubscriptionProductID.yearly,
  125. SubscriptionProductID.lifetime,
  126. ]
  127. let leftIndex = order.firstIndex(of: lhs.productID) ?? Int.max
  128. let rightIndex = order.firstIndex(of: rhs.productID) ?? Int.max
  129. if leftIndex != rightIndex {
  130. return leftIndex < rightIndex
  131. }
  132. return lhs.productID < rhs.productID
  133. }
  134. }