Sin descripción

PremiumPlansWindowController.swift 42KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049
  1. import Cocoa
  2. import StoreKit
  3. final class PremiumPlansWindowController: NSWindowController {
  4. /// Matches `PremiumPlansViewController.Theme.pageStart` so the window backing fills sheet corners.
  5. static let paywallSheetBackground = NSColor(srgbRed: 249 / 255, green: 252 / 255, blue: 255 / 255, alpha: 1)
  6. init() {
  7. let viewController = PremiumPlansViewController()
  8. let window = NSWindow(contentViewController: viewController)
  9. window.title = "Premium Plans"
  10. // Borderless avoids titled-window chrome: its rounded titlebar frame often leaves dark wedges at
  11. // the corners when combined with a custom full-bleed paywall (this window is only shown as a sheet).
  12. window.styleMask = [.borderless, .closable, .resizable]
  13. window.isOpaque = true
  14. window.backgroundColor = Self.paywallSheetBackground
  15. window.setContentSize(NSSize(width: 1160, height: 760))
  16. window.minSize = NSSize(width: 980, height: 680)
  17. window.isRestorable = false
  18. window.center()
  19. super.init(window: window)
  20. }
  21. @available(*, unavailable)
  22. required init?(coder: NSCoder) {
  23. nil
  24. }
  25. }
  26. private final class PremiumPlansViewController: NSViewController {
  27. private final class HoverPricingCardView: NSView {
  28. private let baseBorderColor: NSColor
  29. private let hoverBorderColor: NSColor
  30. private var trackingAreaRef: NSTrackingArea?
  31. init(baseBorderColor: NSColor, hoverBorderColor: NSColor) {
  32. self.baseBorderColor = baseBorderColor
  33. self.hoverBorderColor = hoverBorderColor
  34. super.init(frame: .zero)
  35. wantsLayer = true
  36. layer?.cornerRadius = 16
  37. applyHoverStyle(isHovered: false, animated: false)
  38. }
  39. @available(*, unavailable)
  40. required init?(coder: NSCoder) {
  41. nil
  42. }
  43. override func updateTrackingAreas() {
  44. super.updateTrackingAreas()
  45. if let trackingAreaRef {
  46. removeTrackingArea(trackingAreaRef)
  47. }
  48. let options: NSTrackingArea.Options = [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect]
  49. let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
  50. addTrackingArea(area)
  51. trackingAreaRef = area
  52. }
  53. override func mouseEntered(with event: NSEvent) {
  54. super.mouseEntered(with: event)
  55. applyHoverStyle(isHovered: true, animated: true)
  56. }
  57. override func mouseExited(with event: NSEvent) {
  58. super.mouseExited(with: event)
  59. applyHoverStyle(isHovered: false, animated: true)
  60. }
  61. private func applyHoverStyle(isHovered: Bool, animated: Bool) {
  62. guard let layer else { return }
  63. let updates = {
  64. layer.borderWidth = isHovered ? 2 : 1
  65. layer.borderColor = (isHovered ? self.hoverBorderColor : self.baseBorderColor).cgColor
  66. layer.shadowColor = self.hoverBorderColor.withAlphaComponent(0.35).cgColor
  67. layer.shadowOpacity = isHovered ? 0.22 : 0
  68. layer.shadowRadius = isHovered ? 14 : 0
  69. layer.shadowOffset = .init(width: 0, height: -2)
  70. }
  71. if animated {
  72. NSAnimationContext.runAnimationGroup { context in
  73. context.duration = 0.16
  74. updates()
  75. }
  76. } else {
  77. updates()
  78. }
  79. }
  80. }
  81. /// Footer text actions: accent color + pointing hand on hover (matches pricing-card tracking behavior).
  82. private final class FooterLinkButton: NSButton {
  83. private var trackingAreaRef: NSTrackingArea?
  84. private var didPushCursor = false
  85. override func updateTrackingAreas() {
  86. super.updateTrackingAreas()
  87. if let trackingAreaRef {
  88. removeTrackingArea(trackingAreaRef)
  89. }
  90. let options: NSTrackingArea.Options = [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect]
  91. let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
  92. addTrackingArea(area)
  93. trackingAreaRef = area
  94. }
  95. override func mouseEntered(with event: NSEvent) {
  96. super.mouseEntered(with: event)
  97. setHoverVisuals(hovered: true)
  98. if !didPushCursor {
  99. NSCursor.pointingHand.push()
  100. didPushCursor = true
  101. }
  102. }
  103. override func mouseExited(with event: NSEvent) {
  104. super.mouseExited(with: event)
  105. setHoverVisuals(hovered: false)
  106. if didPushCursor {
  107. NSCursor.pop()
  108. didPushCursor = false
  109. }
  110. }
  111. override func viewWillMove(toWindow newWindow: NSWindow?) {
  112. super.viewWillMove(toWindow: newWindow)
  113. if newWindow == nil, didPushCursor {
  114. NSCursor.pop()
  115. didPushCursor = false
  116. }
  117. if newWindow == nil {
  118. setHoverVisuals(hovered: false, animated: false)
  119. }
  120. }
  121. private func setHoverVisuals(hovered: Bool, animated: Bool = true) {
  122. let color = hovered ? Theme.accent : Theme.secondaryText
  123. if animated {
  124. NSAnimationContext.runAnimationGroup { context in
  125. context.duration = 0.15
  126. self.animator().contentTintColor = color
  127. }
  128. } else {
  129. contentTintColor = color
  130. }
  131. }
  132. }
  133. /// Purchase CTAs: fill/border/shadow + pointing hand on hover.
  134. private final class PlanPurchaseHoverButton: NSButton {
  135. private var trackingAreaRef: NSTrackingArea?
  136. private var didPushCursor = false
  137. private let isPrimaryStyle: Bool
  138. private static let primaryFill = NSColor(srgbRed: 189 / 255, green: 52 / 255, blue: 255 / 255, alpha: 1)
  139. private static let primaryFillHover = NSColor(srgbRed: 205 / 255, green: 88 / 255, blue: 255 / 255, alpha: 1)
  140. init(planId: String, title: String, isPrimaryStyle: Bool, target: AnyObject?, action: Selector) {
  141. self.isPrimaryStyle = isPrimaryStyle
  142. super.init(frame: .zero)
  143. identifier = NSUserInterfaceItemIdentifier(planId)
  144. self.title = title
  145. self.target = target
  146. self.action = action
  147. isBordered = false
  148. bezelStyle = .rounded
  149. font = .systemFont(ofSize: 14, weight: .semibold)
  150. wantsLayer = true
  151. layer?.cornerRadius = 12
  152. focusRingType = .none
  153. translatesAutoresizingMaskIntoConstraints = false
  154. applyBaseStyle(hovered: false)
  155. }
  156. @available(*, unavailable)
  157. required init?(coder: NSCoder) {
  158. nil
  159. }
  160. override var isEnabled: Bool {
  161. didSet {
  162. if !isEnabled {
  163. applyBaseStyle(hovered: false, animated: true)
  164. if didPushCursor {
  165. NSCursor.pop()
  166. didPushCursor = false
  167. }
  168. }
  169. }
  170. }
  171. override func updateTrackingAreas() {
  172. super.updateTrackingAreas()
  173. if let trackingAreaRef {
  174. removeTrackingArea(trackingAreaRef)
  175. }
  176. let options: NSTrackingArea.Options = [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect]
  177. let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
  178. addTrackingArea(area)
  179. trackingAreaRef = area
  180. }
  181. override func mouseEntered(with event: NSEvent) {
  182. super.mouseEntered(with: event)
  183. guard isEnabled else { return }
  184. applyBaseStyle(hovered: true, animated: true)
  185. if !didPushCursor {
  186. NSCursor.pointingHand.push()
  187. didPushCursor = true
  188. }
  189. }
  190. override func mouseExited(with event: NSEvent) {
  191. super.mouseExited(with: event)
  192. applyBaseStyle(hovered: false, animated: true)
  193. if didPushCursor {
  194. NSCursor.pop()
  195. didPushCursor = false
  196. }
  197. }
  198. override func viewWillMove(toWindow newWindow: NSWindow?) {
  199. super.viewWillMove(toWindow: newWindow)
  200. if newWindow == nil, didPushCursor {
  201. NSCursor.pop()
  202. didPushCursor = false
  203. }
  204. if newWindow == nil {
  205. applyBaseStyle(hovered: false, animated: false)
  206. }
  207. }
  208. private func applyBaseStyle(hovered: Bool, animated: Bool = true) {
  209. let updates = {
  210. if self.isPrimaryStyle {
  211. self.layer?.backgroundColor = (hovered ? Self.primaryFillHover : Self.primaryFill).cgColor
  212. self.layer?.borderColor = Theme.accent.cgColor
  213. self.layer?.borderWidth = hovered ? 2 : 1
  214. self.contentTintColor = .white
  215. self.layer?.shadowColor = Self.primaryFill.cgColor
  216. self.layer?.shadowOpacity = hovered ? 0.28 : 0
  217. self.layer?.shadowRadius = hovered ? 12 : 0
  218. self.layer?.shadowOffset = CGSize(width: 0, height: -2)
  219. } else {
  220. let baseFill = Theme.mutedButtonFill
  221. let hoverFill = baseFill.blended(withFraction: 0.22, of: Theme.accent) ?? baseFill
  222. self.layer?.backgroundColor = (hovered ? hoverFill : baseFill).cgColor
  223. self.layer?.borderColor = (hovered ? Theme.accent : Theme.divider).cgColor
  224. self.layer?.borderWidth = hovered ? 2 : 1
  225. self.contentTintColor = Theme.primaryText
  226. self.layer?.shadowColor = Theme.accent.withAlphaComponent(0.35).cgColor
  227. self.layer?.shadowOpacity = hovered ? 0.18 : 0
  228. self.layer?.shadowRadius = hovered ? 10 : 0
  229. self.layer?.shadowOffset = CGSize(width: 0, height: -2)
  230. }
  231. }
  232. if animated {
  233. NSAnimationContext.runAnimationGroup { context in
  234. context.duration = 0.16
  235. updates()
  236. }
  237. } else {
  238. updates()
  239. }
  240. }
  241. }
  242. /// Close control: subtle lift + accent tint on hover.
  243. private final class PremiumCloseHoverButton: NSButton {
  244. private var trackingAreaRef: NSTrackingArea?
  245. private var didPushCursor = false
  246. init(target: AnyObject?, action: Selector) {
  247. super.init(frame: .zero)
  248. self.target = target
  249. self.action = action
  250. isBordered = false
  251. wantsLayer = true
  252. layer?.cornerRadius = 15
  253. bezelStyle = .regularSquare
  254. image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close")
  255. imageScaling = .scaleProportionallyDown
  256. focusRingType = .none
  257. translatesAutoresizingMaskIntoConstraints = false
  258. applyStyle(hovered: false, animated: false)
  259. }
  260. @available(*, unavailable)
  261. required init?(coder: NSCoder) {
  262. nil
  263. }
  264. override func updateTrackingAreas() {
  265. super.updateTrackingAreas()
  266. if let trackingAreaRef {
  267. removeTrackingArea(trackingAreaRef)
  268. }
  269. let options: NSTrackingArea.Options = [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect]
  270. let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
  271. addTrackingArea(area)
  272. trackingAreaRef = area
  273. }
  274. override func mouseEntered(with event: NSEvent) {
  275. super.mouseEntered(with: event)
  276. applyStyle(hovered: true, animated: true)
  277. if !didPushCursor {
  278. NSCursor.pointingHand.push()
  279. didPushCursor = true
  280. }
  281. }
  282. override func mouseExited(with event: NSEvent) {
  283. super.mouseExited(with: event)
  284. applyStyle(hovered: false, animated: true)
  285. if didPushCursor {
  286. NSCursor.pop()
  287. didPushCursor = false
  288. }
  289. }
  290. override func viewWillMove(toWindow newWindow: NSWindow?) {
  291. super.viewWillMove(toWindow: newWindow)
  292. if newWindow == nil, didPushCursor {
  293. NSCursor.pop()
  294. didPushCursor = false
  295. }
  296. if newWindow == nil {
  297. applyStyle(hovered: false, animated: false)
  298. }
  299. }
  300. private func applyStyle(hovered: Bool, animated: Bool) {
  301. let updates = {
  302. self.layer?.backgroundColor = (hovered
  303. ? NSColor.white.withAlphaComponent(0.98)
  304. : NSColor.white.withAlphaComponent(0.92)).cgColor
  305. self.layer?.borderColor = (hovered ? Theme.accent.withAlphaComponent(0.45) : Theme.divider).cgColor
  306. self.layer?.borderWidth = hovered ? 1.5 : 1
  307. self.contentTintColor = hovered ? Theme.accent : Theme.secondaryText
  308. self.layer?.shadowColor = Theme.accent.withAlphaComponent(0.25).cgColor
  309. self.layer?.shadowOpacity = hovered ? 0.2 : 0
  310. self.layer?.shadowRadius = hovered ? 8 : 0
  311. self.layer?.shadowOffset = CGSize(width: 0, height: -1)
  312. }
  313. if animated {
  314. NSAnimationContext.runAnimationGroup { context in
  315. context.duration = 0.15
  316. updates()
  317. }
  318. } else {
  319. updates()
  320. }
  321. }
  322. }
  323. /// Shown until StoreKit returns localized `Product.displayPrice` (never use hardcoded currency amounts).
  324. private static let unloadedPricePlaceholder = "—"
  325. private struct Plan {
  326. let id: String
  327. let title: String
  328. let subtitle: String
  329. let period: String
  330. let billedPill: String
  331. let billedLine: String
  332. let crossedPrice: String?
  333. let savingsText: String?
  334. let features: [String]
  335. let iconName: String
  336. let iconTint: NSColor
  337. let highlight: Bool
  338. }
  339. private enum Theme {
  340. static let pageStart = NSColor(srgbRed: 249 / 255, green: 252 / 255, blue: 255 / 255, alpha: 1)
  341. static let pageEnd = NSColor(srgbRed: 238 / 255, green: 244 / 255, blue: 255 / 255, alpha: 1)
  342. static let cardBackground = NSColor.white
  343. static let primaryText = NSColor(srgbRed: 27 / 255, green: 38 / 255, blue: 79 / 255, alpha: 1)
  344. static let secondaryText = NSColor(srgbRed: 108 / 255, green: 120 / 255, blue: 157 / 255, alpha: 1)
  345. static let cardBorder = NSColor(srgbRed: 198 / 255, green: 216 / 255, blue: 255 / 255, alpha: 1)
  346. static let accent = NSColor(srgbRed: 55 / 255, green: 128 / 255, blue: 255 / 255, alpha: 1)
  347. static let accentHover = NSColor(srgbRed: 38 / 255, green: 108 / 255, blue: 232 / 255, alpha: 1)
  348. static let mutedButtonFill = NSColor(srgbRed: 238 / 255, green: 243 / 255, blue: 252 / 255, alpha: 1)
  349. static let bottomStrip = NSColor(srgbRed: 244 / 255, green: 248 / 255, blue: 255 / 255, alpha: 1)
  350. static let divider = NSColor(srgbRed: 218 / 255, green: 228 / 255, blue: 247 / 255, alpha: 1)
  351. static let successText = NSColor(srgbRed: 21 / 255, green: 154 / 255, blue: 220 / 255, alpha: 1)
  352. static let iconTint = NSColor(srgbRed: 47 / 255, green: 136 / 255, blue: 255 / 255, alpha: 1)
  353. }
  354. private enum FeatureListMetrics {
  355. static let spacing = CGFloat(10)
  356. static let edgeInsets = NSEdgeInsets(top: 21, left: 37, bottom: 21, right: 0)
  357. }
  358. private let subscriptionStore = SubscriptionStore.shared
  359. private var planPriceFields: [String: (price: NSTextField, period: NSTextField)] = [:]
  360. private var planPurchaseButtons: [String: NSButton] = [:]
  361. private var subscriptionPrimaryFooterButton: NSButton?
  362. private var premiumCloseButton: NSButton?
  363. private var subscriptionStatusObservation: NSObjectProtocol?
  364. private let plans: [Plan] = [
  365. Plan(
  366. id: "weekly",
  367. title: "Weekly",
  368. subtitle: "Flexible and commitment-free",
  369. period: "/ week",
  370. billedPill: "",
  371. billedLine: "",
  372. crossedPrice: nil,
  373. savingsText: nil,
  374. features: [
  375. "All premium features",
  376. "Perfect for short-term goals",
  377. "Cancel anytime"
  378. ],
  379. iconName: "paperplane.fill",
  380. iconTint: Theme.iconTint,
  381. highlight: false
  382. ),
  383. Plan(
  384. id: "monthly",
  385. title: "Monthly",
  386. subtitle: "Balanced for regular productivity",
  387. period: "/ month",
  388. billedPill: "",
  389. billedLine: "",
  390. crossedPrice: nil,
  391. savingsText: nil,
  392. features: [
  393. "All premium features",
  394. "Best value for regular users",
  395. "Priority support"
  396. ],
  397. iconName: "bolt.fill",
  398. iconTint: Theme.accent,
  399. highlight: true
  400. ),
  401. Plan(
  402. id: "yearly",
  403. title: "Yearly",
  404. subtitle: "Best value for long-term users",
  405. period: "/ year",
  406. billedPill: "3 days free trial",
  407. billedLine: "",
  408. crossedPrice: nil,
  409. savingsText: nil,
  410. features: [
  411. "All premium features",
  412. "Lowest effective monthly cost",
  413. "Ideal for long-term use"
  414. ],
  415. iconName: "crown.fill",
  416. iconTint: Theme.successText,
  417. highlight: false
  418. )
  419. ]
  420. private let pageGradient = CAGradientLayer()
  421. deinit {
  422. if let subscriptionStatusObservation {
  423. NotificationCenter.default.removeObserver(subscriptionStatusObservation)
  424. }
  425. }
  426. override func viewDidLoad() {
  427. super.viewDidLoad()
  428. subscriptionStatusObservation = NotificationCenter.default.addObserver(
  429. forName: .subscriptionStatusDidChange,
  430. object: nil,
  431. queue: .main
  432. ) { [weak self] _ in
  433. Task { @MainActor in
  434. await self?.subscriptionStore.loadProducts()
  435. self?.applyStorePricing()
  436. self?.updateSubscriptionPrimaryFooter()
  437. self?.updatePremiumCloseButtonVisibility()
  438. }
  439. }
  440. Task { @MainActor in
  441. await loadStoreProducts()
  442. }
  443. }
  444. override func viewDidLayout() {
  445. super.viewDidLayout()
  446. pageGradient.frame = view.bounds
  447. }
  448. override func loadView() {
  449. view = NSView()
  450. view.wantsLayer = true
  451. pageGradient.colors = [Theme.pageStart.cgColor, Theme.pageEnd.cgColor]
  452. pageGradient.startPoint = CGPoint(x: 0, y: 1)
  453. pageGradient.endPoint = CGPoint(x: 1, y: 0)
  454. view.layer?.addSublayer(pageGradient)
  455. setupLayout()
  456. }
  457. private func setupLayout() {
  458. let closeButton = PremiumCloseHoverButton(target: self, action: #selector(didTapClose))
  459. let crownIcon = NSImageView()
  460. crownIcon.translatesAutoresizingMaskIntoConstraints = false
  461. crownIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
  462. crownIcon.image = NSImage(systemSymbolName: "crown.fill", accessibilityDescription: nil)
  463. crownIcon.contentTintColor = NSColor(srgbRed: 254 / 255, green: 214 / 255, blue: 92 / 255, alpha: 1)
  464. let title = NSTextField(labelWithString: "Upgrade to Pro")
  465. title.font = .systemFont(ofSize: 40, weight: .semibold)
  466. title.textColor = Theme.primaryText
  467. title.alignment = .center
  468. let subtitle = NSTextField(labelWithString: "Unlock unlimited access to premium tools and boost your productivity.")
  469. subtitle.font = .systemFont(ofSize: 14, weight: .regular)
  470. subtitle.textColor = Theme.secondaryText
  471. subtitle.alignment = .center
  472. let cardsRow = NSStackView(views: plans.map(makePricingCard(_:)))
  473. cardsRow.orientation = .horizontal
  474. cardsRow.spacing = 14
  475. cardsRow.alignment = .top
  476. cardsRow.distribution = .fillEqually
  477. cardsRow.translatesAutoresizingMaskIntoConstraints = false
  478. for card in cardsRow.arrangedSubviews {
  479. card.heightAnchor.constraint(equalTo: cardsRow.heightAnchor).isActive = true
  480. }
  481. let trustRow = makeTrustRow()
  482. let footerRow = makeFooterRow()
  483. let root = NSStackView(views: [crownIcon, title, subtitle, cardsRow, trustRow, footerRow])
  484. root.orientation = .vertical
  485. root.spacing = 18
  486. root.alignment = .centerX
  487. root.translatesAutoresizingMaskIntoConstraints = false
  488. view.addSubview(root)
  489. view.addSubview(closeButton)
  490. NSLayoutConstraint.activate([
  491. root.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
  492. root.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
  493. root.topAnchor.constraint(equalTo: view.topAnchor, constant: 20),
  494. root.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -16),
  495. closeButton.topAnchor.constraint(equalTo: view.topAnchor, constant: 14),
  496. closeButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -14),
  497. closeButton.widthAnchor.constraint(equalToConstant: 30),
  498. closeButton.heightAnchor.constraint(equalToConstant: 30),
  499. cardsRow.widthAnchor.constraint(equalTo: root.widthAnchor),
  500. cardsRow.heightAnchor.constraint(equalToConstant: 420),
  501. trustRow.widthAnchor.constraint(equalTo: root.widthAnchor),
  502. footerRow.widthAnchor.constraint(equalTo: root.widthAnchor),
  503. crownIcon.heightAnchor.constraint(equalToConstant: 20)
  504. ])
  505. premiumCloseButton = closeButton
  506. updatePremiumCloseButtonVisibility()
  507. }
  508. private func updatePremiumCloseButtonVisibility() {
  509. premiumCloseButton?.isHidden = !subscriptionStore.isProActive
  510. }
  511. private func makePricingCard(_ plan: Plan) -> NSView {
  512. let card = HoverPricingCardView(baseBorderColor: Theme.cardBorder, hoverBorderColor: Theme.accent)
  513. card.translatesAutoresizingMaskIntoConstraints = false
  514. card.layer?.backgroundColor = Theme.cardBackground.cgColor
  515. let iconWell = NSView()
  516. iconWell.translatesAutoresizingMaskIntoConstraints = false
  517. iconWell.wantsLayer = true
  518. iconWell.layer?.cornerRadius = 10
  519. iconWell.layer?.backgroundColor = Theme.bottomStrip.cgColor
  520. iconWell.widthAnchor.constraint(equalToConstant: 24).isActive = true
  521. iconWell.heightAnchor.constraint(equalToConstant: 24).isActive = true
  522. let icon = NSImageView()
  523. icon.translatesAutoresizingMaskIntoConstraints = false
  524. icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .bold)
  525. icon.image = NSImage(systemSymbolName: plan.iconName, accessibilityDescription: nil)
  526. icon.contentTintColor = plan.iconTint
  527. iconWell.addSubview(icon)
  528. NSLayoutConstraint.activate([
  529. icon.centerXAnchor.constraint(equalTo: iconWell.centerXAnchor),
  530. icon.centerYAnchor.constraint(equalTo: iconWell.centerYAnchor)
  531. ])
  532. let titleLabel = NSTextField(labelWithString: plan.title)
  533. titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
  534. titleLabel.textColor = Theme.primaryText
  535. titleLabel.alignment = .center
  536. let subtitleLabel = NSTextField(labelWithString: plan.subtitle)
  537. subtitleLabel.font = .systemFont(ofSize: 12, weight: .regular)
  538. subtitleLabel.textColor = Theme.secondaryText
  539. subtitleLabel.alignment = .center
  540. let topRightTag = pillLabel(text: plan.billedPill, tint: Theme.bottomStrip, textColor: Theme.iconTint)
  541. topRightTag.isHidden = plan.billedPill.isEmpty
  542. topRightTag.font = .systemFont(ofSize: 10, weight: .bold)
  543. let priceLabel = NSTextField(labelWithString: Self.unloadedPricePlaceholder)
  544. priceLabel.font = .systemFont(ofSize: 18, weight: .semibold)
  545. priceLabel.textColor = Theme.primaryText
  546. let periodLabel = NSTextField(labelWithString: plan.period)
  547. periodLabel.font = .systemFont(ofSize: 13, weight: .medium)
  548. periodLabel.textColor = Theme.secondaryText
  549. let priceRow = NSStackView(views: [priceLabel, periodLabel])
  550. priceRow.orientation = .horizontal
  551. priceRow.spacing = 4
  552. priceRow.alignment = .firstBaseline
  553. let billingLabel = NSTextField(labelWithString: plan.billedLine)
  554. billingLabel.font = .systemFont(ofSize: 13, weight: .medium)
  555. billingLabel.textColor = Theme.secondaryText
  556. let inlinePriceInfo = inlinePriceInfoLabel(oldPrice: plan.crossedPrice, newPrice: plan.savingsText)
  557. let divider = NSBox()
  558. divider.boxType = .separator
  559. divider.translatesAutoresizingMaskIntoConstraints = false
  560. divider.borderColor = Theme.divider
  561. let featuresStack = NSStackView(views: plan.features.map(makeFeatureRow(_:)))
  562. featuresStack.orientation = .vertical
  563. featuresStack.spacing = FeatureListMetrics.spacing
  564. featuresStack.alignment = .leading
  565. featuresStack.edgeInsets = FeatureListMetrics.edgeInsets
  566. let selectButton = PlanPurchaseHoverButton(
  567. planId: plan.id,
  568. title: "Get \(plan.title)",
  569. isPrimaryStyle: plan.highlight,
  570. target: self,
  571. action: #selector(didTapSelectPlan)
  572. )
  573. planPurchaseButtons[plan.id] = selectButton
  574. planPriceFields[plan.id] = (priceLabel, periodLabel)
  575. selectButton.heightAnchor.constraint(equalToConstant: 50).isActive = true
  576. var contentViews: [NSView] = [iconWell, titleLabel, subtitleLabel, priceRow]
  577. if !plan.billedLine.isEmpty {
  578. contentViews.append(billingLabel)
  579. }
  580. if plan.crossedPrice != nil, plan.savingsText != nil {
  581. contentViews.append(inlinePriceInfo)
  582. }
  583. contentViews.append(contentsOf: [divider, featuresStack])
  584. let verticalFlex = NSView()
  585. verticalFlex.translatesAutoresizingMaskIntoConstraints = false
  586. verticalFlex.setContentHuggingPriority(.defaultLow, for: .vertical)
  587. verticalFlex.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
  588. let column = NSStackView(views: contentViews + [verticalFlex, selectButton])
  589. column.orientation = .vertical
  590. column.spacing = 10
  591. column.alignment = .centerX
  592. column.distribution = .fill
  593. column.translatesAutoresizingMaskIntoConstraints = false
  594. card.addSubview(column)
  595. card.addSubview(topRightTag)
  596. NSLayoutConstraint.activate([
  597. divider.widthAnchor.constraint(equalTo: column.widthAnchor),
  598. featuresStack.widthAnchor.constraint(equalTo: column.widthAnchor),
  599. column.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 18),
  600. column.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -18),
  601. column.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
  602. column.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -12),
  603. selectButton.widthAnchor.constraint(equalTo: column.widthAnchor),
  604. topRightTag.topAnchor.constraint(equalTo: card.topAnchor, constant: 12),
  605. topRightTag.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12)
  606. ])
  607. return card
  608. }
  609. private func makeFeatureRow(_ text: String) -> NSView {
  610. let icon = NSImageView()
  611. icon.translatesAutoresizingMaskIntoConstraints = false
  612. icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .bold)
  613. icon.image = NSImage(systemSymbolName: "checkmark.circle.fill", accessibilityDescription: nil)
  614. icon.contentTintColor = Theme.iconTint
  615. icon.widthAnchor.constraint(equalToConstant: 14).isActive = true
  616. let label = NSTextField(labelWithString: text)
  617. label.font = .systemFont(ofSize: 14, weight: .medium)
  618. label.textColor = Theme.primaryText
  619. let row = NSStackView(views: [icon, label])
  620. row.orientation = .horizontal
  621. row.spacing = FeatureListMetrics.spacing
  622. row.alignment = .centerY
  623. row.distribution = .fill
  624. return row
  625. }
  626. private func inlinePriceInfoLabel(oldPrice: String?, newPrice: String?) -> NSTextField {
  627. guard let oldPrice, let newPrice else {
  628. return NSTextField(labelWithString: "")
  629. }
  630. let full = NSMutableAttributedString()
  631. let oldAttributes: [NSAttributedString.Key: Any] = [
  632. .font: NSFont.systemFont(ofSize: 12, weight: .semibold),
  633. .foregroundColor: Theme.secondaryText,
  634. .strikethroughStyle: NSUnderlineStyle.single.rawValue
  635. ]
  636. let newAttributes: [NSAttributedString.Key: Any] = [
  637. .font: NSFont.systemFont(ofSize: 12, weight: .bold),
  638. .foregroundColor: Theme.successText
  639. ]
  640. full.append(NSAttributedString(string: "\(oldPrice) ", attributes: oldAttributes))
  641. full.append(NSAttributedString(string: newPrice, attributes: newAttributes))
  642. let label = NSTextField(labelWithAttributedString: full)
  643. return label
  644. }
  645. private func pillLabel(text: String, tint: NSColor, textColor: NSColor) -> NSTextField {
  646. let pill = NSTextField(labelWithString: text)
  647. pill.font = .systemFont(ofSize: 10, weight: .semibold)
  648. pill.textColor = textColor
  649. pill.alignment = .center
  650. pill.wantsLayer = true
  651. pill.layer?.backgroundColor = tint.cgColor
  652. pill.layer?.cornerRadius = 9
  653. pill.translatesAutoresizingMaskIntoConstraints = false
  654. pill.heightAnchor.constraint(equalToConstant: 18).isActive = true
  655. pill.widthAnchor.constraint(greaterThanOrEqualToConstant: 95).isActive = true
  656. return pill
  657. }
  658. private func makeTrustRow() -> NSView {
  659. let badges = NSStackView(views: [
  660. trustBadge(icon: "shield.fill", title: "Secure Payments", subtitle: "Your payment is 100% secure."),
  661. trustBadge(icon: "arrow.counterclockwise", title: "Cancel Anytime", subtitle: "No commitment, cancel anytime."),
  662. trustBadge(icon: "headphones", title: "24/7 Support", subtitle: "We're here to help you anytime."),
  663. trustBadge(icon: "lock.fill", title: "Privacy First", subtitle: "Your data is safe with us.")
  664. ])
  665. badges.orientation = .horizontal
  666. badges.alignment = .centerY
  667. badges.distribution = .fillEqually
  668. badges.spacing = 12
  669. badges.edgeInsets = NSEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
  670. badges.translatesAutoresizingMaskIntoConstraints = false
  671. badges.wantsLayer = true
  672. badges.layer?.backgroundColor = Theme.bottomStrip.cgColor
  673. badges.layer?.borderColor = Theme.divider.cgColor
  674. badges.layer?.borderWidth = 1
  675. badges.layer?.cornerRadius = 10
  676. badges.setHuggingPriority(.defaultLow, for: .horizontal)
  677. badges.heightAnchor.constraint(equalToConstant: 72).isActive = true
  678. return badges
  679. }
  680. private func makeFooterRow() -> NSView {
  681. let primary = footerActionCell(
  682. title: subscriptionPrimaryFooterTitle(),
  683. action: #selector(didTapPrimaryFooterSubscriptionAction),
  684. showsTrailingDivider: true
  685. )
  686. subscriptionPrimaryFooterButton = primary.button
  687. let entries: [(text: String, action: Selector)] = [
  688. ("Restore Purchase", #selector(didTapRestorePurchases)),
  689. ("Privacy Policy", #selector(didTapFooterPrivacyPolicy)),
  690. ("Terms of Use", #selector(didTapFooterTermsOfServices)),
  691. ("Support", #selector(didTapFooterSupport))
  692. ]
  693. let cells = [primary.container] + entries.enumerated().map { index, entry in
  694. footerActionCell(title: entry.text, action: entry.action, showsTrailingDivider: index < entries.count - 1).container
  695. }
  696. let links = NSStackView(views: cells)
  697. links.orientation = .horizontal
  698. links.distribution = .fillEqually
  699. links.spacing = 0
  700. links.alignment = .centerY
  701. links.translatesAutoresizingMaskIntoConstraints = false
  702. return links
  703. }
  704. private func footerActionCell(title: String, action: Selector, showsTrailingDivider: Bool) -> (container: NSView, button: NSButton) {
  705. let container = NSView()
  706. container.translatesAutoresizingMaskIntoConstraints = false
  707. let button = FooterLinkButton(title: title, target: self, action: action)
  708. button.isBordered = false
  709. button.bezelStyle = .rounded
  710. button.font = .systemFont(ofSize: 12, weight: .medium)
  711. button.contentTintColor = Theme.secondaryText
  712. button.focusRingType = .none
  713. button.translatesAutoresizingMaskIntoConstraints = false
  714. container.addSubview(button)
  715. var constraints: [NSLayoutConstraint] = [
  716. button.centerXAnchor.constraint(equalTo: container.centerXAnchor),
  717. button.centerYAnchor.constraint(equalTo: container.centerYAnchor)
  718. ]
  719. if showsTrailingDivider {
  720. let divider = footerDivider()
  721. container.addSubview(divider)
  722. constraints.append(contentsOf: [
  723. divider.trailingAnchor.constraint(equalTo: container.trailingAnchor),
  724. divider.centerYAnchor.constraint(equalTo: container.centerYAnchor)
  725. ])
  726. }
  727. NSLayoutConstraint.activate(constraints)
  728. return (container, button)
  729. }
  730. private enum PrimaryFooterSubscriptionTitle {
  731. static let manage = "Manage Subscription"
  732. static let continueFree = "Continue with free plan"
  733. }
  734. private func subscriptionPrimaryFooterTitle() -> String {
  735. subscriptionStore.isProActive ? PrimaryFooterSubscriptionTitle.manage : PrimaryFooterSubscriptionTitle.continueFree
  736. }
  737. private func updateSubscriptionPrimaryFooter() {
  738. subscriptionPrimaryFooterButton?.title = subscriptionPrimaryFooterTitle()
  739. }
  740. private func trustBadge(icon: String, title: String, subtitle: String) -> NSView {
  741. let image = NSImageView()
  742. image.translatesAutoresizingMaskIntoConstraints = false
  743. image.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
  744. image.image = NSImage(systemSymbolName: icon, accessibilityDescription: nil)
  745. image.contentTintColor = Theme.primaryText
  746. let titleLabel = NSTextField(labelWithString: title)
  747. titleLabel.font = .systemFont(ofSize: 12, weight: .bold)
  748. titleLabel.textColor = Theme.primaryText
  749. let subtitleLabel = NSTextField(labelWithString: subtitle)
  750. subtitleLabel.font = .systemFont(ofSize: 10, weight: .medium)
  751. subtitleLabel.textColor = Theme.secondaryText
  752. let textStack = NSStackView(views: [titleLabel, subtitleLabel])
  753. textStack.orientation = .vertical
  754. textStack.spacing = 2
  755. textStack.alignment = .leading
  756. let stack = NSStackView(views: [image, textStack])
  757. stack.orientation = .horizontal
  758. stack.spacing = 8
  759. stack.alignment = .leading
  760. stack.edgeInsets = NSEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
  761. stack.wantsLayer = true
  762. stack.layer?.backgroundColor = NSColor.clear.cgColor
  763. return stack
  764. }
  765. private func footerDivider() -> NSBox {
  766. let divider = NSBox()
  767. divider.boxType = .separator
  768. divider.borderColor = Theme.divider
  769. divider.translatesAutoresizingMaskIntoConstraints = false
  770. divider.widthAnchor.constraint(equalToConstant: 1).isActive = true
  771. divider.heightAnchor.constraint(equalToConstant: 14).isActive = true
  772. return divider
  773. }
  774. @objc private func didTapSelectPlan(_ sender: NSButton) {
  775. guard let planKey = sender.identifier?.rawValue else { return }
  776. Task { await purchasePlan(planKey: planKey) }
  777. }
  778. @objc private func didTapPrimaryFooterSubscriptionAction(_ sender: NSButton) {
  779. let userTappedManage = (sender.title == PrimaryFooterSubscriptionTitle.manage)
  780. Task { @MainActor [weak self] in
  781. guard let self else { return }
  782. await subscriptionStore.refreshEntitlements(deep: true)
  783. updateSubscriptionPrimaryFooter()
  784. let active = subscriptionStore.isProActive
  785. if active || userTappedManage {
  786. guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
  787. NSWorkspace.shared.open(url)
  788. return
  789. }
  790. // Non-pro: dismiss paywall and return to the home (dashboard) window.
  791. dismissPremiumSheetFromParentIfNeeded()
  792. }
  793. }
  794. @objc private func didTapRestorePurchases() {
  795. Task { await restorePurchases() }
  796. }
  797. @objc private func didTapFooterPrivacyPolicy() {
  798. AppLegalURLs.openInSafari(AppLegalURLs.privacyPolicy)
  799. }
  800. @objc private func didTapFooterTermsOfServices() {
  801. AppLegalURLs.openInSafari(AppLegalURLs.termsOfUse)
  802. }
  803. @objc private func didTapFooterSupport() {
  804. AppLegalURLs.openInSafari(AppLegalURLs.support)
  805. }
  806. private func loadStoreProducts() async {
  807. await subscriptionStore.refreshEntitlements(deep: true)
  808. await subscriptionStore.loadProducts()
  809. applyStorePricing()
  810. updateSubscriptionPrimaryFooter()
  811. updatePremiumCloseButtonVisibility()
  812. }
  813. private func applyStorePricing() {
  814. for plan in plans {
  815. guard let fields = planPriceFields[plan.id] else { continue }
  816. guard let product = subscriptionStore.product(forPlanKey: plan.id) else {
  817. fields.price.stringValue = Self.unloadedPricePlaceholder
  818. continue
  819. }
  820. fields.price.stringValue = product.displayPrice
  821. if let period = product.subscription?.subscriptionPeriod {
  822. fields.period.stringValue = periodSuffix(for: period)
  823. }
  824. }
  825. }
  826. private func periodSuffix(for period: Product.SubscriptionPeriod) -> String {
  827. let value = period.value
  828. switch period.unit {
  829. case .day: return value == 1 ? "/ day" : "/ \(value) days"
  830. case .week: return value == 1 ? "/ week" : "/ \(value) weeks"
  831. case .month: return value == 1 ? "/ month" : "/ \(value) months"
  832. case .year: return value == 1 ? "/ year" : "/ \(value) years"
  833. @unknown default: return ""
  834. }
  835. }
  836. private func setPurchasing(_ isPurchasing: Bool) {
  837. for button in planPurchaseButtons.values {
  838. button.isEnabled = !isPurchasing
  839. }
  840. }
  841. private func purchasePlan(planKey: String) async {
  842. setPurchasing(true)
  843. defer { setPurchasing(false) }
  844. do {
  845. let completed = try await subscriptionStore.purchase(planKey: planKey)
  846. guard completed else { return }
  847. AppRatingCoordinator.shared.scheduleReviewAfterSubscriptionPurchase()
  848. let alert = NSAlert()
  849. alert.messageText = "You're subscribed"
  850. alert.informativeText = "Thank you — Pro features are now available."
  851. alert.alertStyle = .informational
  852. alert.addButton(withTitle: "OK")
  853. if let window = view.window {
  854. alert.beginSheetModal(for: window) { [weak self] _ in
  855. self?.dismissPremiumSheetFromParentIfNeeded()
  856. }
  857. } else {
  858. alert.runModal()
  859. dismissPremiumSheetFromParentIfNeeded()
  860. }
  861. } catch {
  862. await MainActor.run {
  863. self.presentPurchaseError(error)
  864. }
  865. }
  866. }
  867. private func restorePurchases() async {
  868. setPurchasing(true)
  869. defer { setPurchasing(false) }
  870. do {
  871. try await subscriptionStore.restorePurchases()
  872. let active = subscriptionStore.isProActive
  873. let alert = NSAlert()
  874. if active {
  875. alert.messageText = "Purchases restored"
  876. alert.informativeText = "Your subscription is active."
  877. } else {
  878. alert.messageText = "No subscription found"
  879. alert.informativeText = "There was nothing to restore for this Apple ID."
  880. }
  881. alert.alertStyle = .informational
  882. alert.addButton(withTitle: "OK")
  883. if let window = view.window {
  884. alert.beginSheetModal(for: window) { [weak self] _ in
  885. if active {
  886. self?.dismissPremiumSheetFromParentIfNeeded()
  887. }
  888. }
  889. } else {
  890. alert.runModal()
  891. if active {
  892. dismissPremiumSheetFromParentIfNeeded()
  893. }
  894. }
  895. } catch {
  896. await MainActor.run {
  897. self.presentPurchaseError(error)
  898. }
  899. }
  900. }
  901. private func presentPurchaseError(_ error: Error) {
  902. let alert = NSAlert()
  903. alert.messageText = "Something went wrong"
  904. if let localized = error as? LocalizedError {
  905. var parts: [String] = []
  906. if let description = localized.errorDescription {
  907. parts.append(description)
  908. }
  909. if let recovery = localized.recoverySuggestion {
  910. parts.append(recovery)
  911. }
  912. alert.informativeText = parts.isEmpty ? error.localizedDescription : parts.joined(separator: "\n\n")
  913. } else {
  914. alert.informativeText = error.localizedDescription
  915. }
  916. alert.alertStyle = .warning
  917. alert.addButton(withTitle: "OK")
  918. if let window = view.window {
  919. alert.beginSheetModal(for: window)
  920. } else {
  921. alert.runModal()
  922. }
  923. }
  924. private func dismissPremiumSheetFromParentIfNeeded() {
  925. guard let sheet = view.window, let parent = sheet.sheetParent else { return }
  926. parent.endSheet(sheet)
  927. }
  928. @objc private func didTapClose() {
  929. guard let window = view.window else { return }
  930. if let parent = window.sheetParent {
  931. parent.endSheet(window)
  932. return
  933. }
  934. window.close()
  935. }
  936. }