PaywallView.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. import SwiftUI
  2. struct PaywallView: View {
  3. let onClose: () -> Void
  4. @StateObject private var viewModel = PaywallViewModel()
  5. @State private var isCTAHovered = false
  6. var body: some View {
  7. GeometryReader { proxy in
  8. let horizontalInset = min(max(proxy.size.width * 0.03, 12), 34)
  9. let availableWidth = max(320, proxy.size.width - (horizontalInset * 2))
  10. let availableHeight = max(420, proxy.size.height - 24)
  11. let baseWidth: CGFloat = 860
  12. let baseHeight: CGFloat = 680
  13. let contentWidth = min(baseWidth, availableWidth)
  14. let widthScale = contentWidth / baseWidth
  15. let heightScale = availableHeight / baseHeight
  16. // Keep growth balanced to avoid oversized cards on tall/large windows.
  17. let layoutScale = min(max((widthScale * 0.65) + (heightScale * 0.35), 0.86), 1.0)
  18. let verticalSpacing = max(14, min(18, 18 * layoutScale))
  19. let topPadding: CGFloat = 10
  20. let featureHeight = max(74, min(92, 84 * layoutScale))
  21. let planHeight = max(160, min(174, 166 * layoutScale))
  22. VStack(spacing: 0) {
  23. VStack(spacing: verticalSpacing) {
  24. titleSection
  25. featuresSection(featureHeight: featureHeight)
  26. plansSection(planHeight: planHeight)
  27. ctaSection
  28. legalSection
  29. }
  30. .frame(width: contentWidth, alignment: .top)
  31. .padding(.top, topPadding)
  32. .padding(.bottom, topPadding)
  33. }
  34. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
  35. .background(Color.white.ignoresSafeArea())
  36. }
  37. }
  38. private var titleSection: some View {
  39. VStack(spacing: 4) {
  40. HStack(spacing: 10) {
  41. Text("Upgrade to")
  42. .font(.system(size: 38, weight: .heavy, design: .rounded))
  43. .foregroundStyle(Color(red: 0.11, green: 0.17, blue: 0.26))
  44. Text("PRO")
  45. .font(.system(size: 32, weight: .heavy, design: .rounded))
  46. .foregroundStyle(.white)
  47. .padding(.horizontal, 18)
  48. .padding(.vertical, 7)
  49. .background(Color(red: 0.03, green: 0.62, blue: 0.48))
  50. .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
  51. }
  52. Text("Upgrade to get all premium features.")
  53. .font(.system(size: 16, weight: .medium))
  54. .foregroundStyle(Color(red: 0.47, green: 0.51, blue: 0.57))
  55. }
  56. }
  57. private func featuresSection(featureHeight: CGFloat) -> some View {
  58. LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 14), count: 3), spacing: 14) {
  59. ForEach(viewModel.features) { feature in
  60. PaywallFeatureBox(feature: feature, minHeight: featureHeight)
  61. }
  62. }
  63. }
  64. private func plansSection(planHeight: CGFloat) -> some View {
  65. HStack(spacing: 14) {
  66. ForEach(PaywallPlan.allCases) { plan in
  67. PaywallPlanCardView(
  68. plan: plan,
  69. isSelected: viewModel.selectedPlan == plan,
  70. minHeight: planHeight,
  71. onSelect: { viewModel.selectPlan(plan) }
  72. )
  73. }
  74. }
  75. .frame(height: planHeight)
  76. }
  77. private var ctaSection: some View {
  78. VStack(spacing: 14) {
  79. Text(viewModel.selectedPlan.billingDescription)
  80. .font(.system(size: 13, weight: .medium))
  81. .foregroundStyle(Color(red: 0.47, green: 0.51, blue: 0.57))
  82. Button(action: {}) {
  83. HStack {
  84. Spacer()
  85. Text(viewModel.selectedPlan.ctaTitle)
  86. .font(.system(size: 20, weight: .heavy, design: .rounded))
  87. .foregroundStyle(.white)
  88. .lineLimit(1)
  89. .minimumScaleFactor(0.85)
  90. Spacer()
  91. }
  92. .padding(.vertical, 14)
  93. .background(isCTAHovered ? Color(red: 0.02, green: 0.56, blue: 0.43) : Color(red: 0.03, green: 0.62, blue: 0.48))
  94. .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
  95. .overlay(alignment: .trailing) {
  96. Image(systemName: "chevron.right.2")
  97. .font(.system(size: 18, weight: .heavy))
  98. .foregroundStyle(Color.white.opacity(0.75))
  99. .padding(.trailing, 24)
  100. }
  101. }
  102. .scaleEffect(isCTAHovered ? 1.01 : 1.0)
  103. .shadow(
  104. color: isCTAHovered ? AppTheme.primaryShadow.opacity(0.32) : .clear,
  105. radius: isCTAHovered ? 8 : 0,
  106. y: isCTAHovered ? 3 : 0
  107. )
  108. .animation(.easeOut(duration: 0.15), value: isCTAHovered)
  109. .buttonStyle(.plain)
  110. .onHover { isCTAHovered = $0 }
  111. Text("No commitment, cancel anytime.")
  112. .font(.system(size: 13, weight: .semibold))
  113. .foregroundStyle(Color(red: 0.58, green: 0.61, blue: 0.66))
  114. }
  115. }
  116. private var legalSection: some View {
  117. VStack(spacing: 12) {
  118. VStack(spacing: 2) {
  119. Text("Your subscription will automatically renew unless auto-renew is turned off at least 24-hours before the end of the current subscription period.")
  120. Text("Payment will be charged to your iTunes account at confirmation of purchase.")
  121. }
  122. .font(.system(size: 12, weight: .medium))
  123. .foregroundStyle(Color(red: 0.59, green: 0.62, blue: 0.68))
  124. .multilineTextAlignment(.center)
  125. HStack(spacing: 0) {
  126. PaywallFooterLinkView(title: "Continue with free plan", action: onClose)
  127. PaywallFooterLinkView(title: "Privacy Policy")
  128. PaywallFooterLinkView(title: "Support")
  129. PaywallFooterLinkView(title: "Terms of Services")
  130. }
  131. .frame(height: 44)
  132. .frame(maxWidth: .infinity)
  133. .background(Color.white)
  134. .clipShape(RoundedRectangle(cornerRadius: 0))
  135. }
  136. }
  137. }
  138. private struct PaywallFeatureBox: View {
  139. let feature: PaywallFeature
  140. let minHeight: CGFloat
  141. @State private var isHovered = false
  142. var body: some View {
  143. HStack(alignment: .top, spacing: 10) {
  144. RoundedRectangle(cornerRadius: 10, style: .continuous)
  145. .fill(feature.iconBackground)
  146. .frame(width: 42, height: 42)
  147. .overlay(
  148. Image(systemName: feature.iconName)
  149. .font(.system(size: 18, weight: .semibold))
  150. .foregroundStyle(.white)
  151. )
  152. VStack(alignment: .leading, spacing: 2) {
  153. Text(feature.title)
  154. .font(.system(size: 14, weight: .bold))
  155. .foregroundStyle(AppTheme.textPrimary)
  156. .lineLimit(2)
  157. Text(feature.subtitle)
  158. .font(.system(size: 11, weight: .medium))
  159. .foregroundStyle(AppTheme.textSecondary)
  160. .lineLimit(2)
  161. }
  162. Spacer(minLength: 0)
  163. }
  164. .padding(.horizontal, 12)
  165. .padding(.vertical, 12)
  166. .frame(maxWidth: .infinity, minHeight: minHeight, alignment: .topLeading)
  167. .background(isHovered ? AppTheme.tealLight.opacity(0.55) : AppTheme.cardBackground)
  168. .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
  169. .overlay(
  170. RoundedRectangle(cornerRadius: 12, style: .continuous)
  171. .stroke(isHovered ? AppTheme.toolbarBorderHover : AppTheme.border, lineWidth: 1)
  172. )
  173. .shadow(color: AppTheme.cardShadow, radius: isHovered ? 6 : 3, y: isHovered ? 3 : 1)
  174. .scaleEffect(isHovered ? 1.01 : 1.0)
  175. .animation(.easeOut(duration: 0.15), value: isHovered)
  176. .onHover { isHovered = $0 }
  177. }
  178. }
  179. private struct PaywallPlanCardView: View {
  180. let plan: PaywallPlan
  181. let isSelected: Bool
  182. let minHeight: CGFloat
  183. let onSelect: () -> Void
  184. @State private var isHovered = false
  185. var body: some View {
  186. Button(action: onSelect) {
  187. VStack(alignment: .leading, spacing: 0) {
  188. HStack(alignment: .top) {
  189. Circle()
  190. .stroke(Color(red: 0.74, green: 0.76, blue: 0.80), lineWidth: 2)
  191. .frame(width: 18, height: 18)
  192. .overlay {
  193. if isSelected {
  194. Circle()
  195. .fill(Color(red: 0.03, green: 0.62, blue: 0.48))
  196. .frame(width: 9, height: 9)
  197. }
  198. }
  199. Spacer()
  200. if let tagText = plan.tagText {
  201. Text(tagText)
  202. .font(.system(size: 12, weight: .semibold))
  203. .foregroundStyle(tagText == "Pay Once" ? Color(red: 0.53, green: 0.39, blue: 0.90) : Color(red: 0.07, green: 0.60, blue: 0.46))
  204. .padding(.horizontal, 12)
  205. .padding(.vertical, 5)
  206. .background(tagText == "Pay Once" ? Color(red: 0.94, green: 0.91, blue: 0.99) : Color(red: 0.91, green: 0.98, blue: 0.95))
  207. .clipShape(Capsule())
  208. }
  209. }
  210. .padding(.horizontal, 14)
  211. .padding(.top, 12)
  212. Text(plan.title)
  213. .font(.system(size: 15, weight: .semibold))
  214. .foregroundStyle(Color(red: 0.16, green: 0.20, blue: 0.26))
  215. .padding(.horizontal, 14)
  216. .padding(.top, 14)
  217. Text(plan.mainPrice)
  218. .font(.system(size: 21, weight: .heavy, design: .rounded))
  219. .foregroundStyle(Color(red: 0.03, green: 0.62, blue: 0.48))
  220. .lineLimit(1)
  221. .minimumScaleFactor(0.75)
  222. .padding(.horizontal, 14)
  223. .padding(.top, 2)
  224. .padding(.bottom, 12)
  225. VStack(alignment: .leading, spacing: 0) {
  226. HStack {
  227. Text(plan.secondaryPrice)
  228. .font(.system(size: 14, weight: .semibold))
  229. .foregroundStyle(Color(red: 0.45, green: 0.48, blue: 0.54))
  230. .overlay(alignment: .center) {
  231. if plan == .weekly || plan == .lifetime {
  232. Rectangle()
  233. .fill(Color(red: 0.45, green: 0.48, blue: 0.54))
  234. .frame(height: 1)
  235. }
  236. }
  237. Spacer(minLength: 0)
  238. }
  239. .padding(.horizontal, 14)
  240. .padding(.vertical, 12)
  241. .frame(maxWidth: .infinity, alignment: .leading)
  242. }
  243. .background(Color(red: 0.95, green: 0.96, blue: 0.97))
  244. .clipShape(
  245. RoundedRectangle(cornerRadius: 0)
  246. )
  247. }
  248. .frame(maxWidth: .infinity)
  249. .frame(height: minHeight, alignment: .topLeading)
  250. .background((isSelected || isHovered) ? AppTheme.tealLight.opacity(isSelected ? 0.28 : 0.16) : AppTheme.cardBackground)
  251. .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
  252. .overlay(
  253. RoundedRectangle(cornerRadius: 16, style: .continuous)
  254. .stroke(
  255. (isSelected || isHovered) ? AppTheme.toolbarBorderHover : AppTheme.border,
  256. lineWidth: isSelected ? 2 : 1
  257. )
  258. )
  259. .shadow(
  260. color: (isSelected || isHovered) ? AppTheme.primaryShadow.opacity(isSelected ? 0.35 : 0.22) : .clear,
  261. radius: (isSelected || isHovered) ? (isSelected ? 6 : 4) : 0,
  262. y: (isSelected || isHovered) ? 2 : 0
  263. )
  264. .scaleEffect(isHovered && !isSelected ? 1.008 : 1.0)
  265. .animation(.easeOut(duration: 0.15), value: isHovered)
  266. }
  267. .buttonStyle(.plain)
  268. .onHover { isHovered = $0 }
  269. }
  270. }
  271. private struct PaywallFooterLinkView: View {
  272. let title: String
  273. let action: () -> Void
  274. @State private var isHovered = false
  275. init(title: String, action: @escaping () -> Void = {}) {
  276. self.title = title
  277. self.action = action
  278. }
  279. var body: some View {
  280. Button(action: action) {
  281. Text(title)
  282. .font(.system(size: 14, weight: .semibold))
  283. .foregroundStyle(isHovered ? Color(red: 0.47, green: 0.51, blue: 0.57) : Color(red: 0.59, green: 0.62, blue: 0.68))
  284. .lineLimit(1)
  285. .minimumScaleFactor(0.75)
  286. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
  287. .contentShape(Rectangle())
  288. }
  289. .frame(maxWidth: .infinity, maxHeight: .infinity)
  290. .background(isHovered ? AppTheme.cardBackground : Color.clear)
  291. .scaleEffect(isHovered ? 1.01 : 1.0)
  292. .animation(.easeOut(duration: 0.15), value: isHovered)
  293. .buttonStyle(.plain)
  294. .onHover { isHovered = $0 }
  295. }
  296. }