|
@@ -24,6 +24,17 @@ private enum SubscriptionOfferFormatting {
|
|
|
static func freeTrialCTATitle(from offer: Product.SubscriptionOffer) -> String {
|
|
static func freeTrialCTATitle(from offer: Product.SubscriptionOffer) -> String {
|
|
|
"Start \(trialDurationDescription(from: offer)) Free Trial"
|
|
"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
|
|
// MARK: - Plan Model
|
|
@@ -87,6 +98,35 @@ enum PaywallPlan: CaseIterable {
|
|
|
|
|
|
|
|
return copy.ctaTemplate.replacingOccurrences(of: "{price}", with: price)
|
|
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
|
|
// MARK: - StoreKit
|
|
@@ -1032,6 +1072,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
private var selectedPlan: PaywallPlan = .yearly
|
|
private var selectedPlan: PaywallPlan = .yearly
|
|
|
private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
|
|
private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
|
|
|
private let ctaButton = PaywallCTAButton()
|
|
private let ctaButton = PaywallCTAButton()
|
|
|
|
|
+ private let renewalDisclosureLabel = NSTextField(wrappingLabelWithString: "")
|
|
|
private let continueFreePlanLink = PaywallFooterLink(title: "")
|
|
private let continueFreePlanLink = PaywallFooterLink(title: "")
|
|
|
private let manageSubscriptionLink = PaywallFooterLink(title: "")
|
|
private let manageSubscriptionLink = PaywallFooterLink(title: "")
|
|
|
private let restoreLink = PaywallFooterLink(title: "")
|
|
private let restoreLink = PaywallFooterLink(title: "")
|
|
@@ -1152,6 +1193,13 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
config: config,
|
|
config: config,
|
|
|
introOffer: store.eligibleIntroOffer(for: selectedPlan)
|
|
introOffer: store.eligibleIntroOffer(for: selectedPlan)
|
|
|
)
|
|
)
|
|
|
|
|
+ updateRenewalDisclosure(
|
|
|
|
|
+ selectedPlan.localizedRenewalDisclosure(
|
|
|
|
|
+ from: store.product(for: selectedPlan),
|
|
|
|
|
+ config: config,
|
|
|
|
|
+ introOffer: store.eligibleIntroOffer(for: selectedPlan)
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
refreshTrustItemsForSelectedPlan()
|
|
refreshTrustItemsForSelectedPlan()
|
|
|
refreshPurchaseState()
|
|
refreshPurchaseState()
|
|
|
}
|
|
}
|
|
@@ -1202,11 +1250,26 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
refreshProductDisplay()
|
|
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() {
|
|
func refreshAppearance() {
|
|
|
layer?.backgroundColor = AppTheme.paywallBackground.cgColor
|
|
layer?.backgroundColor = AppTheme.paywallBackground.cgColor
|
|
|
leftPanelTitle?.refreshThemeLabelColor()
|
|
leftPanelTitle?.refreshThemeLabelColor()
|
|
|
rightTitle?.refreshThemeLabelColor()
|
|
rightTitle?.refreshThemeLabelColor()
|
|
|
rightSubtitle?.refreshThemeLabelColor()
|
|
rightSubtitle?.refreshThemeLabelColor()
|
|
|
|
|
+ renewalDisclosureLabel.textColor = AppTheme.textSecondary
|
|
|
trustStack?.layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor
|
|
trustStack?.layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor
|
|
|
trustStack?.layer?.borderColor = AppTheme.paywallBorder.cgColor
|
|
trustStack?.layer?.borderColor = AppTheme.paywallBorder.cgColor
|
|
|
ctaButton.refreshAppearance()
|
|
ctaButton.refreshAppearance()
|
|
@@ -1335,6 +1398,25 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
ctaButton.action = #selector(purchaseTapped)
|
|
ctaButton.action = #selector(purchaseTapped)
|
|
|
ctaButton.translatesAutoresizingMaskIntoConstraints = false
|
|
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 trustRow = makeTrustRow()
|
|
|
let footerLinks = makeFooterLinks()
|
|
let footerLinks = makeFooterLinks()
|
|
|
|
|
|
|
@@ -1343,6 +1425,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
panel.addSubview(plansStack)
|
|
panel.addSubview(plansStack)
|
|
|
panel.addSubview(trustRow)
|
|
panel.addSubview(trustRow)
|
|
|
panel.addSubview(ctaButton)
|
|
panel.addSubview(ctaButton)
|
|
|
|
|
+ panel.addSubview(renewalDisclosureLabel)
|
|
|
panel.addSubview(footerLinks)
|
|
panel.addSubview(footerLinks)
|
|
|
|
|
|
|
|
NSLayoutConstraint.activate([
|
|
NSLayoutConstraint.activate([
|
|
@@ -1366,10 +1449,14 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
ctaButton.trailingAnchor.constraint(equalTo: plansStack.trailingAnchor),
|
|
ctaButton.trailingAnchor.constraint(equalTo: plansStack.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),
|
|
|
- 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.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: -20),
|
|
|
])
|
|
])
|
|
|
|
|
|
|
@@ -1504,6 +1591,13 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
config: paywallConfig,
|
|
config: paywallConfig,
|
|
|
introOffer: StoreManager.shared.eligibleIntroOffer(for: plan)
|
|
introOffer: StoreManager.shared.eligibleIntroOffer(for: plan)
|
|
|
)
|
|
)
|
|
|
|
|
+ updateRenewalDisclosure(
|
|
|
|
|
+ plan.localizedRenewalDisclosure(
|
|
|
|
|
+ from: StoreManager.shared.product(for: plan),
|
|
|
|
|
+ config: paywallConfig,
|
|
|
|
|
+ introOffer: StoreManager.shared.eligibleIntroOffer(for: plan)
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
refreshTrustItemsForSelectedPlan()
|
|
refreshTrustItemsForSelectedPlan()
|
|
|
refreshPurchaseState()
|
|
refreshPurchaseState()
|
|
|
}
|
|
}
|
|
@@ -1583,6 +1677,7 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
|
|
|
private let blurView = NSVisualEffectView()
|
|
private let blurView = NSVisualEffectView()
|
|
|
private let backdrop = NSView()
|
|
private let backdrop = NSView()
|
|
|
private let pattern = WavePatternView()
|
|
private let pattern = WavePatternView()
|
|
|
|
|
+ private var acceptsHitTesting = false
|
|
|
|
|
|
|
|
init() {
|
|
init() {
|
|
|
paywallView = PaywallView()
|
|
paywallView = PaywallView()
|
|
@@ -1606,6 +1701,11 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
|
|
|
@available(*, unavailable)
|
|
@available(*, unavailable)
|
|
|
required init?(coder: NSCoder) { nil }
|
|
required init?(coder: NSCoder) { nil }
|
|
|
|
|
|
|
|
|
|
+ override func hitTest(_ point: NSPoint) -> NSView? {
|
|
|
|
|
+ guard acceptsHitTesting else { return nil }
|
|
|
|
|
+ return super.hitTest(point)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func setup() {
|
|
private func setup() {
|
|
|
blurView.translatesAutoresizingMaskIntoConstraints = false
|
|
blurView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
blurView.material = .underWindowBackground
|
|
blurView.material = .underWindowBackground
|
|
@@ -1670,6 +1770,7 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
|
|
|
bottomAnchor.constraint(equalTo: parent.bottomAnchor),
|
|
bottomAnchor.constraint(equalTo: parent.bottomAnchor),
|
|
|
])
|
|
])
|
|
|
}
|
|
}
|
|
|
|
|
+ acceptsHitTesting = false
|
|
|
alphaValue = 0
|
|
alphaValue = 0
|
|
|
Task { @MainActor in
|
|
Task { @MainActor in
|
|
|
await StoreManager.shared.ensureEntitlementsResolved()
|
|
await StoreManager.shared.ensureEntitlementsResolved()
|
|
@@ -1679,10 +1780,12 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
|
|
|
await PaywallConfigService.shared.refreshFromRemote()
|
|
await PaywallConfigService.shared.refreshFromRemote()
|
|
|
paywallView.refreshStoreState()
|
|
paywallView.refreshStoreState()
|
|
|
alphaValue = 1
|
|
alphaValue = 1
|
|
|
|
|
+ acceptsHitTesting = true
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func dismiss() {
|
|
func dismiss() {
|
|
|
|
|
+ acceptsHitTesting = false
|
|
|
NSAnimationContext.runAnimationGroup({ context in
|
|
NSAnimationContext.runAnimationGroup({ context in
|
|
|
context.duration = 0.15
|
|
context.duration = 0.15
|
|
|
animator().alphaValue = 0
|
|
animator().alphaValue = 0
|