|
@@ -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)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|