diff --git a/app/onboarding.tsx b/app/onboarding.tsx new file mode 100644 index 0000000..e24d092 --- /dev/null +++ b/app/onboarding.tsx @@ -0,0 +1,1127 @@ +/** + * 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 ( + + + + + + + + {t('onboarding.problem.title')} + + + {t('onboarding.problem.subtitle1')} + + + {t('onboarding.problem.subtitle2')} + + + + + { + haptics.buttonTap() + onNext() + }} + > + + {t('onboarding.problem.cta')} + + + + + ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// SCREEN 2 — EMPATHY +// ═══════════════════════════════════════════════════════════════════════════ + +const BARRIERS = [ + { id: 'no-time', labelKey: 'onboarding.empathy.noTime' as const, icon: 'time-outline' as const }, + { id: 'low-motivation', labelKey: 'onboarding.empathy.lowMotivation' as const, icon: 'battery-dead-outline' as const }, + { id: 'no-knowledge', labelKey: 'onboarding.empathy.noKnowledge' as const, icon: 'help-circle-outline' as const }, + { id: 'no-gym', labelKey: 'onboarding.empathy.noGym' as const, icon: 'home-outline' as const }, +] + +function EmpathyScreen({ + onNext, + barriers, + setBarriers, +}: { + onNext: () => void + barriers: string[] + setBarriers: (b: string[]) => void +}) { + const { t } = useTranslation('screens') + const haptics = useHaptics() + + const toggleBarrier = (id: string) => { + haptics.selection() + if (barriers.includes(id)) { + setBarriers(barriers.filter((b) => b !== id)) + } else if (barriers.length < 2) { + setBarriers([...barriers, id]) + } + } + + return ( + + + {t('onboarding.empathy.title')} + + + {t('onboarding.empathy.chooseUpTo')} + + + + {BARRIERS.map((item) => { + const selected = barriers.includes(item.id) + return ( + toggleBarrier(item.id)} + > + + + {t(item.labelKey)} + + + ) + })} + + + + { + if (barriers.length > 0) { + haptics.buttonTap() + onNext() + } + }} + > + 0 ? TEXT.PRIMARY : TEXT.DISABLED} + > + {t('common:continue')} + + + + + ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 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 ( + + + {t('onboarding.solution.title')} + + + {/* Comparison bars */} + + {/* Tabata bar */} + + + {t('onboarding.solution.tabataCalories')} + + + + + + {t('onboarding.solution.tabata')} + + + {t('onboarding.solution.tabataDuration')} + + + + {/* VS */} + + + {t('onboarding.solution.vs')} + + + + {/* Cardio bar */} + + + {t('onboarding.solution.cardioCalories')} + + + + + + {t('onboarding.solution.cardio')} + + + {t('onboarding.solution.cardioDuration')} + + + + + {/* Citation */} + + + {t('onboarding.solution.citation')} + + + {t('onboarding.solution.citationAuthor')} + + + + + { + haptics.buttonTap() + onNext() + }} + > + + {t('onboarding.solution.cta')} + + + + + ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// SCREEN 4 — WOW MOMENT (Staggered Feature Reveal) +// ═══════════════════════════════════════════════════════════════════════════ + +const WOW_FEATURES = [ + { icon: 'timer-outline' as const, iconColor: BRAND.PRIMARY, titleKey: 'onboarding.wow.card1Title', subtitleKey: 'onboarding.wow.card1Subtitle' }, + { icon: 'barbell-outline' as const, iconColor: PHASE.REST, titleKey: 'onboarding.wow.card2Title', subtitleKey: 'onboarding.wow.card2Subtitle' }, + { icon: 'mic-outline' as const, iconColor: PHASE.PREP, titleKey: 'onboarding.wow.card3Title', subtitleKey: 'onboarding.wow.card3Subtitle' }, + { icon: 'trending-up-outline' as const, iconColor: PHASE.COMPLETE, titleKey: 'onboarding.wow.card4Title', subtitleKey: 'onboarding.wow.card4Subtitle' }, +] as const + +function WowScreen({ onNext }: { onNext: () => void }) { + const { t } = useTranslation('screens') + const haptics = useHaptics() + const rowAnims = useRef(WOW_FEATURES.map(() => ({ + opacity: new Animated.Value(0), + translateY: new Animated.Value(20), + }))).current + const ctaOpacity = useRef(new Animated.Value(0)).current + const [ctaReady, setCtaReady] = useState(false) + + useEffect(() => { + // Staggered reveal: each row fades in + slides up, 150ms apart, starting at 300ms + const STAGGER_DELAY = 150 + const ROW_DURATION = DURATION.NORMAL // 300ms + const START_DELAY = 300 + + WOW_FEATURES.forEach((_, i) => { + setTimeout(() => { + Animated.parallel([ + Animated.timing(rowAnims[i].opacity, { + toValue: 1, + duration: ROW_DURATION, + easing: EASE.EASE_OUT, + useNativeDriver: true, + }), + Animated.timing(rowAnims[i].translateY, { + toValue: 0, + duration: ROW_DURATION, + easing: EASE.EASE_OUT, + useNativeDriver: true, + }), + ]).start() + }, START_DELAY + i * STAGGER_DELAY) + }) + + // CTA fades in 200ms after last row finishes + const ctaDelay = START_DELAY + (WOW_FEATURES.length - 1) * STAGGER_DELAY + ROW_DURATION + 200 + setTimeout(() => { + setCtaReady(true) + Animated.timing(ctaOpacity, { + toValue: 1, + duration: ROW_DURATION, + easing: EASE.EASE_OUT, + useNativeDriver: true, + }).start() + }, ctaDelay) + }, []) + + return ( + + + {t('onboarding.wow.title')} + + + {t('onboarding.wow.subtitle')} + + + {/* Feature list */} + + {WOW_FEATURES.map((feature, i) => ( + + + + + + + {t(feature.titleKey)} + + + {t(feature.subtitleKey)} + + + + ))} + + + {/* CTA fades in after all rows */} + + { + if (ctaReady) { + haptics.buttonTap() + onNext() + } + }} + > + + {t('common:next')} + + + + + ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// SCREEN 5 — PERSONALIZATION +// ═══════════════════════════════════════════════════════════════════════════ + +const LEVELS: { value: FitnessLevel; labelKey: string }[] = [ + { value: 'beginner', labelKey: 'common:levels.beginner' }, + { value: 'intermediate', labelKey: 'common:levels.intermediate' }, + { value: 'advanced', labelKey: 'common:levels.advanced' }, +] + +const GOALS: { value: FitnessGoal; labelKey: string }[] = [ + { value: 'weight-loss', labelKey: 'onboarding.personalization.goals.weightLoss' }, + { value: 'cardio', labelKey: 'onboarding.personalization.goals.cardio' }, + { value: 'strength', labelKey: 'onboarding.personalization.goals.strength' }, + { value: 'wellness', labelKey: 'onboarding.personalization.goals.wellness' }, +] + +const FREQUENCIES: { value: WeeklyFrequency; labelKey: string }[] = [ + { value: 2, labelKey: 'onboarding.personalization.frequencies.2x' }, + { value: 3, labelKey: 'onboarding.personalization.frequencies.3x' }, + { value: 5, labelKey: 'onboarding.personalization.frequencies.5x' }, +] + +function PersonalizationScreen({ + onNext, + name, + setName, + level, + setLevel, + goal, + setGoal, + frequency, + setFrequency, +}: { + onNext: () => void + name: string + setName: (n: string) => void + level: FitnessLevel + setLevel: (l: FitnessLevel) => void + goal: FitnessGoal + setGoal: (g: FitnessGoal) => void + frequency: WeeklyFrequency + setFrequency: (f: WeeklyFrequency) => void +}) { + const { t } = useTranslation('screens') + const haptics = useHaptics() + + return ( + + + {t('onboarding.personalization.title')} + + + {/* Name input */} + + + {t('onboarding.personalization.yourName')} + + + + + {/* Fitness Level */} + + + {t('onboarding.personalization.fitnessLevel')} + + + {LEVELS.map((item) => ( + { + haptics.selection() + setLevel(item.value) + }} + > + + {t(item.labelKey)} + + + ))} + + + + {/* Goal */} + + + {t('onboarding.personalization.yourGoal')} + + + {GOALS.map((item) => ( + { + haptics.selection() + setGoal(item.value) + }} + > + + {t(item.labelKey)} + + + ))} + + + + {/* Frequency */} + + + {t('onboarding.personalization.weeklyFrequency')} + + + {FREQUENCIES.map((item) => ( + { + haptics.selection() + setFrequency(item.value) + }} + > + + {t(item.labelKey)} + + + ))} + + + + {name.trim().length > 0 && ( + + {t('onboarding.personalization.readyMessage')} + + )} + + + { + if (name.trim()) { + haptics.buttonTap() + onNext() + } + }} + > + + {t('common:continue')} + + + + + ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// SCREEN 6 — PAYWALL +// ═══════════════════════════════════════════════════════════════════════════ + +const PREMIUM_FEATURE_KEYS = [ + 'onboarding.paywall.features.unlimited', + 'onboarding.paywall.features.offline', + 'onboarding.paywall.features.stats', + 'onboarding.paywall.features.noAds', +] as const + +function PaywallScreen({ + onSubscribe, + onSkip, +}: { + onSubscribe: (plan: 'premium-monthly' | 'premium-yearly') => void + onSkip: () => void +}) { + const { t } = useTranslation('screens') + const haptics = useHaptics() + const [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 ( + + + {t('onboarding.paywall.title')} + + + {/* Features */} + + {PREMIUM_FEATURE_KEYS.map((featureKey, i) => ( + + + + {t(featureKey)} + + + ))} + + + {/* Pricing cards */} + + {/* Annual */} + { + haptics.selection() + setSelectedPlan('premium-yearly') + }} + > + + + {t('onboarding.paywall.bestValue')} + + + + {t('onboarding.paywall.yearlyPrice')} + + + {t('common:units.perYear')} + + + {t('onboarding.paywall.savePercent')} + + + + {/* Monthly */} + { + haptics.selection() + setSelectedPlan('premium-monthly') + }} + > + + {t('onboarding.paywall.monthlyPrice')} + + + {t('common:units.perMonth')} + + + + + {/* CTA */} + { + haptics.buttonTap() + onSubscribe(selectedPlan) + }} + > + + {t('onboarding.paywall.trialCta')} + + + + {/* Guarantees */} + + + {t('onboarding.paywall.guarantees')} + + + + {/* Skip */} + + + {t('onboarding.paywall.skipButton')} + + + + ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// MAIN ONBOARDING CONTROLLER +// ═══════════════════════════════════════════════════════════════════════════ + +export default function OnboardingScreen() { + const router = useRouter() + const [step, setStep] = useState(1) + + // Personalization state + const [barriers, setBarriers] = useState([]) + const [name, setName] = useState('') + const [level, setLevel] = useState('beginner') + const [goal, setGoal] = useState('cardio') + const [frequency, setFrequency] = useState(3) + + const completeOnboarding = useUserStore((s) => s.completeOnboarding) + const setSubscription = useUserStore((s) => s.setSubscription) + + 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 + case 2: + return ( + + ) + case 3: + return + case 4: + return + case 5: + return ( + + ) + case 6: + return ( + finishOnboarding(plan)} + onSkip={() => finishOnboarding('free')} + /> + ) + default: + return null + } + } + + return ( + + {renderStep()} + + ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// STYLES +// ═══════════════════════════════════════════════════════════════════════════ + +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, + }, +}) diff --git a/src/shared/components/OnboardingStep.tsx b/src/shared/components/OnboardingStep.tsx new file mode 100644 index 0000000..0b87172 --- /dev/null +++ b/src/shared/components/OnboardingStep.tsx @@ -0,0 +1,108 @@ +/** + * TabataFit OnboardingStep + * Reusable wrapper for each onboarding screen — progress bar, animation, layout + */ + +import { useRef, useEffect } from 'react' +import { View, StyleSheet, Animated, Dimensions } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { DARK, BRAND, TEXT } from '../constants/colors' +import { SPACING, LAYOUT } from '../constants/spacing' +import { DURATION, EASE } from '../constants/animations' + +const { width: SCREEN_WIDTH } = Dimensions.get('window') + +interface OnboardingStepProps { + step: number + totalSteps: number + children: React.ReactNode +} + +export function OnboardingStep({ step, totalSteps, children }: OnboardingStepProps) { + const insets = useSafeAreaInsets() + const slideAnim = useRef(new Animated.Value(SCREEN_WIDTH)).current + const fadeAnim = useRef(new Animated.Value(0)).current + const progressAnim = useRef(new Animated.Value(0)).current + + useEffect(() => { + // Reset position for new step + slideAnim.setValue(SCREEN_WIDTH) + fadeAnim.setValue(0) + + // Animate in + Animated.parallel([ + Animated.timing(slideAnim, { + toValue: 0, + duration: DURATION.NORMAL, + easing: EASE.EASE_OUT, + useNativeDriver: true, + }), + Animated.timing(fadeAnim, { + toValue: 1, + duration: DURATION.NORMAL, + easing: EASE.EASE_OUT, + useNativeDriver: true, + }), + ]).start() + + // Animate progress bar + Animated.timing(progressAnim, { + toValue: step / totalSteps, + duration: DURATION.SLOW, + easing: EASE.EASE_OUT, + useNativeDriver: false, + }).start() + }, [step]) + + const progressWidth = progressAnim.interpolate({ + inputRange: [0, 1], + outputRange: ['0%', '100%'], + }) + + return ( + + {/* Progress bar */} + + + + + {/* Step content */} + + {children} + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DARK.BASE, + }, + progressTrack: { + height: 3, + backgroundColor: DARK.SURFACE, + marginHorizontal: LAYOUT.SCREEN_PADDING, + borderRadius: 2, + overflow: 'hidden', + }, + progressFill: { + height: '100%', + backgroundColor: BRAND.PRIMARY, + borderRadius: 2, + }, + content: { + flex: 1, + paddingHorizontal: LAYOUT.SCREEN_PADDING, + paddingTop: SPACING[8], + }, +})