PaywallView.swift 60 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592
  1. import Cocoa
  2. import StoreKit
  3. // MARK: - Left Panel
  4. private final class PaywallLeftPanelView: NSView, AppearanceRefreshable {
  5. private let gradientLayer = CAGradientLayer()
  6. override init(frame frameRect: NSRect) {
  7. super.init(frame: frameRect)
  8. wantsLayer = true
  9. gradientLayer.startPoint = CGPoint(x: 0.5, y: 1)
  10. gradientLayer.endPoint = CGPoint(x: 0.5, y: 0)
  11. layer?.insertSublayer(gradientLayer, at: 0)
  12. refreshAppearance()
  13. }
  14. func refreshAppearance() {
  15. gradientLayer.colors = AppTheme.paywallLeftGradientColors.map(\.cgColor)
  16. }
  17. @available(*, unavailable)
  18. required init?(coder: NSCoder) { nil }
  19. override func layout() {
  20. super.layout()
  21. gradientLayer.frame = bounds
  22. let mask = CAShapeLayer()
  23. mask.path = CGPath(
  24. roundedRect: bounds,
  25. cornerWidth: 20,
  26. cornerHeight: 20,
  27. transform: nil
  28. )
  29. layer?.mask = mask
  30. }
  31. }
  32. // MARK: - Badge
  33. private final class PaywallBadgeView: NSView {
  34. private let label: NSTextField
  35. init(text: String, iconName: String, background: NSColor, foreground: NSColor) {
  36. label = NSTextField(labelWithString: text)
  37. super.init(frame: .zero)
  38. translatesAutoresizingMaskIntoConstraints = false
  39. wantsLayer = true
  40. layer?.backgroundColor = background.cgColor
  41. layer?.cornerRadius = 10
  42. layer?.masksToBounds = true
  43. let icon = NSImageView()
  44. icon.translatesAutoresizingMaskIntoConstraints = false
  45. if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) {
  46. let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .semibold)
  47. icon.image = image.withSymbolConfiguration(config)
  48. }
  49. icon.contentTintColor = foreground
  50. label.font = AppTheme.semiboldFont(size: 10)
  51. label.textColor = foreground
  52. label.translatesAutoresizingMaskIntoConstraints = false
  53. addSubview(icon)
  54. addSubview(label)
  55. NSLayoutConstraint.activate([
  56. heightAnchor.constraint(equalToConstant: 20),
  57. icon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
  58. icon.centerYAnchor.constraint(equalTo: centerYAnchor),
  59. icon.widthAnchor.constraint(equalToConstant: 12),
  60. icon.heightAnchor.constraint(equalToConstant: 12),
  61. label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 4),
  62. label.centerYAnchor.constraint(equalTo: centerYAnchor),
  63. label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
  64. ])
  65. }
  66. func updateText(_ text: String) {
  67. label.stringValue = text
  68. }
  69. @available(*, unavailable)
  70. required init?(coder: NSCoder) { nil }
  71. }
  72. // MARK: - Feature Row
  73. private final class PaywallFeatureRow: NSView, AppearanceRefreshable {
  74. private let label: NSTextField
  75. init(text: String) {
  76. label = NSTextField.themeLabel(text, style: .primary, font: AppTheme.regularFont(size: 14))
  77. super.init(frame: .zero)
  78. translatesAutoresizingMaskIntoConstraints = false
  79. let checkContainer = NSView()
  80. checkContainer.translatesAutoresizingMaskIntoConstraints = false
  81. checkContainer.wantsLayer = true
  82. checkContainer.layer?.backgroundColor = AppTheme.green.cgColor
  83. checkContainer.layer?.cornerRadius = 10
  84. checkContainer.layer?.masksToBounds = true
  85. let checkIcon = NSImageView()
  86. checkIcon.translatesAutoresizingMaskIntoConstraints = false
  87. if let image = NSImage(systemSymbolName: "checkmark", accessibilityDescription: nil) {
  88. let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .bold)
  89. checkIcon.image = image.withSymbolConfiguration(config)
  90. }
  91. checkIcon.contentTintColor = .white
  92. label.translatesAutoresizingMaskIntoConstraints = false
  93. addSubview(checkContainer)
  94. checkContainer.addSubview(checkIcon)
  95. addSubview(label)
  96. NSLayoutConstraint.activate([
  97. heightAnchor.constraint(equalToConstant: 28),
  98. checkContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
  99. checkContainer.centerYAnchor.constraint(equalTo: centerYAnchor),
  100. checkContainer.widthAnchor.constraint(equalToConstant: 20),
  101. checkContainer.heightAnchor.constraint(equalToConstant: 20),
  102. checkIcon.centerXAnchor.constraint(equalTo: checkContainer.centerXAnchor),
  103. checkIcon.centerYAnchor.constraint(equalTo: checkContainer.centerYAnchor),
  104. checkIcon.widthAnchor.constraint(equalToConstant: 12),
  105. checkIcon.heightAnchor.constraint(equalToConstant: 12),
  106. label.leadingAnchor.constraint(equalTo: checkContainer.trailingAnchor, constant: 12),
  107. label.centerYAnchor.constraint(equalTo: centerYAnchor),
  108. label.trailingAnchor.constraint(equalTo: trailingAnchor),
  109. ])
  110. }
  111. func updateText(_ text: String) {
  112. label.stringValue = text
  113. }
  114. @available(*, unavailable)
  115. required init?(coder: NSCoder) { nil }
  116. func refreshAppearance() {
  117. label.refreshThemeLabelColor()
  118. }
  119. }
  120. // MARK: - Plan Card
  121. private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
  122. var onSelect: (() -> Void)?
  123. private let plan: PaywallPlan
  124. private let titleLabel = NSTextField(labelWithString: "")
  125. private let subtitleLabel = NSTextField(labelWithString: "")
  126. private let priceLabel = NSTextField(labelWithString: "")
  127. private var trialBadgeView: PaywallBadgeView?
  128. private var hoverTracker: HoverTracker?
  129. private var isHovered = false
  130. var isChosen: Bool = false {
  131. didSet { updateAppearance() }
  132. }
  133. init(plan: PaywallPlan) {
  134. self.plan = plan
  135. super.init(frame: .zero)
  136. translatesAutoresizingMaskIntoConstraints = false
  137. wantsLayer = true
  138. layer?.cornerRadius = 12
  139. layer?.masksToBounds = false
  140. let config = PaywallConfigService.shared.config
  141. titleLabel.stringValue = plan.planCopy(from: config).title
  142. titleLabel.font = AppTheme.semiboldFont(size: 15)
  143. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  144. subtitleLabel.stringValue = config.loadingPrice
  145. subtitleLabel.font = AppTheme.regularFont(size: 11)
  146. subtitleLabel.themeLabelStyle = .secondary
  147. subtitleLabel.textColor = AppTheme.textSecondary
  148. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  149. priceLabel.stringValue = config.loadingPrice
  150. priceLabel.font = AppTheme.semiboldFont(size: 15)
  151. priceLabel.alignment = .right
  152. priceLabel.translatesAutoresizingMaskIntoConstraints = false
  153. addSubview(titleLabel)
  154. addSubview(subtitleLabel)
  155. addSubview(priceLabel)
  156. NSLayoutConstraint.activate([
  157. heightAnchor.constraint(equalToConstant: 86),
  158. titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
  159. titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 24),
  160. subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  161. subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 3),
  162. subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: priceLabel.leadingAnchor, constant: -12),
  163. priceLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
  164. priceLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  165. ])
  166. applyCardShadow()
  167. updateAppearance()
  168. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  169. self?.setHovered(hovering)
  170. }
  171. }
  172. @available(*, unavailable)
  173. required init?(coder: NSCoder) { nil }
  174. func updateDisplay(
  175. product: Product?,
  176. config: PaywallConfig,
  177. trial: PaywallTrialDisplay? = nil
  178. ) {
  179. titleLabel.stringValue = plan.planCopy(from: config).title
  180. subtitleLabel.stringValue = plan.localizedSubtitle(from: product, config: config, trial: trial)
  181. priceLabel.stringValue = plan.localizedPrice(from: product, config: config)
  182. updateTrialBadge(trial: trial)
  183. }
  184. private func updateTrialBadge(trial: PaywallTrialDisplay?) {
  185. guard plan == PaywallConfigService.shared.config.trialEligiblePlan else { return }
  186. if let trial {
  187. let text = trial.badgeText
  188. if let trialBadgeView {
  189. trialBadgeView.updateText(text)
  190. trialBadgeView.isHidden = false
  191. } else {
  192. let badge = PaywallBadgeView(
  193. text: text,
  194. iconName: "calendar",
  195. background: AppTheme.paywallPink,
  196. foreground: AppTheme.paywallPinkText
  197. )
  198. trialBadgeView = badge
  199. addSubview(badge, positioned: .above, relativeTo: nil)
  200. NSLayoutConstraint.activate([
  201. badge.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
  202. badge.topAnchor.constraint(equalTo: topAnchor, constant: 8),
  203. ])
  204. }
  205. trialBadgeView?.layer?.zPosition = 10
  206. } else {
  207. trialBadgeView?.isHidden = true
  208. }
  209. }
  210. func refreshAppearance() {
  211. updateAppearance()
  212. subtitleLabel.refreshThemeLabelColor()
  213. if isHovered {
  214. applyHoverLift(true)
  215. }
  216. }
  217. private func setHovered(_ hovering: Bool) {
  218. isHovered = hovering
  219. applyHoverLift(hovering)
  220. updateAppearance()
  221. }
  222. private func updateAppearance() {
  223. let trialPlan = PaywallConfigService.shared.config.trialEligiblePlan
  224. let titleColor = isChosen && plan == trialPlan ? AppTheme.green : AppTheme.paywallAccent
  225. titleLabel.textColor = titleColor
  226. priceLabel.textColor = titleColor
  227. layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor
  228. if isChosen {
  229. layer?.borderWidth = 2
  230. layer?.borderColor = AppTheme.green.cgColor
  231. } else if isHovered {
  232. layer?.borderWidth = 2
  233. let hoverBorder = AppTheme.paywallBorder.blended(withFraction: 0.35, of: AppTheme.paywallAccent)
  234. ?? AppTheme.paywallBorder
  235. layer?.borderColor = hoverBorder.cgColor
  236. } else {
  237. layer?.borderWidth = 1.5
  238. layer?.borderColor = AppTheme.paywallBorder.cgColor
  239. }
  240. }
  241. override func mouseUp(with event: NSEvent) {
  242. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  243. onSelect?()
  244. }
  245. override func resetCursorRects() {
  246. addCursorRect(bounds, cursor: .pointingHand)
  247. }
  248. }
  249. // MARK: - Footer Link
  250. private final class PaywallFooterLink: NSButton, AppearanceRefreshable {
  251. private var hoverTracker: HoverTracker?
  252. private var isHovered = false
  253. init(title: String) {
  254. super.init(frame: .zero)
  255. updateTitle(title)
  256. isBordered = false
  257. bezelStyle = .inline
  258. font = AppTheme.regularFont(size: 11)
  259. translatesAutoresizingMaskIntoConstraints = false
  260. refreshAppearance()
  261. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  262. self?.isHovered = hovering
  263. self?.refreshAppearance()
  264. }
  265. }
  266. @available(*, unavailable)
  267. required init?(coder: NSCoder) { nil }
  268. func updateTitle(_ title: String) {
  269. self.title = title
  270. invalidateIntrinsicContentSize()
  271. needsDisplay = true
  272. }
  273. func refreshAppearance() {
  274. contentTintColor = isHovered ? AppTheme.textPrimary : AppTheme.textSecondary
  275. }
  276. override func resetCursorRects() {
  277. addCursorRect(bounds, cursor: .pointingHand)
  278. }
  279. }
  280. // MARK: - Footer Trust Item
  281. private final class PaywallTrustItemView: NSView, AppearanceRefreshable {
  282. private let iconContainer = NSView()
  283. private let icon = NSImageView()
  284. private let titleLabel: NSTextField
  285. private let subtitleLabel: NSTextField
  286. init(iconName: String, title: String, subtitle: String) {
  287. titleLabel = NSTextField.themeLabel(title, style: .primary, font: AppTheme.semiboldFont(size: 11))
  288. subtitleLabel = NSTextField.themeLabel(subtitle, style: .secondary, font: AppTheme.regularFont(size: 9))
  289. super.init(frame: .zero)
  290. translatesAutoresizingMaskIntoConstraints = false
  291. iconContainer.translatesAutoresizingMaskIntoConstraints = false
  292. iconContainer.wantsLayer = true
  293. iconContainer.layer?.cornerRadius = 10
  294. iconContainer.layer?.masksToBounds = true
  295. icon.translatesAutoresizingMaskIntoConstraints = false
  296. if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) {
  297. let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  298. icon.image = image.withSymbolConfiguration(config)
  299. }
  300. titleLabel.lineBreakMode = .byTruncatingTail
  301. titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  302. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  303. subtitleLabel.lineBreakMode = .byTruncatingTail
  304. subtitleLabel.maximumNumberOfLines = 2
  305. subtitleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  306. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  307. addSubview(iconContainer)
  308. iconContainer.addSubview(icon)
  309. addSubview(titleLabel)
  310. addSubview(subtitleLabel)
  311. NSLayoutConstraint.activate([
  312. heightAnchor.constraint(equalToConstant: Self.preferredHeight),
  313. iconContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
  314. iconContainer.topAnchor.constraint(equalTo: topAnchor),
  315. iconContainer.widthAnchor.constraint(equalToConstant: 20),
  316. iconContainer.heightAnchor.constraint(equalToConstant: 20),
  317. icon.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
  318. icon.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
  319. icon.widthAnchor.constraint(equalToConstant: 12),
  320. icon.heightAnchor.constraint(equalToConstant: 12),
  321. titleLabel.leadingAnchor.constraint(equalTo: iconContainer.trailingAnchor, constant: 8),
  322. titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 1),
  323. titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
  324. subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  325. subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 2),
  326. subtitleLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
  327. subtitleLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
  328. ])
  329. setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  330. setContentHuggingPriority(.defaultLow, for: .horizontal)
  331. setContentHuggingPriority(.required, for: .vertical)
  332. setContentCompressionResistancePriority(.required, for: .vertical)
  333. refreshAppearance()
  334. }
  335. fileprivate static let preferredHeight: CGFloat = 34
  336. func update(iconName: String, title: String, subtitle: String) {
  337. titleLabel.stringValue = title
  338. subtitleLabel.stringValue = subtitle
  339. if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) {
  340. let symbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  341. icon.image = image.withSymbolConfiguration(symbolConfig)
  342. }
  343. }
  344. @available(*, unavailable)
  345. required init?(coder: NSCoder) { nil }
  346. func refreshAppearance() {
  347. iconContainer.layer?.backgroundColor = AppTheme.paywallTrustIconBackground.cgColor
  348. icon.contentTintColor = AppTheme.paywallIconAccent
  349. titleLabel.refreshThemeLabelColor()
  350. subtitleLabel.refreshThemeLabelColor()
  351. }
  352. }
  353. // MARK: - Products Error
  354. private final class PaywallProductsErrorView: NSView, AppearanceRefreshable {
  355. var onRetry: (() -> Void)?
  356. private let messageLabel = NSTextField(wrappingLabelWithString: "")
  357. private let retryButton = PaywallFooterLink(title: "")
  358. init() {
  359. super.init(frame: .zero)
  360. translatesAutoresizingMaskIntoConstraints = false
  361. messageLabel.font = AppTheme.regularFont(size: 12)
  362. messageLabel.textColor = AppTheme.textSecondary
  363. messageLabel.alignment = .center
  364. messageLabel.maximumNumberOfLines = 3
  365. messageLabel.lineBreakMode = .byWordWrapping
  366. messageLabel.cell?.wraps = true
  367. messageLabel.cell?.isScrollable = false
  368. messageLabel.translatesAutoresizingMaskIntoConstraints = false
  369. retryButton.target = self
  370. retryButton.action = #selector(retryTapped)
  371. addSubview(messageLabel)
  372. addSubview(retryButton)
  373. NSLayoutConstraint.activate([
  374. messageLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
  375. messageLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
  376. messageLabel.topAnchor.constraint(equalTo: topAnchor),
  377. retryButton.centerXAnchor.constraint(equalTo: centerXAnchor),
  378. retryButton.topAnchor.constraint(equalTo: messageLabel.bottomAnchor, constant: 8),
  379. retryButton.bottomAnchor.constraint(equalTo: bottomAnchor),
  380. ])
  381. }
  382. @available(*, unavailable)
  383. required init?(coder: NSCoder) { nil }
  384. func update(message: String, isRetrying: Bool) {
  385. let messages = PaywallConfigService.shared.config.messages
  386. messageLabel.stringValue = message
  387. retryButton.isHidden = false
  388. retryButton.isEnabled = !isRetrying
  389. retryButton.updateTitle(isRetrying ? messages.retrying : messages.retry)
  390. }
  391. func refreshAppearance() {
  392. messageLabel.textColor = AppTheme.textSecondary
  393. retryButton.refreshAppearance()
  394. }
  395. @objc private func retryTapped() {
  396. onRetry?()
  397. }
  398. }
  399. // MARK: - Close Button
  400. private final class PaywallCloseButton: NSButton, AppearanceRefreshable {
  401. private var hoverTracker: HoverTracker?
  402. private var isHovered = false
  403. init() {
  404. super.init(frame: .zero)
  405. isBordered = false
  406. bezelStyle = .inline
  407. translatesAutoresizingMaskIntoConstraints = false
  408. wantsLayer = true
  409. layer?.cornerRadius = 15
  410. layer?.borderWidth = 1.5
  411. if let image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close") {
  412. let config = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
  413. self.image = image.withSymbolConfiguration(config)
  414. }
  415. refreshAppearance()
  416. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  417. self?.setHovered(hovering)
  418. }
  419. }
  420. @available(*, unavailable)
  421. required init?(coder: NSCoder) { nil }
  422. func refreshAppearance() {
  423. updateAppearance()
  424. }
  425. private func setHovered(_ hovering: Bool) {
  426. isHovered = hovering
  427. animateHover {
  428. updateAppearance()
  429. layer?.transform = hovering
  430. ? CATransform3DMakeScale(1.06, 1.06, 1)
  431. : CATransform3DIdentity
  432. }
  433. }
  434. private func updateAppearance() {
  435. if isHovered {
  436. layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
  437. layer?.borderColor = AppTheme.paywallAccent.withAlphaComponent(0.45).cgColor
  438. contentTintColor = AppTheme.textPrimary
  439. } else {
  440. layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor
  441. layer?.borderColor = AppTheme.paywallBorder.cgColor
  442. contentTintColor = AppTheme.textSecondary
  443. }
  444. }
  445. override func resetCursorRects() {
  446. addCursorRect(bounds, cursor: .pointingHand)
  447. }
  448. }
  449. // MARK: - CTA Button
  450. private final class PaywallCTAButton: NSButton, AppearanceRefreshable {
  451. private var hoverTracker: HoverTracker?
  452. private let progressIndicator = NSProgressIndicator()
  453. init() {
  454. super.init(frame: .zero)
  455. isBordered = false
  456. wantsLayer = true
  457. layer?.cornerRadius = 12
  458. font = AppTheme.semiboldFont(size: 15)
  459. translatesAutoresizingMaskIntoConstraints = false
  460. progressIndicator.style = .spinning
  461. progressIndicator.controlSize = .small
  462. progressIndicator.isDisplayedWhenStopped = false
  463. progressIndicator.translatesAutoresizingMaskIntoConstraints = false
  464. progressIndicator.isHidden = true
  465. addSubview(progressIndicator)
  466. NSLayoutConstraint.activate([
  467. progressIndicator.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
  468. progressIndicator.centerYAnchor.constraint(equalTo: centerYAnchor),
  469. ])
  470. refreshAppearance()
  471. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  472. self?.setHovered(hovering)
  473. }
  474. }
  475. @available(*, unavailable)
  476. required init?(coder: NSCoder) { nil }
  477. func setShowsProgress(_ showsProgress: Bool) {
  478. progressIndicator.isHidden = !showsProgress
  479. if showsProgress {
  480. progressIndicator.startAnimation(nil)
  481. } else {
  482. progressIndicator.stopAnimation(nil)
  483. }
  484. }
  485. func refreshAppearance() {
  486. layer?.backgroundColor = AppTheme.paywallCTABackground.cgColor
  487. contentTintColor = AppTheme.paywallCTAForeground
  488. }
  489. private func setHovered(_ hovering: Bool) {
  490. let base = AppTheme.paywallCTABackground
  491. let color = hovering ? base.blended(withFraction: 0.12, of: .black) ?? base : base
  492. animateHover {
  493. layer?.backgroundColor = color.cgColor
  494. layer?.transform = hovering
  495. ? CATransform3DMakeScale(1.02, 1.02, 1)
  496. : CATransform3DIdentity
  497. }
  498. }
  499. override func resetCursorRects() {
  500. addCursorRect(bounds, cursor: .pointingHand)
  501. }
  502. }
  503. // MARK: - Main Paywall Card
  504. final class PaywallView: NSView, AppearanceRefreshable {
  505. var onClose: (() -> Void)?
  506. private var selectedPlan: PaywallPlan = .yearly
  507. private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
  508. private let ctaButton = PaywallCTAButton()
  509. private let renewalDisclosureLabel = NSTextField(wrappingLabelWithString: "")
  510. private let securePaymentLabel = NSTextField(labelWithString: "")
  511. private let continueFreePlanLink = PaywallFooterLink(title: "")
  512. private let manageSubscriptionLink = PaywallFooterLink(title: "")
  513. private let restoreLink = PaywallFooterLink(title: "")
  514. private let privacyLink = PaywallFooterLink(title: "")
  515. private let termsLink = PaywallFooterLink(title: "")
  516. private let supportLink = PaywallFooterLink(title: "")
  517. private let premiumCloseButton = PaywallCloseButton()
  518. private var primaryFooterLinkCell: NSView?
  519. private var leftPanelTitle: NSTextField!
  520. private var rightTitle: NSTextField!
  521. private var rightSubtitle: NSTextField!
  522. private var featuresStack: NSStackView!
  523. private var featureRows: [PaywallFeatureRow] = []
  524. private var trustItemViews: [PaywallTrustItemView] = []
  525. private var trustStack: NSStackView!
  526. private var productsErrorView: PaywallProductsErrorView!
  527. private var plansStack: NSStackView!
  528. private var plansStackHeightConstraint: NSLayoutConstraint!
  529. private var plansAreaViewHeightConstraint: NSLayoutConstraint!
  530. private var productsErrorHeightConstraint: NSLayoutConstraint!
  531. private var storeObservers: [NSObjectProtocol] = []
  532. init() {
  533. super.init(frame: .zero)
  534. translatesAutoresizingMaskIntoConstraints = false
  535. wantsLayer = true
  536. layer?.cornerRadius = 0
  537. setup()
  538. observeStoreUpdates()
  539. refreshConfigDisplay()
  540. refreshProductDisplay()
  541. refreshAppearance()
  542. }
  543. deinit {
  544. storeObservers.forEach { NotificationCenter.default.removeObserver($0) }
  545. }
  546. private func observeStoreUpdates() {
  547. let center = NotificationCenter.default
  548. storeObservers = [
  549. center.addObserver(forName: .storeProductsDidUpdate, object: nil, queue: .main) { [weak self] _ in
  550. self?.refreshProductDisplay()
  551. },
  552. center.addObserver(forName: .storeStateDidChange, object: nil, queue: .main) { [weak self] _ in
  553. self?.refreshProductDisplay()
  554. self?.selectTrialEligiblePlanIfAvailable()
  555. },
  556. center.addObserver(forName: .premiumStatusDidChange, object: nil, queue: .main) { [weak self] _ in
  557. self?.refreshProductDisplay()
  558. },
  559. center.addObserver(forName: .paywallConfigDidUpdate, object: nil, queue: .main) { [weak self] _ in
  560. self?.refreshConfigDisplay()
  561. self?.refreshProductDisplay()
  562. },
  563. ]
  564. }
  565. private func selectTrialEligiblePlanIfAvailable() {
  566. let store = StoreManager.shared
  567. let config = paywallConfig
  568. guard let eligiblePlan = config.trialEligiblePlan,
  569. store.trialDisplay(for: eligiblePlan) != nil else {
  570. return
  571. }
  572. selectPlan(eligiblePlan)
  573. }
  574. private var paywallConfig: PaywallConfig {
  575. PaywallConfigService.shared.config
  576. }
  577. private func refreshConfigDisplay() {
  578. let config = paywallConfig
  579. leftPanelTitle?.stringValue = config.leftPanelTitle
  580. rightTitle?.stringValue = config.rightTitle
  581. rightSubtitle?.stringValue = config.rightSubtitle
  582. syncFeatureRows(with: config.features)
  583. syncTrustItems(with: config.trustItems)
  584. continueFreePlanLink.updateTitle(config.footer.continueFree)
  585. manageSubscriptionLink.updateTitle(config.footer.manageSubscription)
  586. restoreLink.updateTitle(config.footer.restorePurchase)
  587. privacyLink.updateTitle(config.footer.privacyPolicy)
  588. termsLink.updateTitle(config.footer.termsOfService)
  589. supportLink.updateTitle(config.footer.support)
  590. refreshSecurePaymentNote()
  591. }
  592. private func syncFeatureRows(with features: [String]) {
  593. guard let featuresStack else { return }
  594. while featureRows.count < features.count {
  595. let row = PaywallFeatureRow(text: "")
  596. featureRows.append(row)
  597. featuresStack.addArrangedSubview(row)
  598. }
  599. while featureRows.count > features.count {
  600. let row = featureRows.removeLast()
  601. featuresStack.removeArrangedSubview(row)
  602. row.removeFromSuperview()
  603. }
  604. for (index, text) in features.enumerated() {
  605. featureRows[index].updateText(text)
  606. }
  607. }
  608. private func syncTrustItems(with items: [PaywallConfig.TrustItem]) {
  609. for (index, item) in items.enumerated() where index < trustItemViews.count {
  610. trustItemViews[index].update(
  611. iconName: item.icon,
  612. title: item.title,
  613. subtitle: item.subtitle
  614. )
  615. }
  616. }
  617. private func refreshProductDisplay() {
  618. let store = StoreManager.shared
  619. let config = paywallConfig
  620. let availablePlans = store.availablePlans
  621. syncPlanCards(with: availablePlans)
  622. for (plan, card) in planCards {
  623. let product = store.product(for: plan)
  624. card.updateDisplay(
  625. product: product,
  626. config: config,
  627. trial: store.trialDisplay(for: plan)
  628. )
  629. }
  630. ensureValidSelectedPlan(from: availablePlans)
  631. refreshPlansLayout(planCount: availablePlans.count)
  632. ctaButton.title = selectedPlan.localizedCTATitle(
  633. from: store.product(for: selectedPlan),
  634. config: config,
  635. trial: store.trialDisplay(for: selectedPlan)
  636. )
  637. updateRenewalDisclosure(
  638. selectedPlan.localizedRenewalDisclosure(
  639. from: store.product(for: selectedPlan),
  640. config: config,
  641. trial: store.trialDisplay(for: selectedPlan)
  642. )
  643. )
  644. refreshTrustItemsForSelectedPlan()
  645. refreshProductsErrorState()
  646. refreshPurchaseState()
  647. refreshSecurePaymentNote()
  648. selectTrialEligiblePlanIfAvailable()
  649. }
  650. private func syncPlanCards(with plans: [PaywallPlan]) {
  651. guard let plansStack else { return }
  652. let removedPlans = planCards.keys.filter { !plans.contains($0) }
  653. for plan in removedPlans {
  654. guard let card = planCards.removeValue(forKey: plan) else { continue }
  655. plansStack.removeArrangedSubview(card)
  656. card.removeFromSuperview()
  657. }
  658. for plan in plans where planCards[plan] == nil {
  659. let card = PaywallPlanCard(plan: plan)
  660. card.isChosen = plan == selectedPlan
  661. card.onSelect = { [weak self] in self?.selectPlan(plan) }
  662. planCards[plan] = card
  663. }
  664. for view in plansStack.arrangedSubviews {
  665. plansStack.removeArrangedSubview(view)
  666. }
  667. for plan in plans {
  668. if let card = planCards[plan] {
  669. plansStack.addArrangedSubview(card)
  670. }
  671. }
  672. }
  673. private func ensureValidSelectedPlan(from availablePlans: [PaywallPlan]) {
  674. guard !availablePlans.isEmpty else { return }
  675. guard !availablePlans.contains(selectedPlan) else { return }
  676. let preferred = paywallConfig.trialEligiblePlan
  677. .flatMap { trialPlan in
  678. availablePlans.contains(trialPlan) && StoreManager.shared.trialDisplay(for: trialPlan) != nil
  679. ? trialPlan
  680. : nil
  681. }
  682. ?? availablePlans.first(where: { $0 == .yearly })
  683. ?? availablePlans[0]
  684. selectPlan(preferred)
  685. }
  686. private func refreshPlansLayout(planCount: Int) {
  687. let height = plansAreaHeight(for: planCount)
  688. plansStackHeightConstraint?.constant = height
  689. plansAreaViewHeightConstraint?.constant = height
  690. }
  691. private func plansAreaHeight(for planCount: Int) -> CGFloat {
  692. let cardHeight: CGFloat = 86
  693. let spacing: CGFloat = 12
  694. let count = CGFloat(max(planCount, 0))
  695. guard count > 0 else { return 0 }
  696. return count * cardHeight + max(0, count - 1) * spacing
  697. }
  698. private func refreshSecurePaymentNote() {
  699. let config = paywallConfig
  700. securePaymentLabel.stringValue = selectedPlan == .lifetime
  701. ? config.lifetimeSecurePaymentNote
  702. : config.securePaymentNote
  703. }
  704. private func refreshProductsErrorState() {
  705. let store = StoreManager.shared
  706. let hasLoadFailure = store.productLoadError != nil && !store.hasAvailablePlans
  707. let isRetrying = hasLoadFailure && store.isLoadingProducts
  708. productsErrorView.isHidden = !hasLoadFailure
  709. plansStack.isHidden = hasLoadFailure || store.availablePlans.isEmpty
  710. let visiblePlanCount = hasLoadFailure ? 0 : store.availablePlans.count
  711. refreshPlansLayout(planCount: visiblePlanCount)
  712. productsErrorHeightConstraint.constant = hasLoadFailure ? productsErrorAreaHeight : 0
  713. if hasLoadFailure, let error = store.productLoadError {
  714. productsErrorView.update(message: error, isRetrying: isRetrying)
  715. }
  716. }
  717. private var productsErrorAreaHeight: CGFloat { 86 }
  718. private func refreshPurchaseState() {
  719. let store = StoreManager.shared
  720. let hasProductLoadFailure = store.productLoadError != nil && !store.hasAvailablePlans
  721. let isProductReady = store.product(for: selectedPlan) != nil
  722. let isBusy = store.isPurchasing || store.isLoadingProducts
  723. let isDisabled = isBusy || !isProductReady || hasProductLoadFailure
  724. ctaButton.isEnabled = !isDisabled
  725. ctaButton.alphaValue = isDisabled ? 0.65 : 1
  726. ctaButton.setShowsProgress(store.purchasingPlan == selectedPlan)
  727. restoreLink.isEnabled = !store.isRestoringPurchases
  728. restoreLink.updateTitle(
  729. store.isRestoringPurchases
  730. ? paywallConfig.messages.retrying
  731. : paywallConfig.footer.restorePurchase
  732. )
  733. refreshPrimaryFooterLink()
  734. }
  735. private func refreshPrimaryFooterLink() {
  736. let store = StoreManager.shared
  737. let showContinue = !store.isPro
  738. let showManage = store.hasActiveSubscription
  739. continueFreePlanLink.isHidden = !showContinue
  740. manageSubscriptionLink.isHidden = !showManage
  741. premiumCloseButton.isHidden = !store.isPro
  742. primaryFooterLinkCell?.isHidden = !showContinue && !showManage
  743. primaryFooterLinkCell?.needsLayout = true
  744. primaryFooterLinkCell?.layoutSubtreeIfNeeded()
  745. }
  746. private func refreshTrustItemsForSelectedPlan() {
  747. guard trustItemViews.count > 1, paywallConfig.trustItems.count > 1 else { return }
  748. let cancelItem = paywallConfig.trustItems[1]
  749. if selectedPlan == .lifetime {
  750. let item = paywallConfig.lifetimeTrustItem
  751. trustItemViews[1].update(
  752. iconName: item.icon,
  753. title: item.title,
  754. subtitle: item.subtitle
  755. )
  756. } else {
  757. trustItemViews[1].update(
  758. iconName: cancelItem.icon,
  759. title: cancelItem.title,
  760. subtitle: cancelItem.subtitle
  761. )
  762. }
  763. }
  764. func refreshStoreState() {
  765. refreshProductDisplay()
  766. }
  767. override func layout() {
  768. super.layout()
  769. let width = renewalDisclosureLabel.bounds.width
  770. guard width > 0, renewalDisclosureLabel.preferredMaxLayoutWidth != width else { return }
  771. renewalDisclosureLabel.preferredMaxLayoutWidth = width
  772. renewalDisclosureLabel.invalidateIntrinsicContentSize()
  773. }
  774. private func updateRenewalDisclosure(_ text: String) {
  775. renewalDisclosureLabel.stringValue = text
  776. renewalDisclosureLabel.invalidateIntrinsicContentSize()
  777. needsLayout = true
  778. }
  779. func refreshAppearance() {
  780. layer?.backgroundColor = AppTheme.paywallBackground.cgColor
  781. leftPanelTitle?.refreshThemeLabelColor()
  782. rightTitle?.refreshThemeLabelColor()
  783. rightSubtitle?.refreshThemeLabelColor()
  784. renewalDisclosureLabel.textColor = AppTheme.textSecondary
  785. securePaymentLabel.textColor = AppTheme.textSecondary
  786. trustStack?.layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor
  787. trustStack?.layer?.borderColor = AppTheme.paywallBorder.cgColor
  788. ctaButton.refreshAppearance()
  789. subviews.forEach { $0.refreshAppearanceRecursively() }
  790. }
  791. @available(*, unavailable)
  792. required init?(coder: NSCoder) { nil }
  793. private func setup() {
  794. let leftPanel = makeLeftPanel()
  795. let rightPanel = makeRightPanel()
  796. premiumCloseButton.target = self
  797. premiumCloseButton.action = #selector(premiumCloseTapped)
  798. premiumCloseButton.toolTip = paywallConfig.closeButtonHelp
  799. premiumCloseButton.isHidden = true
  800. addSubview(leftPanel)
  801. addSubview(rightPanel)
  802. addSubview(premiumCloseButton)
  803. NSLayoutConstraint.activate([
  804. leftPanel.leadingAnchor.constraint(equalTo: leadingAnchor),
  805. leftPanel.topAnchor.constraint(equalTo: topAnchor),
  806. leftPanel.bottomAnchor.constraint(equalTo: bottomAnchor),
  807. leftPanel.widthAnchor.constraint(equalToConstant: 320),
  808. rightPanel.leadingAnchor.constraint(equalTo: leftPanel.trailingAnchor),
  809. rightPanel.trailingAnchor.constraint(equalTo: trailingAnchor),
  810. rightPanel.topAnchor.constraint(equalTo: topAnchor),
  811. rightPanel.bottomAnchor.constraint(equalTo: bottomAnchor),
  812. premiumCloseButton.topAnchor.constraint(equalTo: topAnchor, constant: 16),
  813. premiumCloseButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
  814. premiumCloseButton.widthAnchor.constraint(equalToConstant: 30),
  815. premiumCloseButton.heightAnchor.constraint(equalToConstant: 30),
  816. ])
  817. }
  818. private func makeLeftPanel() -> NSView {
  819. let panel = PaywallLeftPanelView()
  820. panel.translatesAutoresizingMaskIntoConstraints = false
  821. let config = paywallConfig
  822. let title = NSTextField.themeLabel(
  823. config.leftPanelTitle,
  824. style: .primary,
  825. font: AppTheme.semiboldFont(size: 22)
  826. )
  827. title.maximumNumberOfLines = 2
  828. title.translatesAutoresizingMaskIntoConstraints = false
  829. leftPanelTitle = title
  830. let featuresStack = NSStackView()
  831. featuresStack.orientation = .vertical
  832. featuresStack.spacing = 6
  833. featuresStack.alignment = .leading
  834. featuresStack.translatesAutoresizingMaskIntoConstraints = false
  835. self.featuresStack = featuresStack
  836. for feature in config.features {
  837. let row = PaywallFeatureRow(text: feature)
  838. featureRows.append(row)
  839. featuresStack.addArrangedSubview(row)
  840. }
  841. panel.addSubview(title)
  842. panel.addSubview(featuresStack)
  843. NSLayoutConstraint.activate([
  844. title.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  845. title.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -20),
  846. title.topAnchor.constraint(equalTo: panel.topAnchor, constant: 48),
  847. featuresStack.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  848. featuresStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -20),
  849. featuresStack.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 28),
  850. ])
  851. return panel
  852. }
  853. private func makeRightPanel() -> NSView {
  854. let panel = NSView()
  855. panel.translatesAutoresizingMaskIntoConstraints = false
  856. let config = paywallConfig
  857. let title = NSTextField.themeLabel(
  858. config.rightTitle,
  859. style: .primary,
  860. font: AppTheme.semiboldFont(size: 26)
  861. )
  862. title.alignment = .center
  863. title.translatesAutoresizingMaskIntoConstraints = false
  864. rightTitle = title
  865. let subtitle = NSTextField.themeLabel(
  866. config.rightSubtitle,
  867. style: .secondary,
  868. font: AppTheme.regularFont(size: 13)
  869. )
  870. subtitle.alignment = .center
  871. subtitle.maximumNumberOfLines = 2
  872. subtitle.translatesAutoresizingMaskIntoConstraints = false
  873. rightSubtitle = subtitle
  874. let plansStack = NSStackView()
  875. plansStack.orientation = .vertical
  876. plansStack.spacing = 12
  877. plansStack.translatesAutoresizingMaskIntoConstraints = false
  878. self.plansStack = plansStack
  879. let plansAreaView = NSView()
  880. plansAreaView.translatesAutoresizingMaskIntoConstraints = false
  881. let productsErrorView = PaywallProductsErrorView()
  882. productsErrorView.isHidden = true
  883. productsErrorView.onRetry = { [weak self] in
  884. self?.retryProductLoad()
  885. }
  886. self.productsErrorView = productsErrorView
  887. ctaButton.title = selectedPlan.localizedCTATitle(
  888. from: StoreManager.shared.product(for: selectedPlan),
  889. config: config,
  890. trial: StoreManager.shared.trialDisplay(for: selectedPlan)
  891. )
  892. ctaButton.target = self
  893. ctaButton.action = #selector(purchaseTapped)
  894. ctaButton.translatesAutoresizingMaskIntoConstraints = false
  895. renewalDisclosureLabel.font = AppTheme.regularFont(size: 10)
  896. renewalDisclosureLabel.textColor = AppTheme.textSecondary
  897. renewalDisclosureLabel.alignment = .center
  898. renewalDisclosureLabel.maximumNumberOfLines = 3
  899. renewalDisclosureLabel.lineBreakMode = .byWordWrapping
  900. renewalDisclosureLabel.cell?.wraps = true
  901. renewalDisclosureLabel.cell?.isScrollable = false
  902. renewalDisclosureLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  903. renewalDisclosureLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
  904. renewalDisclosureLabel.setContentHuggingPriority(.required, for: .vertical)
  905. renewalDisclosureLabel.translatesAutoresizingMaskIntoConstraints = false
  906. updateRenewalDisclosure(
  907. selectedPlan.localizedRenewalDisclosure(
  908. from: StoreManager.shared.product(for: selectedPlan),
  909. config: config,
  910. trial: StoreManager.shared.trialDisplay(for: selectedPlan)
  911. )
  912. )
  913. securePaymentLabel.font = AppTheme.regularFont(size: 10)
  914. securePaymentLabel.textColor = AppTheme.textSecondary
  915. securePaymentLabel.alignment = .center
  916. securePaymentLabel.translatesAutoresizingMaskIntoConstraints = false
  917. refreshSecurePaymentNote()
  918. let trustRow = makeTrustRow()
  919. let footerLinks = makeFooterLinks()
  920. let footerSpacer = NSView()
  921. footerSpacer.translatesAutoresizingMaskIntoConstraints = false
  922. footerSpacer.setContentHuggingPriority(.defaultLow, for: .vertical)
  923. footerSpacer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
  924. plansAreaView.addSubview(plansStack)
  925. plansAreaView.addSubview(productsErrorView)
  926. plansStackHeightConstraint = plansStack.heightAnchor.constraint(equalToConstant: 0)
  927. productsErrorHeightConstraint = productsErrorView.heightAnchor.constraint(equalToConstant: 0)
  928. plansAreaViewHeightConstraint = plansAreaView.heightAnchor.constraint(equalToConstant: 0)
  929. panel.addSubview(title)
  930. panel.addSubview(subtitle)
  931. panel.addSubview(plansAreaView)
  932. panel.addSubview(trustRow)
  933. panel.addSubview(ctaButton)
  934. panel.addSubview(renewalDisclosureLabel)
  935. panel.addSubview(securePaymentLabel)
  936. panel.addSubview(footerSpacer)
  937. panel.addSubview(footerLinks)
  938. NSLayoutConstraint.activate([
  939. title.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  940. title.topAnchor.constraint(equalTo: panel.topAnchor, constant: 40),
  941. title.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  942. subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  943. subtitle.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  944. subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6),
  945. plansAreaView.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  946. plansAreaView.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  947. plansAreaView.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 24),
  948. plansAreaViewHeightConstraint,
  949. plansStack.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor),
  950. plansStack.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor),
  951. plansStack.topAnchor.constraint(equalTo: plansAreaView.topAnchor),
  952. plansStackHeightConstraint,
  953. productsErrorView.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor),
  954. productsErrorView.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor),
  955. productsErrorView.topAnchor.constraint(equalTo: plansAreaView.topAnchor),
  956. productsErrorHeightConstraint,
  957. trustRow.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor),
  958. trustRow.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor),
  959. trustRow.topAnchor.constraint(equalTo: plansAreaView.bottomAnchor, constant: 18),
  960. ctaButton.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor),
  961. ctaButton.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor),
  962. ctaButton.topAnchor.constraint(equalTo: trustRow.bottomAnchor, constant: 16),
  963. ctaButton.heightAnchor.constraint(equalToConstant: 48),
  964. renewalDisclosureLabel.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor),
  965. renewalDisclosureLabel.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor),
  966. renewalDisclosureLabel.topAnchor.constraint(equalTo: ctaButton.bottomAnchor, constant: 10),
  967. securePaymentLabel.centerXAnchor.constraint(equalTo: plansAreaView.centerXAnchor),
  968. securePaymentLabel.topAnchor.constraint(equalTo: renewalDisclosureLabel.bottomAnchor, constant: 8),
  969. footerSpacer.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
  970. footerSpacer.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
  971. footerSpacer.topAnchor.constraint(equalTo: securePaymentLabel.bottomAnchor, constant: 16),
  972. footerSpacer.bottomAnchor.constraint(equalTo: footerLinks.topAnchor),
  973. footerLinks.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  974. footerLinks.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  975. footerLinks.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -16),
  976. ])
  977. return panel
  978. }
  979. private func makeTrustRow() -> NSView {
  980. let config = paywallConfig
  981. trustItemViews = config.trustItems.map { item in
  982. PaywallTrustItemView(
  983. iconName: item.icon,
  984. title: item.title,
  985. subtitle: item.subtitle
  986. )
  987. }
  988. let trustStack = NSStackView(views: trustItemViews)
  989. trustStack.orientation = .horizontal
  990. trustStack.distribution = .fillEqually
  991. trustStack.spacing = 16
  992. trustStack.alignment = .top
  993. trustStack.translatesAutoresizingMaskIntoConstraints = false
  994. trustStack.wantsLayer = true
  995. trustStack.layer?.cornerRadius = 12
  996. trustStack.layer?.borderWidth = 1.5
  997. trustStack.layer?.masksToBounds = true
  998. trustStack.edgeInsets = NSEdgeInsets(top: 12, left: 14, bottom: 12, right: 14)
  999. trustStack.translatesAutoresizingMaskIntoConstraints = false
  1000. trustStack.setContentHuggingPriority(.required, for: .vertical)
  1001. trustStack.setContentCompressionResistancePriority(.required, for: .vertical)
  1002. trustStack.heightAnchor.constraint(
  1003. equalToConstant: PaywallTrustItemView.preferredHeight + trustStack.edgeInsets.top + trustStack.edgeInsets.bottom
  1004. ).isActive = true
  1005. self.trustStack = trustStack
  1006. return trustStack
  1007. }
  1008. private func makeFooterLinks() -> NSView {
  1009. let container = NSView()
  1010. container.translatesAutoresizingMaskIntoConstraints = false
  1011. container.setContentHuggingPriority(.required, for: .vertical)
  1012. container.setContentCompressionResistancePriority(.required, for: .vertical)
  1013. let config = paywallConfig
  1014. let separator = NSView()
  1015. separator.translatesAutoresizingMaskIntoConstraints = false
  1016. separator.wantsLayer = true
  1017. separator.layer?.backgroundColor = AppTheme.paywallBorder.cgColor
  1018. continueFreePlanLink.updateTitle(config.footer.continueFree)
  1019. continueFreePlanLink.target = self
  1020. continueFreePlanLink.action = #selector(continueWithFreePlanTapped)
  1021. manageSubscriptionLink.updateTitle(config.footer.manageSubscription)
  1022. manageSubscriptionLink.target = self
  1023. manageSubscriptionLink.action = #selector(manageSubscriptionTapped)
  1024. let primaryCell = makePrimaryFooterLinkCell()
  1025. primaryFooterLinkCell = primaryCell
  1026. refreshPrimaryFooterLink()
  1027. restoreLink.updateTitle(config.footer.restorePurchase)
  1028. restoreLink.target = self
  1029. restoreLink.action = #selector(restoreTapped)
  1030. privacyLink.updateTitle(config.footer.privacyPolicy)
  1031. privacyLink.target = self
  1032. privacyLink.action = #selector(privacyPolicyTapped)
  1033. termsLink.updateTitle(config.footer.termsOfService)
  1034. termsLink.target = self
  1035. termsLink.action = #selector(termsOfServiceTapped)
  1036. supportLink.updateTitle(config.footer.support)
  1037. supportLink.target = self
  1038. supportLink.action = #selector(supportTapped)
  1039. let linkCells = [
  1040. primaryCell,
  1041. makeFooterLinkCell(link: restoreLink),
  1042. makeFooterLinkCell(link: privacyLink),
  1043. makeFooterLinkCell(link: termsLink),
  1044. makeFooterLinkCell(link: supportLink),
  1045. ]
  1046. let linksStack = NSStackView(views: linkCells)
  1047. linksStack.orientation = .horizontal
  1048. linksStack.spacing = 0
  1049. linksStack.alignment = .centerY
  1050. linksStack.distribution = .fillEqually
  1051. linksStack.translatesAutoresizingMaskIntoConstraints = false
  1052. container.addSubview(separator)
  1053. container.addSubview(linksStack)
  1054. NSLayoutConstraint.activate([
  1055. separator.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  1056. separator.trailingAnchor.constraint(equalTo: container.trailingAnchor),
  1057. separator.topAnchor.constraint(equalTo: container.topAnchor),
  1058. separator.heightAnchor.constraint(equalToConstant: 1),
  1059. linksStack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  1060. linksStack.trailingAnchor.constraint(equalTo: container.trailingAnchor),
  1061. linksStack.topAnchor.constraint(equalTo: separator.bottomAnchor, constant: 10),
  1062. linksStack.bottomAnchor.constraint(equalTo: container.bottomAnchor),
  1063. ])
  1064. return container
  1065. }
  1066. private func makePrimaryFooterLinkCell() -> NSView {
  1067. let cell = NSView()
  1068. cell.translatesAutoresizingMaskIntoConstraints = false
  1069. cell.setContentHuggingPriority(.required, for: .vertical)
  1070. cell.setContentCompressionResistancePriority(.required, for: .vertical)
  1071. for link in [continueFreePlanLink, manageSubscriptionLink] {
  1072. cell.addSubview(link)
  1073. NSLayoutConstraint.activate([
  1074. link.centerXAnchor.constraint(equalTo: cell.centerXAnchor),
  1075. link.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
  1076. link.topAnchor.constraint(equalTo: cell.topAnchor),
  1077. link.bottomAnchor.constraint(equalTo: cell.bottomAnchor),
  1078. ])
  1079. }
  1080. return cell
  1081. }
  1082. private func makeFooterLinkCell(link: PaywallFooterLink) -> NSView {
  1083. let cell = NSView()
  1084. cell.translatesAutoresizingMaskIntoConstraints = false
  1085. cell.setContentHuggingPriority(.required, for: .vertical)
  1086. cell.setContentCompressionResistancePriority(.required, for: .vertical)
  1087. cell.addSubview(link)
  1088. NSLayoutConstraint.activate([
  1089. link.centerXAnchor.constraint(equalTo: cell.centerXAnchor),
  1090. link.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
  1091. link.topAnchor.constraint(equalTo: cell.topAnchor),
  1092. link.bottomAnchor.constraint(equalTo: cell.bottomAnchor),
  1093. ])
  1094. return cell
  1095. }
  1096. private func selectPlan(_ plan: PaywallPlan) {
  1097. selectedPlan = plan
  1098. for (key, card) in planCards {
  1099. card.isChosen = key == plan
  1100. }
  1101. ctaButton.title = plan.localizedCTATitle(
  1102. from: StoreManager.shared.product(for: plan),
  1103. config: paywallConfig,
  1104. trial: StoreManager.shared.trialDisplay(for: plan)
  1105. )
  1106. updateRenewalDisclosure(
  1107. plan.localizedRenewalDisclosure(
  1108. from: StoreManager.shared.product(for: plan),
  1109. config: paywallConfig,
  1110. trial: StoreManager.shared.trialDisplay(for: plan)
  1111. )
  1112. )
  1113. refreshTrustItemsForSelectedPlan()
  1114. refreshPurchaseState()
  1115. refreshSecurePaymentNote()
  1116. }
  1117. private func retryProductLoad() {
  1118. Task { @MainActor in
  1119. refreshProductsErrorState()
  1120. refreshPurchaseState()
  1121. await StoreManager.shared.loadProducts(presentLoadingUI: true)
  1122. refreshStoreState()
  1123. }
  1124. }
  1125. @objc private func purchaseTapped() {
  1126. Task { @MainActor in
  1127. StoreManager.shared.clearPurchaseError()
  1128. refreshPurchaseState()
  1129. let purchased = await StoreManager.shared.purchase(plan: selectedPlan)
  1130. refreshStoreState()
  1131. if purchased {
  1132. onClose?()
  1133. } else if let error = StoreManager.shared.purchaseError {
  1134. StoreManager.shared.showPurchaseError(error, on: window)
  1135. }
  1136. }
  1137. }
  1138. @objc private func restoreTapped() {
  1139. Task { @MainActor in
  1140. StoreManager.shared.clearPurchaseError()
  1141. refreshPurchaseState()
  1142. let restored = await StoreManager.shared.restorePurchases()
  1143. refreshStoreState()
  1144. if restored {
  1145. onClose?()
  1146. } else if let error = StoreManager.shared.purchaseError {
  1147. StoreManager.shared.showPurchaseError(error, on: window)
  1148. }
  1149. }
  1150. }
  1151. @objc private func continueWithFreePlanTapped() {
  1152. onClose?()
  1153. }
  1154. @objc private func premiumCloseTapped() {
  1155. onClose?()
  1156. }
  1157. @objc private func manageSubscriptionTapped() {
  1158. StoreManager.shared.showManageSubscriptions()
  1159. }
  1160. @objc private func privacyPolicyTapped() {
  1161. openExternalLink(paywallConfig.urls.privacy)
  1162. }
  1163. @objc private func termsOfServiceTapped() {
  1164. openExternalLink(paywallConfig.urls.terms)
  1165. }
  1166. @objc private func supportTapped() {
  1167. openExternalLink(paywallConfig.urls.support)
  1168. }
  1169. private func openExternalLink(_ urlString: String) {
  1170. guard let url = URL(string: urlString) else { return }
  1171. NSWorkspace.shared.open(url)
  1172. }
  1173. }
  1174. // MARK: - Overlay Presenter
  1175. final class PaywallOverlayView: NSView, AppearanceRefreshable {
  1176. var onDismiss: (() -> Void)?
  1177. private let paywallView: PaywallView
  1178. private let blurView = NSVisualEffectView()
  1179. private let backdrop = NSView()
  1180. private let pattern = WavePatternView()
  1181. private let loadingOverlay = NSView()
  1182. private let loadingIndicator = NSProgressIndicator()
  1183. private var acceptsHitTesting = false
  1184. private var storeObservers: [NSObjectProtocol] = []
  1185. init() {
  1186. paywallView = PaywallView()
  1187. super.init(frame: .zero)
  1188. translatesAutoresizingMaskIntoConstraints = false
  1189. setup()
  1190. observeStoreUpdates()
  1191. refreshAppearance()
  1192. refreshLoadingOverlay()
  1193. }
  1194. deinit {
  1195. storeObservers.forEach { NotificationCenter.default.removeObserver($0) }
  1196. }
  1197. private func observeStoreUpdates() {
  1198. let center = NotificationCenter.default
  1199. storeObservers = [
  1200. center.addObserver(forName: .storeStateDidChange, object: nil, queue: .main) { [weak self] _ in
  1201. self?.refreshLoadingOverlay()
  1202. },
  1203. center.addObserver(forName: .storeProductsDidUpdate, object: nil, queue: .main) { [weak self] _ in
  1204. self?.refreshLoadingOverlay()
  1205. },
  1206. ]
  1207. }
  1208. private func refreshLoadingOverlay() {
  1209. let store = StoreManager.shared
  1210. let shouldShow = store.isLoadingProducts && !store.hasAvailablePlans && store.productLoadError == nil
  1211. loadingOverlay.isHidden = !shouldShow
  1212. if shouldShow {
  1213. loadingIndicator.startAnimation(nil)
  1214. } else {
  1215. loadingIndicator.stopAnimation(nil)
  1216. }
  1217. }
  1218. func refreshAppearance() {
  1219. backdrop.layer?.backgroundColor = AppTheme.paywallOverlayBackdrop.cgColor
  1220. blurView.material = AppSettings.darkModeEnabled ? .hudWindow : .underWindowBackground
  1221. pattern.refreshAppearance()
  1222. paywallView.refreshAppearance()
  1223. }
  1224. func refreshStoreState() {
  1225. paywallView.refreshStoreState()
  1226. refreshLoadingOverlay()
  1227. }
  1228. @available(*, unavailable)
  1229. required init?(coder: NSCoder) { nil }
  1230. override func hitTest(_ point: NSPoint) -> NSView? {
  1231. guard acceptsHitTesting else { return nil }
  1232. return super.hitTest(point)
  1233. }
  1234. private func setup() {
  1235. blurView.translatesAutoresizingMaskIntoConstraints = false
  1236. blurView.material = .underWindowBackground
  1237. blurView.blendingMode = .withinWindow
  1238. blurView.state = .active
  1239. backdrop.translatesAutoresizingMaskIntoConstraints = false
  1240. backdrop.wantsLayer = true
  1241. backdrop.layer?.backgroundColor = NSColor(calibratedWhite: 0.15, alpha: 0.22).cgColor
  1242. pattern.translatesAutoresizingMaskIntoConstraints = false
  1243. pattern.alphaValue = 0.35
  1244. paywallView.translatesAutoresizingMaskIntoConstraints = false
  1245. paywallView.onClose = { [weak self] in self?.dismiss() }
  1246. loadingOverlay.translatesAutoresizingMaskIntoConstraints = false
  1247. loadingOverlay.wantsLayer = true
  1248. loadingOverlay.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.08).cgColor
  1249. loadingOverlay.isHidden = true
  1250. loadingIndicator.style = .spinning
  1251. loadingIndicator.controlSize = .large
  1252. loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
  1253. loadingOverlay.addSubview(loadingIndicator)
  1254. addSubview(blurView)
  1255. addSubview(backdrop)
  1256. backdrop.addSubview(pattern)
  1257. addSubview(paywallView)
  1258. addSubview(loadingOverlay)
  1259. NSLayoutConstraint.activate([
  1260. blurView.leadingAnchor.constraint(equalTo: leadingAnchor),
  1261. blurView.trailingAnchor.constraint(equalTo: trailingAnchor),
  1262. blurView.topAnchor.constraint(equalTo: topAnchor),
  1263. blurView.bottomAnchor.constraint(equalTo: bottomAnchor),
  1264. backdrop.leadingAnchor.constraint(equalTo: leadingAnchor),
  1265. backdrop.trailingAnchor.constraint(equalTo: trailingAnchor),
  1266. backdrop.topAnchor.constraint(equalTo: topAnchor),
  1267. backdrop.bottomAnchor.constraint(equalTo: bottomAnchor),
  1268. pattern.leadingAnchor.constraint(equalTo: backdrop.leadingAnchor),
  1269. pattern.trailingAnchor.constraint(equalTo: backdrop.trailingAnchor),
  1270. pattern.topAnchor.constraint(equalTo: backdrop.topAnchor),
  1271. pattern.bottomAnchor.constraint(equalTo: backdrop.bottomAnchor),
  1272. paywallView.leadingAnchor.constraint(equalTo: leadingAnchor),
  1273. paywallView.trailingAnchor.constraint(equalTo: trailingAnchor),
  1274. paywallView.topAnchor.constraint(equalTo: topAnchor),
  1275. paywallView.bottomAnchor.constraint(equalTo: bottomAnchor),
  1276. loadingOverlay.leadingAnchor.constraint(equalTo: leadingAnchor),
  1277. loadingOverlay.trailingAnchor.constraint(equalTo: trailingAnchor),
  1278. loadingOverlay.topAnchor.constraint(equalTo: topAnchor),
  1279. loadingOverlay.bottomAnchor.constraint(equalTo: bottomAnchor),
  1280. loadingIndicator.centerXAnchor.constraint(equalTo: loadingOverlay.centerXAnchor),
  1281. loadingIndicator.centerYAnchor.constraint(equalTo: loadingOverlay.centerYAnchor),
  1282. ])
  1283. }
  1284. func present(in parent: NSView) {
  1285. guard superview == nil else { return }
  1286. if let window = parent.window,
  1287. let windowFrameView = window.contentView?.superview {
  1288. windowFrameView.addSubview(self)
  1289. NSLayoutConstraint.activate([
  1290. leadingAnchor.constraint(equalTo: windowFrameView.leadingAnchor),
  1291. trailingAnchor.constraint(equalTo: windowFrameView.trailingAnchor),
  1292. topAnchor.constraint(equalTo: windowFrameView.topAnchor),
  1293. bottomAnchor.constraint(equalTo: windowFrameView.bottomAnchor),
  1294. ])
  1295. } else {
  1296. parent.addSubview(self)
  1297. NSLayoutConstraint.activate([
  1298. leadingAnchor.constraint(equalTo: parent.leadingAnchor),
  1299. trailingAnchor.constraint(equalTo: parent.trailingAnchor),
  1300. topAnchor.constraint(equalTo: parent.topAnchor),
  1301. bottomAnchor.constraint(equalTo: parent.bottomAnchor),
  1302. ])
  1303. }
  1304. alphaValue = 1
  1305. acceptsHitTesting = true
  1306. paywallView.refreshStoreState()
  1307. Task { @MainActor in
  1308. await StoreManager.shared.ensurePaywallStoreDataLoaded()
  1309. paywallView.refreshStoreState()
  1310. refreshLoadingOverlay()
  1311. }
  1312. Task { @MainActor in
  1313. await PaywallConfigService.shared.refreshFromRemote()
  1314. paywallView.refreshStoreState()
  1315. }
  1316. }
  1317. func dismiss() {
  1318. acceptsHitTesting = false
  1319. NSAnimationContext.runAnimationGroup({ context in
  1320. context.duration = 0.15
  1321. animator().alphaValue = 0
  1322. }, completionHandler: { [weak self] in
  1323. self?.removeFromSuperview()
  1324. self?.onDismiss?()
  1325. })
  1326. }
  1327. }