diff --git a/locales/en/learn.json b/locales/en/learn.json index 845d28d216..0fe882b40f 100644 --- a/locales/en/learn.json +++ b/locales/en/learn.json @@ -38,6 +38,7 @@ "prev-lesson": "Previous Lesson", "reflection-description-1": "Clicking \"Add Reflection\" will take you to QuranReflect, a platform for sharing personal reflections on the Quran. Unlike Tafsir (scholarly interpretation), reflections are personal insights and experiences related to the verses.", "reflection-description-2": "If you post publicly, your reflection will be reviewed by the moderation team and become visible to the QuranReflect community.", + "start-here": "Start here", "start-learning": "Start Learning", "tabs": { "main": "Main Details", diff --git a/locales/en/quran-reader.json b/locales/en/quran-reader.json index 922f1760f5..f0e13af02f 100644 --- a/locales/en/quran-reader.json +++ b/locales/en/quran-reader.json @@ -112,7 +112,7 @@ "learning-plan-banner": { "main-headline": "Discover powerful lessons in Surah Al-Mulk today", "subtitle-description": "(A free Learning Plan)", - "call-to-action-button": "Begin Now", + "call-to-action-button": "Start Now", "banner-image-description": "View The Rescuer learning plan for Surah Al-Mulk", "button-accessibility-label": "Learn more about The Rescuer learning plan", "banner-image-alt-text": "The Rescuer: Powerful Lessons in Surah Al-Mulk learning plan banner", diff --git a/src/components/Course/Buttons/StartOrContinueLearning/index.tsx b/src/components/Course/Buttons/StartOrContinueLearning/index.tsx index 77e1c498e5..60ec491f79 100644 --- a/src/components/Course/Buttons/StartOrContinueLearning/index.tsx +++ b/src/components/Course/Buttons/StartOrContinueLearning/index.tsx @@ -18,15 +18,11 @@ const StartOrContinueLearning: React.FC = ({ course, isHeaderButton = tru const { t } = useTranslation('learn'); const { lessons, continueFromLesson, id, slug } = course; const userType = getUserType(); - /** - * there is a corner case when the user enrolls, - * goes back to main page then clicks start learning again, - * continueFromLesson is undefined since it has been cached from - * before the user enrolled. - */ - const redirectToLessonSlug = continueFromLesson || lessons?.[0]?.slug; const router = useRouter(); - const userCompletedAnyLesson = lessons.some((lesson) => lesson.isCompleted === true); + + // Navigate to the lesson user should continue from, or first lesson if not set + const redirectToLessonSlug = continueFromLesson || lessons?.[0]?.slug; + const onContinueLearningClicked = () => { logButtonClick('continue_learning', { courseId: id, @@ -36,19 +32,7 @@ const StartOrContinueLearning: React.FC = ({ course, isHeaderButton = tru router.push(getLessonNavigationUrl(slug, redirectToLessonSlug)); }; - const onStartLearningClicked = () => { - logButtonClick('start_learning', { - courseId: id, - isHeaderButton, - userType, - }); - router.push(getLessonNavigationUrl(slug, redirectToLessonSlug)); - }; - - if (userCompletedAnyLesson) { - return ; - } - return ; + return ; }; export default StartOrContinueLearning; diff --git a/src/components/Course/CourseDetails/StatusHeader/index.tsx b/src/components/Course/CourseDetails/StatusHeader/index.tsx index 67283ca4b6..bf941e5201 100644 --- a/src/components/Course/CourseDetails/StatusHeader/index.tsx +++ b/src/components/Course/CourseDetails/StatusHeader/index.tsx @@ -11,11 +11,10 @@ import CourseFeedback, { FeedbackSource } from '@/components/Course/CourseFeedba import Button from '@/dls/Button/Button'; import Pill from '@/dls/Pill'; import { ToastStatus, useToast } from '@/dls/Toast/Toast'; -import { useEnrollGuest, useIsEnrolled } from '@/hooks/auth/useGuestEnrollment'; +import useEnrollUser from '@/hooks/auth/useEnrollUser'; import useMutateWithoutRevalidation from '@/hooks/useMutateWithoutRevalidation'; -import { logErrorToSentry } from '@/lib/sentry'; import { Course } from '@/types/auth/Course'; -import { enrollUser } from '@/utils/auth/api'; +import EnrollmentMethod from '@/types/auth/EnrollmentMethod'; import { makeGetCourseUrl } from '@/utils/auth/apiPaths'; import { getUserType, isLoggedIn } from '@/utils/auth/login'; import { logButtonClick } from '@/utils/eventLogger'; @@ -31,79 +30,65 @@ type Props = { }; const StatusHeader: React.FC = ({ course, isCTA = false }) => { - const { title, id, isUserEnrolled, slug, isCompleted, lessons, allowGuestAccess } = course; + const { id, isUserEnrolled, slug, isCompleted, lessons, allowGuestAccess } = course; const [isLoading, setIsLoading] = useState(false); const toast = useToast(); const router = useRouter(); const { t } = useTranslation('learn'); const mutate = useMutateWithoutRevalidation(); - const enrollGuest = useEnrollGuest(); - const isEnrolled = useIsEnrolled(id, isUserEnrolled); const userLoggedIn = isLoggedIn(); + const enrollUserInCourse = useEnrollUser(); - const handleEnrollmentSuccess = async (loggedIn: boolean): Promise => { - toast(t('enroll-success', { title }), { status: ToastStatus.Success }); - if (loggedIn) { + const enrollAndNavigate = async (): Promise => { + setIsLoading(true); + + const { success } = await enrollUserInCourse(id, EnrollmentMethod.MANUAL); + + if (success) { mutate(makeGetCourseUrl(slug), (currentCourse: Course) => ({ ...currentCourse, isUserEnrolled: true, })); - } - if (lessons?.length > 0) { - await router.replace(getLessonNavigationUrl(slug, lessons[0].slug)); + if (lessons?.length > 0) { + await router.push(getLessonNavigationUrl(slug, lessons[0].slug)); + } + } else { + toast(t('common:error.general'), { status: ToastStatus.Error }); } - }; - const onEnrollClicked = async (): Promise => { - const userType = getUserType(userLoggedIn); + setIsLoading(false); + }; + const onStartHereClicked = async (): Promise => { logButtonClick('course_enroll', { courseId: id, isCTA, - userType, + userType: getUserType(userLoggedIn), }); - if (!userLoggedIn && !allowGuestAccess) { - const redirectUrl = getCourseNavigationUrl(slug); - router.replace(getLoginNavigationUrl(redirectUrl)); - return; - } - - setIsLoading(true); - try { - if (userLoggedIn) { - await enrollUser(id); + if (!userLoggedIn) { + if (allowGuestAccess && lessons?.length > 0) { + router.push(getLessonNavigationUrl(slug, lessons[0].slug)); } else { - enrollGuest(id); + router.push(getLoginNavigationUrl(getCourseNavigationUrl(slug))); } - await handleEnrollmentSuccess(userLoggedIn); - } catch (error) { - logErrorToSentry(error, { - metadata: { - context: `${userType}_course_enrollment`, - courseId: id, - courseSlug: slug, - }, - }); - toast(t('common:error.general'), { - status: ToastStatus.Error, - }); - } finally { - setIsLoading(false); + return; } + + await enrollAndNavigate(); }; + const startButton = ( + + ); + if (isCTA) { - if (isEnrolled) { - return <>; - } - return ( - - ); + return isUserEnrolled ? null : startButton; } + if (isCompleted) { return (
@@ -114,15 +99,12 @@ const StatusHeader: React.FC = ({ course, isCTA = false }) => {
); } - if (isEnrolled) { + + if (isUserEnrolled) { return ; } - return ( - - ); + return startButton; }; export default StatusHeader; diff --git a/src/components/Course/CourseDetails/Tabs/Syllabus/Syllabus.module.scss b/src/components/Course/CourseDetails/Tabs/Syllabus/Syllabus.module.scss index 8377b4fc6d..63445cecb7 100644 --- a/src/components/Course/CourseDetails/Tabs/Syllabus/Syllabus.module.scss +++ b/src/components/Course/CourseDetails/Tabs/Syllabus/Syllabus.module.scss @@ -9,16 +9,3 @@ .day { font-weight: var(--font-weight-bold); } - -.notEnrolledLink { - background: none; - border: none; - padding: 0; - color: var(--color-text-link); - block-size: auto; - - &:hover { - text-decoration: underline; - color: var(--color-text-link); - } -} diff --git a/src/components/Course/CourseDetails/Tabs/Syllabus/index.tsx b/src/components/Course/CourseDetails/Tabs/Syllabus/index.tsx index 92c6d66019..aeb4cf313f 100644 --- a/src/components/Course/CourseDetails/Tabs/Syllabus/index.tsx +++ b/src/components/Course/CourseDetails/Tabs/Syllabus/index.tsx @@ -6,34 +6,22 @@ import useTranslation from 'next-translate/useTranslation'; import styles from './Syllabus.module.scss'; import CompletedTick from '@/components/Course/CompletedTick'; -import Button from '@/components/dls/Button/Button'; import Link, { LinkVariant } from '@/dls/Link/Link'; -import { ToastStatus, useToast } from '@/dls/Toast/Toast'; -import { useIsEnrolled } from '@/hooks/auth/useGuestEnrollment'; import { Course } from '@/types/auth/Course'; import { getUserType } from '@/utils/auth/login'; import { logButtonClick } from '@/utils/eventLogger'; import { toLocalizedNumber } from '@/utils/locale'; import { getLessonNavigationUrl } from '@/utils/navigation'; -import { stripHTMLTags } from '@/utils/string'; type Props = { course: Course; }; const Syllabus: React.FC = ({ course }) => { - const { lessons = [], slug: courseSlug, id: courseId, isUserEnrolled } = course; + const { lessons = [], slug: courseSlug, id: courseId } = course; const { t, lang } = useTranslation('learn'); - const toast = useToast(); - const userType = getUserType(); - const isEnrolled = useIsEnrolled(courseId, isUserEnrolled); - /** - * Log syllabus lesson click for analytics - * @param {number} dayNumber - The day number of the lesson - * @param {string} lessonId - The ID of the lesson - */ const logSyllabusClick = (dayNumber: number, lessonId: string) => { logButtonClick('course_syllabus_day', { courseId, @@ -43,18 +31,6 @@ const Syllabus: React.FC = ({ course }) => { }); }; - /** - * Handle lesson click for non-enrolled users, shows toast message - * @param {number} dayNumber - The day number of the lesson - * @param {string} lessonId - The ID of the lesson - */ - const onNonEnrolledDayClick = (dayNumber: number, lessonId: string) => { - logSyllabusClick(dayNumber, lessonId); - toast(stripHTMLTags(t('not-enrolled')), { - status: ToastStatus.Warning, - }); - }; - return (
{lessons.map((lesson, index) => { @@ -70,24 +46,13 @@ const Syllabus: React.FC = ({ course }) => { )}`} {`: `} - {isEnrolled ? ( - logSyllabusClick(dayNumber, id)} - href={url} - variant={LinkVariant.Highlight} - > - {title} - - ) : ( - - )} + logSyllabusClick(dayNumber, id)} + href={url} + variant={LinkVariant.Highlight} + > + {title} + {isCompleted ? : null}

diff --git a/src/components/Course/LessonContent/index.tsx b/src/components/Course/LessonContent/index.tsx index e279b484ac..4461727a1f 100644 --- a/src/components/Course/LessonContent/index.tsx +++ b/src/components/Course/LessonContent/index.tsx @@ -3,9 +3,7 @@ import React from 'react'; import useTranslation from 'next-translate/useTranslation'; import LessonView from '@/components/Course/LessonView'; -import NotEnrolledNotice from '@/components/Course/NotEnrolledNotice'; import NextSeoWrapper from '@/components/NextSeoWrapper'; -import { useIsEnrolled } from '@/hooks/auth/useGuestEnrollment'; import { Lesson } from '@/types/auth/Course'; import { getCanonicalUrl, getLessonNavigationUrl } from '@/utils/navigation'; @@ -17,11 +15,6 @@ interface Props { const LessonContent: React.FC = ({ lesson, lessonSlugOrId, courseSlug }) => { const { lang } = useTranslation('learn'); - const isEnrolled = useIsEnrolled(lesson.course.id, lesson.course.isUserEnrolled); - - if (isEnrolled === false) { - return ; - } return ( <> diff --git a/src/components/Course/NotEnrolledNotice/NotEnrolledNotice.module.scss b/src/components/Course/NotEnrolledNotice/NotEnrolledNotice.module.scss deleted file mode 100644 index 896b47d7e3..0000000000 --- a/src/components/Course/NotEnrolledNotice/NotEnrolledNotice.module.scss +++ /dev/null @@ -1,3 +0,0 @@ -.container { - min-block-size: 70vh; -} diff --git a/src/components/Course/NotEnrolledNotice/index.tsx b/src/components/Course/NotEnrolledNotice/index.tsx deleted file mode 100644 index 80f7173858..0000000000 --- a/src/components/Course/NotEnrolledNotice/index.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React, { useEffect, useRef } from 'react'; - -import { useRouter } from 'next/router'; -import Trans from 'next-translate/Trans'; -import useTranslation from 'next-translate/useTranslation'; - -import styles from './NotEnrolledNotice.module.scss'; - -import PageContainer from '@/components/PageContainer'; -import Link, { LinkVariant } from '@/dls/Link/Link'; -import { ToastStatus, useToast } from '@/dls/Toast/Toast'; -import { logButtonClick } from '@/utils/eventLogger'; -import { getCourseNavigationUrl } from '@/utils/navigation'; -import { stripHTMLTags } from '@/utils/string'; - -interface Props { - courseSlug: string; - lessonSlugOrId: string; -} - -const NotEnrolledNotice: React.FC = ({ courseSlug, lessonSlugOrId }) => { - const { t: tLearn } = useTranslation('learn'); - const router = useRouter(); - const noticeToast = useToast(); - const hasHandledNotEnrolledRef = useRef(false); - - useEffect(() => { - if (hasHandledNotEnrolledRef.current) return; - noticeToast(stripHTMLTags(tLearn('not-enrolled')), { status: ToastStatus.Error }); - router.replace(getCourseNavigationUrl(courseSlug)); - hasHandledNotEnrolledRef.current = true; - }, [tLearn, router, noticeToast, courseSlug]); - - const onUnEnrolledNavigationLinkClicked = () => { - logButtonClick('unenrolled_course_link', { courseSlugOrId: courseSlug, lessonSlugOrId }); - }; - - return ( -
- - - ), - }} - /> - -
- ); -}; - -export default NotEnrolledNotice; diff --git a/src/hooks/auth/useEnrollUser.ts b/src/hooks/auth/useEnrollUser.ts new file mode 100644 index 0000000000..57e9e2888b --- /dev/null +++ b/src/hooks/auth/useEnrollUser.ts @@ -0,0 +1,35 @@ +import { useCallback } from 'react'; + +import { logErrorToSentry } from '@/lib/sentry'; +import EnrollmentMethod from '@/types/auth/EnrollmentMethod'; +import { enrollUser } from '@/utils/auth/api'; +import { isLoggedIn } from '@/utils/auth/login'; + +/** + * Enrolls a logged-in user in a course. + * + * @returns {(courseId: string, enrollmentMethod: EnrollmentMethod) => Promise<{success: boolean}>} Function to enroll user with automatic error handling + * + * @example + * const enroll = useEnrollUser(); + * const { success } = await enroll(courseId, EnrollmentMethod.MANUAL); + */ +const useEnrollUser = () => { + return useCallback(async (courseId: string, enrollmentMethod: EnrollmentMethod) => { + if (!isLoggedIn()) { + return { success: false }; + } + + try { + return await enrollUser({ courseId, enrollmentMethod }); + } catch (error) { + logErrorToSentry(error, { + transactionName: 'useEnrollUser', + metadata: { courseId, enrollmentMethod }, + }); + return { success: false }; + } + }, []); +}; + +export default useEnrollUser; diff --git a/src/hooks/auth/useGuestEnrollment.ts b/src/hooks/auth/useGuestEnrollment.ts deleted file mode 100644 index 33f1f46c60..0000000000 --- a/src/hooks/auth/useGuestEnrollment.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useCallback } from 'react'; - -import { useSelector, useDispatch } from 'react-redux'; - -import { RootState } from '@/redux/RootState'; -import { enrollInCourse, selectIsGuestEnrolledInCourse } from '@/redux/slices/guestEnrollment'; -import { isLoggedIn } from '@/utils/auth/login'; - -/** - * Hook to check if user or guest is enrolled in a course - * For logged-in users: checks server-side enrollment status - * For guests: checks Redux state - * @param {string} courseId - The ID of the course to check - * @param {boolean} isUserEnrolled - Server-side enrollment status for logged-in users - * @returns {boolean} True if enrolled - */ -export const useIsEnrolled = (courseId: string, isUserEnrolled?: boolean): boolean => { - const isGuestEnrolled = useSelector((state: RootState) => - selectIsGuestEnrolledInCourse(state, courseId), - ); - - if (isLoggedIn()) { - return Boolean(isUserEnrolled); - } - return isGuestEnrolled; -}; - -/** - * Hook for enrolling guest in a course - * @returns {(courseId: string) => void} Function to enroll guest in a course - */ -export const useEnrollGuest = (): ((courseId: string) => void) => { - const dispatch = useDispatch(); - - const enroll = useCallback( - (courseId: string) => { - dispatch(enrollInCourse(courseId)); - }, - [dispatch], - ); - - return enroll; -}; - -export default useIsEnrolled; diff --git a/src/pages/learning-plans/[slug]/lessons/[lessonSlugOrId]/index.tsx b/src/pages/learning-plans/[slug]/lessons/[lessonSlugOrId]/index.tsx index fdcc8a6dbd..7b56c920c6 100644 --- a/src/pages/learning-plans/[slug]/lessons/[lessonSlugOrId]/index.tsx +++ b/src/pages/learning-plans/[slug]/lessons/[lessonSlugOrId]/index.tsx @@ -1,15 +1,20 @@ +import { useCallback } from 'react'; + import { NextPage } from 'next'; import { useRouter } from 'next/router'; import LessonContent from '@/components/Course/LessonContent'; -import NotEnrolledNotice from '@/components/Course/NotEnrolledNotice'; import DataFetcher from '@/components/DataFetcher'; import Spinner from '@/dls/Spinner/Spinner'; +import useEnrollUser from '@/hooks/auth/useEnrollUser'; +import useMutateWithoutRevalidation from '@/hooks/useMutateWithoutRevalidation'; import layoutStyles from '@/pages/index.module.scss'; import ApiErrorMessage from '@/types/ApiErrorMessage'; -import { Lesson } from '@/types/auth/Course'; +import { Course, Lesson } from '@/types/auth/Course'; +import EnrollmentMethod from '@/types/auth/EnrollmentMethod'; import { privateFetcher } from '@/utils/auth/api'; -import { makeGetLessonUrl } from '@/utils/auth/apiPaths'; +import { makeGetCourseUrl, makeGetLessonUrl } from '@/utils/auth/apiPaths'; +import { getLessonNavigationUrl, getLoginNavigationUrl } from '@/utils/navigation'; interface Props { hasError?: boolean; @@ -19,16 +24,36 @@ interface Props { const LessonPage: NextPage = () => { const router = useRouter(); const { slug, lessonSlugOrId } = router.query; + const enrollUserInCourse = useEnrollUser(); + const mutate = useMutateWithoutRevalidation(); const renderError = (error: any) => { if (error?.message === ApiErrorMessage.CourseNotEnrolled) { - return ( - + router.push( + getLoginNavigationUrl(getLessonNavigationUrl(slug as string, lessonSlugOrId as string)), ); } return undefined; }; + const handleFetchSuccess = useCallback( + async (lesson: Lesson) => { + if (!lesson?.course || lesson.course.isUserEnrolled) { + return; + } + + const { success } = await enrollUserInCourse(lesson.course.id, EnrollmentMethod.AUTOMATIC); + + if (success) { + mutate(makeGetCourseUrl(slug as string), (currentCourse: Course) => ({ + ...currentCourse, + isUserEnrolled: true, + })); + } + }, + [enrollUserInCourse, mutate, slug], + ); + const bodyRenderer = ((lesson: Lesson) => { if (lesson) { return ( @@ -54,6 +79,7 @@ const LessonPage: NextPage = () => { fetcher={privateFetcher} renderError={renderError} render={bodyRenderer} + onFetchSuccess={handleFetchSuccess} />
); diff --git a/src/redux/slices/guestEnrollment.ts b/src/redux/slices/guestEnrollment.ts deleted file mode 100644 index 1fdf7f3291..0000000000 --- a/src/redux/slices/guestEnrollment.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; - -import { RootState } from '../RootState'; - -import SliceName from '@/redux/types/SliceName'; - -export type GuestEnrollmentState = { - enrolledCourses: string[]; -}; - -const initialState: GuestEnrollmentState = { - enrolledCourses: [], -}; - -export const guestEnrollmentSlice = createSlice({ - name: SliceName.GUEST_ENROLLMENT, - initialState, - reducers: { - enrollInCourse: (state: GuestEnrollmentState, action: PayloadAction) => { - if (!state.enrolledCourses.includes(action.payload)) { - state.enrolledCourses.push(action.payload); - } - }, - }, -}); - -export const { enrollInCourse } = guestEnrollmentSlice.actions; - -export const selectIsGuestEnrolledInCourse = (state: RootState, courseId: string) => - state.guestEnrollment.enrolledCourses.includes(courseId); - -export default guestEnrollmentSlice.reducer; diff --git a/src/redux/store.ts b/src/redux/store.ts index 238d48d323..a73c0bfd3c 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -21,7 +21,6 @@ import commandBarPersistConfig from './slices/CommandBar/persistConfig'; import commandBar from './slices/CommandBar/state'; import defaultSettings from './slices/defaultSettings'; import fundraisingBanner from './slices/fundraisingBanner'; -import guestEnrollment from './slices/guestEnrollment'; import mediaMaker from './slices/mediaMaker'; import microphone from './slices/microphone'; import navbar from './slices/navbar'; @@ -72,7 +71,6 @@ const persistConfig = { SliceName.REVELATION_ORDER, SliceName.ONBOARDING, SliceName.MEDIA_MAKER, - SliceName.GUEST_ENROLLMENT, ], // Reducers defined here will be have their values saved in local storage and persist across sessions. See: https://github.com/rt2zz/redux-persist#blacklist--whitelist }; @@ -104,7 +102,6 @@ export const rootReducer = combineReducers({ onboarding, mediaMaker, microphone, - guestEnrollment, }); const persistedReducer = persistReducer(persistConfig, rootReducer); diff --git a/src/redux/types/SliceName.ts b/src/redux/types/SliceName.ts index d9780f0289..073e4e503e 100644 --- a/src/redux/types/SliceName.ts +++ b/src/redux/types/SliceName.ts @@ -28,7 +28,6 @@ enum SliceName { ONBOARDING = 'onboarding', MEDIA_MAKER = 'mediaMaker', MICROPHONE = 'microphone', - GUEST_ENROLLMENT = 'guestEnrollment', } export default SliceName; diff --git a/src/utils/auth/api.ts b/src/utils/auth/api.ts index 8b00a00915..ccf8aef14c 100644 --- a/src/utils/auth/api.ts +++ b/src/utils/auth/api.ts @@ -97,6 +97,7 @@ import { } from '@/utils/auth/apiPaths'; import { getAdditionalHeaders } from '@/utils/headers'; import CompleteAnnouncementRequest from 'types/auth/CompleteAnnouncementRequest'; +import EnrollmentMethod from 'types/auth/EnrollmentMethod'; import { GetBookmarkCollectionsIdResponse } from 'types/auth/GetBookmarksByCollectionId'; import PreferenceGroup from 'types/auth/PreferenceGroup'; import RefreshToken from 'types/auth/RefreshToken'; @@ -448,9 +449,18 @@ export const getBookmarksByCollectionId = async ( return privateFetcher(makeGetBookmarkByCollectionId(collectionId, queryParams)); }; -export const enrollUser = async (courseId: string): Promise<{ success: boolean }> => +type EnrollUserParams = { + courseId: string; + enrollmentMethod: EnrollmentMethod; +}; + +export const enrollUser = async ({ + courseId, + enrollmentMethod, +}: EnrollUserParams): Promise<{ success: boolean }> => postRequest(makeEnrollUserUrl(), { courseId, + enrollmentMethod, }); export const postCourseFeedback = async ({ diff --git a/tests/integration/learning-plans/guest-access.spec.ts b/tests/integration/learning-plans/guest-access.spec.ts index 6a54085e16..09bcecfda0 100644 --- a/tests/integration/learning-plans/guest-access.spec.ts +++ b/tests/integration/learning-plans/guest-access.spec.ts @@ -1,7 +1,6 @@ import { test, expect, type Page, type BrowserContext } from '@playwright/test'; const LP_URL = '/learning-plans/the-rescuer-powerful-lessons-in-surah-al-mulk'; -const FIRST_LESSON_URL = `${LP_URL}/lessons/the-king-of-all-kings`; const clearState = async (context: BrowserContext, page: Page): Promise => { await context.clearCookies(); @@ -9,151 +8,118 @@ const clearState = async (context: BrowserContext, page: Page): Promise => await page.evaluate(() => localStorage.clear()); }; -const enrollGuest = async (page: Page): Promise => { - await page - .getByRole('button', { name: /enroll/i }) - .first() - .click(); +/** + * Click the "Start here" button and wait for navigation to lesson page + */ +const clickStartHereButton = async (page: Page): Promise => { + const startHereButton = page.getByRole('button', { name: /start\s+here/i }).first(); + await startHereButton.click(); await page.waitForURL(/\/learning-plans\/.*\/lessons\/.+/); }; + +/** + * Click "Start here" and return to the course page + */ +const clickStartHereAndReturnToCoursePage = async (page: Page): Promise => { + await clickStartHereButton(page); + await page.goto(LP_URL, { waitUntil: 'networkidle' }); +}; + /** - * Scroll to the bottom to ensure lazy-rendered buttons are in view - * @param {Page} page - Playwright page instance - * @returns {Promise} resolves after scrolling is done + * Scroll to bottom of page to ensure lazy-rendered content is visible */ -const scrollToEnd = async (page: Page): Promise => { +const scrollToPageBottom = async (page: Page): Promise => { await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); await page.waitForLoadState('networkidle'); + // Scroll twice to ensure all lazy content is loaded await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); await page.waitForLoadState('networkidle'); }; -const enrollAndReturn = async (page: Page): Promise => { - await enrollGuest(page); - await page.goto(LP_URL, { waitUntil: 'networkidle' }); -}; - -const expectNotEnrolledToast = async (page: Page): Promise => { - const toast = page.getByRole('alert').filter({ hasText: /you are not enrolled/i }); - await expect(toast).toBeVisible({ timeout: 10000 }); +/** + * Open the syllabus tab + */ +const openSyllabusTab = async (page: Page): Promise => { + const syllabusTab = page.getByRole('button', { name: /syllabus/i }).first(); + await syllabusTab.scrollIntoViewIfNeeded(); + await expect(syllabusTab).toBeVisible({ timeout: 10000 }); + await syllabusTab.click(); }; /** - * Get enrolled courses from Redux persist storage - * Redux persist stores data under 'persist:root' key with nested JSON structure - * @param {Page} page - Playwright page instance - * @returns {Promise} resolves with the enrolled courses + * Test Suites */ -const getStoredCourses = (page: Page): Promise => - page.evaluate(() => { - try { - const persistRoot = localStorage.getItem('persist:root'); - if (!persistRoot) return []; - - const rootState = JSON.parse(persistRoot); - const rawGuestEnrollment = rootState?.guestEnrollment; - let guestEnrollmentState: any = {}; - - if (typeof rawGuestEnrollment === 'string') { - try { - guestEnrollmentState = JSON.parse(rawGuestEnrollment); - } catch { - guestEnrollmentState = {}; - } - } else if (rawGuestEnrollment && typeof rawGuestEnrollment === 'object') { - guestEnrollmentState = rawGuestEnrollment; - } - - const enrolled = guestEnrollmentState?.enrolledCourses; - return Array.isArray(enrolled) ? enrolled.filter((id: any) => typeof id === 'string') : []; - } catch { - return []; - } + +test.describe('Guest Access - Start Here Button', () => { + test.beforeEach(async ({ page, context }) => { + await clearState(context, page); }); -const setupNonEnrolled = async (page: Page): Promise => { - await page.goto(LP_URL); - await page.evaluate(() => localStorage.clear()); - await page.reload({ waitUntil: 'networkidle' }); -}; + test('should display "Start here" button for guest users', async ({ page }) => { + const startHereButton = page.getByRole('button', { name: /start\s+here/i }).first(); + await expect(startHereButton).toBeVisible(); + }); -test.describe('Guest Enrollment', () => { - test.beforeEach(async ({ page, context }) => clearState(context, page)); + test('should redirect guest to first lesson when clicking "Start here"', async ({ page }) => { + await clickStartHereButton(page); - test('should show enroll button', async ({ page }) => { - await expect(page.getByRole('button', { name: /enroll/i }).first()).toBeVisible(); + // Verify navigation to lesson page + await expect(page).toHaveURL(/\/lessons\//); }); +}); - test('should redirect to lesson and save to localStorage', async ({ page }) => { - await enrollGuest(page); - await expect(page).toHaveURL(/.*\/lessons\/.*/); - expect((await getStoredCourses(page)).length).toBeGreaterThan(0); +test.describe('Guest User Behavior', () => { + test.beforeEach(async ({ page, context }) => { + await clearState(context, page); }); - test('should persist after reload', async ({ page }) => { - await enrollGuest(page); - await page.reload(); - expect((await getStoredCourses(page)).length).toBeGreaterThan(0); + test('should show "Start here" button after accessing a lesson', async ({ page }) => { + // Navigate to lesson and return to course page + await clickStartHereAndReturnToCoursePage(page); + + // Guests are not enrolled, so they should still see "Start here" + const startHereButton = page.getByRole('button', { name: /start\s+here/i }).first(); + await expect(startHereButton).toBeVisible(); }); }); -test.describe('Post-Enrollment Navigation', () => { - test.beforeEach(async ({ page, context }) => clearState(context, page)); - - test('should show start learning button', async ({ page }) => { - await enrollAndReturn(page); - await expect( - page.getByRole('button', { name: /(start|continue).learning/i }).first(), - ).toBeVisible(); +test.describe('Guest User Limitations', () => { + test.beforeEach(async ({ page, context }) => { + await clearState(context, page); }); - test('should navigate from start learning button', async ({ page }) => { - await enrollAndReturn(page); - await page - .getByRole('button', { name: /(start|continue).learning/i }) - .first() - .click(); - await page.waitForURL(/\/lessons\//); - }); -}); + test('should redirect to login when guest tries to mark lesson as complete', async ({ page }) => { + // Navigate to lesson + await clickStartHereButton(page); + + // Scroll to bottom to ensure mark complete button is visible + await scrollToPageBottom(page); -test.describe('Login Redirects', () => { - test.beforeEach(async ({ page, context }) => clearState(context, page)); + // Try to mark lesson as complete + const markCompleteButton = page.getByRole('button', { name: /mark\s+as\s+completed/i }).first(); + await expect(markCompleteButton).toBeVisible({ timeout: 10000 }); + await markCompleteButton.click(); - test('should redirect to login on mark complete', async ({ page }) => { - await enrollGuest(page); - await scrollToEnd(page); - const markComplete = page.getByRole('button', { name: /mark\s+as\s+completed/i }).first(); - await expect(markComplete).toBeVisible({ timeout: 10000 }); - await markComplete.click(); + // Should redirect to login or signup page await page.waitForURL(/\/(login|signup)/); }); }); -test.describe('Access Control', () => { - test.beforeEach(async ({ context }) => context.clearCookies()); - - test('should show not enrolled message for direct lesson access', async ({ page }) => { - await page.goto(LP_URL); - await page.evaluate(() => localStorage.clear()); - await Promise.all([ - page.goto(FIRST_LESSON_URL, { waitUntil: 'networkidle' }), - expectNotEnrolledToast(page), - ]); +test.describe('Syllabus Navigation', () => { + test.beforeEach(async ({ page, context }) => { + await clearState(context, page); }); - test('should show toast when clicking syllabus lesson', async ({ page }) => { - await setupNonEnrolled(page); - // Open Syllabus tab (rendered as button) - const syllabusTab = page.getByRole('button', { name: /syllabus/i }).first(); - await syllabusTab.scrollIntoViewIfNeeded(); - await expect(syllabusTab).toBeVisible({ timeout: 10000 }); - await syllabusTab.click(); - const firstSyllabusLink = page.getByText(/\bday\s+\d+/i).first(); - // make it click on the button next to the text if exists - const button = firstSyllabusLink.locator('..').getByRole('button'); - if (await button.count()) { - await Promise.all([button.click(), expectNotEnrolledToast(page)]); - } + test('should navigate to lesson when clicking on syllabus lesson', async ({ page }) => { + // Open syllabus tab + await openSyllabusTab(page); + + // Click on first lesson in syllabus + const firstLesson = page.getByText(/The King of All Kings/i).first(); + await expect(firstLesson).toBeVisible(); + await firstLesson.click(); + + // Verify navigation to lesson page + await expect(page).toHaveURL(/\/lessons\//); }); }); diff --git a/types/auth/EnrollmentMethod.ts b/types/auth/EnrollmentMethod.ts new file mode 100644 index 0000000000..83deb8e203 --- /dev/null +++ b/types/auth/EnrollmentMethod.ts @@ -0,0 +1,6 @@ +enum EnrollmentMethod { + MANUAL = 'MANUAL', + AUTOMATIC = 'AUTOMATIC', +} + +export default EnrollmentMethod;