|
|
@@ -0,0 +1,406 @@
|
|
|
+import SwiftUI
|
|
|
+
|
|
|
+struct PaywallView: View {
|
|
|
+ @ObservedObject var viewModel: PaywallViewModel
|
|
|
+ @Environment(\.openURL) private var openURL
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ ZStack {
|
|
|
+ AppTheme.pageBackground
|
|
|
+ .ignoresSafeArea()
|
|
|
+
|
|
|
+ VStack(spacing: 0) {
|
|
|
+ header
|
|
|
+ .padding(.bottom, 24)
|
|
|
+
|
|
|
+ featureHighlights
|
|
|
+ .padding(.bottom, 16)
|
|
|
+
|
|
|
+ trustHighlights
|
|
|
+ .padding(.bottom, 20)
|
|
|
+
|
|
|
+ pricingSection
|
|
|
+ .padding(.bottom, 20)
|
|
|
+
|
|
|
+ ctaSection
|
|
|
+ .padding(.bottom, 16)
|
|
|
+
|
|
|
+ legalLinks
|
|
|
+
|
|
|
+ Spacer(minLength: 0)
|
|
|
+ }
|
|
|
+ .padding(.horizontal, 48)
|
|
|
+ .padding(.vertical, 28)
|
|
|
+ .frame(maxWidth: 920)
|
|
|
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
+ }
|
|
|
+ .accessibilityElement(children: .contain)
|
|
|
+ .accessibilityLabel("Premium subscription")
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Header
|
|
|
+
|
|
|
+ private var header: some View {
|
|
|
+ HStack(spacing: 10) {
|
|
|
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
|
+ .fill(AppTheme.accent)
|
|
|
+ .frame(width: 32, height: 32)
|
|
|
+ .overlay {
|
|
|
+ Image(systemName: "sparkle")
|
|
|
+ .font(.system(size: 14, weight: .bold))
|
|
|
+ .foregroundStyle(.white)
|
|
|
+ }
|
|
|
+
|
|
|
+ Text("AI ChatBot Assistant")
|
|
|
+ .font(.system(size: 17, weight: .bold))
|
|
|
+ .foregroundStyle(AppTheme.textPrimary)
|
|
|
+ }
|
|
|
+ .frame(maxWidth: .infinity, alignment: .center)
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Feature Highlights
|
|
|
+
|
|
|
+ private var featureHighlights: some View {
|
|
|
+ HStack(spacing: 10) {
|
|
|
+ PaywallFeatureCard(
|
|
|
+ icon: "bubble.left.and.bubble.right.fill",
|
|
|
+ title: "Smarter responses"
|
|
|
+ )
|
|
|
+ PaywallFeatureCard(
|
|
|
+ icon: "bolt.fill",
|
|
|
+ title: "Faster workflow"
|
|
|
+ )
|
|
|
+ PaywallFeatureCard(
|
|
|
+ icon: "sparkles",
|
|
|
+ title: "Upgraded models"
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Pricing
|
|
|
+
|
|
|
+ private var pricingSection: some View {
|
|
|
+ HStack(alignment: .top, spacing: 14) {
|
|
|
+ ForEach(SubscriptionPlan.allCases) { plan in
|
|
|
+ PaywallPlanCard(
|
|
|
+ plan: plan,
|
|
|
+ price: viewModel.price(for: plan),
|
|
|
+ isSelected: viewModel.selectedPlan == plan
|
|
|
+ ) {
|
|
|
+ viewModel.selectPlan(plan)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Trust Highlights
|
|
|
+
|
|
|
+ private var trustHighlights: some View {
|
|
|
+ HStack(spacing: 10) {
|
|
|
+ PaywallTrustCard(
|
|
|
+ icon: "headphones",
|
|
|
+ title: "Priority support",
|
|
|
+ subtitle: "Fast assistance"
|
|
|
+ )
|
|
|
+ PaywallTrustCard(
|
|
|
+ icon: "key.fill",
|
|
|
+ title: "Instant access",
|
|
|
+ subtitle: "Unlock all tools"
|
|
|
+ )
|
|
|
+ PaywallTrustCard(
|
|
|
+ icon: "lock.shield.fill",
|
|
|
+ title: "Secure billing",
|
|
|
+ subtitle: "Handled by Apple"
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - CTA
|
|
|
+
|
|
|
+ private var ctaSection: some View {
|
|
|
+ VStack(spacing: 10) {
|
|
|
+ Button(action: viewModel.startPremium) {
|
|
|
+ HStack(spacing: 8) {
|
|
|
+ if viewModel.isProcessing {
|
|
|
+ ProgressView()
|
|
|
+ .controlSize(.small)
|
|
|
+ .tint(.white)
|
|
|
+ }
|
|
|
+
|
|
|
+ Text(viewModel.isProcessing ? "Processing…" : "Start Premium")
|
|
|
+ .font(.system(size: 16, weight: .bold))
|
|
|
+ }
|
|
|
+ .foregroundStyle(.white)
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
+ .padding(.vertical, 15)
|
|
|
+ .background(
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.cornerRadiusMedium, style: .continuous)
|
|
|
+ .fill(AppTheme.accent)
|
|
|
+ )
|
|
|
+ }
|
|
|
+ .buttonStyle(.plain)
|
|
|
+ .disabled(viewModel.isProcessing)
|
|
|
+ .accessibilityLabel("Start Premium subscription")
|
|
|
+
|
|
|
+ if let statusMessage = viewModel.statusMessage {
|
|
|
+ Text(statusMessage)
|
|
|
+ .font(.system(size: 13, weight: .medium))
|
|
|
+ .foregroundStyle(AppTheme.paywallSuccess)
|
|
|
+ }
|
|
|
+
|
|
|
+ if let errorMessage = viewModel.errorMessage {
|
|
|
+ Text(errorMessage)
|
|
|
+ .font(.system(size: 13))
|
|
|
+ .foregroundStyle(AppTheme.paywallError)
|
|
|
+ .multilineTextAlignment(.center)
|
|
|
+ }
|
|
|
+
|
|
|
+ Text("Secured by Apple. Cancel anytime.")
|
|
|
+ .font(.system(size: 12))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+
|
|
|
+ Text(autoRenewalDisclosure)
|
|
|
+ .font(.system(size: 11))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary.opacity(0.85))
|
|
|
+ .multilineTextAlignment(.center)
|
|
|
+ .lineSpacing(2)
|
|
|
+ .padding(.top, 2)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private var autoRenewalDisclosure: String {
|
|
|
+ let price = viewModel.price(for: viewModel.selectedPlan)
|
|
|
+ let period: String
|
|
|
+ switch viewModel.selectedPlan {
|
|
|
+ case .monthly:
|
|
|
+ period = "month"
|
|
|
+ case .biYearly:
|
|
|
+ period = "6 months"
|
|
|
+ case .yearly:
|
|
|
+ period = "year"
|
|
|
+ }
|
|
|
+ return "Payment will be charged to your Apple ID at confirmation of purchase. Subscription automatically renews unless canceled at least 24 hours before the end of the current period. Your account will be charged \(price) for renewal within 24 hours prior to the end of the current \(period)."
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Legal Links
|
|
|
+
|
|
|
+ private var legalLinks: some View {
|
|
|
+ HStack(spacing: 16) {
|
|
|
+ PaywallLinkButton(title: "Continue with Free Plan") {
|
|
|
+ viewModel.dismiss()
|
|
|
+ }
|
|
|
+ PaywallLinkButton(title: "Restore Purchase") {
|
|
|
+ viewModel.restorePurchases()
|
|
|
+ }
|
|
|
+ PaywallLinkButton(title: "Privacy Policy") {
|
|
|
+ openURL(AppLinks.privacyPolicy)
|
|
|
+ }
|
|
|
+ PaywallLinkButton(title: "Support") {
|
|
|
+ openURL(AppLinks.support)
|
|
|
+ }
|
|
|
+ PaywallLinkButton(title: "Terms") {
|
|
|
+ openURL(AppLinks.termsOfUse)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// MARK: - Subviews
|
|
|
+
|
|
|
+private struct PaywallFeatureCard: View {
|
|
|
+ let icon: String
|
|
|
+ let title: String
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ VStack(spacing: 6) {
|
|
|
+ Image(systemName: icon)
|
|
|
+ .font(.system(size: 15, weight: .semibold))
|
|
|
+ .foregroundStyle(AppTheme.accent)
|
|
|
+
|
|
|
+ Text(title)
|
|
|
+ .font(.system(size: 12, weight: .semibold))
|
|
|
+ .foregroundStyle(AppTheme.textPrimary)
|
|
|
+ .multilineTextAlignment(.center)
|
|
|
+ }
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
+ .padding(.vertical, 10)
|
|
|
+ .padding(.horizontal, 8)
|
|
|
+ .background(
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.cornerRadiusMedium, style: .continuous)
|
|
|
+ .fill(AppTheme.paywallHeroBackground)
|
|
|
+ )
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.cornerRadiusMedium, style: .continuous)
|
|
|
+ .stroke(AppTheme.paywallCardBorder, lineWidth: 1)
|
|
|
+ )
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private struct PaywallTrustCard: View {
|
|
|
+ let icon: String
|
|
|
+ let title: String
|
|
|
+ let subtitle: String
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ VStack(spacing: 4) {
|
|
|
+ Image(systemName: icon)
|
|
|
+ .font(.system(size: 15, weight: .semibold))
|
|
|
+ .foregroundStyle(AppTheme.accent)
|
|
|
+
|
|
|
+ Text(title)
|
|
|
+ .font(.system(size: 12, weight: .semibold))
|
|
|
+ .foregroundStyle(AppTheme.textPrimary)
|
|
|
+ .multilineTextAlignment(.center)
|
|
|
+
|
|
|
+ Text(subtitle)
|
|
|
+ .font(.system(size: 10))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ .multilineTextAlignment(.center)
|
|
|
+ }
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
+ .padding(.vertical, 10)
|
|
|
+ .padding(.horizontal, 8)
|
|
|
+ .background(
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.cornerRadiusMedium, style: .continuous)
|
|
|
+ .fill(AppTheme.paywallHeroBackground)
|
|
|
+ )
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.cornerRadiusMedium, style: .continuous)
|
|
|
+ .stroke(AppTheme.paywallCardBorder, lineWidth: 1)
|
|
|
+ )
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private struct PaywallPlanCard: View {
|
|
|
+ let plan: SubscriptionPlan
|
|
|
+ let price: String
|
|
|
+ let isSelected: Bool
|
|
|
+ let onSelect: () -> Void
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ Button(action: onSelect) {
|
|
|
+ VStack(spacing: 0) {
|
|
|
+ badgeRow
|
|
|
+ .frame(height: 28)
|
|
|
+ .padding(.bottom, 8)
|
|
|
+
|
|
|
+ Text(plan.title)
|
|
|
+ .font(.system(size: 14, weight: .semibold))
|
|
|
+ .foregroundStyle(AppTheme.textPrimary)
|
|
|
+ .padding(.bottom, 6)
|
|
|
+
|
|
|
+ Text(price)
|
|
|
+ .font(.system(size: 28, weight: .bold))
|
|
|
+ .foregroundStyle(AppTheme.textPrimary)
|
|
|
+ .padding(.bottom, 4)
|
|
|
+
|
|
|
+ Text(plan.subtitle)
|
|
|
+ .font(.system(size: 11))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ .multilineTextAlignment(.center)
|
|
|
+ .padding(.bottom, 8)
|
|
|
+
|
|
|
+ Text(plan.billingDetail ?? "\u{00A0}")
|
|
|
+ .font(.system(size: 10))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary.opacity(plan.billingDetail == nil ? 0 : 0.8))
|
|
|
+ }
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
+ .padding(.horizontal, 12)
|
|
|
+ .padding(.vertical, 16)
|
|
|
+ .background(
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.cornerRadiusMedium, style: .continuous)
|
|
|
+ .fill(Color.white)
|
|
|
+ )
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.cornerRadiusMedium, style: .continuous)
|
|
|
+ .stroke(
|
|
|
+ isSelected ? AppTheme.accent : AppTheme.paywallCardBorder,
|
|
|
+ lineWidth: isSelected ? 2 : 1
|
|
|
+ )
|
|
|
+ )
|
|
|
+ }
|
|
|
+ .buttonStyle(.plain)
|
|
|
+ .accessibilityLabel("\(plan.title), \(price)")
|
|
|
+ .accessibilityAddTraits(isSelected ? .isSelected : [])
|
|
|
+ }
|
|
|
+
|
|
|
+ @ViewBuilder
|
|
|
+ private var badgeRow: some View {
|
|
|
+ HStack(spacing: 6) {
|
|
|
+ if plan.isRecommended {
|
|
|
+ PaywallBadge(text: "Best Value", style: .accent)
|
|
|
+ }
|
|
|
+ if let savings = plan.savingsBadge {
|
|
|
+ PaywallBadge(text: savings, style: .savings)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private struct PaywallBadge: View {
|
|
|
+ enum Style {
|
|
|
+ case accent
|
|
|
+ case savings
|
|
|
+ }
|
|
|
+
|
|
|
+ let text: String
|
|
|
+ let style: Style
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ Text(text)
|
|
|
+ .font(.system(size: 9, weight: .bold))
|
|
|
+ .foregroundStyle(foregroundColor)
|
|
|
+ .padding(.horizontal, 8)
|
|
|
+ .padding(.vertical, 4)
|
|
|
+ .background(
|
|
|
+ Capsule()
|
|
|
+ .fill(backgroundColor)
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ private var foregroundColor: Color {
|
|
|
+ switch style {
|
|
|
+ case .accent:
|
|
|
+ .white
|
|
|
+ case .savings:
|
|
|
+ AppTheme.paywallSavingsText
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private var backgroundColor: Color {
|
|
|
+ switch style {
|
|
|
+ case .accent:
|
|
|
+ AppTheme.accent
|
|
|
+ case .savings:
|
|
|
+ AppTheme.paywallSavingsBackground
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private struct PaywallLinkButton: View {
|
|
|
+ let title: String
|
|
|
+ let action: () -> Void
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ Button(action: action) {
|
|
|
+ Text(title)
|
|
|
+ .font(.system(size: 11))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ .underline(false)
|
|
|
+ }
|
|
|
+ .buttonStyle(.plain)
|
|
|
+ .accessibilityLabel(title)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+#Preview {
|
|
|
+ PaywallView(
|
|
|
+ viewModel: PaywallViewModel(
|
|
|
+ subscriptionManager: SubscriptionManager(),
|
|
|
+ onDismiss: {},
|
|
|
+ onPurchaseSuccess: {}
|
|
|
+ )
|
|
|
+ )
|
|
|
+ .frame(width: 1080, height: 780)
|
|
|
+}
|