From 4f33e539b44e8d9c3387b1b1f1eda8a9df3a654e Mon Sep 17 00:00:00 2001 From: Rajat Date: Sat, 4 Oct 2025 19:36:47 +0000 Subject: [PATCH 1/9] WIP: added admin side settings for certificates; added routes to show certificate --- .../accomplishment/[certId]/page.tsx | 21 +++ .../course/[slug]/[id]/page.tsx | 28 ++- .../product/[id]/manage/certificate/page.tsx | 51 ++++++ .../(sidebar)/product/[id]/manage/page.tsx | 93 +++++----- .../dashboard/(sidebar)/profile/page.tsx | 27 ++- apps/web/app/(with-contexts)/helpers.ts | 23 +-- .../(with-contexts)/layout-with-context.tsx | 46 ----- .../certificate/internal/[certId]/page.tsx | 87 ++++++++++ .../certificate-data.ts | 27 +++ .../certificates-templates/default.tsx | 161 ++++++++++++++++++ .../components/public/payments/login-form.tsx | 45 +---- apps/web/config/strings.ts | 1 + apps/web/graphql/courses/helpers.ts | 7 + apps/web/graphql/courses/logic.ts | 18 +- apps/web/graphql/courses/types/index.ts | 2 + apps/web/graphql/lessons/logic.ts | 75 +++++++- apps/web/graphql/users/logic.ts | 56 +++++- apps/web/graphql/users/query.ts | 12 ++ apps/web/graphql/users/types.ts | 21 +++ apps/web/hooks/use-product.ts | 9 +- apps/web/models/Certificate.ts | 33 ++++ apps/web/models/CertificateTemplate.ts | 47 +++++ apps/web/next-env.d.ts | 1 - apps/web/ui-config/strings.ts | 2 + apps/web/ui-lib/utils.ts | 44 ----- packages/common-logic/src/models/course.ts | 2 + .../common-logic/src/models/user/progress.ts | 2 + packages/common-models/src/progress.ts | 1 + 28 files changed, 694 insertions(+), 248 deletions(-) create mode 100644 apps/web/app/(with-contexts)/(with-layout)/accomplishment/[certId]/page.tsx create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/certificate/page.tsx create mode 100644 apps/web/app/certificate/internal/[certId]/page.tsx create mode 100644 apps/web/components/certificates-templates/certificate-data.ts create mode 100644 apps/web/components/certificates-templates/default.tsx create mode 100644 apps/web/models/Certificate.ts create mode 100644 apps/web/models/CertificateTemplate.ts diff --git a/apps/web/app/(with-contexts)/(with-layout)/accomplishment/[certId]/page.tsx b/apps/web/app/(with-contexts)/(with-layout)/accomplishment/[certId]/page.tsx new file mode 100644 index 000000000..90e998727 --- /dev/null +++ b/apps/web/app/(with-contexts)/(with-layout)/accomplishment/[certId]/page.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { Section } from "@courselit/page-primitives"; +import { useParams } from "next/navigation"; +import { ThemeContext } from "@components/contexts"; +import { useContext } from "react"; + +export default function AccomplishmentPage() { + const params = useParams(); + const certId = params.certId; + const { theme } = useContext(ThemeContext); + + return ( +
+ + + +
); } diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/page.tsx index 1001ea3f2..19abbd69a 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/page.tsx @@ -7,7 +7,6 @@ import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Separator } from "@/components/ui/separator"; import { Trash2, Loader2 } from "lucide-react"; -import Link from "next/link"; import { AlertDialog, @@ -25,7 +24,6 @@ import { APP_MESSAGE_COURSE_SAVED, BTN_DELETE_COURSE, COURSE_SETTINGS_CARD_HEADER, - CUSTOMIZE_CERTIFICATE_TEMPLATE, DANGER_ZONE_HEADER, MANAGE_COURSES_PAGE_HEADING, PRICING_EMAIL, @@ -67,6 +65,7 @@ import { import { COURSE_TYPE_DOWNLOAD, MIMETYPE_IMAGE } from "@ui-config/constants"; import useProduct from "@/hooks/use-product"; import { usePaymentPlanOperations } from "@/hooks/use-payment-plan-operations"; +import { useGraphQLFetch } from "@/hooks/use-graphql-fetch"; import PaymentPlanList from "@components/admin/payments/payment-plan-list"; import { TooltipProvider, @@ -108,20 +107,6 @@ const MUTATIONS = { } } `, - // UPDATE_COST_TYPE: ` - // mutation UpdateCostType($courseId: String!, $costType: CostType!) { - // updateCourse(courseData: { id: $courseId, costType: $costType }) { - // courseId - // } - // } - // `, - // UPDATE_COST: ` - // mutation UpdateCost($courseId: String!, $cost: Float!) { - // updateCourse(courseData: { id: $courseId, cost: $cost }) { - // courseId - // } - // } - // `, UPDATE_LEAD_MAGNET: ` mutation UpdateLeadMagnet($courseId: String!, $leadMagnet: Boolean!) { updateCourse(courseData: { id: $courseId, leadMagnet: $leadMagnet }) { @@ -136,6 +121,29 @@ const MUTATIONS = { } } `, + UPDATE_CERTIFICATE_TEMPLATE: ` + mutation UpdateCertificateTemplate($courseId: String!, $title: String, $subtitle: String, $description: String, $signatureName: String, $signatureDesignation: String, $signatureImage: MediaInput, $logo: MediaInput) { + updateCourseCertificateTemplate(courseId: $courseId, title: $title, subtitle: $subtitle, description: $description, signatureName: $signatureName, signatureDesignation: $signatureDesignation, signatureImage: $signatureImage, logo: $logo) { + title + subtitle + description + signatureName + signatureDesignation + signatureImage { + mediaId + originalFileName + file + thumbnail + } + logo { + mediaId + originalFileName + file + thumbnail + } + } + } + `, }; const updateCourse = async (query: string, variables: any, address: string) => { @@ -179,7 +187,10 @@ export default function SettingsPage() { const router = useRouter(); const params = useParams(); const productId = params?.id as string; - const [errors, setErrors] = useState({}); + const [errors, setErrors] = useState>({}); + const [certificateTemplateErrors, setCertificateTemplateErrors] = useState< + Record + >({}); const address = useContext(AddressContext); const { product, loaded: productLoaded } = useProduct(productId); const profile = useContext(ProfileContext); @@ -205,6 +216,23 @@ export default function SettingsPage() { leadMagnet: false, certificate: false, }); + const [certificateTemplate, setCertificateTemplate] = useState<{ + title: string; + subtitle: string; + description: string; + signatureName: string; + signatureDesignation: string; + signatureImage: any; + logo: any; + }>({ + title: "", + subtitle: "", + description: "", + signatureName: "", + signatureDesignation: "", + signatureImage: {}, + logo: {}, + }); const breadcrumbs = [ { label: MANAGE_COURSES_PAGE_HEADING, href: "/dashboard/products" }, { @@ -217,6 +245,7 @@ export default function SettingsPage() { const [deleteConfirmation, setDeleteConfirmation] = useState(""); const [isDeleting, setIsDeleting] = useState(false); const siteinfo = useContext(SiteInfoContext); + const fetch = useGraphQLFetch(); const { paymentPlans, setPaymentPlans, @@ -230,6 +259,59 @@ export default function SettingsPage() { entityType: MembershipEntityType.COURSE, }); + const loadCertificateTemplate = async (courseId: string) => { + const query = ` + query GetCourseCertificateTemplate($courseId: String!) { + certificateTemplate: getCourseCertificateTemplate(courseId: $courseId) { + title + subtitle + description + signatureName + signatureDesignation + signatureImage { + mediaId + originalFileName + file + thumbnail + } + logo { + mediaId + originalFileName + file + thumbnail + } + } + } + `; + + try { + const fetchInstance = fetch + .setPayload({ + query, + variables: { courseId }, + }) + .build(); + + const response = await fetchInstance.exec(); + if (response.certificateTemplate) { + setCertificateTemplate({ + title: response.certificateTemplate.title || "", + subtitle: response.certificateTemplate.subtitle || "", + description: response.certificateTemplate.description || "", + signatureName: + response.certificateTemplate.signatureName || "", + signatureDesignation: + response.certificateTemplate.signatureDesignation || "", + signatureImage: + response.certificateTemplate.signatureImage || {}, + logo: response.certificateTemplate.logo || {}, + }); + } + } catch (err) { + console.error("Error loading certificate template:", err); + } + }; + useEffect(() => { if (product) { setFormData({ @@ -252,6 +334,11 @@ export default function SettingsPage() { setRefresh(refresh + 1); setPaymentPlans(product?.paymentPlans || []); setDefaultPaymentPlan(product?.defaultPaymentPlan || ""); + + // Load certificate template if certificate is enabled + if (product?.certificate && product?.courseId) { + loadCertificateTemplate(product.courseId); + } } }, [product]); @@ -307,7 +394,7 @@ export default function SettingsPage() { }; const validateForm = () => { - const newErrors = {}; + const newErrors: Record = {}; if (!formData.name.trim()) newErrors.name = "Name is required"; setErrors(newErrors); return Object.keys(newErrors).length === 0; @@ -351,6 +438,105 @@ export default function SettingsPage() { ); }; + const handleCertificateTemplateInputChange = ( + e: React.ChangeEvent, + ) => { + const { name, value } = e.target; + setCertificateTemplate((prev) => ({ ...prev, [name]: value })); + }; + + const validateCertificateTemplate = () => { + const newErrors: Record = {}; + + // Check description length if provided + if ( + certificateTemplate.description && + certificateTemplate.description.length > 400 + ) { + newErrors.description = + "Description must be 400 characters or less"; + } + + setCertificateTemplateErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const saveCertificateTemplate = async (e: FormEvent) => { + e.preventDefault(); + if (!validateCertificateTemplate() || !product?.courseId) return; + + const variables = { + courseId: product.courseId, + title: certificateTemplate.title, + subtitle: certificateTemplate.subtitle, + description: certificateTemplate.description, + signatureName: certificateTemplate.signatureName, + signatureDesignation: certificateTemplate.signatureDesignation, + }; + + await withErrorHandling( + () => + updateCourse( + MUTATIONS.UPDATE_CERTIFICATE_TEMPLATE, + variables, + address.backend, + ), + setLoading, + toast, + ); + }; + + const saveCertificateSignatureImage = async (media?: Media) => { + if (!product?.courseId) return; + + // Update local state immediately + setCertificateTemplate((prev) => ({ + ...prev, + signatureImage: media || {}, + })); + + // Prepare variables for the mutation + const variables = { + courseId: product.courseId, + signatureImage: media || null, + }; + + await withErrorHandling( + () => + updateCourse( + MUTATIONS.UPDATE_CERTIFICATE_TEMPLATE, + variables, + address.backend, + ), + setLoading, + toast, + ); + }; + + const saveCertificateLogo = async (media?: Media) => { + if (!product?.courseId) return; + + // Update local state immediately + setCertificateTemplate((prev) => ({ ...prev, logo: media || {} })); + + // Prepare variables for the mutation + const variables = { + courseId: product.courseId, + logo: media || null, + }; + + await withErrorHandling( + () => + updateCourse( + MUTATIONS.UPDATE_CERTIFICATE_TEMPLATE, + variables, + address.backend, + ), + setLoading, + toast, + ); + }; + const options: { label: string; value: string; @@ -490,7 +676,7 @@ export default function SettingsPage() {
-

Featured image

+

Featured image

The hero image for your course

@@ -522,7 +708,7 @@ export default function SettingsPage() { mimeTypesToShow={[...MIMETYPE_IMAGE]} access="public" strings={{}} - profile={profile as Profile} + profile={profile as unknown as Profile} address={address} mediaId={ (formData.featuredImage && @@ -544,7 +730,9 @@ export default function SettingsPage() {
- +

Manage your product's pricing plans

@@ -706,7 +894,7 @@ export default function SettingsPage() { disabled={!formData.isPublished} />
- {product?.type?.toLowerCase() === UIConstants.COURSE_TYPE_COURSE && ( + {/* {product?.type?.toLowerCase() === UIConstants.COURSE_TYPE_COURSE && (
- )} + )} */}
+ {product?.type?.toLowerCase() === + UIConstants.COURSE_TYPE_COURSE && ( + <> + +
+

+ Certificates +

+
+
+ +

+ Enable certificate for this course. +

+
+ + handleSwitchChange("certificate") + } + /> +
+
+
+

+ Certificate Template +

+ + {/* Form with explicit save button for text fields */} +
+
+ + + {certificateTemplateErrors.title && ( +

+ {certificateTemplateErrors.title} +

+ )} +
+ +
+ + + {certificateTemplateErrors.subtitle && ( +

+ {certificateTemplateErrors.subtitle} +

+ )} +
+ +
+ + +
+ {certificateTemplateErrors.description && ( +

+ { + certificateTemplateErrors.description + } +

+ )} +

400 ? "text-red-500" : "text-muted-foreground"}`} + > + { + certificateTemplate.description + .length + } + /400 characters +

+
+
+ +
+ + + {certificateTemplateErrors.signatureName && ( +

+ { + certificateTemplateErrors.signatureName + } +

+ )} +
+ +
+ + +
+ + +
+ + {/* Media uploads with automatic sync - outside the form */} +
+
+ +

+ Upload a signature image for the + certificate (saves automatically) +

+ { + media && + setCertificateTemplate( + (prev) => ({ + ...prev, + signatureImage: media, + }), + ); + saveCertificateSignatureImage( + media, + ); + }} + mimeTypesToShow={[...MIMETYPE_IMAGE]} + access="public" + strings={{}} + profile={profile as unknown as Profile} + address={address} + mediaId={ + (certificateTemplate.signatureImage && + certificateTemplate + .signatureImage.mediaId) || + "" + } + onRemove={() => { + setCertificateTemplate((prev) => ({ + ...prev, + signatureImage: {}, + })); + saveCertificateSignatureImage(); + }} + type="certificate" + /> +
+ +
+ +

+ Upload a logo for the certificate (saves + automatically) +

+ { + media && + setCertificateTemplate( + (prev) => ({ + ...prev, + logo: media, + }), + ); + saveCertificateLogo(media); + }} + mimeTypesToShow={[...MIMETYPE_IMAGE]} + access="public" + strings={{}} + profile={profile as unknown as Profile} + address={address} + mediaId={ + (certificateTemplate.logo && + certificateTemplate.logo + .mediaId) || + "" + } + onRemove={() => { + setCertificateTemplate((prev) => ({ + ...prev, + logo: {}, + })); + saveCertificateLogo(); + }} + type="certificate" + /> +
+
+
+ + )} +
diff --git a/apps/web/app/api/certificate/[certId]/route.ts b/apps/web/app/api/certificate/[certId]/route.ts new file mode 100644 index 000000000..449b18abf --- /dev/null +++ b/apps/web/app/api/certificate/[certId]/route.ts @@ -0,0 +1,133 @@ +import DomainModel, { Domain } from "@models/Domain"; +import { NextRequest } from "next/server"; +import { getCertificateInternal } from "@/graphql/users/logic"; +import puppeteer from "puppeteer"; +import pug from "pug"; +import path from "path"; +import { formattedLocaleDate } from "@ui-lib/utils"; +import { error } from "@/services/logger"; + +async function createTemplate(certificateData: any): Promise { + const templatePath = path.join( + process.cwd(), + "templates", + "certificates", + "default.pug", + ); + + if (!certificateData) { + throw new Error("Certificate data is undefined"); + } + + const formattedDate = formattedLocaleDate( + certificateData.createdAt, + "long", + ); + + const templateData = { + certificate: certificateData, + createdAt: formattedDate, + }; + + return pug.renderFile(templatePath, templateData); +} + +async function generatePdf(html: string): Promise { + const executablePath = + process.env.NODE_ENV === "production" + ? await puppeteer.executablePath("chrome") + : undefined; + + const browser = await puppeteer.launch({ + headless: true, + executablePath, + args: ["--no-sandbox", "--disable-setuid-sandbox"], + }); + + try { + const page = await browser.newPage(); + + await page.setContent(html, { waitUntil: "networkidle0" }); + + const pdfBuffer = await page.pdf({ + format: "letter", + landscape: true, + printBackground: true, + margin: { + top: "0", + right: "0", + bottom: "0", + left: "0", + }, + }); + + return Buffer.from(pdfBuffer); + } finally { + await browser.close(); + } +} + +async function updateCertificateDownloadTime(certId: string, domainId: any) { + const CertificateModel = (await import("@models/Certificate")).default; + + await CertificateModel.updateOne( + { certificateId: certId, domain: domainId }, + { lastDownloadedAt: new Date() }, + ); +} + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ certId: string }> }, +) { + const domain = await DomainModel.findOne({ + name: req.headers.get("domain"), + }); + if (!domain) { + return { error: { message: "Domain not found", status: 404 } }; + } + + const certId = (await params).certId; + if (!certId) { + return Response.json({ message: "Missing certId" }, { status: 400 }); + } + + try { + const certificateData = await getCertificateInternal( + certId, + (domain as any)._id, + ); + + if (!certificateData) { + return Response.json( + { error: "Certificate not found" }, + { status: 404 }, + ); + } + + const template = await createTemplate(certificateData); + + const pdfBuffer = await generatePdf(template); + + await updateCertificateDownloadTime(certId, (domain as any)._id); + + return new Response(pdfBuffer as any, { + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": `inline; filename="certificate-${certId}.pdf"`, + }, + }); + } catch (err: any) { + error(err.message, { + route: `/api/certificate/${certId}`, + stack: err.stack, + }); + return Response.json( + { + error: "Failed to generate certificate PDF", + details: err.message, + }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/api/media/[mediaId]/[type]/route.ts b/apps/web/app/api/media/[mediaId]/[type]/route.ts index c1864014a..87050d33a 100644 --- a/apps/web/app/api/media/[mediaId]/[type]/route.ts +++ b/apps/web/app/api/media/[mediaId]/[type]/route.ts @@ -10,6 +10,9 @@ import { auth } from "@/auth"; import CourseModel from "@models/Course"; import LessonModel, { Lesson } from "@models/Lesson"; import PageModel, { Page } from "@models/Page"; +import CertificateTemplateModel, { + CertificateTemplate, +} from "@models/CertificateTemplate"; const types = [ "course", @@ -18,6 +21,7 @@ const types = [ "user", "domain", "community", + "certificate", ] as const; type MediaType = (typeof types)[number]; @@ -168,6 +172,41 @@ async function isActionAllowed( return checkPermission(user.permissions, [ constants.permissions.manageCommunity, ]); + case "certificate": + const certificateTemplate = + await CertificateTemplateModel.findOne({ + domain: domain._id, + $or: [ + { "signatureImage.mediaId": mediaId }, + { "logo.mediaId": mediaId }, + ], + }); + if (!certificateTemplate) { + return false; + } + const certificateCourse = await CourseModel.findOne( + { + domain: domain._id, + courseId: certificateTemplate.courseId, + }, + ); + if (!certificateCourse) { + return false; + } + if ( + checkPermission(user.permissions, [ + constants.permissions.manageAnyCourse, + ]) + ) { + return true; + } else { + return ( + certificateCourse.creatorId === user.userId && + checkPermission(user.permissions, [ + constants.permissions.manageCourse, + ]) + ); + } default: return false; } diff --git a/apps/web/app/certificate/internal/[certId]/page.tsx b/apps/web/app/certificate/internal/[certId]/page.tsx deleted file mode 100644 index 5f530c0b6..000000000 --- a/apps/web/app/certificate/internal/[certId]/page.tsx +++ /dev/null @@ -1,87 +0,0 @@ -"use client" - -import { useGraphQLFetch } from "@/hooks/use-graphql-fetch"; -import { AddressContext, ThemeContext } from "@components/contexts"; -import { useParams } from "next/navigation"; -import { useContext, useEffect, useState } from "react"; - -import DefaultCertificateTemplate from "@/components/certificates-templates/default"; - -const templatesMap = { - default: DefaultCertificateTemplate, -} - -export default function CertificatePage() { - const { theme } = useContext(ThemeContext); - const address = useContext(AddressContext); - const [certificate, setCertificate] = useState(null); - const params = useParams(); - const certId = params?.certId as string; - const fetch = useGraphQLFetch(); - const Template = templatesMap[certificate?.templateId] || DefaultCertificateTemplate; - - useEffect(() => { - async function getCertificate() { - const query = ` - query GetCertificate($certificateId: String!) { - certificate: getCertificate(certificateId: $certificateId) { - certificateId - title - subtitle - description - signatureImage { - mediaId - file - thumbnail - } - signatureName - signatureDesignation - logo { - mediaId - file - thumbnail - } - productTitle - userName - createdAt - userImage { - mediaId - file - thumbnail - } - productPageId - } - } - `; - const fetchRequest = fetch - .setPayload({ - query, - variables: { - certificateId: certId, - }, - }) - .build(); - - try { - const response = await fetchRequest.exec(); - if (response.certificate) { - setCertificate(response.certificate); - } - } catch (error) { - console.error(error); - } - } - - if (certId) { - getCertificate(); - } - }, [certId]); - - if (!certificate) { - return null; - } - - return ( -