|
|
@@ -81,6 +81,7 @@ private final class StoreKitCoordinator {
|
|
|
private(set) var productsByID: [String: Product] = [:]
|
|
|
private(set) var activeEntitlementProductIDs: Set<String> = []
|
|
|
private(set) var lastProductLoadError: String?
|
|
|
+ var onEntitlementsChanged: ((Bool) -> Void)?
|
|
|
|
|
|
var hasPremiumAccess: Bool { !activeEntitlementProductIDs.isEmpty }
|
|
|
|
|
|
@@ -112,6 +113,7 @@ private final class StoreKitCoordinator {
|
|
|
}
|
|
|
|
|
|
func refreshEntitlements() async {
|
|
|
+ let previousHasPremiumAccess = hasPremiumAccess
|
|
|
var active = Set<String>()
|
|
|
for await entitlement in Transaction.currentEntitlements {
|
|
|
guard case .verified(let transaction) = entitlement else { continue }
|
|
|
@@ -130,6 +132,10 @@ private final class StoreKitCoordinator {
|
|
|
active.insert(productID)
|
|
|
}
|
|
|
activeEntitlementProductIDs = active
|
|
|
+ let newHasPremiumAccess = hasPremiumAccess
|
|
|
+ if newHasPremiumAccess != previousHasPremiumAccess {
|
|
|
+ onEntitlementsChanged?(newHasPremiumAccess)
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
func purchase(plan: PremiumPlan) async -> PurchaseOutcome {
|
|
|
@@ -243,6 +249,12 @@ final class ViewController: NSViewController {
|
|
|
private weak var sidebarPremiumTitleLabel: NSTextField?
|
|
|
private weak var sidebarPremiumIconView: NSImageView?
|
|
|
private weak var sidebarPremiumButtonView: HoverTrackingView?
|
|
|
+ private weak var instantMeetCardView: HoverSurfaceView?
|
|
|
+ private weak var instantMeetTitleLabel: NSTextField?
|
|
|
+ private weak var instantMeetSubtitleLabel: NSTextField?
|
|
|
+ private weak var joinWithLinkCardView: HoverSurfaceView?
|
|
|
+ private weak var joinWithLinkTitleLabel: NSTextField?
|
|
|
+ private weak var joinMeetPrimaryButton: NSButton?
|
|
|
private weak var meetLinkField: NSTextField?
|
|
|
private weak var browseAddressField: NSTextField?
|
|
|
private var inAppBrowserWindowController: InAppBrowserWindowController?
|
|
|
@@ -257,6 +269,7 @@ final class ViewController: NSViewController {
|
|
|
private var hasCompletedInitialStoreKitSync = false
|
|
|
private var hasPresentedLaunchPaywall = false
|
|
|
private var hasViewAppearedOnce = false
|
|
|
+ private var lastKnownPremiumAccess = false
|
|
|
|
|
|
private enum ScheduleFilter: Int {
|
|
|
case all = 0
|
|
|
@@ -323,6 +336,10 @@ final class ViewController: NSViewController {
|
|
|
// Sync toggle + palette with current macOS appearance on launch.
|
|
|
darkModeEnabled = systemPrefersDarkMode()
|
|
|
palette = Palette(isDarkMode: darkModeEnabled)
|
|
|
+ storeKitCoordinator.onEntitlementsChanged = { [weak self] hasPremiumAccess in
|
|
|
+ guard let self else { return }
|
|
|
+ self.handlePremiumAccessChanged(hasPremiumAccess)
|
|
|
+ }
|
|
|
setupRootView()
|
|
|
buildMainLayout()
|
|
|
startStoreKit()
|
|
|
@@ -449,6 +466,10 @@ private extension ViewController {
|
|
|
}
|
|
|
|
|
|
@objc private func joinMeetClicked(_ sender: Any?) {
|
|
|
+ guard storeKitCoordinator.hasPremiumAccess else {
|
|
|
+ showPaywall()
|
|
|
+ return
|
|
|
+ }
|
|
|
let rawInput = meetLinkField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
|
|
|
guard let url = normalizedMeetJoinURL(from: rawInput) else {
|
|
|
@@ -462,6 +483,14 @@ private extension ViewController {
|
|
|
openInDefaultBrowser(url: url)
|
|
|
}
|
|
|
|
|
|
+ @objc private func joinWithLinkCardClicked(_ sender: NSClickGestureRecognizer) {
|
|
|
+ guard storeKitCoordinator.hasPremiumAccess else {
|
|
|
+ showPaywall()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ meetLinkField?.window?.makeFirstResponder(meetLinkField)
|
|
|
+ }
|
|
|
+
|
|
|
@objc private func cancelMeetJoinClicked(_ sender: Any?) {
|
|
|
meetLinkField?.stringValue = ""
|
|
|
}
|
|
|
@@ -496,6 +525,10 @@ private extension ViewController {
|
|
|
}
|
|
|
|
|
|
@objc private func instantMeetClicked(_ sender: NSClickGestureRecognizer) {
|
|
|
+ guard storeKitCoordinator.hasPremiumAccess else {
|
|
|
+ showPaywall()
|
|
|
+ return
|
|
|
+ }
|
|
|
guard let url = URL(string: "https://meet.google.com/new") else { return }
|
|
|
openInDefaultBrowser(url: url)
|
|
|
}
|
|
|
@@ -876,10 +909,39 @@ private extension ViewController {
|
|
|
label.stringValue = "\(pkrDisplayPrice(product.displayPrice))/\(subscriptionUnitText(period.unit))"
|
|
|
}
|
|
|
refreshSidebarPremiumButton()
|
|
|
+ refreshInstantMeetPremiumState()
|
|
|
updatePaywallPlanSelection()
|
|
|
updatePaywallContinueState(isLoading: false)
|
|
|
}
|
|
|
|
|
|
+ private func refreshInstantMeetPremiumState() {
|
|
|
+ let isPremium = storeKitCoordinator.hasPremiumAccess
|
|
|
+ instantMeetCardView?.alphaValue = isPremium ? 1.0 : 0.65
|
|
|
+ instantMeetTitleLabel?.alphaValue = isPremium ? 1.0 : 0.75
|
|
|
+ instantMeetSubtitleLabel?.alphaValue = isPremium ? 1.0 : 0.75
|
|
|
+ instantMeetCardView?.toolTip = isPremium ? nil : "Premium required. Click to open paywall."
|
|
|
+ instantMeetCardView?.onHoverChanged?(false)
|
|
|
+
|
|
|
+ joinWithLinkCardView?.alphaValue = isPremium ? 1.0 : 0.65
|
|
|
+ joinWithLinkTitleLabel?.alphaValue = isPremium ? 1.0 : 0.75
|
|
|
+ meetLinkField?.isEditable = isPremium
|
|
|
+ meetLinkField?.isSelectable = isPremium
|
|
|
+ meetLinkField?.alphaValue = isPremium ? 1.0 : 0.75
|
|
|
+ // Keep button enabled so non-premium taps can still trigger paywall.
|
|
|
+ joinMeetPrimaryButton?.isEnabled = true
|
|
|
+ joinMeetPrimaryButton?.alphaValue = isPremium ? 1.0 : 0.80
|
|
|
+ joinWithLinkCardView?.toolTip = isPremium ? nil : "Premium required. Click to open paywall."
|
|
|
+ }
|
|
|
+
|
|
|
+ private func handlePremiumAccessChanged(_ hasPremiumAccess: Bool) {
|
|
|
+ let hadPremiumAccess = lastKnownPremiumAccess
|
|
|
+ lastKnownPremiumAccess = hasPremiumAccess
|
|
|
+ refreshPaywallStoreUI()
|
|
|
+ if hadPremiumAccess && !hasPremiumAccess {
|
|
|
+ showPaywall()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
private func refreshSidebarPremiumButton() {
|
|
|
let isPremium = storeKitCoordinator.hasPremiumAccess
|
|
|
if isPremium {
|
|
|
@@ -1476,17 +1538,37 @@ private extension ViewController {
|
|
|
let baseColor = palette.sectionCard
|
|
|
let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
|
|
|
let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
|
|
|
- instant.onHoverChanged = { hovering in
|
|
|
- instant.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
|
|
|
+ instant.onHoverChanged = { [weak self] hovering in
|
|
|
+ guard let self else { return }
|
|
|
+ if self.storeKitCoordinator.hasPremiumAccess {
|
|
|
+ instant.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
|
|
|
+ } else {
|
|
|
+ let disabledColor = self.palette.sectionCard.blended(withFraction: 0.22, of: self.palette.pageBackground) ?? self.palette.sectionCard
|
|
|
+ instant.layer?.backgroundColor = disabledColor.cgColor
|
|
|
+ }
|
|
|
}
|
|
|
- codeCard.onHoverChanged = { hovering in
|
|
|
- codeCard.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
|
|
|
+ codeCard.onHoverChanged = { [weak self] hovering in
|
|
|
+ guard let self else { return }
|
|
|
+ if self.storeKitCoordinator.hasPremiumAccess {
|
|
|
+ codeCard.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
|
|
|
+ } else {
|
|
|
+ let disabledColor = self.palette.sectionCard.blended(withFraction: 0.22, of: self.palette.pageBackground) ?? self.palette.sectionCard
|
|
|
+ codeCard.layer?.backgroundColor = disabledColor.cgColor
|
|
|
+ }
|
|
|
}
|
|
|
instant.onHoverChanged?(false)
|
|
|
codeCard.onHoverChanged?(false)
|
|
|
|
|
|
let instantClick = NSClickGestureRecognizer(target: self, action: #selector(instantMeetClicked(_:)))
|
|
|
instant.addGestureRecognizer(instantClick)
|
|
|
+ let joinWithLinkClick = NSClickGestureRecognizer(target: self, action: #selector(joinWithLinkCardClicked(_:)))
|
|
|
+ codeCard.addGestureRecognizer(joinWithLinkClick)
|
|
|
+ instantMeetCardView = instant
|
|
|
+ instantMeetTitleLabel = instantTitle
|
|
|
+ instantMeetSubtitleLabel = instantSub
|
|
|
+ joinWithLinkCardView = codeCard
|
|
|
+ joinWithLinkTitleLabel = codeTitle
|
|
|
+ refreshInstantMeetPremiumState()
|
|
|
|
|
|
row.addArrangedSubview(instant)
|
|
|
row.addArrangedSubview(codeCard)
|
|
|
@@ -1511,13 +1593,16 @@ private extension ViewController {
|
|
|
width: 110,
|
|
|
action: #selector(cancelMeetJoinClicked(_:))
|
|
|
))
|
|
|
- row.addArrangedSubview(meetActionButton(
|
|
|
+ let joinButton = meetActionButton(
|
|
|
title: "Join",
|
|
|
color: palette.primaryBlue,
|
|
|
textColor: .white,
|
|
|
width: 116,
|
|
|
action: #selector(joinMeetClicked(_:))
|
|
|
- ))
|
|
|
+ )
|
|
|
+ joinMeetPrimaryButton = joinButton
|
|
|
+ row.addArrangedSubview(joinButton)
|
|
|
+ refreshInstantMeetPremiumState()
|
|
|
return row
|
|
|
}
|
|
|
|
|
|
@@ -2552,10 +2637,43 @@ private extension ViewController {
|
|
|
let base = self.palette.sectionCard
|
|
|
let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black
|
|
|
let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
|
|
|
- card.layer?.backgroundColor = (hovering ? hover : base).cgColor
|
|
|
+ if self.storeKitCoordinator.hasPremiumAccess {
|
|
|
+ card.layer?.backgroundColor = (hovering ? hover : base).cgColor
|
|
|
+ } else {
|
|
|
+ card.layer?.backgroundColor = base.cgColor
|
|
|
+ }
|
|
|
}
|
|
|
hit.onHoverChanged?(false)
|
|
|
|
|
|
+ if !storeKitCoordinator.hasPremiumAccess {
|
|
|
+ let lockOverlay = NSVisualEffectView()
|
|
|
+ lockOverlay.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ lockOverlay.material = darkModeEnabled ? .hudWindow : .popover
|
|
|
+ lockOverlay.blendingMode = .withinWindow
|
|
|
+ lockOverlay.state = .active
|
|
|
+ lockOverlay.wantsLayer = true
|
|
|
+ lockOverlay.layer?.cornerRadius = 12
|
|
|
+ lockOverlay.layer?.masksToBounds = true
|
|
|
+ lockOverlay.layer?.backgroundColor = NSColor.black.withAlphaComponent(darkModeEnabled ? 0.28 : 0.12).cgColor
|
|
|
+
|
|
|
+ let lockLabel = textLabel("Get Premium to see events", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: .white)
|
|
|
+ lockLabel.alignment = .center
|
|
|
+
|
|
|
+ card.addSubview(lockOverlay)
|
|
|
+ lockOverlay.addSubview(lockLabel)
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ lockOverlay.leadingAnchor.constraint(equalTo: card.leadingAnchor),
|
|
|
+ lockOverlay.trailingAnchor.constraint(equalTo: card.trailingAnchor),
|
|
|
+ lockOverlay.topAnchor.constraint(equalTo: card.topAnchor),
|
|
|
+ lockOverlay.bottomAnchor.constraint(equalTo: card.bottomAnchor),
|
|
|
+ lockLabel.centerXAnchor.constraint(equalTo: lockOverlay.centerXAnchor),
|
|
|
+ lockLabel.centerYAnchor.constraint(equalTo: lockOverlay.centerYAnchor),
|
|
|
+ lockLabel.leadingAnchor.constraint(greaterThanOrEqualTo: lockOverlay.leadingAnchor, constant: 10),
|
|
|
+ lockLabel.trailingAnchor.constraint(lessThanOrEqualTo: lockOverlay.trailingAnchor, constant: -10)
|
|
|
+ ])
|
|
|
+ hit.toolTip = "Premium required. Click to open paywall."
|
|
|
+ }
|
|
|
+
|
|
|
return hit
|
|
|
}
|
|
|
|
|
|
@@ -3489,6 +3607,10 @@ private extension ViewController {
|
|
|
}
|
|
|
|
|
|
@objc func scheduleCardButtonPressed(_ sender: NSButton) {
|
|
|
+ guard storeKitCoordinator.hasPremiumAccess else {
|
|
|
+ showPaywall()
|
|
|
+ return
|
|
|
+ }
|
|
|
guard let raw = sender.identifier?.rawValue,
|
|
|
let url = URL(string: raw) else { return }
|
|
|
openMeetingURL(url)
|