Skip to content

Commit 21be78e

Browse files
rajat1saxenaRajat
andauthored
Themed course viewer (#700)
* Themeable course viewer using shadcn components * Design adjustments * Removed unused vars --------- Co-authored-by: Rajat <hi@rajatsaxena.dev>
1 parent 425af3a commit 21be78e

File tree

18 files changed

+1033
-139
lines changed

18 files changed

+1033
-139
lines changed
88.1 KB
Loading

apps/docs/src/pages/en/products/section.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ If drip configuration is enabled for a section, a student won't be able to acces
7878

7979
![Drip Notification](/assets/products/drip-notify-email.jpeg)
8080

81+
### Customer's experience
82+
83+
On the course viewer, the customer will see the clock icon against the section name until it has been dripped to them.
84+
85+
![Customer's experience](/assets/products/drip-customer-experience.png)
86+
8187
## Delete Section
8288

8389
1. To delete a section, click on its three dots menu and select `Delete section` from the dropdown, as shown below.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"use client";
2+
3+
import { LessonViewer } from "@components/public/lesson-viewer";
4+
import { redirect } from "next/navigation";
5+
import { useContext, use } from "react";
6+
import { ProfileContext, AddressContext } from "@components/contexts";
7+
import { Profile } from "@courselit/common-models";
8+
9+
export default function LessonPage(props: {
10+
params: Promise<{
11+
slug: string;
12+
id: string;
13+
lesson: string;
14+
}>;
15+
}) {
16+
const params = use(props.params);
17+
const { slug, id, lesson } = params;
18+
const { profile, setProfile } = useContext(ProfileContext);
19+
const address = useContext(AddressContext);
20+
21+
if (!lesson) {
22+
redirect(`/course-old/${slug}/${id}`);
23+
}
24+
25+
return (
26+
<LessonViewer
27+
lessonId={lesson as string}
28+
slug={slug}
29+
profile={profile as Profile}
30+
setProfile={setProfile}
31+
address={address}
32+
productId={id}
33+
path="/course-old"
34+
/>
35+
);
36+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { sortCourseGroups } from "@ui-lib/utils";
2+
import { Course, Group, Lesson } from "@courselit/common-models";
3+
import { FetchBuilder } from "@courselit/utils";
4+
5+
export type CourseFrontend = CourseWithoutGroups & {
6+
groups: GroupWithLessons[];
7+
firstLesson: string;
8+
};
9+
10+
export type GroupWithLessons = Group & { lessons: Lesson[] };
11+
type CourseWithoutGroups = Pick<
12+
Course,
13+
| "title"
14+
| "description"
15+
| "featuredImage"
16+
| "updatedAt"
17+
| "creatorId"
18+
| "slug"
19+
| "cost"
20+
| "courseId"
21+
| "tags"
22+
| "paymentPlans"
23+
| "defaultPaymentPlan"
24+
>;
25+
26+
export const getProduct = async (
27+
id: string,
28+
address: string,
29+
): Promise<CourseFrontend> => {
30+
const fetch = new FetchBuilder()
31+
.setUrl(`${address}/api/graph`)
32+
.setIsGraphQLEndpoint(true)
33+
.setPayload({
34+
query: `
35+
query ($id: String!) {
36+
product: getCourse(id: $id) {
37+
title,
38+
description,
39+
featuredImage {
40+
file,
41+
caption
42+
},
43+
updatedAt,
44+
creatorId,
45+
slug,
46+
cost,
47+
courseId,
48+
groups {
49+
id,
50+
name,
51+
rank,
52+
lessonsOrder,
53+
drip {
54+
status,
55+
type,
56+
delayInMillis,
57+
dateInUTC
58+
}
59+
},
60+
lessons {
61+
lessonId,
62+
title,
63+
requiresEnrollment,
64+
courseId,
65+
groupId,
66+
},
67+
tags,
68+
firstLesson
69+
paymentPlans {
70+
planId
71+
name
72+
type
73+
oneTimeAmount
74+
emiAmount
75+
emiTotalInstallments
76+
subscriptionMonthlyAmount
77+
subscriptionYearlyAmount
78+
}
79+
leadMagnet
80+
defaultPaymentPlan
81+
}
82+
}
83+
`,
84+
variables: { id },
85+
})
86+
.setIsGraphQLEndpoint(true)
87+
.build();
88+
const response = await fetch.exec();
89+
return formatCourse(response.product);
90+
};
91+
92+
export function formatCourse(
93+
post: Course & { lessons: Lesson[]; firstLesson: string; groups: Group[] },
94+
): CourseFrontend {
95+
for (const group of sortCourseGroups(post as Course)) {
96+
(group as GroupWithLessons).lessons = post.lessons
97+
.filter((lesson: Lesson) => lesson.groupId === group.id)
98+
.sort(
99+
(a: any, b: any) =>
100+
group.lessonsOrder?.indexOf(a.lessonId) -
101+
group.lessonsOrder?.indexOf(b.lessonId),
102+
);
103+
}
104+
105+
return {
106+
title: post.title,
107+
description: post.description,
108+
featuredImage: post.featuredImage,
109+
updatedAt: post.updatedAt,
110+
creatorId: post.creatorId,
111+
slug: post.slug,
112+
cost: post.cost,
113+
courseId: post.courseId,
114+
groups: post.groups as GroupWithLessons[],
115+
tags: post.tags,
116+
firstLesson: post.firstLesson,
117+
paymentPlans: post.paymentPlans,
118+
defaultPaymentPlan: post.defaultPaymentPlan,
119+
};
120+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"use client";
2+
3+
import { useContext } from "react";
4+
import {
5+
formattedLocaleDate,
6+
isEnrolled,
7+
isLessonCompleted,
8+
} from "@ui-lib/utils";
9+
import { CheckCircled, Circle, Lock } from "@courselit/icons";
10+
import { SIDEBAR_TEXT_COURSE_ABOUT } from "@ui-config/strings";
11+
import { Profile, Constants } from "@courselit/common-models";
12+
import {
13+
ComponentScaffoldMenuItem,
14+
ComponentScaffold,
15+
Divider,
16+
} from "@components/public/scaffold";
17+
import { ProfileContext, SiteInfoContext } from "@components/contexts";
18+
import { CourseFrontend, GroupWithLessons } from "./helpers";
19+
20+
export default function ProductPage({
21+
product,
22+
children,
23+
}: {
24+
product: CourseFrontend;
25+
children: React.ReactNode;
26+
}) {
27+
const { profile } = useContext(ProfileContext);
28+
const siteInfo = useContext(SiteInfoContext);
29+
30+
if (!profile) {
31+
return null;
32+
}
33+
34+
return (
35+
<ComponentScaffold
36+
items={generateSideBarItems(product, profile as Profile)}
37+
drawerWidth={360}
38+
showCourseLitBranding={true}
39+
siteinfo={siteInfo}
40+
>
41+
{children}
42+
</ComponentScaffold>
43+
);
44+
}
45+
46+
export function generateSideBarItems(
47+
course: CourseFrontend,
48+
profile: Profile,
49+
): (ComponentScaffoldMenuItem | Divider)[] {
50+
if (!course) return [];
51+
52+
const items: (ComponentScaffoldMenuItem | Divider)[] = [
53+
{
54+
label: SIDEBAR_TEXT_COURSE_ABOUT,
55+
href: `/course/${course.slug}/${course.courseId}`,
56+
},
57+
];
58+
59+
let lastGroupDripDateInMillis = Date.now();
60+
61+
for (const group of course.groups) {
62+
let availableLabel = "";
63+
if (group.drip && group.drip.status) {
64+
if (
65+
group.drip.type ===
66+
Constants.dripType[0].split("-")[0].toUpperCase()
67+
) {
68+
const delayInMillis =
69+
(group?.drip?.delayInMillis ?? 0) +
70+
lastGroupDripDateInMillis;
71+
const daysUntilAvailable = Math.ceil(
72+
(delayInMillis - Date.now()) / 86400000,
73+
);
74+
availableLabel =
75+
daysUntilAvailable &&
76+
!isGroupAccessibleToUser(course, profile as Profile, group)
77+
? isEnrolled(course.courseId, profile)
78+
? `Available in ${daysUntilAvailable} days`
79+
: `Available ${daysUntilAvailable} days after enrollment`
80+
: "";
81+
} else {
82+
const today = new Date();
83+
const dripDate = new Date(group?.drip?.dateInUTC ?? "");
84+
const timeDiff = dripDate.getTime() - today.getTime();
85+
const daysDiff = Math.ceil(timeDiff / (1000 * 3600 * 24));
86+
87+
availableLabel =
88+
daysDiff > 0 &&
89+
!isGroupAccessibleToUser(course, profile, group)
90+
? `Available on ${formattedLocaleDate(dripDate)}`
91+
: "";
92+
}
93+
}
94+
95+
// Update lastGroupDripDateInMillis for relative drip types
96+
if (
97+
group.drip &&
98+
group.drip.status &&
99+
group.drip.type ===
100+
Constants.dripType[0].split("-")[0].toUpperCase()
101+
) {
102+
lastGroupDripDateInMillis += group?.drip?.delayInMillis ?? 0;
103+
}
104+
105+
items.push({
106+
badge: availableLabel,
107+
label: group.name,
108+
});
109+
110+
for (const lesson of group.lessons) {
111+
items.push({
112+
label: lesson.title,
113+
href: `/course/${course.slug}/${course.courseId}/${lesson.lessonId}`,
114+
icon:
115+
profile && profile.userId ? (
116+
isEnrolled(course.courseId, profile) ? (
117+
isLessonCompleted({
118+
courseId: course.courseId,
119+
lessonId: lesson.lessonId,
120+
profile,
121+
}) ? (
122+
<CheckCircled />
123+
) : (
124+
<Circle />
125+
)
126+
) : lesson.requiresEnrollment ? (
127+
<Lock />
128+
) : undefined
129+
) : lesson.requiresEnrollment ? (
130+
<Lock />
131+
) : undefined,
132+
iconPlacementRight: true,
133+
});
134+
}
135+
}
136+
137+
return items;
138+
}
139+
140+
export function isGroupAccessibleToUser(
141+
course: CourseFrontend,
142+
profile: Profile,
143+
group: GroupWithLessons,
144+
): boolean {
145+
if (!group.drip || !group.drip.status) return true;
146+
147+
if (!Array.isArray(profile.purchases)) return false;
148+
149+
for (const purchase of profile.purchases) {
150+
if (purchase.courseId === course.courseId) {
151+
if (Array.isArray(purchase.accessibleGroups)) {
152+
if (purchase.accessibleGroups.includes(group.id)) {
153+
return true;
154+
}
155+
}
156+
}
157+
}
158+
159+
return false;
160+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { Metadata, ResolvingMetadata } from "next";
2+
import { getFullSiteSetup } from "@ui-lib/utils";
3+
import { headers } from "next/headers";
4+
import { FetchBuilder } from "@courselit/utils";
5+
import { notFound } from "next/navigation";
6+
import LayoutWithSidebar from "./layout-with-sidebar";
7+
import { getProduct } from "./helpers";
8+
import { getAddressFromHeaders } from "@/app/actions";
9+
10+
export async function generateMetadata(
11+
props: { params: Promise<{ slug: string; id: string }> },
12+
parent: ResolvingMetadata,
13+
): Promise<Metadata> {
14+
const params = await props.params;
15+
const address = await getAddressFromHeaders(headers);
16+
const siteInfo = await getFullSiteSetup(address);
17+
18+
if (!siteInfo) {
19+
return {
20+
title: `${(await parent)?.title?.absolute}`,
21+
};
22+
}
23+
24+
try {
25+
const query = `
26+
query ($id: String!) {
27+
course: getCourse(id: $id) {
28+
title
29+
}
30+
}
31+
`;
32+
const fetch = new FetchBuilder()
33+
.setUrl(`${address}/api/graph`)
34+
.setPayload({
35+
query,
36+
variables: { id: params.id },
37+
})
38+
.setIsGraphQLEndpoint(true)
39+
.build();
40+
const response = await fetch.exec();
41+
const course = response.course;
42+
43+
return {
44+
title: `${course?.title} | ${(await parent)?.title?.absolute}`,
45+
};
46+
} catch (error) {
47+
notFound();
48+
}
49+
}
50+
51+
export default async function Layout(props: {
52+
children: React.ReactNode;
53+
params: Promise<{ slug: string; id: string }>;
54+
}) {
55+
const params = await props.params;
56+
57+
const { children } = props;
58+
59+
const { id } = params;
60+
const address = await getAddressFromHeaders(headers);
61+
const product = await getProduct(id, address);
62+
63+
return <LayoutWithSidebar product={product}>{children}</LayoutWithSidebar>;
64+
}

0 commit comments

Comments
 (0)