소스 검색

Add full-screen dynamic paywall flow from sidebar upgrade.

Introduce a new paywall view with selectable plans, responsive layout tuning, and CTA text that updates based on the active plan so the purchase UI stays consistent and clear.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 개월 전
부모
커밋
6d49b71fc5

+ 1 - 1
gramora.xcodeproj/project.pbxproj

@@ -7,7 +7,7 @@
 	objects = {
 
 /* Begin PBXFileReference section */
-		A10000012F00000000000001 /* gramora.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = gramora.app; sourceTree = BUILT_PRODUCTS_DIR; };
+		A10000012F00000000000001 /* gramora.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; name = gramora.app; path = Gramora.app; sourceTree = BUILT_PRODUCTS_DIR; };
 /* End PBXFileReference section */
 
 /* Begin PBXFileSystemSynchronizedRootGroup section */

+ 76 - 0
gramora/Models/PaywallModels.swift

@@ -0,0 +1,76 @@
+import SwiftUI
+
+struct PaywallFeature: Identifiable {
+    let id = UUID()
+    let title: String
+    let subtitle: String
+    let iconName: String
+    let iconBackground: Color
+}
+
+enum PaywallPlan: String, CaseIterable, Identifiable {
+    case weekly
+    case monthly
+    case yearly
+    case lifetime
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .weekly: "Weekly"
+        case .monthly: "Monthly"
+        case .yearly: "Yearly"
+        case .lifetime: "Lifetime"
+        }
+    }
+
+    var mainPrice: String {
+        switch self {
+        case .weekly: "$19.00"
+        case .monthly: "$39.00"
+        case .yearly: "$79.00"
+        case .lifetime: "$249.00"
+        }
+    }
+
+    var secondaryPrice: String {
+        switch self {
+        case .weekly: "$38.00 / week"
+        case .monthly: "$9.75 / week"
+        case .yearly: "$1.52 / week"
+        case .lifetime: "$498.00"
+        }
+    }
+
+    var tagText: String? {
+        switch self {
+        case .weekly: "Basic"
+        case .monthly: "Free Trial"
+        case .yearly: "Save 92%"
+        case .lifetime: "Pay Once"
+        }
+    }
+
+    var ctaTitle: String {
+        switch self {
+        case .weekly: "START WEEKLY PLAN"
+        case .monthly: "START FOR FREE"
+        case .yearly: "START YEARLY PLAN"
+        case .lifetime: "UNLOCK LIFETIME"
+        }
+    }
+
+    var billingDescription: String {
+        switch self {
+        case .weekly:
+            return "Billed at \(mainPrice) every week"
+        case .monthly:
+            return "3 Days Free Trial, then \(mainPrice) per month"
+        case .yearly:
+            return "Billed at \(mainPrice) every year"
+        case .lifetime:
+            return "One-time payment of \(mainPrice)"
+        }
+    }
+}

+ 9 - 0
gramora/ViewModels/MainViewModel.swift

@@ -4,4 +4,13 @@ import Foundation
 @MainActor
 final class MainViewModel: ObservableObject {
     @Published var selectedDestination: NavigationDestination = .grammarChecker
+    @Published var isShowingPaywall = false
+
+    func showPaywall() {
+        isShowingPaywall = true
+    }
+
+    func hidePaywall() {
+        isShowingPaywall = false
+    }
 }

+ 50 - 0
gramora/ViewModels/PaywallViewModel.swift

@@ -0,0 +1,50 @@
+import Combine
+import SwiftUI
+
+@MainActor
+final class PaywallViewModel: ObservableObject {
+    @Published var selectedPlan: PaywallPlan = .monthly
+
+    let features: [PaywallFeature] = [
+        PaywallFeature(
+            title: "Grammar Checker",
+            subtitle: "Check Your Grammar Instantly",
+            iconName: "text.badge.checkmark",
+            iconBackground: Color(red: 0.36, green: 0.65, blue: 0.95)
+        ),
+        PaywallFeature(
+            title: "Paraphrasing Tool",
+            subtitle: "Revamp Your Writing",
+            iconName: "text.redaction",
+            iconBackground: Color(red: 0.64, green: 0.87, blue: 0.80)
+        ),
+        PaywallFeature(
+            title: "Spelling Checker",
+            subtitle: "Accuracy in Every Word",
+            iconName: "character.cursor.ibeam",
+            iconBackground: Color(red: 0.59, green: 0.39, blue: 0.93)
+        ),
+        PaywallFeature(
+            title: "Dictionary",
+            subtitle: "Meaning Made Easy",
+            iconName: "book.closed.fill",
+            iconBackground: Color(red: 0.95, green: 0.32, blue: 0.44)
+        ),
+        PaywallFeature(
+            title: "AI Email Writer",
+            subtitle: "Perfect Emails in Seconds",
+            iconName: "envelope.fill",
+            iconBackground: Color(red: 0.86, green: 0.35, blue: 0.95)
+        ),
+        PaywallFeature(
+            title: "Punctuation Checker",
+            subtitle: "Perfect Your Punctuation",
+            iconName: "text.quote",
+            iconBackground: Color(red: 0.98, green: 0.60, blue: 0.20)
+        )
+    ]
+
+    func selectPlan(_ plan: PaywallPlan) {
+        selectedPlan = plan
+    }
+}

+ 2 - 1
gramora/Views/Components/PremiumCardView.swift

@@ -1,6 +1,7 @@
 import SwiftUI
 
 struct PremiumCardView: View {
+    let onUpgradeTapped: () -> Void
     @State private var isUpgradeHovered = false
 
     var body: some View {
@@ -21,7 +22,7 @@ struct PremiumCardView: View {
                 .lineSpacing(2)
                 .fixedSize(horizontal: false, vertical: true)
 
-            Button(action: {}) {
+            Button(action: onUpgradeTapped) {
                 HStack(spacing: 6) {
                     Text("Upgrade Now")
                         .font(.system(size: 12, weight: .semibold))

+ 28 - 17
gramora/Views/MainView.swift

@@ -4,28 +4,39 @@ struct MainView: View {
     @StateObject private var viewModel = MainViewModel()
 
     var body: some View {
-        HStack(spacing: 0) {
-            SidebarView(selectedDestination: $viewModel.selectedDestination)
-                .frame(maxHeight: .infinity)
+        Group {
+            if viewModel.isShowingPaywall {
+                PaywallView(onClose: viewModel.hidePaywall)
+                    .transition(.opacity)
+            } else {
+                HStack(spacing: 0) {
+                    SidebarView(
+                        selectedDestination: $viewModel.selectedDestination,
+                        onUpgradeTapped: viewModel.showPaywall
+                    )
+                    .frame(maxHeight: .infinity)
 
-            Rectangle()
-                .fill(AppTheme.border)
-                .frame(width: 1)
-                .frame(maxHeight: .infinity)
-                .ignoresSafeArea(edges: .top)
+                    Rectangle()
+                        .fill(AppTheme.border)
+                        .frame(width: 1)
+                        .frame(maxHeight: .infinity)
+                        .ignoresSafeArea(edges: .top)
 
-            ZStack {
-                BackgroundDecorations()
+                    ZStack {
+                        BackgroundDecorations()
 
-                contentView
-                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+                        contentView
+                            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+                    }
+                    .frame(maxWidth: .infinity, maxHeight: .infinity)
+                }
+                .background {
+                    AppTheme.background
+                        .ignoresSafeArea(edges: .top)
+                }
             }
-            .frame(maxWidth: .infinity, maxHeight: .infinity)
-        }
-        .background {
-            AppTheme.background
-                .ignoresSafeArea(edges: .top)
         }
+        .animation(.easeInOut(duration: 0.2), value: viewModel.isShowingPaywall)
     }
 
     @ViewBuilder

+ 301 - 0
gramora/Views/PaywallView.swift

@@ -0,0 +1,301 @@
+import SwiftUI
+
+struct PaywallView: View {
+    let onClose: () -> Void
+    @StateObject private var viewModel = PaywallViewModel()
+
+    var body: some View {
+        GeometryReader { proxy in
+            let availableWidth = max(320, proxy.size.width - 24)
+            let availableHeight = max(420, proxy.size.height - 24)
+            let baseWidth: CGFloat = 860
+            let baseContentHeight: CGFloat = 610
+            let topChromeHeight: CGFloat = 44
+            let totalBaseHeight = baseContentHeight + topChromeHeight
+            let scale = min(1, availableWidth / baseWidth, availableHeight / totalBaseHeight)
+
+            VStack(spacing: 0) {
+                HStack {
+                    Button(action: onClose) {
+                        Image(systemName: "xmark")
+                            .font(.system(size: 14, weight: .semibold))
+                            .foregroundStyle(Color(red: 0.58, green: 0.61, blue: 0.66))
+                            .padding(8)
+                    }
+                    .buttonStyle(.plain)
+
+                    Spacer()
+                }
+                .padding(.horizontal, 20)
+                .padding(.top, 8)
+
+                VStack(spacing: 18) {
+                    titleSection
+                    featuresSection
+                    plansSection
+                    ctaSection
+                    legalSection
+                }
+                .frame(width: baseWidth, height: baseContentHeight, alignment: .top)
+                .scaleEffect(scale, anchor: .top)
+                .padding(.top, -8)
+
+                Spacer(minLength: 0)
+            }
+            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+            .background(Color.white.ignoresSafeArea())
+        }
+    }
+
+    private var titleSection: some View {
+        VStack(spacing: 4) {
+            HStack(spacing: 10) {
+                Text("Upgrade to")
+                    .font(.system(size: 38, weight: .heavy, design: .rounded))
+                    .foregroundStyle(Color(red: 0.11, green: 0.17, blue: 0.26))
+
+                Text("PRO")
+                    .font(.system(size: 32, weight: .heavy, design: .rounded))
+                    .foregroundStyle(.white)
+                    .padding(.horizontal, 18)
+                    .padding(.vertical, 7)
+                    .background(Color(red: 0.03, green: 0.62, blue: 0.48))
+                    .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
+            }
+
+            Text("Upgrade to get all premium features.")
+                .font(.system(size: 16, weight: .medium))
+                .foregroundStyle(Color(red: 0.47, green: 0.51, blue: 0.57))
+        }
+    }
+
+    private var featuresSection: some View {
+        LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 14), count: 3), spacing: 14) {
+            ForEach(viewModel.features) { feature in
+                PaywallFeatureBox(feature: feature)
+            }
+        }
+    }
+
+    private var plansSection: some View {
+        HStack(spacing: 14) {
+            ForEach(PaywallPlan.allCases) { plan in
+                PaywallPlanCardView(
+                    plan: plan,
+                    isSelected: viewModel.selectedPlan == plan,
+                    onSelect: { viewModel.selectPlan(plan) }
+                )
+            }
+        }
+    }
+
+    private var ctaSection: some View {
+        VStack(spacing: 14) {
+            Text(viewModel.selectedPlan.billingDescription)
+                .font(.system(size: 13, weight: .medium))
+                .foregroundStyle(Color(red: 0.47, green: 0.51, blue: 0.57))
+
+            Button(action: {}) {
+                HStack {
+                    Spacer()
+                    Text(viewModel.selectedPlan.ctaTitle)
+                        .font(.system(size: 20, weight: .heavy, design: .rounded))
+                        .foregroundStyle(.white)
+                        .lineLimit(1)
+                        .minimumScaleFactor(0.85)
+                    Spacer()
+                }
+                .padding(.vertical, 14)
+                .background(Color(red: 0.03, green: 0.62, blue: 0.48))
+                .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
+                .overlay(alignment: .trailing) {
+                    Image(systemName: "chevron.right.2")
+                        .font(.system(size: 18, weight: .heavy))
+                        .foregroundStyle(Color.white.opacity(0.75))
+                        .padding(.trailing, 24)
+                }
+            }
+            .buttonStyle(.plain)
+
+            Text("No commitment, cancel anytime.")
+                .font(.system(size: 13, weight: .semibold))
+                .foregroundStyle(Color(red: 0.58, green: 0.61, blue: 0.66))
+        }
+    }
+
+    private var legalSection: some View {
+        VStack(spacing: 10) {
+            Text("Your subscription will automatically renew unless auto-renew is turned off at least 24-hours before the end of the current subscription period.\nPayment will be charged to your iTunes account at confirmation of purchase.")
+                .font(.system(size: 12, weight: .medium))
+                .foregroundStyle(Color(red: 0.59, green: 0.62, blue: 0.68))
+                .multilineTextAlignment(.center)
+                .lineSpacing(2)
+
+            HStack(spacing: 18) {
+                legalLink("Privacy Policy")
+                legalDivider
+                legalLink("Terms of Use")
+                legalDivider
+                legalLink("Restore Purchases")
+                legalDivider
+                legalLink("EULA")
+            }
+            .padding(.bottom, 6)
+        }
+    }
+
+    private var legalDivider: some View {
+        RoundedRectangle(cornerRadius: 1)
+            .fill(Color(red: 0.84, green: 0.86, blue: 0.89))
+            .frame(width: 1, height: 16)
+    }
+
+    private func legalLink(_ title: String) -> some View {
+        Button(action: {}) {
+            Text(title)
+                .font(.system(size: 13, weight: .semibold))
+                .foregroundStyle(Color(red: 0.07, green: 0.60, blue: 0.46))
+        }
+        .buttonStyle(.plain)
+    }
+}
+
+private struct PaywallFeatureBox: View {
+    let feature: PaywallFeature
+    @State private var isHovered = false
+
+    var body: some View {
+        HStack(alignment: .top, spacing: 10) {
+            RoundedRectangle(cornerRadius: 10, style: .continuous)
+                .fill(feature.iconBackground)
+                .frame(width: 42, height: 42)
+                .overlay(
+                    Image(systemName: feature.iconName)
+                        .font(.system(size: 18, weight: .semibold))
+                        .foregroundStyle(.white)
+                )
+
+            VStack(alignment: .leading, spacing: 2) {
+                Text(feature.title)
+                    .font(.system(size: 14, weight: .bold))
+                    .foregroundStyle(AppTheme.textPrimary)
+                    .lineLimit(2)
+
+                Text(feature.subtitle)
+                    .font(.system(size: 11, weight: .medium))
+                    .foregroundStyle(AppTheme.textSecondary)
+                    .lineLimit(2)
+            }
+
+            Spacer(minLength: 0)
+        }
+        .padding(.horizontal, 12)
+        .padding(.vertical, 12)
+        .frame(maxWidth: .infinity, minHeight: 84, maxHeight: 84, alignment: .topLeading)
+        .background(isHovered ? AppTheme.tealLight.opacity(0.55) : AppTheme.cardBackground)
+        .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
+        .overlay(
+            RoundedRectangle(cornerRadius: 12, style: .continuous)
+                .stroke(isHovered ? AppTheme.toolbarBorderHover : AppTheme.border, lineWidth: 1)
+        )
+        .shadow(color: AppTheme.cardShadow, radius: isHovered ? 6 : 3, y: isHovered ? 3 : 1)
+        .scaleEffect(isHovered ? 1.01 : 1.0)
+        .animation(.easeOut(duration: 0.15), value: isHovered)
+        .onHover { isHovered = $0 }
+    }
+}
+
+private struct PaywallPlanCardView: View {
+    let plan: PaywallPlan
+    let isSelected: Bool
+    let onSelect: () -> Void
+
+    var body: some View {
+        Button(action: onSelect) {
+            VStack(alignment: .leading, spacing: 0) {
+                HStack(alignment: .top) {
+                    Circle()
+                        .stroke(Color(red: 0.74, green: 0.76, blue: 0.80), lineWidth: 2)
+                        .frame(width: 18, height: 18)
+                        .overlay {
+                            if isSelected {
+                                Circle()
+                                    .fill(Color(red: 0.03, green: 0.62, blue: 0.48))
+                                    .frame(width: 9, height: 9)
+                            }
+                        }
+
+                    Spacer()
+
+                    if let tagText = plan.tagText {
+                        Text(tagText)
+                            .font(.system(size: 12, weight: .semibold))
+                            .foregroundStyle(tagText == "Pay Once" ? Color(red: 0.53, green: 0.39, blue: 0.90) : Color(red: 0.07, green: 0.60, blue: 0.46))
+                            .padding(.horizontal, 12)
+                            .padding(.vertical, 5)
+                            .background(tagText == "Pay Once" ? Color(red: 0.94, green: 0.91, blue: 0.99) : Color(red: 0.91, green: 0.98, blue: 0.95))
+                            .clipShape(Capsule())
+                    }
+                }
+                .padding(.horizontal, 14)
+                .padding(.top, 12)
+
+                Spacer(minLength: 8)
+
+                Text(plan.title)
+                    .font(.system(size: 15, weight: .semibold))
+                    .foregroundStyle(Color(red: 0.16, green: 0.20, blue: 0.26))
+                    .padding(.horizontal, 14)
+
+                Text(plan.mainPrice)
+                    .font(.system(size: 21, weight: .heavy, design: .rounded))
+                    .foregroundStyle(Color(red: 0.03, green: 0.62, blue: 0.48))
+                    .lineLimit(1)
+                    .minimumScaleFactor(0.75)
+                    .padding(.horizontal, 14)
+                    .padding(.top, 2)
+
+                Spacer(minLength: 8)
+
+                VStack(alignment: .leading, spacing: 0) {
+                    HStack {
+                        Text(plan.secondaryPrice)
+                            .font(.system(size: 14, weight: .semibold))
+                            .foregroundStyle(Color(red: 0.45, green: 0.48, blue: 0.54))
+                            .overlay(alignment: .center) {
+                                if plan == .weekly || plan == .lifetime {
+                                    Rectangle()
+                                        .fill(Color(red: 0.45, green: 0.48, blue: 0.54))
+                                        .frame(height: 1)
+                                }
+                            }
+                        Spacer(minLength: 0)
+                    }
+                    .padding(.horizontal, 14)
+                    .padding(.vertical, 12)
+                    .frame(maxWidth: .infinity, alignment: .leading)
+                }
+                .background(Color(red: 0.95, green: 0.96, blue: 0.97))
+                .clipShape(
+                    RoundedRectangle(cornerRadius: 0)
+                )
+            }
+            .frame(maxWidth: .infinity, minHeight: 170, alignment: .topLeading)
+            .background(isSelected ? AppTheme.tealLight.opacity(0.28) : AppTheme.cardBackground)
+            .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
+            .overlay(
+                RoundedRectangle(cornerRadius: 16, style: .continuous)
+                    .stroke(
+                        isSelected ? AppTheme.toolbarBorderHover : AppTheme.border,
+                        lineWidth: isSelected ? 2 : 1
+                    )
+            )
+            .shadow(
+                color: isSelected ? AppTheme.primaryShadow.opacity(0.35) : .clear,
+                radius: isSelected ? 6 : 0,
+                y: isSelected ? 2 : 0
+            )
+        }
+        .buttonStyle(.plain)
+    }
+}

+ 2 - 1
gramora/Views/SidebarView.swift

@@ -2,6 +2,7 @@ import SwiftUI
 
 struct SidebarView: View {
     @Binding var selectedDestination: NavigationDestination
+    let onUpgradeTapped: () -> Void
 
     var body: some View {
         VStack(alignment: .leading, spacing: 0) {
@@ -24,7 +25,7 @@ struct SidebarView: View {
 
             Spacer(minLength: 12)
 
-            PremiumCardView()
+            PremiumCardView(onUpgradeTapped: onUpgradeTapped)
                 .padding(.horizontal, 16)
                 .padding(.bottom, 20)
         }