Просмотр исходного кода

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 8 часов назад
Родитель
Сommit
d0d95fc298
3 измененных файлов с 682 добавлено и 0 удалено
  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 {
47 47
     static let orangeLight = NSColor(red: 1.0, green: 0.94, blue: 0.88, alpha: 1)
48 48
     static let purple = NSColor(red: 0.55, green: 0.36, blue: 0.96, alpha: 1)
49 49
     static let teal = NSColor(red: 0.18, green: 0.72, blue: 0.82, alpha: 1)
50
+    static let navy = NSColor(red: 0.12, green: 0.18, blue: 0.32, alpha: 1)
51
+    static let paywallPink = NSColor(red: 0.98, green: 0.85, blue: 0.90, alpha: 1)
52
+    static let paywallPinkText = NSColor(red: 0.75, green: 0.30, blue: 0.45, alpha: 1)
53
+    static let paywallGold = NSColor(red: 0.96, green: 0.90, blue: 0.78, alpha: 1)
54
+    static let paywallGoldText = NSColor(red: 0.65, green: 0.48, blue: 0.22, alpha: 1)
55
+    static let paywallBorder = NSColor(calibratedWhite: 0.88, alpha: 1)
50 56
 
51 57
     static func semiboldFont(size: CGFloat) -> NSFont {
52 58
         .systemFont(ofSize: size, weight: .semibold)

+ 652 - 0
smart_printer/PaywallView.swift

@@ -0,0 +1,652 @@
1
+import Cocoa
2
+
3
+// MARK: - Plan Model
4
+
5
+enum PaywallPlan: CaseIterable {
6
+    case monthly
7
+    case yearly
8
+    case lifetime
9
+
10
+    var title: String {
11
+        switch self {
12
+        case .monthly: "Monthly"
13
+        case .yearly: "Yearly"
14
+        case .lifetime: "Lifetime"
15
+        }
16
+    }
17
+
18
+    var subtitle: String {
19
+        switch self {
20
+        case .monthly: "$4.99 / month, cancel anytime"
21
+        case .yearly: "Eligible new subscribers get 7 days free, then $29.99 / year"
22
+        case .lifetime: "$99.99 once, lifetime access"
23
+        }
24
+    }
25
+
26
+    var price: String {
27
+        switch self {
28
+        case .monthly: "$4.99"
29
+        case .yearly: "$29.99"
30
+        case .lifetime: "$99.99"
31
+        }
32
+    }
33
+
34
+    var ctaTitle: String {
35
+        switch self {
36
+        case .monthly: "Subscribe for $4.99 / Month"
37
+        case .yearly: "Start 7-Day Free Trial"
38
+        case .lifetime: "Buy Lifetime Access"
39
+        }
40
+    }
41
+}
42
+
43
+// MARK: - Left Panel
44
+
45
+private final class PaywallLeftPanelView: NSView {
46
+    private let gradientLayer = CAGradientLayer()
47
+
48
+    override init(frame frameRect: NSRect) {
49
+        super.init(frame: frameRect)
50
+        wantsLayer = true
51
+        gradientLayer.colors = [
52
+            NSColor(red: 0.88, green: 0.94, blue: 1.0, alpha: 1).cgColor,
53
+            NSColor(red: 0.95, green: 0.97, blue: 1.0, alpha: 1).cgColor,
54
+        ]
55
+        gradientLayer.startPoint = CGPoint(x: 0.5, y: 1)
56
+        gradientLayer.endPoint = CGPoint(x: 0.5, y: 0)
57
+        layer?.insertSublayer(gradientLayer, at: 0)
58
+    }
59
+
60
+    @available(*, unavailable)
61
+    required init?(coder: NSCoder) { nil }
62
+
63
+    override func layout() {
64
+        super.layout()
65
+        gradientLayer.frame = bounds
66
+        let mask = CAShapeLayer()
67
+        mask.path = CGPath(
68
+            roundedRect: bounds,
69
+            cornerWidth: 20,
70
+            cornerHeight: 20,
71
+            transform: nil
72
+        )
73
+        layer?.mask = mask
74
+    }
75
+}
76
+
77
+// MARK: - Badge
78
+
79
+private final class PaywallBadgeView: NSView {
80
+    init(text: String, iconName: String, background: NSColor, foreground: NSColor) {
81
+        super.init(frame: .zero)
82
+        translatesAutoresizingMaskIntoConstraints = false
83
+        wantsLayer = true
84
+        layer?.backgroundColor = background.cgColor
85
+        layer?.cornerRadius = 10
86
+        layer?.masksToBounds = true
87
+
88
+        let icon = NSImageView()
89
+        icon.translatesAutoresizingMaskIntoConstraints = false
90
+        if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) {
91
+            let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .semibold)
92
+            icon.image = image.withSymbolConfiguration(config)
93
+        }
94
+        icon.contentTintColor = foreground
95
+
96
+        let label = NSTextField(labelWithString: text)
97
+        label.font = AppTheme.semiboldFont(size: 10)
98
+        label.textColor = foreground
99
+        label.translatesAutoresizingMaskIntoConstraints = false
100
+
101
+        addSubview(icon)
102
+        addSubview(label)
103
+
104
+        NSLayoutConstraint.activate([
105
+            heightAnchor.constraint(equalToConstant: 20),
106
+
107
+            icon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
108
+            icon.centerYAnchor.constraint(equalTo: centerYAnchor),
109
+            icon.widthAnchor.constraint(equalToConstant: 12),
110
+            icon.heightAnchor.constraint(equalToConstant: 12),
111
+
112
+            label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 4),
113
+            label.centerYAnchor.constraint(equalTo: centerYAnchor),
114
+            label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
115
+        ])
116
+    }
117
+
118
+    @available(*, unavailable)
119
+    required init?(coder: NSCoder) { nil }
120
+}
121
+
122
+// MARK: - Feature Row
123
+
124
+private final class PaywallFeatureRow: NSView {
125
+    init(text: String) {
126
+        super.init(frame: .zero)
127
+        translatesAutoresizingMaskIntoConstraints = false
128
+
129
+        let checkContainer = NSView()
130
+        checkContainer.translatesAutoresizingMaskIntoConstraints = false
131
+        checkContainer.wantsLayer = true
132
+        checkContainer.layer?.backgroundColor = AppTheme.green.cgColor
133
+        checkContainer.layer?.cornerRadius = 10
134
+        checkContainer.layer?.masksToBounds = true
135
+
136
+        let checkIcon = NSImageView()
137
+        checkIcon.translatesAutoresizingMaskIntoConstraints = false
138
+        if let image = NSImage(systemSymbolName: "checkmark", accessibilityDescription: nil) {
139
+            let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .bold)
140
+            checkIcon.image = image.withSymbolConfiguration(config)
141
+        }
142
+        checkIcon.contentTintColor = .white
143
+
144
+        let label = NSTextField(labelWithString: text)
145
+        label.font = AppTheme.regularFont(size: 14)
146
+        label.textColor = AppTheme.navy
147
+        label.translatesAutoresizingMaskIntoConstraints = false
148
+
149
+        addSubview(checkContainer)
150
+        checkContainer.addSubview(checkIcon)
151
+        addSubview(label)
152
+
153
+        NSLayoutConstraint.activate([
154
+            heightAnchor.constraint(equalToConstant: 28),
155
+
156
+            checkContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
157
+            checkContainer.centerYAnchor.constraint(equalTo: centerYAnchor),
158
+            checkContainer.widthAnchor.constraint(equalToConstant: 20),
159
+            checkContainer.heightAnchor.constraint(equalToConstant: 20),
160
+
161
+            checkIcon.centerXAnchor.constraint(equalTo: checkContainer.centerXAnchor),
162
+            checkIcon.centerYAnchor.constraint(equalTo: checkContainer.centerYAnchor),
163
+            checkIcon.widthAnchor.constraint(equalToConstant: 12),
164
+            checkIcon.heightAnchor.constraint(equalToConstant: 12),
165
+
166
+            label.leadingAnchor.constraint(equalTo: checkContainer.trailingAnchor, constant: 12),
167
+            label.centerYAnchor.constraint(equalTo: centerYAnchor),
168
+            label.trailingAnchor.constraint(equalTo: trailingAnchor),
169
+        ])
170
+    }
171
+
172
+    @available(*, unavailable)
173
+    required init?(coder: NSCoder) { nil }
174
+}
175
+
176
+// MARK: - Plan Card
177
+
178
+private final class PaywallPlanCard: NSControl {
179
+    var onSelect: (() -> Void)?
180
+
181
+    private let plan: PaywallPlan
182
+    private let titleLabel = NSTextField(labelWithString: "")
183
+    private let subtitleLabel = NSTextField(labelWithString: "")
184
+    private let priceLabel = NSTextField(labelWithString: "")
185
+    private var badgeView: PaywallBadgeView?
186
+
187
+    var isChosen: Bool = false {
188
+        didSet { updateAppearance() }
189
+    }
190
+
191
+    init(plan: PaywallPlan) {
192
+        self.plan = plan
193
+        super.init(frame: .zero)
194
+        translatesAutoresizingMaskIntoConstraints = false
195
+        wantsLayer = true
196
+        layer?.cornerRadius = 12
197
+        layer?.backgroundColor = NSColor.white.cgColor
198
+        layer?.masksToBounds = false
199
+
200
+        titleLabel.stringValue = plan.title
201
+        titleLabel.font = AppTheme.semiboldFont(size: 15)
202
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
203
+
204
+        subtitleLabel.stringValue = plan.subtitle
205
+        subtitleLabel.font = AppTheme.regularFont(size: 11)
206
+        subtitleLabel.textColor = AppTheme.textSecondary
207
+        subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
208
+
209
+        priceLabel.stringValue = plan.price
210
+        priceLabel.font = AppTheme.semiboldFont(size: 15)
211
+        priceLabel.alignment = .right
212
+        priceLabel.translatesAutoresizingMaskIntoConstraints = false
213
+
214
+        addSubview(titleLabel)
215
+        addSubview(subtitleLabel)
216
+        addSubview(priceLabel)
217
+
218
+        NSLayoutConstraint.activate([
219
+            heightAnchor.constraint(equalToConstant: 86),
220
+
221
+            titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
222
+            titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 24),
223
+
224
+            subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
225
+            subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 3),
226
+            subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: priceLabel.leadingAnchor, constant: -12),
227
+
228
+            priceLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
229
+            priceLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
230
+        ])
231
+
232
+        if plan == .yearly {
233
+            let badge = PaywallBadgeView(
234
+                text: "7 Days Free Trial",
235
+                iconName: "calendar",
236
+                background: AppTheme.paywallPink,
237
+                foreground: AppTheme.paywallPinkText
238
+            )
239
+            badgeView = badge
240
+            addSubview(badge)
241
+            NSLayoutConstraint.activate([
242
+                badge.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
243
+                badge.topAnchor.constraint(equalTo: topAnchor, constant: 8),
244
+            ])
245
+        } else if plan == .lifetime {
246
+            let badge = PaywallBadgeView(
247
+                text: "Best Value",
248
+                iconName: "star.fill",
249
+                background: AppTheme.paywallGold,
250
+                foreground: AppTheme.paywallGoldText
251
+            )
252
+            badgeView = badge
253
+            addSubview(badge)
254
+            NSLayoutConstraint.activate([
255
+                badge.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
256
+                badge.topAnchor.constraint(equalTo: topAnchor, constant: 8),
257
+            ])
258
+        }
259
+
260
+        updateAppearance()
261
+    }
262
+
263
+    @available(*, unavailable)
264
+    required init?(coder: NSCoder) { nil }
265
+
266
+    private func updateAppearance() {
267
+        let titleColor = isChosen && plan == .yearly ? AppTheme.green : AppTheme.navy
268
+        titleLabel.textColor = titleColor
269
+        priceLabel.textColor = titleColor
270
+
271
+        layer?.borderWidth = isChosen ? 2 : 1
272
+        layer?.borderColor = (isChosen ? AppTheme.green : AppTheme.paywallBorder).cgColor
273
+    }
274
+
275
+    override func mouseUp(with event: NSEvent) {
276
+        guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
277
+        onSelect?()
278
+    }
279
+
280
+    override func resetCursorRects() {
281
+        addCursorRect(bounds, cursor: .pointingHand)
282
+    }
283
+}
284
+
285
+// MARK: - Close Button
286
+
287
+private final class PaywallCloseButton: NSButton {
288
+    var onClose: (() -> Void)?
289
+
290
+    init() {
291
+        super.init(frame: .zero)
292
+        isBordered = false
293
+        translatesAutoresizingMaskIntoConstraints = false
294
+        wantsLayer = true
295
+        layer?.backgroundColor = AppTheme.blueLight.cgColor
296
+        layer?.cornerRadius = 14
297
+        if let image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close") {
298
+            let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
299
+            self.image = image.withSymbolConfiguration(config)
300
+        }
301
+        contentTintColor = AppTheme.blue
302
+        target = self
303
+        action = #selector(tapped)
304
+    }
305
+
306
+    @available(*, unavailable)
307
+    required init?(coder: NSCoder) { nil }
308
+
309
+    @objc private func tapped() {
310
+        onClose?()
311
+    }
312
+
313
+    override func resetCursorRects() {
314
+        addCursorRect(bounds, cursor: .pointingHand)
315
+    }
316
+}
317
+
318
+// MARK: - Footer Link
319
+
320
+private final class PaywallFooterLink: NSButton {
321
+    init(title: String) {
322
+        super.init(frame: .zero)
323
+        self.title = title
324
+        isBordered = false
325
+        font = AppTheme.regularFont(size: 11)
326
+        contentTintColor = AppTheme.textSecondary
327
+        translatesAutoresizingMaskIntoConstraints = false
328
+    }
329
+
330
+    @available(*, unavailable)
331
+    required init?(coder: NSCoder) { nil }
332
+
333
+    override func resetCursorRects() {
334
+        addCursorRect(bounds, cursor: .pointingHand)
335
+    }
336
+}
337
+
338
+// MARK: - Main Paywall Card
339
+
340
+final class PaywallView: NSView {
341
+    var onClose: (() -> Void)?
342
+    var onPurchase: ((PaywallPlan) -> Void)?
343
+    var onRestore: (() -> Void)?
344
+
345
+    private var selectedPlan: PaywallPlan = .yearly
346
+    private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
347
+    private let ctaButton = NSButton()
348
+
349
+    init() {
350
+        super.init(frame: .zero)
351
+        translatesAutoresizingMaskIntoConstraints = false
352
+        wantsLayer = true
353
+        layer?.backgroundColor = NSColor.white.cgColor
354
+        layer?.cornerRadius = 20
355
+        applyCardShadow()
356
+        setup()
357
+    }
358
+
359
+    @available(*, unavailable)
360
+    required init?(coder: NSCoder) { nil }
361
+
362
+    private func setup() {
363
+        let leftPanel = makeLeftPanel()
364
+        let rightPanel = makeRightPanel()
365
+
366
+        addSubview(leftPanel)
367
+        addSubview(rightPanel)
368
+
369
+        NSLayoutConstraint.activate([
370
+            widthAnchor.constraint(equalToConstant: 780),
371
+            heightAnchor.constraint(equalToConstant: 500),
372
+
373
+            leftPanel.leadingAnchor.constraint(equalTo: leadingAnchor),
374
+            leftPanel.topAnchor.constraint(equalTo: topAnchor),
375
+            leftPanel.bottomAnchor.constraint(equalTo: bottomAnchor),
376
+            leftPanel.widthAnchor.constraint(equalToConstant: 320),
377
+
378
+            rightPanel.leadingAnchor.constraint(equalTo: leftPanel.trailingAnchor),
379
+            rightPanel.trailingAnchor.constraint(equalTo: trailingAnchor),
380
+            rightPanel.topAnchor.constraint(equalTo: topAnchor),
381
+            rightPanel.bottomAnchor.constraint(equalTo: bottomAnchor),
382
+        ])
383
+    }
384
+
385
+    private func makeLeftPanel() -> NSView {
386
+        let panel = PaywallLeftPanelView()
387
+        panel.translatesAutoresizingMaskIntoConstraints = false
388
+
389
+        let title = NSTextField(labelWithString: "Unlock Your Full\nPrinting Potential")
390
+        title.font = AppTheme.semiboldFont(size: 22)
391
+        title.textColor = AppTheme.navy
392
+        title.maximumNumberOfLines = 2
393
+        title.translatesAutoresizingMaskIntoConstraints = false
394
+
395
+        let featuresStack = NSStackView()
396
+        featuresStack.orientation = .vertical
397
+        featuresStack.spacing = 6
398
+        featuresStack.alignment = .leading
399
+        featuresStack.translatesAutoresizingMaskIntoConstraints = false
400
+
401
+        let features = [
402
+            "Unlimited high-quality scans",
403
+            "Advanced OCR technology",
404
+            "Direct cloud printing",
405
+            "Ad-free experience",
406
+            "Priority support",
407
+            "Secure storage",
408
+        ]
409
+        for feature in features {
410
+            featuresStack.addArrangedSubview(PaywallFeatureRow(text: feature))
411
+        }
412
+
413
+        panel.addSubview(title)
414
+        panel.addSubview(featuresStack)
415
+
416
+        NSLayoutConstraint.activate([
417
+            title.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
418
+            title.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -20),
419
+            title.topAnchor.constraint(equalTo: panel.topAnchor, constant: 48),
420
+
421
+            featuresStack.leadingAnchor.constraint(equalTo: title.leadingAnchor),
422
+            featuresStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -20),
423
+            featuresStack.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 28),
424
+        ])
425
+
426
+        return panel
427
+    }
428
+
429
+    private func makeRightPanel() -> NSView {
430
+        let panel = NSView()
431
+        panel.translatesAutoresizingMaskIntoConstraints = false
432
+
433
+        let closeButton = PaywallCloseButton()
434
+        closeButton.onClose = { [weak self] in self?.onClose?() }
435
+
436
+        let title = NSTextField(labelWithString: "Go Premium")
437
+        title.font = AppTheme.semiboldFont(size: 26)
438
+        title.textColor = AppTheme.navy
439
+        title.translatesAutoresizingMaskIntoConstraints = false
440
+
441
+        let subtitle = NSTextField(labelWithString: "Experience professional quality printing and scanning without limits.")
442
+        subtitle.font = AppTheme.regularFont(size: 13)
443
+        subtitle.textColor = AppTheme.textSecondary
444
+        subtitle.maximumNumberOfLines = 2
445
+        subtitle.translatesAutoresizingMaskIntoConstraints = false
446
+
447
+        let plansStack = NSStackView()
448
+        plansStack.orientation = .vertical
449
+        plansStack.spacing = 12
450
+        plansStack.translatesAutoresizingMaskIntoConstraints = false
451
+
452
+        for plan in PaywallPlan.allCases {
453
+            let card = PaywallPlanCard(plan: plan)
454
+            card.isChosen = plan == selectedPlan
455
+            card.onSelect = { [weak self] in self?.selectPlan(plan) }
456
+            planCards[plan] = card
457
+            plansStack.addArrangedSubview(card)
458
+        }
459
+
460
+        ctaButton.title = selectedPlan.ctaTitle
461
+        ctaButton.isBordered = false
462
+        ctaButton.wantsLayer = true
463
+        ctaButton.layer?.backgroundColor = AppTheme.navy.cgColor
464
+        ctaButton.layer?.cornerRadius = 12
465
+        ctaButton.font = AppTheme.semiboldFont(size: 15)
466
+        ctaButton.contentTintColor = .white
467
+        ctaButton.target = self
468
+        ctaButton.action = #selector(purchaseTapped)
469
+        ctaButton.translatesAutoresizingMaskIntoConstraints = false
470
+
471
+        let footer = makeFooter()
472
+
473
+        panel.addSubview(closeButton)
474
+        panel.addSubview(title)
475
+        panel.addSubview(subtitle)
476
+        panel.addSubview(plansStack)
477
+        panel.addSubview(ctaButton)
478
+        panel.addSubview(footer)
479
+
480
+        NSLayoutConstraint.activate([
481
+            closeButton.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -20),
482
+            closeButton.topAnchor.constraint(equalTo: panel.topAnchor, constant: 20),
483
+            closeButton.widthAnchor.constraint(equalToConstant: 28),
484
+            closeButton.heightAnchor.constraint(equalToConstant: 28),
485
+
486
+            title.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
487
+            title.topAnchor.constraint(equalTo: panel.topAnchor, constant: 40),
488
+            title.trailingAnchor.constraint(equalTo: closeButton.leadingAnchor, constant: -12),
489
+
490
+            subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
491
+            subtitle.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
492
+            subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6),
493
+
494
+            plansStack.leadingAnchor.constraint(equalTo: title.leadingAnchor),
495
+            plansStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
496
+            plansStack.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 24),
497
+
498
+            ctaButton.leadingAnchor.constraint(equalTo: title.leadingAnchor),
499
+            ctaButton.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
500
+            ctaButton.topAnchor.constraint(equalTo: plansStack.bottomAnchor, constant: 20),
501
+            ctaButton.heightAnchor.constraint(equalToConstant: 48),
502
+
503
+            footer.centerXAnchor.constraint(equalTo: panel.centerXAnchor),
504
+            footer.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -20),
505
+        ])
506
+
507
+        return panel
508
+    }
509
+
510
+    private func makeFooter() -> NSView {
511
+        let container = NSView()
512
+        container.translatesAutoresizingMaskIntoConstraints = false
513
+
514
+        let restoreLink = PaywallFooterLink(title: "Restore Purchases")
515
+        restoreLink.target = self
516
+        restoreLink.action = #selector(restoreTapped)
517
+
518
+        let privacyLink = PaywallFooterLink(title: "Privacy Policy")
519
+        let termsLink = PaywallFooterLink(title: "Terms of Service")
520
+
521
+        let dot1 = makeFooterDot()
522
+        let dot2 = makeFooterDot()
523
+
524
+        let stack = NSStackView(views: [restoreLink, dot1, privacyLink, dot2, termsLink])
525
+        stack.orientation = .horizontal
526
+        stack.spacing = 6
527
+        stack.alignment = .centerY
528
+        stack.translatesAutoresizingMaskIntoConstraints = false
529
+
530
+        container.addSubview(stack)
531
+        NSLayoutConstraint.activate([
532
+            stack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
533
+            stack.trailingAnchor.constraint(equalTo: container.trailingAnchor),
534
+            stack.topAnchor.constraint(equalTo: container.topAnchor),
535
+            stack.bottomAnchor.constraint(equalTo: container.bottomAnchor),
536
+        ])
537
+
538
+        return container
539
+    }
540
+
541
+    private func makeFooterDot() -> NSView {
542
+        let dot = NSView()
543
+        dot.translatesAutoresizingMaskIntoConstraints = false
544
+        dot.wantsLayer = true
545
+        dot.layer?.backgroundColor = AppTheme.textSecondary.cgColor
546
+        dot.layer?.cornerRadius = 1.5
547
+        NSLayoutConstraint.activate([
548
+            dot.widthAnchor.constraint(equalToConstant: 3),
549
+            dot.heightAnchor.constraint(equalToConstant: 3),
550
+        ])
551
+        return dot
552
+    }
553
+
554
+    private func selectPlan(_ plan: PaywallPlan) {
555
+        selectedPlan = plan
556
+        for (key, card) in planCards {
557
+            card.isChosen = key == plan
558
+        }
559
+        ctaButton.title = plan.ctaTitle
560
+    }
561
+
562
+    @objc private func purchaseTapped() {
563
+        onPurchase?(selectedPlan)
564
+    }
565
+
566
+    @objc private func restoreTapped() {
567
+        onRestore?()
568
+    }
569
+}
570
+
571
+// MARK: - Overlay Presenter
572
+
573
+final class PaywallOverlayView: NSView {
574
+    var onDismiss: (() -> Void)?
575
+
576
+    private let paywallView: PaywallView
577
+    private let backdrop = NSView()
578
+
579
+    init() {
580
+        paywallView = PaywallView()
581
+        super.init(frame: .zero)
582
+        translatesAutoresizingMaskIntoConstraints = false
583
+        setup()
584
+    }
585
+
586
+    @available(*, unavailable)
587
+    required init?(coder: NSCoder) { nil }
588
+
589
+    private func setup() {
590
+        backdrop.translatesAutoresizingMaskIntoConstraints = false
591
+        backdrop.wantsLayer = true
592
+        backdrop.layer?.backgroundColor = NSColor(calibratedWhite: 0.15, alpha: 0.45).cgColor
593
+
594
+        let pattern = WavePatternView()
595
+        pattern.translatesAutoresizingMaskIntoConstraints = false
596
+        pattern.alphaValue = 0.35
597
+
598
+        paywallView.translatesAutoresizingMaskIntoConstraints = false
599
+        paywallView.onClose = { [weak self] in self?.dismiss() }
600
+        paywallView.onPurchase = { plan in
601
+            NSLog("Purchase tapped: \(plan.title)")
602
+        }
603
+        paywallView.onRestore = {
604
+            NSLog("Restore purchases tapped")
605
+        }
606
+
607
+        addSubview(backdrop)
608
+        backdrop.addSubview(pattern)
609
+        addSubview(paywallView)
610
+
611
+        NSLayoutConstraint.activate([
612
+            backdrop.leadingAnchor.constraint(equalTo: leadingAnchor),
613
+            backdrop.trailingAnchor.constraint(equalTo: trailingAnchor),
614
+            backdrop.topAnchor.constraint(equalTo: topAnchor),
615
+            backdrop.bottomAnchor.constraint(equalTo: bottomAnchor),
616
+
617
+            pattern.leadingAnchor.constraint(equalTo: backdrop.leadingAnchor),
618
+            pattern.trailingAnchor.constraint(equalTo: backdrop.trailingAnchor),
619
+            pattern.topAnchor.constraint(equalTo: backdrop.topAnchor),
620
+            pattern.bottomAnchor.constraint(equalTo: backdrop.bottomAnchor),
621
+
622
+            paywallView.centerXAnchor.constraint(equalTo: centerXAnchor),
623
+            paywallView.centerYAnchor.constraint(equalTo: centerYAnchor),
624
+        ])
625
+    }
626
+
627
+    func present(in parent: NSView) {
628
+        guard superview == nil else { return }
629
+        parent.addSubview(self)
630
+        NSLayoutConstraint.activate([
631
+            leadingAnchor.constraint(equalTo: parent.leadingAnchor),
632
+            trailingAnchor.constraint(equalTo: parent.trailingAnchor),
633
+            topAnchor.constraint(equalTo: parent.topAnchor),
634
+            bottomAnchor.constraint(equalTo: parent.bottomAnchor),
635
+        ])
636
+        alphaValue = 0
637
+        NSAnimationContext.runAnimationGroup { context in
638
+            context.duration = 0.2
639
+            animator().alphaValue = 1
640
+        }
641
+    }
642
+
643
+    func dismiss() {
644
+        NSAnimationContext.runAnimationGroup({ context in
645
+            context.duration = 0.15
646
+            animator().alphaValue = 0
647
+        }, completionHandler: { [weak self] in
648
+            self?.removeFromSuperview()
649
+            self?.onDismiss?()
650
+        })
651
+    }
652
+}

+ 24 - 0
smart_printer/ViewController.swift

@@ -12,6 +12,7 @@ class ViewController: NSViewController {
12 12
     private var scanContentView: NSView!
13 13
     private var scanAndHomeContentView: NSView!
14 14
     private var contentContainer: NSView!
15
+    private var paywallOverlay: PaywallOverlayView?
15 16
 
16 17
     override func loadView() {
17 18
         let container = NSView(frame: NSRect(x: 0, y: 0, width: AppTheme.windowWidth, height: AppTheme.windowHeight))
@@ -105,6 +106,29 @@ class ViewController: NSViewController {
105 106
         homeContentView.isHidden = destination != .home
106 107
         scanContentView.isHidden = destination != .scan
107 108
         scanAndHomeContentView.isHidden = destination != .scanAndHome
109
+
110
+        if destination == .scanAndHome {
111
+            presentPaywall()
112
+        } else {
113
+            dismissPaywall()
114
+        }
115
+    }
116
+
117
+    private func presentPaywall() {
118
+        guard paywallOverlay == nil else { return }
119
+        let overlay = PaywallOverlayView()
120
+        overlay.onDismiss = { [weak self] in
121
+            self?.paywallOverlay = nil
122
+            self?.sidebar.select(.home)
123
+            self?.showDestination(.home)
124
+        }
125
+        overlay.present(in: view)
126
+        paywallOverlay = overlay
127
+    }
128
+
129
+    private func dismissPaywall() {
130
+        paywallOverlay?.removeFromSuperview()
131
+        paywallOverlay = nil
108 132
     }
109 133
 
110 134
     private func makeHomeContentView() -> NSView {