Parcourir la Source

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 il y a 5 jours
Parent
commit
6f35c51c71
1 fichiers modifiés avec 580 ajouts et 7 suppressions
  1. 580 7
      zoom_app/ViewController.swift

+ 580 - 7
zoom_app/ViewController.swift

@@ -96,6 +96,7 @@ class ViewController: NSViewController {
96 96
     private final class HoverButton: NSButton {
97 97
         var normalColor: NSColor = .clear { didSet { applyBackground() } }
98 98
         var hoverColor: NSColor = .clear
99
+        var onHoverChanged: ((Bool) -> Void)?
99 100
         private var tracking: NSTrackingArea?
100 101
         private var hovering = false { didSet { applyBackground() } }
101 102
 
@@ -109,10 +110,12 @@ class ViewController: NSViewController {
109 110
 
110 111
         override func mouseEntered(with event: NSEvent) {
111 112
             hovering = true
113
+            onHoverChanged?(true)
112 114
         }
113 115
 
114 116
         override func mouseExited(with event: NSEvent) {
115 117
             hovering = false
118
+            onHoverChanged?(false)
116 119
         }
117 120
 
118 121
         private func applyBackground() {
@@ -183,6 +186,17 @@ class ViewController: NSViewController {
183 186
     private weak var settingsUpgradeButton: NSButton?
184 187
     private weak var settingsRestoreButton: NSButton?
185 188
     private weak var settingsGoogleActionButton: NSButton?
189
+    private weak var topBarPremiumButton: NSButton?
190
+    private var paywallWindow: NSWindow?
191
+    private let paywallContentWidth: CGFloat = 520
192
+    private var selectedPremiumPlan: PremiumPlan = .monthly
193
+    private var paywallPlanViews: [PremiumPlan: NSView] = [:]
194
+    private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
195
+    private weak var paywallOfferLabel: NSTextField?
196
+    private weak var paywallContinueLabel: NSTextField?
197
+    private weak var paywallContinueButton: NSView?
198
+    private var paywallPurchaseTask: Task<Void, Never>?
199
+    private var paywallContinueEnabled = true
186 200
     private var allScheduledMeetings: [ScheduledMeeting] = []
187 201
     private var selectedMeetingsDayStart: Date = Calendar.current.startOfDay(for: Date())
188 202
     private var selectedHomeSidebarItem: String = "Home"
@@ -314,20 +328,41 @@ class ViewController: NSViewController {
314 328
 
315 329
         @MainActor
316 330
         private func refreshEntitlements() async {
331
+            let previousHasPremiumAccess = hasPremiumAccess
332
+            let allIDs = Set(PremiumPlan.allCases.map(\.rawValue))
317 333
             var active = Set<String>()
334
+
318 335
             for await entitlement in Transaction.currentEntitlements {
319 336
                 guard case .verified(let transaction) = entitlement else { continue }
320
-                if PremiumPlan.allCases.map(\.rawValue).contains(transaction.productID),
321
-                   transaction.revocationDate == nil {
337
+                guard allIDs.contains(transaction.productID) else { continue }
338
+                if Self.isTransactionActive(transaction) {
322 339
                     active.insert(transaction.productID)
323 340
                 }
324 341
             }
325
-            let changed = active != activeProductIDs
342
+
343
+            // StoreKit testing can briefly report empty current entitlements even though a latest
344
+            // verified transaction exists for a non-consumable. Merge in latest transactions.
345
+            for productID in allIDs {
346
+                guard let latest = await Transaction.latest(for: productID),
347
+                      case .verified(let transaction) = latest,
348
+                      Self.isTransactionActive(transaction) else { continue }
349
+                active.insert(productID)
350
+            }
351
+
326 352
             activeProductIDs = active
327
-            if changed {
328
-                onEntitlementsChanged?(hasPremiumAccess)
353
+            let newHasPremiumAccess = hasPremiumAccess
354
+            if newHasPremiumAccess != previousHasPremiumAccess {
355
+                onEntitlementsChanged?(newHasPremiumAccess)
329 356
             }
330 357
         }
358
+
359
+        private static func isTransactionActive(_ transaction: Transaction) -> Bool {
360
+            if transaction.revocationDate != nil { return false }
361
+            if let expirationDate = transaction.expirationDate {
362
+                return expirationDate > Date()
363
+            }
364
+            return true
365
+        }
331 366
     }
332 367
 
333 368
     private let storeKitCoordinator = StoreKitCoordinator()
@@ -346,7 +381,9 @@ class ViewController: NSViewController {
346 381
         super.viewDidAppear()
347 382
         if let window = view.window {
348 383
             window.setContentSize(NSSize(width: 1020, height: 690))
349
-            window.backgroundColor = chromeUnifiedBackground
384
+            // Match the outer window background to the app background so rounded shell corners
385
+            // don't reveal a lighter window color in Dark Mode.
386
+            window.backgroundColor = appBackground
350 387
             // Use full-size content view so custom top chrome sits in the titlebar region.
351 388
             window.titleVisibility = .hidden
352 389
             window.titlebarAppearsTransparent = true
@@ -528,6 +565,20 @@ class ViewController: NSViewController {
528 565
         // Reserved for future titlebar control actions.
529 566
     }
530 567
 
568
+    @objc private func upgradeToProTapped() {
569
+        if storeKitCoordinator.hasPremiumAccess {
570
+            openManageSubscriptions()
571
+        } else {
572
+            showPaywall()
573
+        }
574
+    }
575
+
576
+    private func openManageSubscriptions() {
577
+        if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
578
+            NSWorkspace.shared.open(url)
579
+        }
580
+    }
581
+
531 582
     @objc private func refreshMeetingsTapped() {
532 583
         Task { @MainActor in
533 584
             self.animateMeetingsRefresh()
@@ -1487,6 +1538,44 @@ class ViewController: NSViewController {
1487 1538
         settingsUpgradeButton?.isEnabled = isPremium == false
1488 1539
         settingsUpgradeButton?.alphaValue = isPremium ? 0.6 : 1.0
1489 1540
         settingsRestoreButton?.isEnabled = true
1541
+
1542
+        updateTopBarPremiumButton()
1543
+    }
1544
+
1545
+    @MainActor
1546
+    private func updateTopBarPremiumButton() {
1547
+        guard let button = topBarPremiumButton else { return }
1548
+        let isPremium = storeKitCoordinator.hasPremiumAccess
1549
+        if isPremium {
1550
+            let title = "Manage Subscription"
1551
+            let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
1552
+            button.attributedTitle = NSAttributedString(string: title, attributes: [
1553
+                .foregroundColor: NSColor.white,
1554
+                .font: font
1555
+            ])
1556
+            let symbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
1557
+            if let base = NSImage(systemSymbolName: "crown.fill", accessibilityDescription: "Premium"),
1558
+               let img = base.withSymbolConfiguration(symbolConfig) {
1559
+                img.isTemplate = true
1560
+                button.image = img
1561
+                button.imagePosition = .imageLeading
1562
+                button.imageHugsTitle = true
1563
+            }
1564
+            button.contentTintColor = NSColor.white
1565
+            button.toolTip = title
1566
+            button.layer?.backgroundColor = NSColor(calibratedRed: 214 / 255, green: 175 / 255, blue: 54 / 255, alpha: 1).cgColor
1567
+        } else {
1568
+            let title = "Upgrade to Pro"
1569
+            let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
1570
+            button.attributedTitle = NSAttributedString(string: title, attributes: [
1571
+                .foregroundColor: NSColor.white,
1572
+                .font: font
1573
+            ])
1574
+            button.image = nil
1575
+            button.toolTip = title
1576
+            button.layer?.backgroundColor = accentBlue.cgColor
1577
+            button.contentTintColor = .white
1578
+        }
1490 1579
     }
1491 1580
 
1492 1581
     @objc private func settingsDarkModeToggled(_ sender: NSSwitch) {
@@ -1548,6 +1637,481 @@ class ViewController: NSViewController {
1548 1637
         }
1549 1638
     }
1550 1639
 
1640
+    // MARK: - Paywall (ported from meetings_app)
1641
+
1642
+    private func showPaywall() {
1643
+        if let existing = paywallWindow {
1644
+            refreshPaywallStoreUI()
1645
+            existing.makeKeyAndOrderFront(nil)
1646
+            NSApp.activate(ignoringOtherApps: true)
1647
+            return
1648
+        }
1649
+
1650
+        let content = makePaywallContent()
1651
+        let controller = NSViewController()
1652
+        controller.view = content
1653
+
1654
+        let panel = NSPanel(
1655
+            contentRect: NSRect(x: 0, y: 0, width: 640, height: 820),
1656
+            styleMask: [.titled, .closable, .fullSizeContentView],
1657
+            backing: .buffered,
1658
+            defer: false
1659
+        )
1660
+        panel.title = "Get Premium"
1661
+        panel.titleVisibility = .hidden
1662
+        panel.titlebarAppearsTransparent = true
1663
+        panel.hidesOnDeactivate = true
1664
+        panel.isReleasedWhenClosed = false
1665
+        panel.standardWindowButton(.closeButton)?.isHidden = true
1666
+        panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
1667
+        panel.standardWindowButton(.zoomButton)?.isHidden = true
1668
+        panel.center()
1669
+        panel.contentViewController = controller
1670
+        panel.makeKeyAndOrderFront(nil)
1671
+        NSApp.activate(ignoringOtherApps: true)
1672
+        paywallWindow = panel
1673
+
1674
+        Task { [weak self] in
1675
+            guard let self else { return }
1676
+            await self.storeKitCoordinator.refreshProducts()
1677
+            await MainActor.run {
1678
+                self.refreshPaywallStoreUI()
1679
+            }
1680
+        }
1681
+    }
1682
+
1683
+    @objc private func closePaywallClicked(_ sender: Any?) {
1684
+        paywallWindow?.performClose(nil)
1685
+        paywallWindow = nil
1686
+    }
1687
+
1688
+    private func makePaywallContent() -> NSView {
1689
+        paywallPlanViews.removeAll()
1690
+        premiumPlanByView.removeAll()
1691
+        paywallOfferLabel = nil
1692
+        paywallContinueLabel = nil
1693
+        paywallContinueButton = nil
1694
+        paywallContinueEnabled = true
1695
+
1696
+        let panel = NSView()
1697
+        panel.translatesAutoresizingMaskIntoConstraints = false
1698
+        panel.wantsLayer = true
1699
+        panel.layer?.backgroundColor = appBackground.cgColor
1700
+
1701
+        let contentStack = NSStackView()
1702
+        contentStack.translatesAutoresizingMaskIntoConstraints = false
1703
+        contentStack.orientation = .vertical
1704
+        contentStack.spacing = 12
1705
+        contentStack.alignment = .leading
1706
+        panel.addSubview(contentStack)
1707
+
1708
+        let topRow = NSStackView()
1709
+        topRow.translatesAutoresizingMaskIntoConstraints = false
1710
+        topRow.orientation = .horizontal
1711
+        topRow.alignment = .centerY
1712
+        topRow.distribution = .fill
1713
+        topRow.spacing = 10
1714
+        topRow.addArrangedSubview(textLabel("Get Premium", font: NSFont.systemFont(ofSize: 24, weight: .bold), color: primaryText))
1715
+        let topSpacer = NSView()
1716
+        topSpacer.translatesAutoresizingMaskIntoConstraints = false
1717
+        topRow.addArrangedSubview(topSpacer)
1718
+
1719
+        let closeButton = HoverButton(title: "✕", target: self, action: #selector(closePaywallClicked(_:)))
1720
+        closeButton.translatesAutoresizingMaskIntoConstraints = false
1721
+        closeButton.isBordered = false
1722
+        closeButton.bezelStyle = .regularSquare
1723
+        closeButton.wantsLayer = true
1724
+        closeButton.layer?.cornerRadius = 14
1725
+        closeButton.normalColor = palette.inputBackground
1726
+        closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
1727
+        closeButton.layer?.borderColor = palette.inputBorder.cgColor
1728
+        closeButton.layer?.borderWidth = 1
1729
+        closeButton.font = NSFont.systemFont(ofSize: 13, weight: .bold)
1730
+        closeButton.contentTintColor = secondaryText
1731
+        closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
1732
+        closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
1733
+        topRow.addArrangedSubview(closeButton)
1734
+        topRow.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
1735
+        contentStack.addArrangedSubview(topRow)
1736
+
1737
+        contentStack.addArrangedSubview(textLabel("Upgrade to unlock premium features.", font: NSFont.systemFont(ofSize: 12, weight: .medium), color: secondaryText))
1738
+        let benefits = paywallBenefitsSection()
1739
+        contentStack.addArrangedSubview(benefits)
1740
+        contentStack.setCustomSpacing(18, after: benefits)
1741
+
1742
+        let weeklyCard = paywallPlanCard(
1743
+            title: "Weekly",
1744
+            price: "PKR 1,100.00",
1745
+            badge: "Basic Deal",
1746
+            badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
1747
+            subtitle: nil,
1748
+            plan: .weekly,
1749
+            strikePrice: nil
1750
+        )
1751
+        contentStack.addArrangedSubview(weeklyCard)
1752
+
1753
+        let monthlyCard = paywallPlanCard(
1754
+            title: "Monthly",
1755
+            price: "PKR 2,500.00",
1756
+            badge: "Free Trial",
1757
+            badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
1758
+            subtitle: "625.00/week",
1759
+            plan: .monthly,
1760
+            strikePrice: nil
1761
+        )
1762
+        contentStack.addArrangedSubview(monthlyCard)
1763
+
1764
+        let yearlyCard = paywallPlanCard(
1765
+            title: "Yearly",
1766
+            price: "PKR 9,900.00",
1767
+            badge: "Best Deal",
1768
+            badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
1769
+            subtitle: "190.38/week",
1770
+            plan: .yearly,
1771
+            strikePrice: nil
1772
+        )
1773
+        contentStack.addArrangedSubview(yearlyCard)
1774
+
1775
+        let lifetimeCard = paywallPlanCard(
1776
+            title: "Lifetime",
1777
+            price: "PKR 14,900.00",
1778
+            badge: "Save 50%",
1779
+            badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
1780
+            subtitle: nil,
1781
+            plan: .lifetime,
1782
+            strikePrice: "PKR 29,800.00"
1783
+        )
1784
+        contentStack.addArrangedSubview(lifetimeCard)
1785
+        updatePaywallPlanSelection()
1786
+        contentStack.setCustomSpacing(20, after: lifetimeCard)
1787
+
1788
+        let offer = textLabel(paywallOfferText(for: selectedPremiumPlan), font: NSFont.systemFont(ofSize: 13, weight: .semibold), color: primaryText)
1789
+        offer.alignment = .center
1790
+        paywallOfferLabel = offer
1791
+        let offerWrap = NSView()
1792
+        offerWrap.translatesAutoresizingMaskIntoConstraints = false
1793
+        offerWrap.addSubview(offer)
1794
+        NSLayoutConstraint.activate([
1795
+            offerWrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth),
1796
+            offer.centerXAnchor.constraint(equalTo: offerWrap.centerXAnchor),
1797
+            offer.topAnchor.constraint(equalTo: offerWrap.topAnchor, constant: 6),
1798
+            offer.bottomAnchor.constraint(equalTo: offerWrap.bottomAnchor, constant: -2)
1799
+        ])
1800
+        contentStack.addArrangedSubview(offerWrap)
1801
+        contentStack.setCustomSpacing(18, after: offerWrap)
1802
+
1803
+        let continueButton = HoverButton(title: "", target: self, action: #selector(paywallContinueClicked(_:)))
1804
+        continueButton.translatesAutoresizingMaskIntoConstraints = false
1805
+        continueButton.isBordered = false
1806
+        continueButton.bezelStyle = .regularSquare
1807
+        continueButton.wantsLayer = true
1808
+        continueButton.layer?.cornerRadius = 14
1809
+        continueButton.normalColor = accentBlue
1810
+        continueButton.hoverColor = accentBlue.withAlphaComponent(0.92)
1811
+        continueButton.heightAnchor.constraint(equalToConstant: 44).isActive = true
1812
+        continueButton.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
1813
+        styleSurface(continueButton, borderColor: accentBlue.withAlphaComponent(0.85), borderWidth: 1, shadow: true)
1814
+        let continueLabel = textLabel("Continue", font: NSFont.systemFont(ofSize: 16, weight: .bold), color: .white)
1815
+        continueButton.addSubview(continueLabel)
1816
+        NSLayoutConstraint.activate([
1817
+            continueLabel.centerXAnchor.constraint(equalTo: continueButton.centerXAnchor),
1818
+            continueLabel.centerYAnchor.constraint(equalTo: continueButton.centerYAnchor)
1819
+        ])
1820
+        paywallContinueButton = continueButton
1821
+        paywallContinueLabel = continueLabel
1822
+        contentStack.addArrangedSubview(continueButton)
1823
+        contentStack.setCustomSpacing(16, after: continueButton)
1824
+
1825
+        let secure = textLabel("Secured by Apple. Cancel anytime.", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: secondaryText)
1826
+        secure.alignment = .center
1827
+        let secureWrap = NSView()
1828
+        secureWrap.translatesAutoresizingMaskIntoConstraints = false
1829
+        secureWrap.addSubview(secure)
1830
+        NSLayoutConstraint.activate([
1831
+            secureWrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth),
1832
+            secure.centerXAnchor.constraint(equalTo: secureWrap.centerXAnchor),
1833
+            secure.topAnchor.constraint(equalTo: secureWrap.topAnchor, constant: 4),
1834
+            secure.bottomAnchor.constraint(equalTo: secureWrap.bottomAnchor, constant: -8)
1835
+        ])
1836
+        contentStack.addArrangedSubview(secureWrap)
1837
+
1838
+        NSLayoutConstraint.activate([
1839
+            contentStack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 18),
1840
+            contentStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -18),
1841
+            contentStack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 16),
1842
+            contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -12)
1843
+        ])
1844
+
1845
+        refreshPaywallStoreUI()
1846
+        return panel
1847
+    }
1848
+
1849
+    private func paywallBenefitsSection() -> NSView {
1850
+        let stack = NSStackView()
1851
+        stack.translatesAutoresizingMaskIntoConstraints = false
1852
+        stack.orientation = .vertical
1853
+        stack.spacing = 8
1854
+        stack.alignment = .leading
1855
+        stack.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
1856
+
1857
+        let rowOne = NSStackView()
1858
+        rowOne.translatesAutoresizingMaskIntoConstraints = false
1859
+        rowOne.orientation = .horizontal
1860
+        rowOne.spacing = 10
1861
+        rowOne.distribution = .fillEqually
1862
+        rowOne.alignment = .centerY
1863
+        rowOne.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Manage meetings"))
1864
+        rowOne.addArrangedSubview(paywallBenefitItem(icon: "🖼️", text: "Virtual backgrounds"))
1865
+
1866
+        let rowTwo = NSStackView()
1867
+        rowTwo.translatesAutoresizingMaskIntoConstraints = false
1868
+        rowTwo.orientation = .horizontal
1869
+        rowTwo.spacing = 10
1870
+        rowTwo.distribution = .fillEqually
1871
+        rowTwo.alignment = .centerY
1872
+        rowTwo.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Tools for productivity"))
1873
+        rowTwo.addArrangedSubview(paywallBenefitItem(icon: "🛟", text: "24/7 support"))
1874
+
1875
+        stack.addArrangedSubview(rowOne)
1876
+        stack.addArrangedSubview(rowTwo)
1877
+        return stack
1878
+    }
1879
+
1880
+    private func paywallBenefitItem(icon: String, text: String) -> NSView {
1881
+        let card = NSView()
1882
+        card.translatesAutoresizingMaskIntoConstraints = false
1883
+        card.wantsLayer = true
1884
+        card.layer?.cornerRadius = 10
1885
+        card.layer?.backgroundColor = palette.inputBackground.cgColor
1886
+        card.heightAnchor.constraint(equalToConstant: 36).isActive = true
1887
+        styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
1888
+
1889
+        let iconWrap = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
1890
+        iconWrap.translatesAutoresizingMaskIntoConstraints = false
1891
+        iconWrap.widthAnchor.constraint(equalToConstant: 24).isActive = true
1892
+        iconWrap.heightAnchor.constraint(equalToConstant: 24).isActive = true
1893
+        styleSurface(iconWrap, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
1894
+
1895
+        let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: accentBlue)
1896
+        iconWrap.addSubview(iconLabel)
1897
+        NSLayoutConstraint.activate([
1898
+            iconLabel.centerXAnchor.constraint(equalTo: iconWrap.centerXAnchor),
1899
+            iconLabel.centerYAnchor.constraint(equalTo: iconWrap.centerYAnchor)
1900
+        ])
1901
+
1902
+        let title = textLabel(text, font: NSFont.systemFont(ofSize: 11, weight: .medium), color: primaryText)
1903
+
1904
+        card.addSubview(iconWrap)
1905
+        card.addSubview(title)
1906
+        NSLayoutConstraint.activate([
1907
+            iconWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 8),
1908
+            iconWrap.centerYAnchor.constraint(equalTo: card.centerYAnchor),
1909
+            title.leadingAnchor.constraint(equalTo: iconWrap.trailingAnchor, constant: 10),
1910
+            title.centerYAnchor.constraint(equalTo: card.centerYAnchor),
1911
+            title.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -8)
1912
+        ])
1913
+
1914
+        return card
1915
+    }
1916
+
1917
+    private func paywallPlanCard(
1918
+        title: String,
1919
+        price: String,
1920
+        badge: String,
1921
+        badgeColor: NSColor,
1922
+        subtitle: String?,
1923
+        plan: PremiumPlan,
1924
+        strikePrice: String?
1925
+    ) -> NSView {
1926
+        let wrapper = HoverButton(title: "", target: self, action: #selector(paywallPlanButtonClicked(_:)))
1927
+        wrapper.translatesAutoresizingMaskIntoConstraints = false
1928
+        wrapper.isBordered = false
1929
+        wrapper.bezelStyle = .regularSquare
1930
+        wrapper.wantsLayer = true
1931
+        wrapper.layer?.backgroundColor = NSColor.clear.cgColor
1932
+        wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
1933
+        wrapper.heightAnchor.constraint(equalToConstant: 94).isActive = true
1934
+        wrapper.tag = PremiumPlan.allCases.firstIndex(of: plan) ?? 0
1935
+
1936
+        let card = NSView()
1937
+        card.translatesAutoresizingMaskIntoConstraints = false
1938
+        card.wantsLayer = true
1939
+        card.layer?.cornerRadius = 16
1940
+        card.layer?.backgroundColor = palette.sectionCard.cgColor
1941
+        card.heightAnchor.constraint(equalToConstant: 82).isActive = true
1942
+        wrapper.addSubview(card)
1943
+        NSLayoutConstraint.activate([
1944
+            card.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
1945
+            card.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
1946
+            card.topAnchor.constraint(equalTo: wrapper.topAnchor, constant: 12),
1947
+            card.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
1948
+        ])
1949
+        styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
1950
+
1951
+        let badgeLabel = textLabel(badge, font: NSFont.systemFont(ofSize: 10, weight: .bold), color: .white)
1952
+        let badgeWrap = roundedContainer(cornerRadius: 10, color: badgeColor)
1953
+        badgeWrap.translatesAutoresizingMaskIntoConstraints = false
1954
+        badgeWrap.wantsLayer = true
1955
+        badgeWrap.layer?.borderColor = NSColor(calibratedWhite: 1, alpha: 0.22).cgColor
1956
+        badgeWrap.layer?.borderWidth = 1
1957
+        badgeWrap.layer?.shadowColor = NSColor.black.cgColor
1958
+        badgeWrap.layer?.shadowOpacity = 0.20
1959
+        badgeWrap.layer?.shadowOffset = CGSize(width: 0, height: -1)
1960
+        badgeWrap.layer?.shadowRadius = 3
1961
+        badgeWrap.addSubview(badgeLabel)
1962
+        NSLayoutConstraint.activate([
1963
+            badgeLabel.leadingAnchor.constraint(equalTo: badgeWrap.leadingAnchor, constant: 8),
1964
+            badgeLabel.trailingAnchor.constraint(equalTo: badgeWrap.trailingAnchor, constant: -8),
1965
+            badgeLabel.topAnchor.constraint(equalTo: badgeWrap.topAnchor, constant: 2),
1966
+            badgeLabel.bottomAnchor.constraint(equalTo: badgeWrap.bottomAnchor, constant: -2)
1967
+        ])
1968
+        wrapper.addSubview(badgeWrap)
1969
+
1970
+        let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: .bold), color: accentBlue)
1971
+        card.addSubview(titleLabel)
1972
+        let priceLabel = textLabel(price, font: NSFont.systemFont(ofSize: 12, weight: .bold), color: primaryText)
1973
+        card.addSubview(priceLabel)
1974
+
1975
+        NSLayoutConstraint.activate([
1976
+            badgeWrap.centerXAnchor.constraint(equalTo: card.centerXAnchor),
1977
+            badgeWrap.centerYAnchor.constraint(equalTo: card.topAnchor),
1978
+
1979
+            titleLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
1980
+            titleLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 34),
1981
+
1982
+            priceLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
1983
+            priceLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 32)
1984
+        ])
1985
+
1986
+        if let subtitle {
1987
+            let sub = textLabel(subtitle, font: NSFont.systemFont(ofSize: 10, weight: .semibold), color: secondaryText)
1988
+            card.addSubview(sub)
1989
+            NSLayoutConstraint.activate([
1990
+                sub.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
1991
+                sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 0)
1992
+            ])
1993
+        }
1994
+
1995
+        if let strikePrice {
1996
+            let strike = textLabel(strikePrice, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: NSColor.systemRed)
1997
+            card.addSubview(strike)
1998
+            NSLayoutConstraint.activate([
1999
+                strike.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
2000
+                strike.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 4)
2001
+            ])
2002
+        }
2003
+
2004
+        paywallPlanViews[plan] = card
2005
+        premiumPlanByView[ObjectIdentifier(card)] = plan
2006
+        wrapper.onHoverChanged = { [weak self, weak card] hovering in
2007
+            guard let self, let card else { return }
2008
+            self.applyPaywallPlanStyle(card, isSelected: plan == self.selectedPremiumPlan, hovering: hovering)
2009
+        }
2010
+        wrapper.onHoverChanged?(false)
2011
+        return wrapper
2012
+    }
2013
+
2014
+    @objc private func paywallPlanButtonClicked(_ sender: NSButton) {
2015
+        guard let plan = PremiumPlan.allCases[safe: sender.tag] else { return }
2016
+        selectedPremiumPlan = plan
2017
+        updatePaywallPlanSelection()
2018
+    }
2019
+
2020
+    private func updatePaywallPlanSelection() {
2021
+        for (plan, view) in paywallPlanViews {
2022
+            applyPaywallPlanStyle(view, isSelected: plan == selectedPremiumPlan)
2023
+        }
2024
+        paywallOfferLabel?.stringValue = paywallOfferText(for: selectedPremiumPlan)
2025
+    }
2026
+
2027
+    private func paywallOfferText(for plan: PremiumPlan) -> String {
2028
+        if storeKitCoordinator.hasPremiumAccess {
2029
+            return "Premium is active on this Apple ID."
2030
+        }
2031
+        if let product = storeKitCoordinator.productsByID[plan.rawValue] {
2032
+            return "\(product.displayPrice) purchase"
2033
+        }
2034
+        switch plan {
2035
+        case .weekly: return "PKR 1,100.00/week"
2036
+        case .monthly: return "PKR 2,500.00/month"
2037
+        case .yearly: return "PKR 9,900.00/year"
2038
+        case .lifetime: return "PKR 14,900.00 one-time purchase"
2039
+        }
2040
+    }
2041
+
2042
+    private func refreshPaywallStoreUI() {
2043
+        updatePaywallPlanSelection()
2044
+        updatePaywallContinueState(isLoading: false)
2045
+    }
2046
+
2047
+    @objc private func paywallContinueClicked(_ sender: Any?) {
2048
+        startSelectedPlanPurchase()
2049
+    }
2050
+
2051
+    private func startSelectedPlanPurchase() {
2052
+        guard paywallContinueEnabled else { return }
2053
+        paywallPurchaseTask?.cancel()
2054
+        updatePaywallContinueState(isLoading: true)
2055
+        let selectedPlan = selectedPremiumPlan
2056
+        paywallPurchaseTask = Task { [weak self] in
2057
+            guard let self else { return }
2058
+            let result = await self.storeKitCoordinator.purchase(plan: selectedPlan)
2059
+            await MainActor.run {
2060
+                self.updatePaywallContinueState(isLoading: false)
2061
+                self.refreshPaywallStoreUI()
2062
+                self.updatePremiumButtons()
2063
+                switch result {
2064
+                case .success:
2065
+                    self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
2066
+                    self.paywallWindow?.performClose(nil)
2067
+                    self.paywallWindow = nil
2068
+                case .pending:
2069
+                    self.showSimpleAlert(title: "Purchase Pending", message: "Your purchase is pending approval. You can continue once it completes.")
2070
+                case .cancelled:
2071
+                    break
2072
+                case .failed(let message):
2073
+                    self.showSimpleAlert(title: "Purchase Failed", message: message)
2074
+                }
2075
+            }
2076
+        }
2077
+    }
2078
+
2079
+    private func updatePaywallContinueState(isLoading: Bool) {
2080
+        if isLoading {
2081
+            paywallContinueEnabled = false
2082
+            paywallContinueLabel?.stringValue = "Processing..."
2083
+            paywallContinueButton?.alphaValue = 0.75
2084
+            return
2085
+        }
2086
+        if storeKitCoordinator.hasPremiumAccess {
2087
+            paywallContinueEnabled = false
2088
+            paywallContinueLabel?.stringValue = "Premium Active"
2089
+            paywallContinueButton?.alphaValue = 0.75
2090
+        } else {
2091
+            paywallContinueEnabled = true
2092
+            paywallContinueLabel?.stringValue = "Continue"
2093
+            paywallContinueButton?.alphaValue = 1.0
2094
+        }
2095
+    }
2096
+
2097
+    private func applyPaywallPlanStyle(_ card: NSView, isSelected: Bool, hovering: Bool = false) {
2098
+        let selectedBorder = NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1)
2099
+        let idleBorder = palette.inputBorder
2100
+        let hoverBlend = palette.isDarkMode ? NSColor.white : NSColor.black
2101
+        let hoverIdleBackground =
2102
+            palette.sectionCard.blended(withFraction: 0.10, of: hoverBlend) ?? palette.sectionCard
2103
+        let selectedBackground = palette.isDarkMode
2104
+            ? NSColor(calibratedRed: 30.0 / 255.0, green: 34.0 / 255.0, blue: 42.0 / 255.0, alpha: 1)
2105
+            : NSColor(calibratedRed: 255.0 / 255.0, green: 246.0 / 255.0, blue: 236.0 / 255.0, alpha: 1)
2106
+        card.layer?.backgroundColor = (isSelected ? selectedBackground : (hovering ? hoverIdleBackground : palette.sectionCard)).cgColor
2107
+        card.layer?.borderColor = (isSelected ? selectedBorder : (hovering ? selectedBorder.withAlphaComponent(0.55) : idleBorder)).cgColor
2108
+        card.layer?.borderWidth = isSelected ? 2 : 1
2109
+        card.layer?.shadowColor = NSColor.black.cgColor
2110
+        card.layer?.shadowOpacity = isSelected ? (palette.isDarkMode ? 0.26 : 0.10) : (hovering ? 0.18 : 0.12)
2111
+        card.layer?.shadowOffset = CGSize(width: 0, height: -1)
2112
+        card.layer?.shadowRadius = isSelected ? (palette.isDarkMode ? 10 : 6) : (hovering ? 7 : 5)
2113
+    }
2114
+
1551 2115
     // MARK: - Home UI
1552 2116
 
1553 2117
     private func makeHomeView(profile: GoogleUserProfile?) -> NSView {
@@ -1701,7 +2265,9 @@ class ViewController: NSViewController {
1701 2265
         rightTopBarCluster.orientation = .horizontal
1702 2266
         rightTopBarCluster.spacing = 10
1703 2267
         rightTopBarCluster.alignment = .centerY
1704
-        let upgradeToProButton = makeUpgradeToProButton(action: #selector(topBarPlaceholderTapped))
2268
+        let upgradeToProButton = makeUpgradeToProButton(action: #selector(upgradeToProTapped))
2269
+        topBarPremiumButton = upgradeToProButton
2270
+        updateTopBarPremiumButton()
1705 2271
 
1706 2272
         let profileChip = NSButton(title: String((profile?.name ?? "H").prefix(1)).uppercased(), target: self, action: #selector(logoutTapped))
1707 2273
         profileChip.isBordered = false
@@ -2508,6 +3074,13 @@ class ViewController: NSViewController {
2508 3074
     }
2509 3075
 }
2510 3076
 
3077
+private extension Array {
3078
+    subscript(safe index: Int) -> Element? {
3079
+        guard index >= 0, index < count else { return nil }
3080
+        return self[index]
3081
+    }
3082
+}
3083
+
2511 3084
 private final class SearchPillTextField: NSTextField {
2512 3085
     var onFocusChange: ((Bool) -> Void)?
2513 3086
     private(set) var isSearchFocused = false