|
@@ -180,6 +180,7 @@ final class StoreManager {
|
|
|
var hasActiveSubscription: Bool { premiumAccessKind == .subscription }
|
|
var hasActiveSubscription: Bool { premiumAccessKind == .subscription }
|
|
|
var hasLifetimeAccess: Bool { premiumAccessKind == .lifetime }
|
|
var hasLifetimeAccess: Bool { premiumAccessKind == .lifetime }
|
|
|
private(set) var isLoadingProducts = false
|
|
private(set) var isLoadingProducts = false
|
|
|
|
|
+ private(set) var productLoadError: String?
|
|
|
private(set) var isPurchasing = false
|
|
private(set) var isPurchasing = false
|
|
|
private(set) var isResolvingEntitlements = true
|
|
private(set) var isResolvingEntitlements = true
|
|
|
private(set) var introOfferEligibleByProductID: [String: Bool] = [:]
|
|
private(set) var introOfferEligibleByProductID: [String: Bool] = [:]
|
|
@@ -234,6 +235,7 @@ final class StoreManager {
|
|
|
|
|
|
|
|
func loadProducts() async {
|
|
func loadProducts() async {
|
|
|
isLoadingProducts = true
|
|
isLoadingProducts = true
|
|
|
|
|
+ productLoadError = nil
|
|
|
postStoreStateDidChange()
|
|
postStoreStateDidChange()
|
|
|
defer {
|
|
defer {
|
|
|
isLoadingProducts = false
|
|
isLoadingProducts = false
|
|
@@ -241,13 +243,20 @@ final class StoreManager {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
do {
|
|
do {
|
|
|
- products = try await Product.products(for: StoreProductID.all)
|
|
|
|
|
- .sorted { lhs, rhs in
|
|
|
|
|
- productSortOrder(for: lhs.id) < productSortOrder(for: rhs.id)
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ let loaded = try await Product.products(for: StoreProductID.all)
|
|
|
|
|
+ guard !loaded.isEmpty else {
|
|
|
|
|
+ productLoadError = "No subscription plans are available right now."
|
|
|
|
|
+ products = []
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ products = loaded.sorted { lhs, rhs in
|
|
|
|
|
+ productSortOrder(for: lhs.id) < productSortOrder(for: rhs.id)
|
|
|
|
|
+ }
|
|
|
|
|
+ productLoadError = nil
|
|
|
await refreshIntroOfferEligibility()
|
|
await refreshIntroOfferEligibility()
|
|
|
NotificationCenter.default.post(name: .storeProductsDidUpdate, object: nil)
|
|
NotificationCenter.default.post(name: .storeProductsDidUpdate, object: nil)
|
|
|
} catch {
|
|
} catch {
|
|
|
|
|
+ productLoadError = "Couldn't load subscription plans. Check your connection and try again."
|
|
|
NSLog("Failed to load products: \(error.localizedDescription)")
|
|
NSLog("Failed to load products: \(error.localizedDescription)")
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -908,6 +917,7 @@ private final class PaywallTrustItemView: NSView, AppearanceRefreshable {
|
|
|
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
|
|
subtitleLabel.lineBreakMode = .byTruncatingTail
|
|
subtitleLabel.lineBreakMode = .byTruncatingTail
|
|
|
|
|
+ subtitleLabel.maximumNumberOfLines = 2
|
|
|
subtitleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
subtitleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
|
@@ -917,6 +927,8 @@ private final class PaywallTrustItemView: NSView, AppearanceRefreshable {
|
|
|
addSubview(subtitleLabel)
|
|
addSubview(subtitleLabel)
|
|
|
|
|
|
|
|
NSLayoutConstraint.activate([
|
|
NSLayoutConstraint.activate([
|
|
|
|
|
+ heightAnchor.constraint(equalToConstant: Self.preferredHeight),
|
|
|
|
|
+
|
|
|
iconContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
iconContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
|
iconContainer.topAnchor.constraint(equalTo: topAnchor),
|
|
iconContainer.topAnchor.constraint(equalTo: topAnchor),
|
|
|
iconContainer.widthAnchor.constraint(equalToConstant: 20),
|
|
iconContainer.widthAnchor.constraint(equalToConstant: 20),
|
|
@@ -939,9 +951,13 @@ private final class PaywallTrustItemView: NSView, AppearanceRefreshable {
|
|
|
|
|
|
|
|
setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
|
|
+ setContentHuggingPriority(.required, for: .vertical)
|
|
|
|
|
+ setContentCompressionResistancePriority(.required, for: .vertical)
|
|
|
refreshAppearance()
|
|
refreshAppearance()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ fileprivate static let preferredHeight: CGFloat = 34
|
|
|
|
|
+
|
|
|
func update(iconName: String, title: String, subtitle: String) {
|
|
func update(iconName: String, title: String, subtitle: String) {
|
|
|
titleLabel.stringValue = title
|
|
titleLabel.stringValue = title
|
|
|
subtitleLabel.stringValue = subtitle
|
|
subtitleLabel.stringValue = subtitle
|
|
@@ -962,6 +978,64 @@ private final class PaywallTrustItemView: NSView, AppearanceRefreshable {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// MARK: - Products Error
|
|
|
|
|
+
|
|
|
|
|
+private final class PaywallProductsErrorView: NSView, AppearanceRefreshable {
|
|
|
|
|
+ var onRetry: (() -> Void)?
|
|
|
|
|
+
|
|
|
|
|
+ private let messageLabel = NSTextField(wrappingLabelWithString: "")
|
|
|
|
|
+ private let retryButton = PaywallFooterLink(title: "Retry")
|
|
|
|
|
+
|
|
|
|
|
+ init() {
|
|
|
|
|
+ super.init(frame: .zero)
|
|
|
|
|
+ translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ messageLabel.font = AppTheme.regularFont(size: 12)
|
|
|
|
|
+ messageLabel.textColor = AppTheme.textSecondary
|
|
|
|
|
+ messageLabel.alignment = .center
|
|
|
|
|
+ messageLabel.maximumNumberOfLines = 3
|
|
|
|
|
+ messageLabel.lineBreakMode = .byWordWrapping
|
|
|
|
|
+ messageLabel.cell?.wraps = true
|
|
|
|
|
+ messageLabel.cell?.isScrollable = false
|
|
|
|
|
+ messageLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ retryButton.target = self
|
|
|
|
|
+ retryButton.action = #selector(retryTapped)
|
|
|
|
|
+
|
|
|
|
|
+ addSubview(messageLabel)
|
|
|
|
|
+ addSubview(retryButton)
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ messageLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
|
|
|
+ messageLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
|
|
|
+ messageLabel.topAnchor.constraint(equalTo: topAnchor),
|
|
|
|
|
+
|
|
|
|
|
+ retryButton.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
|
|
|
+ retryButton.topAnchor.constraint(equalTo: messageLabel.bottomAnchor, constant: 8),
|
|
|
|
|
+ retryButton.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
|
|
|
+ ])
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @available(*, unavailable)
|
|
|
|
|
+ required init?(coder: NSCoder) { nil }
|
|
|
|
|
+
|
|
|
|
|
+ func update(message: String, isRetrying: Bool) {
|
|
|
|
|
+ messageLabel.stringValue = message
|
|
|
|
|
+ retryButton.isHidden = false
|
|
|
|
|
+ retryButton.isEnabled = !isRetrying
|
|
|
|
|
+ retryButton.updateTitle(isRetrying ? "Retrying…" : "Retry")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func refreshAppearance() {
|
|
|
|
|
+ messageLabel.textColor = AppTheme.textSecondary
|
|
|
|
|
+ retryButton.refreshAppearance()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @objc private func retryTapped() {
|
|
|
|
|
+ onRetry?()
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// MARK: - Close Button
|
|
// MARK: - Close Button
|
|
|
|
|
|
|
|
private final class PaywallCloseButton: NSButton, AppearanceRefreshable {
|
|
private final class PaywallCloseButton: NSButton, AppearanceRefreshable {
|
|
@@ -1088,6 +1162,10 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
private var featureRows: [PaywallFeatureRow] = []
|
|
private var featureRows: [PaywallFeatureRow] = []
|
|
|
private var trustItemViews: [PaywallTrustItemView] = []
|
|
private var trustItemViews: [PaywallTrustItemView] = []
|
|
|
private var trustStack: NSStackView!
|
|
private var trustStack: NSStackView!
|
|
|
|
|
+ private var productsErrorView: PaywallProductsErrorView!
|
|
|
|
|
+ private var plansStack: NSStackView!
|
|
|
|
|
+ private var plansStackHeightConstraint: NSLayoutConstraint!
|
|
|
|
|
+ private var productsErrorHeightConstraint: NSLayoutConstraint!
|
|
|
private var storeObservers: [NSObjectProtocol] = []
|
|
private var storeObservers: [NSObjectProtocol] = []
|
|
|
|
|
|
|
|
init() {
|
|
init() {
|
|
@@ -1201,14 +1279,41 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
)
|
|
)
|
|
|
)
|
|
)
|
|
|
refreshTrustItemsForSelectedPlan()
|
|
refreshTrustItemsForSelectedPlan()
|
|
|
|
|
+ refreshProductsErrorState()
|
|
|
refreshPurchaseState()
|
|
refreshPurchaseState()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private func refreshProductsErrorState() {
|
|
|
|
|
+ let store = StoreManager.shared
|
|
|
|
|
+ let shouldShowError = store.productLoadError != nil
|
|
|
|
|
+ && store.products.isEmpty
|
|
|
|
|
+ && !store.isLoadingProducts
|
|
|
|
|
+
|
|
|
|
|
+ productsErrorView.isHidden = !shouldShowError
|
|
|
|
|
+ plansStack.isHidden = shouldShowError
|
|
|
|
|
+ plansStackHeightConstraint.constant = shouldShowError ? 0 : plansAreaHeight
|
|
|
|
|
+ productsErrorHeightConstraint.constant = shouldShowError ? productsErrorAreaHeight : 0
|
|
|
|
|
+
|
|
|
|
|
+ if shouldShowError, let error = store.productLoadError {
|
|
|
|
|
+ productsErrorView.update(message: error, isRetrying: false)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var plansAreaHeight: CGFloat {
|
|
|
|
|
+ let cardHeight: CGFloat = 86
|
|
|
|
|
+ let spacing: CGFloat = 12
|
|
|
|
|
+ let planCount = CGFloat(PaywallPlan.allCases.count)
|
|
|
|
|
+ return planCount * cardHeight + max(0, planCount - 1) * spacing
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var productsErrorAreaHeight: CGFloat { 86 }
|
|
|
|
|
+
|
|
|
private func refreshPurchaseState() {
|
|
private func refreshPurchaseState() {
|
|
|
let store = StoreManager.shared
|
|
let store = StoreManager.shared
|
|
|
|
|
+ let hasProductLoadFailure = store.productLoadError != nil && store.products.isEmpty
|
|
|
let isProductReady = store.product(for: selectedPlan) != nil
|
|
let isProductReady = store.product(for: selectedPlan) != nil
|
|
|
let isBusy = store.isPurchasing || store.isLoadingProducts
|
|
let isBusy = store.isPurchasing || store.isLoadingProducts
|
|
|
- let isDisabled = isBusy || !isProductReady
|
|
|
|
|
|
|
+ let isDisabled = isBusy || !isProductReady || hasProductLoadFailure
|
|
|
ctaButton.isEnabled = !isDisabled
|
|
ctaButton.isEnabled = !isDisabled
|
|
|
ctaButton.alphaValue = isDisabled ? 0.65 : 1
|
|
ctaButton.alphaValue = isDisabled ? 0.65 : 1
|
|
|
refreshPrimaryFooterLink()
|
|
refreshPrimaryFooterLink()
|
|
@@ -1380,6 +1485,17 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
plansStack.orientation = .vertical
|
|
plansStack.orientation = .vertical
|
|
|
plansStack.spacing = 12
|
|
plansStack.spacing = 12
|
|
|
plansStack.translatesAutoresizingMaskIntoConstraints = false
|
|
plansStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ self.plansStack = plansStack
|
|
|
|
|
+
|
|
|
|
|
+ let plansAreaView = NSView()
|
|
|
|
|
+ plansAreaView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ let productsErrorView = PaywallProductsErrorView()
|
|
|
|
|
+ productsErrorView.isHidden = true
|
|
|
|
|
+ productsErrorView.onRetry = { [weak self] in
|
|
|
|
|
+ self?.retryProductLoad()
|
|
|
|
|
+ }
|
|
|
|
|
+ self.productsErrorView = productsErrorView
|
|
|
|
|
|
|
|
for plan in PaywallPlan.allCases {
|
|
for plan in PaywallPlan.allCases {
|
|
|
let card = PaywallPlanCard(plan: plan)
|
|
let card = PaywallPlanCard(plan: plan)
|
|
@@ -1420,12 +1536,24 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
let trustRow = makeTrustRow()
|
|
let trustRow = makeTrustRow()
|
|
|
let footerLinks = makeFooterLinks()
|
|
let footerLinks = makeFooterLinks()
|
|
|
|
|
|
|
|
|
|
+ let footerSpacer = NSView()
|
|
|
|
|
+ footerSpacer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ footerSpacer.setContentHuggingPriority(.defaultLow, for: .vertical)
|
|
|
|
|
+ footerSpacer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
|
|
|
|
+
|
|
|
|
|
+ plansAreaView.addSubview(plansStack)
|
|
|
|
|
+ plansAreaView.addSubview(productsErrorView)
|
|
|
|
|
+
|
|
|
|
|
+ plansStackHeightConstraint = plansStack.heightAnchor.constraint(equalToConstant: plansAreaHeight)
|
|
|
|
|
+ productsErrorHeightConstraint = productsErrorView.heightAnchor.constraint(equalToConstant: 0)
|
|
|
|
|
+
|
|
|
panel.addSubview(title)
|
|
panel.addSubview(title)
|
|
|
panel.addSubview(subtitle)
|
|
panel.addSubview(subtitle)
|
|
|
- panel.addSubview(plansStack)
|
|
|
|
|
|
|
+ panel.addSubview(plansAreaView)
|
|
|
panel.addSubview(trustRow)
|
|
panel.addSubview(trustRow)
|
|
|
panel.addSubview(ctaButton)
|
|
panel.addSubview(ctaButton)
|
|
|
panel.addSubview(renewalDisclosureLabel)
|
|
panel.addSubview(renewalDisclosureLabel)
|
|
|
|
|
+ panel.addSubview(footerSpacer)
|
|
|
panel.addSubview(footerLinks)
|
|
panel.addSubview(footerLinks)
|
|
|
|
|
|
|
|
NSLayoutConstraint.activate([
|
|
NSLayoutConstraint.activate([
|
|
@@ -1437,27 +1565,42 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
subtitle.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
|
|
subtitle.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
|
|
|
subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6),
|
|
subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6),
|
|
|
|
|
|
|
|
- plansStack.leadingAnchor.constraint(equalTo: title.leadingAnchor),
|
|
|
|
|
- plansStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
|
|
|
|
|
- plansStack.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 24),
|
|
|
|
|
|
|
+ plansAreaView.leadingAnchor.constraint(equalTo: title.leadingAnchor),
|
|
|
|
|
+ plansAreaView.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
|
|
|
|
|
+ plansAreaView.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 24),
|
|
|
|
|
+ plansAreaView.heightAnchor.constraint(equalToConstant: plansAreaHeight),
|
|
|
|
|
+
|
|
|
|
|
+ plansStack.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor),
|
|
|
|
|
+ plansStack.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor),
|
|
|
|
|
+ plansStack.topAnchor.constraint(equalTo: plansAreaView.topAnchor),
|
|
|
|
|
+ plansStackHeightConstraint,
|
|
|
|
|
+
|
|
|
|
|
+ productsErrorView.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor),
|
|
|
|
|
+ productsErrorView.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor),
|
|
|
|
|
+ productsErrorView.topAnchor.constraint(equalTo: plansAreaView.topAnchor),
|
|
|
|
|
+ productsErrorHeightConstraint,
|
|
|
|
|
|
|
|
- trustRow.leadingAnchor.constraint(equalTo: plansStack.leadingAnchor),
|
|
|
|
|
- trustRow.trailingAnchor.constraint(equalTo: plansStack.trailingAnchor),
|
|
|
|
|
- trustRow.topAnchor.constraint(equalTo: plansStack.bottomAnchor, constant: 18),
|
|
|
|
|
|
|
+ trustRow.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor),
|
|
|
|
|
+ trustRow.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor),
|
|
|
|
|
+ trustRow.topAnchor.constraint(equalTo: plansAreaView.bottomAnchor, constant: 18),
|
|
|
|
|
|
|
|
- ctaButton.leadingAnchor.constraint(equalTo: plansStack.leadingAnchor),
|
|
|
|
|
- ctaButton.trailingAnchor.constraint(equalTo: plansStack.trailingAnchor),
|
|
|
|
|
|
|
+ ctaButton.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor),
|
|
|
|
|
+ ctaButton.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor),
|
|
|
ctaButton.topAnchor.constraint(equalTo: trustRow.bottomAnchor, constant: 16),
|
|
ctaButton.topAnchor.constraint(equalTo: trustRow.bottomAnchor, constant: 16),
|
|
|
ctaButton.heightAnchor.constraint(equalToConstant: 48),
|
|
ctaButton.heightAnchor.constraint(equalToConstant: 48),
|
|
|
|
|
|
|
|
- renewalDisclosureLabel.leadingAnchor.constraint(equalTo: plansStack.leadingAnchor),
|
|
|
|
|
- renewalDisclosureLabel.trailingAnchor.constraint(equalTo: plansStack.trailingAnchor),
|
|
|
|
|
|
|
+ renewalDisclosureLabel.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor),
|
|
|
|
|
+ renewalDisclosureLabel.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor),
|
|
|
renewalDisclosureLabel.topAnchor.constraint(equalTo: ctaButton.bottomAnchor, constant: 10),
|
|
renewalDisclosureLabel.topAnchor.constraint(equalTo: ctaButton.bottomAnchor, constant: 10),
|
|
|
|
|
|
|
|
|
|
+ footerSpacer.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
|
|
|
|
|
+ footerSpacer.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
|
|
|
|
|
+ footerSpacer.topAnchor.constraint(equalTo: renewalDisclosureLabel.bottomAnchor, constant: 16),
|
|
|
|
|
+ footerSpacer.bottomAnchor.constraint(equalTo: footerLinks.topAnchor),
|
|
|
|
|
+
|
|
|
footerLinks.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
|
|
footerLinks.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
|
|
|
footerLinks.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
|
|
footerLinks.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
|
|
|
- footerLinks.topAnchor.constraint(equalTo: renewalDisclosureLabel.bottomAnchor, constant: 14),
|
|
|
|
|
- footerLinks.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -20),
|
|
|
|
|
|
|
+ footerLinks.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -16),
|
|
|
])
|
|
])
|
|
|
|
|
|
|
|
return panel
|
|
return panel
|
|
@@ -1485,6 +1628,11 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
trustStack.layer?.masksToBounds = true
|
|
trustStack.layer?.masksToBounds = true
|
|
|
trustStack.edgeInsets = NSEdgeInsets(top: 12, left: 14, bottom: 12, right: 14)
|
|
trustStack.edgeInsets = NSEdgeInsets(top: 12, left: 14, bottom: 12, right: 14)
|
|
|
trustStack.translatesAutoresizingMaskIntoConstraints = false
|
|
trustStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ trustStack.setContentHuggingPriority(.required, for: .vertical)
|
|
|
|
|
+ trustStack.setContentCompressionResistancePriority(.required, for: .vertical)
|
|
|
|
|
+ trustStack.heightAnchor.constraint(
|
|
|
|
|
+ equalToConstant: PaywallTrustItemView.preferredHeight + trustStack.edgeInsets.top + trustStack.edgeInsets.bottom
|
|
|
|
|
+ ).isActive = true
|
|
|
self.trustStack = trustStack
|
|
self.trustStack = trustStack
|
|
|
|
|
|
|
|
return trustStack
|
|
return trustStack
|
|
@@ -1493,8 +1641,15 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
private func makeFooterLinks() -> NSView {
|
|
private func makeFooterLinks() -> NSView {
|
|
|
let container = NSView()
|
|
let container = NSView()
|
|
|
container.translatesAutoresizingMaskIntoConstraints = false
|
|
container.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ container.setContentHuggingPriority(.required, for: .vertical)
|
|
|
|
|
+ container.setContentCompressionResistancePriority(.required, for: .vertical)
|
|
|
let config = paywallConfig
|
|
let config = paywallConfig
|
|
|
|
|
|
|
|
|
|
+ let separator = NSView()
|
|
|
|
|
+ separator.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ separator.wantsLayer = true
|
|
|
|
|
+ separator.layer?.backgroundColor = AppTheme.paywallBorder.cgColor
|
|
|
|
|
+
|
|
|
continueFreePlanLink.updateTitle(config.footer.continueFree)
|
|
continueFreePlanLink.updateTitle(config.footer.continueFree)
|
|
|
continueFreePlanLink.target = self
|
|
continueFreePlanLink.target = self
|
|
|
continueFreePlanLink.action = #selector(continueWithFreePlanTapped)
|
|
continueFreePlanLink.action = #selector(continueWithFreePlanTapped)
|
|
@@ -1538,11 +1693,17 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
linksStack.distribution = .fillEqually
|
|
linksStack.distribution = .fillEqually
|
|
|
linksStack.translatesAutoresizingMaskIntoConstraints = false
|
|
linksStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
|
|
|
|
+ container.addSubview(separator)
|
|
|
container.addSubview(linksStack)
|
|
container.addSubview(linksStack)
|
|
|
NSLayoutConstraint.activate([
|
|
NSLayoutConstraint.activate([
|
|
|
|
|
+ separator.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
|
|
|
|
+ separator.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
|
|
|
|
+ separator.topAnchor.constraint(equalTo: container.topAnchor),
|
|
|
|
|
+ separator.heightAnchor.constraint(equalToConstant: 1),
|
|
|
|
|
+
|
|
|
linksStack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
|
linksStack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
|
|
linksStack.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
|
linksStack.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
|
|
- linksStack.topAnchor.constraint(equalTo: container.topAnchor),
|
|
|
|
|
|
|
+ linksStack.topAnchor.constraint(equalTo: separator.bottomAnchor, constant: 10),
|
|
|
linksStack.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
|
linksStack.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
|
|
])
|
|
])
|
|
|
|
|
|
|
@@ -1552,6 +1713,8 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
private func makePrimaryFooterLinkCell() -> NSView {
|
|
private func makePrimaryFooterLinkCell() -> NSView {
|
|
|
let cell = NSView()
|
|
let cell = NSView()
|
|
|
cell.translatesAutoresizingMaskIntoConstraints = false
|
|
cell.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ cell.setContentHuggingPriority(.required, for: .vertical)
|
|
|
|
|
+ cell.setContentCompressionResistancePriority(.required, for: .vertical)
|
|
|
|
|
|
|
|
for link in [continueFreePlanLink, manageSubscriptionLink] {
|
|
for link in [continueFreePlanLink, manageSubscriptionLink] {
|
|
|
cell.addSubview(link)
|
|
cell.addSubview(link)
|
|
@@ -1569,6 +1732,8 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
private func makeFooterLinkCell(link: PaywallFooterLink) -> NSView {
|
|
private func makeFooterLinkCell(link: PaywallFooterLink) -> NSView {
|
|
|
let cell = NSView()
|
|
let cell = NSView()
|
|
|
cell.translatesAutoresizingMaskIntoConstraints = false
|
|
cell.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ cell.setContentHuggingPriority(.required, for: .vertical)
|
|
|
|
|
+ cell.setContentCompressionResistancePriority(.required, for: .vertical)
|
|
|
|
|
|
|
|
cell.addSubview(link)
|
|
cell.addSubview(link)
|
|
|
NSLayoutConstraint.activate([
|
|
NSLayoutConstraint.activate([
|
|
@@ -1602,6 +1767,15 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
refreshPurchaseState()
|
|
refreshPurchaseState()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private func retryProductLoad() {
|
|
|
|
|
+ Task { @MainActor in
|
|
|
|
|
+ refreshProductsErrorState()
|
|
|
|
|
+ refreshPurchaseState()
|
|
|
|
|
+ await StoreManager.shared.loadProducts()
|
|
|
|
|
+ refreshStoreState()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
@objc private func purchaseTapped() {
|
|
@objc private func purchaseTapped() {
|
|
|
Task { @MainActor in
|
|
Task { @MainActor in
|
|
|
refreshPurchaseState()
|
|
refreshPurchaseState()
|
|
@@ -1770,17 +1944,23 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
|
|
|
bottomAnchor.constraint(equalTo: parent.bottomAnchor),
|
|
bottomAnchor.constraint(equalTo: parent.bottomAnchor),
|
|
|
])
|
|
])
|
|
|
}
|
|
}
|
|
|
- acceptsHitTesting = false
|
|
|
|
|
- alphaValue = 0
|
|
|
|
|
|
|
+ alphaValue = 1
|
|
|
|
|
+ acceptsHitTesting = true
|
|
|
|
|
+ paywallView.refreshStoreState()
|
|
|
|
|
+
|
|
|
Task { @MainActor in
|
|
Task { @MainActor in
|
|
|
- await StoreManager.shared.ensureEntitlementsResolved()
|
|
|
|
|
- if StoreManager.shared.products.isEmpty {
|
|
|
|
|
|
|
+ if StoreManager.shared.isResolvingEntitlements {
|
|
|
|
|
+ await StoreManager.shared.ensureEntitlementsResolved()
|
|
|
|
|
+ paywallView.refreshStoreState()
|
|
|
|
|
+ }
|
|
|
|
|
+ if StoreManager.shared.products.isEmpty, !StoreManager.shared.isLoadingProducts {
|
|
|
await StoreManager.shared.loadProducts()
|
|
await StoreManager.shared.loadProducts()
|
|
|
|
|
+ paywallView.refreshStoreState()
|
|
|
}
|
|
}
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Task { @MainActor in
|
|
|
await PaywallConfigService.shared.refreshFromRemote()
|
|
await PaywallConfigService.shared.refreshFromRemote()
|
|
|
- paywallView.refreshStoreState()
|
|
|
|
|
- alphaValue = 1
|
|
|
|
|
- acceptsHitTesting = true
|
|
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|