소스 검색

Improve free-tier Home UX and fix paywall pricing card layout.

Show free users how many AI job search replies remain under the Home chat
input (hidden for Pro). Replace generic “All premium features” bullets with
concrete Pro benefits on every plan card. Fix paywall layout so long feature
lists no longer stretch cards off-screen: pin the footer (Continue with free
plan, Restore, legal links), scroll the card area, and cap each card’s feature
list with an internal scroll view and wrapping labels.

Co-authored-by: Cursor <cursoragent@cursor.com>
Uzair Tahir 2 주 전
부모
커밋
15f6e0a333
2개의 변경된 파일177개의 추가작업 그리고 43개의 파일을 삭제
  1. 131 41
      App for Indeed/Controllers/PremiumPlansWindowController.swift
  2. 46 2
      App for Indeed/Views/DashboardView.swift

+ 131 - 41
App for Indeed/Controllers/PremiumPlansWindowController.swift

@@ -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
 

+ 46 - 2
App for Indeed/Views/DashboardView.swift

@@ -37,6 +37,11 @@ private enum FreeTierJobSearchQuota {
37 37
         guard !isProActive else { return }
38 38
         userMessageCount += 1
39 39
     }
40
+
41
+    static func remainingUserMessages(isProActive: Bool) -> Int {
42
+        guard !isProActive else { return maxUserMessages }
43
+        return max(0, maxUserMessages - userMessageCount)
44
+    }
40 45
 }
41 46
 
42 47
 private enum SettingsAppearanceID {
@@ -106,7 +111,9 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
106 111
     private let mainOverlay = NSStackView()
107 112
     private let greetingLabel = NSTextField(labelWithString: "")
108 113
     private let subtitleLabel = NSTextField(labelWithString: "")
114
+    private let searchBarColumn = NSStackView()
109 115
     private let searchBarShadowHost = NSView()
116
+    private let freeJobSearchQuotaLabel = NSTextField(labelWithString: "")
110 117
     private let searchCard = HoverableView()
111 118
     private let jobSearchIcon = NSImageView()
112 119
     private let jobKeywordsField = NSTextField()
@@ -306,6 +313,7 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
306 313
         findJobsCTAPill.layer?.backgroundColor = (ctaHovering ? Theme.brandBlueHover : Theme.brandBlue).cgColor
307 314
         sendIconView.contentTintColor = Theme.proCTAText
308 315
         sendLabel.textColor = Theme.proCTAText
316
+        freeJobSearchQuotaLabel.textColor = Theme.secondaryText
309 317
 
310 318
         appearanceModeSegment?.selectedSegment = AppAppearanceManager.shared.mode.segmentIndex
311 319
         cvMakerPageView.applyCurrentAppearance()
@@ -318,6 +326,7 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
318 326
         reloadSavedJobsListings()
319 327
         rebuildChatUI()
320 328
         applyProSubscriptionToSidebar()
329
+        updateFreeJobSearchQuotaLabel()
321 330
         needsLayout = true
322 331
     }
323 332
 
@@ -514,7 +523,7 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
514 523
         mainOverlay.addArrangedSubview(chatHeaderRow)
515 524
         mainOverlay.addArrangedSubview(chatScrollView)
516 525
         mainOverlay.addArrangedSubview(chatBottomSpacer)
517
-        mainOverlay.addArrangedSubview(searchBarShadowHost)
526
+        mainOverlay.addArrangedSubview(searchBarColumn)
518 527
 
519 528
         panelsRow.addSubview(sidebar)
520 529
         panelsRow.addSubview(mainHost)
@@ -556,7 +565,7 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
556 565
             indeedJobBrowserHost.topAnchor.constraint(equalTo: mainHost.topAnchor),
557 566
             indeedJobBrowserHost.bottomAnchor.constraint(equalTo: mainHost.bottomAnchor, constant: -24),
558 567
 
559
-            searchBarShadowHost.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
568
+            searchBarColumn.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
560 569
             featureCardsRow.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
561 570
             chatHeaderRow.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
562 571
             chatScrollView.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
@@ -605,6 +614,21 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
605 614
             upgradeDescription.preferredMaxLayoutWidth = descriptionWidth
606 615
             upgradeButton.title = "Try Pro"
607 616
         }
617
+        updateFreeJobSearchQuotaLabel()
618
+    }
619
+
620
+    private func updateFreeJobSearchQuotaLabel() {
621
+        let isPro = SubscriptionStore.shared.isProActive
622
+        if isPro {
623
+            freeJobSearchQuotaLabel.isHidden = true
624
+            freeJobSearchQuotaLabel.stringValue = ""
625
+            return
626
+        }
627
+        let remaining = FreeTierJobSearchQuota.remainingUserMessages(isProActive: false)
628
+        freeJobSearchQuotaLabel.isHidden = false
629
+        freeJobSearchQuotaLabel.stringValue = remaining == 1
630
+            ? "1 reply left"
631
+            : "\(remaining) replies left"
608 632
     }
609 633
 
610 634
     /// Returns `false` and presents the paywall when the user does not have an active Pro subscription.
@@ -1209,6 +1233,24 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
1209 1233
         let pillCorner: CGFloat = 27
1210 1234
         let barHeight: CGFloat = 54
1211 1235
 
1236
+        searchBarColumn.orientation = .vertical
1237
+        searchBarColumn.spacing = 6
1238
+        searchBarColumn.alignment = .width
1239
+        searchBarColumn.distribution = .fill
1240
+        searchBarColumn.translatesAutoresizingMaskIntoConstraints = false
1241
+        searchBarColumn.setContentHuggingPriority(.defaultHigh, for: .vertical)
1242
+
1243
+        freeJobSearchQuotaLabel.font = .systemFont(ofSize: 11, weight: .medium)
1244
+        freeJobSearchQuotaLabel.textColor = Theme.secondaryText
1245
+        freeJobSearchQuotaLabel.alignment = .center
1246
+        freeJobSearchQuotaLabel.lineBreakMode = .byTruncatingTail
1247
+        freeJobSearchQuotaLabel.maximumNumberOfLines = 1
1248
+        freeJobSearchQuotaLabel.setContentHuggingPriority(.required, for: .vertical)
1249
+        freeJobSearchQuotaLabel.setContentCompressionResistancePriority(.required, for: .vertical)
1250
+
1251
+        searchBarColumn.addArrangedSubview(searchBarShadowHost)
1252
+        searchBarColumn.addArrangedSubview(freeJobSearchQuotaLabel)
1253
+
1212 1254
         searchBarShadowHost.translatesAutoresizingMaskIntoConstraints = false
1213 1255
         searchBarShadowHost.wantsLayer = true
1214 1256
         searchBarShadowHost.layer?.masksToBounds = false
@@ -1384,6 +1426,7 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
1384 1426
             findJobsCTAHost.widthAnchor.constraint(greaterThanOrEqualTo: sendContentStack.widthAnchor, constant: sendContentPadding * 2)
1385 1427
         ])
1386 1428
         searchCard.hoverHandler = nil
1429
+        updateFreeJobSearchQuotaLabel()
1387 1430
     }
1388 1431
 
1389 1432
     private func updateFindJobsCTAShadowPath() {
@@ -2168,6 +2211,7 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
2168 2211
 
2169 2212
     private func startJobSearchRequest(effectiveQuery: String, isContinuation: Bool) {
2170 2213
         FreeTierJobSearchQuota.recordUserMessageSent(isProActive: SubscriptionStore.shared.isProActive)
2214
+        updateFreeJobSearchQuotaLabel()
2171 2215
         isAwaitingResponse = true
2172 2216
         addInlineChatThinkingRow()
2173 2217
         setInputEnabled(false)