/** * TabataFit Onboarding — 6-Screen Conversion Funnel * Problem → Empathy → Solution → Wow Moment → Personalization → Paywall */ import { useState, useRef, useEffect, useCallback, useMemo } from 'react' import { View, StyleSheet, Pressable, Animated, Dimensions, ScrollView, TextInput, } from 'react-native' import { useRouter } from 'expo-router' import { useSafeAreaInsets } from 'react-native-safe-area-context' import Ionicons from '@expo/vector-icons/Ionicons' import { Alert } from 'react-native' import { useTranslation } from 'react-i18next' import { useHaptics, usePurchases } from '@/src/shared/hooks' import { useUserStore } from '@/src/shared/stores' import { OnboardingStep } from '@/src/shared/components/OnboardingStep' import { StyledText } from '@/src/shared/components/StyledText' import { useThemeColors, BRAND, PHASE } from '@/src/shared/theme' import type { ThemeColors } from '@/src/shared/theme/types' import { SPACING, LAYOUT } from '@/src/shared/constants/spacing' import { RADIUS } from '@/src/shared/constants/borderRadius' import { DURATION, EASE, SPRING } from '@/src/shared/constants/animations' import { track, identifyUser, setUserProperties, trackScreen } from '@/src/shared/services/analytics' import type { FitnessLevel, FitnessGoal, WeeklyFrequency } from '@/src/shared/types' const { width: SCREEN_WIDTH } = Dimensions.get('window') const TOTAL_STEPS = 6 // ═══════════════════════════════════════════════════════════════════════════ // SCREEN 1 — THE PROBLEM // ═══════════════════════════════════════════════════════════════════════════ function ProblemScreen({ onNext }: { onNext: () => void }) { const { t } = useTranslation('screens') const haptics = useHaptics() const colors = useThemeColors() const styles = useMemo(() => createStyles(colors), [colors]) const clockScale = useRef(new Animated.Value(0.8)).current const clockOpacity = useRef(new Animated.Value(0)).current const textOpacity = useRef(new Animated.Value(0)).current useEffect(() => { // Clock animation Animated.parallel([ Animated.spring(clockScale, { toValue: 1, ...SPRING.BOUNCY, useNativeDriver: true, }), Animated.timing(clockOpacity, { toValue: 1, duration: DURATION.SLOW, easing: EASE.EASE_OUT, useNativeDriver: true, }), ]).start() // Text fade in after clock setTimeout(() => { Animated.timing(textOpacity, { toValue: 1, duration: DURATION.SLOW, easing: EASE.EASE_OUT, useNativeDriver: true, }).start() }, 400) }, []) return ( {t('onboarding.problem.title')} {t('onboarding.problem.subtitle1')} {t('onboarding.problem.subtitle2')} { haptics.buttonTap() onNext() }} > {t('onboarding.problem.cta')} ) } // ═══════════════════════════════════════════════════════════════════════════ // SCREEN 2 — EMPATHY // ═══════════════════════════════════════════════════════════════════════════ const BARRIERS = [ { id: 'no-time', labelKey: 'onboarding.empathy.noTime' as const, icon: 'time-outline' as const }, { id: 'low-motivation', labelKey: 'onboarding.empathy.lowMotivation' as const, icon: 'battery-dead-outline' as const }, { id: 'no-knowledge', labelKey: 'onboarding.empathy.noKnowledge' as const, icon: 'help-circle-outline' as const }, { id: 'no-gym', labelKey: 'onboarding.empathy.noGym' as const, icon: 'home-outline' as const }, ] function EmpathyScreen({ onNext, barriers, setBarriers, }: { onNext: () => void barriers: string[] setBarriers: (b: string[]) => void }) { const { t } = useTranslation('screens') const haptics = useHaptics() const colors = useThemeColors() const styles = useMemo(() => createStyles(colors), [colors]) const toggleBarrier = (id: string) => { haptics.selection() if (barriers.includes(id)) { setBarriers(barriers.filter((b) => b !== id)) } else if (barriers.length < 2) { setBarriers([...barriers, id]) } } return ( {t('onboarding.empathy.title')} {t('onboarding.empathy.chooseUpTo')} {BARRIERS.map((item) => { const selected = barriers.includes(item.id) return ( toggleBarrier(item.id)} > {t(item.labelKey)} ) })} { if (barriers.length > 0) { haptics.buttonTap() onNext() } }} > 0 ? '#FFFFFF' : colors.text.disabled} > {t('common:continue')} ) } // ═══════════════════════════════════════════════════════════════════════════ // SCREEN 3 — THE SOLUTION (Scientific Proof) // ═══════════════════════════════════════════════════════════════════════════ function SolutionScreen({ onNext }: { onNext: () => void }) { const { t } = useTranslation('screens') const haptics = useHaptics() const colors = useThemeColors() const styles = useMemo(() => createStyles(colors), [colors]) const tabataHeight = useRef(new Animated.Value(0)).current const cardioHeight = useRef(new Animated.Value(0)).current const citationOpacity = useRef(new Animated.Value(0)).current useEffect(() => { // Animate bars Animated.sequence([ Animated.delay(300), Animated.parallel([ Animated.spring(tabataHeight, { toValue: 1, ...SPRING.GENTLE, useNativeDriver: false, }), Animated.spring(cardioHeight, { toValue: 1, ...SPRING.GENTLE, useNativeDriver: false, }), ]), Animated.delay(200), Animated.timing(citationOpacity, { toValue: 1, duration: DURATION.SLOW, easing: EASE.EASE_OUT, useNativeDriver: true, }), ]).start() }, []) const MAX_BAR_HEIGHT = 160 return ( {t('onboarding.solution.title')} {/* Comparison bars */} {/* Tabata bar */} {t('onboarding.solution.tabataCalories')} {t('onboarding.solution.tabata')} {t('onboarding.solution.tabataDuration')} {/* VS */} {t('onboarding.solution.vs')} {/* Cardio bar */} {t('onboarding.solution.cardioCalories')} {t('onboarding.solution.cardio')} {t('onboarding.solution.cardioDuration')} {/* Citation */} {t('onboarding.solution.citation')} {t('onboarding.solution.citationAuthor')} { haptics.buttonTap() onNext() }} > {t('onboarding.solution.cta')} ) } // ═══════════════════════════════════════════════════════════════════════════ // SCREEN 4 — WOW MOMENT (Staggered Feature Reveal) // ═══════════════════════════════════════════════════════════════════════════ const WOW_FEATURES = [ { icon: 'timer-outline' as const, iconColor: BRAND.PRIMARY, titleKey: 'onboarding.wow.card1Title', subtitleKey: 'onboarding.wow.card1Subtitle' }, { icon: 'barbell-outline' as const, iconColor: PHASE.REST, titleKey: 'onboarding.wow.card2Title', subtitleKey: 'onboarding.wow.card2Subtitle' }, { icon: 'mic-outline' as const, iconColor: PHASE.PREP, titleKey: 'onboarding.wow.card3Title', subtitleKey: 'onboarding.wow.card3Subtitle' }, { icon: 'trending-up-outline' as const, iconColor: PHASE.COMPLETE, titleKey: 'onboarding.wow.card4Title', subtitleKey: 'onboarding.wow.card4Subtitle' }, ] as const function WowScreen({ onNext }: { onNext: () => void }) { const { t } = useTranslation('screens') const haptics = useHaptics() const colors = useThemeColors() const styles = useMemo(() => createStyles(colors), [colors]) const wowStyles = useMemo(() => createWowStyles(colors), [colors]) const rowAnims = useRef(WOW_FEATURES.map(() => ({ opacity: new Animated.Value(0), translateY: new Animated.Value(20), }))).current const ctaOpacity = useRef(new Animated.Value(0)).current const [ctaReady, setCtaReady] = useState(false) useEffect(() => { // Staggered reveal: each row fades in + slides up, 150ms apart, starting at 300ms const STAGGER_DELAY = 150 const ROW_DURATION = DURATION.NORMAL // 300ms const START_DELAY = 300 WOW_FEATURES.forEach((_, i) => { setTimeout(() => { Animated.parallel([ Animated.timing(rowAnims[i].opacity, { toValue: 1, duration: ROW_DURATION, easing: EASE.EASE_OUT, useNativeDriver: true, }), Animated.timing(rowAnims[i].translateY, { toValue: 0, duration: ROW_DURATION, easing: EASE.EASE_OUT, useNativeDriver: true, }), ]).start() }, START_DELAY + i * STAGGER_DELAY) }) // CTA fades in 200ms after last row finishes const ctaDelay = START_DELAY + (WOW_FEATURES.length - 1) * STAGGER_DELAY + ROW_DURATION + 200 setTimeout(() => { setCtaReady(true) Animated.timing(ctaOpacity, { toValue: 1, duration: ROW_DURATION, easing: EASE.EASE_OUT, useNativeDriver: true, }).start() }, ctaDelay) }, []) return ( {t('onboarding.wow.title')} {t('onboarding.wow.subtitle')} {/* Feature list */} {WOW_FEATURES.map((feature, i) => ( {t(feature.titleKey)} {t(feature.subtitleKey)} ))} {/* CTA fades in after all rows */} { if (ctaReady) { haptics.buttonTap() onNext() } }} > {t('common:next')} ) } // ═══════════════════════════════════════════════════════════════════════════ // SCREEN 5 — PERSONALIZATION // ═══════════════════════════════════════════════════════════════════════════ const LEVELS: { value: FitnessLevel; labelKey: string }[] = [ { value: 'beginner', labelKey: 'common:levels.beginner' }, { value: 'intermediate', labelKey: 'common:levels.intermediate' }, { value: 'advanced', labelKey: 'common:levels.advanced' }, ] const GOALS: { value: FitnessGoal; labelKey: string }[] = [ { value: 'weight-loss', labelKey: 'onboarding.personalization.goals.weightLoss' }, { value: 'cardio', labelKey: 'onboarding.personalization.goals.cardio' }, { value: 'strength', labelKey: 'onboarding.personalization.goals.strength' }, { value: 'wellness', labelKey: 'onboarding.personalization.goals.wellness' }, ] const FREQUENCIES: { value: WeeklyFrequency; labelKey: string }[] = [ { value: 2, labelKey: 'onboarding.personalization.frequencies.2x' }, { value: 3, labelKey: 'onboarding.personalization.frequencies.3x' }, { value: 5, labelKey: 'onboarding.personalization.frequencies.5x' }, ] function PersonalizationScreen({ onNext, name, setName, level, setLevel, goal, setGoal, frequency, setFrequency, }: { onNext: () => void name: string setName: (n: string) => void level: FitnessLevel setLevel: (l: FitnessLevel) => void goal: FitnessGoal setGoal: (g: FitnessGoal) => void frequency: WeeklyFrequency setFrequency: (f: WeeklyFrequency) => void }) { const { t } = useTranslation('screens') const haptics = useHaptics() const colors = useThemeColors() const styles = useMemo(() => createStyles(colors), [colors]) return ( {t('onboarding.personalization.title')} {/* Name input */} {t('onboarding.personalization.yourName')} {/* Fitness Level */} {t('onboarding.personalization.fitnessLevel')} {LEVELS.map((item) => ( { haptics.selection() setLevel(item.value) }} > {t(item.labelKey)} ))} {/* Goal */} {t('onboarding.personalization.yourGoal')} {GOALS.map((item) => ( { haptics.selection() setGoal(item.value) }} > {t(item.labelKey)} ))} {/* Frequency */} {t('onboarding.personalization.weeklyFrequency')} {FREQUENCIES.map((item) => ( { haptics.selection() setFrequency(item.value) }} > {t(item.labelKey)} ))} {name.trim().length > 0 && ( {t('onboarding.personalization.readyMessage')} )} { if (name.trim()) { haptics.buttonTap() onNext() } }} > {t('common:continue')} ) } // ═══════════════════════════════════════════════════════════════════════════ // SCREEN 6 — PAYWALL // ═══════════════════════════════════════════════════════════════════════════ const PREMIUM_FEATURE_KEYS = [ 'onboarding.paywall.features.unlimited', 'onboarding.paywall.features.offline', 'onboarding.paywall.features.stats', 'onboarding.paywall.features.noAds', ] as const function PaywallScreen({ onSubscribe, onSkip, }: { onSubscribe: (plan: 'premium-monthly' | 'premium-yearly') => void onSkip: () => void }) { const { t } = useTranslation('screens') const haptics = useHaptics() const colors = useThemeColors() const styles = useMemo(() => createStyles(colors), [colors]) const { isLoading, monthlyPackage, annualPackage, purchasePackage, restorePurchases, } = usePurchases() const [selectedPlan, setSelectedPlan] = useState<'premium-monthly' | 'premium-yearly'>('premium-yearly') const [isPurchasing, setIsPurchasing] = useState(false) const featureAnims = useRef(PREMIUM_FEATURE_KEYS.map(() => new Animated.Value(0))).current const handlePlanSelect = (plan: 'premium-monthly' | 'premium-yearly') => { haptics.selection() setSelectedPlan(plan) track('onboarding_paywall_plan_selected', { plan }) } useEffect(() => { // Staggered feature fade-in PREMIUM_FEATURE_KEYS.forEach((_, i) => { setTimeout(() => { Animated.timing(featureAnims[i], { toValue: 1, duration: DURATION.NORMAL, easing: EASE.EASE_OUT, useNativeDriver: true, }).start() }, i * 100) }) }, []) // Get localized prices from RevenueCat packages const yearlyPrice = annualPackage?.product.priceString ?? t('onboarding.paywall.yearlyPrice') const monthlyPrice = monthlyPackage?.product.priceString ?? t('onboarding.paywall.monthlyPrice') const handlePurchase = async () => { if (isPurchasing) return const pkg = selectedPlan === 'premium-yearly' ? annualPackage : monthlyPackage const price = selectedPlan === 'premium-yearly' ? (annualPackage?.product.priceString ?? t('onboarding.paywall.yearlyPrice')) : (monthlyPackage?.product.priceString ?? t('onboarding.paywall.monthlyPrice')) track('onboarding_paywall_purchase_tapped', { plan: selectedPlan, price }) // DEV mode: if RevenueCat hasn't loaded or has no packages, show simulated purchase dialog if (__DEV__ && (isLoading || !pkg)) { haptics.buttonTap() const planLabel = selectedPlan === 'premium-yearly' ? `Annual (${t('onboarding.paywall.yearlyPrice')})` : `Monthly (${t('onboarding.paywall.monthlyPrice')})` Alert.alert( 'Confirm Subscription', `Subscribe to TabataFit+ ${planLabel}?\n\nThis is a sandbox purchase — no real charge.`, [ { text: 'Cancel', style: 'cancel' }, { text: 'Subscribe', onPress: () => { track('onboarding_paywall_purchase_success', { plan: selectedPlan }) onSubscribe(selectedPlan) }, }, ] ) return } if (isLoading || !pkg) return setIsPurchasing(true) haptics.buttonTap() try { const result = await purchasePackage(pkg) if (result.success) { track('onboarding_paywall_purchase_success', { plan: selectedPlan }) onSubscribe(selectedPlan) } } finally { setIsPurchasing(false) } } const handleRestore = async () => { haptics.buttonTap() const restored = await restorePurchases() track('onboarding_paywall_restored', { success: !!restored }) if (restored) { // User has premium now, complete onboarding onSubscribe('premium-yearly') } } return ( {t('onboarding.paywall.title')} {/* Features */} {PREMIUM_FEATURE_KEYS.map((featureKey, i) => ( {t(featureKey)} ))} {/* Pricing cards */} {/* Annual */} handlePlanSelect('premium-yearly')} > {t('onboarding.paywall.bestValue')} {yearlyPrice} {t('common:units.perYear')} {t('onboarding.paywall.savePercent')} {/* Monthly */} handlePlanSelect('premium-monthly')} > {monthlyPrice} {t('common:units.perMonth')} {/* CTA */} {isPurchasing ? '...' : t('onboarding.paywall.trialCta')} {/* Guarantees */} {t('onboarding.paywall.guarantees')} {/* Restore Purchases */} {t('onboarding.paywall.restorePurchases')} {/* Skip */} { track('onboarding_paywall_skipped') onSkip() }} > {t('onboarding.paywall.skipButton')} ) } // ═══════════════════════════════════════════════════════════════════════════ // MAIN ONBOARDING CONTROLLER // ═══════════════════════════════════════════════════════════════════════════ const STEP_NAMES: Record = { 1: 'problem', 2: 'empathy', 3: 'solution', 4: 'wow', 5: 'personalization', 6: 'paywall', } export default function OnboardingScreen() { const router = useRouter() const [step, setStep] = useState(1) // Personalization state const [barriers, setBarriers] = useState([]) const [name, setName] = useState('') const [level, setLevel] = useState('beginner') const [goal, setGoal] = useState('cardio') const [frequency, setFrequency] = useState(3) const completeOnboarding = useUserStore((s) => s.completeOnboarding) const setSubscription = useUserStore((s) => s.setSubscription) // Analytics: track time per step and total onboarding time const onboardingStartTime = useRef(Date.now()) const stepStartTime = useRef(Date.now()) // Track onboarding_started + first step viewed on mount useEffect(() => { trackScreen('onboarding') track('onboarding_started') track('onboarding_step_viewed', { step: 1, step_name: STEP_NAMES[1] }) }, []) const finishOnboarding = useCallback( (plan: 'free' | 'premium-monthly' | 'premium-yearly') => { const totalTime = Date.now() - onboardingStartTime.current track('onboarding_completed', { plan, total_time_ms: totalTime, steps_completed: step, }) const userData = { name: name.trim() || 'Athlete', fitnessLevel: level, goal, weeklyFrequency: frequency, barriers, } completeOnboarding(userData) // Identify user in PostHog for session replay linking const userId = `user_${Date.now()}` // In production, use actual user ID from backend identifyUser(userId, { name: userData.name, fitness_level: level, fitness_goal: goal, weekly_frequency: frequency, subscription_plan: plan, onboarding_completed_at: new Date().toISOString(), barriers: barriers.join(','), }) if (plan !== 'free') { setSubscription(plan) } router.replace('/(tabs)') }, [name, level, goal, frequency, barriers, step] ) const nextStep = useCallback(() => { const now = Date.now() const timeOnStep = now - stepStartTime.current // Track step completed track('onboarding_step_completed', { step, step_name: STEP_NAMES[step], time_on_step_ms: timeOnStep, }) // Track specific step data if (step === 2) { track('onboarding_barriers_selected', { barriers, barrier_count: barriers.length, }) } if (step === 5) { track('onboarding_personalization_completed', { name_provided: name.trim().length > 0, level, goal, frequency, }) } const next = Math.min(step + 1, TOTAL_STEPS) stepStartTime.current = now // Track next step viewed track('onboarding_step_viewed', { step: next, step_name: STEP_NAMES[next] }) setStep(next) }, [step, barriers, name, level, goal, frequency]) const renderStep = () => { switch (step) { case 1: return case 2: return ( ) case 3: return case 4: return case 5: return ( ) case 6: return ( finishOnboarding(plan)} onSkip={() => finishOnboarding('free')} /> ) default: return null } } return ( {renderStep()} ) } // ═══════════════════════════════════════════════════════════════════════════ // STYLES // ═══════════════════════════════════════════════════════════════════════════ function createStyles(colors: ThemeColors) { return StyleSheet.create({ // Layout helpers screenCenter: { flex: 1, justifyContent: 'center', alignItems: 'center', }, screenFull: { flex: 1, }, titleCenter: { textAlign: 'center', }, subtitle: { textAlign: 'center', }, bottomAction: { position: 'absolute', bottom: SPACING[4], left: 0, right: 0, }, // CTA Button ctaButton: { height: LAYOUT.BUTTON_HEIGHT, backgroundColor: BRAND.PRIMARY, borderRadius: RADIUS.GLASS_BUTTON, alignItems: 'center', justifyContent: 'center', }, ctaButtonDisabled: { backgroundColor: colors.bg.elevated, }, // ── Screen 2: Barriers ── barrierGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: SPACING[3], marginTop: SPACING[8], justifyContent: 'center', }, barrierCard: { width: (SCREEN_WIDTH - LAYOUT.SCREEN_PADDING * 2 - SPACING[3]) / 2, paddingVertical: SPACING[6], alignItems: 'center', borderRadius: RADIUS.GLASS_CARD, ...colors.glass.base, }, barrierCardSelected: { borderColor: BRAND.PRIMARY, backgroundColor: 'rgba(255, 107, 53, 0.1)', }, // ── Screen 3: Comparison ── comparisonContainer: { flexDirection: 'row', justifyContent: 'center', alignItems: 'flex-end', marginTop: SPACING[10], paddingHorizontal: SPACING[8], gap: SPACING[4], }, barColumn: { alignItems: 'center', flex: 1, }, barTrack: { width: 60, height: 160, backgroundColor: colors.bg.overlay1, borderRadius: RADIUS.SM, overflow: 'hidden', marginVertical: SPACING[3], justifyContent: 'flex-end', }, barFill: { width: '100%', borderRadius: RADIUS.SM, }, barTabata: { backgroundColor: BRAND.PRIMARY, }, barCardio: { backgroundColor: PHASE.REST, }, vsContainer: { paddingBottom: 80, }, citation: { marginTop: SPACING[8], paddingHorizontal: SPACING[4], }, citationText: { textAlign: 'center', fontStyle: 'italic', lineHeight: 20, }, citationAuthor: { textAlign: 'center', marginTop: SPACING[2], }, // ── Screen 5: Personalization ── personalizationContent: { paddingBottom: SPACING[10], }, fieldGroup: { marginTop: SPACING[6], }, fieldLabel: { letterSpacing: 1.5, marginBottom: SPACING[2], }, textInput: { height: LAYOUT.BUTTON_HEIGHT_SM, backgroundColor: colors.bg.surface, borderRadius: RADIUS.MD, paddingHorizontal: SPACING[4], color: colors.text.primary, fontSize: 17, borderWidth: 1, borderColor: colors.border.glass, }, segmentRow: { flexDirection: 'row', backgroundColor: colors.bg.surface, borderRadius: RADIUS.MD, padding: 3, gap: 2, }, segmentButton: { flex: 1, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: RADIUS.SM, }, segmentButtonActive: { backgroundColor: colors.bg.elevated, }, readyMessage: { textAlign: 'center', marginTop: SPACING[6], }, // ── Screen 6: Paywall ── paywallContent: { paddingBottom: SPACING[10], }, featuresList: { marginTop: SPACING[8], gap: SPACING[4], }, featureRow: { flexDirection: 'row', alignItems: 'center', }, pricingCards: { flexDirection: 'row', gap: SPACING[3], marginTop: SPACING[8], }, pricingCard: { flex: 1, paddingVertical: SPACING[5], alignItems: 'center', justifyContent: 'center', borderRadius: RADIUS.GLASS_CARD, ...colors.glass.base, }, pricingCardSelected: { borderColor: BRAND.PRIMARY, borderWidth: 2, backgroundColor: 'rgba(255, 107, 53, 0.08)', }, bestValueBadge: { backgroundColor: BRAND.PRIMARY, paddingHorizontal: SPACING[3], paddingVertical: SPACING[1], borderRadius: RADIUS.SM, marginBottom: SPACING[2], }, trialButton: { height: LAYOUT.BUTTON_HEIGHT, backgroundColor: BRAND.PRIMARY, borderRadius: RADIUS.GLASS_BUTTON, alignItems: 'center', justifyContent: 'center', marginTop: SPACING[6], }, guarantees: { alignItems: 'center', marginTop: SPACING[4], }, restoreButton: { alignItems: 'center', paddingVertical: SPACING[3], }, skipButton: { alignItems: 'center', paddingVertical: SPACING[5], marginTop: SPACING[2], }, }) } // ── Screen 4: Feature List Styles ── function createWowStyles(colors: ThemeColors) { return StyleSheet.create({ list: { gap: SPACING[5], marginTop: SPACING[4], }, row: { flexDirection: 'row', alignItems: 'center', gap: SPACING[4], }, iconCircle: { width: 44, height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center', }, textCol: { flex: 1, }, }) }