diff --git a/apps/web/components/admin/products/editor/section.tsx b/apps/web/components/admin/products/editor/section.tsx index 037dd8bd1..3d9b5eebb 100644 --- a/apps/web/components/admin/products/editor/section.tsx +++ b/apps/web/components/admin/products/editor/section.tsx @@ -87,7 +87,7 @@ function SectionEditor({ ); setEmailSubject( group.drip?.email?.subject || - `A new section is now avaiable in ${course.title}`, + `A new section is now available in ${course.title}`, ); setStatus( typeof group.drip?.status === "boolean" diff --git a/apps/web/components/public/article.tsx b/apps/web/components/public/article.tsx index 4d1dcdb2c..f89e740a3 100644 --- a/apps/web/components/public/article.tsx +++ b/apps/web/components/public/article.tsx @@ -1,42 +1,42 @@ import React from "react"; -import { formattedLocaleDate, isEnrolled } from "../../ui-lib/utils"; +import { formattedLocaleDate } from "../../ui-lib/utils"; import { connect } from "react-redux"; import { - PriceTag, Image, Link, TextRenderer, TextEditorEmptyDoc, - Button2, } from "@courselit/components-library"; -import { ENROLL_BUTTON_TEXT, FREE_COST } from "../../ui-config/strings"; import { AppState } from "@courselit/state-management"; import { Course, Profile, SiteInfo } from "@courselit/common-models"; import { UIConstants as constants } from "@courselit/common-models"; -import { checkPermission } from "@courselit/utils"; const { permissions } = constants; interface ArticleProps { course: Course; - options: ArticleOptionsProps; + options?: ArticleOptionsProps; profile: Profile; siteInfo: SiteInfo; } interface ArticleOptionsProps { showAttribution?: boolean; - showEnrollmentArea?: boolean; + hideTitle?: boolean; } const Article = (props: ArticleProps) => { - const { course, options, profile } = props; + const { course, options = { hideTitle: false } } = props; return (
-

{course.title}

- {options.showAttribution && ( + {!options?.hideTitle && ( +

+ {course.title} +

+ )} + {options?.showAttribution && (

{course.creatorName}

@@ -59,34 +59,6 @@ const Article = (props: ArticleProps) => {
)} - {options.showEnrollmentArea && - (profile.fetched - ? !isEnrolled(course.courseId, profile) && - checkPermission(profile.permissions, [ - permissions.enrollInCourse, - ]) - : true) && ( -
-

{profile.fetched}

-
- - - {ENROLL_BUTTON_TEXT} - -
-
- )}
{ const { status } = useSession(); const [lesson, setLesson] = useState(); + const [error, setError] = useState(); const router = useRouter(); useEffect(() => { @@ -94,6 +93,8 @@ const LessonViewer = ({ }, [status]); useEffect(() => { + setError(undefined); + setLesson(undefined); if (lessonId) { loadLesson(lessonId); } @@ -135,12 +136,7 @@ const LessonViewer = ({ setLesson(response.lesson); } } catch (err: any) { - if (err.message === "You are not enrolled in the course") { - setLesson(undefined); - return; - } - - dispatch(setAppMessage(new AppMessage(err.message))); + setError(err.message); } finally { dispatch(networkAction(false)); } @@ -181,191 +177,173 @@ const LessonViewer = ({ } }; - if (!lesson) { - return ( -
-

- {NOT_ENROLLED_HEADER} -

-

{ENROLL_IN_THE_COURSE}

- - {ENROLL_BUTTON_TEXT} - -
- ); - } - return (
- - - {lesson.title} | {siteinfo.title} - - - - -
-
-
+
+ {error && ( +

- {lesson.title} + {NOT_ENROLLED_HEADER}

-
- {String.prototype.toUpperCase.call(LESSON_TYPE_VIDEO) === - lesson.type && ( -
-
+ )} + {lesson && !error && ( + <> +
+

+ {lesson.title} +

+
+ {String.prototype.toUpperCase.call( + LESSON_TYPE_VIDEO, + ) === lesson.type && ( +
+ + - Your browser does not support the video tag. - - -
- )} - {String.prototype.toUpperCase.call(LESSON_TYPE_AUDIO) === - lesson.type && ( -
-
)} - {String.prototype.toUpperCase.call(LESSON_TYPE_EMBED) === - lesson.type && - lesson.content && ( - + {String.prototype.toUpperCase.call(LESSON_TYPE_PDF) === + lesson.type && ( +
+ + +
)} - {String.prototype.toUpperCase.call(LESSON_TYPE_QUIZ) === - lesson.type && - lesson.content && ( - + {String.prototype.toUpperCase.call(LESSON_TYPE_TEXT) === + lesson.type && + lesson.content && ( + + )} + {String.prototype.toUpperCase.call( + LESSON_TYPE_EMBED, + ) === lesson.type && + lesson.content && ( + + )} + {String.prototype.toUpperCase.call(LESSON_TYPE_QUIZ) === + lesson.type && + lesson.content && ( + + )} + {String.prototype.toUpperCase.call(LESSON_TYPE_FILE) === + lesson.type && ( +
+ + + + {lesson.media?.originalFileName} + + +
)} - {String.prototype.toUpperCase.call(LESSON_TYPE_FILE) === - lesson.type && ( -
- - - - {lesson.media?.originalFileName} + + )} +
+ {lesson && isEnrolled(lesson.courseId, profile) && ( +
+
+ {!lesson.prevLesson && ( + + + + {COURSE_PROGRESS_INTRO} -
- )} - - {isEnrolled(lesson.courseId, profile) && ( -
-
- {!lesson.prevLesson && ( - - - - {COURSE_PROGRESS_INTRO} - - - )} - {lesson.prevLesson && ( - + - - {COURSE_PROGRESS_PREV} - - - )} -
- - {lesson.nextLesson ? ( -
- {COURSE_PROGRESS_NEXT} -
- ) : ( - COURSE_PROGRESS_FINISH - )} -
+ {COURSE_PROGRESS_PREV} + + + )}
- )} -
+ + {lesson.nextLesson ? ( +
+ {COURSE_PROGRESS_NEXT} +
+ ) : ( + COURSE_PROGRESS_FINISH + )} +
+
+ )}
); }; diff --git a/apps/web/components/public/scaffold.tsx b/apps/web/components/public/scaffold.tsx index 79b736d2d..f905e9cc9 100644 --- a/apps/web/components/public/scaffold.tsx +++ b/apps/web/components/public/scaffold.tsx @@ -3,22 +3,30 @@ import Header from "./base-layout/header"; import { connect } from "react-redux"; import { useRouter } from "next/router"; import { AppState } from "@courselit/state-management"; -import { Modal } from "@courselit/components-library"; +import { Chip, Modal } from "@courselit/components-library"; import AppToast from "@components/app-toast"; export interface ComponentScaffoldMenuItem { label: string; + badge?: string; href?: string; icon?: ReactNode; iconPlacementRight?: boolean; } +export type Divider = "divider"; + interface ComponentScaffoldProps { - items: ComponentScaffoldMenuItem[]; + items: (ComponentScaffoldMenuItem | Divider)[]; children: ReactNode; + drawerWidth?: number; } -const ComponentScaffold = ({ items, children }: ComponentScaffoldProps) => { +const ComponentScaffold = ({ + items, + children, + drawerWidth = 240, +}: ComponentScaffoldProps) => { const [open, setOpen] = useState(false); const router = useRouter(); @@ -28,53 +36,60 @@ const ComponentScaffold = ({ items, children }: ComponentScaffoldProps) => { const drawer = (
    - {items.map((item: ComponentScaffoldMenuItem, index: number) => - item.href ? ( -
  • { - setOpen(false); - navigateTo(item.href as string); - }} - style={{ - backgroundColor: - router.asPath === item.href - ? "#d6d6d6" - : "inherit", - }} - className={`flex items-center px-2 py-3 hover:!bg-slate-100 cursor-pointer ${ - item.icon && item.iconPlacementRight - ? "justify-between" - : "justify-start" - }`} - > - {item.icon && !item.iconPlacementRight && ( -
    {item.icon}
    - )} -

    {item.label as string}

    - {item.icon && item.iconPlacementRight && ( -
    {item.icon}
    - )} -
  • - ) : ( -
  • - {item.label as string} -
  • - ), + {items.map( + (item: ComponentScaffoldMenuItem | Divider, index: number) => + item === "divider" ? ( +
    + ) : item.href ? ( +
  • { + setOpen(false); + navigateTo(item.href as string); + }} + style={{ + backgroundColor: + router.asPath === item.href + ? "#d6d6d6" + : "inherit", + }} + className={`flex items-center px-2 py-3 hover:!bg-slate-200 cursor-pointer ${ + item.icon && item.iconPlacementRight + ? "justify-between" + : "justify-start" + }`} + > + {item.icon && !item.iconPlacementRight && ( +
    {item.icon}
    + )} +

    {item.label as string}

    + {item.icon && item.iconPlacementRight && ( +
    {item.icon}
    + )} +
  • + ) : ( +
  • + {item.label as string} + {item.badge && {item.badge}} +
  • + ), )}
); return (
-
+
setOpen(true)} />
-
+
{drawer}
diff --git a/apps/web/config/strings.ts b/apps/web/config/strings.ts index d78ef96a8..504bcfc96 100644 --- a/apps/web/config/strings.ts +++ b/apps/web/config/strings.ts @@ -22,6 +22,7 @@ export const responses = { content_cannot_be_null: "Content cannot be empty", media_id_cannot_be_null: "Media Id cannot be empty", item_not_found: "Item not found", + drip_not_released: "This section is not yet released for you", not_a_creator: "You do not have rights to perform this action", course_not_empty: "Delete all lessons before trying deleting the course", invalid_offset: "Invalid offset", diff --git a/apps/web/graphql/courses/logic.ts b/apps/web/graphql/courses/logic.ts index ddd0e6086..7d7d43706 100644 --- a/apps/web/graphql/courses/logic.ts +++ b/apps/web/graphql/courses/logic.ts @@ -86,10 +86,6 @@ export const getCourse = async ( throw new Error(responses.item_not_found); } - const accessibleGroups = course.groups.filter( - (group) => !group.drip || !group.drip?.status, - ); - if (ctx.user && !asGuest) { const isOwner = checkPermission(ctx.user.permissions, [ @@ -98,20 +94,6 @@ export const getCourse = async ( if (isOwner) { return course; - } else { - const userPurchase = ctx.user.purchases.find( - (purchase) => purchase.courseId === course.courseId, - ); - if (userPurchase) { - for (let accessibleGroup of userPurchase.accessibleGroups) { - const groupWithDrip = course.groups.find( - (group) => group.id === accessibleGroup, - ); - if (groupWithDrip) { - accessibleGroups.push(groupWithDrip); - } - } - } } } @@ -129,7 +111,7 @@ export const getCourse = async ( ); (course as any).firstLesson = nextLesson; } - course.groups = accessibleGroups; + // course.groups = accessibleGroups; return course; } else { throw new Error(responses.item_not_found); diff --git a/apps/web/graphql/lessons/helpers.ts b/apps/web/graphql/lessons/helpers.ts index a42e01afe..a114771e5 100644 --- a/apps/web/graphql/lessons/helpers.ts +++ b/apps/web/graphql/lessons/helpers.ts @@ -180,3 +180,34 @@ export function evaluateLessonResult(content: Quiz, answers: number[][]) { score: userScoreInPercentage, }; } + +export async function isPartOfDripGroup( + lesson: Lesson, + domain: mongoose.Types.ObjectId, +) { + const course = await CourseModel.findOne({ + courseId: lesson.courseId, + domain, + }); + if (!course) { + throw new Error(responses.item_not_found); + } + const group = course.groups.find((group) => group._id === lesson.groupId); + if (group.drip && group.drip.status) { + return true; + } + + return false; +} + +export function removeCorrectAnswersProp(lesson: Lesson) { + if (lesson.content && lesson.content.questions) { + for (let question of lesson.content.questions as any[]) { + question.options = question.options.map((option: any) => ({ + text: option.text, + })); + } + } + + return lesson; +} diff --git a/apps/web/graphql/lessons/logic.ts b/apps/web/graphql/lessons/logic.ts index 0c5bdc629..927e8d82b 100644 --- a/apps/web/graphql/lessons/logic.ts +++ b/apps/web/graphql/lessons/logic.ts @@ -11,7 +11,9 @@ import CourseModel from "../../models/Course"; import { evaluateLessonResult, getPrevNextCursor, + isPartOfDripGroup, lessonValidator, + removeCorrectAnswersProp, } from "./helpers"; import constants from "../../config/constants"; import GQLContext from "../../models/GQLContext"; @@ -22,7 +24,6 @@ import { Progress, Quiz } from "@courselit/common-models"; import LessonEvaluation from "../../models/LessonEvaluation"; import { checkPermission } from "@courselit/utils"; import { recordActivity } from "../../lib/record-activity"; -import mongoose from "mongoose"; const { permissions, quiz } = constants; @@ -83,13 +84,18 @@ export const getLessonDetails = async (id: string, ctx: GQLContext) => { } if (await isPartOfDripGroup(lesson, ctx.subdomain._id)) { + if (!ctx.user) { + throw new Error(responses.drip_not_released); + } + + const userProgress = ctx.user.purchases.find( + (x) => x.courseId === lesson.courseId, + ); if ( - !ctx.user || - ctx.user.purchases - .find((x) => x.courseId === lesson.courseId) - .accessibleGroups.indexOf(lesson.groupId) === -1 + !userProgress || + userProgress.accessibleGroups.indexOf(lesson.groupId) === -1 ) { - throw new Error(responses.item_not_found); + throw new Error(responses.drip_not_released); } } @@ -108,37 +114,6 @@ export const getLessonDetails = async (id: string, ctx: GQLContext) => { return lesson; }; -async function isPartOfDripGroup( - lesson: Lesson, - domain: mongoose.Types.ObjectId, -) { - const course = await CourseModel.findOne({ - courseId: lesson.courseId, - domain, - }); - if (!course) { - throw new Error(responses.item_not_found); - } - const group = course.groups.find((group) => group._id === lesson.groupId); - if (group.drip && group.drip.status) { - return true; - } - - return false; -} - -function removeCorrectAnswersProp(lesson: Lesson) { - if (lesson.content && lesson.content.questions) { - for (let question of lesson.content.questions as any[]) { - question.options = question.options.map((option: any) => ({ - text: option.text, - })); - } - } - - return lesson; -} - export type LessonWithStringContent = Omit & { content: string; }; @@ -295,6 +270,8 @@ export const markLessonCompleted = async ( lessonId: string, ctx: GQLContext, ) => { + checkIfAuthenticated(ctx); + const lesson = await LessonModel.findOne({ lessonId }); if (!lesson) { throw new Error(responses.item_not_found); @@ -308,6 +285,16 @@ export const markLessonCompleted = async ( throw new Error(responses.not_enrolled); } + if (await isPartOfDripGroup(lesson, ctx.subdomain._id)) { + const groupIsNotInAccessibleGroups = + ctx.user.purchases + .find((x) => x.courseId === lesson.courseId) + .accessibleGroups.indexOf(lesson.groupId) === -1; + if (groupIsNotInAccessibleGroups) { + throw new Error(responses.drip_not_released); + } + } + if (lesson.type === quiz) { const lessonEvaluations = await LessonEvaluation.countDocuments({ pass: true, diff --git a/apps/web/graphql/users/types.ts b/apps/web/graphql/users/types.ts index 13d311760..8b2bd3061 100644 --- a/apps/web/graphql/users/types.ts +++ b/apps/web/graphql/users/types.ts @@ -14,6 +14,7 @@ const progress = new GraphQLObjectType({ fields: { courseId: { type: new GraphQLNonNull(GraphQLString) }, completedLessons: { type: new GraphQLList(GraphQLString) }, + accessibleGroups: { type: new GraphQLList(GraphQLString) }, }, }); diff --git a/apps/web/pages/course/[slug]/[id]/[lesson].tsx b/apps/web/pages/course/[slug]/[id]/[lesson].tsx index 8473d0ded..76c04d466 100644 --- a/apps/web/pages/course/[slug]/[id]/[lesson].tsx +++ b/apps/web/pages/course/[slug]/[id]/[lesson].tsx @@ -1,25 +1,38 @@ import { useRouter } from "next/router"; import RouteBasedComponentScaffold from "../../../../components/public/scaffold"; import LessonViewer from "../../../../components/public/lesson-viewer"; -import { getServerSideProps, generateSideBarItems, CourseFrontend } from "."; -import type { Address, Lesson, Profile } from "@courselit/common-models"; +import { + getServerSideProps, + generateSideBarItems, + CourseFrontend, + formatCourse, + graphQuery, +} from "."; +import type { + Address, + Lesson, + Profile, + SiteInfo, +} from "@courselit/common-models"; import { AppState } from "@courselit/state-management"; import { connect } from "react-redux"; import { useEffect, useState } from "react"; import { FetchBuilder } from "@courselit/utils"; -import { sortCourseGroups } from "@ui-lib/utils"; +import Head from "next/head"; interface LessonProps { course: CourseFrontend; profile: Profile; address: Address; + siteInfo: SiteInfo; } const Lesson = (props: LessonProps) => { - const { profile, address } = props; + const { profile, address, siteInfo } = props; const [course, setCourse] = useState(props.course); const router = useRouter(); const { lesson } = router.query; + const siteImage = course.featuredImage || siteInfo.logo; useEffect(() => { if (profile.fetched) { @@ -28,42 +41,12 @@ const Lesson = (props: LessonProps) => { }, [profile]); const loadCourse = async () => { - const graphQuery = ` - query { - post: getCourse(id: "${props.course.courseId}") { - title, - description, - featuredImage { - file, - caption - }, - updatedAt, - creatorName, - creatorId, - slug, - cost, - courseId, - groups { - id, - name, - rank, - lessonsOrder - }, - lessons { - lessonId, - title, - requiresEnrollment, - courseId, - groupId, - }, - tags, - firstLesson - } - } - `; const fetch = new FetchBuilder() .setUrl(`${address.backend}/api/graph`) - .setPayload(graphQuery) + .setPayload({ + query: graphQuery, + variables: { id: props.course.courseId }, + }) .setIsGraphQLEndpoint(true) .build(); @@ -71,33 +54,7 @@ const Lesson = (props: LessonProps) => { const response = await fetch.exec(); const { post } = response; if (post) { - const lessonsOrderedByGroups: Record = {}; - for (const group of sortCourseGroups(post)) { - lessonsOrderedByGroups[group.name] = post.lessons - .filter((lesson: Lesson) => lesson.groupId === group.id) - .sort( - (a: any, b: any) => - group.lessonsOrder.indexOf(a.lessonId) - - group.lessonsOrder.indexOf(b.lessonId), - ); - } - - const courseGroupedByLessons: CourseFrontend = { - title: post.title, - description: post.description, - featuredImage: post.featuredImage, - updatedAt: post.updatedAt, - creatorName: post.creatorName, - creatorId: post.creatorId, - slug: post.slug, - cost: post.cost, - courseId: post.courseId, - groupOfLessons: lessonsOrderedByGroups, - tags: post.tags, - firstLesson: post.firstLesson, - }; - - setCourse(courseGroupedByLessons); + setCourse(formatCourse(post)); } } catch (err: any) {} }; @@ -107,17 +64,29 @@ const Lesson = (props: LessonProps) => { } return ( - - {lesson && ( - - )} - {/*
-
-
-
*/} -
+ <> + + {course.title} + + + + + + + {lesson && ( + + )} + + ); }; diff --git a/apps/web/pages/course/[slug]/[id]/index.tsx b/apps/web/pages/course/[slug]/[id]/index.tsx index c959d90c8..9118f4340 100644 --- a/apps/web/pages/course/[slug]/[id]/index.tsx +++ b/apps/web/pages/course/[slug]/[id]/index.tsx @@ -9,9 +9,11 @@ import { import { ArrowRight, CheckCircled, Circle, Lock } from "@courselit/icons"; import { COURSE_PROGRESS_START, + ENROLL_BUTTON_TEXT, + FREE_COST, SIDEBAR_TEXT_COURSE_ABOUT, } from "../../../../ui-config/strings"; -import { FetchBuilder } from "@courselit/utils"; +import { FetchBuilder, checkPermission } from "@courselit/utils"; import { AppState, AppDispatch, @@ -20,21 +22,40 @@ import { import { Address, Course, + Group, Lesson, Profile, SiteInfo, + Constants, + UIConstants, } from "@courselit/common-models"; import RouteBasedComponentScaffold, { ComponentScaffoldMenuItem, + Divider, } from "@components/public/scaffold"; import Article from "@components/public/article"; -import { Link, Button2 } from "@courselit/components-library"; +import { Link, Button2, PriceTag } from "@courselit/components-library"; import { useSession } from "next-auth/react"; import { useEffect, useState } from "react"; -import { useRouter } from "next/router"; +const { permissions } = UIConstants; -export type CourseFrontend = Partial & { - groupOfLessons: Record; +type GroupWithLessons = Group & { lessons: Lesson[] }; +type CourseWithoutGroups = Pick< + Course, + | "title" + | "description" + | "featuredImage" + | "updatedAt" + | "creatorName" + | "creatorId" + | "slug" + | "cost" + | "courseId" + | "tags" +>; + +export type CourseFrontend = CourseWithoutGroups & { + groups: GroupWithLessons[]; firstLesson: string; }; @@ -47,53 +68,178 @@ interface CourseProps { dispatch: AppDispatch; } +export const graphQuery = ` + query ($id: String!) { + post: getCourse(id: $id) { + title, + description, + featuredImage { + file, + caption + }, + updatedAt, + creatorName, + creatorId, + slug, + cost, + courseId, + groups { + id, + name, + rank, + lessonsOrder, + drip { + status, + type, + delayInMillis, + dateInUTC + } + }, + lessons { + lessonId, + title, + requiresEnrollment, + courseId, + groupId, + }, + tags, + firstLesson + } + } + `; + +export function isGroupAccessibleToUser( + course: CourseFrontend, + profile: Profile, + group: GroupWithLessons, +): boolean { + if (!group.drip || !group.drip.status) return true; + + if (!Array.isArray(profile.purchases)) return false; + + for (const purchase of profile.purchases) { + if (purchase.courseId === course.courseId) { + if (Array.isArray(purchase.accessibleGroups)) { + if (purchase.accessibleGroups.includes(group.id)) { + return true; + } + } + } + } + + return false; +} + export function generateSideBarItems( course: CourseFrontend, profile: Profile, -): ComponentScaffoldMenuItem[] { +): (ComponentScaffoldMenuItem | Divider)[] { if (!course) return []; - const lessons: ComponentScaffoldMenuItem[] = [ + const items: (ComponentScaffoldMenuItem | Divider)[] = [ { label: SIDEBAR_TEXT_COURSE_ABOUT, href: `/course/${course.slug}/${course.courseId}`, }, ]; - for (const group of Object.keys(course.groupOfLessons)) { - lessons.push({ - label: group, - icon: undefined, + + let lastGroupDripDateInMillis = Date.now(); + + for (const group of course.groups) { + let availableLabel = ""; + if (group.drip && group.drip.status) { + if ( + group.drip.type === + Constants.dripType[0].split("-")[0].toUpperCase() + ) { + const delayInMillis = + group.drip.delayInMillis + lastGroupDripDateInMillis; + const daysUntilAvailable = Math.ceil( + (delayInMillis - Date.now()) / 86400000, + ); + availableLabel = + daysUntilAvailable && + !isGroupAccessibleToUser(course, profile, group) + ? `Available ${daysUntilAvailable} days after enrollment` + : ""; + } else { + const today = new Date(); + const dripDate = new Date(group.drip.dateInUTC); + const timeDiff = dripDate.getTime() - today.getTime(); + const daysDiff = Math.ceil(timeDiff / (1000 * 3600 * 24)); + + availableLabel = + daysDiff > 0 && + !isGroupAccessibleToUser(course, profile, group) + ? `Available in ${daysDiff} days` + : ""; + } + } + + // Update lastGroupDripDateInMillis for relative drip types + if ( + group.drip && + group.drip.status && + group.drip.type === + Constants.dripType[0].split("-")[0].toUpperCase() + ) { + lastGroupDripDateInMillis += group.drip.delayInMillis; + } + + items.push({ + badge: availableLabel, + label: group.name, }); - for (const lesson of course.groupOfLessons[group]) { - lessons.push({ + + // const lessonItems = [] + for (const lesson of group.lessons) { + items.push({ label: lesson.title, href: `/course/${course.slug}/${course.courseId}/${lesson.lessonId}`, icon: - lesson.requiresEnrollment && - !isEnrolled(course.courseId, profile) ? ( + profile && profile.userId ? ( + isEnrolled(course.courseId, profile) ? ( + isLessonCompleted({ + courseId: course.courseId, + lessonId: lesson.lessonId, + profile, + }) ? ( + + ) : ( + + ) + ) : lesson.requiresEnrollment ? ( + + ) : undefined + ) : lesson.requiresEnrollment ? ( - ) : profile.userId ? ( - isLessonCompleted({ - courseId: course.courseId, - lessonId: lesson.lessonId, - profile, - }) ? ( - - ) : ( - - ) ) : undefined, + // lesson.requiresEnrollment && !isEnrolled(course.courseId, profile) + // ? + // : profile.userId + // ? (isLessonCompleted({ + // courseId: course.courseId, + // lessonId: lesson.lessonId, + // profile, + // }) + // ? ( + // + // ) + // : ( + // + // ) + // ) + // : undefined, iconPlacementRight: true, }); } } - return lessons; + return items; } const CourseViewer = (props: CourseProps) => { const { status } = useSession(); - const router = useRouter(); const { profile, dispatch, address } = props; const [course, setCourse] = useState(props.course); @@ -114,42 +260,12 @@ const CourseViewer = (props: CourseProps) => { }, [profile]); const loadCourse = async () => { - const graphQuery = ` - query { - post: getCourse(id: "${props.course.courseId}") { - title, - description, - featuredImage { - file, - caption - }, - updatedAt, - creatorName, - creatorId, - slug, - cost, - courseId, - groups { - id, - name, - rank, - lessonsOrder - }, - lessons { - lessonId, - title, - requiresEnrollment, - courseId, - groupId, - }, - tags, - firstLesson - } - } - `; const fetch = new FetchBuilder() .setUrl(`${address.backend}/api/graph`) - .setPayload(graphQuery) + .setPayload({ + query: graphQuery, + variables: { id: props.course.courseId }, + }) .setIsGraphQLEndpoint(true) .build(); @@ -157,33 +273,7 @@ const CourseViewer = (props: CourseProps) => { const response = await fetch.exec(); const { post } = response; if (post) { - const lessonsOrderedByGroups: Record = {}; - for (const group of sortCourseGroups(post)) { - lessonsOrderedByGroups[group.name] = post.lessons - .filter((lesson: Lesson) => lesson.groupId === group.id) - .sort( - (a: any, b: any) => - group.lessonsOrder.indexOf(a.lessonId) - - group.lessonsOrder.indexOf(b.lessonId), - ); - } - - const courseGroupedByLessons: CourseFrontend = { - title: post.title, - description: post.description, - featuredImage: post.featuredImage, - updatedAt: post.updatedAt, - creatorName: post.creatorName, - creatorId: post.creatorId, - slug: post.slug, - cost: post.cost, - courseId: post.courseId, - groupOfLessons: lessonsOrderedByGroups, - tags: post.tags, - firstLesson: post.firstLesson, - }; - - setCourse(courseGroupedByLessons); + setCourse(formatCourse(post)); } } catch (err: any) {} }; @@ -211,11 +301,37 @@ const CourseViewer = (props: CourseProps) => { -
+
+

+ {course.title} +

+ {(profile.fetched + ? !isEnrolled(course.courseId, profile) && + checkPermission(profile.permissions, [ + permissions.enrollInCourse, + ]) + : true) && ( +
+

{profile.fetched}

+
+ + + {ENROLL_BUTTON_TEXT} + +
+
+ )}
{isEnrolled(course.courseId, profile) && (
@@ -236,42 +352,9 @@ const CourseViewer = (props: CourseProps) => { }; export async function getServerSideProps({ query, req }: any) { - const graphQuery = ` - query { - post: getCourse(id: "${query.id}") { - title, - description, - featuredImage { - file, - caption - }, - updatedAt, - creatorName, - creatorId, - slug, - cost, - courseId, - groups { - id, - name, - rank, - lessonsOrder - }, - lessons { - lessonId, - title, - requiresEnrollment, - courseId, - groupId, - }, - tags, - firstLesson - } - } - `; const fetch = new FetchBuilder() .setUrl(`${getBackendAddress(req.headers)}/api/graph`) - .setPayload(graphQuery) + .setPayload({ query: graphQuery, variables: { id: query.id } }) .setIsGraphQLEndpoint(true) .build(); @@ -279,34 +362,9 @@ export async function getServerSideProps({ query, req }: any) { const response = await fetch.exec(); const { post } = response; if (post) { - const lessonsOrderedByGroups: Record = {}; - for (const group of sortCourseGroups(post)) { - lessonsOrderedByGroups[group.name] = post.lessons - .filter((lesson: Lesson) => lesson.groupId === group.id) - .sort( - (a: any, b: any) => - group.lessonsOrder.indexOf(a.lessonId) - - group.lessonsOrder.indexOf(b.lessonId), - ); - } - - const courseGroupedByLessons = { - title: post.title, - description: post.description, - featuredImage: post.featuredImage, - updatedAt: post.updatedAt, - creatorName: post.creatorName, - creatorId: post.creatorId, - slug: post.slug, - cost: post.cost, - courseId: post.courseId, - groupOfLessons: lessonsOrderedByGroups, - tags: post.tags, - firstLesson: post.firstLesson, - }; return { props: { - course: courseGroupedByLessons, + course: formatCourse(post), }, }; } else { @@ -327,5 +385,34 @@ const mapStateToProps = (state: AppState) => ({ address: state.address, }); +export function formatCourse( + post: Course & { lessons: Lesson[]; firstLesson: string; groups: Group[] }, +): CourseFrontend { + for (const group of sortCourseGroups(post as Course)) { + (group as GroupWithLessons).lessons = post.lessons + .filter((lesson: Lesson) => lesson.groupId === group.id) + .sort( + (a: any, b: any) => + group.lessonsOrder.indexOf(a.lessonId) - + group.lessonsOrder.indexOf(b.lessonId), + ); + } + + return { + title: post.title, + description: post.description, + featuredImage: post.featuredImage, + updatedAt: post.updatedAt, + creatorName: post.creatorName, + creatorId: post.creatorId, + slug: post.slug, + cost: post.cost, + courseId: post.courseId, + groups: post.groups as GroupWithLessons[], + tags: post.tags, + firstLesson: post.firstLesson, + }; +} + const mapDispatchToProps = (dispatch: AppDispatch) => ({ dispatch }); export default connect(mapStateToProps, mapDispatchToProps)(CourseViewer); diff --git a/apps/web/ui-config/strings.ts b/apps/web/ui-config/strings.ts index 1c9d01fa5..c9337a162 100644 --- a/apps/web/ui-config/strings.ts +++ b/apps/web/ui-config/strings.ts @@ -339,6 +339,7 @@ export const GROUP_LESSON_ITEM_UNTITLED = "Untitled"; export const SECTION_GROUP_HEADER = "Sections"; export const ERROR_SIGNIN_GENERATING_LINK = "Error generating sign in link. Try again."; +export const ERROR = "There seems to be a problem!"; export const SIGNIN_SUCCESS_PREFIX = "A sign in link has been sent to"; export const ERROR_SIGNIN_VERIFYING_LINK = "We were unable to sign you in. Please try again."; diff --git a/packages/common-models/src/profile.ts b/packages/common-models/src/profile.ts index a69aa6794..06d4a08b9 100644 --- a/packages/common-models/src/profile.ts +++ b/packages/common-models/src/profile.ts @@ -1,7 +1,4 @@ -interface Progress { - courseId: string; - completedLessons: string[]; -} +import { Progress } from "./progress"; export default interface Profile { name: string; diff --git a/packages/state-management/src/action-creators.ts b/packages/state-management/src/action-creators.ts index 29ec7c23b..235d7934f 100644 --- a/packages/state-management/src/action-creators.ts +++ b/packages/state-management/src/action-creators.ts @@ -55,7 +55,8 @@ export function refreshUserProfile(): ThunkAction< permissions, purchases { courseId, - completedLessons + completedLessons, + accessibleGroups } } }