|
@@ -483,8 +483,11 @@ private final class PremiumPlansViewController: NSViewController {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private enum FeatureListMetrics {
|
|
private enum FeatureListMetrics {
|
|
|
- static let spacing = CGFloat(10)
|
|
|
|
|
- static let edgeInsets = NSEdgeInsets(top: 21, left: 37, bottom: 21, right: 0)
|
|
|
|
|
|
|
+ static let rowSpacing = CGFloat(8)
|
|
|
|
|
+ static let iconLabelSpacing = CGFloat(8)
|
|
|
|
|
+ static let edgeInsets = NSEdgeInsets(top: 8, left: 0, bottom: 8, right: 0)
|
|
|
|
|
+ /// Caps feature list height so pricing cards do not push the footer off-screen.
|
|
|
|
|
+ static let maxScrollHeight = CGFloat(168)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private let subscriptionStore = SubscriptionStore.shared
|
|
private let subscriptionStore = SubscriptionStore.shared
|
|
@@ -495,6 +498,14 @@ private final class PremiumPlansViewController: NSViewController {
|
|
|
private var subscriptionStatusObservation: NSObjectProtocol?
|
|
private var subscriptionStatusObservation: NSObjectProtocol?
|
|
|
private var appearanceObserver: NSObjectProtocol?
|
|
private var appearanceObserver: NSObjectProtocol?
|
|
|
|
|
|
|
|
|
|
+ /// Core Pro capabilities shown on every pricing card (replaces generic “All premium features”).
|
|
|
|
|
+ private static let proCapabilityFeatures = [
|
|
|
|
|
+ "Unlimited AI job search on Home",
|
|
|
|
|
+ "Save jobs & open listings in-app",
|
|
|
|
|
+ "CV Maker, profiles & PDF export",
|
|
|
|
|
+ "Role, company & skill shortcuts"
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
private let plans: [Plan] = [
|
|
private let plans: [Plan] = [
|
|
|
Plan(
|
|
Plan(
|
|
|
id: "weekly",
|
|
id: "weekly",
|
|
@@ -505,9 +516,8 @@ private final class PremiumPlansViewController: NSViewController {
|
|
|
billedLine: "",
|
|
billedLine: "",
|
|
|
crossedPrice: nil,
|
|
crossedPrice: nil,
|
|
|
savingsText: nil,
|
|
savingsText: nil,
|
|
|
- features: [
|
|
|
|
|
- "All premium features",
|
|
|
|
|
- "Perfect for short-term goals",
|
|
|
|
|
|
|
+ features: proCapabilityFeatures + [
|
|
|
|
|
+ "Perfect for short-term job hunts",
|
|
|
"Cancel anytime"
|
|
"Cancel anytime"
|
|
|
],
|
|
],
|
|
|
iconName: "paperplane.fill",
|
|
iconName: "paperplane.fill",
|
|
@@ -523,9 +533,8 @@ private final class PremiumPlansViewController: NSViewController {
|
|
|
billedLine: "",
|
|
billedLine: "",
|
|
|
crossedPrice: nil,
|
|
crossedPrice: nil,
|
|
|
savingsText: nil,
|
|
savingsText: nil,
|
|
|
- features: [
|
|
|
|
|
- "All premium features",
|
|
|
|
|
- "Best value for regular users",
|
|
|
|
|
|
|
+ features: proCapabilityFeatures + [
|
|
|
|
|
+ "Best for regular job seekers",
|
|
|
"Priority support"
|
|
"Priority support"
|
|
|
],
|
|
],
|
|
|
iconName: "bolt.fill",
|
|
iconName: "bolt.fill",
|
|
@@ -541,8 +550,7 @@ private final class PremiumPlansViewController: NSViewController {
|
|
|
billedLine: "",
|
|
billedLine: "",
|
|
|
crossedPrice: nil,
|
|
crossedPrice: nil,
|
|
|
savingsText: nil,
|
|
savingsText: nil,
|
|
|
- features: [
|
|
|
|
|
- "All premium features",
|
|
|
|
|
|
|
+ features: proCapabilityFeatures + [
|
|
|
"Lowest effective monthly cost",
|
|
"Lowest effective monthly cost",
|
|
|
"Ideal for long-term use"
|
|
"Ideal for long-term use"
|
|
|
],
|
|
],
|
|
@@ -638,34 +646,73 @@ private final class PremiumPlansViewController: NSViewController {
|
|
|
cardsRow.alignment = .top
|
|
cardsRow.alignment = .top
|
|
|
cardsRow.distribution = .fillEqually
|
|
cardsRow.distribution = .fillEqually
|
|
|
cardsRow.translatesAutoresizingMaskIntoConstraints = false
|
|
cardsRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
- for card in cardsRow.arrangedSubviews {
|
|
|
|
|
- card.heightAnchor.constraint(equalTo: cardsRow.heightAnchor).isActive = true
|
|
|
|
|
- }
|
|
|
|
|
|
|
|
|
|
let trustRow = makeTrustRow()
|
|
let trustRow = makeTrustRow()
|
|
|
let footerRow = makeFooterRow()
|
|
let footerRow = makeFooterRow()
|
|
|
|
|
|
|
|
- let root = NSStackView(views: [crownIcon, title, subtitle, cardsRow, trustRow, footerRow])
|
|
|
|
|
- root.orientation = .vertical
|
|
|
|
|
- root.spacing = 18
|
|
|
|
|
- root.alignment = .centerX
|
|
|
|
|
- root.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
-
|
|
|
|
|
- view.addSubview(root)
|
|
|
|
|
|
|
+ let headerStack = NSStackView(views: [crownIcon, title, subtitle])
|
|
|
|
|
+ headerStack.orientation = .vertical
|
|
|
|
|
+ headerStack.spacing = 10
|
|
|
|
|
+ headerStack.alignment = .centerX
|
|
|
|
|
+ headerStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ let bodyStack = NSStackView(views: [cardsRow, trustRow])
|
|
|
|
|
+ bodyStack.orientation = .vertical
|
|
|
|
|
+ bodyStack.spacing = 16
|
|
|
|
|
+ bodyStack.alignment = .centerX
|
|
|
|
|
+ bodyStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ let scrollView = NSScrollView()
|
|
|
|
|
+ scrollView.hasVerticalScroller = true
|
|
|
|
|
+ scrollView.autohidesScrollers = true
|
|
|
|
|
+ scrollView.drawsBackground = false
|
|
|
|
|
+ scrollView.borderType = .noBorder
|
|
|
|
|
+ scrollView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ let scrollDocument = NSView()
|
|
|
|
|
+ scrollDocument.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ scrollView.documentView = scrollDocument
|
|
|
|
|
+ scrollDocument.addSubview(bodyStack)
|
|
|
|
|
+
|
|
|
|
|
+ view.addSubview(headerStack)
|
|
|
|
|
+ view.addSubview(scrollView)
|
|
|
|
|
+ view.addSubview(footerRow)
|
|
|
view.addSubview(closeButton)
|
|
view.addSubview(closeButton)
|
|
|
|
|
+
|
|
|
|
|
+ let scrollClip = scrollView.contentView
|
|
|
NSLayoutConstraint.activate([
|
|
NSLayoutConstraint.activate([
|
|
|
- root.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
|
|
|
|
|
- root.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
|
|
|
|
|
- root.topAnchor.constraint(equalTo: view.topAnchor, constant: 20),
|
|
|
|
|
- root.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -16),
|
|
|
|
|
|
|
+ headerStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
|
|
|
|
|
+ headerStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
|
|
|
|
|
+ headerStack.topAnchor.constraint(equalTo: view.topAnchor, constant: 20),
|
|
|
|
|
+
|
|
|
|
|
+ scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
|
|
|
|
|
+ scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
|
|
|
|
|
+ scrollView.topAnchor.constraint(equalTo: headerStack.bottomAnchor, constant: 12),
|
|
|
|
|
+ scrollView.bottomAnchor.constraint(equalTo: footerRow.topAnchor, constant: -12),
|
|
|
|
|
+
|
|
|
|
|
+ footerRow.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
|
|
|
|
|
+ footerRow.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
|
|
|
|
|
+ footerRow.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -16),
|
|
|
|
|
+ footerRow.heightAnchor.constraint(greaterThanOrEqualToConstant: 32),
|
|
|
|
|
+
|
|
|
|
|
+ scrollDocument.leadingAnchor.constraint(equalTo: scrollClip.leadingAnchor),
|
|
|
|
|
+ scrollDocument.trailingAnchor.constraint(equalTo: scrollClip.trailingAnchor),
|
|
|
|
|
+ scrollDocument.topAnchor.constraint(equalTo: scrollClip.topAnchor),
|
|
|
|
|
+ scrollDocument.widthAnchor.constraint(equalTo: scrollClip.widthAnchor),
|
|
|
|
|
+ scrollDocument.bottomAnchor.constraint(equalTo: bodyStack.bottomAnchor, constant: 8),
|
|
|
|
|
+
|
|
|
|
|
+ bodyStack.leadingAnchor.constraint(equalTo: scrollDocument.leadingAnchor),
|
|
|
|
|
+ bodyStack.trailingAnchor.constraint(equalTo: scrollDocument.trailingAnchor),
|
|
|
|
|
+ bodyStack.topAnchor.constraint(equalTo: scrollDocument.topAnchor, constant: 4),
|
|
|
|
|
+ bodyStack.widthAnchor.constraint(equalTo: scrollDocument.widthAnchor),
|
|
|
|
|
+
|
|
|
|
|
+ cardsRow.widthAnchor.constraint(equalTo: bodyStack.widthAnchor),
|
|
|
|
|
+ trustRow.widthAnchor.constraint(equalTo: bodyStack.widthAnchor),
|
|
|
|
|
+
|
|
|
closeButton.topAnchor.constraint(equalTo: view.topAnchor, constant: 14),
|
|
closeButton.topAnchor.constraint(equalTo: view.topAnchor, constant: 14),
|
|
|
closeButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -14),
|
|
closeButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -14),
|
|
|
closeButton.widthAnchor.constraint(equalToConstant: 30),
|
|
closeButton.widthAnchor.constraint(equalToConstant: 30),
|
|
|
closeButton.heightAnchor.constraint(equalToConstant: 30),
|
|
closeButton.heightAnchor.constraint(equalToConstant: 30),
|
|
|
- cardsRow.widthAnchor.constraint(equalTo: root.widthAnchor),
|
|
|
|
|
- cardsRow.heightAnchor.constraint(equalToConstant: 420),
|
|
|
|
|
- trustRow.widthAnchor.constraint(equalTo: root.widthAnchor),
|
|
|
|
|
- footerRow.widthAnchor.constraint(equalTo: root.widthAnchor),
|
|
|
|
|
crownIcon.heightAnchor.constraint(equalToConstant: 20)
|
|
crownIcon.heightAnchor.constraint(equalToConstant: 20)
|
|
|
])
|
|
])
|
|
|
premiumCloseButton = closeButton
|
|
premiumCloseButton = closeButton
|
|
@@ -741,9 +788,15 @@ private final class PremiumPlansViewController: NSViewController {
|
|
|
let featureRows = plan.features.map(makeFeatureRow(_:))
|
|
let featureRows = plan.features.map(makeFeatureRow(_:))
|
|
|
let featuresStack = NSStackView(views: featureRows)
|
|
let featuresStack = NSStackView(views: featureRows)
|
|
|
featuresStack.orientation = .vertical
|
|
featuresStack.orientation = .vertical
|
|
|
- featuresStack.spacing = FeatureListMetrics.spacing
|
|
|
|
|
|
|
+ featuresStack.spacing = FeatureListMetrics.rowSpacing
|
|
|
featuresStack.alignment = .leading
|
|
featuresStack.alignment = .leading
|
|
|
featuresStack.edgeInsets = FeatureListMetrics.edgeInsets
|
|
featuresStack.edgeInsets = FeatureListMetrics.edgeInsets
|
|
|
|
|
+ featuresStack.setContentCompressionResistancePriority(.required, for: .vertical)
|
|
|
|
|
+ featuresStack.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
|
|
|
|
+ for row in featureRows {
|
|
|
|
|
+ row.setContentCompressionResistancePriority(.required, for: .vertical)
|
|
|
|
|
+ }
|
|
|
|
|
+ let featuresScroll = makeFeatureListScroll(featuresStack: featuresStack)
|
|
|
|
|
|
|
|
let selectButton = PlanPurchaseHoverButton(
|
|
let selectButton = PlanPurchaseHoverButton(
|
|
|
planId: plan.id,
|
|
planId: plan.id,
|
|
@@ -788,14 +841,9 @@ private final class PremiumPlansViewController: NSViewController {
|
|
|
if plan.crossedPrice != nil, plan.savingsText != nil {
|
|
if plan.crossedPrice != nil, plan.savingsText != nil {
|
|
|
contentViews.append(inlinePriceInfo)
|
|
contentViews.append(inlinePriceInfo)
|
|
|
}
|
|
}
|
|
|
- contentViews.append(contentsOf: [divider, featuresStack])
|
|
|
|
|
|
|
+ contentViews.append(contentsOf: [divider, featuresScroll])
|
|
|
|
|
|
|
|
- let verticalFlex = NSView()
|
|
|
|
|
- verticalFlex.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
- verticalFlex.setContentHuggingPriority(.defaultLow, for: .vertical)
|
|
|
|
|
- verticalFlex.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
|
|
|
|
-
|
|
|
|
|
- let column = NSStackView(views: contentViews + [verticalFlex, selectButton])
|
|
|
|
|
|
|
+ let column = NSStackView(views: contentViews + [selectButton])
|
|
|
column.orientation = .vertical
|
|
column.orientation = .vertical
|
|
|
column.spacing = 10
|
|
column.spacing = 10
|
|
|
column.alignment = .centerX
|
|
column.alignment = .centerX
|
|
@@ -806,7 +854,7 @@ private final class PremiumPlansViewController: NSViewController {
|
|
|
|
|
|
|
|
NSLayoutConstraint.activate([
|
|
NSLayoutConstraint.activate([
|
|
|
divider.widthAnchor.constraint(equalTo: column.widthAnchor),
|
|
divider.widthAnchor.constraint(equalTo: column.widthAnchor),
|
|
|
- featuresStack.widthAnchor.constraint(equalTo: column.widthAnchor),
|
|
|
|
|
|
|
+ featuresScroll.widthAnchor.constraint(equalTo: column.widthAnchor),
|
|
|
column.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 18),
|
|
column.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 18),
|
|
|
column.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -18),
|
|
column.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -18),
|
|
|
column.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
|
|
column.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
|
|
@@ -819,6 +867,35 @@ private final class PremiumPlansViewController: NSViewController {
|
|
|
return card
|
|
return card
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private func makeFeatureListScroll(featuresStack: NSStackView) -> NSScrollView {
|
|
|
|
|
+ let scroll = NSScrollView()
|
|
|
|
|
+ scroll.hasVerticalScroller = true
|
|
|
|
|
+ scroll.autohidesScrollers = true
|
|
|
|
|
+ scroll.drawsBackground = false
|
|
|
|
|
+ scroll.borderType = .noBorder
|
|
|
|
|
+ scroll.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ let document = NSView()
|
|
|
|
|
+ document.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ featuresStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ scroll.documentView = document
|
|
|
|
|
+ document.addSubview(featuresStack)
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ scroll.heightAnchor.constraint(equalToConstant: FeatureListMetrics.maxScrollHeight),
|
|
|
|
|
+ document.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
|
|
|
|
|
+ document.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
|
|
|
|
|
+ document.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
|
|
|
|
|
+ document.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor),
|
|
|
|
|
+ document.bottomAnchor.constraint(equalTo: featuresStack.bottomAnchor, constant: FeatureListMetrics.edgeInsets.bottom),
|
|
|
|
|
+ featuresStack.leadingAnchor.constraint(equalTo: document.leadingAnchor, constant: FeatureListMetrics.edgeInsets.left),
|
|
|
|
|
+ featuresStack.trailingAnchor.constraint(equalTo: document.trailingAnchor, constant: -FeatureListMetrics.edgeInsets.right),
|
|
|
|
|
+ featuresStack.topAnchor.constraint(equalTo: document.topAnchor, constant: FeatureListMetrics.edgeInsets.top),
|
|
|
|
|
+ featuresStack.widthAnchor.constraint(equalTo: document.widthAnchor, constant: -(FeatureListMetrics.edgeInsets.left + FeatureListMetrics.edgeInsets.right))
|
|
|
|
|
+ ])
|
|
|
|
|
+ return scroll
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func makeFeatureRow(_ text: String) -> NSView {
|
|
private func makeFeatureRow(_ text: String) -> NSView {
|
|
|
let icon = NSImageView()
|
|
let icon = NSImageView()
|
|
|
icon.translatesAutoresizingMaskIntoConstraints = false
|
|
icon.translatesAutoresizingMaskIntoConstraints = false
|
|
@@ -826,16 +903,29 @@ private final class PremiumPlansViewController: NSViewController {
|
|
|
icon.image = NSImage(systemSymbolName: "checkmark.circle.fill", accessibilityDescription: nil)
|
|
icon.image = NSImage(systemSymbolName: "checkmark.circle.fill", accessibilityDescription: nil)
|
|
|
icon.contentTintColor = Theme.iconTint
|
|
icon.contentTintColor = Theme.iconTint
|
|
|
icon.widthAnchor.constraint(equalToConstant: 14).isActive = true
|
|
icon.widthAnchor.constraint(equalToConstant: 14).isActive = true
|
|
|
|
|
+ icon.heightAnchor.constraint(equalToConstant: 14).isActive = true
|
|
|
|
|
+ icon.setContentHuggingPriority(.required, for: .horizontal)
|
|
|
|
|
+ icon.setContentHuggingPriority(.required, for: .vertical)
|
|
|
|
|
|
|
|
- let label = NSTextField(labelWithString: text)
|
|
|
|
|
- label.font = .systemFont(ofSize: 14, weight: .medium)
|
|
|
|
|
|
|
+ let label = NSTextField(wrappingLabelWithString: text)
|
|
|
|
|
+ label.font = .systemFont(ofSize: 13, weight: .medium)
|
|
|
label.textColor = Theme.primaryText
|
|
label.textColor = Theme.primaryText
|
|
|
|
|
+ label.alignment = .left
|
|
|
|
|
+ label.lineBreakMode = .byWordWrapping
|
|
|
|
|
+ label.maximumNumberOfLines = 0
|
|
|
|
|
+ label.cell?.wraps = true
|
|
|
|
|
+ label.cell?.isScrollable = false
|
|
|
|
|
+ label.setContentCompressionResistancePriority(.required, for: .vertical)
|
|
|
|
|
+ label.setContentHuggingPriority(.required, for: .vertical)
|
|
|
|
|
+ label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
|
|
+ label.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
|
|
|
|
|
let row = NSStackView(views: [icon, label])
|
|
let row = NSStackView(views: [icon, label])
|
|
|
row.orientation = .horizontal
|
|
row.orientation = .horizontal
|
|
|
- row.spacing = FeatureListMetrics.spacing
|
|
|
|
|
- row.alignment = .centerY
|
|
|
|
|
|
|
+ row.spacing = FeatureListMetrics.iconLabelSpacing
|
|
|
|
|
+ row.alignment = .top
|
|
|
row.distribution = .fill
|
|
row.distribution = .fill
|
|
|
|
|
+ row.translatesAutoresizingMaskIntoConstraints = false
|
|
|
return row
|
|
return row
|
|
|
}
|
|
}
|
|
|
|
|
|