浏览代码

Merge branch 'paywall-landscape-redesign'

huzaifahayat12 1 月之前
父节点
当前提交
118119fa56
共有 2 个文件被更改,包括 425 次插入192 次删除
  1. 72 46
      meetings_app/StoreKit.storekit
  2. 353 146
      meetings_app/ViewController.swift

+ 72 - 46
meetings_app/StoreKit.storekit

@@ -2,51 +2,6 @@
2 2
   "identifier" : "7B5DA685-94A9-4A9B-86EA-F7D90A0D5249",
3 3
   "nonRenewingSubscriptions" : [],
4 4
   "products" : [
5
-    {
6
-      "displayPrice" : "1100.00",
7
-      "familyShareable" : false,
8
-      "internalID" : "F16C0A1F-5B83-41AB-A9F2-69157A11A11A",
9
-      "localizations" : [
10
-        {
11
-          "description" : "Unlock premium features with weekly access.",
12
-          "displayName" : "Premium Weekly",
13
-          "locale" : "en_US"
14
-        }
15
-      ],
16
-      "productID" : "com.mqldev.meetingsapp.premium.weekly",
17
-      "referenceName" : "Premium Weekly",
18
-      "type" : "NonConsumable"
19
-    },
20
-    {
21
-      "displayPrice" : "2500.00",
22
-      "familyShareable" : false,
23
-      "internalID" : "B2B57D59-AE2B-4953-BF03-5D4AFECAC6C1",
24
-      "localizations" : [
25
-        {
26
-          "description" : "Unlock premium features with monthly access.",
27
-          "displayName" : "Premium Monthly",
28
-          "locale" : "en_US"
29
-        }
30
-      ],
31
-      "productID" : "com.mqldev.meetingsapp.premium.monthly",
32
-      "referenceName" : "Premium Monthly",
33
-      "type" : "NonConsumable"
34
-    },
35
-    {
36
-      "displayPrice" : "9900.00",
37
-      "familyShareable" : false,
38
-      "internalID" : "C5694F51-47D8-4AFD-9D33-95A888527BB5",
39
-      "localizations" : [
40
-        {
41
-          "description" : "Unlock premium features with yearly access.",
42
-          "displayName" : "Premium Yearly",
43
-          "locale" : "en_US"
44
-        }
45
-      ],
46
-      "productID" : "com.mqldev.meetingsapp.premium.yearly",
47
-      "referenceName" : "Premium Yearly",
48
-      "type" : "NonConsumable"
49
-    },
50 5
     {
51 6
       "displayPrice" : "14900.00",
52 7
       "familyShareable" : false,
@@ -74,7 +29,78 @@
74 29
     "_storefront" : "PAK",
75 30
     "_timeRate" : 0
76 31
   },
77
-  "subscriptionGroups" : [],
32
+  "subscriptionGroups" : [
33
+    {
34
+      "id" : "A2D0B1D6",
35
+      "localizations" : [],
36
+      "name" : "Premium",
37
+      "subscriptions" : [
38
+        {
39
+          "adHocOffers" : [],
40
+          "codeOffers" : [],
41
+          "displayPrice" : "1100.00",
42
+          "familyShareable" : false,
43
+          "groupNumber" : 3,
44
+          "internalID" : "F16C0A1F-5B83-41AB-A9F2-69157A11A11A",
45
+          "introductoryOffer" : null,
46
+          "localizations" : [
47
+            {
48
+              "description" : "Unlock premium features with weekly access.",
49
+              "displayName" : "Premium Weekly",
50
+              "locale" : "en_US"
51
+            }
52
+          ],
53
+          "productID" : "com.mqldev.meetingsapp.premium.weekly",
54
+          "recurringSubscriptionPeriod" : "P1W",
55
+          "referenceName" : "Premium Weekly",
56
+          "subscriptionGroupID" : "A2D0B1D6",
57
+          "type" : "RecurringSubscription"
58
+        },
59
+        {
60
+          "adHocOffers" : [],
61
+          "codeOffers" : [],
62
+          "displayPrice" : "2500.00",
63
+          "familyShareable" : false,
64
+          "groupNumber" : 2,
65
+          "internalID" : "B2B57D59-AE2B-4953-BF03-5D4AFECAC6C1",
66
+          "introductoryOffer" : null,
67
+          "localizations" : [
68
+            {
69
+              "description" : "Unlock premium features with monthly access.",
70
+              "displayName" : "Premium Monthly",
71
+              "locale" : "en_US"
72
+            }
73
+          ],
74
+          "productID" : "com.mqldev.meetingsapp.premium.monthly",
75
+          "recurringSubscriptionPeriod" : "P1M",
76
+          "referenceName" : "Premium Monthly",
77
+          "subscriptionGroupID" : "A2D0B1D6",
78
+          "type" : "RecurringSubscription"
79
+        },
80
+        {
81
+          "adHocOffers" : [],
82
+          "codeOffers" : [],
83
+          "displayPrice" : "9900.00",
84
+          "familyShareable" : false,
85
+          "groupNumber" : 1,
86
+          "internalID" : "C5694F51-47D8-4AFD-9D33-95A888527BB5",
87
+          "introductoryOffer" : null,
88
+          "localizations" : [
89
+            {
90
+              "description" : "Unlock premium features with yearly access.",
91
+              "displayName" : "Premium Yearly",
92
+              "locale" : "en_US"
93
+            }
94
+          ],
95
+          "productID" : "com.mqldev.meetingsapp.premium.yearly",
96
+          "recurringSubscriptionPeriod" : "P1Y",
97
+          "referenceName" : "Premium Yearly",
98
+          "subscriptionGroupID" : "A2D0B1D6",
99
+          "type" : "RecurringSubscription"
100
+        }
101
+      ]
102
+    }
103
+  ],
78 104
   "version" : {
79 105
     "major" : 3,
80 106
     "minor" : 0

+ 353 - 146
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?
@@ -539,8 +550,10 @@ private extension ViewController {
539 550
     }
540 551
 
541 552
     @objc private func premiumButtonClicked(_ sender: NSClickGestureRecognizer) {
542
-        if storeKitCoordinator.hasPremiumAccess {
553
+        if storeKitCoordinator.hasLifetimeAccess {
543 554
             openManageSubscriptions()
555
+        } else if storeKitCoordinator.hasPremiumAccess {
556
+            showPaywall(upgradeFlow: true, preferredPlan: .lifetime)
544 557
         } else {
545 558
             showPaywall()
546 559
         }
@@ -745,6 +758,10 @@ private extension ViewController {
745 758
     }
746 759
 
747 760
     private func openManageSubscriptions() {
761
+        if let appStoreURL = URL(string: "macappstore://apps.apple.com/account/subscriptions"),
762
+           NSWorkspace.shared.open(appStoreURL) {
763
+            return
764
+        }
748 765
         guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else {
749 766
             showSimpleAlert(title: "Unable to Open Subscriptions", message: "The subscriptions URL is invalid.")
750 767
             return
@@ -752,6 +769,27 @@ private extension ViewController {
752 769
         openInDefaultBrowser(url: url)
753 770
     }
754 771
 
772
+    private func openRestoreSubscriptionPage() {
773
+        let fallbackURL = "https://support.apple.com/en-us/108096"
774
+        let restoreURL = (Bundle.main.object(forInfoDictionaryKey: "RestoreSubscriptionURL") as? String) ?? fallbackURL
775
+        guard let url = URL(string: restoreURL) else {
776
+            if let fallback = URL(string: fallbackURL) {
777
+                NSWorkspace.shared.open(fallback)
778
+            }
779
+            return
780
+        }
781
+        NSWorkspace.shared.open(url)
782
+    }
783
+
784
+    private func performRestorePurchases() {
785
+        Task { [weak self] in
786
+            guard let self else { return }
787
+            let message = await self.storeKitCoordinator.restorePurchases()
788
+            self.refreshPaywallStoreUI()
789
+            self.showSimpleAlert(title: "Restore Purchases", message: message)
790
+        }
791
+    }
792
+
755 793
     private func shareAppURL() -> URL? {
756 794
         if let configured = Bundle.main.object(forInfoDictionaryKey: "AppShareURL") as? String {
757 795
             let trimmed = configured.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -839,6 +877,7 @@ private extension ViewController {
839 877
         settingsActionByView.removeAll()
840 878
         paywallPlanViews.removeAll()
841 879
         premiumPlanByView.removeAll()
880
+        paywallFooterActionByView.removeAll()
842 881
         paywallPriceLabels.removeAll()
843 882
         paywallSubtitleLabels.removeAll()
844 883
         paywallContinueLabel = nil
@@ -860,12 +899,7 @@ private extension ViewController {
860 899
     private func handleSettingsAction(_ action: SettingsAction, sourceView: NSView? = nil, clickLocationInSourceView: NSPoint? = nil) {
861 900
         switch action {
862 901
         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
-            }
902
+            performRestorePurchases()
869 903
         case .rateUs:
870 904
             openRateUsDestination()
871 905
         case .support:
@@ -990,20 +1024,37 @@ private extension ViewController {
990 1024
         DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: hideWorkItem)
991 1025
     }
992 1026
 
993
-    private func confirmPremiumUpgrade() -> Bool {
1027
+    private func confirmPremiumUpgrade(for targetPlan: PremiumPlan) -> Bool {
994 1028
         let alert = NSAlert()
995
-        alert.messageText = "Already Premium"
996
-        alert.informativeText = "You are already premium. Do you want to continue with this purchase?"
1029
+        if targetPlan == .lifetime, storeKitCoordinator.hasPremiumAccess, !storeKitCoordinator.hasLifetimeAccess {
1030
+            alert.messageText = "Switching to Lifetime"
1031
+            alert.informativeText = "You already have an active subscription. If you buy Lifetime, cancel your current subscription in App Store Subscriptions to avoid future renewal charges."
1032
+        } else {
1033
+            alert.messageText = "Already Premium"
1034
+            alert.informativeText = "You are already premium. Do you want to continue with this purchase?"
1035
+        }
997 1036
         alert.addButton(withTitle: "Continue")
998 1037
         alert.addButton(withTitle: "Cancel")
999 1038
         return alert.runModal() == .alertFirstButtonReturn
1000 1039
     }
1001 1040
 
1002 1041
     private func showPaywall(upgradeFlow: Bool = false, preferredPlan: PremiumPlan? = nil) {
1042
+        if !Thread.isMainThread {
1043
+            DispatchQueue.main.async { [weak self] in
1044
+                self?.showPaywall(upgradeFlow: upgradeFlow, preferredPlan: preferredPlan)
1045
+            }
1046
+            return
1047
+        }
1003 1048
         paywallUpgradeFlowEnabled = upgradeFlow
1004 1049
         if let preferredPlan {
1005 1050
             selectedPremiumPlan = preferredPlan
1006 1051
         }
1052
+        if let existingOverlay = paywallOverlayView {
1053
+            refreshPaywallStoreUI()
1054
+            existingOverlay.alphaValue = 1
1055
+            view.addSubview(existingOverlay, positioned: .above, relativeTo: nil)
1056
+            return
1057
+        }
1007 1058
         if let existing = paywallWindow {
1008 1059
             refreshPaywallStoreUI()
1009 1060
             animatePaywallPresentation(existing)
@@ -1013,33 +1064,30 @@ private extension ViewController {
1013 1064
         }
1014 1065
 
1015 1066
         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)
1067
+        let overlay = NSView()
1068
+        overlay.translatesAutoresizingMaskIntoConstraints = false
1069
+        overlay.wantsLayer = true
1070
+        overlay.layer?.backgroundColor = palette.pageBackground.withAlphaComponent(0.98).cgColor
1071
+        overlay.alphaValue = 0
1072
+        overlay.addSubview(content)
1073
+        view.addSubview(overlay, positioned: .above, relativeTo: nil)
1074
+        NSLayoutConstraint.activate([
1075
+            overlay.leadingAnchor.constraint(equalTo: view.leadingAnchor),
1076
+            overlay.trailingAnchor.constraint(equalTo: view.trailingAnchor),
1077
+            overlay.topAnchor.constraint(equalTo: view.topAnchor),
1078
+            overlay.bottomAnchor.constraint(equalTo: view.bottomAnchor),
1079
+
1080
+            content.leadingAnchor.constraint(equalTo: overlay.leadingAnchor),
1081
+            content.trailingAnchor.constraint(equalTo: overlay.trailingAnchor),
1082
+            content.topAnchor.constraint(equalTo: overlay.topAnchor),
1083
+            content.bottomAnchor.constraint(equalTo: overlay.bottomAnchor)
1084
+        ])
1085
+        paywallOverlayView = overlay
1086
+        NSAnimationContext.runAnimationGroup { context in
1087
+            context.duration = 0.20
1088
+            context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
1089
+            overlay.animator().alphaValue = 1
1090
+        }
1043 1091
 
1044 1092
         Task { [weak self] in
1045 1093
             guard let self else { return }
@@ -1071,6 +1119,12 @@ private extension ViewController {
1071 1119
     }
1072 1120
 
1073 1121
     @objc private func closePaywallClicked(_ sender: Any?) {
1122
+        if let overlay = paywallOverlayView {
1123
+            paywallOverlayView = nil
1124
+            paywallUpgradeFlowEnabled = false
1125
+            overlay.removeFromSuperview()
1126
+            return
1127
+        }
1074 1128
         if let win = paywallWindow {
1075 1129
             win.performClose(nil)
1076 1130
             return
@@ -1085,20 +1139,47 @@ private extension ViewController {
1085 1139
         }
1086 1140
     }
1087 1141
 
1142
+    private func dismissPaywallIfPresented() {
1143
+        if !Thread.isMainThread {
1144
+            DispatchQueue.main.async { [weak self] in
1145
+                self?.dismissPaywallIfPresented()
1146
+            }
1147
+            return
1148
+        }
1149
+        closePaywallClicked(nil)
1150
+    }
1151
+
1088 1152
     @objc private func paywallFooterLinkClicked(_ sender: NSClickGestureRecognizer) {
1089 1153
         guard let view = sender.view else { return }
1154
+        let action = paywallFooterActionByView[ObjectIdentifier(view)]
1090 1155
         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)
1156
+        guard let action else {
1157
+            showSimpleAlert(title: text, message: "\(text) tapped.")
1099 1158
             return
1100 1159
         }
1101
-        showSimpleAlert(title: text, message: "\(text) tapped.")
1160
+        handlePaywallFooterAction(action)
1161
+    }
1162
+
1163
+    private func handlePaywallFooterAction(_ action: PaywallFooterAction) {
1164
+        switch action {
1165
+        case .manageSubscription:
1166
+            openManageSubscriptions()
1167
+        case .restorePurchase:
1168
+            openRestoreSubscriptionPage()
1169
+        case .continueWithFreePlan:
1170
+            closePaywallClicked(nil)
1171
+        case .privacyPolicy:
1172
+            openSettingsLink(infoKey: "PrivacyPolicyURL")
1173
+        case .support:
1174
+            openSettingsLink(infoKey: "SupportURL")
1175
+        case .termsOfServices:
1176
+            openSettingsLink(infoKey: "TermsOfServiceURL")
1177
+        }
1178
+    }
1179
+
1180
+    @objc private func paywallFooterButtonPressed(_ sender: NSButton) {
1181
+        guard let action = paywallFooterActionByView[ObjectIdentifier(sender)] else { return }
1182
+        handlePaywallFooterAction(action)
1102 1183
     }
1103 1184
 
1104 1185
     @objc private func paywallPlanClicked(_ sender: NSClickGestureRecognizer) {
@@ -1141,8 +1222,13 @@ private extension ViewController {
1141 1222
             if product.type == .nonConsumable {
1142 1223
                 return "\(pkrPrice) one-time purchase"
1143 1224
             }
1144
-            if let period = product.subscription?.subscriptionPeriod {
1145
-                return "\(pkrPrice)/\(subscriptionUnitText(period.unit))"
1225
+            if let subscription = product.subscription {
1226
+                let billingText = "\(pkrPrice)/\(subscriptionUnitText(subscription.subscriptionPeriod.unit))"
1227
+                if let introOffer = subscription.introductoryOffer,
1228
+                   introOffer.paymentMode == .freeTrial {
1229
+                    return "Free for \(subscriptionPeriodText(introOffer.period)), then \(billingText)"
1230
+                }
1231
+                return billingText
1146 1232
             }
1147 1233
             return pkrPrice
1148 1234
         }
@@ -1150,7 +1236,7 @@ private extension ViewController {
1150 1236
         case .weekly:
1151 1237
             return "PKR 1,100.00/week"
1152 1238
         case .monthly:
1153
-            return "Free for 3 Days then PKR 2,500.00/month"
1239
+            return "PKR 2,500.00/month (3 days free trial)"
1154 1240
         case .yearly:
1155 1241
             return "PKR 9,900.00/year (about 190.38/week)"
1156 1242
         case .lifetime:
@@ -1177,6 +1263,22 @@ private extension ViewController {
1177 1263
         }
1178 1264
     }
1179 1265
 
1266
+    private func subscriptionPeriodText(_ period: Product.SubscriptionPeriod) -> String {
1267
+        let unit = subscriptionUnitText(period.unit)
1268
+        if period.value == 1 {
1269
+            return "1 \(unit)"
1270
+        }
1271
+        return "\(period.value) \(unit)s"
1272
+    }
1273
+
1274
+    private func freeTrialPackageText(for product: Product) -> String? {
1275
+        guard let introOffer = product.subscription?.introductoryOffer,
1276
+              introOffer.paymentMode == .freeTrial else {
1277
+            return nil
1278
+        }
1279
+        return "\(subscriptionPeriodText(introOffer.period)) free trial"
1280
+    }
1281
+
1180 1282
     private func startStoreKit() {
1181 1283
         storeKitStartupTask?.cancel()
1182 1284
         storeKitStartupTask = Task { [weak self] in
@@ -1198,8 +1300,17 @@ private extension ViewController {
1198 1300
         for (plan, label) in paywallSubtitleLabels {
1199 1301
             let productID = PremiumStoreProduct.productID(for: plan)
1200 1302
             guard let product = storeKitCoordinator.productsByID[productID],
1201
-                  let period = product.subscription?.subscriptionPeriod else { continue }
1202
-            label.stringValue = "\(pkrDisplayPrice(product.displayPrice))/\(subscriptionUnitText(period.unit))"
1303
+                  let period = product.subscription?.subscriptionPeriod else {
1304
+                // Show neutral fallback text when subscription metadata isn't available.
1305
+                label.stringValue = "Billed via App Store"
1306
+                continue
1307
+            }
1308
+            let recurringText = "\(pkrDisplayPrice(product.displayPrice))/\(subscriptionUnitText(period.unit))"
1309
+            if let trialText = freeTrialPackageText(for: product) {
1310
+                label.stringValue = "\(recurringText)  •  \(trialText)"
1311
+            } else {
1312
+                label.stringValue = recurringText
1313
+            }
1203 1314
         }
1204 1315
         refreshSidebarPremiumButton()
1205 1316
         refreshInstantMeetPremiumState()
@@ -1236,6 +1347,7 @@ private extension ViewController {
1236 1347
         }
1237 1348
 
1238 1349
         if !hadPremiumAccess && hasPremiumAccess {
1350
+            dismissPaywallIfPresented()
1239 1351
             if selectedSidebarPage != .joinMeetings {
1240 1352
                 Task { [weak self] in
1241 1353
                     await self?.loadSchedule()
@@ -1419,7 +1531,7 @@ private extension ViewController {
1419 1531
             }
1420 1532
             return
1421 1533
         }
1422
-        if paywallUpgradeFlowEnabled && storeKitCoordinator.hasPremiumAccess && !confirmPremiumUpgrade() {
1534
+        if paywallUpgradeFlowEnabled && storeKitCoordinator.hasPremiumAccess && !confirmPremiumUpgrade(for: selectedPremiumPlan) {
1423 1535
             return
1424 1536
         }
1425 1537
         paywallPurchaseTask?.cancel()
@@ -1437,7 +1549,7 @@ private extension ViewController {
1437 1549
                     await self?.loadSchedule()
1438 1550
                 }
1439 1551
                 self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
1440
-                self.paywallWindow?.performClose(nil)
1552
+                self.dismissPaywallIfPresented()
1441 1553
                 self.scheduleRatingPromptAfterPremiumUpgrade()
1442 1554
             case .cancelled:
1443 1555
                 break
@@ -2568,8 +2680,10 @@ private extension ViewController {
2568 2680
         contentStack.translatesAutoresizingMaskIntoConstraints = false
2569 2681
         contentStack.orientation = .vertical
2570 2682
         contentStack.spacing = 12
2571
-        contentStack.alignment = .leading
2683
+        contentStack.distribution = .fill
2684
+        contentStack.alignment = .centerX
2572 2685
         panel.addSubview(contentStack)
2686
+        let paywallLayoutWidth: CGFloat = 980
2573 2687
 
2574 2688
         let topRow = NSStackView()
2575 2689
         topRow.translatesAutoresizingMaskIntoConstraints = false
@@ -2577,73 +2691,109 @@ private extension ViewController {
2577 2691
         topRow.alignment = .centerY
2578 2692
         topRow.distribution = .fill
2579 2693
         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
2694
+        topRow.addArrangedSubview(textLabel("Meetings Pro", font: NSFont.systemFont(ofSize: 27, weight: .bold), color: palette.textPrimary))
2695
+        var closeButton: HoverButton?
2696
+        if storeKitCoordinator.hasPremiumAccess {
2697
+            let button = HoverButton(title: "✕", target: self, action: #selector(closePaywallClicked(_:)))
2698
+            button.translatesAutoresizingMaskIntoConstraints = false
2699
+            button.isBordered = false
2700
+            button.bezelStyle = .regularSquare
2701
+            button.wantsLayer = true
2702
+            button.layer?.cornerRadius = 14
2703
+            button.layer?.backgroundColor = palette.inputBackground.cgColor
2704
+            button.layer?.borderColor = palette.inputBorder.cgColor
2705
+            button.layer?.borderWidth = 1
2706
+            button.font = typography.iconButton
2707
+            button.contentTintColor = palette.textSecondary
2708
+            button.widthAnchor.constraint(equalToConstant: 28).isActive = true
2709
+            button.heightAnchor.constraint(equalToConstant: 28).isActive = true
2710
+            button.onHoverChanged = { [weak button, weak self] hovering in
2711
+                guard let button, let self else { return }
2712
+                let base = self.palette.inputBackground
2713
+                let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black
2714
+                let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
2715
+                button.layer?.backgroundColor = (hovering ? hover : base).cgColor
2716
+                button.contentTintColor = hovering ? (self.darkModeEnabled ? .white : self.palette.textPrimary) : self.palette.textSecondary
2717
+            }
2718
+            panel.addSubview(button)
2719
+            closeButton = button
2604 2720
         }
2605
-        topRow.addArrangedSubview(closeButton)
2606
-        topRow.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
2607 2721
         contentStack.addArrangedSubview(topRow)
2722
+        topRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2723
+
2724
+        let hero = roundedContainer(cornerRadius: 16, color: palette.sectionCard)
2725
+        hero.translatesAutoresizingMaskIntoConstraints = false
2726
+        styleSurface(hero, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
2727
+        contentStack.addArrangedSubview(hero)
2728
+        hero.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2729
+
2730
+        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))
2731
+        hero.addSubview(heroBadge)
2732
+        let heroTitle = textLabel("Elevate your meetings workflow", font: NSFont.systemFont(ofSize: 22, weight: .bold), color: palette.textPrimary)
2733
+        let heroSubtitle = textLabel("Unlock automation, premium meeting tools, and priority support for every session.", font: NSFont.systemFont(ofSize: 13, weight: .medium), color: palette.textSecondary)
2734
+        heroTitle.maximumNumberOfLines = 2
2735
+        heroSubtitle.maximumNumberOfLines = 3
2736
+        hero.addSubview(heroTitle)
2737
+        hero.addSubview(heroSubtitle)
2738
+        NSLayoutConstraint.activate([
2739
+            heroBadge.leadingAnchor.constraint(equalTo: hero.leadingAnchor, constant: 16),
2740
+            heroBadge.topAnchor.constraint(equalTo: hero.topAnchor, constant: 14),
2741
+            heroTitle.leadingAnchor.constraint(equalTo: hero.leadingAnchor, constant: 16),
2742
+            heroTitle.trailingAnchor.constraint(lessThanOrEqualTo: hero.trailingAnchor, constant: -16),
2743
+            heroTitle.topAnchor.constraint(equalTo: heroBadge.bottomAnchor, constant: 10),
2744
+            heroSubtitle.leadingAnchor.constraint(equalTo: heroTitle.leadingAnchor),
2745
+            heroSubtitle.trailingAnchor.constraint(equalTo: hero.trailingAnchor, constant: -16),
2746
+            heroSubtitle.topAnchor.constraint(equalTo: heroTitle.bottomAnchor, constant: 8),
2747
+            heroSubtitle.bottomAnchor.constraint(equalTo: hero.bottomAnchor, constant: -16)
2748
+        ])
2608 2749
 
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)
2750
+        let benefitsRow = NSStackView()
2751
+        benefitsRow.translatesAutoresizingMaskIntoConstraints = false
2752
+        benefitsRow.orientation = .horizontal
2753
+        benefitsRow.spacing = 8
2754
+        benefitsRow.distribution = .fillEqually
2755
+        benefitsRow.alignment = .centerY
2756
+        benefitsRow.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Manage meetings"))
2757
+        benefitsRow.addArrangedSubview(paywallBenefitItem(icon: "🧠", text: "Smart scheduling"))
2758
+        benefitsRow.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Faster workflow"))
2759
+        benefitsRow.addArrangedSubview(paywallBenefitItem(icon: "🔔", text: "Reminder notifications"))
2760
+        contentStack.addArrangedSubview(benefitsRow)
2761
+        benefitsRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2762
+
2763
+        let plansRow = NSStackView()
2764
+        plansRow.translatesAutoresizingMaskIntoConstraints = false
2765
+        plansRow.orientation = .horizontal
2766
+        plansRow.spacing = 10
2767
+        plansRow.distribution = .fillEqually
2768
+        plansRow.alignment = .centerY
2613 2769
 
2614 2770
         let weeklyCard = paywallPlanCard(
2615 2771
             title: "Weekly",
2616 2772
             price: "PKR 1,100.00",
2617
-            badge: "Basic Deal",
2773
+            badge: "Basic",
2618 2774
             badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
2619 2775
             subtitle: nil,
2620 2776
             plan: .weekly,
2621 2777
             strikePrice: nil
2622 2778
         )
2623
-        contentStack.addArrangedSubview(weeklyCard)
2624
-
2625 2779
         let monthlyCard = paywallPlanCard(
2626 2780
             title: "Monthly",
2627 2781
             price: "PKR 2,500.00",
2628
-            badge: "Free Trial",
2782
+            badge: "Popular",
2629 2783
             badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
2630
-            subtitle: "625.00/week",
2784
+            subtitle: "3 days free trial",
2631 2785
             plan: .monthly,
2632 2786
             strikePrice: nil
2633 2787
         )
2634
-        contentStack.addArrangedSubview(monthlyCard)
2635
-
2636 2788
         let yearlyCard = paywallPlanCard(
2637 2789
             title: "Yearly",
2638 2790
             price: "PKR 9,900.00",
2639
-            badge: "Best Deal",
2791
+            badge: "Best Value",
2640 2792
             badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
2641 2793
             subtitle: "190.38/week",
2642 2794
             plan: .yearly,
2643 2795
             strikePrice: nil
2644 2796
         )
2645
-        contentStack.addArrangedSubview(yearlyCard)
2646
-
2647 2797
         let lifetimeCard = paywallPlanCard(
2648 2798
             title: "Lifetime",
2649 2799
             price: "PKR 14,900.00",
@@ -2653,9 +2803,25 @@ private extension ViewController {
2653 2803
             plan: .lifetime,
2654 2804
             strikePrice: "PKR 29,800.00"
2655 2805
         )
2656
-        contentStack.addArrangedSubview(lifetimeCard)
2806
+        plansRow.addArrangedSubview(weeklyCard)
2807
+        plansRow.addArrangedSubview(monthlyCard)
2808
+        plansRow.addArrangedSubview(yearlyCard)
2809
+        plansRow.addArrangedSubview(lifetimeCard)
2810
+        contentStack.addArrangedSubview(plansRow)
2811
+        plansRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2657 2812
         updatePaywallPlanSelection()
2658
-        contentStack.setCustomSpacing(20, after: lifetimeCard)
2813
+
2814
+        let trustRow = NSStackView()
2815
+        trustRow.translatesAutoresizingMaskIntoConstraints = false
2816
+        trustRow.orientation = .horizontal
2817
+        trustRow.spacing = 8
2818
+        trustRow.distribution = .fillEqually
2819
+        trustRow.alignment = .centerY
2820
+        trustRow.addArrangedSubview(paywallMetaItem(title: "Cancel anytime", subtitle: "No lock-in"))
2821
+        trustRow.addArrangedSubview(paywallMetaItem(title: "Instant access", subtitle: "Unlock all tools"))
2822
+        trustRow.addArrangedSubview(paywallMetaItem(title: "Secure billing", subtitle: "Handled by Apple"))
2823
+        contentStack.addArrangedSubview(trustRow)
2824
+        trustRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2659 2825
 
2660 2826
         let offer = textLabel(paywallOfferText(for: selectedPremiumPlan), font: NSFont.systemFont(ofSize: 13, weight: .semibold), color: palette.textPrimary)
2661 2827
         offer.alignment = .center
@@ -2663,26 +2829,24 @@ private extension ViewController {
2663 2829
         let offerWrap = NSView()
2664 2830
         offerWrap.translatesAutoresizingMaskIntoConstraints = false
2665 2831
         offerWrap.addSubview(offer)
2832
+        contentStack.addArrangedSubview(offerWrap)
2833
+        offerWrap.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2666 2834
         NSLayoutConstraint.activate([
2667
-            offerWrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth),
2668 2835
             offer.centerXAnchor.constraint(equalTo: offerWrap.centerXAnchor),
2669
-            offer.topAnchor.constraint(equalTo: offerWrap.topAnchor, constant: 6),
2836
+            offer.topAnchor.constraint(equalTo: offerWrap.topAnchor, constant: 4),
2670 2837
             offer.bottomAnchor.constraint(equalTo: offerWrap.bottomAnchor, constant: -2)
2671 2838
         ])
2672
-        contentStack.addArrangedSubview(offerWrap)
2673
-        contentStack.setCustomSpacing(18, after: offerWrap)
2674 2839
 
2675 2840
         let continueButton = HoverButton(title: "", target: self, action: #selector(paywallContinueClicked(_:)))
2676 2841
         continueButton.translatesAutoresizingMaskIntoConstraints = false
2677 2842
         continueButton.isBordered = false
2678 2843
         continueButton.bezelStyle = .regularSquare
2679 2844
         continueButton.wantsLayer = true
2680
-        continueButton.layer?.cornerRadius = 14
2845
+        continueButton.layer?.cornerRadius = 12
2681 2846
         continueButton.layer?.backgroundColor = palette.primaryBlue.cgColor
2682
-        continueButton.heightAnchor.constraint(equalToConstant: 44).isActive = true
2683
-        continueButton.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
2847
+        continueButton.heightAnchor.constraint(equalToConstant: 36).isActive = true
2684 2848
         styleSurface(continueButton, borderColor: palette.primaryBlueBorder, borderWidth: 1, shadow: true)
2685
-        let continueLabel = textLabel("Continue", font: NSFont.systemFont(ofSize: 16, weight: .bold), color: .white)
2849
+        let continueLabel = textLabel("Continue", font: NSFont.systemFont(ofSize: 14, weight: .bold), color: .white)
2686 2850
         continueButton.addSubview(continueLabel)
2687 2851
         NSLayoutConstraint.activate([
2688 2852
             continueLabel.centerXAnchor.constraint(equalTo: continueButton.centerXAnchor),
@@ -2698,31 +2862,36 @@ private extension ViewController {
2698 2862
         paywallContinueButton = continueButton
2699 2863
         paywallContinueLabel = continueLabel
2700 2864
         contentStack.addArrangedSubview(continueButton)
2701
-        contentStack.setCustomSpacing(16, after: continueButton)
2865
+        continueButton.widthAnchor.constraint(equalToConstant: 360).isActive = true
2702 2866
 
2703 2867
         let secure = textLabel("Secured by Apple. Cancel anytime.", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: palette.textSecondary)
2704 2868
         secure.alignment = .center
2705 2869
         let secureWrap = NSView()
2706 2870
         secureWrap.translatesAutoresizingMaskIntoConstraints = false
2707 2871
         secureWrap.addSubview(secure)
2872
+        contentStack.addArrangedSubview(secureWrap)
2873
+        secureWrap.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2708 2874
         NSLayoutConstraint.activate([
2709
-            secureWrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth),
2710 2875
             secure.centerXAnchor.constraint(equalTo: secureWrap.centerXAnchor),
2711
-            secure.topAnchor.constraint(equalTo: secureWrap.topAnchor, constant: 4),
2712
-            secure.bottomAnchor.constraint(equalTo: secureWrap.bottomAnchor, constant: -8)
2876
+            secure.topAnchor.constraint(equalTo: secureWrap.topAnchor, constant: 2),
2877
+            secure.bottomAnchor.constraint(equalTo: secureWrap.bottomAnchor, constant: -4)
2713 2878
         ])
2714
-        contentStack.addArrangedSubview(secureWrap)
2715
-        contentStack.setCustomSpacing(16, after: secureWrap)
2716 2879
 
2717 2880
         let footer = paywallFooterLinks()
2718 2881
         contentStack.addArrangedSubview(footer)
2882
+        footer.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2719 2883
 
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
-        ])
2884
+        var panelConstraints: [NSLayoutConstraint] = [
2885
+            contentStack.centerXAnchor.constraint(equalTo: panel.centerXAnchor),
2886
+            contentStack.widthAnchor.constraint(equalToConstant: paywallLayoutWidth),
2887
+            contentStack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 80),
2888
+            contentStack.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -12)
2889
+        ]
2890
+        if let closeButton {
2891
+            panelConstraints.append(closeButton.topAnchor.constraint(equalTo: panel.topAnchor, constant: 30))
2892
+            panelConstraints.append(closeButton.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -30))
2893
+        }
2894
+        NSLayoutConstraint.activate(panelConstraints)
2726 2895
 
2727 2896
         refreshPaywallStoreUI()
2728 2897
         return panel
@@ -2743,8 +2912,8 @@ private extension ViewController {
2743 2912
         wrapper.bezelStyle = .regularSquare
2744 2913
         wrapper.wantsLayer = true
2745 2914
         wrapper.layer?.backgroundColor = NSColor.clear.cgColor
2746
-        wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
2747
-        wrapper.heightAnchor.constraint(equalToConstant: 94).isActive = true
2915
+        wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 160).isActive = true
2916
+        wrapper.heightAnchor.constraint(equalToConstant: 162).isActive = true
2748 2917
         wrapper.tag = plan.rawValue
2749 2918
 
2750 2919
         let card = HoverTrackingView()
@@ -2752,7 +2921,7 @@ private extension ViewController {
2752 2921
         card.wantsLayer = true
2753 2922
         card.layer?.cornerRadius = 16
2754 2923
         card.layer?.backgroundColor = palette.sectionCard.cgColor
2755
-        card.heightAnchor.constraint(equalToConstant: 82).isActive = true
2924
+        card.heightAnchor.constraint(equalToConstant: 150).isActive = true
2756 2925
         wrapper.addSubview(card)
2757 2926
         NSLayoutConstraint.activate([
2758 2927
             card.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
@@ -2788,14 +2957,15 @@ private extension ViewController {
2788 2957
         paywallPriceLabels[plan] = priceLabel
2789 2958
 
2790 2959
         NSLayoutConstraint.activate([
2791
-            badgeWrap.centerXAnchor.constraint(equalTo: card.centerXAnchor),
2960
+            badgeWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12),
2792 2961
             badgeWrap.centerYAnchor.constraint(equalTo: card.topAnchor),
2793 2962
 
2794
-            titleLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
2963
+            titleLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12),
2795 2964
             titleLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 34),
2796 2965
 
2797
-            priceLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
2798
-            priceLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 32)
2966
+            priceLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
2967
+            priceLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10),
2968
+            priceLabel.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -12)
2799 2969
         ])
2800 2970
 
2801 2971
         if let subtitle {
@@ -2803,8 +2973,9 @@ private extension ViewController {
2803 2973
             card.addSubview(sub)
2804 2974
             paywallSubtitleLabels[plan] = sub
2805 2975
             NSLayoutConstraint.activate([
2806
-                sub.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
2807
-                sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 0)
2976
+                sub.leadingAnchor.constraint(equalTo: priceLabel.leadingAnchor),
2977
+                sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 2),
2978
+                sub.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -12)
2808 2979
             ])
2809 2980
         }
2810 2981
 
@@ -2812,8 +2983,9 @@ private extension ViewController {
2812 2983
             let strike = textLabel(strikePrice, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: NSColor.systemRed)
2813 2984
             card.addSubview(strike)
2814 2985
             NSLayoutConstraint.activate([
2815
-                strike.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
2816
-                strike.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 4)
2986
+                strike.leadingAnchor.constraint(equalTo: priceLabel.leadingAnchor),
2987
+                strike.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 4),
2988
+                strike.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -12)
2817 2989
             ])
2818 2990
         }
2819 2991
 
@@ -2830,8 +3002,8 @@ private extension ViewController {
2830 3002
     func paywallFooterLinks() -> NSView {
2831 3003
         let wrap = NSView()
2832 3004
         wrap.translatesAutoresizingMaskIntoConstraints = false
2833
-        wrap.heightAnchor.constraint(equalToConstant: 34).isActive = true
2834
-        wrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
3005
+        wrap.heightAnchor.constraint(equalToConstant: 40).isActive = true
3006
+        paywallFooterActionByView.removeAll()
2835 3007
 
2836 3008
         let row = NSStackView()
2837 3009
         row.translatesAutoresizingMaskIntoConstraints = false
@@ -2841,9 +3013,15 @@ private extension ViewController {
2841 3013
         row.spacing = 0
2842 3014
         wrap.addSubview(row)
2843 3015
 
2844
-        row.addArrangedSubview(footerLink("Privacy Policy"))
2845
-        row.addArrangedSubview(footerLink("Support"))
2846
-        row.addArrangedSubview(footerLink("Terms of Services"))
3016
+        if storeKitCoordinator.hasPremiumAccess {
3017
+            row.addArrangedSubview(footerLink("Manage Subscription", action: .manageSubscription))
3018
+            row.addArrangedSubview(footerLink("Restore Purchase", action: .restorePurchase))
3019
+        } else {
3020
+            row.addArrangedSubview(footerLink("Continue with free plan", action: .continueWithFreePlan))
3021
+        }
3022
+        row.addArrangedSubview(footerLink("Privacy Policy", action: .privacyPolicy))
3023
+        row.addArrangedSubview(footerLink("Support", action: .support))
3024
+        row.addArrangedSubview(footerLink("Terms of Services", action: .termsOfServices))
2847 3025
 
2848 3026
         NSLayoutConstraint.activate([
2849 3027
             row.leadingAnchor.constraint(equalTo: wrap.leadingAnchor),
@@ -2855,25 +3033,54 @@ private extension ViewController {
2855 3033
         return wrap
2856 3034
     }
2857 3035
 
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)
3036
+    private func footerLink(_ title: String, action: PaywallFooterAction) -> NSView {
3037
+        let button = HoverButton(title: title, target: self, action: #selector(paywallFooterButtonPressed(_:)))
3038
+        button.translatesAutoresizingMaskIntoConstraints = false
3039
+        button.isBordered = false
3040
+        button.bezelStyle = .regularSquare
3041
+        button.focusRingType = .none
3042
+        button.font = NSFont.systemFont(ofSize: 12, weight: .semibold)
3043
+        button.alignment = .center
3044
+        button.setButtonType(.momentaryChange)
3045
+        button.contentTintColor = palette.textSecondary
3046
+        paywallFooterActionByView[ObjectIdentifier(button)] = action
3047
+        button.onHoverChanged = { [weak self, weak button] hovering in
3048
+            guard let self, let button else { return }
3049
+            button.contentTintColor = hovering ? (self.darkModeEnabled ? .white : self.palette.textPrimary) : self.palette.textSecondary
3050
+        }
3051
+        button.onHoverChanged?(false)
3052
+        return button
3053
+    }
3054
+
3055
+    func paywallMetaItem(title: String, subtitle: String) -> NSView {
3056
+        let card = HoverTrackingView()
3057
+        card.translatesAutoresizingMaskIntoConstraints = false
3058
+        card.wantsLayer = true
3059
+        card.layer?.cornerRadius = 10
3060
+        card.layer?.backgroundColor = palette.inputBackground.cgColor
3061
+        card.heightAnchor.constraint(equalToConstant: 44).isActive = true
3062
+        styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
2864 3063
 
3064
+        let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 11, weight: .semibold), color: palette.textPrimary)
3065
+        let subtitleLabel = textLabel(subtitle, font: NSFont.systemFont(ofSize: 10, weight: .medium), color: palette.textSecondary)
3066
+        card.addSubview(titleLabel)
3067
+        card.addSubview(subtitleLabel)
2865 3068
         NSLayoutConstraint.activate([
2866
-            label.centerXAnchor.constraint(equalTo: container.centerXAnchor),
2867
-            label.centerYAnchor.constraint(equalTo: container.centerYAnchor)
3069
+            titleLabel.centerXAnchor.constraint(equalTo: card.centerXAnchor),
3070
+            titleLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 7),
3071
+            subtitleLabel.centerXAnchor.constraint(equalTo: card.centerXAnchor),
3072
+            subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 1),
3073
+            subtitleLabel.bottomAnchor.constraint(lessThanOrEqualTo: card.bottomAnchor, constant: -6)
2868 3074
         ])
2869 3075
 
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
3076
+        let base = palette.inputBackground
3077
+        let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
3078
+        let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
3079
+        card.onHoverChanged = { [weak card] hovering in
3080
+            card?.layer?.backgroundColor = (hovering ? hover : base).cgColor
2874 3081
         }
2875
-        container.onHoverChanged?(false)
2876
-        return container
3082
+        card.onHoverChanged?(false)
3083
+        return card
2877 3084
     }
2878 3085
 
2879 3086
     func paywallBenefitsSection() -> NSView {