PaywallView.swift 46 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322
  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. }