diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 32316bee3e..5e8094a980 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/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() } diff --git a/services/web/app/views/project/editor/main.pug b/services/web/app/views/project/editor/main.pug index bab8999af1..65d5d8cb33 100644 --- a/services/web/app/views/project/editor/main.pug +++ b/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" + ) diff --git a/services/web/app/views/project/editor/meta.pug b/services/web/app/views/project/editor/meta.pug index 03f51778a5..981b7f205d 100644 --- a/services/web/app/views/project/editor/meta.pug +++ b/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) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 55fe1b2d47..7cd2537f9d 100644 --- a/services/web/frontend/extracted-translations.json +++ b/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": "", diff --git a/services/web/frontend/js/features/onboarding/components/onboarding-video-tour-modal-body.tsx b/services/web/frontend/js/features/onboarding/components/onboarding-video-tour-modal-body.tsx new file mode 100644 index 0000000000..a6e04e28d0 --- /dev/null +++ b/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(null) + const secondVideoRef = useRef(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 ( + +

+ {step === 'first' ? ( + ]} // eslint-disable-line react/jsx-key + /> + ) : ( + ]} // eslint-disable-line react/jsx-key + /> + )} +

+ {step === 'first' ? ( +
+ ) +} diff --git a/services/web/frontend/js/features/onboarding/components/onboarding-video-tour-modal-footer.tsx b/services/web/frontend/js/features/onboarding/components/onboarding-video-tour-modal-footer.tsx new file mode 100644 index 0000000000..13a7f006fe --- /dev/null +++ b/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> + closeModal: () => void + startTimeWatchedFirstVideo: RefObject + startTimeWatchedSecondVideo: RefObject> +} + +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 ( + + {step === 'first' ? ( + <> + + + + ) : ( + + )} + + ) +} diff --git a/services/web/frontend/js/features/onboarding/components/onboarding-video-tour-modal.tsx b/services/web/frontend/js/features/onboarding/components/onboarding-video-tour-modal.tsx new file mode 100644 index 0000000000..d398cfef70 --- /dev/null +++ b/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('first') + + const startTimeWatchedFirstVideo = useRef(Date.now()) + const startTimeWatchedSecondVideo = useRef>(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 ( + + + {t('welcome_to_your_first_project')} + + + + + ) +} + +export default memo(OnboardingVideoTourModal) diff --git a/services/web/frontend/js/features/onboarding/controllers/onboarding-video-tour-modal-controller.js b/services/web/frontend/js/features/onboarding/controllers/onboarding-video-tour-modal-controller.js new file mode 100644 index 0000000000..f642aa7e66 --- /dev/null +++ b/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', + ]) +) diff --git a/services/web/frontend/js/features/onboarding/utils/onboarding-video-step.ts b/services/web/frontend/js/features/onboarding/utils/onboarding-video-step.ts new file mode 100644 index 0000000000..3546d6957e --- /dev/null +++ b/services/web/frontend/js/features/onboarding/utils/onboarding-video-step.ts @@ -0,0 +1 @@ +export type OnboardingVideoStep = 'first' | 'second' diff --git a/services/web/frontend/js/features/onboarding/utils/watching-time.ts b/services/web/frontend/js/features/onboarding/utils/watching-time.ts new file mode 100644 index 0000000000..435ab67d3d --- /dev/null +++ b/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 +) { + 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 } +} diff --git a/services/web/frontend/js/ide.js b/services/web/frontend/js/ide.js index 45d6a1c194..b67776e3c6 100644 --- a/services/web/frontend/js/ide.js +++ b/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' diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 8265964e85..0fcdb27972 100644 --- a/services/web/locales/en.json +++ b/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 to see your entire LaTeX code, or choose <0>Rich Text for something more visual.", + "edit_in_the_left_pane_click_recompile": "Edit in the left pane. Click <0>Recompile to see the PDF output on the right.", "editing": "Editing", "editor_and_pdf": "Editor <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 to the __appName__ newsletter.", + "next": "Next", "next_payment_of_x_collectected_on_y": "The next payment of <0>__paymentAmmount__ will be collected on <1>__collectionDate__.", "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 log out from your __email1__ account and then log in with __email2__.", "with_premium_subscription_you_also_get": "With an Overleaf Premium subscription you also get",