Implement onboarding video tour split test (#11889)

* Implement onboarding video tour split test:

- Add split test infrastructure
- Create new `OnboardingVideoTourModal` component
- Place the component inside the editor pug template with a split test

* add event segmentation for `onboarding-video-tour-close-button-click` event:

1. video: `first` | `second`
2. firstVideoWatchingTimeInSecond: total time watching first video
2. secondVideoWatchingTimeInSecond: total time watching second video (0 if skipped)

* add event segmentation for:

1.  `onboarding-video-tour-dismiss-button-click`
2. `onboarding-video-tour-next-button-click`
3. `onboarding-video-tour-done-button-click`

with these key/value:

1. firstVideoWatchingTimeInSecond: total time watching first video
2. secondVideoWatchingTimeInSecond: total time watching second video (0 if skipped/not watched yet)

* Use contentful to host video assets

GitOrigin-RevId: 27a6f38d15d7a03b07455e216dda63d99983ca80
This commit is contained in:
M Fahru 2023-03-01 07:10:33 -07:00 committed by Copybot
parent f1304fa205
commit f7131b720b
12 changed files with 349 additions and 0 deletions

View file

@ -1061,6 +1061,21 @@ const ProjectController = {
}
)
},
onboardingVideoTourAssignment(cb) {
SplitTestHandler.getAssignment(
req,
res,
'onboarding-video-tour',
(error, assignment) => {
// do not fail editor load if assignment fails
if (error) {
cb(null, { variant: 'default' })
} else {
cb(null, assignment)
}
}
)
},
accessCheckForOldCompileDomainAssigment(cb) {
SplitTestHandler.getAssignment(
req,
@ -1133,6 +1148,7 @@ const ProjectController = {
pdfjsAssignment,
editorLeftMenuAssignment,
richTextAssignment,
onboardingVideoTourAssignment,
}
) => {
if (err != null) {
@ -1240,6 +1256,12 @@ const ProjectController = {
!userIsMemberOfGroupSubscription &&
!userHasInstitutionLicence
const showOnboardingVideoTour =
Features.hasFeature('saas') &&
userId &&
onboardingVideoTourAssignment.variant === 'active' &&
req.session.justRegistered
const template =
detachRole === 'detached'
? 'project/editor_detached'
@ -1318,6 +1340,7 @@ const ProjectController = {
useOpenTelemetry: Settings.useOpenTelemetryClient,
showCM6SwitchAwaySurvey: Settings.showCM6SwitchAwaySurvey,
richTextVariant: richTextAssignment.variant,
showOnboardingVideoTour,
})
timer.done()
}

View file

@ -45,3 +45,13 @@ else
.ui-layout-east
aside.chat
chat()
if showOnboardingVideoTour
div(
ng-if="!state.loading"
ng-controller="OnboardingVideoTourModalController"
)
onboarding-video-tour-modal(
close-modal="closeModal"
show="show"
)

View file

@ -38,6 +38,7 @@ meta(name="ol-useOpenTelemetry" data-type="boolean" content=useOpenTelemetry)
meta(name="ol-showSupport", data-type="boolean" content=showSupport)
meta(name="ol-showCM6SwitchAwaySurvey", data-type="boolean" content=showCM6SwitchAwaySurvey)
meta(name="ol-richTextVariant" content=richTextVariant)
meta(name="ol-showOnboardingVideoTour", data-type="boolean" content=showOnboardingVideoTour)
if (richTextVariant === 'cm6')
meta(name="ol-mathJax3Path" content=mathJax3Path)

View file

@ -218,6 +218,8 @@
"edit_dictionary_empty": "",
"edit_dictionary_remove": "",
"edit_folder": "",
"edit_in_source_to_see_your_entire_latex_code": "",
"edit_in_the_left_pane_click_recompile": "",
"editing": "",
"editor_and_pdf": "&",
"editor_only_hide_pdf": "",
@ -505,6 +507,7 @@
"new_subscription_will_be_billed_immediately": "",
"new_to_latex_look_at": "",
"newsletter": "",
"next": "",
"next_payment_of_x_collectected_on_y": "",
"no_existing_password": "",
"no_members": "",
@ -913,6 +916,7 @@
"we_logged_you_in": "",
"wed_love_you_to_stay": "",
"welcome_to_sl": "",
"welcome_to_your_first_project": "",
"wide": "",
"with_premium_subscription_you_also_get": "",
"word_count": "",

View file

@ -0,0 +1,65 @@
/* eslint-disable jsx-a11y/media-has-caption */
import { useCallback, useRef } from 'react'
import { Modal } from 'react-bootstrap'
import { Trans } from 'react-i18next'
import type { OnboardingVideoStep } from '../utils/onboarding-video-step'
type OnboardingVideoTourModalBodyProps = {
step: OnboardingVideoStep
}
export default function OnboardingVideoTourModalBody({
step,
}: OnboardingVideoTourModalBodyProps) {
const firstVideoRef = useRef<HTMLVideoElement>(null)
const secondVideoRef = useRef<HTMLVideoElement>(null)
const handleCanPlayFirstVideo = useCallback(() => {
if (firstVideoRef.current) {
firstVideoRef.current.playbackRate = 1.5
}
}, [firstVideoRef])
const handleCanPlaySecondVideo = useCallback(() => {
if (secondVideoRef.current) {
secondVideoRef.current.playbackRate = 3.0
}
}, [secondVideoRef])
return (
<Modal.Body>
<p>
{step === 'first' ? (
<Trans
i18nKey="edit_in_the_left_pane_click_recompile"
components={[<strong />]} // eslint-disable-line react/jsx-key
/>
) : (
<Trans
i18nKey="edit_in_source_to_see_your_entire_latex_code"
components={[<strong />]} // eslint-disable-line react/jsx-key
/>
)}
</p>
{step === 'first' ? (
<video
onCanPlay={handleCanPlayFirstVideo}
ref={firstVideoRef}
autoPlay
loop
width="100%"
src="https://videos.ctfassets.net/nrgyaltdicpt/7MgWt7UdNv4yJcG2OrUene/387ab289e0e408511996f1152fc856d9/onboarding-tour-step-1.mp4"
/>
) : (
<video
onCanPlay={handleCanPlaySecondVideo}
ref={secondVideoRef}
autoPlay
loop
width="100%"
src="https://videos.ctfassets.net/nrgyaltdicpt/2wYrdDqILSXaWP1LZScaDd/86a38effaeb400f42b480dba68a84b06/onboarding-tour-step-2.mp4"
/>
)}
</Modal.Body>
)
}

View file

@ -0,0 +1,110 @@
import {
type Dispatch,
type RefObject,
type SetStateAction,
useCallback,
} from 'react'
import { Button, Modal } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import type { Nullable } from '../../../../../types/utils'
import { sendMB } from '../../../infrastructure/event-tracking'
import customLocalStorage from '../../../infrastructure/local-storage'
import type { OnboardingVideoStep } from '../utils/onboarding-video-step'
import { calculateWatchingTimeInSecond } from '../utils/watching-time'
type OnboardingVideoTourModalFooterProps = {
step: OnboardingVideoStep
setStep: Dispatch<SetStateAction<OnboardingVideoStep>>
closeModal: () => void
startTimeWatchedFirstVideo: RefObject<number>
startTimeWatchedSecondVideo: RefObject<Nullable<number>>
}
export default function OnboardingVideoTourModalFooter({
step,
setStep,
closeModal,
startTimeWatchedFirstVideo,
startTimeWatchedSecondVideo,
}: OnboardingVideoTourModalFooterProps) {
const { t } = useTranslation()
const handleClickDismiss = useCallback(() => {
customLocalStorage.setItem(
'has_dismissed_onboarding_video_tour_modal',
true
)
const { firstVideoWatchingTimeInSecond, secondVideoWatchingTimeInSecond } =
calculateWatchingTimeInSecond(
startTimeWatchedFirstVideo.current ?? 0,
startTimeWatchedSecondVideo.current
)
sendMB('onboarding-video-tour-dismiss-button-click', {
firstVideoWatchingTimeInSecond,
secondVideoWatchingTimeInSecond,
})
closeModal()
}, [closeModal, startTimeWatchedFirstVideo, startTimeWatchedSecondVideo])
const handleClickNext = useCallback(() => {
const { firstVideoWatchingTimeInSecond, secondVideoWatchingTimeInSecond } =
calculateWatchingTimeInSecond(
startTimeWatchedFirstVideo.current ?? 0,
startTimeWatchedSecondVideo.current
)
sendMB('onboarding-video-tour-next-button-click', {
firstVideoWatchingTimeInSecond,
secondVideoWatchingTimeInSecond,
})
setStep('second')
}, [setStep, startTimeWatchedFirstVideo, startTimeWatchedSecondVideo])
const handleClickDone = useCallback(() => {
customLocalStorage.setItem(
'has_dismissed_onboarding_video_tour_modal',
true
)
const { firstVideoWatchingTimeInSecond, secondVideoWatchingTimeInSecond } =
calculateWatchingTimeInSecond(
startTimeWatchedFirstVideo.current ?? 0,
startTimeWatchedSecondVideo.current
)
sendMB('onboarding-video-tour-done-button-click', {
firstVideoWatchingTimeInSecond,
secondVideoWatchingTimeInSecond,
})
closeModal()
}, [closeModal, startTimeWatchedFirstVideo, startTimeWatchedSecondVideo])
return (
<Modal.Footer>
{step === 'first' ? (
<>
<Button
type="button"
bsStyle={null}
className="btn-secondary"
onClick={handleClickDismiss}
>
{t('dismiss')}
</Button>
<Button type="submit" bsStyle="primary" onClick={handleClickNext}>
{t('next')}
</Button>
</>
) : (
<Button type="submit" bsStyle="primary" onClick={handleClickDone}>
{t('done')}
</Button>
)}
</Modal.Footer>
)
}

View file

@ -0,0 +1,75 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { Modal } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import AccessibleModal from '../../../shared/components/accessible-modal'
import customLocalStorage from '../../../infrastructure/local-storage'
import { sendMB } from '../../../infrastructure/event-tracking'
import OnboardingVideoTourModalBody from './onboarding-video-tour-modal-body'
import type { OnboardingVideoStep } from '../utils/onboarding-video-step'
import OnboardingVideoTourModalFooter from './onboarding-video-tour-modal-footer'
import { calculateWatchingTimeInSecond } from '../utils/watching-time'
import type { Nullable } from '../../../../../types/utils'
type OnboardingVideoTourModalProps = {
show: boolean
closeModal: () => void
}
function OnboardingVideoTourModal({
show,
closeModal,
}: OnboardingVideoTourModalProps) {
const { t } = useTranslation()
const [step, setStep] = useState<OnboardingVideoStep>('first')
const startTimeWatchedFirstVideo = useRef(Date.now())
const startTimeWatchedSecondVideo = useRef<Nullable<number>>(null)
const handleClickCloseButton = useCallback(() => {
customLocalStorage.setItem(
'has_dismissed_onboarding_video_tour_modal',
true
)
const { firstVideoWatchingTimeInSecond, secondVideoWatchingTimeInSecond } =
calculateWatchingTimeInSecond(
startTimeWatchedFirstVideo.current,
startTimeWatchedSecondVideo.current
)
sendMB('onboarding-video-tour-close-button-click', {
video: step,
firstVideoWatchingTimeInSecond,
secondVideoWatchingTimeInSecond,
})
closeModal()
}, [closeModal, step])
useEffect(() => {
if (step === 'second') {
startTimeWatchedSecondVideo.current = Date.now()
}
}, [step])
return (
<AccessibleModal
onHide={handleClickCloseButton}
show={show}
backdrop="static"
>
<Modal.Header closeButton>
<Modal.Title>{t('welcome_to_your_first_project')}</Modal.Title>
</Modal.Header>
<OnboardingVideoTourModalBody step={step} />
<OnboardingVideoTourModalFooter
step={step}
setStep={setStep}
closeModal={closeModal}
startTimeWatchedFirstVideo={startTimeWatchedFirstVideo}
startTimeWatchedSecondVideo={startTimeWatchedSecondVideo}
/>
</AccessibleModal>
)
}
export default memo(OnboardingVideoTourModal)

View file

@ -0,0 +1,32 @@
import { react2angular } from 'react2angular'
import { rootContext } from '../../../../../frontend/js/shared/context/root-context'
import App from '../../../../../frontend/js/base'
import getMeta from '../../../utils/meta'
import OnboardingVideoTourModal from '../components/onboarding-video-tour-modal'
export default App.controller(
'OnboardingVideoTourModalController',
function ($scope, localStorage) {
const hasDismissedOnboardingVideoTourModal = localStorage(
'has_dismissed_onboarding_video_tour_modal'
)
const showOnboardingVideoTour = getMeta('ol-showOnboardingVideoTour')
$scope.show =
!hasDismissedOnboardingVideoTourModal && showOnboardingVideoTour
$scope.closeModal = () => {
$scope.$applyAsync(() => {
$scope.show = false
})
}
}
)
App.component(
'onboardingVideoTourModal',
react2angular(rootContext.use(OnboardingVideoTourModal), [
'show',
'closeModal',
])
)

View file

@ -0,0 +1 @@
export type OnboardingVideoStep = 'first' | 'second'

View file

@ -0,0 +1,23 @@
import type { Nullable } from '../../../../../types/utils'
export function calculateWatchingTimeInSecond(
startTimeWatchedFirstVideo: number,
startTimeWatchedSecondVideo: Nullable<number>
) {
let firstVideoWatchingTimeInSecond = 0
let secondVideoWatchingTimeInSecond = 0
if (startTimeWatchedSecondVideo === null) {
firstVideoWatchingTimeInSecond = Math.floor(
(Date.now() - startTimeWatchedFirstVideo) / 1000
)
} else {
firstVideoWatchingTimeInSecond = Math.floor(
(startTimeWatchedSecondVideo - startTimeWatchedFirstVideo) / 1000
)
secondVideoWatchingTimeInSecond = Math.floor(
(Date.now() - startTimeWatchedSecondVideo) / 1000
)
}
return { firstVideoWatchingTimeInSecond, secondVideoWatchingTimeInSecond }
}

View file

@ -67,6 +67,7 @@ import './features/source-editor/controllers/editor-switch-controller'
import './features/source-editor/controllers/cm6-switch-away-survey-controller'
import './features/source-editor/controllers/grammarly-warning-controller'
import './features/outline/controllers/documentation-button-controller'
import './features/onboarding/controllers/onboarding-video-tour-modal-controller'
import { cleanupServiceWorker } from './utils/service-worker-cleanup'
import { reportCM6Perf } from './infrastructure/cm6-performance'
import { reportAcePerf } from './ide/editor/ace-performance'

View file

@ -397,6 +397,8 @@
"edit_dictionary_empty": "Your custom dictionary is empty.",
"edit_dictionary_remove": "Remove from dictionary",
"edit_folder": "Edit Folder",
"edit_in_source_to_see_your_entire_latex_code": "Edit in <0>Source</0> to see your entire LaTeX code, or choose <0>Rich Text</0> for something more visual.",
"edit_in_the_left_pane_click_recompile": "Edit in the left pane. Click <0>Recompile</0> to see the PDF output on the right.",
"editing": "Editing",
"editor_and_pdf": "Editor <0></0> PDF",
"editor_disconected_click_to_reconnect": "Editor disconnected, click anywhere to reconnect.",
@ -956,6 +958,7 @@
"newsletter_info_summary": "Every few months we send a newsletter out summarizing the new features available.",
"newsletter_info_title": "Newsletter Preferences",
"newsletter_info_unsubscribed": "You are currently <0>unsubscribed</0> to the __appName__ newsletter.",
"next": "Next",
"next_payment_of_x_collectected_on_y": "The next payment of <0>__paymentAmmount__</0> will be collected on <1>__collectionDate__</1>.",
"nl": "Dutch",
"no": "Norwegian",
@ -1657,6 +1660,7 @@
"website_status": "Website status",
"wed_love_you_to_stay": "Wed love you to stay",
"welcome_to_sl": "Welcome to __appName__!",
"welcome_to_your_first_project": "Welcome to your first __appName__ project!",
"wide": "Wide",
"will_need_to_log_out_from_and_in_with": "You will need to <b>log out</b> from your <b>__email1__</b> account and then log in with <b>__email2__</b>.",
"with_premium_subscription_you_also_get": "With an Overleaf Premium subscription you also get",