diff --git a/frontends/api/package.json b/frontends/api/package.json index 9a85853dbc..e7763145fa 100644 --- a/frontends/api/package.json +++ b/frontends/api/package.json @@ -30,7 +30,7 @@ "ol-test-utilities": "0.0.0" }, "dependencies": { - "@mitodl/mitxonline-api-axios": "2025.8.12", + "@mitodl/mitxonline-api-axios": "2025.8.18", "@tanstack/react-query": "^5.66.0", "axios": "^1.6.3" } diff --git a/frontends/main/next.config.js b/frontends/main/next.config.js index 9ee6c74782..f2f968dbb7 100644 --- a/frontends/main/next.config.js +++ b/frontends/main/next.config.js @@ -92,16 +92,7 @@ const nextConfig = { images: { remotePatterns: [ { - protocol: "http", hostname: "**", - port: "", - pathname: "**", - }, - { - protocol: "https", - hostname: "**", - port: "", - pathname: "**", }, ], }, diff --git a/frontends/main/public/images/certificate-badge-desktop.svg b/frontends/main/public/images/certificate-badge-desktop.svg new file mode 100644 index 0000000000..d94accad15 --- /dev/null +++ b/frontends/main/public/images/certificate-badge-desktop.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontends/main/public/images/certificate-badge-mobile.svg b/frontends/main/public/images/certificate-badge-mobile.svg new file mode 100644 index 0000000000..e2e09beda8 --- /dev/null +++ b/frontends/main/public/images/certificate-badge-mobile.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontends/main/public/images/mit-learn-logo-black.svg b/frontends/main/public/images/mit-learn-logo-black.svg new file mode 100644 index 0000000000..7fa921fc6c --- /dev/null +++ b/frontends/main/public/images/mit-learn-logo-black.svg @@ -0,0 +1,5 @@ + + + diff --git a/frontends/main/public/images/mit-open-learning-logo.svg b/frontends/main/public/images/mit-open-learning-logo.svg new file mode 100644 index 0000000000..9f54342c30 --- /dev/null +++ b/frontends/main/public/images/mit-open-learning-logo.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx b/frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx new file mode 100644 index 0000000000..2cc5b029ba --- /dev/null +++ b/frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx @@ -0,0 +1,502 @@ +"use client" + +import React from "react" +import { useParams } from "next/navigation" +import Image from "next/image" +import { Link, Typography, styled } from "ol-components" +import backgroundImage from "@/public/images/backgrounds/error_page_background.svg" +import { certificateQueries } from "api/mitxonline-hooks/certificates" +import { useQuery } from "@tanstack/react-query" +import OpenLearningLogo from "@/public/images/mit-open-learning-logo.svg" +import CertificateBadgeDesktop from "@/public/images/certificate-badge-desktop.svg" +import CertificateBadgeMobile from "@/public/images/certificate-badge-mobile.svg" +import { formatDate, NoSSR } from "ol-utilities" + +const Page = styled.div(({ theme }) => ({ + backgroundImage: `url(${backgroundImage.src})`, + backgroundAttachment: "fixed", + backgroundRepeat: "no-repeat", + backgroundSize: "contain", + display: "flow-root", + flexGrow: 1, + height: "100%", + padding: "0 16px 90px", + [theme.breakpoints.down("sm")]: { + backgroundImage: "none", + padding: "0 40px 40px", + }, +})) + +const Title = styled(Typography)(({ theme }) => ({ + margin: "60px 160px 40px", + textAlign: "center", + span: { + fontWeight: theme.typography.fontWeightLight, + color: theme.custom.colors.darkGray2, + }, + [theme.breakpoints.down("lg")]: { + span: { + fontSize: theme.typography.pxToRem(26), + lineHeight: theme.typography.pxToRem(30), + }, + }, + [theme.breakpoints.down("md")]: { + textAlign: "left", + margin: "24px 0", + span: { + fontSize: theme.typography.pxToRem(24), + lineHeight: theme.typography.pxToRem(30), + }, + }, +})) + +const Certificate = styled.div(({ theme }) => ({ + maxWidth: "1306px", + minWidth: "1200px", + border: `4px solid ${theme.custom.colors.silverGray}`, + padding: "24px 23px", + backgroundColor: theme.custom.colors.white, + marginTop: "50px", + margin: "0 auto", + [theme.breakpoints.down("lg")]: { + padding: 0, + border: "none", + maxWidth: "unset", + minWidth: "unset", + }, +})) + +const Inner = styled.div(({ theme }) => ({ + border: `1px solid ${theme.custom.colors.silverGrayLight}`, + padding: "67px", + display: "flex", + flexDirection: "column", + gap: "56px", + position: "relative", + [theme.breakpoints.down("lg")]: { + padding: "40px", + gap: "40px", + }, + [theme.breakpoints.down("md")]: { + border: `2px solid ${theme.custom.colors.lightGray2}`, + padding: "24px 16px", + gap: "24px", + textAlign: "center", + }, +})) + +const Logo = styled(Image)(({ theme }) => ({ + width: "260px", + height: "auto", + [theme.breakpoints.down("lg")]: { + width: "230px", + height: "59px", + }, + [theme.breakpoints.down("md")]: { + width: "129px", + margin: "0 auto", + }, +})) + +const Badge = styled.div(({ theme }) => ({ + backgroundImage: `url(${CertificateBadgeDesktop.src})`, + position: "absolute", + top: 0, + right: "67px", + width: "230px", + height: "391px", + textAlign: "center", + padding: "81px 34px", + backgroundRepeat: "no-repeat", + [theme.breakpoints.down("lg")]: { + backgroundImage: `url(${CertificateBadgeMobile.src})`, + top: "24px", + right: "40px", + width: "156px", + height: "191px", + }, + [theme.breakpoints.down("md")]: { + position: "relative", + height: "191px", + top: 0, + right: 0, + margin: "0 auto", + }, +})) + +const BadgeText = styled(Typography)(({ theme }) => ({ + color: theme.custom.colors.white, + position: "absolute", + top: "169px", + right: "26px", + width: "175px", + textAlign: "center", + [theme.breakpoints.down("lg")]: { + fontSize: theme.typography.pxToRem(16), + lineHeight: "150%", + fontWeight: theme.typography.fontWeightMedium, + top: "53px", + right: "18px", + width: "119px", + }, + [theme.breakpoints.down("md")]: { + width: "130px", + position: "absolute", + top: "50px", + right: "50%", + transform: "translateX(50%)", + }, +})) + +const Certification = styled.div(({ theme }) => ({ + display: "flex", + flexDirection: "column", + gap: "16px", + maxWidth: "850px", + ".MuiTypography-h4": { + fontWeight: theme.typography.fontWeightLight, + color: theme.custom.colors.silverGrayDark, + }, + [theme.breakpoints.down("lg")]: { + ".MuiTypography-h4": { + fontSize: theme.typography.pxToRem(16), + }, + }, + [theme.breakpoints.down("md")]: { + gap: 0, + ".MuiTypography-h4": { + fontSize: theme.typography.pxToRem(16), + fontWeight: theme.typography.fontWeightMedium, + lineHeight: "150%", + color: theme.custom.colors.silverGrayDark, + }, + }, +})) + +const NameText = styled(Typography)(({ theme }) => ({ + color: theme.custom.colors.red, + display: "block", + [theme.breakpoints.down("lg")]: { + fontSize: theme.typography.pxToRem(34), + lineHeight: theme.typography.pxToRem(40), + }, + [theme.breakpoints.down("md")]: { + fontSize: theme.typography.pxToRem(24), + lineHeight: theme.typography.pxToRem(30), + marginTop: "4px", + }, +})) + +const AchievementText = styled(Typography)(({ theme }) => ({ + fontWeight: theme.typography.fontWeightLight, + fontSize: theme.typography.pxToRem(20), + lineHeight: theme.typography.pxToRem(26), + color: theme.custom.colors.silverGrayDark, + strong: { + fontWeight: theme.typography.fontWeightBold, + }, + [theme.breakpoints.down("lg")]: { + fontSize: theme.typography.pxToRem(16), + lineHeight: theme.typography.pxToRem(24), + }, + [theme.breakpoints.down("md")]: { + fontSize: theme.typography.pxToRem(14), + lineHeight: theme.typography.pxToRem(18), + marginTop: "16px", + }, +})) + +const CourseInfo = styled.div(({ theme }) => ({ + display: "flex", + flexDirection: "column", + gap: "16px", + ".MuiTypography-h4": { + fontWeight: theme.typography.fontWeightLight, + color: theme.custom.colors.silverGrayDark, + }, + [theme.breakpoints.down("lg")]: { + ".MuiTypography-h2": { + fontSize: theme.typography.pxToRem(28), + lineHeight: theme.typography.pxToRem(36), + }, + ".MuiTypography-h4": { + fontSize: theme.typography.pxToRem(16), + lineHeight: theme.typography.pxToRem(20), + }, + }, + [theme.breakpoints.down("md")]: { + ".MuiTypography-h2": { + fontSize: theme.typography.pxToRem(18), + lineHeight: theme.typography.pxToRem(26), + fontWeight: theme.typography.fontWeightMedium, + }, + ".MuiTypography-h4": { + fontSize: theme.typography.pxToRem(14), + lineHeight: theme.typography.pxToRem(18), + color: theme.custom.colors.darkGray1, + }, + }, +})) + +const Spacer = styled.div(({ theme }) => ({ + height: "30px", + [theme.breakpoints.down("lg")]: { + display: "none", + }, +})) + +const Signatories = styled.div(({ theme }) => ({ + display: "flex", + flexDirection: "row", + gap: "16px", + width: "100%", + [theme.breakpoints.down("md")]: { + flexDirection: "column", + gap: "24px", + }, +})) + +const Signatory = styled.div(({ theme }) => ({ + flex: "1 1 0", + minWidth: 0, + span: { + display: "block", + }, + ".MuiTypography-body1": { + color: theme.custom.colors.silverGrayDark, + }, + [theme.breakpoints.down("lg")]: { + ".MuiTypography-body1": { + fontSize: theme.typography.pxToRem(12), + lineHeight: theme.typography.pxToRem(16), + }, + ".MuiTypography-body1:last-child": { + marginTop: "8px", + }, + }, + [theme.breakpoints.down("md")]: { + ".MuiTypography-body1": { + color: theme.custom.colors.darkGray1, + fontSize: theme.typography.pxToRem(14), + lineHeight: theme.typography.pxToRem(18), + }, + borderBottom: `1px solid ${theme.custom.colors.lightGray2}`, + paddingBottom: "24px", + p: { + marginTop: "8px", + }, + }, +})) + +const Signature = styled.img(({ theme }) => ({ + width: "auto", + height: "60px", + [theme.breakpoints.down("lg")]: { + height: "54px", + }, + [theme.breakpoints.down("md")]: { + height: "40px", + }, +})) + +const SignatoryName = styled(Typography)(({ theme }) => ({ + marginBottom: "8px", + [theme.breakpoints.down("lg")]: { + fontSize: theme.typography.pxToRem(18), + lineHeight: theme.typography.pxToRem(26), + fontWeight: theme.typography.fontWeightMedium, + }, + [theme.breakpoints.down("md")]: { + fontSize: theme.typography.pxToRem(16), + lineHeight: "150%", + marginTop: "16px", + }, +})) + +const CertificateId = styled(Typography)(({ theme }) => ({ + span: { + color: theme.custom.colors.silverGrayDark, + }, + [theme.breakpoints.down("lg")]: { + fontSize: theme.typography.pxToRem(12), + lineHeight: theme.typography.pxToRem(16), + }, + [theme.breakpoints.down("md")]: { + fontSize: theme.typography.pxToRem(14), + lineHeight: theme.typography.pxToRem(20), + span: { + display: "block", + }, + }, +})) + +const Note = styled(Typography)(({ theme }) => ({ + margin: "48px 0 64px 0", + textAlign: "center", + fontSize: theme.typography.pxToRem(16), + a: { + color: theme.custom.colors.red, + textDecoration: "underline", + fontSize: theme.typography.pxToRem(16), + }, + [theme.breakpoints.down("lg")]: { + fontSize: theme.typography.pxToRem(14), + lineHeight: theme.typography.pxToRem(18), + a: { + fontSize: theme.typography.pxToRem(14), + }, + }, + [theme.breakpoints.down("md")]: { + margin: "32px 0 16px", + textAlign: "left", + }, +})) + +enum CertificateType { + Course = "course", + Program = "program", +} + +const CertificatePage: React.FC = () => { + const { certificateType, uuid } = useParams<{ + certificateType: CertificateType + uuid: string + }>() + + console.log("certificateType", certificateType) + console.log("uuid", uuid) + + const { data: courseCertData, isLoading: isCourseLoading } = useQuery({ + ...certificateQueries.courseCertificatesRetrieve({ + cert_uuid: uuid, + }), + enabled: certificateType === CertificateType.Course, + }) + + const { data: programCertData, isLoading: isProgramLoading } = useQuery({ + ...certificateQueries.programCertificatesRetrieve({ + cert_uuid: uuid, + }), + enabled: certificateType === CertificateType.Program, + }) + + const isLoading = isCourseLoading || isProgramLoading + + if (isLoading) { + return + } + + const title = + certificateType === CertificateType.Course + ? courseCertData?.course_run?.course?.title + : programCertData?.program?.title + + const displayType = + certificateType === CertificateType.Course + ? "Module Certificate" + : `${programCertData?.program?.program_type} Certificate` + + const userName = + certificateType === CertificateType.Course + ? courseCertData?.user?.name + : programCertData?.user?.name + + const shortDisplayType = + certificateType === CertificateType.Course + ? "module" + : programCertData?.program?.program_type === "Series" + ? "series" + : `${programCertData?.program?.program_type} program` + + const ceus = + certificateType === CertificateType.Course + ? null + : programCertData?.certificate_page?.CEUs + + const signatories = + certificateType === CertificateType.Course + ? courseCertData?.certificate_page?.signatory_items + : programCertData?.certificate_page?.signatory_items + + const startDate = + certificateType === CertificateType.Course + ? courseCertData?.course_run?.start_date + : programCertData?.program?.start_date + + const endDate = + certificateType === CertificateType.Course + ? courseCertData?.course_run?.end_date + : programCertData?.program?.end_date + + return ( + + + <Typography variant="h3"> + <strong>{title}</strong> {displayType} + </Typography> + + + + + + {displayType} + + + This is to certify that + {userName} + + has successfully completed all requirements of the{" "} + Universal Artificial Intelligence{" "} + {shortDisplayType}: + + + + {title} + {ceus ? ( + + Awarded {ceus} Continuing Education Units (CEUs) + + ) : null} + {startDate && endDate && ( + + + {formatDate(startDate)} - {formatDate(endDate)} + + + )} + {ceus ? null : } + + + {signatories?.map((signatory, index) => ( + + + {signatory.name} + {signatory.title_1} + {signatory.title_2} + + {signatory.organization} + + + ))} + + + Valid Certificate ID: {uuid} + + + + + Note: The name displayed on your certificate is based + on your{" "} + + Profile + {" "} + information. + + + ) +} + +export default CertificatePage diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx index e697569c04..0bbf1ec12f 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx @@ -338,6 +338,7 @@ const DashboardCard: React.FC = ({ }) => { const course = dashboardResource as DashboardCourse const { title, marketingUrl, enrollment, run } = course + const titleSection = isLoading ? ( <> @@ -353,8 +354,9 @@ const DashboardCard: React.FC = ({ > {title} - {enrollment?.status === EnrollmentStatus.Completed ? ( - + {enrollment?.status === EnrollmentStatus.Completed && + run.certificate?.link ? ( + {} View Certificate diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/transform.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/transform.ts index 0065d5eda5..b96af4ce58 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/transform.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/transform.ts @@ -55,7 +55,11 @@ const mitxonlineEnrollment = (raw: CourseRunEnrollment): DashboardCourse => { coursewareUrl: raw.run.courseware_url, certificate: { uuid: raw.certificate?.uuid ?? "", - link: raw.certificate?.link ?? "", + link: + raw.certificate?.link?.replace( + /^\/certificate\/([^/]+)\/$/, + "/certificate/course/$1/", + ) ?? "", }, }, enrollment: { diff --git a/frontends/main/src/app-pages/ErrorPage/ErrorPageTemplate.tsx b/frontends/main/src/app-pages/ErrorPage/ErrorPageTemplate.tsx index f77e04b1b7..173e2aac8d 100644 --- a/frontends/main/src/app-pages/ErrorPage/ErrorPageTemplate.tsx +++ b/frontends/main/src/app-pages/ErrorPage/ErrorPageTemplate.tsx @@ -13,7 +13,7 @@ type ErrorPageTemplateProps = { loading?: boolean } -const Page = styled(Container)(({ theme }) => ({ +const Page = styled.div(({ theme }) => ({ backgroundImage: `url(${backgroundImage.src})`, backgroundAttachment: "fixed", backgroundRepeat: "no-repeat", @@ -25,7 +25,7 @@ const Page = styled(Container)(({ theme }) => ({ }, })) -const ErrorContainer = styled.div(({ theme }) => ({ +const ErrorContainer = styled(Container)(({ theme }) => ({ padding: "56px 0", display: "flex", flexDirection: "column", diff --git a/frontends/main/src/app/certificate/[certificateType]/[uuid]/page.tsx b/frontends/main/src/app/certificate/[certificateType]/[uuid]/page.tsx new file mode 100644 index 0000000000..044e755952 --- /dev/null +++ b/frontends/main/src/app/certificate/[certificateType]/[uuid]/page.tsx @@ -0,0 +1,31 @@ +import React from "react" +import { notFound } from "next/navigation" +import CertificatePage from "@/app-pages/CertificatePage/CertificatePage" + +enum CertificateType { + Course = "course", + Program = "program", +} + +interface PageProps { + params: Promise<{ + certificateType: CertificateType + uuid: string + }> +} + +const Page: React.FC = async ({ params }) => { + const { certificateType } = await params + + if ( + ![CertificateType.Course, CertificateType.Program].includes( + certificateType as CertificateType, + ) + ) { + notFound() + } + + return +} + +export default Page diff --git a/yarn.lock b/yarn.lock index d080767835..9da5db8882 100755 --- a/yarn.lock +++ b/yarn.lock @@ -3028,6 +3028,16 @@ __metadata: languageName: node linkType: hard +"@mitodl/mitxonline-api-axios@npm:2025.8.18": + version: 2025.8.18 + resolution: "@mitodl/mitxonline-api-axios@npm:2025.8.18" + dependencies: + "@types/node": "npm:^20.11.19" + axios: "npm:^1.6.5" + checksum: 10/91ff2d63fd251ef07dcea8c34497ddbd3b24046ba639b5ac6819d7bcdef560ea4838ecfd2e8db7876d4611072c7d37250d0bd8bc7e643d96d17d61b4db0c192e + languageName: node + linkType: hard + "@mitodl/open-api-axios@npm:2024.9.16": version: 2024.9.16 resolution: "@mitodl/open-api-axios@npm:2024.9.16" @@ -7363,7 +7373,7 @@ __metadata: resolution: "api@workspace:frontends/api" dependencies: "@faker-js/faker": "npm:^9.9.0" - "@mitodl/mitxonline-api-axios": "npm:2025.8.12" + "@mitodl/mitxonline-api-axios": "npm:2025.8.18" "@tanstack/react-query": "npm:^5.66.0" "@testing-library/react": "npm:^16.1.0" axios: "npm:^1.6.3"