소스 검색

Add interactive premium paywall popup and refine card UI.

Replace the temporary premium alert with a dedicated paywall window, add selectable pricing plans with improved styling and badge placement, and tighten spacing/typography for a cleaner premium purchase experience.

Made-with: Cursor
huzaifahayat12 3 달 전
부모
커밋
861b95a21e
1개의 변경된 파일421개의 추가작업 그리고 1개의 파일을 삭제
  1. 421 1
      meetings_app/ViewController.swift

+ 421 - 1
meetings_app/ViewController.swift

@@ -30,6 +30,13 @@ private enum SettingsAction: Int {
     case shareApp = 4
 }
 
+private enum PremiumPlan: Int {
+    case weekly = 0
+    case monthly = 1
+    case yearly = 2
+    case lifetime = 3
+}
+
 final class ViewController: NSViewController {
     private let palette = Palette()
     private let typography = Typography()
@@ -46,6 +53,11 @@ final class ViewController: NSViewController {
     private var meetingProviderByView = [ObjectIdentifier: MeetingProvider]()
     private var settingsActionByView = [ObjectIdentifier: SettingsAction]()
     private weak var centeredTitleLabel: NSTextField?
+    private weak var paywallWindow: NSWindow?
+    private let paywallContentWidth: CGFloat = 520
+    private var selectedPremiumPlan: PremiumPlan = .monthly
+    private var paywallPlanViews: [PremiumPlan: NSView] = [:]
+    private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
 
     private let darkModeDefaultsKey = "settings.darkModeEnabled"
     private var darkModeEnabled: Bool {
@@ -158,7 +170,7 @@ private extension ViewController {
     }
 
     @objc private func premiumButtonClicked(_ sender: NSClickGestureRecognizer) {
-        showSimpleAlert(title: "Get Premium", message: "Premium features coming soon.")
+        showPaywall()
     }
 
     private func showSidebarPage(_ page: SidebarPage) {
@@ -226,6 +238,75 @@ private extension ViewController {
         alert.runModal()
     }
 
+    private func showPaywall() {
+        if let existing = paywallWindow {
+            existing.makeKeyAndOrderFront(nil)
+            NSApp.activate(ignoringOtherApps: true)
+            return
+        }
+
+        let content = makePaywallContent()
+        let controller = NSViewController()
+        controller.view = content
+
+        let panel = NSPanel(
+            contentRect: NSRect(x: 0, y: 0, width: 640, height: 820),
+            styleMask: [.titled, .closable, .fullSizeContentView],
+            backing: .buffered,
+            defer: false
+        )
+        panel.title = "Get Premium"
+        panel.titleVisibility = .hidden
+        panel.titlebarAppearsTransparent = true
+        panel.isFloatingPanel = true
+        panel.hidesOnDeactivate = false
+        panel.standardWindowButton(.closeButton)?.isHidden = true
+        panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
+        panel.standardWindowButton(.zoomButton)?.isHidden = true
+        panel.center()
+        panel.contentViewController = controller
+        panel.makeKeyAndOrderFront(nil)
+        NSApp.activate(ignoringOtherApps: true)
+        paywallWindow = panel
+    }
+
+    @objc private func closePaywallClicked(_ sender: NSClickGestureRecognizer) {
+        paywallWindow?.close()
+        paywallWindow = nil
+    }
+
+    @objc private func paywallFooterLinkClicked(_ sender: NSClickGestureRecognizer) {
+        guard let view = sender.view else { return }
+        let text = (view.subviews.first { $0 is NSTextField } as? NSTextField)?.stringValue ?? "Link"
+        showSimpleAlert(title: text, message: "\(text) tapped.")
+    }
+
+    @objc private func paywallPlanClicked(_ sender: NSClickGestureRecognizer) {
+        guard let view = sender.view,
+              let plan = premiumPlanByView[ObjectIdentifier(view)] else { return }
+        selectedPremiumPlan = plan
+        updatePaywallPlanSelection()
+    }
+
+    private func updatePaywallPlanSelection() {
+        for (plan, view) in paywallPlanViews {
+            applyPaywallPlanStyle(view, isSelected: plan == selectedPremiumPlan)
+        }
+    }
+
+    private func applyPaywallPlanStyle(_ card: NSView, isSelected: Bool) {
+        let selectedBorder = NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1)
+        let idleBorder = palette.inputBorder
+        let selectedBackground = NSColor(calibratedRed: 30.0 / 255.0, green: 34.0 / 255.0, blue: 42.0 / 255.0, alpha: 1)
+        card.layer?.backgroundColor = (isSelected ? selectedBackground : palette.sectionCard).cgColor
+        card.layer?.borderColor = (isSelected ? selectedBorder : idleBorder).cgColor
+        card.layer?.borderWidth = isSelected ? 2 : 1
+        card.layer?.shadowColor = NSColor.black.cgColor
+        card.layer?.shadowOpacity = isSelected ? 0.26 : 0.12
+        card.layer?.shadowOffset = CGSize(width: 0, height: -1)
+        card.layer?.shadowRadius = isSelected ? 10 : 5
+    }
+
     private func viewForPage(_ page: SidebarPage) -> NSView {
         if let cached = pageCache[page] { return cached }
         let built: NSView
@@ -491,6 +572,345 @@ private extension ViewController {
         return panel
     }
 
+    func makePaywallContent() -> NSView {
+        paywallPlanViews.removeAll()
+        premiumPlanByView.removeAll()
+
+        let panel = NSView()
+        panel.translatesAutoresizingMaskIntoConstraints = false
+        panel.wantsLayer = true
+        panel.layer?.backgroundColor = palette.pageBackground.cgColor
+
+        let contentStack = NSStackView()
+        contentStack.translatesAutoresizingMaskIntoConstraints = false
+        contentStack.orientation = .vertical
+        contentStack.spacing = 12
+        contentStack.alignment = .leading
+        panel.addSubview(contentStack)
+
+        let topRow = NSStackView()
+        topRow.translatesAutoresizingMaskIntoConstraints = false
+        topRow.orientation = .horizontal
+        topRow.alignment = .centerY
+        topRow.distribution = .fill
+        topRow.spacing = 10
+        topRow.addArrangedSubview(textLabel("Get Premium", font: NSFont.systemFont(ofSize: 24, weight: .bold), color: palette.textPrimary))
+        let topSpacer = NSView()
+        topSpacer.translatesAutoresizingMaskIntoConstraints = false
+        topRow.addArrangedSubview(topSpacer)
+        let closeButton = iconRoundButton("✕", size: 28)
+        topRow.addArrangedSubview(closeButton)
+        let closeClick = NSClickGestureRecognizer(target: self, action: #selector(closePaywallClicked(_:)))
+        closeButton.addGestureRecognizer(closeClick)
+        topRow.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
+        contentStack.addArrangedSubview(topRow)
+
+        contentStack.addArrangedSubview(textLabel("Upgrade to unlock premium features.", font: NSFont.systemFont(ofSize: 12, weight: .medium), color: palette.textSecondary))
+        let benefits = paywallBenefitsSection()
+        contentStack.addArrangedSubview(benefits)
+        contentStack.setCustomSpacing(18, after: benefits)
+
+        let weeklyCard = paywallPlanCard(
+            title: "Weekly",
+            price: "Rs 1,100.00",
+            badge: "Basic Deal",
+            badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
+            subtitle: nil,
+            plan: .weekly,
+            strikePrice: nil
+        )
+        contentStack.addArrangedSubview(weeklyCard)
+
+        let monthlyCard = paywallPlanCard(
+            title: "Monthly",
+            price: "Rs 2,500.00",
+            badge: "Free Trial",
+            badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
+            subtitle: "625.00/week",
+            plan: .monthly,
+            strikePrice: nil
+        )
+        contentStack.addArrangedSubview(monthlyCard)
+
+        let yearlyCard = paywallPlanCard(
+            title: "Yearly",
+            price: "Rs 9,900.00",
+            badge: "Best Deal",
+            badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
+            subtitle: "190.38/week",
+            plan: .yearly,
+            strikePrice: nil
+        )
+        contentStack.addArrangedSubview(yearlyCard)
+
+        let lifetimeCard = paywallPlanCard(
+            title: "Lifetime",
+            price: "Rs 14,900.00",
+            badge: "Save 50%",
+            badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
+            subtitle: nil,
+            plan: .lifetime,
+            strikePrice: "Rs 29,800.00"
+        )
+        contentStack.addArrangedSubview(lifetimeCard)
+        updatePaywallPlanSelection()
+        contentStack.setCustomSpacing(20, after: lifetimeCard)
+
+        let offer = textLabel("Free for 3 Days then Rs 2,500.00/month", font: NSFont.systemFont(ofSize: 13, weight: .semibold), color: palette.textPrimary)
+        offer.alignment = .center
+        let offerWrap = NSView()
+        offerWrap.translatesAutoresizingMaskIntoConstraints = false
+        offerWrap.addSubview(offer)
+        NSLayoutConstraint.activate([
+            offerWrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth),
+            offer.centerXAnchor.constraint(equalTo: offerWrap.centerXAnchor),
+            offer.topAnchor.constraint(equalTo: offerWrap.topAnchor, constant: 6),
+            offer.bottomAnchor.constraint(equalTo: offerWrap.bottomAnchor, constant: -2)
+        ])
+        contentStack.addArrangedSubview(offerWrap)
+        contentStack.setCustomSpacing(18, after: offerWrap)
+
+        let continueButton = roundedContainer(cornerRadius: 14, color: palette.primaryBlue)
+        continueButton.translatesAutoresizingMaskIntoConstraints = false
+        continueButton.heightAnchor.constraint(equalToConstant: 44).isActive = true
+        continueButton.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
+        styleSurface(continueButton, borderColor: palette.primaryBlueBorder, borderWidth: 1, shadow: true)
+        let continueLabel = textLabel("Continue", font: NSFont.systemFont(ofSize: 16, weight: .bold), color: .white)
+        continueButton.addSubview(continueLabel)
+        NSLayoutConstraint.activate([
+            continueLabel.centerXAnchor.constraint(equalTo: continueButton.centerXAnchor),
+            continueLabel.centerYAnchor.constraint(equalTo: continueButton.centerYAnchor)
+        ])
+        contentStack.addArrangedSubview(continueButton)
+        contentStack.setCustomSpacing(16, after: continueButton)
+
+        let secure = textLabel("Secured by Apple. Cancel anytime.", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: palette.textSecondary)
+        secure.alignment = .center
+        let secureWrap = NSView()
+        secureWrap.translatesAutoresizingMaskIntoConstraints = false
+        secureWrap.addSubview(secure)
+        NSLayoutConstraint.activate([
+            secureWrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth),
+            secure.centerXAnchor.constraint(equalTo: secureWrap.centerXAnchor),
+            secure.topAnchor.constraint(equalTo: secureWrap.topAnchor, constant: 4),
+            secure.bottomAnchor.constraint(equalTo: secureWrap.bottomAnchor, constant: -8)
+        ])
+        contentStack.addArrangedSubview(secureWrap)
+        contentStack.setCustomSpacing(16, after: secureWrap)
+
+        let footer = paywallFooterLinks()
+        contentStack.addArrangedSubview(footer)
+
+        NSLayoutConstraint.activate([
+            contentStack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 18),
+            contentStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -18),
+            contentStack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 16),
+            contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -12)
+        ])
+
+        return panel
+    }
+
+    func paywallPlanCard(
+        title: String,
+        price: String,
+        badge: String,
+        badgeColor: NSColor,
+        subtitle: String?,
+        plan: PremiumPlan,
+        strikePrice: String?
+    ) -> NSView {
+        let wrapper = NSView()
+        wrapper.translatesAutoresizingMaskIntoConstraints = false
+        wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
+        wrapper.heightAnchor.constraint(equalToConstant: 94).isActive = true
+
+        let card = roundedContainer(cornerRadius: 16, color: palette.sectionCard)
+        card.translatesAutoresizingMaskIntoConstraints = false
+        card.heightAnchor.constraint(equalToConstant: 82).isActive = true
+        wrapper.addSubview(card)
+        NSLayoutConstraint.activate([
+            card.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
+            card.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
+            card.topAnchor.constraint(equalTo: wrapper.topAnchor, constant: 12),
+            card.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
+        ])
+        styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
+
+        let badgeLabel = textLabel(badge, font: NSFont.systemFont(ofSize: 10, weight: .bold), color: .white)
+        let badgeWrap = roundedContainer(cornerRadius: 10, color: badgeColor)
+        badgeWrap.translatesAutoresizingMaskIntoConstraints = false
+        badgeWrap.wantsLayer = true
+        badgeWrap.layer?.borderColor = NSColor(calibratedWhite: 1, alpha: 0.22).cgColor
+        badgeWrap.layer?.borderWidth = 1
+        badgeWrap.layer?.shadowColor = NSColor.black.cgColor
+        badgeWrap.layer?.shadowOpacity = 0.20
+        badgeWrap.layer?.shadowOffset = CGSize(width: 0, height: -1)
+        badgeWrap.layer?.shadowRadius = 3
+        badgeWrap.addSubview(badgeLabel)
+        NSLayoutConstraint.activate([
+            badgeLabel.leadingAnchor.constraint(equalTo: badgeWrap.leadingAnchor, constant: 8),
+            badgeLabel.trailingAnchor.constraint(equalTo: badgeWrap.trailingAnchor, constant: -8),
+            badgeLabel.topAnchor.constraint(equalTo: badgeWrap.topAnchor, constant: 2),
+            badgeLabel.bottomAnchor.constraint(equalTo: badgeWrap.bottomAnchor, constant: -2)
+        ])
+        wrapper.addSubview(badgeWrap)
+
+        let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: .bold), color: palette.primaryBlue)
+        card.addSubview(titleLabel)
+        let priceLabel = textLabel(price, font: NSFont.systemFont(ofSize: 12, weight: .bold), color: palette.textPrimary)
+        card.addSubview(priceLabel)
+
+        NSLayoutConstraint.activate([
+            badgeWrap.centerXAnchor.constraint(equalTo: card.centerXAnchor),
+            badgeWrap.centerYAnchor.constraint(equalTo: card.topAnchor),
+
+            titleLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
+            titleLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 34),
+
+            priceLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
+            priceLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 32)
+        ])
+
+        if let subtitle {
+            let sub = textLabel(subtitle, font: NSFont.systemFont(ofSize: 10, weight: .semibold), color: palette.textSecondary)
+            card.addSubview(sub)
+            NSLayoutConstraint.activate([
+                sub.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
+                sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 0)
+            ])
+        }
+
+        if let strikePrice {
+            let strike = textLabel(strikePrice, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: NSColor.systemRed)
+            card.addSubview(strike)
+            NSLayoutConstraint.activate([
+                strike.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
+                strike.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 4)
+            ])
+        }
+
+        let click = NSClickGestureRecognizer(target: self, action: #selector(paywallPlanClicked(_:)))
+        wrapper.addGestureRecognizer(click)
+        premiumPlanByView[ObjectIdentifier(wrapper)] = plan
+        paywallPlanViews[plan] = card
+
+        return wrapper
+    }
+
+    func paywallFooterLinks() -> NSView {
+        let wrap = NSView()
+        wrap.translatesAutoresizingMaskIntoConstraints = false
+        wrap.heightAnchor.constraint(equalToConstant: 34).isActive = true
+        wrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
+
+        let row = NSStackView()
+        row.translatesAutoresizingMaskIntoConstraints = false
+        row.orientation = .horizontal
+        row.distribution = .fillEqually
+        row.alignment = .centerY
+        row.spacing = 0
+        wrap.addSubview(row)
+
+        row.addArrangedSubview(footerLink("Privacy Policy"))
+        row.addArrangedSubview(footerLink("Support"))
+        row.addArrangedSubview(footerLink("Terms of Services"))
+
+        NSLayoutConstraint.activate([
+            row.leadingAnchor.constraint(equalTo: wrap.leadingAnchor),
+            row.trailingAnchor.constraint(equalTo: wrap.trailingAnchor),
+            row.topAnchor.constraint(equalTo: wrap.topAnchor),
+            row.bottomAnchor.constraint(equalTo: wrap.bottomAnchor)
+        ])
+
+        return wrap
+    }
+
+    func footerLink(_ title: String) -> NSView {
+        let container = HoverTrackingView()
+        container.translatesAutoresizingMaskIntoConstraints = false
+        let label = textLabel(title, font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: palette.textSecondary)
+        label.alignment = .center
+        container.addSubview(label)
+
+        NSLayoutConstraint.activate([
+            label.centerXAnchor.constraint(equalTo: container.centerXAnchor),
+            label.centerYAnchor.constraint(equalTo: container.centerYAnchor)
+        ])
+
+        let click = NSClickGestureRecognizer(target: self, action: #selector(paywallFooterLinkClicked(_:)))
+        container.addGestureRecognizer(click)
+        container.onHoverChanged = { hovering in
+            label.textColor = hovering ? .white : self.palette.textSecondary
+        }
+        container.onHoverChanged?(false)
+        return container
+    }
+
+    func paywallBenefitsSection() -> NSView {
+        let stack = NSStackView()
+        stack.translatesAutoresizingMaskIntoConstraints = false
+        stack.orientation = .vertical
+        stack.spacing = 8
+        stack.alignment = .leading
+        stack.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
+
+        let rowOne = NSStackView()
+        rowOne.translatesAutoresizingMaskIntoConstraints = false
+        rowOne.orientation = .horizontal
+        rowOne.spacing = 10
+        rowOne.distribution = .fillEqually
+        rowOne.alignment = .centerY
+        rowOne.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Manage meetings"))
+        rowOne.addArrangedSubview(paywallBenefitItem(icon: "🖼️", text: "Virtual backgrounds"))
+
+        let rowTwo = NSStackView()
+        rowTwo.translatesAutoresizingMaskIntoConstraints = false
+        rowTwo.orientation = .horizontal
+        rowTwo.spacing = 10
+        rowTwo.distribution = .fillEqually
+        rowTwo.alignment = .centerY
+        rowTwo.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Tools for productivity"))
+        rowTwo.addArrangedSubview(paywallBenefitItem(icon: "🛟", text: "24/7 support"))
+
+        stack.addArrangedSubview(rowOne)
+        stack.addArrangedSubview(rowTwo)
+        return stack
+    }
+
+    func paywallBenefitItem(icon: String, text: String) -> NSView {
+        let card = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
+        card.translatesAutoresizingMaskIntoConstraints = false
+        card.heightAnchor.constraint(equalToConstant: 36).isActive = true
+        styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
+
+        let iconWrap = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
+        iconWrap.translatesAutoresizingMaskIntoConstraints = false
+        iconWrap.widthAnchor.constraint(equalToConstant: 24).isActive = true
+        iconWrap.heightAnchor.constraint(equalToConstant: 24).isActive = true
+        styleSurface(iconWrap, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
+
+        let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: palette.primaryBlue)
+        iconWrap.addSubview(iconLabel)
+        NSLayoutConstraint.activate([
+            iconLabel.centerXAnchor.constraint(equalTo: iconWrap.centerXAnchor),
+            iconLabel.centerYAnchor.constraint(equalTo: iconWrap.centerYAnchor)
+        ])
+
+        let title = textLabel(text, font: NSFont.systemFont(ofSize: 11, weight: .medium), color: palette.textPrimary)
+
+        card.addSubview(iconWrap)
+        card.addSubview(title)
+        NSLayoutConstraint.activate([
+            iconWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 8),
+            iconWrap.centerYAnchor.constraint(equalTo: card.centerYAnchor),
+            title.leadingAnchor.constraint(equalTo: iconWrap.trailingAnchor, constant: 10),
+            title.centerYAnchor.constraint(equalTo: card.centerYAnchor),
+            title.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -8)
+        ])
+        return card
+    }
+
     func joinWithURLHeading() -> NSView {
         let container = NSView()
         container.translatesAutoresizingMaskIntoConstraints = false