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