| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322 |
- import Cocoa
- import StoreKit
- // MARK: - Plan Model
- enum PaywallPlan: CaseIterable {
- case monthly
- case yearly
- case lifetime
- var productID: String {
- switch self {
- case .monthly: StoreProductID.monthly
- case .yearly: StoreProductID.yearly
- case .lifetime: StoreProductID.lifetime
- }
- }
- var title: String {
- switch self {
- case .monthly: "Monthly"
- case .yearly: "Yearly"
- case .lifetime: "Lifetime"
- }
- }
- var subtitle: String {
- switch self {
- case .monthly: "$4.99 / month, cancel anytime"
- case .yearly: "Eligible new subscribers get 7 days free, then $29.99 / year"
- case .lifetime: "$99.99 once, lifetime access"
- }
- }
- var price: String {
- switch self {
- case .monthly: "$4.99"
- case .yearly: "$29.99"
- case .lifetime: "$99.99"
- }
- }
- var ctaTitle: String {
- switch self {
- case .monthly: "Subscribe for $4.99 / Month"
- case .yearly: "Start 7-Day Free Trial"
- case .lifetime: "Buy Lifetime Access"
- }
- }
- func localizedPrice(from product: Product?) -> String {
- product?.displayPrice ?? price
- }
- func localizedSubtitle(from product: Product?) -> String {
- guard let product else { return subtitle }
- switch self {
- case .monthly:
- return "\(product.displayPrice) / month, cancel anytime"
- case .yearly:
- if product.subscription?.introductoryOffer != nil {
- return "Eligible new subscribers get 7 days free, then \(product.displayPrice) / year"
- }
- return "\(product.displayPrice) / year, cancel anytime"
- case .lifetime:
- return "\(product.displayPrice) once, lifetime access"
- }
- }
- func localizedCTATitle(from product: Product?) -> String {
- guard let product else { return ctaTitle }
- switch self {
- case .monthly:
- return "Subscribe for \(product.displayPrice) / Month"
- case .yearly:
- if product.subscription?.introductoryOffer != nil {
- return "Start 7-Day Free Trial"
- }
- return "Subscribe for \(product.displayPrice) / Year"
- case .lifetime:
- return "Buy Lifetime Access for \(product.displayPrice)"
- }
- }
- }
- // MARK: - StoreKit
- 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 all: Set<String> = [monthly, yearly, lifetime]
- }
- enum StoreError: LocalizedError {
- case productNotFound
- case failedVerification
- var errorDescription: String? {
- switch self {
- case .productNotFound:
- "The selected plan is not available right now. Please try again later."
- case .failedVerification:
- "We couldn't verify your purchase. Please contact support."
- }
- }
- }
- @MainActor
- final class StoreManager {
- static let shared = StoreManager()
- private(set) var products: [Product] = []
- private(set) var isPremium = false
- var isPro: Bool { isPremium }
- private(set) var isLoadingProducts = false
- private(set) var isPurchasing = false
- private var transactionListener: Task<Void, Never>?
- private var hasStarted = false
- private init() {}
- func start() {
- guard !hasStarted else { return }
- hasStarted = true
- transactionListener = Task { [weak self] in
- for await update in Transaction.updates {
- await self?.handleTransactionUpdate(update)
- }
- }
- Task {
- await loadProducts()
- await refreshPremiumStatus()
- }
- }
- func product(for plan: PaywallPlan) -> Product? {
- products.first { $0.id == plan.productID }
- }
- func loadProducts() async {
- isLoadingProducts = true
- postStoreStateDidChange()
- defer {
- isLoadingProducts = false
- postStoreStateDidChange()
- }
- do {
- products = try await Product.products(for: StoreProductID.all)
- .sorted { lhs, rhs in
- productSortOrder(for: lhs.id) < productSortOrder(for: rhs.id)
- }
- NotificationCenter.default.post(name: .storeProductsDidUpdate, object: nil)
- } catch {
- NSLog("Failed to load products: \(error.localizedDescription)")
- }
- }
- @discardableResult
- func purchase(plan: PaywallPlan) async throws -> Bool {
- if products.isEmpty {
- await loadProducts()
- }
- guard let product = product(for: plan) else {
- throw StoreError.productNotFound
- }
- isPurchasing = true
- postStoreStateDidChange()
- defer {
- isPurchasing = false
- postStoreStateDidChange()
- }
- let result = try await product.purchase()
- switch result {
- case .success(let verification):
- let transaction = try checkVerified(verification)
- await transaction.finish()
- await refreshPremiumStatus()
- return isPremium
- case .userCancelled, .pending:
- return false
- @unknown default:
- return false
- }
- }
- @discardableResult
- func restorePurchases() async throws -> Bool {
- isPurchasing = true
- postStoreStateDidChange()
- defer {
- isPurchasing = false
- postStoreStateDidChange()
- }
- try await AppStore.sync()
- await refreshPremiumStatus()
- return isPremium
- }
- func showAlert(title: String, message: String, on window: NSWindow?) {
- let alert = NSAlert()
- alert.messageText = title
- alert.informativeText = message
- alert.alertStyle = .informational
- alert.addButton(withTitle: "OK")
- if let window {
- alert.beginSheetModal(for: window)
- } else {
- alert.runModal()
- }
- }
- func showManageSubscriptions() {
- guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
- NSWorkspace.shared.open(url)
- }
- func showPurchaseError(_ error: Error, on window: NSWindow?) {
- if let storeError = error as? StoreError {
- showAlert(title: "Purchase Failed", message: storeError.localizedDescription, on: window)
- return
- }
- if let storeKitError = error as? StoreKitError, case .userCancelled = storeKitError {
- return
- }
- showAlert(title: "Purchase Failed", message: error.localizedDescription, on: window)
- }
- private func handleTransactionUpdate(_ update: VerificationResult<Transaction>) async {
- do {
- let transaction = try checkVerified(update)
- await transaction.finish()
- await refreshPremiumStatus()
- } catch {
- NSLog("Transaction verification failed: \(error.localizedDescription)")
- }
- }
- func refreshPremiumStatus() async {
- let hasPremium = await hasActivePremiumAccess()
- let didChange = hasPremium != isPremium
- isPremium = hasPremium
- if didChange {
- NotificationCenter.default.post(name: .premiumStatusDidChange, object: nil)
- }
- postStoreStateDidChange()
- }
- private func hasActivePremiumAccess() async -> Bool {
- if await hasEntitlementFromCurrentEntitlements() {
- return true
- }
- if await hasEntitlementFromLatestTransactions() {
- return true
- }
- if await hasEntitlementFromSubscriptionStatus() {
- return true
- }
- return false
- }
- private func hasEntitlementFromCurrentEntitlements() async -> Bool {
- for await result in Transaction.currentEntitlements {
- guard let transaction = try? checkVerified(result) else { continue }
- if isActivePremiumTransaction(transaction) {
- return true
- }
- }
- return false
- }
- private func hasEntitlementFromLatestTransactions() async -> Bool {
- for productID in StoreProductID.all {
- guard let result = await Transaction.latest(for: productID),
- let transaction = try? checkVerified(result) else { continue }
- if isActivePremiumTransaction(transaction) {
- return true
- }
- }
- return false
- }
- private func hasEntitlementFromSubscriptionStatus() async -> Bool {
- for product in products where product.subscription != nil {
- guard StoreProductID.all.contains(product.id) else { continue }
- guard let statuses = try? await product.subscription?.status else { continue }
- for status in statuses {
- switch status.state {
- case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
- return true
- default:
- continue
- }
- }
- }
- return false
- }
- private func isActivePremiumTransaction(_ transaction: Transaction) -> Bool {
- guard StoreProductID.all.contains(transaction.productID) else { return false }
- guard transaction.revocationDate == nil else { return false }
- if let expirationDate = transaction.expirationDate {
- return expirationDate > Date()
- }
- return true
- }
- private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
- switch result {
- case .unverified:
- throw StoreError.failedVerification
- case .verified(let safe):
- return safe
- }
- }
- private func productSortOrder(for productID: String) -> Int {
- switch productID {
- case StoreProductID.monthly: 0
- case StoreProductID.yearly: 1
- case StoreProductID.lifetime: 2
- default: 99
- }
- }
- private func postStoreStateDidChange() {
- NotificationCenter.default.post(name: .storeStateDidChange, object: nil)
- }
- }
- extension Notification.Name {
- static let premiumStatusDidChange = Notification.Name("premiumStatusDidChange")
- static let storeProductsDidUpdate = Notification.Name("storeProductsDidUpdate")
- static let storeStateDidChange = Notification.Name("storeStateDidChange")
- }
- // MARK: - Left Panel
- private final class PaywallLeftPanelView: NSView, AppearanceRefreshable {
- private let gradientLayer = CAGradientLayer()
- override init(frame frameRect: NSRect) {
- super.init(frame: frameRect)
- wantsLayer = true
- gradientLayer.startPoint = CGPoint(x: 0.5, y: 1)
- gradientLayer.endPoint = CGPoint(x: 0.5, y: 0)
- layer?.insertSublayer(gradientLayer, at: 0)
- refreshAppearance()
- }
- func refreshAppearance() {
- gradientLayer.colors = AppTheme.paywallLeftGradientColors.map(\.cgColor)
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- override func layout() {
- super.layout()
- gradientLayer.frame = bounds
- let mask = CAShapeLayer()
- mask.path = CGPath(
- roundedRect: bounds,
- cornerWidth: 20,
- cornerHeight: 20,
- transform: nil
- )
- layer?.mask = mask
- }
- }
- // MARK: - Badge
- private final class PaywallBadgeView: NSView {
- init(text: String, iconName: String, background: NSColor, foreground: NSColor) {
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- wantsLayer = true
- layer?.backgroundColor = background.cgColor
- layer?.cornerRadius = 10
- layer?.masksToBounds = true
- let icon = NSImageView()
- icon.translatesAutoresizingMaskIntoConstraints = false
- if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) {
- let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .semibold)
- icon.image = image.withSymbolConfiguration(config)
- }
- icon.contentTintColor = foreground
- let label = NSTextField(labelWithString: text)
- label.font = AppTheme.semiboldFont(size: 10)
- label.textColor = foreground
- label.translatesAutoresizingMaskIntoConstraints = false
- addSubview(icon)
- addSubview(label)
- NSLayoutConstraint.activate([
- heightAnchor.constraint(equalToConstant: 20),
- icon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
- icon.centerYAnchor.constraint(equalTo: centerYAnchor),
- icon.widthAnchor.constraint(equalToConstant: 12),
- icon.heightAnchor.constraint(equalToConstant: 12),
- label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 4),
- label.centerYAnchor.constraint(equalTo: centerYAnchor),
- label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
- ])
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- }
- // MARK: - Feature Row
- private final class PaywallFeatureRow: NSView, AppearanceRefreshable {
- private let label: NSTextField
- init(text: String) {
- label = NSTextField.themeLabel(text, style: .primary, font: AppTheme.regularFont(size: 14))
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- let checkContainer = NSView()
- checkContainer.translatesAutoresizingMaskIntoConstraints = false
- checkContainer.wantsLayer = true
- checkContainer.layer?.backgroundColor = AppTheme.green.cgColor
- checkContainer.layer?.cornerRadius = 10
- checkContainer.layer?.masksToBounds = true
- let checkIcon = NSImageView()
- checkIcon.translatesAutoresizingMaskIntoConstraints = false
- if let image = NSImage(systemSymbolName: "checkmark", accessibilityDescription: nil) {
- let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .bold)
- checkIcon.image = image.withSymbolConfiguration(config)
- }
- checkIcon.contentTintColor = .white
- label.translatesAutoresizingMaskIntoConstraints = false
- addSubview(checkContainer)
- checkContainer.addSubview(checkIcon)
- addSubview(label)
- NSLayoutConstraint.activate([
- heightAnchor.constraint(equalToConstant: 28),
- checkContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
- checkContainer.centerYAnchor.constraint(equalTo: centerYAnchor),
- checkContainer.widthAnchor.constraint(equalToConstant: 20),
- checkContainer.heightAnchor.constraint(equalToConstant: 20),
- checkIcon.centerXAnchor.constraint(equalTo: checkContainer.centerXAnchor),
- checkIcon.centerYAnchor.constraint(equalTo: checkContainer.centerYAnchor),
- checkIcon.widthAnchor.constraint(equalToConstant: 12),
- checkIcon.heightAnchor.constraint(equalToConstant: 12),
- label.leadingAnchor.constraint(equalTo: checkContainer.trailingAnchor, constant: 12),
- label.centerYAnchor.constraint(equalTo: centerYAnchor),
- label.trailingAnchor.constraint(equalTo: trailingAnchor),
- ])
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- label.refreshThemeLabelColor()
- }
- }
- // MARK: - Plan Card
- private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
- var onSelect: (() -> Void)?
- private let plan: PaywallPlan
- private let titleLabel = NSTextField(labelWithString: "")
- private let subtitleLabel = NSTextField(labelWithString: "")
- private let priceLabel = NSTextField(labelWithString: "")
- private var badgeView: PaywallBadgeView?
- private var hoverTracker: HoverTracker?
- private var isHovered = false
- var isChosen: Bool = false {
- didSet { updateAppearance() }
- }
- init(plan: PaywallPlan) {
- self.plan = plan
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- wantsLayer = true
- layer?.cornerRadius = 12
- layer?.masksToBounds = false
- titleLabel.stringValue = plan.title
- titleLabel.font = AppTheme.semiboldFont(size: 15)
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- subtitleLabel.stringValue = plan.subtitle
- subtitleLabel.font = AppTheme.regularFont(size: 11)
- subtitleLabel.themeLabelStyle = .secondary
- subtitleLabel.textColor = AppTheme.textSecondary
- subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
- priceLabel.stringValue = plan.price
- priceLabel.font = AppTheme.semiboldFont(size: 15)
- priceLabel.alignment = .right
- priceLabel.translatesAutoresizingMaskIntoConstraints = false
- addSubview(titleLabel)
- addSubview(subtitleLabel)
- addSubview(priceLabel)
- NSLayoutConstraint.activate([
- heightAnchor.constraint(equalToConstant: 86),
- titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
- titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 24),
- subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
- subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 3),
- subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: priceLabel.leadingAnchor, constant: -12),
- priceLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
- priceLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
- ])
- if plan == .yearly {
- let badge = PaywallBadgeView(
- text: "7 Days Free Trial",
- iconName: "calendar",
- background: AppTheme.paywallPink,
- foreground: AppTheme.paywallPinkText
- )
- badgeView = badge
- addSubview(badge)
- NSLayoutConstraint.activate([
- badge.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
- badge.topAnchor.constraint(equalTo: topAnchor, constant: 8),
- ])
- } else if plan == .lifetime {
- let badge = PaywallBadgeView(
- text: "Best Value",
- iconName: "star.fill",
- background: AppTheme.paywallGold,
- foreground: AppTheme.paywallGoldText
- )
- badgeView = badge
- addSubview(badge)
- NSLayoutConstraint.activate([
- badge.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
- badge.topAnchor.constraint(equalTo: topAnchor, constant: 8),
- ])
- }
- applyCardShadow()
- updateAppearance()
- hoverTracker = HoverTracker(view: self) { [weak self] hovering in
- self?.setHovered(hovering)
- }
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func updateDisplay(product: Product?) {
- subtitleLabel.stringValue = plan.localizedSubtitle(from: product)
- priceLabel.stringValue = plan.localizedPrice(from: product)
- }
- func refreshAppearance() {
- updateAppearance()
- subtitleLabel.refreshThemeLabelColor()
- if isHovered {
- applyHoverLift(true)
- }
- }
- private func setHovered(_ hovering: Bool) {
- isHovered = hovering
- applyHoverLift(hovering)
- updateAppearance()
- }
- private func updateAppearance() {
- let titleColor = isChosen && plan == .yearly ? AppTheme.green : AppTheme.paywallAccent
- titleLabel.textColor = titleColor
- priceLabel.textColor = titleColor
- layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor
- if isChosen {
- layer?.borderWidth = 2
- layer?.borderColor = AppTheme.green.cgColor
- } else if isHovered {
- layer?.borderWidth = 2
- let hoverBorder = AppTheme.paywallBorder.blended(withFraction: 0.35, of: AppTheme.paywallAccent)
- ?? AppTheme.paywallBorder
- layer?.borderColor = hoverBorder.cgColor
- } else {
- layer?.borderWidth = 1.5
- layer?.borderColor = AppTheme.paywallBorder.cgColor
- }
- }
- override func mouseUp(with event: NSEvent) {
- guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
- onSelect?()
- }
- override func resetCursorRects() {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
- // MARK: - Footer Link
- private final class PaywallFooterLink: NSButton, AppearanceRefreshable {
- private var hoverTracker: HoverTracker?
- private var isHovered = false
- init(title: String) {
- super.init(frame: .zero)
- updateTitle(title)
- isBordered = false
- bezelStyle = .inline
- font = AppTheme.regularFont(size: 11)
- translatesAutoresizingMaskIntoConstraints = false
- refreshAppearance()
- hoverTracker = HoverTracker(view: self) { [weak self] hovering in
- self?.isHovered = hovering
- self?.refreshAppearance()
- }
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func updateTitle(_ title: String) {
- self.title = title
- invalidateIntrinsicContentSize()
- needsDisplay = true
- }
- func refreshAppearance() {
- contentTintColor = isHovered ? AppTheme.textPrimary : AppTheme.textSecondary
- }
- override func resetCursorRects() {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
- // MARK: - Footer Trust Item
- private final class PaywallTrustItemView: NSView, AppearanceRefreshable {
- private let iconContainer = NSView()
- private let icon = NSImageView()
- private let titleLabel: NSTextField
- private let subtitleLabel: NSTextField
- init(iconName: String, title: String, subtitle: String) {
- titleLabel = NSTextField.themeLabel(title, style: .primary, font: AppTheme.semiboldFont(size: 11))
- subtitleLabel = NSTextField.themeLabel(subtitle, style: .secondary, font: AppTheme.regularFont(size: 9))
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- iconContainer.translatesAutoresizingMaskIntoConstraints = false
- iconContainer.wantsLayer = true
- iconContainer.layer?.cornerRadius = 10
- iconContainer.layer?.masksToBounds = true
- icon.translatesAutoresizingMaskIntoConstraints = false
- if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) {
- let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
- icon.image = image.withSymbolConfiguration(config)
- }
- titleLabel.lineBreakMode = .byTruncatingTail
- titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- subtitleLabel.lineBreakMode = .byTruncatingTail
- subtitleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
- addSubview(iconContainer)
- iconContainer.addSubview(icon)
- addSubview(titleLabel)
- addSubview(subtitleLabel)
- NSLayoutConstraint.activate([
- iconContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
- iconContainer.topAnchor.constraint(equalTo: topAnchor),
- iconContainer.widthAnchor.constraint(equalToConstant: 20),
- iconContainer.heightAnchor.constraint(equalToConstant: 20),
- icon.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
- icon.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
- icon.widthAnchor.constraint(equalToConstant: 12),
- icon.heightAnchor.constraint(equalToConstant: 12),
- titleLabel.leadingAnchor.constraint(equalTo: iconContainer.trailingAnchor, constant: 8),
- titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 1),
- titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
- subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
- subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 2),
- subtitleLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
- subtitleLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
- ])
- setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- setContentHuggingPriority(.defaultLow, for: .horizontal)
- refreshAppearance()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- iconContainer.layer?.backgroundColor = AppTheme.paywallTrustIconBackground.cgColor
- icon.contentTintColor = AppTheme.paywallIconAccent
- titleLabel.refreshThemeLabelColor()
- subtitleLabel.refreshThemeLabelColor()
- }
- }
- // MARK: - CTA Button
- private final class PaywallCTAButton: NSButton, AppearanceRefreshable {
- private var hoverTracker: HoverTracker?
- init() {
- super.init(frame: .zero)
- isBordered = false
- wantsLayer = true
- layer?.cornerRadius = 12
- font = AppTheme.semiboldFont(size: 15)
- translatesAutoresizingMaskIntoConstraints = false
- refreshAppearance()
- hoverTracker = HoverTracker(view: self) { [weak self] hovering in
- self?.setHovered(hovering)
- }
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- layer?.backgroundColor = AppTheme.paywallCTABackground.cgColor
- contentTintColor = AppTheme.paywallCTAForeground
- }
- private func setHovered(_ hovering: Bool) {
- let base = AppTheme.paywallCTABackground
- let color = hovering ? base.blended(withFraction: 0.12, of: .black) ?? base : base
- animateHover {
- layer?.backgroundColor = color.cgColor
- layer?.transform = hovering
- ? CATransform3DMakeScale(1.02, 1.02, 1)
- : CATransform3DIdentity
- }
- }
- override func resetCursorRects() {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
- // MARK: - Main Paywall Card
- final class PaywallView: NSView, AppearanceRefreshable {
- var onClose: (() -> Void)?
- var onPurchaseSucceeded: (() -> Void)?
- private var selectedPlan: PaywallPlan = .yearly
- private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
- private let ctaButton = PaywallCTAButton()
- private let continueFreePlanLink = PaywallFooterLink(title: "Continue with free plan")
- private let manageSubscriptionLink = PaywallFooterLink(title: "Manage Subscription")
- private var primaryFooterLinkCell: NSView?
- private var leftPanelTitle: NSTextField!
- private var rightTitle: NSTextField!
- private var rightSubtitle: NSTextField!
- private var trustStack: NSStackView!
- private var storeObservers: [NSObjectProtocol] = []
- init() {
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- wantsLayer = true
- layer?.cornerRadius = 0
- setup()
- observeStoreUpdates()
- refreshProductDisplay()
- refreshAppearance()
- }
- deinit {
- storeObservers.forEach { NotificationCenter.default.removeObserver($0) }
- }
- private func observeStoreUpdates() {
- let center = NotificationCenter.default
- storeObservers = [
- center.addObserver(forName: .storeProductsDidUpdate, object: nil, queue: .main) { [weak self] _ in
- self?.refreshProductDisplay()
- },
- center.addObserver(forName: .storeStateDidChange, object: nil, queue: .main) { [weak self] _ in
- self?.refreshPurchaseState()
- },
- center.addObserver(forName: .premiumStatusDidChange, object: nil, queue: .main) { [weak self] _ in
- self?.refreshPurchaseState()
- },
- ]
- }
- private func refreshProductDisplay() {
- let store = StoreManager.shared
- for (plan, card) in planCards {
- card.updateDisplay(product: store.product(for: plan))
- }
- ctaButton.title = selectedPlan.localizedCTATitle(from: store.product(for: selectedPlan))
- refreshPurchaseState()
- }
- private func refreshPurchaseState() {
- let store = StoreManager.shared
- let isBusy = store.isPurchasing || store.isLoadingProducts
- ctaButton.isEnabled = !isBusy
- ctaButton.alphaValue = isBusy ? 0.65 : 1
- refreshPrimaryFooterLink()
- }
- private func refreshPrimaryFooterLink() {
- let isPro = StoreManager.shared.isPro
- continueFreePlanLink.isHidden = isPro
- manageSubscriptionLink.isHidden = !isPro
- primaryFooterLinkCell?.needsLayout = true
- primaryFooterLinkCell?.layoutSubtreeIfNeeded()
- }
- func refreshStoreState() {
- refreshProductDisplay()
- }
- func refreshAppearance() {
- layer?.backgroundColor = AppTheme.paywallBackground.cgColor
- leftPanelTitle?.refreshThemeLabelColor()
- rightTitle?.refreshThemeLabelColor()
- rightSubtitle?.refreshThemeLabelColor()
- trustStack?.layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor
- trustStack?.layer?.borderColor = AppTheme.paywallBorder.cgColor
- ctaButton.refreshAppearance()
- subviews.forEach { $0.refreshAppearanceRecursively() }
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- private func setup() {
- let leftPanel = makeLeftPanel()
- let rightPanel = makeRightPanel()
- addSubview(leftPanel)
- addSubview(rightPanel)
- NSLayoutConstraint.activate([
- leftPanel.leadingAnchor.constraint(equalTo: leadingAnchor),
- leftPanel.topAnchor.constraint(equalTo: topAnchor),
- leftPanel.bottomAnchor.constraint(equalTo: bottomAnchor),
- leftPanel.widthAnchor.constraint(equalToConstant: 320),
- rightPanel.leadingAnchor.constraint(equalTo: leftPanel.trailingAnchor),
- rightPanel.trailingAnchor.constraint(equalTo: trailingAnchor),
- rightPanel.topAnchor.constraint(equalTo: topAnchor),
- rightPanel.bottomAnchor.constraint(equalTo: bottomAnchor),
- ])
- }
- private func makeLeftPanel() -> NSView {
- let panel = PaywallLeftPanelView()
- panel.translatesAutoresizingMaskIntoConstraints = false
- let title = NSTextField.themeLabel(
- "Unlock Your Full\nPrinting Potential",
- style: .primary,
- font: AppTheme.semiboldFont(size: 22)
- )
- title.maximumNumberOfLines = 2
- title.translatesAutoresizingMaskIntoConstraints = false
- leftPanelTitle = title
- let featuresStack = NSStackView()
- featuresStack.orientation = .vertical
- featuresStack.spacing = 6
- featuresStack.alignment = .leading
- featuresStack.translatesAutoresizingMaskIntoConstraints = false
- let features = [
- "Unlimited high-quality scans",
- "Advanced OCR technology",
- "Direct cloud printing",
- "Ad-free experience",
- "Priority support",
- "Secure storage",
- ]
- for feature in features {
- featuresStack.addArrangedSubview(PaywallFeatureRow(text: feature))
- }
- panel.addSubview(title)
- panel.addSubview(featuresStack)
- NSLayoutConstraint.activate([
- title.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
- title.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -20),
- title.topAnchor.constraint(equalTo: panel.topAnchor, constant: 48),
- featuresStack.leadingAnchor.constraint(equalTo: title.leadingAnchor),
- featuresStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -20),
- featuresStack.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 28),
- ])
- return panel
- }
- private func makeRightPanel() -> NSView {
- let panel = NSView()
- panel.translatesAutoresizingMaskIntoConstraints = false
- let title = NSTextField.themeLabel("Go Premium", style: .primary, font: AppTheme.semiboldFont(size: 26))
- title.alignment = .center
- title.translatesAutoresizingMaskIntoConstraints = false
- rightTitle = title
- let subtitle = NSTextField.themeLabel(
- "Experience professional quality printing and scanning without limits.",
- style: .secondary,
- font: AppTheme.regularFont(size: 13)
- )
- subtitle.alignment = .center
- subtitle.maximumNumberOfLines = 2
- subtitle.translatesAutoresizingMaskIntoConstraints = false
- rightSubtitle = subtitle
- let plansStack = NSStackView()
- plansStack.orientation = .vertical
- plansStack.spacing = 12
- plansStack.translatesAutoresizingMaskIntoConstraints = false
- for plan in PaywallPlan.allCases {
- let card = PaywallPlanCard(plan: plan)
- card.isChosen = plan == selectedPlan
- card.onSelect = { [weak self] in self?.selectPlan(plan) }
- planCards[plan] = card
- plansStack.addArrangedSubview(card)
- }
- ctaButton.title = selectedPlan.localizedCTATitle(from: StoreManager.shared.product(for: selectedPlan))
- ctaButton.target = self
- ctaButton.action = #selector(purchaseTapped)
- ctaButton.translatesAutoresizingMaskIntoConstraints = false
- let trustRow = makeTrustRow()
- let footerLinks = makeFooterLinks()
- panel.addSubview(title)
- panel.addSubview(subtitle)
- panel.addSubview(plansStack)
- panel.addSubview(trustRow)
- panel.addSubview(ctaButton)
- panel.addSubview(footerLinks)
- NSLayoutConstraint.activate([
- title.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
- title.topAnchor.constraint(equalTo: panel.topAnchor, constant: 40),
- title.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
- subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
- 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),
- trustRow.leadingAnchor.constraint(equalTo: plansStack.leadingAnchor),
- trustRow.trailingAnchor.constraint(equalTo: plansStack.trailingAnchor),
- trustRow.topAnchor.constraint(equalTo: plansStack.bottomAnchor, constant: 18),
- ctaButton.leadingAnchor.constraint(equalTo: plansStack.leadingAnchor),
- 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),
- footerLinks.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
- footerLinks.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
- footerLinks.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -20),
- ])
- return panel
- }
- 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 trustStack = NSStackView(views: [securePayments, cancelAnytime, support, privacyFirst])
- trustStack.orientation = .horizontal
- trustStack.distribution = .fillEqually
- trustStack.spacing = 16
- trustStack.alignment = .top
- trustStack.translatesAutoresizingMaskIntoConstraints = false
- trustStack.wantsLayer = true
- trustStack.layer?.cornerRadius = 12
- trustStack.layer?.borderWidth = 1.5
- trustStack.layer?.masksToBounds = true
- trustStack.edgeInsets = NSEdgeInsets(top: 12, left: 14, bottom: 12, right: 14)
- trustStack.translatesAutoresizingMaskIntoConstraints = false
- self.trustStack = trustStack
- return trustStack
- }
- private func makeFooterLinks() -> NSView {
- let container = NSView()
- container.translatesAutoresizingMaskIntoConstraints = false
- continueFreePlanLink.target = self
- continueFreePlanLink.action = #selector(continueWithFreePlanTapped)
- manageSubscriptionLink.target = self
- manageSubscriptionLink.action = #selector(manageSubscriptionTapped)
- let primaryCell = makePrimaryFooterLinkCell()
- primaryFooterLinkCell = primaryCell
- refreshPrimaryFooterLink()
- let restoreLink = PaywallFooterLink(title: "Restore Purchase")
- restoreLink.target = self
- restoreLink.action = #selector(restoreTapped)
- let privacyLink = PaywallFooterLink(title: "Privacy Policy")
- let termsLink = PaywallFooterLink(title: "Terms of Service")
- let supportLink = PaywallFooterLink(title: "Support")
- let linkCells = [
- primaryCell,
- makeFooterLinkCell(link: restoreLink),
- makeFooterLinkCell(link: privacyLink),
- makeFooterLinkCell(link: termsLink),
- makeFooterLinkCell(link: supportLink),
- ]
- let linksStack = NSStackView(views: linkCells)
- linksStack.orientation = .horizontal
- linksStack.spacing = 0
- linksStack.alignment = .centerY
- linksStack.distribution = .fillEqually
- linksStack.translatesAutoresizingMaskIntoConstraints = false
- container.addSubview(linksStack)
- NSLayoutConstraint.activate([
- linksStack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
- linksStack.trailingAnchor.constraint(equalTo: container.trailingAnchor),
- linksStack.topAnchor.constraint(equalTo: container.topAnchor),
- linksStack.bottomAnchor.constraint(equalTo: container.bottomAnchor),
- ])
- return container
- }
- private func makePrimaryFooterLinkCell() -> NSView {
- let cell = NSView()
- cell.translatesAutoresizingMaskIntoConstraints = false
- for link in [continueFreePlanLink, manageSubscriptionLink] {
- cell.addSubview(link)
- NSLayoutConstraint.activate([
- link.centerXAnchor.constraint(equalTo: cell.centerXAnchor),
- link.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
- link.topAnchor.constraint(equalTo: cell.topAnchor),
- link.bottomAnchor.constraint(equalTo: cell.bottomAnchor),
- ])
- }
- return cell
- }
- private func makeFooterLinkCell(link: PaywallFooterLink) -> NSView {
- let cell = NSView()
- cell.translatesAutoresizingMaskIntoConstraints = false
- cell.addSubview(link)
- NSLayoutConstraint.activate([
- link.centerXAnchor.constraint(equalTo: cell.centerXAnchor),
- link.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
- link.topAnchor.constraint(equalTo: cell.topAnchor),
- link.bottomAnchor.constraint(equalTo: cell.bottomAnchor),
- ])
- return cell
- }
- private func selectPlan(_ plan: PaywallPlan) {
- selectedPlan = plan
- for (key, card) in planCards {
- card.isChosen = key == plan
- }
- ctaButton.title = plan.localizedCTATitle(from: StoreManager.shared.product(for: plan))
- }
- @objc private func purchaseTapped() {
- Task { @MainActor in
- refreshPurchaseState()
- do {
- let succeeded = try await StoreManager.shared.purchase(plan: selectedPlan)
- refreshStoreState()
- if succeeded {
- onPurchaseSucceeded?()
- }
- } catch {
- refreshPurchaseState()
- StoreManager.shared.showPurchaseError(error, on: window)
- }
- }
- }
- @objc private func restoreTapped() {
- Task { @MainActor in
- refreshPurchaseState()
- do {
- let restored = try await StoreManager.shared.restorePurchases()
- refreshStoreState()
- if restored {
- onPurchaseSucceeded?()
- } else {
- StoreManager.shared.showAlert(
- title: "No Purchases Found",
- message: "We couldn't find any previous purchases for this Apple ID.",
- on: window
- )
- }
- } catch {
- refreshPurchaseState()
- StoreManager.shared.showPurchaseError(error, on: window)
- }
- }
- }
- @objc private func continueWithFreePlanTapped() {
- onClose?()
- }
- @objc private func manageSubscriptionTapped() {
- StoreManager.shared.showManageSubscriptions()
- }
- }
- // MARK: - Overlay Presenter
- final class PaywallOverlayView: NSView, AppearanceRefreshable {
- var onDismiss: (() -> Void)?
- private let paywallView: PaywallView
- private let blurView = NSVisualEffectView()
- private let backdrop = NSView()
- private let pattern = WavePatternView()
- init() {
- paywallView = PaywallView()
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- setup()
- refreshAppearance()
- }
- func refreshAppearance() {
- backdrop.layer?.backgroundColor = AppTheme.paywallOverlayBackdrop.cgColor
- blurView.material = AppSettings.darkModeEnabled ? .hudWindow : .underWindowBackground
- pattern.refreshAppearance()
- paywallView.refreshAppearance()
- }
- func refreshStoreState() {
- paywallView.refreshStoreState()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- private func setup() {
- blurView.translatesAutoresizingMaskIntoConstraints = false
- blurView.material = .underWindowBackground
- blurView.blendingMode = .withinWindow
- blurView.state = .active
- backdrop.translatesAutoresizingMaskIntoConstraints = false
- backdrop.wantsLayer = true
- backdrop.layer?.backgroundColor = NSColor(calibratedWhite: 0.15, alpha: 0.22).cgColor
- pattern.translatesAutoresizingMaskIntoConstraints = false
- pattern.alphaValue = 0.35
- paywallView.translatesAutoresizingMaskIntoConstraints = false
- paywallView.onClose = { [weak self] in self?.dismiss() }
- paywallView.onPurchaseSucceeded = { [weak self] in
- self?.paywallView.refreshStoreState()
- }
- addSubview(blurView)
- addSubview(backdrop)
- backdrop.addSubview(pattern)
- addSubview(paywallView)
- NSLayoutConstraint.activate([
- blurView.leadingAnchor.constraint(equalTo: leadingAnchor),
- blurView.trailingAnchor.constraint(equalTo: trailingAnchor),
- blurView.topAnchor.constraint(equalTo: topAnchor),
- blurView.bottomAnchor.constraint(equalTo: bottomAnchor),
- backdrop.leadingAnchor.constraint(equalTo: leadingAnchor),
- backdrop.trailingAnchor.constraint(equalTo: trailingAnchor),
- backdrop.topAnchor.constraint(equalTo: topAnchor),
- backdrop.bottomAnchor.constraint(equalTo: bottomAnchor),
- pattern.leadingAnchor.constraint(equalTo: backdrop.leadingAnchor),
- pattern.trailingAnchor.constraint(equalTo: backdrop.trailingAnchor),
- pattern.topAnchor.constraint(equalTo: backdrop.topAnchor),
- pattern.bottomAnchor.constraint(equalTo: backdrop.bottomAnchor),
- paywallView.leadingAnchor.constraint(equalTo: leadingAnchor),
- paywallView.trailingAnchor.constraint(equalTo: trailingAnchor),
- paywallView.topAnchor.constraint(equalTo: topAnchor),
- paywallView.bottomAnchor.constraint(equalTo: bottomAnchor),
- ])
- }
- func present(in parent: NSView) {
- guard superview == nil else { return }
- if let window = parent.window,
- let windowFrameView = window.contentView?.superview {
- windowFrameView.addSubview(self)
- NSLayoutConstraint.activate([
- leadingAnchor.constraint(equalTo: windowFrameView.leadingAnchor),
- trailingAnchor.constraint(equalTo: windowFrameView.trailingAnchor),
- topAnchor.constraint(equalTo: windowFrameView.topAnchor),
- bottomAnchor.constraint(equalTo: windowFrameView.bottomAnchor),
- ])
- } else {
- parent.addSubview(self)
- NSLayoutConstraint.activate([
- leadingAnchor.constraint(equalTo: parent.leadingAnchor),
- trailingAnchor.constraint(equalTo: parent.trailingAnchor),
- topAnchor.constraint(equalTo: parent.topAnchor),
- bottomAnchor.constraint(equalTo: parent.bottomAnchor),
- ])
- }
- alphaValue = 0
- Task { @MainActor in
- if StoreManager.shared.products.isEmpty {
- await StoreManager.shared.loadProducts()
- }
- await StoreManager.shared.refreshPremiumStatus()
- paywallView.refreshStoreState()
- alphaValue = 1
- }
- }
- func dismiss() {
- NSAnimationContext.runAnimationGroup({ context in
- context.duration = 0.15
- animator().alphaValue = 0
- }, completionHandler: { [weak self] in
- self?.removeFromSuperview()
- self?.onDismiss?()
- })
- }
- }
|