* feat: add additional course end plugin slots * fix: bring plugin slot names in line with new naming scheme * refactor: change plugin files to tsx,remove propTypes * fixup! refactor: change plugin files to tsx,remove propTypes * fixup! fixup! refactor: change plugin files to tsx,remove propTypes * fixup! fixup! fixup! refactor: change plugin files to tsx,remove propTypes * fix: accidentally committed test code * fix: plugin-slot fixes * chore: add ENTERPRISE_LEARNER_PORTAL_URL env var
367 lines
14 KiB
JavaScript
367 lines
14 KiB
JavaScript
import React, { useEffect } from 'react';
|
||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||
import { faLinkedinIn } from '@fortawesome/free-brands-svg-icons';
|
||
|
||
import { FormattedDate, FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||
import { Helmet } from 'react-helmet';
|
||
import { useDispatch, useSelector } from 'react-redux';
|
||
import {
|
||
Alert,
|
||
breakpoints,
|
||
Button,
|
||
Hyperlink,
|
||
useWindowSize,
|
||
} from '@openedx/paragon';
|
||
import { CheckCircle } from '@openedx/paragon/icons';
|
||
import { getConfig } from '@edx/frontend-platform';
|
||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||
|
||
import CelebrationMobile from './assets/celebration_456x328.gif';
|
||
import CelebrationDesktop from './assets/celebration_750x540.gif';
|
||
import certificate from '../../../generic/assets/openedx_certificate.png';
|
||
import certificateLocked from '../../../generic/assets/openedx_locked_certificate.png';
|
||
import { FormattedPricing } from '../../../generic/upgrade-button';
|
||
import messages from './messages';
|
||
import { useModel } from '../../../generic/model-store';
|
||
import { requestCert } from '../../../course-home/data/thunks';
|
||
import ProgramCompletion from './ProgramCompletion';
|
||
import UpgradeFootnote from './UpgradeFootnote';
|
||
import SocialIcons from '../../social-share/SocialIcons';
|
||
import { logClick, logVisit } from './utils';
|
||
import { DashboardLink, IdVerificationSupportLink, ProfileLink } from '../../../shared/links';
|
||
import DashboardFootnote from './DashboardFootnote';
|
||
import { CourseRecommendationsSlot } from '../../../plugin-slots/CourseExitPluginSlots';
|
||
|
||
const LINKEDIN_BLUE = '#2867B2';
|
||
|
||
const CourseCelebration = () => {
|
||
const intl = useIntl();
|
||
const wideScreen = useWindowSize().width >= breakpoints.medium.minWidth;
|
||
const { courseId } = useSelector(state => state.courseware);
|
||
const dispatch = useDispatch();
|
||
const {
|
||
certificateData,
|
||
end,
|
||
linkedinAddToProfileUrl,
|
||
marketingUrl,
|
||
offer,
|
||
relatedPrograms,
|
||
title,
|
||
verifyIdentityUrl,
|
||
verificationStatus,
|
||
} = useModel('coursewareMeta', courseId);
|
||
|
||
const {
|
||
org,
|
||
verifiedMode,
|
||
canViewCertificate,
|
||
userTimezone,
|
||
} = useModel('courseHomeMeta', courseId);
|
||
|
||
const {
|
||
certStatus,
|
||
certWebViewUrl,
|
||
certificateAvailableDate,
|
||
} = certificateData || {};
|
||
|
||
const { administrator } = getAuthenticatedUser();
|
||
|
||
const dashboardLink = <DashboardLink />;
|
||
const idVerificationSupportLink = <IdVerificationSupportLink />;
|
||
const profileLink = <ProfileLink />;
|
||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||
|
||
let buttonPrefix = null;
|
||
let buttonLocation;
|
||
let buttonText;
|
||
let buttonVariant = 'outline-primary';
|
||
let buttonEvent = null;
|
||
let buttonSuffix = null;
|
||
let certificateImage = certificate;
|
||
let footnote;
|
||
let message;
|
||
let certHeader;
|
||
let visitEvent = 'celebration_generic';
|
||
// These cases are taken from the edx-platform `get_cert_data` function found in lms/courseware/views/views.py
|
||
switch (certStatus) {
|
||
case 'downloadable':
|
||
certHeader = intl.formatMessage(messages.certificateHeaderDownloadable);
|
||
message = (
|
||
<p>
|
||
<FormattedMessage
|
||
id="courseCelebration.certificateBody.available"
|
||
defaultMessage="
|
||
Showcase your accomplishment on LinkedIn or your resumé today.
|
||
You can download your certificate now and access it any time from your
|
||
{dashboardLink} and {profileLink}."
|
||
values={{ dashboardLink, profileLink }}
|
||
description="Recommending an action for learner when course certificate is available"
|
||
/>
|
||
</p>
|
||
);
|
||
if (certWebViewUrl) {
|
||
buttonLocation = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
|
||
buttonText = intl.formatMessage(messages.viewCertificateButton);
|
||
}
|
||
if (linkedinAddToProfileUrl) {
|
||
buttonPrefix = (
|
||
<Button
|
||
className="mr-3"
|
||
href={linkedinAddToProfileUrl}
|
||
onClick={() => logClick(org, courseId, administrator, 'linkedin_add_to_profile')}
|
||
style={{ backgroundColor: LINKEDIN_BLUE, border: 'none' }}
|
||
>
|
||
<FontAwesomeIcon icon={faLinkedinIn} className="mr-3" />
|
||
{`${intl.formatMessage(messages.linkedinAddToProfileButton)}`}
|
||
</Button>
|
||
);
|
||
}
|
||
buttonEvent = 'view_cert';
|
||
visitEvent = 'celebration_with_cert';
|
||
footnote = <DashboardFootnote variant={visitEvent} />;
|
||
break;
|
||
case 'earned_but_not_available': {
|
||
const endDate = <FormattedDate value={end} day="numeric" month="long" year="numeric" />;
|
||
const certAvailableDate = <FormattedDate value={certificateAvailableDate} day="numeric" month="long" year="numeric" />;
|
||
certHeader = intl.formatMessage(messages.certificateHeaderNotAvailable);
|
||
message = (
|
||
<>
|
||
<p>
|
||
<FormattedMessage
|
||
id="courseCelebration.certificateBody.notAvailable.endDate.v2"
|
||
defaultMessage="This course ends on {endDate}. Final grades and any earned certificates are
|
||
scheduled to be available after {certAvailableDate}."
|
||
values={{ endDate, certAvailableDate }}
|
||
description="This shown for leaner when they are eligible for certifcate but it't not available yet, it could because leaners just finished the course quickly!"
|
||
/>
|
||
</p>
|
||
<p>
|
||
{intl.formatMessage(messages.certificateNotAvailableBodyAccessCert)}
|
||
</p>
|
||
</>
|
||
);
|
||
visitEvent = 'celebration_with_unavailable_cert';
|
||
footnote = <DashboardFootnote variant={visitEvent} />;
|
||
break;
|
||
}
|
||
case 'requesting':
|
||
// The requesting status needs a different button because it does a POST instead of a GET.
|
||
// So we don't set buttonLocation and instead define a custom button as a buttonPrefix.
|
||
buttonEvent = 'request_cert';
|
||
buttonPrefix = (
|
||
<Button
|
||
variant={buttonVariant}
|
||
onClick={() => {
|
||
logClick(org, courseId, administrator, buttonEvent);
|
||
dispatch(requestCert(courseId));
|
||
}}
|
||
>
|
||
{intl.formatMessage(messages.requestCertificateButton)}
|
||
</Button>
|
||
);
|
||
certHeader = intl.formatMessage(messages.certificateHeaderRequestable);
|
||
message = (<p>{intl.formatMessage(messages.requestCertificateBodyText)}</p>);
|
||
visitEvent = 'celebration_with_requestable_cert';
|
||
footnote = <DashboardFootnote variant={visitEvent} />;
|
||
break;
|
||
case 'unverified':
|
||
certHeader = intl.formatMessage(messages.certificateHeaderUnverified);
|
||
visitEvent = 'celebration_unverified';
|
||
footnote = <DashboardFootnote variant={visitEvent} />;
|
||
if (verificationStatus === 'pending') {
|
||
message = (<p>{intl.formatMessage(messages.verificationPending)}</p>);
|
||
} else {
|
||
buttonText = intl.formatMessage(messages.verifyIdentityButton);
|
||
buttonEvent = 'verify_id';
|
||
buttonLocation = verifyIdentityUrl;
|
||
// todo: check for idVerificationSupportLink null
|
||
message = (
|
||
<p>
|
||
<FormattedMessage
|
||
id="courseCelebration.certificateBody.unverified"
|
||
defaultMessage="In order to generate a certificate, you must complete ID verification.
|
||
{idVerificationSupportLink} now."
|
||
values={{ idVerificationSupportLink }}
|
||
description="Its shown when learner are not verified thus it recommends going over the verification process"
|
||
/>
|
||
</p>
|
||
);
|
||
}
|
||
break;
|
||
case 'audit_passing':
|
||
case 'honor_passing':
|
||
if (verifiedMode) {
|
||
certHeader = intl.formatMessage(messages.certificateHeaderUpgradable);
|
||
message = (
|
||
<p>
|
||
<FormattedMessage
|
||
id="courseCelebration.certificateBody.upgradable"
|
||
defaultMessage="It’s not too late to upgrade. For {price} you will unlock access to all graded
|
||
assignments in this course. Upon completion, you will receive a verified certificate which is a
|
||
valuable credential to improve your job prospects and advance your career, or highlight your
|
||
certificate in school applications."
|
||
values={{ price: <FormattedPricing inline offer={offer} verifiedMode={verifiedMode} /> }}
|
||
description="Body text when the learner needs to upgrade to earn a certifcate and they have passed the course"
|
||
/>
|
||
<br />
|
||
{getConfig().SUPPORT_URL_VERIFIED_CERTIFICATE && (
|
||
<Hyperlink
|
||
className="text-gray-700"
|
||
style={{ textDecoration: 'underline' }}
|
||
destination={getConfig().SUPPORT_URL_VERIFIED_CERTIFICATE}
|
||
>
|
||
{intl.formatMessage(messages.verifiedCertificateSupportLink)}
|
||
</Hyperlink>
|
||
)}
|
||
</p>
|
||
);
|
||
buttonText = intl.formatMessage(messages.upgradeButton);
|
||
buttonEvent = 'upgrade';
|
||
buttonLocation = verifiedMode.upgradeUrl;
|
||
buttonVariant = 'primary';
|
||
if (offer) {
|
||
buttonSuffix = (
|
||
<span className="ml-2 align-middle">
|
||
<FormattedMessage
|
||
id="courseCelebration.upgradeDiscountCodePrompt"
|
||
defaultMessage="Use code {code} at checkout for {percent}% off!"
|
||
values={{
|
||
code: (<b>{offer.code}</b>),
|
||
percent: offer.percentage,
|
||
}}
|
||
description="Shown if learner can use a discount code when they upgrade the course"
|
||
/>
|
||
</span>
|
||
);
|
||
}
|
||
certificateImage = certificateLocked;
|
||
visitEvent = 'celebration_upgrade';
|
||
if (verifiedMode.accessExpirationDate) {
|
||
footnote = <UpgradeFootnote deadline={verifiedMode.accessExpirationDate} href={verifiedMode.upgradeUrl} />;
|
||
} else {
|
||
footnote = <DashboardFootnote variant={visitEvent} />;
|
||
}
|
||
} else {
|
||
visitEvent = 'celebration_audit_no_upgrade';
|
||
}
|
||
break;
|
||
default:
|
||
if (!canViewCertificate) {
|
||
// We reuse the cert event here. Since this default state is so
|
||
// Similar to the earned_not_available state, this event name should be fine
|
||
// to cover the same cases.
|
||
visitEvent = 'celebration_with_unavailable_cert';
|
||
certHeader = intl.formatMessage(messages.certificateHeaderNotAvailable);
|
||
const endDate = intl.formatDate(end, {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
...timezoneFormatArgs,
|
||
});
|
||
message = (
|
||
<>
|
||
<p>
|
||
{intl.formatMessage(messages.certificateNotAvailableEndDateBody, { endDate })}
|
||
</p>
|
||
<p>
|
||
{intl.formatMessage(messages.certificateNotAvailableBodyAccessCert)}
|
||
</p>
|
||
</>
|
||
);
|
||
}
|
||
break;
|
||
}
|
||
|
||
useEffect(() => logVisit(org, courseId, administrator, visitEvent), [org, courseId, administrator, visitEvent]);
|
||
|
||
return (
|
||
<>
|
||
<Helmet>
|
||
<title>{`${intl.formatMessage(messages.congratulationsHeader)} | ${title} | ${getConfig().SITE_NAME}`}</title>
|
||
</Helmet>
|
||
<div className="row w-100 mx-0 mb-4 px-5 py-4 border border-light">
|
||
<div className="col-12 p-0 h2 text-center">
|
||
{intl.formatMessage(messages.congratulationsHeader)}
|
||
</div>
|
||
<div className="col-12 p-0 font-weight-normal lead text-center">
|
||
{intl.formatMessage(messages.completedCourseHeader)}
|
||
{marketingUrl && ` ${intl.formatMessage(messages.shareMessage)}`}
|
||
<SocialIcons
|
||
analyticsId="edx.ui.lms.course_exit.social_share.clicked"
|
||
className="mt-2"
|
||
courseId={courseId}
|
||
emailSubject={messages.socialMessage}
|
||
socialMessage={messages.socialMessage}
|
||
/>
|
||
</div>
|
||
<div className="col-12 mt-3 mb-4 px-0 px-md-5 text-center">
|
||
{!wideScreen && (
|
||
<img
|
||
src={CelebrationMobile}
|
||
alt={`${intl.formatMessage(messages.congratulationsImage)}`}
|
||
className="img-fluid"
|
||
/>
|
||
)}
|
||
{wideScreen && (
|
||
<img
|
||
src={CelebrationDesktop}
|
||
alt={`${intl.formatMessage(messages.congratulationsImage)}`}
|
||
className="img-fluid"
|
||
style={{ width: '36rem' }}
|
||
/>
|
||
)}
|
||
</div>
|
||
<div className="col-12 px-0 px-md-5">
|
||
{certHeader && (
|
||
<Alert variant="success" icon={CheckCircle}>
|
||
<div className="row w-100 m-0">
|
||
<div className="col order-1 order-md-0 pl-0 pr-0 pr-md-5">
|
||
<div className="h4">{certHeader}</div>
|
||
{message}
|
||
<div className="mt-2">
|
||
{buttonPrefix}
|
||
{buttonLocation && (
|
||
<Button
|
||
variant={buttonVariant}
|
||
href={buttonLocation}
|
||
className="w-xs-100 w-md-auto"
|
||
onClick={() => logClick(org, courseId, administrator, buttonEvent)}
|
||
>
|
||
{buttonText}
|
||
</Button>
|
||
)}
|
||
{buttonSuffix}
|
||
</div>
|
||
</div>
|
||
{certStatus !== 'unverified' && (
|
||
<div className="col-12 order-0 col-md-3 order-md-1 w-100 mb-3 p-0 text-center">
|
||
<img
|
||
src={certificateImage}
|
||
alt={`${intl.formatMessage(messages.certificateImage)}`}
|
||
className="w-100"
|
||
style={{ maxWidth: '13rem' }}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Alert>
|
||
)}
|
||
{relatedPrograms && relatedPrograms.map(program => (
|
||
<ProgramCompletion
|
||
key={program.uuid}
|
||
progress={program.progress}
|
||
title={program.title}
|
||
type={program.slug}
|
||
url={program.url}
|
||
/>
|
||
))}
|
||
{footnote}
|
||
<CourseRecommendationsSlot variant={visitEvent} />
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
};
|
||
|
||
export default CourseCelebration;
|