Sfoglia il codice sorgente

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 mesi fa
parent
commit
15f6e0a333

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

@@ -483,8 +483,11 @@ private final class PremiumPlansViewController: NSViewController {
     }
 
     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
@@ -495,6 +498,14 @@ private final class PremiumPlansViewController: NSViewController {
     private var subscriptionStatusObservation: 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] = [
         Plan(
             id: "weekly",
@@ -505,9 +516,8 @@ private final class PremiumPlansViewController: NSViewController {
             billedLine: "",
             crossedPrice: nil,
             savingsText: nil,
-            features: [
-                "All premium features",
-                "Perfect for short-term goals",
+            features: proCapabilityFeatures + [
+                "Perfect for short-term job hunts",
                 "Cancel anytime"
             ],
             iconName: "paperplane.fill",
@@ -523,9 +533,8 @@ private final class PremiumPlansViewController: NSViewController {
             billedLine: "",
             crossedPrice: nil,
             savingsText: nil,
-            features: [
-                "All premium features",
-                "Best value for regular users",
+            features: proCapabilityFeatures + [
+                "Best for regular job seekers",
                 "Priority support"
             ],
             iconName: "bolt.fill",
@@ -541,8 +550,7 @@ private final class PremiumPlansViewController: NSViewController {
             billedLine: "",
             crossedPrice: nil,
             savingsText: nil,
-            features: [
-                "All premium features",
+            features: proCapabilityFeatures + [
                 "Lowest effective monthly cost",
                 "Ideal for long-term use"
             ],
@@ -638,34 +646,73 @@ private final class PremiumPlansViewController: NSViewController {
         cardsRow.alignment = .top
         cardsRow.distribution = .fillEqually
         cardsRow.translatesAutoresizingMaskIntoConstraints = false
-        for card in cardsRow.arrangedSubviews {
-            card.heightAnchor.constraint(equalTo: cardsRow.heightAnchor).isActive = true
-        }
 
         let trustRow = makeTrustRow()
         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)
+
+        let scrollClip = scrollView.contentView
         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.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -14),
             closeButton.widthAnchor.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)
         ])
         premiumCloseButton = closeButton
@@ -741,9 +788,15 @@ private final class PremiumPlansViewController: NSViewController {
         let featureRows = plan.features.map(makeFeatureRow(_:))
         let featuresStack = NSStackView(views: featureRows)
         featuresStack.orientation = .vertical
-        featuresStack.spacing = FeatureListMetrics.spacing
+        featuresStack.spacing = FeatureListMetrics.rowSpacing
         featuresStack.alignment = .leading
         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(
             planId: plan.id,
@@ -788,14 +841,9 @@ private final class PremiumPlansViewController: NSViewController {
         if plan.crossedPrice != nil, plan.savingsText != nil {
             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.spacing = 10
         column.alignment = .centerX
@@ -806,7 +854,7 @@ private final class PremiumPlansViewController: NSViewController {
 
         NSLayoutConstraint.activate([
             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.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -18),
             column.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
@@ -819,6 +867,35 @@ private final class PremiumPlansViewController: NSViewController {
         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 {
         let icon = NSImageView()
         icon.translatesAutoresizingMaskIntoConstraints = false
@@ -826,16 +903,29 @@ private final class PremiumPlansViewController: NSViewController {
         icon.image = NSImage(systemSymbolName: "checkmark.circle.fill", accessibilityDescription: nil)
         icon.contentTintColor = Theme.iconTint
         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.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])
         row.orientation = .horizontal
-        row.spacing = FeatureListMetrics.spacing
-        row.alignment = .centerY
+        row.spacing = FeatureListMetrics.iconLabelSpacing
+        row.alignment = .top
         row.distribution = .fill
+        row.translatesAutoresizingMaskIntoConstraints = false
         return row
     }
 

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

@@ -37,6 +37,11 @@ private enum FreeTierJobSearchQuota {
         guard !isProActive else { return }
         userMessageCount += 1
     }
+
+    static func remainingUserMessages(isProActive: Bool) -> Int {
+        guard !isProActive else { return maxUserMessages }
+        return max(0, maxUserMessages - userMessageCount)
+    }
 }
 
 private enum SettingsAppearanceID {
@@ -106,7 +111,9 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
     private let mainOverlay = NSStackView()
     private let greetingLabel = NSTextField(labelWithString: "")
     private let subtitleLabel = NSTextField(labelWithString: "")
+    private let searchBarColumn = NSStackView()
     private let searchBarShadowHost = NSView()
+    private let freeJobSearchQuotaLabel = NSTextField(labelWithString: "")
     private let searchCard = HoverableView()
     private let jobSearchIcon = NSImageView()
     private let jobKeywordsField = NSTextField()
@@ -306,6 +313,7 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
         findJobsCTAPill.layer?.backgroundColor = (ctaHovering ? Theme.brandBlueHover : Theme.brandBlue).cgColor
         sendIconView.contentTintColor = Theme.proCTAText
         sendLabel.textColor = Theme.proCTAText
+        freeJobSearchQuotaLabel.textColor = Theme.secondaryText
 
         appearanceModeSegment?.selectedSegment = AppAppearanceManager.shared.mode.segmentIndex
         cvMakerPageView.applyCurrentAppearance()
@@ -318,6 +326,7 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
         reloadSavedJobsListings()
         rebuildChatUI()
         applyProSubscriptionToSidebar()
+        updateFreeJobSearchQuotaLabel()
         needsLayout = true
     }
 
@@ -514,7 +523,7 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
         mainOverlay.addArrangedSubview(chatHeaderRow)
         mainOverlay.addArrangedSubview(chatScrollView)
         mainOverlay.addArrangedSubview(chatBottomSpacer)
-        mainOverlay.addArrangedSubview(searchBarShadowHost)
+        mainOverlay.addArrangedSubview(searchBarColumn)
 
         panelsRow.addSubview(sidebar)
         panelsRow.addSubview(mainHost)
@@ -556,7 +565,7 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
             indeedJobBrowserHost.topAnchor.constraint(equalTo: mainHost.topAnchor),
             indeedJobBrowserHost.bottomAnchor.constraint(equalTo: mainHost.bottomAnchor, constant: -24),
 
-            searchBarShadowHost.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
+            searchBarColumn.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
             featureCardsRow.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
             chatHeaderRow.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
             chatScrollView.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
@@ -605,6 +614,21 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
             upgradeDescription.preferredMaxLayoutWidth = descriptionWidth
             upgradeButton.title = "Try Pro"
         }
+        updateFreeJobSearchQuotaLabel()
+    }
+
+    private func updateFreeJobSearchQuotaLabel() {
+        let isPro = SubscriptionStore.shared.isProActive
+        if isPro {
+            freeJobSearchQuotaLabel.isHidden = true
+            freeJobSearchQuotaLabel.stringValue = ""
+            return
+        }
+        let remaining = FreeTierJobSearchQuota.remainingUserMessages(isProActive: false)
+        freeJobSearchQuotaLabel.isHidden = false
+        freeJobSearchQuotaLabel.stringValue = remaining == 1
+            ? "1 reply left"
+            : "\(remaining) replies left"
     }
 
     /// 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
         let pillCorner: CGFloat = 27
         let barHeight: CGFloat = 54
 
+        searchBarColumn.orientation = .vertical
+        searchBarColumn.spacing = 6
+        searchBarColumn.alignment = .width
+        searchBarColumn.distribution = .fill
+        searchBarColumn.translatesAutoresizingMaskIntoConstraints = false
+        searchBarColumn.setContentHuggingPriority(.defaultHigh, for: .vertical)
+
+        freeJobSearchQuotaLabel.font = .systemFont(ofSize: 11, weight: .medium)
+        freeJobSearchQuotaLabel.textColor = Theme.secondaryText
+        freeJobSearchQuotaLabel.alignment = .center
+        freeJobSearchQuotaLabel.lineBreakMode = .byTruncatingTail
+        freeJobSearchQuotaLabel.maximumNumberOfLines = 1
+        freeJobSearchQuotaLabel.setContentHuggingPriority(.required, for: .vertical)
+        freeJobSearchQuotaLabel.setContentCompressionResistancePriority(.required, for: .vertical)
+
+        searchBarColumn.addArrangedSubview(searchBarShadowHost)
+        searchBarColumn.addArrangedSubview(freeJobSearchQuotaLabel)
+
         searchBarShadowHost.translatesAutoresizingMaskIntoConstraints = false
         searchBarShadowHost.wantsLayer = true
         searchBarShadowHost.layer?.masksToBounds = false
@@ -1384,6 +1426,7 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
             findJobsCTAHost.widthAnchor.constraint(greaterThanOrEqualTo: sendContentStack.widthAnchor, constant: sendContentPadding * 2)
         ])
         searchCard.hoverHandler = nil
+        updateFreeJobSearchQuotaLabel()
     }
 
     private func updateFindJobsCTAShadowPath() {
@@ -2168,6 +2211,7 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
 
     private func startJobSearchRequest(effectiveQuery: String, isContinuation: Bool) {
         FreeTierJobSearchQuota.recordUserMessageSent(isProActive: SubscriptionStore.shared.isProActive)
+        updateFreeJobSearchQuotaLabel()
         isAwaitingResponse = true
         addInlineChatThinkingRow()
         setInputEnabled(false)