|
@@ -41,84 +41,62 @@ enum PaywallPlan: CaseIterable {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- var title: String {
|
|
|
|
|
|
|
+ func planCopy(from config: PaywallConfig) -> PaywallConfig.PlanCopy {
|
|
|
switch self {
|
|
switch self {
|
|
|
- case .monthly: "Monthly"
|
|
|
|
|
- case .yearly: "Yearly"
|
|
|
|
|
- case .lifetime: "Lifetime"
|
|
|
|
|
|
|
+ case .monthly: config.plans.monthly
|
|
|
|
|
+ case .yearly: config.plans.yearly
|
|
|
|
|
+ case .lifetime: config.plans.lifetime
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- var subtitle: String {
|
|
|
|
|
- switch self {
|
|
|
|
|
- case .monthly: "$4.99 / month, cancel anytime"
|
|
|
|
|
- case .yearly: "$29.99 / year, cancel anytime"
|
|
|
|
|
- case .lifetime: "$99.99 once, lifetime access"
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ func localizedPrice(from product: Product?, config: PaywallConfig) -> String {
|
|
|
|
|
+ product?.displayPrice ?? config.loadingPrice
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- var price: String {
|
|
|
|
|
- switch self {
|
|
|
|
|
- case .monthly: "$4.99"
|
|
|
|
|
- case .yearly: "$29.99"
|
|
|
|
|
- case .lifetime: "$99.99"
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ func localizedSubtitle(
|
|
|
|
|
+ from product: Product?,
|
|
|
|
|
+ config: PaywallConfig,
|
|
|
|
|
+ introOffer: Product.SubscriptionOffer? = nil
|
|
|
|
|
+ ) -> String {
|
|
|
|
|
+ guard let product else { return config.loadingPrice }
|
|
|
|
|
+ let copy = planCopy(from: config)
|
|
|
|
|
+ let price = product.displayPrice
|
|
|
|
|
|
|
|
- var ctaTitle: String {
|
|
|
|
|
- switch self {
|
|
|
|
|
- case .monthly: "Subscribe for $4.99 / Month"
|
|
|
|
|
- case .yearly: "Subscribe for $29.99 / Year"
|
|
|
|
|
- case .lifetime: "Buy Lifetime Access"
|
|
|
|
|
|
|
+ if self == .yearly, let introOffer, let trialTemplate = copy.trialSubtitleTemplate {
|
|
|
|
|
+ let duration = SubscriptionOfferFormatting.trialDurationDescription(from: introOffer).lowercased()
|
|
|
|
|
+ return trialTemplate
|
|
|
|
|
+ .replacingOccurrences(of: "{trial}", with: duration)
|
|
|
|
|
+ .replacingOccurrences(of: "{price}", with: price)
|
|
|
}
|
|
}
|
|
|
- }
|
|
|
|
|
|
|
|
|
|
- func localizedPrice(from product: Product?) -> String {
|
|
|
|
|
- product?.displayPrice ?? price
|
|
|
|
|
|
|
+ return copy.subtitleTemplate.replacingOccurrences(of: "{price}", with: price)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- func localizedSubtitle(from product: Product?, introOffer: Product.SubscriptionOffer? = nil) -> String {
|
|
|
|
|
- guard let product else { return subtitle }
|
|
|
|
|
|
|
+ func localizedCTATitle(
|
|
|
|
|
+ from product: Product?,
|
|
|
|
|
+ config: PaywallConfig,
|
|
|
|
|
+ introOffer: Product.SubscriptionOffer? = nil
|
|
|
|
|
+ ) -> String {
|
|
|
|
|
+ guard let product else { return config.loadingCTA }
|
|
|
|
|
+ let copy = planCopy(from: config)
|
|
|
|
|
+ let price = product.displayPrice
|
|
|
|
|
|
|
|
- switch self {
|
|
|
|
|
- case .monthly:
|
|
|
|
|
- return "\(product.displayPrice) / month, cancel anytime"
|
|
|
|
|
- case .yearly:
|
|
|
|
|
- if let introOffer {
|
|
|
|
|
- let duration = SubscriptionOfferFormatting.trialDurationDescription(from: introOffer).lowercased()
|
|
|
|
|
- return "Get \(duration) free, then \(product.displayPrice) / year"
|
|
|
|
|
- }
|
|
|
|
|
- return "\(product.displayPrice) / year, cancel anytime"
|
|
|
|
|
- case .lifetime:
|
|
|
|
|
- return "\(product.displayPrice) once, lifetime access"
|
|
|
|
|
|
|
+ if self == .yearly, let introOffer {
|
|
|
|
|
+ return SubscriptionOfferFormatting.freeTrialCTATitle(from: introOffer)
|
|
|
}
|
|
}
|
|
|
- }
|
|
|
|
|
|
|
|
|
|
- func localizedCTATitle(from product: Product?, introOffer: Product.SubscriptionOffer? = nil) -> String {
|
|
|
|
|
- guard let product else { return ctaTitle }
|
|
|
|
|
-
|
|
|
|
|
- switch self {
|
|
|
|
|
- case .monthly:
|
|
|
|
|
- return "Subscribe for \(product.displayPrice) / Month"
|
|
|
|
|
- case .yearly:
|
|
|
|
|
- if let introOffer {
|
|
|
|
|
- return SubscriptionOfferFormatting.freeTrialCTATitle(from: introOffer)
|
|
|
|
|
- }
|
|
|
|
|
- return "Subscribe for \(product.displayPrice) / Year"
|
|
|
|
|
- case .lifetime:
|
|
|
|
|
- return "Buy Lifetime Access for \(product.displayPrice)"
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ return copy.ctaTemplate.replacingOccurrences(of: "{price}", with: price)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// MARK: - StoreKit
|
|
// MARK: - StoreKit
|
|
|
|
|
|
|
|
enum StoreProductID {
|
|
enum StoreProductID {
|
|
|
- static let monthly = "MQL-DEV.smart-printer.premium.monthly"
|
|
|
|
|
- static let yearly = "MQL-DEV.smart-printer.premium.yearly"
|
|
|
|
|
- static let lifetime = "MQL-DEV.smart-printer.premium.lifetime"
|
|
|
|
|
|
|
+ static let monthly = AppStoreConfig.ProductID.monthly
|
|
|
|
|
+ static let yearly = AppStoreConfig.ProductID.yearly
|
|
|
|
|
+ static let lifetime = AppStoreConfig.ProductID.lifetime
|
|
|
|
|
|
|
|
- static let all: Set<String> = [monthly, yearly, lifetime]
|
|
|
|
|
|
|
+ static let all = AppStoreConfig.ProductID.all
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
enum StoreError: LocalizedError {
|
|
enum StoreError: LocalizedError {
|
|
@@ -265,7 +243,8 @@ final class StoreManager {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func showManageSubscriptions() {
|
|
func showManageSubscriptions() {
|
|
|
- guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
|
|
|
|
|
|
|
+ let urlString = PaywallConfigService.shared.config.urls.manageSubscriptions
|
|
|
|
|
+ guard let url = URL(string: urlString) else { return }
|
|
|
NSWorkspace.shared.open(url)
|
|
NSWorkspace.shared.open(url)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -577,6 +556,10 @@ private final class PaywallFeatureRow: NSView, AppearanceRefreshable {
|
|
|
])
|
|
])
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ func updateText(_ text: String) {
|
|
|
|
|
+ label.stringValue = text
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
@available(*, unavailable)
|
|
@available(*, unavailable)
|
|
|
required init?(coder: NSCoder) { nil }
|
|
required init?(coder: NSCoder) { nil }
|
|
|
|
|
|
|
@@ -611,17 +594,18 @@ private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
|
|
|
layer?.cornerRadius = 12
|
|
layer?.cornerRadius = 12
|
|
|
layer?.masksToBounds = false
|
|
layer?.masksToBounds = false
|
|
|
|
|
|
|
|
- titleLabel.stringValue = plan.title
|
|
|
|
|
|
|
+ let config = PaywallConfigService.shared.config
|
|
|
|
|
+ titleLabel.stringValue = plan.planCopy(from: config).title
|
|
|
titleLabel.font = AppTheme.semiboldFont(size: 15)
|
|
titleLabel.font = AppTheme.semiboldFont(size: 15)
|
|
|
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
|
|
- subtitleLabel.stringValue = plan.subtitle
|
|
|
|
|
|
|
+ subtitleLabel.stringValue = config.loadingPrice
|
|
|
subtitleLabel.font = AppTheme.regularFont(size: 11)
|
|
subtitleLabel.font = AppTheme.regularFont(size: 11)
|
|
|
subtitleLabel.themeLabelStyle = .secondary
|
|
subtitleLabel.themeLabelStyle = .secondary
|
|
|
subtitleLabel.textColor = AppTheme.textSecondary
|
|
subtitleLabel.textColor = AppTheme.textSecondary
|
|
|
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
|
|
- priceLabel.stringValue = plan.price
|
|
|
|
|
|
|
+ priceLabel.stringValue = config.loadingPrice
|
|
|
priceLabel.font = AppTheme.semiboldFont(size: 15)
|
|
priceLabel.font = AppTheme.semiboldFont(size: 15)
|
|
|
priceLabel.alignment = .right
|
|
priceLabel.alignment = .right
|
|
|
priceLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
priceLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
@@ -644,9 +628,9 @@ private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
|
|
|
priceLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
priceLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
|
])
|
|
])
|
|
|
|
|
|
|
|
- if plan == .lifetime {
|
|
|
|
|
|
|
+ if plan == .lifetime, let badgeText = plan.planCopy(from: config).badge {
|
|
|
let badge = PaywallBadgeView(
|
|
let badge = PaywallBadgeView(
|
|
|
- text: "Best Value",
|
|
|
|
|
|
|
+ text: badgeText,
|
|
|
iconName: "star.fill",
|
|
iconName: "star.fill",
|
|
|
background: AppTheme.paywallGold,
|
|
background: AppTheme.paywallGold,
|
|
|
foreground: AppTheme.paywallGoldText
|
|
foreground: AppTheme.paywallGoldText
|
|
@@ -670,12 +654,30 @@ private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
|
|
|
@available(*, unavailable)
|
|
@available(*, unavailable)
|
|
|
required init?(coder: NSCoder) { nil }
|
|
required init?(coder: NSCoder) { nil }
|
|
|
|
|
|
|
|
- func updateDisplay(product: Product?, introOffer: Product.SubscriptionOffer? = nil) {
|
|
|
|
|
- subtitleLabel.stringValue = plan.localizedSubtitle(from: product, introOffer: introOffer)
|
|
|
|
|
- priceLabel.stringValue = plan.localizedPrice(from: product)
|
|
|
|
|
|
|
+ func updateDisplay(
|
|
|
|
|
+ product: Product?,
|
|
|
|
|
+ config: PaywallConfig,
|
|
|
|
|
+ introOffer: Product.SubscriptionOffer? = nil
|
|
|
|
|
+ ) {
|
|
|
|
|
+ titleLabel.stringValue = plan.planCopy(from: config).title
|
|
|
|
|
+ subtitleLabel.stringValue = plan.localizedSubtitle(from: product, config: config, introOffer: introOffer)
|
|
|
|
|
+ priceLabel.stringValue = plan.localizedPrice(from: product, config: config)
|
|
|
|
|
+ updateLifetimeBadge(config: config)
|
|
|
updateTrialBadge(introOffer: introOffer)
|
|
updateTrialBadge(introOffer: introOffer)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private func updateLifetimeBadge(config: PaywallConfig) {
|
|
|
|
|
+ guard plan == .lifetime else { return }
|
|
|
|
|
+ if let badgeText = plan.planCopy(from: config).badge {
|
|
|
|
|
+ if let badgeView {
|
|
|
|
|
+ badgeView.updateText(badgeText)
|
|
|
|
|
+ badgeView.isHidden = false
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ badgeView?.isHidden = true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func updateTrialBadge(introOffer: Product.SubscriptionOffer?) {
|
|
private func updateTrialBadge(introOffer: Product.SubscriptionOffer?) {
|
|
|
guard plan == .yearly else { return }
|
|
guard plan == .yearly else { return }
|
|
|
|
|
|
|
@@ -851,6 +853,15 @@ private final class PaywallTrustItemView: NSView, AppearanceRefreshable {
|
|
|
refreshAppearance()
|
|
refreshAppearance()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ func update(iconName: String, title: String, subtitle: String) {
|
|
|
|
|
+ titleLabel.stringValue = title
|
|
|
|
|
+ subtitleLabel.stringValue = subtitle
|
|
|
|
|
+ if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) {
|
|
|
|
|
+ let symbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
|
|
|
|
|
+ icon.image = image.withSymbolConfiguration(symbolConfig)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
@available(*, unavailable)
|
|
@available(*, unavailable)
|
|
|
required init?(coder: NSCoder) { nil }
|
|
required init?(coder: NSCoder) { nil }
|
|
|
|
|
|
|
@@ -973,13 +984,20 @@ 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 continueFreePlanLink = PaywallFooterLink(title: "Continue with free plan")
|
|
|
|
|
- private let manageSubscriptionLink = PaywallFooterLink(title: "Manage Subscription")
|
|
|
|
|
|
|
+ private let continueFreePlanLink = PaywallFooterLink(title: "")
|
|
|
|
|
+ private let manageSubscriptionLink = PaywallFooterLink(title: "")
|
|
|
|
|
+ private let restoreLink = PaywallFooterLink(title: "")
|
|
|
|
|
+ private let privacyLink = PaywallFooterLink(title: "")
|
|
|
|
|
+ private let termsLink = PaywallFooterLink(title: "")
|
|
|
|
|
+ private let supportLink = PaywallFooterLink(title: "")
|
|
|
private let premiumCloseButton = PaywallCloseButton()
|
|
private let premiumCloseButton = PaywallCloseButton()
|
|
|
private var primaryFooterLinkCell: NSView?
|
|
private var primaryFooterLinkCell: NSView?
|
|
|
private var leftPanelTitle: NSTextField!
|
|
private var leftPanelTitle: NSTextField!
|
|
|
private var rightTitle: NSTextField!
|
|
private var rightTitle: NSTextField!
|
|
|
private var rightSubtitle: NSTextField!
|
|
private var rightSubtitle: NSTextField!
|
|
|
|
|
+ private var featuresStack: NSStackView!
|
|
|
|
|
+ private var featureRows: [PaywallFeatureRow] = []
|
|
|
|
|
+ private var trustItemViews: [PaywallTrustItemView] = []
|
|
|
private var trustStack: NSStackView!
|
|
private var trustStack: NSStackView!
|
|
|
private var storeObservers: [NSObjectProtocol] = []
|
|
private var storeObservers: [NSObjectProtocol] = []
|
|
|
|
|
|
|
@@ -990,6 +1008,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
layer?.cornerRadius = 0
|
|
layer?.cornerRadius = 0
|
|
|
setup()
|
|
setup()
|
|
|
observeStoreUpdates()
|
|
observeStoreUpdates()
|
|
|
|
|
+ refreshConfigDisplay()
|
|
|
refreshProductDisplay()
|
|
refreshProductDisplay()
|
|
|
refreshAppearance()
|
|
refreshAppearance()
|
|
|
}
|
|
}
|
|
@@ -1010,20 +1029,79 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
center.addObserver(forName: .premiumStatusDidChange, object: nil, queue: .main) { [weak self] _ in
|
|
center.addObserver(forName: .premiumStatusDidChange, object: nil, queue: .main) { [weak self] _ in
|
|
|
self?.refreshProductDisplay()
|
|
self?.refreshProductDisplay()
|
|
|
},
|
|
},
|
|
|
|
|
+ center.addObserver(forName: .paywallConfigDidUpdate, object: nil, queue: .main) { [weak self] _ in
|
|
|
|
|
+ self?.refreshConfigDisplay()
|
|
|
|
|
+ self?.refreshProductDisplay()
|
|
|
|
|
+ },
|
|
|
]
|
|
]
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private var paywallConfig: PaywallConfig {
|
|
|
|
|
+ PaywallConfigService.shared.config
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func refreshConfigDisplay() {
|
|
|
|
|
+ let config = paywallConfig
|
|
|
|
|
+
|
|
|
|
|
+ leftPanelTitle?.stringValue = config.leftPanelTitle
|
|
|
|
|
+ rightTitle?.stringValue = config.rightTitle
|
|
|
|
|
+ rightSubtitle?.stringValue = config.rightSubtitle
|
|
|
|
|
+
|
|
|
|
|
+ syncFeatureRows(with: config.features)
|
|
|
|
|
+ syncTrustItems(with: config.trustItems)
|
|
|
|
|
+
|
|
|
|
|
+ continueFreePlanLink.updateTitle(config.footer.continueFree)
|
|
|
|
|
+ manageSubscriptionLink.updateTitle(config.footer.manageSubscription)
|
|
|
|
|
+ restoreLink.updateTitle(config.footer.restorePurchase)
|
|
|
|
|
+ privacyLink.updateTitle(config.footer.privacyPolicy)
|
|
|
|
|
+ termsLink.updateTitle(config.footer.termsOfService)
|
|
|
|
|
+ supportLink.updateTitle(config.footer.support)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func syncFeatureRows(with features: [String]) {
|
|
|
|
|
+ guard let featuresStack else { return }
|
|
|
|
|
+
|
|
|
|
|
+ while featureRows.count < features.count {
|
|
|
|
|
+ let row = PaywallFeatureRow(text: "")
|
|
|
|
|
+ featureRows.append(row)
|
|
|
|
|
+ featuresStack.addArrangedSubview(row)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ while featureRows.count > features.count {
|
|
|
|
|
+ let row = featureRows.removeLast()
|
|
|
|
|
+ featuresStack.removeArrangedSubview(row)
|
|
|
|
|
+ row.removeFromSuperview()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for (index, text) in features.enumerated() {
|
|
|
|
|
+ featureRows[index].updateText(text)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func syncTrustItems(with items: [PaywallConfig.TrustItem]) {
|
|
|
|
|
+ for (index, item) in items.enumerated() where index < trustItemViews.count {
|
|
|
|
|
+ trustItemViews[index].update(
|
|
|
|
|
+ iconName: item.icon,
|
|
|
|
|
+ title: item.title,
|
|
|
|
|
+ subtitle: item.subtitle
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func refreshProductDisplay() {
|
|
private func refreshProductDisplay() {
|
|
|
let store = StoreManager.shared
|
|
let store = StoreManager.shared
|
|
|
|
|
+ let config = paywallConfig
|
|
|
for (plan, card) in planCards {
|
|
for (plan, card) in planCards {
|
|
|
let product = store.product(for: plan)
|
|
let product = store.product(for: plan)
|
|
|
card.updateDisplay(
|
|
card.updateDisplay(
|
|
|
product: product,
|
|
product: product,
|
|
|
|
|
+ config: config,
|
|
|
introOffer: store.eligibleIntroOffer(for: plan)
|
|
introOffer: store.eligibleIntroOffer(for: plan)
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
ctaButton.title = selectedPlan.localizedCTATitle(
|
|
ctaButton.title = selectedPlan.localizedCTATitle(
|
|
|
from: store.product(for: selectedPlan),
|
|
from: store.product(for: selectedPlan),
|
|
|
|
|
+ config: config,
|
|
|
introOffer: store.eligibleIntroOffer(for: selectedPlan)
|
|
introOffer: store.eligibleIntroOffer(for: selectedPlan)
|
|
|
)
|
|
)
|
|
|
refreshPurchaseState()
|
|
refreshPurchaseState()
|
|
@@ -1097,9 +1175,10 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
private func makeLeftPanel() -> NSView {
|
|
private func makeLeftPanel() -> NSView {
|
|
|
let panel = PaywallLeftPanelView()
|
|
let panel = PaywallLeftPanelView()
|
|
|
panel.translatesAutoresizingMaskIntoConstraints = false
|
|
panel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ let config = paywallConfig
|
|
|
|
|
|
|
|
let title = NSTextField.themeLabel(
|
|
let title = NSTextField.themeLabel(
|
|
|
- "Unlock Your Full\nPrinting Potential",
|
|
|
|
|
|
|
+ config.leftPanelTitle,
|
|
|
style: .primary,
|
|
style: .primary,
|
|
|
font: AppTheme.semiboldFont(size: 22)
|
|
font: AppTheme.semiboldFont(size: 22)
|
|
|
)
|
|
)
|
|
@@ -1112,17 +1191,12 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
featuresStack.spacing = 6
|
|
featuresStack.spacing = 6
|
|
|
featuresStack.alignment = .leading
|
|
featuresStack.alignment = .leading
|
|
|
featuresStack.translatesAutoresizingMaskIntoConstraints = false
|
|
featuresStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ self.featuresStack = featuresStack
|
|
|
|
|
|
|
|
- let features = [
|
|
|
|
|
- "Unlimited high-quality scans",
|
|
|
|
|
- "Advanced OCR technology",
|
|
|
|
|
- "Draw & print canvas",
|
|
|
|
|
- "Ad-free experience",
|
|
|
|
|
- "Priority support",
|
|
|
|
|
- "Secure storage",
|
|
|
|
|
- ]
|
|
|
|
|
- for feature in features {
|
|
|
|
|
- featuresStack.addArrangedSubview(PaywallFeatureRow(text: feature))
|
|
|
|
|
|
|
+ for feature in config.features {
|
|
|
|
|
+ let row = PaywallFeatureRow(text: feature)
|
|
|
|
|
+ featureRows.append(row)
|
|
|
|
|
+ featuresStack.addArrangedSubview(row)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
panel.addSubview(title)
|
|
panel.addSubview(title)
|
|
@@ -1144,14 +1218,19 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
private func makeRightPanel() -> NSView {
|
|
private func makeRightPanel() -> NSView {
|
|
|
let panel = NSView()
|
|
let panel = NSView()
|
|
|
panel.translatesAutoresizingMaskIntoConstraints = false
|
|
panel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ let config = paywallConfig
|
|
|
|
|
|
|
|
- let title = NSTextField.themeLabel("Go Premium", style: .primary, font: AppTheme.semiboldFont(size: 26))
|
|
|
|
|
|
|
+ let title = NSTextField.themeLabel(
|
|
|
|
|
+ config.rightTitle,
|
|
|
|
|
+ style: .primary,
|
|
|
|
|
+ font: AppTheme.semiboldFont(size: 26)
|
|
|
|
|
+ )
|
|
|
title.alignment = .center
|
|
title.alignment = .center
|
|
|
title.translatesAutoresizingMaskIntoConstraints = false
|
|
title.translatesAutoresizingMaskIntoConstraints = false
|
|
|
rightTitle = title
|
|
rightTitle = title
|
|
|
|
|
|
|
|
let subtitle = NSTextField.themeLabel(
|
|
let subtitle = NSTextField.themeLabel(
|
|
|
- "Experience professional quality printing and scanning without limits.",
|
|
|
|
|
|
|
+ config.rightSubtitle,
|
|
|
style: .secondary,
|
|
style: .secondary,
|
|
|
font: AppTheme.regularFont(size: 13)
|
|
font: AppTheme.regularFont(size: 13)
|
|
|
)
|
|
)
|
|
@@ -1175,6 +1254,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
|
|
|
|
|
ctaButton.title = selectedPlan.localizedCTATitle(
|
|
ctaButton.title = selectedPlan.localizedCTATitle(
|
|
|
from: StoreManager.shared.product(for: selectedPlan),
|
|
from: StoreManager.shared.product(for: selectedPlan),
|
|
|
|
|
+ config: config,
|
|
|
introOffer: StoreManager.shared.eligibleIntroOffer(for: selectedPlan)
|
|
introOffer: StoreManager.shared.eligibleIntroOffer(for: selectedPlan)
|
|
|
)
|
|
)
|
|
|
ctaButton.target = self
|
|
ctaButton.target = self
|
|
@@ -1223,28 +1303,16 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func makeTrustRow() -> NSView {
|
|
private func makeTrustRow() -> NSView {
|
|
|
- let securePayments = PaywallTrustItemView(
|
|
|
|
|
- iconName: "shield.fill",
|
|
|
|
|
- title: "Secure Payments",
|
|
|
|
|
- subtitle: "Your payment is 100% secure"
|
|
|
|
|
- )
|
|
|
|
|
- let cancelAnytime = PaywallTrustItemView(
|
|
|
|
|
- iconName: "arrow.counterclockwise",
|
|
|
|
|
- title: "Cancel Anytime",
|
|
|
|
|
- subtitle: "No commitment, cancel anytime."
|
|
|
|
|
- )
|
|
|
|
|
- let support = PaywallTrustItemView(
|
|
|
|
|
- iconName: "headphones",
|
|
|
|
|
- title: "24/7 Support",
|
|
|
|
|
- subtitle: "We're here to help you anytime."
|
|
|
|
|
- )
|
|
|
|
|
- let privacyFirst = PaywallTrustItemView(
|
|
|
|
|
- iconName: "lock.fill",
|
|
|
|
|
- title: "Privacy First",
|
|
|
|
|
- subtitle: "Your data is safe with us."
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ let config = paywallConfig
|
|
|
|
|
+ trustItemViews = config.trustItems.map { item in
|
|
|
|
|
+ PaywallTrustItemView(
|
|
|
|
|
+ iconName: item.icon,
|
|
|
|
|
+ title: item.title,
|
|
|
|
|
+ subtitle: item.subtitle
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- let trustStack = NSStackView(views: [securePayments, cancelAnytime, support, privacyFirst])
|
|
|
|
|
|
|
+ let trustStack = NSStackView(views: trustItemViews)
|
|
|
trustStack.orientation = .horizontal
|
|
trustStack.orientation = .horizontal
|
|
|
trustStack.distribution = .fillEqually
|
|
trustStack.distribution = .fillEqually
|
|
|
trustStack.spacing = 16
|
|
trustStack.spacing = 16
|
|
@@ -1264,9 +1332,13 @@ 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
|
|
|
|
|
+ let config = paywallConfig
|
|
|
|
|
|
|
|
|
|
+ continueFreePlanLink.updateTitle(config.footer.continueFree)
|
|
|
continueFreePlanLink.target = self
|
|
continueFreePlanLink.target = self
|
|
|
continueFreePlanLink.action = #selector(continueWithFreePlanTapped)
|
|
continueFreePlanLink.action = #selector(continueWithFreePlanTapped)
|
|
|
|
|
+
|
|
|
|
|
+ manageSubscriptionLink.updateTitle(config.footer.manageSubscription)
|
|
|
manageSubscriptionLink.target = self
|
|
manageSubscriptionLink.target = self
|
|
|
manageSubscriptionLink.action = #selector(manageSubscriptionTapped)
|
|
manageSubscriptionLink.action = #selector(manageSubscriptionTapped)
|
|
|
|
|
|
|
@@ -1274,19 +1346,19 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
primaryFooterLinkCell = primaryCell
|
|
primaryFooterLinkCell = primaryCell
|
|
|
refreshPrimaryFooterLink()
|
|
refreshPrimaryFooterLink()
|
|
|
|
|
|
|
|
- let restoreLink = PaywallFooterLink(title: "Restore Purchase")
|
|
|
|
|
|
|
+ restoreLink.updateTitle(config.footer.restorePurchase)
|
|
|
restoreLink.target = self
|
|
restoreLink.target = self
|
|
|
restoreLink.action = #selector(restoreTapped)
|
|
restoreLink.action = #selector(restoreTapped)
|
|
|
|
|
|
|
|
- let privacyLink = PaywallFooterLink(title: "Privacy Policy")
|
|
|
|
|
|
|
+ privacyLink.updateTitle(config.footer.privacyPolicy)
|
|
|
privacyLink.target = self
|
|
privacyLink.target = self
|
|
|
privacyLink.action = #selector(privacyPolicyTapped)
|
|
privacyLink.action = #selector(privacyPolicyTapped)
|
|
|
|
|
|
|
|
- let termsLink = PaywallFooterLink(title: "Terms of Service")
|
|
|
|
|
|
|
+ termsLink.updateTitle(config.footer.termsOfService)
|
|
|
termsLink.target = self
|
|
termsLink.target = self
|
|
|
termsLink.action = #selector(termsOfServiceTapped)
|
|
termsLink.action = #selector(termsOfServiceTapped)
|
|
|
|
|
|
|
|
- let supportLink = PaywallFooterLink(title: "Support")
|
|
|
|
|
|
|
+ supportLink.updateTitle(config.footer.support)
|
|
|
supportLink.target = self
|
|
supportLink.target = self
|
|
|
supportLink.action = #selector(supportTapped)
|
|
supportLink.action = #selector(supportTapped)
|
|
|
|
|
|
|
@@ -1355,6 +1427,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
}
|
|
}
|
|
|
ctaButton.title = plan.localizedCTATitle(
|
|
ctaButton.title = plan.localizedCTATitle(
|
|
|
from: StoreManager.shared.product(for: plan),
|
|
from: StoreManager.shared.product(for: plan),
|
|
|
|
|
+ config: paywallConfig,
|
|
|
introOffer: StoreManager.shared.eligibleIntroOffer(for: plan)
|
|
introOffer: StoreManager.shared.eligibleIntroOffer(for: plan)
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
@@ -1411,15 +1484,15 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@objc private func privacyPolicyTapped() {
|
|
@objc private func privacyPolicyTapped() {
|
|
|
- openExternalLink("https://sites.google.com/view/smartprinterappmacos/privacy-policy")
|
|
|
|
|
|
|
+ openExternalLink(paywallConfig.urls.privacy)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@objc private func termsOfServiceTapped() {
|
|
@objc private func termsOfServiceTapped() {
|
|
|
- openExternalLink("https://sites.google.com/view/smartprinterappmacos/terms-and-condition")
|
|
|
|
|
|
|
+ openExternalLink(paywallConfig.urls.terms)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@objc private func supportTapped() {
|
|
@objc private func supportTapped() {
|
|
|
- openExternalLink("https://sites.google.com/view/smartprinterappmacos/get-support")
|
|
|
|
|
|
|
+ openExternalLink(paywallConfig.urls.support)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func openExternalLink(_ urlString: String) {
|
|
private func openExternalLink(_ urlString: String) {
|
|
@@ -1535,6 +1608,7 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
|
|
|
await StoreManager.shared.loadProducts()
|
|
await StoreManager.shared.loadProducts()
|
|
|
}
|
|
}
|
|
|
await StoreManager.shared.refreshPremiumStatus()
|
|
await StoreManager.shared.refreshPremiumStatus()
|
|
|
|
|
+ await PaywallConfigService.shared.refreshFromRemote()
|
|
|
paywallView.refreshStoreState()
|
|
paywallView.refreshStoreState()
|
|
|
alphaValue = 1
|
|
alphaValue = 1
|
|
|
}
|
|
}
|