瀏覽代碼

Add paywall auto-renewal disclosure and fix overlay loading issues.

Surface App Review-compliant renewal copy below the CTA with wrapping layout, and prevent the invisible paywall overlay from intercepting clicks while products load.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 4 周之前
父節點
當前提交
7c6c3b2d79
共有 3 個文件被更改,包括 113 次插入2 次删除
  1. 4 0
      smart_printer/PaywallConfig.swift
  2. 104 1
      smart_printer/PaywallView.swift
  3. 5 1
      smart_printer/paywall.json

+ 4 - 0
smart_printer/PaywallConfig.swift

@@ -61,6 +61,10 @@ struct PaywallConfig: Codable, Sendable {
     let urls: URLs
     let loadingPrice: String
     let loadingCTA: String
+    let subscriptionDisclosureTemplate: String
+    let trialDisclosureTemplate: String
+    let lifetimeDisclosure: String
+    let loadingDisclosure: String
 }
 
 extension PaywallConfig {

+ 104 - 1
smart_printer/PaywallView.swift

@@ -24,6 +24,17 @@ private enum SubscriptionOfferFormatting {
     static func freeTrialCTATitle(from offer: Product.SubscriptionOffer) -> String {
         "Start \(trialDurationDescription(from: offer)) Free Trial"
     }
+
+    static func subscriptionPeriodDescription(from period: Product.SubscriptionPeriod) -> String {
+        let count = period.value
+        switch period.unit {
+        case .day: return count == 1 ? "day" : "\(count) days"
+        case .week: return count == 1 ? "week" : "\(count) weeks"
+        case .month: return count == 1 ? "month" : "\(count) months"
+        case .year: return count == 1 ? "year" : "\(count) years"
+        @unknown default: return "\(count) days"
+        }
+    }
 }
 
 // MARK: - Plan Model
@@ -87,6 +98,35 @@ enum PaywallPlan: CaseIterable {
 
         return copy.ctaTemplate.replacingOccurrences(of: "{price}", with: price)
     }
+
+    func localizedRenewalDisclosure(
+        from product: Product?,
+        config: PaywallConfig,
+        introOffer: Product.SubscriptionOffer? = nil
+    ) -> String {
+        switch self {
+        case .lifetime:
+            return config.lifetimeDisclosure
+        case .monthly, .yearly:
+            guard let product else { return config.loadingDisclosure }
+            let price = product.displayPrice
+            let period = product.subscription.map {
+                SubscriptionOfferFormatting.subscriptionPeriodDescription(from: $0.subscriptionPeriod)
+            } ?? "period"
+
+            if self == .yearly, let introOffer {
+                let trial = SubscriptionOfferFormatting.trialDurationDescription(from: introOffer).lowercased()
+                return config.trialDisclosureTemplate
+                    .replacingOccurrences(of: "{trial}", with: trial)
+                    .replacingOccurrences(of: "{price}", with: price)
+                    .replacingOccurrences(of: "{period}", with: period)
+            }
+
+            return config.subscriptionDisclosureTemplate
+                .replacingOccurrences(of: "{price}", with: price)
+                .replacingOccurrences(of: "{period}", with: period)
+        }
+    }
 }
 
 // MARK: - StoreKit
@@ -1032,6 +1072,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
     private var selectedPlan: PaywallPlan = .yearly
     private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
     private let ctaButton = PaywallCTAButton()
+    private let renewalDisclosureLabel = NSTextField(wrappingLabelWithString: "")
     private let continueFreePlanLink = PaywallFooterLink(title: "")
     private let manageSubscriptionLink = PaywallFooterLink(title: "")
     private let restoreLink = PaywallFooterLink(title: "")
@@ -1152,6 +1193,13 @@ final class PaywallView: NSView, AppearanceRefreshable {
             config: config,
             introOffer: store.eligibleIntroOffer(for: selectedPlan)
         )
+        updateRenewalDisclosure(
+            selectedPlan.localizedRenewalDisclosure(
+                from: store.product(for: selectedPlan),
+                config: config,
+                introOffer: store.eligibleIntroOffer(for: selectedPlan)
+            )
+        )
         refreshTrustItemsForSelectedPlan()
         refreshPurchaseState()
     }
@@ -1202,11 +1250,26 @@ final class PaywallView: NSView, AppearanceRefreshable {
         refreshProductDisplay()
     }
 
+    override func layout() {
+        super.layout()
+        let width = renewalDisclosureLabel.bounds.width
+        guard width > 0, renewalDisclosureLabel.preferredMaxLayoutWidth != width else { return }
+        renewalDisclosureLabel.preferredMaxLayoutWidth = width
+        renewalDisclosureLabel.invalidateIntrinsicContentSize()
+    }
+
+    private func updateRenewalDisclosure(_ text: String) {
+        renewalDisclosureLabel.stringValue = text
+        renewalDisclosureLabel.invalidateIntrinsicContentSize()
+        needsLayout = true
+    }
+
     func refreshAppearance() {
         layer?.backgroundColor = AppTheme.paywallBackground.cgColor
         leftPanelTitle?.refreshThemeLabelColor()
         rightTitle?.refreshThemeLabelColor()
         rightSubtitle?.refreshThemeLabelColor()
+        renewalDisclosureLabel.textColor = AppTheme.textSecondary
         trustStack?.layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor
         trustStack?.layer?.borderColor = AppTheme.paywallBorder.cgColor
         ctaButton.refreshAppearance()
@@ -1335,6 +1398,25 @@ final class PaywallView: NSView, AppearanceRefreshable {
         ctaButton.action = #selector(purchaseTapped)
         ctaButton.translatesAutoresizingMaskIntoConstraints = false
 
+        renewalDisclosureLabel.font = AppTheme.regularFont(size: 10)
+        renewalDisclosureLabel.textColor = AppTheme.textSecondary
+        renewalDisclosureLabel.alignment = .center
+        renewalDisclosureLabel.maximumNumberOfLines = 3
+        renewalDisclosureLabel.lineBreakMode = .byWordWrapping
+        renewalDisclosureLabel.cell?.wraps = true
+        renewalDisclosureLabel.cell?.isScrollable = false
+        renewalDisclosureLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+        renewalDisclosureLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
+        renewalDisclosureLabel.setContentHuggingPriority(.required, for: .vertical)
+        renewalDisclosureLabel.translatesAutoresizingMaskIntoConstraints = false
+        updateRenewalDisclosure(
+            selectedPlan.localizedRenewalDisclosure(
+                from: StoreManager.shared.product(for: selectedPlan),
+                config: config,
+                introOffer: StoreManager.shared.eligibleIntroOffer(for: selectedPlan)
+            )
+        )
+
         let trustRow = makeTrustRow()
         let footerLinks = makeFooterLinks()
 
@@ -1343,6 +1425,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
         panel.addSubview(plansStack)
         panel.addSubview(trustRow)
         panel.addSubview(ctaButton)
+        panel.addSubview(renewalDisclosureLabel)
         panel.addSubview(footerLinks)
 
         NSLayoutConstraint.activate([
@@ -1366,10 +1449,14 @@ final class PaywallView: NSView, AppearanceRefreshable {
             ctaButton.trailingAnchor.constraint(equalTo: plansStack.trailingAnchor),
             ctaButton.topAnchor.constraint(equalTo: trustRow.bottomAnchor, constant: 16),
             ctaButton.heightAnchor.constraint(equalToConstant: 48),
-            ctaButton.bottomAnchor.constraint(lessThanOrEqualTo: footerLinks.topAnchor, constant: -14),
+
+            renewalDisclosureLabel.leadingAnchor.constraint(equalTo: plansStack.leadingAnchor),
+            renewalDisclosureLabel.trailingAnchor.constraint(equalTo: plansStack.trailingAnchor),
+            renewalDisclosureLabel.topAnchor.constraint(equalTo: ctaButton.bottomAnchor, constant: 10),
 
             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),
         ])
 
@@ -1504,6 +1591,13 @@ final class PaywallView: NSView, AppearanceRefreshable {
             config: paywallConfig,
             introOffer: StoreManager.shared.eligibleIntroOffer(for: plan)
         )
+        updateRenewalDisclosure(
+            plan.localizedRenewalDisclosure(
+                from: StoreManager.shared.product(for: plan),
+                config: paywallConfig,
+                introOffer: StoreManager.shared.eligibleIntroOffer(for: plan)
+            )
+        )
         refreshTrustItemsForSelectedPlan()
         refreshPurchaseState()
     }
@@ -1583,6 +1677,7 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
     private let blurView = NSVisualEffectView()
     private let backdrop = NSView()
     private let pattern = WavePatternView()
+    private var acceptsHitTesting = false
 
     init() {
         paywallView = PaywallView()
@@ -1606,6 +1701,11 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
     @available(*, unavailable)
     required init?(coder: NSCoder) { nil }
 
+    override func hitTest(_ point: NSPoint) -> NSView? {
+        guard acceptsHitTesting else { return nil }
+        return super.hitTest(point)
+    }
+
     private func setup() {
         blurView.translatesAutoresizingMaskIntoConstraints = false
         blurView.material = .underWindowBackground
@@ -1670,6 +1770,7 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
                 bottomAnchor.constraint(equalTo: parent.bottomAnchor),
             ])
         }
+        acceptsHitTesting = false
         alphaValue = 0
         Task { @MainActor in
             await StoreManager.shared.ensureEntitlementsResolved()
@@ -1679,10 +1780,12 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
             await PaywallConfigService.shared.refreshFromRemote()
             paywallView.refreshStoreState()
             alphaValue = 1
+            acceptsHitTesting = true
         }
     }
 
     func dismiss() {
+        acceptsHitTesting = false
         NSAnimationContext.runAnimationGroup({ context in
             context.duration = 0.15
             animator().alphaValue = 0

+ 5 - 1
smart_printer/paywall.json

@@ -66,5 +66,9 @@
     "manageSubscriptions": "https://apps.apple.com/account/subscriptions"
   },
   "loadingPrice": "—",
-  "loadingCTA": "Loading plans…"
+  "loadingCTA": "Loading plans…",
+  "subscriptionDisclosureTemplate": "Payment will be charged to your Apple ID. Subscription automatically renews at {price} per {period} unless canceled at least 24 hours before the end of the current period.",
+  "trialDisclosureTemplate": "After your {trial} free trial, payment will be charged to your Apple ID. Subscription automatically renews at {price} per {period} unless canceled at least 24 hours before the end of the current period.",
+  "lifetimeDisclosure": "One-time purchase. No subscription or recurring charges.",
+  "loadingDisclosure": "Payment will be charged to your Apple ID. Subscription automatically renews unless canceled at least 24 hours before the end of the current period."
 }