|
@@ -0,0 +1,652 @@
|
|
|
|
|
+import Cocoa
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Plan Model
|
|
|
|
|
+
|
|
|
|
|
+enum PaywallPlan: CaseIterable {
|
|
|
|
|
+ case monthly
|
|
|
|
|
+ case yearly
|
|
|
|
|
+ case lifetime
|
|
|
|
|
+
|
|
|
|
|
+ var title: String {
|
|
|
|
|
+ switch self {
|
|
|
|
|
+ case .monthly: "Monthly"
|
|
|
|
|
+ case .yearly: "Yearly"
|
|
|
|
|
+ case .lifetime: "Lifetime"
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var subtitle: String {
|
|
|
|
|
+ switch self {
|
|
|
|
|
+ case .monthly: "$4.99 / month, cancel anytime"
|
|
|
|
|
+ case .yearly: "Eligible new subscribers get 7 days free, then $29.99 / year"
|
|
|
|
|
+ case .lifetime: "$99.99 once, lifetime access"
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var price: String {
|
|
|
|
|
+ switch self {
|
|
|
|
|
+ case .monthly: "$4.99"
|
|
|
|
|
+ case .yearly: "$29.99"
|
|
|
|
|
+ case .lifetime: "$99.99"
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var ctaTitle: String {
|
|
|
|
|
+ switch self {
|
|
|
|
|
+ case .monthly: "Subscribe for $4.99 / Month"
|
|
|
|
|
+ case .yearly: "Start 7-Day Free Trial"
|
|
|
|
|
+ case .lifetime: "Buy Lifetime Access"
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Left Panel
|
|
|
|
|
+
|
|
|
|
|
+private final class PaywallLeftPanelView: NSView {
|
|
|
|
|
+ private let gradientLayer = CAGradientLayer()
|
|
|
|
|
+
|
|
|
|
|
+ override init(frame frameRect: NSRect) {
|
|
|
|
|
+ super.init(frame: frameRect)
|
|
|
|
|
+ wantsLayer = true
|
|
|
|
|
+ gradientLayer.colors = [
|
|
|
|
|
+ NSColor(red: 0.88, green: 0.94, blue: 1.0, alpha: 1).cgColor,
|
|
|
|
|
+ NSColor(red: 0.95, green: 0.97, blue: 1.0, alpha: 1).cgColor,
|
|
|
|
|
+ ]
|
|
|
|
|
+ gradientLayer.startPoint = CGPoint(x: 0.5, y: 1)
|
|
|
|
|
+ gradientLayer.endPoint = CGPoint(x: 0.5, y: 0)
|
|
|
|
|
+ layer?.insertSublayer(gradientLayer, at: 0)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @available(*, unavailable)
|
|
|
|
|
+ required init?(coder: NSCoder) { nil }
|
|
|
|
|
+
|
|
|
|
|
+ override func layout() {
|
|
|
|
|
+ super.layout()
|
|
|
|
|
+ gradientLayer.frame = bounds
|
|
|
|
|
+ let mask = CAShapeLayer()
|
|
|
|
|
+ mask.path = CGPath(
|
|
|
|
|
+ roundedRect: bounds,
|
|
|
|
|
+ cornerWidth: 20,
|
|
|
|
|
+ cornerHeight: 20,
|
|
|
|
|
+ transform: nil
|
|
|
|
|
+ )
|
|
|
|
|
+ layer?.mask = mask
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Badge
|
|
|
|
|
+
|
|
|
|
|
+private final class PaywallBadgeView: NSView {
|
|
|
|
|
+ init(text: String, iconName: String, background: NSColor, foreground: NSColor) {
|
|
|
|
|
+ super.init(frame: .zero)
|
|
|
|
|
+ translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ wantsLayer = true
|
|
|
|
|
+ layer?.backgroundColor = background.cgColor
|
|
|
|
|
+ layer?.cornerRadius = 10
|
|
|
|
|
+ layer?.masksToBounds = true
|
|
|
|
|
+
|
|
|
|
|
+ let icon = NSImageView()
|
|
|
|
|
+ icon.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) {
|
|
|
|
|
+ let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .semibold)
|
|
|
|
|
+ icon.image = image.withSymbolConfiguration(config)
|
|
|
|
|
+ }
|
|
|
|
|
+ icon.contentTintColor = foreground
|
|
|
|
|
+
|
|
|
|
|
+ let label = NSTextField(labelWithString: text)
|
|
|
|
|
+ label.font = AppTheme.semiboldFont(size: 10)
|
|
|
|
|
+ label.textColor = foreground
|
|
|
|
|
+ label.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ addSubview(icon)
|
|
|
|
|
+ addSubview(label)
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ heightAnchor.constraint(equalToConstant: 20),
|
|
|
|
|
+
|
|
|
|
|
+ icon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
|
|
|
|
|
+ icon.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
|
|
|
+ icon.widthAnchor.constraint(equalToConstant: 12),
|
|
|
|
|
+ icon.heightAnchor.constraint(equalToConstant: 12),
|
|
|
|
|
+
|
|
|
|
|
+ label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 4),
|
|
|
|
|
+ label.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
|
|
|
+ label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
|
|
|
|
|
+ ])
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @available(*, unavailable)
|
|
|
|
|
+ required init?(coder: NSCoder) { nil }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Feature Row
|
|
|
|
|
+
|
|
|
|
|
+private final class PaywallFeatureRow: NSView {
|
|
|
|
|
+ init(text: String) {
|
|
|
|
|
+ super.init(frame: .zero)
|
|
|
|
|
+ translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ let checkContainer = NSView()
|
|
|
|
|
+ checkContainer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ checkContainer.wantsLayer = true
|
|
|
|
|
+ checkContainer.layer?.backgroundColor = AppTheme.green.cgColor
|
|
|
|
|
+ checkContainer.layer?.cornerRadius = 10
|
|
|
|
|
+ checkContainer.layer?.masksToBounds = true
|
|
|
|
|
+
|
|
|
|
|
+ let checkIcon = NSImageView()
|
|
|
|
|
+ checkIcon.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ if let image = NSImage(systemSymbolName: "checkmark", accessibilityDescription: nil) {
|
|
|
|
|
+ let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .bold)
|
|
|
|
|
+ checkIcon.image = image.withSymbolConfiguration(config)
|
|
|
|
|
+ }
|
|
|
|
|
+ checkIcon.contentTintColor = .white
|
|
|
|
|
+
|
|
|
|
|
+ let label = NSTextField(labelWithString: text)
|
|
|
|
|
+ label.font = AppTheme.regularFont(size: 14)
|
|
|
|
|
+ label.textColor = AppTheme.navy
|
|
|
|
|
+ label.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ addSubview(checkContainer)
|
|
|
|
|
+ checkContainer.addSubview(checkIcon)
|
|
|
|
|
+ addSubview(label)
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ heightAnchor.constraint(equalToConstant: 28),
|
|
|
|
|
+
|
|
|
|
|
+ checkContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
|
|
|
+ checkContainer.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
|
|
|
+ checkContainer.widthAnchor.constraint(equalToConstant: 20),
|
|
|
|
|
+ checkContainer.heightAnchor.constraint(equalToConstant: 20),
|
|
|
|
|
+
|
|
|
|
|
+ checkIcon.centerXAnchor.constraint(equalTo: checkContainer.centerXAnchor),
|
|
|
|
|
+ checkIcon.centerYAnchor.constraint(equalTo: checkContainer.centerYAnchor),
|
|
|
|
|
+ checkIcon.widthAnchor.constraint(equalToConstant: 12),
|
|
|
|
|
+ checkIcon.heightAnchor.constraint(equalToConstant: 12),
|
|
|
|
|
+
|
|
|
|
|
+ label.leadingAnchor.constraint(equalTo: checkContainer.trailingAnchor, constant: 12),
|
|
|
|
|
+ label.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
|
|
|
+ label.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
|
|
|
+ ])
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @available(*, unavailable)
|
|
|
|
|
+ required init?(coder: NSCoder) { nil }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Plan Card
|
|
|
|
|
+
|
|
|
|
|
+private final class PaywallPlanCard: NSControl {
|
|
|
|
|
+ var onSelect: (() -> Void)?
|
|
|
|
|
+
|
|
|
|
|
+ private let plan: PaywallPlan
|
|
|
|
|
+ private let titleLabel = NSTextField(labelWithString: "")
|
|
|
|
|
+ private let subtitleLabel = NSTextField(labelWithString: "")
|
|
|
|
|
+ private let priceLabel = NSTextField(labelWithString: "")
|
|
|
|
|
+ private var badgeView: PaywallBadgeView?
|
|
|
|
|
+
|
|
|
|
|
+ var isChosen: Bool = false {
|
|
|
|
|
+ didSet { updateAppearance() }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ init(plan: PaywallPlan) {
|
|
|
|
|
+ self.plan = plan
|
|
|
|
|
+ super.init(frame: .zero)
|
|
|
|
|
+ translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ wantsLayer = true
|
|
|
|
|
+ layer?.cornerRadius = 12
|
|
|
|
|
+ layer?.backgroundColor = NSColor.white.cgColor
|
|
|
|
|
+ layer?.masksToBounds = false
|
|
|
|
|
+
|
|
|
|
|
+ titleLabel.stringValue = plan.title
|
|
|
|
|
+ titleLabel.font = AppTheme.semiboldFont(size: 15)
|
|
|
|
|
+ titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ subtitleLabel.stringValue = plan.subtitle
|
|
|
|
|
+ subtitleLabel.font = AppTheme.regularFont(size: 11)
|
|
|
|
|
+ subtitleLabel.textColor = AppTheme.textSecondary
|
|
|
|
|
+ subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ priceLabel.stringValue = plan.price
|
|
|
|
|
+ priceLabel.font = AppTheme.semiboldFont(size: 15)
|
|
|
|
|
+ priceLabel.alignment = .right
|
|
|
|
|
+ priceLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ addSubview(titleLabel)
|
|
|
|
|
+ addSubview(subtitleLabel)
|
|
|
|
|
+ addSubview(priceLabel)
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ heightAnchor.constraint(equalToConstant: 86),
|
|
|
|
|
+
|
|
|
|
|
+ titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
|
|
|
|
|
+ titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 24),
|
|
|
|
|
+
|
|
|
|
|
+ subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
|
|
|
|
|
+ subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 3),
|
|
|
|
|
+ subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: priceLabel.leadingAnchor, constant: -12),
|
|
|
|
|
+
|
|
|
|
|
+ priceLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
|
|
|
|
|
+ priceLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ if plan == .yearly {
|
|
|
|
|
+ let badge = PaywallBadgeView(
|
|
|
|
|
+ text: "7 Days Free Trial",
|
|
|
|
|
+ iconName: "calendar",
|
|
|
|
|
+ background: AppTheme.paywallPink,
|
|
|
|
|
+ foreground: AppTheme.paywallPinkText
|
|
|
|
|
+ )
|
|
|
|
|
+ badgeView = badge
|
|
|
|
|
+ addSubview(badge)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ badge.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
|
|
|
|
|
+ badge.topAnchor.constraint(equalTo: topAnchor, constant: 8),
|
|
|
|
|
+ ])
|
|
|
|
|
+ } else if plan == .lifetime {
|
|
|
|
|
+ let badge = PaywallBadgeView(
|
|
|
|
|
+ text: "Best Value",
|
|
|
|
|
+ iconName: "star.fill",
|
|
|
|
|
+ background: AppTheme.paywallGold,
|
|
|
|
|
+ foreground: AppTheme.paywallGoldText
|
|
|
|
|
+ )
|
|
|
|
|
+ badgeView = badge
|
|
|
|
|
+ addSubview(badge)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ badge.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
|
|
|
|
|
+ badge.topAnchor.constraint(equalTo: topAnchor, constant: 8),
|
|
|
|
|
+ ])
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ updateAppearance()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @available(*, unavailable)
|
|
|
|
|
+ required init?(coder: NSCoder) { nil }
|
|
|
|
|
+
|
|
|
|
|
+ private func updateAppearance() {
|
|
|
|
|
+ let titleColor = isChosen && plan == .yearly ? AppTheme.green : AppTheme.navy
|
|
|
|
|
+ titleLabel.textColor = titleColor
|
|
|
|
|
+ priceLabel.textColor = titleColor
|
|
|
|
|
+
|
|
|
|
|
+ layer?.borderWidth = isChosen ? 2 : 1
|
|
|
|
|
+ layer?.borderColor = (isChosen ? AppTheme.green : AppTheme.paywallBorder).cgColor
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override func mouseUp(with event: NSEvent) {
|
|
|
|
|
+ guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
|
|
|
|
|
+ onSelect?()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override func resetCursorRects() {
|
|
|
|
|
+ addCursorRect(bounds, cursor: .pointingHand)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Close Button
|
|
|
|
|
+
|
|
|
|
|
+private final class PaywallCloseButton: NSButton {
|
|
|
|
|
+ var onClose: (() -> Void)?
|
|
|
|
|
+
|
|
|
|
|
+ init() {
|
|
|
|
|
+ super.init(frame: .zero)
|
|
|
|
|
+ isBordered = false
|
|
|
|
|
+ translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ wantsLayer = true
|
|
|
|
|
+ layer?.backgroundColor = AppTheme.blueLight.cgColor
|
|
|
|
|
+ layer?.cornerRadius = 14
|
|
|
|
|
+ if let image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close") {
|
|
|
|
|
+ let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
|
|
|
|
|
+ self.image = image.withSymbolConfiguration(config)
|
|
|
|
|
+ }
|
|
|
|
|
+ contentTintColor = AppTheme.blue
|
|
|
|
|
+ target = self
|
|
|
|
|
+ action = #selector(tapped)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @available(*, unavailable)
|
|
|
|
|
+ required init?(coder: NSCoder) { nil }
|
|
|
|
|
+
|
|
|
|
|
+ @objc private func tapped() {
|
|
|
|
|
+ onClose?()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override func resetCursorRects() {
|
|
|
|
|
+ addCursorRect(bounds, cursor: .pointingHand)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Footer Link
|
|
|
|
|
+
|
|
|
|
|
+private final class PaywallFooterLink: NSButton {
|
|
|
|
|
+ init(title: String) {
|
|
|
|
|
+ super.init(frame: .zero)
|
|
|
|
|
+ self.title = title
|
|
|
|
|
+ isBordered = false
|
|
|
|
|
+ font = AppTheme.regularFont(size: 11)
|
|
|
|
|
+ contentTintColor = AppTheme.textSecondary
|
|
|
|
|
+ translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @available(*, unavailable)
|
|
|
|
|
+ required init?(coder: NSCoder) { nil }
|
|
|
|
|
+
|
|
|
|
|
+ override func resetCursorRects() {
|
|
|
|
|
+ addCursorRect(bounds, cursor: .pointingHand)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Main Paywall Card
|
|
|
|
|
+
|
|
|
|
|
+final class PaywallView: NSView {
|
|
|
|
|
+ var onClose: (() -> Void)?
|
|
|
|
|
+ var onPurchase: ((PaywallPlan) -> Void)?
|
|
|
|
|
+ var onRestore: (() -> Void)?
|
|
|
|
|
+
|
|
|
|
|
+ private var selectedPlan: PaywallPlan = .yearly
|
|
|
|
|
+ private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
|
|
|
|
|
+ private let ctaButton = NSButton()
|
|
|
|
|
+
|
|
|
|
|
+ init() {
|
|
|
|
|
+ super.init(frame: .zero)
|
|
|
|
|
+ translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ wantsLayer = true
|
|
|
|
|
+ layer?.backgroundColor = NSColor.white.cgColor
|
|
|
|
|
+ layer?.cornerRadius = 20
|
|
|
|
|
+ applyCardShadow()
|
|
|
|
|
+ setup()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @available(*, unavailable)
|
|
|
|
|
+ required init?(coder: NSCoder) { nil }
|
|
|
|
|
+
|
|
|
|
|
+ private func setup() {
|
|
|
|
|
+ let leftPanel = makeLeftPanel()
|
|
|
|
|
+ let rightPanel = makeRightPanel()
|
|
|
|
|
+
|
|
|
|
|
+ addSubview(leftPanel)
|
|
|
|
|
+ addSubview(rightPanel)
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ widthAnchor.constraint(equalToConstant: 780),
|
|
|
|
|
+ heightAnchor.constraint(equalToConstant: 500),
|
|
|
|
|
+
|
|
|
|
|
+ leftPanel.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
|
|
|
+ leftPanel.topAnchor.constraint(equalTo: topAnchor),
|
|
|
|
|
+ leftPanel.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
|
|
|
+ leftPanel.widthAnchor.constraint(equalToConstant: 320),
|
|
|
|
|
+
|
|
|
|
|
+ rightPanel.leadingAnchor.constraint(equalTo: leftPanel.trailingAnchor),
|
|
|
|
|
+ rightPanel.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
|
|
|
+ rightPanel.topAnchor.constraint(equalTo: topAnchor),
|
|
|
|
|
+ rightPanel.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
|
|
|
+ ])
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func makeLeftPanel() -> NSView {
|
|
|
|
|
+ let panel = PaywallLeftPanelView()
|
|
|
|
|
+ panel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ let title = NSTextField(labelWithString: "Unlock Your Full\nPrinting Potential")
|
|
|
|
|
+ title.font = AppTheme.semiboldFont(size: 22)
|
|
|
|
|
+ title.textColor = AppTheme.navy
|
|
|
|
|
+ title.maximumNumberOfLines = 2
|
|
|
|
|
+ title.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ let featuresStack = NSStackView()
|
|
|
|
|
+ featuresStack.orientation = .vertical
|
|
|
|
|
+ featuresStack.spacing = 6
|
|
|
|
|
+ featuresStack.alignment = .leading
|
|
|
|
|
+ featuresStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ let features = [
|
|
|
|
|
+ "Unlimited high-quality scans",
|
|
|
|
|
+ "Advanced OCR technology",
|
|
|
|
|
+ "Direct cloud printing",
|
|
|
|
|
+ "Ad-free experience",
|
|
|
|
|
+ "Priority support",
|
|
|
|
|
+ "Secure storage",
|
|
|
|
|
+ ]
|
|
|
|
|
+ for feature in features {
|
|
|
|
|
+ featuresStack.addArrangedSubview(PaywallFeatureRow(text: feature))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ panel.addSubview(title)
|
|
|
|
|
+ panel.addSubview(featuresStack)
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ title.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
|
|
|
|
|
+ title.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -20),
|
|
|
|
|
+ title.topAnchor.constraint(equalTo: panel.topAnchor, constant: 48),
|
|
|
|
|
+
|
|
|
|
|
+ featuresStack.leadingAnchor.constraint(equalTo: title.leadingAnchor),
|
|
|
|
|
+ featuresStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -20),
|
|
|
|
|
+ featuresStack.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 28),
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ return panel
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func makeRightPanel() -> NSView {
|
|
|
|
|
+ let panel = NSView()
|
|
|
|
|
+ panel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ let closeButton = PaywallCloseButton()
|
|
|
|
|
+ closeButton.onClose = { [weak self] in self?.onClose?() }
|
|
|
|
|
+
|
|
|
|
|
+ let title = NSTextField(labelWithString: "Go Premium")
|
|
|
|
|
+ title.font = AppTheme.semiboldFont(size: 26)
|
|
|
|
|
+ title.textColor = AppTheme.navy
|
|
|
|
|
+ title.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ let subtitle = NSTextField(labelWithString: "Experience professional quality printing and scanning without limits.")
|
|
|
|
|
+ subtitle.font = AppTheme.regularFont(size: 13)
|
|
|
|
|
+ subtitle.textColor = AppTheme.textSecondary
|
|
|
|
|
+ subtitle.maximumNumberOfLines = 2
|
|
|
|
|
+ subtitle.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ let plansStack = NSStackView()
|
|
|
|
|
+ plansStack.orientation = .vertical
|
|
|
|
|
+ plansStack.spacing = 12
|
|
|
|
|
+ plansStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ for plan in PaywallPlan.allCases {
|
|
|
|
|
+ let card = PaywallPlanCard(plan: plan)
|
|
|
|
|
+ card.isChosen = plan == selectedPlan
|
|
|
|
|
+ card.onSelect = { [weak self] in self?.selectPlan(plan) }
|
|
|
|
|
+ planCards[plan] = card
|
|
|
|
|
+ plansStack.addArrangedSubview(card)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ctaButton.title = selectedPlan.ctaTitle
|
|
|
|
|
+ ctaButton.isBordered = false
|
|
|
|
|
+ ctaButton.wantsLayer = true
|
|
|
|
|
+ ctaButton.layer?.backgroundColor = AppTheme.navy.cgColor
|
|
|
|
|
+ ctaButton.layer?.cornerRadius = 12
|
|
|
|
|
+ ctaButton.font = AppTheme.semiboldFont(size: 15)
|
|
|
|
|
+ ctaButton.contentTintColor = .white
|
|
|
|
|
+ ctaButton.target = self
|
|
|
|
|
+ ctaButton.action = #selector(purchaseTapped)
|
|
|
|
|
+ ctaButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ let footer = makeFooter()
|
|
|
|
|
+
|
|
|
|
|
+ panel.addSubview(closeButton)
|
|
|
|
|
+ panel.addSubview(title)
|
|
|
|
|
+ panel.addSubview(subtitle)
|
|
|
|
|
+ panel.addSubview(plansStack)
|
|
|
|
|
+ panel.addSubview(ctaButton)
|
|
|
|
|
+ panel.addSubview(footer)
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ closeButton.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -20),
|
|
|
|
|
+ closeButton.topAnchor.constraint(equalTo: panel.topAnchor, constant: 20),
|
|
|
|
|
+ closeButton.widthAnchor.constraint(equalToConstant: 28),
|
|
|
|
|
+ closeButton.heightAnchor.constraint(equalToConstant: 28),
|
|
|
|
|
+
|
|
|
|
|
+ title.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
|
|
|
|
|
+ title.topAnchor.constraint(equalTo: panel.topAnchor, constant: 40),
|
|
|
|
|
+ title.trailingAnchor.constraint(equalTo: closeButton.leadingAnchor, constant: -12),
|
|
|
|
|
+
|
|
|
|
|
+ subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
|
|
|
|
|
+ subtitle.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
|
|
|
|
|
+ subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6),
|
|
|
|
|
+
|
|
|
|
|
+ plansStack.leadingAnchor.constraint(equalTo: title.leadingAnchor),
|
|
|
|
|
+ plansStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
|
|
|
|
|
+ plansStack.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 24),
|
|
|
|
|
+
|
|
|
|
|
+ ctaButton.leadingAnchor.constraint(equalTo: title.leadingAnchor),
|
|
|
|
|
+ ctaButton.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
|
|
|
|
|
+ ctaButton.topAnchor.constraint(equalTo: plansStack.bottomAnchor, constant: 20),
|
|
|
|
|
+ ctaButton.heightAnchor.constraint(equalToConstant: 48),
|
|
|
|
|
+
|
|
|
|
|
+ footer.centerXAnchor.constraint(equalTo: panel.centerXAnchor),
|
|
|
|
|
+ footer.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -20),
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ return panel
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func makeFooter() -> NSView {
|
|
|
|
|
+ let container = NSView()
|
|
|
|
|
+ container.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ let restoreLink = PaywallFooterLink(title: "Restore Purchases")
|
|
|
|
|
+ restoreLink.target = self
|
|
|
|
|
+ restoreLink.action = #selector(restoreTapped)
|
|
|
|
|
+
|
|
|
|
|
+ let privacyLink = PaywallFooterLink(title: "Privacy Policy")
|
|
|
|
|
+ let termsLink = PaywallFooterLink(title: "Terms of Service")
|
|
|
|
|
+
|
|
|
|
|
+ let dot1 = makeFooterDot()
|
|
|
|
|
+ let dot2 = makeFooterDot()
|
|
|
|
|
+
|
|
|
|
|
+ let stack = NSStackView(views: [restoreLink, dot1, privacyLink, dot2, termsLink])
|
|
|
|
|
+ stack.orientation = .horizontal
|
|
|
|
|
+ stack.spacing = 6
|
|
|
|
|
+ stack.alignment = .centerY
|
|
|
|
|
+ stack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ container.addSubview(stack)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ stack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
|
|
|
|
+ stack.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
|
|
|
|
+ stack.topAnchor.constraint(equalTo: container.topAnchor),
|
|
|
|
|
+ stack.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ return container
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func makeFooterDot() -> NSView {
|
|
|
|
|
+ let dot = NSView()
|
|
|
|
|
+ dot.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ dot.wantsLayer = true
|
|
|
|
|
+ dot.layer?.backgroundColor = AppTheme.textSecondary.cgColor
|
|
|
|
|
+ dot.layer?.cornerRadius = 1.5
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ dot.widthAnchor.constraint(equalToConstant: 3),
|
|
|
|
|
+ dot.heightAnchor.constraint(equalToConstant: 3),
|
|
|
|
|
+ ])
|
|
|
|
|
+ return dot
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func selectPlan(_ plan: PaywallPlan) {
|
|
|
|
|
+ selectedPlan = plan
|
|
|
|
|
+ for (key, card) in planCards {
|
|
|
|
|
+ card.isChosen = key == plan
|
|
|
|
|
+ }
|
|
|
|
|
+ ctaButton.title = plan.ctaTitle
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @objc private func purchaseTapped() {
|
|
|
|
|
+ onPurchase?(selectedPlan)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @objc private func restoreTapped() {
|
|
|
|
|
+ onRestore?()
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Overlay Presenter
|
|
|
|
|
+
|
|
|
|
|
+final class PaywallOverlayView: NSView {
|
|
|
|
|
+ var onDismiss: (() -> Void)?
|
|
|
|
|
+
|
|
|
|
|
+ private let paywallView: PaywallView
|
|
|
|
|
+ private let backdrop = NSView()
|
|
|
|
|
+
|
|
|
|
|
+ init() {
|
|
|
|
|
+ paywallView = PaywallView()
|
|
|
|
|
+ super.init(frame: .zero)
|
|
|
|
|
+ translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ setup()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @available(*, unavailable)
|
|
|
|
|
+ required init?(coder: NSCoder) { nil }
|
|
|
|
|
+
|
|
|
|
|
+ private func setup() {
|
|
|
|
|
+ backdrop.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ backdrop.wantsLayer = true
|
|
|
|
|
+ backdrop.layer?.backgroundColor = NSColor(calibratedWhite: 0.15, alpha: 0.45).cgColor
|
|
|
|
|
+
|
|
|
|
|
+ let pattern = WavePatternView()
|
|
|
|
|
+ pattern.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ pattern.alphaValue = 0.35
|
|
|
|
|
+
|
|
|
|
|
+ paywallView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ paywallView.onClose = { [weak self] in self?.dismiss() }
|
|
|
|
|
+ paywallView.onPurchase = { plan in
|
|
|
|
|
+ NSLog("Purchase tapped: \(plan.title)")
|
|
|
|
|
+ }
|
|
|
|
|
+ paywallView.onRestore = {
|
|
|
|
|
+ NSLog("Restore purchases tapped")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ addSubview(backdrop)
|
|
|
|
|
+ backdrop.addSubview(pattern)
|
|
|
|
|
+ addSubview(paywallView)
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ backdrop.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
|
|
|
+ backdrop.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
|
|
|
+ backdrop.topAnchor.constraint(equalTo: topAnchor),
|
|
|
|
|
+ backdrop.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
|
|
|
+
|
|
|
|
|
+ pattern.leadingAnchor.constraint(equalTo: backdrop.leadingAnchor),
|
|
|
|
|
+ pattern.trailingAnchor.constraint(equalTo: backdrop.trailingAnchor),
|
|
|
|
|
+ pattern.topAnchor.constraint(equalTo: backdrop.topAnchor),
|
|
|
|
|
+ pattern.bottomAnchor.constraint(equalTo: backdrop.bottomAnchor),
|
|
|
|
|
+
|
|
|
|
|
+ paywallView.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
|
|
|
+ paywallView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
|
|
|
+ ])
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func present(in parent: NSView) {
|
|
|
|
|
+ guard superview == nil else { return }
|
|
|
|
|
+ parent.addSubview(self)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ leadingAnchor.constraint(equalTo: parent.leadingAnchor),
|
|
|
|
|
+ trailingAnchor.constraint(equalTo: parent.trailingAnchor),
|
|
|
|
|
+ topAnchor.constraint(equalTo: parent.topAnchor),
|
|
|
|
|
+ bottomAnchor.constraint(equalTo: parent.bottomAnchor),
|
|
|
|
|
+ ])
|
|
|
|
|
+ alphaValue = 0
|
|
|
|
|
+ NSAnimationContext.runAnimationGroup { context in
|
|
|
|
|
+ context.duration = 0.2
|
|
|
|
|
+ animator().alphaValue = 1
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func dismiss() {
|
|
|
|
|
+ NSAnimationContext.runAnimationGroup({ context in
|
|
|
|
|
+ context.duration = 0.15
|
|
|
|
|
+ animator().alphaValue = 0
|
|
|
|
|
+ }, completionHandler: { [weak self] in
|
|
|
|
|
+ self?.removeFromSuperview()
|
|
|
|
|
+ self?.onDismiss?()
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+}
|