diff --git a/services/ui-src/src/components/accordions/AccordionItem.test.tsx b/services/ui-src/src/components/accordions/AccordionItem.test.tsx
new file mode 100644
index 00000000..f947bc4d
--- /dev/null
+++ b/services/ui-src/src/components/accordions/AccordionItem.test.tsx
@@ -0,0 +1,31 @@
+import { render, screen } from "@testing-library/react";
+import { axe } from "jest-axe";
+import { RouterWrappedComponent } from "utils/testing/setupJest";
+import { Accordion } from "@chakra-ui/react";
+import { AccordionItem } from "components";
+
+const accordionItemComponent = (
+
+
+
+
+
+);
+
+describe("Test AccordionItem", () => {
+ beforeEach(() => {
+ render(accordionItemComponent);
+ });
+
+ test("AccordionItem is visible", () => {
+ expect(screen.getByTestId("accordion-item")).toBeVisible();
+ });
+});
+
+describe("Test AccordionItem accessibility", () => {
+ it("Should not have basic accessibility issues", async () => {
+ const { container } = render(accordionItemComponent);
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+});
diff --git a/services/ui-src/src/components/accordions/AccordionItem.tsx b/services/ui-src/src/components/accordions/AccordionItem.tsx
new file mode 100644
index 00000000..84839083
--- /dev/null
+++ b/services/ui-src/src/components/accordions/AccordionItem.tsx
@@ -0,0 +1,59 @@
+import { ReactChild } from "react";
+import {
+ AccordionButton,
+ AccordionItem as AccordionItemRoot,
+ AccordionPanel,
+ Image,
+ Text,
+} from "@chakra-ui/react";
+import plusIcon from "assets/icons/icon_plus.png";
+import minusIcon from "assets/icons/icon_minus.png";
+
+export const AccordionItem = ({ label, children, ...props }: Props) => {
+ return (
+
+ {({ isExpanded }) => (
+ <>
+
+ {label}
+
+
+ {children}
+ >
+ )}
+
+ );
+};
+
+interface Props {
+ children?: ReactChild | ReactChild[];
+ [key: string]: any;
+}
+
+const sx = {
+ root: {
+ borderStyle: "none",
+ },
+ accordionButton: {
+ minHeight: "3.5rem",
+ bg: "palette.gray_lightest",
+ textAlign: "left",
+ },
+ accordionPanel: {
+ padding: "1.5rem 1rem 0.5rem",
+ ".mobile &": {
+ padding: "0.5rem 0",
+ },
+ },
+ accordionIcon: {
+ width: "1rem",
+ },
+};
diff --git a/services/ui-src/src/components/accordions/FaqAccordion.test.tsx b/services/ui-src/src/components/accordions/FaqAccordion.test.tsx
new file mode 100644
index 00000000..7ac51dff
--- /dev/null
+++ b/services/ui-src/src/components/accordions/FaqAccordion.test.tsx
@@ -0,0 +1,50 @@
+import { render, screen } from "@testing-library/react";
+import { axe } from "jest-axe";
+import userEvent from "@testing-library/user-event";
+import { RouterWrappedComponent } from "utils/testing/setupJest";
+import { FaqAccordion } from "components";
+
+const accordionItems = [
+ {
+ question: "Question?",
+ answer: "Answer!",
+ },
+];
+
+const faqAccordionComponent = (
+
+
+
+);
+
+describe("Test FaqAccordion", () => {
+ beforeEach(() => {
+ render(faqAccordionComponent);
+ });
+
+ test("FaqAccordion is visible", () => {
+ expect(screen.getByText(accordionItems[0].question)).toBeVisible();
+ });
+
+ test("FaqAccordion default closed state only shows the question", () => {
+ expect(screen.getByText(accordionItems[0].question)).toBeVisible();
+ expect(screen.getByText(accordionItems[0].answer)).not.toBeVisible();
+ });
+
+ test("FaqAccordion should show answer on click", async () => {
+ const faqQuestion = screen.getByText(accordionItems[0].question);
+ expect(faqQuestion).toBeVisible();
+ expect(screen.getByText(accordionItems[0].answer)).not.toBeVisible();
+ await userEvent.click(faqQuestion);
+ expect(faqQuestion).toBeVisible();
+ expect(screen.getByText(accordionItems[0].answer)).toBeVisible();
+ });
+});
+
+describe("Test FaqAccordion accessibility", () => {
+ it("Should not have basic accessibility issues", async () => {
+ const { container } = render(faqAccordionComponent);
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+});
diff --git a/services/ui-src/src/components/accordions/FaqAccordion.tsx b/services/ui-src/src/components/accordions/FaqAccordion.tsx
new file mode 100644
index 00000000..1c152824
--- /dev/null
+++ b/services/ui-src/src/components/accordions/FaqAccordion.tsx
@@ -0,0 +1,33 @@
+import { Accordion, Box, Text } from "@chakra-ui/react";
+import { AccordionItem } from "components";
+import { AnyObject } from "types";
+
+export const FaqAccordion = ({ accordionItems, ...props }: Props) => {
+ return (
+
+ {accordionItems.map((item: AnyObject, index: number) => (
+
+
+ {item.answer}
+
+
+ ))}
+
+ );
+};
+
+interface Props {
+ accordionItems: AnyObject;
+}
+
+const sx = {
+ item: {
+ marginBottom: "1.5rem",
+ borderStyle: "none",
+ },
+ answerBox: {
+ ".mobile &": {
+ paddingLeft: "1rem",
+ },
+ },
+};
diff --git a/services/ui-src/src/components/app/App.tsx b/services/ui-src/src/components/app/App.tsx
index 8ddbcbb5..88f399ac 100644
--- a/services/ui-src/src/components/app/App.tsx
+++ b/services/ui-src/src/components/app/App.tsx
@@ -1,13 +1,17 @@
import { useContext } from "react";
import { Routes, Route } from "react-router-dom";
-import { Container, Divider, Flex, Heading, Stack } from "@chakra-ui/react";
import {
+ AppRoutes,
+ Error,
Header,
LoginCognito,
LoginIDM,
PostLogoutRedirect,
Footer,
+ Timeout,
} from "components";
+import { Container, Divider, Flex, Heading, Stack } from "@chakra-ui/react";
+import { ErrorBoundary } from "react-error-boundary";
import { makeMediaQueryClasses, UserContext, useStore } from "utils";
export const App = () => {
@@ -32,8 +36,14 @@ export const App = () => {
const authenticatedRoutes = (
<>
{user && (
-
+
+
+
+
+
+
+
)}
diff --git a/services/ui-src/src/components/app/AppRoutes.tsx b/services/ui-src/src/components/app/AppRoutes.tsx
new file mode 100644
index 00000000..2cb554b6
--- /dev/null
+++ b/services/ui-src/src/components/app/AppRoutes.tsx
@@ -0,0 +1,13 @@
+import { Route, Routes } from "react-router-dom";
+import { HelpPage } from "components";
+
+export const AppRoutes = () => {
+ return (
+
+
+ {/* General Routes */}
+ } />
+
+
+ );
+};
diff --git a/services/ui-src/src/components/cards/Card.test.tsx b/services/ui-src/src/components/cards/Card.test.tsx
new file mode 100644
index 00000000..63d88120
--- /dev/null
+++ b/services/ui-src/src/components/cards/Card.test.tsx
@@ -0,0 +1,27 @@
+import { render, screen } from "@testing-library/react";
+import { axe } from "jest-axe";
+import { Card } from "components";
+
+const cardComponent = (
+
+ Mock child component
+
+);
+
+describe("Test Card", () => {
+ beforeEach(() => {
+ render(cardComponent);
+ });
+
+ test("Card is visible", () => {
+ expect(screen.getByTestId("card")).toBeVisible();
+ });
+});
+
+describe("Test Card accessibility", () => {
+ it("Should not have basic accessibility issues", async () => {
+ const { container } = render(cardComponent);
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+});
diff --git a/services/ui-src/src/components/cards/Card.tsx b/services/ui-src/src/components/cards/Card.tsx
new file mode 100644
index 00000000..14de27c6
--- /dev/null
+++ b/services/ui-src/src/components/cards/Card.tsx
@@ -0,0 +1,25 @@
+import { ReactChild } from "react";
+import { Box } from "@chakra-ui/react";
+
+export const Card = ({ children, ...props }: Props) => {
+ return (
+
+ {children}
+
+ );
+};
+
+interface Props {
+ children?: ReactChild | ReactChild[];
+ [key: string]: any;
+}
+
+const sx = {
+ root: {
+ width: "100%",
+ padding: "2rem",
+ ".mobile &": {
+ padding: "1rem",
+ },
+ },
+};
diff --git a/services/ui-src/src/components/cards/EmailCard.test.tsx b/services/ui-src/src/components/cards/EmailCard.test.tsx
new file mode 100644
index 00000000..2ca1daf8
--- /dev/null
+++ b/services/ui-src/src/components/cards/EmailCard.test.tsx
@@ -0,0 +1,45 @@
+import { render, screen } from "@testing-library/react";
+import { axe } from "jest-axe";
+import { EmailCard } from "components";
+import { createEmailLink } from "utils/other/email";
+import verbiage from "verbiage/pages/help";
+
+const emailCardComponent = (
+
+);
+
+describe("Test EmailCard", () => {
+ beforeEach(() => {
+ render(emailCardComponent);
+ });
+
+ test("EmailCard is visible", () => {
+ expect(screen.getByTestId("email-card")).toBeVisible();
+ });
+
+ test("Email links are created correctly", () => {
+ const mockEmailData = {
+ address: "test@test.com",
+ subject: "the subject",
+ body: "the body",
+ };
+ const expectedEmailLink = "mailto:test@test.com?the%20subject";
+ expect(createEmailLink(mockEmailData)).toEqual(expectedEmailLink);
+ });
+
+ test("Email links are visible", () => {
+ expect(screen.getByRole("link")).toBeVisible();
+ });
+});
+
+describe("Test EmailCard accessibility", () => {
+ it("Should not have basic accessibility issues", async () => {
+ const { container } = render(emailCardComponent);
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+});
diff --git a/services/ui-src/src/components/cards/EmailCard.tsx b/services/ui-src/src/components/cards/EmailCard.tsx
new file mode 100644
index 00000000..52ae17c8
--- /dev/null
+++ b/services/ui-src/src/components/cards/EmailCard.tsx
@@ -0,0 +1,79 @@
+import { Flex, Image, Link, Text } from "@chakra-ui/react";
+import { Card } from "components";
+import { useBreakpoint } from "utils";
+import { AnyObject } from "types";
+import { createEmailLink } from "utils/other/email";
+import spreadsheetIcon from "assets/icons/icon_spreadsheet.png";
+import settingsIcon from "assets/icons/icon_wrench_gear.png";
+
+const iconMap = {
+ spreadsheet: {
+ image: spreadsheetIcon,
+ alt: "spreadsheet icon",
+ },
+ settings: {
+ image: settingsIcon,
+ alt: "settings icon",
+ },
+};
+
+export const EmailCard = ({ verbiage, icon, cardprops, ...props }: Props) => {
+ const { isDesktop } = useBreakpoint();
+
+ return (
+
+
+
+
+ {verbiage.body}
+
+ Email {!isDesktop &&
}
+
+ {verbiage.email.address}
+
+
+
+
+
+ );
+};
+
+interface Props {
+ verbiage: AnyObject;
+ icon: keyof typeof iconMap;
+ [key: string]: any;
+}
+
+const sx = {
+ root: {
+ flexDirection: "row",
+ textAlign: "left",
+ ".mobile &": {
+ flexDirection: "column",
+ },
+ },
+ icon: {
+ marginRight: "2rem",
+ boxSize: "78px",
+ ".mobile &": {
+ alignSelf: "center",
+ marginRight: "0",
+ marginBottom: "1rem",
+ },
+ },
+ cardContentFlex: {
+ width: "100%",
+ flexDirection: "column",
+ justifyContent: "center",
+ },
+ bodyText: {
+ marginBottom: "1rem",
+ },
+ emailText: {
+ fontWeight: "bold",
+ },
+};
diff --git a/services/ui-src/src/components/index.ts b/services/ui-src/src/components/index.ts
index 29efb63f..18c73cb5 100644
--- a/services/ui-src/src/components/index.ts
+++ b/services/ui-src/src/components/index.ts
@@ -1,3 +1,6 @@
+// accordions
+export { AccordionItem } from "./accordions/AccordionItem";
+export { FaqAccordion } from "./accordions/FaqAccordion";
// alerts
export { Alert } from "./alerts/Alert";
export { ErrorAlert } from "./alerts/ErrorAlert";
@@ -8,9 +11,16 @@ export { Error } from "./app/Error";
export { Header } from "./layout/Header";
export { PageTemplate } from "./layout/PageTemplate";
export { Footer } from "./layout/Footer";
+export { AppRoutes } from "./app/AppRoutes";
+export { Timeout } from "./layout/Timeout";
+// cards
+export { Card } from "./cards/Card";
+export { EmailCard } from "./cards/EmailCard";
// logins
export { LoginCognito } from "./logins/LoginCognito";
export { LoginIDM } from "./logins/LoginIDM";
+// pages
+export { HelpPage } from "./pages/HelpPage/HelpPage";
// menus
export { Menu } from "./menus/Menu";
export { MenuOption } from "./menus/MenuOption";
diff --git a/services/ui-src/src/components/layout/Timeout.test.tsx b/services/ui-src/src/components/layout/Timeout.test.tsx
new file mode 100644
index 00000000..a8993469
--- /dev/null
+++ b/services/ui-src/src/components/layout/Timeout.test.tsx
@@ -0,0 +1,108 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { act } from "react-dom/test-utils";
+import { axe } from "jest-axe";
+import { Timeout } from "components";
+import { IDLE_WINDOW, PROMPT_AT } from "../../constants";
+import {
+ mockStateUserStore,
+ RouterWrappedComponent,
+} from "utils/testing/setupJest";
+import { initAuthManager, UserContext, useStore } from "utils";
+
+const mockLogout = jest.fn();
+const mockLoginWithIDM = jest.fn();
+const mockUpdateTimeout = jest.fn();
+const mockGetExpiration = jest.fn();
+
+const mockUser = {
+ ...mockStateUserStore,
+};
+
+const mockUserContext = {
+ user: undefined,
+ logout: mockLogout,
+ loginWithIDM: mockLoginWithIDM,
+ updateTimeout: mockUpdateTimeout,
+ getExpiration: mockGetExpiration,
+};
+
+const timeoutComponent = (
+
+
+
+
+
+);
+
+jest.mock("utils/state/useStore");
+const mockedUseStore = useStore as jest.MockedFunction;
+
+const spy = jest.spyOn(global, "setTimeout");
+
+describe("Test Timeout Modal", () => {
+ beforeEach(async () => {
+ jest.useFakeTimers();
+ mockedUseStore.mockReturnValue(mockUser);
+ initAuthManager();
+ await render(timeoutComponent);
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ jest.restoreAllMocks();
+ spy.mockClear();
+ });
+
+ test("Timeout modal is visible", async () => {
+ await act(async () => {
+ jest.advanceTimersByTime(PROMPT_AT + 5000);
+ });
+ await waitFor(() => {
+ expect(screen.getByTestId("modal-refresh-button")).toBeVisible();
+ expect(screen.getByTestId("modal-logout-button")).toBeVisible();
+ });
+ });
+
+ test("Timeout modal refresh button is clickable and closes modal", async () => {
+ await act(async () => {
+ jest.advanceTimersByTime(PROMPT_AT + 5000);
+ });
+ const refreshButton = screen.getByTestId("modal-refresh-button");
+ await act(async () => {
+ await fireEvent.click(refreshButton);
+ });
+ await waitFor(() => {
+ expect(screen.getByTestId("modal-refresh-button")).not.toBeVisible();
+ expect(screen.getByTestId("modal-logout-button")).not.toBeVisible();
+ });
+ });
+
+ test("Timeout modal logout button is clickable and triggers logout", async () => {
+ await act(async () => {
+ jest.advanceTimersByTime(PROMPT_AT + 5000);
+ });
+ const logoutButton = screen.getByTestId("modal-logout-button");
+ mockLogout.mockReset();
+ await act(async () => {
+ await fireEvent.click(logoutButton);
+ });
+ expect(mockLogout).toHaveBeenCalledTimes(1);
+ });
+ test("Timeout modal executes logout on timeout", async () => {
+ mockLogout.mockReset();
+ await act(async () => {
+ jest.advanceTimersByTime(10 * IDLE_WINDOW);
+ });
+ expect(mockLogout).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("Test Timeout Modal accessibility", () => {
+ it("Should not have basic accessibility issues", async () => {
+ initAuthManager();
+ mockedUseStore.mockReturnValue(mockUser);
+ const { container } = render(timeoutComponent);
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+});
diff --git a/services/ui-src/src/components/layout/Timeout.tsx b/services/ui-src/src/components/layout/Timeout.tsx
new file mode 100644
index 00000000..59ba860d
--- /dev/null
+++ b/services/ui-src/src/components/layout/Timeout.tsx
@@ -0,0 +1,170 @@
+import { useContext, useEffect, useState } from "react";
+import {
+ Button,
+ Modal,
+ ModalHeader,
+ ModalOverlay,
+ ModalBody,
+ ModalFooter,
+ ModalContent,
+ Text,
+} from "@chakra-ui/react";
+import { useLocation } from "react-router-dom";
+import {
+ calculateRemainingSeconds,
+ refreshCredentials,
+ updateTimeout,
+ UserContext,
+} from "utils";
+import { PROMPT_AT, IDLE_WINDOW } from "../../constants";
+import moment from "moment";
+
+export const Timeout = () => {
+ const context = useContext(UserContext);
+ const { logout } = context;
+ const [timeLeft, setTimeLeft] = useState((IDLE_WINDOW - PROMPT_AT) / 1000);
+ const [showTimeout, setShowTimeout] = useState(false);
+ const [timeoutPromptId, setTimeoutPromptId] = useState();
+ const [timeoutForceId, setTimeoutForceId] = useState();
+ const [updateTextIntervalId, setUpdateTextIntervalId] = useState();
+ const location = useLocation();
+
+ /*
+ * TODO: When autosave is implemented, set up a callback function to listen to calls to update in authLifecycle
+ * subscribeToUpdateTimeout(() => {
+ * setTimer();
+ * });
+ */
+ useEffect(() => {
+ setTimer();
+ return () => {
+ clearTimers();
+ };
+ }, [location]);
+
+ const setTimer = () => {
+ const expiration = moment().add(IDLE_WINDOW, "milliseconds");
+ if (timeoutPromptId) {
+ clearTimers();
+ }
+ updateTimeout();
+ setShowTimeout(false);
+
+ // Set the initial timer for when a prompt appears
+ const promptTimer = window.setTimeout(() => {
+ // Once the prompt appears, set timers for logging out, and for updating text on screen
+ setTimeLeft(calculateRemainingSeconds(expiration));
+ setShowTimeout(true);
+ const forceLogoutTimer = window.setTimeout(() => {
+ clearTimers();
+ logout();
+ }, IDLE_WINDOW - PROMPT_AT);
+ const updateTextTimer = window.setInterval(() => {
+ setTimeLeft(calculateRemainingSeconds(expiration));
+ }, 500);
+ setTimeoutForceId(forceLogoutTimer);
+ setUpdateTextIntervalId(updateTextTimer);
+ }, PROMPT_AT);
+ setTimeoutPromptId(promptTimer);
+ };
+
+ const clearTimers = () => {
+ clearTimeout(timeoutPromptId);
+ clearTimeout(timeoutForceId);
+ clearTimeout(updateTextIntervalId);
+ };
+
+ const refreshAuth = async () => {
+ await refreshCredentials();
+ setShowTimeout(false);
+ setTimer();
+ };
+
+ const formatTime = (time: number) => {
+ return `${Math.floor(time)} seconds`;
+ };
+
+ return (
+
+
+
+ Session timeout
+
+
+ Due to inactivity, you will be logged out in {formatTime(timeLeft)}.
+ Choose to stay logged in or log out. Otherwise, you will be logged
+ out automatically.
+
+
+
+
+
+
+
+
+ );
+};
+
+const sx = {
+ modalContent: {
+ boxShadow: ".125rem .125rem .25rem",
+ borderRadius: "0",
+ maxWidth: "30rem",
+ marginX: "4rem",
+ padding: "0",
+ },
+ modalHeader: {
+ padding: "2rem 2rem 0 2rem",
+ },
+ modalBody: {
+ padding: "1rem 2rem 0 2rem",
+ },
+ modalFooter: {
+ justifyContent: "flex-start",
+ padding: "0 2rem 2rem 2rem",
+ },
+ stayActive: {
+ justifyContent: "center",
+ marginTop: "1rem",
+ marginRight: "1rem",
+ minWidth: "7.5rem",
+ span: {
+ marginLeft: "1rem",
+ marginRight: "-0.25rem",
+ "&.ds-c-spinner": {
+ marginLeft: 0,
+ },
+ },
+ ".mobile &": {
+ fontSize: "sm",
+ },
+ },
+ close: {
+ justifyContent: "start",
+ padding: ".5rem 1rem",
+ marginTop: "1rem",
+ span: {
+ marginLeft: "0rem",
+ marginRight: "0.5rem",
+ },
+ ".mobile &": {
+ fontSize: "sm",
+ marginRight: "0",
+ },
+ },
+};
diff --git a/services/ui-src/src/components/pages/HelpPage/HelpPage.test.tsx b/services/ui-src/src/components/pages/HelpPage/HelpPage.test.tsx
new file mode 100644
index 00000000..ea29e455
--- /dev/null
+++ b/services/ui-src/src/components/pages/HelpPage/HelpPage.test.tsx
@@ -0,0 +1,29 @@
+import { render, screen } from "@testing-library/react";
+import { axe } from "jest-axe";
+import { HelpPage } from "components/pages/HelpPage/HelpPage";
+import { RouterWrappedComponent } from "utils/testing/setupJest";
+import verbiage from "verbiage/pages/help";
+
+const helpView = (
+
+
+
+);
+
+describe("Test HelpPage", () => {
+ beforeEach(() => {
+ render(helpView);
+ });
+
+ test("Check that HelpPage renders", () => {
+ expect(screen.getByText(verbiage.intro.header)).toBeVisible();
+ });
+});
+
+describe("Test HelpPage accessibility", () => {
+ it("Should not have basic accessibility issues", async () => {
+ const { container } = render(helpView);
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+});
diff --git a/services/ui-src/src/components/pages/HelpPage/HelpPage.tsx b/services/ui-src/src/components/pages/HelpPage/HelpPage.tsx
new file mode 100644
index 00000000..b6e7eda3
--- /dev/null
+++ b/services/ui-src/src/components/pages/HelpPage/HelpPage.tsx
@@ -0,0 +1,56 @@
+import { Box, Heading, Text } from "@chakra-ui/react";
+import { EmailCard, FaqAccordion, PageTemplate } from "components";
+import verbiage from "verbiage/pages/help";
+
+export const HelpPage = () => {
+ const { intro, cards, accordionItems } = verbiage;
+ return (
+
+
+
+ {intro.header}
+
+ {intro.body}
+
+
+
+
+
+ {accordionItems.length > 0 && (
+
+
+
+ )}
+
+ );
+};
+
+const sx = {
+ leadTextBox: {
+ marginBottom: "2.25rem",
+ },
+ headerText: {
+ marginBottom: "1rem",
+ fontSize: "2rem",
+ fontWeight: "normal",
+ },
+ emailCardBox: {
+ width: "100%",
+ marginBottom: "3rem",
+ },
+ card: {
+ marginBottom: "1.5rem",
+ },
+ faqAccordionBox: {
+ width: "100%",
+ marginBottom: "8rem",
+ },
+};
diff --git a/services/ui-src/src/types/other.ts b/services/ui-src/src/types/other.ts
index 7612cef3..74369107 100644
--- a/services/ui-src/src/types/other.ts
+++ b/services/ui-src/src/types/other.ts
@@ -7,7 +7,24 @@ export enum AlertTypes {
WARNING = "warning",
}
+// TIME
+
+export interface DateShape {
+ year: number;
+ month: number;
+ day: number;
+}
+
+export interface TimeShape {
+ hour: number;
+ minute: number;
+ second: number;
+}
+
// OTHER
+export interface AnyObject {
+ [key: string]: any;
+}
export interface CustomHtmlElement {
type: string;
diff --git a/services/ui-src/src/utils/index.ts b/services/ui-src/src/utils/index.ts
index 034f0d63..f3488ebe 100644
--- a/services/ui-src/src/utils/index.ts
+++ b/services/ui-src/src/utils/index.ts
@@ -10,3 +10,4 @@ export * from "./tracking/tealium";
//other
export * from "./other/parsing";
export * from "./other/useBreakpoint";
+export * from "./other/time";
diff --git a/services/ui-src/src/utils/other/time.ts b/services/ui-src/src/utils/other/time.ts
new file mode 100644
index 00000000..8ac5209d
--- /dev/null
+++ b/services/ui-src/src/utils/other/time.ts
@@ -0,0 +1,183 @@
+import { utcToZonedTime, zonedTimeToUtc } from "date-fns-tz";
+import { DateShape, TimeShape } from "types";
+import moment from "moment";
+
+export const midnight: TimeShape = { hour: 0, minute: 0, second: 0 };
+export const oneSecondToMidnight: TimeShape = {
+ hour: 23,
+ minute: 59,
+ second: 59,
+};
+export const noon: TimeShape = {
+ hour: 12,
+ minute: 0,
+ second: 0,
+};
+
+export const calculateTimeByType = (timeType?: string): TimeShape => {
+ const timeMap: any = {
+ startDate: midnight,
+ endDate: oneSecondToMidnight,
+ };
+ return timeMap?.[timeType as keyof typeof timeMap] || noon;
+};
+
+/*
+ * Returns local time in HH:mm format with the "am/pm" indicator
+ * ex: 12:02pm
+ */
+export const getLocalHourMinuteTime = () => {
+ const currentUtcTime = Date.now();
+ const localTime = new Date(currentUtcTime).toLocaleTimeString();
+ const localTimeHourMinute = localTime.substring(
+ 0,
+ localTime.lastIndexOf(":")
+ );
+ const twelveHourIndicator = localTime.includes("AM") ? "am" : "pm";
+ return localTimeHourMinute.concat(twelveHourIndicator);
+};
+
+/*
+ * Converts passed ET datetime to UTC
+ * returns -> UTC datetime in format 'ms since Unix epoch'
+ */
+export const convertDateTimeEtToUtc = (
+ etDate: DateShape,
+ etTime: TimeShape
+): number => {
+ const { year, month, day } = etDate;
+ const { hour, minute, second } = etTime;
+
+ // month - 1 because Date object months are zero-indexed
+ const utcDatetime = zonedTimeToUtc(
+ new Date(year, month - 1, day, hour, minute, second),
+ "America/New_York"
+ );
+ return utcDatetime.getTime();
+};
+
+/*
+ * Converts passed ET date to UTC
+ * returns -> UTC datetime in format 'ms since Unix epoch'
+ */
+export const convertDateEtToUtc = (date: string): number => {
+ const [month, day, year] = date.split("/");
+
+ // month - 1 because Date object months are zero-indexed
+ const utcDatetime = zonedTimeToUtc(
+ new Date(parseInt(year), parseInt(month) - 1, parseInt(day)),
+ "America/New_York"
+ );
+ return utcDatetime.getTime();
+};
+
+/*
+ * Converts passed UTC datetime to ET date
+ * returns -> ET date in format mm/dd/yyyy
+ */
+export const convertDateUtcToEt = (date: number): string => {
+ const convertedDate = date;
+ const easternDatetime = utcToZonedTime(
+ new Date(convertedDate),
+ "America/New_York"
+ );
+
+ const month = twoDigitCalendarDate(new Date(easternDatetime).getMonth() + 1);
+ const day = twoDigitCalendarDate(new Date(easternDatetime).getDate());
+ const year = new Date(easternDatetime).getFullYear();
+
+ // month + 1 because Date object months are zero-indexed
+ return `${month}/${day}/${year}`;
+};
+
+/*
+ * This code ensures the date has a preceeding 0 if the month/day is a single digit.
+ * Ex: 7 becomes 07 while 10 stays 10
+ */
+export const twoDigitCalendarDate = (date: number) => {
+ return ("0" + date).slice(-2);
+};
+
+/*
+ * Converts passed UTC datetime to a local date in the users timezone
+ * returns -> User Timezone date in format Day of Week, Month Day, Year
+ * Ex: Friday, August 12, 2022
+ */
+export const utcDateToReadableDate = (
+ date: number,
+ style?: "full" | "long" | "medium" | "short"
+) => {
+ return new Intl.DateTimeFormat("en-US", {
+ dateStyle: style,
+ }).format(date);
+};
+
+export const checkDateCompleteness = (date: string) => {
+ const month = parseInt(date.split("/")?.[0]);
+ const day = parseInt(date.split("/")?.[1]);
+ const year = parseInt(date.split("/")?.[2]);
+ const dateIsComplete = month && day && year.toString().length === 4;
+ return dateIsComplete ? { year, month, day } : null;
+};
+
+export const convertDatetimeStringToNumber = (
+ date: string,
+ timeType: string | undefined
+): number | undefined => {
+ const completeDate = checkDateCompleteness(date);
+ let convertedTime;
+ if (completeDate) {
+ const time = calculateTimeByType(timeType);
+ convertedTime = convertDateTimeEtToUtc(completeDate, time);
+ }
+ return convertedTime || undefined;
+};
+
+export const checkDateRangeStatus = (
+ startDate: number,
+ endDate: number
+): boolean => {
+ const currentTime = new Date().valueOf();
+ return currentTime >= startDate && currentTime <= endDate;
+};
+
+/*
+ * Calculates time remaining for things like timeout
+ */
+export const calculateRemainingSeconds = (expiresAt?: any) => {
+ if (!expiresAt) return 0;
+ return moment(expiresAt).diff(moment()) / 1000;
+};
+
+export const displayLongformPeriod = (
+ period: number | undefined,
+ reportYear: number | undefined
+) => {
+ if (period === 1) {
+ return ` January 1 to June 30, ${reportYear} reporting period`;
+ } else {
+ return ` July 1 to December 31, ${reportYear} reporting period`;
+ }
+};
+
+export const displayLongformPeriodSection9 = (
+ reportYear: number | undefined
+) => {
+ return ` August 1, ${
+ reportYear ? reportYear - 1 : reportYear
+ } to July 31, ${reportYear}`;
+};
+
+export const calculateNextQuarter = (previousQuarter: string) => {
+ if (previousQuarter) {
+ const formattedQuarter = previousQuarter.split(" ");
+ const year = parseInt(formattedQuarter[0]);
+ const period = parseInt(
+ formattedQuarter[1][formattedQuarter[1].length - 1]
+ );
+ const nextPeriod = (period % 4) + 1;
+ const nextYear = period === 4 ? year + 1 : year;
+ return `${nextYear} Q${nextPeriod}`;
+ }
+ return "";
+};
diff --git a/services/ui-src/src/verbiage/pages/help.ts b/services/ui-src/src/verbiage/pages/help.ts
new file mode 100644
index 00000000..dc71e0e9
--- /dev/null
+++ b/services/ui-src/src/verbiage/pages/help.ts
@@ -0,0 +1,37 @@
+export default {
+ intro: {
+ header: "How can we help you?",
+ body: "Question or feedback? Please email us and we will respond as soon as possible. You can also review our frequently asked questions below.",
+ },
+ cards: {
+ helpdesk: {
+ body: "For technical support and login issues: ",
+ email: {
+ address: "mdct_help@cms.hhs.gov",
+ },
+ },
+ template: {
+ body: "For questions about the online form:",
+ email: {
+ address: "MFPDemo@cms.hhs.gov",
+ },
+ },
+ },
+ accordionItems: [
+ /*
+ * accordion items are in the following format:
+ * {
+ * question: "",
+ * answer: "",
+ * }
+ */
+ {
+ question: "How do I log into my IDM account?",
+ answer: "TBD",
+ },
+ {
+ question: "Question #2",
+ answer: "TBD",
+ },
+ ],
+};