mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-07 20:31:06 -05:00
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:
parent
f1304fa205
commit
f7131b720b
12 changed files with 349 additions and 0 deletions
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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": "",
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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)
|
|
@ -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',
|
||||
])
|
||||
)
|
|
@ -0,0 +1 @@
|
|||
export type OnboardingVideoStep = 'first' | 'second'
|
|
@ -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 }
|
||||
}
|
|
@ -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'
|
||||
|
|
|
@ -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": "We’d 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",
|
||||
|
|
Loading…
Reference in a new issue