From d99f06692ceadd328b85091d4a9d801c56bf1ddb Mon Sep 17 00:00:00 2001 From: Andrew Shin <109984998+ashin-czi@users.noreply.github.com> Date: Thu, 6 Jul 2023 15:20:32 -0700 Subject: [PATCH] feat: Newsletter Banner (#601) * feat: Newsletter Banner --- client/src/analytics/events.ts | 6 + client/src/components/app.tsx | 4 - .../src/components/bottomBanner/CellxGene.svg | 10 + client/src/components/bottomBanner/index.tsx | 286 ++++++++++++++++++ client/src/components/bottomBanner/style.ts | 189 ++++++++++++ client/src/components/framework/layout.tsx | 26 +- client/src/global.d.ts | 22 ++ 7 files changed, 533 insertions(+), 10 deletions(-) create mode 100644 client/src/components/bottomBanner/CellxGene.svg create mode 100644 client/src/components/bottomBanner/index.tsx create mode 100644 client/src/components/bottomBanner/style.ts diff --git a/client/src/analytics/events.ts b/client/src/analytics/events.ts index d57629ed5..ef2a38f56 100644 --- a/client/src/analytics/events.ts +++ b/client/src/analytics/events.ts @@ -53,4 +53,10 @@ export enum EVENTS { CENSUS_CLICK_NAV = "CENSUS_CLICK_NAV", DATASETS_CLICK_NAV = "DATASETS_CLICK_NAV", DOCUMENTATION_CLICK_NAV = "DOCUMENTATION_CLICK_NAV", + NEWSLETTER_EMAIL_SUBMITTED = "NEWSLETTER_EMAIL_SUBMITTED", + NEWSLETTER_SIGNUP_SUCCESS = "NEWSLETTER_SIGNUP_SUCCESS", + NEWSLETTER_SIGNUP_FAILURE = "NEWSLETTER_SIGNUP_FAILURE", + NEWSLETTER_OPEN_MODAL_CLICKED = "NEWSLETTER_OPEN_MODAL_CLICKED", + NEWSLETTER_DIRECT_LINK_NAVIGATED = "NEWSLETTER_DIRECT_LINK_NAVIGATED", + NEWSLETTER_SUBSCRIBE_BUTTON_AVAILABLE = "NEWSLETTER_SUBSCRIBE_BUTTON_AVAILABLE", } diff --git a/client/src/components/app.tsx b/client/src/components/app.tsx index 635d496a9..e40db8ec8 100644 --- a/client/src/components/app.tsx +++ b/client/src/components/app.tsx @@ -6,7 +6,6 @@ import { StylesProvider, ThemeProvider } from "@material-ui/core/styles"; import { theme } from "./theme"; import Controls from "./controls"; -import DatasetSelector from "./datasetSelector/datasetSelector"; import Container from "./framework/container"; import Layout from "./framework/layout"; import LayoutSkeleton from "./framework/layoutSkeleton"; @@ -96,9 +95,6 @@ class App extends React.Component { viewportRef={viewportRef} key={graphRenderCounter} /> - - - )} > diff --git a/client/src/components/bottomBanner/CellxGene.svg b/client/src/components/bottomBanner/CellxGene.svg new file mode 100644 index 000000000..00b74faee --- /dev/null +++ b/client/src/components/bottomBanner/CellxGene.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/client/src/components/bottomBanner/index.tsx b/client/src/components/bottomBanner/index.tsx new file mode 100644 index 000000000..444f18149 --- /dev/null +++ b/client/src/components/bottomBanner/index.tsx @@ -0,0 +1,286 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Button } from "@blueprintjs/core"; +import cellxgeneLogoSvg from "./CellxGene.svg"; +import { + BOTTOM_BANNER_ID, + NewsletterModal, + StyledBanner, + StyledBottomBannerWrapper, + StyledDescription, + StyledDisclaimer, + StyledForm, + StyledTitle, + StyledInputText, + StyledLink, + StyledSubmitButton, + HeaderContainer, + StyledErrorMessage, + HiddenHubspotForm, +} from "./style"; +import { EVENTS } from "../../analytics/events"; +import { track } from "../../analytics"; + +export const FORM_CONTAINER_ID = "hubspot-form-container"; +export const FORM_CONTAINER_ID_QUERY = `#${FORM_CONTAINER_ID}`; + +export const SUBMIT_ISSUE_URL = "https://airtable.com/shrLwepDSEX1HI6bo"; + +export const FAILED_EMAIL_VALIDATION_STRING = + "Please provide a valid email address."; + +interface Props { + includeSurveyLink: boolean; + setIsBannerOpen: React.Dispatch>; +} + +export default function BottomBanner({ + includeSurveyLink = false, + setIsBannerOpen, +}: Props): JSX.Element { + const [newsletterModalIsOpen, setNewsletterModalIsOpen] = useState(false); + const [isHubSpotReady, setIsHubSpotReady] = useState(false); + const [isSubmitted, setIsSubmitted] = useState(false); + const [email, setEmail] = useState(""); + const [emailValidationError, setError] = useState(""); + + // For analytics if submit button was made enabled by user input + const [submitButtonEnabledOnce, setSubmitButtonEnabledOnce] = useState(false); + + function toggleNewsletterSignupModal() { + // Track when modal is opened + if (!newsletterModalIsOpen) { + track(EVENTS.NEWSLETTER_OPEN_MODAL_CLICKED); + } + + setError(""); + setEmail(""); + setNewsletterModalIsOpen(!newsletterModalIsOpen); + } + + useEffect(() => { + const script = document.createElement("script"); + script.src = "https://js.hsforms.net/forms/v2.js"; + script.async = true; + + script.onload = () => { + setIsHubSpotReady(true); + }; + + document.body.appendChild(script); + + // Observer to observe changes in the Hubspot embedded form, which is hidden from the user in order to use our own form view + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + // Loop through all added nodes that were detected + for (let i = 0; i < mutation.addedNodes.length; i += 1) { + const node = mutation.addedNodes.item(i); + + // Submission success flow + if ( + node?.textContent?.includes("Thank you for joining our newsletter.") + ) { + setIsSubmitted(true); + setError(""); + + track(EVENTS.NEWSLETTER_SIGNUP_SUCCESS); + } + + // Hubspot email validation failure flow + else if ( + node?.textContent?.includes("Please enter a valid email address.") + ) { + // HTML email validation may pass, but may not pass validation for Hubspot + // ex. "ashintest_04252023_invalid_email@contractor.chanzuckerberg" does not validate with Hubspot but does with HTML email validation + setError(FAILED_EMAIL_VALIDATION_STRING); + + track(EVENTS.NEWSLETTER_SIGNUP_FAILURE); + } + } + } + }); + + if (isHubSpotReady) { + hbspt.forms.create({ + region: "na1", + portalId: "7272273", + formId: "eb65b811-0451-414d-8304-7b9b6f468ce5", + target: FORM_CONTAINER_ID_QUERY, + }); + + const form = document.querySelector(FORM_CONTAINER_ID_QUERY); + if (form) { + observer.observe(form, { + childList: true, + subtree: true, + }); + } + } + + return () => observer.disconnect(); + }, [isHubSpotReady]); + + const emailRef = useRef(null); + + // Validates if the email is valid or missing + const validate = () => { + const validityState = emailRef.current?.validity; + if (validityState?.valueMissing || validityState?.typeMismatch) { + setError(FAILED_EMAIL_VALIDATION_STRING); + + track(EVENTS.NEWSLETTER_SIGNUP_FAILURE); + + return false; + } + setError(""); // email validation passed, no error + return true; + }; + + const handleSubmit = (event: React.FormEvent) => { + track(EVENTS.NEWSLETTER_EMAIL_SUBMITTED); + + event.preventDefault(); + const isValid = validate(); + const form: HTMLFormElement | null = isValid + ? document.querySelector(`${FORM_CONTAINER_ID_QUERY} form`) + : null; + + if (!isValid || !form) { + return; + } + + const input = form.querySelector("input"); + if (!input) { + return; + } + + input.value = email; + input.dispatchEvent(new Event("input", { bubbles: true })); + + form.submit(); + }; + + return ( + <> + + + + { + setIsBannerOpen(false); + }} + text={ + ( +
+ { + toggleNewsletterSignupModal(); + }} + data-testid="newsletter-modal-open-button" + > + Subscribe + {" "} + to our newsletter to receive updates about new features.{" "} + {includeSurveyLink && ( + <> + Send us feedback with this{" "} + + quick survey + + . + + )} +
+ ) as unknown as string // For some reason setting the "text" prop overwrites the child, so we have to set the element in the text prop but it only takes a string as the type + } + /> + + toggleNewsletterSignupModal()} + > + <> + + CELLxGENE Logo + + +
+ Join Our Newsletter + + + {isSubmitted + ? "Thanks for subscribing!" + : "Get a quarterly email with the latest CELLxGENE features and data."} + + + + {!isSubmitted && ( + <> + { + if (emailValidationError) setError(""); + + if (!submitButtonEnabledOnce) { + setSubmitButtonEnabledOnce(true); + track(EVENTS.NEWSLETTER_SUBSCRIBE_BUTTON_AVAILABLE); + } + + setEmail(event.target.value); + }} + id="email-input" + value={email} + required + type="email" + inputProps={{ "data-testid": "newsletter-email-input" }} + /> + + Subscribe + + + )} + + + + {emailValidationError} + + + + {isSubmitted + ? 'To unsubscribe, click on the "Unsubscribe" link at the bottom of the newsletter.' + : "Unsubscribe at any time."} + +
+ +
+
+ + ); +} diff --git a/client/src/components/bottomBanner/style.ts b/client/src/components/bottomBanner/style.ts new file mode 100644 index 000000000..c76784109 --- /dev/null +++ b/client/src/components/bottomBanner/style.ts @@ -0,0 +1,189 @@ +import { Classes, Dialog } from "@blueprintjs/core"; +import styled from "@emotion/styled"; +import { + Banner, + Button, + CommonThemeProps, + fontBodyS, + fontBodyXxxs, + fontHeaderXl, + getColors, + InputText, +} from "czifui"; + +export const BANNER_HEIGHT_PX = 44; + +export const BOTTOM_BANNER_ID = "bottom-banner"; + +export const HiddenHubspotForm = styled.div` + display: none; +`; + +export const StyledBanner = styled(Banner)` + ${fontBodyS} + + letter-spacing: -0.006em; + + height: inherit; + + ${(props: CommonThemeProps) => { + const colors = getColors(props); + + // beta intent does not exist for SDS banner, but the colors do + // targeting specific id to overwrite style + return ` + border-color: ${colors?.beta[400]} !important; + background-color: ${colors?.beta[100]}; + color: black; + + /* Hide default svg icon in the Banner as it is not in figma */ + :first-child > div:first-child > div:first-child { + display: none; + } + + /* Change close button icon default color */ + button svg { + path { + fill: ${colors?.gray[500]}; + } + } + `; + }} +`; + +export const StyledBottomBannerWrapper = styled.div` + position: fixed; + bottom: 0; + + width: 100%; + height: ${BANNER_HEIGHT_PX}; + + /* Right behind modal overlay */ + z-index: 19; +`; + +export const StyledLink = styled.a` + text-decoration-line: underline; + color: #8f5aff; + font-weight: 500; + + :hover { + color: #5826c1; + } +`; + +export const HeaderContainer = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-start; +`; + +export const StyledTitle = styled.div` + ${fontHeaderXl} + + letter-spacing: -0.019em; + font-size: 24px !important; + margin: 0; + height: auto !important; + + padding-top: 16px; + padding-bottom: 8px; +`; + +export const StyledDescription = styled.div` + ${fontBodyS} + + letter-spacing: -0.006em; + padding-bottom: 16px; +`; + +export const StyledForm = styled.form` + display: flex; + flex-direction: row; + justify-content: space-between; + height: 34px; + margin-bottom: 0; + align-items: center; + width: 100%; +`; + +export const StyledInputText = styled(InputText)` + .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline { + border-color: #5826c1 !important; + } + + flex: 1; + margin-right: 4px; + margin-bottom: 0px; + display: inline-flex; +`; + +export const StyledSubmitButton = styled(Button)` + padding: 6px 12px; + width: 91px; + height: 34px; + background: #8f5aff; + font-weight: 500; + + :hover { + background: #5826c1; + } +`; + +export const StyledDisclaimer = styled.div` + ${fontBodyXxxs} + + letter-spacing: -0.005em; + + ${(props: CommonThemeProps) => { + const colors = getColors(props); + + // beta intent does not exist for SDS banner, but the colors do + // targeting specific id to overwrite style + return ` + color: ${colors?.gray[500]}; + `; + }} +`; + +export const StyledErrorMessage = styled.div` + ${fontBodyXxxs} + + letter-spacing: -0.005em; + + align-self: flex-start; + + height: 16px; + margin-top: 4px; + margin-bottom: 4px; + + ${(props: CommonThemeProps) => { + const colors = getColors(props); + + // beta intent does not exist for SDS banner, but the colors do + // targeting specific id to overwrite style + return ` + color: ${colors?.error[400]}; + `; + }} +`; + +export const NewsletterModal = styled(Dialog)` + background: white; + + .${Classes.DIALOG_HEADER} { + display: none !important; + } + + min-width: 400px !important; + min-height: 266px !important; + max-width: 400px !important; + max-height: 266px !important; + + margin: 0; + + padding: 24px; + + padding-bottom: 24px !important; +`; diff --git a/client/src/components/framework/layout.tsx b/client/src/components/framework/layout.tsx index d94e19fb8..18ba6f296 100644 --- a/client/src/components/framework/layout.tsx +++ b/client/src/components/framework/layout.tsx @@ -1,5 +1,9 @@ import React, { Children, useState } from "react"; import * as globals from "../../globals"; +import BottomBanner from "../bottomBanner"; +import { BANNER_HEIGHT_PX } from "../bottomBanner/style"; +import Controls from "../controls"; +import DatasetSelector from "../datasetSelector/datasetSelector"; interface Props { datasetMetadataError: string | null; @@ -19,6 +23,7 @@ interface Props { const Layout: React.FC = (props) => { const [viewportRef, setViewportRef] = useState(null); + const [isBannerOpen, setIsBannerOpen] = useState(true); const { children, datasetMetadataError, renderGraph } = props; const [leftSidebar, rightSidebar] = Children.toArray(children); @@ -32,12 +37,12 @@ const Layout: React.FC = (props) => { display: "grid", paddingTop: !datasetMetadataError ? globals.HEADER_HEIGHT_PX : 0, gridTemplateColumns: ` - [left-sidebar-start] ${globals.leftSidebarWidth + 1}px - [left-sidebar-end graph-start] auto - [graph-end right-sidebar-start] ${ - globals.rightSidebarWidth + 1 - }px [right-sidebar-end] - `, + [left-sidebar-start] ${globals.leftSidebarWidth + 1}px + [left-sidebar-end graph-start] auto + [graph-end right-sidebar-start] ${ + globals.rightSidebarWidth + 1 + }px [right-sidebar-end] + `, gridTemplateRows: "[top] auto [bottom]", gridTemplateAreas: "left-sidebar | graph | right-sidebar", columnGap: "0px", @@ -57,6 +62,7 @@ const Layout: React.FC = (props) => { position: "relative", height: "inherit", overflowY: "auto", + paddingBottom: isBannerOpen ? `${BANNER_HEIGHT_PX}px` : 0, // add padding to bottom to account for banner height }} > {leftSidebar} @@ -73,6 +79,9 @@ const Layout: React.FC = (props) => { }} > {graphComponent} + + +
= (props) => { position: "relative", height: "inherit", overflowY: "auto", + paddingBottom: isBannerOpen ? `${BANNER_HEIGHT_PX}px` : 0, // add padding to bottom to account for banner height }} > {/* The below conditional is required because the right sidebar initializes as function for some reason...*/} {!(rightSidebar instanceof Function) && rightSidebar}
+ ); }; diff --git a/client/src/global.d.ts b/client/src/global.d.ts index 5e52f805e..d83828f63 100644 --- a/client/src/global.d.ts +++ b/client/src/global.d.ts @@ -2,3 +2,25 @@ declare module "*.svg" { const content: string; export default content; } + +namespace HubSpotFormAPI { + /** + * HubSpot form creation options. + * https://legacydocs.hubspot.com/docs/methods/forms/advanced_form_options + */ + export interface CreateFormOptions { + region: string; + portalId: string; + formId: string; + target?: string; + } + + export interface HubSpotForm { + create(options: CreateFormOptions): void; + } + export interface HubSpot { + forms: HubSpotForm; + } +} + +declare const hbspt: HubSpotFormAPI.HubSpot;