Explorar o código

Improve paywall loading UX and fix footer layout.

Show the paywall immediately with bundled config while products and remote config refresh in the background, surface product load failures with retry, and pin footer links to the bottom without stretching trust badges.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 hai 3 semanas
pai
achega
3580905933
Modificáronse 1 ficheiros con 206 adicións e 26 borrados
  1. 206 26
      smart_printer/PaywallView.swift

+ 206 - 26
smart_printer/PaywallView.swift

@@ -180,6 +180,7 @@ final class StoreManager {
     var hasActiveSubscription: Bool { premiumAccessKind == .subscription }
     var hasLifetimeAccess: Bool { premiumAccessKind == .lifetime }
     private(set) var isLoadingProducts = false
+    private(set) var productLoadError: String?
     private(set) var isPurchasing = false
     private(set) var isResolvingEntitlements = true
     private(set) var introOfferEligibleByProductID: [String: Bool] = [:]
@@ -234,6 +235,7 @@ final class StoreManager {
 
     func loadProducts() async {
         isLoadingProducts = true
+        productLoadError = nil
         postStoreStateDidChange()
         defer {
             isLoadingProducts = false
@@ -241,13 +243,20 @@ final class StoreManager {
         }
 
         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()
             NotificationCenter.default.post(name: .storeProductsDidUpdate, object: nil)
         } catch {
+            productLoadError = "Couldn't load subscription plans. Check your connection and try again."
             NSLog("Failed to load products: \(error.localizedDescription)")
         }
     }
@@ -908,6 +917,7 @@ private final class PaywallTrustItemView: NSView, AppearanceRefreshable {
         titleLabel.translatesAutoresizingMaskIntoConstraints = false
 
         subtitleLabel.lineBreakMode = .byTruncatingTail
+        subtitleLabel.maximumNumberOfLines = 2
         subtitleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
         subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
 
@@ -917,6 +927,8 @@ private final class PaywallTrustItemView: NSView, AppearanceRefreshable {
         addSubview(subtitleLabel)
 
         NSLayoutConstraint.activate([
+            heightAnchor.constraint(equalToConstant: Self.preferredHeight),
+
             iconContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
             iconContainer.topAnchor.constraint(equalTo: topAnchor),
             iconContainer.widthAnchor.constraint(equalToConstant: 20),
@@ -939,9 +951,13 @@ private final class PaywallTrustItemView: NSView, AppearanceRefreshable {
 
         setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
         setContentHuggingPriority(.defaultLow, for: .horizontal)
+        setContentHuggingPriority(.required, for: .vertical)
+        setContentCompressionResistancePriority(.required, for: .vertical)
         refreshAppearance()
     }
 
+    fileprivate static let preferredHeight: CGFloat = 34
+
     func update(iconName: String, title: String, subtitle: String) {
         titleLabel.stringValue = title
         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
 
 private final class PaywallCloseButton: NSButton, AppearanceRefreshable {
@@ -1088,6 +1162,10 @@ final class PaywallView: NSView, AppearanceRefreshable {
     private var featureRows: [PaywallFeatureRow] = []
     private var trustItemViews: [PaywallTrustItemView] = []
     private var trustStack: NSStackView!
+    private var productsErrorView: PaywallProductsErrorView!
+    private var plansStack: NSStackView!
+    private var plansStackHeightConstraint: NSLayoutConstraint!
+    private var productsErrorHeightConstraint: NSLayoutConstraint!
     private var storeObservers: [NSObjectProtocol] = []
 
     init() {
@@ -1201,14 +1279,41 @@ final class PaywallView: NSView, AppearanceRefreshable {
             )
         )
         refreshTrustItemsForSelectedPlan()
+        refreshProductsErrorState()
         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() {
         let store = StoreManager.shared
+        let hasProductLoadFailure = store.productLoadError != nil && store.products.isEmpty
         let isProductReady = store.product(for: selectedPlan) != nil
         let isBusy = store.isPurchasing || store.isLoadingProducts
-        let isDisabled = isBusy || !isProductReady
+        let isDisabled = isBusy || !isProductReady || hasProductLoadFailure
         ctaButton.isEnabled = !isDisabled
         ctaButton.alphaValue = isDisabled ? 0.65 : 1
         refreshPrimaryFooterLink()
@@ -1380,6 +1485,17 @@ final class PaywallView: NSView, AppearanceRefreshable {
         plansStack.orientation = .vertical
         plansStack.spacing = 12
         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 {
             let card = PaywallPlanCard(plan: plan)
@@ -1420,12 +1536,24 @@ final class PaywallView: NSView, AppearanceRefreshable {
         let trustRow = makeTrustRow()
         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(subtitle)
-        panel.addSubview(plansStack)
+        panel.addSubview(plansAreaView)
         panel.addSubview(trustRow)
         panel.addSubview(ctaButton)
         panel.addSubview(renewalDisclosureLabel)
+        panel.addSubview(footerSpacer)
         panel.addSubview(footerLinks)
 
         NSLayoutConstraint.activate([
@@ -1437,27 +1565,42 @@ final class PaywallView: NSView, AppearanceRefreshable {
             subtitle.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
             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.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),
 
+            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.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
@@ -1485,6 +1628,11 @@ final class PaywallView: NSView, AppearanceRefreshable {
         trustStack.layer?.masksToBounds = true
         trustStack.edgeInsets = NSEdgeInsets(top: 12, left: 14, bottom: 12, right: 14)
         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
 
         return trustStack
@@ -1493,8 +1641,15 @@ final class PaywallView: NSView, AppearanceRefreshable {
     private func makeFooterLinks() -> NSView {
         let container = NSView()
         container.translatesAutoresizingMaskIntoConstraints = false
+        container.setContentHuggingPriority(.required, for: .vertical)
+        container.setContentCompressionResistancePriority(.required, for: .vertical)
         let config = paywallConfig
 
+        let separator = NSView()
+        separator.translatesAutoresizingMaskIntoConstraints = false
+        separator.wantsLayer = true
+        separator.layer?.backgroundColor = AppTheme.paywallBorder.cgColor
+
         continueFreePlanLink.updateTitle(config.footer.continueFree)
         continueFreePlanLink.target = self
         continueFreePlanLink.action = #selector(continueWithFreePlanTapped)
@@ -1538,11 +1693,17 @@ final class PaywallView: NSView, AppearanceRefreshable {
         linksStack.distribution = .fillEqually
         linksStack.translatesAutoresizingMaskIntoConstraints = false
 
+        container.addSubview(separator)
         container.addSubview(linksStack)
         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.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),
         ])
 
@@ -1552,6 +1713,8 @@ final class PaywallView: NSView, AppearanceRefreshable {
     private func makePrimaryFooterLinkCell() -> NSView {
         let cell = NSView()
         cell.translatesAutoresizingMaskIntoConstraints = false
+        cell.setContentHuggingPriority(.required, for: .vertical)
+        cell.setContentCompressionResistancePriority(.required, for: .vertical)
 
         for link in [continueFreePlanLink, manageSubscriptionLink] {
             cell.addSubview(link)
@@ -1569,6 +1732,8 @@ final class PaywallView: NSView, AppearanceRefreshable {
     private func makeFooterLinkCell(link: PaywallFooterLink) -> NSView {
         let cell = NSView()
         cell.translatesAutoresizingMaskIntoConstraints = false
+        cell.setContentHuggingPriority(.required, for: .vertical)
+        cell.setContentCompressionResistancePriority(.required, for: .vertical)
 
         cell.addSubview(link)
         NSLayoutConstraint.activate([
@@ -1602,6 +1767,15 @@ final class PaywallView: NSView, AppearanceRefreshable {
         refreshPurchaseState()
     }
 
+    private func retryProductLoad() {
+        Task { @MainActor in
+            refreshProductsErrorState()
+            refreshPurchaseState()
+            await StoreManager.shared.loadProducts()
+            refreshStoreState()
+        }
+    }
+
     @objc private func purchaseTapped() {
         Task { @MainActor in
             refreshPurchaseState()
@@ -1770,17 +1944,23 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
                 bottomAnchor.constraint(equalTo: parent.bottomAnchor),
             ])
         }
-        acceptsHitTesting = false
-        alphaValue = 0
+        alphaValue = 1
+        acceptsHitTesting = true
+        paywallView.refreshStoreState()
+
         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()
+                paywallView.refreshStoreState()
             }
+        }
+
+        Task { @MainActor in
             await PaywallConfigService.shared.refreshFromRemote()
-            paywallView.refreshStoreState()
-            alphaValue = 1
-            acceptsHitTesting = true
         }
     }