|
|
@@ -1,4 +1,5 @@
|
|
1
|
1
|
import Cocoa
|
|
|
2
|
+import StoreKit
|
|
2
|
3
|
|
|
3
|
4
|
// MARK: - Plan Model
|
|
4
|
5
|
|
|
|
@@ -7,6 +8,14 @@ enum PaywallPlan: CaseIterable {
|
|
7
|
8
|
case yearly
|
|
8
|
9
|
case lifetime
|
|
9
|
10
|
|
|
|
11
|
+ var productID: String {
|
|
|
12
|
+ switch self {
|
|
|
13
|
+ case .monthly: StoreProductID.monthly
|
|
|
14
|
+ case .yearly: StoreProductID.yearly
|
|
|
15
|
+ case .lifetime: StoreProductID.lifetime
|
|
|
16
|
+ }
|
|
|
17
|
+ }
|
|
|
18
|
+
|
|
10
|
19
|
var title: String {
|
|
11
|
20
|
switch self {
|
|
12
|
21
|
case .monthly: "Monthly"
|
|
|
@@ -38,6 +47,246 @@ enum PaywallPlan: CaseIterable {
|
|
38
|
47
|
case .lifetime: "Buy Lifetime Access"
|
|
39
|
48
|
}
|
|
40
|
49
|
}
|
|
|
50
|
+
|
|
|
51
|
+ func localizedPrice(from product: Product?) -> String {
|
|
|
52
|
+ product?.displayPrice ?? price
|
|
|
53
|
+ }
|
|
|
54
|
+
|
|
|
55
|
+ func localizedSubtitle(from product: Product?) -> String {
|
|
|
56
|
+ guard let product else { return subtitle }
|
|
|
57
|
+
|
|
|
58
|
+ switch self {
|
|
|
59
|
+ case .monthly:
|
|
|
60
|
+ return "\(product.displayPrice) / month, cancel anytime"
|
|
|
61
|
+ case .yearly:
|
|
|
62
|
+ if product.subscription?.introductoryOffer != nil {
|
|
|
63
|
+ return "Eligible new subscribers get 7 days free, then \(product.displayPrice) / year"
|
|
|
64
|
+ }
|
|
|
65
|
+ return "\(product.displayPrice) / year, cancel anytime"
|
|
|
66
|
+ case .lifetime:
|
|
|
67
|
+ return "\(product.displayPrice) once, lifetime access"
|
|
|
68
|
+ }
|
|
|
69
|
+ }
|
|
|
70
|
+
|
|
|
71
|
+ func localizedCTATitle(from product: Product?) -> String {
|
|
|
72
|
+ guard let product else { return ctaTitle }
|
|
|
73
|
+
|
|
|
74
|
+ switch self {
|
|
|
75
|
+ case .monthly:
|
|
|
76
|
+ return "Subscribe for \(product.displayPrice) / Month"
|
|
|
77
|
+ case .yearly:
|
|
|
78
|
+ if product.subscription?.introductoryOffer != nil {
|
|
|
79
|
+ return "Start 7-Day Free Trial"
|
|
|
80
|
+ }
|
|
|
81
|
+ return "Subscribe for \(product.displayPrice) / Year"
|
|
|
82
|
+ case .lifetime:
|
|
|
83
|
+ return "Buy Lifetime Access for \(product.displayPrice)"
|
|
|
84
|
+ }
|
|
|
85
|
+ }
|
|
|
86
|
+}
|
|
|
87
|
+
|
|
|
88
|
+// MARK: - StoreKit
|
|
|
89
|
+
|
|
|
90
|
+enum StoreProductID {
|
|
|
91
|
+ static let monthly = "MQL-DEV.smart-printer.premium.monthly"
|
|
|
92
|
+ static let yearly = "MQL-DEV.smart-printer.premium.yearly"
|
|
|
93
|
+ static let lifetime = "MQL-DEV.smart-printer.premium.lifetime"
|
|
|
94
|
+
|
|
|
95
|
+ static let all: Set<String> = [monthly, yearly, lifetime]
|
|
|
96
|
+}
|
|
|
97
|
+
|
|
|
98
|
+enum StoreError: LocalizedError {
|
|
|
99
|
+ case productNotFound
|
|
|
100
|
+ case failedVerification
|
|
|
101
|
+
|
|
|
102
|
+ var errorDescription: String? {
|
|
|
103
|
+ switch self {
|
|
|
104
|
+ case .productNotFound:
|
|
|
105
|
+ "The selected plan is not available right now. Please try again later."
|
|
|
106
|
+ case .failedVerification:
|
|
|
107
|
+ "We couldn't verify your purchase. Please contact support."
|
|
|
108
|
+ }
|
|
|
109
|
+ }
|
|
|
110
|
+}
|
|
|
111
|
+
|
|
|
112
|
+@MainActor
|
|
|
113
|
+final class StoreManager {
|
|
|
114
|
+ static let shared = StoreManager()
|
|
|
115
|
+
|
|
|
116
|
+ private(set) var products: [Product] = []
|
|
|
117
|
+ private(set) var isPremium = false
|
|
|
118
|
+ private(set) var isLoadingProducts = false
|
|
|
119
|
+ private(set) var isPurchasing = false
|
|
|
120
|
+
|
|
|
121
|
+ private var transactionListener: Task<Void, Never>?
|
|
|
122
|
+ private var hasStarted = false
|
|
|
123
|
+
|
|
|
124
|
+ private init() {}
|
|
|
125
|
+
|
|
|
126
|
+ func start() {
|
|
|
127
|
+ guard !hasStarted else { return }
|
|
|
128
|
+ hasStarted = true
|
|
|
129
|
+
|
|
|
130
|
+ transactionListener = Task { [weak self] in
|
|
|
131
|
+ for await update in Transaction.updates {
|
|
|
132
|
+ await self?.handleTransactionUpdate(update)
|
|
|
133
|
+ }
|
|
|
134
|
+ }
|
|
|
135
|
+
|
|
|
136
|
+ Task {
|
|
|
137
|
+ await loadProducts()
|
|
|
138
|
+ await refreshPremiumStatus()
|
|
|
139
|
+ }
|
|
|
140
|
+ }
|
|
|
141
|
+
|
|
|
142
|
+ func product(for plan: PaywallPlan) -> Product? {
|
|
|
143
|
+ products.first { $0.id == plan.productID }
|
|
|
144
|
+ }
|
|
|
145
|
+
|
|
|
146
|
+ func loadProducts() async {
|
|
|
147
|
+ isLoadingProducts = true
|
|
|
148
|
+ postStoreStateDidChange()
|
|
|
149
|
+ defer {
|
|
|
150
|
+ isLoadingProducts = false
|
|
|
151
|
+ postStoreStateDidChange()
|
|
|
152
|
+ }
|
|
|
153
|
+
|
|
|
154
|
+ do {
|
|
|
155
|
+ products = try await Product.products(for: StoreProductID.all)
|
|
|
156
|
+ .sorted { lhs, rhs in
|
|
|
157
|
+ productSortOrder(for: lhs.id) < productSortOrder(for: rhs.id)
|
|
|
158
|
+ }
|
|
|
159
|
+ NotificationCenter.default.post(name: .storeProductsDidUpdate, object: nil)
|
|
|
160
|
+ } catch {
|
|
|
161
|
+ NSLog("Failed to load products: \(error.localizedDescription)")
|
|
|
162
|
+ }
|
|
|
163
|
+ }
|
|
|
164
|
+
|
|
|
165
|
+ @discardableResult
|
|
|
166
|
+ func purchase(plan: PaywallPlan) async throws -> Bool {
|
|
|
167
|
+ if products.isEmpty {
|
|
|
168
|
+ await loadProducts()
|
|
|
169
|
+ }
|
|
|
170
|
+
|
|
|
171
|
+ guard let product = product(for: plan) else {
|
|
|
172
|
+ throw StoreError.productNotFound
|
|
|
173
|
+ }
|
|
|
174
|
+
|
|
|
175
|
+ isPurchasing = true
|
|
|
176
|
+ postStoreStateDidChange()
|
|
|
177
|
+ defer {
|
|
|
178
|
+ isPurchasing = false
|
|
|
179
|
+ postStoreStateDidChange()
|
|
|
180
|
+ }
|
|
|
181
|
+
|
|
|
182
|
+ let result = try await product.purchase()
|
|
|
183
|
+
|
|
|
184
|
+ switch result {
|
|
|
185
|
+ case .success(let verification):
|
|
|
186
|
+ let transaction = try checkVerified(verification)
|
|
|
187
|
+ await transaction.finish()
|
|
|
188
|
+ await refreshPremiumStatus()
|
|
|
189
|
+ return isPremium
|
|
|
190
|
+ case .userCancelled, .pending:
|
|
|
191
|
+ return false
|
|
|
192
|
+ @unknown default:
|
|
|
193
|
+ return false
|
|
|
194
|
+ }
|
|
|
195
|
+ }
|
|
|
196
|
+
|
|
|
197
|
+ @discardableResult
|
|
|
198
|
+ func restorePurchases() async throws -> Bool {
|
|
|
199
|
+ isPurchasing = true
|
|
|
200
|
+ postStoreStateDidChange()
|
|
|
201
|
+ defer {
|
|
|
202
|
+ isPurchasing = false
|
|
|
203
|
+ postStoreStateDidChange()
|
|
|
204
|
+ }
|
|
|
205
|
+
|
|
|
206
|
+ try await AppStore.sync()
|
|
|
207
|
+ await refreshPremiumStatus()
|
|
|
208
|
+ return isPremium
|
|
|
209
|
+ }
|
|
|
210
|
+
|
|
|
211
|
+ func showAlert(title: String, message: String, on window: NSWindow?) {
|
|
|
212
|
+ let alert = NSAlert()
|
|
|
213
|
+ alert.messageText = title
|
|
|
214
|
+ alert.informativeText = message
|
|
|
215
|
+ alert.alertStyle = .informational
|
|
|
216
|
+ alert.addButton(withTitle: "OK")
|
|
|
217
|
+ if let window {
|
|
|
218
|
+ alert.beginSheetModal(for: window)
|
|
|
219
|
+ } else {
|
|
|
220
|
+ alert.runModal()
|
|
|
221
|
+ }
|
|
|
222
|
+ }
|
|
|
223
|
+
|
|
|
224
|
+ func showPurchaseError(_ error: Error, on window: NSWindow?) {
|
|
|
225
|
+ if let storeError = error as? StoreError {
|
|
|
226
|
+ showAlert(title: "Purchase Failed", message: storeError.localizedDescription, on: window)
|
|
|
227
|
+ return
|
|
|
228
|
+ }
|
|
|
229
|
+
|
|
|
230
|
+ if let storeKitError = error as? StoreKitError, case .userCancelled = storeKitError {
|
|
|
231
|
+ return
|
|
|
232
|
+ }
|
|
|
233
|
+
|
|
|
234
|
+ showAlert(title: "Purchase Failed", message: error.localizedDescription, on: window)
|
|
|
235
|
+ }
|
|
|
236
|
+
|
|
|
237
|
+ private func handleTransactionUpdate(_ update: VerificationResult<Transaction>) async {
|
|
|
238
|
+ do {
|
|
|
239
|
+ let transaction = try checkVerified(update)
|
|
|
240
|
+ await transaction.finish()
|
|
|
241
|
+ await refreshPremiumStatus()
|
|
|
242
|
+ } catch {
|
|
|
243
|
+ NSLog("Transaction verification failed: \(error.localizedDescription)")
|
|
|
244
|
+ }
|
|
|
245
|
+ }
|
|
|
246
|
+
|
|
|
247
|
+ private func refreshPremiumStatus() async {
|
|
|
248
|
+ var hasPremium = false
|
|
|
249
|
+
|
|
|
250
|
+ for await result in Transaction.currentEntitlements {
|
|
|
251
|
+ guard let transaction = try? checkVerified(result) else { continue }
|
|
|
252
|
+ if StoreProductID.all.contains(transaction.productID) {
|
|
|
253
|
+ hasPremium = true
|
|
|
254
|
+ break
|
|
|
255
|
+ }
|
|
|
256
|
+ }
|
|
|
257
|
+
|
|
|
258
|
+ guard hasPremium != isPremium else { return }
|
|
|
259
|
+ isPremium = hasPremium
|
|
|
260
|
+ NotificationCenter.default.post(name: .premiumStatusDidChange, object: nil)
|
|
|
261
|
+ }
|
|
|
262
|
+
|
|
|
263
|
+ private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
|
|
|
264
|
+ switch result {
|
|
|
265
|
+ case .unverified:
|
|
|
266
|
+ throw StoreError.failedVerification
|
|
|
267
|
+ case .verified(let safe):
|
|
|
268
|
+ return safe
|
|
|
269
|
+ }
|
|
|
270
|
+ }
|
|
|
271
|
+
|
|
|
272
|
+ private func productSortOrder(for productID: String) -> Int {
|
|
|
273
|
+ switch productID {
|
|
|
274
|
+ case StoreProductID.monthly: 0
|
|
|
275
|
+ case StoreProductID.yearly: 1
|
|
|
276
|
+ case StoreProductID.lifetime: 2
|
|
|
277
|
+ default: 99
|
|
|
278
|
+ }
|
|
|
279
|
+ }
|
|
|
280
|
+
|
|
|
281
|
+ private func postStoreStateDidChange() {
|
|
|
282
|
+ NotificationCenter.default.post(name: .storeStateDidChange, object: nil)
|
|
|
283
|
+ }
|
|
|
284
|
+}
|
|
|
285
|
+
|
|
|
286
|
+extension Notification.Name {
|
|
|
287
|
+ static let premiumStatusDidChange = Notification.Name("premiumStatusDidChange")
|
|
|
288
|
+ static let storeProductsDidUpdate = Notification.Name("storeProductsDidUpdate")
|
|
|
289
|
+ static let storeStateDidChange = Notification.Name("storeStateDidChange")
|
|
41
|
290
|
}
|
|
42
|
291
|
|
|
43
|
292
|
// MARK: - Left Panel
|
|
|
@@ -275,6 +524,11 @@ private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
|
|
275
|
524
|
@available(*, unavailable)
|
|
276
|
525
|
required init?(coder: NSCoder) { nil }
|
|
277
|
526
|
|
|
|
527
|
+ func updateDisplay(product: Product?) {
|
|
|
528
|
+ subtitleLabel.stringValue = plan.localizedSubtitle(from: product)
|
|
|
529
|
+ priceLabel.stringValue = plan.localizedPrice(from: product)
|
|
|
530
|
+ }
|
|
|
531
|
+
|
|
278
|
532
|
func refreshAppearance() {
|
|
279
|
533
|
updateAppearance()
|
|
280
|
534
|
subtitleLabel.refreshThemeLabelColor()
|
|
|
@@ -474,8 +728,7 @@ private final class PaywallCTAButton: NSButton, AppearanceRefreshable {
|
|
474
|
728
|
|
|
475
|
729
|
final class PaywallView: NSView, AppearanceRefreshable {
|
|
476
|
730
|
var onClose: (() -> Void)?
|
|
477
|
|
- var onPurchase: ((PaywallPlan) -> Void)?
|
|
478
|
|
- var onRestore: (() -> Void)?
|
|
|
731
|
+ var onPurchaseSucceeded: (() -> Void)?
|
|
479
|
732
|
|
|
480
|
733
|
private var selectedPlan: PaywallPlan = .yearly
|
|
481
|
734
|
private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
|
|
|
@@ -484,6 +737,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
484
|
737
|
private var rightTitle: NSTextField!
|
|
485
|
738
|
private var rightSubtitle: NSTextField!
|
|
486
|
739
|
private var trustStack: NSStackView!
|
|
|
740
|
+ private var storeObservers: [NSObjectProtocol] = []
|
|
487
|
741
|
|
|
488
|
742
|
init() {
|
|
489
|
743
|
super.init(frame: .zero)
|
|
|
@@ -491,9 +745,46 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
491
|
745
|
wantsLayer = true
|
|
492
|
746
|
layer?.cornerRadius = 0
|
|
493
|
747
|
setup()
|
|
|
748
|
+ observeStoreUpdates()
|
|
|
749
|
+ refreshProductDisplay()
|
|
494
|
750
|
refreshAppearance()
|
|
495
|
751
|
}
|
|
496
|
752
|
|
|
|
753
|
+ deinit {
|
|
|
754
|
+ storeObservers.forEach { NotificationCenter.default.removeObserver($0) }
|
|
|
755
|
+ }
|
|
|
756
|
+
|
|
|
757
|
+ private func observeStoreUpdates() {
|
|
|
758
|
+ let center = NotificationCenter.default
|
|
|
759
|
+ storeObservers = [
|
|
|
760
|
+ center.addObserver(forName: .storeProductsDidUpdate, object: nil, queue: .main) { [weak self] _ in
|
|
|
761
|
+ self?.refreshProductDisplay()
|
|
|
762
|
+ },
|
|
|
763
|
+ center.addObserver(forName: .storeStateDidChange, object: nil, queue: .main) { [weak self] _ in
|
|
|
764
|
+ self?.refreshPurchaseState()
|
|
|
765
|
+ },
|
|
|
766
|
+ center.addObserver(forName: .premiumStatusDidChange, object: nil, queue: .main) { [weak self] _ in
|
|
|
767
|
+ self?.refreshPurchaseState()
|
|
|
768
|
+ },
|
|
|
769
|
+ ]
|
|
|
770
|
+ }
|
|
|
771
|
+
|
|
|
772
|
+ private func refreshProductDisplay() {
|
|
|
773
|
+ let store = StoreManager.shared
|
|
|
774
|
+ for (plan, card) in planCards {
|
|
|
775
|
+ card.updateDisplay(product: store.product(for: plan))
|
|
|
776
|
+ }
|
|
|
777
|
+ ctaButton.title = selectedPlan.localizedCTATitle(from: store.product(for: selectedPlan))
|
|
|
778
|
+ refreshPurchaseState()
|
|
|
779
|
+ }
|
|
|
780
|
+
|
|
|
781
|
+ private func refreshPurchaseState() {
|
|
|
782
|
+ let store = StoreManager.shared
|
|
|
783
|
+ let isBusy = store.isPurchasing || store.isLoadingProducts
|
|
|
784
|
+ ctaButton.isEnabled = !isBusy
|
|
|
785
|
+ ctaButton.alphaValue = isBusy ? 0.65 : 1
|
|
|
786
|
+ }
|
|
|
787
|
+
|
|
497
|
788
|
func refreshAppearance() {
|
|
498
|
789
|
layer?.backgroundColor = AppTheme.paywallBackground.cgColor
|
|
499
|
790
|
leftPanelTitle?.refreshThemeLabelColor()
|
|
|
@@ -607,7 +898,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
607
|
898
|
plansStack.addArrangedSubview(card)
|
|
608
|
899
|
}
|
|
609
|
900
|
|
|
610
|
|
- ctaButton.title = selectedPlan.ctaTitle
|
|
|
901
|
+ ctaButton.title = selectedPlan.localizedCTATitle(from: StoreManager.shared.product(for: selectedPlan))
|
|
611
|
902
|
ctaButton.target = self
|
|
612
|
903
|
ctaButton.action = #selector(purchaseTapped)
|
|
613
|
904
|
ctaButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
@@ -756,15 +1047,45 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
756
|
1047
|
for (key, card) in planCards {
|
|
757
|
1048
|
card.isChosen = key == plan
|
|
758
|
1049
|
}
|
|
759
|
|
- ctaButton.title = plan.ctaTitle
|
|
|
1050
|
+ ctaButton.title = plan.localizedCTATitle(from: StoreManager.shared.product(for: plan))
|
|
760
|
1051
|
}
|
|
761
|
1052
|
|
|
762
|
1053
|
@objc private func purchaseTapped() {
|
|
763
|
|
- onPurchase?(selectedPlan)
|
|
|
1054
|
+ Task { @MainActor in
|
|
|
1055
|
+ refreshPurchaseState()
|
|
|
1056
|
+ do {
|
|
|
1057
|
+ let succeeded = try await StoreManager.shared.purchase(plan: selectedPlan)
|
|
|
1058
|
+ refreshPurchaseState()
|
|
|
1059
|
+ if succeeded {
|
|
|
1060
|
+ onPurchaseSucceeded?()
|
|
|
1061
|
+ }
|
|
|
1062
|
+ } catch {
|
|
|
1063
|
+ refreshPurchaseState()
|
|
|
1064
|
+ StoreManager.shared.showPurchaseError(error, on: window)
|
|
|
1065
|
+ }
|
|
|
1066
|
+ }
|
|
764
|
1067
|
}
|
|
765
|
1068
|
|
|
766
|
1069
|
@objc private func restoreTapped() {
|
|
767
|
|
- onRestore?()
|
|
|
1070
|
+ Task { @MainActor in
|
|
|
1071
|
+ refreshPurchaseState()
|
|
|
1072
|
+ do {
|
|
|
1073
|
+ let restored = try await StoreManager.shared.restorePurchases()
|
|
|
1074
|
+ refreshPurchaseState()
|
|
|
1075
|
+ if restored {
|
|
|
1076
|
+ onPurchaseSucceeded?()
|
|
|
1077
|
+ } else {
|
|
|
1078
|
+ StoreManager.shared.showAlert(
|
|
|
1079
|
+ title: "No Purchases Found",
|
|
|
1080
|
+ message: "We couldn't find any previous purchases for this Apple ID.",
|
|
|
1081
|
+ on: window
|
|
|
1082
|
+ )
|
|
|
1083
|
+ }
|
|
|
1084
|
+ } catch {
|
|
|
1085
|
+ refreshPurchaseState()
|
|
|
1086
|
+ StoreManager.shared.showPurchaseError(error, on: window)
|
|
|
1087
|
+ }
|
|
|
1088
|
+ }
|
|
768
|
1089
|
}
|
|
769
|
1090
|
|
|
770
|
1091
|
@objc private func continueWithFreePlanTapped() {
|
|
|
@@ -815,11 +1136,8 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
|
|
815
|
1136
|
|
|
816
|
1137
|
paywallView.translatesAutoresizingMaskIntoConstraints = false
|
|
817
|
1138
|
paywallView.onClose = { [weak self] in self?.dismiss() }
|
|
818
|
|
- paywallView.onPurchase = { plan in
|
|
819
|
|
- NSLog("Purchase tapped: \(plan.title)")
|
|
820
|
|
- }
|
|
821
|
|
- paywallView.onRestore = {
|
|
822
|
|
- NSLog("Restore purchases tapped")
|
|
|
1139
|
+ paywallView.onPurchaseSucceeded = { [weak self] in
|
|
|
1140
|
+ self?.dismiss()
|
|
823
|
1141
|
}
|
|
824
|
1142
|
|
|
825
|
1143
|
addSubview(blurView)
|
|
|
@@ -871,6 +1189,11 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
|
|
871
|
1189
|
])
|
|
872
|
1190
|
}
|
|
873
|
1191
|
alphaValue = 0
|
|
|
1192
|
+ Task { @MainActor in
|
|
|
1193
|
+ if StoreManager.shared.products.isEmpty {
|
|
|
1194
|
+ await StoreManager.shared.loadProducts()
|
|
|
1195
|
+ }
|
|
|
1196
|
+ }
|
|
874
|
1197
|
NSAnimationContext.runAnimationGroup { context in
|
|
875
|
1198
|
context.duration = 0.2
|
|
876
|
1199
|
animator().alphaValue = 1
|