diff --git a/frontend/src/app/[locale]/newsletter/NewsletterForm.tsx b/frontend/src/app/[locale]/newsletter/NewsletterForm.tsx new file mode 100644 index 0000000000..9495a2674b --- /dev/null +++ b/frontend/src/app/[locale]/newsletter/NewsletterForm.tsx @@ -0,0 +1,217 @@ +"use client"; +import { NEWSLETTER_CONFIRMATION } from "src/constants/breadcrumbs"; +import { ExternalRoutes } from "src/constants/routes"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { + Alert, + Button, + ErrorMessage, + FormGroup, + Label, + TextInput, +} from "@trussworks/react-uswds"; + +import { Data } from "src/pages/api/subscribe"; +import { useTranslations } from "next-intl"; + +export default function NewsletterForm() { + const t = useTranslations("Newsletter"); + + const router = useRouter(); + const email = ExternalRoutes.EMAIL_SIMPLERGRANTSGOV; + + const [formSubmitted, setFormSubmitted] = useState(false); + + const [formData, setFormData] = useState({ + name: "", + LastName: "", + email: "", + hp: "", + }); + + const [sendyError, setSendyError] = useState(""); + const [erroredEmail, setErroredEmail] = useState(""); + + const validateField = (fieldName: string) => { + // returns the string "valid" or the i18n key for the error message + const emailRegex = + /^(\D)+(\w)*((\.(\w)+)?)+@(\D)+(\w)*((\.(\D)+(\w)*)+)?(\.)[a-z]{2,}$/g; + if (fieldName === "name" && formData.name === "") + return t("errors.missing_name"); + if (fieldName === "email" && formData.email === "") + return t("errors.missing_email"); + if (fieldName === "email" && !emailRegex.test(formData.email)) + return t("errors.invalid_email"); + return "valid"; + }; + + const showError = (fieldName: string): boolean => + formSubmitted && validateField(fieldName) !== "valid"; + + const handleInput = (e: React.ChangeEvent) => { + const fieldName = e.target.name; + const fieldValue = e.target.value; + + setFormData((prevState) => ({ + ...prevState, + [fieldName]: fieldValue, + })); + }; + + const submitForm = async () => { + const formURL = "api/subscribe"; + if (validateField("email") !== "valid" || validateField("name") !== "valid") + return; + + const res = await fetch(formURL, { + method: "POST", + body: JSON.stringify(formData), + headers: { + Accept: "application/json", + }, + }); + + if (res.ok) { + const { message } = (await res.json()) as Data; + router.push(`${NEWSLETTER_CONFIRMATION.path}?sendy=${message as string}`); + return setSendyError(""); + } else { + const { error } = (await res.json()) as Data; + console.error("client error", error); + setErroredEmail(formData.email); + return setSendyError(error || ""); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setFormSubmitted(true); + submitForm().catch((err) => { + console.error("catch block", err); + }); + }; + + return ( +
+ {sendyError ? ( + + {t.rich( + sendyError === "Already subscribed." + ? "errors.already_subscribed" + : "errors.sendy", + { + email: (chunks) => ( + + {chunks} + + ), + sendy_error: (chunks) => ( + + {chunks} + + ), + email_address: (chunks) => ( + + {chunks} + + ), + }, + )} + + ) : ( + <> + )} + + + {showError("name") ? ( + + {validateField("name")} + + ) : ( + <> + )} + + + + + + + {showError("email") ? ( + + {validateField("email")} + + ) : ( + <> + )} + + +
+ + +
+ + + ); +} diff --git a/frontend/src/app/[locale]/newsletter/confirmation/page.tsx b/frontend/src/app/[locale]/newsletter/confirmation/page.tsx new file mode 100644 index 0000000000..e621b610a7 --- /dev/null +++ b/frontend/src/app/[locale]/newsletter/confirmation/page.tsx @@ -0,0 +1,66 @@ +import { NEWSLETTER_CONFIRMATION_CRUMBS } from "src/constants/breadcrumbs"; + +import Link from "next/link"; +import { Grid, GridContainer } from "@trussworks/react-uswds"; + +import Breadcrumbs from "src/components/Breadcrumbs"; +import PageSEO from "src/components/PageSEO"; +import BetaAlert from "src/components/BetaAlert"; +import { useTranslations } from "next-intl"; +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; + +export async function generateMetadata() { + const t = await getTranslations({ locale: "en" }); + const meta: Metadata = { + title: t("Newsletter.page_title"), + description: t("Index.meta_description"), + }; + + return meta; +} + +export default function NewsletterConfirmation() { + const t = useTranslations("Newsletter_confirmation"); + + return ( + <> + + + + + +

+ {t("title")} +

+

+ {t("intro")} +

+ + +

{t("paragraph_1")}

+
+ +

+ {t("heading")} +

+

+ {t.rich("paragraph_2", { + strong: (chunks) => {chunks}, + "process-link": (chunks) => ( + {chunks} + ), + "research-link": (chunks) => ( + {chunks} + ), + })} +

+
+
+
+ +

{t("disclaimer")}

+
+ + ); +} diff --git a/frontend/src/app/[locale]/newsletter/page.tsx b/frontend/src/app/[locale]/newsletter/page.tsx new file mode 100644 index 0000000000..080aaed5a2 --- /dev/null +++ b/frontend/src/app/[locale]/newsletter/page.tsx @@ -0,0 +1,71 @@ +import { NEWSLETTER_CRUMBS } from "src/constants/breadcrumbs"; + +import { Grid, GridContainer } from "@trussworks/react-uswds"; +import pick from "lodash/pick"; +import Breadcrumbs from "src/components/Breadcrumbs"; +import PageSEO from "src/components/PageSEO"; +import BetaAlert from "src/components/BetaAlert"; +import NewsletterForm from "src/app/[locale]/newsletter/NewsletterForm"; +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { + useTranslations, + useMessages, + NextIntlClientProvider, +} from "next-intl"; + +export async function generateMetadata() { + const t = await getTranslations({ locale: "en" }); + const meta: Metadata = { + title: t("Newsletter.page_title"), + description: t("Index.meta_description"), + }; + + return meta; +} + +export default function Newsletter() { + const t = useTranslations("Newsletter"); + const messages = useMessages(); + + return ( + <> + + + + + +

+ {t("title")} +

+

+ {t("intro")} +

+ + +

{t("paragraph_1")}

+ {t.rich("list", { + ul: (chunks) => ( +
    + {chunks} +
+ ), + li: (chunks) =>
  • {chunks}
  • , + })} +
    + + + + + +
    +
    + +

    {t("disclaimer")}

    +
    + + ); +} diff --git a/frontend/src/app/[locale]/newsletter/unsubscribe/page.tsx b/frontend/src/app/[locale]/newsletter/unsubscribe/page.tsx new file mode 100644 index 0000000000..9a095211ae --- /dev/null +++ b/frontend/src/app/[locale]/newsletter/unsubscribe/page.tsx @@ -0,0 +1,69 @@ +import { NEWSLETTER_UNSUBSCRIBE_CRUMBS } from "src/constants/breadcrumbs"; + +import Link from "next/link"; +import { Grid, GridContainer } from "@trussworks/react-uswds"; + +import Breadcrumbs from "src/components/Breadcrumbs"; +import PageSEO from "src/components/PageSEO"; +import BetaAlert from "src/components/BetaAlert"; +import { useTranslations } from "next-intl"; +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; + +export async function generateMetadata() { + const t = await getTranslations({ locale: "en" }); + const meta: Metadata = { + title: t("Newsletter.page_title"), + description: t("Index.meta_description"), + }; + + return meta; +} + +export default function NewsletterUnsubscribe() { + const t = useTranslations("Newsletter_unsubscribe"); + + return ( + <> + + + + + +

    + {t("title")} +

    +

    + {t("intro")} +

    + + +

    {t("paragraph_1")}

    + + {t("button_resub")} + +
    + +

    + {t("heading")} +

    +

    + {t.rich("paragraph_2", { + strong: (chunks) => {chunks}, + "process-link": (chunks) => ( + {chunks} + ), + "research-link": (chunks) => ( + {chunks} + ), + })} +

    +
    +
    +
    + +

    {t("disclaimer")}

    +
    + + ); +} diff --git a/frontend/src/pages/newsletter/confirmation.tsx b/frontend/src/pages/newsletter/confirmation.tsx deleted file mode 100644 index 3a9abc0636..0000000000 --- a/frontend/src/pages/newsletter/confirmation.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import type { GetStaticProps, NextPage } from "next"; -import { NEWSLETTER_CONFIRMATION_CRUMBS } from "src/constants/breadcrumbs"; - -import { Trans, useTranslation } from "next-i18next"; -import { serverSideTranslations } from "next-i18next/serverSideTranslations"; -import Link from "next/link"; -import { Grid, GridContainer } from "@trussworks/react-uswds"; - -import Breadcrumbs from "src/components/Breadcrumbs"; -import PageSEO from "src/components/PageSEO"; -import BetaAlert from "../../components/BetaAlert"; - -const NewsletterConfirmation: NextPage = () => { - const { t } = useTranslation("common"); - - // TODO: Remove during move to app router and next-intl upgrade - const beta_strings = { - alert_title: t("Beta_alert.alert_title"), - alert: t("Beta_alert.alert"), - }; - - return ( - <> - - - - - -

    - {t("Newsletter_confirmation.title")} -

    -

    - {t("Newsletter_confirmation.intro")} -

    - - -

    {t("paragraph_1")}

    -
    - -

    - {t("Newsletter_confirmation.heading")} -

    -

    - , - "research-link": , - }} - /> -

    -
    -
    -
    - -

    - {t("Newsletter.disclaimer")} -

    -
    - - ); -}; - -// Change this to GetServerSideProps if you're using server-side rendering -export const getStaticProps: GetStaticProps = async ({ locale }) => { - const translations = await serverSideTranslations(locale ?? "en"); - return { props: { ...translations } }; -}; - -export default NewsletterConfirmation; diff --git a/frontend/src/pages/newsletter/index.tsx b/frontend/src/pages/newsletter/index.tsx deleted file mode 100644 index c49e03f8c2..0000000000 --- a/frontend/src/pages/newsletter/index.tsx +++ /dev/null @@ -1,276 +0,0 @@ -import type { GetStaticProps, NextPage } from "next"; -import { - NEWSLETTER_CONFIRMATION, - NEWSLETTER_CRUMBS, -} from "src/constants/breadcrumbs"; -import { ExternalRoutes } from "src/constants/routes"; - -import { Trans, useTranslation } from "next-i18next"; -import { serverSideTranslations } from "next-i18next/serverSideTranslations"; -import { useRouter } from "next/router"; -import { useState } from "react"; -import { - Alert, - Button, - ErrorMessage, - FormGroup, - Grid, - GridContainer, - Label, - TextInput, -} from "@trussworks/react-uswds"; - -import Breadcrumbs from "src/components/Breadcrumbs"; -import PageSEO from "src/components/PageSEO"; -import BetaAlert from "../../components/BetaAlert"; -import { Data } from "../api/subscribe"; - -const Newsletter: NextPage = () => { - const { t } = useTranslation("common"); - // TODO: Remove during move to app router and next-intl upgrade - const beta_strings = { - alert_title: t("Beta_alert.alert_title"), - alert: t("Beta_alert.alert"), - }; - const router = useRouter(); - const email = ExternalRoutes.EMAIL_SIMPLERGRANTSGOV; - - const [formSubmitted, setFormSubmitted] = useState(false); - - const [formData, setFormData] = useState({ - name: "", - LastName: "", - email: "", - hp: "", - }); - - const [sendyError, setSendyError] = useState(""); - const [erroredEmail, setErroredEmail] = useState(""); - - const validateField = (fieldName: string) => { - // returns the string "valid" or the i18n key for the error message - const emailRegex = - /^(\D)+(\w)*((\.(\w)+)?)+@(\D)+(\w)*((\.(\D)+(\w)*)+)?(\.)[a-z]{2,}$/g; - if (fieldName === "name" && formData.name === "") - return "Newsletter.errors.missing_name"; - if (fieldName === "email" && formData.email === "") - return "Newsletter.errors.missing_email"; - if (fieldName === "email" && !emailRegex.test(formData.email)) - return "Newsletter.errors.invalid_email"; - return "valid"; - }; - - const showError = (fieldName: string): boolean => - formSubmitted && validateField(fieldName) !== "valid"; - - const handleInput = (e: React.ChangeEvent) => { - const fieldName = e.target.name; - const fieldValue = e.target.value; - - setFormData((prevState) => ({ - ...prevState, - [fieldName]: fieldValue, - })); - }; - - const submitForm = async () => { - const formURL = "api/subscribe"; - if (validateField("email") !== "valid" || validateField("name") !== "valid") - return; - - const res = await fetch(formURL, { - method: "POST", - body: JSON.stringify(formData), - headers: { - Accept: "application/json", - }, - }); - - if (res.ok) { - const { message } = (await res.json()) as Data; - await router.push({ - pathname: NEWSLETTER_CONFIRMATION.path, - query: { sendy: message }, - }); - return setSendyError(""); - } else { - const { error }: Data = (await res.json()) as Data; - console.error("client error", error); - setErroredEmail(formData.email); - return setSendyError(error || ""); - } - }; - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - setFormSubmitted(true); - submitForm().catch((err) => { - console.error("catch block", err); - }); - }; - - return ( - <> - - - - - -

    - {t("Newsletter.title")} -

    -

    - {t("Newsletter.intro")} -

    - - -

    {t("Newsletter.paragraph_1")}

    - - ), - li:
  • , - }} - /> - - -
    - {sendyError ? ( - - - ), - }} - /> - - ) : ( - <> - )} - - - {showError("name") ? ( - - {t(validateField("name"))} - - ) : ( - <> - )} - - - - - - - {showError("email") ? ( - - {t(validateField("email"))} - - ) : ( - <> - )} - - -
    - - -
    - - -
    - - - -

    {t("disclaimer")}

    -
    - - ); -}; - -// Change this to GetServerSideProps if you're using server-side rendering -export const getStaticProps: GetStaticProps = async ({ locale }) => { - const translations = await serverSideTranslations(locale ?? "en"); - return { props: { ...translations } }; -}; - -export default Newsletter; diff --git a/frontend/src/pages/newsletter/unsubscribe.tsx b/frontend/src/pages/newsletter/unsubscribe.tsx deleted file mode 100644 index 8c25eca76e..0000000000 --- a/frontend/src/pages/newsletter/unsubscribe.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import type { GetStaticProps, NextPage } from "next"; -import { NEWSLETTER_UNSUBSCRIBE_CRUMBS } from "src/constants/breadcrumbs"; - -import { Trans, useTranslation } from "next-i18next"; -import { serverSideTranslations } from "next-i18next/serverSideTranslations"; -import Link from "next/link"; -import { Grid, GridContainer } from "@trussworks/react-uswds"; - -import Breadcrumbs from "src/components/Breadcrumbs"; -import PageSEO from "src/components/PageSEO"; -import BetaAlert from "../../components/BetaAlert"; - -const NewsletterUnsubscribe: NextPage = () => { - const { t } = useTranslation("common"); - // TODO: Remove during move to app router and next-intl upgrade - const beta_strings = { - alert_title: t("Beta_alert.alert_title"), - alert: t("Beta_alert.alert"), - }; - - return ( - <> - - - - - -

    - {t("Newsletter_unsubscribe.title")} -

    -

    - {t("Newsletter_unsubscribe.intro")} -

    - - -

    - {t("Newsletter_unsubscribe.paragraph_1")} -

    - - {t("Newsletter_unsubscribe.button_resub")} - -
    - -

    - {t("Newsletter_unsubscribe.heading")} -

    -

    - , - "research-link": , - }} - /> -

    -
    -
    -
    - -

    - {t("Newsletter_unsubscribe.disclaimer")} -

    -
    - - ); -}; - -// Change this to GetServerSideProps if you're using server-side rendering -export const getStaticProps: GetStaticProps = async ({ locale }) => { - const translations = await serverSideTranslations(locale ?? "en"); - return { props: { ...translations } }; -}; - -export default NewsletterUnsubscribe; diff --git a/frontend/tests/pages/newsletter/confirmation.test.tsx b/frontend/tests/pages/newsletter/confirmation.test.tsx index 16c9fc1d04..7324e9c7cc 100644 --- a/frontend/tests/pages/newsletter/confirmation.test.tsx +++ b/frontend/tests/pages/newsletter/confirmation.test.tsx @@ -1,6 +1,6 @@ -import { render, waitFor } from "@testing-library/react"; +import { render, waitFor } from "tests/react-utils"; import { axe } from "jest-axe"; -import NewsletterConfirmation from "src/pages/newsletter/confirmation"; +import NewsletterConfirmation from "src/app/[locale]/newsletter/confirmation/page"; describe("Newsletter", () => { it("passes accessibility scan", async () => { diff --git a/frontend/tests/pages/newsletter/index.test.tsx b/frontend/tests/pages/newsletter/index.test.tsx index 3df1b12ab7..9f430ef2bb 100644 --- a/frontend/tests/pages/newsletter/index.test.tsx +++ b/frontend/tests/pages/newsletter/index.test.tsx @@ -1,13 +1,12 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "tests/react-utils"; + import userEvent from "@testing-library/user-event"; import { axe } from "jest-axe"; -import Newsletter from "src/pages/newsletter"; +import Newsletter from "src/app/[locale]/newsletter/page"; -import { useRouter } from "next/router"; +import { useRouter } from "next/navigation"; -jest.mock("next/router", () => ({ - useRouter: jest.fn(), -})); +jest.mock("next/navigation"); describe("Newsletter", () => { it("renders signup form with a submit button", () => { @@ -44,10 +43,9 @@ describe("Newsletter", () => { // Wait for the form submission await waitFor(() => { - expect(mockRouter.push).toHaveBeenCalledWith({ - pathname: "/newsletter/confirmation/", - query: { sendy: "Success" }, - }); + expect(mockRouter.push).toHaveBeenCalledWith( + "/newsletter/confirmation/?sendy=Success", + ); }); }); diff --git a/frontend/tests/pages/newsletter/unsubscribe.test.tsx b/frontend/tests/pages/newsletter/unsubscribe.test.tsx index d9b0c06ac4..b544f941f0 100644 --- a/frontend/tests/pages/newsletter/unsubscribe.test.tsx +++ b/frontend/tests/pages/newsletter/unsubscribe.test.tsx @@ -1,6 +1,6 @@ -import { render, waitFor } from "@testing-library/react"; +import { render, waitFor } from "tests/react-utils"; import { axe } from "jest-axe"; -import NewsletterUnsubscribe from "src/pages/newsletter/unsubscribe"; +import NewsletterUnsubscribe from "src/app/[locale]/newsletter/unsubscribe/page"; describe("Newsletter", () => { it("passes accessibility scan", async () => {