Без опису

PremiumPlansWindowController.swift 42KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059
  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. private struct Plan {
  324. let id: String
  325. let title: String
  326. let subtitle: String
  327. let price: String
  328. let period: String
  329. let billedPill: String
  330. let billedLine: String
  331. let crossedPrice: String?
  332. let savingsText: String?
  333. let features: [String]
  334. let iconName: String
  335. let iconTint: NSColor
  336. let highlight: Bool
  337. }
  338. private enum Theme {
  339. static let pageStart = NSColor(srgbRed: 249 / 255, green: 252 / 255, blue: 255 / 255, alpha: 1)
  340. static let pageEnd = NSColor(srgbRed: 238 / 255, green: 244 / 255, blue: 255 / 255, alpha: 1)
  341. static let cardBackground = NSColor.white
  342. static let primaryText = NSColor(srgbRed: 27 / 255, green: 38 / 255, blue: 79 / 255, alpha: 1)
  343. static let secondaryText = NSColor(srgbRed: 108 / 255, green: 120 / 255, blue: 157 / 255, alpha: 1)
  344. static let cardBorder = NSColor(srgbRed: 198 / 255, green: 216 / 255, blue: 255 / 255, alpha: 1)
  345. static let accent = NSColor(srgbRed: 55 / 255, green: 128 / 255, blue: 255 / 255, alpha: 1)
  346. static let accentHover = NSColor(srgbRed: 38 / 255, green: 108 / 255, blue: 232 / 255, alpha: 1)
  347. static let mutedButtonFill = NSColor(srgbRed: 238 / 255, green: 243 / 255, blue: 252 / 255, alpha: 1)
  348. static let bottomStrip = NSColor(srgbRed: 244 / 255, green: 248 / 255, blue: 255 / 255, alpha: 1)
  349. static let divider = NSColor(srgbRed: 218 / 255, green: 228 / 255, blue: 247 / 255, alpha: 1)
  350. static let successText = NSColor(srgbRed: 21 / 255, green: 154 / 255, blue: 220 / 255, alpha: 1)
  351. static let iconTint = NSColor(srgbRed: 47 / 255, green: 136 / 255, blue: 255 / 255, alpha: 1)
  352. }
  353. private enum FeatureListMetrics {
  354. static let spacing = CGFloat(10)
  355. static let edgeInsets = NSEdgeInsets(top: 21, left: 37, bottom: 21, right: 0)
  356. }
  357. private let subscriptionStore = SubscriptionStore.shared
  358. private var planPriceFields: [String: (price: NSTextField, period: NSTextField)] = [:]
  359. private var planPurchaseButtons: [String: NSButton] = [:]
  360. private var subscriptionPrimaryFooterButton: NSButton?
  361. private var premiumCloseButton: NSButton?
  362. private var subscriptionStatusObservation: NSObjectProtocol?
  363. private let plans: [Plan] = [
  364. Plan(
  365. id: "weekly",
  366. title: "Weekly",
  367. subtitle: "Flexible and commitment-free",
  368. price: "$9.99",
  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. price: "$19.99",
  388. period: "/ month",
  389. billedPill: "",
  390. billedLine: "",
  391. crossedPrice: nil,
  392. savingsText: nil,
  393. features: [
  394. "All premium features",
  395. "Best value for regular users",
  396. "Priority support"
  397. ],
  398. iconName: "bolt.fill",
  399. iconTint: Theme.accent,
  400. highlight: true
  401. ),
  402. Plan(
  403. id: "yearly",
  404. title: "Yearly",
  405. subtitle: "Best value for long-term users",
  406. price: "$39.99",
  407. period: "/ year",
  408. billedPill: "3 days free trial",
  409. billedLine: "",
  410. crossedPrice: nil,
  411. savingsText: nil,
  412. features: [
  413. "All premium features",
  414. "Lowest effective monthly cost",
  415. "Ideal for long-term use"
  416. ],
  417. iconName: "crown.fill",
  418. iconTint: Theme.successText,
  419. highlight: false
  420. )
  421. ]
  422. private let pageGradient = CAGradientLayer()
  423. deinit {
  424. if let subscriptionStatusObservation {
  425. NotificationCenter.default.removeObserver(subscriptionStatusObservation)
  426. }
  427. }
  428. override func viewDidLoad() {
  429. super.viewDidLoad()
  430. subscriptionStatusObservation = NotificationCenter.default.addObserver(
  431. forName: .subscriptionStatusDidChange,
  432. object: nil,
  433. queue: .main
  434. ) { [weak self] _ in
  435. Task { @MainActor in
  436. await self?.subscriptionStore.loadProducts()
  437. self?.applyStorePricing()
  438. self?.updateSubscriptionPrimaryFooter()
  439. self?.updatePremiumCloseButtonVisibility()
  440. }
  441. }
  442. Task { @MainActor in
  443. await loadStoreProducts()
  444. }
  445. }
  446. override func viewDidLayout() {
  447. super.viewDidLayout()
  448. pageGradient.frame = view.bounds
  449. }
  450. override func loadView() {
  451. view = NSView()
  452. view.wantsLayer = true
  453. pageGradient.colors = [Theme.pageStart.cgColor, Theme.pageEnd.cgColor]
  454. pageGradient.startPoint = CGPoint(x: 0, y: 1)
  455. pageGradient.endPoint = CGPoint(x: 1, y: 0)
  456. view.layer?.addSublayer(pageGradient)
  457. setupLayout()
  458. }
  459. private func setupLayout() {
  460. let closeButton = PremiumCloseHoverButton(target: self, action: #selector(didTapClose))
  461. let crownIcon = NSImageView()
  462. crownIcon.translatesAutoresizingMaskIntoConstraints = false
  463. crownIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
  464. crownIcon.image = NSImage(systemSymbolName: "crown.fill", accessibilityDescription: nil)
  465. crownIcon.contentTintColor = NSColor(srgbRed: 254 / 255, green: 214 / 255, blue: 92 / 255, alpha: 1)
  466. let title = NSTextField(labelWithString: "Upgrade to Pro")
  467. title.font = .systemFont(ofSize: 40, weight: .semibold)
  468. title.textColor = Theme.primaryText
  469. title.alignment = .center
  470. let subtitle = NSTextField(labelWithString: "Unlock unlimited access to premium tools and boost your productivity.")
  471. subtitle.font = .systemFont(ofSize: 14, weight: .regular)
  472. subtitle.textColor = Theme.secondaryText
  473. subtitle.alignment = .center
  474. let cardsRow = NSStackView(views: plans.map(makePricingCard(_:)))
  475. cardsRow.orientation = .horizontal
  476. cardsRow.spacing = 14
  477. cardsRow.alignment = .top
  478. cardsRow.distribution = .fillEqually
  479. cardsRow.translatesAutoresizingMaskIntoConstraints = false
  480. for card in cardsRow.arrangedSubviews {
  481. card.heightAnchor.constraint(equalTo: cardsRow.heightAnchor).isActive = true
  482. }
  483. let trustRow = makeTrustRow()
  484. let footerRow = makeFooterRow()
  485. let root = NSStackView(views: [crownIcon, title, subtitle, cardsRow, trustRow, footerRow])
  486. root.orientation = .vertical
  487. root.spacing = 18
  488. root.alignment = .centerX
  489. root.translatesAutoresizingMaskIntoConstraints = false
  490. view.addSubview(root)
  491. view.addSubview(closeButton)
  492. NSLayoutConstraint.activate([
  493. root.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
  494. root.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
  495. root.topAnchor.constraint(equalTo: view.topAnchor, constant: 20),
  496. root.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -16),
  497. closeButton.topAnchor.constraint(equalTo: view.topAnchor, constant: 14),
  498. closeButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -14),
  499. closeButton.widthAnchor.constraint(equalToConstant: 30),
  500. closeButton.heightAnchor.constraint(equalToConstant: 30),
  501. cardsRow.widthAnchor.constraint(equalTo: root.widthAnchor),
  502. cardsRow.heightAnchor.constraint(equalToConstant: 420),
  503. trustRow.widthAnchor.constraint(equalTo: root.widthAnchor),
  504. footerRow.widthAnchor.constraint(equalTo: root.widthAnchor),
  505. crownIcon.heightAnchor.constraint(equalToConstant: 20)
  506. ])
  507. premiumCloseButton = closeButton
  508. updatePremiumCloseButtonVisibility()
  509. }
  510. private func updatePremiumCloseButtonVisibility() {
  511. premiumCloseButton?.isHidden = !subscriptionStore.isProActive
  512. }
  513. private func makePricingCard(_ plan: Plan) -> NSView {
  514. let card = HoverPricingCardView(baseBorderColor: Theme.cardBorder, hoverBorderColor: Theme.accent)
  515. card.translatesAutoresizingMaskIntoConstraints = false
  516. card.layer?.backgroundColor = Theme.cardBackground.cgColor
  517. let iconWell = NSView()
  518. iconWell.translatesAutoresizingMaskIntoConstraints = false
  519. iconWell.wantsLayer = true
  520. iconWell.layer?.cornerRadius = 10
  521. iconWell.layer?.backgroundColor = Theme.bottomStrip.cgColor
  522. iconWell.widthAnchor.constraint(equalToConstant: 24).isActive = true
  523. iconWell.heightAnchor.constraint(equalToConstant: 24).isActive = true
  524. let icon = NSImageView()
  525. icon.translatesAutoresizingMaskIntoConstraints = false
  526. icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .bold)
  527. icon.image = NSImage(systemSymbolName: plan.iconName, accessibilityDescription: nil)
  528. icon.contentTintColor = plan.iconTint
  529. iconWell.addSubview(icon)
  530. NSLayoutConstraint.activate([
  531. icon.centerXAnchor.constraint(equalTo: iconWell.centerXAnchor),
  532. icon.centerYAnchor.constraint(equalTo: iconWell.centerYAnchor)
  533. ])
  534. let titleLabel = NSTextField(labelWithString: plan.title)
  535. titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
  536. titleLabel.textColor = Theme.primaryText
  537. titleLabel.alignment = .center
  538. let subtitleLabel = NSTextField(labelWithString: plan.subtitle)
  539. subtitleLabel.font = .systemFont(ofSize: 12, weight: .regular)
  540. subtitleLabel.textColor = Theme.secondaryText
  541. subtitleLabel.alignment = .center
  542. let topRightTag = pillLabel(text: plan.billedPill, tint: Theme.bottomStrip, textColor: Theme.iconTint)
  543. topRightTag.isHidden = plan.billedPill.isEmpty
  544. topRightTag.font = .systemFont(ofSize: 10, weight: .bold)
  545. let priceLabel = NSTextField(labelWithString: plan.price)
  546. priceLabel.font = .systemFont(ofSize: 18, weight: .semibold)
  547. priceLabel.textColor = Theme.primaryText
  548. let periodLabel = NSTextField(labelWithString: plan.period)
  549. periodLabel.font = .systemFont(ofSize: 13, weight: .medium)
  550. periodLabel.textColor = Theme.secondaryText
  551. let priceRow = NSStackView(views: [priceLabel, periodLabel])
  552. priceRow.orientation = .horizontal
  553. priceRow.spacing = 4
  554. priceRow.alignment = .firstBaseline
  555. let billingLabel = NSTextField(labelWithString: plan.billedLine)
  556. billingLabel.font = .systemFont(ofSize: 13, weight: .medium)
  557. billingLabel.textColor = Theme.secondaryText
  558. let inlinePriceInfo = inlinePriceInfoLabel(oldPrice: plan.crossedPrice, newPrice: plan.savingsText)
  559. let divider = NSBox()
  560. divider.boxType = .separator
  561. divider.translatesAutoresizingMaskIntoConstraints = false
  562. divider.borderColor = Theme.divider
  563. let featuresStack = NSStackView(views: plan.features.map(makeFeatureRow(_:)))
  564. featuresStack.orientation = .vertical
  565. featuresStack.spacing = FeatureListMetrics.spacing
  566. featuresStack.alignment = .leading
  567. featuresStack.edgeInsets = FeatureListMetrics.edgeInsets
  568. let selectButton = PlanPurchaseHoverButton(
  569. planId: plan.id,
  570. title: "Get \(plan.title)",
  571. isPrimaryStyle: plan.highlight,
  572. target: self,
  573. action: #selector(didTapSelectPlan)
  574. )
  575. planPurchaseButtons[plan.id] = selectButton
  576. planPriceFields[plan.id] = (priceLabel, periodLabel)
  577. selectButton.heightAnchor.constraint(equalToConstant: 50).isActive = true
  578. var contentViews: [NSView] = [iconWell, titleLabel, subtitleLabel, priceRow]
  579. if !plan.billedLine.isEmpty {
  580. contentViews.append(billingLabel)
  581. }
  582. if plan.crossedPrice != nil, plan.savingsText != nil {
  583. contentViews.append(inlinePriceInfo)
  584. }
  585. contentViews.append(contentsOf: [divider, featuresStack])
  586. let verticalFlex = NSView()
  587. verticalFlex.translatesAutoresizingMaskIntoConstraints = false
  588. verticalFlex.setContentHuggingPriority(.defaultLow, for: .vertical)
  589. verticalFlex.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
  590. let column = NSStackView(views: contentViews + [verticalFlex, selectButton])
  591. column.orientation = .vertical
  592. column.spacing = 10
  593. column.alignment = .centerX
  594. column.distribution = .fill
  595. column.translatesAutoresizingMaskIntoConstraints = false
  596. card.addSubview(column)
  597. card.addSubview(topRightTag)
  598. NSLayoutConstraint.activate([
  599. divider.widthAnchor.constraint(equalTo: column.widthAnchor),
  600. featuresStack.widthAnchor.constraint(equalTo: column.widthAnchor),
  601. column.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 18),
  602. column.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -18),
  603. column.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
  604. column.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -12),
  605. selectButton.widthAnchor.constraint(equalTo: column.widthAnchor),
  606. topRightTag.topAnchor.constraint(equalTo: card.topAnchor, constant: 12),
  607. topRightTag.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12)
  608. ])
  609. return card
  610. }
  611. private func makeFeatureRow(_ text: String) -> NSView {
  612. let icon = NSImageView()
  613. icon.translatesAutoresizingMaskIntoConstraints = false
  614. icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .bold)
  615. icon.image = NSImage(systemSymbolName: "checkmark.circle.fill", accessibilityDescription: nil)
  616. icon.contentTintColor = Theme.iconTint
  617. icon.widthAnchor.constraint(equalToConstant: 14).isActive = true
  618. let label = NSTextField(labelWithString: text)
  619. label.font = .systemFont(ofSize: 14, weight: .medium)
  620. label.textColor = Theme.primaryText
  621. let row = NSStackView(views: [icon, label])
  622. row.orientation = .horizontal
  623. row.spacing = FeatureListMetrics.spacing
  624. row.alignment = .centerY
  625. row.distribution = .fill
  626. return row
  627. }
  628. private func inlinePriceInfoLabel(oldPrice: String?, newPrice: String?) -> NSTextField {
  629. guard let oldPrice, let newPrice else {
  630. return NSTextField(labelWithString: "")
  631. }
  632. let full = NSMutableAttributedString()
  633. let oldAttributes: [NSAttributedString.Key: Any] = [
  634. .font: NSFont.systemFont(ofSize: 12, weight: .semibold),
  635. .foregroundColor: Theme.secondaryText,
  636. .strikethroughStyle: NSUnderlineStyle.single.rawValue
  637. ]
  638. let newAttributes: [NSAttributedString.Key: Any] = [
  639. .font: NSFont.systemFont(ofSize: 12, weight: .bold),
  640. .foregroundColor: Theme.successText
  641. ]
  642. full.append(NSAttributedString(string: "\(oldPrice) ", attributes: oldAttributes))
  643. full.append(NSAttributedString(string: newPrice, attributes: newAttributes))
  644. let label = NSTextField(labelWithAttributedString: full)
  645. return label
  646. }
  647. private func pillLabel(text: String, tint: NSColor, textColor: NSColor) -> NSTextField {
  648. let pill = NSTextField(labelWithString: text)
  649. pill.font = .systemFont(ofSize: 10, weight: .semibold)
  650. pill.textColor = textColor
  651. pill.alignment = .center
  652. pill.wantsLayer = true
  653. pill.layer?.backgroundColor = tint.cgColor
  654. pill.layer?.cornerRadius = 9
  655. pill.translatesAutoresizingMaskIntoConstraints = false
  656. pill.heightAnchor.constraint(equalToConstant: 18).isActive = true
  657. pill.widthAnchor.constraint(greaterThanOrEqualToConstant: 95).isActive = true
  658. return pill
  659. }
  660. private func makeTrustRow() -> NSView {
  661. let badges = NSStackView(views: [
  662. trustBadge(icon: "shield.fill", title: "Secure Payments", subtitle: "Your payment is 100% secure."),
  663. trustBadge(icon: "arrow.counterclockwise", title: "Cancel Anytime", subtitle: "No commitment, cancel anytime."),
  664. trustBadge(icon: "headphones", title: "24/7 Support", subtitle: "We're here to help you anytime."),
  665. trustBadge(icon: "lock.fill", title: "Privacy First", subtitle: "Your data is safe with us.")
  666. ])
  667. badges.orientation = .horizontal
  668. badges.alignment = .centerY
  669. badges.distribution = .fillEqually
  670. badges.spacing = 12
  671. badges.edgeInsets = NSEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
  672. badges.translatesAutoresizingMaskIntoConstraints = false
  673. badges.wantsLayer = true
  674. badges.layer?.backgroundColor = Theme.bottomStrip.cgColor
  675. badges.layer?.borderColor = Theme.divider.cgColor
  676. badges.layer?.borderWidth = 1
  677. badges.layer?.cornerRadius = 10
  678. badges.setHuggingPriority(.defaultLow, for: .horizontal)
  679. badges.heightAnchor.constraint(equalToConstant: 72).isActive = true
  680. return badges
  681. }
  682. private func makeFooterRow() -> NSView {
  683. let primary = footerActionCell(
  684. title: subscriptionPrimaryFooterTitle(),
  685. action: #selector(didTapPrimaryFooterSubscriptionAction),
  686. showsTrailingDivider: true
  687. )
  688. subscriptionPrimaryFooterButton = primary.button
  689. let entries: [(text: String, action: Selector)] = [
  690. ("Restore Purchase", #selector(didTapRestorePurchases)),
  691. ("Privacy Policy", #selector(didTapFooterPrivacyPolicy)),
  692. ("Terms of Services", #selector(didTapFooterTermsOfServices)),
  693. ("Support", #selector(didTapFooterSupport))
  694. ]
  695. let cells = [primary.container] + entries.enumerated().map { index, entry in
  696. footerActionCell(title: entry.text, action: entry.action, showsTrailingDivider: index < entries.count - 1).container
  697. }
  698. let links = NSStackView(views: cells)
  699. links.orientation = .horizontal
  700. links.distribution = .fillEqually
  701. links.spacing = 0
  702. links.alignment = .centerY
  703. links.translatesAutoresizingMaskIntoConstraints = false
  704. return links
  705. }
  706. private func footerActionCell(title: String, action: Selector, showsTrailingDivider: Bool) -> (container: NSView, button: NSButton) {
  707. let container = NSView()
  708. container.translatesAutoresizingMaskIntoConstraints = false
  709. let button = FooterLinkButton(title: title, target: self, action: action)
  710. button.isBordered = false
  711. button.bezelStyle = .rounded
  712. button.font = .systemFont(ofSize: 12, weight: .medium)
  713. button.contentTintColor = Theme.secondaryText
  714. button.focusRingType = .none
  715. button.translatesAutoresizingMaskIntoConstraints = false
  716. container.addSubview(button)
  717. var constraints: [NSLayoutConstraint] = [
  718. button.centerXAnchor.constraint(equalTo: container.centerXAnchor),
  719. button.centerYAnchor.constraint(equalTo: container.centerYAnchor)
  720. ]
  721. if showsTrailingDivider {
  722. let divider = footerDivider()
  723. container.addSubview(divider)
  724. constraints.append(contentsOf: [
  725. divider.trailingAnchor.constraint(equalTo: container.trailingAnchor),
  726. divider.centerYAnchor.constraint(equalTo: container.centerYAnchor)
  727. ])
  728. }
  729. NSLayoutConstraint.activate(constraints)
  730. return (container, button)
  731. }
  732. private enum PrimaryFooterSubscriptionTitle {
  733. static let manage = "Manage Subscription"
  734. static let continueFree = "Continue with free plan"
  735. }
  736. private func subscriptionPrimaryFooterTitle() -> String {
  737. subscriptionStore.isProActive ? PrimaryFooterSubscriptionTitle.manage : PrimaryFooterSubscriptionTitle.continueFree
  738. }
  739. private func updateSubscriptionPrimaryFooter() {
  740. subscriptionPrimaryFooterButton?.title = subscriptionPrimaryFooterTitle()
  741. }
  742. private func trustBadge(icon: String, title: String, subtitle: String) -> NSView {
  743. let image = NSImageView()
  744. image.translatesAutoresizingMaskIntoConstraints = false
  745. image.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
  746. image.image = NSImage(systemSymbolName: icon, accessibilityDescription: nil)
  747. image.contentTintColor = Theme.primaryText
  748. let titleLabel = NSTextField(labelWithString: title)
  749. titleLabel.font = .systemFont(ofSize: 12, weight: .bold)
  750. titleLabel.textColor = Theme.primaryText
  751. let subtitleLabel = NSTextField(labelWithString: subtitle)
  752. subtitleLabel.font = .systemFont(ofSize: 10, weight: .medium)
  753. subtitleLabel.textColor = Theme.secondaryText
  754. let textStack = NSStackView(views: [titleLabel, subtitleLabel])
  755. textStack.orientation = .vertical
  756. textStack.spacing = 2
  757. textStack.alignment = .leading
  758. let stack = NSStackView(views: [image, textStack])
  759. stack.orientation = .horizontal
  760. stack.spacing = 8
  761. stack.alignment = .leading
  762. stack.edgeInsets = NSEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
  763. stack.wantsLayer = true
  764. stack.layer?.backgroundColor = NSColor.clear.cgColor
  765. return stack
  766. }
  767. private func footerDivider() -> NSBox {
  768. let divider = NSBox()
  769. divider.boxType = .separator
  770. divider.borderColor = Theme.divider
  771. divider.translatesAutoresizingMaskIntoConstraints = false
  772. divider.widthAnchor.constraint(equalToConstant: 1).isActive = true
  773. divider.heightAnchor.constraint(equalToConstant: 14).isActive = true
  774. return divider
  775. }
  776. @objc private func didTapSelectPlan(_ sender: NSButton) {
  777. guard let planKey = sender.identifier?.rawValue else { return }
  778. Task { await purchasePlan(planKey: planKey) }
  779. }
  780. @objc private func didTapPrimaryFooterSubscriptionAction(_ sender: NSButton) {
  781. let userTappedManage = (sender.title == PrimaryFooterSubscriptionTitle.manage)
  782. Task { @MainActor [weak self] in
  783. guard let self else { return }
  784. await subscriptionStore.refreshEntitlements(deep: true)
  785. updateSubscriptionPrimaryFooter()
  786. let active = subscriptionStore.isProActive
  787. if active || userTappedManage {
  788. guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
  789. NSWorkspace.shared.open(url)
  790. return
  791. }
  792. // Non-pro: dismiss paywall and return to the home (dashboard) window.
  793. dismissPremiumSheetFromParentIfNeeded()
  794. }
  795. }
  796. @objc private func didTapRestorePurchases() {
  797. Task { await restorePurchases() }
  798. }
  799. @objc private func didTapFooterPrivacyPolicy() {
  800. presentFooterPlaceholderAlert(for: "Privacy Policy")
  801. }
  802. @objc private func didTapFooterTermsOfServices() {
  803. presentFooterPlaceholderAlert(for: "Terms of Services")
  804. }
  805. @objc private func didTapFooterSupport() {
  806. presentFooterPlaceholderAlert(for: "Support")
  807. }
  808. private func presentFooterPlaceholderAlert(for featureName: String) {
  809. let alert = NSAlert()
  810. alert.messageText = "\(featureName) coming soon"
  811. alert.informativeText = "This section is a placeholder for now."
  812. alert.alertStyle = .informational
  813. alert.addButton(withTitle: "OK")
  814. if let window = view.window {
  815. alert.beginSheetModal(for: window)
  816. } else {
  817. alert.runModal()
  818. }
  819. }
  820. private func loadStoreProducts() async {
  821. await subscriptionStore.refreshEntitlements(deep: true)
  822. await subscriptionStore.loadProducts()
  823. applyStorePricing()
  824. updateSubscriptionPrimaryFooter()
  825. updatePremiumCloseButtonVisibility()
  826. }
  827. private func applyStorePricing() {
  828. for plan in plans {
  829. guard let fields = planPriceFields[plan.id],
  830. let product = subscriptionStore.product(forPlanKey: plan.id) else { continue }
  831. fields.price.stringValue = product.displayPrice
  832. if let period = product.subscription?.subscriptionPeriod {
  833. fields.period.stringValue = periodSuffix(for: period)
  834. }
  835. }
  836. }
  837. private func periodSuffix(for period: Product.SubscriptionPeriod) -> String {
  838. let value = period.value
  839. switch period.unit {
  840. case .day: return value == 1 ? "/ day" : "/ \(value) days"
  841. case .week: return value == 1 ? "/ week" : "/ \(value) weeks"
  842. case .month: return value == 1 ? "/ month" : "/ \(value) months"
  843. case .year: return value == 1 ? "/ year" : "/ \(value) years"
  844. @unknown default: return ""
  845. }
  846. }
  847. private func setPurchasing(_ isPurchasing: Bool) {
  848. for button in planPurchaseButtons.values {
  849. button.isEnabled = !isPurchasing
  850. }
  851. }
  852. private func purchasePlan(planKey: String) async {
  853. setPurchasing(true)
  854. defer { setPurchasing(false) }
  855. do {
  856. let completed = try await subscriptionStore.purchase(planKey: planKey)
  857. guard completed else { return }
  858. let alert = NSAlert()
  859. alert.messageText = "You're subscribed"
  860. alert.informativeText = "Thank you — Pro features are now available."
  861. alert.alertStyle = .informational
  862. alert.addButton(withTitle: "OK")
  863. if let window = view.window {
  864. alert.beginSheetModal(for: window) { [weak self] _ in
  865. self?.dismissPremiumSheetFromParentIfNeeded()
  866. }
  867. } else {
  868. alert.runModal()
  869. dismissPremiumSheetFromParentIfNeeded()
  870. }
  871. } catch {
  872. await MainActor.run {
  873. self.presentPurchaseError(error)
  874. }
  875. }
  876. }
  877. private func restorePurchases() async {
  878. setPurchasing(true)
  879. defer { setPurchasing(false) }
  880. do {
  881. try await subscriptionStore.restorePurchases()
  882. let active = subscriptionStore.isProActive
  883. let alert = NSAlert()
  884. if active {
  885. alert.messageText = "Purchases restored"
  886. alert.informativeText = "Your subscription is active."
  887. } else {
  888. alert.messageText = "No subscription found"
  889. alert.informativeText = "There was nothing to restore for this Apple ID."
  890. }
  891. alert.alertStyle = .informational
  892. alert.addButton(withTitle: "OK")
  893. if let window = view.window {
  894. alert.beginSheetModal(for: window) { [weak self] _ in
  895. if active {
  896. self?.dismissPremiumSheetFromParentIfNeeded()
  897. }
  898. }
  899. } else {
  900. alert.runModal()
  901. if active {
  902. dismissPremiumSheetFromParentIfNeeded()
  903. }
  904. }
  905. } catch {
  906. await MainActor.run {
  907. self.presentPurchaseError(error)
  908. }
  909. }
  910. }
  911. private func presentPurchaseError(_ error: Error) {
  912. let alert = NSAlert()
  913. alert.messageText = "Something went wrong"
  914. if let localized = error as? LocalizedError {
  915. var parts: [String] = []
  916. if let description = localized.errorDescription {
  917. parts.append(description)
  918. }
  919. if let recovery = localized.recoverySuggestion {
  920. parts.append(recovery)
  921. }
  922. alert.informativeText = parts.isEmpty ? error.localizedDescription : parts.joined(separator: "\n\n")
  923. } else {
  924. alert.informativeText = error.localizedDescription
  925. }
  926. alert.alertStyle = .warning
  927. alert.addButton(withTitle: "OK")
  928. if let window = view.window {
  929. alert.beginSheetModal(for: window)
  930. } else {
  931. alert.runModal()
  932. }
  933. }
  934. private func dismissPremiumSheetFromParentIfNeeded() {
  935. guard let sheet = view.window, let parent = sheet.sheetParent else { return }
  936. parent.endSheet(sheet)
  937. }
  938. @objc private func didTapClose() {
  939. guard let window = view.window else { return }
  940. if let parent = window.sheetParent {
  941. parent.endSheet(window)
  942. return
  943. }
  944. window.close()
  945. }
  946. }