|
|
@@ -30,6 +30,68 @@ final class PremiumPlansWindowController: NSWindowController {
|
|
|
}
|
|
|
|
|
|
private final class PremiumPlansViewController: NSViewController {
|
|
|
+ private final class HoverPricingCardView: NSView {
|
|
|
+ private let baseBorderColor: NSColor
|
|
|
+ private let hoverBorderColor: NSColor
|
|
|
+ private var trackingAreaRef: NSTrackingArea?
|
|
|
+
|
|
|
+ init(baseBorderColor: NSColor, hoverBorderColor: NSColor) {
|
|
|
+ self.baseBorderColor = baseBorderColor
|
|
|
+ self.hoverBorderColor = hoverBorderColor
|
|
|
+ super.init(frame: .zero)
|
|
|
+ wantsLayer = true
|
|
|
+ layer?.cornerRadius = 16
|
|
|
+ applyHoverStyle(isHovered: false, animated: false)
|
|
|
+ }
|
|
|
+
|
|
|
+ @available(*, unavailable)
|
|
|
+ required init?(coder: NSCoder) {
|
|
|
+ nil
|
|
|
+ }
|
|
|
+
|
|
|
+ override func updateTrackingAreas() {
|
|
|
+ super.updateTrackingAreas()
|
|
|
+ if let trackingAreaRef {
|
|
|
+ removeTrackingArea(trackingAreaRef)
|
|
|
+ }
|
|
|
+ let options: NSTrackingArea.Options = [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect]
|
|
|
+ let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
|
|
|
+ addTrackingArea(area)
|
|
|
+ trackingAreaRef = area
|
|
|
+ }
|
|
|
+
|
|
|
+ override func mouseEntered(with event: NSEvent) {
|
|
|
+ super.mouseEntered(with: event)
|
|
|
+ applyHoverStyle(isHovered: true, animated: true)
|
|
|
+ }
|
|
|
+
|
|
|
+ override func mouseExited(with event: NSEvent) {
|
|
|
+ super.mouseExited(with: event)
|
|
|
+ applyHoverStyle(isHovered: false, animated: true)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func applyHoverStyle(isHovered: Bool, animated: Bool) {
|
|
|
+ guard let layer else { return }
|
|
|
+ let updates = {
|
|
|
+ layer.borderWidth = isHovered ? 2 : 1
|
|
|
+ layer.borderColor = (isHovered ? self.hoverBorderColor : self.baseBorderColor).cgColor
|
|
|
+ layer.shadowColor = self.hoverBorderColor.withAlphaComponent(0.35).cgColor
|
|
|
+ layer.shadowOpacity = isHovered ? 0.22 : 0
|
|
|
+ layer.shadowRadius = isHovered ? 14 : 0
|
|
|
+ layer.shadowOffset = .init(width: 0, height: -2)
|
|
|
+ }
|
|
|
+
|
|
|
+ if animated {
|
|
|
+ NSAnimationContext.runAnimationGroup { context in
|
|
|
+ context.duration = 0.16
|
|
|
+ updates()
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ updates()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
private struct Plan {
|
|
|
let id: String
|
|
|
let title: String
|
|
|
@@ -107,7 +169,7 @@ private final class PremiumPlansViewController: NSViewController {
|
|
|
subtitle: "Best value for long-term users",
|
|
|
price: "$39.99",
|
|
|
period: "/ year",
|
|
|
- billedPill: "3-day free trial",
|
|
|
+ billedPill: "3 days free trial",
|
|
|
billedLine: "",
|
|
|
crossedPrice: nil,
|
|
|
savingsText: nil,
|
|
|
@@ -176,6 +238,9 @@ 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()
|
|
|
@@ -206,13 +271,9 @@ private final class PremiumPlansViewController: NSViewController {
|
|
|
}
|
|
|
|
|
|
private func makePricingCard(_ plan: Plan) -> NSView {
|
|
|
- let card = NSView()
|
|
|
+ let card = HoverPricingCardView(baseBorderColor: Theme.cardBorder, hoverBorderColor: Theme.accent)
|
|
|
card.translatesAutoresizingMaskIntoConstraints = false
|
|
|
- card.wantsLayer = true
|
|
|
card.layer?.backgroundColor = Theme.cardBackground.cgColor
|
|
|
- card.layer?.cornerRadius = 16
|
|
|
- card.layer?.borderWidth = plan.highlight ? 2 : 1
|
|
|
- card.layer?.borderColor = (plan.highlight ? Theme.accent : Theme.cardBorder).cgColor
|
|
|
|
|
|
let iconWell = NSView()
|
|
|
iconWell.translatesAutoresizingMaskIntoConstraints = false
|
|
|
@@ -243,8 +304,10 @@ private final class PremiumPlansViewController: NSViewController {
|
|
|
subtitleLabel.textColor = Theme.secondaryText
|
|
|
subtitleLabel.alignment = .center
|
|
|
|
|
|
- let billingPill = pillLabel(text: plan.billedPill, tint: Theme.bottomStrip, textColor: Theme.iconTint)
|
|
|
- billingPill.isHidden = plan.billedPill.isEmpty
|
|
|
+ let topRightTag = pillLabel(text: plan.billedPill, tint: Theme.bottomStrip, textColor: Theme.iconTint)
|
|
|
+ topRightTag.isHidden = plan.billedPill.isEmpty
|
|
|
+ topRightTag.font = .systemFont(ofSize: 10, weight: .bold)
|
|
|
+ topRightTag.heightAnchor.constraint(equalToConstant: 20).isActive = true
|
|
|
|
|
|
let priceLabel = NSTextField(labelWithString: plan.price)
|
|
|
priceLabel.font = .systemFont(ofSize: 18, weight: .semibold)
|
|
|
@@ -262,10 +325,8 @@ private final class PremiumPlansViewController: NSViewController {
|
|
|
let billingLabel = NSTextField(labelWithString: plan.billedLine)
|
|
|
billingLabel.font = .systemFont(ofSize: 13, weight: .medium)
|
|
|
billingLabel.textColor = Theme.secondaryText
|
|
|
- billingLabel.isHidden = plan.billedLine.isEmpty
|
|
|
|
|
|
let inlinePriceInfo = inlinePriceInfoLabel(oldPrice: plan.crossedPrice, newPrice: plan.savingsText)
|
|
|
- inlinePriceInfo.isHidden = (plan.crossedPrice == nil || plan.savingsText == nil)
|
|
|
|
|
|
let divider = NSBox()
|
|
|
divider.boxType = .separator
|
|
|
@@ -276,6 +337,7 @@ private final class PremiumPlansViewController: NSViewController {
|
|
|
featuresStack.orientation = .vertical
|
|
|
featuresStack.spacing = 9
|
|
|
featuresStack.alignment = .leading
|
|
|
+ featuresStack.edgeInsets = NSEdgeInsets(top: 20, left: 22, bottom: 0, right: 0)
|
|
|
|
|
|
let selectButton = NSButton(title: "Get \(plan.title)", target: self, action: #selector(didTapSelectPlan))
|
|
|
selectButton.identifier = NSUserInterfaceItemIdentifier(plan.id)
|
|
|
@@ -291,25 +353,41 @@ private final class PremiumPlansViewController: NSViewController {
|
|
|
? NSColor(srgbRed: 189 / 255, green: 52 / 255, blue: 255 / 255, alpha: 1)
|
|
|
: Theme.mutedButtonFill).cgColor
|
|
|
selectButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
- selectButton.heightAnchor.constraint(equalToConstant: 58).isActive = true
|
|
|
-
|
|
|
- let spacer = NSView()
|
|
|
- spacer.setContentHuggingPriority(.defaultLow, for: .vertical)
|
|
|
+ selectButton.heightAnchor.constraint(equalToConstant: 50).isActive = true
|
|
|
|
|
|
- let content = NSStackView(views: [iconWell, titleLabel, subtitleLabel, billingPill, priceRow, billingLabel, inlinePriceInfo, divider, featuresStack, spacer, selectButton])
|
|
|
- content.orientation = .vertical
|
|
|
- content.spacing = 10
|
|
|
- content.alignment = .centerX
|
|
|
- content.translatesAutoresizingMaskIntoConstraints = false
|
|
|
- card.addSubview(content)
|
|
|
+ var contentViews: [NSView] = [iconWell, titleLabel, subtitleLabel, priceRow]
|
|
|
+ if !plan.billedLine.isEmpty {
|
|
|
+ contentViews.append(billingLabel)
|
|
|
+ }
|
|
|
+ if plan.crossedPrice != nil, plan.savingsText != nil {
|
|
|
+ contentViews.append(inlinePriceInfo)
|
|
|
+ }
|
|
|
+ contentViews.append(contentsOf: [divider, featuresStack])
|
|
|
+
|
|
|
+ let verticalFlex = NSView()
|
|
|
+ verticalFlex.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ verticalFlex.setContentHuggingPriority(.defaultLow, for: .vertical)
|
|
|
+ verticalFlex.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
|
|
+
|
|
|
+ let column = NSStackView(views: contentViews + [verticalFlex, selectButton])
|
|
|
+ column.orientation = .vertical
|
|
|
+ column.spacing = 10
|
|
|
+ column.alignment = .centerX
|
|
|
+ column.distribution = .fill
|
|
|
+ column.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ card.addSubview(column)
|
|
|
+ card.addSubview(topRightTag)
|
|
|
|
|
|
NSLayoutConstraint.activate([
|
|
|
- divider.widthAnchor.constraint(equalTo: content.widthAnchor),
|
|
|
- content.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 18),
|
|
|
- content.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -18),
|
|
|
- content.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
|
|
|
- content.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -16),
|
|
|
- selectButton.widthAnchor.constraint(equalTo: content.widthAnchor)
|
|
|
+ divider.widthAnchor.constraint(equalTo: column.widthAnchor),
|
|
|
+ featuresStack.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),
|
|
|
+ column.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -12),
|
|
|
+ selectButton.widthAnchor.constraint(equalTo: column.widthAnchor),
|
|
|
+ topRightTag.topAnchor.constraint(equalTo: card.topAnchor, constant: 12),
|
|
|
+ topRightTag.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12)
|
|
|
])
|
|
|
|
|
|
return card
|