Sin descripción

PremiumPlansWindowController.swift 25KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593
  1. import Cocoa
  2. final class PremiumPlansWindowController: NSWindowController {
  3. init() {
  4. let viewController = PremiumPlansViewController()
  5. let window = NSWindow(contentViewController: viewController)
  6. window.title = "Premium Plans"
  7. window.styleMask = [.titled, .closable, .miniaturizable, .resizable]
  8. window.styleMask.insert(.fullSizeContentView)
  9. window.titlebarAppearsTransparent = true
  10. window.titleVisibility = .hidden
  11. window.isOpaque = false
  12. window.backgroundColor = .clear
  13. window.setContentSize(NSSize(width: 1160, height: 760))
  14. window.minSize = NSSize(width: 980, height: 680)
  15. window.center()
  16. super.init(window: window)
  17. if let frameView = window.contentView?.superview {
  18. frameView.wantsLayer = true
  19. frameView.layer?.cornerRadius = 18
  20. frameView.layer?.masksToBounds = true
  21. }
  22. }
  23. @available(*, unavailable)
  24. required init?(coder: NSCoder) {
  25. nil
  26. }
  27. }
  28. private final class PremiumPlansViewController: NSViewController {
  29. private final class HoverPricingCardView: NSView {
  30. private let baseBorderColor: NSColor
  31. private let hoverBorderColor: NSColor
  32. private var trackingAreaRef: NSTrackingArea?
  33. init(baseBorderColor: NSColor, hoverBorderColor: NSColor) {
  34. self.baseBorderColor = baseBorderColor
  35. self.hoverBorderColor = hoverBorderColor
  36. super.init(frame: .zero)
  37. wantsLayer = true
  38. layer?.cornerRadius = 16
  39. applyHoverStyle(isHovered: false, animated: false)
  40. }
  41. @available(*, unavailable)
  42. required init?(coder: NSCoder) {
  43. nil
  44. }
  45. override func updateTrackingAreas() {
  46. super.updateTrackingAreas()
  47. if let trackingAreaRef {
  48. removeTrackingArea(trackingAreaRef)
  49. }
  50. let options: NSTrackingArea.Options = [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect]
  51. let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
  52. addTrackingArea(area)
  53. trackingAreaRef = area
  54. }
  55. override func mouseEntered(with event: NSEvent) {
  56. super.mouseEntered(with: event)
  57. applyHoverStyle(isHovered: true, animated: true)
  58. }
  59. override func mouseExited(with event: NSEvent) {
  60. super.mouseExited(with: event)
  61. applyHoverStyle(isHovered: false, animated: true)
  62. }
  63. private func applyHoverStyle(isHovered: Bool, animated: Bool) {
  64. guard let layer else { return }
  65. let updates = {
  66. layer.borderWidth = isHovered ? 2 : 1
  67. layer.borderColor = (isHovered ? self.hoverBorderColor : self.baseBorderColor).cgColor
  68. layer.shadowColor = self.hoverBorderColor.withAlphaComponent(0.35).cgColor
  69. layer.shadowOpacity = isHovered ? 0.22 : 0
  70. layer.shadowRadius = isHovered ? 14 : 0
  71. layer.shadowOffset = .init(width: 0, height: -2)
  72. }
  73. if animated {
  74. NSAnimationContext.runAnimationGroup { context in
  75. context.duration = 0.16
  76. updates()
  77. }
  78. } else {
  79. updates()
  80. }
  81. }
  82. }
  83. private struct Plan {
  84. let id: String
  85. let title: String
  86. let subtitle: String
  87. let price: String
  88. let period: String
  89. let billedPill: String
  90. let billedLine: String
  91. let crossedPrice: String?
  92. let savingsText: String?
  93. let features: [String]
  94. let iconName: String
  95. let iconTint: NSColor
  96. let highlight: Bool
  97. }
  98. private enum Theme {
  99. static let pageStart = NSColor(srgbRed: 249 / 255, green: 252 / 255, blue: 255 / 255, alpha: 1)
  100. static let pageEnd = NSColor(srgbRed: 238 / 255, green: 244 / 255, blue: 255 / 255, alpha: 1)
  101. static let cardBackground = NSColor.white
  102. static let primaryText = NSColor(srgbRed: 27 / 255, green: 38 / 255, blue: 79 / 255, alpha: 1)
  103. static let secondaryText = NSColor(srgbRed: 108 / 255, green: 120 / 255, blue: 157 / 255, alpha: 1)
  104. static let cardBorder = NSColor(srgbRed: 198 / 255, green: 216 / 255, blue: 255 / 255, alpha: 1)
  105. static let accent = NSColor(srgbRed: 55 / 255, green: 128 / 255, blue: 255 / 255, alpha: 1)
  106. static let accentHover = NSColor(srgbRed: 38 / 255, green: 108 / 255, blue: 232 / 255, alpha: 1)
  107. static let mutedButtonFill = NSColor(srgbRed: 238 / 255, green: 243 / 255, blue: 252 / 255, alpha: 1)
  108. static let bottomStrip = NSColor(srgbRed: 244 / 255, green: 248 / 255, blue: 255 / 255, alpha: 1)
  109. static let divider = NSColor(srgbRed: 218 / 255, green: 228 / 255, blue: 247 / 255, alpha: 1)
  110. static let successText = NSColor(srgbRed: 21 / 255, green: 154 / 255, blue: 220 / 255, alpha: 1)
  111. static let iconTint = NSColor(srgbRed: 47 / 255, green: 136 / 255, blue: 255 / 255, alpha: 1)
  112. }
  113. private let plans: [Plan] = [
  114. Plan(
  115. id: "weekly",
  116. title: "Weekly",
  117. subtitle: "Flexible and commitment-free",
  118. price: "$9.99",
  119. period: "/ week",
  120. billedPill: "",
  121. billedLine: "",
  122. crossedPrice: nil,
  123. savingsText: nil,
  124. features: [
  125. "All premium features",
  126. "Perfect for short-term goals",
  127. "Cancel anytime"
  128. ],
  129. iconName: "paperplane.fill",
  130. iconTint: Theme.iconTint,
  131. highlight: false
  132. ),
  133. Plan(
  134. id: "monthly",
  135. title: "Monthly",
  136. subtitle: "Balanced for regular productivity",
  137. price: "$19.99",
  138. period: "/ month",
  139. billedPill: "",
  140. billedLine: "",
  141. crossedPrice: nil,
  142. savingsText: nil,
  143. features: [
  144. "All premium features",
  145. "Best value for regular users",
  146. "Priority support"
  147. ],
  148. iconName: "bolt.fill",
  149. iconTint: Theme.accent,
  150. highlight: true
  151. ),
  152. Plan(
  153. id: "yearly",
  154. title: "Yearly",
  155. subtitle: "Best value for long-term users",
  156. price: "$39.99",
  157. period: "/ year",
  158. billedPill: "3 days free trial",
  159. billedLine: "",
  160. crossedPrice: nil,
  161. savingsText: nil,
  162. features: [
  163. "All premium features",
  164. "Lowest effective monthly cost",
  165. "Ideal for long-term use"
  166. ],
  167. iconName: "crown.fill",
  168. iconTint: Theme.successText,
  169. highlight: false
  170. )
  171. ]
  172. private let pageGradient = CAGradientLayer()
  173. override func viewDidLayout() {
  174. super.viewDidLayout()
  175. pageGradient.frame = view.bounds
  176. }
  177. override func loadView() {
  178. view = NSView()
  179. view.wantsLayer = true
  180. view.layer?.cornerRadius = 18
  181. view.layer?.masksToBounds = true
  182. pageGradient.colors = [Theme.pageStart.cgColor, Theme.pageEnd.cgColor]
  183. pageGradient.startPoint = CGPoint(x: 0, y: 1)
  184. pageGradient.endPoint = CGPoint(x: 1, y: 0)
  185. view.layer?.addSublayer(pageGradient)
  186. setupLayout()
  187. }
  188. private func setupLayout() {
  189. let closeButton = NSButton(title: "", target: self, action: #selector(didTapClose))
  190. closeButton.translatesAutoresizingMaskIntoConstraints = false
  191. closeButton.isBordered = false
  192. closeButton.wantsLayer = true
  193. closeButton.layer?.cornerRadius = 15
  194. closeButton.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.92).cgColor
  195. closeButton.layer?.borderWidth = 1
  196. closeButton.layer?.borderColor = Theme.divider.cgColor
  197. closeButton.contentTintColor = Theme.secondaryText
  198. closeButton.image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close")
  199. closeButton.imageScaling = .scaleProportionallyDown
  200. closeButton.bezelStyle = .regularSquare
  201. let crownIcon = NSImageView()
  202. crownIcon.translatesAutoresizingMaskIntoConstraints = false
  203. crownIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
  204. crownIcon.image = NSImage(systemSymbolName: "crown.fill", accessibilityDescription: nil)
  205. crownIcon.contentTintColor = NSColor(srgbRed: 254 / 255, green: 214 / 255, blue: 92 / 255, alpha: 1)
  206. let title = NSTextField(labelWithString: "Upgrade to Pro")
  207. title.font = .systemFont(ofSize: 40, weight: .semibold)
  208. title.textColor = Theme.primaryText
  209. title.alignment = .center
  210. let subtitle = NSTextField(labelWithString: "Unlock unlimited access to premium tools and boost your productivity.")
  211. subtitle.font = .systemFont(ofSize: 14, weight: .regular)
  212. subtitle.textColor = Theme.secondaryText
  213. subtitle.alignment = .center
  214. let cardsRow = NSStackView(views: plans.map(makePricingCard(_:)))
  215. cardsRow.orientation = .horizontal
  216. cardsRow.spacing = 14
  217. cardsRow.alignment = .top
  218. cardsRow.distribution = .fillEqually
  219. cardsRow.translatesAutoresizingMaskIntoConstraints = false
  220. for card in cardsRow.arrangedSubviews {
  221. card.heightAnchor.constraint(equalTo: cardsRow.heightAnchor).isActive = true
  222. }
  223. let trustRow = makeTrustRow()
  224. let footerRow = makeFooterRow()
  225. let root = NSStackView(views: [crownIcon, title, subtitle, cardsRow, trustRow, footerRow])
  226. root.orientation = .vertical
  227. root.spacing = 18
  228. root.alignment = .centerX
  229. root.translatesAutoresizingMaskIntoConstraints = false
  230. view.addSubview(root)
  231. view.addSubview(closeButton)
  232. NSLayoutConstraint.activate([
  233. root.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
  234. root.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
  235. root.topAnchor.constraint(equalTo: view.topAnchor, constant: 20),
  236. root.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -16),
  237. closeButton.topAnchor.constraint(equalTo: view.topAnchor, constant: 14),
  238. closeButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -14),
  239. closeButton.widthAnchor.constraint(equalToConstant: 30),
  240. closeButton.heightAnchor.constraint(equalToConstant: 30),
  241. cardsRow.widthAnchor.constraint(equalTo: root.widthAnchor),
  242. cardsRow.heightAnchor.constraint(equalToConstant: 420),
  243. trustRow.widthAnchor.constraint(equalTo: root.widthAnchor),
  244. footerRow.widthAnchor.constraint(equalTo: root.widthAnchor),
  245. crownIcon.heightAnchor.constraint(equalToConstant: 20)
  246. ])
  247. }
  248. private func makePricingCard(_ plan: Plan) -> NSView {
  249. let card = HoverPricingCardView(baseBorderColor: Theme.cardBorder, hoverBorderColor: Theme.accent)
  250. card.translatesAutoresizingMaskIntoConstraints = false
  251. card.layer?.backgroundColor = Theme.cardBackground.cgColor
  252. let iconWell = NSView()
  253. iconWell.translatesAutoresizingMaskIntoConstraints = false
  254. iconWell.wantsLayer = true
  255. iconWell.layer?.cornerRadius = 10
  256. iconWell.layer?.backgroundColor = Theme.bottomStrip.cgColor
  257. iconWell.widthAnchor.constraint(equalToConstant: 24).isActive = true
  258. iconWell.heightAnchor.constraint(equalToConstant: 24).isActive = true
  259. let icon = NSImageView()
  260. icon.translatesAutoresizingMaskIntoConstraints = false
  261. icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .bold)
  262. icon.image = NSImage(systemSymbolName: plan.iconName, accessibilityDescription: nil)
  263. icon.contentTintColor = plan.iconTint
  264. iconWell.addSubview(icon)
  265. NSLayoutConstraint.activate([
  266. icon.centerXAnchor.constraint(equalTo: iconWell.centerXAnchor),
  267. icon.centerYAnchor.constraint(equalTo: iconWell.centerYAnchor)
  268. ])
  269. let titleLabel = NSTextField(labelWithString: plan.title)
  270. titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
  271. titleLabel.textColor = Theme.primaryText
  272. titleLabel.alignment = .center
  273. let subtitleLabel = NSTextField(labelWithString: plan.subtitle)
  274. subtitleLabel.font = .systemFont(ofSize: 12, weight: .regular)
  275. subtitleLabel.textColor = Theme.secondaryText
  276. subtitleLabel.alignment = .center
  277. let topRightTag = pillLabel(text: plan.billedPill, tint: Theme.bottomStrip, textColor: Theme.iconTint)
  278. topRightTag.isHidden = plan.billedPill.isEmpty
  279. topRightTag.font = .systemFont(ofSize: 10, weight: .bold)
  280. topRightTag.heightAnchor.constraint(equalToConstant: 20).isActive = true
  281. let priceLabel = NSTextField(labelWithString: plan.price)
  282. priceLabel.font = .systemFont(ofSize: 18, weight: .semibold)
  283. priceLabel.textColor = Theme.primaryText
  284. let periodLabel = NSTextField(labelWithString: plan.period)
  285. periodLabel.font = .systemFont(ofSize: 13, weight: .medium)
  286. periodLabel.textColor = Theme.secondaryText
  287. let priceRow = NSStackView(views: [priceLabel, periodLabel])
  288. priceRow.orientation = .horizontal
  289. priceRow.spacing = 4
  290. priceRow.alignment = .firstBaseline
  291. let billingLabel = NSTextField(labelWithString: plan.billedLine)
  292. billingLabel.font = .systemFont(ofSize: 13, weight: .medium)
  293. billingLabel.textColor = Theme.secondaryText
  294. let inlinePriceInfo = inlinePriceInfoLabel(oldPrice: plan.crossedPrice, newPrice: plan.savingsText)
  295. let divider = NSBox()
  296. divider.boxType = .separator
  297. divider.translatesAutoresizingMaskIntoConstraints = false
  298. divider.borderColor = Theme.divider
  299. let featuresStack = NSStackView(views: plan.features.map(makeFeatureRow(_:)))
  300. featuresStack.orientation = .vertical
  301. featuresStack.spacing = 9
  302. featuresStack.alignment = .leading
  303. featuresStack.edgeInsets = NSEdgeInsets(top: 20, left: 22, bottom: 0, right: 0)
  304. let selectButton = NSButton(title: "Get \(plan.title)", target: self, action: #selector(didTapSelectPlan))
  305. selectButton.identifier = NSUserInterfaceItemIdentifier(plan.id)
  306. selectButton.isBordered = false
  307. selectButton.bezelStyle = .rounded
  308. selectButton.font = .systemFont(ofSize: 14, weight: .semibold)
  309. selectButton.contentTintColor = plan.highlight ? .white : Theme.primaryText
  310. selectButton.wantsLayer = true
  311. selectButton.layer?.cornerRadius = 12
  312. selectButton.layer?.borderWidth = 1
  313. selectButton.layer?.borderColor = (plan.highlight ? Theme.accent : Theme.divider).cgColor
  314. selectButton.layer?.backgroundColor = (plan.highlight
  315. ? NSColor(srgbRed: 189 / 255, green: 52 / 255, blue: 255 / 255, alpha: 1)
  316. : Theme.mutedButtonFill).cgColor
  317. selectButton.translatesAutoresizingMaskIntoConstraints = false
  318. selectButton.heightAnchor.constraint(equalToConstant: 50).isActive = true
  319. var contentViews: [NSView] = [iconWell, titleLabel, subtitleLabel, priceRow]
  320. if !plan.billedLine.isEmpty {
  321. contentViews.append(billingLabel)
  322. }
  323. if plan.crossedPrice != nil, plan.savingsText != nil {
  324. contentViews.append(inlinePriceInfo)
  325. }
  326. contentViews.append(contentsOf: [divider, featuresStack])
  327. let verticalFlex = NSView()
  328. verticalFlex.translatesAutoresizingMaskIntoConstraints = false
  329. verticalFlex.setContentHuggingPriority(.defaultLow, for: .vertical)
  330. verticalFlex.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
  331. let column = NSStackView(views: contentViews + [verticalFlex, selectButton])
  332. column.orientation = .vertical
  333. column.spacing = 10
  334. column.alignment = .centerX
  335. column.distribution = .fill
  336. column.translatesAutoresizingMaskIntoConstraints = false
  337. card.addSubview(column)
  338. card.addSubview(topRightTag)
  339. NSLayoutConstraint.activate([
  340. divider.widthAnchor.constraint(equalTo: column.widthAnchor),
  341. featuresStack.widthAnchor.constraint(equalTo: column.widthAnchor),
  342. column.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 18),
  343. column.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -18),
  344. column.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
  345. column.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -12),
  346. selectButton.widthAnchor.constraint(equalTo: column.widthAnchor),
  347. topRightTag.topAnchor.constraint(equalTo: card.topAnchor, constant: 12),
  348. topRightTag.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12)
  349. ])
  350. return card
  351. }
  352. private func makeFeatureRow(_ text: String) -> NSView {
  353. let icon = NSImageView()
  354. icon.translatesAutoresizingMaskIntoConstraints = false
  355. icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .bold)
  356. icon.image = NSImage(systemSymbolName: "checkmark.circle.fill", accessibilityDescription: nil)
  357. icon.contentTintColor = Theme.iconTint
  358. icon.widthAnchor.constraint(equalToConstant: 14).isActive = true
  359. let label = NSTextField(labelWithString: text)
  360. label.font = .systemFont(ofSize: 14, weight: .medium)
  361. label.textColor = Theme.primaryText
  362. let row = NSStackView(views: [icon, label])
  363. row.orientation = .horizontal
  364. row.spacing = 8
  365. row.alignment = .centerY
  366. row.distribution = .fill
  367. return row
  368. }
  369. private func inlinePriceInfoLabel(oldPrice: String?, newPrice: String?) -> NSTextField {
  370. guard let oldPrice, let newPrice else {
  371. return NSTextField(labelWithString: "")
  372. }
  373. let full = NSMutableAttributedString()
  374. let oldAttributes: [NSAttributedString.Key: Any] = [
  375. .font: NSFont.systemFont(ofSize: 12, weight: .semibold),
  376. .foregroundColor: Theme.secondaryText,
  377. .strikethroughStyle: NSUnderlineStyle.single.rawValue
  378. ]
  379. let newAttributes: [NSAttributedString.Key: Any] = [
  380. .font: NSFont.systemFont(ofSize: 12, weight: .bold),
  381. .foregroundColor: Theme.successText
  382. ]
  383. full.append(NSAttributedString(string: "\(oldPrice) ", attributes: oldAttributes))
  384. full.append(NSAttributedString(string: newPrice, attributes: newAttributes))
  385. let label = NSTextField(labelWithAttributedString: full)
  386. return label
  387. }
  388. private func pillLabel(text: String, tint: NSColor, textColor: NSColor) -> NSTextField {
  389. let pill = NSTextField(labelWithString: text)
  390. pill.font = .systemFont(ofSize: 10, weight: .semibold)
  391. pill.textColor = textColor
  392. pill.alignment = .center
  393. pill.wantsLayer = true
  394. pill.layer?.backgroundColor = tint.cgColor
  395. pill.layer?.cornerRadius = 9
  396. pill.translatesAutoresizingMaskIntoConstraints = false
  397. pill.heightAnchor.constraint(equalToConstant: 18).isActive = true
  398. pill.widthAnchor.constraint(greaterThanOrEqualToConstant: 95).isActive = true
  399. return pill
  400. }
  401. private func makeTrustRow() -> NSView {
  402. let badges = NSStackView(views: [
  403. trustBadge(icon: "shield.fill", title: "Secure Payments", subtitle: "Your payment is 100% secure."),
  404. trustBadge(icon: "arrow.counterclockwise", title: "Cancel Anytime", subtitle: "No commitment, cancel anytime."),
  405. trustBadge(icon: "headphones", title: "24/7 Support", subtitle: "We're here to help you anytime."),
  406. trustBadge(icon: "lock.fill", title: "Privacy First", subtitle: "Your data is safe with us.")
  407. ])
  408. badges.orientation = .horizontal
  409. badges.alignment = .centerY
  410. badges.distribution = .fillEqually
  411. badges.spacing = 12
  412. badges.edgeInsets = NSEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
  413. badges.translatesAutoresizingMaskIntoConstraints = false
  414. badges.wantsLayer = true
  415. badges.layer?.backgroundColor = Theme.bottomStrip.cgColor
  416. badges.layer?.borderColor = Theme.divider.cgColor
  417. badges.layer?.borderWidth = 1
  418. badges.layer?.cornerRadius = 10
  419. badges.setHuggingPriority(.defaultLow, for: .horizontal)
  420. badges.heightAnchor.constraint(equalToConstant: 72).isActive = true
  421. return badges
  422. }
  423. private func makeFooterRow() -> NSView {
  424. let items = [
  425. "Manage Subscription",
  426. "Restore Purchase",
  427. "Privacy Policy",
  428. "Terms of Services",
  429. "Support"
  430. ]
  431. let cells = items.enumerated().map { index, text in
  432. footerCell(text: text, showsTrailingDivider: index < items.count - 1)
  433. }
  434. let links = NSStackView(views: cells)
  435. links.orientation = .horizontal
  436. links.distribution = .fillEqually
  437. links.spacing = 0
  438. links.alignment = .centerY
  439. links.translatesAutoresizingMaskIntoConstraints = false
  440. return links
  441. }
  442. private func footerCell(text: String, showsTrailingDivider: Bool) -> NSView {
  443. let container = NSView()
  444. container.translatesAutoresizingMaskIntoConstraints = false
  445. let label = footerLink(text)
  446. label.translatesAutoresizingMaskIntoConstraints = false
  447. label.alignment = .center
  448. container.addSubview(label)
  449. var constraints = [
  450. label.centerXAnchor.constraint(equalTo: container.centerXAnchor),
  451. label.centerYAnchor.constraint(equalTo: container.centerYAnchor)
  452. ]
  453. if showsTrailingDivider {
  454. let divider = footerDivider()
  455. container.addSubview(divider)
  456. constraints.append(contentsOf: [
  457. divider.trailingAnchor.constraint(equalTo: container.trailingAnchor),
  458. divider.centerYAnchor.constraint(equalTo: container.centerYAnchor)
  459. ])
  460. }
  461. NSLayoutConstraint.activate(constraints)
  462. return container
  463. }
  464. private func trustBadge(icon: String, title: String, subtitle: String) -> NSView {
  465. let image = NSImageView()
  466. image.translatesAutoresizingMaskIntoConstraints = false
  467. image.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
  468. image.image = NSImage(systemSymbolName: icon, accessibilityDescription: nil)
  469. image.contentTintColor = Theme.primaryText
  470. let titleLabel = NSTextField(labelWithString: title)
  471. titleLabel.font = .systemFont(ofSize: 12, weight: .bold)
  472. titleLabel.textColor = Theme.primaryText
  473. let subtitleLabel = NSTextField(labelWithString: subtitle)
  474. subtitleLabel.font = .systemFont(ofSize: 10, weight: .medium)
  475. subtitleLabel.textColor = Theme.secondaryText
  476. let textStack = NSStackView(views: [titleLabel, subtitleLabel])
  477. textStack.orientation = .vertical
  478. textStack.spacing = 2
  479. textStack.alignment = .leading
  480. let stack = NSStackView(views: [image, textStack])
  481. stack.orientation = .horizontal
  482. stack.spacing = 8
  483. stack.alignment = .leading
  484. stack.edgeInsets = NSEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
  485. stack.wantsLayer = true
  486. stack.layer?.backgroundColor = NSColor.clear.cgColor
  487. return stack
  488. }
  489. private func footerLink(_ text: String) -> NSTextField {
  490. let label = NSTextField(labelWithString: text)
  491. label.font = .systemFont(ofSize: 12, weight: .medium)
  492. label.textColor = Theme.secondaryText
  493. return label
  494. }
  495. private func footerDivider() -> NSBox {
  496. let divider = NSBox()
  497. divider.boxType = .separator
  498. divider.borderColor = Theme.divider
  499. divider.translatesAutoresizingMaskIntoConstraints = false
  500. divider.widthAnchor.constraint(equalToConstant: 1).isActive = true
  501. divider.heightAnchor.constraint(equalToConstant: 14).isActive = true
  502. return divider
  503. }
  504. @objc private func didTapSelectPlan(_ sender: NSButton) {
  505. sender.layer?.backgroundColor = Theme.accentHover.cgColor
  506. let selectedPlan = sender.identifier?.rawValue ?? sender.title
  507. let alert = NSAlert()
  508. alert.messageText = "Premium checkout coming soon"
  509. alert.informativeText = "Plan selected: \(selectedPlan.capitalized). Payment flow can be connected next."
  510. alert.alertStyle = .informational
  511. alert.addButton(withTitle: "OK")
  512. if let window = view.window {
  513. alert.beginSheetModal(for: window)
  514. } else {
  515. alert.runModal()
  516. }
  517. }
  518. @objc private func didTapClose() {
  519. guard let window = view.window else { return }
  520. if let parent = window.sheetParent {
  521. parent.endSheet(window)
  522. return
  523. }
  524. window.close()
  525. }
  526. }