Bladeren bron

Add a premium paywall modal with selectable subscription plans.

This introduces a mock paywall overlay from the Premium tab and adds themed styling for plan badges, pricing cards, and purchase/restore actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 maand geleden
bovenliggende
commit
d0d95fc298
3 gewijzigde bestanden met toevoegingen van 682 en 0 verwijderingen
  1. 6 0
      smart_printer/AppTheme.swift
  2. 652 0
      smart_printer/PaywallView.swift
  3. 24 0
      smart_printer/ViewController.swift

+ 6 - 0
smart_printer/AppTheme.swift

@@ -47,6 +47,12 @@ enum AppTheme {
     static let orangeLight = NSColor(red: 1.0, green: 0.94, blue: 0.88, alpha: 1)
     static let purple = NSColor(red: 0.55, green: 0.36, blue: 0.96, alpha: 1)
     static let teal = NSColor(red: 0.18, green: 0.72, blue: 0.82, alpha: 1)
+    static let navy = NSColor(red: 0.12, green: 0.18, blue: 0.32, alpha: 1)
+    static let paywallPink = NSColor(red: 0.98, green: 0.85, blue: 0.90, alpha: 1)
+    static let paywallPinkText = NSColor(red: 0.75, green: 0.30, blue: 0.45, alpha: 1)
+    static let paywallGold = NSColor(red: 0.96, green: 0.90, blue: 0.78, alpha: 1)
+    static let paywallGoldText = NSColor(red: 0.65, green: 0.48, blue: 0.22, alpha: 1)
+    static let paywallBorder = NSColor(calibratedWhite: 0.88, alpha: 1)
 
     static func semiboldFont(size: CGFloat) -> NSFont {
         .systemFont(ofSize: size, weight: .semibold)

+ 652 - 0
smart_printer/PaywallView.swift

@@ -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?()
+        })
+    }
+}

+ 24 - 0
smart_printer/ViewController.swift

@@ -12,6 +12,7 @@ class ViewController: NSViewController {
     private var scanContentView: NSView!
     private var scanAndHomeContentView: NSView!
     private var contentContainer: NSView!
+    private var paywallOverlay: PaywallOverlayView?
 
     override func loadView() {
         let container = NSView(frame: NSRect(x: 0, y: 0, width: AppTheme.windowWidth, height: AppTheme.windowHeight))
@@ -105,6 +106,29 @@ class ViewController: NSViewController {
         homeContentView.isHidden = destination != .home
         scanContentView.isHidden = destination != .scan
         scanAndHomeContentView.isHidden = destination != .scanAndHome
+
+        if destination == .scanAndHome {
+            presentPaywall()
+        } else {
+            dismissPaywall()
+        }
+    }
+
+    private func presentPaywall() {
+        guard paywallOverlay == nil else { return }
+        let overlay = PaywallOverlayView()
+        overlay.onDismiss = { [weak self] in
+            self?.paywallOverlay = nil
+            self?.sidebar.select(.home)
+            self?.showDestination(.home)
+        }
+        overlay.present(in: view)
+        paywallOverlay = overlay
+    }
+
+    private func dismissPaywall() {
+        paywallOverlay?.removeFromSuperview()
+        paywallOverlay = nil
     }
 
     private func makeHomeContentView() -> NSView {