No Description

PremiumPlansWindowController.swift 42KB

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