diff --git a/frontends/main/src/app-pages/CertificatePage/CertificatePage.test.tsx b/frontends/main/src/app-pages/CertificatePage/CertificatePage.test.tsx
index 4015429088..37b61afb3a 100644
--- a/frontends/main/src/app-pages/CertificatePage/CertificatePage.test.tsx
+++ b/frontends/main/src/app-pages/CertificatePage/CertificatePage.test.tsx
@@ -1,9 +1,15 @@
import React from "react"
import moment from "moment"
import { factories, setMockResponse } from "api/test-utils"
-import { screen, renderWithProviders } from "@/test-utils"
+import { screen, renderWithProviders, user } from "@/test-utils"
import CertificatePage, { CertificateType } from "./CertificatePage"
+import SharePopover from "./SharePopover"
import { urls } from "api/mitxonline-test-utils"
+import {
+ FACEBOOK_SHARE_BASE_URL,
+ TWITTER_SHARE_BASE_URL,
+ LINKEDIN_SHARE_BASE_URL,
+} from "@/common/urls"
describe("CertificatePage", () => {
it("renders a course certificate", async () => {
@@ -18,6 +24,7 @@ describe("CertificatePage", () => {
,
)
@@ -80,6 +87,7 @@ describe("CertificatePage", () => {
,
)
@@ -99,3 +107,70 @@ describe("CertificatePage", () => {
await screen.findAllByText(certificate.uuid)
})
})
+
+describe("CertificatePage - SharePopover", () => {
+ const mockProps = {
+ open: true,
+ title: "Test Certificate",
+ anchorEl: document.createElement("div"),
+ onClose: jest.fn(),
+ pageUrl: "https://example.com/certificate/123",
+ }
+
+ const mockWriteText = jest.fn()
+ Object.assign(navigator, {
+ clipboard: {
+ writeText: mockWriteText,
+ },
+ })
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it("renders the SharePopover with correct content", () => {
+ renderWithProviders()
+
+ expect(screen.getByText("Share on social")).toBeInTheDocument()
+ expect(screen.getByText("Share a link")).toBeInTheDocument()
+ expect(screen.getByDisplayValue(mockProps.pageUrl)).toBeInTheDocument()
+ expect(screen.getByText("Copy Link")).toBeInTheDocument()
+ })
+
+ it("renders social media share links with correct URLs", () => {
+ renderWithProviders()
+
+ const facebookHref = `${FACEBOOK_SHARE_BASE_URL}?u=${encodeURIComponent(mockProps.pageUrl)}`
+ const twitterHref = `${TWITTER_SHARE_BASE_URL}?text=${encodeURIComponent(mockProps.title)}&url=${encodeURIComponent(mockProps.pageUrl)}`
+ const linkedinHref = `${LINKEDIN_SHARE_BASE_URL}?url=${encodeURIComponent(mockProps.pageUrl)}`
+
+ const facebookLink = screen.getByRole("link", { name: "Share on Facebook" })
+ const twitterLink = screen.getByRole("link", { name: "Share on Twitter" })
+ const linkedinLink = screen.getByRole("link", { name: "Share on LinkedIn" })
+
+ expect(facebookLink).toHaveAttribute("href", facebookHref)
+ expect(twitterLink).toHaveAttribute("href", twitterHref)
+ expect(linkedinLink).toHaveAttribute("href", linkedinHref)
+
+ expect(facebookLink).toHaveAttribute("target", "_blank")
+ expect(twitterLink).toHaveAttribute("target", "_blank")
+ expect(linkedinLink).toHaveAttribute("target", "_blank")
+ })
+
+ it("copies link to clipboard when copy button is clicked", async () => {
+ renderWithProviders()
+
+ const copyButton = screen.getByRole("button", { name: "Copy Link" })
+ await user.click(copyButton)
+
+ expect(mockWriteText).toHaveBeenCalledWith(mockProps.pageUrl)
+ screen.getByRole("button", { name: "Copied!" })
+ })
+
+ it("does not render when open is false", () => {
+ renderWithProviders()
+
+ expect(screen.queryByText("Share on social")).not.toBeInTheDocument()
+ expect(screen.queryByText("Share a link")).not.toBeInTheDocument()
+ })
+})
diff --git a/frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx b/frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx
index a9a2ae60bf..f8317c5b7b 100644
--- a/frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx
+++ b/frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx
@@ -1,6 +1,6 @@
"use client"
-import React, { useRef, useEffect, useCallback } from "react"
+import React, { useRef, useEffect, useCallback, useState } from "react"
import { notFound } from "next/navigation"
import Image from "next/image"
import { Link, Typography, styled } from "ol-components"
@@ -12,12 +12,13 @@ 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"
-import { RiDownloadLine, RiPrinterLine } from "@remixicon/react"
+import { RiDownloadLine, RiPrinterLine, RiShareLine } from "@remixicon/react"
import type {
V2ProgramCertificate,
V2CourseRunCertificate,
SignatoryItem,
} from "@mitodl/mitxonline-api-axios/v2"
+import SharePopover from "./SharePopover"
const Page = styled.div(({ theme }) => ({
backgroundImage: `url(${backgroundImage.src})`,
@@ -57,12 +58,16 @@ const Title = styled(Typography)(({ theme }) => ({
},
}))
-const Buttons = styled.div({
+const Buttons = styled.div(({ theme }) => ({
display: "flex",
gap: "12px",
justifyContent: "center",
- marginBottom: "50px",
-})
+ width: "fit-content",
+ margin: "0 auto 50px auto",
+ [theme.breakpoints.down("md")]: {
+ margin: "0 auto 32px auto",
+ },
+}))
const Outer = styled.div(({ theme }) => ({
maxWidth: "1306px",
@@ -640,7 +645,8 @@ export enum CertificateType {
const CertificatePage: React.FC<{
certificateType: CertificateType
uuid: string
-}> = ({ certificateType, uuid }) => {
+ pageUrl: string
+}> = ({ certificateType, uuid, pageUrl }) => {
const {
data: courseCertificateData,
isLoading: isCourseLoading,
@@ -694,6 +700,9 @@ const CertificatePage: React.FC<{
}
}, [print])
+ const [shareOpen, setShareOpen] = useState(false)
+ const shareButtonRef = useRef(null)
+
if (isCourseLoading || isProgramLoading) {
return
}
@@ -709,7 +718,7 @@ const CertificatePage: React.FC<{
const url = window.URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
- a.download = `${title} Certificate - MIT Open Learning.pdf`
+ a.download = `${title} Certificate issued by MIT Open Learning.pdf`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
@@ -728,12 +737,19 @@ const CertificatePage: React.FC<{
return (
+ setShareOpen(false)}
+ pageUrl={pageUrl}
+ />
{title} {displayType}
-
+
}
@@ -741,6 +757,13 @@ const CertificatePage: React.FC<{
>
Download PDF
+ }
+ onClick={() => setShareOpen(true)}
+ >
+ Share
+
}
diff --git a/frontends/main/src/app-pages/CertificatePage/SharePopover.tsx b/frontends/main/src/app-pages/CertificatePage/SharePopover.tsx
new file mode 100644
index 0000000000..3b1b4ba065
--- /dev/null
+++ b/frontends/main/src/app-pages/CertificatePage/SharePopover.tsx
@@ -0,0 +1,164 @@
+import React, { useState } from "react"
+import { Popover, Typography, styled, theme } from "ol-components"
+import Link from "next/link"
+import {
+ FACEBOOK_SHARE_BASE_URL,
+ TWITTER_SHARE_BASE_URL,
+ LINKEDIN_SHARE_BASE_URL,
+} from "@/common/urls"
+import {
+ RiFacebookFill,
+ RiTwitterXLine,
+ RiLinkedinFill,
+ RiLink,
+} from "@remixicon/react"
+import { Button, Input } from "@mitodl/smoot-design"
+
+const StyledPopover = styled(Popover)({
+ width: "648px",
+ maxWidth: "calc(100vw - 48px)",
+ ".MuiPopper-arrow": {
+ display: "none",
+ },
+})
+
+const Contents = styled.div(({ theme }) => ({
+ padding: "8px",
+ display: "flex",
+ gap: "40px",
+ [theme.breakpoints.down("sm")]: {
+ flexDirection: "column",
+ gap: "32px",
+ },
+}))
+
+const SocialContainer = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ gap: "16px",
+})
+
+const LinkContainer = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ gap: "12px",
+ flex: 1,
+})
+
+const Heading = styled(Typography)(({ theme }) => ({
+ ...theme.typography.body2,
+ color: theme.custom.colors.darkGray2,
+ fontWeight: theme.typography.fontWeightBold,
+}))
+
+const ButtonContainer = styled.div({
+ display: "flex",
+ alignSelf: "stretch",
+ gap: "16px",
+ a: {
+ height: "18px",
+ },
+})
+
+const ShareLink = styled(Link)({
+ color: theme.custom.colors.silverGrayDark,
+ "&:hover": {
+ color: theme.custom.colors.lightRed,
+ },
+})
+
+const LinkControls = styled.div(({ theme }) => ({
+ display: "flex",
+ gap: "16px",
+ input: {
+ ...theme.typography.body3,
+ color: theme.custom.colors.darkGray2,
+ padding: "0 3px",
+ },
+}))
+
+const RedLinkIcon = styled(RiLink)({
+ color: theme.custom.colors.red,
+})
+
+const CopyLinkButton = styled(Button)({
+ minWidth: "104px",
+})
+
+const SharePopover = ({
+ open,
+ title,
+ anchorEl,
+ onClose,
+ pageUrl,
+}: {
+ open: boolean
+ title: string
+ anchorEl: HTMLDivElement | null
+ onClose: () => void
+ pageUrl: string
+}) => {
+ const [copyText, setCopyText] = useState("Copy Link")
+
+ return (
+
+
+
+ Share on social
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Share a link
+
+ {
+ const input = event.currentTarget.querySelector("input")
+ if (!input) return
+ input.select()
+ }}
+ />
+ }
+ onClick={() => {
+ navigator.clipboard?.writeText(pageUrl)
+ setCopyText("Copied!")
+ }}
+ >
+ {copyText}
+
+
+
+
+
+ )
+}
+
+export default SharePopover
diff --git a/frontends/main/src/app/certificate/[certificateType]/[uuid]/page.tsx b/frontends/main/src/app/certificate/[certificateType]/[uuid]/page.tsx
index 2b3f485cd6..9cc044384f 100644
--- a/frontends/main/src/app/certificate/[certificateType]/[uuid]/page.tsx
+++ b/frontends/main/src/app/certificate/[certificateType]/[uuid]/page.tsx
@@ -1,16 +1,69 @@
import React from "react"
+import { Metadata } from "next"
import CertificatePage from "@/app-pages/CertificatePage/CertificatePage"
import { prefetch } from "api/ssr/prefetch"
import { certificateQueries } from "api/mitxonline-hooks/certificates"
import { HydrationBoundary } from "@tanstack/react-query"
import { isInEnum } from "@/common/utils"
import { notFound } from "next/navigation"
+import { standardizeMetadata } from "@/common/metadata"
+import { courseCertificatesApi, programCertificatesApi } from "api/mitxonline"
+import * as Sentry from "@sentry/nextjs"
+
+const { NEXT_PUBLIC_ORIGIN } = process.env
enum CertificateType {
Course = "course",
Program = "program",
}
+export async function generateMetadata({
+ params,
+}: PageProps<"/certificate/[certificateType]/[uuid]">): Promise {
+ const { certificateType, uuid } = await params
+
+ let title, displayType, userName
+
+ try {
+ if (certificateType === CertificateType.Course) {
+ const { data } = await courseCertificatesApi.courseCertificatesRetrieve({
+ cert_uuid: uuid,
+ })
+
+ title = data.course_run.course.title
+
+ displayType = "Module Certificate"
+
+ userName = data?.user?.name
+ } else {
+ const { data } = await programCertificatesApi.programCertificatesRetrieve(
+ {
+ cert_uuid: uuid,
+ },
+ )
+
+ title = data.program.title
+
+ displayType = `${data.program.program_type} Certificate`
+
+ userName = data.user.name
+ }
+ } catch (error) {
+ Sentry.captureException(error)
+ console.error("Error fetching certificate for metadata", {
+ certificateType,
+ uuid,
+ error,
+ })
+ return standardizeMetadata({})
+ }
+
+ return standardizeMetadata({
+ title: `${userName}'s ${displayType}`,
+ description: `${userName} has successfully completed the Universal Artificial Intelligence ${displayType}: ${title}`,
+ })
+}
+
const Page: React.FC<
PageProps<"/certificate/[certificateType]/[uuid]">
> = async ({ params }) => {
@@ -32,7 +85,11 @@ const Page: React.FC<
return (
-
+
)
}
diff --git a/frontends/main/src/app/certificate/[certificateType]/[uuid]/pdf/route.tsx b/frontends/main/src/app/certificate/[certificateType]/[uuid]/pdf/route.tsx
index 7dab6e7244..5b7eeda33d 100644
--- a/frontends/main/src/app/certificate/[certificateType]/[uuid]/pdf/route.tsx
+++ b/frontends/main/src/app/certificate/[certificateType]/[uuid]/pdf/route.tsx
@@ -2,6 +2,7 @@
import React from "react"
import type { AxiosError } from "axios"
import type { NextRequest } from "next/server"
+import * as Sentry from "@sentry/nextjs"
import moment from "moment"
import { courseCertificatesApi, programCertificatesApi } from "api/mitxonline"
import {
@@ -253,7 +254,7 @@ const CertificateDoc = ({
}) => {
return (
@@ -570,6 +571,7 @@ export async function GET(req: NextRequest, ctx: RouteContext) {
if ([400, 404].includes((error as AxiosError).status ?? -1)) {
return redirect("/not-found")
}
+ Sentry.captureException(error)
console.error("Error fetching certificate for PDF generation", {
certificateType,
uuid,
diff --git a/frontends/main/src/common/urls.ts b/frontends/main/src/common/urls.ts
index 14392545b3..c85c02e4e1 100644
--- a/frontends/main/src/common/urls.ts
+++ b/frontends/main/src/common/urls.ts
@@ -172,4 +172,10 @@ export const B2B_ATTACH_VIEW = "/attach/[code]"
export const b2bAttachView = (code: string) =>
generatePath(B2B_ATTACH_VIEW, { code: code })
+export const FACEBOOK_SHARE_BASE_URL =
+ "https://www.facebook.com/sharer/sharer.php"
+export const TWITTER_SHARE_BASE_URL = "https://x.com/share"
+export const LINKEDIN_SHARE_BASE_URL =
+ "https://www.linkedin.com/sharing/share-offsite"
+
export const COURSE_PAGE_VIEW = "/courses/[readableId]/"
diff --git a/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.tsx b/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.tsx
index 7ceda75b04..5a29670210 100644
--- a/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.tsx
+++ b/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.tsx
@@ -30,6 +30,11 @@ import type { User } from "api/hooks/user"
import { PostHogEvents } from "@/common/constants"
import VideoFrame from "./VideoFrame"
import { kebabCase } from "lodash"
+import {
+ FACEBOOK_SHARE_BASE_URL,
+ TWITTER_SHARE_BASE_URL,
+ LINKEDIN_SHARE_BASE_URL,
+} from "@/common/urls"
const showChatClass = "show-chat"
const showChatSelector = `.${showChatClass} &`
@@ -328,9 +333,6 @@ const CallToActionSection = ({
const bookmarkLabel = "Bookmark"
const shareLabel = "Share"
const socialIconSize = 18
- const facebookShareBaseUrl = "https://www.facebook.com/sharer/sharer.php"
- const twitterShareBaseUrl = "https://x.com/share"
- const linkedInShareBaseUrl = "https://www.linkedin.com/sharing/share-offsite"
return (
@@ -411,19 +413,19 @@ const CallToActionSection = ({
/>