PaywallView.swift 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764
  1. import Cocoa
  2. // MARK: - Plan Model
  3. enum PaywallPlan: CaseIterable {
  4. case monthly
  5. case yearly
  6. case lifetime
  7. var title: String {
  8. switch self {
  9. case .monthly: "Monthly"
  10. case .yearly: "Yearly"
  11. case .lifetime: "Lifetime"
  12. }
  13. }
  14. var subtitle: String {
  15. switch self {
  16. case .monthly: "$4.99 / month, cancel anytime"
  17. case .yearly: "Eligible new subscribers get 7 days free, then $29.99 / year"
  18. case .lifetime: "$99.99 once, lifetime access"
  19. }
  20. }
  21. var price: String {
  22. switch self {
  23. case .monthly: "$4.99"
  24. case .yearly: "$29.99"
  25. case .lifetime: "$99.99"
  26. }
  27. }
  28. var ctaTitle: String {
  29. switch self {
  30. case .monthly: "Subscribe for $4.99 / Month"
  31. case .yearly: "Start 7-Day Free Trial"
  32. case .lifetime: "Buy Lifetime Access"
  33. }
  34. }
  35. }
  36. // MARK: - Left Panel
  37. private final class PaywallLeftPanelView: NSView {
  38. private let gradientLayer = CAGradientLayer()
  39. override init(frame frameRect: NSRect) {
  40. super.init(frame: frameRect)
  41. wantsLayer = true
  42. gradientLayer.colors = [
  43. NSColor(red: 0.88, green: 0.94, blue: 1.0, alpha: 1).cgColor,
  44. NSColor(red: 0.95, green: 0.97, blue: 1.0, alpha: 1).cgColor,
  45. ]
  46. gradientLayer.startPoint = CGPoint(x: 0.5, y: 1)
  47. gradientLayer.endPoint = CGPoint(x: 0.5, y: 0)
  48. layer?.insertSublayer(gradientLayer, at: 0)
  49. }
  50. @available(*, unavailable)
  51. required init?(coder: NSCoder) { nil }
  52. override func layout() {
  53. super.layout()
  54. gradientLayer.frame = bounds
  55. let mask = CAShapeLayer()
  56. mask.path = CGPath(
  57. roundedRect: bounds,
  58. cornerWidth: 20,
  59. cornerHeight: 20,
  60. transform: nil
  61. )
  62. layer?.mask = mask
  63. }
  64. }
  65. // MARK: - Badge
  66. private final class PaywallBadgeView: NSView {
  67. init(text: String, iconName: String, background: NSColor, foreground: NSColor) {
  68. super.init(frame: .zero)
  69. translatesAutoresizingMaskIntoConstraints = false
  70. wantsLayer = true
  71. layer?.backgroundColor = background.cgColor
  72. layer?.cornerRadius = 10
  73. layer?.masksToBounds = true
  74. let icon = NSImageView()
  75. icon.translatesAutoresizingMaskIntoConstraints = false
  76. if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) {
  77. let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .semibold)
  78. icon.image = image.withSymbolConfiguration(config)
  79. }
  80. icon.contentTintColor = foreground
  81. let label = NSTextField(labelWithString: text)
  82. label.font = AppTheme.semiboldFont(size: 10)
  83. label.textColor = foreground
  84. label.translatesAutoresizingMaskIntoConstraints = false
  85. addSubview(icon)
  86. addSubview(label)
  87. NSLayoutConstraint.activate([
  88. heightAnchor.constraint(equalToConstant: 20),
  89. icon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
  90. icon.centerYAnchor.constraint(equalTo: centerYAnchor),
  91. icon.widthAnchor.constraint(equalToConstant: 12),
  92. icon.heightAnchor.constraint(equalToConstant: 12),
  93. label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 4),
  94. label.centerYAnchor.constraint(equalTo: centerYAnchor),
  95. label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
  96. ])
  97. }
  98. @available(*, unavailable)
  99. required init?(coder: NSCoder) { nil }
  100. }
  101. // MARK: - Feature Row
  102. private final class PaywallFeatureRow: NSView {
  103. init(text: String) {
  104. super.init(frame: .zero)
  105. translatesAutoresizingMaskIntoConstraints = false
  106. let checkContainer = NSView()
  107. checkContainer.translatesAutoresizingMaskIntoConstraints = false
  108. checkContainer.wantsLayer = true
  109. checkContainer.layer?.backgroundColor = AppTheme.green.cgColor
  110. checkContainer.layer?.cornerRadius = 10
  111. checkContainer.layer?.masksToBounds = true
  112. let checkIcon = NSImageView()
  113. checkIcon.translatesAutoresizingMaskIntoConstraints = false
  114. if let image = NSImage(systemSymbolName: "checkmark", accessibilityDescription: nil) {
  115. let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .bold)
  116. checkIcon.image = image.withSymbolConfiguration(config)
  117. }
  118. checkIcon.contentTintColor = .white
  119. let label = NSTextField(labelWithString: text)
  120. label.font = AppTheme.regularFont(size: 14)
  121. label.textColor = AppTheme.navy
  122. label.translatesAutoresizingMaskIntoConstraints = false
  123. addSubview(checkContainer)
  124. checkContainer.addSubview(checkIcon)
  125. addSubview(label)
  126. NSLayoutConstraint.activate([
  127. heightAnchor.constraint(equalToConstant: 28),
  128. checkContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
  129. checkContainer.centerYAnchor.constraint(equalTo: centerYAnchor),
  130. checkContainer.widthAnchor.constraint(equalToConstant: 20),
  131. checkContainer.heightAnchor.constraint(equalToConstant: 20),
  132. checkIcon.centerXAnchor.constraint(equalTo: checkContainer.centerXAnchor),
  133. checkIcon.centerYAnchor.constraint(equalTo: checkContainer.centerYAnchor),
  134. checkIcon.widthAnchor.constraint(equalToConstant: 12),
  135. checkIcon.heightAnchor.constraint(equalToConstant: 12),
  136. label.leadingAnchor.constraint(equalTo: checkContainer.trailingAnchor, constant: 12),
  137. label.centerYAnchor.constraint(equalTo: centerYAnchor),
  138. label.trailingAnchor.constraint(equalTo: trailingAnchor),
  139. ])
  140. }
  141. @available(*, unavailable)
  142. required init?(coder: NSCoder) { nil }
  143. }
  144. // MARK: - Plan Card
  145. private final class PaywallPlanCard: NSControl {
  146. var onSelect: (() -> Void)?
  147. private let plan: PaywallPlan
  148. private let titleLabel = NSTextField(labelWithString: "")
  149. private let subtitleLabel = NSTextField(labelWithString: "")
  150. private let priceLabel = NSTextField(labelWithString: "")
  151. private var badgeView: PaywallBadgeView?
  152. var isChosen: Bool = false {
  153. didSet { updateAppearance() }
  154. }
  155. init(plan: PaywallPlan) {
  156. self.plan = plan
  157. super.init(frame: .zero)
  158. translatesAutoresizingMaskIntoConstraints = false
  159. wantsLayer = true
  160. layer?.cornerRadius = 12
  161. layer?.backgroundColor = NSColor.white.cgColor
  162. layer?.masksToBounds = false
  163. titleLabel.stringValue = plan.title
  164. titleLabel.font = AppTheme.semiboldFont(size: 15)
  165. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  166. subtitleLabel.stringValue = plan.subtitle
  167. subtitleLabel.font = AppTheme.regularFont(size: 11)
  168. subtitleLabel.textColor = AppTheme.textSecondary
  169. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  170. priceLabel.stringValue = plan.price
  171. priceLabel.font = AppTheme.semiboldFont(size: 15)
  172. priceLabel.alignment = .right
  173. priceLabel.translatesAutoresizingMaskIntoConstraints = false
  174. addSubview(titleLabel)
  175. addSubview(subtitleLabel)
  176. addSubview(priceLabel)
  177. NSLayoutConstraint.activate([
  178. heightAnchor.constraint(equalToConstant: 86),
  179. titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
  180. titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 24),
  181. subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  182. subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 3),
  183. subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: priceLabel.leadingAnchor, constant: -12),
  184. priceLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
  185. priceLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  186. ])
  187. if plan == .yearly {
  188. let badge = PaywallBadgeView(
  189. text: "7 Days Free Trial",
  190. iconName: "calendar",
  191. background: AppTheme.paywallPink,
  192. foreground: AppTheme.paywallPinkText
  193. )
  194. badgeView = badge
  195. addSubview(badge)
  196. NSLayoutConstraint.activate([
  197. badge.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
  198. badge.topAnchor.constraint(equalTo: topAnchor, constant: 8),
  199. ])
  200. } else if plan == .lifetime {
  201. let badge = PaywallBadgeView(
  202. text: "Best Value",
  203. iconName: "star.fill",
  204. background: AppTheme.paywallGold,
  205. foreground: AppTheme.paywallGoldText
  206. )
  207. badgeView = badge
  208. addSubview(badge)
  209. NSLayoutConstraint.activate([
  210. badge.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
  211. badge.topAnchor.constraint(equalTo: topAnchor, constant: 8),
  212. ])
  213. }
  214. updateAppearance()
  215. }
  216. @available(*, unavailable)
  217. required init?(coder: NSCoder) { nil }
  218. private func updateAppearance() {
  219. let titleColor = isChosen && plan == .yearly ? AppTheme.green : AppTheme.navy
  220. titleLabel.textColor = titleColor
  221. priceLabel.textColor = titleColor
  222. layer?.borderWidth = isChosen ? 2 : 1
  223. layer?.borderColor = (isChosen ? AppTheme.green : AppTheme.paywallBorder).cgColor
  224. }
  225. override func mouseUp(with event: NSEvent) {
  226. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  227. onSelect?()
  228. }
  229. override func resetCursorRects() {
  230. addCursorRect(bounds, cursor: .pointingHand)
  231. }
  232. }
  233. // MARK: - Footer Link
  234. private final class PaywallFooterLink: NSButton {
  235. init(title: String) {
  236. super.init(frame: .zero)
  237. self.title = title
  238. isBordered = false
  239. font = AppTheme.regularFont(size: 11)
  240. contentTintColor = AppTheme.textSecondary
  241. translatesAutoresizingMaskIntoConstraints = false
  242. }
  243. @available(*, unavailable)
  244. required init?(coder: NSCoder) { nil }
  245. override func resetCursorRects() {
  246. addCursorRect(bounds, cursor: .pointingHand)
  247. }
  248. }
  249. // MARK: - Footer Trust Item
  250. private final class PaywallTrustItemView: NSView {
  251. init(iconName: String, title: String, subtitle: String) {
  252. super.init(frame: .zero)
  253. translatesAutoresizingMaskIntoConstraints = false
  254. let iconContainer = NSView()
  255. iconContainer.translatesAutoresizingMaskIntoConstraints = false
  256. iconContainer.wantsLayer = true
  257. iconContainer.layer?.backgroundColor = AppTheme.blueLight.cgColor
  258. iconContainer.layer?.cornerRadius = 10
  259. iconContainer.layer?.masksToBounds = true
  260. let icon = NSImageView()
  261. icon.translatesAutoresizingMaskIntoConstraints = false
  262. if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) {
  263. let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  264. icon.image = image.withSymbolConfiguration(config)
  265. }
  266. icon.contentTintColor = AppTheme.navy
  267. let titleLabel = NSTextField(labelWithString: title)
  268. titleLabel.font = AppTheme.semiboldFont(size: 11)
  269. titleLabel.textColor = AppTheme.navy
  270. titleLabel.lineBreakMode = .byTruncatingTail
  271. titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  272. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  273. let subtitleLabel = NSTextField(labelWithString: subtitle)
  274. subtitleLabel.font = AppTheme.regularFont(size: 9)
  275. subtitleLabel.textColor = AppTheme.textSecondary
  276. subtitleLabel.lineBreakMode = .byTruncatingTail
  277. subtitleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  278. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  279. addSubview(iconContainer)
  280. iconContainer.addSubview(icon)
  281. addSubview(titleLabel)
  282. addSubview(subtitleLabel)
  283. NSLayoutConstraint.activate([
  284. iconContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
  285. iconContainer.topAnchor.constraint(equalTo: topAnchor),
  286. iconContainer.widthAnchor.constraint(equalToConstant: 20),
  287. iconContainer.heightAnchor.constraint(equalToConstant: 20),
  288. icon.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
  289. icon.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
  290. icon.widthAnchor.constraint(equalToConstant: 12),
  291. icon.heightAnchor.constraint(equalToConstant: 12),
  292. titleLabel.leadingAnchor.constraint(equalTo: iconContainer.trailingAnchor, constant: 8),
  293. titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 1),
  294. titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
  295. subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  296. subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 2),
  297. subtitleLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
  298. subtitleLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
  299. ])
  300. setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  301. setContentHuggingPriority(.defaultLow, for: .horizontal)
  302. }
  303. @available(*, unavailable)
  304. required init?(coder: NSCoder) { nil }
  305. }
  306. // MARK: - Main Paywall Card
  307. final class PaywallView: NSView {
  308. var onClose: (() -> Void)?
  309. var onPurchase: ((PaywallPlan) -> Void)?
  310. var onRestore: (() -> Void)?
  311. private var selectedPlan: PaywallPlan = .yearly
  312. private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
  313. private let ctaButton = NSButton()
  314. init() {
  315. super.init(frame: .zero)
  316. translatesAutoresizingMaskIntoConstraints = false
  317. wantsLayer = true
  318. layer?.backgroundColor = NSColor.white.cgColor
  319. layer?.cornerRadius = 0
  320. setup()
  321. }
  322. @available(*, unavailable)
  323. required init?(coder: NSCoder) { nil }
  324. private func setup() {
  325. let leftPanel = makeLeftPanel()
  326. let rightPanel = makeRightPanel()
  327. addSubview(leftPanel)
  328. addSubview(rightPanel)
  329. NSLayoutConstraint.activate([
  330. leftPanel.leadingAnchor.constraint(equalTo: leadingAnchor),
  331. leftPanel.topAnchor.constraint(equalTo: topAnchor),
  332. leftPanel.bottomAnchor.constraint(equalTo: bottomAnchor),
  333. leftPanel.widthAnchor.constraint(equalToConstant: 320),
  334. rightPanel.leadingAnchor.constraint(equalTo: leftPanel.trailingAnchor),
  335. rightPanel.trailingAnchor.constraint(equalTo: trailingAnchor),
  336. rightPanel.topAnchor.constraint(equalTo: topAnchor),
  337. rightPanel.bottomAnchor.constraint(equalTo: bottomAnchor),
  338. ])
  339. }
  340. private func makeLeftPanel() -> NSView {
  341. let panel = PaywallLeftPanelView()
  342. panel.translatesAutoresizingMaskIntoConstraints = false
  343. let title = NSTextField(labelWithString: "Unlock Your Full\nPrinting Potential")
  344. title.font = AppTheme.semiboldFont(size: 22)
  345. title.textColor = AppTheme.navy
  346. title.maximumNumberOfLines = 2
  347. title.translatesAutoresizingMaskIntoConstraints = false
  348. let featuresStack = NSStackView()
  349. featuresStack.orientation = .vertical
  350. featuresStack.spacing = 6
  351. featuresStack.alignment = .leading
  352. featuresStack.translatesAutoresizingMaskIntoConstraints = false
  353. let features = [
  354. "Unlimited high-quality scans",
  355. "Advanced OCR technology",
  356. "Direct cloud printing",
  357. "Ad-free experience",
  358. "Priority support",
  359. "Secure storage",
  360. ]
  361. for feature in features {
  362. featuresStack.addArrangedSubview(PaywallFeatureRow(text: feature))
  363. }
  364. panel.addSubview(title)
  365. panel.addSubview(featuresStack)
  366. NSLayoutConstraint.activate([
  367. title.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  368. title.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -20),
  369. title.topAnchor.constraint(equalTo: panel.topAnchor, constant: 48),
  370. featuresStack.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  371. featuresStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -20),
  372. featuresStack.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 28),
  373. ])
  374. return panel
  375. }
  376. private func makeRightPanel() -> NSView {
  377. let panel = NSView()
  378. panel.translatesAutoresizingMaskIntoConstraints = false
  379. let title = NSTextField(labelWithString: "Go Premium")
  380. title.font = AppTheme.semiboldFont(size: 26)
  381. title.textColor = AppTheme.navy
  382. title.alignment = .center
  383. title.translatesAutoresizingMaskIntoConstraints = false
  384. let subtitle = NSTextField(labelWithString: "Experience professional quality printing and scanning without limits.")
  385. subtitle.font = AppTheme.regularFont(size: 13)
  386. subtitle.textColor = AppTheme.textSecondary
  387. subtitle.alignment = .center
  388. subtitle.maximumNumberOfLines = 2
  389. subtitle.translatesAutoresizingMaskIntoConstraints = false
  390. let plansStack = NSStackView()
  391. plansStack.orientation = .vertical
  392. plansStack.spacing = 12
  393. plansStack.translatesAutoresizingMaskIntoConstraints = false
  394. for plan in PaywallPlan.allCases {
  395. let card = PaywallPlanCard(plan: plan)
  396. card.isChosen = plan == selectedPlan
  397. card.onSelect = { [weak self] in self?.selectPlan(plan) }
  398. planCards[plan] = card
  399. plansStack.addArrangedSubview(card)
  400. }
  401. ctaButton.title = selectedPlan.ctaTitle
  402. ctaButton.isBordered = false
  403. ctaButton.wantsLayer = true
  404. ctaButton.layer?.backgroundColor = AppTheme.navy.cgColor
  405. ctaButton.layer?.cornerRadius = 12
  406. ctaButton.font = AppTheme.semiboldFont(size: 15)
  407. ctaButton.contentTintColor = .white
  408. ctaButton.target = self
  409. ctaButton.action = #selector(purchaseTapped)
  410. ctaButton.translatesAutoresizingMaskIntoConstraints = false
  411. let trustRow = makeTrustRow()
  412. let footerLinks = makeFooterLinks()
  413. panel.addSubview(title)
  414. panel.addSubview(subtitle)
  415. panel.addSubview(plansStack)
  416. panel.addSubview(trustRow)
  417. panel.addSubview(ctaButton)
  418. panel.addSubview(footerLinks)
  419. NSLayoutConstraint.activate([
  420. title.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  421. title.topAnchor.constraint(equalTo: panel.topAnchor, constant: 40),
  422. title.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  423. subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  424. subtitle.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  425. subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6),
  426. plansStack.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  427. plansStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  428. plansStack.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 24),
  429. trustRow.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 22),
  430. trustRow.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -22),
  431. trustRow.topAnchor.constraint(equalTo: plansStack.bottomAnchor, constant: 18),
  432. ctaButton.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  433. ctaButton.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  434. ctaButton.topAnchor.constraint(equalTo: trustRow.bottomAnchor, constant: 16),
  435. ctaButton.heightAnchor.constraint(equalToConstant: 48),
  436. ctaButton.bottomAnchor.constraint(lessThanOrEqualTo: footerLinks.topAnchor, constant: -14),
  437. footerLinks.centerXAnchor.constraint(equalTo: panel.centerXAnchor),
  438. footerLinks.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -20),
  439. ])
  440. return panel
  441. }
  442. private func makeTrustRow() -> NSView {
  443. let securePayments = PaywallTrustItemView(
  444. iconName: "shield.fill",
  445. title: "Secure Payments",
  446. subtitle: "Your payment is 100% secure"
  447. )
  448. let cancelAnytime = PaywallTrustItemView(
  449. iconName: "arrow.counterclockwise",
  450. title: "Cancel Anytime",
  451. subtitle: "No commitment, cancel anytime."
  452. )
  453. let support = PaywallTrustItemView(
  454. iconName: "headphones",
  455. title: "24/7 Support",
  456. subtitle: "We're here to help you anytime."
  457. )
  458. let privacyFirst = PaywallTrustItemView(
  459. iconName: "lock.fill",
  460. title: "Privacy First",
  461. subtitle: "Your data is safe with us."
  462. )
  463. let trustStack = NSStackView(views: [securePayments, cancelAnytime, support, privacyFirst])
  464. trustStack.orientation = .horizontal
  465. trustStack.distribution = .fillEqually
  466. trustStack.spacing = 16
  467. trustStack.alignment = .top
  468. trustStack.translatesAutoresizingMaskIntoConstraints = false
  469. trustStack.wantsLayer = true
  470. trustStack.layer?.backgroundColor = NSColor.white.cgColor
  471. trustStack.layer?.cornerRadius = 12
  472. trustStack.layer?.borderWidth = 1
  473. trustStack.layer?.borderColor = AppTheme.paywallBorder.cgColor
  474. trustStack.layer?.masksToBounds = true
  475. trustStack.edgeInsets = NSEdgeInsets(top: 12, left: 14, bottom: 12, right: 14)
  476. trustStack.translatesAutoresizingMaskIntoConstraints = false
  477. return trustStack
  478. }
  479. private func makeFooterLinks() -> NSView {
  480. let container = NSView()
  481. container.translatesAutoresizingMaskIntoConstraints = false
  482. let continueWithFreePlanLink = PaywallFooterLink(title: "Continue with free plan")
  483. continueWithFreePlanLink.target = self
  484. continueWithFreePlanLink.action = #selector(continueWithFreePlanTapped)
  485. let restoreLink = PaywallFooterLink(title: "Restore Purchase")
  486. restoreLink.target = self
  487. restoreLink.action = #selector(restoreTapped)
  488. let privacyLink = PaywallFooterLink(title: "Privacy Policy")
  489. let termsLink = PaywallFooterLink(title: "Terms of Service")
  490. let supportLink = PaywallFooterLink(title: "Support")
  491. let separator1 = makeFooterSeparator()
  492. let separator2 = makeFooterSeparator()
  493. let separator3 = makeFooterSeparator()
  494. let separator4 = makeFooterSeparator()
  495. let linksStack = NSStackView(views: [
  496. continueWithFreePlanLink, separator1, restoreLink, separator2, privacyLink, separator3, termsLink, separator4, supportLink,
  497. ])
  498. linksStack.orientation = .horizontal
  499. linksStack.spacing = 8
  500. linksStack.alignment = .centerY
  501. linksStack.distribution = .fillProportionally
  502. linksStack.translatesAutoresizingMaskIntoConstraints = false
  503. container.addSubview(linksStack)
  504. NSLayoutConstraint.activate([
  505. linksStack.centerXAnchor.constraint(equalTo: container.centerXAnchor),
  506. linksStack.leadingAnchor.constraint(greaterThanOrEqualTo: container.leadingAnchor),
  507. linksStack.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor),
  508. linksStack.topAnchor.constraint(equalTo: container.topAnchor),
  509. linksStack.bottomAnchor.constraint(equalTo: container.bottomAnchor)
  510. ])
  511. return container
  512. }
  513. private func makeFooterSeparator() -> NSView {
  514. let separator = NSView()
  515. separator.translatesAutoresizingMaskIntoConstraints = false
  516. separator.wantsLayer = true
  517. separator.layer?.backgroundColor = AppTheme.paywallBorder.cgColor
  518. NSLayoutConstraint.activate([
  519. separator.widthAnchor.constraint(equalToConstant: 1),
  520. separator.heightAnchor.constraint(equalToConstant: 12),
  521. ])
  522. return separator
  523. }
  524. private func selectPlan(_ plan: PaywallPlan) {
  525. selectedPlan = plan
  526. for (key, card) in planCards {
  527. card.isChosen = key == plan
  528. }
  529. ctaButton.title = plan.ctaTitle
  530. }
  531. @objc private func purchaseTapped() {
  532. onPurchase?(selectedPlan)
  533. }
  534. @objc private func restoreTapped() {
  535. onRestore?()
  536. }
  537. @objc private func continueWithFreePlanTapped() {
  538. onClose?()
  539. }
  540. }
  541. // MARK: - Overlay Presenter
  542. final class PaywallOverlayView: NSView {
  543. var onDismiss: (() -> Void)?
  544. private let paywallView: PaywallView
  545. private let blurView = NSVisualEffectView()
  546. private let backdrop = NSView()
  547. init() {
  548. paywallView = PaywallView()
  549. super.init(frame: .zero)
  550. translatesAutoresizingMaskIntoConstraints = false
  551. setup()
  552. }
  553. @available(*, unavailable)
  554. required init?(coder: NSCoder) { nil }
  555. private func setup() {
  556. blurView.translatesAutoresizingMaskIntoConstraints = false
  557. blurView.material = .underWindowBackground
  558. blurView.blendingMode = .withinWindow
  559. blurView.state = .active
  560. backdrop.translatesAutoresizingMaskIntoConstraints = false
  561. backdrop.wantsLayer = true
  562. backdrop.layer?.backgroundColor = NSColor(calibratedWhite: 0.15, alpha: 0.22).cgColor
  563. let pattern = WavePatternView()
  564. pattern.translatesAutoresizingMaskIntoConstraints = false
  565. pattern.alphaValue = 0.35
  566. paywallView.translatesAutoresizingMaskIntoConstraints = false
  567. paywallView.onClose = { [weak self] in self?.dismiss() }
  568. paywallView.onPurchase = { plan in
  569. NSLog("Purchase tapped: \(plan.title)")
  570. }
  571. paywallView.onRestore = {
  572. NSLog("Restore purchases tapped")
  573. }
  574. addSubview(blurView)
  575. addSubview(backdrop)
  576. backdrop.addSubview(pattern)
  577. addSubview(paywallView)
  578. NSLayoutConstraint.activate([
  579. blurView.leadingAnchor.constraint(equalTo: leadingAnchor),
  580. blurView.trailingAnchor.constraint(equalTo: trailingAnchor),
  581. blurView.topAnchor.constraint(equalTo: topAnchor),
  582. blurView.bottomAnchor.constraint(equalTo: bottomAnchor),
  583. backdrop.leadingAnchor.constraint(equalTo: leadingAnchor),
  584. backdrop.trailingAnchor.constraint(equalTo: trailingAnchor),
  585. backdrop.topAnchor.constraint(equalTo: topAnchor),
  586. backdrop.bottomAnchor.constraint(equalTo: bottomAnchor),
  587. pattern.leadingAnchor.constraint(equalTo: backdrop.leadingAnchor),
  588. pattern.trailingAnchor.constraint(equalTo: backdrop.trailingAnchor),
  589. pattern.topAnchor.constraint(equalTo: backdrop.topAnchor),
  590. pattern.bottomAnchor.constraint(equalTo: backdrop.bottomAnchor),
  591. paywallView.leadingAnchor.constraint(equalTo: leadingAnchor),
  592. paywallView.trailingAnchor.constraint(equalTo: trailingAnchor),
  593. paywallView.topAnchor.constraint(equalTo: topAnchor),
  594. paywallView.bottomAnchor.constraint(equalTo: bottomAnchor),
  595. ])
  596. }
  597. func present(in parent: NSView) {
  598. guard superview == nil else { return }
  599. if let window = parent.window,
  600. let windowFrameView = window.contentView?.superview {
  601. windowFrameView.addSubview(self)
  602. NSLayoutConstraint.activate([
  603. leadingAnchor.constraint(equalTo: windowFrameView.leadingAnchor),
  604. trailingAnchor.constraint(equalTo: windowFrameView.trailingAnchor),
  605. topAnchor.constraint(equalTo: windowFrameView.topAnchor),
  606. bottomAnchor.constraint(equalTo: windowFrameView.bottomAnchor),
  607. ])
  608. } else {
  609. parent.addSubview(self)
  610. NSLayoutConstraint.activate([
  611. leadingAnchor.constraint(equalTo: parent.leadingAnchor),
  612. trailingAnchor.constraint(equalTo: parent.trailingAnchor),
  613. topAnchor.constraint(equalTo: parent.topAnchor),
  614. bottomAnchor.constraint(equalTo: parent.bottomAnchor),
  615. ])
  616. }
  617. alphaValue = 0
  618. NSAnimationContext.runAnimationGroup { context in
  619. context.duration = 0.2
  620. animator().alphaValue = 1
  621. }
  622. }
  623. func dismiss() {
  624. NSAnimationContext.runAnimationGroup({ context in
  625. context.duration = 0.15
  626. animator().alphaValue = 0
  627. }, completionHandler: { [weak self] in
  628. self?.removeFromSuperview()
  629. self?.onDismiss?()
  630. })
  631. }
  632. }