Ei kuvausta

PaywallView.swift 32KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884
  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, AppearanceRefreshable {
  38. private let gradientLayer = CAGradientLayer()
  39. override init(frame frameRect: NSRect) {
  40. super.init(frame: frameRect)
  41. wantsLayer = true
  42. gradientLayer.startPoint = CGPoint(x: 0.5, y: 1)
  43. gradientLayer.endPoint = CGPoint(x: 0.5, y: 0)
  44. layer?.insertSublayer(gradientLayer, at: 0)
  45. refreshAppearance()
  46. }
  47. func refreshAppearance() {
  48. gradientLayer.colors = AppTheme.paywallLeftGradientColors.map(\.cgColor)
  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, AppearanceRefreshable {
  103. private let label: NSTextField
  104. init(text: String) {
  105. label = NSTextField.themeLabel(text, style: .primary, font: AppTheme.regularFont(size: 14))
  106. super.init(frame: .zero)
  107. translatesAutoresizingMaskIntoConstraints = false
  108. let checkContainer = NSView()
  109. checkContainer.translatesAutoresizingMaskIntoConstraints = false
  110. checkContainer.wantsLayer = true
  111. checkContainer.layer?.backgroundColor = AppTheme.green.cgColor
  112. checkContainer.layer?.cornerRadius = 10
  113. checkContainer.layer?.masksToBounds = true
  114. let checkIcon = NSImageView()
  115. checkIcon.translatesAutoresizingMaskIntoConstraints = false
  116. if let image = NSImage(systemSymbolName: "checkmark", accessibilityDescription: nil) {
  117. let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .bold)
  118. checkIcon.image = image.withSymbolConfiguration(config)
  119. }
  120. checkIcon.contentTintColor = .white
  121. label.translatesAutoresizingMaskIntoConstraints = false
  122. addSubview(checkContainer)
  123. checkContainer.addSubview(checkIcon)
  124. addSubview(label)
  125. NSLayoutConstraint.activate([
  126. heightAnchor.constraint(equalToConstant: 28),
  127. checkContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
  128. checkContainer.centerYAnchor.constraint(equalTo: centerYAnchor),
  129. checkContainer.widthAnchor.constraint(equalToConstant: 20),
  130. checkContainer.heightAnchor.constraint(equalToConstant: 20),
  131. checkIcon.centerXAnchor.constraint(equalTo: checkContainer.centerXAnchor),
  132. checkIcon.centerYAnchor.constraint(equalTo: checkContainer.centerYAnchor),
  133. checkIcon.widthAnchor.constraint(equalToConstant: 12),
  134. checkIcon.heightAnchor.constraint(equalToConstant: 12),
  135. label.leadingAnchor.constraint(equalTo: checkContainer.trailingAnchor, constant: 12),
  136. label.centerYAnchor.constraint(equalTo: centerYAnchor),
  137. label.trailingAnchor.constraint(equalTo: trailingAnchor),
  138. ])
  139. }
  140. @available(*, unavailable)
  141. required init?(coder: NSCoder) { nil }
  142. func refreshAppearance() {
  143. label.refreshThemeLabelColor()
  144. }
  145. }
  146. // MARK: - Plan Card
  147. private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
  148. var onSelect: (() -> Void)?
  149. private let plan: PaywallPlan
  150. private let titleLabel = NSTextField(labelWithString: "")
  151. private let subtitleLabel = NSTextField(labelWithString: "")
  152. private let priceLabel = NSTextField(labelWithString: "")
  153. private var badgeView: PaywallBadgeView?
  154. private var hoverTracker: HoverTracker?
  155. private var isHovered = false
  156. var isChosen: Bool = false {
  157. didSet { updateAppearance() }
  158. }
  159. init(plan: PaywallPlan) {
  160. self.plan = plan
  161. super.init(frame: .zero)
  162. translatesAutoresizingMaskIntoConstraints = false
  163. wantsLayer = true
  164. layer?.cornerRadius = 12
  165. layer?.masksToBounds = false
  166. titleLabel.stringValue = plan.title
  167. titleLabel.font = AppTheme.semiboldFont(size: 15)
  168. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  169. subtitleLabel.stringValue = plan.subtitle
  170. subtitleLabel.font = AppTheme.regularFont(size: 11)
  171. subtitleLabel.themeLabelStyle = .secondary
  172. subtitleLabel.textColor = AppTheme.textSecondary
  173. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  174. priceLabel.stringValue = plan.price
  175. priceLabel.font = AppTheme.semiboldFont(size: 15)
  176. priceLabel.alignment = .right
  177. priceLabel.translatesAutoresizingMaskIntoConstraints = false
  178. addSubview(titleLabel)
  179. addSubview(subtitleLabel)
  180. addSubview(priceLabel)
  181. NSLayoutConstraint.activate([
  182. heightAnchor.constraint(equalToConstant: 86),
  183. titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
  184. titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 24),
  185. subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  186. subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 3),
  187. subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: priceLabel.leadingAnchor, constant: -12),
  188. priceLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
  189. priceLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  190. ])
  191. if plan == .yearly {
  192. let badge = PaywallBadgeView(
  193. text: "7 Days Free Trial",
  194. iconName: "calendar",
  195. background: AppTheme.paywallPink,
  196. foreground: AppTheme.paywallPinkText
  197. )
  198. badgeView = badge
  199. addSubview(badge)
  200. NSLayoutConstraint.activate([
  201. badge.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
  202. badge.topAnchor.constraint(equalTo: topAnchor, constant: 8),
  203. ])
  204. } else if plan == .lifetime {
  205. let badge = PaywallBadgeView(
  206. text: "Best Value",
  207. iconName: "star.fill",
  208. background: AppTheme.paywallGold,
  209. foreground: AppTheme.paywallGoldText
  210. )
  211. badgeView = badge
  212. addSubview(badge)
  213. NSLayoutConstraint.activate([
  214. badge.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
  215. badge.topAnchor.constraint(equalTo: topAnchor, constant: 8),
  216. ])
  217. }
  218. applyCardShadow()
  219. updateAppearance()
  220. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  221. self?.setHovered(hovering)
  222. }
  223. }
  224. @available(*, unavailable)
  225. required init?(coder: NSCoder) { nil }
  226. func refreshAppearance() {
  227. updateAppearance()
  228. subtitleLabel.refreshThemeLabelColor()
  229. if isHovered {
  230. applyHoverLift(true)
  231. }
  232. }
  233. private func setHovered(_ hovering: Bool) {
  234. isHovered = hovering
  235. applyHoverLift(hovering)
  236. updateAppearance()
  237. }
  238. private func updateAppearance() {
  239. let titleColor = isChosen && plan == .yearly ? AppTheme.green : AppTheme.paywallAccent
  240. titleLabel.textColor = titleColor
  241. priceLabel.textColor = titleColor
  242. layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor
  243. if isChosen {
  244. layer?.borderWidth = 2
  245. layer?.borderColor = AppTheme.green.cgColor
  246. } else if isHovered {
  247. layer?.borderWidth = 1.5
  248. let hoverBorder = AppTheme.paywallBorder.blended(withFraction: 0.35, of: AppTheme.paywallAccent)
  249. ?? AppTheme.paywallBorder
  250. layer?.borderColor = hoverBorder.cgColor
  251. } else {
  252. layer?.borderWidth = 1
  253. layer?.borderColor = AppTheme.paywallBorder.cgColor
  254. }
  255. }
  256. override func mouseUp(with event: NSEvent) {
  257. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  258. onSelect?()
  259. }
  260. override func resetCursorRects() {
  261. addCursorRect(bounds, cursor: .pointingHand)
  262. }
  263. }
  264. // MARK: - Footer Link
  265. private final class PaywallFooterLink: NSButton, AppearanceRefreshable {
  266. private var hoverTracker: HoverTracker?
  267. private var isHovered = false
  268. init(title: String) {
  269. super.init(frame: .zero)
  270. self.title = title
  271. isBordered = false
  272. font = AppTheme.regularFont(size: 11)
  273. translatesAutoresizingMaskIntoConstraints = false
  274. refreshAppearance()
  275. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  276. self?.isHovered = hovering
  277. self?.refreshAppearance()
  278. }
  279. }
  280. @available(*, unavailable)
  281. required init?(coder: NSCoder) { nil }
  282. func refreshAppearance() {
  283. contentTintColor = isHovered ? AppTheme.textPrimary : AppTheme.textSecondary
  284. }
  285. override func resetCursorRects() {
  286. addCursorRect(bounds, cursor: .pointingHand)
  287. }
  288. }
  289. // MARK: - Footer Trust Item
  290. private final class PaywallTrustItemView: NSView, AppearanceRefreshable {
  291. private let iconContainer = NSView()
  292. private let icon = NSImageView()
  293. private let titleLabel: NSTextField
  294. private let subtitleLabel: NSTextField
  295. init(iconName: String, title: String, subtitle: String) {
  296. titleLabel = NSTextField.themeLabel(title, style: .primary, font: AppTheme.semiboldFont(size: 11))
  297. subtitleLabel = NSTextField.themeLabel(subtitle, style: .secondary, font: AppTheme.regularFont(size: 9))
  298. super.init(frame: .zero)
  299. translatesAutoresizingMaskIntoConstraints = false
  300. iconContainer.translatesAutoresizingMaskIntoConstraints = false
  301. iconContainer.wantsLayer = true
  302. iconContainer.layer?.cornerRadius = 10
  303. iconContainer.layer?.masksToBounds = true
  304. icon.translatesAutoresizingMaskIntoConstraints = false
  305. if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) {
  306. let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  307. icon.image = image.withSymbolConfiguration(config)
  308. }
  309. titleLabel.lineBreakMode = .byTruncatingTail
  310. titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  311. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  312. subtitleLabel.lineBreakMode = .byTruncatingTail
  313. subtitleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  314. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  315. addSubview(iconContainer)
  316. iconContainer.addSubview(icon)
  317. addSubview(titleLabel)
  318. addSubview(subtitleLabel)
  319. NSLayoutConstraint.activate([
  320. iconContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
  321. iconContainer.topAnchor.constraint(equalTo: topAnchor),
  322. iconContainer.widthAnchor.constraint(equalToConstant: 20),
  323. iconContainer.heightAnchor.constraint(equalToConstant: 20),
  324. icon.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
  325. icon.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
  326. icon.widthAnchor.constraint(equalToConstant: 12),
  327. icon.heightAnchor.constraint(equalToConstant: 12),
  328. titleLabel.leadingAnchor.constraint(equalTo: iconContainer.trailingAnchor, constant: 8),
  329. titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 1),
  330. titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
  331. subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  332. subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 2),
  333. subtitleLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
  334. subtitleLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
  335. ])
  336. setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  337. setContentHuggingPriority(.defaultLow, for: .horizontal)
  338. refreshAppearance()
  339. }
  340. @available(*, unavailable)
  341. required init?(coder: NSCoder) { nil }
  342. func refreshAppearance() {
  343. iconContainer.layer?.backgroundColor = AppTheme.paywallTrustIconBackground.cgColor
  344. icon.contentTintColor = AppTheme.paywallIconAccent
  345. titleLabel.refreshThemeLabelColor()
  346. subtitleLabel.refreshThemeLabelColor()
  347. }
  348. }
  349. // MARK: - CTA Button
  350. private final class PaywallCTAButton: NSButton, AppearanceRefreshable {
  351. private var hoverTracker: HoverTracker?
  352. init() {
  353. super.init(frame: .zero)
  354. isBordered = false
  355. wantsLayer = true
  356. layer?.cornerRadius = 12
  357. font = AppTheme.semiboldFont(size: 15)
  358. translatesAutoresizingMaskIntoConstraints = false
  359. refreshAppearance()
  360. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  361. self?.setHovered(hovering)
  362. }
  363. }
  364. @available(*, unavailable)
  365. required init?(coder: NSCoder) { nil }
  366. func refreshAppearance() {
  367. layer?.backgroundColor = AppTheme.paywallCTABackground.cgColor
  368. contentTintColor = AppTheme.paywallCTAForeground
  369. }
  370. private func setHovered(_ hovering: Bool) {
  371. let base = AppTheme.paywallCTABackground
  372. let color = hovering ? base.blended(withFraction: 0.12, of: .black) ?? base : base
  373. animateHover {
  374. layer?.backgroundColor = color.cgColor
  375. layer?.transform = hovering
  376. ? CATransform3DMakeScale(1.02, 1.02, 1)
  377. : CATransform3DIdentity
  378. }
  379. }
  380. override func resetCursorRects() {
  381. addCursorRect(bounds, cursor: .pointingHand)
  382. }
  383. }
  384. // MARK: - Main Paywall Card
  385. final class PaywallView: NSView, AppearanceRefreshable {
  386. var onClose: (() -> Void)?
  387. var onPurchase: ((PaywallPlan) -> Void)?
  388. var onRestore: (() -> Void)?
  389. private var selectedPlan: PaywallPlan = .yearly
  390. private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
  391. private let ctaButton = PaywallCTAButton()
  392. private var leftPanelTitle: NSTextField!
  393. private var rightTitle: NSTextField!
  394. private var rightSubtitle: NSTextField!
  395. private var trustStack: NSStackView!
  396. init() {
  397. super.init(frame: .zero)
  398. translatesAutoresizingMaskIntoConstraints = false
  399. wantsLayer = true
  400. layer?.cornerRadius = 0
  401. setup()
  402. refreshAppearance()
  403. }
  404. func refreshAppearance() {
  405. layer?.backgroundColor = AppTheme.paywallBackground.cgColor
  406. leftPanelTitle?.refreshThemeLabelColor()
  407. rightTitle?.refreshThemeLabelColor()
  408. rightSubtitle?.refreshThemeLabelColor()
  409. trustStack?.layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor
  410. trustStack?.layer?.borderColor = AppTheme.paywallBorder.cgColor
  411. ctaButton.refreshAppearance()
  412. subviews.forEach { $0.refreshAppearanceRecursively() }
  413. }
  414. @available(*, unavailable)
  415. required init?(coder: NSCoder) { nil }
  416. private func setup() {
  417. let leftPanel = makeLeftPanel()
  418. let rightPanel = makeRightPanel()
  419. addSubview(leftPanel)
  420. addSubview(rightPanel)
  421. NSLayoutConstraint.activate([
  422. leftPanel.leadingAnchor.constraint(equalTo: leadingAnchor),
  423. leftPanel.topAnchor.constraint(equalTo: topAnchor),
  424. leftPanel.bottomAnchor.constraint(equalTo: bottomAnchor),
  425. leftPanel.widthAnchor.constraint(equalToConstant: 320),
  426. rightPanel.leadingAnchor.constraint(equalTo: leftPanel.trailingAnchor),
  427. rightPanel.trailingAnchor.constraint(equalTo: trailingAnchor),
  428. rightPanel.topAnchor.constraint(equalTo: topAnchor),
  429. rightPanel.bottomAnchor.constraint(equalTo: bottomAnchor),
  430. ])
  431. }
  432. private func makeLeftPanel() -> NSView {
  433. let panel = PaywallLeftPanelView()
  434. panel.translatesAutoresizingMaskIntoConstraints = false
  435. let title = NSTextField.themeLabel(
  436. "Unlock Your Full\nPrinting Potential",
  437. style: .primary,
  438. font: AppTheme.semiboldFont(size: 22)
  439. )
  440. title.maximumNumberOfLines = 2
  441. title.translatesAutoresizingMaskIntoConstraints = false
  442. leftPanelTitle = title
  443. let featuresStack = NSStackView()
  444. featuresStack.orientation = .vertical
  445. featuresStack.spacing = 6
  446. featuresStack.alignment = .leading
  447. featuresStack.translatesAutoresizingMaskIntoConstraints = false
  448. let features = [
  449. "Unlimited high-quality scans",
  450. "Advanced OCR technology",
  451. "Direct cloud printing",
  452. "Ad-free experience",
  453. "Priority support",
  454. "Secure storage",
  455. ]
  456. for feature in features {
  457. featuresStack.addArrangedSubview(PaywallFeatureRow(text: feature))
  458. }
  459. panel.addSubview(title)
  460. panel.addSubview(featuresStack)
  461. NSLayoutConstraint.activate([
  462. title.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  463. title.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -20),
  464. title.topAnchor.constraint(equalTo: panel.topAnchor, constant: 48),
  465. featuresStack.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  466. featuresStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -20),
  467. featuresStack.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 28),
  468. ])
  469. return panel
  470. }
  471. private func makeRightPanel() -> NSView {
  472. let panel = NSView()
  473. panel.translatesAutoresizingMaskIntoConstraints = false
  474. let title = NSTextField.themeLabel("Go Premium", style: .primary, font: AppTheme.semiboldFont(size: 26))
  475. title.alignment = .center
  476. title.translatesAutoresizingMaskIntoConstraints = false
  477. rightTitle = title
  478. let subtitle = NSTextField.themeLabel(
  479. "Experience professional quality printing and scanning without limits.",
  480. style: .secondary,
  481. font: AppTheme.regularFont(size: 13)
  482. )
  483. subtitle.alignment = .center
  484. subtitle.maximumNumberOfLines = 2
  485. subtitle.translatesAutoresizingMaskIntoConstraints = false
  486. rightSubtitle = subtitle
  487. let plansStack = NSStackView()
  488. plansStack.orientation = .vertical
  489. plansStack.spacing = 12
  490. plansStack.translatesAutoresizingMaskIntoConstraints = false
  491. for plan in PaywallPlan.allCases {
  492. let card = PaywallPlanCard(plan: plan)
  493. card.isChosen = plan == selectedPlan
  494. card.onSelect = { [weak self] in self?.selectPlan(plan) }
  495. planCards[plan] = card
  496. plansStack.addArrangedSubview(card)
  497. }
  498. ctaButton.title = selectedPlan.ctaTitle
  499. ctaButton.target = self
  500. ctaButton.action = #selector(purchaseTapped)
  501. ctaButton.translatesAutoresizingMaskIntoConstraints = false
  502. let trustRow = makeTrustRow()
  503. let footerLinks = makeFooterLinks()
  504. panel.addSubview(title)
  505. panel.addSubview(subtitle)
  506. panel.addSubview(plansStack)
  507. panel.addSubview(trustRow)
  508. panel.addSubview(ctaButton)
  509. panel.addSubview(footerLinks)
  510. NSLayoutConstraint.activate([
  511. title.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  512. title.topAnchor.constraint(equalTo: panel.topAnchor, constant: 40),
  513. title.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  514. subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  515. subtitle.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  516. subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6),
  517. plansStack.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  518. plansStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  519. plansStack.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 24),
  520. trustRow.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 22),
  521. trustRow.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -22),
  522. trustRow.topAnchor.constraint(equalTo: plansStack.bottomAnchor, constant: 18),
  523. ctaButton.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  524. ctaButton.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  525. ctaButton.topAnchor.constraint(equalTo: trustRow.bottomAnchor, constant: 16),
  526. ctaButton.heightAnchor.constraint(equalToConstant: 48),
  527. ctaButton.bottomAnchor.constraint(lessThanOrEqualTo: footerLinks.topAnchor, constant: -14),
  528. footerLinks.centerXAnchor.constraint(equalTo: panel.centerXAnchor),
  529. footerLinks.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -20),
  530. ])
  531. return panel
  532. }
  533. private func makeTrustRow() -> NSView {
  534. let securePayments = PaywallTrustItemView(
  535. iconName: "shield.fill",
  536. title: "Secure Payments",
  537. subtitle: "Your payment is 100% secure"
  538. )
  539. let cancelAnytime = PaywallTrustItemView(
  540. iconName: "arrow.counterclockwise",
  541. title: "Cancel Anytime",
  542. subtitle: "No commitment, cancel anytime."
  543. )
  544. let support = PaywallTrustItemView(
  545. iconName: "headphones",
  546. title: "24/7 Support",
  547. subtitle: "We're here to help you anytime."
  548. )
  549. let privacyFirst = PaywallTrustItemView(
  550. iconName: "lock.fill",
  551. title: "Privacy First",
  552. subtitle: "Your data is safe with us."
  553. )
  554. let trustStack = NSStackView(views: [securePayments, cancelAnytime, support, privacyFirst])
  555. trustStack.orientation = .horizontal
  556. trustStack.distribution = .fillEqually
  557. trustStack.spacing = 16
  558. trustStack.alignment = .top
  559. trustStack.translatesAutoresizingMaskIntoConstraints = false
  560. trustStack.wantsLayer = true
  561. trustStack.layer?.cornerRadius = 12
  562. trustStack.layer?.borderWidth = 1
  563. trustStack.layer?.masksToBounds = true
  564. trustStack.edgeInsets = NSEdgeInsets(top: 12, left: 14, bottom: 12, right: 14)
  565. trustStack.translatesAutoresizingMaskIntoConstraints = false
  566. self.trustStack = trustStack
  567. return trustStack
  568. }
  569. private func makeFooterLinks() -> NSView {
  570. let container = NSView()
  571. container.translatesAutoresizingMaskIntoConstraints = false
  572. let continueWithFreePlanLink = PaywallFooterLink(title: "Continue with free plan")
  573. continueWithFreePlanLink.target = self
  574. continueWithFreePlanLink.action = #selector(continueWithFreePlanTapped)
  575. let restoreLink = PaywallFooterLink(title: "Restore Purchase")
  576. restoreLink.target = self
  577. restoreLink.action = #selector(restoreTapped)
  578. let privacyLink = PaywallFooterLink(title: "Privacy Policy")
  579. let termsLink = PaywallFooterLink(title: "Terms of Service")
  580. let supportLink = PaywallFooterLink(title: "Support")
  581. let separator1 = makeFooterSeparator()
  582. let separator2 = makeFooterSeparator()
  583. let separator3 = makeFooterSeparator()
  584. let separator4 = makeFooterSeparator()
  585. let linksStack = NSStackView(views: [
  586. continueWithFreePlanLink, separator1, restoreLink, separator2, privacyLink, separator3, termsLink, separator4, supportLink,
  587. ])
  588. linksStack.orientation = .horizontal
  589. linksStack.spacing = 8
  590. linksStack.alignment = .centerY
  591. linksStack.distribution = .fillProportionally
  592. linksStack.translatesAutoresizingMaskIntoConstraints = false
  593. container.addSubview(linksStack)
  594. NSLayoutConstraint.activate([
  595. linksStack.centerXAnchor.constraint(equalTo: container.centerXAnchor),
  596. linksStack.leadingAnchor.constraint(greaterThanOrEqualTo: container.leadingAnchor),
  597. linksStack.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor),
  598. linksStack.topAnchor.constraint(equalTo: container.topAnchor),
  599. linksStack.bottomAnchor.constraint(equalTo: container.bottomAnchor)
  600. ])
  601. return container
  602. }
  603. private func makeFooterSeparator() -> NSView {
  604. let separator = NSView()
  605. separator.translatesAutoresizingMaskIntoConstraints = false
  606. separator.wantsLayer = true
  607. separator.layer?.backgroundColor = AppTheme.paywallBorder.cgColor
  608. NSLayoutConstraint.activate([
  609. separator.widthAnchor.constraint(equalToConstant: 1),
  610. separator.heightAnchor.constraint(equalToConstant: 12),
  611. ])
  612. return separator
  613. }
  614. private func selectPlan(_ plan: PaywallPlan) {
  615. selectedPlan = plan
  616. for (key, card) in planCards {
  617. card.isChosen = key == plan
  618. }
  619. ctaButton.title = plan.ctaTitle
  620. }
  621. @objc private func purchaseTapped() {
  622. onPurchase?(selectedPlan)
  623. }
  624. @objc private func restoreTapped() {
  625. onRestore?()
  626. }
  627. @objc private func continueWithFreePlanTapped() {
  628. onClose?()
  629. }
  630. }
  631. // MARK: - Overlay Presenter
  632. final class PaywallOverlayView: NSView, AppearanceRefreshable {
  633. var onDismiss: (() -> Void)?
  634. private let paywallView: PaywallView
  635. private let blurView = NSVisualEffectView()
  636. private let backdrop = NSView()
  637. private let pattern = WavePatternView()
  638. init() {
  639. paywallView = PaywallView()
  640. super.init(frame: .zero)
  641. translatesAutoresizingMaskIntoConstraints = false
  642. setup()
  643. refreshAppearance()
  644. }
  645. func refreshAppearance() {
  646. backdrop.layer?.backgroundColor = AppTheme.paywallOverlayBackdrop.cgColor
  647. blurView.material = AppSettings.darkModeEnabled ? .hudWindow : .underWindowBackground
  648. pattern.refreshAppearance()
  649. paywallView.refreshAppearance()
  650. }
  651. @available(*, unavailable)
  652. required init?(coder: NSCoder) { nil }
  653. private func setup() {
  654. blurView.translatesAutoresizingMaskIntoConstraints = false
  655. blurView.material = .underWindowBackground
  656. blurView.blendingMode = .withinWindow
  657. blurView.state = .active
  658. backdrop.translatesAutoresizingMaskIntoConstraints = false
  659. backdrop.wantsLayer = true
  660. backdrop.layer?.backgroundColor = NSColor(calibratedWhite: 0.15, alpha: 0.22).cgColor
  661. pattern.translatesAutoresizingMaskIntoConstraints = false
  662. pattern.alphaValue = 0.35
  663. paywallView.translatesAutoresizingMaskIntoConstraints = false
  664. paywallView.onClose = { [weak self] in self?.dismiss() }
  665. paywallView.onPurchase = { plan in
  666. NSLog("Purchase tapped: \(plan.title)")
  667. }
  668. paywallView.onRestore = {
  669. NSLog("Restore purchases tapped")
  670. }
  671. addSubview(blurView)
  672. addSubview(backdrop)
  673. backdrop.addSubview(pattern)
  674. addSubview(paywallView)
  675. NSLayoutConstraint.activate([
  676. blurView.leadingAnchor.constraint(equalTo: leadingAnchor),
  677. blurView.trailingAnchor.constraint(equalTo: trailingAnchor),
  678. blurView.topAnchor.constraint(equalTo: topAnchor),
  679. blurView.bottomAnchor.constraint(equalTo: bottomAnchor),
  680. backdrop.leadingAnchor.constraint(equalTo: leadingAnchor),
  681. backdrop.trailingAnchor.constraint(equalTo: trailingAnchor),
  682. backdrop.topAnchor.constraint(equalTo: topAnchor),
  683. backdrop.bottomAnchor.constraint(equalTo: bottomAnchor),
  684. pattern.leadingAnchor.constraint(equalTo: backdrop.leadingAnchor),
  685. pattern.trailingAnchor.constraint(equalTo: backdrop.trailingAnchor),
  686. pattern.topAnchor.constraint(equalTo: backdrop.topAnchor),
  687. pattern.bottomAnchor.constraint(equalTo: backdrop.bottomAnchor),
  688. paywallView.leadingAnchor.constraint(equalTo: leadingAnchor),
  689. paywallView.trailingAnchor.constraint(equalTo: trailingAnchor),
  690. paywallView.topAnchor.constraint(equalTo: topAnchor),
  691. paywallView.bottomAnchor.constraint(equalTo: bottomAnchor),
  692. ])
  693. }
  694. func present(in parent: NSView) {
  695. guard superview == nil else { return }
  696. if let window = parent.window,
  697. let windowFrameView = window.contentView?.superview {
  698. windowFrameView.addSubview(self)
  699. NSLayoutConstraint.activate([
  700. leadingAnchor.constraint(equalTo: windowFrameView.leadingAnchor),
  701. trailingAnchor.constraint(equalTo: windowFrameView.trailingAnchor),
  702. topAnchor.constraint(equalTo: windowFrameView.topAnchor),
  703. bottomAnchor.constraint(equalTo: windowFrameView.bottomAnchor),
  704. ])
  705. } else {
  706. parent.addSubview(self)
  707. NSLayoutConstraint.activate([
  708. leadingAnchor.constraint(equalTo: parent.leadingAnchor),
  709. trailingAnchor.constraint(equalTo: parent.trailingAnchor),
  710. topAnchor.constraint(equalTo: parent.topAnchor),
  711. bottomAnchor.constraint(equalTo: parent.bottomAnchor),
  712. ])
  713. }
  714. alphaValue = 0
  715. NSAnimationContext.runAnimationGroup { context in
  716. context.duration = 0.2
  717. animator().alphaValue = 1
  718. }
  719. }
  720. func dismiss() {
  721. NSAnimationContext.runAnimationGroup({ context in
  722. context.duration = 0.15
  723. animator().alphaValue = 0
  724. }, completionHandler: { [weak self] in
  725. self?.removeFromSuperview()
  726. self?.onDismiss?()
  727. })
  728. }
  729. }