1289 lines
41 KiB
TypeScript
1289 lines
41 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 } 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}
|
|
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}
|
|
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]}
|
|
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}
|
|
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}
|
|
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}
|
|
/>
|
|
</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}
|
|
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}
|
|
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}
|
|
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]}
|
|
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
|
|
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
|
|
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]}
|
|
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}>
|
|
<StyledText size={14} color={colors.text.hint}>
|
|
{t('onboarding.paywall.restorePurchases')}
|
|
</StyledText>
|
|
</Pressable>
|
|
|
|
{/* Skip */}
|
|
<Pressable style={styles.skipButton} 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(() => {
|
|
track('onboarding_started')
|
|
track('onboarding_step_viewed', { step: 1, step_name: STEP_NAMES[1] })
|
|
}, [])
|
|
|
|
const finishOnboarding = useCallback(
|
|
(plan: 'free' | 'premium-monthly' | 'premium-yearly') => {
|
|
track('onboarding_completed', {
|
|
plan,
|
|
total_time_ms: Date.now() - onboardingStartTime.current,
|
|
steps_completed: step,
|
|
})
|
|
|
|
completeOnboarding({
|
|
name: name.trim() || 'Athlete',
|
|
fitnessLevel: level,
|
|
goal,
|
|
weeklyFrequency: frequency,
|
|
barriers,
|
|
})
|
|
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',
|
|
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,
|
|
},
|
|
})
|
|
}
|