feat: onboarding flow with staggered-reveal wow screen
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>
This commit is contained in:
108
src/shared/components/OnboardingStep.tsx
Normal file
108
src/shared/components/OnboardingStep.tsx
Normal file
@@ -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 (
|
||||
<View style={[styles.container, { paddingTop: insets.top + SPACING[3] }]}>
|
||||
{/* Progress bar */}
|
||||
<View style={styles.progressTrack}>
|
||||
<Animated.View style={[styles.progressFill, { width: progressWidth }]} />
|
||||
</View>
|
||||
|
||||
{/* Step content */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.content,
|
||||
{
|
||||
transform: [{ translateX: slideAnim }],
|
||||
opacity: fadeAnim,
|
||||
},
|
||||
{ paddingBottom: insets.bottom + SPACING[6] },
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user