PaywallView.swift 72 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973
  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. func planCopy(from config: PaywallConfig) -> PaywallConfig.PlanCopy {
  16. switch self {
  17. case .monthly: config.plans.monthly
  18. case .yearly: config.plans.yearly
  19. case .lifetime: config.plans.lifetime
  20. }
  21. }
  22. func localizedPrice(from product: Product?, config: PaywallConfig) -> String {
  23. product?.displayPrice ?? config.loadingPrice
  24. }
  25. func localizedSubtitle(
  26. from product: Product?,
  27. config: PaywallConfig,
  28. introOffer: Product.SubscriptionOffer? = nil
  29. ) -> String {
  30. guard let product else { return config.loadingPrice }
  31. let copy = planCopy(from: config)
  32. let price = product.displayPrice
  33. if self == .yearly, let introOffer, let trialTemplate = copy.trialSubtitleTemplate {
  34. let duration = config.trialDurationDescription(from: introOffer, lowercase: true)
  35. return trialTemplate
  36. .replacingOccurrences(of: "{trial}", with: duration)
  37. .replacingOccurrences(of: "{price}", with: price)
  38. }
  39. return copy.subtitleTemplate.replacingOccurrences(of: "{price}", with: price)
  40. }
  41. func localizedCTATitle(
  42. from product: Product?,
  43. config: PaywallConfig,
  44. introOffer: Product.SubscriptionOffer? = nil
  45. ) -> String {
  46. guard let product else { return config.loadingCTA }
  47. let copy = planCopy(from: config)
  48. let price = product.displayPrice
  49. if self == .yearly, let introOffer {
  50. return config.trialCTATitle(from: introOffer)
  51. }
  52. return copy.ctaTemplate.replacingOccurrences(of: "{price}", with: price)
  53. }
  54. func localizedRenewalDisclosure(
  55. from product: Product?,
  56. config: PaywallConfig,
  57. introOffer: Product.SubscriptionOffer? = nil
  58. ) -> String {
  59. switch self {
  60. case .lifetime:
  61. return config.lifetimeDisclosure
  62. case .monthly, .yearly:
  63. guard let product else { return config.loadingDisclosure }
  64. let price = product.displayPrice
  65. let period = product.subscription.map {
  66. config.subscriptionPeriodDescription(from: $0.subscriptionPeriod)
  67. } ?? config.messages.periodFallback
  68. if self == .yearly, let introOffer {
  69. let trial = config.trialDurationDescription(from: introOffer, lowercase: true)
  70. return config.trialDisclosureTemplate
  71. .replacingOccurrences(of: "{trial}", with: trial)
  72. .replacingOccurrences(of: "{price}", with: price)
  73. .replacingOccurrences(of: "{period}", with: period)
  74. }
  75. return config.subscriptionDisclosureTemplate
  76. .replacingOccurrences(of: "{price}", with: price)
  77. .replacingOccurrences(of: "{period}", with: period)
  78. }
  79. }
  80. }
  81. // MARK: - StoreKit
  82. enum StoreProductID {
  83. static let monthly = AppStoreConfig.ProductID.monthly
  84. static let yearly = AppStoreConfig.ProductID.yearly
  85. static let lifetime = AppStoreConfig.ProductID.lifetime
  86. static let all = AppStoreConfig.ProductID.all
  87. }
  88. enum PremiumAccessKind: String {
  89. case none
  90. case subscription
  91. case lifetime
  92. }
  93. enum StoreError: Error {
  94. case productNotFound
  95. case failedVerification
  96. }
  97. enum PurchaseOutcome: Sendable {
  98. case purchased
  99. case cancelled
  100. case pending
  101. }
  102. @MainActor
  103. final class StoreManager {
  104. static let shared = StoreManager()
  105. private static let cachedPremiumKey = "StoreManager.cachedIsPremium"
  106. private static let cachedPremiumKindKey = "StoreManager.cachedPremiumAccessKind"
  107. private(set) var products: [Product] = []
  108. private(set) var isPremium = UserDefaults.standard.bool(forKey: cachedPremiumKey)
  109. private(set) var premiumAccessKind: PremiumAccessKind = {
  110. guard let raw = UserDefaults.standard.string(forKey: cachedPremiumKindKey),
  111. let kind = PremiumAccessKind(rawValue: raw) else {
  112. return .none
  113. }
  114. return kind
  115. }()
  116. var isPro: Bool { isPremium }
  117. var hasActiveSubscription: Bool { premiumAccessKind == .subscription }
  118. var hasLifetimeAccess: Bool { premiumAccessKind == .lifetime }
  119. private(set) var isLoadingProducts = false
  120. private(set) var productLoadError: String?
  121. private(set) var isPurchasing = false
  122. private(set) var isResolvingEntitlements = true
  123. private(set) var introOfferEligibleByProductID: [String: Bool] = [:]
  124. private var transactionListener: Task<Void, Never>?
  125. private var initialEntitlementTask: Task<Void, Never>?
  126. private var hasStarted = false
  127. private init() {}
  128. func start() {
  129. guard !hasStarted else { return }
  130. hasStarted = true
  131. transactionListener = Task { [weak self] in
  132. for await update in Transaction.updates {
  133. await self?.handleTransactionUpdate(update)
  134. }
  135. }
  136. initialEntitlementTask = Task { [weak self] in
  137. guard let self else { return }
  138. await self.refreshEntitlementsOnly()
  139. self.isResolvingEntitlements = false
  140. NotificationCenter.default.post(name: .entitlementsDidResolve, object: nil)
  141. await self.loadProducts()
  142. await self.refreshPremiumStatus()
  143. }
  144. }
  145. func ensureEntitlementsResolved() async {
  146. await initialEntitlementTask?.value
  147. }
  148. func product(for plan: PaywallPlan) -> Product? {
  149. products.first { $0.id == plan.productID }
  150. }
  151. func eligibleIntroOffer(for plan: PaywallPlan) -> Product.SubscriptionOffer? {
  152. guard let product = product(for: plan) else { return nil }
  153. return eligibleIntroOffer(for: product)
  154. }
  155. func eligibleIntroOffer(for product: Product) -> Product.SubscriptionOffer? {
  156. guard introOfferEligibleByProductID[product.id] == true,
  157. let offer = product.subscription?.introductoryOffer,
  158. offer.paymentMode == .freeTrial else {
  159. return nil
  160. }
  161. return offer
  162. }
  163. func loadProducts() async {
  164. isLoadingProducts = true
  165. productLoadError = nil
  166. postStoreStateDidChange()
  167. defer {
  168. isLoadingProducts = false
  169. postStoreStateDidChange()
  170. }
  171. do {
  172. let loaded = try await Product.products(for: StoreProductID.all)
  173. guard !loaded.isEmpty else {
  174. productLoadError = PaywallConfigService.shared.config.messages.noPlansAvailable
  175. products = []
  176. return
  177. }
  178. products = loaded.sorted { lhs, rhs in
  179. productSortOrder(for: lhs.id) < productSortOrder(for: rhs.id)
  180. }
  181. productLoadError = nil
  182. await refreshIntroOfferEligibility()
  183. NotificationCenter.default.post(name: .storeProductsDidUpdate, object: nil)
  184. } catch {
  185. productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
  186. NSLog("Failed to load products: \(error.localizedDescription)")
  187. }
  188. }
  189. @discardableResult
  190. func purchase(plan: PaywallPlan) async throws -> PurchaseOutcome {
  191. if products.isEmpty {
  192. await loadProducts()
  193. }
  194. guard let product = product(for: plan) else {
  195. throw StoreError.productNotFound
  196. }
  197. isPurchasing = true
  198. postStoreStateDidChange()
  199. defer {
  200. isPurchasing = false
  201. postStoreStateDidChange()
  202. }
  203. let result = try await product.purchase()
  204. switch result {
  205. case .success(let verification):
  206. let transaction = try checkVerified(verification)
  207. await transaction.finish()
  208. await refreshPremiumStatus()
  209. return isPremium ? .purchased : .cancelled
  210. case .userCancelled:
  211. return .cancelled
  212. case .pending:
  213. return .pending
  214. @unknown default:
  215. return .cancelled
  216. }
  217. }
  218. @discardableResult
  219. func restorePurchases() async throws -> Bool {
  220. isPurchasing = true
  221. postStoreStateDidChange()
  222. defer {
  223. isPurchasing = false
  224. postStoreStateDidChange()
  225. }
  226. try await AppStore.sync()
  227. await refreshPremiumStatus()
  228. return isPremium
  229. }
  230. func showAlert(title: String, message: String, on window: NSWindow?) {
  231. let alert = NSAlert()
  232. alert.messageText = title
  233. alert.informativeText = message
  234. alert.alertStyle = .informational
  235. alert.addButton(withTitle: PaywallConfigService.shared.config.messages.alertOK)
  236. if let window {
  237. alert.beginSheetModal(for: window)
  238. } else {
  239. alert.runModal()
  240. }
  241. }
  242. func showManageSubscriptions() {
  243. let urlString = PaywallConfigService.shared.config.urls.manageSubscriptions
  244. guard let url = URL(string: urlString) else { return }
  245. NSWorkspace.shared.open(url)
  246. }
  247. func showPurchaseError(_ error: Error, on window: NSWindow?) {
  248. let messages = PaywallConfigService.shared.config.messages
  249. switch error {
  250. case StoreError.productNotFound:
  251. showAlert(title: messages.purchaseFailedTitle, message: messages.productNotFound, on: window)
  252. case StoreError.failedVerification:
  253. showAlert(title: messages.purchaseFailedTitle, message: messages.failedVerification, on: window)
  254. default:
  255. if let storeKitError = error as? StoreKitError, case .userCancelled = storeKitError {
  256. return
  257. }
  258. showAlert(title: messages.purchaseFailedTitle, message: error.localizedDescription, on: window)
  259. }
  260. }
  261. private func handleTransactionUpdate(_ update: VerificationResult<Transaction>) async {
  262. do {
  263. let transaction = try checkVerified(update)
  264. await transaction.finish()
  265. await refreshPremiumStatus()
  266. } catch {
  267. NSLog("Transaction verification failed: \(error.localizedDescription)")
  268. }
  269. }
  270. func refreshEntitlementsOnly() async {
  271. applyPremiumStatus(await resolvePremiumAccessKind())
  272. }
  273. func refreshPremiumStatus() async {
  274. await refreshIntroOfferEligibility()
  275. applyPremiumStatus(await resolvePremiumAccessKind())
  276. }
  277. private func applyPremiumStatus(_ kind: PremiumAccessKind) {
  278. let hasPremium = kind != .none
  279. let didChange = hasPremium != isPremium || kind != premiumAccessKind
  280. isPremium = hasPremium
  281. premiumAccessKind = hasPremium ? kind : .none
  282. UserDefaults.standard.set(hasPremium, forKey: Self.cachedPremiumKey)
  283. UserDefaults.standard.set(premiumAccessKind.rawValue, forKey: Self.cachedPremiumKindKey)
  284. if didChange {
  285. NotificationCenter.default.post(name: .premiumStatusDidChange, object: nil)
  286. }
  287. postStoreStateDidChange()
  288. }
  289. private func resolvePremiumAccessKind() async -> PremiumAccessKind {
  290. if await hasActiveEntitlement(for: StoreProductID.lifetime) {
  291. return .lifetime
  292. }
  293. if await hasActiveEntitlement(for: StoreProductID.monthly) {
  294. return .subscription
  295. }
  296. if await hasActiveEntitlement(for: StoreProductID.yearly) {
  297. return .subscription
  298. }
  299. if await hasEntitlementFromSubscriptionStatus() {
  300. return .subscription
  301. }
  302. return .none
  303. }
  304. private func hasActiveEntitlement(for productID: String) async -> Bool {
  305. for await result in Transaction.currentEntitlements {
  306. guard let transaction = try? checkVerified(result) else { continue }
  307. if transaction.productID == productID, isActivePremiumTransaction(transaction) {
  308. return true
  309. }
  310. }
  311. guard let result = await Transaction.latest(for: productID),
  312. let transaction = try? checkVerified(result) else {
  313. return false
  314. }
  315. return isActivePremiumTransaction(transaction)
  316. }
  317. private func hasEntitlementFromSubscriptionStatus() async -> Bool {
  318. for product in products where product.subscription != nil {
  319. guard StoreProductID.all.contains(product.id) else { continue }
  320. guard let statuses = try? await product.subscription?.status else { continue }
  321. for status in statuses {
  322. switch status.state {
  323. case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
  324. return true
  325. default:
  326. continue
  327. }
  328. }
  329. }
  330. return false
  331. }
  332. private func isActivePremiumTransaction(_ transaction: Transaction) -> Bool {
  333. guard StoreProductID.all.contains(transaction.productID) else { return false }
  334. guard transaction.revocationDate == nil else { return false }
  335. if let expirationDate = transaction.expirationDate {
  336. return expirationDate > Date()
  337. }
  338. return true
  339. }
  340. private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
  341. switch result {
  342. case .unverified:
  343. throw StoreError.failedVerification
  344. case .verified(let safe):
  345. return safe
  346. }
  347. }
  348. private func productSortOrder(for productID: String) -> Int {
  349. switch productID {
  350. case StoreProductID.monthly: 0
  351. case StoreProductID.yearly: 1
  352. case StoreProductID.lifetime: 2
  353. default: 99
  354. }
  355. }
  356. private func refreshIntroOfferEligibility() async {
  357. var eligibility: [String: Bool] = [:]
  358. for product in products {
  359. guard let subscription = product.subscription,
  360. subscription.introductoryOffer != nil else {
  361. continue
  362. }
  363. let groupID = subscription.subscriptionGroupID
  364. eligibility[product.id] = await Product.SubscriptionInfo.isEligibleForIntroOffer(for: groupID)
  365. }
  366. introOfferEligibleByProductID = eligibility
  367. }
  368. private func postStoreStateDidChange() {
  369. NotificationCenter.default.post(name: .storeStateDidChange, object: nil)
  370. }
  371. }
  372. extension Notification.Name {
  373. static let premiumStatusDidChange = Notification.Name("premiumStatusDidChange")
  374. static let entitlementsDidResolve = Notification.Name("entitlementsDidResolve")
  375. static let storeProductsDidUpdate = Notification.Name("storeProductsDidUpdate")
  376. static let storeStateDidChange = Notification.Name("storeStateDidChange")
  377. }
  378. // MARK: - Premium Access
  379. enum PremiumAccess {
  380. @MainActor
  381. static func canAccess(_ feature: AppFeature) -> Bool {
  382. let store = StoreManager.shared
  383. guard !store.isResolvingEntitlements else { return false }
  384. return FreeTierManager.canAccess(feature, isPremium: store.isPremium)
  385. }
  386. @MainActor
  387. @discardableResult
  388. static func require(
  389. feature: AppFeature,
  390. from window: NSWindow?,
  391. returnToHomeOnDismiss: Bool = false
  392. ) -> Bool {
  393. guard canAccess(feature) else {
  394. presentPaywallOrLimitAlert(for: feature, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss)
  395. return false
  396. }
  397. return true
  398. }
  399. @MainActor
  400. @discardableResult
  401. static func require(from window: NSWindow?, returnToHomeOnDismiss: Bool = false) -> Bool {
  402. require(feature: .documentImport, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss)
  403. }
  404. @MainActor
  405. private static func presentPaywallOrLimitAlert(
  406. for feature: AppFeature,
  407. from window: NSWindow?,
  408. returnToHomeOnDismiss: Bool
  409. ) {
  410. if StoreManager.shared.isResolvingEntitlements {
  411. Task { @MainActor in
  412. await StoreManager.shared.ensureEntitlementsResolved()
  413. guard !canAccess(feature) else { return }
  414. presentPaywallOrLimitAlert(for: feature, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss)
  415. }
  416. return
  417. }
  418. guard let viewController = mainViewController(from: window) else { return }
  419. viewController.presentPaywall(returnToHomeOnDismiss: returnToHomeOnDismiss)
  420. }
  421. @MainActor
  422. static func mainViewController(from window: NSWindow?) -> ViewController? {
  423. if let viewController = window?.contentViewController as? ViewController {
  424. return viewController
  425. }
  426. if let viewController = NSApp.keyWindow?.contentViewController as? ViewController {
  427. return viewController
  428. }
  429. for appWindow in NSApp.windows where appWindow.canBecomeMain {
  430. if let viewController = appWindow.contentViewController as? ViewController {
  431. return viewController
  432. }
  433. }
  434. return nil
  435. }
  436. }
  437. // MARK: - Left Panel
  438. private final class PaywallLeftPanelView: NSView, AppearanceRefreshable {
  439. private let gradientLayer = CAGradientLayer()
  440. override init(frame frameRect: NSRect) {
  441. super.init(frame: frameRect)
  442. wantsLayer = true
  443. gradientLayer.startPoint = CGPoint(x: 0.5, y: 1)
  444. gradientLayer.endPoint = CGPoint(x: 0.5, y: 0)
  445. layer?.insertSublayer(gradientLayer, at: 0)
  446. refreshAppearance()
  447. }
  448. func refreshAppearance() {
  449. gradientLayer.colors = AppTheme.paywallLeftGradientColors.map(\.cgColor)
  450. }
  451. @available(*, unavailable)
  452. required init?(coder: NSCoder) { nil }
  453. override func layout() {
  454. super.layout()
  455. gradientLayer.frame = bounds
  456. let mask = CAShapeLayer()
  457. mask.path = CGPath(
  458. roundedRect: bounds,
  459. cornerWidth: 20,
  460. cornerHeight: 20,
  461. transform: nil
  462. )
  463. layer?.mask = mask
  464. }
  465. }
  466. // MARK: - Badge
  467. private final class PaywallBadgeView: NSView {
  468. private let label: NSTextField
  469. init(text: String, iconName: String, background: NSColor, foreground: NSColor) {
  470. label = NSTextField(labelWithString: text)
  471. super.init(frame: .zero)
  472. translatesAutoresizingMaskIntoConstraints = false
  473. wantsLayer = true
  474. layer?.backgroundColor = background.cgColor
  475. layer?.cornerRadius = 10
  476. layer?.masksToBounds = true
  477. let icon = NSImageView()
  478. icon.translatesAutoresizingMaskIntoConstraints = false
  479. if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) {
  480. let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .semibold)
  481. icon.image = image.withSymbolConfiguration(config)
  482. }
  483. icon.contentTintColor = foreground
  484. label.font = AppTheme.semiboldFont(size: 10)
  485. label.textColor = foreground
  486. label.translatesAutoresizingMaskIntoConstraints = false
  487. addSubview(icon)
  488. addSubview(label)
  489. NSLayoutConstraint.activate([
  490. heightAnchor.constraint(equalToConstant: 20),
  491. icon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
  492. icon.centerYAnchor.constraint(equalTo: centerYAnchor),
  493. icon.widthAnchor.constraint(equalToConstant: 12),
  494. icon.heightAnchor.constraint(equalToConstant: 12),
  495. label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 4),
  496. label.centerYAnchor.constraint(equalTo: centerYAnchor),
  497. label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
  498. ])
  499. }
  500. func updateText(_ text: String) {
  501. label.stringValue = text
  502. }
  503. @available(*, unavailable)
  504. required init?(coder: NSCoder) { nil }
  505. }
  506. // MARK: - Feature Row
  507. private final class PaywallFeatureRow: NSView, AppearanceRefreshable {
  508. private let label: NSTextField
  509. init(text: String) {
  510. label = NSTextField.themeLabel(text, style: .primary, font: AppTheme.regularFont(size: 14))
  511. super.init(frame: .zero)
  512. translatesAutoresizingMaskIntoConstraints = false
  513. let checkContainer = NSView()
  514. checkContainer.translatesAutoresizingMaskIntoConstraints = false
  515. checkContainer.wantsLayer = true
  516. checkContainer.layer?.backgroundColor = AppTheme.green.cgColor
  517. checkContainer.layer?.cornerRadius = 10
  518. checkContainer.layer?.masksToBounds = true
  519. let checkIcon = NSImageView()
  520. checkIcon.translatesAutoresizingMaskIntoConstraints = false
  521. if let image = NSImage(systemSymbolName: "checkmark", accessibilityDescription: nil) {
  522. let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .bold)
  523. checkIcon.image = image.withSymbolConfiguration(config)
  524. }
  525. checkIcon.contentTintColor = .white
  526. label.translatesAutoresizingMaskIntoConstraints = false
  527. addSubview(checkContainer)
  528. checkContainer.addSubview(checkIcon)
  529. addSubview(label)
  530. NSLayoutConstraint.activate([
  531. heightAnchor.constraint(equalToConstant: 28),
  532. checkContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
  533. checkContainer.centerYAnchor.constraint(equalTo: centerYAnchor),
  534. checkContainer.widthAnchor.constraint(equalToConstant: 20),
  535. checkContainer.heightAnchor.constraint(equalToConstant: 20),
  536. checkIcon.centerXAnchor.constraint(equalTo: checkContainer.centerXAnchor),
  537. checkIcon.centerYAnchor.constraint(equalTo: checkContainer.centerYAnchor),
  538. checkIcon.widthAnchor.constraint(equalToConstant: 12),
  539. checkIcon.heightAnchor.constraint(equalToConstant: 12),
  540. label.leadingAnchor.constraint(equalTo: checkContainer.trailingAnchor, constant: 12),
  541. label.centerYAnchor.constraint(equalTo: centerYAnchor),
  542. label.trailingAnchor.constraint(equalTo: trailingAnchor),
  543. ])
  544. }
  545. func updateText(_ text: String) {
  546. label.stringValue = text
  547. }
  548. @available(*, unavailable)
  549. required init?(coder: NSCoder) { nil }
  550. func refreshAppearance() {
  551. label.refreshThemeLabelColor()
  552. }
  553. }
  554. // MARK: - Plan Card
  555. private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
  556. var onSelect: (() -> Void)?
  557. private let plan: PaywallPlan
  558. private let titleLabel = NSTextField(labelWithString: "")
  559. private let subtitleLabel = NSTextField(labelWithString: "")
  560. private let priceLabel = NSTextField(labelWithString: "")
  561. private var badgeView: PaywallBadgeView?
  562. private var trialBadgeView: PaywallBadgeView?
  563. private var hoverTracker: HoverTracker?
  564. private var isHovered = false
  565. var isChosen: Bool = false {
  566. didSet { updateAppearance() }
  567. }
  568. init(plan: PaywallPlan) {
  569. self.plan = plan
  570. super.init(frame: .zero)
  571. translatesAutoresizingMaskIntoConstraints = false
  572. wantsLayer = true
  573. layer?.cornerRadius = 12
  574. layer?.masksToBounds = false
  575. let config = PaywallConfigService.shared.config
  576. titleLabel.stringValue = plan.planCopy(from: config).title
  577. titleLabel.font = AppTheme.semiboldFont(size: 15)
  578. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  579. subtitleLabel.stringValue = config.loadingPrice
  580. subtitleLabel.font = AppTheme.regularFont(size: 11)
  581. subtitleLabel.themeLabelStyle = .secondary
  582. subtitleLabel.textColor = AppTheme.textSecondary
  583. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  584. priceLabel.stringValue = config.loadingPrice
  585. priceLabel.font = AppTheme.semiboldFont(size: 15)
  586. priceLabel.alignment = .right
  587. priceLabel.translatesAutoresizingMaskIntoConstraints = false
  588. addSubview(titleLabel)
  589. addSubview(subtitleLabel)
  590. addSubview(priceLabel)
  591. NSLayoutConstraint.activate([
  592. heightAnchor.constraint(equalToConstant: 86),
  593. titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
  594. titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 24),
  595. subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  596. subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 3),
  597. subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: priceLabel.leadingAnchor, constant: -12),
  598. priceLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
  599. priceLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  600. ])
  601. if plan == .lifetime, let badgeText = plan.planCopy(from: config).badge {
  602. let badge = PaywallBadgeView(
  603. text: badgeText,
  604. iconName: "star.fill",
  605. background: AppTheme.paywallGold,
  606. foreground: AppTheme.paywallGoldText
  607. )
  608. badgeView = badge
  609. addSubview(badge)
  610. NSLayoutConstraint.activate([
  611. badge.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
  612. badge.topAnchor.constraint(equalTo: topAnchor, constant: 8),
  613. ])
  614. }
  615. applyCardShadow()
  616. updateAppearance()
  617. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  618. self?.setHovered(hovering)
  619. }
  620. }
  621. @available(*, unavailable)
  622. required init?(coder: NSCoder) { nil }
  623. func updateDisplay(
  624. product: Product?,
  625. config: PaywallConfig,
  626. introOffer: Product.SubscriptionOffer? = nil
  627. ) {
  628. titleLabel.stringValue = plan.planCopy(from: config).title
  629. subtitleLabel.stringValue = plan.localizedSubtitle(from: product, config: config, introOffer: introOffer)
  630. priceLabel.stringValue = plan.localizedPrice(from: product, config: config)
  631. updateLifetimeBadge(config: config)
  632. updateTrialBadge(introOffer: introOffer)
  633. }
  634. private func updateLifetimeBadge(config: PaywallConfig) {
  635. guard plan == .lifetime else { return }
  636. if let badgeText = plan.planCopy(from: config).badge {
  637. if let badgeView {
  638. badgeView.updateText(badgeText)
  639. badgeView.isHidden = false
  640. }
  641. } else {
  642. badgeView?.isHidden = true
  643. }
  644. }
  645. private func updateTrialBadge(introOffer: Product.SubscriptionOffer?) {
  646. guard plan == .yearly else { return }
  647. if let introOffer {
  648. let text = PaywallConfigService.shared.config.trialBadgeText(from: introOffer)
  649. if let trialBadgeView {
  650. trialBadgeView.updateText(text)
  651. trialBadgeView.isHidden = false
  652. } else {
  653. let badge = PaywallBadgeView(
  654. text: text,
  655. iconName: "calendar",
  656. background: AppTheme.paywallPink,
  657. foreground: AppTheme.paywallPinkText
  658. )
  659. trialBadgeView = badge
  660. addSubview(badge)
  661. NSLayoutConstraint.activate([
  662. badge.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
  663. badge.topAnchor.constraint(equalTo: topAnchor, constant: 8),
  664. ])
  665. }
  666. } else {
  667. trialBadgeView?.isHidden = true
  668. }
  669. }
  670. func refreshAppearance() {
  671. updateAppearance()
  672. subtitleLabel.refreshThemeLabelColor()
  673. if isHovered {
  674. applyHoverLift(true)
  675. }
  676. }
  677. private func setHovered(_ hovering: Bool) {
  678. isHovered = hovering
  679. applyHoverLift(hovering)
  680. updateAppearance()
  681. }
  682. private func updateAppearance() {
  683. let titleColor = isChosen && plan == .yearly ? AppTheme.green : AppTheme.paywallAccent
  684. titleLabel.textColor = titleColor
  685. priceLabel.textColor = titleColor
  686. layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor
  687. if isChosen {
  688. layer?.borderWidth = 2
  689. layer?.borderColor = AppTheme.green.cgColor
  690. } else if isHovered {
  691. layer?.borderWidth = 2
  692. let hoverBorder = AppTheme.paywallBorder.blended(withFraction: 0.35, of: AppTheme.paywallAccent)
  693. ?? AppTheme.paywallBorder
  694. layer?.borderColor = hoverBorder.cgColor
  695. } else {
  696. layer?.borderWidth = 1.5
  697. layer?.borderColor = AppTheme.paywallBorder.cgColor
  698. }
  699. }
  700. override func mouseUp(with event: NSEvent) {
  701. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  702. onSelect?()
  703. }
  704. override func resetCursorRects() {
  705. addCursorRect(bounds, cursor: .pointingHand)
  706. }
  707. }
  708. // MARK: - Footer Link
  709. private final class PaywallFooterLink: NSButton, AppearanceRefreshable {
  710. private var hoverTracker: HoverTracker?
  711. private var isHovered = false
  712. init(title: String) {
  713. super.init(frame: .zero)
  714. updateTitle(title)
  715. isBordered = false
  716. bezelStyle = .inline
  717. font = AppTheme.regularFont(size: 11)
  718. translatesAutoresizingMaskIntoConstraints = false
  719. refreshAppearance()
  720. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  721. self?.isHovered = hovering
  722. self?.refreshAppearance()
  723. }
  724. }
  725. @available(*, unavailable)
  726. required init?(coder: NSCoder) { nil }
  727. func updateTitle(_ title: String) {
  728. self.title = title
  729. invalidateIntrinsicContentSize()
  730. needsDisplay = true
  731. }
  732. func refreshAppearance() {
  733. contentTintColor = isHovered ? AppTheme.textPrimary : AppTheme.textSecondary
  734. }
  735. override func resetCursorRects() {
  736. addCursorRect(bounds, cursor: .pointingHand)
  737. }
  738. }
  739. // MARK: - Footer Trust Item
  740. private final class PaywallTrustItemView: NSView, AppearanceRefreshable {
  741. private let iconContainer = NSView()
  742. private let icon = NSImageView()
  743. private let titleLabel: NSTextField
  744. private let subtitleLabel: NSTextField
  745. init(iconName: String, title: String, subtitle: String) {
  746. titleLabel = NSTextField.themeLabel(title, style: .primary, font: AppTheme.semiboldFont(size: 11))
  747. subtitleLabel = NSTextField.themeLabel(subtitle, style: .secondary, font: AppTheme.regularFont(size: 9))
  748. super.init(frame: .zero)
  749. translatesAutoresizingMaskIntoConstraints = false
  750. iconContainer.translatesAutoresizingMaskIntoConstraints = false
  751. iconContainer.wantsLayer = true
  752. iconContainer.layer?.cornerRadius = 10
  753. iconContainer.layer?.masksToBounds = true
  754. icon.translatesAutoresizingMaskIntoConstraints = false
  755. if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) {
  756. let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  757. icon.image = image.withSymbolConfiguration(config)
  758. }
  759. titleLabel.lineBreakMode = .byTruncatingTail
  760. titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  761. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  762. subtitleLabel.lineBreakMode = .byTruncatingTail
  763. subtitleLabel.maximumNumberOfLines = 2
  764. subtitleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  765. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  766. addSubview(iconContainer)
  767. iconContainer.addSubview(icon)
  768. addSubview(titleLabel)
  769. addSubview(subtitleLabel)
  770. NSLayoutConstraint.activate([
  771. heightAnchor.constraint(equalToConstant: Self.preferredHeight),
  772. iconContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
  773. iconContainer.topAnchor.constraint(equalTo: topAnchor),
  774. iconContainer.widthAnchor.constraint(equalToConstant: 20),
  775. iconContainer.heightAnchor.constraint(equalToConstant: 20),
  776. icon.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
  777. icon.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
  778. icon.widthAnchor.constraint(equalToConstant: 12),
  779. icon.heightAnchor.constraint(equalToConstant: 12),
  780. titleLabel.leadingAnchor.constraint(equalTo: iconContainer.trailingAnchor, constant: 8),
  781. titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 1),
  782. titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
  783. subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  784. subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 2),
  785. subtitleLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
  786. subtitleLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
  787. ])
  788. setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  789. setContentHuggingPriority(.defaultLow, for: .horizontal)
  790. setContentHuggingPriority(.required, for: .vertical)
  791. setContentCompressionResistancePriority(.required, for: .vertical)
  792. refreshAppearance()
  793. }
  794. fileprivate static let preferredHeight: CGFloat = 34
  795. func update(iconName: String, title: String, subtitle: String) {
  796. titleLabel.stringValue = title
  797. subtitleLabel.stringValue = subtitle
  798. if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) {
  799. let symbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  800. icon.image = image.withSymbolConfiguration(symbolConfig)
  801. }
  802. }
  803. @available(*, unavailable)
  804. required init?(coder: NSCoder) { nil }
  805. func refreshAppearance() {
  806. iconContainer.layer?.backgroundColor = AppTheme.paywallTrustIconBackground.cgColor
  807. icon.contentTintColor = AppTheme.paywallIconAccent
  808. titleLabel.refreshThemeLabelColor()
  809. subtitleLabel.refreshThemeLabelColor()
  810. }
  811. }
  812. // MARK: - Products Error
  813. private final class PaywallProductsErrorView: NSView, AppearanceRefreshable {
  814. var onRetry: (() -> Void)?
  815. private let messageLabel = NSTextField(wrappingLabelWithString: "")
  816. private let retryButton = PaywallFooterLink(title: "")
  817. init() {
  818. super.init(frame: .zero)
  819. translatesAutoresizingMaskIntoConstraints = false
  820. messageLabel.font = AppTheme.regularFont(size: 12)
  821. messageLabel.textColor = AppTheme.textSecondary
  822. messageLabel.alignment = .center
  823. messageLabel.maximumNumberOfLines = 3
  824. messageLabel.lineBreakMode = .byWordWrapping
  825. messageLabel.cell?.wraps = true
  826. messageLabel.cell?.isScrollable = false
  827. messageLabel.translatesAutoresizingMaskIntoConstraints = false
  828. retryButton.target = self
  829. retryButton.action = #selector(retryTapped)
  830. addSubview(messageLabel)
  831. addSubview(retryButton)
  832. NSLayoutConstraint.activate([
  833. messageLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
  834. messageLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
  835. messageLabel.topAnchor.constraint(equalTo: topAnchor),
  836. retryButton.centerXAnchor.constraint(equalTo: centerXAnchor),
  837. retryButton.topAnchor.constraint(equalTo: messageLabel.bottomAnchor, constant: 8),
  838. retryButton.bottomAnchor.constraint(equalTo: bottomAnchor),
  839. ])
  840. }
  841. @available(*, unavailable)
  842. required init?(coder: NSCoder) { nil }
  843. func update(message: String, isRetrying: Bool) {
  844. let messages = PaywallConfigService.shared.config.messages
  845. messageLabel.stringValue = message
  846. retryButton.isHidden = false
  847. retryButton.isEnabled = !isRetrying
  848. retryButton.updateTitle(isRetrying ? messages.retrying : messages.retry)
  849. }
  850. func refreshAppearance() {
  851. messageLabel.textColor = AppTheme.textSecondary
  852. retryButton.refreshAppearance()
  853. }
  854. @objc private func retryTapped() {
  855. onRetry?()
  856. }
  857. }
  858. // MARK: - Close Button
  859. private final class PaywallCloseButton: NSButton, AppearanceRefreshable {
  860. private var hoverTracker: HoverTracker?
  861. private var isHovered = false
  862. init() {
  863. super.init(frame: .zero)
  864. isBordered = false
  865. bezelStyle = .inline
  866. translatesAutoresizingMaskIntoConstraints = false
  867. wantsLayer = true
  868. layer?.cornerRadius = 15
  869. layer?.borderWidth = 1.5
  870. if let image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close") {
  871. let config = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
  872. self.image = image.withSymbolConfiguration(config)
  873. }
  874. refreshAppearance()
  875. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  876. self?.setHovered(hovering)
  877. }
  878. }
  879. @available(*, unavailable)
  880. required init?(coder: NSCoder) { nil }
  881. func refreshAppearance() {
  882. updateAppearance()
  883. }
  884. private func setHovered(_ hovering: Bool) {
  885. isHovered = hovering
  886. animateHover {
  887. updateAppearance()
  888. layer?.transform = hovering
  889. ? CATransform3DMakeScale(1.06, 1.06, 1)
  890. : CATransform3DIdentity
  891. }
  892. }
  893. private func updateAppearance() {
  894. if isHovered {
  895. layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
  896. layer?.borderColor = AppTheme.paywallAccent.withAlphaComponent(0.45).cgColor
  897. contentTintColor = AppTheme.textPrimary
  898. } else {
  899. layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor
  900. layer?.borderColor = AppTheme.paywallBorder.cgColor
  901. contentTintColor = AppTheme.textSecondary
  902. }
  903. }
  904. override func resetCursorRects() {
  905. addCursorRect(bounds, cursor: .pointingHand)
  906. }
  907. }
  908. // MARK: - CTA Button
  909. private final class PaywallCTAButton: NSButton, AppearanceRefreshable {
  910. private var hoverTracker: HoverTracker?
  911. init() {
  912. super.init(frame: .zero)
  913. isBordered = false
  914. wantsLayer = true
  915. layer?.cornerRadius = 12
  916. font = AppTheme.semiboldFont(size: 15)
  917. translatesAutoresizingMaskIntoConstraints = false
  918. refreshAppearance()
  919. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  920. self?.setHovered(hovering)
  921. }
  922. }
  923. @available(*, unavailable)
  924. required init?(coder: NSCoder) { nil }
  925. func refreshAppearance() {
  926. layer?.backgroundColor = AppTheme.paywallCTABackground.cgColor
  927. contentTintColor = AppTheme.paywallCTAForeground
  928. }
  929. private func setHovered(_ hovering: Bool) {
  930. let base = AppTheme.paywallCTABackground
  931. let color = hovering ? base.blended(withFraction: 0.12, of: .black) ?? base : base
  932. animateHover {
  933. layer?.backgroundColor = color.cgColor
  934. layer?.transform = hovering
  935. ? CATransform3DMakeScale(1.02, 1.02, 1)
  936. : CATransform3DIdentity
  937. }
  938. }
  939. override func resetCursorRects() {
  940. addCursorRect(bounds, cursor: .pointingHand)
  941. }
  942. }
  943. // MARK: - Main Paywall Card
  944. final class PaywallView: NSView, AppearanceRefreshable {
  945. var onClose: (() -> Void)?
  946. private var selectedPlan: PaywallPlan = .yearly
  947. private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
  948. private let ctaButton = PaywallCTAButton()
  949. private let renewalDisclosureLabel = NSTextField(wrappingLabelWithString: "")
  950. private let continueFreePlanLink = PaywallFooterLink(title: "")
  951. private let manageSubscriptionLink = PaywallFooterLink(title: "")
  952. private let restoreLink = PaywallFooterLink(title: "")
  953. private let privacyLink = PaywallFooterLink(title: "")
  954. private let termsLink = PaywallFooterLink(title: "")
  955. private let supportLink = PaywallFooterLink(title: "")
  956. private let premiumCloseButton = PaywallCloseButton()
  957. private var primaryFooterLinkCell: NSView?
  958. private var leftPanelTitle: NSTextField!
  959. private var rightTitle: NSTextField!
  960. private var rightSubtitle: NSTextField!
  961. private var featuresStack: NSStackView!
  962. private var featureRows: [PaywallFeatureRow] = []
  963. private var trustItemViews: [PaywallTrustItemView] = []
  964. private var trustStack: NSStackView!
  965. private var productsErrorView: PaywallProductsErrorView!
  966. private var plansStack: NSStackView!
  967. private var plansStackHeightConstraint: NSLayoutConstraint!
  968. private var productsErrorHeightConstraint: NSLayoutConstraint!
  969. private var storeObservers: [NSObjectProtocol] = []
  970. init() {
  971. super.init(frame: .zero)
  972. translatesAutoresizingMaskIntoConstraints = false
  973. wantsLayer = true
  974. layer?.cornerRadius = 0
  975. setup()
  976. observeStoreUpdates()
  977. refreshConfigDisplay()
  978. refreshProductDisplay()
  979. refreshAppearance()
  980. }
  981. deinit {
  982. storeObservers.forEach { NotificationCenter.default.removeObserver($0) }
  983. }
  984. private func observeStoreUpdates() {
  985. let center = NotificationCenter.default
  986. storeObservers = [
  987. center.addObserver(forName: .storeProductsDidUpdate, object: nil, queue: .main) { [weak self] _ in
  988. self?.refreshProductDisplay()
  989. },
  990. center.addObserver(forName: .storeStateDidChange, object: nil, queue: .main) { [weak self] _ in
  991. self?.refreshProductDisplay()
  992. },
  993. center.addObserver(forName: .premiumStatusDidChange, object: nil, queue: .main) { [weak self] _ in
  994. self?.refreshProductDisplay()
  995. },
  996. center.addObserver(forName: .paywallConfigDidUpdate, object: nil, queue: .main) { [weak self] _ in
  997. self?.refreshConfigDisplay()
  998. self?.refreshProductDisplay()
  999. },
  1000. ]
  1001. }
  1002. private var paywallConfig: PaywallConfig {
  1003. PaywallConfigService.shared.config
  1004. }
  1005. private func refreshConfigDisplay() {
  1006. let config = paywallConfig
  1007. leftPanelTitle?.stringValue = config.leftPanelTitle
  1008. rightTitle?.stringValue = config.rightTitle
  1009. rightSubtitle?.stringValue = config.rightSubtitle
  1010. syncFeatureRows(with: config.features)
  1011. syncTrustItems(with: config.trustItems)
  1012. continueFreePlanLink.updateTitle(config.footer.continueFree)
  1013. manageSubscriptionLink.updateTitle(config.footer.manageSubscription)
  1014. restoreLink.updateTitle(config.footer.restorePurchase)
  1015. privacyLink.updateTitle(config.footer.privacyPolicy)
  1016. termsLink.updateTitle(config.footer.termsOfService)
  1017. supportLink.updateTitle(config.footer.support)
  1018. }
  1019. private func syncFeatureRows(with features: [String]) {
  1020. guard let featuresStack else { return }
  1021. while featureRows.count < features.count {
  1022. let row = PaywallFeatureRow(text: "")
  1023. featureRows.append(row)
  1024. featuresStack.addArrangedSubview(row)
  1025. }
  1026. while featureRows.count > features.count {
  1027. let row = featureRows.removeLast()
  1028. featuresStack.removeArrangedSubview(row)
  1029. row.removeFromSuperview()
  1030. }
  1031. for (index, text) in features.enumerated() {
  1032. featureRows[index].updateText(text)
  1033. }
  1034. }
  1035. private func syncTrustItems(with items: [PaywallConfig.TrustItem]) {
  1036. for (index, item) in items.enumerated() where index < trustItemViews.count {
  1037. trustItemViews[index].update(
  1038. iconName: item.icon,
  1039. title: item.title,
  1040. subtitle: item.subtitle
  1041. )
  1042. }
  1043. }
  1044. private func refreshProductDisplay() {
  1045. let store = StoreManager.shared
  1046. let config = paywallConfig
  1047. for (plan, card) in planCards {
  1048. let product = store.product(for: plan)
  1049. card.updateDisplay(
  1050. product: product,
  1051. config: config,
  1052. introOffer: store.eligibleIntroOffer(for: plan)
  1053. )
  1054. }
  1055. ctaButton.title = selectedPlan.localizedCTATitle(
  1056. from: store.product(for: selectedPlan),
  1057. config: config,
  1058. introOffer: store.eligibleIntroOffer(for: selectedPlan)
  1059. )
  1060. updateRenewalDisclosure(
  1061. selectedPlan.localizedRenewalDisclosure(
  1062. from: store.product(for: selectedPlan),
  1063. config: config,
  1064. introOffer: store.eligibleIntroOffer(for: selectedPlan)
  1065. )
  1066. )
  1067. refreshTrustItemsForSelectedPlan()
  1068. refreshProductsErrorState()
  1069. refreshPurchaseState()
  1070. }
  1071. private func refreshProductsErrorState() {
  1072. let store = StoreManager.shared
  1073. let hasLoadFailure = store.productLoadError != nil && store.products.isEmpty
  1074. let isRetrying = hasLoadFailure && store.isLoadingProducts
  1075. productsErrorView.isHidden = !hasLoadFailure
  1076. plansStack.isHidden = hasLoadFailure
  1077. plansStackHeightConstraint.constant = hasLoadFailure ? 0 : plansAreaHeight
  1078. productsErrorHeightConstraint.constant = hasLoadFailure ? productsErrorAreaHeight : 0
  1079. if hasLoadFailure, let error = store.productLoadError {
  1080. productsErrorView.update(message: error, isRetrying: isRetrying)
  1081. }
  1082. }
  1083. private var plansAreaHeight: CGFloat {
  1084. let cardHeight: CGFloat = 86
  1085. let spacing: CGFloat = 12
  1086. let planCount = CGFloat(PaywallPlan.allCases.count)
  1087. return planCount * cardHeight + max(0, planCount - 1) * spacing
  1088. }
  1089. private var productsErrorAreaHeight: CGFloat { 86 }
  1090. private func refreshPurchaseState() {
  1091. let store = StoreManager.shared
  1092. let hasProductLoadFailure = store.productLoadError != nil && store.products.isEmpty
  1093. let isProductReady = store.product(for: selectedPlan) != nil
  1094. let isBusy = store.isPurchasing || store.isLoadingProducts
  1095. let isDisabled = isBusy || !isProductReady || hasProductLoadFailure
  1096. ctaButton.isEnabled = !isDisabled
  1097. ctaButton.alphaValue = isDisabled ? 0.65 : 1
  1098. refreshPrimaryFooterLink()
  1099. }
  1100. private func refreshPrimaryFooterLink() {
  1101. let store = StoreManager.shared
  1102. let showContinue = !store.isPro
  1103. let showManage = store.hasActiveSubscription
  1104. continueFreePlanLink.isHidden = !showContinue
  1105. manageSubscriptionLink.isHidden = !showManage
  1106. premiumCloseButton.isHidden = !store.isPro
  1107. primaryFooterLinkCell?.isHidden = !showContinue && !showManage
  1108. primaryFooterLinkCell?.needsLayout = true
  1109. primaryFooterLinkCell?.layoutSubtreeIfNeeded()
  1110. }
  1111. private func refreshTrustItemsForSelectedPlan() {
  1112. guard trustItemViews.count > 1, paywallConfig.trustItems.count > 1 else { return }
  1113. let cancelItem = paywallConfig.trustItems[1]
  1114. if selectedPlan == .lifetime {
  1115. let item = paywallConfig.lifetimeTrustItem
  1116. trustItemViews[1].update(
  1117. iconName: item.icon,
  1118. title: item.title,
  1119. subtitle: item.subtitle
  1120. )
  1121. } else {
  1122. trustItemViews[1].update(
  1123. iconName: cancelItem.icon,
  1124. title: cancelItem.title,
  1125. subtitle: cancelItem.subtitle
  1126. )
  1127. }
  1128. }
  1129. func refreshStoreState() {
  1130. refreshProductDisplay()
  1131. }
  1132. override func layout() {
  1133. super.layout()
  1134. let width = renewalDisclosureLabel.bounds.width
  1135. guard width > 0, renewalDisclosureLabel.preferredMaxLayoutWidth != width else { return }
  1136. renewalDisclosureLabel.preferredMaxLayoutWidth = width
  1137. renewalDisclosureLabel.invalidateIntrinsicContentSize()
  1138. }
  1139. private func updateRenewalDisclosure(_ text: String) {
  1140. renewalDisclosureLabel.stringValue = text
  1141. renewalDisclosureLabel.invalidateIntrinsicContentSize()
  1142. needsLayout = true
  1143. }
  1144. func refreshAppearance() {
  1145. layer?.backgroundColor = AppTheme.paywallBackground.cgColor
  1146. leftPanelTitle?.refreshThemeLabelColor()
  1147. rightTitle?.refreshThemeLabelColor()
  1148. rightSubtitle?.refreshThemeLabelColor()
  1149. renewalDisclosureLabel.textColor = AppTheme.textSecondary
  1150. trustStack?.layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor
  1151. trustStack?.layer?.borderColor = AppTheme.paywallBorder.cgColor
  1152. ctaButton.refreshAppearance()
  1153. subviews.forEach { $0.refreshAppearanceRecursively() }
  1154. }
  1155. @available(*, unavailable)
  1156. required init?(coder: NSCoder) { nil }
  1157. private func setup() {
  1158. let leftPanel = makeLeftPanel()
  1159. let rightPanel = makeRightPanel()
  1160. premiumCloseButton.target = self
  1161. premiumCloseButton.action = #selector(premiumCloseTapped)
  1162. premiumCloseButton.isHidden = true
  1163. addSubview(leftPanel)
  1164. addSubview(rightPanel)
  1165. addSubview(premiumCloseButton)
  1166. NSLayoutConstraint.activate([
  1167. leftPanel.leadingAnchor.constraint(equalTo: leadingAnchor),
  1168. leftPanel.topAnchor.constraint(equalTo: topAnchor),
  1169. leftPanel.bottomAnchor.constraint(equalTo: bottomAnchor),
  1170. leftPanel.widthAnchor.constraint(equalToConstant: 320),
  1171. rightPanel.leadingAnchor.constraint(equalTo: leftPanel.trailingAnchor),
  1172. rightPanel.trailingAnchor.constraint(equalTo: trailingAnchor),
  1173. rightPanel.topAnchor.constraint(equalTo: topAnchor),
  1174. rightPanel.bottomAnchor.constraint(equalTo: bottomAnchor),
  1175. premiumCloseButton.topAnchor.constraint(equalTo: topAnchor, constant: 16),
  1176. premiumCloseButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
  1177. premiumCloseButton.widthAnchor.constraint(equalToConstant: 30),
  1178. premiumCloseButton.heightAnchor.constraint(equalToConstant: 30),
  1179. ])
  1180. }
  1181. private func makeLeftPanel() -> NSView {
  1182. let panel = PaywallLeftPanelView()
  1183. panel.translatesAutoresizingMaskIntoConstraints = false
  1184. let config = paywallConfig
  1185. let title = NSTextField.themeLabel(
  1186. config.leftPanelTitle,
  1187. style: .primary,
  1188. font: AppTheme.semiboldFont(size: 22)
  1189. )
  1190. title.maximumNumberOfLines = 2
  1191. title.translatesAutoresizingMaskIntoConstraints = false
  1192. leftPanelTitle = title
  1193. let featuresStack = NSStackView()
  1194. featuresStack.orientation = .vertical
  1195. featuresStack.spacing = 6
  1196. featuresStack.alignment = .leading
  1197. featuresStack.translatesAutoresizingMaskIntoConstraints = false
  1198. self.featuresStack = featuresStack
  1199. for feature in config.features {
  1200. let row = PaywallFeatureRow(text: feature)
  1201. featureRows.append(row)
  1202. featuresStack.addArrangedSubview(row)
  1203. }
  1204. panel.addSubview(title)
  1205. panel.addSubview(featuresStack)
  1206. NSLayoutConstraint.activate([
  1207. title.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  1208. title.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -20),
  1209. title.topAnchor.constraint(equalTo: panel.topAnchor, constant: 48),
  1210. featuresStack.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  1211. featuresStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -20),
  1212. featuresStack.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 28),
  1213. ])
  1214. return panel
  1215. }
  1216. private func makeRightPanel() -> NSView {
  1217. let panel = NSView()
  1218. panel.translatesAutoresizingMaskIntoConstraints = false
  1219. let config = paywallConfig
  1220. let title = NSTextField.themeLabel(
  1221. config.rightTitle,
  1222. style: .primary,
  1223. font: AppTheme.semiboldFont(size: 26)
  1224. )
  1225. title.alignment = .center
  1226. title.translatesAutoresizingMaskIntoConstraints = false
  1227. rightTitle = title
  1228. let subtitle = NSTextField.themeLabel(
  1229. config.rightSubtitle,
  1230. style: .secondary,
  1231. font: AppTheme.regularFont(size: 13)
  1232. )
  1233. subtitle.alignment = .center
  1234. subtitle.maximumNumberOfLines = 2
  1235. subtitle.translatesAutoresizingMaskIntoConstraints = false
  1236. rightSubtitle = subtitle
  1237. let plansStack = NSStackView()
  1238. plansStack.orientation = .vertical
  1239. plansStack.spacing = 12
  1240. plansStack.translatesAutoresizingMaskIntoConstraints = false
  1241. self.plansStack = plansStack
  1242. let plansAreaView = NSView()
  1243. plansAreaView.translatesAutoresizingMaskIntoConstraints = false
  1244. let productsErrorView = PaywallProductsErrorView()
  1245. productsErrorView.isHidden = true
  1246. productsErrorView.onRetry = { [weak self] in
  1247. self?.retryProductLoad()
  1248. }
  1249. self.productsErrorView = productsErrorView
  1250. for plan in PaywallPlan.allCases {
  1251. let card = PaywallPlanCard(plan: plan)
  1252. card.isChosen = plan == selectedPlan
  1253. card.onSelect = { [weak self] in self?.selectPlan(plan) }
  1254. planCards[plan] = card
  1255. plansStack.addArrangedSubview(card)
  1256. }
  1257. ctaButton.title = selectedPlan.localizedCTATitle(
  1258. from: StoreManager.shared.product(for: selectedPlan),
  1259. config: config,
  1260. introOffer: StoreManager.shared.eligibleIntroOffer(for: selectedPlan)
  1261. )
  1262. ctaButton.target = self
  1263. ctaButton.action = #selector(purchaseTapped)
  1264. ctaButton.translatesAutoresizingMaskIntoConstraints = false
  1265. renewalDisclosureLabel.font = AppTheme.regularFont(size: 10)
  1266. renewalDisclosureLabel.textColor = AppTheme.textSecondary
  1267. renewalDisclosureLabel.alignment = .center
  1268. renewalDisclosureLabel.maximumNumberOfLines = 3
  1269. renewalDisclosureLabel.lineBreakMode = .byWordWrapping
  1270. renewalDisclosureLabel.cell?.wraps = true
  1271. renewalDisclosureLabel.cell?.isScrollable = false
  1272. renewalDisclosureLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  1273. renewalDisclosureLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
  1274. renewalDisclosureLabel.setContentHuggingPriority(.required, for: .vertical)
  1275. renewalDisclosureLabel.translatesAutoresizingMaskIntoConstraints = false
  1276. updateRenewalDisclosure(
  1277. selectedPlan.localizedRenewalDisclosure(
  1278. from: StoreManager.shared.product(for: selectedPlan),
  1279. config: config,
  1280. introOffer: StoreManager.shared.eligibleIntroOffer(for: selectedPlan)
  1281. )
  1282. )
  1283. let trustRow = makeTrustRow()
  1284. let footerLinks = makeFooterLinks()
  1285. let footerSpacer = NSView()
  1286. footerSpacer.translatesAutoresizingMaskIntoConstraints = false
  1287. footerSpacer.setContentHuggingPriority(.defaultLow, for: .vertical)
  1288. footerSpacer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
  1289. plansAreaView.addSubview(plansStack)
  1290. plansAreaView.addSubview(productsErrorView)
  1291. plansStackHeightConstraint = plansStack.heightAnchor.constraint(equalToConstant: plansAreaHeight)
  1292. productsErrorHeightConstraint = productsErrorView.heightAnchor.constraint(equalToConstant: 0)
  1293. panel.addSubview(title)
  1294. panel.addSubview(subtitle)
  1295. panel.addSubview(plansAreaView)
  1296. panel.addSubview(trustRow)
  1297. panel.addSubview(ctaButton)
  1298. panel.addSubview(renewalDisclosureLabel)
  1299. panel.addSubview(footerSpacer)
  1300. panel.addSubview(footerLinks)
  1301. NSLayoutConstraint.activate([
  1302. title.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  1303. title.topAnchor.constraint(equalTo: panel.topAnchor, constant: 40),
  1304. title.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  1305. subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  1306. subtitle.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  1307. subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6),
  1308. plansAreaView.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  1309. plansAreaView.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  1310. plansAreaView.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 24),
  1311. plansAreaView.heightAnchor.constraint(equalToConstant: plansAreaHeight),
  1312. plansStack.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor),
  1313. plansStack.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor),
  1314. plansStack.topAnchor.constraint(equalTo: plansAreaView.topAnchor),
  1315. plansStackHeightConstraint,
  1316. productsErrorView.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor),
  1317. productsErrorView.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor),
  1318. productsErrorView.topAnchor.constraint(equalTo: plansAreaView.topAnchor),
  1319. productsErrorHeightConstraint,
  1320. trustRow.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor),
  1321. trustRow.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor),
  1322. trustRow.topAnchor.constraint(equalTo: plansAreaView.bottomAnchor, constant: 18),
  1323. ctaButton.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor),
  1324. ctaButton.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor),
  1325. ctaButton.topAnchor.constraint(equalTo: trustRow.bottomAnchor, constant: 16),
  1326. ctaButton.heightAnchor.constraint(equalToConstant: 48),
  1327. renewalDisclosureLabel.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor),
  1328. renewalDisclosureLabel.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor),
  1329. renewalDisclosureLabel.topAnchor.constraint(equalTo: ctaButton.bottomAnchor, constant: 10),
  1330. footerSpacer.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
  1331. footerSpacer.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
  1332. footerSpacer.topAnchor.constraint(equalTo: renewalDisclosureLabel.bottomAnchor, constant: 16),
  1333. footerSpacer.bottomAnchor.constraint(equalTo: footerLinks.topAnchor),
  1334. footerLinks.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  1335. footerLinks.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  1336. footerLinks.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -16),
  1337. ])
  1338. return panel
  1339. }
  1340. private func makeTrustRow() -> NSView {
  1341. let config = paywallConfig
  1342. trustItemViews = config.trustItems.map { item in
  1343. PaywallTrustItemView(
  1344. iconName: item.icon,
  1345. title: item.title,
  1346. subtitle: item.subtitle
  1347. )
  1348. }
  1349. let trustStack = NSStackView(views: trustItemViews)
  1350. trustStack.orientation = .horizontal
  1351. trustStack.distribution = .fillEqually
  1352. trustStack.spacing = 16
  1353. trustStack.alignment = .top
  1354. trustStack.translatesAutoresizingMaskIntoConstraints = false
  1355. trustStack.wantsLayer = true
  1356. trustStack.layer?.cornerRadius = 12
  1357. trustStack.layer?.borderWidth = 1.5
  1358. trustStack.layer?.masksToBounds = true
  1359. trustStack.edgeInsets = NSEdgeInsets(top: 12, left: 14, bottom: 12, right: 14)
  1360. trustStack.translatesAutoresizingMaskIntoConstraints = false
  1361. trustStack.setContentHuggingPriority(.required, for: .vertical)
  1362. trustStack.setContentCompressionResistancePriority(.required, for: .vertical)
  1363. trustStack.heightAnchor.constraint(
  1364. equalToConstant: PaywallTrustItemView.preferredHeight + trustStack.edgeInsets.top + trustStack.edgeInsets.bottom
  1365. ).isActive = true
  1366. self.trustStack = trustStack
  1367. return trustStack
  1368. }
  1369. private func makeFooterLinks() -> NSView {
  1370. let container = NSView()
  1371. container.translatesAutoresizingMaskIntoConstraints = false
  1372. container.setContentHuggingPriority(.required, for: .vertical)
  1373. container.setContentCompressionResistancePriority(.required, for: .vertical)
  1374. let config = paywallConfig
  1375. let separator = NSView()
  1376. separator.translatesAutoresizingMaskIntoConstraints = false
  1377. separator.wantsLayer = true
  1378. separator.layer?.backgroundColor = AppTheme.paywallBorder.cgColor
  1379. continueFreePlanLink.updateTitle(config.footer.continueFree)
  1380. continueFreePlanLink.target = self
  1381. continueFreePlanLink.action = #selector(continueWithFreePlanTapped)
  1382. manageSubscriptionLink.updateTitle(config.footer.manageSubscription)
  1383. manageSubscriptionLink.target = self
  1384. manageSubscriptionLink.action = #selector(manageSubscriptionTapped)
  1385. let primaryCell = makePrimaryFooterLinkCell()
  1386. primaryFooterLinkCell = primaryCell
  1387. refreshPrimaryFooterLink()
  1388. restoreLink.updateTitle(config.footer.restorePurchase)
  1389. restoreLink.target = self
  1390. restoreLink.action = #selector(restoreTapped)
  1391. privacyLink.updateTitle(config.footer.privacyPolicy)
  1392. privacyLink.target = self
  1393. privacyLink.action = #selector(privacyPolicyTapped)
  1394. termsLink.updateTitle(config.footer.termsOfService)
  1395. termsLink.target = self
  1396. termsLink.action = #selector(termsOfServiceTapped)
  1397. supportLink.updateTitle(config.footer.support)
  1398. supportLink.target = self
  1399. supportLink.action = #selector(supportTapped)
  1400. let linkCells = [
  1401. primaryCell,
  1402. makeFooterLinkCell(link: restoreLink),
  1403. makeFooterLinkCell(link: privacyLink),
  1404. makeFooterLinkCell(link: termsLink),
  1405. makeFooterLinkCell(link: supportLink),
  1406. ]
  1407. let linksStack = NSStackView(views: linkCells)
  1408. linksStack.orientation = .horizontal
  1409. linksStack.spacing = 0
  1410. linksStack.alignment = .centerY
  1411. linksStack.distribution = .fillEqually
  1412. linksStack.translatesAutoresizingMaskIntoConstraints = false
  1413. container.addSubview(separator)
  1414. container.addSubview(linksStack)
  1415. NSLayoutConstraint.activate([
  1416. separator.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  1417. separator.trailingAnchor.constraint(equalTo: container.trailingAnchor),
  1418. separator.topAnchor.constraint(equalTo: container.topAnchor),
  1419. separator.heightAnchor.constraint(equalToConstant: 1),
  1420. linksStack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  1421. linksStack.trailingAnchor.constraint(equalTo: container.trailingAnchor),
  1422. linksStack.topAnchor.constraint(equalTo: separator.bottomAnchor, constant: 10),
  1423. linksStack.bottomAnchor.constraint(equalTo: container.bottomAnchor),
  1424. ])
  1425. return container
  1426. }
  1427. private func makePrimaryFooterLinkCell() -> NSView {
  1428. let cell = NSView()
  1429. cell.translatesAutoresizingMaskIntoConstraints = false
  1430. cell.setContentHuggingPriority(.required, for: .vertical)
  1431. cell.setContentCompressionResistancePriority(.required, for: .vertical)
  1432. for link in [continueFreePlanLink, manageSubscriptionLink] {
  1433. cell.addSubview(link)
  1434. NSLayoutConstraint.activate([
  1435. link.centerXAnchor.constraint(equalTo: cell.centerXAnchor),
  1436. link.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
  1437. link.topAnchor.constraint(equalTo: cell.topAnchor),
  1438. link.bottomAnchor.constraint(equalTo: cell.bottomAnchor),
  1439. ])
  1440. }
  1441. return cell
  1442. }
  1443. private func makeFooterLinkCell(link: PaywallFooterLink) -> NSView {
  1444. let cell = NSView()
  1445. cell.translatesAutoresizingMaskIntoConstraints = false
  1446. cell.setContentHuggingPriority(.required, for: .vertical)
  1447. cell.setContentCompressionResistancePriority(.required, for: .vertical)
  1448. cell.addSubview(link)
  1449. NSLayoutConstraint.activate([
  1450. link.centerXAnchor.constraint(equalTo: cell.centerXAnchor),
  1451. link.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
  1452. link.topAnchor.constraint(equalTo: cell.topAnchor),
  1453. link.bottomAnchor.constraint(equalTo: cell.bottomAnchor),
  1454. ])
  1455. return cell
  1456. }
  1457. private func selectPlan(_ plan: PaywallPlan) {
  1458. selectedPlan = plan
  1459. for (key, card) in planCards {
  1460. card.isChosen = key == plan
  1461. }
  1462. ctaButton.title = plan.localizedCTATitle(
  1463. from: StoreManager.shared.product(for: plan),
  1464. config: paywallConfig,
  1465. introOffer: StoreManager.shared.eligibleIntroOffer(for: plan)
  1466. )
  1467. updateRenewalDisclosure(
  1468. plan.localizedRenewalDisclosure(
  1469. from: StoreManager.shared.product(for: plan),
  1470. config: paywallConfig,
  1471. introOffer: StoreManager.shared.eligibleIntroOffer(for: plan)
  1472. )
  1473. )
  1474. refreshTrustItemsForSelectedPlan()
  1475. refreshPurchaseState()
  1476. }
  1477. private func retryProductLoad() {
  1478. Task { @MainActor in
  1479. refreshProductsErrorState()
  1480. refreshPurchaseState()
  1481. await StoreManager.shared.loadProducts()
  1482. refreshStoreState()
  1483. }
  1484. }
  1485. @objc private func purchaseTapped() {
  1486. Task { @MainActor in
  1487. refreshPurchaseState()
  1488. do {
  1489. let outcome = try await StoreManager.shared.purchase(plan: selectedPlan)
  1490. refreshStoreState()
  1491. switch outcome {
  1492. case .purchased, .cancelled:
  1493. break
  1494. case .pending:
  1495. let messages = paywallConfig.messages
  1496. StoreManager.shared.showAlert(
  1497. title: messages.pendingPurchaseTitle,
  1498. message: messages.pendingPurchaseMessage,
  1499. on: window
  1500. )
  1501. }
  1502. } catch {
  1503. refreshPurchaseState()
  1504. StoreManager.shared.showPurchaseError(error, on: window)
  1505. }
  1506. }
  1507. }
  1508. @objc private func restoreTapped() {
  1509. Task { @MainActor in
  1510. refreshPurchaseState()
  1511. do {
  1512. let restored = try await StoreManager.shared.restorePurchases()
  1513. refreshStoreState()
  1514. if !restored {
  1515. let messages = paywallConfig.messages
  1516. StoreManager.shared.showAlert(
  1517. title: messages.restoreNotFoundTitle,
  1518. message: messages.restoreNotFoundMessage,
  1519. on: window
  1520. )
  1521. }
  1522. } catch {
  1523. refreshPurchaseState()
  1524. StoreManager.shared.showPurchaseError(error, on: window)
  1525. }
  1526. }
  1527. }
  1528. @objc private func continueWithFreePlanTapped() {
  1529. onClose?()
  1530. }
  1531. @objc private func premiumCloseTapped() {
  1532. onClose?()
  1533. }
  1534. @objc private func manageSubscriptionTapped() {
  1535. StoreManager.shared.showManageSubscriptions()
  1536. }
  1537. @objc private func privacyPolicyTapped() {
  1538. openExternalLink(paywallConfig.urls.privacy)
  1539. }
  1540. @objc private func termsOfServiceTapped() {
  1541. openExternalLink(paywallConfig.urls.terms)
  1542. }
  1543. @objc private func supportTapped() {
  1544. openExternalLink(paywallConfig.urls.support)
  1545. }
  1546. private func openExternalLink(_ urlString: String) {
  1547. guard let url = URL(string: urlString) else { return }
  1548. NSWorkspace.shared.open(url)
  1549. }
  1550. }
  1551. // MARK: - Overlay Presenter
  1552. final class PaywallOverlayView: NSView, AppearanceRefreshable {
  1553. var onDismiss: (() -> Void)?
  1554. private let paywallView: PaywallView
  1555. private let blurView = NSVisualEffectView()
  1556. private let backdrop = NSView()
  1557. private let pattern = WavePatternView()
  1558. private var acceptsHitTesting = false
  1559. init() {
  1560. paywallView = PaywallView()
  1561. super.init(frame: .zero)
  1562. translatesAutoresizingMaskIntoConstraints = false
  1563. setup()
  1564. refreshAppearance()
  1565. }
  1566. func refreshAppearance() {
  1567. backdrop.layer?.backgroundColor = AppTheme.paywallOverlayBackdrop.cgColor
  1568. blurView.material = AppSettings.darkModeEnabled ? .hudWindow : .underWindowBackground
  1569. pattern.refreshAppearance()
  1570. paywallView.refreshAppearance()
  1571. }
  1572. func refreshStoreState() {
  1573. paywallView.refreshStoreState()
  1574. }
  1575. @available(*, unavailable)
  1576. required init?(coder: NSCoder) { nil }
  1577. override func hitTest(_ point: NSPoint) -> NSView? {
  1578. guard acceptsHitTesting else { return nil }
  1579. return super.hitTest(point)
  1580. }
  1581. private func setup() {
  1582. blurView.translatesAutoresizingMaskIntoConstraints = false
  1583. blurView.material = .underWindowBackground
  1584. blurView.blendingMode = .withinWindow
  1585. blurView.state = .active
  1586. backdrop.translatesAutoresizingMaskIntoConstraints = false
  1587. backdrop.wantsLayer = true
  1588. backdrop.layer?.backgroundColor = NSColor(calibratedWhite: 0.15, alpha: 0.22).cgColor
  1589. pattern.translatesAutoresizingMaskIntoConstraints = false
  1590. pattern.alphaValue = 0.35
  1591. paywallView.translatesAutoresizingMaskIntoConstraints = false
  1592. paywallView.onClose = { [weak self] in self?.dismiss() }
  1593. addSubview(blurView)
  1594. addSubview(backdrop)
  1595. backdrop.addSubview(pattern)
  1596. addSubview(paywallView)
  1597. NSLayoutConstraint.activate([
  1598. blurView.leadingAnchor.constraint(equalTo: leadingAnchor),
  1599. blurView.trailingAnchor.constraint(equalTo: trailingAnchor),
  1600. blurView.topAnchor.constraint(equalTo: topAnchor),
  1601. blurView.bottomAnchor.constraint(equalTo: bottomAnchor),
  1602. backdrop.leadingAnchor.constraint(equalTo: leadingAnchor),
  1603. backdrop.trailingAnchor.constraint(equalTo: trailingAnchor),
  1604. backdrop.topAnchor.constraint(equalTo: topAnchor),
  1605. backdrop.bottomAnchor.constraint(equalTo: bottomAnchor),
  1606. pattern.leadingAnchor.constraint(equalTo: backdrop.leadingAnchor),
  1607. pattern.trailingAnchor.constraint(equalTo: backdrop.trailingAnchor),
  1608. pattern.topAnchor.constraint(equalTo: backdrop.topAnchor),
  1609. pattern.bottomAnchor.constraint(equalTo: backdrop.bottomAnchor),
  1610. paywallView.leadingAnchor.constraint(equalTo: leadingAnchor),
  1611. paywallView.trailingAnchor.constraint(equalTo: trailingAnchor),
  1612. paywallView.topAnchor.constraint(equalTo: topAnchor),
  1613. paywallView.bottomAnchor.constraint(equalTo: bottomAnchor),
  1614. ])
  1615. }
  1616. func present(in parent: NSView) {
  1617. guard superview == nil else { return }
  1618. if let window = parent.window,
  1619. let windowFrameView = window.contentView?.superview {
  1620. windowFrameView.addSubview(self)
  1621. NSLayoutConstraint.activate([
  1622. leadingAnchor.constraint(equalTo: windowFrameView.leadingAnchor),
  1623. trailingAnchor.constraint(equalTo: windowFrameView.trailingAnchor),
  1624. topAnchor.constraint(equalTo: windowFrameView.topAnchor),
  1625. bottomAnchor.constraint(equalTo: windowFrameView.bottomAnchor),
  1626. ])
  1627. } else {
  1628. parent.addSubview(self)
  1629. NSLayoutConstraint.activate([
  1630. leadingAnchor.constraint(equalTo: parent.leadingAnchor),
  1631. trailingAnchor.constraint(equalTo: parent.trailingAnchor),
  1632. topAnchor.constraint(equalTo: parent.topAnchor),
  1633. bottomAnchor.constraint(equalTo: parent.bottomAnchor),
  1634. ])
  1635. }
  1636. alphaValue = 1
  1637. acceptsHitTesting = true
  1638. paywallView.refreshStoreState()
  1639. Task { @MainActor in
  1640. if StoreManager.shared.isResolvingEntitlements {
  1641. await StoreManager.shared.ensureEntitlementsResolved()
  1642. paywallView.refreshStoreState()
  1643. }
  1644. if StoreManager.shared.products.isEmpty, !StoreManager.shared.isLoadingProducts {
  1645. await StoreManager.shared.loadProducts()
  1646. paywallView.refreshStoreState()
  1647. }
  1648. }
  1649. Task { @MainActor in
  1650. await PaywallConfigService.shared.refreshFromRemote()
  1651. }
  1652. }
  1653. func dismiss() {
  1654. acceptsHitTesting = false
  1655. NSAnimationContext.runAnimationGroup({ context in
  1656. context.duration = 0.15
  1657. animator().alphaValue = 0
  1658. }, completionHandler: { [weak self] in
  1659. self?.removeFromSuperview()
  1660. self?.onDismiss?()
  1661. })
  1662. }
  1663. }