239 lines
9.4 KiB
Swift
239 lines
9.4 KiB
Swift
import SwiftUI
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindInfrastructure
|
|
import TermindPresentation
|
|
|
|
struct ProPaywallView: View {
|
|
@Environment(AppModel.self) private var app
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.locale) private var locale
|
|
@State private var selectedPlan: ProPurchasePlan = .annual
|
|
|
|
private var pro: ProAccessController { app.proAccess }
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
VStack(spacing: 14) {
|
|
Image(systemName: "sparkles.rectangle.stack.fill")
|
|
.font(.system(size: 40, weight: .medium))
|
|
.foregroundStyle(.tint)
|
|
VStack(spacing: Metrics.Space.s) {
|
|
Text("Termind Pro").font(.largeTitle.bold())
|
|
Text("Choose annual access or unlock Pro permanently.")
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
}
|
|
.padding(.top, 24)
|
|
|
|
VStack(alignment: .leading, spacing: Metrics.Space.m) {
|
|
feature("Use the AI Assistant", "sparkles")
|
|
feature("Monitor more than 3 servers", "waveform.path.ecg")
|
|
feature("Upload files by dropping them on a terminal", "arrow.up.doc")
|
|
feature("Connect MCP tools and run the built-in MCP Server", "server.rack")
|
|
feature("Sync Vault, Metrics, and Settings across devices", "icloud")
|
|
}
|
|
.frame(maxWidth: 460)
|
|
.padding(.vertical, 20)
|
|
|
|
if pro.requestedFeature == .unlimitedMetrics {
|
|
Text("Free includes up to 3 monitoring cards. Upgrade to Pro for more.")
|
|
.font(.callout).foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center).padding(.horizontal, 24).padding(.bottom, 12)
|
|
}
|
|
|
|
if pro.isProcessing { ProgressView("Contacting the App Store…").padding(.bottom, 12) }
|
|
if let message = pro.message {
|
|
Text(message).font(.caption).foregroundStyle(.orange)
|
|
.multilineTextAlignment(.center).padding(.bottom, 12)
|
|
}
|
|
|
|
VStack(spacing: 8) {
|
|
if pro.status.canPurchase {
|
|
let offer = ProOfferPresentation(
|
|
plan: selectedPlan, annualPrice: pro.annualDisplayPrice,
|
|
billingPeriod: pro.annualBillingPeriod, trialDuration: pro.annualIntroOfferDuration,
|
|
canStartTrial: pro.canStartTrial, locale: locale
|
|
) { AppLocalization.string($0, locale: locale) }
|
|
planPicker(offer)
|
|
Button(action: purchaseSelectedPlan) {
|
|
Text(offer.purchaseButtonTitle).frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.large)
|
|
.frame(maxWidth: 420)
|
|
.disabled(!canPurchaseSelectedPlan)
|
|
if let detail = offer.selectedPlanDetail {
|
|
Text(detail)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.frame(maxWidth: 420)
|
|
}
|
|
}
|
|
if canManageSubscription {
|
|
Link(
|
|
"Manage Subscription",
|
|
destination: URL(string: "https://apps.apple.com/account/subscriptions")!
|
|
)
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.large)
|
|
}
|
|
Button("Restore Purchase") { Task { await pro.restorePurchases() } }
|
|
.buttonStyle(.link)
|
|
.disabled(pro.isProcessing)
|
|
if pro.annualProduct == nil || pro.product == nil {
|
|
Button("Reload App Store Prices") { Task { await pro.reloadProducts() } }
|
|
.buttonStyle(.link)
|
|
.disabled(pro.isProcessing)
|
|
}
|
|
HStack(spacing: Metrics.Space.m) {
|
|
Link("Privacy Policy", destination: URL(string: "https://termind.akinokaede.com/privacy/")!)
|
|
Link(
|
|
"Terms of Use",
|
|
destination: URL(string: "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/")!)
|
|
}
|
|
.font(.caption2)
|
|
Text("Apple Family Sharing supported · AI provider charges are separate")
|
|
.font(.caption2).foregroundStyle(.tertiary)
|
|
}
|
|
.padding(.bottom, 20)
|
|
}
|
|
.frame(width: 540)
|
|
.padding(.horizontal, 32)
|
|
.task {
|
|
await pro.reloadProducts()
|
|
if pro.annualProduct == nil, pro.product != nil { selectedPlan = .lifetime }
|
|
}
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Close") { dismiss() }
|
|
}
|
|
}
|
|
}
|
|
|
|
private func planPicker(_ offer: ProOfferPresentation) -> some View {
|
|
HStack(spacing: Metrics.Space.s) {
|
|
planCard(
|
|
.annual,
|
|
title: "Annual",
|
|
price: pro.annualDisplayPrice,
|
|
detail: offer.annualBillingUnit.map { AppLocalization.string("per \($0)", locale: locale) },
|
|
badge: offer.trialBadge)
|
|
planCard(
|
|
.lifetime,
|
|
title: "Lifetime",
|
|
price: pro.displayPrice,
|
|
detail: AppLocalization.string("One-time purchase", locale: locale),
|
|
badge: nil)
|
|
}
|
|
.frame(maxWidth: 420)
|
|
}
|
|
|
|
private func planCard(
|
|
_ plan: ProPurchasePlan,
|
|
title: LocalizedStringKey,
|
|
price: String?,
|
|
detail: String?,
|
|
badge: String?
|
|
) -> some View {
|
|
let isSelected = selectedPlan == plan
|
|
let isAvailable = pro.canPurchase(plan)
|
|
return Button {
|
|
selectedPlan = plan
|
|
} label: {
|
|
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
|
|
HStack {
|
|
Text(title).font(.headline)
|
|
Spacer(minLength: 4)
|
|
if let badge {
|
|
Text(badge)
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundStyle(.tint)
|
|
.padding(.horizontal, 7)
|
|
.padding(.vertical, 3)
|
|
.background(.tint.opacity(0.12), in: .capsule)
|
|
}
|
|
}
|
|
Text(price ?? "—")
|
|
.font(.title2.bold())
|
|
.foregroundStyle(.primary)
|
|
if let detail {
|
|
Text(detail).font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 62, alignment: .leading)
|
|
.padding(12)
|
|
.background(isSelected ? Color.accentColor.opacity(0.1) : Color.secondary.opacity(0.06))
|
|
.clipShape(.rect(cornerRadius: 12))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.strokeBorder(
|
|
isSelected ? Color.accentColor : Color.secondary.opacity(0.2),
|
|
lineWidth: isSelected ? 2 : 1)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.focusEffectDisabled()
|
|
.disabled(!isAvailable)
|
|
.opacity(isAvailable ? 1 : 0.55)
|
|
}
|
|
|
|
private var canPurchaseSelectedPlan: Bool {
|
|
pro.canPurchase(selectedPlan)
|
|
}
|
|
|
|
private func purchaseSelectedPlan() {
|
|
switch selectedPlan {
|
|
case .annual:
|
|
Task { await pro.purchaseAnnual() }
|
|
case .lifetime:
|
|
Task { await pro.purchaseLifetime() }
|
|
}
|
|
}
|
|
|
|
private var canManageSubscription: Bool {
|
|
guard case .subscription(let subscription) = pro.status else { return false }
|
|
return subscription.ownership == .purchased
|
|
}
|
|
|
|
private func feature(_ title: LocalizedStringKey, _ symbol: String) -> some View {
|
|
Label(title, systemImage: symbol).font(.body)
|
|
}
|
|
}
|
|
|
|
struct ProPaywallPresenter: ViewModifier {
|
|
let app: AppModel
|
|
let window: WindowModel
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.onChange(of: app.proAccess.isPaywallPresented, initial: true) {
|
|
app.mainWindows.synchronizePaywall()
|
|
}
|
|
.sheet(
|
|
isPresented: Binding(
|
|
get: {
|
|
app.proAccess.isPaywallPresented
|
|
&& app.mainWindows.paywallOwnerID == ObjectIdentifier(window)
|
|
},
|
|
set: { presented in
|
|
guard app.mainWindows.paywallOwnerID == ObjectIdentifier(window) else { return }
|
|
app.proAccess.isPaywallPresented = presented
|
|
app.mainWindows.synchronizePaywall()
|
|
}
|
|
)
|
|
) {
|
|
ProPaywallView().environment(app)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
func proPaywallPresenter(app: AppModel, window: WindowModel) -> some View {
|
|
modifier(ProPaywallPresenter(app: app, window: window))
|
|
}
|
|
}
|