Procházet zdrojové kódy

Redesign paywall to match the classroom landscape experience.

Align package, free-trial, and footer actions by adding manage and restore flows and updating the paywall interaction model.

Made-with: Cursor
huzaifahayat12 před 1 měsícem
rodič
revize
ac818bbe83
1 změnil soubory, kde provedl 330 přidání a 140 odebrání
  1. 330 140
      meetings_app/ViewController.swift

+ 330 - 140
meetings_app/ViewController.swift

@@ -226,6 +226,15 @@ private final class StoreKitCoordinator {
226 226
 }
227 227
 
228 228
 final class ViewController: NSViewController {
229
+    private enum PaywallFooterAction {
230
+        case manageSubscription
231
+        case restorePurchase
232
+        case continueWithFreePlan
233
+        case privacyPolicy
234
+        case support
235
+        case termsOfServices
236
+    }
237
+
229 238
     private struct GoogleProfileDisplay {
230 239
         let name: String
231 240
         let email: String
@@ -249,11 +258,13 @@ final class ViewController: NSViewController {
249 258
     private var zoomJoinModeViews: [ZoomJoinMode: NSView] = [:]
250 259
     private var settingsActionByView = [ObjectIdentifier: SettingsAction]()
251 260
     private var paywallWindow: NSWindow?
261
+    private weak var paywallOverlayView: NSView?
252 262
     private let paywallContentWidth: CGFloat = 520
253 263
     private let launchWindowLeftOffset: CGFloat = 80
254 264
     private var selectedPremiumPlan: PremiumPlan = .monthly
255 265
     private var paywallPlanViews: [PremiumPlan: NSView] = [:]
256 266
     private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
267
+    private var paywallFooterActionByView = [ObjectIdentifier: PaywallFooterAction]()
257 268
     private weak var paywallOfferLabel: NSTextField?
258 269
     private weak var paywallContinueLabel: NSTextField?
259 270
     private weak var paywallContinueButton: NSView?
@@ -745,6 +756,10 @@ private extension ViewController {
745 756
     }
746 757
 
747 758
     private func openManageSubscriptions() {
759
+        if let appStoreURL = URL(string: "macappstore://apps.apple.com/account/subscriptions"),
760
+           NSWorkspace.shared.open(appStoreURL) {
761
+            return
762
+        }
748 763
         guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else {
749 764
             showSimpleAlert(title: "Unable to Open Subscriptions", message: "The subscriptions URL is invalid.")
750 765
             return
@@ -752,6 +767,27 @@ private extension ViewController {
752 767
         openInDefaultBrowser(url: url)
753 768
     }
754 769
 
770
+    private func openRestoreSubscriptionPage() {
771
+        let fallbackURL = "https://support.apple.com/en-us/108096"
772
+        let restoreURL = (Bundle.main.object(forInfoDictionaryKey: "RestoreSubscriptionURL") as? String) ?? fallbackURL
773
+        guard let url = URL(string: restoreURL) else {
774
+            if let fallback = URL(string: fallbackURL) {
775
+                NSWorkspace.shared.open(fallback)
776
+            }
777
+            return
778
+        }
779
+        NSWorkspace.shared.open(url)
780
+    }
781
+
782
+    private func performRestorePurchases() {
783
+        Task { [weak self] in
784
+            guard let self else { return }
785
+            let message = await self.storeKitCoordinator.restorePurchases()
786
+            self.refreshPaywallStoreUI()
787
+            self.showSimpleAlert(title: "Restore Purchases", message: message)
788
+        }
789
+    }
790
+
755 791
     private func shareAppURL() -> URL? {
756 792
         if let configured = Bundle.main.object(forInfoDictionaryKey: "AppShareURL") as? String {
757 793
             let trimmed = configured.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -839,6 +875,7 @@ private extension ViewController {
839 875
         settingsActionByView.removeAll()
840 876
         paywallPlanViews.removeAll()
841 877
         premiumPlanByView.removeAll()
878
+        paywallFooterActionByView.removeAll()
842 879
         paywallPriceLabels.removeAll()
843 880
         paywallSubtitleLabels.removeAll()
844 881
         paywallContinueLabel = nil
@@ -860,12 +897,7 @@ private extension ViewController {
860 897
     private func handleSettingsAction(_ action: SettingsAction, sourceView: NSView? = nil, clickLocationInSourceView: NSPoint? = nil) {
861 898
         switch action {
862 899
         case .restore:
863
-            Task { [weak self] in
864
-                guard let self else { return }
865
-                let message = await self.storeKitCoordinator.restorePurchases()
866
-                self.refreshPaywallStoreUI()
867
-                self.showSimpleAlert(title: "Restore Purchases", message: message)
868
-            }
900
+            performRestorePurchases()
869 901
         case .rateUs:
870 902
             openRateUsDestination()
871 903
         case .support:
@@ -1000,10 +1032,22 @@ private extension ViewController {
1000 1032
     }
1001 1033
 
1002 1034
     private func showPaywall(upgradeFlow: Bool = false, preferredPlan: PremiumPlan? = nil) {
1035
+        if !Thread.isMainThread {
1036
+            DispatchQueue.main.async { [weak self] in
1037
+                self?.showPaywall(upgradeFlow: upgradeFlow, preferredPlan: preferredPlan)
1038
+            }
1039
+            return
1040
+        }
1003 1041
         paywallUpgradeFlowEnabled = upgradeFlow
1004 1042
         if let preferredPlan {
1005 1043
             selectedPremiumPlan = preferredPlan
1006 1044
         }
1045
+        if let existingOverlay = paywallOverlayView {
1046
+            refreshPaywallStoreUI()
1047
+            existingOverlay.alphaValue = 1
1048
+            view.addSubview(existingOverlay, positioned: .above, relativeTo: nil)
1049
+            return
1050
+        }
1007 1051
         if let existing = paywallWindow {
1008 1052
             refreshPaywallStoreUI()
1009 1053
             animatePaywallPresentation(existing)
@@ -1013,33 +1057,30 @@ private extension ViewController {
1013 1057
         }
1014 1058
 
1015 1059
         let content = makePaywallContent()
1016
-        let controller = NSViewController()
1017
-        controller.view = content
1018
-
1019
-        let panel = NSPanel(
1020
-            contentRect: NSRect(x: 0, y: 0, width: 640, height: 820),
1021
-            styleMask: [.titled, .closable, .fullSizeContentView],
1022
-            backing: .buffered,
1023
-            defer: false
1024
-        )
1025
-        panel.title = "Get Premium"
1026
-        panel.titleVisibility = .hidden
1027
-        panel.titlebarAppearsTransparent = true
1028
-        panel.isFloatingPanel = false
1029
-        panel.level = .normal
1030
-        panel.hidesOnDeactivate = true
1031
-        panel.isReleasedWhenClosed = false
1032
-        panel.delegate = self
1033
-        panel.standardWindowButton(.closeButton)?.isHidden = true
1034
-        panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
1035
-        panel.standardWindowButton(.zoomButton)?.isHidden = true
1036
-        panel.center()
1037
-        panel.contentViewController = controller
1038
-        panel.alphaValue = 0
1039
-        panel.makeKeyAndOrderFront(nil)
1040
-        NSApp.activate(ignoringOtherApps: true)
1041
-        paywallWindow = panel
1042
-        animatePaywallPresentation(panel)
1060
+        let overlay = NSView()
1061
+        overlay.translatesAutoresizingMaskIntoConstraints = false
1062
+        overlay.wantsLayer = true
1063
+        overlay.layer?.backgroundColor = palette.pageBackground.withAlphaComponent(0.98).cgColor
1064
+        overlay.alphaValue = 0
1065
+        overlay.addSubview(content)
1066
+        view.addSubview(overlay, positioned: .above, relativeTo: nil)
1067
+        NSLayoutConstraint.activate([
1068
+            overlay.leadingAnchor.constraint(equalTo: view.leadingAnchor),
1069
+            overlay.trailingAnchor.constraint(equalTo: view.trailingAnchor),
1070
+            overlay.topAnchor.constraint(equalTo: view.topAnchor),
1071
+            overlay.bottomAnchor.constraint(equalTo: view.bottomAnchor),
1072
+
1073
+            content.leadingAnchor.constraint(equalTo: overlay.leadingAnchor),
1074
+            content.trailingAnchor.constraint(equalTo: overlay.trailingAnchor),
1075
+            content.topAnchor.constraint(equalTo: overlay.topAnchor),
1076
+            content.bottomAnchor.constraint(equalTo: overlay.bottomAnchor)
1077
+        ])
1078
+        paywallOverlayView = overlay
1079
+        NSAnimationContext.runAnimationGroup { context in
1080
+            context.duration = 0.20
1081
+            context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
1082
+            overlay.animator().alphaValue = 1
1083
+        }
1043 1084
 
1044 1085
         Task { [weak self] in
1045 1086
             guard let self else { return }
@@ -1071,6 +1112,12 @@ private extension ViewController {
1071 1112
     }
1072 1113
 
1073 1114
     @objc private func closePaywallClicked(_ sender: Any?) {
1115
+        if let overlay = paywallOverlayView {
1116
+            paywallOverlayView = nil
1117
+            paywallUpgradeFlowEnabled = false
1118
+            overlay.removeFromSuperview()
1119
+            return
1120
+        }
1074 1121
         if let win = paywallWindow {
1075 1122
             win.performClose(nil)
1076 1123
             return
@@ -1087,18 +1134,35 @@ private extension ViewController {
1087 1134
 
1088 1135
     @objc private func paywallFooterLinkClicked(_ sender: NSClickGestureRecognizer) {
1089 1136
         guard let view = sender.view else { return }
1137
+        let action = paywallFooterActionByView[ObjectIdentifier(view)]
1090 1138
         let text = (view.subviews.first { $0 is NSTextField } as? NSTextField)?.stringValue ?? "Link"
1091
-        let defaultURL = (Bundle.main.object(forInfoDictionaryKey: "AppLaunchPlaceholderURL") as? String) ?? "https://example.com/app-link-coming-soon"
1092
-        let map: [String: String] = [
1093
-            "Privacy Policy": (Bundle.main.object(forInfoDictionaryKey: "PrivacyPolicyURL") as? String) ?? defaultURL,
1094
-            "Support": (Bundle.main.object(forInfoDictionaryKey: "SupportURL") as? String) ?? defaultURL,
1095
-            "Terms of Services": (Bundle.main.object(forInfoDictionaryKey: "TermsOfServiceURL") as? String) ?? defaultURL
1096
-        ]
1097
-        if let urlString = map[text], let url = URL(string: urlString) {
1098
-            openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
1139
+        guard let action else {
1140
+            showSimpleAlert(title: text, message: "\(text) tapped.")
1099 1141
             return
1100 1142
         }
1101
-        showSimpleAlert(title: text, message: "\(text) tapped.")
1143
+        handlePaywallFooterAction(action)
1144
+    }
1145
+
1146
+    private func handlePaywallFooterAction(_ action: PaywallFooterAction) {
1147
+        switch action {
1148
+        case .manageSubscription:
1149
+            openManageSubscriptions()
1150
+        case .restorePurchase:
1151
+            openRestoreSubscriptionPage()
1152
+        case .continueWithFreePlan:
1153
+            closePaywallClicked(nil)
1154
+        case .privacyPolicy:
1155
+            openSettingsLink(infoKey: "PrivacyPolicyURL")
1156
+        case .support:
1157
+            openSettingsLink(infoKey: "SupportURL")
1158
+        case .termsOfServices:
1159
+            openSettingsLink(infoKey: "TermsOfServiceURL")
1160
+        }
1161
+    }
1162
+
1163
+    @objc private func paywallFooterButtonPressed(_ sender: NSButton) {
1164
+        guard let action = paywallFooterActionByView[ObjectIdentifier(sender)] else { return }
1165
+        handlePaywallFooterAction(action)
1102 1166
     }
1103 1167
 
1104 1168
     @objc private func paywallPlanClicked(_ sender: NSClickGestureRecognizer) {
@@ -1141,8 +1205,13 @@ private extension ViewController {
1141 1205
             if product.type == .nonConsumable {
1142 1206
                 return "\(pkrPrice) one-time purchase"
1143 1207
             }
1144
-            if let period = product.subscription?.subscriptionPeriod {
1145
-                return "\(pkrPrice)/\(subscriptionUnitText(period.unit))"
1208
+            if let subscription = product.subscription {
1209
+                let billingText = "\(pkrPrice)/\(subscriptionUnitText(subscription.subscriptionPeriod.unit))"
1210
+                if let introOffer = subscription.introductoryOffer,
1211
+                   introOffer.paymentMode == .freeTrial {
1212
+                    return "Free for \(subscriptionPeriodText(introOffer.period)), then \(billingText)"
1213
+                }
1214
+                return billingText
1146 1215
             }
1147 1216
             return pkrPrice
1148 1217
         }
@@ -1150,7 +1219,7 @@ private extension ViewController {
1150 1219
         case .weekly:
1151 1220
             return "PKR 1,100.00/week"
1152 1221
         case .monthly:
1153
-            return "Free for 3 Days then PKR 2,500.00/month"
1222
+            return "PKR 2,500.00/month (3 days free trial)"
1154 1223
         case .yearly:
1155 1224
             return "PKR 9,900.00/year (about 190.38/week)"
1156 1225
         case .lifetime:
@@ -1177,6 +1246,22 @@ private extension ViewController {
1177 1246
         }
1178 1247
     }
1179 1248
 
1249
+    private func subscriptionPeriodText(_ period: Product.SubscriptionPeriod) -> String {
1250
+        let unit = subscriptionUnitText(period.unit)
1251
+        if period.value == 1 {
1252
+            return "1 \(unit)"
1253
+        }
1254
+        return "\(period.value) \(unit)s"
1255
+    }
1256
+
1257
+    private func freeTrialPackageText(for product: Product) -> String? {
1258
+        guard let introOffer = product.subscription?.introductoryOffer,
1259
+              introOffer.paymentMode == .freeTrial else {
1260
+            return nil
1261
+        }
1262
+        return "\(subscriptionPeriodText(introOffer.period)) free trial"
1263
+    }
1264
+
1180 1265
     private func startStoreKit() {
1181 1266
         storeKitStartupTask?.cancel()
1182 1267
         storeKitStartupTask = Task { [weak self] in
@@ -1198,8 +1283,18 @@ private extension ViewController {
1198 1283
         for (plan, label) in paywallSubtitleLabels {
1199 1284
             let productID = PremiumStoreProduct.productID(for: plan)
1200 1285
             guard let product = storeKitCoordinator.productsByID[productID],
1201
-                  let period = product.subscription?.subscriptionPeriod else { continue }
1202
-            label.stringValue = "\(pkrDisplayPrice(product.displayPrice))/\(subscriptionUnitText(period.unit))"
1286
+                  let period = product.subscription?.subscriptionPeriod else {
1287
+                if plan == .monthly {
1288
+                    label.stringValue = "3 days free trial"
1289
+                }
1290
+                continue
1291
+            }
1292
+            let recurringText = "\(pkrDisplayPrice(product.displayPrice))/\(subscriptionUnitText(period.unit))"
1293
+            if let trialText = freeTrialPackageText(for: product) {
1294
+                label.stringValue = "\(recurringText)  •  \(trialText)"
1295
+            } else {
1296
+                label.stringValue = recurringText
1297
+            }
1203 1298
         }
1204 1299
         refreshSidebarPremiumButton()
1205 1300
         refreshInstantMeetPremiumState()
@@ -2568,8 +2663,10 @@ private extension ViewController {
2568 2663
         contentStack.translatesAutoresizingMaskIntoConstraints = false
2569 2664
         contentStack.orientation = .vertical
2570 2665
         contentStack.spacing = 12
2571
-        contentStack.alignment = .leading
2666
+        contentStack.distribution = .fill
2667
+        contentStack.alignment = .centerX
2572 2668
         panel.addSubview(contentStack)
2669
+        let paywallLayoutWidth: CGFloat = 980
2573 2670
 
2574 2671
         let topRow = NSStackView()
2575 2672
         topRow.translatesAutoresizingMaskIntoConstraints = false
@@ -2577,73 +2674,109 @@ private extension ViewController {
2577 2674
         topRow.alignment = .centerY
2578 2675
         topRow.distribution = .fill
2579 2676
         topRow.spacing = 10
2580
-        topRow.addArrangedSubview(textLabel("Get Premium", font: NSFont.systemFont(ofSize: 24, weight: .bold), color: palette.textPrimary))
2581
-        let topSpacer = NSView()
2582
-        topSpacer.translatesAutoresizingMaskIntoConstraints = false
2583
-        topRow.addArrangedSubview(topSpacer)
2584
-        let closeButton = HoverButton(title: "✕", target: self, action: #selector(closePaywallClicked(_:)))
2585
-        closeButton.translatesAutoresizingMaskIntoConstraints = false
2586
-        closeButton.isBordered = false
2587
-        closeButton.bezelStyle = .regularSquare
2588
-        closeButton.wantsLayer = true
2589
-        closeButton.layer?.cornerRadius = 14
2590
-        closeButton.layer?.backgroundColor = palette.inputBackground.cgColor
2591
-        closeButton.layer?.borderColor = palette.inputBorder.cgColor
2592
-        closeButton.layer?.borderWidth = 1
2593
-        closeButton.font = typography.iconButton
2594
-        closeButton.contentTintColor = palette.textSecondary
2595
-        closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
2596
-        closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
2597
-        closeButton.onHoverChanged = { [weak closeButton, weak self] hovering in
2598
-            guard let closeButton, let self else { return }
2599
-            let base = self.palette.inputBackground
2600
-            let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black
2601
-            let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
2602
-            closeButton.layer?.backgroundColor = (hovering ? hover : base).cgColor
2603
-            closeButton.contentTintColor = hovering ? (self.darkModeEnabled ? .white : self.palette.textPrimary) : self.palette.textSecondary
2677
+        topRow.addArrangedSubview(textLabel("Meetings Pro", font: NSFont.systemFont(ofSize: 27, weight: .bold), color: palette.textPrimary))
2678
+        var closeButton: HoverButton?
2679
+        if storeKitCoordinator.hasPremiumAccess {
2680
+            let button = HoverButton(title: "✕", target: self, action: #selector(closePaywallClicked(_:)))
2681
+            button.translatesAutoresizingMaskIntoConstraints = false
2682
+            button.isBordered = false
2683
+            button.bezelStyle = .regularSquare
2684
+            button.wantsLayer = true
2685
+            button.layer?.cornerRadius = 14
2686
+            button.layer?.backgroundColor = palette.inputBackground.cgColor
2687
+            button.layer?.borderColor = palette.inputBorder.cgColor
2688
+            button.layer?.borderWidth = 1
2689
+            button.font = typography.iconButton
2690
+            button.contentTintColor = palette.textSecondary
2691
+            button.widthAnchor.constraint(equalToConstant: 28).isActive = true
2692
+            button.heightAnchor.constraint(equalToConstant: 28).isActive = true
2693
+            button.onHoverChanged = { [weak button, weak self] hovering in
2694
+                guard let button, let self else { return }
2695
+                let base = self.palette.inputBackground
2696
+                let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black
2697
+                let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
2698
+                button.layer?.backgroundColor = (hovering ? hover : base).cgColor
2699
+                button.contentTintColor = hovering ? (self.darkModeEnabled ? .white : self.palette.textPrimary) : self.palette.textSecondary
2700
+            }
2701
+            panel.addSubview(button)
2702
+            closeButton = button
2604 2703
         }
2605
-        topRow.addArrangedSubview(closeButton)
2606
-        topRow.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
2607 2704
         contentStack.addArrangedSubview(topRow)
2705
+        topRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2706
+
2707
+        let hero = roundedContainer(cornerRadius: 16, color: palette.sectionCard)
2708
+        hero.translatesAutoresizingMaskIntoConstraints = false
2709
+        styleSurface(hero, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
2710
+        contentStack.addArrangedSubview(hero)
2711
+        hero.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2712
+
2713
+        let heroBadge = textLabel("PREMIUM EXPERIENCE", font: NSFont.systemFont(ofSize: 10, weight: .bold), color: NSColor(calibratedRed: 0.42, green: 0.93, blue: 0.70, alpha: 1))
2714
+        hero.addSubview(heroBadge)
2715
+        let heroTitle = textLabel("Elevate your meetings workflow", font: NSFont.systemFont(ofSize: 22, weight: .bold), color: palette.textPrimary)
2716
+        let heroSubtitle = textLabel("Unlock automation, premium meeting tools, and priority support for every session.", font: NSFont.systemFont(ofSize: 13, weight: .medium), color: palette.textSecondary)
2717
+        heroTitle.maximumNumberOfLines = 2
2718
+        heroSubtitle.maximumNumberOfLines = 3
2719
+        hero.addSubview(heroTitle)
2720
+        hero.addSubview(heroSubtitle)
2721
+        NSLayoutConstraint.activate([
2722
+            heroBadge.leadingAnchor.constraint(equalTo: hero.leadingAnchor, constant: 16),
2723
+            heroBadge.topAnchor.constraint(equalTo: hero.topAnchor, constant: 14),
2724
+            heroTitle.leadingAnchor.constraint(equalTo: hero.leadingAnchor, constant: 16),
2725
+            heroTitle.trailingAnchor.constraint(lessThanOrEqualTo: hero.trailingAnchor, constant: -16),
2726
+            heroTitle.topAnchor.constraint(equalTo: heroBadge.bottomAnchor, constant: 10),
2727
+            heroSubtitle.leadingAnchor.constraint(equalTo: heroTitle.leadingAnchor),
2728
+            heroSubtitle.trailingAnchor.constraint(equalTo: hero.trailingAnchor, constant: -16),
2729
+            heroSubtitle.topAnchor.constraint(equalTo: heroTitle.bottomAnchor, constant: 8),
2730
+            heroSubtitle.bottomAnchor.constraint(equalTo: hero.bottomAnchor, constant: -16)
2731
+        ])
2608 2732
 
2609
-        contentStack.addArrangedSubview(textLabel("Upgrade to unlock premium features.", font: NSFont.systemFont(ofSize: 12, weight: .medium), color: palette.textSecondary))
2610
-        let benefits = paywallBenefitsSection()
2611
-        contentStack.addArrangedSubview(benefits)
2612
-        contentStack.setCustomSpacing(18, after: benefits)
2733
+        let benefitsRow = NSStackView()
2734
+        benefitsRow.translatesAutoresizingMaskIntoConstraints = false
2735
+        benefitsRow.orientation = .horizontal
2736
+        benefitsRow.spacing = 8
2737
+        benefitsRow.distribution = .fillEqually
2738
+        benefitsRow.alignment = .centerY
2739
+        benefitsRow.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Manage meetings"))
2740
+        benefitsRow.addArrangedSubview(paywallBenefitItem(icon: "🧠", text: "Smart scheduling"))
2741
+        benefitsRow.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Faster workflow"))
2742
+        benefitsRow.addArrangedSubview(paywallBenefitItem(icon: "🔔", text: "Reminder notifications"))
2743
+        contentStack.addArrangedSubview(benefitsRow)
2744
+        benefitsRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2745
+
2746
+        let plansRow = NSStackView()
2747
+        plansRow.translatesAutoresizingMaskIntoConstraints = false
2748
+        plansRow.orientation = .horizontal
2749
+        plansRow.spacing = 10
2750
+        plansRow.distribution = .fillEqually
2751
+        plansRow.alignment = .centerY
2613 2752
 
2614 2753
         let weeklyCard = paywallPlanCard(
2615 2754
             title: "Weekly",
2616 2755
             price: "PKR 1,100.00",
2617
-            badge: "Basic Deal",
2756
+            badge: "Basic",
2618 2757
             badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
2619 2758
             subtitle: nil,
2620 2759
             plan: .weekly,
2621 2760
             strikePrice: nil
2622 2761
         )
2623
-        contentStack.addArrangedSubview(weeklyCard)
2624
-
2625 2762
         let monthlyCard = paywallPlanCard(
2626 2763
             title: "Monthly",
2627 2764
             price: "PKR 2,500.00",
2628
-            badge: "Free Trial",
2765
+            badge: "Popular",
2629 2766
             badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
2630
-            subtitle: "625.00/week",
2767
+            subtitle: "3 days free trial",
2631 2768
             plan: .monthly,
2632 2769
             strikePrice: nil
2633 2770
         )
2634
-        contentStack.addArrangedSubview(monthlyCard)
2635
-
2636 2771
         let yearlyCard = paywallPlanCard(
2637 2772
             title: "Yearly",
2638 2773
             price: "PKR 9,900.00",
2639
-            badge: "Best Deal",
2774
+            badge: "Best Value",
2640 2775
             badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
2641 2776
             subtitle: "190.38/week",
2642 2777
             plan: .yearly,
2643 2778
             strikePrice: nil
2644 2779
         )
2645
-        contentStack.addArrangedSubview(yearlyCard)
2646
-
2647 2780
         let lifetimeCard = paywallPlanCard(
2648 2781
             title: "Lifetime",
2649 2782
             price: "PKR 14,900.00",
@@ -2653,9 +2786,25 @@ private extension ViewController {
2653 2786
             plan: .lifetime,
2654 2787
             strikePrice: "PKR 29,800.00"
2655 2788
         )
2656
-        contentStack.addArrangedSubview(lifetimeCard)
2789
+        plansRow.addArrangedSubview(weeklyCard)
2790
+        plansRow.addArrangedSubview(monthlyCard)
2791
+        plansRow.addArrangedSubview(yearlyCard)
2792
+        plansRow.addArrangedSubview(lifetimeCard)
2793
+        contentStack.addArrangedSubview(plansRow)
2794
+        plansRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2657 2795
         updatePaywallPlanSelection()
2658
-        contentStack.setCustomSpacing(20, after: lifetimeCard)
2796
+
2797
+        let trustRow = NSStackView()
2798
+        trustRow.translatesAutoresizingMaskIntoConstraints = false
2799
+        trustRow.orientation = .horizontal
2800
+        trustRow.spacing = 8
2801
+        trustRow.distribution = .fillEqually
2802
+        trustRow.alignment = .centerY
2803
+        trustRow.addArrangedSubview(paywallMetaItem(title: "Cancel anytime", subtitle: "No lock-in"))
2804
+        trustRow.addArrangedSubview(paywallMetaItem(title: "Instant access", subtitle: "Unlock all tools"))
2805
+        trustRow.addArrangedSubview(paywallMetaItem(title: "Secure billing", subtitle: "Handled by Apple"))
2806
+        contentStack.addArrangedSubview(trustRow)
2807
+        trustRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2659 2808
 
2660 2809
         let offer = textLabel(paywallOfferText(for: selectedPremiumPlan), font: NSFont.systemFont(ofSize: 13, weight: .semibold), color: palette.textPrimary)
2661 2810
         offer.alignment = .center
@@ -2663,26 +2812,24 @@ private extension ViewController {
2663 2812
         let offerWrap = NSView()
2664 2813
         offerWrap.translatesAutoresizingMaskIntoConstraints = false
2665 2814
         offerWrap.addSubview(offer)
2815
+        contentStack.addArrangedSubview(offerWrap)
2816
+        offerWrap.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2666 2817
         NSLayoutConstraint.activate([
2667
-            offerWrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth),
2668 2818
             offer.centerXAnchor.constraint(equalTo: offerWrap.centerXAnchor),
2669
-            offer.topAnchor.constraint(equalTo: offerWrap.topAnchor, constant: 6),
2819
+            offer.topAnchor.constraint(equalTo: offerWrap.topAnchor, constant: 4),
2670 2820
             offer.bottomAnchor.constraint(equalTo: offerWrap.bottomAnchor, constant: -2)
2671 2821
         ])
2672
-        contentStack.addArrangedSubview(offerWrap)
2673
-        contentStack.setCustomSpacing(18, after: offerWrap)
2674 2822
 
2675 2823
         let continueButton = HoverButton(title: "", target: self, action: #selector(paywallContinueClicked(_:)))
2676 2824
         continueButton.translatesAutoresizingMaskIntoConstraints = false
2677 2825
         continueButton.isBordered = false
2678 2826
         continueButton.bezelStyle = .regularSquare
2679 2827
         continueButton.wantsLayer = true
2680
-        continueButton.layer?.cornerRadius = 14
2828
+        continueButton.layer?.cornerRadius = 12
2681 2829
         continueButton.layer?.backgroundColor = palette.primaryBlue.cgColor
2682
-        continueButton.heightAnchor.constraint(equalToConstant: 44).isActive = true
2683
-        continueButton.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
2830
+        continueButton.heightAnchor.constraint(equalToConstant: 36).isActive = true
2684 2831
         styleSurface(continueButton, borderColor: palette.primaryBlueBorder, borderWidth: 1, shadow: true)
2685
-        let continueLabel = textLabel("Continue", font: NSFont.systemFont(ofSize: 16, weight: .bold), color: .white)
2832
+        let continueLabel = textLabel("Continue", font: NSFont.systemFont(ofSize: 14, weight: .bold), color: .white)
2686 2833
         continueButton.addSubview(continueLabel)
2687 2834
         NSLayoutConstraint.activate([
2688 2835
             continueLabel.centerXAnchor.constraint(equalTo: continueButton.centerXAnchor),
@@ -2698,31 +2845,36 @@ private extension ViewController {
2698 2845
         paywallContinueButton = continueButton
2699 2846
         paywallContinueLabel = continueLabel
2700 2847
         contentStack.addArrangedSubview(continueButton)
2701
-        contentStack.setCustomSpacing(16, after: continueButton)
2848
+        continueButton.widthAnchor.constraint(equalToConstant: 360).isActive = true
2702 2849
 
2703 2850
         let secure = textLabel("Secured by Apple. Cancel anytime.", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: palette.textSecondary)
2704 2851
         secure.alignment = .center
2705 2852
         let secureWrap = NSView()
2706 2853
         secureWrap.translatesAutoresizingMaskIntoConstraints = false
2707 2854
         secureWrap.addSubview(secure)
2855
+        contentStack.addArrangedSubview(secureWrap)
2856
+        secureWrap.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2708 2857
         NSLayoutConstraint.activate([
2709
-            secureWrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth),
2710 2858
             secure.centerXAnchor.constraint(equalTo: secureWrap.centerXAnchor),
2711
-            secure.topAnchor.constraint(equalTo: secureWrap.topAnchor, constant: 4),
2712
-            secure.bottomAnchor.constraint(equalTo: secureWrap.bottomAnchor, constant: -8)
2859
+            secure.topAnchor.constraint(equalTo: secureWrap.topAnchor, constant: 2),
2860
+            secure.bottomAnchor.constraint(equalTo: secureWrap.bottomAnchor, constant: -4)
2713 2861
         ])
2714
-        contentStack.addArrangedSubview(secureWrap)
2715
-        contentStack.setCustomSpacing(16, after: secureWrap)
2716 2862
 
2717 2863
         let footer = paywallFooterLinks()
2718 2864
         contentStack.addArrangedSubview(footer)
2865
+        footer.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2719 2866
 
2720
-        NSLayoutConstraint.activate([
2721
-            contentStack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 18),
2722
-            contentStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -18),
2723
-            contentStack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 16),
2724
-            contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -12)
2725
-        ])
2867
+        var panelConstraints: [NSLayoutConstraint] = [
2868
+            contentStack.centerXAnchor.constraint(equalTo: panel.centerXAnchor),
2869
+            contentStack.widthAnchor.constraint(equalToConstant: paywallLayoutWidth),
2870
+            contentStack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 80),
2871
+            contentStack.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -12)
2872
+        ]
2873
+        if let closeButton {
2874
+            panelConstraints.append(closeButton.topAnchor.constraint(equalTo: panel.topAnchor, constant: 30))
2875
+            panelConstraints.append(closeButton.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -30))
2876
+        }
2877
+        NSLayoutConstraint.activate(panelConstraints)
2726 2878
 
2727 2879
         refreshPaywallStoreUI()
2728 2880
         return panel
@@ -2743,8 +2895,8 @@ private extension ViewController {
2743 2895
         wrapper.bezelStyle = .regularSquare
2744 2896
         wrapper.wantsLayer = true
2745 2897
         wrapper.layer?.backgroundColor = NSColor.clear.cgColor
2746
-        wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
2747
-        wrapper.heightAnchor.constraint(equalToConstant: 94).isActive = true
2898
+        wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 160).isActive = true
2899
+        wrapper.heightAnchor.constraint(equalToConstant: 162).isActive = true
2748 2900
         wrapper.tag = plan.rawValue
2749 2901
 
2750 2902
         let card = HoverTrackingView()
@@ -2752,7 +2904,7 @@ private extension ViewController {
2752 2904
         card.wantsLayer = true
2753 2905
         card.layer?.cornerRadius = 16
2754 2906
         card.layer?.backgroundColor = palette.sectionCard.cgColor
2755
-        card.heightAnchor.constraint(equalToConstant: 82).isActive = true
2907
+        card.heightAnchor.constraint(equalToConstant: 150).isActive = true
2756 2908
         wrapper.addSubview(card)
2757 2909
         NSLayoutConstraint.activate([
2758 2910
             card.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
@@ -2788,14 +2940,15 @@ private extension ViewController {
2788 2940
         paywallPriceLabels[plan] = priceLabel
2789 2941
 
2790 2942
         NSLayoutConstraint.activate([
2791
-            badgeWrap.centerXAnchor.constraint(equalTo: card.centerXAnchor),
2943
+            badgeWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12),
2792 2944
             badgeWrap.centerYAnchor.constraint(equalTo: card.topAnchor),
2793 2945
 
2794
-            titleLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
2946
+            titleLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12),
2795 2947
             titleLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 34),
2796 2948
 
2797
-            priceLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
2798
-            priceLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 32)
2949
+            priceLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
2950
+            priceLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10),
2951
+            priceLabel.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -12)
2799 2952
         ])
2800 2953
 
2801 2954
         if let subtitle {
@@ -2803,8 +2956,9 @@ private extension ViewController {
2803 2956
             card.addSubview(sub)
2804 2957
             paywallSubtitleLabels[plan] = sub
2805 2958
             NSLayoutConstraint.activate([
2806
-                sub.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
2807
-                sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 0)
2959
+                sub.leadingAnchor.constraint(equalTo: priceLabel.leadingAnchor),
2960
+                sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 2),
2961
+                sub.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -12)
2808 2962
             ])
2809 2963
         }
2810 2964
 
@@ -2812,8 +2966,9 @@ private extension ViewController {
2812 2966
             let strike = textLabel(strikePrice, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: NSColor.systemRed)
2813 2967
             card.addSubview(strike)
2814 2968
             NSLayoutConstraint.activate([
2815
-                strike.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
2816
-                strike.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 4)
2969
+                strike.leadingAnchor.constraint(equalTo: priceLabel.leadingAnchor),
2970
+                strike.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 4),
2971
+                strike.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -12)
2817 2972
             ])
2818 2973
         }
2819 2974
 
@@ -2830,8 +2985,8 @@ private extension ViewController {
2830 2985
     func paywallFooterLinks() -> NSView {
2831 2986
         let wrap = NSView()
2832 2987
         wrap.translatesAutoresizingMaskIntoConstraints = false
2833
-        wrap.heightAnchor.constraint(equalToConstant: 34).isActive = true
2834
-        wrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
2988
+        wrap.heightAnchor.constraint(equalToConstant: 40).isActive = true
2989
+        paywallFooterActionByView.removeAll()
2835 2990
 
2836 2991
         let row = NSStackView()
2837 2992
         row.translatesAutoresizingMaskIntoConstraints = false
@@ -2841,9 +2996,15 @@ private extension ViewController {
2841 2996
         row.spacing = 0
2842 2997
         wrap.addSubview(row)
2843 2998
 
2844
-        row.addArrangedSubview(footerLink("Privacy Policy"))
2845
-        row.addArrangedSubview(footerLink("Support"))
2846
-        row.addArrangedSubview(footerLink("Terms of Services"))
2999
+        if storeKitCoordinator.hasPremiumAccess {
3000
+            row.addArrangedSubview(footerLink("Manage Subscription", action: .manageSubscription))
3001
+            row.addArrangedSubview(footerLink("Restore Purchase", action: .restorePurchase))
3002
+        } else {
3003
+            row.addArrangedSubview(footerLink("Continue with free plan", action: .continueWithFreePlan))
3004
+        }
3005
+        row.addArrangedSubview(footerLink("Privacy Policy", action: .privacyPolicy))
3006
+        row.addArrangedSubview(footerLink("Support", action: .support))
3007
+        row.addArrangedSubview(footerLink("Terms of Services", action: .termsOfServices))
2847 3008
 
2848 3009
         NSLayoutConstraint.activate([
2849 3010
             row.leadingAnchor.constraint(equalTo: wrap.leadingAnchor),
@@ -2855,25 +3016,54 @@ private extension ViewController {
2855 3016
         return wrap
2856 3017
     }
2857 3018
 
2858
-    func footerLink(_ title: String) -> NSView {
2859
-        let container = HoverTrackingView()
2860
-        container.translatesAutoresizingMaskIntoConstraints = false
2861
-        let label = textLabel(title, font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: palette.textSecondary)
2862
-        label.alignment = .center
2863
-        container.addSubview(label)
3019
+    private func footerLink(_ title: String, action: PaywallFooterAction) -> NSView {
3020
+        let button = HoverButton(title: title, target: self, action: #selector(paywallFooterButtonPressed(_:)))
3021
+        button.translatesAutoresizingMaskIntoConstraints = false
3022
+        button.isBordered = false
3023
+        button.bezelStyle = .regularSquare
3024
+        button.focusRingType = .none
3025
+        button.font = NSFont.systemFont(ofSize: 12, weight: .semibold)
3026
+        button.alignment = .center
3027
+        button.setButtonType(.momentaryChange)
3028
+        button.contentTintColor = palette.textSecondary
3029
+        paywallFooterActionByView[ObjectIdentifier(button)] = action
3030
+        button.onHoverChanged = { [weak self, weak button] hovering in
3031
+            guard let self, let button else { return }
3032
+            button.contentTintColor = hovering ? (self.darkModeEnabled ? .white : self.palette.textPrimary) : self.palette.textSecondary
3033
+        }
3034
+        button.onHoverChanged?(false)
3035
+        return button
3036
+    }
2864 3037
 
3038
+    func paywallMetaItem(title: String, subtitle: String) -> NSView {
3039
+        let card = HoverTrackingView()
3040
+        card.translatesAutoresizingMaskIntoConstraints = false
3041
+        card.wantsLayer = true
3042
+        card.layer?.cornerRadius = 10
3043
+        card.layer?.backgroundColor = palette.inputBackground.cgColor
3044
+        card.heightAnchor.constraint(equalToConstant: 44).isActive = true
3045
+        styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
3046
+
3047
+        let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 11, weight: .semibold), color: palette.textPrimary)
3048
+        let subtitleLabel = textLabel(subtitle, font: NSFont.systemFont(ofSize: 10, weight: .medium), color: palette.textSecondary)
3049
+        card.addSubview(titleLabel)
3050
+        card.addSubview(subtitleLabel)
2865 3051
         NSLayoutConstraint.activate([
2866
-            label.centerXAnchor.constraint(equalTo: container.centerXAnchor),
2867
-            label.centerYAnchor.constraint(equalTo: container.centerYAnchor)
3052
+            titleLabel.centerXAnchor.constraint(equalTo: card.centerXAnchor),
3053
+            titleLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 7),
3054
+            subtitleLabel.centerXAnchor.constraint(equalTo: card.centerXAnchor),
3055
+            subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 1),
3056
+            subtitleLabel.bottomAnchor.constraint(lessThanOrEqualTo: card.bottomAnchor, constant: -6)
2868 3057
         ])
2869 3058
 
2870
-        let click = NSClickGestureRecognizer(target: self, action: #selector(paywallFooterLinkClicked(_:)))
2871
-        container.addGestureRecognizer(click)
2872
-        container.onHoverChanged = { hovering in
2873
-            label.textColor = hovering ? (self.darkModeEnabled ? .white : self.palette.textPrimary) : self.palette.textSecondary
3059
+        let base = palette.inputBackground
3060
+        let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
3061
+        let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
3062
+        card.onHoverChanged = { [weak card] hovering in
3063
+            card?.layer?.backgroundColor = (hovering ? hover : base).cgColor
2874 3064
         }
2875
-        container.onHoverChanged?(false)
2876
-        return container
3065
+        card.onHoverChanged?(false)
3066
+        return card
2877 3067
     }
2878 3068
 
2879 3069
     func paywallBenefitsSection() -> NSView {