Selaa lähdekoodia

Wire paywall and premium manage button.

Open the meetings_app paywall from the top bar and improve StoreKit entitlement refresh so premium status is detected reliably. When premium is active, show a gold 'Manage Subscription' button with a crown icon.

Made-with: Cursor
huzaifahayat12 3 kuukautta sitten
vanhempi
sitoutus
6f35c51c71
1 muutettua tiedostoa jossa 580 lisäystä ja 7 poistoa
  1. 580 7
      zoom_app/ViewController.swift

+ 580 - 7
zoom_app/ViewController.swift

@@ -96,6 +96,7 @@ class ViewController: NSViewController {
     private final class HoverButton: NSButton {
         var normalColor: NSColor = .clear { didSet { applyBackground() } }
         var hoverColor: NSColor = .clear
+        var onHoverChanged: ((Bool) -> Void)?
         private var tracking: NSTrackingArea?
         private var hovering = false { didSet { applyBackground() } }
 
@@ -109,10 +110,12 @@ class ViewController: NSViewController {
 
         override func mouseEntered(with event: NSEvent) {
             hovering = true
+            onHoverChanged?(true)
         }
 
         override func mouseExited(with event: NSEvent) {
             hovering = false
+            onHoverChanged?(false)
         }
 
         private func applyBackground() {
@@ -183,6 +186,17 @@ class ViewController: NSViewController {
     private weak var settingsUpgradeButton: NSButton?
     private weak var settingsRestoreButton: NSButton?
     private weak var settingsGoogleActionButton: NSButton?
+    private weak var topBarPremiumButton: NSButton?
+    private var paywallWindow: NSWindow?
+    private let paywallContentWidth: CGFloat = 520
+    private var selectedPremiumPlan: PremiumPlan = .monthly
+    private var paywallPlanViews: [PremiumPlan: NSView] = [:]
+    private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
+    private weak var paywallOfferLabel: NSTextField?
+    private weak var paywallContinueLabel: NSTextField?
+    private weak var paywallContinueButton: NSView?
+    private var paywallPurchaseTask: Task<Void, Never>?
+    private var paywallContinueEnabled = true
     private var allScheduledMeetings: [ScheduledMeeting] = []
     private var selectedMeetingsDayStart: Date = Calendar.current.startOfDay(for: Date())
     private var selectedHomeSidebarItem: String = "Home"
@@ -314,20 +328,41 @@ class ViewController: NSViewController {
 
         @MainActor
         private func refreshEntitlements() async {
+            let previousHasPremiumAccess = hasPremiumAccess
+            let allIDs = Set(PremiumPlan.allCases.map(\.rawValue))
             var active = Set<String>()
+
             for await entitlement in Transaction.currentEntitlements {
                 guard case .verified(let transaction) = entitlement else { continue }
-                if PremiumPlan.allCases.map(\.rawValue).contains(transaction.productID),
-                   transaction.revocationDate == nil {
+                guard allIDs.contains(transaction.productID) else { continue }
+                if Self.isTransactionActive(transaction) {
                     active.insert(transaction.productID)
                 }
             }
-            let changed = active != activeProductIDs
+
+            // StoreKit testing can briefly report empty current entitlements even though a latest
+            // verified transaction exists for a non-consumable. Merge in latest transactions.
+            for productID in allIDs {
+                guard let latest = await Transaction.latest(for: productID),
+                      case .verified(let transaction) = latest,
+                      Self.isTransactionActive(transaction) else { continue }
+                active.insert(productID)
+            }
+
             activeProductIDs = active
-            if changed {
-                onEntitlementsChanged?(hasPremiumAccess)
+            let newHasPremiumAccess = hasPremiumAccess
+            if newHasPremiumAccess != previousHasPremiumAccess {
+                onEntitlementsChanged?(newHasPremiumAccess)
             }
         }
+
+        private static func isTransactionActive(_ transaction: Transaction) -> Bool {
+            if transaction.revocationDate != nil { return false }
+            if let expirationDate = transaction.expirationDate {
+                return expirationDate > Date()
+            }
+            return true
+        }
     }
 
     private let storeKitCoordinator = StoreKitCoordinator()
@@ -346,7 +381,9 @@ class ViewController: NSViewController {
         super.viewDidAppear()
         if let window = view.window {
             window.setContentSize(NSSize(width: 1020, height: 690))
-            window.backgroundColor = chromeUnifiedBackground
+            // Match the outer window background to the app background so rounded shell corners
+            // don't reveal a lighter window color in Dark Mode.
+            window.backgroundColor = appBackground
             // Use full-size content view so custom top chrome sits in the titlebar region.
             window.titleVisibility = .hidden
             window.titlebarAppearsTransparent = true
@@ -528,6 +565,20 @@ class ViewController: NSViewController {
         // Reserved for future titlebar control actions.
     }
 
+    @objc private func upgradeToProTapped() {
+        if storeKitCoordinator.hasPremiumAccess {
+            openManageSubscriptions()
+        } else {
+            showPaywall()
+        }
+    }
+
+    private func openManageSubscriptions() {
+        if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
+            NSWorkspace.shared.open(url)
+        }
+    }
+
     @objc private func refreshMeetingsTapped() {
         Task { @MainActor in
             self.animateMeetingsRefresh()
@@ -1487,6 +1538,44 @@ class ViewController: NSViewController {
         settingsUpgradeButton?.isEnabled = isPremium == false
         settingsUpgradeButton?.alphaValue = isPremium ? 0.6 : 1.0
         settingsRestoreButton?.isEnabled = true
+
+        updateTopBarPremiumButton()
+    }
+
+    @MainActor
+    private func updateTopBarPremiumButton() {
+        guard let button = topBarPremiumButton else { return }
+        let isPremium = storeKitCoordinator.hasPremiumAccess
+        if isPremium {
+            let title = "Manage Subscription"
+            let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
+            button.attributedTitle = NSAttributedString(string: title, attributes: [
+                .foregroundColor: NSColor.white,
+                .font: font
+            ])
+            let symbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
+            if let base = NSImage(systemSymbolName: "crown.fill", accessibilityDescription: "Premium"),
+               let img = base.withSymbolConfiguration(symbolConfig) {
+                img.isTemplate = true
+                button.image = img
+                button.imagePosition = .imageLeading
+                button.imageHugsTitle = true
+            }
+            button.contentTintColor = NSColor.white
+            button.toolTip = title
+            button.layer?.backgroundColor = NSColor(calibratedRed: 214 / 255, green: 175 / 255, blue: 54 / 255, alpha: 1).cgColor
+        } else {
+            let title = "Upgrade to Pro"
+            let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
+            button.attributedTitle = NSAttributedString(string: title, attributes: [
+                .foregroundColor: NSColor.white,
+                .font: font
+            ])
+            button.image = nil
+            button.toolTip = title
+            button.layer?.backgroundColor = accentBlue.cgColor
+            button.contentTintColor = .white
+        }
     }
 
     @objc private func settingsDarkModeToggled(_ sender: NSSwitch) {
@@ -1548,6 +1637,481 @@ class ViewController: NSViewController {
         }
     }
 
+    // MARK: - Paywall (ported from meetings_app)
+
+    private func showPaywall() {
+        if let existing = paywallWindow {
+            refreshPaywallStoreUI()
+            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.hidesOnDeactivate = true
+        panel.isReleasedWhenClosed = 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
+
+        Task { [weak self] in
+            guard let self else { return }
+            await self.storeKitCoordinator.refreshProducts()
+            await MainActor.run {
+                self.refreshPaywallStoreUI()
+            }
+        }
+    }
+
+    @objc private func closePaywallClicked(_ sender: Any?) {
+        paywallWindow?.performClose(nil)
+        paywallWindow = nil
+    }
+
+    private func makePaywallContent() -> NSView {
+        paywallPlanViews.removeAll()
+        premiumPlanByView.removeAll()
+        paywallOfferLabel = nil
+        paywallContinueLabel = nil
+        paywallContinueButton = nil
+        paywallContinueEnabled = true
+
+        let panel = NSView()
+        panel.translatesAutoresizingMaskIntoConstraints = false
+        panel.wantsLayer = true
+        panel.layer?.backgroundColor = appBackground.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: primaryText))
+        let topSpacer = NSView()
+        topSpacer.translatesAutoresizingMaskIntoConstraints = false
+        topRow.addArrangedSubview(topSpacer)
+
+        let closeButton = HoverButton(title: "✕", target: self, action: #selector(closePaywallClicked(_:)))
+        closeButton.translatesAutoresizingMaskIntoConstraints = false
+        closeButton.isBordered = false
+        closeButton.bezelStyle = .regularSquare
+        closeButton.wantsLayer = true
+        closeButton.layer?.cornerRadius = 14
+        closeButton.normalColor = palette.inputBackground
+        closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
+        closeButton.layer?.borderColor = palette.inputBorder.cgColor
+        closeButton.layer?.borderWidth = 1
+        closeButton.font = NSFont.systemFont(ofSize: 13, weight: .bold)
+        closeButton.contentTintColor = secondaryText
+        closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
+        closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
+        topRow.addArrangedSubview(closeButton)
+        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: secondaryText))
+        let benefits = paywallBenefitsSection()
+        contentStack.addArrangedSubview(benefits)
+        contentStack.setCustomSpacing(18, after: benefits)
+
+        let weeklyCard = paywallPlanCard(
+            title: "Weekly",
+            price: "PKR 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: "PKR 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: "PKR 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: "PKR 14,900.00",
+            badge: "Save 50%",
+            badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
+            subtitle: nil,
+            plan: .lifetime,
+            strikePrice: "PKR 29,800.00"
+        )
+        contentStack.addArrangedSubview(lifetimeCard)
+        updatePaywallPlanSelection()
+        contentStack.setCustomSpacing(20, after: lifetimeCard)
+
+        let offer = textLabel(paywallOfferText(for: selectedPremiumPlan), font: NSFont.systemFont(ofSize: 13, weight: .semibold), color: primaryText)
+        offer.alignment = .center
+        paywallOfferLabel = offer
+        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 = HoverButton(title: "", target: self, action: #selector(paywallContinueClicked(_:)))
+        continueButton.translatesAutoresizingMaskIntoConstraints = false
+        continueButton.isBordered = false
+        continueButton.bezelStyle = .regularSquare
+        continueButton.wantsLayer = true
+        continueButton.layer?.cornerRadius = 14
+        continueButton.normalColor = accentBlue
+        continueButton.hoverColor = accentBlue.withAlphaComponent(0.92)
+        continueButton.heightAnchor.constraint(equalToConstant: 44).isActive = true
+        continueButton.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
+        styleSurface(continueButton, borderColor: accentBlue.withAlphaComponent(0.85), 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)
+        ])
+        paywallContinueButton = continueButton
+        paywallContinueLabel = continueLabel
+        contentStack.addArrangedSubview(continueButton)
+        contentStack.setCustomSpacing(16, after: continueButton)
+
+        let secure = textLabel("Secured by Apple. Cancel anytime.", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: secondaryText)
+        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)
+
+        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)
+        ])
+
+        refreshPaywallStoreUI()
+        return panel
+    }
+
+    private 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
+    }
+
+    private func paywallBenefitItem(icon: String, text: String) -> NSView {
+        let card = NSView()
+        card.translatesAutoresizingMaskIntoConstraints = false
+        card.wantsLayer = true
+        card.layer?.cornerRadius = 10
+        card.layer?.backgroundColor = palette.inputBackground.cgColor
+        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: accentBlue)
+        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: primaryText)
+
+        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
+    }
+
+    private func paywallPlanCard(
+        title: String,
+        price: String,
+        badge: String,
+        badgeColor: NSColor,
+        subtitle: String?,
+        plan: PremiumPlan,
+        strikePrice: String?
+    ) -> NSView {
+        let wrapper = HoverButton(title: "", target: self, action: #selector(paywallPlanButtonClicked(_:)))
+        wrapper.translatesAutoresizingMaskIntoConstraints = false
+        wrapper.isBordered = false
+        wrapper.bezelStyle = .regularSquare
+        wrapper.wantsLayer = true
+        wrapper.layer?.backgroundColor = NSColor.clear.cgColor
+        wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
+        wrapper.heightAnchor.constraint(equalToConstant: 94).isActive = true
+        wrapper.tag = PremiumPlan.allCases.firstIndex(of: plan) ?? 0
+
+        let card = NSView()
+        card.translatesAutoresizingMaskIntoConstraints = false
+        card.wantsLayer = true
+        card.layer?.cornerRadius = 16
+        card.layer?.backgroundColor = palette.sectionCard.cgColor
+        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: accentBlue)
+        card.addSubview(titleLabel)
+        let priceLabel = textLabel(price, font: NSFont.systemFont(ofSize: 12, weight: .bold), color: primaryText)
+        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: secondaryText)
+            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)
+            ])
+        }
+
+        paywallPlanViews[plan] = card
+        premiumPlanByView[ObjectIdentifier(card)] = plan
+        wrapper.onHoverChanged = { [weak self, weak card] hovering in
+            guard let self, let card else { return }
+            self.applyPaywallPlanStyle(card, isSelected: plan == self.selectedPremiumPlan, hovering: hovering)
+        }
+        wrapper.onHoverChanged?(false)
+        return wrapper
+    }
+
+    @objc private func paywallPlanButtonClicked(_ sender: NSButton) {
+        guard let plan = PremiumPlan.allCases[safe: sender.tag] else { return }
+        selectedPremiumPlan = plan
+        updatePaywallPlanSelection()
+    }
+
+    private func updatePaywallPlanSelection() {
+        for (plan, view) in paywallPlanViews {
+            applyPaywallPlanStyle(view, isSelected: plan == selectedPremiumPlan)
+        }
+        paywallOfferLabel?.stringValue = paywallOfferText(for: selectedPremiumPlan)
+    }
+
+    private func paywallOfferText(for plan: PremiumPlan) -> String {
+        if storeKitCoordinator.hasPremiumAccess {
+            return "Premium is active on this Apple ID."
+        }
+        if let product = storeKitCoordinator.productsByID[plan.rawValue] {
+            return "\(product.displayPrice) purchase"
+        }
+        switch plan {
+        case .weekly: return "PKR 1,100.00/week"
+        case .monthly: return "PKR 2,500.00/month"
+        case .yearly: return "PKR 9,900.00/year"
+        case .lifetime: return "PKR 14,900.00 one-time purchase"
+        }
+    }
+
+    private func refreshPaywallStoreUI() {
+        updatePaywallPlanSelection()
+        updatePaywallContinueState(isLoading: false)
+    }
+
+    @objc private func paywallContinueClicked(_ sender: Any?) {
+        startSelectedPlanPurchase()
+    }
+
+    private func startSelectedPlanPurchase() {
+        guard paywallContinueEnabled else { return }
+        paywallPurchaseTask?.cancel()
+        updatePaywallContinueState(isLoading: true)
+        let selectedPlan = selectedPremiumPlan
+        paywallPurchaseTask = Task { [weak self] in
+            guard let self else { return }
+            let result = await self.storeKitCoordinator.purchase(plan: selectedPlan)
+            await MainActor.run {
+                self.updatePaywallContinueState(isLoading: false)
+                self.refreshPaywallStoreUI()
+                self.updatePremiumButtons()
+                switch result {
+                case .success:
+                    self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
+                    self.paywallWindow?.performClose(nil)
+                    self.paywallWindow = nil
+                case .pending:
+                    self.showSimpleAlert(title: "Purchase Pending", message: "Your purchase is pending approval. You can continue once it completes.")
+                case .cancelled:
+                    break
+                case .failed(let message):
+                    self.showSimpleAlert(title: "Purchase Failed", message: message)
+                }
+            }
+        }
+    }
+
+    private func updatePaywallContinueState(isLoading: Bool) {
+        if isLoading {
+            paywallContinueEnabled = false
+            paywallContinueLabel?.stringValue = "Processing..."
+            paywallContinueButton?.alphaValue = 0.75
+            return
+        }
+        if storeKitCoordinator.hasPremiumAccess {
+            paywallContinueEnabled = false
+            paywallContinueLabel?.stringValue = "Premium Active"
+            paywallContinueButton?.alphaValue = 0.75
+        } else {
+            paywallContinueEnabled = true
+            paywallContinueLabel?.stringValue = "Continue"
+            paywallContinueButton?.alphaValue = 1.0
+        }
+    }
+
+    private func applyPaywallPlanStyle(_ card: NSView, isSelected: Bool, hovering: Bool = false) {
+        let selectedBorder = NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1)
+        let idleBorder = palette.inputBorder
+        let hoverBlend = palette.isDarkMode ? NSColor.white : NSColor.black
+        let hoverIdleBackground =
+            palette.sectionCard.blended(withFraction: 0.10, of: hoverBlend) ?? palette.sectionCard
+        let selectedBackground = palette.isDarkMode
+            ? NSColor(calibratedRed: 30.0 / 255.0, green: 34.0 / 255.0, blue: 42.0 / 255.0, alpha: 1)
+            : NSColor(calibratedRed: 255.0 / 255.0, green: 246.0 / 255.0, blue: 236.0 / 255.0, alpha: 1)
+        card.layer?.backgroundColor = (isSelected ? selectedBackground : (hovering ? hoverIdleBackground : palette.sectionCard)).cgColor
+        card.layer?.borderColor = (isSelected ? selectedBorder : (hovering ? selectedBorder.withAlphaComponent(0.55) : idleBorder)).cgColor
+        card.layer?.borderWidth = isSelected ? 2 : 1
+        card.layer?.shadowColor = NSColor.black.cgColor
+        card.layer?.shadowOpacity = isSelected ? (palette.isDarkMode ? 0.26 : 0.10) : (hovering ? 0.18 : 0.12)
+        card.layer?.shadowOffset = CGSize(width: 0, height: -1)
+        card.layer?.shadowRadius = isSelected ? (palette.isDarkMode ? 10 : 6) : (hovering ? 7 : 5)
+    }
+
     // MARK: - Home UI
 
     private func makeHomeView(profile: GoogleUserProfile?) -> NSView {
@@ -1701,7 +2265,9 @@ class ViewController: NSViewController {
         rightTopBarCluster.orientation = .horizontal
         rightTopBarCluster.spacing = 10
         rightTopBarCluster.alignment = .centerY
-        let upgradeToProButton = makeUpgradeToProButton(action: #selector(topBarPlaceholderTapped))
+        let upgradeToProButton = makeUpgradeToProButton(action: #selector(upgradeToProTapped))
+        topBarPremiumButton = upgradeToProButton
+        updateTopBarPremiumButton()
 
         let profileChip = NSButton(title: String((profile?.name ?? "H").prefix(1)).uppercased(), target: self, action: #selector(logoutTapped))
         profileChip.isBordered = false
@@ -2508,6 +3074,13 @@ class ViewController: NSViewController {
     }
 }
 
+private extension Array {
+    subscript(safe index: Int) -> Element? {
+        guard index >= 0, index < count else { return nil }
+        return self[index]
+    }
+}
+
 private final class SearchPillTextField: NSTextField {
     var onFocusChange: ((Bool) -> Void)?
     private(set) var isSearchFocused = false