PaywallView.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. import SwiftUI
  2. struct PaywallView: View {
  3. @Bindable var viewModel: PaywallViewModel
  4. var onDismiss: () -> Void
  5. var body: some View {
  6. GeometryReader { geometry in
  7. let metrics = PaywallLayoutMetrics(size: geometry.size)
  8. ZStack(alignment: .topTrailing) {
  9. AppTheme.background
  10. .ignoresSafeArea()
  11. VStack(spacing: metrics.sectionSpacing) {
  12. header(metrics: metrics)
  13. featuresSection(metrics: metrics)
  14. pricingCards(metrics: metrics)
  15. ctaSection(metrics: metrics)
  16. footerLinks(metrics: metrics)
  17. }
  18. .padding(.horizontal, metrics.horizontalInset)
  19. .padding(.top, metrics.topInset)
  20. .padding(.bottom, metrics.bottomInset)
  21. .frame(width: geometry.size.width, height: geometry.size.height, alignment: .top)
  22. closeButton
  23. .padding(metrics.closeButtonPadding)
  24. }
  25. }
  26. .frame(maxWidth: .infinity, maxHeight: .infinity)
  27. .ignoresSafeArea()
  28. }
  29. private var closeButton: some View {
  30. Button(action: onDismiss) {
  31. Image(systemName: "xmark")
  32. .font(.system(size: 12, weight: .semibold))
  33. .foregroundStyle(AppTheme.textSecondary)
  34. .frame(width: 32, height: 32)
  35. .background(AppTheme.cardBackground)
  36. .clipShape(Circle())
  37. .overlay(
  38. Circle()
  39. .stroke(AppTheme.cardBorder, lineWidth: 1)
  40. )
  41. }
  42. .buttonStyle(.plain)
  43. }
  44. private func header(metrics: PaywallLayoutMetrics) -> some View {
  45. VStack(spacing: metrics.headerSpacing) {
  46. Text(PaywallContent.title)
  47. .font(.system(size: metrics.titleFontSize, weight: .bold))
  48. .foregroundStyle(AppTheme.textPrimary)
  49. Text(PaywallContent.subtitle)
  50. .font(.system(size: metrics.subtitleFontSize))
  51. .foregroundStyle(AppTheme.textSecondary)
  52. .multilineTextAlignment(.center)
  53. .frame(maxWidth: metrics.subtitleMaxWidth)
  54. }
  55. .frame(maxWidth: .infinity)
  56. }
  57. private func featuresSection(metrics: PaywallLayoutMetrics) -> some View {
  58. VStack(alignment: .leading, spacing: metrics.featureSectionSpacing) {
  59. Text(PaywallContent.includedSectionTitle)
  60. .font(.system(size: metrics.sectionTitleFontSize, weight: .semibold))
  61. .foregroundStyle(AppTheme.textPrimary)
  62. LazyVGrid(columns: metrics.featureColumns, alignment: .leading, spacing: metrics.featureRowSpacing) {
  63. ForEach(PaywallContent.features) { feature in
  64. PaywallFeatureRow(feature: feature, metrics: metrics)
  65. }
  66. }
  67. }
  68. .frame(maxWidth: .infinity, alignment: .leading)
  69. }
  70. private func pricingCards(metrics: PaywallLayoutMetrics) -> some View {
  71. HStack(alignment: .top, spacing: metrics.cardSpacing) {
  72. ForEach(PaywallPlan.allCases) { plan in
  73. PaywallPricingCard(
  74. plan: plan,
  75. isSelected: viewModel.selectedPlan == plan,
  76. metrics: metrics
  77. ) {
  78. viewModel.selectPlan(plan)
  79. }
  80. }
  81. }
  82. .frame(maxWidth: .infinity)
  83. }
  84. private func ctaSection(metrics: PaywallLayoutMetrics) -> some View {
  85. VStack(spacing: metrics.ctaSpacing) {
  86. Button {
  87. viewModel.purchase()
  88. } label: {
  89. Text("Get Premium")
  90. .font(.system(size: metrics.ctaFontSize, weight: .bold))
  91. .foregroundStyle(Color(hex: 0x0B0E14))
  92. .frame(maxWidth: .infinity)
  93. .padding(.vertical, metrics.ctaVerticalPadding)
  94. .background(
  95. LinearGradient(
  96. colors: [AppTheme.accentPurpleLight, AppTheme.accentTeal],
  97. startPoint: .leading,
  98. endPoint: .trailing
  99. )
  100. )
  101. .clipShape(RoundedRectangle(cornerRadius: metrics.cornerRadius))
  102. }
  103. .buttonStyle(.plain)
  104. HStack(spacing: 6) {
  105. Image(systemName: "lock.fill")
  106. .font(.system(size: metrics.footerNoteFontSize))
  107. Text("Secure payment. Cancel anytime.")
  108. .font(.system(size: metrics.footerNoteFontSize))
  109. }
  110. .foregroundStyle(AppTheme.textTertiary)
  111. }
  112. }
  113. private func footerLinks(metrics: PaywallLayoutMetrics) -> some View {
  114. HStack {
  115. Button("Continue with free plan") {
  116. onDismiss()
  117. }
  118. .buttonStyle(.plain)
  119. .font(.system(size: metrics.footerLinkFontSize))
  120. .foregroundStyle(AppTheme.textSecondary)
  121. Spacer()
  122. HStack(spacing: metrics.footerLinkSpacing) {
  123. footerLink("Restore Purchases", metrics: metrics) {
  124. viewModel.restorePurchases()
  125. }
  126. footerLink("Privacy Policy", metrics: metrics) {}
  127. footerLink("Support", metrics: metrics) {}
  128. footerLink("Terms of Services", metrics: metrics) {}
  129. }
  130. }
  131. }
  132. private func footerLink(_ title: String, metrics: PaywallLayoutMetrics, action: @escaping () -> Void) -> some View {
  133. Button(title, action: action)
  134. .buttonStyle(.plain)
  135. .font(.system(size: metrics.footerLinkFontSize))
  136. .foregroundStyle(AppTheme.textTertiary)
  137. }
  138. }
  139. // MARK: - Layout Metrics
  140. private struct PaywallLayoutMetrics {
  141. let size: CGSize
  142. var horizontalInset: CGFloat { size.width * 0.06 }
  143. var topInset: CGFloat { size.height * 0.07 }
  144. var bottomInset: CGFloat { size.height * 0.05 }
  145. var closeButtonPadding: CGFloat { max(16, size.width * 0.02) }
  146. var sectionSpacing: CGFloat { size.height * 0.04 }
  147. var headerSpacing: CGFloat { size.height * 0.012 }
  148. var featureSectionSpacing: CGFloat { size.height * 0.018 }
  149. var featureRowSpacing: CGFloat { size.height * 0.016 }
  150. var cardSpacing: CGFloat { size.width * 0.018 }
  151. var ctaSpacing: CGFloat { size.height * 0.014 }
  152. var footerLinkSpacing: CGFloat { size.width * 0.014 }
  153. var titleFontSize: CGFloat { clamp(size.height * 0.048, min: 24, max: 40) }
  154. var subtitleFontSize: CGFloat { clamp(size.height * 0.022, min: 13, max: 18) }
  155. var sectionTitleFontSize: CGFloat { clamp(size.height * 0.02, min: 12, max: 16) }
  156. var featureFontSize: CGFloat { clamp(size.height * 0.019, min: 11, max: 15) }
  157. var ctaFontSize: CGFloat { clamp(size.height * 0.024, min: 14, max: 18) }
  158. var footerNoteFontSize: CGFloat { clamp(size.height * 0.017, min: 10, max: 13) }
  159. var footerLinkFontSize: CGFloat { clamp(size.height * 0.017, min: 10, max: 13) }
  160. var subtitleMaxWidth: CGFloat { size.width * 0.55 }
  161. var cardMinHeight: CGFloat { size.height * 0.32 }
  162. var cardPadding: CGFloat { size.width * 0.014 }
  163. var cornerRadius: CGFloat { clamp(size.height * 0.014, min: 8, max: 12) }
  164. var ctaVerticalPadding: CGFloat { size.height * 0.022 }
  165. var featureColumns: [GridItem] {
  166. [
  167. GridItem(.flexible(), spacing: featureRowSpacing),
  168. GridItem(.flexible(), spacing: featureRowSpacing),
  169. GridItem(.flexible(), spacing: featureRowSpacing),
  170. ]
  171. }
  172. private func clamp(_ value: CGFloat, min: CGFloat, max: CGFloat) -> CGFloat {
  173. Swift.min(Swift.max(value, min), max)
  174. }
  175. }
  176. // MARK: - Components
  177. private struct PaywallFeatureRow: View {
  178. let feature: PaywallFeature
  179. let metrics: PaywallLayoutMetrics
  180. var body: some View {
  181. HStack(spacing: 8) {
  182. Image(systemName: feature.systemImage)
  183. .font(.system(size: metrics.featureFontSize * 0.9, weight: .semibold))
  184. .foregroundStyle(AppTheme.accentTeal)
  185. .frame(width: metrics.featureFontSize + 4)
  186. Text(feature.title)
  187. .font(.system(size: metrics.featureFontSize))
  188. .foregroundStyle(AppTheme.textSecondary)
  189. .lineLimit(1)
  190. .minimumScaleFactor(0.85)
  191. }
  192. }
  193. }
  194. private struct PaywallPricingCard: View {
  195. let plan: PaywallPlan
  196. let isSelected: Bool
  197. let metrics: PaywallLayoutMetrics
  198. let onSelect: () -> Void
  199. var body: some View {
  200. Button(action: onSelect) {
  201. VStack(alignment: .leading, spacing: 0) {
  202. if plan.isBestValue {
  203. Text("BEST VALUE")
  204. .font(.system(size: metrics.featureFontSize * 0.75, weight: .bold))
  205. .foregroundStyle(Color(hex: 0x0B0E14))
  206. .padding(.horizontal, 10)
  207. .padding(.vertical, 4)
  208. .background(AppTheme.accentTeal)
  209. .clipShape(Capsule())
  210. .frame(maxWidth: .infinity)
  211. .padding(.bottom, metrics.cardPadding)
  212. } else {
  213. Color.clear
  214. .frame(height: metrics.cardPadding * 1.6)
  215. .padding(.bottom, metrics.cardPadding * 0.4)
  216. }
  217. Text(plan.title)
  218. .font(.system(size: metrics.ctaFontSize, weight: .semibold))
  219. .foregroundStyle(AppTheme.textPrimary)
  220. Text(plan.subtitle)
  221. .font(.system(size: metrics.featureFontSize * 0.85))
  222. .foregroundStyle(AppTheme.textTertiary)
  223. .fixedSize(horizontal: false, vertical: true)
  224. .padding(.top, 2)
  225. HStack(alignment: .firstTextBaseline, spacing: 2) {
  226. Text(plan.price)
  227. .font(.system(size: metrics.titleFontSize * 0.85, weight: .bold))
  228. .foregroundStyle(AppTheme.textPrimary)
  229. if let suffix = plan.priceSuffix {
  230. Text(suffix)
  231. .font(.system(size: metrics.featureFontSize))
  232. .foregroundStyle(AppTheme.textSecondary)
  233. }
  234. }
  235. .padding(.top, metrics.cardPadding)
  236. .padding(.bottom, metrics.cardPadding * 0.9)
  237. VStack(alignment: .leading, spacing: metrics.featureRowSpacing * 0.7) {
  238. ForEach(plan.features, id: \.self) { feature in
  239. HStack(alignment: .top, spacing: 8) {
  240. Image(systemName: "checkmark")
  241. .font(.system(size: metrics.featureFontSize * 0.75, weight: .bold))
  242. .foregroundStyle(AppTheme.accentGreen)
  243. .padding(.top, 2)
  244. Text(feature)
  245. .font(.system(size: metrics.featureFontSize * 0.92))
  246. .foregroundStyle(AppTheme.textSecondary)
  247. .fixedSize(horizontal: false, vertical: true)
  248. .multilineTextAlignment(.leading)
  249. }
  250. }
  251. }
  252. Spacer(minLength: 0)
  253. }
  254. .padding(metrics.cardPadding)
  255. .frame(maxWidth: .infinity, minHeight: metrics.cardMinHeight, alignment: .topLeading)
  256. .background(
  257. RoundedRectangle(cornerRadius: metrics.cornerRadius)
  258. .fill(AppTheme.cardBackground)
  259. )
  260. .overlay(
  261. RoundedRectangle(cornerRadius: metrics.cornerRadius)
  262. .stroke(
  263. isSelected ? AppTheme.accentTeal : AppTheme.cardBorder,
  264. lineWidth: isSelected ? 2 : 1
  265. )
  266. )
  267. }
  268. .buttonStyle(.plain)
  269. }
  270. }
  271. #Preview {
  272. PaywallView(viewModel: PaywallViewModel(), onDismiss: {})
  273. .frame(width: AppWindow.width, height: AppWindow.height)
  274. }