Няма описание

PremiumPlansWindowController.swift 36KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874
  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. private struct Plan {
  137. let id: String
  138. let title: String
  139. let subtitle: String
  140. let price: String
  141. let period: String
  142. let billedPill: String
  143. let billedLine: String
  144. let crossedPrice: String?
  145. let savingsText: String?
  146. let features: [String]
  147. let iconName: String
  148. let iconTint: NSColor
  149. let highlight: Bool
  150. }
  151. private enum Theme {
  152. static let pageStart = NSColor(srgbRed: 249 / 255, green: 252 / 255, blue: 255 / 255, alpha: 1)
  153. static let pageEnd = NSColor(srgbRed: 238 / 255, green: 244 / 255, blue: 255 / 255, alpha: 1)
  154. static let cardBackground = NSColor.white
  155. static let primaryText = NSColor(srgbRed: 27 / 255, green: 38 / 255, blue: 79 / 255, alpha: 1)
  156. static let secondaryText = NSColor(srgbRed: 108 / 255, green: 120 / 255, blue: 157 / 255, alpha: 1)
  157. static let cardBorder = NSColor(srgbRed: 198 / 255, green: 216 / 255, blue: 255 / 255, alpha: 1)
  158. static let accent = NSColor(srgbRed: 55 / 255, green: 128 / 255, blue: 255 / 255, alpha: 1)
  159. static let accentHover = NSColor(srgbRed: 38 / 255, green: 108 / 255, blue: 232 / 255, alpha: 1)
  160. static let mutedButtonFill = NSColor(srgbRed: 238 / 255, green: 243 / 255, blue: 252 / 255, alpha: 1)
  161. static let bottomStrip = NSColor(srgbRed: 244 / 255, green: 248 / 255, blue: 255 / 255, alpha: 1)
  162. static let divider = NSColor(srgbRed: 218 / 255, green: 228 / 255, blue: 247 / 255, alpha: 1)
  163. static let successText = NSColor(srgbRed: 21 / 255, green: 154 / 255, blue: 220 / 255, alpha: 1)
  164. static let iconTint = NSColor(srgbRed: 47 / 255, green: 136 / 255, blue: 255 / 255, alpha: 1)
  165. }
  166. private enum FeatureListMetrics {
  167. static let spacing = CGFloat(10)
  168. static let edgeInsets = NSEdgeInsets(top: 21, left: 37, bottom: 21, right: 0)
  169. }
  170. private let subscriptionStore = SubscriptionStore.shared
  171. private var planPriceFields: [String: (price: NSTextField, period: NSTextField)] = [:]
  172. private var planPurchaseButtons: [String: NSButton] = [:]
  173. private var subscriptionPrimaryFooterButton: NSButton?
  174. private var premiumCloseButton: NSButton?
  175. private var subscriptionStatusObservation: NSObjectProtocol?
  176. private let plans: [Plan] = [
  177. Plan(
  178. id: "weekly",
  179. title: "Weekly",
  180. subtitle: "Flexible and commitment-free",
  181. price: "$9.99",
  182. period: "/ week",
  183. billedPill: "",
  184. billedLine: "",
  185. crossedPrice: nil,
  186. savingsText: nil,
  187. features: [
  188. "All premium features",
  189. "Perfect for short-term goals",
  190. "Cancel anytime"
  191. ],
  192. iconName: "paperplane.fill",
  193. iconTint: Theme.iconTint,
  194. highlight: false
  195. ),
  196. Plan(
  197. id: "monthly",
  198. title: "Monthly",
  199. subtitle: "Balanced for regular productivity",
  200. price: "$19.99",
  201. period: "/ month",
  202. billedPill: "",
  203. billedLine: "",
  204. crossedPrice: nil,
  205. savingsText: nil,
  206. features: [
  207. "All premium features",
  208. "Best value for regular users",
  209. "Priority support"
  210. ],
  211. iconName: "bolt.fill",
  212. iconTint: Theme.accent,
  213. highlight: true
  214. ),
  215. Plan(
  216. id: "yearly",
  217. title: "Yearly",
  218. subtitle: "Best value for long-term users",
  219. price: "$39.99",
  220. period: "/ year",
  221. billedPill: "3 days free trial",
  222. billedLine: "",
  223. crossedPrice: nil,
  224. savingsText: nil,
  225. features: [
  226. "All premium features",
  227. "Lowest effective monthly cost",
  228. "Ideal for long-term use"
  229. ],
  230. iconName: "crown.fill",
  231. iconTint: Theme.successText,
  232. highlight: false
  233. )
  234. ]
  235. private let pageGradient = CAGradientLayer()
  236. deinit {
  237. if let subscriptionStatusObservation {
  238. NotificationCenter.default.removeObserver(subscriptionStatusObservation)
  239. }
  240. }
  241. override func viewDidLoad() {
  242. super.viewDidLoad()
  243. subscriptionStatusObservation = NotificationCenter.default.addObserver(
  244. forName: .subscriptionStatusDidChange,
  245. object: nil,
  246. queue: .main
  247. ) { [weak self] _ in
  248. Task { @MainActor in
  249. await self?.subscriptionStore.loadProducts()
  250. self?.applyStorePricing()
  251. self?.updateSubscriptionPrimaryFooter()
  252. self?.updatePremiumCloseButtonVisibility()
  253. }
  254. }
  255. Task { @MainActor in
  256. await loadStoreProducts()
  257. }
  258. }
  259. override func viewDidLayout() {
  260. super.viewDidLayout()
  261. pageGradient.frame = view.bounds
  262. }
  263. override func loadView() {
  264. view = NSView()
  265. view.wantsLayer = true
  266. view.layer?.cornerRadius = 18
  267. view.layer?.masksToBounds = true
  268. pageGradient.colors = [Theme.pageStart.cgColor, Theme.pageEnd.cgColor]
  269. pageGradient.startPoint = CGPoint(x: 0, y: 1)
  270. pageGradient.endPoint = CGPoint(x: 1, y: 0)
  271. view.layer?.addSublayer(pageGradient)
  272. setupLayout()
  273. }
  274. private func setupLayout() {
  275. let closeButton = NSButton(title: "", target: self, action: #selector(didTapClose))
  276. closeButton.translatesAutoresizingMaskIntoConstraints = false
  277. closeButton.isBordered = false
  278. closeButton.wantsLayer = true
  279. closeButton.layer?.cornerRadius = 15
  280. closeButton.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.92).cgColor
  281. closeButton.layer?.borderWidth = 1
  282. closeButton.layer?.borderColor = Theme.divider.cgColor
  283. closeButton.contentTintColor = Theme.secondaryText
  284. closeButton.image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close")
  285. closeButton.imageScaling = .scaleProportionallyDown
  286. closeButton.bezelStyle = .regularSquare
  287. let crownIcon = NSImageView()
  288. crownIcon.translatesAutoresizingMaskIntoConstraints = false
  289. crownIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
  290. crownIcon.image = NSImage(systemSymbolName: "crown.fill", accessibilityDescription: nil)
  291. crownIcon.contentTintColor = NSColor(srgbRed: 254 / 255, green: 214 / 255, blue: 92 / 255, alpha: 1)
  292. let title = NSTextField(labelWithString: "Upgrade to Pro")
  293. title.font = .systemFont(ofSize: 40, weight: .semibold)
  294. title.textColor = Theme.primaryText
  295. title.alignment = .center
  296. let subtitle = NSTextField(labelWithString: "Unlock unlimited access to premium tools and boost your productivity.")
  297. subtitle.font = .systemFont(ofSize: 14, weight: .regular)
  298. subtitle.textColor = Theme.secondaryText
  299. subtitle.alignment = .center
  300. let cardsRow = NSStackView(views: plans.map(makePricingCard(_:)))
  301. cardsRow.orientation = .horizontal
  302. cardsRow.spacing = 14
  303. cardsRow.alignment = .top
  304. cardsRow.distribution = .fillEqually
  305. cardsRow.translatesAutoresizingMaskIntoConstraints = false
  306. for card in cardsRow.arrangedSubviews {
  307. card.heightAnchor.constraint(equalTo: cardsRow.heightAnchor).isActive = true
  308. }
  309. let trustRow = makeTrustRow()
  310. let footerRow = makeFooterRow()
  311. let root = NSStackView(views: [crownIcon, title, subtitle, cardsRow, trustRow, footerRow])
  312. root.orientation = .vertical
  313. root.spacing = 18
  314. root.alignment = .centerX
  315. root.translatesAutoresizingMaskIntoConstraints = false
  316. view.addSubview(root)
  317. view.addSubview(closeButton)
  318. NSLayoutConstraint.activate([
  319. root.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
  320. root.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
  321. root.topAnchor.constraint(equalTo: view.topAnchor, constant: 20),
  322. root.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -16),
  323. closeButton.topAnchor.constraint(equalTo: view.topAnchor, constant: 14),
  324. closeButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -14),
  325. closeButton.widthAnchor.constraint(equalToConstant: 30),
  326. closeButton.heightAnchor.constraint(equalToConstant: 30),
  327. cardsRow.widthAnchor.constraint(equalTo: root.widthAnchor),
  328. cardsRow.heightAnchor.constraint(equalToConstant: 420),
  329. trustRow.widthAnchor.constraint(equalTo: root.widthAnchor),
  330. footerRow.widthAnchor.constraint(equalTo: root.widthAnchor),
  331. crownIcon.heightAnchor.constraint(equalToConstant: 20)
  332. ])
  333. premiumCloseButton = closeButton
  334. updatePremiumCloseButtonVisibility()
  335. }
  336. private func updatePremiumCloseButtonVisibility() {
  337. premiumCloseButton?.isHidden = !subscriptionStore.isProActive
  338. }
  339. private func makePricingCard(_ plan: Plan) -> NSView {
  340. let card = HoverPricingCardView(baseBorderColor: Theme.cardBorder, hoverBorderColor: Theme.accent)
  341. card.translatesAutoresizingMaskIntoConstraints = false
  342. card.layer?.backgroundColor = Theme.cardBackground.cgColor
  343. let iconWell = NSView()
  344. iconWell.translatesAutoresizingMaskIntoConstraints = false
  345. iconWell.wantsLayer = true
  346. iconWell.layer?.cornerRadius = 10
  347. iconWell.layer?.backgroundColor = Theme.bottomStrip.cgColor
  348. iconWell.widthAnchor.constraint(equalToConstant: 24).isActive = true
  349. iconWell.heightAnchor.constraint(equalToConstant: 24).isActive = true
  350. let icon = NSImageView()
  351. icon.translatesAutoresizingMaskIntoConstraints = false
  352. icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .bold)
  353. icon.image = NSImage(systemSymbolName: plan.iconName, accessibilityDescription: nil)
  354. icon.contentTintColor = plan.iconTint
  355. iconWell.addSubview(icon)
  356. NSLayoutConstraint.activate([
  357. icon.centerXAnchor.constraint(equalTo: iconWell.centerXAnchor),
  358. icon.centerYAnchor.constraint(equalTo: iconWell.centerYAnchor)
  359. ])
  360. let titleLabel = NSTextField(labelWithString: plan.title)
  361. titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
  362. titleLabel.textColor = Theme.primaryText
  363. titleLabel.alignment = .center
  364. let subtitleLabel = NSTextField(labelWithString: plan.subtitle)
  365. subtitleLabel.font = .systemFont(ofSize: 12, weight: .regular)
  366. subtitleLabel.textColor = Theme.secondaryText
  367. subtitleLabel.alignment = .center
  368. let topRightTag = pillLabel(text: plan.billedPill, tint: Theme.bottomStrip, textColor: Theme.iconTint)
  369. topRightTag.isHidden = plan.billedPill.isEmpty
  370. topRightTag.font = .systemFont(ofSize: 10, weight: .bold)
  371. let priceLabel = NSTextField(labelWithString: plan.price)
  372. priceLabel.font = .systemFont(ofSize: 18, weight: .semibold)
  373. priceLabel.textColor = Theme.primaryText
  374. let periodLabel = NSTextField(labelWithString: plan.period)
  375. periodLabel.font = .systemFont(ofSize: 13, weight: .medium)
  376. periodLabel.textColor = Theme.secondaryText
  377. let priceRow = NSStackView(views: [priceLabel, periodLabel])
  378. priceRow.orientation = .horizontal
  379. priceRow.spacing = 4
  380. priceRow.alignment = .firstBaseline
  381. let billingLabel = NSTextField(labelWithString: plan.billedLine)
  382. billingLabel.font = .systemFont(ofSize: 13, weight: .medium)
  383. billingLabel.textColor = Theme.secondaryText
  384. let inlinePriceInfo = inlinePriceInfoLabel(oldPrice: plan.crossedPrice, newPrice: plan.savingsText)
  385. let divider = NSBox()
  386. divider.boxType = .separator
  387. divider.translatesAutoresizingMaskIntoConstraints = false
  388. divider.borderColor = Theme.divider
  389. let featuresStack = NSStackView(views: plan.features.map(makeFeatureRow(_:)))
  390. featuresStack.orientation = .vertical
  391. featuresStack.spacing = FeatureListMetrics.spacing
  392. featuresStack.alignment = .leading
  393. featuresStack.edgeInsets = FeatureListMetrics.edgeInsets
  394. let selectButton = NSButton(title: "Get \(plan.title)", target: self, action: #selector(didTapSelectPlan))
  395. selectButton.identifier = NSUserInterfaceItemIdentifier(plan.id)
  396. planPurchaseButtons[plan.id] = selectButton
  397. planPriceFields[plan.id] = (priceLabel, periodLabel)
  398. selectButton.isBordered = false
  399. selectButton.bezelStyle = .rounded
  400. selectButton.font = .systemFont(ofSize: 14, weight: .semibold)
  401. selectButton.contentTintColor = plan.highlight ? .white : Theme.primaryText
  402. selectButton.wantsLayer = true
  403. selectButton.layer?.cornerRadius = 12
  404. selectButton.layer?.borderWidth = 1
  405. selectButton.layer?.borderColor = (plan.highlight ? Theme.accent : Theme.divider).cgColor
  406. selectButton.layer?.backgroundColor = (plan.highlight
  407. ? NSColor(srgbRed: 189 / 255, green: 52 / 255, blue: 255 / 255, alpha: 1)
  408. : Theme.mutedButtonFill).cgColor
  409. selectButton.translatesAutoresizingMaskIntoConstraints = false
  410. selectButton.heightAnchor.constraint(equalToConstant: 50).isActive = true
  411. var contentViews: [NSView] = [iconWell, titleLabel, subtitleLabel, priceRow]
  412. if !plan.billedLine.isEmpty {
  413. contentViews.append(billingLabel)
  414. }
  415. if plan.crossedPrice != nil, plan.savingsText != nil {
  416. contentViews.append(inlinePriceInfo)
  417. }
  418. contentViews.append(contentsOf: [divider, featuresStack])
  419. let verticalFlex = NSView()
  420. verticalFlex.translatesAutoresizingMaskIntoConstraints = false
  421. verticalFlex.setContentHuggingPriority(.defaultLow, for: .vertical)
  422. verticalFlex.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
  423. let column = NSStackView(views: contentViews + [verticalFlex, selectButton])
  424. column.orientation = .vertical
  425. column.spacing = 10
  426. column.alignment = .centerX
  427. column.distribution = .fill
  428. column.translatesAutoresizingMaskIntoConstraints = false
  429. card.addSubview(column)
  430. card.addSubview(topRightTag)
  431. NSLayoutConstraint.activate([
  432. divider.widthAnchor.constraint(equalTo: column.widthAnchor),
  433. featuresStack.widthAnchor.constraint(equalTo: column.widthAnchor),
  434. column.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 18),
  435. column.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -18),
  436. column.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
  437. column.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -12),
  438. selectButton.widthAnchor.constraint(equalTo: column.widthAnchor),
  439. topRightTag.topAnchor.constraint(equalTo: card.topAnchor, constant: 12),
  440. topRightTag.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12)
  441. ])
  442. return card
  443. }
  444. private func makeFeatureRow(_ text: String) -> NSView {
  445. let icon = NSImageView()
  446. icon.translatesAutoresizingMaskIntoConstraints = false
  447. icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .bold)
  448. icon.image = NSImage(systemSymbolName: "checkmark.circle.fill", accessibilityDescription: nil)
  449. icon.contentTintColor = Theme.iconTint
  450. icon.widthAnchor.constraint(equalToConstant: 14).isActive = true
  451. let label = NSTextField(labelWithString: text)
  452. label.font = .systemFont(ofSize: 14, weight: .medium)
  453. label.textColor = Theme.primaryText
  454. let row = NSStackView(views: [icon, label])
  455. row.orientation = .horizontal
  456. row.spacing = FeatureListMetrics.spacing
  457. row.alignment = .centerY
  458. row.distribution = .fill
  459. return row
  460. }
  461. private func inlinePriceInfoLabel(oldPrice: String?, newPrice: String?) -> NSTextField {
  462. guard let oldPrice, let newPrice else {
  463. return NSTextField(labelWithString: "")
  464. }
  465. let full = NSMutableAttributedString()
  466. let oldAttributes: [NSAttributedString.Key: Any] = [
  467. .font: NSFont.systemFont(ofSize: 12, weight: .semibold),
  468. .foregroundColor: Theme.secondaryText,
  469. .strikethroughStyle: NSUnderlineStyle.single.rawValue
  470. ]
  471. let newAttributes: [NSAttributedString.Key: Any] = [
  472. .font: NSFont.systemFont(ofSize: 12, weight: .bold),
  473. .foregroundColor: Theme.successText
  474. ]
  475. full.append(NSAttributedString(string: "\(oldPrice) ", attributes: oldAttributes))
  476. full.append(NSAttributedString(string: newPrice, attributes: newAttributes))
  477. let label = NSTextField(labelWithAttributedString: full)
  478. return label
  479. }
  480. private func pillLabel(text: String, tint: NSColor, textColor: NSColor) -> NSTextField {
  481. let pill = NSTextField(labelWithString: text)
  482. pill.font = .systemFont(ofSize: 10, weight: .semibold)
  483. pill.textColor = textColor
  484. pill.alignment = .center
  485. pill.wantsLayer = true
  486. pill.layer?.backgroundColor = tint.cgColor
  487. pill.layer?.cornerRadius = 9
  488. pill.translatesAutoresizingMaskIntoConstraints = false
  489. pill.heightAnchor.constraint(equalToConstant: 18).isActive = true
  490. pill.widthAnchor.constraint(greaterThanOrEqualToConstant: 95).isActive = true
  491. return pill
  492. }
  493. private func makeTrustRow() -> NSView {
  494. let badges = NSStackView(views: [
  495. trustBadge(icon: "shield.fill", title: "Secure Payments", subtitle: "Your payment is 100% secure."),
  496. trustBadge(icon: "arrow.counterclockwise", title: "Cancel Anytime", subtitle: "No commitment, cancel anytime."),
  497. trustBadge(icon: "headphones", title: "24/7 Support", subtitle: "We're here to help you anytime."),
  498. trustBadge(icon: "lock.fill", title: "Privacy First", subtitle: "Your data is safe with us.")
  499. ])
  500. badges.orientation = .horizontal
  501. badges.alignment = .centerY
  502. badges.distribution = .fillEqually
  503. badges.spacing = 12
  504. badges.edgeInsets = NSEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
  505. badges.translatesAutoresizingMaskIntoConstraints = false
  506. badges.wantsLayer = true
  507. badges.layer?.backgroundColor = Theme.bottomStrip.cgColor
  508. badges.layer?.borderColor = Theme.divider.cgColor
  509. badges.layer?.borderWidth = 1
  510. badges.layer?.cornerRadius = 10
  511. badges.setHuggingPriority(.defaultLow, for: .horizontal)
  512. badges.heightAnchor.constraint(equalToConstant: 72).isActive = true
  513. return badges
  514. }
  515. private func makeFooterRow() -> NSView {
  516. let primary = footerActionCell(
  517. title: subscriptionPrimaryFooterTitle(),
  518. action: #selector(didTapPrimaryFooterSubscriptionAction),
  519. showsTrailingDivider: true
  520. )
  521. subscriptionPrimaryFooterButton = primary.button
  522. let entries: [(text: String, action: Selector)] = [
  523. ("Restore Purchase", #selector(didTapRestorePurchases)),
  524. ("Privacy Policy", #selector(didTapFooterPrivacyPolicy)),
  525. ("Terms of Services", #selector(didTapFooterTermsOfServices)),
  526. ("Support", #selector(didTapFooterSupport))
  527. ]
  528. let cells = [primary.container] + entries.enumerated().map { index, entry in
  529. footerActionCell(title: entry.text, action: entry.action, showsTrailingDivider: index < entries.count - 1).container
  530. }
  531. let links = NSStackView(views: cells)
  532. links.orientation = .horizontal
  533. links.distribution = .fillEqually
  534. links.spacing = 0
  535. links.alignment = .centerY
  536. links.translatesAutoresizingMaskIntoConstraints = false
  537. return links
  538. }
  539. private func footerActionCell(title: String, action: Selector, showsTrailingDivider: Bool) -> (container: NSView, button: NSButton) {
  540. let container = NSView()
  541. container.translatesAutoresizingMaskIntoConstraints = false
  542. let button = FooterLinkButton(title: title, target: self, action: action)
  543. button.isBordered = false
  544. button.bezelStyle = .rounded
  545. button.font = .systemFont(ofSize: 12, weight: .medium)
  546. button.contentTintColor = Theme.secondaryText
  547. button.focusRingType = .none
  548. button.translatesAutoresizingMaskIntoConstraints = false
  549. container.addSubview(button)
  550. var constraints: [NSLayoutConstraint] = [
  551. button.centerXAnchor.constraint(equalTo: container.centerXAnchor),
  552. button.centerYAnchor.constraint(equalTo: container.centerYAnchor)
  553. ]
  554. if showsTrailingDivider {
  555. let divider = footerDivider()
  556. container.addSubview(divider)
  557. constraints.append(contentsOf: [
  558. divider.trailingAnchor.constraint(equalTo: container.trailingAnchor),
  559. divider.centerYAnchor.constraint(equalTo: container.centerYAnchor)
  560. ])
  561. }
  562. NSLayoutConstraint.activate(constraints)
  563. return (container, button)
  564. }
  565. private enum PrimaryFooterSubscriptionTitle {
  566. static let manage = "Manage Subscription"
  567. static let continueFree = "Continue with free plan"
  568. }
  569. private func subscriptionPrimaryFooterTitle() -> String {
  570. subscriptionStore.isProActive ? PrimaryFooterSubscriptionTitle.manage : PrimaryFooterSubscriptionTitle.continueFree
  571. }
  572. private func updateSubscriptionPrimaryFooter() {
  573. subscriptionPrimaryFooterButton?.title = subscriptionPrimaryFooterTitle()
  574. }
  575. private func trustBadge(icon: String, title: String, subtitle: String) -> NSView {
  576. let image = NSImageView()
  577. image.translatesAutoresizingMaskIntoConstraints = false
  578. image.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
  579. image.image = NSImage(systemSymbolName: icon, accessibilityDescription: nil)
  580. image.contentTintColor = Theme.primaryText
  581. let titleLabel = NSTextField(labelWithString: title)
  582. titleLabel.font = .systemFont(ofSize: 12, weight: .bold)
  583. titleLabel.textColor = Theme.primaryText
  584. let subtitleLabel = NSTextField(labelWithString: subtitle)
  585. subtitleLabel.font = .systemFont(ofSize: 10, weight: .medium)
  586. subtitleLabel.textColor = Theme.secondaryText
  587. let textStack = NSStackView(views: [titleLabel, subtitleLabel])
  588. textStack.orientation = .vertical
  589. textStack.spacing = 2
  590. textStack.alignment = .leading
  591. let stack = NSStackView(views: [image, textStack])
  592. stack.orientation = .horizontal
  593. stack.spacing = 8
  594. stack.alignment = .leading
  595. stack.edgeInsets = NSEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
  596. stack.wantsLayer = true
  597. stack.layer?.backgroundColor = NSColor.clear.cgColor
  598. return stack
  599. }
  600. private func footerDivider() -> NSBox {
  601. let divider = NSBox()
  602. divider.boxType = .separator
  603. divider.borderColor = Theme.divider
  604. divider.translatesAutoresizingMaskIntoConstraints = false
  605. divider.widthAnchor.constraint(equalToConstant: 1).isActive = true
  606. divider.heightAnchor.constraint(equalToConstant: 14).isActive = true
  607. return divider
  608. }
  609. @objc private func didTapSelectPlan(_ sender: NSButton) {
  610. guard let planKey = sender.identifier?.rawValue else { return }
  611. Task { await purchasePlan(planKey: planKey) }
  612. }
  613. @objc private func didTapPrimaryFooterSubscriptionAction(_ sender: NSButton) {
  614. let userTappedManage = (sender.title == PrimaryFooterSubscriptionTitle.manage)
  615. Task { @MainActor [weak self] in
  616. guard let self else { return }
  617. await subscriptionStore.refreshEntitlements(deep: true)
  618. updateSubscriptionPrimaryFooter()
  619. let active = subscriptionStore.isProActive
  620. if active || userTappedManage {
  621. guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
  622. NSWorkspace.shared.open(url)
  623. return
  624. }
  625. // Non-pro: dismiss paywall and return to the home (dashboard) window.
  626. dismissPremiumSheetFromParentIfNeeded()
  627. }
  628. }
  629. @objc private func didTapRestorePurchases() {
  630. Task { await restorePurchases() }
  631. }
  632. @objc private func didTapFooterPrivacyPolicy() {
  633. presentFooterPlaceholderAlert(for: "Privacy Policy")
  634. }
  635. @objc private func didTapFooterTermsOfServices() {
  636. presentFooterPlaceholderAlert(for: "Terms of Services")
  637. }
  638. @objc private func didTapFooterSupport() {
  639. presentFooterPlaceholderAlert(for: "Support")
  640. }
  641. private func presentFooterPlaceholderAlert(for featureName: String) {
  642. let alert = NSAlert()
  643. alert.messageText = "\(featureName) coming soon"
  644. alert.informativeText = "This section is a placeholder for now."
  645. alert.alertStyle = .informational
  646. alert.addButton(withTitle: "OK")
  647. if let window = view.window {
  648. alert.beginSheetModal(for: window)
  649. } else {
  650. alert.runModal()
  651. }
  652. }
  653. private func loadStoreProducts() async {
  654. await subscriptionStore.refreshEntitlements(deep: true)
  655. await subscriptionStore.loadProducts()
  656. applyStorePricing()
  657. updateSubscriptionPrimaryFooter()
  658. updatePremiumCloseButtonVisibility()
  659. }
  660. private func applyStorePricing() {
  661. for plan in plans {
  662. guard let fields = planPriceFields[plan.id],
  663. let product = subscriptionStore.product(forPlanKey: plan.id) else { continue }
  664. fields.price.stringValue = product.displayPrice
  665. if let period = product.subscription?.subscriptionPeriod {
  666. fields.period.stringValue = periodSuffix(for: period)
  667. }
  668. }
  669. }
  670. private func periodSuffix(for period: Product.SubscriptionPeriod) -> String {
  671. let value = period.value
  672. switch period.unit {
  673. case .day: return value == 1 ? "/ day" : "/ \(value) days"
  674. case .week: return value == 1 ? "/ week" : "/ \(value) weeks"
  675. case .month: return value == 1 ? "/ month" : "/ \(value) months"
  676. case .year: return value == 1 ? "/ year" : "/ \(value) years"
  677. @unknown default: return ""
  678. }
  679. }
  680. private func setPurchasing(_ isPurchasing: Bool) {
  681. for button in planPurchaseButtons.values {
  682. button.isEnabled = !isPurchasing
  683. }
  684. }
  685. private func purchasePlan(planKey: String) async {
  686. setPurchasing(true)
  687. defer { setPurchasing(false) }
  688. do {
  689. let completed = try await subscriptionStore.purchase(planKey: planKey)
  690. guard completed else { return }
  691. let alert = NSAlert()
  692. alert.messageText = "You're subscribed"
  693. alert.informativeText = "Thank you — Pro features are now available."
  694. alert.alertStyle = .informational
  695. alert.addButton(withTitle: "OK")
  696. if let window = view.window {
  697. alert.beginSheetModal(for: window) { [weak self] _ in
  698. self?.dismissPremiumSheetFromParentIfNeeded()
  699. }
  700. } else {
  701. alert.runModal()
  702. dismissPremiumSheetFromParentIfNeeded()
  703. }
  704. } catch {
  705. await MainActor.run {
  706. self.presentPurchaseError(error)
  707. }
  708. }
  709. }
  710. private func restorePurchases() async {
  711. setPurchasing(true)
  712. defer { setPurchasing(false) }
  713. do {
  714. try await subscriptionStore.restorePurchases()
  715. let active = subscriptionStore.isProActive
  716. let alert = NSAlert()
  717. if active {
  718. alert.messageText = "Purchases restored"
  719. alert.informativeText = "Your subscription is active."
  720. } else {
  721. alert.messageText = "No subscription found"
  722. alert.informativeText = "There was nothing to restore for this Apple ID."
  723. }
  724. alert.alertStyle = .informational
  725. alert.addButton(withTitle: "OK")
  726. if let window = view.window {
  727. alert.beginSheetModal(for: window) { [weak self] _ in
  728. if active {
  729. self?.dismissPremiumSheetFromParentIfNeeded()
  730. }
  731. }
  732. } else {
  733. alert.runModal()
  734. if active {
  735. dismissPremiumSheetFromParentIfNeeded()
  736. }
  737. }
  738. } catch {
  739. await MainActor.run {
  740. self.presentPurchaseError(error)
  741. }
  742. }
  743. }
  744. private func presentPurchaseError(_ error: Error) {
  745. let alert = NSAlert()
  746. alert.messageText = "Something went wrong"
  747. if let localized = error as? LocalizedError {
  748. var parts: [String] = []
  749. if let description = localized.errorDescription {
  750. parts.append(description)
  751. }
  752. if let recovery = localized.recoverySuggestion {
  753. parts.append(recovery)
  754. }
  755. alert.informativeText = parts.isEmpty ? error.localizedDescription : parts.joined(separator: "\n\n")
  756. } else {
  757. alert.informativeText = error.localizedDescription
  758. }
  759. alert.alertStyle = .warning
  760. alert.addButton(withTitle: "OK")
  761. if let window = view.window {
  762. alert.beginSheetModal(for: window)
  763. } else {
  764. alert.runModal()
  765. }
  766. }
  767. private func dismissPremiumSheetFromParentIfNeeded() {
  768. guard let sheet = view.window, let parent = sheet.sheetParent else { return }
  769. parent.endSheet(sheet)
  770. }
  771. @objc private func didTapClose() {
  772. guard let window = view.window else { return }
  773. if let parent = window.sheetParent {
  774. parent.endSheet(window)
  775. return
  776. }
  777. window.close()
  778. }
  779. }