Просмотр исходного кода

Add a full-screen responsive paywall triggered from the sidebar upgrade card.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 месяц назад
Родитель
Сommit
77f50adb4b

+ 84 - 0
Reddit App/Models/PaywallModels.swift

@@ -0,0 +1,84 @@
+import Foundation
+
+enum PaywallPlan: String, CaseIterable, Identifiable {
+    case monthly
+    case yearly
+    case lifetime
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .monthly: "Monthly"
+        case .yearly: "Yearly"
+        case .lifetime: "Lifetime"
+        }
+    }
+
+    var subtitle: String {
+        switch self {
+        case .monthly: "Billed every month"
+        case .yearly: "Save 42%. Billed once per year."
+        case .lifetime: "One-time payment. All future updates."
+        }
+    }
+
+    var price: String {
+        switch self {
+        case .monthly: "$7.99"
+        case .yearly: "$49.99"
+        case .lifetime: "$79.99"
+        }
+    }
+
+    var priceSuffix: String? {
+        switch self {
+        case .monthly: "/mo"
+        case .yearly: "/yr"
+        case .lifetime: nil
+        }
+    }
+
+    var isBestValue: Bool {
+        self == .yearly
+    }
+
+    var features: [String] {
+        switch self {
+        case .monthly, .yearly:
+            [
+                "Unlimited AI Generations",
+                "Post Generator & Title Optimizer",
+                "Comment Writer with smart replies",
+            ]
+        case .lifetime:
+            [
+                "Pay once, use forever",
+                "Priority Support",
+                "Multi-device Sync",
+            ]
+        }
+    }
+}
+
+struct PaywallFeature: Identifiable {
+    let id = UUID()
+    let title: String
+    let systemImage: String
+}
+
+enum PaywallContent {
+    static let title = "Unlock Full Pro Power"
+    static let subtitle = "Generate unlimited posts, optimize titles, and craft smarter replies with advanced AI."
+
+    static let includedSectionTitle = "Everything included in Premium"
+
+    static let features: [PaywallFeature] = [
+        PaywallFeature(title: "Unlimited AI Generations", systemImage: "sparkles"),
+        PaywallFeature(title: "Post Generator", systemImage: "square.and.pencil"),
+        PaywallFeature(title: "Title Optimizer", systemImage: "textformat.size"),
+        PaywallFeature(title: "Comment Writer", systemImage: "bubble.left.and.bubble.right.fill"),
+        PaywallFeature(title: "Custom AI Prompts", systemImage: "wand.and.stars"),
+        PaywallFeature(title: "Post Analytics", systemImage: "chart.bar.fill"),
+    ]
+}

+ 9 - 0
Reddit App/ViewModels/FrontPageViewModel.swift

@@ -9,6 +9,7 @@ final class FrontPageViewModel {
     var selectedNavItem: SidebarNavItem?
     var isRedditActive = true
     var isSettingsActive = false
+    var isPaywallPresented = false
     private(set) var redditReloadTrigger = 0
 
     static let settingsItem = SidebarNavItem(
@@ -37,6 +38,14 @@ final class FrontPageViewModel {
         isSettingsActive = false
     }
 
+    func showPaywall() {
+        isPaywallPresented = true
+    }
+
+    func dismissPaywall() {
+        isPaywallPresented = false
+    }
+
     let createItems: [SidebarNavItem] = [
         SidebarNavItem(title: "Post Generator", subtitle: "Generate engaging posts", iconKind: .postGenerator),
         SidebarNavItem(title: "Title Optimizer", subtitle: "Create viral titles", iconKind: .titleOptimizer),

+ 19 - 0
Reddit App/ViewModels/PaywallViewModel.swift

@@ -0,0 +1,19 @@
+import SwiftUI
+
+@MainActor
+@Observable
+final class PaywallViewModel {
+    var selectedPlan: PaywallPlan = .yearly
+
+    func selectPlan(_ plan: PaywallPlan) {
+        selectedPlan = plan
+    }
+
+    func purchase() {
+        // StoreKit integration placeholder
+    }
+
+    func restorePurchases() {
+        // StoreKit integration placeholder
+    }
+}

+ 17 - 3
Reddit App/Views/FrontPageView.swift

@@ -2,17 +2,31 @@ import SwiftUI
 
 struct FrontPageView: View {
     @State private var viewModel = FrontPageViewModel()
+    @State private var paywallViewModel = PaywallViewModel()
     @State private var postGeneratorViewModel = PostGeneratorViewModel()
     @State private var titleOptimizerViewModel = TitleOptimizerViewModel()
     @State private var commentWriterViewModel = CommentWriterViewModel()
 
     var body: some View {
-        HStack(spacing: 0) {
-            SidebarView(viewModel: viewModel)
+        ZStack {
+            HStack(spacing: 0) {
+                SidebarView(viewModel: viewModel)
+
+                mainContent
+                    .frame(maxWidth: .infinity, maxHeight: .infinity)
+            }
 
-            mainContent
+            if viewModel.isPaywallPresented {
+                PaywallView(viewModel: paywallViewModel) {
+                    viewModel.dismissPaywall()
+                }
                 .frame(maxWidth: .infinity, maxHeight: .infinity)
+                .ignoresSafeArea()
+                .zIndex(1)
+                .transition(.opacity)
+            }
         }
+        .animation(.easeInOut(duration: 0.2), value: viewModel.isPaywallPresented)
         .frame(minWidth: AppWindow.minWidth, minHeight: AppWindow.minHeight)
         .background(AppTheme.background)
     }

+ 313 - 0
Reddit App/Views/PaywallView.swift

@@ -0,0 +1,313 @@
+import SwiftUI
+
+struct PaywallView: View {
+    @Bindable var viewModel: PaywallViewModel
+    var onDismiss: () -> Void
+
+    var body: some View {
+        GeometryReader { geometry in
+            let metrics = PaywallLayoutMetrics(size: geometry.size)
+
+            ZStack(alignment: .topTrailing) {
+                AppTheme.background
+                    .ignoresSafeArea()
+
+                VStack(spacing: metrics.sectionSpacing) {
+                    header(metrics: metrics)
+                    featuresSection(metrics: metrics)
+                    pricingCards(metrics: metrics)
+                    ctaSection(metrics: metrics)
+                    footerLinks(metrics: metrics)
+                }
+                .padding(.horizontal, metrics.horizontalInset)
+                .padding(.top, metrics.topInset)
+                .padding(.bottom, metrics.bottomInset)
+                .frame(width: geometry.size.width, height: geometry.size.height, alignment: .top)
+
+                closeButton
+                    .padding(metrics.closeButtonPadding)
+            }
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity)
+        .ignoresSafeArea()
+    }
+
+    private var closeButton: some View {
+        Button(action: onDismiss) {
+            Image(systemName: "xmark")
+                .font(.system(size: 12, weight: .semibold))
+                .foregroundStyle(AppTheme.textSecondary)
+                .frame(width: 32, height: 32)
+                .background(AppTheme.cardBackground)
+                .clipShape(Circle())
+                .overlay(
+                    Circle()
+                        .stroke(AppTheme.cardBorder, lineWidth: 1)
+                )
+        }
+        .buttonStyle(.plain)
+    }
+
+    private func header(metrics: PaywallLayoutMetrics) -> some View {
+        VStack(spacing: metrics.headerSpacing) {
+            Text(PaywallContent.title)
+                .font(.system(size: metrics.titleFontSize, weight: .bold))
+                .foregroundStyle(AppTheme.textPrimary)
+
+            Text(PaywallContent.subtitle)
+                .font(.system(size: metrics.subtitleFontSize))
+                .foregroundStyle(AppTheme.textSecondary)
+                .multilineTextAlignment(.center)
+                .frame(maxWidth: metrics.subtitleMaxWidth)
+        }
+        .frame(maxWidth: .infinity)
+    }
+
+    private func featuresSection(metrics: PaywallLayoutMetrics) -> some View {
+        VStack(alignment: .leading, spacing: metrics.featureSectionSpacing) {
+            Text(PaywallContent.includedSectionTitle)
+                .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)
+                }
+            }
+        }
+        .frame(maxWidth: .infinity, alignment: .leading)
+    }
+
+    private func pricingCards(metrics: PaywallLayoutMetrics) -> some View {
+        HStack(alignment: .top, spacing: metrics.cardSpacing) {
+            ForEach(PaywallPlan.allCases) { plan in
+                PaywallPricingCard(
+                    plan: plan,
+                    isSelected: viewModel.selectedPlan == plan,
+                    metrics: metrics
+                ) {
+                    viewModel.selectPlan(plan)
+                }
+            }
+        }
+        .frame(maxWidth: .infinity)
+    }
+
+    private func ctaSection(metrics: PaywallLayoutMetrics) -> some View {
+        VStack(spacing: metrics.ctaSpacing) {
+            Button {
+                viewModel.purchase()
+            } label: {
+                Text("Get Premium")
+                    .font(.system(size: metrics.ctaFontSize, weight: .bold))
+                    .foregroundStyle(Color(hex: 0x0B0E14))
+                    .frame(maxWidth: .infinity)
+                    .padding(.vertical, metrics.ctaVerticalPadding)
+                    .background(
+                        LinearGradient(
+                            colors: [AppTheme.accentPurpleLight, AppTheme.accentTeal],
+                            startPoint: .leading,
+                            endPoint: .trailing
+                        )
+                    )
+                    .clipShape(RoundedRectangle(cornerRadius: metrics.cornerRadius))
+            }
+            .buttonStyle(.plain)
+
+            HStack(spacing: 6) {
+                Image(systemName: "lock.fill")
+                    .font(.system(size: metrics.footerNoteFontSize))
+                Text("Secure payment. Cancel anytime.")
+                    .font(.system(size: metrics.footerNoteFontSize))
+            }
+            .foregroundStyle(AppTheme.textTertiary)
+        }
+    }
+
+    private func footerLinks(metrics: PaywallLayoutMetrics) -> some View {
+        HStack {
+            Button("Continue with free plan") {
+                onDismiss()
+            }
+            .buttonStyle(.plain)
+            .font(.system(size: metrics.footerLinkFontSize))
+            .foregroundStyle(AppTheme.textSecondary)
+
+            Spacer()
+
+            HStack(spacing: metrics.footerLinkSpacing) {
+                footerLink("Restore Purchases", metrics: metrics) {
+                    viewModel.restorePurchases()
+                }
+                footerLink("Privacy Policy", metrics: metrics) {}
+                footerLink("Support", metrics: metrics) {}
+                footerLink("Terms of Services", metrics: metrics) {}
+            }
+        }
+    }
+
+    private func footerLink(_ title: String, metrics: PaywallLayoutMetrics, action: @escaping () -> Void) -> some View {
+        Button(title, action: action)
+            .buttonStyle(.plain)
+            .font(.system(size: metrics.footerLinkFontSize))
+            .foregroundStyle(AppTheme.textTertiary)
+    }
+}
+
+// MARK: - Layout Metrics
+
+private struct PaywallLayoutMetrics {
+    let size: CGSize
+
+    var horizontalInset: CGFloat { size.width * 0.06 }
+    var topInset: CGFloat { size.height * 0.07 }
+    var bottomInset: CGFloat { size.height * 0.05 }
+    var closeButtonPadding: CGFloat { max(16, size.width * 0.02) }
+
+    var sectionSpacing: CGFloat { size.height * 0.04 }
+    var headerSpacing: CGFloat { size.height * 0.012 }
+    var featureSectionSpacing: CGFloat { size.height * 0.018 }
+    var featureRowSpacing: CGFloat { size.height * 0.016 }
+    var cardSpacing: CGFloat { size.width * 0.018 }
+    var ctaSpacing: CGFloat { size.height * 0.014 }
+    var footerLinkSpacing: CGFloat { size.width * 0.014 }
+
+    var titleFontSize: CGFloat { clamp(size.height * 0.048, min: 24, max: 40) }
+    var subtitleFontSize: CGFloat { clamp(size.height * 0.022, min: 13, max: 18) }
+    var sectionTitleFontSize: CGFloat { clamp(size.height * 0.02, min: 12, max: 16) }
+    var featureFontSize: CGFloat { clamp(size.height * 0.019, min: 11, max: 15) }
+    var ctaFontSize: CGFloat { clamp(size.height * 0.024, min: 14, max: 18) }
+    var footerNoteFontSize: CGFloat { clamp(size.height * 0.017, min: 10, max: 13) }
+    var footerLinkFontSize: CGFloat { clamp(size.height * 0.017, min: 10, max: 13) }
+
+    var subtitleMaxWidth: CGFloat { size.width * 0.55 }
+    var cardMinHeight: CGFloat { size.height * 0.32 }
+    var cardPadding: CGFloat { size.width * 0.014 }
+    var cornerRadius: CGFloat { clamp(size.height * 0.014, min: 8, max: 12) }
+    var ctaVerticalPadding: CGFloat { size.height * 0.022 }
+
+    var featureColumns: [GridItem] {
+        [
+            GridItem(.flexible(), spacing: featureRowSpacing),
+            GridItem(.flexible(), spacing: featureRowSpacing),
+            GridItem(.flexible(), spacing: featureRowSpacing),
+        ]
+    }
+
+    private func clamp(_ value: CGFloat, min: CGFloat, max: CGFloat) -> CGFloat {
+        Swift.min(Swift.max(value, min), max)
+    }
+}
+
+// MARK: - Components
+
+private struct PaywallFeatureRow: View {
+    let feature: PaywallFeature
+    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)
+                .frame(width: metrics.featureFontSize + 4)
+
+            Text(feature.title)
+                .font(.system(size: metrics.featureFontSize))
+                .foregroundStyle(AppTheme.textSecondary)
+                .lineLimit(1)
+                .minimumScaleFactor(0.85)
+        }
+    }
+}
+
+private struct PaywallPricingCard: View {
+    let plan: PaywallPlan
+    let isSelected: Bool
+    let metrics: PaywallLayoutMetrics
+    let onSelect: () -> Void
+
+    var body: some View {
+        Button(action: onSelect) {
+            VStack(alignment: .leading, spacing: 0) {
+                if plan.isBestValue {
+                    Text("BEST VALUE")
+                        .font(.system(size: metrics.featureFontSize * 0.75, weight: .bold))
+                        .foregroundStyle(Color(hex: 0x0B0E14))
+                        .padding(.horizontal, 10)
+                        .padding(.vertical, 4)
+                        .background(AppTheme.accentTeal)
+                        .clipShape(Capsule())
+                        .frame(maxWidth: .infinity)
+                        .padding(.bottom, metrics.cardPadding)
+                } else {
+                    Color.clear
+                        .frame(height: metrics.cardPadding * 1.6)
+                        .padding(.bottom, metrics.cardPadding * 0.4)
+                }
+
+                Text(plan.title)
+                    .font(.system(size: metrics.ctaFontSize, weight: .semibold))
+                    .foregroundStyle(AppTheme.textPrimary)
+
+                Text(plan.subtitle)
+                    .font(.system(size: metrics.featureFontSize * 0.85))
+                    .foregroundStyle(AppTheme.textTertiary)
+                    .fixedSize(horizontal: false, vertical: true)
+                    .padding(.top, 2)
+
+                HStack(alignment: .firstTextBaseline, spacing: 2) {
+                    Text(plan.price)
+                        .font(.system(size: metrics.titleFontSize * 0.85, weight: .bold))
+                        .foregroundStyle(AppTheme.textPrimary)
+
+                    if let suffix = plan.priceSuffix {
+                        Text(suffix)
+                            .font(.system(size: metrics.featureFontSize))
+                            .foregroundStyle(AppTheme.textSecondary)
+                    }
+                }
+                .padding(.top, metrics.cardPadding)
+                .padding(.bottom, metrics.cardPadding * 0.9)
+
+                VStack(alignment: .leading, spacing: metrics.featureRowSpacing * 0.7) {
+                    ForEach(plan.features, id: \.self) { feature in
+                        HStack(alignment: .top, spacing: 8) {
+                            Image(systemName: "checkmark")
+                                .font(.system(size: metrics.featureFontSize * 0.75, weight: .bold))
+                                .foregroundStyle(AppTheme.accentGreen)
+                                .padding(.top, 2)
+
+                            Text(feature)
+                                .font(.system(size: metrics.featureFontSize * 0.92))
+                                .foregroundStyle(AppTheme.textSecondary)
+                                .fixedSize(horizontal: false, vertical: true)
+                                .multilineTextAlignment(.leading)
+                        }
+                    }
+                }
+
+                Spacer(minLength: 0)
+            }
+            .padding(metrics.cardPadding)
+            .frame(maxWidth: .infinity, minHeight: metrics.cardMinHeight, alignment: .topLeading)
+            .background(
+                RoundedRectangle(cornerRadius: metrics.cornerRadius)
+                    .fill(AppTheme.cardBackground)
+            )
+            .overlay(
+                RoundedRectangle(cornerRadius: metrics.cornerRadius)
+                    .stroke(
+                        isSelected ? AppTheme.accentTeal : AppTheme.cardBorder,
+                        lineWidth: isSelected ? 2 : 1
+                    )
+            )
+        }
+        .buttonStyle(.plain)
+    }
+}
+
+#Preview {
+    PaywallView(viewModel: PaywallViewModel(), onDismiss: {})
+        .frame(width: AppWindow.width, height: AppWindow.height)
+}

+ 3 - 1
Reddit App/Views/SidebarView.swift

@@ -112,7 +112,9 @@ struct SidebarView: View {
                 .fixedSize(horizontal: false, vertical: true)
                 .lineSpacing(2)
 
-            Button("Upgrade Now") {}
+            Button("Upgrade Now") {
+                viewModel.showPaywall()
+            }
                 .font(.system(size: 11, weight: .semibold))
                 .foregroundStyle(.white)
                 .frame(maxWidth: .infinity)