|
|
@@ -1,4 +1,5 @@
|
|
1
|
1
|
import Cocoa
|
|
|
2
|
+import StoreKit
|
|
2
|
3
|
|
|
3
|
4
|
final class PremiumPlansWindowController: NSWindowController {
|
|
4
|
5
|
init() {
|
|
|
@@ -129,6 +130,11 @@ private final class PremiumPlansViewController: NSViewController {
|
|
129
|
130
|
static let edgeInsets = NSEdgeInsets(top: 21, left: 37, bottom: 21, right: 0)
|
|
130
|
131
|
}
|
|
131
|
132
|
|
|
|
133
|
+ private let subscriptionStore = SubscriptionStore.shared
|
|
|
134
|
+ private var planPriceFields: [String: (price: NSTextField, period: NSTextField)] = [:]
|
|
|
135
|
+ private var planPurchaseButtons: [String: NSButton] = [:]
|
|
|
136
|
+ private var subscriptionStatusObservation: NSObjectProtocol?
|
|
|
137
|
+
|
|
132
|
138
|
private let plans: [Plan] = [
|
|
133
|
139
|
Plan(
|
|
134
|
140
|
id: "weekly",
|
|
|
@@ -190,6 +196,28 @@ private final class PremiumPlansViewController: NSViewController {
|
|
190
|
196
|
]
|
|
191
|
197
|
|
|
192
|
198
|
private let pageGradient = CAGradientLayer()
|
|
|
199
|
+
|
|
|
200
|
+ deinit {
|
|
|
201
|
+ if let subscriptionStatusObservation {
|
|
|
202
|
+ NotificationCenter.default.removeObserver(subscriptionStatusObservation)
|
|
|
203
|
+ }
|
|
|
204
|
+ }
|
|
|
205
|
+
|
|
|
206
|
+ override func viewDidLoad() {
|
|
|
207
|
+ super.viewDidLoad()
|
|
|
208
|
+ subscriptionStatusObservation = NotificationCenter.default.addObserver(
|
|
|
209
|
+ forName: .subscriptionStatusDidChange,
|
|
|
210
|
+ object: nil,
|
|
|
211
|
+ queue: .main
|
|
|
212
|
+ ) { [weak self] _ in
|
|
|
213
|
+ Task { @MainActor in
|
|
|
214
|
+ await self?.subscriptionStore.loadProducts()
|
|
|
215
|
+ self?.applyStorePricing()
|
|
|
216
|
+ }
|
|
|
217
|
+ }
|
|
|
218
|
+ Task { await loadStoreProducts() }
|
|
|
219
|
+ }
|
|
|
220
|
+
|
|
193
|
221
|
override func viewDidLayout() {
|
|
194
|
222
|
super.viewDidLayout()
|
|
195
|
223
|
pageGradient.frame = view.bounds
|
|
|
@@ -346,6 +374,8 @@ private final class PremiumPlansViewController: NSViewController {
|
|
346
|
374
|
|
|
347
|
375
|
let selectButton = NSButton(title: "Get \(plan.title)", target: self, action: #selector(didTapSelectPlan))
|
|
348
|
376
|
selectButton.identifier = NSUserInterfaceItemIdentifier(plan.id)
|
|
|
377
|
+ planPurchaseButtons[plan.id] = selectButton
|
|
|
378
|
+ planPriceFields[plan.id] = (priceLabel, periodLabel)
|
|
349
|
379
|
selectButton.isBordered = false
|
|
350
|
380
|
selectButton.bezelStyle = .rounded
|
|
351
|
381
|
selectButton.font = .systemFont(ofSize: 14, weight: .semibold)
|
|
|
@@ -476,16 +506,19 @@ private final class PremiumPlansViewController: NSViewController {
|
|
476
|
506
|
}
|
|
477
|
507
|
|
|
478
|
508
|
private func makeFooterRow() -> NSView {
|
|
479
|
|
- let items = [
|
|
480
|
|
- "Manage Subscription",
|
|
481
|
|
- "Restore Purchase",
|
|
482
|
|
- "Privacy Policy",
|
|
483
|
|
- "Terms of Services",
|
|
484
|
|
- "Support"
|
|
|
509
|
+ let entries: [(text: String, action: Selector?)] = [
|
|
|
510
|
+ ("Manage Subscription", #selector(didTapManageSubscription)),
|
|
|
511
|
+ ("Restore Purchase", #selector(didTapRestorePurchases)),
|
|
|
512
|
+ ("Privacy Policy", nil),
|
|
|
513
|
+ ("Terms of Services", nil),
|
|
|
514
|
+ ("Support", nil)
|
|
485
|
515
|
]
|
|
486
|
516
|
|
|
487
|
|
- let cells = items.enumerated().map { index, text in
|
|
488
|
|
- footerCell(text: text, showsTrailingDivider: index < items.count - 1)
|
|
|
517
|
+ let cells = entries.enumerated().map { index, entry in
|
|
|
518
|
+ if let action = entry.action {
|
|
|
519
|
+ return footerActionCell(title: entry.text, action: action, showsTrailingDivider: index < entries.count - 1)
|
|
|
520
|
+ }
|
|
|
521
|
+ return footerCell(text: entry.text, showsTrailingDivider: index < entries.count - 1)
|
|
489
|
522
|
}
|
|
490
|
523
|
|
|
491
|
524
|
let links = NSStackView(views: cells)
|
|
|
@@ -497,6 +530,37 @@ private final class PremiumPlansViewController: NSViewController {
|
|
497
|
530
|
return links
|
|
498
|
531
|
}
|
|
499
|
532
|
|
|
|
533
|
+ private func footerActionCell(title: String, action: Selector, showsTrailingDivider: Bool) -> NSView {
|
|
|
534
|
+ let container = NSView()
|
|
|
535
|
+ container.translatesAutoresizingMaskIntoConstraints = false
|
|
|
536
|
+
|
|
|
537
|
+ let button = NSButton(title: title, target: self, action: action)
|
|
|
538
|
+ button.isBordered = false
|
|
|
539
|
+ button.bezelStyle = .rounded
|
|
|
540
|
+ button.font = .systemFont(ofSize: 12, weight: .medium)
|
|
|
541
|
+ button.contentTintColor = Theme.secondaryText
|
|
|
542
|
+ button.focusRingType = .none
|
|
|
543
|
+ button.translatesAutoresizingMaskIntoConstraints = false
|
|
|
544
|
+ container.addSubview(button)
|
|
|
545
|
+
|
|
|
546
|
+ var constraints: [NSLayoutConstraint] = [
|
|
|
547
|
+ button.centerXAnchor.constraint(equalTo: container.centerXAnchor),
|
|
|
548
|
+ button.centerYAnchor.constraint(equalTo: container.centerYAnchor)
|
|
|
549
|
+ ]
|
|
|
550
|
+
|
|
|
551
|
+ if showsTrailingDivider {
|
|
|
552
|
+ let divider = footerDivider()
|
|
|
553
|
+ container.addSubview(divider)
|
|
|
554
|
+ constraints.append(contentsOf: [
|
|
|
555
|
+ divider.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
|
|
556
|
+ divider.centerYAnchor.constraint(equalTo: container.centerYAnchor)
|
|
|
557
|
+ ])
|
|
|
558
|
+ }
|
|
|
559
|
+
|
|
|
560
|
+ NSLayoutConstraint.activate(constraints)
|
|
|
561
|
+ return container
|
|
|
562
|
+ }
|
|
|
563
|
+
|
|
500
|
564
|
private func footerCell(text: String, showsTrailingDivider: Bool) -> NSView {
|
|
501
|
565
|
let container = NSView()
|
|
502
|
566
|
container.translatesAutoresizingMaskIntoConstraints = false
|
|
|
@@ -571,13 +635,129 @@ private final class PremiumPlansViewController: NSViewController {
|
|
571
|
635
|
}
|
|
572
|
636
|
|
|
573
|
637
|
@objc private func didTapSelectPlan(_ sender: NSButton) {
|
|
574
|
|
- sender.layer?.backgroundColor = Theme.accentHover.cgColor
|
|
575
|
|
- let selectedPlan = sender.identifier?.rawValue ?? sender.title
|
|
|
638
|
+ guard let planKey = sender.identifier?.rawValue else { return }
|
|
|
639
|
+ Task { await purchasePlan(planKey: planKey) }
|
|
|
640
|
+ }
|
|
|
641
|
+
|
|
|
642
|
+ @objc private func didTapManageSubscription() {
|
|
|
643
|
+ guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
|
|
|
644
|
+ NSWorkspace.shared.open(url)
|
|
|
645
|
+ }
|
|
|
646
|
+
|
|
|
647
|
+ @objc private func didTapRestorePurchases() {
|
|
|
648
|
+ Task { await restorePurchases() }
|
|
|
649
|
+ }
|
|
|
650
|
+
|
|
|
651
|
+ private func loadStoreProducts() async {
|
|
|
652
|
+ await subscriptionStore.loadProducts()
|
|
|
653
|
+ applyStorePricing()
|
|
|
654
|
+ }
|
|
|
655
|
+
|
|
|
656
|
+ private func applyStorePricing() {
|
|
|
657
|
+ for plan in plans {
|
|
|
658
|
+ guard let fields = planPriceFields[plan.id],
|
|
|
659
|
+ let product = subscriptionStore.product(forPlanKey: plan.id) else { continue }
|
|
|
660
|
+ fields.price.stringValue = product.displayPrice
|
|
|
661
|
+ if let period = product.subscription?.subscriptionPeriod {
|
|
|
662
|
+ fields.period.stringValue = periodSuffix(for: period)
|
|
|
663
|
+ }
|
|
|
664
|
+ }
|
|
|
665
|
+ }
|
|
|
666
|
+
|
|
|
667
|
+ private func periodSuffix(for period: Product.SubscriptionPeriod) -> String {
|
|
|
668
|
+ let value = period.value
|
|
|
669
|
+ switch period.unit {
|
|
|
670
|
+ case .day: return value == 1 ? "/ day" : "/ \(value) days"
|
|
|
671
|
+ case .week: return value == 1 ? "/ week" : "/ \(value) weeks"
|
|
|
672
|
+ case .month: return value == 1 ? "/ month" : "/ \(value) months"
|
|
|
673
|
+ case .year: return value == 1 ? "/ year" : "/ \(value) years"
|
|
|
674
|
+ @unknown default: return ""
|
|
|
675
|
+ }
|
|
|
676
|
+ }
|
|
|
677
|
+
|
|
|
678
|
+ private func setPurchasing(_ isPurchasing: Bool) {
|
|
|
679
|
+ for button in planPurchaseButtons.values {
|
|
|
680
|
+ button.isEnabled = !isPurchasing
|
|
|
681
|
+ }
|
|
|
682
|
+ }
|
|
|
683
|
+
|
|
|
684
|
+ private func purchasePlan(planKey: String) async {
|
|
|
685
|
+ setPurchasing(true)
|
|
|
686
|
+ defer { setPurchasing(false) }
|
|
|
687
|
+ do {
|
|
|
688
|
+ let completed = try await subscriptionStore.purchase(planKey: planKey)
|
|
|
689
|
+ guard completed else { return }
|
|
|
690
|
+ let alert = NSAlert()
|
|
|
691
|
+ alert.messageText = "You're subscribed"
|
|
|
692
|
+ alert.informativeText = "Thank you — Pro features are now available."
|
|
|
693
|
+ alert.alertStyle = .informational
|
|
|
694
|
+ alert.addButton(withTitle: "OK")
|
|
|
695
|
+ if let window = view.window {
|
|
|
696
|
+ alert.beginSheetModal(for: window) { [weak self] _ in
|
|
|
697
|
+ self?.dismissPremiumSheetFromParentIfNeeded()
|
|
|
698
|
+ }
|
|
|
699
|
+ } else {
|
|
|
700
|
+ alert.runModal()
|
|
|
701
|
+ dismissPremiumSheetFromParentIfNeeded()
|
|
|
702
|
+ }
|
|
|
703
|
+ } catch {
|
|
|
704
|
+ await MainActor.run {
|
|
|
705
|
+ self.presentPurchaseError(error)
|
|
|
706
|
+ }
|
|
|
707
|
+ }
|
|
|
708
|
+ }
|
|
576
|
709
|
|
|
|
710
|
+ private func restorePurchases() async {
|
|
|
711
|
+ setPurchasing(true)
|
|
|
712
|
+ defer { setPurchasing(false) }
|
|
|
713
|
+ do {
|
|
|
714
|
+ try await subscriptionStore.restorePurchases()
|
|
|
715
|
+ let active = subscriptionStore.isProActive
|
|
|
716
|
+ let alert = NSAlert()
|
|
|
717
|
+ if active {
|
|
|
718
|
+ alert.messageText = "Purchases restored"
|
|
|
719
|
+ alert.informativeText = "Your subscription is active."
|
|
|
720
|
+ } else {
|
|
|
721
|
+ alert.messageText = "No subscription found"
|
|
|
722
|
+ alert.informativeText = "There was nothing to restore for this Apple ID."
|
|
|
723
|
+ }
|
|
|
724
|
+ alert.alertStyle = .informational
|
|
|
725
|
+ alert.addButton(withTitle: "OK")
|
|
|
726
|
+ if let window = view.window {
|
|
|
727
|
+ alert.beginSheetModal(for: window) { [weak self] _ in
|
|
|
728
|
+ if active {
|
|
|
729
|
+ self?.dismissPremiumSheetFromParentIfNeeded()
|
|
|
730
|
+ }
|
|
|
731
|
+ }
|
|
|
732
|
+ } else {
|
|
|
733
|
+ alert.runModal()
|
|
|
734
|
+ if active {
|
|
|
735
|
+ dismissPremiumSheetFromParentIfNeeded()
|
|
|
736
|
+ }
|
|
|
737
|
+ }
|
|
|
738
|
+ } catch {
|
|
|
739
|
+ await MainActor.run {
|
|
|
740
|
+ self.presentPurchaseError(error)
|
|
|
741
|
+ }
|
|
|
742
|
+ }
|
|
|
743
|
+ }
|
|
|
744
|
+
|
|
|
745
|
+ private func presentPurchaseError(_ error: Error) {
|
|
577
|
746
|
let alert = NSAlert()
|
|
578
|
|
- alert.messageText = "Premium checkout coming soon"
|
|
579
|
|
- alert.informativeText = "Plan selected: \(selectedPlan.capitalized). Payment flow can be connected next."
|
|
580
|
|
- alert.alertStyle = .informational
|
|
|
747
|
+ alert.messageText = "Something went wrong"
|
|
|
748
|
+ if let localized = error as? LocalizedError {
|
|
|
749
|
+ var parts: [String] = []
|
|
|
750
|
+ if let description = localized.errorDescription {
|
|
|
751
|
+ parts.append(description)
|
|
|
752
|
+ }
|
|
|
753
|
+ if let recovery = localized.recoverySuggestion {
|
|
|
754
|
+ parts.append(recovery)
|
|
|
755
|
+ }
|
|
|
756
|
+ alert.informativeText = parts.isEmpty ? error.localizedDescription : parts.joined(separator: "\n\n")
|
|
|
757
|
+ } else {
|
|
|
758
|
+ alert.informativeText = error.localizedDescription
|
|
|
759
|
+ }
|
|
|
760
|
+ alert.alertStyle = .warning
|
|
581
|
761
|
alert.addButton(withTitle: "OK")
|
|
582
|
762
|
if let window = view.window {
|
|
583
|
763
|
alert.beginSheetModal(for: window)
|
|
|
@@ -586,6 +766,11 @@ private final class PremiumPlansViewController: NSViewController {
|
|
586
|
766
|
}
|
|
587
|
767
|
}
|
|
588
|
768
|
|
|
|
769
|
+ private func dismissPremiumSheetFromParentIfNeeded() {
|
|
|
770
|
+ guard let sheet = view.window, let parent = sheet.sheetParent else { return }
|
|
|
771
|
+ parent.endSheet(sheet)
|
|
|
772
|
+ }
|
|
|
773
|
+
|
|
589
|
774
|
@objc private func didTapClose() {
|
|
590
|
775
|
guard let window = view.window else { return }
|
|
591
|
776
|
if let parent = window.sheetParent {
|