Brak opisu

PremiumPlansWindowController.swift 54KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339
  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 var paywallSheetBackground: NSColor { PremiumPlansViewController.Theme.pageStart }
  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 var baseBorderColor: NSColor
  29. private var hoverBorderColor: NSColor
  30. private var trackingAreaRef: NSTrackingArea?
  31. private var isHovered = false
  32. init(baseBorderColor: NSColor, hoverBorderColor: NSColor) {
  33. self.baseBorderColor = baseBorderColor
  34. self.hoverBorderColor = hoverBorderColor
  35. super.init(frame: .zero)
  36. wantsLayer = true
  37. layer?.cornerRadius = 16
  38. applyHoverStyle(isHovered: false, animated: false)
  39. }
  40. @available(*, unavailable)
  41. required init?(coder: NSCoder) {
  42. nil
  43. }
  44. override func updateTrackingAreas() {
  45. super.updateTrackingAreas()
  46. if let trackingAreaRef {
  47. removeTrackingArea(trackingAreaRef)
  48. }
  49. let options: NSTrackingArea.Options = [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect]
  50. let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
  51. addTrackingArea(area)
  52. trackingAreaRef = area
  53. }
  54. override func mouseEntered(with event: NSEvent) {
  55. super.mouseEntered(with: event)
  56. isHovered = true
  57. applyHoverStyle(isHovered: true, animated: true)
  58. }
  59. override func mouseExited(with event: NSEvent) {
  60. super.mouseExited(with: event)
  61. isHovered = false
  62. applyHoverStyle(isHovered: false, animated: true)
  63. }
  64. func updateBorderColors(base: NSColor, hover: NSColor) {
  65. baseBorderColor = base
  66. hoverBorderColor = hover
  67. applyHoverStyle(isHovered: isHovered, animated: false)
  68. }
  69. private func applyHoverStyle(isHovered: Bool, animated: Bool) {
  70. guard let layer else { return }
  71. let updates = {
  72. layer.borderWidth = isHovered ? 2 : 1
  73. layer.borderColor = (isHovered ? self.hoverBorderColor : self.baseBorderColor).cgColor
  74. layer.shadowColor = self.hoverBorderColor.withAlphaComponent(0.35).cgColor
  75. layer.shadowOpacity = isHovered ? 0.22 : 0
  76. layer.shadowRadius = isHovered ? 14 : 0
  77. layer.shadowOffset = .init(width: 0, height: -2)
  78. }
  79. if animated {
  80. NSAnimationContext.runAnimationGroup { context in
  81. context.duration = 0.16
  82. updates()
  83. }
  84. } else {
  85. updates()
  86. }
  87. }
  88. }
  89. /// Footer text actions: accent color + pointing hand on hover (matches pricing-card tracking behavior).
  90. private final class FooterLinkButton: NSButton {
  91. private var trackingAreaRef: NSTrackingArea?
  92. private var didPushCursor = false
  93. override func updateTrackingAreas() {
  94. super.updateTrackingAreas()
  95. if let trackingAreaRef {
  96. removeTrackingArea(trackingAreaRef)
  97. }
  98. let options: NSTrackingArea.Options = [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect]
  99. let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
  100. addTrackingArea(area)
  101. trackingAreaRef = area
  102. }
  103. override func mouseEntered(with event: NSEvent) {
  104. super.mouseEntered(with: event)
  105. setHoverVisuals(hovered: true)
  106. if !didPushCursor {
  107. NSCursor.pointingHand.push()
  108. didPushCursor = true
  109. }
  110. }
  111. override func mouseExited(with event: NSEvent) {
  112. super.mouseExited(with: event)
  113. setHoverVisuals(hovered: false)
  114. if didPushCursor {
  115. NSCursor.pop()
  116. didPushCursor = false
  117. }
  118. }
  119. override func viewWillMove(toWindow newWindow: NSWindow?) {
  120. super.viewWillMove(toWindow: newWindow)
  121. if newWindow == nil, didPushCursor {
  122. NSCursor.pop()
  123. didPushCursor = false
  124. }
  125. if newWindow == nil {
  126. setHoverVisuals(hovered: false, animated: false)
  127. }
  128. }
  129. private func setHoverVisuals(hovered: Bool, animated: Bool = true) {
  130. let color = hovered ? Theme.accent : Theme.secondaryText
  131. if animated {
  132. NSAnimationContext.runAnimationGroup { context in
  133. context.duration = 0.15
  134. self.animator().contentTintColor = color
  135. }
  136. } else {
  137. contentTintColor = color
  138. }
  139. }
  140. }
  141. /// Purchase CTAs: fill/border/shadow + pointing hand on hover.
  142. private final class PlanPurchaseHoverButton: NSButton {
  143. private var trackingAreaRef: NSTrackingArea?
  144. private var didPushCursor = false
  145. private let isPrimaryStyle: Bool
  146. private static let primaryFill = NSColor(srgbRed: 189 / 255, green: 52 / 255, blue: 255 / 255, alpha: 1)
  147. private static let primaryFillHover = NSColor(srgbRed: 205 / 255, green: 88 / 255, blue: 255 / 255, alpha: 1)
  148. init(planId: String, title: String, isPrimaryStyle: Bool, target: AnyObject?, action: Selector) {
  149. self.isPrimaryStyle = isPrimaryStyle
  150. super.init(frame: .zero)
  151. identifier = NSUserInterfaceItemIdentifier(planId)
  152. self.title = title
  153. self.target = target
  154. self.action = action
  155. isBordered = false
  156. bezelStyle = .rounded
  157. font = .systemFont(ofSize: 14, weight: .semibold)
  158. wantsLayer = true
  159. layer?.cornerRadius = 12
  160. focusRingType = .none
  161. translatesAutoresizingMaskIntoConstraints = false
  162. applyBaseStyle(hovered: false)
  163. }
  164. @available(*, unavailable)
  165. required init?(coder: NSCoder) {
  166. nil
  167. }
  168. override var isEnabled: Bool {
  169. didSet {
  170. if !isEnabled {
  171. applyBaseStyle(hovered: false, animated: true)
  172. if didPushCursor {
  173. NSCursor.pop()
  174. didPushCursor = false
  175. }
  176. }
  177. }
  178. }
  179. override func updateTrackingAreas() {
  180. super.updateTrackingAreas()
  181. if let trackingAreaRef {
  182. removeTrackingArea(trackingAreaRef)
  183. }
  184. let options: NSTrackingArea.Options = [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect]
  185. let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
  186. addTrackingArea(area)
  187. trackingAreaRef = area
  188. }
  189. override func mouseEntered(with event: NSEvent) {
  190. super.mouseEntered(with: event)
  191. guard isEnabled else { return }
  192. applyBaseStyle(hovered: true, animated: true)
  193. if !didPushCursor {
  194. NSCursor.pointingHand.push()
  195. didPushCursor = true
  196. }
  197. }
  198. override func mouseExited(with event: NSEvent) {
  199. super.mouseExited(with: event)
  200. applyBaseStyle(hovered: false, animated: true)
  201. if didPushCursor {
  202. NSCursor.pop()
  203. didPushCursor = false
  204. }
  205. }
  206. override func viewWillMove(toWindow newWindow: NSWindow?) {
  207. super.viewWillMove(toWindow: newWindow)
  208. if newWindow == nil, didPushCursor {
  209. NSCursor.pop()
  210. didPushCursor = false
  211. }
  212. if newWindow == nil {
  213. applyBaseStyle(hovered: false, animated: false)
  214. }
  215. }
  216. func refreshAppearance() {
  217. applyBaseStyle(hovered: false, animated: false)
  218. }
  219. private func applyBaseStyle(hovered: Bool, animated: Bool = true) {
  220. let updates = {
  221. if self.isPrimaryStyle {
  222. self.layer?.backgroundColor = (hovered ? Self.primaryFillHover : Self.primaryFill).cgColor
  223. self.layer?.borderColor = Theme.accent.cgColor
  224. self.layer?.borderWidth = hovered ? 2 : 1
  225. self.contentTintColor = .white
  226. self.layer?.shadowColor = Self.primaryFill.cgColor
  227. self.layer?.shadowOpacity = hovered ? 0.28 : 0
  228. self.layer?.shadowRadius = hovered ? 12 : 0
  229. self.layer?.shadowOffset = CGSize(width: 0, height: -2)
  230. } else {
  231. let baseFill = Theme.mutedButtonFill
  232. let hoverFill = baseFill.blended(withFraction: 0.22, of: Theme.accent) ?? baseFill
  233. self.layer?.backgroundColor = (hovered ? hoverFill : baseFill).cgColor
  234. self.layer?.borderColor = (hovered ? Theme.accent : Theme.divider).cgColor
  235. self.layer?.borderWidth = hovered ? 2 : 1
  236. self.contentTintColor = Theme.primaryText
  237. self.layer?.shadowColor = Theme.accent.withAlphaComponent(0.35).cgColor
  238. self.layer?.shadowOpacity = hovered ? 0.18 : 0
  239. self.layer?.shadowRadius = hovered ? 10 : 0
  240. self.layer?.shadowOffset = CGSize(width: 0, height: -2)
  241. }
  242. }
  243. if animated {
  244. NSAnimationContext.runAnimationGroup { context in
  245. context.duration = 0.16
  246. updates()
  247. }
  248. } else {
  249. updates()
  250. }
  251. }
  252. }
  253. /// Close control: subtle lift + accent tint on hover.
  254. private final class PremiumCloseHoverButton: NSButton {
  255. private var trackingAreaRef: NSTrackingArea?
  256. private var didPushCursor = false
  257. init(target: AnyObject?, action: Selector) {
  258. super.init(frame: .zero)
  259. self.target = target
  260. self.action = action
  261. isBordered = false
  262. wantsLayer = true
  263. layer?.cornerRadius = 15
  264. bezelStyle = .regularSquare
  265. image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close")
  266. imageScaling = .scaleProportionallyDown
  267. focusRingType = .none
  268. translatesAutoresizingMaskIntoConstraints = false
  269. applyStyle(hovered: false, animated: false)
  270. }
  271. @available(*, unavailable)
  272. required init?(coder: NSCoder) {
  273. nil
  274. }
  275. override func updateTrackingAreas() {
  276. super.updateTrackingAreas()
  277. if let trackingAreaRef {
  278. removeTrackingArea(trackingAreaRef)
  279. }
  280. let options: NSTrackingArea.Options = [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect]
  281. let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
  282. addTrackingArea(area)
  283. trackingAreaRef = area
  284. }
  285. override func mouseEntered(with event: NSEvent) {
  286. super.mouseEntered(with: event)
  287. applyStyle(hovered: true, animated: true)
  288. if !didPushCursor {
  289. NSCursor.pointingHand.push()
  290. didPushCursor = true
  291. }
  292. }
  293. override func mouseExited(with event: NSEvent) {
  294. super.mouseExited(with: event)
  295. applyStyle(hovered: false, animated: true)
  296. if didPushCursor {
  297. NSCursor.pop()
  298. didPushCursor = false
  299. }
  300. }
  301. override func viewWillMove(toWindow newWindow: NSWindow?) {
  302. super.viewWillMove(toWindow: newWindow)
  303. if newWindow == nil, didPushCursor {
  304. NSCursor.pop()
  305. didPushCursor = false
  306. }
  307. if newWindow == nil {
  308. applyStyle(hovered: false, animated: false)
  309. }
  310. }
  311. func refreshAppearance(hovered: Bool) {
  312. applyStyle(hovered: hovered, animated: false)
  313. }
  314. private func applyStyle(hovered: Bool, animated: Bool) {
  315. let updates = {
  316. self.layer?.backgroundColor = (hovered
  317. ? Theme.closeButtonBackgroundHover
  318. : Theme.closeButtonBackground).cgColor
  319. self.layer?.borderColor = (hovered ? Theme.accent.withAlphaComponent(0.45) : Theme.divider).cgColor
  320. self.layer?.borderWidth = hovered ? 1.5 : 1
  321. self.contentTintColor = hovered ? Theme.accent : Theme.secondaryText
  322. self.layer?.shadowColor = Theme.accent.withAlphaComponent(0.25).cgColor
  323. self.layer?.shadowOpacity = hovered ? 0.2 : 0
  324. self.layer?.shadowRadius = hovered ? 8 : 0
  325. self.layer?.shadowOffset = CGSize(width: 0, height: -1)
  326. }
  327. if animated {
  328. NSAnimationContext.runAnimationGroup { context in
  329. context.duration = 0.15
  330. updates()
  331. }
  332. } else {
  333. updates()
  334. }
  335. }
  336. }
  337. /// Shown until StoreKit returns localized `Product.displayPrice` (never use hardcoded currency amounts).
  338. private static let unloadedPricePlaceholder = "—"
  339. private struct Plan {
  340. let id: String
  341. let title: String
  342. let subtitle: String
  343. let period: String
  344. let billedPill: String
  345. let billedLine: String
  346. let crossedPrice: String?
  347. let savingsText: String?
  348. let features: [String]
  349. let iconName: String
  350. let iconTint: NSColor
  351. let highlight: Bool
  352. }
  353. fileprivate enum Theme {
  354. static var pageStart: NSColor {
  355. AppDashboardTheme.isDark
  356. ? AppDashboardTheme.pageBackground
  357. : NSColor(srgbRed: 249 / 255, green: 252 / 255, blue: 255 / 255, alpha: 1)
  358. }
  359. static var pageEnd: NSColor {
  360. AppDashboardTheme.isDark
  361. ? AppDashboardTheme.loadingPageBackgroundBottom
  362. : NSColor(srgbRed: 238 / 255, green: 244 / 255, blue: 255 / 255, alpha: 1)
  363. }
  364. static var cardBackground: NSColor { AppDashboardTheme.cardBackground }
  365. static var primaryText: NSColor {
  366. AppDashboardTheme.isDark
  367. ? AppDashboardTheme.primaryText
  368. : NSColor(srgbRed: 27 / 255, green: 38 / 255, blue: 79 / 255, alpha: 1)
  369. }
  370. static var secondaryText: NSColor {
  371. AppDashboardTheme.isDark
  372. ? AppDashboardTheme.secondaryText
  373. : NSColor(srgbRed: 108 / 255, green: 120 / 255, blue: 157 / 255, alpha: 1)
  374. }
  375. static var cardBorder: NSColor {
  376. AppDashboardTheme.isDark
  377. ? AppDashboardTheme.border
  378. : NSColor(srgbRed: 198 / 255, green: 216 / 255, blue: 255 / 255, alpha: 1)
  379. }
  380. static var accent: NSColor { AppDashboardTheme.brandBlue }
  381. static var accentHover: NSColor { AppDashboardTheme.brandBlueHover }
  382. static var mutedButtonFill: NSColor {
  383. AppDashboardTheme.isDark
  384. ? AppDashboardTheme.profileFieldFill
  385. : NSColor(srgbRed: 238 / 255, green: 243 / 255, blue: 252 / 255, alpha: 1)
  386. }
  387. static var bottomStrip: NSColor {
  388. AppDashboardTheme.isDark
  389. ? AppDashboardTheme.proCardFill
  390. : NSColor(srgbRed: 244 / 255, green: 248 / 255, blue: 255 / 255, alpha: 1)
  391. }
  392. static var divider: NSColor {
  393. AppDashboardTheme.isDark
  394. ? AppDashboardTheme.border
  395. : NSColor(srgbRed: 218 / 255, green: 228 / 255, blue: 247 / 255, alpha: 1)
  396. }
  397. static var successText: NSColor {
  398. AppDashboardTheme.isDark
  399. ? AppDashboardTheme.welcomeHeroHeadingBlue
  400. : NSColor(srgbRed: 21 / 255, green: 154 / 255, blue: 220 / 255, alpha: 1)
  401. }
  402. static var iconTint: NSColor { AppDashboardTheme.brandBlue }
  403. static var closeButtonBackground: NSColor {
  404. AppDashboardTheme.isDark
  405. ? AppDashboardTheme.cardBackground
  406. : NSColor.white.withAlphaComponent(0.92)
  407. }
  408. static var closeButtonBackgroundHover: NSColor {
  409. AppDashboardTheme.isDark
  410. ? AppDashboardTheme.neutralHoverFill
  411. : NSColor.white.withAlphaComponent(0.98)
  412. }
  413. }
  414. private struct PricingCardAppearanceTarget {
  415. let card: HoverPricingCardView
  416. let iconWell: NSView
  417. let planIconView: NSImageView
  418. let planId: String
  419. let titleLabel: NSTextField
  420. let subtitleLabel: NSTextField
  421. let priceLabel: NSTextField
  422. let periodLabel: NSTextField
  423. let billingLabel: NSTextField?
  424. let divider: NSBox
  425. let featureLabels: [NSTextField]
  426. let featureIcons: [NSImageView]
  427. let purchaseButton: PlanPurchaseHoverButton
  428. }
  429. private enum FeatureListMetrics {
  430. static let rowSpacing = CGFloat(8)
  431. static let iconLabelSpacing = CGFloat(8)
  432. static let edgeInsets = NSEdgeInsets(top: 8, left: 0, bottom: 8, right: 0)
  433. /// Caps feature list height so pricing cards do not push the footer off-screen.
  434. static let maxScrollHeight = CGFloat(168)
  435. }
  436. private let subscriptionStore = SubscriptionStore.shared
  437. private var planPriceFields: [String: (price: NSTextField, period: NSTextField)] = [:]
  438. private var planPurchaseButtons: [String: NSButton] = [:]
  439. private var subscriptionPrimaryFooterButton: NSButton?
  440. private var premiumCloseButton: PremiumCloseHoverButton?
  441. private var subscriptionStatusObservation: NSObjectProtocol?
  442. private var appearanceObserver: NSObjectProtocol?
  443. /// Core Pro capabilities shown on every pricing card (replaces generic “All premium features”).
  444. private static let proCapabilityFeatures = [
  445. "Unlimited AI job search on Home",
  446. "Save jobs & open listings in-app",
  447. "CV Maker, profiles & PDF export",
  448. "Role, company & skill shortcuts"
  449. ]
  450. private let plans: [Plan] = [
  451. Plan(
  452. id: "weekly",
  453. title: "Weekly",
  454. subtitle: "Flexible and commitment-free",
  455. period: "/ week",
  456. billedPill: "",
  457. billedLine: "",
  458. crossedPrice: nil,
  459. savingsText: nil,
  460. features: proCapabilityFeatures + [
  461. "Perfect for short-term job hunts",
  462. "Cancel anytime"
  463. ],
  464. iconName: "paperplane.fill",
  465. iconTint: Theme.iconTint,
  466. highlight: false
  467. ),
  468. Plan(
  469. id: "monthly",
  470. title: "Monthly",
  471. subtitle: "Balanced for regular productivity",
  472. period: "/ month",
  473. billedPill: "",
  474. billedLine: "",
  475. crossedPrice: nil,
  476. savingsText: nil,
  477. features: proCapabilityFeatures + [
  478. "Best for regular job seekers",
  479. "Priority support"
  480. ],
  481. iconName: "bolt.fill",
  482. iconTint: Theme.accent,
  483. highlight: true
  484. ),
  485. Plan(
  486. id: "yearly",
  487. title: "Yearly",
  488. subtitle: "Best value for long-term users",
  489. period: "/ year",
  490. billedPill: "3 days free trial",
  491. billedLine: "",
  492. crossedPrice: nil,
  493. savingsText: nil,
  494. features: proCapabilityFeatures + [
  495. "Lowest effective monthly cost",
  496. "Ideal for long-term use"
  497. ],
  498. iconName: "crown.fill",
  499. iconTint: Theme.successText,
  500. highlight: false
  501. )
  502. ]
  503. private let pageGradient = CAGradientLayer()
  504. private var premiumTitleLabel: NSTextField?
  505. private var premiumSubtitleLabel: NSTextField?
  506. private var pricingCardTargets: [PricingCardAppearanceTarget] = []
  507. private weak var trustBadgesRow: NSStackView?
  508. private var footerLinkButtons: [FooterLinkButton] = []
  509. deinit {
  510. if let subscriptionStatusObservation {
  511. NotificationCenter.default.removeObserver(subscriptionStatusObservation)
  512. }
  513. if let appearanceObserver {
  514. NotificationCenter.default.removeObserver(appearanceObserver)
  515. }
  516. }
  517. override func viewDidLoad() {
  518. super.viewDidLoad()
  519. appearanceObserver = NotificationCenter.default.addObserver(
  520. forName: AppAppearanceManager.didChangeNotification,
  521. object: nil,
  522. queue: .main
  523. ) { [weak self] _ in
  524. self?.applyCurrentAppearance()
  525. }
  526. subscriptionStatusObservation = NotificationCenter.default.addObserver(
  527. forName: .subscriptionStatusDidChange,
  528. object: nil,
  529. queue: .main
  530. ) { [weak self] _ in
  531. Task { @MainActor in
  532. await self?.subscriptionStore.loadProducts()
  533. self?.applyStorePricing()
  534. self?.updateSubscriptionPrimaryFooter()
  535. self?.updatePremiumCloseButtonVisibility()
  536. }
  537. }
  538. Task { @MainActor in
  539. await loadStoreProducts()
  540. }
  541. }
  542. override func viewDidLayout() {
  543. super.viewDidLayout()
  544. pageGradient.frame = view.bounds
  545. }
  546. override func loadView() {
  547. view = NSView()
  548. view.wantsLayer = true
  549. pageGradient.colors = [Theme.pageStart.cgColor, Theme.pageEnd.cgColor]
  550. pageGradient.startPoint = CGPoint(x: 0, y: 1)
  551. pageGradient.endPoint = CGPoint(x: 1, y: 0)
  552. view.layer?.addSublayer(pageGradient)
  553. setupLayout()
  554. applyCurrentAppearance()
  555. }
  556. private func setupLayout() {
  557. let closeButton = PremiumCloseHoverButton(target: self, action: #selector(didTapClose))
  558. let crownIcon = NSImageView()
  559. crownIcon.translatesAutoresizingMaskIntoConstraints = false
  560. crownIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
  561. crownIcon.image = NSImage(systemSymbolName: "crown.fill", accessibilityDescription: nil)
  562. crownIcon.contentTintColor = NSColor(srgbRed: 254 / 255, green: 214 / 255, blue: 92 / 255, alpha: 1)
  563. let title = NSTextField(labelWithString: "Upgrade to Pro")
  564. title.font = .systemFont(ofSize: 40, weight: .semibold)
  565. title.textColor = Theme.primaryText
  566. title.alignment = .center
  567. premiumTitleLabel = title
  568. let subtitle = NSTextField(labelWithString: "Unlock unlimited access to premium tools and boost your productivity.")
  569. subtitle.font = .systemFont(ofSize: 14, weight: .regular)
  570. subtitle.textColor = Theme.secondaryText
  571. subtitle.alignment = .center
  572. premiumSubtitleLabel = subtitle
  573. pricingCardTargets = []
  574. let cardsRow = NSStackView(views: plans.map(makePricingCard(_:)))
  575. cardsRow.orientation = .horizontal
  576. cardsRow.spacing = 14
  577. cardsRow.alignment = .top
  578. cardsRow.distribution = .fillEqually
  579. cardsRow.translatesAutoresizingMaskIntoConstraints = false
  580. let trustRow = makeTrustRow()
  581. let footerRow = makeFooterRow()
  582. let headerStack = NSStackView(views: [crownIcon, title, subtitle])
  583. headerStack.orientation = .vertical
  584. headerStack.spacing = 10
  585. headerStack.alignment = .centerX
  586. headerStack.translatesAutoresizingMaskIntoConstraints = false
  587. let bodyStack = NSStackView(views: [cardsRow, trustRow])
  588. bodyStack.orientation = .vertical
  589. bodyStack.spacing = 16
  590. bodyStack.alignment = .centerX
  591. bodyStack.translatesAutoresizingMaskIntoConstraints = false
  592. let scrollView = NSScrollView()
  593. scrollView.hasVerticalScroller = true
  594. scrollView.autohidesScrollers = true
  595. scrollView.drawsBackground = false
  596. scrollView.borderType = .noBorder
  597. scrollView.translatesAutoresizingMaskIntoConstraints = false
  598. let scrollDocument = NSView()
  599. scrollDocument.translatesAutoresizingMaskIntoConstraints = false
  600. scrollView.documentView = scrollDocument
  601. scrollDocument.addSubview(bodyStack)
  602. view.addSubview(headerStack)
  603. view.addSubview(scrollView)
  604. view.addSubview(footerRow)
  605. view.addSubview(closeButton)
  606. let scrollClip = scrollView.contentView
  607. NSLayoutConstraint.activate([
  608. headerStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
  609. headerStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
  610. headerStack.topAnchor.constraint(equalTo: view.topAnchor, constant: 20),
  611. scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
  612. scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
  613. scrollView.topAnchor.constraint(equalTo: headerStack.bottomAnchor, constant: 12),
  614. scrollView.bottomAnchor.constraint(equalTo: footerRow.topAnchor, constant: -12),
  615. footerRow.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
  616. footerRow.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
  617. footerRow.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -16),
  618. footerRow.heightAnchor.constraint(greaterThanOrEqualToConstant: 32),
  619. scrollDocument.leadingAnchor.constraint(equalTo: scrollClip.leadingAnchor),
  620. scrollDocument.trailingAnchor.constraint(equalTo: scrollClip.trailingAnchor),
  621. scrollDocument.topAnchor.constraint(equalTo: scrollClip.topAnchor),
  622. scrollDocument.widthAnchor.constraint(equalTo: scrollClip.widthAnchor),
  623. scrollDocument.bottomAnchor.constraint(equalTo: bodyStack.bottomAnchor, constant: 8),
  624. bodyStack.leadingAnchor.constraint(equalTo: scrollDocument.leadingAnchor),
  625. bodyStack.trailingAnchor.constraint(equalTo: scrollDocument.trailingAnchor),
  626. bodyStack.topAnchor.constraint(equalTo: scrollDocument.topAnchor, constant: 4),
  627. bodyStack.widthAnchor.constraint(equalTo: scrollDocument.widthAnchor),
  628. cardsRow.widthAnchor.constraint(equalTo: bodyStack.widthAnchor),
  629. trustRow.widthAnchor.constraint(equalTo: bodyStack.widthAnchor),
  630. closeButton.topAnchor.constraint(equalTo: view.topAnchor, constant: 14),
  631. closeButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -14),
  632. closeButton.widthAnchor.constraint(equalToConstant: 30),
  633. closeButton.heightAnchor.constraint(equalToConstant: 30),
  634. crownIcon.heightAnchor.constraint(equalToConstant: 20)
  635. ])
  636. premiumCloseButton = closeButton
  637. updatePremiumCloseButtonVisibility()
  638. }
  639. private func updatePremiumCloseButtonVisibility() {
  640. premiumCloseButton?.isHidden = !subscriptionStore.isProActive
  641. }
  642. private func makePricingCard(_ plan: Plan) -> NSView {
  643. let card = HoverPricingCardView(baseBorderColor: Theme.cardBorder, hoverBorderColor: Theme.accent)
  644. card.translatesAutoresizingMaskIntoConstraints = false
  645. card.layer?.backgroundColor = Theme.cardBackground.cgColor
  646. let iconWell = NSView()
  647. iconWell.translatesAutoresizingMaskIntoConstraints = false
  648. iconWell.wantsLayer = true
  649. iconWell.layer?.cornerRadius = 10
  650. iconWell.layer?.backgroundColor = Theme.bottomStrip.cgColor
  651. iconWell.widthAnchor.constraint(equalToConstant: 24).isActive = true
  652. iconWell.heightAnchor.constraint(equalToConstant: 24).isActive = true
  653. let icon = NSImageView()
  654. icon.translatesAutoresizingMaskIntoConstraints = false
  655. icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .bold)
  656. icon.image = NSImage(systemSymbolName: plan.iconName, accessibilityDescription: nil)
  657. icon.contentTintColor = plan.iconTint
  658. iconWell.addSubview(icon)
  659. NSLayoutConstraint.activate([
  660. icon.centerXAnchor.constraint(equalTo: iconWell.centerXAnchor),
  661. icon.centerYAnchor.constraint(equalTo: iconWell.centerYAnchor)
  662. ])
  663. let titleLabel = NSTextField(labelWithString: plan.title)
  664. titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
  665. titleLabel.textColor = Theme.primaryText
  666. titleLabel.alignment = .center
  667. let subtitleLabel = NSTextField(labelWithString: plan.subtitle)
  668. subtitleLabel.font = .systemFont(ofSize: 12, weight: .regular)
  669. subtitleLabel.textColor = Theme.secondaryText
  670. subtitleLabel.alignment = .center
  671. let topRightTag = pillLabel(text: plan.billedPill, tint: Theme.bottomStrip, textColor: Theme.iconTint)
  672. topRightTag.isHidden = plan.billedPill.isEmpty
  673. topRightTag.font = .systemFont(ofSize: 10, weight: .bold)
  674. let priceLabel = NSTextField(labelWithString: Self.unloadedPricePlaceholder)
  675. priceLabel.font = .systemFont(ofSize: 18, weight: .semibold)
  676. priceLabel.textColor = Theme.primaryText
  677. let periodLabel = NSTextField(labelWithString: plan.period)
  678. periodLabel.font = .systemFont(ofSize: 13, weight: .medium)
  679. periodLabel.textColor = Theme.secondaryText
  680. let priceRow = NSStackView(views: [priceLabel, periodLabel])
  681. priceRow.orientation = .horizontal
  682. priceRow.spacing = 4
  683. priceRow.alignment = .firstBaseline
  684. let billingLabel = NSTextField(labelWithString: plan.billedLine)
  685. billingLabel.font = .systemFont(ofSize: 13, weight: .medium)
  686. billingLabel.textColor = Theme.secondaryText
  687. let inlinePriceInfo = inlinePriceInfoLabel(oldPrice: plan.crossedPrice, newPrice: plan.savingsText)
  688. let divider = NSBox()
  689. divider.boxType = .separator
  690. divider.translatesAutoresizingMaskIntoConstraints = false
  691. divider.borderColor = Theme.divider
  692. let featureRows = plan.features.map(makeFeatureRow(_:))
  693. let featuresStack = NSStackView(views: featureRows)
  694. featuresStack.orientation = .vertical
  695. featuresStack.spacing = FeatureListMetrics.rowSpacing
  696. featuresStack.alignment = .leading
  697. featuresStack.edgeInsets = FeatureListMetrics.edgeInsets
  698. featuresStack.setContentCompressionResistancePriority(.required, for: .vertical)
  699. featuresStack.setContentHuggingPriority(.defaultHigh, for: .vertical)
  700. for row in featureRows {
  701. row.setContentCompressionResistancePriority(.required, for: .vertical)
  702. }
  703. let featuresScroll = makeFeatureListScroll(featuresStack: featuresStack)
  704. let selectButton = PlanPurchaseHoverButton(
  705. planId: plan.id,
  706. title: "Get \(plan.title)",
  707. isPrimaryStyle: plan.highlight,
  708. target: self,
  709. action: #selector(didTapSelectPlan)
  710. )
  711. planPurchaseButtons[plan.id] = selectButton
  712. planPriceFields[plan.id] = (priceLabel, periodLabel)
  713. selectButton.heightAnchor.constraint(equalToConstant: 50).isActive = true
  714. let featureLabels = featureRows.compactMap { row -> NSTextField? in
  715. (row as? NSStackView)?.arrangedSubviews.compactMap { $0 as? NSTextField }.first
  716. }
  717. let featureIcons = featureRows.compactMap { row -> NSImageView? in
  718. (row as? NSStackView)?.arrangedSubviews.compactMap { $0 as? NSImageView }.first
  719. }
  720. pricingCardTargets.append(
  721. PricingCardAppearanceTarget(
  722. card: card,
  723. iconWell: iconWell,
  724. planIconView: icon,
  725. planId: plan.id,
  726. titleLabel: titleLabel,
  727. subtitleLabel: subtitleLabel,
  728. priceLabel: priceLabel,
  729. periodLabel: periodLabel,
  730. billingLabel: plan.billedLine.isEmpty ? nil : billingLabel,
  731. divider: divider,
  732. featureLabels: featureLabels,
  733. featureIcons: featureIcons,
  734. purchaseButton: selectButton
  735. )
  736. )
  737. var contentViews: [NSView] = [iconWell, titleLabel, subtitleLabel, priceRow]
  738. if !plan.billedLine.isEmpty {
  739. contentViews.append(billingLabel)
  740. }
  741. if plan.crossedPrice != nil, plan.savingsText != nil {
  742. contentViews.append(inlinePriceInfo)
  743. }
  744. contentViews.append(contentsOf: [divider, featuresScroll])
  745. let column = NSStackView(views: contentViews + [selectButton])
  746. column.orientation = .vertical
  747. column.spacing = 10
  748. column.alignment = .centerX
  749. column.distribution = .fill
  750. column.translatesAutoresizingMaskIntoConstraints = false
  751. card.addSubview(column)
  752. card.addSubview(topRightTag)
  753. NSLayoutConstraint.activate([
  754. divider.widthAnchor.constraint(equalTo: column.widthAnchor),
  755. featuresScroll.widthAnchor.constraint(equalTo: column.widthAnchor),
  756. column.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 18),
  757. column.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -18),
  758. column.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
  759. column.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -12),
  760. selectButton.widthAnchor.constraint(equalTo: column.widthAnchor),
  761. topRightTag.topAnchor.constraint(equalTo: card.topAnchor, constant: 12),
  762. topRightTag.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12)
  763. ])
  764. return card
  765. }
  766. private func makeFeatureListScroll(featuresStack: NSStackView) -> NSScrollView {
  767. let scroll = NSScrollView()
  768. scroll.hasVerticalScroller = true
  769. scroll.autohidesScrollers = true
  770. scroll.drawsBackground = false
  771. scroll.borderType = .noBorder
  772. scroll.translatesAutoresizingMaskIntoConstraints = false
  773. let document = NSView()
  774. document.translatesAutoresizingMaskIntoConstraints = false
  775. featuresStack.translatesAutoresizingMaskIntoConstraints = false
  776. scroll.documentView = document
  777. document.addSubview(featuresStack)
  778. NSLayoutConstraint.activate([
  779. scroll.heightAnchor.constraint(equalToConstant: FeatureListMetrics.maxScrollHeight),
  780. document.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
  781. document.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
  782. document.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
  783. document.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor),
  784. document.bottomAnchor.constraint(equalTo: featuresStack.bottomAnchor, constant: FeatureListMetrics.edgeInsets.bottom),
  785. featuresStack.leadingAnchor.constraint(equalTo: document.leadingAnchor, constant: FeatureListMetrics.edgeInsets.left),
  786. featuresStack.trailingAnchor.constraint(equalTo: document.trailingAnchor, constant: -FeatureListMetrics.edgeInsets.right),
  787. featuresStack.topAnchor.constraint(equalTo: document.topAnchor, constant: FeatureListMetrics.edgeInsets.top),
  788. featuresStack.widthAnchor.constraint(equalTo: document.widthAnchor, constant: -(FeatureListMetrics.edgeInsets.left + FeatureListMetrics.edgeInsets.right))
  789. ])
  790. return scroll
  791. }
  792. private func makeFeatureRow(_ text: String) -> NSView {
  793. let icon = NSImageView()
  794. icon.translatesAutoresizingMaskIntoConstraints = false
  795. icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .bold)
  796. icon.image = NSImage(systemSymbolName: "checkmark.circle.fill", accessibilityDescription: nil)
  797. icon.contentTintColor = Theme.iconTint
  798. icon.widthAnchor.constraint(equalToConstant: 14).isActive = true
  799. icon.heightAnchor.constraint(equalToConstant: 14).isActive = true
  800. icon.setContentHuggingPriority(.required, for: .horizontal)
  801. icon.setContentHuggingPriority(.required, for: .vertical)
  802. let label = NSTextField(wrappingLabelWithString: text)
  803. label.font = .systemFont(ofSize: 13, weight: .medium)
  804. label.textColor = Theme.primaryText
  805. label.alignment = .left
  806. label.lineBreakMode = .byWordWrapping
  807. label.maximumNumberOfLines = 0
  808. label.cell?.wraps = true
  809. label.cell?.isScrollable = false
  810. label.setContentCompressionResistancePriority(.required, for: .vertical)
  811. label.setContentHuggingPriority(.required, for: .vertical)
  812. label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  813. label.setContentHuggingPriority(.defaultLow, for: .horizontal)
  814. let row = NSStackView(views: [icon, label])
  815. row.orientation = .horizontal
  816. row.spacing = FeatureListMetrics.iconLabelSpacing
  817. row.alignment = .top
  818. row.distribution = .fill
  819. row.translatesAutoresizingMaskIntoConstraints = false
  820. return row
  821. }
  822. private func inlinePriceInfoLabel(oldPrice: String?, newPrice: String?) -> NSTextField {
  823. guard let oldPrice, let newPrice else {
  824. return NSTextField(labelWithString: "")
  825. }
  826. let full = NSMutableAttributedString()
  827. let oldAttributes: [NSAttributedString.Key: Any] = [
  828. .font: NSFont.systemFont(ofSize: 12, weight: .semibold),
  829. .foregroundColor: Theme.secondaryText,
  830. .strikethroughStyle: NSUnderlineStyle.single.rawValue
  831. ]
  832. let newAttributes: [NSAttributedString.Key: Any] = [
  833. .font: NSFont.systemFont(ofSize: 12, weight: .bold),
  834. .foregroundColor: Theme.successText
  835. ]
  836. full.append(NSAttributedString(string: "\(oldPrice) ", attributes: oldAttributes))
  837. full.append(NSAttributedString(string: newPrice, attributes: newAttributes))
  838. let label = NSTextField(labelWithAttributedString: full)
  839. return label
  840. }
  841. private func pillLabel(text: String, tint: NSColor, textColor: NSColor) -> NSTextField {
  842. let pill = NSTextField(labelWithString: text)
  843. pill.font = .systemFont(ofSize: 10, weight: .semibold)
  844. pill.textColor = textColor
  845. pill.alignment = .center
  846. pill.wantsLayer = true
  847. pill.layer?.backgroundColor = tint.cgColor
  848. pill.layer?.cornerRadius = 9
  849. pill.translatesAutoresizingMaskIntoConstraints = false
  850. pill.heightAnchor.constraint(equalToConstant: 18).isActive = true
  851. pill.widthAnchor.constraint(greaterThanOrEqualToConstant: 95).isActive = true
  852. return pill
  853. }
  854. private func makeTrustRow() -> NSView {
  855. let badges = NSStackView(views: [
  856. trustBadge(icon: "shield.fill", title: "Secure Payments", subtitle: "Your payment is 100% secure."),
  857. trustBadge(icon: "arrow.counterclockwise", title: "Cancel Anytime", subtitle: "No commitment, cancel anytime."),
  858. trustBadge(icon: "headphones", title: "24/7 Support", subtitle: "We're here to help you anytime."),
  859. trustBadge(icon: "lock.fill", title: "Privacy First", subtitle: "Your data is safe with us.")
  860. ])
  861. badges.orientation = .horizontal
  862. badges.alignment = .centerY
  863. badges.distribution = .fillEqually
  864. badges.spacing = 12
  865. badges.edgeInsets = NSEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
  866. badges.translatesAutoresizingMaskIntoConstraints = false
  867. badges.wantsLayer = true
  868. badges.layer?.backgroundColor = Theme.bottomStrip.cgColor
  869. badges.layer?.borderColor = Theme.divider.cgColor
  870. badges.layer?.borderWidth = 1
  871. badges.layer?.cornerRadius = 10
  872. badges.setHuggingPriority(.defaultLow, for: .horizontal)
  873. badges.heightAnchor.constraint(equalToConstant: 72).isActive = true
  874. trustBadgesRow = badges
  875. return badges
  876. }
  877. private func makeFooterRow() -> NSView {
  878. footerLinkButtons = []
  879. let primary = footerActionCell(
  880. title: subscriptionPrimaryFooterTitle(),
  881. action: #selector(didTapPrimaryFooterSubscriptionAction),
  882. showsTrailingDivider: true
  883. )
  884. subscriptionPrimaryFooterButton = primary.button
  885. let entries: [(text: String, action: Selector)] = [
  886. ("Restore Purchase", #selector(didTapRestorePurchases)),
  887. ("Privacy Policy", #selector(didTapFooterPrivacyPolicy)),
  888. ("Terms of Use", #selector(didTapFooterTermsOfServices)),
  889. ("Support", #selector(didTapFooterSupport))
  890. ]
  891. let cells = [primary.container] + entries.enumerated().map { index, entry in
  892. footerActionCell(title: entry.text, action: entry.action, showsTrailingDivider: index < entries.count - 1).container
  893. }
  894. let links = NSStackView(views: cells)
  895. links.orientation = .horizontal
  896. links.distribution = .fillEqually
  897. links.spacing = 0
  898. links.alignment = .centerY
  899. links.translatesAutoresizingMaskIntoConstraints = false
  900. return links
  901. }
  902. private func footerActionCell(title: String, action: Selector, showsTrailingDivider: Bool) -> (container: NSView, button: NSButton) {
  903. let container = NSView()
  904. container.translatesAutoresizingMaskIntoConstraints = false
  905. let button = FooterLinkButton(title: title, target: self, action: action)
  906. button.isBordered = false
  907. button.bezelStyle = .rounded
  908. button.font = .systemFont(ofSize: 12, weight: .medium)
  909. button.contentTintColor = Theme.secondaryText
  910. button.focusRingType = .none
  911. button.translatesAutoresizingMaskIntoConstraints = false
  912. footerLinkButtons.append(button)
  913. container.addSubview(button)
  914. var constraints: [NSLayoutConstraint] = [
  915. button.centerXAnchor.constraint(equalTo: container.centerXAnchor),
  916. button.centerYAnchor.constraint(equalTo: container.centerYAnchor)
  917. ]
  918. if showsTrailingDivider {
  919. let divider = footerDivider()
  920. container.addSubview(divider)
  921. constraints.append(contentsOf: [
  922. divider.trailingAnchor.constraint(equalTo: container.trailingAnchor),
  923. divider.centerYAnchor.constraint(equalTo: container.centerYAnchor)
  924. ])
  925. }
  926. NSLayoutConstraint.activate(constraints)
  927. return (container, button)
  928. }
  929. private enum PrimaryFooterSubscriptionTitle {
  930. static let manage = "Manage Subscription"
  931. static let continueFree = "Continue with free plan"
  932. }
  933. private func subscriptionPrimaryFooterTitle() -> String {
  934. subscriptionStore.isProActive ? PrimaryFooterSubscriptionTitle.manage : PrimaryFooterSubscriptionTitle.continueFree
  935. }
  936. private func updateSubscriptionPrimaryFooter() {
  937. subscriptionPrimaryFooterButton?.title = subscriptionPrimaryFooterTitle()
  938. }
  939. private func trustBadge(icon: String, title: String, subtitle: String) -> NSView {
  940. let image = NSImageView()
  941. image.translatesAutoresizingMaskIntoConstraints = false
  942. image.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
  943. image.image = NSImage(systemSymbolName: icon, accessibilityDescription: nil)
  944. image.contentTintColor = Theme.primaryText
  945. let titleLabel = NSTextField(labelWithString: title)
  946. titleLabel.font = .systemFont(ofSize: 12, weight: .bold)
  947. titleLabel.textColor = Theme.primaryText
  948. let subtitleLabel = NSTextField(labelWithString: subtitle)
  949. subtitleLabel.font = .systemFont(ofSize: 10, weight: .medium)
  950. subtitleLabel.textColor = Theme.secondaryText
  951. let textStack = NSStackView(views: [titleLabel, subtitleLabel])
  952. textStack.orientation = .vertical
  953. textStack.spacing = 2
  954. textStack.alignment = .leading
  955. let stack = NSStackView(views: [image, textStack])
  956. stack.orientation = .horizontal
  957. stack.spacing = 8
  958. stack.alignment = .leading
  959. stack.edgeInsets = NSEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
  960. stack.wantsLayer = true
  961. stack.layer?.backgroundColor = NSColor.clear.cgColor
  962. return stack
  963. }
  964. private func footerDivider() -> NSBox {
  965. let divider = NSBox()
  966. divider.boxType = .separator
  967. divider.borderColor = Theme.divider
  968. divider.translatesAutoresizingMaskIntoConstraints = false
  969. divider.widthAnchor.constraint(equalToConstant: 1).isActive = true
  970. divider.heightAnchor.constraint(equalToConstant: 14).isActive = true
  971. return divider
  972. }
  973. @objc private func didTapSelectPlan(_ sender: NSButton) {
  974. guard let planKey = sender.identifier?.rawValue else { return }
  975. Task { await purchasePlan(planKey: planKey) }
  976. }
  977. @objc private func didTapPrimaryFooterSubscriptionAction(_ sender: NSButton) {
  978. let userTappedManage = (sender.title == PrimaryFooterSubscriptionTitle.manage)
  979. Task { @MainActor [weak self] in
  980. guard let self else { return }
  981. await subscriptionStore.refreshEntitlements(deep: true)
  982. updateSubscriptionPrimaryFooter()
  983. let active = subscriptionStore.isProActive
  984. if active || userTappedManage {
  985. guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
  986. NSWorkspace.shared.open(url)
  987. return
  988. }
  989. // Non-pro: dismiss paywall and return to the home (dashboard) window.
  990. dismissPremiumSheetFromParentIfNeeded()
  991. }
  992. }
  993. @objc private func didTapRestorePurchases() {
  994. Task { await restorePurchases() }
  995. }
  996. @objc private func didTapFooterPrivacyPolicy() {
  997. AppLegalURLs.openInSafari(AppLegalURLs.privacyPolicy)
  998. }
  999. @objc private func didTapFooterTermsOfServices() {
  1000. AppLegalURLs.openInSafari(AppLegalURLs.termsOfUse)
  1001. }
  1002. @objc private func didTapFooterSupport() {
  1003. AppLegalURLs.openInSafari(AppLegalURLs.support)
  1004. }
  1005. private func loadStoreProducts() async {
  1006. await subscriptionStore.refreshEntitlements(deep: true)
  1007. await subscriptionStore.loadProducts()
  1008. applyStorePricing()
  1009. updateSubscriptionPrimaryFooter()
  1010. updatePremiumCloseButtonVisibility()
  1011. }
  1012. private func applyStorePricing() {
  1013. for plan in plans {
  1014. guard let fields = planPriceFields[plan.id] else { continue }
  1015. guard let product = subscriptionStore.product(forPlanKey: plan.id) else {
  1016. fields.price.stringValue = Self.unloadedPricePlaceholder
  1017. continue
  1018. }
  1019. fields.price.stringValue = product.displayPrice
  1020. if let period = product.subscription?.subscriptionPeriod {
  1021. fields.period.stringValue = periodSuffix(for: period)
  1022. }
  1023. }
  1024. }
  1025. private func periodSuffix(for period: Product.SubscriptionPeriod) -> String {
  1026. let value = period.value
  1027. switch period.unit {
  1028. case .day: return value == 1 ? "/ day" : "/ \(value) days"
  1029. case .week: return value == 1 ? "/ week" : "/ \(value) weeks"
  1030. case .month: return value == 1 ? "/ month" : "/ \(value) months"
  1031. case .year: return value == 1 ? "/ year" : "/ \(value) years"
  1032. @unknown default: return ""
  1033. }
  1034. }
  1035. private func setPurchasing(_ isPurchasing: Bool) {
  1036. for button in planPurchaseButtons.values {
  1037. button.isEnabled = !isPurchasing
  1038. }
  1039. }
  1040. private func purchasePlan(planKey: String) async {
  1041. setPurchasing(true)
  1042. defer { setPurchasing(false) }
  1043. do {
  1044. let completed = try await subscriptionStore.purchase(planKey: planKey)
  1045. guard completed else { return }
  1046. AppRatingCoordinator.shared.scheduleReviewAfterSubscriptionPurchase()
  1047. let alert = NSAlert()
  1048. alert.messageText = "You're subscribed"
  1049. alert.informativeText = "Thank you — Pro features are now available."
  1050. alert.alertStyle = .informational
  1051. alert.addButton(withTitle: "OK")
  1052. if let window = view.window {
  1053. alert.beginSheetModal(for: window) { [weak self] _ in
  1054. self?.dismissPremiumSheetFromParentIfNeeded()
  1055. }
  1056. } else {
  1057. alert.runModal()
  1058. dismissPremiumSheetFromParentIfNeeded()
  1059. }
  1060. } catch {
  1061. await MainActor.run {
  1062. self.presentPurchaseError(error)
  1063. }
  1064. }
  1065. }
  1066. private func restorePurchases() async {
  1067. setPurchasing(true)
  1068. defer { setPurchasing(false) }
  1069. do {
  1070. try await subscriptionStore.restorePurchases()
  1071. let active = subscriptionStore.isProActive
  1072. let alert = NSAlert()
  1073. if active {
  1074. alert.messageText = "Purchases restored"
  1075. alert.informativeText = "Your subscription is active."
  1076. } else {
  1077. alert.messageText = "No subscription found"
  1078. alert.informativeText = "There was nothing to restore for this Apple ID."
  1079. }
  1080. alert.alertStyle = .informational
  1081. alert.addButton(withTitle: "OK")
  1082. if let window = view.window {
  1083. alert.beginSheetModal(for: window) { [weak self] _ in
  1084. if active {
  1085. self?.dismissPremiumSheetFromParentIfNeeded()
  1086. }
  1087. }
  1088. } else {
  1089. alert.runModal()
  1090. if active {
  1091. dismissPremiumSheetFromParentIfNeeded()
  1092. }
  1093. }
  1094. } catch {
  1095. await MainActor.run {
  1096. self.presentPurchaseError(error)
  1097. }
  1098. }
  1099. }
  1100. private func presentPurchaseError(_ error: Error) {
  1101. let alert = NSAlert()
  1102. alert.messageText = "Something went wrong"
  1103. if let localized = error as? LocalizedError {
  1104. var parts: [String] = []
  1105. if let description = localized.errorDescription {
  1106. parts.append(description)
  1107. }
  1108. if let recovery = localized.recoverySuggestion {
  1109. parts.append(recovery)
  1110. }
  1111. alert.informativeText = parts.isEmpty ? error.localizedDescription : parts.joined(separator: "\n\n")
  1112. } else {
  1113. alert.informativeText = error.localizedDescription
  1114. }
  1115. alert.alertStyle = .warning
  1116. alert.addButton(withTitle: "OK")
  1117. if let window = view.window {
  1118. alert.beginSheetModal(for: window)
  1119. } else {
  1120. alert.runModal()
  1121. }
  1122. }
  1123. private func dismissPremiumSheetFromParentIfNeeded() {
  1124. guard let sheet = view.window, let parent = sheet.sheetParent else { return }
  1125. parent.endSheet(sheet)
  1126. }
  1127. @objc private func didTapClose() {
  1128. guard let window = view.window else { return }
  1129. if let parent = window.sheetParent {
  1130. parent.endSheet(window)
  1131. return
  1132. }
  1133. window.close()
  1134. }
  1135. private func applyCurrentAppearance() {
  1136. view.window?.backgroundColor = PremiumPlansWindowController.paywallSheetBackground
  1137. pageGradient.colors = [Theme.pageStart.cgColor, Theme.pageEnd.cgColor]
  1138. premiumTitleLabel?.textColor = Theme.primaryText
  1139. premiumSubtitleLabel?.textColor = Theme.secondaryText
  1140. for target in pricingCardTargets {
  1141. target.card.updateBorderColors(base: Theme.cardBorder, hover: Theme.accent)
  1142. target.card.layer?.backgroundColor = Theme.cardBackground.cgColor
  1143. target.iconWell.layer?.backgroundColor = Theme.bottomStrip.cgColor
  1144. target.planIconView.contentTintColor = planIconTint(planId: target.planId)
  1145. target.titleLabel.textColor = Theme.primaryText
  1146. target.subtitleLabel.textColor = Theme.secondaryText
  1147. target.priceLabel.textColor = Theme.primaryText
  1148. target.periodLabel.textColor = Theme.secondaryText
  1149. target.billingLabel?.textColor = Theme.secondaryText
  1150. target.divider.borderColor = Theme.divider
  1151. for label in target.featureLabels {
  1152. label.textColor = Theme.primaryText
  1153. }
  1154. for icon in target.featureIcons {
  1155. icon.contentTintColor = Theme.iconTint
  1156. }
  1157. target.purchaseButton.refreshAppearance()
  1158. }
  1159. if let trustBadgesRow {
  1160. trustBadgesRow.layer?.backgroundColor = Theme.bottomStrip.cgColor
  1161. trustBadgesRow.layer?.borderColor = Theme.divider.cgColor
  1162. for case let badge as NSStackView in trustBadgesRow.arrangedSubviews {
  1163. for case let image as NSImageView in badge.arrangedSubviews {
  1164. image.contentTintColor = Theme.primaryText
  1165. }
  1166. for case let textStack as NSStackView in badge.arrangedSubviews {
  1167. let labels = textStack.arrangedSubviews.compactMap { $0 as? NSTextField }
  1168. if labels.count >= 2 {
  1169. labels[0].textColor = Theme.primaryText
  1170. labels[1].textColor = Theme.secondaryText
  1171. }
  1172. }
  1173. }
  1174. }
  1175. for button in footerLinkButtons {
  1176. button.contentTintColor = Theme.secondaryText
  1177. }
  1178. premiumCloseButton?.refreshAppearance(hovered: false)
  1179. }
  1180. private func planIconTint(planId: String) -> NSColor {
  1181. switch planId {
  1182. case "monthly": Theme.accent
  1183. case "yearly": Theme.successText
  1184. default: Theme.iconTint
  1185. }
  1186. }
  1187. }