|
@@ -1,4 +1,5 @@
|
|
|
import Cocoa
|
|
import Cocoa
|
|
|
|
|
+import StoreKit
|
|
|
|
|
|
|
|
final class PremiumPlansWindowController: NSWindowController {
|
|
final class PremiumPlansWindowController: NSWindowController {
|
|
|
init() {
|
|
init() {
|
|
@@ -129,6 +130,11 @@ private final class PremiumPlansViewController: NSViewController {
|
|
|
static let edgeInsets = NSEdgeInsets(top: 21, left: 37, bottom: 21, right: 0)
|
|
static let edgeInsets = NSEdgeInsets(top: 21, left: 37, bottom: 21, right: 0)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private let subscriptionStore = SubscriptionStore.shared
|
|
|
|
|
+ private var planPriceFields: [String: (price: NSTextField, period: NSTextField)] = [:]
|
|
|
|
|
+ private var planPurchaseButtons: [String: NSButton] = [:]
|
|
|
|
|
+ private var subscriptionStatusObservation: NSObjectProtocol?
|
|
|
|
|
+
|
|
|
private let plans: [Plan] = [
|
|
private let plans: [Plan] = [
|
|
|
Plan(
|
|
Plan(
|
|
|
id: "weekly",
|
|
id: "weekly",
|
|
@@ -190,6 +196,28 @@ private final class PremiumPlansViewController: NSViewController {
|
|
|
]
|
|
]
|
|
|
|
|
|
|
|
private let pageGradient = CAGradientLayer()
|
|
private let pageGradient = CAGradientLayer()
|
|
|
|
|
+
|
|
|
|
|
+ deinit {
|
|
|
|
|
+ if let subscriptionStatusObservation {
|
|
|
|
|
+ NotificationCenter.default.removeObserver(subscriptionStatusObservation)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override func viewDidLoad() {
|
|
|
|
|
+ super.viewDidLoad()
|
|
|
|
|
+ subscriptionStatusObservation = NotificationCenter.default.addObserver(
|
|
|
|
|
+ forName: .subscriptionStatusDidChange,
|
|
|
|
|
+ object: nil,
|
|
|
|
|
+ queue: .main
|
|
|
|
|
+ ) { [weak self] _ in
|
|
|
|
|
+ Task { @MainActor in
|
|
|
|
|
+ await self?.subscriptionStore.loadProducts()
|
|
|
|
|
+ self?.applyStorePricing()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ Task { await loadStoreProducts() }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
override func viewDidLayout() {
|
|
override func viewDidLayout() {
|
|
|
super.viewDidLayout()
|
|
super.viewDidLayout()
|
|
|
pageGradient.frame = view.bounds
|
|
pageGradient.frame = view.bounds
|
|
@@ -346,6 +374,8 @@ private final class PremiumPlansViewController: NSViewController {
|
|
|
|
|
|
|
|
let selectButton = NSButton(title: "Get \(plan.title)", target: self, action: #selector(didTapSelectPlan))
|
|
let selectButton = NSButton(title: "Get \(plan.title)", target: self, action: #selector(didTapSelectPlan))
|
|
|
selectButton.identifier = NSUserInterfaceItemIdentifier(plan.id)
|
|
selectButton.identifier = NSUserInterfaceItemIdentifier(plan.id)
|
|
|
|
|
+ planPurchaseButtons[plan.id] = selectButton
|
|
|
|
|
+ planPriceFields[plan.id] = (priceLabel, periodLabel)
|
|
|
selectButton.isBordered = false
|
|
selectButton.isBordered = false
|
|
|
selectButton.bezelStyle = .rounded
|
|
selectButton.bezelStyle = .rounded
|
|
|
selectButton.font = .systemFont(ofSize: 14, weight: .semibold)
|
|
selectButton.font = .systemFont(ofSize: 14, weight: .semibold)
|
|
@@ -476,16 +506,19 @@ private final class PremiumPlansViewController: NSViewController {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func makeFooterRow() -> NSView {
|
|
private func makeFooterRow() -> NSView {
|
|
|
- let items = [
|
|
|
|
|
- "Manage Subscription",
|
|
|
|
|
- "Restore Purchase",
|
|
|
|
|
- "Privacy Policy",
|
|
|
|
|
- "Terms of Services",
|
|
|
|
|
- "Support"
|
|
|
|
|
|
|
+ let entries: [(text: String, action: Selector?)] = [
|
|
|
|
|
+ ("Manage Subscription", #selector(didTapManageSubscription)),
|
|
|
|
|
+ ("Restore Purchase", #selector(didTapRestorePurchases)),
|
|
|
|
|
+ ("Privacy Policy", nil),
|
|
|
|
|
+ ("Terms of Services", nil),
|
|
|
|
|
+ ("Support", nil)
|
|
|
]
|
|
]
|
|
|
|
|
|
|
|
- let cells = items.enumerated().map { index, text in
|
|
|
|
|
- footerCell(text: text, showsTrailingDivider: index < items.count - 1)
|
|
|
|
|
|
|
+ let cells = entries.enumerated().map { index, entry in
|
|
|
|
|
+ if let action = entry.action {
|
|
|
|
|
+ return footerActionCell(title: entry.text, action: action, showsTrailingDivider: index < entries.count - 1)
|
|
|
|
|
+ }
|
|
|
|
|
+ return footerCell(text: entry.text, showsTrailingDivider: index < entries.count - 1)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
let links = NSStackView(views: cells)
|
|
let links = NSStackView(views: cells)
|
|
@@ -497,6 +530,37 @@ private final class PremiumPlansViewController: NSViewController {
|
|
|
return links
|
|
return links
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private func footerActionCell(title: String, action: Selector, showsTrailingDivider: Bool) -> NSView {
|
|
|
|
|
+ let container = NSView()
|
|
|
|
|
+ container.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ let button = NSButton(title: title, target: self, action: action)
|
|
|
|
|
+ button.isBordered = false
|
|
|
|
|
+ button.bezelStyle = .rounded
|
|
|
|
|
+ button.font = .systemFont(ofSize: 12, weight: .medium)
|
|
|
|
|
+ button.contentTintColor = Theme.secondaryText
|
|
|
|
|
+ button.focusRingType = .none
|
|
|
|
|
+ button.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ container.addSubview(button)
|
|
|
|
|
+
|
|
|
|
|
+ var constraints: [NSLayoutConstraint] = [
|
|
|
|
|
+ button.centerXAnchor.constraint(equalTo: container.centerXAnchor),
|
|
|
|
|
+ button.centerYAnchor.constraint(equalTo: container.centerYAnchor)
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ if showsTrailingDivider {
|
|
|
|
|
+ let divider = footerDivider()
|
|
|
|
|
+ container.addSubview(divider)
|
|
|
|
|
+ constraints.append(contentsOf: [
|
|
|
|
|
+ divider.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
|
|
|
|
+ divider.centerYAnchor.constraint(equalTo: container.centerYAnchor)
|
|
|
|
|
+ ])
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate(constraints)
|
|
|
|
|
+ return container
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func footerCell(text: String, showsTrailingDivider: Bool) -> NSView {
|
|
private func footerCell(text: String, showsTrailingDivider: Bool) -> NSView {
|
|
|
let container = NSView()
|
|
let container = NSView()
|
|
|
container.translatesAutoresizingMaskIntoConstraints = false
|
|
container.translatesAutoresizingMaskIntoConstraints = false
|
|
@@ -571,13 +635,129 @@ private final class PremiumPlansViewController: NSViewController {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@objc private func didTapSelectPlan(_ sender: NSButton) {
|
|
@objc private func didTapSelectPlan(_ sender: NSButton) {
|
|
|
- sender.layer?.backgroundColor = Theme.accentHover.cgColor
|
|
|
|
|
- let selectedPlan = sender.identifier?.rawValue ?? sender.title
|
|
|
|
|
|
|
+ guard let planKey = sender.identifier?.rawValue else { return }
|
|
|
|
|
+ Task { await purchasePlan(planKey: planKey) }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @objc private func didTapManageSubscription() {
|
|
|
|
|
+ guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
|
|
|
|
|
+ NSWorkspace.shared.open(url)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @objc private func didTapRestorePurchases() {
|
|
|
|
|
+ Task { await restorePurchases() }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func loadStoreProducts() async {
|
|
|
|
|
+ await subscriptionStore.loadProducts()
|
|
|
|
|
+ applyStorePricing()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func applyStorePricing() {
|
|
|
|
|
+ for plan in plans {
|
|
|
|
|
+ guard let fields = planPriceFields[plan.id],
|
|
|
|
|
+ let product = subscriptionStore.product(forPlanKey: plan.id) else { continue }
|
|
|
|
|
+ fields.price.stringValue = product.displayPrice
|
|
|
|
|
+ if let period = product.subscription?.subscriptionPeriod {
|
|
|
|
|
+ fields.period.stringValue = periodSuffix(for: period)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func periodSuffix(for period: Product.SubscriptionPeriod) -> String {
|
|
|
|
|
+ let value = period.value
|
|
|
|
|
+ switch period.unit {
|
|
|
|
|
+ case .day: return value == 1 ? "/ day" : "/ \(value) days"
|
|
|
|
|
+ case .week: return value == 1 ? "/ week" : "/ \(value) weeks"
|
|
|
|
|
+ case .month: return value == 1 ? "/ month" : "/ \(value) months"
|
|
|
|
|
+ case .year: return value == 1 ? "/ year" : "/ \(value) years"
|
|
|
|
|
+ @unknown default: return ""
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func setPurchasing(_ isPurchasing: Bool) {
|
|
|
|
|
+ for button in planPurchaseButtons.values {
|
|
|
|
|
+ button.isEnabled = !isPurchasing
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func purchasePlan(planKey: String) async {
|
|
|
|
|
+ setPurchasing(true)
|
|
|
|
|
+ defer { setPurchasing(false) }
|
|
|
|
|
+ do {
|
|
|
|
|
+ let completed = try await subscriptionStore.purchase(planKey: planKey)
|
|
|
|
|
+ guard completed else { return }
|
|
|
|
|
+ let alert = NSAlert()
|
|
|
|
|
+ alert.messageText = "You're subscribed"
|
|
|
|
|
+ alert.informativeText = "Thank you — Pro features are now available."
|
|
|
|
|
+ alert.alertStyle = .informational
|
|
|
|
|
+ alert.addButton(withTitle: "OK")
|
|
|
|
|
+ if let window = view.window {
|
|
|
|
|
+ alert.beginSheetModal(for: window) { [weak self] _ in
|
|
|
|
|
+ self?.dismissPremiumSheetFromParentIfNeeded()
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ alert.runModal()
|
|
|
|
|
+ dismissPremiumSheetFromParentIfNeeded()
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ await MainActor.run {
|
|
|
|
|
+ self.presentPurchaseError(error)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
|
|
+ private func restorePurchases() async {
|
|
|
|
|
+ setPurchasing(true)
|
|
|
|
|
+ defer { setPurchasing(false) }
|
|
|
|
|
+ do {
|
|
|
|
|
+ try await subscriptionStore.restorePurchases()
|
|
|
|
|
+ let active = subscriptionStore.isProActive
|
|
|
|
|
+ let alert = NSAlert()
|
|
|
|
|
+ if active {
|
|
|
|
|
+ alert.messageText = "Purchases restored"
|
|
|
|
|
+ alert.informativeText = "Your subscription is active."
|
|
|
|
|
+ } else {
|
|
|
|
|
+ alert.messageText = "No subscription found"
|
|
|
|
|
+ alert.informativeText = "There was nothing to restore for this Apple ID."
|
|
|
|
|
+ }
|
|
|
|
|
+ alert.alertStyle = .informational
|
|
|
|
|
+ alert.addButton(withTitle: "OK")
|
|
|
|
|
+ if let window = view.window {
|
|
|
|
|
+ alert.beginSheetModal(for: window) { [weak self] _ in
|
|
|
|
|
+ if active {
|
|
|
|
|
+ self?.dismissPremiumSheetFromParentIfNeeded()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ alert.runModal()
|
|
|
|
|
+ if active {
|
|
|
|
|
+ dismissPremiumSheetFromParentIfNeeded()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ await MainActor.run {
|
|
|
|
|
+ self.presentPurchaseError(error)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func presentPurchaseError(_ error: Error) {
|
|
|
let alert = NSAlert()
|
|
let alert = NSAlert()
|
|
|
- alert.messageText = "Premium checkout coming soon"
|
|
|
|
|
- alert.informativeText = "Plan selected: \(selectedPlan.capitalized). Payment flow can be connected next."
|
|
|
|
|
- alert.alertStyle = .informational
|
|
|
|
|
|
|
+ alert.messageText = "Something went wrong"
|
|
|
|
|
+ if let localized = error as? LocalizedError {
|
|
|
|
|
+ var parts: [String] = []
|
|
|
|
|
+ if let description = localized.errorDescription {
|
|
|
|
|
+ parts.append(description)
|
|
|
|
|
+ }
|
|
|
|
|
+ if let recovery = localized.recoverySuggestion {
|
|
|
|
|
+ parts.append(recovery)
|
|
|
|
|
+ }
|
|
|
|
|
+ alert.informativeText = parts.isEmpty ? error.localizedDescription : parts.joined(separator: "\n\n")
|
|
|
|
|
+ } else {
|
|
|
|
|
+ alert.informativeText = error.localizedDescription
|
|
|
|
|
+ }
|
|
|
|
|
+ alert.alertStyle = .warning
|
|
|
alert.addButton(withTitle: "OK")
|
|
alert.addButton(withTitle: "OK")
|
|
|
if let window = view.window {
|
|
if let window = view.window {
|
|
|
alert.beginSheetModal(for: window)
|
|
alert.beginSheetModal(for: window)
|
|
@@ -586,6 +766,11 @@ private final class PremiumPlansViewController: NSViewController {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private func dismissPremiumSheetFromParentIfNeeded() {
|
|
|
|
|
+ guard let sheet = view.window, let parent = sheet.sheetParent else { return }
|
|
|
|
|
+ parent.endSheet(sheet)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
@objc private func didTapClose() {
|
|
@objc private func didTapClose() {
|
|
|
guard let window = view.window else { return }
|
|
guard let window = view.window else { return }
|
|
|
if let parent = window.sheetParent {
|
|
if let parent = window.sheetParent {
|