Skip to content

Commit 14e46c3

Browse files
Certificate social media / URL sharing (#2524)
* Share button and popover * OG metadata. Pass page URL for SSR * Center the popover to the buttons/page * Element type * Pass pageUrl * Update title and filnames * Remove redundant aria-label. Add tests * Update frontends/main/src/app-pages/CertificatePage/CertificatePage.test.tsx Co-authored-by: Chris Chudzicki <[email protected]> * Remove unused * aria-labels and improve test selectors * Remove unused --------- Co-authored-by: Chris Chudzicki <[email protected]>
1 parent 9c02afb commit 14e46c3

File tree

7 files changed

+346
-17
lines changed

7 files changed

+346
-17
lines changed

frontends/main/src/app-pages/CertificatePage/CertificatePage.test.tsx

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import React from "react"
22
import moment from "moment"
33
import { factories, setMockResponse } from "api/test-utils"
4-
import { screen, renderWithProviders } from "@/test-utils"
4+
import { screen, renderWithProviders, user } from "@/test-utils"
55
import CertificatePage, { CertificateType } from "./CertificatePage"
6+
import SharePopover from "./SharePopover"
67
import { urls } from "api/mitxonline-test-utils"
8+
import {
9+
FACEBOOK_SHARE_BASE_URL,
10+
TWITTER_SHARE_BASE_URL,
11+
LINKEDIN_SHARE_BASE_URL,
12+
} from "@/common/urls"
713

814
describe("CertificatePage", () => {
915
it("renders a course certificate", async () => {
@@ -18,6 +24,7 @@ describe("CertificatePage", () => {
1824
<CertificatePage
1925
certificateType={CertificateType.Course}
2026
uuid={certificate.uuid}
27+
pageUrl={`https://${process.env.NEXT_PUBLIC_ORIGIN}/certificate/course/${certificate.uuid}`}
2128
/>,
2229
)
2330

@@ -80,6 +87,7 @@ describe("CertificatePage", () => {
8087
<CertificatePage
8188
certificateType={CertificateType.Program}
8289
uuid={certificate.uuid}
90+
pageUrl={`https://${process.env.NEXT_PUBLIC_ORIGIN}/certificate/program/${certificate.uuid}`}
8391
/>,
8492
)
8593

@@ -99,3 +107,70 @@ describe("CertificatePage", () => {
99107
await screen.findAllByText(certificate.uuid)
100108
})
101109
})
110+
111+
describe("CertificatePage - SharePopover", () => {
112+
const mockProps = {
113+
open: true,
114+
title: "Test Certificate",
115+
anchorEl: document.createElement("div"),
116+
onClose: jest.fn(),
117+
pageUrl: "https://example.com/certificate/123",
118+
}
119+
120+
const mockWriteText = jest.fn()
121+
Object.assign(navigator, {
122+
clipboard: {
123+
writeText: mockWriteText,
124+
},
125+
})
126+
127+
beforeEach(() => {
128+
jest.clearAllMocks()
129+
})
130+
131+
it("renders the SharePopover with correct content", () => {
132+
renderWithProviders(<SharePopover {...mockProps} />)
133+
134+
expect(screen.getByText("Share on social")).toBeInTheDocument()
135+
expect(screen.getByText("Share a link")).toBeInTheDocument()
136+
expect(screen.getByDisplayValue(mockProps.pageUrl)).toBeInTheDocument()
137+
expect(screen.getByText("Copy Link")).toBeInTheDocument()
138+
})
139+
140+
it("renders social media share links with correct URLs", () => {
141+
renderWithProviders(<SharePopover {...mockProps} />)
142+
143+
const facebookHref = `${FACEBOOK_SHARE_BASE_URL}?u=${encodeURIComponent(mockProps.pageUrl)}`
144+
const twitterHref = `${TWITTER_SHARE_BASE_URL}?text=${encodeURIComponent(mockProps.title)}&url=${encodeURIComponent(mockProps.pageUrl)}`
145+
const linkedinHref = `${LINKEDIN_SHARE_BASE_URL}?url=${encodeURIComponent(mockProps.pageUrl)}`
146+
147+
const facebookLink = screen.getByRole("link", { name: "Share on Facebook" })
148+
const twitterLink = screen.getByRole("link", { name: "Share on Twitter" })
149+
const linkedinLink = screen.getByRole("link", { name: "Share on LinkedIn" })
150+
151+
expect(facebookLink).toHaveAttribute("href", facebookHref)
152+
expect(twitterLink).toHaveAttribute("href", twitterHref)
153+
expect(linkedinLink).toHaveAttribute("href", linkedinHref)
154+
155+
expect(facebookLink).toHaveAttribute("target", "_blank")
156+
expect(twitterLink).toHaveAttribute("target", "_blank")
157+
expect(linkedinLink).toHaveAttribute("target", "_blank")
158+
})
159+
160+
it("copies link to clipboard when copy button is clicked", async () => {
161+
renderWithProviders(<SharePopover {...mockProps} />)
162+
163+
const copyButton = screen.getByRole("button", { name: "Copy Link" })
164+
await user.click(copyButton)
165+
166+
expect(mockWriteText).toHaveBeenCalledWith(mockProps.pageUrl)
167+
screen.getByRole("button", { name: "Copied!" })
168+
})
169+
170+
it("does not render when open is false", () => {
171+
renderWithProviders(<SharePopover {...mockProps} open={false} />)
172+
173+
expect(screen.queryByText("Share on social")).not.toBeInTheDocument()
174+
expect(screen.queryByText("Share a link")).not.toBeInTheDocument()
175+
})
176+
})

frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client"
22

3-
import React, { useRef, useEffect, useCallback } from "react"
3+
import React, { useRef, useEffect, useCallback, useState } from "react"
44
import { notFound } from "next/navigation"
55
import Image from "next/image"
66
import { Link, Typography, styled } from "ol-components"
@@ -12,12 +12,13 @@ import OpenLearningLogo from "@/public/images/mit-open-learning-logo.svg"
1212
import CertificateBadgeDesktop from "@/public/images/certificate-badge-desktop.svg"
1313
import CertificateBadgeMobile from "@/public/images/certificate-badge-mobile.svg"
1414
import { formatDate, NoSSR } from "ol-utilities"
15-
import { RiDownloadLine, RiPrinterLine } from "@remixicon/react"
15+
import { RiDownloadLine, RiPrinterLine, RiShareLine } from "@remixicon/react"
1616
import type {
1717
V2ProgramCertificate,
1818
V2CourseRunCertificate,
1919
SignatoryItem,
2020
} from "@mitodl/mitxonline-api-axios/v2"
21+
import SharePopover from "./SharePopover"
2122

2223
const Page = styled.div(({ theme }) => ({
2324
backgroundImage: `url(${backgroundImage.src})`,
@@ -57,12 +58,16 @@ const Title = styled(Typography)(({ theme }) => ({
5758
},
5859
}))
5960

60-
const Buttons = styled.div({
61+
const Buttons = styled.div(({ theme }) => ({
6162
display: "flex",
6263
gap: "12px",
6364
justifyContent: "center",
64-
marginBottom: "50px",
65-
})
65+
width: "fit-content",
66+
margin: "0 auto 50px auto",
67+
[theme.breakpoints.down("md")]: {
68+
margin: "0 auto 32px auto",
69+
},
70+
}))
6671

6772
const Outer = styled.div(({ theme }) => ({
6873
maxWidth: "1306px",
@@ -640,7 +645,8 @@ export enum CertificateType {
640645
const CertificatePage: React.FC<{
641646
certificateType: CertificateType
642647
uuid: string
643-
}> = ({ certificateType, uuid }) => {
648+
pageUrl: string
649+
}> = ({ certificateType, uuid, pageUrl }) => {
644650
const {
645651
data: courseCertificateData,
646652
isLoading: isCourseLoading,
@@ -694,6 +700,9 @@ const CertificatePage: React.FC<{
694700
}
695701
}, [print])
696702

703+
const [shareOpen, setShareOpen] = useState(false)
704+
const shareButtonRef = useRef<HTMLDivElement>(null)
705+
697706
if (isCourseLoading || isProgramLoading) {
698707
return <Page />
699708
}
@@ -709,7 +718,7 @@ const CertificatePage: React.FC<{
709718
const url = window.URL.createObjectURL(blob)
710719
const a = document.createElement("a")
711720
a.href = url
712-
a.download = `${title} Certificate - MIT Open Learning.pdf`
721+
a.download = `${title} Certificate issued by MIT Open Learning.pdf`
713722
document.body.appendChild(a)
714723
a.click()
715724
document.body.removeChild(a)
@@ -728,19 +737,33 @@ const CertificatePage: React.FC<{
728737

729738
return (
730739
<Page>
740+
<SharePopover
741+
open={shareOpen}
742+
title={`${title} Certificate issued by MIT Open Learning`}
743+
anchorEl={shareButtonRef.current}
744+
onClose={() => setShareOpen(false)}
745+
pageUrl={pageUrl}
746+
/>
731747
<Title>
732748
<Typography variant="h3">
733749
<strong>{title}</strong> {displayType}
734750
</Typography>
735751
</Title>
736-
<Buttons>
752+
<Buttons ref={shareButtonRef}>
737753
<Button
738754
variant="primary"
739755
startIcon={<RiDownloadLine />}
740756
onClick={download}
741757
>
742758
Download PDF
743759
</Button>
760+
<Button
761+
variant="bordered"
762+
startIcon={<RiShareLine />}
763+
onClick={() => setShareOpen(true)}
764+
>
765+
Share
766+
</Button>
744767
<Button
745768
variant="bordered"
746769
startIcon={<RiPrinterLine />}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import React, { useState } from "react"
2+
import { Popover, Typography, styled, theme } from "ol-components"
3+
import Link from "next/link"
4+
import {
5+
FACEBOOK_SHARE_BASE_URL,
6+
TWITTER_SHARE_BASE_URL,
7+
LINKEDIN_SHARE_BASE_URL,
8+
} from "@/common/urls"
9+
import {
10+
RiFacebookFill,
11+
RiTwitterXLine,
12+
RiLinkedinFill,
13+
RiLink,
14+
} from "@remixicon/react"
15+
import { Button, Input } from "@mitodl/smoot-design"
16+
17+
const StyledPopover = styled(Popover)({
18+
width: "648px",
19+
maxWidth: "calc(100vw - 48px)",
20+
".MuiPopper-arrow": {
21+
display: "none",
22+
},
23+
})
24+
25+
const Contents = styled.div(({ theme }) => ({
26+
padding: "8px",
27+
display: "flex",
28+
gap: "40px",
29+
[theme.breakpoints.down("sm")]: {
30+
flexDirection: "column",
31+
gap: "32px",
32+
},
33+
}))
34+
35+
const SocialContainer = styled.div({
36+
display: "flex",
37+
flexDirection: "column",
38+
gap: "16px",
39+
})
40+
41+
const LinkContainer = styled.div({
42+
display: "flex",
43+
flexDirection: "column",
44+
gap: "12px",
45+
flex: 1,
46+
})
47+
48+
const Heading = styled(Typography)(({ theme }) => ({
49+
...theme.typography.body2,
50+
color: theme.custom.colors.darkGray2,
51+
fontWeight: theme.typography.fontWeightBold,
52+
}))
53+
54+
const ButtonContainer = styled.div({
55+
display: "flex",
56+
alignSelf: "stretch",
57+
gap: "16px",
58+
a: {
59+
height: "18px",
60+
},
61+
})
62+
63+
const ShareLink = styled(Link)({
64+
color: theme.custom.colors.silverGrayDark,
65+
"&:hover": {
66+
color: theme.custom.colors.lightRed,
67+
},
68+
})
69+
70+
const LinkControls = styled.div(({ theme }) => ({
71+
display: "flex",
72+
gap: "16px",
73+
input: {
74+
...theme.typography.body3,
75+
color: theme.custom.colors.darkGray2,
76+
padding: "0 3px",
77+
},
78+
}))
79+
80+
const RedLinkIcon = styled(RiLink)({
81+
color: theme.custom.colors.red,
82+
})
83+
84+
const CopyLinkButton = styled(Button)({
85+
minWidth: "104px",
86+
})
87+
88+
const SharePopover = ({
89+
open,
90+
title,
91+
anchorEl,
92+
onClose,
93+
pageUrl,
94+
}: {
95+
open: boolean
96+
title: string
97+
anchorEl: HTMLDivElement | null
98+
onClose: () => void
99+
pageUrl: string
100+
}) => {
101+
const [copyText, setCopyText] = useState("Copy Link")
102+
103+
return (
104+
<StyledPopover open={open} onClose={onClose} anchorEl={anchorEl}>
105+
<Contents>
106+
<SocialContainer>
107+
<Heading variant="body2">Share on social</Heading>
108+
<ButtonContainer>
109+
<ShareLink
110+
href={`${FACEBOOK_SHARE_BASE_URL}?u=${encodeURIComponent(pageUrl)}`}
111+
aria-label="Share on Facebook"
112+
target="_blank"
113+
>
114+
<RiFacebookFill size={18} />
115+
</ShareLink>
116+
<ShareLink
117+
href={`${TWITTER_SHARE_BASE_URL}?text=${encodeURIComponent(title)}&url=${encodeURIComponent(pageUrl)}`}
118+
aria-label="Share on Twitter"
119+
target="_blank"
120+
>
121+
<RiTwitterXLine size={18} />
122+
</ShareLink>
123+
<ShareLink
124+
href={`${LINKEDIN_SHARE_BASE_URL}?url=${encodeURIComponent(pageUrl)}`}
125+
aria-label="Share on LinkedIn"
126+
target="_blank"
127+
>
128+
<RiLinkedinFill size={18} />
129+
</ShareLink>
130+
</ButtonContainer>
131+
</SocialContainer>
132+
<LinkContainer>
133+
<Heading variant="body2">Share a link</Heading>
134+
<LinkControls>
135+
<Input
136+
fullWidth
137+
value={pageUrl}
138+
size="small"
139+
onClick={(event) => {
140+
const input = event.currentTarget.querySelector("input")
141+
if (!input) return
142+
input.select()
143+
}}
144+
/>
145+
<CopyLinkButton
146+
size="small"
147+
edge="circular"
148+
variant="bordered"
149+
startIcon={<RedLinkIcon />}
150+
onClick={() => {
151+
navigator.clipboard?.writeText(pageUrl)
152+
setCopyText("Copied!")
153+
}}
154+
>
155+
{copyText}
156+
</CopyLinkButton>
157+
</LinkControls>
158+
</LinkContainer>
159+
</Contents>
160+
</StyledPopover>
161+
)
162+
}
163+
164+
export default SharePopover

0 commit comments

Comments
 (0)