6-screen conversion funnel: Problem → Empathy → Solution → Wow → Personalization → Paywall. Screen 4 uses a staggered-reveal feature list where all 4 features animate in sequentially (150ms apart), replacing a carousel pattern. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1128 lines
35 KiB
TypeScript
1128 lines
35 KiB
TypeScript
/**
|
|
* TabataFit Onboarding — 6-Screen Conversion Funnel
|
|
* Problem → Empathy → Solution → Wow Moment → Personalization → Paywall
|
|
*/
|
|
|
|
import { useState, useRef, useEffect, useCallback } 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 { useTranslation } from 'react-i18next'
|
|
import { useHaptics } 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 { BRAND, DARK, TEXT, PHASE, GLASS, BORDER } from '@/src/shared/constants/colors'
|
|
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 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 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={TEXT.PRIMARY}
|
|
style={styles.titleCenter}
|
|
>
|
|
{t('onboarding.problem.title')}
|
|
</StyledText>
|
|
<StyledText
|
|
size={17}
|
|
color={TEXT.SECONDARY}
|
|
style={[styles.subtitle, { marginTop: SPACING[3] }]}
|
|
>
|
|
{t('onboarding.problem.subtitle1')}
|
|
</StyledText>
|
|
<StyledText
|
|
size={17}
|
|
color={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={TEXT.PRIMARY}>
|
|
{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 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={TEXT.PRIMARY} style={styles.titleCenter}>
|
|
{t('onboarding.empathy.title')}
|
|
</StyledText>
|
|
<StyledText size={15} color={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 : TEXT.TERTIARY}
|
|
/>
|
|
<StyledText
|
|
size={15}
|
|
weight={selected ? 'semibold' : 'regular'}
|
|
color={selected ? TEXT.PRIMARY : 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 ? TEXT.PRIMARY : 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 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={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={TEXT.PRIMARY}>
|
|
{t('onboarding.solution.tabata')}
|
|
</StyledText>
|
|
<StyledText size={13} color={TEXT.TERTIARY}>
|
|
{t('onboarding.solution.tabataDuration')}
|
|
</StyledText>
|
|
</View>
|
|
|
|
{/* VS */}
|
|
<View style={styles.vsContainer}>
|
|
<StyledText size={15} weight="bold" color={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={TEXT.PRIMARY}>
|
|
{t('onboarding.solution.cardio')}
|
|
</StyledText>
|
|
<StyledText size={13} color={TEXT.TERTIARY}>
|
|
{t('onboarding.solution.cardioDuration')}
|
|
</StyledText>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Citation */}
|
|
<Animated.View style={[styles.citation, { opacity: citationOpacity }]}>
|
|
<StyledText size={13} color={TEXT.TERTIARY} style={styles.citationText}>
|
|
{t('onboarding.solution.citation')}
|
|
</StyledText>
|
|
<StyledText size={11} color={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={TEXT.PRIMARY}>
|
|
{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 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={TEXT.PRIMARY} style={styles.titleCenter}>
|
|
{t('onboarding.wow.title')}
|
|
</StyledText>
|
|
<StyledText size={15} color={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={TEXT.PRIMARY}>
|
|
{t(feature.titleKey)}
|
|
</StyledText>
|
|
<StyledText size={14} color={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={TEXT.PRIMARY}>
|
|
{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()
|
|
|
|
return (
|
|
<ScrollView
|
|
style={{ flex: 1 }}
|
|
contentContainerStyle={styles.personalizationContent}
|
|
showsVerticalScrollIndicator={false}
|
|
keyboardShouldPersistTaps="handled"
|
|
>
|
|
<StyledText size={28} weight="bold" color={TEXT.PRIMARY} style={styles.titleCenter}>
|
|
{t('onboarding.personalization.title')}
|
|
</StyledText>
|
|
|
|
{/* Name input */}
|
|
<View style={styles.fieldGroup}>
|
|
<StyledText size={13} weight="semibold" color={TEXT.TERTIARY} style={styles.fieldLabel}>
|
|
{t('onboarding.personalization.yourName')}
|
|
</StyledText>
|
|
<TextInput
|
|
style={styles.textInput}
|
|
value={name}
|
|
onChangeText={setName}
|
|
placeholder={t('onboarding.personalization.namePlaceholder')}
|
|
placeholderTextColor={TEXT.HINT}
|
|
autoCapitalize="words"
|
|
autoCorrect={false}
|
|
/>
|
|
</View>
|
|
|
|
{/* Fitness Level */}
|
|
<View style={styles.fieldGroup}>
|
|
<StyledText size={13} weight="semibold" color={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 ? TEXT.PRIMARY : TEXT.TERTIARY}
|
|
>
|
|
{t(item.labelKey)}
|
|
</StyledText>
|
|
</Pressable>
|
|
))}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Goal */}
|
|
<View style={styles.fieldGroup}>
|
|
<StyledText size={13} weight="semibold" color={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 ? TEXT.PRIMARY : TEXT.TERTIARY}
|
|
>
|
|
{t(item.labelKey)}
|
|
</StyledText>
|
|
</Pressable>
|
|
))}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Frequency */}
|
|
<View style={styles.fieldGroup}>
|
|
<StyledText size={13} weight="semibold" color={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 ? TEXT.PRIMARY : 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() ? TEXT.PRIMARY : 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 [selectedPlan, setSelectedPlan] = useState<'premium-monthly' | 'premium-yearly'>('premium-yearly')
|
|
const featureAnims = useRef(PREMIUM_FEATURE_KEYS.map(() => new Animated.Value(0))).current
|
|
|
|
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)
|
|
})
|
|
}, [])
|
|
|
|
return (
|
|
<ScrollView
|
|
style={{ flex: 1 }}
|
|
contentContainerStyle={styles.paywallContent}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
<StyledText size={28} weight="bold" color={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={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={() => {
|
|
haptics.selection()
|
|
setSelectedPlan('premium-yearly')
|
|
}}
|
|
>
|
|
<View style={styles.bestValueBadge}>
|
|
<StyledText size={11} weight="bold" color={TEXT.PRIMARY}>
|
|
{t('onboarding.paywall.bestValue')}
|
|
</StyledText>
|
|
</View>
|
|
<StyledText size={22} weight="bold" color={TEXT.PRIMARY}>
|
|
{t('onboarding.paywall.yearlyPrice')}
|
|
</StyledText>
|
|
<StyledText size={13} color={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={() => {
|
|
haptics.selection()
|
|
setSelectedPlan('premium-monthly')
|
|
}}
|
|
>
|
|
<StyledText size={22} weight="bold" color={TEXT.PRIMARY}>
|
|
{t('onboarding.paywall.monthlyPrice')}
|
|
</StyledText>
|
|
<StyledText size={13} color={TEXT.SECONDARY}>
|
|
{t('common:units.perMonth')}
|
|
</StyledText>
|
|
</Pressable>
|
|
</View>
|
|
|
|
{/* CTA */}
|
|
<Pressable
|
|
style={styles.trialButton}
|
|
onPress={() => {
|
|
haptics.buttonTap()
|
|
onSubscribe(selectedPlan)
|
|
}}
|
|
>
|
|
<StyledText size={17} weight="bold" color={TEXT.PRIMARY}>
|
|
{t('onboarding.paywall.trialCta')}
|
|
</StyledText>
|
|
</Pressable>
|
|
|
|
{/* Guarantees */}
|
|
<View style={styles.guarantees}>
|
|
<StyledText size={13} color={TEXT.TERTIARY}>
|
|
{t('onboarding.paywall.guarantees')}
|
|
</StyledText>
|
|
</View>
|
|
|
|
{/* Skip */}
|
|
<Pressable style={styles.skipButton} onPress={onSkip}>
|
|
<StyledText size={14} color={TEXT.HINT}>
|
|
{t('onboarding.paywall.skipButton')}
|
|
</StyledText>
|
|
</Pressable>
|
|
</ScrollView>
|
|
)
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// MAIN ONBOARDING CONTROLLER
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
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)
|
|
|
|
const finishOnboarding = useCallback(
|
|
(plan: 'free' | 'premium-monthly' | 'premium-yearly') => {
|
|
completeOnboarding({
|
|
name: name.trim() || 'Athlete',
|
|
fitnessLevel: level,
|
|
goal,
|
|
weeklyFrequency: frequency,
|
|
barriers,
|
|
})
|
|
if (plan !== 'free') {
|
|
setSubscription(plan)
|
|
}
|
|
router.replace('/(tabs)')
|
|
},
|
|
[name, level, goal, frequency, barriers]
|
|
)
|
|
|
|
const nextStep = useCallback(() => {
|
|
setStep((s) => Math.min(s + 1, TOTAL_STEPS))
|
|
}, [])
|
|
|
|
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
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
const styles = 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: DARK.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,
|
|
...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: DARK.OVERLAY_1,
|
|
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: DARK.SURFACE,
|
|
borderRadius: RADIUS.MD,
|
|
paddingHorizontal: SPACING[4],
|
|
color: TEXT.PRIMARY,
|
|
fontSize: 17,
|
|
borderWidth: 1,
|
|
borderColor: BORDER.GLASS,
|
|
},
|
|
segmentRow: {
|
|
flexDirection: 'row',
|
|
backgroundColor: DARK.SURFACE,
|
|
borderRadius: RADIUS.MD,
|
|
padding: 3,
|
|
gap: 2,
|
|
},
|
|
segmentButton: {
|
|
flex: 1,
|
|
height: 36,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
borderRadius: RADIUS.SM,
|
|
},
|
|
segmentButtonActive: {
|
|
backgroundColor: DARK.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,
|
|
...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],
|
|
},
|
|
skipButton: {
|
|
alignItems: 'center',
|
|
paddingVertical: SPACING[5],
|
|
marginTop: SPACING[2],
|
|
},
|
|
})
|
|
|
|
// ── Screen 4: Feature List Styles ──
|
|
const wowStyles = 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,
|
|
},
|
|
})
|