Browse Source

Polish Premium Plans window layout and pricing cards

- Add hover border/shadow on pricing cards via HoverPricingCardView
- Keep cards equal height; use flexible spacer before the action button
- Position trial pill top-right; build card stack conditionally for billing lines
- Add top inset below the divider so feature lists breathe (20pt)
- Minor tweaks: yearly pill copy, button height, card bottom inset

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 3 weeks ago
parent
commit
1814456f74
1 changed files with 104 additions and 26 deletions
  1. 104 26
      App for Indeed/Controllers/PremiumPlansWindowController.swift

+ 104 - 26
App for Indeed/Controllers/PremiumPlansWindowController.swift

@@ -30,6 +30,68 @@ final class PremiumPlansWindowController: NSWindowController {
30 30
 }
31 31
 
32 32
 private final class PremiumPlansViewController: NSViewController {
33
+    private final class HoverPricingCardView: NSView {
34
+        private let baseBorderColor: NSColor
35
+        private let hoverBorderColor: NSColor
36
+        private var trackingAreaRef: NSTrackingArea?
37
+
38
+        init(baseBorderColor: NSColor, hoverBorderColor: NSColor) {
39
+            self.baseBorderColor = baseBorderColor
40
+            self.hoverBorderColor = hoverBorderColor
41
+            super.init(frame: .zero)
42
+            wantsLayer = true
43
+            layer?.cornerRadius = 16
44
+            applyHoverStyle(isHovered: false, animated: false)
45
+        }
46
+
47
+        @available(*, unavailable)
48
+        required init?(coder: NSCoder) {
49
+            nil
50
+        }
51
+
52
+        override func updateTrackingAreas() {
53
+            super.updateTrackingAreas()
54
+            if let trackingAreaRef {
55
+                removeTrackingArea(trackingAreaRef)
56
+            }
57
+            let options: NSTrackingArea.Options = [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect]
58
+            let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
59
+            addTrackingArea(area)
60
+            trackingAreaRef = area
61
+        }
62
+
63
+        override func mouseEntered(with event: NSEvent) {
64
+            super.mouseEntered(with: event)
65
+            applyHoverStyle(isHovered: true, animated: true)
66
+        }
67
+
68
+        override func mouseExited(with event: NSEvent) {
69
+            super.mouseExited(with: event)
70
+            applyHoverStyle(isHovered: false, animated: true)
71
+        }
72
+
73
+        private func applyHoverStyle(isHovered: Bool, animated: Bool) {
74
+            guard let layer else { return }
75
+            let updates = {
76
+                layer.borderWidth = isHovered ? 2 : 1
77
+                layer.borderColor = (isHovered ? self.hoverBorderColor : self.baseBorderColor).cgColor
78
+                layer.shadowColor = self.hoverBorderColor.withAlphaComponent(0.35).cgColor
79
+                layer.shadowOpacity = isHovered ? 0.22 : 0
80
+                layer.shadowRadius = isHovered ? 14 : 0
81
+                layer.shadowOffset = .init(width: 0, height: -2)
82
+            }
83
+
84
+            if animated {
85
+                NSAnimationContext.runAnimationGroup { context in
86
+                    context.duration = 0.16
87
+                    updates()
88
+                }
89
+            } else {
90
+                updates()
91
+            }
92
+        }
93
+    }
94
+
33 95
     private struct Plan {
34 96
         let id: String
35 97
         let title: String
@@ -107,7 +169,7 @@ private final class PremiumPlansViewController: NSViewController {
107 169
             subtitle: "Best value for long-term users",
108 170
             price: "$39.99",
109 171
             period: "/ year",
110
-            billedPill: "3-day free trial",
172
+            billedPill: "3 days free trial",
111 173
             billedLine: "",
112 174
             crossedPrice: nil,
113 175
             savingsText: nil,
@@ -176,6 +238,9 @@ private final class PremiumPlansViewController: NSViewController {
176 238
         cardsRow.alignment = .top
177 239
         cardsRow.distribution = .fillEqually
178 240
         cardsRow.translatesAutoresizingMaskIntoConstraints = false
241
+        for card in cardsRow.arrangedSubviews {
242
+            card.heightAnchor.constraint(equalTo: cardsRow.heightAnchor).isActive = true
243
+        }
179 244
 
180 245
         let trustRow = makeTrustRow()
181 246
         let footerRow = makeFooterRow()
@@ -206,13 +271,9 @@ private final class PremiumPlansViewController: NSViewController {
206 271
     }
207 272
 
208 273
     private func makePricingCard(_ plan: Plan) -> NSView {
209
-        let card = NSView()
274
+        let card = HoverPricingCardView(baseBorderColor: Theme.cardBorder, hoverBorderColor: Theme.accent)
210 275
         card.translatesAutoresizingMaskIntoConstraints = false
211
-        card.wantsLayer = true
212 276
         card.layer?.backgroundColor = Theme.cardBackground.cgColor
213
-        card.layer?.cornerRadius = 16
214
-        card.layer?.borderWidth = plan.highlight ? 2 : 1
215
-        card.layer?.borderColor = (plan.highlight ? Theme.accent : Theme.cardBorder).cgColor
216 277
 
217 278
         let iconWell = NSView()
218 279
         iconWell.translatesAutoresizingMaskIntoConstraints = false
@@ -243,8 +304,10 @@ private final class PremiumPlansViewController: NSViewController {
243 304
         subtitleLabel.textColor = Theme.secondaryText
244 305
         subtitleLabel.alignment = .center
245 306
 
246
-        let billingPill = pillLabel(text: plan.billedPill, tint: Theme.bottomStrip, textColor: Theme.iconTint)
247
-        billingPill.isHidden = plan.billedPill.isEmpty
307
+        let topRightTag = pillLabel(text: plan.billedPill, tint: Theme.bottomStrip, textColor: Theme.iconTint)
308
+        topRightTag.isHidden = plan.billedPill.isEmpty
309
+        topRightTag.font = .systemFont(ofSize: 10, weight: .bold)
310
+        topRightTag.heightAnchor.constraint(equalToConstant: 20).isActive = true
248 311
 
249 312
         let priceLabel = NSTextField(labelWithString: plan.price)
250 313
         priceLabel.font = .systemFont(ofSize: 18, weight: .semibold)
@@ -262,10 +325,8 @@ private final class PremiumPlansViewController: NSViewController {
262 325
         let billingLabel = NSTextField(labelWithString: plan.billedLine)
263 326
         billingLabel.font = .systemFont(ofSize: 13, weight: .medium)
264 327
         billingLabel.textColor = Theme.secondaryText
265
-        billingLabel.isHidden = plan.billedLine.isEmpty
266 328
 
267 329
         let inlinePriceInfo = inlinePriceInfoLabel(oldPrice: plan.crossedPrice, newPrice: plan.savingsText)
268
-        inlinePriceInfo.isHidden = (plan.crossedPrice == nil || plan.savingsText == nil)
269 330
 
270 331
         let divider = NSBox()
271 332
         divider.boxType = .separator
@@ -276,6 +337,7 @@ private final class PremiumPlansViewController: NSViewController {
276 337
         featuresStack.orientation = .vertical
277 338
         featuresStack.spacing = 9
278 339
         featuresStack.alignment = .leading
340
+        featuresStack.edgeInsets = NSEdgeInsets(top: 20, left: 22, bottom: 0, right: 0)
279 341
 
280 342
         let selectButton = NSButton(title: "Get \(plan.title)", target: self, action: #selector(didTapSelectPlan))
281 343
         selectButton.identifier = NSUserInterfaceItemIdentifier(plan.id)
@@ -291,25 +353,41 @@ private final class PremiumPlansViewController: NSViewController {
291 353
             ? NSColor(srgbRed: 189 / 255, green: 52 / 255, blue: 255 / 255, alpha: 1)
292 354
             : Theme.mutedButtonFill).cgColor
293 355
         selectButton.translatesAutoresizingMaskIntoConstraints = false
294
-        selectButton.heightAnchor.constraint(equalToConstant: 58).isActive = true
295
-
296
-        let spacer = NSView()
297
-        spacer.setContentHuggingPriority(.defaultLow, for: .vertical)
356
+        selectButton.heightAnchor.constraint(equalToConstant: 50).isActive = true
298 357
 
299
-        let content = NSStackView(views: [iconWell, titleLabel, subtitleLabel, billingPill, priceRow, billingLabel, inlinePriceInfo, divider, featuresStack, spacer, selectButton])
300
-        content.orientation = .vertical
301
-        content.spacing = 10
302
-        content.alignment = .centerX
303
-        content.translatesAutoresizingMaskIntoConstraints = false
304
-        card.addSubview(content)
358
+        var contentViews: [NSView] = [iconWell, titleLabel, subtitleLabel, priceRow]
359
+        if !plan.billedLine.isEmpty {
360
+            contentViews.append(billingLabel)
361
+        }
362
+        if plan.crossedPrice != nil, plan.savingsText != nil {
363
+            contentViews.append(inlinePriceInfo)
364
+        }
365
+        contentViews.append(contentsOf: [divider, featuresStack])
366
+
367
+        let verticalFlex = NSView()
368
+        verticalFlex.translatesAutoresizingMaskIntoConstraints = false
369
+        verticalFlex.setContentHuggingPriority(.defaultLow, for: .vertical)
370
+        verticalFlex.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
371
+
372
+        let column = NSStackView(views: contentViews + [verticalFlex, selectButton])
373
+        column.orientation = .vertical
374
+        column.spacing = 10
375
+        column.alignment = .centerX
376
+        column.distribution = .fill
377
+        column.translatesAutoresizingMaskIntoConstraints = false
378
+        card.addSubview(column)
379
+        card.addSubview(topRightTag)
305 380
 
306 381
         NSLayoutConstraint.activate([
307
-            divider.widthAnchor.constraint(equalTo: content.widthAnchor),
308
-            content.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 18),
309
-            content.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -18),
310
-            content.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
311
-            content.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -16),
312
-            selectButton.widthAnchor.constraint(equalTo: content.widthAnchor)
382
+            divider.widthAnchor.constraint(equalTo: column.widthAnchor),
383
+            featuresStack.widthAnchor.constraint(equalTo: column.widthAnchor),
384
+            column.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 18),
385
+            column.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -18),
386
+            column.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
387
+            column.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -12),
388
+            selectButton.widthAnchor.constraint(equalTo: column.widthAnchor),
389
+            topRightTag.topAnchor.constraint(equalTo: card.topAnchor, constant: 12),
390
+            topRightTag.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12)
313 391
         ])
314 392
 
315 393
         return card