説明なし

PremiumPlansWindowController.swift 21KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  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 struct Plan {
  30. let id: String
  31. let title: String
  32. let subtitle: String
  33. let price: String
  34. let period: String
  35. let billedPill: String
  36. let billedLine: String
  37. let crossedPrice: String?
  38. let savingsText: String?
  39. let features: [String]
  40. let iconName: String
  41. let iconTint: NSColor
  42. let highlight: Bool
  43. }
  44. private enum Theme {
  45. static let pageStart = NSColor(srgbRed: 249 / 255, green: 252 / 255, blue: 255 / 255, alpha: 1)
  46. static let pageEnd = NSColor(srgbRed: 238 / 255, green: 244 / 255, blue: 255 / 255, alpha: 1)
  47. static let cardBackground = NSColor.white
  48. static let primaryText = NSColor(srgbRed: 27 / 255, green: 38 / 255, blue: 79 / 255, alpha: 1)
  49. static let secondaryText = NSColor(srgbRed: 108 / 255, green: 120 / 255, blue: 157 / 255, alpha: 1)
  50. static let cardBorder = NSColor(srgbRed: 198 / 255, green: 216 / 255, blue: 255 / 255, alpha: 1)
  51. static let accent = NSColor(srgbRed: 55 / 255, green: 128 / 255, blue: 255 / 255, alpha: 1)
  52. static let accentHover = NSColor(srgbRed: 38 / 255, green: 108 / 255, blue: 232 / 255, alpha: 1)
  53. static let mutedButtonFill = NSColor(srgbRed: 238 / 255, green: 243 / 255, blue: 252 / 255, alpha: 1)
  54. static let bottomStrip = NSColor(srgbRed: 244 / 255, green: 248 / 255, blue: 255 / 255, alpha: 1)
  55. static let divider = NSColor(srgbRed: 218 / 255, green: 228 / 255, blue: 247 / 255, alpha: 1)
  56. static let successText = NSColor(srgbRed: 21 / 255, green: 154 / 255, blue: 220 / 255, alpha: 1)
  57. static let iconTint = NSColor(srgbRed: 47 / 255, green: 136 / 255, blue: 255 / 255, alpha: 1)
  58. }
  59. private let plans: [Plan] = [
  60. Plan(
  61. id: "weekly",
  62. title: "Weekly",
  63. subtitle: "Flexible and commitment-free",
  64. price: "$9.99",
  65. period: "/ week",
  66. billedPill: "",
  67. billedLine: "",
  68. crossedPrice: nil,
  69. savingsText: nil,
  70. features: [
  71. "All premium features",
  72. "Perfect for short-term goals",
  73. "Cancel anytime"
  74. ],
  75. iconName: "paperplane.fill",
  76. iconTint: Theme.iconTint,
  77. highlight: false
  78. ),
  79. Plan(
  80. id: "monthly",
  81. title: "Monthly",
  82. subtitle: "Balanced for regular productivity",
  83. price: "$19.99",
  84. period: "/ month",
  85. billedPill: "",
  86. billedLine: "",
  87. crossedPrice: nil,
  88. savingsText: nil,
  89. features: [
  90. "All premium features",
  91. "Best value for regular users",
  92. "Priority support"
  93. ],
  94. iconName: "bolt.fill",
  95. iconTint: Theme.accent,
  96. highlight: true
  97. ),
  98. Plan(
  99. id: "yearly",
  100. title: "Yearly",
  101. subtitle: "Best value for long-term users",
  102. price: "$39.99",
  103. period: "/ year",
  104. billedPill: "3-day free trial",
  105. billedLine: "",
  106. crossedPrice: nil,
  107. savingsText: nil,
  108. features: [
  109. "All premium features",
  110. "Lowest effective monthly cost",
  111. "Ideal for long-term use"
  112. ],
  113. iconName: "crown.fill",
  114. iconTint: Theme.successText,
  115. highlight: false
  116. )
  117. ]
  118. private let pageGradient = CAGradientLayer()
  119. override func viewDidLayout() {
  120. super.viewDidLayout()
  121. pageGradient.frame = view.bounds
  122. }
  123. override func loadView() {
  124. view = NSView()
  125. view.wantsLayer = true
  126. view.layer?.cornerRadius = 18
  127. view.layer?.masksToBounds = true
  128. pageGradient.colors = [Theme.pageStart.cgColor, Theme.pageEnd.cgColor]
  129. pageGradient.startPoint = CGPoint(x: 0, y: 1)
  130. pageGradient.endPoint = CGPoint(x: 1, y: 0)
  131. view.layer?.addSublayer(pageGradient)
  132. setupLayout()
  133. }
  134. private func setupLayout() {
  135. let crownIcon = NSImageView()
  136. crownIcon.translatesAutoresizingMaskIntoConstraints = false
  137. crownIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
  138. crownIcon.image = NSImage(systemSymbolName: "crown.fill", accessibilityDescription: nil)
  139. crownIcon.contentTintColor = NSColor(srgbRed: 254 / 255, green: 214 / 255, blue: 92 / 255, alpha: 1)
  140. let title = NSTextField(labelWithString: "Upgrade to Pro")
  141. title.font = .systemFont(ofSize: 52, weight: .bold)
  142. title.textColor = Theme.primaryText
  143. title.alignment = .center
  144. let subtitle = NSTextField(labelWithString: "Unlock unlimited access to premium tools and boost your productivity.")
  145. subtitle.font = .systemFont(ofSize: 15, weight: .medium)
  146. subtitle.textColor = Theme.secondaryText
  147. subtitle.alignment = .center
  148. let cardsRow = NSStackView(views: plans.map(makePricingCard(_:)))
  149. cardsRow.orientation = .horizontal
  150. cardsRow.spacing = 14
  151. cardsRow.alignment = .top
  152. cardsRow.distribution = .fillEqually
  153. cardsRow.translatesAutoresizingMaskIntoConstraints = false
  154. let trustRow = makeTrustRow()
  155. let footerRow = makeFooterRow()
  156. let root = NSStackView(views: [crownIcon, title, subtitle, cardsRow, trustRow, footerRow])
  157. root.orientation = .vertical
  158. root.spacing = 18
  159. root.alignment = .centerX
  160. root.translatesAutoresizingMaskIntoConstraints = false
  161. view.addSubview(root)
  162. NSLayoutConstraint.activate([
  163. root.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
  164. root.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
  165. root.topAnchor.constraint(equalTo: view.topAnchor, constant: 20),
  166. root.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -16),
  167. cardsRow.widthAnchor.constraint(equalTo: root.widthAnchor),
  168. cardsRow.heightAnchor.constraint(equalToConstant: 420),
  169. trustRow.widthAnchor.constraint(equalTo: root.widthAnchor),
  170. footerRow.widthAnchor.constraint(equalTo: root.widthAnchor),
  171. crownIcon.heightAnchor.constraint(equalToConstant: 20)
  172. ])
  173. }
  174. private func makePricingCard(_ plan: Plan) -> NSView {
  175. let card = NSView()
  176. card.translatesAutoresizingMaskIntoConstraints = false
  177. card.wantsLayer = true
  178. card.layer?.backgroundColor = Theme.cardBackground.cgColor
  179. card.layer?.cornerRadius = 16
  180. card.layer?.borderWidth = plan.highlight ? 2 : 1
  181. card.layer?.borderColor = (plan.highlight ? Theme.accent : Theme.cardBorder).cgColor
  182. let iconWell = NSView()
  183. iconWell.translatesAutoresizingMaskIntoConstraints = false
  184. iconWell.wantsLayer = true
  185. iconWell.layer?.cornerRadius = 10
  186. iconWell.layer?.backgroundColor = Theme.bottomStrip.cgColor
  187. iconWell.widthAnchor.constraint(equalToConstant: 24).isActive = true
  188. iconWell.heightAnchor.constraint(equalToConstant: 24).isActive = true
  189. let icon = NSImageView()
  190. icon.translatesAutoresizingMaskIntoConstraints = false
  191. icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .bold)
  192. icon.image = NSImage(systemSymbolName: plan.iconName, accessibilityDescription: nil)
  193. icon.contentTintColor = plan.iconTint
  194. iconWell.addSubview(icon)
  195. NSLayoutConstraint.activate([
  196. icon.centerXAnchor.constraint(equalTo: iconWell.centerXAnchor),
  197. icon.centerYAnchor.constraint(equalTo: iconWell.centerYAnchor)
  198. ])
  199. let titleLabel = NSTextField(labelWithString: plan.title)
  200. titleLabel.font = .systemFont(ofSize: 44, weight: .bold)
  201. titleLabel.textColor = Theme.primaryText
  202. titleLabel.alignment = .center
  203. let subtitleLabel = NSTextField(labelWithString: plan.subtitle)
  204. subtitleLabel.font = .systemFont(ofSize: 13, weight: .medium)
  205. subtitleLabel.textColor = Theme.secondaryText
  206. subtitleLabel.alignment = .center
  207. let billingPill = pillLabel(text: plan.billedPill, tint: Theme.bottomStrip, textColor: Theme.iconTint)
  208. billingPill.isHidden = plan.billedPill.isEmpty
  209. let priceLabel = NSTextField(labelWithString: plan.price)
  210. priceLabel.font = .systemFont(ofSize: 40, weight: .bold)
  211. priceLabel.textColor = Theme.primaryText
  212. let periodLabel = NSTextField(labelWithString: plan.period)
  213. periodLabel.font = .systemFont(ofSize: 30, weight: .semibold)
  214. periodLabel.textColor = Theme.secondaryText
  215. let priceRow = NSStackView(views: [priceLabel, periodLabel])
  216. priceRow.orientation = .horizontal
  217. priceRow.spacing = 4
  218. priceRow.alignment = .firstBaseline
  219. let billingLabel = NSTextField(labelWithString: plan.billedLine)
  220. billingLabel.font = .systemFont(ofSize: 13, weight: .medium)
  221. billingLabel.textColor = Theme.secondaryText
  222. billingLabel.isHidden = plan.billedLine.isEmpty
  223. let inlinePriceInfo = inlinePriceInfoLabel(oldPrice: plan.crossedPrice, newPrice: plan.savingsText)
  224. inlinePriceInfo.isHidden = (plan.crossedPrice == nil || plan.savingsText == nil)
  225. let divider = NSBox()
  226. divider.boxType = .separator
  227. divider.translatesAutoresizingMaskIntoConstraints = false
  228. divider.borderColor = Theme.divider
  229. let featuresStack = NSStackView(views: plan.features.map(makeFeatureRow(_:)))
  230. featuresStack.orientation = .vertical
  231. featuresStack.spacing = 9
  232. featuresStack.alignment = .leading
  233. let selectButton = NSButton(title: "Get \(plan.title)", target: self, action: #selector(didTapSelectPlan))
  234. selectButton.identifier = NSUserInterfaceItemIdentifier(plan.id)
  235. selectButton.isBordered = false
  236. selectButton.bezelStyle = .rounded
  237. selectButton.font = .systemFont(ofSize: 15, weight: .bold)
  238. selectButton.contentTintColor = plan.highlight ? .white : Theme.primaryText
  239. selectButton.wantsLayer = true
  240. selectButton.layer?.cornerRadius = 12
  241. selectButton.layer?.borderWidth = 1
  242. selectButton.layer?.borderColor = (plan.highlight ? Theme.accent : Theme.divider).cgColor
  243. selectButton.layer?.backgroundColor = (plan.highlight
  244. ? NSColor(srgbRed: 189 / 255, green: 52 / 255, blue: 255 / 255, alpha: 1)
  245. : Theme.mutedButtonFill).cgColor
  246. selectButton.translatesAutoresizingMaskIntoConstraints = false
  247. selectButton.heightAnchor.constraint(equalToConstant: 58).isActive = true
  248. let spacer = NSView()
  249. spacer.setContentHuggingPriority(.defaultLow, for: .vertical)
  250. let content = NSStackView(views: [iconWell, titleLabel, subtitleLabel, billingPill, priceRow, billingLabel, inlinePriceInfo, divider, featuresStack, spacer, selectButton])
  251. content.orientation = .vertical
  252. content.spacing = 10
  253. content.alignment = .centerX
  254. content.translatesAutoresizingMaskIntoConstraints = false
  255. card.addSubview(content)
  256. NSLayoutConstraint.activate([
  257. divider.widthAnchor.constraint(equalTo: content.widthAnchor),
  258. content.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 18),
  259. content.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -18),
  260. content.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
  261. content.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -16),
  262. selectButton.widthAnchor.constraint(equalTo: content.widthAnchor)
  263. ])
  264. return card
  265. }
  266. private func makeFeatureRow(_ text: String) -> NSView {
  267. let icon = NSImageView()
  268. icon.translatesAutoresizingMaskIntoConstraints = false
  269. icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .bold)
  270. icon.image = NSImage(systemSymbolName: "checkmark.circle.fill", accessibilityDescription: nil)
  271. icon.contentTintColor = Theme.iconTint
  272. icon.widthAnchor.constraint(equalToConstant: 14).isActive = true
  273. let label = NSTextField(labelWithString: text)
  274. label.font = .systemFont(ofSize: 16, weight: .semibold)
  275. label.textColor = Theme.primaryText
  276. let row = NSStackView(views: [icon, label])
  277. row.orientation = .horizontal
  278. row.spacing = 8
  279. row.alignment = .centerY
  280. row.distribution = .fill
  281. return row
  282. }
  283. private func inlinePriceInfoLabel(oldPrice: String?, newPrice: String?) -> NSTextField {
  284. guard let oldPrice, let newPrice else {
  285. return NSTextField(labelWithString: "")
  286. }
  287. let full = NSMutableAttributedString()
  288. let oldAttributes: [NSAttributedString.Key: Any] = [
  289. .font: NSFont.systemFont(ofSize: 12, weight: .semibold),
  290. .foregroundColor: Theme.secondaryText,
  291. .strikethroughStyle: NSUnderlineStyle.single.rawValue
  292. ]
  293. let newAttributes: [NSAttributedString.Key: Any] = [
  294. .font: NSFont.systemFont(ofSize: 12, weight: .bold),
  295. .foregroundColor: Theme.successText
  296. ]
  297. full.append(NSAttributedString(string: "\(oldPrice) ", attributes: oldAttributes))
  298. full.append(NSAttributedString(string: newPrice, attributes: newAttributes))
  299. let label = NSTextField(labelWithAttributedString: full)
  300. return label
  301. }
  302. private func pillLabel(text: String, tint: NSColor, textColor: NSColor) -> NSTextField {
  303. let pill = NSTextField(labelWithString: text)
  304. pill.font = .systemFont(ofSize: 10, weight: .semibold)
  305. pill.textColor = textColor
  306. pill.alignment = .center
  307. pill.wantsLayer = true
  308. pill.layer?.backgroundColor = tint.cgColor
  309. pill.layer?.cornerRadius = 9
  310. pill.translatesAutoresizingMaskIntoConstraints = false
  311. pill.heightAnchor.constraint(equalToConstant: 18).isActive = true
  312. pill.widthAnchor.constraint(greaterThanOrEqualToConstant: 95).isActive = true
  313. return pill
  314. }
  315. private func makeTrustRow() -> NSView {
  316. let badges = NSStackView(views: [
  317. trustBadge(icon: "shield.fill", title: "Secure Payments", subtitle: "Your payment is 100% secure."),
  318. trustBadge(icon: "arrow.counterclockwise", title: "Cancel Anytime", subtitle: "No commitment, cancel anytime."),
  319. trustBadge(icon: "headphones", title: "24/7 Support", subtitle: "We're here to help you anytime."),
  320. trustBadge(icon: "lock.fill", title: "Privacy First", subtitle: "Your data is safe with us.")
  321. ])
  322. badges.orientation = .horizontal
  323. badges.alignment = .centerY
  324. badges.distribution = .fillEqually
  325. badges.spacing = 12
  326. badges.edgeInsets = NSEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
  327. badges.translatesAutoresizingMaskIntoConstraints = false
  328. badges.wantsLayer = true
  329. badges.layer?.backgroundColor = Theme.bottomStrip.cgColor
  330. badges.layer?.borderColor = Theme.divider.cgColor
  331. badges.layer?.borderWidth = 1
  332. badges.layer?.cornerRadius = 10
  333. badges.setHuggingPriority(.defaultLow, for: .horizontal)
  334. badges.heightAnchor.constraint(equalToConstant: 72).isActive = true
  335. return badges
  336. }
  337. private func makeFooterRow() -> NSView {
  338. let items = [
  339. "Manage Subscription",
  340. "Restore Purchase",
  341. "Privacy Policy",
  342. "Terms of Services",
  343. "Support"
  344. ]
  345. let cells = items.enumerated().map { index, text in
  346. footerCell(text: text, showsTrailingDivider: index < items.count - 1)
  347. }
  348. let links = NSStackView(views: cells)
  349. links.orientation = .horizontal
  350. links.distribution = .fillEqually
  351. links.spacing = 0
  352. links.alignment = .centerY
  353. links.translatesAutoresizingMaskIntoConstraints = false
  354. return links
  355. }
  356. private func footerCell(text: String, showsTrailingDivider: Bool) -> NSView {
  357. let container = NSView()
  358. container.translatesAutoresizingMaskIntoConstraints = false
  359. let label = footerLink(text)
  360. label.translatesAutoresizingMaskIntoConstraints = false
  361. label.alignment = .center
  362. container.addSubview(label)
  363. var constraints = [
  364. label.centerXAnchor.constraint(equalTo: container.centerXAnchor),
  365. label.centerYAnchor.constraint(equalTo: container.centerYAnchor)
  366. ]
  367. if showsTrailingDivider {
  368. let divider = footerDivider()
  369. container.addSubview(divider)
  370. constraints.append(contentsOf: [
  371. divider.trailingAnchor.constraint(equalTo: container.trailingAnchor),
  372. divider.centerYAnchor.constraint(equalTo: container.centerYAnchor)
  373. ])
  374. }
  375. NSLayoutConstraint.activate(constraints)
  376. return container
  377. }
  378. private func trustBadge(icon: String, title: String, subtitle: String) -> NSView {
  379. let image = NSImageView()
  380. image.translatesAutoresizingMaskIntoConstraints = false
  381. image.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
  382. image.image = NSImage(systemSymbolName: icon, accessibilityDescription: nil)
  383. image.contentTintColor = Theme.primaryText
  384. let titleLabel = NSTextField(labelWithString: title)
  385. titleLabel.font = .systemFont(ofSize: 12, weight: .bold)
  386. titleLabel.textColor = Theme.primaryText
  387. let subtitleLabel = NSTextField(labelWithString: subtitle)
  388. subtitleLabel.font = .systemFont(ofSize: 10, weight: .medium)
  389. subtitleLabel.textColor = Theme.secondaryText
  390. let textStack = NSStackView(views: [titleLabel, subtitleLabel])
  391. textStack.orientation = .vertical
  392. textStack.spacing = 2
  393. textStack.alignment = .leading
  394. let stack = NSStackView(views: [image, textStack])
  395. stack.orientation = .horizontal
  396. stack.spacing = 8
  397. stack.alignment = .leading
  398. stack.edgeInsets = NSEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
  399. stack.wantsLayer = true
  400. stack.layer?.backgroundColor = NSColor.clear.cgColor
  401. return stack
  402. }
  403. private func footerLink(_ text: String) -> NSTextField {
  404. let label = NSTextField(labelWithString: text)
  405. label.font = .systemFont(ofSize: 12, weight: .medium)
  406. label.textColor = Theme.secondaryText
  407. return label
  408. }
  409. private func footerDivider() -> NSBox {
  410. let divider = NSBox()
  411. divider.boxType = .separator
  412. divider.borderColor = Theme.divider
  413. divider.translatesAutoresizingMaskIntoConstraints = false
  414. divider.widthAnchor.constraint(equalToConstant: 1).isActive = true
  415. divider.heightAnchor.constraint(equalToConstant: 14).isActive = true
  416. return divider
  417. }
  418. @objc private func didTapSelectPlan(_ sender: NSButton) {
  419. sender.layer?.backgroundColor = Theme.accentHover.cgColor
  420. let selectedPlan = sender.identifier?.rawValue ?? sender.title
  421. let alert = NSAlert()
  422. alert.messageText = "Premium checkout coming soon"
  423. alert.informativeText = "Plan selected: \(selectedPlan.capitalized). Payment flow can be connected next."
  424. alert.alertStyle = .informational
  425. alert.addButton(withTitle: "OK")
  426. if let window = view.window {
  427. alert.beginSheetModal(for: window)
  428. } else {
  429. alert.runModal()
  430. }
  431. }
  432. }