Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions apps/docs/src/pages/en/products/section.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ If drip configuration is enabled for a section, a student won't be able to acces

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

### Customer's experience

On the course viewer, the customer will see the clock icon against the section name until it has been dripped to them.

![Customer's experience](/assets/products/drip-customer-experience.png)

## Delete Section

1. To delete a section, click on its three dots menu and select `Delete section` from the dropdown, as shown below.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"use client";

import { LessonViewer } from "@components/public/lesson-viewer";
import { redirect } from "next/navigation";
import { useContext, use } from "react";
import { ProfileContext, AddressContext } from "@components/contexts";
import { Profile } from "@courselit/common-models";

export default function LessonPage(props: {
params: Promise<{
slug: string;
id: string;
lesson: string;
}>;
}) {
const params = use(props.params);
const { slug, id, lesson } = params;
const { profile, setProfile } = useContext(ProfileContext);
const address = useContext(AddressContext);

if (!lesson) {
redirect(`/course-old/${slug}/${id}`);
}

return (
<LessonViewer
lessonId={lesson as string}
slug={slug}
profile={profile as Profile}
setProfile={setProfile}
address={address}
productId={id}
path="/course-old"
/>
);
}
120 changes: 120 additions & 0 deletions apps/web/app/(with-contexts)/course-old/[slug]/[id]/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { sortCourseGroups } from "@ui-lib/utils";
import { Course, Group, Lesson } from "@courselit/common-models";
import { FetchBuilder } from "@courselit/utils";

export type CourseFrontend = CourseWithoutGroups & {
groups: GroupWithLessons[];
firstLesson: string;
};

export type GroupWithLessons = Group & { lessons: Lesson[] };
type CourseWithoutGroups = Pick<
Course,
| "title"
| "description"
| "featuredImage"
| "updatedAt"
| "creatorId"
| "slug"
| "cost"
| "courseId"
| "tags"
| "paymentPlans"
| "defaultPaymentPlan"
>;

export const getProduct = async (
id: string,
address: string,
): Promise<CourseFrontend> => {
const fetch = new FetchBuilder()
.setUrl(`${address}/api/graph`)
.setIsGraphQLEndpoint(true)
.setPayload({
query: `
query ($id: String!) {
product: getCourse(id: $id) {
title,
description,
featuredImage {
file,
caption
},
updatedAt,
creatorId,
slug,
cost,
courseId,
groups {
id,
name,
rank,
lessonsOrder,
drip {
status,
type,
delayInMillis,
dateInUTC
}
},
lessons {
lessonId,
title,
requiresEnrollment,
courseId,
groupId,
},
tags,
firstLesson
paymentPlans {
planId
name
type
oneTimeAmount
emiAmount
emiTotalInstallments
subscriptionMonthlyAmount
subscriptionYearlyAmount
}
leadMagnet
defaultPaymentPlan
}
}
`,
variables: { id },
})
.setIsGraphQLEndpoint(true)
.build();
const response = await fetch.exec();
return formatCourse(response.product);
};

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,
creatorId: post.creatorId,
slug: post.slug,
cost: post.cost,
courseId: post.courseId,
groups: post.groups as GroupWithLessons[],
tags: post.tags,
firstLesson: post.firstLesson,
paymentPlans: post.paymentPlans,
defaultPaymentPlan: post.defaultPaymentPlan,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"use client";

import { useContext } from "react";
import {
formattedLocaleDate,
isEnrolled,
isLessonCompleted,
} from "@ui-lib/utils";
import { CheckCircled, Circle, Lock } from "@courselit/icons";
import { SIDEBAR_TEXT_COURSE_ABOUT } from "@ui-config/strings";
import { Profile, Constants } from "@courselit/common-models";
import {
ComponentScaffoldMenuItem,
ComponentScaffold,
Divider,
} from "@components/public/scaffold";
import { ProfileContext, SiteInfoContext } from "@components/contexts";
import { CourseFrontend, GroupWithLessons } from "./helpers";

export default function ProductPage({
product,
children,
}: {
product: CourseFrontend;
children: React.ReactNode;
}) {
const { profile } = useContext(ProfileContext);
const siteInfo = useContext(SiteInfoContext);

if (!profile) {
return null;
}

return (
<ComponentScaffold
items={generateSideBarItems(product, profile as Profile)}
drawerWidth={360}
showCourseLitBranding={true}
siteinfo={siteInfo}
>
{children}
</ComponentScaffold>
);
}

export function generateSideBarItems(
course: CourseFrontend,
profile: Profile,
): (ComponentScaffoldMenuItem | Divider)[] {
if (!course) return [];

const items: (ComponentScaffoldMenuItem | Divider)[] = [
{
label: SIDEBAR_TEXT_COURSE_ABOUT,
href: `/course/${course.slug}/${course.courseId}`,
},
];

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 ?? 0) +
lastGroupDripDateInMillis;
const daysUntilAvailable = Math.ceil(
(delayInMillis - Date.now()) / 86400000,
);
availableLabel =
daysUntilAvailable &&
!isGroupAccessibleToUser(course, profile as Profile, group)
? isEnrolled(course.courseId, profile)
? `Available in ${daysUntilAvailable} days`
: `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 on ${formattedLocaleDate(dripDate)}`
: "";
}
}

// 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 ?? 0;
}

items.push({
badge: availableLabel,
label: group.name,
});

for (const lesson of group.lessons) {
items.push({
label: lesson.title,
href: `/course/${course.slug}/${course.courseId}/${lesson.lessonId}`,
icon:
profile && profile.userId ? (
isEnrolled(course.courseId, profile) ? (
isLessonCompleted({
courseId: course.courseId,
lessonId: lesson.lessonId,
profile,
}) ? (
<CheckCircled />
) : (
<Circle />
)
) : lesson.requiresEnrollment ? (
<Lock />
) : undefined
) : lesson.requiresEnrollment ? (
<Lock />
) : undefined,
iconPlacementRight: true,
});
}
}

return items;
}

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;
}
64 changes: 64 additions & 0 deletions apps/web/app/(with-contexts)/course-old/[slug]/[id]/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Metadata, ResolvingMetadata } from "next";
import { getFullSiteSetup } from "@ui-lib/utils";
import { headers } from "next/headers";
import { FetchBuilder } from "@courselit/utils";
import { notFound } from "next/navigation";
import LayoutWithSidebar from "./layout-with-sidebar";
import { getProduct } from "./helpers";
import { getAddressFromHeaders } from "@/app/actions";

export async function generateMetadata(
props: { params: Promise<{ slug: string; id: string }> },
parent: ResolvingMetadata,
): Promise<Metadata> {
const params = await props.params;
const address = await getAddressFromHeaders(headers);
const siteInfo = await getFullSiteSetup(address);

if (!siteInfo) {
return {
title: `${(await parent)?.title?.absolute}`,
};
}

try {
const query = `
query ($id: String!) {
course: getCourse(id: $id) {
title
}
}
`;
const fetch = new FetchBuilder()
.setUrl(`${address}/api/graph`)
.setPayload({
query,
variables: { id: params.id },
})
.setIsGraphQLEndpoint(true)
.build();
const response = await fetch.exec();
const course = response.course;

return {
title: `${course?.title} | ${(await parent)?.title?.absolute}`,
};
} catch (error) {
notFound();
}
}

export default async function Layout(props: {
children: React.ReactNode;
params: Promise<{ slug: string; id: string }>;
}) {
const params = await props.params;

const { children } = props;

const { id } = params;
const address = await getAddressFromHeaders(headers);
const product = await getProduct(id, address);

return <LayoutWithSidebar product={product}>{children}</LayoutWithSidebar>;
}
Loading
Loading