PaywallModels.swift 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  1. import Foundation
  2. import StoreKit
  3. struct PaywallPlan: Hashable, Sendable {
  4. let productID: String
  5. init(productID: String) {
  6. self.productID = productID
  7. }
  8. static var displayOrder: [PaywallPlan] {
  9. StoreKitCatalog.orderedPlans
  10. }
  11. var kind: PaywallPlanKind? {
  12. StoreKitCatalog.shared.kind(for: productID)
  13. }
  14. var isLifetime: Bool {
  15. kind == .lifetime
  16. }
  17. var isSubscription: Bool {
  18. kind != .lifetime && kind != nil
  19. }
  20. static func fromStoredValue(_ value: String) -> PaywallPlan? {
  21. StoreKitCatalog.shared.plan(matching: value)
  22. }
  23. func planCopy(from config: PaywallConfig, product: Product? = nil) -> PaywallConfig.PlanCopy {
  24. if let copy = config.planCopy(for: productID) {
  25. return copy
  26. }
  27. if let product {
  28. return config.synthesizedPlanCopy(for: product)
  29. }
  30. return config.placeholderPlanCopy(for: productID)
  31. }
  32. func localizedPrice(from product: Product?, config: PaywallConfig) -> String {
  33. product?.displayPrice ?? config.loadingPrice
  34. }
  35. func localizedSubtitle(
  36. from product: Product?,
  37. config: PaywallConfig,
  38. trial: PaywallTrialDisplay? = nil
  39. ) -> String {
  40. guard let product else { return config.loadingPrice }
  41. let copy = planCopy(from: config, product: product)
  42. let price = product.displayPrice
  43. if let trial, let trialTemplate = copy.trialSubtitleTemplate {
  44. return trialTemplate
  45. .replacingOccurrences(of: "{trial}", with: trial.durationLowercased)
  46. .replacingOccurrences(of: "{price}", with: price)
  47. }
  48. return copy.subtitleTemplate.replacingOccurrences(of: "{price}", with: price)
  49. }
  50. func localizedCTATitle(
  51. from product: Product?,
  52. config: PaywallConfig,
  53. trial: PaywallTrialDisplay? = nil
  54. ) -> String {
  55. guard let product else { return config.loadingCTA }
  56. if let trial {
  57. return trial.ctaText
  58. }
  59. let copy = planCopy(from: config, product: product)
  60. return copy.ctaTemplate.replacingOccurrences(of: "{price}", with: product.displayPrice)
  61. }
  62. func localizedRenewalDisclosure(
  63. from product: Product?,
  64. config: PaywallConfig,
  65. trial: PaywallTrialDisplay? = nil
  66. ) -> String {
  67. if isLifetime {
  68. return config.lifetimeDisclosure
  69. }
  70. guard let product else { return config.loadingDisclosure }
  71. let price = product.displayPrice
  72. let period = product.subscription.map {
  73. config.subscriptionPeriodDescription(from: $0.subscriptionPeriod)
  74. } ?? config.messages.periodFallback
  75. if let trial {
  76. return config.trialDisclosureTemplate
  77. .replacingOccurrences(of: "{trial}", with: trial.durationLowercased)
  78. .replacingOccurrences(of: "{price}", with: price)
  79. .replacingOccurrences(of: "{period}", with: period)
  80. }
  81. return config.subscriptionDisclosureTemplate
  82. .replacingOccurrences(of: "{price}", with: price)
  83. .replacingOccurrences(of: "{period}", with: period)
  84. }
  85. }
  86. struct PaywallTrialDisplay: Equatable, Sendable {
  87. let badgeText: String
  88. let durationLowercased: String
  89. let ctaText: String
  90. static func from(offer: Product.SubscriptionOffer, config: PaywallConfig) -> PaywallTrialDisplay {
  91. let durationLowercased = config.trialDurationDescription(from: offer, lowercase: true)
  92. return PaywallTrialDisplay(
  93. badgeText: config.trialBadgeText(from: offer),
  94. durationLowercased: durationLowercased,
  95. ctaText: config.trialCTATitle(from: offer)
  96. )
  97. }
  98. static func from(fallback: PaywallConfig.TrialFallbackDuration, config: PaywallConfig) -> PaywallTrialDisplay? {
  99. guard let duration = config.trialDurationDescription(from: fallback),
  100. let durationLowercased = config.trialDurationDescription(from: fallback, lowercase: true) else {
  101. return nil
  102. }
  103. return PaywallTrialDisplay(
  104. badgeText: config.trialBadgeText(duration: duration),
  105. durationLowercased: durationLowercased,
  106. ctaText: config.trialCTATitle(duration: duration)
  107. )
  108. }
  109. }
  110. enum PurchaseError: Equatable {
  111. case productsLoadTimeout
  112. case subscriptionUnavailable
  113. case planUnavailable
  114. case purchaseNotVerified
  115. case purchasePending
  116. case noActiveSubscriptions
  117. case noActivePurchases
  118. case restoreSyncTimeout
  119. case generic
  120. }