PaywallView.swift 73 KB

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