Keine Beschreibung

PremiumPlansWindowController.swift 33KB

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