|
|
@@ -483,8 +483,11 @@ private final class PremiumPlansViewController: NSViewController {
|
|
483
|
483
|
}
|
|
484
|
484
|
|
|
485
|
485
|
private enum FeatureListMetrics {
|
|
486
|
|
- static let spacing = CGFloat(10)
|
|
487
|
|
- static let edgeInsets = NSEdgeInsets(top: 21, left: 37, bottom: 21, right: 0)
|
|
|
486
|
+ static let rowSpacing = CGFloat(8)
|
|
|
487
|
+ static let iconLabelSpacing = CGFloat(8)
|
|
|
488
|
+ static let edgeInsets = NSEdgeInsets(top: 8, left: 0, bottom: 8, right: 0)
|
|
|
489
|
+ /// Caps feature list height so pricing cards do not push the footer off-screen.
|
|
|
490
|
+ static let maxScrollHeight = CGFloat(168)
|
|
488
|
491
|
}
|
|
489
|
492
|
|
|
490
|
493
|
private let subscriptionStore = SubscriptionStore.shared
|
|
|
@@ -495,6 +498,14 @@ private final class PremiumPlansViewController: NSViewController {
|
|
495
|
498
|
private var subscriptionStatusObservation: NSObjectProtocol?
|
|
496
|
499
|
private var appearanceObserver: NSObjectProtocol?
|
|
497
|
500
|
|
|
|
501
|
+ /// Core Pro capabilities shown on every pricing card (replaces generic “All premium features”).
|
|
|
502
|
+ private static let proCapabilityFeatures = [
|
|
|
503
|
+ "Unlimited AI job search on Home",
|
|
|
504
|
+ "Save jobs & open listings in-app",
|
|
|
505
|
+ "CV Maker, profiles & PDF export",
|
|
|
506
|
+ "Role, company & skill shortcuts"
|
|
|
507
|
+ ]
|
|
|
508
|
+
|
|
498
|
509
|
private let plans: [Plan] = [
|
|
499
|
510
|
Plan(
|
|
500
|
511
|
id: "weekly",
|
|
|
@@ -505,9 +516,8 @@ private final class PremiumPlansViewController: NSViewController {
|
|
505
|
516
|
billedLine: "",
|
|
506
|
517
|
crossedPrice: nil,
|
|
507
|
518
|
savingsText: nil,
|
|
508
|
|
- features: [
|
|
509
|
|
- "All premium features",
|
|
510
|
|
- "Perfect for short-term goals",
|
|
|
519
|
+ features: proCapabilityFeatures + [
|
|
|
520
|
+ "Perfect for short-term job hunts",
|
|
511
|
521
|
"Cancel anytime"
|
|
512
|
522
|
],
|
|
513
|
523
|
iconName: "paperplane.fill",
|
|
|
@@ -523,9 +533,8 @@ private final class PremiumPlansViewController: NSViewController {
|
|
523
|
533
|
billedLine: "",
|
|
524
|
534
|
crossedPrice: nil,
|
|
525
|
535
|
savingsText: nil,
|
|
526
|
|
- features: [
|
|
527
|
|
- "All premium features",
|
|
528
|
|
- "Best value for regular users",
|
|
|
536
|
+ features: proCapabilityFeatures + [
|
|
|
537
|
+ "Best for regular job seekers",
|
|
529
|
538
|
"Priority support"
|
|
530
|
539
|
],
|
|
531
|
540
|
iconName: "bolt.fill",
|
|
|
@@ -541,8 +550,7 @@ private final class PremiumPlansViewController: NSViewController {
|
|
541
|
550
|
billedLine: "",
|
|
542
|
551
|
crossedPrice: nil,
|
|
543
|
552
|
savingsText: nil,
|
|
544
|
|
- features: [
|
|
545
|
|
- "All premium features",
|
|
|
553
|
+ features: proCapabilityFeatures + [
|
|
546
|
554
|
"Lowest effective monthly cost",
|
|
547
|
555
|
"Ideal for long-term use"
|
|
548
|
556
|
],
|
|
|
@@ -638,34 +646,73 @@ private final class PremiumPlansViewController: NSViewController {
|
|
638
|
646
|
cardsRow.alignment = .top
|
|
639
|
647
|
cardsRow.distribution = .fillEqually
|
|
640
|
648
|
cardsRow.translatesAutoresizingMaskIntoConstraints = false
|
|
641
|
|
- for card in cardsRow.arrangedSubviews {
|
|
642
|
|
- card.heightAnchor.constraint(equalTo: cardsRow.heightAnchor).isActive = true
|
|
643
|
|
- }
|
|
644
|
649
|
|
|
645
|
650
|
let trustRow = makeTrustRow()
|
|
646
|
651
|
let footerRow = makeFooterRow()
|
|
647
|
652
|
|
|
648
|
|
- let root = NSStackView(views: [crownIcon, title, subtitle, cardsRow, trustRow, footerRow])
|
|
649
|
|
- root.orientation = .vertical
|
|
650
|
|
- root.spacing = 18
|
|
651
|
|
- root.alignment = .centerX
|
|
652
|
|
- root.translatesAutoresizingMaskIntoConstraints = false
|
|
653
|
|
-
|
|
654
|
|
- view.addSubview(root)
|
|
|
653
|
+ let headerStack = NSStackView(views: [crownIcon, title, subtitle])
|
|
|
654
|
+ headerStack.orientation = .vertical
|
|
|
655
|
+ headerStack.spacing = 10
|
|
|
656
|
+ headerStack.alignment = .centerX
|
|
|
657
|
+ headerStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
658
|
+
|
|
|
659
|
+ let bodyStack = NSStackView(views: [cardsRow, trustRow])
|
|
|
660
|
+ bodyStack.orientation = .vertical
|
|
|
661
|
+ bodyStack.spacing = 16
|
|
|
662
|
+ bodyStack.alignment = .centerX
|
|
|
663
|
+ bodyStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
664
|
+
|
|
|
665
|
+ let scrollView = NSScrollView()
|
|
|
666
|
+ scrollView.hasVerticalScroller = true
|
|
|
667
|
+ scrollView.autohidesScrollers = true
|
|
|
668
|
+ scrollView.drawsBackground = false
|
|
|
669
|
+ scrollView.borderType = .noBorder
|
|
|
670
|
+ scrollView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
671
|
+
|
|
|
672
|
+ let scrollDocument = NSView()
|
|
|
673
|
+ scrollDocument.translatesAutoresizingMaskIntoConstraints = false
|
|
|
674
|
+ scrollView.documentView = scrollDocument
|
|
|
675
|
+ scrollDocument.addSubview(bodyStack)
|
|
|
676
|
+
|
|
|
677
|
+ view.addSubview(headerStack)
|
|
|
678
|
+ view.addSubview(scrollView)
|
|
|
679
|
+ view.addSubview(footerRow)
|
|
655
|
680
|
view.addSubview(closeButton)
|
|
|
681
|
+
|
|
|
682
|
+ let scrollClip = scrollView.contentView
|
|
656
|
683
|
NSLayoutConstraint.activate([
|
|
657
|
|
- root.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
|
|
658
|
|
- root.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
|
|
659
|
|
- root.topAnchor.constraint(equalTo: view.topAnchor, constant: 20),
|
|
660
|
|
- root.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -16),
|
|
|
684
|
+ headerStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
|
|
|
685
|
+ headerStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
|
|
|
686
|
+ headerStack.topAnchor.constraint(equalTo: view.topAnchor, constant: 20),
|
|
|
687
|
+
|
|
|
688
|
+ scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
|
|
|
689
|
+ scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
|
|
|
690
|
+ scrollView.topAnchor.constraint(equalTo: headerStack.bottomAnchor, constant: 12),
|
|
|
691
|
+ scrollView.bottomAnchor.constraint(equalTo: footerRow.topAnchor, constant: -12),
|
|
|
692
|
+
|
|
|
693
|
+ footerRow.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
|
|
|
694
|
+ footerRow.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
|
|
|
695
|
+ footerRow.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -16),
|
|
|
696
|
+ footerRow.heightAnchor.constraint(greaterThanOrEqualToConstant: 32),
|
|
|
697
|
+
|
|
|
698
|
+ scrollDocument.leadingAnchor.constraint(equalTo: scrollClip.leadingAnchor),
|
|
|
699
|
+ scrollDocument.trailingAnchor.constraint(equalTo: scrollClip.trailingAnchor),
|
|
|
700
|
+ scrollDocument.topAnchor.constraint(equalTo: scrollClip.topAnchor),
|
|
|
701
|
+ scrollDocument.widthAnchor.constraint(equalTo: scrollClip.widthAnchor),
|
|
|
702
|
+ scrollDocument.bottomAnchor.constraint(equalTo: bodyStack.bottomAnchor, constant: 8),
|
|
|
703
|
+
|
|
|
704
|
+ bodyStack.leadingAnchor.constraint(equalTo: scrollDocument.leadingAnchor),
|
|
|
705
|
+ bodyStack.trailingAnchor.constraint(equalTo: scrollDocument.trailingAnchor),
|
|
|
706
|
+ bodyStack.topAnchor.constraint(equalTo: scrollDocument.topAnchor, constant: 4),
|
|
|
707
|
+ bodyStack.widthAnchor.constraint(equalTo: scrollDocument.widthAnchor),
|
|
|
708
|
+
|
|
|
709
|
+ cardsRow.widthAnchor.constraint(equalTo: bodyStack.widthAnchor),
|
|
|
710
|
+ trustRow.widthAnchor.constraint(equalTo: bodyStack.widthAnchor),
|
|
|
711
|
+
|
|
661
|
712
|
closeButton.topAnchor.constraint(equalTo: view.topAnchor, constant: 14),
|
|
662
|
713
|
closeButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -14),
|
|
663
|
714
|
closeButton.widthAnchor.constraint(equalToConstant: 30),
|
|
664
|
715
|
closeButton.heightAnchor.constraint(equalToConstant: 30),
|
|
665
|
|
- cardsRow.widthAnchor.constraint(equalTo: root.widthAnchor),
|
|
666
|
|
- cardsRow.heightAnchor.constraint(equalToConstant: 420),
|
|
667
|
|
- trustRow.widthAnchor.constraint(equalTo: root.widthAnchor),
|
|
668
|
|
- footerRow.widthAnchor.constraint(equalTo: root.widthAnchor),
|
|
669
|
716
|
crownIcon.heightAnchor.constraint(equalToConstant: 20)
|
|
670
|
717
|
])
|
|
671
|
718
|
premiumCloseButton = closeButton
|
|
|
@@ -741,9 +788,15 @@ private final class PremiumPlansViewController: NSViewController {
|
|
741
|
788
|
let featureRows = plan.features.map(makeFeatureRow(_:))
|
|
742
|
789
|
let featuresStack = NSStackView(views: featureRows)
|
|
743
|
790
|
featuresStack.orientation = .vertical
|
|
744
|
|
- featuresStack.spacing = FeatureListMetrics.spacing
|
|
|
791
|
+ featuresStack.spacing = FeatureListMetrics.rowSpacing
|
|
745
|
792
|
featuresStack.alignment = .leading
|
|
746
|
793
|
featuresStack.edgeInsets = FeatureListMetrics.edgeInsets
|
|
|
794
|
+ featuresStack.setContentCompressionResistancePriority(.required, for: .vertical)
|
|
|
795
|
+ featuresStack.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
|
|
796
|
+ for row in featureRows {
|
|
|
797
|
+ row.setContentCompressionResistancePriority(.required, for: .vertical)
|
|
|
798
|
+ }
|
|
|
799
|
+ let featuresScroll = makeFeatureListScroll(featuresStack: featuresStack)
|
|
747
|
800
|
|
|
748
|
801
|
let selectButton = PlanPurchaseHoverButton(
|
|
749
|
802
|
planId: plan.id,
|
|
|
@@ -788,14 +841,9 @@ private final class PremiumPlansViewController: NSViewController {
|
|
788
|
841
|
if plan.crossedPrice != nil, plan.savingsText != nil {
|
|
789
|
842
|
contentViews.append(inlinePriceInfo)
|
|
790
|
843
|
}
|
|
791
|
|
- contentViews.append(contentsOf: [divider, featuresStack])
|
|
|
844
|
+ contentViews.append(contentsOf: [divider, featuresScroll])
|
|
792
|
845
|
|
|
793
|
|
- let verticalFlex = NSView()
|
|
794
|
|
- verticalFlex.translatesAutoresizingMaskIntoConstraints = false
|
|
795
|
|
- verticalFlex.setContentHuggingPriority(.defaultLow, for: .vertical)
|
|
796
|
|
- verticalFlex.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
|
797
|
|
-
|
|
798
|
|
- let column = NSStackView(views: contentViews + [verticalFlex, selectButton])
|
|
|
846
|
+ let column = NSStackView(views: contentViews + [selectButton])
|
|
799
|
847
|
column.orientation = .vertical
|
|
800
|
848
|
column.spacing = 10
|
|
801
|
849
|
column.alignment = .centerX
|
|
|
@@ -806,7 +854,7 @@ private final class PremiumPlansViewController: NSViewController {
|
|
806
|
854
|
|
|
807
|
855
|
NSLayoutConstraint.activate([
|
|
808
|
856
|
divider.widthAnchor.constraint(equalTo: column.widthAnchor),
|
|
809
|
|
- featuresStack.widthAnchor.constraint(equalTo: column.widthAnchor),
|
|
|
857
|
+ featuresScroll.widthAnchor.constraint(equalTo: column.widthAnchor),
|
|
810
|
858
|
column.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 18),
|
|
811
|
859
|
column.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -18),
|
|
812
|
860
|
column.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
|
|
|
@@ -819,6 +867,35 @@ private final class PremiumPlansViewController: NSViewController {
|
|
819
|
867
|
return card
|
|
820
|
868
|
}
|
|
821
|
869
|
|
|
|
870
|
+ private func makeFeatureListScroll(featuresStack: NSStackView) -> NSScrollView {
|
|
|
871
|
+ let scroll = NSScrollView()
|
|
|
872
|
+ scroll.hasVerticalScroller = true
|
|
|
873
|
+ scroll.autohidesScrollers = true
|
|
|
874
|
+ scroll.drawsBackground = false
|
|
|
875
|
+ scroll.borderType = .noBorder
|
|
|
876
|
+ scroll.translatesAutoresizingMaskIntoConstraints = false
|
|
|
877
|
+
|
|
|
878
|
+ let document = NSView()
|
|
|
879
|
+ document.translatesAutoresizingMaskIntoConstraints = false
|
|
|
880
|
+ featuresStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
881
|
+ scroll.documentView = document
|
|
|
882
|
+ document.addSubview(featuresStack)
|
|
|
883
|
+
|
|
|
884
|
+ NSLayoutConstraint.activate([
|
|
|
885
|
+ scroll.heightAnchor.constraint(equalToConstant: FeatureListMetrics.maxScrollHeight),
|
|
|
886
|
+ document.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
|
|
|
887
|
+ document.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
|
|
|
888
|
+ document.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
|
|
|
889
|
+ document.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor),
|
|
|
890
|
+ document.bottomAnchor.constraint(equalTo: featuresStack.bottomAnchor, constant: FeatureListMetrics.edgeInsets.bottom),
|
|
|
891
|
+ featuresStack.leadingAnchor.constraint(equalTo: document.leadingAnchor, constant: FeatureListMetrics.edgeInsets.left),
|
|
|
892
|
+ featuresStack.trailingAnchor.constraint(equalTo: document.trailingAnchor, constant: -FeatureListMetrics.edgeInsets.right),
|
|
|
893
|
+ featuresStack.topAnchor.constraint(equalTo: document.topAnchor, constant: FeatureListMetrics.edgeInsets.top),
|
|
|
894
|
+ featuresStack.widthAnchor.constraint(equalTo: document.widthAnchor, constant: -(FeatureListMetrics.edgeInsets.left + FeatureListMetrics.edgeInsets.right))
|
|
|
895
|
+ ])
|
|
|
896
|
+ return scroll
|
|
|
897
|
+ }
|
|
|
898
|
+
|
|
822
|
899
|
private func makeFeatureRow(_ text: String) -> NSView {
|
|
823
|
900
|
let icon = NSImageView()
|
|
824
|
901
|
icon.translatesAutoresizingMaskIntoConstraints = false
|
|
|
@@ -826,16 +903,29 @@ private final class PremiumPlansViewController: NSViewController {
|
|
826
|
903
|
icon.image = NSImage(systemSymbolName: "checkmark.circle.fill", accessibilityDescription: nil)
|
|
827
|
904
|
icon.contentTintColor = Theme.iconTint
|
|
828
|
905
|
icon.widthAnchor.constraint(equalToConstant: 14).isActive = true
|
|
|
906
|
+ icon.heightAnchor.constraint(equalToConstant: 14).isActive = true
|
|
|
907
|
+ icon.setContentHuggingPriority(.required, for: .horizontal)
|
|
|
908
|
+ icon.setContentHuggingPriority(.required, for: .vertical)
|
|
829
|
909
|
|
|
830
|
|
- let label = NSTextField(labelWithString: text)
|
|
831
|
|
- label.font = .systemFont(ofSize: 14, weight: .medium)
|
|
|
910
|
+ let label = NSTextField(wrappingLabelWithString: text)
|
|
|
911
|
+ label.font = .systemFont(ofSize: 13, weight: .medium)
|
|
832
|
912
|
label.textColor = Theme.primaryText
|
|
|
913
|
+ label.alignment = .left
|
|
|
914
|
+ label.lineBreakMode = .byWordWrapping
|
|
|
915
|
+ label.maximumNumberOfLines = 0
|
|
|
916
|
+ label.cell?.wraps = true
|
|
|
917
|
+ label.cell?.isScrollable = false
|
|
|
918
|
+ label.setContentCompressionResistancePriority(.required, for: .vertical)
|
|
|
919
|
+ label.setContentHuggingPriority(.required, for: .vertical)
|
|
|
920
|
+ label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
921
|
+ label.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
833
|
922
|
|
|
834
|
923
|
let row = NSStackView(views: [icon, label])
|
|
835
|
924
|
row.orientation = .horizontal
|
|
836
|
|
- row.spacing = FeatureListMetrics.spacing
|
|
837
|
|
- row.alignment = .centerY
|
|
|
925
|
+ row.spacing = FeatureListMetrics.iconLabelSpacing
|
|
|
926
|
+ row.alignment = .top
|
|
838
|
927
|
row.distribution = .fill
|
|
|
928
|
+ row.translatesAutoresizingMaskIntoConstraints = false
|
|
839
|
929
|
return row
|
|
840
|
930
|
}
|
|
841
|
931
|
|