diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index bb79c5eb5a..912da763f3 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -677,6 +677,17 @@ const ProjectController = { cb() }) }, + tableGeneratorPromotionAssignment(cb) { + SplitTestHandler.getAssignment( + req, + res, + 'table-generator-promotion', + () => { + // We'll pick up the assignment from the res.locals assignment. + cb() + } + ) + }, pasteHtmlAssignment(cb) { SplitTestHandler.getAssignment(req, res, 'paste-html', () => { // We'll pick up the assignment from the res.locals assignment. diff --git a/services/web/app/src/Features/Tutorial/TutorialController.js b/services/web/app/src/Features/Tutorial/TutorialController.js index 876722404e..e0eed5ce00 100644 --- a/services/web/app/src/Features/Tutorial/TutorialController.js +++ b/services/web/app/src/Features/Tutorial/TutorialController.js @@ -2,7 +2,10 @@ const SessionManager = require('../Authentication/SessionManager') const TutorialHandler = require('./TutorialHandler') const { expressify } = require('../../util/promises') -const VALID_KEYS = ['react-history-buttons-tutorial'] +const VALID_KEYS = [ + 'react-history-buttons-tutorial', + 'table-generator-promotion', +] async function completeTutorial(req, res, next) { const userId = SessionManager.getLoggedInUserId(req.session) diff --git a/services/web/frontend/js/features/source-editor/components/editor-switch.tsx b/services/web/frontend/js/features/source-editor/components/editor-switch.tsx index 64d45a0c15..7c4113e154 100644 --- a/services/web/frontend/js/features/source-editor/components/editor-switch.tsx +++ b/services/web/frontend/js/features/source-editor/components/editor-switch.tsx @@ -5,6 +5,7 @@ import { sendMB } from '../../../infrastructure/event-tracking' import isValidTeXFile from '../../../main/is-valid-tex-file' import { useTranslation } from 'react-i18next' import SplitTestBadge from '../../../shared/components/split-test-badge' +import { PromotionOverlay } from './table-generator/promotion/popover' function EditorSwitch() { const { t } = useTranslation() @@ -114,7 +115,7 @@ const RichTextToggle: FC<{ ) } - return toggle + return {toggle} } export default memo(EditorSwitch) diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/promotion/popover.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/promotion/popover.tsx new file mode 100644 index 0000000000..049223d82d --- /dev/null +++ b/services/web/frontend/js/features/source-editor/components/table-generator/promotion/popover.tsx @@ -0,0 +1,269 @@ +import { + FC, + Ref, + forwardRef, + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import PropTypes from 'prop-types' +import { useEditorContext } from '../../../../../shared/context/editor-context' +import { Button, Overlay, Popover } from 'react-bootstrap' +import Close from '../../../../../shared/components/close' +import { postJSON } from '../../../../../infrastructure/fetch-json' +import { sendMB } from '../../../../../infrastructure/event-tracking' +import { useSplitTestContext } from '../../../../../shared/context/split-test-context' +import { User } from '../../../../../../../types/user' +import { useUserContext } from '../../../../../shared/context/user-context' +import grammarlyExtensionPresent from '../../../../../shared/utils/grammarly' +import { debugConsole } from '../../../../../utils/debugging' + +const DELAY_BEFORE_SHOWING_PROMOTION = 1000 +const NEW_USER_CUTOFF_TIME = new Date(2023, 8, 20).getTime() +const NOW_TIME = new Date().getTime() +const GRAMMARLY_CUTOFF_TIME = new Date(2023, 9, 10).getTime() + +type CompletedTutorials = { + 'table-generator-promotion'?: Date | string +} +type EditorTutorials = { + completedTutorials?: CompletedTutorials + setCompletedTutorial: (key: string) => void +} + +const editorContextPropTypes = { + completedTutorials: PropTypes.shape({ + 'table-generator-promotion': PropTypes.oneOfType([ + PropTypes.instanceOf(Date), + PropTypes.string, + ]), + }), + setCompletedTutorial: PropTypes.func.isRequired, +} + +export const PromotionOverlay: FC = ({ children }) => { + const ref = useRef(null) + + const { completedTutorials }: EditorTutorials = useEditorContext( + editorContextPropTypes + ) + const { + splitTestVariants, + }: { splitTestVariants: Record } = + useSplitTestContext() + + const user = useUserContext() as User | undefined + + const userRegistrationTime = useMemo(() => { + if (user?.signUpDate) { + return new Date(user.signUpDate).getTime() + } + }, [user]) + + const hideBecauseNewUser = + !userRegistrationTime || userRegistrationTime > NEW_USER_CUTOFF_TIME + + const showPromotion = + splitTestVariants['table-generator-promotion'] === 'enabled' && + !completedTutorials?.['table-generator-promotion'] && + !hideBecauseNewUser + + if (!showPromotion) { + return <>{children} + } + + return ( + <> + + {children} + + ) +} + +const PromotionOverlayContent = memo( + forwardRef(function PromotionOverlayContent( + _props, + ref: Ref + ) { + const { setCompletedTutorial }: EditorTutorials = useEditorContext( + editorContextPropTypes + ) + const [timeoutExpired, setTimeoutExpired] = useState(false) + + const onClose = useCallback(() => { + setCompletedTutorial('table-generator-promotion') + postJSON('/tutorial/table-generator-promotion/complete').catch( + debugConsole.error + ) + }, [setCompletedTutorial]) + + const onDismiss = useCallback(() => { + onClose() + sendMB('table-generator-promotion-dismissed') + }, [onClose]) + + const onComplete = useCallback(() => { + onClose() + sendMB('table-generator-promotion-completed') + }, [onClose]) + + useEffect(() => { + const interval = setTimeout(() => { + setTimeoutExpired(true) + }, DELAY_BEFORE_SHOWING_PROMOTION) + return () => clearTimeout(interval) + }, []) + + const [currentPage, setCurrentPage] = useState(0) + + const nextPage = useCallback(() => { + setCurrentPage(cur => clamp(cur + 1)) + sendMB('table-generator-promotion-next-page') + }, []) + + const page = PROMOTION_PAGES[clamp(currentPage)] + const PageComponent = page.body + const pageTitle = page.title + const isAtLastPage = currentPage >= PROMOTION_PAGES.length - 1 + + const hideBecauseOfGrammarly = + grammarlyExtensionPresent() && NOW_TIME < GRAMMARLY_CUTOFF_TIME + + if ( + !timeoutExpired || + !ref || + typeof ref === 'function' || + !ref.current || + hideBecauseOfGrammarly + ) { + return null + } + + return ( + + + {pageTitle} + + + } + className="dark-themed" + > + + + +