|
|
@@ -9,6 +9,7 @@ import Cocoa
|
|
9
|
9
|
import QuartzCore
|
|
10
|
10
|
import WebKit
|
|
11
|
11
|
import AuthenticationServices
|
|
|
12
|
+import StoreKit
|
|
12
|
13
|
|
|
13
|
14
|
private enum SidebarPage: Int {
|
|
14
|
15
|
case joinMeetings = 0
|
|
|
@@ -38,6 +39,168 @@ private enum PremiumPlan: Int {
|
|
38
|
39
|
case lifetime = 3
|
|
39
|
40
|
}
|
|
40
|
41
|
|
|
|
42
|
+private enum PremiumStoreProduct {
|
|
|
43
|
+ static let weekly = "com.mqldev.meetingsapp.premium.weekly"
|
|
|
44
|
+ static let monthly = "com.mqldev.meetingsapp.premium.monthly"
|
|
|
45
|
+ static let yearly = "com.mqldev.meetingsapp.premium.yearly"
|
|
|
46
|
+ static let lifetime = "com.mqldev.meetingsapp.premium.lifetime"
|
|
|
47
|
+
|
|
|
48
|
+ static let allIDs = [weekly, monthly, yearly, lifetime]
|
|
|
49
|
+
|
|
|
50
|
+ static func productID(for plan: PremiumPlan) -> String {
|
|
|
51
|
+ switch plan {
|
|
|
52
|
+ case .weekly: return weekly
|
|
|
53
|
+ case .monthly: return monthly
|
|
|
54
|
+ case .yearly: return yearly
|
|
|
55
|
+ case .lifetime: return lifetime
|
|
|
56
|
+ }
|
|
|
57
|
+ }
|
|
|
58
|
+
|
|
|
59
|
+ static func plan(for productID: String) -> PremiumPlan? {
|
|
|
60
|
+ switch productID {
|
|
|
61
|
+ case weekly: return .weekly
|
|
|
62
|
+ case monthly: return .monthly
|
|
|
63
|
+ case yearly: return .yearly
|
|
|
64
|
+ case lifetime: return .lifetime
|
|
|
65
|
+ default: return nil
|
|
|
66
|
+ }
|
|
|
67
|
+ }
|
|
|
68
|
+}
|
|
|
69
|
+
|
|
|
70
|
+@MainActor
|
|
|
71
|
+private final class StoreKitCoordinator {
|
|
|
72
|
+ enum PurchaseOutcome {
|
|
|
73
|
+ case success
|
|
|
74
|
+ case cancelled
|
|
|
75
|
+ case pending
|
|
|
76
|
+ case unavailable
|
|
|
77
|
+ case alreadyOwned
|
|
|
78
|
+ case failed(String)
|
|
|
79
|
+ }
|
|
|
80
|
+
|
|
|
81
|
+ private(set) var productsByID: [String: Product] = [:]
|
|
|
82
|
+ private(set) var activeEntitlementProductIDs: Set<String> = []
|
|
|
83
|
+ private(set) var lastProductLoadError: String?
|
|
|
84
|
+
|
|
|
85
|
+ var hasPremiumAccess: Bool { !activeEntitlementProductIDs.isEmpty }
|
|
|
86
|
+
|
|
|
87
|
+ private var transactionUpdatesTask: Task<Void, Never>?
|
|
|
88
|
+
|
|
|
89
|
+ deinit {
|
|
|
90
|
+ transactionUpdatesTask?.cancel()
|
|
|
91
|
+ }
|
|
|
92
|
+
|
|
|
93
|
+ func start() async {
|
|
|
94
|
+ if transactionUpdatesTask == nil {
|
|
|
95
|
+ transactionUpdatesTask = Task { [weak self] in
|
|
|
96
|
+ await self?.observeTransactionUpdates()
|
|
|
97
|
+ }
|
|
|
98
|
+ }
|
|
|
99
|
+ await refreshProducts()
|
|
|
100
|
+ await refreshEntitlements()
|
|
|
101
|
+ }
|
|
|
102
|
+
|
|
|
103
|
+ func refreshProducts() async {
|
|
|
104
|
+ do {
|
|
|
105
|
+ let products = try await Product.products(for: PremiumStoreProduct.allIDs)
|
|
|
106
|
+ productsByID = Dictionary(uniqueKeysWithValues: products.map { ($0.id, $0) })
|
|
|
107
|
+ lastProductLoadError = nil
|
|
|
108
|
+ } catch {
|
|
|
109
|
+ productsByID = [:]
|
|
|
110
|
+ lastProductLoadError = error.localizedDescription
|
|
|
111
|
+ }
|
|
|
112
|
+ }
|
|
|
113
|
+
|
|
|
114
|
+ func refreshEntitlements() async {
|
|
|
115
|
+ var active = Set<String>()
|
|
|
116
|
+ for await entitlement in Transaction.currentEntitlements {
|
|
|
117
|
+ guard case .verified(let transaction) = entitlement else { continue }
|
|
|
118
|
+ guard PremiumStoreProduct.allIDs.contains(transaction.productID) else { continue }
|
|
|
119
|
+ if Self.isTransactionActive(transaction) {
|
|
|
120
|
+ active.insert(transaction.productID)
|
|
|
121
|
+ }
|
|
|
122
|
+ }
|
|
|
123
|
+ activeEntitlementProductIDs = active
|
|
|
124
|
+ }
|
|
|
125
|
+
|
|
|
126
|
+ func purchase(plan: PremiumPlan) async -> PurchaseOutcome {
|
|
|
127
|
+ let productID = PremiumStoreProduct.productID(for: plan)
|
|
|
128
|
+
|
|
|
129
|
+ if activeEntitlementProductIDs.contains(productID) {
|
|
|
130
|
+ return .alreadyOwned
|
|
|
131
|
+ }
|
|
|
132
|
+
|
|
|
133
|
+ guard let product = productsByID[productID] else {
|
|
|
134
|
+ await refreshProducts()
|
|
|
135
|
+ guard let refreshed = productsByID[productID] else {
|
|
|
136
|
+ if let lastProductLoadError, !lastProductLoadError.isEmpty {
|
|
|
137
|
+ return .failed("Unable to load products: \(lastProductLoadError)")
|
|
|
138
|
+ }
|
|
|
139
|
+ let loadedIDs = productsByID.keys.sorted().joined(separator: ", ")
|
|
|
140
|
+ let debugIDs = loadedIDs.isEmpty ? "none" : loadedIDs
|
|
|
141
|
+ return .failed("Product ID not found in StoreKit response. Requested: \(productID). Loaded IDs: \(debugIDs)")
|
|
|
142
|
+ }
|
|
|
143
|
+ return await purchase(product: refreshed)
|
|
|
144
|
+ }
|
|
|
145
|
+
|
|
|
146
|
+ return await purchase(product: product)
|
|
|
147
|
+ }
|
|
|
148
|
+
|
|
|
149
|
+ func restorePurchases() async -> String {
|
|
|
150
|
+ do {
|
|
|
151
|
+ try await AppStore.sync()
|
|
|
152
|
+ await refreshEntitlements()
|
|
|
153
|
+ if hasPremiumAccess {
|
|
|
154
|
+ return "Purchases restored successfully."
|
|
|
155
|
+ }
|
|
|
156
|
+ return "No previous premium purchase was found for this Apple ID."
|
|
|
157
|
+ } catch {
|
|
|
158
|
+ return "Restore failed: \(error.localizedDescription)"
|
|
|
159
|
+ }
|
|
|
160
|
+ }
|
|
|
161
|
+
|
|
|
162
|
+ private func purchase(product: Product) async -> PurchaseOutcome {
|
|
|
163
|
+ do {
|
|
|
164
|
+ let result = try await product.purchase()
|
|
|
165
|
+ switch result {
|
|
|
166
|
+ case .success(let verificationResult):
|
|
|
167
|
+ guard case .verified(let transaction) = verificationResult else {
|
|
|
168
|
+ return .failed("Purchase verification failed.")
|
|
|
169
|
+ }
|
|
|
170
|
+ await transaction.finish()
|
|
|
171
|
+ await refreshEntitlements()
|
|
|
172
|
+ return .success
|
|
|
173
|
+ case .pending:
|
|
|
174
|
+ return .pending
|
|
|
175
|
+ case .userCancelled:
|
|
|
176
|
+ return .cancelled
|
|
|
177
|
+ @unknown default:
|
|
|
178
|
+ return .failed("Unknown purchase state.")
|
|
|
179
|
+ }
|
|
|
180
|
+ } catch {
|
|
|
181
|
+ return .failed(error.localizedDescription)
|
|
|
182
|
+ }
|
|
|
183
|
+ }
|
|
|
184
|
+
|
|
|
185
|
+ private func observeTransactionUpdates() async {
|
|
|
186
|
+ for await update in Transaction.updates {
|
|
|
187
|
+ guard case .verified(let transaction) = update else { continue }
|
|
|
188
|
+ if PremiumStoreProduct.allIDs.contains(transaction.productID) {
|
|
|
189
|
+ await refreshEntitlements()
|
|
|
190
|
+ }
|
|
|
191
|
+ await transaction.finish()
|
|
|
192
|
+ }
|
|
|
193
|
+ }
|
|
|
194
|
+
|
|
|
195
|
+ private static func isTransactionActive(_ transaction: Transaction) -> Bool {
|
|
|
196
|
+ if transaction.revocationDate != nil { return false }
|
|
|
197
|
+ if let expirationDate = transaction.expirationDate {
|
|
|
198
|
+ return expirationDate > Date()
|
|
|
199
|
+ }
|
|
|
200
|
+ return true
|
|
|
201
|
+ }
|
|
|
202
|
+}
|
|
|
203
|
+
|
|
41
|
204
|
final class ViewController: NSViewController {
|
|
42
|
205
|
private struct GoogleProfileDisplay {
|
|
43
|
206
|
let name: String
|
|
|
@@ -66,11 +229,19 @@ final class ViewController: NSViewController {
|
|
66
|
229
|
private var paywallPlanViews: [PremiumPlan: NSView] = [:]
|
|
67
|
230
|
private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
|
|
68
|
231
|
private weak var paywallOfferLabel: NSTextField?
|
|
|
232
|
+ private weak var paywallContinueLabel: NSTextField?
|
|
|
233
|
+ private weak var paywallContinueButton: NSView?
|
|
69
|
234
|
private weak var meetLinkField: NSTextField?
|
|
70
|
235
|
private weak var browseAddressField: NSTextField?
|
|
71
|
236
|
private var inAppBrowserWindowController: InAppBrowserWindowController?
|
|
72
|
237
|
private let googleOAuth = GoogleOAuthService.shared
|
|
73
|
238
|
private let calendarClient = GoogleCalendarClient()
|
|
|
239
|
+ private let storeKitCoordinator = StoreKitCoordinator()
|
|
|
240
|
+ private var storeKitStartupTask: Task<Void, Never>?
|
|
|
241
|
+ private var paywallPurchaseTask: Task<Void, Never>?
|
|
|
242
|
+ private var paywallPriceLabels: [PremiumPlan: NSTextField] = [:]
|
|
|
243
|
+ private var paywallSubtitleLabels: [PremiumPlan: NSTextField] = [:]
|
|
|
244
|
+ private var paywallContinueEnabled = true
|
|
74
|
245
|
|
|
75
|
246
|
private enum ScheduleFilter: Int {
|
|
76
|
247
|
case all = 0
|
|
|
@@ -139,6 +310,7 @@ final class ViewController: NSViewController {
|
|
139
|
310
|
palette = Palette(isDarkMode: darkModeEnabled)
|
|
140
|
311
|
setupRootView()
|
|
141
|
312
|
buildMainLayout()
|
|
|
313
|
+ startStoreKit()
|
|
142
|
314
|
}
|
|
143
|
315
|
|
|
144
|
316
|
override func viewDidAppear() {
|
|
|
@@ -167,6 +339,11 @@ final class ViewController: NSViewController {
|
|
167
|
339
|
override var representedObject: Any? {
|
|
168
|
340
|
didSet {}
|
|
169
|
341
|
}
|
|
|
342
|
+
|
|
|
343
|
+ deinit {
|
|
|
344
|
+ storeKitStartupTask?.cancel()
|
|
|
345
|
+ paywallPurchaseTask?.cancel()
|
|
|
346
|
+ }
|
|
170
|
347
|
}
|
|
171
|
348
|
|
|
172
|
349
|
private extension ViewController {
|
|
|
@@ -445,6 +622,11 @@ private extension ViewController {
|
|
445
|
622
|
settingsActionByView.removeAll()
|
|
446
|
623
|
paywallPlanViews.removeAll()
|
|
447
|
624
|
premiumPlanByView.removeAll()
|
|
|
625
|
+ paywallPriceLabels.removeAll()
|
|
|
626
|
+ paywallSubtitleLabels.removeAll()
|
|
|
627
|
+ paywallContinueLabel = nil
|
|
|
628
|
+ paywallContinueButton = nil
|
|
|
629
|
+ paywallContinueEnabled = true
|
|
448
|
630
|
|
|
449
|
631
|
googleAccountPopover?.performClose(nil)
|
|
450
|
632
|
googleAccountPopover = nil
|
|
|
@@ -459,7 +641,14 @@ private extension ViewController {
|
|
459
|
641
|
private func handleSettingsAction(_ action: SettingsAction) {
|
|
460
|
642
|
switch action {
|
|
461
|
643
|
case .restore:
|
|
462
|
|
- showSimpleAlert(title: "Restore", message: "Restore action tapped.")
|
|
|
644
|
+ settingsPopover?.performClose(nil)
|
|
|
645
|
+ settingsPopover = nil
|
|
|
646
|
+ Task { [weak self] in
|
|
|
647
|
+ guard let self else { return }
|
|
|
648
|
+ let message = await self.storeKitCoordinator.restorePurchases()
|
|
|
649
|
+ self.refreshPaywallStoreUI()
|
|
|
650
|
+ self.showSimpleAlert(title: "Restore Purchases", message: message)
|
|
|
651
|
+ }
|
|
463
|
652
|
case .rateUs:
|
|
464
|
653
|
settingsPopover?.performClose(nil)
|
|
465
|
654
|
settingsPopover = nil
|
|
|
@@ -529,6 +718,12 @@ private extension ViewController {
|
|
529
|
718
|
panel.makeKeyAndOrderFront(nil)
|
|
530
|
719
|
NSApp.activate(ignoringOtherApps: true)
|
|
531
|
720
|
paywallWindow = panel
|
|
|
721
|
+
|
|
|
722
|
+ Task { [weak self] in
|
|
|
723
|
+ guard let self else { return }
|
|
|
724
|
+ await self.storeKitCoordinator.refreshProducts()
|
|
|
725
|
+ self.refreshPaywallStoreUI()
|
|
|
726
|
+ }
|
|
532
|
727
|
}
|
|
533
|
728
|
|
|
534
|
729
|
@objc private func closePaywallClicked(_ sender: Any?) {
|
|
|
@@ -582,6 +777,19 @@ private extension ViewController {
|
|
582
|
777
|
}
|
|
583
|
778
|
|
|
584
|
779
|
private func paywallOfferText(for plan: PremiumPlan) -> String {
|
|
|
780
|
+ if storeKitCoordinator.hasPremiumAccess {
|
|
|
781
|
+ return "Premium is active on this Apple ID."
|
|
|
782
|
+ }
|
|
|
783
|
+ let productID = PremiumStoreProduct.productID(for: plan)
|
|
|
784
|
+ if let product = storeKitCoordinator.productsByID[productID] {
|
|
|
785
|
+ if product.type == .nonConsumable {
|
|
|
786
|
+ return "\(product.displayPrice) one-time purchase"
|
|
|
787
|
+ }
|
|
|
788
|
+ if let period = product.subscription?.subscriptionPeriod {
|
|
|
789
|
+ return "\(product.displayPrice)/\(subscriptionUnitText(period.unit))"
|
|
|
790
|
+ }
|
|
|
791
|
+ return product.displayPrice
|
|
|
792
|
+ }
|
|
585
|
793
|
switch plan {
|
|
586
|
794
|
case .weekly:
|
|
587
|
795
|
return "Rs 1,100.00/week"
|
|
|
@@ -594,6 +802,99 @@ private extension ViewController {
|
|
594
|
802
|
}
|
|
595
|
803
|
}
|
|
596
|
804
|
|
|
|
805
|
+ private func subscriptionUnitText(_ unit: Product.SubscriptionPeriod.Unit) -> String {
|
|
|
806
|
+ switch unit {
|
|
|
807
|
+ case .day: return "day"
|
|
|
808
|
+ case .week: return "week"
|
|
|
809
|
+ case .month: return "month"
|
|
|
810
|
+ case .year: return "year"
|
|
|
811
|
+ @unknown default: return "period"
|
|
|
812
|
+ }
|
|
|
813
|
+ }
|
|
|
814
|
+
|
|
|
815
|
+ private func startStoreKit() {
|
|
|
816
|
+ storeKitStartupTask?.cancel()
|
|
|
817
|
+ storeKitStartupTask = Task { [weak self] in
|
|
|
818
|
+ guard let self else { return }
|
|
|
819
|
+ await self.storeKitCoordinator.start()
|
|
|
820
|
+ self.refreshPaywallStoreUI()
|
|
|
821
|
+ }
|
|
|
822
|
+ }
|
|
|
823
|
+
|
|
|
824
|
+ private func refreshPaywallStoreUI() {
|
|
|
825
|
+ for (plan, label) in paywallPriceLabels {
|
|
|
826
|
+ let productID = PremiumStoreProduct.productID(for: plan)
|
|
|
827
|
+ if let product = storeKitCoordinator.productsByID[productID] {
|
|
|
828
|
+ label.stringValue = product.displayPrice
|
|
|
829
|
+ }
|
|
|
830
|
+ }
|
|
|
831
|
+ for (plan, label) in paywallSubtitleLabels {
|
|
|
832
|
+ let productID = PremiumStoreProduct.productID(for: plan)
|
|
|
833
|
+ guard let product = storeKitCoordinator.productsByID[productID],
|
|
|
834
|
+ let period = product.subscription?.subscriptionPeriod else { continue }
|
|
|
835
|
+ label.stringValue = "\(product.displayPrice)/\(subscriptionUnitText(period.unit))"
|
|
|
836
|
+ }
|
|
|
837
|
+ updatePaywallPlanSelection()
|
|
|
838
|
+ updatePaywallContinueState(isLoading: false)
|
|
|
839
|
+ }
|
|
|
840
|
+
|
|
|
841
|
+ @objc private func paywallContinueClicked(_ sender: Any?) {
|
|
|
842
|
+ startSelectedPlanPurchase()
|
|
|
843
|
+ }
|
|
|
844
|
+
|
|
|
845
|
+ private func startSelectedPlanPurchase() {
|
|
|
846
|
+ guard paywallContinueEnabled else {
|
|
|
847
|
+ if storeKitCoordinator.hasPremiumAccess {
|
|
|
848
|
+ showSimpleAlert(title: "Premium Active", message: "This Apple ID already has premium access.")
|
|
|
849
|
+ } else {
|
|
|
850
|
+ showSimpleAlert(title: "Please Wait", message: "A purchase is already being processed.")
|
|
|
851
|
+ }
|
|
|
852
|
+ return
|
|
|
853
|
+ }
|
|
|
854
|
+ paywallPurchaseTask?.cancel()
|
|
|
855
|
+ updatePaywallContinueState(isLoading: true)
|
|
|
856
|
+ let selectedPlan = selectedPremiumPlan
|
|
|
857
|
+ paywallPurchaseTask = Task { [weak self] in
|
|
|
858
|
+ guard let self else { return }
|
|
|
859
|
+ let result = await self.storeKitCoordinator.purchase(plan: selectedPlan)
|
|
|
860
|
+ self.updatePaywallContinueState(isLoading: false)
|
|
|
861
|
+ self.refreshPaywallStoreUI()
|
|
|
862
|
+ switch result {
|
|
|
863
|
+ case .success:
|
|
|
864
|
+ self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
|
|
|
865
|
+ self.paywallWindow?.performClose(nil)
|
|
|
866
|
+ case .cancelled:
|
|
|
867
|
+ break
|
|
|
868
|
+ case .pending:
|
|
|
869
|
+ self.showSimpleAlert(title: "Purchase Pending", message: "Your purchase is pending approval. You can continue once it completes.")
|
|
|
870
|
+ case .unavailable:
|
|
|
871
|
+ self.showSimpleAlert(title: "Product Not Available", message: "Unable to load this product. Check your StoreKit configuration and product IDs.")
|
|
|
872
|
+ case .alreadyOwned:
|
|
|
873
|
+ self.showSimpleAlert(title: "Already Purchased", message: "This plan is already active on your Apple ID.")
|
|
|
874
|
+ case .failed(let message):
|
|
|
875
|
+ self.showSimpleAlert(title: "Purchase Failed", message: message)
|
|
|
876
|
+ }
|
|
|
877
|
+ }
|
|
|
878
|
+ }
|
|
|
879
|
+
|
|
|
880
|
+ private func updatePaywallContinueState(isLoading: Bool) {
|
|
|
881
|
+ if isLoading {
|
|
|
882
|
+ paywallContinueEnabled = false
|
|
|
883
|
+ paywallContinueLabel?.stringValue = "Processing..."
|
|
|
884
|
+ paywallContinueButton?.alphaValue = 0.75
|
|
|
885
|
+ return
|
|
|
886
|
+ }
|
|
|
887
|
+ if storeKitCoordinator.hasPremiumAccess {
|
|
|
888
|
+ paywallContinueEnabled = false
|
|
|
889
|
+ paywallContinueLabel?.stringValue = "Premium Active"
|
|
|
890
|
+ paywallContinueButton?.alphaValue = 0.75
|
|
|
891
|
+ } else {
|
|
|
892
|
+ paywallContinueEnabled = true
|
|
|
893
|
+ paywallContinueLabel?.stringValue = "Continue"
|
|
|
894
|
+ paywallContinueButton?.alphaValue = 1.0
|
|
|
895
|
+ }
|
|
|
896
|
+ }
|
|
|
897
|
+
|
|
597
|
898
|
private func applyPaywallPlanStyle(_ card: NSView, isSelected: Bool, hovering: Bool = false) {
|
|
598
|
899
|
let selectedBorder = NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1)
|
|
599
|
900
|
let idleBorder = palette.inputBorder
|
|
|
@@ -1270,8 +1571,10 @@ private extension ViewController {
|
|
1270
|
1571
|
contentStack.addArrangedSubview(offerWrap)
|
|
1271
|
1572
|
contentStack.setCustomSpacing(18, after: offerWrap)
|
|
1272
|
1573
|
|
|
1273
|
|
- let continueButton = HoverTrackingView()
|
|
|
1574
|
+ let continueButton = HoverButton(title: "", target: self, action: #selector(paywallContinueClicked(_:)))
|
|
1274
|
1575
|
continueButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1576
|
+ continueButton.isBordered = false
|
|
|
1577
|
+ continueButton.bezelStyle = .regularSquare
|
|
1275
|
1578
|
continueButton.wantsLayer = true
|
|
1276
|
1579
|
continueButton.layer?.cornerRadius = 14
|
|
1277
|
1580
|
continueButton.layer?.backgroundColor = palette.primaryBlue.cgColor
|
|
|
@@ -1291,6 +1594,8 @@ private extension ViewController {
|
|
1291
|
1594
|
continueButton.layer?.backgroundColor = (hovering ? hoverBlue : baseBlue).cgColor
|
|
1292
|
1595
|
}
|
|
1293
|
1596
|
continueButton.onHoverChanged?(false)
|
|
|
1597
|
+ paywallContinueButton = continueButton
|
|
|
1598
|
+ paywallContinueLabel = continueLabel
|
|
1294
|
1599
|
contentStack.addArrangedSubview(continueButton)
|
|
1295
|
1600
|
contentStack.setCustomSpacing(16, after: continueButton)
|
|
1296
|
1601
|
|
|
|
@@ -1318,6 +1623,7 @@ private extension ViewController {
|
|
1318
|
1623
|
contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -12)
|
|
1319
|
1624
|
])
|
|
1320
|
1625
|
|
|
|
1626
|
+ refreshPaywallStoreUI()
|
|
1321
|
1627
|
return panel
|
|
1322
|
1628
|
}
|
|
1323
|
1629
|
|
|
|
@@ -1378,6 +1684,7 @@ private extension ViewController {
|
|
1378
|
1684
|
card.addSubview(titleLabel)
|
|
1379
|
1685
|
let priceLabel = textLabel(price, font: NSFont.systemFont(ofSize: 12, weight: .bold), color: palette.textPrimary)
|
|
1380
|
1686
|
card.addSubview(priceLabel)
|
|
|
1687
|
+ paywallPriceLabels[plan] = priceLabel
|
|
1381
|
1688
|
|
|
1382
|
1689
|
NSLayoutConstraint.activate([
|
|
1383
|
1690
|
badgeWrap.centerXAnchor.constraint(equalTo: card.centerXAnchor),
|
|
|
@@ -1393,6 +1700,7 @@ private extension ViewController {
|
|
1393
|
1700
|
if let subtitle {
|
|
1394
|
1701
|
let sub = textLabel(subtitle, font: NSFont.systemFont(ofSize: 10, weight: .semibold), color: palette.textSecondary)
|
|
1395
|
1702
|
card.addSubview(sub)
|
|
|
1703
|
+ paywallSubtitleLabels[plan] = sub
|
|
1396
|
1704
|
NSLayoutConstraint.activate([
|
|
1397
|
1705
|
sub.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
|
|
1398
|
1706
|
sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 0)
|