|
|
@@ -1,12 +1,16 @@
|
|
|
import AppKit
|
|
|
+import StoreKit
|
|
|
import SwiftUI
|
|
|
|
|
|
struct PaywallView: View {
|
|
|
@EnvironmentObject private var subscriptions: SubscriptionManager
|
|
|
@EnvironmentObject private var appRating: AppRatingManager
|
|
|
+ @ObservedObject private var paywallConfigService = PaywallConfigService.shared
|
|
|
@Bindable var viewModel: PaywallViewModel
|
|
|
var onDismiss: () -> Void
|
|
|
|
|
|
+ private var config: PaywallConfig { paywallConfigService.config }
|
|
|
+
|
|
|
private var isPurchasingSelectedPlan: Bool {
|
|
|
subscriptions.purchasingPlan == viewModel.selectedPlan
|
|
|
}
|
|
|
@@ -19,6 +23,14 @@ struct PaywallView: View {
|
|
|
subscriptions.hasPremiumAccess
|
|
|
}
|
|
|
|
|
|
+ private var hasProductLoadFailure: Bool {
|
|
|
+ subscriptions.productLoadError != nil && !subscriptions.hasAllProductsLoaded
|
|
|
+ }
|
|
|
+
|
|
|
+ private var isProductReady: Bool {
|
|
|
+ subscriptions.product(for: viewModel.selectedPlan) != nil
|
|
|
+ }
|
|
|
+
|
|
|
var body: some View {
|
|
|
GeometryReader { geometry in
|
|
|
let metrics = PaywallLayoutMetrics(size: geometry.size)
|
|
|
@@ -30,7 +42,13 @@ struct PaywallView: View {
|
|
|
VStack(spacing: metrics.sectionSpacing) {
|
|
|
header(metrics: metrics)
|
|
|
featuresSection(metrics: metrics)
|
|
|
- pricingCards(metrics: metrics)
|
|
|
+
|
|
|
+ if hasProductLoadFailure {
|
|
|
+ productsErrorSection(metrics: metrics)
|
|
|
+ } else {
|
|
|
+ pricingCards(metrics: metrics)
|
|
|
+ }
|
|
|
+
|
|
|
ctaSection(metrics: metrics)
|
|
|
Spacer(minLength: 0)
|
|
|
footerLinks(metrics: metrics)
|
|
|
@@ -40,7 +58,7 @@ struct PaywallView: View {
|
|
|
.padding(.bottom, metrics.bottomInset)
|
|
|
.frame(width: geometry.size.width, height: geometry.size.height, alignment: .top)
|
|
|
|
|
|
- if subscriptions.isLoadingProducts && !subscriptions.hasAllProductsLoaded {
|
|
|
+ if subscriptions.isLoadingProducts && !subscriptions.hasAllProductsLoaded && !hasProductLoadFailure {
|
|
|
AppTheme.scrim
|
|
|
.ignoresSafeArea()
|
|
|
.allowsHitTesting(false)
|
|
|
@@ -56,10 +74,15 @@ struct PaywallView: View {
|
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
.ignoresSafeArea()
|
|
|
.task {
|
|
|
- await subscriptions.loadProducts()
|
|
|
+ await subscriptions.ensureEntitlementsResolved()
|
|
|
+ if subscriptions.productsByID.isEmpty, !subscriptions.isLoadingProducts {
|
|
|
+ await subscriptions.loadProducts()
|
|
|
+ }
|
|
|
+ await paywallConfigService.refreshFromRemote()
|
|
|
}
|
|
|
+ .onReceive(NotificationCenter.default.publisher(for: .paywallConfigDidUpdate)) { _ in }
|
|
|
.alert(
|
|
|
- "Subscriptions",
|
|
|
+ config.messages.purchaseFailedTitle,
|
|
|
isPresented: Binding(
|
|
|
get: { subscriptions.purchaseError != nil },
|
|
|
set: { presented in
|
|
|
@@ -67,7 +90,7 @@ struct PaywallView: View {
|
|
|
}
|
|
|
)
|
|
|
) {
|
|
|
- Button("OK", role: .cancel) {}
|
|
|
+ Button(config.messages.alertOK, role: .cancel) {}
|
|
|
} message: {
|
|
|
Text(subscriptions.purchaseError?.message ?? "")
|
|
|
}
|
|
|
@@ -102,11 +125,11 @@ struct PaywallView: View {
|
|
|
|
|
|
private func header(metrics: PaywallLayoutMetrics) -> some View {
|
|
|
VStack(spacing: metrics.headerSpacing) {
|
|
|
- Text(PaywallContent.title)
|
|
|
+ Text(config.rightTitle)
|
|
|
.font(.system(size: metrics.titleFontSize, weight: .bold))
|
|
|
.foregroundStyle(AppTheme.textPrimary)
|
|
|
|
|
|
- Text(PaywallContent.subtitle)
|
|
|
+ Text(config.rightSubtitle)
|
|
|
.font(.system(size: metrics.subtitleFontSize))
|
|
|
.foregroundStyle(AppTheme.textSecondary)
|
|
|
.multilineTextAlignment(.center)
|
|
|
@@ -117,26 +140,45 @@ struct PaywallView: View {
|
|
|
|
|
|
private func featuresSection(metrics: PaywallLayoutMetrics) -> some View {
|
|
|
VStack(alignment: .leading, spacing: metrics.featureSectionSpacing) {
|
|
|
- Text(PaywallContent.includedSectionTitle)
|
|
|
+ Text(config.leftPanelTitle)
|
|
|
.font(.system(size: metrics.sectionTitleFontSize, weight: .semibold))
|
|
|
.foregroundStyle(AppTheme.textPrimary)
|
|
|
|
|
|
LazyVGrid(columns: metrics.featureColumns, alignment: .leading, spacing: metrics.featureRowSpacing) {
|
|
|
- ForEach(PaywallContent.features) { feature in
|
|
|
- PaywallFeatureRow(feature: feature, metrics: metrics)
|
|
|
+ ForEach(Array(config.features.enumerated()), id: \.offset) { _, feature in
|
|
|
+ PaywallFeatureRow(title: feature, metrics: metrics)
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
}
|
|
|
|
|
|
+ private func productsErrorSection(metrics: PaywallLayoutMetrics) -> some View {
|
|
|
+ VStack(spacing: metrics.ctaSpacing) {
|
|
|
+ Text(subscriptions.productLoadError ?? config.messages.plansLoadFailed)
|
|
|
+ .font(.system(size: metrics.footerNoteFontSize))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ .multilineTextAlignment(.center)
|
|
|
+
|
|
|
+ Button(subscriptions.isLoadingProducts ? config.messages.retrying : config.messages.retry) {
|
|
|
+ Task { await subscriptions.loadProducts(presentLoadingUI: true) }
|
|
|
+ }
|
|
|
+ .buttonStyle(AppTextLinkButtonStyle())
|
|
|
+ .font(.system(size: metrics.footerLinkFontSize))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ .disabled(subscriptions.isLoadingProducts)
|
|
|
+ }
|
|
|
+ .frame(maxWidth: .infinity, minHeight: metrics.cardMinHeight)
|
|
|
+ }
|
|
|
+
|
|
|
private func pricingCards(metrics: PaywallLayoutMetrics) -> some View {
|
|
|
HStack(alignment: .top, spacing: metrics.cardSpacing) {
|
|
|
ForEach(PaywallPlan.allCases) { plan in
|
|
|
PaywallPricingCard(
|
|
|
plan: plan,
|
|
|
- price: subscriptions.mainPrice(for: plan),
|
|
|
- priceSuffix: subscriptions.priceSuffix(for: plan),
|
|
|
+ config: config,
|
|
|
+ product: subscriptions.product(for: plan),
|
|
|
+ introOffer: subscriptions.eligibleIntroOffer(for: plan),
|
|
|
isSelected: viewModel.selectedPlan == plan,
|
|
|
metrics: metrics
|
|
|
) {
|
|
|
@@ -149,11 +191,21 @@ struct PaywallView: View {
|
|
|
}
|
|
|
|
|
|
private func ctaSection(metrics: PaywallLayoutMetrics) -> some View {
|
|
|
- VStack(spacing: metrics.ctaSpacing) {
|
|
|
- Text(subscriptions.billingDescription(for: viewModel.selectedPlan))
|
|
|
- .font(.system(size: metrics.footerNoteFontSize))
|
|
|
- .foregroundStyle(AppTheme.textTertiary)
|
|
|
- .multilineTextAlignment(.center)
|
|
|
+ let selectedProduct = subscriptions.product(for: viewModel.selectedPlan)
|
|
|
+ let introOffer = subscriptions.eligibleIntroOffer(for: viewModel.selectedPlan)
|
|
|
+ let ctaDisabled = buttonsBusy || subscriptions.isLoadingProducts || !isProductReady || hasProductLoadFailure
|
|
|
+
|
|
|
+ return VStack(spacing: metrics.ctaSpacing) {
|
|
|
+ Text(
|
|
|
+ viewModel.selectedPlan.localizedRenewalDisclosure(
|
|
|
+ from: selectedProduct,
|
|
|
+ config: config,
|
|
|
+ introOffer: introOffer
|
|
|
+ )
|
|
|
+ )
|
|
|
+ .font(.system(size: metrics.footerNoteFontSize))
|
|
|
+ .foregroundStyle(AppTheme.textTertiary)
|
|
|
+ .multilineTextAlignment(.center)
|
|
|
|
|
|
Button {
|
|
|
Task {
|
|
|
@@ -170,9 +222,15 @@ struct PaywallView: View {
|
|
|
.controlSize(.small)
|
|
|
.tint(Color(hex: 0x0B0E14))
|
|
|
}
|
|
|
- Text(viewModel.selectedPlan.ctaTitle)
|
|
|
- .font(.system(size: metrics.ctaFontSize, weight: .bold))
|
|
|
- .foregroundStyle(Color(hex: 0x0B0E14))
|
|
|
+ Text(
|
|
|
+ viewModel.selectedPlan.localizedCTATitle(
|
|
|
+ from: selectedProduct,
|
|
|
+ config: config,
|
|
|
+ introOffer: introOffer
|
|
|
+ )
|
|
|
+ )
|
|
|
+ .font(.system(size: metrics.ctaFontSize, weight: .bold))
|
|
|
+ .foregroundStyle(Color(hex: 0x0B0E14))
|
|
|
}
|
|
|
.frame(maxWidth: .infinity)
|
|
|
.padding(.vertical, metrics.ctaVerticalPadding)
|
|
|
@@ -186,8 +244,8 @@ struct PaywallView: View {
|
|
|
.clipShape(RoundedRectangle(cornerRadius: metrics.cornerRadius))
|
|
|
}
|
|
|
.buttonStyle(AppPrimaryButtonStyle())
|
|
|
- .disabled(buttonsBusy || subscriptions.isLoadingProducts)
|
|
|
- .opacity((buttonsBusy || subscriptions.isLoadingProducts) ? 0.7 : 1)
|
|
|
+ .disabled(ctaDisabled)
|
|
|
+ .opacity(ctaDisabled ? 0.7 : 1)
|
|
|
|
|
|
HStack(spacing: 6) {
|
|
|
Image(systemName: "lock.fill")
|
|
|
@@ -201,21 +259,22 @@ struct PaywallView: View {
|
|
|
|
|
|
private func footerLinks(metrics: PaywallLayoutMetrics) -> some View {
|
|
|
HStack(spacing: 0) {
|
|
|
- footerLink(
|
|
|
- isPremium ? "Manage Subscription" : PaywallContent.continueWithFreePlan,
|
|
|
- metrics: metrics
|
|
|
- ) {
|
|
|
- if isPremium {
|
|
|
- subscriptions.openSubscriptionManagement()
|
|
|
- } else {
|
|
|
+ if !isPremium {
|
|
|
+ footerLink(config.footer.continueFree, metrics: metrics) {
|
|
|
onDismiss()
|
|
|
}
|
|
|
- }
|
|
|
|
|
|
- Spacer(minLength: metrics.footerLinkSpacing)
|
|
|
+ Spacer(minLength: metrics.footerLinkSpacing)
|
|
|
+ } else if subscriptions.hasActiveSubscription {
|
|
|
+ footerLink(config.footer.manageSubscription, metrics: metrics) {
|
|
|
+ subscriptions.openSubscriptionManagement()
|
|
|
+ }
|
|
|
+
|
|
|
+ Spacer(minLength: metrics.footerLinkSpacing)
|
|
|
+ }
|
|
|
|
|
|
footerLink(
|
|
|
- subscriptions.isRestoringPurchases ? "Restoring..." : PaywallContent.restorePurchases,
|
|
|
+ subscriptions.isRestoringPurchases ? config.messages.retrying : config.footer.restorePurchase,
|
|
|
metrics: metrics
|
|
|
) {
|
|
|
Task {
|
|
|
@@ -229,26 +288,31 @@ struct PaywallView: View {
|
|
|
|
|
|
Spacer(minLength: metrics.footerLinkSpacing)
|
|
|
|
|
|
- footerLink(PaywallContent.privacyPolicy, metrics: metrics) {
|
|
|
- NSWorkspace.shared.open(AppLinks.privacy)
|
|
|
+ footerLink(config.footer.privacyPolicy, metrics: metrics) {
|
|
|
+ openExternalLink(config.urls.privacy)
|
|
|
}
|
|
|
|
|
|
Spacer(minLength: metrics.footerLinkSpacing)
|
|
|
|
|
|
- footerLink(PaywallContent.support, metrics: metrics) {
|
|
|
- NSWorkspace.shared.open(AppLinks.support)
|
|
|
+ footerLink(config.footer.support, metrics: metrics) {
|
|
|
+ openExternalLink(config.urls.support)
|
|
|
}
|
|
|
|
|
|
Spacer(minLength: metrics.footerLinkSpacing)
|
|
|
|
|
|
- footerLink(PaywallContent.termsOfService, metrics: metrics) {
|
|
|
- NSWorkspace.shared.open(AppLinks.terms)
|
|
|
+ footerLink(config.footer.termsOfService, metrics: metrics) {
|
|
|
+ openExternalLink(config.urls.terms)
|
|
|
}
|
|
|
}
|
|
|
.padding(.horizontal, metrics.footerLinksHorizontalInset)
|
|
|
.frame(maxWidth: .infinity)
|
|
|
}
|
|
|
|
|
|
+ private func openExternalLink(_ urlString: String) {
|
|
|
+ guard let url = URL(string: urlString) else { return }
|
|
|
+ NSWorkspace.shared.open(url)
|
|
|
+ }
|
|
|
+
|
|
|
private func footerLink(_ title: String, metrics: PaywallLayoutMetrics, action: @escaping () -> Void) -> some View {
|
|
|
Button(title, action: action)
|
|
|
.buttonStyle(AppTextLinkButtonStyle())
|
|
|
@@ -309,17 +373,17 @@ private struct PaywallLayoutMetrics {
|
|
|
// MARK: - Components
|
|
|
|
|
|
private struct PaywallFeatureRow: View {
|
|
|
- let feature: PaywallFeature
|
|
|
+ let title: String
|
|
|
let metrics: PaywallLayoutMetrics
|
|
|
|
|
|
var body: some View {
|
|
|
HStack(spacing: 8) {
|
|
|
- Image(systemName: feature.systemImage)
|
|
|
- .font(.system(size: metrics.featureFontSize * 0.9, weight: .semibold))
|
|
|
- .foregroundStyle(AppTheme.accentTeal)
|
|
|
+ Image(systemName: "checkmark")
|
|
|
+ .font(.system(size: metrics.featureFontSize * 0.75, weight: .bold))
|
|
|
+ .foregroundStyle(AppTheme.accentGreen)
|
|
|
.frame(width: metrics.featureFontSize + 4)
|
|
|
|
|
|
- Text(feature.title)
|
|
|
+ Text(title)
|
|
|
.font(.system(size: metrics.featureFontSize))
|
|
|
.foregroundStyle(AppTheme.textSecondary)
|
|
|
.lineLimit(1)
|
|
|
@@ -330,12 +394,17 @@ private struct PaywallFeatureRow: View {
|
|
|
|
|
|
private struct PaywallPricingCard: View {
|
|
|
let plan: PaywallPlan
|
|
|
- let price: String
|
|
|
- let priceSuffix: String?
|
|
|
+ let config: PaywallConfig
|
|
|
+ let product: Product?
|
|
|
+ let introOffer: Product.SubscriptionOffer?
|
|
|
let isSelected: Bool
|
|
|
let metrics: PaywallLayoutMetrics
|
|
|
let onSelect: () -> Void
|
|
|
|
|
|
+ private var planCopy: PaywallConfig.PlanCopy {
|
|
|
+ plan.planCopy(from: config)
|
|
|
+ }
|
|
|
+
|
|
|
var body: some View {
|
|
|
Button(action: onSelect) {
|
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
|
@@ -343,23 +412,23 @@ private struct PaywallPricingCard: View {
|
|
|
.frame(height: metrics.cardPadding * 1.0)
|
|
|
.padding(.bottom, metrics.cardPadding * 0.15)
|
|
|
|
|
|
- Text(plan.title)
|
|
|
+ Text(planCopy.title)
|
|
|
.font(.system(size: metrics.ctaFontSize, weight: .semibold))
|
|
|
.foregroundStyle(AppTheme.textPrimary)
|
|
|
|
|
|
- Text(plan.subtitle)
|
|
|
+ Text(plan.localizedSubtitle(from: product, config: config, introOffer: introOffer))
|
|
|
.font(.system(size: metrics.featureFontSize * 0.85))
|
|
|
.foregroundStyle(AppTheme.textTertiary)
|
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
.padding(.top, 2)
|
|
|
|
|
|
HStack(alignment: .firstTextBaseline, spacing: 2) {
|
|
|
- Text(price)
|
|
|
+ Text(plan.localizedPrice(from: product, config: config))
|
|
|
.font(.system(size: metrics.titleFontSize * 0.85, weight: .bold))
|
|
|
.foregroundStyle(AppTheme.textPrimary)
|
|
|
|
|
|
- if let suffix = priceSuffix {
|
|
|
- Text(suffix)
|
|
|
+ if plan.priceSuffix != nil {
|
|
|
+ Text(plan.priceSuffix ?? "")
|
|
|
.font(.system(size: metrics.featureFontSize))
|
|
|
.foregroundStyle(AppTheme.textSecondary)
|
|
|
}
|
|
|
@@ -400,8 +469,8 @@ private struct PaywallPricingCard: View {
|
|
|
)
|
|
|
)
|
|
|
.overlay(alignment: .topTrailing) {
|
|
|
- if plan.isBestValue {
|
|
|
- Text("BEST VALUE")
|
|
|
+ if let badge = planCopy.badge, plan == .lifetime {
|
|
|
+ Text(badge)
|
|
|
.font(.system(size: metrics.featureFontSize * 0.75, weight: .bold))
|
|
|
.foregroundStyle(Color(hex: 0x0B0E14))
|
|
|
.padding(.horizontal, 10)
|
|
|
@@ -410,6 +479,16 @@ private struct PaywallPricingCard: View {
|
|
|
.clipShape(Capsule())
|
|
|
.padding(.top, metrics.cardPadding * 0.65)
|
|
|
.padding(.trailing, metrics.cardPadding * 0.65)
|
|
|
+ } else if plan == .yearly, let introOffer {
|
|
|
+ Text(config.trialBadgeText(from: introOffer))
|
|
|
+ .font(.system(size: metrics.featureFontSize * 0.75, weight: .bold))
|
|
|
+ .foregroundStyle(Color(hex: 0x0B0E14))
|
|
|
+ .padding(.horizontal, 10)
|
|
|
+ .padding(.vertical, 4)
|
|
|
+ .background(AppTheme.accentPurpleLight)
|
|
|
+ .clipShape(Capsule())
|
|
|
+ .padding(.top, metrics.cardPadding * 0.65)
|
|
|
+ .padding(.trailing, metrics.cardPadding * 0.65)
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
@@ -419,8 +498,13 @@ private struct PaywallPricingCard: View {
|
|
|
}
|
|
|
|
|
|
#Preview {
|
|
|
- PaywallView(viewModel: PaywallViewModel(), onDismiss: {})
|
|
|
- .environmentObject(SubscriptionManager())
|
|
|
+ let subscriptions = SubscriptionManager()
|
|
|
+ return PaywallView(viewModel: PaywallViewModel(), onDismiss: {})
|
|
|
+ .environmentObject(subscriptions)
|
|
|
.environmentObject(AppRatingManager())
|
|
|
.frame(width: AppWindow.width, height: AppWindow.height)
|
|
|
+ .onAppear {
|
|
|
+ subscriptions.start()
|
|
|
+ PaywallConfigService.shared.start()
|
|
|
+ }
|
|
|
}
|