Add value proposition screen experiment (#8898)
* add assets * add value prop experiment * add alt text * add metrics * add transitions * add skip button * tweak copy Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * add borderless variant for web * rm pointer events --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 103 KiB |
@@ -8,6 +8,7 @@ export type Gate =
|
||||
| 'old_postonboarding'
|
||||
| 'onboarding_add_video_feed'
|
||||
| 'onboarding_suggested_accounts'
|
||||
| 'onboarding_value_prop'
|
||||
| 'post_follow_profile_suggested_accounts'
|
||||
| 'post_threads_v2_unspecced'
|
||||
| 'remove_show_latest_button'
|
||||
|
||||
@@ -125,6 +125,9 @@ export type MetricEvents = {
|
||||
'onboarding:finished:avatarResult': {
|
||||
avatarResult: 'default' | 'created' | 'uploaded'
|
||||
}
|
||||
'onboarding:valueProp:stepOne:nextPressed': {}
|
||||
'onboarding:valueProp:stepTwo:nextPressed': {}
|
||||
'onboarding:valueProp:skipPressed': {}
|
||||
'home:feedDisplayed': {
|
||||
feedUrl: string
|
||||
feedType: string
|
||||
|
||||
@@ -20,12 +20,14 @@ import {
|
||||
import {leading} from '#/alf/typography'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow'
|
||||
import {HEADER_SLOT_SIZE} from '#/components/Layout'
|
||||
import {createPortalGroup} from '#/components/Portal'
|
||||
import {P, Text} from '#/components/Typography'
|
||||
|
||||
const ONBOARDING_COL_WIDTH = 420
|
||||
|
||||
export const OnboardingControls = createPortalGroup()
|
||||
export const OnboardingHeaderSlot = createPortalGroup()
|
||||
|
||||
export function Layout({children}: React.PropsWithChildren<{}>) {
|
||||
const {_} = useLingui()
|
||||
@@ -65,46 +67,67 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
|
||||
t.atoms.bg,
|
||||
]}>
|
||||
{__DEV__ && (
|
||||
<View style={[a.absolute, a.p_xl, a.z_10, {right: 0, top: insets.top}]}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="negative"
|
||||
size="small"
|
||||
onPress={() => onboardDispatch({type: 'skip'})}
|
||||
// DEV ONLY
|
||||
label="Clear onboarding state">
|
||||
<ButtonText>Clear</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="negative"
|
||||
size="tiny"
|
||||
onPress={() => onboardDispatch({type: 'skip'})}
|
||||
// DEV ONLY
|
||||
label="Clear onboarding state"
|
||||
style={[
|
||||
a.absolute,
|
||||
a.z_10,
|
||||
{
|
||||
left: '50%',
|
||||
top: insets.top + 2,
|
||||
transform: [{translateX: '-50%'}],
|
||||
},
|
||||
]}>
|
||||
<ButtonText>[DEV] Clear</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!gtMobile && state.hasPrev && (
|
||||
{!gtMobile && (
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
style={[
|
||||
web(a.fixed),
|
||||
native(a.absolute),
|
||||
a.left_0,
|
||||
a.right_0,
|
||||
a.flex_row,
|
||||
a.w_full,
|
||||
a.justify_center,
|
||||
a.z_20,
|
||||
a.px_xl,
|
||||
{
|
||||
top: paddingTop.paddingTop + insets.top - 1,
|
||||
},
|
||||
{top: paddingTop.paddingTop + insets.top - 1},
|
||||
]}>
|
||||
<View
|
||||
style={[a.w_full, a.align_start, {maxWidth: ONBOARDING_COL_WIDTH}]}>
|
||||
<Button
|
||||
key={state.activeStep} // remove focus state on nav
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
size="small"
|
||||
label={_(msg`Go back to previous step`)}
|
||||
style={[a.absolute]}
|
||||
onPress={() => dispatch({type: 'prev'})}>
|
||||
<ButtonIcon icon={ArrowLeft} size="lg" />
|
||||
</Button>
|
||||
pointerEvents="box-none"
|
||||
style={[
|
||||
a.w_full,
|
||||
a.align_start,
|
||||
a.flex_row,
|
||||
a.justify_between,
|
||||
{maxWidth: ONBOARDING_COL_WIDTH},
|
||||
]}>
|
||||
{state.hasPrev ? (
|
||||
<Button
|
||||
key={state.activeStep} // remove focus state on nav
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
size="small"
|
||||
label={_(msg`Go back to previous step`)}
|
||||
onPress={() => dispatch({type: 'prev'})}
|
||||
style={[a.bg_transparent]}>
|
||||
<ButtonIcon icon={ArrowLeft} size="lg" />
|
||||
</Button>
|
||||
) : (
|
||||
<View />
|
||||
)}
|
||||
|
||||
<OnboardingHeaderSlot.Outlet />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
@@ -125,7 +148,8 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
|
||||
a.flex_row,
|
||||
a.gap_sm,
|
||||
a.w_full,
|
||||
{paddingTop: 17, maxWidth: '60%'},
|
||||
a.align_center,
|
||||
{height: HEADER_SLOT_SIZE, maxWidth: '60%'},
|
||||
]}>
|
||||
{Array(state.totalSteps)
|
||||
.fill(0)
|
||||
@@ -149,10 +173,7 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[a.w_full, a.mb_5xl, {paddingTop: gtMobile ? 20 : 40}]}>
|
||||
{children}
|
||||
</View>
|
||||
<View style={[a.w_full, a.mb_5xl, a.pt_md]}>{children}</View>
|
||||
|
||||
<View style={{height: 100 + footerHeight}} />
|
||||
</View>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import React from 'react'
|
||||
import {useCallback, useContext, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import Animated, {
|
||||
Easing,
|
||||
LayoutAnimationConfig,
|
||||
SlideInRight,
|
||||
SlideOutLeft,
|
||||
} from 'react-native-reanimated'
|
||||
import {Image} from 'expo-image'
|
||||
import {
|
||||
type AppBskyActorDefs,
|
||||
type AppBskyActorProfile,
|
||||
@@ -22,6 +29,7 @@ import {
|
||||
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
|
||||
import {logEvent, useGate} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
|
||||
import {getAllListMembers} from '#/state/queries/list-members'
|
||||
import {preferencesQueryKey} from '#/state/queries/preferences'
|
||||
@@ -36,11 +44,19 @@ import {
|
||||
import {
|
||||
DescriptionText,
|
||||
OnboardingControls,
|
||||
OnboardingHeaderSlot,
|
||||
TitleText,
|
||||
} from '#/screens/Onboarding/Layout'
|
||||
import {Context} from '#/screens/Onboarding/state'
|
||||
import {Context, type OnboardingState} from '#/screens/Onboarding/state'
|
||||
import {bulkWriteFollows} from '#/screens/Onboarding/util'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {
|
||||
atoms as a,
|
||||
native,
|
||||
platform,
|
||||
tokens,
|
||||
useBreakpoints,
|
||||
useTheme,
|
||||
} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {IconCircle} from '#/components/IconCircle'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
@@ -53,10 +69,9 @@ import * as bsky from '#/types/bsky'
|
||||
|
||||
export function StepFinished() {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {state, dispatch} = React.useContext(Context)
|
||||
const {state, dispatch} = useContext(Context)
|
||||
const onboardDispatch = useOnboardingDispatch()
|
||||
const [saving, setSaving] = React.useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const agent = useAgent()
|
||||
const requestNotificationsPermission = useRequestNotificationsPermission()
|
||||
@@ -66,7 +81,7 @@ export function StepFinished() {
|
||||
const {startProgressGuide} = useProgressGuideControls()
|
||||
const gate = useGate()
|
||||
|
||||
const finishOnboarding = React.useCallback(async () => {
|
||||
const finishOnboarding = useCallback(async () => {
|
||||
setSaving(true)
|
||||
|
||||
let starterPack: AppBskyGraphDefs.StarterPackView | undefined
|
||||
@@ -245,6 +260,259 @@ export function StepFinished() {
|
||||
gate,
|
||||
])
|
||||
|
||||
return state.experiments?.onboarding_value_prop ? (
|
||||
<ValueProposition
|
||||
finishOnboarding={finishOnboarding}
|
||||
saving={saving}
|
||||
state={state}
|
||||
/>
|
||||
) : (
|
||||
<LegacyFinalStep
|
||||
finishOnboarding={finishOnboarding}
|
||||
saving={saving}
|
||||
state={state}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const PROP_1 = {
|
||||
light: platform({
|
||||
native: require('../../../assets/images/onboarding/value_prop_1_light.webp'),
|
||||
web: require('../../../assets/images/onboarding/value_prop_1_light_borderless.webp'),
|
||||
}),
|
||||
dim: platform({
|
||||
native: require('../../../assets/images/onboarding/value_prop_1_dim.webp'),
|
||||
web: require('../../../assets/images/onboarding/value_prop_1_dim_borderless.webp'),
|
||||
}),
|
||||
dark: platform({
|
||||
native: require('../../../assets/images/onboarding/value_prop_1_dark.webp'),
|
||||
web: require('../../../assets/images/onboarding/value_prop_1_dark_borderless.webp'),
|
||||
}),
|
||||
} as const
|
||||
|
||||
const PROP_2 = {
|
||||
light: require('../../../assets/images/onboarding/value_prop_2_light.webp'),
|
||||
dim: require('../../../assets/images/onboarding/value_prop_2_dim.webp'),
|
||||
dark: require('../../../assets/images/onboarding/value_prop_2_dark.webp'),
|
||||
} as const
|
||||
|
||||
const PROP_3 = {
|
||||
light: require('../../../assets/images/onboarding/value_prop_3_light.webp'),
|
||||
dim: require('../../../assets/images/onboarding/value_prop_3_dim.webp'),
|
||||
dark: require('../../../assets/images/onboarding/value_prop_3_dark.webp'),
|
||||
} as const
|
||||
|
||||
function ValueProposition({
|
||||
finishOnboarding,
|
||||
saving,
|
||||
state,
|
||||
}: {
|
||||
finishOnboarding: () => void
|
||||
saving: boolean
|
||||
state: OnboardingState
|
||||
}) {
|
||||
const [subStep, setSubStep] = useState<0 | 1 | 2>(0)
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
const image = [PROP_1[t.name], PROP_2[t.name], PROP_3[t.name]][subStep]
|
||||
|
||||
const onPress = () => {
|
||||
if (subStep === 2) {
|
||||
finishOnboarding() // has its own metrics
|
||||
} else if (subStep === 1) {
|
||||
setSubStep(2)
|
||||
logger.metric('onboarding:valueProp:stepTwo:nextPressed', {})
|
||||
} else if (subStep === 0) {
|
||||
setSubStep(1)
|
||||
logger.metric('onboarding:valueProp:stepOne:nextPressed', {})
|
||||
}
|
||||
}
|
||||
|
||||
const {title, description, alt} = [
|
||||
{
|
||||
title: _(msg`Free your feed`),
|
||||
description: _(
|
||||
msg`No more doomscrolling junk-filled algorithms. Find feeds that work for you, not against you.`,
|
||||
),
|
||||
alt: _(
|
||||
msg`A collection of popular feeds you can find on Bluesky, including News, Booksky, Game Dev, Blacksky, and Fountain Pens`,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: _(msg`Find your people`),
|
||||
description: _(
|
||||
msg`Ditch the trolls and clickbait. Find real people and conversations that matter to you.`,
|
||||
),
|
||||
alt: _(
|
||||
msg`Your profile picture surrounded by concentric circles of other users' profile pictures`,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: _(msg`Forget the noise`),
|
||||
description: _(
|
||||
msg`No ads, no invasive tracking, no engagement traps. Bluesky respects your time and attention.`,
|
||||
),
|
||||
alt: _(
|
||||
msg`An illustration of several Bluesky posts alongside repost, like, and comment icons`,
|
||||
),
|
||||
},
|
||||
][subStep]
|
||||
|
||||
return (
|
||||
<>
|
||||
{!gtMobile && (
|
||||
<OnboardingHeaderSlot.Portal>
|
||||
<Button
|
||||
disabled={saving}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="small"
|
||||
label={_(msg`Skip introduction and start using your account`)}
|
||||
onPress={() => {
|
||||
logger.metric('onboarding:valueProp:skipPressed', {})
|
||||
finishOnboarding()
|
||||
}}
|
||||
style={[a.bg_transparent]}>
|
||||
<ButtonText>
|
||||
<Trans>Skip</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</OnboardingHeaderSlot.Portal>
|
||||
)}
|
||||
|
||||
<LayoutAnimationConfig skipEntering skipExiting>
|
||||
<Animated.View
|
||||
key={subStep}
|
||||
entering={native(
|
||||
SlideInRight.easing(Easing.out(Easing.exp)).duration(500),
|
||||
)}
|
||||
exiting={native(
|
||||
SlideOutLeft.easing(Easing.out(Easing.exp)).duration(500),
|
||||
)}>
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
isNative && {marginHorizontal: tokens.space.xl * -1},
|
||||
a.pointer_events_none,
|
||||
]}>
|
||||
<Image
|
||||
source={image}
|
||||
style={[a.w_full, {aspectRatio: 1}]}
|
||||
alt={alt}
|
||||
accessibilityIgnoresInvertColors={false} // I guess we do need it to blend into the background
|
||||
/>
|
||||
{subStep === 1 && (
|
||||
<Image
|
||||
source={state.profileStepResults.imageUri}
|
||||
style={[
|
||||
a.z_10,
|
||||
a.absolute,
|
||||
a.rounded_full,
|
||||
{
|
||||
width: `${(80 / 393) * 100}%`,
|
||||
height: `${(80 / 393) * 100}%`,
|
||||
},
|
||||
]}
|
||||
accessibilityIgnoresInvertColors
|
||||
alt={_(msg`Your profile picture`)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[a.mt_4xl, a.gap_2xl, a.align_center]}>
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
<Dot active={subStep === 0} />
|
||||
<Dot active={subStep === 1} />
|
||||
<Dot active={subStep === 2} />
|
||||
</View>
|
||||
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text style={[a.font_heavy, a.text_3xl, a.text_center]}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
a.text_center,
|
||||
]}>
|
||||
{description}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</LayoutAnimationConfig>
|
||||
|
||||
<OnboardingControls.Portal>
|
||||
<View style={gtMobile && [a.gap_md, a.flex_row]}>
|
||||
{gtMobile && (
|
||||
<Button
|
||||
disabled={saving}
|
||||
color="secondary"
|
||||
size="large"
|
||||
label={_(msg`Skip introduction and start using your account`)}
|
||||
onPress={() => finishOnboarding()}>
|
||||
<ButtonText>
|
||||
<Trans>Skip</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={saving}
|
||||
key={state.activeStep} // remove focus state on nav
|
||||
color="primary"
|
||||
size="large"
|
||||
label={
|
||||
subStep === 2
|
||||
? _(msg`Complete onboarding and start using your account`)
|
||||
: _(msg`Next`)
|
||||
}
|
||||
onPress={onPress}>
|
||||
<ButtonText>
|
||||
{saving ? <Trans>Finalizing</Trans> : <Trans>Next</Trans>}
|
||||
</ButtonText>
|
||||
{saving && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
</View>
|
||||
</OnboardingControls.Portal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Dot({active}: {active: boolean}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
{width: 8, height: 8},
|
||||
active
|
||||
? {backgroundColor: t.palette.primary_500}
|
||||
: t.atoms.bg_contrast_50,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function LegacyFinalStep({
|
||||
finishOnboarding,
|
||||
saving,
|
||||
state,
|
||||
}: {
|
||||
finishOnboarding: () => void
|
||||
saving: boolean
|
||||
state: OnboardingState
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<View style={[a.align_start]}>
|
||||
<IconCircle icon={Check} style={[a.mb_2xl]} />
|
||||
@@ -305,7 +573,6 @@ export function StepFinished() {
|
||||
<Button
|
||||
disabled={saving}
|
||||
key={state.activeStep} // remove focus state on nav
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
label={_(msg`Complete onboarding and start using your account`)}
|
||||
|
||||
@@ -266,7 +266,7 @@ export function StepProfile() {
|
||||
</View>
|
||||
|
||||
<OnboardingControls.Portal>
|
||||
<View style={[a.gap_md, gtMobile && {flexDirection: 'row-reverse'}]}>
|
||||
<View style={[a.gap_md, gtMobile && a.flex_row_reverse]}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
|
||||
@@ -3,7 +3,11 @@ import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {Layout, OnboardingControls} from '#/screens/Onboarding/Layout'
|
||||
import {
|
||||
Layout,
|
||||
OnboardingControls,
|
||||
OnboardingHeaderSlot,
|
||||
} from '#/screens/Onboarding/Layout'
|
||||
import {Context, initialState, reducer} from '#/screens/Onboarding/state'
|
||||
import {StepFinished} from '#/screens/Onboarding/StepFinished'
|
||||
import {StepInterests} from '#/screens/Onboarding/StepInterests'
|
||||
@@ -14,12 +18,14 @@ import {StepSuggestedAccounts} from './StepSuggestedAccounts'
|
||||
export function Onboarding() {
|
||||
const {_} = useLingui()
|
||||
const gate = useGate()
|
||||
const showValueProp = gate('onboarding_value_prop')
|
||||
const showSuggestedAccounts = gate('onboarding_suggested_accounts')
|
||||
const [state, dispatch] = useReducer(reducer, {
|
||||
...initialState,
|
||||
totalSteps: showSuggestedAccounts ? 4 : 3,
|
||||
experiments: {
|
||||
onboarding_suggested_accounts: showSuggestedAccounts,
|
||||
onboarding_value_prop: showValueProp,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -53,20 +59,22 @@ export function Onboarding() {
|
||||
return (
|
||||
<Portal>
|
||||
<OnboardingControls.Provider>
|
||||
<Context.Provider
|
||||
value={useMemo(
|
||||
() => ({state, dispatch, interestsDisplayNames}),
|
||||
[state, dispatch, interestsDisplayNames],
|
||||
)}>
|
||||
<Layout>
|
||||
{state.activeStep === 'profile' && <StepProfile />}
|
||||
{state.activeStep === 'interests' && <StepInterests />}
|
||||
{state.activeStep === 'suggested-accounts' && (
|
||||
<StepSuggestedAccounts />
|
||||
)}
|
||||
{state.activeStep === 'finished' && <StepFinished />}
|
||||
</Layout>
|
||||
</Context.Provider>
|
||||
<OnboardingHeaderSlot.Provider>
|
||||
<Context.Provider
|
||||
value={useMemo(
|
||||
() => ({state, dispatch, interestsDisplayNames}),
|
||||
[state, dispatch, interestsDisplayNames],
|
||||
)}>
|
||||
<Layout>
|
||||
{state.activeStep === 'profile' && <StepProfile />}
|
||||
{state.activeStep === 'interests' && <StepInterests />}
|
||||
{state.activeStep === 'suggested-accounts' && (
|
||||
<StepSuggestedAccounts />
|
||||
)}
|
||||
{state.activeStep === 'finished' && <StepFinished />}
|
||||
</Layout>
|
||||
</Context.Provider>
|
||||
</OnboardingHeaderSlot.Provider>
|
||||
</OnboardingControls.Provider>
|
||||
</Portal>
|
||||
)
|
||||
|
||||
@@ -37,6 +37,7 @@ export type OnboardingState = {
|
||||
|
||||
experiments?: {
|
||||
onboarding_suggested_accounts?: boolean
|
||||
onboarding_value_prop?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
|
||||