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
10 changes: 10 additions & 0 deletions .cursor/rules/basics.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
alwaysApply: true
---

- Use `pnpm` as package manager.
- The project is structured as a monorepo i.e. a pnpm workspace. The apps are in `apps` folder and re-usable packages are in `packages`.
- Command for running script in a workspace: `pnpm --filter <workspace> <command>`.
- Command for running tests: `pnpm test`.
- The project uses shadcn for building UI so stick to its conventions and design.
- In `apps/web` workspace, create a string first in `apps/web/config/strings.ts` and then import it in the `.tsx` files, instead of using inline strings.
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ coverage
# Text editors configurations
.vscode
.rgignore
.cursor

# Env file
.env*.local
Expand All @@ -39,4 +38,7 @@ report*.json
# Dev tools files
.eslintcache

.npmrc
.npmrc

# Jest files
globalConfig.json
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/docs/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export const SIDEBAR: Sidebar = {
{ text: "Add content", link: "en/courses/add-content" },
{ text: "Manage sections", link: "en/products/section" },
{ text: "Invite customers", link: "en/products/invite-customers" },
{ text: "Certificates", link: "en/products/certificates" },
],
"Digital downloads": [
{ text: "Introduction", link: "en/downloads/introduction" },
Expand Down
88 changes: 88 additions & 0 deletions apps/docs/src/pages/en/products/certificates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
---
title: Certificates
description: Issue certificate on course completion
layout: ../../../layouts/MainLayout.astro
---

Certificates add significant value to your courses by providing your customers with verifiable credentials they can showcase. CourseLit makes it effortless to issue certificates automatically upon course completion.

Here are the key features of CourseLit certificates:

- A dedicated page for each certificate issued, allowing customers to share a verifiable link on their profiles
- Customizable text and images to make certificates reflect your brand

**Note**: Certificates are only available for `Course` products.

## Overview

Customers will receive a certificate after completing all lessons in a course.

The certificate will be hosted on a dedicated page that can be used to verify the customer's learning achievement.

Here is an example certificate:

![CourseLit Example Certificate](/assets/products/courselit-certificate-example.jpg)

## Enable certificates for a course

1. Go to the manage section of your `Course` product.

![Product manage menu](/assets/products/product-manage-menu.png)

2. Scroll down to the `Certificates` section and toggle on the `Issue certificate` switch.

![Issue certificate switch](/assets/products/issue-certificate-toggle.png)

That's it! Your customers will now receive a certificate upon course completion.

> Previous customers will not receive certificates retroactively, as the certificate generation process is automated.

## Customize the certificate

1. Click on the `Chevron` icon on the far right of the `Certificate Template` subsection under the `Certificates` section to expand it.

![Certificate template customization expand button](/assets/products/certificate-template-customization-expand.png)

2. Here are the elements you can customize in a certificate:

- Certificate Title
- Certificate Subtitle
- Certificate Description
- Signature Name
- Signature Designation
- Signature Image
- Logo (defaults to school's logo if not provided)

3. Modify the text configuration to your preference and click `Save`.

![Customize certificate texts](/assets/products/customize-certificate-texts.png)

4. `Signature Image` and `Logo` properties are automatically saved.

## Preview the certificate

Click on the `Preview` link located to the left of the `Issue certificates` switch.

You will be taken to a new tab where you can see an actual certificate in PDF format with your changes applied.

![Preview certificate](/assets/products/preview-certificate.png)

## Customer's experience

Upon completing a course, customers will be taken back to the `My content` lobby. On the product card, they will see a badge indicating that a certificate is available.

![Certificate badge on product card](/assets/products/certificate-badge-product-card.png)

On the course's introduction page, customers will see a `View certificate` button.

![View certificate button on product's intro in course viewer](/assets/products/view-certificate-button.png)

When they click the `View certificate` button, customers are taken to the `Accomplishment` page, where they can view all certification details.

![Accomplishment Page](/assets/products/accomplishment-page.png)

Customers can also print their certificate by clicking the `Print` button.

## Stuck somewhere?

We are always here for you. Come chat with us in our <a href="https://discord.com/invite/GR4bQsN" target="_blank">Discord</a> channel or send a tweet at <a href="https://twitter.com/courselit" target="_blank">@CourseLit</a>.
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"use client";

import {
Badge,
Button,
Header1,
Header2,
Section,
Text1,
} from "@courselit/page-primitives";
import { redirect, useParams } from "next/navigation";
import { ThemeContext } from "@components/contexts";
import { useContext, useRef, useState } from "react";
import { BadgeCheck, ExternalLinkIcon, Printer } from "lucide-react";
import Link from "next/link";
import { Image } from "@courselit/components-library";
import { formattedLocaleDate } from "@ui-lib/utils";
import { useCertificate } from "@/hooks/use-certificate";
import { Skeleton } from "@/components/ui/skeleton";

export default function AccomplishmentPage() {
const params = useParams();
const certId = params.certId;
const { theme } = useContext(ThemeContext);
const [isIframeLoaded, setIsIframeLoaded] = useState(false);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const { certificate, loaded } = useCertificate(certId as string);

const handlePrint = () => {
const iframeElement = iframeRef.current;
if (!iframeElement) return;
const iframeWindow = iframeElement.contentWindow;
if (!iframeWindow) return;
iframeWindow.focus();
iframeWindow.print();
};

if (loaded && !certificate) {
redirect("/");
}

if (!certificate) {
return (
<Section theme={theme.theme}>
<div className="flex flex-col gap-8">
<div className="flex flex-col sm:flex-row gap-2 items-center">
<Skeleton className="h-7 sm:h-9 w-48 sm:w-80" />
<Skeleton className="h-6 w-20" />
</div>

{/* User completion info skeleton */}
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
<Skeleton className="h-24 w-24 sm:h-32 sm:w-32 md:h-40 md:w-40 rounded-full mx-auto sm:mx-0" />
<div className="flex flex-col gap-2 text-center sm:text-left w-full">
<Skeleton className="h-5 w-64 sm:w-80 mx-auto sm:mx-0" />
<Skeleton className="h-4 w-40 mx-auto sm:mx-0" />
</div>
</div>

{/* Certificate section header + viewer skeleton */}
<div className="flex flex-col gap-4">
<Skeleton className="h-6 w-32" />
<Skeleton className="w-full lg:h-[900px] h-[300px] md:h-[600px] rounded-lg" />
</div>
</div>
</Section>
);
}

return (
<Section theme={theme.theme}>
<div className="flex flex-col gap-8">
<div className="flex flex-col sm:flex-row gap-2 items-start sm:items-center">
<Header1 theme={theme.theme}>
{certificate.productTitle}
</Header1>
<div>
<Link href={`/p/${certificate.productPageId}`}>
<Badge
theme={theme.theme}
className="flex items-center gap-1"
>
Visit <ExternalLinkIcon className="h-4 w-4" />
</Badge>
</Link>
</div>
</div>

{/* User completion info - responsive layout */}
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
{certificate.userImage && (
<div className="h-24 w-24 sm:h-32 sm:w-32 md:h-40 md:w-40 rounded-full overflow-hidden flex-shrink-0 mx-auto sm:mx-0">
<Image
src={certificate.userImage.file}
alt={certificate.userName}
className="w-full h-full object-cover"
/>
</div>
)}
<div className="flex flex-col gap-2">
<Text1
theme={theme.theme}
className="flex items-center gap-2"
>
<BadgeCheck className="h-4 w-4 sm:h-5 sm:w-5" />
Completed by{" "}
<span className="font-bold">
{certificate.userName}
</span>
</Text1>
<Text1 theme={theme.theme} className="text-gray-500">
{formattedLocaleDate(+certificate.createdAt)}
</Text1>
</div>
</div>

{/* Certificate section header - responsive */}
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<Header2 theme={theme.theme}>Certificate</Header2>
<Button
theme={theme.theme}
onClick={handlePrint}
disabled={!isIframeLoaded}
>
<Printer className="h-4 w-4" />
Print
</Button>
</div>

<div className="relative w-full">
{!isIframeLoaded && (
<Skeleton className="w-full lg:h-[900px] h-[300px] md:h-[600px] rounded-lg" />
)}
<div
className={`transition-opacity duration-300 ${isIframeLoaded ? "opacity-100" : "opacity-0"}`}
style={{ overflow: "auto" }}
>
<div style={{ width: 1100, margin: "0 auto" }}>
<iframe
src={`/certificate/${certId}`}
style={{
width: 1100,
height: 850,
border: 0,
display: "block",
background: "white",
}}
ref={iframeRef}
onLoad={() => setIsIframeLoaded(true)}
/>
</div>
</div>
</div>
</div>
</div>
</Section>
);
}
35 changes: 33 additions & 2 deletions apps/web/app/(with-contexts)/course/[slug]/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
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 } from "@ui-config/strings";
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 {
Expand All @@ -20,6 +24,8 @@
SiteInfoContext,
} from "@components/contexts";
import { getProduct } from "./helpers";
import { getUserProfile } from "@/app/(with-contexts)/helpers";
import { BadgeCheck } from "lucide-react";
const { permissions } = UIConstants;

export default function ProductPage(props: {
Expand All @@ -28,9 +34,10 @@
const params = use(props.params);
const { id } = params;
const [product, setProduct] = useState<any>(null);
const { profile } = useContext(ProfileContext);
const { profile, setProfile } = useContext(ProfileContext);
const siteInfo = useContext(SiteInfoContext);
const address = useContext(AddressContext);
const [progress, setProgress] = useState<any>(null);

useEffect(() => {
if (id) {
Expand All @@ -38,8 +45,21 @@
setProduct(product);
});
}
}, [id]);

Check warning on line 48 in apps/web/app/(with-contexts)/course/[slug]/[id]/page.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect has a missing dependency: 'address.backend'. Either include it or remove the dependency array

useEffect(() => {
if (product) {
getUserProfile(address.backend).then((profile) => {
setProfile(profile);
setProgress(
profile.purchases?.find(
(purchase) => purchase.courseId === product.courseId,
),
);
});
}
}, [product]);

Check warning on line 61 in apps/web/app/(with-contexts)/course/[slug]/[id]/page.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect has missing dependencies: 'address.backend' and 'setProfile'. Either include them or remove the dependency array

if (!profile) {
return null;
}
Expand All @@ -51,6 +71,17 @@
return (
<div className="flex flex-col pb-[100px] lg:max-w-[40rem] xl:max-w-[48rem] mx-auto">
<h1 className="text-4xl font-semibold mb-8">{product.title}</h1>
{progress?.certificateId && (
<Link
href={`/accomplishment/${progress.certificateId}`}
className="mb-4"
>
<Button2>
<BadgeCheck className="h-4 w-4" />{" "}
{BTN_VIEW_CERTIFICATE}
</Button2>
</Link>
)}
{!isEnrolled(product.courseId, profile as Profile) &&
checkPermission(profile.permissions ?? [], [
permissions.enrollInCourse,
Expand Down
Loading
Loading