Нет описания

PaywallView.swift 46KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323
  1. import Cocoa
  2. import StoreKit
  3. // MARK: - Plan Model
  4. enum PaywallPlan: CaseIterable {
  5. case monthly
  6. case yearly
  7. case lifetime
  8. var productID: String {
  9. switch self {
  10. case .monthly: StoreProductID.monthly
  11. case .yearly: StoreProductID.yearly
  12. case .lifetime: StoreProductID.lifetime
  13. }
  14. }
  15. var title: String {
  16. switch self {
  17. case .monthly: "Monthly"
  18. case .yearly: "Yearly"
  19. case .lifetime: "Lifetime"
  20. }
  21. }
  22. var subtitle: String {
  23. switch self {
  24. case .monthly: "$4.99 / month, cancel anytime"
  25. case .yearly: "Eligible new subscribers get 7 days free, then $29.99 / year"
  26. case .lifetime: "$99.99 once, lifetime access"
  27. }
  28. }
  29. var price: String {
  30. switch self {
  31. case .monthly: "$4.99"
  32. case .yearly: "$29.99"
  33. case .lifetime: "$99.99"
  34. }
  35. }
  36. var ctaTitle: String {
  37. switch self {
  38. case .monthly: "Subscribe for $4.99 / Month"
  39. case .yearly: "Start 7-Day Free Trial"
  40. case .lifetime: "Buy Lifetime Access"
  41. }
  42. }
  43. func localizedPrice(from product: Product?) -> String {
  44. product?.displayPrice ?? price
  45. }
  46. func localizedSubtitle(from product: Product?) -> String {
  47. guard let product else { return subtitle }
  48. switch self {
  49. case .monthly:
  50. return "\(product.displayPrice) / month, cancel anytime"
  51. case .yearly:
  52. if product.subscription?.introductoryOffer != nil {
  53. return "Eligible new subscribers get 7 days free, then \(product.displayPrice) / year"
  54. }
  55. return "\(product.displayPrice) / year, cancel anytime"
  56. case .lifetime:
  57. return "\(product.displayPrice) once, lifetime access"
  58. }
  59. }
  60. func localizedCTATitle(from product: Product?) -> String {
  61. guard let product else { return ctaTitle }
  62. switch self {
  63. case .monthly:
  64. return "Subscribe for \(product.displayPrice) / Month"
  65. case .yearly:
  66. if product.subscription?.introductoryOffer != nil {
  67. return "Start 7-Day Free Trial"
  68. }
  69. return "Subscribe for \(product.displayPrice) / Year"
  70. case .lifetime:
  71. return "Buy Lifetime Access for \(product.displayPrice)"
  72. }
  73. }
  74. }
  75. // MARK: - StoreKit
  76. enum StoreProductID {
  77. static let monthly = "MQL-DEV.smart-printer.premium.monthly"
  78. static let yearly = "MQL-DEV.smart-printer.premium.yearly"
  79. static let lifetime = "MQL-DEV.smart-printer.premium.lifetime"
  80. static let all: Set<String> = [monthly, yearly, lifetime]
  81. }
  82. enum StoreError: LocalizedError {
  83. case productNotFound
  84. case failedVerification
  85. var errorDescription: String? {
  86. switch self {
  87. case .productNotFound:
  88. "The selected plan is not available right now. Please try again later."
  89. case .failedVerification:
  90. "We couldn't verify your purchase. Please contact support."
  91. }
  92. }
  93. }
  94. @MainActor
  95. final class StoreManager {
  96. static let shared = StoreManager()
  97. private(set) var products: [Product] = []
  98. private(set) var isPremium = false
  99. var isPro: Bool { isPremium }
  100. private(set) var isLoadingProducts = false
  101. private(set) var isPurchasing = false
  102. private var transactionListener: Task<Void, Never>?
  103. private var hasStarted = false
  104. private init() {}
  105. func start() {
  106. guard !hasStarted else { return }
  107. hasStarted = true
  108. transactionListener = Task { [weak self] in
  109. for await update in Transaction.updates {
  110. await self?.handleTransactionUpdate(update)
  111. }
  112. }
  113. Task {
  114. await loadProducts()
  115. await refreshPremiumStatus()
  116. }
  117. }
  118. func product(for plan: PaywallPlan) -> Product? {
  119. products.first { $0.id == plan.productID }
  120. }
  121. func loadProducts() async {
  122. isLoadingProducts = true
  123. postStoreStateDidChange()
  124. defer {
  125. isLoadingProducts = false
  126. postStoreStateDidChange()
  127. }
  128. do {
  129. products = try await Product.products(for: StoreProductID.all)
  130. .sorted { lhs, rhs in
  131. productSortOrder(for: lhs.id) < productSortOrder(for: rhs.id)
  132. }
  133. NotificationCenter.default.post(name: .storeProductsDidUpdate, object: nil)
  134. } catch {
  135. NSLog("Failed to load products: \(error.localizedDescription)")
  136. }
  137. }
  138. @discardableResult
  139. func purchase(plan: PaywallPlan) async throws -> Bool {
  140. if products.isEmpty {
  141. await loadProducts()
  142. }
  143. guard let product = product(for: plan) else {
  144. throw StoreError.productNotFound
  145. }
  146. isPurchasing = true
  147. postStoreStateDidChange()
  148. defer {
  149. isPurchasing = false
  150. postStoreStateDidChange()
  151. }
  152. let result = try await product.purchase()
  153. switch result {
  154. case .success(let verification):
  155. let transaction = try checkVerified(verification)
  156. await transaction.finish()
  157. await refreshPremiumStatus()
  158. return isPremium
  159. case .userCancelled, .pending:
  160. return false
  161. @unknown default:
  162. return false
  163. }
  164. }
  165. @discardableResult
  166. func restorePurchases() async throws -> Bool {
  167. isPurchasing = true
  168. postStoreStateDidChange()
  169. defer {
  170. isPurchasing = false
  171. postStoreStateDidChange()
  172. }
  173. try await AppStore.sync()
  174. await refreshPremiumStatus()
  175. return isPremium
  176. }
  177. func showAlert(title: String, message: String, on window: NSWindow?) {
  178. let alert = NSAlert()
  179. alert.messageText = title
  180. alert.informativeText = message
  181. alert.alertStyle = .informational
  182. alert.addButton(withTitle: "OK")
  183. if let window {
  184. alert.beginSheetModal(for: window)
  185. } else {
  186. alert.runModal()
  187. }
  188. }
  189. func showManageSubscriptions() {
  190. guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
  191. NSWorkspace.shared.open(url)
  192. }
  193. func showPurchaseError(_ error: Error, on window: NSWindow?) {
  194. if let storeError = error as? StoreError {
  195. showAlert(title: "Purchase Failed", message: storeError.localizedDescription, on: window)
  196. return
  197. }
  198. if let storeKitError = error as? StoreKitError, case .userCancelled = storeKitError {
  199. return
  200. }
  201. showAlert(title: "Purchase Failed", message: error.localizedDescription, on: window)
  202. }
  203. private func handleTransactionUpdate(_ update: VerificationResult<Transaction>) async {
  204. do {
  205. let transaction = try checkVerified(update)
  206. await transaction.finish()
  207. await refreshPremiumStatus()
  208. } catch {
  209. NSLog("Transaction verification failed: \(error.localizedDescription)")
  210. }
  211. }
  212. func refreshPremiumStatus() async {
  213. let hasPremium = await hasActivePremiumAccess()
  214. let didChange = hasPremium != isPremium
  215. isPremium = hasPremium
  216. if didChange {
  217. NotificationCenter.default.post(name: .premiumStatusDidChange, object: nil)
  218. }
  219. postStoreStateDidChange()
  220. }
  221. private func hasActivePremiumAccess() async -> Bool {
  222. if await hasEntitlementFromCurrentEntitlements() {
  223. return true
  224. }
  225. if await hasEntitlementFromLatestTransactions() {
  226. return true
  227. }
  228. if await hasEntitlementFromSubscriptionStatus() {
  229. return true
  230. }
  231. return false
  232. }
  233. private func hasEntitlementFromCurrentEntitlements() async -> Bool {
  234. for await result in Transaction.currentEntitlements {
  235. guard let transaction = try? checkVerified(result) else { continue }
  236. if isActivePremiumTransaction(transaction) {
  237. return true
  238. }
  239. }
  240. return false
  241. }
  242. private func hasEntitlementFromLatestTransactions() async -> Bool {
  243. for productID in StoreProductID.all {
  244. guard let result = await Transaction.latest(for: productID),
  245. let transaction = try? checkVerified(result) else { continue }
  246. if isActivePremiumTransaction(transaction) {
  247. return true
  248. }
  249. }
  250. return false
  251. }
  252. private func hasEntitlementFromSubscriptionStatus() async -> Bool {
  253. for product in products where product.subscription != nil {
  254. guard StoreProductID.all.contains(product.id) else { continue }
  255. guard let statuses = try? await product.subscription?.status else { continue }
  256. for status in statuses {
  257. switch status.state {
  258. case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
  259. return true
  260. default:
  261. continue
  262. }
  263. }
  264. }
  265. return false
  266. }
  267. private func isActivePremiumTransaction(_ transaction: Transaction) -> Bool {
  268. guard StoreProductID.all.contains(transaction.productID) else { return false }
  269. guard transaction.revocationDate == nil else { return false }
  270. if let expirationDate = transaction.expirationDate {
  271. return expirationDate > Date()
  272. }
  273. return true
  274. }
  275. private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
  276. switch result {
  277. case .unverified:
  278. throw StoreError.failedVerification
  279. case .verified(let safe):
  280. return safe
  281. }
  282. }
  283. private func productSortOrder(for productID: String) -> Int {
  284. switch productID {
  285. case StoreProductID.monthly: 0
  286. case StoreProductID.yearly: 1
  287. case StoreProductID.lifetime: 2
  288. default: 99
  289. }
  290. }
  291. private func postStoreStateDidChange() {
  292. NotificationCenter.default.post(name: .storeStateDidChange, object: nil)
  293. }
  294. }
  295. extension Notification.Name {
  296. static let premiumStatusDidChange = Notification.Name("premiumStatusDidChange")
  297. static let storeProductsDidUpdate = Notification.Name("storeProductsDidUpdate")
  298. static let storeStateDidChange = Notification.Name("storeStateDidChange")
  299. }
  300. // MARK: - Left Panel
  301. private final class PaywallLeftPanelView: NSView, AppearanceRefreshable {
  302. private let gradientLayer = CAGradientLayer()
  303. override init(frame frameRect: NSRect) {
  304. super.init(frame: frameRect)
  305. wantsLayer = true
  306. gradientLayer.startPoint = CGPoint(x: 0.5, y: 1)
  307. gradientLayer.endPoint = CGPoint(x: 0.5, y: 0)
  308. layer?.insertSublayer(gradientLayer, at: 0)
  309. refreshAppearance()
  310. }
  311. func refreshAppearance() {
  312. gradientLayer.colors = AppTheme.paywallLeftGradientColors.map(\.cgColor)
  313. }
  314. @available(*, unavailable)
  315. required init?(coder: NSCoder) { nil }
  316. override func layout() {
  317. super.layout()
  318. gradientLayer.frame = bounds
  319. let mask = CAShapeLayer()
  320. mask.path = CGPath(
  321. roundedRect: bounds,
  322. cornerWidth: 20,
  323. cornerHeight: 20,
  324. transform: nil
  325. )
  326. layer?.mask = mask
  327. }
  328. }
  329. // MARK: - Badge
  330. private final class PaywallBadgeView: NSView {
  331. init(text: String, iconName: String, background: NSColor, foreground: NSColor) {
  332. super.init(frame: .zero)
  333. translatesAutoresizingMaskIntoConstraints = false
  334. wantsLayer = true
  335. layer?.backgroundColor = background.cgColor
  336. layer?.cornerRadius = 10
  337. layer?.masksToBounds = true
  338. let icon = NSImageView()
  339. icon.translatesAutoresizingMaskIntoConstraints = false
  340. if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) {
  341. let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .semibold)
  342. icon.image = image.withSymbolConfiguration(config)
  343. }
  344. icon.contentTintColor = foreground
  345. let label = NSTextField(labelWithString: text)
  346. label.font = AppTheme.semiboldFont(size: 10)
  347. label.textColor = foreground
  348. label.translatesAutoresizingMaskIntoConstraints = false
  349. addSubview(icon)
  350. addSubview(label)
  351. NSLayoutConstraint.activate([
  352. heightAnchor.constraint(equalToConstant: 20),
  353. icon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
  354. icon.centerYAnchor.constraint(equalTo: centerYAnchor),
  355. icon.widthAnchor.constraint(equalToConstant: 12),
  356. icon.heightAnchor.constraint(equalToConstant: 12),
  357. label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 4),
  358. label.centerYAnchor.constraint(equalTo: centerYAnchor),
  359. label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
  360. ])
  361. }
  362. @available(*, unavailable)
  363. required init?(coder: NSCoder) { nil }
  364. }
  365. // MARK: - Feature Row
  366. private final class PaywallFeatureRow: NSView, AppearanceRefreshable {
  367. private let label: NSTextField
  368. init(text: String) {
  369. label = NSTextField.themeLabel(text, style: .primary, font: AppTheme.regularFont(size: 14))
  370. super.init(frame: .zero)
  371. translatesAutoresizingMaskIntoConstraints = false
  372. let checkContainer = NSView()
  373. checkContainer.translatesAutoresizingMaskIntoConstraints = false
  374. checkContainer.wantsLayer = true
  375. checkContainer.layer?.backgroundColor = AppTheme.green.cgColor
  376. checkContainer.layer?.cornerRadius = 10
  377. checkContainer.layer?.masksToBounds = true
  378. let checkIcon = NSImageView()
  379. checkIcon.translatesAutoresizingMaskIntoConstraints = false
  380. if let image = NSImage(systemSymbolName: "checkmark", accessibilityDescription: nil) {
  381. let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .bold)
  382. checkIcon.image = image.withSymbolConfiguration(config)
  383. }
  384. checkIcon.contentTintColor = .white
  385. label.translatesAutoresizingMaskIntoConstraints = false
  386. addSubview(checkContainer)
  387. checkContainer.addSubview(checkIcon)
  388. addSubview(label)
  389. NSLayoutConstraint.activate([
  390. heightAnchor.constraint(equalToConstant: 28),
  391. checkContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
  392. checkContainer.centerYAnchor.constraint(equalTo: centerYAnchor),
  393. checkContainer.widthAnchor.constraint(equalToConstant: 20),
  394. checkContainer.heightAnchor.constraint(equalToConstant: 20),
  395. checkIcon.centerXAnchor.constraint(equalTo: checkContainer.centerXAnchor),
  396. checkIcon.centerYAnchor.constraint(equalTo: checkContainer.centerYAnchor),
  397. checkIcon.widthAnchor.constraint(equalToConstant: 12),
  398. checkIcon.heightAnchor.constraint(equalToConstant: 12),
  399. label.leadingAnchor.constraint(equalTo: checkContainer.trailingAnchor, constant: 12),
  400. label.centerYAnchor.constraint(equalTo: centerYAnchor),
  401. label.trailingAnchor.constraint(equalTo: trailingAnchor),
  402. ])
  403. }
  404. @available(*, unavailable)
  405. required init?(coder: NSCoder) { nil }
  406. func refreshAppearance() {
  407. label.refreshThemeLabelColor()
  408. }
  409. }
  410. // MARK: - Plan Card
  411. private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
  412. var onSelect: (() -> Void)?
  413. private let plan: PaywallPlan
  414. private let titleLabel = NSTextField(labelWithString: "")
  415. private let subtitleLabel = NSTextField(labelWithString: "")
  416. private let priceLabel = NSTextField(labelWithString: "")
  417. private var badgeView: PaywallBadgeView?
  418. private var hoverTracker: HoverTracker?
  419. private var isHovered = false
  420. var isChosen: Bool = false {
  421. didSet { updateAppearance() }
  422. }
  423. init(plan: PaywallPlan) {
  424. self.plan = plan
  425. super.init(frame: .zero)
  426. translatesAutoresizingMaskIntoConstraints = false
  427. wantsLayer = true
  428. layer?.cornerRadius = 12
  429. layer?.masksToBounds = false
  430. titleLabel.stringValue = plan.title
  431. titleLabel.font = AppTheme.semiboldFont(size: 15)
  432. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  433. subtitleLabel.stringValue = plan.subtitle
  434. subtitleLabel.font = AppTheme.regularFont(size: 11)
  435. subtitleLabel.themeLabelStyle = .secondary
  436. subtitleLabel.textColor = AppTheme.textSecondary
  437. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  438. priceLabel.stringValue = plan.price
  439. priceLabel.font = AppTheme.semiboldFont(size: 15)
  440. priceLabel.alignment = .right
  441. priceLabel.translatesAutoresizingMaskIntoConstraints = false
  442. addSubview(titleLabel)
  443. addSubview(subtitleLabel)
  444. addSubview(priceLabel)
  445. NSLayoutConstraint.activate([
  446. heightAnchor.constraint(equalToConstant: 86),
  447. titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
  448. titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 24),
  449. subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  450. subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 3),
  451. subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: priceLabel.leadingAnchor, constant: -12),
  452. priceLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
  453. priceLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  454. ])
  455. if plan == .yearly {
  456. let badge = PaywallBadgeView(
  457. text: "7 Days Free Trial",
  458. iconName: "calendar",
  459. background: AppTheme.paywallPink,
  460. foreground: AppTheme.paywallPinkText
  461. )
  462. badgeView = badge
  463. addSubview(badge)
  464. NSLayoutConstraint.activate([
  465. badge.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
  466. badge.topAnchor.constraint(equalTo: topAnchor, constant: 8),
  467. ])
  468. } else if plan == .lifetime {
  469. let badge = PaywallBadgeView(
  470. text: "Best Value",
  471. iconName: "star.fill",
  472. background: AppTheme.paywallGold,
  473. foreground: AppTheme.paywallGoldText
  474. )
  475. badgeView = badge
  476. addSubview(badge)
  477. NSLayoutConstraint.activate([
  478. badge.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
  479. badge.topAnchor.constraint(equalTo: topAnchor, constant: 8),
  480. ])
  481. }
  482. applyCardShadow()
  483. updateAppearance()
  484. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  485. self?.setHovered(hovering)
  486. }
  487. }
  488. @available(*, unavailable)
  489. required init?(coder: NSCoder) { nil }
  490. func updateDisplay(product: Product?) {
  491. subtitleLabel.stringValue = plan.localizedSubtitle(from: product)
  492. priceLabel.stringValue = plan.localizedPrice(from: product)
  493. }
  494. func refreshAppearance() {
  495. updateAppearance()
  496. subtitleLabel.refreshThemeLabelColor()
  497. if isHovered {
  498. applyHoverLift(true)
  499. }
  500. }
  501. private func setHovered(_ hovering: Bool) {
  502. isHovered = hovering
  503. applyHoverLift(hovering)
  504. updateAppearance()
  505. }
  506. private func updateAppearance() {
  507. let titleColor = isChosen && plan == .yearly ? AppTheme.green : AppTheme.paywallAccent
  508. titleLabel.textColor = titleColor
  509. priceLabel.textColor = titleColor
  510. layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor
  511. if isChosen {
  512. layer?.borderWidth = 2
  513. layer?.borderColor = AppTheme.green.cgColor
  514. } else if isHovered {
  515. layer?.borderWidth = 2
  516. let hoverBorder = AppTheme.paywallBorder.blended(withFraction: 0.35, of: AppTheme.paywallAccent)
  517. ?? AppTheme.paywallBorder
  518. layer?.borderColor = hoverBorder.cgColor
  519. } else {
  520. layer?.borderWidth = 1.5
  521. layer?.borderColor = AppTheme.paywallBorder.cgColor
  522. }
  523. }
  524. override func mouseUp(with event: NSEvent) {
  525. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  526. onSelect?()
  527. }
  528. override func resetCursorRects() {
  529. addCursorRect(bounds, cursor: .pointingHand)
  530. }
  531. }
  532. // MARK: - Footer Link
  533. private final class PaywallFooterLink: NSButton, AppearanceRefreshable {
  534. private var hoverTracker: HoverTracker?
  535. private var isHovered = false
  536. init(title: String) {
  537. super.init(frame: .zero)
  538. updateTitle(title)
  539. isBordered = false
  540. bezelStyle = .inline
  541. font = AppTheme.regularFont(size: 11)
  542. translatesAutoresizingMaskIntoConstraints = false
  543. refreshAppearance()
  544. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  545. self?.isHovered = hovering
  546. self?.refreshAppearance()
  547. }
  548. }
  549. @available(*, unavailable)
  550. required init?(coder: NSCoder) { nil }
  551. func updateTitle(_ title: String) {
  552. self.title = title
  553. invalidateIntrinsicContentSize()
  554. needsDisplay = true
  555. }
  556. func refreshAppearance() {
  557. contentTintColor = isHovered ? AppTheme.textPrimary : AppTheme.textSecondary
  558. }
  559. override func resetCursorRects() {
  560. addCursorRect(bounds, cursor: .pointingHand)
  561. }
  562. }
  563. // MARK: - Footer Trust Item
  564. private final class PaywallTrustItemView: NSView, AppearanceRefreshable {
  565. private let iconContainer = NSView()
  566. private let icon = NSImageView()
  567. private let titleLabel: NSTextField
  568. private let subtitleLabel: NSTextField
  569. init(iconName: String, title: String, subtitle: String) {
  570. titleLabel = NSTextField.themeLabel(title, style: .primary, font: AppTheme.semiboldFont(size: 11))
  571. subtitleLabel = NSTextField.themeLabel(subtitle, style: .secondary, font: AppTheme.regularFont(size: 9))
  572. super.init(frame: .zero)
  573. translatesAutoresizingMaskIntoConstraints = false
  574. iconContainer.translatesAutoresizingMaskIntoConstraints = false
  575. iconContainer.wantsLayer = true
  576. iconContainer.layer?.cornerRadius = 10
  577. iconContainer.layer?.masksToBounds = true
  578. icon.translatesAutoresizingMaskIntoConstraints = false
  579. if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) {
  580. let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  581. icon.image = image.withSymbolConfiguration(config)
  582. }
  583. titleLabel.lineBreakMode = .byTruncatingTail
  584. titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  585. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  586. subtitleLabel.lineBreakMode = .byTruncatingTail
  587. subtitleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  588. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  589. addSubview(iconContainer)
  590. iconContainer.addSubview(icon)
  591. addSubview(titleLabel)
  592. addSubview(subtitleLabel)
  593. NSLayoutConstraint.activate([
  594. iconContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
  595. iconContainer.topAnchor.constraint(equalTo: topAnchor),
  596. iconContainer.widthAnchor.constraint(equalToConstant: 20),
  597. iconContainer.heightAnchor.constraint(equalToConstant: 20),
  598. icon.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
  599. icon.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
  600. icon.widthAnchor.constraint(equalToConstant: 12),
  601. icon.heightAnchor.constraint(equalToConstant: 12),
  602. titleLabel.leadingAnchor.constraint(equalTo: iconContainer.trailingAnchor, constant: 8),
  603. titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 1),
  604. titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
  605. subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  606. subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 2),
  607. subtitleLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
  608. subtitleLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
  609. ])
  610. setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  611. setContentHuggingPriority(.defaultLow, for: .horizontal)
  612. refreshAppearance()
  613. }
  614. @available(*, unavailable)
  615. required init?(coder: NSCoder) { nil }
  616. func refreshAppearance() {
  617. iconContainer.layer?.backgroundColor = AppTheme.paywallTrustIconBackground.cgColor
  618. icon.contentTintColor = AppTheme.paywallIconAccent
  619. titleLabel.refreshThemeLabelColor()
  620. subtitleLabel.refreshThemeLabelColor()
  621. }
  622. }
  623. // MARK: - CTA Button
  624. private final class PaywallCTAButton: NSButton, AppearanceRefreshable {
  625. private var hoverTracker: HoverTracker?
  626. init() {
  627. super.init(frame: .zero)
  628. isBordered = false
  629. wantsLayer = true
  630. layer?.cornerRadius = 12
  631. font = AppTheme.semiboldFont(size: 15)
  632. translatesAutoresizingMaskIntoConstraints = false
  633. refreshAppearance()
  634. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  635. self?.setHovered(hovering)
  636. }
  637. }
  638. @available(*, unavailable)
  639. required init?(coder: NSCoder) { nil }
  640. func refreshAppearance() {
  641. layer?.backgroundColor = AppTheme.paywallCTABackground.cgColor
  642. contentTintColor = AppTheme.paywallCTAForeground
  643. }
  644. private func setHovered(_ hovering: Bool) {
  645. let base = AppTheme.paywallCTABackground
  646. let color = hovering ? base.blended(withFraction: 0.12, of: .black) ?? base : base
  647. animateHover {
  648. layer?.backgroundColor = color.cgColor
  649. layer?.transform = hovering
  650. ? CATransform3DMakeScale(1.02, 1.02, 1)
  651. : CATransform3DIdentity
  652. }
  653. }
  654. override func resetCursorRects() {
  655. addCursorRect(bounds, cursor: .pointingHand)
  656. }
  657. }
  658. // MARK: - Main Paywall Card
  659. final class PaywallView: NSView, AppearanceRefreshable {
  660. var onClose: (() -> Void)?
  661. var onPurchaseSucceeded: (() -> Void)?
  662. private var selectedPlan: PaywallPlan = .yearly
  663. private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
  664. private let ctaButton = PaywallCTAButton()
  665. private let continueFreePlanLink = PaywallFooterLink(title: "Continue with free plan")
  666. private let manageSubscriptionLink = PaywallFooterLink(title: "Manage Subscription")
  667. private var primaryFooterLinkCell: NSView?
  668. private var leftPanelTitle: NSTextField!
  669. private var rightTitle: NSTextField!
  670. private var rightSubtitle: NSTextField!
  671. private var trustStack: NSStackView!
  672. private var storeObservers: [NSObjectProtocol] = []
  673. init() {
  674. super.init(frame: .zero)
  675. translatesAutoresizingMaskIntoConstraints = false
  676. wantsLayer = true
  677. layer?.cornerRadius = 0
  678. setup()
  679. observeStoreUpdates()
  680. refreshProductDisplay()
  681. refreshAppearance()
  682. }
  683. deinit {
  684. storeObservers.forEach { NotificationCenter.default.removeObserver($0) }
  685. }
  686. private func observeStoreUpdates() {
  687. let center = NotificationCenter.default
  688. storeObservers = [
  689. center.addObserver(forName: .storeProductsDidUpdate, object: nil, queue: .main) { [weak self] _ in
  690. self?.refreshProductDisplay()
  691. },
  692. center.addObserver(forName: .storeStateDidChange, object: nil, queue: .main) { [weak self] _ in
  693. self?.refreshPurchaseState()
  694. },
  695. center.addObserver(forName: .premiumStatusDidChange, object: nil, queue: .main) { [weak self] _ in
  696. self?.refreshPurchaseState()
  697. },
  698. ]
  699. }
  700. private func refreshProductDisplay() {
  701. let store = StoreManager.shared
  702. for (plan, card) in planCards {
  703. card.updateDisplay(product: store.product(for: plan))
  704. }
  705. ctaButton.title = selectedPlan.localizedCTATitle(from: store.product(for: selectedPlan))
  706. refreshPurchaseState()
  707. }
  708. private func refreshPurchaseState() {
  709. let store = StoreManager.shared
  710. let isBusy = store.isPurchasing || store.isLoadingProducts
  711. ctaButton.isEnabled = !isBusy
  712. ctaButton.alphaValue = isBusy ? 0.65 : 1
  713. refreshPrimaryFooterLink()
  714. }
  715. private func refreshPrimaryFooterLink() {
  716. let isPro = StoreManager.shared.isPro
  717. continueFreePlanLink.isHidden = isPro
  718. manageSubscriptionLink.isHidden = !isPro
  719. primaryFooterLinkCell?.needsLayout = true
  720. primaryFooterLinkCell?.layoutSubtreeIfNeeded()
  721. }
  722. func refreshStoreState() {
  723. refreshProductDisplay()
  724. }
  725. func refreshAppearance() {
  726. layer?.backgroundColor = AppTheme.paywallBackground.cgColor
  727. leftPanelTitle?.refreshThemeLabelColor()
  728. rightTitle?.refreshThemeLabelColor()
  729. rightSubtitle?.refreshThemeLabelColor()
  730. trustStack?.layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor
  731. trustStack?.layer?.borderColor = AppTheme.paywallBorder.cgColor
  732. ctaButton.refreshAppearance()
  733. subviews.forEach { $0.refreshAppearanceRecursively() }
  734. }
  735. @available(*, unavailable)
  736. required init?(coder: NSCoder) { nil }
  737. private func setup() {
  738. let leftPanel = makeLeftPanel()
  739. let rightPanel = makeRightPanel()
  740. addSubview(leftPanel)
  741. addSubview(rightPanel)
  742. NSLayoutConstraint.activate([
  743. leftPanel.leadingAnchor.constraint(equalTo: leadingAnchor),
  744. leftPanel.topAnchor.constraint(equalTo: topAnchor),
  745. leftPanel.bottomAnchor.constraint(equalTo: bottomAnchor),
  746. leftPanel.widthAnchor.constraint(equalToConstant: 320),
  747. rightPanel.leadingAnchor.constraint(equalTo: leftPanel.trailingAnchor),
  748. rightPanel.trailingAnchor.constraint(equalTo: trailingAnchor),
  749. rightPanel.topAnchor.constraint(equalTo: topAnchor),
  750. rightPanel.bottomAnchor.constraint(equalTo: bottomAnchor),
  751. ])
  752. }
  753. private func makeLeftPanel() -> NSView {
  754. let panel = PaywallLeftPanelView()
  755. panel.translatesAutoresizingMaskIntoConstraints = false
  756. let title = NSTextField.themeLabel(
  757. "Unlock Your Full\nPrinting Potential",
  758. style: .primary,
  759. font: AppTheme.semiboldFont(size: 22)
  760. )
  761. title.maximumNumberOfLines = 2
  762. title.translatesAutoresizingMaskIntoConstraints = false
  763. leftPanelTitle = title
  764. let featuresStack = NSStackView()
  765. featuresStack.orientation = .vertical
  766. featuresStack.spacing = 6
  767. featuresStack.alignment = .leading
  768. featuresStack.translatesAutoresizingMaskIntoConstraints = false
  769. let features = [
  770. "Unlimited high-quality scans",
  771. "Advanced OCR technology",
  772. "Direct cloud printing",
  773. "Ad-free experience",
  774. "Priority support",
  775. "Secure storage",
  776. ]
  777. for feature in features {
  778. featuresStack.addArrangedSubview(PaywallFeatureRow(text: feature))
  779. }
  780. panel.addSubview(title)
  781. panel.addSubview(featuresStack)
  782. NSLayoutConstraint.activate([
  783. title.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  784. title.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -20),
  785. title.topAnchor.constraint(equalTo: panel.topAnchor, constant: 48),
  786. featuresStack.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  787. featuresStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -20),
  788. featuresStack.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 28),
  789. ])
  790. return panel
  791. }
  792. private func makeRightPanel() -> NSView {
  793. let panel = NSView()
  794. panel.translatesAutoresizingMaskIntoConstraints = false
  795. let title = NSTextField.themeLabel("Go Premium", style: .primary, font: AppTheme.semiboldFont(size: 26))
  796. title.alignment = .center
  797. title.translatesAutoresizingMaskIntoConstraints = false
  798. rightTitle = title
  799. let subtitle = NSTextField.themeLabel(
  800. "Experience professional quality printing and scanning without limits.",
  801. style: .secondary,
  802. font: AppTheme.regularFont(size: 13)
  803. )
  804. subtitle.alignment = .center
  805. subtitle.maximumNumberOfLines = 2
  806. subtitle.translatesAutoresizingMaskIntoConstraints = false
  807. rightSubtitle = subtitle
  808. let plansStack = NSStackView()
  809. plansStack.orientation = .vertical
  810. plansStack.spacing = 12
  811. plansStack.translatesAutoresizingMaskIntoConstraints = false
  812. for plan in PaywallPlan.allCases {
  813. let card = PaywallPlanCard(plan: plan)
  814. card.isChosen = plan == selectedPlan
  815. card.onSelect = { [weak self] in self?.selectPlan(plan) }
  816. planCards[plan] = card
  817. plansStack.addArrangedSubview(card)
  818. }
  819. ctaButton.title = selectedPlan.localizedCTATitle(from: StoreManager.shared.product(for: selectedPlan))
  820. ctaButton.target = self
  821. ctaButton.action = #selector(purchaseTapped)
  822. ctaButton.translatesAutoresizingMaskIntoConstraints = false
  823. let trustRow = makeTrustRow()
  824. let footerLinks = makeFooterLinks()
  825. panel.addSubview(title)
  826. panel.addSubview(subtitle)
  827. panel.addSubview(plansStack)
  828. panel.addSubview(trustRow)
  829. panel.addSubview(ctaButton)
  830. panel.addSubview(footerLinks)
  831. NSLayoutConstraint.activate([
  832. title.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  833. title.topAnchor.constraint(equalTo: panel.topAnchor, constant: 40),
  834. title.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  835. subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  836. subtitle.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  837. subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6),
  838. plansStack.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  839. plansStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  840. plansStack.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 24),
  841. trustRow.leadingAnchor.constraint(equalTo: plansStack.leadingAnchor),
  842. trustRow.trailingAnchor.constraint(equalTo: plansStack.trailingAnchor),
  843. trustRow.topAnchor.constraint(equalTo: plansStack.bottomAnchor, constant: 18),
  844. ctaButton.leadingAnchor.constraint(equalTo: plansStack.leadingAnchor),
  845. ctaButton.trailingAnchor.constraint(equalTo: plansStack.trailingAnchor),
  846. ctaButton.topAnchor.constraint(equalTo: trustRow.bottomAnchor, constant: 16),
  847. ctaButton.heightAnchor.constraint(equalToConstant: 48),
  848. ctaButton.bottomAnchor.constraint(lessThanOrEqualTo: footerLinks.topAnchor, constant: -14),
  849. footerLinks.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  850. footerLinks.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  851. footerLinks.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -20),
  852. ])
  853. return panel
  854. }
  855. private func makeTrustRow() -> NSView {
  856. let securePayments = PaywallTrustItemView(
  857. iconName: "shield.fill",
  858. title: "Secure Payments",
  859. subtitle: "Your payment is 100% secure"
  860. )
  861. let cancelAnytime = PaywallTrustItemView(
  862. iconName: "arrow.counterclockwise",
  863. title: "Cancel Anytime",
  864. subtitle: "No commitment, cancel anytime."
  865. )
  866. let support = PaywallTrustItemView(
  867. iconName: "headphones",
  868. title: "24/7 Support",
  869. subtitle: "We're here to help you anytime."
  870. )
  871. let privacyFirst = PaywallTrustItemView(
  872. iconName: "lock.fill",
  873. title: "Privacy First",
  874. subtitle: "Your data is safe with us."
  875. )
  876. let trustStack = NSStackView(views: [securePayments, cancelAnytime, support, privacyFirst])
  877. trustStack.orientation = .horizontal
  878. trustStack.distribution = .fillEqually
  879. trustStack.spacing = 16
  880. trustStack.alignment = .top
  881. trustStack.translatesAutoresizingMaskIntoConstraints = false
  882. trustStack.wantsLayer = true
  883. trustStack.layer?.cornerRadius = 12
  884. trustStack.layer?.borderWidth = 1.5
  885. trustStack.layer?.masksToBounds = true
  886. trustStack.edgeInsets = NSEdgeInsets(top: 12, left: 14, bottom: 12, right: 14)
  887. trustStack.translatesAutoresizingMaskIntoConstraints = false
  888. self.trustStack = trustStack
  889. return trustStack
  890. }
  891. private func makeFooterLinks() -> NSView {
  892. let container = NSView()
  893. container.translatesAutoresizingMaskIntoConstraints = false
  894. continueFreePlanLink.target = self
  895. continueFreePlanLink.action = #selector(continueWithFreePlanTapped)
  896. manageSubscriptionLink.target = self
  897. manageSubscriptionLink.action = #selector(manageSubscriptionTapped)
  898. let primaryCell = makePrimaryFooterLinkCell()
  899. primaryFooterLinkCell = primaryCell
  900. refreshPrimaryFooterLink()
  901. let restoreLink = PaywallFooterLink(title: "Restore Purchase")
  902. restoreLink.target = self
  903. restoreLink.action = #selector(restoreTapped)
  904. let privacyLink = PaywallFooterLink(title: "Privacy Policy")
  905. let termsLink = PaywallFooterLink(title: "Terms of Service")
  906. let supportLink = PaywallFooterLink(title: "Support")
  907. let linkCells = [
  908. primaryCell,
  909. makeFooterLinkCell(link: restoreLink),
  910. makeFooterLinkCell(link: privacyLink),
  911. makeFooterLinkCell(link: termsLink),
  912. makeFooterLinkCell(link: supportLink),
  913. ]
  914. let linksStack = NSStackView(views: linkCells)
  915. linksStack.orientation = .horizontal
  916. linksStack.spacing = 0
  917. linksStack.alignment = .centerY
  918. linksStack.distribution = .fillEqually
  919. linksStack.translatesAutoresizingMaskIntoConstraints = false
  920. container.addSubview(linksStack)
  921. NSLayoutConstraint.activate([
  922. linksStack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  923. linksStack.trailingAnchor.constraint(equalTo: container.trailingAnchor),
  924. linksStack.topAnchor.constraint(equalTo: container.topAnchor),
  925. linksStack.bottomAnchor.constraint(equalTo: container.bottomAnchor),
  926. ])
  927. return container
  928. }
  929. private func makePrimaryFooterLinkCell() -> NSView {
  930. let cell = NSView()
  931. cell.translatesAutoresizingMaskIntoConstraints = false
  932. for link in [continueFreePlanLink, manageSubscriptionLink] {
  933. cell.addSubview(link)
  934. NSLayoutConstraint.activate([
  935. link.centerXAnchor.constraint(equalTo: cell.centerXAnchor),
  936. link.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
  937. link.topAnchor.constraint(equalTo: cell.topAnchor),
  938. link.bottomAnchor.constraint(equalTo: cell.bottomAnchor),
  939. ])
  940. }
  941. return cell
  942. }
  943. private func makeFooterLinkCell(link: PaywallFooterLink) -> NSView {
  944. let cell = NSView()
  945. cell.translatesAutoresizingMaskIntoConstraints = false
  946. cell.addSubview(link)
  947. NSLayoutConstraint.activate([
  948. link.centerXAnchor.constraint(equalTo: cell.centerXAnchor),
  949. link.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
  950. link.topAnchor.constraint(equalTo: cell.topAnchor),
  951. link.bottomAnchor.constraint(equalTo: cell.bottomAnchor),
  952. ])
  953. return cell
  954. }
  955. private func selectPlan(_ plan: PaywallPlan) {
  956. selectedPlan = plan
  957. for (key, card) in planCards {
  958. card.isChosen = key == plan
  959. }
  960. ctaButton.title = plan.localizedCTATitle(from: StoreManager.shared.product(for: plan))
  961. }
  962. @objc private func purchaseTapped() {
  963. Task { @MainActor in
  964. refreshPurchaseState()
  965. do {
  966. let succeeded = try await StoreManager.shared.purchase(plan: selectedPlan)
  967. refreshStoreState()
  968. if succeeded {
  969. onPurchaseSucceeded?()
  970. }
  971. } catch {
  972. refreshPurchaseState()
  973. StoreManager.shared.showPurchaseError(error, on: window)
  974. }
  975. }
  976. }
  977. @objc private func restoreTapped() {
  978. Task { @MainActor in
  979. refreshPurchaseState()
  980. do {
  981. let restored = try await StoreManager.shared.restorePurchases()
  982. refreshStoreState()
  983. if restored {
  984. onPurchaseSucceeded?()
  985. } else {
  986. StoreManager.shared.showAlert(
  987. title: "No Purchases Found",
  988. message: "We couldn't find any previous purchases for this Apple ID.",
  989. on: window
  990. )
  991. }
  992. } catch {
  993. refreshPurchaseState()
  994. StoreManager.shared.showPurchaseError(error, on: window)
  995. }
  996. }
  997. }
  998. @objc private func continueWithFreePlanTapped() {
  999. onClose?()
  1000. }
  1001. @objc private func manageSubscriptionTapped() {
  1002. StoreManager.shared.showManageSubscriptions()
  1003. }
  1004. }
  1005. // MARK: - Overlay Presenter
  1006. final class PaywallOverlayView: NSView, AppearanceRefreshable {
  1007. var onDismiss: (() -> Void)?
  1008. private let paywallView: PaywallView
  1009. private let blurView = NSVisualEffectView()
  1010. private let backdrop = NSView()
  1011. private let pattern = WavePatternView()
  1012. init() {
  1013. paywallView = PaywallView()
  1014. super.init(frame: .zero)
  1015. translatesAutoresizingMaskIntoConstraints = false
  1016. setup()
  1017. refreshAppearance()
  1018. }
  1019. func refreshAppearance() {
  1020. backdrop.layer?.backgroundColor = AppTheme.paywallOverlayBackdrop.cgColor
  1021. blurView.material = AppSettings.darkModeEnabled ? .hudWindow : .underWindowBackground
  1022. pattern.refreshAppearance()
  1023. paywallView.refreshAppearance()
  1024. }
  1025. func refreshStoreState() {
  1026. paywallView.refreshStoreState()
  1027. }
  1028. @available(*, unavailable)
  1029. required init?(coder: NSCoder) { nil }
  1030. private func setup() {
  1031. blurView.translatesAutoresizingMaskIntoConstraints = false
  1032. blurView.material = .underWindowBackground
  1033. blurView.blendingMode = .withinWindow
  1034. blurView.state = .active
  1035. backdrop.translatesAutoresizingMaskIntoConstraints = false
  1036. backdrop.wantsLayer = true
  1037. backdrop.layer?.backgroundColor = NSColor(calibratedWhite: 0.15, alpha: 0.22).cgColor
  1038. pattern.translatesAutoresizingMaskIntoConstraints = false
  1039. pattern.alphaValue = 0.35
  1040. paywallView.translatesAutoresizingMaskIntoConstraints = false
  1041. paywallView.onClose = { [weak self] in self?.dismiss() }
  1042. paywallView.onPurchaseSucceeded = { [weak self] in
  1043. self?.paywallView.refreshStoreState()
  1044. }
  1045. addSubview(blurView)
  1046. addSubview(backdrop)
  1047. backdrop.addSubview(pattern)
  1048. addSubview(paywallView)
  1049. NSLayoutConstraint.activate([
  1050. blurView.leadingAnchor.constraint(equalTo: leadingAnchor),
  1051. blurView.trailingAnchor.constraint(equalTo: trailingAnchor),
  1052. blurView.topAnchor.constraint(equalTo: topAnchor),
  1053. blurView.bottomAnchor.constraint(equalTo: bottomAnchor),
  1054. backdrop.leadingAnchor.constraint(equalTo: leadingAnchor),
  1055. backdrop.trailingAnchor.constraint(equalTo: trailingAnchor),
  1056. backdrop.topAnchor.constraint(equalTo: topAnchor),
  1057. backdrop.bottomAnchor.constraint(equalTo: bottomAnchor),
  1058. pattern.leadingAnchor.constraint(equalTo: backdrop.leadingAnchor),
  1059. pattern.trailingAnchor.constraint(equalTo: backdrop.trailingAnchor),
  1060. pattern.topAnchor.constraint(equalTo: backdrop.topAnchor),
  1061. pattern.bottomAnchor.constraint(equalTo: backdrop.bottomAnchor),
  1062. paywallView.leadingAnchor.constraint(equalTo: leadingAnchor),
  1063. paywallView.trailingAnchor.constraint(equalTo: trailingAnchor),
  1064. paywallView.topAnchor.constraint(equalTo: topAnchor),
  1065. paywallView.bottomAnchor.constraint(equalTo: bottomAnchor),
  1066. ])
  1067. }
  1068. func present(in parent: NSView) {
  1069. guard superview == nil else { return }
  1070. if let window = parent.window,
  1071. let windowFrameView = window.contentView?.superview {
  1072. windowFrameView.addSubview(self)
  1073. NSLayoutConstraint.activate([
  1074. leadingAnchor.constraint(equalTo: windowFrameView.leadingAnchor),
  1075. trailingAnchor.constraint(equalTo: windowFrameView.trailingAnchor),
  1076. topAnchor.constraint(equalTo: windowFrameView.topAnchor),
  1077. bottomAnchor.constraint(equalTo: windowFrameView.bottomAnchor),
  1078. ])
  1079. } else {
  1080. parent.addSubview(self)
  1081. NSLayoutConstraint.activate([
  1082. leadingAnchor.constraint(equalTo: parent.leadingAnchor),
  1083. trailingAnchor.constraint(equalTo: parent.trailingAnchor),
  1084. topAnchor.constraint(equalTo: parent.topAnchor),
  1085. bottomAnchor.constraint(equalTo: parent.bottomAnchor),
  1086. ])
  1087. }
  1088. alphaValue = 0
  1089. Task { @MainActor in
  1090. if StoreManager.shared.products.isEmpty {
  1091. await StoreManager.shared.loadProducts()
  1092. }
  1093. await StoreManager.shared.refreshPremiumStatus()
  1094. paywallView.refreshStoreState()
  1095. alphaValue = 1
  1096. }
  1097. }
  1098. func dismiss() {
  1099. NSAnimationContext.runAnimationGroup({ context in
  1100. context.duration = 0.15
  1101. animator().alphaValue = 0
  1102. }, completionHandler: { [weak self] in
  1103. self?.removeFromSuperview()
  1104. self?.onDismiss?()
  1105. })
  1106. }
  1107. }