Просмотр исходного кода

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
M Fahru 3 лет назад
Родитель
Сommit
f7131b720b

+ 23 - 0
services/web/app/src/Features/Project/ProjectController.js

@@ -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()
           }

+ 10 - 0
services/web/app/views/project/editor/main.pug

@@ -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"
+					)

+ 1 - 0
services/web/app/views/project/editor/meta.pug

@@ -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)
 

+ 4 - 0
services/web/frontend/extracted-translations.json

@@ -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": "",

+ 65 - 0
services/web/frontend/js/features/onboarding/components/onboarding-video-tour-modal-body.tsx

@@ -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>
+  )
+}

+ 110 - 0
services/web/frontend/js/features/onboarding/components/onboarding-video-tour-modal-footer.tsx

@@ -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>
+  )
+}

+ 75 - 0
services/web/frontend/js/features/onboarding/components/onboarding-video-tour-modal.tsx

@@ -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)

+ 32 - 0
services/web/frontend/js/features/onboarding/controllers/onboarding-video-tour-modal-controller.js

@@ -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',
+  ])
+)

+ 1 - 0
services/web/frontend/js/features/onboarding/utils/onboarding-video-step.ts

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

+ 23 - 0
services/web/frontend/js/features/onboarding/utils/watching-time.ts

@@ -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 }
+}

+ 1 - 0
services/web/frontend/js/ide.js

@@ -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'

+ 4 - 0
services/web/locales/en.json

@@ -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",