|
@@ -1,42 +1,6 @@
|
|
|
import Cocoa
|
|
import Cocoa
|
|
|
import StoreKit
|
|
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"
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- static func subscriptionPeriodDescription(from period: Product.SubscriptionPeriod) -> String {
|
|
|
|
|
- let count = period.value
|
|
|
|
|
- switch period.unit {
|
|
|
|
|
- case .day: return count == 1 ? "day" : "\(count) days"
|
|
|
|
|
- case .week: return count == 1 ? "week" : "\(count) weeks"
|
|
|
|
|
- case .month: return count == 1 ? "month" : "\(count) months"
|
|
|
|
|
- case .year: return count == 1 ? "year" : "\(count) years"
|
|
|
|
|
- @unknown default: return "\(count) days"
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
// MARK: - Plan Model
|
|
// MARK: - Plan Model
|
|
|
|
|
|
|
|
enum PaywallPlan: CaseIterable {
|
|
enum PaywallPlan: CaseIterable {
|
|
@@ -74,7 +38,7 @@ enum PaywallPlan: CaseIterable {
|
|
|
let price = product.displayPrice
|
|
let price = product.displayPrice
|
|
|
|
|
|
|
|
if self == .yearly, let introOffer, let trialTemplate = copy.trialSubtitleTemplate {
|
|
if self == .yearly, let introOffer, let trialTemplate = copy.trialSubtitleTemplate {
|
|
|
- let duration = SubscriptionOfferFormatting.trialDurationDescription(from: introOffer).lowercased()
|
|
|
|
|
|
|
+ let duration = config.trialDurationDescription(from: introOffer, lowercase: true)
|
|
|
return trialTemplate
|
|
return trialTemplate
|
|
|
.replacingOccurrences(of: "{trial}", with: duration)
|
|
.replacingOccurrences(of: "{trial}", with: duration)
|
|
|
.replacingOccurrences(of: "{price}", with: price)
|
|
.replacingOccurrences(of: "{price}", with: price)
|
|
@@ -93,7 +57,7 @@ enum PaywallPlan: CaseIterable {
|
|
|
let price = product.displayPrice
|
|
let price = product.displayPrice
|
|
|
|
|
|
|
|
if self == .yearly, let introOffer {
|
|
if self == .yearly, let introOffer {
|
|
|
- return SubscriptionOfferFormatting.freeTrialCTATitle(from: introOffer)
|
|
|
|
|
|
|
+ return config.trialCTATitle(from: introOffer)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
return copy.ctaTemplate.replacingOccurrences(of: "{price}", with: price)
|
|
return copy.ctaTemplate.replacingOccurrences(of: "{price}", with: price)
|
|
@@ -111,11 +75,11 @@ enum PaywallPlan: CaseIterable {
|
|
|
guard let product else { return config.loadingDisclosure }
|
|
guard let product else { return config.loadingDisclosure }
|
|
|
let price = product.displayPrice
|
|
let price = product.displayPrice
|
|
|
let period = product.subscription.map {
|
|
let period = product.subscription.map {
|
|
|
- SubscriptionOfferFormatting.subscriptionPeriodDescription(from: $0.subscriptionPeriod)
|
|
|
|
|
- } ?? "period"
|
|
|
|
|
|
|
+ config.subscriptionPeriodDescription(from: $0.subscriptionPeriod)
|
|
|
|
|
+ } ?? config.messages.periodFallback
|
|
|
|
|
|
|
|
if self == .yearly, let introOffer {
|
|
if self == .yearly, let introOffer {
|
|
|
- let trial = SubscriptionOfferFormatting.trialDurationDescription(from: introOffer).lowercased()
|
|
|
|
|
|
|
+ let trial = config.trialDurationDescription(from: introOffer, lowercase: true)
|
|
|
return config.trialDisclosureTemplate
|
|
return config.trialDisclosureTemplate
|
|
|
.replacingOccurrences(of: "{trial}", with: trial)
|
|
.replacingOccurrences(of: "{trial}", with: trial)
|
|
|
.replacingOccurrences(of: "{price}", with: price)
|
|
.replacingOccurrences(of: "{price}", with: price)
|
|
@@ -145,18 +109,15 @@ enum PremiumAccessKind: String {
|
|
|
case lifetime
|
|
case lifetime
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-enum StoreError: LocalizedError {
|
|
|
|
|
|
|
+enum StoreError: Error {
|
|
|
case productNotFound
|
|
case productNotFound
|
|
|
case failedVerification
|
|
case failedVerification
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- var errorDescription: String? {
|
|
|
|
|
- switch self {
|
|
|
|
|
- case .productNotFound:
|
|
|
|
|
- "The selected plan is not available right now. Please try again later."
|
|
|
|
|
- case .failedVerification:
|
|
|
|
|
- "We couldn't verify your purchase. Please contact support."
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+enum PurchaseOutcome: Sendable {
|
|
|
|
|
+ case purchased
|
|
|
|
|
+ case cancelled
|
|
|
|
|
+ case pending
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@MainActor
|
|
@MainActor
|
|
@@ -245,7 +206,7 @@ final class StoreManager {
|
|
|
do {
|
|
do {
|
|
|
let loaded = try await Product.products(for: StoreProductID.all)
|
|
let loaded = try await Product.products(for: StoreProductID.all)
|
|
|
guard !loaded.isEmpty else {
|
|
guard !loaded.isEmpty else {
|
|
|
- productLoadError = "No subscription plans are available right now."
|
|
|
|
|
|
|
+ productLoadError = PaywallConfigService.shared.config.messages.noPlansAvailable
|
|
|
products = []
|
|
products = []
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
@@ -256,13 +217,13 @@ final class StoreManager {
|
|
|
await refreshIntroOfferEligibility()
|
|
await refreshIntroOfferEligibility()
|
|
|
NotificationCenter.default.post(name: .storeProductsDidUpdate, object: nil)
|
|
NotificationCenter.default.post(name: .storeProductsDidUpdate, object: nil)
|
|
|
} catch {
|
|
} catch {
|
|
|
- productLoadError = "Couldn't load subscription plans. Check your connection and try again."
|
|
|
|
|
|
|
+ productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
|
|
|
NSLog("Failed to load products: \(error.localizedDescription)")
|
|
NSLog("Failed to load products: \(error.localizedDescription)")
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@discardableResult
|
|
@discardableResult
|
|
|
- func purchase(plan: PaywallPlan) async throws -> Bool {
|
|
|
|
|
|
|
+ func purchase(plan: PaywallPlan) async throws -> PurchaseOutcome {
|
|
|
if products.isEmpty {
|
|
if products.isEmpty {
|
|
|
await loadProducts()
|
|
await loadProducts()
|
|
|
}
|
|
}
|
|
@@ -285,11 +246,13 @@ final class StoreManager {
|
|
|
let transaction = try checkVerified(verification)
|
|
let transaction = try checkVerified(verification)
|
|
|
await transaction.finish()
|
|
await transaction.finish()
|
|
|
await refreshPremiumStatus()
|
|
await refreshPremiumStatus()
|
|
|
- return isPremium
|
|
|
|
|
- case .userCancelled, .pending:
|
|
|
|
|
- return false
|
|
|
|
|
|
|
+ return isPremium ? .purchased : .cancelled
|
|
|
|
|
+ case .userCancelled:
|
|
|
|
|
+ return .cancelled
|
|
|
|
|
+ case .pending:
|
|
|
|
|
+ return .pending
|
|
|
@unknown default:
|
|
@unknown default:
|
|
|
- return false
|
|
|
|
|
|
|
+ return .cancelled
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -312,7 +275,7 @@ final class StoreManager {
|
|
|
alert.messageText = title
|
|
alert.messageText = title
|
|
|
alert.informativeText = message
|
|
alert.informativeText = message
|
|
|
alert.alertStyle = .informational
|
|
alert.alertStyle = .informational
|
|
|
- alert.addButton(withTitle: "OK")
|
|
|
|
|
|
|
+ alert.addButton(withTitle: PaywallConfigService.shared.config.messages.alertOK)
|
|
|
if let window {
|
|
if let window {
|
|
|
alert.beginSheetModal(for: window)
|
|
alert.beginSheetModal(for: window)
|
|
|
} else {
|
|
} else {
|
|
@@ -327,16 +290,18 @@ final class StoreManager {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func showPurchaseError(_ error: Error, on window: NSWindow?) {
|
|
func showPurchaseError(_ error: Error, on window: NSWindow?) {
|
|
|
- if let storeError = error as? StoreError {
|
|
|
|
|
- showAlert(title: "Purchase Failed", message: storeError.localizedDescription, on: window)
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if let storeKitError = error as? StoreKitError, case .userCancelled = storeKitError {
|
|
|
|
|
- return
|
|
|
|
|
|
|
+ let messages = PaywallConfigService.shared.config.messages
|
|
|
|
|
+ switch error {
|
|
|
|
|
+ case StoreError.productNotFound:
|
|
|
|
|
+ showAlert(title: messages.purchaseFailedTitle, message: messages.productNotFound, on: window)
|
|
|
|
|
+ case StoreError.failedVerification:
|
|
|
|
|
+ showAlert(title: messages.purchaseFailedTitle, message: messages.failedVerification, on: window)
|
|
|
|
|
+ default:
|
|
|
|
|
+ if let storeKitError = error as? StoreKitError, case .userCancelled = storeKitError {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ showAlert(title: messages.purchaseFailedTitle, message: error.localizedDescription, on: window)
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- showAlert(title: "Purchase Failed", message: error.localizedDescription, on: window)
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func handleTransactionUpdate(_ update: VerificationResult<Transaction>) async {
|
|
private func handleTransactionUpdate(_ update: VerificationResult<Transaction>) async {
|
|
@@ -478,7 +443,9 @@ extension Notification.Name {
|
|
|
enum PremiumAccess {
|
|
enum PremiumAccess {
|
|
|
@MainActor
|
|
@MainActor
|
|
|
static var isGranted: Bool {
|
|
static var isGranted: Bool {
|
|
|
- StoreManager.shared.isPremium
|
|
|
|
|
|
|
+ let store = StoreManager.shared
|
|
|
|
|
+ guard !store.isResolvingEntitlements else { return false }
|
|
|
|
|
+ return store.isPremium
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@MainActor
|
|
@MainActor
|
|
@@ -780,7 +747,7 @@ private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
|
|
|
guard plan == .yearly else { return }
|
|
guard plan == .yearly else { return }
|
|
|
|
|
|
|
|
if let introOffer {
|
|
if let introOffer {
|
|
|
- let text = SubscriptionOfferFormatting.freeTrialBadgeText(from: introOffer)
|
|
|
|
|
|
|
+ let text = PaywallConfigService.shared.config.trialBadgeText(from: introOffer)
|
|
|
if let trialBadgeView {
|
|
if let trialBadgeView {
|
|
|
trialBadgeView.updateText(text)
|
|
trialBadgeView.updateText(text)
|
|
|
trialBadgeView.isHidden = false
|
|
trialBadgeView.isHidden = false
|
|
@@ -984,7 +951,7 @@ private final class PaywallProductsErrorView: NSView, AppearanceRefreshable {
|
|
|
var onRetry: (() -> Void)?
|
|
var onRetry: (() -> Void)?
|
|
|
|
|
|
|
|
private let messageLabel = NSTextField(wrappingLabelWithString: "")
|
|
private let messageLabel = NSTextField(wrappingLabelWithString: "")
|
|
|
- private let retryButton = PaywallFooterLink(title: "Retry")
|
|
|
|
|
|
|
+ private let retryButton = PaywallFooterLink(title: "")
|
|
|
|
|
|
|
|
init() {
|
|
init() {
|
|
|
super.init(frame: .zero)
|
|
super.init(frame: .zero)
|
|
@@ -1020,10 +987,11 @@ private final class PaywallProductsErrorView: NSView, AppearanceRefreshable {
|
|
|
required init?(coder: NSCoder) { nil }
|
|
required init?(coder: NSCoder) { nil }
|
|
|
|
|
|
|
|
func update(message: String, isRetrying: Bool) {
|
|
func update(message: String, isRetrying: Bool) {
|
|
|
|
|
+ let messages = PaywallConfigService.shared.config.messages
|
|
|
messageLabel.stringValue = message
|
|
messageLabel.stringValue = message
|
|
|
retryButton.isHidden = false
|
|
retryButton.isHidden = false
|
|
|
retryButton.isEnabled = !isRetrying
|
|
retryButton.isEnabled = !isRetrying
|
|
|
- retryButton.updateTitle(isRetrying ? "Retrying…" : "Retry")
|
|
|
|
|
|
|
+ retryButton.updateTitle(isRetrying ? messages.retrying : messages.retry)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func refreshAppearance() {
|
|
func refreshAppearance() {
|
|
@@ -1285,17 +1253,16 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
|
|
|
|
|
private func refreshProductsErrorState() {
|
|
private func refreshProductsErrorState() {
|
|
|
let store = StoreManager.shared
|
|
let store = StoreManager.shared
|
|
|
- let shouldShowError = store.productLoadError != nil
|
|
|
|
|
- && store.products.isEmpty
|
|
|
|
|
- && !store.isLoadingProducts
|
|
|
|
|
|
|
+ let hasLoadFailure = store.productLoadError != nil && store.products.isEmpty
|
|
|
|
|
+ let isRetrying = hasLoadFailure && store.isLoadingProducts
|
|
|
|
|
|
|
|
- productsErrorView.isHidden = !shouldShowError
|
|
|
|
|
- plansStack.isHidden = shouldShowError
|
|
|
|
|
- plansStackHeightConstraint.constant = shouldShowError ? 0 : plansAreaHeight
|
|
|
|
|
- productsErrorHeightConstraint.constant = shouldShowError ? productsErrorAreaHeight : 0
|
|
|
|
|
|
|
+ productsErrorView.isHidden = !hasLoadFailure
|
|
|
|
|
+ plansStack.isHidden = hasLoadFailure
|
|
|
|
|
+ plansStackHeightConstraint.constant = hasLoadFailure ? 0 : plansAreaHeight
|
|
|
|
|
+ productsErrorHeightConstraint.constant = hasLoadFailure ? productsErrorAreaHeight : 0
|
|
|
|
|
|
|
|
- if shouldShowError, let error = store.productLoadError {
|
|
|
|
|
- productsErrorView.update(message: error, isRetrying: false)
|
|
|
|
|
|
|
+ if hasLoadFailure, let error = store.productLoadError {
|
|
|
|
|
+ productsErrorView.update(message: error, isRetrying: isRetrying)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -1337,10 +1304,11 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
|
|
|
|
|
let cancelItem = paywallConfig.trustItems[1]
|
|
let cancelItem = paywallConfig.trustItems[1]
|
|
|
if selectedPlan == .lifetime {
|
|
if selectedPlan == .lifetime {
|
|
|
|
|
+ let item = paywallConfig.lifetimeTrustItem
|
|
|
trustItemViews[1].update(
|
|
trustItemViews[1].update(
|
|
|
- iconName: "checkmark.seal.fill",
|
|
|
|
|
- title: "One-Time Purchase",
|
|
|
|
|
- subtitle: "Pay once, no recurring charges."
|
|
|
|
|
|
|
+ iconName: item.icon,
|
|
|
|
|
+ title: item.title,
|
|
|
|
|
+ subtitle: item.subtitle
|
|
|
)
|
|
)
|
|
|
} else {
|
|
} else {
|
|
|
trustItemViews[1].update(
|
|
trustItemViews[1].update(
|
|
@@ -1780,10 +1748,20 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
Task { @MainActor in
|
|
Task { @MainActor in
|
|
|
refreshPurchaseState()
|
|
refreshPurchaseState()
|
|
|
do {
|
|
do {
|
|
|
- let succeeded = try await StoreManager.shared.purchase(plan: selectedPlan)
|
|
|
|
|
|
|
+ let outcome = try await StoreManager.shared.purchase(plan: selectedPlan)
|
|
|
refreshStoreState()
|
|
refreshStoreState()
|
|
|
- if succeeded {
|
|
|
|
|
|
|
+ switch outcome {
|
|
|
|
|
+ case .purchased:
|
|
|
AppRatingManager.shared.promptAfterFirstPurchaseIfNeeded(on: window)
|
|
AppRatingManager.shared.promptAfterFirstPurchaseIfNeeded(on: window)
|
|
|
|
|
+ case .pending:
|
|
|
|
|
+ let messages = paywallConfig.messages
|
|
|
|
|
+ StoreManager.shared.showAlert(
|
|
|
|
|
+ title: messages.pendingPurchaseTitle,
|
|
|
|
|
+ message: messages.pendingPurchaseMessage,
|
|
|
|
|
+ on: window
|
|
|
|
|
+ )
|
|
|
|
|
+ case .cancelled:
|
|
|
|
|
+ break
|
|
|
}
|
|
}
|
|
|
} catch {
|
|
} catch {
|
|
|
refreshPurchaseState()
|
|
refreshPurchaseState()
|
|
@@ -1799,9 +1777,10 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
let restored = try await StoreManager.shared.restorePurchases()
|
|
let restored = try await StoreManager.shared.restorePurchases()
|
|
|
refreshStoreState()
|
|
refreshStoreState()
|
|
|
if !restored {
|
|
if !restored {
|
|
|
|
|
+ let messages = paywallConfig.messages
|
|
|
StoreManager.shared.showAlert(
|
|
StoreManager.shared.showAlert(
|
|
|
- title: "No Purchases Found",
|
|
|
|
|
- message: "We couldn't find any previous purchases for this Apple ID.",
|
|
|
|
|
|
|
+ title: messages.restoreNotFoundTitle,
|
|
|
|
|
+ message: messages.restoreNotFoundMessage,
|
|
|
on: window
|
|
on: window
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|