Brak opisu

PremiumPlansWindowController.swift 42KB

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