|
|
@@ -1,4 +1,5 @@
|
|
|
import Cocoa
|
|
|
+import StoreKit
|
|
|
|
|
|
// MARK: - Plan Model
|
|
|
|
|
|
@@ -7,6 +8,14 @@ enum PaywallPlan: CaseIterable {
|
|
|
case yearly
|
|
|
case lifetime
|
|
|
|
|
|
+ var productID: String {
|
|
|
+ switch self {
|
|
|
+ case .monthly: StoreProductID.monthly
|
|
|
+ case .yearly: StoreProductID.yearly
|
|
|
+ case .lifetime: StoreProductID.lifetime
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
var title: String {
|
|
|
switch self {
|
|
|
case .monthly: "Monthly"
|
|
|
@@ -38,6 +47,246 @@ enum PaywallPlan: CaseIterable {
|
|
|
case .lifetime: "Buy Lifetime Access"
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+ func localizedPrice(from product: Product?) -> String {
|
|
|
+ product?.displayPrice ?? price
|
|
|
+ }
|
|
|
+
|
|
|
+ func localizedSubtitle(from product: Product?) -> String {
|
|
|
+ guard let product else { return subtitle }
|
|
|
+
|
|
|
+ switch self {
|
|
|
+ case .monthly:
|
|
|
+ return "\(product.displayPrice) / month, cancel anytime"
|
|
|
+ case .yearly:
|
|
|
+ if product.subscription?.introductoryOffer != nil {
|
|
|
+ return "Eligible new subscribers get 7 days free, then \(product.displayPrice) / year"
|
|
|
+ }
|
|
|
+ return "\(product.displayPrice) / year, cancel anytime"
|
|
|
+ case .lifetime:
|
|
|
+ return "\(product.displayPrice) once, lifetime access"
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ func localizedCTATitle(from product: Product?) -> String {
|
|
|
+ guard let product else { return ctaTitle }
|
|
|
+
|
|
|
+ switch self {
|
|
|
+ case .monthly:
|
|
|
+ return "Subscribe for \(product.displayPrice) / Month"
|
|
|
+ case .yearly:
|
|
|
+ if product.subscription?.introductoryOffer != nil {
|
|
|
+ return "Start 7-Day Free Trial"
|
|
|
+ }
|
|
|
+ return "Subscribe for \(product.displayPrice) / Year"
|
|
|
+ case .lifetime:
|
|
|
+ return "Buy Lifetime Access for \(product.displayPrice)"
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// MARK: - StoreKit
|
|
|
+
|
|
|
+enum StoreProductID {
|
|
|
+ static let monthly = "MQL-DEV.smart-printer.premium.monthly"
|
|
|
+ static let yearly = "MQL-DEV.smart-printer.premium.yearly"
|
|
|
+ static let lifetime = "MQL-DEV.smart-printer.premium.lifetime"
|
|
|
+
|
|
|
+ static let all: Set<String> = [monthly, yearly, lifetime]
|
|
|
+}
|
|
|
+
|
|
|
+enum StoreError: LocalizedError {
|
|
|
+ case productNotFound
|
|
|
+ case failedVerification
|
|
|
+
|
|
|
+ var errorDescription: String? {
|
|
|
+ switch self {
|
|
|
+ case .productNotFound:
|
|
|
+ "The selected plan is not available right now. Please try again later."
|
|
|
+ case .failedVerification:
|
|
|
+ "We couldn't verify your purchase. Please contact support."
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+@MainActor
|
|
|
+final class StoreManager {
|
|
|
+ static let shared = StoreManager()
|
|
|
+
|
|
|
+ private(set) var products: [Product] = []
|
|
|
+ private(set) var isPremium = false
|
|
|
+ private(set) var isLoadingProducts = false
|
|
|
+ private(set) var isPurchasing = false
|
|
|
+
|
|
|
+ private var transactionListener: Task<Void, Never>?
|
|
|
+ private var hasStarted = false
|
|
|
+
|
|
|
+ private init() {}
|
|
|
+
|
|
|
+ func start() {
|
|
|
+ guard !hasStarted else { return }
|
|
|
+ hasStarted = true
|
|
|
+
|
|
|
+ transactionListener = Task { [weak self] in
|
|
|
+ for await update in Transaction.updates {
|
|
|
+ await self?.handleTransactionUpdate(update)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ Task {
|
|
|
+ await loadProducts()
|
|
|
+ await refreshPremiumStatus()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ func product(for plan: PaywallPlan) -> Product? {
|
|
|
+ products.first { $0.id == plan.productID }
|
|
|
+ }
|
|
|
+
|
|
|
+ func loadProducts() async {
|
|
|
+ isLoadingProducts = true
|
|
|
+ postStoreStateDidChange()
|
|
|
+ defer {
|
|
|
+ isLoadingProducts = false
|
|
|
+ postStoreStateDidChange()
|
|
|
+ }
|
|
|
+
|
|
|
+ do {
|
|
|
+ products = try await Product.products(for: StoreProductID.all)
|
|
|
+ .sorted { lhs, rhs in
|
|
|
+ productSortOrder(for: lhs.id) < productSortOrder(for: rhs.id)
|
|
|
+ }
|
|
|
+ NotificationCenter.default.post(name: .storeProductsDidUpdate, object: nil)
|
|
|
+ } catch {
|
|
|
+ NSLog("Failed to load products: \(error.localizedDescription)")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @discardableResult
|
|
|
+ func purchase(plan: PaywallPlan) async throws -> Bool {
|
|
|
+ if products.isEmpty {
|
|
|
+ await loadProducts()
|
|
|
+ }
|
|
|
+
|
|
|
+ guard let product = product(for: plan) else {
|
|
|
+ throw StoreError.productNotFound
|
|
|
+ }
|
|
|
+
|
|
|
+ isPurchasing = true
|
|
|
+ postStoreStateDidChange()
|
|
|
+ defer {
|
|
|
+ isPurchasing = false
|
|
|
+ postStoreStateDidChange()
|
|
|
+ }
|
|
|
+
|
|
|
+ let result = try await product.purchase()
|
|
|
+
|
|
|
+ switch result {
|
|
|
+ case .success(let verification):
|
|
|
+ let transaction = try checkVerified(verification)
|
|
|
+ await transaction.finish()
|
|
|
+ await refreshPremiumStatus()
|
|
|
+ return isPremium
|
|
|
+ case .userCancelled, .pending:
|
|
|
+ return false
|
|
|
+ @unknown default:
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @discardableResult
|
|
|
+ func restorePurchases() async throws -> Bool {
|
|
|
+ isPurchasing = true
|
|
|
+ postStoreStateDidChange()
|
|
|
+ defer {
|
|
|
+ isPurchasing = false
|
|
|
+ postStoreStateDidChange()
|
|
|
+ }
|
|
|
+
|
|
|
+ try await AppStore.sync()
|
|
|
+ await refreshPremiumStatus()
|
|
|
+ return isPremium
|
|
|
+ }
|
|
|
+
|
|
|
+ func showAlert(title: String, message: String, on window: NSWindow?) {
|
|
|
+ let alert = NSAlert()
|
|
|
+ alert.messageText = title
|
|
|
+ alert.informativeText = message
|
|
|
+ alert.alertStyle = .informational
|
|
|
+ alert.addButton(withTitle: "OK")
|
|
|
+ if let window {
|
|
|
+ alert.beginSheetModal(for: window)
|
|
|
+ } else {
|
|
|
+ alert.runModal()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ func showPurchaseError(_ error: Error, on window: NSWindow?) {
|
|
|
+ if let storeError = error as? StoreError {
|
|
|
+ showAlert(title: "Purchase Failed", message: storeError.localizedDescription, on: window)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if let storeKitError = error as? StoreKitError, case .userCancelled = storeKitError {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ showAlert(title: "Purchase Failed", message: error.localizedDescription, on: window)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func handleTransactionUpdate(_ update: VerificationResult<Transaction>) async {
|
|
|
+ do {
|
|
|
+ let transaction = try checkVerified(update)
|
|
|
+ await transaction.finish()
|
|
|
+ await refreshPremiumStatus()
|
|
|
+ } catch {
|
|
|
+ NSLog("Transaction verification failed: \(error.localizedDescription)")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func refreshPremiumStatus() async {
|
|
|
+ var hasPremium = false
|
|
|
+
|
|
|
+ for await result in Transaction.currentEntitlements {
|
|
|
+ guard let transaction = try? checkVerified(result) else { continue }
|
|
|
+ if StoreProductID.all.contains(transaction.productID) {
|
|
|
+ hasPremium = true
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ guard hasPremium != isPremium else { return }
|
|
|
+ isPremium = hasPremium
|
|
|
+ NotificationCenter.default.post(name: .premiumStatusDidChange, object: nil)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
|
|
|
+ switch result {
|
|
|
+ case .unverified:
|
|
|
+ throw StoreError.failedVerification
|
|
|
+ case .verified(let safe):
|
|
|
+ return safe
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func productSortOrder(for productID: String) -> Int {
|
|
|
+ switch productID {
|
|
|
+ case StoreProductID.monthly: 0
|
|
|
+ case StoreProductID.yearly: 1
|
|
|
+ case StoreProductID.lifetime: 2
|
|
|
+ default: 99
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func postStoreStateDidChange() {
|
|
|
+ NotificationCenter.default.post(name: .storeStateDidChange, object: nil)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+extension Notification.Name {
|
|
|
+ static let premiumStatusDidChange = Notification.Name("premiumStatusDidChange")
|
|
|
+ static let storeProductsDidUpdate = Notification.Name("storeProductsDidUpdate")
|
|
|
+ static let storeStateDidChange = Notification.Name("storeStateDidChange")
|
|
|
}
|
|
|
|
|
|
// MARK: - Left Panel
|
|
|
@@ -275,6 +524,11 @@ private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
|
|
|
@available(*, unavailable)
|
|
|
required init?(coder: NSCoder) { nil }
|
|
|
|
|
|
+ func updateDisplay(product: Product?) {
|
|
|
+ subtitleLabel.stringValue = plan.localizedSubtitle(from: product)
|
|
|
+ priceLabel.stringValue = plan.localizedPrice(from: product)
|
|
|
+ }
|
|
|
+
|
|
|
func refreshAppearance() {
|
|
|
updateAppearance()
|
|
|
subtitleLabel.refreshThemeLabelColor()
|
|
|
@@ -474,8 +728,7 @@ private final class PaywallCTAButton: NSButton, AppearanceRefreshable {
|
|
|
|
|
|
final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
var onClose: (() -> Void)?
|
|
|
- var onPurchase: ((PaywallPlan) -> Void)?
|
|
|
- var onRestore: (() -> Void)?
|
|
|
+ var onPurchaseSucceeded: (() -> Void)?
|
|
|
|
|
|
private var selectedPlan: PaywallPlan = .yearly
|
|
|
private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
|
|
|
@@ -484,6 +737,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
private var rightTitle: NSTextField!
|
|
|
private var rightSubtitle: NSTextField!
|
|
|
private var trustStack: NSStackView!
|
|
|
+ private var storeObservers: [NSObjectProtocol] = []
|
|
|
|
|
|
init() {
|
|
|
super.init(frame: .zero)
|
|
|
@@ -491,9 +745,46 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
wantsLayer = true
|
|
|
layer?.cornerRadius = 0
|
|
|
setup()
|
|
|
+ observeStoreUpdates()
|
|
|
+ refreshProductDisplay()
|
|
|
refreshAppearance()
|
|
|
}
|
|
|
|
|
|
+ deinit {
|
|
|
+ storeObservers.forEach { NotificationCenter.default.removeObserver($0) }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func observeStoreUpdates() {
|
|
|
+ let center = NotificationCenter.default
|
|
|
+ storeObservers = [
|
|
|
+ center.addObserver(forName: .storeProductsDidUpdate, object: nil, queue: .main) { [weak self] _ in
|
|
|
+ self?.refreshProductDisplay()
|
|
|
+ },
|
|
|
+ center.addObserver(forName: .storeStateDidChange, object: nil, queue: .main) { [weak self] _ in
|
|
|
+ self?.refreshPurchaseState()
|
|
|
+ },
|
|
|
+ center.addObserver(forName: .premiumStatusDidChange, object: nil, queue: .main) { [weak self] _ in
|
|
|
+ self?.refreshPurchaseState()
|
|
|
+ },
|
|
|
+ ]
|
|
|
+ }
|
|
|
+
|
|
|
+ private func refreshProductDisplay() {
|
|
|
+ let store = StoreManager.shared
|
|
|
+ for (plan, card) in planCards {
|
|
|
+ card.updateDisplay(product: store.product(for: plan))
|
|
|
+ }
|
|
|
+ ctaButton.title = selectedPlan.localizedCTATitle(from: store.product(for: selectedPlan))
|
|
|
+ refreshPurchaseState()
|
|
|
+ }
|
|
|
+
|
|
|
+ private func refreshPurchaseState() {
|
|
|
+ let store = StoreManager.shared
|
|
|
+ let isBusy = store.isPurchasing || store.isLoadingProducts
|
|
|
+ ctaButton.isEnabled = !isBusy
|
|
|
+ ctaButton.alphaValue = isBusy ? 0.65 : 1
|
|
|
+ }
|
|
|
+
|
|
|
func refreshAppearance() {
|
|
|
layer?.backgroundColor = AppTheme.paywallBackground.cgColor
|
|
|
leftPanelTitle?.refreshThemeLabelColor()
|
|
|
@@ -607,7 +898,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
plansStack.addArrangedSubview(card)
|
|
|
}
|
|
|
|
|
|
- ctaButton.title = selectedPlan.ctaTitle
|
|
|
+ ctaButton.title = selectedPlan.localizedCTATitle(from: StoreManager.shared.product(for: selectedPlan))
|
|
|
ctaButton.target = self
|
|
|
ctaButton.action = #selector(purchaseTapped)
|
|
|
ctaButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
@@ -756,15 +1047,45 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
for (key, card) in planCards {
|
|
|
card.isChosen = key == plan
|
|
|
}
|
|
|
- ctaButton.title = plan.ctaTitle
|
|
|
+ ctaButton.title = plan.localizedCTATitle(from: StoreManager.shared.product(for: plan))
|
|
|
}
|
|
|
|
|
|
@objc private func purchaseTapped() {
|
|
|
- onPurchase?(selectedPlan)
|
|
|
+ Task { @MainActor in
|
|
|
+ refreshPurchaseState()
|
|
|
+ do {
|
|
|
+ let succeeded = try await StoreManager.shared.purchase(plan: selectedPlan)
|
|
|
+ refreshPurchaseState()
|
|
|
+ if succeeded {
|
|
|
+ onPurchaseSucceeded?()
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ refreshPurchaseState()
|
|
|
+ StoreManager.shared.showPurchaseError(error, on: window)
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
@objc private func restoreTapped() {
|
|
|
- onRestore?()
|
|
|
+ Task { @MainActor in
|
|
|
+ refreshPurchaseState()
|
|
|
+ do {
|
|
|
+ let restored = try await StoreManager.shared.restorePurchases()
|
|
|
+ refreshPurchaseState()
|
|
|
+ if restored {
|
|
|
+ onPurchaseSucceeded?()
|
|
|
+ } else {
|
|
|
+ StoreManager.shared.showAlert(
|
|
|
+ title: "No Purchases Found",
|
|
|
+ message: "We couldn't find any previous purchases for this Apple ID.",
|
|
|
+ on: window
|
|
|
+ )
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ refreshPurchaseState()
|
|
|
+ StoreManager.shared.showPurchaseError(error, on: window)
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
@objc private func continueWithFreePlanTapped() {
|
|
|
@@ -815,11 +1136,8 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
|
|
|
|
|
|
paywallView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
paywallView.onClose = { [weak self] in self?.dismiss() }
|
|
|
- paywallView.onPurchase = { plan in
|
|
|
- NSLog("Purchase tapped: \(plan.title)")
|
|
|
- }
|
|
|
- paywallView.onRestore = {
|
|
|
- NSLog("Restore purchases tapped")
|
|
|
+ paywallView.onPurchaseSucceeded = { [weak self] in
|
|
|
+ self?.dismiss()
|
|
|
}
|
|
|
|
|
|
addSubview(blurView)
|
|
|
@@ -871,6 +1189,11 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
|
|
|
])
|
|
|
}
|
|
|
alphaValue = 0
|
|
|
+ Task { @MainActor in
|
|
|
+ if StoreManager.shared.products.isEmpty {
|
|
|
+ await StoreManager.shared.loadProducts()
|
|
|
+ }
|
|
|
+ }
|
|
|
NSAnimationContext.runAnimationGroup { context in
|
|
|
context.duration = 0.2
|
|
|
animator().alphaValue = 1
|