PaywallView.swift 49 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397
  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: - Close Button
  624. private final class PaywallCloseButton: NSButton, AppearanceRefreshable {
  625. private var hoverTracker: HoverTracker?
  626. private var isHovered = false
  627. init() {
  628. super.init(frame: .zero)
  629. isBordered = false
  630. bezelStyle = .inline
  631. translatesAutoresizingMaskIntoConstraints = false
  632. wantsLayer = true
  633. layer?.cornerRadius = 15
  634. layer?.borderWidth = 1.5
  635. if let image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close") {
  636. let config = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
  637. self.image = image.withSymbolConfiguration(config)
  638. }
  639. refreshAppearance()
  640. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  641. self?.setHovered(hovering)
  642. }
  643. }
  644. @available(*, unavailable)
  645. required init?(coder: NSCoder) { nil }
  646. func refreshAppearance() {
  647. updateAppearance()
  648. }
  649. private func setHovered(_ hovering: Bool) {
  650. isHovered = hovering
  651. animateHover {
  652. updateAppearance()
  653. layer?.transform = hovering
  654. ? CATransform3DMakeScale(1.06, 1.06, 1)
  655. : CATransform3DIdentity
  656. }
  657. }
  658. private func updateAppearance() {
  659. if isHovered {
  660. layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
  661. layer?.borderColor = AppTheme.paywallAccent.withAlphaComponent(0.45).cgColor
  662. contentTintColor = AppTheme.textPrimary
  663. } else {
  664. layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor
  665. layer?.borderColor = AppTheme.paywallBorder.cgColor
  666. contentTintColor = AppTheme.textSecondary
  667. }
  668. }
  669. override func resetCursorRects() {
  670. addCursorRect(bounds, cursor: .pointingHand)
  671. }
  672. }
  673. // MARK: - CTA Button
  674. private final class PaywallCTAButton: NSButton, AppearanceRefreshable {
  675. private var hoverTracker: HoverTracker?
  676. init() {
  677. super.init(frame: .zero)
  678. isBordered = false
  679. wantsLayer = true
  680. layer?.cornerRadius = 12
  681. font = AppTheme.semiboldFont(size: 15)
  682. translatesAutoresizingMaskIntoConstraints = false
  683. refreshAppearance()
  684. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  685. self?.setHovered(hovering)
  686. }
  687. }
  688. @available(*, unavailable)
  689. required init?(coder: NSCoder) { nil }
  690. func refreshAppearance() {
  691. layer?.backgroundColor = AppTheme.paywallCTABackground.cgColor
  692. contentTintColor = AppTheme.paywallCTAForeground
  693. }
  694. private func setHovered(_ hovering: Bool) {
  695. let base = AppTheme.paywallCTABackground
  696. let color = hovering ? base.blended(withFraction: 0.12, of: .black) ?? base : base
  697. animateHover {
  698. layer?.backgroundColor = color.cgColor
  699. layer?.transform = hovering
  700. ? CATransform3DMakeScale(1.02, 1.02, 1)
  701. : CATransform3DIdentity
  702. }
  703. }
  704. override func resetCursorRects() {
  705. addCursorRect(bounds, cursor: .pointingHand)
  706. }
  707. }
  708. // MARK: - Main Paywall Card
  709. final class PaywallView: NSView, AppearanceRefreshable {
  710. var onClose: (() -> Void)?
  711. var onPurchaseSucceeded: (() -> Void)?
  712. private var selectedPlan: PaywallPlan = .yearly
  713. private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
  714. private let ctaButton = PaywallCTAButton()
  715. private let continueFreePlanLink = PaywallFooterLink(title: "Continue with free plan")
  716. private let manageSubscriptionLink = PaywallFooterLink(title: "Manage Subscription")
  717. private let premiumCloseButton = PaywallCloseButton()
  718. private var primaryFooterLinkCell: NSView?
  719. private var leftPanelTitle: NSTextField!
  720. private var rightTitle: NSTextField!
  721. private var rightSubtitle: NSTextField!
  722. private var trustStack: NSStackView!
  723. private var storeObservers: [NSObjectProtocol] = []
  724. init() {
  725. super.init(frame: .zero)
  726. translatesAutoresizingMaskIntoConstraints = false
  727. wantsLayer = true
  728. layer?.cornerRadius = 0
  729. setup()
  730. observeStoreUpdates()
  731. refreshProductDisplay()
  732. refreshAppearance()
  733. }
  734. deinit {
  735. storeObservers.forEach { NotificationCenter.default.removeObserver($0) }
  736. }
  737. private func observeStoreUpdates() {
  738. let center = NotificationCenter.default
  739. storeObservers = [
  740. center.addObserver(forName: .storeProductsDidUpdate, object: nil, queue: .main) { [weak self] _ in
  741. self?.refreshProductDisplay()
  742. },
  743. center.addObserver(forName: .storeStateDidChange, object: nil, queue: .main) { [weak self] _ in
  744. self?.refreshPurchaseState()
  745. },
  746. center.addObserver(forName: .premiumStatusDidChange, object: nil, queue: .main) { [weak self] _ in
  747. self?.refreshPurchaseState()
  748. },
  749. ]
  750. }
  751. private func refreshProductDisplay() {
  752. let store = StoreManager.shared
  753. for (plan, card) in planCards {
  754. card.updateDisplay(product: store.product(for: plan))
  755. }
  756. ctaButton.title = selectedPlan.localizedCTATitle(from: store.product(for: selectedPlan))
  757. refreshPurchaseState()
  758. }
  759. private func refreshPurchaseState() {
  760. let store = StoreManager.shared
  761. let isBusy = store.isPurchasing || store.isLoadingProducts
  762. ctaButton.isEnabled = !isBusy
  763. ctaButton.alphaValue = isBusy ? 0.65 : 1
  764. refreshPrimaryFooterLink()
  765. }
  766. private func refreshPrimaryFooterLink() {
  767. let isPro = StoreManager.shared.isPro
  768. continueFreePlanLink.isHidden = isPro
  769. manageSubscriptionLink.isHidden = !isPro
  770. premiumCloseButton.isHidden = !isPro
  771. primaryFooterLinkCell?.needsLayout = true
  772. primaryFooterLinkCell?.layoutSubtreeIfNeeded()
  773. }
  774. func refreshStoreState() {
  775. refreshProductDisplay()
  776. }
  777. func refreshAppearance() {
  778. layer?.backgroundColor = AppTheme.paywallBackground.cgColor
  779. leftPanelTitle?.refreshThemeLabelColor()
  780. rightTitle?.refreshThemeLabelColor()
  781. rightSubtitle?.refreshThemeLabelColor()
  782. trustStack?.layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor
  783. trustStack?.layer?.borderColor = AppTheme.paywallBorder.cgColor
  784. ctaButton.refreshAppearance()
  785. subviews.forEach { $0.refreshAppearanceRecursively() }
  786. }
  787. @available(*, unavailable)
  788. required init?(coder: NSCoder) { nil }
  789. private func setup() {
  790. let leftPanel = makeLeftPanel()
  791. let rightPanel = makeRightPanel()
  792. premiumCloseButton.target = self
  793. premiumCloseButton.action = #selector(premiumCloseTapped)
  794. premiumCloseButton.isHidden = true
  795. addSubview(leftPanel)
  796. addSubview(rightPanel)
  797. addSubview(premiumCloseButton)
  798. NSLayoutConstraint.activate([
  799. leftPanel.leadingAnchor.constraint(equalTo: leadingAnchor),
  800. leftPanel.topAnchor.constraint(equalTo: topAnchor),
  801. leftPanel.bottomAnchor.constraint(equalTo: bottomAnchor),
  802. leftPanel.widthAnchor.constraint(equalToConstant: 320),
  803. rightPanel.leadingAnchor.constraint(equalTo: leftPanel.trailingAnchor),
  804. rightPanel.trailingAnchor.constraint(equalTo: trailingAnchor),
  805. rightPanel.topAnchor.constraint(equalTo: topAnchor),
  806. rightPanel.bottomAnchor.constraint(equalTo: bottomAnchor),
  807. premiumCloseButton.topAnchor.constraint(equalTo: topAnchor, constant: 16),
  808. premiumCloseButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
  809. premiumCloseButton.widthAnchor.constraint(equalToConstant: 30),
  810. premiumCloseButton.heightAnchor.constraint(equalToConstant: 30),
  811. ])
  812. }
  813. private func makeLeftPanel() -> NSView {
  814. let panel = PaywallLeftPanelView()
  815. panel.translatesAutoresizingMaskIntoConstraints = false
  816. let title = NSTextField.themeLabel(
  817. "Unlock Your Full\nPrinting Potential",
  818. style: .primary,
  819. font: AppTheme.semiboldFont(size: 22)
  820. )
  821. title.maximumNumberOfLines = 2
  822. title.translatesAutoresizingMaskIntoConstraints = false
  823. leftPanelTitle = title
  824. let featuresStack = NSStackView()
  825. featuresStack.orientation = .vertical
  826. featuresStack.spacing = 6
  827. featuresStack.alignment = .leading
  828. featuresStack.translatesAutoresizingMaskIntoConstraints = false
  829. let features = [
  830. "Unlimited high-quality scans",
  831. "Advanced OCR technology",
  832. "Direct cloud printing",
  833. "Ad-free experience",
  834. "Priority support",
  835. "Secure storage",
  836. ]
  837. for feature in features {
  838. featuresStack.addArrangedSubview(PaywallFeatureRow(text: feature))
  839. }
  840. panel.addSubview(title)
  841. panel.addSubview(featuresStack)
  842. NSLayoutConstraint.activate([
  843. title.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  844. title.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -20),
  845. title.topAnchor.constraint(equalTo: panel.topAnchor, constant: 48),
  846. featuresStack.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  847. featuresStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -20),
  848. featuresStack.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 28),
  849. ])
  850. return panel
  851. }
  852. private func makeRightPanel() -> NSView {
  853. let panel = NSView()
  854. panel.translatesAutoresizingMaskIntoConstraints = false
  855. let title = NSTextField.themeLabel("Go Premium", style: .primary, font: AppTheme.semiboldFont(size: 26))
  856. title.alignment = .center
  857. title.translatesAutoresizingMaskIntoConstraints = false
  858. rightTitle = title
  859. let subtitle = NSTextField.themeLabel(
  860. "Experience professional quality printing and scanning without limits.",
  861. style: .secondary,
  862. font: AppTheme.regularFont(size: 13)
  863. )
  864. subtitle.alignment = .center
  865. subtitle.maximumNumberOfLines = 2
  866. subtitle.translatesAutoresizingMaskIntoConstraints = false
  867. rightSubtitle = subtitle
  868. let plansStack = NSStackView()
  869. plansStack.orientation = .vertical
  870. plansStack.spacing = 12
  871. plansStack.translatesAutoresizingMaskIntoConstraints = false
  872. for plan in PaywallPlan.allCases {
  873. let card = PaywallPlanCard(plan: plan)
  874. card.isChosen = plan == selectedPlan
  875. card.onSelect = { [weak self] in self?.selectPlan(plan) }
  876. planCards[plan] = card
  877. plansStack.addArrangedSubview(card)
  878. }
  879. ctaButton.title = selectedPlan.localizedCTATitle(from: StoreManager.shared.product(for: selectedPlan))
  880. ctaButton.target = self
  881. ctaButton.action = #selector(purchaseTapped)
  882. ctaButton.translatesAutoresizingMaskIntoConstraints = false
  883. let trustRow = makeTrustRow()
  884. let footerLinks = makeFooterLinks()
  885. panel.addSubview(title)
  886. panel.addSubview(subtitle)
  887. panel.addSubview(plansStack)
  888. panel.addSubview(trustRow)
  889. panel.addSubview(ctaButton)
  890. panel.addSubview(footerLinks)
  891. NSLayoutConstraint.activate([
  892. title.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  893. title.topAnchor.constraint(equalTo: panel.topAnchor, constant: 40),
  894. title.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  895. subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  896. subtitle.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  897. subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6),
  898. plansStack.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  899. plansStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  900. plansStack.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 24),
  901. trustRow.leadingAnchor.constraint(equalTo: plansStack.leadingAnchor),
  902. trustRow.trailingAnchor.constraint(equalTo: plansStack.trailingAnchor),
  903. trustRow.topAnchor.constraint(equalTo: plansStack.bottomAnchor, constant: 18),
  904. ctaButton.leadingAnchor.constraint(equalTo: plansStack.leadingAnchor),
  905. ctaButton.trailingAnchor.constraint(equalTo: plansStack.trailingAnchor),
  906. ctaButton.topAnchor.constraint(equalTo: trustRow.bottomAnchor, constant: 16),
  907. ctaButton.heightAnchor.constraint(equalToConstant: 48),
  908. ctaButton.bottomAnchor.constraint(lessThanOrEqualTo: footerLinks.topAnchor, constant: -14),
  909. footerLinks.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  910. footerLinks.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  911. footerLinks.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -20),
  912. ])
  913. return panel
  914. }
  915. private func makeTrustRow() -> NSView {
  916. let securePayments = PaywallTrustItemView(
  917. iconName: "shield.fill",
  918. title: "Secure Payments",
  919. subtitle: "Your payment is 100% secure"
  920. )
  921. let cancelAnytime = PaywallTrustItemView(
  922. iconName: "arrow.counterclockwise",
  923. title: "Cancel Anytime",
  924. subtitle: "No commitment, cancel anytime."
  925. )
  926. let support = PaywallTrustItemView(
  927. iconName: "headphones",
  928. title: "24/7 Support",
  929. subtitle: "We're here to help you anytime."
  930. )
  931. let privacyFirst = PaywallTrustItemView(
  932. iconName: "lock.fill",
  933. title: "Privacy First",
  934. subtitle: "Your data is safe with us."
  935. )
  936. let trustStack = NSStackView(views: [securePayments, cancelAnytime, support, privacyFirst])
  937. trustStack.orientation = .horizontal
  938. trustStack.distribution = .fillEqually
  939. trustStack.spacing = 16
  940. trustStack.alignment = .top
  941. trustStack.translatesAutoresizingMaskIntoConstraints = false
  942. trustStack.wantsLayer = true
  943. trustStack.layer?.cornerRadius = 12
  944. trustStack.layer?.borderWidth = 1.5
  945. trustStack.layer?.masksToBounds = true
  946. trustStack.edgeInsets = NSEdgeInsets(top: 12, left: 14, bottom: 12, right: 14)
  947. trustStack.translatesAutoresizingMaskIntoConstraints = false
  948. self.trustStack = trustStack
  949. return trustStack
  950. }
  951. private func makeFooterLinks() -> NSView {
  952. let container = NSView()
  953. container.translatesAutoresizingMaskIntoConstraints = false
  954. continueFreePlanLink.target = self
  955. continueFreePlanLink.action = #selector(continueWithFreePlanTapped)
  956. manageSubscriptionLink.target = self
  957. manageSubscriptionLink.action = #selector(manageSubscriptionTapped)
  958. let primaryCell = makePrimaryFooterLinkCell()
  959. primaryFooterLinkCell = primaryCell
  960. refreshPrimaryFooterLink()
  961. let restoreLink = PaywallFooterLink(title: "Restore Purchase")
  962. restoreLink.target = self
  963. restoreLink.action = #selector(restoreTapped)
  964. let privacyLink = PaywallFooterLink(title: "Privacy Policy")
  965. let termsLink = PaywallFooterLink(title: "Terms of Service")
  966. let supportLink = PaywallFooterLink(title: "Support")
  967. let linkCells = [
  968. primaryCell,
  969. makeFooterLinkCell(link: restoreLink),
  970. makeFooterLinkCell(link: privacyLink),
  971. makeFooterLinkCell(link: termsLink),
  972. makeFooterLinkCell(link: supportLink),
  973. ]
  974. let linksStack = NSStackView(views: linkCells)
  975. linksStack.orientation = .horizontal
  976. linksStack.spacing = 0
  977. linksStack.alignment = .centerY
  978. linksStack.distribution = .fillEqually
  979. linksStack.translatesAutoresizingMaskIntoConstraints = false
  980. container.addSubview(linksStack)
  981. NSLayoutConstraint.activate([
  982. linksStack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  983. linksStack.trailingAnchor.constraint(equalTo: container.trailingAnchor),
  984. linksStack.topAnchor.constraint(equalTo: container.topAnchor),
  985. linksStack.bottomAnchor.constraint(equalTo: container.bottomAnchor),
  986. ])
  987. return container
  988. }
  989. private func makePrimaryFooterLinkCell() -> NSView {
  990. let cell = NSView()
  991. cell.translatesAutoresizingMaskIntoConstraints = false
  992. for link in [continueFreePlanLink, manageSubscriptionLink] {
  993. cell.addSubview(link)
  994. NSLayoutConstraint.activate([
  995. link.centerXAnchor.constraint(equalTo: cell.centerXAnchor),
  996. link.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
  997. link.topAnchor.constraint(equalTo: cell.topAnchor),
  998. link.bottomAnchor.constraint(equalTo: cell.bottomAnchor),
  999. ])
  1000. }
  1001. return cell
  1002. }
  1003. private func makeFooterLinkCell(link: PaywallFooterLink) -> NSView {
  1004. let cell = NSView()
  1005. cell.translatesAutoresizingMaskIntoConstraints = false
  1006. cell.addSubview(link)
  1007. NSLayoutConstraint.activate([
  1008. link.centerXAnchor.constraint(equalTo: cell.centerXAnchor),
  1009. link.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
  1010. link.topAnchor.constraint(equalTo: cell.topAnchor),
  1011. link.bottomAnchor.constraint(equalTo: cell.bottomAnchor),
  1012. ])
  1013. return cell
  1014. }
  1015. private func selectPlan(_ plan: PaywallPlan) {
  1016. selectedPlan = plan
  1017. for (key, card) in planCards {
  1018. card.isChosen = key == plan
  1019. }
  1020. ctaButton.title = plan.localizedCTATitle(from: StoreManager.shared.product(for: plan))
  1021. }
  1022. @objc private func purchaseTapped() {
  1023. Task { @MainActor in
  1024. refreshPurchaseState()
  1025. do {
  1026. let succeeded = try await StoreManager.shared.purchase(plan: selectedPlan)
  1027. refreshStoreState()
  1028. if succeeded {
  1029. onPurchaseSucceeded?()
  1030. }
  1031. } catch {
  1032. refreshPurchaseState()
  1033. StoreManager.shared.showPurchaseError(error, on: window)
  1034. }
  1035. }
  1036. }
  1037. @objc private func restoreTapped() {
  1038. Task { @MainActor in
  1039. refreshPurchaseState()
  1040. do {
  1041. let restored = try await StoreManager.shared.restorePurchases()
  1042. refreshStoreState()
  1043. if restored {
  1044. onPurchaseSucceeded?()
  1045. } else {
  1046. StoreManager.shared.showAlert(
  1047. title: "No Purchases Found",
  1048. message: "We couldn't find any previous purchases for this Apple ID.",
  1049. on: window
  1050. )
  1051. }
  1052. } catch {
  1053. refreshPurchaseState()
  1054. StoreManager.shared.showPurchaseError(error, on: window)
  1055. }
  1056. }
  1057. }
  1058. @objc private func continueWithFreePlanTapped() {
  1059. onClose?()
  1060. }
  1061. @objc private func premiumCloseTapped() {
  1062. onClose?()
  1063. }
  1064. @objc private func manageSubscriptionTapped() {
  1065. StoreManager.shared.showManageSubscriptions()
  1066. }
  1067. }
  1068. // MARK: - Overlay Presenter
  1069. final class PaywallOverlayView: NSView, AppearanceRefreshable {
  1070. var onDismiss: (() -> Void)?
  1071. private let paywallView: PaywallView
  1072. private let blurView = NSVisualEffectView()
  1073. private let backdrop = NSView()
  1074. private let pattern = WavePatternView()
  1075. init() {
  1076. paywallView = PaywallView()
  1077. super.init(frame: .zero)
  1078. translatesAutoresizingMaskIntoConstraints = false
  1079. setup()
  1080. refreshAppearance()
  1081. }
  1082. func refreshAppearance() {
  1083. backdrop.layer?.backgroundColor = AppTheme.paywallOverlayBackdrop.cgColor
  1084. blurView.material = AppSettings.darkModeEnabled ? .hudWindow : .underWindowBackground
  1085. pattern.refreshAppearance()
  1086. paywallView.refreshAppearance()
  1087. }
  1088. func refreshStoreState() {
  1089. paywallView.refreshStoreState()
  1090. }
  1091. @available(*, unavailable)
  1092. required init?(coder: NSCoder) { nil }
  1093. private func setup() {
  1094. blurView.translatesAutoresizingMaskIntoConstraints = false
  1095. blurView.material = .underWindowBackground
  1096. blurView.blendingMode = .withinWindow
  1097. blurView.state = .active
  1098. backdrop.translatesAutoresizingMaskIntoConstraints = false
  1099. backdrop.wantsLayer = true
  1100. backdrop.layer?.backgroundColor = NSColor(calibratedWhite: 0.15, alpha: 0.22).cgColor
  1101. pattern.translatesAutoresizingMaskIntoConstraints = false
  1102. pattern.alphaValue = 0.35
  1103. paywallView.translatesAutoresizingMaskIntoConstraints = false
  1104. paywallView.onClose = { [weak self] in self?.dismiss() }
  1105. paywallView.onPurchaseSucceeded = { [weak self] in
  1106. self?.paywallView.refreshStoreState()
  1107. }
  1108. addSubview(blurView)
  1109. addSubview(backdrop)
  1110. backdrop.addSubview(pattern)
  1111. addSubview(paywallView)
  1112. NSLayoutConstraint.activate([
  1113. blurView.leadingAnchor.constraint(equalTo: leadingAnchor),
  1114. blurView.trailingAnchor.constraint(equalTo: trailingAnchor),
  1115. blurView.topAnchor.constraint(equalTo: topAnchor),
  1116. blurView.bottomAnchor.constraint(equalTo: bottomAnchor),
  1117. backdrop.leadingAnchor.constraint(equalTo: leadingAnchor),
  1118. backdrop.trailingAnchor.constraint(equalTo: trailingAnchor),
  1119. backdrop.topAnchor.constraint(equalTo: topAnchor),
  1120. backdrop.bottomAnchor.constraint(equalTo: bottomAnchor),
  1121. pattern.leadingAnchor.constraint(equalTo: backdrop.leadingAnchor),
  1122. pattern.trailingAnchor.constraint(equalTo: backdrop.trailingAnchor),
  1123. pattern.topAnchor.constraint(equalTo: backdrop.topAnchor),
  1124. pattern.bottomAnchor.constraint(equalTo: backdrop.bottomAnchor),
  1125. paywallView.leadingAnchor.constraint(equalTo: leadingAnchor),
  1126. paywallView.trailingAnchor.constraint(equalTo: trailingAnchor),
  1127. paywallView.topAnchor.constraint(equalTo: topAnchor),
  1128. paywallView.bottomAnchor.constraint(equalTo: bottomAnchor),
  1129. ])
  1130. }
  1131. func present(in parent: NSView) {
  1132. guard superview == nil else { return }
  1133. if let window = parent.window,
  1134. let windowFrameView = window.contentView?.superview {
  1135. windowFrameView.addSubview(self)
  1136. NSLayoutConstraint.activate([
  1137. leadingAnchor.constraint(equalTo: windowFrameView.leadingAnchor),
  1138. trailingAnchor.constraint(equalTo: windowFrameView.trailingAnchor),
  1139. topAnchor.constraint(equalTo: windowFrameView.topAnchor),
  1140. bottomAnchor.constraint(equalTo: windowFrameView.bottomAnchor),
  1141. ])
  1142. } else {
  1143. parent.addSubview(self)
  1144. NSLayoutConstraint.activate([
  1145. leadingAnchor.constraint(equalTo: parent.leadingAnchor),
  1146. trailingAnchor.constraint(equalTo: parent.trailingAnchor),
  1147. topAnchor.constraint(equalTo: parent.topAnchor),
  1148. bottomAnchor.constraint(equalTo: parent.bottomAnchor),
  1149. ])
  1150. }
  1151. alphaValue = 0
  1152. Task { @MainActor in
  1153. if StoreManager.shared.products.isEmpty {
  1154. await StoreManager.shared.loadProducts()
  1155. }
  1156. await StoreManager.shared.refreshPremiumStatus()
  1157. paywallView.refreshStoreState()
  1158. alphaValue = 1
  1159. }
  1160. }
  1161. func dismiss() {
  1162. NSAnimationContext.runAnimationGroup({ context in
  1163. context.duration = 0.15
  1164. animator().alphaValue = 0
  1165. }, completionHandler: { [weak self] in
  1166. self?.removeFromSuperview()
  1167. self?.onDismiss?()
  1168. })
  1169. }
  1170. }