feat(paywall): enhance onboarding with rich backgrounds and intro pricing
- Add themed backgrounds for each onboarding feature page: - Unlimited Trips: animated route map with dotted paths and traveling car - Export & Share: floating documents with radiating share lines - Track Your Journey: stadium map with pins and achievement badges - Add sports-themed pricing page background with random glow effects - Display introductory offer pricing in subscription rows - Add feature bullets to each onboarding page for better value prop - Add crown icon header and feature pills to pricing page - Add debug button in Settings to preview onboarding flow - Create StoreKit configuration file for testing IAP Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,501 @@
|
||||
import SwiftUI
|
||||
import StoreKit
|
||||
|
||||
// MARK: - Background Components
|
||||
|
||||
/// Route lines background for Unlimited Trips page
|
||||
struct TripRoutesBackground: View {
|
||||
let color: Color
|
||||
@State private var animate = false
|
||||
@State private var pulsePhase: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Main route canvas
|
||||
Canvas { context, size in
|
||||
// City points scattered across the view
|
||||
let points: [CGPoint] = [
|
||||
CGPoint(x: size.width * 0.12, y: size.height * 0.18),
|
||||
CGPoint(x: size.width * 0.35, y: size.height * 0.32),
|
||||
CGPoint(x: size.width * 0.58, y: size.height * 0.12),
|
||||
CGPoint(x: size.width * 0.78, y: size.height * 0.38),
|
||||
CGPoint(x: size.width * 0.22, y: size.height * 0.52),
|
||||
CGPoint(x: size.width * 0.62, y: size.height * 0.62),
|
||||
CGPoint(x: size.width * 0.88, y: size.height * 0.22),
|
||||
CGPoint(x: size.width * 0.42, y: size.height * 0.78),
|
||||
CGPoint(x: size.width * 0.82, y: size.height * 0.68),
|
||||
CGPoint(x: size.width * 0.08, y: size.height * 0.72),
|
||||
CGPoint(x: size.width * 0.5, y: size.height * 0.42),
|
||||
CGPoint(x: size.width * 0.92, y: size.height * 0.52),
|
||||
]
|
||||
|
||||
// Draw dotted route lines connecting points
|
||||
let routePairs: [(Int, Int)] = [
|
||||
(0, 1), (1, 3), (3, 6), (2, 6),
|
||||
(1, 4), (4, 5), (5, 8), (4, 9),
|
||||
(5, 7), (7, 9), (2, 3), (1, 10),
|
||||
(10, 5), (3, 11), (8, 11), (0, 9)
|
||||
]
|
||||
|
||||
for (start, end) in routePairs {
|
||||
var path = Path()
|
||||
path.move(to: points[start])
|
||||
path.addLine(to: points[end])
|
||||
|
||||
context.stroke(
|
||||
path,
|
||||
with: .color(color.opacity(0.22)),
|
||||
style: StrokeStyle(lineWidth: 2.5, dash: [8, 5])
|
||||
)
|
||||
}
|
||||
|
||||
// Draw city dots with varying sizes
|
||||
for (index, point) in points.enumerated() {
|
||||
let isMainCity = index % 3 == 0
|
||||
let dotSize: CGFloat = isMainCity ? 10 : 6
|
||||
let ringSize: CGFloat = isMainCity ? 18 : 12
|
||||
|
||||
// Glow effect for main cities
|
||||
if isMainCity {
|
||||
let glowPath = Path(ellipseIn: CGRect(
|
||||
x: point.x - 14,
|
||||
y: point.y - 14,
|
||||
width: 28,
|
||||
height: 28
|
||||
))
|
||||
context.fill(glowPath, with: .color(color.opacity(0.08)))
|
||||
}
|
||||
|
||||
// Outer ring
|
||||
let ringPath = Path(ellipseIn: CGRect(
|
||||
x: point.x - ringSize / 2,
|
||||
y: point.y - ringSize / 2,
|
||||
width: ringSize,
|
||||
height: ringSize
|
||||
))
|
||||
context.stroke(ringPath, with: .color(color.opacity(0.18)), lineWidth: 1.5)
|
||||
|
||||
// Inner dot
|
||||
let dotPath = Path(ellipseIn: CGRect(
|
||||
x: point.x - dotSize / 2,
|
||||
y: point.y - dotSize / 2,
|
||||
width: dotSize,
|
||||
height: dotSize
|
||||
))
|
||||
context.fill(dotPath, with: .color(color.opacity(isMainCity ? 0.35 : 0.25)))
|
||||
}
|
||||
}
|
||||
|
||||
// Animated car/plane icon traveling along a route
|
||||
TravelingIcon(color: color, animate: animate)
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(.easeInOut(duration: 2).repeatForever(autoreverses: true)) {
|
||||
animate = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Small animated icon that travels across the background
|
||||
private struct TravelingIcon: View {
|
||||
let color: Color
|
||||
let animate: Bool
|
||||
@State private var position: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
Image(systemName: "car.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(color.opacity(0.3))
|
||||
.position(
|
||||
x: geo.size.width * (0.15 + position * 0.7),
|
||||
y: geo.size.height * (0.2 + sin(position * .pi * 2) * 0.15)
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(.linear(duration: 8).repeatForever(autoreverses: false)) {
|
||||
position = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Floating documents background for Export & Share page
|
||||
struct DocumentsBackground: View {
|
||||
let color: Color
|
||||
@State private var animate = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Connecting share lines in background
|
||||
Canvas { context, size in
|
||||
let centerX = size.width * 0.5
|
||||
let centerY = size.height * 0.5
|
||||
|
||||
// Draw radiating lines from center (like sharing to multiple destinations)
|
||||
let endpoints: [CGPoint] = [
|
||||
CGPoint(x: size.width * 0.1, y: size.height * 0.2),
|
||||
CGPoint(x: size.width * 0.9, y: size.height * 0.15),
|
||||
CGPoint(x: size.width * 0.05, y: size.height * 0.6),
|
||||
CGPoint(x: size.width * 0.95, y: size.height * 0.55),
|
||||
CGPoint(x: size.width * 0.15, y: size.height * 0.85),
|
||||
CGPoint(x: size.width * 0.85, y: size.height * 0.9),
|
||||
]
|
||||
|
||||
for endpoint in endpoints {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: centerX, y: centerY))
|
||||
path.addLine(to: endpoint)
|
||||
|
||||
context.stroke(
|
||||
path,
|
||||
with: .color(color.opacity(0.08)),
|
||||
style: StrokeStyle(lineWidth: 1.5, dash: [4, 6])
|
||||
)
|
||||
|
||||
// Small circle at endpoint
|
||||
let dotPath = Path(ellipseIn: CGRect(
|
||||
x: endpoint.x - 4,
|
||||
y: endpoint.y - 4,
|
||||
width: 8,
|
||||
height: 8
|
||||
))
|
||||
context.fill(dotPath, with: .color(color.opacity(0.12)))
|
||||
}
|
||||
}
|
||||
|
||||
// Scattered document icons with enhanced visibility
|
||||
ForEach(0..<12, id: \.self) { index in
|
||||
documentIcon(index: index)
|
||||
}
|
||||
|
||||
// Central PDF badge
|
||||
GeometryReader { geo in
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(color.opacity(0.08))
|
||||
.frame(width: 50, height: 60)
|
||||
|
||||
VStack(spacing: 2) {
|
||||
Image(systemName: "doc.text.fill")
|
||||
.font(.system(size: 24))
|
||||
Text("PDF")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
}
|
||||
.foregroundStyle(color.opacity(0.2))
|
||||
}
|
||||
.position(x: geo.size.width * 0.5, y: geo.size.height * 0.45)
|
||||
.scaleEffect(animate ? 1.05 : 0.95)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(.easeInOut(duration: 2.5).repeatForever(autoreverses: true)) {
|
||||
animate = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func documentIcon(index: Int) -> some View {
|
||||
let positions: [(x: CGFloat, y: CGFloat, rotation: Double, scale: CGFloat)] = [
|
||||
(0.08, 0.12, -18, 0.85),
|
||||
(0.88, 0.18, 14, 0.75),
|
||||
(0.18, 0.38, 10, 0.65),
|
||||
(0.92, 0.45, -12, 0.9),
|
||||
(0.12, 0.72, 22, 0.75),
|
||||
(0.82, 0.78, -10, 0.85),
|
||||
(0.48, 0.08, 6, 0.7),
|
||||
(0.58, 0.88, -14, 0.8),
|
||||
(0.05, 0.55, 8, 0.6),
|
||||
(0.95, 0.32, -6, 0.7),
|
||||
(0.35, 0.92, 16, 0.65),
|
||||
(0.72, 0.05, -20, 0.75),
|
||||
]
|
||||
|
||||
let icons = ["doc.fill", "doc.text.fill", "square.and.arrow.up", "doc.richtext.fill",
|
||||
"doc.fill", "square.and.arrow.up.fill", "doc.text.fill", "doc.fill",
|
||||
"square.and.arrow.up", "doc.richtext.fill", "doc.fill", "square.and.arrow.up.fill"]
|
||||
|
||||
let pos = positions[index]
|
||||
|
||||
GeometryReader { geo in
|
||||
Image(systemName: icons[index])
|
||||
.font(.system(size: 32 * pos.scale))
|
||||
.foregroundStyle(color.opacity(0.18))
|
||||
.rotationEffect(.degrees(pos.rotation))
|
||||
.position(x: geo.size.width * pos.x, y: geo.size.height * pos.y)
|
||||
.offset(y: animate ? -8 : 8)
|
||||
.animation(
|
||||
.easeInOut(duration: 2.2 + Double(index) * 0.25)
|
||||
.repeatForever(autoreverses: true)
|
||||
.delay(Double(index) * 0.15),
|
||||
value: animate
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stadium map background for Track Your Journey page
|
||||
struct StadiumMapBackground: View {
|
||||
let color: Color
|
||||
@State private var animate = false
|
||||
@State private var checkmarkScale: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Map grid canvas
|
||||
Canvas { context, size in
|
||||
// Draw subtle grid lines like a map
|
||||
let gridSpacing: CGFloat = 35
|
||||
|
||||
// Horizontal lines
|
||||
var y: CGFloat = 0
|
||||
while y < size.height {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: 0, y: y))
|
||||
path.addLine(to: CGPoint(x: size.width, y: y))
|
||||
context.stroke(path, with: .color(color.opacity(0.06)), lineWidth: 0.5)
|
||||
y += gridSpacing
|
||||
}
|
||||
|
||||
// Vertical lines
|
||||
var x: CGFloat = 0
|
||||
while x < size.width {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: x, y: 0))
|
||||
path.addLine(to: CGPoint(x: x, y: size.height))
|
||||
context.stroke(path, with: .color(color.opacity(0.06)), lineWidth: 0.5)
|
||||
x += gridSpacing
|
||||
}
|
||||
|
||||
// Stadium pin markers - more of them now
|
||||
let pinPositions: [(CGPoint, Bool)] = [
|
||||
(CGPoint(x: size.width * 0.15, y: size.height * 0.18), true),
|
||||
(CGPoint(x: size.width * 0.72, y: size.height * 0.15), true),
|
||||
(CGPoint(x: size.width * 0.38, y: size.height * 0.35), false),
|
||||
(CGPoint(x: size.width * 0.88, y: size.height * 0.32), true),
|
||||
(CGPoint(x: size.width * 0.12, y: size.height * 0.55), false),
|
||||
(CGPoint(x: size.width * 0.55, y: size.height * 0.48), true),
|
||||
(CGPoint(x: size.width * 0.82, y: size.height * 0.58), false),
|
||||
(CGPoint(x: size.width * 0.28, y: size.height * 0.72), true),
|
||||
(CGPoint(x: size.width * 0.65, y: size.height * 0.75), false),
|
||||
(CGPoint(x: size.width * 0.08, y: size.height * 0.85), false),
|
||||
(CGPoint(x: size.width * 0.92, y: size.height * 0.78), true),
|
||||
(CGPoint(x: size.width * 0.48, y: size.height * 0.88), false),
|
||||
]
|
||||
|
||||
for (pos, isVisited) in pinPositions {
|
||||
// Pin drop shadow
|
||||
let shadowPath = Path(ellipseIn: CGRect(x: pos.x - 7, y: pos.y + 12, width: 14, height: 5))
|
||||
context.fill(shadowPath, with: .color(.black.opacity(0.1)))
|
||||
|
||||
// Pin body (teardrop shape)
|
||||
var pinPath = Path()
|
||||
pinPath.move(to: CGPoint(x: pos.x, y: pos.y + 14))
|
||||
pinPath.addQuadCurve(
|
||||
to: CGPoint(x: pos.x - 10, y: pos.y - 5),
|
||||
control: CGPoint(x: pos.x - 12, y: pos.y + 7)
|
||||
)
|
||||
pinPath.addArc(
|
||||
center: CGPoint(x: pos.x, y: pos.y - 5),
|
||||
radius: 10,
|
||||
startAngle: .degrees(180),
|
||||
endAngle: .degrees(0),
|
||||
clockwise: false
|
||||
)
|
||||
pinPath.addQuadCurve(
|
||||
to: CGPoint(x: pos.x, y: pos.y + 14),
|
||||
control: CGPoint(x: pos.x + 12, y: pos.y + 7)
|
||||
)
|
||||
|
||||
// Visited pins are filled, unvisited are outlined
|
||||
if isVisited {
|
||||
context.fill(pinPath, with: .color(color.opacity(0.28)))
|
||||
// Checkmark inside visited pins
|
||||
let checkCenter = CGPoint(x: pos.x, y: pos.y - 5)
|
||||
var checkPath = Path()
|
||||
checkPath.move(to: CGPoint(x: checkCenter.x - 4, y: checkCenter.y))
|
||||
checkPath.addLine(to: CGPoint(x: checkCenter.x - 1, y: checkCenter.y + 3))
|
||||
checkPath.addLine(to: CGPoint(x: checkCenter.x + 4, y: checkCenter.y - 3))
|
||||
context.stroke(checkPath, with: .color(.white.opacity(0.6)), lineWidth: 2)
|
||||
} else {
|
||||
context.stroke(pinPath, with: .color(color.opacity(0.2)), lineWidth: 2)
|
||||
// Empty dot for unvisited
|
||||
let dotPath = Path(ellipseIn: CGRect(x: pos.x - 4, y: pos.y - 9, width: 8, height: 8))
|
||||
context.stroke(dotPath, with: .color(color.opacity(0.15)), lineWidth: 1.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Floating achievement badges
|
||||
ForEach(0..<3, id: \.self) { index in
|
||||
achievementBadge(index: index)
|
||||
}
|
||||
|
||||
// Progress counter badge
|
||||
GeometryReader { geo in
|
||||
HStack(spacing: 4) {
|
||||
Text("6")
|
||||
.font(.system(size: 18, weight: .bold, design: .rounded))
|
||||
Text("/")
|
||||
.font(.system(size: 14))
|
||||
Text("12")
|
||||
.font(.system(size: 14, weight: .medium, design: .rounded))
|
||||
}
|
||||
.foregroundStyle(color.opacity(0.25))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(color.opacity(0.06), in: Capsule())
|
||||
.position(x: geo.size.width * 0.5, y: geo.size.height * 0.92)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(.easeInOut(duration: 2.5).repeatForever(autoreverses: true)) {
|
||||
animate = true
|
||||
}
|
||||
withAnimation(.spring(response: 0.6, dampingFraction: 0.6).delay(0.3)) {
|
||||
checkmarkScale = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func achievementBadge(index: Int) -> some View {
|
||||
let badges: [(x: CGFloat, y: CGFloat, icon: String, rotation: Double)] = [
|
||||
(0.12, 0.08, "trophy.fill", -12),
|
||||
(0.88, 0.1, "star.fill", 8),
|
||||
(0.9, 0.88, "medal.fill", -6),
|
||||
]
|
||||
|
||||
let badge = badges[index]
|
||||
|
||||
GeometryReader { geo in
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(color.opacity(0.1))
|
||||
.frame(width: 36, height: 36)
|
||||
|
||||
Image(systemName: badge.icon)
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(color.opacity(0.25))
|
||||
}
|
||||
.rotationEffect(.degrees(badge.rotation))
|
||||
.position(x: geo.size.width * badge.x, y: geo.size.height * badge.y)
|
||||
.scaleEffect(animate ? 1.08 : 0.95)
|
||||
.animation(
|
||||
.easeInOut(duration: 2 + Double(index) * 0.3)
|
||||
.repeatForever(autoreverses: true)
|
||||
.delay(Double(index) * 0.2),
|
||||
value: animate
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Premium pricing background with sports icons
|
||||
struct PricingBackground: View {
|
||||
@State private var animate = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Floating sports icons with glow effects
|
||||
ForEach(0..<12, id: \.self) { index in
|
||||
SportsIconWithGlow(index: index, animate: animate)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(.easeInOut(duration: 2).repeatForever(autoreverses: true)) {
|
||||
animate = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Individual sports icon with random glow/flash animation
|
||||
private struct SportsIconWithGlow: View {
|
||||
let index: Int
|
||||
let animate: Bool
|
||||
@State private var isGlowing = false
|
||||
@State private var glowOpacity: Double = 0
|
||||
|
||||
private let configs: [(x: CGFloat, y: CGFloat, icon: String, rotation: Double, scale: CGFloat)] = [
|
||||
(0.08, 0.1, "football.fill", -20, 0.95),
|
||||
(0.92, 0.12, "basketball.fill", 15, 0.9),
|
||||
(0.05, 0.35, "baseball.fill", 10, 0.85),
|
||||
(0.95, 0.38, "hockey.puck.fill", -12, 0.8),
|
||||
(0.1, 0.55, "soccerball", 8, 0.9),
|
||||
(0.9, 0.52, "figure.run", -8, 0.95),
|
||||
(0.06, 0.75, "sportscourt.fill", 5, 0.85),
|
||||
(0.94, 0.78, "trophy.fill", -15, 0.9),
|
||||
(0.12, 0.92, "ticket.fill", 12, 0.8),
|
||||
(0.88, 0.95, "mappin.circle.fill", -10, 0.85),
|
||||
(0.5, 0.05, "car.fill", 0, 0.8),
|
||||
(0.5, 0.98, "map.fill", 5, 0.85),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
let config = configs[index]
|
||||
|
||||
GeometryReader { geo in
|
||||
ZStack {
|
||||
// Glow circle behind icon when active
|
||||
Circle()
|
||||
.fill(Theme.warmOrange)
|
||||
.frame(width: 40 * config.scale, height: 40 * config.scale)
|
||||
.blur(radius: 12)
|
||||
.opacity(glowOpacity * 0.4)
|
||||
|
||||
Image(systemName: config.icon)
|
||||
.font(.system(size: 26 * config.scale))
|
||||
.foregroundStyle(Theme.warmOrange.opacity(0.15 + glowOpacity * 0.25))
|
||||
.rotationEffect(.degrees(config.rotation))
|
||||
}
|
||||
.position(x: geo.size.width * config.x, y: geo.size.height * config.y)
|
||||
.scaleEffect(animate ? 1.08 : 0.95)
|
||||
.scaleEffect(1 + glowOpacity * 0.15)
|
||||
.animation(
|
||||
.easeInOut(duration: 2.5 + Double(index) * 0.1)
|
||||
.repeatForever(autoreverses: true)
|
||||
.delay(Double(index) * 0.1),
|
||||
value: animate
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
startRandomGlow()
|
||||
}
|
||||
}
|
||||
|
||||
private func startRandomGlow() {
|
||||
// Random delay before first glow (stagger the icons)
|
||||
let initialDelay = Double.random(in: 0.5...3.0)
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + initialDelay) {
|
||||
triggerGlow()
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerGlow() {
|
||||
// Glow on
|
||||
withAnimation(.easeIn(duration: 0.3)) {
|
||||
glowOpacity = 1
|
||||
}
|
||||
|
||||
// Glow off after a moment
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
withAnimation(.easeOut(duration: 0.6)) {
|
||||
glowOpacity = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule next glow with random interval
|
||||
let nextGlow = Double.random(in: 2.5...6.0)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + nextGlow) {
|
||||
triggerGlow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingPaywallView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Binding var isPresented: Bool
|
||||
@@ -19,10 +514,43 @@ struct OnboardingPaywallView: View {
|
||||
|
||||
private let storeManager = StoreManager.shared
|
||||
|
||||
private let pages: [(icon: String, title: String, description: String, color: Color)] = [
|
||||
("suitcase.fill", "Unlimited Trips", "Plan as many road trips as you want. Never lose your itineraries.", Theme.warmOrange),
|
||||
("doc.fill", "Export & Share", "Generate beautiful PDF itineraries to share with friends.", Theme.routeGold),
|
||||
("trophy.fill", "Track Your Journey", "Log stadium visits, earn badges, complete your bucket list.", .green)
|
||||
private let pages: [(icon: String, title: String, subtitle: String, bullets: [String], color: Color)] = [
|
||||
(
|
||||
"suitcase.fill",
|
||||
"Unlimited Trips",
|
||||
"Your ultimate sports road trip companion",
|
||||
[
|
||||
"Plan unlimited multi-city adventures",
|
||||
"Smart routing finds the best game combinations",
|
||||
"Save and revisit your favorite itineraries",
|
||||
"Never miss a rivalry game or playoff matchup"
|
||||
],
|
||||
Theme.warmOrange
|
||||
),
|
||||
(
|
||||
"doc.fill",
|
||||
"Export & Share",
|
||||
"Beautiful itineraries at your fingertips",
|
||||
[
|
||||
"Generate stunning PDF trip guides",
|
||||
"Share plans with friends and family",
|
||||
"Includes maps, schedules, and travel times",
|
||||
"Perfect for group trips and coordination"
|
||||
],
|
||||
Theme.routeGold
|
||||
),
|
||||
(
|
||||
"trophy.fill",
|
||||
"Track Your Journey",
|
||||
"Build your sports legacy",
|
||||
[
|
||||
"Log every stadium you visit",
|
||||
"Earn badges for milestones and achievements",
|
||||
"Complete your bucket list across all leagues",
|
||||
"Share your progress with fellow fans"
|
||||
],
|
||||
.green
|
||||
)
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
@@ -68,90 +596,172 @@ struct OnboardingPaywallView: View {
|
||||
private func featurePage(index: Int) -> some View {
|
||||
let page = pages[index]
|
||||
|
||||
return VStack(spacing: Theme.Spacing.xl) {
|
||||
Spacer()
|
||||
return ZStack {
|
||||
// Themed background based on page
|
||||
backgroundForPage(index: index, color: page.color)
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(page.color.opacity(0.15))
|
||||
.frame(width: 120, height: 120)
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: page.icon)
|
||||
.font(.system(size: 50))
|
||||
.foregroundStyle(page.color)
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(page.color.opacity(0.15))
|
||||
.frame(width: 100, height: 100)
|
||||
|
||||
Image(systemName: page.icon)
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(page.color)
|
||||
}
|
||||
|
||||
VStack(spacing: Theme.Spacing.sm) {
|
||||
Text(page.title)
|
||||
.font(.title.bold())
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text(page.subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
|
||||
// Feature bullets
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
ForEach(page.bullets, id: \.self) { bullet in
|
||||
HStack(alignment: .top, spacing: Theme.Spacing.sm) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(page.color)
|
||||
.font(.body)
|
||||
|
||||
Text(bullet)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Theme.Spacing.xl)
|
||||
.padding(.top, Theme.Spacing.md)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VStack(spacing: Theme.Spacing.md) {
|
||||
Text(page.title)
|
||||
.font(.title.bold())
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text(page.description)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Theme.Spacing.xl)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
@ViewBuilder
|
||||
private func backgroundForPage(index: Int, color: Color) -> some View {
|
||||
switch index {
|
||||
case 0:
|
||||
// Unlimited Trips - route map with dotted paths
|
||||
TripRoutesBackground(color: color)
|
||||
case 1:
|
||||
// Export & Share - floating documents
|
||||
DocumentsBackground(color: color)
|
||||
case 2:
|
||||
// Track Your Journey - stadium map with pins
|
||||
StadiumMapBackground(color: color)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pricing Page
|
||||
|
||||
private var pricingPage: some View {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
Spacer()
|
||||
ZStack {
|
||||
// Premium background
|
||||
PricingBackground()
|
||||
|
||||
Text("Choose Your Plan")
|
||||
.font(.title.bold())
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
Spacer()
|
||||
|
||||
if storeManager.isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
VStack(spacing: Theme.Spacing.md) {
|
||||
// Annual (recommended)
|
||||
if let annual = storeManager.annualProduct {
|
||||
OnboardingPricingRow(
|
||||
product: annual,
|
||||
title: "Annual",
|
||||
subtitle: "Best Value - Save 17%",
|
||||
isSelected: selectedProduct?.id == annual.id,
|
||||
isRecommended: true
|
||||
) {
|
||||
selectedProduct = annual
|
||||
}
|
||||
// Header with crown icon
|
||||
VStack(spacing: Theme.Spacing.sm) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Theme.warmOrange.opacity(0.15))
|
||||
.frame(width: 70, height: 70)
|
||||
|
||||
Image(systemName: "crown.fill")
|
||||
.font(.system(size: 30))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
|
||||
// Monthly
|
||||
if let monthly = storeManager.monthlyProduct {
|
||||
OnboardingPricingRow(
|
||||
product: monthly,
|
||||
title: "Monthly",
|
||||
subtitle: "Flexible billing",
|
||||
isSelected: selectedProduct?.id == monthly.id,
|
||||
isRecommended: false
|
||||
) {
|
||||
selectedProduct = monthly
|
||||
}
|
||||
}
|
||||
Text("Choose Your Plan")
|
||||
.font(.title.bold())
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text("Unlock the full SportsTime experience")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
.padding(.horizontal, Theme.Spacing.lg)
|
||||
}
|
||||
|
||||
if let error = errorMessage {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
if storeManager.isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
VStack(spacing: Theme.Spacing.md) {
|
||||
// Annual (recommended)
|
||||
if let annual = storeManager.annualProduct {
|
||||
OnboardingPricingRow(
|
||||
product: annual,
|
||||
title: "Annual",
|
||||
subtitle: "Best Value - Save 17%",
|
||||
isSelected: selectedProduct?.id == annual.id,
|
||||
isRecommended: true
|
||||
) {
|
||||
selectedProduct = annual
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
// Monthly
|
||||
if let monthly = storeManager.monthlyProduct {
|
||||
OnboardingPricingRow(
|
||||
product: monthly,
|
||||
title: "Monthly",
|
||||
subtitle: "Flexible billing",
|
||||
isSelected: selectedProduct?.id == monthly.id,
|
||||
isRecommended: false
|
||||
) {
|
||||
selectedProduct = monthly
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Theme.Spacing.lg)
|
||||
}
|
||||
|
||||
if let error = errorMessage {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
// Features summary
|
||||
HStack(spacing: Theme.Spacing.lg) {
|
||||
featurePill(icon: "infinity", text: "Unlimited")
|
||||
featurePill(icon: "doc.fill", text: "PDF Export")
|
||||
featurePill(icon: "trophy.fill", text: "Progress")
|
||||
}
|
||||
.padding(.top, Theme.Spacing.md)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func featurePill(icon: String, text: String) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 11))
|
||||
Text(text)
|
||||
.font(.caption2)
|
||||
}
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Theme.warmOrange.opacity(0.08), in: Capsule())
|
||||
}
|
||||
|
||||
// MARK: - Bottom Buttons
|
||||
|
||||
private var bottomButtons: some View {
|
||||
@@ -256,6 +866,43 @@ struct OnboardingPricingRow: View {
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
/// The introductory offer for this product, if available
|
||||
private var introOffer: Product.SubscriptionOffer? {
|
||||
product.subscription?.introductoryOffer
|
||||
}
|
||||
|
||||
/// Whether this product has an intro offer to display
|
||||
private var hasIntroOffer: Bool {
|
||||
introOffer != nil
|
||||
}
|
||||
|
||||
/// Format the intro offer period (e.g., "1 month", "1 year")
|
||||
private var introPeriodText: String? {
|
||||
guard let offer = introOffer else { return nil }
|
||||
let unit = offer.period.unit
|
||||
let value = offer.period.value
|
||||
|
||||
switch unit {
|
||||
case .day: return value == 1 ? "day" : "\(value) days"
|
||||
case .week: return value == 1 ? "week" : "\(value) weeks"
|
||||
case .month: return value == 1 ? "month" : "\(value) months"
|
||||
case .year: return value == 1 ? "year" : "\(value) years"
|
||||
@unknown default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// The regular period text (e.g., "/month", "/year")
|
||||
private var regularPeriodText: String {
|
||||
guard let period = product.subscription?.subscriptionPeriod else { return "" }
|
||||
switch period.unit {
|
||||
case .month: return "/month"
|
||||
case .year: return "/year"
|
||||
case .week: return "/week"
|
||||
case .day: return "/day"
|
||||
@unknown default: return ""
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: onSelect) {
|
||||
HStack {
|
||||
@@ -275,16 +922,37 @@ struct OnboardingPricingRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
if hasIntroOffer, let periodText = introPeriodText {
|
||||
Text("First \(periodText) at intro price")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(product.displayPrice)
|
||||
.font(.title3.bold())
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
if let offer = introOffer {
|
||||
// Show intro price prominently
|
||||
Text(offer.displayPrice)
|
||||
.font(.title3.bold())
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
// Show regular price as "then $X.XX/period"
|
||||
Text("then \(product.displayPrice)\(regularPeriodText)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
} else {
|
||||
// No intro offer - show regular price
|
||||
Text(product.displayPrice)
|
||||
.font(.title3.bold())
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
}
|
||||
}
|
||||
|
||||
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme))
|
||||
|
||||
Reference in New Issue
Block a user