diff --git a/apps/docs/public/assets/products/drip-customer-experience.png b/apps/docs/public/assets/products/drip-customer-experience.png new file mode 100644 index 000000000..454101de5 Binary files /dev/null and b/apps/docs/public/assets/products/drip-customer-experience.png differ diff --git a/apps/docs/src/pages/en/products/section.md b/apps/docs/src/pages/en/products/section.md index dc1cf3385..967b60552 100644 --- a/apps/docs/src/pages/en/products/section.md +++ b/apps/docs/src/pages/en/products/section.md @@ -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. diff --git a/apps/web/app/(with-contexts)/course-old/[slug]/[id]/[lesson]/page.tsx b/apps/web/app/(with-contexts)/course-old/[slug]/[id]/[lesson]/page.tsx new file mode 100644 index 000000000..3a45a8cd7 --- /dev/null +++ b/apps/web/app/(with-contexts)/course-old/[slug]/[id]/[lesson]/page.tsx @@ -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 ( + + ); +} diff --git a/apps/web/app/(with-contexts)/course-old/[slug]/[id]/helpers.ts b/apps/web/app/(with-contexts)/course-old/[slug]/[id]/helpers.ts new file mode 100644 index 000000000..ea139709d --- /dev/null +++ b/apps/web/app/(with-contexts)/course-old/[slug]/[id]/helpers.ts @@ -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 => { + 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, + }; +} diff --git a/apps/web/app/(with-contexts)/course-old/[slug]/[id]/layout-with-sidebar.tsx b/apps/web/app/(with-contexts)/course-old/[slug]/[id]/layout-with-sidebar.tsx new file mode 100644 index 000000000..20a9cafb5 --- /dev/null +++ b/apps/web/app/(with-contexts)/course-old/[slug]/[id]/layout-with-sidebar.tsx @@ -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 ( + + {children} + + ); +} + +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, + }) ? ( + + ) : ( + + ) + ) : lesson.requiresEnrollment ? ( + + ) : undefined + ) : lesson.requiresEnrollment ? ( + + ) : 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; +} diff --git a/apps/web/app/(with-contexts)/course-old/[slug]/[id]/layout.tsx b/apps/web/app/(with-contexts)/course-old/[slug]/[id]/layout.tsx new file mode 100644 index 000000000..e5bc545ea --- /dev/null +++ b/apps/web/app/(with-contexts)/course-old/[slug]/[id]/layout.tsx @@ -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 { + 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 {children}; +} diff --git a/apps/web/app/(with-contexts)/course-old/[slug]/[id]/page.tsx b/apps/web/app/(with-contexts)/course-old/[slug]/[id]/page.tsx new file mode 100644 index 000000000..2bf9aa0b5 --- /dev/null +++ b/apps/web/app/(with-contexts)/course-old/[slug]/[id]/page.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useContext, useEffect, useState, use } from "react"; +import { isEnrolled } from "@ui-lib/utils"; +import { ArrowRight } from "@courselit/icons"; +import { + COURSE_PROGRESS_START, + ENROLL_BUTTON_TEXT, + BTN_VIEW_CERTIFICATE, +} from "@ui-config/strings"; +import { checkPermission } from "@courselit/utils"; +import { Profile, UIConstants } from "@courselit/common-models"; +import { + Link, + Button2, + getSymbolFromCurrency, + Image, +} from "@courselit/components-library"; +import { TextRenderer } from "@courselit/page-blocks"; +import { TableOfContent } from "@components/table-of-content"; +import { + AddressContext, + ProfileContext, + SiteInfoContext, + ThemeContext, +} from "@components/contexts"; +import { getProduct } from "./helpers"; +import { getUserProfile } from "@/app/(with-contexts)/helpers"; +import { BadgeCheck } from "lucide-react"; +import { emptyDoc as TextEditorEmptyDoc } from "@courselit/text-editor"; +import WidgetErrorBoundary from "@components/public/base-layout/template/widget-error-boundary"; +const { permissions } = UIConstants; + +export default function ProductPage(props: { + params: Promise<{ slug: string; id: string }>; +}) { + const params = use(props.params); + const { id } = params; + const [product, setProduct] = useState(null); + const { profile, setProfile } = useContext(ProfileContext); + const siteInfo = useContext(SiteInfoContext); + const address = useContext(AddressContext); + const [progress, setProgress] = useState(null); + const { theme } = useContext(ThemeContext); + + useEffect(() => { + if (id) { + getProduct(id, address.backend).then((product) => { + setProduct(product); + }); + } + }, [id]); + + useEffect(() => { + if (product) { + getUserProfile(address.backend).then((profile) => { + setProfile(profile); + setProgress( + profile.purchases?.find( + (purchase) => purchase.courseId === product.courseId, + ), + ); + }); + } + }, [product]); + + if (!profile) { + return null; + } + + if (!product || !siteInfo) { + return null; + } + + const descriptionJson = product.description + ? JSON.parse(product.description) + : TextEditorEmptyDoc; + + return ( +
+

{product.title}

+ {progress?.certificateId && ( + + + {" "} + {BTN_VIEW_CERTIFICATE} + + + )} + {!isEnrolled(product.courseId, profile as Profile) && + checkPermission(profile.permissions ?? [], [ + permissions.enrollInCourse, + ]) && ( +
+
+
+ {getSymbolFromCurrency( + siteInfo.currencyISOCode ?? "", + )} + {product.cost} + + {product.costType ?? ""} + +
+ + {ENROLL_BUTTON_TEXT} + +
+
+ )} + {product.featuredImage && ( +
+
+ {product.featuredImage.caption} +
+
+ )} +
+
+ + + + +
+
+ {isEnrolled(product.courseId, profile as Profile) && ( +
+ + + {COURSE_PROGRESS_START} + + + +
+ )} +
+ ); +} diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/layout-with-sidebar.tsx b/apps/web/app/(with-contexts)/course/[slug]/[id]/layout-with-sidebar.tsx index b0857e286..7b81e0238 100644 --- a/apps/web/app/(with-contexts)/course/[slug]/[id]/layout-with-sidebar.tsx +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/layout-with-sidebar.tsx @@ -1,17 +1,56 @@ "use client"; -import { useContext } from "react"; -import { isEnrolled, isLessonCompleted } from "@ui-lib/utils"; +import { ReactNode, 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 { + BTN_EXIT_COURSE_TOOLTIP, + 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"; + ProfileContext, + SiteInfoContext, + ThemeContext, +} from "@components/contexts"; import { CourseFrontend, GroupWithLessons } from "./helpers"; +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInset, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, + SidebarTrigger, +} from "@components/ui/sidebar"; +import { Image } from "@courselit/components-library"; +import Link from "next/link"; +import { truncate } from "@courselit/utils"; +import { Button } from "@components/ui/button"; +import { ChevronRight, Clock, LogOutIcon } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@components/ui/tooltip"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@components/ui/collapsible"; +import { usePathname } from "next/navigation"; +import { Caption } from "@courselit/page-primitives"; +import NextThemeSwitcher from "@components/admin/next-theme-switcher"; export default function ProductPage({ product, @@ -21,72 +60,281 @@ export default function ProductPage({ children: React.ReactNode; }) { const { profile } = useContext(ProfileContext); - const siteInfo = useContext(SiteInfoContext); if (!profile) { return null; } return ( - - {children} - + + +
+ +
+ + + + + + + {BTN_EXIT_COURSE_TOOLTIP} + + +
+
+
{children}
+
+ ); } +export function AppSidebar({ + course, + profile, + ...rest +}: { + course: CourseFrontend; + profile: Partial; +} & React.ComponentProps) { + const siteinfo = useContext(SiteInfoContext); + const pathname = usePathname(); + const sideBarItems = generateSideBarItems( + course, + profile as Profile, + pathname, + ); + const { theme } = useContext(ThemeContext); + + return ( + + + + + + +
+ logo +
+
+ {siteinfo.title} +
+ +
+
+
+
+ + {sideBarItems.map((item, index) => + item.items?.length ? ( + + + + +
+ + + + + {truncate( + item.title, + item.badge + ? 15 + : 26, + )} + + + {item.title} + + + + {item.badge?.text && ( + + + + + { + item.badge + .text + } + + + +

+ { + item.badge + .description + } +

+
+
+ )} +
+ +
+
+
+ {item.items?.length ? ( + + + + {item.items.map( + (item, index) => ( + + + + + + + + {truncate( + item.title, + 22, + )} + + + { + item.title + } + + + + + {item.icon} + + + + ), + )} + + + + ) : null} +
+
+ ) : ( + + + + + + {item.title} + + + + + + ), + )} +
+ {!siteinfo.hideCourseLitBranding && ( + + + Powered by{" "} + CourseLit + + + )} +
+ ); +} + +interface SidebarItem { + title: string; + href: string; + badge?: { + text: string; + description: string; + }; + isActive?: boolean; + items?: { + title: string; + href: string; + icon?: ReactNode; + isActive?: boolean; + }[]; +} + export function generateSideBarItems( course: CourseFrontend, profile: Profile, -): (ComponentScaffoldMenuItem | Divider)[] { + pathname: string, +): SidebarItem[] { if (!course) return []; - const items: (ComponentScaffoldMenuItem | Divider)[] = [ + const items: SidebarItem[] = [ { - label: SIDEBAR_TEXT_COURSE_ABOUT, + title: SIDEBAR_TEXT_COURSE_ABOUT, href: `/course/${course.slug}/${course.courseId}`, + isActive: pathname === `/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 in ${daysDiff} days` - : ""; - } - } - // Update lastGroupDripDateInMillis for relative drip types if ( group.drip && @@ -97,15 +345,30 @@ export function generateSideBarItems( lastGroupDripDateInMillis += group?.drip?.delayInMillis ?? 0; } - items.push({ - badge: availableLabel, - label: group.name, - }); + const groupItem: SidebarItem = { + title: group.name, + href: "#", + isActive: false, + badge: getDripLabel({ + course, + group, + profile, + lastGroupDripDateInMillis, + }), + items: [], + }; for (const lesson of group.lessons) { - items.push({ - label: lesson.title, + const isActive = + pathname === + `/course/${course.slug}/${course.courseId}/${lesson.lessonId}`; + if (isActive) { + groupItem.isActive = true; + } + groupItem.items!.push({ + title: lesson.title, href: `/course/${course.slug}/${course.courseId}/${lesson.lessonId}`, + isActive, icon: profile && profile.userId ? ( isEnrolled(course.courseId, profile) ? ( @@ -124,14 +387,67 @@ export function generateSideBarItems( ) : lesson.requiresEnrollment ? ( ) : undefined, - iconPlacementRight: true, }); } + + items.push(groupItem); } return items; } +function getDripLabel({ + course, + group, + profile, + lastGroupDripDateInMillis, +}: { + course: CourseFrontend; + group: GroupWithLessons; + profile: Profile; + lastGroupDripDateInMillis: number; +}): { text: string; description: string } | undefined { + if (group.drip && group.drip.status) { + let availableLabel = ""; + let text = ""; + 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` + : ""; + text = `${daysUntilAvailable} days`; + } 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)}` + : ""; + text = formattedLocaleDate(dripDate); + } + return { + text, + description: availableLabel, + }; + } + + return undefined; +} + export function isGroupAccessibleToUser( course: CourseFrontend, profile: Profile, diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/page.tsx b/apps/web/app/(with-contexts)/course/[slug]/[id]/page.tsx index 2bf9aa0b5..76522d0d6 100644 --- a/apps/web/app/(with-contexts)/course/[slug]/[id]/page.tsx +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/page.tsx @@ -12,7 +12,6 @@ import { checkPermission } from "@courselit/utils"; import { Profile, UIConstants } from "@courselit/common-models"; import { Link, - Button2, getSymbolFromCurrency, Image, } from "@courselit/components-library"; @@ -29,6 +28,7 @@ import { getUserProfile } from "@/app/(with-contexts)/helpers"; import { BadgeCheck } from "lucide-react"; import { emptyDoc as TextEditorEmptyDoc } from "@courselit/text-editor"; import WidgetErrorBoundary from "@components/public/base-layout/template/widget-error-boundary"; +import { Button, Header1 } from "@courselit/page-primitives"; const { permissions } = UIConstants; export default function ProductPage(props: { @@ -78,16 +78,18 @@ export default function ProductPage(props: { return (
-

{product.title}

+ + {product.title} + {progress?.certificateId && ( - + )} {!isEnrolled(product.courseId, profile as Profile) && @@ -108,7 +110,9 @@ export default function ProductPage(props: { - {ENROLL_BUTTON_TEXT} +
@@ -126,7 +130,7 @@ export default function ProductPage(props: { )}
-
+
- +
)} diff --git a/apps/web/components/admin/dashboard-content.tsx b/apps/web/components/admin/dashboard-content.tsx index 18b4c76cc..b93b18c25 100644 --- a/apps/web/components/admin/dashboard-content.tsx +++ b/apps/web/components/admin/dashboard-content.tsx @@ -18,6 +18,8 @@ import { Fragment, ReactNode, useContext } from "react"; import LoadingScreen from "./loading-screen"; import PermissionError from "./permission-error"; +import NextThemeSwitcher from "./next-theme-switcher"; + export default function DashboardContent({ breadcrumbs, children, @@ -87,7 +89,8 @@ export default function DashboardContent({ )}
-
+
+
diff --git a/apps/web/components/admin/dashboard-skeleton/app-sidebar.tsx b/apps/web/components/admin/dashboard-skeleton/app-sidebar.tsx index b745faa77..3cfe17a2c 100644 --- a/apps/web/components/admin/dashboard-skeleton/app-sidebar.tsx +++ b/apps/web/components/admin/dashboard-skeleton/app-sidebar.tsx @@ -98,13 +98,12 @@ export function AppSidebar({ ...props }: ComponentProps) { -
+
logo
diff --git a/apps/web/components/admin/next-theme-switcher.tsx b/apps/web/components/admin/next-theme-switcher.tsx new file mode 100644 index 000000000..537b62f01 --- /dev/null +++ b/apps/web/components/admin/next-theme-switcher.tsx @@ -0,0 +1,37 @@ +import { Button } from "@components/ui/button"; +import { Sun, Moon } from "lucide-react"; +import { useTheme } from "next-themes"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@components/ui/tooltip"; +import { BTN_TOGGLE_THEME } from "@ui-config/strings"; + +export default function NextThemeSwitcher({ + variant = "outline", +}: { + variant?: "outline" | "ghost"; +}) { + const { theme, setTheme } = useTheme(); + const isDark = theme === "dark"; + + return ( + + + + + {BTN_TOGGLE_THEME} + + ); +} diff --git a/apps/web/components/admin/page-editor/index.tsx b/apps/web/components/admin/page-editor/index.tsx index b0aef8fc0..034d03606 100644 --- a/apps/web/components/admin/page-editor/index.tsx +++ b/apps/web/components/admin/page-editor/index.tsx @@ -51,7 +51,7 @@ import { } from "@/components/ui/select"; import { ThemeWithDraftState } from "./theme-editor/theme-with-draft-state"; import useThemes from "./use-themes"; -import NextThemeSwitcher from "./next-theme-switcher"; +import NextThemeSwitcher from "../next-theme-switcher"; import { useTheme } from "next-themes"; const EditWidget = dynamic(() => import("./edit-widget")); diff --git a/apps/web/components/admin/page-editor/next-theme-switcher.tsx b/apps/web/components/admin/page-editor/next-theme-switcher.tsx deleted file mode 100644 index eb863ec3e..000000000 --- a/apps/web/components/admin/page-editor/next-theme-switcher.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Button } from "@components/ui/button"; -import { Sun, Moon } from "lucide-react"; -import { useTheme } from "next-themes"; - -export default function NextThemeSwitcher() { - const { theme, setTheme } = useTheme(); - const isDark = theme === "dark"; - - return ( - - ); -} diff --git a/apps/web/components/notifications-viewer.tsx b/apps/web/components/notifications-viewer.tsx index 0b9c77e92..7aea13f7c 100644 --- a/apps/web/components/notifications-viewer.tsx +++ b/apps/web/components/notifications-viewer.tsx @@ -17,7 +17,6 @@ import { TriangleAlert, } from "lucide-react"; import { formatDistanceToNow } from "date-fns"; - import { Button } from "@/components/ui/button"; import { Popover, @@ -229,11 +228,7 @@ export function NotificationsViewer() { return ( - )}
)} {lesson && !error && ( <> -
-

+
+ {lesson.title} -

+
{String.prototype.toUpperCase.call( LESSON_TYPE_VIDEO, @@ -308,6 +314,7 @@ export const LessonViewer = ({ unknown > } + theme={theme.theme} /> )} @@ -335,10 +342,13 @@ export const LessonViewer = ({ lesson.media?.file && (
- +
)} @@ -349,32 +359,38 @@ export const LessonViewer = ({
{!lesson.prevLesson && ( - - + )} {lesson.prevLesson && ( - {COURSE_PROGRESS_PREV} - + )}
- +
)}
diff --git a/apps/web/components/public/lesson-viewer/quiz-viewer.tsx b/apps/web/components/public/lesson-viewer/quiz-viewer.tsx index f0798830d..4678f07ce 100644 --- a/apps/web/components/public/lesson-viewer/quiz-viewer.tsx +++ b/apps/web/components/public/lesson-viewer/quiz-viewer.tsx @@ -4,7 +4,7 @@ import { Quiz as QuizContent, } from "@courselit/common-models"; import { FetchBuilder } from "@courselit/utils"; -import { ChangeEvent, useState } from "react"; +import { ChangeEvent, useContext, useState } from "react"; import { TOAST_TITLE_ERROR, QUIZ_VIEWER_EVALUATE_BTN, @@ -13,7 +13,9 @@ import { TOAST_QUIZ_PASS_MESSAGE, QUIZ_SCORE_PREFIX_MESSAGE, } from "@/ui-config/strings"; -import { Form, FormSubmit, useToast } from "@courselit/components-library"; +import { Form, useToast } from "@courselit/components-library"; +import { Button, Header2, Text1 } from "@courselit/page-primitives"; +import { ThemeContext } from "@components/contexts"; interface QuizViewerProps { lessonId: string; @@ -32,6 +34,7 @@ export default function QuizViewer({ ]); const [loading, setLoading] = useState(false); const { toast } = useToast(); + const { theme } = useContext(ThemeContext); const setAnswerForQuestion = ( checked: boolean, @@ -119,9 +122,9 @@ export default function QuizViewer({
{questions.map((question: Question, questionIndex: number) => (
-

+ {questionIndex + 1}. {question.text} -

+ {question.options.map((option, index: number) => (
- + {option.text}
))}
))}
- +
); diff --git a/apps/web/ui-config/strings.ts b/apps/web/ui-config/strings.ts index 8f48119f1..cdbd0bb7a 100644 --- a/apps/web/ui-config/strings.ts +++ b/apps/web/ui-config/strings.ts @@ -72,6 +72,7 @@ export const FORM_FIELD_FEATURED_IMAGE = "Featured image"; export const BTN_DELETE_COURSE = "Delete product"; export const BTN_EXIT_COURSE = "Exit"; export const BTN_EXIT_COURSE_TOOLTIP = "Exit course"; +export const BTN_TOGGLE_THEME = "Toggle theme"; export const BTN_ADD_VIDEO = "Add"; export const ADD_VIDEO_DIALOG_TITLE = "Embed an online video"; export const LABEL_NEW_PASSWORD = "New password";