diff --git a/.changeset/eleven-dingos-smell.md b/.changeset/eleven-dingos-smell.md new file mode 100644 index 0000000000..150ef75e93 --- /dev/null +++ b/.changeset/eleven-dingos-smell.md @@ -0,0 +1,12 @@ +--- +'@commercetools-website/self-learning-smoke-test': minor +'@commercetools-docs/gatsby-theme-api-docs': minor +'@commercetools-website/api-docs-smoke-test': minor +'@commercetools-docs/gatsby-theme-docs': minor +'@commercetools-website/docs-smoke-test': minor +'@commercetools-website/documentation': minor +'@commercetools-website/site-template': minor +'@commercetools-docs/ui-kit': minor +--- + +Add the ability of tracking course content from video and text pages diff --git a/packages/gatsby-theme-docs/gatsby-config.mjs b/packages/gatsby-theme-docs/gatsby-config.mjs index 794338356a..a13aa5d977 100644 --- a/packages/gatsby-theme-docs/gatsby-config.mjs +++ b/packages/gatsby-theme-docs/gatsby-config.mjs @@ -31,8 +31,12 @@ const validateThemeOptions = (options) => { }; const productionHostname = 'docs.commercetools.com'; +const getTagListOption = (themeOptions) => + !themeOptions.transformerMdx ? [] : themeOptions.transformerMdx.tagList; + const config = (themeOptions = {}) => { const pluginOptions = { ...defaultOptions, ...themeOptions }; + const additionalTags = getTagListOption(themeOptions); // backwards compat to single value GA configuration if ( pluginOptions.gaTrackingId && @@ -318,6 +322,13 @@ const config = (themeOptions = {}) => { ], }, }, + { + resolve: '@commercetools-docs/gatsby-transformer-mdx-introspection', + options: { + ...themeOptions.transformerMdx, + tagList: [...additionalTags], + }, + }, ].filter(Boolean), }; }; diff --git a/packages/gatsby-theme-docs/global.d.ts b/packages/gatsby-theme-docs/global.d.ts new file mode 100644 index 0000000000..dda430ac9f --- /dev/null +++ b/packages/gatsby-theme-docs/global.d.ts @@ -0,0 +1,11 @@ +import { + VideoProgressReachedEvent, + ContentPageViewedEvent, +} from './src/modules/self-learning/hooks/use-learning-tracking'; + +declare global { + interface GlobalEventHandlersEventMap { + 'selflearning:video:progressReached': VideoProgressReachedEvent; + 'selflearning:pageContent:viewed': ContentPageViewedEvent; + } +} diff --git a/packages/gatsby-theme-docs/package.json b/packages/gatsby-theme-docs/package.json index f1a9990ab7..8e0aa93489 100644 --- a/packages/gatsby-theme-docs/package.json +++ b/packages/gatsby-theme-docs/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@auth0/auth0-react": "2.1.0", + "@commercetools-docs/gatsby-transformer-mdx-introspection": "16.0.0", "@commercetools-docs/ui-kit": "22.2.0", "@commercetools-uikit/card": "16.3.0", "@commercetools-uikit/checkbox-input": "16.3.0", diff --git a/packages/gatsby-theme-docs/src/components/video-player-client-side.js b/packages/gatsby-theme-docs/src/components/video-player-client-side.js index f0a360047a..ea9c48851a 100644 --- a/packages/gatsby-theme-docs/src/components/video-player-client-side.js +++ b/packages/gatsby-theme-docs/src/components/video-player-client-side.js @@ -3,8 +3,9 @@ import PropTypes from 'prop-types'; import { useLazyLoad } from '@commercetools-docs/ui-kit'; import LoadingSpinner from '@commercetools-uikit/loading-spinner'; import VideoPlaceholder from './video-placeholder'; +import { EVENT_VIDEO_PROGRESS } from '../modules/self-learning/hooks/use-learning-tracking'; -const videoJsVersion = '8.2.1'; +const videoJsVersion = '8.3.0'; /** * Preset value. Evaluate overtime if any of these needs to be a prop @@ -52,6 +53,27 @@ const VideoPlayer = (props) => { ); } const player = playerRef.current; + let eventTriggered = false; // Flag to track event triggering + + // for tracking purposes, the video player emits a selflearning:video:progressReached + // event when the play time reached a certain threshold (defaults to 80%) + player.on('timeupdate', () => { + const completeThreshold = props.completeAtPercent + ? parseInt(props.completeAtPercent) / 100 + : 0.8; // defaults to 80% (0.8) + const currentTime = player.currentTime(); + const duration = player.duration(); + const progress = currentTime / duration; + if (!eventTriggered && progress >= completeThreshold) { + // Trigger custom event + const customEvent = new CustomEvent(EVENT_VIDEO_PROGRESS, { + detail: { progress, url: props.url }, + }); + const el = document.getElementById('application'); + el.dispatchEvent(customEvent); + eventTriggered = true; // Set the flag to prevent further triggering + } + }); return () => { if (player) { player.dispose(); @@ -78,6 +100,7 @@ const VideoPlayer = (props) => { VideoPlayer.propTypes = { url: PropTypes.string.isRequired, poster: PropTypes.string, + completeAtPercent: PropTypes.string, }; export default VideoPlayer; diff --git a/packages/gatsby-theme-docs/src/hooks/use-page-visibility.js b/packages/gatsby-theme-docs/src/hooks/use-page-visibility.js new file mode 100644 index 0000000000..7ce37f4641 --- /dev/null +++ b/packages/gatsby-theme-docs/src/hooks/use-page-visibility.js @@ -0,0 +1,26 @@ +import { useState, useEffect } from 'react'; + +const usePageVisibility = (isEnabled) => { + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + function handleVisibilityChange() { + setIsVisible(document.visibilityState === 'visible'); + } + + if (isEnabled) { + setIsVisible(document.visibilityState === 'visible'); + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + document.removeEventListener( + 'visibilitychange', + handleVisibilityChange + ); + }; + } + }, [isEnabled]); + + return isVisible; +}; + +export default usePageVisibility; diff --git a/packages/gatsby-theme-docs/src/layouts/content.js b/packages/gatsby-theme-docs/src/layouts/content.js index 8b3beaf28f..4d84512b45 100644 --- a/packages/gatsby-theme-docs/src/layouts/content.js +++ b/packages/gatsby-theme-docs/src/layouts/content.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef } from 'react'; import PropTypes from 'prop-types'; import { Markdown } from '@commercetools-docs/ui-kit'; import SpacingsStack from '@commercetools-uikit/spacings-stack'; @@ -26,11 +26,36 @@ import { CourseCompleteModal, useCourseInfoByPageSlugs, } from '../modules/self-learning'; +import { + useFetchCourseDetails, + getMatchingTopic, +} from '../modules/self-learning/hooks/use-course-details'; +import { + useLearningTrackingHandler, + useContentPageTrackingDispatcher, +} from '../modules/self-learning/hooks/use-learning-tracking'; const LayoutContent = (props) => { const courseInfo = useCourseInfoByPageSlugs([props.pageContext.slug]); const courseId = courseInfo[props.pageContext.slug]?.courseId; + const { data } = useFetchCourseDetails(courseId); + const isSelfLearningPage = !!courseId; + let selfLearningTopic; + if (isSelfLearningPage) { + selfLearningTopic = getMatchingTopic( + data?.result?.topics, + courseInfo[props.pageContext.slug]?.topicName + ); + } + useLearningTrackingHandler( + courseId, + selfLearningTopic, + props.pageContext.slug + ); + useContentPageTrackingDispatcher(selfLearningTopic); + const { ref, inView, entry } = useInView(); + const contentRef = useRef(); const isSearchBoxInView = !Boolean(entry) || inView; const layoutState = useLayoutState(); const siteData = useSiteData(); @@ -95,7 +120,7 @@ const LayoutContent = (props) => { - + {props.children} diff --git a/packages/gatsby-theme-docs/src/modules/self-learning/hooks/hooks.utils.ts b/packages/gatsby-theme-docs/src/modules/self-learning/hooks/hooks.utils.ts index a6bdb20d03..ad4950e230 100644 --- a/packages/gatsby-theme-docs/src/modules/self-learning/hooks/hooks.utils.ts +++ b/packages/gatsby-theme-docs/src/modules/self-learning/hooks/hooks.utils.ts @@ -31,7 +31,8 @@ export const fetcherWithToken = async ( url: string, getAuthToken: () => Promise, learnApiBaseUrl: string, - method: 'GET' | 'POST' + method: 'GET' | 'POST', + body?: object ): Promise> => { const responseHandler = async (response: Response) => { if (!response.ok) { @@ -60,17 +61,23 @@ export const fetcherWithToken = async ( const accessToken = await getAuthToken(); // ...then performs fetch + let bodyHeaders = {}; + if (body) { + bodyHeaders = { 'Content-Type': 'application/json' }; + } const response = await fetch(`${learnApiBaseUrl}${url}`, { method, headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}`, + ...bodyHeaders, }, // Allowing to include authorization and cookie headers because the API is hosted on a different // subdomain even in production and as opposed to cookie domains, CORS same-origin // policy does not consider a subdomain of the document domain same-origin. // ("learning-api.docs.commercetools.com" is not same-origin with "docs.commercetools.com") credentials: 'include', + body: body ? JSON.stringify(body) : undefined, }); return responseHandler(response); } catch (e) { diff --git a/packages/gatsby-theme-docs/src/modules/self-learning/hooks/use-course-details.ts b/packages/gatsby-theme-docs/src/modules/self-learning/hooks/use-course-details.ts index b775e30bf7..a3352c6ae4 100644 --- a/packages/gatsby-theme-docs/src/modules/self-learning/hooks/use-course-details.ts +++ b/packages/gatsby-theme-docs/src/modules/self-learning/hooks/use-course-details.ts @@ -49,17 +49,24 @@ export const useFetchCourseDetails = ( }; }; -export const getTopicStatusByPageTitle = ( +export const getMatchingTopic = ( topics: CourseTopic[] | undefined, pageTitle: string ) => { if (!topics || !pageTitle) { - return 'notAvailable'; + return; } - const matchingTopic = topics.find( + return topics.find( (topic) => topic.name.trim().toLowerCase() === pageTitle.trim().toLowerCase() ); +}; + +export const getTopicStatusByPageTitle = ( + topics: CourseTopic[] | undefined, + pageTitle: string +) => { + const matchingTopic = getMatchingTopic(topics, pageTitle); if (matchingTopic) { return matchingTopic.completed ? 'completed' : 'inProgress'; } diff --git a/packages/gatsby-theme-docs/src/modules/self-learning/hooks/use-learning-tracking.ts b/packages/gatsby-theme-docs/src/modules/self-learning/hooks/use-learning-tracking.ts new file mode 100644 index 0000000000..3fb5539188 --- /dev/null +++ b/packages/gatsby-theme-docs/src/modules/self-learning/hooks/use-learning-tracking.ts @@ -0,0 +1,226 @@ +import { useEffect, useState } from 'react'; +import { + CourseActivities, + CourseTopic, + SupportedActivitiesType, +} from '../external-types'; +import usePageVisibility from '../../../hooks/use-page-visibility'; +import useIsClientSide from './use-is-client-side'; +import { useTrackActivity } from './use-track-activity'; +import { + MapContentItem, + useTrackableContentByPageSlug, +} from './use-trackable-content'; + +export const EVENT_VIDEO_PROGRESS = 'selflearning:video:progressReached'; +export const EVENT_PAGECONTENT_VIEWED = 'selflearning:pageContent:viewed'; + +const pageviewRegexp = /^pageview/; +const videoRegexp = /^video/; + +const isPageviewActivity = (activity?: CourseActivities) => { + return activity?.name && pageviewRegexp.test(activity.name); +}; +const isVideoActivity = (activity?: CourseActivities) => { + return activity?.name && videoRegexp.test(activity.name); +}; + +export interface VideoProgressReachedEvent extends Event { + detail: { + progress: number; + url: string; + }; +} + +export interface ContentPageViewedEvent extends Event { + detail: { + viewed: boolean; + }; +} + +type PageActivity = { + id: number; + type: string; + name: string; + status: string; +}; + +const matchTrackingComponent = ( + activity: PageActivity, + trackableItems?: MapContentItem[] +) => { + if (!trackableItems) { + return undefined; + } + return trackableItems.find( + (item) => item.component.type.toLowerCase() === activity.type.toLowerCase() + ); +}; + +const getMdxAttributeValue = ( + matchedActivity: MapContentItem, + attributeName: string +) => + matchedActivity.component.attributes.find( + (item) => item.name === attributeName + )?.value; + +export const useLearningTrackingHandler = ( + courseId: number | undefined, + topic: CourseTopic | undefined, + pageSlug: string +) => { + const [pageActivities, setPageActivities] = useState< + PageActivity[] | undefined + >(undefined); + const { trackActivity } = useTrackActivity(courseId); + const trackableItems = useTrackableContentByPageSlug(pageSlug); + + useEffect(() => { + const ancestorElement = document.getElementById( + 'application' + ) as HTMLElement; + + if (pageActivities === undefined) { + return; + } + + console.log('[DBG] activities found in API', pageActivities.length); + pageActivities.forEach((pageActivity, index) => { + console.log('[DBG] processing item', index, pageActivity); + // 0. The activity is already completed, we don't even bother going further + if (pageActivity.status === 'completed') { + console.log('[DBG] activity complete - END '); + return; + } + const matchedActivity = matchTrackingComponent( + pageActivity, + trackableItems + ); + console.log('[DBG] matched Activity ', matchedActivity); + // 1. activity exists in the API response (moodle) but is not matched in the mdx + // in this case we mark the activity completed straight away unless it's pageview, which is never + // matched by any actual mdx components but it will create a pageview listener + if (pageActivity.type !== 'pageview' && !matchedActivity) { + console.log('[DBG] unmatched - END '); + trackActivity(pageActivity.id, true); + return; + } + + // 2. activity is pageview or has a matching element in the code. In this case we put in place + // the code needed to track the activity. For the time being, Quiz is totally standalone so the tracking + // will happen upon quiz submission. Pageview and video needs some further code (see below) + if (pageActivity.type === 'pageview') { + console.log('[DBG] pageview'); + + const handleContentPageViewed = (event: ContentPageViewedEvent) => { + const videoProgressEvent = event as ContentPageViewedEvent; + const viewed = videoProgressEvent.detail.viewed; + if (viewed) { + console.log('[DBG] pageview tracked - END'); + trackActivity(pageActivity.id, true); + } + ancestorElement.removeEventListener( + EVENT_PAGECONTENT_VIEWED, + handleContentPageViewed + ); + }; + + if (ancestorElement) { + ancestorElement.addEventListener( + EVENT_PAGECONTENT_VIEWED, + handleContentPageViewed + ); + + return () => { + ancestorElement.removeEventListener( + EVENT_PAGECONTENT_VIEWED, + handleContentPageViewed + ); + }; + } + } + + if (pageActivity.type === 'video') { + console.log('[DBG] video', matchedActivity); + + const handleVideoProgressReached = ( + event: VideoProgressReachedEvent + ) => { + // let's make sure we're tracking the correct video + if ( + matchedActivity && + event.detail.url === getMdxAttributeValue(matchedActivity, 'url') + ) { + console.log('[DBG] video tracked - END', event.detail.url); + trackActivity(pageActivity.id, true); + } + }; + + const ancestorElement = document.getElementById( + 'application' + ) as HTMLElement; + if (ancestorElement) { + ancestorElement.addEventListener( + EVENT_VIDEO_PROGRESS, + handleVideoProgressReached + ); + + return () => { + ancestorElement.removeEventListener( + EVENT_VIDEO_PROGRESS, + handleVideoProgressReached + ); + }; + } + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pageActivities, trackableItems]); + + useEffect(() => { + console.log('[DBG]', topic?.activities); + const pageActs: PageActivity[] = + topic?.activities + .filter((act) => act.type === 'label' || 'quiz') + .map((act) => { + let learningType: string = act.type; + if (isPageviewActivity(act)) { + learningType = 'pageview'; + } + if (isVideoActivity(act)) { + learningType = 'video'; + } + return { + id: act.courseModuleId, + name: act.name, + type: learningType, + status: act.completionStatus, + }; + }) || []; + + setPageActivities(pageActs); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [topic]); +}; + +export const useContentPageTrackingDispatcher = ( + topic: CourseTopic | undefined +) => { + const isContentVisible = usePageVisibility(topic); // enabled only on self-learning pages + const { isClientSide } = useIsClientSide(); + + useEffect(() => { + if (!topic) { + return; + } + const pageviewActivity = topic.activities.find(isPageviewActivity); + if (isClientSide && pageviewActivity && isContentVisible) { + const customEvent = new CustomEvent(EVENT_PAGECONTENT_VIEWED, { + detail: { viewed: isContentVisible }, + }); + const el = document.getElementById('application'); + el?.dispatchEvent(customEvent); + } + }, [topic, isContentVisible, isClientSide]); +}; diff --git a/packages/gatsby-theme-docs/src/modules/self-learning/hooks/use-track-activity.ts b/packages/gatsby-theme-docs/src/modules/self-learning/hooks/use-track-activity.ts new file mode 100644 index 0000000000..9d32995911 --- /dev/null +++ b/packages/gatsby-theme-docs/src/modules/self-learning/hooks/use-track-activity.ts @@ -0,0 +1,59 @@ +import useSWR, { useSWRConfig } from 'swr'; +import { useContext, useEffect, useState } from 'react'; +import ConfigContext from '../../../components/config-context'; +import { fetcherWithToken } from './hooks.utils'; +import { useAuthToken } from './use-auth-token'; +import useAuthentication from '../../sso/hooks/use-authentication'; + +export const useTrackActivity = (courseId?: number) => { + const { learnApiBaseUrl } = useContext(ConfigContext); + const { getAuthToken } = useAuthToken(); + const { isAuthenticated } = useAuthentication(); + const [canTrack, setCanTrack] = useState(false); + const [completed, setCompleted] = useState(false); + const [activityId, setActivityId] = useState(); + const { mutate } = useSWRConfig(); + + const apiEndpoint = `/api/courses/${courseId}/activities/${activityId}`; + + useEffect(() => { + setCanTrack( + isAuthenticated && courseId !== undefined && activityId !== undefined + ); + }, [courseId, activityId, isAuthenticated]); + + const { data, error, isLoading } = useSWR( + canTrack ? apiEndpoint : null, + (url) => + fetcherWithToken(url, getAuthToken, learnApiBaseUrl, 'POST', { + completed, + }), + { + revalidateOnReconnect: false, + revalidateIfStale: false, + revalidateOnMount: true, + revalidateOnFocus: false, + } + ); + + useEffect(() => { + if (!isLoading && !error && data) { + // in case of a successfull response, we invalidate courses' cache + mutate('/api/courses'); + mutate(`/api/courses/${courseId}`); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data, isLoading, error, courseId]); + + const trackActivity = (activityId: number, completed: boolean) => { + setActivityId(activityId); + setCompleted(completed); + }; + + return { + data, + error, + isLoading, + trackActivity, + }; +}; diff --git a/packages/gatsby-theme-docs/src/modules/self-learning/hooks/use-trackable-content.ts b/packages/gatsby-theme-docs/src/modules/self-learning/hooks/use-trackable-content.ts new file mode 100644 index 0000000000..c09c6caa46 --- /dev/null +++ b/packages/gatsby-theme-docs/src/modules/self-learning/hooks/use-trackable-content.ts @@ -0,0 +1,110 @@ +import { useStaticQuery, graphql } from 'gatsby'; + +type TrackableContentAttribute = { + name: string; + value: string; +}; + +type TrackableContentFileInfo = { + file: { + relativePath: string; + }; + frontmatter: { + courseId: number; + topicName: string; + }; +}; +type TrackableContentNode = { + component: 'Quiz' | 'Video'; + attributes: TrackableContentAttribute[]; + mdx: TrackableContentFileInfo; +}; + +type TrackableContentQueryResult = { + allComponentInMdx: { nodes: TrackableContentNode[] }; +}; + +let trackableContentMap = new Map(); +var isIndexed = false; + +const createTrackableContentMap = (data: TrackableContentQueryResult) => { + if (!isIndexed) { + trackableContentMap = new Map( + Object.entries( + data.allComponentInMdx.nodes.reduce( + ( + result: Record, + item: TrackableContentNode + ) => { + const relativePath = item.mdx.file.relativePath; + if (!result[relativePath]) { + result[relativePath] = []; + } + const courseId = item.mdx.frontmatter.courseId; + const topicName = item.mdx.frontmatter.topicName; + const component = item.component; + result[relativePath].push({ + component: { type: component, attributes: item.attributes }, + courseId, + topicName, + }); + return result; + }, + {} + ) + ) + ); + + isIndexed = true; + } +}; + +const useTrackableContent = (): Map => { + const data = useStaticQuery( + graphql` + { + allComponentInMdx(filter: { component: { in: ["Video", "Quiz"] } }) { + nodes { + component + attributes { + name + value + } + mdx { + file: parent { + ... on File { + relativePath + } + } + frontmatter { + courseId + topicName + } + } + } + } + } + ` + ); + createTrackableContentMap(data); + return trackableContentMap; +}; + +export type MapContentItem = { + component: { + type: 'Quiz' | 'Video'; + attributes: TrackableContentAttribute[]; + }; + courseId: number; + topicName: string; +}; + +const slugToRelativeFilePath = (slug: string) => { + const path = slug.startsWith('/') ? slug.substring(1) : slug; + return `${path}.mdx`; +}; + +export const useTrackableContentByPageSlug = (pageSlug: string) => { + const contentMap = useTrackableContent(); + return contentMap.get(slugToRelativeFilePath(pageSlug)); +}; diff --git a/packages/ui-kit/src/icons/generated/Ribbon.tsx b/packages/ui-kit/src/icons/generated/Ribbon.tsx new file mode 100644 index 0000000000..30f2cc6e32 --- /dev/null +++ b/packages/ui-kit/src/icons/generated/Ribbon.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import type { SVGProps } from 'react'; +const SvgRibbon = (props: SVGProps) => ( + + + +); +export default SvgRibbon; diff --git a/packages/ui-kit/src/icons/generated/index.ts b/packages/ui-kit/src/icons/generated/index.ts index cef6859f13..00693feb2e 100644 --- a/packages/ui-kit/src/icons/generated/index.ts +++ b/packages/ui-kit/src/icons/generated/index.ts @@ -10,6 +10,7 @@ export { default as MerchantCenterSvgIcon } from './MerchantCenter'; export { default as OpenSourceSmallSvgIcon } from './OpenSourceSmall'; export { default as OpenSourceSvgIcon } from './OpenSource'; export { default as ReleaseNotesSvgIcon } from './ReleaseNotes'; +export { default as RibbonSvgIcon } from './Ribbon'; export { default as RssSvgIcon } from './Rss'; export { default as SearchSvgIcon } from './Search'; export { default as SlashSvgIcon } from './Slash'; diff --git a/websites/documentation/src/content/writing/images.mdx b/websites/documentation/src/content/writing/images.mdx index c631301768..b38aa5fc78 100644 --- a/websites/documentation/src/content/writing/images.mdx +++ b/websites/documentation/src/content/writing/images.mdx @@ -70,17 +70,20 @@ The component accepts the following properties: - `url` (mandatory): The source URL to a video source to embed. - `poster`: an optional parameter specifying a URL to an image that displays before the video begins playing. This is often a frame of the video or a custom title screen. As soon as the user hits "play" the image will go away. If not specified, the initial frame of the video will be displayed instead. +- `completeAtPercent`: an optional parameter specifying the percentage of video the user need to watch to consider the learning activity complete. It must be a number between 0 and 100, it defaults to 80; ```md title="you write:" secondaryTheme diff --git a/websites/self-learning-smoke-test/gatsby-config.mjs b/websites/self-learning-smoke-test/gatsby-config.mjs index 061ad88202..e44e4c3369 100644 --- a/websites/self-learning-smoke-test/gatsby-config.mjs +++ b/websites/self-learning-smoke-test/gatsby-config.mjs @@ -22,6 +22,9 @@ const config = { selfLearningFeatures: ['status-indicator', 'complete-profile-modal'], hideLogin: false, addOns: [], + transformerMdx: { + tagList: ['Quiz', 'Video'], + }, }), ], }; diff --git a/websites/self-learning-smoke-test/src/content/course-3/multi-content.mdx b/websites/self-learning-smoke-test/src/content/course-3/multi-content.mdx new file mode 100644 index 0000000000..d38e48850f --- /dev/null +++ b/websites/self-learning-smoke-test/src/content/course-3/multi-content.mdx @@ -0,0 +1,36 @@ +--- +title: Multi content page +courseId: 70 +topicName: 'Multi content page' +--- + +The purpose of this activity is to track page view. It contains dummy content + +## Introduction + +In recent years, the landscape of education has witnessed a remarkable transformation with the advent of e-learning. As technological advancements continue to shape the way we live and learn, online education has emerged as a powerful tool that transcends boundaries and provides unparalleled opportunities for knowledge acquisition. In this article, we will delve into the world of e-learning and explore its evolution, benefits, and the potential it holds for the future. + +## The Evolution of E-Learning + +E-learning, or electronic learning, traces its roots back to when the concept of computer-based training emerged. Initially, it involved the use of mainframe computers and punch cards to deliver educational content. However, it was not until the widespread availability of the internet in that e-learning truly took off, paving the way for a revolution in education. + +## Benefits of E-Learning + +E-learning offers numerous advantages over traditional classroom-based learning. First and foremost, it provides flexibility and convenience. Learners can access educational materials and participate in courses from anywhere and at any time, eliminating the need for physical attendance. This flexibility allows individuals to balance their personal and professional commitments while pursuing their educational goals. + +Furthermore, e-learning promotes self-paced learning, allowing students to progress at their own speed. This personalized approach caters to diverse learning styles and preferences, enabling individuals to grasp concepts more effectively. Moreover, e-learning platforms often provide interactive elements, such as quizzes, videos, and simulations, which enhance engagement and knowledge retention. + +Another significant benefit of e-learning is its cost-effectiveness. Traditional education often involves expenses related to transportation, accommodation, and printed materials. E-learning eliminates many of these costs, making education more accessible and affordable for a wider audience. Additionally, e-learning enables institutions and organizations to reach a global audience without the need for physical infrastructure, expanding educational opportunities for individuals in remote areas or underserved communities. + + +## Watch this video + +