| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325 |
- import SwiftUI
- struct PaywallView: View {
- let onClose: () -> Void
- @StateObject private var viewModel = PaywallViewModel()
- @State private var isCTAHovered = false
- var body: some View {
- GeometryReader { proxy in
- let horizontalInset = min(max(proxy.size.width * 0.03, 12), 34)
- let availableWidth = max(320, proxy.size.width - (horizontalInset * 2))
- let availableHeight = max(420, proxy.size.height - 24)
- let baseWidth: CGFloat = 860
- let baseHeight: CGFloat = 680
- let contentWidth = min(baseWidth, availableWidth)
- let widthScale = contentWidth / baseWidth
- let heightScale = availableHeight / baseHeight
- // Keep growth balanced to avoid oversized cards on tall/large windows.
- let layoutScale = min(max((widthScale * 0.65) + (heightScale * 0.35), 0.86), 1.0)
- let verticalSpacing = max(14, min(18, 18 * layoutScale))
- let topPadding: CGFloat = 10
- let featureHeight = max(74, min(92, 84 * layoutScale))
- let planHeight = max(160, min(174, 166 * layoutScale))
- VStack(spacing: 0) {
- VStack(spacing: verticalSpacing) {
- titleSection
- featuresSection(featureHeight: featureHeight)
- plansSection(planHeight: planHeight)
- ctaSection
- legalSection
- }
- .frame(width: contentWidth, alignment: .top)
- .padding(.top, topPadding)
- .padding(.bottom, topPadding)
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
- .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 func featuresSection(featureHeight: CGFloat) -> some View {
- LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 14), count: 3), spacing: 14) {
- ForEach(viewModel.features) { feature in
- PaywallFeatureBox(feature: feature, minHeight: featureHeight)
- }
- }
- }
- private func plansSection(planHeight: CGFloat) -> some View {
- HStack(spacing: 14) {
- ForEach(PaywallPlan.allCases) { plan in
- PaywallPlanCardView(
- plan: plan,
- isSelected: viewModel.selectedPlan == plan,
- minHeight: planHeight,
- onSelect: { viewModel.selectPlan(plan) }
- )
- }
- }
- .frame(height: planHeight)
- }
- 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(isCTAHovered ? Color(red: 0.02, green: 0.56, blue: 0.43) : 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)
- }
- }
- .scaleEffect(isCTAHovered ? 1.01 : 1.0)
- .shadow(
- color: isCTAHovered ? AppTheme.primaryShadow.opacity(0.32) : .clear,
- radius: isCTAHovered ? 8 : 0,
- y: isCTAHovered ? 3 : 0
- )
- .animation(.easeOut(duration: 0.15), value: isCTAHovered)
- .buttonStyle(.plain)
- .onHover { isCTAHovered = $0 }
- 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: 12) {
- VStack(spacing: 2) {
- Text("Your subscription will automatically renew unless auto-renew is turned off at least 24-hours before the end of the current subscription period.")
- Text("Payment 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)
- HStack(spacing: 0) {
- PaywallFooterLinkView(title: "Continue with free plan", action: onClose)
- PaywallFooterLinkView(title: "Privacy Policy")
- PaywallFooterLinkView(title: "Support")
- PaywallFooterLinkView(title: "Terms of Services")
- }
- .frame(height: 44)
- .frame(maxWidth: .infinity)
- .background(Color.white)
- .clipShape(RoundedRectangle(cornerRadius: 0))
- }
- }
- }
- private struct PaywallFeatureBox: View {
- let feature: PaywallFeature
- let minHeight: CGFloat
- @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: minHeight, 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 minHeight: CGFloat
- let onSelect: () -> Void
- @State private var isHovered = false
- 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)
- Text(plan.title)
- .font(.system(size: 15, weight: .semibold))
- .foregroundStyle(Color(red: 0.16, green: 0.20, blue: 0.26))
- .padding(.horizontal, 14)
- .padding(.top, 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)
- .padding(.bottom, 12)
- 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)
- .frame(height: minHeight, alignment: .topLeading)
- .background((isSelected || isHovered) ? AppTheme.tealLight.opacity(isSelected ? 0.28 : 0.16) : AppTheme.cardBackground)
- .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
- .overlay(
- RoundedRectangle(cornerRadius: 16, style: .continuous)
- .stroke(
- (isSelected || isHovered) ? AppTheme.toolbarBorderHover : AppTheme.border,
- lineWidth: isSelected ? 2 : 1
- )
- )
- .shadow(
- color: (isSelected || isHovered) ? AppTheme.primaryShadow.opacity(isSelected ? 0.35 : 0.22) : .clear,
- radius: (isSelected || isHovered) ? (isSelected ? 6 : 4) : 0,
- y: (isSelected || isHovered) ? 2 : 0
- )
- .scaleEffect(isHovered && !isSelected ? 1.008 : 1.0)
- .animation(.easeOut(duration: 0.15), value: isHovered)
- }
- .buttonStyle(.plain)
- .onHover { isHovered = $0 }
- }
- }
- private struct PaywallFooterLinkView: View {
- let title: String
- let action: () -> Void
- @State private var isHovered = false
- init(title: String, action: @escaping () -> Void = {}) {
- self.title = title
- self.action = action
- }
- var body: some View {
- Button(action: action) {
- Text(title)
- .font(.system(size: 14, weight: .semibold))
- .foregroundStyle(isHovered ? Color(red: 0.47, green: 0.51, blue: 0.57) : Color(red: 0.59, green: 0.62, blue: 0.68))
- .lineLimit(1)
- .minimumScaleFactor(0.75)
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
- .contentShape(Rectangle())
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .background(isHovered ? AppTheme.cardBackground : Color.clear)
- .scaleEffect(isHovered ? 1.01 : 1.0)
- .animation(.easeOut(duration: 0.15), value: isHovered)
- .buttonStyle(.plain)
- .onHover { isHovered = $0 }
- }
- }
|