Files
tabatago/app/onboarding.tsx
Millian Lamiaux cd065d07c3 feat: explore tab, React Query data layer, programs, sync, analytics, testing infrastructure
- Replace browse tab with Supabase-connected explore tab with filters
- Add React Query for data fetching with loading states
- Add 3 structured programs with weekly progression
- Add Supabase anonymous auth sync service
- Add PostHog analytics with screen tracking and events
- Add comprehensive test strategy (Vitest + Maestro E2E)
- Add RevenueCat subscription system with DEV simulation
- Add i18n translations for new screens (EN/FR/DE/ES)
- Add data deletion modal, sync consent modal
- Add assessment screen and program routes
- Add GitHub Actions CI workflow
- Update activity store with sync integration
2026-03-24 12:04:48 +01:00

1325 lines
42 KiB
TypeScript

/**
* 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 (
<View style={styles.screenCenter}>
<Animated.View
style={{
opacity: clockOpacity,
transform: [{ scale: clockScale }],
marginBottom: SPACING[8],
}}
>
<Ionicons name="time" size={80} color={BRAND.PRIMARY} />
</Animated.View>
<Animated.View style={{ opacity: textOpacity, alignItems: 'center' }}>
<StyledText
size={28}
weight="bold"
color={colors.text.primary}
style={styles.titleCenter}
>
{t('onboarding.problem.title')}
</StyledText>
<StyledText
size={17}
color={colors.text.secondary}
style={[styles.subtitle, { marginTop: SPACING[3] }]}
>
{t('onboarding.problem.subtitle1')}
</StyledText>
<StyledText
size={17}
color={colors.text.tertiary}
style={[styles.subtitle, { marginTop: SPACING[6] }]}
>
{t('onboarding.problem.subtitle2')}
</StyledText>
</Animated.View>
<View style={styles.bottomAction}>
<Pressable
style={styles.ctaButton}
testID="onboarding-problem-cta"
onPress={() => {
haptics.buttonTap()
onNext()
}}
>
<StyledText size={17} weight="semibold" color="#FFFFFF">
{t('onboarding.problem.cta')}
</StyledText>
</Pressable>
</View>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// 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 (
<View style={styles.screenFull}>
<StyledText size={28} weight="bold" color={colors.text.primary} style={styles.titleCenter}>
{t('onboarding.empathy.title')}
</StyledText>
<StyledText size={15} color={colors.text.tertiary} style={styles.subtitle}>
{t('onboarding.empathy.chooseUpTo')}
</StyledText>
<View style={styles.barrierGrid}>
{BARRIERS.map((item) => {
const selected = barriers.includes(item.id)
return (
<Pressable
key={item.id}
testID={`barrier-${item.id}`}
style={[
styles.barrierCard,
selected && styles.barrierCardSelected,
]}
onPress={() => toggleBarrier(item.id)}
>
<Ionicons
name={item.icon}
size={28}
color={selected ? BRAND.PRIMARY : colors.text.tertiary}
/>
<StyledText
size={15}
weight={selected ? 'semibold' : 'regular'}
color={selected ? colors.text.primary : colors.text.secondary}
style={{ marginTop: SPACING[2], textAlign: 'center' }}
>
{t(item.labelKey)}
</StyledText>
</Pressable>
)
})}
</View>
<View style={styles.bottomAction}>
<Pressable
style={[styles.ctaButton, barriers.length === 0 && styles.ctaButtonDisabled]}
testID="onboarding-empathy-continue"
onPress={() => {
if (barriers.length > 0) {
haptics.buttonTap()
onNext()
}
}}
>
<StyledText
size={17}
weight="semibold"
color={barriers.length > 0 ? '#FFFFFF' : colors.text.disabled}
>
{t('common:continue')}
</StyledText>
</Pressable>
</View>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// 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 (
<View style={styles.screenFull}>
<StyledText size={28} weight="bold" color={colors.text.primary} style={styles.titleCenter}>
{t('onboarding.solution.title')}
</StyledText>
{/* Comparison bars */}
<View style={styles.comparisonContainer}>
{/* Tabata bar */}
<View style={styles.barColumn}>
<StyledText size={22} weight="bold" color={BRAND.PRIMARY}>
{t('onboarding.solution.tabataCalories')}
</StyledText>
<View style={styles.barTrack}>
<Animated.View
style={[
styles.barFill,
styles.barTabata,
{
height: tabataHeight.interpolate({
inputRange: [0, 1],
outputRange: [0, MAX_BAR_HEIGHT * 0.94],
}),
},
]}
/>
</View>
<StyledText size={15} weight="semibold" color={colors.text.primary}>
{t('onboarding.solution.tabata')}
</StyledText>
<StyledText size={13} color={colors.text.tertiary}>
{t('onboarding.solution.tabataDuration')}
</StyledText>
</View>
{/* VS */}
<View style={styles.vsContainer}>
<StyledText size={15} weight="bold" color={colors.text.hint}>
{t('onboarding.solution.vs')}
</StyledText>
</View>
{/* Cardio bar */}
<View style={styles.barColumn}>
<StyledText size={22} weight="bold" color={PHASE.REST}>
{t('onboarding.solution.cardioCalories')}
</StyledText>
<View style={styles.barTrack}>
<Animated.View
style={[
styles.barFill,
styles.barCardio,
{
height: cardioHeight.interpolate({
inputRange: [0, 1],
outputRange: [0, MAX_BAR_HEIGHT],
}),
},
]}
/>
</View>
<StyledText size={15} weight="semibold" color={colors.text.primary}>
{t('onboarding.solution.cardio')}
</StyledText>
<StyledText size={13} color={colors.text.tertiary}>
{t('onboarding.solution.cardioDuration')}
</StyledText>
</View>
</View>
{/* Citation */}
<Animated.View style={[styles.citation, { opacity: citationOpacity }]}>
<StyledText size={13} color={colors.text.tertiary} style={styles.citationText}>
{t('onboarding.solution.citation')}
</StyledText>
<StyledText size={11} color={colors.text.hint} style={styles.citationAuthor}>
{t('onboarding.solution.citationAuthor')}
</StyledText>
</Animated.View>
<View style={styles.bottomAction}>
<Pressable
style={styles.ctaButton}
testID="onboarding-solution-cta"
onPress={() => {
haptics.buttonTap()
onNext()
}}
>
<StyledText size={17} weight="semibold" color="#FFFFFF">
{t('onboarding.solution.cta')}
</StyledText>
</Pressable>
</View>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// 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 (
<View style={styles.screenFull}>
<StyledText size={28} weight="bold" color={colors.text.primary} style={styles.titleCenter}>
{t('onboarding.wow.title')}
</StyledText>
<StyledText size={15} color={colors.text.tertiary} style={[styles.subtitle, { marginBottom: SPACING[8] }]}>
{t('onboarding.wow.subtitle')}
</StyledText>
{/* Feature list */}
<View style={wowStyles.list}>
{WOW_FEATURES.map((feature, i) => (
<Animated.View
key={i}
style={[
wowStyles.row,
{
opacity: rowAnims[i].opacity,
transform: [{ translateY: rowAnims[i].translateY }],
},
]}
>
<View style={[wowStyles.iconCircle, { backgroundColor: `${feature.iconColor}26` }]}>
<Ionicons name={feature.icon} size={22} color={feature.iconColor} />
</View>
<View style={wowStyles.textCol}>
<StyledText size={17} weight="semibold" color={colors.text.primary}>
{t(feature.titleKey)}
</StyledText>
<StyledText size={14} color={colors.text.tertiary} style={{ marginTop: 2, lineHeight: 20 }}>
{t(feature.subtitleKey)}
</StyledText>
</View>
</Animated.View>
))}
</View>
{/* CTA fades in after all rows */}
<Animated.View style={[styles.bottomAction, { opacity: ctaOpacity }]}>
<Pressable
style={styles.ctaButton}
testID="onboarding-wow-cta"
onPress={() => {
if (ctaReady) {
haptics.buttonTap()
onNext()
}
}}
>
<StyledText size={17} weight="semibold" color="#FFFFFF">
{t('common:next')}
</StyledText>
</Pressable>
</Animated.View>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// 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 (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={styles.personalizationContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<StyledText size={28} weight="bold" color={colors.text.primary} style={styles.titleCenter}>
{t('onboarding.personalization.title')}
</StyledText>
{/* Name input */}
<View style={styles.fieldGroup}>
<StyledText size={13} weight="semibold" color={colors.text.tertiary} style={styles.fieldLabel}>
{t('onboarding.personalization.yourName')}
</StyledText>
<TextInput
style={styles.textInput}
value={name}
onChangeText={setName}
placeholder={t('onboarding.personalization.namePlaceholder')}
placeholderTextColor={colors.text.hint}
autoCapitalize="words"
autoCorrect={false}
testID="name-input"
/>
</View>
{/* Fitness Level */}
<View style={styles.fieldGroup}>
<StyledText size={13} weight="semibold" color={colors.text.tertiary} style={styles.fieldLabel}>
{t('onboarding.personalization.fitnessLevel')}
</StyledText>
<View style={styles.segmentRow}>
{LEVELS.map((item) => (
<Pressable
key={item.value}
testID={`level-${item.value}`}
style={[
styles.segmentButton,
level === item.value && styles.segmentButtonActive,
]}
onPress={() => {
haptics.selection()
setLevel(item.value)
}}
>
<StyledText
size={14}
weight={level === item.value ? 'semibold' : 'regular'}
color={level === item.value ? colors.text.primary : colors.text.tertiary}
>
{t(item.labelKey)}
</StyledText>
</Pressable>
))}
</View>
</View>
{/* Goal */}
<View style={styles.fieldGroup}>
<StyledText size={13} weight="semibold" color={colors.text.tertiary} style={styles.fieldLabel}>
{t('onboarding.personalization.yourGoal')}
</StyledText>
<View style={styles.segmentRow}>
{GOALS.map((item) => (
<Pressable
key={item.value}
testID={`goal-${item.value}`}
style={[
styles.segmentButton,
goal === item.value && styles.segmentButtonActive,
]}
onPress={() => {
haptics.selection()
setGoal(item.value)
}}
>
<StyledText
size={14}
weight={goal === item.value ? 'semibold' : 'regular'}
color={goal === item.value ? colors.text.primary : colors.text.tertiary}
>
{t(item.labelKey)}
</StyledText>
</Pressable>
))}
</View>
</View>
{/* Frequency */}
<View style={styles.fieldGroup}>
<StyledText size={13} weight="semibold" color={colors.text.tertiary} style={styles.fieldLabel}>
{t('onboarding.personalization.weeklyFrequency')}
</StyledText>
<View style={styles.segmentRow}>
{FREQUENCIES.map((item) => (
<Pressable
key={item.value}
testID={`frequency-${item.value}x`}
style={[
styles.segmentButton,
frequency === item.value && styles.segmentButtonActive,
]}
onPress={() => {
haptics.selection()
setFrequency(item.value)
}}
>
<StyledText
size={14}
weight={frequency === item.value ? 'semibold' : 'regular'}
color={frequency === item.value ? colors.text.primary : colors.text.tertiary}
>
{t(item.labelKey)}
</StyledText>
</Pressable>
))}
</View>
</View>
{name.trim().length > 0 && (
<StyledText size={15} color={BRAND.SUCCESS} style={styles.readyMessage}>
{t('onboarding.personalization.readyMessage')}
</StyledText>
)}
<View style={{ marginTop: SPACING[8] }}>
<Pressable
style={[styles.ctaButton, !name.trim() && styles.ctaButtonDisabled]}
testID="onboarding-personalization-continue"
onPress={() => {
if (name.trim()) {
haptics.buttonTap()
onNext()
}
}}
>
<StyledText
size={17}
weight="semibold"
color={name.trim() ? '#FFFFFF' : colors.text.disabled}
>
{t('common:continue')}
</StyledText>
</Pressable>
</View>
</ScrollView>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// 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 (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={styles.paywallContent}
showsVerticalScrollIndicator={false}
>
<StyledText size={28} weight="bold" color={colors.text.primary} style={styles.titleCenter}>
{t('onboarding.paywall.title')}
</StyledText>
{/* Features */}
<View style={styles.featuresList}>
{PREMIUM_FEATURE_KEYS.map((featureKey, i) => (
<Animated.View
key={featureKey}
style={[styles.featureRow, { opacity: featureAnims[i] }]}
>
<Ionicons name="checkmark-circle" size={22} color={BRAND.SUCCESS} />
<StyledText
size={16}
color={colors.text.primary}
style={{ marginLeft: SPACING[3], flex: 1 }}
>
{t(featureKey)}
</StyledText>
</Animated.View>
))}
</View>
{/* Pricing cards */}
<View style={styles.pricingCards}>
{/* Annual */}
<Pressable
testID="plan-yearly"
style={[
styles.pricingCard,
selectedPlan === 'premium-yearly' && styles.pricingCardSelected,
]}
onPress={() => handlePlanSelect('premium-yearly')}
>
<View style={styles.bestValueBadge}>
<StyledText size={11} weight="bold" color="#FFFFFF">
{t('onboarding.paywall.bestValue')}
</StyledText>
</View>
<StyledText size={22} weight="bold" color={colors.text.primary}>
{yearlyPrice}
</StyledText>
<StyledText size={13} color={colors.text.secondary}>
{t('common:units.perYear')}
</StyledText>
<StyledText size={12} weight="semibold" color={BRAND.PRIMARY} style={{ marginTop: SPACING[1] }}>
{t('onboarding.paywall.savePercent')}
</StyledText>
</Pressable>
{/* Monthly */}
<Pressable
testID="plan-monthly"
style={[
styles.pricingCard,
selectedPlan === 'premium-monthly' && styles.pricingCardSelected,
]}
onPress={() => handlePlanSelect('premium-monthly')}
>
<StyledText size={22} weight="bold" color={colors.text.primary}>
{monthlyPrice}
</StyledText>
<StyledText size={13} color={colors.text.secondary}>
{t('common:units.perMonth')}
</StyledText>
</Pressable>
</View>
{/* CTA */}
<Pressable
style={[styles.trialButton, isPurchasing && styles.ctaButtonDisabled]}
testID="subscribe-button"
onPress={handlePurchase}
disabled={isPurchasing}
>
<StyledText size={17} weight="bold" color="#FFFFFF">
{isPurchasing ? '...' : t('onboarding.paywall.trialCta')}
</StyledText>
</Pressable>
{/* Guarantees */}
<View style={styles.guarantees}>
<StyledText size={13} color={colors.text.tertiary}>
{t('onboarding.paywall.guarantees')}
</StyledText>
</View>
{/* Restore Purchases */}
<Pressable style={styles.restoreButton} onPress={handleRestore} testID="restore-purchases">
<StyledText size={14} color={colors.text.hint}>
{t('onboarding.paywall.restorePurchases')}
</StyledText>
</Pressable>
{/* Skip */}
<Pressable
style={styles.skipButton}
testID="skip-paywall"
onPress={() => {
track('onboarding_paywall_skipped')
onSkip()
}}
>
<StyledText size={14} color={colors.text.hint}>
{t('onboarding.paywall.skipButton')}
</StyledText>
</Pressable>
</ScrollView>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// MAIN ONBOARDING CONTROLLER
// ═══════════════════════════════════════════════════════════════════════════
const STEP_NAMES: Record<number, string> = {
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<string[]>([])
const [name, setName] = useState('')
const [level, setLevel] = useState<FitnessLevel>('beginner')
const [goal, setGoal] = useState<FitnessGoal>('cardio')
const [frequency, setFrequency] = useState<WeeklyFrequency>(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 <ProblemScreen onNext={nextStep} />
case 2:
return (
<EmpathyScreen
onNext={nextStep}
barriers={barriers}
setBarriers={setBarriers}
/>
)
case 3:
return <SolutionScreen onNext={nextStep} />
case 4:
return <WowScreen onNext={nextStep} />
case 5:
return (
<PersonalizationScreen
onNext={nextStep}
name={name}
setName={setName}
level={level}
setLevel={setLevel}
goal={goal}
setGoal={setGoal}
frequency={frequency}
setFrequency={setFrequency}
/>
)
case 6:
return (
<PaywallScreen
onSubscribe={(plan) => finishOnboarding(plan)}
onSkip={() => finishOnboarding('free')}
/>
)
default:
return null
}
}
return (
<OnboardingStep step={step} totalSteps={TOTAL_STEPS}>
{renderStep()}
</OnboardingStep>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// 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,
},
})
}