|
|
@@ -1,6 +1,31 @@
|
|
|
import Cocoa
|
|
|
import StoreKit
|
|
|
|
|
|
+// MARK: - Intro Offer Formatting
|
|
|
+
|
|
|
+private enum SubscriptionOfferFormatting {
|
|
|
+ static func trialDurationDescription(from offer: Product.SubscriptionOffer) -> String {
|
|
|
+ let count = offer.period.value * offer.periodCount
|
|
|
+ let unit: String
|
|
|
+ switch offer.period.unit {
|
|
|
+ case .day: unit = count == 1 ? "Day" : "Days"
|
|
|
+ case .week: unit = count == 1 ? "Week" : "Weeks"
|
|
|
+ case .month: unit = count == 1 ? "Month" : "Months"
|
|
|
+ case .year: unit = count == 1 ? "Year" : "Years"
|
|
|
+ @unknown default: return "\(count) Days"
|
|
|
+ }
|
|
|
+ return "\(count) \(unit)"
|
|
|
+ }
|
|
|
+
|
|
|
+ static func freeTrialBadgeText(from offer: Product.SubscriptionOffer) -> String {
|
|
|
+ "\(trialDurationDescription(from: offer)) Free Trial"
|
|
|
+ }
|
|
|
+
|
|
|
+ static func freeTrialCTATitle(from offer: Product.SubscriptionOffer) -> String {
|
|
|
+ "Start \(trialDurationDescription(from: offer)) Free Trial"
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
// MARK: - Plan Model
|
|
|
|
|
|
enum PaywallPlan: CaseIterable {
|
|
|
@@ -27,7 +52,7 @@ enum PaywallPlan: CaseIterable {
|
|
|
var subtitle: String {
|
|
|
switch self {
|
|
|
case .monthly: "$4.99 / month, cancel anytime"
|
|
|
- case .yearly: "Eligible new subscribers get 7 days free, then $29.99 / year"
|
|
|
+ case .yearly: "$29.99 / year, cancel anytime"
|
|
|
case .lifetime: "$99.99 once, lifetime access"
|
|
|
}
|
|
|
}
|
|
|
@@ -43,7 +68,7 @@ enum PaywallPlan: CaseIterable {
|
|
|
var ctaTitle: String {
|
|
|
switch self {
|
|
|
case .monthly: "Subscribe for $4.99 / Month"
|
|
|
- case .yearly: "Start 7-Day Free Trial"
|
|
|
+ case .yearly: "Subscribe for $29.99 / Year"
|
|
|
case .lifetime: "Buy Lifetime Access"
|
|
|
}
|
|
|
}
|
|
|
@@ -52,15 +77,16 @@ enum PaywallPlan: CaseIterable {
|
|
|
product?.displayPrice ?? price
|
|
|
}
|
|
|
|
|
|
- func localizedSubtitle(from product: Product?) -> String {
|
|
|
+ func localizedSubtitle(from product: Product?, introOffer: Product.SubscriptionOffer? = nil) -> String {
|
|
|
guard let product else { return subtitle }
|
|
|
|
|
|
switch self {
|
|
|
case .monthly:
|
|
|
return "\(product.displayPrice) / month, cancel anytime"
|
|
|
case .yearly:
|
|
|
- if product.subscription?.introductoryOffer != nil {
|
|
|
- return "Eligible new subscribers get 7 days free, then \(product.displayPrice) / year"
|
|
|
+ if let introOffer {
|
|
|
+ let duration = SubscriptionOfferFormatting.trialDurationDescription(from: introOffer).lowercased()
|
|
|
+ return "Get \(duration) free, then \(product.displayPrice) / year"
|
|
|
}
|
|
|
return "\(product.displayPrice) / year, cancel anytime"
|
|
|
case .lifetime:
|
|
|
@@ -68,15 +94,15 @@ enum PaywallPlan: CaseIterable {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- func localizedCTATitle(from product: Product?) -> String {
|
|
|
+ func localizedCTATitle(from product: Product?, introOffer: Product.SubscriptionOffer? = nil) -> String {
|
|
|
guard let product else { return ctaTitle }
|
|
|
|
|
|
switch self {
|
|
|
case .monthly:
|
|
|
return "Subscribe for \(product.displayPrice) / Month"
|
|
|
case .yearly:
|
|
|
- if product.subscription?.introductoryOffer != nil {
|
|
|
- return "Start 7-Day Free Trial"
|
|
|
+ if let introOffer {
|
|
|
+ return SubscriptionOfferFormatting.freeTrialCTATitle(from: introOffer)
|
|
|
}
|
|
|
return "Subscribe for \(product.displayPrice) / Year"
|
|
|
case .lifetime:
|
|
|
@@ -118,6 +144,7 @@ final class StoreManager {
|
|
|
var isPro: Bool { isPremium }
|
|
|
private(set) var isLoadingProducts = false
|
|
|
private(set) var isPurchasing = false
|
|
|
+ private(set) var introOfferEligibleByProductID: [String: Bool] = [:]
|
|
|
|
|
|
private var transactionListener: Task<Void, Never>?
|
|
|
private var hasStarted = false
|
|
|
@@ -144,6 +171,20 @@ final class StoreManager {
|
|
|
products.first { $0.id == plan.productID }
|
|
|
}
|
|
|
|
|
|
+ func eligibleIntroOffer(for plan: PaywallPlan) -> Product.SubscriptionOffer? {
|
|
|
+ guard let product = product(for: plan) else { return nil }
|
|
|
+ return eligibleIntroOffer(for: product)
|
|
|
+ }
|
|
|
+
|
|
|
+ func eligibleIntroOffer(for product: Product) -> Product.SubscriptionOffer? {
|
|
|
+ guard introOfferEligibleByProductID[product.id] == true,
|
|
|
+ let offer = product.subscription?.introductoryOffer,
|
|
|
+ offer.paymentMode == .freeTrial else {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ return offer
|
|
|
+ }
|
|
|
+
|
|
|
func loadProducts() async {
|
|
|
isLoadingProducts = true
|
|
|
postStoreStateDidChange()
|
|
|
@@ -157,6 +198,7 @@ final class StoreManager {
|
|
|
.sorted { lhs, rhs in
|
|
|
productSortOrder(for: lhs.id) < productSortOrder(for: rhs.id)
|
|
|
}
|
|
|
+ await refreshIntroOfferEligibility()
|
|
|
NotificationCenter.default.post(name: .storeProductsDidUpdate, object: nil)
|
|
|
} catch {
|
|
|
NSLog("Failed to load products: \(error.localizedDescription)")
|
|
|
@@ -251,6 +293,7 @@ final class StoreManager {
|
|
|
}
|
|
|
|
|
|
func refreshPremiumStatus() async {
|
|
|
+ await refreshIntroOfferEligibility()
|
|
|
let hasPremium = await hasActivePremiumAccess()
|
|
|
let didChange = hasPremium != isPremium
|
|
|
isPremium = hasPremium
|
|
|
@@ -338,6 +381,21 @@ final class StoreManager {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ private func refreshIntroOfferEligibility() async {
|
|
|
+ var eligibility: [String: Bool] = [:]
|
|
|
+
|
|
|
+ for product in products {
|
|
|
+ guard let subscription = product.subscription,
|
|
|
+ subscription.introductoryOffer != nil else {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ let groupID = subscription.subscriptionGroupID
|
|
|
+ eligibility[product.id] = await Product.SubscriptionInfo.isEligibleForIntroOffer(for: groupID)
|
|
|
+ }
|
|
|
+
|
|
|
+ introOfferEligibleByProductID = eligibility
|
|
|
+ }
|
|
|
+
|
|
|
private func postStoreStateDidChange() {
|
|
|
NotificationCenter.default.post(name: .storeStateDidChange, object: nil)
|
|
|
}
|
|
|
@@ -421,7 +479,10 @@ private final class PaywallLeftPanelView: NSView, AppearanceRefreshable {
|
|
|
// MARK: - Badge
|
|
|
|
|
|
private final class PaywallBadgeView: NSView {
|
|
|
+ private let label: NSTextField
|
|
|
+
|
|
|
init(text: String, iconName: String, background: NSColor, foreground: NSColor) {
|
|
|
+ label = NSTextField(labelWithString: text)
|
|
|
super.init(frame: .zero)
|
|
|
translatesAutoresizingMaskIntoConstraints = false
|
|
|
wantsLayer = true
|
|
|
@@ -437,7 +498,6 @@ private final class PaywallBadgeView: NSView {
|
|
|
}
|
|
|
icon.contentTintColor = foreground
|
|
|
|
|
|
- let label = NSTextField(labelWithString: text)
|
|
|
label.font = AppTheme.semiboldFont(size: 10)
|
|
|
label.textColor = foreground
|
|
|
label.translatesAutoresizingMaskIntoConstraints = false
|
|
|
@@ -459,6 +519,10 @@ private final class PaywallBadgeView: NSView {
|
|
|
])
|
|
|
}
|
|
|
|
|
|
+ func updateText(_ text: String) {
|
|
|
+ label.stringValue = text
|
|
|
+ }
|
|
|
+
|
|
|
@available(*, unavailable)
|
|
|
required init?(coder: NSCoder) { nil }
|
|
|
}
|
|
|
@@ -531,6 +595,7 @@ private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
|
|
|
private let subtitleLabel = NSTextField(labelWithString: "")
|
|
|
private let priceLabel = NSTextField(labelWithString: "")
|
|
|
private var badgeView: PaywallBadgeView?
|
|
|
+ private var trialBadgeView: PaywallBadgeView?
|
|
|
private var hoverTracker: HoverTracker?
|
|
|
private var isHovered = false
|
|
|
|
|
|
@@ -579,20 +644,7 @@ private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
|
|
|
priceLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
|
])
|
|
|
|
|
|
- if plan == .yearly {
|
|
|
- let badge = PaywallBadgeView(
|
|
|
- text: "7 Days Free Trial",
|
|
|
- iconName: "calendar",
|
|
|
- background: AppTheme.paywallPink,
|
|
|
- foreground: AppTheme.paywallPinkText
|
|
|
- )
|
|
|
- badgeView = badge
|
|
|
- addSubview(badge)
|
|
|
- NSLayoutConstraint.activate([
|
|
|
- badge.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
|
|
|
- badge.topAnchor.constraint(equalTo: topAnchor, constant: 8),
|
|
|
- ])
|
|
|
- } else if plan == .lifetime {
|
|
|
+ if plan == .lifetime {
|
|
|
let badge = PaywallBadgeView(
|
|
|
text: "Best Value",
|
|
|
iconName: "star.fill",
|
|
|
@@ -618,9 +670,37 @@ private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
|
|
|
@available(*, unavailable)
|
|
|
required init?(coder: NSCoder) { nil }
|
|
|
|
|
|
- func updateDisplay(product: Product?) {
|
|
|
- subtitleLabel.stringValue = plan.localizedSubtitle(from: product)
|
|
|
+ func updateDisplay(product: Product?, introOffer: Product.SubscriptionOffer? = nil) {
|
|
|
+ subtitleLabel.stringValue = plan.localizedSubtitle(from: product, introOffer: introOffer)
|
|
|
priceLabel.stringValue = plan.localizedPrice(from: product)
|
|
|
+ updateTrialBadge(introOffer: introOffer)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func updateTrialBadge(introOffer: Product.SubscriptionOffer?) {
|
|
|
+ guard plan == .yearly else { return }
|
|
|
+
|
|
|
+ if let introOffer {
|
|
|
+ let text = SubscriptionOfferFormatting.freeTrialBadgeText(from: introOffer)
|
|
|
+ if let trialBadgeView {
|
|
|
+ trialBadgeView.updateText(text)
|
|
|
+ trialBadgeView.isHidden = false
|
|
|
+ } else {
|
|
|
+ let badge = PaywallBadgeView(
|
|
|
+ text: text,
|
|
|
+ iconName: "calendar",
|
|
|
+ background: AppTheme.paywallPink,
|
|
|
+ foreground: AppTheme.paywallPinkText
|
|
|
+ )
|
|
|
+ trialBadgeView = badge
|
|
|
+ addSubview(badge)
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ badge.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
|
|
|
+ badge.topAnchor.constraint(equalTo: topAnchor, constant: 8),
|
|
|
+ ])
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ trialBadgeView?.isHidden = true
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
func refreshAppearance() {
|
|
|
@@ -925,10 +1005,10 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
self?.refreshProductDisplay()
|
|
|
},
|
|
|
center.addObserver(forName: .storeStateDidChange, object: nil, queue: .main) { [weak self] _ in
|
|
|
- self?.refreshPurchaseState()
|
|
|
+ self?.refreshProductDisplay()
|
|
|
},
|
|
|
center.addObserver(forName: .premiumStatusDidChange, object: nil, queue: .main) { [weak self] _ in
|
|
|
- self?.refreshPurchaseState()
|
|
|
+ self?.refreshProductDisplay()
|
|
|
},
|
|
|
]
|
|
|
}
|
|
|
@@ -936,9 +1016,16 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
private func refreshProductDisplay() {
|
|
|
let store = StoreManager.shared
|
|
|
for (plan, card) in planCards {
|
|
|
- card.updateDisplay(product: store.product(for: plan))
|
|
|
+ let product = store.product(for: plan)
|
|
|
+ card.updateDisplay(
|
|
|
+ product: product,
|
|
|
+ introOffer: store.eligibleIntroOffer(for: plan)
|
|
|
+ )
|
|
|
}
|
|
|
- ctaButton.title = selectedPlan.localizedCTATitle(from: store.product(for: selectedPlan))
|
|
|
+ ctaButton.title = selectedPlan.localizedCTATitle(
|
|
|
+ from: store.product(for: selectedPlan),
|
|
|
+ introOffer: store.eligibleIntroOffer(for: selectedPlan)
|
|
|
+ )
|
|
|
refreshPurchaseState()
|
|
|
}
|
|
|
|
|
|
@@ -1086,7 +1173,10 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
plansStack.addArrangedSubview(card)
|
|
|
}
|
|
|
|
|
|
- ctaButton.title = selectedPlan.localizedCTATitle(from: StoreManager.shared.product(for: selectedPlan))
|
|
|
+ ctaButton.title = selectedPlan.localizedCTATitle(
|
|
|
+ from: StoreManager.shared.product(for: selectedPlan),
|
|
|
+ introOffer: StoreManager.shared.eligibleIntroOffer(for: selectedPlan)
|
|
|
+ )
|
|
|
ctaButton.target = self
|
|
|
ctaButton.action = #selector(purchaseTapped)
|
|
|
ctaButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
@@ -1263,7 +1353,10 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
for (key, card) in planCards {
|
|
|
card.isChosen = key == plan
|
|
|
}
|
|
|
- ctaButton.title = plan.localizedCTATitle(from: StoreManager.shared.product(for: plan))
|
|
|
+ ctaButton.title = plan.localizedCTATitle(
|
|
|
+ from: StoreManager.shared.product(for: plan),
|
|
|
+ introOffer: StoreManager.shared.eligibleIntroOffer(for: plan)
|
|
|
+ )
|
|
|
}
|
|
|
|
|
|
@objc private func purchaseTapped() {
|